feat(scenario-mesh): ESP-NOW scenario hot-load receivers + green builds
CI / platformio (pull_request) Failing after 6m18s

- scenario_mesh: ESP-NOW frame protocol component (master + box3_voice):
  chunking, per-source reassembly, deferred apply off the Wi-Fi callback.
- game_endpoint: POST /game/scenario/relay (master) + shared scenario_apply.
- box3_voice: scenario receiver wiring (scenario_mesh_init).
- espnow_slave (shared by all puzzles): demux scenario frames inside the single
  recv callback so a misrouted relay can't corrupt the MSG_* stream; reassemble
  per source MAC; optional consumer hook (logs+drops by default, puzzles have no
  scenario engine). Add missing esp_mac.h include for MACSTR/MAC2STR.
- CMakeLists (scenario_mesh + 4 puzzle mains): drop invalid `esp_now` REQUIRES;
  the ESP-NOW API lives in esp_wifi. Fixes "Failed to resolve component esp_now".
- docs: scenario-mesh receiver patch note (puzzles done via defensive demux;
  PLIP out of scope — Wi-Fi/HTTP-only, no ESP-NOW stack).

Builds green under ESP-IDF 5.4.4: idf_zacus, box3_voice, p7_coffre.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Worker claude2
2026-06-09 04:11:28 +02:00
parent d220d94607
commit b2267f2261
21 changed files with 1719 additions and 139 deletions
@@ -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
View File
@@ -10,4 +10,5 @@ idf_component_register(
json
nvs_flash
spiffs
scenario_mesh
)
+14
View File
@@ -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
+101 -54
View File
@@ -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 ----------
+10
View File
@@ -2,6 +2,8 @@
// POST /game/scenario (Runtime 3 IR hot-load via reboot).
#pragma once
#include <stddef.h>
#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
+131
View File
@@ -0,0 +1,131 @@
# ESP-NOW scenario receiver — patch to report onto PLIP + puzzles
Status: **box3_voice done**; **puzzle nodes done** (defensive demux in the
shared `espnow_slave.c`, see "Resolution" below); **PLIP out of scope by
design** (Wi-Fi/HTTP-only client, no ESP-NOW stack — see "PLIP" below).
Spec: `docs/specs/2026-05-24-firmware-scenario-hotload.md`, task 6
("Receiver side on each peer").
## Resolution (2026-06-09)
Investigation of the real firmware changed the plan from "vendor a reassembler
into each node" to "**demux defensively, treat as no-op consumer**", because:
- **No puzzle node runs a Runtime 3 scenario.** `p7_coffre` (the final lock)
receives its 8-digit code via `MSG_PUZZLE_CONFIG` (8 bytes), not an IR; the
others are driven entirely by `MSG_*` commands. None has a LittleFS/SPIFFS
scenario store. Pushing a full scenario to them is genuinely a no-op.
- The real risk is **stream corruption**, not a missing feature: a multi-frame
scenario relay misrouted to a puzzle MAC would inject frames whose `data[0]`
equals the frame `seq` low byte — e.g. `seq==1 → 0x01 == MSG_PUZZLE_SOLVED`.
So the receiver was added **once** to the shared `lib/espnow_common/espnow_slave.c`
(compiled into all four puzzles): frames are demultiplexed in
`espnow_slave_process()` (task context, not the ISR), reassembled per source MAC,
and handed to an **optional** `espnow_scenario_callback_t`. Puzzle nodes register
no callback, so a reassembled scenario is logged and dropped — and, critically,
never reaches the `MSG_*` path. A future node that does consume scenarios opts in
via `espnow_slave_register_scenario_callback()`. No per-puzzle code changed; no
new component; bounded heap reassembly (≤64 KiB), 5 s sender-silence timeout.
The original per-node inline-reassembler sketch below is kept for historical
context; the shared-file approach above supersedes it.
## What box3_voice got (the reference implementation)
box3_voice is a standalone IDF project that does **not** use the legacy
ESP-NOW slave. It received:
1. `components/scenario_mesh/` — a vendored copy of the master's component
(frame protocol + reassembly + alias→MAC table). Identical bytes to
`idf_zacus/components/scenario_mesh/`.
2. `scenario_server.c` refactored so the validate+write path is a reusable
`scenario_apply_buffer(const char *data, size_t len)` (declared in
`scenario_server.h`). Both the HTTP `POST /game/scenario` handler and the
ESP-NOW receiver call it — the single `_scenario_apply` the spec mandates.
3. `main.c` calls `scenario_mesh_init(scenario_apply_buffer)` after Wi-Fi /
`scenario_server_start()`.
box3 could take the component wholesale because it owns its ESP-NOW stack — it
does **not** register any other `esp_now_register_recv_cb`.
## Why PLIP + puzzles can't just drop the component in
The puzzle nodes already own the single ESP-NOW receive callback via
`lib/espnow_common/espnow_slave.c` (`esp_now_register_recv_cb(on_recv)`).
ESP-IDF allows **one** recv callback per process. `scenario_mesh_init()` calls
`esp_now_register_recv_cb()` too, so calling it after `espnow_slave_init()`
would silently steal the puzzle command stream (or vice-versa). The two
protocols must be **demultiplexed inside the one existing callback**.
Frame discriminator (no wire-format change needed):
- Legacy puzzle frames: `data[0]` is a `MSG_*` type in `0x01..0x08`
(see `espnow_slave.h`).
- scenario_mesh frames: `data[0..1]` = `seq` (u16 LE), `data[2..3]` = `total`
(u16 LE), then payload. For the first frame `seq==0` so `data[0]==0x00`,
which never collides with a `MSG_*` type. A scenario frame is also always
`>= 4` bytes with `total >= 1` and `seq < total`.
So: **`data[0] == 0x00` (and `len >= 4`) ⇒ scenario frame; otherwise legacy.**
## Patch for each puzzle node (`p1`, `p5`, `p6`, `p7_coffre`)
These share `lib/espnow_common/espnow_slave.c`, so patch it **once** there
(it is compiled into each puzzle via the `../../../lib/espnow_common/espnow_slave.c`
SRC entry already present in every puzzle `main/CMakeLists.txt`).
1. Add the reassembler. Either:
- vendor `scenario_mesh` as a component **but do not let it register the
recv cb** (add a `scenario_mesh_feed_frame(const uint8_t *src, const
uint8_t *data, int len)` entry point and a `scenario_mesh_init_passive()`
that skips `esp_now_register_recv_cb`), or
- inline a ~60-LOC reassembler keyed by `(src_mac, total)` straight into
`espnow_slave.c` (simplest; no new component).
2. In `espnow_slave.c::on_recv`, branch before the queue push:
```c
static void on_recv(const esp_now_recv_info_t *info,
const uint8_t *data, int len) {
if (len >= 4 && data[0] == 0x00) { // scenario frame
scenario_mesh_feed_frame(info->src_addr, data, len);
return;
}
/* …existing puzzle-command path (queue push)… */
}
```
On full reassembly the reassembler writes the JSON to the node's local
filesystem and calls `scenario_engine_reload()` (or, until that symbol
lands, the puzzle's equivalent of `scenario_apply_buffer()` +
deferred `esp_restart()`, mirroring box3).
3. Each puzzle needs a `scenario_apply_buffer()` equivalent. The puzzle nodes
currently have no LittleFS/SPIFFS scenario store — if a node is purely
driven by ESP-NOW puzzle commands and has no IR of its own, task 6 may be a
no-op for it. Confirm per node before adding storage: `p7_coffre` (the
final-code lock) is the most likely to actually consume a scenario.
## PLIP (`PLIP_FIRMWARE`) — out of scope by design
Resolved 2026-06-09: **PLIP needs no ESP-NOW scenario receiver.** It is its own
Arduino/PlatformIO tree (`/PLIP_FIRMWARE`, C++), connects to the master purely
over **Wi-Fi + HTTP** (`network_task.cpp` → `WiFi.begin` / mDNS, `zacus_hook_client`
REST), and registers **no** `esp_now_register_recv_cb` at all. The ESP-NOW relay
exists only as a fallback for boards that run with Wi-Fi disabled (battery / RF
noise); PLIP is mains-powered and always on Wi-Fi.
If PLIP ever needs hot scenario push, the correct path is the master/box3 HTTP
one — `POST /game/scenario` over its existing Wi-Fi link — **not** ESP-NOW. No
code change made.
## Shared-protocol drift risk
The vendored `scenario_mesh` now lives in two places
(`idf_zacus/components/scenario_mesh` and
`box3_voice/components/scenario_mesh`). They are byte-identical today. If the
frame header or `SCENARIO_MESH_*` constants ever change, update **both** (and
any future PLIP/puzzle copy). A follow-up could hoist the component to a shared
`lib/` path referenced via `EXTRA_COMPONENT_DIRS` to remove the duplication.
@@ -9,6 +9,7 @@ idf_component_register(
nvs_flash
hints_client
ota_server
scenario_mesh
freertos
log
joltwallet__littlefs
@@ -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": { <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;
}
@@ -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;
}
@@ -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
View File
@@ -12,6 +12,7 @@ idf_component_register(
voice_pipeline
voice_hook_endpoint
game_endpoint
scenario_mesh
nvs_flash
esp_timer
esp_system
+56
View File
@@ -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,<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
@@ -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();
}
}
+124
View File
@@ -3,10 +3,13 @@
#include "espnow_slave.h"
#include "esp_now.h"
#include "esp_wifi.h"
#include "esp_mac.h" // MACSTR / MAC2STR
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"
#include <string.h>
#include <stdlib.h>
static const char *TAG = "espnow_slave";
@@ -23,6 +26,111 @@ static uint8_t s_master_mac[6];
static uint8_t s_puzzle_id;
static espnow_cmd_callback_t s_callback;
// ---------------------------------------------------------------------------
// Scenario hot-load reassembly (see espnow_slave.h). Demuxed inside
// espnow_slave_process(); runs in task context, never the Wi-Fi ISR.
// ---------------------------------------------------------------------------
#define SCENARIO_PAYLOAD_MAX 236 // 240 ESP-NOW max - 4 header
#define SCENARIO_MAX_BYTES (64 * 1024) // matches master cap
#define SCENARIO_MAX_FRAMES ((SCENARIO_MAX_BYTES + SCENARIO_PAYLOAD_MAX - 1) \
/ SCENARIO_PAYLOAD_MAX)
#define SCENARIO_TIMEOUT_MS 5000
typedef struct {
bool active;
uint8_t src[6];
uint16_t total; // expected frame count
uint16_t count; // distinct frames received
size_t tail; // highest payload end offset = reassembled length
uint8_t *buf; // heap, total * SCENARIO_PAYLOAD_MAX
uint8_t *seen; // heap bitmap, (total + 7) / 8 bytes
uint32_t last_ms;
} scenario_rx_t;
static scenario_rx_t s_scn;
static espnow_scenario_callback_t s_scn_cb;
static inline uint32_t now_ms(void)
{
return (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
}
static void scenario_reset(void)
{
free(s_scn.buf);
free(s_scn.seen);
memset(&s_scn, 0, sizeof(s_scn));
}
// True if this frame belongs to the scenario stream: either a continuation of
// the active transfer (matching sender MAC), or the first frame of a new one
// (seq==0, total>=1). MSG_* commands (data[0] in 0x01..0x08) never match.
static bool scenario_is_frame(const recv_item_t *it)
{
if (s_scn.active && memcmp(it->mac, s_scn.src, 6) == 0)
return true;
if (it->data_len >= 4 && it->data[0] == 0x00 && it->data[1] == 0x00) {
uint16_t total = (uint16_t)it->data[2] | ((uint16_t)it->data[3] << 8);
return total >= 1;
}
return false;
}
static void scenario_feed(const recv_item_t *it)
{
uint16_t seq = (uint16_t)it->data[0] | ((uint16_t)it->data[1] << 8);
uint16_t total = (uint16_t)it->data[2] | ((uint16_t)it->data[3] << 8);
const uint8_t *payload = it->data + 4;
int payload_len = it->data_len - 4;
if (payload_len < 0) return;
// Begin (or restart from a new sender) on the first frame.
if (seq == 0 && (!s_scn.active || memcmp(it->mac, s_scn.src, 6) != 0)) {
scenario_reset();
if (total == 0 || total > SCENARIO_MAX_FRAMES) {
ESP_LOGW(TAG, "scenario rx: bad/oversized total %u — dropped", total);
return;
}
s_scn.buf = calloc(total, SCENARIO_PAYLOAD_MAX);
s_scn.seen = calloc((total + 7) / 8, 1);
if (!s_scn.buf || !s_scn.seen) {
ESP_LOGE(TAG, "scenario rx: OOM for %u frames — dropped", total);
scenario_reset();
return;
}
s_scn.active = true;
s_scn.total = total;
memcpy(s_scn.src, it->mac, 6);
}
if (!s_scn.active) return; // stray non-first frame, no live session
if (total != s_scn.total) return; // inconsistent header, ignore frame
if (seq >= s_scn.total) return;
if (payload_len > SCENARIO_PAYLOAD_MAX) payload_len = SCENARIO_PAYLOAD_MAX;
s_scn.last_ms = now_ms();
if (!(s_scn.seen[seq / 8] & (1u << (seq % 8)))) {
size_t off = (size_t)seq * SCENARIO_PAYLOAD_MAX;
memcpy(s_scn.buf + off, payload, (size_t)payload_len);
s_scn.seen[seq / 8] |= (uint8_t)(1u << (seq % 8));
s_scn.count++;
size_t end = off + (size_t)payload_len;
if (end > s_scn.tail) s_scn.tail = end;
}
if (s_scn.count == s_scn.total) {
ESP_LOGI(TAG, "scenario reassembled: %u frames, %u bytes",
s_scn.total, (unsigned)s_scn.tail);
if (s_scn_cb) {
s_scn_cb(s_scn.src, s_scn.buf, s_scn.tail);
} else {
ESP_LOGI(TAG, "no scenario consumer on this node — dropped");
}
scenario_reset();
}
}
// ---------------------------------------------------------------------------
// Internal callbacks (called from Wi-Fi ISR context)
// ---------------------------------------------------------------------------
@@ -110,11 +218,27 @@ void espnow_slave_register_callback(espnow_cmd_callback_t cb)
s_callback = cb;
}
void espnow_slave_register_scenario_callback(espnow_scenario_callback_t cb)
{
s_scn_cb = cb;
}
void espnow_slave_process(void)
{
// Abandon a half-received scenario whose sender went silent.
if (s_scn.active && (now_ms() - s_scn.last_ms) > SCENARIO_TIMEOUT_MS) {
ESP_LOGW(TAG, "scenario rx timeout (%u/%u frames) — dropped",
s_scn.count, s_scn.total);
scenario_reset();
}
recv_item_t item;
while (xQueueReceive(s_recv_queue, &item, 0) == pdTRUE) {
if (item.data_len < 1) continue;
if (scenario_is_frame(&item)) {
scenario_feed(&item);
continue;
}
espnow_msg_type_t type = (espnow_msg_type_t)item.data[0];
if (s_callback) {
s_callback(type,
+24
View File
@@ -75,5 +75,29 @@ typedef void (*espnow_cmd_callback_t)(espnow_msg_type_t type,
size_t len);
void espnow_slave_register_callback(espnow_cmd_callback_t cb);
// ---------------------------------------------------------------------------
// Scenario hot-load over ESP-NOW (Runtime 3 IR push)
// See docs/specs/2026-05-24-firmware-scenario-hotload.md (task 6, receiver).
//
// Puzzle nodes share this single ESP-NOW recv callback, so scenario frames are
// demultiplexed inside espnow_slave_process() rather than via a second
// esp_now_register_recv_cb() (ESP-IDF allows only one). Frame wire format
// (matches components/scenario_mesh): 4-byte header { seq:u16 LE, total:u16 LE }
// then <=236 payload bytes. The first frame has seq==0; a transfer is tracked
// per source MAC until `total` frames arrive, then reassembled.
//
// Discriminator: the first frame's data[0..1] == 0x0000, which never collides
// with a MSG_* type (0x01..0x08); continuation frames are routed by matching
// the active sender MAC. This keeps a stray/misrouted scenario relay from being
// misread as a puzzle command (e.g. a seq==1 frame whose data[0]==0x01 would
// otherwise look like MSG_PUZZLE_SOLVED).
//
// Most puzzle nodes have no Runtime 3 scenario engine, so they register no
// callback: a reassembled scenario is then logged and dropped. A node that does
// consume scenarios (e.g. a future p7_coffre revision) opts in via this hook.
typedef void (*espnow_scenario_callback_t)(const uint8_t src_mac[6],
const uint8_t *json, size_t len);
void espnow_slave_register_scenario_callback(espnow_scenario_callback_t cb);
// Process inbound ESP-NOW queue — call from main FreeRTOS task loop.
void espnow_slave_process(void);
@@ -9,7 +9,6 @@ idf_component_register(
freertos
driver
esp_wifi
esp_now
nvs_flash
led_strip
esp_common
-1
View File
@@ -9,7 +9,6 @@ idf_component_register(
freertos
driver
esp_wifi
esp_now
nvs_flash
esp_common
)
@@ -9,7 +9,6 @@ idf_component_register(
freertos
driver
esp_wifi
esp_now
nvs_flash
esp_common
)
-1
View File
@@ -9,7 +9,6 @@ idf_component_register(
freertos
driver
esp_wifi
esp_now
nvs_flash
esp_common
)