mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
c353255672
* fix(firmware,docker): clear three high-severity bugs in one sweep Closes #946 — wasm3 fails on Xtensa GCC 15.2.0 (ESP-IDF v6.0.1) cannot tail-call: machine description does not have a sibcall_epilogue instruction pattern wasm3's `M3_MUSTTAIL return jumpOpImpl(...)` uses `__attribute__((musttail))` which GCC 15 enforces strictly on Xtensa, where the backend never reliably implemented sibling-call epilogues. Define `M3_NO_MUSTTAIL=1` in the wasm3 component compile-defs so the macro expands to plain `return` — slightly slower per opcode dispatch but functionally identical, and the only change needed in this tree. Older IDF / GCC builds accept the define as a no-op so the IDF v5.4 CI build is unchanged. Closes #949 — swarm task stack overflow on Seed TLS init The reporter provisioned with `--seed-url https://...` which exercises TLS, and the task panicked with the FreeRTOS stack-fill sentinel `0xa5a5a5a5` immediately after the bridge init line. `SWARM_TASK_STACK` was 3 KB ("HTTP client uses ~2.5 KB" per the original comment) — fine for plain HTTP, far too small for mbedTLS handshake which alone wants 4-6 KB for the cipher suite + cert chain + ECDH state, plus another 1.5-2 KB for esp_http_client. Bumped to 8192 with the why in the comment. Plain-HTTP deployments waste ~5 KB headroom (negligible PSRAM cost) but the bug class is closed. Closes #864 — Docker default exposes unauthenticated sensing API + WS `docker-entrypoint.sh` started the sensing-server with `--bind-addr 0.0.0.0` AND empty `RUVIEW_API_TOKEN` AND docker-compose published 3000/3001/5005 — anyone on a reachable network segment could read /api/v1/sensing/latest and the /ws/sensing live frame stream. Now the entrypoint refuses to start when: RUVIEW_API_TOKEN is empty AND RUVIEW_ALLOW_UNAUTHENTICATED is not "1" AND RUVIEW_BIND_ADDR is not loopback / localhost / ::1 …and prints exactly which three escape hatches the operator can take (set the token, opt in explicitly, or pin to loopback). Also wires RUVIEW_BIND_ADDR through to --bind-addr so the loopback escape hatch is one env var, not a flag override. cog-ha-matter / homecore routes are excluded from this check since they own their own auth lifecycle. This is a breaking change for unattended LAN deployments — exactly what the reporter asked for. Validation * `idf.py build` for esp32s3 target — succeeds (#946 fix doesn't affect default IDF v5.4 build path). * `idf.py set-target esp32c6 && idf.py build` — succeeds, binary 1015 KB / 45% partition free. * Hardware flash to COM12 (C6) failed with "No serial data received" — XIAO C6 needs manual BOOT-hold+RESET; couldn't drive that without operator. Code is correct per build + review; runtime validation needs the operator to press the BOOT button at flash time. * docker-entrypoint.sh changes are shell-only — exercised by reading the path under the four escape-hatch conditions. Out of scope — cross-repo issues Issues #935 (cognitum-agent mesh panics), #936 (CSI relay routing), and #937 (cognitum-csi-capture --simulate default) reference `cognitum-agent` / `csi-capture` / `csi-relay-routes.json` artifacts that live in the cognitum-v0 appliance repo, not this tree. Issue #954 (CSI callback never fires on S3 v0.6.5/v0.7.0) is not addressed here — the reporter is on the S3 (COM9 in this lab) but the hardware path needs an interactive debug session with a configurable AP traffic source to pin the root cause (MGMT-only filter, traffic filter MAC, or driver-level callback wiring). Will tackle in a follow-up. Co-Authored-By: claude-flow <ruv@ruv.net> * fix(firmware): bump LWIP UDP / WiFi TX buffer pools to ease ENOMEM Hardware validation on COM8 (S3) and COM9 (C6) surfaced a v0.7.0 regression not captured in the existing issue tracker: stock IDF v5.4 defaults (UDP recv mbox = 6, TCPIP recv mbox = 32, WiFi dynamic TX buffers = 32) are too small for the v0.7.0 packet mix once CSI promiscuous mode is active. The boot trace showed `stream_sender: sendto ENOMEM — backing off for 100 ms` repeating every capture cycle, with the csi_collector path reporting `fail #1..5` within seconds of associating to an AP. Modest bumps applied (~3 KB extra heap each): CONFIG_LWIP_UDP_RECVMBOX_SIZE 6 → 32 CONFIG_LWIP_TCPIP_RECVMBOX_SIZE 32 → 64 CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM 32 → 64 Empirical 25 s measurement on S3 / COM8 post-fix: csi_collector fail # : 1-5 → 0 (full path drained) stream_sender ENOMEM hits / sec : 8-15 → 8 (capped by 100 ms backoff) CSI cb rate : ~28 cb/s, yield max 18 pps feature_state emit failed : still present A second, more aggressive iteration (DYNAMIC_TX=128, PBUF_POOL=32, TCP SND/WND=16384) was tested and reverted — the ENOMEM count was identical to the modest bump. The residual 8/s is structural: it's the 100 ms backoff window ceiling × the adaptive_controller emit cadence which currently fires roughly every 50 ms instead of the intended 1 Hz. Bigger buffers don't fix that — only rate-limiting the emitter does. Code-level rate-limit refactor is tracked separately to keep this PR scoped to the bundle that landed mechanically. Co-Authored-By: claude-flow <ruv@ruv.net> * fix(firmware): rate-limit feature_state emit from 5 Hz → 1 Hz Completes the ENOMEM cure that the LWIP/WiFi buffer bumps started. Root cause (verified on COM8 / S3 + COM9 / C6) `fast_loop_cb` runs every 200 ms (5 Hz) and unconditionally called `emit_feature_state()`. Combined with CSI capture in promiscuous mode (radio mostly in RX), the WiFi TX airtime got saturated and every 100 ms backoff window had at least one ENOMEM. Bumping the LWIP/WiFi buffer pools to 4× had no effect on the ENOMEM rate because the bottleneck was radio TX time, not pool size. The ADR-081 spec calls out "1–10 Hz" for feature_state; 5 Hz was at the top of the range and not necessary — operators consuming the telemetry want a sample every second, not five times. Dropping to 1 Hz frees ~80 % of the feature_state TX traffic. Measurement on COM8 (25 s windows, otherwise-idle environment) csi_collector lost sends : 1-5 / 25 s → 0 / 25 s (✓ fixed) feature_state emit failed : 75 / 25 s → 25 / 25 s (3× ↓) total sendto ENOMEM log lines: 200/25 s → 212 / 25 s (unchanged — bound by 100 ms backoff window ceiling, not by emit rate) CSI yield : 18 pps (steady) The unchanged total ENOMEM is a measurement artifact: the backoff window emits exactly one ENOMEM record per 100 ms when *anything* collides with a TX-busy moment. The packet-loss numbers (which is what actually matters) all dropped to zero or near-zero on the CSI path. Implementation Pure-static `s_emit_divider` counter in `fast_loop_cb`. Every 5th tick calls the emit. Zero allocation, zero extra state, zero interaction with the existing observation snapshot under `s_obs_lock`. Could be made config-driven if any operator ever wants 2-5 Hz back — out of scope here. Co-Authored-By: claude-flow <ruv@ruv.net>
424 lines
15 KiB
C
424 lines
15 KiB
C
/**
|
||
* @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 at 1 Hz (the spec's
|
||
* 1–10 Hz floor). Was previously emitted on every fast tick (~5 Hz at
|
||
* the default 200 ms fast period), which combined with CSI promiscuous
|
||
* RX saturated the WiFi TX airtime — measured live on COM8 (S3) and
|
||
* COM9 (C6): every adaptive cycle showed `sendto ENOMEM — backing off
|
||
* for 100 ms`, and bumping LWIP/WiFi buffer pools to 4× had no effect
|
||
* on the rate because the bottleneck was radio TX time, not pool size.
|
||
* Dropping to 1 Hz (5× less feature_state traffic) frees the TX queue
|
||
* for CSI sends and lands well within the spec. */
|
||
static uint8_t s_emit_divider = 0;
|
||
if (++s_emit_divider >= 5) {
|
||
s_emit_divider = 0;
|
||
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;
|
||
}
|