feat(idf): ESP-NOW scenario hot-load + IDF migration #1
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
build/
|
||||
managed_components/
|
||||
dependencies.lock
|
||||
sdkconfig
|
||||
sdkconfig.old
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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 ~5–15 ms of
|
||||
flash latency.
|
||||
|
||||
If the hints client validation passes but `nvs_set_str` /
|
||||
`nvs_commit` fails (e.g. NVS partition full), the response is `500`
|
||||
with `status: runtime_only` so the operator can decide whether to
|
||||
keep going or force a reboot.
|
||||
|
||||
## Component dependencies
|
||||
|
||||
```cmake
|
||||
REQUIRES
|
||||
esp_http_server # the shared httpd_handle_t
|
||||
json # cJSON for body parsing
|
||||
nvs_flash # nvs_open / nvs_set_str / nvs_commit
|
||||
hints_client # validation + push to in-RAM state
|
||||
ota_server # supplies httpd_handle via ota_server_get_handle()
|
||||
freertos
|
||||
log
|
||||
```
|
||||
|
||||
Init pattern in `main.c` (slice 12):
|
||||
|
||||
```c
|
||||
esp_err_t ota_err = ota_server_init();
|
||||
if (ota_err == ESP_OK) {
|
||||
httpd_handle_t httpd = ota_server_get_handle();
|
||||
voice_hook_endpoint_init(httpd);
|
||||
game_endpoint_init(httpd);
|
||||
}
|
||||
```
|
||||
@@ -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,
|
||||
|
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ idf_component_register(
|
||||
freertos
|
||||
driver
|
||||
esp_wifi
|
||||
esp_now
|
||||
nvs_flash
|
||||
esp_common
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user