Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions examples/hue/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.light.sdk)
}

android {
compileSdk = rootProject.ext["compileSdk"] as Int

defaultConfig {
minSdk = rootProject.ext["minSdk"] as Int
targetSdk = rootProject.ext["targetSdk"] as Int

manifestPlaceholders["sdkVersion"] = property("sdkVersion") as String
}

signingConfigs {
create("lightsdkDev") {
storeFile = file("../../sdk/keys/lightsdk-dev.jks")
storePassword = "android"
keyAlias = "lightsdk-dev"
keyPassword = "android"
enableV3Signing = true
enableV4Signing = true
}
}

buildTypes {
debug {
signingConfig = signingConfigs.getByName("lightsdkDev")
}
release {
signingConfig = signingConfigs.getByName("lightsdkDev")
}
}

lint {
warningsAsErrors = false
error += "RestrictedApi"
}

compileOptions {
sourceCompatibility = JavaVersion.toVersion(rootProject.ext["jvmTarget"] as String)
targetCompatibility = JavaVersion.toVersion(rootProject.ext["jvmTarget"] as String)
}
}

kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(rootProject.ext["jvmTarget"] as String))
}
}

dependencies {
implementation(project(":sdk:client"))
testImplementation(libs.kotlin.test)
}
9 changes: 9 additions & 0 deletions examples/hue/lighttool.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[tool]
id = "com.thelightphone.hue"
label = "Hue"
versionCode = 1
versionName = "1.0.0"
permissions = ["android.permission.INTERNET"]
# change if you run this on the emulator!
# serverPackage = "com.thelightphone.sdk.emulator"
serverPackage = "com.lightos"
144 changes: 144 additions & 0 deletions examples/hue/src/main/kotlin/com/thelightphone/hue/HueApi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package com.thelightphone.hue

import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

/** Thrown when the bridge reports that the physical link button has not been pressed yet. */
internal class LinkButtonNotPressedException : Exception("Press the link button on your Hue bridge.")

/** Thrown when the stored application key is no longer accepted by the bridge. */
internal class UnauthorizedException : Exception("This bridge no longer recognizes the tool.")

@Serializable
private data class CreateUserRequest(
val devicetype: String,
val generateclientkey: Boolean = true,
)

@Serializable
private data class OnStateRequest(val on: Boolean)

internal class HueApi {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
}

// Public HTTPS with a normal CA-signed cert — uses the platform trust store.
private val cloudClient = HttpClient(OkHttp) {
install(ContentNegotiation) { json(json) }
}

// Local bridge HTTPS — trust is pinned to the Hue bridge root CAs.
private val bridgeClient = HttpClient(OkHttp) {
engine { preconfigured = HueBridgeTls.bridgeClient() }
install(ContentNegotiation) { json(json) }
}

suspend fun discoverBridges(): Result<List<DiscoveredBridge>> = runCatching {
cloudClient.get("https://discovery.meethue.com/")
.body<List<DiscoveredBridge>>()
.filter { it.internalipaddress.isNotBlank() }
}

suspend fun fetchConfig(ip: String): Result<HueConfig> = runCatching {
bridgeClient.get("https://$ip/api/config").body<HueConfig>()
}

/** Attempts to register with the bridge. Requires the link button to have been pressed. */
suspend fun pair(ip: String): Result<HueCreatedUser> = runCatching {
val body = bridgeClient.post("https://$ip/api") {
contentType(ContentType.Application.Json)
setBody(
CreateUserRequest(
devicetype = "light_phone_3#hue tool",
generateclientkey = true,
),
)
}.bodyAsText()

val results = json.decodeFromString<List<HueCommandResult>>(body)
val created = results.firstNotNullOfOrNull { it.success }
if (created != null && created.username.isNotBlank()) {
return@runCatching created
}
val error = results.firstNotNullOfOrNull { it.error }
if (error?.type == HUE_LINK_BUTTON_NOT_PRESSED) {
throw LinkButtonNotPressedException()
}
throw IllegalStateException(error?.description?.ifBlank { null } ?: "Unable to pair with bridge.")
}

