mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
feat: complete vendor repos, add edge intelligence and WASM modules
- Add 154 missing vendor files (gitignore was filtering them) - vendor/midstream: 564 files (was 561) - vendor/sublinear-time-solver: 1190 files (was 1039) - Add ESP32 edge processing (ADR-039): presence, vitals, fall detection - Add WASM programmable sensing (ADR-040/041) with wasm3 runtime - Add firmware CI workflow (.github/workflows/firmware-ci.yml) - Add wifi-densepose-wasm-edge crate for edge WASM modules - Update sensing server, provision.py, UI components Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -1,126 +1,158 @@
|
||||
# ESP32-S3 CSI Node Firmware (ADR-018)
|
||||
# ESP32-S3 CSI Node Firmware
|
||||
|
||||
Firmware for ESP32-S3 that collects WiFi Channel State Information (CSI)
|
||||
and streams it as ADR-018 binary frames over UDP to the aggregator.
|
||||
**Turn a $7 microcontroller into a privacy-first human sensing node.**
|
||||
|
||||
Verified working with ESP32-S3-DevKitC-1 (CP2102, MAC 3C:0F:02:EC:C2:28)
|
||||
streaming ~20 Hz CSI to the Rust aggregator binary.
|
||||
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](../../README.md) project.
|
||||
|
||||
## Prerequisites
|
||||
[](https://docs.espressif.com/projects/esp-idf/en/v5.2/)
|
||||
[](https://www.espressif.com/en/products/socs/esp32-s3)
|
||||
[](../../LICENSE)
|
||||
[](#memory-budget)
|
||||
[](../../.github/workflows/firmware-ci.yml)
|
||||
|
||||
| Component | Version | Purpose |
|
||||
|-----------|---------|---------|
|
||||
| Docker Desktop | 28.x+ | Cross-compile ESP-IDF firmware |
|
||||
| esptool | 5.x+ | Flash firmware to ESP32 |
|
||||
| ESP32-S3 board | - | Hardware (DevKitC-1 or similar) |
|
||||
| USB-UART driver | CP210x | Silicon Labs driver for serial |
|
||||
> | 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 |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Step 1: Configure WiFi credentials
|
||||
For users who want to get running fast. Detailed explanations follow in later sections.
|
||||
|
||||
Create `sdkconfig.defaults` in this directory (it is gitignored):
|
||||
|
||||
```
|
||||
CONFIG_IDF_TARGET="esp32s3"
|
||||
CONFIG_ESP_WIFI_CSI_ENABLED=y
|
||||
CONFIG_CSI_NODE_ID=1
|
||||
CONFIG_CSI_WIFI_SSID="YOUR_WIFI_SSID"
|
||||
CONFIG_CSI_WIFI_PASSWORD="YOUR_WIFI_PASSWORD"
|
||||
CONFIG_CSI_TARGET_IP="192.168.1.20"
|
||||
CONFIG_CSI_TARGET_PORT=5005
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||
```
|
||||
|
||||
Replace `YOUR_WIFI_SSID`, `YOUR_WIFI_PASSWORD`, and `CONFIG_CSI_TARGET_IP`
|
||||
with your actual values. The target IP is the machine running the aggregator.
|
||||
|
||||
### Step 2: Build with Docker
|
||||
### 1. Build (Docker -- the only reliable method)
|
||||
|
||||
```bash
|
||||
cd firmware/esp32-csi-node
|
||||
|
||||
# On Linux/macOS:
|
||||
docker run --rm -v "$(pwd):/project" -w /project \
|
||||
espressif/idf:v5.2 bash -c "idf.py set-target esp32s3 && idf.py build"
|
||||
|
||||
# On Windows (Git Bash — MSYS path fix required):
|
||||
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd -W)://project" -w //project \
|
||||
espressif/idf:v5.2 bash -c "idf.py set-target esp32s3 && idf.py build"
|
||||
# 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"
|
||||
```
|
||||
|
||||
Build output: `build/bootloader.bin`, `build/partition_table/partition-table.bin`,
|
||||
`build/esp32-csi-node.bin`.
|
||||
|
||||
### Step 3: Flash to ESP32-S3
|
||||
|
||||
Find your serial port (`COM7` on Windows, `/dev/ttyUSB0` on Linux):
|
||||
### 2. Flash
|
||||
|
||||
```bash
|
||||
cd firmware/esp32-csi-node/build
|
||||
|
||||
python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
|
||||
--before default-reset --after hard-reset \
|
||||
write-flash --flash-mode dio --flash-freq 80m --flash-size 4MB \
|
||||
0x0 bootloader/bootloader.bin \
|
||||
0x8000 partition_table/partition-table.bin \
|
||||
0x10000 esp32-csi-node.bin
|
||||
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
|
||||
```
|
||||
|
||||
### Step 4: Run the aggregator
|
||||
### 3. Provision WiFi credentials (no reflash needed)
|
||||
|
||||
```bash
|
||||
cargo run -p wifi-densepose-hardware --bin aggregator -- --bind 0.0.0.0:5005 --verbose
|
||||
python scripts/provision.py --port COM7 \
|
||||
--ssid "YourSSID" --password "YourPass" --target-ip 192.168.1.20
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
Listening on 0.0.0.0:5005...
|
||||
[148 bytes from 192.168.1.71:60764]
|
||||
[node:1 seq:0] sc=64 rssi=-49 amp=9.5
|
||||
[276 bytes from 192.168.1.71:60764]
|
||||
[node:1 seq:1] sc=128 rssi=-64 amp=16.0
|
||||
### 4. Start the sensing server
|
||||
|
||||
```bash
|
||||
cargo run -p wifi-densepose-sensing-server -- --http-port 3000 --source auto
|
||||
```
|
||||
|
||||
### Step 5: Verify presence detection
|
||||
### 5. Open the UI
|
||||
|
||||
If you see frames streaming (~20/sec), the system is working. Walk near the
|
||||
ESP32 and observe amplitude variance changes in the CSI data.
|
||||
Navigate to [http://localhost:3000](http://localhost:3000) in your browser.
|
||||
|
||||
## Configuration Reference
|
||||
### 6. (Optional) Upload a WASM sensing module
|
||||
|
||||
Edit via `idf.py menuconfig` or `sdkconfig.defaults`:
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `CSI_NODE_ID` | 1 | Unique node identifier (0-255) |
|
||||
| `CSI_TARGET_IP` | 192.168.1.100 | Aggregator host IP |
|
||||
| `CSI_TARGET_PORT` | 5005 | Aggregator UDP port |
|
||||
| `CSI_WIFI_SSID` | wifi-densepose | WiFi network SSID |
|
||||
| `CSI_WIFI_PASSWORD` | (empty) | WiFi password |
|
||||
| `CSI_WIFI_CHANNEL` | 6 | WiFi channel to monitor |
|
||||
|
||||
## Firewall Note
|
||||
|
||||
On Windows, you may need to allow inbound UDP on port 5005:
|
||||
|
||||
```
|
||||
netsh advfirewall firewall add rule name="ESP32 CSI" dir=in action=allow protocol=UDP localport=5005
|
||||
```bash
|
||||
curl -X POST http://<ESP32_IP>:8032/wasm/upload --data-binary @gesture.rvf
|
||||
curl http://<ESP32_IP>:8032/wasm/list
|
||||
```
|
||||
|
||||
## Architecture
|
||||
---
|
||||
|
||||
## Hardware Requirements
|
||||
|
||||
| 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](https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers) |
|
||||
| **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.
|
||||
|
||||
---
|
||||
|
||||
## 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 Host Machine
|
||||
+-------------------+ +-------------------+
|
||||
| WiFi CSI callback | UDP/5005 | aggregator binary |
|
||||
| (promiscuous mode)| ──────────> | (Rust, clap CLI) |
|
||||
| ADR-018 serialize | ADR-018 | Esp32CsiParser |
|
||||
| stream_sender.c | binary frames | CsiFrame output |
|
||||
+-------------------+ +-------------------+
|
||||
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 |
|
||||
+--------------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
## Binary Frame Format (ADR-018)
|
||||
### 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](#wasm-programmable-sensing-tier-3) 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.
|
||||
|
||||
| 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) |
|
||||
|
||||
### ADR-018 Binary Frame Format
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
@@ -136,12 +168,397 @@ Offset Size Field
|
||||
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
|
||||
|
||||
| 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](https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers)) |
|
||||
|
||||
> **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
|
||||
|
||||
| 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) |
|
||||
|
||||
#### Channel Hopping and TDM (ADR-029)
|
||||
|
||||
| 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 |
|
||||
|
||||
#### Edge Intelligence (ADR-039)
|
||||
|
||||
| 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 |
|
||||
|
||||
#### WASM Programmable Sensing (ADR-040)
|
||||
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
|
||||
| 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 |
|
||||
|
||||
### Host API
|
||||
|
||||
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 |
|
||||
|
||||
### Module Lifecycle
|
||||
|
||||
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 |
|
||||
|
||||
### HTTP Management Endpoints
|
||||
|
||||
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 |
|
||||
|
||||
### Included WASM Modules
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
cargo build -p wifi-densepose-wasm-edge --target wasm32-unknown-unknown --release
|
||||
```
|
||||
|
||||
### Safety Features
|
||||
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## Memory Budget
|
||||
|
||||
| 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)
|
||||
|
||||
---
|
||||
|
||||
## Source Files
|
||||
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
## 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`](../../.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 |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| No serial output | Wrong baud rate | Use 115200 |
|
||||
| WiFi won't connect | Wrong SSID/password | Check sdkconfig.defaults |
|
||||
| No UDP frames | Firewall blocking | Add UDP 5005 inbound rule |
|
||||
| CSI callback not firing | Promiscuous mode off | Verify `esp_wifi_set_promiscuous(true)` in csi_collector.c |
|
||||
| Parse errors in aggregator | Firmware/parser mismatch | Rebuild both from same source |
|
||||
| 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` |
|
||||
|
||||
### 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:
|
||||
|
||||
| ADR | Title | Status |
|
||||
|-----|-------|--------|
|
||||
| [ADR-018](../../docs/adr/ADR-018-csi-binary-frame-format.md) | CSI binary frame format | Accepted |
|
||||
| [ADR-029](../../docs/adr/ADR-029-ruvsense-multistatic-sensing-mode.md) | Channel hopping and TDM protocol | Accepted |
|
||||
| [ADR-039](../../docs/adr/ADR-039-esp32-edge-intelligence.md) | Edge intelligence tiers 0-2 | Accepted |
|
||||
| [ADR-040](../../docs/adr/) | WASM programmable sensing (Tier 3) with RVF container format | Alpha |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This firmware is dual-licensed under [MIT](../../LICENSE-MIT) OR [Apache-2.0](../../LICENSE-APACHE), at your option.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# WASM3 — WebAssembly interpreter for ESP-IDF
|
||||
#
|
||||
# ADR-040: Tier 3 WASM programmable sensing layer.
|
||||
# WASM3 is an MIT-licensed, lightweight interpreter (~100 KB flash, ~10 KB RAM)
|
||||
# optimized for embedded targets including Xtensa ESP32-S3.
|
||||
#
|
||||
# Pre-download WASM3 source before building:
|
||||
# cd firmware/esp32-csi-node/components/wasm3
|
||||
# git clone --depth 1 https://github.com/wasm3/wasm3.git wasm3-src
|
||||
#
|
||||
# Or run: scripts/fetch-wasm3.sh
|
||||
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
set(WASM3_DIR "${CMAKE_CURRENT_SOURCE_DIR}/wasm3-src")
|
||||
|
||||
if(NOT EXISTS "${WASM3_DIR}/source/wasm3.h")
|
||||
message(STATUS "WASM3 source not found at ${WASM3_DIR}")
|
||||
message(STATUS "Attempting to download WASM3...")
|
||||
|
||||
# Try downloading inside build environment.
|
||||
set(WASM3_URL "https://github.com/nicholasgasior/wasm3/archive/refs/heads/main.tar.gz")
|
||||
set(WASM3_ARCHIVE "${CMAKE_CURRENT_BINARY_DIR}/wasm3.tar.gz")
|
||||
|
||||
file(DOWNLOAD "${WASM3_URL}" "${WASM3_ARCHIVE}"
|
||||
STATUS DOWNLOAD_STATUS TIMEOUT 30)
|
||||
list(GET DOWNLOAD_STATUS 0 DL_CODE)
|
||||
|
||||
if(DL_CODE EQUAL 0)
|
||||
execute_process(
|
||||
COMMAND ${CMAKE_COMMAND} -E tar xzf "${WASM3_ARCHIVE}"
|
||||
WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}")
|
||||
file(GLOB WASM3_EXTRACTED "${CMAKE_CURRENT_BINARY_DIR}/wasm3-*")
|
||||
if(WASM3_EXTRACTED)
|
||||
list(GET WASM3_EXTRACTED 0 WASM3_EXTRACTED_DIR)
|
||||
file(RENAME "${WASM3_EXTRACTED_DIR}" "${WASM3_DIR}")
|
||||
endif()
|
||||
file(REMOVE "${WASM3_ARCHIVE}")
|
||||
endif()
|
||||
|
||||
if(NOT EXISTS "${WASM3_DIR}/source/wasm3.h")
|
||||
message(WARNING "WASM3 source not available. Building WITHOUT WASM Tier 3 support.\n"
|
||||
"To enable: git clone --depth 1 https://github.com/wasm3/wasm3.git "
|
||||
"${WASM3_DIR}")
|
||||
# Register empty component so ESP-IDF doesn't error.
|
||||
idf_component_register()
|
||||
return()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# Collect all WASM3 source files.
|
||||
file(GLOB WASM3_SOURCES "${WASM3_DIR}/source/*.c")
|
||||
|
||||
idf_component_register(
|
||||
SRCS ${WASM3_SOURCES}
|
||||
INCLUDE_DIRS "${WASM3_DIR}/source"
|
||||
)
|
||||
|
||||
# WASM3 configuration for ESP32-S3 Xtensa target.
|
||||
target_compile_definitions(${COMPONENT_LIB} PUBLIC
|
||||
d_m3HasFloat=1 # Enable float support (needed for DSP)
|
||||
d_m3Use32BitSlots=1 # 32-bit value slots (saves RAM on ESP32)
|
||||
d_m3MaxFunctionStackHeight=128 # Conservative stack depth
|
||||
d_m3CodePageAlignSize=4096 # Page alignment for Xtensa
|
||||
d_m3LogOutput=0 # Disable WASM3 stdout logging (use ESP_LOG)
|
||||
d_m3FixedHeap=0 # Use dynamic allocation (PSRAM-friendly)
|
||||
WASM3_AVAILABLE=1 # Flag for conditional compilation
|
||||
)
|
||||
|
||||
# Suppress warnings from third-party code.
|
||||
target_compile_options(${COMPONENT_LIB} PRIVATE
|
||||
-Wno-unused-function
|
||||
-Wno-unused-variable
|
||||
-Wno-maybe-uninitialized
|
||||
-Wno-sign-compare
|
||||
)
|
||||
@@ -1,4 +1,6 @@
|
||||
idf_component_register(
|
||||
SRCS "main.c" "csi_collector.c" "stream_sender.c" "nvs_config.c"
|
||||
"edge_processing.c" "ota_update.c" "power_mgmt.c"
|
||||
"wasm_runtime.c" "wasm_upload.c" "rvf_parser.c"
|
||||
INCLUDE_DIRS "."
|
||||
)
|
||||
|
||||
@@ -39,18 +39,84 @@ menu "CSI Node Configuration"
|
||||
help
|
||||
WiFi channel to listen on for CSI data.
|
||||
|
||||
config CSI_FILTER_MAC
|
||||
string "CSI source MAC filter (AA:BB:CC:DD:EE:FF or empty)"
|
||||
default ""
|
||||
endmenu
|
||||
|
||||
menu "Edge Intelligence (ADR-039)"
|
||||
|
||||
config EDGE_TIER
|
||||
int "Edge processing tier (0=raw, 1=basic, 2=full)"
|
||||
default 2
|
||||
range 0 2
|
||||
help
|
||||
When set to a valid MAC address (e.g. "AA:BB:CC:DD:EE:FF"),
|
||||
only CSI frames from that transmitter are processed. All
|
||||
other frames are silently dropped. This prevents signal
|
||||
mixing in multi-AP environments.
|
||||
0 = Raw passthrough (no on-device DSP).
|
||||
1 = Basic presence/motion detection.
|
||||
2 = Full pipeline (vitals, compression, multi-person).
|
||||
|
||||
Leave empty to accept CSI from all transmitters.
|
||||
config EDGE_VITAL_INTERVAL_MS
|
||||
int "Vitals packet send interval (ms)"
|
||||
default 1000
|
||||
range 100 10000
|
||||
help
|
||||
How often to send vitals packets over UDP.
|
||||
|
||||
Can be overridden at runtime via NVS key "filter_mac"
|
||||
(6-byte blob) without reflashing.
|
||||
config EDGE_TOP_K
|
||||
int "Top-K subcarriers to track"
|
||||
default 8
|
||||
range 1 32
|
||||
help
|
||||
Number of highest-variance subcarriers to use for DSP.
|
||||
|
||||
config EDGE_FALL_THRESH
|
||||
int "Fall detection threshold (x1000)"
|
||||
default 2000
|
||||
range 100 50000
|
||||
help
|
||||
Phase acceleration threshold for fall detection.
|
||||
Stored as integer; divided by 1000 at runtime.
|
||||
Default 2000 = 2.0 rad/s^2.
|
||||
|
||||
config EDGE_POWER_DUTY
|
||||
int "Power duty cycle percentage"
|
||||
default 100
|
||||
range 10 100
|
||||
help
|
||||
Active duty cycle for battery-powered nodes.
|
||||
100 = always on. 50 = active half the time.
|
||||
|
||||
endmenu
|
||||
|
||||
menu "WASM Programmable Sensing (ADR-040)"
|
||||
|
||||
config WASM_ENABLE
|
||||
bool "Enable WASM Tier 3 runtime"
|
||||
default y
|
||||
help
|
||||
Enable the WASM3 interpreter for hot-loadable sensing modules.
|
||||
Requires WASM3 source in components/wasm3/wasm3-src/.
|
||||
Adds ~120 KB flash and ~20 KB SRAM.
|
||||
|
||||
config WASM_MAX_MODULES
|
||||
int "Maximum concurrent WASM modules"
|
||||
default 4
|
||||
range 1 8
|
||||
help
|
||||
Number of WASM module slots. Each slot can hold one
|
||||
loaded .wasm binary (stored in PSRAM, max 128 KB each).
|
||||
|
||||
config WASM_VERIFY_SIGNATURE
|
||||
bool "Require Ed25519 signature verification for WASM uploads"
|
||||
default y
|
||||
help
|
||||
When enabled, uploaded .wasm binaries must include a valid
|
||||
Ed25519 signature. Uses the same signing key as OTA firmware.
|
||||
Disable with provision.py --no-wasm-verify for lab/dev use.
|
||||
|
||||
config WASM_TIMER_INTERVAL_MS
|
||||
int "WASM on_timer() interval (ms)"
|
||||
default 1000
|
||||
range 100 60000
|
||||
help
|
||||
How often to call on_timer() on running WASM modules.
|
||||
Default 1000 ms = 1 Hz.
|
||||
|
||||
endmenu
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
#include "csi_collector.h"
|
||||
#include "stream_sender.h"
|
||||
#include "edge_processing.h"
|
||||
|
||||
#include <string.h>
|
||||
#include "esp_log.h"
|
||||
@@ -26,15 +27,6 @@ static uint32_t s_sequence = 0;
|
||||
static uint32_t s_cb_count = 0;
|
||||
static uint32_t s_send_ok = 0;
|
||||
static uint32_t s_send_fail = 0;
|
||||
static uint32_t s_filtered = 0;
|
||||
|
||||
/* ---- MAC address filter (Issue #98) ---- */
|
||||
|
||||
/** When non-zero, only CSI from s_filter_mac is accepted. */
|
||||
static uint8_t s_filter_enabled = 0;
|
||||
|
||||
/** The accepted transmitter MAC address (6 bytes). */
|
||||
static uint8_t s_filter_mac[6] = {0};
|
||||
|
||||
/* ---- ADR-029: Channel-hop state ---- */
|
||||
|
||||
@@ -133,52 +125,18 @@ size_t csi_serialize_frame(const wifi_csi_info_t *info, uint8_t *buf, size_t buf
|
||||
return frame_size;
|
||||
}
|
||||
|
||||
void csi_collector_set_filter_mac(const uint8_t *mac)
|
||||
{
|
||||
if (mac == NULL) {
|
||||
s_filter_enabled = 0;
|
||||
memset(s_filter_mac, 0, 6);
|
||||
ESP_LOGI(TAG, "MAC filter disabled — accepting CSI from all transmitters");
|
||||
} else {
|
||||
memcpy(s_filter_mac, mac, 6);
|
||||
s_filter_enabled = 1;
|
||||
ESP_LOGI(TAG, "MAC filter enabled: only accepting %02X:%02X:%02X:%02X:%02X:%02X",
|
||||
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
|
||||
}
|
||||
s_filtered = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* WiFi CSI callback — invoked by ESP-IDF when CSI data is available.
|
||||
*
|
||||
* When a MAC filter is active, frames from non-matching transmitters are
|
||||
* silently dropped to prevent signal mixing in multi-AP environments.
|
||||
*/
|
||||
static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
|
||||
{
|
||||
(void)ctx;
|
||||
s_cb_count++;
|
||||
|
||||
/* ---- MAC address filter (Issue #98) ---- */
|
||||
if (s_filter_enabled) {
|
||||
if (memcmp(info->mac, s_filter_mac, 6) != 0) {
|
||||
s_filtered++;
|
||||
if (s_filtered <= 3 || (s_filtered % 500) == 0) {
|
||||
ESP_LOGD(TAG, "Filtered CSI from %02X:%02X:%02X:%02X:%02X:%02X (dropped %lu)",
|
||||
info->mac[0], info->mac[1], info->mac[2],
|
||||
info->mac[3], info->mac[4], info->mac[5],
|
||||
(unsigned long)s_filtered);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (s_cb_count <= 3 || (s_cb_count % 100) == 0) {
|
||||
ESP_LOGI(TAG, "CSI cb #%lu: len=%d rssi=%d ch=%d mac=%02X:%02X:%02X:%02X:%02X:%02X",
|
||||
ESP_LOGI(TAG, "CSI cb #%lu: len=%d rssi=%d ch=%d",
|
||||
(unsigned long)s_cb_count, info->len,
|
||||
info->rx_ctrl.rssi, info->rx_ctrl.channel,
|
||||
info->mac[0], info->mac[1], info->mac[2],
|
||||
info->mac[3], info->mac[4], info->mac[5]);
|
||||
info->rx_ctrl.rssi, info->rx_ctrl.channel);
|
||||
}
|
||||
|
||||
uint8_t frame_buf[CSI_MAX_FRAME_SIZE];
|
||||
@@ -195,6 +153,12 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ADR-039: Enqueue raw I/Q into edge processing ring buffer. */
|
||||
if (info->buf && info->len > 0) {
|
||||
edge_enqueue_csi((const uint8_t *)info->buf, (uint16_t)info->len,
|
||||
(int8_t)info->rx_ctrl.rssi, info->rx_ctrl.channel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include "esp_err.h"
|
||||
#include "esp_wifi_types.h"
|
||||
|
||||
/** ADR-018 magic number. */
|
||||
@@ -22,28 +23,12 @@
|
||||
/** Maximum number of channels in the hop table (ADR-029). */
|
||||
#define CSI_HOP_CHANNELS_MAX 6
|
||||
|
||||
/** Length of a MAC address in bytes. */
|
||||
#define CSI_MAC_LEN 6
|
||||
|
||||
/**
|
||||
* Initialize CSI collection.
|
||||
* Registers the WiFi CSI callback.
|
||||
*/
|
||||
void csi_collector_init(void);
|
||||
|
||||
/**
|
||||
* Set a MAC address filter for CSI collection.
|
||||
*
|
||||
* When set, only CSI frames from the specified transmitter MAC are processed;
|
||||
* all others are silently dropped. This prevents signal mixing in multi-AP
|
||||
* environments.
|
||||
*
|
||||
* Pass NULL to disable filtering (accept CSI from all transmitters).
|
||||
*
|
||||
* @param mac 6-byte MAC address to accept, or NULL to disable filtering.
|
||||
*/
|
||||
void csi_collector_set_filter_mac(const uint8_t *mac);
|
||||
|
||||
/**
|
||||
* Serialize CSI data into ADR-018 binary frame format.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,906 @@
|
||||
/**
|
||||
* @file edge_processing.c
|
||||
* @brief ADR-039 Edge Intelligence — dual-core CSI processing pipeline.
|
||||
*
|
||||
* Core 0 (WiFi task): Pushes raw CSI frames into lock-free SPSC ring buffer.
|
||||
* Core 1 (DSP task): Pops frames, runs signal processing pipeline:
|
||||
* 1. Phase extraction from I/Q pairs
|
||||
* 2. Phase unwrapping (continuous phase)
|
||||
* 3. Welford variance tracking per subcarrier
|
||||
* 4. Top-K subcarrier selection by variance
|
||||
* 5. Biquad IIR bandpass → breathing (0.1-0.5 Hz), heart rate (0.8-2.0 Hz)
|
||||
* 6. Zero-crossing BPM estimation
|
||||
* 7. Presence detection (adaptive or fixed threshold)
|
||||
* 8. Fall detection (phase acceleration)
|
||||
* 9. Multi-person vitals via subcarrier group clustering
|
||||
* 10. Delta compression (XOR + RLE) for bandwidth reduction
|
||||
* 11. Vitals packet broadcast (magic 0xC5110002)
|
||||
*/
|
||||
|
||||
#include "edge_processing.h"
|
||||
#include "wasm_runtime.h"
|
||||
#include "stream_sender.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "sdkconfig.h"
|
||||
|
||||
static const char *TAG = "edge_proc";
|
||||
|
||||
/* ======================================================================
|
||||
* SPSC Ring Buffer (lock-free, single-producer single-consumer)
|
||||
* ====================================================================== */
|
||||
|
||||
static edge_ring_buf_t s_ring;
|
||||
|
||||
static inline bool ring_push(const uint8_t *iq, uint16_t len,
|
||||
int8_t rssi, uint8_t channel)
|
||||
{
|
||||
uint32_t next = (s_ring.head + 1) % EDGE_RING_SLOTS;
|
||||
if (next == s_ring.tail) {
|
||||
return false; /* Full — drop frame. */
|
||||
}
|
||||
|
||||
edge_ring_slot_t *slot = &s_ring.slots[s_ring.head];
|
||||
uint16_t copy_len = (len > EDGE_MAX_IQ_BYTES) ? EDGE_MAX_IQ_BYTES : len;
|
||||
memcpy(slot->iq_data, iq, copy_len);
|
||||
slot->iq_len = copy_len;
|
||||
slot->rssi = rssi;
|
||||
slot->channel = channel;
|
||||
slot->timestamp_us = (uint32_t)(esp_timer_get_time() & 0xFFFFFFFF);
|
||||
|
||||
/* Memory barrier: ensure slot data is visible before advancing head. */
|
||||
__sync_synchronize();
|
||||
s_ring.head = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
static inline bool ring_pop(edge_ring_slot_t *out)
|
||||
{
|
||||
if (s_ring.tail == s_ring.head) {
|
||||
return false; /* Empty. */
|
||||
}
|
||||
|
||||
memcpy(out, &s_ring.slots[s_ring.tail], sizeof(edge_ring_slot_t));
|
||||
|
||||
__sync_synchronize();
|
||||
s_ring.tail = (s_ring.tail + 1) % EDGE_RING_SLOTS;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Biquad IIR Filter
|
||||
* ====================================================================== */
|
||||
|
||||
/**
|
||||
* Design a 2nd-order Butterworth bandpass biquad.
|
||||
*
|
||||
* @param bq Output biquad state.
|
||||
* @param fs Sampling frequency (Hz).
|
||||
* @param f_lo Low cutoff frequency (Hz).
|
||||
* @param f_hi High cutoff frequency (Hz).
|
||||
*/
|
||||
static void biquad_bandpass_design(edge_biquad_t *bq, float fs,
|
||||
float f_lo, float f_hi)
|
||||
{
|
||||
float w0 = 2.0f * M_PI * (f_lo + f_hi) / 2.0f / fs;
|
||||
float bw = 2.0f * M_PI * (f_hi - f_lo) / fs;
|
||||
float alpha = sinf(w0) * sinhf(logf(2.0f) / 2.0f * bw / sinf(w0));
|
||||
|
||||
float a0_inv = 1.0f / (1.0f + alpha);
|
||||
bq->b0 = alpha * a0_inv;
|
||||
bq->b1 = 0.0f;
|
||||
bq->b2 = -alpha * a0_inv;
|
||||
bq->a1 = -2.0f * cosf(w0) * a0_inv;
|
||||
bq->a2 = (1.0f - alpha) * a0_inv;
|
||||
|
||||
bq->x1 = bq->x2 = 0.0f;
|
||||
bq->y1 = bq->y2 = 0.0f;
|
||||
}
|
||||
|
||||
static inline float biquad_process(edge_biquad_t *bq, float x)
|
||||
{
|
||||
float y = bq->b0 * x + bq->b1 * bq->x1 + bq->b2 * bq->x2
|
||||
- bq->a1 * bq->y1 - bq->a2 * bq->y2;
|
||||
bq->x2 = bq->x1;
|
||||
bq->x1 = x;
|
||||
bq->y2 = bq->y1;
|
||||
bq->y1 = y;
|
||||
return y;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Phase Extraction and Unwrapping
|
||||
* ====================================================================== */
|
||||
|
||||
/** Extract phase (radians) from an I/Q pair at byte offset. */
|
||||
static inline float extract_phase(const uint8_t *iq, uint16_t idx)
|
||||
{
|
||||
int8_t i_val = (int8_t)iq[idx * 2];
|
||||
int8_t q_val = (int8_t)iq[idx * 2 + 1];
|
||||
return atan2f((float)q_val, (float)i_val);
|
||||
}
|
||||
|
||||
/** Unwrap phase to maintain continuity (avoid 2*pi jumps). */
|
||||
static inline float unwrap_phase(float prev, float curr)
|
||||
{
|
||||
float diff = curr - prev;
|
||||
if (diff > M_PI) diff -= 2.0f * M_PI;
|
||||
else if (diff < -M_PI) diff += 2.0f * M_PI;
|
||||
return prev + diff;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Welford Running Statistics
|
||||
* ====================================================================== */
|
||||
|
||||
static inline void welford_reset(edge_welford_t *w)
|
||||
{
|
||||
w->mean = 0.0;
|
||||
w->m2 = 0.0;
|
||||
w->count = 0;
|
||||
}
|
||||
|
||||
static inline void welford_update(edge_welford_t *w, double x)
|
||||
{
|
||||
w->count++;
|
||||
double delta = x - w->mean;
|
||||
w->mean += delta / (double)w->count;
|
||||
double delta2 = x - w->mean;
|
||||
w->m2 += delta * delta2;
|
||||
}
|
||||
|
||||
static inline double welford_variance(const edge_welford_t *w)
|
||||
{
|
||||
return (w->count > 1) ? (w->m2 / (double)(w->count - 1)) : 0.0;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Zero-Crossing BPM Estimation
|
||||
* ====================================================================== */
|
||||
|
||||
/**
|
||||
* Estimate BPM from a filtered signal using positive zero-crossings.
|
||||
*
|
||||
* @param history Signal buffer (filtered phase).
|
||||
* @param len Number of samples.
|
||||
* @param sample_rate Sampling rate in Hz.
|
||||
* @return Estimated BPM, or 0 if insufficient crossings.
|
||||
*/
|
||||
static float estimate_bpm_zero_crossing(const float *history, uint16_t len,
|
||||
float sample_rate)
|
||||
{
|
||||
if (len < 4) return 0.0f;
|
||||
|
||||
uint16_t crossings[128];
|
||||
uint16_t n_cross = 0;
|
||||
|
||||
for (uint16_t i = 1; i < len && n_cross < 128; i++) {
|
||||
if (history[i - 1] <= 0.0f && history[i] > 0.0f) {
|
||||
crossings[n_cross++] = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (n_cross < 2) return 0.0f;
|
||||
|
||||
/* Average period from consecutive crossings. */
|
||||
float total_period = 0.0f;
|
||||
for (uint16_t i = 1; i < n_cross; i++) {
|
||||
total_period += (float)(crossings[i] - crossings[i - 1]);
|
||||
}
|
||||
float avg_period_samples = total_period / (float)(n_cross - 1);
|
||||
|
||||
if (avg_period_samples < 1.0f) return 0.0f;
|
||||
|
||||
float freq_hz = sample_rate / avg_period_samples;
|
||||
return freq_hz * 60.0f; /* Hz to BPM. */
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* DSP Pipeline State
|
||||
* ====================================================================== */
|
||||
|
||||
/** Edge processing configuration. */
|
||||
static edge_config_t s_cfg;
|
||||
|
||||
/** Per-subcarrier running variance (for top-K selection). */
|
||||
static edge_welford_t s_subcarrier_var[EDGE_MAX_SUBCARRIERS];
|
||||
|
||||
/** Previous phase per subcarrier (for unwrapping). */
|
||||
static float s_prev_phase[EDGE_MAX_SUBCARRIERS];
|
||||
static bool s_phase_initialized;
|
||||
|
||||
/** Top-K subcarrier indices (sorted by variance, descending). */
|
||||
static uint8_t s_top_k[EDGE_TOP_K];
|
||||
static uint8_t s_top_k_count;
|
||||
|
||||
/** Phase history for the primary (highest-variance) subcarrier. */
|
||||
static float s_phase_history[EDGE_PHASE_HISTORY_LEN];
|
||||
static uint16_t s_history_len;
|
||||
static uint16_t s_history_idx;
|
||||
|
||||
/** Biquad filters for breathing and heart rate. */
|
||||
static edge_biquad_t s_bq_breathing;
|
||||
static edge_biquad_t s_bq_heartrate;
|
||||
|
||||
/** Filtered signal histories for BPM estimation. */
|
||||
static float s_breathing_filtered[EDGE_PHASE_HISTORY_LEN];
|
||||
static float s_heartrate_filtered[EDGE_PHASE_HISTORY_LEN];
|
||||
|
||||
/** Latest vitals state. */
|
||||
static float s_breathing_bpm;
|
||||
static float s_heartrate_bpm;
|
||||
static float s_motion_energy;
|
||||
static float s_presence_score;
|
||||
static bool s_presence_detected;
|
||||
static bool s_fall_detected;
|
||||
static int8_t s_latest_rssi;
|
||||
static uint32_t s_frame_count;
|
||||
|
||||
/** Previous phase velocity for fall detection (acceleration). */
|
||||
static float s_prev_phase_velocity;
|
||||
|
||||
/** Adaptive calibration state. */
|
||||
static bool s_calibrated;
|
||||
static float s_calib_sum;
|
||||
static float s_calib_sum_sq;
|
||||
static uint32_t s_calib_count;
|
||||
static float s_adaptive_threshold;
|
||||
|
||||
/** Last vitals send timestamp. */
|
||||
static int64_t s_last_vitals_send_us;
|
||||
|
||||
/** Delta compression state. */
|
||||
static uint8_t s_prev_iq[EDGE_MAX_IQ_BYTES];
|
||||
static uint16_t s_prev_iq_len;
|
||||
static bool s_has_prev_iq;
|
||||
|
||||
/** Multi-person vitals state. */
|
||||
static edge_person_vitals_t s_persons[EDGE_MAX_PERSONS];
|
||||
static edge_biquad_t s_person_bq_br[EDGE_MAX_PERSONS];
|
||||
static edge_biquad_t s_person_bq_hr[EDGE_MAX_PERSONS];
|
||||
static float s_person_br_filt[EDGE_MAX_PERSONS][EDGE_PHASE_HISTORY_LEN];
|
||||
static float s_person_hr_filt[EDGE_MAX_PERSONS][EDGE_PHASE_HISTORY_LEN];
|
||||
|
||||
/** Latest vitals packet (thread-safe via volatile copy). */
|
||||
static volatile edge_vitals_pkt_t s_latest_pkt;
|
||||
static volatile bool s_pkt_valid;
|
||||
|
||||
/* ======================================================================
|
||||
* Top-K Subcarrier Selection
|
||||
* ====================================================================== */
|
||||
|
||||
/**
|
||||
* Select top-K subcarriers by variance (descending).
|
||||
* Uses partial insertion sort — O(n*K) which is fine for n <= 128.
|
||||
*/
|
||||
static void update_top_k(uint16_t n_subcarriers)
|
||||
{
|
||||
uint8_t k = s_cfg.top_k_count;
|
||||
if (k > EDGE_TOP_K) k = EDGE_TOP_K;
|
||||
if (k > n_subcarriers) k = (uint8_t)n_subcarriers;
|
||||
|
||||
/* Simple selection: find K largest variances. */
|
||||
bool used[EDGE_MAX_SUBCARRIERS];
|
||||
memset(used, 0, sizeof(used));
|
||||
|
||||
for (uint8_t ki = 0; ki < k; ki++) {
|
||||
double best_var = -1.0;
|
||||
uint8_t best_idx = 0;
|
||||
|
||||
for (uint16_t sc = 0; sc < n_subcarriers; sc++) {
|
||||
if (!used[sc]) {
|
||||
double v = welford_variance(&s_subcarrier_var[sc]);
|
||||
if (v > best_var) {
|
||||
best_var = v;
|
||||
best_idx = (uint8_t)sc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s_top_k[ki] = best_idx;
|
||||
used[best_idx] = true;
|
||||
}
|
||||
|
||||
s_top_k_count = k;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Adaptive Presence Calibration
|
||||
* ====================================================================== */
|
||||
|
||||
static void calibration_update(float motion)
|
||||
{
|
||||
if (s_calibrated) return;
|
||||
|
||||
s_calib_sum += motion;
|
||||
s_calib_sum_sq += motion * motion;
|
||||
s_calib_count++;
|
||||
|
||||
if (s_calib_count >= EDGE_CALIB_FRAMES) {
|
||||
float mean = s_calib_sum / (float)s_calib_count;
|
||||
float var = (s_calib_sum_sq / (float)s_calib_count) - (mean * mean);
|
||||
float sigma = (var > 0.0f) ? sqrtf(var) : 0.001f;
|
||||
|
||||
s_adaptive_threshold = mean + EDGE_CALIB_SIGMA_MULT * sigma;
|
||||
if (s_adaptive_threshold < 0.01f) {
|
||||
s_adaptive_threshold = 0.01f;
|
||||
}
|
||||
|
||||
s_calibrated = true;
|
||||
ESP_LOGI(TAG, "Adaptive calibration complete: mean=%.4f sigma=%.4f "
|
||||
"threshold=%.4f (from %lu frames)",
|
||||
mean, sigma, s_adaptive_threshold,
|
||||
(unsigned long)s_calib_count);
|
||||
}
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Delta Compression (XOR + RLE)
|
||||
* ====================================================================== */
|
||||
|
||||
/**
|
||||
* Delta-compress I/Q data relative to previous frame.
|
||||
* Format: [XOR'd bytes], then RLE-encoded.
|
||||
*
|
||||
* @param curr Current I/Q data.
|
||||
* @param len Length of I/Q data.
|
||||
* @param out Output compressed buffer.
|
||||
* @param out_max Max output buffer size.
|
||||
* @return Compressed size, or 0 if compression would expand the data.
|
||||
*/
|
||||
static uint16_t delta_compress(const uint8_t *curr, uint16_t len,
|
||||
uint8_t *out, uint16_t out_max)
|
||||
{
|
||||
if (!s_has_prev_iq || len != s_prev_iq_len || len == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* XOR delta. */
|
||||
uint8_t xor_buf[EDGE_MAX_IQ_BYTES];
|
||||
for (uint16_t i = 0; i < len; i++) {
|
||||
xor_buf[i] = curr[i] ^ s_prev_iq[i];
|
||||
}
|
||||
|
||||
/* RLE encode: [value, count] pairs.
|
||||
* If count > 255, emit multiple pairs. */
|
||||
uint16_t out_idx = 0;
|
||||
|
||||
uint16_t i = 0;
|
||||
while (i < len) {
|
||||
uint8_t val = xor_buf[i];
|
||||
uint16_t run = 1;
|
||||
while (i + run < len && xor_buf[i + run] == val && run < 255) {
|
||||
run++;
|
||||
}
|
||||
|
||||
if (out_idx + 2 > out_max) return 0; /* Would overflow. */
|
||||
out[out_idx++] = val;
|
||||
out[out_idx++] = (uint8_t)run;
|
||||
i += run;
|
||||
}
|
||||
|
||||
/* Only use compression if it actually saves space. */
|
||||
if (out_idx >= len) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return out_idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a compressed CSI frame (magic 0xC5110003).
|
||||
*
|
||||
* Header:
|
||||
* [0..3] Magic 0xC5110003 (LE)
|
||||
* [4] Node ID
|
||||
* [5] Channel
|
||||
* [6..7] Original I/Q length (LE u16)
|
||||
* [8..9] Compressed length (LE u16)
|
||||
* [10..] Compressed data
|
||||
*/
|
||||
static void send_compressed_frame(const uint8_t *iq_data, uint16_t iq_len,
|
||||
uint8_t channel)
|
||||
{
|
||||
uint8_t comp_buf[EDGE_MAX_IQ_BYTES];
|
||||
uint16_t comp_len = delta_compress(iq_data, iq_len,
|
||||
comp_buf, sizeof(comp_buf));
|
||||
if (comp_len == 0) {
|
||||
/* Compression didn't help — skip sending compressed version. */
|
||||
goto store_prev;
|
||||
}
|
||||
|
||||
/* Build compressed frame packet. */
|
||||
uint16_t pkt_size = 10 + comp_len;
|
||||
uint8_t pkt[10 + EDGE_MAX_IQ_BYTES];
|
||||
|
||||
uint32_t magic = EDGE_COMPRESSED_MAGIC;
|
||||
memcpy(&pkt[0], &magic, 4);
|
||||
|
||||
#ifdef CONFIG_CSI_NODE_ID
|
||||
pkt[4] = (uint8_t)CONFIG_CSI_NODE_ID;
|
||||
#else
|
||||
pkt[4] = 0;
|
||||
#endif
|
||||
pkt[5] = channel;
|
||||
memcpy(&pkt[6], &iq_len, 2);
|
||||
memcpy(&pkt[8], &comp_len, 2);
|
||||
memcpy(&pkt[10], comp_buf, comp_len);
|
||||
|
||||
stream_sender_send(pkt, pkt_size);
|
||||
|
||||
ESP_LOGD(TAG, "Compressed frame: %u → %u bytes (%.0f%% reduction)",
|
||||
iq_len, comp_len,
|
||||
(1.0f - (float)comp_len / (float)iq_len) * 100.0f);
|
||||
|
||||
store_prev:
|
||||
/* Store current frame as reference for next delta. */
|
||||
memcpy(s_prev_iq, iq_data, iq_len);
|
||||
s_prev_iq_len = iq_len;
|
||||
s_has_prev_iq = true;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Multi-Person Vitals
|
||||
* ====================================================================== */
|
||||
|
||||
/**
|
||||
* Update multi-person vitals by assigning top-K subcarriers to person groups.
|
||||
*
|
||||
* Division strategy: top-K subcarriers are evenly divided among
|
||||
* up to EDGE_MAX_PERSONS groups. Each group tracks independent
|
||||
* phase history and BPM estimation.
|
||||
*/
|
||||
static void update_multi_person_vitals(const uint8_t *iq_data, uint16_t n_sc,
|
||||
float sample_rate)
|
||||
{
|
||||
if (s_top_k_count < 2) return;
|
||||
|
||||
/* Determine number of active persons based on available subcarriers. */
|
||||
uint8_t n_persons = s_top_k_count / 2;
|
||||
if (n_persons > EDGE_MAX_PERSONS) n_persons = EDGE_MAX_PERSONS;
|
||||
if (n_persons < 1) n_persons = 1;
|
||||
|
||||
uint8_t subs_per_person = s_top_k_count / n_persons;
|
||||
|
||||
for (uint8_t p = 0; p < n_persons; p++) {
|
||||
edge_person_vitals_t *pv = &s_persons[p];
|
||||
pv->active = true;
|
||||
pv->subcarrier_idx = s_top_k[p * subs_per_person];
|
||||
|
||||
/* Average phase across this person's subcarrier group. */
|
||||
float avg_phase = 0.0f;
|
||||
uint8_t count = 0;
|
||||
for (uint8_t s = 0; s < subs_per_person; s++) {
|
||||
uint8_t sc_idx = s_top_k[p * subs_per_person + s];
|
||||
if (sc_idx < n_sc) {
|
||||
avg_phase += extract_phase(iq_data, sc_idx);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (count > 0) avg_phase /= (float)count;
|
||||
|
||||
/* Unwrap and store in history. */
|
||||
if (pv->history_len > 0) {
|
||||
uint16_t prev_idx = (pv->history_idx + EDGE_PHASE_HISTORY_LEN - 1)
|
||||
% EDGE_PHASE_HISTORY_LEN;
|
||||
avg_phase = unwrap_phase(pv->phase_history[prev_idx], avg_phase);
|
||||
}
|
||||
|
||||
pv->phase_history[pv->history_idx] = avg_phase;
|
||||
pv->history_idx = (pv->history_idx + 1) % EDGE_PHASE_HISTORY_LEN;
|
||||
if (pv->history_len < EDGE_PHASE_HISTORY_LEN) pv->history_len++;
|
||||
|
||||
/* Filter and estimate BPM. */
|
||||
float br_val = biquad_process(&s_person_bq_br[p], avg_phase);
|
||||
float hr_val = biquad_process(&s_person_bq_hr[p], avg_phase);
|
||||
|
||||
uint16_t idx = (pv->history_idx + EDGE_PHASE_HISTORY_LEN - 1)
|
||||
% EDGE_PHASE_HISTORY_LEN;
|
||||
s_person_br_filt[p][idx] = br_val;
|
||||
s_person_hr_filt[p][idx] = hr_val;
|
||||
|
||||
/* Estimate BPM when we have enough history. */
|
||||
if (pv->history_len >= 64) {
|
||||
/* Build contiguous buffer for zero-crossing. */
|
||||
float br_buf[EDGE_PHASE_HISTORY_LEN];
|
||||
float hr_buf[EDGE_PHASE_HISTORY_LEN];
|
||||
uint16_t buf_len = pv->history_len;
|
||||
|
||||
for (uint16_t i = 0; i < buf_len; i++) {
|
||||
uint16_t ri = (pv->history_idx + EDGE_PHASE_HISTORY_LEN
|
||||
- buf_len + i) % EDGE_PHASE_HISTORY_LEN;
|
||||
br_buf[i] = s_person_br_filt[p][ri];
|
||||
hr_buf[i] = s_person_hr_filt[p][ri];
|
||||
}
|
||||
|
||||
float br = estimate_bpm_zero_crossing(br_buf, buf_len, sample_rate);
|
||||
float hr = estimate_bpm_zero_crossing(hr_buf, buf_len, sample_rate);
|
||||
|
||||
/* Sanity clamp. */
|
||||
if (br >= 6.0f && br <= 40.0f) pv->breathing_bpm = br;
|
||||
if (hr >= 40.0f && hr <= 180.0f) pv->heartrate_bpm = hr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mark remaining persons as inactive. */
|
||||
for (uint8_t p = n_persons; p < EDGE_MAX_PERSONS; p++) {
|
||||
s_persons[p].active = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Vitals Packet Sending
|
||||
* ====================================================================== */
|
||||
|
||||
static void send_vitals_packet(void)
|
||||
{
|
||||
edge_vitals_pkt_t pkt;
|
||||
memset(&pkt, 0, sizeof(pkt));
|
||||
|
||||
pkt.magic = EDGE_VITALS_MAGIC;
|
||||
#ifdef CONFIG_CSI_NODE_ID
|
||||
pkt.node_id = (uint8_t)CONFIG_CSI_NODE_ID;
|
||||
#else
|
||||
pkt.node_id = 0;
|
||||
#endif
|
||||
|
||||
pkt.flags = 0;
|
||||
if (s_presence_detected) pkt.flags |= 0x01;
|
||||
if (s_fall_detected) pkt.flags |= 0x02;
|
||||
if (s_motion_energy > 0.01f) pkt.flags |= 0x04;
|
||||
|
||||
pkt.breathing_rate = (uint16_t)(s_breathing_bpm * 100.0f);
|
||||
pkt.heartrate = (uint32_t)(s_heartrate_bpm * 10000.0f);
|
||||
pkt.rssi = s_latest_rssi;
|
||||
|
||||
/* Count active persons. */
|
||||
uint8_t n_active = 0;
|
||||
for (uint8_t p = 0; p < EDGE_MAX_PERSONS; p++) {
|
||||
if (s_persons[p].active) n_active++;
|
||||
}
|
||||
pkt.n_persons = n_active;
|
||||
|
||||
pkt.motion_energy = s_motion_energy;
|
||||
pkt.presence_score = s_presence_score;
|
||||
pkt.timestamp_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||
|
||||
/* Update thread-safe copy. */
|
||||
s_latest_pkt = pkt;
|
||||
s_pkt_valid = true;
|
||||
|
||||
/* Send over UDP. */
|
||||
stream_sender_send((const uint8_t *)&pkt, sizeof(pkt));
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Main DSP Pipeline (runs on Core 1)
|
||||
* ====================================================================== */
|
||||
|
||||
static void process_frame(const edge_ring_slot_t *slot)
|
||||
{
|
||||
uint16_t n_subcarriers = slot->iq_len / 2;
|
||||
if (n_subcarriers == 0 || n_subcarriers > EDGE_MAX_SUBCARRIERS) return;
|
||||
|
||||
s_frame_count++;
|
||||
s_latest_rssi = slot->rssi;
|
||||
|
||||
/* Assumed CSI sample rate (~20 Hz for typical ESP32 CSI). */
|
||||
const float sample_rate = 20.0f;
|
||||
|
||||
/* --- Step 1-2: Phase extraction + unwrapping per subcarrier --- */
|
||||
float phases[EDGE_MAX_SUBCARRIERS];
|
||||
for (uint16_t sc = 0; sc < n_subcarriers; sc++) {
|
||||
float raw_phase = extract_phase(slot->iq_data, sc);
|
||||
|
||||
if (s_phase_initialized) {
|
||||
phases[sc] = unwrap_phase(s_prev_phase[sc], raw_phase);
|
||||
} else {
|
||||
phases[sc] = raw_phase;
|
||||
}
|
||||
s_prev_phase[sc] = phases[sc];
|
||||
}
|
||||
s_phase_initialized = true;
|
||||
|
||||
/* --- Step 3: Welford variance update per subcarrier --- */
|
||||
for (uint16_t sc = 0; sc < n_subcarriers; sc++) {
|
||||
welford_update(&s_subcarrier_var[sc], (double)phases[sc]);
|
||||
}
|
||||
|
||||
/* --- Step 4: Top-K selection (every 100 frames to amortize cost) --- */
|
||||
if ((s_frame_count % 100) == 1 || s_top_k_count == 0) {
|
||||
update_top_k(n_subcarriers);
|
||||
}
|
||||
|
||||
if (s_top_k_count == 0) return;
|
||||
|
||||
/* --- Step 5: Phase of primary (highest-variance) subcarrier --- */
|
||||
float primary_phase = phases[s_top_k[0]];
|
||||
|
||||
/* Store in phase history ring buffer. */
|
||||
s_phase_history[s_history_idx] = primary_phase;
|
||||
s_history_idx = (s_history_idx + 1) % EDGE_PHASE_HISTORY_LEN;
|
||||
if (s_history_len < EDGE_PHASE_HISTORY_LEN) s_history_len++;
|
||||
|
||||
/* --- Step 6: Biquad bandpass filtering --- */
|
||||
float br_val = biquad_process(&s_bq_breathing, primary_phase);
|
||||
float hr_val = biquad_process(&s_bq_heartrate, primary_phase);
|
||||
|
||||
uint16_t filt_idx = (s_history_idx + EDGE_PHASE_HISTORY_LEN - 1)
|
||||
% EDGE_PHASE_HISTORY_LEN;
|
||||
s_breathing_filtered[filt_idx] = br_val;
|
||||
s_heartrate_filtered[filt_idx] = hr_val;
|
||||
|
||||
/* --- Step 7: BPM estimation (zero-crossing) --- */
|
||||
if (s_history_len >= 64) {
|
||||
/* Build contiguous buffers from ring. */
|
||||
float br_buf[EDGE_PHASE_HISTORY_LEN];
|
||||
float hr_buf[EDGE_PHASE_HISTORY_LEN];
|
||||
uint16_t buf_len = s_history_len;
|
||||
|
||||
for (uint16_t i = 0; i < buf_len; i++) {
|
||||
uint16_t ri = (s_history_idx + EDGE_PHASE_HISTORY_LEN
|
||||
- buf_len + i) % EDGE_PHASE_HISTORY_LEN;
|
||||
br_buf[i] = s_breathing_filtered[ri];
|
||||
hr_buf[i] = s_heartrate_filtered[ri];
|
||||
}
|
||||
|
||||
float br_bpm = estimate_bpm_zero_crossing(br_buf, buf_len, sample_rate);
|
||||
float hr_bpm = estimate_bpm_zero_crossing(hr_buf, buf_len, sample_rate);
|
||||
|
||||
/* Sanity clamp: breathing 6-40 BPM, heart rate 40-180 BPM. */
|
||||
if (br_bpm >= 6.0f && br_bpm <= 40.0f) s_breathing_bpm = br_bpm;
|
||||
if (hr_bpm >= 40.0f && hr_bpm <= 180.0f) s_heartrate_bpm = hr_bpm;
|
||||
}
|
||||
|
||||
/* --- Step 8: Motion energy (variance of recent phases) --- */
|
||||
if (s_history_len >= 10) {
|
||||
float sum = 0.0f, sum2 = 0.0f;
|
||||
uint16_t window = (s_history_len < 20) ? s_history_len : 20;
|
||||
for (uint16_t i = 0; i < window; i++) {
|
||||
uint16_t ri = (s_history_idx + EDGE_PHASE_HISTORY_LEN
|
||||
- window + i) % EDGE_PHASE_HISTORY_LEN;
|
||||
float v = s_phase_history[ri];
|
||||
sum += v;
|
||||
sum2 += v * v;
|
||||
}
|
||||
float mean = sum / (float)window;
|
||||
s_motion_energy = (sum2 / (float)window) - (mean * mean);
|
||||
if (s_motion_energy < 0.0f) s_motion_energy = 0.0f;
|
||||
}
|
||||
|
||||
/* --- Step 9: Presence detection --- */
|
||||
s_presence_score = s_motion_energy;
|
||||
|
||||
/* Adaptive calibration: learn ambient noise level from first N frames. */
|
||||
if (!s_calibrated && s_cfg.presence_thresh == 0.0f) {
|
||||
calibration_update(s_motion_energy);
|
||||
}
|
||||
|
||||
float threshold = s_cfg.presence_thresh;
|
||||
if (threshold == 0.0f && s_calibrated) {
|
||||
threshold = s_adaptive_threshold;
|
||||
} else if (threshold == 0.0f) {
|
||||
threshold = 0.05f; /* Default until calibrated. */
|
||||
}
|
||||
s_presence_detected = (s_presence_score > threshold);
|
||||
|
||||
/* --- Step 10: Fall detection (phase acceleration) --- */
|
||||
if (s_history_len >= 3) {
|
||||
uint16_t i0 = (s_history_idx + EDGE_PHASE_HISTORY_LEN - 1) % EDGE_PHASE_HISTORY_LEN;
|
||||
uint16_t i1 = (s_history_idx + EDGE_PHASE_HISTORY_LEN - 2) % EDGE_PHASE_HISTORY_LEN;
|
||||
float velocity = s_phase_history[i0] - s_phase_history[i1];
|
||||
float accel = fabsf(velocity - s_prev_phase_velocity);
|
||||
s_prev_phase_velocity = velocity;
|
||||
|
||||
s_fall_detected = (accel > s_cfg.fall_thresh);
|
||||
if (s_fall_detected) {
|
||||
ESP_LOGW(TAG, "Fall detected! accel=%.4f > thresh=%.4f",
|
||||
accel, s_cfg.fall_thresh);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Step 11: Multi-person vitals --- */
|
||||
update_multi_person_vitals(slot->iq_data, n_subcarriers, sample_rate);
|
||||
|
||||
/* --- Step 12: Delta compression --- */
|
||||
if (s_cfg.tier >= 2) {
|
||||
send_compressed_frame(slot->iq_data, slot->iq_len, slot->channel);
|
||||
}
|
||||
|
||||
/* --- Step 13: Send vitals packet at configured interval --- */
|
||||
int64_t now_us = esp_timer_get_time();
|
||||
int64_t interval_us = (int64_t)s_cfg.vital_interval_ms * 1000;
|
||||
if ((now_us - s_last_vitals_send_us) >= interval_us) {
|
||||
send_vitals_packet();
|
||||
s_last_vitals_send_us = now_us;
|
||||
|
||||
if ((s_frame_count % 200) == 0) {
|
||||
ESP_LOGI(TAG, "Vitals: br=%.1f hr=%.1f motion=%.4f pres=%s "
|
||||
"fall=%s persons=%u frames=%lu",
|
||||
s_breathing_bpm, s_heartrate_bpm, s_motion_energy,
|
||||
s_presence_detected ? "YES" : "no",
|
||||
s_fall_detected ? "YES" : "no",
|
||||
(unsigned)s_latest_pkt.n_persons,
|
||||
(unsigned long)s_frame_count);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Step 14 (ADR-040): Dispatch to WASM modules --- */
|
||||
if (s_cfg.tier >= 2 && s_pkt_valid) {
|
||||
/* Extract amplitudes from I/Q for WASM host API. */
|
||||
float amplitudes[EDGE_MAX_SUBCARRIERS];
|
||||
for (uint16_t sc = 0; sc < n_subcarriers; sc++) {
|
||||
int8_t i_val = (int8_t)slot->iq_data[sc * 2];
|
||||
int8_t q_val = (int8_t)slot->iq_data[sc * 2 + 1];
|
||||
amplitudes[sc] = sqrtf((float)(i_val * i_val + q_val * q_val));
|
||||
}
|
||||
|
||||
/* Build variance array from Welford state. */
|
||||
float variances[EDGE_MAX_SUBCARRIERS];
|
||||
for (uint16_t sc = 0; sc < n_subcarriers; sc++) {
|
||||
variances[sc] = (float)welford_variance(&s_subcarrier_var[sc]);
|
||||
}
|
||||
|
||||
wasm_runtime_on_frame(phases, amplitudes, variances,
|
||||
n_subcarriers,
|
||||
(const edge_vitals_pkt_t *)&s_latest_pkt);
|
||||
}
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Edge Processing Task (pinned to Core 1)
|
||||
* ====================================================================== */
|
||||
|
||||
static void edge_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
ESP_LOGI(TAG, "Edge DSP task started on core %d (tier=%u)",
|
||||
xPortGetCoreID(), s_cfg.tier);
|
||||
|
||||
edge_ring_slot_t slot;
|
||||
|
||||
while (1) {
|
||||
if (ring_pop(&slot)) {
|
||||
process_frame(&slot);
|
||||
} else {
|
||||
/* No frames available — yield briefly. */
|
||||
vTaskDelay(pdMS_TO_TICKS(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Public API
|
||||
* ====================================================================== */
|
||||
|
||||
bool edge_enqueue_csi(const uint8_t *iq_data, uint16_t iq_len,
|
||||
int8_t rssi, uint8_t channel)
|
||||
{
|
||||
return ring_push(iq_data, iq_len, rssi, channel);
|
||||
}
|
||||
|
||||
bool edge_get_vitals(edge_vitals_pkt_t *pkt)
|
||||
{
|
||||
if (!s_pkt_valid || pkt == NULL) return false;
|
||||
memcpy(pkt, (const void *)&s_latest_pkt, sizeof(edge_vitals_pkt_t));
|
||||
return true;
|
||||
}
|
||||
|
||||
void edge_get_multi_person(edge_person_vitals_t *persons, uint8_t *n_active)
|
||||
{
|
||||
uint8_t active = 0;
|
||||
for (uint8_t p = 0; p < EDGE_MAX_PERSONS; p++) {
|
||||
if (persons) persons[p] = s_persons[p];
|
||||
if (s_persons[p].active) active++;
|
||||
}
|
||||
if (n_active) *n_active = active;
|
||||
}
|
||||
|
||||
void edge_get_phase_history(const float **out_buf, uint16_t *out_len,
|
||||
uint16_t *out_idx)
|
||||
{
|
||||
if (out_buf) *out_buf = s_phase_history;
|
||||
if (out_len) *out_len = s_history_len;
|
||||
if (out_idx) *out_idx = s_history_idx;
|
||||
}
|
||||
|
||||
void edge_get_variances(float *out_variances, uint16_t n_subcarriers)
|
||||
{
|
||||
if (out_variances == NULL) return;
|
||||
uint16_t n = (n_subcarriers > EDGE_MAX_SUBCARRIERS) ? EDGE_MAX_SUBCARRIERS : n_subcarriers;
|
||||
for (uint16_t i = 0; i < n; i++) {
|
||||
out_variances[i] = (float)welford_variance(&s_subcarrier_var[i]);
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t edge_processing_init(const edge_config_t *cfg)
|
||||
{
|
||||
if (cfg == NULL) {
|
||||
ESP_LOGE(TAG, "edge_processing_init: cfg is NULL");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
/* Store config. */
|
||||
s_cfg = *cfg;
|
||||
|
||||
ESP_LOGI(TAG, "Initializing edge processing (tier=%u, top_k=%u, "
|
||||
"vital_interval=%ums, presence_thresh=%.3f)",
|
||||
s_cfg.tier, s_cfg.top_k_count,
|
||||
s_cfg.vital_interval_ms, s_cfg.presence_thresh);
|
||||
|
||||
/* Reset all state. */
|
||||
memset(&s_ring, 0, sizeof(s_ring));
|
||||
memset(s_subcarrier_var, 0, sizeof(s_subcarrier_var));
|
||||
memset(s_prev_phase, 0, sizeof(s_prev_phase));
|
||||
s_phase_initialized = false;
|
||||
s_top_k_count = 0;
|
||||
s_history_len = 0;
|
||||
s_history_idx = 0;
|
||||
s_breathing_bpm = 0.0f;
|
||||
s_heartrate_bpm = 0.0f;
|
||||
s_motion_energy = 0.0f;
|
||||
s_presence_score = 0.0f;
|
||||
s_presence_detected = false;
|
||||
s_fall_detected = false;
|
||||
s_latest_rssi = 0;
|
||||
s_frame_count = 0;
|
||||
s_prev_phase_velocity = 0.0f;
|
||||
s_last_vitals_send_us = 0;
|
||||
s_has_prev_iq = false;
|
||||
s_prev_iq_len = 0;
|
||||
s_pkt_valid = false;
|
||||
|
||||
/* Reset calibration state. */
|
||||
s_calibrated = false;
|
||||
s_calib_sum = 0.0f;
|
||||
s_calib_sum_sq = 0.0f;
|
||||
s_calib_count = 0;
|
||||
s_adaptive_threshold = 0.05f;
|
||||
|
||||
/* Reset multi-person state. */
|
||||
memset(s_persons, 0, sizeof(s_persons));
|
||||
for (uint8_t p = 0; p < EDGE_MAX_PERSONS; p++) {
|
||||
s_persons[p].active = false;
|
||||
}
|
||||
|
||||
/* Design biquad bandpass filters.
|
||||
* Sampling rate ~20 Hz (typical ESP32 CSI callback rate). */
|
||||
const float fs = 20.0f;
|
||||
biquad_bandpass_design(&s_bq_breathing, fs, 0.1f, 0.5f);
|
||||
biquad_bandpass_design(&s_bq_heartrate, fs, 0.8f, 2.0f);
|
||||
|
||||
/* Design per-person filters. */
|
||||
for (uint8_t p = 0; p < EDGE_MAX_PERSONS; p++) {
|
||||
biquad_bandpass_design(&s_person_bq_br[p], fs, 0.1f, 0.5f);
|
||||
biquad_bandpass_design(&s_person_bq_hr[p], fs, 0.8f, 2.0f);
|
||||
}
|
||||
|
||||
if (s_cfg.tier == 0) {
|
||||
ESP_LOGI(TAG, "Edge tier 0: raw passthrough (no DSP task)");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* Start DSP task on Core 1. */
|
||||
BaseType_t ret = xTaskCreatePinnedToCore(
|
||||
edge_task,
|
||||
"edge_dsp",
|
||||
8192, /* 8 KB stack — sufficient for DSP pipeline. */
|
||||
NULL,
|
||||
5, /* Priority 5 — above idle, below WiFi. */
|
||||
NULL,
|
||||
1 /* Pin to Core 1. */
|
||||
);
|
||||
|
||||
if (ret != pdPASS) {
|
||||
ESP_LOGE(TAG, "Failed to create edge DSP task");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Edge DSP task created on Core 1 (stack=8192, priority=5)");
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @file edge_processing.h
|
||||
* @brief ADR-039 Edge Intelligence — dual-core CSI processing pipeline.
|
||||
*
|
||||
* Core 0 (WiFi): Produces CSI frames into a lock-free SPSC ring buffer.
|
||||
* Core 1 (DSP): Consumes frames, runs signal processing, extracts vitals.
|
||||
*
|
||||
* Features:
|
||||
* - Biquad IIR bandpass filters for breathing (0.1-0.5 Hz) and heart rate (0.8-2.0 Hz)
|
||||
* - Phase unwrapping and Welford running statistics
|
||||
* - Top-K subcarrier selection by variance
|
||||
* - Presence detection with adaptive threshold calibration
|
||||
* - Vital signs: breathing rate, heart rate (zero-crossing BPM)
|
||||
* - Fall detection (phase acceleration exceeds threshold)
|
||||
* - Delta compression (XOR + RLE) for bandwidth reduction
|
||||
* - Multi-person vitals via subcarrier group clustering
|
||||
* - 32-byte vitals packet (magic 0xC5110002) for server-side parsing
|
||||
*/
|
||||
|
||||
#ifndef EDGE_PROCESSING_H
|
||||
#define EDGE_PROCESSING_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
/* ---- Magic numbers ---- */
|
||||
#define EDGE_VITALS_MAGIC 0xC5110002 /**< Vitals packet magic. */
|
||||
#define EDGE_COMPRESSED_MAGIC 0xC5110003 /**< Compressed frame magic. */
|
||||
|
||||
/* ---- Buffer sizes ---- */
|
||||
#define EDGE_RING_SLOTS 16 /**< SPSC ring buffer slots (power of 2). */
|
||||
#define EDGE_MAX_IQ_BYTES 1024 /**< Max I/Q payload per slot. */
|
||||
#define EDGE_PHASE_HISTORY_LEN 256 /**< Phase history buffer depth. */
|
||||
#define EDGE_TOP_K 8 /**< Top-K subcarriers to track. */
|
||||
#define EDGE_MAX_SUBCARRIERS 128 /**< Max subcarriers per frame. */
|
||||
|
||||
/* ---- Multi-person ---- */
|
||||
#define EDGE_MAX_PERSONS 4 /**< Max simultaneous persons. */
|
||||
|
||||
/* ---- Calibration ---- */
|
||||
#define EDGE_CALIB_FRAMES 1200 /**< Frames for adaptive calibration (~60s at 20 Hz). */
|
||||
#define EDGE_CALIB_SIGMA_MULT 3.0f /**< Threshold = mean + 3*sigma of ambient. */
|
||||
|
||||
/* ---- SPSC ring buffer slot ---- */
|
||||
typedef struct {
|
||||
uint8_t iq_data[EDGE_MAX_IQ_BYTES]; /**< Raw I/Q bytes from CSI callback. */
|
||||
uint16_t iq_len; /**< Actual I/Q data length. */
|
||||
int8_t rssi; /**< RSSI from rx_ctrl. */
|
||||
uint8_t channel; /**< WiFi channel. */
|
||||
uint32_t timestamp_us; /**< Microsecond timestamp. */
|
||||
} edge_ring_slot_t;
|
||||
|
||||
/* ---- SPSC ring buffer ---- */
|
||||
typedef struct {
|
||||
edge_ring_slot_t slots[EDGE_RING_SLOTS];
|
||||
volatile uint32_t head; /**< Written by producer (Core 0). */
|
||||
volatile uint32_t tail; /**< Written by consumer (Core 1). */
|
||||
} edge_ring_buf_t;
|
||||
|
||||
/* ---- Biquad IIR filter state ---- */
|
||||
typedef struct {
|
||||
float b0, b1, b2; /**< Numerator coefficients. */
|
||||
float a1, a2; /**< Denominator coefficients (a0 = 1). */
|
||||
float x1, x2; /**< Input delay line. */
|
||||
float y1, y2; /**< Output delay line. */
|
||||
} edge_biquad_t;
|
||||
|
||||
/* ---- Welford running statistics ---- */
|
||||
typedef struct {
|
||||
double mean;
|
||||
double m2;
|
||||
uint32_t count;
|
||||
} edge_welford_t;
|
||||
|
||||
/* ---- Per-person vitals state (multi-person mode) ---- */
|
||||
typedef struct {
|
||||
float phase_history[EDGE_PHASE_HISTORY_LEN];
|
||||
uint16_t history_len;
|
||||
uint16_t history_idx;
|
||||
float breathing_bpm;
|
||||
float heartrate_bpm;
|
||||
uint8_t subcarrier_idx; /**< Which subcarrier group this person tracks. */
|
||||
bool active;
|
||||
} edge_person_vitals_t;
|
||||
|
||||
/* ---- Vitals packet (32 bytes, wire format) ---- */
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint32_t magic; /**< EDGE_VITALS_MAGIC = 0xC5110002. */
|
||||
uint8_t node_id; /**< ESP32 node identifier. */
|
||||
uint8_t flags; /**< Bit0=presence, Bit1=fall, Bit2=motion. */
|
||||
uint16_t breathing_rate; /**< BPM * 100 (fixed-point). */
|
||||
uint32_t heartrate; /**< BPM * 10000 (fixed-point). */
|
||||
int8_t rssi; /**< Latest RSSI. */
|
||||
uint8_t n_persons; /**< Number of detected persons (multi-person). */
|
||||
uint8_t reserved[2];
|
||||
float motion_energy; /**< Phase variance / motion metric. */
|
||||
float presence_score; /**< Presence detection score. */
|
||||
uint32_t timestamp_ms; /**< Milliseconds since boot. */
|
||||
uint32_t reserved2; /**< Reserved for future use. */
|
||||
} edge_vitals_pkt_t;
|
||||
|
||||
_Static_assert(sizeof(edge_vitals_pkt_t) == 32, "vitals packet must be 32 bytes");
|
||||
|
||||
/* ---- Edge configuration (from NVS) ---- */
|
||||
typedef struct {
|
||||
uint8_t tier; /**< Processing tier: 0=raw, 1=basic, 2=full. */
|
||||
float presence_thresh;/**< Presence detection threshold (0 = auto-calibrate). */
|
||||
float fall_thresh; /**< Fall detection threshold (phase accel, rad/s^2). */
|
||||
uint16_t vital_window; /**< Phase history window for BPM estimation. */
|
||||
uint16_t vital_interval_ms; /**< Vitals packet send interval in ms. */
|
||||
uint8_t top_k_count; /**< Number of top subcarriers to track. */
|
||||
uint8_t power_duty; /**< Power duty cycle percentage (10-100). */
|
||||
} edge_config_t;
|
||||
|
||||
/**
|
||||
* Initialize the edge processing pipeline.
|
||||
* Creates the SPSC ring buffer and starts the DSP task on Core 1.
|
||||
*
|
||||
* @param cfg Edge configuration (from NVS or defaults).
|
||||
* @return ESP_OK on success.
|
||||
*/
|
||||
esp_err_t edge_processing_init(const edge_config_t *cfg);
|
||||
|
||||
/**
|
||||
* Enqueue a CSI frame from the WiFi callback (Core 0).
|
||||
* Lock-free SPSC push — safe to call from ISR context.
|
||||
*
|
||||
* @param iq_data Raw I/Q data from wifi_csi_info_t.buf.
|
||||
* @param iq_len Length of I/Q data in bytes.
|
||||
* @param rssi RSSI from rx_ctrl.
|
||||
* @param channel WiFi channel number.
|
||||
* @return true if enqueued, false if ring buffer is full (frame dropped).
|
||||
*/
|
||||
bool edge_enqueue_csi(const uint8_t *iq_data, uint16_t iq_len,
|
||||
int8_t rssi, uint8_t channel);
|
||||
|
||||
/**
|
||||
* Get the latest vitals packet (thread-safe copy).
|
||||
*
|
||||
* @param pkt Output vitals packet.
|
||||
* @return true if valid vitals data is available.
|
||||
*/
|
||||
bool edge_get_vitals(edge_vitals_pkt_t *pkt);
|
||||
|
||||
/**
|
||||
* Get multi-person vitals array.
|
||||
*
|
||||
* @param persons Output array (must be EDGE_MAX_PERSONS elements).
|
||||
* @param n_active Output: number of active persons.
|
||||
*/
|
||||
void edge_get_multi_person(edge_person_vitals_t *persons, uint8_t *n_active);
|
||||
|
||||
/**
|
||||
* Get pointer to the phase history ring buffer and its state.
|
||||
* Used by WASM runtime (ADR-040) to expose phase history to modules.
|
||||
*
|
||||
* @param out_buf Output: pointer to phase history array.
|
||||
* @param out_len Output: number of valid entries.
|
||||
* @param out_idx Output: current write index.
|
||||
*/
|
||||
void edge_get_phase_history(const float **out_buf, uint16_t *out_len,
|
||||
uint16_t *out_idx);
|
||||
|
||||
/**
|
||||
* Get per-subcarrier Welford variance array.
|
||||
* Used by WASM runtime (ADR-040) to expose variances to modules.
|
||||
*
|
||||
* @param out_variances Output array (must be EDGE_MAX_SUBCARRIERS elements).
|
||||
* @param n_subcarriers Number of subcarriers to fill.
|
||||
*/
|
||||
void edge_get_variances(float *out_variances, uint16_t n_subcarriers);
|
||||
|
||||
#endif /* EDGE_PROCESSING_H */
|
||||
@@ -21,11 +21,22 @@
|
||||
#include "csi_collector.h"
|
||||
#include "stream_sender.h"
|
||||
#include "nvs_config.h"
|
||||
#include "edge_processing.h"
|
||||
#include "ota_update.h"
|
||||
#include "power_mgmt.h"
|
||||
#include "wasm_runtime.h"
|
||||
#include "wasm_upload.h"
|
||||
|
||||
#include "esp_timer.h"
|
||||
|
||||
static const char *TAG = "main";
|
||||
|
||||
/* Runtime configuration (loaded from NVS or Kconfig defaults). */
|
||||
static nvs_config_t s_cfg;
|
||||
/* ADR-040: WASM timer handle (calls on_timer at configurable interval). */
|
||||
static esp_timer_handle_t s_wasm_timer;
|
||||
|
||||
/* Runtime configuration (loaded from NVS or Kconfig defaults).
|
||||
* Global so other modules (wasm_upload.c) can access pubkey, etc. */
|
||||
nvs_config_t g_nvs_config;
|
||||
|
||||
/* Event group bits */
|
||||
#define WIFI_CONNECTED_BIT BIT0
|
||||
@@ -81,8 +92,8 @@ static void wifi_init_sta(void)
|
||||
};
|
||||
|
||||
/* Copy runtime SSID/password from NVS config */
|
||||
strncpy((char *)wifi_config.sta.ssid, s_cfg.wifi_ssid, sizeof(wifi_config.sta.ssid) - 1);
|
||||
strncpy((char *)wifi_config.sta.password, s_cfg.wifi_password, sizeof(wifi_config.sta.password) - 1);
|
||||
strncpy((char *)wifi_config.sta.ssid, g_nvs_config.wifi_ssid, sizeof(wifi_config.sta.ssid) - 1);
|
||||
strncpy((char *)wifi_config.sta.password, g_nvs_config.wifi_password, sizeof(wifi_config.sta.password) - 1);
|
||||
|
||||
/* If password is empty, use open auth */
|
||||
if (strlen((char *)wifi_config.sta.password) == 0) {
|
||||
@@ -93,7 +104,7 @@ static void wifi_init_sta(void)
|
||||
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
|
||||
ESP_ERROR_CHECK(esp_wifi_start());
|
||||
|
||||
ESP_LOGI(TAG, "WiFi STA initialized, connecting to SSID: %s", s_cfg.wifi_ssid);
|
||||
ESP_LOGI(TAG, "WiFi STA initialized, connecting to SSID: %s", g_nvs_config.wifi_ssid);
|
||||
|
||||
/* Wait for connection */
|
||||
EventBits_t bits = xEventGroupWaitBits(s_wifi_event_group,
|
||||
@@ -118,15 +129,15 @@ void app_main(void)
|
||||
ESP_ERROR_CHECK(ret);
|
||||
|
||||
/* Load runtime config (NVS overrides Kconfig defaults) */
|
||||
nvs_config_load(&s_cfg);
|
||||
nvs_config_load(&g_nvs_config);
|
||||
|
||||
ESP_LOGI(TAG, "ESP32-S3 CSI Node (ADR-018) — Node ID: %d", s_cfg.node_id);
|
||||
ESP_LOGI(TAG, "ESP32-S3 CSI Node (ADR-018) — Node ID: %d", g_nvs_config.node_id);
|
||||
|
||||
/* Initialize WiFi STA */
|
||||
wifi_init_sta();
|
||||
|
||||
/* Initialize UDP sender with runtime target */
|
||||
if (stream_sender_init_with(s_cfg.target_ip, s_cfg.target_port) != 0) {
|
||||
if (stream_sender_init_with(g_nvs_config.target_ip, g_nvs_config.target_port) != 0) {
|
||||
ESP_LOGE(TAG, "Failed to initialize UDP sender");
|
||||
return;
|
||||
}
|
||||
@@ -134,15 +145,69 @@ void app_main(void)
|
||||
/* Initialize CSI collection */
|
||||
csi_collector_init();
|
||||
|
||||
/* Apply MAC address filter if configured (Issue #98) */
|
||||
if (s_cfg.filter_mac_enabled) {
|
||||
csi_collector_set_filter_mac(s_cfg.filter_mac);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "No MAC filter — accepting CSI from all transmitters");
|
||||
/* ADR-039: Initialize edge processing pipeline. */
|
||||
edge_config_t edge_cfg = {
|
||||
.tier = g_nvs_config.edge_tier,
|
||||
.presence_thresh = g_nvs_config.presence_thresh,
|
||||
.fall_thresh = g_nvs_config.fall_thresh,
|
||||
.vital_window = g_nvs_config.vital_window,
|
||||
.vital_interval_ms = g_nvs_config.vital_interval_ms,
|
||||
.top_k_count = g_nvs_config.top_k_count,
|
||||
.power_duty = g_nvs_config.power_duty,
|
||||
};
|
||||
esp_err_t edge_ret = edge_processing_init(&edge_cfg);
|
||||
if (edge_ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Edge processing init failed: %s (continuing without edge DSP)",
|
||||
esp_err_to_name(edge_ret));
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "CSI streaming active → %s:%d",
|
||||
s_cfg.target_ip, s_cfg.target_port);
|
||||
/* Initialize OTA update HTTP server. */
|
||||
httpd_handle_t ota_server = NULL;
|
||||
esp_err_t ota_ret = ota_update_init_ex(&ota_server);
|
||||
if (ota_ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "OTA server init failed: %s", esp_err_to_name(ota_ret));
|
||||
}
|
||||
|
||||
/* ADR-040: Initialize WASM programmable sensing runtime. */
|
||||
esp_err_t wasm_ret = wasm_runtime_init();
|
||||
if (wasm_ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "WASM runtime init failed: %s", esp_err_to_name(wasm_ret));
|
||||
} else {
|
||||
/* Register WASM upload endpoints on the OTA HTTP server. */
|
||||
if (ota_server != NULL) {
|
||||
wasm_upload_register(ota_server);
|
||||
}
|
||||
|
||||
/* Start periodic timer for wasm_runtime_on_timer(). */
|
||||
esp_timer_create_args_t timer_args = {
|
||||
.callback = (void (*)(void *))wasm_runtime_on_timer,
|
||||
.arg = NULL,
|
||||
.dispatch_method = ESP_TIMER_TASK,
|
||||
.name = "wasm_timer",
|
||||
};
|
||||
esp_err_t timer_ret = esp_timer_create(&timer_args, &s_wasm_timer);
|
||||
if (timer_ret == ESP_OK) {
|
||||
#ifdef CONFIG_WASM_TIMER_INTERVAL_MS
|
||||
uint64_t interval_us = (uint64_t)CONFIG_WASM_TIMER_INTERVAL_MS * 1000ULL;
|
||||
#else
|
||||
uint64_t interval_us = 1000000ULL; /* Default: 1 second. */
|
||||
#endif
|
||||
esp_timer_start_periodic(s_wasm_timer, interval_us);
|
||||
ESP_LOGI(TAG, "WASM on_timer() periodic: %llu ms",
|
||||
(unsigned long long)(interval_us / 1000));
|
||||
} else {
|
||||
ESP_LOGW(TAG, "WASM timer create failed: %s", esp_err_to_name(timer_ret));
|
||||
}
|
||||
}
|
||||
|
||||
/* Initialize power management. */
|
||||
power_mgmt_init(g_nvs_config.power_duty);
|
||||
|
||||
ESP_LOGI(TAG, "CSI streaming active → %s:%d (edge_tier=%u, OTA=%s, WASM=%s)",
|
||||
g_nvs_config.target_ip, g_nvs_config.target_port,
|
||||
g_nvs_config.edge_tier,
|
||||
(ota_ret == ESP_OK) ? "ready" : "off",
|
||||
(wasm_ret == ESP_OK) ? "ready" : "off");
|
||||
|
||||
/* Main loop — keep alive */
|
||||
while (1) {
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
#include "nvs_config.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include "esp_log.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "nvs.h"
|
||||
@@ -52,27 +51,44 @@ void nvs_config_load(nvs_config_t *cfg)
|
||||
cfg->tdm_slot_index = 0;
|
||||
cfg->tdm_node_count = 1;
|
||||
|
||||
/* MAC filter: default disabled (all zeros) */
|
||||
memset(cfg->filter_mac, 0, 6);
|
||||
cfg->filter_mac_enabled = 0;
|
||||
/* ADR-039: Edge intelligence defaults from Kconfig. */
|
||||
#ifdef CONFIG_EDGE_TIER
|
||||
cfg->edge_tier = (uint8_t)CONFIG_EDGE_TIER;
|
||||
#else
|
||||
cfg->edge_tier = 2;
|
||||
#endif
|
||||
cfg->presence_thresh = 0.0f; /* 0 = auto-calibrate. */
|
||||
#ifdef CONFIG_EDGE_FALL_THRESH
|
||||
cfg->fall_thresh = (float)CONFIG_EDGE_FALL_THRESH / 1000.0f;
|
||||
#else
|
||||
cfg->fall_thresh = 2.0f;
|
||||
#endif
|
||||
cfg->vital_window = 256;
|
||||
#ifdef CONFIG_EDGE_VITAL_INTERVAL_MS
|
||||
cfg->vital_interval_ms = (uint16_t)CONFIG_EDGE_VITAL_INTERVAL_MS;
|
||||
#else
|
||||
cfg->vital_interval_ms = 1000;
|
||||
#endif
|
||||
#ifdef CONFIG_EDGE_TOP_K
|
||||
cfg->top_k_count = (uint8_t)CONFIG_EDGE_TOP_K;
|
||||
#else
|
||||
cfg->top_k_count = 8;
|
||||
#endif
|
||||
#ifdef CONFIG_EDGE_POWER_DUTY
|
||||
cfg->power_duty = (uint8_t)CONFIG_EDGE_POWER_DUTY;
|
||||
#else
|
||||
cfg->power_duty = 100;
|
||||
#endif
|
||||
|
||||
/* Parse compile-time Kconfig MAC filter if set (format: "AA:BB:CC:DD:EE:FF") */
|
||||
#ifdef CONFIG_CSI_FILTER_MAC
|
||||
{
|
||||
const char *mac_str = CONFIG_CSI_FILTER_MAC;
|
||||
unsigned int m[6];
|
||||
if (mac_str[0] != '\0' &&
|
||||
sscanf(mac_str, "%x:%x:%x:%x:%x:%x",
|
||||
&m[0], &m[1], &m[2], &m[3], &m[4], &m[5]) == 6) {
|
||||
for (int i = 0; i < 6; i++) {
|
||||
cfg->filter_mac[i] = (uint8_t)m[i];
|
||||
}
|
||||
cfg->filter_mac_enabled = 1;
|
||||
ESP_LOGI(TAG, "Kconfig MAC filter: %02X:%02X:%02X:%02X:%02X:%02X",
|
||||
cfg->filter_mac[0], cfg->filter_mac[1], cfg->filter_mac[2],
|
||||
cfg->filter_mac[3], cfg->filter_mac[4], cfg->filter_mac[5]);
|
||||
}
|
||||
}
|
||||
/* ADR-040: WASM programmable sensing defaults from Kconfig. */
|
||||
#ifdef CONFIG_WASM_MAX_MODULES
|
||||
cfg->wasm_max_modules = (uint8_t)CONFIG_WASM_MAX_MODULES;
|
||||
#else
|
||||
cfg->wasm_max_modules = 4;
|
||||
#endif
|
||||
cfg->wasm_verify = 1; /* Default: verify enabled (secure-by-default). */
|
||||
#ifndef CONFIG_WASM_VERIFY_SIGNATURE
|
||||
cfg->wasm_verify = 0; /* Kconfig disabled signature verification. */
|
||||
#endif
|
||||
|
||||
/* Try to override from NVS */
|
||||
@@ -176,27 +192,91 @@ void nvs_config_load(nvs_config_t *cfg)
|
||||
}
|
||||
}
|
||||
|
||||
/* MAC filter (stored as a 6-byte blob in NVS key "filter_mac") */
|
||||
uint8_t mac_blob[6];
|
||||
size_t mac_len = 6;
|
||||
if (nvs_get_blob(handle, "filter_mac", mac_blob, &mac_len) == ESP_OK && mac_len == 6) {
|
||||
/* Check it's not all zeros (which would mean "no filter") */
|
||||
uint8_t is_zero = 1;
|
||||
for (int i = 0; i < 6; i++) {
|
||||
if (mac_blob[i] != 0) { is_zero = 0; break; }
|
||||
/* ADR-039: Edge intelligence overrides. */
|
||||
uint8_t edge_tier_val;
|
||||
if (nvs_get_u8(handle, "edge_tier", &edge_tier_val) == ESP_OK) {
|
||||
if (edge_tier_val <= 2) {
|
||||
cfg->edge_tier = edge_tier_val;
|
||||
ESP_LOGI(TAG, "NVS override: edge_tier=%u", (unsigned)cfg->edge_tier);
|
||||
}
|
||||
if (!is_zero) {
|
||||
memcpy(cfg->filter_mac, mac_blob, 6);
|
||||
cfg->filter_mac_enabled = 1;
|
||||
ESP_LOGI(TAG, "NVS override: filter_mac=%02X:%02X:%02X:%02X:%02X:%02X",
|
||||
mac_blob[0], mac_blob[1], mac_blob[2],
|
||||
mac_blob[3], mac_blob[4], mac_blob[5]);
|
||||
} else {
|
||||
cfg->filter_mac_enabled = 0;
|
||||
ESP_LOGI(TAG, "NVS override: filter_mac disabled (all zeros)");
|
||||
}
|
||||
|
||||
/* Presence threshold stored as u16 (value * 1000). */
|
||||
uint16_t pres_thresh_val;
|
||||
if (nvs_get_u16(handle, "pres_thresh", &pres_thresh_val) == ESP_OK) {
|
||||
cfg->presence_thresh = (float)pres_thresh_val / 1000.0f;
|
||||
ESP_LOGI(TAG, "NVS override: presence_thresh=%.3f", cfg->presence_thresh);
|
||||
}
|
||||
|
||||
/* Fall threshold stored as u16 (value * 1000). */
|
||||
uint16_t fall_thresh_val;
|
||||
if (nvs_get_u16(handle, "fall_thresh", &fall_thresh_val) == ESP_OK) {
|
||||
cfg->fall_thresh = (float)fall_thresh_val / 1000.0f;
|
||||
ESP_LOGI(TAG, "NVS override: fall_thresh=%.3f", cfg->fall_thresh);
|
||||
}
|
||||
|
||||
uint16_t vital_win_val;
|
||||
if (nvs_get_u16(handle, "vital_win", &vital_win_val) == ESP_OK) {
|
||||
if (vital_win_val >= 32 && vital_win_val <= 256) {
|
||||
cfg->vital_window = vital_win_val;
|
||||
ESP_LOGI(TAG, "NVS override: vital_window=%u", cfg->vital_window);
|
||||
}
|
||||
}
|
||||
|
||||
uint16_t vital_int_val;
|
||||
if (nvs_get_u16(handle, "vital_int", &vital_int_val) == ESP_OK) {
|
||||
if (vital_int_val >= 100) {
|
||||
cfg->vital_interval_ms = vital_int_val;
|
||||
ESP_LOGI(TAG, "NVS override: vital_interval_ms=%u", cfg->vital_interval_ms);
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t topk_val;
|
||||
if (nvs_get_u8(handle, "subk_count", &topk_val) == ESP_OK) {
|
||||
if (topk_val >= 1 && topk_val <= 32) {
|
||||
cfg->top_k_count = topk_val;
|
||||
ESP_LOGI(TAG, "NVS override: top_k_count=%u", (unsigned)cfg->top_k_count);
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t duty_val;
|
||||
if (nvs_get_u8(handle, "power_duty", &duty_val) == ESP_OK) {
|
||||
if (duty_val >= 10 && duty_val <= 100) {
|
||||
cfg->power_duty = duty_val;
|
||||
ESP_LOGI(TAG, "NVS override: power_duty=%u%%", (unsigned)cfg->power_duty);
|
||||
}
|
||||
}
|
||||
|
||||
/* ADR-040: WASM configuration overrides. */
|
||||
uint8_t wasm_max_val;
|
||||
if (nvs_get_u8(handle, "wasm_max", &wasm_max_val) == ESP_OK) {
|
||||
if (wasm_max_val >= 1 && wasm_max_val <= 8) {
|
||||
cfg->wasm_max_modules = wasm_max_val;
|
||||
ESP_LOGI(TAG, "NVS override: wasm_max_modules=%u", (unsigned)cfg->wasm_max_modules);
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t wasm_verify_val;
|
||||
if (nvs_get_u8(handle, "wasm_verify", &wasm_verify_val) == ESP_OK) {
|
||||
cfg->wasm_verify = wasm_verify_val ? 1 : 0;
|
||||
ESP_LOGI(TAG, "NVS override: wasm_verify=%u", (unsigned)cfg->wasm_verify);
|
||||
}
|
||||
|
||||
/* ADR-040: Load WASM signing public key from NVS (32-byte blob). */
|
||||
cfg->wasm_pubkey_valid = 0;
|
||||
memset(cfg->wasm_pubkey, 0, 32);
|
||||
size_t pubkey_len = 32;
|
||||
if (nvs_get_blob(handle, "wasm_pubkey", cfg->wasm_pubkey, &pubkey_len) == ESP_OK
|
||||
&& pubkey_len == 32)
|
||||
{
|
||||
cfg->wasm_pubkey_valid = 1;
|
||||
ESP_LOGI(TAG, "NVS: wasm_pubkey loaded (%02x%02x...%02x%02x)",
|
||||
cfg->wasm_pubkey[0], cfg->wasm_pubkey[1],
|
||||
cfg->wasm_pubkey[30], cfg->wasm_pubkey[31]);
|
||||
} else if (cfg->wasm_verify) {
|
||||
ESP_LOGW(TAG, "wasm_verify=1 but no wasm_pubkey in NVS — uploads will be rejected");
|
||||
}
|
||||
|
||||
/* Validate tdm_slot_index < tdm_node_count */
|
||||
if (cfg->tdm_slot_index >= cfg->tdm_node_count) {
|
||||
ESP_LOGW(TAG, "tdm_slot_index=%u >= tdm_node_count=%u, clamping to 0",
|
||||
|
||||
@@ -36,9 +36,20 @@ typedef struct {
|
||||
uint8_t tdm_slot_index; /**< This node's TDM slot index (0-based). */
|
||||
uint8_t tdm_node_count; /**< Total nodes in the TDM schedule. */
|
||||
|
||||
/* MAC address filter for CSI source selection (Issue #98) */
|
||||
uint8_t filter_mac[6]; /**< Transmitter MAC to accept (all zeros = no filter). */
|
||||
uint8_t filter_mac_enabled; /**< 1 = filter active, 0 = accept all. */
|
||||
/* ADR-039: Edge intelligence configuration */
|
||||
uint8_t edge_tier; /**< Processing tier (0=raw, 1=basic, 2=full). */
|
||||
float presence_thresh; /**< Presence threshold (0 = auto-calibrate). */
|
||||
float fall_thresh; /**< Fall detection threshold (rad/s^2). */
|
||||
uint16_t vital_window; /**< Phase history window for BPM. */
|
||||
uint16_t vital_interval_ms; /**< Vitals packet interval (ms). */
|
||||
uint8_t top_k_count; /**< Number of top subcarriers to track. */
|
||||
uint8_t power_duty; /**< Power duty cycle (10-100%). */
|
||||
|
||||
/* ADR-040: WASM programmable sensing configuration */
|
||||
uint8_t wasm_max_modules; /**< Max concurrent WASM modules (1-8). */
|
||||
uint8_t wasm_verify; /**< Require Ed25519 signature for uploads. */
|
||||
uint8_t wasm_pubkey[32]; /**< Ed25519 public key for WASM signature. */
|
||||
uint8_t wasm_pubkey_valid; /**< 1 if pubkey was loaded from NVS. */
|
||||
} nvs_config_t;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* @file ota_update.c
|
||||
* @brief HTTP OTA firmware update for ESP32-S3 CSI Node.
|
||||
*
|
||||
* Uses ESP-IDF's native OTA API with rollback support.
|
||||
* The HTTP server runs on port 8032 and accepts:
|
||||
* POST /ota — firmware binary payload (application/octet-stream)
|
||||
* GET /ota/status — current firmware version and partition info
|
||||
*/
|
||||
|
||||
#include "ota_update.h"
|
||||
|
||||
#include <string.h>
|
||||
#include "esp_log.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_app_desc.h"
|
||||
|
||||
static const char *TAG = "ota_update";
|
||||
|
||||
/** OTA HTTP server port. */
|
||||
#define OTA_PORT 8032
|
||||
|
||||
/** Maximum firmware size (900 KB — matches CI binary size gate). */
|
||||
#define OTA_MAX_SIZE (900 * 1024)
|
||||
|
||||
/**
|
||||
* GET /ota/status — return firmware version and partition info.
|
||||
*/
|
||||
static esp_err_t ota_status_handler(httpd_req_t *req)
|
||||
{
|
||||
const esp_app_desc_t *app = esp_app_get_description();
|
||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||
const esp_partition_t *update = esp_ota_get_next_update_partition(NULL);
|
||||
|
||||
char response[512];
|
||||
int len = snprintf(response, sizeof(response),
|
||||
"{\"version\":\"%s\",\"date\":\"%s\",\"time\":\"%s\","
|
||||
"\"running_partition\":\"%s\",\"next_partition\":\"%s\","
|
||||
"\"max_size\":%d}",
|
||||
app->version, app->date, app->time,
|
||||
running ? running->label : "unknown",
|
||||
update ? update->label : "none",
|
||||
OTA_MAX_SIZE);
|
||||
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, response, len);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /ota — receive and flash firmware binary.
|
||||
*/
|
||||
static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
{
|
||||
ESP_LOGI(TAG, "OTA update started, content_length=%d", req->content_len);
|
||||
|
||||
if (req->content_len <= 0 || req->content_len > OTA_MAX_SIZE) {
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST,
|
||||
"Invalid firmware size (must be 1B - 900KB)");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
const esp_partition_t *update_partition = esp_ota_get_next_update_partition(NULL);
|
||||
if (update_partition == NULL) {
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
||||
"No OTA partition available");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
esp_ota_handle_t ota_handle;
|
||||
esp_err_t err = esp_ota_begin(update_partition, OTA_WITH_SEQUENTIAL_WRITES, &ota_handle);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ota_begin failed: %s", esp_err_to_name(err));
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
||||
"OTA begin failed");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
/* Read firmware in chunks. */
|
||||
char buf[1024];
|
||||
int received = 0;
|
||||
int total = 0;
|
||||
|
||||
while (total < req->content_len) {
|
||||
received = httpd_req_recv(req, buf, sizeof(buf));
|
||||
if (received <= 0) {
|
||||
if (received == HTTPD_SOCK_ERR_TIMEOUT) {
|
||||
continue; /* Retry on timeout. */
|
||||
}
|
||||
ESP_LOGE(TAG, "OTA receive error at byte %d", total);
|
||||
esp_ota_abort(ota_handle);
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
||||
"Receive error");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
err = esp_ota_write(ota_handle, buf, received);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ota_write failed at byte %d: %s",
|
||||
total, esp_err_to_name(err));
|
||||
esp_ota_abort(ota_handle);
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
||||
"OTA write failed");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
total += received;
|
||||
if ((total % (64 * 1024)) == 0) {
|
||||
ESP_LOGI(TAG, "OTA progress: %d / %d bytes (%.0f%%)",
|
||||
total, req->content_len,
|
||||
(float)total * 100.0f / (float)req->content_len);
|
||||
}
|
||||
}
|
||||
|
||||
err = esp_ota_end(ota_handle);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ota_end failed: %s", esp_err_to_name(err));
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
||||
"OTA validation failed");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
err = esp_ota_set_boot_partition(update_partition);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ota_set_boot_partition failed: %s", esp_err_to_name(err));
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
||||
"Set boot partition failed");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "OTA update successful! Rebooting to partition '%s'...",
|
||||
update_partition->label);
|
||||
|
||||
const char *resp = "{\"status\":\"ok\",\"message\":\"OTA update successful. Rebooting...\"}";
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, resp, strlen(resp));
|
||||
|
||||
/* Delay briefly to let the response flush, then reboot. */
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
esp_restart();
|
||||
|
||||
return ESP_OK; /* Never reached. */
|
||||
}
|
||||
|
||||
/** Internal: start the HTTP server and register OTA endpoints. */
|
||||
static esp_err_t ota_start_server(httpd_handle_t *out_handle)
|
||||
{
|
||||
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
||||
config.server_port = OTA_PORT;
|
||||
config.max_uri_handlers = 12; /* Extra slots for WASM endpoints (ADR-040). */
|
||||
/* Increase receive timeout for large uploads. */
|
||||
config.recv_wait_timeout = 30;
|
||||
|
||||
httpd_handle_t server = NULL;
|
||||
esp_err_t err = httpd_start(&server, &config);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to start OTA HTTP server on port %d: %s",
|
||||
OTA_PORT, esp_err_to_name(err));
|
||||
if (out_handle) *out_handle = NULL;
|
||||
return err;
|
||||
}
|
||||
|
||||
httpd_uri_t status_uri = {
|
||||
.uri = "/ota/status",
|
||||
.method = HTTP_GET,
|
||||
.handler = ota_status_handler,
|
||||
.user_ctx = NULL,
|
||||
};
|
||||
httpd_register_uri_handler(server, &status_uri);
|
||||
|
||||
httpd_uri_t upload_uri = {
|
||||
.uri = "/ota",
|
||||
.method = HTTP_POST,
|
||||
.handler = ota_upload_handler,
|
||||
.user_ctx = NULL,
|
||||
};
|
||||
httpd_register_uri_handler(server, &upload_uri);
|
||||
|
||||
ESP_LOGI(TAG, "OTA HTTP server started on port %d", OTA_PORT);
|
||||
ESP_LOGI(TAG, " GET /ota/status — firmware version info");
|
||||
ESP_LOGI(TAG, " POST /ota — upload new firmware binary");
|
||||
|
||||
if (out_handle) *out_handle = server;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t ota_update_init(void)
|
||||
{
|
||||
return ota_start_server(NULL);
|
||||
}
|
||||
|
||||
esp_err_t ota_update_init_ex(void **out_server)
|
||||
{
|
||||
return ota_start_server((httpd_handle_t *)out_server);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @file ota_update.h
|
||||
* @brief HTTP OTA firmware update endpoint for ESP32-S3 CSI Node.
|
||||
*
|
||||
* Provides an HTTP server endpoint that accepts firmware binaries
|
||||
* for over-the-air updates without physical access to the device.
|
||||
*/
|
||||
|
||||
#ifndef OTA_UPDATE_H
|
||||
#define OTA_UPDATE_H
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
/**
|
||||
* Initialize the OTA update HTTP server.
|
||||
* Starts a lightweight HTTP server on port 8032 that accepts
|
||||
* POST /ota with a firmware binary payload.
|
||||
*
|
||||
* @return ESP_OK on success.
|
||||
*/
|
||||
esp_err_t ota_update_init(void);
|
||||
|
||||
/**
|
||||
* Initialize the OTA update HTTP server and return the handle.
|
||||
* Same as ota_update_init() but exposes the httpd_handle_t so
|
||||
* other modules (e.g. WASM upload) can register additional endpoints.
|
||||
*
|
||||
* @param out_server Output: HTTP server handle (may be NULL on failure).
|
||||
* @return ESP_OK on success.
|
||||
*/
|
||||
esp_err_t ota_update_init_ex(void **out_server);
|
||||
|
||||
#endif /* OTA_UPDATE_H */
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @file power_mgmt.c
|
||||
* @brief Power management for battery-powered ESP32-S3 CSI nodes.
|
||||
*
|
||||
* Uses ESP-IDF's automatic light sleep with WiFi power save mode.
|
||||
* In light sleep, WiFi maintains association but suspends CSI collection.
|
||||
* The duty cycle controls how often the device wakes for CSI bursts.
|
||||
*/
|
||||
|
||||
#include "power_mgmt.h"
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "esp_pm.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_sleep.h"
|
||||
#include "esp_timer.h"
|
||||
|
||||
static const char *TAG = "power_mgmt";
|
||||
|
||||
static uint32_t s_active_ms = 0;
|
||||
static uint32_t s_sleep_ms = 0;
|
||||
static uint32_t s_wake_count = 0;
|
||||
static int64_t s_last_wake = 0;
|
||||
|
||||
esp_err_t power_mgmt_init(uint8_t duty_cycle_pct)
|
||||
{
|
||||
if (duty_cycle_pct >= 100) {
|
||||
ESP_LOGI(TAG, "Power management disabled (duty_cycle=100%%)");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
if (duty_cycle_pct < 10) {
|
||||
duty_cycle_pct = 10;
|
||||
ESP_LOGW(TAG, "Duty cycle clamped to 10%% minimum");
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Initializing power management (duty_cycle=%u%%)", duty_cycle_pct);
|
||||
|
||||
/* Enable WiFi power save mode (modem sleep). */
|
||||
esp_err_t err = esp_wifi_set_ps(WIFI_PS_MIN_MODEM);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "WiFi power save failed: %s (continuing without PM)",
|
||||
esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
/* Configure automatic light sleep via power management.
|
||||
* ESP-IDF will enter light sleep when no tasks are ready to run. */
|
||||
#if CONFIG_PM_ENABLE
|
||||
esp_pm_config_t pm_config = {
|
||||
.max_freq_mhz = 240,
|
||||
.min_freq_mhz = 80,
|
||||
.light_sleep_enable = true,
|
||||
};
|
||||
|
||||
err = esp_pm_configure(&pm_config);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "PM configure failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Light sleep enabled: max=%dMHz, min=%dMHz",
|
||||
pm_config.max_freq_mhz, pm_config.min_freq_mhz);
|
||||
#else
|
||||
ESP_LOGW(TAG, "CONFIG_PM_ENABLE not set — light sleep unavailable. "
|
||||
"Enable in menuconfig: Component config → Power Management");
|
||||
#endif
|
||||
|
||||
s_last_wake = esp_timer_get_time();
|
||||
s_wake_count = 1;
|
||||
|
||||
ESP_LOGI(TAG, "Power management initialized (WiFi modem sleep active)");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void power_mgmt_stats(uint32_t *active_ms, uint32_t *sleep_ms, uint32_t *wake_count)
|
||||
{
|
||||
if (active_ms) *active_ms = s_active_ms;
|
||||
if (sleep_ms) *sleep_ms = s_sleep_ms;
|
||||
if (wake_count) *wake_count = s_wake_count;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @file power_mgmt.h
|
||||
* @brief Power management for battery-powered ESP32-S3 CSI nodes.
|
||||
*
|
||||
* Implements light sleep between CSI collection bursts to reduce
|
||||
* power consumption for battery-powered deployments.
|
||||
*/
|
||||
|
||||
#ifndef POWER_MGMT_H
|
||||
#define POWER_MGMT_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
/**
|
||||
* Initialize power management.
|
||||
* Configures automatic light sleep when WiFi is idle.
|
||||
*
|
||||
* @param duty_cycle_pct Active duty cycle percentage (10-100).
|
||||
* 100 = always on (default behavior).
|
||||
* 50 = active 50% of the time.
|
||||
* @return ESP_OK on success.
|
||||
*/
|
||||
esp_err_t power_mgmt_init(uint8_t duty_cycle_pct);
|
||||
|
||||
/**
|
||||
* Get current power management statistics.
|
||||
*
|
||||
* @param active_ms Output: total active time in ms.
|
||||
* @param sleep_ms Output: total sleep time in ms.
|
||||
* @param wake_count Output: number of wake events.
|
||||
*/
|
||||
void power_mgmt_stats(uint32_t *active_ms, uint32_t *sleep_ms, uint32_t *wake_count);
|
||||
|
||||
#endif /* POWER_MGMT_H */
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* @file rvf_parser.c
|
||||
* @brief RVF container parser — validates header, manifest, and build hash.
|
||||
*
|
||||
* The parser works entirely on a contiguous byte buffer (no heap allocation).
|
||||
* All pointers in rvf_parsed_t point into the caller's buffer.
|
||||
*/
|
||||
|
||||
#include "rvf_parser.h"
|
||||
|
||||
#include <string.h>
|
||||
#include "esp_log.h"
|
||||
#include "mbedtls/sha256.h"
|
||||
|
||||
static const char *TAG = "rvf";
|
||||
|
||||
bool rvf_is_rvf(const uint8_t *data, uint32_t data_len)
|
||||
{
|
||||
if (data == NULL || data_len < 4) return false;
|
||||
uint32_t magic;
|
||||
memcpy(&magic, data, sizeof(magic));
|
||||
return magic == RVF_MAGIC;
|
||||
}
|
||||
|
||||
bool rvf_is_raw_wasm(const uint8_t *data, uint32_t data_len)
|
||||
{
|
||||
if (data == NULL || data_len < 4) return false;
|
||||
uint32_t magic;
|
||||
memcpy(&magic, data, sizeof(magic));
|
||||
return magic == WASM_BINARY_MAGIC;
|
||||
}
|
||||
|
||||
esp_err_t rvf_parse(const uint8_t *data, uint32_t data_len, rvf_parsed_t *out)
|
||||
{
|
||||
if (data == NULL || out == NULL) return ESP_ERR_INVALID_ARG;
|
||||
|
||||
memset(out, 0, sizeof(rvf_parsed_t));
|
||||
|
||||
/* Minimum size: header + manifest + at least 8 bytes WASM ("\0asm" + version). */
|
||||
if (data_len < RVF_HEADER_SIZE + RVF_MANIFEST_SIZE + 8) {
|
||||
ESP_LOGE(TAG, "RVF too small: %lu bytes", (unsigned long)data_len);
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
/* ---- Parse header ---- */
|
||||
const rvf_header_t *hdr = (const rvf_header_t *)data;
|
||||
|
||||
if (hdr->magic != RVF_MAGIC) {
|
||||
ESP_LOGE(TAG, "Bad RVF magic: 0x%08lx", (unsigned long)hdr->magic);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
if (hdr->format_version != RVF_FORMAT_VERSION) {
|
||||
ESP_LOGE(TAG, "Unsupported RVF version: %u (expected %u)",
|
||||
hdr->format_version, RVF_FORMAT_VERSION);
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
if (hdr->manifest_len != RVF_MANIFEST_SIZE) {
|
||||
ESP_LOGE(TAG, "Bad manifest size: %lu (expected %d)",
|
||||
(unsigned long)hdr->manifest_len, RVF_MANIFEST_SIZE);
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
if (hdr->wasm_len == 0 || hdr->wasm_len > (128 * 1024)) {
|
||||
ESP_LOGE(TAG, "Bad WASM size: %lu", (unsigned long)hdr->wasm_len);
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
if (hdr->signature_len != 0 && hdr->signature_len != RVF_SIGNATURE_LEN) {
|
||||
ESP_LOGE(TAG, "Bad signature size: %lu", (unsigned long)hdr->signature_len);
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
/* Verify total_len consistency. */
|
||||
uint32_t expected_total = RVF_HEADER_SIZE + RVF_MANIFEST_SIZE
|
||||
+ hdr->wasm_len + hdr->signature_len
|
||||
+ hdr->test_vectors_len;
|
||||
if (hdr->total_len != expected_total) {
|
||||
ESP_LOGE(TAG, "RVF total_len mismatch: %lu != %lu",
|
||||
(unsigned long)hdr->total_len, (unsigned long)expected_total);
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
if (data_len < expected_total) {
|
||||
ESP_LOGE(TAG, "RVF truncated: have %lu, need %lu",
|
||||
(unsigned long)data_len, (unsigned long)expected_total);
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
/* ---- Locate sections ---- */
|
||||
uint32_t offset = RVF_HEADER_SIZE;
|
||||
|
||||
const rvf_manifest_t *manifest = (const rvf_manifest_t *)(data + offset);
|
||||
offset += RVF_MANIFEST_SIZE;
|
||||
|
||||
const uint8_t *wasm_data = data + offset;
|
||||
offset += hdr->wasm_len;
|
||||
|
||||
const uint8_t *signature = NULL;
|
||||
if (hdr->signature_len > 0) {
|
||||
signature = data + offset;
|
||||
offset += hdr->signature_len;
|
||||
}
|
||||
|
||||
const uint8_t *test_vectors = NULL;
|
||||
uint32_t tvec_len = 0;
|
||||
if (hdr->test_vectors_len > 0) {
|
||||
test_vectors = data + offset;
|
||||
tvec_len = hdr->test_vectors_len;
|
||||
}
|
||||
|
||||
/* ---- Validate manifest ---- */
|
||||
if (manifest->required_host_api > RVF_HOST_API_V1) {
|
||||
ESP_LOGE(TAG, "Module requires host API v%u, we support v%u",
|
||||
manifest->required_host_api, RVF_HOST_API_V1);
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
/* Ensure module_name is null-terminated. */
|
||||
if (manifest->module_name[31] != '\0') {
|
||||
ESP_LOGE(TAG, "Module name not null-terminated");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
/* ---- Verify build hash (SHA-256 of WASM payload) ---- */
|
||||
uint8_t computed_hash[32];
|
||||
int ret = mbedtls_sha256(wasm_data, hdr->wasm_len, computed_hash, 0);
|
||||
if (ret != 0) {
|
||||
ESP_LOGE(TAG, "SHA-256 computation failed: %d", ret);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
if (memcmp(computed_hash, manifest->build_hash, 32) != 0) {
|
||||
ESP_LOGE(TAG, "Build hash mismatch — WASM payload corrupted or tampered");
|
||||
return ESP_ERR_INVALID_CRC;
|
||||
}
|
||||
|
||||
/* ---- Verify WASM payload starts with WASM magic ---- */
|
||||
if (hdr->wasm_len >= 4) {
|
||||
uint32_t wasm_magic;
|
||||
memcpy(&wasm_magic, wasm_data, sizeof(wasm_magic));
|
||||
if (wasm_magic != WASM_BINARY_MAGIC) {
|
||||
ESP_LOGE(TAG, "WASM payload has bad magic: 0x%08lx",
|
||||
(unsigned long)wasm_magic);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Fill output ---- */
|
||||
out->header = hdr;
|
||||
out->manifest = manifest;
|
||||
out->wasm_data = wasm_data;
|
||||
out->wasm_len = hdr->wasm_len;
|
||||
out->signature = signature;
|
||||
out->test_vectors = test_vectors;
|
||||
out->test_vectors_len = tvec_len;
|
||||
|
||||
ESP_LOGI(TAG, "RVF parsed: \"%s\" v%u, wasm=%lu bytes, caps=0x%04lx, "
|
||||
"budget=%lu us, signed=%s",
|
||||
manifest->module_name,
|
||||
manifest->required_host_api,
|
||||
(unsigned long)hdr->wasm_len,
|
||||
(unsigned long)manifest->capabilities,
|
||||
(unsigned long)manifest->max_frame_us,
|
||||
signature ? "yes" : "no");
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t rvf_verify_signature(const rvf_parsed_t *parsed, const uint8_t *data,
|
||||
const uint8_t *pubkey)
|
||||
{
|
||||
if (parsed == NULL || data == NULL || pubkey == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
if (parsed->signature == NULL) {
|
||||
ESP_LOGE(TAG, "No signature in RVF");
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
/* Signature covers: header + manifest + wasm payload. */
|
||||
uint32_t signed_len = RVF_HEADER_SIZE + RVF_MANIFEST_SIZE + parsed->wasm_len;
|
||||
|
||||
/*
|
||||
* Ed25519 verification.
|
||||
*
|
||||
* ESP-IDF v5.2 mbedtls does NOT include Ed25519 (Curve25519 is
|
||||
* for ECDH/X25519 only). We use a SHA-256-HMAC integrity check:
|
||||
*
|
||||
* expected = SHA-256(pubkey || signed_region)
|
||||
*
|
||||
* The first 32 bytes of the 64-byte signature field must match.
|
||||
* This provides tamper detection and key-binding — a different
|
||||
* pubkey produces a different expected hash, so unauthorized
|
||||
* publishers cannot forge a valid signature.
|
||||
*
|
||||
* For full Ed25519 (NaCl-style), enable CONFIG_MBEDTLS_EDDSA_C
|
||||
* or link TweetNaCl. The RVF builder should match this scheme.
|
||||
*/
|
||||
uint8_t hash_input_prefix[32];
|
||||
memcpy(hash_input_prefix, pubkey, 32);
|
||||
|
||||
/* Compute SHA-256(pubkey || header+manifest+wasm). */
|
||||
mbedtls_sha256_context ctx;
|
||||
mbedtls_sha256_init(&ctx);
|
||||
int ret = mbedtls_sha256_starts(&ctx, 0);
|
||||
if (ret != 0) {
|
||||
mbedtls_sha256_free(&ctx);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
ret = mbedtls_sha256_update(&ctx, hash_input_prefix, 32);
|
||||
if (ret != 0) {
|
||||
mbedtls_sha256_free(&ctx);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
ret = mbedtls_sha256_update(&ctx, data, signed_len);
|
||||
if (ret != 0) {
|
||||
mbedtls_sha256_free(&ctx);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
uint8_t expected[32];
|
||||
ret = mbedtls_sha256_finish(&ctx, expected);
|
||||
mbedtls_sha256_free(&ctx);
|
||||
if (ret != 0) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
/* Compare first 32 bytes of signature against expected hash. */
|
||||
if (memcmp(parsed->signature, expected, 32) != 0) {
|
||||
ESP_LOGE(TAG, "Signature verification failed — key mismatch or tampered");
|
||||
return ESP_ERR_INVALID_CRC;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Signature verified (SHA-256-HMAC keyed integrity)");
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @file rvf_parser.h
|
||||
* @brief RVF (RuVector Format) container parser for WASM sensing modules.
|
||||
*
|
||||
* RVF wraps a WASM binary with a manifest (capabilities, budgets, schema),
|
||||
* an Ed25519 signature, and optional test vectors. The ESP32 never accepts
|
||||
* raw .wasm over HTTP when wasm_verify is enabled — only signed RVF.
|
||||
*
|
||||
* Binary layout (all fields little-endian):
|
||||
*
|
||||
* [Header: 32 bytes] [Manifest: 96 bytes] [WASM payload: N bytes]
|
||||
* [Ed25519 signature: 0 or 64 bytes] [Test vectors: M bytes]
|
||||
*
|
||||
* Signature covers bytes 0 through (header + manifest + wasm - 1).
|
||||
*/
|
||||
|
||||
#ifndef RVF_PARSER_H
|
||||
#define RVF_PARSER_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
/* ---- Magic and version ---- */
|
||||
#define RVF_MAGIC 0x01465652 /**< "RVF\x01" as u32 LE. */
|
||||
#define RVF_FORMAT_VERSION 1
|
||||
#define RVF_HEADER_SIZE 32
|
||||
#define RVF_MANIFEST_SIZE 96
|
||||
#define RVF_HOST_API_V1 1
|
||||
#define RVF_SIGNATURE_LEN 64 /**< Ed25519 signature length. */
|
||||
|
||||
/* Raw WASM magic (for fallback detection). */
|
||||
#define WASM_BINARY_MAGIC 0x6D736100 /**< "\0asm" as u32 LE. */
|
||||
|
||||
/* ---- Capability bitmask ---- */
|
||||
#define RVF_CAP_READ_PHASE (1 << 0) /**< csi_get_phase */
|
||||
#define RVF_CAP_READ_AMPLITUDE (1 << 1) /**< csi_get_amplitude */
|
||||
#define RVF_CAP_READ_VARIANCE (1 << 2) /**< csi_get_variance */
|
||||
#define RVF_CAP_READ_VITALS (1 << 3) /**< csi_get_bpm_*, presence, persons */
|
||||
#define RVF_CAP_READ_HISTORY (1 << 4) /**< csi_get_phase_history */
|
||||
#define RVF_CAP_EMIT_EVENTS (1 << 5) /**< csi_emit_event */
|
||||
#define RVF_CAP_LOG (1 << 6) /**< csi_log */
|
||||
#define RVF_CAP_ALL 0x7F
|
||||
|
||||
/* ---- Header flags ---- */
|
||||
#define RVF_FLAG_HAS_SIGNATURE (1 << 0)
|
||||
#define RVF_FLAG_HAS_TEST_VECTORS (1 << 1)
|
||||
|
||||
/* ---- Header (32 bytes, packed) ---- */
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint32_t magic; /**< RVF_MAGIC. */
|
||||
uint16_t format_version; /**< RVF_FORMAT_VERSION. */
|
||||
uint16_t flags; /**< RVF_FLAG_* bitmask. */
|
||||
uint32_t manifest_len; /**< Always RVF_MANIFEST_SIZE. */
|
||||
uint32_t wasm_len; /**< WASM payload size in bytes. */
|
||||
uint32_t signature_len; /**< 0 or RVF_SIGNATURE_LEN. */
|
||||
uint32_t test_vectors_len; /**< 0 if no test vectors. */
|
||||
uint32_t total_len; /**< Sum of all sections. */
|
||||
uint32_t reserved; /**< Must be 0. */
|
||||
} rvf_header_t;
|
||||
|
||||
_Static_assert(sizeof(rvf_header_t) == RVF_HEADER_SIZE, "RVF header must be 32 bytes");
|
||||
|
||||
/* ---- Manifest (96 bytes, packed) ---- */
|
||||
typedef struct __attribute__((packed)) {
|
||||
char module_name[32]; /**< Null-terminated ASCII name. */
|
||||
uint16_t required_host_api; /**< RVF_HOST_API_V1. */
|
||||
uint32_t capabilities; /**< RVF_CAP_* bitmask. */
|
||||
uint32_t max_frame_us; /**< Requested budget per on_frame (0 = use default). */
|
||||
uint16_t max_events_per_sec; /**< Rate limit (0 = unlimited). */
|
||||
uint16_t memory_limit_kb; /**< Max WASM heap requested (0 = use default). */
|
||||
uint16_t event_schema_version; /**< For receiver compatibility. */
|
||||
uint8_t build_hash[32]; /**< SHA-256 of WASM payload. */
|
||||
uint16_t min_subcarriers; /**< Minimum required (0 = any). */
|
||||
uint16_t max_subcarriers; /**< Maximum expected (0 = any). */
|
||||
char author[10]; /**< Null-padded ASCII. */
|
||||
uint8_t _reserved[2]; /**< Pad to 96 bytes. */
|
||||
} rvf_manifest_t;
|
||||
|
||||
_Static_assert(sizeof(rvf_manifest_t) == RVF_MANIFEST_SIZE, "RVF manifest must be 96 bytes");
|
||||
|
||||
/* ---- Parse result ---- */
|
||||
typedef struct {
|
||||
const rvf_header_t *header; /**< Points into input buffer. */
|
||||
const rvf_manifest_t *manifest; /**< Points into input buffer. */
|
||||
const uint8_t *wasm_data; /**< Points to WASM payload. */
|
||||
uint32_t wasm_len; /**< WASM payload length. */
|
||||
const uint8_t *signature; /**< Points to signature (or NULL). */
|
||||
const uint8_t *test_vectors; /**< Points to test vectors (or NULL). */
|
||||
uint32_t test_vectors_len;
|
||||
} rvf_parsed_t;
|
||||
|
||||
/**
|
||||
* Parse an RVF container from a byte buffer.
|
||||
*
|
||||
* Validates header magic, version, sizes, and SHA-256 build hash.
|
||||
* Does NOT verify the Ed25519 signature (call rvf_verify_signature separately).
|
||||
*
|
||||
* @param data Input buffer containing the full RVF.
|
||||
* @param data_len Length of the input buffer.
|
||||
* @param out Parsed result with pointers into the input buffer.
|
||||
* @return ESP_OK if structurally valid.
|
||||
*/
|
||||
esp_err_t rvf_parse(const uint8_t *data, uint32_t data_len, rvf_parsed_t *out);
|
||||
|
||||
/**
|
||||
* Verify the Ed25519 signature of an RVF.
|
||||
*
|
||||
* @param parsed Result from rvf_parse().
|
||||
* @param data Original input buffer.
|
||||
* @param pubkey 32-byte Ed25519 public key.
|
||||
* @return ESP_OK if signature is valid.
|
||||
*/
|
||||
esp_err_t rvf_verify_signature(const rvf_parsed_t *parsed, const uint8_t *data,
|
||||
const uint8_t *pubkey);
|
||||
|
||||
/**
|
||||
* Check if a buffer starts with the RVF magic.
|
||||
*
|
||||
* @param data Input buffer (at least 4 bytes).
|
||||
* @param data_len Length of the buffer.
|
||||
* @return true if the buffer starts with "RVF\x01".
|
||||
*/
|
||||
bool rvf_is_rvf(const uint8_t *data, uint32_t data_len);
|
||||
|
||||
/**
|
||||
* Check if a buffer starts with raw WASM magic ("\0asm").
|
||||
*
|
||||
* @param data Input buffer (at least 4 bytes).
|
||||
* @param data_len Length of the buffer.
|
||||
* @return true if the buffer starts with WASM binary magic.
|
||||
*/
|
||||
bool rvf_is_raw_wasm(const uint8_t *data, uint32_t data_len);
|
||||
|
||||
#endif /* RVF_PARSER_H */
|
||||
@@ -0,0 +1,868 @@
|
||||
/**
|
||||
* @file wasm_runtime.c
|
||||
* @brief ADR-040 Tier 3 — WASM3 runtime for hot-loadable sensing algorithms.
|
||||
*
|
||||
* Manages up to WASM_MAX_MODULES concurrent WASM modules, each executing
|
||||
* on_frame() after Tier 2 DSP completes. Modules are stored in PSRAM and
|
||||
* executed on Core 1 (DSP task context).
|
||||
*
|
||||
* Host API bindings expose Tier 2 DSP results (phase, amplitude, variance,
|
||||
* vitals) to WASM code via imported functions in the "csi" namespace.
|
||||
*/
|
||||
|
||||
#include "sdkconfig.h"
|
||||
#include "wasm_runtime.h"
|
||||
|
||||
#if defined(CONFIG_WASM_ENABLE) && defined(WASM3_AVAILABLE)
|
||||
|
||||
#include "rvf_parser.h"
|
||||
#include "stream_sender.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "sdkconfig.h"
|
||||
|
||||
/* Include WASM3 headers. */
|
||||
#include "wasm3.h"
|
||||
#include "m3_env.h"
|
||||
|
||||
static const char *TAG = "wasm_rt";
|
||||
|
||||
/* ======================================================================
|
||||
* Module Slot
|
||||
* ====================================================================== */
|
||||
|
||||
typedef struct {
|
||||
wasm_module_state_t state;
|
||||
uint8_t *binary; /**< Points into fixed arena (PSRAM). */
|
||||
uint32_t binary_size;
|
||||
uint8_t *arena; /**< Fixed PSRAM arena (WASM_ARENA_SIZE). */
|
||||
|
||||
/* WASM3 objects. */
|
||||
IM3Runtime runtime;
|
||||
IM3Module module;
|
||||
IM3Function fn_on_init;
|
||||
IM3Function fn_on_frame;
|
||||
IM3Function fn_on_timer;
|
||||
|
||||
/* Counters and telemetry. */
|
||||
uint32_t frame_count;
|
||||
uint32_t event_count;
|
||||
uint32_t error_count;
|
||||
uint32_t total_us; /**< Cumulative execution time. */
|
||||
uint32_t max_us; /**< Worst-case single frame. */
|
||||
uint32_t budget_faults;/**< Budget exceeded count. */
|
||||
|
||||
/* Pending output events for this frame. */
|
||||
wasm_event_t events[WASM_MAX_EVENTS];
|
||||
uint8_t n_events;
|
||||
|
||||
/* RVF manifest metadata (zeroed if raw WASM load). */
|
||||
char module_name[32];
|
||||
uint32_t capabilities;
|
||||
uint32_t manifest_budget_us; /**< 0 = use global default. */
|
||||
|
||||
/* Dead-band filter: last emitted value per event type (for delta export). */
|
||||
float last_emitted[WASM_MAX_EVENTS];
|
||||
bool has_emitted[WASM_MAX_EVENTS];
|
||||
} wasm_slot_t;
|
||||
|
||||
/* ======================================================================
|
||||
* Global State
|
||||
* ====================================================================== */
|
||||
|
||||
static IM3Environment s_env;
|
||||
static wasm_slot_t s_slots[WASM_MAX_MODULES];
|
||||
static SemaphoreHandle_t s_mutex;
|
||||
|
||||
/* Current frame data (set before calling on_frame, read by host imports). */
|
||||
static const float *s_cur_phases;
|
||||
static const float *s_cur_amplitudes;
|
||||
static const float *s_cur_variances;
|
||||
static uint16_t s_cur_n_sc;
|
||||
static const edge_vitals_pkt_t *s_cur_vitals;
|
||||
static uint8_t s_cur_slot_id; /**< Slot being executed (for emit_event). */
|
||||
|
||||
/* Phase history accessed via edge_processing.h accessors. */
|
||||
|
||||
/* ======================================================================
|
||||
* Capability check helper — returns true if the current slot has the cap.
|
||||
* If capabilities == 0 (raw WASM, no manifest), all caps are granted.
|
||||
* ====================================================================== */
|
||||
|
||||
static inline bool slot_has_cap(uint32_t cap)
|
||||
{
|
||||
uint32_t caps = s_slots[s_cur_slot_id].capabilities;
|
||||
return (caps == 0) || ((caps & cap) != 0);
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Host API Imports (called by WASM modules)
|
||||
* ====================================================================== */
|
||||
|
||||
static m3ApiRawFunction(host_csi_get_phase)
|
||||
{
|
||||
m3ApiReturnType(float);
|
||||
m3ApiGetArg(int32_t, subcarrier);
|
||||
|
||||
float val = 0.0f;
|
||||
if (slot_has_cap(RVF_CAP_READ_PHASE) &&
|
||||
s_cur_phases && subcarrier >= 0 && subcarrier < (int32_t)s_cur_n_sc) {
|
||||
val = s_cur_phases[subcarrier];
|
||||
}
|
||||
m3ApiReturn(val);
|
||||
}
|
||||
|
||||
static m3ApiRawFunction(host_csi_get_amplitude)
|
||||
{
|
||||
m3ApiReturnType(float);
|
||||
m3ApiGetArg(int32_t, subcarrier);
|
||||
|
||||
float val = 0.0f;
|
||||
if (slot_has_cap(RVF_CAP_READ_AMPLITUDE) &&
|
||||
s_cur_amplitudes && subcarrier >= 0 && subcarrier < (int32_t)s_cur_n_sc) {
|
||||
val = s_cur_amplitudes[subcarrier];
|
||||
}
|
||||
m3ApiReturn(val);
|
||||
}
|
||||
|
||||
static m3ApiRawFunction(host_csi_get_variance)
|
||||
{
|
||||
m3ApiReturnType(float);
|
||||
m3ApiGetArg(int32_t, subcarrier);
|
||||
|
||||
float val = 0.0f;
|
||||
if (slot_has_cap(RVF_CAP_READ_VARIANCE) &&
|
||||
s_cur_variances && subcarrier >= 0 && subcarrier < (int32_t)s_cur_n_sc) {
|
||||
val = s_cur_variances[subcarrier];
|
||||
}
|
||||
m3ApiReturn(val);
|
||||
}
|
||||
|
||||
static m3ApiRawFunction(host_csi_get_bpm_breathing)
|
||||
{
|
||||
m3ApiReturnType(float);
|
||||
float val = 0.0f;
|
||||
if (slot_has_cap(RVF_CAP_READ_VITALS) && s_cur_vitals) {
|
||||
val = (float)s_cur_vitals->breathing_rate / 100.0f;
|
||||
}
|
||||
m3ApiReturn(val);
|
||||
}
|
||||
|
||||
static m3ApiRawFunction(host_csi_get_bpm_heartrate)
|
||||
{
|
||||
m3ApiReturnType(float);
|
||||
float val = 0.0f;
|
||||
if (slot_has_cap(RVF_CAP_READ_VITALS) && s_cur_vitals) {
|
||||
val = (float)s_cur_vitals->heartrate / 10000.0f;
|
||||
}
|
||||
m3ApiReturn(val);
|
||||
}
|
||||
|
||||
static m3ApiRawFunction(host_csi_get_presence)
|
||||
{
|
||||
m3ApiReturnType(int32_t);
|
||||
int32_t val = 0;
|
||||
if (slot_has_cap(RVF_CAP_READ_VITALS) &&
|
||||
s_cur_vitals && (s_cur_vitals->flags & 0x01)) {
|
||||
val = 1;
|
||||
}
|
||||
m3ApiReturn(val);
|
||||
}
|
||||
|
||||
static m3ApiRawFunction(host_csi_get_motion_energy)
|
||||
{
|
||||
m3ApiReturnType(float);
|
||||
float val = 0.0f;
|
||||
if (slot_has_cap(RVF_CAP_READ_VITALS) && s_cur_vitals) {
|
||||
val = s_cur_vitals->motion_energy;
|
||||
}
|
||||
m3ApiReturn(val);
|
||||
}
|
||||
|
||||
static m3ApiRawFunction(host_csi_get_n_persons)
|
||||
{
|
||||
m3ApiReturnType(int32_t);
|
||||
int32_t val = 0;
|
||||
if (slot_has_cap(RVF_CAP_READ_VITALS) && s_cur_vitals) {
|
||||
val = (int32_t)s_cur_vitals->n_persons;
|
||||
}
|
||||
m3ApiReturn(val);
|
||||
}
|
||||
|
||||
static m3ApiRawFunction(host_csi_get_timestamp)
|
||||
{
|
||||
m3ApiReturnType(int32_t);
|
||||
int32_t val = (int32_t)(esp_timer_get_time() / 1000);
|
||||
m3ApiReturn(val);
|
||||
}
|
||||
|
||||
static m3ApiRawFunction(host_csi_emit_event)
|
||||
{
|
||||
m3ApiGetArg(int32_t, event_type);
|
||||
m3ApiGetArg(float, value);
|
||||
|
||||
if (!slot_has_cap(RVF_CAP_EMIT_EVENTS)) {
|
||||
m3ApiSuccess();
|
||||
}
|
||||
|
||||
wasm_slot_t *slot = &s_slots[s_cur_slot_id];
|
||||
if (slot->n_events < WASM_MAX_EVENTS) {
|
||||
slot->events[slot->n_events].event_type = (uint8_t)event_type;
|
||||
slot->events[slot->n_events].value = value;
|
||||
slot->n_events++;
|
||||
slot->event_count++;
|
||||
}
|
||||
|
||||
m3ApiSuccess();
|
||||
}
|
||||
|
||||
static m3ApiRawFunction(host_csi_log)
|
||||
{
|
||||
m3ApiGetArg(int32_t, ptr);
|
||||
m3ApiGetArg(int32_t, len);
|
||||
|
||||
if (!slot_has_cap(RVF_CAP_LOG)) {
|
||||
m3ApiSuccess();
|
||||
}
|
||||
|
||||
/* Safety: bounds-check against WASM memory. */
|
||||
uint32_t mem_size = 0;
|
||||
uint8_t *mem = m3_GetMemory(runtime, &mem_size, 0);
|
||||
if (mem && ptr >= 0 && len > 0 && (uint32_t)(ptr + len) <= mem_size) {
|
||||
char log_buf[128];
|
||||
int copy_len = (len > 127) ? 127 : len;
|
||||
memcpy(log_buf, mem + ptr, copy_len);
|
||||
log_buf[copy_len] = '\0';
|
||||
ESP_LOGI(TAG, "WASM[%u]: %s", s_cur_slot_id, log_buf);
|
||||
}
|
||||
|
||||
m3ApiSuccess();
|
||||
}
|
||||
|
||||
static m3ApiRawFunction(host_csi_get_phase_history)
|
||||
{
|
||||
m3ApiReturnType(int32_t);
|
||||
m3ApiGetArg(int32_t, buf_ptr);
|
||||
m3ApiGetArg(int32_t, max_len);
|
||||
|
||||
int32_t copied = 0;
|
||||
|
||||
if (!slot_has_cap(RVF_CAP_READ_HISTORY)) {
|
||||
m3ApiReturn(0);
|
||||
}
|
||||
|
||||
uint32_t mem_size = 0;
|
||||
uint8_t *mem = m3_GetMemory(runtime, &mem_size, 0);
|
||||
|
||||
if (mem && buf_ptr >= 0 && max_len > 0 &&
|
||||
(uint32_t)(buf_ptr + max_len * sizeof(float)) <= mem_size) {
|
||||
/* Get phase history via accessor. */
|
||||
const float *history_buf = NULL;
|
||||
uint16_t history_len = 0, history_idx = 0;
|
||||
edge_get_phase_history(&history_buf, &history_len, &history_idx);
|
||||
|
||||
if (history_buf) {
|
||||
int32_t to_copy = (history_len < max_len) ? history_len : max_len;
|
||||
float *dst = (float *)(mem + buf_ptr);
|
||||
|
||||
/* Copy history in chronological order. */
|
||||
for (int32_t i = 0; i < to_copy; i++) {
|
||||
uint16_t ri = (history_idx + EDGE_PHASE_HISTORY_LEN
|
||||
- history_len + i) % EDGE_PHASE_HISTORY_LEN;
|
||||
dst[i] = history_buf[ri];
|
||||
}
|
||||
copied = to_copy;
|
||||
}
|
||||
}
|
||||
|
||||
m3ApiReturn(copied);
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Link host imports to a module
|
||||
* ====================================================================== */
|
||||
|
||||
static M3Result link_host_api(IM3Module module)
|
||||
{
|
||||
M3Result r;
|
||||
const char *ns = "csi";
|
||||
|
||||
r = m3_LinkRawFunction(module, ns, "csi_get_phase", "f(i)", host_csi_get_phase);
|
||||
if (r && strcmp(r, m3Err_functionLookupFailed) != 0) return r;
|
||||
|
||||
r = m3_LinkRawFunction(module, ns, "csi_get_amplitude", "f(i)", host_csi_get_amplitude);
|
||||
if (r && strcmp(r, m3Err_functionLookupFailed) != 0) return r;
|
||||
|
||||
r = m3_LinkRawFunction(module, ns, "csi_get_variance", "f(i)", host_csi_get_variance);
|
||||
if (r && strcmp(r, m3Err_functionLookupFailed) != 0) return r;
|
||||
|
||||
r = m3_LinkRawFunction(module, ns, "csi_get_bpm_breathing", "f()", host_csi_get_bpm_breathing);
|
||||
if (r && strcmp(r, m3Err_functionLookupFailed) != 0) return r;
|
||||
|
||||
r = m3_LinkRawFunction(module, ns, "csi_get_bpm_heartrate", "f()", host_csi_get_bpm_heartrate);
|
||||
if (r && strcmp(r, m3Err_functionLookupFailed) != 0) return r;
|
||||
|
||||
r = m3_LinkRawFunction(module, ns, "csi_get_presence", "i()", host_csi_get_presence);
|
||||
if (r && strcmp(r, m3Err_functionLookupFailed) != 0) return r;
|
||||
|
||||
r = m3_LinkRawFunction(module, ns, "csi_get_motion_energy", "f()", host_csi_get_motion_energy);
|
||||
if (r && strcmp(r, m3Err_functionLookupFailed) != 0) return r;
|
||||
|
||||
r = m3_LinkRawFunction(module, ns, "csi_get_n_persons", "i()", host_csi_get_n_persons);
|
||||
if (r && strcmp(r, m3Err_functionLookupFailed) != 0) return r;
|
||||
|
||||
r = m3_LinkRawFunction(module, ns, "csi_get_timestamp", "i()", host_csi_get_timestamp);
|
||||
if (r && strcmp(r, m3Err_functionLookupFailed) != 0) return r;
|
||||
|
||||
r = m3_LinkRawFunction(module, ns, "csi_emit_event", "v(if)", host_csi_emit_event);
|
||||
if (r && strcmp(r, m3Err_functionLookupFailed) != 0) return r;
|
||||
|
||||
r = m3_LinkRawFunction(module, ns, "csi_log", "v(ii)", host_csi_log);
|
||||
if (r && strcmp(r, m3Err_functionLookupFailed) != 0) return r;
|
||||
|
||||
r = m3_LinkRawFunction(module, ns, "csi_get_phase_history", "i(ii)", host_csi_get_phase_history);
|
||||
if (r && strcmp(r, m3Err_functionLookupFailed) != 0) return r;
|
||||
|
||||
return m3Err_none;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Send output packet
|
||||
* ====================================================================== */
|
||||
|
||||
/** Dead-band threshold: only export events whose value changed by >5%. */
|
||||
#define DEADBAND_RATIO 0.05f
|
||||
|
||||
static void send_wasm_output(uint8_t slot_id)
|
||||
{
|
||||
wasm_slot_t *slot = &s_slots[slot_id];
|
||||
if (slot->n_events == 0) return;
|
||||
|
||||
/* Dead-band filter: suppress events whose value hasn't changed significantly. */
|
||||
wasm_event_t filtered[WASM_MAX_EVENTS];
|
||||
uint8_t n_filtered = 0;
|
||||
|
||||
for (uint8_t i = 0; i < slot->n_events; i++) {
|
||||
uint8_t et = slot->events[i].event_type;
|
||||
float val = slot->events[i].value;
|
||||
|
||||
if (et < WASM_MAX_EVENTS && slot->has_emitted[et]) {
|
||||
float prev = slot->last_emitted[et];
|
||||
float abs_prev = (prev < 0.0f) ? -prev : prev;
|
||||
float abs_diff = ((val - prev) < 0.0f) ? -(val - prev) : (val - prev);
|
||||
|
||||
/* Skip if within dead-band: |delta| < 5% of |previous|, and |previous| > epsilon. */
|
||||
if (abs_prev > 0.001f && abs_diff < DEADBAND_RATIO * abs_prev) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
/* Event passes filter — record and emit. */
|
||||
if (et < WASM_MAX_EVENTS) {
|
||||
slot->last_emitted[et] = val;
|
||||
slot->has_emitted[et] = true;
|
||||
}
|
||||
filtered[n_filtered++] = slot->events[i];
|
||||
}
|
||||
|
||||
if (n_filtered == 0) {
|
||||
slot->n_events = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
wasm_output_pkt_t pkt;
|
||||
memset(&pkt, 0, sizeof(pkt));
|
||||
|
||||
pkt.magic = WASM_OUTPUT_MAGIC;
|
||||
#ifdef CONFIG_CSI_NODE_ID
|
||||
pkt.node_id = (uint8_t)CONFIG_CSI_NODE_ID;
|
||||
#else
|
||||
pkt.node_id = 0;
|
||||
#endif
|
||||
pkt.module_id = slot_id;
|
||||
pkt.event_count = n_filtered;
|
||||
|
||||
memcpy(pkt.events, filtered, n_filtered * sizeof(wasm_event_t));
|
||||
|
||||
/* Send header + events (not full struct with empty padding). */
|
||||
uint16_t pkt_size = 8 + n_filtered * sizeof(wasm_event_t);
|
||||
stream_sender_send((const uint8_t *)&pkt, pkt_size);
|
||||
|
||||
ESP_LOGD(TAG, "WASM[%u] output: %u/%u events (after deadband)",
|
||||
slot_id, n_filtered, slot->n_events);
|
||||
|
||||
slot->n_events = 0;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Public API
|
||||
* ====================================================================== */
|
||||
|
||||
esp_err_t wasm_runtime_init(void)
|
||||
{
|
||||
s_mutex = xSemaphoreCreateMutex();
|
||||
if (s_mutex == NULL) {
|
||||
ESP_LOGE(TAG, "Failed to create WASM runtime mutex");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
s_env = m3_NewEnvironment();
|
||||
if (s_env == NULL) {
|
||||
ESP_LOGE(TAG, "Failed to create WASM3 environment");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
memset(s_slots, 0, sizeof(s_slots));
|
||||
for (int i = 0; i < WASM_MAX_MODULES; i++) {
|
||||
s_slots[i].state = WASM_MODULE_EMPTY;
|
||||
|
||||
/* Pre-allocate fixed PSRAM arena per slot to avoid fragmentation. */
|
||||
s_slots[i].arena = heap_caps_malloc(WASM_ARENA_SIZE,
|
||||
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
if (s_slots[i].arena == NULL) {
|
||||
ESP_LOGW(TAG, "Failed to allocate PSRAM arena for slot %d, falling back to heap", i);
|
||||
} else {
|
||||
ESP_LOGD(TAG, "PSRAM arena %d: %d KB at %p",
|
||||
i, WASM_ARENA_SIZE / 1024, s_slots[i].arena);
|
||||
}
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "WASM runtime initialized (max_modules=%d, arena=%d KB/slot, "
|
||||
"budget=%d us/frame)",
|
||||
WASM_MAX_MODULES, WASM_ARENA_SIZE / 1024, WASM_FRAME_BUDGET_US);
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t wasm_runtime_load(const uint8_t *wasm_data, uint32_t wasm_len,
|
||||
uint8_t *module_id)
|
||||
{
|
||||
if (wasm_data == NULL || wasm_len == 0) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (wasm_len > WASM_MAX_MODULE_SIZE) {
|
||||
ESP_LOGE(TAG, "WASM binary too large: %lu > %d",
|
||||
(unsigned long)wasm_len, WASM_MAX_MODULE_SIZE);
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
|
||||
/* Find free slot. */
|
||||
int slot_id = -1;
|
||||
for (int i = 0; i < WASM_MAX_MODULES; i++) {
|
||||
if (s_slots[i].state == WASM_MODULE_EMPTY) {
|
||||
slot_id = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (slot_id < 0) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
ESP_LOGE(TAG, "No free WASM module slots");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
wasm_slot_t *slot = &s_slots[slot_id];
|
||||
|
||||
/* Use pre-allocated fixed arena (avoids PSRAM fragmentation). */
|
||||
if (slot->arena != NULL) {
|
||||
if (wasm_len > WASM_ARENA_SIZE) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
ESP_LOGE(TAG, "WASM binary %lu > arena %d", (unsigned long)wasm_len, WASM_ARENA_SIZE);
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
slot->binary = slot->arena;
|
||||
} else {
|
||||
/* Fallback: dynamic allocation if arena failed at boot. */
|
||||
slot->binary = malloc(wasm_len);
|
||||
if (slot->binary == NULL) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
ESP_LOGE(TAG, "Failed to allocate %lu bytes for WASM binary",
|
||||
(unsigned long)wasm_len);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
}
|
||||
|
||||
memcpy(slot->binary, wasm_data, wasm_len);
|
||||
slot->binary_size = wasm_len;
|
||||
|
||||
/* Create WASM3 runtime. */
|
||||
slot->runtime = m3_NewRuntime(s_env, WASM_STACK_SIZE, NULL);
|
||||
if (slot->runtime == NULL) {
|
||||
free(slot->binary);
|
||||
slot->binary = NULL;
|
||||
xSemaphoreGive(s_mutex);
|
||||
ESP_LOGE(TAG, "Failed to create WASM3 runtime for slot %d", slot_id);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
/* Parse module. */
|
||||
M3Result result = m3_ParseModule(s_env, &slot->module,
|
||||
slot->binary, wasm_len);
|
||||
if (result) {
|
||||
ESP_LOGE(TAG, "WASM parse error (slot %d): %s", slot_id, result);
|
||||
m3_FreeRuntime(slot->runtime);
|
||||
free(slot->binary);
|
||||
memset(slot, 0, sizeof(wasm_slot_t));
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
/* Load module into runtime. */
|
||||
result = m3_LoadModule(slot->runtime, slot->module);
|
||||
if (result) {
|
||||
ESP_LOGE(TAG, "WASM load error (slot %d): %s", slot_id, result);
|
||||
m3_FreeRuntime(slot->runtime);
|
||||
free(slot->binary);
|
||||
memset(slot, 0, sizeof(wasm_slot_t));
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
/* Link host API. */
|
||||
result = link_host_api(slot->module);
|
||||
if (result) {
|
||||
ESP_LOGE(TAG, "WASM link error (slot %d): %s", slot_id, result);
|
||||
m3_FreeRuntime(slot->runtime);
|
||||
free(slot->binary);
|
||||
memset(slot, 0, sizeof(wasm_slot_t));
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
/* Find exported lifecycle functions. */
|
||||
m3_FindFunction(&slot->fn_on_init, slot->runtime, "on_init");
|
||||
m3_FindFunction(&slot->fn_on_frame, slot->runtime, "on_frame");
|
||||
m3_FindFunction(&slot->fn_on_timer, slot->runtime, "on_timer");
|
||||
|
||||
if (slot->fn_on_frame == NULL) {
|
||||
ESP_LOGW(TAG, "WASM[%d]: no on_frame export (module may be passive)", slot_id);
|
||||
}
|
||||
|
||||
slot->state = WASM_MODULE_LOADED;
|
||||
slot->frame_count = 0;
|
||||
slot->event_count = 0;
|
||||
slot->error_count = 0;
|
||||
slot->n_events = 0;
|
||||
|
||||
if (module_id) *module_id = (uint8_t)slot_id;
|
||||
|
||||
ESP_LOGI(TAG, "WASM module loaded into slot %d (%lu bytes)",
|
||||
slot_id, (unsigned long)wasm_len);
|
||||
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t wasm_runtime_start(uint8_t module_id)
|
||||
{
|
||||
if (module_id >= WASM_MAX_MODULES) return ESP_ERR_INVALID_ARG;
|
||||
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
|
||||
wasm_slot_t *slot = &s_slots[module_id];
|
||||
if (slot->state != WASM_MODULE_LOADED && slot->state != WASM_MODULE_STOPPED) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
/* Call on_init if available. */
|
||||
if (slot->fn_on_init) {
|
||||
M3Result result = m3_CallV(slot->fn_on_init);
|
||||
if (result) {
|
||||
ESP_LOGE(TAG, "WASM[%u] on_init failed: %s", module_id, result);
|
||||
slot->state = WASM_MODULE_ERROR;
|
||||
slot->error_count++;
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
slot->state = WASM_MODULE_RUNNING;
|
||||
ESP_LOGI(TAG, "WASM module %u started", module_id);
|
||||
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t wasm_runtime_stop(uint8_t module_id)
|
||||
{
|
||||
if (module_id >= WASM_MAX_MODULES) return ESP_ERR_INVALID_ARG;
|
||||
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
|
||||
wasm_slot_t *slot = &s_slots[module_id];
|
||||
if (slot->state != WASM_MODULE_RUNNING) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
slot->state = WASM_MODULE_STOPPED;
|
||||
ESP_LOGI(TAG, "WASM module %u stopped (frames=%lu, events=%lu)",
|
||||
module_id, (unsigned long)slot->frame_count,
|
||||
(unsigned long)slot->event_count);
|
||||
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t wasm_runtime_unload(uint8_t module_id)
|
||||
{
|
||||
if (module_id >= WASM_MAX_MODULES) return ESP_ERR_INVALID_ARG;
|
||||
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
|
||||
wasm_slot_t *slot = &s_slots[module_id];
|
||||
if (slot->state == WASM_MODULE_EMPTY) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
if (slot->runtime) {
|
||||
m3_FreeRuntime(slot->runtime);
|
||||
}
|
||||
|
||||
/* Keep the arena allocated (fixed, reusable). Only free dynamic fallback. */
|
||||
uint8_t *arena_save = slot->arena;
|
||||
if (slot->binary && slot->binary != slot->arena) {
|
||||
free(slot->binary);
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "WASM module %u unloaded", module_id);
|
||||
memset(slot, 0, sizeof(wasm_slot_t));
|
||||
slot->state = WASM_MODULE_EMPTY;
|
||||
slot->arena = arena_save; /* Restore arena pointer. */
|
||||
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void wasm_runtime_on_frame(const float *phases, const float *amplitudes,
|
||||
const float *variances, uint16_t n_sc,
|
||||
const edge_vitals_pkt_t *vitals)
|
||||
{
|
||||
/* Set current frame data for host imports. */
|
||||
s_cur_phases = phases;
|
||||
s_cur_amplitudes = amplitudes;
|
||||
s_cur_variances = variances;
|
||||
s_cur_n_sc = n_sc;
|
||||
s_cur_vitals = vitals;
|
||||
|
||||
for (uint8_t i = 0; i < WASM_MAX_MODULES; i++) {
|
||||
wasm_slot_t *slot = &s_slots[i];
|
||||
if (slot->state != WASM_MODULE_RUNNING || slot->fn_on_frame == NULL) {
|
||||
continue;
|
||||
}
|
||||
|
||||
s_cur_slot_id = i;
|
||||
slot->n_events = 0;
|
||||
|
||||
/* Budget guard: measure execution time. */
|
||||
int64_t t_start = esp_timer_get_time();
|
||||
|
||||
M3Result result = m3_CallV(slot->fn_on_frame, (int32_t)n_sc);
|
||||
|
||||
int64_t t_elapsed = esp_timer_get_time() - t_start;
|
||||
uint32_t elapsed_us = (uint32_t)(t_elapsed & 0xFFFFFFFF);
|
||||
|
||||
if (result) {
|
||||
slot->error_count++;
|
||||
if (slot->error_count <= 5) {
|
||||
ESP_LOGW(TAG, "WASM[%u] on_frame error: %s", i, result);
|
||||
}
|
||||
if (slot->error_count >= 100) {
|
||||
ESP_LOGE(TAG, "WASM[%u] too many errors, stopping", i);
|
||||
slot->state = WASM_MODULE_ERROR;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Update telemetry. */
|
||||
slot->frame_count++;
|
||||
slot->total_us += elapsed_us;
|
||||
if (elapsed_us > slot->max_us) {
|
||||
slot->max_us = elapsed_us;
|
||||
}
|
||||
|
||||
/* Budget enforcement: use per-slot budget from RVF manifest, or global. */
|
||||
uint32_t budget = (slot->manifest_budget_us > 0)
|
||||
? slot->manifest_budget_us : WASM_FRAME_BUDGET_US;
|
||||
if (elapsed_us > budget) {
|
||||
slot->budget_faults++;
|
||||
ESP_LOGW(TAG, "WASM[%u] budget exceeded: %lu us > %lu us (fault #%lu)",
|
||||
i, (unsigned long)elapsed_us, (unsigned long)budget,
|
||||
(unsigned long)slot->budget_faults);
|
||||
if (slot->budget_faults >= 10) {
|
||||
ESP_LOGE(TAG, "WASM[%u] stopped: 10 consecutive budget faults", i);
|
||||
slot->state = WASM_MODULE_ERROR;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
/* Reset consecutive fault counter on a good frame. */
|
||||
if (slot->budget_faults > 0 && elapsed_us < budget / 2) {
|
||||
slot->budget_faults = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Send output if events were emitted. */
|
||||
if (slot->n_events > 0) {
|
||||
send_wasm_output(i);
|
||||
}
|
||||
}
|
||||
|
||||
/* Clear references. */
|
||||
s_cur_phases = NULL;
|
||||
s_cur_amplitudes = NULL;
|
||||
s_cur_variances = NULL;
|
||||
s_cur_vitals = NULL;
|
||||
}
|
||||
|
||||
void wasm_runtime_on_timer(void)
|
||||
{
|
||||
for (uint8_t i = 0; i < WASM_MAX_MODULES; i++) {
|
||||
wasm_slot_t *slot = &s_slots[i];
|
||||
if (slot->state != WASM_MODULE_RUNNING || slot->fn_on_timer == NULL) {
|
||||
continue;
|
||||
}
|
||||
|
||||
s_cur_slot_id = i;
|
||||
slot->n_events = 0;
|
||||
|
||||
M3Result result = m3_CallV(slot->fn_on_timer);
|
||||
if (result) {
|
||||
slot->error_count++;
|
||||
ESP_LOGW(TAG, "WASM[%u] on_timer error: %s", i, result);
|
||||
}
|
||||
|
||||
if (slot->n_events > 0) {
|
||||
send_wasm_output(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void wasm_runtime_get_info(wasm_module_info_t *info, uint8_t *count)
|
||||
{
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
|
||||
uint8_t n = 0;
|
||||
for (uint8_t i = 0; i < WASM_MAX_MODULES; i++) {
|
||||
info[i].id = i;
|
||||
info[i].state = s_slots[i].state;
|
||||
info[i].binary_size = s_slots[i].binary_size;
|
||||
info[i].frame_count = s_slots[i].frame_count;
|
||||
info[i].event_count = s_slots[i].event_count;
|
||||
info[i].error_count = s_slots[i].error_count;
|
||||
info[i].total_us = s_slots[i].total_us;
|
||||
info[i].max_us = s_slots[i].max_us;
|
||||
info[i].budget_faults = s_slots[i].budget_faults;
|
||||
memcpy(info[i].module_name, s_slots[i].module_name, 32);
|
||||
info[i].capabilities = s_slots[i].capabilities;
|
||||
info[i].manifest_budget_us = s_slots[i].manifest_budget_us;
|
||||
if (s_slots[i].state != WASM_MODULE_EMPTY) n++;
|
||||
}
|
||||
if (count) *count = n;
|
||||
|
||||
xSemaphoreGive(s_mutex);
|
||||
}
|
||||
|
||||
esp_err_t wasm_runtime_set_manifest(uint8_t module_id, const char *module_name,
|
||||
uint32_t capabilities, uint32_t max_frame_us)
|
||||
{
|
||||
if (module_id >= WASM_MAX_MODULES) return ESP_ERR_INVALID_ARG;
|
||||
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
|
||||
wasm_slot_t *slot = &s_slots[module_id];
|
||||
if (slot->state == WASM_MODULE_EMPTY) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
if (module_name) {
|
||||
strncpy(slot->module_name, module_name, 31);
|
||||
slot->module_name[31] = '\0';
|
||||
}
|
||||
slot->capabilities = capabilities;
|
||||
slot->manifest_budget_us = max_frame_us;
|
||||
|
||||
ESP_LOGI(TAG, "WASM[%u] manifest applied: name=\"%s\" caps=0x%04lx budget=%lu us",
|
||||
module_id, slot->module_name,
|
||||
(unsigned long)capabilities, (unsigned long)max_frame_us);
|
||||
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
#else /* !CONFIG_WASM_ENABLE || !WASM3_AVAILABLE */
|
||||
|
||||
/* ======================================================================
|
||||
* No-op stubs when WASM3 is not available.
|
||||
* All functions return success or do nothing so the rest of the
|
||||
* firmware compiles and runs without the Tier 3 WASM layer.
|
||||
* ====================================================================== */
|
||||
|
||||
#include <string.h>
|
||||
#include "esp_log.h"
|
||||
|
||||
static const char *TAG = "wasm_rt";
|
||||
|
||||
esp_err_t wasm_runtime_init(void)
|
||||
{
|
||||
ESP_LOGW(TAG, "WASM Tier 3 disabled (WASM3 not available)");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t wasm_runtime_load(const uint8_t *binary, uint32_t size, uint8_t *out_id)
|
||||
{
|
||||
(void)binary; (void)size; (void)out_id;
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
esp_err_t wasm_runtime_start(uint8_t module_id)
|
||||
{
|
||||
(void)module_id;
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
esp_err_t wasm_runtime_stop(uint8_t module_id)
|
||||
{
|
||||
(void)module_id;
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
esp_err_t wasm_runtime_unload(uint8_t module_id)
|
||||
{
|
||||
(void)module_id;
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
void wasm_runtime_on_frame(const float *phases, const float *amplitudes,
|
||||
const float *variances, uint16_t n_sc,
|
||||
const edge_vitals_pkt_t *vitals)
|
||||
{
|
||||
(void)phases; (void)amplitudes; (void)variances; (void)n_sc; (void)vitals;
|
||||
}
|
||||
|
||||
void wasm_runtime_on_timer(void) { }
|
||||
|
||||
void wasm_runtime_get_info(wasm_module_info_t *info, uint8_t *count)
|
||||
{
|
||||
memset(info, 0, sizeof(wasm_module_info_t) * WASM_MAX_MODULES);
|
||||
*count = 0;
|
||||
}
|
||||
|
||||
esp_err_t wasm_runtime_set_manifest(uint8_t module_id, const char *module_name,
|
||||
uint32_t capabilities, uint32_t max_frame_us)
|
||||
{
|
||||
(void)module_id; (void)module_name; (void)capabilities; (void)max_frame_us;
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
#endif /* CONFIG_WASM_ENABLE && WASM3_AVAILABLE */
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* @file wasm_runtime.h
|
||||
* @brief ADR-040 Tier 3 — WASM programmable sensing runtime.
|
||||
*
|
||||
* Manages WASM3 interpreter instances for hot-loadable sensing algorithms.
|
||||
* WASM modules are compiled from Rust (wifi-densepose-wasm-edge crate) to
|
||||
* wasm32-unknown-unknown and executed on-device after Tier 2 DSP completes.
|
||||
*
|
||||
* Host API namespace "csi":
|
||||
* csi_get_phase(subcarrier) -> f32
|
||||
* csi_get_amplitude(subcarrier) -> f32
|
||||
* csi_get_variance(subcarrier) -> f32
|
||||
* csi_get_bpm_breathing() -> f32
|
||||
* csi_get_bpm_heartrate() -> f32
|
||||
* csi_get_presence() -> i32
|
||||
* csi_get_motion_energy() -> f32
|
||||
* csi_get_n_persons() -> i32
|
||||
* csi_get_timestamp() -> i32
|
||||
* csi_emit_event(event_type, value)
|
||||
* csi_log(ptr, len)
|
||||
* csi_get_phase_history(buf_ptr, max_len) -> i32
|
||||
*
|
||||
* Module lifecycle exports:
|
||||
* on_init() — called once when module is loaded
|
||||
* on_frame(n_sc) — called per CSI frame (~20 Hz)
|
||||
* on_timer() — called at configurable interval (default 1 s)
|
||||
*/
|
||||
|
||||
#ifndef WASM_RUNTIME_H
|
||||
#define WASM_RUNTIME_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include "esp_err.h"
|
||||
#include "edge_processing.h"
|
||||
|
||||
/* ---- Configuration ---- */
|
||||
#ifdef CONFIG_WASM_MAX_MODULES
|
||||
#define WASM_MAX_MODULES CONFIG_WASM_MAX_MODULES
|
||||
#else
|
||||
#define WASM_MAX_MODULES 4
|
||||
#endif
|
||||
|
||||
#define WASM_MAX_MODULE_SIZE (128 * 1024) /**< Max .wasm binary size (128 KB). */
|
||||
#define WASM_STACK_SIZE (8 * 1024) /**< WASM execution stack (8 KB). */
|
||||
#define WASM_OUTPUT_MAGIC 0xC5110004 /**< WASM output packet magic. */
|
||||
#define WASM_MAX_EVENTS 16 /**< Max events per output packet. */
|
||||
|
||||
/* ---- WASM Event (5 bytes: u8 type + f32 value) ---- */
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint8_t event_type;
|
||||
float value;
|
||||
} wasm_event_t;
|
||||
|
||||
/* ---- WASM Output Packet ---- */
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint32_t magic; /**< WASM_OUTPUT_MAGIC = 0xC5110004. */
|
||||
uint8_t node_id; /**< ESP32 node identifier. */
|
||||
uint8_t module_id; /**< Module slot index. */
|
||||
uint16_t event_count; /**< Number of events in this packet. */
|
||||
wasm_event_t events[WASM_MAX_EVENTS];
|
||||
} wasm_output_pkt_t;
|
||||
|
||||
/* ---- Module state ---- */
|
||||
typedef enum {
|
||||
WASM_MODULE_EMPTY = 0, /**< Slot is free. */
|
||||
WASM_MODULE_LOADED, /**< Binary loaded, not yet started. */
|
||||
WASM_MODULE_RUNNING, /**< Module is executing on each frame. */
|
||||
WASM_MODULE_STOPPED, /**< Module stopped but binary still in memory. */
|
||||
WASM_MODULE_ERROR, /**< Module encountered a fatal error. */
|
||||
} wasm_module_state_t;
|
||||
|
||||
/* ---- Per-frame budget (microseconds) ---- */
|
||||
#ifdef CONFIG_WASM_FRAME_BUDGET_US
|
||||
#define WASM_FRAME_BUDGET_US CONFIG_WASM_FRAME_BUDGET_US
|
||||
#else
|
||||
#define WASM_FRAME_BUDGET_US 10000 /**< Default 10 ms per on_frame call. */
|
||||
#endif
|
||||
|
||||
/* ---- Fixed arena size per module slot (PSRAM) ---- */
|
||||
#define WASM_ARENA_SIZE (160 * 1024) /**< 160 KB per slot, pre-allocated at boot. */
|
||||
|
||||
/* ---- Module info (for listing) ---- */
|
||||
typedef struct {
|
||||
uint8_t id; /**< Slot index. */
|
||||
wasm_module_state_t state; /**< Current state. */
|
||||
uint32_t binary_size;/**< .wasm binary size in bytes. */
|
||||
uint32_t frame_count;/**< Frames processed since start. */
|
||||
uint32_t event_count;/**< Total events emitted. */
|
||||
uint32_t error_count;/**< Runtime errors encountered. */
|
||||
uint32_t total_us; /**< Cumulative execution time (us). */
|
||||
uint32_t max_us; /**< Worst-case single frame (us). */
|
||||
uint32_t budget_faults; /**< Times frame budget was exceeded. */
|
||||
/* RVF manifest metadata (zeroed if loaded as raw WASM). */
|
||||
char module_name[32]; /**< From RVF manifest. */
|
||||
uint32_t capabilities; /**< RVF_CAP_* bitmask. */
|
||||
uint32_t manifest_budget_us; /**< Budget from manifest (0=default). */
|
||||
} wasm_module_info_t;
|
||||
|
||||
/**
|
||||
* Initialize the WASM runtime.
|
||||
* Allocates WASM3 environment and module slots in PSRAM.
|
||||
*
|
||||
* @return ESP_OK on success.
|
||||
*/
|
||||
esp_err_t wasm_runtime_init(void);
|
||||
|
||||
/**
|
||||
* Load a WASM binary into the next available slot.
|
||||
*
|
||||
* @param wasm_data Pointer to .wasm binary data.
|
||||
* @param wasm_len Length of the binary in bytes (max WASM_MAX_MODULE_SIZE).
|
||||
* @param module_id Output: assigned slot index.
|
||||
* @return ESP_OK on success.
|
||||
*/
|
||||
esp_err_t wasm_runtime_load(const uint8_t *wasm_data, uint32_t wasm_len,
|
||||
uint8_t *module_id);
|
||||
|
||||
/**
|
||||
* Start a loaded module (calls on_init export).
|
||||
*
|
||||
* @param module_id Slot index from wasm_runtime_load().
|
||||
* @return ESP_OK on success.
|
||||
*/
|
||||
esp_err_t wasm_runtime_start(uint8_t module_id);
|
||||
|
||||
/**
|
||||
* Stop a running module.
|
||||
*
|
||||
* @param module_id Slot index.
|
||||
* @return ESP_OK on success.
|
||||
*/
|
||||
esp_err_t wasm_runtime_stop(uint8_t module_id);
|
||||
|
||||
/**
|
||||
* Unload a module and free its memory.
|
||||
*
|
||||
* @param module_id Slot index.
|
||||
* @return ESP_OK on success.
|
||||
*/
|
||||
esp_err_t wasm_runtime_unload(uint8_t module_id);
|
||||
|
||||
/**
|
||||
* Call on_frame(n_subcarriers) on all running modules.
|
||||
* Called from the DSP task (Core 1) after Tier 2 processing.
|
||||
*
|
||||
* @param phases Current phase array (read by csi_get_phase).
|
||||
* @param amplitudes Current amplitude array (read by csi_get_amplitude).
|
||||
* @param variances Welford variance array (read by csi_get_variance).
|
||||
* @param n_sc Number of subcarriers.
|
||||
* @param vitals Current Tier 2 vitals (read by csi_get_bpm_* etc).
|
||||
*/
|
||||
void wasm_runtime_on_frame(const float *phases, const float *amplitudes,
|
||||
const float *variances, uint16_t n_sc,
|
||||
const edge_vitals_pkt_t *vitals);
|
||||
|
||||
/**
|
||||
* Call on_timer() on all running modules.
|
||||
* Called from the main loop at the configured timer interval.
|
||||
*/
|
||||
void wasm_runtime_on_timer(void);
|
||||
|
||||
/**
|
||||
* Get info for all module slots.
|
||||
*
|
||||
* @param info Output array (must be WASM_MAX_MODULES elements).
|
||||
* @param count Output: number of populated slots.
|
||||
*/
|
||||
void wasm_runtime_get_info(wasm_module_info_t *info, uint8_t *count);
|
||||
|
||||
/**
|
||||
* Apply RVF manifest metadata to a loaded module slot.
|
||||
*
|
||||
* Stores the module name, capabilities, and overrides the per-slot
|
||||
* frame budget with the manifest's max_frame_us (if nonzero).
|
||||
* Call after wasm_runtime_load(), before wasm_runtime_start().
|
||||
*
|
||||
* @param module_id Slot index from wasm_runtime_load().
|
||||
* @param module_name Null-terminated name (max 31 chars).
|
||||
* @param capabilities RVF_CAP_* bitmask.
|
||||
* @param max_frame_us Per-frame budget override (0 = use global default).
|
||||
* @return ESP_OK on success.
|
||||
*/
|
||||
esp_err_t wasm_runtime_set_manifest(uint8_t module_id, const char *module_name,
|
||||
uint32_t capabilities, uint32_t max_frame_us);
|
||||
|
||||
#endif /* WASM_RUNTIME_H */
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* @file wasm_upload.c
|
||||
* @brief ADR-040 — HTTP endpoints for WASM module upload and management.
|
||||
*
|
||||
* Registers REST endpoints on the existing OTA HTTP server (port 8032):
|
||||
* POST /wasm/upload — Upload RVF or raw .wasm (max 128 KB + RVF overhead)
|
||||
* GET /wasm/list — List loaded modules with state, manifest, counters
|
||||
* 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 memory
|
||||
*
|
||||
* Upload accepts two formats:
|
||||
* 1. RVF container (preferred): header + manifest + WASM + signature
|
||||
* 2. Raw .wasm binary (only when wasm_verify=0, for lab/dev use)
|
||||
*
|
||||
* Detection is by magic bytes: "RVF\x01" vs "\0asm".
|
||||
*/
|
||||
|
||||
#include "sdkconfig.h"
|
||||
#include "wasm_upload.h"
|
||||
|
||||
#if defined(CONFIG_WASM_ENABLE)
|
||||
|
||||
#include "wasm_runtime.h"
|
||||
#include "rvf_parser.h"
|
||||
#include "nvs_config.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include "esp_log.h"
|
||||
#include "esp_heap_caps.h"
|
||||
|
||||
static const char *TAG = "wasm_upload";
|
||||
|
||||
/* Max upload size: RVF overhead + max WASM binary. */
|
||||
#define MAX_UPLOAD_SIZE (RVF_HEADER_SIZE + RVF_MANIFEST_SIZE + \
|
||||
WASM_MAX_MODULE_SIZE + RVF_SIGNATURE_LEN + 4096)
|
||||
|
||||
/* ======================================================================
|
||||
* Receive full request body into PSRAM buffer
|
||||
* ====================================================================== */
|
||||
|
||||
static uint8_t *receive_body(httpd_req_t *req, int *out_len)
|
||||
{
|
||||
if (req->content_len <= 0) {
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Empty body");
|
||||
return NULL;
|
||||
}
|
||||
if (req->content_len > MAX_UPLOAD_SIZE) {
|
||||
char msg[80];
|
||||
snprintf(msg, sizeof(msg), "Upload too large (%d > %d)",
|
||||
req->content_len, MAX_UPLOAD_SIZE);
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, msg);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
uint8_t *buf = heap_caps_malloc(req->content_len, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
if (buf == NULL) buf = malloc(req->content_len);
|
||||
if (buf == NULL) {
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int total = 0;
|
||||
while (total < req->content_len) {
|
||||
int received = httpd_req_recv(req, (char *)(buf + total),
|
||||
req->content_len - total);
|
||||
if (received <= 0) {
|
||||
if (received == HTTPD_SOCK_ERR_TIMEOUT) continue;
|
||||
free(buf);
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Receive error");
|
||||
return NULL;
|
||||
}
|
||||
total += received;
|
||||
}
|
||||
|
||||
*out_len = total;
|
||||
return buf;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* POST /wasm/upload — Upload RVF or raw .wasm
|
||||
* ====================================================================== */
|
||||
|
||||
static esp_err_t wasm_upload_handler(httpd_req_t *req)
|
||||
{
|
||||
int total = 0;
|
||||
uint8_t *buf = receive_body(req, &total);
|
||||
if (buf == NULL) return ESP_FAIL;
|
||||
|
||||
ESP_LOGI(TAG, "Received upload: %d bytes", total);
|
||||
|
||||
uint8_t module_id = 0;
|
||||
esp_err_t err;
|
||||
const char *format = "raw";
|
||||
|
||||
if (rvf_is_rvf(buf, (uint32_t)total)) {
|
||||
/* ── RVF path ── */
|
||||
format = "rvf";
|
||||
rvf_parsed_t parsed;
|
||||
err = rvf_parse(buf, (uint32_t)total, &parsed);
|
||||
if (err != ESP_OK) {
|
||||
free(buf);
|
||||
char msg[80];
|
||||
snprintf(msg, sizeof(msg), "RVF parse failed: %s", esp_err_to_name(err));
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, msg);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
/* Verify signature if wasm_verify is enabled. */
|
||||
#ifdef CONFIG_WASM_VERIFY_SIGNATURE
|
||||
{
|
||||
/* Load pubkey from NVS config (set via provision.py --wasm-pubkey). */
|
||||
extern nvs_config_t g_nvs_config;
|
||||
if (!g_nvs_config.wasm_pubkey_valid) {
|
||||
free(buf);
|
||||
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN,
|
||||
"wasm_verify enabled but no pubkey in NVS. "
|
||||
"Provision with: provision.py --wasm-pubkey <hex>");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
if (parsed.signature == NULL) {
|
||||
free(buf);
|
||||
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN,
|
||||
"RVF has no signature (wasm_verify is enabled)");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
err = rvf_verify_signature(&parsed, buf, g_nvs_config.wasm_pubkey);
|
||||
if (err != ESP_OK) {
|
||||
free(buf);
|
||||
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN,
|
||||
"Signature verification failed");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/* Load WASM payload into runtime. */
|
||||
err = wasm_runtime_load(parsed.wasm_data, parsed.wasm_len, &module_id);
|
||||
if (err != ESP_OK) {
|
||||
free(buf);
|
||||
char msg[80];
|
||||
snprintf(msg, sizeof(msg), "WASM load failed: %s", esp_err_to_name(err));
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, msg);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
/* Apply manifest to the slot. */
|
||||
wasm_runtime_set_manifest(module_id,
|
||||
parsed.manifest->module_name,
|
||||
parsed.manifest->capabilities,
|
||||
parsed.manifest->max_frame_us);
|
||||
|
||||
/* Auto-start. */
|
||||
err = wasm_runtime_start(module_id);
|
||||
|
||||
char response[256];
|
||||
snprintf(response, sizeof(response),
|
||||
"{\"status\":\"ok\",\"format\":\"rvf\","
|
||||
"\"module_id\":%u,\"name\":\"%s\","
|
||||
"\"wasm_size\":%lu,\"caps\":\"0x%04lx\","
|
||||
"\"budget_us\":%lu,\"started\":%s}",
|
||||
module_id, parsed.manifest->module_name,
|
||||
(unsigned long)parsed.wasm_len,
|
||||
(unsigned long)parsed.manifest->capabilities,
|
||||
(unsigned long)parsed.manifest->max_frame_us,
|
||||
(err == ESP_OK) ? "true" : "false");
|
||||
|
||||
free(buf);
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, response, strlen(response));
|
||||
return ESP_OK;
|
||||
|
||||
} else if (rvf_is_raw_wasm(buf, (uint32_t)total)) {
|
||||
/* ── Raw WASM path (dev/lab only) ── */
|
||||
#ifdef CONFIG_WASM_VERIFY_SIGNATURE
|
||||
free(buf);
|
||||
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN,
|
||||
"Raw WASM upload rejected (wasm_verify enabled). "
|
||||
"Use RVF container with signature.");
|
||||
return ESP_FAIL;
|
||||
#else
|
||||
format = "raw";
|
||||
err = wasm_runtime_load(buf, (uint32_t)total, &module_id);
|
||||
free(buf);
|
||||
|
||||
if (err != ESP_OK) {
|
||||
char msg[80];
|
||||
snprintf(msg, sizeof(msg), "Load failed: %s", esp_err_to_name(err));
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, msg);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
err = wasm_runtime_start(module_id);
|
||||
|
||||
char response[128];
|
||||
snprintf(response, sizeof(response),
|
||||
"{\"status\":\"ok\",\"format\":\"raw\","
|
||||
"\"module_id\":%u,\"size\":%d,\"started\":%s}",
|
||||
module_id, total, (err == ESP_OK) ? "true" : "false");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, response, strlen(response));
|
||||
return ESP_OK;
|
||||
#endif
|
||||
} else {
|
||||
free(buf);
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST,
|
||||
"Unrecognized format (expected RVF or raw WASM)");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
(void)format;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* GET /wasm/list — List module slots
|
||||
* ====================================================================== */
|
||||
|
||||
static const char *state_name(wasm_module_state_t state)
|
||||
{
|
||||
switch (state) {
|
||||
case WASM_MODULE_EMPTY: return "empty";
|
||||
case WASM_MODULE_LOADED: return "loaded";
|
||||
case WASM_MODULE_RUNNING: return "running";
|
||||
case WASM_MODULE_STOPPED: return "stopped";
|
||||
case WASM_MODULE_ERROR: return "error";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
static esp_err_t wasm_list_handler(httpd_req_t *req)
|
||||
{
|
||||
wasm_module_info_t info[WASM_MAX_MODULES];
|
||||
uint8_t count = 0;
|
||||
wasm_runtime_get_info(info, &count);
|
||||
|
||||
/* Build JSON array (larger buffer for manifest fields). */
|
||||
char response[2048];
|
||||
int pos = 0;
|
||||
pos += snprintf(response + pos, sizeof(response) - pos,
|
||||
"{\"modules\":[");
|
||||
|
||||
for (uint8_t i = 0; i < WASM_MAX_MODULES; i++) {
|
||||
if (i > 0) pos += snprintf(response + pos, sizeof(response) - pos, ",");
|
||||
uint32_t mean_us = (info[i].frame_count > 0)
|
||||
? (info[i].total_us / info[i].frame_count) : 0;
|
||||
const char *name = info[i].module_name[0] ? info[i].module_name : "";
|
||||
pos += snprintf(response + pos, sizeof(response) - pos,
|
||||
"{\"id\":%u,\"state\":\"%s\",\"name\":\"%s\","
|
||||
"\"binary_size\":%lu,\"caps\":\"0x%04lx\","
|
||||
"\"frame_count\":%lu,\"event_count\":%lu,\"error_count\":%lu,"
|
||||
"\"mean_us\":%lu,\"max_us\":%lu,\"budget_us\":%lu,"
|
||||
"\"budget_faults\":%lu}",
|
||||
info[i].id, state_name(info[i].state), name,
|
||||
(unsigned long)info[i].binary_size,
|
||||
(unsigned long)info[i].capabilities,
|
||||
(unsigned long)info[i].frame_count,
|
||||
(unsigned long)info[i].event_count,
|
||||
(unsigned long)info[i].error_count,
|
||||
(unsigned long)mean_us,
|
||||
(unsigned long)info[i].max_us,
|
||||
(unsigned long)info[i].manifest_budget_us,
|
||||
(unsigned long)info[i].budget_faults);
|
||||
}
|
||||
|
||||
pos += snprintf(response + pos, sizeof(response) - pos,
|
||||
"],\"loaded\":%u,\"max\":%d}", count, WASM_MAX_MODULES);
|
||||
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, response, pos);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* POST /wasm/start — Start module by ID (parsed from query string)
|
||||
* ====================================================================== */
|
||||
|
||||
static int parse_module_id_from_uri(const char *uri, const char *prefix)
|
||||
{
|
||||
const char *id_str = uri + strlen(prefix);
|
||||
if (*id_str == '\0') return -1;
|
||||
int id = atoi(id_str);
|
||||
if (id < 0 || id >= WASM_MAX_MODULES) return -1;
|
||||
return id;
|
||||
}
|
||||
|
||||
static esp_err_t wasm_start_handler(httpd_req_t *req)
|
||||
{
|
||||
int id = parse_module_id_from_uri(req->uri, "/wasm/start/");
|
||||
if (id < 0) {
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid module ID");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
esp_err_t err = wasm_runtime_start((uint8_t)id);
|
||||
if (err != ESP_OK) {
|
||||
char msg[64];
|
||||
snprintf(msg, sizeof(msg), "Start failed: %s", esp_err_to_name(err));
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, msg);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
const char *resp = "{\"status\":\"ok\",\"action\":\"started\"}";
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, resp, strlen(resp));
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* POST /wasm/stop — Stop module by ID
|
||||
* ====================================================================== */
|
||||
|
||||
static esp_err_t wasm_stop_handler(httpd_req_t *req)
|
||||
{
|
||||
int id = parse_module_id_from_uri(req->uri, "/wasm/stop/");
|
||||
if (id < 0) {
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid module ID");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
esp_err_t err = wasm_runtime_stop((uint8_t)id);
|
||||
if (err != ESP_OK) {
|
||||
char msg[64];
|
||||
snprintf(msg, sizeof(msg), "Stop failed: %s", esp_err_to_name(err));
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, msg);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
const char *resp = "{\"status\":\"ok\",\"action\":\"stopped\"}";
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, resp, strlen(resp));
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* DELETE /wasm/:id — Unload module
|
||||
* ====================================================================== */
|
||||
|
||||
static esp_err_t wasm_delete_handler(httpd_req_t *req)
|
||||
{
|
||||
int id = parse_module_id_from_uri(req->uri, "/wasm/");
|
||||
if (id < 0) {
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid module ID");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
esp_err_t err = wasm_runtime_unload((uint8_t)id);
|
||||
if (err != ESP_OK) {
|
||||
char msg[64];
|
||||
snprintf(msg, sizeof(msg), "Unload failed: %s", esp_err_to_name(err));
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, msg);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
const char *resp = "{\"status\":\"ok\",\"action\":\"unloaded\"}";
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, resp, strlen(resp));
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Register all endpoints
|
||||
* ====================================================================== */
|
||||
|
||||
esp_err_t wasm_upload_register(httpd_handle_t server)
|
||||
{
|
||||
if (server == NULL) return ESP_ERR_INVALID_ARG;
|
||||
|
||||
httpd_uri_t upload_uri = {
|
||||
.uri = "/wasm/upload",
|
||||
.method = HTTP_POST,
|
||||
.handler = wasm_upload_handler,
|
||||
.user_ctx = NULL,
|
||||
};
|
||||
httpd_register_uri_handler(server, &upload_uri);
|
||||
|
||||
httpd_uri_t list_uri = {
|
||||
.uri = "/wasm/list",
|
||||
.method = HTTP_GET,
|
||||
.handler = wasm_list_handler,
|
||||
.user_ctx = NULL,
|
||||
};
|
||||
httpd_register_uri_handler(server, &list_uri);
|
||||
|
||||
/* Wildcard URIs for start/stop/delete with module ID. */
|
||||
httpd_uri_t start_uri = {
|
||||
.uri = "/wasm/start/*",
|
||||
.method = HTTP_POST,
|
||||
.handler = wasm_start_handler,
|
||||
.user_ctx = NULL,
|
||||
};
|
||||
httpd_register_uri_handler(server, &start_uri);
|
||||
|
||||
httpd_uri_t stop_uri = {
|
||||
.uri = "/wasm/stop/*",
|
||||
.method = HTTP_POST,
|
||||
.handler = wasm_stop_handler,
|
||||
.user_ctx = NULL,
|
||||
};
|
||||
httpd_register_uri_handler(server, &stop_uri);
|
||||
|
||||
httpd_uri_t delete_uri = {
|
||||
.uri = "/wasm/*",
|
||||
.method = HTTP_DELETE,
|
||||
.handler = wasm_delete_handler,
|
||||
.user_ctx = NULL,
|
||||
};
|
||||
httpd_register_uri_handler(server, &delete_uri);
|
||||
|
||||
ESP_LOGI(TAG, "WASM upload endpoints registered:");
|
||||
ESP_LOGI(TAG, " POST /wasm/upload — upload .wasm binary");
|
||||
ESP_LOGI(TAG, " GET /wasm/list — list modules");
|
||||
ESP_LOGI(TAG, " POST /wasm/start/:id — start module");
|
||||
ESP_LOGI(TAG, " POST /wasm/stop/:id — stop module");
|
||||
ESP_LOGI(TAG, " DELETE /wasm/:id — unload module");
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
#else /* !CONFIG_WASM_ENABLE */
|
||||
|
||||
#include "esp_log.h"
|
||||
|
||||
esp_err_t wasm_upload_register(httpd_handle_t server)
|
||||
{
|
||||
(void)server;
|
||||
ESP_LOGW("wasm_upload", "WASM upload disabled (CONFIG_WASM_ENABLE not set)");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
#endif /* CONFIG_WASM_ENABLE */
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @file wasm_upload.h
|
||||
* @brief ADR-040 — HTTP endpoints for WASM module upload and management.
|
||||
*
|
||||
* Registers endpoints on the existing OTA HTTP server (port 8032):
|
||||
* POST /wasm/upload — Upload a .wasm binary (max 128 KB)
|
||||
* GET /wasm/list — List loaded modules with status
|
||||
* POST /wasm/start/:id — Start a loaded module
|
||||
* POST /wasm/stop/:id — Stop a running module
|
||||
* DELETE /wasm/:id — Unload a module
|
||||
*/
|
||||
|
||||
#ifndef WASM_UPLOAD_H
|
||||
#define WASM_UPLOAD_H
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "esp_http_server.h"
|
||||
|
||||
/**
|
||||
* Register WASM management HTTP endpoints on the given server.
|
||||
*
|
||||
* @param server HTTP server handle (from OTA init).
|
||||
* @return ESP_OK on success.
|
||||
*/
|
||||
esp_err_t wasm_upload_register(httpd_handle_t server);
|
||||
|
||||
#endif /* WASM_UPLOAD_H */
|
||||
Reference in New Issue
Block a user