diff --git a/docs/design/espnow-json-protocol.md b/docs/design/espnow-json-protocol.md new file mode 100644 index 0000000000..bafa863feb --- /dev/null +++ b/docs/design/espnow-json-protocol.md @@ -0,0 +1,153 @@ +# Bidirectional ESP-NOW API + +This document specifies the wire protocol WLED uses for **bidirectional** ESP-NOW communication with a remote. It is aimed at authors of remote firmware (e.g. a touch-screen controller) that want WLED JSON state control. [See a working remote that uses this API](https://github.com/figamore/wled-touch-remote) + +The API is supported on ESP32-family builds. ESP8266 builds retain classic ESP-NOW sync and WizMote support but do not include this API. + +The classic ESP-NOW remote support is one-way: WizMote-style remotes can only *send* button presses. This API adds a two-way channel so a remote can issue WLED JSON state API commands (`/json/state` parity), receive responses, subscribe to binary LED peek frames, and be **pushed** state updates whenever WLED's state changes - including changes made from the WebUI or another remote. + +## Relationship to the WebSocket / HTTP JSON API + +A `REQUEST` payload is byte-for-byte the same JSON you would `POST` to `/json/state` or send over the `/ws` WebSocket for state control. It is applied through the same `deserializeState()` path, so every state field (`on`, `bri`, `seg`, `ps`, `playlist`, …) behaves identically. A verbose `RESPONSE` carries `{"state":…, "info":…}`, the same object the WebSocket broadcasts. Routine companion polling and `PUSH` messages use the compact state form described below. + +The WebSocket live LED peek command `{"lv":true}` is also supported. It subscribes the remote to `LIVE` frames carrying the same binary payload used by WebSocket liveview: `L`, version byte, optional 2D dimensions, then RGB triples. `{"lv":false}` unsubscribes. + +### Catalog requests + +`deserializeState()` only *applies* state, so the effect/palette/preset catalogs (which the WebUI fetches from `/json` and `/presets.json`) are exposed through a `{"get":…}` REQUEST. The `RESPONSE` lets a remote populate its lists instead of hardcoding them: + +| Request | Response | +|------------------|----------| +| `{"get":"fx"}` | `{"effects":["Solid","Blink",…]}` - array index = effect id | +| `{"get":"pal"}` | `{"palettes":["Default","* Random Cycle",…]}` - array index = palette id | +| `{"get":"ps"}` | `{"presets":{"1":"Sunset","2":"Party",…}}` - only existing presets, by id | + +The preset catalog covers the full id range 1–250 in a single pass over `presets.json`. Catalog responses can be large; if one exceeds `ESPNOW_API_MAX_JSON`, WLED returns `{"error":8}` and the remote should fall back to a built-in list. An unknown `get` value returns `{"error":10}`. + +## Security model + +There is **no encryption or authentication on the wire in v1**. Access is gated by the existing **MAC allow-list** (`linked_remotes`, configured under *WiFi Settings → ESP-NOW Wireless*). Only frames from an allow-listed MAC are processed, including `DISCOVER` frames - identical to how the existing WizMote remote path already grants allow-listed MACs full JSON-API access via `remote.json`. Optional ESP-NOW PMK/LMK encryption is possible future work. + +No extra setting is required: as with the WizMote path, the API is active whenever ESP-NOW is enabled (*WiFi Settings → ESP-NOW Wireless*) and the remote's MAC is in the allow-list. Before pairing, a syntactically valid WiZ Mote frame, API `REQUEST`, or API `DISCOVER` only updates the single WiFi-settings *"Last device seen"* bonding candidate; WLED does not parse JSON, send `ANNOUNCE`, or apply control from an unknown MAC. WLED only broadcasts state `PUSH` frames once it has actually seen an API frame from an allow-listed remote, so a WizMote-only deployment never emits them. + +## Frame format + +ESP-NOW limits a frame to 250 bytes. Each frame is a 6-byte header followed by up to 244 bytes of a possibly fragmented payload: + +| Offset | Field | Size | Description | +|-------:|-------------|-----:|--------------------------------------------------------------------| +| 0 | `magic` | 1 | `0x4E` (`'N'`). Distinct from WizMote (`0x80/0x81/0x91`) and sync (`'W'`/`0x57`). | +| 1 | `version` | 1 | Protocol version, currently `0x01`. | +| 2 | `msgType` | 1 | Message type (see below). | +| 3 | `msgId` | 1 | Sender-chosen id; a `RESPONSE` echoes the `REQUEST`'s `msgId`. | +| 4 | `fragIndex` | 1 | 0-based fragment index. | +| 5 | `fragTotal` | 1 | Total number of fragments (`>= 1`). | +| 6… | `data` | ≤244 | Raw payload bytes for this fragment (not NUL-terminated). | + +### Message types (`msgType`) + +| Value | Name | Direction | Payload | +|------:|------------|------------------|---------| +| 0x01 | `REQUEST` | remote → WLED | A JSON command (same as a `/json/state` body). `{"v":true}` requests current state without changing anything. `{"lv":true}` starts live LED peek frames (keepalive, see below); `{"lv":false}` stops them. | +| 0x02 | `RESPONSE` | WLED → remote | Reply to a `REQUEST`. `{"state":…,"info":…}` when verbose (the request set `"v":true`), otherwise `{"success":true}`. When the request **changed** state, a compact broadcast `PUSH` is imminent, so the direct reply is `{"success":true}` even if `"v":true` was set (same rule as the WebSocket API). On error, `{"error":}` - see the error codes below. `msgId` echoes the request. | +| 0x03 | `PUSH` | WLED → remotes | Unsolicited compact `{"state":…}` broadcast whenever WLED's state changes. `msgId` is a free-running counter. No reply expected. | +| 0x04 | `DISCOVER` | remote → WLED | Empty discovery query, broadcast on the channel being scanned. `msgId` identifies that scan. The source must already be in WLED's MAC allow-list. The constant retains its historical `ESPNOW_API_HELLO` name in source for compatibility. | +| 0x05 | `LIVE` | WLED → remote | Binary LED peek frame sent after `{"lv":true}`. Payload is the same as WebSocket liveview binary frames: `L`, version `1` or `2`, optional 2D width/height for version `2`, then sampled RGB triples. | +| 0x06 | `ANNOUNCE` | WLED → remote | Reliable unicast response to `DISCOVER`. It echoes the discovery `msgId` and carries `{"announce":{"name":…,"mac":…,"ver":…,"ch":…,"proto":1,"cap":15}}`. The link-layer source MAC is authoritative. | + +`announce.cap` is a bit mask: bit 0 = compact state, bit 1 = catalogs, bit 2 = unsolicited state push, and bit 3 = live peek. A remote must ignore unknown capability bits and must not use an optional feature whose bit is absent. + +For low-overhead polling, `{"v":"compact"}` returns power, brightness, preset, and the main-segment effect controls in a single-frame state document. `{"v":true}` retains the full WebSocket-compatible state response. Unsolicited `PUSH` messages use the compact form. + +### Error codes + +| Code | Meaning | Remote should | +|-----:|---------|---------------| +| 3 | Transient: JSON buffer, TX slot or heap busy | Retry after a short delay | +| 8 | Response exceeds `ESPNOW_API_MAX_JSON` | Not retry; fall back (non-verbose polling / built-in list) | +| 9 | Request failed to parse as JSON | Fix the request | +| 10 | Unknown `{"get":…}` catalog key | Fix the request | + +Every accepted `REQUEST` - including catalog requests - is answered with either its payload or an `{"error":…}`. A request may still time out before it reaches the handler if reassembly allocation fails, an incomplete request is displaced, or the receive inbox is full; the remote should retry the same request after its timeout. `ANNOUNCE` and `RESPONSE` are reliable unicasts; `PUSH` and `LIVE` remain best-effort because a later poll or frame supersedes them. + +**Live keepalive.** ESP-NOW has no connection or disconnect signal, so a `{"lv":true}` subscription is a keepalive: WLED stops streaming `LIVE` frames **30 s** after the last `{"lv":true}` unless the remote re-arms it. A remote that wants a continuous live view should re-send `{"lv":true}` every ~10 s; send `{"lv":false}` to stop immediately. Only one live subscriber is served at a time (a `LIVE` payload is binary - branch on `msgType == 0x05` before parsing reassembled bytes as JSON). + +> **Response size cap.** A full `{"state","info"}` response is often 1.5–3 KB. If it exceeds +> `ESPNOW_API_MAX_JSON`, a verbose `RESPONSE` returns `{"error":8}`. Compact polling and +> compact `PUSH` messages remain available. + +## Fragmentation & reassembly + +- A logical message is identified by the tuple `(source MAC, msgType, msgId)`. All of its fragments share the same `magic`, `version`, `msgType`, `msgId` and `fragTotal`; `fragIndex` runs `0 … fragTotal-1`. +- Every fragment except the last **must** carry a full 244-byte payload so byte offsets line up; the last fragment carries the remainder. +- The receiver allocates a buffer of `fragTotal × 244` bytes on the first fragment and writes each fragment at `fragIndex × 244`. The message is complete once all fragments have arrived. +- **Fail-closed:** any out-of-order start, mismatched `(MAC,msgType,msgId,fragTotal)`, oversized payload, or duplicate is discarded; partial buffers are abandoned after **500 ms**, even if no additional ESP-NOW API frames arrive. +- WLED bounds a single message to `ESPNOW_API_MAX_JSON` (8192 bytes). A remote should do the same for JSON requests. A full multi-segment `{"state","info"}` is typically 1–4 KB, i.e. several fragments. If a verbose response is too large to send, WLED replies with `{"error":8}` instead of silently dropping the response. + +WLED's native transport copies callback frames into a bounded two-frame receive queue allocated only while ESP-NOW is active, then reassembles and applies JSON in the main loop. It processes requests serially; a remote should wait for a matching `RESPONSE` (or retry after a short timeout) before sending the next `REQUEST`. + +## Multiple WLED instances from one remote + +No explicit instance id is needed. Because every ESP-NOW frame carries the sender's MAC at the link layer, a remote: + +- **targets** a specific WLED by unicasting a `REQUEST` to that WLED's MAC, and +- **distinguishes** instances by the source MAC of the `RESPONSE`/`PUSH` frames it receives. + +`PUSH` frames are broadcast (one transmission reaches all paired remotes) but still carry the originating WLED's source MAC, so a remote tracking several instances can route each push to the right one. + +## Discovery & channel + +ESP-NOW peers must be on the **same WiFi channel**. A WLED instance uses the channel of the network it joined (STA mode) or its AP channel (AP mode). To discover instances, a remote broadcasts a `DISCOVER` on a channel and collects unicast `ANNOUNCE` replies whose `msgId` matches that scan; it can iterate channels until replies arrive. Matching the transaction ID prevents delayed or unrelated discovery traffic from changing the registry. The remote learns each WLED's MAC from the reply's link-layer source address (the `mac` field is convenience metadata). + +WLED learns the remote's MAC for the WiFi-settings *"Last device seen"* field from a syntactically valid inbound control `REQUEST`, remote `DISCOVER`, or WiZ Mote frame, including before the remote has been allow-listed. This bonding-candidate update keeps only one MAC, performs no JSON parsing or reassembly, and sends no reply; unknown remotes must be added to the allow-list through configuration before discovery replies or control frames are processed. Outbound-direction frames such as `ANNOUNCE` are deliberately ignored as bonding candidates. + +`ANNOUNCE` replies use a short MAC-derived delay. This staggers several WLED instances responding to one discovery event, while unicast delivery supplies a MAC-level acknowledgement. Remotes should keep the discovery window open long enough to collect every reply rather than locking onto the first responder. + +## Reliability notes (v1) + +- A remote sends one `REQUEST` at a time and waits for the matching `(source MAC,msgId)` `RESPONSE`. It retries with the **same message ID and identical payload** after timeout. +- WLED caches recently completed mutations by `(source MAC,msgId,payload hash)` for ten seconds, longer than the bounded retry horizon but short enough to avoid normal 8-bit `msgId` wrap. A retry receives another success response without applying a toggle or other non-idempotent mutation twice. Read-only polls and catalog requests may safely be regenerated. +- A dropped fragment loses the logical message. The request timeout/retry recovers it; fragmented best-effort `PUSH` and `LIVE` data are simply superseded by a later poll/frame. +- `PUSH` is best-effort: a missed push is corrected by the next state change or an explicit poll. +- WLED transmits one outbound message at a time, a few fragments per main-loop pass, so large responses never stall LED rendering. A `RESPONSE` or `ANNOUNCE` reply preempts a pending `PUSH`/`LIVE` transmission; a `PUSH` or `LIVE` frame that would have to wait is skipped instead (the next state change or live frame supersedes it). +- Only one ESP-NOW live LED peek subscriber is tracked at a time, matching the existing WebSocket liveview behavior. +- Live preview is capped at 256 pixels and 10 frames per second so fragmented preview traffic does not starve request/response control traffic. +- While an API remote is actively communicating (any API frame within the last 2 minutes), WLED keeps its fallback AP and ESP-NOW channel stable instead of performing a destructive STA retry. Normal WiFi retries resume after the remote becomes inactive; an explicit settings change or manual reconnect may still restart the radio. A remote should treat silence as a hint to re-`DISCOVER`/re-subscribe. + +## Examples + +Set power and brightness (single fragment): + +```text +header: 4E 01 01 07 00 01 msgType=REQUEST msgId=7 frag 0/1 +data : {"on":true,"bri":128,"v":true} +``` +WLED replies with `{"success":true}` and then broadcasts the updated compact state in a +`PUSH` frame. + +Poll current state without changing anything: + +```text +header: 4E 01 01 08 00 01 REQUEST msgId=8 +data : {"v":true} +``` +WLED replies with one or more `RESPONSE` (0x02) frames, `msgId=8`, reassembling to +`{"state":{…},"info":{…}}`. + +Companion remotes should poll with `{"v":"compact"}` instead. Its response contains only the power, brightness, preset, and main-segment controls and fits in one ESP-NOW frame. + +Subscribe to LED peek frames: + +```text +header: 4E 01 01 09 00 01 REQUEST msgId=9 +data : {"lv":true} +``` +WLED replies with `{"success":true}`, then sends `LIVE` (0x05) frames about every 100 ms. Reassemble each `LIVE` message by `msgId`; its payload starts with the WebSocket liveview binary header (`L`, version byte) followed by sampled RGB triples. + +Discovery: + +```text +header: 4E 01 04 2A 00 01 DISCOVER msgId=42, broadcast +data : +``` +Each allow-listed WLED replies by unicast with type `ANNOUNCE` (`0x06`), `msgId=42`, and `{"announce":{"name":"Living Room","mac":"a1b2c3d4e5f6","ver":2605011,"ch":6,"proto":1,"cap":15}}`. diff --git a/platformio_override.sample.ini b/platformio_override.sample.ini index b1c3d21e55..32000428d0 100644 --- a/platformio_override.sample.ini +++ b/platformio_override.sample.ini @@ -26,8 +26,6 @@ lib_deps = ${esp8266.lib_deps} ; adafruit/Adafruit BME280 Library@^2.2.2 ; Wire ; robtillaart/SHT85@~0.3.3 -; ;gmag11/QuickESPNow @ ~0.7.0 # will also load QuickDebug -; https://github.com/blazoncek/QuickESPNow.git#optional-debug ;; exludes debug library build_unflags = ${common.build_unflags} build_flags = ${common.build_flags} ${esp8266.build_flags} -D WLED_RELEASE_NAME=\"ESP8266_generic_1M\" diff --git a/wled00/const.h b/wled00/const.h index 00a6b4d226..94e69b4821 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -404,6 +404,28 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); #define ESP_NOW_STATE_ON 1 #define ESP_NOW_STATE_ERROR 2 +#ifdef ARDUINO_ARCH_ESP32 +static constexpr uint8_t ESPNOW_BROADCAST_ADDRESS[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + +// Bidirectional ESP-NOW API +#define ESPNOW_API_MAGIC 0x4E // 'N' - distinct from WizMote (0x80/0x81/0x91) and sync ('W'/0x57) +#define ESPNOW_API_VERSION 0x01 // wire protocol version +#define ESPNOW_API_HEADER_SIZE 6 // magic, version, msgType, msgId, fragIndex, fragTotal +#define ESPNOW_API_FRAG_SIZE 244 // payload bytes per frame (250 ESP-NOW limit - 6 header) +// message types +#define ESPNOW_API_REQUEST 0x01 // remote -> WLED, JSON command (deserializeState parity) +#define ESPNOW_API_RESPONSE 0x02 // WLED -> remote, reply to a request (echoes msgId) +#define ESPNOW_API_PUSH 0x03 // WLED -> remotes, unsolicited state broadcast on change +#define ESPNOW_API_DISCOVER 0x04 // remote -> WLED discovery query +#define ESPNOW_API_HELLO ESPNOW_API_DISCOVER // compatibility alias for early API prototypes +#define ESPNOW_API_LIVE 0x05 // WLED -> remote, binary LED peek frame (same payload as WS liveview) +#define ESPNOW_API_ANNOUNCE 0x06 // WLED -> remote reliable unicast discovery response +// reassembly/serialization caps +#define ESPNOW_API_MAX_JSON 8192 +#define ESPNOW_API_MAX_FRAGS ((ESPNOW_API_MAX_JSON / ESPNOW_API_FRAG_SIZE) + 1) +#define ESPNOW_API_REASM_TIMEOUT 500 // ms before an incomplete reassembly buffer is abandoned +#endif + //Button type #define BTN_TYPE_NONE 0 #define BTN_TYPE_RESERVED 1 diff --git a/wled00/espnow_api.cpp b/wled00/espnow_api.cpp new file mode 100644 index 0000000000..0f27f8d850 --- /dev/null +++ b/wled00/espnow_api.cpp @@ -0,0 +1,663 @@ +#include "wled.h" +#if !defined(WLED_DISABLE_ESPNOW) && defined(ARDUINO_ARCH_ESP32) && defined(WLED_ENABLE_ESPNOW_API) + +// Bidirectional JSON transport for linked ESP-NOW remotes. Frames are fragmented to fit +// the 250-byte ESP-NOW payload limit +// Received frames are reassembled in loop context before touching JSON, FS or LED state. + +#define ESPNOW_LIVE_INTERVAL 100 // ESP-NOW live peek cadence (ms), bounded to avoid radio saturation +#define ESPNOW_LIVE_TIMEOUT 30000 // stop live peek if {"lv":true} is not re-armed within this window +#define ESPNOW_API_PRESENCE_TIMEOUT 120000 // push state only while an API remote has been seen this recently +#define ESPNOW_API_DEDUPE_TIMEOUT 10000 // exceeds the bounded retry horizon without spanning normal msgId wrap + +#define ESPNOW_API_CAP_COMPACT 0x01 +#define ESPNOW_API_CAP_CATALOGS 0x02 +#define ESPNOW_API_CAP_PUSH 0x04 +#define ESPNOW_API_CAP_LIVE 0x08 + +// Wire error codes. +#define ESPNOW_API_ERR_BUSY 3 // transient (JSON buffer or TX slot busy, low heap) - retry +#define ESPNOW_API_ERR_SIZE 8 // response exceeds ESPNOW_API_MAX_JSON - do not retry +#define ESPNOW_API_ERR_JSON 9 // request failed to parse +#define ESPNOW_API_ERR_GET 10 // unknown {"get":...} catalog key + +#if ESPNOW_API_MAX_FRAGS <= 16 +typedef uint16_t espnow_frag_mask_t; // ESP8266: 9 fragments max, avoid 64-bit shifts in the RX callback +#else +typedef uint64_t espnow_frag_mask_t; +#endif + +struct EspNowApiInbox { + uint8_t srcMac[6]; + uint8_t msgId; + uint8_t* json; // NUL-terminated heap buffer; ownership passes to the loop + size_t len; +}; +// Two slots absorb a request arriving while the main loop is finishing the previous one. +// The remote still retries on timeout because ESP-NOW itself is best-effort. +static EspNowApiInbox apiInbox[2] = {}; +static uint8_t apiInboxRead = 0; +static uint8_t apiInboxWrite = 0; +static uint8_t apiInboxCount = 0; + +// One in-progress fragmented request. Completed buffers move to apiInbox. +static uint8_t* apiReasmBuf = nullptr; +static uint8_t apiReasmSrc[6]= {0}; +static uint8_t apiReasmId = 0; +static uint8_t apiReasmTotal = 0; +static uint8_t apiReasmCount = 0; +static espnow_frag_mask_t apiReasmFlags = 0; // received-fragment bitmask +static size_t apiReasmLen = 0; +static unsigned long apiReasmLast = 0; +static unsigned long apiRemoteSeen = 0; // last API frame; gates state pushes/reconnect deferral + +// Live preview is best-effort and tracks one subscriber at a time. +static bool apiLiveActive = false; +static uint8_t apiLiveMac[6] = {0}; +static uint8_t apiLiveMsgId = 0; +static uint8_t apiLiveSendFailures = 0; +static unsigned long apiLastLiveTime = 0; +static unsigned long apiLiveExpiry = 0; // live peek is a keepalive (no disconnect signal over ESP-NOW) + +// Pushes are coalesced because only the newest state matters. +static bool apiPushPending = false; // coalesced state push waiting for the reliable TX slot +static unsigned long apiPushDue = 0; // MAC-derived jitter avoids simultaneous multi-WLED broadcasts +struct EspNowApiDiscoveryReply { + uint8_t mac[6]; + uint8_t msgId; + unsigned long due; + bool pending; +}; +// Two slots allow two whitelisted remotes to discover concurrently without one replacing the other. +static EspNowApiDiscoveryReply apiDiscoveryReplies[2] = {}; + +struct EspNowApiCompletedMutation { + uint8_t mac[6]; + uint8_t msgId; + uint32_t hash; + unsigned long completedAt; +}; +// Retaining a few completed mutations makes retries idempotent even when their first response +// was lost after WLED had already applied the state change. +static EspNowApiCompletedMutation apiCompletedMutations[4] = {}; +static uint8_t apiCompletedMutationNext = 0; + +// Single pending outbound message, drained incrementally by serviceEspNowApiTx(). +struct EspNowApiTx { + uint8_t mac[6]; + uint8_t msgType; + uint8_t msgId; + uint8_t* payload; // heap buffer, owned; nullptr = slot idle + size_t len; + uint8_t fragTotal; + uint8_t fragNext; +}; +static EspNowApiTx apiTx = {{0}, 0, 0, nullptr, 0, 0, 0}; + +// Drop the partial message and release its buffer. +static void apiReasmReset() { + if (apiReasmBuf) { free(apiReasmBuf); apiReasmBuf = nullptr; } + apiReasmTotal = apiReasmCount = 0; + apiReasmFlags = 0; + apiReasmLen = 0; +} + +#ifdef WLED_DEBUG +static const char* apiTypeName(uint8_t type) { + switch (type) { + case ESPNOW_API_REQUEST: return "REQUEST"; + case ESPNOW_API_RESPONSE: return "RESPONSE"; + case ESPNOW_API_PUSH: return "PUSH"; + case ESPNOW_API_DISCOVER: return "DISCOVER"; + case ESPNOW_API_LIVE: return "LIVE"; + case ESPNOW_API_ANNOUNCE: return "ANNOUNCE"; + default: return "UNKNOWN"; + } +} +#endif + +// Release completed requests that have not yet reached the main handler. +static void apiInboxReset() { + for (auto &inbox : apiInbox) { + if (inbox.json) { free(inbox.json); inbox.json = nullptr; } + inbox.len = 0; + } + apiInboxRead = apiInboxWrite = apiInboxCount = 0; +} + +// Clear the subscriber and its delivery health counters. +static void apiLiveReset() { + apiLiveActive = false; + apiLiveMsgId = 0; + apiLiveSendFailures = 0; + apiLastLiveTime = 0; + apiLiveExpiry = 0; +} + +// Returns a stable per-instance delay so several WLEDs do not answer one broadcast in lockstep. +static uint16_t apiInstanceJitter(uint16_t window, uint16_t minimum = 0) { + uint8_t mac[6]; + WiFi.macAddress(mac); + const uint16_t hash = (uint16_t(mac[3]) << 8) ^ (uint16_t(mac[4]) << 4) ^ mac[5]; + return minimum + (window ? hash % window : 0); +} + +static uint32_t apiPayloadHash(const uint8_t* data, size_t len) { + // This only distinguishes retries; it is not used for authentication!! + uint32_t hash = 2166136261UL; + for (size_t i = 0; i < len; i++) hash = (hash ^ data[i]) * 16777619UL; + return hash; +} + +// Match a retry against a recently applied state-changing request. +static bool apiMutationWasCompleted(const uint8_t* mac, uint8_t msgId, uint32_t hash) { + const unsigned long now = millis(); + for (const auto &record : apiCompletedMutations) { + if (!record.completedAt || now - record.completedAt > ESPNOW_API_DEDUPE_TIMEOUT) continue; + if (record.msgId == msgId && record.hash == hash && memcmp(record.mac, mac, sizeof(record.mac)) == 0) return true; + } + return false; +} + +// Store mutations in a small ring so old entries naturally cycle out. +static void rememberCompletedMutation(const uint8_t* mac, uint8_t msgId, uint32_t hash) { + EspNowApiCompletedMutation &record = apiCompletedMutations[apiCompletedMutationNext]; + memcpy(record.mac, mac, sizeof(record.mac)); + record.msgId = msgId; + record.hash = hash; + const unsigned long now = millis(); + record.completedAt = now ? now : 1; + apiCompletedMutationNext = (apiCompletedMutationNext + 1) % (sizeof(apiCompletedMutations) / sizeof(apiCompletedMutations[0])); +} + +// Stop a stale live stream after repeated MAC-level failures; a refresh request restarts it. +void espNowApiOnSendResult(uint8_t* address, uint8_t status) { + if (!apiLiveActive || !address || memcmp(address, apiLiveMac, sizeof(apiLiveMac)) != 0) return; + if (!status) { + apiLiveSendFailures = 0; + return; + } + if (++apiLiveSendFailures >= 3) { + DEBUG_PRINTLN(F("ESP-NOW API stopping live stream after 3 send failures.")); + apiLiveReset(); + } +} + +// Release the current outbound payload and mark the single TX slot idle. +static void apiTxReset() { + if (apiTx.payload) { free(apiTx.payload); apiTx.payload = nullptr; } +} + +// Reassembly can expire even when no further API packets arrive. +static void apiReasmCleanupStale() { + if (apiReasmBuf && millis() - apiReasmLast > ESPNOW_API_REASM_TIMEOUT) apiReasmReset(); +} + +static void scheduleEspNowAnnounce(const uint8_t* mac, uint8_t msgId); + +bool espNowApiReady() { + return enableESPNow && statusESPNow == ESP_NOW_STATE_ON; +} + +// Recent traffic keeps pushes and the fallback AP useful to a companion remote. +bool espNowApiRemoteActive() { + const unsigned long seen = apiRemoteSeen; + return seen && millis() - seen < ESPNOW_API_PRESENCE_TIMEOUT; +} + +// AI: below section was partly generated by an AI +// Reassemble a frame delivered by the native transport in loop context. +void handleEspNowApiData(uint8_t* address, uint8_t* data, uint8_t len) { + if (len < ESPNOW_API_HEADER_SIZE) return; + const uint8_t msgType = data[2]; + const uint8_t msgId = data[3]; + const uint8_t fragIndex = data[4]; + const uint8_t fragTotal = data[5]; + const uint8_t payloadLen = len - ESPNOW_API_HEADER_SIZE; + + // Check untrusted header values before indexing or allocating. + if (msgType != ESPNOW_API_REQUEST && msgType != ESPNOW_API_DISCOVER) return; // inbound direction only + if (fragTotal < 1 || fragTotal > ESPNOW_API_MAX_FRAGS) return; + if (fragIndex >= fragTotal) return; + if (payloadLen > ESPNOW_API_FRAG_SIZE) return; + if (fragIndex < fragTotal - 1 && payloadLen != ESPNOW_API_FRAG_SIZE) return; // non-final fragments are full so offsets align + if (msgType == ESPNOW_API_DISCOVER && + (fragIndex != 0 || fragTotal != 1 || + (payloadLen != 0 && (payloadLen != 2 || data[ESPNOW_API_HEADER_SIZE] != '{' || + data[ESPNOW_API_HEADER_SIZE + 1] != '}')))) return; + + unsigned long now = millis(); + apiRemoteSeen = now ? now : 1; + // DISCOVER is already fully validated and carries no useful body. Scheduling it directly + // avoids periodic heap allocation/reassembly churn on constrained WLED targets. + if (msgType == ESPNOW_API_DISCOVER) { + scheduleEspNowAnnounce(address, msgId); + return; + } + bool newMsg = (apiReasmBuf == nullptr) || (now - apiReasmLast > ESPNOW_API_REASM_TIMEOUT) || + (memcmp(apiReasmSrc, address, 6) != 0) || (apiReasmId != msgId) || + (apiReasmTotal != fragTotal); + if (newMsg) { + // Only one fragmented request is assembled at a time; remotes retry anything displaced here. + if (apiReasmBuf) { + DEBUG_PRINTF_P(PSTR("ESP-NOW API RX replacing incomplete %s id=%u fragments=%u/%u age=%lums\n"), + apiTypeName(ESPNOW_API_REQUEST), apiReasmId, + apiReasmCount, apiReasmTotal, now - apiReasmLast); + } + apiReasmReset(); + if (fragIndex != 0) { + DEBUG_PRINTF_P(PSTR("ESP-NOW API RX dropped orphan %s id=%u fragment=%u/%u\n"), + apiTypeName(msgType), msgId, fragIndex + 1, fragTotal); + return; + } + apiReasmBuf = (uint8_t*)d_malloc((size_t)fragTotal * ESPNOW_API_FRAG_SIZE + 1); + if (!apiReasmBuf) return; + memcpy(apiReasmSrc, address, 6); + apiReasmId = msgId; + apiReasmTotal = fragTotal; + } + const espnow_frag_mask_t bit = (espnow_frag_mask_t)1 << fragIndex; + if (apiReasmFlags & bit) return; // duplicate + apiReasmLast = now; + memcpy(apiReasmBuf + (size_t)fragIndex * ESPNOW_API_FRAG_SIZE, data + ESPNOW_API_HEADER_SIZE, payloadLen); + apiReasmFlags |= bit; + apiReasmCount++; + if (fragIndex == fragTotal - 1) apiReasmLen = (size_t)fragIndex * ESPNOW_API_FRAG_SIZE + payloadLen; + + if (apiReasmCount < fragTotal) return; + if (apiInboxCount >= sizeof(apiInbox) / sizeof(apiInbox[0])) { + DEBUG_PRINTF_P(PSTR("ESP-NOW API RX inbox full; dropped %s id=%u\n"), + apiTypeName(msgType), msgId); + apiReasmReset(); + return; + } + if (apiReasmLen > ESPNOW_API_MAX_JSON) { apiReasmReset(); return; } + // Hand the completed allocation to the inbox without copying it again. + apiReasmBuf[apiReasmLen] = '\0'; + EspNowApiInbox &inbox = apiInbox[apiInboxWrite]; + inbox.json = apiReasmBuf; + inbox.len = apiReasmLen; + inbox.msgId = apiReasmId; + memcpy(inbox.srcMac, apiReasmSrc, 6); + apiInboxWrite = (apiInboxWrite + 1) % (sizeof(apiInbox) / sizeof(apiInbox[0])); + apiInboxCount++; + DEBUG_PRINTF_P(PSTR("ESP-NOW API RX complete %s id=%u bytes=%u fragments=%u inbox=%u\n"), + apiTypeName(msgType), msgId, + unsigned(apiReasmLen), fragTotal, apiInboxCount); + apiReasmBuf = nullptr; // ownership moved to the inbox; reset clears the remaining state + apiReasmReset(); +} +// AI: end + +static bool apiTxIdle() { return apiTx.payload == nullptr; } + +// Send the next pending fragment; the transport admits another only after completion. +static void serviceEspNowApiTx() { + if (apiTxIdle()) return; + if (statusESPNow != ESP_NOW_STATE_ON) { apiTxReset(); return; } + if (!espNowTransportReadyToSend()) return; + uint8_t frame[ESPNOW_API_HEADER_SIZE + ESPNOW_API_FRAG_SIZE]; + frame[0] = ESPNOW_API_MAGIC; + frame[1] = ESPNOW_API_VERSION; + frame[2] = apiTx.msgType; + frame[3] = apiTx.msgId; + frame[5] = apiTx.fragTotal; + size_t off = (size_t)apiTx.fragNext * ESPNOW_API_FRAG_SIZE; + size_t chunk = apiTx.len - off; + if (chunk > ESPNOW_API_FRAG_SIZE) chunk = ESPNOW_API_FRAG_SIZE; + frame[4] = apiTx.fragNext; + memcpy(frame + ESPNOW_API_HEADER_SIZE, apiTx.payload + off, chunk); + // Submission errors abort this message; link failures arrive later via the send callback. + if (espNowTransportSend(apiTx.mac, frame, ESPNOW_API_HEADER_SIZE + chunk)) { apiTxReset(); return; } + if (++apiTx.fragNext >= apiTx.fragTotal) apiTxReset(); +} + +// Queue a payload for transmission; takes ownership of the heap buffer on success. +// A pending PUSH or LIVE frame is droppable and is preempted; anything else keeps the slot. +static bool apiTxEnqueue(const uint8_t* mac, uint8_t msgType, uint8_t msgId, uint8_t* payload, size_t len) { + if (statusESPNow != ESP_NOW_STATE_ON || !mac || !payload || !len) return false; + size_t total = (len + ESPNOW_API_FRAG_SIZE - 1) / ESPNOW_API_FRAG_SIZE; + if (total > ESPNOW_API_MAX_FRAGS) return false; + if (!apiTxIdle()) { + if (apiTx.msgType == ESPNOW_API_PUSH || apiTx.msgType == ESPNOW_API_LIVE) apiTxReset(); + else return false; + } + memcpy(apiTx.mac, mac, 6); + apiTx.msgType = msgType; + apiTx.msgId = msgId; + apiTx.payload = payload; + apiTx.len = len; + apiTx.fragTotal = (uint8_t)total; + apiTx.fragNext = 0; + if (msgType != ESPNOW_API_LIVE) { + DEBUG_PRINTF_P(PSTR("ESP-NOW API TX queued %s id=%u bytes=%u fragments=%u to " MACSTR " ch=%u\n"), + apiTypeName(msgType), msgId, unsigned(len), + unsigned(total), MAC2STR(mac), WiFi.channel()); + } + serviceEspNowApiTx(); + return true; +} + +// Copy short stack-backed JSON into the owned TX buffer. +static bool sendEspNowApiJson(const uint8_t* mac, uint8_t msgType, uint8_t msgId, const char* json, size_t jsonLen) { + uint8_t* buf = (uint8_t*)d_malloc(jsonLen); + if (!buf) return false; + memcpy(buf, json, jsonLen); + if (!apiTxEnqueue(mac, msgType, msgId, buf, jsonLen)) { free(buf); return false; } + return true; +} + +static void sendEspNowApiError(const uint8_t* mac, uint8_t msgId, uint8_t code) { + char buf[16]; + int len = sprintf_P(buf, PSTR("{\"error\":%u}"), code); + sendEspNowApiJson(mac, ESPNOW_API_RESPONSE, msgId, buf, len); +} + +static void sendEspNowApiSuccess(const uint8_t* mac, uint8_t msgId) { + static const char response[] = "{\"success\":true}"; + sendEspNowApiJson(mac, ESPNOW_API_RESPONSE, msgId, response, sizeof(response) - 1); +} + +// Serialize the prepared pDoc and queue it. Caller holds JSON_LOCK_REMOTE; this releases it. +// Returns 0 on success or the API error code to report. +static uint8_t queueApiDocLocked(const uint8_t* mac, uint8_t msgType, uint8_t msgId) { + size_t len = measureJson(*pDoc); + if (len == 0 || len > ESPNOW_API_MAX_JSON) { releaseJSONBufferLock(); return ESPNOW_API_ERR_SIZE; } + uint8_t* buf = (uint8_t*)d_malloc(len + 1); + if (!buf) { releaseJSONBufferLock(); return ESPNOW_API_ERR_BUSY; } + serializeJson(*pDoc, (char*)buf, len + 1); + releaseJSONBufferLock(); + if (!apiTxEnqueue(mac, msgType, msgId, buf, len)) { free(buf); return ESPNOW_API_ERR_BUSY; } + return 0; +} + +// Build the full WebSocket-compatible state and info response. +static uint8_t queueEspNowApiState(const uint8_t* mac, uint8_t msgType, uint8_t msgId) { + if (statusESPNow != ESP_NOW_STATE_ON) return ESPNOW_API_ERR_BUSY; + if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) return ESPNOW_API_ERR_BUSY; + pDoc->clear(); + JsonObject state = pDoc->createNestedObject("state"); + serializeState(state); + JsonObject info = pDoc->createNestedObject("info"); + serializeInfo(info); + return queueApiDocLocked(mac, msgType, msgId); +} + +// Serialize only the fields used by companion remotes so routine state fits in one frame. +static uint8_t queueEspNowApiCompactState(const uint8_t* mac, uint8_t msgType, uint8_t msgId) { + if (statusESPNow != ESP_NOW_STATE_ON) return ESPNOW_API_ERR_BUSY; + const uint8_t mainSegmentId = strip.getMainSegmentId(); + const Segment &mainseg = strip.getMainSegment(); + char json[176]; + int len = snprintf_P(json, sizeof(json), PSTR("{\"state\":{\"on\":%s,\"bri\":%u,\"ps\":%d,\"mainseg\":%u,\"seg\":[{\"id\":%u,\"fx\":%u,\"pal\":%u,\"sx\":%u,\"ix\":%u,\"c1\":%u,\"c2\":%u,\"c3\":%u,\"col\":[[%u,%u,%u]]}]}}"), + bri > 0 ? "true" : "false", unsigned(briLast), currentPreset > 0 ? int(currentPreset) : -1, + unsigned(mainSegmentId), unsigned(mainSegmentId), + unsigned(mainseg.mode), unsigned(mainseg.palette), unsigned(mainseg.speed), unsigned(mainseg.intensity), + unsigned(mainseg.custom1), unsigned(mainseg.custom2), unsigned(mainseg.custom3), + unsigned(R(mainseg.colors[0])), unsigned(G(mainseg.colors[0])), unsigned(B(mainseg.colors[0]))); + if (len <= 0 || len >= int(sizeof(json))) return ESPNOW_API_ERR_SIZE; + return sendEspNowApiJson(mac, msgType, msgId, json, len) ? 0 : ESPNOW_API_ERR_BUSY; +} + +// Choose between a small acknowledgement and a full state snapshot. +static void sendEspNowApiResponse(const uint8_t* mac, uint8_t msgId, bool verbose) { + if (!verbose) { sendEspNowApiSuccess(mac, msgId); return; } + uint8_t err = queueEspNowApiState(mac, ESPNOW_API_RESPONSE, msgId); + if (err) sendEspNowApiError(mac, msgId, err); +} + +// Reply to discovery by reliable unicast. The transport registers the already-whitelisted +// remote as a peer, and the echoed message ID binds this announcement to one discovery scan. +static bool sendEspNowAnnounce(const uint8_t* mac, uint8_t msgId) { + if (statusESPNow != ESP_NOW_STATE_ON || !mac) return false; + if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) return false; + pDoc->clear(); + JsonObject announce = pDoc->createNestedObject("announce"); + announce[F("name")] = serverDescription; + announce[F("mac")] = escapedMac; + announce[F("ver")] = VERSION; + announce[F("ch")] = WiFi.channel(); + announce[F("proto")] = ESPNOW_API_VERSION; + announce[F("cap")] = ESPNOW_API_CAP_COMPACT | ESPNOW_API_CAP_CATALOGS | + ESPNOW_API_CAP_PUSH | ESPNOW_API_CAP_LIVE; + return queueApiDocLocked(mac, ESPNOW_API_ANNOUNCE, msgId) == 0; +} + +// Answer a {"get":"fx|pal|ps"} catalog request so a remote can populate effect, palette and +// preset lists instead of hardcoding them. These mirror the WebUI's /json effects/palettes +// and presets.json. Every request is answered: with the catalog or with an error code. +static void sendEspNowApiCatalog(const uint8_t* mac, uint8_t msgId, const char* what) { + if (statusESPNow != ESP_NOW_STATE_ON || !what) return; + uint8_t err; + + if (!strcmp_P(what, PSTR("ps"))) { + if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) { sendEspNowApiError(mac, msgId, ESPNOW_API_ERR_BUSY); return; } + // one filtered pass over presets.json (names only) instead of a per-id file scan + StaticJsonDocument<64> filter; + filter["*"]["n"] = true; + pDoc->clear(); + if (!readObjectFromFile(getPresetsFileName(), nullptr, pDoc, &filter)) pDoc->clear(); // no file = no presets + std::vector> presets; + for (JsonPair kv : pDoc->as()) { + int id = atoi(kv.key().c_str()); + const char* name = kv.value()["n"] | ""; + if (id >= 1 && id <= 250 && *name) presets.push_back({(uint8_t)id, String(name)}); + } + // Preserve the names before reusing the shared document for the response. + pDoc->clear(); + JsonObject ps = pDoc->createNestedObject("presets"); + for (auto& p : presets) ps[String(p.first)] = p.second; + err = queueApiDocLocked(mac, ESPNOW_API_RESPONSE, msgId); + } else if (!strcmp_P(what, PSTR("fx"))) { + if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) { sendEspNowApiError(mac, msgId, ESPNOW_API_ERR_BUSY); return; } + pDoc->clear(); + JsonArray effects = pDoc->createNestedArray("effects"); + serializeModeNames(effects); + err = queueApiDocLocked(mac, ESPNOW_API_RESPONSE, msgId); + } else if (!strcmp_P(what, PSTR("pal"))) { + if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) { sendEspNowApiError(mac, msgId, ESPNOW_API_ERR_BUSY); return; } + pDoc->clear(); + (*pDoc)[F("palettes")] = serialized((const __FlashStringHelper*)JSON_palette_names); + err = queueApiDocLocked(mac, ESPNOW_API_RESPONSE, msgId); + } else { + sendEspNowApiError(mac, msgId, ESPNOW_API_ERR_GET); + return; + } + if (err) sendEspNowApiError(mac, msgId, err); +} + +// Binary live LED peek, sharing the payload builder (and format) of the WebSocket liveview. +static bool queueEspNowLiveLeds(const uint8_t* mac, uint8_t msgId) { + if (statusESPNow != ESP_NOW_STATE_ON || !mac) return false; + if (!apiTxIdle()) return false; // a live frame is stale the moment it waits; skip it + const size_t MAX_LIVE_LEDS_ESPNOW = 256U; + size_t bufSize = buildLiveLedsPayload(nullptr, 0, MAX_LIVE_LEDS_ESPNOW); + if (!bufSize || bufSize > ESPNOW_API_MAX_JSON) return false; + uint8_t* buffer = (uint8_t*)d_malloc(bufSize); + if (!buffer) return false; + if (!buildLiveLedsPayload(buffer, bufSize, MAX_LIVE_LEDS_ESPNOW) || + !apiTxEnqueue(mac, ESPNOW_API_LIVE, msgId, buffer, bufSize)) { + free(buffer); + return false; + } + return true; +} + +// Send live frames at a fixed rate while the remote refreshes its "lease". +static void handleEspNowLive() { + if (!apiLiveActive) return; + if ((long)(millis() - apiLiveExpiry) >= 0) { apiLiveReset(); return; } // remote stopped re-arming + if (millis() - apiLastLiveTime <= ESPNOW_LIVE_INTERVAL) return; + bool success = queueEspNowLiveLeds(apiLiveMac, apiLiveMsgId++); + apiLastLiveTime = millis(); + if (!success) apiLastLiveTime -= 20; // retry sooner if TX slot or heap was busy +} + +// Release all API-owned state when ESP-NOW stops or changes interface. +static void apiResetAll() { + apiReasmReset(); + apiInboxReset(); + apiLiveReset(); + apiTxReset(); + apiRemoteSeen = 0; + apiPushPending = false; + apiPushDue = 0; + for (auto &reply : apiDiscoveryReplies) reply = EspNowApiDiscoveryReply{}; + for (auto &record : apiCompletedMutations) record = EspNowApiCompletedMutation{}; + apiCompletedMutationNext = 0; +} + +// Retry a coalesced state push after responses have drained; live preview yields to state. +static bool handlePendingEspNowPush() { + if (!apiPushPending || !apiTxIdle()) return false; + if ((long)(millis() - apiPushDue) < 0) return false; + if (!espNowApiReady() || linked_remotes.empty() || !espNowApiRemoteActive()) { + apiPushPending = false; + return false; + } + static uint8_t pushId = 0; + if (queueEspNowApiCompactState(ESPNOW_BROADCAST_ADDRESS, ESPNOW_API_PUSH, pushId) != 0) return false; + apiPushPending = false; + pushId++; + return true; +} + +// Queue a discovery reply without letting simultaneous remotes overwrite each other. +static void scheduleEspNowAnnounce(const uint8_t* mac, uint8_t msgId) { + EspNowApiDiscoveryReply* slot = nullptr; + for (auto &reply : apiDiscoveryReplies) { + if (reply.pending && memcmp(reply.mac, mac, sizeof(reply.mac)) == 0) { slot = &reply; break; } + if (!reply.pending && !slot) slot = &reply; + } + if (!slot) slot = &apiDiscoveryReplies[0]; // bounded replacement; the remote repeats discovery + memcpy(slot->mac, mac, sizeof(slot->mac)); + slot->msgId = msgId; + slot->due = millis() + apiInstanceJitter(90, 10); + slot->pending = true; +} + +// Sends one due discovery response after the reliable response slot becomes available. +static bool handlePendingEspNowAnnounce() { + if (!apiTxIdle()) return false; + for (auto &reply : apiDiscoveryReplies) { + if (!reply.pending || (long)(millis() - reply.due) < 0) continue; + if (sendEspNowAnnounce(reply.mac, reply.msgId)) reply.pending = false; + else reply.due = millis() + 20; + return true; + } + return false; +} + +// AI: below section was partly generated by an AI +// Apply a completed inbound message in loop context. +void handleEspNowApi() { + static bool needCleanup = false; + if (!espNowApiReady()) { + if (needCleanup) { apiResetAll(); needCleanup = false; } + return; + } + needCleanup = true; + + serviceEspNowApiTx(); + if (!apiTxIdle()) return; // finish the previous response before consuming another request + + uint8_t srcMac[6]; + uint8_t msgId = 0; + uint8_t* json = nullptr; + size_t jsonLen = 0; + + apiReasmCleanupStale(); + if (apiInboxCount) { + EspNowApiInbox &inbox = apiInbox[apiInboxRead]; + memcpy(srcMac, inbox.srcMac, 6); + msgId = inbox.msgId; + json = inbox.json; // ownership moves to this invocation + jsonLen = inbox.len; + inbox.json = nullptr; + inbox.len = 0; + apiInboxRead = (apiInboxRead + 1) % (sizeof(apiInbox) / sizeof(apiInbox[0])); + apiInboxCount--; + } + + if (!json) { + if (!handlePendingEspNowAnnounce() && !handlePendingEspNowPush()) handleEspNowLive(); + return; + } + + unsigned long start = millis(); + const unsigned long stripWaitTimeout = strip.getFrameTime(); + // Avoid changing segment state mid-render, but never stall longer than one frame. + while (strip.isUpdating() && millis() - start < stripWaitTimeout) yield(); + + DEBUG_PRINTF_P(PSTR("ESP-NOW API handling REQUEST id=%u bytes=%u from " MACSTR "\n"), + msgId, unsigned(jsonLen), MAC2STR(srcMac)); + const uint32_t requestHash = apiPayloadHash(json, jsonLen); + if (apiMutationWasCompleted(srcMac, msgId, requestHash)) { + DEBUG_PRINTF_P(PSTR("ESP-NOW API suppressing duplicate mutation id=%u from " MACSTR "\n"), + msgId, MAC2STR(srcMac)); + sendEspNowApiSuccess(srcMac, msgId); + } else if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) { + sendEspNowApiError(srcMac, msgId, ESPNOW_API_ERR_BUSY); + } else { + DeserializationError err = deserializeJson(*pDoc, json, jsonLen); + JsonObject root = pDoc->as(); + if (err || root.isNull()) { + releaseJSONBufferLock(); + sendEspNowApiError(srcMac, msgId, ESPNOW_API_ERR_JSON); + } else { + // Match wsEvent(): {"v":true} polls state, {"lv":...} toggles live peek. + bool verbose = false; + bool compact = false; + const char* responseMode = root["v"].is() ? root["v"].as() : nullptr; + // Treat these as polls only when no state-changing fields share the request. + if (responseMode && !strcmp(responseMode, "compact") && root.size() == 1) { + compact = true; + } else if (root["v"] && root.size() == 1) { + verbose = true; + } else if (root.containsKey("lv")) { + if (root["lv"] | false) { + memcpy(apiLiveMac, srcMac, 6); + apiLiveActive = true; + apiLastLiveTime = 0; + apiLiveExpiry = millis() + ESPNOW_LIVE_TIMEOUT; + } else if (apiLiveActive && memcmp(apiLiveMac, srcMac, 6) == 0) { + apiLiveReset(); + } + } else if (root.containsKey("get")) { + char what[8]; + strlcpy(what, root["get"] | "", sizeof(what)); + // Catalog generation needs pDoc, so release the parsing lock before dispatching it. + releaseJSONBufferLock(); + sendEspNowApiCatalog(srcMac, msgId, what); + free(json); + return; + } else { + // Use button semantics so remote commands follow the normal notify path. + verbose = deserializeState(root, CALL_MODE_BUTTON); + rememberCompletedMutation(srcMac, msgId, requestHash); + } + releaseJSONBufferLock(); + // If the request changed state, a PUSH will follow soon. Acknowledge here + // instead of serializing the same state twice. + if (verbose && interfaceUpdateCallMode) verbose = false; + if (compact) { + uint8_t compactErr = queueEspNowApiCompactState(srcMac, ESPNOW_API_RESPONSE, msgId); + if (compactErr) sendEspNowApiError(srcMac, msgId, compactErr); + } else { + sendEspNowApiResponse(srcMac, msgId, verbose); + } + } + } + + free(json); +} +// AI: end + +// Broadcast current state to paired remotes from updateInterfaces(), inheriting its cooldown. +// Gated on a recent API frame so WizMote-only setups never see these frames. +void pushEspNowState() { + if (!espNowApiReady() || linked_remotes.empty() || !espNowApiRemoteActive()) return; + apiPushPending = true; // coalesce rapid changes; handleEspNowApi() sends the latest state + apiPushDue = millis() + apiInstanceJitter(35, 5); +} +#endif // !WLED_DISABLE_ESPNOW && ARDUINO_ARCH_ESP32 && WLED_ENABLE_ESPNOW_API diff --git a/wled00/espnow_transport.cpp b/wled00/espnow_transport.cpp new file mode 100644 index 0000000000..47c6942c9f --- /dev/null +++ b/wled00/espnow_transport.cpp @@ -0,0 +1,183 @@ +#include "wled.h" +#if !defined(WLED_DISABLE_ESPNOW) && defined(ARDUINO_ARCH_ESP32) +#include + +// AI: below section was partly generated by an AI +// ESP-NOW callbacks run outside WLED's loop context. Keep callback work small: copy received +// frames into a small queue and send one outbound frame at a time. +// Espressif callback guidance: https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/network/esp_now.html + +namespace { + +static constexpr uint8_t ESPNOW_TRANSPORT_RX_QUEUE_SIZE = 2; +static constexpr uint8_t ESPNOW_TRANSPORT_RX_PER_LOOP = 2; +static constexpr size_t ESPNOW_TRANSPORT_MAX_PAYLOAD = 250; + +struct EspNowTransportFrame { + uint8_t address[6]; + uint8_t data[ESPNOW_TRANSPORT_MAX_PAYLOAD]; + uint8_t len; + int8_t rssi; + bool broadcast; +}; + +static EspNowTransportFrame* rxQueue = nullptr; +static uint8_t rxRead = 0, rxWrite = 0, rxCount = 0; +static std::atomic_flag rxLock = ATOMIC_FLAG_INIT; +static std::atomic txInFlight{false}; +static std::atomic sentEventPending{false}; +// The callback writes these before publishing sentEventPending. +static uint8_t sentAddress[6] = {}; +static uint8_t sentStatus = 0; +static std::atomic transportActive{false}; +static bool transportUsesAP = false; + +// Reset indices under the same lock used by the receive callback. +static void resetQueues() { + while (rxLock.test_and_set(std::memory_order_acquire)) delay(1); + rxRead = rxWrite = rxCount = 0; + rxLock.clear(std::memory_order_release); + txInFlight.store(false, std::memory_order_release); + sentEventPending.store(false, std::memory_order_release); +} + +static void freeReceiveQueue() { + free(rxQueue); + rxQueue = nullptr; +} + +// Copy callback data because the ESP-IDF buffer is invalid after the callback returns. +static void queueReceivedFrame(const uint8_t* address, const uint8_t* data, size_t len) { + if (!transportActive.load(std::memory_order_acquire) || !rxQueue || !address || !data || !len || + len > ESPNOW_TRANSPORT_MAX_PAYLOAD) return; + if (rxLock.test_and_set(std::memory_order_acquire)) return; // callbacks never wait on the main loop + if (rxCount < ESPNOW_TRANSPORT_RX_QUEUE_SIZE) { + EspNowTransportFrame &frame = rxQueue[rxWrite]; + memcpy(frame.address, address, sizeof(frame.address)); + memcpy(frame.data, data, len); + frame.len = len; + frame.rssi = 0; // legacy ESP-NOW callbacks do not provide portable RSSI metadata + frame.broadcast = data[0] == 'W'; // only WLED sync packets require broadcast classification + rxWrite = (rxWrite + 1) % ESPNOW_TRANSPORT_RX_QUEUE_SIZE; + rxCount++; + } + rxLock.clear(std::memory_order_release); +} + +// ESP-IDF callback: defer all packet processing to WLED's main loop. +static void onEspNowReceive(const uint8_t* address, const uint8_t* data, int len) { + if (len > 0) queueReceivedFrame(address, data, size_t(len)); +} + +// ESP-IDF callback: publish completion without running application code here. +static void onEspNowSent(const uint8_t* address, esp_now_send_status_t status) { + if (!transportActive.load(std::memory_order_acquire)) return; + memcpy(sentAddress, address, sizeof(sentAddress)); + sentStatus = uint8_t(status); + sentEventPending.store(true, std::memory_order_release); +} + +// Native ESP-NOW requires every unicast and broadcast destination to be a peer. +static bool checkForPeer(const uint8_t* address) { + if (esp_now_is_peer_exist(address)) return true; + esp_now_peer_info_t peer = {}; + memcpy(peer.peer_addr, address, sizeof(peer.peer_addr)); + peer.channel = 0; // follow the active interface's home channel + peer.ifidx = transportUsesAP ? WIFI_IF_AP : WIFI_IF_STA; + peer.encrypt = false; + return esp_now_add_peer(&peer) == ESP_OK; +} + +// Move one queued callback frame into loop-owned stack storage. +static bool popReceivedFrame(EspNowTransportFrame &frame) { + if (rxLock.test_and_set(std::memory_order_acquire)) return false; + if (!rxCount) { + rxLock.clear(std::memory_order_release); + return false; + } + frame = rxQueue[rxRead]; + rxRead = (rxRead + 1) % ESPNOW_TRANSPORT_RX_QUEUE_SIZE; + rxCount--; + rxLock.clear(std::memory_order_release); + return true; +} + +} // namespace + +// Allocate callback storage and attach ESP-NOW to the settled Wi-Fi interface. +bool espNowTransportBegin(uint8_t channel, bool useAP) { + espNowTransportStop(); + resetQueues(); + rxQueue = (EspNowTransportFrame*)d_malloc(sizeof(EspNowTransportFrame) * ESPNOW_TRANSPORT_RX_QUEUE_SIZE); + if (!rxQueue) return false; + transportUsesAP = useAP; + + (void)channel; // WiFi owns the settled STA/AP home channel before this function is called + if (esp_now_init() != ESP_OK) { freeReceiveQueue(); return false; } + if (esp_now_register_recv_cb(onEspNowReceive) != ESP_OK || + esp_now_register_send_cb(onEspNowSent) != ESP_OK) { + esp_now_deinit(); + freeReceiveQueue(); + return false; + } + transportActive.store(true, std::memory_order_release); + DEBUG_PRINTF_P(PSTR("ESP-NOW transport ready: requestedCh=%u actualCh=%u interface=%s queue=%u\n"), + channel, WiFi.channel(), useAP ? "AP" : "STA", ESPNOW_TRANSPORT_RX_QUEUE_SIZE); + return true; +} + +// Disable callbacks before freeing memory they may access otherwise. +void espNowTransportStop() { + if (!transportActive.load(std::memory_order_acquire)) return; + transportActive.store(false, std::memory_order_release); + DEBUG_PRINTF_P(PSTR("ESP-NOW transport stopping: ch=%u interface=%s rx=%u inFlight=%u\n"), + WiFi.channel(), transportUsesAP ? "AP" : "STA", rxCount, + txInFlight.load(std::memory_order_acquire)); + esp_now_unregister_recv_cb(); + esp_now_unregister_send_cb(); + esp_now_deinit(); + resetQueues(); + freeReceiveQueue(); +} + +// Only one frame may be submitted until its completion callback is drained. +bool espNowTransportReadyToSend() { + return transportActive.load(std::memory_order_acquire) && + !txInFlight.load(std::memory_order_acquire) && + !sentEventPending.load(std::memory_order_acquire); +} + +// Submit one frame, (zero means accepted, matching the legacy QuickESPNow convention). +uint8_t espNowTransportSend(const uint8_t* address, const uint8_t* data, size_t len) { + if (!transportActive.load(std::memory_order_acquire) || !address || !data || !len || + len > ESPNOW_TRANSPORT_MAX_PAYLOAD || !espNowTransportReadyToSend()) return 1; + if (!checkForPeer(address)) return 1; + txInFlight.store(true, std::memory_order_release); + const esp_err_t result = esp_now_send(address, data, len); + if (result != 0) { + txInFlight.store(false, std::memory_order_release); + espNowSentCB((uint8_t*)address, 1); + return 1; + } + return 0; +} + +// Deliver deferred send and receive callbacks from safe loop context. +void handleEspNowTransport() { + uint8_t completedAddress[6]; + uint8_t completedStatus = 0; + const bool haveSentEvent = sentEventPending.load(std::memory_order_acquire); + if (haveSentEvent) { + memcpy(completedAddress, sentAddress, sizeof(completedAddress)); + completedStatus = sentStatus; + sentEventPending.store(false, std::memory_order_release); + txInFlight.store(false, std::memory_order_release); + } + // Clear the in-flight state before notifying clients so they may queue the next frame. + if (haveSentEvent) espNowSentCB(completedAddress, completedStatus); + EspNowTransportFrame frame; + for (uint8_t i = 0; i < ESPNOW_TRANSPORT_RX_PER_LOOP && popReceivedFrame(frame); i++) + espNowReceiveCB(frame.address, frame.data, frame.len, frame.rssi, frame.broadcast); +} +// AI: end +#endif // !WLED_DISABLE_ESPNOW && ARDUINO_ARCH_ESP32 diff --git a/wled00/fcn_declare.h b/wled00/fcn_declare.h index 18327eb877..907c9c7a23 100644 --- a/wled00/fcn_declare.h +++ b/wled00/fcn_declare.h @@ -191,6 +191,9 @@ void serveJson(AsyncWebServerRequest* request); bool serveLiveLeds(AsyncWebServerRequest* request, uint32_t wsClient = 0); #endif +// Used to serve Peek/Preview over ESP-NOW - Shared liveview builder; pass nullptr to query the required size. +size_t buildLiveLedsPayload(uint8_t* buffer, size_t bufSize, size_t maxLeds); + //led.cpp void setValuesFromSegment(uint8_t s); #define setValuesFromMainSeg() setValuesFromSegment(strip.getMainSegmentId()) @@ -289,6 +292,24 @@ bool getPresetName(byte index, String& name); void handleWiZdata(uint8_t *incomingData, size_t len); void handleRemote(); +#if !defined(WLED_DISABLE_ESPNOW) && defined(ARDUINO_ARCH_ESP32) +bool espNowTransportBegin(uint8_t channel, bool useAP); +void espNowTransportStop(); +void handleEspNowTransport(); +bool espNowTransportReadyToSend(); +uint8_t espNowTransportSend(const uint8_t* address, const uint8_t* data, size_t len); + +#ifdef WLED_ENABLE_ESPNOW_API +//espnow_api.cpp +void handleEspNowApiData(uint8_t* address, uint8_t* data, uint8_t len); +bool espNowApiReady(); +bool espNowApiRemoteActive(); +void handleEspNowApi(); +void pushEspNowState(); +void espNowApiOnSendResult(uint8_t* address, uint8_t status); +#endif +#endif + //set.cpp bool isAsterisksOnly(const char* str, byte maxLen); void handleSettingsSet(AsyncWebServerRequest *request, byte subPage); diff --git a/wled00/json.cpp b/wled00/json.cpp index be3bb28724..3403738612 100644 --- a/wled00/json.cpp +++ b/wled00/json.cpp @@ -1429,6 +1429,59 @@ void serveJson(AsyncWebServerRequest* request) request->send(response); } +// Binary liveview payload shared by the WebSocket liveview and the ESP-NOW API: +// 'L', version (2 = 2D with width/height bytes), then sampled RGB triples. +// Call with buffer == nullptr to get the required buffer size, then again to fill it; +// returns 0 when there is nothing to serve or the buffer is too small. +size_t buildLiveLedsPayload(uint8_t* buffer, size_t bufSize, size_t maxLeds) +{ + size_t used = strip.getLengthTotal(); + if (!used || !maxLeds) return 0; + size_t n = ((used -1)/maxLeds) +1; //only serve every n'th LED if count over maxLeds + size_t pos = 2; // start of data + size_t count = used/n; +#ifndef WLED_DISABLE_2D + if (strip.isMatrix) { + // ignore anything behind matrix (i.e. extra strip) + used = Segment::maxWidth*Segment::maxHeight; // always the size of matrix (more or less than strip.getLengthTotal()) + n = 1; + if (used > maxLeds) n = 2; + if (used > maxLeds*4) n = 4; + pos = 4; + count = (Segment::maxWidth/n) * (Segment::maxHeight/n); // matches the advertised dimensions + } +#endif + size_t needed = pos + count*3; + if (!buffer) return needed; + if (bufSize < needed) return 0; + + buffer[0] = 'L'; + buffer[1] = 1; //version +#ifndef WLED_DISABLE_2D + if (strip.isMatrix) { + buffer[1] = 2; //version + buffer[2] = Segment::maxWidth/n; + buffer[3] = Segment::maxHeight/n; + } +#endif + + for (size_t i = 0; pos < needed; i += n) + { +#ifndef WLED_DISABLE_2D + if (strip.isMatrix && n>1 && (i/Segment::maxWidth)%n) i += Segment::maxWidth * (n-1); +#endif + uint32_t c = strip.getPixelColor(i); // note: LEDs mapped outside of valid range are set to black + uint8_t r = R(c); + uint8_t g = G(c); + uint8_t b = B(c); + uint8_t w = W(c); + buffer[pos++] = bri ? qadd8(w, r) : 0; //R, add white channel to RGB channels as a simple RGBW -> RGB map + buffer[pos++] = bri ? qadd8(w, g) : 0; //G + buffer[pos++] = bri ? qadd8(w, b) : 0; //B + } + return needed; +} + #ifdef WLED_ENABLE_JSONLIVE #define MAX_LIVE_LEDS 256 diff --git a/wled00/led.cpp b/wled00/led.cpp index 131ff95bab..4e0c444d04 100644 --- a/wled00/led.cpp +++ b/wled00/led.cpp @@ -142,6 +142,9 @@ void updateInterfaces(uint8_t callMode) { if (!interfaceUpdateCallMode || millis() - lastInterfaceUpdate < INTERFACE_UPDATE_COOLDOWN) return; sendDataWs(); + #if !defined(WLED_DISABLE_ESPNOW) && defined(WLED_ENABLE_ESPNOW_API) + pushEspNowState(); + #endif lastInterfaceUpdate = millis(); interfaceUpdateCallMode = CALL_MODE_INIT; //disable further updates diff --git a/wled00/set.cpp b/wled00/set.cpp index dee8d00378..573dda15f5 100644 --- a/wled00/set.cpp +++ b/wled00/set.cpp @@ -150,13 +150,20 @@ void handleSettingsSet(AsyncWebServerRequest *request, byte subPage) snprintf(rm, sizeof(rm), "RM%d", n); // "RM0" to "RM9" if (request->hasArg(rm)) { const String& arg = request->arg(rm); - if (arg.isEmpty()) continue; + if (arg.length() != 12) continue; // stored form is 12 hex digits without separators, this ensures that! std::array mac{}; - strlcpy(mac.data(), request->arg(rm).c_str(), 13); + strlcpy(mac.data(), arg.c_str(), sizeof(mac)); strlwr(mac.data()); - if (mac[0] != '\0') { - linked_remotes.emplace_back(mac); + bool valid = true; + for (size_t i = 0; i < 12; i++) { + if ((mac[i] < '0' || mac[i] > '9') && (mac[i] < 'a' || mac[i] > 'f')) { valid = false; break; } } + if (!valid) continue; + bool duplicate = false; + for (const auto &linked : linked_remotes) { + if (!memcmp(linked.data(), mac.data(), 12)) { duplicate = true; break; } + } + if (!duplicate) linked_remotes.emplace_back(mac); } } #endif diff --git a/wled00/udp.cpp b/wled00/udp.cpp index 077c077f7d..75b59bd1ef 100644 --- a/wled00/udp.cpp +++ b/wled00/udp.cpp @@ -1,4 +1,7 @@ #include "wled.h" +#if !defined(WLED_DISABLE_ESPNOW) && defined(ARDUINO_ARCH_ESP32) +#include +#endif /* * UDP sync notifier / Realtime / Hyperion / TPM2.NET @@ -17,6 +20,85 @@ typedef struct PartialEspNowPacket { uint8_t data[247]; } partial_packet_t; +#if !defined(WLED_DISABLE_ESPNOW) && defined(ARDUINO_ARCH_ESP32) +// AI: below section was partly generated by an AI +namespace { +static constexpr size_t ESPNOW_SYNC_FIRST_SEGMENTS = (sizeof(partial_packet_t::data) - SEG_OFFSET) / UDP_SEG_SIZE; +static constexpr size_t ESPNOW_SYNC_NEXT_SEGMENTS = sizeof(partial_packet_t::data) / UDP_SEG_SIZE; + +struct EspNowSyncTx { + uint8_t* payload = nullptr; + uint8_t segmentCount = 0; + uint8_t packetNext = 0; + uint8_t packetTotal = 0; +}; + +static EspNowSyncTx espNowSyncTx; + +// Free the notifier snapshot after completion, failure, or replacement. +static void resetEspNowSyncTx() { + free(espNowSyncTx.payload); + espNowSyncTx = EspNowSyncTx{}; +} + +// Keep the complete notifier snapshot and generate one radio packet at a time. This avoids a +// permanent TX ring while preserving multi-packet sync for configurations with many segments. +static void serviceEspNowSyncTx() { + if (!espNowSyncTx.payload) return; + if (!enableESPNow || !useESPNowSync || statusESPNow != ESP_NOW_STATE_ON) { + resetEspNowSyncTx(); + return; + } + if (!espNowTransportReadyToSend()) return; + + // Packet zero carries global state plus segments; later packets carry only segments. + partial_packet_t packet = {'W', espNowSyncTx.packetNext, espNowSyncTx.packetTotal, {0}}; + size_t firstSegment = 0; + size_t segmentCount = 0; + size_t payloadLen = 0; + if (packet.packet == 0) { + memcpy(packet.data, espNowSyncTx.payload, SEG_OFFSET); + segmentCount = min(espNowSyncTx.segmentCount, ESPNOW_SYNC_FIRST_SEGMENTS); + memcpy(packet.data + SEG_OFFSET, espNowSyncTx.payload + SEG_OFFSET, segmentCount * UDP_SEG_SIZE); + payloadLen = SEG_OFFSET + segmentCount * UDP_SEG_SIZE; + } else { + firstSegment = ESPNOW_SYNC_FIRST_SEGMENTS + size_t(packet.packet - 1) * ESPNOW_SYNC_NEXT_SEGMENTS; + segmentCount = min(espNowSyncTx.segmentCount - firstSegment, ESPNOW_SYNC_NEXT_SEGMENTS); + memcpy(packet.data, espNowSyncTx.payload + SEG_OFFSET + firstSegment * UDP_SEG_SIZE, + segmentCount * UDP_SEG_SIZE); + payloadLen = segmentCount * UDP_SEG_SIZE; + } + + if (espNowTransportSend(ESPNOW_BROADCAST_ADDRESS, reinterpret_cast(&packet), payloadLen + 3)) { + DEBUG_PRINTLN(F("ESP-NOW sync send failed.")); + resetEspNowSyncTx(); + return; + } + if (++espNowSyncTx.packetNext >= espNowSyncTx.packetTotal) resetEspNowSyncTx(); +} + +// Snapshot udpOut because notify() uses a stack buffer that cannot outlive this call. +static void queueEspNowSyncTx(const uint8_t* payload, size_t segmentCount) { + const size_t payloadLen = SEG_OFFSET + segmentCount * UDP_SEG_SIZE; + uint8_t* copy = (uint8_t*)d_malloc(payloadLen); + if (!copy) { + DEBUG_PRINTLN(F("ESP-NOW sync allocation failed.")); + return; + } + memcpy(copy, payload, payloadLen); + // A newer notification supersedes any snapshot still being transmitted. + resetEspNowSyncTx(); + espNowSyncTx.payload = copy; + espNowSyncTx.segmentCount = segmentCount; + const size_t remaining = segmentCount > ESPNOW_SYNC_FIRST_SEGMENTS ? segmentCount - ESPNOW_SYNC_FIRST_SEGMENTS : 0; + // The first packet has less segment room because it also contains global state. + espNowSyncTx.packetTotal = 1 + (remaining + ESPNOW_SYNC_NEXT_SEGMENTS - 1) / ESPNOW_SYNC_NEXT_SEGMENTS; + serviceEspNowSyncTx(); +} +} +// AI: end +#endif + void notify(byte callMode, bool followUp) { #ifndef WLED_DISABLE_ESPNOW @@ -151,6 +233,7 @@ void notify(byte callMode, bool followUp) #ifndef WLED_DISABLE_ESPNOW if (enableESPNow && useESPNowSync && statusESPNow == ESP_NOW_STATE_ON) { + #ifdef ESP8266 partial_packet_t buffer = {'W', 0, 1, {0}}; // send global data DEBUG_PRINTLN(F("ESP-NOW sending first packet.")); @@ -188,9 +271,10 @@ void notify(byte callMode, bool followUp) err = quickEspNow.send(ESPNOW_BROADCAST_ADDRESS, reinterpret_cast(&buffer), packetSize+3); } } - if (err) { - DEBUG_PRINTLN(F("ESP-NOW sending packet failed.")); - } + if (err) DEBUG_PRINTLN(F("ESP-NOW sending packet failed.")); + #else + queueEspNowSyncTx(udpOut, s); + #endif } if (udpConnected) #endif @@ -470,6 +554,10 @@ void handleNotifications() { IPAddress localIP; + #if !defined(WLED_DISABLE_ESPNOW) && defined(ARDUINO_ARCH_ESP32) + serviceEspNowSyncTx(); + #endif + //send second notification if enabled if(udpConnected && (notificationCount < udpNumRetries) && ((millis()-notificationSentTime) > 250)){ notify(notificationSentCallMode,true); @@ -891,15 +979,51 @@ uint8_t realtimeBroadcast(uint8_t type, IPAddress client, uint16_t length, const #ifndef WLED_DISABLE_ESPNOW // ESP-NOW message sent callback function void espNowSentCB(uint8_t* address, uint8_t status) { - DEBUG_PRINTF_P(PSTR("Message sent to " MACSTR ", status: %d\n"), MAC2STR(address), status); + #ifdef ESP8266 + DEBUG_PRINTF_P(PSTR("Message sent to " MACSTR ", status: %d\n"), MAC2STR(address), status); + #else + #ifdef WLED_ENABLE_ESPNOW_API + // Delivery failures stop a stale live subscriber before its lease expires. + espNowApiOnSendResult(address, status); + #endif + if (status) DEBUG_PRINTF_P(PSTR("ESP-NOW send to " MACSTR " failed: status=%u ch=%u wifi=%u\n"), + MAC2STR(address), status, WiFi.channel(), unsigned(WiFi.status())); + #endif +} + +// Classify only frame types that establish the sender as a control remote. In particular, +// outbound ANNOUNCE frames must not replace the bonding candidate. +static bool isEspNowBondCandidate(const uint8_t* data, uint8_t len) { + if (!data || !len) return false; + if (data[0] == 0x91 || data[0] == 0x81 || data[0] == 0x80) return true; // WiZ Mote + #ifndef WLED_ENABLE_ESPNOW_API + return false; + #else + if (len < ESPNOW_API_HEADER_SIZE || data[0] != ESPNOW_API_MAGIC || data[1] != ESPNOW_API_VERSION) return false; + + const uint8_t msgType = data[2]; + const uint8_t fragIndex = data[4]; + const uint8_t fragTotal = data[5]; + const uint8_t payloadLen = len - ESPNOW_API_HEADER_SIZE; + if (fragTotal < 1 || fragTotal > ESPNOW_API_MAX_FRAGS || fragIndex >= fragTotal) return false; + if (payloadLen > ESPNOW_API_FRAG_SIZE || (fragIndex < fragTotal - 1 && payloadLen != ESPNOW_API_FRAG_SIZE)) return false; + if (msgType == ESPNOW_API_REQUEST) return true; + if (msgType != ESPNOW_API_DISCOVER || fragIndex != 0 || fragTotal != 1) return false; + return payloadLen == 0 || (payloadLen == 2 && data[ESPNOW_API_HEADER_SIZE] == '{' && + data[ESPNOW_API_HEADER_SIZE + 1] == '}'); + #endif } // ESP-NOW message receive callback function -void espNowReceiveCB(uint8_t* address, uint8_t* data, uint8_t len, signed int rssi, bool broadcast) { - sprintf_P(last_signal_src, PSTR("%02x%02x%02x%02x%02x%02x"), address[0], address[1], address[2], address[3], address[4], address[5]); +void espNowReceiveCB(uint8_t* address, uint8_t* data, uint8_t len, signed int, bool broadcast) { + if (!address || !data || len == 0) return; + char senderMac[13]; + snprintf_P(senderMac, sizeof(senderMac), PSTR("%02x%02x%02x%02x%02x%02x"), + address[0], address[1], address[2], address[3], address[4], address[5]); + if (isEspNowBondCandidate(data, len)) strlcpy(last_signal_src, senderMac, sizeof(last_signal_src)); #ifdef WLED_DEBUG - DEBUG_PRINT(F("ESP-NOW: ")); DEBUG_PRINT(last_signal_src); DEBUG_PRINT(F(" -> ")); DEBUG_PRINTLN(len); + DEBUG_PRINT(F("ESP-NOW: ")); DEBUG_PRINT(senderMac); DEBUG_PRINT(F(" -> ")); DEBUG_PRINTLN(len); for (int i=0; i= ESPNOW_API_HEADER_SIZE && data[0] == ESPNOW_API_MAGIC && data[1] == ESPNOW_API_VERSION) { + if (!espNowApiReady()) return; + handleEspNowApiData(address, data, len); + return; + } + #endif + partial_packet_t *buffer = reinterpret_cast(data); if (len < 3 || !broadcast || buffer->magic != 'W' || !useESPNowSync || WLED_CONNECTED) { DEBUG_PRINTLN(F("ESP-NOW unexpected packet, not syncing or connected to WiFi.")); @@ -937,16 +1071,30 @@ void espNowReceiveCB(uint8_t* address, uint8_t* data, uint8_t len, signed int rs static uint8_t segsReceived = 0; static unsigned long lastProcessed = 0; + // Validate fragment shape before copying data supplied by the radio. + const size_t payloadLen = len - 3; + if (buffer->noOfPackets == 0 || payloadLen > WLEDPACKETSIZE || + (buffer->packet == 0 && (payloadLen < SEG_OFFSET || (payloadLen - SEG_OFFSET) % UDP_SEG_SIZE != 0)) || + (buffer->packet > 0 && (payloadLen % UDP_SEG_SIZE) != 0)) { + DEBUG_PRINTLN(F("ESP-NOW malformed sync packet.")); + if (udpIn) free(udpIn); + udpIn = nullptr; + packetsReceived = 0; + segsReceived = 0; + return; + } + if (buffer->packet == 0) { + // Packet zero always starts a new sync snapshot. packetsReceived = 0; // it will increment later (this is to make sure we start counting packets correctly) if (udpIn == nullptr) { - udpIn = (uint8_t *)malloc(WLEDPACKETSIZE); // we cannot use stack as we are in callback + udpIn = (uint8_t *)d_malloc(WLEDPACKETSIZE); // we cannot use stack as we are in callback if (!udpIn) return; // memory alocation failed DEBUG_PRINTLN(F("ESP-NOW inited UDP buffer.")); } - memcpy(udpIn, buffer->data, len-3); // global data (41 bytes + up to 5 segments) - segsReceived = (len - 3 - 41) / UDP_SEG_SIZE; - } else if (buffer->packet == packetsReceived && udpIn && ((len - 3) / UDP_SEG_SIZE) * UDP_SEG_SIZE == (len-3)) { + memcpy(udpIn, buffer->data, payloadLen); // global data (41 bytes + up to 5 segments) + segsReceived = (payloadLen - SEG_OFFSET) / UDP_SEG_SIZE; + } else if (buffer->packet == packetsReceived && udpIn) { // we received a packet full of segments if (segsReceived >= MAX_NUM_SEGMENTS) { // we are already past max segments, just ignore diff --git a/wled00/wled.cpp b/wled00/wled.cpp index 43233f55e4..35f3c54d32 100644 --- a/wled00/wled.cpp +++ b/wled00/wled.cpp @@ -25,6 +25,39 @@ #endif extern "C" void usePWMFixedNMI(); +#if !defined(WLED_DISABLE_ESPNOW) && defined(ARDUINO_ARCH_ESP32) +static bool espNowUsingAP = false; + +// Start ESP-NOW only after the radio has a stable home channel, and rebind its native transport +// when WLED changes between STA and AP interfaces. +static bool startEspNowForCurrentNetwork() { + if (!enableESPNow) return false; + + if (statusESPNow == ESP_NOW_STATE_ON) { + espNowTransportStop(); + statusESPNow = ESP_NOW_STATE_UNINIT; + } + + bool espNowOK = false; + const bool wifiConnected = WiFi.status() == WL_CONNECTED && WiFi.localIP()[0] != 0; + if (wifiConnected) { + DEBUG_PRINTLN(F("ESP-NOW initing in STA mode.")); + espNowOK = espNowTransportBegin(WiFi.channel(), false); + espNowUsingAP = false; + } else if (apActive) { + DEBUG_PRINTLN(F("ESP-NOW initing in AP mode.")); + #ifdef ARDUINO_ARCH_ESP32 + esp_wifi_set_bandwidth(WIFI_IF_AP, WIFI_BW_HT20); + #endif + espNowOK = espNowTransportBegin(apChannel, true); + espNowUsingAP = true; + } + + statusESPNow = espNowOK ? ESP_NOW_STATE_ON : ESP_NOW_STATE_ERROR; + return espNowOK; +} +#endif + /* * Main WLED class implementation. Mostly initialization and connection logic */ @@ -73,6 +106,10 @@ void WLED::loop() #endif handleTime(); + #if !defined(WLED_DISABLE_ESPNOW) && defined(ARDUINO_ARCH_ESP32) + // Drain native callbacks before protocol handlers consume their events. + handleEspNowTransport(); + #endif #ifndef WLED_DISABLE_INFRARED handleIR(); // 2nd call to function needed for ESP32 to return valid results -- should be good for ESP8266, too #endif @@ -108,6 +145,9 @@ void WLED::loop() #endif #ifndef WLED_DISABLE_ESPNOW handleRemote(); + #ifdef WLED_ENABLE_ESPNOW_API + handleEspNowApi(); + #endif #endif #ifndef WLED_DISABLE_ALEXA handleAlexa(); @@ -738,6 +778,13 @@ void WLED::initAP(bool resetAP) dnsServer.start(53, "*", WiFi.softAPIP()); } apActive = true; + + #if !defined(WLED_DISABLE_ESPNOW) && defined(ARDUINO_ARCH_ESP32) + // A fallback AP may be started after ESP-NOW was initialized for STA. Rebind now so + // the transport's peer interface and channel match the AP's actual home channel. + if (enableESPNow && !Network.isConnected() && + (statusESPNow != ESP_NOW_STATE_ON || !espNowUsingAP)) startEspNowForCurrentNetwork(); + #endif } void WLED::initConnection() @@ -750,7 +797,11 @@ void WLED::initConnection() #ifndef WLED_DISABLE_ESPNOW if (statusESPNow == ESP_NOW_STATE_ON) { DEBUG_PRINTLN(F("ESP-NOW stopping.")); + #ifdef ESP8266 quickEspNow.stop(); + #else + espNowTransportStop(); + #endif statusESPNow = ESP_NOW_STATE_UNINIT; } #endif @@ -873,16 +924,13 @@ void WLED::initConnection() #endif } -#ifndef WLED_DISABLE_ESPNOW + #if !defined(WLED_DISABLE_ESPNOW) && defined(ESP8266) if (enableESPNow) { quickEspNow.onDataSent(espNowSentCB); // see udp.cpp quickEspNow.onDataRcvd(espNowReceiveCB); // see udp.cpp bool espNowOK; if (apActive) { DEBUG_PRINTLN(F("ESP-NOW initing in AP mode.")); - #ifdef ESP32 - quickEspNow.setWiFiBandwidth(WIFI_IF_AP, WIFI_BW_HT20); // Only needed for ESP32 in case you need coexistence with ESP8266 in the same network - #endif //ESP32 espNowOK = quickEspNow.begin(apChannel, WIFI_IF_AP); // Same channel must be used for both AP and ESP-NOW } else { DEBUG_PRINTLN(F("ESP-NOW initing in STA mode.")); @@ -890,13 +938,23 @@ void WLED::initConnection() } statusESPNow = espNowOK ? ESP_NOW_STATE_ON : ESP_NOW_STATE_ERROR; } -#endif + #elif !defined(WLED_DISABLE_ESPNOW) + // With a configured STA, wait for either connection success or fallback AP startup. Starting + // during a scan would bind ESP-NOW to a transient channel and break later peer sends. + if (enableESPNow && statusESPNow != ESP_NOW_STATE_ON && + (Network.isConnected() || apActive)) startEspNowForCurrentNetwork(); + #endif } void WLED::initInterfaces() { DEBUG_PRINTLN(F("Init STA interfaces")); + #if !defined(WLED_DISABLE_ESPNOW) && defined(ARDUINO_ARCH_ESP32) + // A successful STA connection changes the peer interface from a fallback AP to STA. + if (enableESPNow && (statusESPNow != ESP_NOW_STATE_ON || espNowUsingAP)) startEspNowForCurrentNetwork(); + #endif + #ifndef WLED_DISABLE_HUESYNC IPAddress ipAddress = WLEDNetwork.localIP(); if (hueIP[0] == 0) { @@ -987,14 +1045,27 @@ void WLED::handleConnection() if (!WLEDNetwork.isConnected() && wifiConfigured) { // trying to connect, but not connected if (stac) WiFi.disconnect(); // disable search so that AP can work - else + } else { + #if !defined(WLED_DISABLE_ESPNOW) && defined(WLED_ENABLE_ESPNOW_API) + if (!espNowApiRemoteActive()) initConnection(); // restart search + #else initConnection(); // restart search + #endif + } } } } if (!WLEDNetwork.isConnected()) { if (interfacesInited) { + #if !defined(WLED_DISABLE_ESPNOW) && defined(WLED_ENABLE_ESPNOW_API) + if (espNowApiRemoteActive()) { + DEBUG_PRINTLN(F("Disconnected; keeping AP and ESP-NOW active for remote.")); + interfacesInited = false; + if (!apActive) initAP(); + return; + } + #endif if (scanDone && multiWiFi.size() > 1) { DEBUG_PRINTLN(F("WiFi scan initiated on disconnect.")); findWiFi(true); // reinit scan @@ -1013,7 +1084,14 @@ void WLED::handleConnection() sendImprovStateResponse(0x03, true); improvActive = 2; } - if (now - lastReconnectAttempt > ((stac) ? 300000 : 18000) && wifiConfigured) { + unsigned long retryInterval = stac ? 300000 : 18000; + #if !defined(WLED_DISABLE_ESPNOW) && defined(WLED_ENABLE_ESPNOW_API) + // Scanning would move the radio off-channel and make the active remote unreachable. + const bool deferReconnectForEspNow = espNowApiRemoteActive(); + #else + const bool deferReconnectForEspNow = false; + #endif + if (!deferReconnectForEspNow && now - lastReconnectAttempt > retryInterval && wifiConfigured) { if (improvActive == 2) improvActive = 3; DEBUG_PRINTF_P(PSTR("Last reconnect (%lus) too old (@ %lus).\n"), lastReconnectAttempt/1000, nowS); if (++selectedWiFi >= multiWiFi.size()) selectedWiFi = 0; // we couldn't connect, try with another network from the list @@ -1025,7 +1103,8 @@ void WLED::handleConnection() initAP(); // start AP only within first 5min } } - if (apActive && apBehavior == AP_BEHAVIOR_TEMPORARY && now > WLED_AP_TIMEOUT && stac == 0) { // disconnect AP after 5min if no clients connected + if (apActive && apBehavior == AP_BEHAVIOR_TEMPORARY && now > WLED_AP_TIMEOUT && stac == 0 + && !deferReconnectForEspNow) { // disconnect AP after 5min if no clients or ESP-NOW remote are active // if AP was enabled more than 10min after boot or if client was connected more than 10min after boot do not disconnect AP mode if (now < 2*WLED_AP_TIMEOUT) { dnsServer.stop(); diff --git a/wled00/wled.h b/wled00/wled.h index aa501a8c76..689de91ccd 100644 --- a/wled00/wled.h +++ b/wled00/wled.h @@ -51,6 +51,14 @@ #endif //#define WLED_DISABLE_ESPNOW // Removes dependence on esp now +// The bidirectional JSON API is supported only on ESP32-family targets +// Supporting it on ESP8266 would exceed its flash limit +#if defined(ARDUINO_ARCH_ESP32) && !defined(WLED_ENABLE_ESPNOW_API) + #define WLED_ENABLE_ESPNOW_API +#endif +#if !defined(ARDUINO_ARCH_ESP32) && defined(WLED_ENABLE_ESPNOW_API) + #undef WLED_ENABLE_ESPNOW_API +#endif #define WLED_ENABLE_FS_EDITOR // enable /edit page for editing FS content. Will also be disabled with OTA lock @@ -124,7 +132,6 @@ #ifndef WLED_DISABLE_ESPNOW #include - #include #endif #endif #include diff --git a/wled00/ws.cpp b/wled00/ws.cpp index 6e9038c101..013a2666d3 100644 --- a/wled00/ws.cpp +++ b/wled00/ws.cpp @@ -188,55 +188,20 @@ static bool sendLiveLedsWs(uint32_t wsClient) AsyncWebSocketClient * wsc = ws.client(wsClient); if (!wsc || wsc->queueLength() > 0) return false; //only send if queue free - size_t used = strip.getLengthTotal(); #ifdef ESP8266 const size_t MAX_LIVE_LEDS_WS = 256U; #else const size_t MAX_LIVE_LEDS_WS = 1024U; #endif - size_t n = ((used -1)/MAX_LIVE_LEDS_WS) +1; //only serve every n'th LED if count over MAX_LIVE_LEDS_WS - size_t pos = 2; // start of data -#ifndef WLED_DISABLE_2D - if (strip.isMatrix) { - // ignore anything behid matrix (i.e. extra strip) - used = Segment::maxWidth*Segment::maxHeight; // always the size of matrix (more or less than strip.getLengthTotal()) - n = 1; - if (used > MAX_LIVE_LEDS_WS) n = 2; - if (used > MAX_LIVE_LEDS_WS*4) n = 4; - pos = 4; - } -#endif - size_t bufSize = pos + (used/n)*3; + // Query first so the WebSocket buffer is allocated at exactly the payload size. + size_t bufSize = buildLiveLedsPayload(nullptr, 0, MAX_LIVE_LEDS_WS); + if (!bufSize) return false; AsyncWebSocketBuffer wsBuf(bufSize); if (!wsBuf) return false; //out of memory uint8_t* buffer = reinterpret_cast(wsBuf.data()); if (!buffer) return false; //out of memory - buffer[0] = 'L'; - buffer[1] = 1; //version - -#ifndef WLED_DISABLE_2D - if (strip.isMatrix) { - buffer[1] = 2; //version - buffer[2] = Segment::maxWidth/n; - buffer[3] = Segment::maxHeight/n; - } -#endif - - for (size_t i = 0; pos < bufSize -2; i += n) - { -#ifndef WLED_DISABLE_2D - if (strip.isMatrix && n>1 && (i/Segment::maxWidth)%n) i += Segment::maxWidth * (n-1); -#endif - uint32_t c = strip.getPixelColor(i); // note: LEDs mapped outside of valid range are set to black - uint8_t r = R(c); - uint8_t g = G(c); - uint8_t b = B(c); - uint8_t w = W(c); - buffer[pos++] = bri ? qadd8(w, r) : 0; //R, add white channel to RGB channels as a simple RGBW -> RGB map - buffer[pos++] = bri ? qadd8(w, g) : 0; //G - buffer[pos++] = bri ? qadd8(w, b) : 0; //B - } + if (!buildLiveLedsPayload(buffer, bufSize, MAX_LIVE_LEDS_WS)) return false; wsc->binary(std::move(wsBuf)); return true;