diff --git a/.gitignore b/.gitignore index 20f8d59..acad090 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,9 @@ data/hotline_tts/**/*.mp3 # Local venvs .venv*/ + +# IDF managed components + lock + per-build sdkconfig +managed_components/ +dependencies.lock +sdkconfig +.cache/ diff --git a/box3_voice/components/scenario_mesh/CMakeLists.txt b/box3_voice/components/scenario_mesh/CMakeLists.txt new file mode 100644 index 0000000..4520f05 --- /dev/null +++ b/box3_voice/components/scenario_mesh/CMakeLists.txt @@ -0,0 +1,17 @@ +## Zacus scenario_mesh — ESP-NOW transport for Runtime 3 IR hot-load. +## +## Tasks 4 & 5 of docs/specs/2026-05-24-firmware-scenario-hotload.md: chunk the +## IR JSON into <=240-byte ESP-NOW frames, send sequentially with per-frame +## ack, and reassemble on the receive side keyed by sender+total before +## invoking the same _scenario_apply() path as the HTTP handler. + +idf_component_register( + SRCS + "scenario_mesh.c" + INCLUDE_DIRS + "include" + REQUIRES + esp_wifi + freertos + log +) diff --git a/box3_voice/components/scenario_mesh/include/scenario_mesh.h b/box3_voice/components/scenario_mesh/include/scenario_mesh.h new file mode 100644 index 0000000..2db86d4 --- /dev/null +++ b/box3_voice/components/scenario_mesh/include/scenario_mesh.h @@ -0,0 +1,86 @@ +// scenario_mesh — ESP-NOW transport for Runtime 3 IR hot-load (Phase 2). +// +// Implements tasks 4 & 5 of docs/specs/2026-05-24-firmware-scenario-hotload.md: +// +// * Frame protocol: the IR JSON blob is chunked into ESP-NOW frames of +// <= SCENARIO_MESH_FRAME_MAX (240) bytes. Each frame carries a 4-byte +// header { seq:u16, total:u16 } (little-endian on the wire) followed by +// up to SCENARIO_MESH_PAYLOAD_MAX payload bytes. +// * Sender (master): scenario_mesh_send() resolves an alias to a MAC, +// registers the peer, and transmits every frame sequentially, awaiting +// the per-frame esp_now send-callback ack before advancing. +// * Receiver (peer board): the registered esp_now recv callback accumulates +// frames keyed by (sender MAC + total) until `total` frames have arrived, +// concatenates them, and hands the reassembled buffer to the apply +// callback supplied at init — the same internal `_scenario_apply()` path +// the HTTP POST /game/scenario handler uses. +// +// NOTE ON THE "EXISTING PEER REGISTRY": +// The spec references an existing ESP-NOW peer registry + `espnow_recv_cb` +// to extend. In the IDF tree (idf_zacus) no such registry exists yet — the +// only ESP-NOW code is the legacy Arduino lib/espnow_common (broadcast-only, +// puzzle-id keyed, not an IDF component). So this component carries its own +// minimal alias->MAC table (scenario_mesh_register_peer / _mac_for_alias). +// When a real shared registry lands, point mac_for_alias() at it. + +#pragma once + +#include +#include +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ESP-NOW hard limit on a single payload is 250 bytes. We cap the whole +// frame (header + payload) at 240 to stay clear of vendor headers and keep a +// safety margin, matching the spec's "<= 240-byte ESP-NOW frames". +#define SCENARIO_MESH_FRAME_MAX 240 +#define SCENARIO_MESH_HEADER_BYTES 4 +#define SCENARIO_MESH_PAYLOAD_MAX (SCENARIO_MESH_FRAME_MAX - SCENARIO_MESH_HEADER_BYTES) // 236 + +// Largest IR blob we will reassemble on the receive side. Mirrors +// GAME_ENDPOINT_MAX_SCENARIO_BYTES (64 KiB). 64 KiB / 236 ≈ 285 frames, well +// under the u16 sequence space. +#define SCENARIO_MESH_MAX_BLOB (64 * 1024) + +// Per-frame ack timeout. ESP-NOW send-callbacks normally fire within a few ms; +// a generous window absorbs RF retries without stalling the relay loop. +#define SCENARIO_MESH_ACK_TIMEOUT_MS 300 + +// Callback invoked on the receive side once a full blob has been reassembled. +// `data` is a NUL-terminated buffer of `len` bytes (the IR JSON). The callback +// must NOT take ownership — the buffer is freed by scenario_mesh after return. +// Return ESP_OK if the scenario was applied; any other value is logged. +typedef esp_err_t (*scenario_mesh_apply_cb_t)(const char *data, size_t len); + +// Initialize ESP-NOW (idempotent — tolerates an already-initialized stack), +// register the send + recv callbacks, and register the broadcast peer. +// +// `apply_cb` may be NULL on a pure sender (master) that never receives +// scenarios; pass the board's _scenario_apply wrapper on receiver boards. +esp_err_t scenario_mesh_init(scenario_mesh_apply_cb_t apply_cb); + +// Register / update an alias -> MAC mapping in the local peer table and add the +// MAC as an unencrypted ESP-NOW peer. Safe to call repeatedly with the same +// alias (updates the MAC). Returns ESP_ERR_NO_MEM if the table is full. +esp_err_t scenario_mesh_register_peer(const char *alias, const uint8_t mac[6]); + +// Resolve an alias to its MAC. Returns ESP_OK and fills `mac_out` on hit, +// ESP_ERR_NOT_FOUND otherwise. +esp_err_t scenario_mesh_mac_for_alias(const char *alias, uint8_t mac_out[6]); + +// Chunk `data` (len bytes) into frames and send them all sequentially to +// `dest_mac`, awaiting the per-frame ack. Returns ESP_OK only if every frame +// was acked; ESP_ERR_TIMEOUT if any frame ack timed out, or the underlying +// esp_now_send error. The caller (relay handler) treats a non-OK return as a +// skipped peer and continues with the others. +esp_err_t scenario_mesh_send(const uint8_t dest_mac[6], + const char *data, size_t len); + +#ifdef __cplusplus +} +#endif diff --git a/box3_voice/components/scenario_mesh/scenario_mesh.c b/box3_voice/components/scenario_mesh/scenario_mesh.c new file mode 100644 index 0000000..89d10e3 --- /dev/null +++ b/box3_voice/components/scenario_mesh/scenario_mesh.c @@ -0,0 +1,389 @@ +// scenario_mesh — see include/scenario_mesh.h for the design notes. +// +// Tasks 4 & 5 of the firmware-scenario-hotload spec: ESP-NOW frame protocol +// (chunk + reassembly) for relaying Runtime 3 IR to WiFi-disabled peers. + +#include "scenario_mesh.h" + +#include + +#include "esp_log.h" +#include "esp_now.h" +#include "esp_wifi.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + +static const char *TAG = "scenario_mesh"; + +static const uint8_t kBroadcast[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + +// ─── alias -> MAC peer table (minimal local registry) ─────────────────────── + +#define MESH_ALIAS_MAX 32 +#define MESH_PEERS_MAX 8 + +typedef struct { + bool used; + char alias[MESH_ALIAS_MAX]; + uint8_t mac[6]; +} mesh_peer_t; + +static mesh_peer_t s_peers[MESH_PEERS_MAX]; + +// ─── send-side ack synchronization ────────────────────────────────────────── + +static SemaphoreHandle_t s_send_done; // given by on_sent for each frame +static volatile esp_now_send_status_t s_last_send_status; +static SemaphoreHandle_t s_send_lock; // serializes scenario_mesh_send calls + +// ─── receive-side reassembly ──────────────────────────────────────────────── +// +// One in-flight reassembly slot per distinct (sender MAC, total) tuple. A +// single slot is enough in practice — the master relays to one peer at a time +// and frames arrive in order — but we keep a small array so two senders (or a +// retried transfer) don't clobber each other. + +#define MESH_REASM_SLOTS 2 + +typedef struct { + bool used; + uint8_t src[6]; + uint16_t total; + uint16_t received; // count of distinct frames stored + bool *seen; // [total] frame-arrival bitmap (heap) + char *buf; // [total * PAYLOAD_MAX + 1] (heap) + size_t buf_len; // running max byte offset written + payload + uint32_t last_tick; // for staleness eviction +} mesh_reasm_t; + +static mesh_reasm_t s_reasm[MESH_REASM_SLOTS]; +static SemaphoreHandle_t s_reasm_lock; +static scenario_mesh_apply_cb_t s_apply_cb; + +// A completed reassembly is handed off to a worker task rather than applied in +// the Wi-Fi recv-callback context: the apply does filesystem I/O (and triggers +// a reboot) which must not run on the Wi-Fi stack's callback. +typedef struct { + char *buf; // heap, NUL-terminated, ownership transferred to the task + size_t len; +} mesh_apply_job_t; + +static QueueHandle_t s_apply_queue; + +static void apply_worker_task(void *arg) { + (void) arg; + mesh_apply_job_t job; + for (;;) { + if (xQueueReceive(s_apply_queue, &job, portMAX_DELAY) != pdTRUE) { + continue; + } + if (s_apply_cb) { + esp_err_t aerr = s_apply_cb(job.buf, job.len); + if (aerr != ESP_OK) { + ESP_LOGW(TAG, "apply_cb returned %s", esp_err_to_name(aerr)); + } + } else { + ESP_LOGW(TAG, "no apply_cb registered — dropping scenario"); + } + free(job.buf); + } +} + +// ─── peer table helpers ───────────────────────────────────────────────────── + +esp_err_t scenario_mesh_register_peer(const char *alias, const uint8_t mac[6]) { + if (!alias || !mac) return ESP_ERR_INVALID_ARG; + + int free_slot = -1; + for (int i = 0; i < MESH_PEERS_MAX; i++) { + if (s_peers[i].used && strncmp(s_peers[i].alias, alias, + MESH_ALIAS_MAX) == 0) { + free_slot = i; // update existing + break; + } + if (!s_peers[i].used && free_slot < 0) free_slot = i; + } + if (free_slot < 0) { + ESP_LOGE(TAG, "peer table full, cannot register \"%s\"", alias); + return ESP_ERR_NO_MEM; + } + + s_peers[free_slot].used = true; + strncpy(s_peers[free_slot].alias, alias, MESH_ALIAS_MAX - 1); + s_peers[free_slot].alias[MESH_ALIAS_MAX - 1] = '\0'; + memcpy(s_peers[free_slot].mac, mac, 6); + + // Add (or refresh) the ESP-NOW peer entry. esp_now_add_peer fails with + // ESP_ERR_ESPNOW_EXIST if already present — treat that as success. + esp_now_peer_info_t pi = {0}; + memcpy(pi.peer_addr, mac, 6); + pi.channel = 0; // current channel + pi.ifidx = WIFI_IF_STA; + pi.encrypt = false; + esp_err_t err = esp_now_add_peer(&pi); + if (err == ESP_ERR_ESPNOW_EXIST) { + err = ESP_OK; + } else if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_now_add_peer(%s) failed: %s", + alias, esp_err_to_name(err)); + } + + ESP_LOGI(TAG, "peer \"%s\" -> %02x:%02x:%02x:%02x:%02x:%02x", + alias, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + return err; +} + +esp_err_t scenario_mesh_mac_for_alias(const char *alias, uint8_t mac_out[6]) { + if (!alias || !mac_out) return ESP_ERR_INVALID_ARG; + for (int i = 0; i < MESH_PEERS_MAX; i++) { + if (s_peers[i].used && + strncmp(s_peers[i].alias, alias, MESH_ALIAS_MAX) == 0) { + memcpy(mac_out, s_peers[i].mac, 6); + return ESP_OK; + } + } + return ESP_ERR_NOT_FOUND; +} + +// ─── ESP-NOW callbacks (Wi-Fi task context — keep short) ──────────────────── + +static void on_sent(const uint8_t *mac, esp_now_send_status_t status) { + (void) mac; + s_last_send_status = status; + if (s_send_done) { + BaseType_t hp = pdFALSE; + xSemaphoreGiveFromISR(s_send_done, &hp); + if (hp) portYIELD_FROM_ISR(); + } +} + +// Locate (or allocate) the reassembly slot for this sender/total. Caller holds +// s_reasm_lock. Returns NULL on allocation failure. +static mesh_reasm_t *reasm_slot_for(const uint8_t src[6], uint16_t total) { + mesh_reasm_t *free_slot = NULL; + mesh_reasm_t *oldest = NULL; + for (int i = 0; i < MESH_REASM_SLOTS; i++) { + mesh_reasm_t *r = &s_reasm[i]; + if (r->used && r->total == total && memcmp(r->src, src, 6) == 0) { + return r; + } + if (!r->used && !free_slot) free_slot = r; + if (r->used && (!oldest || r->last_tick < oldest->last_tick)) oldest = r; + } + + // New transfer. Reuse a free slot, else evict the oldest in-flight one. + mesh_reasm_t *r = free_slot ? free_slot : oldest; + if (!r) return NULL; + if (r->used) { + ESP_LOGW(TAG, "evicting stale reassembly (%u/%u frames)", + r->received, r->total); + free(r->seen); + free(r->buf); + } + memset(r, 0, sizeof(*r)); + + if (total == 0 || (size_t) total * SCENARIO_MESH_PAYLOAD_MAX > SCENARIO_MESH_MAX_BLOB) { + ESP_LOGW(TAG, "reject transfer: implausible total=%u", total); + return NULL; + } + r->seen = calloc(total, sizeof(bool)); + r->buf = malloc((size_t) total * SCENARIO_MESH_PAYLOAD_MAX + 1); + if (!r->seen || !r->buf) { + free(r->seen); + free(r->buf); + ESP_LOGE(TAG, "OOM allocating reassembly for total=%u", total); + return NULL; + } + r->used = true; + r->total = total; + memcpy(r->src, src, 6); + return r; +} + +static void on_recv(const esp_now_recv_info_t *info, + const uint8_t *data, int len) { + if (!info || !data || len < SCENARIO_MESH_HEADER_BYTES) return; + if (len > SCENARIO_MESH_FRAME_MAX) return; + + // Header: seq:u16, total:u16 (little-endian). + uint16_t seq = (uint16_t) (data[0] | (data[1] << 8)); + uint16_t total = (uint16_t) (data[2] | (data[3] << 8)); + const uint8_t *payload = data + SCENARIO_MESH_HEADER_BYTES; + int payload_len = len - SCENARIO_MESH_HEADER_BYTES; + if (seq >= total) return; // malformed + + if (!s_reasm_lock) return; + // Run reassembly off the Wi-Fi callback by doing the bookkeeping under a + // mutex here; the (potentially slow) apply is deferred to a short task so + // we never block the Wi-Fi stack inside the recv callback. + char *complete_buf = NULL; + size_t complete_len = 0; + + xSemaphoreTake(s_reasm_lock, portMAX_DELAY); + mesh_reasm_t *r = reasm_slot_for(info->src_addr, total); + if (r) { + if (!r->seen[seq]) { + r->seen[seq] = true; + r->received++; + size_t off = (size_t) seq * SCENARIO_MESH_PAYLOAD_MAX; + memcpy(r->buf + off, payload, payload_len); + // Track the highest end offset so the final length is exact even + // though only the last frame is short. + if (off + payload_len > r->buf_len) r->buf_len = off + payload_len; + } + r->last_tick = (uint32_t) xTaskGetTickCount(); + + if (r->received == r->total) { + r->buf[r->buf_len] = '\0'; + complete_buf = r->buf; + complete_len = r->buf_len; + free(r->seen); + memset(r, 0, sizeof(*r)); // releases the slot; buf handed off + } + } + xSemaphoreGive(s_reasm_lock); + + if (complete_buf) { + ESP_LOGI(TAG, "reassembled scenario: %u bytes from " + "%02x:%02x:%02x:%02x:%02x:%02x", + (unsigned) complete_len, + info->src_addr[0], info->src_addr[1], info->src_addr[2], + info->src_addr[3], info->src_addr[4], info->src_addr[5]); + // Hand off to the worker task — never touch the filesystem (or reboot) + // from the Wi-Fi recv-callback context. + mesh_apply_job_t job = { .buf = complete_buf, .len = complete_len }; + if (!s_apply_queue || + xQueueSend(s_apply_queue, &job, 0) != pdTRUE) { + ESP_LOGW(TAG, "apply queue full/unavailable — dropping scenario"); + free(complete_buf); + } + } +} + +// ─── init ─────────────────────────────────────────────────────────────────── + +esp_err_t scenario_mesh_init(scenario_mesh_apply_cb_t apply_cb) { + s_apply_cb = apply_cb; + + if (!s_send_done) s_send_done = xSemaphoreCreateBinary(); + if (!s_send_lock) s_send_lock = xSemaphoreCreateMutex(); + if (!s_reasm_lock) s_reasm_lock = xSemaphoreCreateMutex(); + if (!s_send_done || !s_send_lock || !s_reasm_lock) { + return ESP_ERR_NO_MEM; + } + + // Receiver path: spin up the apply worker (and its job queue) so completed + // reassemblies are applied off the Wi-Fi callback. A pure sender (master + // relay with no apply_cb) skips this to save RAM. + if (apply_cb && !s_apply_queue) { + s_apply_queue = xQueueCreate(2, sizeof(mesh_apply_job_t)); + if (!s_apply_queue) return ESP_ERR_NO_MEM; + BaseType_t ok = xTaskCreate(apply_worker_task, "scn_mesh_apply", + 4096, NULL, tskIDLE_PRIORITY + 2, NULL); + if (ok != pdPASS) { + vQueueDelete(s_apply_queue); + s_apply_queue = NULL; + return ESP_ERR_NO_MEM; + } + } + + // esp_now_init() requires Wi-Fi to be started already (the caller brings up + // STA/AP). A second init returns ESP_ERR_ESPNOW_INTERNAL on some IDF lines; + // since no other component in this tree owns ESP-NOW yet a hard failure is + // genuinely fatal, but we keep the relay endpoint optional at the call site. + esp_err_t err = esp_now_init(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_now_init: %s", esp_err_to_name(err)); + return err; + } + + esp_now_register_send_cb(on_sent); + esp_now_register_recv_cb(on_recv); + + // Broadcast peer (handy for future fan-out; unicast peers are added on + // demand by scenario_mesh_register_peer). + esp_now_peer_info_t bcast = {0}; + memcpy(bcast.peer_addr, kBroadcast, 6); + bcast.channel = 0; + bcast.ifidx = WIFI_IF_STA; + bcast.encrypt = false; + esp_err_t berr = esp_now_add_peer(&bcast); + if (berr != ESP_OK && berr != ESP_ERR_ESPNOW_EXIST) { + ESP_LOGW(TAG, "esp_now_add_peer(broadcast): %s", + esp_err_to_name(berr)); + } + + ESP_LOGI(TAG, "scenario_mesh ready (apply_cb=%s)", + apply_cb ? "set" : "none"); + return ESP_OK; +} + +// ─── send (chunk + per-frame ack) ─────────────────────────────────────────── + +esp_err_t scenario_mesh_send(const uint8_t dest_mac[6], + const char *data, size_t len) { + if (!dest_mac || !data || len == 0) return ESP_ERR_INVALID_ARG; + if (len > SCENARIO_MESH_MAX_BLOB) return ESP_ERR_INVALID_SIZE; + + size_t total = (len + SCENARIO_MESH_PAYLOAD_MAX - 1) / + SCENARIO_MESH_PAYLOAD_MAX; + if (total == 0 || total > 0xFFFF) return ESP_ERR_INVALID_SIZE; + + // Serialize: the single send-done semaphore is shared across frames. + if (xSemaphoreTake(s_send_lock, portMAX_DELAY) != pdTRUE) { + return ESP_FAIL; + } + + esp_err_t result = ESP_OK; + uint8_t frame[SCENARIO_MESH_FRAME_MAX]; + + for (size_t seq = 0; seq < total; seq++) { + size_t off = seq * SCENARIO_MESH_PAYLOAD_MAX; + size_t chunk = len - off; + if (chunk > SCENARIO_MESH_PAYLOAD_MAX) chunk = SCENARIO_MESH_PAYLOAD_MAX; + + frame[0] = (uint8_t) (seq & 0xFF); + frame[1] = (uint8_t) ((seq >> 8) & 0xFF); + frame[2] = (uint8_t) (total & 0xFF); + frame[3] = (uint8_t) ((total >> 8) & 0xFF); + memcpy(frame + SCENARIO_MESH_HEADER_BYTES, data + off, chunk); + + // Drain any stale ack from a previous frame, then send + await ack. + xSemaphoreTake(s_send_done, 0); + s_last_send_status = ESP_NOW_SEND_FAIL; + + esp_err_t serr = esp_now_send(dest_mac, frame, + SCENARIO_MESH_HEADER_BYTES + chunk); + if (serr != ESP_OK) { + ESP_LOGW(TAG, "esp_now_send frame %u/%u: %s", + (unsigned) seq, (unsigned) total, esp_err_to_name(serr)); + result = serr; + break; + } + + if (xSemaphoreTake(s_send_done, + pdMS_TO_TICKS(SCENARIO_MESH_ACK_TIMEOUT_MS)) + != pdTRUE) { + ESP_LOGW(TAG, "ack timeout on frame %u/%u", + (unsigned) seq, (unsigned) total); + result = ESP_ERR_TIMEOUT; + break; + } + if (s_last_send_status != ESP_NOW_SEND_SUCCESS) { + ESP_LOGW(TAG, "frame %u/%u not acked by peer", + (unsigned) seq, (unsigned) total); + result = ESP_ERR_TIMEOUT; // surfaced as a skipped peer + break; + } + } + + xSemaphoreGive(s_send_lock); + + if (result == ESP_OK) { + ESP_LOGI(TAG, "sent scenario: %u bytes in %u frames", + (unsigned) len, (unsigned) total); + } + return result; +} diff --git a/box3_voice/main/CMakeLists.txt b/box3_voice/main/CMakeLists.txt index 7180a0d..b13cec6 100644 --- a/box3_voice/main/CMakeLists.txt +++ b/box3_voice/main/CMakeLists.txt @@ -1,4 +1,14 @@ idf_component_register( - SRCS "main.c" "voice_ws_client.c" + SRCS "main.c" "voice_ws_client.c" "scenario_server.c" INCLUDE_DIRS "." + PRIV_REQUIRES + driver + esp_event + esp_http_server + esp_netif + esp_wifi + json + nvs_flash + spiffs + scenario_mesh ) diff --git a/box3_voice/main/idf_component.yml b/box3_voice/main/idf_component.yml new file mode 100644 index 0000000..ea4d95e --- /dev/null +++ b/box3_voice/main/idf_component.yml @@ -0,0 +1,15 @@ +dependencies: + espressif/esp-box: + version: ">=1.2.0" + espressif/esp-sr: + version: ">=1.4.0" + espressif/esp_codec_dev: + # 1.3.x+ moved to driver_ng i2c, conflicting with esp-box 1.x legacy BSP. + # Pin to the last 1.2 release that still uses the legacy driver. + version: "1.2.0" + espressif/button: + # esp-box 1.x bsp uses the old button_config_t.custom_button_config API; + # button v3.x removed it. Pin to the last 2.x release for compat. + version: "~2.5.0" + espressif/esp_websocket_client: + version: ">=1.0.0" diff --git a/box3_voice/main/main.c b/box3_voice/main/main.c index c5ac122..ca63431 100644 --- a/box3_voice/main/main.c +++ b/box3_voice/main/main.c @@ -25,6 +25,8 @@ #include "board_config.h" #include "voice_ws_client.h" +#include "scenario_server.h" +#include "scenario_mesh.h" /* BSP header — provided by espressif/esp-box component */ #include "bsp/esp-bsp.h" @@ -377,6 +379,25 @@ void app_main(void) /* Start voice bridge connection task (waits for WiFi, then connects WS) */ xTaskCreate(voice_bridge_task, "voice_bridge", 6144, NULL, 5, NULL); + /* Start the scenario hot-load HTTP server (POST /game/scenario). + * httpd_start binds to all netifs — works as soon as the WiFi STA has an IP. */ + if (scenario_server_start() != ESP_OK) { + ESP_LOGW(TAG, "scenario_server_start failed — IR hot-load unavailable"); + } + + /* Start the ESP-NOW receiver so the master can relay scenarios to us even + * when WiFi is unreachable (battery / RF-noise fallback per the spec). The + * reassembled IR is funnelled through the exact same scenario_apply_buffer() + * path the HTTP handler uses. esp_wifi_start() already ran in + * wifi_init_sta(), so esp_now_init() inside has its prerequisite. */ + esp_err_t mesh_err = scenario_mesh_init(scenario_apply_buffer); + if (mesh_err != ESP_OK) { + ESP_LOGW(TAG, "scenario_mesh_init failed: %s — ESP-NOW IR relay unavailable", + esp_err_to_name(mesh_err)); + } else { + ESP_LOGI(TAG, "ESP-NOW scenario receiver active"); + } + /* TODO: Initialize ESP-SR WakeNet for wake-word detection * - Load WakeNet9 model ("hi esp" or custom) * - Feed audio frames from mic to WakeNet diff --git a/box3_voice/main/scenario_server.c b/box3_voice/main/scenario_server.c new file mode 100644 index 0000000..36a4241 --- /dev/null +++ b/box3_voice/main/scenario_server.c @@ -0,0 +1,250 @@ +// scenario_server.c — minimal HTTP server for receiving Runtime 3 IR scenarios +// on the ESP32-S3-BOX-3. Mirrors the master's game_endpoint handler but is +// self-contained (no shared component) since box3_voice is a separate IDF +// project. Storage uses the existing SPIFFS partition declared in +// partitions.csv (master uses LittleFS — both work, we match the local table). + +#include "scenario_server.h" + +#include +#include +#include +#include +#include +#include + +#include "cJSON.h" +#include "esp_err.h" +#include "esp_http_server.h" +#include "esp_log.h" +#include "esp_spiffs.h" +#include "esp_system.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#define TAG "scenario_srv" + +#define MAX_SCENARIO_BYTES (64 * 1024) +#define SPIFFS_LABEL "storage" +#define SPIFFS_BASE "/spiffs" +#define SCENARIO_PATH SPIFFS_BASE "/scenario.json" +#define SCENARIO_BAK SPIFFS_BASE "/scenario.bak" + +static httpd_handle_t s_server = NULL; +static bool s_spiffs_mounted = false; + +// ---------- helpers ---------- + +static esp_err_t send_json(httpd_req_t *req, const char *status_line, const char *body) { + httpd_resp_set_status(req, status_line); + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + return httpd_resp_sendstr(req, body); +} + +static esp_err_t send_error(httpd_req_t *req, const char *status_line, const char *message) { + char buf[192]; + snprintf(buf, sizeof(buf), "{\"error\":\"%s\"}", message ? message : ""); + return send_json(req, status_line, buf); +} + +static esp_err_t mount_spiffs_lazy(void) { + if (s_spiffs_mounted) return ESP_OK; + esp_vfs_spiffs_conf_t conf = { + .base_path = SPIFFS_BASE, + .partition_label = SPIFFS_LABEL, + .max_files = 6, + .format_if_mount_failed = true, + }; + esp_err_t err = esp_vfs_spiffs_register(&conf); + if (err == ESP_OK || err == ESP_ERR_INVALID_STATE) { + s_spiffs_mounted = true; + ESP_LOGI(TAG, "spiffs '%s' mounted at %s", conf.partition_label, conf.base_path); + return ESP_OK; + } + ESP_LOGE(TAG, "spiffs mount failed: %s", esp_err_to_name(err)); + return err; +} + +static void deferred_restart_task(void *arg) { + (void) arg; + vTaskDelay(pdMS_TO_TICKS(800)); + ESP_LOGW(TAG, "scenario hot-load: rebooting to apply new IR"); + esp_restart(); +} + +static void schedule_restart(void) { + xTaskCreate(deferred_restart_task, "scenario_restart", + 4096, NULL, tskIDLE_PRIORITY + 1, NULL); +} + +// ---------- handlers ---------- + +static esp_err_t handle_healthz_get(httpd_req_t *req) { + httpd_resp_set_type(req, "text/plain"); + return httpd_resp_sendstr(req, "ok"); +} + +// Shared internal apply path. Returns ESP_OK on success and fills the optional +// out-params; on failure returns a specific esp_err_t and (if non-NULL) sets a +// static reason string. The HTTP handler and the ESP-NOW receiver both call +// this — the single `_scenario_apply` the spec mandates. +static esp_err_t scenario_apply_internal(const char *body, size_t len, + int *steps_count_out, + char *entry_out, size_t entry_cap, + const char **err_msg_out) { + if (err_msg_out) *err_msg_out = NULL; + if (steps_count_out) *steps_count_out = 0; + if (entry_out && entry_cap) entry_out[0] = '\0'; + + if (!body || len == 0 || len > MAX_SCENARIO_BYTES) { + if (err_msg_out) *err_msg_out = "body must be 1..65536 bytes"; + return ESP_ERR_INVALID_SIZE; + } + if (mount_spiffs_lazy() != ESP_OK) { + if (err_msg_out) *err_msg_out = "spiffs mount failed"; + return ESP_FAIL; + } + + cJSON *root = cJSON_Parse(body); + if (!root) { + if (err_msg_out) *err_msg_out = "malformed json"; + return ESP_ERR_INVALID_ARG; + } + const cJSON *schema = cJSON_GetObjectItemCaseSensitive(root, "schema_version"); + if (!cJSON_IsString(schema) || strcmp(schema->valuestring, "zacus.runtime3.v1") != 0) { + cJSON_Delete(root); + if (err_msg_out) *err_msg_out = "schema_version must be zacus.runtime3.v1"; + return ESP_ERR_INVALID_ARG; + } + const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps"); + if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) { + cJSON_Delete(root); + if (err_msg_out) *err_msg_out = "steps must be a non-empty array"; + return ESP_ERR_INVALID_ARG; + } + const cJSON *scenario_obj = cJSON_GetObjectItemCaseSensitive(root, "scenario"); + const cJSON *entry = scenario_obj + ? cJSON_GetObjectItemCaseSensitive(scenario_obj, "entry_step_id") : NULL; + if (entry_out && entry_cap && cJSON_IsString(entry) && entry->valuestring) { + strncpy(entry_out, entry->valuestring, entry_cap - 1); + entry_out[entry_cap - 1] = '\0'; + } + int steps_count = cJSON_GetArraySize(steps); + cJSON_Delete(root); + if (steps_count_out) *steps_count_out = steps_count; + + // Rotate current -> .bak + struct stat st; + if (stat(SCENARIO_PATH, &st) == 0) { + unlink(SCENARIO_BAK); + if (rename(SCENARIO_PATH, SCENARIO_BAK) != 0) { + ESP_LOGW(TAG, "rename .json -> .bak failed (errno=%d)", errno); + } + } + + FILE *f = fopen(SCENARIO_PATH, "wb"); + if (!f) { + ESP_LOGE(TAG, "fopen %s for write failed (errno=%d)", SCENARIO_PATH, errno); + if (err_msg_out) *err_msg_out = "scenario write open failed"; + return ESP_FAIL; + } + size_t written = fwrite(body, 1, len, f); + fclose(f); + if (written != len) { + unlink(SCENARIO_PATH); + if (stat(SCENARIO_BAK, &st) == 0) rename(SCENARIO_BAK, SCENARIO_PATH); + if (err_msg_out) *err_msg_out = "scenario write short"; + return ESP_FAIL; + } + + ESP_LOGI(TAG, "scenario hot-load OK: %zu bytes, %d steps, entry=%s", + len, steps_count, (entry_out && entry_cap) ? entry_out : ""); + schedule_restart(); + return ESP_OK; +} + +// Public thin wrapper used by the ESP-NOW receiver (matches +// scenario_mesh_apply_cb_t: esp_err_t (*)(const char *, size_t)). +esp_err_t scenario_apply_buffer(const char *data, size_t len) { + const char *emsg = NULL; + esp_err_t err = scenario_apply_internal(data, len, NULL, NULL, 0, &emsg); + if (err != ESP_OK) { + ESP_LOGW(TAG, "ESP-NOW scenario rejected: %s", + emsg ? emsg : esp_err_to_name(err)); + } + return err; +} + +static esp_err_t handle_scenario_post(httpd_req_t *req) { + if (req->content_len <= 0 || req->content_len > MAX_SCENARIO_BYTES) { + ESP_LOGW(TAG, "POST /game/scenario: bad body length %d", (int) req->content_len); + return send_error(req, "413 Payload Too Large", "body must be 1..65536 bytes"); + } + char *body = (char *) malloc((size_t) req->content_len + 1); + if (!body) return send_error(req, "500 Internal Server Error", "out of memory"); + int total = 0; + while (total < (int) req->content_len) { + int got = httpd_req_recv(req, body + total, req->content_len - total); + if (got <= 0) { + if (got == HTTPD_SOCK_ERR_TIMEOUT) continue; + free(body); + return send_error(req, "400 Bad Request", "recv failed"); + } + total += got; + } + body[total] = '\0'; + + int steps_count = 0; + char entry_str[64] = {0}; + const char *emsg = NULL; + esp_err_t aerr = scenario_apply_internal(body, (size_t) total, &steps_count, + entry_str, sizeof(entry_str), &emsg); + free(body); + if (aerr != ESP_OK) { + const char *status = (aerr == ESP_ERR_INVALID_ARG || + aerr == ESP_ERR_INVALID_SIZE) + ? "400 Bad Request" : "500 Internal Server Error"; + return send_error(req, status, emsg ? emsg : esp_err_to_name(aerr)); + } + + char buf[256]; + snprintf(buf, sizeof(buf), + "{\"status\":\"ok\",\"board\":\"box3_voice\",\"steps_count\":%d," + "\"entry_step_id\":\"%s\",\"bytes\":%d,\"reload\":\"reboot_pending\"}", + steps_count, entry_str, total); + return send_json(req, "200 OK", buf); +} + +// ---------- public init ---------- + +esp_err_t scenario_server_start(void) { + if (s_server) { + ESP_LOGW(TAG, "scenario_server already running"); + return ESP_OK; + } + httpd_config_t cfg = HTTPD_DEFAULT_CONFIG(); + cfg.server_port = 80; + cfg.max_uri_handlers = 8; + cfg.stack_size = 8192; + + esp_err_t err = httpd_start(&s_server, &cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "httpd_start: %s", esp_err_to_name(err)); + return err; + } + + static const httpd_uri_t uri_healthz = { + .uri = "/healthz", .method = HTTP_GET, + .handler = handle_healthz_get, .user_ctx = NULL, + }; + static const httpd_uri_t uri_scenario = { + .uri = "/game/scenario", .method = HTTP_POST, + .handler = handle_scenario_post, .user_ctx = NULL, + }; + httpd_register_uri_handler(s_server, &uri_healthz); + httpd_register_uri_handler(s_server, &uri_scenario); + + ESP_LOGI(TAG, "scenario server up on :80 (GET /healthz, POST /game/scenario)"); + return ESP_OK; +} diff --git a/box3_voice/main/scenario_server.h b/box3_voice/main/scenario_server.h new file mode 100644 index 0000000..50b9cef --- /dev/null +++ b/box3_voice/main/scenario_server.h @@ -0,0 +1,25 @@ +// scenario_server.h — start the BOX-3 minimal HTTP server that accepts +// POST /game/scenario (Runtime 3 IR hot-load via reboot). +#pragma once + +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +esp_err_t scenario_server_start(void); + +// Shared internal apply path (the spec's `_scenario_apply`): validate a +// Runtime 3 IR blob, atomically write it to SPIFFS, and schedule the hot-reload +// reboot. Used both by the HTTP POST /game/scenario handler and by the ESP-NOW +// receiver (scenario_mesh). `data` need not be NUL-terminated beyond `len`. +// Returns ESP_OK on success; ESP_ERR_INVALID_ARG / _INVALID_SIZE on bad input, +// ESP_FAIL on storage errors. +esp_err_t scenario_apply_buffer(const char *data, size_t len); + +#ifdef __cplusplus +} +#endif diff --git a/box3_voice/sdkconfig.defaults b/box3_voice/sdkconfig.defaults index d57a974..0344c42 100644 --- a/box3_voice/sdkconfig.defaults +++ b/box3_voice/sdkconfig.defaults @@ -44,3 +44,14 @@ CONFIG_MBEDTLS_DYNAMIC_BUFFER=y # Heap: place large buffers in PSRAM CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=4096 CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=32768 + +# WiFi credentials — set via `idf.py menuconfig` (Zacus BOX-3 Voice +# Configuration) or via a local sdkconfig override that is NOT committed. +# Defaults stay empty so credentials never land in git. +# CONFIG_ZACUS_WIFI_SSID="..." +# CONFIG_ZACUS_WIFI_PASSWORD="..." + +# esp-box BSP uses the legacy i2c driver; esp_codec_dev uses driver_ng. +# Force-enable legacy mode so both coexist (and prevent the runtime abort +# in bsp_display_start when both drivers race to register on the same bus). +CONFIG_I2C_ENABLE_LEGACY_DRIVERS=y diff --git a/docs/scenario-mesh-receiver-patch.md b/docs/scenario-mesh-receiver-patch.md new file mode 100644 index 0000000..928354a --- /dev/null +++ b/docs/scenario-mesh-receiver-patch.md @@ -0,0 +1,131 @@ +# ESP-NOW scenario receiver — patch to report onto PLIP + puzzles + +Status: **box3_voice done**; **puzzle nodes done** (defensive demux in the +shared `espnow_slave.c`, see "Resolution" below); **PLIP out of scope by +design** (Wi-Fi/HTTP-only client, no ESP-NOW stack — see "PLIP" below). + +Spec: `docs/specs/2026-05-24-firmware-scenario-hotload.md`, task 6 +("Receiver side on each peer"). + +## Resolution (2026-06-09) + +Investigation of the real firmware changed the plan from "vendor a reassembler +into each node" to "**demux defensively, treat as no-op consumer**", because: + +- **No puzzle node runs a Runtime 3 scenario.** `p7_coffre` (the final lock) + receives its 8-digit code via `MSG_PUZZLE_CONFIG` (8 bytes), not an IR; the + others are driven entirely by `MSG_*` commands. None has a LittleFS/SPIFFS + scenario store. Pushing a full scenario to them is genuinely a no-op. +- The real risk is **stream corruption**, not a missing feature: a multi-frame + scenario relay misrouted to a puzzle MAC would inject frames whose `data[0]` + equals the frame `seq` low byte — e.g. `seq==1 → 0x01 == MSG_PUZZLE_SOLVED`. + +So the receiver was added **once** to the shared `lib/espnow_common/espnow_slave.c` +(compiled into all four puzzles): frames are demultiplexed in +`espnow_slave_process()` (task context, not the ISR), reassembled per source MAC, +and handed to an **optional** `espnow_scenario_callback_t`. Puzzle nodes register +no callback, so a reassembled scenario is logged and dropped — and, critically, +never reaches the `MSG_*` path. A future node that does consume scenarios opts in +via `espnow_slave_register_scenario_callback()`. No per-puzzle code changed; no +new component; bounded heap reassembly (≤64 KiB), 5 s sender-silence timeout. + +The original per-node inline-reassembler sketch below is kept for historical +context; the shared-file approach above supersedes it. + +## What box3_voice got (the reference implementation) + +box3_voice is a standalone IDF project that does **not** use the legacy +ESP-NOW slave. It received: + +1. `components/scenario_mesh/` — a vendored copy of the master's component + (frame protocol + reassembly + alias→MAC table). Identical bytes to + `idf_zacus/components/scenario_mesh/`. +2. `scenario_server.c` refactored so the validate+write path is a reusable + `scenario_apply_buffer(const char *data, size_t len)` (declared in + `scenario_server.h`). Both the HTTP `POST /game/scenario` handler and the + ESP-NOW receiver call it — the single `_scenario_apply` the spec mandates. +3. `main.c` calls `scenario_mesh_init(scenario_apply_buffer)` after Wi-Fi / + `scenario_server_start()`. + +box3 could take the component wholesale because it owns its ESP-NOW stack — it +does **not** register any other `esp_now_register_recv_cb`. + +## Why PLIP + puzzles can't just drop the component in + +The puzzle nodes already own the single ESP-NOW receive callback via +`lib/espnow_common/espnow_slave.c` (`esp_now_register_recv_cb(on_recv)`). +ESP-IDF allows **one** recv callback per process. `scenario_mesh_init()` calls +`esp_now_register_recv_cb()` too, so calling it after `espnow_slave_init()` +would silently steal the puzzle command stream (or vice-versa). The two +protocols must be **demultiplexed inside the one existing callback**. + +Frame discriminator (no wire-format change needed): + +- Legacy puzzle frames: `data[0]` is a `MSG_*` type in `0x01..0x08` + (see `espnow_slave.h`). +- scenario_mesh frames: `data[0..1]` = `seq` (u16 LE), `data[2..3]` = `total` + (u16 LE), then payload. For the first frame `seq==0` so `data[0]==0x00`, + which never collides with a `MSG_*` type. A scenario frame is also always + `>= 4` bytes with `total >= 1` and `seq < total`. + +So: **`data[0] == 0x00` (and `len >= 4`) ⇒ scenario frame; otherwise legacy.** + +## Patch for each puzzle node (`p1`, `p5`, `p6`, `p7_coffre`) + +These share `lib/espnow_common/espnow_slave.c`, so patch it **once** there +(it is compiled into each puzzle via the `../../../lib/espnow_common/espnow_slave.c` +SRC entry already present in every puzzle `main/CMakeLists.txt`). + +1. Add the reassembler. Either: + - vendor `scenario_mesh` as a component **but do not let it register the + recv cb** (add a `scenario_mesh_feed_frame(const uint8_t *src, const + uint8_t *data, int len)` entry point and a `scenario_mesh_init_passive()` + that skips `esp_now_register_recv_cb`), or + - inline a ~60-LOC reassembler keyed by `(src_mac, total)` straight into + `espnow_slave.c` (simplest; no new component). + +2. In `espnow_slave.c::on_recv`, branch before the queue push: + + ```c + static void on_recv(const esp_now_recv_info_t *info, + const uint8_t *data, int len) { + if (len >= 4 && data[0] == 0x00) { // scenario frame + scenario_mesh_feed_frame(info->src_addr, data, len); + return; + } + /* …existing puzzle-command path (queue push)… */ + } + ``` + + On full reassembly the reassembler writes the JSON to the node's local + filesystem and calls `scenario_engine_reload()` (or, until that symbol + lands, the puzzle's equivalent of `scenario_apply_buffer()` + + deferred `esp_restart()`, mirroring box3). + +3. Each puzzle needs a `scenario_apply_buffer()` equivalent. The puzzle nodes + currently have no LittleFS/SPIFFS scenario store — if a node is purely + driven by ESP-NOW puzzle commands and has no IR of its own, task 6 may be a + no-op for it. Confirm per node before adding storage: `p7_coffre` (the + final-code lock) is the most likely to actually consume a scenario. + +## PLIP (`PLIP_FIRMWARE`) — out of scope by design + +Resolved 2026-06-09: **PLIP needs no ESP-NOW scenario receiver.** It is its own +Arduino/PlatformIO tree (`/PLIP_FIRMWARE`, C++), connects to the master purely +over **Wi-Fi + HTTP** (`network_task.cpp` → `WiFi.begin` / mDNS, `zacus_hook_client` +REST), and registers **no** `esp_now_register_recv_cb` at all. The ESP-NOW relay +exists only as a fallback for boards that run with Wi-Fi disabled (battery / RF +noise); PLIP is mains-powered and always on Wi-Fi. + +If PLIP ever needs hot scenario push, the correct path is the master/box3 HTTP +one — `POST /game/scenario` over its existing Wi-Fi link — **not** ESP-NOW. No +code change made. + +## Shared-protocol drift risk + +The vendored `scenario_mesh` now lives in two places +(`idf_zacus/components/scenario_mesh` and +`box3_voice/components/scenario_mesh`). They are byte-identical today. If the +frame header or `SCENARIO_MESH_*` constants ever change, update **both** (and +any future PLIP/puzzle copy). A follow-up could hoist the component to a shared +`lib/` path referenced via `EXTRA_COMPONENT_DIRS` to remove the duplication. diff --git a/idf_zacus/.gitignore b/idf_zacus/.gitignore new file mode 100644 index 0000000..a5c27a6 --- /dev/null +++ b/idf_zacus/.gitignore @@ -0,0 +1,5 @@ +build/ +managed_components/ +dependencies.lock +sdkconfig +sdkconfig.old diff --git a/idf_zacus/CMakeLists.txt b/idf_zacus/CMakeLists.txt new file mode 100644 index 0000000..5087f06 --- /dev/null +++ b/idf_zacus/CMakeLists.txt @@ -0,0 +1,13 @@ +# Zacus master ESP-IDF project +# Coexists with the Arduino tree under ui_freenove_allinone/ — this scaffold +# is the future home of the master firmware (P1 of the voice pipeline spec +# 2026-05-03-voice-pipeline-esp-sr-design.md). + +cmake_minimum_required(VERSION 3.16) + +# Local components live under idf_zacus/components/ (e.g. ota_server inherited +# from the 2026-04-03 IDF bootstrap). +set(EXTRA_COMPONENT_DIRS components) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(zacus_master) diff --git a/idf_zacus/QEMU_HOWTO.md b/idf_zacus/QEMU_HOWTO.md new file mode 100644 index 0000000..77896c8 --- /dev/null +++ b/idf_zacus/QEMU_HOWTO.md @@ -0,0 +1,77 @@ +# QEMU smoke test for `idf_zacus` + +## What QEMU can do today + +- Boot the firmware end-to-end (NVS init, partition table, app_main). +- Validate that new components do not break boot. +- Surface any link-time / runtime init crashes that escape the build. + +Tested 2026-05-24: firmware with the new `POST /game/scenario` handler boots +cleanly to `app_main()` in QEMU 9.0.0 (esp_develop build). + +## What QEMU can NOT do (yet) + +- **WiFi radio**: stubbed. The board comes up in AP fallback but no station + ever associates, so the IP netif never gets an address and the HTTP server + (which waits for `IP_EVENT_STA_GOT_IP`) doesn't bind. +- **PSRAM**: QEMU's esp32s3 machine does not emulate the Octal PSRAM the + Freenove N16R8 ships with. Use `sdkconfig.qemu` to disable. +- **esp-sr / WakeNet**: depends on PSRAM, also disabled in `sdkconfig.qemu`. +- **WiFi-driven HTTP smoke**: see the "future work" section below. + +## Run + +```bash +. $HOME/esp/esp-idf/export.sh +export PATH=$HOME/.espressif/tools/qemu-xtensa/esp_develop_9.0.0_20240606/qemu/bin:$PATH + +# clean reconfigure with the QEMU overrides +rm -rf build sdkconfig +idf.py -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.qemu" set-target esp32s3 +idf.py build + +# launch with port forward for the future ethernet integration +idf.py qemu --qemu-extra-args="-nic user,model=open_eth,hostfwd=tcp::8580-:80" + +# in another terminal — when HTTP arrives, this is the smoke test +curl -sS http://127.0.0.1:8580/healthz # → "ok" +curl -sS -X POST -H "Content-Type: application/json" \ + --data @../../../game/scenarios/zacus_cond_demo.ir.json \ + http://127.0.0.1:8580/game/scenario +``` + +Press `Ctrl-A x` to exit the QEMU console. + +## Restore the production build + +The `sdkconfig.qemu` overrides break the real board (no PSRAM = no esp-sr = +no voice pipeline). To return to the canonical config: + +```bash +rm -rf build sdkconfig +idf.py set-target esp32s3 # picks sdkconfig.defaults only +idf.py build flash monitor # real board path +``` + +`sdkconfig.qemu` is committed but never used by the default build — only when +explicitly listed in `SDKCONFIG_DEFAULTS`. + +## Future work — HTTP smoke in QEMU + +The main blocker is `main.c`: it gates `ota_server_init()` / `game_endpoint_init()` +on a WiFi `IP_EVENT_STA_GOT_IP` callback. To unblock HTTP testing under QEMU +without WiFi: + +1. Add a `CONFIG_ZACUS_QEMU_ETHERNET=y` Kconfig option in `main/Kconfig.projbuild` + (default `n`). +2. In `app_main()`, if the option is set, initialise the `esp_eth` driver against + the `open_eth` NIC and use its `IP_EVENT_ETH_GOT_IP` event to start the HTTP + stack — same lifecycle as the WiFi path, different transport. +3. Add `CONFIG_ZACUS_QEMU_ETHERNET=y` to `sdkconfig.qemu`. + +Once that lands, `curl http://127.0.0.1:8580/game/scenario` from the host hits +the real handler inside QEMU and we get a true integration test of the +hot-load path (scenario validation, LittleFS write, deferred reboot). + +Estimated effort: ~80 LOC + Kconfig + one `esp_eth_open_eth_new()` glue — +half a day of work. diff --git a/idf_zacus/README.md b/idf_zacus/README.md new file mode 100644 index 0000000..fcf2cb6 --- /dev/null +++ b/idf_zacus/README.md @@ -0,0 +1,79 @@ +# `idf_zacus/` — Zacus master ESP-IDF scaffold + +This tree is the future home of the Zacus master firmware. It is the **P1 +first slice** of the voice pipeline migration described in +`docs/superpowers/specs/2026-05-03-voice-pipeline-esp-sr-design.md`. + +The Arduino firmware in `../ui_freenove_allinone/` keeps running unchanged +during the transition; this scaffold lives side-by-side until feature parity +is reached. + +## Prerequisites + +- ESP-IDF v5.4 or v5.5 installed under `~/esp/esp-idf/`. +- Source the IDF environment in each shell: + ```bash + . $HOME/esp/esp-idf/export.sh + ``` + +## Build + +```bash +cd idf_zacus +idf.py set-target esp32s3 +idf.py build +``` + +## Flash & monitor + +```bash +idf.py -p /dev/cu.usbmodem* flash monitor +``` + +Exit the monitor with `Ctrl-]`. + +## What the first slice does + +`main/main.c` boots the device, initializes NVS, mounts the LittleFS +`storage` partition on `/littlefs`, lists its contents, logs heap stats +(internal + PSRAM), and enters an idle heartbeat loop (no deep sleep — the +inherited `ota_server` listening loop will be wired in slice 2). + +Inherited components: + +- `components/ota_server/` — HTTP server on :80 with rate-limited OTA upload + and 30 s watchdog auto-rollback (`POST /ota`, `POST /ota/rollback`, + `GET /version`, `GET /status`, `GET /ota/status`). Not yet started by + `main.c`; that comes next. + +## Layout + +``` +idf_zacus/ +├── CMakeLists.txt # project entry, points EXTRA_COMPONENT_DIRS at components/ +├── sdkconfig.defaults # ESP32-S3, octal PSRAM 80 MHz, custom partitions +├── partitions.csv # OTA layout + 2 MB LittleFS "storage" +├── main/ +│ ├── CMakeLists.txt +│ ├── idf_component.yml # joltwallet/littlefs ^1.14 +│ └── main.c # app_main + LittleFS mount + heartbeat +└── components/ + └── ota_server/ # inherited from 2026-04-03 IDF bootstrap +``` + +## Coexistence with Arduino + +`../ui_freenove_allinone/` (Arduino, PlatformIO) remains the production +firmware until the IDF port reaches feature parity. The two trees do **not** +share build artifacts. To work on the Arduino tree: +`cd ui_freenove_allinone && pio run`. To work on the IDF tree, source the +ESP-IDF env first. + +## Roadmap (next P1 slices) + +1. Boot `ota_server_init()` from `app_main` after a small Wi-Fi STA bring-up. +2. Port the NPC engine and media manager skeleton. +3. Scaffold the voice pipeline (I2S RX task, no esp-sr yet). +4. Bring up esp-sr AFE + wakenet ("hi_esp" placeholder) — start of P3. + +See the design spec for the full plan. diff --git a/idf_zacus/components/game_endpoint/CMakeLists.txt b/idf_zacus/components/game_endpoint/CMakeLists.txt new file mode 100644 index 0000000..827a2f6 --- /dev/null +++ b/idf_zacus/components/game_endpoint/CMakeLists.txt @@ -0,0 +1,16 @@ +idf_component_register( + SRCS + "game_endpoint.c" + INCLUDE_DIRS + "include" + REQUIRES + esp_http_server + json + nvs_flash + hints_client + ota_server + scenario_mesh + freertos + log + joltwallet__littlefs +) diff --git a/idf_zacus/components/game_endpoint/README.md b/idf_zacus/components/game_endpoint/README.md new file mode 100644 index 0000000..83dba56 --- /dev/null +++ b/idf_zacus/components/game_endpoint/README.md @@ -0,0 +1,102 @@ +# game_endpoint + +REST surface for **runtime game configuration**. Slice 12 of the IDF +migration. Currently exposes a single resource — the hints-engine +group profile — but the component is the natural home for additional +runtime tunables (cooldowns, voice persona, etc.) as scenarios grow. + +## Why + +The hints engine adapts its policy to the audience: `TECH`, +`NON_TECH`, `MIXED`, or `BOTH`. Until this slice the value was baked +into NVS via `idf.py nvs-partition-gen` or the dashboard's flash +helper — both require a power cycle. The game master needs to be able +to change profile mid-session if the actual room composition differs +from the booking, so we expose a REST endpoint that updates both the +in-RAM hints client and the persistent NVS slot. + +## Routes + +Listener: existing `esp_http_server` instance on port **80** (shared +with `ota_server` and `voice_hook_endpoint` — same TCP socket, no +second httpd). + +### `GET /game/group_profile` + +Returns the live profile (whatever `hints_client_group_profile()` +reports — defaults to `MIXED` after init). + +```json +{ "group_profile": "MIXED" } +``` + +### `POST /game/group_profile` + +Body (JSON, max 256 bytes): + +```json +{ "group_profile": "NON_TECH" } +``` + +| Status | Body | Meaning | +|--------|------|---------| +| 200 | `{"status":"ok","group_profile":"NON_TECH"}` | accepted, NVS persisted | +| 400 | `{"error":"missing 'group_profile'"}` | empty / non-string field | +| 400 | `{"error":"invalid group_profile, must be one of [TECH, NON_TECH, MIXED, BOTH]"}` | rejected by hints client whitelist | +| 400 | `{"error":"malformed json"}` | body did not parse | +| 413 | `{"error":"body must be 1..256 bytes"}` | body too large | +| 500 | `{"status":"runtime_only","group_profile":"NON_TECH","warning":"nvs write failed: …"}` | hints client updated but NVS commit failed (will not survive reboot) | + +## curl examples + +```bash +# Set the profile (uses mDNS — see main.c slice 12 wire-up) +curl -X POST http://zacus-master.local/game/group_profile \ + -H "Content-Type: application/json" \ + -d '{"group_profile":"NON_TECH"}' + +# Probe current state +curl http://zacus-master.local/game/group_profile + +# Static-IP fallback +curl -X POST http://192.168.0./game/group_profile \ + -H "Content-Type: application/json" \ + -d '{"group_profile":"MIXED"}' +``` + +## Persistence + +Successful POSTs write to NVS namespace `zacus`, key `group_profile`. +This is the same slot `main.c` reads at boot to seed the hints client, +so a successful POST survives reboot without a flash step. The +write/commit pair runs on the httpd worker task; expect ~5–15 ms of +flash latency. + +If the hints client validation passes but `nvs_set_str` / +`nvs_commit` fails (e.g. NVS partition full), the response is `500` +with `status: runtime_only` so the operator can decide whether to +keep going or force a reboot. + +## Component dependencies + +```cmake +REQUIRES + esp_http_server # the shared httpd_handle_t + json # cJSON for body parsing + nvs_flash # nvs_open / nvs_set_str / nvs_commit + hints_client # validation + push to in-RAM state + ota_server # supplies httpd_handle via ota_server_get_handle() + freertos + log +``` + +Init pattern in `main.c` (slice 12): + +```c +esp_err_t ota_err = ota_server_init(); +if (ota_err == ESP_OK) { + httpd_handle_t httpd = ota_server_get_handle(); + voice_hook_endpoint_init(httpd); + game_endpoint_init(httpd); +} +``` diff --git a/idf_zacus/components/game_endpoint/game_endpoint.c b/idf_zacus/components/game_endpoint/game_endpoint.c new file mode 100644 index 0000000..4210752 --- /dev/null +++ b/idf_zacus/components/game_endpoint/game_endpoint.c @@ -0,0 +1,575 @@ +// game_endpoint — see include/game_endpoint.h for design notes. +// +// Slice 12 of the IDF migration: live-tunable group profile so the +// game master can switch the hints engine policy from the dashboard +// (or any HTTP client) without reflashing NVS. + +#include "game_endpoint.h" + +#include +#include +#include +#include +#include +#include + +#include "cJSON.h" +#include "esp_err.h" +#include "esp_http_server.h" +#include "esp_littlefs.h" +#include "esp_log.h" +#include "esp_system.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "nvs.h" +#include "nvs_flash.h" + +#include "hints_client.h" +#include "scenario_mesh.h" + +static const char *TAG = "game_endpoint"; + +// Whitelist mirrored in the 4xx error message so the operator can +// recover without grepping the source. Keep aligned with +// hints_client_set_group_profile() validation. +#define GAME_ENDPOINT_PROFILE_HELP \ + "invalid group_profile, must be one of [TECH, NON_TECH, MIXED, BOTH]" + +// ─── small JSON response helper (mirrors voice_hook_endpoint) ─────────────── + +static esp_err_t send_json(httpd_req_t *req, const char *status_line, + const char *body) { + httpd_resp_set_status(req, status_line); + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + return httpd_resp_sendstr(req, body); +} + +static esp_err_t send_error(httpd_req_t *req, const char *status_line, + const char *message) { + char buf[192]; + snprintf(buf, sizeof(buf), "{\"error\":\"%s\"}", message ? message : ""); + return send_json(req, status_line, buf); +} + +// ─── NVS persistence helper ───────────────────────────────────────────────── + +// Writes the (already-validated) profile into NVS namespace "zacus", +// key "group_profile". Logs and returns the underlying error on +// failure — the caller decides whether to surface it to the client. +static esp_err_t persist_group_profile(const char *profile) { + nvs_handle_t h; + esp_err_t err = nvs_open("zacus", NVS_READWRITE, &h); + if (err != ESP_OK) { + ESP_LOGW(TAG, "nvs_open(zacus, RW): %s", esp_err_to_name(err)); + return err; + } + + err = nvs_set_str(h, "group_profile", profile); + if (err != ESP_OK) { + ESP_LOGW(TAG, "nvs_set_str(group_profile=\"%s\"): %s", + profile, esp_err_to_name(err)); + nvs_close(h); + return err; + } + + err = nvs_commit(h); + if (err != ESP_OK) { + ESP_LOGW(TAG, "nvs_commit(zacus): %s", esp_err_to_name(err)); + } + nvs_close(h); + return err; +} + +// ─── GET /game/group_profile ──────────────────────────────────────────────── + +static esp_err_t handle_group_profile_get(httpd_req_t *req) { + const char *current = hints_client_group_profile(); + char buf[96]; + snprintf(buf, sizeof(buf), + "{\"group_profile\":\"%s\"}", + current ? current : "MIXED"); + return send_json(req, "200 OK", buf); +} + +// ─── POST /game/group_profile ─────────────────────────────────────────────── + +static esp_err_t handle_group_profile_post(httpd_req_t *req) { + if (req->content_len <= 0 || + req->content_len > GAME_ENDPOINT_MAX_BODY_BYTES) { + ESP_LOGW(TAG, "POST /game/group_profile: bad body length %d", + (int) req->content_len); + return send_error(req, "413 Payload Too Large", + "body must be 1..256 bytes"); + } + + char body[GAME_ENDPOINT_MAX_BODY_BYTES + 1] = {0}; + int total = 0; + while (total < (int) req->content_len) { + int got = httpd_req_recv(req, body + total, + req->content_len - total); + if (got <= 0) { + if (got == HTTPD_SOCK_ERR_TIMEOUT) continue; + return send_error(req, "400 Bad Request", "recv failed"); + } + total += got; + } + body[total] = '\0'; + + cJSON *root = cJSON_Parse(body); + if (!root) { + ESP_LOGW(TAG, "POST /game/group_profile: malformed JSON: %s", body); + return send_error(req, "400 Bad Request", "malformed json"); + } + + const cJSON *profile = cJSON_GetObjectItemCaseSensitive(root, "group_profile"); + if (!cJSON_IsString(profile) || profile->valuestring == NULL || + profile->valuestring[0] == '\0') { + cJSON_Delete(root); + return send_error(req, "400 Bad Request", + "missing 'group_profile'"); + } + + // Push to the hints client first — its built-in whitelist is the + // source of truth for valid profiles. If it accepts the value we + // then persist; if it rejects we never touch NVS so a bad POST + // cannot brick the boot-time seed. + esp_err_t set_err = hints_client_set_group_profile(profile->valuestring); + if (set_err != ESP_OK) { + ESP_LOGW(TAG, "hints_client_set_group_profile(\"%s\") rejected: %s", + profile->valuestring, esp_err_to_name(set_err)); + cJSON_Delete(root); + return send_error(req, "400 Bad Request", GAME_ENDPOINT_PROFILE_HELP); + } + + // Best-effort NVS persistence. If it fails the in-RAM hints client + // is still updated, but we surface a 500 so the operator knows the + // change won't survive reboot. Log line above already captured the + // underlying NVS error. + esp_err_t nvs_err = persist_group_profile(profile->valuestring); + if (nvs_err != ESP_OK) { + char buf[192]; + snprintf(buf, sizeof(buf), + "{\"status\":\"runtime_only\"," + "\"group_profile\":\"%s\"," + "\"warning\":\"nvs write failed: %s\"}", + profile->valuestring, esp_err_to_name(nvs_err)); + cJSON_Delete(root); + return send_json(req, "500 Internal Server Error", buf); + } + + ESP_LOGI(TAG, "group_profile updated -> %s (NVS persisted)", + profile->valuestring); + + char buf[128]; + snprintf(buf, sizeof(buf), + "{\"status\":\"ok\",\"group_profile\":\"%s\"}", + profile->valuestring); + cJSON_Delete(root); + return send_json(req, "200 OK", buf); +} + +// ─── LittleFS lazy mount (shared with media_manager — idempotent) ────────── + +static bool s_storage_mounted = false; + +static esp_err_t mount_storage_lazy(void) { + if (s_storage_mounted) return ESP_OK; + esp_vfs_littlefs_conf_t conf = { + .base_path = GAME_ENDPOINT_STORAGE_BASE, + .partition_label = GAME_ENDPOINT_STORAGE_LABEL, + .format_if_mount_failed = true, + .dont_mount = false, + }; + esp_err_t err = esp_vfs_littlefs_register(&conf); + if (err == ESP_OK || err == ESP_ERR_INVALID_STATE) { + // INVALID_STATE = already registered by another component → fine. + s_storage_mounted = true; + ESP_LOGI(TAG, "littlefs '%s' mounted at %s", + conf.partition_label, conf.base_path); + return ESP_OK; + } + ESP_LOGE(TAG, "esp_vfs_littlefs_register(%s) failed: %s", + conf.partition_label, esp_err_to_name(err)); + return err; +} + +// ─── deferred reboot (lets the HTTP response flush first) ────────────────── + +static void deferred_restart_task(void *arg) { + (void) arg; + vTaskDelay(pdMS_TO_TICKS(800)); + ESP_LOGW(TAG, "scenario hot-load: rebooting to apply new IR"); + esp_restart(); +} + +static void schedule_restart(void) { + xTaskCreate(deferred_restart_task, "scenario_restart", + 4096, NULL, tskIDLE_PRIORITY + 1, NULL); +} + +// ─── _scenario_apply — shared validate + atomic-write path ────────────────── +// +// The single internal entry point both the HTTP POST /game/scenario handler +// and the ESP-NOW receiver (scenario_mesh) funnel through, per the spec. It +// validates the IR (schema_version + non-empty steps), rotates the current +// scenario to .bak, and atomically writes the new blob to LittleFS. On +// success it schedules the hot-reload reboot and returns ESP_OK; the optional +// out-params report the parsed step count / entry id for the HTTP response. +// +// On failure it returns a specific esp_err_t and leaves the previous scenario +// in place (rolled back from .bak when a short write corrupted the new file). +// `err_msg_out` (if non-NULL) receives a static human-readable reason. +static esp_err_t scenario_apply_buffer(const char *body, size_t len, + int *steps_count_out, + char *entry_out, size_t entry_cap, + const char **err_msg_out) { + if (err_msg_out) *err_msg_out = NULL; + if (steps_count_out) *steps_count_out = 0; + if (entry_out && entry_cap) entry_out[0] = '\0'; + + if (!body || len == 0 || len > GAME_ENDPOINT_MAX_SCENARIO_BYTES) { + if (err_msg_out) *err_msg_out = "body must be 1..65536 bytes"; + return ESP_ERR_INVALID_SIZE; + } + if (mount_storage_lazy() != ESP_OK) { + if (err_msg_out) *err_msg_out = "littlefs mount failed"; + return ESP_FAIL; + } + + // Minimal validation: parse + schema_version + non-empty steps array. + // The runtime3_common.py validator is the strict source of truth on the + // gateway side; here we keep the firmware permissive but safe. + cJSON *root = cJSON_Parse(body); + if (!root) { + ESP_LOGW(TAG, "scenario apply: malformed JSON (len=%d)", (int) len); + if (err_msg_out) *err_msg_out = "malformed json"; + return ESP_ERR_INVALID_ARG; + } + const cJSON *schema = cJSON_GetObjectItemCaseSensitive(root, "schema_version"); + if (!cJSON_IsString(schema) || + strcmp(schema->valuestring, "zacus.runtime3.v1") != 0) { + cJSON_Delete(root); + if (err_msg_out) *err_msg_out = "schema_version must be zacus.runtime3.v1"; + return ESP_ERR_INVALID_ARG; + } + const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps"); + if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) { + cJSON_Delete(root); + if (err_msg_out) *err_msg_out = "steps must be a non-empty array"; + return ESP_ERR_INVALID_ARG; + } + const cJSON *scenario_obj = cJSON_GetObjectItemCaseSensitive(root, "scenario"); + const cJSON *entry = scenario_obj + ? cJSON_GetObjectItemCaseSensitive(scenario_obj, "entry_step_id") + : NULL; + if (entry_out && entry_cap && cJSON_IsString(entry) && entry->valuestring) { + strncpy(entry_out, entry->valuestring, entry_cap - 1); + entry_out[entry_cap - 1] = '\0'; + } + int steps_count = cJSON_GetArraySize(steps); + cJSON_Delete(root); + if (steps_count_out) *steps_count_out = steps_count; + + // Rotate existing scenario -> .bak so a broken push can be rolled back + // by a future scenario_engine_reload() failure path. + struct stat st; + if (stat(GAME_ENDPOINT_SCENARIO_PATH, &st) == 0) { + // Best-effort: ignore rename failure (e.g. .bak already exists from + // a previous push — overwrite via unlink+rename). + unlink(GAME_ENDPOINT_SCENARIO_BAK); + if (rename(GAME_ENDPOINT_SCENARIO_PATH, + GAME_ENDPOINT_SCENARIO_BAK) != 0) { + ESP_LOGW(TAG, "rename current scenario -> .bak failed (errno=%d)", + errno); + } + } + + FILE *f = fopen(GAME_ENDPOINT_SCENARIO_PATH, "wb"); + if (!f) { + ESP_LOGE(TAG, "fopen %s for write failed (errno=%d)", + GAME_ENDPOINT_SCENARIO_PATH, errno); + if (err_msg_out) *err_msg_out = "scenario write open failed"; + return ESP_FAIL; + } + size_t written = fwrite(body, 1, len, f); + fclose(f); + + if (written != len) { + ESP_LOGE(TAG, "scenario write short: %zu/%zu bytes (rolling back)", + written, len); + unlink(GAME_ENDPOINT_SCENARIO_PATH); + if (stat(GAME_ENDPOINT_SCENARIO_BAK, &st) == 0) { + rename(GAME_ENDPOINT_SCENARIO_BAK, GAME_ENDPOINT_SCENARIO_PATH); + } + if (err_msg_out) *err_msg_out = "scenario write short"; + return ESP_FAIL; + } + + ESP_LOGI(TAG, "scenario hot-load OK: %zu bytes, %d steps, entry=%s", + len, steps_count, (entry_out && entry_cap) ? entry_out : ""); + + // Hot-reload-via-reboot until scenario_engine_reload() lands (Phase 3). + schedule_restart(); + return ESP_OK; +} + +// scenario_mesh apply callback: a received-over-ESP-NOW scenario takes the +// exact same path as an HTTP POST. The mesh frees `data` after this returns. +static esp_err_t scenario_mesh_apply_adapter(const char *data, size_t len) { + int steps = 0; + char entry[64] = {0}; + const char *emsg = NULL; + esp_err_t err = scenario_apply_buffer(data, len, &steps, + entry, sizeof(entry), &emsg); + if (err != ESP_OK) { + ESP_LOGW(TAG, "ESP-NOW scenario rejected: %s", + emsg ? emsg : esp_err_to_name(err)); + } + return err; +} + +// ─── POST /game/scenario — accept a Runtime 3 IR JSON ────────────────────── + +static esp_err_t handle_scenario_post(httpd_req_t *req) { + if (req->content_len <= 0 || + req->content_len > GAME_ENDPOINT_MAX_SCENARIO_BYTES) { + ESP_LOGW(TAG, "POST /game/scenario: bad body length %d", + (int) req->content_len); + return send_error(req, "413 Payload Too Large", + "body must be 1..65536 bytes"); + } + + char *body = (char *) malloc((size_t) req->content_len + 1); + if (!body) { + return send_error(req, "500 Internal Server Error", + "out of memory"); + } + int total = 0; + while (total < (int) req->content_len) { + int got = httpd_req_recv(req, body + total, + req->content_len - total); + if (got <= 0) { + if (got == HTTPD_SOCK_ERR_TIMEOUT) continue; + free(body); + return send_error(req, "400 Bad Request", "recv failed"); + } + total += got; + } + body[total] = '\0'; + + int steps_count = 0; + char entry_str[64] = {0}; + const char *emsg = NULL; + esp_err_t aerr = scenario_apply_buffer(body, (size_t) total, &steps_count, + entry_str, sizeof(entry_str), &emsg); + free(body); + + if (aerr != ESP_OK) { + const char *status = (aerr == ESP_ERR_INVALID_ARG || + aerr == ESP_ERR_INVALID_SIZE) + ? "400 Bad Request" : "500 Internal Server Error"; + return send_error(req, status, emsg ? emsg : esp_err_to_name(aerr)); + } + + char buf[256]; + snprintf(buf, sizeof(buf), + "{\"status\":\"ok\",\"steps_count\":%d," + "\"entry_step_id\":\"%s\",\"bytes\":%d," + "\"reload\":\"reboot_pending\"}", + steps_count, entry_str, total); + // The HTTP response is queued by send_json; scenario_apply_buffer already + // scheduled the deferred restart, which waits 800 ms before rebooting so + // the TCP stack can flush this response first. + return send_json(req, "200 OK", buf); +} + +// ─── POST /game/scenario/relay (master only) ─────────────────────────────── +// +// Body: { "peers": ["box3","plip",...], "ir": { } } +// +// Resolves each alias to a MAC via the scenario_mesh peer registry, then +// chunks + sends the IR over ESP-NOW. A failure on one peer (unknown alias, +// ack timeout) does not abort the others — it lands in "skipped" with a +// reason. Responds 200 with { "relayed":[...], "skipped":[{name,reason}] }. +static esp_err_t handle_scenario_relay_post(httpd_req_t *req) { + if (req->content_len <= 0 || + req->content_len > GAME_ENDPOINT_MAX_SCENARIO_BYTES) { + ESP_LOGW(TAG, "POST /game/scenario/relay: bad body length %d", + (int) req->content_len); + return send_error(req, "413 Payload Too Large", + "body must be 1..65536 bytes"); + } + + char *body = (char *) malloc((size_t) req->content_len + 1); + if (!body) { + return send_error(req, "500 Internal Server Error", "out of memory"); + } + int total = 0; + while (total < (int) req->content_len) { + int got = httpd_req_recv(req, body + total, req->content_len - total); + if (got <= 0) { + if (got == HTTPD_SOCK_ERR_TIMEOUT) continue; + free(body); + return send_error(req, "400 Bad Request", "recv failed"); + } + total += got; + } + body[total] = '\0'; + + cJSON *root = cJSON_Parse(body); + free(body); + if (!root) { + return send_error(req, "400 Bad Request", "malformed json"); + } + const cJSON *peers = cJSON_GetObjectItemCaseSensitive(root, "peers"); + const cJSON *ir = cJSON_GetObjectItemCaseSensitive(root, "ir"); + if (!cJSON_IsArray(peers) || cJSON_GetArraySize(peers) == 0) { + cJSON_Delete(root); + return send_error(req, "400 Bad Request", + "'peers' must be a non-empty array"); + } + if (!cJSON_IsObject(ir)) { + cJSON_Delete(root); + return send_error(req, "400 Bad Request", "'ir' must be an object"); + } + + // Serialize the IR object once (unformatted, compact) — this is the exact + // byte stream the receiver reassembles and feeds to _scenario_apply(). + char *ir_str = cJSON_PrintUnformatted(ir); + if (!ir_str) { + cJSON_Delete(root); + return send_error(req, "500 Internal Server Error", + "ir serialize failed"); + } + size_t ir_len = strlen(ir_str); + + // Build the two result arrays into a response cJSON tree. + cJSON *resp = cJSON_CreateObject(); + cJSON *relayed = cJSON_AddArrayToObject(resp, "relayed"); + cJSON *skipped = cJSON_AddArrayToObject(resp, "skipped"); + + int n = cJSON_GetArraySize(peers); + for (int i = 0; i < n; i++) { + const cJSON *p = cJSON_GetArrayItem(peers, i); + if (!cJSON_IsString(p) || !p->valuestring) continue; + const char *alias = p->valuestring; + + uint8_t mac[6]; + esp_err_t rerr = scenario_mesh_mac_for_alias(alias, mac); + if (rerr != ESP_OK) { + cJSON *s = cJSON_CreateObject(); + cJSON_AddStringToObject(s, "name", alias); + cJSON_AddStringToObject(s, "reason", "unknown_peer"); + cJSON_AddItemToArray(skipped, s); + ESP_LOGW(TAG, "relay: alias \"%s\" not in peer registry", alias); + continue; + } + + esp_err_t serr = scenario_mesh_send(mac, ir_str, ir_len); + if (serr == ESP_OK) { + cJSON_AddItemToArray(relayed, cJSON_CreateString(alias)); + ESP_LOGI(TAG, "relay: \"%s\" OK (%u bytes)", + alias, (unsigned) ir_len); + } else { + cJSON *s = cJSON_CreateObject(); + cJSON_AddStringToObject(s, "name", alias); + cJSON_AddStringToObject(s, "reason", + serr == ESP_ERR_TIMEOUT ? "timeout" : esp_err_to_name(serr)); + cJSON_AddItemToArray(skipped, s); + ESP_LOGW(TAG, "relay: \"%s\" failed: %s", + alias, esp_err_to_name(serr)); + } + } + + cJSON_free(ir_str); + cJSON_Delete(root); + + char *resp_str = cJSON_PrintUnformatted(resp); + cJSON_Delete(resp); + if (!resp_str) { + return send_error(req, "500 Internal Server Error", + "response serialize failed"); + } + esp_err_t ret = send_json(req, "200 OK", resp_str); + cJSON_free(resp_str); + return ret; +} + +// ─── public init ──────────────────────────────────────────────────────────── + +esp_err_t game_endpoint_init(httpd_handle_t server) { + if (server == NULL) { + ESP_LOGE(TAG, "game_endpoint_init: NULL httpd handle " + "(did ota_server_init() succeed?)"); + return ESP_ERR_INVALID_ARG; + } + + static const httpd_uri_t uri_get = { + .uri = "/game/group_profile", + .method = HTTP_GET, + .handler = handle_group_profile_get, + .user_ctx = NULL, + }; + static const httpd_uri_t uri_post = { + .uri = "/game/group_profile", + .method = HTTP_POST, + .handler = handle_group_profile_post, + .user_ctx = NULL, + }; + static const httpd_uri_t uri_scenario_post = { + .uri = "/game/scenario", + .method = HTTP_POST, + .handler = handle_scenario_post, + .user_ctx = NULL, + }; + static const httpd_uri_t uri_scenario_relay = { + .uri = "/game/scenario/relay", + .method = HTTP_POST, + .handler = handle_scenario_relay_post, + .user_ctx = NULL, + }; + + // Bring up the ESP-NOW mesh transport. The master is primarily a sender + // (the relay handler) but we also register the apply adapter so a peer + // could push a scenario back over ESP-NOW symmetrically. Non-fatal: if the + // Wi-Fi stack isn't ready the relay endpoint will just report send errors. + esp_err_t mesh_err = scenario_mesh_init(scenario_mesh_apply_adapter); + if (mesh_err != ESP_OK) { + ESP_LOGW(TAG, "scenario_mesh_init failed: %s — /game/scenario/relay " + "will be unavailable", esp_err_to_name(mesh_err)); + } + + esp_err_t err = httpd_register_uri_handler(server, &uri_get); + if (err != ESP_OK) { + ESP_LOGE(TAG, "register GET /game/group_profile: %s", + esp_err_to_name(err)); + return err; + } + err = httpd_register_uri_handler(server, &uri_post); + if (err != ESP_OK) { + ESP_LOGE(TAG, "register POST /game/group_profile: %s", + esp_err_to_name(err)); + return err; + } + err = httpd_register_uri_handler(server, &uri_scenario_post); + if (err != ESP_OK) { + ESP_LOGE(TAG, "register POST /game/scenario: %s", + esp_err_to_name(err)); + return err; + } + // Relay is best-effort: only register it when the mesh came up, but a + // registration failure here is non-fatal to the rest of the surface. + if (mesh_err == ESP_OK) { + err = httpd_register_uri_handler(server, &uri_scenario_relay); + if (err != ESP_OK) { + ESP_LOGW(TAG, "register POST /game/scenario/relay: %s", + esp_err_to_name(err)); + } + } + + ESP_LOGI(TAG, "game endpoint registered " + "(GET+POST /game/group_profile, POST /game/scenario%s)", + mesh_err == ESP_OK ? ", POST /game/scenario/relay" : ""); + return ESP_OK; +} diff --git a/idf_zacus/components/game_endpoint/include/game_endpoint.h b/idf_zacus/components/game_endpoint/include/game_endpoint.h new file mode 100644 index 0000000..41f4db1 --- /dev/null +++ b/idf_zacus/components/game_endpoint/include/game_endpoint.h @@ -0,0 +1,69 @@ +// game_endpoint — REST surface for runtime game configuration. +// +// Slice 12 of the IDF migration. Today this exposes a single resource: +// +// GET /game/group_profile — read the active hints group profile. +// POST /game/group_profile — set a new profile, persist to NVS, and +// push it to the hints_client so the +// next /hints/ask body carries it. +// +// The handlers are attached to the existing esp_http_server instance +// owned by the ota_server component (port 80) — same pattern as +// voice_hook_endpoint. No second TCP socket, no second worker pool. +// +// NVS persistence: namespace "zacus", key "group_profile". This is the +// same key main.c reads at boot to seed the hints client, so a +// successful POST survives reboot without any flash step. +// +// Validation is delegated to hints_client_set_group_profile() which +// already enforces the "TECH" / "NON_TECH" / "MIXED" / "BOTH" whitelist. +// On invalid input the NVS write is skipped and the client keeps its +// previous value. + +#pragma once + +#include "esp_err.h" +#include "esp_http_server.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Cap the request body so a malformed PLIP / dashboard client cannot +// blow up the worker stack. 256 bytes is plenty for {"group_profile": +// "NON_TECH"} (~30 bytes) plus future additive fields. +#define GAME_ENDPOINT_MAX_BODY_BYTES 256 + +// Larger cap for the Runtime 3 IR scenario blob. 64 KiB lets a +// reasonable escape-room scenario (~50 steps, dialogues + actions) +// fit comfortably. Scenarios that exceed this should be split +// across multiple boards or trimmed. +#define GAME_ENDPOINT_MAX_SCENARIO_BYTES (64 * 1024) + +// LittleFS partition label declared in partitions.csv. game_endpoint +// mounts lazily on first scenario POST. media_manager may also mount +// the same label — esp_vfs_littlefs_register is idempotent per label. +#define GAME_ENDPOINT_STORAGE_LABEL "storage" +// main.c mounts the storage partition at /littlefs at boot — we reuse the +// same mount point instead of registering a second base path for the same +// partition (which fails silently with INVALID_STATE). +#define GAME_ENDPOINT_STORAGE_BASE "/littlefs" +#define GAME_ENDPOINT_SCENARIO_PATH GAME_ENDPOINT_STORAGE_BASE "/scenario.json" +#define GAME_ENDPOINT_SCENARIO_BAK GAME_ENDPOINT_STORAGE_BASE "/scenario.bak" + +/** + * @brief Attach all game endpoint handlers to an existing esp_http_server. + * + * Registers: + * - GET/POST /game/group_profile (slice 12, runtime hints profile) + * - POST /game/scenario (slice 13, Runtime 3 IR hot-load) + * + * Pass the handle returned by `ota_server_get_handle()`. Returns + * ESP_ERR_INVALID_ARG if `server` is NULL, or any error propagated + * from `httpd_register_uri_handler()`. + */ +esp_err_t game_endpoint_init(httpd_handle_t server); + +#ifdef __cplusplus +} +#endif diff --git a/idf_zacus/components/hints_client/CMakeLists.txt b/idf_zacus/components/hints_client/CMakeLists.txt new file mode 100644 index 0000000..bdd93e0 --- /dev/null +++ b/idf_zacus/components/hints_client/CMakeLists.txt @@ -0,0 +1,16 @@ +idf_component_register( + SRCS + "hints_client.c" + INCLUDE_DIRS + "include" + REQUIRES + esp_http_client + esp_timer + esp_system + esp_wifi + esp_netif + nvs_flash + json + freertos + log +) diff --git a/idf_zacus/components/hints_client/hints_client.c b/idf_zacus/components/hints_client/hints_client.c new file mode 100644 index 0000000..d40a5e4 --- /dev/null +++ b/idf_zacus/components/hints_client/hints_client.c @@ -0,0 +1,327 @@ +#include "hints_client.h" + +#include +#include +#include + +#include "cJSON.h" +#include "esp_event.h" +#include "esp_http_client.h" +#include "esp_log.h" +#include "esp_mac.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static const char *TAG = "hints_client"; + +#define WORKER_STACK_DEFAULT 6144 +#define WORKER_PRIO_DEFAULT 5 +#define ASK_PATH "/hints/ask" +#define PUZZLE_START_PATH "/hints/puzzle_start" +#define ATTEMPT_FAILED_PATH "/hints/attempt_failed" +#define SESSION_ID_LEN 13 // 12 hex + NUL + +static struct { + bool ready; + char base_url[HINTS_CLIENT_BASE_URL_MAX]; + char session_id[SESSION_ID_LEN]; + char group_profile[HINTS_CLIENT_GROUP_PROFILE_MAX]; +} s_client = {0}; + +// Whitelist of accepted group profile values. Keep in sync with the +// `group_profile` enum in the hints engine (game/hints/* server-side). +static const char *const kAllowedProfiles[] = { + "TECH", "NON_TECH", "MIXED", "BOTH", +}; +static const size_t kAllowedProfilesCount = + sizeof(kAllowedProfiles) / sizeof(kAllowedProfiles[0]); + +static bool profile_is_allowed(const char *p) { + if (!p || !*p) return false; + for (size_t i = 0; i < kAllowedProfilesCount; ++i) { + if (strcmp(p, kAllowedProfiles[i]) == 0) return true; + } + return false; +} + +// Receive buffer used by hints_client_ask. Sized to one HINTS_CLIENT_HINT_MAX +// hint plus generous JSON envelope. +typedef struct { + char *buf; + size_t cap; + size_t len; +} recv_buf_t; + +static void session_id_init(void) { + uint8_t mac[6] = {0}; + if (esp_efuse_mac_get_default(mac) == ESP_OK) { + snprintf(s_client.session_id, SESSION_ID_LEN, + "%02x%02x%02x%02x%02x%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + } else { + strncpy(s_client.session_id, "unknown-mac", SESSION_ID_LEN - 1); + } + s_client.session_id[SESSION_ID_LEN - 1] = '\0'; +} + +esp_err_t hints_client_init(const char *base_url) { + if (!base_url || !*base_url) return ESP_ERR_INVALID_ARG; + if (strlen(base_url) >= sizeof(s_client.base_url)) return ESP_ERR_INVALID_SIZE; + + strncpy(s_client.base_url, base_url, sizeof(s_client.base_url) - 1); + s_client.base_url[sizeof(s_client.base_url) - 1] = '\0'; + // Default group profile until main reads NVS or the dashboard pushes + // a new one. Validated through hints_client_set_group_profile. + strncpy(s_client.group_profile, "MIXED", + sizeof(s_client.group_profile) - 1); + s_client.group_profile[sizeof(s_client.group_profile) - 1] = '\0'; + session_id_init(); + s_client.ready = true; + ESP_LOGI(TAG, "ready, base_url=%s session_id=%s group_profile=%s", + s_client.base_url, s_client.session_id, s_client.group_profile); + return ESP_OK; +} + +bool hints_client_is_ready(void) { + return s_client.ready; +} + +esp_err_t hints_client_set_group_profile(const char *profile) { + if (!profile_is_allowed(profile)) { + ESP_LOGW(TAG, "set_group_profile: invalid value \"%s\" — keeping \"%s\"", + profile ? profile : "(null)", s_client.group_profile); + return ESP_ERR_INVALID_ARG; + } + strncpy(s_client.group_profile, profile, + sizeof(s_client.group_profile) - 1); + s_client.group_profile[sizeof(s_client.group_profile) - 1] = '\0'; + ESP_LOGI(TAG, "group_profile set to \"%s\"", s_client.group_profile); + return ESP_OK; +} + +const char *hints_client_group_profile(void) { + // Always non-NULL after init(); falls back to empty string before + // init so callers don't need a separate ready check. + return s_client.group_profile[0] ? s_client.group_profile : ""; +} + +static esp_err_t http_event_cb(esp_http_client_event_t *evt) { + if (evt->event_id != HTTP_EVENT_ON_DATA) return ESP_OK; + recv_buf_t *r = (recv_buf_t *) evt->user_data; + if (!r || !r->buf) return ESP_OK; + int chunk = evt->data_len; + if (r->len + chunk >= r->cap) { + chunk = (int) (r->cap - r->len - 1); + } + if (chunk > 0) { + memcpy(r->buf + r->len, evt->data, (size_t) chunk); + r->len += (size_t) chunk; + r->buf[r->len] = '\0'; + } + return ESP_OK; +} + +// ── Shared HTTP helper ─────────────────────────────────────────────────── +// +// Performs `POST {base_url}{path}` with `body_str` as the request body +// and writes the response into `recv` (NUL-terminated, truncated to +// recv->cap-1). Returns: +// ESP_OK any 2xx (including 204 No Content) +// ESP_FAIL transport ok, non-2xx response +// transport-level error from esp_http_client_perform() +// +// The caller owns `body_str` and `recv->buf`. `timeout_ms` is per-request. +static esp_err_t post_json(const char *path, const char *body_str, + int timeout_ms, recv_buf_t *recv) { + char url[HINTS_CLIENT_BASE_URL_MAX + 64]; + snprintf(url, sizeof(url), "%s%s", s_client.base_url, path); + + esp_http_client_config_t cfg = { + .url = url, + .method = HTTP_METHOD_POST, + .timeout_ms = timeout_ms, + .event_handler = recv ? http_event_cb : NULL, + .user_data = recv, + .disable_auto_redirect = true, + }; + esp_http_client_handle_t client = esp_http_client_init(&cfg); + if (!client) return ESP_FAIL; + esp_http_client_set_header(client, "Content-Type", "application/json"); + esp_http_client_set_post_field(client, body_str, (int) strlen(body_str)); + + esp_err_t err = esp_http_client_perform(client); + int status = esp_http_client_get_status_code(client); + esp_http_client_cleanup(client); + + if (err != ESP_OK) { + ESP_LOGW(TAG, "POST %s perform failed: %s", + path, esp_err_to_name(err)); + return err; + } + if (status < 200 || status >= 300) { + ESP_LOGW(TAG, "POST %s non-2xx %d body=%.*s", + path, status, + recv ? (int) recv->len : 0, + recv ? recv->buf : ""); + return ESP_FAIL; + } + return ESP_OK; +} + +esp_err_t hints_client_ask(const char *puzzle_id, uint8_t level, + char *out_hint, size_t out_size) { + if (!s_client.ready) return ESP_ERR_INVALID_STATE; + if (!puzzle_id || !*puzzle_id || !out_hint || out_size == 0) { + return ESP_ERR_INVALID_ARG; + } + + cJSON *body = cJSON_CreateObject(); + if (!body) return ESP_ERR_NO_MEM; + cJSON_AddStringToObject(body, "puzzle_id", puzzle_id); + cJSON_AddNumberToObject(body, "level", level); + cJSON_AddStringToObject(body, "session_id", s_client.session_id); + if (s_client.group_profile[0] != '\0') { + cJSON_AddStringToObject(body, "group_profile", s_client.group_profile); + } + char *body_str = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + if (!body_str) return ESP_ERR_NO_MEM; + + char recv_storage[1024]; + recv_buf_t recv = {.buf = recv_storage, .cap = sizeof(recv_storage), .len = 0}; + recv_storage[0] = '\0'; + + esp_err_t err = post_json(ASK_PATH, body_str, + HINTS_CLIENT_TIMEOUT_MS, &recv); + free(body_str); + if (err != ESP_OK) return err; + + cJSON *root = cJSON_Parse(recv.buf); + if (!root) { + ESP_LOGW(TAG, "JSON parse failed: %.*s", (int) recv.len, recv.buf); + return ESP_FAIL; + } + cJSON *refused = cJSON_GetObjectItemCaseSensitive(root, "refused"); + if (cJSON_IsTrue(refused)) { + cJSON *reason = cJSON_GetObjectItemCaseSensitive(root, "reason"); + const char *reason_str = (reason && cJSON_IsString(reason)) + ? reason->valuestring : "unknown"; + snprintf(out_hint, out_size, + "Le Professeur Zacus reste muet pour l'instant."); + ESP_LOGI(TAG, "refused: %s", reason_str); + cJSON_Delete(root); + return ESP_OK; // refusal is a valid response, not an error + } + cJSON *hint = cJSON_GetObjectItemCaseSensitive(root, "hint"); + if (!hint || !cJSON_IsString(hint)) { + ESP_LOGW(TAG, "no hint field in response"); + cJSON_Delete(root); + return ESP_FAIL; + } + strncpy(out_hint, hint->valuestring, out_size - 1); + out_hint[out_size - 1] = '\0'; + cJSON_Delete(root); + return ESP_OK; +} + +// ── Lifecycle endpoints (slice 11 / P5) ──────────────────────────────────── + +// Shared body builder for /puzzle_start and /attempt_failed. +// Both endpoints take the same minimal payload {session_id, puzzle_id}. +static esp_err_t lifecycle_post(const char *path, const char *puzzle_id) { + if (!s_client.ready) return ESP_ERR_INVALID_STATE; + if (!puzzle_id || !*puzzle_id) return ESP_ERR_INVALID_ARG; + + cJSON *body = cJSON_CreateObject(); + if (!body) return ESP_ERR_NO_MEM; + cJSON_AddStringToObject(body, "session_id", s_client.session_id); + cJSON_AddStringToObject(body, "puzzle_id", puzzle_id); + char *body_str = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + if (!body_str) return ESP_ERR_NO_MEM; + + char recv_storage[256]; + recv_buf_t recv = {.buf = recv_storage, .cap = sizeof(recv_storage), .len = 0}; + recv_storage[0] = '\0'; + + esp_err_t err = post_json(path, body_str, + HINTS_CLIENT_LIFECYCLE_TIMEOUT_MS, &recv); + free(body_str); + return err; +} + +esp_err_t hints_client_puzzle_start(const char *puzzle_id) { + esp_err_t err = lifecycle_post(PUZZLE_START_PATH, puzzle_id); + if (err == ESP_OK) { + ESP_LOGI(TAG, "puzzle_start ok puzzle=\"%s\"", puzzle_id); + } else { + ESP_LOGW(TAG, "puzzle_start best-effort failed (puzzle=\"%s\"): %s", + puzzle_id ? puzzle_id : "(null)", esp_err_to_name(err)); + } + return err; +} + +esp_err_t hints_client_attempt_failed(const char *puzzle_id) { + esp_err_t err = lifecycle_post(ATTEMPT_FAILED_PATH, puzzle_id); + if (err == ESP_OK) { + ESP_LOGI(TAG, "attempt_failed ok puzzle=\"%s\"", puzzle_id); + } else { + ESP_LOGW(TAG, "attempt_failed best-effort failed (puzzle=\"%s\"): %s", + puzzle_id ? puzzle_id : "(null)", esp_err_to_name(err)); + } + return err; +} + +// ── Async wrapper ────────────────────────────────────────────────────────── + +typedef struct { + char puzzle_id_str[64]; + uint8_t puzzle_id_num; + uint8_t level; + hints_client_callback_t cb; + void *user_ctx; +} async_arg_t; + +static void async_worker(void *pv) { + async_arg_t *arg = (async_arg_t *) pv; + char hint[HINTS_CLIENT_HINT_MAX]; + hint[0] = '\0'; + esp_err_t err = hints_client_ask(arg->puzzle_id_str, arg->level, + hint, sizeof(hint)); + if (arg->cb) { + arg->cb(arg->puzzle_id_num, arg->level, err, + err == ESP_OK ? hint : NULL, arg->user_ctx); + } + free(arg); + vTaskDelete(NULL); +} + +esp_err_t hints_client_ask_async(const char *puzzle_id_str, + uint8_t puzzle_id_num, + uint8_t level, + hints_client_callback_t cb, + void *user_ctx, + uint32_t stack, + uint8_t prio) { + if (!s_client.ready) return ESP_ERR_INVALID_STATE; + if (!puzzle_id_str || !*puzzle_id_str || !cb) return ESP_ERR_INVALID_ARG; + + async_arg_t *arg = (async_arg_t *) calloc(1, sizeof(async_arg_t)); + if (!arg) return ESP_ERR_NO_MEM; + strncpy(arg->puzzle_id_str, puzzle_id_str, sizeof(arg->puzzle_id_str) - 1); + arg->puzzle_id_num = puzzle_id_num; + arg->level = level; + arg->cb = cb; + arg->user_ctx = user_ctx; + + BaseType_t ok = xTaskCreate(async_worker, "hints_async", + stack ? stack : WORKER_STACK_DEFAULT, + arg, + prio ? prio : WORKER_PRIO_DEFAULT, + NULL); + if (ok != pdPASS) { + free(arg); + return ESP_ERR_NO_MEM; + } + return ESP_OK; +} diff --git a/idf_zacus/components/hints_client/include/hints_client.h b/idf_zacus/components/hints_client/include/hints_client.h new file mode 100644 index 0000000..fe25441 --- /dev/null +++ b/idf_zacus/components/hints_client/include/hints_client.h @@ -0,0 +1,110 @@ +// hints_client — HTTP client to the Zacus hints engine. +// +// POSTs JSON {"puzzle_id":..,"level":..,"session_id":..,"group_profile":..} +// to {base_url}/hints/ask and parses the "hint" / "source" / "refused" / +// "cooldown_until_ms" fields. +// +// Slice 11 (P5) adds two best-effort lifecycle endpoints used by the +// scenario engine to give the hints backend richer context for its +// adaptive policy: +// * /hints/puzzle_start — entered a new pivot +// * /hints/attempt_failed — operator reported an invalid input +// Both POST a minimal `{session_id, puzzle_id}` body. Failures are logged +// but never fatal: the engine works without these signals, they only +// improve the hint quality. +// +// Surfaces: +// * hints_client_ask() — synchronous, blocks the calling task up +// to HINTS_CLIENT_TIMEOUT_MS. Returns +// ESP_OK on success and writes the hint +// into out_hint. +// * hints_client_ask_async() — spawns a one-shot worker task that +// performs the request and invokes `cb` +// when done. +// * hints_client_puzzle_start() — synchronous best-effort POST. +// * hints_client_attempt_failed()— synchronous best-effort POST. +// * hints_client_set_group_profile() — global profile attached to every +// /hints/ask payload (default "MIXED"). + +#pragma once + +#include +#include +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define HINTS_CLIENT_BASE_URL_MAX 128 +#define HINTS_CLIENT_HINT_MAX 512 +#define HINTS_CLIENT_TIMEOUT_MS 10000 // 10 s — covers MLX 32B latency +#define HINTS_CLIENT_LIFECYCLE_TIMEOUT_MS 5000 // /puzzle_start + /attempt_failed +#define HINTS_CLIENT_GROUP_PROFILE_MAX 32 // "TECH","NON_TECH","MIXED","BOTH" + +// Callback signature mirrors npc_engine.h's npc_hint_callback_t so the +// engine can forward `cb` directly without a trampoline. +typedef void (*hints_client_callback_t)(uint8_t puzzle_id, uint8_t level, + esp_err_t status, const char *text, + void *user_ctx); + +// Initialise the client with the base URL of the hints engine +// (e.g. "http://192.168.0.150:8302"). Caller retains ownership of the +// string — it is copied internally. +esp_err_t hints_client_init(const char *base_url); + +// Returns true once hints_client_init() succeeded. +bool hints_client_is_ready(void); + +// Synchronous request. `out_hint` receives a NUL-terminated UTF-8 string, +// up to `out_size` bytes. Errors: +// ESP_ERR_INVALID_STATE not initialised +// ESP_ERR_INVALID_ARG bad params +// ESP_ERR_TIMEOUT server did not respond in time +// ESP_FAIL HTTP non-2xx or JSON parse error +esp_err_t hints_client_ask(const char *puzzle_id, uint8_t level, + char *out_hint, size_t out_size); + +// Async wrapper. Spawns a worker FreeRTOS task with `stack` bytes of stack +// (default 6144 if 0 passed) and priority `prio` (default 5 if 0). The worker +// calls hints_client_ask() and then `cb` from its own context. +// +// `puzzle_id_str` and the user_ctx pointer are copied into the worker arg +// block, so the caller does not need to keep them alive. +esp_err_t hints_client_ask_async(const char *puzzle_id_str, + uint8_t puzzle_id_num, + uint8_t level, + hints_client_callback_t cb, + void *user_ctx, + uint32_t stack, + uint8_t prio); + +// Slice 11 (P5): notify the hints engine that the operator just entered +// the pivot identified by `puzzle_id`. Synchronous POST, timeout +// HINTS_CLIENT_LIFECYCLE_TIMEOUT_MS. Returns ESP_OK on any 2xx +// (the engine treats this as idempotent), ESP_FAIL otherwise. The call +// is best-effort — callers should log failures and continue. +esp_err_t hints_client_puzzle_start(const char *puzzle_id); + +// Same shape as hints_client_puzzle_start, but for the +// /hints/attempt_failed endpoint. Bumps the engine's failure counter +// for the current pivot, which feeds the adaptive escalation policy. +esp_err_t hints_client_attempt_failed(const char *puzzle_id); + +// Replace the global group profile attached to every /hints/ask body. +// `profile` must be one of "TECH", "NON_TECH", "MIXED", "BOTH". Any +// other value (including NULL/empty) is rejected with +// ESP_ERR_INVALID_ARG and the previous profile is preserved. Default +// after init() is "MIXED". +esp_err_t hints_client_set_group_profile(const char *profile); + +// Returns the currently configured group profile (always non-NULL). +// The pointer is owned by the client; copy if you need it past the +// next set_group_profile() call. +const char *hints_client_group_profile(void); + +#ifdef __cplusplus +} +#endif diff --git a/idf_zacus/components/media_manager/CMakeLists.txt b/idf_zacus/components/media_manager/CMakeLists.txt new file mode 100644 index 0000000..fa9c79a --- /dev/null +++ b/idf_zacus/components/media_manager/CMakeLists.txt @@ -0,0 +1,21 @@ +## Zacus media_manager — ESP-IDF port (slice 3, P1). +## +## Ports the Arduino `MediaManager` C++ class +## (ui_freenove_allinone/src/system/media/media_manager.cpp, 416 LOC) into a +## C-only ESP-IDF component. Public API exposes catalog browsing + play/stop +## + recording control. Real MP3 decoding is intentionally deferred to a +## later slice (see TODO in media_manager.c). The recorder code path is +## stubbed in the same way to keep the dependency surface minimal until the +## ES8388 / I2S microphone wiring is brought up under IDF. + +idf_component_register( + SRCS + "media_manager.c" + INCLUDE_DIRS + "include" + REQUIRES + driver + esp_timer + esp_system + joltwallet__littlefs +) diff --git a/idf_zacus/components/media_manager/include/media_manager.h b/idf_zacus/components/media_manager/include/media_manager.h new file mode 100644 index 0000000..8f4085d --- /dev/null +++ b/idf_zacus/components/media_manager/include/media_manager.h @@ -0,0 +1,125 @@ +// Zacus media_manager — ESP-IDF C port of the Arduino MediaManager class. +// +// Source of truth for the Arduino implementation: +// ESP32_ZACUS/ui_freenove_allinone/src/system/media/media_manager.cpp +// +// The Arduino version is a C++ class with stateful catalog + I2S recorder +// helpers driven from the Freenove UI loop. The IDF port keeps the same +// runtime contract (catalog browsing, play/stop, fixed-duration WAV +// recording, snapshot read-out) but exposes it as a pure-C singleton +// because every consumer in the new firmware (NPC engine, voice pipeline, +// HTTP services) speaks C and we want to avoid a C++ runtime dependency +// on this layer. +// +// MP3 decoding and the I2S microphone capture path are deliberately stubbed +// in this slice — they pull in heavy managed components (esp-adf or +// audio_pipeline + helix-mp3, plus ES8388 codec bringup) that belong in +// their own dedicated slices. The stub still mounts LittleFS, opens the +// requested file to validate it exists, simulates a 2 s playback window +// (so callers can sequence cues end-to-end), and returns ESP_OK so the +// surrounding NPC coordination logic can be exercised today. + +#pragma once + +#include +#include +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define MEDIA_PATH_MAX 128 +#define MEDIA_DIR_MAX 32 +#define MEDIA_ERROR_MAX 64 +#define MEDIA_DEFAULT_RECORD_MAX_S 30U + +// Configuration mirrors `MediaManager::Config` from the Arduino sources but +// with C strings instead of fixed char arrays embedded in a struct method. +typedef struct { + char music_dir[MEDIA_DIR_MAX]; // default "/littlefs/music" + char picture_dir[MEDIA_DIR_MAX]; // default "/littlefs/picture" + char record_dir[MEDIA_DIR_MAX]; // default "/littlefs/recorder" + uint16_t record_max_seconds; // default 30 + bool auto_stop_record_on_step_change; // default true +} media_manager_config_t; + +// Snapshot mirrors `MediaManager::Snapshot`. Returned by value (cheap, ~400B). +typedef struct { + bool ready; + bool playing; + bool recording; + bool last_ok; + bool record_simulated; // true while recorder remains stubbed + uint16_t record_limit_seconds; + uint16_t record_elapsed_seconds; + uint32_t record_started_ms; + char playing_path[MEDIA_PATH_MAX]; + char record_file[MEDIA_PATH_MAX]; + char last_error[MEDIA_ERROR_MAX]; + char music_dir[MEDIA_DIR_MAX]; + char picture_dir[MEDIA_DIR_MAX]; + char record_dir[MEDIA_DIR_MAX]; +} media_manager_snapshot_t; + +// Fill `cfg` with the defaults used by the Arduino firmware. +void media_manager_default_config(media_manager_config_t *cfg); + +// Initialize the singleton media manager. Idempotent — re-initialization +// updates the configuration without losing the recorder state. +// +// Pre-conditions: +// * LittleFS partition mounted at `cfg->music_dir` root (the manager will +// create `music_dir`, `picture_dir`, `record_dir` if missing, but the +// parent FS must exist first). +// +// Returns ESP_OK on success, ESP_ERR_INVALID_ARG on null config. +esp_err_t media_manager_init(const media_manager_config_t *cfg); + +// Periodic tick — call from the main loop (Arduino did this from `loop()`). +// `now_ms` is a monotonic millisecond counter (use esp_timer_get_time/1000). +// Updates the simulated playback completion + recorder timeout. +void media_manager_update(uint32_t now_ms); + +// Inform the manager that the active scenario step changed. When +// `auto_stop_record_on_step_change` is enabled this stops any recording +// in flight (matches Arduino behaviour). +void media_manager_note_step_change(void); + +// Begin (simulated) playback of `path`. The path can be absolute (resolved +// as-is, e.g. "/littlefs/music/foo.mp3") or relative (resolved against +// `music_dir`). Returns ESP_OK if the file exists and ESP_ERR_NOT_FOUND +// otherwise; ESP_ERR_INVALID_ARG if `path` is null/empty. +// +// TODO(slice-4+): replace simulation with real I2S MP3 playback. +esp_err_t media_manager_play(const char *path); + +// Stop any active (simulated) playback. Always succeeds. +esp_err_t media_manager_stop(void); + +// Set the playback gain (0..100). Stored in the snapshot only — the stub +// playback path does not yet drive a codec. Returns ESP_OK or +// ESP_ERR_INVALID_ARG when value > 100. +esp_err_t media_manager_set_volume(uint8_t volume); + +// Start recording up to `seconds` (clamped to `record_max_seconds`). The +// stubbed recorder allocates an empty WAV file at `record_dir/` +// so the file plumbing is exercised; future slices will plug the real I2S +// capture loop into `media_manager_update`. +// +// `filename_hint` may be null — a `record_.wav` name is generated. +esp_err_t media_manager_start_recording(uint16_t seconds, + const char *filename_hint); + +// Stop the active recording. Safe to call when not recording (returns OK). +esp_err_t media_manager_stop_recording(void); + +// Copy the current snapshot into `out` (caller-owned). Useful for +// status endpoints. +void media_manager_snapshot(media_manager_snapshot_t *out); + +#ifdef __cplusplus +} +#endif diff --git a/idf_zacus/components/media_manager/media_manager.c b/idf_zacus/components/media_manager/media_manager.c new file mode 100644 index 0000000..206c4ec --- /dev/null +++ b/idf_zacus/components/media_manager/media_manager.c @@ -0,0 +1,485 @@ +// Zacus media_manager — IDF C port (slice 3, P1 voice pipeline migration). +// +// Mirrors the Arduino MediaManager class in +// ui_freenove_allinone/src/system/media/media_manager.cpp (~416 LOC C++). +// +// What is real here: +// * Catalog directory bookkeeping (music / picture / record). +// * `media_manager_play()` validates the file exists on LittleFS and +// records the simulated-playback state into the snapshot. +// * Recorder writes an empty WAV header so the file plumbing is real +// and downstream consumers (NPC engine, voice bridge) can list / +// fetch the recorder output. +// * Step-change auto-stop hook matches Arduino behaviour. +// +// TODO(slice-4+): replace the stub with real I2S MP3 playback. The +// candidate paths are (a) ESP-ADF audio_pipeline + helix-mp3 decoder, or +// (b) a custom mini decoder reusing the helix-mp3 source already vendored +// in the Arduino tree. The decision belongs to the next slice that ports +// the AudioManager wrapper. Likewise, the I2S microphone capture path +// needs the ES8388 codec bringup before the recorder can deliver real PCM. + +#include "media_manager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "esp_check.h" +#include "esp_err.h" +#include "esp_log.h" +#include "esp_timer.h" + +static const char *TAG = "media_manager"; + +#define MEDIA_DEFAULT_MUSIC_DIR "/littlefs/music" +#define MEDIA_DEFAULT_PICTURE_DIR "/littlefs/picture" +#define MEDIA_DEFAULT_RECORD_DIR "/littlefs/recorder" +#define MEDIA_RECORDER_SAMPLE_RATE 16000UL +#define MEDIA_RECORDER_BITS 16U +#define MEDIA_RECORDER_CHANNELS 1U + +// Mirror Arduino's 2-second simulated playback window so callers can +// sequence cues without a real decoder behind us. +#define MEDIA_STUB_PLAYBACK_MS 2000U + +// ─── Singleton state ───────────────────────────────────────────────────────── + +static struct { + bool initialized; + media_manager_config_t config; + media_manager_snapshot_t snapshot; + uint8_t volume; // 0..100 + uint32_t playback_ends_ms; + FILE *recording_file; + uint32_t recording_data_bytes; +} s_media; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +static void copy_text(char *dst, size_t dst_len, const char *src) { + if (dst == NULL || dst_len == 0U) { + return; + } + if (src == NULL) { + dst[0] = '\0'; + return; + } + strncpy(dst, src, dst_len - 1U); + dst[dst_len - 1U] = '\0'; +} + +static void normalize_dir(char *out, size_t out_len, const char *src) { + if (out == NULL || out_len == 0U) { + return; + } + if (src == NULL || src[0] == '\0') { + copy_text(out, out_len, "/"); + return; + } + // Skip leading whitespace. + while (*src == ' ' || *src == '\t') { + ++src; + } + if (src[0] == '\0') { + copy_text(out, out_len, "/"); + return; + } + if (src[0] != '/') { + snprintf(out, out_len, "/%s", src); + } else { + copy_text(out, out_len, src); + } + // Trim trailing slash unless root. + size_t len = strlen(out); + while (len > 1U && out[len - 1U] == '/') { + out[len - 1U] = '\0'; + --len; + } +} + +static bool file_exists(const char *path) { + if (path == NULL || path[0] == '\0') { + return false; + } + struct stat st; + return stat(path, &st) == 0; +} + +static esp_err_t ensure_dir(const char *path) { + if (path == NULL || path[0] == '\0') { + return ESP_ERR_INVALID_ARG; + } + struct stat st; + if (stat(path, &st) == 0) { + return ESP_OK; + } + if (mkdir(path, 0777) == 0) { + return ESP_OK; + } + ESP_LOGW(TAG, "mkdir(%s) failed: errno=%d", path, errno); + return ESP_FAIL; +} + +static void set_last_error(const char *msg) { + s_media.snapshot.last_ok = false; + copy_text(s_media.snapshot.last_error, + sizeof(s_media.snapshot.last_error), + msg != NULL ? msg : "media_unknown_error"); +} + +static void clear_last_error(void) { + s_media.snapshot.last_ok = true; + s_media.snapshot.last_error[0] = '\0'; +} + +// Resolve `path` against `music_dir` if relative; otherwise copy as-is. +static void resolve_play_path(char *out, size_t out_len, const char *path) { + if (out == NULL || out_len == 0U) { + return; + } + if (path == NULL || path[0] == '\0') { + out[0] = '\0'; + return; + } + if (path[0] == '/') { + copy_text(out, out_len, path); + } else { + snprintf(out, out_len, "%s/%s", s_media.config.music_dir, path); + } +} + +static void sanitize_filename(char *out, size_t out_len, + const char *hint, const char *default_prefix, + const char *extension) { + if (out == NULL || out_len == 0U) { + return; + } + if (hint == NULL || hint[0] == '\0') { + const uint32_t now_ms = + (uint32_t) (esp_timer_get_time() / 1000LL); + snprintf(out, out_len, "%s_%lu", default_prefix, + (unsigned long) now_ms); + } else { + copy_text(out, out_len, hint); + } + // Replace anything not [A-Za-z0-9_.-] with '_'. + for (size_t i = 0U; out[i] != '\0'; ++i) { + const unsigned char ch = (unsigned char) out[i]; + const bool keep = isalnum(ch) || ch == '_' || ch == '-' || ch == '.'; + if (!keep) { + out[i] = '_'; + } + } + if (extension != NULL && extension[0] != '\0') { + const size_t cur_len = strlen(out); + const size_t ext_len = strlen(extension); + if (cur_len < ext_len || + strcmp(out + cur_len - ext_len, extension) != 0) { + if (cur_len + ext_len < out_len) { + strcat(out, extension); + } + } + } +} + +// Write a minimal RIFF/WAVE header with `data_size` bytes (0 means "open" +// header, will be patched by stop_recording). +static bool write_wav_header(FILE *f, uint32_t data_size) { + if (f == NULL) { + return false; + } + const uint32_t byte_rate = + MEDIA_RECORDER_SAMPLE_RATE * MEDIA_RECORDER_CHANNELS * + (MEDIA_RECORDER_BITS / 8U); + const uint16_t block_align = + (uint16_t) (MEDIA_RECORDER_CHANNELS * (MEDIA_RECORDER_BITS / 8U)); + const uint32_t chunk_size = 36U + data_size; + const uint32_t fmt_size = 16U; + const uint16_t audio_format = 1U; // PCM + const uint16_t channels = MEDIA_RECORDER_CHANNELS; + const uint32_t sample_rate = MEDIA_RECORDER_SAMPLE_RATE; + const uint16_t bits = MEDIA_RECORDER_BITS; + + if (fseek(f, 0, SEEK_SET) != 0) { + return false; + } + if (fwrite("RIFF", 1, 4, f) != 4) return false; + if (fwrite(&chunk_size, sizeof(chunk_size), 1, f) != 1) return false; + if (fwrite("WAVE", 1, 4, f) != 4) return false; + if (fwrite("fmt ", 1, 4, f) != 4) return false; + if (fwrite(&fmt_size, sizeof(fmt_size), 1, f) != 1) return false; + if (fwrite(&audio_format, sizeof(audio_format), 1, f) != 1) return false; + if (fwrite(&channels, sizeof(channels), 1, f) != 1) return false; + if (fwrite(&sample_rate, sizeof(sample_rate), 1, f) != 1) return false; + if (fwrite(&byte_rate, sizeof(byte_rate), 1, f) != 1) return false; + if (fwrite(&block_align, sizeof(block_align), 1, f) != 1) return false; + if (fwrite(&bits, sizeof(bits), 1, f) != 1) return false; + if (fwrite("data", 1, 4, f) != 4) return false; + if (fwrite(&data_size, sizeof(data_size), 1, f) != 1) return false; + return true; +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +void media_manager_default_config(media_manager_config_t *cfg) { + if (cfg == NULL) { + return; + } + memset(cfg, 0, sizeof(*cfg)); + copy_text(cfg->music_dir, sizeof(cfg->music_dir), MEDIA_DEFAULT_MUSIC_DIR); + copy_text(cfg->picture_dir, sizeof(cfg->picture_dir), MEDIA_DEFAULT_PICTURE_DIR); + copy_text(cfg->record_dir, sizeof(cfg->record_dir), MEDIA_DEFAULT_RECORD_DIR); + cfg->record_max_seconds = MEDIA_DEFAULT_RECORD_MAX_S; + cfg->auto_stop_record_on_step_change = true; +} + +esp_err_t media_manager_init(const media_manager_config_t *cfg) { + if (cfg == NULL) { + return ESP_ERR_INVALID_ARG; + } + + // Apply config + normalize dirs. + media_manager_config_t normalized = *cfg; + char tmp[MEDIA_DIR_MAX]; + normalize_dir(tmp, sizeof(tmp), cfg->music_dir); + copy_text(normalized.music_dir, sizeof(normalized.music_dir), tmp); + normalize_dir(tmp, sizeof(tmp), cfg->picture_dir); + copy_text(normalized.picture_dir, sizeof(normalized.picture_dir), tmp); + normalize_dir(tmp, sizeof(tmp), cfg->record_dir); + copy_text(normalized.record_dir, sizeof(normalized.record_dir), tmp); + if (normalized.record_max_seconds == 0U) { + normalized.record_max_seconds = MEDIA_DEFAULT_RECORD_MAX_S; + } + if (normalized.record_max_seconds > 1800U) { + normalized.record_max_seconds = 1800U; + } + + s_media.config = normalized; + if (!s_media.initialized) { + s_media.volume = 80U; + s_media.playback_ends_ms = 0U; + s_media.recording_file = NULL; + s_media.recording_data_bytes = 0U; + } + + memset(&s_media.snapshot, 0, sizeof(s_media.snapshot)); + s_media.snapshot.ready = true; + s_media.snapshot.last_ok = true; + s_media.snapshot.record_simulated = true; // recorder is stubbed + s_media.snapshot.record_limit_seconds = normalized.record_max_seconds; + copy_text(s_media.snapshot.music_dir, sizeof(s_media.snapshot.music_dir), + normalized.music_dir); + copy_text(s_media.snapshot.picture_dir, sizeof(s_media.snapshot.picture_dir), + normalized.picture_dir); + copy_text(s_media.snapshot.record_dir, sizeof(s_media.snapshot.record_dir), + normalized.record_dir); + + (void) ensure_dir(normalized.music_dir); + (void) ensure_dir(normalized.picture_dir); + (void) ensure_dir(normalized.record_dir); + + s_media.initialized = true; + ESP_LOGI(TAG, "init music=%s picture=%s record=%s rec_max=%us", + normalized.music_dir, normalized.picture_dir, + normalized.record_dir, normalized.record_max_seconds); + ESP_LOGW(TAG, + "playback + capture are STUBBED — see TODO in media_manager.c"); + return ESP_OK; +} + +void media_manager_update(uint32_t now_ms) { + if (!s_media.initialized) { + return; + } + // Simulated playback: clear `playing` after the stub window elapses. + if (s_media.snapshot.playing && + s_media.playback_ends_ms != 0U && + (int32_t) (now_ms - s_media.playback_ends_ms) >= 0) { + ESP_LOGI(TAG, "simulated playback finished: %s", + s_media.snapshot.playing_path); + s_media.snapshot.playing = false; + s_media.snapshot.playing_path[0] = '\0'; + s_media.playback_ends_ms = 0U; + } + // Recorder timeout (stub: data bytes never grow, but the timer mirrors + // Arduino behaviour so callers can rely on auto-stop). + if (s_media.snapshot.recording) { + const uint32_t elapsed_ms = now_ms - s_media.snapshot.record_started_ms; + s_media.snapshot.record_elapsed_seconds = + (uint16_t) (elapsed_ms / 1000U); + if (s_media.snapshot.record_limit_seconds > 0U && + s_media.snapshot.record_elapsed_seconds >= + s_media.snapshot.record_limit_seconds) { + (void) media_manager_stop_recording(); + } + } +} + +void media_manager_note_step_change(void) { + if (!s_media.initialized) { + return; + } + if (s_media.config.auto_stop_record_on_step_change && + s_media.snapshot.recording) { + (void) media_manager_stop_recording(); + } +} + +esp_err_t media_manager_play(const char *path) { + if (!s_media.initialized) { + return ESP_ERR_INVALID_STATE; + } + if (path == NULL || path[0] == '\0') { + set_last_error("media_play_invalid_args"); + return ESP_ERR_INVALID_ARG; + } + + char resolved[MEDIA_PATH_MAX]; + resolve_play_path(resolved, sizeof(resolved), path); + if (resolved[0] == '\0') { + set_last_error("media_play_empty_path"); + return ESP_ERR_INVALID_ARG; + } + + if (!file_exists(resolved)) { + ESP_LOGW(TAG, "play(%s): file not found", resolved); + set_last_error("media_play_not_found"); + return ESP_ERR_NOT_FOUND; + } + + // TODO(slice-4+): hand off to the real I2S MP3 decoder here. + const uint32_t now_ms = (uint32_t) (esp_timer_get_time() / 1000LL); + s_media.snapshot.playing = true; + copy_text(s_media.snapshot.playing_path, + sizeof(s_media.snapshot.playing_path), resolved); + s_media.playback_ends_ms = now_ms + MEDIA_STUB_PLAYBACK_MS; + clear_last_error(); + ESP_LOGI(TAG, "playing %s (simulated %ums) vol=%u", + resolved, MEDIA_STUB_PLAYBACK_MS, s_media.volume); + return ESP_OK; +} + +esp_err_t media_manager_stop(void) { + if (!s_media.initialized) { + return ESP_ERR_INVALID_STATE; + } + if (s_media.snapshot.playing) { + ESP_LOGI(TAG, "stop %s", s_media.snapshot.playing_path); + } + s_media.snapshot.playing = false; + s_media.snapshot.playing_path[0] = '\0'; + s_media.playback_ends_ms = 0U; + clear_last_error(); + return ESP_OK; +} + +esp_err_t media_manager_set_volume(uint8_t volume) { + if (volume > 100U) { + return ESP_ERR_INVALID_ARG; + } + s_media.volume = volume; + ESP_LOGI(TAG, "volume=%u", volume); + return ESP_OK; +} + +esp_err_t media_manager_start_recording(uint16_t seconds, + const char *filename_hint) { + if (!s_media.initialized) { + return ESP_ERR_INVALID_STATE; + } + if (s_media.snapshot.recording) { + set_last_error("recorder_already_running"); + return ESP_ERR_INVALID_STATE; + } + if (seconds == 0U) { + seconds = s_media.config.record_max_seconds; + } + if (seconds > s_media.config.record_max_seconds) { + seconds = s_media.config.record_max_seconds; + } + if (seconds == 0U) { + seconds = 1U; + } + if (ensure_dir(s_media.config.record_dir) != ESP_OK) { + set_last_error("recorder_dir_missing"); + return ESP_FAIL; + } + + // Filename bounded so the concatenation below cannot overflow `path` + // (record_dir <= MEDIA_DIR_MAX, plus '/' separator, plus filename). + char filename[MEDIA_PATH_MAX - MEDIA_DIR_MAX - 1]; + sanitize_filename(filename, sizeof(filename), + filename_hint, "record", ".wav"); + char path[MEDIA_PATH_MAX]; + snprintf(path, sizeof(path), "%s/%s", + s_media.config.record_dir, filename); + + if (s_media.recording_file != NULL) { + fclose(s_media.recording_file); + s_media.recording_file = NULL; + } + s_media.recording_file = fopen(path, "wb+"); + if (s_media.recording_file == NULL) { + ESP_LOGW(TAG, "fopen(%s) failed: errno=%d", path, errno); + set_last_error("recorder_create_failed"); + return ESP_FAIL; + } + if (!write_wav_header(s_media.recording_file, 0U)) { + fclose(s_media.recording_file); + s_media.recording_file = NULL; + set_last_error("recorder_header_failed"); + return ESP_FAIL; + } + s_media.recording_data_bytes = 0U; + + const uint32_t now_ms = (uint32_t) (esp_timer_get_time() / 1000LL); + s_media.snapshot.recording = true; + s_media.snapshot.record_limit_seconds = seconds; + s_media.snapshot.record_started_ms = now_ms; + s_media.snapshot.record_elapsed_seconds = 0U; + copy_text(s_media.snapshot.record_file, + sizeof(s_media.snapshot.record_file), path); + clear_last_error(); + ESP_LOGI(TAG, "recording started -> %s (limit=%us, simulated PCM)", + path, seconds); + return ESP_OK; +} + +esp_err_t media_manager_stop_recording(void) { + if (!s_media.initialized) { + return ESP_ERR_INVALID_STATE; + } + if (!s_media.snapshot.recording) { + return ESP_OK; + } + if (s_media.recording_file != NULL) { + // Patch header with the real (zero, in stub mode) data length. + (void) write_wav_header(s_media.recording_file, + s_media.recording_data_bytes); + fclose(s_media.recording_file); + s_media.recording_file = NULL; + } + const uint32_t now_ms = (uint32_t) (esp_timer_get_time() / 1000LL); + const uint32_t elapsed_ms = now_ms - s_media.snapshot.record_started_ms; + s_media.snapshot.record_elapsed_seconds = (uint16_t) (elapsed_ms / 1000U); + s_media.snapshot.recording = false; + clear_last_error(); + ESP_LOGI(TAG, "recording stopped (%us elapsed, file=%s)", + s_media.snapshot.record_elapsed_seconds, + s_media.snapshot.record_file); + return ESP_OK; +} + +void media_manager_snapshot(media_manager_snapshot_t *out) { + if (out == NULL) { + return; + } + *out = s_media.snapshot; +} diff --git a/idf_zacus/components/npc_engine/CMakeLists.txt b/idf_zacus/components/npc_engine/CMakeLists.txt new file mode 100644 index 0000000..a0f5ccb --- /dev/null +++ b/idf_zacus/components/npc_engine/CMakeLists.txt @@ -0,0 +1,30 @@ +## Zacus npc_engine — ESP-IDF port (slice 4, P1). +## +## Ports the Arduino `npc_engine` decision module +## (ui_freenove_allinone/src/npc/npc_engine.cpp, 198 LOC) into an IDF +## component. The Arduino sources were already pure C with `extern "C"` +## guards and zero Arduino-runtime calls, so the port reuses the same +## state machine verbatim and only adapts the wrapper layer (init/update/ +## trigger entry points) to the IDF idioms (esp_err_t returns, esp_log, +## media_manager integration). The hint-request HTTP path remains a stub +## (callback invoked synchronously with a hardcoded text) until the +## hints-engine HTTP client lands in slice 6. +## +## Persistence of "cues already played" lives in RAM only at this slice; +## an NVS-backed log can be added once a real trigger source feeds the +## engine. + +idf_component_register( + SRCS + "npc_engine.c" + INCLUDE_DIRS + "include" + REQUIRES + media_manager + hints_client + esp_timer + esp_system + nvs_flash + freertos + log +) diff --git a/idf_zacus/components/npc_engine/include/npc_engine.h b/idf_zacus/components/npc_engine/include/npc_engine.h new file mode 100644 index 0000000..e1b2c77 --- /dev/null +++ b/idf_zacus/components/npc_engine/include/npc_engine.h @@ -0,0 +1,203 @@ +// Zacus npc_engine — ESP-IDF C port of the Arduino NPC decision engine. +// +// Source of truth for the Arduino implementation: +// ESP32_ZACUS/ui_freenove_allinone/src/npc/npc_engine.cpp +// ESP32_ZACUS/ui_freenove_allinone/include/npc/npc_engine.h +// +// The IDF port keeps the Arduino "core" state-machine API verbatim +// (npc_init / npc_evaluate / npc_on_*) because that code was already +// pure-C, side-effect free and free of Arduino-runtime dependencies. +// +// On top of that core the port adds an IDF-idiomatic "engine" wrapper +// layer with: +// * npc_engine_init(config) — boot the singleton, log readiness +// * npc_engine_update(now_ms) — periodic tick (mood + auto-evaluate) +// * npc_engine_trigger_cue(cue_id) — best-effort cue dispatch through +// media_manager_play() +// * npc_engine_set_step(step_id) — bridge to the scenario engine +// * npc_engine_request_hint(...) — async hint request (stubbed locally +// until the hints-engine HTTP client +// lands in a later slice) +// +// All wrapper entry points return `esp_err_t`. Callbacks are plain C +// function pointers — no C++ classes, no lambdas, RTOS-friendly. + +#pragma once + +#include +#include +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ── Core (ported verbatim from the Arduino sources) ───────────────────────── + +#define NPC_MAX_SCENES 12 +#define NPC_MAX_HINT_LEVEL 3 +#define NPC_PHRASE_MAX_LEN 200 +#define NPC_STUCK_TIMEOUT_MS (3UL * 60UL * 1000UL) +#define NPC_FAST_THRESHOLD_PCT 50 +#define NPC_SLOW_THRESHOLD_PCT 150 +#define NPC_QR_DEBOUNCE_MS 30000 + +typedef enum { + NPC_MOOD_NEUTRAL = 0, + NPC_MOOD_IMPRESSED, + NPC_MOOD_WORRIED, + NPC_MOOD_AMUSED, + NPC_MOOD_COUNT +} npc_mood_t; + +typedef enum { + NPC_TRIGGER_NONE = 0, + NPC_TRIGGER_HINT_REQUEST, + NPC_TRIGGER_STUCK_TIMER, + NPC_TRIGGER_QR_SCANNED, + NPC_TRIGGER_WRONG_ACTION, + NPC_TRIGGER_FAST_PROGRESS, + NPC_TRIGGER_SLOW_PROGRESS, + NPC_TRIGGER_SCENE_TRANSITION, + NPC_TRIGGER_GAME_START, + NPC_TRIGGER_GAME_END, + NPC_TRIGGER_COUNT +} npc_trigger_t; + +typedef enum { + NPC_AUDIO_NONE = 0, + NPC_AUDIO_LIVE_TTS, + NPC_AUDIO_SD_CONTEXTUAL, + NPC_AUDIO_SD_GENERIC +} npc_audio_source_t; + +typedef struct { + uint8_t current_scene; + uint8_t current_step; + uint32_t scene_start_ms; + uint32_t total_elapsed_ms; + uint8_t hints_given[NPC_MAX_SCENES]; + uint8_t qr_scanned_count; + uint8_t failed_attempts; + bool phone_off_hook; + bool tower_reachable; + npc_mood_t mood; + uint32_t last_qr_scan_ms; + uint32_t expected_scene_duration_ms; +} npc_state_t; + +typedef struct { + npc_trigger_t trigger; + npc_audio_source_t audio_source; + char phrase_text[NPC_PHRASE_MAX_LEN]; + char sd_path[128]; + npc_mood_t resulting_mood; +} npc_decision_t; + +void npc_init(npc_state_t *state); +void npc_reset(npc_state_t *state); +bool npc_evaluate(const npc_state_t *state, uint32_t now_ms, + npc_decision_t *out); +void npc_on_scene_change(npc_state_t *state, uint8_t new_scene, + uint32_t expected_duration_ms, uint32_t now_ms); +void npc_on_qr_scan(npc_state_t *state, bool valid, uint32_t now_ms); +void npc_on_phone_hook(npc_state_t *state, bool off_hook); +void npc_on_hint_request(npc_state_t *state, uint32_t now_ms); +void npc_on_tower_status(npc_state_t *state, bool reachable); +void npc_update_mood(npc_state_t *state, uint32_t now_ms); +uint8_t npc_hint_level(const npc_state_t *state, uint8_t scene); +bool npc_build_sd_path(char *out_path, size_t capacity, + uint8_t scene, npc_trigger_t trigger, + npc_mood_t mood, uint8_t variant); + +// ── Engine wrapper (IDF idioms) ───────────────────────────────────────────── + +#define NPC_ENGINE_MAX_CUES 32 +#define NPC_ENGINE_CUE_PATH_MAX 128 +#define NPC_ENGINE_CUE_ID_MAX 32 + +// Static cue table entry. Authored cues live in flash; runtime state +// (already-played flag, cooldown) is tracked separately in RAM. +typedef struct { + char id[NPC_ENGINE_CUE_ID_MAX]; + char audio_path[NPC_ENGINE_CUE_PATH_MAX]; + uint8_t scene; // associated scene index (0xFF = global cue) + npc_mood_t mood; +} npc_cue_t; + +// Configuration for the wrapper. `cues` may be NULL/0 — the engine still +// boots and accepts triggers (each `trigger_cue` call simply tries +// media_manager_play() with the supplied cue identifier as a path). +typedef struct { + const npc_cue_t *cues; + size_t cue_count; + bool auto_evaluate; // run npc_evaluate() each tick + bool auto_play_decisions; // dispatch decisions through media_manager_play +} npc_engine_config_t; + +// Hint request callback. Invoked when the hints-engine produced a result +// (today: synchronously with a hardcoded stub text). `text` is owned by +// the engine and only valid for the duration of the call — copy if needed. +typedef void (*npc_hint_callback_t)(uint8_t puzzle_id, uint8_t level, + esp_err_t status, const char *text, + void *user_ctx); + +// Initialize the engine singleton. `config` may be NULL — defaults are +// applied (no cue table, auto_evaluate=false, auto_play_decisions=false). +esp_err_t npc_engine_init(const npc_engine_config_t *config); + +// Periodic tick. `now_ms` is the same monotonic millisecond counter as +// `media_manager_update`. Updates mood, optionally runs npc_evaluate and +// dispatches the resulting cue when `auto_*` flags are enabled. +esp_err_t npc_engine_update(uint32_t now_ms); + +// Manually trigger a cue by id. Looks up the cue table and dispatches the +// associated audio path through media_manager_play(). When the cue is not +// in the table the engine treats `cue_id` itself as the path and forwards +// it as-is — useful for ad-hoc tests from the REST surface. +// +// Returns: +// ESP_OK cue dispatched (media_manager_play returned OK) +// ESP_ERR_NOT_FOUND cue id not in table AND raw path not playable +// ESP_ERR_INVALID_* propagated from media_manager +esp_err_t npc_engine_trigger_cue(const char *cue_id); + +// Bridge from the scenario runtime — informs the engine of the active +// step (and implicitly the active scene). Resets failed-attempts counter +// and primes the stuck timer. +esp_err_t npc_engine_set_step(uint8_t step_id, uint32_t expected_duration_ms); + +// Request a hint for `puzzle_id` at escalation `level` (0..3). The engine +// invokes `cb` with the resulting text. The current implementation is a +// LOCAL STUB: it returns a hardcoded French placeholder synchronously. +// TODO(slice-6): replace with HTTP POST to /hints/ask on the hints engine. +esp_err_t npc_engine_request_hint(uint8_t puzzle_id, uint8_t level, + npc_hint_callback_t cb, void *user_ctx); + +// Read-only access to the underlying core state — handy for diagnostics. +const npc_state_t *npc_engine_state(void); + +// Slice 11 (P5): forward the global hints group profile to hints_client. +// `profile` must be one of "TECH", "NON_TECH", "MIXED", "BOTH". +// Thin wrapper kept here to give callers a single npc_engine_* surface. +esp_err_t npc_engine_set_group_profile(const char *profile); + +// Write the active puzzle id (e.g. "SCENE_LA_DETECTOR") into `out`, +// truncated to `cap` bytes (NUL-terminated). Falls back to "SCENE_NPC" +// when no scene is active or the engine is not initialised so callers +// always have a non-empty id to send to the hints engine. Returns the +// number of bytes written excluding the trailing NUL. +size_t npc_engine_current_puzzle_id(char *out, size_t cap); + +// Slice 11 (P5): notify the hints engine that the operator just made an +// invalid attempt on `scene`. Resolves the scene to the same string id +// returned by npc_engine_current_puzzle_id() and forwards through +// hints_client_attempt_failed(). Best-effort: returns ESP_OK even if the +// hints engine is unreachable, the failure is logged. +esp_err_t npc_engine_report_failed_attempt(uint8_t scene); + +#ifdef __cplusplus +} +#endif diff --git a/idf_zacus/components/npc_engine/npc_engine.c b/idf_zacus/components/npc_engine/npc_engine.c new file mode 100644 index 0000000..9829689 --- /dev/null +++ b/idf_zacus/components/npc_engine/npc_engine.c @@ -0,0 +1,464 @@ +// Zacus npc_engine — ESP-IDF C port (slice 4, P1). +// +// Portage strategy: the Arduino sources were already pure C (no classes, +// no Arduino runtime calls), so the "core" half of this file is a verbatim +// copy of ui_freenove_allinone/src/npc/npc_engine.cpp with `cstring`/`cstdio` +// replaced by their C headers. The "engine wrapper" half is new and follows +// the same pattern as the media_manager component: +// +// * single static singleton, idempotent init +// * esp_err_t returns + ESP_LOG instrumentation +// * media_manager_play() integration for cue dispatch +// +// Hint requests are stubbed locally (synchronous callback with a hardcoded +// French placeholder). The real HTTP call to the hints engine lands in a +// later slice (see TODO below). + +#include "npc_engine.h" + +#include +#include + +#include "esp_err.h" +#include "esp_log.h" + +#include "media_manager.h" +#include "hints_client.h" + +static const char *TAG = "npc_engine"; + +// ─── Core: scene/trigger/mood lookup tables (verbatim Arduino) ────────────── + +static const char *const kSceneIds[] = { + "SCENE_U_SON_PROTO", + "SCENE_LA_DETECTOR", + "SCENE_WIN_ETAPE1", + "SCENE_WARNING", + "SCENE_LEFOU_DETECTOR", + "SCENE_WIN_ETAPE2", + "SCENE_QR_DETECTOR", + "SCENE_FINAL_WIN", +}; +static const uint8_t kSceneCount = sizeof(kSceneIds) / sizeof(kSceneIds[0]); + +static const char *const kTriggerDirs[] = { + [NPC_TRIGGER_NONE] = "generic", + [NPC_TRIGGER_HINT_REQUEST] = "indice", + [NPC_TRIGGER_STUCK_TIMER] = "indice", + [NPC_TRIGGER_QR_SCANNED] = "felicitations", + [NPC_TRIGGER_WRONG_ACTION] = "attention", + [NPC_TRIGGER_FAST_PROGRESS] = "fausse_piste", + [NPC_TRIGGER_SLOW_PROGRESS] = "adaptation", + [NPC_TRIGGER_SCENE_TRANSITION] = "transition", + [NPC_TRIGGER_GAME_START] = "ambiance", + [NPC_TRIGGER_GAME_END] = "ambiance", +}; + +static const char *const kMoodSuffixes[] = { + [NPC_MOOD_NEUTRAL] = "neutral", + [NPC_MOOD_IMPRESSED] = "impressed", + [NPC_MOOD_WORRIED] = "worried", + [NPC_MOOD_AMUSED] = "amused", +}; + +// ─── Core: state-machine API (verbatim Arduino, NULL guards preserved) ────── + +void npc_init(npc_state_t *state) { + if (state == NULL) return; + memset(state, 0, sizeof(*state)); + state->mood = NPC_MOOD_NEUTRAL; +} + +void npc_reset(npc_state_t *state) { + npc_init(state); +} + +void npc_on_scene_change(npc_state_t *state, uint8_t new_scene, + uint32_t expected_duration_ms, uint32_t now_ms) { + if (state == NULL) return; + state->current_scene = new_scene; + state->scene_start_ms = now_ms; + state->expected_scene_duration_ms = expected_duration_ms; + state->failed_attempts = 0; +} + +void npc_on_qr_scan(npc_state_t *state, bool valid, uint32_t now_ms) { + if (state == NULL) return; + if (valid) { + state->qr_scanned_count++; + } else { + state->failed_attempts++; + } + state->last_qr_scan_ms = now_ms; +} + +void npc_on_phone_hook(npc_state_t *state, bool off_hook) { + if (state == NULL) return; + state->phone_off_hook = off_hook; +} + +void npc_on_hint_request(npc_state_t *state, uint32_t now_ms) { + if (state == NULL) return; + uint8_t scene = state->current_scene; + if (scene < NPC_MAX_SCENES && state->hints_given[scene] < NPC_MAX_HINT_LEVEL) { + state->hints_given[scene]++; + } + (void) now_ms; +} + +void npc_on_tower_status(npc_state_t *state, bool reachable) { + if (state == NULL) return; + state->tower_reachable = reachable; +} + +void npc_update_mood(npc_state_t *state, uint32_t now_ms) { + if (state == NULL || state->expected_scene_duration_ms == 0) return; + uint32_t elapsed = now_ms - state->scene_start_ms; + uint32_t expected = state->expected_scene_duration_ms; + uint32_t pct = (elapsed * 100U) / expected; + + if (state->failed_attempts >= 3) { + state->mood = NPC_MOOD_AMUSED; + } else if (pct < NPC_FAST_THRESHOLD_PCT) { + state->mood = NPC_MOOD_IMPRESSED; + } else if (pct > NPC_SLOW_THRESHOLD_PCT) { + state->mood = NPC_MOOD_WORRIED; + } else { + state->mood = NPC_MOOD_NEUTRAL; + } +} + +uint8_t npc_hint_level(const npc_state_t *state, uint8_t scene) { + if (state == NULL || scene >= NPC_MAX_SCENES) return 0; + return state->hints_given[scene]; +} + +bool npc_build_sd_path(char *out_path, size_t capacity, + uint8_t scene, npc_trigger_t trigger, + npc_mood_t mood, uint8_t variant) { + if (out_path == NULL || capacity < 16) return false; + + const char *scene_id = (scene < kSceneCount) ? kSceneIds[scene] : "npc"; + const char *trigger_dir = (trigger < NPC_TRIGGER_COUNT) + ? kTriggerDirs[trigger] : "generic"; + const char *mood_str = (mood < NPC_MOOD_COUNT) + ? kMoodSuffixes[mood] : "neutral"; + + bool is_scene_specific = (trigger != NPC_TRIGGER_GAME_START + && trigger != NPC_TRIGGER_GAME_END + && trigger != NPC_TRIGGER_NONE); + + int written; + if (is_scene_specific && scene < kSceneCount) { + written = snprintf(out_path, capacity, + "/hotline_tts/%s/%s_%s_%u.mp3", + scene_id, trigger_dir, mood_str, (unsigned) variant); + } else { + written = snprintf(out_path, capacity, + "/hotline_tts/npc/%s_%s_%u.mp3", + trigger_dir, mood_str, (unsigned) variant); + } + return (written > 0 && (size_t) written < capacity); +} + +bool npc_evaluate(const npc_state_t *state, uint32_t now_ms, + npc_decision_t *out) { + if (state == NULL || out == NULL) return false; + memset(out, 0, sizeof(*out)); + + uint32_t scene_elapsed = now_ms - state->scene_start_ms; + uint32_t expected = state->expected_scene_duration_ms; + + // Priority 1: Hint request (phone off hook while stuck) + if (state->phone_off_hook && scene_elapsed > NPC_STUCK_TIMEOUT_MS) { + uint8_t level = npc_hint_level(state, state->current_scene); + out->trigger = NPC_TRIGGER_HINT_REQUEST; + out->resulting_mood = state->mood; + npc_build_sd_path(out->sd_path, sizeof(out->sd_path), + state->current_scene, NPC_TRIGGER_HINT_REQUEST, + state->mood, level); + out->audio_source = state->tower_reachable + ? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL; + return true; + } + + // Priority 2: Stuck timer (proactive, no phone needed) + if (scene_elapsed > NPC_STUCK_TIMEOUT_MS + && npc_hint_level(state, state->current_scene) == 0) { + out->trigger = NPC_TRIGGER_STUCK_TIMER; + out->resulting_mood = NPC_MOOD_WORRIED; + npc_build_sd_path(out->sd_path, sizeof(out->sd_path), + state->current_scene, NPC_TRIGGER_STUCK_TIMER, + NPC_MOOD_WORRIED, 0); + out->audio_source = state->tower_reachable + ? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL; + return true; + } + + // Priority 3: Fast progress detection + if (expected > 0 && scene_elapsed > 0 + && (scene_elapsed * 100U / expected) < NPC_FAST_THRESHOLD_PCT) { + out->trigger = NPC_TRIGGER_FAST_PROGRESS; + out->resulting_mood = NPC_MOOD_IMPRESSED; + npc_build_sd_path(out->sd_path, sizeof(out->sd_path), + state->current_scene, NPC_TRIGGER_FAST_PROGRESS, + NPC_MOOD_IMPRESSED, 0); + out->audio_source = state->tower_reachable + ? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL; + return true; + } + + // Priority 4: Slow progress detection + if (expected > 0 && (scene_elapsed * 100U / expected) > NPC_SLOW_THRESHOLD_PCT) { + out->trigger = NPC_TRIGGER_SLOW_PROGRESS; + out->resulting_mood = NPC_MOOD_WORRIED; + npc_build_sd_path(out->sd_path, sizeof(out->sd_path), + state->current_scene, NPC_TRIGGER_SLOW_PROGRESS, + NPC_MOOD_WORRIED, 0); + out->audio_source = state->tower_reachable + ? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL; + return true; + } + + return false; +} + +// ─── Wrapper singleton (slice-4 IDF surface) ──────────────────────────────── + +typedef struct { + bool ready; + npc_state_t core; + const npc_cue_t *cues; + size_t cue_count; + bool played[NPC_ENGINE_MAX_CUES]; // already-played log + bool auto_evaluate; + bool auto_play_decisions; +} engine_t; + +static engine_t s_engine; + +static const npc_cue_t *find_cue(const char *cue_id) { + if (cue_id == NULL || s_engine.cues == NULL) return NULL; + for (size_t i = 0; i < s_engine.cue_count && i < NPC_ENGINE_MAX_CUES; ++i) { + if (strncmp(s_engine.cues[i].id, cue_id, + NPC_ENGINE_CUE_ID_MAX) == 0) { + return &s_engine.cues[i]; + } + } + return NULL; +} + +static size_t cue_index(const npc_cue_t *cue) { + if (cue == NULL || s_engine.cues == NULL) return SIZE_MAX; + return (size_t) (cue - s_engine.cues); +} + +esp_err_t npc_engine_init(const npc_engine_config_t *config) { + memset(&s_engine, 0, sizeof(s_engine)); + npc_init(&s_engine.core); + + if (config != NULL) { + s_engine.cues = config->cues; + s_engine.cue_count = config->cue_count; + s_engine.auto_evaluate = config->auto_evaluate; + s_engine.auto_play_decisions = config->auto_play_decisions; + + if (s_engine.cue_count > NPC_ENGINE_MAX_CUES) { + ESP_LOGW(TAG, "cue table truncated: %u > NPC_ENGINE_MAX_CUES (%u)", + (unsigned) s_engine.cue_count, + (unsigned) NPC_ENGINE_MAX_CUES); + s_engine.cue_count = NPC_ENGINE_MAX_CUES; + } + } + + s_engine.ready = true; + ESP_LOGI(TAG, "npc_engine ready, %u cues registered " + "(auto_evaluate=%d, auto_play=%d)", + (unsigned) s_engine.cue_count, + (int) s_engine.auto_evaluate, + (int) s_engine.auto_play_decisions); + return ESP_OK; +} + +esp_err_t npc_engine_update(uint32_t now_ms) { + if (!s_engine.ready) return ESP_ERR_INVALID_STATE; + + s_engine.core.total_elapsed_ms = now_ms; + npc_update_mood(&s_engine.core, now_ms); + + if (!s_engine.auto_evaluate) return ESP_OK; + + npc_decision_t decision; + if (!npc_evaluate(&s_engine.core, now_ms, &decision)) return ESP_OK; + + ESP_LOGI(TAG, "decision: trigger=%d mood=%d audio=%d path=\"%s\"", + (int) decision.trigger, + (int) decision.resulting_mood, + (int) decision.audio_source, + decision.sd_path); + + if (s_engine.auto_play_decisions && decision.sd_path[0] != '\0') { + esp_err_t err = media_manager_play(decision.sd_path); + if (err != ESP_OK) { + ESP_LOGW(TAG, "auto-play \"%s\" failed: %s", + decision.sd_path, esp_err_to_name(err)); + } + } + return ESP_OK; +} + +esp_err_t npc_engine_trigger_cue(const char *cue_id) { + if (!s_engine.ready) return ESP_ERR_INVALID_STATE; + if (cue_id == NULL || cue_id[0] == '\0') return ESP_ERR_INVALID_ARG; + + const npc_cue_t *cue = find_cue(cue_id); + const char *path = NULL; + size_t idx = SIZE_MAX; + + if (cue != NULL) { + path = cue->audio_path; + idx = cue_index(cue); + ESP_LOGI(TAG, "trigger_cue id=\"%s\" -> path=\"%s\" (scene=%u, mood=%d)", + cue_id, path, (unsigned) cue->scene, (int) cue->mood); + } else { + // Fallback: treat the id itself as a path. Useful for ad-hoc REST tests. + path = cue_id; + ESP_LOGI(TAG, "trigger_cue id=\"%s\" not in table — playing raw path", + cue_id); + } + + esp_err_t err = media_manager_play(path); + if (err == ESP_OK && idx < NPC_ENGINE_MAX_CUES) { + s_engine.played[idx] = true; + } + return err; +} + +// Resolve a scene index to the canonical puzzle id used by the hints +// engine (matches game/scenarios/npc_phrases.yaml). Returns the count +// of bytes written (excluding NUL). Always writes at least the fallback +// "SCENE_NPC" so callers can rely on a non-empty string. +static size_t scene_to_puzzle_id(uint8_t scene, char *out, size_t cap) { + if (out == NULL || cap == 0) return 0; + const char *id = (scene < kSceneCount) ? kSceneIds[scene] : "SCENE_NPC"; + int written = snprintf(out, cap, "%s", id); + if (written < 0) { + out[0] = '\0'; + return 0; + } + return ((size_t) written < cap) ? (size_t) written : (cap - 1); +} + +esp_err_t npc_engine_set_step(uint8_t step_id, uint32_t expected_duration_ms) { + if (!s_engine.ready) return ESP_ERR_INVALID_STATE; + + const uint8_t prev_scene = s_engine.core.current_scene; + s_engine.core.current_step = step_id; + npc_on_scene_change(&s_engine.core, step_id, expected_duration_ms, + s_engine.core.total_elapsed_ms); + ESP_LOGI(TAG, "step set to %u (expected_duration=%u ms)", + (unsigned) step_id, (unsigned) expected_duration_ms); + + // Slice 11 (P5): notify the hints engine that the operator just + // entered a new pivot. Idempotent on the server, so we still post + // even if step_id hasn't moved (defensive: the scenario may rebind + // the same step after a recovery). hints_client logs the outcome + // internally — we don't propagate the failure (best-effort). + if (hints_client_is_ready()) { + char puzzle_id[NPC_ENGINE_CUE_ID_MAX]; + scene_to_puzzle_id(step_id, puzzle_id, sizeof(puzzle_id)); + if (step_id != prev_scene) { + ESP_LOGI(TAG, "scene changed %u → %u, signalling /puzzle_start", + (unsigned) prev_scene, (unsigned) step_id); + } + (void) hints_client_puzzle_start(puzzle_id); + } + return ESP_OK; +} + +esp_err_t npc_engine_set_group_profile(const char *profile) { + // Thin pass-through. hints_client validates the value and logs the + // outcome. Kept on npc_engine so the rest of the firmware doesn't + // need to depend directly on hints_client just for this knob. + if (!hints_client_is_ready()) { + ESP_LOGW(TAG, "set_group_profile(\"%s\") before hints_client_init", + profile ? profile : "(null)"); + return ESP_ERR_INVALID_STATE; + } + return hints_client_set_group_profile(profile); +} + +size_t npc_engine_current_puzzle_id(char *out, size_t cap) { + if (out == NULL || cap == 0) return 0; + uint8_t scene = s_engine.ready ? s_engine.core.current_scene : 0xFF; + return scene_to_puzzle_id(scene, out, cap); +} + +esp_err_t npc_engine_report_failed_attempt(uint8_t scene) { + if (!s_engine.ready) return ESP_ERR_INVALID_STATE; + if (!hints_client_is_ready()) { + // Track locally only — keeps failed_attempts coherent so the + // stuck timer / mood updater still react. + s_engine.core.failed_attempts++; + ESP_LOGD(TAG, "failed_attempt scene=%u (hints offline, local only)", + (unsigned) scene); + return ESP_OK; + } + char puzzle_id[NPC_ENGINE_CUE_ID_MAX]; + scene_to_puzzle_id(scene, puzzle_id, sizeof(puzzle_id)); + s_engine.core.failed_attempts++; + (void) hints_client_attempt_failed(puzzle_id); + return ESP_OK; +} + +esp_err_t npc_engine_request_hint(uint8_t puzzle_id, uint8_t level, + npc_hint_callback_t cb, void *user_ctx) { + if (!s_engine.ready) return ESP_ERR_INVALID_STATE; + if (cb == NULL) return ESP_ERR_INVALID_ARG; + + const uint8_t clamped = (level > NPC_MAX_HINT_LEVEL) + ? NPC_MAX_HINT_LEVEL : level; + + npc_on_hint_request(&s_engine.core, s_engine.core.total_elapsed_ms); + + // Slice 5: when the hints_client component has been initialised, route + // the request through the real HTTP backend asynchronously. Otherwise + // fall back to a hardcoded French placeholder so the surrounding NPC + // orchestration can still be exercised end-to-end (CI smoke, dry runs). + if (hints_client_is_ready()) { + // Slice 11 (P5): map the numeric puzzle hint id to the same + // SCENE_* string id used by /hints/puzzle_start. When the + // dispatcher passes id=0 (placeholder), fall back to the active + // scene so the hints engine can still pick a contextual answer. + char puzzle_str[NPC_ENGINE_CUE_ID_MAX]; + const uint8_t scene_for_id = (puzzle_id == 0) + ? s_engine.core.current_scene : puzzle_id; + scene_to_puzzle_id(scene_for_id, puzzle_str, sizeof(puzzle_str)); + esp_err_t err = hints_client_ask_async(puzzle_str, puzzle_id, clamped, + (hints_client_callback_t) cb, + user_ctx, 0, 0); + if (err == ESP_OK) { + ESP_LOGI(TAG, "hint request puzzle=%u level=%u -> hints_client async", + (unsigned) puzzle_id, (unsigned) clamped); + return ESP_OK; + } + ESP_LOGW(TAG, "hints_client_ask_async failed (%s) — using stub", + esp_err_to_name(err)); + } + + static const char *const kStubHints[NPC_MAX_HINT_LEVEL + 1] = { + "Regarde autour de toi, la solution est plus proche que tu ne crois.", + "As-tu pensé à observer chaque indice plus attentivement ?", + "Concentre-toi sur l'objet le plus inhabituel de la pièce.", + "Le code se trouve dans la séquence des couleurs, dans l'ordre.", + }; + const char *text = kStubHints[clamped]; + ESP_LOGI(TAG, "hint request puzzle=%u level=%u -> stub \"%s\"", + (unsigned) puzzle_id, (unsigned) level, text); + cb(puzzle_id, clamped, ESP_OK, text, user_ctx); + return ESP_OK; +} + +const npc_state_t *npc_engine_state(void) { + return s_engine.ready ? &s_engine.core : NULL; +} diff --git a/idf_zacus/components/ota_server/CMakeLists.txt b/idf_zacus/components/ota_server/CMakeLists.txt new file mode 100644 index 0000000..a30692b --- /dev/null +++ b/idf_zacus/components/ota_server/CMakeLists.txt @@ -0,0 +1,29 @@ +idf_component_register( + SRCS + "ota_server.c" + INCLUDE_DIRS + "include" + REQUIRES + esp_http_server + app_update + esp_timer + esp_system + nvs_flash + mbedtls + freertos +) + +# Firmware metadata injected at compile time +# Override these in the puzzle's CMakeLists.txt before adding the component +if(NOT DEFINED OTA_FIRMWARE_NAME) + set(OTA_FIRMWARE_NAME "zacus_puzzle") +endif() + +if(NOT DEFINED OTA_FIRMWARE_VERSION) + set(OTA_FIRMWARE_VERSION "1.0.0") +endif() + +target_compile_definitions(${COMPONENT_LIB} PRIVATE + OTA_FIRMWARE_NAME="${OTA_FIRMWARE_NAME}" + OTA_FIRMWARE_VERSION="${OTA_FIRMWARE_VERSION}" +) diff --git a/idf_zacus/components/ota_server/include/ota_server.h b/idf_zacus/components/ota_server/include/ota_server.h new file mode 100644 index 0000000..9402b35 --- /dev/null +++ b/idf_zacus/components/ota_server/include/ota_server.h @@ -0,0 +1,95 @@ +#pragma once + +#include "esp_err.h" +#include "esp_http_server.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ─── Version info (override in each puzzle's CMakeLists.txt) ───────────────── +#ifndef OTA_FIRMWARE_NAME +#define OTA_FIRMWARE_NAME "zacus_puzzle" +#endif + +#ifndef OTA_FIRMWARE_VERSION +#define OTA_FIRMWARE_VERSION "1.0.0" +#endif + +// ─── Configuration ──────────────────────────────────────────────────────────── +#define OTA_SERVER_PORT 80 +#define OTA_RATE_LIMIT_SECS 60 // Minimum seconds between OTA updates +#define OTA_WATCHDOG_SECS 30 // Auto-rollback if new firmware crashes within this +#define OTA_MAX_UPLOAD_SIZE (4 * 1024 * 1024) // 4 MB max firmware size +#define OTA_CHUNK_SIZE 4096 + +// ─── State ──────────────────────────────────────────────────────────────────── +typedef enum { + OTA_STATE_IDLE = 0, + OTA_STATE_DOWNLOADING = 1, + OTA_STATE_VERIFYING = 2, + OTA_STATE_REBOOTING = 3, + OTA_STATE_ERROR = 4, +} ota_state_t; + +typedef struct { + ota_state_t state; + int progress; // 0-100 + char error[128]; + uint32_t bytes_received; + uint32_t total_bytes; + int64_t last_ota_time; // Unix timestamp of last OTA attempt +} ota_status_t; + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * @brief Initialize the OTA HTTP server on port 80. + * + * Registers 5 endpoints: + * GET /version -> firmware name, version, IDF version + * GET /status -> battery, heap, uptime, ESP-NOW peers + * POST /ota -> receive .bin, write to OTA partition, reboot + * GET /ota/status -> current OTA state and progress + * POST /ota/rollback -> revert to previous firmware partition + * + * @return ESP_OK on success, error code otherwise. + */ +esp_err_t ota_server_init(void); + +/** + * @brief Mark current firmware as valid (call after successful startup). + * + * Cancels the rollback watchdog. Call this after all subsystems have + * initialized successfully, typically 5-10 seconds after boot. + */ +void ota_server_mark_valid(void); + +/** + * @brief Get the current OTA status. + */ +const ota_status_t* ota_server_get_status(void); + +/** + * @brief Register a callback invoked when an OTA update completes. + * + * Called before the device reboots. Use to flush pending data to NVS. + */ +void ota_server_set_complete_cb(void (*cb)(bool success)); + +/** + * @brief Get the underlying esp_http_server handle so other components + * can register additional URI handlers on the same listener + * (port 80) instead of standing up a second httpd instance. + * + * Returns NULL if ota_server_init() has not been called or failed. + * + * Used by the voice_hook_endpoint component (PLIP /voice/hook bridge, + * slice 10) to attach POST /voice/hook + GET /voice/hook/state without + * burning a second TCP socket / second httpd worker. + */ +httpd_handle_t ota_server_get_handle(void); + +#ifdef __cplusplus +} +#endif diff --git a/idf_zacus/components/ota_server/ota_server.c b/idf_zacus/components/ota_server/ota_server.c new file mode 100644 index 0000000..aeae175 --- /dev/null +++ b/idf_zacus/components/ota_server/ota_server.c @@ -0,0 +1,366 @@ +#include "ota_server.h" + +#include +#include +#include + +#include "esp_log.h" +#include "esp_ota_ops.h" +#include "esp_app_format.h" +#include "esp_timer.h" +#include "esp_system.h" +#include "esp_http_server.h" +#include "esp_mac.h" +#include "nvs_flash.h" +#include "mbedtls/sha256.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +// ─── External symbols (provided by each puzzle's main component) ────────────── +extern int puzzle_get_battery_pct(void); +extern int puzzle_get_espnow_peer_count(void); + +static const char* TAG = "ota_server"; + +// ─── Module state ───────────────────────────────────────────────────────────── +static httpd_handle_t s_server = NULL; +static ota_status_t s_status = { .state = OTA_STATE_IDLE }; +static void (*s_complete_cb)(bool) = NULL; +static esp_timer_handle_t s_watchdog = NULL; + +// ─── JSON helpers ───────────────────────────────────────────────────────────── + +static void json_str(char* buf, size_t size, const char* key, const char* val, bool comma) { + snprintf(buf + strlen(buf), size - strlen(buf), + "\"%s\":\"%s\"%s", key, val, comma ? "," : ""); +} + +static void json_int(char* buf, size_t size, const char* key, int val, bool comma) { + snprintf(buf + strlen(buf), size - strlen(buf), + "\"%s\":%d%s", key, val, comma ? "," : ""); +} + +// ─── GET /version ───────────────────────────────────────────────────────────── + +static esp_err_t handle_version(httpd_req_t* req) { + char buf[256] = "{"; + json_str(buf, sizeof(buf), "firmware", OTA_FIRMWARE_NAME, true); + json_str(buf, sizeof(buf), "version", OTA_FIRMWARE_VERSION, true); + json_str(buf, sizeof(buf), "idf", IDF_VER, false); + strncat(buf, "}", sizeof(buf) - strlen(buf) - 1); + + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + httpd_resp_sendstr(req, buf); + return ESP_OK; +} + +// ─── GET /status ────────────────────────────────────────────────────────────── + +static esp_err_t handle_status(httpd_req_t* req) { + char buf[256] = "{"; + json_int(buf, sizeof(buf), "battery_pct", puzzle_get_battery_pct(), true); + json_int(buf, sizeof(buf), "uptime_s", (int)(esp_timer_get_time() / 1000000), true); + json_int(buf, sizeof(buf), "espnow_peers", puzzle_get_espnow_peer_count(), true); + json_int(buf, sizeof(buf), "heap_free", (int)esp_get_free_heap_size(), false); + strncat(buf, "}", sizeof(buf) - strlen(buf) - 1); + + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + httpd_resp_sendstr(req, buf); + return ESP_OK; +} + +// ─── GET /ota/status ────────────────────────────────────────────────────────── + +static const char* state_to_str(ota_state_t state) { + switch (state) { + case OTA_STATE_IDLE: return "idle"; + case OTA_STATE_DOWNLOADING: return "downloading"; + case OTA_STATE_VERIFYING: return "verifying"; + case OTA_STATE_REBOOTING: return "rebooting"; + case OTA_STATE_ERROR: return "error"; + default: return "unknown"; + } +} + +static esp_err_t handle_ota_status(httpd_req_t* req) { + char buf[256] = "{"; + json_str(buf, sizeof(buf), "state", state_to_str(s_status.state), true); + json_int(buf, sizeof(buf), "progress", s_status.progress, false); + strncat(buf, "}", sizeof(buf) - strlen(buf) - 1); + + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + httpd_resp_sendstr(req, buf); + return ESP_OK; +} + +// ─── POST /ota ──────────────────────────────────────────────────────────────── + +static void do_ota_task(void* arg) { + esp_ota_handle_t ota_handle = 0; + const esp_partition_t* ota_part = NULL; + httpd_req_t* req = (httpd_req_t*)arg; + esp_err_t err = ESP_OK; + mbedtls_sha256_context sha_ctx; + mbedtls_sha256_init(&sha_ctx); + + uint8_t* buf = malloc(OTA_CHUNK_SIZE); + if (!buf) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OOM"); goto cleanup; } + + // Check content length + int total = req->content_len; + if (total <= 0 || total > OTA_MAX_UPLOAD_SIZE) { + ESP_LOGE(TAG, "Invalid content length: %d", total); + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid size"); + goto cleanup; + } + + // Get OTA partition + ota_part = esp_ota_get_next_update_partition(NULL); + if (!ota_part) { + ESP_LOGE(TAG, "No OTA partition available"); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "No OTA partition"); + goto cleanup; + } + + err = esp_ota_begin(ota_part, OTA_SIZE_UNKNOWN, &ota_handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_begin failed: %s", esp_err_to_name(err)); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA begin failed"); + goto cleanup; + } + + s_status.state = OTA_STATE_DOWNLOADING; + s_status.bytes_received = 0; + s_status.total_bytes = total; + s_status.progress = 0; + + // SHA256 context for integrity check + mbedtls_sha256_starts(&sha_ctx, 0); + + // Receive and write firmware chunks + int received = 0; + while (received < total) { + int chunk_size = MIN(OTA_CHUNK_SIZE, total - received); + int r = httpd_req_recv(req, (char*)buf, chunk_size); + + if (r <= 0) { + if (r == HTTPD_SOCK_ERR_TIMEOUT) continue; + ESP_LOGE(TAG, "Recv error: %d", r); + err = ESP_FAIL; + break; + } + + err = esp_ota_write(ota_handle, buf, r); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_write failed at offset %d: %s", received, esp_err_to_name(err)); + break; + } + + mbedtls_sha256_update(&sha_ctx, buf, r); + received += r; + s_status.bytes_received = received; + s_status.progress = (received * 100) / total; + } + + if (err != ESP_OK) { + snprintf(s_status.error, sizeof(s_status.error), "Write failed: %s", esp_err_to_name(err)); + s_status.state = OTA_STATE_ERROR; + esp_ota_abort(ota_handle); + ota_handle = 0; + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, s_status.error); + goto cleanup; + } + + // Compute SHA256 of received data + uint8_t sha256[32]; + mbedtls_sha256_finish(&sha_ctx, sha256); + mbedtls_sha256_free(&sha_ctx); + + s_status.state = OTA_STATE_VERIFYING; + s_status.progress = 95; + + // Finalize OTA + err = esp_ota_end(ota_handle); + ota_handle = 0; + if (err != ESP_OK) { + snprintf(s_status.error, sizeof(s_status.error), "OTA end failed: %s", esp_err_to_name(err)); + s_status.state = OTA_STATE_ERROR; + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, s_status.error); + goto cleanup; + } + + // Set boot partition + err = esp_ota_set_boot_partition(ota_part); + if (err != ESP_OK) { + snprintf(s_status.error, sizeof(s_status.error), "Set boot failed: %s", esp_err_to_name(err)); + s_status.state = OTA_STATE_ERROR; + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, s_status.error); + goto cleanup; + } + + s_status.progress = 100; + s_status.state = OTA_STATE_REBOOTING; + + // Respond before reboot + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + httpd_resp_sendstr(req, "{\"status\":\"ok\",\"message\":\"Firmware accepted, rebooting\"}"); + + if (s_complete_cb) s_complete_cb(true); + + ESP_LOGI(TAG, "OTA success, rebooting in 1s"); + vTaskDelay(pdMS_TO_TICKS(1000)); + esp_restart(); + +cleanup: + if (ota_handle) esp_ota_abort(ota_handle); + free(buf); + mbedtls_sha256_free(&sha_ctx); + vTaskDelete(NULL); +} + +static esp_err_t handle_ota_upload(httpd_req_t* req) { + // Rate limiting + int64_t now = esp_timer_get_time() / 1000000; + if (s_status.last_ota_time > 0 && (now - s_status.last_ota_time) < OTA_RATE_LIMIT_SECS) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Rate limited: wait 60s"); + return ESP_FAIL; + } + + if (s_status.state != OTA_STATE_IDLE) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "OTA already in progress"); + return ESP_FAIL; + } + + s_status.last_ota_time = now; + + // Run OTA in a separate task to not block the HTTP server + if (xTaskCreate(do_ota_task, "ota_task", 8192, req, 5, NULL) != pdPASS) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Task create failed"); + return ESP_FAIL; + } + + // Task will send the HTTP response + return ESP_OK; +} + +// ─── POST /ota/rollback ─────────────────────────────────────────────────────── + +static esp_err_t handle_ota_rollback(httpd_req_t* req) { + const esp_partition_t* prev = esp_ota_get_last_invalid_partition(); + if (!prev) { + // Try running partition as fallback + prev = esp_ota_get_running_partition(); + } + + if (!prev) { + httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "No previous partition to roll back to"); + return ESP_FAIL; + } + + esp_err_t err = esp_ota_set_boot_partition(prev); + if (err != ESP_OK) { + char msg[64]; + snprintf(msg, sizeof(msg), "Rollback failed: %s", esp_err_to_name(err)); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, msg); + return ESP_FAIL; + } + + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, "{\"status\":\"ok\",\"message\":\"Rolling back, rebooting\"}"); + + vTaskDelay(pdMS_TO_TICKS(500)); + esp_restart(); + return ESP_OK; +} + +// ─── Watchdog (auto-rollback) ────────────────────────────────────────────────── + +static void watchdog_cb(void* arg) { + ESP_LOGE(TAG, "Watchdog expired -- new firmware did not call ota_server_mark_valid(), rolling back"); + esp_ota_mark_app_invalid_rollback_and_reboot(); +} + +static void start_watchdog(void) { + const esp_timer_create_args_t args = { + .callback = watchdog_cb, + .name = "ota_watchdog", + }; + esp_timer_create(&args, &s_watchdog); + esp_timer_start_once(s_watchdog, (int64_t)OTA_WATCHDOG_SECS * 1000000); + ESP_LOGI(TAG, "OTA watchdog started (%ds to mark valid)", OTA_WATCHDOG_SECS); +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +esp_err_t ota_server_init(void) { + // Check if we booted from an OTA partition that needs validation + const esp_partition_t* running = esp_ota_get_running_partition(); + esp_ota_img_states_t ota_state; + if (esp_ota_get_state_partition(running, &ota_state) == ESP_OK) { + if (ota_state == ESP_OTA_IMG_PENDING_VERIFY) { + ESP_LOGW(TAG, "Running unvalidated OTA firmware -- starting watchdog"); + start_watchdog(); + } + } + + // HTTP server config + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.server_port = OTA_SERVER_PORT; + config.max_uri_handlers = 16; // ota (3) + voice_hook (2) + game (3) + headroom + config.uri_match_fn = httpd_uri_match_wildcard; + config.stack_size = 8192; + + esp_err_t err = httpd_start(&s_server, &config); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to start HTTP server: %s", esp_err_to_name(err)); + return err; + } + + // Register URI handlers + static const httpd_uri_t uris[] = { + { .uri = "/version", .method = HTTP_GET, .handler = handle_version }, + { .uri = "/status", .method = HTTP_GET, .handler = handle_status }, + { .uri = "/ota", .method = HTTP_POST, .handler = handle_ota_upload }, + { .uri = "/ota/status", .method = HTTP_GET, .handler = handle_ota_status }, + { .uri = "/ota/rollback", .method = HTTP_POST, .handler = handle_ota_rollback }, + }; + + for (int i = 0; i < (int)(sizeof(uris) / sizeof(uris[0])); i++) { + err = httpd_register_uri_handler(s_server, &uris[i]); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to register URI %s: %s", uris[i].uri, esp_err_to_name(err)); + return err; + } + } + + ESP_LOGI(TAG, "OTA server started on port %d (%s v%s)", + OTA_SERVER_PORT, OTA_FIRMWARE_NAME, OTA_FIRMWARE_VERSION); + + return ESP_OK; +} + +void ota_server_mark_valid(void) { + if (s_watchdog) { + esp_timer_stop(s_watchdog); + esp_timer_delete(s_watchdog); + s_watchdog = NULL; + ESP_LOGI(TAG, "OTA watchdog cancelled -- firmware marked valid"); + } + esp_ota_mark_app_valid_cancel_rollback(); +} + +const ota_status_t* ota_server_get_status(void) { + return &s_status; +} + +void ota_server_set_complete_cb(void (*cb)(bool success)) { + s_complete_cb = cb; +} + +httpd_handle_t ota_server_get_handle(void) { + return s_server; +} diff --git a/idf_zacus/components/ota_server/partitions_ota.csv b/idf_zacus/components/ota_server/partitions_ota.csv new file mode 100644 index 0000000..12364c4 --- /dev/null +++ b/idf_zacus/components/ota_server/partitions_ota.csv @@ -0,0 +1,8 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +otadata, data, ota, 0xf000, 0x2000, +phy_init, data, phy, 0x11000, 0x1000, +factory, app, factory, 0x20000, 1500K, +ota_0, app, ota_0, , 1500K, +ota_1, app, ota_1, , 1500K, +spiffs, data, spiffs, , 512K, diff --git a/idf_zacus/components/scenario_mesh/CMakeLists.txt b/idf_zacus/components/scenario_mesh/CMakeLists.txt new file mode 100644 index 0000000..4520f05 --- /dev/null +++ b/idf_zacus/components/scenario_mesh/CMakeLists.txt @@ -0,0 +1,17 @@ +## Zacus scenario_mesh — ESP-NOW transport for Runtime 3 IR hot-load. +## +## Tasks 4 & 5 of docs/specs/2026-05-24-firmware-scenario-hotload.md: chunk the +## IR JSON into <=240-byte ESP-NOW frames, send sequentially with per-frame +## ack, and reassemble on the receive side keyed by sender+total before +## invoking the same _scenario_apply() path as the HTTP handler. + +idf_component_register( + SRCS + "scenario_mesh.c" + INCLUDE_DIRS + "include" + REQUIRES + esp_wifi + freertos + log +) diff --git a/idf_zacus/components/scenario_mesh/include/scenario_mesh.h b/idf_zacus/components/scenario_mesh/include/scenario_mesh.h new file mode 100644 index 0000000..2db86d4 --- /dev/null +++ b/idf_zacus/components/scenario_mesh/include/scenario_mesh.h @@ -0,0 +1,86 @@ +// scenario_mesh — ESP-NOW transport for Runtime 3 IR hot-load (Phase 2). +// +// Implements tasks 4 & 5 of docs/specs/2026-05-24-firmware-scenario-hotload.md: +// +// * Frame protocol: the IR JSON blob is chunked into ESP-NOW frames of +// <= SCENARIO_MESH_FRAME_MAX (240) bytes. Each frame carries a 4-byte +// header { seq:u16, total:u16 } (little-endian on the wire) followed by +// up to SCENARIO_MESH_PAYLOAD_MAX payload bytes. +// * Sender (master): scenario_mesh_send() resolves an alias to a MAC, +// registers the peer, and transmits every frame sequentially, awaiting +// the per-frame esp_now send-callback ack before advancing. +// * Receiver (peer board): the registered esp_now recv callback accumulates +// frames keyed by (sender MAC + total) until `total` frames have arrived, +// concatenates them, and hands the reassembled buffer to the apply +// callback supplied at init — the same internal `_scenario_apply()` path +// the HTTP POST /game/scenario handler uses. +// +// NOTE ON THE "EXISTING PEER REGISTRY": +// The spec references an existing ESP-NOW peer registry + `espnow_recv_cb` +// to extend. In the IDF tree (idf_zacus) no such registry exists yet — the +// only ESP-NOW code is the legacy Arduino lib/espnow_common (broadcast-only, +// puzzle-id keyed, not an IDF component). So this component carries its own +// minimal alias->MAC table (scenario_mesh_register_peer / _mac_for_alias). +// When a real shared registry lands, point mac_for_alias() at it. + +#pragma once + +#include +#include +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ESP-NOW hard limit on a single payload is 250 bytes. We cap the whole +// frame (header + payload) at 240 to stay clear of vendor headers and keep a +// safety margin, matching the spec's "<= 240-byte ESP-NOW frames". +#define SCENARIO_MESH_FRAME_MAX 240 +#define SCENARIO_MESH_HEADER_BYTES 4 +#define SCENARIO_MESH_PAYLOAD_MAX (SCENARIO_MESH_FRAME_MAX - SCENARIO_MESH_HEADER_BYTES) // 236 + +// Largest IR blob we will reassemble on the receive side. Mirrors +// GAME_ENDPOINT_MAX_SCENARIO_BYTES (64 KiB). 64 KiB / 236 ≈ 285 frames, well +// under the u16 sequence space. +#define SCENARIO_MESH_MAX_BLOB (64 * 1024) + +// Per-frame ack timeout. ESP-NOW send-callbacks normally fire within a few ms; +// a generous window absorbs RF retries without stalling the relay loop. +#define SCENARIO_MESH_ACK_TIMEOUT_MS 300 + +// Callback invoked on the receive side once a full blob has been reassembled. +// `data` is a NUL-terminated buffer of `len` bytes (the IR JSON). The callback +// must NOT take ownership — the buffer is freed by scenario_mesh after return. +// Return ESP_OK if the scenario was applied; any other value is logged. +typedef esp_err_t (*scenario_mesh_apply_cb_t)(const char *data, size_t len); + +// Initialize ESP-NOW (idempotent — tolerates an already-initialized stack), +// register the send + recv callbacks, and register the broadcast peer. +// +// `apply_cb` may be NULL on a pure sender (master) that never receives +// scenarios; pass the board's _scenario_apply wrapper on receiver boards. +esp_err_t scenario_mesh_init(scenario_mesh_apply_cb_t apply_cb); + +// Register / update an alias -> MAC mapping in the local peer table and add the +// MAC as an unencrypted ESP-NOW peer. Safe to call repeatedly with the same +// alias (updates the MAC). Returns ESP_ERR_NO_MEM if the table is full. +esp_err_t scenario_mesh_register_peer(const char *alias, const uint8_t mac[6]); + +// Resolve an alias to its MAC. Returns ESP_OK and fills `mac_out` on hit, +// ESP_ERR_NOT_FOUND otherwise. +esp_err_t scenario_mesh_mac_for_alias(const char *alias, uint8_t mac_out[6]); + +// Chunk `data` (len bytes) into frames and send them all sequentially to +// `dest_mac`, awaiting the per-frame ack. Returns ESP_OK only if every frame +// was acked; ESP_ERR_TIMEOUT if any frame ack timed out, or the underlying +// esp_now_send error. The caller (relay handler) treats a non-OK return as a +// skipped peer and continues with the others. +esp_err_t scenario_mesh_send(const uint8_t dest_mac[6], + const char *data, size_t len); + +#ifdef __cplusplus +} +#endif diff --git a/idf_zacus/components/scenario_mesh/scenario_mesh.c b/idf_zacus/components/scenario_mesh/scenario_mesh.c new file mode 100644 index 0000000..89d10e3 --- /dev/null +++ b/idf_zacus/components/scenario_mesh/scenario_mesh.c @@ -0,0 +1,389 @@ +// scenario_mesh — see include/scenario_mesh.h for the design notes. +// +// Tasks 4 & 5 of the firmware-scenario-hotload spec: ESP-NOW frame protocol +// (chunk + reassembly) for relaying Runtime 3 IR to WiFi-disabled peers. + +#include "scenario_mesh.h" + +#include + +#include "esp_log.h" +#include "esp_now.h" +#include "esp_wifi.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + +static const char *TAG = "scenario_mesh"; + +static const uint8_t kBroadcast[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + +// ─── alias -> MAC peer table (minimal local registry) ─────────────────────── + +#define MESH_ALIAS_MAX 32 +#define MESH_PEERS_MAX 8 + +typedef struct { + bool used; + char alias[MESH_ALIAS_MAX]; + uint8_t mac[6]; +} mesh_peer_t; + +static mesh_peer_t s_peers[MESH_PEERS_MAX]; + +// ─── send-side ack synchronization ────────────────────────────────────────── + +static SemaphoreHandle_t s_send_done; // given by on_sent for each frame +static volatile esp_now_send_status_t s_last_send_status; +static SemaphoreHandle_t s_send_lock; // serializes scenario_mesh_send calls + +// ─── receive-side reassembly ──────────────────────────────────────────────── +// +// One in-flight reassembly slot per distinct (sender MAC, total) tuple. A +// single slot is enough in practice — the master relays to one peer at a time +// and frames arrive in order — but we keep a small array so two senders (or a +// retried transfer) don't clobber each other. + +#define MESH_REASM_SLOTS 2 + +typedef struct { + bool used; + uint8_t src[6]; + uint16_t total; + uint16_t received; // count of distinct frames stored + bool *seen; // [total] frame-arrival bitmap (heap) + char *buf; // [total * PAYLOAD_MAX + 1] (heap) + size_t buf_len; // running max byte offset written + payload + uint32_t last_tick; // for staleness eviction +} mesh_reasm_t; + +static mesh_reasm_t s_reasm[MESH_REASM_SLOTS]; +static SemaphoreHandle_t s_reasm_lock; +static scenario_mesh_apply_cb_t s_apply_cb; + +// A completed reassembly is handed off to a worker task rather than applied in +// the Wi-Fi recv-callback context: the apply does filesystem I/O (and triggers +// a reboot) which must not run on the Wi-Fi stack's callback. +typedef struct { + char *buf; // heap, NUL-terminated, ownership transferred to the task + size_t len; +} mesh_apply_job_t; + +static QueueHandle_t s_apply_queue; + +static void apply_worker_task(void *arg) { + (void) arg; + mesh_apply_job_t job; + for (;;) { + if (xQueueReceive(s_apply_queue, &job, portMAX_DELAY) != pdTRUE) { + continue; + } + if (s_apply_cb) { + esp_err_t aerr = s_apply_cb(job.buf, job.len); + if (aerr != ESP_OK) { + ESP_LOGW(TAG, "apply_cb returned %s", esp_err_to_name(aerr)); + } + } else { + ESP_LOGW(TAG, "no apply_cb registered — dropping scenario"); + } + free(job.buf); + } +} + +// ─── peer table helpers ───────────────────────────────────────────────────── + +esp_err_t scenario_mesh_register_peer(const char *alias, const uint8_t mac[6]) { + if (!alias || !mac) return ESP_ERR_INVALID_ARG; + + int free_slot = -1; + for (int i = 0; i < MESH_PEERS_MAX; i++) { + if (s_peers[i].used && strncmp(s_peers[i].alias, alias, + MESH_ALIAS_MAX) == 0) { + free_slot = i; // update existing + break; + } + if (!s_peers[i].used && free_slot < 0) free_slot = i; + } + if (free_slot < 0) { + ESP_LOGE(TAG, "peer table full, cannot register \"%s\"", alias); + return ESP_ERR_NO_MEM; + } + + s_peers[free_slot].used = true; + strncpy(s_peers[free_slot].alias, alias, MESH_ALIAS_MAX - 1); + s_peers[free_slot].alias[MESH_ALIAS_MAX - 1] = '\0'; + memcpy(s_peers[free_slot].mac, mac, 6); + + // Add (or refresh) the ESP-NOW peer entry. esp_now_add_peer fails with + // ESP_ERR_ESPNOW_EXIST if already present — treat that as success. + esp_now_peer_info_t pi = {0}; + memcpy(pi.peer_addr, mac, 6); + pi.channel = 0; // current channel + pi.ifidx = WIFI_IF_STA; + pi.encrypt = false; + esp_err_t err = esp_now_add_peer(&pi); + if (err == ESP_ERR_ESPNOW_EXIST) { + err = ESP_OK; + } else if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_now_add_peer(%s) failed: %s", + alias, esp_err_to_name(err)); + } + + ESP_LOGI(TAG, "peer \"%s\" -> %02x:%02x:%02x:%02x:%02x:%02x", + alias, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + return err; +} + +esp_err_t scenario_mesh_mac_for_alias(const char *alias, uint8_t mac_out[6]) { + if (!alias || !mac_out) return ESP_ERR_INVALID_ARG; + for (int i = 0; i < MESH_PEERS_MAX; i++) { + if (s_peers[i].used && + strncmp(s_peers[i].alias, alias, MESH_ALIAS_MAX) == 0) { + memcpy(mac_out, s_peers[i].mac, 6); + return ESP_OK; + } + } + return ESP_ERR_NOT_FOUND; +} + +// ─── ESP-NOW callbacks (Wi-Fi task context — keep short) ──────────────────── + +static void on_sent(const uint8_t *mac, esp_now_send_status_t status) { + (void) mac; + s_last_send_status = status; + if (s_send_done) { + BaseType_t hp = pdFALSE; + xSemaphoreGiveFromISR(s_send_done, &hp); + if (hp) portYIELD_FROM_ISR(); + } +} + +// Locate (or allocate) the reassembly slot for this sender/total. Caller holds +// s_reasm_lock. Returns NULL on allocation failure. +static mesh_reasm_t *reasm_slot_for(const uint8_t src[6], uint16_t total) { + mesh_reasm_t *free_slot = NULL; + mesh_reasm_t *oldest = NULL; + for (int i = 0; i < MESH_REASM_SLOTS; i++) { + mesh_reasm_t *r = &s_reasm[i]; + if (r->used && r->total == total && memcmp(r->src, src, 6) == 0) { + return r; + } + if (!r->used && !free_slot) free_slot = r; + if (r->used && (!oldest || r->last_tick < oldest->last_tick)) oldest = r; + } + + // New transfer. Reuse a free slot, else evict the oldest in-flight one. + mesh_reasm_t *r = free_slot ? free_slot : oldest; + if (!r) return NULL; + if (r->used) { + ESP_LOGW(TAG, "evicting stale reassembly (%u/%u frames)", + r->received, r->total); + free(r->seen); + free(r->buf); + } + memset(r, 0, sizeof(*r)); + + if (total == 0 || (size_t) total * SCENARIO_MESH_PAYLOAD_MAX > SCENARIO_MESH_MAX_BLOB) { + ESP_LOGW(TAG, "reject transfer: implausible total=%u", total); + return NULL; + } + r->seen = calloc(total, sizeof(bool)); + r->buf = malloc((size_t) total * SCENARIO_MESH_PAYLOAD_MAX + 1); + if (!r->seen || !r->buf) { + free(r->seen); + free(r->buf); + ESP_LOGE(TAG, "OOM allocating reassembly for total=%u", total); + return NULL; + } + r->used = true; + r->total = total; + memcpy(r->src, src, 6); + return r; +} + +static void on_recv(const esp_now_recv_info_t *info, + const uint8_t *data, int len) { + if (!info || !data || len < SCENARIO_MESH_HEADER_BYTES) return; + if (len > SCENARIO_MESH_FRAME_MAX) return; + + // Header: seq:u16, total:u16 (little-endian). + uint16_t seq = (uint16_t) (data[0] | (data[1] << 8)); + uint16_t total = (uint16_t) (data[2] | (data[3] << 8)); + const uint8_t *payload = data + SCENARIO_MESH_HEADER_BYTES; + int payload_len = len - SCENARIO_MESH_HEADER_BYTES; + if (seq >= total) return; // malformed + + if (!s_reasm_lock) return; + // Run reassembly off the Wi-Fi callback by doing the bookkeeping under a + // mutex here; the (potentially slow) apply is deferred to a short task so + // we never block the Wi-Fi stack inside the recv callback. + char *complete_buf = NULL; + size_t complete_len = 0; + + xSemaphoreTake(s_reasm_lock, portMAX_DELAY); + mesh_reasm_t *r = reasm_slot_for(info->src_addr, total); + if (r) { + if (!r->seen[seq]) { + r->seen[seq] = true; + r->received++; + size_t off = (size_t) seq * SCENARIO_MESH_PAYLOAD_MAX; + memcpy(r->buf + off, payload, payload_len); + // Track the highest end offset so the final length is exact even + // though only the last frame is short. + if (off + payload_len > r->buf_len) r->buf_len = off + payload_len; + } + r->last_tick = (uint32_t) xTaskGetTickCount(); + + if (r->received == r->total) { + r->buf[r->buf_len] = '\0'; + complete_buf = r->buf; + complete_len = r->buf_len; + free(r->seen); + memset(r, 0, sizeof(*r)); // releases the slot; buf handed off + } + } + xSemaphoreGive(s_reasm_lock); + + if (complete_buf) { + ESP_LOGI(TAG, "reassembled scenario: %u bytes from " + "%02x:%02x:%02x:%02x:%02x:%02x", + (unsigned) complete_len, + info->src_addr[0], info->src_addr[1], info->src_addr[2], + info->src_addr[3], info->src_addr[4], info->src_addr[5]); + // Hand off to the worker task — never touch the filesystem (or reboot) + // from the Wi-Fi recv-callback context. + mesh_apply_job_t job = { .buf = complete_buf, .len = complete_len }; + if (!s_apply_queue || + xQueueSend(s_apply_queue, &job, 0) != pdTRUE) { + ESP_LOGW(TAG, "apply queue full/unavailable — dropping scenario"); + free(complete_buf); + } + } +} + +// ─── init ─────────────────────────────────────────────────────────────────── + +esp_err_t scenario_mesh_init(scenario_mesh_apply_cb_t apply_cb) { + s_apply_cb = apply_cb; + + if (!s_send_done) s_send_done = xSemaphoreCreateBinary(); + if (!s_send_lock) s_send_lock = xSemaphoreCreateMutex(); + if (!s_reasm_lock) s_reasm_lock = xSemaphoreCreateMutex(); + if (!s_send_done || !s_send_lock || !s_reasm_lock) { + return ESP_ERR_NO_MEM; + } + + // Receiver path: spin up the apply worker (and its job queue) so completed + // reassemblies are applied off the Wi-Fi callback. A pure sender (master + // relay with no apply_cb) skips this to save RAM. + if (apply_cb && !s_apply_queue) { + s_apply_queue = xQueueCreate(2, sizeof(mesh_apply_job_t)); + if (!s_apply_queue) return ESP_ERR_NO_MEM; + BaseType_t ok = xTaskCreate(apply_worker_task, "scn_mesh_apply", + 4096, NULL, tskIDLE_PRIORITY + 2, NULL); + if (ok != pdPASS) { + vQueueDelete(s_apply_queue); + s_apply_queue = NULL; + return ESP_ERR_NO_MEM; + } + } + + // esp_now_init() requires Wi-Fi to be started already (the caller brings up + // STA/AP). A second init returns ESP_ERR_ESPNOW_INTERNAL on some IDF lines; + // since no other component in this tree owns ESP-NOW yet a hard failure is + // genuinely fatal, but we keep the relay endpoint optional at the call site. + esp_err_t err = esp_now_init(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_now_init: %s", esp_err_to_name(err)); + return err; + } + + esp_now_register_send_cb(on_sent); + esp_now_register_recv_cb(on_recv); + + // Broadcast peer (handy for future fan-out; unicast peers are added on + // demand by scenario_mesh_register_peer). + esp_now_peer_info_t bcast = {0}; + memcpy(bcast.peer_addr, kBroadcast, 6); + bcast.channel = 0; + bcast.ifidx = WIFI_IF_STA; + bcast.encrypt = false; + esp_err_t berr = esp_now_add_peer(&bcast); + if (berr != ESP_OK && berr != ESP_ERR_ESPNOW_EXIST) { + ESP_LOGW(TAG, "esp_now_add_peer(broadcast): %s", + esp_err_to_name(berr)); + } + + ESP_LOGI(TAG, "scenario_mesh ready (apply_cb=%s)", + apply_cb ? "set" : "none"); + return ESP_OK; +} + +// ─── send (chunk + per-frame ack) ─────────────────────────────────────────── + +esp_err_t scenario_mesh_send(const uint8_t dest_mac[6], + const char *data, size_t len) { + if (!dest_mac || !data || len == 0) return ESP_ERR_INVALID_ARG; + if (len > SCENARIO_MESH_MAX_BLOB) return ESP_ERR_INVALID_SIZE; + + size_t total = (len + SCENARIO_MESH_PAYLOAD_MAX - 1) / + SCENARIO_MESH_PAYLOAD_MAX; + if (total == 0 || total > 0xFFFF) return ESP_ERR_INVALID_SIZE; + + // Serialize: the single send-done semaphore is shared across frames. + if (xSemaphoreTake(s_send_lock, portMAX_DELAY) != pdTRUE) { + return ESP_FAIL; + } + + esp_err_t result = ESP_OK; + uint8_t frame[SCENARIO_MESH_FRAME_MAX]; + + for (size_t seq = 0; seq < total; seq++) { + size_t off = seq * SCENARIO_MESH_PAYLOAD_MAX; + size_t chunk = len - off; + if (chunk > SCENARIO_MESH_PAYLOAD_MAX) chunk = SCENARIO_MESH_PAYLOAD_MAX; + + frame[0] = (uint8_t) (seq & 0xFF); + frame[1] = (uint8_t) ((seq >> 8) & 0xFF); + frame[2] = (uint8_t) (total & 0xFF); + frame[3] = (uint8_t) ((total >> 8) & 0xFF); + memcpy(frame + SCENARIO_MESH_HEADER_BYTES, data + off, chunk); + + // Drain any stale ack from a previous frame, then send + await ack. + xSemaphoreTake(s_send_done, 0); + s_last_send_status = ESP_NOW_SEND_FAIL; + + esp_err_t serr = esp_now_send(dest_mac, frame, + SCENARIO_MESH_HEADER_BYTES + chunk); + if (serr != ESP_OK) { + ESP_LOGW(TAG, "esp_now_send frame %u/%u: %s", + (unsigned) seq, (unsigned) total, esp_err_to_name(serr)); + result = serr; + break; + } + + if (xSemaphoreTake(s_send_done, + pdMS_TO_TICKS(SCENARIO_MESH_ACK_TIMEOUT_MS)) + != pdTRUE) { + ESP_LOGW(TAG, "ack timeout on frame %u/%u", + (unsigned) seq, (unsigned) total); + result = ESP_ERR_TIMEOUT; + break; + } + if (s_last_send_status != ESP_NOW_SEND_SUCCESS) { + ESP_LOGW(TAG, "frame %u/%u not acked by peer", + (unsigned) seq, (unsigned) total); + result = ESP_ERR_TIMEOUT; // surfaced as a skipped peer + break; + } + } + + xSemaphoreGive(s_send_lock); + + if (result == ESP_OK) { + ESP_LOGI(TAG, "sent scenario: %u bytes in %u frames", + (unsigned) len, (unsigned) total); + } + return result; +} diff --git a/idf_zacus/components/voice_hook_endpoint/CMakeLists.txt b/idf_zacus/components/voice_hook_endpoint/CMakeLists.txt new file mode 100644 index 0000000..8daa133 --- /dev/null +++ b/idf_zacus/components/voice_hook_endpoint/CMakeLists.txt @@ -0,0 +1,13 @@ +idf_component_register( + SRCS + "voice_hook_endpoint.c" + INCLUDE_DIRS + "include" + REQUIRES + esp_http_server + json + voice_pipeline + ota_server + freertos + log +) diff --git a/idf_zacus/components/voice_hook_endpoint/README.md b/idf_zacus/components/voice_hook_endpoint/README.md new file mode 100644 index 0000000..9c80926 --- /dev/null +++ b/idf_zacus/components/voice_hook_endpoint/README.md @@ -0,0 +1,130 @@ +# voice_hook_endpoint + +REST bridge between the **PLIP** retro-telephone annex (Si3210 SLIC +hook switch on the JLCPCB-fab'd PCB) and the **Zacus master** voice +pipeline. Slice 10 of the IDF migration. + +## Why + +The escape-room narrative: *"le téléphone sonne, décroche pour parler +à Zacus"*. Wake-word ("hi esp") still works as a backup, but the +canonical interaction is to physically lift the handset. + +When PLIP detects an off-hook transition on the Si3210 INT line it +POSTs the new state to the master ESP32 over Wi-Fi. The master arms +the voice pipeline immediately (LISTENING + capture + WS streaming), +**bypassing the wake-word detector entirely**. On-hook closes the WS +and returns the pipeline to IDLE. + +## Hardware-side contract (PLIP firmware) + +PLIP firmware lives in a separate tree (`PLIP_FIRMWARE/`) and is +**not** modified by this slice. The only thing it must do: + +1. Discover the master ESP32 IP — mDNS lookup `zacus-master.local` is + the planned mechanism (not yet implemented either side); a static + IP from DHCP reservation works as a fallback. +2. On the Si3210 INT ISR (or its debounced FreeRTOS task), POST to + `/voice/hook` whenever the hook state actually changes. +3. Treat any non-2xx response as transient — retry once after 250 ms + then give up. Do **not** block the audio path on the REST call. + +## Wire protocol + +Listener: existing `esp_http_server` instance on port **80** (shared +with `ota_server` — same TCP socket, same worker pool, no second +httpd brought up). + +### `POST /voice/hook` + +Request body (JSON, max 256 bytes): + +```json +{ "state": "off", "reason": "pickup" } +``` + +- `state` (required, string): `"off"` = handset lifted (off-hook, + user wants to talk) or `"on"` = handset hung up (on-hook). +- `reason` (optional, string): free-form diagnostic tag — `pickup`, + `hangup`, `ring_started`, `ring_timeout`, etc. Logged but never + acted on. + +Responses: + +| Status | Body | Meaning | +|--------|------|---------| +| 200 | `{"status":"listening","mute_gate":false}` | off-hook accepted | +| 200 | `{"status":"idle"}` | on-hook accepted | +| 400 | `{"error":"missing 'state'"}` | malformed/empty JSON or missing key | +| 400 | `{"error":"bad state"}` | `state` not in `{"off","on"}` | +| 405 | `{"error":"use POST"}` | GET on `/voice/hook` | +| 413 | `{"error":"body must be 1..256 bytes"}` | body too large | + +### `GET /voice/hook/state` + +Debug-only introspection. Returns the live voice pipeline state: + +```json +{ + "voice_state": "listening", + "wake_word_active": true, + "streaming": true +} +``` + +`voice_state` is one of `idle | listening | speaking | muted`. + +## curl examples + +```bash +# Off-hook (PLIP picks up the handset) +curl -X POST http:///voice/hook \ + -H "Content-Type: application/json" \ + -d '{"state":"off","reason":"pickup"}' + +# On-hook (PLIP hangs up) +curl -X POST http:///voice/hook \ + -H "Content-Type: application/json" \ + -d '{"state":"on","reason":"hangup"}' + +# Probe current state +curl http:///voice/hook/state +``` + +## Behaviour vs. wake-word + +The voice pipeline runs in **mixed mode**: both the wake-word +detector (`enable_wake_word = true`) and the REST hook are armed at +the same time. Whichever fires first wins. This is deliberate — +the Si3210 + RJ9 combiné is the primary user-facing surface but if +the phone is unplugged or the hook switch fails the master remains +voice-controllable from any open mic in the room. + +The off-hook handler is **idempotent**: bouncing the hook switch +inside the Si3210 debounce window cannot corrupt state. The handler +forces `LISTENING + start_capture + start_streaming` every time, and +all three are no-ops when already in those states. + +## Component dependencies + +Registered with: + +```cmake +REQUIRES + esp_http_server # the shared httpd_handle_t + json # cJSON for body parsing + voice_pipeline # state-machine + capture/stream control + ota_server # supplies httpd_handle via ota_server_get_handle() + freertos + log +``` + +Init pattern in `main.c`: + +```c +esp_err_t ota_err = ota_server_init(); +if (ota_err == ESP_OK) { + httpd_handle_t httpd = ota_server_get_handle(); + voice_hook_endpoint_init(httpd); +} +``` diff --git a/idf_zacus/components/voice_hook_endpoint/include/voice_hook_endpoint.h b/idf_zacus/components/voice_hook_endpoint/include/voice_hook_endpoint.h new file mode 100644 index 0000000..8a3955b --- /dev/null +++ b/idf_zacus/components/voice_hook_endpoint/include/voice_hook_endpoint.h @@ -0,0 +1,56 @@ +// voice_hook_endpoint — REST bridge between the PLIP retro-telephone +// (Si3210 SLIC hook switch) and the Zacus master voice pipeline. +// +// Slice 10 deliverable: PLIP detects off-hook / on-hook on the Si3210 +// INT line and POSTs the new state to the Zacus master at +// `POST /voice/hook`. Off-hook bypasses the wake-word and arms the +// voice pipeline directly (LISTENING + capture). On-hook tears the +// streaming WebSocket down and returns the pipeline to IDLE. +// +// The handlers are attached to the existing esp_http_server instance +// owned by the ota_server component (port 80) — no second httpd +// listener, no second TCP socket. Caller is expected to obtain the +// handle from `ota_server_get_handle()` after a successful +// `ota_server_init()`. +// +// Routes registered: +// POST /voice/hook — body {"state":"off"|"on","reason"?:"..."} +// GET /voice/hook/state — debug introspection (current voice state) +// +// Thread safety: the esp_http_server runs each handler on its own +// worker task. The voice_pipeline_* APIs invoked here are documented +// as safe to call from any task (the pipeline serialises state +// transitions internally via its capture-task mailbox). Off-hook +// requests are idempotent — bouncing the hook switch is safe. + +#pragma once + +#include "esp_err.h" +#include "esp_http_server.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Wire-protocol constants — also used by the PLIP firmware as the only +// two valid `state` payload values. +#define VOICE_HOOK_OFF "off" // pickup → arm voice pipeline +#define VOICE_HOOK_ON "on" // hangup → tear pipeline down + +// Maximum body size accepted by POST /voice/hook. Anything larger is +// rejected with 413 to keep the worker-stack footprint bounded. +#define VOICE_HOOK_MAX_BODY_BYTES 256 + +/** + * @brief Attach `/voice/hook` + `/voice/hook/state` handlers to an + * existing esp_http_server. + * + * Pass the handle returned by `ota_server_get_handle()`. Returns + * ESP_ERR_INVALID_ARG if `server` is NULL, or any error propagated + * from `httpd_register_uri_handler()`. + */ +esp_err_t voice_hook_endpoint_init(httpd_handle_t server); + +#ifdef __cplusplus +} +#endif diff --git a/idf_zacus/components/voice_hook_endpoint/voice_hook_endpoint.c b/idf_zacus/components/voice_hook_endpoint/voice_hook_endpoint.c new file mode 100644 index 0000000..fac22b0 --- /dev/null +++ b/idf_zacus/components/voice_hook_endpoint/voice_hook_endpoint.c @@ -0,0 +1,227 @@ +// voice_hook_endpoint — see include/voice_hook_endpoint.h for the +// design notes. Slice 10 of the IDF migration: PLIP retro-telephone +// hook switch → REST → voice pipeline state machine. + +#include "voice_hook_endpoint.h" + +#include + +#include "cJSON.h" +#include "esp_err.h" +#include "esp_http_server.h" +#include "esp_log.h" + +#include "voice_pipeline.h" + +static const char *TAG = "voice_hook"; + +// ─── small JSON response helper ────────────────────────────────────────────── + +static esp_err_t send_json(httpd_req_t *req, const char *status_line, + const char *body) { + httpd_resp_set_status(req, status_line); + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + return httpd_resp_sendstr(req, body); +} + +static esp_err_t send_error(httpd_req_t *req, const char *status_line, + const char *message) { + char buf[160]; + snprintf(buf, sizeof(buf), "{\"error\":\"%s\"}", message ? message : ""); + return send_json(req, status_line, buf); +} + +static const char *voice_state_to_str(voice_state_t s) { + switch (s) { + case VOICE_STATE_IDLE: return "idle"; + case VOICE_STATE_LISTENING: return "listening"; + case VOICE_STATE_SPEAKING: return "speaking"; + case VOICE_STATE_MUTED: return "muted"; + default: return "unknown"; + } +} + +// ─── POST /voice/hook ──────────────────────────────────────────────────────── + +static esp_err_t handle_voice_hook_post(httpd_req_t *req) { + if (req->content_len <= 0 || + req->content_len > VOICE_HOOK_MAX_BODY_BYTES) { + ESP_LOGW(TAG, "POST /voice/hook: bad body length %d", + (int) req->content_len); + return send_error(req, "413 Payload Too Large", + "body must be 1..256 bytes"); + } + + char body[VOICE_HOOK_MAX_BODY_BYTES + 1] = {0}; + int total = 0; + while (total < (int) req->content_len) { + int got = httpd_req_recv(req, body + total, + req->content_len - total); + if (got <= 0) { + if (got == HTTPD_SOCK_ERR_TIMEOUT) continue; + return send_error(req, "400 Bad Request", "recv failed"); + } + total += got; + } + body[total] = '\0'; + + cJSON *root = cJSON_Parse(body); + if (!root) { + ESP_LOGW(TAG, "POST /voice/hook: malformed JSON: %s", body); + return send_error(req, "400 Bad Request", "malformed json"); + } + + const cJSON *state = cJSON_GetObjectItemCaseSensitive(root, "state"); + const cJSON *reason = cJSON_GetObjectItemCaseSensitive(root, "reason"); + + if (!cJSON_IsString(state) || state->valuestring == NULL) { + cJSON_Delete(root); + return send_error(req, "400 Bad Request", "missing 'state'"); + } + + const char *reason_str = (cJSON_IsString(reason) && reason->valuestring) + ? reason->valuestring + : "(unspecified)"; + + esp_err_t err = ESP_OK; + + if (strcmp(state->valuestring, VOICE_HOOK_OFF) == 0) { + // PLIP picked up → bypass wake-word, arm capture immediately. + ESP_LOGI(TAG, "PLIP picked up (reason=%s) — arming voice pipeline", + reason_str); + + // Idempotent: if we were already LISTENING the pipeline just + // returns ESP_OK. Walk both knobs so a stale state (e.g. SPEAKING + // from a TTS playback that ended without a clean speak_end) gets + // forced back to LISTENING for the new conversation. + (void) voice_pipeline_set_state(VOICE_STATE_LISTENING); + err = voice_pipeline_start_capture(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGW(TAG, "voice_pipeline_start_capture: %s", + esp_err_to_name(err)); + } + + // Open the streaming WS proactively so STT starts the moment + // the user speaks — no need to wait for end-of-speech VAD on + // the wake-word path. + esp_err_t stream_err = voice_pipeline_start_streaming(); + if (stream_err != ESP_OK && stream_err != ESP_ERR_INVALID_STATE) { + ESP_LOGW(TAG, "voice_pipeline_start_streaming: %s", + esp_err_to_name(stream_err)); + } + + cJSON_Delete(root); + return send_json(req, "200 OK", + "{\"status\":\"listening\",\"mute_gate\":false}"); + } + + if (strcmp(state->valuestring, VOICE_HOOK_ON) == 0) { + // PLIP hung up → tear streaming down, force IDLE. + ESP_LOGI(TAG, "PLIP hung up (reason=%s) — releasing voice pipeline", + reason_str); + + esp_err_t stream_err = voice_pipeline_stop_streaming(); + if (stream_err != ESP_OK && stream_err != ESP_ERR_INVALID_STATE) { + ESP_LOGW(TAG, "voice_pipeline_stop_streaming: %s", + esp_err_to_name(stream_err)); + } + + err = voice_pipeline_stop_capture(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGW(TAG, "voice_pipeline_stop_capture: %s", + esp_err_to_name(err)); + } + (void) voice_pipeline_set_state(VOICE_STATE_IDLE); + + cJSON_Delete(root); + return send_json(req, "200 OK", "{\"status\":\"idle\"}"); + } + + ESP_LOGW(TAG, "POST /voice/hook: bad state value '%s'", + state->valuestring); + cJSON_Delete(root); + return send_error(req, "400 Bad Request", "bad state"); +} + +// ─── GET /voice/hook/state ─────────────────────────────────────────────────── + +static esp_err_t handle_voice_hook_state(httpd_req_t *req) { + voice_state_t st = voice_pipeline_get_state(); + bool wake = voice_pipeline_wake_word_active(); + bool streaming = voice_pipeline_is_streaming(); + + char buf[160]; + snprintf(buf, sizeof(buf), + "{\"voice_state\":\"%s\"," + "\"wake_word_active\":%s," + "\"streaming\":%s}", + voice_state_to_str(st), + wake ? "true" : "false", + streaming ? "true" : "false"); + return send_json(req, "200 OK", buf); +} + +// ─── 405 method-not-allowed catcher ────────────────────────────────────────── +// +// esp_http_server already returns 404 for unknown URIs. We register an +// explicit GET /voice/hook handler that returns 405 so PLIP integrators +// who confuse GET vs POST get an actionable error instead of a 404 +// pointing them at the wrong fix. + +static esp_err_t handle_voice_hook_get_405(httpd_req_t *req) { + httpd_resp_set_hdr(req, "Allow", "POST"); + return send_error(req, "405 Method Not Allowed", "use POST"); +} + +// ─── public init ───────────────────────────────────────────────────────────── + +esp_err_t voice_hook_endpoint_init(httpd_handle_t server) { + if (server == NULL) { + ESP_LOGE(TAG, "voice_hook_endpoint_init: NULL httpd handle " + "(did ota_server_init() succeed?)"); + return ESP_ERR_INVALID_ARG; + } + + static const httpd_uri_t uri_post = { + .uri = "/voice/hook", + .method = HTTP_POST, + .handler = handle_voice_hook_post, + .user_ctx = NULL, + }; + static const httpd_uri_t uri_get_405 = { + .uri = "/voice/hook", + .method = HTTP_GET, + .handler = handle_voice_hook_get_405, + .user_ctx = NULL, + }; + static const httpd_uri_t uri_state = { + .uri = "/voice/hook/state", + .method = HTTP_GET, + .handler = handle_voice_hook_state, + .user_ctx = NULL, + }; + + esp_err_t err = httpd_register_uri_handler(server, &uri_post); + if (err != ESP_OK) { + ESP_LOGE(TAG, "register POST /voice/hook: %s", + esp_err_to_name(err)); + return err; + } + err = httpd_register_uri_handler(server, &uri_get_405); + if (err != ESP_OK) { + ESP_LOGE(TAG, "register GET /voice/hook (405): %s", + esp_err_to_name(err)); + return err; + } + err = httpd_register_uri_handler(server, &uri_state); + if (err != ESP_OK) { + ESP_LOGE(TAG, "register GET /voice/hook/state: %s", + esp_err_to_name(err)); + return err; + } + + ESP_LOGI(TAG, "voice hook endpoint registered " + "(POST /voice/hook, GET /voice/hook/state)"); + return ESP_OK; +} diff --git a/idf_zacus/components/voice_pipeline/CMakeLists.txt b/idf_zacus/components/voice_pipeline/CMakeLists.txt new file mode 100644 index 0000000..4f30fa0 --- /dev/null +++ b/idf_zacus/components/voice_pipeline/CMakeLists.txt @@ -0,0 +1,30 @@ +idf_component_register( + SRCS + "voice_pipeline.c" + "voice_pipeline_ws.c" + "voice_dispatcher.c" + INCLUDE_DIRS + "include" + PRIV_INCLUDE_DIRS + "." + REQUIRES + esp_driver_i2s + esp_timer + esp_system + esp_event + nvs_flash + freertos + log + media_manager + npc_engine + hints_client + # Slice 6: esp-sr managed component (Espressif AFE + WakeNet9). + # Listed in main/idf_component.yml so the component manager + # pulls it; this REQUIRES line is what lets us include + # esp_afe_*.h / esp_wn_*.h from voice_pipeline.c. + espressif__esp-sr + # Slice 7: WebSocket streaming to the MacStudio voice-bridge, + # plus cJSON for parsing the bridge's text frames. + espressif__esp_websocket_client + json +) diff --git a/idf_zacus/components/voice_pipeline/include/voice_dispatcher.h b/idf_zacus/components/voice_pipeline/include/voice_dispatcher.h new file mode 100644 index 0000000..6f13277 --- /dev/null +++ b/idf_zacus/components/voice_pipeline/include/voice_dispatcher.h @@ -0,0 +1,57 @@ +// voice_dispatcher — slice 8 routing layer between the voice pipeline +// (STT + LLM intent results) and the npc_engine. +// +// Two entry points (one per server-side message type emitted by the +// MacStudio voice-bridge): +// +// * voice_dispatcher_handle_stt(text, final) +// Called from voice_pipeline_ws when the bridge sends +// `{"type":"stt", ...}`. Slice 8 only acts on `final == true` +// transcripts. The text is normalized (lowercase + ASCII fold for +// common French diacritics) and matched against a small keyword +// set ("indice", "aide", "hint", "bloqué", …). On a hit the +// dispatcher fires `npc_engine_request_hint()` directly — the +// hints_client side is already async so this stays non-blocking. +// On a miss the call is logged and dropped: the LLM intent path +// is owned server-side (voice-bridge forwards to /voice/intent +// and pushes the answer back as `{"type":"intent", ...}`). +// +// * voice_dispatcher_handle_intent(text, model) +// Called from voice_pipeline_ws when the bridge sends +// `{"type":"intent", ...}`. Slice 8 only logs the payload and +// fires a best-effort `intent_ack` cue through npc_engine. Real +// intent → action mapping (open puzzle, give hint, etc.) lands +// in a later slice once the scenario engine exposes the active +// puzzle context. +// +// The dispatcher does NOT own a queue or a task: STT callbacks already +// run on the WebSocket event-loop task and `npc_engine_request_hint` +// is itself async (spawns a worker). Direct invocation keeps the slice +// small; introducing a FreeRTOS queue / dispatcher task is a follow-up +// once we need to coalesce or rate-limit. + +#pragma once + +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Initialize the dispatcher. Must be called after `npc_engine_init()` +// (and ideally after `hints_client_init()` so the request_hint path +// reaches the real backend instead of the local stub). Idempotent. +esp_err_t voice_dispatcher_init(void); + +// Handle one STT segment. `text` may be NULL/empty (no-op). `final` +// gates the action: interim transcripts are ignored in slice 8. +void voice_dispatcher_handle_stt(const char *text, bool final); + +// Handle one LLM intent payload. Either argument may be NULL. +void voice_dispatcher_handle_intent(const char *text, const char *model); + +#ifdef __cplusplus +} +#endif diff --git a/idf_zacus/components/voice_pipeline/include/voice_pipeline.h b/idf_zacus/components/voice_pipeline/include/voice_pipeline.h new file mode 100644 index 0000000..794f5a9 --- /dev/null +++ b/idf_zacus/components/voice_pipeline/include/voice_pipeline.h @@ -0,0 +1,183 @@ +// voice_pipeline — I2S capture + ESP-SR (AFE + WakeNet9) pipeline for the +// Zacus NPC voice loop. +// +// Slice 5 deliverable: +// * I2S microphone bring-up (ESP-IDF 5.x i2s_std driver) +// * placeholder VAD/wake-word task that just logs heartbeat +// * state machine idle <-> listening <-> speaking <-> muted +// +// Slice 6 deliverable: +// * managed dependency on `espressif/esp-sr` (>= 2.0) +// * AFE pipeline (`AFE_TYPE_SR`, low-cost mode, 1 mic / no reference) +// wired on top of the existing I2S capture task +// * WakeNet9 wake-word detection. The active model is a placeholder +// standard model shipped by Espressif (default: `wn9_hiesp` = +// "Hi ESP"). Custom "Professeur Zacus" model is out of scope for +// this slice (requires a 2-4 week training round-trip with +// Espressif — tracked under the P2 voice-pipeline spec). +// * wake event routes through a user callback and auto-transitions +// the pipeline into VOICE_STATE_LISTENING. +// +// Slice 7 deliverable: +// * managed dependency on `espressif/esp_websocket_client` (~1.4) +// * after a wake event, post-AFE PCM chunks (16 kHz mono int16) are +// streamed as binary WebSocket frames to the MacStudio voice-bridge +// (`ws://studio:8200/voice/ws`). +// * the AFE VAD output drives end-of-speech detection: ~1.5 s of +// sustained `AFE_VAD_SILENCE` closes the upload with a JSON `end` +// control message and returns the state machine to IDLE. +// * STT transcripts received from the bridge fan out through a new +// `voice_pipeline_set_stt_callback`. The hint/intent dispatch +// itself stays in npc_engine (out of scope for this slice). +// +// Slice 9 deliverable (this revision): +// * TTS playback from the voice-bridge: PCM 16-bit mono @ 24 kHz +// received as binary WebSocket frames between +// `{"type":"speak_start", ...}` and `{"type":"speak_end", ...}`. +// * I2S TX bring-up on I2S_NUM_1 (MAX98357A DAC: BCLK / LRC / DIN). +// Disabled by default — opt-in via `enable_tts_playback`. +// * mute-during-TTS gate: while `state == VOICE_STATE_SPEAKING`, the +// capture task keeps draining I2S input but does NOT feed AFE, +// guaranteeing no wake re-trigger from the speaker echo. +// * state machine: LISTENING → SPEAKING on `speak_start`, +// SPEAKING → IDLE on `speak_end`. +// +// The HTTP plumbing to the hints engine still lives in the separate +// hints_client component to avoid a circular dependency with npc_engine. + +#pragma once + +#include +#include +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + VOICE_STATE_IDLE = 0, + VOICE_STATE_LISTENING, + VOICE_STATE_SPEAKING, + VOICE_STATE_MUTED, +} voice_state_t; + +// Callback invoked from the capture task whenever the wake-word engine +// fires. The pipeline auto-transitions to VOICE_STATE_LISTENING *before* +// invoking the callback, so the callback can safely kick off STT capture +// or play an acknowledgement cue. The string `wake_word` is owned by +// esp-sr (lifetime tied to the AFE handle); if the callback needs to +// keep it, it must copy. +typedef void (*voice_wake_callback_t)(const char *wake_word, void *user_ctx); + +// Callback invoked when the voice-bridge returns a transcription. The +// callback runs on the WebSocket task context (esp_websocket_client's +// event loop) — keep it short and offload heavy work to another task. +// `text` is a NUL-terminated UTF-8 string owned by the pipeline; copy +// before returning if you need to keep it. `final == true` means the +// bridge has flagged the segment as final; `final == false` is an +// interim transcript (may still be revised). +typedef void (*voice_stt_callback_t)(const char *text, bool final, + void *user_ctx); + +typedef struct { + int i2s_bclk_pin; // BCLK (SCK) + int i2s_ws_pin; // WS (LRCK) + int i2s_din_pin; // DIN (mic data) + uint32_t sample_rate_hz; // 16000 default + bool auto_start_capture; // launch capture task at init + bool enable_wake_word; // bring up esp-sr AFE + WakeNet (slice 6) + + // Slice 7: voice-bridge WebSocket endpoint, e.g. + // "ws://100.116.92.12:8200/voice/ws" + // Pass NULL (default) to disable streaming entirely — the wake + // callback still fires but no audio leaves the device. Lifetime of + // the string must outlive `voice_pipeline_init` (typically a static + // `#define` in main.c). + const char *voice_bridge_ws_url; + + // Slice 9: TTS playback configuration. The voice-bridge streams + // back PCM 16-bit mono at 24 kHz between `speak_start` and + // `speak_end`. We render it on I2S_NUM_1 driving a MAX98357A class-D + // DAC (3-pin I2S: BCLK / LRC / DIN). Opt-in to keep slice-7-only + // builds bit-identical. + bool enable_tts_playback; + int i2s_out_bclk_pin; // GPIO11 default (Freenove convention) + int i2s_out_lrc_pin; // GPIO12 default (LRCK / WS) + int i2s_out_din_pin; // GPIO13 default (DIN to DAC) +} voice_pipeline_config_t; + +// Reasonable defaults for a Freenove ESP32-S3 + INMP441 wiring. Override per +// hardware revision before calling voice_pipeline_init(). +// +// Slice 6: `enable_wake_word` defaults to `false` to preserve slice-5 +// behaviour for callers that only want the I2S capture stub. The Zacus +// master sets it to `true` in its own init flow. +// +// Slice 7: `voice_bridge_ws_url` defaults to NULL (streaming disabled). +void voice_pipeline_default_config(voice_pipeline_config_t *out); + +esp_err_t voice_pipeline_init(const voice_pipeline_config_t *config); + +esp_err_t voice_pipeline_start_capture(void); +esp_err_t voice_pipeline_stop_capture(void); + +voice_state_t voice_pipeline_get_state(void); +esp_err_t voice_pipeline_set_state(voice_state_t state); + +// Register the wake-word callback. Pass `cb = NULL` to clear. May be +// called before or after init. The callback runs in the capture task +// context — keep it short and offload heavy work to another task. +esp_err_t voice_pipeline_set_wake_callback(voice_wake_callback_t cb, + void *user_ctx); + +// Register the STT result callback (slice 7). Pass `cb = NULL` to +// clear. May be called before or after init. +esp_err_t voice_pipeline_set_stt_callback(voice_stt_callback_t cb, + void *user_ctx); + +// Returns true once esp-sr AFE + WakeNet have been brought up and the +// pipeline is actively running them in the capture task. Returns false +// if init was called with `enable_wake_word = false`, if the model +// partition was missing, or if AFE/WakeNet alloc failed (in which case +// the pipeline silently degrades to the slice-5 stub capture). +bool voice_pipeline_wake_word_active(void); + +// Slice 7 manual control — open / close the WebSocket stream to the +// voice-bridge without going through the wake detector. Useful for +// REST-driven smoke tests and for forcing capture during dev. Returns +// ESP_ERR_INVALID_STATE if no `voice_bridge_ws_url` was configured. +esp_err_t voice_pipeline_start_streaming(void); +esp_err_t voice_pipeline_stop_streaming(void); + +// True between voice_pipeline_start_streaming and the moment the +// pipeline detected end-of-speech (or stop_streaming was called). +bool voice_pipeline_is_streaming(void); + +// Slice 9 — TTS playback API. Called from the WS layer when the +// voice-bridge announces / streams / closes a `speak_*` exchange. +// +// `voice_pipeline_play_start` reconfigures the I2S TX clock if the +// requested `sample_rate` differs from the current one and enables the +// channel. Transitions the state machine to VOICE_STATE_SPEAKING which +// activates the mute gate (mic input keeps draining I2S but is NOT +// fed to AFE). +// +// `voice_pipeline_play_chunk` writes PCM bytes (16-bit mono LE) to +// the DAC. `len` is in bytes. Blocks up to 100 ms in the I2S DMA +// queue. +// +// `voice_pipeline_play_end` disables the TX channel and returns the +// state machine to VOICE_STATE_IDLE so the next wake can fire. +// +// All three are no-ops (return ESP_ERR_INVALID_STATE) if +// `enable_tts_playback` was false at init. +esp_err_t voice_pipeline_play_start(uint32_t sample_rate, const char *format); +esp_err_t voice_pipeline_play_chunk(const uint8_t *buf, size_t len); +esp_err_t voice_pipeline_play_end(void); + +#ifdef __cplusplus +} +#endif diff --git a/idf_zacus/components/voice_pipeline/voice_dispatcher.c b/idf_zacus/components/voice_pipeline/voice_dispatcher.c new file mode 100644 index 0000000..58695d1 --- /dev/null +++ b/idf_zacus/components/voice_pipeline/voice_dispatcher.c @@ -0,0 +1,380 @@ +// voice_dispatcher — see voice_dispatcher.h for the slice-8 contract. + +#include "voice_dispatcher.h" + +#include +#include +#include +#include +#include + +#include "esp_err.h" +#include "esp_log.h" + +#include "npc_engine.h" + +static const char *TAG = "voice_disp"; + +// Slice 13 keyword fast-path. Matched as case-insensitive ASCII-folded +// substrings of the normalized STT text. Each entry has a `primary` +// label (used in logs) plus a NULL-terminated `aliases` array of short +// variants that absorb common whisper-large-v3-turbo mistranscriptions +// (e.g. "ait" instead of "aide", "bloké" instead of "bloqué"). Each +// alias must already be ASCII-folded — normalize_for_match() runs on +// the input only. Lookup is linear (≤ ~30 short tokens total) and runs +// once per final transcript, so cost is negligible vs. the strstr() +// fallback we already paid before. +// +// Rule of thumb when adding aliases: keep them short (≤ 8 chars), keep +// them post-fold (all lowercase, no diacritics), and keep them as a +// strict superset of the previous slice-8/11 list — anything that +// matched before MUST still match. +typedef struct { + const char *primary; // canonical, surfaced in logs + const char *const *aliases; // NULL-terminated, includes primary +} keyword_entry_t; + +// HINT keywords. Order = scan order. First hit wins (logged). +static const char *const kAliasIndice[] = {"indice", "indices", "endis", "andis", NULL}; +static const char *const kAliasAide[] = {"aide", "aidez", "aidemoi", "ait", NULL}; +static const char *const kAliasHint[] = {"hint", "hints", "ant", NULL}; +static const char *const kAliasBloque[] = {"bloque", "bloke", "bloquai", "blok", "coince", NULL}; +static const char *const kAliasPerdu[] = {"perdu", "perdue", "perds", NULL}; +static const char *const kAliasCommentFaire[] = {"comment faire", "comment fair", "commencer", NULL}; +static const char *const kAliasSaisPas[] = {"sais pas", "sais pa", "saispas", NULL}; + +static const keyword_entry_t kHintKeywords[] = { + {"indice", kAliasIndice}, + {"aide", kAliasAide}, + {"hint", kAliasHint}, + {"bloque", kAliasBloque}, + {"perdu", kAliasPerdu}, + {"comment faire", kAliasCommentFaire}, + {"sais pas", kAliasSaisPas}, + {NULL, NULL}, +}; + +// Slice 11 (P5) failure-signal keywords. These hints map onto the +// hints engine's /attempt_failed lifecycle endpoint (best-effort, +// only updates the failure counter for adaptive escalation). The +// dispatcher fires this BEFORE the hint fast-path so a single +// "non c'est faux, donne-moi un indice" both bumps the counter and +// requests a hint. +static const char *const kAliasNon[] = {"non", "nan", "nope", NULL}; +static const char *const kAliasFaux[] = {"faux", "fausse", "fau", NULL}; +static const char *const kAliasMauvais[] = {"mauvais", "mauvaise", "movais", NULL}; +static const char *const kAliasRate[] = {"rate", "rater", "loupe", NULL}; +static const char *const kAliasMarchePas[] = {"marche pas", "marchepas", "march pas", NULL}; +static const char *const kAliasCaMarchePas[] = {"ca marche pas", "ca march pas", "ca marchepas", NULL}; + +static const keyword_entry_t kFailKeywords[] = { + {"non", kAliasNon}, + {"faux", kAliasFaux}, + {"mauvais", kAliasMauvais}, + {"rate", kAliasRate}, + {"marche pas", kAliasMarchePas}, + {"ca marche pas", kAliasCaMarchePas}, + {NULL, NULL}, +}; + +// level == 0 = let the hints engine pick the escalation level via its +// adaptive policy. See specs/AI_INTEGRATION_SPEC.md. +#define DISPATCHER_DEFAULT_HINT_LEVEL 0 + +static bool s_initialized = false; + +// ── ASCII-fold helpers ────────────────────────────────────────────── +// +// We receive UTF-8 from the bridge. The set of characters we care about +// for FR keyword matching is small; instead of pulling in a full +// Unicode lib we hand-fold the common diacritics we expect to see in +// transcribed French. Anything we don't recognise either passes through +// untouched (ASCII) or gets dropped (multi-byte unknowns). +// +// Mapping table (hex = first/second UTF-8 byte): +// é è ê ë → e (C3 A9 / C3 A8 / C3 AA / C3 AB) +// à â ä → a (C3 A0 / C3 A2 / C3 A4) +// î ï → i (C3 AE / C3 AF) +// ô ö → o (C3 B4 / C3 B6) +// ù û ü → u (C3 B9 / C3 BB / C3 BC) +// ç → c (C3 A7) +// œ → oe (C5 93) +// É È Ê Ë → e (uppercase versions, second byte 0x88..0x8B) +// À Â Ä → a (second byte 0x80 / 0x82 / 0x84) +// Î Ï → i (second byte 0x8E / 0x8F) +// Ô Ö → o (second byte 0x94 / 0x96) +// Ù Û Ü → u (second byte 0x99 / 0x9B / 0x9C) +// Ç → c (second byte 0x87) +// +// Returns the number of source bytes consumed and writes 0..2 bytes to +// `dst`. `*written` is updated. If `dst_remaining < 2` and the fold +// would produce 2 bytes (only "œ"), we drop the char to avoid overflow. +static size_t fold_one_codepoint(const unsigned char *src, size_t src_len, + char *dst, size_t dst_remaining, + size_t *written) { + *written = 0; + if (src_len == 0) return 0; + + unsigned char b0 = src[0]; + + // Pure ASCII fast path. + if (b0 < 0x80) { + if (dst_remaining >= 1) { + dst[0] = (char) tolower((int) b0); + *written = 1; + } + return 1; + } + + // Two-byte UTF-8: 110xxxxx 10xxxxxx — anything else (3/4 byte) is + // skipped (we don't expect emoji or non-Latin in FR transcripts). + if ((b0 & 0xE0) == 0xC0 && src_len >= 2) { + unsigned char b1 = src[1]; + + if (b0 == 0xC3) { + // Latin-1 supplement (most accented FR chars live here). + char folded = '\0'; + switch (b1) { + case 0xA9: case 0xA8: case 0xAA: case 0xAB: // éèêë + case 0x89: case 0x88: case 0x8A: case 0x8B: // ÉÈÊË + folded = 'e'; break; + case 0xA0: case 0xA2: case 0xA4: // àâä + case 0x80: case 0x82: case 0x84: // ÀÂÄ + folded = 'a'; break; + case 0xAE: case 0xAF: // îï + case 0x8E: case 0x8F: // ÎÏ + folded = 'i'; break; + case 0xB4: case 0xB6: // ôö + case 0x94: case 0x96: // ÔÖ + folded = 'o'; break; + case 0xB9: case 0xBB: case 0xBC: // ùûü + case 0x99: case 0x9B: case 0x9C: // ÙÛÜ + folded = 'u'; break; + case 0xA7: case 0x87: // çÇ + folded = 'c'; break; + default: + // Unknown C3 codepoint — drop silently. + break; + } + if (folded && dst_remaining >= 1) { + dst[0] = folded; + *written = 1; + } + return 2; + } + + if (b0 == 0xC5 && b1 == 0x93) { + // œ → oe (only 2-byte fold; needs 2 dst bytes). + if (dst_remaining >= 2) { + dst[0] = 'o'; + dst[1] = 'e'; + *written = 2; + } + return 2; + } + + // Other 2-byte sequences: drop quietly. + return 2; + } + + // 3-byte (1110xxxx) — skip 3. + if ((b0 & 0xF0) == 0xE0 && src_len >= 3) { + return 3; + } + // 4-byte (11110xxx) — skip 4. + if ((b0 & 0xF8) == 0xF0 && src_len >= 4) { + return 4; + } + + // Malformed continuation byte or truncated sequence — skip 1. + return 1; +} + +static void normalize_for_match(const char *src, char *dst, size_t cap) { + if (!dst || cap == 0) return; + dst[0] = '\0'; + if (!src) return; + + const unsigned char *u = (const unsigned char *) src; + size_t src_len = strlen(src); + size_t out = 0; + + while (src_len > 0 && out + 1 < cap) { // reserve 1 for NUL + size_t written = 0; + size_t consumed = fold_one_codepoint( + u, src_len, dst + out, cap - 1 - out, &written); + if (consumed == 0) break; + out += written; + u += consumed; + src_len -= consumed; + } + dst[out] = '\0'; +} + +// Iterate every alias of every entry; first hit wins. Returns the +// matching entry's primary label and the matched alias for logging. +static bool contains_any_alias(const char *normalized, + const keyword_entry_t *table, + const char **primary_out, + const char **alias_out) { + if (!normalized || !*normalized || !table) return false; + for (const keyword_entry_t *e = table; e->primary != NULL; ++e) { + if (!e->aliases) continue; + for (const char *const *a = e->aliases; *a != NULL; ++a) { + if (**a == '\0') continue; + if (strstr(normalized, *a) != NULL) { + if (primary_out) *primary_out = e->primary; + if (alias_out) *alias_out = *a; + return true; + } + } + } + return false; +} + +static bool contains_keyword(const char *normalized) { + const char *primary = NULL; + const char *alias = NULL; + if (contains_any_alias(normalized, kHintKeywords, &primary, &alias)) { + ESP_LOGI(TAG, "hint keyword hit: primary=\"%s\" alias=\"%s\"", + primary, alias); + return true; + } + return false; +} + +static bool contains_failure_signal(const char *normalized) { + const char *primary = NULL; + const char *alias = NULL; + if (contains_any_alias(normalized, kFailKeywords, &primary, &alias)) { + ESP_LOGI(TAG, "failure keyword hit: primary=\"%s\" alias=\"%s\"", + primary, alias); + return true; + } + return false; +} + +static size_t count_entries(const keyword_entry_t *table) { + size_t n = 0; + if (!table) return 0; + for (const keyword_entry_t *e = table; e->primary != NULL; ++e) ++n; + return n; +} + +// ── Hint result callback ──────────────────────────────────────────── +// +// Slice 8 only logs the answer. TTS playback (sending the text back to +// the bridge for synthesis, or playing a pre-baked MP3) lands in slice 9. +static void on_hint_response(uint8_t puzzle_id, uint8_t level, + esp_err_t status, const char *text, + void *user_ctx) { + (void) user_ctx; + if (status != ESP_OK) { + ESP_LOGW(TAG, "HINT failed (puzzle=%u level=%u): %s", + (unsigned) puzzle_id, (unsigned) level, + esp_err_to_name(status)); + return; + } + ESP_LOGI(TAG, "HINT received (puzzle=%u level=%u): %s", + (unsigned) puzzle_id, (unsigned) level, + text ? text : "(empty)"); +} + +// ── Public API ────────────────────────────────────────────────────── + +esp_err_t voice_dispatcher_init(void) { + if (s_initialized) return ESP_OK; + s_initialized = true; + ESP_LOGI(TAG, "voice_dispatcher ready (%u hint / %u fail FR keywords)", + (unsigned) count_entries(kHintKeywords), + (unsigned) count_entries(kFailKeywords)); + return ESP_OK; +} + +void voice_dispatcher_handle_stt(const char *text, bool final) { + if (!s_initialized) { + ESP_LOGW(TAG, "stt before init: dropping"); + return; + } + if (!text || !*text) return; + + if (!final) { + // Interim transcript. Slice 8 only acts on finals. + ESP_LOGD(TAG, "stt interim: \"%s\"", text); + return; + } + + // Normalize once into a stack buffer; matching uses substr scan. + // 256 bytes covers ~120 chars of folded FR comfortably (NPC turns + // are short by design). + char folded[256]; + normalize_for_match(text, folded, sizeof(folded)); + ESP_LOGI(TAG, "stt final raw=\"%s\" folded=\"%s\"", text, folded); + + // Slice 11 (P5): bump the failure counter on a clear "non/faux/..." + // signal BEFORE the hint fast-path, so the hints engine's adaptive + // policy sees the incremented count when picking the level. This is + // a coarse heuristic — wiring real input validators (puzzle engines + // reporting wrong codes / wrong gestures) is the proper hook. + // TODO(slice-12): replace the keyword heuristic with explicit input + // validation hooks from the scenario / puzzle engines. + if (contains_failure_signal(folded)) { + const npc_state_t *st = npc_engine_state(); + uint8_t scene = st ? st->current_scene : 0xFF; + esp_err_t fa_err = npc_engine_report_failed_attempt(scene); + if (fa_err != ESP_OK) { + ESP_LOGD(TAG, "report_failed_attempt: %s", + esp_err_to_name(fa_err)); + } + } + + if (!contains_keyword(folded)) { + ESP_LOGI(TAG, "no keyword match, deferring to LLM intent path"); + return; + } + + // Slice 11 (P5): resolve the active puzzle id from npc_engine + // (e.g. "SCENE_LA_DETECTOR") so the hints engine can pick a + // contextual answer. Empty/unknown scenes fall back to "SCENE_NPC" + // (handled inside npc_engine_current_puzzle_id). + char puzzle_id[64] = {0}; + npc_engine_current_puzzle_id(puzzle_id, sizeof(puzzle_id)); + + // npc_engine_request_hint takes a numeric puzzle id; passing the + // current scene index keeps that layer in sync with the string id + // we just resolved (npc_engine maps both back to kSceneIds[]). + const npc_state_t *st = npc_engine_state(); + uint8_t puzzle_num = st ? st->current_scene : 0; + + esp_err_t err = npc_engine_request_hint( + puzzle_num, + DISPATCHER_DEFAULT_HINT_LEVEL, + on_hint_response, + NULL); + if (err != ESP_OK) { + ESP_LOGW(TAG, "npc_engine_request_hint: %s", esp_err_to_name(err)); + } else { + ESP_LOGI(TAG, "hint request dispatched (puzzle=\"%s\" num=%u level=%u)", + puzzle_id, (unsigned) puzzle_num, + (unsigned) DISPATCHER_DEFAULT_HINT_LEVEL); + } +} + +void voice_dispatcher_handle_intent(const char *text, const char *model) { + if (!s_initialized) { + ESP_LOGW(TAG, "intent before init: dropping"); + return; + } + ESP_LOGI(TAG, "INTENT received (model=%s): %s", + model && *model ? model : "?", + text && *text ? text : "(empty)"); + + // Best-effort acknowledgement cue. The cue id is a convention — + // when no entry exists in the npc_engine cue table, the engine + // tries the id as a literal media path; failures are logged but + // non-fatal. + esp_err_t err = npc_engine_trigger_cue("intent_ack"); + if (err != ESP_OK && err != ESP_ERR_NOT_FOUND) { + ESP_LOGD(TAG, "intent_ack cue: %s", esp_err_to_name(err)); + } +} diff --git a/idf_zacus/components/voice_pipeline/voice_pipeline.c b/idf_zacus/components/voice_pipeline/voice_pipeline.c new file mode 100644 index 0000000..7a220b3 --- /dev/null +++ b/idf_zacus/components/voice_pipeline/voice_pipeline.c @@ -0,0 +1,663 @@ +// voice_pipeline — ESP-IDF implementation. See voice_pipeline.h for the +// scope of slices 5, 6 and 7. The AFE / WakeNet integration is gated on +// `cfg.enable_wake_word`; if init fails (PSRAM exhausted, model +// partition absent, etc.) we log + degrade silently to the slice-5 +// I2S-only capture path so the rest of the firmware still boots. + +#include "voice_pipeline.h" +#include "voice_pipeline_ws.h" + +#include + +#include "driver/i2s_std.h" +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "esp_mac.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "esp_afe_config.h" +#include "esp_afe_sr_iface.h" +#include "esp_afe_sr_models.h" +#include "esp_wn_iface.h" +#include "esp_wn_models.h" +#include "model_path.h" + +static const char *TAG = "voice_pipeline"; + +// Slice 6 placeholder wake word. Standard Espressif WakeNet9 model +// shipped under permissive license — no commercial agreement required. +// Custom "Professeur Zacus" model lands later (see voice spec P4). +#define VOICE_DEFAULT_WAKE_WORD_NAME "wn9_hiesp" + +// Partition holding srmodels.bin (added in partitions.csv as a 1 MB +// SPIFFS region). Must match the partition `Name` column. +#define VOICE_SR_MODEL_PARTITION "model" + +#define CAPTURE_TASK_STACK 8192 +#define CAPTURE_TASK_PRIO 5 +#define CAPTURE_CHUNK_BYTES 1024 // fallback (slice-5 path) — 16-bit @16 kHz = 32 ms slice + +// Slice 7: end-of-utterance detection. AFE feed chunk size at 16 kHz / +// AFE_MODE_LOW_COST is typically 512 samples = 32 ms. 50 consecutive +// silence chunks ≈ 1.5 s of silence, which matches the spec's +// "sustained ~1.5 s" end-of-speech criterion. A safety cap stops a +// runaway stream after ~10 s of audio so a stuck VAD never blocks the +// pipeline forever. +#define END_SILENCE_CHUNKS 50 +#define STREAMING_MAX_CHUNKS (16000 * 10 / 512) // ~10 s + +static struct { + bool ready; + voice_pipeline_config_t cfg; + i2s_chan_handle_t rx_chan; + // Slice 9: I2S TX channel for TTS playback (MAX98357A DAC). NULL + // if `enable_tts_playback == false` or alloc/init failed. + i2s_chan_handle_t tx_chan; + bool tx_enabled; // channel currently enabled + uint32_t tx_sample_rate; // current configured rate + voice_state_t state; + TaskHandle_t capture_task; + bool capture_run; + + // Wake-word callback (set independently of init, may be NULL). + voice_wake_callback_t wake_cb; + void *wake_cb_ctx; + + // ESP-SR handles. NULL if wake-word not enabled or alloc failed — + // capture_task uses the dumb I2S read path in that case. + srmodel_list_t *sr_models; + const esp_afe_sr_iface_t *afe_iface; + esp_afe_sr_data_t *afe_data; + char wake_word_name[32]; + int afe_feed_chunk_samples; // per-channel + int afe_feed_channel_num; // mic + ref + int afe_fetch_chunk_samples; // post-AFE, what we stream + + // Slice 7: streaming state. `stream_active` mirrors voice_ws_is_streaming + // but is owned by the capture task so we don't race with the WS event + // loop on transitions. `silence_chunks` counts sustained AFE_VAD_SILENCE + // fetches; reaching VAD_SILENCE_CHUNKS_TO_END closes the upload. + bool stream_active; + uint32_t silence_chunks; + uint32_t streamed_chunks; + char session_id[32]; +} s_pipe = { + .state = VOICE_STATE_IDLE, +}; + +void voice_pipeline_default_config(voice_pipeline_config_t *out) { + if (!out) return; + out->i2s_bclk_pin = 14; // GPIO14 -> SCK + out->i2s_ws_pin = 15; // GPIO15 -> WS + out->i2s_din_pin = 22; // GPIO22 -> SD (INMP441) + out->sample_rate_hz = 16000; + out->auto_start_capture = false; + out->enable_wake_word = false; + out->voice_bridge_ws_url = NULL; + // Slice 9: TTS playback defaults — disabled. Pinout follows the + // suggested Freenove ESP32-S3 convention: BCLK=11, LRC=12, DIN=13. + // Confirm at flash time before driving the DAC. + out->enable_tts_playback = false; + out->i2s_out_bclk_pin = 11; + out->i2s_out_lrc_pin = 12; + out->i2s_out_din_pin = 13; +} + +bool voice_pipeline_wake_word_active(void) { + return (s_pipe.afe_iface != NULL && s_pipe.afe_data != NULL); +} + +esp_err_t voice_pipeline_set_wake_callback(voice_wake_callback_t cb, + void *user_ctx) { + s_pipe.wake_cb = cb; + s_pipe.wake_cb_ctx = user_ctx; + return ESP_OK; +} + +esp_err_t voice_pipeline_set_stt_callback(voice_stt_callback_t cb, + void *user_ctx) { + voice_ws_set_stt_callback(cb, user_ctx); + return ESP_OK; +} + +bool voice_pipeline_is_streaming(void) { + return s_pipe.stream_active; +} + +static void session_id_init(void) { + uint8_t mac[6] = {0}; + if (esp_efuse_mac_get_default(mac) == ESP_OK) { + snprintf(s_pipe.session_id, sizeof(s_pipe.session_id), + "%02x%02x%02x%02x%02x%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + } else { + strncpy(s_pipe.session_id, "unknown", + sizeof(s_pipe.session_id) - 1); + s_pipe.session_id[sizeof(s_pipe.session_id) - 1] = '\0'; + } +} + +// Open the WebSocket lazily on the first wake (or on +// voice_pipeline_start_streaming). Returns ESP_OK on success or if the +// stream is already open. +static esp_err_t streaming_begin(void) { + if (s_pipe.stream_active) return ESP_OK; + if (!voice_ws_is_configured()) return ESP_ERR_INVALID_STATE; + + esp_err_t err = voice_ws_open_streaming(); + if (err != ESP_OK) { + ESP_LOGW(TAG, "voice_ws_open_streaming failed: %s", + esp_err_to_name(err)); + return err; + } + s_pipe.stream_active = true; + s_pipe.silence_chunks = 0; + s_pipe.streamed_chunks = 0; + ESP_LOGI(TAG, "streaming started"); + return ESP_OK; +} + +static void streaming_end(const char *reason) { + if (!s_pipe.stream_active) return; + ESP_LOGI(TAG, "streaming end (%s) — sent %u chunks, silence=%u", + reason ? reason : "?", + (unsigned) s_pipe.streamed_chunks, + (unsigned) s_pipe.silence_chunks); + voice_ws_close_streaming(); + s_pipe.stream_active = false; + s_pipe.silence_chunks = 0; + s_pipe.streamed_chunks = 0; + voice_pipeline_set_state(VOICE_STATE_IDLE); +} + +esp_err_t voice_pipeline_start_streaming(void) { + if (!s_pipe.ready) return ESP_ERR_INVALID_STATE; + if (!voice_ws_is_configured()) { + ESP_LOGW(TAG, "start_streaming: no voice_bridge_ws_url configured"); + return ESP_ERR_INVALID_STATE; + } + voice_pipeline_set_state(VOICE_STATE_LISTENING); + return streaming_begin(); +} + +esp_err_t voice_pipeline_stop_streaming(void) { + streaming_end("manual_stop"); + return ESP_OK; +} + +static esp_err_t i2s_setup(void) { + i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER); + esp_err_t err = i2s_new_channel(&chan_cfg, NULL, &s_pipe.rx_chan); + if (err != ESP_OK) return err; + + i2s_std_config_t std_cfg = { + .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(s_pipe.cfg.sample_rate_hz), + .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, + I2S_SLOT_MODE_MONO), + .gpio_cfg = { + .mclk = I2S_GPIO_UNUSED, + .bclk = s_pipe.cfg.i2s_bclk_pin, + .ws = s_pipe.cfg.i2s_ws_pin, + .dout = I2S_GPIO_UNUSED, + .din = s_pipe.cfg.i2s_din_pin, + .invert_flags = { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false, + }, + }, + }; + err = i2s_channel_init_std_mode(s_pipe.rx_chan, &std_cfg); + if (err != ESP_OK) { + i2s_del_channel(s_pipe.rx_chan); + s_pipe.rx_chan = NULL; + return err; + } + return ESP_OK; +} + +// Slice 9: bring up the I2S TX channel for TTS playback on a separate +// I2S port (I2S_NUM_1) so the mic capture on I2S_NUM_0 keeps running +// untouched. Configures Philips std mode, mono, 16-bit, at the +// pipeline's default sample rate (typically 16 kHz). The actual TTS +// rate may differ (Piper f5_tts ≈ 24 kHz) — voice_pipeline_play_start +// reconfigures the clock on the fly when needed. +static esp_err_t i2s_tx_setup(void) { + i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_1, I2S_ROLE_MASTER); + esp_err_t err = i2s_new_channel(&chan_cfg, &s_pipe.tx_chan, NULL); + if (err != ESP_OK) return err; + + // Initial clock = same as mic; will be reconfigured at play_start + // if the bridge announces a different rate. + s_pipe.tx_sample_rate = s_pipe.cfg.sample_rate_hz; + + i2s_std_config_t std_cfg = { + .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(s_pipe.tx_sample_rate), + .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, + I2S_SLOT_MODE_MONO), + .gpio_cfg = { + .mclk = I2S_GPIO_UNUSED, + .bclk = s_pipe.cfg.i2s_out_bclk_pin, + .ws = s_pipe.cfg.i2s_out_lrc_pin, + .dout = s_pipe.cfg.i2s_out_din_pin, + .din = I2S_GPIO_UNUSED, + .invert_flags = { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false, + }, + }, + }; + err = i2s_channel_init_std_mode(s_pipe.tx_chan, &std_cfg); + if (err != ESP_OK) { + i2s_del_channel(s_pipe.tx_chan); + s_pipe.tx_chan = NULL; + return err; + } + s_pipe.tx_enabled = false; + return ESP_OK; +} + +// Bring up esp-sr AFE + WakeNet. Returns ESP_OK on success. On failure +// the caller logs and falls back to the dumb I2S capture path. +static esp_err_t wake_word_setup(void) { + s_pipe.sr_models = esp_srmodel_init(VOICE_SR_MODEL_PARTITION); + if (!s_pipe.sr_models || s_pipe.sr_models->num <= 0) { + ESP_LOGW(TAG, "esp_srmodel_init('%s') returned no models — " + "wake word disabled (check srmodels.bin flashed)", + VOICE_SR_MODEL_PARTITION); + return ESP_ERR_NOT_FOUND; + } + ESP_LOGI(TAG, "esp-sr loaded %d model(s) from partition '%s'", + s_pipe.sr_models->num, VOICE_SR_MODEL_PARTITION); + for (int i = 0; i < s_pipe.sr_models->num; i++) { + ESP_LOGI(TAG, " model[%d] = %s", i, s_pipe.sr_models->model_name[i]); + } + + char *wn_name = esp_srmodel_filter(s_pipe.sr_models, ESP_WN_PREFIX, NULL); + if (!wn_name) { + ESP_LOGW(TAG, "no WakeNet model found in partition — wake disabled"); + return ESP_ERR_NOT_FOUND; + } + strncpy(s_pipe.wake_word_name, wn_name, sizeof(s_pipe.wake_word_name) - 1); + s_pipe.wake_word_name[sizeof(s_pipe.wake_word_name) - 1] = '\0'; + ESP_LOGI(TAG, "selected wake model = %s (placeholder, slice 6)", + s_pipe.wake_word_name); + + // Single mic, no reference channel: input format "M" (one micropohne). + afe_config_t *afe_cfg = afe_config_init("M", s_pipe.sr_models, + AFE_TYPE_SR, AFE_MODE_LOW_COST); + if (!afe_cfg) { + ESP_LOGW(TAG, "afe_config_init returned NULL"); + return ESP_FAIL; + } + // Force the wake model we just discovered, in case the default + // selection logic picks something we don't want. + afe_cfg->wakenet_init = true; + afe_cfg->wakenet_model_name = s_pipe.wake_word_name; + // 1-mic SR: AEC needs a reference channel we don't have, SE (BSS) + // needs >= 2 mics. Disable both, keep NS + VAD + AGC. + afe_cfg->aec_init = false; + afe_cfg->se_init = false; + afe_cfg->memory_alloc_mode = AFE_MEMORY_ALLOC_MORE_PSRAM; + + s_pipe.afe_iface = esp_afe_handle_from_config(afe_cfg); + if (!s_pipe.afe_iface) { + ESP_LOGW(TAG, "esp_afe_handle_from_config returned NULL"); + afe_config_free(afe_cfg); + return ESP_FAIL; + } + s_pipe.afe_data = s_pipe.afe_iface->create_from_config(afe_cfg); + afe_config_free(afe_cfg); + if (!s_pipe.afe_data) { + ESP_LOGW(TAG, "AFE create_from_config failed (likely OOM in PSRAM)"); + s_pipe.afe_iface = NULL; + return ESP_ERR_NO_MEM; + } + + s_pipe.afe_feed_chunk_samples = s_pipe.afe_iface->get_feed_chunksize(s_pipe.afe_data); + s_pipe.afe_feed_channel_num = s_pipe.afe_iface->get_feed_channel_num(s_pipe.afe_data); + s_pipe.afe_fetch_chunk_samples = s_pipe.afe_iface->get_fetch_chunksize(s_pipe.afe_data); + + ESP_LOGI(TAG, "AFE up: feed_chunk=%d samples × %d ch, fetch_chunk=%d, sample_rate=%d Hz", + s_pipe.afe_feed_chunk_samples, + s_pipe.afe_feed_channel_num, + s_pipe.afe_fetch_chunk_samples, + s_pipe.afe_iface->get_samp_rate(s_pipe.afe_data)); + if (s_pipe.afe_iface->print_pipeline) { + s_pipe.afe_iface->print_pipeline(s_pipe.afe_data); + } + return ESP_OK; +} + +static void wake_word_teardown(void) { + if (s_pipe.afe_data && s_pipe.afe_iface && s_pipe.afe_iface->destroy) { + s_pipe.afe_iface->destroy(s_pipe.afe_data); + } + s_pipe.afe_data = NULL; + s_pipe.afe_iface = NULL; + if (s_pipe.sr_models) { + esp_srmodel_deinit(s_pipe.sr_models); + s_pipe.sr_models = NULL; + } +} + +// Capture task. Two modes: +// * AFE active (esp-sr loaded) : feed I2S into AFE, fetch results, +// detect wake → fire callback, +// stream post-AFE PCM until VAD silence. +// * AFE inactive (slice-5 stub) : log a heartbeat every ~1.6 s. +static void capture_task(void *pv) { + if (voice_pipeline_wake_word_active()) { + const int chunk_samples = s_pipe.afe_feed_chunk_samples; + const int chunk_channels = s_pipe.afe_feed_channel_num; + const size_t feed_bytes = (size_t) chunk_samples * chunk_channels * sizeof(int16_t); + + int16_t *feed_buf = heap_caps_malloc(feed_bytes, + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!feed_buf) { + ESP_LOGE(TAG, "PSRAM alloc for AFE feed buffer failed (%u B) — " + "stopping capture", (unsigned) feed_bytes); + s_pipe.capture_task = NULL; + vTaskDelete(NULL); + return; + } + + ESP_LOGI(TAG, "capture_task: AFE mode — chunk=%d samples × %d ch (%u B)", + chunk_samples, chunk_channels, (unsigned) feed_bytes); + + size_t bytes_read = 0; + uint32_t feeds = 0; + while (s_pipe.capture_run) { + // INMP441 mono I2S: read directly into the feed buffer (1 ch). + // If chunk_channels > 1 (e.g. with reference), this would need + // interleaving — slice-6 single-mic path keeps it simple. + esp_err_t err = i2s_channel_read(s_pipe.rx_chan, feed_buf, + feed_bytes, &bytes_read, + pdMS_TO_TICKS(200)); + if (err == ESP_ERR_TIMEOUT) { + continue; + } + if (err != ESP_OK) { + ESP_LOGW(TAG, "i2s read err: %s", esp_err_to_name(err)); + vTaskDelay(pdMS_TO_TICKS(200)); + continue; + } + if (bytes_read == 0) continue; + + // Slice 9: mute-during-TTS gate. While the pipeline is in + // SPEAKING state we keep draining I2S so the DMA buffers + // don't overflow, but we do NOT feed AFE — this prevents + // the speaker output (which leaks back into the mic) from + // re-triggering the wake word during a TTS reply. + if (s_pipe.state == VOICE_STATE_SPEAKING) continue; + + s_pipe.afe_iface->feed(s_pipe.afe_data, feed_buf); + + // Drain anything available without blocking the feed cadence. + afe_fetch_result_t *res = s_pipe.afe_iface->fetch_with_delay( + s_pipe.afe_data, 0); + if (res && res->ret_value == ESP_OK) { + if (res->wakeup_state == WAKENET_DETECTED) { + const char *word = s_pipe.wake_word_name; + ESP_LOGI(TAG, "WAKE detected: word=%s vol=%.1fdB chan=%d", + word, res->data_volume, res->trigger_channel_id); + voice_pipeline_set_state(VOICE_STATE_LISTENING); + if (s_pipe.wake_cb) { + s_pipe.wake_cb(word, s_pipe.wake_cb_ctx); + } + // Slice 7: open the WS stream as soon as the wake + // fires so the player's first words make it across. + if (voice_ws_is_configured() && !s_pipe.stream_active) { + if (streaming_begin() != ESP_OK) { + ESP_LOGW(TAG, "streaming_begin failed at wake — " + "returning to IDLE"); + voice_pipeline_set_state(VOICE_STATE_IDLE); + } + } + } + + // Slice 7: while streaming, push the post-AFE PCM out + // and watch the VAD for end-of-speech. `res->data` is + // the cleaned, single-channel int16 buffer of length + // `afe_fetch_chunk_samples`. + if (s_pipe.stream_active && res->data && res->data_size > 0) { + esp_err_t serr = voice_ws_send_chunk( + res->data, res->data_size / sizeof(int16_t)); + if (serr != ESP_OK) { + ESP_LOGW(TAG, "send_chunk err %s — closing stream", + esp_err_to_name(serr)); + streaming_end("send_error"); + } else { + s_pipe.streamed_chunks++; + + // res->vad_state is a `vad_state_t` (VAD_SILENCE = 0, + // VAD_SPEECH = 1). The legacy `AFE_VAD_*` enum is + // marked deprecated in esp_afe_sr_iface.h. + if (res->vad_state == VAD_SILENCE) { + s_pipe.silence_chunks++; + } else { + s_pipe.silence_chunks = 0; + } + + if (s_pipe.silence_chunks >= END_SILENCE_CHUNKS) { + streaming_end("vad_silence"); + } else if (s_pipe.streamed_chunks >= STREAMING_MAX_CHUNKS) { + streaming_end("max_duration"); + } + } + } + } + + if (++feeds % 100 == 0) { + ESP_LOGD(TAG, "AFE feed heartbeat: %u chunks", (unsigned) feeds); + } + } + // Make sure we don't leak an open WS if capture is being torn down. + if (s_pipe.stream_active) { + streaming_end("capture_stop"); + } + free(feed_buf); + } else { + // Slice-5 fallback: dumb capture, no detection. + static uint8_t buf[CAPTURE_CHUNK_BYTES]; + size_t bytes_read = 0; + uint32_t total = 0; + uint32_t ticks = 0; + ESP_LOGI(TAG, "capture_task: stub mode (no esp-sr)"); + while (s_pipe.capture_run) { + esp_err_t err = i2s_channel_read(s_pipe.rx_chan, buf, sizeof(buf), + &bytes_read, pdMS_TO_TICKS(100)); + if (err == ESP_OK) { + total += bytes_read; + if (++ticks % 50 == 0) { + ESP_LOGI(TAG, "capture heartbeat: %u bytes total", + (unsigned) total); + } + } else if (err != ESP_ERR_TIMEOUT) { + ESP_LOGW(TAG, "i2s read err: %s", esp_err_to_name(err)); + vTaskDelay(pdMS_TO_TICKS(200)); + } + } + } + s_pipe.capture_task = NULL; + vTaskDelete(NULL); +} + +esp_err_t voice_pipeline_init(const voice_pipeline_config_t *config) { + if (s_pipe.ready) return ESP_OK; + voice_pipeline_config_t cfg; + if (config) { + cfg = *config; + } else { + voice_pipeline_default_config(&cfg); + } + s_pipe.cfg = cfg; + + session_id_init(); + + esp_err_t err = i2s_setup(); + if (err != ESP_OK) { + ESP_LOGW(TAG, "i2s setup failed: %s — staying idle without capture", + esp_err_to_name(err)); + // Don't fail init: we still want the state machine to be usable so + // the rest of the system (npc_engine, REST surface) can wire calls. + s_pipe.ready = true; + s_pipe.state = VOICE_STATE_IDLE; + return ESP_OK; + } + + // Slice 9: optional I2S TX bring-up for TTS playback. Failure is + // non-fatal — the rest of the voice loop still works. + if (cfg.enable_tts_playback) { + esp_err_t te = i2s_tx_setup(); + if (te != ESP_OK) { + ESP_LOGW(TAG, "i2s_tx_setup failed: %s — TTS playback disabled", + esp_err_to_name(te)); + } else { + ESP_LOGI(TAG, "I2S TX ready (BCLK=%d LRC=%d DIN=%d) — TTS enabled", + cfg.i2s_out_bclk_pin, cfg.i2s_out_lrc_pin, + cfg.i2s_out_din_pin); + } + } + + if (cfg.enable_wake_word) { + esp_err_t we = wake_word_setup(); + if (we != ESP_OK) { + ESP_LOGW(TAG, "wake_word_setup failed: %s — degrading to stub capture", + esp_err_to_name(we)); + // Don't fail init — the rest of the firmware should still + // come up. The capture task will run in slice-5 stub mode. + wake_word_teardown(); + } + } + + // Slice 7: configure the WS layer if the caller provided a URL. + // The actual connection is opened lazily on the first wake (or + // on voice_pipeline_start_streaming). + if (cfg.voice_bridge_ws_url && *cfg.voice_bridge_ws_url) { + esp_err_t wsc = voice_ws_configure(cfg.voice_bridge_ws_url, + s_pipe.session_id, + cfg.sample_rate_hz); + if (wsc != ESP_OK) { + ESP_LOGW(TAG, "voice_ws_configure failed: %s — streaming disabled", + esp_err_to_name(wsc)); + } else { + ESP_LOGI(TAG, "voice-bridge streaming wired: %s", + cfg.voice_bridge_ws_url); + } + } + + s_pipe.ready = true; + s_pipe.state = VOICE_STATE_IDLE; + ESP_LOGI(TAG, "ready (BCLK=%d WS=%d DIN=%d @%u Hz, wake=%s, stream=%s)", + cfg.i2s_bclk_pin, cfg.i2s_ws_pin, cfg.i2s_din_pin, + (unsigned) cfg.sample_rate_hz, + voice_pipeline_wake_word_active() ? s_pipe.wake_word_name : "off", + voice_ws_is_configured() ? "on" : "off"); + + if (cfg.auto_start_capture) { + return voice_pipeline_start_capture(); + } + return ESP_OK; +} + +esp_err_t voice_pipeline_start_capture(void) { + if (!s_pipe.ready) return ESP_ERR_INVALID_STATE; + if (!s_pipe.rx_chan) return ESP_ERR_INVALID_STATE; + if (s_pipe.capture_task) return ESP_OK; // already running + esp_err_t err = i2s_channel_enable(s_pipe.rx_chan); + if (err != ESP_OK) return err; + s_pipe.capture_run = true; + if (xTaskCreate(capture_task, "voice_capture", CAPTURE_TASK_STACK, NULL, + CAPTURE_TASK_PRIO, &s_pipe.capture_task) != pdPASS) { + s_pipe.capture_run = false; + i2s_channel_disable(s_pipe.rx_chan); + return ESP_ERR_NO_MEM; + } + voice_pipeline_set_state(VOICE_STATE_LISTENING); + return ESP_OK; +} + +esp_err_t voice_pipeline_stop_capture(void) { + if (!s_pipe.ready) return ESP_ERR_INVALID_STATE; + if (!s_pipe.capture_task) return ESP_OK; + s_pipe.capture_run = false; + // Task observes the flag and self-deletes on its next tick. + if (s_pipe.rx_chan) { + i2s_channel_disable(s_pipe.rx_chan); + } + voice_pipeline_set_state(VOICE_STATE_IDLE); + return ESP_OK; +} + +voice_state_t voice_pipeline_get_state(void) { + return s_pipe.state; +} + +esp_err_t voice_pipeline_set_state(voice_state_t state) { + s_pipe.state = state; + return ESP_OK; +} + +// ── Slice 9: TTS playback over I2S TX (I2S_NUM_1) ──────────────────────────── + +esp_err_t voice_pipeline_play_start(uint32_t sample_rate, const char *format) { + if (!s_pipe.ready) return ESP_ERR_INVALID_STATE; + if (!s_pipe.tx_chan) { + ESP_LOGW(TAG, "play_start: no TX channel (enable_tts_playback=false?)"); + return ESP_ERR_INVALID_STATE; + } + // Reconfigure the I2S clock if the bridge announces a different rate. + // F5-TTS produces 24 kHz; the mic side runs at 16 kHz by default. + if (sample_rate != 0 && sample_rate != s_pipe.tx_sample_rate) { + if (s_pipe.tx_enabled) { + i2s_channel_disable(s_pipe.tx_chan); + s_pipe.tx_enabled = false; + } + i2s_std_clk_config_t clk = I2S_STD_CLK_DEFAULT_CONFIG(sample_rate); + esp_err_t err = i2s_channel_reconfig_std_clock(s_pipe.tx_chan, &clk); + if (err != ESP_OK) { + ESP_LOGW(TAG, "play_start: clk reconfig %u Hz failed: %s", + (unsigned) sample_rate, esp_err_to_name(err)); + return err; + } + s_pipe.tx_sample_rate = sample_rate; + } + if (!s_pipe.tx_enabled) { + esp_err_t err = i2s_channel_enable(s_pipe.tx_chan); + if (err != ESP_OK) return err; + s_pipe.tx_enabled = true; + } + voice_pipeline_set_state(VOICE_STATE_SPEAKING); + ESP_LOGI(TAG, "play_start: sr=%u format=%s", + (unsigned) sample_rate, format ? format : "(null)"); + return ESP_OK; +} + +esp_err_t voice_pipeline_play_chunk(const uint8_t *buf, size_t len) { + if (!s_pipe.ready || !s_pipe.tx_chan || !s_pipe.tx_enabled) { + return ESP_ERR_INVALID_STATE; + } + if (!buf || len == 0) return ESP_ERR_INVALID_ARG; + size_t written = 0; + esp_err_t err = i2s_channel_write(s_pipe.tx_chan, buf, len, &written, + pdMS_TO_TICKS(100)); + if (err != ESP_OK) { + ESP_LOGW(TAG, "play_chunk: i2s write err=%s wrote=%u/%u", + esp_err_to_name(err), (unsigned) written, (unsigned) len); + } + return err; +} + +esp_err_t voice_pipeline_play_end(void) { + if (!s_pipe.ready) return ESP_ERR_INVALID_STATE; + if (s_pipe.tx_chan && s_pipe.tx_enabled) { + i2s_channel_disable(s_pipe.tx_chan); + s_pipe.tx_enabled = false; + } + voice_pipeline_set_state(VOICE_STATE_IDLE); + ESP_LOGI(TAG, "play_end"); + return ESP_OK; +} diff --git a/idf_zacus/components/voice_pipeline/voice_pipeline_ws.c b/idf_zacus/components/voice_pipeline/voice_pipeline_ws.c new file mode 100644 index 0000000..8cdeb7e --- /dev/null +++ b/idf_zacus/components/voice_pipeline/voice_pipeline_ws.c @@ -0,0 +1,348 @@ +// voice_pipeline_ws — WebSocket streaming layer for the Zacus voice +// pipeline. Talks to the MacStudio voice-bridge: +// +// client → server (text, on connect): +// {"type":"hello","version":1,"sample_rate":16000, +// "format":"pcm_s16","session_id":""} +// +// client → server (binary, while streaming): +// raw little-endian int16 PCM mono frames (typically ~32 ms each, +// i.e. 512 samples / 1024 bytes — sized by the AFE feed chunk). +// +// client → server (text, end of utterance): +// {"type":"end"} +// +// server → client (text): +// {"type":"stt","text":"...","final":true|false} +// {"type":"intent","content":"...","model":"..."} +// {"type":"error","message":"..."} +// +// This slice (7) only consumes `stt` (forwarded to the user callback) +// and logs `intent` / `error`. Acting on intents and replaying TTS +// audio land in subsequent slices. + +#include "voice_pipeline_ws.h" + +#include +#include +#include + +#include "cJSON.h" +#include "esp_log.h" +#include "esp_websocket_client.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" + +// Slice 8: route final STT + LLM intent payloads into npc_engine via +// the small dispatcher layer. The user-supplied stt_cb still fires in +// parallel for back-compat (main.c keeps logging it). +#include "voice_dispatcher.h" +#include "voice_pipeline.h" + +static const char *TAG = "voice_ws"; + +#define WS_CONNECT_TIMEOUT_MS 5000 +#define WS_NETWORK_TIMEOUT_MS 5000 +#define WS_HELLO_BUF_LEN 192 +#define WS_RX_BUFFER_BYTES 1024 +#define WS_TASK_STACK_BYTES 6144 + +// Event group bits for the connect handshake. +#define WS_BIT_CONNECTED BIT0 +#define WS_BIT_FAILED BIT1 + +static struct { + bool configured; + char url[160]; + char session_id[32]; + uint32_t sample_rate_hz; + + voice_stt_callback_t stt_cb; + void *stt_cb_ctx; + + esp_websocket_client_handle_t client; + EventGroupHandle_t ev; + bool streaming; + // Slice 9b: track whether the bridge announced a speak_* sequence. + // We forward incoming binary frames to voice_pipeline_play_chunk() + // only between speak_start and speak_end. + bool in_speak; +} s_ws = {0}; + +static void handle_text_message(const char *data, int len) { + cJSON *root = cJSON_ParseWithLength(data, (size_t) len); + if (!root) { + ESP_LOGW(TAG, "rx: malformed JSON (%d bytes): %.*s", + len, len > 64 ? 64 : len, data); + return; + } + const cJSON *type = cJSON_GetObjectItemCaseSensitive(root, "type"); + if (!cJSON_IsString(type) || !type->valuestring) { + cJSON_Delete(root); + return; + } + + if (strcmp(type->valuestring, "stt") == 0) { + const cJSON *text = cJSON_GetObjectItemCaseSensitive(root, "text"); + const cJSON *final = cJSON_GetObjectItemCaseSensitive(root, "final"); + bool is_final = cJSON_IsBool(final) ? cJSON_IsTrue(final) : false; + if (cJSON_IsString(text) && text->valuestring) { + ESP_LOGI(TAG, "stt(final=%d): %s", is_final, text->valuestring); + // Slice 8: keyword fast-path → npc_engine_request_hint(). + // The legacy user callback still fires below for callers + // that want their own logging / instrumentation. + voice_dispatcher_handle_stt(text->valuestring, is_final); + if (s_ws.stt_cb) { + s_ws.stt_cb(text->valuestring, is_final, s_ws.stt_cb_ctx); + } + } + } else if (strcmp(type->valuestring, "intent") == 0) { + const cJSON *content = cJSON_GetObjectItemCaseSensitive(root, "content"); + const cJSON *model = cJSON_GetObjectItemCaseSensitive(root, "model"); + const char *content_str = cJSON_IsString(content) ? content->valuestring : NULL; + const char *model_str = cJSON_IsString(model) ? model->valuestring : NULL; + ESP_LOGI(TAG, "intent (model=%s): %s", + model_str ? model_str : "?", + content_str ? content_str : "?"); + // Slice 8: hand off to the dispatcher (logs + best-effort cue). + voice_dispatcher_handle_intent(content_str, model_str); + } else if (strcmp(type->valuestring, "speak_start") == 0) { + // Slice 9b: bridge is about to stream PCM TTS reply. Lift the + // playback gate, configure the I2S TX clock to the announced + // sample rate (typically 24 kHz from F5-TTS). + const cJSON *sr = cJSON_GetObjectItemCaseSensitive(root, "sample_rate"); + const cJSON *fmt = cJSON_GetObjectItemCaseSensitive(root, "format"); + uint32_t rate = (cJSON_IsNumber(sr) && sr->valueint > 0) + ? (uint32_t) sr->valueint : 24000; + const char *fmt_s = (cJSON_IsString(fmt) && fmt->valuestring) + ? fmt->valuestring : "pcm_s16"; + ESP_LOGI(TAG, "speak_start: sr=%u fmt=%s", (unsigned) rate, fmt_s); + esp_err_t pe = voice_pipeline_play_start(rate, fmt_s); + if (pe == ESP_OK) { + s_ws.in_speak = true; + } else { + ESP_LOGW(TAG, "speak_start: voice_pipeline_play_start=%s — " + "TTS playback unavailable, dropping audio frames", + esp_err_to_name(pe)); + s_ws.in_speak = false; + } + } else if (strcmp(type->valuestring, "speak_end") == 0) { + const cJSON *dur = cJSON_GetObjectItemCaseSensitive(root, "duration_ms"); + const cJSON *backend = cJSON_GetObjectItemCaseSensitive(root, "backend"); + const cJSON *lat = cJSON_GetObjectItemCaseSensitive(root, "latency_ms"); + ESP_LOGI(TAG, "speak_end: duration=%dms backend=%s first_chunk_lat=%dms", + cJSON_IsNumber(dur) ? dur->valueint : 0, + (cJSON_IsString(backend) && backend->valuestring) ? backend->valuestring : "?", + cJSON_IsNumber(lat) ? lat->valueint : 0); + if (s_ws.in_speak) { + voice_pipeline_play_end(); + s_ws.in_speak = false; + } + } else if (strcmp(type->valuestring, "error") == 0) { + const cJSON *msg = cJSON_GetObjectItemCaseSensitive(root, "message"); + ESP_LOGW(TAG, "bridge error: %s", + cJSON_IsString(msg) ? msg->valuestring : "(no message)"); + // If an error arrives mid-speak, close the playback gate cleanly. + if (s_ws.in_speak) { + voice_pipeline_play_end(); + s_ws.in_speak = false; + } + } else { + ESP_LOGD(TAG, "rx: unknown type=%s", type->valuestring); + } + + cJSON_Delete(root); +} + +static void ws_event_handler(void *handler_args, esp_event_base_t base, + int32_t event_id, void *event_data) { + (void) handler_args; + (void) base; + esp_websocket_event_data_t *data = (esp_websocket_event_data_t *) event_data; + + switch (event_id) { + case WEBSOCKET_EVENT_CONNECTED: + ESP_LOGI(TAG, "ws connected to %s", s_ws.url); + if (s_ws.ev) xEventGroupSetBits(s_ws.ev, WS_BIT_CONNECTED); + break; + case WEBSOCKET_EVENT_DISCONNECTED: + ESP_LOGW(TAG, "ws disconnected"); + // Don't auto-reconnect this slice — capture task is the only + // producer and it'll just notice via voice_ws_is_streaming(). + s_ws.streaming = false; + break; + case WEBSOCKET_EVENT_DATA: + if (!data) break; + // op_code 0x1 = text frame, 0x2 = binary, 0x8 = close, 0x9/0xA = ping/pong. + if (data->op_code == 0x01 && data->data_len > 0) { + handle_text_message((const char *) data->data_ptr, data->data_len); + } else if (data->op_code == 0x02 && data->data_len > 0 + && s_ws.in_speak) { + // Slice 9b: PCM TTS chunk from the bridge. Forward to I2S TX. + voice_pipeline_play_chunk((const uint8_t *) data->data_ptr, + (size_t) data->data_len); + } + break; + case WEBSOCKET_EVENT_ERROR: + ESP_LOGW(TAG, "ws error event"); + if (s_ws.ev) xEventGroupSetBits(s_ws.ev, WS_BIT_FAILED); + break; + default: + break; + } +} + +esp_err_t voice_ws_configure(const char *url, + const char *session_id, + uint32_t sample_rate_hz) { + if (!url || !*url) return ESP_ERR_INVALID_ARG; + if (strlen(url) >= sizeof(s_ws.url)) return ESP_ERR_INVALID_SIZE; + + strncpy(s_ws.url, url, sizeof(s_ws.url) - 1); + s_ws.url[sizeof(s_ws.url) - 1] = '\0'; + + if (session_id && *session_id) { + strncpy(s_ws.session_id, session_id, sizeof(s_ws.session_id) - 1); + } else { + strncpy(s_ws.session_id, "unknown", sizeof(s_ws.session_id) - 1); + } + s_ws.session_id[sizeof(s_ws.session_id) - 1] = '\0'; + + s_ws.sample_rate_hz = sample_rate_hz ? sample_rate_hz : 16000; + s_ws.configured = true; + + ESP_LOGI(TAG, "configured url=%s session=%s sr=%u", + s_ws.url, s_ws.session_id, (unsigned) s_ws.sample_rate_hz); + return ESP_OK; +} + +void voice_ws_set_stt_callback(voice_stt_callback_t cb, void *user_ctx) { + s_ws.stt_cb = cb; + s_ws.stt_cb_ctx = user_ctx; +} + +bool voice_ws_is_configured(void) { + return s_ws.configured; +} + +bool voice_ws_is_streaming(void) { + return s_ws.streaming; +} + +static esp_err_t send_hello(void) { + char buf[WS_HELLO_BUF_LEN]; + int n = snprintf(buf, sizeof(buf), + "{\"type\":\"hello\",\"version\":1," + "\"sample_rate\":%u,\"format\":\"pcm_s16\"," + "\"session_id\":\"%s\"}", + (unsigned) s_ws.sample_rate_hz, + s_ws.session_id); + if (n <= 0 || n >= (int) sizeof(buf)) return ESP_ERR_INVALID_SIZE; + + int sent = esp_websocket_client_send_text(s_ws.client, buf, n, + pdMS_TO_TICKS(2000)); + if (sent < 0) { + ESP_LOGW(TAG, "hello send failed (rc=%d)", sent); + return ESP_FAIL; + } + ESP_LOGI(TAG, "hello sent: %s", buf); + return ESP_OK; +} + +esp_err_t voice_ws_open_streaming(void) { + if (!s_ws.configured) return ESP_ERR_INVALID_STATE; + if (s_ws.streaming) return ESP_OK; + + if (!s_ws.ev) { + s_ws.ev = xEventGroupCreate(); + if (!s_ws.ev) return ESP_ERR_NO_MEM; + } + xEventGroupClearBits(s_ws.ev, WS_BIT_CONNECTED | WS_BIT_FAILED); + + if (!s_ws.client) { + const esp_websocket_client_config_t cfg = { + .uri = s_ws.url, + .reconnect_timeout_ms = WS_NETWORK_TIMEOUT_MS, + .network_timeout_ms = WS_NETWORK_TIMEOUT_MS, + .buffer_size = WS_RX_BUFFER_BYTES, + .task_stack = WS_TASK_STACK_BYTES, + .disable_auto_reconnect = true, + }; + s_ws.client = esp_websocket_client_init(&cfg); + if (!s_ws.client) { + ESP_LOGE(TAG, "esp_websocket_client_init failed"); + return ESP_FAIL; + } + esp_err_t reg = esp_websocket_register_events( + s_ws.client, WEBSOCKET_EVENT_ANY, ws_event_handler, NULL); + if (reg != ESP_OK) { + ESP_LOGE(TAG, "register_events: %s", esp_err_to_name(reg)); + esp_websocket_client_destroy(s_ws.client); + s_ws.client = NULL; + return reg; + } + } + + esp_err_t err = esp_websocket_client_start(s_ws.client); + if (err != ESP_OK) { + ESP_LOGE(TAG, "ws_client_start: %s", esp_err_to_name(err)); + return err; + } + + EventBits_t bits = xEventGroupWaitBits( + s_ws.ev, WS_BIT_CONNECTED | WS_BIT_FAILED, + pdFALSE, pdFALSE, pdMS_TO_TICKS(WS_CONNECT_TIMEOUT_MS)); + + if (!(bits & WS_BIT_CONNECTED)) { + ESP_LOGW(TAG, "ws connect timeout (%d ms) — closing", + WS_CONNECT_TIMEOUT_MS); + esp_websocket_client_stop(s_ws.client); + return ESP_ERR_TIMEOUT; + } + + s_ws.streaming = true; + esp_err_t hello_err = send_hello(); + if (hello_err != ESP_OK) { + ESP_LOGW(TAG, "hello failed (%s) — tearing down", + esp_err_to_name(hello_err)); + s_ws.streaming = false; + esp_websocket_client_stop(s_ws.client); + return hello_err; + } + return ESP_OK; +} + +esp_err_t voice_ws_send_chunk(const int16_t *pcm, size_t samples) { + if (!s_ws.streaming || !s_ws.client) return ESP_ERR_INVALID_STATE; + if (!pcm || samples == 0) return ESP_ERR_INVALID_ARG; + + const size_t bytes = samples * sizeof(int16_t); + int sent = esp_websocket_client_send_bin( + s_ws.client, (const char *) pcm, (int) bytes, pdMS_TO_TICKS(500)); + if (sent < 0) { + ESP_LOGW(TAG, "send_bin failed (rc=%d, %u B)", sent, (unsigned) bytes); + return ESP_FAIL; + } + return ESP_OK; +} + +esp_err_t voice_ws_close_streaming(void) { + if (!s_ws.client) return ESP_OK; + if (s_ws.streaming) { + const char *end_msg = "{\"type\":\"end\"}"; + int sent = esp_websocket_client_send_text( + s_ws.client, end_msg, (int) strlen(end_msg), + pdMS_TO_TICKS(2000)); + if (sent < 0) { + ESP_LOGW(TAG, "end send failed (rc=%d) — closing anyway", sent); + } else { + ESP_LOGI(TAG, "end frame sent"); + } + } + s_ws.streaming = false; + // Keep the handle for a clean teardown but stop the inner task. + esp_websocket_client_stop(s_ws.client); + esp_websocket_client_destroy(s_ws.client); + s_ws.client = NULL; + return ESP_OK; +} diff --git a/idf_zacus/components/voice_pipeline/voice_pipeline_ws.h b/idf_zacus/components/voice_pipeline/voice_pipeline_ws.h new file mode 100644 index 0000000..a0a777d --- /dev/null +++ b/idf_zacus/components/voice_pipeline/voice_pipeline_ws.h @@ -0,0 +1,55 @@ +// Internal WebSocket helper for voice_pipeline. Split out so the AFE / +// VAD state machine in voice_pipeline.c doesn't have to deal with +// esp_websocket_client wiring directly. + +#pragma once + +#include +#include +#include + +#include "esp_err.h" + +#include "voice_pipeline.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Configure the WebSocket helper with the bridge URL and the +// session_id derived from the device MAC. Safe to call multiple times; +// the connection is opened lazily on the first +// voice_ws_open_streaming() call. +esp_err_t voice_ws_configure(const char *url, + const char *session_id, + uint32_t sample_rate_hz); + +// Register the STT callback (forwarded to user code by the WS event +// handler when the bridge sends `{"type":"stt", ...}`). +void voice_ws_set_stt_callback(voice_stt_callback_t cb, void *user_ctx); + +// Open the WebSocket and send the `hello` handshake. Idempotent: if +// already connected and streaming, returns ESP_OK immediately. +// Blocks up to ~5 s waiting for connect; returns ESP_ERR_TIMEOUT on +// connect failure. +esp_err_t voice_ws_open_streaming(void); + +// Send a binary PCM chunk (16-bit mono little-endian). Caller owns +// the buffer. Drops silently if the WS is not currently open +// (returns ESP_ERR_INVALID_STATE). +esp_err_t voice_ws_send_chunk(const int16_t *pcm, size_t samples); + +// Send the `{"type":"end"}` control frame, then close the WS. Safe +// to call from any task. After this the WS is fully torn down — a +// new open_streaming() call will reconnect from scratch. +esp_err_t voice_ws_close_streaming(void); + +// True between open_streaming and close_streaming. +bool voice_ws_is_streaming(void); + +// True if the helper has a configured URL (i.e. streaming is wired). +bool voice_ws_is_configured(void); + +#ifdef __cplusplus +} +#endif diff --git a/idf_zacus/main/CMakeLists.txt b/idf_zacus/main/CMakeLists.txt new file mode 100644 index 0000000..4af2a99 --- /dev/null +++ b/idf_zacus/main/CMakeLists.txt @@ -0,0 +1,23 @@ +idf_component_register( + SRCS + "main.c" + INCLUDE_DIRS + "." + REQUIRES + joltwallet__littlefs + ota_server + media_manager + npc_engine + hints_client + voice_pipeline + voice_hook_endpoint + game_endpoint + scenario_mesh + nvs_flash + esp_timer + esp_system + esp_wifi + esp_netif + esp_event + espressif__mdns +) diff --git a/idf_zacus/main/idf_component.yml b/idf_zacus/main/idf_component.yml new file mode 100644 index 0000000..0ef5318 --- /dev/null +++ b/idf_zacus/main/idf_component.yml @@ -0,0 +1,21 @@ +## Managed component manifest for the Zacus master `main` component. +## P1 first slice — keep deps minimal; voice / NPC / vision deps come later. + +dependencies: + joltwallet/littlefs: "^1.14" + ## Slice 6: ESP-SR (AFE + WakeNet9) for the voice_pipeline component. + ## Pinned to the 2.x line — 2.0+ is the IDF 5.x-friendly API. The + ## active wake model is selected via sdkconfig (CONFIG_SR_WN_WN9_HIESP). + ## A custom "Professeur Zacus" model is out of scope for this slice + ## (P4 of the voice spec, requires Espressif training round-trip). + espressif/esp-sr: "~2.0" + ## Slice 7: WebSocket client used by voice_pipeline to stream PCM + ## chunks to the MacStudio voice-bridge (`ws://studio:8200/voice/ws`) + ## and receive STT transcripts. The 1.4 line is the current stable + ## series for IDF 5.x (event loop based, supports binary frames). + espressif/esp_websocket_client: "~1.4" + ## Slice 12: mDNS so PLIP and the dashboard can discover the master + ## as `zacus-master.local` instead of relying on a DHCP reservation + ## or a manual IP probe. 1.6 is the current stable line for IDF 5.x + ## (TXT records + service browsing supported). + espressif/mdns: "~1.6" diff --git a/idf_zacus/main/main.c b/idf_zacus/main/main.c new file mode 100644 index 0000000..d0f21c7 --- /dev/null +++ b/idf_zacus/main/main.c @@ -0,0 +1,598 @@ +// Zacus master — ESP-IDF entry point (P1 slice 2). +// +// Responsibilities at this slice: +// 1. Initialize NVS (required by Wi-Fi). +// 2. Initialize esp_netif + default event loop. +// 3. Read Wi-Fi creds from NVS namespace "wifi" (keys "ssid" / "pwd"). +// - If creds present : start STA, wait for IP_EVENT_STA_GOT_IP. +// - If creds absent : fall back to open AP "zacus-setup" so the +// operator can still reach the OTA endpoint +// (and provision creds in a later slice). +// 4. Once the network is up, call ota_server_init() so the inherited +// HTTP server (port 80) starts answering /version, /status, /ota. +// 5. Mount the LittleFS "storage" partition on /littlefs and list it. +// 6. Log heap stats + idle loop with periodic heartbeat (60 s). +// +// Subsequent slices port the NPC engine, voice pipeline, media manager, etc. +// See docs/superpowers/specs/2026-05-03-voice-pipeline-esp-sr-design.md. + +#include +#include +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/event_groups.h" + +#include "esp_log.h" +#include "esp_system.h" +#include "esp_heap_caps.h" +#include "esp_err.h" +#include "esp_event.h" +#include "esp_netif.h" +#include "esp_wifi.h" +#include "esp_littlefs.h" +#include "nvs_flash.h" +#include "nvs.h" + +#include "mdns.h" + +#include "ota_server.h" +#include "media_manager.h" +#include "npc_engine.h" +#include "hints_client.h" +#include "voice_pipeline.h" +#include "voice_dispatcher.h" +#include "voice_hook_endpoint.h" +#include "game_endpoint.h" +#include "scenario_mesh.h" + +// Hints engine endpoint (slice 5). Hardcoded for now — slice 7 will move +// this to NVS so the field operator can repoint the firmware without a flash. +#define ZACUS_HINTS_BASE_URL "http://192.168.0.150:8302" + +// Slice 7: voice-bridge WebSocket on the MacStudio (Tailscale address). +// Hardcoded here for the same reason as ZACUS_HINTS_BASE_URL — moves to +// NVS in a follow-up slice. The bridge endpoint is documented in +// docs/superpowers/specs/2026-05-03-voice-pipeline-esp-sr-design.md. +#define ZACUS_VOICE_BRIDGE_WS_URL "ws://100.116.92.12:8200/voice/ws" + +static const char *TAG = "zacus_main"; + +// Soft-AP fallback when no creds in NVS yet. +#define ZACUS_FALLBACK_AP_SSID "zacus-setup" +#define ZACUS_FALLBACK_AP_CHAN 6 +#define ZACUS_STA_MAX_RETRY 8 + +// ─── Wi-Fi state ───────────────────────────────────────────────────────────── +static EventGroupHandle_t s_wifi_event_group; +#define WIFI_CONNECTED_BIT BIT0 +#define WIFI_FAIL_BIT BIT1 +static int s_sta_retry = 0; + +// ─── ota_server externs (real implementations come with the puzzle/master +// state in a later P1 slice; for now we provide trivial stubs so the +// component links cleanly). ───────────────────────────────────────────── +int puzzle_get_battery_pct(void) { + return 100; +} + +int puzzle_get_espnow_peer_count(void) { + return 0; +} + +// Slice 6: wake-word callback. Runs on the voice_pipeline capture task, +// keep it short. The pipeline already auto-transitioned to LISTENING +// before invoking us; here we just log + ensure capture is running so +// downstream STT (slice 7) has audio to consume. +static void on_voice_wake(const char *wake_word, void *user_ctx) { + (void) user_ctx; + ESP_LOGI(TAG, "WAKE: \"%s\" detected, transitioning to LISTENING", + wake_word ? wake_word : "(null)"); + // Capture is already running (esp-sr feeds it), but if a future + // slice toggles it off between wakes, this keeps us robust. + esp_err_t err = voice_pipeline_start_capture(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGW(TAG, "voice_pipeline_start_capture from wake cb: %s", + esp_err_to_name(err)); + } +} + +// Slice 7/8: STT callback. Runs on the WebSocket event-loop task — +// keep it short. The actual routing (keyword fast-path → hints engine, +// non-keyword → defer to LLM intent path) is owned by voice_dispatcher, +// which voice_pipeline_ws calls in parallel with this user callback. +// We keep the log here as a diagnostic breadcrumb for field debugging. +static void on_voice_stt(const char *text, bool final, void *user_ctx) { + (void) user_ctx; + ESP_LOGI(TAG, "STT(final=%d): %s", final ? 1 : 0, + text ? text : "(null)"); +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +static void log_heap_stats(const char *phase) { + ESP_LOGI(TAG, "[heap @ %s] free=%u internal=%u psram=%u", + phase, + (unsigned) esp_get_free_heap_size(), + (unsigned) esp_get_free_internal_heap_size(), + (unsigned) heap_caps_get_free_size(MALLOC_CAP_SPIRAM)); +} + +static esp_err_t mount_littlefs(void) { + const esp_vfs_littlefs_conf_t conf = { + .base_path = "/littlefs", + .partition_label = "storage", + .format_if_mount_failed = true, + .dont_mount = false, + }; + esp_err_t err = esp_vfs_littlefs_register(&conf); + if (err != ESP_OK) { + ESP_LOGE(TAG, "LittleFS mount failed: %s", esp_err_to_name(err)); + return err; + } + + size_t total = 0, used = 0; + if (esp_littlefs_info(conf.partition_label, &total, &used) == ESP_OK) { + ESP_LOGI(TAG, "LittleFS mounted at %s — %u / %u bytes used", + conf.base_path, (unsigned) used, (unsigned) total); + } + return ESP_OK; +} + +static void list_littlefs_root(void) { + DIR *dir = opendir("/littlefs"); + if (!dir) { + ESP_LOGW(TAG, "opendir(/littlefs) failed"); + return; + } + struct dirent *ent; + int count = 0; + while ((ent = readdir(dir)) != NULL) { + ESP_LOGI(TAG, " /littlefs/%s (type=%d)", ent->d_name, ent->d_type); + count++; + } + closedir(dir); + ESP_LOGI(TAG, "LittleFS root contains %d entries", count); +} + +// ─── ESP-NOW relay peer registry seed ──────────────────────────────────────── +// +// The /game/scenario/relay endpoint resolves a peer alias (e.g. "box3", +// "plip", "p7_coffre") to a MAC via the scenario_mesh registry. Seed it from +// NVS namespace "peers": one entry per alias whose value is the 6-byte MAC +// blob (set with `idf.py nvs-partition-gen` or from the dashboard). This keeps +// MAC addresses out of the firmware image and lets the GM repoint a satellite +// board without a reflash. +// +// Provisioning example (nvs CSV): +// key,type,encoding,value +// peers,namespace,, +// box3,data,base64, +static void seed_relay_peers_from_nvs(void) { + nvs_iterator_t it = NULL; + esp_err_t err = nvs_entry_find("nvs", "peers", NVS_TYPE_BLOB, &it); + if (err != ESP_OK) { + ESP_LOGI(TAG, "no NVS 'peers' namespace — relay registry empty " + "(set MACs to enable /game/scenario/relay)"); + return; + } + + nvs_handle_t h; + if (nvs_open("peers", NVS_READONLY, &h) != ESP_OK) { + nvs_release_iterator(it); + return; + } + + int seeded = 0; + while (err == ESP_OK && it != NULL) { + nvs_entry_info_t info; + nvs_entry_info(it, &info); + + uint8_t mac[6]; + size_t len = sizeof(mac); + if (nvs_get_blob(h, info.key, mac, &len) == ESP_OK && len == 6) { + if (scenario_mesh_register_peer(info.key, mac) == ESP_OK) { + seeded++; + } + } else { + ESP_LOGW(TAG, "peers/%s: not a 6-byte MAC blob — skipped", + info.key); + } + err = nvs_entry_next(&it); + } + nvs_release_iterator(it); + nvs_close(h); + ESP_LOGI(TAG, "relay peer registry seeded: %d peer(s)", seeded); +} + +// ─── Wi-Fi: NVS creds + event handler ──────────────────────────────────────── + +// Reads NVS namespace "wifi" keys "ssid" + "pwd". Returns ESP_OK if both +// keys are present and ssid is non-empty. Buffers are NUL-terminated. +static esp_err_t load_wifi_creds(char *ssid, size_t ssid_len, + char *pwd, size_t pwd_len) { + nvs_handle_t h; + esp_err_t err = nvs_open("wifi", NVS_READONLY, &h); + if (err != ESP_OK) { + ESP_LOGI(TAG, "NVS namespace 'wifi' not found (%s)", esp_err_to_name(err)); + return err; + } + + size_t len = ssid_len; + err = nvs_get_str(h, "ssid", ssid, &len); + if (err != ESP_OK || len <= 1) { + ESP_LOGI(TAG, "NVS 'wifi/ssid' missing (%s)", esp_err_to_name(err)); + nvs_close(h); + return ESP_ERR_NOT_FOUND; + } + + len = pwd_len; + err = nvs_get_str(h, "pwd", pwd, &len); + if (err != ESP_OK) { + // Empty password is acceptable (open network). + pwd[0] = '\0'; + } + nvs_close(h); + return ESP_OK; +} + +static void wifi_event_handler(void *arg, esp_event_base_t base, + int32_t id, void *data) { + if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) { + ESP_LOGI(TAG, "STA start — connecting…"); + esp_wifi_connect(); + } else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) { + if (s_sta_retry < ZACUS_STA_MAX_RETRY) { + s_sta_retry++; + ESP_LOGW(TAG, "STA disconnected — retry %d/%d", s_sta_retry, ZACUS_STA_MAX_RETRY); + esp_wifi_connect(); + } else { + ESP_LOGE(TAG, "STA give up after %d retries", ZACUS_STA_MAX_RETRY); + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + } + } else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) { + ip_event_got_ip_t *event = (ip_event_got_ip_t *) data; + ESP_LOGI(TAG, "STA got IP " IPSTR, IP2STR(&event->ip_info.ip)); + s_sta_retry = 0; + xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); + } else if (base == WIFI_EVENT && id == WIFI_EVENT_AP_START) { + ESP_LOGI(TAG, "AP started — SSID=\"%s\" (open)", ZACUS_FALLBACK_AP_SSID); + } else if (base == WIFI_EVENT && id == WIFI_EVENT_AP_STACONNECTED) { + ESP_LOGI(TAG, "AP: client joined"); + } +} + +// Returns true if STA connected, false if AP fallback (or STA gave up). +static bool wifi_bring_up(void) { + s_wifi_event_group = xEventGroupCreate(); + + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + + ESP_ERROR_CHECK(esp_event_handler_instance_register( + WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL, NULL)); + ESP_ERROR_CHECK(esp_event_handler_instance_register( + IP_EVENT, IP_EVENT_STA_GOT_IP, &wifi_event_handler, NULL, NULL)); + + char ssid[33] = {0}; + char pwd[65] = {0}; + bool have_creds = (load_wifi_creds(ssid, sizeof(ssid), pwd, sizeof(pwd)) == ESP_OK); + + if (have_creds) { + ESP_LOGI(TAG, "Wi-Fi: STA mode (ssid=\"%s\")", ssid); + esp_netif_create_default_wifi_sta(); + + wifi_config_t wc = {0}; + strncpy((char *) wc.sta.ssid, ssid, sizeof(wc.sta.ssid) - 1); + strncpy((char *) wc.sta.password, pwd, sizeof(wc.sta.password) - 1); + wc.sta.threshold.authmode = WIFI_AUTH_OPEN; // accept any; we don't pin + + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wc)); + ESP_ERROR_CHECK(esp_wifi_start()); + + EventBits_t bits = xEventGroupWaitBits( + s_wifi_event_group, + WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, + pdFALSE, pdFALSE, + pdMS_TO_TICKS(20000)); + + if (bits & WIFI_CONNECTED_BIT) { + return true; + } + ESP_LOGW(TAG, "STA failed within 20 s — fallback to AP"); + esp_wifi_stop(); + } else { + ESP_LOGI(TAG, "Wi-Fi: no creds in NVS, starting AP fallback"); + } + + // AP fallback (open network — provisioning will be added later). + esp_netif_create_default_wifi_ap(); + + wifi_config_t ap_cfg = {0}; + strncpy((char *) ap_cfg.ap.ssid, ZACUS_FALLBACK_AP_SSID, sizeof(ap_cfg.ap.ssid) - 1); + ap_cfg.ap.ssid_len = strlen(ZACUS_FALLBACK_AP_SSID); + ap_cfg.ap.channel = ZACUS_FALLBACK_AP_CHAN; + ap_cfg.ap.max_connection = 4; + ap_cfg.ap.authmode = WIFI_AUTH_OPEN; + + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &ap_cfg)); + ESP_ERROR_CHECK(esp_wifi_start()); + return false; +} + +// ─── mDNS bring-up (slice 12) ──────────────────────────────────────────────── +// +// Publishes `zacus-master.local` once Wi-Fi STA is up so PLIP and the +// dashboard can discover the master without a DHCP reservation. We +// also advertise a `_zacus._tcp` service on port 80 with TXT records +// pointing at the voice-hook URI — useful for `dns-sd -B _zacus._tcp` +// style introspection from the workshop laptop. +// +// In AP-fallback mode we deliberately skip mDNS: there is no upstream +// resolver to claim the hostname against, and several tooling stacks +// (avahi, bonjour) trip over a duplicate-name race when the AP later +// goes back to STA. The PLIP fallback in that scenario is the static +// AP IP (192.168.4.1). + +static void start_mdns(void) { + esp_err_t err = mdns_init(); + if (err != ESP_OK) { + ESP_LOGW(TAG, "mdns_init failed: %s", esp_err_to_name(err)); + return; + } + + err = mdns_hostname_set("zacus-master"); + if (err != ESP_OK) { + ESP_LOGW(TAG, "mdns_hostname_set: %s", esp_err_to_name(err)); + // Continue — the daemon is up, just no hostname claim. + } + + err = mdns_instance_name_set("Zacus Master ESP32-S3"); + if (err != ESP_OK) { + ESP_LOGW(TAG, "mdns_instance_name_set: %s", esp_err_to_name(err)); + } + + // Advertise the master HTTP surface as a `_zacus._tcp` service on + // port 80. The TXT records let PLIP firmware confirm it found the + // right device + which voice-hook path to POST to (so a future + // protocol bump can be discovered without reflashing PLIP). + err = mdns_service_add(NULL, "_zacus", "_tcp", 80, NULL, 0); + if (err != ESP_OK) { + ESP_LOGW(TAG, "mdns_service_add(_zacus._tcp:80): %s", + esp_err_to_name(err)); + return; + } + + (void) mdns_service_instance_name_set("_zacus", "_tcp", + "Zacus Master Voice Hook"); + + mdns_txt_item_t txt[] = { + {"path", "/voice/hook"}, + {"version", "1"}, + }; + err = mdns_service_txt_set("_zacus", "_tcp", txt, + sizeof(txt) / sizeof(txt[0])); + if (err != ESP_OK) { + ESP_LOGW(TAG, "mdns_service_txt_set: %s", esp_err_to_name(err)); + } + + ESP_LOGI(TAG, "mDNS up — hostname=zacus-master.local, " + "service=_zacus._tcp:80"); +} + +// ─── app_main ──────────────────────────────────────────────────────────────── + +void app_main(void) { + ESP_LOGI(TAG, "Zacus master booting (ESP-IDF scaffold, P1 slice 2)"); + + log_heap_stats("boot"); + + esp_err_t nvs_err = nvs_flash_init(); + if (nvs_err == ESP_ERR_NVS_NO_FREE_PAGES || nvs_err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + nvs_err = nvs_flash_init(); + } + ESP_ERROR_CHECK(nvs_err); + ESP_LOGI(TAG, "NVS initialized"); + + bool sta_ok = wifi_bring_up(); + ESP_LOGI(TAG, "Wi-Fi up (mode=%s)", sta_ok ? "STA" : "AP-fallback"); + + // Slice 12: publish zacus-master.local once we're on a real LAN. + // Skip in AP-fallback to avoid hostname-claim races when STA later + // recovers (and because there is no upstream resolver anyway). + if (sta_ok) { + start_mdns(); + } else { + ESP_LOGW(TAG, "mDNS not started in AP mode " + "(PLIP must use the AP IP fallback)"); + } + + esp_err_t ota_err = ota_server_init(); + if (ota_err != ESP_OK) { + ESP_LOGE(TAG, "ota_server_init failed: %s", esp_err_to_name(ota_err)); + } else { + ESP_LOGI(TAG, "OTA server listening on :%d", OTA_SERVER_PORT); + + // Slice 10: piggyback the PLIP /voice/hook endpoint on the same + // esp_http_server instance. Independent of voice_pipeline_init + // success — the handler tolerates a degraded pipeline (the + // voice_pipeline_* APIs return ESP_ERR_INVALID_STATE which we + // log and report as a 200 with whatever state we have). + httpd_handle_t httpd = ota_server_get_handle(); + esp_err_t hook_err = voice_hook_endpoint_init(httpd); + if (hook_err != ESP_OK) { + ESP_LOGW(TAG, "voice_hook_endpoint_init: %s", + esp_err_to_name(hook_err)); + } + + // Slice 12: REST surface for runtime game tuning. Today this + // exposes /game/group_profile (GET + POST) so the dashboard / + // GM can swap the hints policy without reflashing NVS. The + // POST handler validates via hints_client_set_group_profile() + // and persists to NVS namespace "zacus" / key "group_profile" + // (the same slot main.c reads at boot). + esp_err_t game_err = game_endpoint_init(httpd); + if (game_err != ESP_OK) { + ESP_LOGW(TAG, "game_endpoint_init: %s", + esp_err_to_name(game_err)); + } else { + // game_endpoint_init brought up scenario_mesh (ESP-NOW). Seed the + // relay peer registry from NVS so /game/scenario/relay can resolve + // aliases to MACs without a reflash. + seed_relay_peers_from_nvs(); + } + } + + if (mount_littlefs() == ESP_OK) { + list_littlefs_root(); + + // Slice 3: bring up the ported media_manager. Catalog dirs live on + // LittleFS so this must run *after* the mount succeeds. + media_manager_config_t media_cfg; + media_manager_default_config(&media_cfg); + esp_err_t media_err = media_manager_init(&media_cfg); + if (media_err != ESP_OK) { + ESP_LOGE(TAG, "media_manager_init failed: %s", + esp_err_to_name(media_err)); + } else { + // Smoke test: try to play /littlefs/intro.mp3. The file is + // unlikely to exist this early — that's fine, the manager + // returns ESP_ERR_NOT_FOUND and logs a warning, no crash. + esp_err_t play_err = media_manager_play("/littlefs/intro.mp3"); + ESP_LOGI(TAG, "media smoke play -> %s", + esp_err_to_name(play_err)); + + // Slice 4: bring up the ported npc_engine. Cue table is empty + // at this stage — wiring the scenario IR-driven cue catalog is + // a follow-up slice. The engine still boots, accepts ticks + // (no-op when auto_evaluate is false), and is ready to receive + // trigger_cue calls from REST/diagnostic surfaces. + const npc_engine_config_t npc_cfg = { + .cues = NULL, + .cue_count = 0, + .auto_evaluate = false, + .auto_play_decisions = false, + }; + esp_err_t npc_err = npc_engine_init(&npc_cfg); + if (npc_err != ESP_OK) { + ESP_LOGE(TAG, "npc_engine_init failed: %s", + esp_err_to_name(npc_err)); + } + + // Slice 5: bring up the hints HTTP client (so npc_engine can + // route hint requests through the real backend) and the voice + // pipeline (I2S capture stub + state machine, no auto-start). + esp_err_t hints_err = hints_client_init(ZACUS_HINTS_BASE_URL); + if (hints_err != ESP_OK) { + ESP_LOGW(TAG, "hints_client_init failed: %s — npc will use stub", + esp_err_to_name(hints_err)); + } else { + // Slice 11 (P5): load the group profile from NVS so the + // hints engine can tune answers per audience (TECH / + // NON_TECH / MIXED / BOTH). Default to MIXED when the + // key is absent or holds an unknown value. + // TODO(slice-11): endpoint /game/group_profile to update + // NVS at runtime — for now the value is flashed by the + // dashboard or `idf.py nvs-partition-gen` outputs. + nvs_handle_t gh; + esp_err_t open_err = nvs_open("zacus", NVS_READONLY, &gh); + char profile[HINTS_CLIENT_GROUP_PROFILE_MAX] = "MIXED"; + if (open_err == ESP_OK) { + size_t plen = sizeof(profile); + esp_err_t kerr = nvs_get_str(gh, "group_profile", + profile, &plen); + if (kerr != ESP_OK) { + ESP_LOGI(TAG, "NVS zacus/group_profile missing (%s) " + "— defaulting to MIXED", + esp_err_to_name(kerr)); + strncpy(profile, "MIXED", sizeof(profile) - 1); + profile[sizeof(profile) - 1] = '\0'; + } + nvs_close(gh); + } else { + ESP_LOGI(TAG, "NVS namespace 'zacus' not found (%s) " + "— defaulting group_profile=MIXED", + esp_err_to_name(open_err)); + } + esp_err_t set_err = hints_client_set_group_profile(profile); + if (set_err != ESP_OK) { + // Validation rejected the NVS value — force MIXED + // so the engine still has a usable hint policy. + ESP_LOGW(TAG, "group_profile \"%s\" rejected — falling " + "back to MIXED", profile); + (void) hints_client_set_group_profile("MIXED"); + } + } + + // Slice 8: voice → npc_engine routing layer. Must come + // after npc_engine_init / hints_client_init so the hint + // fast-path lands on the real backend (fallback: local stub). + esp_err_t disp_err = voice_dispatcher_init(); + if (disp_err != ESP_OK) { + ESP_LOGW(TAG, "voice_dispatcher_init failed: %s", + esp_err_to_name(disp_err)); + } + + voice_pipeline_config_t voice_cfg; + voice_pipeline_default_config(&voice_cfg); + // Slice 6: bring up esp-sr AFE + WakeNet (placeholder + // wn9_hiesp). Auto-start capture so the wake detector is + // hot from boot — saying "Hi ESP" fires the callback below. + voice_cfg.enable_wake_word = true; + voice_cfg.auto_start_capture = true; + // Slice 7: stream post-AFE PCM to the MacStudio voice-bridge + // over WebSocket once the wake word fires. The bridge runs + // STT (whisper) and may auto-route to the LLM intent layer. + voice_cfg.voice_bridge_ws_url = ZACUS_VOICE_BRIDGE_WS_URL; + // Slice 9 + 10: enable the I2S TX leg so TTS payloads coming + // back from the bridge land on the MAX98357A DAC. Wake-word + // stays enabled — PLIP hook is the primary path for voice + // sessions, but "hi esp" remains a backup if the phone is + // unplugged or its hook switch fails. + voice_cfg.enable_tts_playback = true; + voice_pipeline_set_wake_callback(on_voice_wake, NULL); + voice_pipeline_set_stt_callback(on_voice_stt, NULL); + esp_err_t voice_err = voice_pipeline_init(&voice_cfg); + if (voice_err != ESP_OK) { + ESP_LOGW(TAG, "voice_pipeline_init failed: %s", + esp_err_to_name(voice_err)); + } else if (voice_pipeline_wake_word_active()) { + ESP_LOGI(TAG, "voice: wake-word detector active " + "(placeholder \"hi esp\")"); + } else { + ESP_LOGW(TAG, "voice: wake-word inactive — running " + "in slice-5 stub mode"); + } + } + } + + log_heap_stats("post-init"); + + // Mark this firmware valid only after subsystems came up cleanly. + ota_server_mark_valid(); + + ESP_LOGI(TAG, "entering idle loop (heartbeat every 60 s)"); + uint32_t tick = 0; + for (;;) { + vTaskDelay(pdMS_TO_TICKS(60000)); + tick++; + const uint32_t uptime_ms = (uint32_t) esp_log_timestamp(); + ESP_LOGI(TAG, "heartbeat #%u — uptime=%llu s", + (unsigned) tick, + (unsigned long long) (uptime_ms / 1000)); + // Drive the slice-3/4 subsystems from the heartbeat. Once we have + // a real game loop these will move to a dedicated FreeRTOS task + // running at ~5 Hz; for now 60 s is enough to keep mood + media + // simulation state coherent without spamming the log. + media_manager_update(uptime_ms); + npc_engine_update(uptime_ms); + } +} diff --git a/idf_zacus/partitions.csv b/idf_zacus/partitions.csv new file mode 100644 index 0000000..6107bf0 --- /dev/null +++ b/idf_zacus/partitions.csv @@ -0,0 +1,25 @@ +# Zacus master partition table — merges ota_server's OTA layout (factory + +# ota_0 + ota_1 + otadata) with a dedicated LittleFS partition for assets +# (NPC phrases, scenario IR cache, …) and a SPIFFS region for esp-sr model +# blobs (`model`, slice 6 — flashed automatically as srmodels.bin by the +# esp-sr component CMake glue). +# +# Total target: 16 MB flash (Freenove ESP32-S3 N16R8). +# Layout (rough): 0x000000-0x020000 system, 0x020000-0x620000 apps (3×2 MB), +# 0x620000-0x720000 model (SPIFFS 1 MB, esp-sr models), +# 0x720000-0xC20000 storage (LittleFS 5 MB), +# 0xC20000-0x1000000 unused (~3.87 MB headroom). +# +# App partition bumped 1.5 MB → 2 MB (2026-05-03) to give voice/STT/hints +# slices and esp-sr integration ~25 % free space margin. OTA dual-bank +# preserved (factory + ota_0 + ota_1 all 2 MB → rollback symmetry intact). +# +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +otadata, data, ota, 0xf000, 0x2000, +phy_init, data, phy, 0x11000, 0x1000, +factory, app, factory, 0x20000, 0x200000, +ota_0, app, ota_0, , 0x200000, +ota_1, app, ota_1, , 0x200000, +model, data, spiffs, , 0x100000, +storage, data, littlefs,, 0x500000, diff --git a/idf_zacus/sdkconfig.defaults b/idf_zacus/sdkconfig.defaults new file mode 100644 index 0000000..dc30aeb --- /dev/null +++ b/idf_zacus/sdkconfig.defaults @@ -0,0 +1,60 @@ +# sdkconfig.defaults — Zacus master ESP-IDF scaffold (P1 first slice) +# +# Target: Freenove ESP32-S3 WROOM N16R8 (8 MB Octal PSRAM, 16 MB flash). +# Provides the minimum config to boot, mount LittleFS, and run the inherited +# ota_server component. Voice / NPC / vision configs come in later P1 slices. + +# ─── Target ────────────────────────────────────────────────────────────────── +CONFIG_IDF_TARGET="esp32s3" +CONFIG_IDF_TARGET_ESP32S3=y + +# ─── Flash ─────────────────────────────────────────────────────────────────── +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y +CONFIG_ESPTOOLPY_FLASHMODE_QIO=y +CONFIG_ESPTOOLPY_FLASHFREQ_80M=y + +# ─── PSRAM (Octal 80 MHz, required for esp-sr buffers in P3+) ──────────────── +CONFIG_SPIRAM=y +CONFIG_SPIRAM_MODE_OCT=y +CONFIG_SPIRAM_SPEED_80M=y +CONFIG_SPIRAM_USE_MALLOC=y +CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y + +# ─── Partition table (custom, supports OTA + LittleFS dual boot) ───────────── +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_PARTITION_TABLE_FILENAME="partitions.csv" + +# ─── HTTP server (used by ota_server, port 80) ─────────────────────────────── +CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024 +CONFIG_HTTPD_MAX_URI_LEN=512 + +# ─── Wi-Fi (STA + AP fallback for first-boot provisioning) ─────────────────── +CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=10 +CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=32 +CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=32 +CONFIG_ESP_WIFI_AMPDU_RX_ENABLED=y +CONFIG_ESP_WIFI_AMPDU_TX_ENABLED=y +CONFIG_ESP_WIFI_NVS_ENABLED=y +CONFIG_LWIP_LOCAL_HOSTNAME="zacus-master" + +# ─── FreeRTOS ──────────────────────────────────────────────────────────────── +CONFIG_FREERTOS_HZ=1000 + +# ─── Logging ───────────────────────────────────────────────────────────────── +CONFIG_LOG_DEFAULT_LEVEL_INFO=y +CONFIG_LOG_COLORS=y + +# ─── Bootloader / OTA rollback ─────────────────────────────────────────────── +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y + +# ─── ESP-SR (slice 6 — voice_pipeline) ─────────────────────────────────────── +# Standard placeholder wake-word: "Hi ESP" (wn9_hiesp). Custom Zacus model +# is out of scope here (Espressif training, P4 voice spec). Keep MultiNet +# disabled — STT happens off-device on MacStudio (whisper). Disabling MN +# saves >1 MB of flash + lots of PSRAM at runtime. +CONFIG_SR_WN_WN9_HIESP=y +CONFIG_SR_MN_CN_NONE=y +CONFIG_SR_MN_EN_NONE=y +CONFIG_MODEL_IN_FLASH=y +CONFIG_AFE_INTERFACE_V1=y diff --git a/idf_zacus/sdkconfig.qemu b/idf_zacus/sdkconfig.qemu new file mode 100644 index 0000000..786d630 --- /dev/null +++ b/idf_zacus/sdkconfig.qemu @@ -0,0 +1,18 @@ +# QEMU-specific overrides. Layered on top of sdkconfig.defaults via +# idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.qemu" build qemu +# Reasons: +# - QEMU's esp32s3 machine does not emulate Octal PSRAM → boot crashes on +# "PSRAM chip not found" without these overrides. +# - WiFi / BT radios are stubbed in QEMU, the Ethernet open_eth NIC stands in +# for IP connectivity (hostfwd=tcp::8580-:80). +# - esp-sr / wake-word buffers depend on PSRAM, so they must also be disabled. + +CONFIG_SPIRAM=n +CONFIG_SPIRAM_MODE_OCT=n +CONFIG_SPIRAM_SPEED_80M=n + +# esp-sr disabled because its buffers expect PSRAM. The HTTP scenario endpoint +# we're smoke-testing has no dependency on the voice pipeline. +CONFIG_USE_AFE=n +CONFIG_USE_WAKENET=n +CONFIG_USE_MULTINET=n diff --git a/lib/espnow_common/espnow_slave.c b/lib/espnow_common/espnow_slave.c index 0caeac6..85905cf 100644 --- a/lib/espnow_common/espnow_slave.c +++ b/lib/espnow_common/espnow_slave.c @@ -3,10 +3,13 @@ #include "espnow_slave.h" #include "esp_now.h" #include "esp_wifi.h" +#include "esp_mac.h" // MACSTR / MAC2STR #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/queue.h" +#include "freertos/task.h" #include +#include static const char *TAG = "espnow_slave"; @@ -23,6 +26,111 @@ static uint8_t s_master_mac[6]; static uint8_t s_puzzle_id; static espnow_cmd_callback_t s_callback; +// --------------------------------------------------------------------------- +// Scenario hot-load reassembly (see espnow_slave.h). Demuxed inside +// espnow_slave_process(); runs in task context, never the Wi-Fi ISR. +// --------------------------------------------------------------------------- +#define SCENARIO_PAYLOAD_MAX 236 // 240 ESP-NOW max - 4 header +#define SCENARIO_MAX_BYTES (64 * 1024) // matches master cap +#define SCENARIO_MAX_FRAMES ((SCENARIO_MAX_BYTES + SCENARIO_PAYLOAD_MAX - 1) \ + / SCENARIO_PAYLOAD_MAX) +#define SCENARIO_TIMEOUT_MS 5000 + +typedef struct { + bool active; + uint8_t src[6]; + uint16_t total; // expected frame count + uint16_t count; // distinct frames received + size_t tail; // highest payload end offset = reassembled length + uint8_t *buf; // heap, total * SCENARIO_PAYLOAD_MAX + uint8_t *seen; // heap bitmap, (total + 7) / 8 bytes + uint32_t last_ms; +} scenario_rx_t; + +static scenario_rx_t s_scn; +static espnow_scenario_callback_t s_scn_cb; + +static inline uint32_t now_ms(void) +{ + return (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); +} + +static void scenario_reset(void) +{ + free(s_scn.buf); + free(s_scn.seen); + memset(&s_scn, 0, sizeof(s_scn)); +} + +// True if this frame belongs to the scenario stream: either a continuation of +// the active transfer (matching sender MAC), or the first frame of a new one +// (seq==0, total>=1). MSG_* commands (data[0] in 0x01..0x08) never match. +static bool scenario_is_frame(const recv_item_t *it) +{ + if (s_scn.active && memcmp(it->mac, s_scn.src, 6) == 0) + return true; + if (it->data_len >= 4 && it->data[0] == 0x00 && it->data[1] == 0x00) { + uint16_t total = (uint16_t)it->data[2] | ((uint16_t)it->data[3] << 8); + return total >= 1; + } + return false; +} + +static void scenario_feed(const recv_item_t *it) +{ + uint16_t seq = (uint16_t)it->data[0] | ((uint16_t)it->data[1] << 8); + uint16_t total = (uint16_t)it->data[2] | ((uint16_t)it->data[3] << 8); + const uint8_t *payload = it->data + 4; + int payload_len = it->data_len - 4; + if (payload_len < 0) return; + + // Begin (or restart from a new sender) on the first frame. + if (seq == 0 && (!s_scn.active || memcmp(it->mac, s_scn.src, 6) != 0)) { + scenario_reset(); + if (total == 0 || total > SCENARIO_MAX_FRAMES) { + ESP_LOGW(TAG, "scenario rx: bad/oversized total %u — dropped", total); + return; + } + s_scn.buf = calloc(total, SCENARIO_PAYLOAD_MAX); + s_scn.seen = calloc((total + 7) / 8, 1); + if (!s_scn.buf || !s_scn.seen) { + ESP_LOGE(TAG, "scenario rx: OOM for %u frames — dropped", total); + scenario_reset(); + return; + } + s_scn.active = true; + s_scn.total = total; + memcpy(s_scn.src, it->mac, 6); + } + + if (!s_scn.active) return; // stray non-first frame, no live session + if (total != s_scn.total) return; // inconsistent header, ignore frame + if (seq >= s_scn.total) return; + if (payload_len > SCENARIO_PAYLOAD_MAX) payload_len = SCENARIO_PAYLOAD_MAX; + + s_scn.last_ms = now_ms(); + + if (!(s_scn.seen[seq / 8] & (1u << (seq % 8)))) { + size_t off = (size_t)seq * SCENARIO_PAYLOAD_MAX; + memcpy(s_scn.buf + off, payload, (size_t)payload_len); + s_scn.seen[seq / 8] |= (uint8_t)(1u << (seq % 8)); + s_scn.count++; + size_t end = off + (size_t)payload_len; + if (end > s_scn.tail) s_scn.tail = end; + } + + if (s_scn.count == s_scn.total) { + ESP_LOGI(TAG, "scenario reassembled: %u frames, %u bytes", + s_scn.total, (unsigned)s_scn.tail); + if (s_scn_cb) { + s_scn_cb(s_scn.src, s_scn.buf, s_scn.tail); + } else { + ESP_LOGI(TAG, "no scenario consumer on this node — dropped"); + } + scenario_reset(); + } +} + // --------------------------------------------------------------------------- // Internal callbacks (called from Wi-Fi ISR context) // --------------------------------------------------------------------------- @@ -110,11 +218,27 @@ void espnow_slave_register_callback(espnow_cmd_callback_t cb) s_callback = cb; } +void espnow_slave_register_scenario_callback(espnow_scenario_callback_t cb) +{ + s_scn_cb = cb; +} + void espnow_slave_process(void) { + // Abandon a half-received scenario whose sender went silent. + if (s_scn.active && (now_ms() - s_scn.last_ms) > SCENARIO_TIMEOUT_MS) { + ESP_LOGW(TAG, "scenario rx timeout (%u/%u frames) — dropped", + s_scn.count, s_scn.total); + scenario_reset(); + } + recv_item_t item; while (xQueueReceive(s_recv_queue, &item, 0) == pdTRUE) { if (item.data_len < 1) continue; + if (scenario_is_frame(&item)) { + scenario_feed(&item); + continue; + } espnow_msg_type_t type = (espnow_msg_type_t)item.data[0]; if (s_callback) { s_callback(type, diff --git a/lib/espnow_common/espnow_slave.h b/lib/espnow_common/espnow_slave.h index b9a4bf6..7b38aff 100644 --- a/lib/espnow_common/espnow_slave.h +++ b/lib/espnow_common/espnow_slave.h @@ -75,5 +75,29 @@ typedef void (*espnow_cmd_callback_t)(espnow_msg_type_t type, size_t len); void espnow_slave_register_callback(espnow_cmd_callback_t cb); +// --------------------------------------------------------------------------- +// Scenario hot-load over ESP-NOW (Runtime 3 IR push) +// See docs/specs/2026-05-24-firmware-scenario-hotload.md (task 6, receiver). +// +// Puzzle nodes share this single ESP-NOW recv callback, so scenario frames are +// demultiplexed inside espnow_slave_process() rather than via a second +// esp_now_register_recv_cb() (ESP-IDF allows only one). Frame wire format +// (matches components/scenario_mesh): 4-byte header { seq:u16 LE, total:u16 LE } +// then <=236 payload bytes. The first frame has seq==0; a transfer is tracked +// per source MAC until `total` frames arrive, then reassembled. +// +// Discriminator: the first frame's data[0..1] == 0x0000, which never collides +// with a MSG_* type (0x01..0x08); continuation frames are routed by matching +// the active sender MAC. This keeps a stray/misrouted scenario relay from being +// misread as a puzzle command (e.g. a seq==1 frame whose data[0]==0x01 would +// otherwise look like MSG_PUZZLE_SOLVED). +// +// Most puzzle nodes have no Runtime 3 scenario engine, so they register no +// callback: a reassembled scenario is then logged and dropped. A node that does +// consume scenarios (e.g. a future p7_coffre revision) opts in via this hook. +typedef void (*espnow_scenario_callback_t)(const uint8_t src_mac[6], + const uint8_t *json, size_t len); +void espnow_slave_register_scenario_callback(espnow_scenario_callback_t cb); + // Process inbound ESP-NOW queue — call from main FreeRTOS task loop. void espnow_slave_process(void); diff --git a/puzzles/p1_sequence_sonore/main/CMakeLists.txt b/puzzles/p1_sequence_sonore/main/CMakeLists.txt index 2f9cd6d..f18ce34 100644 --- a/puzzles/p1_sequence_sonore/main/CMakeLists.txt +++ b/puzzles/p1_sequence_sonore/main/CMakeLists.txt @@ -9,7 +9,6 @@ idf_component_register( freertos driver esp_wifi - esp_now nvs_flash led_strip esp_common diff --git a/puzzles/p5_morse/main/CMakeLists.txt b/puzzles/p5_morse/main/CMakeLists.txt index 7f86900..e87139b 100644 --- a/puzzles/p5_morse/main/CMakeLists.txt +++ b/puzzles/p5_morse/main/CMakeLists.txt @@ -9,7 +9,6 @@ idf_component_register( freertos driver esp_wifi - esp_now nvs_flash esp_common ) diff --git a/puzzles/p6_symboles_nfc/main/CMakeLists.txt b/puzzles/p6_symboles_nfc/main/CMakeLists.txt index 7f86900..e87139b 100644 --- a/puzzles/p6_symboles_nfc/main/CMakeLists.txt +++ b/puzzles/p6_symboles_nfc/main/CMakeLists.txt @@ -9,7 +9,6 @@ idf_component_register( freertos driver esp_wifi - esp_now nvs_flash esp_common ) diff --git a/puzzles/p7_coffre/main/CMakeLists.txt b/puzzles/p7_coffre/main/CMakeLists.txt index 7f86900..e87139b 100644 --- a/puzzles/p7_coffre/main/CMakeLists.txt +++ b/puzzles/p7_coffre/main/CMakeLists.txt @@ -9,7 +9,6 @@ idf_component_register( freertos driver esp_wifi - esp_now nvs_flash esp_common )