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
+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);