diff --git a/.gitignore b/.gitignore index acad090..9847f8f 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,4 @@ managed_components/ dependencies.lock sdkconfig .cache/ +sdkconfig.old diff --git a/box3_voice/CMakeLists.txt b/box3_voice/CMakeLists.txt index 6d966db..0cfb4a7 100644 --- a/box3_voice/CMakeLists.txt +++ b/box3_voice/CMakeLists.txt @@ -1,4 +1,9 @@ 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) project(zacus-box3-voice) diff --git a/docs/scenario-mesh-receiver-patch.md b/docs/scenario-mesh-receiver-patch.md index ce7ca88..e5a265d 100644 --- a/docs/scenario-mesh-receiver-patch.md +++ b/docs/scenario-mesh-receiver-patch.md @@ -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 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 -(`idf_zacus/components/scenario_mesh` and -`box3_voice/components/scenario_mesh`). They are byte-identical today. If the -frame header or `SCENARIO_MESH_*` constants ever change, update **both** (and -any future PLIP/puzzle copy). A follow-up could hoist the component to a shared -`lib/` path referenced via `EXTRA_COMPONENT_DIRS` to remove the duplication. +`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`. diff --git a/idf_zacus/CMakeLists.txt b/idf_zacus/CMakeLists.txt index 5087f06..b7f0bcb 100644 --- a/idf_zacus/CMakeLists.txt +++ b/idf_zacus/CMakeLists.txt @@ -6,8 +6,9 @@ cmake_minimum_required(VERSION 3.16) # Local components live under idf_zacus/components/ (e.g. ota_server inherited -# from the 2026-04-03 IDF bootstrap). -set(EXTRA_COMPONENT_DIRS components) +# from the 2026-04-03 IDF bootstrap). scenario_mesh is shared with box3_voice +# 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) project(zacus_master) diff --git a/idf_zacus/components/scenario_mesh/CMakeLists.txt b/idf_zacus/components/scenario_mesh/CMakeLists.txt deleted file mode 100644 index 4520f05..0000000 --- a/idf_zacus/components/scenario_mesh/CMakeLists.txt +++ /dev/null @@ -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 -) diff --git a/idf_zacus/components/scenario_mesh/include/scenario_mesh.h b/idf_zacus/components/scenario_mesh/include/scenario_mesh.h deleted file mode 100644 index 2db86d4..0000000 --- a/idf_zacus/components/scenario_mesh/include/scenario_mesh.h +++ /dev/null @@ -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 -#include -#include - -#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 diff --git a/idf_zacus/components/scenario_mesh/scenario_mesh.c b/idf_zacus/components/scenario_mesh/scenario_mesh.c deleted file mode 100644 index 89d10e3..0000000 --- a/idf_zacus/components/scenario_mesh/scenario_mesh.c +++ /dev/null @@ -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 - -#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; -} diff --git a/box3_voice/components/scenario_mesh/CMakeLists.txt b/lib/scenario_mesh/CMakeLists.txt similarity index 100% rename from box3_voice/components/scenario_mesh/CMakeLists.txt rename to lib/scenario_mesh/CMakeLists.txt diff --git a/box3_voice/components/scenario_mesh/include/scenario_mesh.h b/lib/scenario_mesh/include/scenario_mesh.h similarity index 100% rename from box3_voice/components/scenario_mesh/include/scenario_mesh.h rename to lib/scenario_mesh/include/scenario_mesh.h diff --git a/box3_voice/components/scenario_mesh/scenario_mesh.c b/lib/scenario_mesh/scenario_mesh.c similarity index 100% rename from box3_voice/components/scenario_mesh/scenario_mesh.c rename to lib/scenario_mesh/scenario_mesh.c