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:
parent
8914538bfe
commit
9648a47fdc
34
CHANGELOG.md
34
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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 "")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
@ -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 */
|
||||
|
|
@ -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");
|
||||
}
|
||||
Loading…
Reference in New Issue