Status: Accepted Date: 2026-03-02 Deciders: @ruvnet
ADR-039 implemented Tiers 0-2 of the ESP32-S3 edge intelligence pipeline:
- Tier 0: Raw CSI passthrough (magic
0xC5110001) - Tier 1: Basic DSP — phase unwrap, Welford stats, top-K, delta compression
- Tier 2: Full pipeline — vitals, presence, fall detection, multi-person
The firmware uses ~820 KB of flash, leaving ~80 KB headroom in the 1 MB OTA partition. The ESP32-S3 has 8 MB PSRAM available for runtime data. New sensing algorithms (gesture recognition, signal coherence monitoring, adversarial detection) currently require a full firmware reflash — impractical for deployed sensor networks.
The project already has 35+ RuVector WASM crates and 28 pre-built .wasm binaries, but none are integrated into the ESP32 firmware.
Add a Tier 3 WASM programmable sensing layer that executes hot-loadable algorithms compiled from Rust to wasm32-unknown-unknown, interpreted on-device via the WASM3 runtime.
Core 1 (DSP Task)
┌──────────────────────────────────────────────────┐
│ Tier 2 Pipeline (existing) │
│ Phase extract → Welford → Top-K → Biquad → │
│ BPM → Presence → Fall → Multi-person │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Tier 3 WASM Runtime (new) │ │
│ │ WASM3 Interpreter (MIT, ~100 KB flash) │ │
│ │ ┌────────────┐ ┌────────────┐ │ │
│ │ │ Module 0 │ │ Module 1 │ ...×4 │ │
│ │ │ gesture.wm │ │ coherence │ │ │
│ │ └─────┬──────┘ └─────┬──────┘ │ │
│ │ │ │ │ │
│ │ Host API ("csi" namespace) │ │
│ │ csi_get_phase, csi_get_amplitude, ... │ │
│ └──────────────────────────────────────────────┘ │
│ │ │
│ UDP output (0xC5110004) │
└──────────────────────────────────────────────────┘
| Component | File | Description |
|---|---|---|
| WASM3 component | components/wasm3/CMakeLists.txt |
ESP-IDF managed component, fetches WASM3 from GitHub |
| Runtime host | main/wasm_runtime.c/h |
WASM3 environment, module slots, host API bindings |
| HTTP upload | main/wasm_upload.c/h |
REST endpoints for module management on port 8032 |
| Rust WASM crate | wifi-densepose-wasm-edge/ |
no_std sensing algorithms compiled to WASM |
| Import | Signature | Description |
|---|---|---|
csi_get_phase |
(i32) -> f32 |
Current phase for subcarrier index |
csi_get_amplitude |
(i32) -> f32 |
Current amplitude |
csi_get_variance |
(i32) -> f32 |
Welford running variance |
csi_get_bpm_breathing |
() -> f32 |
Breathing BPM from Tier 2 |
csi_get_bpm_heartrate |
() -> f32 |
Heart rate BPM from Tier 2 |
csi_get_presence |
() -> i32 |
Presence flag (0/1) |
csi_get_motion_energy |
() -> f32 |
Motion energy scalar |
csi_get_n_persons |
() -> i32 |
Detected person count |
csi_get_timestamp |
() -> i32 |
Milliseconds since boot |
csi_emit_event |
(i32, f32) -> void |
Emit custom event to host |
csi_log |
(i32, i32) -> void |
Debug log from WASM memory |
csi_get_phase_history |
(i32, i32) -> i32 |
Copy phase history ring buffer |
| Export | Called | Description |
|---|---|---|
on_init() |
Once, when module starts | Initialize module state |
on_frame(n_sc: i32) |
Per CSI frame (~20 Hz) | Process current frame |
on_timer() |
At configurable interval | Periodic tasks |
| Offset | Type | Field |
|---|---|---|
| 0-3 | u32 LE | Magic 0xC5110004 |
| 4 | u8 | Node ID |
| 5 | u8 | Module ID (slot index) |
| 6-7 | u16 LE | Event count |
| 8+ | Event[] | Array of (u8 type, f32 value) tuples |
| Method | Path | Description |
|---|---|---|
POST |
/wasm/upload |
Upload .wasm binary (max 128 KB) |
GET |
/wasm/list |
List loaded modules with status |
POST |
/wasm/start/:id |
Start a module |
POST |
/wasm/stop/:id |
Stop a module |
DELETE |
/wasm/:id |
Unload a module |
| Module | Source | Events | Description |
|---|---|---|---|
gesture.rs |
ruvsense/gesture.rs |
1 (Core) | DTW template matching for gesture recognition |
coherence.rs |
ruvector/viewpoint/coherence.rs |
2 (Core) | Phase phasor coherence monitoring |
adversarial.rs |
ruvsense/adversarial.rs |
3 (Core) | Signal anomaly/adversarial detection |
vital_trend.rs |
ADR-041 Phase 1 | 100-111 (Medical) | Clinical vital sign trend analysis (bradypnea, tachypnea, bradycardia, tachycardia, apnea) |
occupancy.rs |
ADR-041 Phase 1 | 300-302 (Building) | Spatial occupancy zone detection with per-zone variance analysis |
intrusion.rs |
ADR-041 Phase 1 | 200-203 (Security) | State-machine intrusion detector (calibrate-monitor-arm-alert) |
| Component | SRAM | PSRAM | Flash |
|---|---|---|---|
| WASM3 interpreter | ~10 KB | — | ~100 KB |
| WASM module storage (×4) | — | 512 KB | — |
| WASM execution stack | 8 KB | — | — |
| Host API bindings | 2 KB | — | ~15 KB |
| HTTP upload handler | 1 KB | — | ~8 KB |
| RVF parser + verifier | 1 KB | — | ~6 KB |
| Total Tier 3 | ~22 KB | 512 KB | ~129 KB |
| Running total (Tier 0-3) | ~34 KB | 512 KB | ~925 KB |
Measured binary size: 925 KB (0xE7440 bytes), 10% free in 1 MB OTA partition.
| Key | Type | Default | Description |
|---|---|---|---|
wasm_max |
u8 | 4 | Maximum concurrent WASM modules |
wasm_verify |
u8 | 1 | Require signature verification (secure-by-default) |
wasm_pubkey |
blob(32) | — | Signing public key for WASM verification |
- Deploy new sensing algorithms to 1000+ nodes without reflashing firmware
- 20-year extensibility horizon — new algorithms via .wasm uploads
- Algorithms developed/tested in Rust, compiled to portable WASM
- PSRAM utilization (previously unused 8 MB) for module storage
- Hot-swap algorithms for A/B testing in production deployments
- Same
no_stdRust code runs on ESP32 (WASM3) and in browser (wasm-pack)
- WASM3 interpreter overhead: ~10× slower than native C for compute-heavy code
- Adds ~123 KB flash footprint (firmware approaches 950 KB of 1 MB limit)
- Additional attack surface via WASM module upload endpoint
- Debugging WASM modules on ESP32 is harder than native C
| Risk | Mitigation |
|---|---|
| WASM3 memory management may fragment PSRAM over time | Fixed 160 KB arenas pre-allocated at boot per slot — no runtime malloc/free cycles |
| Complex WASM modules (>64 KB) may cause stack overflow in interpreter | WASM_STACK_SIZE = 8 KB, d_m3MaxFunctionStackHeight = 128; modules validated at load time |
| HTTP upload endpoint requires network security | Ed25519 signature verification enabled by default (wasm_verify=1); disable only via NVS for lab/dev |
| Runaway WASM module blocks DSP pipeline | Per-frame budget guard (10 ms default); module auto-stopped after 10 consecutive faults |
| Denial-of-service via rapid upload/unload cycles | Max 4 concurrent slots; upload handler validates size before PSRAM copy |
firmware/esp32-csi-node/components/wasm3/CMakeLists.txt— WASM3 ESP-IDF componentfirmware/esp32-csi-node/main/wasm_runtime.c/h— Runtime host with 12 API bindings + manifestfirmware/esp32-csi-node/main/wasm_upload.c/h— HTTP REST endpoints (RVF-aware)firmware/esp32-csi-node/main/rvf_parser.c/h— RVF container parser and verifierrust-port/.../wifi-densepose-wasm-edge/— Rust WASM crate (gesture, coherence, adversarial, rvf, occupancy, vital_trend, intrusion)rust-port/.../wifi-densepose-sensing-server/src/main.rs—0xC5110004parserdocs/adr/ADR-039-esp32-edge-intelligence.md— Updated with Tier 3 reference
The initial Tier 3 implementation addresses five production-readiness concerns:
Dynamic heap_caps_malloc / free cycles on PSRAM fragment memory over days of
continuous operation. Instead, each module slot pre-allocates a 160 KB fixed arena
at boot (WASM_ARENA_SIZE). The WASM binary and WASM3 runtime heap both live inside
this arena. Unloading a module zeroes the arena but never frees it — the slot is
reused on the next wasm_runtime_load().
Boot: [arena0: 160 KB][arena1: 160 KB][arena2: 160 KB][arena3: 160 KB]
Total: 640 KB PSRAM
Load: [module0 binary | wasm3 heap | ...padding... ]
Unload:[zeroed .......................................] ← slot reusable
This eliminates fragmentation at the cost of reserving 640 KB PSRAM at boot (8% of 8 MB). The remaining 7.36 MB is available for future use.
Each on_frame() call is measured with esp_timer_get_time(). If execution
exceeds WASM_FRAME_BUDGET_US (default 10 ms = 10,000 us), a budget fault is
recorded. After 10 consecutive faults, the module is auto-stopped with
WASM_MODULE_ERROR state. This prevents a runaway WASM module from blocking the
Tier 2 DSP pipeline.
int64_t t_start = esp_timer_get_time();
m3_CallV(slot->fn_on_frame, n_sc);
uint32_t elapsed_us = (uint32_t)(esp_timer_get_time() - t_start);
slot->total_us += elapsed_us;
if (elapsed_us > slot->max_us) slot->max_us = elapsed_us;
if (elapsed_us > WASM_FRAME_BUDGET_US) {
slot->budget_faults++;
if (slot->budget_faults >= 10) {
slot->state = WASM_MODULE_ERROR; // auto-stop
}
}The budget is configurable via WASM_FRAME_BUDGET_US (Kconfig or NVS override).
The /wasm/list endpoint and wasm_module_info_t struct expose per-module
telemetry:
| Field | Type | Description |
|---|---|---|
frame_count |
u32 | Total on_frame calls since start |
event_count |
u32 | Total csi_emit_event calls |
error_count |
u32 | WASM3 runtime errors |
total_us |
u32 | Cumulative execution time (microseconds) |
max_us |
u32 | Worst-case single frame execution time |
budget_faults |
u32 | Times frame budget was exceeded |
Mean execution time = total_us / frame_count. This enables remote monitoring
of module health and performance regression detection.
wasm_verify defaults to 1 in both Kconfig and the NVS fallback path.
Uploaded .wasm binaries must include a valid Ed25519 signature (same key as
OTA firmware). Disable only for lab/dev use via:
python provision.py --port COM7 --wasm-verify # NVS: wasm_verify=1 (default)
# To disable in dev: write wasm_verify=0 to NVS directlyOne control loop turns sensing into a bounded compute budget, spends that budget on sparse or spiking inference, and exports only deltas. The budget is driven by the mincut eigenvalue gap (Δλ = λ₂ − λ₁ of the CSI graph Laplacian), which reflects scene complexity: a quiet room has Δλ ≈ 0, a busy room has large Δλ.
┌─────────────────────────────────┐
CSI frames ───→ │ Tier 2 DSP (existing) │
│ Welford stats, top-K, presence │
└──────────┬────────────────────────┘
│
┌──────────────▼──────────────────────┐
│ Budget Controller │
│ │
│ Inputs: │
│ Δλ = mincut eigenvalue gap │
│ A = anomaly_score (adversarial) │
│ T = thermal_pressure (0.0-1.0) │
│ P = battery_pressure (0.0-1.0) │
│ │
│ Output: │
│ B = frame compute budget (μs) │
│ │
│ B = clamp(B₀ + k₁·max(0,Δλ) │
│ + k₂·A │
│ − k₃·T │
│ − k₄·P, │
│ B_min, B_max) │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ WASM Module Dispatch │
│ Budget B split across active modules│
│ Each module gets B/N μs per frame │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ Delta Export │
│ Only emit events when Δ > threshold │
│ Quiet room → near-zero UDP traffic │
└─────────────────────────────────────┘
B = clamp(B₀ + k₁·max(0, Δλ) + k₂·A − k₃·T − k₄·P, B_min, B_max)
| Symbol | Default | Description |
|---|---|---|
| B₀ | 5,000 μs | Base budget (5 ms) |
| k₁ | 2,000 | Δλ sensitivity (more scene change → more budget) |
| k₂ | 3,000 | Anomaly boost (detected anomaly → more compute) |
| k₃ | 4,000 | Thermal penalty (chip hot → less compute) |
| k₄ | 3,000 | Battery penalty (low SoC → less compute) |
| B_min | 1,000 μs | Floor: always run at least 1 ms |
| B_max | 15,000 μs | Ceiling: never exceed 15 ms |
The mincut graph is the top-K subcarrier correlation graph already maintained by Tier 1/2 DSP. Subcarriers are nodes; edge weights are pairwise Pearson correlation magnitudes over the Welford window. The algebraic connectivity (Fiedler value λ₂) of this graph's Laplacian approximates the mincut value. On ESP32-S3 with K=8 subcarriers, this is an 8×8 eigenvalue problem — solvable with power iteration in <100 μs.
When the budget is tight (Δλ ≈ 0, quiet room), WASM modules should:
- Skip on_frame entirely if Δλ < ε (no scene change → no computation)
- Sparse inference: Only process the top-K subcarriers that changed (already tracked by Tier 1 delta compression)
- Spiking semantics: Modules emit events only when state transitions occur, not on every frame. The host tracks a per-module "last emitted" state and suppresses duplicate events.
ESP32-S3 provides:
temp_sensor_read()— on-chip temperature (°C)- ADC reading of battery voltage (if wired)
Thermal pressure: T = clamp((temp_celsius - 60) / 20, 0, 1) — ramps
from 0 at 60°C to 1.0 at 80°C (thermal throttle zone).
Battery pressure: P = clamp((3.3 - battery_volts) / 0.6, 0, 1) — ramps
from 0 at 3.3V to 1.0 at 2.7V (brownout zone).
WASM output packets (0xC5110004) adopt delta-only export:
- Events are only emitted when the value changes by more than a configurable dead-band (default: 5% of previous value)
- Quiet room = zero WASM UDP packets (only Tier 2 vitals at 1 Hz)
- Busy room = bursty WASM events, naturally rate-limited by budget B
Future work: QUIC-lite transport with 0-RTT connection resumption and congestion-aware pacing, replacing raw UDP for WASM event streams.
Measured on ESP32-S3 (QFN56 rev v0.2, 8 MB flash, 160 MHz, ESP-IDF v5.2, board without PSRAM). WiFi connected to AP at RSSI -25 dBm, channel 5 BW20.
| Metric | Value |
|---|---|
| WASM runtime init | 106 ms |
| Total boot to ready | 3.9 s (including WiFi connect) |
| Module slots | 4 × 160 KB (heap fallback, no PSRAM) |
| WASM binary size (7 modules) | 13.8 KB (wasm32-unknown-unknown release) |
| Frame budget | 10,000 µs (10 ms) |
| Timer interval | 1,000 ms (1 Hz) |
| Metric | Value |
|---|---|
| Frame rate | 28.5 Hz (exceeds 20 Hz estimate) |
| Frame sizes | 128 / 256 bytes |
| Per-frame interval | 30.6 ms avg |
| RSSI range | -83 to -32 dBm (mean -62 dBm) |
| Crate | Tests | Status |
|---|---|---|
| wifi-densepose-wasm-edge (std) | 14 | All pass, 0 warnings |
| Full workspace | 1,411 | All pass, 0 failed |
- Fall threshold too sensitive — default 2.0 rad/s² produces 6.7 false positives/s in static environment. Recommend 5.0-8.0 for deployment.
- No PSRAM on test board — WASM arenas fall back to internal heap (316 KiB total). Production boards with 8 MB PSRAM will use dedicated PSRAM arenas.
- WiFi-Ethernet isolation — some consumer routers block bridging between WiFi and wired clients. Verify network path during deployment.
| Step | Scope | Effort |
|---|---|---|
| 1 | Add edge_compute_fiedler() in edge_processing.c — power iteration on 8×8 Laplacian |
~50 lines C |
| 2 | Add budget controller struct and update formula in wasm_runtime.c |
~30 lines C |
| 3 | Wire thermal/battery sensors into budget inputs | ~20 lines C |
| 4 | Add delta-export dead-band filter in wasm_runtime_on_frame() |
~15 lines C |
| 5 | NVS keys for k₁-k₄, B_min, B_max, dead-band threshold | ~10 lines C |
Total: ~125 lines of C, no new files. All constants configurable via NVS.
| Failure | Behavior |
|---|---|
| Δλ estimate wrong (correlation noise) | Budget oscillates — clamped by B_min/B_max |
| Thermal sensor absent | T defaults to 0 (no throttle) |
| Battery ADC not wired | P defaults to 0 (always-on mode) |
| All WASM modules budget-faulted | DSP pipeline runs Tier 2 only — graceful degradation |
Raw .wasm uploads over HTTP are remote code execution. Signatures solve
authenticity, but without a manifest the host has no way to enforce budgets,
check API compatibility, or identify what it's running. RVF wraps the WASM
payload with governance metadata in a single artifact.
Offset Size Type Field
────────────────────────────────────────────
0 4 [u8;4] Magic "RVF\x01" (0x01465652 LE)
4 2 u16 LE format_version (1)
6 2 u16 LE flags (bit 0: has_signature, bit 1: has_test_vectors)
8 4 u32 LE manifest_len (always 96)
12 4 u32 LE wasm_len
16 4 u32 LE signature_len (0 or 64)
20 4 u32 LE test_vectors_len (0 if none)
24 4 u32 LE total_len (header + manifest + wasm + sig + tvec)
28 4 u32 LE reserved (0)
────────────────────────────────────────────
32 96 struct Manifest (see below)
128 N bytes WASM payload ("\0asm" magic)
128+N 0|64 bytes Ed25519 signature (signs bytes 0..128+N-1)
128+N+S M bytes Test vectors (optional)
Total overhead: 32 (header) + 96 (manifest) + 64 (signature) = 192 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 32 | char[] | module_name — null-terminated ASCII |
| 32 | 2 | u16 | required_host_api — version (1 = current) |
| 34 | 4 | u32 | capabilities — RVF_CAP_* bitmask |
| 38 | 4 | u32 | max_frame_us — requested per-frame budget (0 = use default) |
| 42 | 2 | u16 | max_events_per_sec — rate limit (0 = unlimited) |
| 44 | 2 | u16 | memory_limit_kb — max WASM heap (0 = use default) |
| 46 | 2 | u16 | event_schema_version — for receiver compatibility |
| 48 | 32 | [u8;32] | build_hash — SHA-256 of WASM payload |
| 80 | 2 | u16 | min_subcarriers — minimum required (0 = any) |
| 82 | 2 | u16 | max_subcarriers — maximum expected (0 = any) |
| 84 | 10 | char[] | author — null-padded ASCII |
| 94 | 2 | [u8;2] | reserved (0) |
| Bit | Flag | Host API functions |
|---|---|---|
| 0 | READ_PHASE |
csi_get_phase |
| 1 | READ_AMPLITUDE |
csi_get_amplitude |
| 2 | READ_VARIANCE |
csi_get_variance |
| 3 | READ_VITALS |
csi_get_bpm_*, csi_get_presence, csi_get_n_persons |
| 4 | READ_HISTORY |
csi_get_phase_history |
| 5 | EMIT_EVENTS |
csi_emit_event |
| 6 | LOG |
csi_log |
Modules declare which host APIs they need. Future firmware versions may refuse to link imports that aren't declared in capabilities — defense in depth against supply-chain attacks.
HTTP POST /wasm/upload
│
▼
┌────────────────────────┐
│ Check first 4 bytes │
│ "RVF\x01" → RVF path │
│ "\0asm" → raw path │
└───────┬────────────────┘
│
┌────▼────┐ ┌───────────┐
│ RVF │ │ Raw WASM │
│ parse │ │ (dev only,│
│ header │ │ verify=0) │
└────┬────┘ └─────┬─────┘
│ │
┌────▼────┐ │
│ Verify │ │
│ SHA-256 │ │
│ hash │ │
└────┬────┘ │
│ │
┌────▼────┐ │
│ Verify │ │
│ Ed25519 │ │
│ sig │ │
└────┬────┘ │
│ │
┌────▼────┐ │
│ Check │ │
│ host API│ │
│ version │ │
└────┬────┘ │
│ │
├────────────────┘
▼
┌───────────────────┐
│ wasm_runtime_load │
│ set_manifest │
│ start module │
└───────────────────┘
Each slot stores the SHA-256 build hash from the manifest. The /wasm/list
endpoint returns this hash. Fleet management systems can:
- Push an RVF to a node
- Verify the installed hash matches via GET
/wasm/list - Roll back by pushing the previous RVF (same slot reused after unload)
Two-slot strategy: maintain slot 0 as "last known good" and slot 1 as "candidate". Promote by stopping slot 0 and starting slot 1.
The wifi-densepose-wasm-edge crate provides rvf::builder::build_rvf()
(behind the std feature) to package a .wasm binary into an .rvf:
use wifi_densepose_wasm_edge::rvf::builder::{build_rvf, RvfConfig};
let wasm = std::fs::read("target/wasm32-unknown-unknown/release/module.wasm")?;
let rvf = build_rvf(&wasm, &RvfConfig {
module_name: "gesture".into(),
author: "rUv".into(),
capabilities: CAP_READ_PHASE | CAP_EMIT_EVENTS,
max_frame_us: 5000,
..Default::default()
});
std::fs::write("gesture.rvf", &rvf)?;
// Then sign externally with Ed25519 and patch_signature()| File | Description |
|---|---|
firmware/.../main/rvf_parser.h |
RVF types, capability flags, parse/verify API |
firmware/.../main/rvf_parser.c |
Header/manifest parser, SHA-256 hash check |
wifi-densepose-wasm-edge/src/rvf.rs |
Format constants, builder (std), tests |
| Failure | Behavior |
|---|---|
| RVF too large for PSRAM buffer | Rejected at receive with 400 |
| Build hash mismatch | Rejected at parse with ESP_ERR_INVALID_CRC |
Signature absent when wasm_verify=1 |
Rejected with 403 |
| Host API version too new | Rejected with ESP_ERR_NOT_SUPPORTED |
Raw WASM when wasm_verify=1 |
Rejected with 403 |