Device-free presence/motion sensing using WiFi signals — no camera. A Rust port of the core idea behind WiFi CSI sensing
WiFi radios constantly measure Channel State Information (CSI) — how each OFDM subcarrier of a received packet was distorted by the path it travelled (multipath reflections, in plain terms). A body moving through that path perturbs the reflections in a detectable way. That's the whole trick: no video, no audio, just statistics on radio distortion.
Two things this is not:
- Not something your existing router + a laptop can do alone. Consumer
WiFi chipsets don't expose CSI to normal software. You need at least one
extra piece of hardware that can — this project targets ESP32 boards
because they're cheap and Espressif ships an official CSI example
(
esp-csi) to build on. Your router is still useful here — it's the ambient traffic source the ESP32 node listens to — but the sensing itself happens on the ESP32, not the router. - Not pose/imaging out of the box. What's here is occupancy + motion detection (empty / occupied / motion, per node) from a fairly simple online anomaly score over subcarrier amplitudes. Turning CSI into an actual body outline is a much bigger project (dense antenna arrays, trained neural models) — worth knowing that "WiFi that sees a person's shape" claims from some open-source projects are contested; treat those demos skeptically and start from what's verifiable.
Zero-purchase path (crates/rssi-sniffer) — uses your existing wifi
card, switched into monitor mode, to track RSSI (plain signal strength) of
packets from your router. No new hardware. Much coarser than CSI — you lose
per-subcarrier detail entirely, so this is closer to "something changed
near the router" than real sensing, and it's more prone to false positives
(a passing car, someone's phone, another network on the same channel). It
needs a wifi chipset that supports monitor mode (most laptop cards on Linux
do; check with iw list) and root to switch modes. It reuses the exact
same wire format as the ESP32 path, so it plugs straight into the existing
aggregator and radar.html with zero changes there.
ESP32 path (firmware/esp32-csi-node) — true CSI, per-subcarrier
detail, much better signal-to-noise, needs a ~$8-10 board. See the section
further down.
# 1. put your wifi card into monitor mode (needs root)
sudo scripts/enable-monitor-mode.sh wlan0
# lock it to your router's channel so you're not sniffing every network in range
sudo iw dev wlan0mon set channel <N>
# 2. find your router's BSSID (its MAC address) so you can filter to just it
iw dev wlan0mon scan | grep -B5 "<your SSID>" | grep freq
# 3. run the aggregator (same one used for ESP32 nodes)
cargo run -p aggregator
# 4. run the sniffer, pointed at your router's BSSID
sudo cargo run -p rssi-sniffer -- --interface wlan0mon --target-bssid AA:BB:CC:DD:EE:FF
# 5. open radar.html — it doesn't care which path fed the dataExpect this to need real tuning (--occupied-threshold / --motion-threshold
on aggregator) against your own space before it's usable — RSSI is noisy,
and the default thresholds were picked with CSI's much richer signal in
mind. Walk around and watch the raw score numbers in the aggregator's
terminal output before trusting any alert off it. The guess (human vs
animal vs object) heuristic in csi-core leans much harder on burst
duration than CSI does here, since RSSI alone gives it almost nothing else
to work with — trust that label even less on this path than on the ESP32
one.
Open radar.html in a browser (no build step, it's static) while
aggregator is running — it polls http://localhost:8090/api/status
every 800ms and draws a sweeping radar with a blip per node, colored by
state (green = empty, amber = occupied, red = motion).
Two things worth being upfront about:
- The angle on the dial is not measured. There's no antenna array here,
so there's no real angle-of-arrival or range estimate.
radar.htmlhas aNODE_ANGLESmap at the top — you tell it where eachnode_idphysically sits in your home, and it draws the blip there. It's a labeled floorplan, not a sensor-derived position. - The "guess" column is a heuristic, not a classifier.
csi-corenow tracks how long a motion burst lasts and how large the disturbance peaks, and makes a rough call: longer bursts / sustained occupied readings lean "likely human", short sharp bursts lean "likely small animal or object". There's no training data behind this and no way for it to reliably tell a person from a large dog, or a pet from a draft — treat the label as a hint to glance at, not a verified detection. If you want something closer to real man/animal/object discrimination, that means collecting labeled CSI recordings in your own space and training a small classifier on top of the amplitude features already computed incsi-core::amplitudes()— a meaningfully bigger project than this starting point.
ESP32 node(s) this repo (Rust)
┌─────────────────────┐ UDP/JSON ┌──────────────────────────┐
│ esp-csi capture │ ────────────▶│ aggregator (crates/aggregator)
│ (see firmware/) │ :5005 │ - parses CsiFrame │
│ one board per room/ │ │ - per-node PresenceDetector
│ hallway you want to │ │ (crates/csi-core) │
│ cover │ │ - prints live status │
└─────────────────────┘ └──────────────────────────┘
crates/csi-core— theCsiFrametype andPresenceDetector: a rolling per-subcarrier baseline (mean/variance, EMA-updated) scored against each new frame. High deviation = motion; moderate and sustained = someone present but still; low = empty. Unit-tested.crates/aggregator— a Tokio UDP server (port5005) plus an HTTP status endpoint (port8090, CORS enabled) at/api/status. Any number of ESP32 nodes can point the UDP side at it; eachnode_idgets its own independent detector, so you get per-room state, not just one global reading.radar.html— static page, polls/api/status, renders the radar view described above.crates/rssi-sniffer— the zero-purchase capture path: monitor-mode packet sniffing + RSSI extraction, repackaged as the sameCsiFramewire format so it feeds the aggregator unchanged.scripts/enable-monitor-mode.sh— puts a wifi interface into monitor mode (needs root; not every chipset supports this).firmware/esp32-csi-node— instructions + the glue snippet for the ESP32 side, built on top of Espressif'sesp-csi.
Building rssi-sniffer needs libpcap's headers installed first:
sudo apt install libpcap-dev on Debian/Ubuntu (or your distro's equivalent).
csi-core and aggregator need nothing extra.
cd xw
cargo test -p csi-core # sanity-check the detection logic, no hardware needed
cargo run -p aggregator # starts listening on 0.0.0.0:5005Sample output once a node is sending frames:
[21:04:12] node=living-room rssi= -47 ch= 6 score= 0.41 avg= 0.38 -> empty
[21:04:13] node=living-room rssi= -48 ch= 6 score= 0.55 avg= 0.40 -> empty
[21:04:14] node=living-room rssi= -46 ch= 6 score= 3.92 avg= 0.95 -> MOTION!
You can also feed it synthetic frames (no ESP32 yet) to see it work —
crates/csi-core's tests do exactly that, or write a tiny script that
sendtos the aggregator UDP port with hand-built JSON matching
CsiFrame's shape.