13bbb56244
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).
150 lines
7.5 KiB
Markdown
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`.
|