haunt / commit
Show Hue lights grouped by room/zone in Add Light dialog
.../main/java/net/fuzzyworld/haunt/MainActivity.kt | 123 ++++++++++++++------- .../net/fuzzyworld/haunt/debug/DebugViewModel.kt | 3 +- .../main/java/net/fuzzyworld/haunt/hue/HueApi.kt | 18 +++ .../net/fuzzyworld/haunt/hue/HueBridgeClient.kt | 11 ++ .../java/net/fuzzyworld/haunt/hue/HueLightPoint.kt | 1 + 5 files changed, 118 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt b/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt index b8c80cf..eafd985 100644 --- a/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt +++ b/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt @@ -21,6 +21,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager +import kotlinx.coroutines.async import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import net.fuzzyworld.haunt.databinding.ActivityMainBinding @@ -254,54 +255,68 @@ class MainActivity : AppCompatActivity() { val ip = vm.hueConfig.bridgeIp ?: return@launch val username = vm.hueConfig.username ?: return@launch val client = HueBridgeClient(ip) - val lights = client.getLights(username) + + // Fetch groups and lights concurrently + val groupsDeferred = async { client.getGroups(username) } + val lightsDeferred = async { client.getLights(username) } + val groups = groupsDeferred.await() + val lights = lightsDeferred.await() container.removeAllViews() - if (lights.isEmpty()) { + if (groups.isEmpty() && lights.isEmpty()) { container.addView(TextView(this@MainActivity).apply { text = "No lights found." }) return@launch } - lights.forEach { light -> - val row = LinearLayout(this@MainActivity).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(0, (6 * dp).toInt(), 0, (6 * dp).toInt()) - } - - val nameText = TextView(this@MainActivity).apply { - text = "${light.name} (${light.id})" - layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) - } - - val blinkBtn = Button(this@MainActivity).apply { - text = "Blink" - setOnClickListener { - lifecycleScope.launch { - try { client.blink(username, light.id) } - catch (e: Exception) { - Toast.makeText(this@MainActivity, "Blink failed: ${e.message}", Toast.LENGTH_SHORT).show() + // --- Groups section --- + if (groups.isNotEmpty()) { + container.addView(sectionHeader("Rooms & Zones", dp)) + groups.forEach { group -> + container.addView(lightRow( + label = "${group.name} (${group.lightCount} lights)", + blinkLabel = "Blink all", + onBlink = { + lifecycleScope.launch { + try { client.blinkGroup(username, group.id) } + catch (e: Exception) { + Toast.makeText(this@MainActivity, "Blink failed: ${e.message}", Toast.LENGTH_SHORT).show() + } } - } - } + }, + onAdd = { + vm.addLightPoint(group.id, group.name, isGroup = true) + dialog.dismiss() + Toast.makeText(this@MainActivity, "Added \"${group.name}\"", Toast.LENGTH_SHORT).show() + }, + dp = dp, + )) } + } - val addBtn = Button(this@MainActivity).apply { - text = "Add here" - setBackgroundColor(Color.parseColor("#4CAF50")) - setTextColor(Color.WHITE) - setOnClickListener { - vm.addLightPoint(light.id, light.name) - dialog.dismiss() - Toast.makeText(this@MainActivity, "Added \"${light.name}\"", Toast.LENGTH_SHORT).show() - } + // --- Individual lights section --- + if (lights.isNotEmpty()) { + container.addView(sectionHeader("Individual Lights", dp)) + lights.forEach { light -> + container.addView(lightRow( + label = light.name, + blinkLabel = "Blink", + onBlink = { + lifecycleScope.launch { + try { client.blink(username, light.id) } + catch (e: Exception) { + Toast.makeText(this@MainActivity, "Blink failed: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + }, + onAdd = { + vm.addLightPoint(light.id, light.name) + dialog.dismiss() + Toast.makeText(this@MainActivity, "Added \"${light.name}\"", Toast.LENGTH_SHORT).show() + }, + dp = dp, + )) } - - row.addView(nameText) - row.addView(blinkBtn) - row.addView(addBtn) - container.addView(row) } } catch (e: Exception) { container.removeAllViews() @@ -311,4 +326,38 @@ class MainActivity : AppCompatActivity() { } } } + + private fun sectionHeader(title: String, dp: Float) = TextView(this).apply { + text = title + setTypeface(typeface, android.graphics.Typeface.BOLD) + setTextColor(android.graphics.Color.parseColor("#555555")) + setPadding(0, (12 * dp).toInt(), 0, (4 * dp).toInt()) + } + + private fun lightRow( + label: String, + blinkLabel: String, + onBlink: () -> Unit, + onAdd: () -> Unit, + dp: Float, + ) = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(0, (4 * dp).toInt(), 0, (4 * dp).toInt()) + + addView(TextView(this@MainActivity).apply { + text = label + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + }) + addView(Button(this@MainActivity).apply { + text = blinkLabel + setOnClickListener { onBlink() } + }) + addView(Button(this@MainActivity).apply { + text = "Add here" + setBackgroundColor(android.graphics.Color.parseColor("#4CAF50")) + setTextColor(android.graphics.Color.WHITE) + setOnClickListener { onAdd() } + }) + } } diff --git a/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt b/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt index e0d06ea..0f7a92c 100644 --- a/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt +++ b/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt @@ -197,7 +197,7 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) { store.save(_points.value) } - fun addLightPoint(hueLightId: String, name: String) { + fun addLightPoint(hueLightId: String, name: String, isGroup: Boolean = false) { val current = _readings.value if (current.isEmpty()) return val point = HueLightPoint( @@ -206,6 +206,7 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) { name = name, color = randomColor(), readings = current.associate { it.bssid to it.distanceMeters }, + isGroup = isGroup, ) _lightPoints.value = _lightPoints.value + point lightStore.save(_lightPoints.value) diff --git a/app/src/main/java/net/fuzzyworld/haunt/hue/HueApi.kt b/app/src/main/java/net/fuzzyworld/haunt/hue/HueApi.kt index 1a2cd5c..f1f1d17 100644 --- a/app/src/main/java/net/fuzzyworld/haunt/hue/HueApi.kt +++ b/app/src/main/java/net/fuzzyworld/haunt/hue/HueApi.kt @@ -26,6 +26,12 @@ data class HueLightResponse( data class HueLightState(val on: Boolean) +data class HueGroupResponse( + val name: String, + val type: String, + val lights: List<String>, +) + data class PairResultItem( val success: Map<String, String>?, val error: Map<String, Any>?, @@ -47,6 +53,18 @@ interface HueApi { @Body state: LightStateRequest, ): List<JsonElement> + @GET("api/{username}/groups") + suspend fun getGroups( + @Path("username") username: String, + ): Map<String, HueGroupResponse> + + @PUT("api/{username}/groups/{id}/action") + suspend fun setGroupAction( + @Path("username") username: String, + @Path("id") id: String, + @Body state: LightStateRequest, + ): List<JsonElement> + @POST("api") suspend fun pair(@Body body: PairRequest): List<PairResultItem> } diff --git a/app/src/main/java/net/fuzzyworld/haunt/hue/HueBridgeClient.kt b/app/src/main/java/net/fuzzyworld/haunt/hue/HueBridgeClient.kt index 9279277..f4439af 100644 --- a/app/src/main/java/net/fuzzyworld/haunt/hue/HueBridgeClient.kt +++ b/app/src/main/java/net/fuzzyworld/haunt/hue/HueBridgeClient.kt @@ -4,6 +4,7 @@ import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory data class HueLightInfo(val id: String, val name: String, val on: Boolean) +data class HueGroupInfo(val id: String, val name: String, val type: String, val lightCount: Int) class HueBridgeClient(bridgeIp: String) { @@ -18,10 +19,20 @@ class HueBridgeClient(bridgeIp: String) { .map { (id, resp) -> HueLightInfo(id, resp.name, resp.state.on) } .sortedBy { it.id.toIntOrNull() ?: Int.MAX_VALUE } + suspend fun getGroups(username: String): List<HueGroupInfo> = + api.getGroups(username) + .filter { (_, resp) -> resp.type in setOf("Room", "Zone", "LightGroup") } + .map { (id, resp) -> HueGroupInfo(id, resp.name, resp.type, resp.lights.size) } + .sortedBy { it.name } + suspend fun blink(username: String, lightId: String) { api.setState(username, lightId, LightStateRequest(alert = "select")) } + suspend fun blinkGroup(username: String, groupId: String) { + api.setGroupAction(username, groupId, LightStateRequest(alert = "select")) + } + // Returns the new username on success, null if the bridge button wasn't pressed. suspend fun pair(): String? { val results = api.pair(PairRequest("haunt#android")) diff --git a/app/src/main/java/net/fuzzyworld/haunt/hue/HueLightPoint.kt b/app/src/main/java/net/fuzzyworld/haunt/hue/HueLightPoint.kt index afd6ea8..955d562 100644 --- a/app/src/main/java/net/fuzzyworld/haunt/hue/HueLightPoint.kt +++ b/app/src/main/java/net/fuzzyworld/haunt/hue/HueLightPoint.kt @@ -6,4 +6,5 @@ data class HueLightPoint( val name: String, val color: Int, val readings: Map<String, Double>, + val isGroup: Boolean = false, )