There is a genre of research paper that promises your router can already see you. Count people through walls. Read breathing from across the house. Tell standing from sitting from falling, all from the way a body bends the WiFi already passing through it. No camera, no wearable. Just math on a radio you already own.
The papers are not lying. They are leaving out the part where the result was captured in a stripped room, with lab gear, by people who already knew the answer. I wanted what is left when you remove all three. So I built it on cheap hardware, in a real house, and ran it blind.
What CSI is, in one paragraph
Every WiFi packet arrives distorted by the bounces it took off walls, furniture, and bodies. The receiver can report that distortion as Channel State Information: amplitude and phase across the radio subcarriers. A moving body perturbs the bounces. Read CSI off enough links and you can, in principle, recover presence, motion, even the chest movement of breathing. All true. This entire post is about the word "enough."
The chip, and why it is not arbitrary
The node is a Seeed Studio XIAO ESP32-S3 (p-5627).
- The ESP32-S3 radio exposes per-packet CSI through the Espressif SDK. Most WiFi silicon does not. That alone sets the floor on eligible hardware.
- Native USB-C with built-in USB-Serial-JTAG: no second chip to flash through, no flaky serial adapter. 8MB flash and 8MB PSRAM, enough to buffer CSI without dropping frames. Thumbnail-sized, so nine of them vanish into the trim.
- Antenna is the one decision that matters. The board has an onboard ceramic antenna and a u.FL connector. For CSI the antenna is the signal, so every node runs the external 2.4G A-02 over u.FL. A poor antenna injects exactly the variance you will later try to blame on a human.
- Each node lives in an identical printed enclosure, so antenna orientation and wall standoff are constant and placement stays the only variable.
Count: nine. The build guide's capability tiers are presence good at 1 node and excellent at 3, room-level location at 3+, multi-person and pose at 6, respiration at 3, heartbeat weak on ESP32 regardless. Nine is the strong tier on paper.

The build
- Firmware: RuView v0.6.7, prebuilt
s3-adr110bundle. 8MB dual-OTA layout: bootloader at 0x0, partition table at 0x8000, app ota_0 at 0x20000, nvs at 0x9000. - Flash with esptool 5.3.0. Provision separately over NVS (
provision.py, esp-idf-nvs-partition-gen): WiFi SSID, password, collector IP, node ID. Re-runnable, no reflash to re-point a node. - The node ID rides at byte 4 of every packet header, so the receiver tells nine nodes apart without labeling traffic.
- Scripted and bash 3.2 safe, because macOS still ships bash from 2007:
flash-node.sh,repoint-node.sh,monitor-csi.sh, and anode-registry.csvmapping node ID to MAC to label. - Gotcha: back-to-back native-USB connects drop the S3 out of download mode ("Failed to connect: No serial data received"). Recovery is hold BOOT, tap RESET, release BOOT. A single combined erase-plus-write connect avoids it entirely.
- A provisioned node needs only power after that. The radio samples CSI at roughly 10Hz, and the node distills that into about 5 feature frames per second, the flat trace on the dashboard's frame-rate panel.

