diff --git a/usermods/espnow_json_handler/espnow_json_handler.cpp b/usermods/espnow_json_handler/espnow_json_handler.cpp new file mode 100644 index 0000000000..4f9bf5a37f --- /dev/null +++ b/usermods/espnow_json_handler/espnow_json_handler.cpp @@ -0,0 +1,201 @@ +#include "wled.h" + +#ifndef WLED_DISABLE_ESPNOW + +/* + * ESP-NOW JSON Handler Usermod + * + * This usermod handles fragmented JSON messages received via ESP-NOW. + * It reassembles fragments and deserializes the complete JSON payload + * to control WLED state. + * + * Fragment header structure (3 bytes): + * - Byte 0: message_id (unique identifier for reassembly) + * - Byte 1: fragment_index (0-based fragment number) + * - Byte 2: total_fragments (total number of fragments in message) + * - Byte 3+: JSON data payload + */ + +#define ESPNOW_FRAGMENT_HEADER_SIZE 3 +#define ESPNOW_REASSEMBLY_TIMEOUT_MS 5000 // 5 second timeout for incomplete fragment reassembly + +class EspNowJsonHandler : public Usermod { + +private: + // Fragment reassembly state + uint8_t lastMsgId = 0; + uint8_t lastProcessedMsgId = 255; + uint8_t fragmentsReceived = 0; + uint8_t* reassemblyBuffer = nullptr; + size_t reassemblySize = 0; + char _last_signal_src[13]; + unsigned long reassemblyStartTime = 0; // Timestamp when reassembly began + + /** + * Cleanup reassembly state + * Frees the reassembly buffer and resets all fragment tracking variables. + * Called when reassembly completes, times out, or encounters an error. + */ + void cleanupReassembly() { + if (reassemblyBuffer) { + free(reassemblyBuffer); + reassemblyBuffer = nullptr; + } + fragmentsReceived = 0; + reassemblySize = 0; + reassemblyStartTime = 0; + } + +public: + /** + * Called once at startup + * No initialization required for this usermod. + */ + void setup() override { + // Nothing to initialize + } + + /** + * Called every loop iteration + * Monitors for stale fragment reassembly and cleans up timed-out messages + * to prevent memory leaks from incomplete transmissions. + */ + void loop() override { + // Check for stale reassembly state and clean up if timed out + if (reassemblyBuffer && reassemblyStartTime > 0) { + if (millis() - reassemblyStartTime > ESPNOW_REASSEMBLY_TIMEOUT_MS) { + DEBUG_PRINTF_P(PSTR("ESP-NOW reassembly timeout for message %d, discarding %d fragments\n"), lastMsgId, fragmentsReceived); + cleanupReassembly(); + } + } + } + + /** + * Handle incoming ESP-NOW messages + * Returns true if the message was handled (prevents default processing) + */ + bool onEspNowMessage(uint8_t* sender, uint8_t* payload, uint8_t len) override { + sprintf_P(_last_signal_src, PSTR("%02x%02x%02x%02x%02x%02x"), sender[0], sender[1], sender[2], sender[3], sender[4], sender[5]); + + bool knownRemote = false; + for (const auto& mac : linked_remotes) { + if (strlen(mac.data()) == 12 && strcmp(_last_signal_src, mac.data()) == 0) { + knownRemote = true; + break; + } + } + if (!knownRemote) { + DEBUG_PRINT(F("ESP Now Message Received from Unlinked Sender: ")); + DEBUG_PRINTLN(_last_signal_src); + return false; // Not handled + } + + // Need at least header size to process + if (len < ESPNOW_FRAGMENT_HEADER_SIZE) { + return false; // Not handled + } + + // Check if this looks like a fragmented JSON message + // First byte should be a reasonable message ID (not a WiZ Mote signature or 'W' sync packet) + if (payload[0] == 0x91 || payload[0] == 0x81 || payload[0] == 0x80 || payload[0] == 'W') { + return false; // Let default handlers process these + } + + uint8_t messageId = payload[0]; + uint8_t fragmentIndex = payload[1]; + uint8_t totalFragments = payload[2]; + + // Validate fragment header - sanity checks + if (totalFragments == 0 || fragmentIndex >= totalFragments) { + return false; // Invalid fragment header + } + + DEBUG_PRINTF_P(PSTR("ESP-NOW JSON fragment %d/%d of message %d (%d bytes)\n"), + fragmentIndex + 1, totalFragments, messageId, len - ESPNOW_FRAGMENT_HEADER_SIZE); + + // Check if this message was already processed (deduplication for multi-channel reception) + if (messageId == lastProcessedMsgId) { + DEBUG_PRINTF_P(PSTR("ESP-NOW message %d already processed, skipping\n"), messageId); + // If we're currently reassembling this message, clean up + if (messageId == lastMsgId && reassemblyBuffer) { + cleanupReassembly(); + } + return true; // Message was handled (by ignoring duplicate) + } + + // Check if this is a new message + if (messageId != lastMsgId) { + // Clean up old reassembly buffer if exists + cleanupReassembly(); + lastMsgId = messageId; + reassemblyStartTime = millis(); // Start timeout timer for new message + } + + // Validate fragment index is sequential + if (fragmentIndex != fragmentsReceived) { + DEBUG_PRINTF_P(PSTR("ESP-NOW fragment out of order: expected %d, got %d\n"), + fragmentsReceived, fragmentIndex); + cleanupReassembly(); + return true; // Handled by aborting + } + + // Allocate or reallocate buffer + size_t fragmentDataSize = len - ESPNOW_FRAGMENT_HEADER_SIZE; + size_t newSize = reassemblySize + fragmentDataSize; + + uint8_t* newBuffer = (uint8_t*)realloc(reassemblyBuffer, newSize + 1); // +1 for null terminator + if (!newBuffer) { + DEBUG_PRINTLN(F("ESP-NOW fragment reassembly: memory allocation failed")); + cleanupReassembly(); + return true; // Handled by failing gracefully + } + + reassemblyBuffer = newBuffer; + + // Copy fragment data + memcpy(reassemblyBuffer + reassemblySize, payload + ESPNOW_FRAGMENT_HEADER_SIZE, fragmentDataSize); + reassemblySize = newSize; + fragmentsReceived++; + + // Check if we have all fragments + if (fragmentsReceived >= totalFragments) { + reassemblyBuffer[reassemblySize] = '\0'; // Null terminate + DEBUG_PRINTF_P(PSTR("ESP-NOW complete message reassembled (%d bytes): %s\n"), reassemblySize, (char*)reassemblyBuffer); + + // Mark this message as processed for deduplication + lastProcessedMsgId = messageId; + + // Process the complete JSON message + if (requestJSONBufferLock(18)) { + DeserializationError error = deserializeJson(*pDoc, reassemblyBuffer, reassemblySize); + JsonObject root = pDoc->as(); + if (!error && !root.isNull()) { + deserializeState(root); + } + else { + DEBUG_PRINTF_P(PSTR("ESP-NOW JSON deserialization error: %s\n"), error.c_str()); + } + releaseJSONBufferLock(); + } + + // Clean up + cleanupReassembly(); + } + + return true; // Message was handled + } + + /** + * Returns the unique identifier for this usermod + * @return USERMOD_ID_ESPNOW_JSON + */ + uint16_t getId() override { + return USERMOD_ID_ESPNOW_JSON; + } +}; + +// Allocate static instance and register with WLED +static EspNowJsonHandler espNowJsonHandler; +REGISTER_USERMOD(espNowJsonHandler); + +#endif // WLED_DISABLE_ESPNOW diff --git a/usermods/espnow_json_handler/library.json b/usermods/espnow_json_handler/library.json new file mode 100644 index 0000000000..97c25f054a --- /dev/null +++ b/usermods/espnow_json_handler/library.json @@ -0,0 +1,5 @@ +{ + "name": "espnow_json_handler", + "build": { "libArchive": false }, + "dependencies": {} +} diff --git a/usermods/espnow_json_handler/readme.md b/usermods/espnow_json_handler/readme.md new file mode 100644 index 0000000000..7ca61ae36c --- /dev/null +++ b/usermods/espnow_json_handler/readme.md @@ -0,0 +1,42 @@ +# ESP-NOW JSON Handler Usermod + +This usermod handles fragmented JSON messages received via ESP-NOW, allowing you to control WLED state from ESP-NOW enabled devices. + +## Features + +- Receives fragmented JSON messages over ESP-NOW +- Reassembles fragments into complete JSON payloads +- Deduplication to prevent processing duplicate messages when broadcasting on multiple channels + +## Fragment Protocol + +Messages are fragmented with a 3-byte header: + +| Byte | Description | +|------|-------------| +| 0 | Message ID (unique identifier for reassembly) | +| 1 | Fragment Index | +| 2 | Total Fragments | +| 3+ | JSON data payload | + +## Usage + +1. Enable ESP-NOW in WLED settings +2. Add the sender's MAC address to the linked remotes list +3. Send fragmented JSON payloads following the protocol above + +## Compilation + +Add the following to your `platformio_override.ini`: + +```ini +[env:yourenv] +extends = env:esp32dev +custom_usermods = espnow_json_handler +``` + +## Notes + +- ESP-NOW must be enabled (`WLED_DISABLE_ESPNOW` must NOT be defined) +- The sender MAC address must be in the linked remotes list +- Maximum ESP-NOW payload per fragment is 250 bytes (247 bytes of JSON data) for V1 versions and 1470 bytes (1467 bytes of JSON data) for V2 versions diff --git a/wled00/const.h b/wled00/const.h index ac48838435..adb1b2c0cd 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -207,6 +207,7 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); #define USERMOD_ID_RF433 56 //Usermod "usermod_v2_RF433.h" #define USERMOD_ID_BRIGHTNESS_FOLLOW_SUN 57 //Usermod "usermod_v2_brightness_follow_sun.h" #define USERMOD_ID_USER_FX 58 //Usermod "user_fx" +#define USERMOD_ID_ESPNOW_JSON 59 //Usermod "espnow_json_handler.cpp" //Access point behavior #define AP_BEHAVIOR_BOOT_NO_CONN 0 //Open AP when no connection after boot