feat(plip): scenario hot-load via ESP-NOW

PLIP was out of scope by design (Wi-Fi/HTTP-only); brought back in by
request. New src/scenario_now.{h,cpp}: owns the single esp_now recv
callback (no MSG_* demux needed — PLIP has no legacy ESP-NOW traffic),
reassembles scenario_mesh frames ({seq:u16 LE, total:u16 LE} + <=236 B
payload, <=64 KiB, 5 s sender-silence timeout) in a worker task, then
persists to LittleFS /scenario.json (temp-then-rename). Optional
consumer hook for a future Runtime 3 engine; stored-and-logged without
one. network_task calls scenario_now_init() once the station is up
(idempotent, repeated on reconnect).

Build green: pio run (devkit_es8388), RAM 14.8%, Flash 63.1%.
This commit is contained in:
Claude Worker claude2
2026-06-10 10:24:01 +02:00
parent 26969640a3
commit e7a974e1be
4 changed files with 315 additions and 0 deletions
+1
View File
@@ -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
+6
View File
@@ -23,6 +23,8 @@
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#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();
}
}
}
+260
View File
@@ -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 <Arduino.h>
#include <LittleFS.h>
#include <WiFi.h>
#include <esp_now.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
#include <stdlib.h>
#include <string.h>
#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;
}
+48
View File
@@ -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 <stddef.h>
#include <stdint.h>
#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