fuzzyworld

haunt / commit

Add collapsible map, haunt mode, and light state restoration

0b21814b6998ce04896d67ac9edabd9047ec1a51 · Pavel Safronov · 2026-05-13 13:59:52 -0700 · parents fb0f17435120

Add collapsible map, haunt mode, and light state restoration

- Map toggle button collapses/expands the MapView
- Haunt button enables proximity-triggered light blinking (~1.5s on/off)
- DebugViewModel: hauntMode/hauntTarget StateFlow, startHaunting/stopHaunting,
  blinkLoop with HAUNT_THRESHOLD=2.0 fingerprint-space proximity check
- Before blinking, capture the light's current on/off state via new
  getLightState/getGroupState; restore it in a finally+NonCancellable block
  so the light always returns to its prior state when haunting stops
- HueApi: added GET /lights/{id} and GET /groups/{id} endpoints
- Haunt button turns red and reads "Haunting" while active

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 .../main/java/net/fuzzyworld/haunt/MainActivity.kt |  86 +++++++++++++++++
 .../net/fuzzyworld/haunt/debug/DebugViewModel.kt   | 106 +++++++++++++++++++++
 .../main/java/net/fuzzyworld/haunt/hue/HueApi.kt   |  19 ++++
 .../net/fuzzyworld/haunt/hue/HueBridgeClient.kt    |  14 +++
 app/src/main/res/layout/activity_main.xml          |  38 +++++++-
 5 files changed, 261 insertions(+), 2 deletions(-)

diff --git a/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt b/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt
index eafd985..6f284fb 100644
--- a/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt
+++ b/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt
@@ -1,8 +1,13 @@
 package net.fuzzyworld.haunt
 
 import android.Manifest
+import android.content.Context
 import android.content.pm.PackageManager
 import android.graphics.Color
+import android.hardware.Sensor
+import android.hardware.SensorEvent
+import android.hardware.SensorEventListener
+import android.hardware.SensorManager
 import android.os.Build
 import android.os.Bundle
 import android.view.Gravity
