diff --git a/examples/hue/build.gradle.kts b/examples/hue/build.gradle.kts new file mode 100644 index 00000000..44695041 --- /dev/null +++ b/examples/hue/build.gradle.kts @@ -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) +} diff --git a/examples/hue/lighttool.toml b/examples/hue/lighttool.toml new file mode 100644 index 00000000..bf435ed3 --- /dev/null +++ b/examples/hue/lighttool.toml @@ -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" diff --git a/examples/hue/src/main/kotlin/com/thelightphone/hue/HueApi.kt b/examples/hue/src/main/kotlin/com/thelightphone/hue/HueApi.kt new file mode 100644 index 00000000..20a17eb5 --- /dev/null +++ b/examples/hue/src/main/kotlin/com/thelightphone/hue/HueApi.kt @@ -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> = runCatching { + cloudClient.get("https://discovery.meethue.com/") + .body>() + .filter { it.internalipaddress.isNotBlank() } + } + + suspend fun fetchConfig(ip: String): Result = runCatching { + bridgeClient.get("https://$ip/api/config").body() + } + + /** Attempts to register with the bridge. Requires the link button to have been pressed. */ + suspend fun pair(ip: String): Result = 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>(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> = runCatching { + val body = bridgeClient.get("https://$ip/api/$appKey/lights").bodyAsText() + ensureAuthorized(body) + json.decodeFromString>(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 = + 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 = 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>(body) }.getOrNull() + val error = results?.firstNotNullOfOrNull { it.error } + if (error != null) throw UnauthorizedException() + } + + private fun throwOnCommandError(body: String) { + val results = runCatching { json.decodeFromString>(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, +) diff --git a/examples/hue/src/main/kotlin/com/thelightphone/hue/HueBridgeTls.kt b/examples/hue/src/main/kotlin/com/thelightphone/hue/HueBridgeTls.kt new file mode 100644 index 00000000..056fbc0a --- /dev/null +++ b/examples/hue/src/main/kotlin/com/thelightphone/hue/HueBridgeTls.kt @@ -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): 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().first() + } +} diff --git a/examples/hue/src/main/kotlin/com/thelightphone/hue/HueHomeScreen.kt b/examples/hue/src/main/kotlin/com/thelightphone/hue/HueHomeScreen.kt new file mode 100644 index 00000000..e82208fd --- /dev/null +++ b/examples/hue/src/main/kotlin/com/thelightphone/hue/HueHomeScreen.kt @@ -0,0 +1,278 @@ +package com.thelightphone.hue + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import com.thelightphone.sdk.InitialScreen +import com.thelightphone.sdk.LightScreen +import com.thelightphone.sdk.SealedLightActivity +import com.thelightphone.sdk.rememberKeyboardOptions +import com.thelightphone.sdk.ui.LightBarButton +import com.thelightphone.sdk.ui.LightBottomBar +import com.thelightphone.sdk.ui.LightFullscreenModal +import com.thelightphone.sdk.ui.LightIcon +import com.thelightphone.sdk.ui.LightIcons +import com.thelightphone.sdk.ui.LightScrollView +import com.thelightphone.sdk.ui.LightText +import com.thelightphone.sdk.ui.LightTextInputEditor +import com.thelightphone.sdk.ui.LightTextVariant +import com.thelightphone.sdk.ui.LightTheme +import com.thelightphone.sdk.ui.LightThemeController +import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.LightTopBar +import com.thelightphone.sdk.ui.LightTopBarCenter +import com.thelightphone.sdk.ui.gridUnitsAsDp +import com.thelightphone.sdk.ui.lightClickable + +@InitialScreen +class HueHomeScreen(sealedActivity: SealedLightActivity) : + LightScreen(sealedActivity) { + + override val viewModelClass: Class + get() = HueViewModel::class.java + + override fun createViewModel(): HueViewModel = HueViewModel(lightContext.dataStore) + + @Composable + override fun Content() { + val themeColors by LightThemeController.colors.collectAsState() + val state by viewModel.uiState.collectAsState() + val textFieldState = rememberTextFieldState("") + val keyboardOptionsFlow = rememberKeyboardOptions() + + LightTheme(colors = themeColors) { + Box( + modifier = Modifier + .fillMaxSize() + .background(LightThemeTokens.colors.background), + ) { + when (val mode = state.mode) { + is HueScreenMode.Loading -> + StatusScreen(title = "Hue", message = "Loading…") + + is HueScreenMode.Discovering -> + StatusScreen(title = "Hue", message = "Looking for your bridge…") + + is HueScreenMode.BridgePicker -> + BridgePickerContent( + bridges = mode.bridges, + onSelect = viewModel::selectBridge, + onEnterIp = viewModel::openManualIp, + ) + + is HueScreenMode.ManualIp -> + LightTextInputEditor( + title = "Bridge IP address", + editorKey = state.manualIpSession, + keyboardOptionsFlow = keyboardOptionsFlow, + state = textFieldState, + onSubmit = viewModel::submitManualIp, + onBack = viewModel::startDiscovery, + submitIcon = LightIcons.ACCEPT, + singleLine = true, + modifier = Modifier.fillMaxSize(), + ) + + is HueScreenMode.Pairing -> + PairingContent( + message = mode.message, + onPair = viewModel::pair, + onBack = viewModel::startDiscovery, + ) + + is HueScreenMode.Lights -> + LightsContent( + bridgeName = mode.bridgeName, + lights = mode.lights, + onToggle = viewModel::toggleLight, + onAllOff = viewModel::turnAllOff, + onAllOn = viewModel::turnAllOn, + onRefresh = viewModel::loadLights, + onForget = viewModel::forgetBridge, + ) + } + + state.errorModal?.let { message -> + LightFullscreenModal(message = message, onClose = viewModel::dismissError) + } + } + } + } +} + +@Composable +private fun StatusScreen(title: String, message: String) { + Column(modifier = Modifier.fillMaxSize()) { + LightTopBar( + center = LightTopBarCenter.Text(title), + modifier = Modifier.padding(bottom = 1f.gridUnitsAsDp()), + ) + Box( + modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = 1f.gridUnitsAsDp()), + contentAlignment = Alignment.Center, + ) { + LightText(text = message, variant = LightTextVariant.Copy, align = TextAlign.Center) + } + } +} + +@Composable +private fun BridgePickerContent( + bridges: List, + onSelect: (DiscoveredBridge) -> Unit, + onEnterIp: () -> Unit, +) { + Column(modifier = Modifier.fillMaxSize()) { + LightTopBar( + center = LightTopBarCenter.Text("Choose bridge"), + modifier = Modifier.padding(bottom = 0.5f.gridUnitsAsDp()), + ) + LightScrollView( + modifier = Modifier.weight(1f).fillMaxWidth().padding(start = 1f.gridUnitsAsDp()), + ) { + bridges.forEach { bridge -> + Column( + modifier = Modifier + .fillMaxWidth() + .lightClickable { onSelect(bridge) } + .padding(bottom = 1f.gridUnitsAsDp()), + ) { + LightText(text = bridge.internalipaddress, variant = LightTextVariant.Copy) + if (bridge.id.isNotBlank()) { + LightText(text = bridge.id, variant = LightTextVariant.Detail, lighten = true) + } + } + } + } + LightBottomBar( + items = listOf( + null, + LightBarButton.Text(text = "ENTER IP", onClick = onEnterIp), + null, + ), + ) + } +} + +@Composable +private fun PairingContent( + message: String, + onPair: () -> Unit, + onBack: () -> Unit, +) { + Column(modifier = Modifier.fillMaxSize()) { + LightTopBar( + center = LightTopBarCenter.Text("Pair bridge"), + modifier = Modifier.padding(bottom = 1f.gridUnitsAsDp()), + ) + Box( + modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = 1f.gridUnitsAsDp()), + contentAlignment = Alignment.Center, + ) { + LightText(text = message, variant = LightTextVariant.Copy, align = TextAlign.Center) + } + LightBottomBar( + items = listOf( + LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = onBack), + LightBarButton.Text(text = "PAIR", onClick = onPair), + null, + ), + ) + } +} + +@Composable +private fun LightsContent( + bridgeName: String, + lights: List, + onToggle: (UiLight) -> Unit, + onAllOff: () -> Unit, + onAllOn: () -> Unit, + onRefresh: () -> Unit, + onForget: () -> Unit, +) { + Column(modifier = Modifier.fillMaxSize()) { + LightTopBar( + center = LightTopBarCenter.Text(bridgeName), + modifier = Modifier.padding(bottom = 0.5f.gridUnitsAsDp()), + ) + LightScrollView( + modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = 1f.gridUnitsAsDp()), + ) { + LightText( + text = "Turn everything off", + variant = LightTextVariant.Heading, + modifier = Modifier + .fillMaxWidth() + .lightClickable(onClick = onAllOff) + .padding(vertical = 0.5f.gridUnitsAsDp()), + ) + LightText( + text = "Turn everything on", + variant = LightTextVariant.Copy, + lighten = true, + modifier = Modifier + .fillMaxWidth() + .lightClickable(onClick = onAllOn) + .padding(bottom = 1f.gridUnitsAsDp()), + ) + + if (lights.isEmpty()) { + LightText( + text = "No lights found on this bridge.", + variant = LightTextVariant.Copy, + lighten = true, + ) + } else { + lights.forEach { light -> + Row( + modifier = Modifier + .fillMaxWidth() + .lightClickable { onToggle(light) } + .padding(vertical = 0.5f.gridUnitsAsDp()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + LightText( + text = light.name, + variant = LightTextVariant.Copy, + lighten = !light.reachable, + modifier = Modifier.weight(1f).padding(end = 1f.gridUnitsAsDp()), + ) + LightIcon( + icon = if (light.on) LightIcons.TOGGLE_STATE_ON else LightIcons.TOGGLE_STATE_OFF, + contentDescription = if (light.on) "On" else "Off", + ) + } + } + } + } + LightBottomBar( + items = listOf( + LightBarButton.LightIcon( + icon = LightIcons.SETTINGS, + onClick = onForget, + contentDescription = "Forget bridge", + ), + LightBarButton.Text(text = "ALL OFF", onClick = onAllOff), + LightBarButton.LightIcon( + icon = LightIcons.REFRESH, + onClick = onRefresh, + contentDescription = "Refresh", + ), + ), + ) + } +} diff --git a/examples/hue/src/main/kotlin/com/thelightphone/hue/HueModels.kt b/examples/hue/src/main/kotlin/com/thelightphone/hue/HueModels.kt new file mode 100644 index 00000000..24e9fa9b --- /dev/null +++ b/examples/hue/src/main/kotlin/com/thelightphone/hue/HueModels.kt @@ -0,0 +1,55 @@ +package com.thelightphone.hue + +import kotlinx.serialization.Serializable + +/** One entry from the meethue.com N-UPnP discovery endpoint. */ +@Serializable +data class DiscoveredBridge( + val id: String = "", + val internalipaddress: String = "", + val port: Int = 443, +) + +/** Unauthenticated `/api/config` payload, used to learn the bridge id/name before pairing. */ +@Serializable +data class HueConfig( + val name: String = "", + val bridgeid: String = "", + val modelid: String = "", + val swversion: String = "", +) + +@Serializable +data class HueCreatedUser( + val username: String = "", + val clientkey: String = "", +) + +@Serializable +data class HueApiError( + val type: Int = 0, + val address: String = "", + val description: String = "", +) + +/** Bridge responses are arrays of `{"success": ...}` / `{"error": ...}` items. */ +@Serializable +data class HueCommandResult( + val success: HueCreatedUser? = null, + val error: HueApiError? = null, +) + +@Serializable +data class HueLightState( + val on: Boolean = false, + val reachable: Boolean = true, +) + +@Serializable +data class HueLight( + val name: String = "", + val state: HueLightState = HueLightState(), +) + +/** Bridge error code returned when the link button has not been pressed yet. */ +internal const val HUE_LINK_BUTTON_NOT_PRESSED = 101 diff --git a/examples/hue/src/main/kotlin/com/thelightphone/hue/HuePreferences.kt b/examples/hue/src/main/kotlin/com/thelightphone/hue/HuePreferences.kt new file mode 100644 index 00000000..1b1e8ae9 --- /dev/null +++ b/examples/hue/src/main/kotlin/com/thelightphone/hue/HuePreferences.kt @@ -0,0 +1,9 @@ +package com.thelightphone.hue + +import androidx.datastore.preferences.core.stringPreferencesKey + +internal object HuePreferences { + val BRIDGE_IP = stringPreferencesKey("bridge_ip") + val BRIDGE_ID = stringPreferencesKey("bridge_id") + val APP_KEY = stringPreferencesKey("app_key") +} diff --git a/examples/hue/src/main/kotlin/com/thelightphone/hue/HueViewModel.kt b/examples/hue/src/main/kotlin/com/thelightphone/hue/HueViewModel.kt new file mode 100644 index 00000000..5f95ef0d --- /dev/null +++ b/examples/hue/src/main/kotlin/com/thelightphone/hue/HueViewModel.kt @@ -0,0 +1,278 @@ +package com.thelightphone.hue + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.lifecycle.viewModelScope +import com.thelightphone.sdk.LightViewModel +import com.thelightphone.sdk.SimpleLightScreen +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +sealed class HueScreenMode { + data object Loading : HueScreenMode() + data object Discovering : HueScreenMode() + data class BridgePicker(val bridges: List) : HueScreenMode() + data object ManualIp : HueScreenMode() + data class Pairing(val ip: String, val message: String) : HueScreenMode() + data class Lights( + val bridgeName: String, + val lights: List, + val busy: Boolean = false, + ) : HueScreenMode() +} + +data class HueUiState( + val mode: HueScreenMode = HueScreenMode.Loading, + val manualIpSession: Int = 0, + val errorModal: String? = null, +) + +private const val NETWORK_ERROR = + "The Hue tool needs to reach your bridge over Wi-Fi. Connect to the same network as your Hue bridge and try again." + +class HueViewModel( + private val dataStore: DataStore, +) : LightViewModel() { + private val api = HueApi() + + private val _uiState = MutableStateFlow(HueUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var bridgeIp: String? = null + private var appKey: String? = null + + init { + viewModelScope.launch(Dispatchers.IO) { + val prefs = dataStore.data.first() + bridgeIp = prefs[HuePreferences.BRIDGE_IP] + appKey = prefs[HuePreferences.APP_KEY] + val ip = bridgeIp + val key = appKey + if (ip.isNullOrBlank() || key.isNullOrBlank()) { + startDiscovery() + } else { + loadLights() + } + } + } + + private suspend fun setMode(mode: HueScreenMode) = withContext(Dispatchers.Main) { + _uiState.update { it.copy(mode = mode) } + } + + private suspend fun showError(message: String) = withContext(Dispatchers.Main) { + _uiState.update { it.copy(errorModal = message) } + } + + fun dismissError() { + _uiState.update { it.copy(errorModal = null) } + } + + fun startDiscovery() { + viewModelScope.launch(Dispatchers.IO) { + setMode(HueScreenMode.Discovering) + api.discoverBridges().fold( + onSuccess = { bridges -> + if (bridges.isEmpty()) { + openManualIp() + } else { + setMode(HueScreenMode.BridgePicker(bridges)) + } + }, + onFailure = { openManualIp() }, + ) + } + } + + fun selectBridge(bridge: DiscoveredBridge) { + beginPairing(bridge.internalipaddress) + } + + fun openManualIp() { + _uiState.update { + it.copy(mode = HueScreenMode.ManualIp, manualIpSession = it.manualIpSession + 1) + } + } + + fun submitManualIp(raw: CharSequence) { + val ip = raw.toString().trim() + if (!isValidHost(ip)) { + _uiState.update { it.copy(errorModal = "Enter a valid IP address, e.g. 192.168.1.42") } + return + } + beginPairing(ip) + } + + private fun beginPairing(ip: String) { + viewModelScope.launch(Dispatchers.IO) { + setMode( + HueScreenMode.Pairing( + ip = ip, + message = "Press the link button on top of your Hue bridge, then tap Pair.", + ), + ) + } + } + + fun pair() { + val mode = _uiState.value.mode as? HueScreenMode.Pairing ?: return + val ip = mode.ip + viewModelScope.launch(Dispatchers.IO) { + setMode(mode.copy(message = "Pairing…")) + api.pair(ip).fold( + onSuccess = { created -> + val bridgeId = api.fetchConfig(ip).getOrNull()?.bridgeid.orEmpty() + persistBridge(ip, bridgeId, created.username) + loadLights() + }, + onFailure = { error -> + when (error) { + is LinkButtonNotPressedException -> setMode( + mode.copy( + message = "Link button not detected yet. Press it on your bridge, then tap Pair.", + ), + ) + else -> { + setMode(mode.copy(message = "Press the link button, then tap Pair.")) + showError(error.friendlyMessage()) + } + } + }, + ) + } + } + + fun loadLights() { + val ip = bridgeIp + val key = appKey + if (ip.isNullOrBlank() || key.isNullOrBlank()) { + startDiscovery() + return + } + viewModelScope.launch(Dispatchers.IO) { + val current = _uiState.value.mode + if (current !is HueScreenMode.Lights) setMode(HueScreenMode.Loading) + val name = api.fetchConfig(ip).getOrNull()?.name?.ifBlank { null } ?: "Hue" + api.listLights(ip, key).fold( + onSuccess = { lights -> + setMode(HueScreenMode.Lights(bridgeName = name, lights = lights)) + }, + onFailure = { error -> + if (error is UnauthorizedException) { + forgetBridge() + } else { + setMode(HueScreenMode.Lights(bridgeName = name, lights = emptyList())) + showError(error.friendlyMessage()) + } + }, + ) + } + } + + fun toggleLight(light: UiLight) { + val ip = bridgeIp ?: return + val key = appKey ?: return + val target = !light.on + updateLightsMode { it.copy(lights = it.lights.replacing(light.id, target), busy = true) } + viewModelScope.launch(Dispatchers.IO) { + api.setLightOn(ip, key, light.id, target).fold( + onSuccess = { loadLights() }, + onFailure = { error -> onCommandFailure(error) }, + ) + } + } + + fun turnAllOff() = setAll(false) + + fun turnAllOn() = setAll(true) + + private fun setAll(on: Boolean) { + val ip = bridgeIp ?: return + val key = appKey ?: return + updateLightsMode { mode -> + mode.copy(lights = mode.lights.map { it.copy(on = on) }, busy = true) + } + viewModelScope.launch(Dispatchers.IO) { + api.setAllLightsOn(ip, key, on).fold( + onSuccess = { loadLights() }, + onFailure = { error -> onCommandFailure(error) }, + ) + } + } + + fun forgetBridge() { + viewModelScope.launch(Dispatchers.IO) { + dataStore.edit { prefs -> + prefs.remove(HuePreferences.BRIDGE_IP) + prefs.remove(HuePreferences.BRIDGE_ID) + prefs.remove(HuePreferences.APP_KEY) + } + bridgeIp = null + appKey = null + startDiscovery() + } + } + + fun cancelToLights() { + if (appKey.isNullOrBlank()) startDiscovery() else loadLights() + } + + private suspend fun onCommandFailure(error: Throwable) { + if (error is UnauthorizedException) { + forgetBridge() + return + } + showError(error.friendlyMessage()) + loadLights() + } + + private fun updateLightsMode(transform: (HueScreenMode.Lights) -> HueScreenMode.Lights) { + _uiState.update { state -> + val mode = state.mode as? HueScreenMode.Lights ?: return@update state + state.copy(mode = transform(mode)) + } + } + + private suspend fun persistBridge(ip: String, bridgeId: String, key: String) { + bridgeIp = ip + appKey = key + runCatching { + dataStore.edit { prefs -> + prefs[HuePreferences.BRIDGE_IP] = ip + prefs[HuePreferences.BRIDGE_ID] = bridgeId + prefs[HuePreferences.APP_KEY] = key + } + } + } + + override fun onCleared() { + super.onCleared() + api.close() + } +} + +private fun List.replacing(id: String, on: Boolean): List = + map { if (it.id == id) it.copy(on = on) else it } + +private fun Throwable.friendlyMessage(): String = when (this) { + is UnauthorizedException -> message ?: "The bridge no longer recognizes this tool." + is IllegalStateException -> message ?: NETWORK_ERROR + else -> NETWORK_ERROR +} + +internal fun isValidHost(value: String): Boolean { + if (value.isBlank()) return false + val parts = value.split(".") + if (parts.size == 4 && parts.all { part -> part.toIntOrNull()?.let { it in 0..255 } == true }) { + return true + } + // Allow hostnames too (e.g. a static DNS name for the bridge). + return value.matches(Regex("^[A-Za-z0-9][A-Za-z0-9.-]*$")) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index f12d7f22..e3bc4964 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -47,3 +47,5 @@ include(":examples:weather") project(":examples:weather").projectDir = file("examples/weather") include(":examples:authenticator") project(":examples:authenticator").projectDir = file("examples/authenticator") +include(":examples:hue") +project(":examples:hue").projectDir = file("examples/hue")