Status: Accepted (hardware-validated on RuView ESP32-S3) Date: 2026-03-02 Deciders: @ruvnet
WiFi-DensePose captures Channel State Information (CSI) from ESP32-S3 nodes and streams raw I/Q data to a host server for processing. This architecture has limitations:
- Bandwidth: Raw CSI at 20 Hz × 128 subcarriers × 2 bytes = ~5 KB/frame = ~100 KB/s per node. Multi-node deployments saturate low-bandwidth links.
- Latency: Server-side processing adds network round-trip delay for time-critical signals like fall detection.
- Power: Continuous raw streaming prevents duty-cycling for battery-powered deployments.
- Scalability: Server CPU scales linearly with node count for basic signal processing that could run on the ESP32-S3's dual cores.
Implement a tiered edge processing pipeline on the ESP32-S3 that performs signal processing locally and sends compact results:
No on-device processing. CSI frames streamed as-is (magic 0xC5110001).
- Phase extraction and unwrapping from I/Q pairs
- Welford running variance per subcarrier
- Top-K subcarrier selection by variance
- Delta compression (XOR + RLE) for 30-50% bandwidth reduction (magic
0xC5110003)
All of Tier 1, plus:
- Biquad IIR bandpass filters: breathing (0.1-0.5 Hz), heart rate (0.8-2.0 Hz)
- Zero-crossing BPM estimation
- Presence detection with adaptive threshold calibration (1200 frames, 3-sigma)
- Fall detection (phase acceleration exceeding configurable threshold)
- Multi-person vitals via subcarrier group clustering (up to 4 persons)
- 32-byte vitals packet at configurable interval (magic
0xC5110002)
Core 0 (WiFi) Core 1 (DSP)
┌─────────────────┐ ┌──────────────────────────┐
│ CSI callback │──SPSC ring──▶│ Phase extract + unwrap │
│ (wifi_csi_cb) │ buffer │ Welford variance │
│ │ │ Top-K selection │
│ UDP raw stream │ │ Biquad bandpass filters │
│ (0xC5110001) │ │ Zero-crossing BPM │
└─────────────────┘ │ Presence detection │
│ Fall detection │
│ Multi-person clustering │
│ Delta compression │
│ ──▶ UDP vitals (0xC5110002)│
│ ──▶ UDP compressed (0x03) │
└──────────────────────────┘
Vitals Packet (32 bytes, magic 0xC5110002):
| Offset | Type | Field |
|---|---|---|
| 0-3 | u32 LE | Magic 0xC5110002 |
| 4 | u8 | Node ID |
| 5 | u8 | Flags (bit0=presence, bit1=fall, bit2=motion) |
| 6-7 | u16 LE | Breathing rate (BPM × 100) |
| 8-11 | u32 LE | Heart rate (BPM × 10000) |
| 12 | i8 | RSSI |
| 13 | u8 | Number of detected persons |
| 14-15 | u8[2] | Reserved |
| 16-19 | f32 LE | Motion energy |
| 20-23 | f32 LE | Presence score |
| 24-27 | u32 LE | Timestamp (ms since boot) |
| 28-31 | u32 LE | Reserved |
Compressed Frame (magic 0xC5110003):
| Offset | Type | Field |
|---|---|---|
| 0-3 | u32 LE | Magic 0xC5110003 |
| 4 | u8 | Node ID |
| 5 | u8 | WiFi channel |
| 6-7 | u16 LE | Original I/Q length |
| 8-9 | u16 LE | Compressed length |
| 10+ | bytes | RLE-encoded XOR delta |
Six NVS keys in the csi_cfg namespace:
| NVS Key | Type | Default | Description |
|---|---|---|---|
edge_tier |
u8 | 2 | Processing tier (0/1/2) |
pres_thresh |
u16 | 0 | Presence threshold × 1000 (0 = auto) |
fall_thresh |
u16 | 2000 | Fall threshold × 1000 (rad/s²) |
vital_win |
u16 | 256 | Phase history window |
vital_int |
u16 | 1000 | Vitals interval (ms) |
subk_count |
u8 | 8 | Top-K subcarrier count |
All configurable via provision.py --edge-tier 2 --pres-thresh 0.05 ...
- OTA Updates: HTTP server on port 8032 (
POST /ota,GET /ota/status) with rollback support - Power Management: WiFi modem sleep + automatic light sleep with configurable duty cycle
- Fall detection latency reduced from ~500 ms (network RTT) to <50 ms (on-device)
- Bandwidth reduced 30-50% with delta compression, or 95%+ with vitals-only mode
- Battery-powered deployments possible with duty-cycled light sleep
- Server can handle 10x more nodes (only parses 32-byte vitals instead of ~5 KB CSI)
- Firmware complexity increases (edge_processing.c is ~750 lines)
- ESP32-S3 RAM usage increases ~12 KB for ring buffer + filter state
- Binary size increases from ~550 KB to ~925 KB with full WASM3 Tier 3 (10% free in 1 MB partition — see ADR-040)
- BPM accuracy depends on subject distance and movement; needs real-world validation
- Fall detection heuristic may false-positive on environmental motion (doors, pets)
- Multi-person separation via subcarrier clustering is approximate without calibration
firmware/esp32-csi-node/main/edge_processing.c— DSP pipeline (~750 lines)firmware/esp32-csi-node/main/edge_processing.h— Types and APIfirmware/esp32-csi-node/main/ota_update.c/h— HTTP OTA endpointfirmware/esp32-csi-node/main/power_mgmt.c/h— Power managementrust-port/.../wifi-densepose-sensing-server/src/main.rs— Vitals parser + REST endpointscripts/provision.py— Edge config CLI arguments.github/workflows/firmware-ci.yml— CI build + size gate (updated to 950 KB for Tier 3)
See ADR-040 for hot-loadable WASM modules compiled from Rust, executed via WASM3 interpreter on-device. Core modules: gesture recognition, coherence monitoring, adversarial detection.
ADR-041 defines the curated module collection (37 modules across 6 categories). Phase 1 implemented modules:
vital_trend.rs— Clinical vital sign trend analysis (bradypnea, tachypnea, apnea)intrusion.rs— State-machine intrusion detection (calibrate-monitor-arm-alert)occupancy.rs— Spatial occupancy zone detection with per-zone variance analysis
Measured on ESP32-S3 (QFN56 rev v0.2, 8 MB flash, 160 MHz, ESP-IDF v5.2).
| Milestone | Time (ms) |
|---|---|
app_main() |
412 |
| WiFi STA init | 627 |
| WiFi connected + IP | 3,732 |
| CSI collection init | 3,754 |
| Edge DSP task started | 3,773 |
| WASM runtime initialized | 3,857 |
| Total boot → ready | ~3.9 s |
| Metric | Value |
|---|---|
| Frame rate | 28.5 Hz (measured, ch 5 BW20) |
| Frame sizes | 128 / 256 bytes |
| RSSI range | -83 to -32 dBm (mean -62 dBm) |
| Per-frame interval | 30.6 ms avg |
| Region | Size |
|---|---|
| RAM (main heap) | 256 KiB |
| RAM (secondary) | 21 KiB |
| DRAM | 32 KiB |
| RTC RAM | 7 KiB |
| Total available | 316 KiB |
| PSRAM | Not populated on test board |
| WASM arena fallback | Internal heap (160 KB/slot × 4) |
| Metric | Value |
|---|---|
| Binary size | 925 KB (0xE7440 bytes) |
| Partition size | 1 MB (factory) |
| Free space | 10% (99 KB) |
| CI size gate | 950 KB (PASS) |
| WASM3 interpreter | Included (full, ~100 KB) |
| WASM binary (7 modules) | 13.8 KB (wasm32-unknown-unknown release) |
| Metric | Value |
|---|---|
| Init time | 106 ms |
| Module slots | 4 |
| Arena per slot | 160 KB |
| Frame budget | 10,000 µs (10 ms) |
| Timer interval | 1,000 ms (1 Hz) |
- Fall detection threshold too low — default
fall_thresh=2000(2.0 rad/s²) triggers 6.7 false positives/s in static indoor environment. Recommend increasing to 5000-8000 for typical deployments. - No PSRAM on test board — WASM arena falls back to internal heap. Boards with PSRAM would support larger modules.
- CSI rate exceeds spec — measured 28.5 Hz vs. expected ~20 Hz. Performance headroom is better than estimated.
- WiFi-to-Ethernet isolation — some routers block UDP between WiFi and wired clients. Recommend same-subnet verification in deployment guide.
- sendto ENOMEM crash (Issue #127) — CSI callbacks in promiscuous mode fire 100-500+ times/sec, exhausting the lwIP pbuf pool and causing a guru meditation crash. Fixed with a dual approach: 50 Hz rate limiter in
csi_collector.c(20 ms minimum send interval) and a 100 ms ENOMEM backoff instream_sender.c. Binary size with fix: 947 KB. Hardware-verified stable for 200+ CSI callbacks with zero ENOMEM errors.