haunt / commit
Add README, docs/, and fix ranging gaps
.gitignore | 4 + README.md | 56 +++++ app/build.gradle.kts | 1 + .../net/fuzzyworld/haunt/debug/DebugViewModel.kt | 35 ++- docs/wifi-rtt-hue-brief.md | 254 +++++++++++++++++++++ gradle/libs.versions.toml | 1 + 6 files changed, 350 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 10cfdbf..d675d47 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ .externalNativeBuild .cxx local.properties +app/build/ +.claude/ +.kotlin/ +local.properties diff --git a/README.md b/README.md new file mode 100644 index 0000000..1480b8b --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# Haunt + +Android app that uses WiFi Round-Trip Time (RTT) ranging to fingerprint your phone's position and activate the corresponding Philips Hue light — no floor plans, no coordinate systems, no backend. + +## How it works + +Three Google WiFi pucks each return a distance reading in meters. The combination of all three — a *distance-triple* — is unique to a physical location. During calibration you record a triple for each room. At runtime, the app finds the nearest stored triple and switches the light for that room. + +See [docs/wifi-rtt-hue-brief.md](docs/wifi-rtt-hue-brief.md) for the full design brief including accuracy expectations, gotchas, and development order. + +## Hardware required + +- 3× Google WiFi pucks (2016 squat-cylinder model) — support IEEE 802.11mc FTM on 5 GHz +- Philips Hue Bridge v2 (square), on the same LAN +- Android phone: Pixel 2 or newer, Android 9+ + +## Setup + +### 1. Hue credentials + +Press the button on your Hue Bridge, then within 30 seconds: + +```bash +curl -X POST http://<bridge-ip>/api \ + -H "Content-Type: application/json" \ + -d '{"devicetype":"haunt#android"}' +# Save the username from the response + +curl http://<bridge-ip>/api/<username>/lights +# Note the integer ID for each light you want to control +``` + +### 2. Find puck BSSIDs + +Run the app in debug mode — it lists every RTT-capable AP with its BSSID. Walk up to each puck; it will auto-pin to the map when you're within 0.5 m. Rename the pinned points (e.g. "Router kitchen") so you can identify which BSSID belongs to which puck. + +### 3. Open in Android Studio + +Requires Android Studio Ladybug or later. On first open, let Gradle sync finish and generate the wrapper JAR, then run on a physical device (RTT does not work in the emulator). + +## Development status + +| Layer | Status | Description | +|---|---|---| +| 1 | ✅ | Project scaffold, permissions, RTT availability check | +| 2 | ✅ | RTT ranging, debug map, add/rename/delete points | +| 3 | — | CalibrationStore — JSON zone fingerprints | +| 4 | — | ZoneMatcher — nearest-neighbour in RTT space | +| 5 | — | Calibration UI — walk-and-tap flow | +| 6 | — | HueBridgeClient — Retrofit, toggle light by ID | +| 7 | — | Live mode + debounce logic | +| 8 | — | Tuning N (readings averaged) and K (debounce count) | + +## Package + +`net.fuzzyworld.haunt` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6da8c40..da73a31 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -37,6 +37,7 @@ android { dependencies { implementation(libs.androidx.appcompat) + implementation(libs.androidx.activity.ktx) implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.lifecycle.runtime) implementation(libs.androidx.recyclerview) 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 fd79791..3ca000c 100644 --- a/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt +++ b/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt @@ -24,20 +24,53 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) { val isRttAvailable: Boolean get() = rttManager.isAvailable + // Last known distance per BSSID — used to fill gaps when an AP temporarily drops out + private val lastKnownReadings: MutableMap<String, Double> = mutableMapOf() + + // BSSIDs already auto-added as router points (persisted via point IDs tagged with the BSSID) + private val autoAddedBssids: MutableSet<String> = mutableSetOf() + init { _points.value = store.load() + // Restore which BSSIDs were already auto-added so we don't duplicate on restart + _points.value.filter { it.id.startsWith("router:") }.forEach { + autoAddedBssids.add(it.id.removePrefix("router:")) + } startRanging() } private fun startRanging() { viewModelScope.launch { while (true) { - _readings.value = rttManager.rangeAll() + val fresh = rttManager.rangeAll() + fresh.forEach { lastKnownReadings[it.bssid] = it.distanceMeters } + val readings = lastKnownReadings.entries + .map { RttRangingManager.ApReading(it.key, it.value) } + .sortedBy { it.bssid } + _readings.value = readings + autoAddNearbyRouters(fresh) delay(2000) } } } + private fun autoAddNearbyRouters(readings: List<RttRangingManager.ApReading>) { + val nearby = readings.filter { it.distanceMeters <= 0.5 && it.bssid !in autoAddedBssids } + if (nearby.isEmpty()) return + val allReadings = readings.associate { it.bssid to it.distanceMeters } + val newPoints = nearby.map { ap -> + autoAddedBssids.add(ap.bssid) + DebugPoint( + id = "router:${ap.bssid}", + name = ap.bssid, + color = randomColor(), + readings = allReadings, + ) + } + _points.value = _points.value + newPoints + store.save(_points.value) + } + fun addPoint(name: String) { val current = _readings.value if (current.isEmpty()) return diff --git a/docs/wifi-rtt-hue-brief.md b/docs/wifi-rtt-hue-brief.md new file mode 100644 index 0000000..597ed5d --- /dev/null +++ b/docs/wifi-rtt-hue-brief.md @@ -0,0 +1,254 @@ +# WiFi RTT → Hue Light Activation — Project Brief + +## Goal +An Android app (Kotlin) that uses WiFi Round-Trip Time (RTT) ranging against 3 Google WiFi pucks to fingerprint the phone's position by comparing live distance-triples to calibrated zone readings, then activates the corresponding Philips Hue light via the local Hue Bridge API. + +No floor plans, no measuring tape, no coordinate systems required. + +--- + +## Core Concept: Distance-Triple Fingerprinting + +Each Google WiFi puck gives a distance reading in meters. Three pucks produce a **distance-triple**: +``` +reading = (d1, d2, d3) // meters to puck A, B, C respectively +``` + +This triple is unique to a physical location — two spots in different rooms will have meaningfully different triples even without knowing where the pucks are. During calibration, you walk to each zone and record its triple. At runtime, you compare the live triple to all stored ones and pick the closest match. + +**Match score** (lower = better): +``` +diff = sqrt((d1_live - d1_cal)² + (d2_live - d2_cal)² + (d3_live - d3_cal)²) +``` + +This is the same geometric insight used to reconstruct positions from relative distances alone — no absolute reference frame needed. + +--- + +## Architecture + +``` +Android App (Kotlin) + ├── RttRangingManager — ranges against 3 Google WiFi APs, returns distance-triple + ├── CalibrationStore — saves/loads named zone fingerprints (JSON on device) + ├── ZoneMatcher — nearest-neighbor match in distance-triple space + ├── HueBridgeClient — calls local Hue Bridge REST API to toggle light + └── MainActivity — orchestrates calibration mode and live mode +``` + +No trilateration. No coordinate system. No backend server. + +--- + +## Prerequisites + +### Hardware +- 3x Google WiFi pucks (original squat-cylinder model, 2016+) + - Natively support IEEE 802.11mc FTM RTT on the 5 GHz band +- Philips Hue Bridge (v2 square, connected to same LAN) +- Android phone: Pixel 2 or newer, Android 9+ (Android 10+ recommended) + +### Accounts / Tokens +- **Hue Bridge IP**: discoverable via `https://discovery.meethue.com` or your router's DHCP table +- **Hue API username**: created by pressing the Bridge button and hitting `POST /api` (see setup steps below) + +### Dev Environment +- Android Studio (Ladybug or later) +- Kotlin (bundled with Android Studio) +- minSdk 28 (Android 9 / Pie) — required for WifiRttManager API +- targetSdk 35 + +--- + +## Setup Steps + +### 1. Find Puck BSSIDs +You need the MAC address (BSSID) of each puck's 5 GHz radio so the app knows which RTT responses to use. + +Options: +- Google Home app → device details (may show MAC) +- `adb shell dumpsys wifi` while connected — look for your network's BSSIDs +- The app can scan and list all RTT-capable APs on first launch for you to identify and hardcode + +Record them: +```kotlin +val knownBssids = listOf( + "AA:BB:CC:DD:EE:01", // Puck 1 (label with tape so you know which is which) + "AA:BB:CC:DD:EE:02", // Puck 2 + "AA:BB:CC:DD:EE:03", // Puck 3 +) +``` +Order must stay consistent — d1 always means puck 1, etc. + +### 2. Get Hue API Username +```bash +# 1. Press the physical button on your Hue Bridge +# 2. Within 30 seconds, run: +curl -X POST http://<bridge-ip>/api \ + -H "Content-Type: application/json" \ + -d '{"devicetype":"wifi_rtt_hue#android"}' +# Response contains your username token — save it +``` + +### 3. Find Hue Light IDs +```bash +curl http://<bridge-ip>/api/<username>/lights +# Returns JSON listing all lights and their integer IDs +``` + +### 4. Calibrate Zones (in-app, no measuring) +1. Open the app, enter **Calibration Mode** +2. Stand in a zone (e.g. living room), tap **"Add Zone"** +3. Name it (e.g. "Living Room"), enter the Hue light ID for that room +4. App takes N RTT readings, averages them, stores the distance-triple +5. Repeat for each zone +6. Done — no rulers involved + +--- + +## Android Project Structure + +``` +app/ +└── src/main/ + ├── AndroidManifest.xml + └── java/com/example/wifirtthue/ + ├── MainActivity.kt — tab/mode switcher: Calibrate vs Live + ├── model/ + │ ├── DistanceTriple.kt — data class: d1, d2, d3 (Doubles, meters) + │ └── ZoneFingerprint.kt — data class: name, hueLightId, triple + ├── ranging/ + │ └── RttRangingManager.kt — wraps WifiRttManager, returns DistanceTriple + ├── calibration/ + │ ├── CalibrationStore.kt — persist/load List<ZoneFingerprint> as JSON + │ └── CalibrationViewModel.kt — drives calibration UI, averages N readings + ├── matching/ + │ └── ZoneMatcher.kt — finds nearest ZoneFingerprint by diff score + └── hue/ + └── HueBridgeClient.kt — Retrofit calls to Hue Bridge +``` + +--- + +## Key Android Permissions (AndroidManifest.xml) + +```xml +<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> +<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> +<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" /> +<uses-permission android:name="android.permission.INTERNET" /> +<uses-feature android:name="android.hardware.wifi.rtt" android:required="true" /> +``` + +--- + +## Key Dependencies (libs.versions.toml) + +```toml +[versions] +kotlin = "2.0.0" +coroutines = "1.8.1" +retrofit = "2.11.0" +okhttp = "4.12.0" + +[libraries] +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +gson = { module = "com.google.code.gson:gson", version = "2.11.0" } +``` + +--- + +## Core Logic Sketches + +### DistanceTriple.kt +```kotlin +data class DistanceTriple(val d1: Double, val d2: Double, val d3: Double) { + fun distanceTo(other: DistanceTriple): Double = sqrt( + (d1 - other.d1).pow(2) + (d2 - other.d2).pow(2) + (d3 - other.d3).pow(2) + ) + + companion object { + fun average(readings: List<DistanceTriple>) = DistanceTriple( + readings.map { it.d1 }.average(), + readings.map { it.d2 }.average(), + readings.map { it.d3 }.average(), + ) + } +} +``` + +### ZoneMatcher.kt +```kotlin +fun findNearestZone(live: DistanceTriple, zones: List<ZoneFingerprint>): ZoneFingerprint? = + zones.minByOrNull { it.triple.distanceTo(live) } +``` + +### Hue toggle (HueBridgeClient.kt) +``` +PUT http://<bridge-ip>/api/<username>/lights/<id>/state +Body: {"on": true, "bri": 200} +``` + +--- + +## App Flow + +### Calibration Mode +``` +For each zone: + 1. User stands in zone, taps "Add Zone" + 2. App fires N=5 RTT ranging passes (coroutine loop) + 3. Averages the N DistanceTriples → stored as zone fingerprint + 4. User names zone + enters Hue light ID + 5. Saved to CalibrationStore (JSON on device) +``` + +### Live Mode +``` +Loop every 2 seconds: + 1. Fire RTT ranging → live DistanceTriple + 2. ZoneMatcher.findNearestZone() + 3. If same zone detected K=3 consecutive times (debounce): + → HueBridgeClient.turnOn(zone.hueLightId) + → optionally turn off previous zone's light +``` + +--- + +## Accuracy Expectations + +| Condition | Expected zone accuracy | +|---|---| +| Same floor, rooms 3m+ apart | Reliable room-level detection | +| Open plan with subtle zones | May need more calibration points per zone | +| Different floor from pucks | Unreliable — treat as a separate calibration set | + +More calibration points per zone (taken from slightly different spots within the zone) improve robustness significantly. + +--- + +## Development Order + +1. **Scaffold** project, add permissions, confirm `WifiRttManager.isAvailable()` returns true on device +2. **RttRangingManager** — scan for RTT-capable APs, log BSSIDs, return raw DistanceTriple +3. **CalibrationStore** — save/load fingerprints as JSON; unit test serialization +4. **ZoneMatcher** — unit test with synthetic triples before wiring to real data +5. **CalibrationViewModel + UI** — walk-and-tap calibration flow +6. **HueBridgeClient** — test light toggle independently with a hardcoded light ID +7. **Wire live mode** in MainActivity with debounce logic +8. **Tune** — adjust N (readings averaged) and K (debounce count) for your home + +--- + +## Notes & Gotchas + +- **5 GHz only**: Google WiFi pucks only support FTM RTT on the 5 GHz radio. Confirm all 3 pucks appear in RTT scan results before calibrating. +- **Location permission is mandatory** at runtime — RTT ranging returns nothing without it, with no error thrown. +- **BSSID order must be stable** — always map puck 1 → d1, puck 2 → d2, puck 3 → d3. If a puck is temporarily unreachable, the triple is invalid; skip that reading. +- **BSSID randomization** — Android randomizes MACs for passive scans. RTT uses the real BSSID of the AP being ranged against, but confirm this works on your specific device/Android version early. +- **Hue Bridge must be on the same LAN** — won't work over mobile data. +- **Recalibrate if pucks move** — fingerprints are relative to puck positions; moving a puck invalidates all stored zones. +- **Multiple calibration points per zone** — instead of one averaged triple per zone, consider storing 3–5 samples from different spots within the zone and matching against all of them (take the best match across all samples). This handles within-room variance better. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a9eb983..0734c32 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ recyclerview = "1.3.2" androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version = "1.9.3" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }