Detect a person's breathing rate through the air, using nothing but WiFi signal distortion between two ESP32-S3 boards - no camera, no wearable.
One board (tx) sits in a corner broadcasting plain WiFi packets at a fixed 100 Hz. The other (rx) sits across the room, extracts Channel State Information (CSI) from every packet it receives - a per-subcarrier snapshot of how the radio channel between the two boards distorted the signal - and streams it out over serial as CSV.
Not every packet arrives: ~75-90 Hz of the nominal 100 make it through in
practice, so every analysis step derives the real rate from the capture's
own timestamps (estimate_fs in src/csi.py) instead of assuming 100.
A person's chest moving while breathing perturbs that channel by a tiny, periodic amount (roughly 0.08-0.18 Hz, i.e. 5-11 breaths/min). Filter the CSI amplitude down to that band and the dominant frequency is the breathing rate.
The catch: a single antenna on each end means this is one shared,
un-directional measurement of the whole room - there's no way to tell
whose reflection is whose. A second person moving anywhere in the link's
path swamps the breathing signal completely, and even a second stationary
person doesn't spatially separate. So rather than trying to solve multi-
person separation (an open research problem on this hardware), this project
explicitly gates: a rolling standard deviation of the above-1 Hz motion
band (src/motion_gate.py) flags whether the scene is currently quiet
enough (roughly: one person, not moving) to trust a breathing-rate estimate
at all, and only runs the estimator on windows that pass.
This is deliberately classical signal processing (bandpass filter + power spectrum), not deep learning - that's what the task needs, nothing more.
┌─────────────┐ WiFi packets, 100 Hz ┌─────────────┐ serial (CSV) ┌──────────────┐
│ esp/tx │ ────────────────────────► │ esp/rx │ ───────────────► │ laptop (uv) │
│ ESP32-S3 │ fixed MAC/channel │ ESP32-S3 │ CSI_DATA rows │ src/*.py │
└─────────────┘ └─────────────┘ └──────────────┘
- 2x ESP32-S3 boards (both flashed from this repo, one per role)
- 2x USB cables
- Position tx/rx a few meters apart with the region you want to sense between them
Requires idf.py on your PATH (source esp-idf/export.sh first if it
isn't). Each board is its own ESP-IDF project - flash them one at a time.
Use the UART bridge port, not the native-USB port. ESP32-S3-DevKitC-1
boards expose two ports: a native-USB one (shows up as /dev/cu.usbmodem*
on macOS) and a separate UART-bridge one (/dev/cu.usbserial-*). Both can
flash, but rx's console/CSI output is configured to go out physical UART0
pins (CONFIG_ESP_CONSOLE_UART_CUSTOM in esp/rx/sdkconfig.defaults,
matching Espressif's own examples) - which are only reachable through the
UART-bridge port. Connect via usbmodem* and you'll flash fine but see zero
output ever, with no error - this cost real debugging time, so: always use
the usbserial-* port for both flashing and src.capture. (tx doesn't set
that option, since nothing ever reads its output - but keeping both boards
on the same kind of port saves you from having to remember which is which.)
Find your board's port:
ls /dev/cu.*Flash the transmitter:
cd esp/tx
idf.py set-target esp32s3
idf.py -p /dev/cu.<TX_PORT> build flash
cd ../..Flash the receiver:
cd esp/rx
idf.py set-target esp32s3
idf.py -p /dev/cu.<RX_PORT> build flash
cd ../..Once flashed, both boards run standalone off USB power - no need to keep
idf.py monitor open unless you want to watch boot logs.
uv syncWith tx powered on and rx connected to your laptop:
# record 30s of CSI to data/raw/cap_<timestamp>.csv
uv run python -m src.capture /dev/cu.<RX_PORT> --seconds 30
# (or: just capture /dev/cu.<RX_PORT> --seconds 30)
# estimate breathing rate from the longest quiet stretch in that capture
uv run python -m src.breathing data/raw/cap_<timestamp>.csv
# render an amplitude heatmap + quiet/active gate + spectrogram as HTML
uv run python -m scripts.explore_capture data/raw/cap_<timestamp>.csvWhile a capture is running, press SPACE once per breath-in to mark it:
that packet gets label=1 in the CSV (everything else 0), which is how
you get a hand-counted rate to check the estimate against later.
just capture, just breathe, just analyze are shortcuts for the same
three commands, alongside just monitor <port> (raw serial dump, for
checking the board is talking at all), just live, just writeup and
just lint - see justfile. Flashing, building and menuconfig stay
one-offs you run directly with idf.py per the setup steps above, not
worth wrapping.
just live
# or: uv run marimo edit notebooks/live.pyOpens a browser notebook that reads the rx port directly, keeps a rolling
~60-90s buffer, and re-estimates breathing rate on every tick so you can
watch the bpm number update as you breathe. Pick the port from the dropdown
and click Start live view inside the notebook. Hitting Mark breath
(or Ctrl-Space) once per breath puts a hand-counted rate next to the
estimated one - that's how the estimator was validated.
esp/
tx/ ESP-IDF project: broadcasts packets at 100Hz, nothing else
rx/ ESP-IDF project: extracts CSI, streams CSV rows over serial
src/
capture.py host-side: serial -> timestamped CSV in data/raw/
csi.py CSV -> (n_packets, 52) amplitude matrix, real sample-rate estimate
motion_gate.py rolling-std quiet/active gate + longest-quiet-run helper
breathing.py bandpass filter + spectral peak -> breaths/min
scripts/
explore_capture.py one capture -> HTML chart (heatmap + gate + spectrogram)
monitor_serial.py raw serial dump from a port, for bring-up
build_presentation_data.py real capture numbers -> the deck's data JSON
render_filter_figure.py that same data -> assets/filter-band.svg for the writeup
notebooks/
live.py live serial read -> rolling buffer -> bpm estimate, browser notebook
docu/
writeup.typ project writeup (Typst source; `just writeup` -> writeup.pdf)
HYPERPARAMETERS.md every tunable in firmware and DSP, and what it does
notes.md hardware/ordering scratch notes
presentation/ static slide deck (open index.html)
paper/ the reference PDFs below, plus my own hash_radio_fields draft
assets/ figures the writeup embeds
justfile the shortcuts above (`just --list`)
data/raw/ captures: CSVs + generated .parquet caches / .html reports (gitignored)
- One person only, and they need to hold still. See "How it works" above - this is a hardware limitation (single antenna, single link), not a bug. The gate detects and rejects invalid windows rather than trying to fix this.
- Environment-dependent. CSI reflects the whole room's static geometry
(furniture, walls), not just the person - retune
quiet_mask's threshold if you change rooms. - Coarse frequency resolution. The Welch window scales with how much quiet data there is (15s minimum, 60s cap), so steps run from roughly 4 bpm on a short quiet run down to ~1 bpm on a long one. Fine for validating against a stopwatch count; not a medical-grade instrument.
Firmware bring-updone - CSI_DATA rows stream between the two boards, ~77 Hz effective on the reference capture against 100 nominal.- Baseline captures: partly done - one 160s single-person capture is
the reference for everything in
docu/writeup.typ. Still missing the comparison set: empty room, and one person still + a second person moving nearby (to see the masking effect). Validatedonesrc/breathing.py's estimate against a hand count- 9.0 bpm estimated vs ~7.8 bpm from 11 marks over the same 80s window
(see the validation section of
docu/writeup.typ).
- 9.0 bpm estimated vs ~7.8 bpm from 11 marks over the same 80s window
(see the validation section of
- Only if needed: extend the motion gate from binary quiet/active into a coarse occupant-count bucket (same underlying statistic, more classes) - and only reach for a learned model if classical features prove insufficient there.
- Strohmayer, J., and Kampel, M. (2024). "Data Augmentation Techniques for Cross-Domain WiFi CSI-Based Human Activity Recognition." IFIP AIAI 2024. https://doi.org/10.1007/978-3-031-63211-2_4
- Strohmayer, J., and Kampel, M. (2024). "Directional Antenna Systems for Long-Range Through-Wall Human Activity Recognition." IEEE ICIP 2024. https://doi.org/10.1109/ICIP51287.2024.10647666
- Yang, J., Chen, X., Zou, H., Wang, D., Lu, C. X., Sun, S., and Xie, L. (2023). "SenseFi: A Library and Benchmark on Deep-Learning-Empowered WiFi Human Sensing." arXiv:2207.07859. https://arxiv.org/abs/2207.07859
- Espressif
esp-csiget-started examples (csi_send/csi_recv) - this project's firmware is a trimmed, single-target adaptation of these.