How it works, and the line of code that reframes everything
Each node measures CSI off its own link to the access point: beacons plus a self-injected probe at roughly 10Hz (csi_collector.c:531). One node, one link, one path to the router.
It does not sound the other nodes. The multistatic dream, nodes pinging each other so a body anywhere between any two shows up, needs Null Data Packet injection. In this firmware that is, verbatim, csi_collector.c:763: "NDP frame injection stub ... TODO construct a proper Null Data Packet." The ESP-NOW traffic between nodes is clock sync only (c6_sync_espnow), not measurement. So more nodes means more independent single-link sensors, not finer radar, and turning on TDM or mesh does not raise SNR.
The consequence is the whole story: sensing quality is geometry, not a software knob. A node feels only the motion that crosses the line between it and the router.
The data path is deliberately dumb. UDP on port 5005, no broker, header magic 0xC511xxxx:
0xC5110001raw CSI, 148 bytes0xC5110006feature_state, 60 bytes: presence, motion, resp_bpm, heartbeat_bpm, anomaly, coherence, per node, computed on-device. This is what the collector consumes.0xC5110002and0xC5110003, plus0xC511a110mesh sync.- node ID at byte 4 of every frame.
The collector and the Grafana stack
The collector is small stdlib Python, no dependencies. It binds UDP 5005, parses the feature frames, and exposes Prometheus metrics on :9105.
It runs on the home NAS under Portainer: python:3.12-slim, script bind-mounted, with network_mode: host. Host networking is mandatory. The default container bridge does NAT and silently drops multi-node UDP, collapsing the whole fleet into one node (RuView issues #374 and #386). One line, one lost afternoon. The healthcheck watches state-file freshness.
Do not store raw CSI in Prometheus. It is a high-rate radio stream and the database is built for the low-rate features the nodes already compute. Prometheus scrapes the ruview job; Grafana renders the ruview-presence dashboard: occupancy, a per-node state timeline of activity against each node's trip threshold, motion, the respiration and heart-rate estimates, an anomaly lane, frame rate, packet CRC rate, and ruview_last_seen_seconds per node. That last one drives a node-offline Slack alert through the same Alertmanager path as the cameras and the cistern. Key series: ruview_occupancy, ruview_motion_activity, ruview_activity_baseline, ruview_activity_trip.

What we experienced: three detectors, two lies
v1 was a fixed-threshold relay: presence over 0.40 means motion. Idle CSI floats around 6 and never reaches 0, so it read motion almost constantly. Obviously broken.
v2 added per-node self-calibration. About 45 seconds of empty room builds a Welford baseline mean and standard deviation; a node trips when its slow-EMA presence exceeds mean + max(k * std, frac * mean), with on and off hysteresis. It passed a live walk, the two nodes nearest my path tripped, and trip points landed around 12 to 16 versus an unreachable 30 from the naive raw version.
Then I ran it blind: 60 seconds of confirmed-empty room against 60 seconds of walking the full space.
- occupied aggregate mean 37.5 versus empty 36.7
- occupied max 46.1, below empty max 47.2
- only about 12% of occupied samples beat any node's empty max, which is chance
Statistically identical. The tempting conclusion: passive CSI reaches a meter or two, a person mid-room is invisible, ship the null.
The correction: it was a measurement artifact. Raw per-packet capture, not the EMA, told the opposite story. At maximum distance across the room, vigorous motion drove raw spread to 10 to 22 versus 1 to 3 standing still. The signal was strong at room distance the entire time. The bug was detecting on the slow EMA (alpha 0.08, about 2.5 seconds), which averages away the quantity that carries the human. The one-line lesson:
WiFi-CSI motion is the variance of the signal, not its level. Empty and occupied have nearly the same mean and the same smoothed variance. They separate cleanly only in raw variance.
v3 measured that directly: motion energy as the standard deviation of raw presence over a 3-second window, calibrated per node, trip at baseline + max(k * std, margin). The synthetic check was clean: still gave 0.85 and no trip, moving gave 6.24 and active. I deployed it, recalibrated against an empty room, and watched a 95-second window. Occupancy read ON for 36 of 48 samples, about 75%, with node 3 (14.2 versus trip 9) and node 1 (11.1 versus trip 10) crossing.
The room was empty for all 95 seconds. That is not 75% detection, it is a 75% false-positive rate. Empty-room CSI variance is bursty and non-stationary; it spikes to human-motion levels with nobody present, worst on perimeter nodes far from the router on weak links. The only well-behaved node sat on a desk beside the router. Some perimeter trips were probably real motion at that desk bleeding through a wall, which is the wrong room entirely.
Two declared victories, two reversals, both from trusting a dashboard without confirmed ground truth.
What it actually does
- Short-range point presence works. A node at a doorway, chair, bed, or desk reliably catches a body crossing its link to the router. Real, camera-free, network-native.
- Room-scale occupancy does not work reliably. Not a range problem, an overlap problem: empty-room noise on geometrically poor links overlaps the human signal, and you cannot threshold across an overlap.
- The fix is not more code. It is the unbuilt active sounding, far denser nodes, or different sensors. mmWave radar does room-scale occupancy today, cheaply, and does not pretend otherwise.
The lessons worth keeping
- Variance, not level. For CSI the information is in the fast spread. Any smoothing in the detection path can erase the thing you are detecting.
- No ground truth, no result. A sensing test without a logged, confirmed in-or-out is a vibe, not evidence. I burned two cycles learning this.
- Read the firmware before you trust the architecture. The "add nodes, enable mesh, get radar" plan died at a single TODO comment.
- Geometry is destiny. On passive single-link CSI, where a node sits relative to the router beats anything you type.
- The hype is real and so is the gap. The published miracles are genuine, and they are the best case, in the best conditions, graded with the answer key in hand. The deployable version is smaller, and it is the only one that matters.
None of this is failure. I have a fistful of tiny, camera-free, network-native point sensors that drop into the same dashboard as the rest of the house, and I now understand in my hands, not from a paper, exactly what this technology can and cannot promise. The router can almost see you. Almost is doing a lot of work, and now I know precisely how much.
Comments
// Comments are reviewed before appearing. No spam. No noise.