# 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 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`) — 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 shared `espnow_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 optional `scenario_now_register_callback()` hook lets a future Runtime 3 engine consume it. With no consumer registered the scenario is stored and logged. - `network_task.cpp` calls `scenario_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`.