Files
ruvnet--RuView/firmware/esp32-csi-node/main/adaptive_controller.c
rUv 5a7f431b0e 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>
2026-04-20 10:38:23 -04:00

415 lines
14 KiB
C
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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 110 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;
}