feat(scenario-mesh): ESP-NOW scenario hot-load receivers + green builds
CI / platformio (pull_request) Failing after 6m18s
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:
@@ -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;
|
||||
}
|
||||
@@ -10,4 +10,5 @@ idf_component_register(
|
||||
json
|
||||
nvs_flash
|
||||
spiffs
|
||||
scenario_mesh
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ idf_component_register(
|
||||
voice_pipeline
|
||||
voice_hook_endpoint
|
||||
game_endpoint
|
||||
scenario_mesh
|
||||
nvs_flash
|
||||
esp_timer
|
||||
esp_system
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
#include "espnow_slave.h"
|
||||
#include "esp_now.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_mac.h" // MACSTR / MAC2STR
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/task.h"
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
static const char *TAG = "espnow_slave";
|
||||
|
||||
@@ -23,6 +26,111 @@ static uint8_t s_master_mac[6];
|
||||
static uint8_t s_puzzle_id;
|
||||
static espnow_cmd_callback_t s_callback;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario hot-load reassembly (see espnow_slave.h). Demuxed inside
|
||||
// espnow_slave_process(); runs in task context, never the Wi-Fi ISR.
|
||||
// ---------------------------------------------------------------------------
|
||||
#define SCENARIO_PAYLOAD_MAX 236 // 240 ESP-NOW max - 4 header
|
||||
#define SCENARIO_MAX_BYTES (64 * 1024) // matches master cap
|
||||
#define SCENARIO_MAX_FRAMES ((SCENARIO_MAX_BYTES + SCENARIO_PAYLOAD_MAX - 1) \
|
||||
/ SCENARIO_PAYLOAD_MAX)
|
||||
#define SCENARIO_TIMEOUT_MS 5000
|
||||
|
||||
typedef struct {
|
||||
bool active;
|
||||
uint8_t src[6];
|
||||
uint16_t total; // expected frame count
|
||||
uint16_t count; // distinct frames received
|
||||
size_t tail; // highest payload end offset = reassembled length
|
||||
uint8_t *buf; // heap, total * SCENARIO_PAYLOAD_MAX
|
||||
uint8_t *seen; // heap bitmap, (total + 7) / 8 bytes
|
||||
uint32_t last_ms;
|
||||
} scenario_rx_t;
|
||||
|
||||
static scenario_rx_t s_scn;
|
||||
static espnow_scenario_callback_t s_scn_cb;
|
||||
|
||||
static inline uint32_t now_ms(void)
|
||||
{
|
||||
return (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
|
||||
}
|
||||
|
||||
static void scenario_reset(void)
|
||||
{
|
||||
free(s_scn.buf);
|
||||
free(s_scn.seen);
|
||||
memset(&s_scn, 0, sizeof(s_scn));
|
||||
}
|
||||
|
||||
// True if this frame belongs to the scenario stream: either a continuation of
|
||||
// the active transfer (matching sender MAC), or the first frame of a new one
|
||||
// (seq==0, total>=1). MSG_* commands (data[0] in 0x01..0x08) never match.
|
||||
static bool scenario_is_frame(const recv_item_t *it)
|
||||
{
|
||||
if (s_scn.active && memcmp(it->mac, s_scn.src, 6) == 0)
|
||||
return true;
|
||||
if (it->data_len >= 4 && it->data[0] == 0x00 && it->data[1] == 0x00) {
|
||||
uint16_t total = (uint16_t)it->data[2] | ((uint16_t)it->data[3] << 8);
|
||||
return total >= 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static void scenario_feed(const recv_item_t *it)
|
||||
{
|
||||
uint16_t seq = (uint16_t)it->data[0] | ((uint16_t)it->data[1] << 8);
|
||||
uint16_t total = (uint16_t)it->data[2] | ((uint16_t)it->data[3] << 8);
|
||||
const uint8_t *payload = it->data + 4;
|
||||
int payload_len = it->data_len - 4;
|
||||
if (payload_len < 0) return;
|
||||
|
||||
// Begin (or restart from a new sender) on the first frame.
|
||||
if (seq == 0 && (!s_scn.active || memcmp(it->mac, s_scn.src, 6) != 0)) {
|
||||
scenario_reset();
|
||||
if (total == 0 || total > SCENARIO_MAX_FRAMES) {
|
||||
ESP_LOGW(TAG, "scenario rx: bad/oversized total %u — dropped", total);
|
||||
return;
|
||||
}
|
||||
s_scn.buf = calloc(total, SCENARIO_PAYLOAD_MAX);
|
||||
s_scn.seen = calloc((total + 7) / 8, 1);
|
||||
if (!s_scn.buf || !s_scn.seen) {
|
||||
ESP_LOGE(TAG, "scenario rx: OOM for %u frames — dropped", total);
|
||||
scenario_reset();
|
||||
return;
|
||||
}
|
||||
s_scn.active = true;
|
||||
s_scn.total = total;
|
||||
memcpy(s_scn.src, it->mac, 6);
|
||||
}
|
||||
|
||||
if (!s_scn.active) return; // stray non-first frame, no live session
|
||||
if (total != s_scn.total) return; // inconsistent header, ignore frame
|
||||
if (seq >= s_scn.total) return;
|
||||
if (payload_len > SCENARIO_PAYLOAD_MAX) payload_len = SCENARIO_PAYLOAD_MAX;
|
||||
|
||||
s_scn.last_ms = now_ms();
|
||||
|
||||
if (!(s_scn.seen[seq / 8] & (1u << (seq % 8)))) {
|
||||
size_t off = (size_t)seq * SCENARIO_PAYLOAD_MAX;
|
||||
memcpy(s_scn.buf + off, payload, (size_t)payload_len);
|
||||
s_scn.seen[seq / 8] |= (uint8_t)(1u << (seq % 8));
|
||||
s_scn.count++;
|
||||
size_t end = off + (size_t)payload_len;
|
||||
if (end > s_scn.tail) s_scn.tail = end;
|
||||
}
|
||||
|
||||
if (s_scn.count == s_scn.total) {
|
||||
ESP_LOGI(TAG, "scenario reassembled: %u frames, %u bytes",
|
||||
s_scn.total, (unsigned)s_scn.tail);
|
||||
if (s_scn_cb) {
|
||||
s_scn_cb(s_scn.src, s_scn.buf, s_scn.tail);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "no scenario consumer on this node — dropped");
|
||||
}
|
||||
scenario_reset();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal callbacks (called from Wi-Fi ISR context)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -110,11 +218,27 @@ void espnow_slave_register_callback(espnow_cmd_callback_t cb)
|
||||
s_callback = cb;
|
||||
}
|
||||
|
||||
void espnow_slave_register_scenario_callback(espnow_scenario_callback_t cb)
|
||||
{
|
||||
s_scn_cb = cb;
|
||||
}
|
||||
|
||||
void espnow_slave_process(void)
|
||||
{
|
||||
// Abandon a half-received scenario whose sender went silent.
|
||||
if (s_scn.active && (now_ms() - s_scn.last_ms) > SCENARIO_TIMEOUT_MS) {
|
||||
ESP_LOGW(TAG, "scenario rx timeout (%u/%u frames) — dropped",
|
||||
s_scn.count, s_scn.total);
|
||||
scenario_reset();
|
||||
}
|
||||
|
||||
recv_item_t item;
|
||||
while (xQueueReceive(s_recv_queue, &item, 0) == pdTRUE) {
|
||||
if (item.data_len < 1) continue;
|
||||
if (scenario_is_frame(&item)) {
|
||||
scenario_feed(&item);
|
||||
continue;
|
||||
}
|
||||
espnow_msg_type_t type = (espnow_msg_type_t)item.data[0];
|
||||
if (s_callback) {
|
||||
s_callback(type,
|
||||
|
||||
@@ -75,5 +75,29 @@ typedef void (*espnow_cmd_callback_t)(espnow_msg_type_t type,
|
||||
size_t len);
|
||||
void espnow_slave_register_callback(espnow_cmd_callback_t cb);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario hot-load over ESP-NOW (Runtime 3 IR push)
|
||||
// See docs/specs/2026-05-24-firmware-scenario-hotload.md (task 6, receiver).
|
||||
//
|
||||
// Puzzle nodes share this single ESP-NOW recv callback, so scenario frames are
|
||||
// demultiplexed inside espnow_slave_process() rather than via a second
|
||||
// esp_now_register_recv_cb() (ESP-IDF allows only one). Frame wire format
|
||||
// (matches components/scenario_mesh): 4-byte header { seq:u16 LE, total:u16 LE }
|
||||
// then <=236 payload bytes. The first frame has seq==0; a transfer is tracked
|
||||
// per source MAC until `total` frames arrive, then reassembled.
|
||||
//
|
||||
// Discriminator: the first frame's data[0..1] == 0x0000, which never collides
|
||||
// with a MSG_* type (0x01..0x08); continuation frames are routed by matching
|
||||
// the active sender MAC. This keeps a stray/misrouted scenario relay from being
|
||||
// misread as a puzzle command (e.g. a seq==1 frame whose data[0]==0x01 would
|
||||
// otherwise look like MSG_PUZZLE_SOLVED).
|
||||
//
|
||||
// Most puzzle nodes have no Runtime 3 scenario engine, so they register no
|
||||
// callback: a reassembled scenario is then logged and dropped. A node that does
|
||||
// consume scenarios (e.g. a future p7_coffre revision) opts in via this hook.
|
||||
typedef void (*espnow_scenario_callback_t)(const uint8_t src_mac[6],
|
||||
const uint8_t *json, size_t len);
|
||||
void espnow_slave_register_scenario_callback(espnow_scenario_callback_t cb);
|
||||
|
||||
// Process inbound ESP-NOW queue — call from main FreeRTOS task loop.
|
||||
void espnow_slave_process(void);
|
||||
|
||||
@@ -9,7 +9,6 @@ idf_component_register(
|
||||
freertos
|
||||
driver
|
||||
esp_wifi
|
||||
esp_now
|
||||
nvs_flash
|
||||
led_strip
|
||||
esp_common
|
||||
|
||||
@@ -9,7 +9,6 @@ idf_component_register(
|
||||
freertos
|
||||
driver
|
||||
esp_wifi
|
||||
esp_now
|
||||
nvs_flash
|
||||
esp_common
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ idf_component_register(
|
||||
freertos
|
||||
driver
|
||||
esp_wifi
|
||||
esp_now
|
||||
nvs_flash
|
||||
esp_common
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ idf_component_register(
|
||||
freertos
|
||||
driver
|
||||
esp_wifi
|
||||
esp_now
|
||||
nvs_flash
|
||||
esp_common
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user