Mini Observer turns a few small Wi-Fi devices into a simple room awareness system. Place them around a space, open the dashboard, and see whether the room is quiet, occupied, or full of movement.
There are no cameras and no images of people. It works by noticing how everyday Wi-Fi signals change as people move through the room. The live web page gives you an at-a-glance view of activity, occupancy, and device health.
The Python web dashboard served by tools/live.py shows live CSI-derived occupancy, motion, baseline, and node/link health information in the browser.
The lower dashboard sections expose ESP-NOW link evidence and per-node telemetry so you can inspect which nodes and directed links are contributing to the current RF activity estimate.
Mini Observer is designed around a Mac-compute, ESP32-sensor architecture:
- ESP32-S3 nodes collect raw Wi-Fi CSI frames.
- Nodes send
raw_csiJSON packets over UDP to the host machine. - The host Python app decodes I/Q CSI samples, computes amplitude/phase metrics, tracks per-node and per-link activity, and serves a live browser dashboard.
- ESP-NOW is used for controlled probe illumination: the Mac broadcasts TDMA schedules, and nodes take turns sending probe bursts so other nodes can capture link-specific CSI.
- Scenario logs can be saved as JSONL and replayed without hardware.
This project does not do firmware-side person counting in the active runtime path. The ESP32 firmware streams raw evidence; tools/live.py performs decode, DSP, fusion, dashboard state, logging, and replay on the Mac.
mini-observer/
├── CMakeLists.txt # ESP-IDF project definition
├── init-guide.md # Step-by-step bring-up guide
├── report.md # Architecture and processing notes
├── sdkconfig # ESP-IDF build configuration
├── main/
│ ├── main.c # Firmware entrypoint and runtime startup
│ ├── app_config.c/.h # sdkconfig + NVS configuration loading
│ ├── csi_collector.c/.h # ESP-IDF CSI callback and frame queueing
│ ├── raw_csi_telemetry.c/.h # UDP JSON/base64 raw CSI sender
│ ├── espnow_probe.c/.h # ESP-NOW TDMA probing and probe matching
│ ├── Kconfig.projbuild # Project configuration defaults
│ └── CMakeLists.txt # Firmware component registration
└── tools/
├── live.py # UDP receiver, CSI decoder, fusion, dashboard, replay
├── provision_node.py # Flash/provision one node with node-specific NVS
├── fleet_nodes.json # Node ID to role/label mapping
├── test_live.py # Host-side tests for live processing
└── test_provision_node.py # Host-side tests for provisioning helpers
Generated/local directories such as build/, logs/, .cache/, __pycache__/, and local provisioning secrets are ignored by git.
The checked-in fleet manifest defines four nodes:
| Node ID | Role | Label |
|---|---|---|
1 |
Mini | Mini 1 |
2 |
Mini | Mini 2 |
3 |
Mini | Mini 3 |
4 |
Nano | Nano 4 |
The firmware does not auto-detect board role. node_id stored in NVS is the identity source of truth; labels and roles come from tools/fleet_nodes.json on the host side.
flowchart TD
A[ESP32-S3 nodes] -->|Wi-Fi STA connection| B[Access Point]
A -->|UDP raw_csi JSON/base64| C[Mac tools/live.py]
C -->|Browser UI / SSE| D[Dashboard]
C -->|JSONL packets and snapshots| E[logs/*.jsonl]
E -->|Replay mode| C
C -->|UDP probe_schedule on port 5006| A
A -->|ESP-NOW probe bursts in TDMA slots| A
At boot, each ESP32 node:
- Initializes NVS.
- Loads defaults from
sdkconfigand overrides from NVS namespacecsi_cfg. - Connects to Wi-Fi as a station.
- Starts raw CSI UDP telemetry.
- Starts ESP-NOW probing on the current AP channel.
- Listens for Mac-generated
probe_schedulepackets on UDP port5006. - Starts CSI collection.
- Starts a small UDP traffic pump to keep AP traffic flowing.
- Queues CSI frames from the ESP-IDF CSI callback.
- Rate-limits, truncates, base64-encodes, and sends
raw_csipackets to the host.
tools/live.py:
- Listens for UDP
raw_csipackets, default0.0.0.0:5005. - Decodes base64 CSI into signed int8 I/Q pairs.
- Computes amplitude and phase arrays.
- Maintains rolling per-node CSI history.
- Maintains rolling per-link CSI history for ESP-NOW probe matches.
- Tracks health metrics such as CSI FPS, RSSI, sequence gaps, dropped frames, channel, and active subcarriers.
- Estimates occupancy and activity groups from Mac receive-time aligned windows.
- Broadcasts ESP-NOW TDMA probe schedules, default
255.255.255.255:5006. - Serves the web dashboard, default
http://127.0.0.1:8080. - Optionally writes JSONL logs and replays them later.
- ESP-IDF installed and activated.
- A build directory already configured for this project, or an ESP-IDF environment capable of configuring it.
- ESP32-S3-compatible boards.
- USB serial access to each board.
- Python 3.
- ESP-IDF tools, including:
nvs_partition_gen.pyesptool.py
The current helper script defaults to this local ESP-IDF installation:
/Users/atasesli/.espressif/v5.5.4/esp-idf
If your ESP-IDF installation lives elsewhere, pass explicit paths to tools/provision_node.py with --idf-python, --nvs-gen, and --esptool.
tools/live.py uses Python standard-library modules only. No Python package install is required for the dashboard code as currently written.
From the repository root:
IDF_PATH=/Users/atasesli/.espressif/v5.5.4/esp-idf cmake --build buildThis creates one shared firmware image. Node-specific identity and Wi-Fi settings are written separately into NVS during provisioning.
The project name in CMakeLists.txt is mini-observer, so the provisioning script expects this application binary:
build/mini-observer.bin
Create a local provision.env file in the repository root. This file should contain private Wi-Fi credentials and default provisioning values.
Do not commit real Wi-Fi credentials.
provision.envis intended to stay local.
Example:
WIFI_SSID="Your Wi-Fi Name"
WIFI_PASSWORD="Your Wi-Fi Password"
RAW_CSI_HZ=20
RAW_CSI_MAX_BYTES=384
ESPNOW_PROBE_ENABLED=1
PROBE_CONTROL_PORT=5006
PROBE_SLOT_MS=100
PROBE_GUARD_MS=20
PROBE_BURST_COUNT=4
PROBE_BURST_GAP_MS=12
PROBE_PAYLOAD_BYTES=96
By default, telemetry is sent to UDP broadcast:
255.255.255.255:5005
That means nodes usually do not need the Mac's changing DHCP IP address. If your router blocks broadcast, add a direct host IP:
TELEMETRY_HOST=192.168.1.15
Optional telemetry values:
TELEMETRY_PORT=5005
TELEMETRY_MS=100
Plug in exactly one board, find the serial port, then provision it with a fleet node ID.
On macOS:
ls /dev/cu.*Example provisioning command:
python3 tools/provision_node.py --port /dev/cu.usbmodem101 --node-id 1Repeat for every node:
python3 tools/provision_node.py --port /dev/cu.usbmodem101 --node-id 1
python3 tools/provision_node.py --port /dev/cu.usbmodem101 --node-id 2
python3 tools/provision_node.py --port /dev/cu.usbmodem101 --node-id 3
python3 tools/provision_node.py --port /dev/cu.usbmodem101 --node-id 4Physically label each board after flashing so the hardware matches tools/fleet_nodes.json.
tools/provision_node.py can override provision.env values from the CLI:
python3 tools/provision_node.py \
--port /dev/cu.usbmodem101 \
--node-id 4 \
--ssid "Lab WiFi" \
--password "secret" \
--telemetry-host 192.168.1.15 \
--raw-csi-hz 20 \
--raw-csi-max-bytes 384Useful options include:
| Option | Purpose |
|---|---|
--port |
USB serial port to flash. Required. |
--node-id |
Node ID from tools/fleet_nodes.json. Required. |
--env-file |
Alternate provisioning env file. |
--manifest |
Alternate node manifest. |
--build-dir |
Alternate ESP-IDF build directory. |
--ssid / --password |
Wi-Fi credential overrides. |
--telemetry-host |
UDP telemetry destination host. |
--telemetry-port |
UDP telemetry destination port. |
--raw-csi-hz |
Raw CSI send rate limit. |
--raw-csi-max-bytes |
Maximum CSI bytes included per packet. |
--enable-espnow-probe |
Write espnow_probe_en=1 to NVS. |
--disable-espnow-probe |
Write espnow_probe_en=0 to NVS. |
--disable-telemetry |
Write telemetry_en=0 to NVS. |
--dry-run |
Print generated NVS CSV and flash command without flashing. |
Start the host dashboard before or after powering the nodes:
python3 tools/live.py --udp-port 5005 --http-port 8080Open:
http://127.0.0.1:8080
The dashboard shows node health, CSI FPS, RSSI, sequence gaps, motion energy, baseline state, presence score, active subcarriers, link matrix state, and consensus occupancy fields.
Common options:
| Option | Default | Purpose |
|---|---|---|
--udp-host |
0.0.0.0 |
UDP listen address for raw CSI packets. |
--udp-port |
5005 |
UDP listen port for raw CSI packets. |
--host |
127.0.0.1 |
HTTP dashboard bind host. |
--http-port |
8080 |
HTTP dashboard port. |
--offline-after |
3.0 |
Seconds before a silent node is marked offline. |
--manifest |
tools/fleet_nodes.json |
Node role/label manifest. |
--log-jsonl |
unset | Write raw packets and snapshots to JSONL. |
--scenario |
unset | Scenario label written into JSONL logs. |
--replay |
unset | Replay a JSONL log instead of listening on UDP. |
--replay-speed |
1.0 |
Replay speed multiplier; 0 disables sleeping. |
--sensitivity |
normal |
Detection sensitivity: low, normal, or high. |
--probe-control-host |
255.255.255.255 |
Destination for TDMA schedule broadcasts. |
--probe-control-port |
5006 |
UDP port for TDMA schedule broadcasts. |
--probe-slot-ms |
100 |
ESP-NOW TDMA slot duration sent to nodes. |
--probe-enabled |
enabled | Broadcast ESP-NOW schedules. |
--no-probe-enabled |
disabled | Disable schedule broadcasts for raw-CSI-only debugging. |
Create the logs directory if needed:
mkdir -p logsRecord an empty-room baseline run:
python3 tools/live.py \
--udp-port 5005 \
--http-port 8080 \
--scenario empty_room \
--log-jsonl logs/empty_room.jsonlRecord other scenarios:
python3 tools/live.py --udp-port 5005 --http-port 8080 --scenario one_walking --log-jsonl logs/one_walking.jsonl
python3 tools/live.py --udp-port 5005 --http-port 8080 --scenario one_sitting --log-jsonl logs/one_sitting.jsonl
python3 tools/live.py --udp-port 5005 --http-port 8080 --scenario two_moving_separate --log-jsonl logs/two_moving_separate.jsonl
python3 tools/live.py --udp-port 5005 --http-port 8080 --scenario two_close_together --log-jsonl logs/two_close_together.jsonl
python3 tools/live.py --udp-port 5005 --http-port 8080 --scenario one_moving_one_stationary --log-jsonl logs/one_moving_one_stationary.jsonlUseful scenario labels:
empty_roomone_walkingone_sittingtwo_moving_separatetwo_close_togetherone_moving_one_stationary
Replay uses the logged host receive timestamps to feed raw CSI packets back through the same decode, DSP, fusion, and dashboard path.
python3 tools/live.py --replay logs/one_walking.jsonl --http-port 8080Replay as fast as possible:
python3 tools/live.py --replay logs/one_walking.jsonl --replay-speed 0 --http-port 8080Each telemetry packet is one UTF-8 JSON object sent over UDP:
{
"type": "raw_csi",
"version": 1,
"node_id": 1,
"seq": 123,
"ts_us": 123456789,
"rssi": -52,
"channel": 6,
"csi_len": 384,
"first_word_invalid": false,
"dropped_frames": 0,
"src_mac": "aa:bb:cc:dd:ee:ff",
"dst_mac": "ff:ff:ff:ff:ff:ff",
"rx_seq": 1234,
"probe_match": true,
"probe_tx_node_id": 2,
"probe_round": 88,
"probe_slot": 1,
"probe_seq": 920,
"probe_burst_index": 2,
"encoding": "base64",
"csi": "..."
}Notes:
ts_usis local to each ESP32 and is not comparable across nodes.- The Mac adds host receive timestamps and uses them for cross-node alignment.
first_word_invalid=truetells the host decoder to discard the first four CSI bytes before I/Q conversion.probe_match=truemeans the CSI frame matched an ESP-NOW probe packet from another node.probe_match=falsemeans ordinary non-probe CSI;probe_tx_node_idis0in that case.- Version 1 uses JSON and base64 for debuggability rather than bandwidth efficiency.
The host broadcasts ESP-NOW TDMA schedules over UDP, default port 5006:
{
"type": "probe_schedule",
"version": 1,
"schedule_seq": 42,
"start_after_ms": 200,
"slot_ms": 100,
"guard_ms": 20,
"burst_count": 4,
"burst_gap_ms": 12,
"probe_payload_bytes": 96,
"nodes": [1, 2, 3, 4]
}Only online nodes are included in schedules generated by tools/live.py.
Provisioned NVS namespace:
csi_cfg
Supported keys:
| NVS key | Meaning |
|---|---|
node_id |
Fleet identity for the board. |
telemetry_en |
Enable/disable raw CSI UDP telemetry. |
ssid |
Wi-Fi SSID override. |
password |
Wi-Fi password override. |
telemetry_host |
UDP telemetry destination host. |
telemetry_port |
UDP telemetry destination port. |
telemetry_ms |
Legacy/interval telemetry setting. |
raw_csi_hz |
Raw CSI UDP send rate limit. |
raw_csi_max_b |
Maximum raw CSI bytes per UDP packet. |
espnow_probe_en |
Enable/disable ESP-NOW probing. |
probe_ctrl_port |
UDP schedule listener port. |
probe_slot_ms |
TDMA slot duration. |
probe_guard_ms |
TDMA guard duration. |
probe_burst_cnt |
Number of ESP-NOW probe packets per slot. |
probe_burst_gap |
Gap between packets in a probe burst. |
probe_payload_b |
ESP-NOW probe payload size. |
Important defaults from main/Kconfig.projbuild:
| Setting | Default |
|---|---|
| Wi-Fi SSID | wifi-densepose |
| Telemetry enabled | yes |
| Telemetry host | 255.255.255.255 |
| Telemetry port | 5005 |
| Raw CSI rate | 20 Hz |
| Raw CSI max bytes | 384 |
| ESP-NOW probing | enabled |
| Probe control port | 5006 |
| Probe slot | 100 ms |
| Probe guard | 20 ms |
| Probe burst count | 4 |
| Probe burst gap | 12 ms |
| Probe payload bytes | 96 |
The dashboard and snapshots expose Mac-side consensus fields such as:
mac_room_occupiedmac_estimated_countmac_confidencemac_active_nodesmac_groupsmac_link_motion_countmac_active_linksmac_link_groupsmac_link_matrixmac_count_basismac_note
Treat these values as RF activity estimates, not ground truth. Close people can collapse into one RF event, and environmental multipath can affect both motion and static-presence estimates. Scenario logs are the main tool for tuning thresholds and comparing behavior across room layouts.
Run host-side unit tests from the repository root:
python3 -m unittest discover -s tools -p 'test_*.py'Or run individual files:
python3 tools/test_live.py
python3 tools/test_provision_node.pyThe tests cover CSI packet decoding, state updates, link matrix behavior, occupancy grouping logic, replay helpers, provisioning NVS generation, manifest loading, and flash command construction.
- Confirm the Mac is on the same Wi-Fi network as the ESP32 nodes.
- Confirm the nodes were provisioned with the correct SSID/password.
- Confirm
tools/live.pyis listening on UDP port5005. - Check whether your router blocks broadcast. If so, provision
TELEMETRY_HOSTwith the Mac's IP address. - Check firewall settings on macOS for inbound UDP traffic.
- Confirm the node is connected to Wi-Fi and staying on the AP channel.
- Keep ESP-NOW probing enabled for link illumination unless debugging raw CSI only.
- Make sure there is enough Wi-Fi traffic; the firmware traffic pump sends periodic UDP packets to help keep CSI flowing.
- Try lowering
RAW_CSI_HZorRAW_CSI_MAX_BYTESif UDP load is too high.
- Make sure
ESPNOW_PROBE_ENABLED=1was provisioned. - Make sure
tools/live.pyis running with probe scheduling enabled. - Confirm UDP schedules can reach nodes on port
5006. - Use
--no-probe-enabledonly for raw-CSI-only debugging.
Build first:
IDF_PATH=/Users/atasesli/.espressif/v5.5.4/esp-idf cmake --build buildThen verify these files exist:
build/bootloader/bootloader.bin
build/partition_table/partition-table.bin
build/mini-observer.bin
Pass explicit paths:
python3 tools/provision_node.py \
--port /dev/cu.usbmodem101 \
--node-id 1 \
--idf-python /path/to/esp-idf-python \
--nvs-gen /path/to/nvs_partition_gen.py \
--esptool /path/to/esptool.pyinit-guide.mdcontains the concise operator bring-up flow.report.mdcontains implementation notes about the current raw CSI architecture, packet contract, and Mac-side processing model.

