WiFi Sensing
A room full of Wi-Fi is also a room full of radar. Six ESP32 boards report how the radio channel distorts every packet they receive. A Raspberry Pi turns that stream into a live picture of the room: whether someone is there and, experimentally, how fast they are breathing.
The idea
Every Wi-Fi packet carries training symbols that let the receiver estimate the Channel State Information (CSI): a complex number per OFDM subcarrier that describes how amplitude and phase were changed on the way. When a person stands, moves or even breathes in the room, the reflections change and so does the CSI. Ordinary routers throw this data away. ESP32 chips can export it.
The goal was a complete pipeline, not just a plot. It needed reliable capture from several nodes at once, tools to record and label sessions, training from the browser, and a model running live on the same Pi that collects the data.
Architecture
1 · Excite the channel
A stimulus thread on the Pi broadcasts small UDP packets at 100 Hz. Every node therefore sees a steady, predictable packet rate, which gives a stable CSI sample rate without depending on whatever other traffic happens to be on the network.
2 · Capture & stream
Each ESP32 extracts CSI from every received packet and streams it back as one or more UDP chunks, tagged with node ID, sequence number, RSSI, channel and an on-chip microsecond timestamp.
3 · Reassemble & process
A non-blocking receiver drains up to 5,000 datagrams per pass and rebuilds frames keyed by (mac, seq). It also tracks gaps, duplicates and out-of-order sequences per node.
4 · Use it
Finished frames feed the recorder, the vital-sign estimators and the live classifier, and a PIN-protected web dashboard shows waterfalls and health stats for every node.
A tiny wire protocol
CSI payloads can exceed one datagram, so each chunk carries a packed little-endian header with a "CSI!" magic word plus the offset and length of its slice. The receiver allocates a buffer when the first chunk of a frame arrives, fills in slices as they land, and hands the frame on once every byte is present. Frames that stay incomplete for more than one second are dropped and counted.
| Field | Type | Purpose |
|---|---|---|
magic | u32 | 0x43534921 ("CSI!"), rejects stray traffic |
node_id · rssi · chan | u8 · i8 · u8 | who sent it, signal strength, Wi-Fi channel |
seq · t_us | u32 · u32 | frame sequence number and ESP32 timestamp |
total_len · offset · chunk_len | u16 × 3 | where this chunk sits inside the full CSI frame |
mac | 6 bytes | stable node identity, independent of IP |
HDR_FMT = "<IBbBIIHHH6s" # 27-byte header, then chunk_len bytes of CSI
def parse_chunk(data):
magic, node_id, rssi, chan, seq, t_us, total_len, offset, chunk_len, mac = \
struct.unpack_from(HDR_FMT, data, 0)
if magic != CSI_MAGIC: return None
...
Signal processing
Raw CSI arrives as interleaved signed 8-bit I/Q pairs. Every completed frame goes through three parallel views. Each is cheap enough to compute per frame on a Pi, and together they answer different questions:
| View | Computation | What it shows |
|---|---|---|
| Method 1 | |I + jQ| per subcarrier | Channel amplitude profile, the base for everything else |
| Method 2 | |amp − baseline| | Distance from an empty-room calibration (a 10 s average per node) |
| Method 3 | |amp − ampprev| | Frame-to-frame change, which spikes with motion |
Each frame also gets a quality summary (mean, spread, energy, dynamic range, non-zero bins). The dashboard uses it to show whether a node is producing healthy data before you record anything.
def iq_to_complex(csi_bytes):
iq = np.frombuffer(csi_bytes, dtype=np.int8).astype(np.float32).reshape(-1, 2)
return iq[:, 0] + 1j * iq[:, 1]
def method_2(csi_bytes, max_bins, baseline):
amp = fit_to_bins(np.abs(iq_to_complex(csi_bytes)), max_bins)
return np.abs(amp - baseline)
From recordings to a model
The dashboard is split into tools that cover the whole ML loop on the device itself:
- DREC records sessions. Each node gets a compact binary log (
<Q I B b B I Hrecord header + raw CSI), and aframes.jsonlfile holds per-frame diagnostics such as sequence gaps and quality metrics. - DEDI browses sessions as downsampled amplitude overviews, scores how consistent the nodes were, lets you select a time window and exports it as a labelled CSV (for example Present / Empty).
- DML builds a dataset from labelled CSVs and trains Random Forest, Gradient Boosting, SVM or an MLP with optional class balancing. It reports train/test accuracy, 5-fold stratified cross-validation, a confusion matrix and feature importances, then saves the model with its metadata.
- Runner loads a saved model and classifies the live stream. It keeps a ring buffer of recent frames per node, averages them across nodes into the model's feature layout, and publishes a label, a confidence score and a history.
Vital signs: honest about uncertainty
Breathing moves the chest a few millimetres, enough to modulate CSI amplitude at 0.1–0.5 Hz. Two modules try to recover it, written in pure NumPy with no SciPy dependency:
DHB · debug heartbeat
A zero-phase windowed-sinc FIR bandpass (Blackman window, run forwards and backwards), Welch PSD and an STFT spectrogram. It estimates respiration over 6–30 RPM and heart rate over 42–180 BPM, with a peak-confidence score for each.
BIO · respiration feasibility
A deliberately conservative estimator. It keeps a 120 s rolling buffer of three candidate scalars and picks the one with the best in-band SNR, then runs 20 s windows on a 5 s step.
BIO's main design choice is a reliability gate: it would rather report nothing than a confident wrong number. It withholds an estimate unless all of these hold:
- the in-band SNR is at least 2.5;
- at least 60% of windows agree within ±0.05 Hz;
- the peak isn't hugging the band edge or sitting on a 2nd/3rd harmonic of another peak;
- low-frequency drift doesn't overwhelm the breathing band.
Distributed training
Training on a Pi is slow, so the system can hand the work to other machines. A lightweight pitrain agent for Windows and Linux is served by the Pi itself as a zip with one-line install scripts. The pieces:
- Discovery: the Pi finds candidate machines with an ARP/ping LAN scan.
- Pairing: a 6-digit code that expires after 5 minutes, approved from the dashboard.
- Auth: HMAC-SHA256 tokens that rotate every hour.
- Training: the agent reports heartbeats with CPU, GPU, memory, temperature and battery probes, pulls datasets, trains in a subprocess and streams JSON progress lines back to the job log.
Camera ground truth
A separate Pi with a camera runs OpenCV person detection at a few frames per second and posts events and heartbeats to the dashboard. It's there to check the radio-only predictions against what the camera actually saw.
Stack
| Layer | Tools |
|---|---|
| Sensors | 6 × ESP32 (CSI firmware), UDP |
| Edge server | Raspberry Pi, Python 3, threading, standard-library HTTP server |
| DSP | NumPy (FIR, Welch PSD, STFT implemented by hand) |
| ML | scikit-learn, pandas |
| Agents | Python, psutil, pynvml, requests, PowerShell / bash installers |
| Vision | OpenCV |