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

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 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:

    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.