haunt / commit
Add LAN device discovery via ARP + OUI + mDNS
.../main/java/net/fuzzyworld/haunt/MainActivity.kt | 10 ++ .../net/fuzzyworld/haunt/debug/DebugViewModel.kt | 42 ++++++++ .../java/net/fuzzyworld/haunt/debug/MapView.kt | 23 +++- .../net/fuzzyworld/haunt/debug/PointsAdapter.kt | 45 +++++++- .../net/fuzzyworld/haunt/network/NetworkDevice.kt | 19 ++++ .../net/fuzzyworld/haunt/network/NetworkScanner.kt | 117 +++++++++++++++++++++ app/src/main/res/drawable/ic_laptop.xml | 11 ++ app/src/main/res/drawable/ic_network_device.xml | 11 ++ app/src/main/res/drawable/ic_raspberry.xml | 11 ++ 9 files changed, 281 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt b/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt index 9189b02..b8c80cf 100644 --- a/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt +++ b/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt @@ -42,6 +42,10 @@ class MainActivity : AppCompatActivity() { onRenameLight = { id, name -> vm.renameLightPoint(id, name) }, onDeleteLight = { id -> vm.deleteLightPoint(id) }, onRecolorLight = { id, color -> vm.recolorLightPoint(id, color) }, + onPinDevice = { device -> + vm.pinNetworkDevice(device) + Toast.makeText(this, "Pinned ${device.displayName}", Toast.LENGTH_SHORT).show() + }, ) private val requestPermissions = registerForActivityResult( @@ -103,6 +107,12 @@ class MainActivity : AppCompatActivity() { } } + lifecycleScope.launch { + vm.networkDevices.collectLatest { devices -> + adapter.submitNetworkDevices(devices) + } + } + } // --- 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 09df9ff..e0d06ea 100644 --- a/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt +++ b/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt @@ -17,6 +17,9 @@ 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.network.DeviceType +import net.fuzzyworld.haunt.network.NetworkDevice +import net.fuzzyworld.haunt.network.NetworkScanner import net.fuzzyworld.haunt.ranging.RttRangingManager import java.util.UUID @@ -26,6 +29,7 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) { private val store = DebugPointStore(application) private val historyStore = HistoryStore(application) private val lightStore = HueLightStore(application) + private val networkScanner = NetworkScanner(application) private val prefs = application.getSharedPreferences("haunt_prefs", Context.MODE_PRIVATE) val hueConfig = HueBridgeConfig(application) @@ -42,6 +46,9 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) { private val _lightPoints = MutableStateFlow<List<HueLightPoint>>(emptyList()) val lightPoints: StateFlow<List<HueLightPoint>> = _lightPoints + private val _networkDevices = MutableStateFlow<List<NetworkDevice>>(emptyList()) + val networkDevices: StateFlow<List<NetworkDevice>> = _networkDevices + 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 @@ -68,6 +75,7 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) { _history.value = loaded } startRanging() + startNetworkScanning() } private fun startRanging() { @@ -96,6 +104,40 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) { } } + private fun startNetworkScanning() { + viewModelScope.launch { + while (true) { + try { _networkDevices.value = networkScanner.scan() } catch (_: Exception) {} + delay(30_000) + } + } + } + + fun pinNetworkDevice(device: NetworkDevice) { + val current = _readings.value + if (current.isEmpty()) return + val color = when (device.type) { + DeviceType.RASPBERRY_PI -> Color.HSVToColor(floatArrayOf(120f, 0.65f, 0.70f)) + DeviceType.APPLE -> Color.HSVToColor(floatArrayOf(210f, 0.50f, 0.85f)) + DeviceType.HUE_BRIDGE -> Color.HSVToColor(floatArrayOf(45f, 0.80f, 0.90f)) + DeviceType.UNKNOWN -> randomColor() + } + val typePrefix = when (device.type) { + DeviceType.RASPBERRY_PI -> "pi" + DeviceType.APPLE -> "apple" + DeviceType.HUE_BRIDGE -> "hue" + DeviceType.UNKNOWN -> "device" + } + val point = DebugPoint( + id = "net:$typePrefix:${device.id}", + name = device.displayName, + color = color, + readings = current.associate { it.bssid to it.distanceMeters }, + ) + _points.value = _points.value.filter { it.id != point.id } + point + store.save(_points.value) + } + fun setYouColor(color: Int) { prefs.edit().putInt("you_color", color).apply() _youColor.value = color 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 aae5d36..2559cf2 100644 --- a/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt +++ b/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt @@ -117,6 +117,19 @@ class MapView @JvmOverloads constructor( canvas.drawCircle(cx - radius * 0.18f, cy - radius * 0.28f, radius * 0.18f, fillPaint) } + private fun drawNetworkDeviceIcon(canvas: Canvas, cx: Float, cy: Float, color: Int, radius: Float) { + // Square with rounded corners (looks like a device/chip) + fillPaint.color = color + val r = radius * 0.82f + canvas.drawRoundRect(cx - r, cy - r, cx + r, cy + r, r * 0.3f, r * 0.3f, fillPaint) + strokePaint.color = Color.WHITE + strokePaint.strokeWidth = radius * 0.12f + canvas.drawRoundRect(cx - r, cy - r, cx + r, cy + r, r * 0.3f, r * 0.3f, strokePaint) + // Small center dot + fillPaint.color = Color.argb(120, 255, 255, 255) + canvas.drawCircle(cx, cy, r * 0.28f, fillPaint) + } + private fun drawPointIcon(canvas: Canvas, cx: Float, cy: Float, color: Int, radius: Float) { fillPaint.color = color canvas.drawCircle(cx, cy, radius, fillPaint) @@ -221,15 +234,15 @@ class MapView @JvmOverloads constructor( canvas.drawText(point.name, cx + 34f, cy + 11f, labelPaint) } - // Saved points — routers get AP icon, others get generic pin + // Saved points — routers get AP icon, network devices get small ring, others get generic pin labelPaint.color = Color.BLACK savedCoords.forEach { (point, xy) -> val cx = sx(xy.first) val cy = sy(xy.second) - if (point.id.startsWith("router:")) { - drawApIcon(canvas, cx, cy, point.color, 26f) - } else { - drawPointIcon(canvas, cx, cy, point.color, 26f) + when { + point.id.startsWith("router:") -> drawApIcon(canvas, cx, cy, point.color, 26f) + point.id.startsWith("net:") -> drawNetworkDeviceIcon(canvas, cx, cy, point.color, 26f) + else -> drawPointIcon(canvas, cx, cy, point.color, 26f) } labelPaint.textAlign = Paint.Align.LEFT canvas.drawText(point.name, cx + 34f, cy + 11f, labelPaint) 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 48bd4b7..e42a3c8 100644 --- a/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt +++ b/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt @@ -14,6 +14,8 @@ import androidx.recyclerview.widget.RecyclerView import net.fuzzyworld.haunt.R import net.fuzzyworld.haunt.databinding.ItemDebugPointBinding import net.fuzzyworld.haunt.hue.HueLightPoint +import net.fuzzyworld.haunt.network.DeviceType +import net.fuzzyworld.haunt.network.NetworkDevice import kotlin.math.sqrt private val PRESET_COLORS = listOf( @@ -74,21 +76,25 @@ class PointsAdapter( private val onRenameLight: (id: String, newName: String) -> Unit, private val onDeleteLight: (id: String) -> Unit, private val onRecolorLight: (id: String, color: Int) -> Unit, + private val onPinDevice: (NetworkDevice) -> Unit, ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { companion object { private const val TYPE_YOU = 0 private const val TYPE_LIGHT = 1 private const val TYPE_POINT = 2 + private const val TYPE_DEVICE = 3 } private var points: List<DebugPoint> = emptyList() private var lightPoints: List<HueLightPoint> = emptyList() + private var networkDevices: List<NetworkDevice> = emptyList() var youColor: Int = Color.GRAY var youReadings: Map<String, Double> = emptyMap() fun submitList(list: List<DebugPoint>) { points = list; notifyDataSetChanged() } fun submitLightList(list: List<HueLightPoint>) { lightPoints = list; notifyDataSetChanged() } + fun submitNetworkDevices(list: List<NetworkDevice>) { networkDevices = list; notifyDataSetChanged() } private fun distanceToYou(pointReadings: Map<String, Double>): Double? { val common = youReadings.keys.intersect(pointReadings.keys) @@ -99,12 +105,13 @@ class PointsAdapter( private fun formatDistance(d: Double?) = if (d != null) "${"%.2f".format(d)} m away" else "" - override fun getItemCount() = 1 + lightPoints.size + points.size + override fun getItemCount() = 1 + lightPoints.size + points.size + networkDevices.size override fun getItemViewType(position: Int) = when { position == 0 -> TYPE_YOU position <= lightPoints.size -> TYPE_LIGHT - else -> TYPE_POINT + position <= lightPoints.size + points.size -> TYPE_POINT + else -> TYPE_DEVICE } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { @@ -112,6 +119,7 @@ class PointsAdapter( return when (viewType) { TYPE_YOU -> YouViewHolder(binding) TYPE_LIGHT -> LightViewHolder(binding) + TYPE_DEVICE -> DeviceViewHolder(binding) else -> PointViewHolder(binding) } } @@ -121,6 +129,7 @@ class PointsAdapter( is YouViewHolder -> holder.bind() is LightViewHolder -> holder.bind(lightPoints[position - 1]) is PointViewHolder -> holder.bind(points[position - 1 - lightPoints.size]) + is DeviceViewHolder -> holder.bind(networkDevices[position - 1 - lightPoints.size - points.size]) } } @@ -158,7 +167,13 @@ class PointsAdapter( inner class PointViewHolder(private val b: ItemDebugPointBinding) : RecyclerView.ViewHolder(b.root) { fun bind(point: DebugPoint) { - val iconRes = if (point.id.startsWith("router:")) R.drawable.ic_ap else R.drawable.ic_point + val iconRes = when { + point.id.startsWith("router:") -> R.drawable.ic_ap + point.id.startsWith("net:pi:") -> R.drawable.ic_raspberry + point.id.startsWith("net:apple:") -> R.drawable.ic_laptop + point.id.startsWith("net:") -> R.drawable.ic_network_device + else -> R.drawable.ic_point + } b.colorSwatch.setImageResource(iconRes) ImageViewCompat.setImageTintList(b.colorSwatch, ColorStateList.valueOf(point.color)) b.colorSwatch.setOnClickListener { @@ -174,4 +189,28 @@ class PointsAdapter( b.xyText.text = formatDistance(distanceToYou(point.readings)) } } + + inner class DeviceViewHolder(private val b: ItemDebugPointBinding) : RecyclerView.ViewHolder(b.root) { + fun bind(device: NetworkDevice) { + val iconRes = when (device.type) { + DeviceType.RASPBERRY_PI -> R.drawable.ic_raspberry + DeviceType.APPLE -> R.drawable.ic_laptop + DeviceType.HUE_BRIDGE -> R.drawable.ic_light + DeviceType.UNKNOWN -> R.drawable.ic_network_device + } + val iconColor = Color.parseColor("#999999") + b.colorSwatch.setImageResource(iconRes) + ImageViewCompat.setImageTintList(b.colorSwatch, ColorStateList.valueOf(iconColor)) + b.colorSwatch.setOnClickListener(null) + + b.nameEdit.setText(device.displayName) + b.nameEdit.isEnabled = false + b.xyText.text = device.type.label + + b.deleteButton.setImageResource(R.drawable.ic_point) + ImageViewCompat.setImageTintList(b.deleteButton, ColorStateList.valueOf(Color.parseColor("#4CAF50"))) + b.deleteButton.visibility = View.VISIBLE + b.deleteButton.setOnClickListener { onPinDevice(device) } + } + } } diff --git a/app/src/main/java/net/fuzzyworld/haunt/network/NetworkDevice.kt b/app/src/main/java/net/fuzzyworld/haunt/network/NetworkDevice.kt new file mode 100644 index 0000000..45b97d5 --- /dev/null +++ b/app/src/main/java/net/fuzzyworld/haunt/network/NetworkDevice.kt @@ -0,0 +1,19 @@ +package net.fuzzyworld.haunt.network + +data class NetworkDevice( + val ip: String, + val mac: String?, + val hostname: String?, + val services: Set<String>, + val type: DeviceType, +) { + val displayName: String get() = hostname?.removeSuffix(".local") ?: ip + val id: String get() = mac ?: ip +} + +enum class DeviceType(val label: String) { + HUE_BRIDGE("Hue Bridge"), + RASPBERRY_PI("Raspberry Pi"), + APPLE("Apple device"), + UNKNOWN("Network device"), +} diff --git a/app/src/main/java/net/fuzzyworld/haunt/network/NetworkScanner.kt b/app/src/main/java/net/fuzzyworld/haunt/network/NetworkScanner.kt new file mode 100644 index 0000000..c15955c --- /dev/null +++ b/app/src/main/java/net/fuzzyworld/haunt/network/NetworkScanner.kt @@ -0,0 +1,117 @@ +package net.fuzzyworld.haunt.network + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import java.net.InetAddress + +class NetworkScanner(context: Context) { + + private val nsd = context.getSystemService(Context.NSD_SERVICE) as NsdManager + + companion object { + private val MDNS_TYPES = listOf( + "_ssh._tcp.", + "_hue._tcp.", + "_workstation._tcp.", + "_afpovertcp._tcp.", + ) + + // First 3 MAC octets → device type + private val OUI_TYPES = mapOf( + "b8:27:eb" to DeviceType.RASPBERRY_PI, // Pi 1–3 + "dc:a6:32" to DeviceType.RASPBERRY_PI, // Pi 4 + "e4:5f:01" to DeviceType.RASPBERRY_PI, // Pi 4 alt + "d8:3a:dd" to DeviceType.RASPBERRY_PI, // Pi 5 + "2c:cf:67" to DeviceType.RASPBERRY_PI, // Pi Zero 2W + "00:17:88" to DeviceType.HUE_BRIDGE, + "ec:b5:fa" to DeviceType.HUE_BRIDGE, + ) + + private val APPLE_SERVICES = setOf("_afpovertcp._tcp.", "_workstation._tcp.") + } + + suspend fun scan(): List<NetworkDevice> = coroutineScope { + val arpDeferred = async(Dispatchers.IO) { readArp() } + val mdnsDeferred = async { collectMdns(3_000L) } + + val arpEntries = arpDeferred.await() + val mdnsMap = mdnsDeferred.await() // serviceName → serviceType + + // Reverse DNS for all IPs in parallel + val withHostnames = arpEntries.map { (ip, mac) -> + async(Dispatchers.IO) { + val hostname = try { + InetAddress.getByName(ip).canonicalHostName.takeIf { it != ip } + } catch (_: Exception) { null } + Triple(ip, mac, hostname) + } + }.awaitAll() + + withHostnames.map { (ip, mac, hostname) -> + var type = OUI_TYPES[mac.take(8)] ?: DeviceType.UNKNOWN + + // Refine from hostname patterns + if (type == DeviceType.UNKNOWN) { + val h = hostname?.lowercase() ?: "" + if ("raspberry" in h) type = DeviceType.RASPBERRY_PI + } + + // Match mDNS records by hostname fragment + val baseName = hostname?.removeSuffix(".local") ?: "" + val matched = if (baseName.isNotEmpty()) { + mdnsMap.filter { (name, _) -> + name.contains(baseName, ignoreCase = true) + }.values.toSet() + } else emptySet() + + if (matched.any { it.startsWith("_hue") }) type = DeviceType.HUE_BRIDGE + if (type == DeviceType.UNKNOWN && matched.any { it in APPLE_SERVICES }) { + type = DeviceType.APPLE + } + + NetworkDevice(ip = ip, mac = mac, hostname = hostname, services = matched, type = type) + } + } + + private fun readArp(): List<Pair<String, String>> = + try { + java.io.File("/proc/net/arp").readLines().drop(1).mapNotNull { line -> + val p = line.trim().split(Regex("\\s+")) + // columns: IP, HW type, Flags, HW addr, Mask, Device + if (p.size >= 4 && p[3] != "00:00:00:00:00:00") p[0] to p[3].lowercase() + else null + } + } catch (_: Exception) { emptyList() } + + private suspend fun collectMdns(timeoutMs: Long): Map<String, String> { + val results = mutableMapOf<String, String>() // serviceName → serviceType + val listeners = mutableListOf<NsdManager.DiscoveryListener>() + + for (type in MDNS_TYPES) { + val listener = object : NsdManager.DiscoveryListener { + override fun onStartDiscoveryFailed(t: String, e: Int) {} + override fun onStopDiscoveryFailed(t: String, e: Int) {} + override fun onDiscoveryStarted(t: String) {} + override fun onDiscoveryStopped(t: String) {} + override fun onServiceFound(info: NsdServiceInfo) { + synchronized(results) { results[info.serviceName] = type } + } + override fun onServiceLost(info: NsdServiceInfo) {} + } + try { + nsd.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, listener) + listeners.add(listener) + } catch (_: Exception) {} + } + + delay(timeoutMs) + listeners.forEach { try { nsd.stopServiceDiscovery(it) } catch (_: Exception) {} } + return results + } +} diff --git a/app/src/main/res/drawable/ic_laptop.xml b/app/src/main/res/drawable/ic_laptop.xml new file mode 100644 index 0000000..281c517 --- /dev/null +++ b/app/src/main/res/drawable/ic_laptop.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Laptop icon (Apple / Mac) --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M4,6H20A2,2 0,0 1,22 8V17A2,2 0,0 1,20 19H13V21H16V23H8V21H11V19H4A2,2 0,0 1,2 17V8A2,2 0,0 1,4 6M4,8V17H20V8H4Z" /> +</vector> diff --git a/app/src/main/res/drawable/ic_network_device.xml b/app/src/main/res/drawable/ic_network_device.xml new file mode 100644 index 0000000..ee91340 --- /dev/null +++ b/app/src/main/res/drawable/ic_network_device.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generic network device --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M15,20A1,1 0,0 0,14 19H10A1,1 0,0 0,9 20V21H15V20M17,14H7A1,1 0,0 0,6 15V19A1,1 0,0 0,7 20H17A1,1 0,0 0,18 19V15A1,1 0,0 0,17 14M10,17H8V16H10V17M13,17H11V16H13V17M16,17H14V16H16V17M4,12H20V6H4V12M11,7H13V11H11V7M6,3H18A2,2 0,0 1,20 5V13H4V5A2,2 0,0 1,6 3Z" /> +</vector> diff --git a/app/src/main/res/drawable/ic_raspberry.xml b/app/src/main/res/drawable/ic_raspberry.xml new file mode 100644 index 0000000..ec6cc8d --- /dev/null +++ b/app/src/main/res/drawable/ic_raspberry.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- CPU / chip icon (Raspberry Pi) --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M9,3H7V1H5V3H3V5H1V7H3V9H1V11H3V13H1V15H3V17H5V19H7V21H9V19H11V21H13V19H15V21H17V19H19V17H21V15H19V13H21V11H19V9H21V7H19V5H17V3H15V1H13V3H11V1H9V3M7,5H17V17H7V5M9,7V17H15V7H9M11,9H13V15H11V9Z" /> +</vector>