Inspiration
The people most in need of being watched over are the people who least want to be watched. A camera in a bedroom solves the monitoring problem by creating a surveillance problem, and a wearable only works while someone remembers to wear it. We wanted the third answer: sense the room, not the person, using a signal that is already there and that cannot reconstruct a face, a voice or a name because it never measures one.
WiFi channel state information is that signal. Every packet that crosses a room arrives having been bent by everything in it, including a chest that rises and falls. The information is genuinely present in hardware that costs fifteen dollars. The hard part is not detecting it. The hard part is being honest about what you detected.
Where this fits in the longevity stack
The healthcare track describes longevity as layers: molecular and cellular aging at the base, biomarkers and diagnostics above that, then the everyday factors of sleep, nutrition, exercise and stress, and environment at the outermost layer. Vigil sits on the lifestyle layer, and it earns that place by measuring the factor people are worst at self-reporting: how they actually rest.
Sleep is the longevity input with the strongest evidence behind it and the weakest data in practice. A wearable has to be charged and worn, and the people whose healthspan is most at risk are the least likely to keep one on. Vigil needs nothing worn and no camera in the room. Night after night it can report whether the room was occupied, whether the person was still, what their breathing rate was, and how long any pause in breathing lasted, with a confidence attached to every number and a flag whenever the number is not trustworthy.
That is a passive biomarker stream, produced in the place where the behaviour actually happens. What we built this weekend is the sensing layer and an interface honest enough to sit on top of it. The coaching layer above it is the obvious next build, and we deliberately did not fake it.
What it does
One ESP32-C6 transmits, one receives, and the receiver reports per-subcarrier channel state information over USB at about 100 packets a second. A Python hub turns that stream into four judgements, and a browser twin renders the room and what the hub currently believes:
- Presence, against a 60-second calibration of the empty room.
- Moving or still, a motion score in standard deviations above that empty-room baseline.
- A count bucket: 0, 1, or 2+, never a headcount, and 2+ is capped at 0.5 confidence.
- Breathing rate, the 0.1-0.5 Hz band of the channel, in breaths per minute.
Two alerts sit on top: a still-timer that opens when someone has not moved for a set period, and a breathing-pause alert that escalates yellow at 15 seconds and red at 25. Every number on screen carries a confidence, and every number that fails its gate is published as null with a flag rather than as a stale value dressed up as fresh.
A second tab shows the signal chain itself: the same wave at five steps, from the packet as it arrived, through outlier removal and resampling, to the channel in baseline-sigma units, through the breathing band-pass, and finally to the spectrum where the peak is either accepted or refused against its noise floor. It is the answer to "how do you know", drawn rather than asserted.
How we built it
The chain runs one direction with hard seams. Firmware (vendored esp-csi on ESP-IDF 5.5.5) emits CSI as ASCII lines. A parser turns lines into frames stamped with a monotonic clock. Preprocessing resamples to 20 Hz and converts amplitudes into z, a vector in baseline-sigma units. The DSP stages consume z and nothing else. One module knows the stage order and is the only place DSP results become protocol messages, so adding a stage is one file.
Three decisions did most of the work.
The sources are interchangeable. A live radio, the simulator and a recorded run satisfy the same interface, so everything downstream is identical for all three. A recording is a legitimate demo rather than a mock, and a 10x replay reproduces the original run's numbers because no stage is allowed to read the wall clock.
The protocol is generated, not hand-written. Pydantic models are the source of truth; the TypeScript the browser uses is exported from them and diff-gated, so the hub and the twin cannot drift apart silently.
The hub decides and the twin renders. No threshold, no alert rule and no derived state lives in the frontend. That is also what makes the privacy property enforceable: raw CSI never crosses the WebSocket, only small derived summaries. Full frames go to a file on disk and nowhere else.
Challenges we ran into
An int8 rail eating 12-bit data. The clipping constant was 127, inherited from the classic ESP32 CSI format. The C6 emits 12-bit values. Calibration was discarding 62 of 64 subcarriers, and the link looked dead in exactly the placement that later worked best.
A gate that sat inside its own noise. The breathing detector picks the 10 most responsive subcarriers out of 56 and then measures a peak-to-noise ratio. Selecting the best 10 and then measuring how good they look is a look-elsewhere effect, and our 8 dB validity gate sat below the maximum that statistic reached on an empty room, 13.1 dB. It was raised to 14 dB with regression tests that fail if anyone lowers it.
A false positive that taught us the rule. A very clean calibration gives a very small sigma, and an empty room's own slow drift then crosses a threshold expressed purely in sigma units. The hub reported a still person in an empty room for five minutes. The fix is absolute floors under the sigma-relative thresholds, measured from real empty-room recordings. The deeper rule: a static baseline cannot distinguish a body from moved furniture, and it must never re-baseline while it believes someone is present, or a sleeping person is quietly erased.
A door that moved. The transmitter was hung on a door knob. Opening the door on the way out moved the channel into a state calibration had never seen, and the hub called the empty room occupied. Mount nothing to anything that moves, and calibrate last.
Thirteen minutes of an empty room, analysed as a person. We read a long recording as someone sitting still. They were in the shower. Now occupancy is confirmed out loud and verified in the raw data before anyone interprets anything.
Accomplishments that we're proud of
A live run on real hardware, start to finish, from a recording we still have:
| What | Result |
|---|---|
| Empty room, presence | absent on 38 of 38 checks |
| Person walks in | detected within about 1 s |
| Seated, still | wander 25-32 against an empty-room 0.2-0.8 |
| Breathing rate | median 14.8 bpm, confidence up to 0.99 |
| Breath held | yellow alert at 15 s, red at 25 s |
| Breathing resumes | alert cleared at 41.6 s |
The simulator acceptance gate, 18 checks on beat timing, passes 18 of 18, and the merge gate (lint, hub tests, twin tests, protocol drift) is green.
We are equally proud of what the system refuses to say. It publishes no identity, no position and no medical claim of any kind. An alert says what was observed and for how long, never what it means.
What we learned
That the honest version of a sensing project is mostly about gates, and that every threshold you cannot derive from a measurement is a promise you are making to someone else on their behalf. Three of our four worst bugs were a number that looked reasonable and had never been measured.
Also, concretely: the ROM printf on an ESP32-C6 follows the console port selected in the firmware config, so choosing the UART console silently sends your data out the pads instead of USB; warm-up is long and real, and a demo that hides a 45-second wait is lying about the physics; and a serial device number is not a stable identity, which is why every path in this project is a by-id path.
What's next for Vigil
Nightly trends are the obvious next layer, and the one the longevity framing asks for: breathing rate and restlessness across weeks rather than a single evening, which is where a lifestyle biomarker becomes actionable rather than merely true.
After that, the 2D localisation layer, which needs a second link, and the LiDAR teacher we designed for and deliberately deferred: a 2D LiDAR's tracks auto-label CSI windows, so the WiFi model learns to report position from a teacher it eventually no longer needs. Every message in the protocol already has room for the tracks it would emit. Nothing in the current build pretends that work is done.
Built With
- c
- esp-idf
- esp-now
- esp32
- fastapi
- numpy
- pnpm
- pytest
- python
- react
- react-three-fiber
- scipy
- three.js
- typescript
- uv
- vite
- vitest
- websockets
- wifi-csi

Log in or sign up for Devpost to join the conversation.