The component was vendored byte-identical in idf_zacus/components/ and box3_voice/components/ (documented drift risk). Hoist the single copy to lib/scenario_mesh, referenced from both projects via EXTRA_COMPONENT_DIRS in their root CMakeLists. The two deliberate frame-format reimplementations (puzzle demux in espnow_common, PLIP's Arduino scenario_now) are now called out in the receiver-patch doc. All 3 firmwares rebuilt green (p7_coffre, idf_zacus, box3_voice).
7.5 KiB
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 done (brought back
into scope on 2026-06-10 — dedicated receiver src/scenario_now.{h,cpp} in
PLIP_FIRMWARE, 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 viaMSG_PUZZLE_CONFIG(8 bytes), not an IR; the others are driven entirely byMSG_*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 frameseqlow 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:
components/scenario_mesh/— a vendored copy of the master's component (frame protocol + reassembly + alias→MAC table). Identical bytes toidf_zacus/components/scenario_mesh/.scenario_server.crefactored so the validate+write path is a reusablescenario_apply_buffer(const char *data, size_t len)(declared inscenario_server.h). Both the HTTPPOST /game/scenariohandler and the ESP-NOW receiver call it — the single_scenario_applythe spec mandates.main.ccallsscenario_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 aMSG_*type in0x01..0x08(seeespnow_slave.h). - scenario_mesh frames:
data[0..1]=seq(u16 LE),data[2..3]=total(u16 LE), then payload. For the first frameseq==0sodata[0]==0x00, which never collides with aMSG_*type. A scenario frame is also always>= 4bytes withtotal >= 1andseq < 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).
-
Add the reassembler. Either:
- vendor
scenario_meshas a component but do not let it register the recv cb (add ascenario_mesh_feed_frame(const uint8_t *src, const uint8_t *data, int len)entry point and ascenario_mesh_init_passive()that skipsesp_now_register_recv_cb), or - inline a ~60-LOC reassembler keyed by
(src_mac, total)straight intoespnow_slave.c(simplest; no new component).
- vendor
-
In
espnow_slave.c::on_recv, branch before the queue push: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 ofscenario_apply_buffer()+ deferredesp_restart(), mirroring box3). -
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) — done (re-scoped 2026-06-10)
Originally resolved out of scope on 2026-06-09 (Wi-Fi/HTTP-only client, no
ESP-NOW stack). Re-scoped in on 2026-06-10 by explicit request: PLIP now has a
dedicated receiver, PLIP_FIRMWARE/src/scenario_now.{h,cpp} (Arduino C++,
matching the zacus_hook_client worker-task pattern).
- Same wire format as
scenario_mesh/ the sharedespnow_slave.c: 4-byte header{ seq:u16 LE, total:u16 LE }, <=236 payload bytes, bounded heap reassembly (<=64 KiB), 5 s sender-silence timeout. - PLIP registers no other
esp_now_register_recv_cb, so the module owns the single callback — no MSG_* demux needed (unlike the puzzle nodes). - A completed scenario is persisted to LittleFS at
/scenario.json(temp-then-rename); an optionalscenario_now_register_callback()hook lets a future Runtime 3 engine consume it. With no consumer registered the scenario is stored and logged. network_task.cppcallsscenario_now_init()once the station is up (ESP-NOW rides the AP's channel); the call is idempotent and repeated on reconnect.
The HTTP path (POST /game/scenario on PLIP's future REST server) remains the
recommended push channel once that server lands; the ESP-NOW receiver covers
the relay/fallback case in the meantime.
Shared-protocol drift risk — resolved 2026-06-10
scenario_mesh was vendored byte-identical in two places
(idf_zacus/components/ and box3_voice/components/). It now lives once
at lib/scenario_mesh, referenced by both projects via
EXTRA_COMPONENT_DIRS in their root CMakeLists — the follow-up suggested
below is done; both firmwares rebuilt green after the hoist.
Two independent reimplementations of the frame format remain by design
(different runtimes, not copies): the puzzle nodes' demux inside the shared
lib/espnow_common/espnow_slave.c, and PLIP's Arduino-side
PLIP_FIRMWARE/src/scenario_now.cpp. If the 4-byte header
{ seq:u16 LE, total:u16 LE } or the 236-byte payload cap ever changes,
update those two alongside lib/scenario_mesh.