Turn a $7 microcontroller into a privacy-first human sensing node.
This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 and transforms it into real-time presence detection, vital sign monitoring, and programmable sensing -- all without cameras or wearables. Part of the WiFi-DensePose project.
Capability Method Performance CSI streaming Per-subcarrier I/Q capture over UDP ~20 Hz, ADR-018 binary format Breathing detection Bandpass 0.1-0.5 Hz, zero-crossing BPM 6-30 BPM Heart rate Bandpass 0.8-2.0 Hz, zero-crossing BPM 40-120 BPM Presence sensing Phase variance + adaptive calibration < 1 ms latency Fall detection Phase acceleration threshold Configurable sensitivity Programmable sensing WASM modules loaded over HTTP Hot-swap, no reflash
For users who want to get running fast. Detailed explanations follow in later sections.
# From the repository root:
MSYS_NO_PATHCONV=1 docker run --rm \
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
espressif/idf:v5.2 bash -c \
"rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build"python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
write_flash --flash_mode dio --flash_size 8MB \
0x0 firmware/esp32-csi-node/build/bootloader/bootloader.bin \
0x8000 firmware/esp32-csi-node/build/partition_table/partition-table.bin \
0x10000 firmware/esp32-csi-node/build/esp32-csi-node.binpython scripts/provision.py --port COM7 \
--ssid "YourSSID" --password "YourPass" --target-ip 192.168.1.20cargo run -p wifi-densepose-sensing-server -- --http-port 3000 --source autoNavigate to http://localhost:3000 in your browser.
curl -X POST http://<ESP32_IP>:8032/wasm/upload --data-binary @gesture.rvf
curl http://<ESP32_IP>:8032/wasm/list| Component | Specification | Notes |
|---|---|---|
| SoC | ESP32-S3 (QFN56) | Dual-core Xtensa LX7, 240 MHz |
| Flash | 8 MB | ~943 KB used by firmware |
| PSRAM | 8 MB | 640 KB used for WASM arenas |
| USB bridge | Silicon Labs CP210x | Install the CP210x driver |
| Recommended boards | ESP32-S3-DevKitC-1, XIAO ESP32-S3 | Any ESP32-S3 with 8 MB flash works |
| Deployment | 3-6 nodes per room | Multistatic mesh for 360-degree coverage |
Tip: A single node provides presence and vital signs along its line of sight. Multiple nodes (3-6) create a multistatic mesh that resolves 3D pose with <30 mm jitter and zero identity swaps.
The firmware implements a tiered processing pipeline. Each tier builds on the previous one. The active tier is selectable at compile time (Kconfig) or at runtime (NVS) without reflashing.
ESP32-S3 CSI Node
+--------------------------------------------------------------------------+
| Core 0 (WiFi) | Core 1 (DSP) |
| | |
| WiFi STA + CSI callback | SPSC ring buffer consumer |
| Channel hopping (ADR-029) | Tier 0: Raw passthrough |
| NDP injection | Tier 1: Phase unwrap, Welford, top-K |
| TDM slot management | Tier 2: Vitals, presence, fall detect |
| | Tier 3: WASM module dispatch |
+--------------------------------------------------------------------------+
| NVS config | OTA server (8032) | UDP sender | Power management |
+--------------------------------------------------------------------------+
The default, production-stable baseline. Captures CSI frames from the WiFi driver and streams them over UDP in the ADR-018 binary format.
- Magic:
0xC5110001 - Rate: ~20 Hz per channel
- Payload: 20-byte header + I/Q pairs (2 bytes per subcarrier per antenna)
- Bandwidth: ~5 KB/s per node (64 subcarriers, 1 antenna)
Adds on-device signal conditioning to reduce bandwidth and improve signal quality.
- Phase unwrapping -- removes 2-pi discontinuities
- Welford running statistics -- incremental mean and variance per subcarrier
- Top-K subcarrier selection -- tracks only the K highest-variance subcarriers
- Delta compression -- XOR + RLE encoding reduces bandwidth by ~70%
Adds real-time health and safety monitoring.
- Breathing rate -- biquad IIR bandpass 0.1-0.5 Hz, zero-crossing BPM (6-30 BPM)
- Heart rate -- biquad IIR bandpass 0.8-2.0 Hz, zero-crossing BPM (40-120 BPM)
- Presence detection -- adaptive threshold calibration (60 s ambient learning)
- Fall detection -- phase acceleration exceeds configurable threshold
- Multi-person estimation -- subcarrier group clustering (up to 4 persons)
- Vitals packet -- 32-byte UDP packet at 1 Hz (magic
0xC5110002)
Turns the ESP32 from a fixed-function sensor into a programmable sensing computer. Instead of reflashing firmware to change algorithms, you upload new sensing logic as small WASM modules -- compiled from Rust, packaged in signed RVF containers.
See the WASM Programmable Sensing section for full details.
All packets are sent over UDP to the configured aggregator. The magic number in the first 4 bytes identifies the packet type.
| Magic | Name | Rate | Size | Contents |
|---|---|---|---|---|
0xC5110001 |
CSI Frame (ADR-018) | ~20 Hz | Variable | Raw I/Q per subcarrier per antenna |
0xC5110002 |
Vitals Packet | 1 Hz | 32 bytes | Presence, breathing BPM, heart rate, fall flag, occupancy |
0xC5110004 |
WASM Output | Event-driven | Variable | Custom events from WASM modules (u8 type + f32 value) |
Offset Size Field
0 4 Magic: 0xC5110001
4 1 Node ID
5 1 Number of antennas
6 2 Number of subcarriers (LE u16)
8 4 Frequency MHz (LE u32)
12 4 Sequence number (LE u32)
16 1 RSSI (i8)
17 1 Noise floor (i8)
18 2 Reserved
20 N*2 I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
Offset Size Field
0 4 Magic: 0xC5110002
4 1 Node ID
5 1 Flags (bit0=presence, bit1=fall, bit2=motion)
6 2 Breathing rate (BPM * 100, fixed-point)
8 4 Heart rate (BPM * 10000, fixed-point)
12 1 RSSI (i8)
13 1 Number of detected persons
14 2 Reserved
16 4 Motion energy (f32)
20 4 Presence score (f32)
24 4 Timestamp (ms since boot)
28 4 Reserved
| Component | Version | Purpose |
|---|---|---|
| Docker Desktop | 28.x+ | Cross-compile firmware in ESP-IDF container |
| esptool | 5.x+ | Flash firmware to ESP32 (pip install esptool) |
| Python 3.10+ | 3.10+ | Provisioning script, serial monitor |
| ESP32-S3 board | -- | Target hardware |
| CP210x driver | -- | USB-UART bridge driver (download) |
Why Docker? ESP-IDF does NOT work from Git Bash/MSYS2 on Windows. The
idf.pyscript detects theMSYSTEMenvironment variable and skipsmain(). Even removingMSYSTEM, thecmd.exesubprocess injectsdoskeyaliases that break the ninja linker. Docker is the only reliable cross-platform build method.
# From the repository root:
MSYS_NO_PATHCONV=1 docker run --rm \
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
espressif/idf:v5.2 bash -c \
"rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build"The MSYS_NO_PATHCONV=1 prefix prevents Git Bash from mangling the /project path to C:/Program Files/Git/project.
Build output:
build/bootloader/bootloader.bin-- second-stage bootloaderbuild/partition_table/partition-table.bin-- flash partition layoutbuild/esp32-csi-node.bin-- application firmware
To change Kconfig settings before building:
MSYS_NO_PATHCONV=1 docker run --rm -it \
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
espressif/idf:v5.2 bash -c \
"idf.py set-target esp32s3 && idf.py menuconfig"Or create/edit sdkconfig.defaults before building:
CONFIG_IDF_TARGET="esp32s3"
CONFIG_ESP_WIFI_CSI_ENABLED=y
CONFIG_CSI_NODE_ID=1
CONFIG_CSI_WIFI_SSID="wifi-densepose"
CONFIG_CSI_WIFI_PASSWORD=""
CONFIG_CSI_TARGET_IP="192.168.1.100"
CONFIG_CSI_TARGET_PORT=5005
CONFIG_EDGE_TIER=2
CONFIG_WASM_MAX_MODULES=4
CONFIG_WASM_VERIFY_SIGNATURE=yFind your serial port: COM7 on Windows, /dev/ttyUSB0 on Linux, /dev/cu.SLAB_USBtoUART on macOS.
python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
write_flash --flash_mode dio --flash_size 8MB \
0x0 firmware/esp32-csi-node/build/bootloader/bootloader.bin \
0x8000 firmware/esp32-csi-node/build/partition_table/partition-table.bin \
0x10000 firmware/esp32-csi-node/build/esp32-csi-node.binpython -m serial.tools.miniterm COM7 115200Expected output after boot:
I (321) main: ESP32-S3 CSI Node (ADR-018) -- Node ID: 1
I (345) main: WiFi STA initialized, connecting to SSID: wifi-densepose
I (1023) main: Connected to WiFi
I (1025) main: CSI streaming active -> 192.168.1.100:5005 (edge_tier=2, OTA=ready, WASM=ready)
All settings can be changed at runtime via Non-Volatile Storage (NVS) without reflashing the firmware. NVS values override Kconfig defaults.
The easiest way to write NVS settings:
python scripts/provision.py --port COM7 \
--ssid "MyWiFi" \
--password "MyPassword" \
--target-ip 192.168.1.20| Key | Type | Default | Description |
|---|---|---|---|
ssid |
string | wifi-densepose |
WiFi SSID |
password |
string | (empty) | WiFi password |
target_ip |
string | 192.168.1.100 |
Aggregator server IP address |
target_port |
u16 | 5005 |
Aggregator UDP port |
node_id |
u8 | 1 |
Unique node identifier (0-255) |
| Key | Type | Default | Description |
|---|---|---|---|
hop_count |
u8 | 1 |
Number of channels to hop (1 = single-channel mode) |
chan_list |
blob | [6] |
WiFi channel numbers for hopping |
dwell_ms |
u32 | 50 |
Dwell time per channel in milliseconds |
tdm_slot |
u8 | 0 |
This node's TDM slot index (0-based) |
tdm_nodes |
u8 | 1 |
Total number of nodes in the TDM schedule |
| Key | Type | Default | Description |
|---|---|---|---|
edge_tier |
u8 | 2 |
Processing tier: 0=raw, 1=basic DSP, 2=full pipeline |
pres_thresh |
u16 | auto | Presence threshold (x1000). 0 = auto-calibrate from 60 s ambient |
fall_thresh |
u16 | 2000 |
Fall detection threshold (x1000). 2000 = 2.0 rad/s^2 |
vital_win |
u16 | 256 |
Phase history window depth (frames) |
vital_int |
u16 | 1000 |
Vitals packet send interval (ms) |
subk_count |
u8 | 8 |
Top-K subcarrier count for variance tracking |
power_duty |
u8 | 100 |
Power duty cycle percentage (10-100). 100 = always on |
| Key | Type | Default | Description |
|---|---|---|---|
wasm_max |
u8 | 4 |
Maximum concurrent WASM module slots (1-8) |
wasm_verify |
u8 | 1 |
Require Ed25519 signature verification for uploads |
Three configuration menus are available via idf.py menuconfig:
Basic WiFi and network settings: SSID, password, channel, node ID, aggregator IP/port.
Processing tier selection, vitals interval, top-K subcarrier count, fall detection threshold, power duty cycle.
Maximum module slots, Ed25519 signature verification toggle, timer interval for on_timer() callbacks.
Tier 3 turns the ESP32 from a fixed-function sensor into a programmable sensing computer. Instead of reflashing firmware to change algorithms, you upload new sensing logic as small WASM modules. These modules are:
- Compiled from Rust using the
wasm32-unknown-unknowntarget - Packaged in signed RVF containers with Ed25519 signatures
- Uploaded over HTTP to the running device (no physical access needed)
- Executed per-frame (~20 Hz) by the WASM3 interpreter after Tier 2 DSP completes
RVF is a signed container that wraps a WASM binary with metadata for tamper detection and authenticity.
+------------------+-------------------+------------------+------------------+
| Header (32 B) | Manifest (96 B) | WASM payload | Ed25519 sig (64B)|
+------------------+-------------------+------------------+------------------+
Total overhead: 192 bytes (32-byte header + 96-byte manifest + 64-byte signature).
| Field | Size | Contents |
|---|---|---|
| Header | 32 bytes | Magic (RVF\x01), format version, section sizes, flags |
| Manifest | 96 bytes | Module name, author, capabilities bitmask, budget request, SHA-256 build hash, event schema version |
| WASM payload | Variable | The compiled .wasm binary (max 128 KB) |
| Signature | 64 bytes | Ed25519 signature covering header + manifest + WASM |
WASM modules import functions from the "csi" namespace to access sensor data:
| Function | Signature | Description |
|---|---|---|
csi_get_phase |
(i32) -> f32 |
Phase (radians) for subcarrier index |
csi_get_amplitude |
(i32) -> f32 |
Amplitude for subcarrier index |
csi_get_variance |
(i32) -> f32 |
Running variance (Welford) for subcarrier |
csi_get_bpm_breathing |
() -> f32 |
Breathing rate BPM from Tier 2 |
csi_get_bpm_heartrate |
() -> f32 |
Heart rate BPM from Tier 2 |
csi_get_presence |
() -> i32 |
Presence flag (0 = empty, 1 = present) |
csi_get_motion_energy |
() -> f32 |
Motion energy scalar |
csi_get_n_persons |
() -> i32 |
Number of detected persons |
csi_get_timestamp |
() -> i32 |
Milliseconds since boot |
csi_emit_event |
(i32, f32) |
Emit a typed event to the host (sent over UDP) |
csi_log |
(i32, i32) |
Debug log from WASM (pointer + length) |
csi_get_phase_history |
(i32, i32) -> i32 |
Copy phase ring buffer into WASM memory |
Every WASM module must export these three functions:
| Export | Called | Purpose |
|---|---|---|
on_init() |
Once, when started | Allocate state, initialize algorithms |
on_frame(n_subcarriers: i32) |
Per CSI frame (~20 Hz) | Process sensor data, emit events |
on_timer() |
At configurable interval (default 1 s) | Periodic housekeeping, aggregated output |
All endpoints are served on port 8032 (shared with the OTA update server).
| Method | Path | Description |
|---|---|---|
POST |
/wasm/upload |
Upload an RVF container or raw .wasm binary (max 128 KB) |
GET |
/wasm/list |
List all module slots with state, telemetry, and RVF metadata |
POST |
/wasm/start/:id |
Start a loaded module (calls on_init) |
POST |
/wasm/stop/:id |
Stop a running module |
DELETE |
/wasm/:id |
Unload a module and free its PSRAM arena |
The wifi-densepose-wasm-edge Rust crate provides three flagship modules:
| Module | File | Description |
|---|---|---|
| gesture | gesture.rs |
DTW template matching for wave, push, pull, and swipe gestures |
| coherence | coherence.rs |
Phase phasor coherence monitoring with hysteresis gate |
| adversarial | adversarial.rs |
Signal anomaly detection (phase jumps, flatlines, energy spikes) |
Build all modules:
cargo build -p wifi-densepose-wasm-edge --target wasm32-unknown-unknown --release| Protection | Detail |
|---|---|
| Memory isolation | Fixed 160 KB PSRAM arenas per slot (no heap fragmentation) |
| Budget guard | 10 ms per-frame default; auto-stop after 10 consecutive budget faults |
| Signature verification | Ed25519 enabled by default; disable with wasm_verify=0 in NVS for development |
| Hash verification | SHA-256 of WASM payload checked against RVF manifest |
| Slot limit | Maximum 4 concurrent module slots (configurable to 8) |
| Per-module telemetry | Frame count, event count, mean/max execution time, budget faults |
| Component | SRAM | PSRAM | Flash |
|---|---|---|---|
| Base firmware (Tier 0) | ~12 KB | -- | ~820 KB |
| Tier 1-2 DSP pipeline | ~10 KB | -- | ~33 KB |
| WASM3 interpreter | ~10 KB | -- | ~100 KB |
| WASM arenas (x4 slots) | -- | 640 KB | -- |
| Host API + HTTP upload | ~3 KB | -- | ~23 KB |
| Total | ~35 KB | 640 KB | ~943 KB |
- PSRAM remaining: 7.36 MB (available for future use)
- Flash partition: 1 MB OTA slot (6% headroom at current binary size)
- SRAM remaining: ~280 KB (FreeRTOS + WiFi stack uses the rest)
| File | Description |
|---|---|
main/main.c |
Application entry point: NVS init, WiFi STA, CSI collector, edge pipeline, OTA server, WASM runtime init |
main/csi_collector.c / .h |
WiFi CSI frame capture, ADR-018 binary serialization, channel hopping, NDP injection |
main/stream_sender.c / .h |
UDP socket management and packet transmission to aggregator |
main/nvs_config.c / .h |
Runtime configuration: loads Kconfig defaults, overrides from NVS |
main/edge_processing.c / .h |
Tier 0-2 DSP pipeline: SPSC ring buffer, biquad IIR filters, Welford stats, BPM extraction, presence, fall detection |
main/ota_update.c / .h |
HTTP OTA firmware update server on port 8032 |
main/power_mgmt.c / .h |
Battery-aware light sleep duty cycling |
main/wasm_runtime.c / .h |
WASM3 interpreter: module slots, host API bindings, budget guard, per-frame dispatch |
main/wasm_upload.c / .h |
HTTP endpoints for WASM module upload, list, start, stop, delete |
main/rvf_parser.c / .h |
RVF container parser: header validation, manifest extraction, SHA-256 hash verification |
components/wasm3/ |
WASM3 interpreter library (MIT license, ~100 KB flash, ~10 KB RAM) |
ESP32-S3 Node Host Machine
+------------------------------------------+ +---------------------------+
| Core 0 (WiFi) Core 1 (DSP) | | |
| | | |
| WiFi STA --------> SPSC Ring Buffer | | |
| CSI Callback | | | |
| Channel Hop v | | |
| NDP Inject +-- Tier 0: Raw ADR-018 ---------> UDP/5005 |
| | Tier 1: Phase + Welford | | Sensing Server |
| | Tier 2: Vitals + Fall ---------> (vitals) |
| | Tier 3: WASM Dispatch ---------> (events) |
| + | | | |
| NVS Config OTA/WASM HTTP (port 8032) | | v |
| Power Mgmt POST /ota | | Web UI (:3000) |
| POST /wasm/upload | | Pose + Vitals + Alerts |
+------------------------------------------+ +---------------------------+
The firmware is continuously verified by .github/workflows/firmware-ci.yml:
| Step | Check | Threshold |
|---|---|---|
| Docker build | Full compile with ESP-IDF v5.4 container | Must succeed |
| Binary size gate | esp32-csi-node.bin file size |
Must be < 950 KB |
| Flash image integrity | Partition table magic, bootloader presence, non-padding content | Warnings on failure |
| Artifact upload | Bootloader + partition table + app binary | 30-day retention |
Test the firmware without physical hardware using Espressif's QEMU fork. A compile-time mock CSI generator (CONFIG_CSI_MOCK_ENABLED=y) replaces the real WiFi CSI callback with a timer-driven synthetic frame injector that exercises the full edge processing pipeline -- biquad filtering, Welford stats, top-K selection, presence/fall detection, and vitals extraction.
- ESP-IDF v5.4 -- installation guide
- Espressif QEMU fork -- must be built from source (not in Ubuntu packages):
git clone --depth 1 https://github.com/espressif/qemu.git /tmp/qemu
cd /tmp/qemu
./configure --target-list=xtensa-softmmu --enable-slirp
make -j$(nproc)
sudo cp build/qemu-system-xtensa /usr/local/bin/Three commands to go from source to running firmware in QEMU:
cd firmware/esp32-csi-node
# 1. Build with mock CSI enabled (replaces real WiFi CSI with synthetic frames)
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.qemu" build
# 2. Create merged flash image
esptool.py --chip esp32s3 merge_bin -o build/qemu_flash.bin \
--flash_mode dio --flash_freq 80m --flash_size 8MB \
0x0 build/bootloader/bootloader.bin \
0x8000 build/partition_table/partition-table.bin \
0x20000 build/esp32-csi-node.bin
# 3. Run in QEMU
qemu-system-xtensa -machine esp32s3 -nographic \
-drive file=build/qemu_flash.bin,if=mtd,format=raw \
-serial mon:stdio -no-rebootThe firmware boots FreeRTOS, loads NVS config, starts the mock CSI generator at 20 Hz, and runs all edge processing. UART output shows log lines that can be validated automatically.
The mock generator cycles through 10 scenarios that exercise every edge processing path:
| ID | Scenario | Duration | Expected Output |
|---|---|---|---|
| 0 | Empty room | 10 s | presence=0, motion_energy < thresh |
| 1 | Static person | 10 s | presence=1, breathing_rate in [10, 25], fall=0 |
| 2 | Walking person | 10 s | presence=1, motion_energy > 0.5, fall=0 |
| 3 | Fall event | 5 s | fall=1 flag set, motion_energy spike |
| 4 | Multi-person | 15 s | n_persons=2, independent breathing rates |
| 5 | Channel sweep | 5 s | Frames on channels 1, 6, 11 in sequence |
| 6 | MAC filter test | 5 s | Frames with wrong MAC dropped (counter check) |
| 7 | Ring buffer overflow | 3 s | 1000 frames in 100 ms burst, graceful drop |
| 8 | Boundary RSSI | 5 s | RSSI sweeps -127 to 0, no crash |
| 9 | Zero-length frame | 2 s | iq_len=0 frames, serialize returns 0 |
14 NVS configurations are tested in CI to ensure all config paths work correctly:
| Config | NVS Values | Validates |
|---|---|---|
default |
(empty NVS) | Kconfig fallback paths |
wifi-only |
ssid, password | Basic provisioning |
full-adr060 |
channel=6, filter_mac=AA:BB:CC:DD:EE:FF | Channel override + MAC filter |
edge-tier0 |
edge_tier=0 | Raw CSI passthrough (no DSP) |
edge-tier1 |
edge_tier=1, pres_thresh=100, fall_thresh=2000 | Stats-only mode |
edge-tier2-custom |
edge_tier=2, vital_win=128, vital_int=500, subk_count=16 | Full vitals with custom params |
tdm-3node |
tdm_slot=1, tdm_nodes=3, node_id=1 | TDM mesh timing |
wasm-signed |
wasm_max=4, wasm_verify=1, wasm_pubkey=<32B> | WASM with Ed25519 verification |
wasm-unsigned |
wasm_max=2, wasm_verify=0 | WASM without signature check |
5ghz-channel |
channel=36, filter_mac=... | 5 GHz CSI collection |
boundary-max |
target_port=65535, node_id=255, top_k=32, vital_win=256 | Max-range values |
boundary-min |
target_port=1, node_id=0, top_k=1, vital_win=32 | Min-range values |
power-save |
power_duty=10, edge_tier=0 | Low-power mode |
corrupt-nvs |
(partial/corrupt partition) | Graceful fallback to defaults |
Generate all configs for CI testing:
python scripts/generate_nvs_matrix.pyThe output validation script (scripts/validate_qemu_output.py) parses UART logs and checks:
| Check | Pass Criteria | Severity |
|---|---|---|
| Boot | app_main() called, no panic/assert |
FATAL |
| NVS load | nvs_config: log line present |
FATAL |
| Mock CSI init | mock_csi: Starting mock CSI generator |
FATAL |
| Frame generation | mock_csi: Generated N frames where N > 0 |
ERROR |
| Edge pipeline | edge_processing: DSP task started on Core 1 |
ERROR |
| Vitals output | At least one vitals: log line with valid BPM |
ERROR |
| Presence detection | presence=1 during person scenarios |
WARN |
| Fall detection | fall=1 during fall scenario |
WARN |
| MAC filter | csi_collector: MAC filter dropped N frames where N > 0 |
WARN |
| ADR-018 serialize | csi_collector: Serialized N frames where N > 0 |
ERROR |
| No crash | No Guru Meditation Error, no assert failed, no abort() |
FATAL |
| Clean exit | Firmware reaches end of scenario sequence | ERROR |
| Heap OK | No HEAP_ERROR or out of memory |
FATAL |
| Stack OK | No Stack overflow detected |
FATAL |
Exit codes: 0 = all pass, 1 = WARN only, 2 = ERROR, 3 = FATAL.
QEMU provides a built-in GDB stub for zero-cost breakpoint debugging without JTAG hardware:
# Launch QEMU paused, with GDB stub on port 1234
qemu-system-xtensa \
-machine esp32s3 -nographic \
-drive file=build/qemu_flash.bin,if=mtd,format=raw \
-serial mon:stdio \
-s -S
# In another terminal, attach GDB
xtensa-esp-elf-gdb build/esp32-csi-node.elf \
-ex "target remote :1234" \
-ex "b edge_processing.c:dsp_task" \
-ex "b csi_collector.c:csi_serialize_frame" \
-ex "b mock_csi.c:mock_generate_csi_frame" \
-ex "watch g_nvs_config.csi_channel" \
-ex "continue"Key breakpoints:
| Location | Purpose |
|---|---|
edge_processing.c:dsp_task |
DSP consumer loop entry |
edge_processing.c:presence_detect |
Threshold comparison |
edge_processing.c:fall_detect |
Phase acceleration check |
csi_collector.c:csi_serialize_frame |
ADR-018 serialization |
nvs_config.c:nvs_config_load |
NVS parse logic |
wasm_runtime.c:wasm_on_csi |
WASM module dispatch |
mock_csi.c:mock_generate_csi_frame |
Synthetic frame generation |
VS Code integration -- add to .vscode/launch.json:
{
"name": "QEMU ESP32-S3 Debug",
"type": "cppdbg",
"request": "launch",
"program": "${workspaceFolder}/firmware/esp32-csi-node/build/esp32-csi-node.elf",
"miDebuggerPath": "xtensa-esp-elf-gdb",
"miDebuggerServerAddress": "localhost:1234",
"setupCommands": [
{ "text": "set remote hardware-breakpoint-limit 2" },
{ "text": "set remote hardware-watchpoint-limit 2" }
]
}Build with gcov enabled and collect coverage after a QEMU run:
# Build with coverage overlay
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.qemu;sdkconfig.coverage" build
# After QEMU run, generate HTML report
lcov --capture --directory build --output-file coverage.info
lcov --remove coverage.info '*/esp-idf/*' '*/test/*' --output-file coverage_filtered.info
genhtml coverage_filtered.info --output-directory build/coverage_reportCoverage targets:
| Module | Target |
|---|---|
edge_processing.c |
>= 80% |
csi_collector.c |
>= 90% |
nvs_config.c |
>= 95% |
mock_csi.c |
>= 95% |
stream_sender.c |
>= 80% |
wasm_runtime.c |
>= 70% |
Host-native fuzz targets compiled with libFuzzer + AddressSanitizer (no QEMU needed):
cd firmware/esp32-csi-node/test
# Build fuzz target
clang -fsanitize=fuzzer,address -I../main \
fuzz_csi_serialize.c ../main/csi_collector.c \
-o fuzz_serialize
# Run for 5 minutes
timeout 300 ./fuzz_serialize corpus/ || trueFuzz targets:
| Target | Input | Looking For |
|---|---|---|
csi_serialize_frame() |
Random wifi_csi_info_t |
Buffer overflow, NULL deref |
nvs_config_load() |
Crafted NVS partition binary | No crash, fallback to defaults |
edge_enqueue_csi() |
Rapid-fire 10,000 frames | Ring overflow, no data corruption |
rvf_parser.c |
Malformed RVF packets | Parse rejection, no crash |
wasm_upload.c |
Corrupt WASM blobs | Rejection without crash |
The GitHub Actions workflow (.github/workflows/firmware-qemu.yml) runs on every push or PR touching firmware/**:
- Uses the
espressif/idf:v5.4container image - Builds Espressif's QEMU fork from source
- Runs a CI matrix across NVS configurations:
default,nvs-full,nvs-edge-tier0,nvs-tdm-3node - For each config: provisions NVS, builds with mock CSI, runs in QEMU with timeout, validates UART output
- Uploads QEMU logs as build artifacts for debugging failures
No physical ESP32 hardware is needed in CI.
| Symptom | Cause | Fix |
|---|---|---|
| No serial output | Wrong baud rate | Use 115200 in your serial monitor |
| WiFi won't connect | Wrong SSID/password | Re-run provision.py with correct credentials |
| No UDP frames received | Firewall blocking | Allow inbound UDP on port 5005 (see below) |
idf.py fails on Windows |
Git Bash/MSYS2 incompatibility | Use Docker -- this is the only supported build method on Windows |
| CSI callback not firing | Promiscuous mode issue | Verify esp_wifi_set_promiscuous(true) in csi_collector.c |
| WASM upload rejected | Signature verification | Disable with wasm_verify=0 via NVS for development, or sign with Ed25519 |
| High frame drop rate | Ring buffer overflow | Reduce edge_tier or increase dwell_ms |
| Vitals readings unstable | Calibration period | Wait 60 seconds for adaptive threshold to settle |
| OTA update fails | Binary too large | Check binary is < 1 MB; current headroom is ~6% |
| Docker path error on Windows | MSYS path conversion | Prefix command with MSYS_NO_PATHCONV=1 |
netsh advfirewall firewall add rule name="ESP32 CSI" dir=in action=allow protocol=UDP localport=5005This firmware implements or references the following ADRs:
| ADR | Title | Status |
|---|---|---|
| ADR-018 | CSI binary frame format | Accepted |
| ADR-029 | Channel hopping and TDM protocol | Accepted |
| ADR-039 | Edge intelligence tiers 0-2 | Accepted |
| ADR-040 | WASM programmable sensing (Tier 3) with RVF container format | Alpha |
| ADR-057 | Build-time CSI guard (CONFIG_ESP_WIFI_CSI_ENABLED) |
Accepted |
| ADR-060 | Channel override and MAC address filter | Accepted |
| ADR-061 | QEMU ESP32-S3 emulation for firmware testing | Proposed |
This firmware is dual-licensed under MIT OR Apache-2.0, at your option.