fuzzyworld

haunt / commit

Add Philips Hue light discovery, pairing, and blink-to-identify flow

d6ee68c1d1c2ef7276cecdb3d9b20815cb1314b2 · Pavel Safronov · 2026-05-12 22:49:50 -0700 · parents 246276e6cc13

Add Philips Hue light discovery, pairing, and blink-to-identify flow

Bridge found via mDNS (_hue._tcp.) with manual IP fallback; pairing
stores username in SharedPrefs. Light list dialog lets user blink each
light to identify it, then pin it to the map at current position.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 .../main/java/net/fuzzyworld/haunt/MainActivity.kt | 212 ++++++++++++++++++++-
 .../net/fuzzyworld/haunt/debug/DebugViewModel.kt   |  44 ++++-
 .../java/net/fuzzyworld/haunt/debug/MapView.kt     |  21 +-
 .../net/fuzzyworld/haunt/debug/PointsAdapter.kt    |  56 +++++-
 .../main/java/net/fuzzyworld/haunt/hue/HueApi.kt   |  52 +++++
 .../net/fuzzyworld/haunt/hue/HueBridgeClient.kt    |  30 +++
 .../net/fuzzyworld/haunt/hue/HueBridgeConfig.kt    |  18 ++
 .../net/fuzzyworld/haunt/hue/HueBridgeDiscovery.kt |  46 +++++
 .../java/net/fuzzyworld/haunt/hue/HueLightPoint.kt |   9 +
 .../java/net/fuzzyworld/haunt/hue/HueLightStore.kt |  26 +++
 app/src/main/res/layout/activity_main.xml          |  26 ++-
 11 files changed, 515 insertions(+), 25 deletions(-)

diff --git a/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt b/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt
index 404eec9..f9209e4 100644
--- a/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt
+++ b/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt
@@ -2,9 +2,17 @@ package net.fuzzyworld.haunt
 
 import android.Manifest
 import android.content.pm.PackageManager
+import android.graphics.Color
 import android.os.Build
 import android.os.Bundle
+import android.view.Gravity
+import android.widget.Button
 import android.widget.EditText
+import android.widget.LinearLayout
+import android.widget.ProgressBar
+import android.widget.ScrollView
+import android.widget.TextView
+import android.widget.Toast
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.activity.viewModels
 import androidx.appcompat.app.AlertDialog
@@ -17,6 +25,8 @@ import kotlinx.coroutines.launch
 import net.fuzzyworld.haunt.databinding.ActivityMainBinding
 import net.fuzzyworld.haunt.debug.DebugViewModel
 import net.fuzzyworld.haunt.debug.PointsAdapter
