RoomTag: indoor positioning from Wi-Fi fingerprints
- Role
- Everything: firmware, server, ML, iOS
- Stack
- ESP32-C6 · FastAPI · PyTorch · ONNX · SwiftUI
- Status
- Live at home, worn daily
- Year
- 2026
RoomTag answers one question continuously: which room is this tag in right now? No cameras, no BLE beacons, no GPS. A small ESP32-C6 tag scans nearby Wi-Fi access points every ~2 seconds and posts the RSSI fingerprint to a self-hosted server, which maps it to a room and pushes the result to an iOS app over WebSocket, and to Home Assistant, so lights and focus modes can follow you around.
Architecture
The interesting problem is stability, not accuracy
A classifier that's right 90% of the time but flickers between rooms every few seconds is useless for automations. Most of the design effort went into making the committed room boring and stable while keeping room changes fast:
- Relative RSSI normalization: the strongest AP in each scan becomes 0 dB and everything else is measured against it, which makes fingerprints immune to router power changes and long-term RSSI drift.
- EMA smoothing over probability vectors (α = 0.72), snappy but stable; a genuine room change commits within one or two scans.
- Hysteresis: a new room needs two consecutive high-confidence predictions before it's committed, which eliminates doorway flicker.
- Time-of-day Bayesian prior: the historical room distribution for the current hour is multiplied in, so borderline cases collapse toward where I almost always am at that time.
- Markov transition prior: room-to-room transition frequencies weight the prediction when the model wants to switch rooms.
The EMA step, from the live server code:
def update_ema(device_id: str, new_probs: dict) -> dict:
"""Exponential moving average over per-room probability vectors."""
prev = ema_state.get(device_id, {})
all_rooms = set(new_probs) | set(prev)
smoothed = {
r: EMA_ALPHA * new_probs.get(r, 0.0)
+ (1.0 - EMA_ALPHA) * prev.get(r, 0.0)
for r in all_rooms
}
ema_state[device_id] = smoothed
return smoothedSee it settle
Rather than describe the stability tradeoff, here it is running. Walk the tag between rooms and watch the raw classifier (the ticks) jump around while the smoothed probability (the bars) stays calm and the committed room holds steady. Then turn smoothing and hysteresis off and watch the same signal turn into doorway flicker.
bar = smoothed probability · tick = raw classifier output
Turn both off and watch the committed room flicker every time the noisy classifier picks a neighbor. Turn them back on: smoothing calms the probabilities, hysteresis refuses to commit until a new room wins twice in a row. That is the whole reason the system is usable for automations.
Two models, one interface
The default classifier is a 300-tree random forest with balanced class weights: CPU-only, no GPU required, and it reports out-of-bag accuracy for free. For better performance I train an attention-weighted MLP ensemble on my gaming PC's GPU with heavy augmentation (Gaussian jitter, AP masking, RSSI scaling, Mixup) and test-time augmentation, export it to ONNX, and the server hot-swaps it in with zero downtime.
The system also trains itself: high-confidence prediction streaks accumulate silently and retrain the model in the background, and a "wrong room?" button in the iOS app relabels the last few fingerprints and retrains immediately. Accuracy at home has held at roughly 90%+.
The app
The consumer end is a SwiftUI app: current room up top, per-room confidence below, and a "wrong room?" button that relabels the last few fingerprints and retrains the model on the spot. Results arrive over WebSocket, with polling as the fallback.
RoomTag
tag: wearable-01 · live
You are in
Office
96% confident
Shipping it
Getting it working was half the project; making it deployable was the other half. Firmware releases build in GitHub Actions, which compiles the ESP32-C6 image and attaches OTA assets to the release; the tag updates itself over the air. The iOS app fetches firmware through the backend so the release repo can stay private. Server, training, and firmware live in one repo with an operator runbook.
Status
Live at home, worn daily, wired into Home Assistant. The repo is private while I scrub deployment-specific configuration out of its history. A public release is planned, and I'm happy to walk through the code in the meantime: bretdubois1@gmail.com.