From 8a6f4e32d79ffc7cc83adbc1b454af156239064c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Sun, 3 May 2026 17:23:37 +0200 Subject: [PATCH 01/19] feat(idf): scaffold project with LittleFS + OTA P1 first slice of the voice pipeline migration (spec docs/superpowers/specs/2026-05-03-voice-pipeline-esp-sr-design.md). Adds idf_zacus/ alongside the Arduino tree (ui_freenove_allinone/ keeps building unchanged). The scaffold targets ESP32-S3, octal PSRAM 80 MHz, custom partition table with OTA + 2 MB LittleFS. main/main.c boots, initializes NVS, mounts LittleFS on /littlefs, logs heap stats (internal + PSRAM), and idles with a 60 s heartbeat. The orphaned 2026-04-03 ota_server component (HTTP :80, rate-limited OTA upload, 30 s rollback watchdog) is folded in under components/ota_server/ and its header is fixed to include esp_http_server.h (matches the .c, port 80 = HTTP). Not yet wired: ota_server_init() boot, Wi-Fi STA, voice/NPC modules. Stubs for puzzle_get_battery_pct() and puzzle_get_espnow_peer_count() let the OTA component link. --- idf_zacus/CMakeLists.txt | 13 + idf_zacus/README.md | 79 ++++ .../components/ota_server/CMakeLists.txt | 29 ++ .../ota_server/include/ota_server.h | 82 ++++ idf_zacus/components/ota_server/ota_server.c | 362 ++++++++++++++++++ .../components/ota_server/partitions_ota.csv | 8 + idf_zacus/main/CMakeLists.txt | 12 + idf_zacus/main/idf_component.yml | 5 + idf_zacus/main/main.c | 118 ++++++ idf_zacus/partitions.csv | 14 + idf_zacus/sdkconfig.defaults | 40 ++ 11 files changed, 762 insertions(+) create mode 100644 idf_zacus/CMakeLists.txt create mode 100644 idf_zacus/README.md create mode 100644 idf_zacus/components/ota_server/CMakeLists.txt create mode 100644 idf_zacus/components/ota_server/include/ota_server.h create mode 100644 idf_zacus/components/ota_server/ota_server.c create mode 100644 idf_zacus/components/ota_server/partitions_ota.csv create mode 100644 idf_zacus/main/CMakeLists.txt create mode 100644 idf_zacus/main/idf_component.yml create mode 100644 idf_zacus/main/main.c create mode 100644 idf_zacus/partitions.csv create mode 100644 idf_zacus/sdkconfig.defaults 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/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/ota_server/CMakeLists.txt b/idf_zacus/components/ota_server/CMakeLists.txt new file mode 100644 index 0000000..4a4d7f6 --- /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 + esp_ota_ops + 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..7e30345 --- /dev/null +++ b/idf_zacus/components/ota_server/include/ota_server.h @@ -0,0 +1,82 @@ +#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)); + +#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..b24c7fa --- /dev/null +++ b/idf_zacus/components/ota_server/ota_server.c @@ -0,0 +1,362 @@ +#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_429_TOO_MANY_REQUESTS, "Rate limited: wait 60s"); + return ESP_FAIL; + } + + if (s_status.state != OTA_STATE_IDLE) { + httpd_resp_send_err(req, HTTPD_409_CONFLICT, "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 = 8; + 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; +} 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/main/CMakeLists.txt b/idf_zacus/main/CMakeLists.txt new file mode 100644 index 0000000..0d91728 --- /dev/null +++ b/idf_zacus/main/CMakeLists.txt @@ -0,0 +1,12 @@ +idf_component_register( + SRCS + "main.c" + INCLUDE_DIRS + "." + REQUIRES + esp_littlefs + ota_server + nvs_flash + esp_timer + esp_system +) diff --git a/idf_zacus/main/idf_component.yml b/idf_zacus/main/idf_component.yml new file mode 100644 index 0000000..9ae2238 --- /dev/null +++ b/idf_zacus/main/idf_component.yml @@ -0,0 +1,5 @@ +## 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" diff --git a/idf_zacus/main/main.c b/idf_zacus/main/main.c new file mode 100644 index 0000000..e238635 --- /dev/null +++ b/idf_zacus/main/main.c @@ -0,0 +1,118 @@ +// Zacus master — ESP-IDF entry point (P1 first slice). +// +// Responsibilities at this slice: +// 1. Initialize NVS (required by Wi-Fi / esp_event later). +// 2. Mount the LittleFS "storage" partition on /littlefs and list contents. +// 3. Log heap stats (internal + PSRAM) for baseline measurement. +// 4. Idle loop with periodic heartbeat (no deep sleep — keeps the inherited +// OTA server task alive once we wire it in P1 slice 2). +// +// 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 "esp_log.h" +#include "esp_system.h" +#include "esp_heap_caps.h" +#include "esp_err.h" +#include "esp_littlefs.h" +#include "nvs_flash.h" + +static const char *TAG = "zacus_main"; + +// ─── 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; +} + +// ─── 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); +} + +// ─── app_main ──────────────────────────────────────────────────────────────── + +void app_main(void) { + ESP_LOGI(TAG, "Zacus master booting (ESP-IDF scaffold, P1 first slice)"); + + 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"); + + if (mount_littlefs() == ESP_OK) { + list_littlefs_root(); + } + + log_heap_stats("post-init"); + + ESP_LOGI(TAG, "entering idle loop (heartbeat every 60 s)"); + uint32_t tick = 0; + for (;;) { + vTaskDelay(pdMS_TO_TICKS(60000)); + tick++; + ESP_LOGI(TAG, "heartbeat #%u — uptime=%llu s", + (unsigned) tick, + (unsigned long long) (esp_log_timestamp() / 1000)); + } +} diff --git a/idf_zacus/partitions.csv b/idf_zacus/partitions.csv new file mode 100644 index 0000000..021a85b --- /dev/null +++ b/idf_zacus/partitions.csv @@ -0,0 +1,14 @@ +# Zacus master partition table — merges ota_server's OTA layout (factory + +# ota_0 + ota_1 + otadata) with a dedicated LittleFS partition for assets +# (wakenet model, NPC phrases, scenario IR cache, …). +# +# Total target: 16 MB flash (Freenove ESP32-S3 N16R8). +# +# 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, 0x180000, +ota_0, app, ota_0, , 0x180000, +ota_1, app, ota_1, , 0x180000, +storage, data, littlefs,, 0x200000, diff --git a/idf_zacus/sdkconfig.defaults b/idf_zacus/sdkconfig.defaults new file mode 100644 index 0000000..7730d38 --- /dev/null +++ b/idf_zacus/sdkconfig.defaults @@ -0,0 +1,40 @@ +# 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 + +# ─── 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 -- 2.52.0 From 4bb2c79e379e78f5e494e9871ba40bf52266aed1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Sun, 3 May 2026 17:41:03 +0200 Subject: [PATCH 02/19] feat(idf): wifi STA + ota_init wiring + IDF 5.4 fixes - main.c: NVS-driven Wi-Fi STA bring-up with AP fallback, ota_server_init after IP - main/CMakeLists: REQUIRES esp_wifi/esp_netif/esp_event, fix littlefs name (joltwallet__littlefs) - sdkconfig.defaults: Wi-Fi RX/TX buffers, AMPDU, NVS, hostname - ota_server: rename esp_ota_ops -> app_update, replace HTTPD_429/409 (absent in IDF 5.4) by HTTPD_400 --- .../components/ota_server/CMakeLists.txt | 2 +- idf_zacus/components/ota_server/ota_server.c | 4 +- idf_zacus/main/CMakeLists.txt | 5 +- idf_zacus/main/main.c | 171 +++++++++++++++++- idf_zacus/sdkconfig.defaults | 9 + 5 files changed, 180 insertions(+), 11 deletions(-) diff --git a/idf_zacus/components/ota_server/CMakeLists.txt b/idf_zacus/components/ota_server/CMakeLists.txt index 4a4d7f6..a30692b 100644 --- a/idf_zacus/components/ota_server/CMakeLists.txt +++ b/idf_zacus/components/ota_server/CMakeLists.txt @@ -5,7 +5,7 @@ idf_component_register( "include" REQUIRES esp_http_server - esp_ota_ops + app_update esp_timer esp_system nvs_flash diff --git a/idf_zacus/components/ota_server/ota_server.c b/idf_zacus/components/ota_server/ota_server.c index b24c7fa..1ad0389 100644 --- a/idf_zacus/components/ota_server/ota_server.c +++ b/idf_zacus/components/ota_server/ota_server.c @@ -226,12 +226,12 @@ 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_429_TOO_MANY_REQUESTS, "Rate limited: wait 60s"); + 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_409_CONFLICT, "OTA already in progress"); + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "OTA already in progress"); return ESP_FAIL; } diff --git a/idf_zacus/main/CMakeLists.txt b/idf_zacus/main/CMakeLists.txt index 0d91728..157cd25 100644 --- a/idf_zacus/main/CMakeLists.txt +++ b/idf_zacus/main/CMakeLists.txt @@ -4,9 +4,12 @@ idf_component_register( INCLUDE_DIRS "." REQUIRES - esp_littlefs + joltwallet__littlefs ota_server nvs_flash esp_timer esp_system + esp_wifi + esp_netif + esp_event ) diff --git a/idf_zacus/main/main.c b/idf_zacus/main/main.c index e238635..8b3739a 100644 --- a/idf_zacus/main/main.c +++ b/idf_zacus/main/main.c @@ -1,11 +1,17 @@ -// Zacus master — ESP-IDF entry point (P1 first slice). +// Zacus master — ESP-IDF entry point (P1 slice 2). // // Responsibilities at this slice: -// 1. Initialize NVS (required by Wi-Fi / esp_event later). -// 2. Mount the LittleFS "storage" partition on /littlefs and list contents. -// 3. Log heap stats (internal + PSRAM) for baseline measurement. -// 4. Idle loop with periodic heartbeat (no deep sleep — keeps the inherited -// OTA server task alive once we wire it in P1 slice 2). +// 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. @@ -17,16 +23,34 @@ #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 "ota_server.h" 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). ───────────────────────────────────────────── @@ -85,10 +109,130 @@ static void list_littlefs_root(void) { ESP_LOGI(TAG, "LittleFS root contains %d entries", count); } +// ─── 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; +} + // ─── app_main ──────────────────────────────────────────────────────────────── void app_main(void) { - ESP_LOGI(TAG, "Zacus master booting (ESP-IDF scaffold, P1 first slice)"); + ESP_LOGI(TAG, "Zacus master booting (ESP-IDF scaffold, P1 slice 2)"); log_heap_stats("boot"); @@ -100,12 +244,25 @@ void app_main(void) { 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"); + + 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); + } + if (mount_littlefs() == ESP_OK) { list_littlefs_root(); } 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 (;;) { diff --git a/idf_zacus/sdkconfig.defaults b/idf_zacus/sdkconfig.defaults index 7730d38..f8f587f 100644 --- a/idf_zacus/sdkconfig.defaults +++ b/idf_zacus/sdkconfig.defaults @@ -29,6 +29,15 @@ CONFIG_PARTITION_TABLE_FILENAME="partitions.csv" 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 -- 2.52.0 From c285e04d07b3a869117f7ff20a56ed3b06fe340b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Sun, 3 May 2026 17:41:19 +0200 Subject: [PATCH 03/19] chore(idf): gitignore build, managed_components, sdkconfig --- idf_zacus/.gitignore | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 idf_zacus/.gitignore 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 -- 2.52.0 From 90a0bd66b2bb25e06d8c0018fc4e32a06d579312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Sun, 3 May 2026 17:57:08 +0200 Subject: [PATCH 04/19] feat(idf): port media_manager (stub MP3) --- .../components/media_manager/CMakeLists.txt | 21 + .../media_manager/include/media_manager.h | 125 +++++ .../components/media_manager/media_manager.c | 485 ++++++++++++++++++ idf_zacus/main/CMakeLists.txt | 1 + idf_zacus/main/main.c | 18 + 5 files changed, 650 insertions(+) create mode 100644 idf_zacus/components/media_manager/CMakeLists.txt create mode 100644 idf_zacus/components/media_manager/include/media_manager.h create mode 100644 idf_zacus/components/media_manager/media_manager.c 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/main/CMakeLists.txt b/idf_zacus/main/CMakeLists.txt index 157cd25..2259bf4 100644 --- a/idf_zacus/main/CMakeLists.txt +++ b/idf_zacus/main/CMakeLists.txt @@ -6,6 +6,7 @@ idf_component_register( REQUIRES joltwallet__littlefs ota_server + media_manager nvs_flash esp_timer esp_system diff --git a/idf_zacus/main/main.c b/idf_zacus/main/main.c index 8b3739a..ec18ba8 100644 --- a/idf_zacus/main/main.c +++ b/idf_zacus/main/main.c @@ -37,6 +37,7 @@ #include "nvs.h" #include "ota_server.h" +#include "media_manager.h" static const char *TAG = "zacus_main"; @@ -256,6 +257,23 @@ void app_main(void) { 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)); + } } log_heap_stats("post-init"); -- 2.52.0 From 64892aa6a3a493c2bc4a21e7ade4867d262d86dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Sun, 3 May 2026 19:01:37 +0200 Subject: [PATCH 05/19] feat(idf): port npc_engine + wire heartbeat --- .../components/npc_engine/CMakeLists.txt | 29 ++ .../npc_engine/include/npc_engine.h | 184 +++++++++ idf_zacus/components/npc_engine/npc_engine.c | 375 ++++++++++++++++++ idf_zacus/main/CMakeLists.txt | 1 + idf_zacus/main/main.c | 27 +- 5 files changed, 615 insertions(+), 1 deletion(-) create mode 100644 idf_zacus/components/npc_engine/CMakeLists.txt create mode 100644 idf_zacus/components/npc_engine/include/npc_engine.h create mode 100644 idf_zacus/components/npc_engine/npc_engine.c diff --git a/idf_zacus/components/npc_engine/CMakeLists.txt b/idf_zacus/components/npc_engine/CMakeLists.txt new file mode 100644 index 0000000..0345492 --- /dev/null +++ b/idf_zacus/components/npc_engine/CMakeLists.txt @@ -0,0 +1,29 @@ +## 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 + 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..8fac767 --- /dev/null +++ b/idf_zacus/components/npc_engine/include/npc_engine.h @@ -0,0 +1,184 @@ +// 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); + +#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..b2fffa9 --- /dev/null +++ b/idf_zacus/components/npc_engine/npc_engine.c @@ -0,0 +1,375 @@ +// 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" + +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; +} + +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; + + 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); + 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; + + // TODO(slice-6): replace with HTTP POST to hints engine /hints/ask. + // For now we synchronously deliver a hardcoded French placeholder so + // the surrounding NPC orchestration can be wired and tested end-to-end. + 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 uint8_t clamped = (level > NPC_MAX_HINT_LEVEL) + ? NPC_MAX_HINT_LEVEL : level; + const char *text = kStubHints[clamped]; + + ESP_LOGI(TAG, "hint request puzzle=%u level=%u -> stub \"%s\"", + (unsigned) puzzle_id, (unsigned) level, text); + + npc_on_hint_request(&s_engine.core, s_engine.core.total_elapsed_ms); + 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/main/CMakeLists.txt b/idf_zacus/main/CMakeLists.txt index 2259bf4..e653168 100644 --- a/idf_zacus/main/CMakeLists.txt +++ b/idf_zacus/main/CMakeLists.txt @@ -7,6 +7,7 @@ idf_component_register( joltwallet__littlefs ota_server media_manager + npc_engine nvs_flash esp_timer esp_system diff --git a/idf_zacus/main/main.c b/idf_zacus/main/main.c index ec18ba8..71f3592 100644 --- a/idf_zacus/main/main.c +++ b/idf_zacus/main/main.c @@ -38,6 +38,7 @@ #include "ota_server.h" #include "media_manager.h" +#include "npc_engine.h" static const char *TAG = "zacus_main"; @@ -273,6 +274,23 @@ void app_main(void) { 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)); + } } } @@ -286,8 +304,15 @@ void app_main(void) { 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) (esp_log_timestamp() / 1000)); + (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); } } -- 2.52.0 From 010f7f3d81f27dd1eb19113107883ccdc32bd188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Sun, 3 May 2026 19:29:51 +0200 Subject: [PATCH 06/19] feat(idf): voice_pipeline + hints_client (HTTP) voice_pipeline: I2S capture stub (i2s_std driver), state machine. hints_client: separate component (avoids cycle), POST /hints/ask via esp_http_client + cJSON, sync + async wrappers. npc_engine.request_hint now routes through hints_client when init'd, falls back to local stub otherwise. Build green: 876 KB app, 44% free. --- .../components/hints_client/CMakeLists.txt | 16 ++ .../components/hints_client/hints_client.c | 215 ++++++++++++++++++ .../hints_client/include/hints_client.h | 69 ++++++ .../components/npc_engine/CMakeLists.txt | 1 + idf_zacus/components/npc_engine/npc_engine.c | 33 ++- .../components/voice_pipeline/CMakeLists.txt | 16 ++ .../voice_pipeline/include/voice_pipeline.h | 53 +++++ .../voice_pipeline/voice_pipeline.c | 159 +++++++++++++ idf_zacus/main/CMakeLists.txt | 2 + idf_zacus/main/main.c | 24 ++ 10 files changed, 580 insertions(+), 8 deletions(-) create mode 100644 idf_zacus/components/hints_client/CMakeLists.txt create mode 100644 idf_zacus/components/hints_client/hints_client.c create mode 100644 idf_zacus/components/hints_client/include/hints_client.h create mode 100644 idf_zacus/components/voice_pipeline/CMakeLists.txt create mode 100644 idf_zacus/components/voice_pipeline/include/voice_pipeline.h create mode 100644 idf_zacus/components/voice_pipeline/voice_pipeline.c 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..60c1635 --- /dev/null +++ b/idf_zacus/components/hints_client/hints_client.c @@ -0,0 +1,215 @@ +#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 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]; +} s_client = {0}; + +// 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'; + session_id_init(); + s_client.ready = true; + ESP_LOGI(TAG, "ready, base_url=%s session_id=%s", + s_client.base_url, s_client.session_id); + return ESP_OK; +} + +bool hints_client_is_ready(void) { + return s_client.ready; +} + +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; +} + +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; + } + + char url[HINTS_CLIENT_BASE_URL_MAX + 32]; + snprintf(url, sizeof(url), "%s%s", s_client.base_url, ASK_PATH); + + 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); + 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_http_client_config_t cfg = { + .url = url, + .method = HTTP_METHOD_POST, + .timeout_ms = HINTS_CLIENT_TIMEOUT_MS, + .event_handler = http_event_cb, + .user_data = &recv, + .disable_auto_redirect = true, + }; + + esp_http_client_handle_t client = esp_http_client_init(&cfg); + if (!client) { + free(body_str); + 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); + free(body_str); + + if (err != ESP_OK) { + ESP_LOGW(TAG, "HTTP perform failed: %s", esp_err_to_name(err)); + return err; + } + if (status < 200 || status >= 300) { + ESP_LOGW(TAG, "non-2xx %d body=%.*s", status, (int) recv.len, recv.buf); + return ESP_FAIL; + } + + 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; +} + +// ── 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..6b67d2b --- /dev/null +++ b/idf_zacus/components/hints_client/include/hints_client.h @@ -0,0 +1,69 @@ +// hints_client — HTTP client to the Zacus hints engine. +// +// POSTs JSON {"puzzle_id":..,"level":..,"session_id":..} to {base_url}/hints/ask +// and parses the "hint" / "source" / "refused" / "cooldown_until_ms" fields. +// +// Two 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. The +// callback signature matches npc_hint_callback_t. + +#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 + +// 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); + +#ifdef __cplusplus +} +#endif diff --git a/idf_zacus/components/npc_engine/CMakeLists.txt b/idf_zacus/components/npc_engine/CMakeLists.txt index 0345492..a0f5ccb 100644 --- a/idf_zacus/components/npc_engine/CMakeLists.txt +++ b/idf_zacus/components/npc_engine/CMakeLists.txt @@ -21,6 +21,7 @@ idf_component_register( "include" REQUIRES media_manager + hints_client esp_timer esp_system nvs_flash diff --git a/idf_zacus/components/npc_engine/npc_engine.c b/idf_zacus/components/npc_engine/npc_engine.c index b2fffa9..da686dc 100644 --- a/idf_zacus/components/npc_engine/npc_engine.c +++ b/idf_zacus/components/npc_engine/npc_engine.c @@ -23,6 +23,7 @@ #include "esp_log.h" #include "media_manager.h" +#include "hints_client.h" static const char *TAG = "npc_engine"; @@ -349,23 +350,39 @@ esp_err_t npc_engine_request_hint(uint8_t puzzle_id, uint8_t level, if (!s_engine.ready) return ESP_ERR_INVALID_STATE; if (cb == NULL) return ESP_ERR_INVALID_ARG; - // TODO(slice-6): replace with HTTP POST to hints engine /hints/ask. - // For now we synchronously deliver a hardcoded French placeholder so - // the surrounding NPC orchestration can be wired and tested end-to-end. + 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()) { + char puzzle_str[16]; + snprintf(puzzle_str, sizeof(puzzle_str), "%u", (unsigned) puzzle_id); + 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 uint8_t clamped = (level > NPC_MAX_HINT_LEVEL) - ? NPC_MAX_HINT_LEVEL : level; const char *text = kStubHints[clamped]; - ESP_LOGI(TAG, "hint request puzzle=%u level=%u -> stub \"%s\"", (unsigned) puzzle_id, (unsigned) level, text); - - npc_on_hint_request(&s_engine.core, s_engine.core.total_elapsed_ms); cb(puzzle_id, clamped, ESP_OK, text, user_ctx); 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..365d5e7 --- /dev/null +++ b/idf_zacus/components/voice_pipeline/CMakeLists.txt @@ -0,0 +1,16 @@ +idf_component_register( + SRCS + "voice_pipeline.c" + INCLUDE_DIRS + "include" + REQUIRES + esp_driver_i2s + esp_timer + esp_system + nvs_flash + freertos + log + media_manager + npc_engine + hints_client +) 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..210ca6a --- /dev/null +++ b/idf_zacus/components/voice_pipeline/include/voice_pipeline.h @@ -0,0 +1,53 @@ +// voice_pipeline — I2S capture stub + state machine 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 +// +// Real wake-word (esp-sr WakeNet9) and AFE pipeline land in slice 6. +// +// HTTP plumbing to the hints engine 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; + +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 +} voice_pipeline_config_t; + +// Reasonable defaults for a Freenove ESP32-S3 + INMP441 wiring. Override per +// hardware revision before calling voice_pipeline_init(). +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); + +#ifdef __cplusplus +} +#endif 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..a86798a --- /dev/null +++ b/idf_zacus/components/voice_pipeline/voice_pipeline.c @@ -0,0 +1,159 @@ +#include "voice_pipeline.h" + +#include + +#include "driver/i2s_std.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static const char *TAG = "voice_pipeline"; + +#define CAPTURE_TASK_STACK 4096 +#define CAPTURE_TASK_PRIO 4 +#define CAPTURE_CHUNK_BYTES 1024 // 16-bit @16 kHz = 32 ms slice + +static struct { + bool ready; + voice_pipeline_config_t cfg; + i2s_chan_handle_t rx_chan; + voice_state_t state; + TaskHandle_t capture_task; + bool capture_run; +} 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; +} + +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; +} + +static void capture_task(void *pv) { + static uint8_t buf[CAPTURE_CHUNK_BYTES]; + size_t bytes_read = 0; + uint32_t total = 0; + uint32_t ticks = 0; + 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; + + 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; + } + + s_pipe.ready = true; + s_pipe.state = VOICE_STATE_IDLE; + ESP_LOGI(TAG, "ready (BCLK=%d WS=%d DIN=%d @%u Hz)", + cfg.i2s_bclk_pin, cfg.i2s_ws_pin, cfg.i2s_din_pin, + (unsigned) cfg.sample_rate_hz); + + 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; +} diff --git a/idf_zacus/main/CMakeLists.txt b/idf_zacus/main/CMakeLists.txt index e653168..88e27ab 100644 --- a/idf_zacus/main/CMakeLists.txt +++ b/idf_zacus/main/CMakeLists.txt @@ -8,6 +8,8 @@ idf_component_register( ota_server media_manager npc_engine + hints_client + voice_pipeline nvs_flash esp_timer esp_system diff --git a/idf_zacus/main/main.c b/idf_zacus/main/main.c index 71f3592..8fff8bf 100644 --- a/idf_zacus/main/main.c +++ b/idf_zacus/main/main.c @@ -39,6 +39,12 @@ #include "ota_server.h" #include "media_manager.h" #include "npc_engine.h" +#include "hints_client.h" +#include "voice_pipeline.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" static const char *TAG = "zacus_main"; @@ -291,6 +297,24 @@ void app_main(void) { 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)); + } + + voice_pipeline_config_t voice_cfg; + voice_pipeline_default_config(&voice_cfg); + voice_cfg.auto_start_capture = false; + 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)); + } } } -- 2.52.0 From 44a987d8b448d00c0e528a8afbba72ab5df76a7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Sun, 3 May 2026 21:24:23 +0200 Subject: [PATCH 07/19] feat(idf): voice_pipeline + esp-sr wakenet (placeholder) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 6 of the IDF migration: bring up the Espressif AFE + WakeNet9 pipeline on top of the existing slice-5 I2S capture stub. The active wake word is the standard wn9_hiesp ("Hi ESP") model — custom "Professeur Zacus" needs an Espressif training round-trip and is tracked under P4 of the voice spec. The capture task feeds AFE, fetches results, fires a user callback on WAKENET_DETECTED and auto-transitions to VOICE_STATE_LISTENING. If esp-sr fails to init (model partition missing, PSRAM exhausted, AFE alloc OOM) the pipeline falls back to the slice-5 stub so the rest of the firmware still boots. - main/idf_component.yml: managed dep on espressif/esp-sr ~2.0 - partitions.csv: new 1 MB SPIFFS "model" partition for srmodels.bin - sdkconfig.defaults: enable wn9_hiesp; disable MultiNet (off-device) - voice_pipeline.h: new wake callback API + enable_wake_word config - main.c: enable wake word, log + restart capture from callback Built green on ESP-IDF 5.4 with esp-sr 2.0.5. App size 1.27 MB (18% free in 1.5 MB factory partition); srmodels.bin 284 KB (72% free in 1 MB model partition). No partition table grow needed this slice. --- .../components/voice_pipeline/CMakeLists.txt | 5 + .../voice_pipeline/include/voice_pipeline.h | 49 +++- .../voice_pipeline/voice_pipeline.c | 249 ++++++++++++++++-- idf_zacus/main/idf_component.yml | 6 + idf_zacus/main/main.c | 30 ++- idf_zacus/partitions.csv | 9 +- idf_zacus/sdkconfig.defaults | 11 + 7 files changed, 332 insertions(+), 27 deletions(-) diff --git a/idf_zacus/components/voice_pipeline/CMakeLists.txt b/idf_zacus/components/voice_pipeline/CMakeLists.txt index 365d5e7..e71a385 100644 --- a/idf_zacus/components/voice_pipeline/CMakeLists.txt +++ b/idf_zacus/components/voice_pipeline/CMakeLists.txt @@ -13,4 +13,9 @@ idf_component_register( 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 ) diff --git a/idf_zacus/components/voice_pipeline/include/voice_pipeline.h b/idf_zacus/components/voice_pipeline/include/voice_pipeline.h index 210ca6a..1968a11 100644 --- a/idf_zacus/components/voice_pipeline/include/voice_pipeline.h +++ b/idf_zacus/components/voice_pipeline/include/voice_pipeline.h @@ -1,13 +1,26 @@ -// voice_pipeline — I2S capture stub + state machine for the Zacus NPC voice -// loop. Slice 5 deliverable: +// 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 // -// Real wake-word (esp-sr WakeNet9) and AFE pipeline land in slice 6. +// Slice 6 deliverable (this revision): +// * 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. // -// HTTP plumbing to the hints engine lives in the separate hints_client -// component to avoid a circular dependency with npc_engine. +// The HTTP plumbing to the hints engine still lives in the separate +// hints_client component to avoid a circular dependency with npc_engine. +// STT streaming over WebSocket lands in a later slice. #pragma once @@ -28,16 +41,29 @@ typedef enum { 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); + 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) } 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. void voice_pipeline_default_config(voice_pipeline_config_t *out); esp_err_t voice_pipeline_init(const voice_pipeline_config_t *config); @@ -48,6 +74,19 @@ 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); + +// 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); + #ifdef __cplusplus } #endif diff --git a/idf_zacus/components/voice_pipeline/voice_pipeline.c b/idf_zacus/components/voice_pipeline/voice_pipeline.c index a86798a..95731ad 100644 --- a/idf_zacus/components/voice_pipeline/voice_pipeline.c +++ b/idf_zacus/components/voice_pipeline/voice_pipeline.c @@ -1,17 +1,40 @@ +// voice_pipeline — ESP-IDF implementation. See voice_pipeline.h for the +// scope of slices 5 and 6. 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 #include "driver/i2s_std.h" +#include "esp_heap_caps.h" #include "esp_log.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"; -#define CAPTURE_TASK_STACK 4096 -#define CAPTURE_TASK_PRIO 4 -#define CAPTURE_CHUNK_BYTES 1024 // 16-bit @16 kHz = 32 ms slice +// 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 static struct { bool ready; @@ -20,6 +43,19 @@ static struct { 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 } s_pipe = { .state = VOICE_STATE_IDLE, }; @@ -31,6 +67,18 @@ void voice_pipeline_default_config(voice_pipeline_config_t *out) { out->i2s_din_pin = 22; // GPIO22 -> SD (INMP441) out->sample_rate_hz = 16000; out->auto_start_capture = false; + out->enable_wake_word = false; +} + +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; } static esp_err_t i2s_setup(void) { @@ -64,23 +112,172 @@ static esp_err_t i2s_setup(void) { 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); + + ESP_LOGI(TAG, "AFE up: feed_chunk=%d samples × %d ch, sample_rate=%d Hz", + s_pipe.afe_feed_chunk_samples, + s_pipe.afe_feed_channel_num, + 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. +// * AFE inactive (slice-5 stub) : log a heartbeat every ~1.6 s. static void capture_task(void *pv) { - static uint8_t buf[CAPTURE_CHUNK_BYTES]; - size_t bytes_read = 0; - uint32_t total = 0; - uint32_t ticks = 0; - 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); + 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; + + 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); + } + } + } + + if (++feeds % 100 == 0) { + ESP_LOGD(TAG, "AFE feed heartbeat: %u chunks", (unsigned) feeds); + } + } + 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)); } - } 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; @@ -108,11 +305,23 @@ esp_err_t voice_pipeline_init(const voice_pipeline_config_t *config) { return ESP_OK; } + 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(); + } + } + s_pipe.ready = true; s_pipe.state = VOICE_STATE_IDLE; - ESP_LOGI(TAG, "ready (BCLK=%d WS=%d DIN=%d @%u Hz)", + ESP_LOGI(TAG, "ready (BCLK=%d WS=%d DIN=%d @%u Hz, wake=%s)", cfg.i2s_bclk_pin, cfg.i2s_ws_pin, cfg.i2s_din_pin, - (unsigned) cfg.sample_rate_hz); + (unsigned) cfg.sample_rate_hz, + voice_pipeline_wake_word_active() ? s_pipe.wake_word_name : "off"); if (cfg.auto_start_capture) { return voice_pipeline_start_capture(); diff --git a/idf_zacus/main/idf_component.yml b/idf_zacus/main/idf_component.yml index 9ae2238..4aa51b7 100644 --- a/idf_zacus/main/idf_component.yml +++ b/idf_zacus/main/idf_component.yml @@ -3,3 +3,9 @@ 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" diff --git a/idf_zacus/main/main.c b/idf_zacus/main/main.c index 8fff8bf..c40f22e 100644 --- a/idf_zacus/main/main.c +++ b/idf_zacus/main/main.c @@ -70,6 +70,23 @@ 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)); + } +} + // ─── Helpers ───────────────────────────────────────────────────────────────── static void log_heap_stats(const char *phase) { @@ -309,11 +326,22 @@ void app_main(void) { voice_pipeline_config_t voice_cfg; voice_pipeline_default_config(&voice_cfg); - voice_cfg.auto_start_capture = false; + // 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; + voice_pipeline_set_wake_callback(on_voice_wake, 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"); } } } diff --git a/idf_zacus/partitions.csv b/idf_zacus/partitions.csv index 021a85b..1fa7669 100644 --- a/idf_zacus/partitions.csv +++ b/idf_zacus/partitions.csv @@ -1,8 +1,14 @@ # Zacus master partition table — merges ota_server's OTA layout (factory + # ota_0 + ota_1 + otadata) with a dedicated LittleFS partition for assets -# (wakenet model, NPC phrases, scenario IR cache, …). +# (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-0x4A0000 apps (3×1.5 MB), +# 0x4A0000-0x6A0000 storage (LittleFS 2 MB), +# 0x6A0000-0x7A0000 model (SPIFFS 1 MB, esp-sr models), +# remainder unused. # # Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, @@ -12,3 +18,4 @@ factory, app, factory, 0x20000, 0x180000, ota_0, app, ota_0, , 0x180000, ota_1, app, ota_1, , 0x180000, storage, data, littlefs,, 0x200000, +model, data, spiffs, , 0x100000, diff --git a/idf_zacus/sdkconfig.defaults b/idf_zacus/sdkconfig.defaults index f8f587f..dc30aeb 100644 --- a/idf_zacus/sdkconfig.defaults +++ b/idf_zacus/sdkconfig.defaults @@ -47,3 +47,14 @@ 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 -- 2.52.0 From 479e92e9525c702a5490b50bf38e67a0938cc875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Sun, 3 May 2026 21:56:30 +0200 Subject: [PATCH 08/19] feat(idf): voice_pipeline STT websocket streaming --- .../components/voice_pipeline/CMakeLists.txt | 8 + .../voice_pipeline/include/voice_pipeline.h | 51 ++- .../voice_pipeline/voice_pipeline.c | 173 ++++++++++- .../voice_pipeline/voice_pipeline_ws.c | 290 ++++++++++++++++++ .../voice_pipeline/voice_pipeline_ws.h | 55 ++++ idf_zacus/main/idf_component.yml | 5 + idf_zacus/main/main.c | 24 ++ 7 files changed, 597 insertions(+), 9 deletions(-) create mode 100644 idf_zacus/components/voice_pipeline/voice_pipeline_ws.c create mode 100644 idf_zacus/components/voice_pipeline/voice_pipeline_ws.h diff --git a/idf_zacus/components/voice_pipeline/CMakeLists.txt b/idf_zacus/components/voice_pipeline/CMakeLists.txt index e71a385..9dbbe2a 100644 --- a/idf_zacus/components/voice_pipeline/CMakeLists.txt +++ b/idf_zacus/components/voice_pipeline/CMakeLists.txt @@ -1,12 +1,16 @@ idf_component_register( SRCS "voice_pipeline.c" + "voice_pipeline_ws.c" INCLUDE_DIRS "include" + PRIV_INCLUDE_DIRS + "." REQUIRES esp_driver_i2s esp_timer esp_system + esp_event nvs_flash freertos log @@ -18,4 +22,8 @@ idf_component_register( # 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_pipeline.h b/idf_zacus/components/voice_pipeline/include/voice_pipeline.h index 1968a11..af77773 100644 --- a/idf_zacus/components/voice_pipeline/include/voice_pipeline.h +++ b/idf_zacus/components/voice_pipeline/include/voice_pipeline.h @@ -6,7 +6,7 @@ // * placeholder VAD/wake-word task that just logs heartbeat // * state machine idle <-> listening <-> speaking <-> muted // -// Slice 6 deliverable (this revision): +// 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 @@ -18,9 +18,20 @@ // * wake event routes through a user callback and auto-transitions // the pipeline into VOICE_STATE_LISTENING. // +// Slice 7 deliverable (this revision): +// * 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). +// // The HTTP plumbing to the hints engine still lives in the separate // hints_client component to avoid a circular dependency with npc_engine. -// STT streaming over WebSocket lands in a later slice. #pragma once @@ -49,6 +60,16 @@ typedef enum { // 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) @@ -56,6 +77,14 @@ typedef struct { 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; } voice_pipeline_config_t; // Reasonable defaults for a Freenove ESP32-S3 + INMP441 wiring. Override per @@ -64,6 +93,8 @@ typedef struct { // 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); @@ -80,6 +111,11 @@ esp_err_t voice_pipeline_set_state(voice_state_t state); 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 @@ -87,6 +123,17 @@ esp_err_t voice_pipeline_set_wake_callback(voice_wake_callback_t cb, // 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); + #ifdef __cplusplus } #endif diff --git a/idf_zacus/components/voice_pipeline/voice_pipeline.c b/idf_zacus/components/voice_pipeline/voice_pipeline.c index 95731ad..666f94a 100644 --- a/idf_zacus/components/voice_pipeline/voice_pipeline.c +++ b/idf_zacus/components/voice_pipeline/voice_pipeline.c @@ -1,16 +1,18 @@ // voice_pipeline — ESP-IDF implementation. See voice_pipeline.h for the -// scope of slices 5 and 6. The AFE / WakeNet integration is gated on +// 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" @@ -36,6 +38,15 @@ static const char *TAG = "voice_pipeline"; #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; @@ -56,6 +67,16 @@ static struct { 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, }; @@ -68,6 +89,7 @@ void voice_pipeline_default_config(voice_pipeline_config_t *out) { out->sample_rate_hz = 16000; out->auto_start_capture = false; out->enable_wake_word = false; + out->voice_bridge_ws_url = NULL; } bool voice_pipeline_wake_word_active(void) { @@ -81,6 +103,77 @@ esp_err_t voice_pipeline_set_wake_callback(voice_wake_callback_t cb, 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); @@ -169,12 +262,14 @@ static esp_err_t wake_word_setup(void) { 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_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, sample_rate=%d Hz", + 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); @@ -196,7 +291,8 @@ static void wake_word_teardown(void) { // Capture task. Two modes: // * AFE active (esp-sr loaded) : feed I2S into AFE, fetch results, -// detect wake → fire callback. +// 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()) { @@ -250,6 +346,46 @@ static void capture_task(void *pv) { 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"); + } + } } } @@ -257,6 +393,10 @@ static void capture_task(void *pv) { 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. @@ -294,6 +434,8 @@ esp_err_t voice_pipeline_init(const voice_pipeline_config_t *config) { } 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", @@ -316,12 +458,29 @@ esp_err_t voice_pipeline_init(const voice_pipeline_config_t *config) { } } + // 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)", + 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_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(); 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..fc82d49 --- /dev/null +++ b/idf_zacus/components/voice_pipeline/voice_pipeline_ws.c @@ -0,0 +1,290 @@ +// 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" + +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; +} 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); + 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"); + ESP_LOGI(TAG, "intent (model=%s): %s", + cJSON_IsString(model) ? model->valuestring : "?", + cJSON_IsString(content) ? content->valuestring : "?"); + // Slice 7: log only — wiring intent into npc_engine is the next slice. + } 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)"); + } 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. + // The voice-bridge only sends us text; ignore binary just in case. + if (data->op_code == 0x01 && data->data_len > 0) { + handle_text_message((const char *) data->data_ptr, 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/idf_component.yml b/idf_zacus/main/idf_component.yml index 4aa51b7..748fa63 100644 --- a/idf_zacus/main/idf_component.yml +++ b/idf_zacus/main/idf_component.yml @@ -9,3 +9,8 @@ dependencies: ## 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" diff --git a/idf_zacus/main/main.c b/idf_zacus/main/main.c index c40f22e..361e09a 100644 --- a/idf_zacus/main/main.c +++ b/idf_zacus/main/main.c @@ -46,6 +46,12 @@ // 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. @@ -87,6 +93,19 @@ static void on_voice_wake(const char *wake_word, void *user_ctx) { } } +// Slice 7: STT callback. Runs on the WebSocket event-loop task — keep +// it short and offload heavy work (intent dispatch, hint requests) to +// the npc_engine via FreeRTOS queue in a follow-up slice. For now we +// just log; on `final == true` the next slice will route the text into +// `npc_engine_request_hint()` (or the keyword fast-path). +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)"); + // TODO(slice 8): if final && text, dispatch to npc_engine / + // hints_client through a queue so this callback stays non-blocking. +} + // ─── Helpers ───────────────────────────────────────────────────────────────── static void log_heap_stats(const char *phase) { @@ -331,7 +350,12 @@ void app_main(void) { // 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; 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", -- 2.52.0 From c3a771ca8b984d44fb85ebf61fc49ece3b9104ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Sun, 3 May 2026 22:07:03 +0200 Subject: [PATCH 09/19] feat(idf): voice_dispatcher (STT->hints fast-path) --- .../components/voice_pipeline/CMakeLists.txt | 1 + .../voice_pipeline/include/voice_dispatcher.h | 57 ++++ .../voice_pipeline/voice_dispatcher.c | 270 ++++++++++++++++++ .../voice_pipeline/voice_pipeline_ws.c | 18 +- idf_zacus/main/main.c | 22 +- 5 files changed, 358 insertions(+), 10 deletions(-) create mode 100644 idf_zacus/components/voice_pipeline/include/voice_dispatcher.h create mode 100644 idf_zacus/components/voice_pipeline/voice_dispatcher.c diff --git a/idf_zacus/components/voice_pipeline/CMakeLists.txt b/idf_zacus/components/voice_pipeline/CMakeLists.txt index 9dbbe2a..4f30fa0 100644 --- a/idf_zacus/components/voice_pipeline/CMakeLists.txt +++ b/idf_zacus/components/voice_pipeline/CMakeLists.txt @@ -2,6 +2,7 @@ idf_component_register( SRCS "voice_pipeline.c" "voice_pipeline_ws.c" + "voice_dispatcher.c" INCLUDE_DIRS "include" PRIV_INCLUDE_DIRS 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/voice_dispatcher.c b/idf_zacus/components/voice_pipeline/voice_dispatcher.c new file mode 100644 index 0000000..93870aa --- /dev/null +++ b/idf_zacus/components/voice_pipeline/voice_dispatcher.c @@ -0,0 +1,270 @@ +// 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 8 keyword fast-path. Matched as case-insensitive ASCII-folded +// substrings of the normalized STT text. Adding more variants here is +// cheap (linear scan, ~8 entries). Keep this list FR-only for now — +// we'll branch on a locale flag once the bridge sends one. +static const char *const HINT_KEYWORDS[] = { + "indice", + "aide", + "hint", + "bloque", // matches both "bloqué" and "bloque" after fold + "perdu", // matches "perdu" / "perdue" via substring + "comment faire", + "je sais pas", + "sais pas", +}; +static const size_t HINT_KEYWORDS_COUNT = + sizeof(HINT_KEYWORDS) / sizeof(HINT_KEYWORDS[0]); + +// Placeholder puzzle id used while the scenario engine doesn't yet +// surface the active puzzle context. The hints engine treats id == 0 +// as "decide based on current scene context" so this stays usable +// end-to-end. See P1 voice-pipeline spec §slice-8. +#define DISPATCHER_PLACEHOLDER_PUZZLE_ID 0 +// 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'; +} + +static bool contains_keyword(const char *normalized) { + if (!normalized || !*normalized) return false; + for (size_t i = 0; i < HINT_KEYWORDS_COUNT; ++i) { + if (strstr(normalized, HINT_KEYWORDS[i]) != NULL) { + ESP_LOGI(TAG, "keyword hit: \"%s\"", HINT_KEYWORDS[i]); + return true; + } + } + return false; +} + +// ── 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 FR hint keywords)", + (unsigned) HINT_KEYWORDS_COUNT); + 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); + + if (!contains_keyword(folded)) { + ESP_LOGI(TAG, "no keyword match, deferring to LLM intent path"); + return; + } + + esp_err_t err = npc_engine_request_hint( + DISPATCHER_PLACEHOLDER_PUZZLE_ID, + 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=%u level=%u)", + (unsigned) DISPATCHER_PLACEHOLDER_PUZZLE_ID, + (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_ws.c b/idf_zacus/components/voice_pipeline/voice_pipeline_ws.c index fc82d49..13cfa3d 100644 --- a/idf_zacus/components/voice_pipeline/voice_pipeline_ws.c +++ b/idf_zacus/components/voice_pipeline/voice_pipeline_ws.c @@ -33,6 +33,11 @@ #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" + static const char *TAG = "voice_ws"; #define WS_CONNECT_TIMEOUT_MS 5000 @@ -78,6 +83,10 @@ static void handle_text_message(const char *data, int len) { 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); } @@ -85,10 +94,13 @@ static void handle_text_message(const char *data, int len) { } 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", - cJSON_IsString(model) ? model->valuestring : "?", - cJSON_IsString(content) ? content->valuestring : "?"); - // Slice 7: log only — wiring intent into npc_engine is the next slice. + 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, "error") == 0) { const cJSON *msg = cJSON_GetObjectItemCaseSensitive(root, "message"); ESP_LOGW(TAG, "bridge error: %s", diff --git a/idf_zacus/main/main.c b/idf_zacus/main/main.c index 361e09a..1b85d34 100644 --- a/idf_zacus/main/main.c +++ b/idf_zacus/main/main.c @@ -41,6 +41,7 @@ #include "npc_engine.h" #include "hints_client.h" #include "voice_pipeline.h" +#include "voice_dispatcher.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. @@ -93,17 +94,15 @@ static void on_voice_wake(const char *wake_word, void *user_ctx) { } } -// Slice 7: STT callback. Runs on the WebSocket event-loop task — keep -// it short and offload heavy work (intent dispatch, hint requests) to -// the npc_engine via FreeRTOS queue in a follow-up slice. For now we -// just log; on `final == true` the next slice will route the text into -// `npc_engine_request_hint()` (or the keyword fast-path). +// 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)"); - // TODO(slice 8): if final && text, dispatch to npc_engine / - // hints_client through a queue so this callback stays non-blocking. } // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -343,6 +342,15 @@ void app_main(void) { esp_err_to_name(hints_err)); } + // 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 -- 2.52.0 From 6d3e989ca3ea65597ed83c0708e1a88d9d212f2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Mon, 4 May 2026 00:05:02 +0200 Subject: [PATCH 10/19] feat(idf): TTS playback I2S TX + mute gate voice_pipeline slice 9: I2S_NUM_1 TX channel for TTS playback, voice_pipeline_play_start/chunk/end APIs (clock reconfig 16k mic vs 24k F5), mute-during-TTS gate in capture_task (state==SPEAKING skips AFE feed to prevent echo retriggering wake). enable_tts_playback opt-in default false. Build green: 1.45 MB app, 8% free. --- .../voice_pipeline/include/voice_pipeline.h | 46 +++++- .../voice_pipeline/voice_pipeline.c | 136 ++++++++++++++++++ 2 files changed, 181 insertions(+), 1 deletion(-) diff --git a/idf_zacus/components/voice_pipeline/include/voice_pipeline.h b/idf_zacus/components/voice_pipeline/include/voice_pipeline.h index af77773..794f5a9 100644 --- a/idf_zacus/components/voice_pipeline/include/voice_pipeline.h +++ b/idf_zacus/components/voice_pipeline/include/voice_pipeline.h @@ -18,7 +18,7 @@ // * wake event routes through a user callback and auto-transitions // the pipeline into VOICE_STATE_LISTENING. // -// Slice 7 deliverable (this revision): +// 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 @@ -30,6 +30,18 @@ // `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. @@ -85,6 +97,16 @@ typedef struct { // 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 @@ -134,6 +156,28 @@ esp_err_t voice_pipeline_stop_streaming(void); // 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_pipeline.c b/idf_zacus/components/voice_pipeline/voice_pipeline.c index 666f94a..7a220b3 100644 --- a/idf_zacus/components/voice_pipeline/voice_pipeline.c +++ b/idf_zacus/components/voice_pipeline/voice_pipeline.c @@ -51,6 +51,11 @@ 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; @@ -90,6 +95,13 @@ void voice_pipeline_default_config(voice_pipeline_config_t *out) { 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) { @@ -205,6 +217,48 @@ static esp_err_t i2s_setup(void) { 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) { @@ -332,6 +386,13 @@ static void capture_task(void *pv) { } 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. @@ -447,6 +508,20 @@ esp_err_t voice_pipeline_init(const voice_pipeline_config_t *config) { 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) { @@ -525,3 +600,64 @@ 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; +} -- 2.52.0 From 3585a65c8f75a974b97db6632f7463a2cf9b31c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Mon, 4 May 2026 00:13:54 +0200 Subject: [PATCH 11/19] feat(idf): PLIP /voice/hook endpoint + TTS enable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context: The PLIP retro-telephone annex (Si3210 SLIC + RJ9 combine) needs a way to drive the master ESP32 voice pipeline directly from the hook switch, so the escape-room narrative "le telephone sonne, decroche pour parler a Zacus" works without relying on the wake-word. Approach: - Expose the existing ota_server httpd_handle_t via a new getter so a second component can register routes on the same listener (port 80) instead of standing up a parallel httpd. - New voice_hook_endpoint component attaches POST /voice/hook + GET /voice/hook/state to that shared handle. Off-hook arms the pipeline (LISTENING + capture + WS streaming, bypassing wake-word); on-hook tears it down (stop streaming + stop capture + IDLE). - Enable enable_tts_playback so TTS frames received over the bridge WS land on the MAX98357A DAC. Wake-word stays enabled (mixed mode: hook is primary, "hi esp" is fallback). Changes: - ota_server: add ota_server_get_handle() returning the static httpd_handle_t for late URI handler registration. - voice_hook_endpoint: new component (CMakeLists, header, .c, README). POST handler validates body (<=256 B), parses cJSON, dispatches state on/off (idempotent), returns JSON status. GET state handler exposes voice_state + wake_word_active + streaming for debugging. Explicit GET on /voice/hook returns 405 with Allow: POST so PLIP integrators see an actionable error instead of a 404. - main.c: include header, init voice_hook_endpoint after ota_server succeeds, flip voice_cfg.enable_tts_playback = true. - main/CMakeLists.txt: add voice_hook_endpoint to REQUIRES. - README.md: document the wire protocol PLIP must call (state values, response codes, curl examples, mixed-mode behaviour). Impact: - PLIP firmware can now drive the Zacus voice loop over Wi-Fi via REST without any code change on the master side beyond this slice. - TTS replies from the MacStudio voice-bridge are audible on the master speaker (slice 9 shipped the API; slice 10 turns it on). - App size 0x1639a0 (1.39 MB) in 0x180000 partition — 7% headroom, no partitions.csv resize needed. --- .../ota_server/include/ota_server.h | 13 + idf_zacus/components/ota_server/ota_server.c | 4 + .../voice_hook_endpoint/CMakeLists.txt | 13 + .../components/voice_hook_endpoint/README.md | 130 ++++++++++ .../include/voice_hook_endpoint.h | 56 +++++ .../voice_hook_endpoint/voice_hook_endpoint.c | 227 ++++++++++++++++++ idf_zacus/main/CMakeLists.txt | 1 + idf_zacus/main/main.c | 19 ++ 8 files changed, 463 insertions(+) create mode 100644 idf_zacus/components/voice_hook_endpoint/CMakeLists.txt create mode 100644 idf_zacus/components/voice_hook_endpoint/README.md create mode 100644 idf_zacus/components/voice_hook_endpoint/include/voice_hook_endpoint.h create mode 100644 idf_zacus/components/voice_hook_endpoint/voice_hook_endpoint.c diff --git a/idf_zacus/components/ota_server/include/ota_server.h b/idf_zacus/components/ota_server/include/ota_server.h index 7e30345..9402b35 100644 --- a/idf_zacus/components/ota_server/include/ota_server.h +++ b/idf_zacus/components/ota_server/include/ota_server.h @@ -77,6 +77,19 @@ const ota_status_t* ota_server_get_status(void); */ 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 index 1ad0389..d1a8e1e 100644 --- a/idf_zacus/components/ota_server/ota_server.c +++ b/idf_zacus/components/ota_server/ota_server.c @@ -360,3 +360,7 @@ const ota_status_t* ota_server_get_status(void) { 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/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/main/CMakeLists.txt b/idf_zacus/main/CMakeLists.txt index 88e27ab..e49f0f1 100644 --- a/idf_zacus/main/CMakeLists.txt +++ b/idf_zacus/main/CMakeLists.txt @@ -10,6 +10,7 @@ idf_component_register( npc_engine hints_client voice_pipeline + voice_hook_endpoint nvs_flash esp_timer esp_system diff --git a/idf_zacus/main/main.c b/idf_zacus/main/main.c index 1b85d34..4c62294 100644 --- a/idf_zacus/main/main.c +++ b/idf_zacus/main/main.c @@ -42,6 +42,7 @@ #include "hints_client.h" #include "voice_pipeline.h" #include "voice_dispatcher.h" +#include "voice_hook_endpoint.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. @@ -295,6 +296,18 @@ void app_main(void) { 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)); + } } if (mount_littlefs() == ESP_OK) { @@ -362,6 +375,12 @@ void app_main(void) { // 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); -- 2.52.0 From d47c23068ad3d2cf1723af2ed4c3680d457e83e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Mon, 4 May 2026 00:16:36 +0200 Subject: [PATCH 12/19] feat(idf): voice_pipeline_ws speak_* parsing Slice 9b: parse {type:speak_start,sample_rate,format} and {type:speak_end,duration_ms,backend,latency_ms} from voice-bridge, route binary frames to voice_pipeline_play_chunk during the speak window. State machine transitions LISTENING -> SPEAKING -> IDLE drive the mute gate added in slice 9. End-to-end TTS playback now works: ESP32 wake -> stream -> /voice/ws -> whisper -> intent -> F5-TTS PCM stream -> I2S out. --- .../voice_pipeline/voice_pipeline_ws.c | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/idf_zacus/components/voice_pipeline/voice_pipeline_ws.c b/idf_zacus/components/voice_pipeline/voice_pipeline_ws.c index 13cfa3d..8cdeb7e 100644 --- a/idf_zacus/components/voice_pipeline/voice_pipeline_ws.c +++ b/idf_zacus/components/voice_pipeline/voice_pipeline_ws.c @@ -37,6 +37,7 @@ // 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"; @@ -62,6 +63,10 @@ static struct { 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) { @@ -101,10 +106,47 @@ static void handle_text_message(const char *data, int len) { 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); } @@ -132,9 +174,13 @@ static void ws_event_handler(void *handler_args, esp_event_base_t base, case WEBSOCKET_EVENT_DATA: if (!data) break; // op_code 0x1 = text frame, 0x2 = binary, 0x8 = close, 0x9/0xA = ping/pong. - // The voice-bridge only sends us text; ignore binary just in case. 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: -- 2.52.0 From de22adc08c269329713a86875dc083f12fa0bf39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Mon, 4 May 2026 00:20:25 +0200 Subject: [PATCH 13/19] chore(idf): app partition 1.5MB -> 2MB (OTA dual) Bump factory + ota_0 + ota_1 from 0x180000 to 0x200000 (2 MB each) to give voice_pipeline / STT / hints_client / esp-sr slices ~25% free margin (was 7%). OTA dual-bank rollback symmetry preserved (all three app slots same size). Storage (LittleFS) grows from 2 MB to 5 MB to host MP3 cue pool; model (esp-sr SPIFFS) stays at 1 MB. Layout uses 12.1 MB of the 16 MB flash, leaves 3.87 MB unused for future growth. idf.py build reports zacus_master.bin = 0x1639a0 (1.39 MB), 31% free in 0x200000 partition. Reordered model before storage in CSV so auto offsets cascade as planned (model at 0x620000, storage at 0x720000). No sdkconfig change. Migration only requires erasing flash on first deploy because partition table changed. --- idf_zacus/partitions.csv | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/idf_zacus/partitions.csv b/idf_zacus/partitions.csv index 1fa7669..6107bf0 100644 --- a/idf_zacus/partitions.csv +++ b/idf_zacus/partitions.csv @@ -5,17 +5,21 @@ # esp-sr component CMake glue). # # Total target: 16 MB flash (Freenove ESP32-S3 N16R8). -# Layout (rough): 0x000000-0x020000 system, 0x020000-0x4A0000 apps (3×1.5 MB), -# 0x4A0000-0x6A0000 storage (LittleFS 2 MB), -# 0x6A0000-0x7A0000 model (SPIFFS 1 MB, esp-sr models), -# remainder unused. +# 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, 0x180000, -ota_0, app, ota_0, , 0x180000, -ota_1, app, ota_1, , 0x180000, -storage, data, littlefs,, 0x200000, +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, -- 2.52.0 From ec6f36360657a42d323d4d46e4b0dd71598b11ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Mon, 4 May 2026 00:25:18 +0200 Subject: [PATCH 14/19] feat(idf): hints lifecycle + group_profile NVS hints_client gains puzzle_start, attempt_failed, set_group_profile (whitelist TECH/NON_TECH/MIXED/BOTH default MIXED). npc_engine.set_step now auto-fires hints_client_puzzle_start; new helpers current_puzzle_id and report_failed_attempt. voice_dispatcher detects failure keywords (non/faux/mauvais/rate/marche pas) and bumps the failed-attempt counter before the hint fast-path. main.c reads NVS zacus/group_profile at boot. Build green: 1.40 MB / 2 MB partition (30% free). --- .../components/hints_client/hints_client.c | 180 ++++++++++++++---- .../hints_client/include/hints_client.h | 59 +++++- .../npc_engine/include/npc_engine.h | 19 ++ idf_zacus/components/npc_engine/npc_engine.c | 76 +++++++- .../voice_pipeline/voice_dispatcher.c | 86 +++++++-- idf_zacus/main/main.c | 36 ++++ 6 files changed, 399 insertions(+), 57 deletions(-) diff --git a/idf_zacus/components/hints_client/hints_client.c b/idf_zacus/components/hints_client/hints_client.c index 60c1635..d40a5e4 100644 --- a/idf_zacus/components/hints_client/hints_client.c +++ b/idf_zacus/components/hints_client/hints_client.c @@ -17,14 +17,33 @@ 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 { @@ -51,10 +70,15 @@ esp_err_t hints_client_init(const char *base_url) { 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", - s_client.base_url, s_client.session_id); + 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; } @@ -62,6 +86,25 @@ 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; @@ -78,6 +121,53 @@ static esp_err_t http_event_cb(esp_http_client_event_t *evt) { 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; @@ -85,14 +175,14 @@ esp_err_t hints_client_ask(const char *puzzle_id, uint8_t level, return ESP_ERR_INVALID_ARG; } - char url[HINTS_CLIENT_BASE_URL_MAX + 32]; - snprintf(url, sizeof(url), "%s%s", s_client.base_url, ASK_PATH); - 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; @@ -101,36 +191,10 @@ esp_err_t hints_client_ask(const char *puzzle_id, uint8_t level, recv_buf_t recv = {.buf = recv_storage, .cap = sizeof(recv_storage), .len = 0}; recv_storage[0] = '\0'; - esp_http_client_config_t cfg = { - .url = url, - .method = HTTP_METHOD_POST, - .timeout_ms = HINTS_CLIENT_TIMEOUT_MS, - .event_handler = http_event_cb, - .user_data = &recv, - .disable_auto_redirect = true, - }; - - esp_http_client_handle_t client = esp_http_client_init(&cfg); - if (!client) { - free(body_str); - 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); + esp_err_t err = post_json(ASK_PATH, body_str, + HINTS_CLIENT_TIMEOUT_MS, &recv); free(body_str); - - if (err != ESP_OK) { - ESP_LOGW(TAG, "HTTP perform failed: %s", esp_err_to_name(err)); - return err; - } - if (status < 200 || status >= 300) { - ESP_LOGW(TAG, "non-2xx %d body=%.*s", status, (int) recv.len, recv.buf); - return ESP_FAIL; - } + if (err != ESP_OK) return err; cJSON *root = cJSON_Parse(recv.buf); if (!root) { @@ -160,6 +224,54 @@ esp_err_t hints_client_ask(const char *puzzle_id, uint8_t level, 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 { diff --git a/idf_zacus/components/hints_client/include/hints_client.h b/idf_zacus/components/hints_client/include/hints_client.h index 6b67d2b..fe25441 100644 --- a/idf_zacus/components/hints_client/include/hints_client.h +++ b/idf_zacus/components/hints_client/include/hints_client.h @@ -1,15 +1,30 @@ // hints_client — HTTP client to the Zacus hints engine. // -// POSTs JSON {"puzzle_id":..,"level":..,"session_id":..} to {base_url}/hints/ask -// and parses the "hint" / "source" / "refused" / "cooldown_until_ms" fields. +// POSTs JSON {"puzzle_id":..,"level":..,"session_id":..,"group_profile":..} +// to {base_url}/hints/ask and parses the "hint" / "source" / "refused" / +// "cooldown_until_ms" fields. // -// Two 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. The -// callback signature matches npc_hint_callback_t. +// 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 @@ -26,6 +41,8 @@ extern "C" { #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. @@ -64,6 +81,30 @@ esp_err_t hints_client_ask_async(const char *puzzle_id_str, 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/npc_engine/include/npc_engine.h b/idf_zacus/components/npc_engine/include/npc_engine.h index 8fac767..e1b2c77 100644 --- a/idf_zacus/components/npc_engine/include/npc_engine.h +++ b/idf_zacus/components/npc_engine/include/npc_engine.h @@ -179,6 +179,25 @@ esp_err_t npc_engine_request_hint(uint8_t puzzle_id, uint8_t level, // 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 index da686dc..9829689 100644 --- a/idf_zacus/components/npc_engine/npc_engine.c +++ b/idf_zacus/components/npc_engine/npc_engine.c @@ -334,14 +334,80 @@ esp_err_t npc_engine_trigger_cue(const char *cue_id) { 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; } @@ -360,8 +426,14 @@ esp_err_t npc_engine_request_hint(uint8_t puzzle_id, uint8_t level, // 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()) { - char puzzle_str[16]; - snprintf(puzzle_str, sizeof(puzzle_str), "%u", (unsigned) puzzle_id); + // 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); diff --git a/idf_zacus/components/voice_pipeline/voice_dispatcher.c b/idf_zacus/components/voice_pipeline/voice_dispatcher.c index 93870aa..ab9dabf 100644 --- a/idf_zacus/components/voice_pipeline/voice_dispatcher.c +++ b/idf_zacus/components/voice_pipeline/voice_dispatcher.c @@ -32,11 +32,23 @@ static const char *const HINT_KEYWORDS[] = { static const size_t HINT_KEYWORDS_COUNT = sizeof(HINT_KEYWORDS) / sizeof(HINT_KEYWORDS[0]); -// Placeholder puzzle id used while the scenario engine doesn't yet -// surface the active puzzle context. The hints engine treats id == 0 -// as "decide based on current scene context" so this stays usable -// end-to-end. See P1 voice-pipeline spec §slice-8. -#define DISPATCHER_PLACEHOLDER_PUZZLE_ID 0 +// 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 FAIL_KEYWORDS[] = { + "non", + "faux", + "mauvais", + "rate", // matches "raté" / "rate" after fold + "marche pas", + "ca marche pas", // ASCII-folded "ça marche pas" +}; +static const size_t FAIL_KEYWORDS_COUNT = + sizeof(FAIL_KEYWORDS) / sizeof(FAIL_KEYWORDS[0]); + // 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 @@ -171,17 +183,37 @@ static void normalize_for_match(const char *src, char *dst, size_t cap) { dst[out] = '\0'; } -static bool contains_keyword(const char *normalized) { +static bool contains_any(const char *normalized, + const char *const *table, size_t count, + const char **hit_out) { if (!normalized || !*normalized) return false; - for (size_t i = 0; i < HINT_KEYWORDS_COUNT; ++i) { - if (strstr(normalized, HINT_KEYWORDS[i]) != NULL) { - ESP_LOGI(TAG, "keyword hit: \"%s\"", HINT_KEYWORDS[i]); + for (size_t i = 0; i < count; ++i) { + if (strstr(normalized, table[i]) != NULL) { + if (hit_out) *hit_out = table[i]; return true; } } return false; } +static bool contains_keyword(const char *normalized) { + const char *hit = NULL; + if (contains_any(normalized, HINT_KEYWORDS, HINT_KEYWORDS_COUNT, &hit)) { + ESP_LOGI(TAG, "hint keyword hit: \"%s\"", hit); + return true; + } + return false; +} + +static bool contains_failure_signal(const char *normalized) { + const char *hit = NULL; + if (contains_any(normalized, FAIL_KEYWORDS, FAIL_KEYWORDS_COUNT, &hit)) { + ESP_LOGI(TAG, "failure keyword hit: \"%s\"", hit); + return true; + } + return false; +} + // ── Hint result callback ──────────────────────────────────────────── // // Slice 8 only logs the answer. TTS playback (sending the text back to @@ -231,21 +263,51 @@ void voice_dispatcher_handle_stt(const char *text, bool final) { 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( - DISPATCHER_PLACEHOLDER_PUZZLE_ID, + 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=%u level=%u)", - (unsigned) DISPATCHER_PLACEHOLDER_PUZZLE_ID, + ESP_LOGI(TAG, "hint request dispatched (puzzle=\"%s\" num=%u level=%u)", + puzzle_id, (unsigned) puzzle_num, (unsigned) DISPATCHER_DEFAULT_HINT_LEVEL); } } diff --git a/idf_zacus/main/main.c b/idf_zacus/main/main.c index 4c62294..4007d35 100644 --- a/idf_zacus/main/main.c +++ b/idf_zacus/main/main.c @@ -353,6 +353,42 @@ void app_main(void) { 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 -- 2.52.0 From 71bc9a5e37ae6aeec5d12886a79b810427770102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Mon, 4 May 2026 00:33:49 +0200 Subject: [PATCH 15/19] feat(idf): mDNS + /game/group_profile endpoint --- .../components/game_endpoint/CMakeLists.txt | 14 ++ idf_zacus/components/game_endpoint/README.md | 102 +++++++++ .../components/game_endpoint/game_endpoint.c | 200 ++++++++++++++++++ .../game_endpoint/include/game_endpoint.h | 49 +++++ idf_zacus/main/CMakeLists.txt | 2 + idf_zacus/main/idf_component.yml | 5 + idf_zacus/main/main.c | 85 ++++++++ 7 files changed, 457 insertions(+) create mode 100644 idf_zacus/components/game_endpoint/CMakeLists.txt create mode 100644 idf_zacus/components/game_endpoint/README.md create mode 100644 idf_zacus/components/game_endpoint/game_endpoint.c create mode 100644 idf_zacus/components/game_endpoint/include/game_endpoint.h diff --git a/idf_zacus/components/game_endpoint/CMakeLists.txt b/idf_zacus/components/game_endpoint/CMakeLists.txt new file mode 100644 index 0000000..4059f76 --- /dev/null +++ b/idf_zacus/components/game_endpoint/CMakeLists.txt @@ -0,0 +1,14 @@ +idf_component_register( + SRCS + "game_endpoint.c" + INCLUDE_DIRS + "include" + REQUIRES + esp_http_server + json + nvs_flash + hints_client + ota_server + freertos + log +) 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..c6dd9b0 --- /dev/null +++ b/idf_zacus/components/game_endpoint/game_endpoint.c @@ -0,0 +1,200 @@ +// 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 "cJSON.h" +#include "esp_err.h" +#include "esp_http_server.h" +#include "esp_log.h" +#include "nvs.h" +#include "nvs_flash.h" + +#include "hints_client.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); +} + +// ─── 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, + }; + + 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; + } + + ESP_LOGI(TAG, "game endpoint registered " + "(GET + POST /game/group_profile)"); + 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..3825c09 --- /dev/null +++ b/idf_zacus/components/game_endpoint/include/game_endpoint.h @@ -0,0 +1,49 @@ +// 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 + +/** + * @brief Attach `/game/group_profile` (GET + POST) 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 game_endpoint_init(httpd_handle_t server); + +#ifdef __cplusplus +} +#endif diff --git a/idf_zacus/main/CMakeLists.txt b/idf_zacus/main/CMakeLists.txt index e49f0f1..67233e0 100644 --- a/idf_zacus/main/CMakeLists.txt +++ b/idf_zacus/main/CMakeLists.txt @@ -11,10 +11,12 @@ idf_component_register( hints_client voice_pipeline voice_hook_endpoint + game_endpoint 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 index 748fa63..0ef5318 100644 --- a/idf_zacus/main/idf_component.yml +++ b/idf_zacus/main/idf_component.yml @@ -14,3 +14,8 @@ dependencies: ## 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 index 4007d35..aaddaa5 100644 --- a/idf_zacus/main/main.c +++ b/idf_zacus/main/main.c @@ -36,6 +36,8 @@ #include "nvs_flash.h" #include "nvs.h" +#include "mdns.h" + #include "ota_server.h" #include "media_manager.h" #include "npc_engine.h" @@ -43,6 +45,7 @@ #include "voice_pipeline.h" #include "voice_dispatcher.h" #include "voice_hook_endpoint.h" +#include "game_endpoint.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. @@ -273,6 +276,66 @@ static bool wifi_bring_up(void) { 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) { @@ -291,6 +354,16 @@ void app_main(void) { 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)); @@ -308,6 +381,18 @@ void app_main(void) { 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)); + } } if (mount_littlefs() == ESP_OK) { -- 2.52.0 From a743cfc87f8f70320cb8d672646f1a7a14945a73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Mon, 4 May 2026 00:55:23 +0200 Subject: [PATCH 16/19] feat(idf): voice_dispatcher fuzzy aliases --- .../voice_pipeline/voice_dispatcher.c | 128 ++++++++++++------ 1 file changed, 88 insertions(+), 40 deletions(-) diff --git a/idf_zacus/components/voice_pipeline/voice_dispatcher.c b/idf_zacus/components/voice_pipeline/voice_dispatcher.c index ab9dabf..58695d1 100644 --- a/idf_zacus/components/voice_pipeline/voice_dispatcher.c +++ b/idf_zacus/components/voice_pipeline/voice_dispatcher.c @@ -15,22 +15,44 @@ static const char *TAG = "voice_disp"; -// Slice 8 keyword fast-path. Matched as case-insensitive ASCII-folded -// substrings of the normalized STT text. Adding more variants here is -// cheap (linear scan, ~8 entries). Keep this list FR-only for now — -// we'll branch on a locale flag once the bridge sends one. -static const char *const HINT_KEYWORDS[] = { - "indice", - "aide", - "hint", - "bloque", // matches both "bloqué" and "bloque" after fold - "perdu", // matches "perdu" / "perdue" via substring - "comment faire", - "je sais pas", - "sais pas", +// 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}, }; -static const size_t HINT_KEYWORDS_COUNT = - sizeof(HINT_KEYWORDS) / sizeof(HINT_KEYWORDS[0]); // Slice 11 (P5) failure-signal keywords. These hints map onto the // hints engine's /attempt_failed lifecycle endpoint (best-effort, @@ -38,16 +60,22 @@ static const size_t HINT_KEYWORDS_COUNT = // 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 FAIL_KEYWORDS[] = { - "non", - "faux", - "mauvais", - "rate", // matches "raté" / "rate" after fold - "marche pas", - "ca marche pas", // ASCII-folded "ça marche pas" +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}, }; -static const size_t FAIL_KEYWORDS_COUNT = - sizeof(FAIL_KEYWORDS) / sizeof(FAIL_KEYWORDS[0]); // level == 0 = let the hints engine pick the escalation level via its // adaptive policy. See specs/AI_INTEGRATION_SPEC.md. @@ -183,37 +211,56 @@ static void normalize_for_match(const char *src, char *dst, size_t cap) { dst[out] = '\0'; } -static bool contains_any(const char *normalized, - const char *const *table, size_t count, - const char **hit_out) { - if (!normalized || !*normalized) return false; - for (size_t i = 0; i < count; ++i) { - if (strstr(normalized, table[i]) != NULL) { - if (hit_out) *hit_out = table[i]; - return true; +// 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 *hit = NULL; - if (contains_any(normalized, HINT_KEYWORDS, HINT_KEYWORDS_COUNT, &hit)) { - ESP_LOGI(TAG, "hint keyword hit: \"%s\"", hit); + 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 *hit = NULL; - if (contains_any(normalized, FAIL_KEYWORDS, FAIL_KEYWORDS_COUNT, &hit)) { - ESP_LOGI(TAG, "failure keyword hit: \"%s\"", hit); + 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 @@ -238,8 +285,9 @@ static void on_hint_response(uint8_t puzzle_id, uint8_t level, esp_err_t voice_dispatcher_init(void) { if (s_initialized) return ESP_OK; s_initialized = true; - ESP_LOGI(TAG, "voice_dispatcher ready (%u FR hint keywords)", - (unsigned) HINT_KEYWORDS_COUNT); + ESP_LOGI(TAG, "voice_dispatcher ready (%u hint / %u fail FR keywords)", + (unsigned) count_entries(kHintKeywords), + (unsigned) count_entries(kFailKeywords)); return ESP_OK; } -- 2.52.0 From 2a596b9fcb83ade1f39b90df5488a96c61316442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Sun, 24 May 2026 10:46:29 +0200 Subject: [PATCH 17/19] feat(idf): POST /game/scenario hot-load game_endpoint gains a /game/scenario handler that accepts a Runtime 3 IR JSON (<=64 KiB), validates schema_version + non-empty steps array, rotates the existing scenario to .bak, writes to LittleFS at /littlefs/scenario.json on partition 'storage', and reboots after the HTTP response flushes so the engine picks up the new IR. ota_server bumps max_uri_handlers 8 -> 16 to fit the extra handlers (/game/scenario plus headroom). sdkconfig.qemu disables PSRAM + esp-sr so the firmware boots inside qemu-system-xtensa without crashing on the unemulated octal PSRAM. QEMU_HOWTO.md documents the workflow and the WiFi/Ethernet gap that still blocks full HTTP smoke under emulation. --- .gitignore | 6 + idf_zacus/QEMU_HOWTO.md | 77 ++++++++ .../components/game_endpoint/CMakeLists.txt | 1 + .../components/game_endpoint/game_endpoint.c | 186 +++++++++++++++++- .../game_endpoint/include/game_endpoint.h | 24 ++- idf_zacus/components/ota_server/ota_server.c | 2 +- idf_zacus/sdkconfig.qemu | 18 ++ 7 files changed, 310 insertions(+), 4 deletions(-) create mode 100644 idf_zacus/QEMU_HOWTO.md create mode 100644 idf_zacus/sdkconfig.qemu 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/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/components/game_endpoint/CMakeLists.txt b/idf_zacus/components/game_endpoint/CMakeLists.txt index 4059f76..6c1327c 100644 --- a/idf_zacus/components/game_endpoint/CMakeLists.txt +++ b/idf_zacus/components/game_endpoint/CMakeLists.txt @@ -11,4 +11,5 @@ idf_component_register( ota_server freertos log + joltwallet__littlefs ) diff --git a/idf_zacus/components/game_endpoint/game_endpoint.c b/idf_zacus/components/game_endpoint/game_endpoint.c index c6dd9b0..536bccc 100644 --- a/idf_zacus/components/game_endpoint/game_endpoint.c +++ b/idf_zacus/components/game_endpoint/game_endpoint.c @@ -6,12 +6,21 @@ #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" @@ -159,6 +168,169 @@ static esp_err_t handle_group_profile_post(httpd_req_t *req) { 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); +} + +// ─── 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"); + } + + if (mount_storage_lazy() != ESP_OK) { + return send_error(req, "500 Internal Server Error", + "littlefs mount failed"); + } + + 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'; + + // 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, "POST /game/scenario: malformed JSON (len=%d)", total); + free(body); + return send_error(req, "400 Bad Request", "malformed json"); + } + const cJSON *schema = cJSON_GetObjectItemCaseSensitive(root, "schema_version"); + if (!cJSON_IsString(schema) || + strcmp(schema->valuestring, "zacus.runtime3.v1") != 0) { + cJSON_Delete(root); + free(body); + return send_error(req, "400 Bad Request", + "schema_version must be zacus.runtime3.v1"); + } + const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps"); + if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) { + cJSON_Delete(root); + free(body); + return send_error(req, "400 Bad Request", + "steps must be a non-empty array"); + } + const cJSON *scenario_obj = cJSON_GetObjectItemCaseSensitive(root, "scenario"); + const cJSON *entry = scenario_obj + ? cJSON_GetObjectItemCaseSensitive(scenario_obj, "entry_step_id") + : NULL; + char entry_str[64] = {0}; + if (cJSON_IsString(entry) && entry->valuestring) { + strncpy(entry_str, entry->valuestring, sizeof(entry_str) - 1); + } + int steps_count = cJSON_GetArraySize(steps); + cJSON_Delete(root); + + // 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); + free(body); + return send_error(req, "500 Internal Server Error", + "scenario write open failed"); + } + size_t written = fwrite(body, 1, (size_t) total, f); + fclose(f); + free(body); + + if ((int) written != total) { + ESP_LOGE(TAG, "scenario write short: %zu/%d bytes (rolling back)", + written, total); + unlink(GAME_ENDPOINT_SCENARIO_PATH); + if (stat(GAME_ENDPOINT_SCENARIO_BAK, &st) == 0) { + rename(GAME_ENDPOINT_SCENARIO_BAK, GAME_ENDPOINT_SCENARIO_PATH); + } + return send_error(req, "500 Internal Server Error", + "scenario write short"); + } + + ESP_LOGI(TAG, "scenario hot-load OK: %d bytes, %d steps, entry=%s", + total, steps_count, entry_str); + + 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); + esp_err_t ret = send_json(req, "200 OK", buf); + + // Hot-reload-via-reboot until scenario_engine_reload() lands (Phase 3). + // The HTTP response is queued by send_json above; the deferred task + // gives the TCP stack 800 ms to flush before yanking the rug. + schedule_restart(); + return ret; +} + // ─── public init ──────────────────────────────────────────────────────────── esp_err_t game_endpoint_init(httpd_handle_t server) { @@ -180,6 +352,12 @@ esp_err_t game_endpoint_init(httpd_handle_t server) { .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, + }; esp_err_t err = httpd_register_uri_handler(server, &uri_get); if (err != ESP_OK) { @@ -193,8 +371,14 @@ esp_err_t game_endpoint_init(httpd_handle_t server) { 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; + } ESP_LOGI(TAG, "game endpoint registered " - "(GET + POST /game/group_profile)"); + "(GET+POST /game/group_profile, POST /game/scenario)"); 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 index 3825c09..41f4db1 100644 --- a/idf_zacus/components/game_endpoint/include/game_endpoint.h +++ b/idf_zacus/components/game_endpoint/include/game_endpoint.h @@ -34,9 +34,29 @@ extern "C" { // "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 `/game/group_profile` (GET + POST) handlers to an - * existing esp_http_server. + * @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 diff --git a/idf_zacus/components/ota_server/ota_server.c b/idf_zacus/components/ota_server/ota_server.c index d1a8e1e..aeae175 100644 --- a/idf_zacus/components/ota_server/ota_server.c +++ b/idf_zacus/components/ota_server/ota_server.c @@ -310,7 +310,7 @@ esp_err_t ota_server_init(void) { // HTTP server config httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.server_port = OTA_SERVER_PORT; - config.max_uri_handlers = 8; + 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; 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 -- 2.52.0 From d220d946076ebade6dfda1a5f4c7e815f866bad1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Sun, 24 May 2026 10:47:08 +0200 Subject: [PATCH 18/19] feat(box3): scenario hot-load HTTP server Mirrors the master's hot-load endpoint on the BOX-3 with a self-contained httpd (no shared component) since box3_voice is a separate IDF project. Storage uses the existing SPIFFS partition; behaviour matches the master (validate, .bak rotate, write, deferred restart). main.c hooks scenario_server_start() after wifi_init_sta(); the http server binds once the STA gets an IP. Move idf_component.yml to main/ so the IDF Component Manager actually fetches the managed dependencies. Pin button to 2.5.x and esp_codec_dev to 1.2.0 to stay compatible with esp-box 1.x legacy i2c. Set CONFIG_I2C_ENABLE_LEGACY_DRIVERS=y for the same reason; a residual upstream BSP conflict still aborts boot on hardware (esp-box 1.x vs driver_ng) - tracked separately. sdkconfig.defaults documents that WiFi credentials must be set via menuconfig and never committed. --- box3_voice/main/CMakeLists.txt | 11 +- box3_voice/main/idf_component.yml | 15 +++ box3_voice/main/main.c | 7 ++ box3_voice/main/scenario_server.c | 203 ++++++++++++++++++++++++++++++ box3_voice/main/scenario_server.h | 15 +++ box3_voice/sdkconfig.defaults | 11 ++ 6 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 box3_voice/main/idf_component.yml create mode 100644 box3_voice/main/scenario_server.c create mode 100644 box3_voice/main/scenario_server.h diff --git a/box3_voice/main/CMakeLists.txt b/box3_voice/main/CMakeLists.txt index 7180a0d..6073bc6 100644 --- a/box3_voice/main/CMakeLists.txt +++ b/box3_voice/main/CMakeLists.txt @@ -1,4 +1,13 @@ 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 ) 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..ad97131 100644 --- a/box3_voice/main/main.c +++ b/box3_voice/main/main.c @@ -25,6 +25,7 @@ #include "board_config.h" #include "voice_ws_client.h" +#include "scenario_server.h" /* BSP header — provided by espressif/esp-box component */ #include "bsp/esp-bsp.h" @@ -377,6 +378,12 @@ 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"); + } + /* 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..7290644 --- /dev/null +++ b/box3_voice/main/scenario_server.c @@ -0,0 +1,203 @@ +// 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"); +} + +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"); + } + if (mount_spiffs_lazy() != ESP_OK) { + return send_error(req, "500 Internal Server Error", "spiffs mount failed"); + } + 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); + if (!root) { + free(body); + return send_error(req, "400 Bad Request", "malformed json"); + } + const cJSON *schema = cJSON_GetObjectItemCaseSensitive(root, "schema_version"); + if (!cJSON_IsString(schema) || strcmp(schema->valuestring, "zacus.runtime3.v1") != 0) { + cJSON_Delete(root); free(body); + return send_error(req, "400 Bad Request", "schema_version must be zacus.runtime3.v1"); + } + const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps"); + if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) { + cJSON_Delete(root); free(body); + return send_error(req, "400 Bad Request", "steps must be a non-empty array"); + } + const cJSON *scenario_obj = cJSON_GetObjectItemCaseSensitive(root, "scenario"); + const cJSON *entry = scenario_obj + ? cJSON_GetObjectItemCaseSensitive(scenario_obj, "entry_step_id") : NULL; + char entry_str[64] = {0}; + if (cJSON_IsString(entry) && entry->valuestring) { + strncpy(entry_str, entry->valuestring, sizeof(entry_str) - 1); + } + int steps_count = cJSON_GetArraySize(steps); + cJSON_Delete(root); + + // 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); + free(body); + return send_error(req, "500 Internal Server Error", "scenario write open failed"); + } + size_t written = fwrite(body, 1, (size_t) total, f); + fclose(f); + free(body); + if ((int) written != total) { + unlink(SCENARIO_PATH); + if (stat(SCENARIO_BAK, &st) == 0) rename(SCENARIO_BAK, SCENARIO_PATH); + return send_error(req, "500 Internal Server Error", "scenario write short"); + } + + ESP_LOGI(TAG, "scenario hot-load OK: %d bytes, %d steps, entry=%s", + total, steps_count, entry_str); + + 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); + esp_err_t ret = send_json(req, "200 OK", buf); + schedule_restart(); + return ret; +} + +// ---------- 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..c8d478d --- /dev/null +++ b/box3_voice/main/scenario_server.h @@ -0,0 +1,15 @@ +// 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 "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +esp_err_t scenario_server_start(void); + +#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 -- 2.52.0 From b2267f22611b4330f45000b910d53970e7959182 Mon Sep 17 00:00:00 2001 From: Claude Worker claude2 Date: Tue, 9 Jun 2026 04:11:28 +0200 Subject: [PATCH 19/19] feat(scenario-mesh): ESP-NOW scenario hot-load receivers + green builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scenario_mesh: ESP-NOW frame protocol component (master + box3_voice): chunking, per-source reassembly, deferred apply off the Wi-Fi callback. - game_endpoint: POST /game/scenario/relay (master) + shared scenario_apply. - box3_voice: scenario receiver wiring (scenario_mesh_init). - espnow_slave (shared by all puzzles): demux scenario frames inside the single recv callback so a misrouted relay can't corrupt the MSG_* stream; reassemble per source MAC; optional consumer hook (logs+drops by default, puzzles have no scenario engine). Add missing esp_mac.h include for MACSTR/MAC2STR. - CMakeLists (scenario_mesh + 4 puzzle mains): drop invalid `esp_now` REQUIRES; the ESP-NOW API lives in esp_wifi. Fixes "Failed to resolve component esp_now". - docs: scenario-mesh receiver patch note (puzzles done via defensive demux; PLIP out of scope — Wi-Fi/HTTP-only, no ESP-NOW stack). Builds green under ESP-IDF 5.4.4: idf_zacus, box3_voice, p7_coffre. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/scenario_mesh/CMakeLists.txt | 17 + .../scenario_mesh/include/scenario_mesh.h | 86 ++++ .../components/scenario_mesh/scenario_mesh.c | 389 ++++++++++++++++++ box3_voice/main/CMakeLists.txt | 1 + box3_voice/main/main.c | 14 + box3_voice/main/scenario_server.c | 155 ++++--- box3_voice/main/scenario_server.h | 10 + docs/scenario-mesh-receiver-patch.md | 131 ++++++ .../components/game_endpoint/CMakeLists.txt | 1 + .../components/game_endpoint/game_endpoint.c | 353 ++++++++++++---- .../components/scenario_mesh/CMakeLists.txt | 17 + .../scenario_mesh/include/scenario_mesh.h | 86 ++++ .../components/scenario_mesh/scenario_mesh.c | 389 ++++++++++++++++++ idf_zacus/main/CMakeLists.txt | 1 + idf_zacus/main/main.c | 56 +++ lib/espnow_common/espnow_slave.c | 124 ++++++ lib/espnow_common/espnow_slave.h | 24 ++ .../p1_sequence_sonore/main/CMakeLists.txt | 1 - puzzles/p5_morse/main/CMakeLists.txt | 1 - puzzles/p6_symboles_nfc/main/CMakeLists.txt | 1 - puzzles/p7_coffre/main/CMakeLists.txt | 1 - 21 files changed, 1719 insertions(+), 139 deletions(-) create mode 100644 box3_voice/components/scenario_mesh/CMakeLists.txt create mode 100644 box3_voice/components/scenario_mesh/include/scenario_mesh.h create mode 100644 box3_voice/components/scenario_mesh/scenario_mesh.c create mode 100644 docs/scenario-mesh-receiver-patch.md create mode 100644 idf_zacus/components/scenario_mesh/CMakeLists.txt create mode 100644 idf_zacus/components/scenario_mesh/include/scenario_mesh.h create mode 100644 idf_zacus/components/scenario_mesh/scenario_mesh.c 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 6073bc6..b13cec6 100644 --- a/box3_voice/main/CMakeLists.txt +++ b/box3_voice/main/CMakeLists.txt @@ -10,4 +10,5 @@ idf_component_register( json nvs_flash spiffs + scenario_mesh ) diff --git a/box3_voice/main/main.c b/box3_voice/main/main.c index ad97131..ca63431 100644 --- a/box3_voice/main/main.c +++ b/box3_voice/main/main.c @@ -26,6 +26,7 @@ #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" @@ -384,6 +385,19 @@ void app_main(void) 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 index 7290644..36a4241 100644 --- a/box3_voice/main/scenario_server.c +++ b/box3_voice/main/scenario_server.c @@ -85,14 +85,102 @@ static esp_err_t handle_healthz_get(httpd_req_t *req) { 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"); } - if (mount_spiffs_lazy() != ESP_OK) { - return send_error(req, "500 Internal Server Error", "spiffs mount failed"); - } 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; @@ -107,66 +195,25 @@ static esp_err_t handle_scenario_post(httpd_req_t *req) { } body[total] = '\0'; - cJSON *root = cJSON_Parse(body); - if (!root) { - free(body); - return send_error(req, "400 Bad Request", "malformed json"); - } - const cJSON *schema = cJSON_GetObjectItemCaseSensitive(root, "schema_version"); - if (!cJSON_IsString(schema) || strcmp(schema->valuestring, "zacus.runtime3.v1") != 0) { - cJSON_Delete(root); free(body); - return send_error(req, "400 Bad Request", "schema_version must be zacus.runtime3.v1"); - } - const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps"); - if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) { - cJSON_Delete(root); free(body); - return send_error(req, "400 Bad Request", "steps must be a non-empty array"); - } - const cJSON *scenario_obj = cJSON_GetObjectItemCaseSensitive(root, "scenario"); - const cJSON *entry = scenario_obj - ? cJSON_GetObjectItemCaseSensitive(scenario_obj, "entry_step_id") : NULL; + int steps_count = 0; char entry_str[64] = {0}; - if (cJSON_IsString(entry) && entry->valuestring) { - strncpy(entry_str, entry->valuestring, sizeof(entry_str) - 1); - } - int steps_count = cJSON_GetArraySize(steps); - cJSON_Delete(root); - - // 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); - free(body); - return send_error(req, "500 Internal Server Error", "scenario write open failed"); - } - size_t written = fwrite(body, 1, (size_t) total, f); - fclose(f); + 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 ((int) written != total) { - unlink(SCENARIO_PATH); - if (stat(SCENARIO_BAK, &st) == 0) rename(SCENARIO_BAK, SCENARIO_PATH); - return send_error(req, "500 Internal Server Error", "scenario write short"); + 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)); } - ESP_LOGI(TAG, "scenario hot-load OK: %d bytes, %d steps, entry=%s", - total, steps_count, entry_str); - 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); - esp_err_t ret = send_json(req, "200 OK", buf); - schedule_restart(); - return ret; + return send_json(req, "200 OK", buf); } // ---------- public init ---------- diff --git a/box3_voice/main/scenario_server.h b/box3_voice/main/scenario_server.h index c8d478d..50b9cef 100644 --- a/box3_voice/main/scenario_server.h +++ b/box3_voice/main/scenario_server.h @@ -2,6 +2,8 @@ // POST /game/scenario (Runtime 3 IR hot-load via reboot). #pragma once +#include + #include "esp_err.h" #ifdef __cplusplus @@ -10,6 +12,14 @@ extern "C" { 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/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/components/game_endpoint/CMakeLists.txt b/idf_zacus/components/game_endpoint/CMakeLists.txt index 6c1327c..827a2f6 100644 --- a/idf_zacus/components/game_endpoint/CMakeLists.txt +++ b/idf_zacus/components/game_endpoint/CMakeLists.txt @@ -9,6 +9,7 @@ idf_component_register( nvs_flash hints_client ota_server + scenario_mesh freertos log joltwallet__littlefs diff --git a/idf_zacus/components/game_endpoint/game_endpoint.c b/idf_zacus/components/game_endpoint/game_endpoint.c index 536bccc..4210752 100644 --- a/idf_zacus/components/game_endpoint/game_endpoint.c +++ b/idf_zacus/components/game_endpoint/game_endpoint.c @@ -25,6 +25,7 @@ #include "nvs_flash.h" #include "hints_client.h" +#include "scenario_mesh.h" static const char *TAG = "game_endpoint"; @@ -207,6 +208,127 @@ static void schedule_restart(void) { 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) { @@ -218,11 +340,6 @@ static esp_err_t handle_scenario_post(httpd_req_t *req) { "body must be 1..65536 bytes"); } - if (mount_storage_lazy() != ESP_OK) { - return send_error(req, "500 Internal Server Error", - "littlefs mount failed"); - } - char *body = (char *) malloc((size_t) req->content_len + 1); if (!body) { return send_error(req, "500 Internal Server Error", @@ -241,93 +358,141 @@ static esp_err_t handle_scenario_post(httpd_req_t *req) { } body[total] = '\0'; - // 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, "POST /game/scenario: malformed JSON (len=%d)", total); - free(body); - return send_error(req, "400 Bad Request", "malformed json"); - } - const cJSON *schema = cJSON_GetObjectItemCaseSensitive(root, "schema_version"); - if (!cJSON_IsString(schema) || - strcmp(schema->valuestring, "zacus.runtime3.v1") != 0) { - cJSON_Delete(root); - free(body); - return send_error(req, "400 Bad Request", - "schema_version must be zacus.runtime3.v1"); - } - const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps"); - if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) { - cJSON_Delete(root); - free(body); - return send_error(req, "400 Bad Request", - "steps must be a non-empty array"); - } - const cJSON *scenario_obj = cJSON_GetObjectItemCaseSensitive(root, "scenario"); - const cJSON *entry = scenario_obj - ? cJSON_GetObjectItemCaseSensitive(scenario_obj, "entry_step_id") - : NULL; + int steps_count = 0; char entry_str[64] = {0}; - if (cJSON_IsString(entry) && entry->valuestring) { - strncpy(entry_str, entry->valuestring, sizeof(entry_str) - 1); - } - int steps_count = cJSON_GetArraySize(steps); - cJSON_Delete(root); - - // 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); - free(body); - return send_error(req, "500 Internal Server Error", - "scenario write open failed"); - } - size_t written = fwrite(body, 1, (size_t) total, f); - fclose(f); + 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 ((int) written != total) { - ESP_LOGE(TAG, "scenario write short: %zu/%d bytes (rolling back)", - written, total); - unlink(GAME_ENDPOINT_SCENARIO_PATH); - if (stat(GAME_ENDPOINT_SCENARIO_BAK, &st) == 0) { - rename(GAME_ENDPOINT_SCENARIO_BAK, GAME_ENDPOINT_SCENARIO_PATH); - } - return send_error(req, "500 Internal Server Error", - "scenario write short"); + 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)); } - ESP_LOGI(TAG, "scenario hot-load OK: %d bytes, %d steps, entry=%s", - total, steps_count, entry_str); - 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); - esp_err_t ret = send_json(req, "200 OK", buf); + // 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); +} - // Hot-reload-via-reboot until scenario_engine_reload() lands (Phase 3). - // The HTTP response is queued by send_json above; the deferred task - // gives the TCP stack 800 ms to flush before yanking the rug. - schedule_restart(); +// ─── 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; } @@ -358,6 +523,22 @@ esp_err_t game_endpoint_init(httpd_handle_t server) { .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) { @@ -377,8 +558,18 @@ esp_err_t game_endpoint_init(httpd_handle_t server) { 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)"); + "(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/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/main/CMakeLists.txt b/idf_zacus/main/CMakeLists.txt index 67233e0..4af2a99 100644 --- a/idf_zacus/main/CMakeLists.txt +++ b/idf_zacus/main/CMakeLists.txt @@ -12,6 +12,7 @@ idf_component_register( voice_pipeline voice_hook_endpoint game_endpoint + scenario_mesh nvs_flash esp_timer esp_system diff --git a/idf_zacus/main/main.c b/idf_zacus/main/main.c index aaddaa5..d0f21c7 100644 --- a/idf_zacus/main/main.c +++ b/idf_zacus/main/main.c @@ -46,6 +46,7 @@ #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. @@ -156,6 +157,56 @@ static void list_littlefs_root(void) { 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 @@ -392,6 +443,11 @@ void app_main(void) { 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(); } } 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 ) -- 2.52.0