ADR-081: adaptive CSI mesh firmware kernel + scaffolding

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).
This commit is contained in:
Claude 2026-04-19 03:14:04 +00:00
parent 8914538bfe
commit 9648a47fdc
No known key found for this signature in database
11 changed files with 1477 additions and 2 deletions

View File

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

View File

@ -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` | 110 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, 110 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) | 50200 pps per observer | Stays on-device unless debug stream enabled |
| `rv_feature_state_t` | 110 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 110 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 ~58 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.

View File

@ -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 "")

View File

@ -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

View File

@ -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 <string.h>
#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;
}

View File

@ -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 <stdint.h>
#include <stdbool.h>
#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 */

View File

@ -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) {

View File

@ -0,0 +1,44 @@
/**
* @file rv_feature_state.c
* @brief ADR-081 Layer 4 Feature state packet helpers.
*/
#include "rv_feature_state.h"
#include <string.h>
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);
}

View File

@ -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 <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#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 */

View File

@ -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 <stdint.h>
#include <stdbool.h>
#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 */

View File

@ -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 <string.h>
#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");
}