Presence detection on an ESP32-S3 that runs off the WiFi already in the room. It listens to channel estimates in other people's packets — it never joins a network, never needs a password, and never has to be calibrated against a room.
Detects an empty room, an occupied one, movement, and breathing rate while someone sits still. About $6 of hardware.
No link required. Most ESP32 CSI projects ping their own router, or run a second board as a transmitter, and read the CSI of their own traffic. That means handing WiFi credentials to something you wanted to be a passive sensor. This one sits in promiscuous mode and takes channel estimates out of every frame it hears. A board with no credentials, on a battery, in a field, still detects.
No calibration. Published CSI work usually records an empty room, builds a
template, and matches against it — so you recalibrate when the sensor moves,
when the furniture moves, or when a new device joins the air. Here every
(transmitter, frame length) pair keeps its own baseline and is scored only
against its own learned quiet level. Move it to another room and it's right
within a minute, with nothing to do.
It can say "I don't know." The dashboard shows which transmitters it's currently using and whether each one's baseline is still trustworthy. A path that stops returning to its own quiet level gets dropped from the verdict automatically.
- Presence — someone is in the room, including sitting perfectly still.
- Motion — that someone is moving.
- Breathing rate — a Goertzel filter on what's left after presence is established. Shown only when the SNR is good enough to mean something.
- Path health — the transmitters in use, their frame counts, their ratios.
- Web dashboard plus an optional 128x64 OLED front panel.
Adaptation rates have to be per second, not per frame. The per-path floor was originally learned with a fixed step on every CSI frame. Frames don't arrive on a clock — a chatty AP sends eight a second, a sleepy smart plug sends one, so the plug's baseline learned eight times slower off the same constant. Worse, it deadlocks: the floor can't catch up, so the reading stays far above the floor, which selects the slow rate, which keeps the floor from catching up. Two paths sat frozen at 2.3x their baseline in an empty room.
Corroboration beats loudness. The verdict originally took the loudest path. Twenty hours of logged per-path ratios showed why that fails — the maximum of ten noisy paths is high even in an empty room, and 70% of quiet-room samples cleared the presence threshold. The sensor read "occupied" 98% of the time. Requiring three paths to agree drops that to 20%, because a body moves several paths at once and a chatty transmitter moves one. The number on the dashboard is now the third-highest path ratio, which is "three paths above x" written so a threshold still applies to it.
Full write-up: docs/article.md
| Board | ESP32-S3 (developed on a YD-ESP32-S3, N8R2) |
| Display | 0.96" SSD1306 128x64, I2C — optional |
| Enclosure | case/wavesight_box.scad, OpenSCAD, prints without support |
OLED wiring is four wires: SDA to GPIO 11, SCL to GPIO 12, plus 3V3 and GND.
With no display attached the firmware runs headless and says so on the console.
ESP-IDF v5.4 or newer.
git clone https://github.com/Tsoakey/WaveSight
cd WaveSight
cp main/wifi_secrets.h.example main/wifi_secrets.h # optional
idf.py set-target esp32s3
idf.py build flash monitor
wifi_secrets.h only decides whether the board joins your network so you can
reach the dashboard. Detection doesn't use it. Skip the file and the board brings
up its own access point instead.
Two numbers, both on the dashboard, both stored in the browser:
- Presence ratio (default 1.40) — how far above its own quiet level a path has to read before it counts toward presence.
- Motion ratio (default 1.95) — the same, for movement.
These travel between rooms, since every path is scored against its own floor rather than against a recording.
logger.js polls /status once a second into a JSONL file. The firmware keeps
no history of its own, and every tuning decision here came out of replaying that
log offline rather than out of guessing and reflashing.
- A cat looks like a person. Four kilos crossing a path disturbs it about as much as you do, and CSI has no concept of species. Breathing rate could separate them in principle — different bands — but that's not implemented.
- Breathing needs stillness. One step displaces two orders of magnitude more than a breath, so the estimate only runs while presence is held and motion isn't.
- It counts nothing. How many paths are disturbed has no fixed relationship to how many people are in the room. The dashboard says so where the number appears.
- Board-side settings don't survive a power cycle. They persist in the browser instead. NVS on this build erases at boot for reasons I haven't run to ground; there's a comment at the spot.
Passive CSI sensing on ESP32 has been demonstrated before, and this would have taken longer without those demos existing to prove the idea worked. The code here is independent: the per-path self-calibration, the corroboration rule, the breathing path and the UI were written for this project.
One correction worth recording, since I got it wrong publicly first: ESP32 CSI
does work on traffic you have nothing to do with. It fails when htltf_en is
false, because ambient traffic is 802.11n and the channel estimate lives in the
HT long training field.
MIT. See LICENSE.
