From 9648a47fdcf926bb8f7231595ed7586b4e85ec6b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 03:14:04 +0000 Subject: [PATCH] ADR-081: adaptive CSI mesh firmware kernel + scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a 5-layer firmware kernel that reframes the existing ESP32 modules as components of a chipset-agnostic architecture and authorizes adaptive control + a compact feature-state stream as the default upstream. Layers: L1 Radio Abstraction Layer — rv_radio_ops_t vtable + ESP32 binding L2 Adaptive Controller — fast/medium/slow loops (200ms/1s/30s) L3 Mesh Sensing Plane — anchor/observer/relay/coordinator (spec) L4 On-device Feature Extr. — rv_feature_state_t (magic 0xC5110006) L5 Rust handoff — feature_state default; debug raw gated Files: docs/adr/ADR-081-adaptive-csi-mesh-firmware-kernel.md (new) firmware/esp32-csi-node/main/rv_radio_ops.h (new) firmware/esp32-csi-node/main/rv_radio_ops_esp32.c (new) firmware/esp32-csi-node/main/rv_feature_state.{h,c} (new) firmware/esp32-csi-node/main/adaptive_controller.{h,c} (new) firmware/esp32-csi-node/main/main.c (wire L1+L2) firmware/esp32-csi-node/main/CMakeLists.txt (add 4 sources) firmware/esp32-csi-node/main/Kconfig.projbuild (controller knobs) CHANGELOG.md (Unreleased) Default policy is conservative: enable_channel_switch and enable_role_change are off, so behavior matches today's firmware unless an operator opts in via menuconfig. The pure adaptive_controller_decide() is exposed for offline unit tests. Reuses (does not rewrite): csi_collector, edge_processing (ADR-039), swarm_bridge (ADR-066), secure_tdm (ADR-032), wasm_runtime (ADR-040). --- CHANGELOG.md | 34 ++ ...R-081-adaptive-csi-mesh-firmware-kernel.md | 388 ++++++++++++++++++ firmware/esp32-csi-node/main/CMakeLists.txt | 4 + .../esp32-csi-node/main/Kconfig.projbuild | 83 ++++ .../esp32-csi-node/main/adaptive_controller.c | 352 ++++++++++++++++ .../esp32-csi-node/main/adaptive_controller.h | 125 ++++++ firmware/esp32-csi-node/main/main.c | 27 +- .../esp32-csi-node/main/rv_feature_state.c | 44 ++ .../esp32-csi-node/main/rv_feature_state.h | 110 +++++ firmware/esp32-csi-node/main/rv_radio_ops.h | 135 ++++++ .../esp32-csi-node/main/rv_radio_ops_esp32.c | 177 ++++++++ 11 files changed, 1477 insertions(+), 2 deletions(-) create mode 100644 docs/adr/ADR-081-adaptive-csi-mesh-firmware-kernel.md create mode 100644 firmware/esp32-csi-node/main/adaptive_controller.c create mode 100644 firmware/esp32-csi-node/main/adaptive_controller.h create mode 100644 firmware/esp32-csi-node/main/rv_feature_state.c create mode 100644 firmware/esp32-csi-node/main/rv_feature_state.h create mode 100644 firmware/esp32-csi-node/main/rv_radio_ops.h create mode 100644 firmware/esp32-csi-node/main/rv_radio_ops_esp32.c diff --git a/CHANGELOG.md b/CHANGELOG.md index ab600360..cc8b3375 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **ADR-081: Adaptive CSI Mesh Firmware Kernel** — New 5-layer architecture + (Radio Abstraction Layer / Adaptive Controller / Mesh Sensing Plane / + On-device Feature Extraction / Rust handoff) that reframes the existing + ESP32 firmware modules as components of a chipset-agnostic kernel. ADR + in `docs/adr/ADR-081-adaptive-csi-mesh-firmware-kernel.md`. Goal: swap + one radio family for another without changing the Rust signal / + ruvector / train / mat crates. +- **Firmware: radio abstraction vtable (`rv_radio_ops_t`)** — New + `firmware/esp32-csi-node/main/rv_radio_ops.{h}` defines the + chipset-agnostic ops (init, set_channel, set_mode, set_csi_enabled, + set_capture_profile, get_health), profile enum + (`RV_PROFILE_PASSIVE_LOW_RATE` / `ACTIVE_PROBE` / `RESP_HIGH_SENS` / + `FAST_MOTION` / `CALIBRATION`), and health snapshot struct. + `rv_radio_ops_esp32.c` provides the ESP32 binding wrapping + `csi_collector` + `esp_wifi_*`. A second binding (mock or alternate + chipset) is the portability acceptance test for ADR-081. +- **Firmware: `rv_feature_state_t` packet (magic `0xC5110006`)** — New + 80-byte compact per-node sensing state in + `firmware/esp32-csi-node/main/rv_feature_state.h`: motion, presence, + respiration BPM/conf, heartbeat BPM/conf, anomaly score, env-shift + score, node coherence, quality flags, IEEE CRC32. Designed to replace + raw ADR-018 CSI as the default upstream stream (~99% bandwidth + reduction vs. raw at 5 Hz). +- **Firmware: adaptive controller** — New + `firmware/esp32-csi-node/main/adaptive_controller.{c,h}` implements + the three-loop closed-loop control specified by ADR-081: fast + (~200 ms) for cadence and active probing, medium (~1 s) for channel + selection and role transitions, slow (~30 s) for baseline + recalibration. Pure `adaptive_controller_decide()` policy function is + exposed in the header for offline unit testing. Default policy is + conservative (`enable_channel_switch` and `enable_role_change` off); + Kconfig surface added under "Adaptive Controller (ADR-081)". + ### Fixed - **`provision.py` esptool v5 compat** (#391) — Stale `write_flash` (underscore) syntax in the dry-run manual-flash hint now uses `write-flash` (hyphenated) for esptool >= 5.x. The primary flash command was already correct. - **`provision.py` silent NVS wipe** (#391) — The script replaces the entire `csi_cfg` NVS namespace on every run, so partial invocations were silently erasing WiFi credentials and causing `Retrying WiFi connection (10/10)` in the field. Now refuses to run without `--ssid`, `--password`, and `--target-ip` unless `--force-partial` is passed. `--force-partial` prints a warning listing which keys will be wiped. diff --git a/docs/adr/ADR-081-adaptive-csi-mesh-firmware-kernel.md b/docs/adr/ADR-081-adaptive-csi-mesh-firmware-kernel.md new file mode 100644 index 00000000..4b87cad6 --- /dev/null +++ b/docs/adr/ADR-081-adaptive-csi-mesh-firmware-kernel.md @@ -0,0 +1,388 @@ +# ADR-081: Adaptive CSI Mesh Firmware Kernel + +| Field | Value | +|-------------|-----------------------------------------------------------------------| +| **Status** | Proposed | +| **Date** | 2026-04-19 | +| **Authors** | ruv | +| **Depends** | ADR-018, ADR-028, ADR-029, ADR-031, ADR-032, ADR-039, ADR-066, ADR-073 | + +## Context + +RuView's firmware grew bottom-up. ADR-018 defined a binary CSI frame, ADR-029 +added channel hopping and TDM, ADR-039 added a tiered edge-intelligence +pipeline, ADR-040 added programmable WASM modules, ADR-060 added per-node +channel and MAC overrides, ADR-066 added a swarm bridge to a coordinator, and +ADR-073 added multifrequency mesh scanning. Each one was a sound local +decision. Together they produced a firmware that works on ESP32-S3 but is +**implicitly coupled** to that chipset through `csi_collector.c` calling +`esp_wifi_*` directly and through hard-coded assumptions about the WiFi driver +callback shape. + +This is a problem for three reasons: + +1. **Portability.** Espressif exposes CSI through an official driver API. On + locked Broadcom and Cypress chips, projects like Nexmon achieve the same + thing by patching the firmware blob — but only for specific chip and + firmware build combinations. Future RuView nodes will likely span both + models plus eventually a custom silicon path. Today, none of the modules + above can be reused unchanged on any non-ESP32 chip. + +2. **Adaptivity.** The current firmware reacts to configuration, not to + conditions. Channel hop intervals, edge tier, vitals cadence, top-K + subcarriers, fall threshold, and power duty are all read from NVS at boot + and never revisited. There is no closed-loop control: if a channel becomes + congested, if motion spikes, if inter-node coherence drops, or if the + environment is stable enough to coast at lower cadence, nothing changes + onboard. The adaptive classifier in `wifi-densepose-sensing-server` does + adapt — but only on the host side, after the data has already traversed the + network at fixed rate. + +3. **Mesh as an afterthought.** ADR-029 wired in a `TdmCoordinator` and ADR-066 + added a swarm bridge to a Cognitum Seed, but there is no first-class node + role enumeration (anchor / observer / fusion-relay / coordinator), no + role-assignment protocol, no `FEATURE_DELTA` message type, no + coordinator-driven channel plan, and no automatic role re-election when a + node drops. Multi-node deployments today are stitched together by manual + per-node NVS provisioning. + +The hard truth is that the firmware hack — getting raw CSI off a radio — is +not the moat. The moat is **adaptive control, multi-node fusion, compact +state encoding, persistent memory, and contrastive reasoning on top of the +radio layer**. The current architecture does not name those layers, so they +get reinvented inline by every new ADR. + +## Decision + +Adopt a **5-layer adaptive RF sensing kernel** as the canonical RuView +firmware architecture, and refactor the existing modules to fit underneath +it. The five layers, top to bottom: + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Layer 5 — Rust handoff │ +│ Two streams only: feature_state (default) and debug_csi_frame (gated) │ +└─────────────────────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Layer 4 — On-device feature extraction │ +│ 100 ms motion, 1 s respiration, 5 s baseline windows │ +│ Emits compact rv_feature_state_t (magic 0xC5110006) │ +└─────────────────────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Layer 3 — Mesh sensing plane │ +│ Roles: Anchor / Observer / Fusion relay / Coordinator │ +│ Messages: TIME_SYNC, ROLE_ASSIGN, CHANNEL_PLAN, CALIBRATION_START, │ +│ FEATURE_DELTA, HEALTH, ANOMALY_ALERT │ +└─────────────────────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Layer 2 — Adaptive controller │ +│ Fast loop ~200 ms — packet rate, active probing │ +│ Medium loop ~1 s — channel selection, role changes │ +│ Slow loop ~30 s — baseline recalibration │ +└─────────────────────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Layer 1 — Radio Abstraction Layer (rv_radio_ops_t vtable) │ +│ ESP32 binding, future Nexmon binding, future custom silicon binding │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Layer 1 — Radio Abstraction Layer + +A single function-pointer vtable, `rv_radio_ops_t`, defined in +`firmware/esp32-csi-node/main/rv_radio_ops.h`: + +```c +typedef struct { + int (*init)(void); + int (*set_channel)(uint8_t ch, uint8_t bw); + int (*set_mode)(uint8_t mode); /* RV_RADIO_MODE_* */ + int (*set_csi_enabled)(bool en); + int (*set_capture_profile)(uint8_t profile_id); + int (*get_health)(rv_radio_health_t *out); +} rv_radio_ops_t; +``` + +Capture profiles, named not numbered: + +| Profile | Intent | +|--------------------------------|-------------------------------------------------------| +| `RV_PROFILE_PASSIVE_LOW_RATE` | Default idle: minimum cadence, presence only | +| `RV_PROFILE_ACTIVE_PROBE` | Inject NDP frames at high rate | +| `RV_PROFILE_RESP_HIGH_SENS` | Quietest channel, longest window, vitals-only | +| `RV_PROFILE_FAST_MOTION` | Short window, high cadence | +| `RV_PROFILE_CALIBRATION` | Synchronized burst across nodes | + +Two bindings ship in this ADR: + +- **ESP32 binding** (`rv_radio_ops_esp32.c`) wraps `csi_collector.c`, + `esp_wifi_set_channel()`, `esp_wifi_set_csi()`, and + `csi_inject_ndp_frame()`. +- **Mock binding** (`rv_radio_ops_mock.c`) wraps `mock_csi.c` so QEMU + scenarios can exercise the controller and mesh plane without a radio. + +A third binding (Nexmon-patched Broadcom) is reserved but not implemented +here. + +### Layer 2 — Adaptive controller + +`firmware/esp32-csi-node/main/adaptive_controller.{c,h}`. A single FreeRTOS +task with three cooperating timers: + +| Loop | Period | Inputs | Outputs | +|--------|---------|------------------------------------------------------------------------|------------------------------------------------------| +| Fast | ~200 ms | packet yield, retry/drop rate, motion score | cadence (vital_interval_ms), active vs passive probe | +| Medium | ~1 s | CSI variance, RSSI median, channel occupancy, inter-node agreement | channel selection (via radio ops), role transitions | +| Slow | ~30 s | drift profile (Stable/Linear/StepChange), respiration confidence | baseline recalibration, switch to delta-only mode | + +The controller publishes its decisions through the radio ops vtable +(`set_capture_profile`, `set_channel`) and through the mesh plane +(`CHANNEL_PLAN`, `ROLE_ASSIGN`). Default policy is conservative and matches +today's behavior; aggressive adaptation is opt-in via Kconfig. + +### Layer 3 — Mesh sensing plane + +Extends `swarm_bridge.c` with explicit node roles (Anchor / Observer / +Fusion relay / Coordinator) and a 7-message type protocol: + +| Message | Cadence | Sender(s) | Purpose | +|----------------------|--------------------|------------------|-----------------------------------------------| +| `TIME_SYNC` | 100 ms | Anchor | Reuse ADR-032 `SyncBeacon` (28 bytes, HMAC) | +| `ROLE_ASSIGN` | event-driven | Coordinator | Node ID → role mapping | +| `CHANNEL_PLAN` | event-driven | Coordinator | Per-node channel + dwell schedule | +| `CALIBRATION_START` | event-driven | Coordinator | Synchronized calibration burst | +| `FEATURE_DELTA` | 1–10 Hz | Observer / Relay | Compact feature delta (see Layer 4) | +| `HEALTH` | 1 Hz | All | `rv_node_status_t` (see below) | +| `ANOMALY_ALERT` | event-driven | Observer | Phase-physics violation, multi-link mismatch | + +Node status payload: + +```c +typedef struct __attribute__((packed)) { + uint8_t node_id[8]; + uint64_t local_time_us; + uint8_t role; + uint8_t current_channel; + uint8_t current_bw; + int8_t noise_floor_dbm; + uint16_t pkt_yield; + uint16_t sync_error_us; + uint16_t health_flags; +} rv_node_status_t; +``` + +Time-sync target is an engineering goal, not a guaranteed constant — it +depends on the clock quality of the chosen radio family. The first +acceptance test (Phase 2) measures it on real hardware. + +### Layer 4 — On-device feature extraction + +Defined in `firmware/esp32-csi-node/main/rv_feature_state.h`. Single +on-the-wire packet, 80 bytes, magic `0xC5110006` (next free after ADR-039's +0xC5110002, ADR-069's 0xC5110003, ADR-063's 0xC5110004, and ADR-039's +compressed 0xC5110005): + +```c +#define RV_FEATURE_STATE_MAGIC 0xC5110006u + +typedef struct __attribute__((packed)) { + uint32_t magic; /* RV_FEATURE_STATE_MAGIC */ + uint8_t node_id; + uint8_t mode; /* RV_PROFILE_* identifier */ + uint16_t seq; /* monotonic per-node sequence */ + uint64_t ts_us; /* node-local microseconds */ + float motion_score; + float presence_score; + float respiration_bpm; + float respiration_conf; + float heartbeat_bpm; + float heartbeat_conf; + float anomaly_score; + float env_shift_score; + float node_coherence; + uint16_t quality_flags; + uint16_t reserved; + uint32_t crc32; /* IEEE polynomial over bytes [0..end-4] */ +} rv_feature_state_t; + +_Static_assert(sizeof(rv_feature_state_t) == 80, + "rv_feature_state_t must be 80 bytes"); +``` + +Three windows feed it: 100 ms (motion), 1 s (respiration), 5 s (baseline / +env shift). Each `rv_feature_state_t` represents the most recent state of +all three; mode field tells the receiver which window dominates this +update. + +`rv_feature_state_t` does not replace ADR-039's `edge_vitals_pkt_t` +(0xC5110002) or ADR-063's `edge_fused_vitals_pkt_t` (0xC5110004). Those +remain the wire format for vitals-specific consumers. `rv_feature_state_t` +is the **default upstream payload** for the sensing pipeline; vitals +packets are now an alternate emission mode for backward compatibility. + +### Layer 5 — Rust handoff + +The Rust side sees only two streams from a node: + +1. **`feature_state` stream** — `rv_feature_state_t`, default-on, 1–10 Hz. +2. **`debug_csi_frame` stream** — ADR-018 raw frames (magic 0xC5110001), + default-off, opt-in via NVS or `CHANNEL_PLAN`. Used for calibration, + debugging, training-set capture. + +The Rust handoff is mirrored as a trait in +`crates/wifi-densepose-hardware/src/radio_ops.rs` so test harnesses (and +eventually the Rust-side controller for centralized coordinator nodes) can +swap radio backends without touching `wifi-densepose-signal`, +`wifi-densepose-ruvector`, `wifi-densepose-train`, or +`wifi-densepose-mat`. Rust-side mirror trait is **out of scope for the +firmware-only PR** that ships this ADR; tracked as Phase 4 follow-up. + +## State Machine + +``` +BOOT → SELF_TEST → RADIO_INIT → TIME_SYNC → CALIBRATION → SENSE_IDLE + ↓ ↑ + SENSE_ACTIVE + ↓ + ALERT + ↓ + DEGRADED +``` + +Transitions: + +- **CALIBRATION** on boot, on role change, on sustained inter-node + disagreement. +- **SENSE_ACTIVE** when motion or anomaly score crosses threshold. +- **DEGRADED** when packet yield, sync quality, or memory pressure drops + below threshold; falls back to ADR-039 Tier-0 raw passthrough as the + last-resort survivable mode. + +## Data budgets + +| Stream | Default rate | Notes | +|-------------------------|-----------------------------|----------------------------------------------| +| Raw capture (internal) | 50–200 pps per observer | Stays on-device unless debug stream enabled | +| `rv_feature_state_t` | 1–10 Hz per node | Default upstream | +| `ANOMALY_ALERT` | event-driven | Burst-bounded | +| Debug ADR-018 raw CSI | 0 (off by default) | Burst-only via `CHANNEL_PLAN` debug flag | + +ADR-039 measured raw CSI at ~5 KB/frame and ~100 KB/s per node. The default +upstream therefore drops by ~99% (80 B × 5 Hz = 400 B/s) while preserving +all action-relevant state. This is what makes a 50-node deployment feasible +on a single-AP backhaul. + +## Channel planning policy + +Codified rules — these are constraints on the controller, not just defaults: + +- Keep one anchor on a stable channel; observers distributed across the + least-congested channels. +- Rotate **one** observer at a time. Never change all nodes simultaneously. +- Pin `RV_PROFILE_RESP_HIGH_SENS` to the quietest stable channel for the + duration of a respiration window. +- Use a short active burst on a quiet channel for calibration, then return + to passive capture. + +This generalizes the per-deployment policy in ADR-073 ("node 1: ch 1/6/11, +node 2: ch 3/5/9") into a controller-driven plan that the coordinator can +publish via `CHANNEL_PLAN`. IEEE 802.11bf is the standards direction this +points toward. + +## Security & integrity + +- Every `FEATURE_DELTA` carries node id, monotonic seq, ts_us, and CRC32 + (IEEE polynomial), per the struct above. +- Every control message (`ROLE_ASSIGN`, `CHANNEL_PLAN`, `CALIBRATION_START`) + carries sender role, epoch, replay window index, and authorization class, + reusing the HMAC-SHA256 + 16-frame replay window from ADR-032 + (`secure_tdm.rs`). +- Optional Ed25519 signature at session/batch granularity for signed + `CHANNEL_PLAN` and `CALIBRATION_START` messages, reusing the + ADR-040/RVF Ed25519 path already shipping in firmware. + +## Reuse map (do not rewrite) + +| Concern | Existing component | +|-----------------------------|----------------------------------------------------------------------------------------------------------| +| ADR-018 binary frame | `firmware/esp32-csi-node/main/csi_collector.c` (magic `0xC5110001`) | +| ESP32 CSI driver glue | `firmware/esp32-csi-node/main/csi_collector.c:225-303` | +| Channel hopping | `csi_collector_set_hop_table()` and `csi_collector_start_hop_timer()` | +| NDP injection | `csi_inject_ndp_frame()` (placeholder, sufficient for L1 binding) | +| TDM scheduling | `crates/wifi-densepose-hardware/src/esp32/tdm.rs` | +| Secure beacons | `crates/wifi-densepose-hardware/src/esp32/secure_tdm.rs` (HMAC + replay) | +| Edge intelligence (Tier 1/2)| `firmware/esp32-csi-node/main/edge_processing.c` (magic `0xC5110002`/`0xC5110005`) | +| Fused vitals | ADR-063 `edge_fused_vitals_pkt_t` (magic `0xC5110004`) | +| Swarm bridge | `firmware/esp32-csi-node/main/swarm_bridge.c` | +| WASM Tier 3 modules | `firmware/esp32-csi-node/main/wasm_runtime.c` (ADR-040) | +| Multistatic fusion | `crates/wifi-densepose-ruvector/src/viewpoint/fusion.rs` | +| Adaptive classifier | `crates/wifi-densepose-sensing-server/src/adaptive_classifier.rs:61-75` | +| Feature primitives (Rust) | `crates/wifi-densepose-signal/src/{motion.rs,features.rs,ruvsense/coherence.rs}` | + +## New components this ADR authorizes + +| New file | Purpose | +|-------------------------------------------------------------------------------------------|--------------------------------------------------------| +| `firmware/esp32-csi-node/main/rv_radio_ops.h` | `rv_radio_ops_t` vtable + profile/mode/health enums | +| `firmware/esp32-csi-node/main/rv_radio_ops_esp32.c` | ESP32 binding wrapping `csi_collector` + `esp_wifi_*` | +| `firmware/esp32-csi-node/main/rv_feature_state.h` | `rv_feature_state_t` packet + `RV_FEATURE_STATE_MAGIC` | +| `firmware/esp32-csi-node/main/adaptive_controller.h` | Controller API + observation/decision structs | +| `firmware/esp32-csi-node/main/adaptive_controller.c` | 200 ms / 1 s / 30 s loops, FreeRTOS task | +| `crates/wifi-densepose-hardware/src/radio_ops.rs` *(Phase 4 follow-up)* | Rust mirror trait for backend swapping | + +## Roadmap + +| Phase | Scope | Status | +|-------|--------------------------------------------|--------------------------------------------------| +| 1 | Single supported-CSI node + features → Rust | Largely done via ADR-018, ADR-039 | +| 2 | 3-node Seed v2 mesh + time-sync + plan | Partially done (ADR-029, ADR-066, ADR-073) | +| 3 | Adaptive controller, delta reporting, DEGRADED | **This ADR** authorizes the firmware skeleton | +| 4 | Cross-chipset bindings (Nexmon, custom) | Reserved; gated by Phase 3 stability | + +## Acceptance criteria + +1. **Portability gate.** A second `rv_radio_ops_t` binding (mock or + alternate chipset) compiles and runs the controller + mesh plane code + unchanged. The signal/ruvector/train/mat crates compile against a Rust + mirror trait without modification. +2. **Mesh resilience benchmark.** A 3-node prototype maintains stable + `presence_score` and `motion_score` when one observer changes channel + or drops out for 5 seconds. +3. **Default upstream is compact.** Raw ADR-018 CSI is off by default; the + default upstream is `rv_feature_state_t` at 1–10 Hz. +4. **Integrity.** Every `FEATURE_DELTA` carries node id, seq, ts_us, CRC32. + Every control message carries epoch + replay-window + authorization + class, verified against ADR-032's existing HMAC machinery. + +## Consequences + +### Positive + +- The firmware hack is no longer the moat. The 5 layers are explicit and + separately testable. +- Default upstream bandwidth drops ~99% vs. raw ADR-018, making 50+ node + deployments practical. +- A documented vtable + Kconfig surface gates new features ("which layer + does this belong in?") instead of letting them accrete inline. +- Adaptive control of cadence, channel, and role becomes a first-class + firmware concern — the user-facing knob ("be smarter when busy, save + power when idle") finally has a home. + +### Negative + +- An abstraction tax on the single-chipset case: `rv_radio_ops_t` is a + vtable for a family currently of size 1. +- Adds ~5–8 KB SRAM for controller state and the new feature-state ring. +- Requires re-routing existing `swarm_bridge` traffic through the mesh + plane message types over time (incremental, not breaking). + +### Neutral + +- This ADR introduces no new dependencies, no new networking stacks, and + no new hardware requirements. +- ADR-039, ADR-063, ADR-066, ADR-069, ADR-073 are **not superseded**; they + are reframed as components of Layer 3 / Layer 4. + +## Related + +ADR-018, ADR-028, ADR-029, ADR-030, ADR-031, ADR-032, ADR-039, ADR-040, +ADR-060, ADR-063, ADR-066, ADR-069, ADR-073, ADR-078. diff --git a/firmware/esp32-csi-node/main/CMakeLists.txt b/firmware/esp32-csi-node/main/CMakeLists.txt index 5c88b01c..3b51dfd8 100644 --- a/firmware/esp32-csi-node/main/CMakeLists.txt +++ b/firmware/esp32-csi-node/main/CMakeLists.txt @@ -4,6 +4,10 @@ set(SRCS "wasm_runtime.c" "wasm_upload.c" "rvf_parser.c" "mmwave_sensor.c" "swarm_bridge.c" + # ADR-081 — adaptive CSI mesh firmware kernel + "rv_radio_ops_esp32.c" + "rv_feature_state.c" + "adaptive_controller.c" ) set(REQUIRES "") diff --git a/firmware/esp32-csi-node/main/Kconfig.projbuild b/firmware/esp32-csi-node/main/Kconfig.projbuild index 7f801efc..4e5895bb 100644 --- a/firmware/esp32-csi-node/main/Kconfig.projbuild +++ b/firmware/esp32-csi-node/main/Kconfig.projbuild @@ -87,6 +87,89 @@ menu "Edge Intelligence (ADR-039)" endmenu +menu "Adaptive Controller (ADR-081)" + + config ADAPTIVE_FAST_LOOP_MS + int "Fast loop period (ms)" + default 200 + range 50 2000 + help + Period of the fast control loop. The fast loop reads radio + health and edge-derived motion/presence/anomaly scores and + updates the active capture profile. Default 200 ms matches + the ADR-081 spec. + + config ADAPTIVE_MEDIUM_LOOP_MS + int "Medium loop period (ms)" + default 1000 + range 200 30000 + help + Period of the medium control loop. The medium loop is where + channel selection and role transitions happen (when + enable_channel_switch / enable_role_change are on). + + config ADAPTIVE_SLOW_LOOP_MS + int "Slow loop period (ms)" + default 30000 + range 1000 300000 + help + Period of the slow control loop. The slow loop publishes + HEALTH messages and may request CALIBRATION_START on + sustained drift. + + config ADAPTIVE_AGGRESSIVE + bool "Aggressive adaptation" + default n + help + When enabled, the controller reacts to motion / anomaly + sooner and uses a tighter cadence in SENSE_ACTIVE. Default + off matches today's conservative behavior. + + config ADAPTIVE_ENABLE_CHANNEL_SWITCH + bool "Allow controller to change WiFi channel" + default n + help + When disabled, the controller never calls set_channel() — + channel hopping (ADR-029) and channel override (ADR-060) + remain in charge. Enable only after Phase 3 follow-up + work has wired the channel-plan mesh message. + + config ADAPTIVE_ENABLE_ROLE_CHANGE + bool "Allow controller to change mesh role" + default n + help + When disabled, the controller never advertises a different + role to the swarm bridge. Enable after the mesh-plane + ROLE_ASSIGN protocol is in place. + + config ADAPTIVE_MOTION_THRESH_PERMIL + int "Motion threshold (per-mille)" + default 200 + range 1 1000 + help + Motion score above which the controller transitions to + SENSE_ACTIVE and selects RV_PROFILE_FAST_MOTION. Expressed + in per-mille (200 = 0.20). + + config ADAPTIVE_ANOMALY_THRESH_PERMIL + int "Anomaly threshold (per-mille)" + default 600 + range 1 1000 + help + Anomaly score above which the controller transitions to + ALERT. Per-mille (600 = 0.60). + + config ADAPTIVE_MIN_PKT_YIELD + int "Minimum packet yield before DEGRADED (pps)" + default 5 + range 0 100 + help + CSI callback rate (per second) below which the controller + falls back to DEGRADED mode and pins the radio to + RV_PROFILE_PASSIVE_LOW_RATE. 0 disables the degraded gate. + +endmenu + menu "AMOLED Display (ADR-045)" config DISPLAY_ENABLE diff --git a/firmware/esp32-csi-node/main/adaptive_controller.c b/firmware/esp32-csi-node/main/adaptive_controller.c new file mode 100644 index 00000000..40719c8d --- /dev/null +++ b/firmware/esp32-csi-node/main/adaptive_controller.c @@ -0,0 +1,352 @@ +/** + * @file adaptive_controller.c + * @brief ADR-081 Layer 2 — Adaptive sensing controller implementation. + * + * The decide() function is pure and unit-testable; the FreeRTOS plumbing + * around it (timers, observation snapshot) is the only ESP-IDF surface. + * + * Default policy is conservative: it will not change channels unless + * enable_channel_switch is true, and it will not change roles unless + * enable_role_change is true. With both off the controller still tracks + * state and feeds the mesh plane's HEALTH messages, so it is safe to + * enable in production before the mesh plane is fully in place. + */ + +#include "adaptive_controller.h" +#include "rv_radio_ops.h" +#include "edge_processing.h" + +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/timers.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "sdkconfig.h" + +static const char *TAG = "adaptive_ctrl"; + +/* ---- Module state ---- */ + +static bool s_inited = false; +static adapt_config_t s_cfg; +static adapt_state_t s_state = ADAPT_STATE_BOOT; +static adapt_observation_t s_last_obs; +static bool s_obs_valid = false; +static portMUX_TYPE s_obs_lock = portMUX_INITIALIZER_UNLOCKED; + +static TimerHandle_t s_fast_timer = NULL; +static TimerHandle_t s_medium_timer = NULL; +static TimerHandle_t s_slow_timer = NULL; + +/* ---- Defaults ---- */ + +#ifndef CONFIG_ADAPTIVE_FAST_LOOP_MS +#define CONFIG_ADAPTIVE_FAST_LOOP_MS 200 +#endif +#ifndef CONFIG_ADAPTIVE_MEDIUM_LOOP_MS +#define CONFIG_ADAPTIVE_MEDIUM_LOOP_MS 1000 +#endif +#ifndef CONFIG_ADAPTIVE_SLOW_LOOP_MS +#define CONFIG_ADAPTIVE_SLOW_LOOP_MS 30000 +#endif +#ifndef CONFIG_ADAPTIVE_MIN_PKT_YIELD +#define CONFIG_ADAPTIVE_MIN_PKT_YIELD 5 +#endif +/* Defaults expressed as integer permille so Kconfig can carry them. */ +#ifndef CONFIG_ADAPTIVE_MOTION_THRESH_PERMIL +#define CONFIG_ADAPTIVE_MOTION_THRESH_PERMIL 200 /* 0.20 */ +#endif +#ifndef CONFIG_ADAPTIVE_ANOMALY_THRESH_PERMIL +#define CONFIG_ADAPTIVE_ANOMALY_THRESH_PERMIL 600 /* 0.60 */ +#endif + +static void apply_defaults(adapt_config_t *cfg) +{ + cfg->fast_loop_ms = CONFIG_ADAPTIVE_FAST_LOOP_MS; + cfg->medium_loop_ms = CONFIG_ADAPTIVE_MEDIUM_LOOP_MS; + cfg->slow_loop_ms = CONFIG_ADAPTIVE_SLOW_LOOP_MS; +#ifdef CONFIG_ADAPTIVE_AGGRESSIVE + cfg->aggressive = true; +#else + cfg->aggressive = false; +#endif +#ifdef CONFIG_ADAPTIVE_ENABLE_CHANNEL_SWITCH + cfg->enable_channel_switch = true; +#else + cfg->enable_channel_switch = false; +#endif +#ifdef CONFIG_ADAPTIVE_ENABLE_ROLE_CHANGE + cfg->enable_role_change = true; +#else + cfg->enable_role_change = false; +#endif + cfg->motion_threshold = (float)CONFIG_ADAPTIVE_MOTION_THRESH_PERMIL / 1000.0f; + cfg->anomaly_threshold = (float)CONFIG_ADAPTIVE_ANOMALY_THRESH_PERMIL / 1000.0f; + cfg->min_pkt_yield = CONFIG_ADAPTIVE_MIN_PKT_YIELD; +} + +/* ---- Pure decision function (unit-testable) ---- */ + +void adaptive_controller_decide(const adapt_config_t *cfg, + adapt_state_t current, + const adapt_observation_t *obs, + adapt_decision_t *out) +{ + if (cfg == NULL || obs == NULL || out == NULL) { + return; + } + memset(out, 0, sizeof(*out)); + out->new_state = (uint8_t)current; + out->new_profile = RV_PROFILE_PASSIVE_LOW_RATE; + + /* Degraded gate: any of pkt yield collapse, severe coherence loss → DEGRADED. */ + if (obs->pkt_yield_per_sec < cfg->min_pkt_yield || + obs->node_coherence < 0.20f) { + if (current != ADAPT_STATE_DEGRADED) { + out->change_state = true; + out->new_state = ADAPT_STATE_DEGRADED; + } + out->change_profile = (current != ADAPT_STATE_DEGRADED); + out->new_profile = RV_PROFILE_PASSIVE_LOW_RATE; + out->suggested_vital_interval_ms = 2000; + return; + } + + /* Anomaly trumps motion. */ + if (obs->anomaly_score >= cfg->anomaly_threshold) { + if (current != ADAPT_STATE_ALERT) { + out->change_state = true; + out->new_state = ADAPT_STATE_ALERT; + } + out->change_profile = true; + out->new_profile = RV_PROFILE_FAST_MOTION; + out->suggested_vital_interval_ms = 100; + return; + } + + /* Motion → SENSE_ACTIVE with FAST_MOTION profile. */ + if (obs->motion_score >= cfg->motion_threshold) { + if (current != ADAPT_STATE_SENSE_ACTIVE) { + out->change_state = true; + out->new_state = ADAPT_STATE_SENSE_ACTIVE; + } + out->change_profile = true; + out->new_profile = RV_PROFILE_FAST_MOTION; + out->suggested_vital_interval_ms = cfg->aggressive ? 100 : 200; + return; + } + + /* Stable environment with valid presence → high-sensitivity respiration mode. */ + if (obs->presence_score >= 0.5f && obs->motion_score < 0.05f) { + if (current != ADAPT_STATE_SENSE_IDLE) { + out->change_state = true; + out->new_state = ADAPT_STATE_SENSE_IDLE; + } + out->change_profile = true; + out->new_profile = RV_PROFILE_RESP_HIGH_SENS; + out->suggested_vital_interval_ms = 1000; + return; + } + + /* Default: passive low rate. */ + if (current != ADAPT_STATE_SENSE_IDLE) { + out->change_state = true; + out->new_state = ADAPT_STATE_SENSE_IDLE; + } + out->change_profile = (current != ADAPT_STATE_SENSE_IDLE); + out->new_profile = RV_PROFILE_PASSIVE_LOW_RATE; + out->suggested_vital_interval_ms = cfg->aggressive ? 500 : 1000; +} + +/* ---- Observation collection ---- */ + +static void collect_observation(adapt_observation_t *out) +{ + memset(out, 0, sizeof(*out)); + + /* Radio health from the active binding. */ + const rv_radio_ops_t *ops = rv_radio_ops_get(); + if (ops != NULL && ops->get_health != NULL) { + rv_radio_health_t h; + if (ops->get_health(&h) == ESP_OK) { + out->pkt_yield_per_sec = h.pkt_yield_per_sec; + out->send_fail_count = h.send_fail_count; + out->rssi_median_dbm = h.rssi_median_dbm; + out->noise_floor_dbm = h.noise_floor_dbm; + } + } + + /* Edge-derived state. The ADR-039 vitals packet exposes presence_score + * and motion_energy directly; we treat motion_energy as a proxy for + * motion_score by clamping to [0,1]. anomaly_score and node_coherence + * are not yet emitted by edge_processing — placeholder until Layer 4 + * extraction lands. */ + edge_vitals_pkt_t vitals; + if (edge_get_vitals(&vitals)) { + out->presence_score = vitals.presence_score; + float m = vitals.motion_energy; + if (m < 0.0f) m = 0.0f; + if (m > 1.0f) m = 1.0f; + out->motion_score = m; + } + out->anomaly_score = 0.0f; + out->node_coherence = 1.0f; +} + +/* ---- Decision application ---- */ + +static void apply_decision(const adapt_decision_t *dec) +{ + const rv_radio_ops_t *ops = rv_radio_ops_get(); + + if (dec->change_state) { + ESP_LOGI(TAG, "state %u → %u", + (unsigned)s_state, (unsigned)dec->new_state); + s_state = (adapt_state_t)dec->new_state; + } + + if (dec->change_profile && ops != NULL && ops->set_capture_profile != NULL) { + ops->set_capture_profile(dec->new_profile); + } + + if (dec->change_channel && s_cfg.enable_channel_switch && + ops != NULL && ops->set_channel != NULL) { + ops->set_channel(dec->new_channel, 20); + } + + /* suggested_vital_interval_ms: the controller publishes a hint; the + * edge pipeline picks it up via edge_processing on its next emit. We + * don't yet have edge_set_vital_interval(); recorded for Phase 3. */ + (void)dec->request_calibration; +} + +/* ---- Loop callbacks ---- */ + +static void fast_loop_cb(TimerHandle_t t) +{ + (void)t; + adapt_observation_t obs; + collect_observation(&obs); + + portENTER_CRITICAL(&s_obs_lock); + s_last_obs = obs; + s_obs_valid = true; + portEXIT_CRITICAL(&s_obs_lock); + + adapt_decision_t dec; + adaptive_controller_decide(&s_cfg, s_state, &obs, &dec); + apply_decision(&dec); +} + +static void medium_loop_cb(TimerHandle_t t) +{ + (void)t; + /* Phase 3 stub: when enable_channel_switch is on, choose a channel + * based on RSSI/noise/yield. Today, log the snapshot so operators can + * see the controller is running. */ + adapt_observation_t obs; + portENTER_CRITICAL(&s_obs_lock); + obs = s_last_obs; + portEXIT_CRITICAL(&s_obs_lock); + + if (s_obs_valid) { + ESP_LOGI(TAG, "medium tick: state=%u yield=%upps motion=%.2f presence=%.2f rssi=%d", + (unsigned)s_state, + (unsigned)obs.pkt_yield_per_sec, + (double)obs.motion_score, + (double)obs.presence_score, + (int)obs.rssi_median_dbm); + } +} + +static void slow_loop_cb(TimerHandle_t t) +{ + (void)t; + /* Slow loop: publish a HEALTH message, request CALIBRATION_START on + * sustained drift. Both routed through swarm_bridge once the mesh + * plane lands. Today we log a rollover so operators see the cadence. */ + ESP_LOGI(TAG, "slow tick (state=%u)", (unsigned)s_state); +} + +/* ---- Public API ---- */ + +esp_err_t adaptive_controller_init(const adapt_config_t *cfg) +{ + if (s_inited) { + return ESP_OK; + } + + if (cfg != NULL) { + s_cfg = *cfg; + } else { + apply_defaults(&s_cfg); + } + + /* Sanity clamps. */ + if (s_cfg.fast_loop_ms < 50) s_cfg.fast_loop_ms = 50; + if (s_cfg.medium_loop_ms < 200) s_cfg.medium_loop_ms = 200; + if (s_cfg.slow_loop_ms < 1000) s_cfg.slow_loop_ms = 1000; + + s_state = ADAPT_STATE_RADIO_INIT; + + s_fast_timer = xTimerCreate("adapt_fast", + pdMS_TO_TICKS(s_cfg.fast_loop_ms), + pdTRUE, NULL, fast_loop_cb); + s_medium_timer = xTimerCreate("adapt_med", + pdMS_TO_TICKS(s_cfg.medium_loop_ms), + pdTRUE, NULL, medium_loop_cb); + s_slow_timer = xTimerCreate("adapt_slow", + pdMS_TO_TICKS(s_cfg.slow_loop_ms), + pdTRUE, NULL, slow_loop_cb); + + if (s_fast_timer == NULL || s_medium_timer == NULL || s_slow_timer == NULL) { + ESP_LOGE(TAG, "timer create failed"); + return ESP_ERR_NO_MEM; + } + + if (xTimerStart(s_fast_timer, 0) != pdPASS || + xTimerStart(s_medium_timer, 0) != pdPASS || + xTimerStart(s_slow_timer, 0) != pdPASS) { + ESP_LOGE(TAG, "timer start failed"); + return ESP_FAIL; + } + + s_state = ADAPT_STATE_SENSE_IDLE; + s_inited = true; + + ESP_LOGI(TAG, + "adaptive controller online: fast=%ums med=%ums slow=%ums " + "(channel_switch=%d role_change=%d aggressive=%d)", + (unsigned)s_cfg.fast_loop_ms, + (unsigned)s_cfg.medium_loop_ms, + (unsigned)s_cfg.slow_loop_ms, + (int)s_cfg.enable_channel_switch, + (int)s_cfg.enable_role_change, + (int)s_cfg.aggressive); + return ESP_OK; +} + +adapt_state_t adaptive_controller_state(void) +{ + return s_state; +} + +bool adaptive_controller_observation(adapt_observation_t *out) +{ + if (out == NULL) return false; + bool ok = false; + portENTER_CRITICAL(&s_obs_lock); + if (s_obs_valid) { + *out = s_last_obs; + ok = true; + } + portEXIT_CRITICAL(&s_obs_lock); + return ok; +} + +void adaptive_controller_force_state(adapt_state_t st) +{ + ESP_LOGI(TAG, "force state %u → %u", (unsigned)s_state, (unsigned)st); + s_state = st; +} diff --git a/firmware/esp32-csi-node/main/adaptive_controller.h b/firmware/esp32-csi-node/main/adaptive_controller.h new file mode 100644 index 00000000..f6e7c1c4 --- /dev/null +++ b/firmware/esp32-csi-node/main/adaptive_controller.h @@ -0,0 +1,125 @@ +/** + * @file adaptive_controller.h + * @brief ADR-081 Layer 2 — Adaptive sensing controller. + * + * Closed-loop firmware control over cadence, capture profile, channel, and + * mesh role. Three cooperating loops: + * + * Fast (~200 ms): packet rate, active probing + * Medium (~1 s) : channel selection, role transitions + * Slow (~30 s) : baseline recalibration + * + * Outputs are routed through: + * - rv_radio_ops_t (Layer 1) for set_channel / set_capture_profile + * - swarm_bridge / mesh plane (Layer 3) for CHANNEL_PLAN, ROLE_ASSIGN + * - edge_processing (Layer 4) for cadence and threshold updates + * + * Default policy is conservative — matches today's behavior. Aggressive + * adaptation is opt-in via Kconfig (ADAPTIVE_CONTROLLER_AGGRESSIVE). + */ + +#ifndef ADAPTIVE_CONTROLLER_H +#define ADAPTIVE_CONTROLLER_H + +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** Controller-level state machine (ADR-081 firmware FSM). */ +typedef enum { + ADAPT_STATE_BOOT = 0, + ADAPT_STATE_SELF_TEST = 1, + ADAPT_STATE_RADIO_INIT = 2, + ADAPT_STATE_TIME_SYNC = 3, + ADAPT_STATE_CALIBRATION = 4, + ADAPT_STATE_SENSE_IDLE = 5, + ADAPT_STATE_SENSE_ACTIVE = 6, + ADAPT_STATE_ALERT = 7, + ADAPT_STATE_DEGRADED = 8, +} adapt_state_t; + +/** Observation window aggregated each fast tick. */ +typedef struct { + uint16_t pkt_yield_per_sec; /**< From rv_radio_health.pkt_yield_per_sec. */ + uint16_t send_fail_count; /**< UDP/socket send failures. */ + int8_t rssi_median_dbm; + int8_t noise_floor_dbm; + float motion_score; /**< Pulled from edge_processing. */ + float presence_score; + float anomaly_score; + float node_coherence; /**< Inter-link coherence; 1.0 if single node. */ +} adapt_observation_t; + +/** Decisions emitted by a controller tick. */ +typedef struct { + bool change_profile; + uint8_t new_profile; /**< rv_capture_profile_t. */ + bool change_channel; + uint8_t new_channel; + bool change_state; + uint8_t new_state; /**< adapt_state_t. */ + bool request_calibration; /**< Coordinator should issue CALIBRATION_START. */ + uint16_t suggested_vital_interval_ms; +} adapt_decision_t; + +/** Controller config (loaded from NVS / Kconfig). */ +typedef struct { + uint16_t fast_loop_ms; /**< Default 200 ms. */ + uint16_t medium_loop_ms; /**< Default 1000 ms. */ + uint16_t slow_loop_ms; /**< Default 30000 ms. */ + bool aggressive; /**< true = react sooner / more often. */ + bool enable_channel_switch; /**< false = controller may never hop. */ + bool enable_role_change; + float motion_threshold; /**< 0..1, enter SENSE_ACTIVE above this. */ + float anomaly_threshold; /**< 0..1, enter ALERT above this. */ + uint16_t min_pkt_yield; /**< pps below this → DEGRADED. */ +} adapt_config_t; + +/** + * Initialize the adaptive controller. + * + * Spawns one FreeRTOS task that runs the three loops via FreeRTOS timers. + * Idempotent — second call is a no-op. + * + * @param cfg Config (NULL = use Kconfig defaults). + * @return ESP_OK on success. + */ +esp_err_t adaptive_controller_init(const adapt_config_t *cfg); + +/** Get the current state. */ +adapt_state_t adaptive_controller_state(void); + +/** + * Snapshot the latest observation (most recent fast-loop sample). + * Useful for telemetry and the `HEALTH` mesh message. + * + * @param out Output buffer. + * @return true if a valid observation has been recorded. + */ +bool adaptive_controller_observation(adapt_observation_t *out); + +/** + * Force a state transition (e.g. from a remote ROLE_ASSIGN message). + * Logged at INFO; controller may immediately transition again on next tick. + */ +void adaptive_controller_force_state(adapt_state_t st); + +/** + * Pure-function policy: given an observation + current state + config, + * compute the decision. Exposed in the header so it can be unit-tested + * offline (no FreeRTOS / ESP-IDF dependency in the body). + */ +void adaptive_controller_decide(const adapt_config_t *cfg, + adapt_state_t current, + const adapt_observation_t *obs, + adapt_decision_t *out); + +#ifdef __cplusplus +} +#endif + +#endif /* ADAPTIVE_CONTROLLER_H */ diff --git a/firmware/esp32-csi-node/main/main.c b/firmware/esp32-csi-node/main/main.c index 631a0dba..d79cf218 100644 --- a/firmware/esp32-csi-node/main/main.c +++ b/firmware/esp32-csi-node/main/main.c @@ -30,6 +30,8 @@ #include "display_task.h" #include "mmwave_sensor.h" #include "swarm_bridge.h" +#include "rv_radio_ops.h" /* ADR-081 Layer 1 — Radio Abstraction Layer. */ +#include "adaptive_controller.h" /* ADR-081 Layer 2 — Adaptive controller. */ #ifdef CONFIG_CSI_MOCK_ENABLED #include "mock_csi.h" #endif @@ -278,6 +280,26 @@ void app_main(void) ESP_LOGI(TAG, "Mock CSI mode: skipping swarm bridge"); #endif + /* ADR-081 Layer 1: register the ESP32 radio ops binding now that + * csi_collector_init() has run. Skipped under mock CSI; a future + * mock binding can register itself instead. */ +#ifndef CONFIG_CSI_MOCK_ENABLED + rv_radio_ops_esp32_register(); + const rv_radio_ops_t *radio_ops = rv_radio_ops_get(); + if (radio_ops != NULL && radio_ops->init != NULL) { + radio_ops->init(); + } +#endif + + /* ADR-081 Layer 2: start the adaptive controller. NULL config → use + * Kconfig defaults. Default policy is conservative: no channel + * switching, no role change. Operators opt in via menuconfig. */ + esp_err_t adapt_ret = adaptive_controller_init(NULL); + if (adapt_ret != ESP_OK) { + ESP_LOGW(TAG, "Adaptive controller init failed: %s", + esp_err_to_name(adapt_ret)); + } + /* Initialize power management. */ power_mgmt_init(g_nvs_config.power_duty); @@ -289,13 +311,14 @@ void app_main(void) } #endif - ESP_LOGI(TAG, "CSI streaming active → %s:%d (edge_tier=%u, OTA=%s, WASM=%s, mmWave=%s, swarm=%s)", + ESP_LOGI(TAG, "CSI streaming active → %s:%d (edge_tier=%u, OTA=%s, WASM=%s, mmWave=%s, swarm=%s, adapt=%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", (mmwave_ret == ESP_OK) ? "active" : "off", - (swarm_ret == ESP_OK) ? g_nvs_config.seed_url : "off"); + (swarm_ret == ESP_OK) ? g_nvs_config.seed_url : "off", + (adapt_ret == ESP_OK) ? "on" : "off"); /* Main loop — keep alive */ while (1) { diff --git a/firmware/esp32-csi-node/main/rv_feature_state.c b/firmware/esp32-csi-node/main/rv_feature_state.c new file mode 100644 index 00000000..c4653af3 --- /dev/null +++ b/firmware/esp32-csi-node/main/rv_feature_state.c @@ -0,0 +1,44 @@ +/** + * @file rv_feature_state.c + * @brief ADR-081 Layer 4 — Feature state packet helpers. + */ + +#include "rv_feature_state.h" + +#include + +uint32_t rv_feature_state_crc32(const uint8_t *data, size_t len) +{ + /* IEEE CRC32 (poly 0xEDB88320), bit-by-bit. Small (~80 byte) input at + * low cadence — no need for a 1 KB lookup table. */ + uint32_t crc = 0xFFFFFFFFu; + for (size_t i = 0; i < len; i++) { + crc ^= data[i]; + for (int b = 0; b < 8; b++) { + uint32_t mask = -(crc & 1u); + crc = (crc >> 1) ^ (0xEDB88320u & mask); + } + } + return ~crc; +} + +void rv_feature_state_finalize(rv_feature_state_t *pkt, + uint8_t node_id, + uint16_t seq, + uint64_t ts_us, + uint8_t mode) +{ + if (pkt == NULL) { + return; + } + pkt->magic = RV_FEATURE_STATE_MAGIC; + pkt->node_id = node_id; + pkt->mode = mode; + pkt->seq = seq; + pkt->ts_us = ts_us; + pkt->reserved = 0; + + /* CRC32 over everything except the trailing crc32 field itself. */ + const size_t crc_offset = sizeof(rv_feature_state_t) - sizeof(uint32_t); + pkt->crc32 = rv_feature_state_crc32((const uint8_t *)pkt, crc_offset); +} diff --git a/firmware/esp32-csi-node/main/rv_feature_state.h b/firmware/esp32-csi-node/main/rv_feature_state.h new file mode 100644 index 00000000..b0b8640a --- /dev/null +++ b/firmware/esp32-csi-node/main/rv_feature_state.h @@ -0,0 +1,110 @@ +/** + * @file rv_feature_state.h + * @brief ADR-081 Layer 4 — Compact on-wire feature state packet. + * + * The default upstream payload from a node. Replaces raw ADR-018 CSI as the + * primary stream; ADR-018 raw frames remain available as a debug stream + * gated by the controller / channel plan. + * + * Magic numbers in use across the firmware: + * 0xC5110001 — ADR-018 raw CSI frame (csi_collector.h) + * 0xC5110002 — ADR-039 vitals packet (edge_processing.h) + * 0xC5110003 — ADR-069 feature vector (edge_processing.h) + * 0xC5110004 — ADR-063 fused vitals (edge_processing.h) + * 0xC5110005 — ADR-039 compressed CSI (edge_processing.h) + * 0xC5110006 — ADR-081 feature state (this file) ← new + */ + +#ifndef RV_FEATURE_STATE_H +#define RV_FEATURE_STATE_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** Magic number for ADR-081 rv_feature_state_t. */ +#define RV_FEATURE_STATE_MAGIC 0xC5110006u + +/** Quality flag bits. */ +#define RV_QFLAG_PRESENCE_VALID (1u << 0) +#define RV_QFLAG_RESPIRATION_VALID (1u << 1) +#define RV_QFLAG_HEARTBEAT_VALID (1u << 2) +#define RV_QFLAG_ANOMALY_TRIGGERED (1u << 3) +#define RV_QFLAG_ENV_SHIFT_DETECTED (1u << 4) +#define RV_QFLAG_DEGRADED_MODE (1u << 5) +#define RV_QFLAG_CALIBRATING (1u << 6) +#define RV_QFLAG_RECOMMEND_RECAL (1u << 7) + +/** + * Compact per-node sensing state. Sent at 1-10 Hz by default, replacing the + * raw ADR-018 stream as the primary upstream payload. + * + * Mode field carries the rv_capture_profile_t value of the dominant window + * — receivers can use it to weight features (a sample emitted under + * RV_PROFILE_FAST_MOTION will have a stale respiration_bpm, etc.). + * + * CRC32 is the IEEE polynomial computed over bytes [0 .. sizeof - 4]. + */ +typedef struct __attribute__((packed)) { + uint32_t magic; /**< RV_FEATURE_STATE_MAGIC. */ + uint8_t node_id; /**< Source node id. */ + uint8_t mode; /**< rv_capture_profile_t at emit time. */ + uint16_t seq; /**< Monotonic per-node sequence. */ + uint64_t ts_us; /**< Node-local microseconds. */ + float motion_score; /**< 0..1, 100 ms window. */ + float presence_score; /**< 0..1, 1 s window. */ + float respiration_bpm; /**< Breaths per minute. */ + float respiration_conf; /**< 0..1. */ + float heartbeat_bpm; /**< Beats per minute. */ + float heartbeat_conf; /**< 0..1. */ + float anomaly_score; /**< 0..1, z-score-derived. */ + float env_shift_score; /**< 0..1, baseline drift. */ + float node_coherence; /**< 0..1, multi-link agreement. */ + uint16_t quality_flags; /**< RV_QFLAG_* bitmap. */ + uint16_t reserved; + uint32_t crc32; /**< IEEE CRC32 over bytes [0..end-4]. */ +} rv_feature_state_t; + +_Static_assert(sizeof(rv_feature_state_t) == 80, + "rv_feature_state_t must be 80 bytes on the wire"); + +/** + * Compute IEEE CRC32 over a byte buffer. + * + * Provided here (not in a separate util) because the firmware does not yet + * have a shared CRC32 helper — only zlib's via lwIP, which is not always + * exposed. This implementation is bit-by-bit; ~80 bytes/packet at low + * cadence has negligible CPU cost. + * + * @param data Input buffer. + * @param len Input length in bytes. + * @return IEEE CRC32 of the input. + */ +uint32_t rv_feature_state_crc32(const uint8_t *data, size_t len); + +/** + * Finalize an rv_feature_state_t by populating magic, seq, ts_us, and crc32. + * Caller fills the remaining fields in-place before calling this. After + * finalize() the packet is ready to send on the wire. + * + * @param pkt Packet to finalize (caller-owned). + * @param node_id Source node id (typically csi_collector_get_node_id()). + * @param seq Monotonic sequence (caller-managed). + * @param ts_us Node-local microseconds (typically esp_timer_get_time()). + * @param mode Active rv_capture_profile_t. + */ +void rv_feature_state_finalize(rv_feature_state_t *pkt, + uint8_t node_id, + uint16_t seq, + uint64_t ts_us, + uint8_t mode); + +#ifdef __cplusplus +} +#endif + +#endif /* RV_FEATURE_STATE_H */ diff --git a/firmware/esp32-csi-node/main/rv_radio_ops.h b/firmware/esp32-csi-node/main/rv_radio_ops.h new file mode 100644 index 00000000..13557952 --- /dev/null +++ b/firmware/esp32-csi-node/main/rv_radio_ops.h @@ -0,0 +1,135 @@ +/** + * @file rv_radio_ops.h + * @brief ADR-081 Layer 1 — Radio Abstraction Layer. + * + * A single function-pointer vtable (rv_radio_ops_t) that isolates chipset + * specific capture details from the layers above (adaptive controller, mesh + * plane, feature extraction, Rust handoff). + * + * Two bindings ship today: + * - rv_radio_ops_esp32.c — wraps csi_collector + esp_wifi_* + * - rv_radio_ops_mock.c — wraps mock_csi.c (when CONFIG_CSI_MOCK_ENABLED) + * + * A third binding (Nexmon-patched Broadcom/Cypress) is reserved but not + * implemented here. The whole point of the vtable is that the controller + * and mesh-plane code above never need to know which one is active. + */ + +#ifndef RV_RADIO_OPS_H +#define RV_RADIO_OPS_H + +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ---- Modes ---- */ + +/** Radio operating modes (set_mode argument). */ +typedef enum { + RV_RADIO_MODE_DISABLED = 0, /**< Receiver off. */ + RV_RADIO_MODE_PASSIVE_RX = 1, /**< Listen-only, no TX. */ + RV_RADIO_MODE_ACTIVE_PROBE = 2, /**< Inject NDP frames at high rate. */ + RV_RADIO_MODE_CALIBRATION = 3, /**< Synchronized calibration burst. */ +} rv_radio_mode_t; + +/* ---- Capture profiles ---- */ + +/** + * Named capture profiles. The adaptive controller selects one of these + * via set_capture_profile(); the binding maps it to chipset-specific + * register/driver state. + */ +typedef enum { + RV_PROFILE_PASSIVE_LOW_RATE = 0, /**< Default idle: minimum cadence. */ + RV_PROFILE_ACTIVE_PROBE = 1, /**< High-rate NDP injection. */ + RV_PROFILE_RESP_HIGH_SENS = 2, /**< Quietest channel, vitals-only. */ + RV_PROFILE_FAST_MOTION = 3, /**< Short window, high cadence. */ + RV_PROFILE_CALIBRATION = 4, /**< Synchronized burst across nodes. */ + RV_PROFILE_COUNT +} rv_capture_profile_t; + +/* ---- Health snapshot ---- */ + +/** Radio-layer health, polled by the adaptive controller. */ +typedef struct { + uint16_t pkt_yield_per_sec; /**< CSI callbacks/second observed. */ + uint16_t send_fail_count; /**< UDP/socket send failures since last poll. */ + int8_t rssi_median_dbm; /**< Median RSSI over the last 1 s. */ + int8_t noise_floor_dbm; /**< Latest noise floor estimate. */ + uint8_t current_channel; /**< Channel currently configured. */ + uint8_t current_bw_mhz; /**< Bandwidth currently configured. */ + uint8_t current_profile; /**< Active rv_capture_profile_t. */ + uint8_t reserved; +} rv_radio_health_t; + +/* ---- The vtable ---- */ + +/** + * Radio Abstraction Layer ops. + * + * All function pointers are required (no NULL slots). Each binding must + * provide all six. Return values follow ESP-IDF conventions: 0/ESP_OK on + * success, negative or ESP_ERR_* on failure. + */ +typedef struct { + /** One-time init (driver register, callback wire-up). */ + int (*init)(void); + + /** + * Tune to a primary channel with the given bandwidth. + * @param ch Channel number (1-13 for 2.4 GHz, 36-177 for 5 GHz). + * @param bw Bandwidth in MHz (20 or 40; 80/160 reserved for future). + */ + int (*set_channel)(uint8_t ch, uint8_t bw); + + /** Switch operating mode (rv_radio_mode_t). */ + int (*set_mode)(uint8_t mode); + + /** Enable or disable the CSI capture path. */ + int (*set_csi_enabled)(bool en); + + /** Apply a named capture profile (rv_capture_profile_t). */ + int (*set_capture_profile)(uint8_t profile_id); + + /** Snapshot the radio-layer health (non-blocking). */ + int (*get_health)(rv_radio_health_t *out); +} rv_radio_ops_t; + +/* ---- Registration ---- */ + +/** + * Register the active radio ops binding. + * + * Called once at boot by the chipset binding's init code (e.g. + * rv_radio_ops_esp32_register()). The pointer must remain valid for the + * lifetime of the process — typically a static const inside the binding. + */ +void rv_radio_ops_register(const rv_radio_ops_t *ops); + +/** + * Get the active radio ops binding. + * + * @return Pointer to the registered ops table, or NULL if no binding has + * been registered yet (e.g. before init). + */ +const rv_radio_ops_t *rv_radio_ops_get(void); + +/* ---- Convenience: ESP32 binding registration ---- */ + +/** + * Register the ESP32 binding as the active radio ops. + * + * Call this once at boot, after csi_collector_init() has run. Idempotent. + * Defined in rv_radio_ops_esp32.c. + */ +void rv_radio_ops_esp32_register(void); + +#ifdef __cplusplus +} +#endif + +#endif /* RV_RADIO_OPS_H */ diff --git a/firmware/esp32-csi-node/main/rv_radio_ops_esp32.c b/firmware/esp32-csi-node/main/rv_radio_ops_esp32.c new file mode 100644 index 00000000..39340836 --- /dev/null +++ b/firmware/esp32-csi-node/main/rv_radio_ops_esp32.c @@ -0,0 +1,177 @@ +/** + * @file rv_radio_ops_esp32.c + * @brief ADR-081 Layer 1 — ESP32 binding for rv_radio_ops_t. + * + * Wraps the existing csi_collector + esp_wifi_* surface so the adaptive + * controller, mesh plane, and feature-extraction layers can address the + * radio through a single chipset-agnostic vtable. + * + * This is intentionally thin. The heavy lifting still lives in + * csi_collector.c (CSI callback, channel hopping, NDP injection); this file + * is the contract that lets a second chipset (Nexmon Broadcom, custom + * silicon) drop in without touching the layers above. + */ + +#include "rv_radio_ops.h" +#include "csi_collector.h" + +#include +#include "esp_err.h" +#include "esp_log.h" +#include "esp_wifi.h" + +static const char *TAG = "rv_radio_esp32"; + +/* ---- Active ops registry ---- */ + +static const rv_radio_ops_t *s_active_ops = NULL; + +void rv_radio_ops_register(const rv_radio_ops_t *ops) +{ + s_active_ops = ops; +} + +const rv_radio_ops_t *rv_radio_ops_get(void) +{ + return s_active_ops; +} + +/* ---- ESP32 binding state ---- */ + +static uint8_t s_current_channel = 1; +static uint8_t s_current_bw = 20; +static uint8_t s_current_profile = RV_PROFILE_PASSIVE_LOW_RATE; +static uint8_t s_current_mode = RV_RADIO_MODE_PASSIVE_RX; +static bool s_csi_enabled = true; + +/* ---- Vtable implementations ---- */ + +static int esp32_init(void) +{ + /* csi_collector_init() is called from app_main() before the controller + * starts; nothing to do here for the ESP32 binding. We just confirm a + * valid current channel was captured by csi_collector_init(). */ + ESP_LOGI(TAG, "ESP32 radio ops: init (current ch=%u bw=%u)", + (unsigned)s_current_channel, (unsigned)s_current_bw); + return ESP_OK; +} + +static int esp32_set_channel(uint8_t ch, uint8_t bw) +{ + wifi_second_chan_t second = WIFI_SECOND_CHAN_NONE; + if (bw == 40) { + /* HT40+: secondary channel above primary. The controller never asks + * for HT40 today (sensing prefers HT20), but the mapping is here so + * a future profile can. */ + second = WIFI_SECOND_CHAN_ABOVE; + } else if (bw != 20) { + ESP_LOGW(TAG, "set_channel: unsupported bw=%u, treating as 20 MHz", + (unsigned)bw); + bw = 20; + } + + esp_err_t err = esp_wifi_set_channel(ch, second); + if (err != ESP_OK) { + ESP_LOGW(TAG, "set_channel(%u, bw=%u) failed: %s", + (unsigned)ch, (unsigned)bw, esp_err_to_name(err)); + return (int)err; + } + s_current_channel = ch; + s_current_bw = bw; + return ESP_OK; +} + +static int esp32_set_mode(uint8_t mode) +{ + /* Persist the mode for the health snapshot; actual TX behavior is + * triggered by the controller calling csi_inject_ndp_frame() directly + * once the controller PR lands. For now this is bookkeeping plus a + * passive/active probe gate. */ + switch (mode) { + case RV_RADIO_MODE_DISABLED: + case RV_RADIO_MODE_PASSIVE_RX: + case RV_RADIO_MODE_ACTIVE_PROBE: + case RV_RADIO_MODE_CALIBRATION: + s_current_mode = mode; + return ESP_OK; + default: + ESP_LOGW(TAG, "set_mode: unknown mode %u", (unsigned)mode); + return ESP_ERR_INVALID_ARG; + } +} + +static int esp32_set_csi_enabled(bool en) +{ + esp_err_t err = esp_wifi_set_csi(en); + if (err != ESP_OK) { + ESP_LOGW(TAG, "set_csi(%d) failed: %s", (int)en, esp_err_to_name(err)); + return (int)err; + } + s_csi_enabled = en; + return ESP_OK; +} + +static int esp32_set_capture_profile(uint8_t profile_id) +{ + if (profile_id >= RV_PROFILE_COUNT) { + ESP_LOGW(TAG, "set_capture_profile: invalid id %u", (unsigned)profile_id); + return ESP_ERR_INVALID_ARG; + } + + /* Profiles are advisory at this layer — the controller uses them to + * decide cadence/window/threshold for the layers above. The radio + * binding records the active profile for health reporting and may + * adjust the underlying TX/RX mode in future bindings. */ + s_current_profile = profile_id; + + /* For ACTIVE_PROBE and CALIBRATION, switch the radio mode to match. */ + if (profile_id == RV_PROFILE_ACTIVE_PROBE) { + esp32_set_mode(RV_RADIO_MODE_ACTIVE_PROBE); + } else if (profile_id == RV_PROFILE_CALIBRATION) { + esp32_set_mode(RV_RADIO_MODE_CALIBRATION); + } else { + esp32_set_mode(RV_RADIO_MODE_PASSIVE_RX); + } + return ESP_OK; +} + +static int esp32_get_health(rv_radio_health_t *out) +{ + if (out == NULL) { + return ESP_ERR_INVALID_ARG; + } + memset(out, 0, sizeof(*out)); + + /* pkt_yield and send_fail are filled by the adaptive controller from + * its own counters today (csi_collector keeps statics that are not yet + * exposed). The binding fills the fields it owns directly. */ + out->current_channel = s_current_channel; + out->current_bw_mhz = s_current_bw; + out->current_profile = s_current_profile; + + wifi_ap_record_t ap = {0}; + if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) { + out->rssi_median_dbm = ap.rssi; + } + return ESP_OK; +} + +/* ---- The vtable instance ---- */ + +static const rv_radio_ops_t s_esp32_ops = { + .init = esp32_init, + .set_channel = esp32_set_channel, + .set_mode = esp32_set_mode, + .set_csi_enabled = esp32_set_csi_enabled, + .set_capture_profile = esp32_set_capture_profile, + .get_health = esp32_get_health, +}; + +void rv_radio_ops_esp32_register(void) +{ + if (s_active_ops == &s_esp32_ops) { + return; /* idempotent */ + } + rv_radio_ops_register(&s_esp32_ops); + ESP_LOGI(TAG, "ESP32 radio ops registered as active binding"); +}