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:
@@ -23,6 +23,7 @@ pio device monitor # serial @ 115200
|
|||||||
| `src/phone_task.cpp` | Off-hook GPIO interrupt + ring control |
|
| `src/phone_task.cpp` | Off-hook GPIO interrupt + ring control |
|
||||||
| `src/audio_task.cpp` | ES8388 / Si3210 audio routing, drains a command queue |
|
| `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/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
|
## Anti-patterns
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
#include <freertos/FreeRTOS.h>
|
#include <freertos/FreeRTOS.h>
|
||||||
#include <freertos/task.h>
|
#include <freertos/task.h>
|
||||||
|
|
||||||
|
#include "scenario_now.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
constexpr char kFallbackSsid[] = "ZACUS-SETUP";
|
constexpr char kFallbackSsid[] = "ZACUS-SETUP";
|
||||||
@@ -109,6 +111,9 @@ void network_task(void *) {
|
|||||||
const WifiCreds creds = load_credentials();
|
const WifiCreds creds = load_credentials();
|
||||||
if (connect_wifi(creds)) {
|
if (connect_wifi(creds)) {
|
||||||
start_mdns_and_probe_master();
|
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 {
|
} else {
|
||||||
Serial.println(F("[net] proceeding offline — will retry every 5 s"));
|
Serial.println(F("[net] proceeding offline — will retry every 5 s"));
|
||||||
}
|
}
|
||||||
@@ -125,6 +130,7 @@ void network_task(void *) {
|
|||||||
const WifiCreds fresh = load_credentials();
|
const WifiCreds fresh = load_credentials();
|
||||||
if (connect_wifi(fresh)) {
|
if (connect_wifi(fresh)) {
|
||||||
start_mdns_and_probe_master();
|
start_mdns_and_probe_master();
|
||||||
|
scenario_now_init();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user