@@ -35,6 +40,33 @@ class MainActivity : AppCompatActivity() {
     private lateinit var binding: ActivityMainBinding
     private val vm: DebugViewModel by viewModels()
 
+    private lateinit var sensorManager: SensorManager
+    private var rotationSensor: Sensor? = null
+    private var orientActive = false
+    private var smoothedHeading = 0f
+    private var hasFirstHeading = false
+
+    private val prefs by lazy { getSharedPreferences("haunt_prefs", Context.MODE_PRIVATE) }
+
+    private val headingListener = object : SensorEventListener {
+        private val rotMat = FloatArray(9)
+        private val orientation = FloatArray(3)
+        override fun onSensorChanged(event: SensorEvent) {
+            SensorManager.getRotationMatrixFromVector(rotMat, event.values)
+            SensorManager.getOrientation(rotMat, orientation)
+            val raw = (Math.toDegrees(orientation[0].toDouble()).toFloat() + 360f) % 360f
+            if (!hasFirstHeading) { smoothedHeading = raw; hasFirstHeading = true }
+            else {
+                var delta = raw - smoothedHeading
+                if (delta > 180f) delta -= 360f
+                if (delta < -180f) delta += 360f
+                smoothedHeading = (smoothedHeading + delta * 0.25f + 360f) % 360f
+            }
+            binding.mapView.currentHeading = smoothedHeading
+        }
+        override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
+    }
+
     private val adapter = PointsAdapter(
         onRename = { id, name -> vm.renamePoint(id, name) },
         onDelete = { id -> vm.deletePoint(id) },
@@ -64,6 +96,37 @@ class MainActivity : AppCompatActivity() {
         binding.addPointButton.setOnClickListener { showAddPointDialog() }
         binding.addLightButton.setOnClickListener { showAddLightFlow() }
 
+        var mapExpanded = true
+        binding.mapToggleButton.setOnClickListener {
+            mapExpanded = !mapExpanded
+            binding.mapView.visibility = if (mapExpanded) android.view.View.VISIBLE else android.view.View.GONE
+            binding.mapToggleButton.text = if (mapExpanded) "Map ▾" else "Map ▸"
+        }
+
+        binding.hauntButton.setOnClickListener { vm.toggleHauntMode() }
+
+        sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
+        rotationSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
+
+        binding.mapView.northOffsetDeg = prefs.getFloat("north_offset", 0f)
+        binding.mapView.onNorthOffsetChanged = { offset ->
+            prefs.edit().putFloat("north_offset", offset).apply()
+        }
+
+        binding.orientButton.setOnClickListener {
+            orientActive = !orientActive
+            binding.mapView.orientationEnabled = orientActive
+            if (orientActive) {
+                rotationSensor?.let { s ->
+                    sensorManager.registerListener(headingListener, s, SensorManager.SENSOR_DELAY_UI)
+                }
+                binding.orientButton.text = "⊙ Orient ●"
+            } else {
+                sensorManager.unregisterListener(headingListener)
+                binding.orientButton.text = "⊙ Orient"
+            }
+        }
+
         val needed = buildList {
             if (ContextCompat.checkSelfPermission(this@MainActivity, Manifest.permission.ACCESS_FINE_LOCATION)
                 != PackageManager.PERMISSION_GRANTED) add(Manifest.permission.ACCESS_FINE_LOCATION)
@@ -114,6 +177,29 @@ class MainActivity : AppCompatActivity() {
             }
         }
 
+        lifecycleScope.launch {
+            vm.hauntMode.collectLatest { active ->
+                binding.hauntButton.text = if (active) "Haunting" else "Haunt"
+                binding.hauntButton.setBackgroundColor(
+                    if (active) Color.parseColor("#CC3333") else Color.parseColor("#555555")
+                )
+            }
+        }
+
+    }
+
+    override fun onResume() {
+        super.onResume()
+        if (orientActive) {
+            rotationSensor?.let { s ->
+                sensorManager.registerListener(headingListener, s, SensorManager.SENSOR_DELAY_UI)
+            }
+        }
+    }
+
+    override fun onPause() {
+        super.onPause()
+        sensorManager.unregisterListener(headingListener)
     }
 
     // --- Add Point dialog ---
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 0f7a92c..5169e51 100644
--- a/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt
+++ b/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt
@@ -5,15 +5,22 @@ import android.content.Context
 import android.graphics.Color
 import androidx.lifecycle.AndroidViewModel
 import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.NonCancellable
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collect
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
+import net.fuzzyworld.haunt.hue.HueBridgeClient
+import net.fuzzyworld.haunt.hue.LightStateRequest
+import kotlin.math.sqrt
 import net.fuzzyworld.haunt.hue.HueBridgeConfig
 import net.fuzzyworld.haunt.hue.HueLightPoint
 import net.fuzzyworld.haunt.hue.HueLightStore
@@ -46,6 +53,14 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) {
     private val _lightPoints = MutableStateFlow<List<HueLightPoint>>(emptyList())
     val lightPoints: StateFlow<List<HueLightPoint>> = _lightPoints
 
+    private val _hauntMode = MutableStateFlow(false)
+    val hauntMode: StateFlow<Boolean> = _hauntMode
+
+    private val _hauntTarget = MutableStateFlow<String?>(null) // hueLightId currently being haunted
+    val hauntTarget: StateFlow<String?> = _hauntTarget
+
+    private var hauntJob: Job? = null
+
     private val _networkDevices = MutableStateFlow<List<NetworkDevice>>(emptyList())
     val networkDevices: StateFlow<List<NetworkDevice>> = _networkDevices
 
@@ -147,8 +162,95 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) {
         historyStore.save(_history.value)
     }
 
+    fun toggleHauntMode() {
+        val active = !_hauntMode.value
+        _hauntMode.value = active
+        if (active) startHaunting() else stopHaunting()
+    }
+
+    private fun startHaunting() {
+        hauntJob?.cancel()
+        hauntJob = viewModelScope.launch {
+            var blinkJob: Job? = null
+            var currentId: String? = null
+            _readings.collect { readings ->
+                val target = nearestLight(readings.associate { it.bssid to it.distanceMeters })
+                if (target?.hueLightId != currentId) {
+                    blinkJob?.cancel()
+                    currentId = target?.hueLightId
+                    _hauntTarget.value = currentId
+                    if (target != null) {
+                        blinkJob = launch(Dispatchers.IO) { blinkLoop(target) }
+                    }
+                }
+            }
+        }
+    }
+
+    private fun stopHaunting() {
+        hauntJob?.cancel()
+        hauntJob = null
+        _hauntTarget.value = null
+    }
+
+    private suspend fun blinkLoop(point: HueLightPoint) {
+        val ip = hueConfig.bridgeIp ?: return
+        val username = hueConfig.username ?: return
+        val client = HueBridgeClient(ip)
+
+        val wasOn = try {
+            if (point.isGroup) client.getGroupState(username, point.hueLightId)
+            else client.getLightState(username, point.hueLightId)
+        } catch (_: Exception) { false }
+
+        try {
+            while (true) {
+                try {
+                    val on = LightStateRequest(on = true)
+                    val off = LightStateRequest(on = false)
+                    if (point.isGroup) {
+                        client.setGroupState(username, point.hueLightId, on)
+                        delay(1500)
+                        client.setGroupState(username, point.hueLightId, off)
+                        delay(1500)
+                    } else {
+                        client.setLightState(username, point.hueLightId, on)
+                        delay(1500)
+                        client.setLightState(username, point.hueLightId, off)
+                        delay(1500)
+                    }
+                } catch (e: CancellationException) {
+                    throw e
+                } catch (_: Exception) {
+                    delay(2000)
+                }
+            }
+        } finally {
+            withContext(NonCancellable) {
+                try {
+                    val restore = LightStateRequest(on = wasOn)
+                    if (point.isGroup) client.setGroupState(username, point.hueLightId, restore)
+                    else client.setLightState(username, point.hueLightId, restore)
+                } catch (_: Exception) {}
+            }
+        }
+    }
+
+    private fun nearestLight(current: Map<String, Double>): HueLightPoint? {
+        if (current.isEmpty()) return null
+        return _lightPoints.value.mapNotNull { light ->
+            val common = current.keys.intersect(light.readings.keys)
+            if (common.isEmpty()) return@mapNotNull null
+            val dist = sqrt(common.sumOf { k -> (current[k]!! - light.readings[k]!!).let { d -> d * d } })
+            light to dist
+        }.filter { (_, d) -> d < HAUNT_THRESHOLD }
+            .minByOrNull { (_, d) -> d }
+            ?.first
+    }
+
     override fun onCleared() {
         super.onCleared()
+        hauntJob?.cancel()
         viewModelScope.launch(Dispatchers.IO) { historyStore.save(_history.value) }
     }
 
@@ -232,4 +334,8 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) {
         nextHue = (nextHue + 137.5f) % 360f
         return Color.HSVToColor(floatArrayOf(nextHue, 0.72f, 0.82f))
     }
