feat(idf): ESP-NOW scenario hot-load + IDF migration #1

Merged
electron merged 19 commits from feat/idf-migration into main 2026-06-09 05:45:21 +00:00
58 changed files with 7704 additions and 5 deletions
+6
View File
@@ -55,3 +55,9 @@ data/hotline_tts/**/*.mp3
# Local venvs
.venv*/
# IDF managed components + lock + per-build sdkconfig
managed_components/
dependencies.lock
sdkconfig
.cache/
@@ -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
)
@@ -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 <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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
@@ -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 <string.h>
#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;
}
+11 -1
View File
@@ -1,4 +1,14 @@
idf_component_register(
SRCS "main.c" "voice_ws_client.c"
SRCS "main.c" "voice_ws_client.c" "scenario_server.c"
INCLUDE_DIRS "."
PRIV_REQUIRES
driver
esp_event
esp_http_server
esp_netif
esp_wifi
json
nvs_flash
spiffs
scenario_mesh
)
+15
View File
@@ -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"
+21
View File
@@ -25,6 +25,8 @@
#include "board_config.h"
#include "voice_ws_client.h"
#include "scenario_server.h"
#include "scenario_mesh.h"
/* BSP header — provided by espressif/esp-box component */
#include "bsp/esp-bsp.h"
@@ -377,6 +379,25 @@ void app_main(void)
/* Start voice bridge connection task (waits for WiFi, then connects WS) */
xTaskCreate(voice_bridge_task, "voice_bridge", 6144, NULL, 5, NULL);
/* Start the scenario hot-load HTTP server (POST /game/scenario).
* httpd_start binds to all netifs — works as soon as the WiFi STA has an IP. */
if (scenario_server_start() != ESP_OK) {
ESP_LOGW(TAG, "scenario_server_start failed — IR hot-load unavailable");
}
/* Start the ESP-NOW receiver so the master can relay scenarios to us even
* when WiFi is unreachable (battery / RF-noise fallback per the spec). The
* reassembled IR is funnelled through the exact same scenario_apply_buffer()
* path the HTTP handler uses. esp_wifi_start() already ran in
* wifi_init_sta(), so esp_now_init() inside has its prerequisite. */
esp_err_t mesh_err = scenario_mesh_init(scenario_apply_buffer);
if (mesh_err != ESP_OK) {
ESP_LOGW(TAG, "scenario_mesh_init failed: %s — ESP-NOW IR relay unavailable",
esp_err_to_name(mesh_err));
} else {
ESP_LOGI(TAG, "ESP-NOW scenario receiver active");
}
/* TODO: Initialize ESP-SR WakeNet for wake-word detection
* - Load WakeNet9 model ("hi esp" or custom)
* - Feed audio frames from mic to WakeNet
+250
View File
@@ -0,0 +1,250 @@
// scenario_server.c — minimal HTTP server for receiving Runtime 3 IR scenarios
// on the ESP32-S3-BOX-3. Mirrors the master's game_endpoint handler but is
// self-contained (no shared component) since box3_voice is a separate IDF
// project. Storage uses the existing SPIFFS partition declared in
// partitions.csv (master uses LittleFS — both work, we match the local table).
#include "scenario_server.h"
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include "cJSON.h"
#include "esp_err.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_spiffs.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#define TAG "scenario_srv"
#define MAX_SCENARIO_BYTES (64 * 1024)
#define SPIFFS_LABEL "storage"
#define SPIFFS_BASE "/spiffs"
#define SCENARIO_PATH SPIFFS_BASE "/scenario.json"
#define SCENARIO_BAK SPIFFS_BASE "/scenario.bak"
static httpd_handle_t s_server = NULL;
static bool s_spiffs_mounted = false;
// ---------- helpers ----------
static esp_err_t send_json(httpd_req_t *req, const char *status_line, const char *body) {
httpd_resp_set_status(req, status_line);
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
return httpd_resp_sendstr(req, body);
}
static esp_err_t send_error(httpd_req_t *req, const char *status_line, const char *message) {
char buf[192];
snprintf(buf, sizeof(buf), "{\"error\":\"%s\"}", message ? message : "");
return send_json(req, status_line, buf);
}
static esp_err_t mount_spiffs_lazy(void) {
if (s_spiffs_mounted) return ESP_OK;
esp_vfs_spiffs_conf_t conf = {
.base_path = SPIFFS_BASE,
.partition_label = SPIFFS_LABEL,
.max_files = 6,
.format_if_mount_failed = true,
};
esp_err_t err = esp_vfs_spiffs_register(&conf);
if (err == ESP_OK || err == ESP_ERR_INVALID_STATE) {
s_spiffs_mounted = true;
ESP_LOGI(TAG, "spiffs '%s' mounted at %s", conf.partition_label, conf.base_path);
return ESP_OK;
}
ESP_LOGE(TAG, "spiffs mount failed: %s", esp_err_to_name(err));
return err;
}
static void deferred_restart_task(void *arg) {
(void) arg;
vTaskDelay(pdMS_TO_TICKS(800));
ESP_LOGW(TAG, "scenario hot-load: rebooting to apply new IR");
esp_restart();
}
static void schedule_restart(void) {
xTaskCreate(deferred_restart_task, "scenario_restart",
4096, NULL, tskIDLE_PRIORITY + 1, NULL);
}
// ---------- handlers ----------
static esp_err_t handle_healthz_get(httpd_req_t *req) {
httpd_resp_set_type(req, "text/plain");
return httpd_resp_sendstr(req, "ok");
}
// Shared internal apply path. Returns ESP_OK on success and fills the optional
// out-params; on failure returns a specific esp_err_t and (if non-NULL) sets a
// static reason string. The HTTP handler and the ESP-NOW receiver both call
// this — the single `_scenario_apply` the spec mandates.
static esp_err_t scenario_apply_internal(const char *body, size_t len,
int *steps_count_out,
char *entry_out, size_t entry_cap,
const char **err_msg_out) {
if (err_msg_out) *err_msg_out = NULL;
if (steps_count_out) *steps_count_out = 0;
if (entry_out && entry_cap) entry_out[0] = '\0';
if (!body || len == 0 || len > MAX_SCENARIO_BYTES) {
if (err_msg_out) *err_msg_out = "body must be 1..65536 bytes";
return ESP_ERR_INVALID_SIZE;
}
if (mount_spiffs_lazy() != ESP_OK) {
if (err_msg_out) *err_msg_out = "spiffs mount failed";
return ESP_FAIL;
}
cJSON *root = cJSON_Parse(body);
if (!root) {
if (err_msg_out) *err_msg_out = "malformed json";
return ESP_ERR_INVALID_ARG;
}
const cJSON *schema = cJSON_GetObjectItemCaseSensitive(root, "schema_version");
if (!cJSON_IsString(schema) || strcmp(schema->valuestring, "zacus.runtime3.v1") != 0) {
cJSON_Delete(root);
if (err_msg_out) *err_msg_out = "schema_version must be zacus.runtime3.v1";
return ESP_ERR_INVALID_ARG;
}
const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps");
if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) {
cJSON_Delete(root);
if (err_msg_out) *err_msg_out = "steps must be a non-empty array";
return ESP_ERR_INVALID_ARG;
}
const cJSON *scenario_obj = cJSON_GetObjectItemCaseSensitive(root, "scenario");
const cJSON *entry = scenario_obj
? cJSON_GetObjectItemCaseSensitive(scenario_obj, "entry_step_id") : NULL;
if (entry_out && entry_cap && cJSON_IsString(entry) && entry->valuestring) {
strncpy(entry_out, entry->valuestring, entry_cap - 1);
entry_out[entry_cap - 1] = '\0';
}
int steps_count = cJSON_GetArraySize(steps);
cJSON_Delete(root);
if (steps_count_out) *steps_count_out = steps_count;
// Rotate current -> .bak
struct stat st;
if (stat(SCENARIO_PATH, &st) == 0) {
unlink(SCENARIO_BAK);
if (rename(SCENARIO_PATH, SCENARIO_BAK) != 0) {
ESP_LOGW(TAG, "rename .json -> .bak failed (errno=%d)", errno);
}
}
FILE *f = fopen(SCENARIO_PATH, "wb");
if (!f) {
ESP_LOGE(TAG, "fopen %s for write failed (errno=%d)", SCENARIO_PATH, errno);
if (err_msg_out) *err_msg_out = "scenario write open failed";
return ESP_FAIL;
}
size_t written = fwrite(body, 1, len, f);
fclose(f);
if (written != len) {
unlink(SCENARIO_PATH);
if (stat(SCENARIO_BAK, &st) == 0) rename(SCENARIO_BAK, SCENARIO_PATH);
if (err_msg_out) *err_msg_out = "scenario write short";
return ESP_FAIL;
}
ESP_LOGI(TAG, "scenario hot-load OK: %zu bytes, %d steps, entry=%s",
len, steps_count, (entry_out && entry_cap) ? entry_out : "");
schedule_restart();
return ESP_OK;
}
// Public thin wrapper used by the ESP-NOW receiver (matches
// scenario_mesh_apply_cb_t: esp_err_t (*)(const char *, size_t)).
esp_err_t scenario_apply_buffer(const char *data, size_t len) {
const char *emsg = NULL;
esp_err_t err = scenario_apply_internal(data, len, NULL, NULL, 0, &emsg);
if (err != ESP_OK) {
ESP_LOGW(TAG, "ESP-NOW scenario rejected: %s",
emsg ? emsg : esp_err_to_name(err));
}
return err;
}
static esp_err_t handle_scenario_post(httpd_req_t *req) {
if (req->content_len <= 0 || req->content_len > MAX_SCENARIO_BYTES) {
ESP_LOGW(TAG, "POST /game/scenario: bad body length %d", (int) req->content_len);
return send_error(req, "413 Payload Too Large", "body must be 1..65536 bytes");
}
char *body = (char *) malloc((size_t) req->content_len + 1);
if (!body) return send_error(req, "500 Internal Server Error", "out of memory");
int total = 0;
while (total < (int) req->content_len) {
int got = httpd_req_recv(req, body + total, req->content_len - total);
if (got <= 0) {
if (got == HTTPD_SOCK_ERR_TIMEOUT) continue;
free(body);
return send_error(req, "400 Bad Request", "recv failed");
}
total += got;
}
body[total] = '\0';
int steps_count = 0;
char entry_str[64] = {0};
const char *emsg = NULL;
esp_err_t aerr = scenario_apply_internal(body, (size_t) total, &steps_count,
entry_str, sizeof(entry_str), &emsg);
free(body);
if (aerr != ESP_OK) {
const char *status = (aerr == ESP_ERR_INVALID_ARG ||
aerr == ESP_ERR_INVALID_SIZE)
? "400 Bad Request" : "500 Internal Server Error";
return send_error(req, status, emsg ? emsg : esp_err_to_name(aerr));
}
char buf[256];
snprintf(buf, sizeof(buf),
"{\"status\":\"ok\",\"board\":\"box3_voice\",\"steps_count\":%d,"
"\"entry_step_id\":\"%s\",\"bytes\":%d,\"reload\":\"reboot_pending\"}",
steps_count, entry_str, total);
return send_json(req, "200 OK", buf);
}
// ---------- public init ----------
esp_err_t scenario_server_start(void) {
if (s_server) {
ESP_LOGW(TAG, "scenario_server already running");
return ESP_OK;
}
httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
cfg.server_port = 80;
cfg.max_uri_handlers = 8;
cfg.stack_size = 8192;
esp_err_t err = httpd_start(&s_server, &cfg);
if (err != ESP_OK) {
ESP_LOGE(TAG, "httpd_start: %s", esp_err_to_name(err));
return err;
}
static const httpd_uri_t uri_healthz = {
.uri = "/healthz", .method = HTTP_GET,
.handler = handle_healthz_get, .user_ctx = NULL,
};
static const httpd_uri_t uri_scenario = {
.uri = "/game/scenario", .method = HTTP_POST,
.handler = handle_scenario_post, .user_ctx = NULL,
};
httpd_register_uri_handler(s_server, &uri_healthz);
httpd_register_uri_handler(s_server, &uri_scenario);
ESP_LOGI(TAG, "scenario server up on :80 (GET /healthz, POST /game/scenario)");
return ESP_OK;
}
+25
View File
@@ -0,0 +1,25 @@
// scenario_server.h — start the BOX-3 minimal HTTP server that accepts
// POST /game/scenario (Runtime 3 IR hot-load via reboot).
#pragma once
#include <stddef.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
esp_err_t scenario_server_start(void);
// Shared internal apply path (the spec's `_scenario_apply`): validate a
// Runtime 3 IR blob, atomically write it to SPIFFS, and schedule the hot-reload
// reboot. Used both by the HTTP POST /game/scenario handler and by the ESP-NOW
// receiver (scenario_mesh). `data` need not be NUL-terminated beyond `len`.
// Returns ESP_OK on success; ESP_ERR_INVALID_ARG / _INVALID_SIZE on bad input,
// ESP_FAIL on storage errors.
esp_err_t scenario_apply_buffer(const char *data, size_t len);
#ifdef __cplusplus
}
#endif
+11
View File
@@ -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
+131
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
build/
managed_components/
dependencies.lock
sdkconfig
sdkconfig.old
+13
View File
@@ -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)
+77
View File
@@ -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.
+79
View File
@@ -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.
@@ -0,0 +1,16 @@
idf_component_register(
SRCS
"game_endpoint.c"
INCLUDE_DIRS
"include"
REQUIRES
esp_http_server
json
nvs_flash
hints_client
ota_server
scenario_mesh
freertos
log
joltwallet__littlefs
)
@@ -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.<master-ip>/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 ~515 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);
}
```
@@ -0,0 +1,575 @@
// game_endpoint — see include/game_endpoint.h for design notes.
//
// Slice 12 of the IDF migration: live-tunable group profile so the
// game master can switch the hints engine policy from the dashboard
// (or any HTTP client) without reflashing NVS.
#include "game_endpoint.h"
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include "cJSON.h"
#include "esp_err.h"
#include "esp_http_server.h"
#include "esp_littlefs.h"
#include "esp_log.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "nvs.h"
#include "nvs_flash.h"
#include "hints_client.h"
#include "scenario_mesh.h"
static const char *TAG = "game_endpoint";
// Whitelist mirrored in the 4xx error message so the operator can
// recover without grepping the source. Keep aligned with
// hints_client_set_group_profile() validation.
#define GAME_ENDPOINT_PROFILE_HELP \
"invalid group_profile, must be one of [TECH, NON_TECH, MIXED, BOTH]"
// ─── small JSON response helper (mirrors voice_hook_endpoint) ───────────────
static esp_err_t send_json(httpd_req_t *req, const char *status_line,
const char *body) {
httpd_resp_set_status(req, status_line);
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
return httpd_resp_sendstr(req, body);
}
static esp_err_t send_error(httpd_req_t *req, const char *status_line,
const char *message) {
char buf[192];
snprintf(buf, sizeof(buf), "{\"error\":\"%s\"}", message ? message : "");
return send_json(req, status_line, buf);
}
// ─── NVS persistence helper ─────────────────────────────────────────────────
// Writes the (already-validated) profile into NVS namespace "zacus",
// key "group_profile". Logs and returns the underlying error on
// failure — the caller decides whether to surface it to the client.
static esp_err_t persist_group_profile(const char *profile) {
nvs_handle_t h;
esp_err_t err = nvs_open("zacus", NVS_READWRITE, &h);
if (err != ESP_OK) {
ESP_LOGW(TAG, "nvs_open(zacus, RW): %s", esp_err_to_name(err));
return err;
}
err = nvs_set_str(h, "group_profile", profile);
if (err != ESP_OK) {
ESP_LOGW(TAG, "nvs_set_str(group_profile=\"%s\"): %s",
profile, esp_err_to_name(err));
nvs_close(h);
return err;
}
err = nvs_commit(h);
if (err != ESP_OK) {
ESP_LOGW(TAG, "nvs_commit(zacus): %s", esp_err_to_name(err));
}
nvs_close(h);
return err;
}
// ─── GET /game/group_profile ────────────────────────────────────────────────
static esp_err_t handle_group_profile_get(httpd_req_t *req) {
const char *current = hints_client_group_profile();
char buf[96];
snprintf(buf, sizeof(buf),
"{\"group_profile\":\"%s\"}",
current ? current : "MIXED");
return send_json(req, "200 OK", buf);
}
// ─── POST /game/group_profile ───────────────────────────────────────────────
static esp_err_t handle_group_profile_post(httpd_req_t *req) {
if (req->content_len <= 0 ||
req->content_len > GAME_ENDPOINT_MAX_BODY_BYTES) {
ESP_LOGW(TAG, "POST /game/group_profile: bad body length %d",
(int) req->content_len);
return send_error(req, "413 Payload Too Large",
"body must be 1..256 bytes");
}
char body[GAME_ENDPOINT_MAX_BODY_BYTES + 1] = {0};
int total = 0;
while (total < (int) req->content_len) {
int got = httpd_req_recv(req, body + total,
req->content_len - total);
if (got <= 0) {
if (got == HTTPD_SOCK_ERR_TIMEOUT) continue;
return send_error(req, "400 Bad Request", "recv failed");
}
total += got;
}
body[total] = '\0';
cJSON *root = cJSON_Parse(body);
if (!root) {
ESP_LOGW(TAG, "POST /game/group_profile: malformed JSON: %s", body);
return send_error(req, "400 Bad Request", "malformed json");
}
const cJSON *profile = cJSON_GetObjectItemCaseSensitive(root, "group_profile");
if (!cJSON_IsString(profile) || profile->valuestring == NULL ||
profile->valuestring[0] == '\0') {
cJSON_Delete(root);
return send_error(req, "400 Bad Request",
"missing 'group_profile'");
}
// Push to the hints client first — its built-in whitelist is the
// source of truth for valid profiles. If it accepts the value we
// then persist; if it rejects we never touch NVS so a bad POST
// cannot brick the boot-time seed.
esp_err_t set_err = hints_client_set_group_profile(profile->valuestring);
if (set_err != ESP_OK) {
ESP_LOGW(TAG, "hints_client_set_group_profile(\"%s\") rejected: %s",
profile->valuestring, esp_err_to_name(set_err));
cJSON_Delete(root);
return send_error(req, "400 Bad Request", GAME_ENDPOINT_PROFILE_HELP);
}
// Best-effort NVS persistence. If it fails the in-RAM hints client
// is still updated, but we surface a 500 so the operator knows the
// change won't survive reboot. Log line above already captured the
// underlying NVS error.
esp_err_t nvs_err = persist_group_profile(profile->valuestring);
if (nvs_err != ESP_OK) {
char buf[192];
snprintf(buf, sizeof(buf),
"{\"status\":\"runtime_only\","
"\"group_profile\":\"%s\","
"\"warning\":\"nvs write failed: %s\"}",
profile->valuestring, esp_err_to_name(nvs_err));
cJSON_Delete(root);
return send_json(req, "500 Internal Server Error", buf);
}
ESP_LOGI(TAG, "group_profile updated -> %s (NVS persisted)",
profile->valuestring);
char buf[128];
snprintf(buf, sizeof(buf),
"{\"status\":\"ok\",\"group_profile\":\"%s\"}",
profile->valuestring);
cJSON_Delete(root);
return send_json(req, "200 OK", buf);
}
// ─── LittleFS lazy mount (shared with media_manager — idempotent) ──────────
static bool s_storage_mounted = false;
static esp_err_t mount_storage_lazy(void) {
if (s_storage_mounted) return ESP_OK;
esp_vfs_littlefs_conf_t conf = {
.base_path = GAME_ENDPOINT_STORAGE_BASE,
.partition_label = GAME_ENDPOINT_STORAGE_LABEL,
.format_if_mount_failed = true,
.dont_mount = false,
};
esp_err_t err = esp_vfs_littlefs_register(&conf);
if (err == ESP_OK || err == ESP_ERR_INVALID_STATE) {
// INVALID_STATE = already registered by another component → fine.
s_storage_mounted = true;
ESP_LOGI(TAG, "littlefs '%s' mounted at %s",
conf.partition_label, conf.base_path);
return ESP_OK;
}
ESP_LOGE(TAG, "esp_vfs_littlefs_register(%s) failed: %s",
conf.partition_label, esp_err_to_name(err));
return err;
}
// ─── deferred reboot (lets the HTTP response flush first) ──────────────────
static void deferred_restart_task(void *arg) {
(void) arg;
vTaskDelay(pdMS_TO_TICKS(800));
ESP_LOGW(TAG, "scenario hot-load: rebooting to apply new IR");
esp_restart();
}
static void schedule_restart(void) {
xTaskCreate(deferred_restart_task, "scenario_restart",
4096, NULL, tskIDLE_PRIORITY + 1, NULL);
}
// ─── _scenario_apply — shared validate + atomic-write path ──────────────────
//
// The single internal entry point both the HTTP POST /game/scenario handler
// and the ESP-NOW receiver (scenario_mesh) funnel through, per the spec. It
// validates the IR (schema_version + non-empty steps), rotates the current
// scenario to .bak, and atomically writes the new blob to LittleFS. On
// success it schedules the hot-reload reboot and returns ESP_OK; the optional
// out-params report the parsed step count / entry id for the HTTP response.
//
// On failure it returns a specific esp_err_t and leaves the previous scenario
// in place (rolled back from .bak when a short write corrupted the new file).
// `err_msg_out` (if non-NULL) receives a static human-readable reason.
static esp_err_t scenario_apply_buffer(const char *body, size_t len,
int *steps_count_out,
char *entry_out, size_t entry_cap,
const char **err_msg_out) {
if (err_msg_out) *err_msg_out = NULL;
if (steps_count_out) *steps_count_out = 0;
if (entry_out && entry_cap) entry_out[0] = '\0';
if (!body || len == 0 || len > GAME_ENDPOINT_MAX_SCENARIO_BYTES) {
if (err_msg_out) *err_msg_out = "body must be 1..65536 bytes";
return ESP_ERR_INVALID_SIZE;
}
if (mount_storage_lazy() != ESP_OK) {
if (err_msg_out) *err_msg_out = "littlefs mount failed";
return ESP_FAIL;
}
// Minimal validation: parse + schema_version + non-empty steps array.
// The runtime3_common.py validator is the strict source of truth on the
// gateway side; here we keep the firmware permissive but safe.
cJSON *root = cJSON_Parse(body);
if (!root) {
ESP_LOGW(TAG, "scenario apply: malformed JSON (len=%d)", (int) len);
if (err_msg_out) *err_msg_out = "malformed json";
return ESP_ERR_INVALID_ARG;
}
const cJSON *schema = cJSON_GetObjectItemCaseSensitive(root, "schema_version");
if (!cJSON_IsString(schema) ||
strcmp(schema->valuestring, "zacus.runtime3.v1") != 0) {
cJSON_Delete(root);
if (err_msg_out) *err_msg_out = "schema_version must be zacus.runtime3.v1";
return ESP_ERR_INVALID_ARG;
}
const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps");
if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) {
cJSON_Delete(root);
if (err_msg_out) *err_msg_out = "steps must be a non-empty array";
return ESP_ERR_INVALID_ARG;
}
const cJSON *scenario_obj = cJSON_GetObjectItemCaseSensitive(root, "scenario");
const cJSON *entry = scenario_obj
? cJSON_GetObjectItemCaseSensitive(scenario_obj, "entry_step_id")
: NULL;
if (entry_out && entry_cap && cJSON_IsString(entry) && entry->valuestring) {
strncpy(entry_out, entry->valuestring, entry_cap - 1);
entry_out[entry_cap - 1] = '\0';
}
int steps_count = cJSON_GetArraySize(steps);
cJSON_Delete(root);
if (steps_count_out) *steps_count_out = steps_count;
// Rotate existing scenario -> .bak so a broken push can be rolled back
// by a future scenario_engine_reload() failure path.
struct stat st;
if (stat(GAME_ENDPOINT_SCENARIO_PATH, &st) == 0) {
// Best-effort: ignore rename failure (e.g. .bak already exists from
// a previous push — overwrite via unlink+rename).
unlink(GAME_ENDPOINT_SCENARIO_BAK);
if (rename(GAME_ENDPOINT_SCENARIO_PATH,
GAME_ENDPOINT_SCENARIO_BAK) != 0) {
ESP_LOGW(TAG, "rename current scenario -> .bak failed (errno=%d)",
errno);
}
}
FILE *f = fopen(GAME_ENDPOINT_SCENARIO_PATH, "wb");
if (!f) {
ESP_LOGE(TAG, "fopen %s for write failed (errno=%d)",
GAME_ENDPOINT_SCENARIO_PATH, errno);
if (err_msg_out) *err_msg_out = "scenario write open failed";
return ESP_FAIL;
}
size_t written = fwrite(body, 1, len, f);
fclose(f);
if (written != len) {
ESP_LOGE(TAG, "scenario write short: %zu/%zu bytes (rolling back)",
written, len);
unlink(GAME_ENDPOINT_SCENARIO_PATH);
if (stat(GAME_ENDPOINT_SCENARIO_BAK, &st) == 0) {
rename(GAME_ENDPOINT_SCENARIO_BAK, GAME_ENDPOINT_SCENARIO_PATH);
}
if (err_msg_out) *err_msg_out = "scenario write short";
return ESP_FAIL;
}
ESP_LOGI(TAG, "scenario hot-load OK: %zu bytes, %d steps, entry=%s",
len, steps_count, (entry_out && entry_cap) ? entry_out : "");
// Hot-reload-via-reboot until scenario_engine_reload() lands (Phase 3).
schedule_restart();
return ESP_OK;
}
// scenario_mesh apply callback: a received-over-ESP-NOW scenario takes the
// exact same path as an HTTP POST. The mesh frees `data` after this returns.
static esp_err_t scenario_mesh_apply_adapter(const char *data, size_t len) {
int steps = 0;
char entry[64] = {0};
const char *emsg = NULL;
esp_err_t err = scenario_apply_buffer(data, len, &steps,
entry, sizeof(entry), &emsg);
if (err != ESP_OK) {
ESP_LOGW(TAG, "ESP-NOW scenario rejected: %s",
emsg ? emsg : esp_err_to_name(err));
}
return err;
}
// ─── POST /game/scenario — accept a Runtime 3 IR JSON ──────────────────────
static esp_err_t handle_scenario_post(httpd_req_t *req) {
if (req->content_len <= 0 ||
req->content_len > GAME_ENDPOINT_MAX_SCENARIO_BYTES) {
ESP_LOGW(TAG, "POST /game/scenario: bad body length %d",
(int) req->content_len);
return send_error(req, "413 Payload Too Large",
"body must be 1..65536 bytes");
}
char *body = (char *) malloc((size_t) req->content_len + 1);
if (!body) {
return send_error(req, "500 Internal Server Error",
"out of memory");
}
int total = 0;
while (total < (int) req->content_len) {
int got = httpd_req_recv(req, body + total,
req->content_len - total);
if (got <= 0) {
if (got == HTTPD_SOCK_ERR_TIMEOUT) continue;
free(body);
return send_error(req, "400 Bad Request", "recv failed");
}
total += got;
}
body[total] = '\0';
int steps_count = 0;
char entry_str[64] = {0};
const char *emsg = NULL;
esp_err_t aerr = scenario_apply_buffer(body, (size_t) total, &steps_count,
entry_str, sizeof(entry_str), &emsg);
free(body);
if (aerr != ESP_OK) {
const char *status = (aerr == ESP_ERR_INVALID_ARG ||
aerr == ESP_ERR_INVALID_SIZE)
? "400 Bad Request" : "500 Internal Server Error";
return send_error(req, status, emsg ? emsg : esp_err_to_name(aerr));
}
char buf[256];
snprintf(buf, sizeof(buf),
"{\"status\":\"ok\",\"steps_count\":%d,"
"\"entry_step_id\":\"%s\",\"bytes\":%d,"
"\"reload\":\"reboot_pending\"}",
steps_count, entry_str, total);
// The HTTP response is queued by send_json; scenario_apply_buffer already
// scheduled the deferred restart, which waits 800 ms before rebooting so
// the TCP stack can flush this response first.
return send_json(req, "200 OK", buf);
}
// ─── POST /game/scenario/relay (master only) ───────────────────────────────
//
// Body: { "peers": ["box3","plip",...], "ir": { <runtime3 IR> } }
//
// Resolves each alias to a MAC via the scenario_mesh peer registry, then
// chunks + sends the IR over ESP-NOW. A failure on one peer (unknown alias,
// ack timeout) does not abort the others — it lands in "skipped" with a
// reason. Responds 200 with { "relayed":[...], "skipped":[{name,reason}] }.
static esp_err_t handle_scenario_relay_post(httpd_req_t *req) {
if (req->content_len <= 0 ||
req->content_len > GAME_ENDPOINT_MAX_SCENARIO_BYTES) {
ESP_LOGW(TAG, "POST /game/scenario/relay: bad body length %d",
(int) req->content_len);
return send_error(req, "413 Payload Too Large",
"body must be 1..65536 bytes");
}
char *body = (char *) malloc((size_t) req->content_len + 1);
if (!body) {
return send_error(req, "500 Internal Server Error", "out of memory");
}
int total = 0;
while (total < (int) req->content_len) {
int got = httpd_req_recv(req, body + total, req->content_len - total);
if (got <= 0) {
if (got == HTTPD_SOCK_ERR_TIMEOUT) continue;
free(body);
return send_error(req, "400 Bad Request", "recv failed");
}
total += got;
}
body[total] = '\0';
cJSON *root = cJSON_Parse(body);
free(body);
if (!root) {
return send_error(req, "400 Bad Request", "malformed json");
}
const cJSON *peers = cJSON_GetObjectItemCaseSensitive(root, "peers");
const cJSON *ir = cJSON_GetObjectItemCaseSensitive(root, "ir");
if (!cJSON_IsArray(peers) || cJSON_GetArraySize(peers) == 0) {
cJSON_Delete(root);
return send_error(req, "400 Bad Request",
"'peers' must be a non-empty array");
}
if (!cJSON_IsObject(ir)) {
cJSON_Delete(root);
return send_error(req, "400 Bad Request", "'ir' must be an object");
}
// Serialize the IR object once (unformatted, compact) — this is the exact
// byte stream the receiver reassembles and feeds to _scenario_apply().
char *ir_str = cJSON_PrintUnformatted(ir);
if (!ir_str) {
cJSON_Delete(root);
return send_error(req, "500 Internal Server Error",
"ir serialize failed");
}
size_t ir_len = strlen(ir_str);
// Build the two result arrays into a response cJSON tree.
cJSON *resp = cJSON_CreateObject();
cJSON *relayed = cJSON_AddArrayToObject(resp, "relayed");
cJSON *skipped = cJSON_AddArrayToObject(resp, "skipped");
int n = cJSON_GetArraySize(peers);
for (int i = 0; i < n; i++) {
const cJSON *p = cJSON_GetArrayItem(peers, i);
if (!cJSON_IsString(p) || !p->valuestring) continue;
const char *alias = p->valuestring;
uint8_t mac[6];
esp_err_t rerr = scenario_mesh_mac_for_alias(alias, mac);
if (rerr != ESP_OK) {
cJSON *s = cJSON_CreateObject();
cJSON_AddStringToObject(s, "name", alias);
cJSON_AddStringToObject(s, "reason", "unknown_peer");
cJSON_AddItemToArray(skipped, s);
ESP_LOGW(TAG, "relay: alias \"%s\" not in peer registry", alias);
continue;
}
esp_err_t serr = scenario_mesh_send(mac, ir_str, ir_len);
if (serr == ESP_OK) {
cJSON_AddItemToArray(relayed, cJSON_CreateString(alias));
ESP_LOGI(TAG, "relay: \"%s\" OK (%u bytes)",
alias, (unsigned) ir_len);
} else {
cJSON *s = cJSON_CreateObject();
cJSON_AddStringToObject(s, "name", alias);
cJSON_AddStringToObject(s, "reason",
serr == ESP_ERR_TIMEOUT ? "timeout" : esp_err_to_name(serr));
cJSON_AddItemToArray(skipped, s);
ESP_LOGW(TAG, "relay: \"%s\" failed: %s",
alias, esp_err_to_name(serr));
}
}
cJSON_free(ir_str);
cJSON_Delete(root);
char *resp_str = cJSON_PrintUnformatted(resp);
cJSON_Delete(resp);
if (!resp_str) {
return send_error(req, "500 Internal Server Error",
"response serialize failed");
}
esp_err_t ret = send_json(req, "200 OK", resp_str);
cJSON_free(resp_str);
return ret;
}
// ─── public init ────────────────────────────────────────────────────────────
esp_err_t game_endpoint_init(httpd_handle_t server) {
if (server == NULL) {
ESP_LOGE(TAG, "game_endpoint_init: NULL httpd handle "
"(did ota_server_init() succeed?)");
return ESP_ERR_INVALID_ARG;
}
static const httpd_uri_t uri_get = {
.uri = "/game/group_profile",
.method = HTTP_GET,
.handler = handle_group_profile_get,
.user_ctx = NULL,
};
static const httpd_uri_t uri_post = {
.uri = "/game/group_profile",
.method = HTTP_POST,
.handler = handle_group_profile_post,
.user_ctx = NULL,
};
static const httpd_uri_t uri_scenario_post = {
.uri = "/game/scenario",
.method = HTTP_POST,
.handler = handle_scenario_post,
.user_ctx = NULL,
};
static const httpd_uri_t uri_scenario_relay = {
.uri = "/game/scenario/relay",
.method = HTTP_POST,
.handler = handle_scenario_relay_post,
.user_ctx = NULL,
};
// Bring up the ESP-NOW mesh transport. The master is primarily a sender
// (the relay handler) but we also register the apply adapter so a peer
// could push a scenario back over ESP-NOW symmetrically. Non-fatal: if the
// Wi-Fi stack isn't ready the relay endpoint will just report send errors.
esp_err_t mesh_err = scenario_mesh_init(scenario_mesh_apply_adapter);
if (mesh_err != ESP_OK) {
ESP_LOGW(TAG, "scenario_mesh_init failed: %s — /game/scenario/relay "
"will be unavailable", esp_err_to_name(mesh_err));
}
esp_err_t err = httpd_register_uri_handler(server, &uri_get);
if (err != ESP_OK) {
ESP_LOGE(TAG, "register GET /game/group_profile: %s",
esp_err_to_name(err));
return err;
}
err = httpd_register_uri_handler(server, &uri_post);
if (err != ESP_OK) {
ESP_LOGE(TAG, "register POST /game/group_profile: %s",
esp_err_to_name(err));
return err;
}
err = httpd_register_uri_handler(server, &uri_scenario_post);
if (err != ESP_OK) {
ESP_LOGE(TAG, "register POST /game/scenario: %s",
esp_err_to_name(err));
return err;
}
// Relay is best-effort: only register it when the mesh came up, but a
// registration failure here is non-fatal to the rest of the surface.
if (mesh_err == ESP_OK) {
err = httpd_register_uri_handler(server, &uri_scenario_relay);
if (err != ESP_OK) {
ESP_LOGW(TAG, "register POST /game/scenario/relay: %s",
esp_err_to_name(err));
}
}
ESP_LOGI(TAG, "game endpoint registered "
"(GET+POST /game/group_profile, POST /game/scenario%s)",
mesh_err == ESP_OK ? ", POST /game/scenario/relay" : "");
return ESP_OK;
}
@@ -0,0 +1,69 @@
// game_endpoint — REST surface for runtime game configuration.
//
// Slice 12 of the IDF migration. Today this exposes a single resource:
//
// GET /game/group_profile — read the active hints group profile.
// POST /game/group_profile — set a new profile, persist to NVS, and
// push it to the hints_client so the
// next /hints/ask body carries it.
//
// The handlers are attached to the existing esp_http_server instance
// owned by the ota_server component (port 80) — same pattern as
// voice_hook_endpoint. No second TCP socket, no second worker pool.
//
// NVS persistence: namespace "zacus", key "group_profile". This is the
// same key main.c reads at boot to seed the hints client, so a
// successful POST survives reboot without any flash step.
//
// Validation is delegated to hints_client_set_group_profile() which
// already enforces the "TECH" / "NON_TECH" / "MIXED" / "BOTH" whitelist.
// On invalid input the NVS write is skipped and the client keeps its
// previous value.
#pragma once
#include "esp_err.h"
#include "esp_http_server.h"
#ifdef __cplusplus
extern "C" {
#endif
// Cap the request body so a malformed PLIP / dashboard client cannot
// blow up the worker stack. 256 bytes is plenty for {"group_profile":
// "NON_TECH"} (~30 bytes) plus future additive fields.
#define GAME_ENDPOINT_MAX_BODY_BYTES 256
// Larger cap for the Runtime 3 IR scenario blob. 64 KiB lets a
// reasonable escape-room scenario (~50 steps, dialogues + actions)
// fit comfortably. Scenarios that exceed this should be split
// across multiple boards or trimmed.
#define GAME_ENDPOINT_MAX_SCENARIO_BYTES (64 * 1024)
// LittleFS partition label declared in partitions.csv. game_endpoint
// mounts lazily on first scenario POST. media_manager may also mount
// the same label — esp_vfs_littlefs_register is idempotent per label.
#define GAME_ENDPOINT_STORAGE_LABEL "storage"
// main.c mounts the storage partition at /littlefs at boot — we reuse the
// same mount point instead of registering a second base path for the same
// partition (which fails silently with INVALID_STATE).
#define GAME_ENDPOINT_STORAGE_BASE "/littlefs"
#define GAME_ENDPOINT_SCENARIO_PATH GAME_ENDPOINT_STORAGE_BASE "/scenario.json"
#define GAME_ENDPOINT_SCENARIO_BAK GAME_ENDPOINT_STORAGE_BASE "/scenario.bak"
/**
* @brief Attach all game endpoint handlers to an existing esp_http_server.
*
* Registers:
* - GET/POST /game/group_profile (slice 12, runtime hints profile)
* - POST /game/scenario (slice 13, Runtime 3 IR hot-load)
*
* Pass the handle returned by `ota_server_get_handle()`. Returns
* ESP_ERR_INVALID_ARG if `server` is NULL, or any error propagated
* from `httpd_register_uri_handler()`.
*/
esp_err_t game_endpoint_init(httpd_handle_t server);
#ifdef __cplusplus
}
#endif
@@ -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
)
@@ -0,0 +1,327 @@
#include "hints_client.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cJSON.h"
#include "esp_event.h"
#include "esp_http_client.h"
#include "esp_log.h"
#include "esp_mac.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
static const char *TAG = "hints_client";
#define WORKER_STACK_DEFAULT 6144
#define WORKER_PRIO_DEFAULT 5
#define ASK_PATH "/hints/ask"
#define PUZZLE_START_PATH "/hints/puzzle_start"
#define ATTEMPT_FAILED_PATH "/hints/attempt_failed"
#define SESSION_ID_LEN 13 // 12 hex + NUL
static struct {
bool ready;
char base_url[HINTS_CLIENT_BASE_URL_MAX];
char session_id[SESSION_ID_LEN];
char group_profile[HINTS_CLIENT_GROUP_PROFILE_MAX];
} s_client = {0};
// Whitelist of accepted group profile values. Keep in sync with the
// `group_profile` enum in the hints engine (game/hints/* server-side).
static const char *const kAllowedProfiles[] = {
"TECH", "NON_TECH", "MIXED", "BOTH",
};
static const size_t kAllowedProfilesCount =
sizeof(kAllowedProfiles) / sizeof(kAllowedProfiles[0]);
static bool profile_is_allowed(const char *p) {
if (!p || !*p) return false;
for (size_t i = 0; i < kAllowedProfilesCount; ++i) {
if (strcmp(p, kAllowedProfiles[i]) == 0) return true;
}
return false;
}
// Receive buffer used by hints_client_ask. Sized to one HINTS_CLIENT_HINT_MAX
// hint plus generous JSON envelope.
typedef struct {
char *buf;
size_t cap;
size_t len;
} recv_buf_t;
static void session_id_init(void) {
uint8_t mac[6] = {0};
if (esp_efuse_mac_get_default(mac) == ESP_OK) {
snprintf(s_client.session_id, SESSION_ID_LEN,
"%02x%02x%02x%02x%02x%02x",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
} else {
strncpy(s_client.session_id, "unknown-mac", SESSION_ID_LEN - 1);
}
s_client.session_id[SESSION_ID_LEN - 1] = '\0';
}
esp_err_t hints_client_init(const char *base_url) {
if (!base_url || !*base_url) return ESP_ERR_INVALID_ARG;
if (strlen(base_url) >= sizeof(s_client.base_url)) return ESP_ERR_INVALID_SIZE;
strncpy(s_client.base_url, base_url, sizeof(s_client.base_url) - 1);
s_client.base_url[sizeof(s_client.base_url) - 1] = '\0';
// Default group profile until main reads NVS or the dashboard pushes
// a new one. Validated through hints_client_set_group_profile.
strncpy(s_client.group_profile, "MIXED",
sizeof(s_client.group_profile) - 1);
s_client.group_profile[sizeof(s_client.group_profile) - 1] = '\0';
session_id_init();
s_client.ready = true;
ESP_LOGI(TAG, "ready, base_url=%s session_id=%s group_profile=%s",
s_client.base_url, s_client.session_id, s_client.group_profile);
return ESP_OK;
}
bool hints_client_is_ready(void) {
return s_client.ready;
}
esp_err_t hints_client_set_group_profile(const char *profile) {
if (!profile_is_allowed(profile)) {
ESP_LOGW(TAG, "set_group_profile: invalid value \"%s\" — keeping \"%s\"",
profile ? profile : "(null)", s_client.group_profile);
return ESP_ERR_INVALID_ARG;
}
strncpy(s_client.group_profile, profile,
sizeof(s_client.group_profile) - 1);
s_client.group_profile[sizeof(s_client.group_profile) - 1] = '\0';
ESP_LOGI(TAG, "group_profile set to \"%s\"", s_client.group_profile);
return ESP_OK;
}
const char *hints_client_group_profile(void) {
// Always non-NULL after init(); falls back to empty string before
// init so callers don't need a separate ready check.
return s_client.group_profile[0] ? s_client.group_profile : "";
}
static esp_err_t http_event_cb(esp_http_client_event_t *evt) {
if (evt->event_id != HTTP_EVENT_ON_DATA) return ESP_OK;
recv_buf_t *r = (recv_buf_t *) evt->user_data;
if (!r || !r->buf) return ESP_OK;
int chunk = evt->data_len;
if (r->len + chunk >= r->cap) {
chunk = (int) (r->cap - r->len - 1);
}
if (chunk > 0) {
memcpy(r->buf + r->len, evt->data, (size_t) chunk);
r->len += (size_t) chunk;
r->buf[r->len] = '\0';
}
return ESP_OK;
}
// ── Shared HTTP helper ───────────────────────────────────────────────────
//
// Performs `POST {base_url}{path}` with `body_str` as the request body
// and writes the response into `recv` (NUL-terminated, truncated to
// recv->cap-1). Returns:
// ESP_OK any 2xx (including 204 No Content)
// ESP_FAIL transport ok, non-2xx response
// <esp_err_t> transport-level error from esp_http_client_perform()
//
// The caller owns `body_str` and `recv->buf`. `timeout_ms` is per-request.
static esp_err_t post_json(const char *path, const char *body_str,
int timeout_ms, recv_buf_t *recv) {
char url[HINTS_CLIENT_BASE_URL_MAX + 64];
snprintf(url, sizeof(url), "%s%s", s_client.base_url, path);
esp_http_client_config_t cfg = {
.url = url,
.method = HTTP_METHOD_POST,
.timeout_ms = timeout_ms,
.event_handler = recv ? http_event_cb : NULL,
.user_data = recv,
.disable_auto_redirect = true,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
if (!client) return ESP_FAIL;
esp_http_client_set_header(client, "Content-Type", "application/json");
esp_http_client_set_post_field(client, body_str, (int) strlen(body_str));
esp_err_t err = esp_http_client_perform(client);
int status = esp_http_client_get_status_code(client);
esp_http_client_cleanup(client);
if (err != ESP_OK) {
ESP_LOGW(TAG, "POST %s perform failed: %s",
path, esp_err_to_name(err));
return err;
}
if (status < 200 || status >= 300) {
ESP_LOGW(TAG, "POST %s non-2xx %d body=%.*s",
path, status,
recv ? (int) recv->len : 0,
recv ? recv->buf : "");
return ESP_FAIL;
}
return ESP_OK;
}
esp_err_t hints_client_ask(const char *puzzle_id, uint8_t level,
char *out_hint, size_t out_size) {
if (!s_client.ready) return ESP_ERR_INVALID_STATE;
if (!puzzle_id || !*puzzle_id || !out_hint || out_size == 0) {
return ESP_ERR_INVALID_ARG;
}
cJSON *body = cJSON_CreateObject();
if (!body) return ESP_ERR_NO_MEM;
cJSON_AddStringToObject(body, "puzzle_id", puzzle_id);
cJSON_AddNumberToObject(body, "level", level);
cJSON_AddStringToObject(body, "session_id", s_client.session_id);
if (s_client.group_profile[0] != '\0') {
cJSON_AddStringToObject(body, "group_profile", s_client.group_profile);
}
char *body_str = cJSON_PrintUnformatted(body);
cJSON_Delete(body);
if (!body_str) return ESP_ERR_NO_MEM;
char recv_storage[1024];
recv_buf_t recv = {.buf = recv_storage, .cap = sizeof(recv_storage), .len = 0};
recv_storage[0] = '\0';
esp_err_t err = post_json(ASK_PATH, body_str,
HINTS_CLIENT_TIMEOUT_MS, &recv);
free(body_str);
if (err != ESP_OK) return err;
cJSON *root = cJSON_Parse(recv.buf);
if (!root) {
ESP_LOGW(TAG, "JSON parse failed: %.*s", (int) recv.len, recv.buf);
return ESP_FAIL;
}
cJSON *refused = cJSON_GetObjectItemCaseSensitive(root, "refused");
if (cJSON_IsTrue(refused)) {
cJSON *reason = cJSON_GetObjectItemCaseSensitive(root, "reason");
const char *reason_str = (reason && cJSON_IsString(reason))
? reason->valuestring : "unknown";
snprintf(out_hint, out_size,
"Le Professeur Zacus reste muet pour l'instant.");
ESP_LOGI(TAG, "refused: %s", reason_str);
cJSON_Delete(root);
return ESP_OK; // refusal is a valid response, not an error
}
cJSON *hint = cJSON_GetObjectItemCaseSensitive(root, "hint");
if (!hint || !cJSON_IsString(hint)) {
ESP_LOGW(TAG, "no hint field in response");
cJSON_Delete(root);
return ESP_FAIL;
}
strncpy(out_hint, hint->valuestring, out_size - 1);
out_hint[out_size - 1] = '\0';
cJSON_Delete(root);
return ESP_OK;
}
// ── Lifecycle endpoints (slice 11 / P5) ────────────────────────────────────
// Shared body builder for /puzzle_start and /attempt_failed.
// Both endpoints take the same minimal payload {session_id, puzzle_id}.
static esp_err_t lifecycle_post(const char *path, const char *puzzle_id) {
if (!s_client.ready) return ESP_ERR_INVALID_STATE;
if (!puzzle_id || !*puzzle_id) return ESP_ERR_INVALID_ARG;
cJSON *body = cJSON_CreateObject();
if (!body) return ESP_ERR_NO_MEM;
cJSON_AddStringToObject(body, "session_id", s_client.session_id);
cJSON_AddStringToObject(body, "puzzle_id", puzzle_id);
char *body_str = cJSON_PrintUnformatted(body);
cJSON_Delete(body);
if (!body_str) return ESP_ERR_NO_MEM;
char recv_storage[256];
recv_buf_t recv = {.buf = recv_storage, .cap = sizeof(recv_storage), .len = 0};
recv_storage[0] = '\0';
esp_err_t err = post_json(path, body_str,
HINTS_CLIENT_LIFECYCLE_TIMEOUT_MS, &recv);
free(body_str);
return err;
}
esp_err_t hints_client_puzzle_start(const char *puzzle_id) {
esp_err_t err = lifecycle_post(PUZZLE_START_PATH, puzzle_id);
if (err == ESP_OK) {
ESP_LOGI(TAG, "puzzle_start ok puzzle=\"%s\"", puzzle_id);
} else {
ESP_LOGW(TAG, "puzzle_start best-effort failed (puzzle=\"%s\"): %s",
puzzle_id ? puzzle_id : "(null)", esp_err_to_name(err));
}
return err;
}
esp_err_t hints_client_attempt_failed(const char *puzzle_id) {
esp_err_t err = lifecycle_post(ATTEMPT_FAILED_PATH, puzzle_id);
if (err == ESP_OK) {
ESP_LOGI(TAG, "attempt_failed ok puzzle=\"%s\"", puzzle_id);
} else {
ESP_LOGW(TAG, "attempt_failed best-effort failed (puzzle=\"%s\"): %s",
puzzle_id ? puzzle_id : "(null)", esp_err_to_name(err));
}
return err;
}
// ── Async wrapper ──────────────────────────────────────────────────────────
typedef struct {
char puzzle_id_str[64];
uint8_t puzzle_id_num;
uint8_t level;
hints_client_callback_t cb;
void *user_ctx;
} async_arg_t;
static void async_worker(void *pv) {
async_arg_t *arg = (async_arg_t *) pv;
char hint[HINTS_CLIENT_HINT_MAX];
hint[0] = '\0';
esp_err_t err = hints_client_ask(arg->puzzle_id_str, arg->level,
hint, sizeof(hint));
if (arg->cb) {
arg->cb(arg->puzzle_id_num, arg->level, err,
err == ESP_OK ? hint : NULL, arg->user_ctx);
}
free(arg);
vTaskDelete(NULL);
}
esp_err_t hints_client_ask_async(const char *puzzle_id_str,
uint8_t puzzle_id_num,
uint8_t level,
hints_client_callback_t cb,
void *user_ctx,
uint32_t stack,
uint8_t prio) {
if (!s_client.ready) return ESP_ERR_INVALID_STATE;
if (!puzzle_id_str || !*puzzle_id_str || !cb) return ESP_ERR_INVALID_ARG;
async_arg_t *arg = (async_arg_t *) calloc(1, sizeof(async_arg_t));
if (!arg) return ESP_ERR_NO_MEM;
strncpy(arg->puzzle_id_str, puzzle_id_str, sizeof(arg->puzzle_id_str) - 1);
arg->puzzle_id_num = puzzle_id_num;
arg->level = level;
arg->cb = cb;
arg->user_ctx = user_ctx;
BaseType_t ok = xTaskCreate(async_worker, "hints_async",
stack ? stack : WORKER_STACK_DEFAULT,
arg,
prio ? prio : WORKER_PRIO_DEFAULT,
NULL);
if (ok != pdPASS) {
free(arg);
return ESP_ERR_NO_MEM;
}
return ESP_OK;
}
@@ -0,0 +1,110 @@
// hints_client — HTTP client to the Zacus hints engine.
//
// POSTs JSON {"puzzle_id":..,"level":..,"session_id":..,"group_profile":..}
// to {base_url}/hints/ask and parses the "hint" / "source" / "refused" /
// "cooldown_until_ms" fields.
//
// Slice 11 (P5) adds two best-effort lifecycle endpoints used by the
// scenario engine to give the hints backend richer context for its
// adaptive policy:
// * /hints/puzzle_start — entered a new pivot
// * /hints/attempt_failed — operator reported an invalid input
// Both POST a minimal `{session_id, puzzle_id}` body. Failures are logged
// but never fatal: the engine works without these signals, they only
// improve the hint quality.
//
// Surfaces:
// * hints_client_ask() — synchronous, blocks the calling task up
// to HINTS_CLIENT_TIMEOUT_MS. Returns
// ESP_OK on success and writes the hint
// into out_hint.
// * hints_client_ask_async() — spawns a one-shot worker task that
// performs the request and invokes `cb`
// when done.
// * hints_client_puzzle_start() — synchronous best-effort POST.
// * hints_client_attempt_failed()— synchronous best-effort POST.
// * hints_client_set_group_profile() — global profile attached to every
// /hints/ask payload (default "MIXED").
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
#define HINTS_CLIENT_BASE_URL_MAX 128
#define HINTS_CLIENT_HINT_MAX 512
#define HINTS_CLIENT_TIMEOUT_MS 10000 // 10 s — covers MLX 32B latency
#define HINTS_CLIENT_LIFECYCLE_TIMEOUT_MS 5000 // /puzzle_start + /attempt_failed
#define HINTS_CLIENT_GROUP_PROFILE_MAX 32 // "TECH","NON_TECH","MIXED","BOTH"
// Callback signature mirrors npc_engine.h's npc_hint_callback_t so the
// engine can forward `cb` directly without a trampoline.
typedef void (*hints_client_callback_t)(uint8_t puzzle_id, uint8_t level,
esp_err_t status, const char *text,
void *user_ctx);
// Initialise the client with the base URL of the hints engine
// (e.g. "http://192.168.0.150:8302"). Caller retains ownership of the
// string — it is copied internally.
esp_err_t hints_client_init(const char *base_url);
// Returns true once hints_client_init() succeeded.
bool hints_client_is_ready(void);
// Synchronous request. `out_hint` receives a NUL-terminated UTF-8 string,
// up to `out_size` bytes. Errors:
// ESP_ERR_INVALID_STATE not initialised
// ESP_ERR_INVALID_ARG bad params
// ESP_ERR_TIMEOUT server did not respond in time
// ESP_FAIL HTTP non-2xx or JSON parse error
esp_err_t hints_client_ask(const char *puzzle_id, uint8_t level,
char *out_hint, size_t out_size);
// Async wrapper. Spawns a worker FreeRTOS task with `stack` bytes of stack
// (default 6144 if 0 passed) and priority `prio` (default 5 if 0). The worker
// calls hints_client_ask() and then `cb` from its own context.
//
// `puzzle_id_str` and the user_ctx pointer are copied into the worker arg
// block, so the caller does not need to keep them alive.
esp_err_t hints_client_ask_async(const char *puzzle_id_str,
uint8_t puzzle_id_num,
uint8_t level,
hints_client_callback_t cb,
void *user_ctx,
uint32_t stack,
uint8_t prio);
// Slice 11 (P5): notify the hints engine that the operator just entered
// the pivot identified by `puzzle_id`. Synchronous POST, timeout
// HINTS_CLIENT_LIFECYCLE_TIMEOUT_MS. Returns ESP_OK on any 2xx
// (the engine treats this as idempotent), ESP_FAIL otherwise. The call
// is best-effort — callers should log failures and continue.
esp_err_t hints_client_puzzle_start(const char *puzzle_id);
// Same shape as hints_client_puzzle_start, but for the
// /hints/attempt_failed endpoint. Bumps the engine's failure counter
// for the current pivot, which feeds the adaptive escalation policy.
esp_err_t hints_client_attempt_failed(const char *puzzle_id);
// Replace the global group profile attached to every /hints/ask body.
// `profile` must be one of "TECH", "NON_TECH", "MIXED", "BOTH". Any
// other value (including NULL/empty) is rejected with
// ESP_ERR_INVALID_ARG and the previous profile is preserved. Default
// after init() is "MIXED".
esp_err_t hints_client_set_group_profile(const char *profile);
// Returns the currently configured group profile (always non-NULL).
// The pointer is owned by the client; copy if you need it past the
// next set_group_profile() call.
const char *hints_client_group_profile(void);
#ifdef __cplusplus
}
#endif
@@ -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
)
@@ -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 <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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/<filename>`
// 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_<ms>.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
@@ -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 <ctype.h>
#include <dirent.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#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;
}
@@ -0,0 +1,30 @@
## Zacus npc_engine — ESP-IDF port (slice 4, P1).
##
## Ports the Arduino `npc_engine` decision module
## (ui_freenove_allinone/src/npc/npc_engine.cpp, 198 LOC) into an IDF
## component. The Arduino sources were already pure C with `extern "C"`
## guards and zero Arduino-runtime calls, so the port reuses the same
## state machine verbatim and only adapts the wrapper layer (init/update/
## trigger entry points) to the IDF idioms (esp_err_t returns, esp_log,
## media_manager integration). The hint-request HTTP path remains a stub
## (callback invoked synchronously with a hardcoded text) until the
## hints-engine HTTP client lands in slice 6.
##
## Persistence of "cues already played" lives in RAM only at this slice;
## an NVS-backed log can be added once a real trigger source feeds the
## engine.
idf_component_register(
SRCS
"npc_engine.c"
INCLUDE_DIRS
"include"
REQUIRES
media_manager
hints_client
esp_timer
esp_system
nvs_flash
freertos
log
)
@@ -0,0 +1,203 @@
// Zacus npc_engine — ESP-IDF C port of the Arduino NPC decision engine.
//
// Source of truth for the Arduino implementation:
// ESP32_ZACUS/ui_freenove_allinone/src/npc/npc_engine.cpp
// ESP32_ZACUS/ui_freenove_allinone/include/npc/npc_engine.h
//
// The IDF port keeps the Arduino "core" state-machine API verbatim
// (npc_init / npc_evaluate / npc_on_*) because that code was already
// pure-C, side-effect free and free of Arduino-runtime dependencies.
//
// On top of that core the port adds an IDF-idiomatic "engine" wrapper
// layer with:
// * npc_engine_init(config) — boot the singleton, log readiness
// * npc_engine_update(now_ms) — periodic tick (mood + auto-evaluate)
// * npc_engine_trigger_cue(cue_id) — best-effort cue dispatch through
// media_manager_play()
// * npc_engine_set_step(step_id) — bridge to the scenario engine
// * npc_engine_request_hint(...) — async hint request (stubbed locally
// until the hints-engine HTTP client
// lands in a later slice)
//
// All wrapper entry points return `esp_err_t`. Callbacks are plain C
// function pointers — no C++ classes, no lambdas, RTOS-friendly.
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
// ── Core (ported verbatim from the Arduino sources) ─────────────────────────
#define NPC_MAX_SCENES 12
#define NPC_MAX_HINT_LEVEL 3
#define NPC_PHRASE_MAX_LEN 200
#define NPC_STUCK_TIMEOUT_MS (3UL * 60UL * 1000UL)
#define NPC_FAST_THRESHOLD_PCT 50
#define NPC_SLOW_THRESHOLD_PCT 150
#define NPC_QR_DEBOUNCE_MS 30000
typedef enum {
NPC_MOOD_NEUTRAL = 0,
NPC_MOOD_IMPRESSED,
NPC_MOOD_WORRIED,
NPC_MOOD_AMUSED,
NPC_MOOD_COUNT
} npc_mood_t;
typedef enum {
NPC_TRIGGER_NONE = 0,
NPC_TRIGGER_HINT_REQUEST,
NPC_TRIGGER_STUCK_TIMER,
NPC_TRIGGER_QR_SCANNED,
NPC_TRIGGER_WRONG_ACTION,
NPC_TRIGGER_FAST_PROGRESS,
NPC_TRIGGER_SLOW_PROGRESS,
NPC_TRIGGER_SCENE_TRANSITION,
NPC_TRIGGER_GAME_START,
NPC_TRIGGER_GAME_END,
NPC_TRIGGER_COUNT
} npc_trigger_t;
typedef enum {
NPC_AUDIO_NONE = 0,
NPC_AUDIO_LIVE_TTS,
NPC_AUDIO_SD_CONTEXTUAL,
NPC_AUDIO_SD_GENERIC
} npc_audio_source_t;
typedef struct {
uint8_t current_scene;
uint8_t current_step;
uint32_t scene_start_ms;
uint32_t total_elapsed_ms;
uint8_t hints_given[NPC_MAX_SCENES];
uint8_t qr_scanned_count;
uint8_t failed_attempts;
bool phone_off_hook;
bool tower_reachable;
npc_mood_t mood;
uint32_t last_qr_scan_ms;
uint32_t expected_scene_duration_ms;
} npc_state_t;
typedef struct {
npc_trigger_t trigger;
npc_audio_source_t audio_source;
char phrase_text[NPC_PHRASE_MAX_LEN];
char sd_path[128];
npc_mood_t resulting_mood;
} npc_decision_t;
void npc_init(npc_state_t *state);
void npc_reset(npc_state_t *state);
bool npc_evaluate(const npc_state_t *state, uint32_t now_ms,
npc_decision_t *out);
void npc_on_scene_change(npc_state_t *state, uint8_t new_scene,
uint32_t expected_duration_ms, uint32_t now_ms);
void npc_on_qr_scan(npc_state_t *state, bool valid, uint32_t now_ms);
void npc_on_phone_hook(npc_state_t *state, bool off_hook);
void npc_on_hint_request(npc_state_t *state, uint32_t now_ms);
void npc_on_tower_status(npc_state_t *state, bool reachable);
void npc_update_mood(npc_state_t *state, uint32_t now_ms);
uint8_t npc_hint_level(const npc_state_t *state, uint8_t scene);
bool npc_build_sd_path(char *out_path, size_t capacity,
uint8_t scene, npc_trigger_t trigger,
npc_mood_t mood, uint8_t variant);
// ── Engine wrapper (IDF idioms) ─────────────────────────────────────────────
#define NPC_ENGINE_MAX_CUES 32
#define NPC_ENGINE_CUE_PATH_MAX 128
#define NPC_ENGINE_CUE_ID_MAX 32
// Static cue table entry. Authored cues live in flash; runtime state
// (already-played flag, cooldown) is tracked separately in RAM.
typedef struct {
char id[NPC_ENGINE_CUE_ID_MAX];
char audio_path[NPC_ENGINE_CUE_PATH_MAX];
uint8_t scene; // associated scene index (0xFF = global cue)
npc_mood_t mood;
} npc_cue_t;
// Configuration for the wrapper. `cues` may be NULL/0 — the engine still
// boots and accepts triggers (each `trigger_cue` call simply tries
// media_manager_play() with the supplied cue identifier as a path).
typedef struct {
const npc_cue_t *cues;
size_t cue_count;
bool auto_evaluate; // run npc_evaluate() each tick
bool auto_play_decisions; // dispatch decisions through media_manager_play
} npc_engine_config_t;
// Hint request callback. Invoked when the hints-engine produced a result
// (today: synchronously with a hardcoded stub text). `text` is owned by
// the engine and only valid for the duration of the call — copy if needed.
typedef void (*npc_hint_callback_t)(uint8_t puzzle_id, uint8_t level,
esp_err_t status, const char *text,
void *user_ctx);
// Initialize the engine singleton. `config` may be NULL — defaults are
// applied (no cue table, auto_evaluate=false, auto_play_decisions=false).
esp_err_t npc_engine_init(const npc_engine_config_t *config);
// Periodic tick. `now_ms` is the same monotonic millisecond counter as
// `media_manager_update`. Updates mood, optionally runs npc_evaluate and
// dispatches the resulting cue when `auto_*` flags are enabled.
esp_err_t npc_engine_update(uint32_t now_ms);
// Manually trigger a cue by id. Looks up the cue table and dispatches the
// associated audio path through media_manager_play(). When the cue is not
// in the table the engine treats `cue_id` itself as the path and forwards
// it as-is — useful for ad-hoc tests from the REST surface.
//
// Returns:
// ESP_OK cue dispatched (media_manager_play returned OK)
// ESP_ERR_NOT_FOUND cue id not in table AND raw path not playable
// ESP_ERR_INVALID_* propagated from media_manager
esp_err_t npc_engine_trigger_cue(const char *cue_id);
// Bridge from the scenario runtime — informs the engine of the active
// step (and implicitly the active scene). Resets failed-attempts counter
// and primes the stuck timer.
esp_err_t npc_engine_set_step(uint8_t step_id, uint32_t expected_duration_ms);
// Request a hint for `puzzle_id` at escalation `level` (0..3). The engine
// invokes `cb` with the resulting text. The current implementation is a
// LOCAL STUB: it returns a hardcoded French placeholder synchronously.
// TODO(slice-6): replace with HTTP POST to /hints/ask on the hints engine.
esp_err_t npc_engine_request_hint(uint8_t puzzle_id, uint8_t level,
npc_hint_callback_t cb, void *user_ctx);
// Read-only access to the underlying core state — handy for diagnostics.
const npc_state_t *npc_engine_state(void);
// Slice 11 (P5): forward the global hints group profile to hints_client.
// `profile` must be one of "TECH", "NON_TECH", "MIXED", "BOTH".
// Thin wrapper kept here to give callers a single npc_engine_* surface.
esp_err_t npc_engine_set_group_profile(const char *profile);
// Write the active puzzle id (e.g. "SCENE_LA_DETECTOR") into `out`,
// truncated to `cap` bytes (NUL-terminated). Falls back to "SCENE_NPC"
// when no scene is active or the engine is not initialised so callers
// always have a non-empty id to send to the hints engine. Returns the
// number of bytes written excluding the trailing NUL.
size_t npc_engine_current_puzzle_id(char *out, size_t cap);
// Slice 11 (P5): notify the hints engine that the operator just made an
// invalid attempt on `scene`. Resolves the scene to the same string id
// returned by npc_engine_current_puzzle_id() and forwards through
// hints_client_attempt_failed(). Best-effort: returns ESP_OK even if the
// hints engine is unreachable, the failure is logged.
esp_err_t npc_engine_report_failed_attempt(uint8_t scene);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,464 @@
// Zacus npc_engine — ESP-IDF C port (slice 4, P1).
//
// Portage strategy: the Arduino sources were already pure C (no classes,
// no Arduino runtime calls), so the "core" half of this file is a verbatim
// copy of ui_freenove_allinone/src/npc/npc_engine.cpp with `cstring`/`cstdio`
// replaced by their C headers. The "engine wrapper" half is new and follows
// the same pattern as the media_manager component:
//
// * single static singleton, idempotent init
// * esp_err_t returns + ESP_LOG instrumentation
// * media_manager_play() integration for cue dispatch
//
// Hint requests are stubbed locally (synchronous callback with a hardcoded
// French placeholder). The real HTTP call to the hints engine lands in a
// later slice (see TODO below).
#include "npc_engine.h"
#include <stdio.h>
#include <string.h>
#include "esp_err.h"
#include "esp_log.h"
#include "media_manager.h"
#include "hints_client.h"
static const char *TAG = "npc_engine";
// ─── Core: scene/trigger/mood lookup tables (verbatim Arduino) ──────────────
static const char *const kSceneIds[] = {
"SCENE_U_SON_PROTO",
"SCENE_LA_DETECTOR",
"SCENE_WIN_ETAPE1",
"SCENE_WARNING",
"SCENE_LEFOU_DETECTOR",
"SCENE_WIN_ETAPE2",
"SCENE_QR_DETECTOR",
"SCENE_FINAL_WIN",
};
static const uint8_t kSceneCount = sizeof(kSceneIds) / sizeof(kSceneIds[0]);
static const char *const kTriggerDirs[] = {
[NPC_TRIGGER_NONE] = "generic",
[NPC_TRIGGER_HINT_REQUEST] = "indice",
[NPC_TRIGGER_STUCK_TIMER] = "indice",
[NPC_TRIGGER_QR_SCANNED] = "felicitations",
[NPC_TRIGGER_WRONG_ACTION] = "attention",
[NPC_TRIGGER_FAST_PROGRESS] = "fausse_piste",
[NPC_TRIGGER_SLOW_PROGRESS] = "adaptation",
[NPC_TRIGGER_SCENE_TRANSITION] = "transition",
[NPC_TRIGGER_GAME_START] = "ambiance",
[NPC_TRIGGER_GAME_END] = "ambiance",
};
static const char *const kMoodSuffixes[] = {
[NPC_MOOD_NEUTRAL] = "neutral",
[NPC_MOOD_IMPRESSED] = "impressed",
[NPC_MOOD_WORRIED] = "worried",
[NPC_MOOD_AMUSED] = "amused",
};
// ─── Core: state-machine API (verbatim Arduino, NULL guards preserved) ──────
void npc_init(npc_state_t *state) {
if (state == NULL) return;
memset(state, 0, sizeof(*state));
state->mood = NPC_MOOD_NEUTRAL;
}
void npc_reset(npc_state_t *state) {
npc_init(state);
}
void npc_on_scene_change(npc_state_t *state, uint8_t new_scene,
uint32_t expected_duration_ms, uint32_t now_ms) {
if (state == NULL) return;
state->current_scene = new_scene;
state->scene_start_ms = now_ms;
state->expected_scene_duration_ms = expected_duration_ms;
state->failed_attempts = 0;
}
void npc_on_qr_scan(npc_state_t *state, bool valid, uint32_t now_ms) {
if (state == NULL) return;
if (valid) {
state->qr_scanned_count++;
} else {
state->failed_attempts++;
}
state->last_qr_scan_ms = now_ms;
}
void npc_on_phone_hook(npc_state_t *state, bool off_hook) {
if (state == NULL) return;
state->phone_off_hook = off_hook;
}
void npc_on_hint_request(npc_state_t *state, uint32_t now_ms) {
if (state == NULL) return;
uint8_t scene = state->current_scene;
if (scene < NPC_MAX_SCENES && state->hints_given[scene] < NPC_MAX_HINT_LEVEL) {
state->hints_given[scene]++;
}
(void) now_ms;
}
void npc_on_tower_status(npc_state_t *state, bool reachable) {
if (state == NULL) return;
state->tower_reachable = reachable;
}
void npc_update_mood(npc_state_t *state, uint32_t now_ms) {
if (state == NULL || state->expected_scene_duration_ms == 0) return;
uint32_t elapsed = now_ms - state->scene_start_ms;
uint32_t expected = state->expected_scene_duration_ms;
uint32_t pct = (elapsed * 100U) / expected;
if (state->failed_attempts >= 3) {
state->mood = NPC_MOOD_AMUSED;
} else if (pct < NPC_FAST_THRESHOLD_PCT) {
state->mood = NPC_MOOD_IMPRESSED;
} else if (pct > NPC_SLOW_THRESHOLD_PCT) {
state->mood = NPC_MOOD_WORRIED;
} else {
state->mood = NPC_MOOD_NEUTRAL;
}
}
uint8_t npc_hint_level(const npc_state_t *state, uint8_t scene) {
if (state == NULL || scene >= NPC_MAX_SCENES) return 0;
return state->hints_given[scene];
}
bool npc_build_sd_path(char *out_path, size_t capacity,
uint8_t scene, npc_trigger_t trigger,
npc_mood_t mood, uint8_t variant) {
if (out_path == NULL || capacity < 16) return false;
const char *scene_id = (scene < kSceneCount) ? kSceneIds[scene] : "npc";
const char *trigger_dir = (trigger < NPC_TRIGGER_COUNT)
? kTriggerDirs[trigger] : "generic";
const char *mood_str = (mood < NPC_MOOD_COUNT)
? kMoodSuffixes[mood] : "neutral";
bool is_scene_specific = (trigger != NPC_TRIGGER_GAME_START
&& trigger != NPC_TRIGGER_GAME_END
&& trigger != NPC_TRIGGER_NONE);
int written;
if (is_scene_specific && scene < kSceneCount) {
written = snprintf(out_path, capacity,
"/hotline_tts/%s/%s_%s_%u.mp3",
scene_id, trigger_dir, mood_str, (unsigned) variant);
} else {
written = snprintf(out_path, capacity,
"/hotline_tts/npc/%s_%s_%u.mp3",
trigger_dir, mood_str, (unsigned) variant);
}
return (written > 0 && (size_t) written < capacity);
}
bool npc_evaluate(const npc_state_t *state, uint32_t now_ms,
npc_decision_t *out) {
if (state == NULL || out == NULL) return false;
memset(out, 0, sizeof(*out));
uint32_t scene_elapsed = now_ms - state->scene_start_ms;
uint32_t expected = state->expected_scene_duration_ms;
// Priority 1: Hint request (phone off hook while stuck)
if (state->phone_off_hook && scene_elapsed > NPC_STUCK_TIMEOUT_MS) {
uint8_t level = npc_hint_level(state, state->current_scene);
out->trigger = NPC_TRIGGER_HINT_REQUEST;
out->resulting_mood = state->mood;
npc_build_sd_path(out->sd_path, sizeof(out->sd_path),
state->current_scene, NPC_TRIGGER_HINT_REQUEST,
state->mood, level);
out->audio_source = state->tower_reachable
? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL;
return true;
}
// Priority 2: Stuck timer (proactive, no phone needed)
if (scene_elapsed > NPC_STUCK_TIMEOUT_MS
&& npc_hint_level(state, state->current_scene) == 0) {
out->trigger = NPC_TRIGGER_STUCK_TIMER;
out->resulting_mood = NPC_MOOD_WORRIED;
npc_build_sd_path(out->sd_path, sizeof(out->sd_path),
state->current_scene, NPC_TRIGGER_STUCK_TIMER,
NPC_MOOD_WORRIED, 0);
out->audio_source = state->tower_reachable
? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL;
return true;
}
// Priority 3: Fast progress detection
if (expected > 0 && scene_elapsed > 0
&& (scene_elapsed * 100U / expected) < NPC_FAST_THRESHOLD_PCT) {
out->trigger = NPC_TRIGGER_FAST_PROGRESS;
out->resulting_mood = NPC_MOOD_IMPRESSED;
npc_build_sd_path(out->sd_path, sizeof(out->sd_path),
state->current_scene, NPC_TRIGGER_FAST_PROGRESS,
NPC_MOOD_IMPRESSED, 0);
out->audio_source = state->tower_reachable
? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL;
return true;
}
// Priority 4: Slow progress detection
if (expected > 0 && (scene_elapsed * 100U / expected) > NPC_SLOW_THRESHOLD_PCT) {
out->trigger = NPC_TRIGGER_SLOW_PROGRESS;
out->resulting_mood = NPC_MOOD_WORRIED;
npc_build_sd_path(out->sd_path, sizeof(out->sd_path),
state->current_scene, NPC_TRIGGER_SLOW_PROGRESS,
NPC_MOOD_WORRIED, 0);
out->audio_source = state->tower_reachable
? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL;
return true;
}
return false;
}
// ─── Wrapper singleton (slice-4 IDF surface) ────────────────────────────────
typedef struct {
bool ready;
npc_state_t core;
const npc_cue_t *cues;
size_t cue_count;
bool played[NPC_ENGINE_MAX_CUES]; // already-played log
bool auto_evaluate;
bool auto_play_decisions;
} engine_t;
static engine_t s_engine;
static const npc_cue_t *find_cue(const char *cue_id) {
if (cue_id == NULL || s_engine.cues == NULL) return NULL;
for (size_t i = 0; i < s_engine.cue_count && i < NPC_ENGINE_MAX_CUES; ++i) {
if (strncmp(s_engine.cues[i].id, cue_id,
NPC_ENGINE_CUE_ID_MAX) == 0) {
return &s_engine.cues[i];
}
}
return NULL;
}
static size_t cue_index(const npc_cue_t *cue) {
if (cue == NULL || s_engine.cues == NULL) return SIZE_MAX;
return (size_t) (cue - s_engine.cues);
}
esp_err_t npc_engine_init(const npc_engine_config_t *config) {
memset(&s_engine, 0, sizeof(s_engine));
npc_init(&s_engine.core);
if (config != NULL) {
s_engine.cues = config->cues;
s_engine.cue_count = config->cue_count;
s_engine.auto_evaluate = config->auto_evaluate;
s_engine.auto_play_decisions = config->auto_play_decisions;
if (s_engine.cue_count > NPC_ENGINE_MAX_CUES) {
ESP_LOGW(TAG, "cue table truncated: %u > NPC_ENGINE_MAX_CUES (%u)",
(unsigned) s_engine.cue_count,
(unsigned) NPC_ENGINE_MAX_CUES);
s_engine.cue_count = NPC_ENGINE_MAX_CUES;
}
}
s_engine.ready = true;
ESP_LOGI(TAG, "npc_engine ready, %u cues registered "
"(auto_evaluate=%d, auto_play=%d)",
(unsigned) s_engine.cue_count,
(int) s_engine.auto_evaluate,
(int) s_engine.auto_play_decisions);
return ESP_OK;
}
esp_err_t npc_engine_update(uint32_t now_ms) {
if (!s_engine.ready) return ESP_ERR_INVALID_STATE;
s_engine.core.total_elapsed_ms = now_ms;
npc_update_mood(&s_engine.core, now_ms);
if (!s_engine.auto_evaluate) return ESP_OK;
npc_decision_t decision;
if (!npc_evaluate(&s_engine.core, now_ms, &decision)) return ESP_OK;
ESP_LOGI(TAG, "decision: trigger=%d mood=%d audio=%d path=\"%s\"",
(int) decision.trigger,
(int) decision.resulting_mood,
(int) decision.audio_source,
decision.sd_path);
if (s_engine.auto_play_decisions && decision.sd_path[0] != '\0') {
esp_err_t err = media_manager_play(decision.sd_path);
if (err != ESP_OK) {
ESP_LOGW(TAG, "auto-play \"%s\" failed: %s",
decision.sd_path, esp_err_to_name(err));
}
}
return ESP_OK;
}
esp_err_t npc_engine_trigger_cue(const char *cue_id) {
if (!s_engine.ready) return ESP_ERR_INVALID_STATE;
if (cue_id == NULL || cue_id[0] == '\0') return ESP_ERR_INVALID_ARG;
const npc_cue_t *cue = find_cue(cue_id);
const char *path = NULL;
size_t idx = SIZE_MAX;
if (cue != NULL) {
path = cue->audio_path;
idx = cue_index(cue);
ESP_LOGI(TAG, "trigger_cue id=\"%s\" -> path=\"%s\" (scene=%u, mood=%d)",
cue_id, path, (unsigned) cue->scene, (int) cue->mood);
} else {
// Fallback: treat the id itself as a path. Useful for ad-hoc REST tests.
path = cue_id;
ESP_LOGI(TAG, "trigger_cue id=\"%s\" not in table — playing raw path",
cue_id);
}
esp_err_t err = media_manager_play(path);
if (err == ESP_OK && idx < NPC_ENGINE_MAX_CUES) {
s_engine.played[idx] = true;
}
return err;
}
// Resolve a scene index to the canonical puzzle id used by the hints
// engine (matches game/scenarios/npc_phrases.yaml). Returns the count
// of bytes written (excluding NUL). Always writes at least the fallback
// "SCENE_NPC" so callers can rely on a non-empty string.
static size_t scene_to_puzzle_id(uint8_t scene, char *out, size_t cap) {
if (out == NULL || cap == 0) return 0;
const char *id = (scene < kSceneCount) ? kSceneIds[scene] : "SCENE_NPC";
int written = snprintf(out, cap, "%s", id);
if (written < 0) {
out[0] = '\0';
return 0;
}
return ((size_t) written < cap) ? (size_t) written : (cap - 1);
}
esp_err_t npc_engine_set_step(uint8_t step_id, uint32_t expected_duration_ms) {
if (!s_engine.ready) return ESP_ERR_INVALID_STATE;
const uint8_t prev_scene = s_engine.core.current_scene;
s_engine.core.current_step = step_id;
npc_on_scene_change(&s_engine.core, step_id, expected_duration_ms,
s_engine.core.total_elapsed_ms);
ESP_LOGI(TAG, "step set to %u (expected_duration=%u ms)",
(unsigned) step_id, (unsigned) expected_duration_ms);
// Slice 11 (P5): notify the hints engine that the operator just
// entered a new pivot. Idempotent on the server, so we still post
// even if step_id hasn't moved (defensive: the scenario may rebind
// the same step after a recovery). hints_client logs the outcome
// internally — we don't propagate the failure (best-effort).
if (hints_client_is_ready()) {
char puzzle_id[NPC_ENGINE_CUE_ID_MAX];
scene_to_puzzle_id(step_id, puzzle_id, sizeof(puzzle_id));
if (step_id != prev_scene) {
ESP_LOGI(TAG, "scene changed %u → %u, signalling /puzzle_start",
(unsigned) prev_scene, (unsigned) step_id);
}
(void) hints_client_puzzle_start(puzzle_id);
}
return ESP_OK;
}
esp_err_t npc_engine_set_group_profile(const char *profile) {
// Thin pass-through. hints_client validates the value and logs the
// outcome. Kept on npc_engine so the rest of the firmware doesn't
// need to depend directly on hints_client just for this knob.
if (!hints_client_is_ready()) {
ESP_LOGW(TAG, "set_group_profile(\"%s\") before hints_client_init",
profile ? profile : "(null)");
return ESP_ERR_INVALID_STATE;
}
return hints_client_set_group_profile(profile);
}
size_t npc_engine_current_puzzle_id(char *out, size_t cap) {
if (out == NULL || cap == 0) return 0;
uint8_t scene = s_engine.ready ? s_engine.core.current_scene : 0xFF;
return scene_to_puzzle_id(scene, out, cap);
}
esp_err_t npc_engine_report_failed_attempt(uint8_t scene) {
if (!s_engine.ready) return ESP_ERR_INVALID_STATE;
if (!hints_client_is_ready()) {
// Track locally only — keeps failed_attempts coherent so the
// stuck timer / mood updater still react.
s_engine.core.failed_attempts++;
ESP_LOGD(TAG, "failed_attempt scene=%u (hints offline, local only)",
(unsigned) scene);
return ESP_OK;
}
char puzzle_id[NPC_ENGINE_CUE_ID_MAX];
scene_to_puzzle_id(scene, puzzle_id, sizeof(puzzle_id));
s_engine.core.failed_attempts++;
(void) hints_client_attempt_failed(puzzle_id);
return ESP_OK;
}
esp_err_t npc_engine_request_hint(uint8_t puzzle_id, uint8_t level,
npc_hint_callback_t cb, void *user_ctx) {
if (!s_engine.ready) return ESP_ERR_INVALID_STATE;
if (cb == NULL) return ESP_ERR_INVALID_ARG;
const uint8_t clamped = (level > NPC_MAX_HINT_LEVEL)
? NPC_MAX_HINT_LEVEL : level;
npc_on_hint_request(&s_engine.core, s_engine.core.total_elapsed_ms);
// Slice 5: when the hints_client component has been initialised, route
// the request through the real HTTP backend asynchronously. Otherwise
// fall back to a hardcoded French placeholder so the surrounding NPC
// orchestration can still be exercised end-to-end (CI smoke, dry runs).
if (hints_client_is_ready()) {
// Slice 11 (P5): map the numeric puzzle hint id to the same
// SCENE_* string id used by /hints/puzzle_start. When the
// dispatcher passes id=0 (placeholder), fall back to the active
// scene so the hints engine can still pick a contextual answer.
char puzzle_str[NPC_ENGINE_CUE_ID_MAX];
const uint8_t scene_for_id = (puzzle_id == 0)
? s_engine.core.current_scene : puzzle_id;
scene_to_puzzle_id(scene_for_id, puzzle_str, sizeof(puzzle_str));
esp_err_t err = hints_client_ask_async(puzzle_str, puzzle_id, clamped,
(hints_client_callback_t) cb,
user_ctx, 0, 0);
if (err == ESP_OK) {
ESP_LOGI(TAG, "hint request puzzle=%u level=%u -> hints_client async",
(unsigned) puzzle_id, (unsigned) clamped);
return ESP_OK;
}
ESP_LOGW(TAG, "hints_client_ask_async failed (%s) — using stub",
esp_err_to_name(err));
}
static const char *const kStubHints[NPC_MAX_HINT_LEVEL + 1] = {
"Regarde autour de toi, la solution est plus proche que tu ne crois.",
"As-tu pensé à observer chaque indice plus attentivement ?",
"Concentre-toi sur l'objet le plus inhabituel de la pièce.",
"Le code se trouve dans la séquence des couleurs, dans l'ordre.",
};
const char *text = kStubHints[clamped];
ESP_LOGI(TAG, "hint request puzzle=%u level=%u -> stub \"%s\"",
(unsigned) puzzle_id, (unsigned) level, text);
cb(puzzle_id, clamped, ESP_OK, text, user_ctx);
return ESP_OK;
}
const npc_state_t *npc_engine_state(void) {
return s_engine.ready ? &s_engine.core : NULL;
}
@@ -0,0 +1,29 @@
idf_component_register(
SRCS
"ota_server.c"
INCLUDE_DIRS
"include"
REQUIRES
esp_http_server
app_update
esp_timer
esp_system
nvs_flash
mbedtls
freertos
)
# Firmware metadata injected at compile time
# Override these in the puzzle's CMakeLists.txt before adding the component
if(NOT DEFINED OTA_FIRMWARE_NAME)
set(OTA_FIRMWARE_NAME "zacus_puzzle")
endif()
if(NOT DEFINED OTA_FIRMWARE_VERSION)
set(OTA_FIRMWARE_VERSION "1.0.0")
endif()
target_compile_definitions(${COMPONENT_LIB} PRIVATE
OTA_FIRMWARE_NAME="${OTA_FIRMWARE_NAME}"
OTA_FIRMWARE_VERSION="${OTA_FIRMWARE_VERSION}"
)
@@ -0,0 +1,95 @@
#pragma once
#include "esp_err.h"
#include "esp_http_server.h"
#ifdef __cplusplus
extern "C" {
#endif
// ─── Version info (override in each puzzle's CMakeLists.txt) ─────────────────
#ifndef OTA_FIRMWARE_NAME
#define OTA_FIRMWARE_NAME "zacus_puzzle"
#endif
#ifndef OTA_FIRMWARE_VERSION
#define OTA_FIRMWARE_VERSION "1.0.0"
#endif
// ─── Configuration ────────────────────────────────────────────────────────────
#define OTA_SERVER_PORT 80
#define OTA_RATE_LIMIT_SECS 60 // Minimum seconds between OTA updates
#define OTA_WATCHDOG_SECS 30 // Auto-rollback if new firmware crashes within this
#define OTA_MAX_UPLOAD_SIZE (4 * 1024 * 1024) // 4 MB max firmware size
#define OTA_CHUNK_SIZE 4096
// ─── State ────────────────────────────────────────────────────────────────────
typedef enum {
OTA_STATE_IDLE = 0,
OTA_STATE_DOWNLOADING = 1,
OTA_STATE_VERIFYING = 2,
OTA_STATE_REBOOTING = 3,
OTA_STATE_ERROR = 4,
} ota_state_t;
typedef struct {
ota_state_t state;
int progress; // 0-100
char error[128];
uint32_t bytes_received;
uint32_t total_bytes;
int64_t last_ota_time; // Unix timestamp of last OTA attempt
} ota_status_t;
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* @brief Initialize the OTA HTTP server on port 80.
*
* Registers 5 endpoints:
* GET /version -> firmware name, version, IDF version
* GET /status -> battery, heap, uptime, ESP-NOW peers
* POST /ota -> receive .bin, write to OTA partition, reboot
* GET /ota/status -> current OTA state and progress
* POST /ota/rollback -> revert to previous firmware partition
*
* @return ESP_OK on success, error code otherwise.
*/
esp_err_t ota_server_init(void);
/**
* @brief Mark current firmware as valid (call after successful startup).
*
* Cancels the rollback watchdog. Call this after all subsystems have
* initialized successfully, typically 5-10 seconds after boot.
*/
void ota_server_mark_valid(void);
/**
* @brief Get the current OTA status.
*/
const ota_status_t* ota_server_get_status(void);
/**
* @brief Register a callback invoked when an OTA update completes.
*
* Called before the device reboots. Use to flush pending data to NVS.
*/
void ota_server_set_complete_cb(void (*cb)(bool success));
/**
* @brief Get the underlying esp_http_server handle so other components
* can register additional URI handlers on the same listener
* (port 80) instead of standing up a second httpd instance.
*
* Returns NULL if ota_server_init() has not been called or failed.
*
* Used by the voice_hook_endpoint component (PLIP /voice/hook bridge,
* slice 10) to attach POST /voice/hook + GET /voice/hook/state without
* burning a second TCP socket / second httpd worker.
*/
httpd_handle_t ota_server_get_handle(void);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,366 @@
#include "ota_server.h"
#include <string.h>
#include <time.h>
#include <sys/param.h>
#include "esp_log.h"
#include "esp_ota_ops.h"
#include "esp_app_format.h"
#include "esp_timer.h"
#include "esp_system.h"
#include "esp_http_server.h"
#include "esp_mac.h"
#include "nvs_flash.h"
#include "mbedtls/sha256.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
// ─── External symbols (provided by each puzzle's main component) ──────────────
extern int puzzle_get_battery_pct(void);
extern int puzzle_get_espnow_peer_count(void);
static const char* TAG = "ota_server";
// ─── Module state ─────────────────────────────────────────────────────────────
static httpd_handle_t s_server = NULL;
static ota_status_t s_status = { .state = OTA_STATE_IDLE };
static void (*s_complete_cb)(bool) = NULL;
static esp_timer_handle_t s_watchdog = NULL;
// ─── JSON helpers ─────────────────────────────────────────────────────────────
static void json_str(char* buf, size_t size, const char* key, const char* val, bool comma) {
snprintf(buf + strlen(buf), size - strlen(buf),
"\"%s\":\"%s\"%s", key, val, comma ? "," : "");
}
static void json_int(char* buf, size_t size, const char* key, int val, bool comma) {
snprintf(buf + strlen(buf), size - strlen(buf),
"\"%s\":%d%s", key, val, comma ? "," : "");
}
// ─── GET /version ─────────────────────────────────────────────────────────────
static esp_err_t handle_version(httpd_req_t* req) {
char buf[256] = "{";
json_str(buf, sizeof(buf), "firmware", OTA_FIRMWARE_NAME, true);
json_str(buf, sizeof(buf), "version", OTA_FIRMWARE_VERSION, true);
json_str(buf, sizeof(buf), "idf", IDF_VER, false);
strncat(buf, "}", sizeof(buf) - strlen(buf) - 1);
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
httpd_resp_sendstr(req, buf);
return ESP_OK;
}
// ─── GET /status ──────────────────────────────────────────────────────────────
static esp_err_t handle_status(httpd_req_t* req) {
char buf[256] = "{";
json_int(buf, sizeof(buf), "battery_pct", puzzle_get_battery_pct(), true);
json_int(buf, sizeof(buf), "uptime_s", (int)(esp_timer_get_time() / 1000000), true);
json_int(buf, sizeof(buf), "espnow_peers", puzzle_get_espnow_peer_count(), true);
json_int(buf, sizeof(buf), "heap_free", (int)esp_get_free_heap_size(), false);
strncat(buf, "}", sizeof(buf) - strlen(buf) - 1);
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
httpd_resp_sendstr(req, buf);
return ESP_OK;
}
// ─── GET /ota/status ──────────────────────────────────────────────────────────
static const char* state_to_str(ota_state_t state) {
switch (state) {
case OTA_STATE_IDLE: return "idle";
case OTA_STATE_DOWNLOADING: return "downloading";
case OTA_STATE_VERIFYING: return "verifying";
case OTA_STATE_REBOOTING: return "rebooting";
case OTA_STATE_ERROR: return "error";
default: return "unknown";
}
}
static esp_err_t handle_ota_status(httpd_req_t* req) {
char buf[256] = "{";
json_str(buf, sizeof(buf), "state", state_to_str(s_status.state), true);
json_int(buf, sizeof(buf), "progress", s_status.progress, false);
strncat(buf, "}", sizeof(buf) - strlen(buf) - 1);
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
httpd_resp_sendstr(req, buf);
return ESP_OK;
}
// ─── POST /ota ────────────────────────────────────────────────────────────────
static void do_ota_task(void* arg) {
esp_ota_handle_t ota_handle = 0;
const esp_partition_t* ota_part = NULL;
httpd_req_t* req = (httpd_req_t*)arg;
esp_err_t err = ESP_OK;
mbedtls_sha256_context sha_ctx;
mbedtls_sha256_init(&sha_ctx);
uint8_t* buf = malloc(OTA_CHUNK_SIZE);
if (!buf) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OOM"); goto cleanup; }
// Check content length
int total = req->content_len;
if (total <= 0 || total > OTA_MAX_UPLOAD_SIZE) {
ESP_LOGE(TAG, "Invalid content length: %d", total);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid size");
goto cleanup;
}
// Get OTA partition
ota_part = esp_ota_get_next_update_partition(NULL);
if (!ota_part) {
ESP_LOGE(TAG, "No OTA partition available");
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "No OTA partition");
goto cleanup;
}
err = esp_ota_begin(ota_part, OTA_SIZE_UNKNOWN, &ota_handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_begin failed: %s", esp_err_to_name(err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA begin failed");
goto cleanup;
}
s_status.state = OTA_STATE_DOWNLOADING;
s_status.bytes_received = 0;
s_status.total_bytes = total;
s_status.progress = 0;
// SHA256 context for integrity check
mbedtls_sha256_starts(&sha_ctx, 0);
// Receive and write firmware chunks
int received = 0;
while (received < total) {
int chunk_size = MIN(OTA_CHUNK_SIZE, total - received);
int r = httpd_req_recv(req, (char*)buf, chunk_size);
if (r <= 0) {
if (r == HTTPD_SOCK_ERR_TIMEOUT) continue;
ESP_LOGE(TAG, "Recv error: %d", r);
err = ESP_FAIL;
break;
}
err = esp_ota_write(ota_handle, buf, r);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_write failed at offset %d: %s", received, esp_err_to_name(err));
break;
}
mbedtls_sha256_update(&sha_ctx, buf, r);
received += r;
s_status.bytes_received = received;
s_status.progress = (received * 100) / total;
}
if (err != ESP_OK) {
snprintf(s_status.error, sizeof(s_status.error), "Write failed: %s", esp_err_to_name(err));
s_status.state = OTA_STATE_ERROR;
esp_ota_abort(ota_handle);
ota_handle = 0;
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, s_status.error);
goto cleanup;
}
// Compute SHA256 of received data
uint8_t sha256[32];
mbedtls_sha256_finish(&sha_ctx, sha256);
mbedtls_sha256_free(&sha_ctx);
s_status.state = OTA_STATE_VERIFYING;
s_status.progress = 95;
// Finalize OTA
err = esp_ota_end(ota_handle);
ota_handle = 0;
if (err != ESP_OK) {
snprintf(s_status.error, sizeof(s_status.error), "OTA end failed: %s", esp_err_to_name(err));
s_status.state = OTA_STATE_ERROR;
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, s_status.error);
goto cleanup;
}
// Set boot partition
err = esp_ota_set_boot_partition(ota_part);
if (err != ESP_OK) {
snprintf(s_status.error, sizeof(s_status.error), "Set boot failed: %s", esp_err_to_name(err));
s_status.state = OTA_STATE_ERROR;
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, s_status.error);
goto cleanup;
}
s_status.progress = 100;
s_status.state = OTA_STATE_REBOOTING;
// Respond before reboot
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
httpd_resp_sendstr(req, "{\"status\":\"ok\",\"message\":\"Firmware accepted, rebooting\"}");
if (s_complete_cb) s_complete_cb(true);
ESP_LOGI(TAG, "OTA success, rebooting in 1s");
vTaskDelay(pdMS_TO_TICKS(1000));
esp_restart();
cleanup:
if (ota_handle) esp_ota_abort(ota_handle);
free(buf);
mbedtls_sha256_free(&sha_ctx);
vTaskDelete(NULL);
}
static esp_err_t handle_ota_upload(httpd_req_t* req) {
// Rate limiting
int64_t now = esp_timer_get_time() / 1000000;
if (s_status.last_ota_time > 0 && (now - s_status.last_ota_time) < OTA_RATE_LIMIT_SECS) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Rate limited: wait 60s");
return ESP_FAIL;
}
if (s_status.state != OTA_STATE_IDLE) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "OTA already in progress");
return ESP_FAIL;
}
s_status.last_ota_time = now;
// Run OTA in a separate task to not block the HTTP server
if (xTaskCreate(do_ota_task, "ota_task", 8192, req, 5, NULL) != pdPASS) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Task create failed");
return ESP_FAIL;
}
// Task will send the HTTP response
return ESP_OK;
}
// ─── POST /ota/rollback ───────────────────────────────────────────────────────
static esp_err_t handle_ota_rollback(httpd_req_t* req) {
const esp_partition_t* prev = esp_ota_get_last_invalid_partition();
if (!prev) {
// Try running partition as fallback
prev = esp_ota_get_running_partition();
}
if (!prev) {
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "No previous partition to roll back to");
return ESP_FAIL;
}
esp_err_t err = esp_ota_set_boot_partition(prev);
if (err != ESP_OK) {
char msg[64];
snprintf(msg, sizeof(msg), "Rollback failed: %s", esp_err_to_name(err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, msg);
return ESP_FAIL;
}
httpd_resp_set_type(req, "application/json");
httpd_resp_sendstr(req, "{\"status\":\"ok\",\"message\":\"Rolling back, rebooting\"}");
vTaskDelay(pdMS_TO_TICKS(500));
esp_restart();
return ESP_OK;
}
// ─── Watchdog (auto-rollback) ──────────────────────────────────────────────────
static void watchdog_cb(void* arg) {
ESP_LOGE(TAG, "Watchdog expired -- new firmware did not call ota_server_mark_valid(), rolling back");
esp_ota_mark_app_invalid_rollback_and_reboot();
}
static void start_watchdog(void) {
const esp_timer_create_args_t args = {
.callback = watchdog_cb,
.name = "ota_watchdog",
};
esp_timer_create(&args, &s_watchdog);
esp_timer_start_once(s_watchdog, (int64_t)OTA_WATCHDOG_SECS * 1000000);
ESP_LOGI(TAG, "OTA watchdog started (%ds to mark valid)", OTA_WATCHDOG_SECS);
}
// ─── Public API ───────────────────────────────────────────────────────────────
esp_err_t ota_server_init(void) {
// Check if we booted from an OTA partition that needs validation
const esp_partition_t* running = esp_ota_get_running_partition();
esp_ota_img_states_t ota_state;
if (esp_ota_get_state_partition(running, &ota_state) == ESP_OK) {
if (ota_state == ESP_OTA_IMG_PENDING_VERIFY) {
ESP_LOGW(TAG, "Running unvalidated OTA firmware -- starting watchdog");
start_watchdog();
}
}
// HTTP server config
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = OTA_SERVER_PORT;
config.max_uri_handlers = 16; // ota (3) + voice_hook (2) + game (3) + headroom
config.uri_match_fn = httpd_uri_match_wildcard;
config.stack_size = 8192;
esp_err_t err = httpd_start(&s_server, &config);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to start HTTP server: %s", esp_err_to_name(err));
return err;
}
// Register URI handlers
static const httpd_uri_t uris[] = {
{ .uri = "/version", .method = HTTP_GET, .handler = handle_version },
{ .uri = "/status", .method = HTTP_GET, .handler = handle_status },
{ .uri = "/ota", .method = HTTP_POST, .handler = handle_ota_upload },
{ .uri = "/ota/status", .method = HTTP_GET, .handler = handle_ota_status },
{ .uri = "/ota/rollback", .method = HTTP_POST, .handler = handle_ota_rollback },
};
for (int i = 0; i < (int)(sizeof(uris) / sizeof(uris[0])); i++) {
err = httpd_register_uri_handler(s_server, &uris[i]);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to register URI %s: %s", uris[i].uri, esp_err_to_name(err));
return err;
}
}
ESP_LOGI(TAG, "OTA server started on port %d (%s v%s)",
OTA_SERVER_PORT, OTA_FIRMWARE_NAME, OTA_FIRMWARE_VERSION);
return ESP_OK;
}
void ota_server_mark_valid(void) {
if (s_watchdog) {
esp_timer_stop(s_watchdog);
esp_timer_delete(s_watchdog);
s_watchdog = NULL;
ESP_LOGI(TAG, "OTA watchdog cancelled -- firmware marked valid");
}
esp_ota_mark_app_valid_cancel_rollback();
}
const ota_status_t* ota_server_get_status(void) {
return &s_status;
}
void ota_server_set_complete_cb(void (*cb)(bool success)) {
s_complete_cb = cb;
}
httpd_handle_t ota_server_get_handle(void) {
return s_server;
}
@@ -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,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x6000
3 otadata data ota 0xf000 0x2000
4 phy_init data phy 0x11000 0x1000
5 factory app factory 0x20000 1500K
6 ota_0 app ota_0 1500K
7 ota_1 app ota_1 1500K
8 spiffs data spiffs 512K
@@ -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
)
@@ -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 <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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
@@ -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 <string.h>
#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;
}
@@ -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
)
@@ -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://<zacus-master-ip>/voice/hook \
-H "Content-Type: application/json" \
-d '{"state":"off","reason":"pickup"}'
# On-hook (PLIP hangs up)
curl -X POST http://<zacus-master-ip>/voice/hook \
-H "Content-Type: application/json" \
-d '{"state":"on","reason":"hangup"}'
# Probe current state
curl http://<zacus-master-ip>/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);
}
```
@@ -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
@@ -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 <string.h>
#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;
}
@@ -0,0 +1,30 @@
idf_component_register(
SRCS
"voice_pipeline.c"
"voice_pipeline_ws.c"
"voice_dispatcher.c"
INCLUDE_DIRS
"include"
PRIV_INCLUDE_DIRS
"."
REQUIRES
esp_driver_i2s
esp_timer
esp_system
esp_event
nvs_flash
freertos
log
media_manager
npc_engine
hints_client
# Slice 6: esp-sr managed component (Espressif AFE + WakeNet9).
# Listed in main/idf_component.yml so the component manager
# pulls it; this REQUIRES line is what lets us include
# esp_afe_*.h / esp_wn_*.h from voice_pipeline.c.
espressif__esp-sr
# Slice 7: WebSocket streaming to the MacStudio voice-bridge,
# plus cJSON for parsing the bridge's text frames.
espressif__esp_websocket_client
json
)
@@ -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 <stdbool.h>
#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
@@ -0,0 +1,183 @@
// voice_pipeline — I2S capture + ESP-SR (AFE + WakeNet9) pipeline for the
// Zacus NPC voice loop.
//
// Slice 5 deliverable:
// * I2S microphone bring-up (ESP-IDF 5.x i2s_std driver)
// * placeholder VAD/wake-word task that just logs heartbeat
// * state machine idle <-> listening <-> speaking <-> muted
//
// Slice 6 deliverable:
// * managed dependency on `espressif/esp-sr` (>= 2.0)
// * AFE pipeline (`AFE_TYPE_SR`, low-cost mode, 1 mic / no reference)
// wired on top of the existing I2S capture task
// * WakeNet9 wake-word detection. The active model is a placeholder
// standard model shipped by Espressif (default: `wn9_hiesp` =
// "Hi ESP"). Custom "Professeur Zacus" model is out of scope for
// this slice (requires a 2-4 week training round-trip with
// Espressif — tracked under the P2 voice-pipeline spec).
// * wake event routes through a user callback and auto-transitions
// the pipeline into VOICE_STATE_LISTENING.
//
// Slice 7 deliverable:
// * managed dependency on `espressif/esp_websocket_client` (~1.4)
// * after a wake event, post-AFE PCM chunks (16 kHz mono int16) are
// streamed as binary WebSocket frames to the MacStudio voice-bridge
// (`ws://studio:8200/voice/ws`).
// * the AFE VAD output drives end-of-speech detection: ~1.5 s of
// sustained `AFE_VAD_SILENCE` closes the upload with a JSON `end`
// control message and returns the state machine to IDLE.
// * STT transcripts received from the bridge fan out through a new
// `voice_pipeline_set_stt_callback`. The hint/intent dispatch
// itself stays in npc_engine (out of scope for this slice).
//
// Slice 9 deliverable (this revision):
// * TTS playback from the voice-bridge: PCM 16-bit mono @ 24 kHz
// received as binary WebSocket frames between
// `{"type":"speak_start", ...}` and `{"type":"speak_end", ...}`.
// * I2S TX bring-up on I2S_NUM_1 (MAX98357A DAC: BCLK / LRC / DIN).
// Disabled by default — opt-in via `enable_tts_playback`.
// * mute-during-TTS gate: while `state == VOICE_STATE_SPEAKING`, the
// capture task keeps draining I2S input but does NOT feed AFE,
// guaranteeing no wake re-trigger from the speaker echo.
// * state machine: LISTENING → SPEAKING on `speak_start`,
// SPEAKING → IDLE on `speak_end`.
//
// The HTTP plumbing to the hints engine still lives in the separate
// hints_client component to avoid a circular dependency with npc_engine.
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
VOICE_STATE_IDLE = 0,
VOICE_STATE_LISTENING,
VOICE_STATE_SPEAKING,
VOICE_STATE_MUTED,
} voice_state_t;
// Callback invoked from the capture task whenever the wake-word engine
// fires. The pipeline auto-transitions to VOICE_STATE_LISTENING *before*
// invoking the callback, so the callback can safely kick off STT capture
// or play an acknowledgement cue. The string `wake_word` is owned by
// esp-sr (lifetime tied to the AFE handle); if the callback needs to
// keep it, it must copy.
typedef void (*voice_wake_callback_t)(const char *wake_word, void *user_ctx);
// Callback invoked when the voice-bridge returns a transcription. The
// callback runs on the WebSocket task context (esp_websocket_client's
// event loop) — keep it short and offload heavy work to another task.
// `text` is a NUL-terminated UTF-8 string owned by the pipeline; copy
// before returning if you need to keep it. `final == true` means the
// bridge has flagged the segment as final; `final == false` is an
// interim transcript (may still be revised).
typedef void (*voice_stt_callback_t)(const char *text, bool final,
void *user_ctx);
typedef struct {
int i2s_bclk_pin; // BCLK (SCK)
int i2s_ws_pin; // WS (LRCK)
int i2s_din_pin; // DIN (mic data)
uint32_t sample_rate_hz; // 16000 default
bool auto_start_capture; // launch capture task at init
bool enable_wake_word; // bring up esp-sr AFE + WakeNet (slice 6)
// Slice 7: voice-bridge WebSocket endpoint, e.g.
// "ws://100.116.92.12:8200/voice/ws"
// Pass NULL (default) to disable streaming entirely — the wake
// callback still fires but no audio leaves the device. Lifetime of
// the string must outlive `voice_pipeline_init` (typically a static
// `#define` in main.c).
const char *voice_bridge_ws_url;
// Slice 9: TTS playback configuration. The voice-bridge streams
// back PCM 16-bit mono at 24 kHz between `speak_start` and
// `speak_end`. We render it on I2S_NUM_1 driving a MAX98357A class-D
// DAC (3-pin I2S: BCLK / LRC / DIN). Opt-in to keep slice-7-only
// builds bit-identical.
bool enable_tts_playback;
int i2s_out_bclk_pin; // GPIO11 default (Freenove convention)
int i2s_out_lrc_pin; // GPIO12 default (LRCK / WS)
int i2s_out_din_pin; // GPIO13 default (DIN to DAC)
} voice_pipeline_config_t;
// Reasonable defaults for a Freenove ESP32-S3 + INMP441 wiring. Override per
// hardware revision before calling voice_pipeline_init().
//
// Slice 6: `enable_wake_word` defaults to `false` to preserve slice-5
// behaviour for callers that only want the I2S capture stub. The Zacus
// master sets it to `true` in its own init flow.
//
// Slice 7: `voice_bridge_ws_url` defaults to NULL (streaming disabled).
void voice_pipeline_default_config(voice_pipeline_config_t *out);
esp_err_t voice_pipeline_init(const voice_pipeline_config_t *config);
esp_err_t voice_pipeline_start_capture(void);
esp_err_t voice_pipeline_stop_capture(void);
voice_state_t voice_pipeline_get_state(void);
esp_err_t voice_pipeline_set_state(voice_state_t state);
// Register the wake-word callback. Pass `cb = NULL` to clear. May be
// called before or after init. The callback runs in the capture task
// context — keep it short and offload heavy work to another task.
esp_err_t voice_pipeline_set_wake_callback(voice_wake_callback_t cb,
void *user_ctx);
// Register the STT result callback (slice 7). Pass `cb = NULL` to
// clear. May be called before or after init.
esp_err_t voice_pipeline_set_stt_callback(voice_stt_callback_t cb,
void *user_ctx);
// Returns true once esp-sr AFE + WakeNet have been brought up and the
// pipeline is actively running them in the capture task. Returns false
// if init was called with `enable_wake_word = false`, if the model
// partition was missing, or if AFE/WakeNet alloc failed (in which case
// the pipeline silently degrades to the slice-5 stub capture).
bool voice_pipeline_wake_word_active(void);
// Slice 7 manual control — open / close the WebSocket stream to the
// voice-bridge without going through the wake detector. Useful for
// REST-driven smoke tests and for forcing capture during dev. Returns
// ESP_ERR_INVALID_STATE if no `voice_bridge_ws_url` was configured.
esp_err_t voice_pipeline_start_streaming(void);
esp_err_t voice_pipeline_stop_streaming(void);
// True between voice_pipeline_start_streaming and the moment the
// pipeline detected end-of-speech (or stop_streaming was called).
bool voice_pipeline_is_streaming(void);
// Slice 9 — TTS playback API. Called from the WS layer when the
// voice-bridge announces / streams / closes a `speak_*` exchange.
//
// `voice_pipeline_play_start` reconfigures the I2S TX clock if the
// requested `sample_rate` differs from the current one and enables the
// channel. Transitions the state machine to VOICE_STATE_SPEAKING which
// activates the mute gate (mic input keeps draining I2S but is NOT
// fed to AFE).
//
// `voice_pipeline_play_chunk` writes PCM bytes (16-bit mono LE) to
// the DAC. `len` is in bytes. Blocks up to 100 ms in the I2S DMA
// queue.
//
// `voice_pipeline_play_end` disables the TX channel and returns the
// state machine to VOICE_STATE_IDLE so the next wake can fire.
//
// All three are no-ops (return ESP_ERR_INVALID_STATE) if
// `enable_tts_playback` was false at init.
esp_err_t voice_pipeline_play_start(uint32_t sample_rate, const char *format);
esp_err_t voice_pipeline_play_chunk(const uint8_t *buf, size_t len);
esp_err_t voice_pipeline_play_end(void);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,380 @@
// voice_dispatcher — see voice_dispatcher.h for the slice-8 contract.
#include "voice_dispatcher.h"
#include <ctype.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include "esp_err.h"
#include "esp_log.h"
#include "npc_engine.h"
static const char *TAG = "voice_disp";
// Slice 13 keyword fast-path. Matched as case-insensitive ASCII-folded
// substrings of the normalized STT text. Each entry has a `primary`
// label (used in logs) plus a NULL-terminated `aliases` array of short
// variants that absorb common whisper-large-v3-turbo mistranscriptions
// (e.g. "ait" instead of "aide", "bloké" instead of "bloqué"). Each
// alias must already be ASCII-folded — normalize_for_match() runs on
// the input only. Lookup is linear (≤ ~30 short tokens total) and runs
// once per final transcript, so cost is negligible vs. the strstr()
// fallback we already paid before.
//
// Rule of thumb when adding aliases: keep them short (≤ 8 chars), keep
// them post-fold (all lowercase, no diacritics), and keep them as a
// strict superset of the previous slice-8/11 list — anything that
// matched before MUST still match.
typedef struct {
const char *primary; // canonical, surfaced in logs
const char *const *aliases; // NULL-terminated, includes primary
} keyword_entry_t;
// HINT keywords. Order = scan order. First hit wins (logged).
static const char *const kAliasIndice[] = {"indice", "indices", "endis", "andis", NULL};
static const char *const kAliasAide[] = {"aide", "aidez", "aidemoi", "ait", NULL};
static const char *const kAliasHint[] = {"hint", "hints", "ant", NULL};
static const char *const kAliasBloque[] = {"bloque", "bloke", "bloquai", "blok", "coince", NULL};
static const char *const kAliasPerdu[] = {"perdu", "perdue", "perds", NULL};
static const char *const kAliasCommentFaire[] = {"comment faire", "comment fair", "commencer", NULL};
static const char *const kAliasSaisPas[] = {"sais pas", "sais pa", "saispas", NULL};
static const keyword_entry_t kHintKeywords[] = {
{"indice", kAliasIndice},
{"aide", kAliasAide},
{"hint", kAliasHint},
{"bloque", kAliasBloque},
{"perdu", kAliasPerdu},
{"comment faire", kAliasCommentFaire},
{"sais pas", kAliasSaisPas},
{NULL, NULL},
};
// Slice 11 (P5) failure-signal keywords. These hints map onto the
// hints engine's /attempt_failed lifecycle endpoint (best-effort,
// only updates the failure counter for adaptive escalation). The
// dispatcher fires this BEFORE the hint fast-path so a single
// "non c'est faux, donne-moi un indice" both bumps the counter and
// requests a hint.
static const char *const kAliasNon[] = {"non", "nan", "nope", NULL};
static const char *const kAliasFaux[] = {"faux", "fausse", "fau", NULL};
static const char *const kAliasMauvais[] = {"mauvais", "mauvaise", "movais", NULL};
static const char *const kAliasRate[] = {"rate", "rater", "loupe", NULL};
static const char *const kAliasMarchePas[] = {"marche pas", "marchepas", "march pas", NULL};
static const char *const kAliasCaMarchePas[] = {"ca marche pas", "ca march pas", "ca marchepas", NULL};
static const keyword_entry_t kFailKeywords[] = {
{"non", kAliasNon},
{"faux", kAliasFaux},
{"mauvais", kAliasMauvais},
{"rate", kAliasRate},
{"marche pas", kAliasMarchePas},
{"ca marche pas", kAliasCaMarchePas},
{NULL, NULL},
};
// level == 0 = let the hints engine pick the escalation level via its
// adaptive policy. See specs/AI_INTEGRATION_SPEC.md.
#define DISPATCHER_DEFAULT_HINT_LEVEL 0
static bool s_initialized = false;
// ── ASCII-fold helpers ──────────────────────────────────────────────
//
// We receive UTF-8 from the bridge. The set of characters we care about
// for FR keyword matching is small; instead of pulling in a full
// Unicode lib we hand-fold the common diacritics we expect to see in
// transcribed French. Anything we don't recognise either passes through
// untouched (ASCII) or gets dropped (multi-byte unknowns).
//
// Mapping table (hex = first/second UTF-8 byte):
// é è ê ë → e (C3 A9 / C3 A8 / C3 AA / C3 AB)
// à â ä → a (C3 A0 / C3 A2 / C3 A4)
// î ï → i (C3 AE / C3 AF)
// ô ö → o (C3 B4 / C3 B6)
// ù û ü → u (C3 B9 / C3 BB / C3 BC)
// ç → c (C3 A7)
// œ → oe (C5 93)
// É È Ê Ë → e (uppercase versions, second byte 0x88..0x8B)
// À Â Ä → a (second byte 0x80 / 0x82 / 0x84)
// Î Ï → i (second byte 0x8E / 0x8F)
// Ô Ö → o (second byte 0x94 / 0x96)
// Ù Û Ü → u (second byte 0x99 / 0x9B / 0x9C)
// Ç → c (second byte 0x87)
//
// Returns the number of source bytes consumed and writes 0..2 bytes to
// `dst`. `*written` is updated. If `dst_remaining < 2` and the fold
// would produce 2 bytes (only "œ"), we drop the char to avoid overflow.
static size_t fold_one_codepoint(const unsigned char *src, size_t src_len,
char *dst, size_t dst_remaining,
size_t *written) {
*written = 0;
if (src_len == 0) return 0;
unsigned char b0 = src[0];
// Pure ASCII fast path.
if (b0 < 0x80) {
if (dst_remaining >= 1) {
dst[0] = (char) tolower((int) b0);
*written = 1;
}
return 1;
}
// Two-byte UTF-8: 110xxxxx 10xxxxxx — anything else (3/4 byte) is
// skipped (we don't expect emoji or non-Latin in FR transcripts).
if ((b0 & 0xE0) == 0xC0 && src_len >= 2) {
unsigned char b1 = src[1];
if (b0 == 0xC3) {
// Latin-1 supplement (most accented FR chars live here).
char folded = '\0';
switch (b1) {
case 0xA9: case 0xA8: case 0xAA: case 0xAB: // éèêë
case 0x89: case 0x88: case 0x8A: case 0x8B: // ÉÈÊË
folded = 'e'; break;
case 0xA0: case 0xA2: case 0xA4: // àâä
case 0x80: case 0x82: case 0x84: // ÀÂÄ
folded = 'a'; break;
case 0xAE: case 0xAF: // îï
case 0x8E: case 0x8F: // ÎÏ
folded = 'i'; break;
case 0xB4: case 0xB6: // ôö
case 0x94: case 0x96: // ÔÖ
folded = 'o'; break;
case 0xB9: case 0xBB: case 0xBC: // ùûü
case 0x99: case 0x9B: case 0x9C: // ÙÛÜ
folded = 'u'; break;
case 0xA7: case 0x87: // çÇ
folded = 'c'; break;
default:
// Unknown C3 codepoint — drop silently.
break;
}
if (folded && dst_remaining >= 1) {
dst[0] = folded;
*written = 1;
}
return 2;
}
if (b0 == 0xC5 && b1 == 0x93) {
// œ → oe (only 2-byte fold; needs 2 dst bytes).
if (dst_remaining >= 2) {
dst[0] = 'o';
dst[1] = 'e';
*written = 2;
}
return 2;
}
// Other 2-byte sequences: drop quietly.
return 2;
}
// 3-byte (1110xxxx) — skip 3.
if ((b0 & 0xF0) == 0xE0 && src_len >= 3) {
return 3;
}
// 4-byte (11110xxx) — skip 4.
if ((b0 & 0xF8) == 0xF0 && src_len >= 4) {
return 4;
}
// Malformed continuation byte or truncated sequence — skip 1.
return 1;
}
static void normalize_for_match(const char *src, char *dst, size_t cap) {
if (!dst || cap == 0) return;
dst[0] = '\0';
if (!src) return;
const unsigned char *u = (const unsigned char *) src;
size_t src_len = strlen(src);
size_t out = 0;
while (src_len > 0 && out + 1 < cap) { // reserve 1 for NUL
size_t written = 0;
size_t consumed = fold_one_codepoint(
u, src_len, dst + out, cap - 1 - out, &written);
if (consumed == 0) break;
out += written;
u += consumed;
src_len -= consumed;
}
dst[out] = '\0';
}
// Iterate every alias of every entry; first hit wins. Returns the
// matching entry's primary label and the matched alias for logging.
static bool contains_any_alias(const char *normalized,
const keyword_entry_t *table,
const char **primary_out,
const char **alias_out) {
if (!normalized || !*normalized || !table) return false;
for (const keyword_entry_t *e = table; e->primary != NULL; ++e) {
if (!e->aliases) continue;
for (const char *const *a = e->aliases; *a != NULL; ++a) {
if (**a == '\0') continue;
if (strstr(normalized, *a) != NULL) {
if (primary_out) *primary_out = e->primary;
if (alias_out) *alias_out = *a;
return true;
}
}
}
return false;
}
static bool contains_keyword(const char *normalized) {
const char *primary = NULL;
const char *alias = NULL;
if (contains_any_alias(normalized, kHintKeywords, &primary, &alias)) {
ESP_LOGI(TAG, "hint keyword hit: primary=\"%s\" alias=\"%s\"",
primary, alias);
return true;
}
return false;
}
static bool contains_failure_signal(const char *normalized) {
const char *primary = NULL;
const char *alias = NULL;
if (contains_any_alias(normalized, kFailKeywords, &primary, &alias)) {
ESP_LOGI(TAG, "failure keyword hit: primary=\"%s\" alias=\"%s\"",
primary, alias);
return true;
}
return false;
}
static size_t count_entries(const keyword_entry_t *table) {
size_t n = 0;
if (!table) return 0;
for (const keyword_entry_t *e = table; e->primary != NULL; ++e) ++n;
return n;
}
// ── Hint result callback ────────────────────────────────────────────
//
// Slice 8 only logs the answer. TTS playback (sending the text back to
// the bridge for synthesis, or playing a pre-baked MP3) lands in slice 9.
static void on_hint_response(uint8_t puzzle_id, uint8_t level,
esp_err_t status, const char *text,
void *user_ctx) {
(void) user_ctx;
if (status != ESP_OK) {
ESP_LOGW(TAG, "HINT failed (puzzle=%u level=%u): %s",
(unsigned) puzzle_id, (unsigned) level,
esp_err_to_name(status));
return;
}
ESP_LOGI(TAG, "HINT received (puzzle=%u level=%u): %s",
(unsigned) puzzle_id, (unsigned) level,
text ? text : "(empty)");
}
// ── Public API ──────────────────────────────────────────────────────
esp_err_t voice_dispatcher_init(void) {
if (s_initialized) return ESP_OK;
s_initialized = true;
ESP_LOGI(TAG, "voice_dispatcher ready (%u hint / %u fail FR keywords)",
(unsigned) count_entries(kHintKeywords),
(unsigned) count_entries(kFailKeywords));
return ESP_OK;
}
void voice_dispatcher_handle_stt(const char *text, bool final) {
if (!s_initialized) {
ESP_LOGW(TAG, "stt before init: dropping");
return;
}
if (!text || !*text) return;
if (!final) {
// Interim transcript. Slice 8 only acts on finals.
ESP_LOGD(TAG, "stt interim: \"%s\"", text);
return;
}
// Normalize once into a stack buffer; matching uses substr scan.
// 256 bytes covers ~120 chars of folded FR comfortably (NPC turns
// are short by design).
char folded[256];
normalize_for_match(text, folded, sizeof(folded));
ESP_LOGI(TAG, "stt final raw=\"%s\" folded=\"%s\"", text, folded);
// Slice 11 (P5): bump the failure counter on a clear "non/faux/..."
// signal BEFORE the hint fast-path, so the hints engine's adaptive
// policy sees the incremented count when picking the level. This is
// a coarse heuristic — wiring real input validators (puzzle engines
// reporting wrong codes / wrong gestures) is the proper hook.
// TODO(slice-12): replace the keyword heuristic with explicit input
// validation hooks from the scenario / puzzle engines.
if (contains_failure_signal(folded)) {
const npc_state_t *st = npc_engine_state();
uint8_t scene = st ? st->current_scene : 0xFF;
esp_err_t fa_err = npc_engine_report_failed_attempt(scene);
if (fa_err != ESP_OK) {
ESP_LOGD(TAG, "report_failed_attempt: %s",
esp_err_to_name(fa_err));
}
}
if (!contains_keyword(folded)) {
ESP_LOGI(TAG, "no keyword match, deferring to LLM intent path");
return;
}
// Slice 11 (P5): resolve the active puzzle id from npc_engine
// (e.g. "SCENE_LA_DETECTOR") so the hints engine can pick a
// contextual answer. Empty/unknown scenes fall back to "SCENE_NPC"
// (handled inside npc_engine_current_puzzle_id).
char puzzle_id[64] = {0};
npc_engine_current_puzzle_id(puzzle_id, sizeof(puzzle_id));
// npc_engine_request_hint takes a numeric puzzle id; passing the
// current scene index keeps that layer in sync with the string id
// we just resolved (npc_engine maps both back to kSceneIds[]).
const npc_state_t *st = npc_engine_state();
uint8_t puzzle_num = st ? st->current_scene : 0;
esp_err_t err = npc_engine_request_hint(
puzzle_num,
DISPATCHER_DEFAULT_HINT_LEVEL,
on_hint_response,
NULL);
if (err != ESP_OK) {
ESP_LOGW(TAG, "npc_engine_request_hint: %s", esp_err_to_name(err));
} else {
ESP_LOGI(TAG, "hint request dispatched (puzzle=\"%s\" num=%u level=%u)",
puzzle_id, (unsigned) puzzle_num,
(unsigned) DISPATCHER_DEFAULT_HINT_LEVEL);
}
}
void voice_dispatcher_handle_intent(const char *text, const char *model) {
if (!s_initialized) {
ESP_LOGW(TAG, "intent before init: dropping");
return;
}
ESP_LOGI(TAG, "INTENT received (model=%s): %s",
model && *model ? model : "?",
text && *text ? text : "(empty)");
// Best-effort acknowledgement cue. The cue id is a convention —
// when no entry exists in the npc_engine cue table, the engine
// tries the id as a literal media path; failures are logged but
// non-fatal.
esp_err_t err = npc_engine_trigger_cue("intent_ack");
if (err != ESP_OK && err != ESP_ERR_NOT_FOUND) {
ESP_LOGD(TAG, "intent_ack cue: %s", esp_err_to_name(err));
}
}
@@ -0,0 +1,663 @@
// voice_pipeline — ESP-IDF implementation. See voice_pipeline.h for the
// scope of slices 5, 6 and 7. The AFE / WakeNet integration is gated on
// `cfg.enable_wake_word`; if init fails (PSRAM exhausted, model
// partition absent, etc.) we log + degrade silently to the slice-5
// I2S-only capture path so the rest of the firmware still boots.
#include "voice_pipeline.h"
#include "voice_pipeline_ws.h"
#include <string.h>
#include "driver/i2s_std.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_mac.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_afe_config.h"
#include "esp_afe_sr_iface.h"
#include "esp_afe_sr_models.h"
#include "esp_wn_iface.h"
#include "esp_wn_models.h"
#include "model_path.h"
static const char *TAG = "voice_pipeline";
// Slice 6 placeholder wake word. Standard Espressif WakeNet9 model
// shipped under permissive license — no commercial agreement required.
// Custom "Professeur Zacus" model lands later (see voice spec P4).
#define VOICE_DEFAULT_WAKE_WORD_NAME "wn9_hiesp"
// Partition holding srmodels.bin (added in partitions.csv as a 1 MB
// SPIFFS region). Must match the partition `Name` column.
#define VOICE_SR_MODEL_PARTITION "model"
#define CAPTURE_TASK_STACK 8192
#define CAPTURE_TASK_PRIO 5
#define CAPTURE_CHUNK_BYTES 1024 // fallback (slice-5 path) — 16-bit @16 kHz = 32 ms slice
// Slice 7: end-of-utterance detection. AFE feed chunk size at 16 kHz /
// AFE_MODE_LOW_COST is typically 512 samples = 32 ms. 50 consecutive
// silence chunks ≈ 1.5 s of silence, which matches the spec's
// "sustained ~1.5 s" end-of-speech criterion. A safety cap stops a
// runaway stream after ~10 s of audio so a stuck VAD never blocks the
// pipeline forever.
#define END_SILENCE_CHUNKS 50
#define STREAMING_MAX_CHUNKS (16000 * 10 / 512) // ~10 s
static struct {
bool ready;
voice_pipeline_config_t cfg;
i2s_chan_handle_t rx_chan;
// Slice 9: I2S TX channel for TTS playback (MAX98357A DAC). NULL
// if `enable_tts_playback == false` or alloc/init failed.
i2s_chan_handle_t tx_chan;
bool tx_enabled; // channel currently enabled
uint32_t tx_sample_rate; // current configured rate
voice_state_t state;
TaskHandle_t capture_task;
bool capture_run;
// Wake-word callback (set independently of init, may be NULL).
voice_wake_callback_t wake_cb;
void *wake_cb_ctx;
// ESP-SR handles. NULL if wake-word not enabled or alloc failed —
// capture_task uses the dumb I2S read path in that case.
srmodel_list_t *sr_models;
const esp_afe_sr_iface_t *afe_iface;
esp_afe_sr_data_t *afe_data;
char wake_word_name[32];
int afe_feed_chunk_samples; // per-channel
int afe_feed_channel_num; // mic + ref
int afe_fetch_chunk_samples; // post-AFE, what we stream
// Slice 7: streaming state. `stream_active` mirrors voice_ws_is_streaming
// but is owned by the capture task so we don't race with the WS event
// loop on transitions. `silence_chunks` counts sustained AFE_VAD_SILENCE
// fetches; reaching VAD_SILENCE_CHUNKS_TO_END closes the upload.
bool stream_active;
uint32_t silence_chunks;
uint32_t streamed_chunks;
char session_id[32];
} s_pipe = {
.state = VOICE_STATE_IDLE,
};
void voice_pipeline_default_config(voice_pipeline_config_t *out) {
if (!out) return;
out->i2s_bclk_pin = 14; // GPIO14 -> SCK
out->i2s_ws_pin = 15; // GPIO15 -> WS
out->i2s_din_pin = 22; // GPIO22 -> SD (INMP441)
out->sample_rate_hz = 16000;
out->auto_start_capture = false;
out->enable_wake_word = false;
out->voice_bridge_ws_url = NULL;
// Slice 9: TTS playback defaults — disabled. Pinout follows the
// suggested Freenove ESP32-S3 convention: BCLK=11, LRC=12, DIN=13.
// Confirm at flash time before driving the DAC.
out->enable_tts_playback = false;
out->i2s_out_bclk_pin = 11;
out->i2s_out_lrc_pin = 12;
out->i2s_out_din_pin = 13;
}
bool voice_pipeline_wake_word_active(void) {
return (s_pipe.afe_iface != NULL && s_pipe.afe_data != NULL);
}
esp_err_t voice_pipeline_set_wake_callback(voice_wake_callback_t cb,
void *user_ctx) {
s_pipe.wake_cb = cb;
s_pipe.wake_cb_ctx = user_ctx;
return ESP_OK;
}
esp_err_t voice_pipeline_set_stt_callback(voice_stt_callback_t cb,
void *user_ctx) {
voice_ws_set_stt_callback(cb, user_ctx);
return ESP_OK;
}
bool voice_pipeline_is_streaming(void) {
return s_pipe.stream_active;
}
static void session_id_init(void) {
uint8_t mac[6] = {0};
if (esp_efuse_mac_get_default(mac) == ESP_OK) {
snprintf(s_pipe.session_id, sizeof(s_pipe.session_id),
"%02x%02x%02x%02x%02x%02x",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
} else {
strncpy(s_pipe.session_id, "unknown",
sizeof(s_pipe.session_id) - 1);
s_pipe.session_id[sizeof(s_pipe.session_id) - 1] = '\0';
}
}
// Open the WebSocket lazily on the first wake (or on
// voice_pipeline_start_streaming). Returns ESP_OK on success or if the
// stream is already open.
static esp_err_t streaming_begin(void) {
if (s_pipe.stream_active) return ESP_OK;
if (!voice_ws_is_configured()) return ESP_ERR_INVALID_STATE;
esp_err_t err = voice_ws_open_streaming();
if (err != ESP_OK) {
ESP_LOGW(TAG, "voice_ws_open_streaming failed: %s",
esp_err_to_name(err));
return err;
}
s_pipe.stream_active = true;
s_pipe.silence_chunks = 0;
s_pipe.streamed_chunks = 0;
ESP_LOGI(TAG, "streaming started");
return ESP_OK;
}
static void streaming_end(const char *reason) {
if (!s_pipe.stream_active) return;
ESP_LOGI(TAG, "streaming end (%s) — sent %u chunks, silence=%u",
reason ? reason : "?",
(unsigned) s_pipe.streamed_chunks,
(unsigned) s_pipe.silence_chunks);
voice_ws_close_streaming();
s_pipe.stream_active = false;
s_pipe.silence_chunks = 0;
s_pipe.streamed_chunks = 0;
voice_pipeline_set_state(VOICE_STATE_IDLE);
}
esp_err_t voice_pipeline_start_streaming(void) {
if (!s_pipe.ready) return ESP_ERR_INVALID_STATE;
if (!voice_ws_is_configured()) {
ESP_LOGW(TAG, "start_streaming: no voice_bridge_ws_url configured");
return ESP_ERR_INVALID_STATE;
}
voice_pipeline_set_state(VOICE_STATE_LISTENING);
return streaming_begin();
}
esp_err_t voice_pipeline_stop_streaming(void) {
streaming_end("manual_stop");
return ESP_OK;
}
static esp_err_t i2s_setup(void) {
i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
esp_err_t err = i2s_new_channel(&chan_cfg, NULL, &s_pipe.rx_chan);
if (err != ESP_OK) return err;
i2s_std_config_t std_cfg = {
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(s_pipe.cfg.sample_rate_hz),
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT,
I2S_SLOT_MODE_MONO),
.gpio_cfg = {
.mclk = I2S_GPIO_UNUSED,
.bclk = s_pipe.cfg.i2s_bclk_pin,
.ws = s_pipe.cfg.i2s_ws_pin,
.dout = I2S_GPIO_UNUSED,
.din = s_pipe.cfg.i2s_din_pin,
.invert_flags = {
.mclk_inv = false,
.bclk_inv = false,
.ws_inv = false,
},
},
};
err = i2s_channel_init_std_mode(s_pipe.rx_chan, &std_cfg);
if (err != ESP_OK) {
i2s_del_channel(s_pipe.rx_chan);
s_pipe.rx_chan = NULL;
return err;
}
return ESP_OK;
}
// Slice 9: bring up the I2S TX channel for TTS playback on a separate
// I2S port (I2S_NUM_1) so the mic capture on I2S_NUM_0 keeps running
// untouched. Configures Philips std mode, mono, 16-bit, at the
// pipeline's default sample rate (typically 16 kHz). The actual TTS
// rate may differ (Piper f5_tts ≈ 24 kHz) — voice_pipeline_play_start
// reconfigures the clock on the fly when needed.
static esp_err_t i2s_tx_setup(void) {
i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_1, I2S_ROLE_MASTER);
esp_err_t err = i2s_new_channel(&chan_cfg, &s_pipe.tx_chan, NULL);
if (err != ESP_OK) return err;
// Initial clock = same as mic; will be reconfigured at play_start
// if the bridge announces a different rate.
s_pipe.tx_sample_rate = s_pipe.cfg.sample_rate_hz;
i2s_std_config_t std_cfg = {
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(s_pipe.tx_sample_rate),
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT,
I2S_SLOT_MODE_MONO),
.gpio_cfg = {
.mclk = I2S_GPIO_UNUSED,
.bclk = s_pipe.cfg.i2s_out_bclk_pin,
.ws = s_pipe.cfg.i2s_out_lrc_pin,
.dout = s_pipe.cfg.i2s_out_din_pin,
.din = I2S_GPIO_UNUSED,
.invert_flags = {
.mclk_inv = false,
.bclk_inv = false,
.ws_inv = false,
},
},
};
err = i2s_channel_init_std_mode(s_pipe.tx_chan, &std_cfg);
if (err != ESP_OK) {
i2s_del_channel(s_pipe.tx_chan);
s_pipe.tx_chan = NULL;
return err;
}
s_pipe.tx_enabled = false;
return ESP_OK;
}
// Bring up esp-sr AFE + WakeNet. Returns ESP_OK on success. On failure
// the caller logs and falls back to the dumb I2S capture path.
static esp_err_t wake_word_setup(void) {
s_pipe.sr_models = esp_srmodel_init(VOICE_SR_MODEL_PARTITION);
if (!s_pipe.sr_models || s_pipe.sr_models->num <= 0) {
ESP_LOGW(TAG, "esp_srmodel_init('%s') returned no models — "
"wake word disabled (check srmodels.bin flashed)",
VOICE_SR_MODEL_PARTITION);
return ESP_ERR_NOT_FOUND;
}
ESP_LOGI(TAG, "esp-sr loaded %d model(s) from partition '%s'",
s_pipe.sr_models->num, VOICE_SR_MODEL_PARTITION);
for (int i = 0; i < s_pipe.sr_models->num; i++) {
ESP_LOGI(TAG, " model[%d] = %s", i, s_pipe.sr_models->model_name[i]);
}
char *wn_name = esp_srmodel_filter(s_pipe.sr_models, ESP_WN_PREFIX, NULL);
if (!wn_name) {
ESP_LOGW(TAG, "no WakeNet model found in partition — wake disabled");
return ESP_ERR_NOT_FOUND;
}
strncpy(s_pipe.wake_word_name, wn_name, sizeof(s_pipe.wake_word_name) - 1);
s_pipe.wake_word_name[sizeof(s_pipe.wake_word_name) - 1] = '\0';
ESP_LOGI(TAG, "selected wake model = %s (placeholder, slice 6)",
s_pipe.wake_word_name);
// Single mic, no reference channel: input format "M" (one micropohne).
afe_config_t *afe_cfg = afe_config_init("M", s_pipe.sr_models,
AFE_TYPE_SR, AFE_MODE_LOW_COST);
if (!afe_cfg) {
ESP_LOGW(TAG, "afe_config_init returned NULL");
return ESP_FAIL;
}
// Force the wake model we just discovered, in case the default
// selection logic picks something we don't want.
afe_cfg->wakenet_init = true;
afe_cfg->wakenet_model_name = s_pipe.wake_word_name;
// 1-mic SR: AEC needs a reference channel we don't have, SE (BSS)
// needs >= 2 mics. Disable both, keep NS + VAD + AGC.
afe_cfg->aec_init = false;
afe_cfg->se_init = false;
afe_cfg->memory_alloc_mode = AFE_MEMORY_ALLOC_MORE_PSRAM;
s_pipe.afe_iface = esp_afe_handle_from_config(afe_cfg);
if (!s_pipe.afe_iface) {
ESP_LOGW(TAG, "esp_afe_handle_from_config returned NULL");
afe_config_free(afe_cfg);
return ESP_FAIL;
}
s_pipe.afe_data = s_pipe.afe_iface->create_from_config(afe_cfg);
afe_config_free(afe_cfg);
if (!s_pipe.afe_data) {
ESP_LOGW(TAG, "AFE create_from_config failed (likely OOM in PSRAM)");
s_pipe.afe_iface = NULL;
return ESP_ERR_NO_MEM;
}
s_pipe.afe_feed_chunk_samples = s_pipe.afe_iface->get_feed_chunksize(s_pipe.afe_data);
s_pipe.afe_feed_channel_num = s_pipe.afe_iface->get_feed_channel_num(s_pipe.afe_data);
s_pipe.afe_fetch_chunk_samples = s_pipe.afe_iface->get_fetch_chunksize(s_pipe.afe_data);
ESP_LOGI(TAG, "AFE up: feed_chunk=%d samples × %d ch, fetch_chunk=%d, sample_rate=%d Hz",
s_pipe.afe_feed_chunk_samples,
s_pipe.afe_feed_channel_num,
s_pipe.afe_fetch_chunk_samples,
s_pipe.afe_iface->get_samp_rate(s_pipe.afe_data));
if (s_pipe.afe_iface->print_pipeline) {
s_pipe.afe_iface->print_pipeline(s_pipe.afe_data);
}
return ESP_OK;
}
static void wake_word_teardown(void) {
if (s_pipe.afe_data && s_pipe.afe_iface && s_pipe.afe_iface->destroy) {
s_pipe.afe_iface->destroy(s_pipe.afe_data);
}
s_pipe.afe_data = NULL;
s_pipe.afe_iface = NULL;
if (s_pipe.sr_models) {
esp_srmodel_deinit(s_pipe.sr_models);
s_pipe.sr_models = NULL;
}
}
// Capture task. Two modes:
// * AFE active (esp-sr loaded) : feed I2S into AFE, fetch results,
// detect wake → fire callback,
// stream post-AFE PCM until VAD silence.
// * AFE inactive (slice-5 stub) : log a heartbeat every ~1.6 s.
static void capture_task(void *pv) {
if (voice_pipeline_wake_word_active()) {
const int chunk_samples = s_pipe.afe_feed_chunk_samples;
const int chunk_channels = s_pipe.afe_feed_channel_num;
const size_t feed_bytes = (size_t) chunk_samples * chunk_channels * sizeof(int16_t);
int16_t *feed_buf = heap_caps_malloc(feed_bytes,
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!feed_buf) {
ESP_LOGE(TAG, "PSRAM alloc for AFE feed buffer failed (%u B) — "
"stopping capture", (unsigned) feed_bytes);
s_pipe.capture_task = NULL;
vTaskDelete(NULL);
return;
}
ESP_LOGI(TAG, "capture_task: AFE mode — chunk=%d samples × %d ch (%u B)",
chunk_samples, chunk_channels, (unsigned) feed_bytes);
size_t bytes_read = 0;
uint32_t feeds = 0;
while (s_pipe.capture_run) {
// INMP441 mono I2S: read directly into the feed buffer (1 ch).
// If chunk_channels > 1 (e.g. with reference), this would need
// interleaving — slice-6 single-mic path keeps it simple.
esp_err_t err = i2s_channel_read(s_pipe.rx_chan, feed_buf,
feed_bytes, &bytes_read,
pdMS_TO_TICKS(200));
if (err == ESP_ERR_TIMEOUT) {
continue;
}
if (err != ESP_OK) {
ESP_LOGW(TAG, "i2s read err: %s", esp_err_to_name(err));
vTaskDelay(pdMS_TO_TICKS(200));
continue;
}
if (bytes_read == 0) continue;
// Slice 9: mute-during-TTS gate. While the pipeline is in
// SPEAKING state we keep draining I2S so the DMA buffers
// don't overflow, but we do NOT feed AFE — this prevents
// the speaker output (which leaks back into the mic) from
// re-triggering the wake word during a TTS reply.
if (s_pipe.state == VOICE_STATE_SPEAKING) continue;
s_pipe.afe_iface->feed(s_pipe.afe_data, feed_buf);
// Drain anything available without blocking the feed cadence.
afe_fetch_result_t *res = s_pipe.afe_iface->fetch_with_delay(
s_pipe.afe_data, 0);
if (res && res->ret_value == ESP_OK) {
if (res->wakeup_state == WAKENET_DETECTED) {
const char *word = s_pipe.wake_word_name;
ESP_LOGI(TAG, "WAKE detected: word=%s vol=%.1fdB chan=%d",
word, res->data_volume, res->trigger_channel_id);
voice_pipeline_set_state(VOICE_STATE_LISTENING);
if (s_pipe.wake_cb) {
s_pipe.wake_cb(word, s_pipe.wake_cb_ctx);
}
// Slice 7: open the WS stream as soon as the wake
// fires so the player's first words make it across.
if (voice_ws_is_configured() && !s_pipe.stream_active) {
if (streaming_begin() != ESP_OK) {
ESP_LOGW(TAG, "streaming_begin failed at wake — "
"returning to IDLE");
voice_pipeline_set_state(VOICE_STATE_IDLE);
}
}
}
// Slice 7: while streaming, push the post-AFE PCM out
// and watch the VAD for end-of-speech. `res->data` is
// the cleaned, single-channel int16 buffer of length
// `afe_fetch_chunk_samples`.
if (s_pipe.stream_active && res->data && res->data_size > 0) {
esp_err_t serr = voice_ws_send_chunk(
res->data, res->data_size / sizeof(int16_t));
if (serr != ESP_OK) {
ESP_LOGW(TAG, "send_chunk err %s — closing stream",
esp_err_to_name(serr));
streaming_end("send_error");
} else {
s_pipe.streamed_chunks++;
// res->vad_state is a `vad_state_t` (VAD_SILENCE = 0,
// VAD_SPEECH = 1). The legacy `AFE_VAD_*` enum is
// marked deprecated in esp_afe_sr_iface.h.
if (res->vad_state == VAD_SILENCE) {
s_pipe.silence_chunks++;
} else {
s_pipe.silence_chunks = 0;
}
if (s_pipe.silence_chunks >= END_SILENCE_CHUNKS) {
streaming_end("vad_silence");
} else if (s_pipe.streamed_chunks >= STREAMING_MAX_CHUNKS) {
streaming_end("max_duration");
}
}
}
}
if (++feeds % 100 == 0) {
ESP_LOGD(TAG, "AFE feed heartbeat: %u chunks", (unsigned) feeds);
}
}
// Make sure we don't leak an open WS if capture is being torn down.
if (s_pipe.stream_active) {
streaming_end("capture_stop");
}
free(feed_buf);
} else {
// Slice-5 fallback: dumb capture, no detection.
static uint8_t buf[CAPTURE_CHUNK_BYTES];
size_t bytes_read = 0;
uint32_t total = 0;
uint32_t ticks = 0;
ESP_LOGI(TAG, "capture_task: stub mode (no esp-sr)");
while (s_pipe.capture_run) {
esp_err_t err = i2s_channel_read(s_pipe.rx_chan, buf, sizeof(buf),
&bytes_read, pdMS_TO_TICKS(100));
if (err == ESP_OK) {
total += bytes_read;
if (++ticks % 50 == 0) {
ESP_LOGI(TAG, "capture heartbeat: %u bytes total",
(unsigned) total);
}
} else if (err != ESP_ERR_TIMEOUT) {
ESP_LOGW(TAG, "i2s read err: %s", esp_err_to_name(err));
vTaskDelay(pdMS_TO_TICKS(200));
}
}
}
s_pipe.capture_task = NULL;
vTaskDelete(NULL);
}
esp_err_t voice_pipeline_init(const voice_pipeline_config_t *config) {
if (s_pipe.ready) return ESP_OK;
voice_pipeline_config_t cfg;
if (config) {
cfg = *config;
} else {
voice_pipeline_default_config(&cfg);
}
s_pipe.cfg = cfg;
session_id_init();
esp_err_t err = i2s_setup();
if (err != ESP_OK) {
ESP_LOGW(TAG, "i2s setup failed: %s — staying idle without capture",
esp_err_to_name(err));
// Don't fail init: we still want the state machine to be usable so
// the rest of the system (npc_engine, REST surface) can wire calls.
s_pipe.ready = true;
s_pipe.state = VOICE_STATE_IDLE;
return ESP_OK;
}
// Slice 9: optional I2S TX bring-up for TTS playback. Failure is
// non-fatal — the rest of the voice loop still works.
if (cfg.enable_tts_playback) {
esp_err_t te = i2s_tx_setup();
if (te != ESP_OK) {
ESP_LOGW(TAG, "i2s_tx_setup failed: %s — TTS playback disabled",
esp_err_to_name(te));
} else {
ESP_LOGI(TAG, "I2S TX ready (BCLK=%d LRC=%d DIN=%d) — TTS enabled",
cfg.i2s_out_bclk_pin, cfg.i2s_out_lrc_pin,
cfg.i2s_out_din_pin);
}
}
if (cfg.enable_wake_word) {
esp_err_t we = wake_word_setup();
if (we != ESP_OK) {
ESP_LOGW(TAG, "wake_word_setup failed: %s — degrading to stub capture",
esp_err_to_name(we));
// Don't fail init — the rest of the firmware should still
// come up. The capture task will run in slice-5 stub mode.
wake_word_teardown();
}
}
// Slice 7: configure the WS layer if the caller provided a URL.
// The actual connection is opened lazily on the first wake (or
// on voice_pipeline_start_streaming).
if (cfg.voice_bridge_ws_url && *cfg.voice_bridge_ws_url) {
esp_err_t wsc = voice_ws_configure(cfg.voice_bridge_ws_url,
s_pipe.session_id,
cfg.sample_rate_hz);
if (wsc != ESP_OK) {
ESP_LOGW(TAG, "voice_ws_configure failed: %s — streaming disabled",
esp_err_to_name(wsc));
} else {
ESP_LOGI(TAG, "voice-bridge streaming wired: %s",
cfg.voice_bridge_ws_url);
}
}
s_pipe.ready = true;
s_pipe.state = VOICE_STATE_IDLE;
ESP_LOGI(TAG, "ready (BCLK=%d WS=%d DIN=%d @%u Hz, wake=%s, stream=%s)",
cfg.i2s_bclk_pin, cfg.i2s_ws_pin, cfg.i2s_din_pin,
(unsigned) cfg.sample_rate_hz,
voice_pipeline_wake_word_active() ? s_pipe.wake_word_name : "off",
voice_ws_is_configured() ? "on" : "off");
if (cfg.auto_start_capture) {
return voice_pipeline_start_capture();
}
return ESP_OK;
}
esp_err_t voice_pipeline_start_capture(void) {
if (!s_pipe.ready) return ESP_ERR_INVALID_STATE;
if (!s_pipe.rx_chan) return ESP_ERR_INVALID_STATE;
if (s_pipe.capture_task) return ESP_OK; // already running
esp_err_t err = i2s_channel_enable(s_pipe.rx_chan);
if (err != ESP_OK) return err;
s_pipe.capture_run = true;
if (xTaskCreate(capture_task, "voice_capture", CAPTURE_TASK_STACK, NULL,
CAPTURE_TASK_PRIO, &s_pipe.capture_task) != pdPASS) {
s_pipe.capture_run = false;
i2s_channel_disable(s_pipe.rx_chan);
return ESP_ERR_NO_MEM;
}
voice_pipeline_set_state(VOICE_STATE_LISTENING);
return ESP_OK;
}
esp_err_t voice_pipeline_stop_capture(void) {
if (!s_pipe.ready) return ESP_ERR_INVALID_STATE;
if (!s_pipe.capture_task) return ESP_OK;
s_pipe.capture_run = false;
// Task observes the flag and self-deletes on its next tick.
if (s_pipe.rx_chan) {
i2s_channel_disable(s_pipe.rx_chan);
}
voice_pipeline_set_state(VOICE_STATE_IDLE);
return ESP_OK;
}
voice_state_t voice_pipeline_get_state(void) {
return s_pipe.state;
}
esp_err_t voice_pipeline_set_state(voice_state_t state) {
s_pipe.state = state;
return ESP_OK;
}
// ── Slice 9: TTS playback over I2S TX (I2S_NUM_1) ────────────────────────────
esp_err_t voice_pipeline_play_start(uint32_t sample_rate, const char *format) {
if (!s_pipe.ready) return ESP_ERR_INVALID_STATE;
if (!s_pipe.tx_chan) {
ESP_LOGW(TAG, "play_start: no TX channel (enable_tts_playback=false?)");
return ESP_ERR_INVALID_STATE;
}
// Reconfigure the I2S clock if the bridge announces a different rate.
// F5-TTS produces 24 kHz; the mic side runs at 16 kHz by default.
if (sample_rate != 0 && sample_rate != s_pipe.tx_sample_rate) {
if (s_pipe.tx_enabled) {
i2s_channel_disable(s_pipe.tx_chan);
s_pipe.tx_enabled = false;
}
i2s_std_clk_config_t clk = I2S_STD_CLK_DEFAULT_CONFIG(sample_rate);
esp_err_t err = i2s_channel_reconfig_std_clock(s_pipe.tx_chan, &clk);
if (err != ESP_OK) {
ESP_LOGW(TAG, "play_start: clk reconfig %u Hz failed: %s",
(unsigned) sample_rate, esp_err_to_name(err));
return err;
}
s_pipe.tx_sample_rate = sample_rate;
}
if (!s_pipe.tx_enabled) {
esp_err_t err = i2s_channel_enable(s_pipe.tx_chan);
if (err != ESP_OK) return err;
s_pipe.tx_enabled = true;
}
voice_pipeline_set_state(VOICE_STATE_SPEAKING);
ESP_LOGI(TAG, "play_start: sr=%u format=%s",
(unsigned) sample_rate, format ? format : "(null)");
return ESP_OK;
}
esp_err_t voice_pipeline_play_chunk(const uint8_t *buf, size_t len) {
if (!s_pipe.ready || !s_pipe.tx_chan || !s_pipe.tx_enabled) {
return ESP_ERR_INVALID_STATE;
}
if (!buf || len == 0) return ESP_ERR_INVALID_ARG;
size_t written = 0;
esp_err_t err = i2s_channel_write(s_pipe.tx_chan, buf, len, &written,
pdMS_TO_TICKS(100));
if (err != ESP_OK) {
ESP_LOGW(TAG, "play_chunk: i2s write err=%s wrote=%u/%u",
esp_err_to_name(err), (unsigned) written, (unsigned) len);
}
return err;
}
esp_err_t voice_pipeline_play_end(void) {
if (!s_pipe.ready) return ESP_ERR_INVALID_STATE;
if (s_pipe.tx_chan && s_pipe.tx_enabled) {
i2s_channel_disable(s_pipe.tx_chan);
s_pipe.tx_enabled = false;
}
voice_pipeline_set_state(VOICE_STATE_IDLE);
ESP_LOGI(TAG, "play_end");
return ESP_OK;
}
@@ -0,0 +1,348 @@
// voice_pipeline_ws — WebSocket streaming layer for the Zacus voice
// pipeline. Talks to the MacStudio voice-bridge:
//
// client → server (text, on connect):
// {"type":"hello","version":1,"sample_rate":16000,
// "format":"pcm_s16","session_id":"<mac>"}
//
// 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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cJSON.h"
#include "esp_log.h"
#include "esp_websocket_client.h"
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
// Slice 8: route final STT + LLM intent payloads into npc_engine via
// the small dispatcher layer. The user-supplied stt_cb still fires in
// parallel for back-compat (main.c keeps logging it).
#include "voice_dispatcher.h"
#include "voice_pipeline.h"
static const char *TAG = "voice_ws";
#define WS_CONNECT_TIMEOUT_MS 5000
#define WS_NETWORK_TIMEOUT_MS 5000
#define WS_HELLO_BUF_LEN 192
#define WS_RX_BUFFER_BYTES 1024
#define WS_TASK_STACK_BYTES 6144
// Event group bits for the connect handshake.
#define WS_BIT_CONNECTED BIT0
#define WS_BIT_FAILED BIT1
static struct {
bool configured;
char url[160];
char session_id[32];
uint32_t sample_rate_hz;
voice_stt_callback_t stt_cb;
void *stt_cb_ctx;
esp_websocket_client_handle_t client;
EventGroupHandle_t ev;
bool streaming;
// Slice 9b: track whether the bridge announced a speak_* sequence.
// We forward incoming binary frames to voice_pipeline_play_chunk()
// only between speak_start and speak_end.
bool in_speak;
} s_ws = {0};
static void handle_text_message(const char *data, int len) {
cJSON *root = cJSON_ParseWithLength(data, (size_t) len);
if (!root) {
ESP_LOGW(TAG, "rx: malformed JSON (%d bytes): %.*s",
len, len > 64 ? 64 : len, data);
return;
}
const cJSON *type = cJSON_GetObjectItemCaseSensitive(root, "type");
if (!cJSON_IsString(type) || !type->valuestring) {
cJSON_Delete(root);
return;
}
if (strcmp(type->valuestring, "stt") == 0) {
const cJSON *text = cJSON_GetObjectItemCaseSensitive(root, "text");
const cJSON *final = cJSON_GetObjectItemCaseSensitive(root, "final");
bool is_final = cJSON_IsBool(final) ? cJSON_IsTrue(final) : false;
if (cJSON_IsString(text) && text->valuestring) {
ESP_LOGI(TAG, "stt(final=%d): %s", is_final, text->valuestring);
// Slice 8: keyword fast-path → npc_engine_request_hint().
// The legacy user callback still fires below for callers
// that want their own logging / instrumentation.
voice_dispatcher_handle_stt(text->valuestring, is_final);
if (s_ws.stt_cb) {
s_ws.stt_cb(text->valuestring, is_final, s_ws.stt_cb_ctx);
}
}
} else if (strcmp(type->valuestring, "intent") == 0) {
const cJSON *content = cJSON_GetObjectItemCaseSensitive(root, "content");
const cJSON *model = cJSON_GetObjectItemCaseSensitive(root, "model");
const char *content_str = cJSON_IsString(content) ? content->valuestring : NULL;
const char *model_str = cJSON_IsString(model) ? model->valuestring : NULL;
ESP_LOGI(TAG, "intent (model=%s): %s",
model_str ? model_str : "?",
content_str ? content_str : "?");
// Slice 8: hand off to the dispatcher (logs + best-effort cue).
voice_dispatcher_handle_intent(content_str, model_str);
} else if (strcmp(type->valuestring, "speak_start") == 0) {
// Slice 9b: bridge is about to stream PCM TTS reply. Lift the
// playback gate, configure the I2S TX clock to the announced
// sample rate (typically 24 kHz from F5-TTS).
const cJSON *sr = cJSON_GetObjectItemCaseSensitive(root, "sample_rate");
const cJSON *fmt = cJSON_GetObjectItemCaseSensitive(root, "format");
uint32_t rate = (cJSON_IsNumber(sr) && sr->valueint > 0)
? (uint32_t) sr->valueint : 24000;
const char *fmt_s = (cJSON_IsString(fmt) && fmt->valuestring)
? fmt->valuestring : "pcm_s16";
ESP_LOGI(TAG, "speak_start: sr=%u fmt=%s", (unsigned) rate, fmt_s);
esp_err_t pe = voice_pipeline_play_start(rate, fmt_s);
if (pe == ESP_OK) {
s_ws.in_speak = true;
} else {
ESP_LOGW(TAG, "speak_start: voice_pipeline_play_start=%s — "
"TTS playback unavailable, dropping audio frames",
esp_err_to_name(pe));
s_ws.in_speak = false;
}
} else if (strcmp(type->valuestring, "speak_end") == 0) {
const cJSON *dur = cJSON_GetObjectItemCaseSensitive(root, "duration_ms");
const cJSON *backend = cJSON_GetObjectItemCaseSensitive(root, "backend");
const cJSON *lat = cJSON_GetObjectItemCaseSensitive(root, "latency_ms");
ESP_LOGI(TAG, "speak_end: duration=%dms backend=%s first_chunk_lat=%dms",
cJSON_IsNumber(dur) ? dur->valueint : 0,
(cJSON_IsString(backend) && backend->valuestring) ? backend->valuestring : "?",
cJSON_IsNumber(lat) ? lat->valueint : 0);
if (s_ws.in_speak) {
voice_pipeline_play_end();
s_ws.in_speak = false;
}
} else if (strcmp(type->valuestring, "error") == 0) {
const cJSON *msg = cJSON_GetObjectItemCaseSensitive(root, "message");
ESP_LOGW(TAG, "bridge error: %s",
cJSON_IsString(msg) ? msg->valuestring : "(no message)");
// If an error arrives mid-speak, close the playback gate cleanly.
if (s_ws.in_speak) {
voice_pipeline_play_end();
s_ws.in_speak = false;
}
} else {
ESP_LOGD(TAG, "rx: unknown type=%s", type->valuestring);
}
cJSON_Delete(root);
}
static void ws_event_handler(void *handler_args, esp_event_base_t base,
int32_t event_id, void *event_data) {
(void) handler_args;
(void) base;
esp_websocket_event_data_t *data = (esp_websocket_event_data_t *) event_data;
switch (event_id) {
case WEBSOCKET_EVENT_CONNECTED:
ESP_LOGI(TAG, "ws connected to %s", s_ws.url);
if (s_ws.ev) xEventGroupSetBits(s_ws.ev, WS_BIT_CONNECTED);
break;
case WEBSOCKET_EVENT_DISCONNECTED:
ESP_LOGW(TAG, "ws disconnected");
// Don't auto-reconnect this slice — capture task is the only
// producer and it'll just notice via voice_ws_is_streaming().
s_ws.streaming = false;
break;
case WEBSOCKET_EVENT_DATA:
if (!data) break;
// op_code 0x1 = text frame, 0x2 = binary, 0x8 = close, 0x9/0xA = ping/pong.
if (data->op_code == 0x01 && data->data_len > 0) {
handle_text_message((const char *) data->data_ptr, data->data_len);
} else if (data->op_code == 0x02 && data->data_len > 0
&& s_ws.in_speak) {
// Slice 9b: PCM TTS chunk from the bridge. Forward to I2S TX.
voice_pipeline_play_chunk((const uint8_t *) data->data_ptr,
(size_t) data->data_len);
}
break;
case WEBSOCKET_EVENT_ERROR:
ESP_LOGW(TAG, "ws error event");
if (s_ws.ev) xEventGroupSetBits(s_ws.ev, WS_BIT_FAILED);
break;
default:
break;
}
}
esp_err_t voice_ws_configure(const char *url,
const char *session_id,
uint32_t sample_rate_hz) {
if (!url || !*url) return ESP_ERR_INVALID_ARG;
if (strlen(url) >= sizeof(s_ws.url)) return ESP_ERR_INVALID_SIZE;
strncpy(s_ws.url, url, sizeof(s_ws.url) - 1);
s_ws.url[sizeof(s_ws.url) - 1] = '\0';
if (session_id && *session_id) {
strncpy(s_ws.session_id, session_id, sizeof(s_ws.session_id) - 1);
} else {
strncpy(s_ws.session_id, "unknown", sizeof(s_ws.session_id) - 1);
}
s_ws.session_id[sizeof(s_ws.session_id) - 1] = '\0';
s_ws.sample_rate_hz = sample_rate_hz ? sample_rate_hz : 16000;
s_ws.configured = true;
ESP_LOGI(TAG, "configured url=%s session=%s sr=%u",
s_ws.url, s_ws.session_id, (unsigned) s_ws.sample_rate_hz);
return ESP_OK;
}
void voice_ws_set_stt_callback(voice_stt_callback_t cb, void *user_ctx) {
s_ws.stt_cb = cb;
s_ws.stt_cb_ctx = user_ctx;
}
bool voice_ws_is_configured(void) {
return s_ws.configured;
}
bool voice_ws_is_streaming(void) {
return s_ws.streaming;
}
static esp_err_t send_hello(void) {
char buf[WS_HELLO_BUF_LEN];
int n = snprintf(buf, sizeof(buf),
"{\"type\":\"hello\",\"version\":1,"
"\"sample_rate\":%u,\"format\":\"pcm_s16\","
"\"session_id\":\"%s\"}",
(unsigned) s_ws.sample_rate_hz,
s_ws.session_id);
if (n <= 0 || n >= (int) sizeof(buf)) return ESP_ERR_INVALID_SIZE;
int sent = esp_websocket_client_send_text(s_ws.client, buf, n,
pdMS_TO_TICKS(2000));
if (sent < 0) {
ESP_LOGW(TAG, "hello send failed (rc=%d)", sent);
return ESP_FAIL;
}
ESP_LOGI(TAG, "hello sent: %s", buf);
return ESP_OK;
}
esp_err_t voice_ws_open_streaming(void) {
if (!s_ws.configured) return ESP_ERR_INVALID_STATE;
if (s_ws.streaming) return ESP_OK;
if (!s_ws.ev) {
s_ws.ev = xEventGroupCreate();
if (!s_ws.ev) return ESP_ERR_NO_MEM;
}
xEventGroupClearBits(s_ws.ev, WS_BIT_CONNECTED | WS_BIT_FAILED);
if (!s_ws.client) {
const esp_websocket_client_config_t cfg = {
.uri = s_ws.url,
.reconnect_timeout_ms = WS_NETWORK_TIMEOUT_MS,
.network_timeout_ms = WS_NETWORK_TIMEOUT_MS,
.buffer_size = WS_RX_BUFFER_BYTES,
.task_stack = WS_TASK_STACK_BYTES,
.disable_auto_reconnect = true,
};
s_ws.client = esp_websocket_client_init(&cfg);
if (!s_ws.client) {
ESP_LOGE(TAG, "esp_websocket_client_init failed");
return ESP_FAIL;
}
esp_err_t reg = esp_websocket_register_events(
s_ws.client, WEBSOCKET_EVENT_ANY, ws_event_handler, NULL);
if (reg != ESP_OK) {
ESP_LOGE(TAG, "register_events: %s", esp_err_to_name(reg));
esp_websocket_client_destroy(s_ws.client);
s_ws.client = NULL;
return reg;
}
}
esp_err_t err = esp_websocket_client_start(s_ws.client);
if (err != ESP_OK) {
ESP_LOGE(TAG, "ws_client_start: %s", esp_err_to_name(err));
return err;
}
EventBits_t bits = xEventGroupWaitBits(
s_ws.ev, WS_BIT_CONNECTED | WS_BIT_FAILED,
pdFALSE, pdFALSE, pdMS_TO_TICKS(WS_CONNECT_TIMEOUT_MS));
if (!(bits & WS_BIT_CONNECTED)) {
ESP_LOGW(TAG, "ws connect timeout (%d ms) — closing",
WS_CONNECT_TIMEOUT_MS);
esp_websocket_client_stop(s_ws.client);
return ESP_ERR_TIMEOUT;
}
s_ws.streaming = true;
esp_err_t hello_err = send_hello();
if (hello_err != ESP_OK) {
ESP_LOGW(TAG, "hello failed (%s) — tearing down",
esp_err_to_name(hello_err));
s_ws.streaming = false;
esp_websocket_client_stop(s_ws.client);
return hello_err;
}
return ESP_OK;
}
esp_err_t voice_ws_send_chunk(const int16_t *pcm, size_t samples) {
if (!s_ws.streaming || !s_ws.client) return ESP_ERR_INVALID_STATE;
if (!pcm || samples == 0) return ESP_ERR_INVALID_ARG;
const size_t bytes = samples * sizeof(int16_t);
int sent = esp_websocket_client_send_bin(
s_ws.client, (const char *) pcm, (int) bytes, pdMS_TO_TICKS(500));
if (sent < 0) {
ESP_LOGW(TAG, "send_bin failed (rc=%d, %u B)", sent, (unsigned) bytes);
return ESP_FAIL;
}
return ESP_OK;
}
esp_err_t voice_ws_close_streaming(void) {
if (!s_ws.client) return ESP_OK;
if (s_ws.streaming) {
const char *end_msg = "{\"type\":\"end\"}";
int sent = esp_websocket_client_send_text(
s_ws.client, end_msg, (int) strlen(end_msg),
pdMS_TO_TICKS(2000));
if (sent < 0) {
ESP_LOGW(TAG, "end send failed (rc=%d) — closing anyway", sent);
} else {
ESP_LOGI(TAG, "end frame sent");
}
}
s_ws.streaming = false;
// Keep the handle for a clean teardown but stop the inner task.
esp_websocket_client_stop(s_ws.client);
esp_websocket_client_destroy(s_ws.client);
s_ws.client = NULL;
return ESP_OK;
}
@@ -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 <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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
+23
View File
@@ -0,0 +1,23 @@
idf_component_register(
SRCS
"main.c"
INCLUDE_DIRS
"."
REQUIRES
joltwallet__littlefs
ota_server
media_manager
npc_engine
hints_client
voice_pipeline
voice_hook_endpoint
game_endpoint
scenario_mesh
nvs_flash
esp_timer
esp_system
esp_wifi
esp_netif
esp_event
espressif__mdns
)
+21
View File
@@ -0,0 +1,21 @@
## Managed component manifest for the Zacus master `main` component.
## P1 first slice — keep deps minimal; voice / NPC / vision deps come later.
dependencies:
joltwallet/littlefs: "^1.14"
## Slice 6: ESP-SR (AFE + WakeNet9) for the voice_pipeline component.
## Pinned to the 2.x line — 2.0+ is the IDF 5.x-friendly API. The
## active wake model is selected via sdkconfig (CONFIG_SR_WN_WN9_HIESP).
## A custom "Professeur Zacus" model is out of scope for this slice
## (P4 of the voice spec, requires Espressif training round-trip).
espressif/esp-sr: "~2.0"
## Slice 7: WebSocket client used by voice_pipeline to stream PCM
## chunks to the MacStudio voice-bridge (`ws://studio:8200/voice/ws`)
## and receive STT transcripts. The 1.4 line is the current stable
## series for IDF 5.x (event loop based, supports binary frames).
espressif/esp_websocket_client: "~1.4"
## Slice 12: mDNS so PLIP and the dashboard can discover the master
## as `zacus-master.local` instead of relying on a DHCP reservation
## or a manual IP probe. 1.6 is the current stable line for IDF 5.x
## (TXT records + service browsing supported).
espressif/mdns: "~1.6"
+598
View File
@@ -0,0 +1,598 @@
// Zacus master — ESP-IDF entry point (P1 slice 2).
//
// Responsibilities at this slice:
// 1. Initialize NVS (required by Wi-Fi).
// 2. Initialize esp_netif + default event loop.
// 3. Read Wi-Fi creds from NVS namespace "wifi" (keys "ssid" / "pwd").
// - If creds present : start STA, wait for IP_EVENT_STA_GOT_IP.
// - If creds absent : fall back to open AP "zacus-setup" so the
// operator can still reach the OTA endpoint
// (and provision creds in a later slice).
// 4. Once the network is up, call ota_server_init() so the inherited
// HTTP server (port 80) starts answering /version, /status, /ota.
// 5. Mount the LittleFS "storage" partition on /littlefs and list it.
// 6. Log heap stats + idle loop with periodic heartbeat (60 s).
//
// Subsequent slices port the NPC engine, voice pipeline, media manager, etc.
// See docs/superpowers/specs/2026-05-03-voice-pipeline-esp-sr-design.md.
#include <stdio.h>
#include <string.h>
#include <dirent.h>
#include <sys/stat.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/event_groups.h"
#include "esp_log.h"
#include "esp_system.h"
#include "esp_heap_caps.h"
#include "esp_err.h"
#include "esp_event.h"
#include "esp_netif.h"
#include "esp_wifi.h"
#include "esp_littlefs.h"
#include "nvs_flash.h"
#include "nvs.h"
#include "mdns.h"
#include "ota_server.h"
#include "media_manager.h"
#include "npc_engine.h"
#include "hints_client.h"
#include "voice_pipeline.h"
#include "voice_dispatcher.h"
#include "voice_hook_endpoint.h"
#include "game_endpoint.h"
#include "scenario_mesh.h"
// Hints engine endpoint (slice 5). Hardcoded for now — slice 7 will move
// this to NVS so the field operator can repoint the firmware without a flash.
#define ZACUS_HINTS_BASE_URL "http://192.168.0.150:8302"
// Slice 7: voice-bridge WebSocket on the MacStudio (Tailscale address).
// Hardcoded here for the same reason as ZACUS_HINTS_BASE_URL — moves to
// NVS in a follow-up slice. The bridge endpoint is documented in
// docs/superpowers/specs/2026-05-03-voice-pipeline-esp-sr-design.md.
#define ZACUS_VOICE_BRIDGE_WS_URL "ws://100.116.92.12:8200/voice/ws"
static const char *TAG = "zacus_main";
// Soft-AP fallback when no creds in NVS yet.
#define ZACUS_FALLBACK_AP_SSID "zacus-setup"
#define ZACUS_FALLBACK_AP_CHAN 6
#define ZACUS_STA_MAX_RETRY 8
// ─── Wi-Fi state ─────────────────────────────────────────────────────────────
static EventGroupHandle_t s_wifi_event_group;
#define WIFI_CONNECTED_BIT BIT0
#define WIFI_FAIL_BIT BIT1
static int s_sta_retry = 0;
// ─── ota_server externs (real implementations come with the puzzle/master
// state in a later P1 slice; for now we provide trivial stubs so the
// component links cleanly). ─────────────────────────────────────────────
int puzzle_get_battery_pct(void) {
return 100;
}
int puzzle_get_espnow_peer_count(void) {
return 0;
}
// Slice 6: wake-word callback. Runs on the voice_pipeline capture task,
// keep it short. The pipeline already auto-transitioned to LISTENING
// before invoking us; here we just log + ensure capture is running so
// downstream STT (slice 7) has audio to consume.
static void on_voice_wake(const char *wake_word, void *user_ctx) {
(void) user_ctx;
ESP_LOGI(TAG, "WAKE: \"%s\" detected, transitioning to LISTENING",
wake_word ? wake_word : "(null)");
// Capture is already running (esp-sr feeds it), but if a future
// slice toggles it off between wakes, this keeps us robust.
esp_err_t err = voice_pipeline_start_capture();
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
ESP_LOGW(TAG, "voice_pipeline_start_capture from wake cb: %s",
esp_err_to_name(err));
}
}
// Slice 7/8: STT callback. Runs on the WebSocket event-loop task —
// keep it short. The actual routing (keyword fast-path → hints engine,
// non-keyword → defer to LLM intent path) is owned by voice_dispatcher,
// which voice_pipeline_ws calls in parallel with this user callback.
// We keep the log here as a diagnostic breadcrumb for field debugging.
static void on_voice_stt(const char *text, bool final, void *user_ctx) {
(void) user_ctx;
ESP_LOGI(TAG, "STT(final=%d): %s", final ? 1 : 0,
text ? text : "(null)");
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
static void log_heap_stats(const char *phase) {
ESP_LOGI(TAG, "[heap @ %s] free=%u internal=%u psram=%u",
phase,
(unsigned) esp_get_free_heap_size(),
(unsigned) esp_get_free_internal_heap_size(),
(unsigned) heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
}
static esp_err_t mount_littlefs(void) {
const esp_vfs_littlefs_conf_t conf = {
.base_path = "/littlefs",
.partition_label = "storage",
.format_if_mount_failed = true,
.dont_mount = false,
};
esp_err_t err = esp_vfs_littlefs_register(&conf);
if (err != ESP_OK) {
ESP_LOGE(TAG, "LittleFS mount failed: %s", esp_err_to_name(err));
return err;
}
size_t total = 0, used = 0;
if (esp_littlefs_info(conf.partition_label, &total, &used) == ESP_OK) {
ESP_LOGI(TAG, "LittleFS mounted at %s — %u / %u bytes used",
conf.base_path, (unsigned) used, (unsigned) total);
}
return ESP_OK;
}
static void list_littlefs_root(void) {
DIR *dir = opendir("/littlefs");
if (!dir) {
ESP_LOGW(TAG, "opendir(/littlefs) failed");
return;
}
struct dirent *ent;
int count = 0;
while ((ent = readdir(dir)) != NULL) {
ESP_LOGI(TAG, " /littlefs/%s (type=%d)", ent->d_name, ent->d_type);
count++;
}
closedir(dir);
ESP_LOGI(TAG, "LittleFS root contains %d entries", count);
}
// ─── ESP-NOW relay peer registry seed ────────────────────────────────────────
//
// The /game/scenario/relay endpoint resolves a peer alias (e.g. "box3",
// "plip", "p7_coffre") to a MAC via the scenario_mesh registry. Seed it from
// NVS namespace "peers": one entry per alias whose value is the 6-byte MAC
// blob (set with `idf.py nvs-partition-gen` or from the dashboard). This keeps
// MAC addresses out of the firmware image and lets the GM repoint a satellite
// board without a reflash.
//
// Provisioning example (nvs CSV):
// key,type,encoding,value
// peers,namespace,,
// box3,data,base64,<base64 of the 6 MAC bytes>
static void seed_relay_peers_from_nvs(void) {
nvs_iterator_t it = NULL;
esp_err_t err = nvs_entry_find("nvs", "peers", NVS_TYPE_BLOB, &it);
if (err != ESP_OK) {
ESP_LOGI(TAG, "no NVS 'peers' namespace — relay registry empty "
"(set MACs to enable /game/scenario/relay)");
return;
}
nvs_handle_t h;
if (nvs_open("peers", NVS_READONLY, &h) != ESP_OK) {
nvs_release_iterator(it);
return;
}
int seeded = 0;
while (err == ESP_OK && it != NULL) {
nvs_entry_info_t info;
nvs_entry_info(it, &info);
uint8_t mac[6];
size_t len = sizeof(mac);
if (nvs_get_blob(h, info.key, mac, &len) == ESP_OK && len == 6) {
if (scenario_mesh_register_peer(info.key, mac) == ESP_OK) {
seeded++;
}
} else {
ESP_LOGW(TAG, "peers/%s: not a 6-byte MAC blob — skipped",
info.key);
}
err = nvs_entry_next(&it);
}
nvs_release_iterator(it);
nvs_close(h);
ESP_LOGI(TAG, "relay peer registry seeded: %d peer(s)", seeded);
}
// ─── Wi-Fi: NVS creds + event handler ────────────────────────────────────────
// Reads NVS namespace "wifi" keys "ssid" + "pwd". Returns ESP_OK if both
// keys are present and ssid is non-empty. Buffers are NUL-terminated.
static esp_err_t load_wifi_creds(char *ssid, size_t ssid_len,
char *pwd, size_t pwd_len) {
nvs_handle_t h;
esp_err_t err = nvs_open("wifi", NVS_READONLY, &h);
if (err != ESP_OK) {
ESP_LOGI(TAG, "NVS namespace 'wifi' not found (%s)", esp_err_to_name(err));
return err;
}
size_t len = ssid_len;
err = nvs_get_str(h, "ssid", ssid, &len);
if (err != ESP_OK || len <= 1) {
ESP_LOGI(TAG, "NVS 'wifi/ssid' missing (%s)", esp_err_to_name(err));
nvs_close(h);
return ESP_ERR_NOT_FOUND;
}
len = pwd_len;
err = nvs_get_str(h, "pwd", pwd, &len);
if (err != ESP_OK) {
// Empty password is acceptable (open network).
pwd[0] = '\0';
}
nvs_close(h);
return ESP_OK;
}
static void wifi_event_handler(void *arg, esp_event_base_t base,
int32_t id, void *data) {
if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) {
ESP_LOGI(TAG, "STA start — connecting…");
esp_wifi_connect();
} else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
if (s_sta_retry < ZACUS_STA_MAX_RETRY) {
s_sta_retry++;
ESP_LOGW(TAG, "STA disconnected — retry %d/%d", s_sta_retry, ZACUS_STA_MAX_RETRY);
esp_wifi_connect();
} else {
ESP_LOGE(TAG, "STA give up after %d retries", ZACUS_STA_MAX_RETRY);
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
}
} else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t *event = (ip_event_got_ip_t *) data;
ESP_LOGI(TAG, "STA got IP " IPSTR, IP2STR(&event->ip_info.ip));
s_sta_retry = 0;
xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
} else if (base == WIFI_EVENT && id == WIFI_EVENT_AP_START) {
ESP_LOGI(TAG, "AP started — SSID=\"%s\" (open)", ZACUS_FALLBACK_AP_SSID);
} else if (base == WIFI_EVENT && id == WIFI_EVENT_AP_STACONNECTED) {
ESP_LOGI(TAG, "AP: client joined");
}
}
// Returns true if STA connected, false if AP fallback (or STA gave up).
static bool wifi_bring_up(void) {
s_wifi_event_group = xEventGroupCreate();
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
ESP_ERROR_CHECK(esp_event_handler_instance_register(
WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL, NULL));
ESP_ERROR_CHECK(esp_event_handler_instance_register(
IP_EVENT, IP_EVENT_STA_GOT_IP, &wifi_event_handler, NULL, NULL));
char ssid[33] = {0};
char pwd[65] = {0};
bool have_creds = (load_wifi_creds(ssid, sizeof(ssid), pwd, sizeof(pwd)) == ESP_OK);
if (have_creds) {
ESP_LOGI(TAG, "Wi-Fi: STA mode (ssid=\"%s\")", ssid);
esp_netif_create_default_wifi_sta();
wifi_config_t wc = {0};
strncpy((char *) wc.sta.ssid, ssid, sizeof(wc.sta.ssid) - 1);
strncpy((char *) wc.sta.password, pwd, sizeof(wc.sta.password) - 1);
wc.sta.threshold.authmode = WIFI_AUTH_OPEN; // accept any; we don't pin
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wc));
ESP_ERROR_CHECK(esp_wifi_start());
EventBits_t bits = xEventGroupWaitBits(
s_wifi_event_group,
WIFI_CONNECTED_BIT | WIFI_FAIL_BIT,
pdFALSE, pdFALSE,
pdMS_TO_TICKS(20000));
if (bits & WIFI_CONNECTED_BIT) {
return true;
}
ESP_LOGW(TAG, "STA failed within 20 s — fallback to AP");
esp_wifi_stop();
} else {
ESP_LOGI(TAG, "Wi-Fi: no creds in NVS, starting AP fallback");
}
// AP fallback (open network — provisioning will be added later).
esp_netif_create_default_wifi_ap();
wifi_config_t ap_cfg = {0};
strncpy((char *) ap_cfg.ap.ssid, ZACUS_FALLBACK_AP_SSID, sizeof(ap_cfg.ap.ssid) - 1);
ap_cfg.ap.ssid_len = strlen(ZACUS_FALLBACK_AP_SSID);
ap_cfg.ap.channel = ZACUS_FALLBACK_AP_CHAN;
ap_cfg.ap.max_connection = 4;
ap_cfg.ap.authmode = WIFI_AUTH_OPEN;
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &ap_cfg));
ESP_ERROR_CHECK(esp_wifi_start());
return false;
}
// ─── mDNS bring-up (slice 12) ────────────────────────────────────────────────
//
// Publishes `zacus-master.local` once Wi-Fi STA is up so PLIP and the
// dashboard can discover the master without a DHCP reservation. We
// also advertise a `_zacus._tcp` service on port 80 with TXT records
// pointing at the voice-hook URI — useful for `dns-sd -B _zacus._tcp`
// style introspection from the workshop laptop.
//
// In AP-fallback mode we deliberately skip mDNS: there is no upstream
// resolver to claim the hostname against, and several tooling stacks
// (avahi, bonjour) trip over a duplicate-name race when the AP later
// goes back to STA. The PLIP fallback in that scenario is the static
// AP IP (192.168.4.1).
static void start_mdns(void) {
esp_err_t err = mdns_init();
if (err != ESP_OK) {
ESP_LOGW(TAG, "mdns_init failed: %s", esp_err_to_name(err));
return;
}
err = mdns_hostname_set("zacus-master");
if (err != ESP_OK) {
ESP_LOGW(TAG, "mdns_hostname_set: %s", esp_err_to_name(err));
// Continue — the daemon is up, just no hostname claim.
}
err = mdns_instance_name_set("Zacus Master ESP32-S3");
if (err != ESP_OK) {
ESP_LOGW(TAG, "mdns_instance_name_set: %s", esp_err_to_name(err));
}
// Advertise the master HTTP surface as a `_zacus._tcp` service on
// port 80. The TXT records let PLIP firmware confirm it found the
// right device + which voice-hook path to POST to (so a future
// protocol bump can be discovered without reflashing PLIP).
err = mdns_service_add(NULL, "_zacus", "_tcp", 80, NULL, 0);
if (err != ESP_OK) {
ESP_LOGW(TAG, "mdns_service_add(_zacus._tcp:80): %s",
esp_err_to_name(err));
return;
}
(void) mdns_service_instance_name_set("_zacus", "_tcp",
"Zacus Master Voice Hook");
mdns_txt_item_t txt[] = {
{"path", "/voice/hook"},
{"version", "1"},
};
err = mdns_service_txt_set("_zacus", "_tcp", txt,
sizeof(txt) / sizeof(txt[0]));
if (err != ESP_OK) {
ESP_LOGW(TAG, "mdns_service_txt_set: %s", esp_err_to_name(err));
}
ESP_LOGI(TAG, "mDNS up — hostname=zacus-master.local, "
"service=_zacus._tcp:80");
}
// ─── app_main ────────────────────────────────────────────────────────────────
void app_main(void) {
ESP_LOGI(TAG, "Zacus master booting (ESP-IDF scaffold, P1 slice 2)");
log_heap_stats("boot");
esp_err_t nvs_err = nvs_flash_init();
if (nvs_err == ESP_ERR_NVS_NO_FREE_PAGES || nvs_err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
nvs_err = nvs_flash_init();
}
ESP_ERROR_CHECK(nvs_err);
ESP_LOGI(TAG, "NVS initialized");
bool sta_ok = wifi_bring_up();
ESP_LOGI(TAG, "Wi-Fi up (mode=%s)", sta_ok ? "STA" : "AP-fallback");
// Slice 12: publish zacus-master.local once we're on a real LAN.
// Skip in AP-fallback to avoid hostname-claim races when STA later
// recovers (and because there is no upstream resolver anyway).
if (sta_ok) {
start_mdns();
} else {
ESP_LOGW(TAG, "mDNS not started in AP mode "
"(PLIP must use the AP IP fallback)");
}
esp_err_t ota_err = ota_server_init();
if (ota_err != ESP_OK) {
ESP_LOGE(TAG, "ota_server_init failed: %s", esp_err_to_name(ota_err));
} else {
ESP_LOGI(TAG, "OTA server listening on :%d", OTA_SERVER_PORT);
// Slice 10: piggyback the PLIP /voice/hook endpoint on the same
// esp_http_server instance. Independent of voice_pipeline_init
// success — the handler tolerates a degraded pipeline (the
// voice_pipeline_* APIs return ESP_ERR_INVALID_STATE which we
// log and report as a 200 with whatever state we have).
httpd_handle_t httpd = ota_server_get_handle();
esp_err_t hook_err = voice_hook_endpoint_init(httpd);
if (hook_err != ESP_OK) {
ESP_LOGW(TAG, "voice_hook_endpoint_init: %s",
esp_err_to_name(hook_err));
}
// Slice 12: REST surface for runtime game tuning. Today this
// exposes /game/group_profile (GET + POST) so the dashboard /
// GM can swap the hints policy without reflashing NVS. The
// POST handler validates via hints_client_set_group_profile()
// and persists to NVS namespace "zacus" / key "group_profile"
// (the same slot main.c reads at boot).
esp_err_t game_err = game_endpoint_init(httpd);
if (game_err != ESP_OK) {
ESP_LOGW(TAG, "game_endpoint_init: %s",
esp_err_to_name(game_err));
} else {
// game_endpoint_init brought up scenario_mesh (ESP-NOW). Seed the
// relay peer registry from NVS so /game/scenario/relay can resolve
// aliases to MACs without a reflash.
seed_relay_peers_from_nvs();
}
}
if (mount_littlefs() == ESP_OK) {
list_littlefs_root();
// Slice 3: bring up the ported media_manager. Catalog dirs live on
// LittleFS so this must run *after* the mount succeeds.
media_manager_config_t media_cfg;
media_manager_default_config(&media_cfg);
esp_err_t media_err = media_manager_init(&media_cfg);
if (media_err != ESP_OK) {
ESP_LOGE(TAG, "media_manager_init failed: %s",
esp_err_to_name(media_err));
} else {
// Smoke test: try to play /littlefs/intro.mp3. The file is
// unlikely to exist this early — that's fine, the manager
// returns ESP_ERR_NOT_FOUND and logs a warning, no crash.
esp_err_t play_err = media_manager_play("/littlefs/intro.mp3");
ESP_LOGI(TAG, "media smoke play -> %s",
esp_err_to_name(play_err));
// Slice 4: bring up the ported npc_engine. Cue table is empty
// at this stage — wiring the scenario IR-driven cue catalog is
// a follow-up slice. The engine still boots, accepts ticks
// (no-op when auto_evaluate is false), and is ready to receive
// trigger_cue calls from REST/diagnostic surfaces.
const npc_engine_config_t npc_cfg = {
.cues = NULL,
.cue_count = 0,
.auto_evaluate = false,
.auto_play_decisions = false,
};
esp_err_t npc_err = npc_engine_init(&npc_cfg);
if (npc_err != ESP_OK) {
ESP_LOGE(TAG, "npc_engine_init failed: %s",
esp_err_to_name(npc_err));
}
// Slice 5: bring up the hints HTTP client (so npc_engine can
// route hint requests through the real backend) and the voice
// pipeline (I2S capture stub + state machine, no auto-start).
esp_err_t hints_err = hints_client_init(ZACUS_HINTS_BASE_URL);
if (hints_err != ESP_OK) {
ESP_LOGW(TAG, "hints_client_init failed: %s — npc will use stub",
esp_err_to_name(hints_err));
} else {
// Slice 11 (P5): load the group profile from NVS so the
// hints engine can tune answers per audience (TECH /
// NON_TECH / MIXED / BOTH). Default to MIXED when the
// key is absent or holds an unknown value.
// TODO(slice-11): endpoint /game/group_profile to update
// NVS at runtime — for now the value is flashed by the
// dashboard or `idf.py nvs-partition-gen` outputs.
nvs_handle_t gh;
esp_err_t open_err = nvs_open("zacus", NVS_READONLY, &gh);
char profile[HINTS_CLIENT_GROUP_PROFILE_MAX] = "MIXED";
if (open_err == ESP_OK) {
size_t plen = sizeof(profile);
esp_err_t kerr = nvs_get_str(gh, "group_profile",
profile, &plen);
if (kerr != ESP_OK) {
ESP_LOGI(TAG, "NVS zacus/group_profile missing (%s) "
"— defaulting to MIXED",
esp_err_to_name(kerr));
strncpy(profile, "MIXED", sizeof(profile) - 1);
profile[sizeof(profile) - 1] = '\0';
}
nvs_close(gh);
} else {
ESP_LOGI(TAG, "NVS namespace 'zacus' not found (%s) "
"— defaulting group_profile=MIXED",
esp_err_to_name(open_err));
}
esp_err_t set_err = hints_client_set_group_profile(profile);
if (set_err != ESP_OK) {
// Validation rejected the NVS value — force MIXED
// so the engine still has a usable hint policy.
ESP_LOGW(TAG, "group_profile \"%s\" rejected — falling "
"back to MIXED", profile);
(void) hints_client_set_group_profile("MIXED");
}
}
// Slice 8: voice → npc_engine routing layer. Must come
// after npc_engine_init / hints_client_init so the hint
// fast-path lands on the real backend (fallback: local stub).
esp_err_t disp_err = voice_dispatcher_init();
if (disp_err != ESP_OK) {
ESP_LOGW(TAG, "voice_dispatcher_init failed: %s",
esp_err_to_name(disp_err));
}
voice_pipeline_config_t voice_cfg;
voice_pipeline_default_config(&voice_cfg);
// Slice 6: bring up esp-sr AFE + WakeNet (placeholder
// wn9_hiesp). Auto-start capture so the wake detector is
// hot from boot — saying "Hi ESP" fires the callback below.
voice_cfg.enable_wake_word = true;
voice_cfg.auto_start_capture = true;
// Slice 7: stream post-AFE PCM to the MacStudio voice-bridge
// over WebSocket once the wake word fires. The bridge runs
// STT (whisper) and may auto-route to the LLM intent layer.
voice_cfg.voice_bridge_ws_url = ZACUS_VOICE_BRIDGE_WS_URL;
// Slice 9 + 10: enable the I2S TX leg so TTS payloads coming
// back from the bridge land on the MAX98357A DAC. Wake-word
// stays enabled — PLIP hook is the primary path for voice
// sessions, but "hi esp" remains a backup if the phone is
// unplugged or its hook switch fails.
voice_cfg.enable_tts_playback = true;
voice_pipeline_set_wake_callback(on_voice_wake, NULL);
voice_pipeline_set_stt_callback(on_voice_stt, NULL);
esp_err_t voice_err = voice_pipeline_init(&voice_cfg);
if (voice_err != ESP_OK) {
ESP_LOGW(TAG, "voice_pipeline_init failed: %s",
esp_err_to_name(voice_err));
} else if (voice_pipeline_wake_word_active()) {
ESP_LOGI(TAG, "voice: wake-word detector active "
"(placeholder \"hi esp\")");
} else {
ESP_LOGW(TAG, "voice: wake-word inactive — running "
"in slice-5 stub mode");
}
}
}
log_heap_stats("post-init");
// Mark this firmware valid only after subsystems came up cleanly.
ota_server_mark_valid();
ESP_LOGI(TAG, "entering idle loop (heartbeat every 60 s)");
uint32_t tick = 0;
for (;;) {
vTaskDelay(pdMS_TO_TICKS(60000));
tick++;
const uint32_t uptime_ms = (uint32_t) esp_log_timestamp();
ESP_LOGI(TAG, "heartbeat #%u — uptime=%llu s",
(unsigned) tick,
(unsigned long long) (uptime_ms / 1000));
// Drive the slice-3/4 subsystems from the heartbeat. Once we have
// a real game loop these will move to a dedicated FreeRTOS task
// running at ~5 Hz; for now 60 s is enough to keep mood + media
// simulation state coherent without spamming the log.
media_manager_update(uptime_ms);
npc_engine_update(uptime_ms);
}
}
+25
View File
@@ -0,0 +1,25 @@
# Zacus master partition table — merges ota_server's OTA layout (factory +
# ota_0 + ota_1 + otadata) with a dedicated LittleFS partition for assets
# (NPC phrases, scenario IR cache, …) and a SPIFFS region for esp-sr model
# blobs (`model`, slice 6 — flashed automatically as srmodels.bin by the
# esp-sr component CMake glue).
#
# Total target: 16 MB flash (Freenove ESP32-S3 N16R8).
# Layout (rough): 0x000000-0x020000 system, 0x020000-0x620000 apps (3×2 MB),
# 0x620000-0x720000 model (SPIFFS 1 MB, esp-sr models),
# 0x720000-0xC20000 storage (LittleFS 5 MB),
# 0xC20000-0x1000000 unused (~3.87 MB headroom).
#
# App partition bumped 1.5 MB → 2 MB (2026-05-03) to give voice/STT/hints
# slices and esp-sr integration ~25 % free space margin. OTA dual-bank
# preserved (factory + ota_0 + ota_1 all 2 MB → rollback symmetry intact).
#
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
otadata, data, ota, 0xf000, 0x2000,
phy_init, data, phy, 0x11000, 0x1000,
factory, app, factory, 0x20000, 0x200000,
ota_0, app, ota_0, , 0x200000,
ota_1, app, ota_1, , 0x200000,
model, data, spiffs, , 0x100000,
storage, data, littlefs,, 0x500000,
1 # Zacus master partition table — merges ota_server's OTA layout (factory +
2 # ota_0 + ota_1 + otadata) with a dedicated LittleFS partition for assets
3 # (NPC phrases, scenario IR cache, …) and a SPIFFS region for esp-sr model
4 # blobs (`model`, slice 6 — flashed automatically as srmodels.bin by the
5 # esp-sr component CMake glue).
6 #
7 # Total target: 16 MB flash (Freenove ESP32-S3 N16R8).
8 # Layout (rough): 0x000000-0x020000 system, 0x020000-0x620000 apps (3×2 MB),
9 # 0x620000-0x720000 model (SPIFFS 1 MB, esp-sr models),
10 # 0x720000-0xC20000 storage (LittleFS 5 MB),
11 # 0xC20000-0x1000000 unused (~3.87 MB headroom).
12 #
13 # App partition bumped 1.5 MB → 2 MB (2026-05-03) to give voice/STT/hints
14 # slices and esp-sr integration ~25 % free space margin. OTA dual-bank
15 # preserved (factory + ota_0 + ota_1 all 2 MB → rollback symmetry intact).
16 #
17 # Name, Type, SubType, Offset, Size, Flags
18 nvs, data, nvs, 0x9000, 0x6000,
19 otadata, data, ota, 0xf000, 0x2000,
20 phy_init, data, phy, 0x11000, 0x1000,
21 factory, app, factory, 0x20000, 0x200000,
22 ota_0, app, ota_0, , 0x200000,
23 ota_1, app, ota_1, , 0x200000,
24 model, data, spiffs, , 0x100000,
25 storage, data, littlefs,, 0x500000,
+60
View File
@@ -0,0 +1,60 @@
# sdkconfig.defaults — Zacus master ESP-IDF scaffold (P1 first slice)
#
# Target: Freenove ESP32-S3 WROOM N16R8 (8 MB Octal PSRAM, 16 MB flash).
# Provides the minimum config to boot, mount LittleFS, and run the inherited
# ota_server component. Voice / NPC / vision configs come in later P1 slices.
# ─── Target ──────────────────────────────────────────────────────────────────
CONFIG_IDF_TARGET="esp32s3"
CONFIG_IDF_TARGET_ESP32S3=y
# ─── Flash ───────────────────────────────────────────────────────────────────
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
CONFIG_ESPTOOLPY_FLASHMODE_QIO=y
CONFIG_ESPTOOLPY_FLASHFREQ_80M=y
# ─── PSRAM (Octal 80 MHz, required for esp-sr buffers in P3+) ────────────────
CONFIG_SPIRAM=y
CONFIG_SPIRAM_MODE_OCT=y
CONFIG_SPIRAM_SPEED_80M=y
CONFIG_SPIRAM_USE_MALLOC=y
CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y
# ─── Partition table (custom, supports OTA + LittleFS dual boot) ─────────────
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
CONFIG_PARTITION_TABLE_FILENAME="partitions.csv"
# ─── HTTP server (used by ota_server, port 80) ───────────────────────────────
CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024
CONFIG_HTTPD_MAX_URI_LEN=512
# ─── Wi-Fi (STA + AP fallback for first-boot provisioning) ───────────────────
CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=10
CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=32
CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=32
CONFIG_ESP_WIFI_AMPDU_RX_ENABLED=y
CONFIG_ESP_WIFI_AMPDU_TX_ENABLED=y
CONFIG_ESP_WIFI_NVS_ENABLED=y
CONFIG_LWIP_LOCAL_HOSTNAME="zacus-master"
# ─── FreeRTOS ────────────────────────────────────────────────────────────────
CONFIG_FREERTOS_HZ=1000
# ─── Logging ─────────────────────────────────────────────────────────────────
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
CONFIG_LOG_COLORS=y
# ─── Bootloader / OTA rollback ───────────────────────────────────────────────
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y
# ─── ESP-SR (slice 6 — voice_pipeline) ───────────────────────────────────────
# Standard placeholder wake-word: "Hi ESP" (wn9_hiesp). Custom Zacus model
# is out of scope here (Espressif training, P4 voice spec). Keep MultiNet
# disabled — STT happens off-device on MacStudio (whisper). Disabling MN
# saves >1 MB of flash + lots of PSRAM at runtime.
CONFIG_SR_WN_WN9_HIESP=y
CONFIG_SR_MN_CN_NONE=y
CONFIG_SR_MN_EN_NONE=y
CONFIG_MODEL_IN_FLASH=y
CONFIG_AFE_INTERFACE_V1=y
+18
View File
@@ -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
+124
View File
@@ -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 <string.h>
#include <stdlib.h>
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,
+24
View File
@@ -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);
@@ -9,7 +9,6 @@ idf_component_register(
freertos
driver
esp_wifi
esp_now
nvs_flash
led_strip
esp_common
-1
View File
@@ -9,7 +9,6 @@ idf_component_register(
freertos
driver
esp_wifi
esp_now
nvs_flash
esp_common
)
@@ -9,7 +9,6 @@ idf_component_register(
freertos
driver
esp_wifi
esp_now
nvs_flash
esp_common
)
-1
View File
@@ -9,7 +9,6 @@ idf_component_register(
freertos
driver
esp_wifi
esp_now
nvs_flash
esp_common
)