+import net.fuzzyworld.haunt.hue.HueBridgeClient
+import net.fuzzyworld.haunt.hue.HueBridgeDiscovery
 
 class MainActivity : AppCompatActivity() {
 
@@ -28,14 +38,16 @@ class MainActivity : AppCompatActivity() {
         onDelete = { id -> vm.deletePoint(id) },
         onRecolor = { id, color -> vm.recolorPoint(id, color) },
         onRecolorYou = { color -> vm.setYouColor(color) },
+        onRenameLight = { id, name -> vm.renameLightPoint(id, name) },
+        onDeleteLight = { id -> vm.deleteLightPoint(id) },
+        onRecolorLight = { id, color -> vm.recolorLightPoint(id, color) },
     )
 
     private val requestPermissions = registerForActivityResult(
         ActivityResultContracts.RequestMultiplePermissions()
     ) { grants ->
-        if (grants.values.any { !it }) {
+        if (grants.values.any { !it })
             binding.readingsText.text = "Location/nearby-wifi permission required for RTT ranging"
-        }
     }
 
     override fun onCreate(savedInstanceState: Bundle?) {
@@ -47,6 +59,7 @@ class MainActivity : AppCompatActivity() {
         binding.pointsList.adapter = adapter
 
         binding.addPointButton.setOnClickListener { showAddPointDialog() }
+        binding.addLightButton.setOnClickListener { showAddLightFlow() }
 
         val needed = buildList {
             if (ContextCompat.checkSelfPermission(this@MainActivity, Manifest.permission.ACCESS_FINE_LOCATION)
@@ -79,11 +92,16 @@ class MainActivity : AppCompatActivity() {
         }
 
         lifecycleScope.launch {
-            vm.history.collectLatest { history ->
-                binding.mapView.history = history
+            vm.lightPoints.collectLatest { lights ->
+                adapter.submitLightList(lights)
+                binding.mapView.lightPoints = lights
             }
         }
 
+        lifecycleScope.launch {
+            vm.history.collectLatest { binding.mapView.history = it }
+        }
+
         lifecycleScope.launch {
             vm.youColor.collectLatest { color ->
                 binding.mapView.youColor = color
@@ -100,22 +118,200 @@ class MainActivity : AppCompatActivity() {
         }
     }
 
+    // --- Add Point dialog ---
+
     private fun showAddPointDialog() {
         val input = EditText(this).apply {
             hint = "Point name"
             setPadding(48, 24, 48, 8)
-            val count = vm.points.value.size + 1
-            setText("Point $count")
+            setText("Point ${vm.points.value.size + 1}")
             selectAll()
         }
         AlertDialog.Builder(this)
             .setTitle("Name this location")
             .setView(input)
             .setPositiveButton("Add") { _, _ ->
-                val name = input.text.toString().trim().ifEmpty { "Point" }
-                vm.addPoint(name)
+                vm.addPoint(input.text.toString().trim().ifEmpty { "Point" })
             }
             .setNegativeButton("Cancel", null)
             .show()
     }
+
+    // --- Add Light flow ---
+
+    private fun showAddLightFlow() {
+        if (!vm.hueConfig.isConfigured) {
+            showPairingDialog(onPaired = { showLightListDialog() })
+        } else {
+            showLightListDialog()
+        }
+    }
+
+    private fun showPairingDialog(onPaired: () -> Unit) {
+        val dp = resources.displayMetrics.density
+        val pad = (16 * dp).toInt()
+
+        val root = LinearLayout(this).apply {
+            orientation = LinearLayout.VERTICAL
+            setPadding(pad, pad, pad, pad)
+        }
+        val statusText = TextView(this).apply { text = "Searching for Hue Bridge…" }
+        val progress = ProgressBar(this)
+        val ipEdit = EditText(this).apply {
+            hint = "Bridge IP (e.g. 192.168.1.x)"
+            visibility = android.view.View.GONE
+        }
+        val hint = TextView(this).apply {
+            text = "Press the button on your Hue Bridge, then tap Pair."
+            setPadding(0, (8 * dp).toInt(), 0, 0)
+            visibility = android.view.View.GONE
+        }
+
+        root.addView(statusText)
+        root.addView(progress)
+        root.addView(ipEdit)
+        root.addView(hint)
+
+        val dialog = AlertDialog.Builder(this)
+            .setTitle("Connect to Hue Bridge")
+            .setView(root)
+            .setNegativeButton("Cancel", null)
+            .setPositiveButton("Pair", null) // set below to avoid auto-dismiss
+            .create()
+
+        dialog.show()
+
+        val pairButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
+        pairButton.isEnabled = false
+
+        // Auto-discover bridge IP
+        lifecycleScope.launch {
+            val found = HueBridgeDiscovery(this@MainActivity).discoverBridgeIp()
+            progress.visibility = android.view.View.GONE
+            if (found != null) {
+                vm.hueConfig.bridgeIp = found
+                statusText.text = "Found bridge at $found"
+                hint.visibility = android.view.View.VISIBLE
+                pairButton.isEnabled = true
+            } else {
+                statusText.text = "Bridge not found automatically."
+                ipEdit.visibility = android.view.View.VISIBLE
+                hint.visibility = android.view.View.VISIBLE
+                pairButton.isEnabled = true
+            }
+        }
+
+        pairButton.setOnClickListener {
+            val ip = ipEdit.text.toString().trim().ifEmpty { vm.hueConfig.bridgeIp }
+            if (ip.isNullOrBlank()) {
+                Toast.makeText(this, "Enter bridge IP", Toast.LENGTH_SHORT).show()
+                return@setOnClickListener
+            }
+            vm.hueConfig.bridgeIp = ip
+            statusText.text = "Pairing…"
+            pairButton.isEnabled = false
+
+            lifecycleScope.launch {
+                try {
+                    val username = HueBridgeClient(ip).pair()
+                    if (username != null) {
+                        vm.hueConfig.username = username
+                        dialog.dismiss()
+                        Toast.makeText(this@MainActivity, "Paired!", Toast.LENGTH_SHORT).show()
+                        onPaired()
+                    } else {
+                        statusText.text = "Press the bridge button first, then tap Pair."
+                        pairButton.isEnabled = true
+                    }
+                } catch (e: Exception) {
+                    statusText.text = "Error: ${e.message}"
+                    pairButton.isEnabled = true
+                }
+            }
+        }
+    }
+
+    private fun showLightListDialog() {
+        val dp = resources.displayMetrics.density
+        val pad = (16 * dp).toInt()
+
+        val scroll = ScrollView(this)
+        val container = LinearLayout(this).apply {
+            orientation = LinearLayout.VERTICAL
+            setPadding(pad, pad, pad, pad)
+        }
+        scroll.addView(container)
+
+        val loadingText = TextView(this).apply { text = "Loading lights…" }
+        container.addView(loadingText)
+
+        val dialog = AlertDialog.Builder(this)
+            .setTitle("Add a Light")
+            .setView(scroll)
+            .setNegativeButton("Done", null)
+            .create()
+        dialog.show()
+
+        lifecycleScope.launch {
+            try {
+                val ip = vm.hueConfig.bridgeIp ?: return@launch
+                val username = vm.hueConfig.username ?: return@launch
+                val client = HueBridgeClient(ip)
+                val lights = client.getLights(username)
+
+                container.removeAllViews()
+
+                if (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()
+                                }
+                            }
+                        }
+                    }
+
+                    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()
+                        }
+                    }
+
+                    row.addView(nameText)
+                    row.addView(blinkBtn)
+                    row.addView(addBtn)
+                    container.addView(row)
+                }
+            } catch (e: Exception) {
+                container.removeAllViews()
+                container.addView(TextView(this@MainActivity).apply {
+                    text = "Failed to load lights: ${e.message}"
+                })
+            }
+        }
+    }
 }
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 6d3d842..09df9ff 100644
--- a/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt
+++ b/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt
@@ -14,6 +14,9 @@ import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
+import net.fuzzyworld.haunt.hue.HueBridgeConfig
+import net.fuzzyworld.haunt.hue.HueLightPoint
+import net.fuzzyworld.haunt.hue.HueLightStore
 import net.fuzzyworld.haunt.ranging.RttRangingManager
 import java.util.UUID
 
