Files
ESP32_ZACUS/docs/scenario-mesh-receiver-patch.md
Claude Worker claude2 13bbb56244 refactor: single scenario_mesh in shared lib/
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).
2026-06-10 10:50:15 +02:00

150 lines
7.5 KiB
Markdown

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