Skip to content

Latest commit

 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

wifi-project: breathing-rate sensing over WiFi CSI

Detect a person's breathing rate through the air, using nothing but WiFi signal distortion between two ESP32-S3 boards - no camera, no wearable.

How it works

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     │
└─────────────┘                           └─────────────┘                  └──────────────┘

Hardware

  • 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

Setup

1. ESP-IDF toolchain

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.

2. Python environment

uv sync

Usage

With 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>.csv

While 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.

Live view

just live
# or: uv run marimo edit notebooks/live.py

Opens 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.

Repo structure

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)

Known limitations

  • 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.

Roadmap

  1. Firmware bring-up done - CSI_DATA rows stream between the two boards, ~77 Hz effective on the reference capture against 100 nominal.
  2. 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).
  3. Validate src/breathing.py's estimate against a hand count done
    • 9.0 bpm estimated vs ~7.8 bpm from 11 marks over the same 80s window (see the validation section of docu/writeup.typ).
  4. 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.

References

  • 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-csi get-started examples (csi_send/csi_recv) - this project's firmware is a trimmed, single-target adaptation of these.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages