diff --git a/PLIP_FIRMWARE/CLAUDE.md b/PLIP_FIRMWARE/CLAUDE.md index f87adcf..3b8a223 100644 --- a/PLIP_FIRMWARE/CLAUDE.md +++ b/PLIP_FIRMWARE/CLAUDE.md @@ -23,6 +23,7 @@ pio device monitor # serial @ 115200 | `src/phone_task.cpp` | Off-hook GPIO interrupt + ring control | | `src/audio_task.cpp` | ES8388 / Si3210 audio routing, drains a command queue | | `src/network_task.cpp` | WiFi station + REST server (`/ring`, `/play`, `/stop`, `/status`) | +| `src/scenario_now.cpp` | ESP-NOW scenario hot-load receiver → LittleFS `/scenario.json` | ## Anti-patterns diff --git a/PLIP_FIRMWARE/src/network_task.cpp b/PLIP_FIRMWARE/src/network_task.cpp index e9dc783..9cf27cb 100644 --- a/PLIP_FIRMWARE/src/network_task.cpp +++ b/PLIP_FIRMWARE/src/network_task.cpp @@ -23,6 +23,8 @@ #include #include +#include "scenario_now.h" + namespace { constexpr char kFallbackSsid[] = "ZACUS-SETUP"; @@ -109,6 +111,9 @@ void network_task(void *) { const WifiCreds creds = load_credentials(); if (connect_wifi(creds)) { start_mdns_and_probe_master(); + // Scenario hot-load receiver (ESP-NOW rides the station's channel). + // Idempotent; safe to call again from the reconnect path below. + scenario_now_init(); } else { Serial.println(F("[net] proceeding offline — will retry every 5 s")); } @@ -125,6 +130,7 @@ void network_task(void *) { const WifiCreds fresh = load_credentials(); if (connect_wifi(fresh)) { start_mdns_and_probe_master(); + scenario_now_init(); } } } diff --git a/PLIP_FIRMWARE/src/scenario_now.cpp b/PLIP_FIRMWARE/src/scenario_now.cpp new file mode 100644 index 0000000..d44b125 --- /dev/null +++ b/PLIP_FIRMWARE/src/scenario_now.cpp @@ -0,0 +1,260 @@ +// scenario_now — implementation. See scenario_now.h for the design notes. +// +// Threading: the esp_now recv callback runs in the Wi-Fi task on the +// Arduino core; it only copies the frame onto a queue. Reassembly, the +// LittleFS write and the consumer callback all run in the worker task. +// +// Failure policy mirrors the puzzle nodes' shared receiver +// (ESP32_ZACUS/lib/espnow_common/espnow_slave.c): bounded heap reassembly +// (<=64 KiB), 5 s sender-silence timeout, a new first frame (seq==0) from a +// different sender preempts a stale transfer. + +#include "scenario_now.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef SCENARIO_NOW_QUEUE_DEPTH +#define SCENARIO_NOW_QUEUE_DEPTH 16 +#endif + +namespace { + +constexpr char kTag[] = "[scenario-now]"; +constexpr char kPath[] = "/scenario.json"; +constexpr char kTmpPath[] = "/scenario.json.tmp"; + +// Wire format constants — must match scenario_mesh / espnow_slave.c. +constexpr size_t kPayloadMax = 236; // 240 ESP-NOW max - 4 header +constexpr size_t kMaxBytes = 64 * 1024; // master-side cap +constexpr uint16_t kMaxFrames = (kMaxBytes + kPayloadMax - 1) / kPayloadMax; +constexpr uint32_t kTimeoutMs = 5000; + +struct FrameItem { + uint8_t mac[6]; + uint8_t data[250]; + int len; +}; + +struct Reassembly { + bool active; + uint8_t src[6]; + uint16_t total; // expected frame count + uint16_t count; // distinct frames received + size_t tail; // highest payload end offset = reassembled length + uint8_t *buf; // heap, total * kPayloadMax + uint8_t *seen; // heap bitmap, (total + 7) / 8 bytes + uint32_t last_ms; +}; + +QueueHandle_t g_queue = nullptr; +Reassembly g_rx = {}; +scenario_now_callback_t g_cb = nullptr; +bool g_fs_ok = false; + +void rx_reset() { + free(g_rx.buf); + free(g_rx.seen); + memset(&g_rx, 0, sizeof(g_rx)); +} + +// Persist atomically: write the temp file, then rename over the live path. +// A reset mid-write leaves at worst a stale .tmp, never a torn scenario. +bool persist(const uint8_t *json, size_t len) { + if (!g_fs_ok) { + Serial.printf("%s LittleFS unavailable, scenario not persisted\n", kTag); + return false; + } + File f = LittleFS.open(kTmpPath, "w"); + if (!f) { + Serial.printf("%s open %s failed\n", kTag, kTmpPath); + return false; + } + const size_t written = f.write(json, len); + f.close(); + if (written != len) { + Serial.printf("%s short write %u/%u to %s\n", + kTag, (unsigned)written, (unsigned)len, kTmpPath); + LittleFS.remove(kTmpPath); + return false; + } + LittleFS.remove(kPath); // rename does not overwrite on LittleFS + if (!LittleFS.rename(kTmpPath, kPath)) { + Serial.printf("%s rename %s -> %s failed\n", kTag, kTmpPath, kPath); + return false; + } + return true; +} + +void feed(const FrameItem &it) { + if (it.len < 4) return; + const uint16_t seq = (uint16_t)it.data[0] | ((uint16_t)it.data[1] << 8); + const uint16_t total = (uint16_t)it.data[2] | ((uint16_t)it.data[3] << 8); + const uint8_t *payload = it.data + 4; + int payload_len = it.len - 4; + + // Begin (or restart from a new sender) on a first frame. + if (seq == 0 && (!g_rx.active || memcmp(it.mac, g_rx.src, 6) != 0)) { + rx_reset(); + if (total == 0 || total > kMaxFrames) { + Serial.printf("%s bad/oversized total %u — dropped\n", kTag, total); + return; + } + g_rx.buf = (uint8_t *)calloc(total, kPayloadMax); + g_rx.seen = (uint8_t *)calloc((total + 7) / 8, 1); + if (!g_rx.buf || !g_rx.seen) { + Serial.printf("%s OOM for %u frames — dropped\n", kTag, total); + rx_reset(); + return; + } + g_rx.active = true; + g_rx.total = total; + memcpy(g_rx.src, it.mac, 6); + } + + if (!g_rx.active) return; // stray frame, no session + if (memcmp(it.mac, g_rx.src, 6) != 0) return; // other sender mid-transfer + if (total != g_rx.total) return; // inconsistent header + if (seq >= g_rx.total) return; + if (payload_len > (int)kPayloadMax) payload_len = kPayloadMax; + + g_rx.last_ms = millis(); + + if (!(g_rx.seen[seq / 8] & (1u << (seq % 8)))) { + const size_t off = (size_t)seq * kPayloadMax; + memcpy(g_rx.buf + off, payload, (size_t)payload_len); + g_rx.seen[seq / 8] |= (uint8_t)(1u << (seq % 8)); + g_rx.count++; + const size_t end = off + (size_t)payload_len; + if (end > g_rx.tail) g_rx.tail = end; + } + + if (g_rx.count != g_rx.total) return; + + // Complete. Cheap sanity check before touching flash: an IR is a JSON + // object, so the first non-whitespace byte must be '{'. + size_t i = 0; + while (i < g_rx.tail && isspace(g_rx.buf[i])) i++; + if (i == g_rx.tail || g_rx.buf[i] != '{') { + Serial.printf("%s reassembled %u bytes but not JSON — dropped\n", + kTag, (unsigned)g_rx.tail); + rx_reset(); + return; + } + + Serial.printf("%s scenario reassembled: %u frames, %u bytes from " + "%02X:%02X:%02X:%02X:%02X:%02X\n", + kTag, g_rx.total, (unsigned)g_rx.tail, + g_rx.src[0], g_rx.src[1], g_rx.src[2], + g_rx.src[3], g_rx.src[4], g_rx.src[5]); + + if (persist(g_rx.buf, g_rx.tail)) { + Serial.printf("%s stored at %s\n", kTag, kPath); + } + if (g_cb) { + g_cb(g_rx.src, (const char *)g_rx.buf, g_rx.tail); + } else { + Serial.printf("%s no consumer registered — stored only\n", kTag); + } + rx_reset(); +} + +void on_recv(const uint8_t *mac, const uint8_t *data, int len) { + FrameItem item; + memcpy(item.mac, mac, 6); + const int copy_len = len < (int)sizeof(item.data) ? len : (int)sizeof(item.data); + memcpy(item.data, data, copy_len); + item.len = copy_len; + // Non-blocking: under burst the frame is lost and the 5 s timeout (or the + // master's next relay attempt) recovers — same policy as the puzzle nodes. + xQueueSend(g_queue, &item, 0); +} + +void worker_task(void *) { + Serial.printf("%s worker ready, storage=%s\n", kTag, kPath); + FrameItem item; + for (;;) { + // Wake at least once a second so the sender-silence timeout fires even + // with an empty queue. + if (xQueueReceive(g_queue, &item, pdMS_TO_TICKS(1000)) == pdTRUE) { + feed(item); + } + if (g_rx.active && (millis() - g_rx.last_ms) > kTimeoutMs) { + Serial.printf("%s rx timeout (%u/%u frames) — dropped\n", + kTag, g_rx.count, g_rx.total); + rx_reset(); + } + } +} + +} // namespace + +bool scenario_now_init() { + if (g_queue != nullptr) return true; // idempotent — reconnect path safe + + // network_task owns Wi-Fi; esp_now_init() requires the stack started. + if (WiFi.getMode() == WIFI_MODE_NULL) { + Serial.printf("%s init before Wi-Fi start — call after network_task is up\n", + kTag); + return false; + } + + g_fs_ok = LittleFS.begin(true /* format on first mount */); + if (!g_fs_ok) { + Serial.printf("%s LittleFS mount failed — receiver runs without storage\n", + kTag); + } else if (LittleFS.exists(kPath)) { + File f = LittleFS.open(kPath, "r"); + Serial.printf("%s existing scenario on flash: %u bytes\n", + kTag, f ? (unsigned)f.size() : 0u); + if (f) f.close(); + } + + g_queue = xQueueCreate(SCENARIO_NOW_QUEUE_DEPTH, sizeof(FrameItem)); + if (g_queue == nullptr) { + Serial.printf("%s xQueueCreate failed\n", kTag); + return false; + } + + if (esp_now_init() != ESP_OK) { + Serial.printf("%s esp_now_init failed\n", kTag); + vQueueDelete(g_queue); + g_queue = nullptr; + return false; + } + if (esp_now_register_recv_cb(on_recv) != ESP_OK) { + Serial.printf("%s esp_now_register_recv_cb failed\n", kTag); + esp_now_deinit(); + vQueueDelete(g_queue); + g_queue = nullptr; + return false; + } + + if (xTaskCreate(worker_task, "scenario-now", 6144, nullptr, 4, nullptr) + != pdPASS) { + Serial.printf("%s xTaskCreate failed\n", kTag); + esp_now_unregister_recv_cb(); + esp_now_deinit(); + vQueueDelete(g_queue); + g_queue = nullptr; + return false; + } + + Serial.printf("%s ESP-NOW receiver up (channel follows the AP)\n", kTag); + return true; +} + +void scenario_now_register_callback(scenario_now_callback_t cb) { + g_cb = cb; +} + +const char *scenario_now_storage_path() { + return kPath; +} diff --git a/PLIP_FIRMWARE/src/scenario_now.h b/PLIP_FIRMWARE/src/scenario_now.h new file mode 100644 index 0000000..1676337 --- /dev/null +++ b/PLIP_FIRMWARE/src/scenario_now.h @@ -0,0 +1,48 @@ +// scenario_now — ESP-NOW scenario hot-load receiver for PLIP. +// +// Brings PLIP into the scope of the firmware scenario hot-load +// (docs/specs/2026-05-24-firmware-scenario-hotload.md, task 6). The Zacus +// master relays a Runtime 3 scenario IR (JSON) over ESP-NOW in frames of +// up to 240 bytes: a 4-byte header { seq:u16 LE, total:u16 LE } followed by +// <=236 payload bytes. Same wire format as the master's scenario_mesh +// component and the puzzle nodes' shared espnow_slave.c receiver. +// +// PLIP registers no other esp_now recv callback, so this module owns the +// single callback — no demultiplexing against legacy MSG_* traffic needed +// (unlike the puzzle nodes). Frames are copied onto a FreeRTOS queue from +// the Wi-Fi task and reassembled per source MAC in a dedicated worker task; +// a completed scenario is persisted to LittleFS at /scenario.json +// (temp-then-rename, so a torn write never replaces a good scenario). +// +// Wi-Fi: network_task owns the connection. Call scenario_now_init() once +// the station is up (esp_now_init() fails before Wi-Fi starts); it is +// idempotent so the reconnect path may call it again. + +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Start the receiver: ESP-NOW init, recv callback, worker task, LittleFS +// mount. Idempotent. Returns true when the receiver is (already) running. +bool scenario_now_init(); + +// Optional consumer hook, invoked from the worker task after the scenario +// has been persisted to LittleFS. `json` points at the reassembled buffer +// (valid only for the duration of the call). PLIP has no Runtime 3 engine +// yet: with no callback registered the scenario is stored and logged, ready +// for a future engine to load at boot. +typedef void (*scenario_now_callback_t)(const uint8_t src_mac[6], + const char *json, size_t len); +void scenario_now_register_callback(scenario_now_callback_t cb); + +// Path of the persisted scenario on LittleFS. +const char *scenario_now_storage_path(); + +#ifdef __cplusplus +} +#endif