surriworks/engineering
Project · RF sensing

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.

PythonNumPyscikit-learnESP32Raspberry PiUDPDSPOpenCV
6ESP32 CSI nodes
100 Hzstimulus broadcast
80subcarrier bins per frame
4model families trained on the Pi

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.

Illustration (simulated, not recorded data): a CSI amplitude waterfall with subcarriers on the vertical axis and time scrolling left. The channel is quiet in an empty room and turns turbulent when someone walks through.

Architecture

ESP1 ESP2 ESP3 ESP4 ESP5 ESP6 ESP32 nodes CSI · UDP Raspberry Pi · csi_receiver Receiverchunk reassembly Frame pipelineamp · baseline · Δt Recorder.bin + frames.jsonl BIO / DHBbreathing · heartbeat Runnerlive inference Web dashboardDEDI · DML · DIST stimulus broadcast · 100 Hz pitrain agentslaptops / GPUsremote training Camera nodeOpenCV people det.
The Pi drives the radio channel with its own broadcast traffic, collects CSI from every node, then processes, records, analyses and serves everything from a single Python process.

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.

FieldTypePurpose
magicu320x43534921 ("CSI!"), rejects stray traffic
node_id · rssi · chanu8 · i8 · u8who sent it, signal strength, Wi-Fi channel
seq · t_usu32 · u32frame sequence number and ESP32 timestamp
total_len · offset · chunk_lenu16 × 3where this chunk sits inside the full CSI frame
mac6 bytesstable 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:

ViewComputationWhat it shows
Method 1|I + jQ| per subcarrierChannel 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:

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:

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:

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

LayerTools
Sensors6 × ESP32 (CSI firmware), UDP
Edge serverRaspberry Pi, Python 3, threading, standard-library HTTP server
DSPNumPy (FIR, Welch PSD, STFT implemented by hand)
MLscikit-learn, pandas
AgentsPython, psutil, pynvml, requests, PowerShell / bash installers
VisionOpenCV
Status: active research build. Presence detection is the main target. The breathing and heart-rate tools are diagnostic, so treat their output as experimental.