Back to Ruview

ESP32-S3 CSI Node Firmware

firmware/esp32-csi-node/README.md

0.7.032.3 KB
Original Source

ESP32-S3 CSI Node Firmware

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.

CapabilityMethodPerformance
CSI streamingPer-subcarrier I/Q capture over UDP~20 Hz, ADR-018 binary format
Breathing detectionBandpass 0.1-0.5 Hz, zero-crossing BPM6-30 BPM
Heart rateBandpass 0.8-2.0 Hz, zero-crossing BPM40-120 BPM
Presence sensingPhase variance + adaptive calibration< 1 ms latency
Fall detectionPhase acceleration thresholdConfigurable sensitivity
Programmable sensingWASM modules loaded over HTTPHot-swap, no reflash

Quick Start

For users who want to get running fast. Detailed explanations follow in later sections.

1. Build (Docker -- the only reliable method)

bash
# 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"

2. Flash

bash
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.bin

3. Provision WiFi credentials (no reflash needed)

bash
python scripts/provision.py --port COM7 \
  --ssid "YourSSID" --password "YourPass" --target-ip 192.168.1.20

4. Start the sensing server

bash
cargo run -p wifi-densepose-sensing-server -- --http-port 3000 --source auto

5. Open the UI

Navigate to http://localhost:3000 in your browser.

6. (Optional) Upload a WASM sensing module

bash
curl -X POST http://<ESP32_IP>:8032/wasm/upload --data-binary @gesture.rvf
curl http://<ESP32_IP>:8032/wasm/list

Hardware Requirements

ComponentSpecificationNotes
SoCESP32-S3 (QFN56)Dual-core Xtensa LX7, 240 MHz
Flash8 MB~943 KB used by firmware
PSRAM8 MB640 KB used for WASM arenas
USB bridgeSilicon Labs CP210xInstall the CP210x driver
Recommended boardsESP32-S3-DevKitC-1, XIAO ESP32-S3Any ESP32-S3 with 8 MB flash works
Deployment3-6 nodes per roomMultistatic 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.


Firmware Architecture

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    |
+--------------------------------------------------------------------------+

Tier 0 -- Raw CSI Passthrough (Stable)

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)

Tier 1 -- Basic DSP (Stable)

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%

Tier 2 -- Full Pipeline (Stable)

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)

Tier 3 -- WASM Programmable Sensing (Alpha)

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.


Wire Protocols

All packets are sent over UDP to the configured aggregator. The magic number in the first 4 bytes identifies the packet type.

MagicNameRateSizeContents
0xC5110001CSI Frame (ADR-018)~20 HzVariableRaw I/Q per subcarrier per antenna
0xC5110002Vitals Packet1 Hz32 bytesPresence, breathing BPM, heart rate, fall flag, occupancy
0xC5110004WASM OutputEvent-drivenVariableCustom events from WASM modules (u8 type + f32 value)

ADR-018 Binary Frame Format

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)

Vitals Packet (32 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

Building

Prerequisites

ComponentVersionPurpose
Docker Desktop28.x+Cross-compile firmware in ESP-IDF container
esptool5.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.py script detects the MSYSTEM environment variable and skips main(). Even removing MSYSTEM, the cmd.exe subprocess injects doskey aliases that break the ninja linker. Docker is the only reliable cross-platform build method.

Build Command

bash
# 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 bootloader
  • build/partition_table/partition-table.bin -- flash partition layout
  • build/esp32-csi-node.bin -- application firmware

Custom Configuration

To change Kconfig settings before building:

bash
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:

ini
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=y

Flashing

Find your serial port: COM7 on Windows, /dev/ttyUSB0 on Linux, /dev/cu.SLAB_USBtoUART on macOS.

bash
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.bin

Serial Monitor

bash
python -m serial.tools.miniterm COM7 115200

Expected 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)

Runtime Configuration (NVS)

All settings can be changed at runtime via Non-Volatile Storage (NVS) without reflashing the firmware. NVS values override Kconfig defaults.

Provisioning Script

The easiest way to write NVS settings:

bash
python scripts/provision.py --port COM7 \
  --ssid "MyWiFi" \
  --password "MyPassword" \
  --target-ip 192.168.1.20

NVS Key Reference

Network Settings

KeyTypeDefaultDescription
ssidstringwifi-denseposeWiFi SSID
passwordstring(empty)WiFi password
target_ipstring192.168.1.100Aggregator server IP address
target_portu165005Aggregator UDP port
node_idu81Unique node identifier (0-255)

Channel Hopping and TDM (ADR-029)

