# 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`.
