Skip to content

ata-sesli/mini-observer

Repository files navigation

Mini Observer

Summary

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.

Dashboard Screenshots

The Python web dashboard served by tools/live.py shows live CSI-derived occupancy, motion, baseline, and node/link health information in the browser.

ESP32 Live Monitor dashboard showing occupancy summary, live charts, and zone evidence

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.

ESP32 Live Monitor dashboard showing zone evidence, ESP-NOW link matrix, and node telemetry

What This Project Does

Mini Observer is designed around a Mac-compute, ESP32-sensor architecture:

  • ESP32-S3 nodes collect raw Wi-Fi CSI frames.
  • Nodes send raw_csi JSON 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.

Repository Layout

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.

Hardware Model

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.

Runtime Architecture

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
Loading

Firmware Flow

At boot, each ESP32 node:

  1. Initializes NVS.
  2. Loads defaults from sdkconfig and overrides from NVS namespace csi_cfg.
  3. Connects to Wi-Fi as a station.
  4. Starts raw CSI UDP telemetry.
  5. Starts ESP-NOW probing on the current AP channel.
  6. Listens for Mac-generated probe_schedule packets on UDP port 5006.
  7. Starts CSI collection.
  8. Starts a small UDP traffic pump to keep AP traffic flowing.
  9. Queues CSI frames from the ESP-IDF CSI callback.
  10. Rate-limits, truncates, base64-encodes, and sends raw_csi packets to the host.

Host Flow

tools/live.py:

  1. Listens for UDP raw_csi packets, default 0.0.0.0:5005.
  2. Decodes base64 CSI into signed int8 I/Q pairs.
  3. Computes amplitude and phase arrays.
  4. Maintains rolling per-node CSI history.
  5. Maintains rolling per-link CSI history for ESP-NOW probe matches.
  6. Tracks health metrics such as CSI FPS, RSSI, sequence gaps, dropped frames, channel, and active subcarriers.
  7. Estimates occupancy and activity groups from Mac receive-time aligned windows.
  8. Broadcasts ESP-NOW TDMA probe schedules, default 255.255.255.255:5006.
  9. Serves the web dashboard, default http://127.0.0.1:8080.
  10. Optionally writes JSONL logs and replays them later.

Prerequisites

Firmware Build/Flash

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

Host Dashboard

tools/live.py uses Python standard-library modules only. No Python package install is required for the dashboard code as currently written.

Build

From the repository root:

IDF_PATH=/Users/atasesli/.espressif/v5.5.4/esp-idf cmake --build build

This 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

Provisioning Configuration

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.env is 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

Flash One Node

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 1

Repeat 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 4

Physically label each board after flashing so the hardware matches tools/fleet_nodes.json.

Provisioning Overrides

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 384

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

Run the Live Dashboard

Start the host dashboard before or after powering the nodes:

python3 tools/live.py --udp-port 5005 --http-port 8080

Open:

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.

Dashboard CLI Options

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.

Logging Scenarios

Create the logs directory if needed:

mkdir -p logs

Record an empty-room baseline run:

python3 tools/live.py \
  --udp-port 5005 \
  --http-port 8080 \
  --scenario empty_room \
  --log-jsonl logs/empty_room.jsonl

Record 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.jsonl

Useful scenario labels:

  • empty_room
  • one_walking
  • one_sitting
  • two_moving_separate
  • two_close_together
  • one_moving_one_stationary

Replay Logs Without Hardware

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 8080

Replay as fast as possible:

python3 tools/live.py --replay logs/one_walking.jsonl --replay-speed 0 --http-port 8080

UDP Protocol

Node to Host: raw_csi

Each 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_us is 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=true tells the host decoder to discard the first four CSI bytes before I/Q conversion.
  • probe_match=true means the CSI frame matched an ESP-NOW probe packet from another node.
  • probe_match=false means ordinary non-probe CSI; probe_tx_node_id is 0 in that case.
  • Version 1 uses JSON and base64 for debuggability rather than bandwidth efficiency.

Host to Nodes: probe_schedule

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.

NVS Configuration

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

Dashboard Interpretation

The dashboard and snapshots expose Mac-side consensus fields such as:

  • mac_room_occupied
  • mac_estimated_count
  • mac_confidence
  • mac_active_nodes
  • mac_groups
  • mac_link_motion_count
  • mac_active_links
  • mac_link_groups
  • mac_link_matrix
  • mac_count_basis
  • mac_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.

Testing

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

The tests cover CSI packet decoding, state updates, link matrix behavior, occupancy grouping logic, replay helpers, provisioning NVS generation, manifest loading, and flash command construction.

Troubleshooting

Dashboard shows no nodes

  • 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.py is listening on UDP port 5005.
  • Check whether your router blocks broadcast. If so, provision TELEMETRY_HOST with the Mac's IP address.
  • Check firewall settings on macOS for inbound UDP traffic.

Node appears online but CSI FPS is zero or very low

  • 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_HZ or RAW_CSI_MAX_BYTES if UDP load is too high.

ESP-NOW link matrix is empty

  • Make sure ESPNOW_PROBE_ENABLED=1 was provisioned.
  • Make sure tools/live.py is running with probe scheduling enabled.
  • Confirm UDP schedules can reach nodes on port 5006.
  • Use --no-probe-enabled only for raw-CSI-only debugging.

Flashing fails because artifacts are missing

Build first:

IDF_PATH=/Users/atasesli/.espressif/v5.5.4/esp-idf cmake --build build

Then verify these files exist:

build/bootloader/bootloader.bin
build/partition_table/partition-table.bin
build/mini-observer.bin

Provisioning fails because ESP-IDF paths are different

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

Related Documentation

  • init-guide.md contains the concise operator bring-up flow.
  • report.md contains implementation notes about the current raw CSI architecture, packet contract, and Mac-side processing model.

About

Camera-free room awareness system that uses ESP32-S3 devices to detect occupancy and movement in real time.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors