haunt / commit
Add collapsible map, haunt mode, and light state restoration
.../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" />