Skip to content
Open
201 changes: 201 additions & 0 deletions usermods/espnow_json_handler/espnow_json_handler.cpp
Original file line number Diff line number Diff line change
@@ -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
}
Comment on lines +142 to +151
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add an upper bound on total reassembly buffer size to prevent memory exhaustion.

A malicious or buggy sender could specify totalFragments=255 with ~250-byte payloads, causing up to ~62KB of heap allocation. On memory-constrained ESP devices, this could exhaust available heap. Add a reasonable cap (e.g., 4KB or 8KB) for total reassembled message size.

🛠️ Suggested fix

Add a constant and check:

 `#define` ESPNOW_FRAGMENT_HEADER_SIZE 3
 `#define` ESPNOW_REASSEMBLY_TIMEOUT_MS 5000
+#define ESPNOW_MAX_REASSEMBLY_SIZE 4096  // 4KB max JSON payload
         size_t fragmentDataSize = len - ESPNOW_FRAGMENT_HEADER_SIZE;
         size_t newSize = reassemblySize + fragmentDataSize;
 
+        if (newSize > ESPNOW_MAX_REASSEMBLY_SIZE) {
+            DEBUG_PRINTLN(F("ESP-NOW fragment reassembly: max size exceeded"));
+            cleanupReassembly();
+            return true; // Handled by failing gracefully
+        }
+
         uint8_t* newBuffer = (uint8_t*)realloc(reassemblyBuffer, newSize + 1);
🤖 Prompt for AI Agents
In `@usermods/espnow_json_handler/espnow_json_handler.cpp` around lines 140 - 149,
Add a hard cap on the total reassembly buffer size (e.g., define
MAX_REASSEMBLY_SIZE = 4096 or 8192) and check it before calling realloc: compute
newSize = reassemblySize + fragmentDataSize and if newSize > MAX_REASSEMBLY_SIZE
log an error, call cleanupReassembly(), and return true to fail gracefully;
otherwise proceed with the existing realloc on reassemblyBuffer and the +1 null
terminator. Ensure the cap check is placed immediately before the realloc in the
fragment handling path so reassemblySize, fragmentDataSize, reassemblyBuffer,
and cleanupReassembly() are used as shown.


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<JsonObject>();
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
5 changes: 5 additions & 0 deletions usermods/espnow_json_handler/library.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "espnow_json_handler",
"build": { "libArchive": false },
"dependencies": {}
}
42 changes: 42 additions & 0 deletions usermods/espnow_json_handler/readme.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions wled00/const.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down