Skip to content

Latest commit

 

History

History
582 lines (465 loc) · 25.6 KB

File metadata and controls

582 lines (465 loc) · 25.6 KB

ADR-040: WASM Programmable Sensing (Tier 3)

Status: Accepted Date: 2026-03-02 Deciders: @ruvnet

Context

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.

Decision

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.

Architecture

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)              │
└──────────────────────────────────────────────────┘

Components

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

Host API (namespace "csi")

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

Module Lifecycle

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

Wire Protocol (magic 0xC5110004)

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

HTTP Endpoints (port 8032)

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

WASM Crate Modules

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)

Memory Budget

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.

NVS Configuration

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

Consequences

Positive

  • 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_std Rust code runs on ESP32 (WASM3) and in browser (wasm-pack)

Negative

  • 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

Risks

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

Implementation

  • firmware/esp32-csi-node/components/wasm3/CMakeLists.txt — WASM3 ESP-IDF component
  • firmware/esp32-csi-node/main/wasm_runtime.c/h — Runtime host with 12 API bindings + manifest
  • firmware/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 verifier
  • rust-port/.../wifi-densepose-wasm-edge/ — Rust WASM crate (gesture, coherence, adversarial, rvf, occupancy, vital_trend, intrusion)
  • rust-port/.../wifi-densepose-sensing-server/src/main.rs0xC5110004 parser
  • docs/adr/ADR-039-esp32-edge-intelligence.md — Updated with Tier 3 reference

Appendix A: Production Hardening

The initial Tier 3 implementation addresses five production-readiness concerns:

A.1 Fixed PSRAM Arenas

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.

A.2 Per-Frame Budget Guard

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

A.3 Per-Module Telemetry

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.

A.4 Secure-by-Default

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 directly

Appendix B: Adaptive Budget Architecture (Mincut-Driven)

B.1 Design Principle

One 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 Δλ.

B.2 Control Loop

                  ┌─────────────────────────────────┐
  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.3 Budget Formula

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

B.4 Where Δλ Comes From

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.

B.5 Spiking and Sparse Optimizations

When the budget is tight (Δλ ≈ 0, quiet room), WASM modules should:

  1. Skip on_frame entirely if Δλ < ε (no scene change → no computation)
  2. Sparse inference: Only process the top-K subcarriers that changed (already tracked by Tier 1 delta compression)
  3. 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.

B.6 Thermal and Power Hooks

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

B.7 Transport Strategy

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.


Appendix C: Hardware Benchmark (RuView ESP32-S3)

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.

WASM Runtime Performance

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)

CSI Throughput

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)

Rust Test Results

Crate Tests Status
wifi-densepose-wasm-edge (std) 14 All pass, 0 warnings
Full workspace 1,411 All pass, 0 failed

Known Issues

  1. 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.
  2. 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.
  3. WiFi-Ethernet isolation — some consumer routers block bridging between WiFi and wired clients. Verify network path during deployment.

B.8 Implementation Plan

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.

B.9 Failure Modes

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

Appendix C: RVF Container Format

C.1 Problem

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.

C.2 Binary Layout

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.

C.3 Manifest (96 bytes, packed)

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)

C.4 Capability Bitmask

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.

C.5 On-Device Flow

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      │
 └───────────────────┘

C.6 Rollback Support

Each slot stores the SHA-256 build hash from the manifest. The /wasm/list endpoint returns this hash. Fleet management systems can:

  1. Push an RVF to a node
  2. Verify the installed hash matches via GET /wasm/list
  3. 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.

C.7 Rust Builder

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

C.8 Implementation Files

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

C.9 Failure Modes

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