fuzzyworld

haunt / commit

Show Hue lights grouped by room/zone in Add Light dialog

fb0f17435120b16429eeaa7c87d6fb3b7c78881f · Pavel Safronov · 2026-05-13 13:30:12 -0700 · parents cce3b9430320

Show Hue lights grouped by room/zone in Add Light dialog

Fetches /groups and /lights concurrently. Dialog shows a 'Rooms & Zones'
section first (each with a Blink All button), then 'Individual Lights'.
Adding a group pins it as a single map point with isGroup=true so the
correct group-action endpoint is used when triggering lights later.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 .../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,
 )