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).
This commit is contained in:
@@ -1,4 +1,9 @@
|
|||||||
cmake_minimum_required(VERSION 3.16)
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
|
||||||
|
# scenario_mesh is shared with idf_zacus and lives in the repo-level lib/
|
||||||
|
# (single copy, no protocol drift). Project-local components under
|
||||||
|
# box3_voice/components/ are picked up automatically by ESP-IDF.
|
||||||
|
set(EXTRA_COMPONENT_DIRS ../lib/scenario_mesh)
|
||||||
|
|
||||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
project(zacus-box3-voice)
|
project(zacus-box3-voice)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -133,11 +133,17 @@ 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
|
recommended push channel once that server lands; the ESP-NOW receiver covers
|
||||||
the relay/fallback case in the meantime.
|
the relay/fallback case in the meantime.
|
||||||
|
|
||||||
## Shared-protocol drift risk
|
## Shared-protocol drift risk — resolved 2026-06-10
|
||||||
|
|
||||||
The vendored `scenario_mesh` now lives in two places
|
`scenario_mesh` was vendored byte-identical in two places
|
||||||
(`idf_zacus/components/scenario_mesh` and
|
(`idf_zacus/components/` and `box3_voice/components/`). It now lives **once**
|
||||||
`box3_voice/components/scenario_mesh`). They are byte-identical today. If the
|
at `lib/scenario_mesh`, referenced by both projects via
|
||||||
frame header or `SCENARIO_MESH_*` constants ever change, update **both** (and
|
`EXTRA_COMPONENT_DIRS` in their root CMakeLists — the follow-up suggested
|
||||||
any future PLIP/puzzle copy). A follow-up could hoist the component to a shared
|
below is done; both firmwares rebuilt green after the hoist.
|
||||||
`lib/` path referenced via `EXTRA_COMPONENT_DIRS` to remove the duplication.
|
|
||||||
|
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`.
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
cmake_minimum_required(VERSION 3.16)
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
|
||||||
# Local components live under idf_zacus/components/ (e.g. ota_server inherited
|
# Local components live under idf_zacus/components/ (e.g. ota_server inherited
|
||||||
# from the 2026-04-03 IDF bootstrap).
|
# from the 2026-04-03 IDF bootstrap). scenario_mesh is shared with box3_voice
|
||||||
set(EXTRA_COMPONENT_DIRS components)
|
# and lives in the repo-level lib/ (single copy, no protocol drift).
|
||||||
|
set(EXTRA_COMPONENT_DIRS components ../lib/scenario_mesh)
|
||||||
|
|
||||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
project(zacus_master)
|
project(zacus_master)
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
## Zacus scenario_mesh — ESP-NOW transport for Runtime 3 IR hot-load.
|
|
||||||
##
|
|
||||||
## Tasks 4 & 5 of docs/specs/2026-05-24-firmware-scenario-hotload.md: chunk the
|
|
||||||
## IR JSON into <=240-byte ESP-NOW frames, send sequentially with per-frame
|
|
||||||
## ack, and reassemble on the receive side keyed by sender+total before
|
|
||||||
## invoking the same _scenario_apply() path as the HTTP handler.
|
|
||||||
|
|
||||||
idf_component_register(
|
|
||||||
SRCS
|
|
||||||
"scenario_mesh.c"
|
|
||||||
INCLUDE_DIRS
|
|
||||||
"include"
|
|
||||||
REQUIRES
|
|
||||||
esp_wifi
|
|
||||||
freertos
|
|
||||||
log
|
|
||||||
)
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
// scenario_mesh — ESP-NOW transport for Runtime 3 IR hot-load (Phase 2).
|
|
||||||
//
|
|
||||||
// Implements tasks 4 & 5 of docs/specs/2026-05-24-firmware-scenario-hotload.md:
|
|
||||||
//
|
|
||||||
// * Frame protocol: the IR JSON blob is chunked into ESP-NOW frames of
|
|
||||||
// <= SCENARIO_MESH_FRAME_MAX (240) bytes. Each frame carries a 4-byte
|
|
||||||
// header { seq:u16, total:u16 } (little-endian on the wire) followed by
|
|
||||||
// up to SCENARIO_MESH_PAYLOAD_MAX payload bytes.
|
|
||||||
// * Sender (master): scenario_mesh_send() resolves an alias to a MAC,
|
|
||||||
// registers the peer, and transmits every frame sequentially, awaiting
|
|
||||||
// the per-frame esp_now send-callback ack before advancing.
|
|
||||||
// * Receiver (peer board): the registered esp_now recv callback accumulates
|
|
||||||
// frames keyed by (sender MAC + total) until `total` frames have arrived,
|
|
||||||
// concatenates them, and hands the reassembled buffer to the apply
|
|
||||||
// callback supplied at init — the same internal `_scenario_apply()` path
|
|
||||||
// the HTTP POST /game/scenario handler uses.
|
|
||||||
//
|
|
||||||
// NOTE ON THE "EXISTING PEER REGISTRY":
|
|
||||||
// The spec references an existing ESP-NOW peer registry + `espnow_recv_cb`
|
|
||||||
// to extend. In the IDF tree (idf_zacus) no such registry exists yet — the
|
|
||||||
// only ESP-NOW code is the legacy Arduino lib/espnow_common (broadcast-only,
|
|
||||||
// puzzle-id keyed, not an IDF component). So this component carries its own
|
|
||||||
// minimal alias->MAC table (scenario_mesh_register_peer / _mac_for_alias).
|
|
||||||
// When a real shared registry lands, point mac_for_alias() at it.
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <stdbool.h>
|
|
||||||
#include <stddef.h>
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
#include "esp_err.h"
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// ESP-NOW hard limit on a single payload is 250 bytes. We cap the whole
|
|
||||||
// frame (header + payload) at 240 to stay clear of vendor headers and keep a
|
|
||||||
// safety margin, matching the spec's "<= 240-byte ESP-NOW frames".
|
|
||||||
#define SCENARIO_MESH_FRAME_MAX 240
|
|
||||||
#define SCENARIO_MESH_HEADER_BYTES 4
|
|
||||||
#define SCENARIO_MESH_PAYLOAD_MAX (SCENARIO_MESH_FRAME_MAX - SCENARIO_MESH_HEADER_BYTES) // 236
|
|
||||||
|
|
||||||
// Largest IR blob we will reassemble on the receive side. Mirrors
|
|
||||||
// GAME_ENDPOINT_MAX_SCENARIO_BYTES (64 KiB). 64 KiB / 236 ≈ 285 frames, well
|
|
||||||
// under the u16 sequence space.
|
|
||||||
#define SCENARIO_MESH_MAX_BLOB (64 * 1024)
|
|
||||||
|
|
||||||
// Per-frame ack timeout. ESP-NOW send-callbacks normally fire within a few ms;
|
|
||||||
// a generous window absorbs RF retries without stalling the relay loop.
|
|
||||||
#define SCENARIO_MESH_ACK_TIMEOUT_MS 300
|
|
||||||
|
|
||||||
// Callback invoked on the receive side once a full blob has been reassembled.
|
|
||||||
// `data` is a NUL-terminated buffer of `len` bytes (the IR JSON). The callback
|
|
||||||
// must NOT take ownership — the buffer is freed by scenario_mesh after return.
|
|
||||||
// Return ESP_OK if the scenario was applied; any other value is logged.
|
|
||||||
typedef esp_err_t (*scenario_mesh_apply_cb_t)(const char *data, size_t len);
|
|
||||||
|
|
||||||
// Initialize ESP-NOW (idempotent — tolerates an already-initialized stack),
|
|
||||||
// register the send + recv callbacks, and register the broadcast peer.
|
|
||||||
//
|
|
||||||
// `apply_cb` may be NULL on a pure sender (master) that never receives
|
|
||||||
// scenarios; pass the board's _scenario_apply wrapper on receiver boards.
|
|
||||||
esp_err_t scenario_mesh_init(scenario_mesh_apply_cb_t apply_cb);
|
|
||||||
|
|
||||||
// Register / update an alias -> MAC mapping in the local peer table and add the
|
|
||||||
// MAC as an unencrypted ESP-NOW peer. Safe to call repeatedly with the same
|
|
||||||
// alias (updates the MAC). Returns ESP_ERR_NO_MEM if the table is full.
|
|
||||||
esp_err_t scenario_mesh_register_peer(const char *alias, const uint8_t mac[6]);
|
|
||||||
|
|
||||||
// Resolve an alias to its MAC. Returns ESP_OK and fills `mac_out` on hit,
|
|
||||||
// ESP_ERR_NOT_FOUND otherwise.
|
|
||||||
esp_err_t scenario_mesh_mac_for_alias(const char *alias, uint8_t mac_out[6]);
|
|
||||||
|
|
||||||
// Chunk `data` (len bytes) into frames and send them all sequentially to
|
|
||||||
// `dest_mac`, awaiting the per-frame ack. Returns ESP_OK only if every frame
|
|
||||||
// was acked; ESP_ERR_TIMEOUT if any frame ack timed out, or the underlying
|
|
||||||
// esp_now_send error. The caller (relay handler) treats a non-OK return as a
|
|
||||||
// skipped peer and continues with the others.
|
|
||||||
esp_err_t scenario_mesh_send(const uint8_t dest_mac[6],
|
|
||||||
const char *data, size_t len);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
// scenario_mesh — see include/scenario_mesh.h for the design notes.
|
|
||||||
//
|
|
||||||
// Tasks 4 & 5 of the firmware-scenario-hotload spec: ESP-NOW frame protocol
|
|
||||||
// (chunk + reassembly) for relaying Runtime 3 IR to WiFi-disabled peers.
|
|
||||||
|
|
||||||
#include "scenario_mesh.h"
|
|
||||||
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
#include "esp_log.h"
|
|
||||||
#include "esp_now.h"
|
|
||||||
#include "esp_wifi.h"
|
|
||||||
#include "freertos/FreeRTOS.h"
|
|
||||||
#include "freertos/queue.h"
|
|
||||||
#include "freertos/semphr.h"
|
|
||||||
#include "freertos/task.h"
|
|
||||||
|
|
||||||
static const char *TAG = "scenario_mesh";
|
|
||||||
|
|
||||||
static const uint8_t kBroadcast[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
|
|
||||||
|
|
||||||
// ─── alias -> MAC peer table (minimal local registry) ───────────────────────
|
|
||||||
|
|
||||||
#define MESH_ALIAS_MAX 32
|
|
||||||
#define MESH_PEERS_MAX 8
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
bool used;
|
|
||||||
char alias[MESH_ALIAS_MAX];
|
|
||||||
uint8_t mac[6];
|
|
||||||
} mesh_peer_t;
|
|
||||||
|
|
||||||
static mesh_peer_t s_peers[MESH_PEERS_MAX];
|
|
||||||
|
|
||||||
// ─── send-side ack synchronization ──────────────────────────────────────────
|
|
||||||
|
|
||||||
static SemaphoreHandle_t s_send_done; // given by on_sent for each frame
|
|
||||||
static volatile esp_now_send_status_t s_last_send_status;
|
|
||||||
static SemaphoreHandle_t s_send_lock; // serializes scenario_mesh_send calls
|
|
||||||
|
|
||||||
// ─── receive-side reassembly ────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// One in-flight reassembly slot per distinct (sender MAC, total) tuple. A
|
|
||||||
// single slot is enough in practice — the master relays to one peer at a time
|
|
||||||
// and frames arrive in order — but we keep a small array so two senders (or a
|
|
||||||
// retried transfer) don't clobber each other.
|
|
||||||
|
|
||||||
#define MESH_REASM_SLOTS 2
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
bool used;
|
|
||||||
uint8_t src[6];
|
|
||||||
uint16_t total;
|
|
||||||
uint16_t received; // count of distinct frames stored
|
|
||||||
bool *seen; // [total] frame-arrival bitmap (heap)
|
|
||||||
char *buf; // [total * PAYLOAD_MAX + 1] (heap)
|
|
||||||
size_t buf_len; // running max byte offset written + payload
|
|
||||||
uint32_t last_tick; // for staleness eviction
|
|
||||||
} mesh_reasm_t;
|
|
||||||
|
|
||||||
static mesh_reasm_t s_reasm[MESH_REASM_SLOTS];
|
|
||||||
static SemaphoreHandle_t s_reasm_lock;
|
|
||||||
static scenario_mesh_apply_cb_t s_apply_cb;
|
|
||||||
|
|
||||||
// A completed reassembly is handed off to a worker task rather than applied in
|
|
||||||
// the Wi-Fi recv-callback context: the apply does filesystem I/O (and triggers
|
|
||||||
// a reboot) which must not run on the Wi-Fi stack's callback.
|
|
||||||
typedef struct {
|
|
||||||
char *buf; // heap, NUL-terminated, ownership transferred to the task
|
|
||||||
size_t len;
|
|
||||||
} mesh_apply_job_t;
|
|
||||||
|
|
||||||
static QueueHandle_t s_apply_queue;
|
|
||||||
|
|
||||||
static void apply_worker_task(void *arg) {
|
|
||||||
(void) arg;
|
|
||||||
mesh_apply_job_t job;
|
|
||||||
for (;;) {
|
|
||||||
if (xQueueReceive(s_apply_queue, &job, portMAX_DELAY) != pdTRUE) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (s_apply_cb) {
|
|
||||||
esp_err_t aerr = s_apply_cb(job.buf, job.len);
|
|
||||||
if (aerr != ESP_OK) {
|
|
||||||
ESP_LOGW(TAG, "apply_cb returned %s", esp_err_to_name(aerr));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ESP_LOGW(TAG, "no apply_cb registered — dropping scenario");
|
|
||||||
}
|
|
||||||
free(job.buf);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── peer table helpers ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
esp_err_t scenario_mesh_register_peer(const char *alias, const uint8_t mac[6]) {
|
|
||||||
if (!alias || !mac) return ESP_ERR_INVALID_ARG;
|
|
||||||
|
|
||||||
int free_slot = -1;
|
|
||||||
for (int i = 0; i < MESH_PEERS_MAX; i++) {
|
|
||||||
if (s_peers[i].used && strncmp(s_peers[i].alias, alias,
|
|
||||||
MESH_ALIAS_MAX) == 0) {
|
|
||||||
free_slot = i; // update existing
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!s_peers[i].used && free_slot < 0) free_slot = i;
|
|
||||||
}
|
|
||||||
if (free_slot < 0) {
|
|
||||||
ESP_LOGE(TAG, "peer table full, cannot register \"%s\"", alias);
|
|
||||||
return ESP_ERR_NO_MEM;
|
|
||||||
}
|
|
||||||
|
|
||||||
s_peers[free_slot].used = true;
|
|
||||||
strncpy(s_peers[free_slot].alias, alias, MESH_ALIAS_MAX - 1);
|
|
||||||
s_peers[free_slot].alias[MESH_ALIAS_MAX - 1] = '\0';
|
|
||||||
memcpy(s_peers[free_slot].mac, mac, 6);
|
|
||||||
|
|
||||||
// Add (or refresh) the ESP-NOW peer entry. esp_now_add_peer fails with
|
|
||||||
// ESP_ERR_ESPNOW_EXIST if already present — treat that as success.
|
|
||||||
esp_now_peer_info_t pi = {0};
|
|
||||||
memcpy(pi.peer_addr, mac, 6);
|
|
||||||
pi.channel = 0; // current channel
|
|
||||||
pi.ifidx = WIFI_IF_STA;
|
|
||||||
pi.encrypt = false;
|
|
||||||
esp_err_t err = esp_now_add_peer(&pi);
|
|
||||||
if (err == ESP_ERR_ESPNOW_EXIST) {
|
|
||||||
err = ESP_OK;
|
|
||||||
} else if (err != ESP_OK) {
|
|
||||||
ESP_LOGW(TAG, "esp_now_add_peer(%s) failed: %s",
|
|
||||||
alias, esp_err_to_name(err));
|
|
||||||
}
|
|
||||||
|
|
||||||
ESP_LOGI(TAG, "peer \"%s\" -> %02x:%02x:%02x:%02x:%02x:%02x",
|
|
||||||
alias, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
|
|
||||||
esp_err_t scenario_mesh_mac_for_alias(const char *alias, uint8_t mac_out[6]) {
|
|
||||||
if (!alias || !mac_out) return ESP_ERR_INVALID_ARG;
|
|
||||||
for (int i = 0; i < MESH_PEERS_MAX; i++) {
|
|
||||||
if (s_peers[i].used &&
|
|
||||||
strncmp(s_peers[i].alias, alias, MESH_ALIAS_MAX) == 0) {
|
|
||||||
memcpy(mac_out, s_peers[i].mac, 6);
|
|
||||||
return ESP_OK;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ESP_ERR_NOT_FOUND;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── ESP-NOW callbacks (Wi-Fi task context — keep short) ────────────────────
|
|
||||||
|
|
||||||
static void on_sent(const uint8_t *mac, esp_now_send_status_t status) {
|
|
||||||
(void) mac;
|
|
||||||
s_last_send_status = status;
|
|
||||||
if (s_send_done) {
|
|
||||||
BaseType_t hp = pdFALSE;
|
|
||||||
xSemaphoreGiveFromISR(s_send_done, &hp);
|
|
||||||
if (hp) portYIELD_FROM_ISR();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Locate (or allocate) the reassembly slot for this sender/total. Caller holds
|
|
||||||
// s_reasm_lock. Returns NULL on allocation failure.
|
|
||||||
static mesh_reasm_t *reasm_slot_for(const uint8_t src[6], uint16_t total) {
|
|
||||||
mesh_reasm_t *free_slot = NULL;
|
|
||||||
mesh_reasm_t *oldest = NULL;
|
|
||||||
for (int i = 0; i < MESH_REASM_SLOTS; i++) {
|
|
||||||
mesh_reasm_t *r = &s_reasm[i];
|
|
||||||
if (r->used && r->total == total && memcmp(r->src, src, 6) == 0) {
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
if (!r->used && !free_slot) free_slot = r;
|
|
||||||
if (r->used && (!oldest || r->last_tick < oldest->last_tick)) oldest = r;
|
|
||||||
}
|
|
||||||
|
|
||||||
// New transfer. Reuse a free slot, else evict the oldest in-flight one.
|
|
||||||
mesh_reasm_t *r = free_slot ? free_slot : oldest;
|
|
||||||
if (!r) return NULL;
|
|
||||||
if (r->used) {
|
|
||||||
ESP_LOGW(TAG, "evicting stale reassembly (%u/%u frames)",
|
|
||||||
r->received, r->total);
|
|
||||||
free(r->seen);
|
|
||||||
free(r->buf);
|
|
||||||
}
|
|
||||||
memset(r, 0, sizeof(*r));
|
|
||||||
|
|
||||||
if (total == 0 || (size_t) total * SCENARIO_MESH_PAYLOAD_MAX > SCENARIO_MESH_MAX_BLOB) {
|
|
||||||
ESP_LOGW(TAG, "reject transfer: implausible total=%u", total);
|
|
||||||
return NULL;
|
|
||||||
}
|
|
||||||
r->seen = calloc(total, sizeof(bool));
|
|
||||||
r->buf = malloc((size_t) total * SCENARIO_MESH_PAYLOAD_MAX + 1);
|
|
||||||
if (!r->seen || !r->buf) {
|
|
||||||
free(r->seen);
|
|
||||||
free(r->buf);
|
|
||||||
ESP_LOGE(TAG, "OOM allocating reassembly for total=%u", total);
|
|
||||||
return NULL;
|
|
||||||
}
|
|
||||||
r->used = true;
|
|
||||||
r->total = total;
|
|
||||||
memcpy(r->src, src, 6);
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void on_recv(const esp_now_recv_info_t *info,
|
|
||||||
const uint8_t *data, int len) {
|
|
||||||
if (!info || !data || len < SCENARIO_MESH_HEADER_BYTES) return;
|
|
||||||
if (len > SCENARIO_MESH_FRAME_MAX) return;
|
|
||||||
|
|
||||||
// Header: seq:u16, total:u16 (little-endian).
|
|
||||||
uint16_t seq = (uint16_t) (data[0] | (data[1] << 8));
|
|
||||||
uint16_t total = (uint16_t) (data[2] | (data[3] << 8));
|
|
||||||
const uint8_t *payload = data + SCENARIO_MESH_HEADER_BYTES;
|
|
||||||
int payload_len = len - SCENARIO_MESH_HEADER_BYTES;
|
|
||||||
if (seq >= total) return; // malformed
|
|
||||||
|
|
||||||
if (!s_reasm_lock) return;
|
|
||||||
// Run reassembly off the Wi-Fi callback by doing the bookkeeping under a
|
|
||||||
// mutex here; the (potentially slow) apply is deferred to a short task so
|
|
||||||
// we never block the Wi-Fi stack inside the recv callback.
|
|
||||||
char *complete_buf = NULL;
|
|
||||||
size_t complete_len = 0;
|
|
||||||
|
|
||||||
xSemaphoreTake(s_reasm_lock, portMAX_DELAY);
|
|
||||||
mesh_reasm_t *r = reasm_slot_for(info->src_addr, total);
|
|
||||||
if (r) {
|
|
||||||
if (!r->seen[seq]) {
|
|
||||||
r->seen[seq] = true;
|
|
||||||
r->received++;
|
|
||||||
size_t off = (size_t) seq * SCENARIO_MESH_PAYLOAD_MAX;
|
|
||||||
memcpy(r->buf + off, payload, payload_len);
|
|
||||||
// Track the highest end offset so the final length is exact even
|
|
||||||
// though only the last frame is short.
|
|
||||||
if (off + payload_len > r->buf_len) r->buf_len = off + payload_len;
|
|
||||||
}
|
|
||||||
r->last_tick = (uint32_t) xTaskGetTickCount();
|
|
||||||
|
|
||||||
if (r->received == r->total) {
|
|
||||||
r->buf[r->buf_len] = '\0';
|
|
||||||
complete_buf = r->buf;
|
|
||||||
complete_len = r->buf_len;
|
|
||||||
free(r->seen);
|
|
||||||
memset(r, 0, sizeof(*r)); // releases the slot; buf handed off
|
|
||||||
}
|
|
||||||
}
|
|
||||||
xSemaphoreGive(s_reasm_lock);
|
|
||||||
|
|
||||||
if (complete_buf) {
|
|
||||||
ESP_LOGI(TAG, "reassembled scenario: %u bytes from "
|
|
||||||
"%02x:%02x:%02x:%02x:%02x:%02x",
|
|
||||||
(unsigned) complete_len,
|
|
||||||
info->src_addr[0], info->src_addr[1], info->src_addr[2],
|
|
||||||
info->src_addr[3], info->src_addr[4], info->src_addr[5]);
|
|
||||||
// Hand off to the worker task — never touch the filesystem (or reboot)
|
|
||||||
// from the Wi-Fi recv-callback context.
|
|
||||||
mesh_apply_job_t job = { .buf = complete_buf, .len = complete_len };
|
|
||||||
if (!s_apply_queue ||
|
|
||||||
xQueueSend(s_apply_queue, &job, 0) != pdTRUE) {
|
|
||||||
ESP_LOGW(TAG, "apply queue full/unavailable — dropping scenario");
|
|
||||||
free(complete_buf);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── init ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
esp_err_t scenario_mesh_init(scenario_mesh_apply_cb_t apply_cb) {
|
|
||||||
s_apply_cb = apply_cb;
|
|
||||||
|
|
||||||
if (!s_send_done) s_send_done = xSemaphoreCreateBinary();
|
|
||||||
if (!s_send_lock) s_send_lock = xSemaphoreCreateMutex();
|
|
||||||
if (!s_reasm_lock) s_reasm_lock = xSemaphoreCreateMutex();
|
|
||||||
if (!s_send_done || !s_send_lock || !s_reasm_lock) {
|
|
||||||
return ESP_ERR_NO_MEM;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Receiver path: spin up the apply worker (and its job queue) so completed
|
|
||||||
// reassemblies are applied off the Wi-Fi callback. A pure sender (master
|
|
||||||
// relay with no apply_cb) skips this to save RAM.
|
|
||||||
if (apply_cb && !s_apply_queue) {
|
|
||||||
s_apply_queue = xQueueCreate(2, sizeof(mesh_apply_job_t));
|
|
||||||
if (!s_apply_queue) return ESP_ERR_NO_MEM;
|
|
||||||
BaseType_t ok = xTaskCreate(apply_worker_task, "scn_mesh_apply",
|
|
||||||
4096, NULL, tskIDLE_PRIORITY + 2, NULL);
|
|
||||||
if (ok != pdPASS) {
|
|
||||||
vQueueDelete(s_apply_queue);
|
|
||||||
s_apply_queue = NULL;
|
|
||||||
return ESP_ERR_NO_MEM;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// esp_now_init() requires Wi-Fi to be started already (the caller brings up
|
|
||||||
// STA/AP). A second init returns ESP_ERR_ESPNOW_INTERNAL on some IDF lines;
|
|
||||||
// since no other component in this tree owns ESP-NOW yet a hard failure is
|
|
||||||
// genuinely fatal, but we keep the relay endpoint optional at the call site.
|
|
||||||
esp_err_t err = esp_now_init();
|
|
||||||
if (err != ESP_OK) {
|
|
||||||
ESP_LOGE(TAG, "esp_now_init: %s", esp_err_to_name(err));
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
|
|
||||||
esp_now_register_send_cb(on_sent);
|
|
||||||
esp_now_register_recv_cb(on_recv);
|
|
||||||
|
|
||||||
// Broadcast peer (handy for future fan-out; unicast peers are added on
|
|
||||||
// demand by scenario_mesh_register_peer).
|
|
||||||
esp_now_peer_info_t bcast = {0};
|
|
||||||
memcpy(bcast.peer_addr, kBroadcast, 6);
|
|
||||||
bcast.channel = 0;
|
|
||||||
bcast.ifidx = WIFI_IF_STA;
|
|
||||||
bcast.encrypt = false;
|
|
||||||
esp_err_t berr = esp_now_add_peer(&bcast);
|
|
||||||
if (berr != ESP_OK && berr != ESP_ERR_ESPNOW_EXIST) {
|
|
||||||
ESP_LOGW(TAG, "esp_now_add_peer(broadcast): %s",
|
|
||||||
esp_err_to_name(berr));
|
|
||||||
}
|
|
||||||
|
|
||||||
ESP_LOGI(TAG, "scenario_mesh ready (apply_cb=%s)",
|
|
||||||
apply_cb ? "set" : "none");
|
|
||||||
return ESP_OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── send (chunk + per-frame ack) ───────────────────────────────────────────
|
|
||||||
|
|
||||||
esp_err_t scenario_mesh_send(const uint8_t dest_mac[6],
|
|
||||||
const char *data, size_t len) {
|
|
||||||
if (!dest_mac || !data || len == 0) return ESP_ERR_INVALID_ARG;
|
|
||||||
if (len > SCENARIO_MESH_MAX_BLOB) return ESP_ERR_INVALID_SIZE;
|
|
||||||
|
|
||||||
size_t total = (len + SCENARIO_MESH_PAYLOAD_MAX - 1) /
|
|
||||||
SCENARIO_MESH_PAYLOAD_MAX;
|
|
||||||
if (total == 0 || total > 0xFFFF) return ESP_ERR_INVALID_SIZE;
|
|
||||||
|
|
||||||
// Serialize: the single send-done semaphore is shared across frames.
|
|
||||||
if (xSemaphoreTake(s_send_lock, portMAX_DELAY) != pdTRUE) {
|
|
||||||
return ESP_FAIL;
|
|
||||||
}
|
|
||||||
|
|
||||||
esp_err_t result = ESP_OK;
|
|
||||||
uint8_t frame[SCENARIO_MESH_FRAME_MAX];
|
|
||||||
|
|
||||||
for (size_t seq = 0; seq < total; seq++) {
|
|
||||||
size_t off = seq * SCENARIO_MESH_PAYLOAD_MAX;
|
|
||||||
size_t chunk = len - off;
|
|
||||||
if (chunk > SCENARIO_MESH_PAYLOAD_MAX) chunk = SCENARIO_MESH_PAYLOAD_MAX;
|
|
||||||
|
|
||||||
frame[0] = (uint8_t) (seq & 0xFF);
|
|
||||||
frame[1] = (uint8_t) ((seq >> 8) & 0xFF);
|
|
||||||
frame[2] = (uint8_t) (total & 0xFF);
|
|
||||||
frame[3] = (uint8_t) ((total >> 8) & 0xFF);
|
|
||||||
memcpy(frame + SCENARIO_MESH_HEADER_BYTES, data + off, chunk);
|
|
||||||
|
|
||||||
// Drain any stale ack from a previous frame, then send + await ack.
|
|
||||||
xSemaphoreTake(s_send_done, 0);
|
|
||||||
s_last_send_status = ESP_NOW_SEND_FAIL;
|
|
||||||
|
|
||||||
esp_err_t serr = esp_now_send(dest_mac, frame,
|
|
||||||
SCENARIO_MESH_HEADER_BYTES + chunk);
|
|
||||||
if (serr != ESP_OK) {
|
|
||||||
ESP_LOGW(TAG, "esp_now_send frame %u/%u: %s",
|
|
||||||
(unsigned) seq, (unsigned) total, esp_err_to_name(serr));
|
|
||||||
result = serr;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (xSemaphoreTake(s_send_done,
|
|
||||||
pdMS_TO_TICKS(SCENARIO_MESH_ACK_TIMEOUT_MS))
|
|
||||||
!= pdTRUE) {
|
|
||||||
ESP_LOGW(TAG, "ack timeout on frame %u/%u",
|
|
||||||
(unsigned) seq, (unsigned) total);
|
|
||||||
result = ESP_ERR_TIMEOUT;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (s_last_send_status != ESP_NOW_SEND_SUCCESS) {
|
|
||||||
ESP_LOGW(TAG, "frame %u/%u not acked by peer",
|
|
||||||
(unsigned) seq, (unsigned) total);
|
|
||||||
result = ESP_ERR_TIMEOUT; // surfaced as a skipped peer
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
xSemaphoreGive(s_send_lock);
|
|
||||||
|
|
||||||
if (result == ESP_OK) {
|
|
||||||
ESP_LOGI(TAG, "sent scenario: %u bytes in %u frames",
|
|
||||||
(unsigned) len, (unsigned) total);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user