suspend fun listLights(ip: String, appKey: String): Result<List<UiLight>> = runCatching {
val body = bridgeClient.get("https://$ip/api/$appKey/lights").bodyAsText()
ensureAuthorized(body)
json.decodeFromString<Map<String, HueLight>>(body)
.map { (id, light) ->
UiLight(
id = id,
name = light.name.ifBlank { "Light $id" },
on = light.state.on,
reachable = light.state.reachable,
)
}
.sortedBy { it.name.lowercase() }
}

suspend fun setLightOn(ip: String, appKey: String, lightId: String, on: Boolean): Result<Unit> =
runCatching {
val body = bridgeClient.put("https://$ip/api/$appKey/lights/$lightId/state") {
contentType(ContentType.Application.Json)
setBody(OnStateRequest(on))
}.bodyAsText()
throwOnCommandError(body)
}

/** Group 0 is the special "all lights" group on every bridge. */
suspend fun setAllLightsOn(ip: String, appKey: String, on: Boolean): Result<Unit> = runCatching {
val body = bridgeClient.put("https://$ip/api/$appKey/groups/0/action") {
contentType(ContentType.Application.Json)
setBody(OnStateRequest(on))
}.bodyAsText()
throwOnCommandError(body)
}

private fun ensureAuthorized(body: String) {
if (!body.trimStart().startsWith("[")) return
val results = runCatching { json.decodeFromString<List<HueCommandResult>>(body) }.getOrNull()
val error = results?.firstNotNullOfOrNull { it.error }
if (error != null) throw UnauthorizedException()
}

private fun throwOnCommandError(body: String) {
val results = runCatching { json.decodeFromString<List<HueCommandResult>>(body) }.getOrNull()
?: return
val error = results.firstNotNullOfOrNull { it.error } ?: return
if (error.type == HUE_LINK_BUTTON_NOT_PRESSED) throw LinkButtonNotPressedException()
if (error.description.contains("unauthorized", ignoreCase = true)) throw UnauthorizedException()
throw IllegalStateException(error.description.ifBlank { "Bridge rejected the command." })
}

fun close() {
cloudClient.close()
bridgeClient.close()
}
}

data class UiLight(
val id: String,
val name: String,
val on: Boolean,
val reachable: Boolean,
)
81 changes: 81 additions & 0 deletions examples/hue/src/main/kotlin/com/thelightphone/hue/HueBridgeTls.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.thelightphone.hue

import okhttp3.OkHttpClient
import java.io.ByteArrayInputStream
import java.security.KeyStore
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager

/**
* A Hue bridge serves its local API over HTTPS with a certificate signed by one of Philips/Signify's
* bridge root CAs. Because we reach the bridge by LAN IP (not by the hostname baked into the cert),
* standard hostname verification can't apply — so instead we pin trust to those root CAs. Only a
* genuine Hue bridge whose chain validates against them is accepted; hostname verification is then
* relaxed since the pinned chain is the real trust boundary.
*
* The PEMs below are Signify's publicly published Hue bridge root certificates.
*/
internal object HueBridgeTls {
// "Philips Hue / root-bridge" — original bridge root CA.
private const val ROOT_BRIDGE_PEM = """-----BEGIN CERTIFICATE-----
MIICMjCCAdigAwIBAgIUO7FSLbaxikuXAljzVaurLXWmFw4wCgYIKoZIzj0EAwIw
OTELMAkGA1UEBhMCTkwxFDASBgNVBAoMC1BoaWxpcHMgSHVlMRQwEgYDVQQDDAty
b290LWJyaWRnZTAiGA8yMDE3MDEwMTAwMDAwMFoYDzIwMzgwMTE5MDMxNDA3WjA5
MQswCQYDVQQGEwJOTDEUMBIGA1UECgwLUGhpbGlwcyBIdWUxFDASBgNVBAMMC3Jv
b3QtYnJpZGdlMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEjNw2tx2AplOf9x86
aTdvEcL1FU65QDxziKvBpW9XXSIcibAeQiKxegpq8Exbr9v6LBnYbna2VcaK0G22
jOKkTqOBuTCBtjAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNV
HQ4EFgQUZ2ONTFrDT6o8ItRnKfqWKnHFGmQwdAYDVR0jBG0wa4AUZ2ONTFrDT6o8
ItRnKfqWKnHFGmShPaQ7MDkxCzAJBgNVBAYTAk5MMRQwEgYDVQQKDAtQaGlsaXBz
IEh1ZTEUMBIGA1UEAwwLcm9vdC1icmlkZ2WCFDuxUi22sYpLlwJY81Wrqy11phcO
MAoGCCqGSM49BAMCA0gAMEUCIEBYYEOsa07TH7E5MJnGw557lVkORgit2Rm1h3B2
sFgDAiEA1Fj/C3AN5psFMjo0//mrQebo0eKd3aWRx+pQY08mk48=
-----END CERTIFICATE-----"""

// "Signify Hue / Hue Root CA 01" — newer bridge root CA.
private const val HUE_ROOT_CA_01_PEM = """-----BEGIN CERTIFICATE-----
MIIBzDCCAXOgAwIBAgICEAAwCgYIKoZIzj0EAwIwPDELMAkGA1UEBhMCTkwxFDAS
BgNVBAoMC1NpZ25pZnkgSHVlMRcwFQYDVQQDDA5IdWUgUm9vdCBDQSAwMTAgFw0y
NTAyMjUwMDAwMDBaGA8yMDUwMTIzMTIzNTk1OVowPDELMAkGA1UEBhMCTkwxFDAS
BgNVBAoMC1NpZ25pZnkgSHVlMRcwFQYDVQQDDA5IdWUgUm9vdCBDQSAwMTBZMBMG
ByqGSM49AgEGCCqGSM49AwEHA0IABFfOO0jfSAUXGQ9kjEDzyBrcMQ3ItyA5krE+
cyvb1Y3xFti7KlAad8UOnAx0FBLn7HZrlmIwm1QnX0fK3LPM13mjYzBhMB0GA1Ud
DgQWBBTF1pSpsCASX/z0VHLigxU2CAaqoTAfBgNVHSMEGDAWgBTF1pSpsCASX/z0
VHLigxU2CAaqoTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggq
hkjOPQQDAgNHADBEAiAk7duT+IHbOGO4UUuGLAEpyYejGZK9Z7V9oSfnvuQ5BQIg
IYSgwwxHXm73/JgcU9lAM6c8Bmu3UE3kBIUwBs1qXFw=
-----END CERTIFICATE-----"""

fun bridgeClient(): OkHttpClient {
val trustManager = buildTrustManager(listOf(ROOT_BRIDGE_PEM, HUE_ROOT_CA_01_PEM))
val sslContext = SSLContext.getInstance("TLS").apply {
init(null, arrayOf(trustManager), null)
}
return OkHttpClient.Builder()
.sslSocketFactory(sslContext.socketFactory, trustManager)
// Chain trust is enforced by the pinned trust manager above; the LAN IP will never
// match the cert's baked-in hostname, so hostname verification is intentionally relaxed.
.hostnameVerifier { _, _ -> true }
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
}

private fun buildTrustManager(pems: List<String>): X509TrustManager {
val certificateFactory = CertificateFactory.getInstance("X.509")
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null, null) }
pems.forEachIndexed { index, pem ->
val certificate = ByteArrayInputStream(pem.toByteArray(Charsets.UTF_8)).use { stream ->
certificateFactory.generateCertificate(stream) as X509Certificate
}
keyStore.setCertificateEntry("hue-$index", certificate)
}
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(keyStore)
return factory.trustManagers.filterIsInstance<X509TrustManager>().first()
}
}
Loading
Loading