KeyTypeDefaultDescription
hop_countu81Number of channels to hop (1 = single-channel mode)
chan_listblob[6]WiFi channel numbers for hopping
dwell_msu3250Dwell time per channel in milliseconds
tdm_slotu80This node's TDM slot index (0-based)
tdm_nodesu81Total number of nodes in the TDM schedule

Edge Intelligence (ADR-039)

KeyTypeDefaultDescription
edge_tieru82Processing tier: 0=raw, 1=basic DSP, 2=full pipeline
pres_threshu16autoPresence threshold (x1000). 0 = auto-calibrate from 60 s ambient
fall_threshu162000Fall detection threshold (x1000). 2000 = 2.0 rad/s^2
vital_winu16256Phase history window depth (frames)
vital_intu161000Vitals packet send interval (ms)
subk_countu88Top-K subcarrier count for variance tracking
power_dutyu8100Power duty cycle percentage (10-100). 100 = always on

WASM Programmable Sensing (ADR-040)

KeyTypeDefaultDescription
wasm_maxu84Maximum concurrent WASM module slots (1-8)
wasm_verifyu81Require Ed25519 signature verification for uploads

Kconfig Menus

Three configuration menus are available via idf.py menuconfig:

"CSI Node Configuration"

Basic WiFi and network settings: SSID, password, channel, node ID, aggregator IP/port.

"Edge Intelligence (ADR-039)"

Processing tier selection, vitals interval, top-K subcarrier count, fall detection threshold, power duty cycle.

"WASM Programmable Sensing (ADR-040)"

Maximum module slots, Ed25519 signature verification toggle, timer interval for on_timer() callbacks.


WASM Programmable Sensing (Tier 3)

Overview

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-unknown target
  • 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 (RuVector Format)

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

FieldSizeContents
Header32 bytesMagic (RVF\x01), format version, section sizes, flags
Manifest96 bytesModule name, author, capabilities bitmask, budget request, SHA-256 build hash, event schema version
WASM payloadVariableThe compiled .wasm binary (max 128 KB)
Signature64 bytesEd25519 signature covering header + manifest + WASM

Host API

WASM modules import functions from the "csi" namespace to access sensor data:

FunctionSignatureDescription
csi_get_phase(i32) -> f32Phase (radians) for subcarrier index
csi_get_amplitude(i32) -> f32Amplitude for subcarrier index
csi_get_variance(i32) -> f32Running variance (Welford) for subcarrier
csi_get_bpm_breathing() -> f32Breathing rate BPM from Tier 2
csi_get_bpm_heartrate() -> f32Heart rate BPM from Tier 2
csi_get_presence() -> i32Presence flag (0 = empty, 1 = present)
csi_get_motion_energy() -> f32Motion energy scalar
csi_get_n_persons() -> i32Number of detected persons
csi_get_timestamp() -> i32Milliseconds 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) -> i32Copy phase ring buffer into WASM memory

Module Lifecycle

Every WASM module must export these three functions:

ExportCalledPurpose
on_init()Once, when startedAllocate 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

HTTP Management Endpoints

All endpoints are served on port 8032 (shared with the OTA update server).

MethodPathDescription
POST/wasm/uploadUpload an RVF container or raw .wasm binary (max 128 KB)
GET/wasm/listList all module slots with state, telemetry, and RVF metadata
POST/wasm/start/:idStart a loaded module (calls on_init)
POST/wasm/stop/:idStop a running module
DELETE/wasm/:idUnload a module and free its PSRAM arena

Included WASM Modules

The wifi-densepose-wasm-edge Rust crate provides three flagship modules:

ModuleFileDescription
gesturegesture.rsDTW template matching for wave, push, pull, and swipe gestures
coherencecoherence.rsPhase phasor coherence monitoring with hysteresis gate
adversarialadversarial.rsSignal anomaly detection (phase jumps, flatlines, energy spikes)

Build all modules:

bash
cargo build -p wifi-densepose-wasm-edge --target wasm32-unknown-unknown --release

Safety Features

ProtectionDetail
Memory isolationFixed 160 KB PSRAM arenas per slot (no heap fragmentation)
Budget guard10 ms per-frame default; auto-stop after 10 consecutive budget faults
Signature verificationEd25519 enabled by default; disable with wasm_verify=0 in NVS for development
Hash verificationSHA-256 of WASM payload checked against RVF manifest
Slot limitMaximum 4 concurrent module slots (configurable to 8)
Per-module telemetryFrame count, event count, mean/max execution time, budget faults

Memory Budget

ComponentSRAMPSRAMFlash
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 KB640 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)

Source Files

