mirror of
https://github.com/ruvnet/RuView
synced 2026-07-30 18:41:42 +00:00
ADR-081: adaptive CSI mesh firmware kernel + scaffolding
Introduces a 5-layer firmware kernel that reframes the existing ESP32
modules as components of a chipset-agnostic architecture and authorizes
adaptive control + a compact feature-state stream as the default upstream.
Layers:
L1 Radio Abstraction Layer — rv_radio_ops_t vtable + ESP32 binding
L2 Adaptive Controller — fast/medium/slow loops (200ms/1s/30s)
L3 Mesh Sensing Plane — anchor/observer/relay/coordinator (spec)
L4 On-device Feature Extr. — rv_feature_state_t (magic 0xC5110006)
L5 Rust handoff — feature_state default; debug raw gated
Files:
docs/adr/ADR-081-adaptive-csi-mesh-firmware-kernel.md (new)
firmware/esp32-csi-node/main/rv_radio_ops.h (new)
firmware/esp32-csi-node/main/rv_radio_ops_esp32.c (new)
firmware/esp32-csi-node/main/rv_feature_state.{h,c} (new)
firmware/esp32-csi-node/main/adaptive_controller.{h,c} (new)
firmware/esp32-csi-node/main/main.c (wire L1+L2)
firmware/esp32-csi-node/main/CMakeLists.txt (add 4 sources)
firmware/esp32-csi-node/main/Kconfig.projbuild (controller knobs)
CHANGELOG.md (Unreleased)
Default policy is conservative: enable_channel_switch and
enable_role_change are off, so behavior matches today's firmware
unless an operator opts in via menuconfig. The pure
adaptive_controller_decide() is exposed for offline unit tests.
Reuses (does not rewrite): csi_collector, edge_processing (ADR-039),
swarm_bridge (ADR-066), secure_tdm (ADR-032), wasm_runtime (ADR-040).
This commit is contained in:
@@ -4,6 +4,10 @@ set(SRCS
|
||||
"wasm_runtime.c" "wasm_upload.c" "rvf_parser.c"
|
||||
"mmwave_sensor.c"
|
||||
"swarm_bridge.c"
|
||||
# ADR-081 — adaptive CSI mesh firmware kernel
|
||||
"rv_radio_ops_esp32.c"
|
||||
"rv_feature_state.c"
|
||||
"adaptive_controller.c"
|
||||
)
|
||||
|
||||
set(REQUIRES "")
|
||||
|
||||
@@ -87,6 +87,89 @@ menu "Edge Intelligence (ADR-039)"
|
||||
|
||||
endmenu
|
||||
|
||||
menu "Adaptive Controller (ADR-081)"
|
||||
|
||||
config ADAPTIVE_FAST_LOOP_MS
|
||||
int "Fast loop period (ms)"
|
||||
default 200
|
||||
range 50 2000
|
||||
help
|
||||
Period of the fast control loop. The fast loop reads radio
|
||||
health and edge-derived motion/presence/anomaly scores and
|
||||
updates the active capture profile. Default 200 ms matches
|
||||
the ADR-081 spec.
|
||||
|
||||
config ADAPTIVE_MEDIUM_LOOP_MS
|
||||
int "Medium loop period (ms)"
|
||||
default 1000
|
||||
range 200 30000
|
||||
help
|
||||
Period of the medium control loop. The medium loop is where
|
||||
channel selection and role transitions happen (when
|
||||
enable_channel_switch / enable_role_change are on).
|
||||
|
||||
config ADAPTIVE_SLOW_LOOP_MS
|
||||
int "Slow loop period (ms)"
|
||||
default 30000
|
||||
range 1000 300000
|
||||
help
|
||||
Period of the slow control loop. The slow loop publishes
|
||||
HEALTH messages and may request CALIBRATION_START on
|
||||
sustained drift.
|
||||
|
||||
config ADAPTIVE_AGGRESSIVE
|
||||
bool "Aggressive adaptation"
|
||||
default n
|
||||
help
|
||||
When enabled, the controller reacts to motion / anomaly
|
||||
sooner and uses a tighter cadence in SENSE_ACTIVE. Default
|
||||
off matches today's conservative behavior.
|
||||
|
||||
config ADAPTIVE_ENABLE_CHANNEL_SWITCH
|
||||
bool "Allow controller to change WiFi channel"
|
||||
default n
|
||||
help
|
||||
When disabled, the controller never calls set_channel() —
|
||||
channel hopping (ADR-029) and channel override (ADR-060)
|
||||
remain in charge. Enable only after Phase 3 follow-up
|
||||
work has wired the channel-plan mesh message.
|
||||
|
||||
config ADAPTIVE_ENABLE_ROLE_CHANGE
|
||||
bool "Allow controller to change mesh role"
|
||||
default n
|
||||
help
|
||||
When disabled, the controller never advertises a different
|
||||
role to the swarm bridge. Enable after the mesh-plane
|
||||
ROLE_ASSIGN protocol is in place.
|
||||
|
||||
config ADAPTIVE_MOTION_THRESH_PERMIL
|
||||
int "Motion threshold (per-mille)"
|
||||
default 200
|
||||
range 1 1000
|
||||
help
|
||||
Motion score above which the controller transitions to
|
||||
SENSE_ACTIVE and selects RV_PROFILE_FAST_MOTION. Expressed
|
||||
in per-mille (200 = 0.20).
|
||||
|
||||
config ADAPTIVE_ANOMALY_THRESH_PERMIL
|
||||
int "Anomaly threshold (per-mille)"
|
||||
default 600
|
||||
range 1 1000
|
||||
help
|
||||
Anomaly score above which the controller transitions to
|
||||
ALERT. Per-mille (600 = 0.60).
|
||||
|
||||
config ADAPTIVE_MIN_PKT_YIELD
|
||||
int "Minimum packet yield before DEGRADED (pps)"
|
||||
default 5
|
||||
range 0 100
|
||||
help
|
||||
CSI callback rate (per second) below which the controller
|
||||
falls back to DEGRADED mode and pins the radio to
|
||||
RV_PROFILE_PASSIVE_LOW_RATE. 0 disables the degraded gate.
|
||||
|
||||
endmenu
|
||||
|
||||
menu "AMOLED Display (ADR-045)"
|
||||
|
||||
config DISPLAY_ENABLE
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* @file adaptive_controller.c
|
||||
* @brief ADR-081 Layer 2 — Adaptive sensing controller implementation.
|
||||
*
|
||||
* The decide() function is pure and unit-testable; the FreeRTOS plumbing
|
||||
* around it (timers, observation snapshot) is the only ESP-IDF surface.
|
||||
*
|
||||
* Default policy is conservative: it will not change channels unless
|
||||
* enable_channel_switch is true, and it will not change roles unless
|
||||
* enable_role_change is true. With both off the controller still tracks
|
||||
* state and feeds the mesh plane's HEALTH messages, so it is safe to
|
||||
* enable in production before the mesh plane is fully in place.
|
||||
*/
|
||||
|
||||
#include "adaptive_controller.h"
|
||||
#include "rv_radio_ops.h"
|
||||
#include "edge_processing.h"
|
||||
|
||||
#include <string.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/timers.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "sdkconfig.h"
|
||||
|
||||
static const char *TAG = "adaptive_ctrl";
|
||||
|
||||
/* ---- Module state ---- */
|
||||
|
||||
static bool s_inited = false;
|
||||
static adapt_config_t s_cfg;
|
||||
static adapt_state_t s_state = ADAPT_STATE_BOOT;
|
||||
static adapt_observation_t s_last_obs;
|
||||
static bool s_obs_valid = false;
|
||||
static portMUX_TYPE s_obs_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
|
||||
static TimerHandle_t s_fast_timer = NULL;
|
||||
static TimerHandle_t s_medium_timer = NULL;
|
||||
static TimerHandle_t s_slow_timer = NULL;
|
||||
|
||||
/* ---- Defaults ---- */
|
||||
|
||||
#ifndef CONFIG_ADAPTIVE_FAST_LOOP_MS
|
||||
#define CONFIG_ADAPTIVE_FAST_LOOP_MS 200
|
||||
#endif
|
||||
#ifndef CONFIG_ADAPTIVE_MEDIUM_LOOP_MS
|
||||
#define CONFIG_ADAPTIVE_MEDIUM_LOOP_MS 1000
|
||||
#endif
|
||||
#ifndef CONFIG_ADAPTIVE_SLOW_LOOP_MS
|
||||
#define CONFIG_ADAPTIVE_SLOW_LOOP_MS 30000
|
||||
#endif
|
||||
#ifndef CONFIG_ADAPTIVE_MIN_PKT_YIELD
|
||||
#define CONFIG_ADAPTIVE_MIN_PKT_YIELD 5
|
||||
#endif
|
||||
/* Defaults expressed as integer permille so Kconfig can carry them. */
|
||||
#ifndef CONFIG_ADAPTIVE_MOTION_THRESH_PERMIL
|
||||
#define CONFIG_ADAPTIVE_MOTION_THRESH_PERMIL 200 /* 0.20 */
|
||||
#endif
|
||||
#ifndef CONFIG_ADAPTIVE_ANOMALY_THRESH_PERMIL
|
||||
#define CONFIG_ADAPTIVE_ANOMALY_THRESH_PERMIL 600 /* 0.60 */
|
||||
#endif
|
||||
|
||||
static void apply_defaults(adapt_config_t *cfg)
|
||||
{
|
||||
cfg->fast_loop_ms = CONFIG_ADAPTIVE_FAST_LOOP_MS;
|
||||
cfg->medium_loop_ms = CONFIG_ADAPTIVE_MEDIUM_LOOP_MS;
|
||||
cfg->slow_loop_ms = CONFIG_ADAPTIVE_SLOW_LOOP_MS;
|
||||
#ifdef CONFIG_ADAPTIVE_AGGRESSIVE
|
||||
cfg->aggressive = true;
|
||||
#else
|
||||
cfg->aggressive = false;
|
||||
#endif
|
||||
#ifdef CONFIG_ADAPTIVE_ENABLE_CHANNEL_SWITCH
|
||||
cfg->enable_channel_switch = true;
|
||||
#else
|
||||
cfg->enable_channel_switch = false;
|
||||
#endif
|
||||
#ifdef CONFIG_ADAPTIVE_ENABLE_ROLE_CHANGE
|
||||
cfg->enable_role_change = true;
|
||||
#else
|
||||
cfg->enable_role_change = false;
|
||||
#endif
|
||||
cfg->motion_threshold = (float)CONFIG_ADAPTIVE_MOTION_THRESH_PERMIL / 1000.0f;
|
||||
cfg->anomaly_threshold = (float)CONFIG_ADAPTIVE_ANOMALY_THRESH_PERMIL / 1000.0f;
|
||||
cfg->min_pkt_yield = CONFIG_ADAPTIVE_MIN_PKT_YIELD;
|
||||
}
|
||||
|
||||
/* ---- Pure decision function (unit-testable) ---- */
|
||||
|
||||
void adaptive_controller_decide(const adapt_config_t *cfg,
|
||||
adapt_state_t current,
|
||||
const adapt_observation_t *obs,
|
||||
adapt_decision_t *out)
|
||||
{
|
||||
if (cfg == NULL || obs == NULL || out == NULL) {
|
||||
return;
|
||||
}
|
||||
memset(out, 0, sizeof(*out));
|
||||
out->new_state = (uint8_t)current;
|
||||
out->new_profile = RV_PROFILE_PASSIVE_LOW_RATE;
|
||||
|
||||
/* Degraded gate: any of pkt yield collapse, severe coherence loss → DEGRADED. */
|
||||
if (obs->pkt_yield_per_sec < cfg->min_pkt_yield ||
|
||||
obs->node_coherence < 0.20f) {
|
||||
if (current != ADAPT_STATE_DEGRADED) {
|
||||
out->change_state = true;
|
||||
out->new_state = ADAPT_STATE_DEGRADED;
|
||||
}
|
||||
out->change_profile = (current != ADAPT_STATE_DEGRADED);
|
||||
out->new_profile = RV_PROFILE_PASSIVE_LOW_RATE;
|
||||
out->suggested_vital_interval_ms = 2000;
|
||||
return;
|
||||
}
|
||||
|
||||
/* Anomaly trumps motion. */
|
||||
if (obs->anomaly_score >= cfg->anomaly_threshold) {
|
||||
if (current != ADAPT_STATE_ALERT) {
|
||||
out->change_state = true;
|
||||
out->new_state = ADAPT_STATE_ALERT;
|
||||
}
|
||||
out->change_profile = true;
|
||||
out->new_profile = RV_PROFILE_FAST_MOTION;
|
||||
out->suggested_vital_interval_ms = 100;
|
||||
return;
|
||||
}
|
||||
|
||||
/* Motion → SENSE_ACTIVE with FAST_MOTION profile. */
|
||||
if (obs->motion_score >= cfg->motion_threshold) {
|
||||
if (current != ADAPT_STATE_SENSE_ACTIVE) {
|
||||
out->change_state = true;
|
||||
out->new_state = ADAPT_STATE_SENSE_ACTIVE;
|
||||
}
|
||||
out->change_profile = true;
|
||||
out->new_profile = RV_PROFILE_FAST_MOTION;
|
||||
out->suggested_vital_interval_ms = cfg->aggressive ? 100 : 200;
|
||||
return;
|
||||
}
|
||||
|
||||
/* Stable environment with valid presence → high-sensitivity respiration mode. */
|
||||
if (obs->presence_score >= 0.5f && obs->motion_score < 0.05f) {
|
||||
if (current != ADAPT_STATE_SENSE_IDLE) {
|
||||
out->change_state = true;
|
||||
out->new_state = ADAPT_STATE_SENSE_IDLE;
|
||||
}
|
||||
out->change_profile = true;
|
||||
out->new_profile = RV_PROFILE_RESP_HIGH_SENS;
|
||||
out->suggested_vital_interval_ms = 1000;
|
||||
return;
|
||||
}
|
||||
|
||||
/* Default: passive low rate. */
|
||||
if (current != ADAPT_STATE_SENSE_IDLE) {
|
||||
out->change_state = true;
|
||||
out->new_state = ADAPT_STATE_SENSE_IDLE;
|
||||
}
|
||||
out->change_profile = (current != ADAPT_STATE_SENSE_IDLE);
|
||||
out->new_profile = RV_PROFILE_PASSIVE_LOW_RATE;
|
||||
out->suggested_vital_interval_ms = cfg->aggressive ? 500 : 1000;
|
||||
}
|
||||
|
||||
/* ---- Observation collection ---- */
|
||||
|
||||
static void collect_observation(adapt_observation_t *out)
|
||||
{
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
/* Radio health from the active binding. */
|
||||
const rv_radio_ops_t *ops = rv_radio_ops_get();
|
||||
if (ops != NULL && ops->get_health != NULL) {
|
||||
rv_radio_health_t h;
|
||||
if (ops->get_health(&h) == ESP_OK) {
|
||||
out->pkt_yield_per_sec = h.pkt_yield_per_sec;
|
||||
out->send_fail_count = h.send_fail_count;
|
||||
out->rssi_median_dbm = h.rssi_median_dbm;
|
||||
out->noise_floor_dbm = h.noise_floor_dbm;
|
||||
}
|
||||
}
|
||||
|
||||
/* Edge-derived state. The ADR-039 vitals packet exposes presence_score
|
||||
* and motion_energy directly; we treat motion_energy as a proxy for
|
||||
* motion_score by clamping to [0,1]. anomaly_score and node_coherence
|
||||
* are not yet emitted by edge_processing — placeholder until Layer 4
|
||||
* extraction lands. */
|
||||
edge_vitals_pkt_t vitals;
|
||||
if (edge_get_vitals(&vitals)) {
|
||||
out->presence_score = vitals.presence_score;
|
||||
float m = vitals.motion_energy;
|
||||
if (m < 0.0f) m = 0.0f;
|
||||
if (m > 1.0f) m = 1.0f;
|
||||
out->motion_score = m;
|
||||
}
|
||||
out->anomaly_score = 0.0f;
|
||||
out->node_coherence = 1.0f;
|
||||
}
|
||||
|
||||
/* ---- Decision application ---- */
|
||||
|
||||
static void apply_decision(const adapt_decision_t *dec)
|
||||
{
|
||||
const rv_radio_ops_t *ops = rv_radio_ops_get();
|
||||
|
||||
if (dec->change_state) {
|
||||
ESP_LOGI(TAG, "state %u → %u",
|
||||
(unsigned)s_state, (unsigned)dec->new_state);
|
||||
s_state = (adapt_state_t)dec->new_state;
|
||||
}
|
||||
|
||||
if (dec->change_profile && ops != NULL && ops->set_capture_profile != NULL) {
|
||||
ops->set_capture_profile(dec->new_profile);
|
||||
}
|
||||
|
||||
if (dec->change_channel && s_cfg.enable_channel_switch &&
|
||||
ops != NULL && ops->set_channel != NULL) {
|
||||
ops->set_channel(dec->new_channel, 20);
|
||||
}
|
||||
|
||||
/* suggested_vital_interval_ms: the controller publishes a hint; the
|
||||
* edge pipeline picks it up via edge_processing on its next emit. We
|
||||
* don't yet have edge_set_vital_interval(); recorded for Phase 3. */
|
||||
(void)dec->request_calibration;
|
||||
}
|
||||
|
||||
/* ---- Loop callbacks ---- */
|
||||
|
||||
static void fast_loop_cb(TimerHandle_t t)
|
||||
{
|
||||
(void)t;
|
||||
adapt_observation_t obs;
|
||||
collect_observation(&obs);
|
||||
|
||||
portENTER_CRITICAL(&s_obs_lock);
|
||||
s_last_obs = obs;
|
||||
s_obs_valid = true;
|
||||
portEXIT_CRITICAL(&s_obs_lock);
|
||||
|
||||
adapt_decision_t dec;
|
||||
adaptive_controller_decide(&s_cfg, s_state, &obs, &dec);
|
||||
apply_decision(&dec);
|
||||
}
|
||||
|
||||
static void medium_loop_cb(TimerHandle_t t)
|
||||
{
|
||||
(void)t;
|
||||
/* Phase 3 stub: when enable_channel_switch is on, choose a channel
|
||||
* based on RSSI/noise/yield. Today, log the snapshot so operators can
|
||||
* see the controller is running. */
|
||||
adapt_observation_t obs;
|
||||
portENTER_CRITICAL(&s_obs_lock);
|
||||
obs = s_last_obs;
|
||||
portEXIT_CRITICAL(&s_obs_lock);
|
||||
|
||||
if (s_obs_valid) {
|
||||
ESP_LOGI(TAG, "medium tick: state=%u yield=%upps motion=%.2f presence=%.2f rssi=%d",
|
||||
(unsigned)s_state,
|
||||
(unsigned)obs.pkt_yield_per_sec,
|
||||
(double)obs.motion_score,
|
||||
(double)obs.presence_score,
|
||||
(int)obs.rssi_median_dbm);
|
||||
}
|
||||
}
|
||||
|
||||
static void slow_loop_cb(TimerHandle_t t)
|
||||
{
|
||||
(void)t;
|
||||
/* Slow loop: publish a HEALTH message, request CALIBRATION_START on
|
||||
* sustained drift. Both routed through swarm_bridge once the mesh
|
||||
* plane lands. Today we log a rollover so operators see the cadence. */
|
||||
ESP_LOGI(TAG, "slow tick (state=%u)", (unsigned)s_state);
|
||||
}
|
||||
|
||||
/* ---- Public API ---- */
|
||||
|
||||
esp_err_t adaptive_controller_init(const adapt_config_t *cfg)
|
||||
{
|
||||
if (s_inited) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
if (cfg != NULL) {
|
||||
s_cfg = *cfg;
|
||||
} else {
|
||||
apply_defaults(&s_cfg);
|
||||
}
|
||||
|
||||
/* Sanity clamps. */
|
||||
if (s_cfg.fast_loop_ms < 50) s_cfg.fast_loop_ms = 50;
|
||||
if (s_cfg.medium_loop_ms < 200) s_cfg.medium_loop_ms = 200;
|
||||
if (s_cfg.slow_loop_ms < 1000) s_cfg.slow_loop_ms = 1000;
|
||||
|
||||
s_state = ADAPT_STATE_RADIO_INIT;
|
||||
|
||||
s_fast_timer = xTimerCreate("adapt_fast",
|
||||
pdMS_TO_TICKS(s_cfg.fast_loop_ms),
|
||||
pdTRUE, NULL, fast_loop_cb);
|
||||
s_medium_timer = xTimerCreate("adapt_med",
|
||||
pdMS_TO_TICKS(s_cfg.medium_loop_ms),
|
||||
pdTRUE, NULL, medium_loop_cb);
|
||||
s_slow_timer = xTimerCreate("adapt_slow",
|
||||
pdMS_TO_TICKS(s_cfg.slow_loop_ms),
|
||||
pdTRUE, NULL, slow_loop_cb);
|
||||
|
||||
if (s_fast_timer == NULL || s_medium_timer == NULL || s_slow_timer == NULL) {
|
||||
ESP_LOGE(TAG, "timer create failed");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
if (xTimerStart(s_fast_timer, 0) != pdPASS ||
|
||||
xTimerStart(s_medium_timer, 0) != pdPASS ||
|
||||
xTimerStart(s_slow_timer, 0) != pdPASS) {
|
||||
ESP_LOGE(TAG, "timer start failed");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
s_state = ADAPT_STATE_SENSE_IDLE;
|
||||
s_inited = true;
|
||||
|
||||
ESP_LOGI(TAG,
|
||||
"adaptive controller online: fast=%ums med=%ums slow=%ums "
|
||||
"(channel_switch=%d role_change=%d aggressive=%d)",
|
||||
(unsigned)s_cfg.fast_loop_ms,
|
||||
(unsigned)s_cfg.medium_loop_ms,
|
||||
(unsigned)s_cfg.slow_loop_ms,
|
||||
(int)s_cfg.enable_channel_switch,
|
||||
(int)s_cfg.enable_role_change,
|
||||
(int)s_cfg.aggressive);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
adapt_state_t adaptive_controller_state(void)
|
||||
{
|
||||
return s_state;
|
||||
}
|
||||
|
||||
bool adaptive_controller_observation(adapt_observation_t *out)
|
||||
{
|
||||
if (out == NULL) return false;
|
||||
bool ok = false;
|
||||
portENTER_CRITICAL(&s_obs_lock);
|
||||
if (s_obs_valid) {
|
||||
*out = s_last_obs;
|
||||
ok = true;
|
||||
}
|
||||
portEXIT_CRITICAL(&s_obs_lock);
|
||||
return ok;
|
||||
}
|
||||
|
||||
void adaptive_controller_force_state(adapt_state_t st)
|
||||
{
|
||||
ESP_LOGI(TAG, "force state %u → %u", (unsigned)s_state, (unsigned)st);
|
||||
s_state = st;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @file adaptive_controller.h
|
||||
* @brief ADR-081 Layer 2 — Adaptive sensing controller.
|
||||
*
|
||||
* Closed-loop firmware control over cadence, capture profile, channel, and
|
||||
* mesh role. Three cooperating loops:
|
||||
*
|
||||
* Fast (~200 ms): packet rate, active probing
|
||||
* Medium (~1 s) : channel selection, role transitions
|
||||
* Slow (~30 s) : baseline recalibration
|
||||
*
|
||||
* Outputs are routed through:
|
||||
* - rv_radio_ops_t (Layer 1) for set_channel / set_capture_profile
|
||||
* - swarm_bridge / mesh plane (Layer 3) for CHANNEL_PLAN, ROLE_ASSIGN
|
||||
* - edge_processing (Layer 4) for cadence and threshold updates
|
||||
*
|
||||
* Default policy is conservative — matches today's behavior. Aggressive
|
||||
* adaptation is opt-in via Kconfig (ADAPTIVE_CONTROLLER_AGGRESSIVE).
|
||||
*/
|
||||
|
||||
#ifndef ADAPTIVE_CONTROLLER_H
|
||||
#define ADAPTIVE_CONTROLLER_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/** Controller-level state machine (ADR-081 firmware FSM). */
|
||||
typedef enum {
|
||||
ADAPT_STATE_BOOT = 0,
|
||||
ADAPT_STATE_SELF_TEST = 1,
|
||||
ADAPT_STATE_RADIO_INIT = 2,
|
||||
ADAPT_STATE_TIME_SYNC = 3,
|
||||
ADAPT_STATE_CALIBRATION = 4,
|
||||
ADAPT_STATE_SENSE_IDLE = 5,
|
||||
ADAPT_STATE_SENSE_ACTIVE = 6,
|
||||
ADAPT_STATE_ALERT = 7,
|
||||
ADAPT_STATE_DEGRADED = 8,
|
||||
} adapt_state_t;
|
||||
|
||||
/** Observation window aggregated each fast tick. */
|
||||
typedef struct {
|
||||
uint16_t pkt_yield_per_sec; /**< From rv_radio_health.pkt_yield_per_sec. */
|
||||
uint16_t send_fail_count; /**< UDP/socket send failures. */
|
||||
int8_t rssi_median_dbm;
|
||||
int8_t noise_floor_dbm;
|
||||
float motion_score; /**< Pulled from edge_processing. */
|
||||
float presence_score;
|
||||
float anomaly_score;
|
||||
float node_coherence; /**< Inter-link coherence; 1.0 if single node. */
|
||||
} adapt_observation_t;
|
||||
|
||||
/** Decisions emitted by a controller tick. */
|
||||
typedef struct {
|
||||
bool change_profile;
|
||||
uint8_t new_profile; /**< rv_capture_profile_t. */
|
||||
bool change_channel;
|
||||
uint8_t new_channel;
|
||||
bool change_state;
|
||||
uint8_t new_state; /**< adapt_state_t. */
|
||||
bool request_calibration; /**< Coordinator should issue CALIBRATION_START. */
|
||||
uint16_t suggested_vital_interval_ms;
|
||||
} adapt_decision_t;
|
||||
|
||||
/** Controller config (loaded from NVS / Kconfig). */
|
||||
typedef struct {
|
||||
uint16_t fast_loop_ms; /**< Default 200 ms. */
|
||||
uint16_t medium_loop_ms; /**< Default 1000 ms. */
|
||||
uint16_t slow_loop_ms; /**< Default 30000 ms. */
|
||||
bool aggressive; /**< true = react sooner / more often. */
|
||||
bool enable_channel_switch; /**< false = controller may never hop. */
|
||||
bool enable_role_change;
|
||||
float motion_threshold; /**< 0..1, enter SENSE_ACTIVE above this. */
|
||||
float anomaly_threshold; /**< 0..1, enter ALERT above this. */
|
||||
uint16_t min_pkt_yield; /**< pps below this → DEGRADED. */
|
||||
} adapt_config_t;
|
||||
|
||||
/**
|
||||
* Initialize the adaptive controller.
|
||||
*
|
||||
* Spawns one FreeRTOS task that runs the three loops via FreeRTOS timers.
|
||||
* Idempotent — second call is a no-op.
|
||||
*
|
||||
* @param cfg Config (NULL = use Kconfig defaults).
|
||||
* @return ESP_OK on success.
|
||||
*/
|
||||
esp_err_t adaptive_controller_init(const adapt_config_t *cfg);
|
||||
|
||||
/** Get the current state. */
|
||||
adapt_state_t adaptive_controller_state(void);
|
||||
|
||||
/**
|
||||
* Snapshot the latest observation (most recent fast-loop sample).
|
||||
* Useful for telemetry and the `HEALTH` mesh message.
|
||||
*
|
||||
* @param out Output buffer.
|
||||
* @return true if a valid observation has been recorded.
|
||||
*/
|
||||
bool adaptive_controller_observation(adapt_observation_t *out);
|
||||
|
||||
/**
|
||||
* Force a state transition (e.g. from a remote ROLE_ASSIGN message).
|
||||
* Logged at INFO; controller may immediately transition again on next tick.
|
||||
*/
|
||||
void adaptive_controller_force_state(adapt_state_t st);
|
||||
|
||||
/**
|
||||
* Pure-function policy: given an observation + current state + config,
|
||||
* compute the decision. Exposed in the header so it can be unit-tested
|
||||
* offline (no FreeRTOS / ESP-IDF dependency in the body).
|
||||
*/
|
||||
void adaptive_controller_decide(const adapt_config_t *cfg,
|
||||
adapt_state_t current,
|
||||
const adapt_observation_t *obs,
|
||||
adapt_decision_t *out);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* ADAPTIVE_CONTROLLER_H */
|
||||
@@ -30,6 +30,8 @@
|
||||
#include "display_task.h"
|
||||
#include "mmwave_sensor.h"
|
||||
#include "swarm_bridge.h"
|
||||
#include "rv_radio_ops.h" /* ADR-081 Layer 1 — Radio Abstraction Layer. */
|
||||
#include "adaptive_controller.h" /* ADR-081 Layer 2 — Adaptive controller. */
|
||||
#ifdef CONFIG_CSI_MOCK_ENABLED
|
||||
#include "mock_csi.h"
|
||||
#endif
|
||||
@@ -278,6 +280,26 @@ void app_main(void)
|
||||
ESP_LOGI(TAG, "Mock CSI mode: skipping swarm bridge");
|
||||
#endif
|
||||
|
||||
/* ADR-081 Layer 1: register the ESP32 radio ops binding now that
|
||||
* csi_collector_init() has run. Skipped under mock CSI; a future
|
||||
* mock binding can register itself instead. */
|
||||
#ifndef CONFIG_CSI_MOCK_ENABLED
|
||||
rv_radio_ops_esp32_register();
|
||||
const rv_radio_ops_t *radio_ops = rv_radio_ops_get();
|
||||
if (radio_ops != NULL && radio_ops->init != NULL) {
|
||||
radio_ops->init();
|
||||
}
|
||||
#endif
|
||||
|
||||
/* ADR-081 Layer 2: start the adaptive controller. NULL config → use
|
||||
* Kconfig defaults. Default policy is conservative: no channel
|
||||
* switching, no role change. Operators opt in via menuconfig. */
|
||||
esp_err_t adapt_ret = adaptive_controller_init(NULL);
|
||||
if (adapt_ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Adaptive controller init failed: %s",
|
||||
esp_err_to_name(adapt_ret));
|
||||
}
|
||||
|
||||
/* Initialize power management. */
|
||||
power_mgmt_init(g_nvs_config.power_duty);
|
||||
|
||||
@@ -289,13 +311,14 @@ void app_main(void)
|
||||
}
|
||||
#endif
|
||||
|
||||
ESP_LOGI(TAG, "CSI streaming active → %s:%d (edge_tier=%u, OTA=%s, WASM=%s, mmWave=%s, swarm=%s)",
|
||||
ESP_LOGI(TAG, "CSI streaming active → %s:%d (edge_tier=%u, OTA=%s, WASM=%s, mmWave=%s, swarm=%s, adapt=%s)",
|
||||
g_nvs_config.target_ip, g_nvs_config.target_port,
|
||||
g_nvs_config.edge_tier,
|
||||
(ota_ret == ESP_OK) ? "ready" : "off",
|
||||
(wasm_ret == ESP_OK) ? "ready" : "off",
|
||||
(mmwave_ret == ESP_OK) ? "active" : "off",
|
||||
(swarm_ret == ESP_OK) ? g_nvs_config.seed_url : "off");
|
||||
(swarm_ret == ESP_OK) ? g_nvs_config.seed_url : "off",
|
||||
(adapt_ret == ESP_OK) ? "on" : "off");
|
||||
|
||||
/* Main loop — keep alive */
|
||||
while (1) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @file rv_feature_state.c
|
||||
* @brief ADR-081 Layer 4 — Feature state packet helpers.
|
||||
*/
|
||||
|
||||
#include "rv_feature_state.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
uint32_t rv_feature_state_crc32(const uint8_t *data, size_t len)
|
||||
{
|
||||
/* IEEE CRC32 (poly 0xEDB88320), bit-by-bit. Small (~80 byte) input at
|
||||
* low cadence — no need for a 1 KB lookup table. */
|
||||
uint32_t crc = 0xFFFFFFFFu;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
crc ^= data[i];
|
||||
for (int b = 0; b < 8; b++) {
|
||||
uint32_t mask = -(crc & 1u);
|
||||
crc = (crc >> 1) ^ (0xEDB88320u & mask);
|
||||
}
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
void rv_feature_state_finalize(rv_feature_state_t *pkt,
|
||||
uint8_t node_id,
|
||||
uint16_t seq,
|
||||
uint64_t ts_us,
|
||||
uint8_t mode)
|
||||
{
|
||||
if (pkt == NULL) {
|
||||
return;
|
||||
}
|
||||
pkt->magic = RV_FEATURE_STATE_MAGIC;
|
||||
pkt->node_id = node_id;
|
||||
pkt->mode = mode;
|
||||
pkt->seq = seq;
|
||||
pkt->ts_us = ts_us;
|
||||
pkt->reserved = 0;
|
||||
|
||||
/* CRC32 over everything except the trailing crc32 field itself. */
|
||||
const size_t crc_offset = sizeof(rv_feature_state_t) - sizeof(uint32_t);
|
||||
pkt->crc32 = rv_feature_state_crc32((const uint8_t *)pkt, crc_offset);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @file rv_feature_state.h
|
||||
* @brief ADR-081 Layer 4 — Compact on-wire feature state packet.
|
||||
*
|
||||
* The default upstream payload from a node. Replaces raw ADR-018 CSI as the
|
||||
* primary stream; ADR-018 raw frames remain available as a debug stream
|
||||
* gated by the controller / channel plan.
|
||||
*
|
||||
* Magic numbers in use across the firmware:
|
||||
* 0xC5110001 — ADR-018 raw CSI frame (csi_collector.h)
|
||||
* 0xC5110002 — ADR-039 vitals packet (edge_processing.h)
|
||||
* 0xC5110003 — ADR-069 feature vector (edge_processing.h)
|
||||
* 0xC5110004 — ADR-063 fused vitals (edge_processing.h)
|
||||
* 0xC5110005 — ADR-039 compressed CSI (edge_processing.h)
|
||||
* 0xC5110006 — ADR-081 feature state (this file) ← new
|
||||
*/
|
||||
|
||||
#ifndef RV_FEATURE_STATE_H
|
||||
#define RV_FEATURE_STATE_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/** Magic number for ADR-081 rv_feature_state_t. */
|
||||
#define RV_FEATURE_STATE_MAGIC 0xC5110006u
|
||||
|
||||
/** Quality flag bits. */
|
||||
#define RV_QFLAG_PRESENCE_VALID (1u << 0)
|
||||
#define RV_QFLAG_RESPIRATION_VALID (1u << 1)
|
||||
#define RV_QFLAG_HEARTBEAT_VALID (1u << 2)
|
||||
#define RV_QFLAG_ANOMALY_TRIGGERED (1u << 3)
|
||||
#define RV_QFLAG_ENV_SHIFT_DETECTED (1u << 4)
|
||||
#define RV_QFLAG_DEGRADED_MODE (1u << 5)
|
||||
#define RV_QFLAG_CALIBRATING (1u << 6)
|
||||
#define RV_QFLAG_RECOMMEND_RECAL (1u << 7)
|
||||
|
||||
/**
|
||||
* Compact per-node sensing state. Sent at 1-10 Hz by default, replacing the
|
||||
* raw ADR-018 stream as the primary upstream payload.
|
||||
*
|
||||
* Mode field carries the rv_capture_profile_t value of the dominant window
|
||||
* — receivers can use it to weight features (a sample emitted under
|
||||
* RV_PROFILE_FAST_MOTION will have a stale respiration_bpm, etc.).
|
||||
*
|
||||
* CRC32 is the IEEE polynomial computed over bytes [0 .. sizeof - 4].
|
||||
*/
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint32_t magic; /**< RV_FEATURE_STATE_MAGIC. */
|
||||
uint8_t node_id; /**< Source node id. */
|
||||
uint8_t mode; /**< rv_capture_profile_t at emit time. */
|
||||
uint16_t seq; /**< Monotonic per-node sequence. */
|
||||
uint64_t ts_us; /**< Node-local microseconds. */
|
||||
float motion_score; /**< 0..1, 100 ms window. */
|
||||
float presence_score; /**< 0..1, 1 s window. */
|
||||
float respiration_bpm; /**< Breaths per minute. */
|
||||
float respiration_conf; /**< 0..1. */
|
||||
float heartbeat_bpm; /**< Beats per minute. */
|
||||
float heartbeat_conf; /**< 0..1. */
|
||||
float anomaly_score; /**< 0..1, z-score-derived. */
|
||||
float env_shift_score; /**< 0..1, baseline drift. */
|
||||
float node_coherence; /**< 0..1, multi-link agreement. */
|
||||
uint16_t quality_flags; /**< RV_QFLAG_* bitmap. */
|
||||
uint16_t reserved;
|
||||
uint32_t crc32; /**< IEEE CRC32 over bytes [0..end-4]. */
|
||||
} rv_feature_state_t;
|
||||
|
||||
_Static_assert(sizeof(rv_feature_state_t) == 80,
|
||||
"rv_feature_state_t must be 80 bytes on the wire");
|
||||
|
||||
/**
|
||||
* Compute IEEE CRC32 over a byte buffer.
|
||||
*
|
||||
* Provided here (not in a separate util) because the firmware does not yet
|
||||
* have a shared CRC32 helper — only zlib's via lwIP, which is not always
|
||||
* exposed. This implementation is bit-by-bit; ~80 bytes/packet at low
|
||||
* cadence has negligible CPU cost.
|
||||
*
|
||||
* @param data Input buffer.
|
||||
* @param len Input length in bytes.
|
||||
* @return IEEE CRC32 of the input.
|
||||
*/
|
||||
uint32_t rv_feature_state_crc32(const uint8_t *data, size_t len);
|
||||
|
||||
/**
|
||||
* Finalize an rv_feature_state_t by populating magic, seq, ts_us, and crc32.
|
||||
* Caller fills the remaining fields in-place before calling this. After
|
||||
* finalize() the packet is ready to send on the wire.
|
||||
*
|
||||
* @param pkt Packet to finalize (caller-owned).
|
||||
* @param node_id Source node id (typically csi_collector_get_node_id()).
|
||||
* @param seq Monotonic sequence (caller-managed).
|
||||
* @param ts_us Node-local microseconds (typically esp_timer_get_time()).
|
||||
* @param mode Active rv_capture_profile_t.
|
||||
*/
|
||||
void rv_feature_state_finalize(rv_feature_state_t *pkt,
|
||||
uint8_t node_id,
|
||||
uint16_t seq,
|
||||
uint64_t ts_us,
|
||||
uint8_t mode);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* RV_FEATURE_STATE_H */
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @file rv_radio_ops.h
|
||||
* @brief ADR-081 Layer 1 — Radio Abstraction Layer.
|
||||
*
|
||||
* A single function-pointer vtable (rv_radio_ops_t) that isolates chipset
|
||||
* specific capture details from the layers above (adaptive controller, mesh
|
||||
* plane, feature extraction, Rust handoff).
|
||||
*
|
||||
* Two bindings ship today:
|
||||
* - rv_radio_ops_esp32.c — wraps csi_collector + esp_wifi_*
|
||||
* - rv_radio_ops_mock.c — wraps mock_csi.c (when CONFIG_CSI_MOCK_ENABLED)
|
||||
*
|
||||
* A third binding (Nexmon-patched Broadcom/Cypress) is reserved but not
|
||||
* implemented here. The whole point of the vtable is that the controller
|
||||
* and mesh-plane code above never need to know which one is active.
|
||||
*/
|
||||
|
||||
#ifndef RV_RADIO_OPS_H
|
||||
#define RV_RADIO_OPS_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ---- Modes ---- */
|
||||
|
||||
/** Radio operating modes (set_mode argument). */
|
||||
typedef enum {
|
||||
RV_RADIO_MODE_DISABLED = 0, /**< Receiver off. */
|
||||
RV_RADIO_MODE_PASSIVE_RX = 1, /**< Listen-only, no TX. */
|
||||
RV_RADIO_MODE_ACTIVE_PROBE = 2, /**< Inject NDP frames at high rate. */
|
||||
RV_RADIO_MODE_CALIBRATION = 3, /**< Synchronized calibration burst. */
|
||||
} rv_radio_mode_t;
|
||||
|
||||
/* ---- Capture profiles ---- */
|
||||
|
||||
/**
|
||||
* Named capture profiles. The adaptive controller selects one of these
|
||||
* via set_capture_profile(); the binding maps it to chipset-specific
|
||||
* register/driver state.
|
||||
*/
|
||||
typedef enum {
|
||||
RV_PROFILE_PASSIVE_LOW_RATE = 0, /**< Default idle: minimum cadence. */
|
||||
RV_PROFILE_ACTIVE_PROBE = 1, /**< High-rate NDP injection. */
|
||||
RV_PROFILE_RESP_HIGH_SENS = 2, /**< Quietest channel, vitals-only. */
|
||||
RV_PROFILE_FAST_MOTION = 3, /**< Short window, high cadence. */
|
||||
RV_PROFILE_CALIBRATION = 4, /**< Synchronized burst across nodes. */
|
||||
RV_PROFILE_COUNT
|
||||
} rv_capture_profile_t;
|
||||
|
||||
/* ---- Health snapshot ---- */
|
||||
|
||||
/** Radio-layer health, polled by the adaptive controller. */
|
||||
typedef struct {
|
||||
uint16_t pkt_yield_per_sec; /**< CSI callbacks/second observed. */
|
||||
uint16_t send_fail_count; /**< UDP/socket send failures since last poll. */
|
||||
int8_t rssi_median_dbm; /**< Median RSSI over the last 1 s. */
|
||||
int8_t noise_floor_dbm; /**< Latest noise floor estimate. */
|
||||
uint8_t current_channel; /**< Channel currently configured. */
|
||||
uint8_t current_bw_mhz; /**< Bandwidth currently configured. */
|
||||
uint8_t current_profile; /**< Active rv_capture_profile_t. */
|
||||
uint8_t reserved;
|
||||
} rv_radio_health_t;
|
||||
|
||||
/* ---- The vtable ---- */
|
||||
|
||||
/**
|
||||
* Radio Abstraction Layer ops.
|
||||
*
|
||||
* All function pointers are required (no NULL slots). Each binding must
|
||||
* provide all six. Return values follow ESP-IDF conventions: 0/ESP_OK on
|
||||
* success, negative or ESP_ERR_* on failure.
|
||||
*/
|
||||
typedef struct {
|
||||
/** One-time init (driver register, callback wire-up). */
|
||||
int (*init)(void);
|
||||
|
||||
/**
|
||||
* Tune to a primary channel with the given bandwidth.
|
||||
* @param ch Channel number (1-13 for 2.4 GHz, 36-177 for 5 GHz).
|
||||
* @param bw Bandwidth in MHz (20 or 40; 80/160 reserved for future).
|
||||
*/
|
||||
int (*set_channel)(uint8_t ch, uint8_t bw);
|
||||
|
||||
/** Switch operating mode (rv_radio_mode_t). */
|
||||
int (*set_mode)(uint8_t mode);
|
||||
|
||||
/** Enable or disable the CSI capture path. */
|
||||
int (*set_csi_enabled)(bool en);
|
||||
|
||||
/** Apply a named capture profile (rv_capture_profile_t). */
|
||||
int (*set_capture_profile)(uint8_t profile_id);
|
||||
|
||||
/** Snapshot the radio-layer health (non-blocking). */
|
||||
int (*get_health)(rv_radio_health_t *out);
|
||||
} rv_radio_ops_t;
|
||||
|
||||
/* ---- Registration ---- */
|
||||
|
||||
/**
|
||||
* Register the active radio ops binding.
|
||||
*
|
||||
* Called once at boot by the chipset binding's init code (e.g.
|
||||
* rv_radio_ops_esp32_register()). The pointer must remain valid for the
|
||||
* lifetime of the process — typically a static const inside the binding.
|
||||
*/
|
||||
void rv_radio_ops_register(const rv_radio_ops_t *ops);
|
||||
|
||||
/**
|
||||
* Get the active radio ops binding.
|
||||
*
|
||||
* @return Pointer to the registered ops table, or NULL if no binding has
|
||||
* been registered yet (e.g. before init).
|
||||
*/
|
||||
const rv_radio_ops_t *rv_radio_ops_get(void);
|
||||
|
||||
/* ---- Convenience: ESP32 binding registration ---- */
|
||||
|
||||
/**
|
||||
* Register the ESP32 binding as the active radio ops.
|
||||
*
|
||||
* Call this once at boot, after csi_collector_init() has run. Idempotent.
|
||||
* Defined in rv_radio_ops_esp32.c.
|
||||
*/
|
||||
void rv_radio_ops_esp32_register(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* RV_RADIO_OPS_H */
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @file rv_radio_ops_esp32.c
|
||||
* @brief ADR-081 Layer 1 — ESP32 binding for rv_radio_ops_t.
|
||||
*
|
||||
* Wraps the existing csi_collector + esp_wifi_* surface so the adaptive
|
||||
* controller, mesh plane, and feature-extraction layers can address the
|
||||
* radio through a single chipset-agnostic vtable.
|
||||
*
|
||||
* This is intentionally thin. The heavy lifting still lives in
|
||||
* csi_collector.c (CSI callback, channel hopping, NDP injection); this file
|
||||
* is the contract that lets a second chipset (Nexmon Broadcom, custom
|
||||
* silicon) drop in without touching the layers above.
|
||||
*/
|
||||
|
||||
#include "rv_radio_ops.h"
|
||||
#include "csi_collector.h"
|
||||
|
||||
#include <string.h>
|
||||
#include "esp_err.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_wifi.h"
|
||||
|
||||
static const char *TAG = "rv_radio_esp32";
|
||||
|
||||
/* ---- Active ops registry ---- */
|
||||
|
||||
static const rv_radio_ops_t *s_active_ops = NULL;
|
||||
|
||||
void rv_radio_ops_register(const rv_radio_ops_t *ops)
|
||||
{
|
||||
s_active_ops = ops;
|
||||
}
|
||||
|
||||
const rv_radio_ops_t *rv_radio_ops_get(void)
|
||||
{
|
||||
return s_active_ops;
|
||||
}
|
||||
|
||||
/* ---- ESP32 binding state ---- */
|
||||
|
||||
static uint8_t s_current_channel = 1;
|
||||
static uint8_t s_current_bw = 20;
|
||||
static uint8_t s_current_profile = RV_PROFILE_PASSIVE_LOW_RATE;
|
||||
static uint8_t s_current_mode = RV_RADIO_MODE_PASSIVE_RX;
|
||||
static bool s_csi_enabled = true;
|
||||
|
||||
/* ---- Vtable implementations ---- */
|
||||
|
||||
static int esp32_init(void)
|
||||
{
|
||||
/* csi_collector_init() is called from app_main() before the controller
|
||||
* starts; nothing to do here for the ESP32 binding. We just confirm a
|
||||
* valid current channel was captured by csi_collector_init(). */
|
||||
ESP_LOGI(TAG, "ESP32 radio ops: init (current ch=%u bw=%u)",
|
||||
(unsigned)s_current_channel, (unsigned)s_current_bw);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static int esp32_set_channel(uint8_t ch, uint8_t bw)
|
||||
{
|
||||
wifi_second_chan_t second = WIFI_SECOND_CHAN_NONE;
|
||||
if (bw == 40) {
|
||||
/* HT40+: secondary channel above primary. The controller never asks
|
||||
* for HT40 today (sensing prefers HT20), but the mapping is here so
|
||||
* a future profile can. */
|
||||
second = WIFI_SECOND_CHAN_ABOVE;
|
||||
} else if (bw != 20) {
|
||||
ESP_LOGW(TAG, "set_channel: unsupported bw=%u, treating as 20 MHz",
|
||||
(unsigned)bw);
|
||||
bw = 20;
|
||||
}
|
||||
|
||||
esp_err_t err = esp_wifi_set_channel(ch, second);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "set_channel(%u, bw=%u) failed: %s",
|
||||
(unsigned)ch, (unsigned)bw, esp_err_to_name(err));
|
||||
return (int)err;
|
||||
}
|
||||
s_current_channel = ch;
|
||||
s_current_bw = bw;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static int esp32_set_mode(uint8_t mode)
|
||||
{
|
||||
/* Persist the mode for the health snapshot; actual TX behavior is
|
||||
* triggered by the controller calling csi_inject_ndp_frame() directly
|
||||
* once the controller PR lands. For now this is bookkeeping plus a
|
||||
* passive/active probe gate. */
|
||||
switch (mode) {
|
||||
case RV_RADIO_MODE_DISABLED:
|
||||
case RV_RADIO_MODE_PASSIVE_RX:
|
||||
case RV_RADIO_MODE_ACTIVE_PROBE:
|
||||
case RV_RADIO_MODE_CALIBRATION:
|
||||
s_current_mode = mode;
|
||||
return ESP_OK;
|
||||
default:
|
||||
ESP_LOGW(TAG, "set_mode: unknown mode %u", (unsigned)mode);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
}
|
||||
|
||||
static int esp32_set_csi_enabled(bool en)
|
||||
{
|
||||
esp_err_t err = esp_wifi_set_csi(en);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "set_csi(%d) failed: %s", (int)en, esp_err_to_name(err));
|
||||
return (int)err;
|
||||
}
|
||||
s_csi_enabled = en;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static int esp32_set_capture_profile(uint8_t profile_id)
|
||||
{
|
||||
if (profile_id >= RV_PROFILE_COUNT) {
|
||||
ESP_LOGW(TAG, "set_capture_profile: invalid id %u", (unsigned)profile_id);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
/* Profiles are advisory at this layer — the controller uses them to
|
||||
* decide cadence/window/threshold for the layers above. The radio
|
||||
* binding records the active profile for health reporting and may
|
||||
* adjust the underlying TX/RX mode in future bindings. */
|
||||
s_current_profile = profile_id;
|
||||
|
||||
/* For ACTIVE_PROBE and CALIBRATION, switch the radio mode to match. */
|
||||
if (profile_id == RV_PROFILE_ACTIVE_PROBE) {
|
||||
esp32_set_mode(RV_RADIO_MODE_ACTIVE_PROBE);
|
||||
} else if (profile_id == RV_PROFILE_CALIBRATION) {
|
||||
esp32_set_mode(RV_RADIO_MODE_CALIBRATION);
|
||||
} else {
|
||||
esp32_set_mode(RV_RADIO_MODE_PASSIVE_RX);
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static int esp32_get_health(rv_radio_health_t *out)
|
||||
{
|
||||
if (out == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
/* pkt_yield and send_fail are filled by the adaptive controller from
|
||||
* its own counters today (csi_collector keeps statics that are not yet
|
||||
* exposed). The binding fills the fields it owns directly. */
|
||||
out->current_channel = s_current_channel;
|
||||
out->current_bw_mhz = s_current_bw;
|
||||
out->current_profile = s_current_profile;
|
||||
|
||||
wifi_ap_record_t ap = {0};
|
||||
if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) {
|
||||
out->rssi_median_dbm = ap.rssi;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* ---- The vtable instance ---- */
|
||||
|
||||
static const rv_radio_ops_t s_esp32_ops = {
|
||||
.init = esp32_init,
|
||||
.set_channel = esp32_set_channel,
|
||||
.set_mode = esp32_set_mode,
|
||||
.set_csi_enabled = esp32_set_csi_enabled,
|
||||
.set_capture_profile = esp32_set_capture_profile,
|
||||
.get_health = esp32_get_health,
|
||||
};
|
||||
|
||||
void rv_radio_ops_esp32_register(void)
|
||||
{
|
||||
if (s_active_ops == &s_esp32_ops) {
|
||||
return; /* idempotent */
|
||||
}
|
||||
rv_radio_ops_register(&s_esp32_ops);
|
||||
ESP_LOGI(TAG, "ESP32 radio ops registered as active binding");
|
||||
}
|
||||
Reference in New Issue
Block a user