+
+    companion object {
+        private const val HAUNT_THRESHOLD = 2.0
+    }
 }
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 f1f1d17..db97b97 100644
--- a/app/src/main/java/net/fuzzyworld/haunt/hue/HueApi.kt
+++ b/app/src/main/java/net/fuzzyworld/haunt/hue/HueApi.kt
@@ -32,6 +32,13 @@ data class HueGroupResponse(
     val lights: List<String>,
 )
 
+data class HueGroupDetailResponse(
+    val name: String,
+    val type: String,
+    val lights: List<String>,
+    val action: HueLightState,
+)
+
 data class PairResultItem(
     val success: Map<String, String>?,
     val error: Map<String, Any>?,
@@ -46,6 +53,18 @@ interface HueApi {
         @Path("username") username: String,
     ): Map<String, HueLightResponse>
 
+    @GET("api/{username}/lights/{id}")
+    suspend fun getLight(
+        @Path("username") username: String,
+        @Path("id") id: String,
+    ): HueLightResponse
+
+    @GET("api/{username}/groups/{id}")
+    suspend fun getGroup(
+        @Path("username") username: String,
+        @Path("id") id: String,
+    ): HueGroupDetailResponse
+
     @PUT("api/{username}/lights/{id}/state")
     suspend fun setState(
         @Path("username") username: String,
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 f4439af..3f597eb 100644
--- a/app/src/main/java/net/fuzzyworld/haunt/hue/HueBridgeClient.kt
+++ b/app/src/main/java/net/fuzzyworld/haunt/hue/HueBridgeClient.kt
@@ -25,6 +25,12 @@ class HueBridgeClient(bridgeIp: String) {
             .map { (id, resp) -> HueGroupInfo(id, resp.name, resp.type, resp.lights.size) }
             .sortedBy { it.name }
 
+    suspend fun getLightState(username: String, lightId: String): Boolean =
+        api.getLight(username, lightId).state.on
+
+    suspend fun getGroupState(username: String, groupId: String): Boolean =
+        api.getGroup(username, groupId).action.on
+
     suspend fun blink(username: String, lightId: String) {
         api.setState(username, lightId, LightStateRequest(alert = "select"))
     }
@@ -33,6 +39,14 @@ class HueBridgeClient(bridgeIp: String) {
         api.setGroupAction(username, groupId, LightStateRequest(alert = "select"))
     }
 
+    suspend fun setLightState(username: String, lightId: String, state: LightStateRequest) {
+        api.setState(username, lightId, state)
+    }
+
+    suspend fun setGroupState(username: String, groupId: String, state: LightStateRequest) {
+        api.setGroupAction(username, groupId, state)
+    }
+
     // 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/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 1a6bbfa..9be7c12 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -5,6 +5,31 @@
     android:orientation="vertical"
     android:fitsSystemWindows="true">
 
+    <!-- Map toggle bar -->
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="36dp"
+        android:orientation="horizontal">
+
+        <Button
+            android:id="@+id/mapToggleButton"
+            android:layout_width="0dp"
+            android:layout_height="match_parent"
+            android:layout_weight="1"
+            android:text="Map ▾"
+            android:textSize="12sp"
+            style="?android:attr/borderlessButtonStyle" />
+
+        <Button
+            android:id="@+id/orientButton"
+            android:layout_width="wrap_content"
+            android:layout_height="match_parent"
+            android:text="⊙ Orient"
+            android:textSize="12sp"
+            style="?android:attr/borderlessButtonStyle" />
+
+    </LinearLayout>
+
     <net.fuzzyworld.haunt.debug.MapView
         android:id="@+id/mapView"
         android:layout_width="match_parent"
@@ -23,7 +48,7 @@
             android:layout_width="0dp"
             android:layout_height="wrap_content"
             android:layout_weight="1"
-            android:layout_marginEnd="6dp"
+            android:layout_marginEnd="4dp"
             android:text="Add Point" />
 
         <Button
@@ -31,14 +56,23 @@
             android:layout_width="0dp"
             android:layout_height="wrap_content"
             android:layout_weight="1"
+            android:layout_marginEnd="4dp"
             android:text="Add Light" />
 
+        <Button
+            android:id="@+id/hauntButton"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:text="Haunt" />
+
     </LinearLayout>
 
     <androidx.recyclerview.widget.RecyclerView
         android:id="@+id/pointsList"
         android:layout_width="match_parent"
-        android:layout_height="180dp"
+        android:layout_height="0dp"
+        android:layout_weight="1"
         android:clipToPadding="false"
         android:paddingBottom="8dp" />