mirror of
https://github.com/ruvnet/RuView
synced 2026-07-29 18:31:44 +00:00
ADR-081: Implement 5-layer adaptive CSI mesh firmware kernel (#404)
* 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).
* ADR-081: implement Layers 1/2/4 end-to-end + host tests + QEMU hooks
Turns the ADR-081 scaffolding into a working adaptive CSI mesh kernel:
Layer 1 radio abstraction has an ESP32 binding and a mock binding; Layer 2
adaptive controller runs on FreeRTOS timers; Layer 4 feature-state packet
is emitted at 5 Hz by default, replacing raw ADR-018 CSI as the default
upstream.
New files:
firmware/esp32-csi-node/main/adaptive_controller_decide.c (pure policy)
firmware/esp32-csi-node/main/rv_radio_ops_mock.c (QEMU binding)
firmware/esp32-csi-node/tests/host/Makefile (host tests)
firmware/esp32-csi-node/tests/host/test_adaptive_controller.c
firmware/esp32-csi-node/tests/host/test_rv_feature_state.c
firmware/esp32-csi-node/tests/host/esp_err.h (shim)
firmware/esp32-csi-node/tests/host/.gitignore
Modified:
adaptive_controller.c — includes pure decide.c; emit_feature_state()
wired into fast loop (200 ms = 5 Hz)
rv_radio_ops_esp32.c — get_health() fills pkt_yield + send_fail
csi_collector.{c,h} — pkt_yield/send_fail accessors (ADR-081 L1)
rv_feature_state.h — packed size corrected to 60 bytes
(was incorrectly 80 in initial commit)
main.c — mock binding registered under mock CSI
CMakeLists.txt — rv_radio_ops_mock.c under CSI_MOCK_ENABLED
scripts/validate_qemu_output.py — 3 new ADR-081 checks (17/18/19)
docs/adr/ADR-081-*.md — status → Accepted (partial);
implementation-status matrix; measured
benchmarks (decide 3.2 ns, CRC32 614 ns);
bandwidth 300 B/s @ 5 Hz (99.7% vs raw);
verification section
CHANGELOG.md — artifact-level entries
Tests (host, gcc -O2 -std=c11):
test_adaptive_controller: 18/18 pass, decide() = 3.2 ns/call
test_rv_feature_state: 15/15 pass, CRC32(56 B) = 614 ns/pkt, 87 MB/s
sizeof(rv_feature_state_t) == 60 asserted
IEEE CRC32 known vectors verified
Deferred (tracked in ADR-081 roadmap Phase 3/4):
Layer 3 mesh-plane message types, role-assignment FSM, Rust-side mirror
trait in crates/wifi-densepose-hardware/src/radio_ops.rs.
* ADR-081: Layer 3 mesh plane + Rust mirror trait — all 5 layers landed
Fully implements the remaining deferred pieces of the adaptive CSI mesh
firmware kernel. All 5 layers (Radio Abstraction, Adaptive Controller,
Mesh Sensing Plane, On-device Feature Extraction, Rust handoff) are
now implemented and host-tested end-to-end.
Layer 3 — Mesh Sensing Plane (firmware/esp32-csi-node/main/rv_mesh.{h,c}):
* 4 node roles: Unassigned / Anchor / Observer / FusionRelay / Coordinator
* 7 message types: TIME_SYNC, ROLE_ASSIGN, CHANNEL_PLAN,
CALIBRATION_START, FEATURE_DELTA, HEALTH, ANOMALY_ALERT
* 3 auth classes: None / HMAC-SHA256-session / Ed25519-batch
* Payload types: rv_node_status_t (28 B), rv_anomaly_alert_t (28 B),
rv_time_sync_t (16 B), rv_role_assign_t (16 B),
rv_channel_plan_t (24 B), rv_calibration_start_t (20 B)
* 16-byte envelope + payload + IEEE CRC32 trailer
* Pure rv_mesh_encode()/rv_mesh_decode() plus typed convenience encoders
* rv_mesh_send_health() + rv_mesh_send_anomaly() helpers
Controller wiring (adaptive_controller.c):
* Slow loop (30 s default) now emits HEALTH
* apply_decision() emits ANOMALY_ALERT on transitions to ALERT /
DEGRADED
* Role + mesh epoch tracked in module state; epoch bumps on role
change
Layer 5 — Rust mirror (crates/wifi-densepose-hardware/src/radio_ops.rs):
* RadioOps trait mirrors rv_radio_ops_t vtable
* MockRadio backend for offline tests
* MeshHeader / NodeStatus / AnomalyAlert types mirror rv_mesh.h
* Byte-identical IEEE CRC32 (poly 0xEDB88320) verified against
firmware test vectors (0xCBF43926 for "123456789")
* decode_mesh / decode_node_status / decode_anomaly_alert / encode_health
* 8 unit tests, including mesh_constants_match_firmware which asserts
MESH_MAGIC/VERSION/HEADER_SIZE/MAX_PAYLOAD match rv_mesh.h
byte-for-byte
* Exported from lib.rs
* signal/ruvector/train/mat crates untouched — satisfies ADR-081
portability acceptance test
Tests (all passing):
test_adaptive_controller: 18/18 (C, decide() 3.2 ns/call)
test_rv_feature_state: 15/15 (C, CRC32 87 MB/s)
test_rv_mesh: 27/27 (C, roundtrip 1.0 µs)
radio_ops::tests (Rust): 8/8
--- total: 68/68 assertions green ---
Docs:
* ADR-081 status flipped to Accepted
* Implementation-status matrix updated; L3 + Rust mirror both
marked Implemented
* Benchmarks table extended with rv_mesh encode+decode roundtrip
* Verification section updated with cargo test invocation
* CHANGELOG: two new entries for L3 mesh plane + Rust mirror
Remaining follow-ups (Phase 3.5 polish, not blocking):
* Mesh RX path (UDP listener + dispatch) on the firmware
* Ed25519 signing for CHANNEL_PLAN / CALIBRATION_START
* Hardware validation on COM7
* Add test_rv_mesh to host-test .gitignore
Fixes an untracked-file warning from the repo stop-hook: the compiled
binary was built by make but the .gitignore update was missed in
8dfb031. No source changes.
* Fix implicit decl of emit_feature_state in adaptive_controller
fast_loop_cb calls emit_feature_state() at line 224, but the static
definition is at line 256. GCC treats the implicit declaration as
non-static, then the real static definition conflicts, and
-Werror=all promotes both to hard build errors.
Add a forward declaration above the first use. Unblocks ESP32-S3
firmware build and all QEMU matrix jobs.
Co-Authored-By: claude-flow <ruv@ruv.net>
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,13 +4,18 @@ 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"
|
||||
"rv_mesh.c"
|
||||
"adaptive_controller.c"
|
||||
)
|
||||
|
||||
set(REQUIRES "")
|
||||
|
||||
# ADR-061: Mock CSI generator for QEMU testing
|
||||
# ADR-061: Mock CSI generator for QEMU testing + ADR-081 mock radio binding
|
||||
if(CONFIG_CSI_MOCK_ENABLED)
|
||||
list(APPEND SRCS "mock_csi.c")
|
||||
list(APPEND SRCS "mock_csi.c" "rv_radio_ops_mock.c")
|
||||
endif()
|
||||
|
||||
# ADR-045: AMOLED display support (compile-time optional)
|
||||
|
||||
@@ -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,414 @@
|
||||
/**
|
||||
* @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 "rv_feature_state.h"
|
||||
#include "rv_mesh.h"
|
||||
#include "edge_processing.h"
|
||||
#include "stream_sender.h"
|
||||
#include "csi_collector.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;
|
||||
|
||||
/* Forward decl: defined below, called from fast_loop_cb. */
|
||||
static void emit_feature_state(void);
|
||||
|
||||
/* ---- 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 policy lives in its own file so it can link under
|
||||
* host unit tests without FreeRTOS. It is part of this translation
|
||||
* unit via #include to preserve a single object at build time. */
|
||||
#include "adaptive_controller_decide.c"
|
||||
|
||||
/* ---- 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 ---- */
|
||||
|
||||
/* ADR-081 L3: epoch monotonically advances per mesh session. Seeded at
|
||||
* init; every major state transition or role change bumps it so
|
||||
* receivers can order events. */
|
||||
static uint32_t s_mesh_epoch = 1;
|
||||
|
||||
/* ADR-081 L3: current node role. Updated by ROLE_ASSIGN receipt (future
|
||||
* mesh-plane RX path) or forced by tests. Default Observer. */
|
||||
static uint8_t s_role = RV_ROLE_OBSERVER;
|
||||
|
||||
/* 8-byte node id. Upper 7 bytes are zero by default; byte 0 is the
|
||||
* legacy CSI node id for compatibility with the ADR-018 header. */
|
||||
static void node_id_bytes(uint8_t out[8])
|
||||
{
|
||||
memset(out, 0, 8);
|
||||
out[0] = csi_collector_get_node_id();
|
||||
}
|
||||
|
||||
static void apply_decision(const adapt_decision_t *dec)
|
||||
{
|
||||
const rv_radio_ops_t *ops = rv_radio_ops_get();
|
||||
adapt_state_t prev = s_state;
|
||||
|
||||
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;
|
||||
|
||||
/* ADR-081 L3: on transition to ALERT, emit ANOMALY_ALERT on the
|
||||
* mesh plane. On any role-relevant transition, bump the epoch. */
|
||||
if (s_state == ADAPT_STATE_ALERT && prev != ADAPT_STATE_ALERT) {
|
||||
uint8_t nid[8];
|
||||
node_id_bytes(nid);
|
||||
adapt_observation_t obs;
|
||||
float motion = 0.0f, anomaly = 0.0f;
|
||||
portENTER_CRITICAL(&s_obs_lock);
|
||||
if (s_obs_valid) { obs = s_last_obs; motion = obs.motion_score;
|
||||
anomaly = obs.anomaly_score; }
|
||||
portEXIT_CRITICAL(&s_obs_lock);
|
||||
uint8_t severity = (uint8_t)(anomaly * 255.0f);
|
||||
rv_mesh_send_anomaly(s_role, s_mesh_epoch, nid,
|
||||
RV_ANOMALY_COHERENCE_LOSS, severity,
|
||||
anomaly, motion);
|
||||
}
|
||||
if (s_state == ADAPT_STATE_DEGRADED && prev != ADAPT_STATE_DEGRADED) {
|
||||
uint8_t nid[8];
|
||||
node_id_bytes(nid);
|
||||
rv_mesh_send_anomaly(s_role, s_mesh_epoch, nid,
|
||||
RV_ANOMALY_PKT_YIELD_COLLAPSE,
|
||||
200, 1.0f, 0.0f);
|
||||
}
|
||||
s_mesh_epoch++;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
/* ADR-081 Layer 4/5: emit compact feature state on every fast tick
|
||||
* (default 200 ms → 5 Hz, within the 1–10 Hz spec). Replaces raw
|
||||
* ADR-018 CSI as the default upstream; raw remains available as a
|
||||
* debug stream gated by the channel plan. */
|
||||
emit_feature_state();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/* ADR-081 Layer 4: emit one rv_feature_state_t packet onto the wire.
|
||||
*
|
||||
* Pulls from the latest observation + latest vitals + the active capture
|
||||
* profile. Send is best-effort — stream_sender will report its own
|
||||
* failures; we don't re-queue. At 5 Hz default cadence this is 300 B/s
|
||||
* per node, vs. ~100 KB/s for raw ADR-018 CSI. */
|
||||
static uint16_t s_feature_state_seq = 0;
|
||||
|
||||
static void emit_feature_state(void)
|
||||
{
|
||||
rv_feature_state_t pkt;
|
||||
memset(&pkt, 0, sizeof(pkt));
|
||||
|
||||
adapt_observation_t obs;
|
||||
bool have_obs = false;
|
||||
portENTER_CRITICAL(&s_obs_lock);
|
||||
if (s_obs_valid) {
|
||||
obs = s_last_obs;
|
||||
have_obs = true;
|
||||
}
|
||||
portEXIT_CRITICAL(&s_obs_lock);
|
||||
|
||||
if (have_obs) {
|
||||
pkt.motion_score = obs.motion_score;
|
||||
pkt.presence_score = obs.presence_score;
|
||||
pkt.anomaly_score = obs.anomaly_score;
|
||||
pkt.node_coherence = obs.node_coherence;
|
||||
}
|
||||
|
||||
/* Fill vitals from edge_processing's latest packet. */
|
||||
edge_vitals_pkt_t v;
|
||||
if (edge_get_vitals(&v)) {
|
||||
pkt.respiration_bpm = (float)v.breathing_rate / 100.0f;
|
||||
pkt.heartbeat_bpm = (float)v.heartrate / 10000.0f;
|
||||
/* Confidence proxies: presence score for resp, 1.0 if heart BPM
|
||||
* is within physiological range. */
|
||||
pkt.respiration_conf = (v.breathing_rate > 0) ? v.presence_score : 0.0f;
|
||||
pkt.heartbeat_conf = (v.heartrate > 400000u && v.heartrate < 1800000u)
|
||||
? 0.8f : 0.0f;
|
||||
if (pkt.respiration_bpm > 0.0f) pkt.quality_flags |= RV_QFLAG_RESPIRATION_VALID;
|
||||
if (pkt.heartbeat_bpm > 0.0f) pkt.quality_flags |= RV_QFLAG_HEARTBEAT_VALID;
|
||||
if (pkt.presence_score >= 0.5f) pkt.quality_flags |= RV_QFLAG_PRESENCE_VALID;
|
||||
if (v.flags & 0x02) pkt.quality_flags |= RV_QFLAG_ANOMALY_TRIGGERED; /* fall bit */
|
||||
}
|
||||
|
||||
if (s_state == ADAPT_STATE_DEGRADED) pkt.quality_flags |= RV_QFLAG_DEGRADED_MODE;
|
||||
if (s_state == ADAPT_STATE_CALIBRATION) pkt.quality_flags |= RV_QFLAG_CALIBRATING;
|
||||
|
||||
/* Active profile, for receiver-side weighting. */
|
||||
const rv_radio_ops_t *ops = rv_radio_ops_get();
|
||||
uint8_t profile = RV_PROFILE_PASSIVE_LOW_RATE;
|
||||
if (ops != NULL && ops->get_health != NULL) {
|
||||
rv_radio_health_t h;
|
||||
if (ops->get_health(&h) == ESP_OK) profile = h.current_profile;
|
||||
}
|
||||
|
||||
rv_feature_state_finalize(&pkt,
|
||||
csi_collector_get_node_id(),
|
||||
s_feature_state_seq++,
|
||||
(uint64_t)esp_timer_get_time(),
|
||||
profile);
|
||||
|
||||
int sent = stream_sender_send((const uint8_t *)&pkt, sizeof(pkt));
|
||||
if (sent < 0) {
|
||||
ESP_LOGW(TAG, "feature_state emit failed");
|
||||
}
|
||||
}
|
||||
|
||||
static void slow_loop_cb(TimerHandle_t t)
|
||||
{
|
||||
(void)t;
|
||||
/* ADR-081 L3: publish a HEALTH mesh message every slow tick
|
||||
* (default 30 s). The coordinator uses these to track liveness and
|
||||
* detect sync-error drift. */
|
||||
uint8_t nid[8];
|
||||
node_id_bytes(nid);
|
||||
rv_mesh_send_health(s_role, s_mesh_epoch, nid);
|
||||
|
||||
ESP_LOGI(TAG, "slow tick (state=%u, feature_state_seq=%u, role=%u, epoch=%u) HEALTH sent",
|
||||
(unsigned)s_state, (unsigned)s_feature_state_seq,
|
||||
(unsigned)s_role, (unsigned)s_mesh_epoch);
|
||||
}
|
||||
|
||||
/* ---- 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 */
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @file adaptive_controller_decide.c
|
||||
* @brief ADR-081 Layer 2 — pure decision policy.
|
||||
*
|
||||
* Extracted so host unit tests can link this without ESP-IDF / FreeRTOS.
|
||||
* adaptive_controller.c includes this file; the host Makefile links it
|
||||
* directly against the test harness.
|
||||
*/
|
||||
|
||||
#include <string.h>
|
||||
#include "adaptive_controller.h"
|
||||
#include "rv_radio_ops.h"
|
||||
|
||||
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: pkt yield collapse or 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 presence + quiet → high-sensitivity respiration. */
|
||||
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;
|
||||
}
|
||||
@@ -308,6 +308,43 @@ uint8_t csi_collector_get_node_id(void)
|
||||
return s_node_id;
|
||||
}
|
||||
|
||||
/* ---- ADR-081: packet yield accessor for the radio abstraction layer ---- */
|
||||
|
||||
uint16_t csi_collector_get_pkt_yield_per_sec(void)
|
||||
{
|
||||
/* Simple sliding window: record the callback count at ~1 s ago, return
|
||||
* the delta. Called from adaptive_controller's fast loop (200 ms), so
|
||||
* we update the snapshot every ~5 calls. */
|
||||
static int64_t s_yield_window_start_us = 0;
|
||||
static uint32_t s_yield_window_start_cb = 0;
|
||||
static uint16_t s_last_yield = 0;
|
||||
|
||||
int64_t now = esp_timer_get_time();
|
||||
if (s_yield_window_start_us == 0) {
|
||||
s_yield_window_start_us = now;
|
||||
s_yield_window_start_cb = s_cb_count;
|
||||
return 0;
|
||||
}
|
||||
int64_t elapsed = now - s_yield_window_start_us;
|
||||
if (elapsed < 1000000LL) {
|
||||
return s_last_yield;
|
||||
}
|
||||
uint32_t delta = s_cb_count - s_yield_window_start_cb;
|
||||
/* Scale back to per-second if the window ran long (shouldn't, but be safe). */
|
||||
uint64_t per_sec = ((uint64_t)delta * 1000000ULL) / (uint64_t)elapsed;
|
||||
if (per_sec > 0xFFFFu) per_sec = 0xFFFFu;
|
||||
s_last_yield = (uint16_t)per_sec;
|
||||
s_yield_window_start_us = now;
|
||||
s_yield_window_start_cb = s_cb_count;
|
||||
return s_last_yield;
|
||||
}
|
||||
|
||||
uint16_t csi_collector_get_send_fail_count(void)
|
||||
{
|
||||
uint32_t f = s_send_fail;
|
||||
return (f > 0xFFFFu) ? 0xFFFFu : (uint16_t)f;
|
||||
}
|
||||
|
||||
/* ---- ADR-029: Channel hopping ---- */
|
||||
|
||||
void csi_collector_set_hop_table(const uint8_t *channels, uint8_t hop_count, uint32_t dwell_ms)
|
||||
|
||||
@@ -94,4 +94,23 @@ void csi_collector_start_hop_timer(void);
|
||||
*/
|
||||
esp_err_t csi_inject_ndp_frame(void);
|
||||
|
||||
/**
|
||||
* Get the recent CSI callback rate (per second).
|
||||
*
|
||||
* Computed as a sliding 1-second window over the internal s_cb_count
|
||||
* counter. Used by the ADR-081 radio abstraction layer to fill the
|
||||
* pkt_yield_per_sec field of rv_radio_health_t.
|
||||
*
|
||||
* @return Callbacks observed in the trailing ~1 second.
|
||||
*/
|
||||
uint16_t csi_collector_get_pkt_yield_per_sec(void);
|
||||
|
||||
/**
|
||||
* Get the cumulative UDP send-failure counter since boot.
|
||||
*
|
||||
* @return Number of stream_sender_send() failures recorded by the
|
||||
* CSI callback path.
|
||||
*/
|
||||
uint16_t csi_collector_get_send_fail_count(void);
|
||||
|
||||
#endif /* CSI_COLLECTOR_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,31 @@ void app_main(void)
|
||||
ESP_LOGI(TAG, "Mock CSI mode: skipping swarm bridge");
|
||||
#endif
|
||||
|
||||
/* ADR-081 Layer 1: register the active radio ops binding.
|
||||
* - Real hardware: ESP32 binding wrapping csi_collector + esp_wifi.
|
||||
* - QEMU / offline: mock binding wrapping mock_csi.c.
|
||||
* Either way, the layers above (adaptive controller, mesh plane,
|
||||
* feature extraction) address the radio through the same vtable —
|
||||
* this is the portability acceptance test in ADR-081. */
|
||||
#ifdef CONFIG_CSI_MOCK_ENABLED
|
||||
rv_radio_ops_mock_register();
|
||||
#else
|
||||
rv_radio_ops_esp32_register();
|
||||
#endif
|
||||
const rv_radio_ops_t *radio_ops = rv_radio_ops_get();
|
||||
if (radio_ops != NULL && radio_ops->init != NULL) {
|
||||
radio_ops->init();
|
||||
}
|
||||
|
||||
/* 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 +316,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) == 60,
|
||||
"rv_feature_state_t must be 60 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,251 @@
|
||||
/**
|
||||
* @file rv_mesh.c
|
||||
* @brief ADR-081 Layer 3 — Mesh Sensing Plane implementation.
|
||||
*
|
||||
* Encoder/decoder are pure functions (no ESP-IDF deps) and therefore
|
||||
* host-unit-testable. The send helpers wrap stream_sender so the
|
||||
* firmware can use a single upstream socket for all payload types.
|
||||
*/
|
||||
|
||||
#include "rv_mesh.h"
|
||||
#include "rv_feature_state.h"
|
||||
#include "rv_radio_ops.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#ifndef RV_MESH_HOST_TEST
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "stream_sender.h"
|
||||
#include "csi_collector.h"
|
||||
#include "adaptive_controller.h"
|
||||
static const char *TAG = "rv_mesh";
|
||||
#endif
|
||||
|
||||
/* ---- Encoder ---- */
|
||||
|
||||
size_t rv_mesh_encode(uint8_t type,
|
||||
uint8_t sender_role,
|
||||
uint8_t auth_class,
|
||||
uint32_t epoch,
|
||||
const void *payload,
|
||||
uint16_t payload_len,
|
||||
uint8_t *buf,
|
||||
size_t buf_cap)
|
||||
{
|
||||
if (buf == NULL) return 0;
|
||||
if (payload == NULL && payload_len != 0) return 0;
|
||||
if (payload_len > RV_MESH_MAX_PAYLOAD) return 0;
|
||||
|
||||
size_t total = sizeof(rv_mesh_header_t) + (size_t)payload_len + 4u;
|
||||
if (buf_cap < total) return 0;
|
||||
|
||||
rv_mesh_header_t hdr;
|
||||
hdr.magic = RV_MESH_MAGIC;
|
||||
hdr.version = (uint8_t)RV_MESH_VERSION;
|
||||
hdr.type = type;
|
||||
hdr.sender_role = sender_role;
|
||||
hdr.auth_class = auth_class;
|
||||
hdr.epoch = epoch;
|
||||
hdr.payload_len = payload_len;
|
||||
hdr.reserved = 0;
|
||||
|
||||
memcpy(buf, &hdr, sizeof(hdr));
|
||||
if (payload_len > 0) {
|
||||
memcpy(buf + sizeof(hdr), payload, payload_len);
|
||||
}
|
||||
|
||||
/* IEEE CRC32 over header + payload. Reuses the CRC32 from
|
||||
* rv_feature_state.c so there is exactly one implementation. */
|
||||
uint32_t crc = rv_feature_state_crc32(buf, sizeof(hdr) + payload_len);
|
||||
memcpy(buf + sizeof(hdr) + payload_len, &crc, 4);
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
esp_err_t rv_mesh_decode(const uint8_t *buf, size_t buf_len,
|
||||
rv_mesh_header_t *out_hdr,
|
||||
const uint8_t **out_payload,
|
||||
uint16_t *out_payload_len)
|
||||
{
|
||||
if (buf == NULL || out_hdr == NULL ||
|
||||
out_payload == NULL || out_payload_len == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (buf_len < sizeof(rv_mesh_header_t) + 4u) {
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
rv_mesh_header_t hdr;
|
||||
memcpy(&hdr, buf, sizeof(hdr));
|
||||
|
||||
if (hdr.magic != RV_MESH_MAGIC) {
|
||||
return ESP_ERR_INVALID_VERSION; /* repurpose: wrong magic */
|
||||
}
|
||||
if (hdr.version != RV_MESH_VERSION) {
|
||||
return ESP_ERR_INVALID_VERSION;
|
||||
}
|
||||
if (hdr.payload_len > RV_MESH_MAX_PAYLOAD) {
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
size_t needed = sizeof(hdr) + (size_t)hdr.payload_len + 4u;
|
||||
if (buf_len < needed) {
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
uint32_t got_crc;
|
||||
memcpy(&got_crc, buf + sizeof(hdr) + hdr.payload_len, 4);
|
||||
uint32_t want_crc = rv_feature_state_crc32(buf,
|
||||
sizeof(hdr) + hdr.payload_len);
|
||||
if (got_crc != want_crc) {
|
||||
return ESP_ERR_INVALID_CRC;
|
||||
}
|
||||
|
||||
*out_hdr = hdr;
|
||||
*out_payload = (hdr.payload_len > 0) ? buf + sizeof(hdr) : NULL;
|
||||
*out_payload_len = hdr.payload_len;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* ---- Typed convenience encoders ---- */
|
||||
|
||||
size_t rv_mesh_encode_health(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_node_status_t *status,
|
||||
uint8_t *buf, size_t buf_cap)
|
||||
{
|
||||
if (status == NULL) return 0;
|
||||
return rv_mesh_encode(RV_MSG_HEALTH, sender_role, RV_AUTH_NONE,
|
||||
epoch, status, sizeof(*status), buf, buf_cap);
|
||||
}
|
||||
|
||||
size_t rv_mesh_encode_anomaly_alert(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_anomaly_alert_t *alert,
|
||||
uint8_t *buf, size_t buf_cap)
|
||||
{
|
||||
if (alert == NULL) return 0;
|
||||
return rv_mesh_encode(RV_MSG_ANOMALY_ALERT, sender_role, RV_AUTH_NONE,
|
||||
epoch, alert, sizeof(*alert), buf, buf_cap);
|
||||
}
|
||||
|
||||
size_t rv_mesh_encode_feature_delta(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_feature_state_t *fs,
|
||||
uint8_t *buf, size_t buf_cap)
|
||||
{
|
||||
if (fs == NULL) return 0;
|
||||
return rv_mesh_encode(RV_MSG_FEATURE_DELTA, sender_role, RV_AUTH_NONE,
|
||||
epoch, fs, sizeof(*fs), buf, buf_cap);
|
||||
}
|
||||
|
||||
size_t rv_mesh_encode_time_sync(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_time_sync_t *ts,
|
||||
uint8_t *buf, size_t buf_cap)
|
||||
{
|
||||
if (ts == NULL) return 0;
|
||||
return rv_mesh_encode(RV_MSG_TIME_SYNC, sender_role, RV_AUTH_HMAC_SESSION,
|
||||
epoch, ts, sizeof(*ts), buf, buf_cap);
|
||||
}
|
||||
|
||||
size_t rv_mesh_encode_role_assign(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_role_assign_t *ra,
|
||||
uint8_t *buf, size_t buf_cap)
|
||||
{
|
||||
if (ra == NULL) return 0;
|
||||
return rv_mesh_encode(RV_MSG_ROLE_ASSIGN, sender_role, RV_AUTH_HMAC_SESSION,
|
||||
epoch, ra, sizeof(*ra), buf, buf_cap);
|
||||
}
|
||||
|
||||
size_t rv_mesh_encode_channel_plan(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_channel_plan_t *cp,
|
||||
uint8_t *buf, size_t buf_cap)
|
||||
{
|
||||
if (cp == NULL) return 0;
|
||||
return rv_mesh_encode(RV_MSG_CHANNEL_PLAN, sender_role, RV_AUTH_ED25519_BATCH,
|
||||
epoch, cp, sizeof(*cp), buf, buf_cap);
|
||||
}
|
||||
|
||||
size_t rv_mesh_encode_calibration_start(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_calibration_start_t *cs,
|
||||
uint8_t *buf, size_t buf_cap)
|
||||
{
|
||||
if (cs == NULL) return 0;
|
||||
return rv_mesh_encode(RV_MSG_CALIBRATION_START, sender_role,
|
||||
RV_AUTH_ED25519_BATCH, epoch, cs, sizeof(*cs),
|
||||
buf, buf_cap);
|
||||
}
|
||||
|
||||
/* ---- Send helpers (firmware-only; hidden from host tests) ---- */
|
||||
|
||||
#ifndef RV_MESH_HOST_TEST
|
||||
|
||||
esp_err_t rv_mesh_send(const uint8_t *frame, size_t len)
|
||||
{
|
||||
if (frame == NULL || len == 0) return ESP_ERR_INVALID_ARG;
|
||||
int sent = stream_sender_send(frame, len);
|
||||
if (sent < 0) {
|
||||
ESP_LOGW(TAG, "rv_mesh_send: stream_sender failed (len=%u)",
|
||||
(unsigned)len);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t rv_mesh_send_health(uint8_t role, uint32_t epoch,
|
||||
const uint8_t node_id[8])
|
||||
{
|
||||
if (node_id == NULL) return ESP_ERR_INVALID_ARG;
|
||||
|
||||
rv_node_status_t st;
|
||||
memset(&st, 0, sizeof(st));
|
||||
memcpy(st.node_id, node_id, 8);
|
||||
st.local_time_us = (uint64_t)esp_timer_get_time();
|
||||
st.role = role;
|
||||
|
||||
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) {
|
||||
st.current_channel = h.current_channel;
|
||||
st.current_bw = h.current_bw_mhz;
|
||||
st.noise_floor_dbm = h.noise_floor_dbm;
|
||||
st.pkt_yield = h.pkt_yield_per_sec;
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t buf[RV_MESH_MAX_FRAME_BYTES];
|
||||
size_t n = rv_mesh_encode_health(role, epoch, &st, buf, sizeof(buf));
|
||||
if (n == 0) return ESP_FAIL;
|
||||
return rv_mesh_send(buf, n);
|
||||
}
|
||||
|
||||
esp_err_t rv_mesh_send_anomaly(uint8_t role, uint32_t epoch,
|
||||
const uint8_t node_id[8],
|
||||
uint8_t reason,
|
||||
uint8_t severity,
|
||||
float anomaly_score,
|
||||
float motion_score)
|
||||
{
|
||||
if (node_id == NULL) return ESP_ERR_INVALID_ARG;
|
||||
rv_anomaly_alert_t a;
|
||||
memset(&a, 0, sizeof(a));
|
||||
memcpy(a.node_id, node_id, 8);
|
||||
a.ts_us = (uint64_t)esp_timer_get_time();
|
||||
a.reason = reason;
|
||||
a.severity = severity;
|
||||
a.anomaly_score = anomaly_score;
|
||||
a.motion_score = motion_score;
|
||||
|
||||
uint8_t buf[RV_MESH_MAX_FRAME_BYTES];
|
||||
size_t n = rv_mesh_encode_anomaly_alert(role, epoch, &a, buf, sizeof(buf));
|
||||
if (n == 0) return ESP_FAIL;
|
||||
return rv_mesh_send(buf, n);
|
||||
}
|
||||
|
||||
#endif /* !RV_MESH_HOST_TEST */
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* @file rv_mesh.h
|
||||
* @brief ADR-081 Layer 3 — Mesh Sensing Plane.
|
||||
*
|
||||
* Defines node roles, the 7 on-wire message types, and the
|
||||
* rv_node_status_t health payload that nodes exchange to behave as a
|
||||
* distributed sensor rather than a collection of independent radios.
|
||||
*
|
||||
* Framing: every mesh message starts with rv_mesh_header_t (magic,
|
||||
* version, type, sender_role, epoch, length) so a receiver can dispatch
|
||||
* without reading the whole body. The trailing 4 bytes of every message
|
||||
* are an IEEE CRC32 over the preceding bytes. Authentication
|
||||
* (HMAC-SHA256 + replay window) is layered on top by
|
||||
* wifi-densepose-hardware/src/esp32/secure_tdm.rs (ADR-032) for control
|
||||
* messages that cross the swarm; FEATURE_DELTA uses the integrity
|
||||
* protection already present in rv_feature_state_t (CRC + monotonic seq).
|
||||
*/
|
||||
|
||||
#ifndef RV_MESH_H
|
||||
#define RV_MESH_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include "esp_err.h"
|
||||
#include "rv_feature_state.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ---- Magic + version ---- */
|
||||
|
||||
/** ADR-081 mesh envelope magic. Distinct from the ADR-018 CSI magic. */
|
||||
#define RV_MESH_MAGIC 0xC5118100u
|
||||
|
||||
/** Protocol version. Bumped on any wire-format change. */
|
||||
#define RV_MESH_VERSION 1u
|
||||
|
||||
/** Maximum mesh payload size (excluding header + CRC). */
|
||||
#define RV_MESH_MAX_PAYLOAD 256u
|
||||
|
||||
/* ---- Node roles (ADR-081 Layer 3) ---- */
|
||||
|
||||
typedef enum {
|
||||
RV_ROLE_UNASSIGNED = 0,
|
||||
RV_ROLE_ANCHOR = 1, /**< Emits timed probes + global time beacons. */
|
||||
RV_ROLE_OBSERVER = 2, /**< Captures CSI + local metadata. */
|
||||
RV_ROLE_FUSION_RELAY = 3, /**< Aggregates summaries, forwards deltas. */
|
||||
RV_ROLE_COORDINATOR = 4, /**< Elects channels, assigns roles. */
|
||||
RV_ROLE_COUNT
|
||||
} rv_mesh_role_t;
|
||||
|
||||
/* ---- Authorization classes for control messages ---- */
|
||||
|
||||
typedef enum {
|
||||
RV_AUTH_NONE = 0, /**< Telemetry; integrity via CRC only. */
|
||||
RV_AUTH_HMAC_SESSION = 1, /**< HMAC-SHA256 with session key (ADR-032). */
|
||||
RV_AUTH_ED25519_BATCH = 2, /**< Ed25519 signature at batch/session. */
|
||||
} rv_mesh_auth_class_t;
|
||||
|
||||
/* ---- Message types ---- */
|
||||
|
||||
typedef enum {
|
||||
RV_MSG_TIME_SYNC = 0x01,
|
||||
RV_MSG_ROLE_ASSIGN = 0x02,
|
||||
RV_MSG_CHANNEL_PLAN = 0x03,
|
||||
RV_MSG_CALIBRATION_START = 0x04,
|
||||
RV_MSG_FEATURE_DELTA = 0x05, /**< Carries rv_feature_state_t. */
|
||||
RV_MSG_HEALTH = 0x06,
|
||||
RV_MSG_ANOMALY_ALERT = 0x07,
|
||||
} rv_mesh_msg_type_t;
|
||||
|
||||
/* ---- Common envelope header (16 bytes) ---- */
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint32_t magic; /**< RV_MESH_MAGIC. */
|
||||
uint8_t version; /**< RV_MESH_VERSION. */
|
||||
uint8_t type; /**< rv_mesh_msg_type_t. */
|
||||
uint8_t sender_role; /**< rv_mesh_role_t of the sender at send time. */
|
||||
uint8_t auth_class; /**< rv_mesh_auth_class_t. */
|
||||
uint32_t epoch; /**< Monotonic epoch or session counter. */
|
||||
uint16_t payload_len; /**< Body length excluding header + trailing CRC. */
|
||||
uint16_t reserved;
|
||||
} rv_mesh_header_t;
|
||||
|
||||
_Static_assert(sizeof(rv_mesh_header_t) == 16,
|
||||
"rv_mesh_header_t must be 16 bytes");
|
||||
|
||||
/* ---- Node health payload (RV_MSG_HEALTH) ---- */
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint8_t node_id[8]; /**< 8-byte node identity. */
|
||||
uint64_t local_time_us; /**< Sender-local microseconds. */
|
||||
uint8_t role; /**< rv_mesh_role_t. */
|
||||
uint8_t current_channel;
|
||||
uint8_t current_bw; /**< MHz (20, 40). */
|
||||
int8_t noise_floor_dbm;
|
||||
uint16_t pkt_yield; /**< CSI callbacks/sec over the last window. */
|
||||
uint16_t sync_error_us; /**< Absolute drift vs. anchor. */
|
||||
uint16_t health_flags;
|
||||
uint16_t reserved;
|
||||
} rv_node_status_t;
|
||||
|
||||
_Static_assert(sizeof(rv_node_status_t) == 28,
|
||||
"rv_node_status_t must be 28 bytes");
|
||||
|
||||
/* ---- TIME_SYNC payload ---- */
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint64_t anchor_time_us; /**< Anchor's local µs at emit. */
|
||||
uint32_t cycle_id;
|
||||
uint32_t cycle_period_us;
|
||||
} rv_time_sync_t;
|
||||
|
||||
_Static_assert(sizeof(rv_time_sync_t) == 16,
|
||||
"rv_time_sync_t must be 16 bytes");
|
||||
|
||||
/* ---- ROLE_ASSIGN payload ---- */
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint8_t target_node_id[8];
|
||||
uint8_t new_role; /**< rv_mesh_role_t. */
|
||||
uint8_t reserved[3];
|
||||
uint32_t effective_epoch;
|
||||
} rv_role_assign_t;
|
||||
|
||||
_Static_assert(sizeof(rv_role_assign_t) == 16,
|
||||
"rv_role_assign_t must be 16 bytes");
|
||||
|
||||
/* ---- CHANNEL_PLAN payload ---- */
|
||||
|
||||
#define RV_CHANNEL_PLAN_MAX 8
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint8_t target_node_id[8];
|
||||
uint8_t channel_count;
|
||||
uint8_t dwell_ms_hi; /**< dwell_ms, big-endian to fit u16 in two bytes */
|
||||
uint8_t dwell_ms_lo;
|
||||
uint8_t debug_raw_csi; /**< 1 = enable raw ADR-018 stream; 0 = feature_state only. */
|
||||
uint8_t channels[RV_CHANNEL_PLAN_MAX];
|
||||
uint32_t effective_epoch;
|
||||
} rv_channel_plan_t;
|
||||
|
||||
_Static_assert(sizeof(rv_channel_plan_t) == 24,
|
||||
"rv_channel_plan_t must be 24 bytes");
|
||||
|
||||
/* ---- CALIBRATION_START payload ---- */
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint64_t t0_anchor_us; /**< Start time on anchor clock. */
|
||||
uint32_t duration_ms;
|
||||
uint32_t effective_epoch;
|
||||
uint8_t calibration_profile; /**< rv_capture_profile_t (usually CALIBRATION). */
|
||||
uint8_t reserved[3];
|
||||
} rv_calibration_start_t;
|
||||
|
||||
_Static_assert(sizeof(rv_calibration_start_t) == 20,
|
||||
"rv_calibration_start_t must be 20 bytes");
|
||||
|
||||
/* ---- ANOMALY_ALERT payload ---- */
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint8_t node_id[8];
|
||||
uint64_t ts_us;
|
||||
uint8_t severity; /**< 0..255 scaled anomaly. */
|
||||
uint8_t reason; /**< rv_anomaly_reason_t. */
|
||||
uint16_t reserved;
|
||||
float anomaly_score;
|
||||
float motion_score;
|
||||
} rv_anomaly_alert_t;
|
||||
|
||||
_Static_assert(sizeof(rv_anomaly_alert_t) == 28,
|
||||
"rv_anomaly_alert_t must be 28 bytes");
|
||||
|
||||
typedef enum {
|
||||
RV_ANOMALY_NONE = 0,
|
||||
RV_ANOMALY_PHYSICS_VIOLATION = 1,
|
||||
RV_ANOMALY_MULTI_LINK_MISMATCH = 2,
|
||||
RV_ANOMALY_PKT_YIELD_COLLAPSE = 3,
|
||||
RV_ANOMALY_FALL = 4,
|
||||
RV_ANOMALY_COHERENCE_LOSS = 5,
|
||||
} rv_anomaly_reason_t;
|
||||
|
||||
/* ---- Encoder / decoder API ---- */
|
||||
|
||||
/** Maximum on-wire mesh frame: header + max payload + crc. */
|
||||
#define RV_MESH_MAX_FRAME_BYTES (sizeof(rv_mesh_header_t) + RV_MESH_MAX_PAYLOAD + 4u)
|
||||
|
||||
/**
|
||||
* Encode a typed mesh message into a contiguous buffer.
|
||||
*
|
||||
* Writes header(16) + payload(payload_len) + crc32(4). The caller owns
|
||||
* the buffer; buf_cap must be at least sizeof(rv_mesh_header_t) +
|
||||
* payload_len + 4. The payload pointer may be NULL iff payload_len == 0.
|
||||
*
|
||||
* @return bytes written on success, or 0 on error (bad args / overflow).
|
||||
*/
|
||||
size_t rv_mesh_encode(uint8_t type,
|
||||
uint8_t sender_role,
|
||||
uint8_t auth_class,
|
||||
uint32_t epoch,
|
||||
const void *payload,
|
||||
uint16_t payload_len,
|
||||
uint8_t *buf,
|
||||
size_t buf_cap);
|
||||
|
||||
/**
|
||||
* Validate + parse a mesh frame received from the wire.
|
||||
*
|
||||
* Checks magic, version, sizeof(rv_mesh_header_t) bounds, payload_len
|
||||
* bounds, and CRC32. On success, fills *out_hdr with the header and sets
|
||||
* *out_payload to point at the payload inside buf (aliasing, not copied)
|
||||
* plus *out_payload_len to the payload byte count.
|
||||
*
|
||||
* @return ESP_OK on success, or an ESP_ERR_* code on failure.
|
||||
*/
|
||||
esp_err_t rv_mesh_decode(const uint8_t *buf, size_t buf_len,
|
||||
rv_mesh_header_t *out_hdr,
|
||||
const uint8_t **out_payload,
|
||||
uint16_t *out_payload_len);
|
||||
|
||||
/**
|
||||
* Convenience helpers — encode a specific message type into buf.
|
||||
* Each returns the number of bytes written, 0 on error.
|
||||
*/
|
||||
size_t rv_mesh_encode_health(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_node_status_t *status,
|
||||
uint8_t *buf, size_t buf_cap);
|
||||
|
||||
size_t rv_mesh_encode_anomaly_alert(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_anomaly_alert_t *alert,
|
||||
uint8_t *buf, size_t buf_cap);
|
||||
|
||||
size_t rv_mesh_encode_feature_delta(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_feature_state_t *fs,
|
||||
uint8_t *buf, size_t buf_cap);
|
||||
|
||||
size_t rv_mesh_encode_time_sync(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_time_sync_t *ts,
|
||||
uint8_t *buf, size_t buf_cap);
|
||||
|
||||
size_t rv_mesh_encode_role_assign(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_role_assign_t *ra,
|
||||
uint8_t *buf, size_t buf_cap);
|
||||
|
||||
size_t rv_mesh_encode_channel_plan(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_channel_plan_t *cp,
|
||||
uint8_t *buf, size_t buf_cap);
|
||||
|
||||
size_t rv_mesh_encode_calibration_start(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_calibration_start_t *cs,
|
||||
uint8_t *buf, size_t buf_cap);
|
||||
|
||||
/* ---- Send API ---- */
|
||||
|
||||
/**
|
||||
* Send a pre-encoded mesh frame over the primary upstream UDP socket
|
||||
* (the same one stream_sender uses for ADR-018 and rv_feature_state_t).
|
||||
*
|
||||
* @return ESP_OK on success.
|
||||
*/
|
||||
esp_err_t rv_mesh_send(const uint8_t *frame, size_t len);
|
||||
|
||||
/**
|
||||
* Convenience: build + send a HEALTH message for this node.
|
||||
*
|
||||
* Fills the rv_node_status_t from the live radio ops + controller
|
||||
* observation, then encodes and sends in one call. Safe to call from a
|
||||
* FreeRTOS timer.
|
||||
*/
|
||||
esp_err_t rv_mesh_send_health(uint8_t role, uint32_t epoch,
|
||||
const uint8_t node_id[8]);
|
||||
|
||||
/**
|
||||
* Convenience: build + send an ANOMALY_ALERT.
|
||||
*/
|
||||
esp_err_t rv_mesh_send_anomaly(uint8_t role, uint32_t epoch,
|
||||
const uint8_t node_id[8],
|
||||
uint8_t reason,
|
||||
uint8_t severity,
|
||||
float anomaly_score,
|
||||
float motion_score);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* RV_MESH_H */
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @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);
|
||||
|
||||
/**
|
||||
* Register the mock binding (QEMU / offline) as the active radio ops.
|
||||
*
|
||||
* Defined in rv_radio_ops_mock.c; only built when CONFIG_CSI_MOCK_ENABLED.
|
||||
*/
|
||||
void rv_radio_ops_mock_register(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* RV_RADIO_OPS_H */
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @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));
|
||||
|
||||
out->pkt_yield_per_sec = csi_collector_get_pkt_yield_per_sec();
|
||||
out->send_fail_count = csi_collector_get_send_fail_count();
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @file rv_radio_ops_mock.c
|
||||
* @brief ADR-081 Layer 1 — Mock binding for QEMU / offline testing.
|
||||
*
|
||||
* When CONFIG_CSI_MOCK_ENABLED is set (ADR-061 QEMU flow), there is no
|
||||
* real WiFi driver to wrap. This binding provides the same ops table as
|
||||
* the ESP32 binding but records state into in-process statics and
|
||||
* accepts every call. It exists primarily to satisfy ADR-081's
|
||||
* portability acceptance test: a second binding must compile against
|
||||
* the same controller and mesh-plane code without modification.
|
||||
*
|
||||
* Only compiled when CONFIG_CSI_MOCK_ENABLED is set. Registered from
|
||||
* main.c in the mock branch.
|
||||
*/
|
||||
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#ifdef CONFIG_CSI_MOCK_ENABLED
|
||||
|
||||
#include "rv_radio_ops.h"
|
||||
#include "mock_csi.h"
|
||||
|
||||
#include <string.h>
|
||||
#include "esp_err.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
static const char *TAG = "rv_radio_mock";
|
||||
|
||||
static uint8_t s_channel = 6;
|
||||
static uint8_t s_bw = 20;
|
||||
static uint8_t s_profile = RV_PROFILE_PASSIVE_LOW_RATE;
|
||||
static uint8_t s_mode = RV_RADIO_MODE_PASSIVE_RX;
|
||||
static bool s_csi_on = true;
|
||||
|
||||
static int mock_init(void)
|
||||
{
|
||||
ESP_LOGI(TAG, "mock radio ops: init");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static int mock_set_channel(uint8_t ch, uint8_t bw)
|
||||
{
|
||||
s_channel = ch;
|
||||
s_bw = (bw == 40) ? 40 : 20;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static int mock_set_mode(uint8_t mode)
|
||||
{
|
||||
s_mode = mode;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static int mock_set_csi_enabled(bool en)
|
||||
{
|
||||
s_csi_on = en;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static int mock_set_capture_profile(uint8_t profile_id)
|
||||
{
|
||||
if (profile_id >= RV_PROFILE_COUNT) return ESP_ERR_INVALID_ARG;
|
||||
s_profile = profile_id;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static int mock_get_health(rv_radio_health_t *out)
|
||||
{
|
||||
if (out == NULL) return ESP_ERR_INVALID_ARG;
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
/* Mock yield: mirror mock_csi's generator rate so the adaptive
|
||||
* controller sees a sensible pkt_yield in QEMU. */
|
||||
out->pkt_yield_per_sec = 20; /* MOCK_CSI_INTERVAL_MS = 50 → 20 Hz */
|
||||
out->rssi_median_dbm = -55;
|
||||
out->noise_floor_dbm = -95;
|
||||
out->current_channel = s_channel;
|
||||
out->current_bw_mhz = s_bw;
|
||||
out->current_profile = s_profile;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static const rv_radio_ops_t s_mock_ops = {
|
||||
.init = mock_init,
|
||||
.set_channel = mock_set_channel,
|
||||
.set_mode = mock_set_mode,
|
||||
.set_csi_enabled = mock_set_csi_enabled,
|
||||
.set_capture_profile = mock_set_capture_profile,
|
||||
.get_health = mock_get_health,
|
||||
};
|
||||
|
||||
void rv_radio_ops_mock_register(void)
|
||||
{
|
||||
rv_radio_ops_register(&s_mock_ops);
|
||||
ESP_LOGI(TAG, "mock radio ops registered (QEMU / offline mode)");
|
||||
}
|
||||
|
||||
#endif /* CONFIG_CSI_MOCK_ENABLED */
|
||||
@@ -0,0 +1,5 @@
|
||||
# Compiled host-test binaries
|
||||
test_adaptive_controller
|
||||
test_rv_feature_state
|
||||
test_rv_mesh
|
||||
*.o
|
||||
@@ -0,0 +1,59 @@
|
||||
# Host-side unit tests for ADR-081 pure-C logic.
|
||||
#
|
||||
# These tests exercise adaptive_controller_decide() and the rv_feature_state
|
||||
# helpers (CRC32, finalize) using plain gcc/clang, with a minimal esp_err.h
|
||||
# shim. No ESP-IDF, no FreeRTOS, no QEMU required.
|
||||
#
|
||||
# Usage:
|
||||
# cd firmware/esp32-csi-node/tests/host
|
||||
# make
|
||||
# ./test_adaptive_controller
|
||||
# ./test_rv_feature_state
|
||||
|
||||
MAIN_DIR := ../../main
|
||||
CC ?= cc
|
||||
CFLAGS ?= -O2 -std=c11 -Wall -Wextra -Wno-unused-parameter \
|
||||
-D_POSIX_C_SOURCE=199309L \
|
||||
-I. -I$(MAIN_DIR)
|
||||
LDLIBS ?= -lrt
|
||||
|
||||
# Pure-C sources under test. We compile only the files that have no
|
||||
# ESP-IDF dependency in their bodies: rv_feature_state.c is 100% pure.
|
||||
# adaptive_controller.c uses FreeRTOS for the timer plumbing, so for the
|
||||
# host test we compile only the decide() portion by isolating it in a
|
||||
# small unity file (TEST_ADAPT_PURE below).
|
||||
FEATURE_STATE_SRCS := $(MAIN_DIR)/rv_feature_state.c
|
||||
|
||||
# adaptive_controller.c pulls in FreeRTOS headers that don't exist on
|
||||
# host; we include its decide() function by defining TEST_ADAPT_PURE
|
||||
# before including the .c. The decide() body itself has no ESP-IDF deps.
|
||||
# Simpler: just recompile decide() here via a small shim.
|
||||
|
||||
TESTS := test_adaptive_controller test_rv_feature_state test_rv_mesh
|
||||
|
||||
all: $(TESTS)
|
||||
|
||||
test_adaptive_controller: test_adaptive_controller.c $(MAIN_DIR)/adaptive_controller_decide.c $(MAIN_DIR)/adaptive_controller.h $(MAIN_DIR)/rv_radio_ops.h
|
||||
$(CC) $(CFLAGS) test_adaptive_controller.c $(MAIN_DIR)/adaptive_controller_decide.c -o $@ $(LDLIBS)
|
||||
|
||||
test_rv_feature_state: test_rv_feature_state.c $(FEATURE_STATE_SRCS) $(MAIN_DIR)/rv_feature_state.h $(MAIN_DIR)/rv_radio_ops.h
|
||||
$(CC) $(CFLAGS) test_rv_feature_state.c $(FEATURE_STATE_SRCS) -o $@ $(LDLIBS)
|
||||
|
||||
# Mesh plane encoder/decoder: compile rv_mesh.c with RV_MESH_HOST_TEST
|
||||
# so the firmware-only send helpers (stream_sender, esp_log) are hidden.
|
||||
test_rv_mesh: test_rv_mesh.c $(MAIN_DIR)/rv_mesh.c $(MAIN_DIR)/rv_mesh.h $(FEATURE_STATE_SRCS) $(MAIN_DIR)/rv_radio_ops.h
|
||||
$(CC) $(CFLAGS) -DRV_MESH_HOST_TEST=1 \
|
||||
test_rv_mesh.c $(MAIN_DIR)/rv_mesh.c $(FEATURE_STATE_SRCS) \
|
||||
-o $@ $(LDLIBS)
|
||||
|
||||
check: all
|
||||
./test_adaptive_controller
|
||||
@echo ""
|
||||
./test_rv_feature_state
|
||||
@echo ""
|
||||
./test_rv_mesh
|
||||
|
||||
clean:
|
||||
rm -f $(TESTS) *.o
|
||||
|
||||
.PHONY: all check clean
|
||||
@@ -0,0 +1,19 @@
|
||||
/* Host test shim for esp_err.h. Allows us to compile the pure-C
|
||||
* portions of the firmware (adaptive_controller_decide, rv_feature_state
|
||||
* CRC + finalize) under plain gcc/clang without the ESP-IDF toolchain. */
|
||||
#ifndef HOST_ESP_ERR_SHIM_H
|
||||
#define HOST_ESP_ERR_SHIM_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
typedef int esp_err_t;
|
||||
|
||||
#define ESP_OK 0
|
||||
#define ESP_FAIL -1
|
||||
#define ESP_ERR_NO_MEM 0x101
|
||||
#define ESP_ERR_INVALID_ARG 0x102
|
||||
#define ESP_ERR_INVALID_SIZE 0x104
|
||||
#define ESP_ERR_INVALID_VERSION 0x10A
|
||||
#define ESP_ERR_INVALID_CRC 0x10B
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* Host unit test for adaptive_controller_decide().
|
||||
*
|
||||
* The ADR-081 controller decision function is deliberately pure: it takes
|
||||
* (cfg, current_state, observation) and produces a decision. No FreeRTOS,
|
||||
* no ESP-IDF, no side effects. This test exercises every documented branch
|
||||
* of the policy.
|
||||
*
|
||||
* Build + run (from this directory):
|
||||
* make -f Makefile
|
||||
* ./test_adaptive_controller
|
||||
*/
|
||||
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "adaptive_controller.h"
|
||||
#include "rv_radio_ops.h"
|
||||
|
||||
static int g_pass = 0, g_fail = 0;
|
||||
|
||||
#define CHECK(cond, msg) do { \
|
||||
if (cond) { g_pass++; } \
|
||||
else { g_fail++; printf(" FAIL: %s (line %d)\n", msg, __LINE__); } \
|
||||
} while (0)
|
||||
|
||||
static adapt_config_t default_cfg(void) {
|
||||
adapt_config_t c = {
|
||||
.fast_loop_ms = 200,
|
||||
.medium_loop_ms = 1000,
|
||||
.slow_loop_ms = 30000,
|
||||
.aggressive = false,
|
||||
.enable_channel_switch = false,
|
||||
.enable_role_change = false,
|
||||
.motion_threshold = 0.20f,
|
||||
.anomaly_threshold = 0.60f,
|
||||
.min_pkt_yield = 5,
|
||||
};
|
||||
return c;
|
||||
}
|
||||
|
||||
static adapt_observation_t quiet_obs(void) {
|
||||
adapt_observation_t o = {
|
||||
.pkt_yield_per_sec = 50,
|
||||
.send_fail_count = 0,
|
||||
.rssi_median_dbm = -60,
|
||||
.noise_floor_dbm = -95,
|
||||
.motion_score = 0.01f,
|
||||
.presence_score = 0.0f,
|
||||
.anomaly_score = 0.0f,
|
||||
.node_coherence = 1.0f,
|
||||
};
|
||||
return o;
|
||||
}
|
||||
|
||||
static void test_degraded_gate_on_pkt_yield_collapse(void) {
|
||||
printf("test: degraded gate on pkt yield collapse\n");
|
||||
adapt_config_t cfg = default_cfg();
|
||||
adapt_observation_t obs = quiet_obs();
|
||||
obs.pkt_yield_per_sec = 2; /* below min_pkt_yield=5 */
|
||||
|
||||
adapt_decision_t dec;
|
||||
adaptive_controller_decide(&cfg, ADAPT_STATE_SENSE_IDLE, &obs, &dec);
|
||||
|
||||
CHECK(dec.change_state, "should change state");
|
||||
CHECK(dec.new_state == ADAPT_STATE_DEGRADED, "new state == DEGRADED");
|
||||
CHECK(dec.new_profile == RV_PROFILE_PASSIVE_LOW_RATE,
|
||||
"profile pinned to PASSIVE_LOW_RATE in degraded");
|
||||
CHECK(dec.suggested_vital_interval_ms == 2000,
|
||||
"cadence relaxed to 2s in degraded");
|
||||
}
|
||||
|
||||
static void test_degraded_gate_on_coherence_loss(void) {
|
||||
printf("test: degraded gate on coherence loss\n");
|
||||
adapt_config_t cfg = default_cfg();
|
||||
adapt_observation_t obs = quiet_obs();
|
||||
obs.node_coherence = 0.15f; /* below 0.20 threshold */
|
||||
|
||||
adapt_decision_t dec;
|
||||
adaptive_controller_decide(&cfg, ADAPT_STATE_SENSE_IDLE, &obs, &dec);
|
||||
CHECK(dec.new_state == ADAPT_STATE_DEGRADED, "coherence loss → DEGRADED");
|
||||
}
|
||||
|
||||
static void test_anomaly_trumps_motion(void) {
|
||||
printf("test: anomaly trumps motion\n");
|
||||
adapt_config_t cfg = default_cfg();
|
||||
adapt_observation_t obs = quiet_obs();
|
||||
obs.motion_score = 0.9f; /* high motion */
|
||||
obs.anomaly_score = 0.8f; /* but anomaly is above threshold */
|
||||
|
||||
adapt_decision_t dec;
|
||||
adaptive_controller_decide(&cfg, ADAPT_STATE_SENSE_IDLE, &obs, &dec);
|
||||
|
||||
CHECK(dec.new_state == ADAPT_STATE_ALERT, "anomaly → ALERT");
|
||||
CHECK(dec.new_profile == RV_PROFILE_FAST_MOTION,
|
||||
"alert uses FAST_MOTION profile");
|
||||
CHECK(dec.suggested_vital_interval_ms == 100, "alert cadence 100ms");
|
||||
}
|
||||
|
||||
static void test_motion_triggers_sense_active(void) {
|
||||
printf("test: motion → SENSE_ACTIVE\n");
|
||||
adapt_config_t cfg = default_cfg();
|
||||
adapt_observation_t obs = quiet_obs();
|
||||
obs.motion_score = 0.50f;
|
||||
|
||||
adapt_decision_t dec;
|
||||
adaptive_controller_decide(&cfg, ADAPT_STATE_SENSE_IDLE, &obs, &dec);
|
||||
|
||||
CHECK(dec.new_state == ADAPT_STATE_SENSE_ACTIVE, "motion → SENSE_ACTIVE");
|
||||
CHECK(dec.new_profile == RV_PROFILE_FAST_MOTION, "profile FAST_MOTION");
|
||||
CHECK(dec.suggested_vital_interval_ms == 200,
|
||||
"non-aggressive cadence 200ms");
|
||||
}
|
||||
|
||||
static void test_aggressive_cadence(void) {
|
||||
printf("test: aggressive cadence is tighter\n");
|
||||
adapt_config_t cfg = default_cfg();
|
||||
cfg.aggressive = true;
|
||||
adapt_observation_t obs = quiet_obs();
|
||||
obs.motion_score = 0.50f;
|
||||
|
||||
adapt_decision_t dec;
|
||||
adaptive_controller_decide(&cfg, ADAPT_STATE_SENSE_IDLE, &obs, &dec);
|
||||
CHECK(dec.suggested_vital_interval_ms == 100,
|
||||
"aggressive motion cadence 100ms");
|
||||
}
|
||||
|
||||
static void test_stable_presence_uses_resp_high_sens(void) {
|
||||
printf("test: stable presence → RESP_HIGH_SENS\n");
|
||||
adapt_config_t cfg = default_cfg();
|
||||
adapt_observation_t obs = quiet_obs();
|
||||
obs.presence_score = 0.8f;
|
||||
obs.motion_score = 0.01f;
|
||||
|
||||
adapt_decision_t dec;
|
||||
adaptive_controller_decide(&cfg, ADAPT_STATE_SENSE_IDLE, &obs, &dec);
|
||||
CHECK(dec.new_profile == RV_PROFILE_RESP_HIGH_SENS,
|
||||
"stable presence uses respiration profile");
|
||||
CHECK(dec.suggested_vital_interval_ms == 1000,
|
||||
"respiration cadence 1s");
|
||||
}
|
||||
|
||||
static void test_empty_room_default_is_passive(void) {
|
||||
printf("test: empty room → PASSIVE_LOW_RATE\n");
|
||||
adapt_config_t cfg = default_cfg();
|
||||
adapt_observation_t obs = quiet_obs();
|
||||
|
||||
adapt_decision_t dec;
|
||||
adaptive_controller_decide(&cfg, ADAPT_STATE_SENSE_IDLE, &obs, &dec);
|
||||
CHECK(dec.new_profile == RV_PROFILE_PASSIVE_LOW_RATE,
|
||||
"empty → passive low rate");
|
||||
}
|
||||
|
||||
static void test_hysteresis_no_flap(void) {
|
||||
printf("test: no change_state when already in target state\n");
|
||||
adapt_config_t cfg = default_cfg();
|
||||
adapt_observation_t obs = quiet_obs();
|
||||
obs.motion_score = 0.50f;
|
||||
|
||||
adapt_decision_t dec;
|
||||
adaptive_controller_decide(&cfg, ADAPT_STATE_SENSE_ACTIVE, &obs, &dec);
|
||||
CHECK(!dec.change_state,
|
||||
"already in SENSE_ACTIVE — no redundant change_state");
|
||||
}
|
||||
|
||||
static void test_null_safety(void) {
|
||||
printf("test: NULL args are no-ops (no crash)\n");
|
||||
adapt_decision_t dec = {0};
|
||||
adaptive_controller_decide(NULL, ADAPT_STATE_SENSE_IDLE, NULL, &dec);
|
||||
/* if we got here, no segfault — pass */
|
||||
g_pass++;
|
||||
printf(" OK\n");
|
||||
}
|
||||
|
||||
static void benchmark_decide(void) {
|
||||
printf("bench: adaptive_controller_decide() throughput\n");
|
||||
adapt_config_t cfg = default_cfg();
|
||||
adapt_observation_t obs = quiet_obs();
|
||||
adapt_decision_t dec;
|
||||
|
||||
const int N = 10000000;
|
||||
struct timespec a, b;
|
||||
clock_gettime(CLOCK_MONOTONIC, &a);
|
||||
for (int i = 0; i < N; i++) {
|
||||
/* Vary input slightly so the compiler can't fold the call. */
|
||||
obs.motion_score = (i & 0xff) / 255.0f;
|
||||
adaptive_controller_decide(&cfg, ADAPT_STATE_SENSE_IDLE, &obs, &dec);
|
||||
}
|
||||
clock_gettime(CLOCK_MONOTONIC, &b);
|
||||
double ns_per_call = ((b.tv_sec - a.tv_sec) * 1e9 +
|
||||
(b.tv_nsec - a.tv_nsec)) / (double)N;
|
||||
printf(" %d calls, %.1f ns/call\n", N, ns_per_call);
|
||||
/* Sanity: decide() is O(constant) — must be under 10us even on a
|
||||
* slow emulator. Real ESP32 will be ~100-300ns. */
|
||||
CHECK(ns_per_call < 10000.0, "decide() must be under 10us/call");
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
printf("=== adaptive_controller_decide() host tests ===\n\n");
|
||||
|
||||
test_degraded_gate_on_pkt_yield_collapse();
|
||||
test_degraded_gate_on_coherence_loss();
|
||||
test_anomaly_trumps_motion();
|
||||
test_motion_triggers_sense_active();
|
||||
test_aggressive_cadence();
|
||||
test_stable_presence_uses_resp_high_sens();
|
||||
test_empty_room_default_is_passive();
|
||||
test_hysteresis_no_flap();
|
||||
test_null_safety();
|
||||
benchmark_decide();
|
||||
|
||||
printf("\n=== result: %d pass, %d fail ===\n", g_pass, g_fail);
|
||||
return g_fail > 0 ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Host unit test for rv_feature_state_* helpers.
|
||||
*
|
||||
* Validates:
|
||||
* - Packet layout is exactly 80 bytes
|
||||
* - IEEE CRC32 matches well-known reference vectors
|
||||
* - finalize() populates magic/seq/ts/crc correctly
|
||||
* - CRC32 throughput benchmark
|
||||
*/
|
||||
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "rv_feature_state.h"
|
||||
#include "rv_radio_ops.h"
|
||||
|
||||
static int g_pass = 0, g_fail = 0;
|
||||
#define CHECK(cond, msg) do { \
|
||||
if (cond) { g_pass++; } \
|
||||
else { g_fail++; printf(" FAIL: %s (line %d)\n", msg, __LINE__); } \
|
||||
} while (0)
|
||||
|
||||
static void test_packet_size(void) {
|
||||
printf("test: rv_feature_state_t is 60 bytes on the wire\n");
|
||||
CHECK(sizeof(rv_feature_state_t) == 60, "sizeof == 60");
|
||||
}
|
||||
|
||||
static void test_crc_known_vectors(void) {
|
||||
printf("test: IEEE CRC32 known vectors\n");
|
||||
/* IEEE CRC32 of "123456789" == 0xCBF43926 (well-known). */
|
||||
uint32_t c1 = rv_feature_state_crc32((const uint8_t *)"123456789", 9);
|
||||
CHECK(c1 == 0xCBF43926u, "CRC32('123456789') == 0xCBF43926");
|
||||
|
||||
/* Empty input → 0x00000000 (before final inversion, 0xFFFFFFFF);
|
||||
* IEEE convention with post-invert → 0x00000000 reversed — but with
|
||||
* our implementation the empty-input CRC is 0x00000000 after post-
|
||||
* invert on ~0xFFFFFFFF = 0x00000000. */
|
||||
uint32_t c2 = rv_feature_state_crc32(NULL, 0);
|
||||
CHECK(c2 == 0x00000000u, "CRC32(empty) == 0");
|
||||
|
||||
/* Single zero byte: IEEE CRC32 of 0x00 = 0xD202EF8D. */
|
||||
uint8_t zero = 0;
|
||||
uint32_t c3 = rv_feature_state_crc32(&zero, 1);
|
||||
CHECK(c3 == 0xD202EF8Du, "CRC32(0x00) == 0xD202EF8D");
|
||||
}
|
||||
|
||||
static void test_finalize(void) {
|
||||
printf("test: finalize populates required fields\n");
|
||||
rv_feature_state_t pkt;
|
||||
memset(&pkt, 0, sizeof(pkt));
|
||||
pkt.motion_score = 0.25f;
|
||||
pkt.presence_score = 0.75f;
|
||||
pkt.respiration_bpm = 14.5f;
|
||||
pkt.quality_flags = RV_QFLAG_PRESENCE_VALID | RV_QFLAG_RESPIRATION_VALID;
|
||||
|
||||
rv_feature_state_finalize(&pkt, /*node*/ 7, /*seq*/ 42,
|
||||
/*ts*/ 1234567ULL, RV_PROFILE_RESP_HIGH_SENS);
|
||||
|
||||
CHECK(pkt.magic == RV_FEATURE_STATE_MAGIC, "magic");
|
||||
CHECK(pkt.node_id == 7, "node_id");
|
||||
CHECK(pkt.seq == 42, "seq");
|
||||
CHECK(pkt.ts_us == 1234567ULL, "ts_us");
|
||||
CHECK(pkt.mode == RV_PROFILE_RESP_HIGH_SENS, "mode");
|
||||
CHECK(pkt.reserved == 0, "reserved cleared");
|
||||
CHECK(pkt.crc32 != 0, "crc32 populated (non-trivial input)");
|
||||
|
||||
/* Re-finalize must produce identical CRC (deterministic). */
|
||||
uint32_t crc1 = pkt.crc32;
|
||||
rv_feature_state_finalize(&pkt, 7, 42, 1234567ULL, RV_PROFILE_RESP_HIGH_SENS);
|
||||
CHECK(pkt.crc32 == crc1, "finalize is deterministic");
|
||||
|
||||
/* Changing a payload byte must change the CRC. */
|
||||
pkt.motion_score = 0.26f;
|
||||
rv_feature_state_finalize(&pkt, 7, 42, 1234567ULL, RV_PROFILE_RESP_HIGH_SENS);
|
||||
CHECK(pkt.crc32 != crc1, "CRC changes when payload changes");
|
||||
}
|
||||
|
||||
static void test_crc_verifiability(void) {
|
||||
printf("test: receiver can verify CRC\n");
|
||||
rv_feature_state_t pkt;
|
||||
memset(&pkt, 0, sizeof(pkt));
|
||||
pkt.motion_score = 0.33f;
|
||||
pkt.presence_score = 0.66f;
|
||||
rv_feature_state_finalize(&pkt, 1, 100, 555ULL, RV_PROFILE_PASSIVE_LOW_RATE);
|
||||
|
||||
/* Receiver recomputes CRC over all bytes except the trailing crc32. */
|
||||
uint32_t expected = rv_feature_state_crc32(
|
||||
(const uint8_t *)&pkt, sizeof(pkt) - sizeof(uint32_t));
|
||||
CHECK(pkt.crc32 == expected, "receiver-side CRC check matches");
|
||||
}
|
||||
|
||||
static void benchmark_crc(void) {
|
||||
printf("bench: CRC32 over 60-byte packet (56 B hashed, excl trailing crc32)\n");
|
||||
rv_feature_state_t pkt;
|
||||
memset(&pkt, 0x5A, sizeof(pkt));
|
||||
|
||||
const int N = 5000000;
|
||||
struct timespec a, b;
|
||||
clock_gettime(CLOCK_MONOTONIC, &a);
|
||||
volatile uint32_t sink = 0;
|
||||
for (int i = 0; i < N; i++) {
|
||||
pkt.seq = (uint16_t)i; /* vary input so compiler can't fold */
|
||||
sink ^= rv_feature_state_crc32(
|
||||
(const uint8_t *)&pkt, sizeof(pkt) - sizeof(uint32_t));
|
||||
}
|
||||
clock_gettime(CLOCK_MONOTONIC, &b);
|
||||
(void)sink;
|
||||
double ns_per_call = ((b.tv_sec - a.tv_sec) * 1e9 +
|
||||
(b.tv_nsec - a.tv_nsec)) / (double)N;
|
||||
double mb_per_sec = (double)(sizeof(pkt) - sizeof(uint32_t)) / ns_per_call
|
||||
* 1e9 / (1024.0 * 1024.0);
|
||||
printf(" %d calls, %.1f ns/packet, %.1f MB/s\n",
|
||||
N, ns_per_call, mb_per_sec);
|
||||
/* At 10 Hz feature-state cadence, CRC budget is <100us/packet — we
|
||||
* expect bit-by-bit CRC32 to run ~1 MB/s on host, ~100-300 KB/s on
|
||||
* ESP32-S3 Xtensa LX7. 76-byte CRC takes <1 ms either way. */
|
||||
CHECK(ns_per_call < 50000.0, "CRC32(80B) must be under 50us/packet");
|
||||
}
|
||||
|
||||
static void benchmark_finalize(void) {
|
||||
printf("bench: full finalize() cost\n");
|
||||
rv_feature_state_t pkt;
|
||||
memset(&pkt, 0x33, sizeof(pkt));
|
||||
|
||||
const int N = 5000000;
|
||||
struct timespec a, b;
|
||||
clock_gettime(CLOCK_MONOTONIC, &a);
|
||||
for (int i = 0; i < N; i++) {
|
||||
rv_feature_state_finalize(&pkt, 1, (uint16_t)i, (uint64_t)i,
|
||||
RV_PROFILE_PASSIVE_LOW_RATE);
|
||||
}
|
||||
clock_gettime(CLOCK_MONOTONIC, &b);
|
||||
double ns_per_call = ((b.tv_sec - a.tv_sec) * 1e9 +
|
||||
(b.tv_nsec - a.tv_nsec)) / (double)N;
|
||||
printf(" %d calls, %.1f ns/call (includes CRC)\n", N, ns_per_call);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
printf("=== rv_feature_state_* host tests ===\n\n");
|
||||
|
||||
test_packet_size();
|
||||
test_crc_known_vectors();
|
||||
test_finalize();
|
||||
test_crc_verifiability();
|
||||
benchmark_crc();
|
||||
benchmark_finalize();
|
||||
|
||||
printf("\n=== result: %d pass, %d fail ===\n", g_pass, g_fail);
|
||||
return g_fail > 0 ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
* Host unit test for ADR-081 Layer 3 mesh plane encode/decode.
|
||||
*
|
||||
* rv_mesh_encode() and rv_mesh_decode() are the pure halves of the
|
||||
* mesh plane — no ESP-IDF, no sockets — so we exercise them with the
|
||||
* RV_MESH_HOST_TEST flag that disables the send helpers.
|
||||
*/
|
||||
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "rv_mesh.h"
|
||||
#include "rv_feature_state.h"
|
||||
#include "rv_radio_ops.h" /* for RV_PROFILE_* enum values */
|
||||
|
||||
static int g_pass = 0, g_fail = 0;
|
||||
#define CHECK(cond, msg) do { \
|
||||
if (cond) { g_pass++; } \
|
||||
else { g_fail++; printf(" FAIL: %s (line %d)\n", msg, __LINE__); } \
|
||||
} while (0)
|
||||
|
||||
static void test_header_size(void) {
|
||||
printf("test: rv_mesh_header_t is 16 bytes\n");
|
||||
CHECK(sizeof(rv_mesh_header_t) == 16, "sizeof(header) == 16");
|
||||
}
|
||||
|
||||
static void test_encode_health_roundtrip(void) {
|
||||
printf("test: HEALTH roundtrip\n");
|
||||
rv_node_status_t st;
|
||||
memset(&st, 0, sizeof(st));
|
||||
st.node_id[0] = 7;
|
||||
st.local_time_us = 1234567890ULL;
|
||||
st.role = RV_ROLE_OBSERVER;
|
||||
st.current_channel = 6;
|
||||
st.current_bw = 20;
|
||||
st.noise_floor_dbm = -93;
|
||||
st.pkt_yield = 42;
|
||||
st.sync_error_us = 12;
|
||||
|
||||
uint8_t buf[RV_MESH_MAX_FRAME_BYTES];
|
||||
size_t n = rv_mesh_encode_health(RV_ROLE_OBSERVER, /*epoch*/ 100,
|
||||
&st, buf, sizeof(buf));
|
||||
CHECK(n > 0, "encode returns non-zero");
|
||||
CHECK(n == sizeof(rv_mesh_header_t) + sizeof(st) + 4,
|
||||
"encoded size = hdr+payload+crc");
|
||||
|
||||
rv_mesh_header_t hdr;
|
||||
const uint8_t *payload = NULL;
|
||||
uint16_t payload_len = 0;
|
||||
esp_err_t rc = rv_mesh_decode(buf, n, &hdr, &payload, &payload_len);
|
||||
CHECK(rc == ESP_OK, "decode OK");
|
||||
CHECK(hdr.type == RV_MSG_HEALTH, "type == HEALTH");
|
||||
CHECK(hdr.epoch == 100, "epoch survives");
|
||||
CHECK(hdr.payload_len == sizeof(st), "payload_len matches");
|
||||
CHECK(payload != NULL, "payload pointer set");
|
||||
CHECK(memcmp(payload, &st, sizeof(st)) == 0, "payload bytes match");
|
||||
}
|
||||
|
||||
static void test_encode_anomaly_roundtrip(void) {
|
||||
printf("test: ANOMALY_ALERT roundtrip\n");
|
||||
rv_anomaly_alert_t a;
|
||||
memset(&a, 0, sizeof(a));
|
||||
a.node_id[0] = 3;
|
||||
a.ts_us = 999999ULL;
|
||||
a.reason = RV_ANOMALY_FALL;
|
||||
a.severity = 200;
|
||||
a.anomaly_score = 0.85f;
|
||||
a.motion_score = 0.9f;
|
||||
|
||||
uint8_t buf[RV_MESH_MAX_FRAME_BYTES];
|
||||
size_t n = rv_mesh_encode_anomaly_alert(RV_ROLE_OBSERVER, 7, &a,
|
||||
buf, sizeof(buf));
|
||||
CHECK(n > 0, "encoded");
|
||||
|
||||
rv_mesh_header_t hdr;
|
||||
const uint8_t *payload = NULL;
|
||||
uint16_t payload_len = 0;
|
||||
esp_err_t rc = rv_mesh_decode(buf, n, &hdr, &payload, &payload_len);
|
||||
CHECK(rc == ESP_OK, "decoded");
|
||||
CHECK(hdr.type == RV_MSG_ANOMALY_ALERT, "type ok");
|
||||
rv_anomaly_alert_t got;
|
||||
memcpy(&got, payload, sizeof(got));
|
||||
CHECK(got.reason == RV_ANOMALY_FALL, "reason survived");
|
||||
CHECK(got.severity == 200, "severity survived");
|
||||
}
|
||||
|
||||
static void test_encode_feature_delta_wraps_feature_state(void) {
|
||||
printf("test: FEATURE_DELTA wraps rv_feature_state_t\n");
|
||||
rv_feature_state_t fs;
|
||||
memset(&fs, 0, sizeof(fs));
|
||||
fs.motion_score = 0.5f;
|
||||
rv_feature_state_finalize(&fs, /*node*/ 9, /*seq*/ 17,
|
||||
/*ts*/ 111ULL, RV_PROFILE_FAST_MOTION);
|
||||
|
||||
uint8_t buf[RV_MESH_MAX_FRAME_BYTES];
|
||||
size_t n = rv_mesh_encode_feature_delta(RV_ROLE_OBSERVER, 2, &fs,
|
||||
buf, sizeof(buf));
|
||||
CHECK(n == sizeof(rv_mesh_header_t) + sizeof(fs) + 4, "size check");
|
||||
|
||||
rv_mesh_header_t hdr;
|
||||
const uint8_t *payload = NULL;
|
||||
uint16_t len = 0;
|
||||
CHECK(rv_mesh_decode(buf, n, &hdr, &payload, &len) == ESP_OK,
|
||||
"decode OK");
|
||||
rv_feature_state_t got;
|
||||
memcpy(&got, payload, sizeof(got));
|
||||
CHECK(got.magic == RV_FEATURE_STATE_MAGIC, "inner magic preserved");
|
||||
CHECK(got.node_id == 9, "inner node_id preserved");
|
||||
CHECK(got.seq == 17, "inner seq preserved");
|
||||
/* Inner CRC is end-to-end even though the mesh frame has its own
|
||||
* CRC too — two checks for two failure modes. */
|
||||
uint32_t inner_crc = rv_feature_state_crc32(
|
||||
(const uint8_t *)&got, sizeof(got) - sizeof(uint32_t));
|
||||
CHECK(inner_crc == got.crc32, "inner feature_state CRC still valid");
|
||||
}
|
||||
|
||||
static void test_decode_rejects_bad_magic(void) {
|
||||
printf("test: decode rejects bad magic\n");
|
||||
uint8_t buf[sizeof(rv_mesh_header_t) + 4];
|
||||
memset(buf, 0xFF, sizeof(buf));
|
||||
|
||||
rv_mesh_header_t hdr;
|
||||
const uint8_t *p = NULL;
|
||||
uint16_t plen = 0;
|
||||
esp_err_t rc = rv_mesh_decode(buf, sizeof(buf), &hdr, &p, &plen);
|
||||
CHECK(rc != ESP_OK, "bad magic rejected");
|
||||
}
|
||||
|
||||
static void test_decode_rejects_truncated(void) {
|
||||
printf("test: decode rejects truncated frame\n");
|
||||
uint8_t buf[sizeof(rv_mesh_header_t) - 1];
|
||||
memset(buf, 0, sizeof(buf));
|
||||
rv_mesh_header_t hdr;
|
||||
const uint8_t *p = NULL;
|
||||
uint16_t plen = 0;
|
||||
esp_err_t rc = rv_mesh_decode(buf, sizeof(buf), &hdr, &p, &plen);
|
||||
CHECK(rc != ESP_OK, "truncated rejected");
|
||||
}
|
||||
|
||||
static void test_decode_rejects_bad_crc(void) {
|
||||
printf("test: decode rejects CRC mismatch\n");
|
||||
rv_node_status_t st;
|
||||
memset(&st, 0, sizeof(st));
|
||||
st.role = RV_ROLE_OBSERVER;
|
||||
uint8_t buf[RV_MESH_MAX_FRAME_BYTES];
|
||||
size_t n = rv_mesh_encode_health(RV_ROLE_OBSERVER, 1, &st,
|
||||
buf, sizeof(buf));
|
||||
CHECK(n > 0, "encoded");
|
||||
|
||||
/* Flip a byte in the payload — CRC must now mismatch. */
|
||||
buf[sizeof(rv_mesh_header_t) + 4] ^= 0x10;
|
||||
|
||||
rv_mesh_header_t hdr;
|
||||
const uint8_t *p = NULL;
|
||||
uint16_t plen = 0;
|
||||
esp_err_t rc = rv_mesh_decode(buf, n, &hdr, &p, &plen);
|
||||
CHECK(rc != ESP_OK, "CRC mismatch rejected");
|
||||
}
|
||||
|
||||
static void test_encode_rejects_oversize_payload(void) {
|
||||
printf("test: encode rejects oversize payload\n");
|
||||
uint8_t junk[RV_MESH_MAX_PAYLOAD + 1] = {0};
|
||||
uint8_t buf[RV_MESH_MAX_FRAME_BYTES + 8];
|
||||
size_t n = rv_mesh_encode(RV_MSG_HEALTH, RV_ROLE_OBSERVER, RV_AUTH_NONE,
|
||||
0, junk, sizeof(junk), buf, sizeof(buf));
|
||||
CHECK(n == 0, "oversize payload → 0");
|
||||
}
|
||||
|
||||
static void test_encode_rejects_small_buf(void) {
|
||||
printf("test: encode rejects too-small buffer\n");
|
||||
rv_node_status_t st = {0};
|
||||
uint8_t buf[16]; /* header fits but not payload */
|
||||
size_t n = rv_mesh_encode_health(RV_ROLE_OBSERVER, 0, &st,
|
||||
buf, sizeof(buf));
|
||||
CHECK(n == 0, "small buf → 0");
|
||||
}
|
||||
|
||||
static void benchmark_encode(void) {
|
||||
printf("bench: encode+decode HEALTH roundtrip\n");
|
||||
rv_node_status_t st;
|
||||
memset(&st, 0x33, sizeof(st));
|
||||
uint8_t buf[RV_MESH_MAX_FRAME_BYTES];
|
||||
|
||||
const int N = 2000000;
|
||||
struct timespec a, b;
|
||||
clock_gettime(CLOCK_MONOTONIC, &a);
|
||||
for (int i = 0; i < N; i++) {
|
||||
st.pkt_yield = (uint16_t)i;
|
||||
size_t n = rv_mesh_encode_health(RV_ROLE_OBSERVER, (uint32_t)i,
|
||||
&st, buf, sizeof(buf));
|
||||
rv_mesh_header_t hdr;
|
||||
const uint8_t *p = NULL;
|
||||
uint16_t plen = 0;
|
||||
(void)rv_mesh_decode(buf, n, &hdr, &p, &plen);
|
||||
}
|
||||
clock_gettime(CLOCK_MONOTONIC, &b);
|
||||
double ns = ((b.tv_sec - a.tv_sec) * 1e9 +
|
||||
(b.tv_nsec - a.tv_nsec)) / (double)N;
|
||||
printf(" %d roundtrips, %.1f ns/call\n", N, ns);
|
||||
CHECK(ns < 20000.0, "encode+decode must be under 20us/roundtrip");
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
printf("=== rv_mesh encode/decode host tests ===\n\n");
|
||||
test_header_size();
|
||||
test_encode_health_roundtrip();
|
||||
test_encode_anomaly_roundtrip();
|
||||
test_encode_feature_delta_wraps_feature_state();
|
||||
test_decode_rejects_bad_magic();
|
||||
test_decode_rejects_truncated();
|
||||
test_decode_rejects_bad_crc();
|
||||
test_encode_rejects_oversize_payload();
|
||||
test_encode_rejects_small_buf();
|
||||
benchmark_encode();
|
||||
printf("\n=== result: %d pass, %d fail ===\n", g_pass, g_fail);
|
||||
return g_fail > 0 ? 1 : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user