From bd4e5cec4e3fbe62cabddfd65b3e64ed0227ed0b Mon Sep 17 00:00:00 2001 From: figamore <90107339+figamore@users.noreply.github.com> Date: Wed, 1 Jul 2026 08:03:31 -0400 Subject: [PATCH 01/17] Initial commit --- wled00/const.h | 20 +++ wled00/fcn_declare.h | 6 + wled00/led.cpp | 3 + wled00/remote.cpp | 419 +++++++++++++++++++++++++++++++++++++++++++ wled00/udp.cpp | 8 + wled00/wled.cpp | 1 + 6 files changed, 457 insertions(+) diff --git a/wled00/const.h b/wled00/const.h index 04ff8ded61..f041f1d8cf 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -382,6 +382,26 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); #define ESP_NOW_STATE_ON 1 #define ESP_NOW_STATE_ERROR 2 +// 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_HELLO 0x04 // discovery: remote queries, WLED replies with name/mac/ver/ch +#define ESPNOW_API_LIVE 0x05 // WLED -> remote, binary LED peek frame (same payload as WS liveview) +// reassembly/serialization caps (bounded to limit RAM use, especially on ESP8266) +#ifdef ESP8266 +#define ESPNOW_API_MAX_JSON 2048 +#else +#define ESPNOW_API_MAX_JSON 8192 +#endif +#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 + //Button type #define BTN_TYPE_NONE 0 #define BTN_TYPE_RESERVED 1 diff --git a/wled00/fcn_declare.h b/wled00/fcn_declare.h index 6201a19192..a7e4f7ade1 100644 --- a/wled00/fcn_declare.h +++ b/wled00/fcn_declare.h @@ -279,6 +279,12 @@ bool getPresetName(byte index, String& name); //remote.cpp void handleWiZdata(uint8_t *incomingData, size_t len); void handleRemote(); +#ifndef WLED_DISABLE_ESPNOW +void handleEspNowApiData(uint8_t* address, uint8_t* data, uint8_t len, bool broadcast); +bool espNowApiReady(); +void handleEspNowApi(); +void pushEspNowState(); +#endif //set.cpp bool isAsterisksOnly(const char* str, byte maxLen); diff --git a/wled00/led.cpp b/wled00/led.cpp index 131ff95bab..84162c7b17 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(); + #ifndef WLED_DISABLE_ESPNOW + pushEspNowState(); + #endif lastInterfaceUpdate = millis(); interfaceUpdateCallMode = CALL_MODE_INIT; //disable further updates diff --git a/wled00/remote.cpp b/wled00/remote.cpp index 7b3375fa66..c927d9c6b6 100644 --- a/wled00/remote.cpp +++ b/wled00/remote.cpp @@ -1,7 +1,11 @@ #include "wled.h" #ifndef WLED_DISABLE_ESPNOW +#include #define ESPNOW_BUSWAIT_TIMEOUT 24 // one frame timeout to wait for bus to finish updating +#define ESPNOW_LIVE_INTERVAL 40 // live peek cadence (ms), matching the WS liveview +#define ESPNOW_LIVE_TIMEOUT 3000 // 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 NIGHT_MODE_DEACTIVATED -1 #define NIGHT_MODE_BRIGHTNESS 5 @@ -209,6 +213,421 @@ void handleWiZdata(uint8_t *incomingData, size_t len) { last_seq = cur_seq; } +// Bidirectional JSON transport for linked ESP-NOW remotes. Frames are fragmented to fit +// the 250-byte ESP-NOW payload limit; see docs/espnow-json-protocol.md. +// Completed messages are applied in loop context, where FS and LED state are safe to touch. + +struct EspNowApiInbox { + volatile bool ready; + uint8_t srcMac[6]; + uint8_t msgType; + uint8_t msgId; + uint8_t* json; // NUL-terminated heap buffer; ownership passes to the loop + size_t len; +}; +static EspNowApiInbox apiInbox = {false, {0}, 0, 0, nullptr, 0}; + +static uint8_t* apiReasmBuf = nullptr; +static uint8_t apiReasmSrc[6]= {0}; +static uint8_t apiReasmId = 0; +static uint8_t apiReasmType = 0; +static uint8_t apiReasmTotal = 0; +static uint8_t apiReasmCount = 0; +static uint64_t apiReasmFlags = 0; // received-fragment bitmask (fragTotal <= 64) +static size_t apiReasmLen = 0; +static unsigned long apiReasmLast = 0; +static unsigned long apiRemoteSeen = 0; // last time any API frame arrived; gates state pushes + +static bool apiLiveActive = false; +static uint8_t apiLiveMac[6] = {0}; +static uint8_t apiLiveMsgId = 0; +static unsigned long apiLastLiveTime = 0; +static unsigned long apiLiveExpiry = 0; // live peek is a keepalive (no disconnect signal over ESP-NOW) + +// The receive callback runs on a separate task; this try-lock guards the shared reassembly +// state. It never blocks: contention just drops a fragment, which the remote re-sends. +static std::atomic_flag apiStateLock = ATOMIC_FLAG_INIT; + +struct EspNowApiStateGuard { + bool locked; + EspNowApiStateGuard() : locked(!apiStateLock.test_and_set(std::memory_order_acquire)) {} + ~EspNowApiStateGuard() { if (locked) apiStateLock.clear(std::memory_order_release); } + operator bool() const { return locked; } +}; + +static void apiReasmReset() { + if (apiReasmBuf) { free(apiReasmBuf); apiReasmBuf = nullptr; } + apiReasmTotal = apiReasmCount = 0; + apiReasmFlags = 0; + apiReasmLen = 0; +} + +static void apiInboxReset() { + if (apiInbox.json) { free(apiInbox.json); apiInbox.json = nullptr; } + apiInbox.ready = false; + apiInbox.len = 0; +} + +static void apiLiveReset() { + apiLiveActive = false; + apiLiveMsgId = 0; + apiLastLiveTime = 0; + apiLiveExpiry = 0; +} + +static void apiReasmCleanupStale() { + if (apiReasmBuf && millis() - apiReasmLast > ESPNOW_API_REASM_TIMEOUT) apiReasmReset(); +} + +static void apiResetAll() { + EspNowApiStateGuard guard; + if (guard) { + apiReasmReset(); + apiInboxReset(); + } + apiLiveReset(); +} + +bool espNowApiReady() { + return enableESPNow && statusESPNow == ESP_NOW_STATE_ON && (interfacesInited || apActive) && (apActive || WLED_CONNECTED); +} + +// Reassemble an inbound frame in receive-task context: validated, bounded copies only. +void handleEspNowApiData(uint8_t* address, uint8_t* data, uint8_t len, bool broadcast) { + 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; + + // reject untrusted header values before any indexing or allocation + 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 + + EspNowApiStateGuard guard; + if (!guard) return; + + unsigned long now = millis(); + apiRemoteSeen = now; + bool newMsg = (apiReasmBuf == nullptr) || (now - apiReasmLast > ESPNOW_API_REASM_TIMEOUT) || + (memcmp(apiReasmSrc, address, 6) != 0) || (apiReasmId != msgId) || (apiReasmTotal != fragTotal); + if (newMsg) { + apiReasmReset(); + if (fragIndex != 0) return; + apiReasmBuf = (uint8_t*)d_malloc((size_t)fragTotal * ESPNOW_API_FRAG_SIZE + 1); + if (!apiReasmBuf) return; + memcpy(apiReasmSrc, address, 6); + apiReasmId = msgId; + apiReasmType = msgType; + apiReasmTotal = fragTotal; + } + apiReasmLast = now; + + const uint64_t bit = (uint64_t)1 << fragIndex; + if (apiReasmFlags & bit) return; // duplicate + 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 (apiInbox.ready) { apiReasmReset(); return; } // loop hasn't drained the previous message; drop this one + if (apiReasmLen > ESPNOW_API_MAX_JSON) { apiReasmReset(); return; } + apiReasmBuf[apiReasmLen] = '\0'; + apiInbox.json = apiReasmBuf; + apiInbox.len = apiReasmLen; + apiInbox.msgType = apiReasmType; + apiInbox.msgId = apiReasmId; + memcpy(apiInbox.srcMac, apiReasmSrc, 6); + apiInbox.ready = true; + apiReasmBuf = nullptr; // ownership moved to the inbox + apiReasmTotal = apiReasmCount = 0; + apiReasmFlags = 0; +} + +// ESP8266 QuickESPNow does not auto-register unicast peers (ESP32 does). +static void espNowEnsurePeer(const uint8_t* mac) { +#ifdef ESP8266 + if (memcmp(mac, ESPNOW_BROADCAST_ADDRESS, 6) == 0) return; + if (!esp_now_is_peer_exist((uint8_t*)mac)) { + esp_now_add_peer((uint8_t*)mac, ESP_NOW_ROLE_COMBO, 0, nullptr, 0); // channel 0 = current + } +#else + (void)mac; +#endif +} + +// Fragment a payload and send it, pacing against the small TX ring buffer. +static bool sendEspNowApiPayload(const uint8_t* mac, uint8_t msgType, uint8_t msgId, const uint8_t* payload, size_t payloadLen) { + if (statusESPNow != ESP_NOW_STATE_ON || !mac) return false; + if (!payload && payloadLen) return false; + size_t total = payloadLen ? (payloadLen + ESPNOW_API_FRAG_SIZE - 1) / ESPNOW_API_FRAG_SIZE : 1; + if (total > ESPNOW_API_MAX_FRAGS) return false; + espNowEnsurePeer(mac); + + uint8_t frame[ESPNOW_API_HEADER_SIZE + ESPNOW_API_FRAG_SIZE]; + frame[0] = ESPNOW_API_MAGIC; + frame[1] = ESPNOW_API_VERSION; + frame[2] = msgType; + frame[3] = msgId; + frame[5] = (uint8_t)total; + for (size_t i = 0; i < total; i++) { + size_t off = i * ESPNOW_API_FRAG_SIZE; + size_t chunk = payloadLen > off ? payloadLen - off : 0; + if (chunk > ESPNOW_API_FRAG_SIZE) chunk = ESPNOW_API_FRAG_SIZE; + frame[4] = (uint8_t)i; + if (chunk) memcpy(frame + ESPNOW_API_HEADER_SIZE, payload + off, chunk); + unsigned long start = millis(); + while (!quickEspNow.readyToSendData() && millis() - start < ESPNOW_BUSWAIT_TIMEOUT) yield(); + if (quickEspNow.send(mac, frame, ESPNOW_API_HEADER_SIZE + chunk)) return false; + } + return true; +} + +static bool sendEspNowApiJson(const uint8_t* mac, uint8_t msgType, uint8_t msgId, const char* json, size_t jsonLen) { + return sendEspNowApiPayload(mac, msgType, msgId, reinterpret_cast(json), jsonLen); +} + +// Serialize the prepared pDoc and send it. Caller holds JSON_LOCK_REMOTE; this releases it +// before the (slow) send so the global JSON buffer is not held during transmission. +static bool sendApiDocLocked(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 false; } + char* buf = (char*)d_malloc(len + 1); + if (!buf) { releaseJSONBufferLock(); return false; } + serializeJson(*pDoc, buf, len + 1); + releaseJSONBufferLock(); + bool ok = sendEspNowApiJson(mac, msgType, msgId, buf, len); + free(buf); + return ok; +} + +static bool sendEspNowApiState(const uint8_t* mac, uint8_t msgType, uint8_t msgId) { + if (statusESPNow != ESP_NOW_STATE_ON) return false; + if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) return false; + pDoc->clear(); + JsonObject state = pDoc->createNestedObject("state"); + serializeState(state); + JsonObject info = pDoc->createNestedObject("info"); + serializeInfo(info); + return sendApiDocLocked(mac, msgType, msgId); +} + +static void sendEspNowApiResponse(const uint8_t* mac, uint8_t msgId, bool verbose) { + if (verbose) { + if (!sendEspNowApiState(mac, ESPNOW_API_RESPONSE, msgId)) { + sendEspNowApiJson(mac, ESPNOW_API_RESPONSE, msgId, "{\"error\":8}", 11); // response exceeded ESPNOW_API_MAX_JSON + } + } else { + static const char ok[] = "{\"success\":true}"; + sendEspNowApiJson(mac, ESPNOW_API_RESPONSE, msgId, ok, strlen(ok)); + } +} + +static void sendEspNowHello(const uint8_t* mac) { + if (statusESPNow != ESP_NOW_STATE_ON) return; + if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) return; + pDoc->clear(); + JsonObject hello = pDoc->createNestedObject("hello"); + hello[F("name")] = serverDescription; + uint8_t myMac[6]; + WiFi.macAddress(myMac); + char macStr[13]; + sprintf_P(macStr, PSTR("%02x%02x%02x%02x%02x%02x"), myMac[0], myMac[1], myMac[2], myMac[3], myMac[4], myMac[5]); + hello[F("mac")] = macStr; + hello[F("ver")] = VERSION; + hello[F("ch")] = WiFi.channel(); + sendApiDocLocked(mac, ESPNOW_API_HELLO, 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. Large lists may exceed ESPNOW_API_MAX_JSON on an ESP8266 host (-> error 8). +static void sendEspNowApiCatalog(const uint8_t* mac, uint8_t msgId, const char* what) { + if (statusESPNow != ESP_NOW_STATE_ON || !what) return; + + if (!strcmp_P(what, PSTR("ps"))) { + // getPresetName() takes the JSON lock and uses pDoc, so collect names before locking. + std::vector> presets; + uint8_t misses = 0; + for (uint16_t i = 1; i <= 250 && misses < 16; i++) { + String name; + if (getPresetName(i, name)) { presets.push_back({(uint8_t)i, name}); misses = 0; } + else misses++; + } + if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) return; + pDoc->clear(); + JsonObject ps = pDoc->createNestedObject("presets"); + for (auto& p : presets) ps[String(p.first)] = p.second; + sendApiDocLocked(mac, ESPNOW_API_RESPONSE, msgId); + return; + } + + if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) return; + pDoc->clear(); + if (!strcmp_P(what, PSTR("fx"))) { + JsonArray effects = pDoc->createNestedArray("effects"); + serializeModeNames(effects); + } else if (!strcmp_P(what, PSTR("pal"))) { + (*pDoc)[F("palettes")] = serialized((const __FlashStringHelper*)JSON_palette_names); + } else { + releaseJSONBufferLock(); + sendEspNowApiJson(mac, ESPNOW_API_RESPONSE, msgId, "{\"error\":9}", 11); + return; + } + sendApiDocLocked(mac, ESPNOW_API_RESPONSE, msgId); +} + +// Binary live LED peek payload, identical in format to the WebSocket liveview. +static bool sendEspNowLiveLeds(const uint8_t* mac, uint8_t msgId) { + if (statusESPNow != ESP_NOW_STATE_ON || !mac) return false; + + size_t used = strip.getLengthTotal(); + if (!used) return false; +#ifdef ESP8266 + const size_t MAX_LIVE_LEDS_ESPNOW = 256U; +#else + const size_t MAX_LIVE_LEDS_ESPNOW = 1024U; +#endif + size_t n = ((used - 1) / MAX_LIVE_LEDS_ESPNOW) + 1; // serve every n'th LED when over the cap + size_t pos = 2; +#ifndef WLED_DISABLE_2D + if (strip.isMatrix) { + used = Segment::maxWidth * Segment::maxHeight; + n = 1; + if (used > MAX_LIVE_LEDS_ESPNOW) n = 2; + if (used > MAX_LIVE_LEDS_ESPNOW * 4) n = 4; + pos = 4; + } +#endif + size_t bufSize = pos + (used / n) * 3; + if (bufSize > ESPNOW_API_MAX_JSON) return false; + + uint8_t* buffer = reinterpret_cast(d_malloc(bufSize)); + if (!buffer) return false; + buffer[0] = 'L'; + buffer[1] = 1; + +#ifndef WLED_DISABLE_2D + if (strip.isMatrix) { + buffer[1] = 2; + 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); + uint8_t r = R(c), g = G(c), b = B(c), w = W(c); + buffer[pos++] = bri ? qadd8(w, r) : 0; // fold the white channel into RGB + buffer[pos++] = bri ? qadd8(w, g) : 0; + buffer[pos++] = bri ? qadd8(w, b) : 0; + } + + bool ok = sendEspNowApiPayload(mac, ESPNOW_API_LIVE, msgId, buffer, bufSize); + free(buffer); + return ok; +} + +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 = sendEspNowLiveLeds(apiLiveMac, apiLiveMsgId++); + apiLastLiveTime = millis(); + if (!success) apiLastLiveTime -= 20; // retry sooner if TX queue or heap was busy +} + +// Apply a completed inbound message in loop context. +void handleEspNowApi() { + if (!espNowApiReady()) { + apiResetAll(); + return; + } + handleEspNowLive(); + + uint8_t srcMac[6]; + uint8_t msgType = 0, msgId = 0; + uint8_t* json = nullptr; + size_t jsonLen = 0; + + { + EspNowApiStateGuard guard; + if (!guard) return; + apiReasmCleanupStale(); + if (!apiInbox.ready) return; + memcpy(srcMac, apiInbox.srcMac, 6); + msgType = apiInbox.msgType; + msgId = apiInbox.msgId; + json = apiInbox.json; // ownership moves to this invocation + jsonLen = apiInbox.len; + apiInbox.json = nullptr; + apiInbox.len = 0; + apiInbox.ready = false; + } + + unsigned long start = millis(); + while (strip.isUpdating() && millis()-start < ESPNOW_BUSWAIT_TIMEOUT) yield(); + + if (msgType == ESPNOW_API_HELLO) { + sendEspNowHello(srcMac); + } else if (msgType == ESPNOW_API_REQUEST) { + bool verbose = true; + if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) { + sendEspNowApiJson(srcMac, ESPNOW_API_RESPONSE, msgId, "{\"error\":3}", 11); + } else { + DeserializationError err = deserializeJson(*pDoc, json, jsonLen); + JsonObject root = pDoc->as(); + if (err || root.isNull()) { + releaseJSONBufferLock(); + sendEspNowApiJson(srcMac, ESPNOW_API_RESPONSE, msgId, "{\"error\":9}", 11); + } else { + // mirror wsEvent(): {"v":true} polls state, {"lv":...} toggles live peek + 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(); + } + verbose = false; + } else if (root.containsKey("get")) { + char what[8]; + strlcpy(what, root["get"] | "", sizeof(what)); + releaseJSONBufferLock(); + sendEspNowApiCatalog(srcMac, msgId, what); + free(json); + return; + } else { + verbose = deserializeState(root, CALL_MODE_BUTTON); + } + releaseJSONBufferLock(); + sendEspNowApiResponse(srcMac, msgId, verbose); + } + } + } + + free(json); +} + +// 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()) return; + if (!apiRemoteSeen || millis() - apiRemoteSeen > ESPNOW_API_PRESENCE_TIMEOUT) return; + static uint8_t pushId = 0; + sendEspNowApiState(ESPNOW_BROADCAST_ADDRESS, ESPNOW_API_PUSH, pushId++); +} // process ESPNow button data (acesses FS, should not be called while update to avoid glitches) void handleRemote() { if(ESPNowButton >= 0) { diff --git a/wled00/udp.cpp b/wled00/udp.cpp index 156a20f990..463033b605 100644 --- a/wled00/udp.cpp +++ b/wled00/udp.cpp @@ -938,6 +938,14 @@ void espNowReceiveCB(uint8_t* address, uint8_t* data, uint8_t len, signed int rs return; } + // bidirectional ESP-NOW JSON API frames; reassembled here, + // applied later in handleEspNowApi(). Already gated by the linked_remotes whitelist above. + if (len >= ESPNOW_API_HEADER_SIZE && data[0] == ESPNOW_API_MAGIC && data[1] == ESPNOW_API_VERSION) { + if (!espNowApiReady()) return; + handleEspNowApiData(address, data, len, broadcast); + return; + } + 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.")); diff --git a/wled00/wled.cpp b/wled00/wled.cpp index 55033d5986..c52f73644b 100644 --- a/wled00/wled.cpp +++ b/wled00/wled.cpp @@ -93,6 +93,7 @@ void WLED::loop() #endif #ifndef WLED_DISABLE_ESPNOW handleRemote(); + handleEspNowApi(); #endif #ifndef WLED_DISABLE_ALEXA handleAlexa(); From 96cb257fa1f3efaf3cac57c97005c5e57380760a Mon Sep 17 00:00:00 2001 From: figamore <90107339+figamore@users.noreply.github.com> Date: Sun, 12 Jul 2026 04:39:19 -0400 Subject: [PATCH 02/17] ESP-NOW AP mode work: WIP --- wled00/espnow_api.cpp | 467 ++++++++++++++++++++++++++++++++++++++++++ wled00/fcn_declare.h | 6 +- wled00/json.cpp | 53 +++++ wled00/remote.cpp | 418 ------------------------------------- wled00/udp.cpp | 2 +- wled00/wled.cpp | 14 +- wled00/ws.cpp | 42 +--- 7 files changed, 540 insertions(+), 462 deletions(-) create mode 100644 wled00/espnow_api.cpp diff --git a/wled00/espnow_api.cpp b/wled00/espnow_api.cpp new file mode 100644 index 0000000000..43b106e55c --- /dev/null +++ b/wled00/espnow_api.cpp @@ -0,0 +1,467 @@ +#include "wled.h" +#ifndef WLED_DISABLE_ESPNOW +#include + +// 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_API_STRIPWAIT_TIMEOUT 24 // one frame timeout to wait for the strip to finish updating +#define ESPNOW_LIVE_INTERVAL 40 // live peek cadence (ms), matching the WS liveview +#define ESPNOW_LIVE_TIMEOUT 3000 // 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_TX_PER_LOOP 3 // max fragments transmitted per loop() pass (bounds loop stall) + +// 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 { + volatile bool ready; + uint8_t srcMac[6]; + uint8_t msgType; + uint8_t msgId; + uint8_t* json; // NUL-terminated heap buffer; ownership passes to the loop + size_t len; +}; +static EspNowApiInbox apiInbox = {false, {0}, 0, 0, nullptr, 0}; + +static uint8_t* apiReasmBuf = nullptr; +static uint8_t apiReasmSrc[6]= {0}; +static uint8_t apiReasmId = 0; +static uint8_t apiReasmType = 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 time an API frame arrived; gates state pushes + +static bool apiLiveActive = false; +static uint8_t apiLiveMac[6] = {0}; +static uint8_t apiLiveMsgId = 0; +static unsigned long apiLastLiveTime = 0; +static unsigned long apiLiveExpiry = 0; // live peek is a keepalive (no disconnect signal over ESP-NOW) + +// 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}; + +// The receive callback runs on a separate task; this try-lock guards the shared reassembly +// state. It never blocks: contention just drops a fragment, which the remote re-sends. +static std::atomic_flag apiStateLock = ATOMIC_FLAG_INIT; + +struct EspNowApiStateGuard { + bool locked; + EspNowApiStateGuard() : locked(!apiStateLock.test_and_set(std::memory_order_acquire)) {} + ~EspNowApiStateGuard() { if (locked) apiStateLock.clear(std::memory_order_release); } + operator bool() const { return locked; } +}; + +static void apiReasmReset() { + if (apiReasmBuf) { free(apiReasmBuf); apiReasmBuf = nullptr; } + apiReasmTotal = apiReasmCount = 0; + apiReasmFlags = 0; + apiReasmLen = 0; +} + +static void apiInboxReset() { + if (apiInbox.json) { free(apiInbox.json); apiInbox.json = nullptr; } + apiInbox.ready = false; + apiInbox.len = 0; +} + +static void apiLiveReset() { + apiLiveActive = false; + apiLiveMsgId = 0; + apiLastLiveTime = 0; + apiLiveExpiry = 0; +} + +static void apiTxReset() { + if (apiTx.payload) { free(apiTx.payload); apiTx.payload = nullptr; } +} + +static void apiReasmCleanupStale() { + if (apiReasmBuf && millis() - apiReasmLast > ESPNOW_API_REASM_TIMEOUT) apiReasmReset(); +} + +bool espNowApiReady() { + return enableESPNow && statusESPNow == ESP_NOW_STATE_ON; +} + +bool espNowApiRemoteActive() { + return apiRemoteSeen && millis() - apiRemoteSeen < ESPNOW_API_PRESENCE_TIMEOUT; +} + +// Reassemble an inbound frame in receive-task context: validated, bounded copies only. +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; + + // reject untrusted header values before any indexing or allocation + if (msgType != ESPNOW_API_REQUEST && msgType != ESPNOW_API_HELLO) 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 + + EspNowApiStateGuard guard; + if (!guard) return; + + unsigned long now = millis(); + apiRemoteSeen = now; + bool newMsg = (apiReasmBuf == nullptr) || (now - apiReasmLast > ESPNOW_API_REASM_TIMEOUT) || + (memcmp(apiReasmSrc, address, 6) != 0) || (apiReasmId != msgId) || (apiReasmTotal != fragTotal); + if (newMsg) { + apiReasmReset(); + if (fragIndex != 0) return; + apiReasmBuf = (uint8_t*)d_malloc((size_t)fragTotal * ESPNOW_API_FRAG_SIZE + 1); + if (!apiReasmBuf) return; + memcpy(apiReasmSrc, address, 6); + apiReasmId = msgId; + apiReasmType = msgType; + apiReasmTotal = fragTotal; + } + apiReasmLast = now; + + const espnow_frag_mask_t bit = (espnow_frag_mask_t)1 << fragIndex; + if (apiReasmFlags & bit) return; // duplicate + 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 (apiInbox.ready) { apiReasmReset(); return; } // loop hasn't drained the previous message; drop this one + if (apiReasmLen > ESPNOW_API_MAX_JSON) { apiReasmReset(); return; } + apiReasmBuf[apiReasmLen] = '\0'; + apiInbox.json = apiReasmBuf; + apiInbox.len = apiReasmLen; + apiInbox.msgType = apiReasmType; + apiInbox.msgId = apiReasmId; + memcpy(apiInbox.srcMac, apiReasmSrc, 6); + apiInbox.ready = true; + apiReasmBuf = nullptr; // ownership moved to the inbox; reset clears the remaining state + apiReasmReset(); +} + +// ESP8266 QuickESPNow does not auto-register unicast peers (ESP32 does). +static void espNowEnsurePeer(const uint8_t* mac) { +#ifdef ESP8266 + if (memcmp(mac, ESPNOW_BROADCAST_ADDRESS, 6) == 0) return; + if (!esp_now_is_peer_exist((uint8_t*)mac)) { + esp_now_add_peer((uint8_t*)mac, ESP_NOW_ROLE_COMBO, 0, nullptr, 0); // channel 0 = current + } +#else + (void)mac; +#endif +} + +static bool apiTxIdle() { return apiTx.payload == nullptr; } + +// Send a few pending fragments, then return; called every loop pass and after each enqueue. +static void serviceEspNowApiTx() { + if (apiTxIdle()) return; + if (statusESPNow != ESP_NOW_STATE_ON) { apiTxReset(); 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; + for (unsigned i = 0; i < ESPNOW_API_TX_PER_LOOP && !apiTxIdle(); i++) { + if (!quickEspNow.readyToSendData()) return; // TX ring full; resume next loop pass + 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); + if (quickEspNow.send(apiTx.mac, frame, ESPNOW_API_HEADER_SIZE + chunk)) { apiTxReset(); return; } // link error; drop, remote retries + 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; + espNowEnsurePeer(mac); + serviceEspNowApiTx(); + return true; +} + +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) { + char buf[20]; + strcpy_P(buf, PSTR("{\"success\":true}")); + sendEspNowApiJson(mac, ESPNOW_API_RESPONSE, msgId, buf, strlen(buf)); +} + +// 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; +} + +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); +} + +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); +} + +// The reply is broadcast: a unicast reply needs a MAC-level ACK, which is unreliable while +// this radio time-shares with WiFi scanning/connecting; the remote identifies us by the +// frame's source MAC (and the "mac" field). +static void sendEspNowHello() { + if (statusESPNow != ESP_NOW_STATE_ON) return; + if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) return; // discovery is best-effort; remote re-broadcasts + pDoc->clear(); + JsonObject hello = pDoc->createNestedObject("hello"); + hello[F("name")] = serverDescription; + hello[F("mac")] = escapedMac; + hello[F("ver")] = VERSION; + hello[F("ch")] = WiFi.channel(); + queueApiDocLocked(ESPNOW_BROADCAST_ADDRESS, ESPNOW_API_HELLO, 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)}); + } + 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 +#ifdef ESP8266 + const size_t MAX_LIVE_LEDS_ESPNOW = 256U; +#else + const size_t MAX_LIVE_LEDS_ESPNOW = 1024U; +#endif + 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; +} + +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 +} + +static bool apiResetAll() { + { + EspNowApiStateGuard guard; + if (!guard) return false; + apiReasmReset(); + apiInboxReset(); + } + apiLiveReset(); + apiTxReset(); + apiRemoteSeen = 0; + return true; +} + +// Apply a completed inbound message in loop context. +void handleEspNowApi() { + static bool needCleanup = false; + if (!espNowApiReady()) { + if (needCleanup) needCleanup = !apiResetAll(); // once, on the on->off transition + return; + } + needCleanup = true; + + serviceEspNowApiTx(); + handleEspNowLive(); + + uint8_t srcMac[6]; + uint8_t msgType = 0, msgId = 0; + uint8_t* json = nullptr; + size_t jsonLen = 0; + + { + EspNowApiStateGuard guard; + if (!guard) return; + apiReasmCleanupStale(); + if (!apiInbox.ready) return; + memcpy(srcMac, apiInbox.srcMac, 6); + msgType = apiInbox.msgType; + msgId = apiInbox.msgId; + json = apiInbox.json; // ownership moves to this invocation + jsonLen = apiInbox.len; + apiInbox.json = nullptr; + apiInbox.len = 0; + apiInbox.ready = false; + } + + unsigned long start = millis(); + while (strip.isUpdating() && millis()-start < ESPNOW_API_STRIPWAIT_TIMEOUT) yield(); + + if (msgType == ESPNOW_API_HELLO) { + sendEspNowHello(); + } else if (msgType == ESPNOW_API_REQUEST) { + 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 { + // mirror wsEvent(): {"v":true} polls state, {"lv":...} toggles live peek + bool verbose = false; + 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)); + releaseJSONBufferLock(); + sendEspNowApiCatalog(srcMac, msgId, what); + free(json); + return; + } else { + verbose = deserializeState(root, CALL_MODE_BUTTON); + } + 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; + sendEspNowApiResponse(srcMac, msgId, verbose); + } + } + } + + free(json); +} + +// 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; + if (!apiTxIdle()) return; // best-effort: a state push never waits behind an in-flight message + static uint8_t pushId = 0; + if (queueEspNowApiState(ESPNOW_BROADCAST_ADDRESS, ESPNOW_API_PUSH, pushId) == 0) pushId++; +} +#endif // WLED_DISABLE_ESPNOW diff --git a/wled00/fcn_declare.h b/wled00/fcn_declare.h index a7e4f7ade1..561235c77c 100644 --- a/wled00/fcn_declare.h +++ b/wled00/fcn_declare.h @@ -181,6 +181,7 @@ void serveJson(AsyncWebServerRequest* request); #ifdef WLED_ENABLE_JSONLIVE bool serveLiveLeds(AsyncWebServerRequest* request, uint32_t wsClient = 0); #endif +size_t buildLiveLedsPayload(uint8_t* buffer, size_t bufSize, size_t maxLeds); //led.cpp void setValuesFromSegment(uint8_t s); @@ -279,9 +280,12 @@ bool getPresetName(byte index, String& name); //remote.cpp void handleWiZdata(uint8_t *incomingData, size_t len); void handleRemote(); + +//espnow_api.cpp #ifndef WLED_DISABLE_ESPNOW -void handleEspNowApiData(uint8_t* address, uint8_t* data, uint8_t len, bool broadcast); +void handleEspNowApiData(uint8_t* address, uint8_t* data, uint8_t len); bool espNowApiReady(); +bool espNowApiRemoteActive(); void handleEspNowApi(); void pushEspNowState(); #endif diff --git a/wled00/json.cpp b/wled00/json.cpp index a4f46bb5e7..47a398da0f 100644 --- a/wled00/json.cpp +++ b/wled00/json.cpp @@ -1393,6 +1393,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/remote.cpp b/wled00/remote.cpp index c927d9c6b6..8de55e2050 100644 --- a/wled00/remote.cpp +++ b/wled00/remote.cpp @@ -1,11 +1,7 @@ #include "wled.h" #ifndef WLED_DISABLE_ESPNOW -#include #define ESPNOW_BUSWAIT_TIMEOUT 24 // one frame timeout to wait for bus to finish updating -#define ESPNOW_LIVE_INTERVAL 40 // live peek cadence (ms), matching the WS liveview -#define ESPNOW_LIVE_TIMEOUT 3000 // 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 NIGHT_MODE_DEACTIVATED -1 #define NIGHT_MODE_BRIGHTNESS 5 @@ -213,421 +209,7 @@ void handleWiZdata(uint8_t *incomingData, size_t len) { last_seq = cur_seq; } -// Bidirectional JSON transport for linked ESP-NOW remotes. Frames are fragmented to fit -// the 250-byte ESP-NOW payload limit; see docs/espnow-json-protocol.md. -// Completed messages are applied in loop context, where FS and LED state are safe to touch. -struct EspNowApiInbox { - volatile bool ready; - uint8_t srcMac[6]; - uint8_t msgType; - uint8_t msgId; - uint8_t* json; // NUL-terminated heap buffer; ownership passes to the loop - size_t len; -}; -static EspNowApiInbox apiInbox = {false, {0}, 0, 0, nullptr, 0}; - -static uint8_t* apiReasmBuf = nullptr; -static uint8_t apiReasmSrc[6]= {0}; -static uint8_t apiReasmId = 0; -static uint8_t apiReasmType = 0; -static uint8_t apiReasmTotal = 0; -static uint8_t apiReasmCount = 0; -static uint64_t apiReasmFlags = 0; // received-fragment bitmask (fragTotal <= 64) -static size_t apiReasmLen = 0; -static unsigned long apiReasmLast = 0; -static unsigned long apiRemoteSeen = 0; // last time any API frame arrived; gates state pushes - -static bool apiLiveActive = false; -static uint8_t apiLiveMac[6] = {0}; -static uint8_t apiLiveMsgId = 0; -static unsigned long apiLastLiveTime = 0; -static unsigned long apiLiveExpiry = 0; // live peek is a keepalive (no disconnect signal over ESP-NOW) - -// The receive callback runs on a separate task; this try-lock guards the shared reassembly -// state. It never blocks: contention just drops a fragment, which the remote re-sends. -static std::atomic_flag apiStateLock = ATOMIC_FLAG_INIT; - -struct EspNowApiStateGuard { - bool locked; - EspNowApiStateGuard() : locked(!apiStateLock.test_and_set(std::memory_order_acquire)) {} - ~EspNowApiStateGuard() { if (locked) apiStateLock.clear(std::memory_order_release); } - operator bool() const { return locked; } -}; - -static void apiReasmReset() { - if (apiReasmBuf) { free(apiReasmBuf); apiReasmBuf = nullptr; } - apiReasmTotal = apiReasmCount = 0; - apiReasmFlags = 0; - apiReasmLen = 0; -} - -static void apiInboxReset() { - if (apiInbox.json) { free(apiInbox.json); apiInbox.json = nullptr; } - apiInbox.ready = false; - apiInbox.len = 0; -} - -static void apiLiveReset() { - apiLiveActive = false; - apiLiveMsgId = 0; - apiLastLiveTime = 0; - apiLiveExpiry = 0; -} - -static void apiReasmCleanupStale() { - if (apiReasmBuf && millis() - apiReasmLast > ESPNOW_API_REASM_TIMEOUT) apiReasmReset(); -} - -static void apiResetAll() { - EspNowApiStateGuard guard; - if (guard) { - apiReasmReset(); - apiInboxReset(); - } - apiLiveReset(); -} - -bool espNowApiReady() { - return enableESPNow && statusESPNow == ESP_NOW_STATE_ON && (interfacesInited || apActive) && (apActive || WLED_CONNECTED); -} - -// Reassemble an inbound frame in receive-task context: validated, bounded copies only. -void handleEspNowApiData(uint8_t* address, uint8_t* data, uint8_t len, bool broadcast) { - 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; - - // reject untrusted header values before any indexing or allocation - 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 - - EspNowApiStateGuard guard; - if (!guard) return; - - unsigned long now = millis(); - apiRemoteSeen = now; - bool newMsg = (apiReasmBuf == nullptr) || (now - apiReasmLast > ESPNOW_API_REASM_TIMEOUT) || - (memcmp(apiReasmSrc, address, 6) != 0) || (apiReasmId != msgId) || (apiReasmTotal != fragTotal); - if (newMsg) { - apiReasmReset(); - if (fragIndex != 0) return; - apiReasmBuf = (uint8_t*)d_malloc((size_t)fragTotal * ESPNOW_API_FRAG_SIZE + 1); - if (!apiReasmBuf) return; - memcpy(apiReasmSrc, address, 6); - apiReasmId = msgId; - apiReasmType = msgType; - apiReasmTotal = fragTotal; - } - apiReasmLast = now; - - const uint64_t bit = (uint64_t)1 << fragIndex; - if (apiReasmFlags & bit) return; // duplicate - 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 (apiInbox.ready) { apiReasmReset(); return; } // loop hasn't drained the previous message; drop this one - if (apiReasmLen > ESPNOW_API_MAX_JSON) { apiReasmReset(); return; } - apiReasmBuf[apiReasmLen] = '\0'; - apiInbox.json = apiReasmBuf; - apiInbox.len = apiReasmLen; - apiInbox.msgType = apiReasmType; - apiInbox.msgId = apiReasmId; - memcpy(apiInbox.srcMac, apiReasmSrc, 6); - apiInbox.ready = true; - apiReasmBuf = nullptr; // ownership moved to the inbox - apiReasmTotal = apiReasmCount = 0; - apiReasmFlags = 0; -} - -// ESP8266 QuickESPNow does not auto-register unicast peers (ESP32 does). -static void espNowEnsurePeer(const uint8_t* mac) { -#ifdef ESP8266 - if (memcmp(mac, ESPNOW_BROADCAST_ADDRESS, 6) == 0) return; - if (!esp_now_is_peer_exist((uint8_t*)mac)) { - esp_now_add_peer((uint8_t*)mac, ESP_NOW_ROLE_COMBO, 0, nullptr, 0); // channel 0 = current - } -#else - (void)mac; -#endif -} - -// Fragment a payload and send it, pacing against the small TX ring buffer. -static bool sendEspNowApiPayload(const uint8_t* mac, uint8_t msgType, uint8_t msgId, const uint8_t* payload, size_t payloadLen) { - if (statusESPNow != ESP_NOW_STATE_ON || !mac) return false; - if (!payload && payloadLen) return false; - size_t total = payloadLen ? (payloadLen + ESPNOW_API_FRAG_SIZE - 1) / ESPNOW_API_FRAG_SIZE : 1; - if (total > ESPNOW_API_MAX_FRAGS) return false; - espNowEnsurePeer(mac); - - uint8_t frame[ESPNOW_API_HEADER_SIZE + ESPNOW_API_FRAG_SIZE]; - frame[0] = ESPNOW_API_MAGIC; - frame[1] = ESPNOW_API_VERSION; - frame[2] = msgType; - frame[3] = msgId; - frame[5] = (uint8_t)total; - for (size_t i = 0; i < total; i++) { - size_t off = i * ESPNOW_API_FRAG_SIZE; - size_t chunk = payloadLen > off ? payloadLen - off : 0; - if (chunk > ESPNOW_API_FRAG_SIZE) chunk = ESPNOW_API_FRAG_SIZE; - frame[4] = (uint8_t)i; - if (chunk) memcpy(frame + ESPNOW_API_HEADER_SIZE, payload + off, chunk); - unsigned long start = millis(); - while (!quickEspNow.readyToSendData() && millis() - start < ESPNOW_BUSWAIT_TIMEOUT) yield(); - if (quickEspNow.send(mac, frame, ESPNOW_API_HEADER_SIZE + chunk)) return false; - } - return true; -} - -static bool sendEspNowApiJson(const uint8_t* mac, uint8_t msgType, uint8_t msgId, const char* json, size_t jsonLen) { - return sendEspNowApiPayload(mac, msgType, msgId, reinterpret_cast(json), jsonLen); -} - -// Serialize the prepared pDoc and send it. Caller holds JSON_LOCK_REMOTE; this releases it -// before the (slow) send so the global JSON buffer is not held during transmission. -static bool sendApiDocLocked(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 false; } - char* buf = (char*)d_malloc(len + 1); - if (!buf) { releaseJSONBufferLock(); return false; } - serializeJson(*pDoc, buf, len + 1); - releaseJSONBufferLock(); - bool ok = sendEspNowApiJson(mac, msgType, msgId, buf, len); - free(buf); - return ok; -} - -static bool sendEspNowApiState(const uint8_t* mac, uint8_t msgType, uint8_t msgId) { - if (statusESPNow != ESP_NOW_STATE_ON) return false; - if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) return false; - pDoc->clear(); - JsonObject state = pDoc->createNestedObject("state"); - serializeState(state); - JsonObject info = pDoc->createNestedObject("info"); - serializeInfo(info); - return sendApiDocLocked(mac, msgType, msgId); -} - -static void sendEspNowApiResponse(const uint8_t* mac, uint8_t msgId, bool verbose) { - if (verbose) { - if (!sendEspNowApiState(mac, ESPNOW_API_RESPONSE, msgId)) { - sendEspNowApiJson(mac, ESPNOW_API_RESPONSE, msgId, "{\"error\":8}", 11); // response exceeded ESPNOW_API_MAX_JSON - } - } else { - static const char ok[] = "{\"success\":true}"; - sendEspNowApiJson(mac, ESPNOW_API_RESPONSE, msgId, ok, strlen(ok)); - } -} - -static void sendEspNowHello(const uint8_t* mac) { - if (statusESPNow != ESP_NOW_STATE_ON) return; - if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) return; - pDoc->clear(); - JsonObject hello = pDoc->createNestedObject("hello"); - hello[F("name")] = serverDescription; - uint8_t myMac[6]; - WiFi.macAddress(myMac); - char macStr[13]; - sprintf_P(macStr, PSTR("%02x%02x%02x%02x%02x%02x"), myMac[0], myMac[1], myMac[2], myMac[3], myMac[4], myMac[5]); - hello[F("mac")] = macStr; - hello[F("ver")] = VERSION; - hello[F("ch")] = WiFi.channel(); - sendApiDocLocked(mac, ESPNOW_API_HELLO, 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. Large lists may exceed ESPNOW_API_MAX_JSON on an ESP8266 host (-> error 8). -static void sendEspNowApiCatalog(const uint8_t* mac, uint8_t msgId, const char* what) { - if (statusESPNow != ESP_NOW_STATE_ON || !what) return; - - if (!strcmp_P(what, PSTR("ps"))) { - // getPresetName() takes the JSON lock and uses pDoc, so collect names before locking. - std::vector> presets; - uint8_t misses = 0; - for (uint16_t i = 1; i <= 250 && misses < 16; i++) { - String name; - if (getPresetName(i, name)) { presets.push_back({(uint8_t)i, name}); misses = 0; } - else misses++; - } - if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) return; - pDoc->clear(); - JsonObject ps = pDoc->createNestedObject("presets"); - for (auto& p : presets) ps[String(p.first)] = p.second; - sendApiDocLocked(mac, ESPNOW_API_RESPONSE, msgId); - return; - } - - if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) return; - pDoc->clear(); - if (!strcmp_P(what, PSTR("fx"))) { - JsonArray effects = pDoc->createNestedArray("effects"); - serializeModeNames(effects); - } else if (!strcmp_P(what, PSTR("pal"))) { - (*pDoc)[F("palettes")] = serialized((const __FlashStringHelper*)JSON_palette_names); - } else { - releaseJSONBufferLock(); - sendEspNowApiJson(mac, ESPNOW_API_RESPONSE, msgId, "{\"error\":9}", 11); - return; - } - sendApiDocLocked(mac, ESPNOW_API_RESPONSE, msgId); -} - -// Binary live LED peek payload, identical in format to the WebSocket liveview. -static bool sendEspNowLiveLeds(const uint8_t* mac, uint8_t msgId) { - if (statusESPNow != ESP_NOW_STATE_ON || !mac) return false; - - size_t used = strip.getLengthTotal(); - if (!used) return false; -#ifdef ESP8266 - const size_t MAX_LIVE_LEDS_ESPNOW = 256U; -#else - const size_t MAX_LIVE_LEDS_ESPNOW = 1024U; -#endif - size_t n = ((used - 1) / MAX_LIVE_LEDS_ESPNOW) + 1; // serve every n'th LED when over the cap - size_t pos = 2; -#ifndef WLED_DISABLE_2D - if (strip.isMatrix) { - used = Segment::maxWidth * Segment::maxHeight; - n = 1; - if (used > MAX_LIVE_LEDS_ESPNOW) n = 2; - if (used > MAX_LIVE_LEDS_ESPNOW * 4) n = 4; - pos = 4; - } -#endif - size_t bufSize = pos + (used / n) * 3; - if (bufSize > ESPNOW_API_MAX_JSON) return false; - - uint8_t* buffer = reinterpret_cast(d_malloc(bufSize)); - if (!buffer) return false; - buffer[0] = 'L'; - buffer[1] = 1; - -#ifndef WLED_DISABLE_2D - if (strip.isMatrix) { - buffer[1] = 2; - 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); - uint8_t r = R(c), g = G(c), b = B(c), w = W(c); - buffer[pos++] = bri ? qadd8(w, r) : 0; // fold the white channel into RGB - buffer[pos++] = bri ? qadd8(w, g) : 0; - buffer[pos++] = bri ? qadd8(w, b) : 0; - } - - bool ok = sendEspNowApiPayload(mac, ESPNOW_API_LIVE, msgId, buffer, bufSize); - free(buffer); - return ok; -} - -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 = sendEspNowLiveLeds(apiLiveMac, apiLiveMsgId++); - apiLastLiveTime = millis(); - if (!success) apiLastLiveTime -= 20; // retry sooner if TX queue or heap was busy -} - -// Apply a completed inbound message in loop context. -void handleEspNowApi() { - if (!espNowApiReady()) { - apiResetAll(); - return; - } - handleEspNowLive(); - - uint8_t srcMac[6]; - uint8_t msgType = 0, msgId = 0; - uint8_t* json = nullptr; - size_t jsonLen = 0; - - { - EspNowApiStateGuard guard; - if (!guard) return; - apiReasmCleanupStale(); - if (!apiInbox.ready) return; - memcpy(srcMac, apiInbox.srcMac, 6); - msgType = apiInbox.msgType; - msgId = apiInbox.msgId; - json = apiInbox.json; // ownership moves to this invocation - jsonLen = apiInbox.len; - apiInbox.json = nullptr; - apiInbox.len = 0; - apiInbox.ready = false; - } - - unsigned long start = millis(); - while (strip.isUpdating() && millis()-start < ESPNOW_BUSWAIT_TIMEOUT) yield(); - - if (msgType == ESPNOW_API_HELLO) { - sendEspNowHello(srcMac); - } else if (msgType == ESPNOW_API_REQUEST) { - bool verbose = true; - if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) { - sendEspNowApiJson(srcMac, ESPNOW_API_RESPONSE, msgId, "{\"error\":3}", 11); - } else { - DeserializationError err = deserializeJson(*pDoc, json, jsonLen); - JsonObject root = pDoc->as(); - if (err || root.isNull()) { - releaseJSONBufferLock(); - sendEspNowApiJson(srcMac, ESPNOW_API_RESPONSE, msgId, "{\"error\":9}", 11); - } else { - // mirror wsEvent(): {"v":true} polls state, {"lv":...} toggles live peek - 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(); - } - verbose = false; - } else if (root.containsKey("get")) { - char what[8]; - strlcpy(what, root["get"] | "", sizeof(what)); - releaseJSONBufferLock(); - sendEspNowApiCatalog(srcMac, msgId, what); - free(json); - return; - } else { - verbose = deserializeState(root, CALL_MODE_BUTTON); - } - releaseJSONBufferLock(); - sendEspNowApiResponse(srcMac, msgId, verbose); - } - } - } - - free(json); -} - -// 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()) return; - if (!apiRemoteSeen || millis() - apiRemoteSeen > ESPNOW_API_PRESENCE_TIMEOUT) return; - static uint8_t pushId = 0; - sendEspNowApiState(ESPNOW_BROADCAST_ADDRESS, ESPNOW_API_PUSH, pushId++); -} // process ESPNow button data (acesses FS, should not be called while update to avoid glitches) void handleRemote() { if(ESPNowButton >= 0) { diff --git a/wled00/udp.cpp b/wled00/udp.cpp index 463033b605..b381808574 100644 --- a/wled00/udp.cpp +++ b/wled00/udp.cpp @@ -942,7 +942,7 @@ void espNowReceiveCB(uint8_t* address, uint8_t* data, uint8_t len, signed int rs // applied later in handleEspNowApi(). Already gated by the linked_remotes whitelist above. if (len >= ESPNOW_API_HEADER_SIZE && data[0] == ESPNOW_API_MAGIC && data[1] == ESPNOW_API_VERSION) { if (!espNowApiReady()) return; - handleEspNowApiData(address, data, len, broadcast); + handleEspNowApiData(address, data, len); return; } diff --git a/wled00/wled.cpp b/wled00/wled.cpp index c52f73644b..7d1ba365aa 100644 --- a/wled00/wled.cpp +++ b/wled00/wled.cpp @@ -821,10 +821,12 @@ void WLED::initConnection() #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 + // async sends (3rd arg): QuickESPNow's synchronous mode spins until a TX callback that + // never fires when esp_now_send() errors out immediately, deadlocking the main loop + espNowOK = quickEspNow.begin(apChannel, WIFI_IF_AP, false); // Same channel must be used for both AP and ESP-NOW } else { DEBUG_PRINTLN(F("ESP-NOW initing in STA mode.")); - espNowOK = quickEspNow.begin(); // Use no parameters to start ESP-NOW on same channel as WiFi, in STA mode + espNowOK = quickEspNow.begin(255, 0, false); // channel 255 = use the current WiFi channel, in STA mode } statusESPNow = espNowOK ? ESP_NOW_STATE_ON : ESP_NOW_STATE_ERROR; } @@ -951,7 +953,13 @@ void WLED::handleConnection() sendImprovStateResponse(0x03, true); improvActive = 2; } - if (now - lastReconnectAttempt > ((stac) ? 300000 : 18000) && wifiConfigured) { + unsigned long retryInterval = stac ? 300000 : 18000; + #ifndef WLED_DISABLE_ESPNOW + // an active bidirectional ESP-NOW remote defers aggressive STA retries the same way an AP + // client does: every retry tears down ESP-NOW (and the AP on ESP32), cutting the remote off + if (espNowApiRemoteActive()) retryInterval = 300000; + #endif + if (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 diff --git a/wled00/ws.cpp b/wled00/ws.cpp index 6e9038c101..7772bec906 100644 --- a/wled00/ws.cpp +++ b/wled00/ws.cpp @@ -188,55 +188,19 @@ 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; + size_t bufSize = buildLiveLedsPayload(nullptr, 0, MAX_LIVE_LEDS_WS); // payload builder shared with the ESP-NOW API + 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; From 0001001bef763b42c91b37549e236c2abd3b7c28 Mon Sep 17 00:00:00 2001 From: figamore <90107339+figamore@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:43:04 -0400 Subject: [PATCH 03/17] good progress - mostly stable --- wled00/const.h | 2 + wled00/espnow_api.cpp | 239 +++++++++++++++++++++++------------ wled00/espnow_transport.cpp | 244 ++++++++++++++++++++++++++++++++++++ wled00/fcn_declare.h | 10 +- wled00/udp.cpp | 33 +++-- wled00/wled.cpp | 100 ++++++++++----- wled00/wled.h | 2 - 7 files changed, 509 insertions(+), 121 deletions(-) create mode 100644 wled00/espnow_transport.cpp diff --git a/wled00/const.h b/wled00/const.h index f041f1d8cf..2b1b2ede36 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -382,6 +382,8 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); #define ESP_NOW_STATE_ON 1 #define ESP_NOW_STATE_ERROR 2 +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 diff --git a/wled00/espnow_api.cpp b/wled00/espnow_api.cpp index 43b106e55c..b610f71124 100644 --- a/wled00/espnow_api.cpp +++ b/wled00/espnow_api.cpp @@ -1,14 +1,13 @@ #include "wled.h" #ifndef WLED_DISABLE_ESPNOW -#include // 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_API_STRIPWAIT_TIMEOUT 24 // one frame timeout to wait for the strip to finish updating -#define ESPNOW_LIVE_INTERVAL 40 // live peek cadence (ms), matching the WS liveview -#define ESPNOW_LIVE_TIMEOUT 3000 // stop live peek if {"lv":true} is not re-armed within this window +#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_TX_PER_LOOP 3 // max fragments transmitted per loop() pass (bounds loop stall) @@ -25,14 +24,18 @@ typedef uint64_t espnow_frag_mask_t; #endif struct EspNowApiInbox { - volatile bool ready; uint8_t srcMac[6]; uint8_t msgType; uint8_t msgId; uint8_t* json; // NUL-terminated heap buffer; ownership passes to the loop size_t len; }; -static EspNowApiInbox apiInbox = {false, {0}, 0, 0, nullptr, 0}; +// 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; static uint8_t* apiReasmBuf = nullptr; static uint8_t apiReasmSrc[6]= {0}; @@ -43,13 +46,15 @@ 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 time an API frame arrived; gates state pushes +static unsigned long apiRemoteSeen = 0; // last API frame; gates state pushes/reconnect deferral 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) +static bool apiPushPending = false; // coalesced state push waiting for the reliable TX slot // single pending outbound message, drained incrementally by serviceEspNowApiTx() struct EspNowApiTx { @@ -63,17 +68,6 @@ struct EspNowApiTx { }; static EspNowApiTx apiTx = {{0}, 0, 0, nullptr, 0, 0, 0}; -// The receive callback runs on a separate task; this try-lock guards the shared reassembly -// state. It never blocks: contention just drops a fragment, which the remote re-sends. -static std::atomic_flag apiStateLock = ATOMIC_FLAG_INIT; - -struct EspNowApiStateGuard { - bool locked; - EspNowApiStateGuard() : locked(!apiStateLock.test_and_set(std::memory_order_acquire)) {} - ~EspNowApiStateGuard() { if (locked) apiStateLock.clear(std::memory_order_release); } - operator bool() const { return locked; } -}; - static void apiReasmReset() { if (apiReasmBuf) { free(apiReasmBuf); apiReasmBuf = nullptr; } apiReasmTotal = apiReasmCount = 0; @@ -81,19 +75,46 @@ static void apiReasmReset() { apiReasmLen = 0; } +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_HELLO: return "HELLO"; + case ESPNOW_API_LIVE: return "LIVE"; + default: return "UNKNOWN"; + } +} + static void apiInboxReset() { - if (apiInbox.json) { free(apiInbox.json); apiInbox.json = nullptr; } - apiInbox.ready = false; - apiInbox.len = 0; + for (auto &inbox : apiInbox) { + if (inbox.json) { free(inbox.json); inbox.json = nullptr; } + inbox.len = 0; + } + apiInboxRead = apiInboxWrite = apiInboxCount = 0; } static void apiLiveReset() { apiLiveActive = false; apiLiveMsgId = 0; + apiLiveSendFailures = 0; apiLastLiveTime = 0; apiLiveExpiry = 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(); + } +} + static void apiTxReset() { if (apiTx.payload) { free(apiTx.payload); apiTx.payload = nullptr; } } @@ -107,10 +128,11 @@ bool espNowApiReady() { } bool espNowApiRemoteActive() { - return apiRemoteSeen && millis() - apiRemoteSeen < ESPNOW_API_PRESENCE_TIMEOUT; + const unsigned long seen = apiRemoteSeen; + return seen && millis() - seen < ESPNOW_API_PRESENCE_TIMEOUT; } -// Reassemble an inbound frame in receive-task context: validated, bounded copies only. +// 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]; @@ -126,16 +148,23 @@ void handleEspNowApiData(uint8_t* address, uint8_t* data, uint8_t len) { 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 - EspNowApiStateGuard guard; - if (!guard) return; - unsigned long now = millis(); apiRemoteSeen = now; bool newMsg = (apiReasmBuf == nullptr) || (now - apiReasmLast > ESPNOW_API_REASM_TIMEOUT) || - (memcmp(apiReasmSrc, address, 6) != 0) || (apiReasmId != msgId) || (apiReasmTotal != fragTotal); + (memcmp(apiReasmSrc, address, 6) != 0) || (apiReasmId != msgId) || + (apiReasmType != msgType) || (apiReasmTotal != fragTotal); if (newMsg) { + if (apiReasmBuf) { + DEBUG_PRINTF_P(PSTR("ESP-NOW API RX replacing incomplete %s id=%u fragments=%u/%u age=%lums\n"), + apiTypeName(apiReasmType), apiReasmId, + apiReasmCount, apiReasmTotal, now - apiReasmLast); + } apiReasmReset(); - if (fragIndex != 0) return; + 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); @@ -153,31 +182,29 @@ void handleEspNowApiData(uint8_t* address, uint8_t* data, uint8_t len) { if (fragIndex == fragTotal - 1) apiReasmLen = (size_t)fragIndex * ESPNOW_API_FRAG_SIZE + payloadLen; if (apiReasmCount < fragTotal) return; - if (apiInbox.ready) { apiReasmReset(); return; } // loop hasn't drained the previous message; drop this one + 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; } apiReasmBuf[apiReasmLen] = '\0'; - apiInbox.json = apiReasmBuf; - apiInbox.len = apiReasmLen; - apiInbox.msgType = apiReasmType; - apiInbox.msgId = apiReasmId; - memcpy(apiInbox.srcMac, apiReasmSrc, 6); - apiInbox.ready = true; + EspNowApiInbox &inbox = apiInbox[apiInboxWrite]; + inbox.json = apiReasmBuf; + inbox.len = apiReasmLen; + inbox.msgType = apiReasmType; + 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(); } -// ESP8266 QuickESPNow does not auto-register unicast peers (ESP32 does). -static void espNowEnsurePeer(const uint8_t* mac) { -#ifdef ESP8266 - if (memcmp(mac, ESPNOW_BROADCAST_ADDRESS, 6) == 0) return; - if (!esp_now_is_peer_exist((uint8_t*)mac)) { - esp_now_add_peer((uint8_t*)mac, ESP_NOW_ROLE_COMBO, 0, nullptr, 0); // channel 0 = current - } -#else - (void)mac; -#endif -} - static bool apiTxIdle() { return apiTx.payload == nullptr; } // Send a few pending fragments, then return; called every loop pass and after each enqueue. @@ -191,13 +218,13 @@ static void serviceEspNowApiTx() { frame[3] = apiTx.msgId; frame[5] = apiTx.fragTotal; for (unsigned i = 0; i < ESPNOW_API_TX_PER_LOOP && !apiTxIdle(); i++) { - if (!quickEspNow.readyToSendData()) return; // TX ring full; resume next loop pass + if (!espNowTransportReadyToSend()) return; // TX ring full; resume next loop pass 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); - if (quickEspNow.send(apiTx.mac, frame, ESPNOW_API_HEADER_SIZE + chunk)) { apiTxReset(); return; } // link error; drop, remote retries + if (espNowTransportSend(apiTx.mac, frame, ESPNOW_API_HEADER_SIZE + chunk)) { apiTxReset(); return; } // link error; drop, remote retries if (++apiTx.fragNext >= apiTx.fragTotal) apiTxReset(); } } @@ -219,7 +246,11 @@ static bool apiTxEnqueue(const uint8_t* mac, uint8_t msgType, uint8_t msgId, uin apiTx.len = len; apiTx.fragTotal = (uint8_t)total; apiTx.fragNext = 0; - espNowEnsurePeer(mac); + 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; } @@ -268,6 +299,33 @@ static uint8_t queueEspNowApiState(const uint8_t* mac, uint8_t msgType, uint8_t 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; + if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) return ESPNOW_API_ERR_BUSY; + pDoc->clear(); + JsonObject state = pDoc->createNestedObject("state"); + state["on"] = bri > 0; + state["bri"] = briLast; + state["ps"] = currentPreset > 0 ? currentPreset : -1; + state["mainseg"] = 0; + + const Segment &mainseg = strip.getMainSegment(); + JsonObject seg = state.createNestedArray("seg").createNestedObject(); + seg["fx"] = mainseg.mode; + seg["pal"] = mainseg.palette; + seg["sx"] = mainseg.speed; + seg["ix"] = mainseg.intensity; + seg["c1"] = mainseg.custom1; + seg["c2"] = mainseg.custom2; + seg["c3"] = mainseg.custom3; + JsonArray color = seg.createNestedArray("col").createNestedArray(); + color.add(R(mainseg.colors[0])); + color.add(G(mainseg.colors[0])); + color.add(B(mainseg.colors[0])); + return queueApiDocLocked(mac, msgType, msgId); +} + 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); @@ -335,11 +393,7 @@ static void sendEspNowApiCatalog(const uint8_t* mac, uint8_t msgId, const char* 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 -#ifdef ESP8266 const size_t MAX_LIVE_LEDS_ESPNOW = 256U; -#else - const size_t MAX_LIVE_LEDS_ESPNOW = 1024U; -#endif 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); @@ -361,16 +415,26 @@ static void handleEspNowLive() { if (!success) apiLastLiveTime -= 20; // retry sooner if TX slot or heap was busy } -static bool apiResetAll() { - { - EspNowApiStateGuard guard; - if (!guard) return false; - apiReasmReset(); - apiInboxReset(); - } +static void apiResetAll() { + apiReasmReset(); + apiInboxReset(); apiLiveReset(); apiTxReset(); apiRemoteSeen = 0; + apiPushPending = false; +} + +// Retry a coalesced state push after responses have drained; live preview yields to state. +static bool handlePendingEspNowPush() { + if (!apiPushPending || !apiTxIdle()) 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; } @@ -378,40 +442,48 @@ static bool apiResetAll() { void handleEspNowApi() { static bool needCleanup = false; if (!espNowApiReady()) { - if (needCleanup) needCleanup = !apiResetAll(); // once, on the on->off transition + if (needCleanup) { apiResetAll(); needCleanup = false; } return; } needCleanup = true; serviceEspNowApiTx(); - handleEspNowLive(); + if (!apiTxIdle()) return; // finish the previous response before consuming another request uint8_t srcMac[6]; uint8_t msgType = 0, msgId = 0; uint8_t* json = nullptr; size_t jsonLen = 0; - { - EspNowApiStateGuard guard; - if (!guard) return; - apiReasmCleanupStale(); - if (!apiInbox.ready) return; - memcpy(srcMac, apiInbox.srcMac, 6); - msgType = apiInbox.msgType; - msgId = apiInbox.msgId; - json = apiInbox.json; // ownership moves to this invocation - jsonLen = apiInbox.len; - apiInbox.json = nullptr; - apiInbox.len = 0; - apiInbox.ready = false; + apiReasmCleanupStale(); + if (apiInboxCount) { + EspNowApiInbox &inbox = apiInbox[apiInboxRead]; + memcpy(srcMac, inbox.srcMac, 6); + msgType = inbox.msgType; + 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 (!handlePendingEspNowPush()) handleEspNowLive(); + return; } unsigned long start = millis(); while (strip.isUpdating() && millis()-start < ESPNOW_API_STRIPWAIT_TIMEOUT) yield(); if (msgType == ESPNOW_API_HELLO) { + DEBUG_PRINTF_P(PSTR("ESP-NOW API handling HELLO id=%u from " MACSTR " ch=%u\n"), + msgId, MAC2STR(srcMac), WiFi.channel()); sendEspNowHello(); } else if (msgType == ESPNOW_API_REQUEST) { + DEBUG_PRINTF_P(PSTR("ESP-NOW API handling REQUEST id=%u bytes=%u from " MACSTR "\n"), + msgId, unsigned(jsonLen), MAC2STR(srcMac)); if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) { sendEspNowApiError(srcMac, msgId, ESPNOW_API_ERR_BUSY); } else { @@ -423,7 +495,11 @@ void handleEspNowApi() { } else { // mirror wsEvent(): {"v":true} polls state, {"lv":...} toggles live peek bool verbose = false; - if (root["v"] && root.size() == 1) { + bool compact = false; + const char* responseMode = root["v"].is() ? root["v"].as() : nullptr; + 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) { @@ -448,7 +524,12 @@ void handleEspNowApi() { // If the request changed state, a PUSH will follow soon. Acknowledge here // instead of serializing the same state twice. if (verbose && interfaceUpdateCallMode) verbose = false; - sendEspNowApiResponse(srcMac, msgId, verbose); + if (compact) { + uint8_t compactErr = queueEspNowApiCompactState(srcMac, ESPNOW_API_RESPONSE, msgId); + if (compactErr) sendEspNowApiError(srcMac, msgId, compactErr); + } else { + sendEspNowApiResponse(srcMac, msgId, verbose); + } } } } @@ -460,8 +541,6 @@ void handleEspNowApi() { // Gated on a recent API frame so WizMote-only setups never see these frames. void pushEspNowState() { if (!espNowApiReady() || linked_remotes.empty() || !espNowApiRemoteActive()) return; - if (!apiTxIdle()) return; // best-effort: a state push never waits behind an in-flight message - static uint8_t pushId = 0; - if (queueEspNowApiState(ESPNOW_BROADCAST_ADDRESS, ESPNOW_API_PUSH, pushId) == 0) pushId++; + apiPushPending = true; // coalesce rapid changes; handleEspNowApi() sends the latest state } #endif // WLED_DISABLE_ESPNOW diff --git a/wled00/espnow_transport.cpp b/wled00/espnow_transport.cpp new file mode 100644 index 0000000000..2f42127acd --- /dev/null +++ b/wled00/espnow_transport.cpp @@ -0,0 +1,244 @@ +#include "wled.h" +#ifndef WLED_DISABLE_ESPNOW +#include + +// ESP-NOW callbacks run outside WLED's loop context. Keep callback work small: copy received +// frames into fixed queues 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 { + +#ifdef ESP8266 +static constexpr uint8_t ESPNOW_TRANSPORT_QUEUE_SIZE = 4; +static constexpr uint8_t ESPNOW_TRANSPORT_RX_PER_LOOP = 2; +#else +static constexpr uint8_t ESPNOW_TRANSPORT_QUEUE_SIZE = 8; +static constexpr uint8_t ESPNOW_TRANSPORT_RX_PER_LOOP = 4; +#endif +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[ESPNOW_TRANSPORT_QUEUE_SIZE]; +static EspNowTransportFrame txQueue[ESPNOW_TRANSPORT_QUEUE_SIZE]; +static uint8_t rxRead = 0, rxWrite = 0, rxCount = 0; +static uint8_t txRead = 0, txWrite = 0, txCount = 0; +static std::atomic_flag rxLock = ATOMIC_FLAG_INIT; +static std::atomic txInFlight{false}; +static std::atomic_flag sentEventLock = ATOMIC_FLAG_INIT; +static bool sentEventPending = false; +static uint8_t sentAddress[6] = {}; +static uint8_t sentStatus = 0; +static std::atomic transportActive{false}; +static bool transportUsesAP = false; + +static void resetQueues() { + while (rxLock.test_and_set(std::memory_order_acquire)) yield(); + rxRead = rxWrite = rxCount = 0; + rxLock.clear(std::memory_order_release); + txRead = txWrite = txCount = 0; + txInFlight.store(false, std::memory_order_release); + sentEventPending = false; +} + +static void queueReceivedFrame(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) return; + if (rxLock.test_and_set(std::memory_order_acquire)) return; + if (rxCount < ESPNOW_TRANSPORT_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_QUEUE_SIZE; + rxCount++; + } + rxLock.clear(std::memory_order_release); +} + +#ifdef ESP8266 +static void onEspNowReceive(uint8_t* address, uint8_t* data, uint8_t len) { + queueReceivedFrame(address, data, len); +} + +static void onEspNowSent(uint8_t* address, uint8_t status) { + if (!transportActive.load(std::memory_order_acquire)) return; + if (!sentEventLock.test_and_set(std::memory_order_acquire)) { + memcpy(sentAddress, address, sizeof(sentAddress)); + sentStatus = status; + sentEventPending = true; + sentEventLock.clear(std::memory_order_release); + } + txInFlight.store(false, std::memory_order_release); +} +#else +static void onEspNowReceive(const uint8_t* address, const uint8_t* data, int len) { + if (len > 0) queueReceivedFrame(address, data, size_t(len)); +} + +static void onEspNowSent(const uint8_t* address, esp_now_send_status_t status) { + if (!transportActive.load(std::memory_order_acquire)) return; + if (!sentEventLock.test_and_set(std::memory_order_acquire)) { + memcpy(sentAddress, address, sizeof(sentAddress)); + sentStatus = uint8_t(status); + sentEventPending = true; + sentEventLock.clear(std::memory_order_release); + } + txInFlight.store(false, std::memory_order_release); +} +#endif + +static bool ensurePeer(const uint8_t* address) { +#ifdef ESP8266 + if (esp_now_is_peer_exist((uint8_t*)address)) return true; + return esp_now_add_peer((uint8_t*)address, ESP_NOW_ROLE_COMBO, 0, nullptr, 0) == 0; +#else + esp_now_peer_info_t peer = {}; + if (esp_now_is_peer_exist(address)) { + if (esp_now_get_peer(address, &peer) != ESP_OK) return false; + const wifi_interface_t requiredInterface = transportUsesAP ? WIFI_IF_AP : WIFI_IF_STA; + if (peer.channel == 0 && peer.ifidx == requiredInterface) return true; + peer.channel = 0; // always follow the radio's current home channel + peer.ifidx = requiredInterface; + peer.encrypt = false; + return esp_now_mod_peer(&peer) == ESP_OK; + } + memcpy(peer.peer_addr, address, sizeof(peer.peer_addr)); + peer.channel = 0; + peer.ifidx = transportUsesAP ? WIFI_IF_AP : WIFI_IF_STA; + peer.encrypt = false; + return esp_now_add_peer(&peer) == ESP_OK; +#endif +} + +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_QUEUE_SIZE; + rxCount--; + rxLock.clear(std::memory_order_release); + return true; +} + +static void serviceTransmit() { + if (!transportActive.load(std::memory_order_acquire) || !txCount || + txInFlight.load(std::memory_order_acquire)) return; + EspNowTransportFrame &frame = txQueue[txRead]; + if (!ensurePeer(frame.address)) { + txRead = (txRead + 1) % ESPNOW_TRANSPORT_QUEUE_SIZE; + txCount--; + espNowSentCB(frame.address, 1); + return; + } + + txInFlight.store(true, std::memory_order_release); +#ifdef ESP8266 + const int result = esp_now_send(frame.address, frame.data, frame.len); +#else + const esp_err_t result = esp_now_send(frame.address, frame.data, frame.len); +#endif + txRead = (txRead + 1) % ESPNOW_TRANSPORT_QUEUE_SIZE; + txCount--; + if (result != 0) { + txInFlight.store(false, std::memory_order_release); + espNowSentCB(frame.address, 1); + } +} + +} // namespace + +bool espNowTransportBegin(uint8_t channel, bool useAP) { + espNowTransportStop(); + resetQueues(); + transportUsesAP = useAP; + +#ifdef ESP8266 + if (channel >= 1 && channel <= 13 && WiFi.channel() != channel) wifi_set_channel(channel); + if (esp_now_init() != 0) return false; + if (esp_now_set_self_role(ESP_NOW_ROLE_COMBO) != 0) { esp_now_deinit(); return false; } + if (esp_now_register_recv_cb(onEspNowReceive) != 0 || esp_now_register_send_cb(onEspNowSent) != 0) { + esp_now_deinit(); + return false; + } +#else + (void)channel; // WiFi owns the settled STA/AP home channel before this function is called + if (esp_now_init() != ESP_OK) return false; + if (esp_now_register_recv_cb(onEspNowReceive) != ESP_OK || + esp_now_register_send_cb(onEspNowSent) != ESP_OK) { + esp_now_deinit(); + return false; + } +#endif + 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_QUEUE_SIZE); + return true; +} + +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 tx=%u inFlight=%u\n"), + WiFi.channel(), transportUsesAP ? "AP" : "STA", rxCount, txCount, + txInFlight.load(std::memory_order_acquire)); +#ifdef ESP8266 + esp_now_unregister_recv_cb(); + esp_now_unregister_send_cb(); +#else + esp_now_unregister_recv_cb(); + esp_now_unregister_send_cb(); +#endif + esp_now_deinit(); + resetQueues(); +} + +bool espNowTransportReadyToSend() { + return transportActive.load(std::memory_order_acquire) && txCount < ESPNOW_TRANSPORT_QUEUE_SIZE; +} + +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 || + txCount >= ESPNOW_TRANSPORT_QUEUE_SIZE) return 1; + EspNowTransportFrame &frame = txQueue[txWrite]; + memcpy(frame.address, address, sizeof(frame.address)); + memcpy(frame.data, data, len); + frame.len = len; + txWrite = (txWrite + 1) % ESPNOW_TRANSPORT_QUEUE_SIZE; + txCount++; + serviceTransmit(); + return 0; +} + +void handleEspNowTransport() { + uint8_t completedAddress[6]; + uint8_t completedStatus = 0; + bool haveSentEvent = false; + if (!sentEventLock.test_and_set(std::memory_order_acquire)) { + if (sentEventPending) { + memcpy(completedAddress, sentAddress, sizeof(completedAddress)); + completedStatus = sentStatus; + sentEventPending = false; + haveSentEvent = true; + } + sentEventLock.clear(std::memory_order_release); + } + 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); + serviceTransmit(); +} +#endif // WLED_DISABLE_ESPNOW diff --git a/wled00/fcn_declare.h b/wled00/fcn_declare.h index 561235c77c..4d4f68495d 100644 --- a/wled00/fcn_declare.h +++ b/wled00/fcn_declare.h @@ -281,13 +281,21 @@ bool getPresetName(byte index, String& name); void handleWiZdata(uint8_t *incomingData, size_t len); void handleRemote(); -//espnow_api.cpp #ifndef WLED_DISABLE_ESPNOW +//espnow_transport.cpp +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); + +//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 //set.cpp diff --git a/wled00/udp.cpp b/wled00/udp.cpp index b381808574..25550deeb4 100644 --- a/wled00/udp.cpp +++ b/wled00/udp.cpp @@ -165,27 +165,25 @@ void notify(byte callMode, bool followUp) s0++; } if (s > s0) buffer.noOfPackets += 1 + ((s - s0) * UDP_SEG_SIZE) / bufferSize; // set number of packets - auto err = quickEspNow.send(ESPNOW_BROADCAST_ADDRESS, reinterpret_cast(&buffer), packetSize+3); + auto err = espNowTransportSend(ESPNOW_BROADCAST_ADDRESS, reinterpret_cast(&buffer), packetSize+3); if (!err && s0 < s) { // send rest of the segments buffer.packet++; packetSize = 0; - // WARNING: this will only work for up to 3 messages (~17 segments) as QuickESPNOW only has a ring buffer capable of holding 3 queued messages - // to work around that limitation it is mandatory to utilize onDataSent() callback which should reduce number queued messages - // and wait until at least one space is available in the buffer + // The native transport has a bounded queue; stop adding fragments if it reports full. for (size_t i = s0; i < s; i++) { memcpy(buffer.data + packetSize, &udpOut[41+i*UDP_SEG_SIZE], UDP_SEG_SIZE); packetSize += UDP_SEG_SIZE; if (packetSize + UDP_SEG_SIZE < bufferSize) continue; DEBUG_PRINTF_P(PSTR("ESP-NOW sending packet: %d (%u)\n"), (int)buffer.packet, packetSize+3); - err = quickEspNow.send(ESPNOW_BROADCAST_ADDRESS, reinterpret_cast(&buffer), packetSize+3); + err = espNowTransportSend(ESPNOW_BROADCAST_ADDRESS, reinterpret_cast(&buffer), packetSize+3); buffer.packet++; packetSize = 0; if (err) break; } if (!err && packetSize > 0) { DEBUG_PRINTF_P(PSTR("ESP-NOW sending last packet: %d (%d)\n"), (int)buffer.packet, packetSize+3); - err = quickEspNow.send(ESPNOW_BROADCAST_ADDRESS, reinterpret_cast(&buffer), packetSize+3); + err = espNowTransportSend(ESPNOW_BROADCAST_ADDRESS, reinterpret_cast(&buffer), packetSize+3); } } if (err) { @@ -903,11 +901,14 @@ 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); + espNowApiOnSendResult(address, status); + 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())); } // ESP-NOW message receive callback function void espNowReceiveCB(uint8_t* address, uint8_t* data, uint8_t len, signed int rssi, bool broadcast) { + if (!address || !data || len == 0) return; sprintf_P(last_signal_src, PSTR("%02x%02x%02x%02x%02x%02x"), address[0], address[1], address[2], address[3], address[4], address[5]); #ifdef WLED_DEBUG @@ -957,6 +958,18 @@ void espNowReceiveCB(uint8_t* address, uint8_t* data, uint8_t len, signed int rs static uint8_t segsReceived = 0; static unsigned long lastProcessed = 0; + const size_t payloadLen = len - 3; + if (buffer->noOfPackets == 0 || + (buffer->packet == 0 && payloadLen < SEG_OFFSET) || + (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) { packetsReceived = 0; // it will increment later (this is to make sure we start counting packets correctly) if (udpIn == nullptr) { @@ -964,9 +977,9 @@ void espNowReceiveCB(uint8_t* address, uint8_t* data, uint8_t len, signed int rs 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 7d1ba365aa..ebe0c9ceeb 100644 --- a/wled00/wled.cpp +++ b/wled00/wled.cpp @@ -14,6 +14,38 @@ extern "C" void usePWMFixedNMI(); +#ifndef WLED_DISABLE_ESPNOW +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; + if (Network.isConnected()) { + 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 */ @@ -92,6 +124,7 @@ void WLED::loop() handleIR(); #endif #ifndef WLED_DISABLE_ESPNOW + handleEspNowTransport(); handleRemote(); handleEspNowApi(); #endif @@ -686,6 +719,13 @@ void WLED::initAP(bool resetAP) dnsServer.start(53, "*", WiFi.softAPIP()); } apActive = true; + + #ifndef WLED_DISABLE_ESPNOW + // 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() @@ -698,7 +738,7 @@ void WLED::initConnection() #ifndef WLED_DISABLE_ESPNOW if (statusESPNow == ESP_NOW_STATE_ON) { DEBUG_PRINTLN(F("ESP-NOW stopping.")); - quickEspNow.stop(); + espNowTransportStop(); statusESPNow = ESP_NOW_STATE_UNINIT; } #endif @@ -811,32 +851,22 @@ void WLED::initConnection() #endif } -#ifndef WLED_DISABLE_ESPNOW - 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 - // async sends (3rd arg): QuickESPNow's synchronous mode spins until a TX callback that - // never fires when esp_now_send() errors out immediately, deadlocking the main loop - espNowOK = quickEspNow.begin(apChannel, WIFI_IF_AP, false); // Same channel must be used for both AP and ESP-NOW - } else { - DEBUG_PRINTLN(F("ESP-NOW initing in STA mode.")); - espNowOK = quickEspNow.begin(255, 0, false); // channel 255 = use the current WiFi channel, in STA mode - } - statusESPNow = espNowOK ? ESP_NOW_STATE_ON : ESP_NOW_STATE_ERROR; - } -#endif + #ifndef 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")); + #ifndef WLED_DISABLE_ESPNOW + if (enableESPNow && (statusESPNow != ESP_NOW_STATE_ON || espNowUsingAP)) startEspNowForCurrentNetwork(); + #endif + #ifndef WLED_DISABLE_HUESYNC IPAddress ipAddress = Network.localIP(); if (hueIP[0] == 0) { @@ -925,16 +955,29 @@ void WLED::handleConnection() stacO = stac; DEBUG_PRINTF_P(PSTR("Connected AP clients: %d\n"), (int)stac); if (!Network.isConnected() && wifiConfigured) { // trying to connect, but not connected - if (stac) + if (stac) { WiFi.disconnect(); // disable search so that AP can work - else + } else { + #ifndef WLED_DISABLE_ESPNOW + if (!espNowApiRemoteActive()) initConnection(); // restart search + #else initConnection(); // restart search + #endif + } } } } if (!Network.isConnected()) { if (interfacesInited) { + #ifndef WLED_DISABLE_ESPNOW + 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 @@ -955,11 +998,11 @@ void WLED::handleConnection() } unsigned long retryInterval = stac ? 300000 : 18000; #ifndef WLED_DISABLE_ESPNOW - // an active bidirectional ESP-NOW remote defers aggressive STA retries the same way an AP - // client does: every retry tears down ESP-NOW (and the AP on ESP32), cutting the remote off - if (espNowApiRemoteActive()) retryInterval = 300000; + const bool deferReconnectForEspNow = espNowApiRemoteActive(); + #else + const bool deferReconnectForEspNow = false; #endif - if (now - lastReconnectAttempt > retryInterval && wifiConfigured) { + 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 @@ -971,7 +1014,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 23c1995edf..6a91020f8a 100644 --- a/wled00/wled.h +++ b/wled00/wled.h @@ -91,7 +91,6 @@ #include #define WIFI_MODE_STA WIFI_STA #define WIFI_MODE_AP WIFI_AP - #include #endif #else // ESP32 #include // ensure we have the correct "Serial" on new MCUs (depends on ARDUINO_USB_MODE and ARDUINO_USB_CDC_ON_BOOT) @@ -112,7 +111,6 @@ #ifndef WLED_DISABLE_ESPNOW #include - #include #endif #endif #include From 380bd240c3597cc6ce48a3b4bf8c448c93845966 Mon Sep 17 00:00:00 2001 From: figamore <90107339+figamore@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:46:55 -0400 Subject: [PATCH 04/17] Begin work on multi-peer --- wled00/espnow_api.cpp | 36 +++++++++++++++++++++++++++++++----- wled00/udp.cpp | 29 +++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/wled00/espnow_api.cpp b/wled00/espnow_api.cpp index b610f71124..dd249404be 100644 --- a/wled00/espnow_api.cpp +++ b/wled00/espnow_api.cpp @@ -55,8 +55,11 @@ 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) 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 +static bool apiHelloPending = false; +static unsigned long apiHelloDue = 0; // discovery replies are staggered to avoid RF collisions -// single pending outbound message, drained incrementally by serviceEspNowApiTx() +// Single pending outbound message, drained incrementally by serviceEspNowApiTx(). struct EspNowApiTx { uint8_t mac[6]; uint8_t msgType; @@ -102,6 +105,14 @@ static void apiLiveReset() { 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); +} + // 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; @@ -141,7 +152,7 @@ void handleEspNowApiData(uint8_t* address, uint8_t* data, uint8_t len) { const uint8_t fragTotal = data[5]; const uint8_t payloadLen = len - ESPNOW_API_HEADER_SIZE; - // reject untrusted header values before any indexing or allocation + // Check untrusted header values before indexing or allocating. if (msgType != ESPNOW_API_REQUEST && msgType != ESPNOW_API_HELLO) return; // inbound direction only if (fragTotal < 1 || fragTotal > ESPNOW_API_MAX_FRAGS) return; if (fragIndex >= fragTotal) return; @@ -422,11 +433,15 @@ static void apiResetAll() { apiTxReset(); apiRemoteSeen = 0; apiPushPending = false; + apiPushDue = 0; + apiHelloPending = false; + apiHelloDue = 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; @@ -438,6 +453,15 @@ static bool handlePendingEspNowPush() { return true; } +// Sends a delayed discovery response after the reliable response slot becomes available. +static bool handlePendingEspNowHello() { + if (!apiHelloPending || !apiTxIdle()) return false; + if ((long)(millis() - apiHelloDue) < 0) return false; + apiHelloPending = false; + sendEspNowHello(); + return true; +} + // Apply a completed inbound message in loop context. void handleEspNowApi() { static bool needCleanup = false; @@ -470,7 +494,7 @@ void handleEspNowApi() { } if (!json) { - if (!handlePendingEspNowPush()) handleEspNowLive(); + if (!handlePendingEspNowHello() && !handlePendingEspNowPush()) handleEspNowLive(); return; } @@ -480,7 +504,8 @@ void handleEspNowApi() { if (msgType == ESPNOW_API_HELLO) { DEBUG_PRINTF_P(PSTR("ESP-NOW API handling HELLO id=%u from " MACSTR " ch=%u\n"), msgId, MAC2STR(srcMac), WiFi.channel()); - sendEspNowHello(); + apiHelloPending = true; + apiHelloDue = millis() + apiInstanceJitter(90, 10); } else if (msgType == ESPNOW_API_REQUEST) { DEBUG_PRINTF_P(PSTR("ESP-NOW API handling REQUEST id=%u bytes=%u from " MACSTR "\n"), msgId, unsigned(jsonLen), MAC2STR(srcMac)); @@ -493,7 +518,7 @@ void handleEspNowApi() { releaseJSONBufferLock(); sendEspNowApiError(srcMac, msgId, ESPNOW_API_ERR_JSON); } else { - // mirror wsEvent(): {"v":true} polls state, {"lv":...} toggles live peek + // 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; @@ -542,5 +567,6 @@ void handleEspNowApi() { 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 diff --git a/wled00/udp.cpp b/wled00/udp.cpp index 25550deeb4..9f61dbf6eb 100644 --- a/wled00/udp.cpp +++ b/wled00/udp.cpp @@ -906,13 +906,34 @@ void espNowSentCB(uint8_t* address, uint8_t status) { MAC2STR(address), status, WiFi.channel(), unsigned(WiFi.status())); } +// Return true only for frame types that establish the sender as a control remote. In +// particular, WLED HELLO replies carry a payload and must not replace the bonding candidate. +static bool espNowIsBondCandidate(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 + 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_HELLO || fragIndex != 0 || fragTotal != 1) return false; + return payloadLen == 0 || (payloadLen == 2 && data[ESPNOW_API_HEADER_SIZE] == '{' && data[ESPNOW_API_HEADER_SIZE + 1] == '}'); +} + // ESP-NOW message receive callback function void espNowReceiveCB(uint8_t* address, uint8_t* data, uint8_t len, signed int rssi, bool broadcast) { if (!address || !data || len == 0) return; - sprintf_P(last_signal_src, PSTR("%02x%02x%02x%02x%02x%02x"), address[0], address[1], address[2], address[3], address[4], address[5]); + 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 (espNowIsBondCandidate(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 Date: Mon, 13 Jul 2026 12:40:46 -0400 Subject: [PATCH 05/17] Harden multi-peer ESP-NOW connectivity and discovery --- wled00/const.h | 4 +- wled00/espnow_api.cpp | 150 ++++++++++++++++++++++++++++++++---------- wled00/udp.cpp | 4 +- 3 files changed, 121 insertions(+), 37 deletions(-) diff --git a/wled00/const.h b/wled00/const.h index 2b1b2ede36..8b72eb8ca5 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -393,8 +393,10 @@ static constexpr uint8_t ESPNOW_BROADCAST_ADDRESS[6] = {0xFF, 0xFF, 0xFF, 0xFF, #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_HELLO 0x04 // discovery: remote queries, WLED replies with name/mac/ver/ch +#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 (bounded to limit RAM use, especially on ESP8266) #ifdef ESP8266 #define ESPNOW_API_MAX_JSON 2048 diff --git a/wled00/espnow_api.cpp b/wled00/espnow_api.cpp index dd249404be..140275c02a 100644 --- a/wled00/espnow_api.cpp +++ b/wled00/espnow_api.cpp @@ -9,8 +9,14 @@ #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_TX_PER_LOOP 3 // max fragments transmitted per loop() pass (bounds loop stall) +#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 @@ -56,8 +62,25 @@ static unsigned long apiLastLiveTime = 0; static unsigned long apiLiveExpiry = 0; // live peek is a keepalive (no disconnect signal over ESP-NOW) 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 -static bool apiHelloPending = false; -static unsigned long apiHelloDue = 0; // discovery replies are staggered to avoid RF collisions +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 { @@ -83,8 +106,9 @@ static const char* apiTypeName(uint8_t type) { case ESPNOW_API_REQUEST: return "REQUEST"; case ESPNOW_API_RESPONSE: return "RESPONSE"; case ESPNOW_API_PUSH: return "PUSH"; - case ESPNOW_API_HELLO: return "HELLO"; + case ESPNOW_API_DISCOVER: return "DISCOVER"; case ESPNOW_API_LIVE: return "LIVE"; + case ESPNOW_API_ANNOUNCE: return "ANNOUNCE"; default: return "UNKNOWN"; } } @@ -113,6 +137,31 @@ static uint16_t apiInstanceJitter(uint16_t window, uint16_t minimum = 0) { return minimum + (window ? hash % window : 0); } +static uint32_t apiPayloadHash(const uint8_t* data, size_t len) { + uint32_t hash = 2166136261UL; + for (size_t i = 0; i < len; i++) hash = (hash ^ data[i]) * 16777619UL; + return hash; +} + +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; +} + +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; @@ -134,6 +183,8 @@ 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; } @@ -153,14 +204,24 @@ void handleEspNowApiData(uint8_t* address, uint8_t* data, uint8_t len) { 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_HELLO) return; // inbound direction only + 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; + 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) || (apiReasmType != msgType) || (apiReasmTotal != fragTotal); @@ -343,19 +404,21 @@ static void sendEspNowApiResponse(const uint8_t* mac, uint8_t msgId, bool verbos if (err) sendEspNowApiError(mac, msgId, err); } -// The reply is broadcast: a unicast reply needs a MAC-level ACK, which is unreliable while -// this radio time-shares with WiFi scanning/connecting; the remote identifies us by the -// frame's source MAC (and the "mac" field). -static void sendEspNowHello() { - if (statusESPNow != ESP_NOW_STATE_ON) return; - if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) return; // discovery is best-effort; remote re-broadcasts +// 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 hello = pDoc->createNestedObject("hello"); - hello[F("name")] = serverDescription; - hello[F("mac")] = escapedMac; - hello[F("ver")] = VERSION; - hello[F("ch")] = WiFi.channel(); - queueApiDocLocked(ESPNOW_BROADCAST_ADDRESS, ESPNOW_API_HELLO, 0); + 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 @@ -434,8 +497,9 @@ static void apiResetAll() { apiRemoteSeen = 0; apiPushPending = false; apiPushDue = 0; - apiHelloPending = false; - apiHelloDue = 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. @@ -453,13 +517,30 @@ static bool handlePendingEspNowPush() { return true; } -// Sends a delayed discovery response after the reliable response slot becomes available. -static bool handlePendingEspNowHello() { - if (!apiHelloPending || !apiTxIdle()) return false; - if ((long)(millis() - apiHelloDue) < 0) return false; - apiHelloPending = false; - sendEspNowHello(); - 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; } // Apply a completed inbound message in loop context. @@ -494,22 +575,22 @@ void handleEspNowApi() { } if (!json) { - if (!handlePendingEspNowHello() && !handlePendingEspNowPush()) handleEspNowLive(); + if (!handlePendingEspNowAnnounce() && !handlePendingEspNowPush()) handleEspNowLive(); return; } unsigned long start = millis(); while (strip.isUpdating() && millis()-start < ESPNOW_API_STRIPWAIT_TIMEOUT) yield(); - if (msgType == ESPNOW_API_HELLO) { - DEBUG_PRINTF_P(PSTR("ESP-NOW API handling HELLO id=%u from " MACSTR " ch=%u\n"), - msgId, MAC2STR(srcMac), WiFi.channel()); - apiHelloPending = true; - apiHelloDue = millis() + apiInstanceJitter(90, 10); - } else if (msgType == ESPNOW_API_REQUEST) { + if (msgType == ESPNOW_API_REQUEST) { DEBUG_PRINTF_P(PSTR("ESP-NOW API handling REQUEST id=%u bytes=%u from " MACSTR "\n"), msgId, unsigned(jsonLen), MAC2STR(srcMac)); - if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) { + 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); @@ -544,6 +625,7 @@ void handleEspNowApi() { return; } else { verbose = deserializeState(root, CALL_MODE_BUTTON); + rememberCompletedMutation(srcMac, msgId, requestHash); } releaseJSONBufferLock(); // If the request changed state, a PUSH will follow soon. Acknowledge here diff --git a/wled00/udp.cpp b/wled00/udp.cpp index 9f61dbf6eb..b931aa9455 100644 --- a/wled00/udp.cpp +++ b/wled00/udp.cpp @@ -907,7 +907,7 @@ void espNowSentCB(uint8_t* address, uint8_t status) { } // Return true only for frame types that establish the sender as a control remote. In -// particular, WLED HELLO replies carry a payload and must not replace the bonding candidate. +// particular, outbound ANNOUNCE frames must not replace the bonding candidate. static bool espNowIsBondCandidate(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 @@ -920,7 +920,7 @@ static bool espNowIsBondCandidate(const uint8_t* data, uint8_t len) { 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_HELLO || fragIndex != 0 || fragTotal != 1) return false; + 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] == '}'); } From 5dfec81bf441652a8a2fc006603444ec646a6264 Mon Sep 17 00:00:00 2001 From: figamore <90107339+figamore@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:01:06 -0400 Subject: [PATCH 06/17] Revamp webui for enhanced pairing experience --- wled00/data/settings_sync.htm | 4 +- wled00/data/settings_wifi.htm | 163 ++++++++++++++++++++++++++-------- wled00/set.cpp | 15 +++- wled00/udp.cpp | 31 ++++--- wled00/wled.h | 3 + wled00/xml.cpp | 10 ++- 6 files changed, 168 insertions(+), 58 deletions(-) diff --git a/wled00/data/settings_sync.htm b/wled00/data/settings_sync.htm index 12eb3e8d07..0a0cc5b462 100644 --- a/wled00/data/settings_sync.htm +++ b/wled00/data/settings_sync.htm @@ -89,7 +89,7 @@

ESP-NOW

Disabled. Enable ESP-NOW in WiFi settings.
-Use ESP-NOW sync:
(in AP mode or no WiFi)
+Use ESP-NOW for WLED-to-WLED sync:
(in AP mode or no WiFi)
@@ -289,4 +289,4 @@

Serial

- \ No newline at end of file + diff --git a/wled00/data/settings_wifi.htm b/wled00/data/settings_wifi.htm index e187f887fb..4bf8afba06 100644 --- a/wled00/data/settings_wifi.htm +++ b/wled00/data/settings_wifi.htm @@ -4,7 +4,15 @@ WiFi Settings - +