diff --git a/firmware/esp32-csi-node/components/ruv_temporal/CMakeLists.txt b/firmware/esp32-csi-node/components/ruv_temporal/CMakeLists.txt index 8a5edcb6..d1d4d9c3 100644 --- a/firmware/esp32-csi-node/components/ruv_temporal/CMakeLists.txt +++ b/firmware/esp32-csi-node/components/ruv_temporal/CMakeLists.txt @@ -1,16 +1,27 @@ # ESP-IDF component manifest for the ruv_temporal Rust staticlib (ADR-095). # # Build flow: -# 1. Run `cargo +esp build --release --target xtensa-esp32s3-none-elf` in -# this directory. Output: target/xtensa-esp32s3-none-elf/release/libruv_temporal.a -# 2. Register the resulting static library and the public header dir -# with idf_component_register so it shows up on the firmware's -# include path and link line. +# - When CONFIG_CSI_TEMPORAL_HEAD_ENABLED is OFF (default): register an +# empty stub. main/temporal_task.c compiles the no-op shim path, no +# cargo, no Rust toolchain dependency. Default firmware build is +# unaffected. +# - When CONFIG_CSI_TEMPORAL_HEAD_ENABLED is ON: invoke +# `cargo +esp build --release --target xtensa-esp32s3-none-elf`, +# register the resulting libruv_temporal.a, and expose include/. # -# Phase 4: scaffold only — registered but no kernel work runs yet. -# Phase 5: cross-compile validated, binary delta measured. -# Phase 6: enabled via CONFIG_CSI_TEMPORAL_HEAD_ENABLED Kconfig flag and -# fed from edge_processing.c. +# add_custom_command is intentionally placed AFTER idf_component_register +# because ESP-IDF runs every component's CMakeLists.txt twice — once in +# script mode for dependency discovery (where add_custom_command is +# forbidden), and once for the actual build. + +if(NOT CONFIG_CSI_TEMPORAL_HEAD_ENABLED) + # Feature disabled — register an empty component so the directory's + # mere existence doesn't break the build, but do NOT invoke cargo + # or pull include/ onto consumers' include paths (the C ABI header + # would advertise capabilities we cannot honour). + idf_component_register() + return() +endif() set(RUV_TEMPORAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}") set(RUV_TEMPORAL_TARGET "xtensa-esp32s3-none-elf") @@ -18,9 +29,13 @@ set(RUV_TEMPORAL_PROFILE "release") set(RUV_TEMPORAL_LIB "${RUV_TEMPORAL_DIR}/target/${RUV_TEMPORAL_TARGET}/${RUV_TEMPORAL_PROFILE}/libruv_temporal.a") -# Run the cargo build as a custom command. ESP-IDF's CMake runs at -# configure time; we want the staticlib to exist before idf_component_register -# runs so it can be added as INTERFACE_LINK_LIBRARIES. +idf_component_register( + SRCS "shim.c" + INCLUDE_DIRS "include" + PRIV_REQUIRES "esp_common" +) + +# Custom command + target run only at build time, not in script mode. add_custom_command( OUTPUT "${RUV_TEMPORAL_LIB}" WORKING_DIRECTORY "${RUV_TEMPORAL_DIR}" @@ -30,12 +45,5 @@ add_custom_command( ) add_custom_target(ruv_temporal_rust_build ALL DEPENDS "${RUV_TEMPORAL_LIB}") -idf_component_register( - SRCS "shim.c" # tiny C shim so idf_component_register has a SRCS - INCLUDE_DIRS "include" - PRIV_REQUIRES "esp_common" -) - -# Wire the staticlib in. add_dependencies(${COMPONENT_LIB} ruv_temporal_rust_build) target_link_libraries(${COMPONENT_LIB} INTERFACE "${RUV_TEMPORAL_LIB}") diff --git a/firmware/esp32-csi-node/main/CMakeLists.txt b/firmware/esp32-csi-node/main/CMakeLists.txt index 6f0930a5..9a087c13 100644 --- a/firmware/esp32-csi-node/main/CMakeLists.txt +++ b/firmware/esp32-csi-node/main/CMakeLists.txt @@ -9,10 +9,19 @@ set(SRCS "rv_feature_state.c" "rv_mesh.c" "adaptive_controller.c" + # ADR-095 / #513 — on-device temporal head (no-op shims when CONFIG_CSI_TEMPORAL_HEAD_ENABLED off) + "temporal_task.c" ) set(REQUIRES "") +# ADR-095: link the Rust ruv_temporal staticlib only when the feature is on, +# so the default firmware build doesn't depend on the (currently blocked) +# esp Rust toolchain. +if(CONFIG_CSI_TEMPORAL_HEAD_ENABLED) + list(APPEND REQUIRES ruv_temporal) +endif() + # ADR-061: Mock CSI generator for QEMU testing + ADR-081 mock radio binding if(CONFIG_CSI_MOCK_ENABLED) list(APPEND SRCS "mock_csi.c" "rv_radio_ops_mock.c") diff --git a/firmware/esp32-csi-node/main/Kconfig.projbuild b/firmware/esp32-csi-node/main/Kconfig.projbuild index 4e5895bb..7da7e03f 100644 --- a/firmware/esp32-csi-node/main/Kconfig.projbuild +++ b/firmware/esp32-csi-node/main/Kconfig.projbuild @@ -323,3 +323,56 @@ menu "Mock CSI (QEMU Testing)" depends on CSI_MOCK_ENABLED default n endmenu + +menu "On-device temporal head (ADR-095, #513)" + + config CSI_TEMPORAL_HEAD_ENABLED + bool "Enable on-device temporal-head classification" + default n + help + Compiles the ruv_temporal FreeRTOS task that runs a learned + transformer-style temporal head over the rv_feature_state + stream. Backed by the Rust ruvllm_sparse_attention staticlib + in components/ruv_temporal/. Default off — the Rust component + requires the esp Rust toolchain (see component README) and + adds ~376 KB to the firmware image. Off-board (8 MB) only + until the binary delta is measured on real hardware. + + config TEMPORAL_INPUT_DIM + int "Input feature dimension" + depends on CSI_TEMPORAL_HEAD_ENABLED + default 16 + range 1 256 + help + Per-frame feature dimension fed into the temporal head. + 16 matches a small projection of rv_feature_state_t; bump + after the host-side training crate fixes the model schema. + + config TEMPORAL_WINDOW_LEN + int "Rolling window length (frames)" + depends on CSI_TEMPORAL_HEAD_ENABLED + default 256 + range 32 1024 + help + Number of feature frames the temporal head reasons over. + 256 frames at the controller's 5 Hz fast-loop rate is ~50 s. + + config TEMPORAL_N_CLASSES + int "Number of output classes" + depends on CSI_TEMPORAL_HEAD_ENABLED + default 4 + range 2 16 + help + Number of classification logits the model produces. Must be + ≤ TEMPORAL_MAX_LOGITS in temporal_task.c (16). + + config TEMPORAL_CLASSIFY_PERIOD_MS + int "Classification cadence (ms)" + depends on CSI_TEMPORAL_HEAD_ENABLED + default 1000 + range 100 60000 + help + How often the temporal task runs ruv_temporal_classify and + emits a 0xC5110007 packet. Default 1 s. + +endmenu diff --git a/firmware/esp32-csi-node/main/adaptive_controller.c b/firmware/esp32-csi-node/main/adaptive_controller.c index 1e8869a9..90372ef2 100644 --- a/firmware/esp32-csi-node/main/adaptive_controller.c +++ b/firmware/esp32-csi-node/main/adaptive_controller.c @@ -19,6 +19,7 @@ #include "edge_processing.h" #include "stream_sender.h" #include "csi_collector.h" +#include "temporal_task.h" /* ADR-095 / #513: on-device temporal head */ #include #include "freertos/FreeRTOS.h" @@ -314,6 +315,18 @@ static void emit_feature_state(void) if (sent < 0) { ESP_LOGW(TAG, "feature_state emit failed"); } + + /* ADR-095 / #513: feed the same 9 feature floats into the on-device + * temporal head if it is enabled. Non-blocking — drops are logged + * by temporal_task itself, never by us. With CONFIG_CSI_TEMPORAL_HEAD_ENABLED + * off, this resolves to a single ESP_ERR_NOT_SUPPORTED return. */ + const float feat[9] = { + pkt.motion_score, pkt.presence_score, + pkt.respiration_bpm, pkt.respiration_conf, + pkt.heartbeat_bpm, pkt.heartbeat_conf, + pkt.anomaly_score, pkt.env_shift_score, pkt.node_coherence, + }; + (void)temporal_task_push_frame(feat, 9); } static void slow_loop_cb(TimerHandle_t t) diff --git a/firmware/esp32-csi-node/main/main.c b/firmware/esp32-csi-node/main/main.c index b80b0f83..518ac09f 100644 --- a/firmware/esp32-csi-node/main/main.c +++ b/firmware/esp32-csi-node/main/main.c @@ -21,6 +21,7 @@ #include "csi_collector.h" #include "stream_sender.h" +#include "temporal_task.h" /* ADR-095 / #513 */ #include "nvs_config.h" #include "edge_processing.h" #include "ota_update.h" @@ -310,6 +311,22 @@ void app_main(void) esp_err_to_name(adapt_ret)); } + /* ADR-095 / #513: spin up the on-device temporal head. Returns + * ESP_ERR_NOT_SUPPORTED when CONFIG_CSI_TEMPORAL_HEAD_ENABLED is + * off — that is the default and not an error. The fast loop + * pushes feature frames; the task runs classify at a slower + * cadence and emits 0xC5110007 packets. */ +#ifdef CONFIG_CSI_TEMPORAL_HEAD_ENABLED + esp_err_t tmp_ret = temporal_task_start( + (uint32_t)CONFIG_TEMPORAL_INPUT_DIM, + (uint32_t)CONFIG_TEMPORAL_WINDOW_LEN, + (uint32_t)CONFIG_TEMPORAL_N_CLASSES); + if (tmp_ret != ESP_OK) { + ESP_LOGW(TAG, "temporal task init failed: %s", + esp_err_to_name(tmp_ret)); + } +#endif + /* Initialize power management. */ power_mgmt_init(g_nvs_config.power_duty); diff --git a/firmware/esp32-csi-node/main/temporal_task.c b/firmware/esp32-csi-node/main/temporal_task.c new file mode 100644 index 00000000..b7897506 --- /dev/null +++ b/firmware/esp32-csi-node/main/temporal_task.c @@ -0,0 +1,304 @@ +/** + * @file temporal_task.c + * @brief ADR-095 / #513 — On-device temporal head FreeRTOS task. + * + * Owns the only `ruv_temporal_ctx_t` in the firmware. Receives feature + * frames from the adaptive_controller fast loop via a FreeRTOS queue, + * pushes them into the rolling window, and at ~1 Hz runs a + * classification forward through the Rust `ruvllm_sparse_attention` + * staticlib (when built — see CONFIG_CSI_TEMPORAL_HEAD_ENABLED). + * + * The whole file compiles down to no-op shims when the feature is off, + * so adaptive_controller.c can call `temporal_task_push_frame()` + * unconditionally — the function returns ESP_ERR_NOT_SUPPORTED and + * costs one nullable check. + */ + +#include "temporal_task.h" + +#include +#include "esp_log.h" +#include "esp_timer.h" +#include "sdkconfig.h" + +static const char *TAG = "temporal"; + +#ifdef CONFIG_CSI_TEMPORAL_HEAD_ENABLED + +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" + +#include "csi_collector.h" /* node_id */ +#include "stream_sender.h" +#include "ruv_temporal.h" /* C ABI from components/ruv_temporal */ + +/* Queue depth — picked so that the adaptive controller's fast loop + * (default 5 Hz) can't overrun the temporal task even if classify() + * stalls for ~6 s. Drops beyond that are logged. */ +#define TEMPORAL_QUEUE_DEPTH 32 + +/* Stack sized per ADR-095 §3.3. The kernel forward + intermediate + * tensors are bounded by `forward_flash` tiling, but rv_feature_state + * marshalling, logging, and stream_sender_send all share this stack. */ +#define TEMPORAL_TASK_STACK 16384 + +/* Pinned to Core 1, like edge_dsp. WiFi runs on Core 0 — keep them + * apart so the temporal forward doesn't compete with CSI capture. */ +#define TEMPORAL_TASK_CORE 1 + +/* Classification cadence in milliseconds. 1 Hz is the ADR-095 §3 default. */ +#ifndef CONFIG_TEMPORAL_CLASSIFY_PERIOD_MS +#define CONFIG_TEMPORAL_CLASSIFY_PERIOD_MS 1000 +#endif + +/* Maximum logits buffer — sized to the largest n_classes any of the + * ADR-095 §4 use cases needs (anomaly = 2, fall = 3, gesture = 8). */ +#define TEMPORAL_MAX_LOGITS 16 + +/* ---- Module state ----------------------------------------------------- */ + +typedef struct { + float frame[TEMPORAL_MAX_LOGITS * 8]; /* generous; trimmed via input_dim */ + uint32_t frame_len; +} temporal_msg_t; + +static QueueHandle_t s_queue; +static TaskHandle_t s_task; +static ruv_temporal_ctx_t *s_ctx; +static uint32_t s_input_dim; +static uint32_t s_window_len; +static uint32_t s_n_classes; +static uint32_t s_seq; +static uint32_t s_drop_count; +static uint64_t s_last_drop_log_us; + +/* Lightweight CRC32 (IEEE 802.3 polynomial 0xEDB88320), table-free. + * Used only for the 36-byte classification packet — speed isn't + * critical. Existing firmware has its own CRC32 in csi_collector.c + * but we don't link against it from here to keep coupling narrow. */ +static uint32_t crc32_ieee(const uint8_t *data, size_t len) +{ + 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 = -(int32_t)(crc & 1u); + crc = (crc >> 1) ^ (0xEDB88320u & mask); + } + } + return ~crc; +} + +static void emit_classification(const float *logits, uint32_t n) +{ + /* Find argmax + margin in one pass. */ + uint32_t argmax = 0; + float top1 = logits[0]; + float top2 = -1e30f; + for (uint32_t i = 1; i < n; i++) { + float v = logits[i]; + if (v > top1) { + top2 = top1; + top1 = v; + argmax = i; + } else if (v > top2) { + top2 = v; + } + } + + rv_temporal_pkt_t pkt; + memset(&pkt, 0, sizeof(pkt)); + pkt.magic = RV_TEMPORAL_PKT_MAGIC; + pkt.version = 1; + pkt.n_classes = (uint16_t)n; + pkt.node_id = csi_collector_get_node_id(); + pkt.ts_us = (uint64_t)esp_timer_get_time(); + pkt.seq = ++s_seq; + pkt.argmax = (uint8_t)argmax; + pkt.top_logit = top1; + pkt.top1_minus_top2 = top1 - top2; + pkt.crc32 = crc32_ieee((const uint8_t *)&pkt, sizeof(pkt) - sizeof(pkt.crc32)); + + int sent = stream_sender_send((const uint8_t *)&pkt, sizeof(pkt)); + if (sent < 0) { + ESP_LOGW(TAG, "classification emit failed"); + } +} + +static void temporal_task_loop(void *arg) +{ + (void)arg; + ESP_LOGI(TAG, "temporal task online (window=%u dim=%u classes=%u core=%d)", + (unsigned)s_window_len, (unsigned)s_input_dim, + (unsigned)s_n_classes, TEMPORAL_TASK_CORE); + + /* Self-test the kernel link before touching real frames. */ + if (ruv_temporal_kernel_self_test() != ESP_OK) { + ESP_LOGE(TAG, "ruv_temporal_kernel_self_test FAILED — temporal head disabled"); + s_ctx = NULL; + vTaskDelete(NULL); + return; + } + + uint64_t next_classify_us = esp_timer_get_time() + + (uint64_t)CONFIG_TEMPORAL_CLASSIFY_PERIOD_MS * 1000ull; + float logits[TEMPORAL_MAX_LOGITS]; + + for (;;) { + temporal_msg_t msg; + /* Block up to 100 ms for a frame, then check if it's time to + * classify. This double-poll keeps the cadence honest even + * during long quiet periods. */ + if (xQueueReceive(s_queue, &msg, pdMS_TO_TICKS(100)) == pdTRUE) { + if (s_ctx != NULL) { + (void)ruv_temporal_push(s_ctx, msg.frame); + } + } + + uint64_t now_us = esp_timer_get_time(); + if (now_us >= next_classify_us && s_ctx != NULL) { + esp_err_t cret = ruv_temporal_classify(s_ctx, logits, s_n_classes); + if (cret == ESP_OK) { + emit_classification(logits, s_n_classes); + } else { + ESP_LOGW(TAG, "classify returned 0x%x", (unsigned)cret); + } + next_classify_us = now_us + + (uint64_t)CONFIG_TEMPORAL_CLASSIFY_PERIOD_MS * 1000ull; + } + + /* Coalesce drop-count logs to once per second so a backlog + * doesn't flood the serial console. */ + if (s_drop_count > 0 && now_us - s_last_drop_log_us > 1000000ull) { + ESP_LOGW(TAG, "queue full — dropped %u feature frames", + (unsigned)s_drop_count); + s_drop_count = 0; + s_last_drop_log_us = now_us; + } + } +} + +esp_err_t temporal_task_start(uint32_t input_dim, + uint32_t window_len, + uint32_t n_classes) +{ + if (s_task != NULL) { + return ESP_OK; /* idempotent */ + } + if (input_dim == 0 || window_len == 0 || n_classes == 0) { + return ESP_ERR_INVALID_ARG; + } + if (n_classes > TEMPORAL_MAX_LOGITS) { + ESP_LOGE(TAG, "n_classes=%u exceeds TEMPORAL_MAX_LOGITS=%d", + (unsigned)n_classes, TEMPORAL_MAX_LOGITS); + return ESP_ERR_INVALID_SIZE; + } + + /* Allocate the kernel context. Phase 4 stub returns ESP_OK without + * weights; Phase 5b will accept a real weights blob. */ + esp_err_t ret = ruv_temporal_init(NULL, 0, input_dim, window_len, n_classes, + &s_ctx); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "ruv_temporal_init failed: 0x%x", (unsigned)ret); + return ret; + } + + s_input_dim = input_dim; + s_window_len = window_len; + s_n_classes = n_classes; + s_seq = 0; + s_drop_count = 0; + s_last_drop_log_us = 0; + + s_queue = xQueueCreate(TEMPORAL_QUEUE_DEPTH, sizeof(temporal_msg_t)); + if (s_queue == NULL) { + ESP_LOGE(TAG, "queue create failed"); + ruv_temporal_destroy(s_ctx); + s_ctx = NULL; + return ESP_ERR_NO_MEM; + } + + BaseType_t ok = xTaskCreatePinnedToCore( + temporal_task_loop, "ruv_temporal", TEMPORAL_TASK_STACK, + NULL, 4 /* priority, below edge_dsp */, + &s_task, TEMPORAL_TASK_CORE); + if (ok != pdPASS) { + ESP_LOGE(TAG, "task create failed"); + vQueueDelete(s_queue); + s_queue = NULL; + ruv_temporal_destroy(s_ctx); + s_ctx = NULL; + return ESP_ERR_NO_MEM; + } + return ESP_OK; +} + +esp_err_t temporal_task_push_frame(const float *frame, uint32_t frame_len) +{ + if (frame == NULL || frame_len == 0) { + return ESP_ERR_INVALID_ARG; + } + if (s_queue == NULL) { + return ESP_ERR_NOT_FOUND; + } + temporal_msg_t msg; + uint32_t cap = (uint32_t)(sizeof(msg.frame) / sizeof(msg.frame[0])); + uint32_t n = (frame_len < cap) ? frame_len : cap; + if (n < s_input_dim) { + /* Pad short frames with zeros so the rolling window stays + * dimension-stable from the kernel's perspective. */ + memcpy(msg.frame, frame, n * sizeof(float)); + memset(&msg.frame[n], 0, (s_input_dim - n) * sizeof(float)); + msg.frame_len = s_input_dim; + } else { + memcpy(msg.frame, frame, s_input_dim * sizeof(float)); + msg.frame_len = s_input_dim; + } + + /* Non-blocking — temporal head is best-effort. */ + if (xQueueSend(s_queue, &msg, 0) != pdPASS) { + s_drop_count++; + return ESP_ERR_TIMEOUT; + } + return ESP_OK; +} + +void temporal_task_stop(void) +{ + if (s_task != NULL) { + vTaskDelete(s_task); + s_task = NULL; + } + if (s_queue != NULL) { + vQueueDelete(s_queue); + s_queue = NULL; + } + if (s_ctx != NULL) { + ruv_temporal_destroy(s_ctx); + s_ctx = NULL; + } +} + +#else /* !CONFIG_CSI_TEMPORAL_HEAD_ENABLED */ + +esp_err_t temporal_task_start(uint32_t input_dim, + uint32_t window_len, + uint32_t n_classes) +{ + (void)input_dim; + (void)window_len; + (void)n_classes; + return ESP_ERR_NOT_SUPPORTED; +} + +esp_err_t temporal_task_push_frame(const float *frame, uint32_t frame_len) +{ + (void)frame; + (void)frame_len; + return ESP_ERR_NOT_SUPPORTED; +} + +void temporal_task_stop(void) {} + +#endif /* CONFIG_CSI_TEMPORAL_HEAD_ENABLED */ diff --git a/firmware/esp32-csi-node/main/temporal_task.h b/firmware/esp32-csi-node/main/temporal_task.h new file mode 100644 index 00000000..94319535 --- /dev/null +++ b/firmware/esp32-csi-node/main/temporal_task.h @@ -0,0 +1,98 @@ +/* SPDX-License-Identifier: MIT + * + * temporal_task.h — On-device temporal head FreeRTOS task (ADR-095, #513). + * + * Owns the lifecycle of the `ruv_temporal_ctx_t` from + * components/ruv_temporal/include/ruv_temporal.h. Exposes: + * + * 1. `temporal_task_start()` — spawn the task with its own 16 KB stack + * pinned to Core 1, allocate a feed queue. Caller (main.c) ignores + * ESP_ERR_NOT_SUPPORTED when CONFIG_CSI_TEMPORAL_HEAD_ENABLED is off. + * 2. `temporal_task_push_frame()` — non-blocking enqueue from the + * adaptive_controller fast loop. Drops on full queue (logs once + * per second) — the temporal head is best-effort, the physics-only + * path keeps producing vitals regardless. + * 3. `temporal_task_stop()` — cleanly tear down (currently used only + * for tests; production firmware never calls this). + * + * Thread safety: per ADR-095 §3.3 the temporal task itself is the + * single owner of the underlying `ruv_temporal_ctx_t`. Callers + * communicate exclusively via the FreeRTOS queue. + * + * Output: every ~1 s the task runs `ruv_temporal_classify` and emits a + * `0xC5110007 RV_TEMPORAL_CLASSIFICATION` packet via stream_sender. + */ + +#pragma once + +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* Magic for the classification packet (ADR-095 §3.5). 0xC5110001..0006 + * are taken; 0007 is the next free slot. */ +#define RV_TEMPORAL_PKT_MAGIC 0xC5110007u + +/* On-the-wire packet for one classification result. Little-endian. + * Size: 40 bytes. CRC covers everything before it. + * + * Field layout (bytes): + * [00..04) magic 4 + * [04..06) version 2 + * [06..08) n_classes 2 + * [08..09) node_id 1 + * [09..0C) reserved 3 + * [0C..14) ts_us 8 + * [14..18) seq 4 + * [18..19) argmax 1 + * [19..1C) reserved2 3 + * [1C..20) top_logit 4 + * [20..24) top1_minus_top2 4 + * [24..28) crc32 4 + * total: 40 + */ +typedef struct __attribute__((packed)) { + uint32_t magic; /* 0xC5110007 */ + uint16_t version; /* 1 */ + uint16_t n_classes; /* matches init() value */ + uint8_t node_id; /* csi_collector_get_node_id() */ + uint8_t reserved[3]; + uint64_t ts_us; /* esp_timer_get_time() at classify */ + uint32_t seq; /* monotonic, increments per emit */ + uint8_t argmax; /* highest-logit class */ + uint8_t reserved2[3]; + float top_logit; /* logits[argmax] */ + float top1_minus_top2; /* margin — useful for downstream gating */ + uint32_t crc32; +} rv_temporal_pkt_t; + +/* Build-time guard so the wire format never silently changes. */ +_Static_assert(sizeof(rv_temporal_pkt_t) == 40, + "rv_temporal_pkt_t must be 40 bytes (ADR-095 §3.5)"); + +/* Start the temporal task. Returns ESP_ERR_NOT_SUPPORTED when the + * feature is compiled out — caller should treat that as a non-error + * and continue. Returns ESP_OK on success. + * + * input_dim : feature dimension per frame (e.g. 60 for rv_feature_state_t) + * window_len : rolling window in frames (e.g. 256) + * n_classes : number of output logits the model produces (e.g. 4) + */ +esp_err_t temporal_task_start(uint32_t input_dim, + uint32_t window_len, + uint32_t n_classes); + +/* Non-blocking push from the adaptive_controller fast loop. Returns + * ESP_OK on enqueue, ESP_ERR_NOT_FOUND if the task isn't running, + * ESP_ERR_TIMEOUT if the queue was full. Never blocks the caller. */ +esp_err_t temporal_task_push_frame(const float *frame, uint32_t frame_len); + +/* Optional teardown — currently unit-test only. */ +void temporal_task_stop(void); + +#ifdef __cplusplus +} +#endif