haunt / files / docs / wifi-rtt-hue-brief.md
wifi-rtt-hue-brief.md
f4823ace 2026-05-13 Pavel Safronov | 1 | # WiFi RTT → Hue Light Activation — Project Brief |
| 2 | ||
| 3 | ## Goal | |
| 4 | 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. | |
| 5 | ||
| 6 | No floor plans, no measuring tape, no coordinate systems required. | |
| 7 | ||
| 8 | --- | |
| 9 | ||
| 10 | ## Core Concept: Distance-Triple Fingerprinting | |
| 11 | ||
| 12 | Each Google WiFi puck gives a distance reading in meters. Three pucks produce a **distance-triple**: | |
| 13 | ``` | |
| 14 | reading = (d1, d2, d3) // meters to puck A, B, C respectively | |
| 15 | ``` | |
| 16 | ||
| 17 | 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. | |
| 18 | ||
| 19 | **Match score** (lower = better): | |
| 20 | ``` | |
| 21 | diff = sqrt((d1_live - d1_cal)² + (d2_live - d2_cal)² + (d3_live - d3_cal)²) | |
| 22 | ``` | |
| 23 | ||
| 24 | This is the same geometric insight used to reconstruct positions from relative distances alone — no absolute reference frame needed. | |
| 25 | ||
| 26 | --- | |
| 27 | ||
| 28 | ## Architecture | |
| 29 | ||
| 30 | ``` | |
| 31 | Android App (Kotlin) | |
| 32 | ├── RttRangingManager — ranges against 3 Google WiFi APs, returns distance-triple | |
| 33 | ├── CalibrationStore — saves/loads named zone fingerprints (JSON on device) | |
| 34 | ├── ZoneMatcher — nearest-neighbor match in distance-triple space | |
| 35 | ├── HueBridgeClient — calls local Hue Bridge REST API to toggle light | |
| 36 | └── MainActivity — orchestrates calibration mode and live mode | |
| 37 | ``` | |
| 38 | ||
| 39 | No trilateration. No coordinate system. No backend server. | |
| 40 | ||
| 41 | --- | |
| 42 | ||
| 43 | ## Prerequisites | |
| 44 | ||
| 45 | ### Hardware | |
| 46 | - 3x Google WiFi pucks (original squat-cylinder model, 2016+) | |
| 47 | - Natively support IEEE 802.11mc FTM RTT on the 5 GHz band | |
| 48 | - Philips Hue Bridge (v2 square, connected to same LAN) | |
| 49 | - Android phone: Pixel 2 or newer, Android 9+ (Android 10+ recommended) | |
| 50 | ||
| 51 | ### Accounts / Tokens | |
| 52 | - **Hue Bridge IP**: discoverable via `https://discovery.meethue.com` or your router's DHCP table | |
| 53 | - **Hue API username**: created by pressing the Bridge button and hitting `POST /api` (see setup steps below) | |
| 54 | ||
| 55 | ### Dev Environment | |
| 56 | - Android Studio (Ladybug or later) | |
| 57 | - Kotlin (bundled with Android Studio) | |
| 58 | - minSdk 28 (Android 9 / Pie) — required for WifiRttManager API | |
| 59 | - targetSdk 35 | |
| 60 | ||
| 61 | --- | |
| 62 | ||
| 63 | ## Setup Steps | |
| 64 | ||
| 65 | ### 1. Find Puck BSSIDs | |
| 66 | You need the MAC address (BSSID) of each puck's 5 GHz radio so the app knows which RTT responses to use. | |
| 67 | ||
| 68 | Options: | |
| 69 | - Google Home app → device details (may show MAC) | |
| 70 | - `adb shell dumpsys wifi` while connected — look for your network's BSSIDs | |
| 71 | - The app can scan and list all RTT-capable APs on first launch for you to identify and hardcode | |
| 72 | ||
| 73 | Record them: | |
| 74 | ```kotlin | |
| 75 | val knownBssids = listOf( | |
| 76 | "AA:BB:CC:DD:EE:01", // Puck 1 (label with tape so you know which is which) | |
| 77 | "AA:BB:CC:DD:EE:02", // Puck 2 | |
| 78 | "AA:BB:CC:DD:EE:03", // Puck 3 | |
| 79 | ) | |
| 80 | ``` | |
| 81 | Order must stay consistent — d1 always means puck 1, etc. | |
| 82 | ||
| 83 | ### 2. Get Hue API Username | |
| 84 | ```bash | |
| 85 | # 1. Press the physical button on your Hue Bridge | |
| 86 | # 2. Within 30 seconds, run: | |
| 87 | curl -X POST http://<bridge-ip>/api \ | |
| 88 | -H "Content-Type: application/json" \ | |
| 89 | -d '{"devicetype":"wifi_rtt_hue#android"}' | |
| 90 | # Response contains your username token — save it | |
| 91 | ``` | |
| 92 | ||
| 93 | ### 3. Find Hue Light IDs | |
| 94 | ```bash | |
| 95 | curl http://<bridge-ip>/api/<username>/lights | |
| 96 | # Returns JSON listing all lights and their integer IDs | |
| 97 | ``` | |
| 98 | ||
| 99 | ### 4. Calibrate Zones (in-app, no measuring) | |
| 100 | 1. Open the app, enter **Calibration Mode** | |
| 101 | 2. Stand in a zone (e.g. living room), tap **"Add Zone"** | |
| 102 | 3. Name it (e.g. "Living Room"), enter the Hue light ID for that room | |
| 103 | 4. App takes N RTT readings, averages them, stores the distance-triple | |
| 104 | 5. Repeat for each zone | |
| 105 | 6. Done — no rulers involved | |
| 106 | ||
| 107 | --- | |
| 108 | ||
| 109 | ## Android Project Structure | |
| 110 | ||
| 111 | ``` | |
| 112 | app/ | |
| 113 | └── src/main/ | |
| 114 | ├── AndroidManifest.xml | |
| 115 | └── java/com/example/wifirtthue/ | |
| 116 | ├── MainActivity.kt — tab/mode switcher: Calibrate vs Live | |
| 117 | ├── model/ | |
| 118 | │ ├── DistanceTriple.kt — data class: d1, d2, d3 (Doubles, meters) | |
| 119 | │ └── ZoneFingerprint.kt — data class: name, hueLightId, triple | |
| 120 | ├── ranging/ | |
| 121 | │ └── RttRangingManager.kt — wraps WifiRttManager, returns DistanceTriple | |
| 122 | ├── calibration/ | |
| 123 | │ ├── CalibrationStore.kt — persist/load List<ZoneFingerprint> as JSON | |
| 124 | │ └── CalibrationViewModel.kt — drives calibration UI, averages N readings | |
| 125 | ├── matching/ | |
| 126 | │ └── ZoneMatcher.kt — finds nearest ZoneFingerprint by diff score | |
| 127 | └── hue/ | |
| 128 | └── HueBridgeClient.kt — Retrofit calls to Hue Bridge | |
| 129 | ``` | |
| 130 | ||
| 131 | --- | |
| 132 | ||
| 133 | ## Key Android Permissions (AndroidManifest.xml) | |
| 134 | ||
| 135 | ```xml | |
| 136 | <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> | |
| 137 | <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> | |
| 138 | <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" /> | |
| 139 | <uses-permission android:name="android.permission.INTERNET" /> | |
| 140 | <uses-feature android:name="android.hardware.wifi.rtt" android:required="true" /> | |
| 141 | ``` | |
| 142 | ||
| 143 | --- | |
| 144 | ||
| 145 | ## Key Dependencies (libs.versions.toml) | |
| 146 | ||
| 147 | ```toml | |
| 148 | [versions] | |
| 149 | kotlin = "2.0.0" | |
| 150 | coroutines = "1.8.1" | |
| 151 | retrofit = "2.11.0" | |
| 152 | okhttp = "4.12.0" | |
| 153 | ||
| 154 | [libraries] | |
| 155 | kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } | |
| 156 | retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } | |
| 157 | retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } | |
| 158 | okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } | |
| 159 | gson = { module = "com.google.code.gson:gson", version = "2.11.0" } | |
| 160 | ``` | |
| 161 | ||
| 162 | --- | |
| 163 | ||
| 164 | ## Core Logic Sketches | |
| 165 | ||
| 166 | ### DistanceTriple.kt | |
| 167 | ```kotlin | |
| 168 | data class DistanceTriple(val d1: Double, val d2: Double, val d3: Double) { | |
| 169 | fun distanceTo(other: DistanceTriple): Double = sqrt( | |
| 170 | (d1 - other.d1).pow(2) + (d2 - other.d2).pow(2) + (d3 - other.d3).pow(2) | |
| 171 | ) | |
| 172 | ||
| 173 | companion object { | |
| 174 | fun average(readings: List<DistanceTriple>) = DistanceTriple( | |
| 175 | readings.map { it.d1 }.average(), | |
| 176 | readings.map { it.d2 }.average(), | |
| 177 | readings.map { it.d3 }.average(), | |
| 178 | ) | |
| 179 | } | |
| 180 | } | |
| 181 | ``` | |
| 182 | ||
| 183 | ### ZoneMatcher.kt | |
| 184 | ```kotlin | |
| 185 | fun findNearestZone(live: DistanceTriple, zones: List<ZoneFingerprint>): ZoneFingerprint? = | |
| 186 | zones.minByOrNull { it.triple.distanceTo(live) } | |
| 187 | ``` | |
| 188 | ||
| 189 | ### Hue toggle (HueBridgeClient.kt) | |
| 190 | ``` | |
| 191 | PUT http://<bridge-ip>/api/<username>/lights/<id>/state | |
| 192 | Body: {"on": true, "bri": 200} | |
| 193 | ``` | |
| 194 | ||
| 195 | --- | |
| 196 | ||
| 197 | ## App Flow | |
| 198 | ||
| 199 | ### Calibration Mode | |
| 200 | ``` | |
| 201 | For each zone: | |
| 202 | 1. User stands in zone, taps "Add Zone" | |
| 203 | 2. App fires N=5 RTT ranging passes (coroutine loop) | |
| 204 | 3. Averages the N DistanceTriples → stored as zone fingerprint | |
| 205 | 4. User names zone + enters Hue light ID | |
| 206 | 5. Saved to CalibrationStore (JSON on device) | |
| 207 | ``` | |
| 208 | ||
| 209 | ### Live Mode | |
| 210 | ``` | |
| 211 | Loop every 2 seconds: | |
| 212 | 1. Fire RTT ranging → live DistanceTriple | |
| 213 | 2. ZoneMatcher.findNearestZone() | |
| 214 | 3. If same zone detected K=3 consecutive times (debounce): | |
| 215 | → HueBridgeClient.turnOn(zone.hueLightId) | |
| 216 | → optionally turn off previous zone's light | |
| 217 | ``` | |
| 218 | ||
| 219 | --- | |
| 220 | ||
| 221 | ## Accuracy Expectations | |
| 222 | ||
| 223 | | Condition | Expected zone accuracy | | |
| 224 | |---|---| | |
| 225 | | Same floor, rooms 3m+ apart | Reliable room-level detection | | |
| 226 | | Open plan with subtle zones | May need more calibration points per zone | | |
| 227 | | Different floor from pucks | Unreliable — treat as a separate calibration set | | |
| 228 | ||
| 229 | More calibration points per zone (taken from slightly different spots within the zone) improve robustness significantly. | |
| 230 | ||
| 231 | --- | |
| 232 | ||
| 233 | ## Development Order | |
| 234 | ||
| 235 | 1. **Scaffold** project, add permissions, confirm `WifiRttManager.isAvailable()` returns true on device | |
| 236 | 2. **RttRangingManager** — scan for RTT-capable APs, log BSSIDs, return raw DistanceTriple | |
| 237 | 3. **CalibrationStore** — save/load fingerprints as JSON; unit test serialization | |
| 238 | 4. **ZoneMatcher** — unit test with synthetic triples before wiring to real data | |
| 239 | 5. **CalibrationViewModel + UI** — walk-and-tap calibration flow | |
| 240 | 6. **HueBridgeClient** — test light toggle independently with a hardcoded light ID | |
| 241 | 7. **Wire live mode** in MainActivity with debounce logic | |
| 242 | 8. **Tune** — adjust N (readings averaged) and K (debounce count) for your home | |
| 243 | ||
| 244 | --- | |
| 245 | ||
| 246 | ## Notes & Gotchas | |
| 247 | ||
| 248 | - **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. | |
| 249 | - **Location permission is mandatory** at runtime — RTT ranging returns nothing without it, with no error thrown. | |
| 250 | - **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. | |
| 251 | - **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. | |
| 252 | - **Hue Bridge must be on the same LAN** — won't work over mobile data. | |
| 253 | - **Recalibrate if pucks move** — fingerprints are relative to puck positions; moving a puck invalidates all stored zones. | |
| 254 | - **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. |