@@ -22,8 +25,11 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) {
     private val rttManager = RttRangingManager(application)
     private val store = DebugPointStore(application)
     private val historyStore = HistoryStore(application)
+    private val lightStore = HueLightStore(application)
     private val prefs = application.getSharedPreferences("haunt_prefs", Context.MODE_PRIVATE)
 
+    val hueConfig = HueBridgeConfig(application)
+
     private val _readings = MutableStateFlow<List<RttRangingManager.ApReading>>(emptyList())
     val readings: StateFlow<List<RttRangingManager.ApReading>> = _readings
 
@@ -33,14 +39,18 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) {
     private val _history = MutableStateFlow<List<HistoryPoint>>(emptyList())
     val history: StateFlow<List<HistoryPoint>> = _history
 
+    private val _lightPoints = MutableStateFlow<List<HueLightPoint>>(emptyList())
+    val lightPoints: StateFlow<List<HueLightPoint>> = _lightPoints
+
     private val defaultYouColor = Color.HSVToColor(floatArrayOf(210f, 0.75f, 0.90f))
     private val _youColor = MutableStateFlow(prefs.getInt("you_color", defaultYouColor))
     val youColor: StateFlow<Int> = _youColor
 
     val isRttAvailable: Boolean get() = rttManager.isAvailable
 
-    val axisBssids: StateFlow<List<String>> = combine(_readings, _points) { r, p ->
-        (r.map { it.bssid } + p.flatMap { it.readings.keys }).distinct().sorted()
+    val axisBssids: StateFlow<List<String>> = combine(_readings, _points, _lightPoints) { r, p, l ->
+        (r.map { it.bssid } + p.flatMap { it.readings.keys } + l.flatMap { it.readings.keys })
+            .distinct().sorted()
     }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
 
     private val lastKnownReadings: MutableMap<String, Double> = mutableMapOf()
@@ -52,6 +62,7 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) {
         _points.value.filter { it.id.startsWith("router:") }.forEach {
             autoAddedBssids.add(it.id.removePrefix("router:"))
         }
+        _lightPoints.value = lightStore.load()
         viewModelScope.launch(Dispatchers.IO) {
             val loaded = historyStore.load()
             _history.value = loaded
@@ -144,6 +155,35 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) {
         store.save(_points.value)
     }
 
+    fun addLightPoint(hueLightId: String, name: String) {
+        val current = _readings.value
+        if (current.isEmpty()) return
+        val point = HueLightPoint(
+            id = UUID.randomUUID().toString(),
+            hueLightId = hueLightId,
+            name = name,
+            color = randomColor(),
+            readings = current.associate { it.bssid to it.distanceMeters },
+        )
+        _lightPoints.value = _lightPoints.value + point
+        lightStore.save(_lightPoints.value)
+    }
+
+    fun renameLightPoint(id: String, newName: String) {
+        _lightPoints.value = _lightPoints.value.map { if (it.id == id) it.copy(name = newName) else it }
+        lightStore.save(_lightPoints.value)
+    }
+
+    fun deleteLightPoint(id: String) {
+        _lightPoints.value = _lightPoints.value.filter { it.id != id }
+        lightStore.save(_lightPoints.value)
+    }
+
+    fun recolorLightPoint(id: String, color: Int) {
+        _lightPoints.value = _lightPoints.value.map { if (it.id == id) it.copy(color = color) else it }
+        lightStore.save(_lightPoints.value)
+    }
+
     private var nextHue = (0..360).random().toFloat()
     private fun randomColor(): Int {
         nextHue = (nextHue + 137.5f) % 360f
diff --git a/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt b/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt
index 5ba1233..442aaad 100644
--- a/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt
+++ b/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt
@@ -8,6 +8,7 @@ import android.graphics.Paint
 import android.graphics.Typeface
 import android.util.AttributeSet
 import android.view.View
+import net.fuzzyworld.haunt.hue.HueLightPoint
 import kotlin.math.roundToInt
 
 class MapView @JvmOverloads constructor(
@@ -24,6 +25,9 @@ class MapView @JvmOverloads constructor(
     var history: List<HistoryPoint> = emptyList()
         set(value) { field = value; invalidate() }
 
+    var lightPoints: List<HueLightPoint> = emptyList()
+        set(value) { field = value; invalidate() }
+
     var youColor: Int = Color.HSVToColor(floatArrayOf(210f, 0.75f, 0.90f))
         set(value) { field = value; invalidate() }
 
@@ -65,9 +69,11 @@ class MapView @JvmOverloads constructor(
 
         val currentCoords = if (currentReadings.isNotEmpty()) coords(currentReadings) else null
         val savedCoords = savedPoints.mapNotNull { p -> coords(p.readings)?.let { p to it } }
+        val lightCoords = lightPoints.mapNotNull { p -> coords(p.readings)?.let { p to it } }
         val historyCoords = history.mapNotNull { coords(it.readings) }
 
-        val allCoords = savedCoords.map { it.second } + historyCoords + listOfNotNull(currentCoords)
+        val allCoords = savedCoords.map { it.second } + lightCoords.map { it.second } +
+            historyCoords + listOfNotNull(currentCoords)
 
         if (allCoords.isEmpty()) {
             labelPaint.color = Color.parseColor("#AAAAAA")
@@ -109,6 +115,19 @@ class MapView @JvmOverloads constructor(
             canvas.drawCircle(sx(x), sy(y), 7f, fillPaint)
         }
 
+        // Light points (same style as saved points for now — icons come next commit)
+        lightCoords.forEach { (point, xy) ->
+            val cx = sx(xy.first)
+            val cy = sy(xy.second)
+            fillPaint.color = point.color
+            canvas.drawCircle(cx, cy, 26f, fillPaint)
+            strokePaint.color = Color.WHITE
+            strokePaint.strokeWidth = 3f
+            canvas.drawCircle(cx, cy, 26f, strokePaint)
+            labelPaint.textAlign = Paint.Align.LEFT
+            canvas.drawText(point.name, cx + 34f, cy + 11f, labelPaint)
+        }
+
         // Saved points
         labelPaint.color = Color.BLACK
         savedCoords.forEach { (point, xy) ->
diff --git a/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt b/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt
index 7e4ec10..3559413 100644
--- a/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt
+++ b/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt
@@ -10,6 +10,7 @@ import android.widget.GridLayout
 import androidx.appcompat.app.AlertDialog
 import androidx.recyclerview.widget.RecyclerView
 import net.fuzzyworld.haunt.databinding.ItemDebugPointBinding
+import net.fuzzyworld.haunt.hue.HueLightPoint
 
 private val PRESET_COLORS = listOf(
     Color.HSVToColor(floatArrayOf(0f,   0.75f, 0.90f)),
@@ -66,35 +67,48 @@ class PointsAdapter(
     private val onDelete: (id: String) -> Unit,
     private val onRecolor: (id: String, color: Int) -> Unit,
     private val onRecolorYou: (color: Int) -> Unit,
+    private val onRenameLight: (id: String, newName: String) -> Unit,
+    private val onDeleteLight: (id: String) -> Unit,
+    private val onRecolorLight: (id: String, color: Int) -> Unit,
 ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 
     companion object {
         private const val TYPE_YOU = 0
-        private const val TYPE_POINT = 1
+        private const val TYPE_LIGHT = 1
+        private const val TYPE_POINT = 2
     }
 
     private var points: List<DebugPoint> = emptyList()
+    private var lightPoints: List<HueLightPoint> = emptyList()
     var axisBssids: List<String> = emptyList()
     var youColor: Int = Color.GRAY
     var youReadings: Map<String, Double> = emptyMap()
 
-    fun submitList(list: List<DebugPoint>) {
-        points = list
-        notifyDataSetChanged()
-    }
+    fun submitList(list: List<DebugPoint>) { points = list; notifyDataSetChanged() }
+    fun submitLightList(list: List<HueLightPoint>) { lightPoints = list; notifyDataSetChanged() }
+
+    override fun getItemCount() = 1 + lightPoints.size + points.size
 
-    override fun getItemCount() = points.size + 1
-    override fun getItemViewType(position: Int) = if (position == 0) TYPE_YOU else TYPE_POINT
+    override fun getItemViewType(position: Int) = when {
+        position == 0 -> TYPE_YOU
+        position <= lightPoints.size -> TYPE_LIGHT
+        else -> TYPE_POINT
+    }
 
     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
         val binding = ItemDebugPointBinding.inflate(LayoutInflater.from(parent.context), parent, false)
-        return if (viewType == TYPE_YOU) YouViewHolder(binding) else PointViewHolder(binding)
+        return when (viewType) {
+            TYPE_YOU -> YouViewHolder(binding)
+            TYPE_LIGHT -> LightViewHolder(binding)
+            else -> PointViewHolder(binding)
+        }
     }
 
     override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
         when (holder) {
             is YouViewHolder -> holder.bind()
-            is PointViewHolder -> holder.bind(points[position - 1])
+            is LightViewHolder -> holder.bind(lightPoints[position - 1])
+            is PointViewHolder -> holder.bind(points[position - 1 - lightPoints.size])
         }
     }
 
@@ -118,6 +132,30 @@ class PointsAdapter(
         }
     }
 
+    inner class LightViewHolder(private val b: ItemDebugPointBinding) : RecyclerView.ViewHolder(b.root) {
+        fun bind(point: HueLightPoint) {
+            b.colorSwatch.setBackgroundColor(point.color)
+            b.colorSwatch.setOnClickListener {
+                showColorPicker(b.root.context, point.color) { color -> onRecolorLight(point.id, color) }
+            }
+            b.nameEdit.isEnabled = true
+            b.deleteButton.visibility = View.VISIBLE
+            b.nameEdit.setText(point.name)
+            b.nameEdit.setOnFocusChangeListener { _, hasFocus ->
+                if (!hasFocus) onRenameLight(point.id, b.nameEdit.text.toString().trim().ifEmpty { point.name })
+            }
+            b.deleteButton.setOnClickListener { onDeleteLight(point.id) }
+
+            val x = axisBssids.getOrNull(0)?.let { point.readings[it] }
+            val y = axisBssids.getOrNull(1)?.let { point.readings[it] }
+            b.xyText.text = when {
+                x != null && y != null -> "x: ${"%.2f".format(x)} m   y: ${"%.2f".format(y)} m"
+                x != null -> "x: ${"%.2f".format(x)} m"
+                else -> ""
+            }
+        }
+    }
+
     inner class PointViewHolder(private val b: ItemDebugPointBinding) : RecyclerView.ViewHolder(b.root) {
         fun bind(point: DebugPoint) {
             b.colorSwatch.setBackgroundColor(point.color)
diff --git a/app/src/main/java/net/fuzzyworld/haunt/hue/HueApi.kt b/app/src/main/java/net/fuzzyworld/haunt/hue/HueApi.kt
new file mode 100644
index 0000000..1a2cd5c
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/haunt/hue/HueApi.kt
@@ -0,0 +1,52 @@
+package net.fuzzyworld.haunt.hue
+
+import com.google.gson.JsonElement
+import retrofit2.http.Body
+import retrofit2.http.GET
+import retrofit2.http.POST
+import retrofit2.http.PUT
+import retrofit2.http.Path
+
+// --- request bodies ---
+
+data class PairRequest(val devicetype: String)
+
+data class LightStateRequest(
+    val alert: String? = null,
+    val on: Boolean? = null,
+    val bri: Int? = null,
+)
+
+// --- response models ---
+
+data class HueLightResponse(
+    val name: String,
+    val state: HueLightState,
+)
+
+data class HueLightState(val on: Boolean)
+
+data class PairResultItem(
+    val success: Map<String, String>?,
+    val error: Map<String, Any>?,
+)
+
+// --- Retrofit interface ---
+
+interface HueApi {
+
+    @GET("api/{username}/lights")
+    suspend fun getLights(
+        @Path("username") username: String,
+    ): Map<String, HueLightResponse>
+
+    @PUT("api/{username}/lights/{id}/state")
+    suspend fun setState(
+        @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
new file mode 100644
index 0000000..9279277
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/haunt/hue/HueBridgeClient.kt
@@ -0,0 +1,30 @@
+package net.fuzzyworld.haunt.hue
+
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+
+data class HueLightInfo(val id: String, val name: String, val on: Boolean)
+
+class HueBridgeClient(bridgeIp: String) {
+
+    private val api: HueApi = Retrofit.Builder()
+        .baseUrl("http://$bridgeIp/")
+        .addConverterFactory(GsonConverterFactory.create())
+        .build()
+        .create(HueApi::class.java)
+
+    suspend fun getLights(username: String): List<HueLightInfo> =
+        api.getLights(username)
+            .map { (id, resp) -> HueLightInfo(id, resp.name, resp.state.on) }
+            .sortedBy { it.id.toIntOrNull() ?: Int.MAX_VALUE }
+
+    suspend fun blink(username: String, lightId: String) {
+        api.setState(username, lightId, 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"))
+        return results.firstOrNull()?.success?.get("username")
+    }
+}
diff --git a/app/src/main/java/net/fuzzyworld/haunt/hue/HueBridgeConfig.kt b/app/src/main/java/net/fuzzyworld/haunt/hue/HueBridgeConfig.kt
new file mode 100644
index 0000000..e15c800
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/haunt/hue/HueBridgeConfig.kt
@@ -0,0 +1,18 @@
+package net.fuzzyworld.haunt.hue
+
+import android.content.Context
+
+class HueBridgeConfig(context: Context) {
+
+    private val prefs = context.getSharedPreferences("hue_config", Context.MODE_PRIVATE)
+
+    var bridgeIp: String?
+        get() = prefs.getString("bridge_ip", null)
+        set(v) = prefs.edit().putString("bridge_ip", v).apply()
+
+    var username: String?
+        get() = prefs.getString("username", null)
+        set(v) = prefs.edit().putString("username", v).apply()
+
+    val isConfigured: Boolean get() = bridgeIp != null && username != null
+}
diff --git a/app/src/main/java/net/fuzzyworld/haunt/hue/HueBridgeDiscovery.kt b/app/src/main/java/net/fuzzyworld/haunt/hue/HueBridgeDiscovery.kt
new file mode 100644
index 0000000..fea852c
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/haunt/hue/HueBridgeDiscovery.kt
@@ -0,0 +1,46 @@
+package net.fuzzyworld.haunt.hue
+
+import android.content.Context
+import android.net.nsd.NsdManager
+import android.net.nsd.NsdServiceInfo
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.withTimeoutOrNull
+
+class HueBridgeDiscovery(context: Context) {
+
+    private val nsdManager = context.getSystemService(NsdManager::class.java)
+
+    // Discovers the local Hue Bridge IP via mDNS (_hue._tcp). Times out after 5 seconds.
+    suspend fun discoverBridgeIp(): String? {
+        val result = CompletableDeferred<String?>()
+
+        val discoveryListener = object : NsdManager.DiscoveryListener {
+            override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
+                result.complete(null)
+            }
+            override fun onServiceFound(serviceInfo: NsdServiceInfo) {
+                @Suppress("DEPRECATION")
+                nsdManager.resolveService(serviceInfo, object : NsdManager.ResolveListener {
+                    override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) {
+                        // another service may still succeed
+                    }
+                    override fun onServiceResolved(info: NsdServiceInfo) {
+                        result.complete(info.host?.hostAddress)
+                    }
+                })
+            }
+            override fun onServiceLost(serviceInfo: NsdServiceInfo) {}
+            override fun onDiscoveryStarted(serviceType: String) {}
+            override fun onDiscoveryStopped(serviceType: String) {}
+            override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}
+        }
+
+        nsdManager.discoverServices("_hue._tcp.", NsdManager.PROTOCOL_DNS_SD, discoveryListener)
+
+        val ip = withTimeoutOrNull(5_000) { result.await() }
+
+        try { nsdManager.stopServiceDiscovery(discoveryListener) } catch (_: Exception) {}
+
+        return ip
+    }
+}
diff --git a/app/src/main/java/net/fuzzyworld/haunt/hue/HueLightPoint.kt b/app/src/main/java/net/fuzzyworld/haunt/hue/HueLightPoint.kt
new file mode 100644
index 0000000..afd6ea8
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/haunt/hue/HueLightPoint.kt
@@ -0,0 +1,9 @@
+package net.fuzzyworld.haunt.hue
+
+data class HueLightPoint(
+    val id: String,
+    val hueLightId: String,
+    val name: String,
+    val color: Int,
+    val readings: Map<String, Double>,
+)
diff --git a/app/src/main/java/net/fuzzyworld/haunt/hue/HueLightStore.kt b/app/src/main/java/net/fuzzyworld/haunt/hue/HueLightStore.kt
new file mode 100644
index 0000000..60f3849
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/haunt/hue/HueLightStore.kt
@@ -0,0 +1,26 @@
+package net.fuzzyworld.haunt.hue
+
+import android.content.Context
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+import java.io.File
+
+class HueLightStore(context: Context) {
+
+    private val file = File(context.filesDir, "hue_lights.json")
+    private val gson = Gson()
+
+    fun load(): List<HueLightPoint> {
+        if (!file.exists()) return emptyList()
+        return try {
+            val type = object : TypeToken<List<HueLightPoint>>() {}.type
+            gson.fromJson<List<HueLightPoint>>(file.readText(), type) ?: emptyList()
+        } catch (_: Exception) {
+            emptyList()
+        }
+    }
+
+    fun save(points: List<HueLightPoint>) {
+        file.writeText(gson.toJson(points))
+    }
+}
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 0d4ea85..ecdae6c 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -22,13 +22,29 @@
         android:layout_height="0dp"
         android:layout_weight="1" />
 
-    <Button
-        android:id="@+id/addPointButton"
+    <LinearLayout
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
-        android:layout_marginHorizontal="12dp"
-        android:layout_marginVertical="6dp"
-        android:text="Add Point Here" />
+        android:orientation="horizontal"
+        android:paddingHorizontal="12dp"
+        android:paddingVertical="6dp">
+
+        <Button
+            android:id="@+id/addPointButton"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:layout_marginEnd="6dp"
+            android:text="Add Point" />
+
+        <Button
+            android:id="@+id/addLightButton"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:text="Add Light" />
+
+    </LinearLayout>
 
     <androidx.recyclerview.widget.RecyclerView
         android:id="@+id/pointsList"