Wireless Sensing2026
Saans
Camera-free sensing of room occupancy and breathing from the WiFi between ESP32 boards, cross-checked in three languages and not yet run on a real radio.

- Type
- Personal project
- Year
- 2026
- Platform
- ESP32-S3 + Linux gateway
- Role
- Sole author
- Collaboration
- Solo
- Timeline
- Oct 2026
- Stack
- Rust · C++ · ESP-IDF · Python · Axum
The problem
Saans (साँस, "breath") started as a question: can the WiFi already in a room tell you where the people in it are, without a camera? An ESP32 listening to another reports channel state information — amplitude and phase for each of 56 subcarriers on every frame — and a body in the room perturbs it. What I built does two things with that: it says whether a space is occupied and, where the space has opted in, how fast the person in it is breathing. Every output carries a confidence and may answer "unknown".
One fact belongs here rather than in a footnote, because the project's own evidence file opens with it: no board has ever run this firmware. Every accuracy figure on this page came out of a simulator in the same repository. What exists is a pipeline that agrees with itself across three languages, tested against a physics model written to find its failures. That is worth something. It is not a field result.
Locating people lost to six lines of arithmetic
Locating a person was the obvious first build, so I built it properly: radio tomographic imaging over a mesh of single-antenna nodes, a regularised inversion, a Kalman tracker, and beside it a trivial baseline — the midpoint of each disturbed link, weighted by how disturbed it was.
The baseline won. At 4–12 nodes, RTI reached 0.72 m median error while answering 13–18% of the time; the baseline reached 0.709 m answering every time, and beat or tied RTI in all 15 parameter settings I tried at eight nodes. The cause is physical: RTI assumes a body shadows the links it stands between, but CSI amplitude is dominated by multipath, and RTI's weight model correlated with the measured disturbance at +0.13.
The one configuration where RTI looked better — four nodes, 0.44 m — answered 15% of the time, and accuracy rose as coverage fell in every sweep. A headline accuracy number is chosen by the abstention threshold, and means nothing without its coverage figure. I kept the negative result and narrowed the scope to what one link can measure.
Raw radio never leaves the room
Raw CSI from a room is enough to re-derive breathing and heartbeat. A server that receives it becomes a store of health data whether anyone meant it to or not, and under India's DPDP Act 2023 that store carries consent, purpose and retention obligations.
So the ESP32 runs the DSP itself and sends a fixed 68-byte frame twice a second:
motion energy, link quality, and breathing fields zeroed on the node unless the
space has opted in. That guarantee is one C++ function, apply_consent_gate,
whose invariant fits in a sentence: it only ever clears, so calling it twice, or
when it was not needed, cannot leak anything. The gateway receives zeros
rather than being trusted to discard something.
The Rust gateway adds a second lock — consent decides what may exist, a role decides who may see it. A space outside someone's scope answers 404 rather than 403, so the scope list is not a directory of the building, and every read of consented breathing data is audited.
One spec, three implementations
The DSP has to exist three times: C++ on the node, Rust in the gateway, and Python, where the research happens. Three implementations of the same arithmetic drift apart silently unless something forces them to agree.
Python is the reference. A generator records its output on fixed inputs for every stage, and the ports must reproduce those vectors; a disagreement is fixed in the implementation that disagrees, never by loosening a tolerance.
Rust matches byte for byte: 672 of 672 end-to-end frames identical to Python's.
That meant reproducing CPython on purpose — its compensated float sum(),
half-to-even round(), even a truncated Euler–Mascheroni constant. The ESP32-S3
has only a single-precision FPU, so C++ runs in float32 and is held to 1e-4
relative, except the encoder, which must emit the reference's exact bytes.
It paid off when the simulator evaluation found three bugs no unit test had seen, among them an empty room reading occupied 36% of the time. Each fix went into Python, and the regenerated vectors failed in both ports before either was edited by hand. A porting bug surfaces as a named vector failing, not as an anomaly in someone's room. That proves the ports agree — not that the arithmetic is right for a real channel.
Breaking the receiver before owning one
The simulator produced a clean channel. A real ESP32 does not: it runs automatic gain control, adds a random phase to every packet and quantises to int8. So I modelled that layer, off by default, and measured what each term costs.
I expected quantisation to hurt; it landed within 0.04 dB of the clean channel. Gain control did the damage, and not where I was looking. Breathing survived. Presence broke: gain steps are impulses in the presence band, the empty-room threshold inflated twelvefold, and the empty room ended up reporting more motion than the seated person — so breathing was suppressed for a room believed empty. A lost breathing rate there is a lost person, not a lost signal, visible only because presence is reported beside it.
The uncomfortable part: the whole effect rests on one constant I guessed, how often a settled gain loop moves anyway. Presence survives a gain change about once every four minutes and fails completely at once every twenty-eight seconds. So I did not retune anything against an uncalibrated model. The most valuable number in the first real recording is how often an idle node's gain moves.
What it does not do yet
- Run on hardware. No ESP32 has been flashed with this firmware. It builds, and it logs whether the driver actually delivers the 56 subcarriers the DSP assumes, because that is still an assumption.
- Pose or through-wall imaging. One antenna gives no angle and 20 MHz gives a 7.5 m range cell. A link that cannot tell which side of a wall someone is on turns through-wall reach into false occupancy from the corridor.
- Tell a fall from sitting down. The collapse detector caught every simulated fall and fired on every simulated sit-down. It ships off.
- Promise a confident answer is right. At a Fresnel blind spot the breathing estimator can lock onto twice the true rate, and has at confidence 1.00. The confidence is a gate, not a probability.
- Trust heart rate. It is off by default; gated, it answered in 39 of 64 synthetic scenarios and was right in 38.
- Run the demo from the public site. That is a static copy; the demo and the dashboard need a local gateway, and the copy says so.