haunt / commit
Add Philips Hue light discovery, pairing, and blink-to-identify flow
.../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"