diff --git a/box3_voice/components/scenario_mesh/CMakeLists.txt b/box3_voice/components/scenario_mesh/CMakeLists.txt new file mode 100644 index 0000000..4520f05 --- /dev/null +++ b/box3_voice/components/scenario_mesh/CMakeLists.txt @@ -0,0 +1,17 @@ +## Zacus scenario_mesh — ESP-NOW transport for Runtime 3 IR hot-load. +## +## Tasks 4 & 5 of docs/specs/2026-05-24-firmware-scenario-hotload.md: chunk the +## IR JSON into <=240-byte ESP-NOW frames, send sequentially with per-frame +## ack, and reassemble on the receive side keyed by sender+total before +## invoking the same _scenario_apply() path as the HTTP handler. + +idf_component_register( + SRCS + "scenario_mesh.c" + INCLUDE_DIRS + "include" + REQUIRES + esp_wifi + freertos + log +) diff --git a/box3_voice/components/scenario_mesh/include/scenario_mesh.h b/box3_voice/components/scenario_mesh/include/scenario_mesh.h new file mode 100644 index 0000000..2db86d4 --- /dev/null +++ b/box3_voice/components/scenario_mesh/include/scenario_mesh.h @@ -0,0 +1,86 @@ +// scenario_mesh — ESP-NOW transport for Runtime 3 IR hot-load (Phase 2). +// +// Implements tasks 4 & 5 of docs/specs/2026-05-24-firmware-scenario-hotload.md: +// +// * Frame protocol: the IR JSON blob is chunked into ESP-NOW frames of +// <= SCENARIO_MESH_FRAME_MAX (240) bytes. Each frame carries a 4-byte +// header { seq:u16, total:u16 } (little-endian on the wire) followed by +// up to SCENARIO_MESH_PAYLOAD_MAX payload bytes. +// * Sender (master): scenario_mesh_send() resolves an alias to a MAC, +// registers the peer, and transmits every frame sequentially, awaiting +// the per-frame esp_now send-callback ack before advancing. +// * Receiver (peer board): the registered esp_now recv callback accumulates +// frames keyed by (sender MAC + total) until `total` frames have arrived, +// concatenates them, and hands the reassembled buffer to the apply +// callback supplied at init — the same internal `_scenario_apply()` path +// the HTTP POST /game/scenario handler uses. +// +// NOTE ON THE "EXISTING PEER REGISTRY": +// The spec references an existing ESP-NOW peer registry + `espnow_recv_cb` +// to extend. In the IDF tree (idf_zacus) no such registry exists yet — the +// only ESP-NOW code is the legacy Arduino lib/espnow_common (broadcast-only, +// puzzle-id keyed, not an IDF component). So this component carries its own +// minimal alias->MAC table (scenario_mesh_register_peer / _mac_for_alias). +// When a real shared registry lands, point mac_for_alias() at it. + +#pragma once + +#include +#include +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ESP-NOW hard limit on a single payload is 250 bytes. We cap the whole +// frame (header + payload) at 240 to stay clear of vendor headers and keep a +// safety margin, matching the spec's "<= 240-byte ESP-NOW frames". +#define SCENARIO_MESH_FRAME_MAX 240 +#define SCENARIO_MESH_HEADER_BYTES 4 +#define SCENARIO_MESH_PAYLOAD_MAX (SCENARIO_MESH_FRAME_MAX - SCENARIO_MESH_HEADER_BYTES) // 236 + +// Largest IR blob we will reassemble on the receive side. Mirrors +// GAME_ENDPOINT_MAX_SCENARIO_BYTES (64 KiB). 64 KiB / 236 ≈ 285 frames, well +// under the u16 sequence space. +#define SCENARIO_MESH_MAX_BLOB (64 * 1024) + +// Per-frame ack timeout. ESP-NOW send-callbacks normally fire within a few ms; +// a generous window absorbs RF retries without stalling the relay loop. +#define SCENARIO_MESH_ACK_TIMEOUT_MS 300 + +// Callback invoked on the receive side once a full blob has been reassembled. +// `data` is a NUL-terminated buffer of `len` bytes (the IR JSON). The callback +// must NOT take ownership — the buffer is freed by scenario_mesh after return. +// Return ESP_OK if the scenario was applied; any other value is logged. +typedef esp_err_t (*scenario_mesh_apply_cb_t)(const char *data, size_t len); + +// Initialize ESP-NOW (idempotent — tolerates an already-initialized stack), +// register the send + recv callbacks, and register the broadcast peer. +// +// `apply_cb` may be NULL on a pure sender (master) that never receives +// scenarios; pass the board's _scenario_apply wrapper on receiver boards. +esp_err_t scenario_mesh_init(scenario_mesh_apply_cb_t apply_cb); + +// Register / update an alias -> MAC mapping in the local peer table and add the +// MAC as an unencrypted ESP-NOW peer. Safe to call repeatedly with the same +// alias (updates the MAC). Returns ESP_ERR_NO_MEM if the table is full. +esp_err_t scenario_mesh_register_peer(const char *alias, const uint8_t mac[6]); + +// Resolve an alias to its MAC. Returns ESP_OK and fills `mac_out` on hit, +// ESP_ERR_NOT_FOUND otherwise. +esp_err_t scenario_mesh_mac_for_alias(const char *alias, uint8_t mac_out[6]); + +// Chunk `data` (len bytes) into frames and send them all sequentially to +// `dest_mac`, awaiting the per-frame ack. Returns ESP_OK only if every frame +// was acked; ESP_ERR_TIMEOUT if any frame ack timed out, or the underlying +// esp_now_send error. The caller (relay handler) treats a non-OK return as a +// skipped peer and continues with the others. +esp_err_t scenario_mesh_send(const uint8_t dest_mac[6], + const char *data, size_t len); + +#ifdef __cplusplus +} +#endif diff --git a/box3_voice/components/scenario_mesh/scenario_mesh.c b/box3_voice/components/scenario_mesh/scenario_mesh.c new file mode 100644 index 0000000..89d10e3 --- /dev/null +++ b/box3_voice/components/scenario_mesh/scenario_mesh.c @@ -0,0 +1,389 @@ +// scenario_mesh — see include/scenario_mesh.h for the design notes. +// +// Tasks 4 & 5 of the firmware-scenario-hotload spec: ESP-NOW frame protocol +// (chunk + reassembly) for relaying Runtime 3 IR to WiFi-disabled peers. + +#include "scenario_mesh.h" + +#include + +#include "esp_log.h" +#include "esp_now.h" +#include "esp_wifi.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + +static const char *TAG = "scenario_mesh"; + +static const uint8_t kBroadcast[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + +// ─── alias -> MAC peer table (minimal local registry) ─────────────────────── + +#define MESH_ALIAS_MAX 32 +#define MESH_PEERS_MAX 8 + +typedef struct { + bool used; + char alias[MESH_ALIAS_MAX]; + uint8_t mac[6]; +} mesh_peer_t; + +static mesh_peer_t s_peers[MESH_PEERS_MAX]; + +// ─── send-side ack synchronization ────────────────────────────────────────── + +static SemaphoreHandle_t s_send_done; // given by on_sent for each frame +static volatile esp_now_send_status_t s_last_send_status; +static SemaphoreHandle_t s_send_lock; // serializes scenario_mesh_send calls + +// ─── receive-side reassembly ──────────────────────────────────────────────── +// +// One in-flight reassembly slot per distinct (sender MAC, total) tuple. A +// single slot is enough in practice — the master relays to one peer at a time +// and frames arrive in order — but we keep a small array so two senders (or a +// retried transfer) don't clobber each other. + +#define MESH_REASM_SLOTS 2 + +typedef struct { + bool used; + uint8_t src[6]; + uint16_t total; + uint16_t received; // count of distinct frames stored + bool *seen; // [total] frame-arrival bitmap (heap) + char *buf; // [total * PAYLOAD_MAX + 1] (heap) + size_t buf_len; // running max byte offset written + payload + uint32_t last_tick; // for staleness eviction +} mesh_reasm_t; + +static mesh_reasm_t s_reasm[MESH_REASM_SLOTS]; +static SemaphoreHandle_t s_reasm_lock; +static scenario_mesh_apply_cb_t s_apply_cb; + +// A completed reassembly is handed off to a worker task rather than applied in +// the Wi-Fi recv-callback context: the apply does filesystem I/O (and triggers +// a reboot) which must not run on the Wi-Fi stack's callback. +typedef struct { + char *buf; // heap, NUL-terminated, ownership transferred to the task + size_t len; +} mesh_apply_job_t; + +static QueueHandle_t s_apply_queue; + +static void apply_worker_task(void *arg) { + (void) arg; + mesh_apply_job_t job; + for (;;) { + if (xQueueReceive(s_apply_queue, &job, portMAX_DELAY) != pdTRUE) { + continue; + } + if (s_apply_cb) { + esp_err_t aerr = s_apply_cb(job.buf, job.len); + if (aerr != ESP_OK) { + ESP_LOGW(TAG, "apply_cb returned %s", esp_err_to_name(aerr)); + } + } else { + ESP_LOGW(TAG, "no apply_cb registered — dropping scenario"); + } + free(job.buf); + } +} + +// ─── peer table helpers ───────────────────────────────────────────────────── + +esp_err_t scenario_mesh_register_peer(const char *alias, const uint8_t mac[6]) { + if (!alias || !mac) return ESP_ERR_INVALID_ARG; + + int free_slot = -1; + for (int i = 0; i < MESH_PEERS_MAX; i++) { + if (s_peers[i].used && strncmp(s_peers[i].alias, alias, + MESH_ALIAS_MAX) == 0) { + free_slot = i; // update existing + break; + } + if (!s_peers[i].used && free_slot < 0) free_slot = i; + } + if (free_slot < 0) { + ESP_LOGE(TAG, "peer table full, cannot register \"%s\"", alias); + return ESP_ERR_NO_MEM; + } + + s_peers[free_slot].used = true; + strncpy(s_peers[free_slot].alias, alias, MESH_ALIAS_MAX - 1); + s_peers[free_slot].alias[MESH_ALIAS_MAX - 1] = '\0'; + memcpy(s_peers[free_slot].mac, mac, 6); + + // Add (or refresh) the ESP-NOW peer entry. esp_now_add_peer fails with + // ESP_ERR_ESPNOW_EXIST if already present — treat that as success. + esp_now_peer_info_t pi = {0}; + memcpy(pi.peer_addr, mac, 6); + pi.channel = 0; // current channel + pi.ifidx = WIFI_IF_STA; + pi.encrypt = false; + esp_err_t err = esp_now_add_peer(&pi); + if (err == ESP_ERR_ESPNOW_EXIST) { + err = ESP_OK; + } else if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_now_add_peer(%s) failed: %s", + alias, esp_err_to_name(err)); + } + + ESP_LOGI(TAG, "peer \"%s\" -> %02x:%02x:%02x:%02x:%02x:%02x", + alias, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + return err; +} + +esp_err_t scenario_mesh_mac_for_alias(const char *alias, uint8_t mac_out[6]) { + if (!alias || !mac_out) return ESP_ERR_INVALID_ARG; + for (int i = 0; i < MESH_PEERS_MAX; i++) { + if (s_peers[i].used && + strncmp(s_peers[i].alias, alias, MESH_ALIAS_MAX) == 0) { + memcpy(mac_out, s_peers[i].mac, 6); + return ESP_OK; + } + } + return ESP_ERR_NOT_FOUND; +} + +// ─── ESP-NOW callbacks (Wi-Fi task context — keep short) ──────────────────── + +static void on_sent(const uint8_t *mac, esp_now_send_status_t status) { + (void) mac; + s_last_send_status = status; + if (s_send_done) { + BaseType_t hp = pdFALSE; + xSemaphoreGiveFromISR(s_send_done, &hp); + if (hp) portYIELD_FROM_ISR(); + } +} + +// Locate (or allocate) the reassembly slot for this sender/total. Caller holds +// s_reasm_lock. Returns NULL on allocation failure. +static mesh_reasm_t *reasm_slot_for(const uint8_t src[6], uint16_t total) { + mesh_reasm_t *free_slot = NULL; + mesh_reasm_t *oldest = NULL; + for (int i = 0; i < MESH_REASM_SLOTS; i++) { + mesh_reasm_t *r = &s_reasm[i]; + if (r->used && r->total == total && memcmp(r->src, src, 6) == 0) { + return r; + } + if (!r->used && !free_slot) free_slot = r; + if (r->used && (!oldest || r->last_tick < oldest->last_tick)) oldest = r; + } + + // New transfer. Reuse a free slot, else evict the oldest in-flight one. + mesh_reasm_t *r = free_slot ? free_slot : oldest; + if (!r) return NULL; + if (r->used) { + ESP_LOGW(TAG, "evicting stale reassembly (%u/%u frames)", + r->received, r->total); + free(r->seen); + free(r->buf); + } + memset(r, 0, sizeof(*r)); + + if (total == 0 || (size_t) total * SCENARIO_MESH_PAYLOAD_MAX > SCENARIO_MESH_MAX_BLOB) { + ESP_LOGW(TAG, "reject transfer: implausible total=%u", total); + return NULL; + } + r->seen = calloc(total, sizeof(bool)); + r->buf = malloc((size_t) total * SCENARIO_MESH_PAYLOAD_MAX + 1); + if (!r->seen || !r->buf) { + free(r->seen); + free(r->buf); + ESP_LOGE(TAG, "OOM allocating reassembly for total=%u", total); + return NULL; + } + r->used = true; + r->total = total; + memcpy(r->src, src, 6); + return r; +} + +static void on_recv(const esp_now_recv_info_t *info, + const uint8_t *data, int len) { + if (!info || !data || len < SCENARIO_MESH_HEADER_BYTES) return; + if (len > SCENARIO_MESH_FRAME_MAX) return; + + // Header: seq:u16, total:u16 (little-endian). + uint16_t seq = (uint16_t) (data[0] | (data[1] << 8)); + uint16_t total = (uint16_t) (data[2] | (data[3] << 8)); + const uint8_t *payload = data + SCENARIO_MESH_HEADER_BYTES; + int payload_len = len - SCENARIO_MESH_HEADER_BYTES; + if (seq >= total) return; // malformed + + if (!s_reasm_lock) return; + // Run reassembly off the Wi-Fi callback by doing the bookkeeping under a + // mutex here; the (potentially slow) apply is deferred to a short task so + // we never block the Wi-Fi stack inside the recv callback. + char *complete_buf = NULL; + size_t complete_len = 0; + + xSemaphoreTake(s_reasm_lock, portMAX_DELAY); + mesh_reasm_t *r = reasm_slot_for(info->src_addr, total); + if (r) { + if (!r->seen[seq]) { + r->seen[seq] = true; + r->received++; + size_t off = (size_t) seq * SCENARIO_MESH_PAYLOAD_MAX; + memcpy(r->buf + off, payload, payload_len); + // Track the highest end offset so the final length is exact even + // though only the last frame is short. + if (off + payload_len > r->buf_len) r->buf_len = off + payload_len; + } + r->last_tick = (uint32_t) xTaskGetTickCount(); + + if (r->received == r->total) { + r->buf[r->buf_len] = '\0'; + complete_buf = r->buf; + complete_len = r->buf_len; + free(r->seen); + memset(r, 0, sizeof(*r)); // releases the slot; buf handed off + } + } + xSemaphoreGive(s_reasm_lock); + + if (complete_buf) { + ESP_LOGI(TAG, "reassembled scenario: %u bytes from " + "%02x:%02x:%02x:%02x:%02x:%02x", + (unsigned) complete_len, + info->src_addr[0], info->src_addr[1], info->src_addr[2], + info->src_addr[3], info->src_addr[4], info->src_addr[5]); + // Hand off to the worker task — never touch the filesystem (or reboot) + // from the Wi-Fi recv-callback context. + mesh_apply_job_t job = { .buf = complete_buf, .len = complete_len }; + if (!s_apply_queue || + xQueueSend(s_apply_queue, &job, 0) != pdTRUE) { + ESP_LOGW(TAG, "apply queue full/unavailable — dropping scenario"); + free(complete_buf); + } + } +} + +// ─── init ─────────────────────────────────────────────────────────────────── + +esp_err_t scenario_mesh_init(scenario_mesh_apply_cb_t apply_cb) { + s_apply_cb = apply_cb; + + if (!s_send_done) s_send_done = xSemaphoreCreateBinary(); + if (!s_send_lock) s_send_lock = xSemaphoreCreateMutex(); + if (!s_reasm_lock) s_reasm_lock = xSemaphoreCreateMutex(); + if (!s_send_done || !s_send_lock || !s_reasm_lock) { + return ESP_ERR_NO_MEM; + } + + // Receiver path: spin up the apply worker (and its job queue) so completed + // reassemblies are applied off the Wi-Fi callback. A pure sender (master + // relay with no apply_cb) skips this to save RAM. + if (apply_cb && !s_apply_queue) { + s_apply_queue = xQueueCreate(2, sizeof(mesh_apply_job_t)); + if (!s_apply_queue) return ESP_ERR_NO_MEM; + BaseType_t ok = xTaskCreate(apply_worker_task, "scn_mesh_apply", + 4096, NULL, tskIDLE_PRIORITY + 2, NULL); + if (ok != pdPASS) { + vQueueDelete(s_apply_queue); + s_apply_queue = NULL; + return ESP_ERR_NO_MEM; + } + } + + // esp_now_init() requires Wi-Fi to be started already (the caller brings up + // STA/AP). A second init returns ESP_ERR_ESPNOW_INTERNAL on some IDF lines; + // since no other component in this tree owns ESP-NOW yet a hard failure is + // genuinely fatal, but we keep the relay endpoint optional at the call site. + esp_err_t err = esp_now_init(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_now_init: %s", esp_err_to_name(err)); + return err; + } + + esp_now_register_send_cb(on_sent); + esp_now_register_recv_cb(on_recv); + + // Broadcast peer (handy for future fan-out; unicast peers are added on + // demand by scenario_mesh_register_peer). + esp_now_peer_info_t bcast = {0}; + memcpy(bcast.peer_addr, kBroadcast, 6); + bcast.channel = 0; + bcast.ifidx = WIFI_IF_STA; + bcast.encrypt = false; + esp_err_t berr = esp_now_add_peer(&bcast); + if (berr != ESP_OK && berr != ESP_ERR_ESPNOW_EXIST) { + ESP_LOGW(TAG, "esp_now_add_peer(broadcast): %s", + esp_err_to_name(berr)); + } + + ESP_LOGI(TAG, "scenario_mesh ready (apply_cb=%s)", + apply_cb ? "set" : "none"); + return ESP_OK; +} + +// ─── send (chunk + per-frame ack) ─────────────────────────────────────────── + +esp_err_t scenario_mesh_send(const uint8_t dest_mac[6], + const char *data, size_t len) { + if (!dest_mac || !data || len == 0) return ESP_ERR_INVALID_ARG; + if (len > SCENARIO_MESH_MAX_BLOB) return ESP_ERR_INVALID_SIZE; + + size_t total = (len + SCENARIO_MESH_PAYLOAD_MAX - 1) / + SCENARIO_MESH_PAYLOAD_MAX; + if (total == 0 || total > 0xFFFF) return ESP_ERR_INVALID_SIZE; + + // Serialize: the single send-done semaphore is shared across frames. + if (xSemaphoreTake(s_send_lock, portMAX_DELAY) != pdTRUE) { + return ESP_FAIL; + } + + esp_err_t result = ESP_OK; + uint8_t frame[SCENARIO_MESH_FRAME_MAX]; + + for (size_t seq = 0; seq < total; seq++) { + size_t off = seq * SCENARIO_MESH_PAYLOAD_MAX; + size_t chunk = len - off; + if (chunk > SCENARIO_MESH_PAYLOAD_MAX) chunk = SCENARIO_MESH_PAYLOAD_MAX; + + frame[0] = (uint8_t) (seq & 0xFF); + frame[1] = (uint8_t) ((seq >> 8) & 0xFF); + frame[2] = (uint8_t) (total & 0xFF); + frame[3] = (uint8_t) ((total >> 8) & 0xFF); + memcpy(frame + SCENARIO_MESH_HEADER_BYTES, data + off, chunk); + + // Drain any stale ack from a previous frame, then send + await ack. + xSemaphoreTake(s_send_done, 0); + s_last_send_status = ESP_NOW_SEND_FAIL; + + esp_err_t serr = esp_now_send(dest_mac, frame, + SCENARIO_MESH_HEADER_BYTES + chunk); + if (serr != ESP_OK) { + ESP_LOGW(TAG, "esp_now_send frame %u/%u: %s", + (unsigned) seq, (unsigned) total, esp_err_to_name(serr)); + result = serr; + break; + } + + if (xSemaphoreTake(s_send_done, + pdMS_TO_TICKS(SCENARIO_MESH_ACK_TIMEOUT_MS)) + != pdTRUE) { + ESP_LOGW(TAG, "ack timeout on frame %u/%u", + (unsigned) seq, (unsigned) total); + result = ESP_ERR_TIMEOUT; + break; + } + if (s_last_send_status != ESP_NOW_SEND_SUCCESS) { + ESP_LOGW(TAG, "frame %u/%u not acked by peer", + (unsigned) seq, (unsigned) total); + result = ESP_ERR_TIMEOUT; // surfaced as a skipped peer + break; + } + } + + xSemaphoreGive(s_send_lock); + + if (result == ESP_OK) { + ESP_LOGI(TAG, "sent scenario: %u bytes in %u frames", + (unsigned) len, (unsigned) total); + } + return result; +} diff --git a/box3_voice/main/CMakeLists.txt b/box3_voice/main/CMakeLists.txt index 6073bc6..b13cec6 100644 --- a/box3_voice/main/CMakeLists.txt +++ b/box3_voice/main/CMakeLists.txt @@ -10,4 +10,5 @@ idf_component_register( json nvs_flash spiffs + scenario_mesh ) diff --git a/box3_voice/main/main.c b/box3_voice/main/main.c index ad97131..ca63431 100644 --- a/box3_voice/main/main.c +++ b/box3_voice/main/main.c @@ -26,6 +26,7 @@ #include "board_config.h" #include "voice_ws_client.h" #include "scenario_server.h" +#include "scenario_mesh.h" /* BSP header — provided by espressif/esp-box component */ #include "bsp/esp-bsp.h" @@ -384,6 +385,19 @@ void app_main(void) ESP_LOGW(TAG, "scenario_server_start failed — IR hot-load unavailable"); } + /* Start the ESP-NOW receiver so the master can relay scenarios to us even + * when WiFi is unreachable (battery / RF-noise fallback per the spec). The + * reassembled IR is funnelled through the exact same scenario_apply_buffer() + * path the HTTP handler uses. esp_wifi_start() already ran in + * wifi_init_sta(), so esp_now_init() inside has its prerequisite. */ + esp_err_t mesh_err = scenario_mesh_init(scenario_apply_buffer); + if (mesh_err != ESP_OK) { + ESP_LOGW(TAG, "scenario_mesh_init failed: %s — ESP-NOW IR relay unavailable", + esp_err_to_name(mesh_err)); + } else { + ESP_LOGI(TAG, "ESP-NOW scenario receiver active"); + } + /* TODO: Initialize ESP-SR WakeNet for wake-word detection * - Load WakeNet9 model ("hi esp" or custom) * - Feed audio frames from mic to WakeNet diff --git a/box3_voice/main/scenario_server.c b/box3_voice/main/scenario_server.c index 7290644..36a4241 100644 --- a/box3_voice/main/scenario_server.c +++ b/box3_voice/main/scenario_server.c @@ -85,14 +85,102 @@ static esp_err_t handle_healthz_get(httpd_req_t *req) { return httpd_resp_sendstr(req, "ok"); } +// Shared internal apply path. Returns ESP_OK on success and fills the optional +// out-params; on failure returns a specific esp_err_t and (if non-NULL) sets a +// static reason string. The HTTP handler and the ESP-NOW receiver both call +// this — the single `_scenario_apply` the spec mandates. +static esp_err_t scenario_apply_internal(const char *body, size_t len, + int *steps_count_out, + char *entry_out, size_t entry_cap, + const char **err_msg_out) { + if (err_msg_out) *err_msg_out = NULL; + if (steps_count_out) *steps_count_out = 0; + if (entry_out && entry_cap) entry_out[0] = '\0'; + + if (!body || len == 0 || len > MAX_SCENARIO_BYTES) { + if (err_msg_out) *err_msg_out = "body must be 1..65536 bytes"; + return ESP_ERR_INVALID_SIZE; + } + if (mount_spiffs_lazy() != ESP_OK) { + if (err_msg_out) *err_msg_out = "spiffs mount failed"; + return ESP_FAIL; + } + + cJSON *root = cJSON_Parse(body); + if (!root) { + if (err_msg_out) *err_msg_out = "malformed json"; + return ESP_ERR_INVALID_ARG; + } + const cJSON *schema = cJSON_GetObjectItemCaseSensitive(root, "schema_version"); + if (!cJSON_IsString(schema) || strcmp(schema->valuestring, "zacus.runtime3.v1") != 0) { + cJSON_Delete(root); + if (err_msg_out) *err_msg_out = "schema_version must be zacus.runtime3.v1"; + return ESP_ERR_INVALID_ARG; + } + const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps"); + if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) { + cJSON_Delete(root); + if (err_msg_out) *err_msg_out = "steps must be a non-empty array"; + return ESP_ERR_INVALID_ARG; + } + const cJSON *scenario_obj = cJSON_GetObjectItemCaseSensitive(root, "scenario"); + const cJSON *entry = scenario_obj + ? cJSON_GetObjectItemCaseSensitive(scenario_obj, "entry_step_id") : NULL; + if (entry_out && entry_cap && cJSON_IsString(entry) && entry->valuestring) { + strncpy(entry_out, entry->valuestring, entry_cap - 1); + entry_out[entry_cap - 1] = '\0'; + } + int steps_count = cJSON_GetArraySize(steps); + cJSON_Delete(root); + if (steps_count_out) *steps_count_out = steps_count; + + // Rotate current -> .bak + struct stat st; + if (stat(SCENARIO_PATH, &st) == 0) { + unlink(SCENARIO_BAK); + if (rename(SCENARIO_PATH, SCENARIO_BAK) != 0) { + ESP_LOGW(TAG, "rename .json -> .bak failed (errno=%d)", errno); + } + } + + FILE *f = fopen(SCENARIO_PATH, "wb"); + if (!f) { + ESP_LOGE(TAG, "fopen %s for write failed (errno=%d)", SCENARIO_PATH, errno); + if (err_msg_out) *err_msg_out = "scenario write open failed"; + return ESP_FAIL; + } + size_t written = fwrite(body, 1, len, f); + fclose(f); + if (written != len) { + unlink(SCENARIO_PATH); + if (stat(SCENARIO_BAK, &st) == 0) rename(SCENARIO_BAK, SCENARIO_PATH); + if (err_msg_out) *err_msg_out = "scenario write short"; + return ESP_FAIL; + } + + ESP_LOGI(TAG, "scenario hot-load OK: %zu bytes, %d steps, entry=%s", + len, steps_count, (entry_out && entry_cap) ? entry_out : ""); + schedule_restart(); + return ESP_OK; +} + +// Public thin wrapper used by the ESP-NOW receiver (matches +// scenario_mesh_apply_cb_t: esp_err_t (*)(const char *, size_t)). +esp_err_t scenario_apply_buffer(const char *data, size_t len) { + const char *emsg = NULL; + esp_err_t err = scenario_apply_internal(data, len, NULL, NULL, 0, &emsg); + if (err != ESP_OK) { + ESP_LOGW(TAG, "ESP-NOW scenario rejected: %s", + emsg ? emsg : esp_err_to_name(err)); + } + return err; +} + static esp_err_t handle_scenario_post(httpd_req_t *req) { if (req->content_len <= 0 || req->content_len > MAX_SCENARIO_BYTES) { ESP_LOGW(TAG, "POST /game/scenario: bad body length %d", (int) req->content_len); return send_error(req, "413 Payload Too Large", "body must be 1..65536 bytes"); } - if (mount_spiffs_lazy() != ESP_OK) { - return send_error(req, "500 Internal Server Error", "spiffs mount failed"); - } char *body = (char *) malloc((size_t) req->content_len + 1); if (!body) return send_error(req, "500 Internal Server Error", "out of memory"); int total = 0; @@ -107,66 +195,25 @@ static esp_err_t handle_scenario_post(httpd_req_t *req) { } body[total] = '\0'; - cJSON *root = cJSON_Parse(body); - if (!root) { - free(body); - return send_error(req, "400 Bad Request", "malformed json"); - } - const cJSON *schema = cJSON_GetObjectItemCaseSensitive(root, "schema_version"); - if (!cJSON_IsString(schema) || strcmp(schema->valuestring, "zacus.runtime3.v1") != 0) { - cJSON_Delete(root); free(body); - return send_error(req, "400 Bad Request", "schema_version must be zacus.runtime3.v1"); - } - const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps"); - if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) { - cJSON_Delete(root); free(body); - return send_error(req, "400 Bad Request", "steps must be a non-empty array"); - } - const cJSON *scenario_obj = cJSON_GetObjectItemCaseSensitive(root, "scenario"); - const cJSON *entry = scenario_obj - ? cJSON_GetObjectItemCaseSensitive(scenario_obj, "entry_step_id") : NULL; + int steps_count = 0; char entry_str[64] = {0}; - if (cJSON_IsString(entry) && entry->valuestring) { - strncpy(entry_str, entry->valuestring, sizeof(entry_str) - 1); - } - int steps_count = cJSON_GetArraySize(steps); - cJSON_Delete(root); - - // Rotate current -> .bak - struct stat st; - if (stat(SCENARIO_PATH, &st) == 0) { - unlink(SCENARIO_BAK); - if (rename(SCENARIO_PATH, SCENARIO_BAK) != 0) { - ESP_LOGW(TAG, "rename .json -> .bak failed (errno=%d)", errno); - } - } - - FILE *f = fopen(SCENARIO_PATH, "wb"); - if (!f) { - ESP_LOGE(TAG, "fopen %s for write failed (errno=%d)", SCENARIO_PATH, errno); - free(body); - return send_error(req, "500 Internal Server Error", "scenario write open failed"); - } - size_t written = fwrite(body, 1, (size_t) total, f); - fclose(f); + const char *emsg = NULL; + esp_err_t aerr = scenario_apply_internal(body, (size_t) total, &steps_count, + entry_str, sizeof(entry_str), &emsg); free(body); - if ((int) written != total) { - unlink(SCENARIO_PATH); - if (stat(SCENARIO_BAK, &st) == 0) rename(SCENARIO_BAK, SCENARIO_PATH); - return send_error(req, "500 Internal Server Error", "scenario write short"); + if (aerr != ESP_OK) { + const char *status = (aerr == ESP_ERR_INVALID_ARG || + aerr == ESP_ERR_INVALID_SIZE) + ? "400 Bad Request" : "500 Internal Server Error"; + return send_error(req, status, emsg ? emsg : esp_err_to_name(aerr)); } - ESP_LOGI(TAG, "scenario hot-load OK: %d bytes, %d steps, entry=%s", - total, steps_count, entry_str); - char buf[256]; snprintf(buf, sizeof(buf), "{\"status\":\"ok\",\"board\":\"box3_voice\",\"steps_count\":%d," "\"entry_step_id\":\"%s\",\"bytes\":%d,\"reload\":\"reboot_pending\"}", steps_count, entry_str, total); - esp_err_t ret = send_json(req, "200 OK", buf); - schedule_restart(); - return ret; + return send_json(req, "200 OK", buf); } // ---------- public init ---------- diff --git a/box3_voice/main/scenario_server.h b/box3_voice/main/scenario_server.h index c8d478d..50b9cef 100644 --- a/box3_voice/main/scenario_server.h +++ b/box3_voice/main/scenario_server.h @@ -2,6 +2,8 @@ // POST /game/scenario (Runtime 3 IR hot-load via reboot). #pragma once +#include + #include "esp_err.h" #ifdef __cplusplus @@ -10,6 +12,14 @@ extern "C" { esp_err_t scenario_server_start(void); +// Shared internal apply path (the spec's `_scenario_apply`): validate a +// Runtime 3 IR blob, atomically write it to SPIFFS, and schedule the hot-reload +// reboot. Used both by the HTTP POST /game/scenario handler and by the ESP-NOW +// receiver (scenario_mesh). `data` need not be NUL-terminated beyond `len`. +// Returns ESP_OK on success; ESP_ERR_INVALID_ARG / _INVALID_SIZE on bad input, +// ESP_FAIL on storage errors. +esp_err_t scenario_apply_buffer(const char *data, size_t len); + #ifdef __cplusplus } #endif diff --git a/docs/scenario-mesh-receiver-patch.md b/docs/scenario-mesh-receiver-patch.md new file mode 100644 index 0000000..928354a --- /dev/null +++ b/docs/scenario-mesh-receiver-patch.md @@ -0,0 +1,131 @@ +# ESP-NOW scenario receiver — patch to report onto PLIP + puzzles + +Status: **box3_voice done**; **puzzle nodes done** (defensive demux in the +shared `espnow_slave.c`, see "Resolution" below); **PLIP out of scope by +design** (Wi-Fi/HTTP-only client, no ESP-NOW stack — see "PLIP" below). + +Spec: `docs/specs/2026-05-24-firmware-scenario-hotload.md`, task 6 +("Receiver side on each peer"). + +## Resolution (2026-06-09) + +Investigation of the real firmware changed the plan from "vendor a reassembler +into each node" to "**demux defensively, treat as no-op consumer**", because: + +- **No puzzle node runs a Runtime 3 scenario.** `p7_coffre` (the final lock) + receives its 8-digit code via `MSG_PUZZLE_CONFIG` (8 bytes), not an IR; the + others are driven entirely by `MSG_*` commands. None has a LittleFS/SPIFFS + scenario store. Pushing a full scenario to them is genuinely a no-op. +- The real risk is **stream corruption**, not a missing feature: a multi-frame + scenario relay misrouted to a puzzle MAC would inject frames whose `data[0]` + equals the frame `seq` low byte — e.g. `seq==1 → 0x01 == MSG_PUZZLE_SOLVED`. + +So the receiver was added **once** to the shared `lib/espnow_common/espnow_slave.c` +(compiled into all four puzzles): frames are demultiplexed in +`espnow_slave_process()` (task context, not the ISR), reassembled per source MAC, +and handed to an **optional** `espnow_scenario_callback_t`. Puzzle nodes register +no callback, so a reassembled scenario is logged and dropped — and, critically, +never reaches the `MSG_*` path. A future node that does consume scenarios opts in +via `espnow_slave_register_scenario_callback()`. No per-puzzle code changed; no +new component; bounded heap reassembly (≤64 KiB), 5 s sender-silence timeout. + +The original per-node inline-reassembler sketch below is kept for historical +context; the shared-file approach above supersedes it. + +## What box3_voice got (the reference implementation) + +box3_voice is a standalone IDF project that does **not** use the legacy +ESP-NOW slave. It received: + +1. `components/scenario_mesh/` — a vendored copy of the master's component + (frame protocol + reassembly + alias→MAC table). Identical bytes to + `idf_zacus/components/scenario_mesh/`. +2. `scenario_server.c` refactored so the validate+write path is a reusable + `scenario_apply_buffer(const char *data, size_t len)` (declared in + `scenario_server.h`). Both the HTTP `POST /game/scenario` handler and the + ESP-NOW receiver call it — the single `_scenario_apply` the spec mandates. +3. `main.c` calls `scenario_mesh_init(scenario_apply_buffer)` after Wi-Fi / + `scenario_server_start()`. + +box3 could take the component wholesale because it owns its ESP-NOW stack — it +does **not** register any other `esp_now_register_recv_cb`. + +## Why PLIP + puzzles can't just drop the component in + +The puzzle nodes already own the single ESP-NOW receive callback via +`lib/espnow_common/espnow_slave.c` (`esp_now_register_recv_cb(on_recv)`). +ESP-IDF allows **one** recv callback per process. `scenario_mesh_init()` calls +`esp_now_register_recv_cb()` too, so calling it after `espnow_slave_init()` +would silently steal the puzzle command stream (or vice-versa). The two +protocols must be **demultiplexed inside the one existing callback**. + +Frame discriminator (no wire-format change needed): + +- Legacy puzzle frames: `data[0]` is a `MSG_*` type in `0x01..0x08` + (see `espnow_slave.h`). +- scenario_mesh frames: `data[0..1]` = `seq` (u16 LE), `data[2..3]` = `total` + (u16 LE), then payload. For the first frame `seq==0` so `data[0]==0x00`, + which never collides with a `MSG_*` type. A scenario frame is also always + `>= 4` bytes with `total >= 1` and `seq < total`. + +So: **`data[0] == 0x00` (and `len >= 4`) ⇒ scenario frame; otherwise legacy.** + +## Patch for each puzzle node (`p1`, `p5`, `p6`, `p7_coffre`) + +These share `lib/espnow_common/espnow_slave.c`, so patch it **once** there +(it is compiled into each puzzle via the `../../../lib/espnow_common/espnow_slave.c` +SRC entry already present in every puzzle `main/CMakeLists.txt`). + +1. Add the reassembler. Either: + - vendor `scenario_mesh` as a component **but do not let it register the + recv cb** (add a `scenario_mesh_feed_frame(const uint8_t *src, const + uint8_t *data, int len)` entry point and a `scenario_mesh_init_passive()` + that skips `esp_now_register_recv_cb`), or + - inline a ~60-LOC reassembler keyed by `(src_mac, total)` straight into + `espnow_slave.c` (simplest; no new component). + +2. In `espnow_slave.c::on_recv`, branch before the queue push: + + ```c + static void on_recv(const esp_now_recv_info_t *info, + const uint8_t *data, int len) { + if (len >= 4 && data[0] == 0x00) { // scenario frame + scenario_mesh_feed_frame(info->src_addr, data, len); + return; + } + /* …existing puzzle-command path (queue push)… */ + } + ``` + + On full reassembly the reassembler writes the JSON to the node's local + filesystem and calls `scenario_engine_reload()` (or, until that symbol + lands, the puzzle's equivalent of `scenario_apply_buffer()` + + deferred `esp_restart()`, mirroring box3). + +3. Each puzzle needs a `scenario_apply_buffer()` equivalent. The puzzle nodes + currently have no LittleFS/SPIFFS scenario store — if a node is purely + driven by ESP-NOW puzzle commands and has no IR of its own, task 6 may be a + no-op for it. Confirm per node before adding storage: `p7_coffre` (the + final-code lock) is the most likely to actually consume a scenario. + +## PLIP (`PLIP_FIRMWARE`) — out of scope by design + +Resolved 2026-06-09: **PLIP needs no ESP-NOW scenario receiver.** It is its own +Arduino/PlatformIO tree (`/PLIP_FIRMWARE`, C++), connects to the master purely +over **Wi-Fi + HTTP** (`network_task.cpp` → `WiFi.begin` / mDNS, `zacus_hook_client` +REST), and registers **no** `esp_now_register_recv_cb` at all. The ESP-NOW relay +exists only as a fallback for boards that run with Wi-Fi disabled (battery / RF +noise); PLIP is mains-powered and always on Wi-Fi. + +If PLIP ever needs hot scenario push, the correct path is the master/box3 HTTP +one — `POST /game/scenario` over its existing Wi-Fi link — **not** ESP-NOW. No +code change made. + +## Shared-protocol drift risk + +The vendored `scenario_mesh` now lives in two places +(`idf_zacus/components/scenario_mesh` and +`box3_voice/components/scenario_mesh`). They are byte-identical today. If the +frame header or `SCENARIO_MESH_*` constants ever change, update **both** (and +any future PLIP/puzzle copy). A follow-up could hoist the component to a shared +`lib/` path referenced via `EXTRA_COMPONENT_DIRS` to remove the duplication. diff --git a/idf_zacus/components/game_endpoint/CMakeLists.txt b/idf_zacus/components/game_endpoint/CMakeLists.txt index 6c1327c..827a2f6 100644 --- a/idf_zacus/components/game_endpoint/CMakeLists.txt +++ b/idf_zacus/components/game_endpoint/CMakeLists.txt @@ -9,6 +9,7 @@ idf_component_register( nvs_flash hints_client ota_server + scenario_mesh freertos log joltwallet__littlefs diff --git a/idf_zacus/components/game_endpoint/game_endpoint.c b/idf_zacus/components/game_endpoint/game_endpoint.c index 536bccc..4210752 100644 --- a/idf_zacus/components/game_endpoint/game_endpoint.c +++ b/idf_zacus/components/game_endpoint/game_endpoint.c @@ -25,6 +25,7 @@ #include "nvs_flash.h" #include "hints_client.h" +#include "scenario_mesh.h" static const char *TAG = "game_endpoint"; @@ -207,6 +208,127 @@ static void schedule_restart(void) { 4096, NULL, tskIDLE_PRIORITY + 1, NULL); } +// ─── _scenario_apply — shared validate + atomic-write path ────────────────── +// +// The single internal entry point both the HTTP POST /game/scenario handler +// and the ESP-NOW receiver (scenario_mesh) funnel through, per the spec. It +// validates the IR (schema_version + non-empty steps), rotates the current +// scenario to .bak, and atomically writes the new blob to LittleFS. On +// success it schedules the hot-reload reboot and returns ESP_OK; the optional +// out-params report the parsed step count / entry id for the HTTP response. +// +// On failure it returns a specific esp_err_t and leaves the previous scenario +// in place (rolled back from .bak when a short write corrupted the new file). +// `err_msg_out` (if non-NULL) receives a static human-readable reason. +static esp_err_t scenario_apply_buffer(const char *body, size_t len, + int *steps_count_out, + char *entry_out, size_t entry_cap, + const char **err_msg_out) { + if (err_msg_out) *err_msg_out = NULL; + if (steps_count_out) *steps_count_out = 0; + if (entry_out && entry_cap) entry_out[0] = '\0'; + + if (!body || len == 0 || len > GAME_ENDPOINT_MAX_SCENARIO_BYTES) { + if (err_msg_out) *err_msg_out = "body must be 1..65536 bytes"; + return ESP_ERR_INVALID_SIZE; + } + if (mount_storage_lazy() != ESP_OK) { + if (err_msg_out) *err_msg_out = "littlefs mount failed"; + return ESP_FAIL; + } + + // Minimal validation: parse + schema_version + non-empty steps array. + // The runtime3_common.py validator is the strict source of truth on the + // gateway side; here we keep the firmware permissive but safe. + cJSON *root = cJSON_Parse(body); + if (!root) { + ESP_LOGW(TAG, "scenario apply: malformed JSON (len=%d)", (int) len); + if (err_msg_out) *err_msg_out = "malformed json"; + return ESP_ERR_INVALID_ARG; + } + const cJSON *schema = cJSON_GetObjectItemCaseSensitive(root, "schema_version"); + if (!cJSON_IsString(schema) || + strcmp(schema->valuestring, "zacus.runtime3.v1") != 0) { + cJSON_Delete(root); + if (err_msg_out) *err_msg_out = "schema_version must be zacus.runtime3.v1"; + return ESP_ERR_INVALID_ARG; + } + const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps"); + if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) { + cJSON_Delete(root); + if (err_msg_out) *err_msg_out = "steps must be a non-empty array"; + return ESP_ERR_INVALID_ARG; + } + const cJSON *scenario_obj = cJSON_GetObjectItemCaseSensitive(root, "scenario"); + const cJSON *entry = scenario_obj + ? cJSON_GetObjectItemCaseSensitive(scenario_obj, "entry_step_id") + : NULL; + if (entry_out && entry_cap && cJSON_IsString(entry) && entry->valuestring) { + strncpy(entry_out, entry->valuestring, entry_cap - 1); + entry_out[entry_cap - 1] = '\0'; + } + int steps_count = cJSON_GetArraySize(steps); + cJSON_Delete(root); + if (steps_count_out) *steps_count_out = steps_count; + + // Rotate existing scenario -> .bak so a broken push can be rolled back + // by a future scenario_engine_reload() failure path. + struct stat st; + if (stat(GAME_ENDPOINT_SCENARIO_PATH, &st) == 0) { + // Best-effort: ignore rename failure (e.g. .bak already exists from + // a previous push — overwrite via unlink+rename). + unlink(GAME_ENDPOINT_SCENARIO_BAK); + if (rename(GAME_ENDPOINT_SCENARIO_PATH, + GAME_ENDPOINT_SCENARIO_BAK) != 0) { + ESP_LOGW(TAG, "rename current scenario -> .bak failed (errno=%d)", + errno); + } + } + + FILE *f = fopen(GAME_ENDPOINT_SCENARIO_PATH, "wb"); + if (!f) { + ESP_LOGE(TAG, "fopen %s for write failed (errno=%d)", + GAME_ENDPOINT_SCENARIO_PATH, errno); + if (err_msg_out) *err_msg_out = "scenario write open failed"; + return ESP_FAIL; + } + size_t written = fwrite(body, 1, len, f); + fclose(f); + + if (written != len) { + ESP_LOGE(TAG, "scenario write short: %zu/%zu bytes (rolling back)", + written, len); + unlink(GAME_ENDPOINT_SCENARIO_PATH); + if (stat(GAME_ENDPOINT_SCENARIO_BAK, &st) == 0) { + rename(GAME_ENDPOINT_SCENARIO_BAK, GAME_ENDPOINT_SCENARIO_PATH); + } + if (err_msg_out) *err_msg_out = "scenario write short"; + return ESP_FAIL; + } + + ESP_LOGI(TAG, "scenario hot-load OK: %zu bytes, %d steps, entry=%s", + len, steps_count, (entry_out && entry_cap) ? entry_out : ""); + + // Hot-reload-via-reboot until scenario_engine_reload() lands (Phase 3). + schedule_restart(); + return ESP_OK; +} + +// scenario_mesh apply callback: a received-over-ESP-NOW scenario takes the +// exact same path as an HTTP POST. The mesh frees `data` after this returns. +static esp_err_t scenario_mesh_apply_adapter(const char *data, size_t len) { + int steps = 0; + char entry[64] = {0}; + const char *emsg = NULL; + esp_err_t err = scenario_apply_buffer(data, len, &steps, + entry, sizeof(entry), &emsg); + if (err != ESP_OK) { + ESP_LOGW(TAG, "ESP-NOW scenario rejected: %s", + emsg ? emsg : esp_err_to_name(err)); + } + return err; +} + // ─── POST /game/scenario — accept a Runtime 3 IR JSON ────────────────────── static esp_err_t handle_scenario_post(httpd_req_t *req) { @@ -218,11 +340,6 @@ static esp_err_t handle_scenario_post(httpd_req_t *req) { "body must be 1..65536 bytes"); } - if (mount_storage_lazy() != ESP_OK) { - return send_error(req, "500 Internal Server Error", - "littlefs mount failed"); - } - char *body = (char *) malloc((size_t) req->content_len + 1); if (!body) { return send_error(req, "500 Internal Server Error", @@ -241,93 +358,141 @@ static esp_err_t handle_scenario_post(httpd_req_t *req) { } body[total] = '\0'; - // Minimal validation: parse + schema_version + non-empty steps array. - // The runtime3_common.py validator is the strict source of truth on - // the gateway side; here we keep the firmware permissive but safe. - cJSON *root = cJSON_Parse(body); - if (!root) { - ESP_LOGW(TAG, "POST /game/scenario: malformed JSON (len=%d)", total); - free(body); - return send_error(req, "400 Bad Request", "malformed json"); - } - const cJSON *schema = cJSON_GetObjectItemCaseSensitive(root, "schema_version"); - if (!cJSON_IsString(schema) || - strcmp(schema->valuestring, "zacus.runtime3.v1") != 0) { - cJSON_Delete(root); - free(body); - return send_error(req, "400 Bad Request", - "schema_version must be zacus.runtime3.v1"); - } - const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps"); - if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) { - cJSON_Delete(root); - free(body); - return send_error(req, "400 Bad Request", - "steps must be a non-empty array"); - } - const cJSON *scenario_obj = cJSON_GetObjectItemCaseSensitive(root, "scenario"); - const cJSON *entry = scenario_obj - ? cJSON_GetObjectItemCaseSensitive(scenario_obj, "entry_step_id") - : NULL; + int steps_count = 0; char entry_str[64] = {0}; - if (cJSON_IsString(entry) && entry->valuestring) { - strncpy(entry_str, entry->valuestring, sizeof(entry_str) - 1); - } - int steps_count = cJSON_GetArraySize(steps); - cJSON_Delete(root); - - // Rotate existing scenario -> .bak so a broken push can be rolled back - // by a future scenario_engine_reload() failure path. - struct stat st; - if (stat(GAME_ENDPOINT_SCENARIO_PATH, &st) == 0) { - // Best-effort: ignore rename failure (e.g. .bak already exists from - // a previous push — overwrite via unlink+rename). - unlink(GAME_ENDPOINT_SCENARIO_BAK); - if (rename(GAME_ENDPOINT_SCENARIO_PATH, - GAME_ENDPOINT_SCENARIO_BAK) != 0) { - ESP_LOGW(TAG, "rename current scenario -> .bak failed (errno=%d)", - errno); - } - } - - FILE *f = fopen(GAME_ENDPOINT_SCENARIO_PATH, "wb"); - if (!f) { - ESP_LOGE(TAG, "fopen %s for write failed (errno=%d)", - GAME_ENDPOINT_SCENARIO_PATH, errno); - free(body); - return send_error(req, "500 Internal Server Error", - "scenario write open failed"); - } - size_t written = fwrite(body, 1, (size_t) total, f); - fclose(f); + const char *emsg = NULL; + esp_err_t aerr = scenario_apply_buffer(body, (size_t) total, &steps_count, + entry_str, sizeof(entry_str), &emsg); free(body); - if ((int) written != total) { - ESP_LOGE(TAG, "scenario write short: %zu/%d bytes (rolling back)", - written, total); - unlink(GAME_ENDPOINT_SCENARIO_PATH); - if (stat(GAME_ENDPOINT_SCENARIO_BAK, &st) == 0) { - rename(GAME_ENDPOINT_SCENARIO_BAK, GAME_ENDPOINT_SCENARIO_PATH); - } - return send_error(req, "500 Internal Server Error", - "scenario write short"); + if (aerr != ESP_OK) { + const char *status = (aerr == ESP_ERR_INVALID_ARG || + aerr == ESP_ERR_INVALID_SIZE) + ? "400 Bad Request" : "500 Internal Server Error"; + return send_error(req, status, emsg ? emsg : esp_err_to_name(aerr)); } - ESP_LOGI(TAG, "scenario hot-load OK: %d bytes, %d steps, entry=%s", - total, steps_count, entry_str); - char buf[256]; snprintf(buf, sizeof(buf), "{\"status\":\"ok\",\"steps_count\":%d," "\"entry_step_id\":\"%s\",\"bytes\":%d," "\"reload\":\"reboot_pending\"}", steps_count, entry_str, total); - esp_err_t ret = send_json(req, "200 OK", buf); + // The HTTP response is queued by send_json; scenario_apply_buffer already + // scheduled the deferred restart, which waits 800 ms before rebooting so + // the TCP stack can flush this response first. + return send_json(req, "200 OK", buf); +} - // Hot-reload-via-reboot until scenario_engine_reload() lands (Phase 3). - // The HTTP response is queued by send_json above; the deferred task - // gives the TCP stack 800 ms to flush before yanking the rug. - schedule_restart(); +// ─── POST /game/scenario/relay (master only) ─────────────────────────────── +// +// Body: { "peers": ["box3","plip",...], "ir": { } } +// +// Resolves each alias to a MAC via the scenario_mesh peer registry, then +// chunks + sends the IR over ESP-NOW. A failure on one peer (unknown alias, +// ack timeout) does not abort the others — it lands in "skipped" with a +// reason. Responds 200 with { "relayed":[...], "skipped":[{name,reason}] }. +static esp_err_t handle_scenario_relay_post(httpd_req_t *req) { + if (req->content_len <= 0 || + req->content_len > GAME_ENDPOINT_MAX_SCENARIO_BYTES) { + ESP_LOGW(TAG, "POST /game/scenario/relay: bad body length %d", + (int) req->content_len); + return send_error(req, "413 Payload Too Large", + "body must be 1..65536 bytes"); + } + + char *body = (char *) malloc((size_t) req->content_len + 1); + if (!body) { + return send_error(req, "500 Internal Server Error", "out of memory"); + } + int total = 0; + while (total < (int) req->content_len) { + int got = httpd_req_recv(req, body + total, req->content_len - total); + if (got <= 0) { + if (got == HTTPD_SOCK_ERR_TIMEOUT) continue; + free(body); + return send_error(req, "400 Bad Request", "recv failed"); + } + total += got; + } + body[total] = '\0'; + + cJSON *root = cJSON_Parse(body); + free(body); + if (!root) { + return send_error(req, "400 Bad Request", "malformed json"); + } + const cJSON *peers = cJSON_GetObjectItemCaseSensitive(root, "peers"); + const cJSON *ir = cJSON_GetObjectItemCaseSensitive(root, "ir"); + if (!cJSON_IsArray(peers) || cJSON_GetArraySize(peers) == 0) { + cJSON_Delete(root); + return send_error(req, "400 Bad Request", + "'peers' must be a non-empty array"); + } + if (!cJSON_IsObject(ir)) { + cJSON_Delete(root); + return send_error(req, "400 Bad Request", "'ir' must be an object"); + } + + // Serialize the IR object once (unformatted, compact) — this is the exact + // byte stream the receiver reassembles and feeds to _scenario_apply(). + char *ir_str = cJSON_PrintUnformatted(ir); + if (!ir_str) { + cJSON_Delete(root); + return send_error(req, "500 Internal Server Error", + "ir serialize failed"); + } + size_t ir_len = strlen(ir_str); + + // Build the two result arrays into a response cJSON tree. + cJSON *resp = cJSON_CreateObject(); + cJSON *relayed = cJSON_AddArrayToObject(resp, "relayed"); + cJSON *skipped = cJSON_AddArrayToObject(resp, "skipped"); + + int n = cJSON_GetArraySize(peers); + for (int i = 0; i < n; i++) { + const cJSON *p = cJSON_GetArrayItem(peers, i); + if (!cJSON_IsString(p) || !p->valuestring) continue; + const char *alias = p->valuestring; + + uint8_t mac[6]; + esp_err_t rerr = scenario_mesh_mac_for_alias(alias, mac); + if (rerr != ESP_OK) { + cJSON *s = cJSON_CreateObject(); + cJSON_AddStringToObject(s, "name", alias); + cJSON_AddStringToObject(s, "reason", "unknown_peer"); + cJSON_AddItemToArray(skipped, s); + ESP_LOGW(TAG, "relay: alias \"%s\" not in peer registry", alias); + continue; + } + + esp_err_t serr = scenario_mesh_send(mac, ir_str, ir_len); + if (serr == ESP_OK) { + cJSON_AddItemToArray(relayed, cJSON_CreateString(alias)); + ESP_LOGI(TAG, "relay: \"%s\" OK (%u bytes)", + alias, (unsigned) ir_len); + } else { + cJSON *s = cJSON_CreateObject(); + cJSON_AddStringToObject(s, "name", alias); + cJSON_AddStringToObject(s, "reason", + serr == ESP_ERR_TIMEOUT ? "timeout" : esp_err_to_name(serr)); + cJSON_AddItemToArray(skipped, s); + ESP_LOGW(TAG, "relay: \"%s\" failed: %s", + alias, esp_err_to_name(serr)); + } + } + + cJSON_free(ir_str); + cJSON_Delete(root); + + char *resp_str = cJSON_PrintUnformatted(resp); + cJSON_Delete(resp); + if (!resp_str) { + return send_error(req, "500 Internal Server Error", + "response serialize failed"); + } + esp_err_t ret = send_json(req, "200 OK", resp_str); + cJSON_free(resp_str); return ret; } @@ -358,6 +523,22 @@ esp_err_t game_endpoint_init(httpd_handle_t server) { .handler = handle_scenario_post, .user_ctx = NULL, }; + static const httpd_uri_t uri_scenario_relay = { + .uri = "/game/scenario/relay", + .method = HTTP_POST, + .handler = handle_scenario_relay_post, + .user_ctx = NULL, + }; + + // Bring up the ESP-NOW mesh transport. The master is primarily a sender + // (the relay handler) but we also register the apply adapter so a peer + // could push a scenario back over ESP-NOW symmetrically. Non-fatal: if the + // Wi-Fi stack isn't ready the relay endpoint will just report send errors. + esp_err_t mesh_err = scenario_mesh_init(scenario_mesh_apply_adapter); + if (mesh_err != ESP_OK) { + ESP_LOGW(TAG, "scenario_mesh_init failed: %s — /game/scenario/relay " + "will be unavailable", esp_err_to_name(mesh_err)); + } esp_err_t err = httpd_register_uri_handler(server, &uri_get); if (err != ESP_OK) { @@ -377,8 +558,18 @@ esp_err_t game_endpoint_init(httpd_handle_t server) { esp_err_to_name(err)); return err; } + // Relay is best-effort: only register it when the mesh came up, but a + // registration failure here is non-fatal to the rest of the surface. + if (mesh_err == ESP_OK) { + err = httpd_register_uri_handler(server, &uri_scenario_relay); + if (err != ESP_OK) { + ESP_LOGW(TAG, "register POST /game/scenario/relay: %s", + esp_err_to_name(err)); + } + } ESP_LOGI(TAG, "game endpoint registered " - "(GET+POST /game/group_profile, POST /game/scenario)"); + "(GET+POST /game/group_profile, POST /game/scenario%s)", + mesh_err == ESP_OK ? ", POST /game/scenario/relay" : ""); return ESP_OK; } diff --git a/idf_zacus/components/scenario_mesh/CMakeLists.txt b/idf_zacus/components/scenario_mesh/CMakeLists.txt new file mode 100644 index 0000000..4520f05 --- /dev/null +++ b/idf_zacus/components/scenario_mesh/CMakeLists.txt @@ -0,0 +1,17 @@ +## Zacus scenario_mesh — ESP-NOW transport for Runtime 3 IR hot-load. +## +## Tasks 4 & 5 of docs/specs/2026-05-24-firmware-scenario-hotload.md: chunk the +## IR JSON into <=240-byte ESP-NOW frames, send sequentially with per-frame +## ack, and reassemble on the receive side keyed by sender+total before +## invoking the same _scenario_apply() path as the HTTP handler. + +idf_component_register( + SRCS + "scenario_mesh.c" + INCLUDE_DIRS + "include" + REQUIRES + esp_wifi + freertos + log +) diff --git a/idf_zacus/components/scenario_mesh/include/scenario_mesh.h b/idf_zacus/components/scenario_mesh/include/scenario_mesh.h new file mode 100644 index 0000000..2db86d4 --- /dev/null +++ b/idf_zacus/components/scenario_mesh/include/scenario_mesh.h @@ -0,0 +1,86 @@ +// scenario_mesh — ESP-NOW transport for Runtime 3 IR hot-load (Phase 2). +// +// Implements tasks 4 & 5 of docs/specs/2026-05-24-firmware-scenario-hotload.md: +// +// * Frame protocol: the IR JSON blob is chunked into ESP-NOW frames of +// <= SCENARIO_MESH_FRAME_MAX (240) bytes. Each frame carries a 4-byte +// header { seq:u16, total:u16 } (little-endian on the wire) followed by +// up to SCENARIO_MESH_PAYLOAD_MAX payload bytes. +// * Sender (master): scenario_mesh_send() resolves an alias to a MAC, +// registers the peer, and transmits every frame sequentially, awaiting +// the per-frame esp_now send-callback ack before advancing. +// * Receiver (peer board): the registered esp_now recv callback accumulates +// frames keyed by (sender MAC + total) until `total` frames have arrived, +// concatenates them, and hands the reassembled buffer to the apply +// callback supplied at init — the same internal `_scenario_apply()` path +// the HTTP POST /game/scenario handler uses. +// +// NOTE ON THE "EXISTING PEER REGISTRY": +// The spec references an existing ESP-NOW peer registry + `espnow_recv_cb` +// to extend. In the IDF tree (idf_zacus) no such registry exists yet — the +// only ESP-NOW code is the legacy Arduino lib/espnow_common (broadcast-only, +// puzzle-id keyed, not an IDF component). So this component carries its own +// minimal alias->MAC table (scenario_mesh_register_peer / _mac_for_alias). +// When a real shared registry lands, point mac_for_alias() at it. + +#pragma once + +#include +#include +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ESP-NOW hard limit on a single payload is 250 bytes. We cap the whole +// frame (header + payload) at 240 to stay clear of vendor headers and keep a +// safety margin, matching the spec's "<= 240-byte ESP-NOW frames". +#define SCENARIO_MESH_FRAME_MAX 240 +#define SCENARIO_MESH_HEADER_BYTES 4 +#define SCENARIO_MESH_PAYLOAD_MAX (SCENARIO_MESH_FRAME_MAX - SCENARIO_MESH_HEADER_BYTES) // 236 + +// Largest IR blob we will reassemble on the receive side. Mirrors +// GAME_ENDPOINT_MAX_SCENARIO_BYTES (64 KiB). 64 KiB / 236 ≈ 285 frames, well +// under the u16 sequence space. +#define SCENARIO_MESH_MAX_BLOB (64 * 1024) + +// Per-frame ack timeout. ESP-NOW send-callbacks normally fire within a few ms; +// a generous window absorbs RF retries without stalling the relay loop. +#define SCENARIO_MESH_ACK_TIMEOUT_MS 300 + +// Callback invoked on the receive side once a full blob has been reassembled. +// `data` is a NUL-terminated buffer of `len` bytes (the IR JSON). The callback +// must NOT take ownership — the buffer is freed by scenario_mesh after return. +// Return ESP_OK if the scenario was applied; any other value is logged. +typedef esp_err_t (*scenario_mesh_apply_cb_t)(const char *data, size_t len); + +// Initialize ESP-NOW (idempotent — tolerates an already-initialized stack), +// register the send + recv callbacks, and register the broadcast peer. +// +// `apply_cb` may be NULL on a pure sender (master) that never receives +// scenarios; pass the board's _scenario_apply wrapper on receiver boards. +esp_err_t scenario_mesh_init(scenario_mesh_apply_cb_t apply_cb); + +// Register / update an alias -> MAC mapping in the local peer table and add the +// MAC as an unencrypted ESP-NOW peer. Safe to call repeatedly with the same +// alias (updates the MAC). Returns ESP_ERR_NO_MEM if the table is full. +esp_err_t scenario_mesh_register_peer(const char *alias, const uint8_t mac[6]); + +// Resolve an alias to its MAC. Returns ESP_OK and fills `mac_out` on hit, +// ESP_ERR_NOT_FOUND otherwise. +esp_err_t scenario_mesh_mac_for_alias(const char *alias, uint8_t mac_out[6]); + +// Chunk `data` (len bytes) into frames and send them all sequentially to +// `dest_mac`, awaiting the per-frame ack. Returns ESP_OK only if every frame +// was acked; ESP_ERR_TIMEOUT if any frame ack timed out, or the underlying +// esp_now_send error. The caller (relay handler) treats a non-OK return as a +// skipped peer and continues with the others. +esp_err_t scenario_mesh_send(const uint8_t dest_mac[6], + const char *data, size_t len); + +#ifdef __cplusplus +} +#endif diff --git a/idf_zacus/components/scenario_mesh/scenario_mesh.c b/idf_zacus/components/scenario_mesh/scenario_mesh.c new file mode 100644 index 0000000..89d10e3 --- /dev/null +++ b/idf_zacus/components/scenario_mesh/scenario_mesh.c @@ -0,0 +1,389 @@ +// scenario_mesh — see include/scenario_mesh.h for the design notes. +// +// Tasks 4 & 5 of the firmware-scenario-hotload spec: ESP-NOW frame protocol +// (chunk + reassembly) for relaying Runtime 3 IR to WiFi-disabled peers. + +#include "scenario_mesh.h" + +#include + +#include "esp_log.h" +#include "esp_now.h" +#include "esp_wifi.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + +static const char *TAG = "scenario_mesh"; + +static const uint8_t kBroadcast[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + +// ─── alias -> MAC peer table (minimal local registry) ─────────────────────── + +#define MESH_ALIAS_MAX 32 +#define MESH_PEERS_MAX 8 + +typedef struct { + bool used; + char alias[MESH_ALIAS_MAX]; + uint8_t mac[6]; +} mesh_peer_t; + +static mesh_peer_t s_peers[MESH_PEERS_MAX]; + +// ─── send-side ack synchronization ────────────────────────────────────────── + +static SemaphoreHandle_t s_send_done; // given by on_sent for each frame +static volatile esp_now_send_status_t s_last_send_status; +static SemaphoreHandle_t s_send_lock; // serializes scenario_mesh_send calls + +// ─── receive-side reassembly ──────────────────────────────────────────────── +// +// One in-flight reassembly slot per distinct (sender MAC, total) tuple. A +// single slot is enough in practice — the master relays to one peer at a time +// and frames arrive in order — but we keep a small array so two senders (or a +// retried transfer) don't clobber each other. + +#define MESH_REASM_SLOTS 2 + +typedef struct { + bool used; + uint8_t src[6]; + uint16_t total; + uint16_t received; // count of distinct frames stored + bool *seen; // [total] frame-arrival bitmap (heap) + char *buf; // [total * PAYLOAD_MAX + 1] (heap) + size_t buf_len; // running max byte offset written + payload + uint32_t last_tick; // for staleness eviction +} mesh_reasm_t; + +static mesh_reasm_t s_reasm[MESH_REASM_SLOTS]; +static SemaphoreHandle_t s_reasm_lock; +static scenario_mesh_apply_cb_t s_apply_cb; + +// A completed reassembly is handed off to a worker task rather than applied in +// the Wi-Fi recv-callback context: the apply does filesystem I/O (and triggers +// a reboot) which must not run on the Wi-Fi stack's callback. +typedef struct { + char *buf; // heap, NUL-terminated, ownership transferred to the task + size_t len; +} mesh_apply_job_t; + +static QueueHandle_t s_apply_queue; + +static void apply_worker_task(void *arg) { + (void) arg; + mesh_apply_job_t job; + for (;;) { + if (xQueueReceive(s_apply_queue, &job, portMAX_DELAY) != pdTRUE) { + continue; + } + if (s_apply_cb) { + esp_err_t aerr = s_apply_cb(job.buf, job.len); + if (aerr != ESP_OK) { + ESP_LOGW(TAG, "apply_cb returned %s", esp_err_to_name(aerr)); + } + } else { + ESP_LOGW(TAG, "no apply_cb registered — dropping scenario"); + } + free(job.buf); + } +} + +// ─── peer table helpers ───────────────────────────────────────────────────── + +esp_err_t scenario_mesh_register_peer(const char *alias, const uint8_t mac[6]) { + if (!alias || !mac) return ESP_ERR_INVALID_ARG; + + int free_slot = -1; + for (int i = 0; i < MESH_PEERS_MAX; i++) { + if (s_peers[i].used && strncmp(s_peers[i].alias, alias, + MESH_ALIAS_MAX) == 0) { + free_slot = i; // update existing + break; + } + if (!s_peers[i].used && free_slot < 0) free_slot = i; + } + if (free_slot < 0) { + ESP_LOGE(TAG, "peer table full, cannot register \"%s\"", alias); + return ESP_ERR_NO_MEM; + } + + s_peers[free_slot].used = true; + strncpy(s_peers[free_slot].alias, alias, MESH_ALIAS_MAX - 1); + s_peers[free_slot].alias[MESH_ALIAS_MAX - 1] = '\0'; + memcpy(s_peers[free_slot].mac, mac, 6); + + // Add (or refresh) the ESP-NOW peer entry. esp_now_add_peer fails with + // ESP_ERR_ESPNOW_EXIST if already present — treat that as success. + esp_now_peer_info_t pi = {0}; + memcpy(pi.peer_addr, mac, 6); + pi.channel = 0; // current channel + pi.ifidx = WIFI_IF_STA; + pi.encrypt = false; + esp_err_t err = esp_now_add_peer(&pi); + if (err == ESP_ERR_ESPNOW_EXIST) { + err = ESP_OK; + } else if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_now_add_peer(%s) failed: %s", + alias, esp_err_to_name(err)); + } + + ESP_LOGI(TAG, "peer \"%s\" -> %02x:%02x:%02x:%02x:%02x:%02x", + alias, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + return err; +} + +esp_err_t scenario_mesh_mac_for_alias(const char *alias, uint8_t mac_out[6]) { + if (!alias || !mac_out) return ESP_ERR_INVALID_ARG; + for (int i = 0; i < MESH_PEERS_MAX; i++) { + if (s_peers[i].used && + strncmp(s_peers[i].alias, alias, MESH_ALIAS_MAX) == 0) { + memcpy(mac_out, s_peers[i].mac, 6); + return ESP_OK; + } + } + return ESP_ERR_NOT_FOUND; +} + +// ─── ESP-NOW callbacks (Wi-Fi task context — keep short) ──────────────────── + +static void on_sent(const uint8_t *mac, esp_now_send_status_t status) { + (void) mac; + s_last_send_status = status; + if (s_send_done) { + BaseType_t hp = pdFALSE; + xSemaphoreGiveFromISR(s_send_done, &hp); + if (hp) portYIELD_FROM_ISR(); + } +} + +// Locate (or allocate) the reassembly slot for this sender/total. Caller holds +// s_reasm_lock. Returns NULL on allocation failure. +static mesh_reasm_t *reasm_slot_for(const uint8_t src[6], uint16_t total) { + mesh_reasm_t *free_slot = NULL; + mesh_reasm_t *oldest = NULL; + for (int i = 0; i < MESH_REASM_SLOTS; i++) { + mesh_reasm_t *r = &s_reasm[i]; + if (r->used && r->total == total && memcmp(r->src, src, 6) == 0) { + return r; + } + if (!r->used && !free_slot) free_slot = r; + if (r->used && (!oldest || r->last_tick < oldest->last_tick)) oldest = r; + } + + // New transfer. Reuse a free slot, else evict the oldest in-flight one. + mesh_reasm_t *r = free_slot ? free_slot : oldest; + if (!r) return NULL; + if (r->used) { + ESP_LOGW(TAG, "evicting stale reassembly (%u/%u frames)", + r->received, r->total); + free(r->seen); + free(r->buf); + } + memset(r, 0, sizeof(*r)); + + if (total == 0 || (size_t) total * SCENARIO_MESH_PAYLOAD_MAX > SCENARIO_MESH_MAX_BLOB) { + ESP_LOGW(TAG, "reject transfer: implausible total=%u", total); + return NULL; + } + r->seen = calloc(total, sizeof(bool)); + r->buf = malloc((size_t) total * SCENARIO_MESH_PAYLOAD_MAX + 1); + if (!r->seen || !r->buf) { + free(r->seen); + free(r->buf); + ESP_LOGE(TAG, "OOM allocating reassembly for total=%u", total); + return NULL; + } + r->used = true; + r->total = total; + memcpy(r->src, src, 6); + return r; +} + +static void on_recv(const esp_now_recv_info_t *info, + const uint8_t *data, int len) { + if (!info || !data || len < SCENARIO_MESH_HEADER_BYTES) return; + if (len > SCENARIO_MESH_FRAME_MAX) return; + + // Header: seq:u16, total:u16 (little-endian). + uint16_t seq = (uint16_t) (data[0] | (data[1] << 8)); + uint16_t total = (uint16_t) (data[2] | (data[3] << 8)); + const uint8_t *payload = data + SCENARIO_MESH_HEADER_BYTES; + int payload_len = len - SCENARIO_MESH_HEADER_BYTES; + if (seq >= total) return; // malformed + + if (!s_reasm_lock) return; + // Run reassembly off the Wi-Fi callback by doing the bookkeeping under a + // mutex here; the (potentially slow) apply is deferred to a short task so + // we never block the Wi-Fi stack inside the recv callback. + char *complete_buf = NULL; + size_t complete_len = 0; + + xSemaphoreTake(s_reasm_lock, portMAX_DELAY); + mesh_reasm_t *r = reasm_slot_for(info->src_addr, total); + if (r) { + if (!r->seen[seq]) { + r->seen[seq] = true; + r->received++; + size_t off = (size_t) seq * SCENARIO_MESH_PAYLOAD_MAX; + memcpy(r->buf + off, payload, payload_len); + // Track the highest end offset so the final length is exact even + // though only the last frame is short. + if (off + payload_len > r->buf_len) r->buf_len = off + payload_len; + } + r->last_tick = (uint32_t) xTaskGetTickCount(); + + if (r->received == r->total) { + r->buf[r->buf_len] = '\0'; + complete_buf = r->buf; + complete_len = r->buf_len; + free(r->seen); + memset(r, 0, sizeof(*r)); // releases the slot; buf handed off + } + } + xSemaphoreGive(s_reasm_lock); + + if (complete_buf) { + ESP_LOGI(TAG, "reassembled scenario: %u bytes from " + "%02x:%02x:%02x:%02x:%02x:%02x", + (unsigned) complete_len, + info->src_addr[0], info->src_addr[1], info->src_addr[2], + info->src_addr[3], info->src_addr[4], info->src_addr[5]); + // Hand off to the worker task — never touch the filesystem (or reboot) + // from the Wi-Fi recv-callback context. + mesh_apply_job_t job = { .buf = complete_buf, .len = complete_len }; + if (!s_apply_queue || + xQueueSend(s_apply_queue, &job, 0) != pdTRUE) { + ESP_LOGW(TAG, "apply queue full/unavailable — dropping scenario"); + free(complete_buf); + } + } +} + +// ─── init ─────────────────────────────────────────────────────────────────── + +esp_err_t scenario_mesh_init(scenario_mesh_apply_cb_t apply_cb) { + s_apply_cb = apply_cb; + + if (!s_send_done) s_send_done = xSemaphoreCreateBinary(); + if (!s_send_lock) s_send_lock = xSemaphoreCreateMutex(); + if (!s_reasm_lock) s_reasm_lock = xSemaphoreCreateMutex(); + if (!s_send_done || !s_send_lock || !s_reasm_lock) { + return ESP_ERR_NO_MEM; + } + + // Receiver path: spin up the apply worker (and its job queue) so completed + // reassemblies are applied off the Wi-Fi callback. A pure sender (master + // relay with no apply_cb) skips this to save RAM. + if (apply_cb && !s_apply_queue) { + s_apply_queue = xQueueCreate(2, sizeof(mesh_apply_job_t)); + if (!s_apply_queue) return ESP_ERR_NO_MEM; + BaseType_t ok = xTaskCreate(apply_worker_task, "scn_mesh_apply", + 4096, NULL, tskIDLE_PRIORITY + 2, NULL); + if (ok != pdPASS) { + vQueueDelete(s_apply_queue); + s_apply_queue = NULL; + return ESP_ERR_NO_MEM; + } + } + + // esp_now_init() requires Wi-Fi to be started already (the caller brings up + // STA/AP). A second init returns ESP_ERR_ESPNOW_INTERNAL on some IDF lines; + // since no other component in this tree owns ESP-NOW yet a hard failure is + // genuinely fatal, but we keep the relay endpoint optional at the call site. + esp_err_t err = esp_now_init(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_now_init: %s", esp_err_to_name(err)); + return err; + } + + esp_now_register_send_cb(on_sent); + esp_now_register_recv_cb(on_recv); + + // Broadcast peer (handy for future fan-out; unicast peers are added on + // demand by scenario_mesh_register_peer). + esp_now_peer_info_t bcast = {0}; + memcpy(bcast.peer_addr, kBroadcast, 6); + bcast.channel = 0; + bcast.ifidx = WIFI_IF_STA; + bcast.encrypt = false; + esp_err_t berr = esp_now_add_peer(&bcast); + if (berr != ESP_OK && berr != ESP_ERR_ESPNOW_EXIST) { + ESP_LOGW(TAG, "esp_now_add_peer(broadcast): %s", + esp_err_to_name(berr)); + } + + ESP_LOGI(TAG, "scenario_mesh ready (apply_cb=%s)", + apply_cb ? "set" : "none"); + return ESP_OK; +} + +// ─── send (chunk + per-frame ack) ─────────────────────────────────────────── + +esp_err_t scenario_mesh_send(const uint8_t dest_mac[6], + const char *data, size_t len) { + if (!dest_mac || !data || len == 0) return ESP_ERR_INVALID_ARG; + if (len > SCENARIO_MESH_MAX_BLOB) return ESP_ERR_INVALID_SIZE; + + size_t total = (len + SCENARIO_MESH_PAYLOAD_MAX - 1) / + SCENARIO_MESH_PAYLOAD_MAX; + if (total == 0 || total > 0xFFFF) return ESP_ERR_INVALID_SIZE; + + // Serialize: the single send-done semaphore is shared across frames. + if (xSemaphoreTake(s_send_lock, portMAX_DELAY) != pdTRUE) { + return ESP_FAIL; + } + + esp_err_t result = ESP_OK; + uint8_t frame[SCENARIO_MESH_FRAME_MAX]; + + for (size_t seq = 0; seq < total; seq++) { + size_t off = seq * SCENARIO_MESH_PAYLOAD_MAX; + size_t chunk = len - off; + if (chunk > SCENARIO_MESH_PAYLOAD_MAX) chunk = SCENARIO_MESH_PAYLOAD_MAX; + + frame[0] = (uint8_t) (seq & 0xFF); + frame[1] = (uint8_t) ((seq >> 8) & 0xFF); + frame[2] = (uint8_t) (total & 0xFF); + frame[3] = (uint8_t) ((total >> 8) & 0xFF); + memcpy(frame + SCENARIO_MESH_HEADER_BYTES, data + off, chunk); + + // Drain any stale ack from a previous frame, then send + await ack. + xSemaphoreTake(s_send_done, 0); + s_last_send_status = ESP_NOW_SEND_FAIL; + + esp_err_t serr = esp_now_send(dest_mac, frame, + SCENARIO_MESH_HEADER_BYTES + chunk); + if (serr != ESP_OK) { + ESP_LOGW(TAG, "esp_now_send frame %u/%u: %s", + (unsigned) seq, (unsigned) total, esp_err_to_name(serr)); + result = serr; + break; + } + + if (xSemaphoreTake(s_send_done, + pdMS_TO_TICKS(SCENARIO_MESH_ACK_TIMEOUT_MS)) + != pdTRUE) { + ESP_LOGW(TAG, "ack timeout on frame %u/%u", + (unsigned) seq, (unsigned) total); + result = ESP_ERR_TIMEOUT; + break; + } + if (s_last_send_status != ESP_NOW_SEND_SUCCESS) { + ESP_LOGW(TAG, "frame %u/%u not acked by peer", + (unsigned) seq, (unsigned) total); + result = ESP_ERR_TIMEOUT; // surfaced as a skipped peer + break; + } + } + + xSemaphoreGive(s_send_lock); + + if (result == ESP_OK) { + ESP_LOGI(TAG, "sent scenario: %u bytes in %u frames", + (unsigned) len, (unsigned) total); + } + return result; +} diff --git a/idf_zacus/main/CMakeLists.txt b/idf_zacus/main/CMakeLists.txt index 67233e0..4af2a99 100644 --- a/idf_zacus/main/CMakeLists.txt +++ b/idf_zacus/main/CMakeLists.txt @@ -12,6 +12,7 @@ idf_component_register( voice_pipeline voice_hook_endpoint game_endpoint + scenario_mesh nvs_flash esp_timer esp_system diff --git a/idf_zacus/main/main.c b/idf_zacus/main/main.c index aaddaa5..d0f21c7 100644 --- a/idf_zacus/main/main.c +++ b/idf_zacus/main/main.c @@ -46,6 +46,7 @@ #include "voice_dispatcher.h" #include "voice_hook_endpoint.h" #include "game_endpoint.h" +#include "scenario_mesh.h" // Hints engine endpoint (slice 5). Hardcoded for now — slice 7 will move // this to NVS so the field operator can repoint the firmware without a flash. @@ -156,6 +157,56 @@ static void list_littlefs_root(void) { ESP_LOGI(TAG, "LittleFS root contains %d entries", count); } +// ─── ESP-NOW relay peer registry seed ──────────────────────────────────────── +// +// The /game/scenario/relay endpoint resolves a peer alias (e.g. "box3", +// "plip", "p7_coffre") to a MAC via the scenario_mesh registry. Seed it from +// NVS namespace "peers": one entry per alias whose value is the 6-byte MAC +// blob (set with `idf.py nvs-partition-gen` or from the dashboard). This keeps +// MAC addresses out of the firmware image and lets the GM repoint a satellite +// board without a reflash. +// +// Provisioning example (nvs CSV): +// key,type,encoding,value +// peers,namespace,, +// box3,data,base64, +static void seed_relay_peers_from_nvs(void) { + nvs_iterator_t it = NULL; + esp_err_t err = nvs_entry_find("nvs", "peers", NVS_TYPE_BLOB, &it); + if (err != ESP_OK) { + ESP_LOGI(TAG, "no NVS 'peers' namespace — relay registry empty " + "(set MACs to enable /game/scenario/relay)"); + return; + } + + nvs_handle_t h; + if (nvs_open("peers", NVS_READONLY, &h) != ESP_OK) { + nvs_release_iterator(it); + return; + } + + int seeded = 0; + while (err == ESP_OK && it != NULL) { + nvs_entry_info_t info; + nvs_entry_info(it, &info); + + uint8_t mac[6]; + size_t len = sizeof(mac); + if (nvs_get_blob(h, info.key, mac, &len) == ESP_OK && len == 6) { + if (scenario_mesh_register_peer(info.key, mac) == ESP_OK) { + seeded++; + } + } else { + ESP_LOGW(TAG, "peers/%s: not a 6-byte MAC blob — skipped", + info.key); + } + err = nvs_entry_next(&it); + } + nvs_release_iterator(it); + nvs_close(h); + ESP_LOGI(TAG, "relay peer registry seeded: %d peer(s)", seeded); +} + // ─── Wi-Fi: NVS creds + event handler ──────────────────────────────────────── // Reads NVS namespace "wifi" keys "ssid" + "pwd". Returns ESP_OK if both @@ -392,6 +443,11 @@ void app_main(void) { if (game_err != ESP_OK) { ESP_LOGW(TAG, "game_endpoint_init: %s", esp_err_to_name(game_err)); + } else { + // game_endpoint_init brought up scenario_mesh (ESP-NOW). Seed the + // relay peer registry from NVS so /game/scenario/relay can resolve + // aliases to MACs without a reflash. + seed_relay_peers_from_nvs(); } } diff --git a/lib/espnow_common/espnow_slave.c b/lib/espnow_common/espnow_slave.c index 0caeac6..85905cf 100644 --- a/lib/espnow_common/espnow_slave.c +++ b/lib/espnow_common/espnow_slave.c @@ -3,10 +3,13 @@ #include "espnow_slave.h" #include "esp_now.h" #include "esp_wifi.h" +#include "esp_mac.h" // MACSTR / MAC2STR #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/queue.h" +#include "freertos/task.h" #include +#include static const char *TAG = "espnow_slave"; @@ -23,6 +26,111 @@ static uint8_t s_master_mac[6]; static uint8_t s_puzzle_id; static espnow_cmd_callback_t s_callback; +// --------------------------------------------------------------------------- +// Scenario hot-load reassembly (see espnow_slave.h). Demuxed inside +// espnow_slave_process(); runs in task context, never the Wi-Fi ISR. +// --------------------------------------------------------------------------- +#define SCENARIO_PAYLOAD_MAX 236 // 240 ESP-NOW max - 4 header +#define SCENARIO_MAX_BYTES (64 * 1024) // matches master cap +#define SCENARIO_MAX_FRAMES ((SCENARIO_MAX_BYTES + SCENARIO_PAYLOAD_MAX - 1) \ + / SCENARIO_PAYLOAD_MAX) +#define SCENARIO_TIMEOUT_MS 5000 + +typedef struct { + bool active; + uint8_t src[6]; + uint16_t total; // expected frame count + uint16_t count; // distinct frames received + size_t tail; // highest payload end offset = reassembled length + uint8_t *buf; // heap, total * SCENARIO_PAYLOAD_MAX + uint8_t *seen; // heap bitmap, (total + 7) / 8 bytes + uint32_t last_ms; +} scenario_rx_t; + +static scenario_rx_t s_scn; +static espnow_scenario_callback_t s_scn_cb; + +static inline uint32_t now_ms(void) +{ + return (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); +} + +static void scenario_reset(void) +{ + free(s_scn.buf); + free(s_scn.seen); + memset(&s_scn, 0, sizeof(s_scn)); +} + +// True if this frame belongs to the scenario stream: either a continuation of +// the active transfer (matching sender MAC), or the first frame of a new one +// (seq==0, total>=1). MSG_* commands (data[0] in 0x01..0x08) never match. +static bool scenario_is_frame(const recv_item_t *it) +{ + if (s_scn.active && memcmp(it->mac, s_scn.src, 6) == 0) + return true; + if (it->data_len >= 4 && it->data[0] == 0x00 && it->data[1] == 0x00) { + uint16_t total = (uint16_t)it->data[2] | ((uint16_t)it->data[3] << 8); + return total >= 1; + } + return false; +} + +static void scenario_feed(const recv_item_t *it) +{ + uint16_t seq = (uint16_t)it->data[0] | ((uint16_t)it->data[1] << 8); + uint16_t total = (uint16_t)it->data[2] | ((uint16_t)it->data[3] << 8); + const uint8_t *payload = it->data + 4; + int payload_len = it->data_len - 4; + if (payload_len < 0) return; + + // Begin (or restart from a new sender) on the first frame. + if (seq == 0 && (!s_scn.active || memcmp(it->mac, s_scn.src, 6) != 0)) { + scenario_reset(); + if (total == 0 || total > SCENARIO_MAX_FRAMES) { + ESP_LOGW(TAG, "scenario rx: bad/oversized total %u — dropped", total); + return; + } + s_scn.buf = calloc(total, SCENARIO_PAYLOAD_MAX); + s_scn.seen = calloc((total + 7) / 8, 1); + if (!s_scn.buf || !s_scn.seen) { + ESP_LOGE(TAG, "scenario rx: OOM for %u frames — dropped", total); + scenario_reset(); + return; + } + s_scn.active = true; + s_scn.total = total; + memcpy(s_scn.src, it->mac, 6); + } + + if (!s_scn.active) return; // stray non-first frame, no live session + if (total != s_scn.total) return; // inconsistent header, ignore frame + if (seq >= s_scn.total) return; + if (payload_len > SCENARIO_PAYLOAD_MAX) payload_len = SCENARIO_PAYLOAD_MAX; + + s_scn.last_ms = now_ms(); + + if (!(s_scn.seen[seq / 8] & (1u << (seq % 8)))) { + size_t off = (size_t)seq * SCENARIO_PAYLOAD_MAX; + memcpy(s_scn.buf + off, payload, (size_t)payload_len); + s_scn.seen[seq / 8] |= (uint8_t)(1u << (seq % 8)); + s_scn.count++; + size_t end = off + (size_t)payload_len; + if (end > s_scn.tail) s_scn.tail = end; + } + + if (s_scn.count == s_scn.total) { + ESP_LOGI(TAG, "scenario reassembled: %u frames, %u bytes", + s_scn.total, (unsigned)s_scn.tail); + if (s_scn_cb) { + s_scn_cb(s_scn.src, s_scn.buf, s_scn.tail); + } else { + ESP_LOGI(TAG, "no scenario consumer on this node — dropped"); + } + scenario_reset(); + } +} + // --------------------------------------------------------------------------- // Internal callbacks (called from Wi-Fi ISR context) // --------------------------------------------------------------------------- @@ -110,11 +218,27 @@ void espnow_slave_register_callback(espnow_cmd_callback_t cb) s_callback = cb; } +void espnow_slave_register_scenario_callback(espnow_scenario_callback_t cb) +{ + s_scn_cb = cb; +} + void espnow_slave_process(void) { + // Abandon a half-received scenario whose sender went silent. + if (s_scn.active && (now_ms() - s_scn.last_ms) > SCENARIO_TIMEOUT_MS) { + ESP_LOGW(TAG, "scenario rx timeout (%u/%u frames) — dropped", + s_scn.count, s_scn.total); + scenario_reset(); + } + recv_item_t item; while (xQueueReceive(s_recv_queue, &item, 0) == pdTRUE) { if (item.data_len < 1) continue; + if (scenario_is_frame(&item)) { + scenario_feed(&item); + continue; + } espnow_msg_type_t type = (espnow_msg_type_t)item.data[0]; if (s_callback) { s_callback(type, diff --git a/lib/espnow_common/espnow_slave.h b/lib/espnow_common/espnow_slave.h index b9a4bf6..7b38aff 100644 --- a/lib/espnow_common/espnow_slave.h +++ b/lib/espnow_common/espnow_slave.h @@ -75,5 +75,29 @@ typedef void (*espnow_cmd_callback_t)(espnow_msg_type_t type, size_t len); void espnow_slave_register_callback(espnow_cmd_callback_t cb); +// --------------------------------------------------------------------------- +// Scenario hot-load over ESP-NOW (Runtime 3 IR push) +// See docs/specs/2026-05-24-firmware-scenario-hotload.md (task 6, receiver). +// +// Puzzle nodes share this single ESP-NOW recv callback, so scenario frames are +// demultiplexed inside espnow_slave_process() rather than via a second +// esp_now_register_recv_cb() (ESP-IDF allows only one). Frame wire format +// (matches components/scenario_mesh): 4-byte header { seq:u16 LE, total:u16 LE } +// then <=236 payload bytes. The first frame has seq==0; a transfer is tracked +// per source MAC until `total` frames arrive, then reassembled. +// +// Discriminator: the first frame's data[0..1] == 0x0000, which never collides +// with a MSG_* type (0x01..0x08); continuation frames are routed by matching +// the active sender MAC. This keeps a stray/misrouted scenario relay from being +// misread as a puzzle command (e.g. a seq==1 frame whose data[0]==0x01 would +// otherwise look like MSG_PUZZLE_SOLVED). +// +// Most puzzle nodes have no Runtime 3 scenario engine, so they register no +// callback: a reassembled scenario is then logged and dropped. A node that does +// consume scenarios (e.g. a future p7_coffre revision) opts in via this hook. +typedef void (*espnow_scenario_callback_t)(const uint8_t src_mac[6], + const uint8_t *json, size_t len); +void espnow_slave_register_scenario_callback(espnow_scenario_callback_t cb); + // Process inbound ESP-NOW queue — call from main FreeRTOS task loop. void espnow_slave_process(void); diff --git a/puzzles/p1_sequence_sonore/main/CMakeLists.txt b/puzzles/p1_sequence_sonore/main/CMakeLists.txt index 2f9cd6d..f18ce34 100644 --- a/puzzles/p1_sequence_sonore/main/CMakeLists.txt +++ b/puzzles/p1_sequence_sonore/main/CMakeLists.txt @@ -9,7 +9,6 @@ idf_component_register( freertos driver esp_wifi - esp_now nvs_flash led_strip esp_common diff --git a/puzzles/p5_morse/main/CMakeLists.txt b/puzzles/p5_morse/main/CMakeLists.txt index 7f86900..e87139b 100644 --- a/puzzles/p5_morse/main/CMakeLists.txt +++ b/puzzles/p5_morse/main/CMakeLists.txt @@ -9,7 +9,6 @@ idf_component_register( freertos driver esp_wifi - esp_now nvs_flash esp_common ) diff --git a/puzzles/p6_symboles_nfc/main/CMakeLists.txt b/puzzles/p6_symboles_nfc/main/CMakeLists.txt index 7f86900..e87139b 100644 --- a/puzzles/p6_symboles_nfc/main/CMakeLists.txt +++ b/puzzles/p6_symboles_nfc/main/CMakeLists.txt @@ -9,7 +9,6 @@ idf_component_register( freertos driver esp_wifi - esp_now nvs_flash esp_common ) diff --git a/puzzles/p7_coffre/main/CMakeLists.txt b/puzzles/p7_coffre/main/CMakeLists.txt index 7f86900..e87139b 100644 --- a/puzzles/p7_coffre/main/CMakeLists.txt +++ b/puzzles/p7_coffre/main/CMakeLists.txt @@ -9,7 +9,6 @@ idf_component_register( freertos driver esp_wifi - esp_now nvs_flash esp_common )