FileDescription
main/main.cApplication entry point: NVS init, WiFi STA, CSI collector, edge pipeline, OTA server, WASM runtime init
main/csi_collector.c / .hWiFi CSI frame capture, ADR-018 binary serialization, channel hopping, NDP injection
main/stream_sender.c / .hUDP socket management and packet transmission to aggregator
main/nvs_config.c / .hRuntime configuration: loads Kconfig defaults, overrides from NVS
main/edge_processing.c / .hTier 0-2 DSP pipeline: SPSC ring buffer, biquad IIR filters, Welford stats, BPM extraction, presence, fall detection
main/ota_update.c / .hHTTP OTA firmware update server on port 8032
main/power_mgmt.c / .hBattery-aware light sleep duty cycling
main/wasm_runtime.c / .hWASM3 interpreter: module slots, host API bindings, budget guard, per-frame dispatch
main/wasm_upload.c / .hHTTP endpoints for WASM module upload, list, start, stop, delete
main/rvf_parser.c / .hRVF container parser: header validation, manifest extraction, SHA-256 hash verification
components/wasm3/WASM3 interpreter library (MIT license, ~100 KB flash, ~10 KB RAM)

Architecture Diagram

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  |
+------------------------------------------+  +---------------------------+

CI/CD

The firmware is continuously verified by .github/workflows/firmware-ci.yml:

StepCheckThreshold
Docker buildFull compile with ESP-IDF v5.4 containerMust succeed
Binary size gateesp32-csi-node.bin file sizeMust be < 950 KB
Flash image integrityPartition table magic, bootloader presence, non-padding contentWarnings on failure
Artifact uploadBootloader + partition table + app binary30-day retention

QEMU Testing (ADR-061)

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.

Prerequisites

  • ESP-IDF v5.4 -- installation guide
  • Espressif QEMU fork -- must be built from source (not in Ubuntu packages):
bash
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/

Quick Start

Three commands to go from source to running firmware in QEMU:

bash
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-reboot

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

Mock CSI Scenarios

The mock generator cycles through 10 scenarios that exercise every edge processing path:

IDScenarioDurationExpected Output
0Empty room10 spresence=0, motion_energy < thresh
1Static person10 spresence=1, breathing_rate in [10, 25], fall=0
2Walking person10 spresence=1, motion_energy > 0.5, fall=0
3Fall event5 sfall=1 flag set, motion_energy spike
4Multi-person15 sn_persons=2, independent breathing rates
5Channel sweep5 sFrames on channels 1, 6, 11 in sequence
6MAC filter test5 sFrames with wrong MAC dropped (counter check)
7Ring buffer overflow3 s1000 frames in 100 ms burst, graceful drop
8Boundary RSSI5 sRSSI sweeps -127 to 0, no crash
9Zero-length frame2 siq_len=0 frames, serialize returns 0

NVS Provisioning Matrix

14 NVS configurations are tested in CI to ensure all config paths work correctly:

ConfigNVS ValuesValidates
default(empty NVS)Kconfig fallback paths
wifi-onlyssid, passwordBasic provisioning
full-adr060channel=6, filter_mac=AA:BB:CC:DD:EE:FFChannel override + MAC filter
edge-tier0edge_tier=0Raw CSI passthrough (no DSP)
edge-tier1edge_tier=1, pres_thresh=100, fall_thresh=2000Stats-only mode
edge-tier2-customedge_tier=2, vital_win=128, vital_int=500, subk_count=16Full vitals with custom params
tdm-3nodetdm_slot=1, tdm_nodes=3, node_id=1TDM mesh timing
wasm-signedwasm_max=4, wasm_verify=1, wasm_pubkey=<32B>WASM with Ed25519 verification
wasm-unsignedwasm_max=2, wasm_verify=0WASM without signature check
5ghz-channelchannel=36, filter_mac=...5 GHz CSI collection
boundary-maxtarget_port=65535, node_id=255, top_k=32, vital_win=256Max-range values
boundary-mintarget_port=1, node_id=0, top_k=1, vital_win=32Min-range values
power-savepower_duty=10, edge_tier=0Low-power mode
corrupt-nvs(partial/corrupt partition)Graceful fallback to defaults

Generate all configs for CI testing:

bash
python scripts/generate_nvs_matrix.py

Validation Checks

The output validation script (scripts/validate_qemu_output.py) parses UART logs and checks:

CheckPass CriteriaSeverity
Bootapp_main() called, no panic/assertFATAL
NVS loadnvs_config: log line presentFATAL
Mock CSI initmock_csi: Starting mock CSI generatorFATAL
Frame generationmock_csi: Generated N frames where N > 0ERROR
Edge pipelineedge_processing: DSP task started on Core 1ERROR
Vitals outputAt least one vitals: log line with valid BPMERROR
Presence detectionpresence=1 during person scenariosWARN
Fall detectionfall=1 during fall scenarioWARN
MAC filtercsi_collector: MAC filter dropped N frames where N > 0WARN
ADR-018 serializecsi_collector: Serialized N frames where N > 0ERROR
No crashNo Guru Meditation Error, no assert failed, no abort()FATAL
Clean exitFirmware reaches end of scenario sequenceERROR
Heap OKNo HEAP_ERROR or out of memoryFATAL
Stack OKNo Stack overflow detectedFATAL

Exit codes: 0 = all pass, 1 = WARN only, 2 = ERROR, 3 = FATAL.

GDB Debugging

QEMU provides a built-in GDB stub for zero-cost breakpoint debugging without JTAG hardware:

bash
# 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:

LocationPurpose
edge_processing.c:dsp_taskDSP consumer loop entry
edge_processing.c:presence_detectThreshold comparison
edge_processing.c:fall_detectPhase acceleration check
csi_collector.c:csi_serialize_frameADR-018 serialization
nvs_config.c:nvs_config_loadNVS parse logic
wasm_runtime.c:wasm_on_csiWASM module dispatch
mock_csi.c:mock_generate_csi_frameSynthetic frame generation

VS Code integration -- add to .vscode/launch.json:

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" }
  ]
}

Code Coverage

Build with gcov enabled and collect coverage after a QEMU run:

bash
# 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_report

Coverage targets:

ModuleTarget
edge_processing.c>= 80%
csi_collector.c>= 90%
nvs_config.c>= 95%
mock_csi.c>= 95%
stream_sender.c>= 80%
wasm_runtime.c>= 70%

Fuzz Testing

Host-native fuzz targets compiled with libFuzzer + AddressSanitizer (no QEMU needed):

bash
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/ || true

Fuzz targets:

TargetInputLooking For
csi_serialize_frame()Random wifi_csi_info_tBuffer overflow, NULL deref
nvs_config_load()Crafted NVS partition binaryNo crash, fallback to defaults
edge_enqueue_csi()Rapid-fire 10,000 framesRing overflow, no data corruption
rvf_parser.cMalformed RVF packetsParse rejection, no crash
wasm_upload.cCorrupt WASM blobsRejection without crash

QEMU CI Workflow

The GitHub Actions workflow (.github/workflows/firmware-qemu.yml) runs on every push or PR touching firmware/**:

  1. Uses the espressif/idf:v5.4 container image
  2. Builds Espressif's QEMU fork from source
  3. Runs a CI matrix across NVS configurations: default, nvs-full, nvs-edge-tier0, nvs-tdm-3node
  4. For each config: provisions NVS, builds with mock CSI, runs in QEMU with timeout, validates UART output
  5. Uploads QEMU logs as build artifacts for debugging failures

No physical ESP32 hardware is needed in CI.


Troubleshooting

SymptomCauseFix
No serial outputWrong baud rateUse 115200 in your serial monitor
WiFi won't connectWrong SSID/passwordRe-run provision.py with correct credentials
No UDP frames receivedFirewall blockingAllow inbound UDP on port 5005 (see below)
idf.py fails on WindowsGit Bash/MSYS2 incompatibilityUse Docker -- this is the only supported build method on Windows
CSI callback not firingPromiscuous mode issueVerify esp_wifi_set_promiscuous(true) in csi_collector.c
WASM upload rejectedSignature verificationDisable with wasm_verify=0 via NVS for development, or sign with Ed25519
High frame drop rateRing buffer overflowReduce edge_tier or increase dwell_ms
Vitals readings unstableCalibration periodWait 60 seconds for adaptive threshold to settle
OTA update failsBinary too largeCheck binary is < 1 MB; current headroom is ~6%
Docker path error on WindowsMSYS path conversionPrefix command with MSYS_NO_PATHCONV=1

Windows Firewall Rule

powershell
netsh advfirewall firewall add rule name="ESP32 CSI" dir=in action=allow protocol=UDP localport=5005

Architecture Decision Records

This firmware implements or references the following ADRs:

ADRTitleStatus
ADR-018CSI binary frame formatAccepted
ADR-029Channel hopping and TDM protocolAccepted
ADR-039Edge intelligence tiers 0-2Accepted
ADR-040WASM programmable sensing (Tier 3) with RVF container formatAlpha
ADR-057Build-time CSI guard (CONFIG_ESP_WIFI_CSI_ENABLED)Accepted
ADR-060Channel override and MAC address filterAccepted
ADR-061QEMU ESP32-S3 emulation for firmware testingProposed

License

This firmware is dual-licensed under MIT OR Apache-2.0, at your option.