From d6cd65e1089d96a3c22ac5805bb99a170224a6bf Mon Sep 17 00:00:00 2001 From: Will Miles Date: Thu, 27 Nov 2025 20:31:41 -0500 Subject: [PATCH 001/164] ota_update: Clean up SHA API Many of these functions are internal, and we can reduce RAM by caching the binary value instead. --- wled00/ota_update.cpp | 67 +++++++++++++++++++++++++++---------------- wled00/ota_update.h | 21 -------------- 2 files changed, 42 insertions(+), 46 deletions(-) diff --git a/wled00/ota_update.cpp b/wled00/ota_update.cpp index 4a93312d3a..5fa8dba4e5 100644 --- a/wled00/ota_update.cpp +++ b/wled00/ota_update.cpp @@ -273,18 +273,18 @@ void markOTAvalid() { } #if defined(ARDUINO_ARCH_ESP32) && !defined(WLED_DISABLE_OTA) -// Cache for bootloader SHA256 digest as hex string -static String bootloaderSHA256HexCache = ""; - -// Calculate and cache the bootloader SHA256 digest as hex string -void calculateBootloaderSHA256() { - if (!bootloaderSHA256HexCache.isEmpty()) return; +static bool bootloaderSHA256CacheValid = false; +static uint8_t bootloaderSHA256Cache[32]; +/** + * Calculate and cache the bootloader SHA256 digest + * Reads the bootloader from flash at offset 0x1000 and computes SHA256 hash + */ +static void calculateBootloaderSHA256() { // Bootloader is at fixed offset 0x1000 (4KB) and is typically 32KB const uint32_t bootloaderSize = 0x8000; // 32KB, typical bootloader size // Calculate SHA256 - uint8_t sha256[32]; mbedtls_sha256_context ctx; mbedtls_sha256_init(&ctx); mbedtls_sha256_starts(&ctx, 0); // 0 = SHA256 (not SHA224) @@ -299,33 +299,50 @@ void calculateBootloaderSHA256() { } } - mbedtls_sha256_finish(&ctx, sha256); + mbedtls_sha256_finish(&ctx, bootloaderSHA256Cache); mbedtls_sha256_free(&ctx); - - // Convert to hex string and cache it - char hex[65]; - for (int i = 0; i < 32; i++) { - sprintf(hex + (i * 2), "%02x", sha256[i]); - } - hex[64] = '\0'; - bootloaderSHA256HexCache = String(hex); + bootloaderSHA256CacheValid = true; } // Get bootloader SHA256 as hex string String getBootloaderSHA256Hex() { - calculateBootloaderSHA256(); - return bootloaderSHA256HexCache; + if (!bootloaderSHA256CacheValid) { + calculateBootloaderSHA256(); + } + + // Convert to hex string + String result; + result.reserve(65); + for (int i = 0; i < 32; i++) { + char b1 = bootloaderSHA256Cache[i]; + char b2 = b1 >> 4; + b1 &= 0x0F; + b1 += '0'; b2 += '0'; + if (b1 > '9') b1 += 7; + if (b2 > '9') b2 += 7; + result.concat(b1); + result.concat(b2); + } + return std::move(result); } -// Invalidate cached bootloader SHA256 (call after bootloader update) -void invalidateBootloaderSHA256Cache() { - bootloaderSHA256HexCache = ""; +/** + * Invalidate cached bootloader SHA256 (call after bootloader update) + * Forces recalculation on next call to calculateBootloaderSHA256 or getBootloaderSHA256Hex + */ +static void invalidateBootloaderSHA256Cache() { + bootloaderSHA256CacheValid = false; } -// Verify complete buffered bootloader using ESP-IDF validation approach -// This matches the key validation steps from esp_image_verify() in ESP-IDF -// Returns the actual bootloader data pointer and length via the buffer and len parameters -bool verifyBootloaderImage(const uint8_t* &buffer, size_t &len, String* bootloaderErrorMsg) { +/** + * Verify complete buffered bootloader using ESP-IDF validation approach + * This matches the key validation steps from esp_image_verify() in ESP-IDF + * @param buffer Reference to pointer to bootloader binary data (will be adjusted if offset detected) + * @param len Reference to length of bootloader data (will be adjusted to actual size) + * @param bootloaderErrorMsg Pointer to String to store error message (must not be null) + * @return true if validation passed, false otherwise + */ +static bool verifyBootloaderImage(const uint8_t* &buffer, size_t &len, String* bootloaderErrorMsg) { size_t availableLen = len; if (!bootloaderErrorMsg) { DEBUG_PRINTLN(F("bootloaderErrorMsg is null")); diff --git a/wled00/ota_update.h b/wled00/ota_update.h index 691429b305..d2fa887d30 100644 --- a/wled00/ota_update.h +++ b/wled00/ota_update.h @@ -58,11 +58,6 @@ void handleOTAData(AsyncWebServerRequest *request, size_t index, uint8_t *data, void markOTAvalid(); #if defined(ARDUINO_ARCH_ESP32) && !defined(WLED_DISABLE_OTA) -/** - * Calculate and cache the bootloader SHA256 digest - * Reads the bootloader from flash at offset 0x1000 and computes SHA256 hash - */ -void calculateBootloaderSHA256(); /** * Get bootloader SHA256 as hex string @@ -70,22 +65,6 @@ void calculateBootloaderSHA256(); */ String getBootloaderSHA256Hex(); -/** - * Invalidate cached bootloader SHA256 (call after bootloader update) - * Forces recalculation on next call to calculateBootloaderSHA256 or getBootloaderSHA256Hex - */ -void invalidateBootloaderSHA256Cache(); - -/** - * Verify complete buffered bootloader using ESP-IDF validation approach - * This matches the key validation steps from esp_image_verify() in ESP-IDF - * @param buffer Reference to pointer to bootloader binary data (will be adjusted if offset detected) - * @param len Reference to length of bootloader data (will be adjusted to actual size) - * @param bootloaderErrorMsg Pointer to String to store error message (must not be null) - * @return true if validation passed, false otherwise - */ -bool verifyBootloaderImage(const uint8_t* &buffer, size_t &len, String* bootloaderErrorMsg); - /** * Create a bootloader OTA context object on an AsyncWebServerRequest * @param request Pointer to web request object From 0b965ea4312490a6940a6f89207469aec3297dc5 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Thu, 27 Nov 2025 21:42:57 -0500 Subject: [PATCH 002/164] ota_update: Fix hex print --- wled00/ota_update.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wled00/ota_update.cpp b/wled00/ota_update.cpp index 5fa8dba4e5..0f7a65c012 100644 --- a/wled00/ota_update.cpp +++ b/wled00/ota_update.cpp @@ -318,10 +318,10 @@ String getBootloaderSHA256Hex() { char b2 = b1 >> 4; b1 &= 0x0F; b1 += '0'; b2 += '0'; - if (b1 > '9') b1 += 7; - if (b2 > '9') b2 += 7; - result.concat(b1); + if (b1 > '9') b1 += 39; + if (b2 > '9') b2 += 39; result.concat(b2); + result.concat(b1); } return std::move(result); } From 379343278d7f7ac639f9aecc12d22d9fe2c36ad5 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Thu, 27 Nov 2025 21:46:06 -0500 Subject: [PATCH 003/164] ota_update: Fix NRVO in getBootloaderSHA256Hex h/t @coderabbitai --- wled00/ota_update.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wled00/ota_update.cpp b/wled00/ota_update.cpp index 0f7a65c012..ac4f83acb2 100644 --- a/wled00/ota_update.cpp +++ b/wled00/ota_update.cpp @@ -323,7 +323,7 @@ String getBootloaderSHA256Hex() { result.concat(b2); result.concat(b1); } - return std::move(result); + return result; } /** From 247a7a51d72bb59d1d580384853dbb11c78fe351 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Dec 2025 17:10:22 +0000 Subject: [PATCH 004/164] Initial plan From 6a2b7995e9964cdb2e6c8f46e791c15dcb40671c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Dec 2025 17:18:23 +0000 Subject: [PATCH 005/164] Add "Yes, Always" option to upgrade prompt and improve messaging Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com> --- wled00/data/index.js | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/wled00/data/index.js b/wled00/data/index.js index 84b256183c..8749ec1486 100644 --- a/wled00/data/index.js +++ b/wled00/data/index.js @@ -3343,8 +3343,14 @@ function checkVersionUpgrade(info) { const storedVersion = versionInfo.version || ''; if (storedVersion && storedVersion !== currentVersion) { - // Version has changed, show upgrade prompt - showVersionUpgradePrompt(info, storedVersion, currentVersion); + // Version has changed + if (versionInfo.alwaysReport) { + // Automatically report if user opted in for always reporting + reportUpgradeEvent(info, storedVersion); + } else { + // Show upgrade prompt + showVersionUpgradePrompt(info, storedVersion, currentVersion); + } } else if (!storedVersion) { // Empty version in file, show install prompt showVersionUpgradePrompt(info, null, currentVersion); @@ -3354,7 +3360,7 @@ function checkVersionUpgrade(info) { console.log('Failed to load version-info.json', e); // On error, save current version for next time if (info && info.ver) { - updateVersionInfo(info.ver, false); + updateVersionInfo(info.ver, false, false); } }); } @@ -3380,7 +3386,7 @@ function showVersionUpgradePrompt(info, oldVersion, newVersion) { ? `You are now running WLED ${newVersion}.` : `Your WLED has been upgraded from ${oldVersion} to ${newVersion}.`; - const question = 'Help make WLED better with a one-time hardware report? It includes only device details like chip type, LED count, etc. — never personal data or your activities.' + const question = 'Help make WLED better by sharing hardware details like chip type and LED count? This helps us understand how WLED is used and prioritize features — we never collect personal data or your activities.' dialog.innerHTML = `

${title}

@@ -3389,8 +3395,9 @@ function showVersionUpgradePrompt(info, oldVersion, newVersion) {

Learn more about what data is collected and why

-
+
+
@@ -3405,19 +3412,25 @@ function showVersionUpgradePrompt(info, oldVersion, newVersion) { d.body.removeChild(overlay); }); + gId('versionReportAlways').addEventListener('click', () => { + reportUpgradeEvent(info, oldVersion, true); // Pass true for alwaysReport + d.body.removeChild(overlay); + showToast('Thank you! Future upgrades will be reported automatically.'); + }); + gId('versionReportNo').addEventListener('click', () => { // Don't update version, will ask again on next load d.body.removeChild(overlay); }); gId('versionReportNever').addEventListener('click', () => { - updateVersionInfo(newVersion, true); + updateVersionInfo(newVersion, true, false); d.body.removeChild(overlay); showToast('You will not be asked again.'); }); } -function reportUpgradeEvent(info, oldVersion) { +function reportUpgradeEvent(info, oldVersion, alwaysReport) { showToast('Reporting upgrade...'); // Fetch fresh data from /json/info endpoint as requested @@ -3458,7 +3471,7 @@ function reportUpgradeEvent(info, oldVersion) { .then(res => { if (res.ok) { showToast('Thank you for reporting!'); - updateVersionInfo(info.ver, false); + updateVersionInfo(info.ver, false, alwaysReport || false); } else { showToast('Report failed. Please try again later.', true); // Do NOT update version info on failure - user will be prompted again @@ -3471,10 +3484,11 @@ function reportUpgradeEvent(info, oldVersion) { }); } -function updateVersionInfo(version, neverAsk) { +function updateVersionInfo(version, neverAsk, alwaysReport) { const versionInfo = { version: version, - neverAsk: neverAsk + neverAsk: neverAsk, + alwaysReport: alwaysReport || false }; // Create a Blob with JSON content and use /upload endpoint From 6ca8ed65e8b623a73b431ed798e911eaeacc6291 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Dec 2025 17:24:04 +0000 Subject: [PATCH 006/164] Address code review feedback: use boolean coercion instead of logical OR Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com> --- wled00/data/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wled00/data/index.js b/wled00/data/index.js index 8749ec1486..a1c4df4c81 100644 --- a/wled00/data/index.js +++ b/wled00/data/index.js @@ -3471,7 +3471,7 @@ function reportUpgradeEvent(info, oldVersion, alwaysReport) { .then(res => { if (res.ok) { showToast('Thank you for reporting!'); - updateVersionInfo(info.ver, false, alwaysReport || false); + updateVersionInfo(info.ver, false, !!alwaysReport); } else { showToast('Report failed. Please try again later.', true); // Do NOT update version info on failure - user will be prompted again @@ -3488,7 +3488,7 @@ function updateVersionInfo(version, neverAsk, alwaysReport) { const versionInfo = { version: version, neverAsk: neverAsk, - alwaysReport: alwaysReport || false + alwaysReport: !!alwaysReport }; // Create a Blob with JSON content and use /upload endpoint From 1585cab3ba9f93bb2efaa15433a6315ca67ed00c Mon Sep 17 00:00:00 2001 From: Will Miles Date: Sat, 8 Nov 2025 18:13:22 -0500 Subject: [PATCH 007/164] Add source version check to OTA update Add a field to the OTA metadata structure indicating the oldest base version it's safe to install this update /from/. This provides a clear path forward in case there are incompatibilities, eg. some case (bootloader compatibility) where 0.16.0 cannot be installed safely from 0.15.2, but a transitional 0.15.3 can arrange the groundwork. --- wled00/wled_metadata.cpp | 28 +++++++++++++++++++++++++--- wled00/wled_metadata.h | 1 + 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/wled00/wled_metadata.cpp b/wled00/wled_metadata.cpp index 19c83dda1c..9ee19ade05 100644 --- a/wled00/wled_metadata.cpp +++ b/wled00/wled_metadata.cpp @@ -16,7 +16,7 @@ #endif constexpr uint32_t WLED_CUSTOM_DESC_MAGIC = 0x57535453; // "WSTS" (WLED System Tag Structure) -constexpr uint32_t WLED_CUSTOM_DESC_VERSION = 1; +constexpr uint32_t WLED_CUSTOM_DESC_VERSION = 2; // v1 - original PR; v2 - "safe to update from" version // Compile-time validation that release name doesn't exceed maximum length static_assert(sizeof(WLED_RELEASE_NAME) <= WLED_RELEASE_NAME_MAX_LEN, @@ -59,6 +59,11 @@ const wled_metadata_t __attribute__((section(BUILD_METADATA_SECTION))) WLED_BUIL TOSTRING(WLED_VERSION), WLED_RELEASE_NAME, // release_name std::integral_constant::value, // hash - computed at compile time; integral_constant enforces this +#if defined(ESP32) && defined(CONFIG_IDF_TARGET_ESP32) + { 0, 15, 3 }, // Some older ESP32 might have bootloader issues; assume we'll have it sorted by 0.15.3 +#else + { 0, 15, 2 }, // All other platforms can update safely +#endif }; static const char repoString_s[] PROGMEM = WLED_REPO; @@ -96,7 +101,7 @@ bool findWledMetadata(const uint8_t* binaryData, size_t dataSize, wled_metadata_ memcpy(&candidate, binaryData + offset, sizeof(candidate)); // Found potential match, validate version - if (candidate.desc_version != WLED_CUSTOM_DESC_VERSION) { + if (candidate.desc_version > WLED_CUSTOM_DESC_VERSION) { DEBUG_PRINTF_P(PSTR("Found WLED structure at offset %u but version mismatch: %u\n"), offset, candidate.desc_version); continue; @@ -151,13 +156,30 @@ bool shouldAllowOTA(const wled_metadata_t& firmwareDescription, char* errorMessa if (strncmp_P(safeFirmwareRelease, releaseString, WLED_RELEASE_NAME_MAX_LEN) != 0) { if (errorMessage && errorMessageLen > 0) { - snprintf_P(errorMessage, errorMessageLen, PSTR("Firmware compatibility mismatch: current='%s', uploaded='%s'."), + snprintf_P(errorMessage, errorMessageLen, PSTR("Firmware release name mismatch: current='%s', uploaded='%s'."), releaseString, safeFirmwareRelease); errorMessage[errorMessageLen - 1] = '\0'; // Ensure null termination } return false; } + if (firmwareDescription.desc_version > 1) { + // Add safe version check + // Parse our version (x.y.z) and compare it to the "safe version" array + char* our_version = const_cast(versionString); // rip off const for legacy strtol compatibility + for(unsigned v_index = 0; v_index < 3; ++v_index) { + long our_v_parsed = strtol(our_version, &our_version, 10); + ++our_version; // skip the decimal point + if (firmwareDescription.safe_update_version[v_index] < our_v_parsed) { + snprintf_P(errorMessage, errorMessageLen, PSTR("Cannot update from this version: requires at least %d.%d.%d, current='%s'."), + firmwareDescription.safe_update_version[0], firmwareDescription.safe_update_version[1], firmwareDescription.safe_update_version[2], + versionString); + errorMessage[errorMessageLen - 1] = '\0'; // Ensure null termination + return false; + } + } + } + // TODO: additional checks go here return true; diff --git a/wled00/wled_metadata.h b/wled00/wled_metadata.h index 7ab4d09936..8c1dc0bb0f 100644 --- a/wled00/wled_metadata.h +++ b/wled00/wled_metadata.h @@ -26,6 +26,7 @@ typedef struct { char wled_version[WLED_VERSION_MAX_LEN]; char release_name[WLED_RELEASE_NAME_MAX_LEN]; // Release name (null-terminated) uint32_t hash; // Structure sanity check + uint8_t safe_update_version[3]; // Indicates version it's known to be safe to install this update from: major, minor, patch } __attribute__((packed)) wled_metadata_t; From ba21ab55f872924c1d1163019b689f708425883d Mon Sep 17 00:00:00 2001 From: Will Miles Date: Sat, 8 Nov 2025 19:18:27 -0500 Subject: [PATCH 008/164] Implement correct update version check --- wled00/wled_metadata.cpp | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/wled00/wled_metadata.cpp b/wled00/wled_metadata.cpp index 9ee19ade05..05616a7294 100644 --- a/wled00/wled_metadata.cpp +++ b/wled00/wled_metadata.cpp @@ -166,17 +166,30 @@ bool shouldAllowOTA(const wled_metadata_t& firmwareDescription, char* errorMessa if (firmwareDescription.desc_version > 1) { // Add safe version check // Parse our version (x.y.z) and compare it to the "safe version" array - char* our_version = const_cast(versionString); // rip off const for legacy strtol compatibility + const char* our_version = versionString; for(unsigned v_index = 0; v_index < 3; ++v_index) { - long our_v_parsed = strtol(our_version, &our_version, 10); - ++our_version; // skip the decimal point - if (firmwareDescription.safe_update_version[v_index] < our_v_parsed) { - snprintf_P(errorMessage, errorMessageLen, PSTR("Cannot update from this version: requires at least %d.%d.%d, current='%s'."), - firmwareDescription.safe_update_version[0], firmwareDescription.safe_update_version[1], firmwareDescription.safe_update_version[2], - versionString); - errorMessage[errorMessageLen - 1] = '\0'; // Ensure null termination + char* our_version_end = nullptr; + long our_v_parsed = strtol(our_version, &our_version_end, 10); + if (!our_version_end || (our_version_end == our_version)) { + // We were built with a malformed version string + // We blame the integrator and attempt the update anyways - nothing the user can do to fix this + break; + } + + if (firmwareDescription.safe_update_version[v_index] > our_v_parsed) { + if (errorMessage && errorMessageLen > 0) { + snprintf_P(errorMessage, errorMessageLen, PSTR("Cannot update from this version: requires at least %d.%d.%d, current='%s'."), + firmwareDescription.safe_update_version[0], firmwareDescription.safe_update_version[1], firmwareDescription.safe_update_version[2], + versionString); + errorMessage[errorMessageLen - 1] = '\0'; // Ensure null termination + } return false; + } else if (firmwareDescription.safe_update_version[v_index] < our_v_parsed) { + break; // no need to check the other components } + + if (*our_version_end == '.') ++our_version_end; + our_version = our_version_end; } } From 788c09972ed678960c5cf9c4d929f38cc765b2ec Mon Sep 17 00:00:00 2001 From: Will Miles Date: Fri, 19 Dec 2025 20:31:45 -0500 Subject: [PATCH 009/164] Remove OTA metadata structure version block If we cap the acceptable version, it becomes impossible to increase it: older firmwares will reject it. Instead we must guarantee backwards compatibility so long as the magic number is the same. If a breaking change is required in the future, a new magic number should be used to identify it. --- wled00/wled_metadata.cpp | 7 ------- 1 file changed, 7 deletions(-) diff --git a/wled00/wled_metadata.cpp b/wled00/wled_metadata.cpp index 05616a7294..ef88343339 100644 --- a/wled00/wled_metadata.cpp +++ b/wled00/wled_metadata.cpp @@ -100,13 +100,6 @@ bool findWledMetadata(const uint8_t* binaryData, size_t dataSize, wled_metadata_ wled_metadata_t candidate; memcpy(&candidate, binaryData + offset, sizeof(candidate)); - // Found potential match, validate version - if (candidate.desc_version > WLED_CUSTOM_DESC_VERSION) { - DEBUG_PRINTF_P(PSTR("Found WLED structure at offset %u but version mismatch: %u\n"), - offset, candidate.desc_version); - continue; - } - // Validate hash using runtime function uint32_t expected_hash = djb2_hash_runtime(candidate.release_name); if (candidate.hash != expected_hash) { From 51a14ede8bd5e79ba3327697fb35f334ae0b207f Mon Sep 17 00:00:00 2001 From: Will Miles Date: Fri, 19 Dec 2025 20:33:21 -0500 Subject: [PATCH 010/164] OTA: Report metadata v1 as workaround Report the older structure version until sufficient penetration of the previous patch. --- wled00/wled_metadata.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wled00/wled_metadata.cpp b/wled00/wled_metadata.cpp index ef88343339..62396a27a0 100644 --- a/wled00/wled_metadata.cpp +++ b/wled00/wled_metadata.cpp @@ -55,7 +55,7 @@ inline uint32_t djb2_hash_runtime(const char* str) { // Structure instantiation for this build const wled_metadata_t __attribute__((section(BUILD_METADATA_SECTION))) WLED_BUILD_DESCRIPTION = { WLED_CUSTOM_DESC_MAGIC, // magic - WLED_CUSTOM_DESC_VERSION, // version + /*WLED_CUSTOM_DESC_VERSION*/ 1, // structure version. Currently set to 1 to allow OTA from broken original version. FIXME before 0.16 release. TOSTRING(WLED_VERSION), WLED_RELEASE_NAME, // release_name std::integral_constant::value, // hash - computed at compile time; integral_constant enforces this From c0d662a9e32b0afad7acb9cf03b6eff4716105ae Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 10 Jan 2026 13:27:51 +0000 Subject: [PATCH 011/164] Swap DMX output to also use esp_dmx --- wled00/dmx_output.cpp | 23 ++- wled00/dmx_output.h | 36 ++++ wled00/src/dependencies/dmx/SparkFunDMX.cpp | 182 -------------------- wled00/src/dependencies/dmx/SparkFunDMX.h | 42 ----- wled00/wled.h | 11 +- 5 files changed, 56 insertions(+), 238 deletions(-) create mode 100644 wled00/dmx_output.h delete mode 100644 wled00/src/dependencies/dmx/SparkFunDMX.cpp delete mode 100644 wled00/src/dependencies/dmx/SparkFunDMX.h diff --git a/wled00/dmx_output.cpp b/wled00/dmx_output.cpp index eace2145e6..6acd35046b 100644 --- a/wled00/dmx_output.cpp +++ b/wled00/dmx_output.cpp @@ -1,13 +1,12 @@ #include "wled.h" - +#include "dmx_output.h" /* * Support for DMX output via serial (e.g. MAX485). * Change the output pin in src/dependencies/ESPDMX.cpp, if needed (ESP8266) - * Change the output pin in src/dependencies/SparkFunDMX.cpp, if needed (ESP32) * ESP8266 Library from: * https://github.com/Rickgg/ESP-Dmx * ESP32 Library from: - * https://github.com/sparkfun/SparkFunDMX + * https://github.com/someweisguy/esp_dmx */ #ifdef WLED_ENABLE_DMX @@ -72,9 +71,25 @@ void initDMXOutput() { #if defined(ESP8266) || defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S2) dmx.init(512); // initialize with bus length #else - dmx.initWrite(512); // initialize with bus length + #endif } + +void DMXOutput::init(uint8_t txPin) +{ + dmx_config_t config = DMX_CONFIG_DEFAULT; + dmx_driver_install(dmxPort, &config, DMX_INTR_FLAGS_DEFAULT); + dmx_set_pin(dmxPort, txPin, -1, -1); +} +void DMXOutput::write(uint8_t channel, uint8_t value) +{ + dmxdata[channel] = value; +} +void DMXOutput::update() +{ + dmx_send(dmxPort, DMX_PACKET_SIZE); +} + #else void initDMXOutput(){} void handleDMXOutput() {} diff --git a/wled00/dmx_output.h b/wled00/dmx_output.h new file mode 100644 index 0000000000..02db052269 --- /dev/null +++ b/wled00/dmx_output.h @@ -0,0 +1,36 @@ +// +// Created by will on 1/10/26. +// + +#ifndef DMX_OUTPUT_H +#define DMX_OUTPUT_H + +#if defined(ESP8266) +#include "src/dependencies/dmx/ESPDMX.h" +DMXESPSerial dmx; +#else +#include +/** + * Support for DMX Output via serial (e.g. max485) on ESP32 + * ESP32 Library from: + * https://github.com/someweisguy/esp_dmx + */ +class DMXOutput +{ +public: + void init(uint8_t txPin); + void write(uint8_t channel, uint8_t value); + void update(); +private: + byte dmxdata[DMX_PACKET_SIZE]; + /* Next, lets decide which DMX port to use. The ESP32 has either 2 or 3 ports. +Port 0 is typically used to transmit serial data back to your Serial Monitor, +so we shouldn't use that port. Lets use port 1! */ + dmx_port_t dmxPort = 1; +}; + +DMXOutput dmx; +#endif + + +#endif //DMX_OUTPUT_H diff --git a/wled00/src/dependencies/dmx/SparkFunDMX.cpp b/wled00/src/dependencies/dmx/SparkFunDMX.cpp deleted file mode 100644 index 064b9ff620..0000000000 --- a/wled00/src/dependencies/dmx/SparkFunDMX.cpp +++ /dev/null @@ -1,182 +0,0 @@ -/****************************************************************************** -SparkFunDMX.h -Arduino Library for the SparkFun ESP32 LED to DMX Shield -Andy England @ SparkFun Electronics -7/22/2019 - -Development environment specifics: -Arduino IDE 1.6.4 - -This code is released under the [MIT License](http://opensource.org/licenses/MIT). -Please review the LICENSE.md file included with this example. If you have any questions -or concerns with licensing, please contact techsupport@sparkfun.com. -Distributed as-is; no warranty is given. -******************************************************************************/ - -/* ----- LIBRARIES ----- */ -#if defined(ARDUINO_ARCH_ESP32) - -#include -#if !defined(CONFIG_IDF_TARGET_ESP32C3) && !defined(CONFIG_IDF_TARGET_ESP32S2) - -#include "SparkFunDMX.h" -#include - -#define dmxMaxChannel 512 -#define defaultMax 32 - -#define DMXSPEED 250000 -#define DMXFORMAT SERIAL_8N2 -#define BREAKSPEED 83333 -#define BREAKFORMAT SERIAL_8N1 - -static const int enablePin = -1; // disable the enable pin because it is not needed -static const int rxPin = -1; // disable the receiving pin because it is not needed - softhack007: Pin=-1 means "use default" not "disable" -static const int txPin = 2; // transmit DMX data over this pin (default is pin 2) - -//DMX value array and size. Entry 0 will hold startbyte, so we need 512+1 elements -static uint8_t dmxData[dmxMaxChannel+1] = { 0 }; -static int chanSize = 0; -#if !defined(DMX_SEND_ONLY) -static int currentChannel = 0; -#endif - -// Some new MCUs (-S2, -C3) don't have HardwareSerial(2) -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 2, 0) - #if SOC_UART_NUM < 3 - #error DMX output is not possible on your MCU, as it does not have HardwareSerial(2) - #endif -#endif - -static HardwareSerial DMXSerial(2); - -/* Interrupt Timer for DMX Receive */ -#if !defined(DMX_SEND_ONLY) -static hw_timer_t * timer = NULL; -static portMUX_TYPE timerMux = portMUX_INITIALIZER_UNLOCKED; -#endif - -static volatile int _interruptCounter = 0; -static volatile bool _startCodeDetected = false; - - -#if !defined(DMX_SEND_ONLY) -/* Start Code is detected by 21 low interrupts */ -void IRAM_ATTR onTimer() { - if ((rxPin >= 0) && (digitalRead(rxPin) == 1)) - { - _interruptCounter = 0; //If the RX Pin is high, we are not in an interrupt - } - else - { - _interruptCounter++; - } - if (_interruptCounter > 9) - { - portENTER_CRITICAL_ISR(&timerMux); - _startCodeDetected = true; - DMXSerial.begin(DMXSPEED, DMXFORMAT, rxPin, txPin); - portEXIT_CRITICAL_ISR(&timerMux); - _interruptCounter = 0; - } -} - -void SparkFunDMX::initRead(int chanQuant) { - - timer = timerBegin(0, 1, true); - timerAttachInterrupt(timer, &onTimer, true); - timerAlarmWrite(timer, 320, true); - timerAlarmEnable(timer); - _READWRITE = _READ; - if (chanQuant > dmxMaxChannel || chanQuant <= 0) - { - chanQuant = defaultMax; - } - chanSize = chanQuant; - if (enablePin >= 0) { - pinMode(enablePin, OUTPUT); - digitalWrite(enablePin, LOW); - } - if (rxPin >= 0) pinMode(rxPin, INPUT); -} -#endif - -// Set up the DMX-Protocol -void SparkFunDMX::initWrite (int chanQuant) { - - _READWRITE = _WRITE; - if (chanQuant > dmxMaxChannel || chanQuant <= 0) { - chanQuant = defaultMax; - } - - chanSize = chanQuant + 1; //Add 1 for start code - - DMXSerial.begin(DMXSPEED, DMXFORMAT, rxPin, txPin); - if (enablePin >= 0) { - pinMode(enablePin, OUTPUT); - digitalWrite(enablePin, HIGH); - } -} - -#if !defined(DMX_SEND_ONLY) -// Function to read DMX data -uint8_t SparkFunDMX::read(int Channel) { - if (Channel > chanSize) Channel = chanSize; - return(dmxData[Channel - 1]); //subtract one to account for start byte -} -#endif - -// Function to send DMX data -void SparkFunDMX::write(int Channel, uint8_t value) { - if (Channel < 0) Channel = 0; - if (Channel > chanSize) chanSize = Channel; - dmxData[0] = 0; - dmxData[Channel] = value; //add one to account for start byte -} - - - -void SparkFunDMX::update() { - if (_READWRITE == _WRITE) - { - //Send DMX break - digitalWrite(txPin, HIGH); - DMXSerial.begin(BREAKSPEED, BREAKFORMAT, rxPin, txPin);//Begin the Serial port - DMXSerial.write(0); - DMXSerial.flush(); - delay(1); - DMXSerial.end(); - - //Send DMX data - DMXSerial.begin(DMXSPEED, DMXFORMAT, rxPin, txPin);//Begin the Serial port - DMXSerial.write(dmxData, chanSize); - DMXSerial.flush(); - DMXSerial.end();//clear our DMX array, end the Hardware Serial port - } -#if !defined(DMX_SEND_ONLY) - else if (_READWRITE == _READ)//In a perfect world, this function ends serial communication upon packet completion and attaches RX to a CHANGE interrupt so the start code can be read again - { - if (_startCodeDetected == true) - { - while (DMXSerial.available()) - { - dmxData[currentChannel++] = DMXSerial.read(); - } - if (currentChannel > chanSize) //Set the channel counter back to 0 if we reach the known end size of our packet - { - - portENTER_CRITICAL(&timerMux); - _startCodeDetected = false; - DMXSerial.flush(); - DMXSerial.end(); - portEXIT_CRITICAL(&timerMux); - currentChannel = 0; - } - } - } -#endif -} - -// Function to update the DMX bus -#endif -#endif diff --git a/wled00/src/dependencies/dmx/SparkFunDMX.h b/wled00/src/dependencies/dmx/SparkFunDMX.h deleted file mode 100644 index 73861153b2..0000000000 --- a/wled00/src/dependencies/dmx/SparkFunDMX.h +++ /dev/null @@ -1,42 +0,0 @@ -/****************************************************************************** -SparkFunDMX.h -Arduino Library for the SparkFun ESP32 LED to DMX Shield -Andy England @ SparkFun Electronics -7/22/2019 - -Development environment specifics: -Arduino IDE 1.6.4 - -This code is released under the [MIT License](http://opensource.org/licenses/MIT). -Please review the LICENSE.md file included with this example. If you have any questions -or concerns with licensing, please contact techsupport@sparkfun.com. -Distributed as-is; no warranty is given. -******************************************************************************/ - -#include - - -#ifndef SparkFunDMX_h -#define SparkFunDMX_h - -#define DMX_SEND_ONLY // this disables DMX sending features, and saves us two GPIO pins - -// ---- Methods ---- - -class SparkFunDMX { -public: - void initWrite(int maxChan); -#if !defined(DMX_SEND_ONLY) - void initRead(int maxChan); - uint8_t read(int Channel); -#endif - void write(int channel, uint8_t value); - void update(); -private: - const uint8_t _startCodeValue = 0xFF; - const bool _READ = true; - const bool _WRITE = false; - bool _READWRITE; -}; - -#endif \ No newline at end of file diff --git a/wled00/wled.h b/wled00/wled.h index 66b33740d6..e791dee6ab 100644 --- a/wled00/wled.h +++ b/wled00/wled.h @@ -139,11 +139,7 @@ #endif #ifdef WLED_ENABLE_DMX - #if defined(ESP8266) || defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S2) - #include "src/dependencies/dmx/ESPDMX.h" - #else //ESP32 - #include "src/dependencies/dmx/SparkFunDMX.h" - #endif +#include "dmx_output.h" #endif #ifdef WLED_ENABLE_DMX_INPUT @@ -454,11 +450,6 @@ WLED_GLOBAL bool arlsDisableGammaCorrection _INIT(true); // activate if WLED_GLOBAL bool arlsForceMaxBri _INIT(false); // enable to force max brightness if source has very dark colors that would be black #ifdef WLED_ENABLE_DMX - #if defined(ESP8266) || defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S2) - WLED_GLOBAL DMXESPSerial dmx; - #else //ESP32 - WLED_GLOBAL SparkFunDMX dmx; - #endif WLED_GLOBAL uint16_t e131ProxyUniverse _INIT(0); // output this E1.31 (sACN) / ArtNet universe via MAX485 (0 = disabled) // dmx CONFIG WLED_GLOBAL byte DMXChannels _INIT(7); // number of channels per fixture From 37732ca21f46b25fe83b5e70afdb9f03acef4036 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 10 Jan 2026 13:29:29 +0000 Subject: [PATCH 012/164] Temp enable DMX Output in all builds --- platformio.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/platformio.ini b/platformio.ini index ec73bc5658..848dad8e3b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -121,6 +121,7 @@ build_flags = -D DECODE_LG=true -DWLED_USE_MY_CONFIG -D WLED_PS_DONT_REPLACE_FX ; PS replacement FX are purely a flash memory saving feature, do not replace classic FX until we run out of flash + -D WLED_ENABLE_DMX build_unflags = From 96d4489a5e58d1460ae405a47d99baaa2aff6118 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 10 Jan 2026 13:37:30 +0000 Subject: [PATCH 013/164] WLED_GLOBAL needed for dmx field to prevent linker errors --- wled00/dmx_output.h | 3 --- wled00/wled.h | 5 +++++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/wled00/dmx_output.h b/wled00/dmx_output.h index 02db052269..59c14084d7 100644 --- a/wled00/dmx_output.h +++ b/wled00/dmx_output.h @@ -7,7 +7,6 @@ #if defined(ESP8266) #include "src/dependencies/dmx/ESPDMX.h" -DMXESPSerial dmx; #else #include /** @@ -28,8 +27,6 @@ Port 0 is typically used to transmit serial data back to your Serial Monitor, so we shouldn't use that port. Lets use port 1! */ dmx_port_t dmxPort = 1; }; - -DMXOutput dmx; #endif diff --git a/wled00/wled.h b/wled00/wled.h index e791dee6ab..6bfaae2f31 100644 --- a/wled00/wled.h +++ b/wled00/wled.h @@ -458,6 +458,11 @@ WLED_GLOBAL bool arlsForceMaxBri _INIT(false); // enable to f WLED_GLOBAL uint16_t DMXGap _INIT(10); // gap between the fixtures. makes addressing easier because you don't have to memorize odd numbers when climbing up onto a rig. WLED_GLOBAL uint16_t DMXStart _INIT(10); // start address of the first fixture WLED_GLOBAL uint16_t DMXStartLED _INIT(0); // LED from which DMX fixtures start + #if defined(ESP8266) + WLED_GLOBAL DMXESPSerial dmx; + #else + WLED_GLOBAL DMXOutput dmx; + #endif #endif #ifdef WLED_ENABLE_DMX_INPUT WLED_GLOBAL int dmxInputTransmitPin _INIT(0); From 84ce66fbc0e4c656dbd88ad693cbb86f9a197ee6 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 10 Jan 2026 13:39:56 +0000 Subject: [PATCH 014/164] ass ifdef for 8266 --- wled00/dmx_output.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/wled00/dmx_output.cpp b/wled00/dmx_output.cpp index 6acd35046b..592ee25971 100644 --- a/wled00/dmx_output.cpp +++ b/wled00/dmx_output.cpp @@ -68,13 +68,10 @@ void handleDMXOutput() } void initDMXOutput() { - #if defined(ESP8266) || defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S2) dmx.init(512); // initialize with bus length - #else - - #endif } +#if !defined(ESP8266) void DMXOutput::init(uint8_t txPin) { dmx_config_t config = DMX_CONFIG_DEFAULT; @@ -89,6 +86,8 @@ void DMXOutput::update() { dmx_send(dmxPort, DMX_PACKET_SIZE); } +#endif + #else void initDMXOutput(){} From d40f0f689e2abee48971f5b27d1efa40bdfc4ee6 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 10 Jan 2026 13:58:20 +0000 Subject: [PATCH 015/164] Set output pin during init --- wled00/dmx_output.cpp | 5 ++-- wled00/src/dependencies/dmx/ESPDMX.cpp | 33 ++------------------------ wled00/src/dependencies/dmx/ESPDMX.h | 20 ++++++++++++++-- 3 files changed, 22 insertions(+), 36 deletions(-) diff --git a/wled00/dmx_output.cpp b/wled00/dmx_output.cpp index 592ee25971..f13bc80638 100644 --- a/wled00/dmx_output.cpp +++ b/wled00/dmx_output.cpp @@ -1,8 +1,7 @@ #include "wled.h" #include "dmx_output.h" /* - * Support for DMX output via serial (e.g. MAX485). - * Change the output pin in src/dependencies/ESPDMX.cpp, if needed (ESP8266) + * Support for DMX output via serial (e.g. MAX485). * ESP8266 Library from: * https://github.com/Rickgg/ESP-Dmx * ESP32 Library from: @@ -68,7 +67,7 @@ void handleDMXOutput() } void initDMXOutput() { - dmx.init(512); // initialize with bus length + dmx.init(2); // set output pin and initialize DMX output } #if !defined(ESP8266) diff --git a/wled00/src/dependencies/dmx/ESPDMX.cpp b/wled00/src/dependencies/dmx/ESPDMX.cpp index a80cad71c8..d1a31d832b 100644 --- a/wled00/src/dependencies/dmx/ESPDMX.cpp +++ b/wled00/src/dependencies/dmx/ESPDMX.cpp @@ -18,24 +18,8 @@ #include "ESPDMX.h" - -#define dmxMaxChannel 512 -#define defaultMax 32 - -#define DMXSPEED 250000 -#define DMXFORMAT SERIAL_8N2 -#define BREAKSPEED 83333 -#define BREAKFORMAT SERIAL_8N1 - -bool dmxStarted = false; -int sendPin = 2; //default on ESP8266 - -//DMX value array and size. Entry 0 will hold startbyte, so we need 512+1 elements -uint8_t dmxDataStore[dmxMaxChannel+1] = {}; -int channelSize; - - -void DMXESPSerial::init() { +void DMXESPSerial::init(int sendPin) { + this->sendPin = sendPin; channelSize = defaultMax; Serial1.begin(DMXSPEED); @@ -43,19 +27,6 @@ void DMXESPSerial::init() { dmxStarted = true; } -// Set up the DMX-Protocol -void DMXESPSerial::init(int chanQuant) { - - if (chanQuant > dmxMaxChannel || chanQuant <= 0) { - chanQuant = defaultMax; - } - - channelSize = chanQuant; - - Serial1.begin(DMXSPEED); - pinMode(sendPin, OUTPUT); - dmxStarted = true; -} // Function to read DMX data uint8_t DMXESPSerial::read(int Channel) { diff --git a/wled00/src/dependencies/dmx/ESPDMX.h b/wled00/src/dependencies/dmx/ESPDMX.h index 4585bdd26f..f3f5a42df5 100644 --- a/wled00/src/dependencies/dmx/ESPDMX.h +++ b/wled00/src/dependencies/dmx/ESPDMX.h @@ -16,16 +16,32 @@ #ifndef ESPDMX_h #define ESPDMX_h + +#define dmxMaxChannel 512 +#define defaultMax 32 + +#define DMXSPEED 250000 +#define DMXFORMAT SERIAL_8N2 +#define BREAKSPEED 83333 +#define BREAKFORMAT SERIAL_8N1 + // ---- Methods ---- class DMXESPSerial { public: - void init(); - void init(int MaxChan); + void init(int sendPin); uint8_t read(int Channel); void write(int channel, uint8_t value); void update(); void end(); +private: + int sendPin; + bool dmxStarted = false; + + //DMX value array and size. Entry 0 will hold startbyte, so we need 512+1 elements + uint8_t dmxDataStore[dmxMaxChannel+1] = {}; + int channelSize; + }; #endif From 7d3a673b619ba2b70f185e7b6fb405afe34f8ff1 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 10 Jan 2026 13:59:00 +0000 Subject: [PATCH 016/164] Remove lazy init --- wled00/src/dependencies/dmx/ESPDMX.cpp | 6 ------ 1 file changed, 6 deletions(-) diff --git a/wled00/src/dependencies/dmx/ESPDMX.cpp b/wled00/src/dependencies/dmx/ESPDMX.cpp index d1a31d832b..ed78bd01e3 100644 --- a/wled00/src/dependencies/dmx/ESPDMX.cpp +++ b/wled00/src/dependencies/dmx/ESPDMX.cpp @@ -30,8 +30,6 @@ void DMXESPSerial::init(int sendPin) { // Function to read DMX data uint8_t DMXESPSerial::read(int Channel) { - if (dmxStarted == false) init(); - if (Channel < 1) Channel = 1; if (Channel > dmxMaxChannel) Channel = dmxMaxChannel; return(dmxDataStore[Channel]); @@ -39,8 +37,6 @@ uint8_t DMXESPSerial::read(int Channel) { // Function to send DMX data void DMXESPSerial::write(int Channel, uint8_t value) { - if (dmxStarted == false) init(); - if (Channel < 1) Channel = 1; if (Channel > channelSize) Channel = channelSize; if (value < 0) value = 0; @@ -56,8 +52,6 @@ void DMXESPSerial::end() { } void DMXESPSerial::update() { - if (dmxStarted == false) init(); - //Send break digitalWrite(sendPin, HIGH); Serial1.begin(BREAKSPEED, BREAKFORMAT); From d336b97093b21b464aed086f65a163409b18137d Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 10 Jan 2026 14:45:45 +0000 Subject: [PATCH 017/164] set output pin --- wled00/dmx_output.cpp | 5 +++-- wled00/fcn_declare.h | 2 +- wled00/wled.cpp | 2 +- wled00/wled.h | 1 + 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/wled00/dmx_output.cpp b/wled00/dmx_output.cpp index f13bc80638..a51c80d66e 100644 --- a/wled00/dmx_output.cpp +++ b/wled00/dmx_output.cpp @@ -66,8 +66,9 @@ void handleDMXOutput() dmx.update(); // update the DMX bus } -void initDMXOutput() { - dmx.init(2); // set output pin and initialize DMX output +void initDMXOutput(int outputPin) { + if (outputPin < 1) return; + dmx.init(outputPin); // set output pin and initialize DMX output } #if !defined(ESP8266) diff --git a/wled00/fcn_declare.h b/wled00/fcn_declare.h index 84b5595df7..973e176d88 100644 --- a/wled00/fcn_declare.h +++ b/wled00/fcn_declare.h @@ -75,7 +75,7 @@ typedef struct WiFiConfig { } wifi_config; //dmx_output.cpp -void initDMXOutput(); +void initDMXOutput(int outputPin); void handleDMXOutput(); //dmx_input.cpp diff --git a/wled00/wled.cpp b/wled00/wled.cpp index c0ec92a916..28713d7254 100644 --- a/wled00/wled.cpp +++ b/wled00/wled.cpp @@ -517,7 +517,7 @@ void WLED::setup() } #endif #ifdef WLED_ENABLE_DMX - initDMXOutput(); + initDMXOutput(dmxOutputPin); #endif #ifdef WLED_ENABLE_DMX_INPUT dmxInput.init(dmxInputReceivePin, dmxInputTransmitPin, dmxInputEnablePin, dmxInputPort); diff --git a/wled00/wled.h b/wled00/wled.h index 6bfaae2f31..db6c198b8e 100644 --- a/wled00/wled.h +++ b/wled00/wled.h @@ -450,6 +450,7 @@ WLED_GLOBAL bool arlsDisableGammaCorrection _INIT(true); // activate if WLED_GLOBAL bool arlsForceMaxBri _INIT(false); // enable to force max brightness if source has very dark colors that would be black #ifdef WLED_ENABLE_DMX + WLED_GLOBAL int dmxOutputPin _INIT(2); WLED_GLOBAL uint16_t e131ProxyUniverse _INIT(0); // output this E1.31 (sACN) / ArtNet universe via MAX485 (0 = disabled) // dmx CONFIG WLED_GLOBAL byte DMXChannels _INIT(7); // number of channels per fixture From 576b62862c968605bae310043269042e5c1beb6c Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 10 Jan 2026 15:07:42 +0000 Subject: [PATCH 018/164] Allow runtime config of DMX output pin --- wled00/cfg.cpp | 6 ++++++ wled00/data/settings_sync.htm | 10 +++++++++- wled00/set.cpp | 4 +++- wled00/xml.cpp | 1 + 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/wled00/cfg.cpp b/wled00/cfg.cpp index 28b63ea65c..31e09348e9 100644 --- a/wled00/cfg.cpp +++ b/wled00/cfg.cpp @@ -597,6 +597,9 @@ bool deserializeConfig(JsonObject doc, bool fromFS) { tdd = if_live[F("timeout")] | -1; if (tdd >= 0) realtimeTimeoutMs = tdd * 100; +#ifdef WLED_ENABLE_DMX + CJSON(dmxOutputPin, if_live_dmx[F("outputPin")]); +#endif #ifdef WLED_ENABLE_DMX_INPUT CJSON(dmxInputTransmitPin, if_live_dmx[F("inputRxPin")]); CJSON(dmxInputReceivePin, if_live_dmx[F("inputTxPin")]); @@ -1118,6 +1121,9 @@ void serializeConfig(JsonObject root) { if_live_dmx[F("addr")] = DMXAddress; if_live_dmx[F("dss")] = DMXSegmentSpacing; if_live_dmx["mode"] = DMXMode; + #ifdef WLED_ENABLE_DMX + if_live_dmx[F("dmxOutputPin")] = dmxOutputPin; + #endif #ifdef WLED_ENABLE_DMX_INPUT if_live_dmx[F("inputRxPin")] = dmxInputTransmitPin; if_live_dmx[F("inputTxPin")] = dmxInputReceivePin; diff --git a/wled00/data/settings_sync.htm b/wled00/data/settings_sync.htm index 73e4d9a268..28624030fe 100644 --- a/wled00/data/settings_sync.htm +++ b/wled00/data/settings_sync.htm @@ -50,6 +50,10 @@ } function hideDMXInput(){gId("dmxInput").style.display="none";} function hideNoDMXInput(){gId("dmxInputOff").style.display="none";} + function hideNoDMX(){ + gId("dmxOnOff2").style.display="none"; + gId("dmxOutput").style.display="inline"; + } @@ -166,7 +170,11 @@

Wired DMX Input Pins

DMX TX: DI
DMX Enable: RE+DE
DMX Port:
-
+ +

This firmware build does not include DMX Input support.
diff --git a/wled00/set.cpp b/wled00/set.cpp index db8b30bac8..7ebd593ab7 100644 --- a/wled00/set.cpp +++ b/wled00/set.cpp @@ -438,7 +438,9 @@ void handleSettingsSet(AsyncWebServerRequest *request, byte subPage) arlsDisableGammaCorrection = request->hasArg(F("RG")); t = request->arg(F("WO")).toInt(); if (t >= -255 && t <= 255) arlsOffset = t; - +#ifdef WLED_ENABLE_DMX + dmxOutputPin = request->arg(F("IDMO")).toInt(); +#endif #ifdef WLED_ENABLE_DMX_INPUT dmxInputTransmitPin = request->arg(F("IDMT")).toInt(); dmxInputReceivePin = request->arg(F("IDMR")).toInt(); diff --git a/wled00/xml.cpp b/wled00/xml.cpp index 194256d82e..5351e5ea89 100644 --- a/wled00/xml.cpp +++ b/wled00/xml.cpp @@ -460,6 +460,7 @@ void getSettingsJS(byte subPage, Print& settingsScript) printSetFormValue(settingsScript,PSTR("EU"),e131Universe); #ifdef WLED_ENABLE_DMX settingsScript.print(SET_F("hideNoDMX();")); // hide "not compiled in" message + printSetFormValue(settingsScript,SET_F("IDMO"), dmxOutputPin); #endif #ifndef WLED_ENABLE_DMX_INPUT settingsScript.print(SET_F("hideDMXInput();")); // hide "dmx input" settings From 2f874cf9ac974dcd7c6f1501bb1c93a1b3fe77a7 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 10 Jan 2026 15:08:17 +0000 Subject: [PATCH 019/164] remove redundant code --- wled00/src/dependencies/dmx/ESPDMX.cpp | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/wled00/src/dependencies/dmx/ESPDMX.cpp b/wled00/src/dependencies/dmx/ESPDMX.cpp index ed78bd01e3..e6c9193d30 100644 --- a/wled00/src/dependencies/dmx/ESPDMX.cpp +++ b/wled00/src/dependencies/dmx/ESPDMX.cpp @@ -27,20 +27,10 @@ void DMXESPSerial::init(int sendPin) { dmxStarted = true; } - -// Function to read DMX data -uint8_t DMXESPSerial::read(int Channel) { - if (Channel < 1) Channel = 1; - if (Channel > dmxMaxChannel) Channel = dmxMaxChannel; - return(dmxDataStore[Channel]); -} - // Function to send DMX data void DMXESPSerial::write(int Channel, uint8_t value) { if (Channel < 1) Channel = 1; if (Channel > channelSize) Channel = channelSize; - if (value < 0) value = 0; - if (value > 255) value = 255; dmxDataStore[Channel] = value; } From 034e4f542d7e9d531b167f60ef61d5fb966ce600 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 10 Jan 2026 15:12:00 +0000 Subject: [PATCH 020/164] register pin with PinManager --- wled00/dmx_output.cpp | 6 ++++++ wled00/pin_manager.h | 1 + 2 files changed, 7 insertions(+) diff --git a/wled00/dmx_output.cpp b/wled00/dmx_output.cpp index a51c80d66e..7b831b4963 100644 --- a/wled00/dmx_output.cpp +++ b/wled00/dmx_output.cpp @@ -68,6 +68,12 @@ void handleDMXOutput() void initDMXOutput(int outputPin) { if (outputPin < 1) return; + const bool pinAllocated = PinManager::allocatePin(outputPin, true, PinOwner::DMX_OUTPUT); + if (!pinAllocated) { + DEBUG_PRINTF("DMXOutput: Error: Failed to allocate pins for DMX_OUTPUT. Pin already in use:\n"); + DEBUG_PRINTF("In use by: %s\n", PinManager::getPinOwner(outputPin)); + return; + } dmx.init(outputPin); // set output pin and initialize DMX output } diff --git a/wled00/pin_manager.h b/wled00/pin_manager.h index a488d24f70..98a9c1c435 100644 --- a/wled00/pin_manager.h +++ b/wled00/pin_manager.h @@ -41,6 +41,7 @@ enum struct PinOwner : uint8_t { HW_SPI = 0x8C, // 'SPI' == hardware (V)SPI pins (13,14&15 on ESP8266, 5,18&23 on ESP32) DMX_INPUT = 0x8D, // 'DMX_INPUT' == DMX input via serial HUB75 = 0x8E, // 'Hub75' == Hub75 driver + DMX_OUTPUT = 0x8F, // 'DMX_OUTPUT' == DMX output via serial // Use UserMod IDs from const.h here UM_Unspecified = USERMOD_ID_UNSPECIFIED, // 0x01 UM_Example = USERMOD_ID_EXAMPLE, // 0x02 // Usermod "usermod_v2_example.h" From 834c285018c6635507c2c0a6b0f8fd94b31dece2 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 10 Jan 2026 15:17:52 +0000 Subject: [PATCH 021/164] default output pin to -1 --- wled00/wled.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wled00/wled.h b/wled00/wled.h index db6c198b8e..190b233a24 100644 --- a/wled00/wled.h +++ b/wled00/wled.h @@ -450,7 +450,7 @@ WLED_GLOBAL bool arlsDisableGammaCorrection _INIT(true); // activate if WLED_GLOBAL bool arlsForceMaxBri _INIT(false); // enable to force max brightness if source has very dark colors that would be black #ifdef WLED_ENABLE_DMX - WLED_GLOBAL int dmxOutputPin _INIT(2); + WLED_GLOBAL int dmxOutputPin _INIT(-1); // DMX output pin (use -1 for disabled) WLED_GLOBAL uint16_t e131ProxyUniverse _INIT(0); // output this E1.31 (sACN) / ArtNet universe via MAX485 (0 = disabled) // dmx CONFIG WLED_GLOBAL byte DMXChannels _INIT(7); // number of channels per fixture From 22aff94809aa05020a99ec0b5e67ab5438043a61 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 10 Jan 2026 15:19:05 +0000 Subject: [PATCH 022/164] move dmx definition back to original location --- wled00/wled.h | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/wled00/wled.h b/wled00/wled.h index 190b233a24..d2da6c7c9f 100644 --- a/wled00/wled.h +++ b/wled00/wled.h @@ -450,6 +450,11 @@ WLED_GLOBAL bool arlsDisableGammaCorrection _INIT(true); // activate if WLED_GLOBAL bool arlsForceMaxBri _INIT(false); // enable to force max brightness if source has very dark colors that would be black #ifdef WLED_ENABLE_DMX + #if defined(ESP8266) + WLED_GLOBAL DMXESPSerial dmx; + #else + WLED_GLOBAL DMXOutput dmx; + #endif WLED_GLOBAL int dmxOutputPin _INIT(-1); // DMX output pin (use -1 for disabled) WLED_GLOBAL uint16_t e131ProxyUniverse _INIT(0); // output this E1.31 (sACN) / ArtNet universe via MAX485 (0 = disabled) // dmx CONFIG @@ -459,11 +464,6 @@ WLED_GLOBAL bool arlsForceMaxBri _INIT(false); // enable to f WLED_GLOBAL uint16_t DMXGap _INIT(10); // gap between the fixtures. makes addressing easier because you don't have to memorize odd numbers when climbing up onto a rig. WLED_GLOBAL uint16_t DMXStart _INIT(10); // start address of the first fixture WLED_GLOBAL uint16_t DMXStartLED _INIT(0); // LED from which DMX fixtures start - #if defined(ESP8266) - WLED_GLOBAL DMXESPSerial dmx; - #else - WLED_GLOBAL DMXOutput dmx; - #endif #endif #ifdef WLED_ENABLE_DMX_INPUT WLED_GLOBAL int dmxInputTransmitPin _INIT(0); From 585d1746c8bdaa5020a7493e42a29febfc44bfa2 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 10 Jan 2026 15:21:05 +0000 Subject: [PATCH 023/164] minor cleanup --- wled00/dmx_output.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wled00/dmx_output.cpp b/wled00/dmx_output.cpp index 7b831b4963..bb4b07b945 100644 --- a/wled00/dmx_output.cpp +++ b/wled00/dmx_output.cpp @@ -70,7 +70,7 @@ void initDMXOutput(int outputPin) { if (outputPin < 1) return; const bool pinAllocated = PinManager::allocatePin(outputPin, true, PinOwner::DMX_OUTPUT); if (!pinAllocated) { - DEBUG_PRINTF("DMXOutput: Error: Failed to allocate pins for DMX_OUTPUT. Pin already in use:\n"); + DEBUG_PRINTF("DMXOutput: Error: Failed to allocate pin for DMX_OUTPUT. Pin already in use:\n"); DEBUG_PRINTF("In use by: %s\n", PinManager::getPinOwner(outputPin)); return; } @@ -78,11 +78,11 @@ void initDMXOutput(int outputPin) { } #if !defined(ESP8266) -void DMXOutput::init(uint8_t txPin) +void DMXOutput::init(uint8_t outputPin) { dmx_config_t config = DMX_CONFIG_DEFAULT; dmx_driver_install(dmxPort, &config, DMX_INTR_FLAGS_DEFAULT); - dmx_set_pin(dmxPort, txPin, -1, -1); + dmx_set_pin(dmxPort, outputPin, -1, -1); } void DMXOutput::write(uint8_t channel, uint8_t value) { From 7a910c8899cc8fec45c8cb7824e18dcbf8bc4ccf Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 10 Jan 2026 15:21:58 +0000 Subject: [PATCH 024/164] minor cleanup, code style --- wled00/dmx_output.cpp | 9 +++------ wled00/dmx_output.h | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/wled00/dmx_output.cpp b/wled00/dmx_output.cpp index bb4b07b945..f1457dfb59 100644 --- a/wled00/dmx_output.cpp +++ b/wled00/dmx_output.cpp @@ -78,18 +78,15 @@ void initDMXOutput(int outputPin) { } #if !defined(ESP8266) -void DMXOutput::init(uint8_t outputPin) -{ +void DMXOutput::init(uint8_t outputPin) { dmx_config_t config = DMX_CONFIG_DEFAULT; dmx_driver_install(dmxPort, &config, DMX_INTR_FLAGS_DEFAULT); dmx_set_pin(dmxPort, outputPin, -1, -1); } -void DMXOutput::write(uint8_t channel, uint8_t value) -{ +void DMXOutput::write(uint8_t channel, uint8_t value) { dmxdata[channel] = value; } -void DMXOutput::update() -{ +void DMXOutput::update() { dmx_send(dmxPort, DMX_PACKET_SIZE); } #endif diff --git a/wled00/dmx_output.h b/wled00/dmx_output.h index 59c14084d7..e292634cb3 100644 --- a/wled00/dmx_output.h +++ b/wled00/dmx_output.h @@ -17,7 +17,7 @@ class DMXOutput { public: - void init(uint8_t txPin); + void init(uint8_t outputPin); void write(uint8_t channel, uint8_t value); void update(); private: From 5cdca5829629b9ed35194d9645277af36c868e9d Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sun, 11 Jan 2026 00:15:36 +0000 Subject: [PATCH 025/164] Fix naming --- wled00/cfg.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wled00/cfg.cpp b/wled00/cfg.cpp index 31e09348e9..4f00b53b8f 100644 --- a/wled00/cfg.cpp +++ b/wled00/cfg.cpp @@ -598,7 +598,7 @@ bool deserializeConfig(JsonObject doc, bool fromFS) { if (tdd >= 0) realtimeTimeoutMs = tdd * 100; #ifdef WLED_ENABLE_DMX - CJSON(dmxOutputPin, if_live_dmx[F("outputPin")]); + CJSON(dmxOutputPin, if_live_dmx[F("dmxOutputPin")]); #endif #ifdef WLED_ENABLE_DMX_INPUT CJSON(dmxInputTransmitPin, if_live_dmx[F("inputRxPin")]); From 48168edb4c05514bdec76d0f8fabdc01082cb7df Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sun, 11 Jan 2026 00:19:52 +0000 Subject: [PATCH 026/164] write data --- wled00/dmx_output.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/wled00/dmx_output.cpp b/wled00/dmx_output.cpp index f1457dfb59..2be3371ee7 100644 --- a/wled00/dmx_output.cpp +++ b/wled00/dmx_output.cpp @@ -87,6 +87,7 @@ void DMXOutput::write(uint8_t channel, uint8_t value) { dmxdata[channel] = value; } void DMXOutput::update() { + dmx_write(dmxPort, dmxdata, DMX_PACKET_SIZE); dmx_send(dmxPort, DMX_PACKET_SIZE); } #endif From d1d9dec40234b81ec4ec79da43d4eb36cb1e31cf Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Jan 2026 07:55:05 +0100 Subject: [PATCH 027/164] Fix gamma correction for color not enabled on fresh install (#5225) Co-authored-by: DedeHai <6280424+DedeHai@users.noreply.github.com> --- wled00/cfg.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wled00/cfg.cpp b/wled00/cfg.cpp index 28b63ea65c..ff491faffd 100644 --- a/wled00/cfg.cpp +++ b/wled00/cfg.cpp @@ -507,8 +507,8 @@ bool deserializeConfig(JsonObject doc, bool fromFS) { CJSON(strip.autoSegments, light[F("aseg")]); CJSON(gammaCorrectVal, light["gc"]["val"]); // default 2.2 - float light_gc_bri = light["gc"]["bri"]; - float light_gc_col = light["gc"]["col"]; + float light_gc_bri = light["gc"]["bri"] | 1.0f; // default to 1.0 (false) + float light_gc_col = light["gc"]["col"] | gammaCorrectVal; // default to gammaCorrectVal (true) if (light_gc_bri > 1.0f) gammaCorrectBri = true; else gammaCorrectBri = false; if (light_gc_col > 1.0f) gammaCorrectCol = true; From ba5cf9cd3cc6c3fd0e3adf35dcd924e87bd40fb1 Mon Sep 17 00:00:00 2001 From: Frank <91616163+softhack007@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:47:40 +0100 Subject: [PATCH 028/164] night build script updates Fixes some deprecation warnings during nightly build runs. Already tested in WLED-MM. * upgrade action-github-changelog-generator to 2.4 * decode changelog into a temporary file (needed for changelog-generator 2.4) * renamed exclude-labels (deprecated); added some more tags for filtering --- .github/workflows/nightly.yml | 7 ++++--- .github/workflows/release.yml | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index ce35140b70..60dd4d5d39 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -23,12 +23,13 @@ jobs: run: ls -la - name: "✏️ Generate release changelog" id: changelog - uses: janheinrichmerker/action-github-changelog-generator@v2.3 + uses: janheinrichmerker/action-github-changelog-generator@v2.4 with: token: ${{ secrets.GITHUB_TOKEN }} sinceTag: v0.15.0 + output: CHANGELOG_NIGHTLY.md # Exclude issues that were closed without resolution from changelog - exclude-labels: 'stale,wontfix,duplicate,invalid' + excludeLabels: 'stale,wontfix,duplicate,invalid,question,use-as-is,not_planned' - name: Update Nightly Release uses: andelf/nightly-release@main env: @@ -37,7 +38,7 @@ jobs: tag_name: nightly name: 'Nightly Release $$' prerelease: true - body: ${{ steps.changelog.outputs.changelog }} + body_path: CHANGELOG_NIGHTLY.md files: | *.bin *.bin.gz diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3b902dd94..1fcea1ecca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,13 +20,13 @@ jobs: merge-multiple: true - name: "✏️ Generate release changelog" id: changelog - uses: janheinrichmerker/action-github-changelog-generator@v2.3 + uses: janheinrichmerker/action-github-changelog-generator@v2.4 with: token: ${{ secrets.GITHUB_TOKEN }} sinceTag: v0.15.0 maxIssues: 500 # Exclude issues that were closed without resolution from changelog - exclude-labels: 'stale,wontfix,duplicate,invalid' + excludeLabels: 'stale,wontfix,duplicate,invalid,question,use-as-is,not_planned' - name: Create draft release uses: softprops/action-gh-release@v1 with: From 8a3cb46007d40754a023238d636380764abfc9b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20M=C3=B6hle?= <91616163+softhack007@users.noreply.github.com> Date: Sat, 17 Jan 2026 00:52:51 +0100 Subject: [PATCH 029/164] Update contributing guidelines for PR management Clarify that multiple commits can be added to an open PR and warn against force-pushing. --- CONTRIBUTING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d73ba5b7d9..c034e7553a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,8 @@ While the PR is open - and under review by maintainers - you may be asked to mod You can simply update your own branch, and push changes in response to reviewer recommendations. Github will pick up the changes so your PR stays up-to-date. +You don't need to provide us with a single commit 'squashed' PR; you can simply add commits while your pull request (PR) is open. + > [!CAUTION] > Do not use "force-push" while your PR is open! > It has many subtle and unexpected consequences on our github reposistory. From 45acb44a3657f460150fc3fb977c8daab2a76205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20M=C3=B6hle?= <91616163+softhack007@users.noreply.github.com> Date: Sat, 17 Jan 2026 00:53:31 +0100 Subject: [PATCH 030/164] Fix typo in CONTRIBUTING.md --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c034e7553a..3812c4719c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ While the PR is open - and under review by maintainers - you may be asked to mod You can simply update your own branch, and push changes in response to reviewer recommendations. Github will pick up the changes so your PR stays up-to-date. -You don't need to provide us with a single commit 'squashed' PR; you can simply add commits while your pull request (PR) is open. +You don't need to provide us with a single-commit 'squashed' PR; you can simply add commits while your pull request (PR) is open. > [!CAUTION] > Do not use "force-push" while your PR is open! From 7a9e7f9c4178a21fdd3c570b11c466ff6275ee7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20M=C3=B6hle?= <91616163+softhack007@users.noreply.github.com> Date: Sat, 17 Jan 2026 01:37:17 +0100 Subject: [PATCH 031/164] Enhance contributing guidelines for pull requests Added guidelines for creating pull requests from a fork. --- CONTRIBUTING.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3812c4719c..77cb51e6ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,13 @@ Here are a few suggestions to make it easier for you to contribute! +### PR from a branch in your own fork +Start your pull request (PR) in a branch of your own fork. Don't make a PR directly from your main branch. +This lets you update your PR if needed, while you can work on other tasks in 'main' or in other branches. + +Tip: When viewing a file in `wled/WLED`, click on the "pen" icon and start making changes. +When you chose to 'Commit changes', GitHub will automatically create a PR from your fork. + ### Describe your PR Please add a description of your proposed code changes. It does not need to be an exhaustive essay, however a PR with no description or just a few words might not get accepted, simply because very basic information is missing. From 10df03e5648d6d7f4051e2fc50c25ed187d9d58d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20M=C3=B6hle?= <91616163+softhack007@users.noreply.github.com> Date: Sat, 17 Jan 2026 01:39:06 +0100 Subject: [PATCH 032/164] reorder sections for clarity --- CONTRIBUTING.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 77cb51e6ce..adeb29e241 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,10 @@ This lets you update your PR if needed, while you can work on other tasks in 'ma Tip: When viewing a file in `wled/WLED`, click on the "pen" icon and start making changes. When you chose to 'Commit changes', GitHub will automatically create a PR from your fork. +### Target branch for pull requests + +Please make all PRs against the `main` branch. + ### Describe your PR Please add a description of your proposed code changes. It does not need to be an exhaustive essay, however a PR with no description or just a few words might not get accepted, simply because very basic information is missing. @@ -19,9 +23,6 @@ A good description helps us to review and understand your proposed changes. For * testing you performed, known limitations, open ends you possibly could not solve. * any areas where you like to get help from an experienced maintainer (yes WLED has become big 😉) -### Target branch for pull requests - -Please make all PRs against the `main` branch. ### Updating your code While the PR is open - and under review by maintainers - you may be asked to modify your PR source code. From a024935c39503cb8aeb79fa8bb5dc38b15b86e87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20M=C3=B6hle?= <91616163+softhack007@users.noreply.github.com> Date: Sat, 17 Jan 2026 01:53:32 +0100 Subject: [PATCH 033/164] adding a screenshot for simple PR creation Added a tip for creating pull requests and forking in one click. --- CONTRIBUTING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index adeb29e241..20f4e25844 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,6 +6,8 @@ Here are a few suggestions to make it easier for you to contribute! Start your pull request (PR) in a branch of your own fork. Don't make a PR directly from your main branch. This lets you update your PR if needed, while you can work on other tasks in 'main' or in other branches. +image: fork and edit + Tip: When viewing a file in `wled/WLED`, click on the "pen" icon and start making changes. When you chose to 'Commit changes', GitHub will automatically create a PR from your fork. From 1773f61ded776100ed003069537be0d3f16abdac Mon Sep 17 00:00:00 2001 From: Damian Schneider Date: Sun, 18 Jan 2026 08:37:56 +0100 Subject: [PATCH 034/164] bugfix: do not disable "unused" pin type - um_p[] always contains "-1" as a placeholder --- wled00/data/settings_leds.htm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wled00/data/settings_leds.htm b/wled00/data/settings_leds.htm index f1ed130f2e..8de233ca77 100644 --- a/wled00/data/settings_leds.htm +++ b/wled00/data/settings_leds.htm @@ -762,7 +762,7 @@ if (d.ro_gpio.includes(j)) txt += " (R/O)"; let opt = addOption(sel, txt, j); if (j === v) opt.selected = true; // this is "our" pin - else if (d.um_p.includes(j)) opt.disabled = true; // someone else's pin + else if (d.um_p.includes(j) && j > -1) opt.disabled = true; // someone else's pin } } } From af8db57f022fd482a02d80fedb5ccffb6357d442 Mon Sep 17 00:00:00 2001 From: Damian Schneider Date: Mon, 19 Jan 2026 19:33:06 +0100 Subject: [PATCH 035/164] Fix for cfg exceeding LED limit (#4939) * Safety Checks for UI, fix for cfg exceeding LED limit * improvements to low heap check * add `isPlaceholder()` to bus, some fixes * remove `disableForceReconnect` for better future implementation * add "glitch gating" for C3 and check heapy every 5 seconds instead of every secondd * replace magic number with the correct define, more robust bus defer by look-ahead In the event that a Bus fails to initialize, or the memory validation fails, keep the configuration around so the settings contents don't change out from under the user. --------- Co-authored-by: Will Miles --- wled00/FX_fcn.cpp | 27 +++++++++++--------- wled00/bus_manager.cpp | 34 +++++++++++++++++++++---- wled00/bus_manager.h | 38 ++++++++++++++++++++++++++-- wled00/cfg.cpp | 2 +- wled00/data/index.js | 2 +- wled00/wled.cpp | 56 +++++++++++++++++++++++++++++++----------- wled00/xml.cpp | 2 +- 7 files changed, 126 insertions(+), 35 deletions(-) diff --git a/wled00/FX_fcn.cpp b/wled00/FX_fcn.cpp index f72540a54e..8834d89fa2 100644 --- a/wled00/FX_fcn.cpp +++ b/wled00/FX_fcn.cpp @@ -1187,9 +1187,9 @@ void WS2812FX::finalizeInit() { // create buses/outputs unsigned mem = 0; unsigned maxI2S = 0; - for (const auto &bus : busConfigs) { - unsigned memB = bus.memUsage(Bus::isDigital(bus.type) && !Bus::is2Pin(bus.type) ? digitalCount++ : 0); // does not include DMA/RMT buffer - mem += memB; + for (auto bus : busConfigs) { + bool use_placeholder = false; + unsigned busMemUsage = bus.memUsage(Bus::isDigital(bus.type) && !Bus::is2Pin(bus.type) ? digitalCount++ : 0); // does not include DMA/RMT buffer // estimate maximum I2S memory usage (only relevant for digital non-2pin busses) #if !defined(CONFIG_IDF_TARGET_ESP32C3) && !defined(ESP8266) #if defined(CONFIG_IDF_TARGET_ESP32) || defined(CONFIG_IDF_TARGET_ESP32S3) @@ -1209,13 +1209,14 @@ void WS2812FX::finalizeInit() { if (i2sCommonSize > maxI2S) maxI2S = i2sCommonSize; } #endif - if (mem + maxI2S <= MAX_LED_MEMORY) { - BusManager::add(bus); - DEBUG_PRINTF_P(PSTR("Bus memory: %uB\n"), memB); - } else { - errorFlag = ERR_NORAM_PX; // alert UI - DEBUG_PRINTF_P(PSTR("Out of LED memory! Bus %d (%d) #%u not created."), (int)bus.type, (int)bus.count, digitalCount); - break; + if (mem + busMemUsage + maxI2S > MAX_LED_MEMORY) { + DEBUG_PRINTF_P(PSTR("Bus %d with %d LEDS memory usage exceeds limit\n"), (int)bus.type, bus.count); + errorFlag = ERR_NORAM; // alert UI TODO: make this a distinct error: not enough memory for bus + use_placeholder = true; + } + if (BusManager::add(bus, use_placeholder) != -1) { + mem += BusManager::busses.back()->getBusSize(); + if (Bus::isDigital(bus.type) && !Bus::is2Pin(bus.type) && BusManager::busses.back()->isPlaceholder()) digitalCount--; // remove placeholder from digital count } } DEBUG_PRINTF_P(PSTR("LED buffer size: %uB/%uB\n"), mem + maxI2S, BusManager::memUsage()); @@ -1824,6 +1825,10 @@ void WS2812FX::resetSegments() { if (isServicing()) return; _segments.clear(); // destructs all Segment as part of clearing _segments.emplace_back(0, isMatrix ? Segment::maxWidth : _length, 0, isMatrix ? Segment::maxHeight : 1); + if(_segments.size() == 0) { + _segments.emplace_back(); // if out of heap, create a default segment + errorFlag = ERR_NORAM_PX; + } _segments.shrink_to_fit(); // just in case ... _mainSegment = 0; } @@ -1846,7 +1851,7 @@ void WS2812FX::makeAutoSegments(bool forceReset) { for (size_t i = s; i < BusManager::getNumBusses(); i++) { const Bus *bus = BusManager::getBus(i); - if (!bus || !bus->isOk()) break; + if (!bus) break; segStarts[s] = bus->getStart(); segStops[s] = segStarts[s] + bus->getLength(); diff --git a/wled00/bus_manager.cpp b/wled00/bus_manager.cpp index 5cc0eb2c95..a73146ec0f 100644 --- a/wled00/bus_manager.cpp +++ b/wled00/bus_manager.cpp @@ -1105,6 +1105,26 @@ size_t BusHub75Matrix::getPins(uint8_t* pinArray) const { #endif // *************************************************************************** +BusPlaceholder::BusPlaceholder(const BusConfig &bc) +: Bus(bc.type, bc.start, bc.autoWhite, bc.count, bc.reversed, bc.refreshReq) +, _colorOrder(bc.colorOrder) +, _skipAmount(bc.skipAmount) +, _frequency(bc.frequency) +, _milliAmpsPerLed(bc.milliAmpsPerLed) +, _milliAmpsMax(bc.milliAmpsMax) +, _text(bc.text) +{ + memcpy(_pins, bc.pins, sizeof(_pins)); +} + +size_t BusPlaceholder::getPins(uint8_t* pinArray) const { + size_t nPins = Bus::getNumberOfPins(_type); + if (pinArray) { + for (size_t i = 0; i < nPins; i++) pinArray[i] = _pins[i]; + } + return nPins; +} + //utility to get the approx. memory usage of a given BusConfig size_t BusConfig::memUsage(unsigned nr) const { if (Bus::isVirtual(type)) { @@ -1148,7 +1168,7 @@ size_t BusManager::memUsage() { return size + maxI2S; } -int BusManager::add(const BusConfig &bc) { +int BusManager::add(const BusConfig &bc, bool placeholder) { DEBUGBUS_PRINTF_P(PSTR("Bus: Adding bus (p:%d v:%d)\n"), getNumBusses(), getNumVirtualBusses()); unsigned digital = 0; unsigned analog = 0; @@ -1158,8 +1178,12 @@ int BusManager::add(const BusConfig &bc) { if (bus->isDigital() && !bus->is2Pin()) digital++; if (bus->is2Pin()) twoPin++; } - if (digital > WLED_MAX_DIGITAL_CHANNELS || analog > WLED_MAX_ANALOG_CHANNELS) return -1; - if (Bus::isVirtual(bc.type)) { + digital += (Bus::isDigital(bc.type) && !Bus::is2Pin(bc.type)); + analog += (Bus::isPWM(bc.type) ? Bus::numPWMPins(bc.type) : 0); + if (digital > WLED_MAX_DIGITAL_CHANNELS || analog > WLED_MAX_ANALOG_CHANNELS) placeholder = true; // TODO: add errorFlag here + if (placeholder) { + busses.push_back(make_unique(bc)); + } else if (Bus::isVirtual(bc.type)) { busses.push_back(make_unique(bc)); #ifdef WLED_ENABLE_HUB75MATRIX } else if (Bus::isHub75(bc.type)) { @@ -1266,7 +1290,7 @@ void BusManager::on() { if (PinManager::getPinOwner(LED_BUILTIN) == PinOwner::BusDigital) { for (auto &bus : busses) { uint8_t pins[2] = {255,255}; - if (bus->isDigital() && bus->getPins(pins)) { + if (bus->isDigital() && bus->getPins(pins) && bus->isOk()) { if (pins[0] == LED_BUILTIN || pins[1] == LED_BUILTIN) { BusDigital &b = static_cast(*bus); b.begin(); @@ -1361,7 +1385,7 @@ void BusManager::initializeABL() { _useABL = true; // at least one bus has ABL set uint32_t ESPshare = MA_FOR_ESP / numABLbuses; // share of ESP current per ABL bus for (auto &bus : busses) { - if (bus->isDigital()) { + if (bus->isDigital() && bus->isOk()) { BusDigital &busd = static_cast(*bus); uint32_t busLength = busd.getLength(); uint32_t busDemand = busLength * busd.getLEDCurrent(); diff --git a/wled00/bus_manager.h b/wled00/bus_manager.h index 87d39fe34b..db49301994 100644 --- a/wled00/bus_manager.h +++ b/wled00/bus_manager.h @@ -133,7 +133,7 @@ class Bus { virtual void setColorOrder(uint8_t co) {} virtual uint32_t getPixelColor(unsigned pix) const { return 0; } virtual size_t getPins(uint8_t* pinArray = nullptr) const { return 0; } - virtual uint16_t getLength() const { return isOk() ? _len : 0; } + virtual uint16_t getLength() const { return _len; } virtual uint8_t getColorOrder() const { return COL_ORDER_RGB; } virtual unsigned skippedLeds() const { return 0; } virtual uint16_t getFrequency() const { return 0U; } @@ -152,6 +152,7 @@ class Bus { inline bool isPWM() const { return isPWM(_type); } inline bool isVirtual() const { return isVirtual(_type); } inline bool is16bit() const { return is16bit(_type); } + virtual bool isPlaceholder() const { return false; } inline bool mustRefresh() const { return mustRefresh(_type); } inline void setReversed(bool reversed) { _reversed = reversed; } inline void setStart(uint16_t start) { _start = start; } @@ -372,6 +373,39 @@ class BusNetwork : public Bus { #endif }; +// Placeholder for buses that we can't construct due to resource limitations +// This preserves the configuration so it can be read back to the settings pages +// Function calls "mimic" the replaced bus, isPlaceholder() can be used to identify a placeholder +class BusPlaceholder : public Bus { + public: + BusPlaceholder(const BusConfig &bc); + + // Actual calls are stubbed out + void setPixelColor(unsigned pix, uint32_t c) override {}; + void show() override {}; + + // Accessors + uint8_t getColorOrder() const override { return _colorOrder; } + size_t getPins(uint8_t* pinArray) const override; + unsigned skippedLeds() const override { return _skipAmount; } + uint16_t getFrequency() const override { return _frequency; } + uint16_t getLEDCurrent() const override { return _milliAmpsPerLed; } + uint16_t getMaxCurrent() const override { return _milliAmpsMax; } + const String getCustomText() const override { return _text; } + bool isPlaceholder() const override { return true; } + + size_t getBusSize() const override { return sizeof(BusPlaceholder); } + + private: + uint8_t _colorOrder; + uint8_t _skipAmount; + uint8_t _pins[OUTPUT_MAX_PINS]; + uint16_t _frequency; + uint8_t _milliAmpsPerLed; + uint16_t _milliAmpsMax; + String _text; +}; + #ifdef WLED_ENABLE_HUB75MATRIX class BusHub75Matrix : public Bus { public: @@ -507,7 +541,7 @@ namespace BusManager { //do not call this method from system context (network callback) void removeAll(); - int add(const BusConfig &bc); + int add(const BusConfig &bc, bool placeholder); void on(); void off(); diff --git a/wled00/cfg.cpp b/wled00/cfg.cpp index ff491faffd..75854751ea 100644 --- a/wled00/cfg.cpp +++ b/wled00/cfg.cpp @@ -958,7 +958,7 @@ void serializeConfig(JsonObject root) { for (size_t s = 0; s < BusManager::getNumBusses(); s++) { DEBUG_PRINTF_P(PSTR("Cfg: Saving bus #%u\n"), s); const Bus *bus = BusManager::getBus(s); - if (!bus || !bus->isOk()) break; + if (!bus) break; // Memory corruption, iterator invalid DEBUG_PRINTF_P(PSTR(" (%d-%d, type:%d, CO:%d, rev:%d, skip:%d, AW:%d kHz:%d, mA:%d/%d)\n"), (int)bus->getStart(), (int)(bus->getStart()+bus->getLength()), (int)(bus->getType() & 0x7F), diff --git a/wled00/data/index.js b/wled00/data/index.js index 7cb989d062..df819a150a 100644 --- a/wled00/data/index.js +++ b/wled00/data/index.js @@ -3455,4 +3455,4 @@ _C.addEventListener('touchstart', lock, false); _C.addEventListener('mouseout', move, false); _C.addEventListener('mouseup', move, false); -_C.addEventListener('touchend', move, false); +_C.addEventListener('touchend', move, false); \ No newline at end of file diff --git a/wled00/wled.cpp b/wled00/wled.cpp index c0ec92a916..bb1befcdd6 100644 --- a/wled00/wled.cpp +++ b/wled00/wled.cpp @@ -40,8 +40,8 @@ void WLED::reset() void WLED::loop() { - static uint32_t lastHeap = UINT32_MAX; - static unsigned long heapTime = 0; + static uint16_t heapTime = 0; // timestamp for heap check + static uint8_t heapDanger = 0; // counter for consecutive low-heap readings #ifdef WLED_DEBUG static unsigned long lastRun = 0; unsigned long loopMillis = millis(); @@ -169,19 +169,47 @@ void WLED::loop() correctPIN = false; } - // reconnect WiFi to clear stale allocations if heap gets too low - if (millis() - heapTime > 15000) { - uint32_t heap = getFreeHeapSize(); - if (heap < MIN_HEAP_SIZE && lastHeap < MIN_HEAP_SIZE) { - DEBUG_PRINTF_P(PSTR("Heap too low! %u\n"), heap); - strip.resetSegments(); // remove all but one segments from memory - if (!Update.isRunning()) forceReconnect = true; - } else if (heap < MIN_HEAP_SIZE) { - DEBUG_PRINTLN(F("Heap low, purging segments.")); - strip.purgeSegments(); + // free memory and reconnect WiFi to clear stale allocations if heap is too low for too long, check once every 5s + if ((uint16_t)(millis() - heapTime) > 5000) { + #ifdef ESP8266 + uint32_t heap = getFreeHeapSize(); // ESP8266 needs ~8k of free heap for UI to work properly + #else + #ifdef CONFIG_IDF_TARGET_ESP32C3 + // calling getContiguousFreeHeap() during led update causes glitches on C3 + // this can (probably) be removed once RMT driver for C3 is fixed + unsigned t0 = millis(); + while (strip.isUpdating() && (millis() - t0 < 15)) delay(1); // be nice, but not too nice. Waits up to 15ms + #endif + uint32_t heap = getContiguousFreeHeap(); // ESP32 family needs ~10k of contiguous free heap for UI to work properly + #endif + if (heap < MIN_HEAP_SIZE - 1024) heapDanger+=5; // allow 1k of "wiggle room" for things that do not respect min heap limits + else heapDanger = 0; + switch (heapDanger) { + case 15: // 15 consecutive seconds + DEBUG_PRINTLN(F("Heap low, purging segments")); + strip.purgeSegments(); + strip.setTransition(0); // disable transitions + for (unsigned i = 0; i < strip.getSegmentsNum(); i++) { + strip.getSegments()[i].setMode(FX_MODE_STATIC); // set static mode to free effect memory + } + errorFlag = ERR_NORAM; // alert UI TODO: make this a distinct error: segment reset + break; + case 30: // 30 consecutive seconds + DEBUG_PRINTLN(F("Heap low, reset segments")); + strip.resetSegments(); // remove all but one segments from memory + errorFlag = ERR_NORAM; // alert UI TODO: make this a distinct error: segment reset + break; + case 45: // 45 consecutive seconds + DEBUG_PRINTF_P(PSTR("Heap panic! Reset strip, reset connection\n")); + strip.~WS2812FX(); // deallocate strip and all its memory + new(&strip) WS2812FX(); // re-create strip object, respecting current memory limits + if (!Update.isRunning()) forceReconnect = true; // in case wifi is broken, make sure UI comes back, set disableForceReconnect = true to avert + errorFlag = ERR_NORAM; // alert UI TODO: make this a distinct error: strip reset + break; + default: + break; } - lastHeap = heap; - heapTime = millis(); + heapTime = (uint16_t)millis(); } //LED settings have been saved, re-init busses diff --git a/wled00/xml.cpp b/wled00/xml.cpp index 194256d82e..eebce343ea 100644 --- a/wled00/xml.cpp +++ b/wled00/xml.cpp @@ -315,7 +315,7 @@ void getSettingsJS(byte subPage, Print& settingsScript) unsigned sumMa = 0; for (size_t s = 0; s < BusManager::getNumBusses(); s++) { const Bus *bus = BusManager::getBus(s); - if (!bus || !bus->isOk()) break; // should not happen but for safety + if (!bus) break; // should not happen but for safety int offset = s < 10 ? '0' : 'A' - 10; char lp[4] = "L0"; lp[2] = offset+s; lp[3] = 0; //ascii 0-9 //strip data pin char lc[4] = "LC"; lc[2] = offset+s; lc[3] = 0; //strip length From be900737d284eecd25e3eeea16737c62d9c658fd Mon Sep 17 00:00:00 2001 From: ChuckMash <86080247+ChuckMash@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:25:22 -0800 Subject: [PATCH 036/164] fix button byte comment --- wled00/remote.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wled00/remote.cpp b/wled00/remote.cpp index 14c3c0d01d..c5dd15d8ff 100644 --- a/wled00/remote.cpp +++ b/wled00/remote.cpp @@ -27,7 +27,7 @@ typedef struct WizMoteMessageStructure { uint8_t program; // 0x91 for ON button, 0x81 for all others uint8_t seq[4]; // Incremetal sequence number 32 bit unsigned integer LSB first - uint8_t dt1; // Button Data Type (0x32) + uint8_t dt1; // Button Data Type (0x20) uint8_t button; // Identifies which button is being pressed uint8_t dt2; // Battery Level Data Type (0x01) uint8_t batLevel; // Battery Level 0-100 From d9cc751db449577f2ba4c7021f616fe6051231fc Mon Sep 17 00:00:00 2001 From: Damian Schneider Date: Fri, 23 Jan 2026 06:45:45 +0100 Subject: [PATCH 037/164] Adding sequential resource loading to edit.htm (#5306) Avoids multiple parallel connections which is helpful in low heap situations, especially on ESP8266 --- wled00/data/edit.htm | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/wled00/data/edit.htm b/wled00/data/edit.htm index d295639f55..2606e4e583 100644 --- a/wled00/data/edit.htm +++ b/wled00/data/edit.htm @@ -5,12 +5,6 @@ - - - - - - - +
From df94a8d5afa343302d3a33f0764f800d0896f2ae Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 06:58:27 +0100 Subject: [PATCH 038/164] Remove MAX_LEDS_PER_BUS limitation for virtual buses (#5238) - Frontend: Updated settings_leds.htm to allow virtual buses up to 16384 LEDs - Backend: Modified BusConfig::adjustBounds() to skip MAX_LEDS_PER_BUS check for virtual buses Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: DedeHai <6280424+DedeHai@users.noreply.github.com> --- wled00/bus_manager.h | 2 +- wled00/data/settings_leds.htm | 20 ++++++++------------ 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/wled00/bus_manager.h b/wled00/bus_manager.h index db49301994..a4cd370c90 100644 --- a/wled00/bus_manager.h +++ b/wled00/bus_manager.h @@ -488,7 +488,7 @@ struct BusConfig { //validates start and length and extends total if needed bool adjustBounds(uint16_t& total) { if (!count) count = 1; - if (count > MAX_LEDS_PER_BUS) count = MAX_LEDS_PER_BUS; + if (!Bus::isVirtual(type) && count > MAX_LEDS_PER_BUS) count = MAX_LEDS_PER_BUS; if (start >= MAX_LEDS) return false; //limit length of strip if it would exceed total permissible LEDs if (start + count > MAX_LEDS) count = MAX_LEDS - start; diff --git a/wled00/data/settings_leds.htm b/wled00/data/settings_leds.htm index 8de233ca77..da2867957b 100644 --- a/wled00/data/settings_leds.htm +++ b/wled00/data/settings_leds.htm @@ -346,33 +346,29 @@ } // do we have a led count field if (nm=="LC") { + if (!isHub75(t)) { + LC.max = isAna(t) ? 1 : (isDig(t) ? maxPB : 16384); // set max value + } else { + LC.min = undefined; + LC.max = undefined; + } let c = parseInt(LC.value,10); //get LED count if (!customStarts || !startsDirty[toNum(n)]) gId("ls"+n).value = sLC; //update start value gId("ls"+n).disabled = !customStarts; //enable/disable field editing if (c) { let s = parseInt(gId("ls"+n).value); //start value if (s+c > sLC) sLC = s+c; //update total count - if (c > maxLC) maxLC = c; //max per output if (!isVir(t)) sPC += c; //virtual out busses do not count towards physical LEDs if (isDig(t)) { + if (c > maxLC) maxLC = c; //max per output sDI += c; // summarize digital LED count let maPL = parseInt(d.Sf["LA"+n].value); - if (maPL == 255) maPL = 12; + if (maPL == 255) maPL = 12; // wacky WS2815 mode (255 == 12mA per LED) busMA += maPL*c; // summarize maximum bus current (calculated) } } // increase led count return; } - // do we have led pins for digital leds - if (nm=="L0" || nm=="L1") { - if (!isHub75(t)) { - d.Sf["LC"+n].max = maxPB; // update max led count value - } - else { - d.Sf["LC"+n].min = undefined; - d.Sf["LC"+n].max = undefined; - } - } // ignore IP address (stored in pins for virtual busses) if (nm.search(/^L[0-3]/) == 0) { // pin fields if (isVir(t)) { From 96f423438b63e70065b4a7ef86d78b28d197b796 Mon Sep 17 00:00:00 2001 From: gustebeast Date: Wed, 21 Jan 2026 08:00:56 +0000 Subject: [PATCH 039/164] Reduce flash size of TetrisAI_V2 by 97% Main branch without Tetris Flash: [======== ] 79.8% (used 1255301 bytes from 1572864 bytes) Main branch with Tetris (+196kb) Flash: [========= ] 92.3% (used 1452049 bytes from 1572864 bytes) This commit with Tetris (+6kb, 97% less flash) Flash: [======== ] 80.2% (used 1261625 bytes from 1572864 bytes) --- usermods/TetrisAI_v2/gridbw.h | 1 - usermods/TetrisAI_v2/pieces.h | 1 - usermods/TetrisAI_v2/tetrisbag.h | 12 +++--------- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/usermods/TetrisAI_v2/gridbw.h b/usermods/TetrisAI_v2/gridbw.h index deea027d79..a96351749a 100644 --- a/usermods/TetrisAI_v2/gridbw.h +++ b/usermods/TetrisAI_v2/gridbw.h @@ -13,7 +13,6 @@ #ifndef __GRIDBW_H__ #define __GRIDBW_H__ -#include #include #include "pieces.h" diff --git a/usermods/TetrisAI_v2/pieces.h b/usermods/TetrisAI_v2/pieces.h index 5d461615ae..0a13704dcf 100644 --- a/usermods/TetrisAI_v2/pieces.h +++ b/usermods/TetrisAI_v2/pieces.h @@ -19,7 +19,6 @@ #include #include #include -#include #define numPieces 7 diff --git a/usermods/TetrisAI_v2/tetrisbag.h b/usermods/TetrisAI_v2/tetrisbag.h index 592dac6c7f..b1698d8143 100644 --- a/usermods/TetrisAI_v2/tetrisbag.h +++ b/usermods/TetrisAI_v2/tetrisbag.h @@ -15,7 +15,6 @@ #include #include -#include #include "tetrisbag.h" @@ -87,17 +86,12 @@ class TetrisBag void queuePiece() { //move vector to left - std::rotate(piecesQueue.begin(), piecesQueue.begin() + 1, piecesQueue.end()); + for (uint8_t i = 1; i < piecesQueue.size(); i++) { + piecesQueue[i - 1] = piecesQueue[i]; + } piecesQueue[piecesQueue.size() - 1] = getNextPiece(); } - void queuePiece(uint8_t idx) - { - //move vector to left - std::rotate(piecesQueue.begin(), piecesQueue.begin() + 1, piecesQueue.end()); - piecesQueue[piecesQueue.size() - 1] = Piece(idx % nPieces); - } - void reset() { bag.clear(); From ca1d6614b2443f3cdb6c19a18752fa677bd957d3 Mon Sep 17 00:00:00 2001 From: Martin Fritzsche Date: Sat, 24 Jan 2026 22:26:47 +0100 Subject: [PATCH 040/164] Add option to save unmodified presets to autosave usermod (#5175) * Add option to save unmodified presets to autosave usermod * Fix lastRun never being assigned --- .../usermod_v2_auto_save.cpp | 43 ++++++++++++------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/usermods/usermod_v2_auto_save/usermod_v2_auto_save.cpp b/usermods/usermod_v2_auto_save/usermod_v2_auto_save.cpp index 1b97ea94da..fe508b1f32 100644 --- a/usermods/usermod_v2_auto_save/usermod_v2_auto_save.cpp +++ b/usermods/usermod_v2_auto_save/usermod_v2_auto_save.cpp @@ -16,6 +16,10 @@ // It can be configured to load auto saved preset at startup, // during the first `loop()`. // +// By default it will not save the state if an unmodified preset +// is selected (to not duplicate it). You can change this behaviour +// by setting autoSaveIgnorePresets=false +// // AutoSaveUsermod is standalone, but if FourLineDisplayUsermod // is installed, it will notify the user of the saved changes. @@ -49,6 +53,8 @@ class AutoSaveUsermod : public Usermod { bool applyAutoSaveOnBoot = false; // do we load auto-saved preset on boot? #endif + bool autoSaveIgnorePresets = true; // ignore by default to not duplicate presets + // If we've detected the need to auto save, this will be non zero. unsigned long autoSaveAfter = 0; @@ -68,6 +74,7 @@ class AutoSaveUsermod : public Usermod { static const char _autoSaveAfterSec[]; static const char _autoSavePreset[]; static const char _autoSaveApplyOnBoot[]; + static const char _autoSaveIgnorePresets[]; void inline saveSettings() { char presetNameBuffer[PRESET_NAME_BUFFER_SIZE]; @@ -122,7 +129,8 @@ class AutoSaveUsermod : public Usermod { void loop() { static unsigned long lastRun = 0; unsigned long now = millis(); - if (!autoSaveAfterSec || !enabled || currentPreset>0 || (strip.isUpdating() && now - lastRun < 240)) return; // setting 0 as autosave seconds disables autosave + if (!autoSaveAfterSec || !enabled || (autoSaveIgnorePresets && currentPreset>0) || (strip.isUpdating() && now - lastRun < 240)) return; // setting 0 as autosave seconds disables autosave + lastRun = now; uint8_t currentMode = strip.getMainSegment().mode; uint8_t currentPalette = strip.getMainSegment().palette; @@ -219,10 +227,11 @@ class AutoSaveUsermod : public Usermod { void addToConfig(JsonObject& root) { // we add JSON object: {"Autosave": {"autoSaveAfterSec": 10, "autoSavePreset": 99}} JsonObject top = root.createNestedObject(FPSTR(_name)); // usermodname - top[FPSTR(_autoSaveEnabled)] = enabled; - top[FPSTR(_autoSaveAfterSec)] = autoSaveAfterSec; // usermodparam - top[FPSTR(_autoSavePreset)] = autoSavePreset; // usermodparam - top[FPSTR(_autoSaveApplyOnBoot)] = applyAutoSaveOnBoot; + top[FPSTR(_autoSaveEnabled)] = enabled; + top[FPSTR(_autoSaveAfterSec)] = autoSaveAfterSec; // usermodparam + top[FPSTR(_autoSavePreset)] = autoSavePreset; // usermodparam + top[FPSTR(_autoSaveApplyOnBoot)] = applyAutoSaveOnBoot; + top[FPSTR(_autoSaveIgnorePresets)] = autoSaveIgnorePresets; DEBUG_PRINTLN(F("Autosave config saved.")); } @@ -245,12 +254,13 @@ class AutoSaveUsermod : public Usermod { return false; } - enabled = top[FPSTR(_autoSaveEnabled)] | enabled; - autoSaveAfterSec = top[FPSTR(_autoSaveAfterSec)] | autoSaveAfterSec; - autoSaveAfterSec = (uint16_t) min(3600,max(10,(int)autoSaveAfterSec)); // bounds checking - autoSavePreset = top[FPSTR(_autoSavePreset)] | autoSavePreset; - autoSavePreset = (uint8_t) min(250,max(100,(int)autoSavePreset)); // bounds checking - applyAutoSaveOnBoot = top[FPSTR(_autoSaveApplyOnBoot)] | applyAutoSaveOnBoot; + enabled = top[FPSTR(_autoSaveEnabled)] | enabled; + autoSaveAfterSec = top[FPSTR(_autoSaveAfterSec)] | autoSaveAfterSec; + autoSaveAfterSec = (uint16_t) min(3600,max(10,(int)autoSaveAfterSec)); // bounds checking + autoSavePreset = top[FPSTR(_autoSavePreset)] | autoSavePreset; + autoSavePreset = (uint8_t) min(250,max(100,(int)autoSavePreset)); // bounds checking + applyAutoSaveOnBoot = top[FPSTR(_autoSaveApplyOnBoot)] | applyAutoSaveOnBoot; + autoSaveIgnorePresets = top[FPSTR(_autoSaveIgnorePresets)] | autoSaveIgnorePresets; DEBUG_PRINT(FPSTR(_name)); DEBUG_PRINTLN(F(" config (re)loaded.")); @@ -268,11 +278,12 @@ class AutoSaveUsermod : public Usermod { }; // strings to reduce flash memory usage (used more than twice) -const char AutoSaveUsermod::_name[] PROGMEM = "Autosave"; -const char AutoSaveUsermod::_autoSaveEnabled[] PROGMEM = "enabled"; -const char AutoSaveUsermod::_autoSaveAfterSec[] PROGMEM = "autoSaveAfterSec"; -const char AutoSaveUsermod::_autoSavePreset[] PROGMEM = "autoSavePreset"; -const char AutoSaveUsermod::_autoSaveApplyOnBoot[] PROGMEM = "autoSaveApplyOnBoot"; +const char AutoSaveUsermod::_name[] PROGMEM = "Autosave"; +const char AutoSaveUsermod::_autoSaveEnabled[] PROGMEM = "enabled"; +const char AutoSaveUsermod::_autoSaveAfterSec[] PROGMEM = "autoSaveAfterSec"; +const char AutoSaveUsermod::_autoSavePreset[] PROGMEM = "autoSavePreset"; +const char AutoSaveUsermod::_autoSaveApplyOnBoot[] PROGMEM = "autoSaveApplyOnBoot"; +const char AutoSaveUsermod::_autoSaveIgnorePresets[] PROGMEM = "autoSaveIgnorePresets"; static AutoSaveUsermod autosave; REGISTER_USERMOD(autosave); From e867fcab1a724ab76d1e32ef3c09bba507c12a61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20M=C3=B6hle?= <91616163+softhack007@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:14:06 +0100 Subject: [PATCH 041/164] Change default LED pin to 4 in Ethernet builds GPIO 4 seems to be one of the few pins that is not used in ANY supported ethernet config. See https://github.com/wled/WLED/issues/5155#issuecomment-3614391561 --- wled00/const.h | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/wled00/const.h b/wled00/const.h index 6d1825d574..264c632f97 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -611,7 +611,12 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); #define DEFAULT_LED_PIN 2 // GPIO2 (D4) on Wemos D1 mini compatible boards, safe to use on any board #endif #else - #define DEFAULT_LED_PIN 16 // aligns with GPIO2 (D4) on Wemos D1 mini32 compatible boards (if it is unusable it will be reassigned in WS2812FX::finalizeInit()) + #if defined(WLED_USE_ETHERNET) + #define DEFAULT_LED_PIN 4 // GPIO4 seems to be a "safe bet" for all known ethernet boards (issue #5155) + #warning "Compiling with Ethernet support. The default LED pin has been changed to pin 4." + #else + #define DEFAULT_LED_PIN 16 // aligns with GPIO2 (D4) on Wemos D1 mini32 compatible boards (if it is unusable it will be reassigned in WS2812FX::finalizeInit()) + #endif #endif #define DEFAULT_LED_TYPE TYPE_WS2812_RGB #define DEFAULT_LED_COUNT 30 From 8d39dac65471031e5b77e2e2e208082fbc95fb93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20M=C3=B6hle?= <91616163+softhack007@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:33:33 +0100 Subject: [PATCH 042/164] ethernet: avoid dangerous pins LED pin: 16 -> 4 AR: no microphone, no pins clarify comment when to disable ESP-NOW --- platformio.ini | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index ec73bc5658..fb7d07c42f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -511,8 +511,10 @@ upload_speed = 921600 custom_usermods = audioreactive build_unflags = ${common.build_unflags} build_flags = ${common.build_flags} ${esp32.build_flags} -D WLED_RELEASE_NAME=\"ESP32_Ethernet\" -D RLYPIN=-1 -D WLED_USE_ETHERNET -D BTNPIN=-1 + -D SR_DTYPE=-1 ;; force AR to not allocate any PINs at startup + -D DATA_PINS=4 ;; default led pin = 16 conflicts with pins used for ethernet + ; -D WLED_DISABLE_ESPNOW ;; ESP-NOW requires wifi, may crash with ethernet only => uncomment if your board uses ETH_CLOCK_GPIO0_OUT, ETH_CLOCK_GPIO16_OUT, ETH_CLOCK_GPIO17_OUT -DARDUINO_USB_CDC_ON_BOOT=0 ;; this flag is mandatory for "classic ESP32" when building with arduino-esp32 >=2.0.3 -; -D WLED_DISABLE_ESPNOW ;; ESP-NOW requires wifi, may crash with ethernet only lib_deps = ${esp32.lib_deps} board_build.partitions = ${esp32.default_partitions} board_build.flash_mode = dio From a64334c32e06076bfbc14345704715dd2540eefd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20M=C3=B6hle?= <91616163+softhack007@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:50:34 +0100 Subject: [PATCH 043/164] correct wrong AR build flag typo --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index fb7d07c42f..0c4b7a1b57 100644 --- a/platformio.ini +++ b/platformio.ini @@ -511,7 +511,7 @@ upload_speed = 921600 custom_usermods = audioreactive build_unflags = ${common.build_unflags} build_flags = ${common.build_flags} ${esp32.build_flags} -D WLED_RELEASE_NAME=\"ESP32_Ethernet\" -D RLYPIN=-1 -D WLED_USE_ETHERNET -D BTNPIN=-1 - -D SR_DTYPE=-1 ;; force AR to not allocate any PINs at startup + -D SR_DMTYPE=-1 ;; force AR to not allocate any PINs at startup -D DATA_PINS=4 ;; default led pin = 16 conflicts with pins used for ethernet ; -D WLED_DISABLE_ESPNOW ;; ESP-NOW requires wifi, may crash with ethernet only => uncomment if your board uses ETH_CLOCK_GPIO0_OUT, ETH_CLOCK_GPIO16_OUT, ETH_CLOCK_GPIO17_OUT -DARDUINO_USB_CDC_ON_BOOT=0 ;; this flag is mandatory for "classic ESP32" when building with arduino-esp32 >=2.0.3 From 81af160be688b9bf999ae61416fc79739ae3f78a Mon Sep 17 00:00:00 2001 From: Frank <91616163+softhack007@users.noreply.github.com> Date: Wed, 28 Jan 2026 15:24:40 +0100 Subject: [PATCH 044/164] disable repeating warning, set all AR pins to "unused" * ethernet warning was repeating too often * make sure that AR usermod will not grab any PINs at startup --- platformio.ini | 2 +- wled00/const.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/platformio.ini b/platformio.ini index 0c4b7a1b57..06551afd65 100644 --- a/platformio.ini +++ b/platformio.ini @@ -511,7 +511,7 @@ upload_speed = 921600 custom_usermods = audioreactive build_unflags = ${common.build_unflags} build_flags = ${common.build_flags} ${esp32.build_flags} -D WLED_RELEASE_NAME=\"ESP32_Ethernet\" -D RLYPIN=-1 -D WLED_USE_ETHERNET -D BTNPIN=-1 - -D SR_DMTYPE=-1 ;; force AR to not allocate any PINs at startup + -D SR_DMTYPE=-1 -D AUDIOPIN=-1 -D I2S_SDPIN=-1 -D I2S_WSPIN=-1 -D I2S_CKPIN=-1 -D MCLK_PIN=-1 ;; force AR to not allocate any PINs at startup -D DATA_PINS=4 ;; default led pin = 16 conflicts with pins used for ethernet ; -D WLED_DISABLE_ESPNOW ;; ESP-NOW requires wifi, may crash with ethernet only => uncomment if your board uses ETH_CLOCK_GPIO0_OUT, ETH_CLOCK_GPIO16_OUT, ETH_CLOCK_GPIO17_OUT -DARDUINO_USB_CDC_ON_BOOT=0 ;; this flag is mandatory for "classic ESP32" when building with arduino-esp32 >=2.0.3 diff --git a/wled00/const.h b/wled00/const.h index 264c632f97..642fc85c42 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -613,7 +613,7 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); #else #if defined(WLED_USE_ETHERNET) #define DEFAULT_LED_PIN 4 // GPIO4 seems to be a "safe bet" for all known ethernet boards (issue #5155) - #warning "Compiling with Ethernet support. The default LED pin has been changed to pin 4." + //#warning "Compiling with Ethernet support. The default LED pin has been changed to pin 4." #else #define DEFAULT_LED_PIN 16 // aligns with GPIO2 (D4) on Wemos D1 mini32 compatible boards (if it is unusable it will be reassigned in WS2812FX::finalizeInit()) #endif From 857e73ab253f05a59a4474bc3f8d5fc92b64533a Mon Sep 17 00:00:00 2001 From: Damian Schneider Date: Wed, 28 Jan 2026 19:15:28 +0100 Subject: [PATCH 045/164] adding image rotation to PixelForge gif tool (#5309) --- wled00/data/pixelforge/pixelforge.htm | 79 +++++++++++++++++++++------ 1 file changed, 63 insertions(+), 16 deletions(-) diff --git a/wled00/data/pixelforge/pixelforge.htm b/wled00/data/pixelforge/pixelforge.htm index 86a1449754..75adf02a7c 100644 --- a/wled00/data/pixelforge/pixelforge.htm +++ b/wled00/data/pixelforge/pixelforge.htm @@ -25,13 +25,13 @@ } /* shimmer text animation */ .title .sh { - background: linear-gradient(90deg, - #7b47db 0%, #ff6b6b 20%, #feca57 40%, #48dbfb 60%, #7b47db 100%); - background-size: 200% 100%; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - animation: shimmer 4s ease-in-out 5; - font-size: 36px; + background: linear-gradient(90deg, + #7b47db 0%, #ff6b6b 20%, #feca57 40%, #48dbfb 60%, #7b47db 100%); + background-size: 200% 100%; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: shimmer 4s ease-in-out 5; + font-size: 36px; } @keyframes shimmer { 50% { background-position: 600% 0; } } @@ -278,11 +278,15 @@

Crop & Adjust Image

+
+ + +
- +
Preview at target resolution @@ -430,9 +434,11 @@

PIXEL MAGIC Tool

/* canvases */ const cv=gId('cv'),cx=cv.getContext('2d',{willReadFrequently:true}); const pv=gId('pv'),pvx=pv.getContext('2d',{willReadFrequently:true}); +const rv = cE('canvas'), rvc = rv.getContext('2d',{willReadFrequently:true}); // off screen canvas for drawing resized & rotated image +rv.width = cv.width; rv.height = cv.height; /* globals */ -let wu='',sI=null,sF=null,cI=null,bS=1,iS=1,pX=0,pY=0; +let wu='',sI=null,sF=null,cI=null,bS=1,iS=1,pX=0,pY=0,rot=0; let cr={x:50,y:50,w:200,h:150},drag=false,dH=null,oX=0,oY=0; let pan=false,psX=0,psY=0,poX=0,poY=0; let iL=[]; // image list @@ -774,6 +780,16 @@

PIXEL MAGIC Tool

crClamp(); crDraw(); }; +/* rotation */ +function rotUpd(v){ + if(gId('snap').checked) v = Math.round(v/15)*15 % 360; // snap to multiples of 15° + rot = v; + gId('rotVal').textContent = v; + if(cI) crDraw(); +} +gId('rotSl').oninput = ()=> rotUpd(+gId('rotSl').value); + + /* color change */ gId('bg').oninput=crDraw; @@ -882,12 +898,25 @@

PIXEL MAGIC Tool

/* draw + preview */ function crDraw(){ + if(!cI) return; + + // render rotated image to offscreen + rvc.clearRect(0,0,rv.width,rv.height); + rvc.fillStyle = gId('bg').value; + rvc.fillRect(0,0,rv.width,rv.height); + rvc.imageSmoothingEnabled = false; + rvc.save(); + const dw = cI.width * iS, dh = cI.height * iS; + rvc.translate(pX + dw/2, pY + dh/2); + rvc.rotate(rot * Math.PI / 180); + rvc.drawImage(cI, -dw/2, -dh/2, dw, dh); + rvc.restore(); + + // copy offscreen to visible cx.clearRect(0,0,cv.width,cv.height); - if(!cI)return; - cx.fillStyle=gId('bg').value; cx.fillRect(0,0,cv.width,cv.height); - cx.imageSmoothingEnabled=false; - cx.drawImage(cI,0,0,cI.width,cI.height,pX,pY,cI.width*iS,cI.height*iS); - /* crop frame */ + cx.drawImage(rv, 0, 0); + + // overlay crop frame (only on visible) cx.lineWidth=3; cx.setLineDash([6,4]); cx.shadowColor="#000"; cx.shadowBlur=2; cx.strokeStyle="#FFF"; cx.beginPath(); cx.roundRect(cr.x,cr.y,cr.w,cr.h,6); cx.stroke(); cx.shadowColor="#000F"; @@ -913,7 +942,8 @@

PIXEL MAGIC Tool

const tcx = tc.getContext('2d'); tcx.fillStyle=gId('bg').value; tcx.fillRect(0,0,w,h); // fill background (for transparent images) - tcx.drawImage(cI,(cr.x-pX)/iS,(cr.y-pY)/iS,cr.w/iS,cr.h/iS,0,0,w,h); + tcx.imageSmoothingEnabled = false; + tcx.drawImage(rv, cr.x, cr.y, cr.w, cr.h, 0, 0, w, h); // sample cropped area from off screen canvas blackTh(tcx); // scale/stretch to preview canvas, limit to 256px in largest dimension but keep aspect ratio const ratio = h/w; @@ -1003,11 +1033,28 @@

PIXEL MAGIC Tool

const frames = []; for (let i = 0; i < gF.length; i++) { + // put current GIF frame into tc const id = new ImageData(new Uint8ClampedArray(gF[i].pixels), gI.width, gI.height); tctx.putImageData(id, 0, 0); + + // render this frame into the offscreen rotated canvas (no overlay) + rvc.clearRect(0, 0, rv.width, rv.height); + rvc.fillStyle = gId('bg').value; + rvc.fillRect(0, 0, rv.width, rv.height); + rvc.imageSmoothingEnabled = false; + rvc.save(); + const dw = gI.width * iS, dh = gI.height * iS; + rvc.translate(pX + dw / 2, pY + dh / 2); + rvc.rotate(rot * Math.PI / 180); + rvc.drawImage(tc, -dw / 2, -dh / 2, dw, dh); + rvc.restore(); + + // sample the crop from the offscreen (already rotated) canvas into output size cctx.fillStyle = gId('bg').value; cctx.fillRect(0, 0, w, h); - cctx.drawImage(tc, (cr.x - pX) / iS, (cr.y - pY) / iS, cr.w / iS, cr.h / iS, 0, 0, w, h); + cctx.imageSmoothingEnabled = false; + cctx.drawImage(rv, cr.x, cr.y, cr.w, cr.h, 0, 0, w, h); + blackTh(cctx); const fd = cctx.getImageData(0, 0, w, h); frames.push({ data: fd.data, delay: gF[i].delay }); From c9f47d4b5c309fcbc6e913c8d0d28e1eecf0d57a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20M=C3=B6hle?= <91616163+softhack007@users.noreply.github.com> Date: Wed, 28 Jan 2026 23:37:22 +0100 Subject: [PATCH 046/164] new ESP32 node types Added new node types for unsupported ESP32 variants, based on same file from ESP Easy. Just to be prepared for new nodes (future support) --- wled00/NodeStruct.h | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/wled00/NodeStruct.h b/wled00/NodeStruct.h index 34f73ab418..9647162054 100644 --- a/wled00/NodeStruct.h +++ b/wled00/NodeStruct.h @@ -15,6 +15,22 @@ #define NODE_TYPE_ID_ESP32S3 34 #define NODE_TYPE_ID_ESP32C3 35 +// updated node types from the ESP Easy project +// https://github.com/letscontrolit/ESPEasy/blob/mega/src/src/DataTypes/NodeTypeID.h +//#define NODE_TYPE_ID_ESP32 33 +//#define NODE_TYPE_ID_ESP32S2 34 +//#define NODE_TYPE_ID_ESP32C3 35 +//#define NODE_TYPE_ID_ESP32S3 36 +#define NODE_TYPE_ID_ESP32C2 37 +#define NODE_TYPE_ID_ESP32H2 38 +#define NODE_TYPE_ID_ESP32C6 39 +#define NODE_TYPE_ID_ESP32C61 40 +#define NODE_TYPE_ID_ESP32C5 41 +#define NODE_TYPE_ID_ESP32P4 42 +#define NODE_TYPE_ID_ESP32P4r3 45 +#define NODE_TYPE_ID_ESP32H21 43 +#define NODE_TYPE_ID_ESP32H4 44 + /*********************************************************************************************\ * NodeStruct \*********************************************************************************************/ From 1031e70d70a49aaaf651ea2dcf917317629ea671 Mon Sep 17 00:00:00 2001 From: Damian Schneider Date: Fri, 30 Jan 2026 08:14:53 +0100 Subject: [PATCH 047/164] Replace buffer lock magic numbers with defines (#5217) * replace magic numbers with defines --- .../usermod_v2_RF433/usermod_v2_RF433.cpp | 2 +- wled00/FX_2Dfcn.cpp | 2 +- wled00/FX_fcn.cpp | 2 +- wled00/cfg.cpp | 8 +++--- wled00/const.h | 25 +++++++++++++++++++ wled00/fcn_declare.h | 4 +-- wled00/ir.cpp | 2 +- wled00/json.cpp | 2 +- wled00/mqtt.cpp | 2 +- wled00/presets.cpp | 6 ++--- wled00/remote.cpp | 2 +- wled00/set.cpp | 2 +- wled00/udp.cpp | 2 +- wled00/util.cpp | 2 +- wled00/wled_serial.cpp | 2 +- wled00/wled_server.cpp | 2 +- wled00/ws.cpp | 4 +-- wled00/xml.cpp | 2 +- 18 files changed, 49 insertions(+), 24 deletions(-) diff --git a/usermods/usermod_v2_RF433/usermod_v2_RF433.cpp b/usermods/usermod_v2_RF433/usermod_v2_RF433.cpp index 9ac6c416d1..ed3d5eb26f 100644 --- a/usermods/usermod_v2_RF433/usermod_v2_RF433.cpp +++ b/usermods/usermod_v2_RF433/usermod_v2_RF433.cpp @@ -124,7 +124,7 @@ class RF433Usermod : public Usermod char objKey[14]; bool parsed = false; - if (!requestJSONBufferLock(22)) return false; + if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) return false; sprintf_P(objKey, PSTR("\"%d\":"), button); diff --git a/wled00/FX_2Dfcn.cpp b/wled00/FX_2Dfcn.cpp index 063d3a6bb3..34b619ea36 100644 --- a/wled00/FX_2Dfcn.cpp +++ b/wled00/FX_2Dfcn.cpp @@ -74,7 +74,7 @@ void WS2812FX::setUpMatrix() { size_t gapSize = 0; int8_t *gapTable = nullptr; - if (isFile && requestJSONBufferLock(20)) { + if (isFile && requestJSONBufferLock(JSON_LOCK_LEDGAP)) { DEBUG_PRINT(F("Reading LED gap from ")); DEBUG_PRINTLN(fileName); // read the array into global JSON buffer diff --git a/wled00/FX_fcn.cpp b/wled00/FX_fcn.cpp index 8834d89fa2..f9065446ba 100644 --- a/wled00/FX_fcn.cpp +++ b/wled00/FX_fcn.cpp @@ -1987,7 +1987,7 @@ bool WS2812FX::deserializeMap(unsigned n) { return false; } - if (!isFile || !requestJSONBufferLock(7)) return false; + if (!isFile || !requestJSONBufferLock(JSON_LOCK_LEDMAP)) return false; StaticJsonDocument<64> filter; filter[F("width")] = true; diff --git a/wled00/cfg.cpp b/wled00/cfg.cpp index 75854751ea..8020f24886 100644 --- a/wled00/cfg.cpp +++ b/wled00/cfg.cpp @@ -791,7 +791,7 @@ void resetConfig() { bool deserializeConfigFromFS() { [[maybe_unused]] bool success = deserializeConfigSec(); - if (!requestJSONBufferLock(1)) return false; + if (!requestJSONBufferLock(JSON_LOCK_CFG_DES)) return false; DEBUG_PRINTLN(F("Reading settings from /cfg.json...")); @@ -812,7 +812,7 @@ void serializeConfigToFS() { DEBUG_PRINTLN(F("Writing settings to /cfg.json...")); - if (!requestJSONBufferLock(2)) return; + if (!requestJSONBufferLock(JSON_LOCK_CFG_SER)) return; JsonObject root = pDoc->to(); @@ -1256,7 +1256,7 @@ static const char s_wsec_json[] PROGMEM = "/wsec.json"; bool deserializeConfigSec() { DEBUG_PRINTLN(F("Reading settings from /wsec.json...")); - if (!requestJSONBufferLock(3)) return false; + if (!requestJSONBufferLock(JSON_LOCK_CFG_SEC_DES)) return false; bool success = readObjectFromFile(s_wsec_json, nullptr, pDoc); if (!success) { @@ -1310,7 +1310,7 @@ bool deserializeConfigSec() { void serializeConfigSec() { DEBUG_PRINTLN(F("Writing settings to /wsec.json...")); - if (!requestJSONBufferLock(4)) return; + if (!requestJSONBufferLock(JSON_LOCK_CFG_SEC_SER)) return; JsonObject root = pDoc->to(); diff --git a/wled00/const.h b/wled00/const.h index 642fc85c42..333451ede4 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -441,6 +441,31 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); #define ERR_OVERCURRENT 31 // An attached current sensor has measured a current above the threshold (not implemented) #define ERR_UNDERVOLT 32 // An attached voltmeter has measured a voltage below the threshold (not implemented) +// JSON buffer lock owners +#define JSON_LOCK_UNKNOWN 255 +#define JSON_LOCK_CFG_DES 1 +#define JSON_LOCK_CFG_SER 2 +#define JSON_LOCK_CFG_SEC_DES 3 +#define JSON_LOCK_CFG_SEC_SER 4 +#define JSON_LOCK_SETTINGS 5 +#define JSON_LOCK_XML 6 +#define JSON_LOCK_LEDMAP 7 +// unused 8 +#define JSON_LOCK_PRESET_LOAD 9 +#define JSON_LOCK_PRESET_SAVE 10 +#define JSON_LOCK_WS_RECEIVE 11 +#define JSON_LOCK_WS_SEND 12 +#define JSON_LOCK_IR 13 +#define JSON_LOCK_SERVER 14 +#define JSON_LOCK_MQTT 15 +#define JSON_LOCK_SERIAL 16 +#define JSON_LOCK_SERVEJSON 17 +#define JSON_LOCK_NOTIFY 18 +#define JSON_LOCK_PRESET_NAME 19 +#define JSON_LOCK_LEDGAP 20 +#define JSON_LOCK_LEDMAP_ENUM 21 +#define JSON_LOCK_REMOTE 22 + // Timer mode types #define NL_MODE_SET 0 //After nightlight time elapsed, set to target brightness #define NL_MODE_FADE 1 //Fade to target brightness gradually diff --git a/wled00/fcn_declare.h b/wled00/fcn_declare.h index 84b5595df7..24c13aae84 100644 --- a/wled00/fcn_declare.h +++ b/wled00/fcn_declare.h @@ -395,7 +395,7 @@ size_t printSetFormIndex(Print& settingsScript, const char* key, int index); size_t printSetClassElementHTML(Print& settingsScript, const char* key, const int index, const char* val); void prepareHostname(char* hostname); [[gnu::pure]] bool isAsterisksOnly(const char* str, byte maxLen); -bool requestJSONBufferLock(uint8_t moduleID=255); +bool requestJSONBufferLock(uint8_t moduleID=JSON_LOCK_UNKNOWN); void releaseJSONBufferLock(); uint8_t extractModeName(uint8_t mode, const char *src, char *dest, uint8_t maxLen); uint8_t extractModeSlider(uint8_t mode, uint8_t slider, char *dest, uint8_t maxLen, uint8_t *var = nullptr); @@ -487,7 +487,7 @@ void bootloopCheckOTA(); // swap boot image if bootloop is detected instead of r class JSONBufferGuard { bool holding_lock; public: - inline JSONBufferGuard(uint8_t module=255) : holding_lock(requestJSONBufferLock(module)) {}; + inline JSONBufferGuard(uint8_t module=JSON_LOCK_UNKNOWN) : holding_lock(requestJSONBufferLock(module)) {}; inline ~JSONBufferGuard() { if (holding_lock) releaseJSONBufferLock(); }; inline JSONBufferGuard(const JSONBufferGuard&) = delete; // Noncopyable inline JSONBufferGuard& operator=(const JSONBufferGuard&) = delete; diff --git a/wled00/ir.cpp b/wled00/ir.cpp index b2fec76f1f..fe0950ab14 100644 --- a/wled00/ir.cpp +++ b/wled00/ir.cpp @@ -559,7 +559,7 @@ static void decodeIRJson(uint32_t code) JsonObject fdo; JsonObject jsonCmdObj; - if (!requestJSONBufferLock(13)) return; + if (!requestJSONBufferLock(JSON_LOCK_IR)) return; sprintf_P(objKey, PSTR("\"0x%lX\":"), (unsigned long)code); strcpy_P(fileName, PSTR("/ir.json")); // for FS.exists() diff --git a/wled00/json.cpp b/wled00/json.cpp index 22ba98236b..fd74e072c7 100644 --- a/wled00/json.cpp +++ b/wled00/json.cpp @@ -1142,7 +1142,7 @@ void serveJson(AsyncWebServerRequest* request) return; } - if (!requestJSONBufferLock(17)) { + if (!requestJSONBufferLock(JSON_LOCK_SERVEJSON)) { request->deferResponse(); return; } diff --git a/wled00/mqtt.cpp b/wled00/mqtt.cpp index ea42297bf7..d64dc29d9b 100644 --- a/wled00/mqtt.cpp +++ b/wled00/mqtt.cpp @@ -113,7 +113,7 @@ static void onMqttMessage(char* topic, char* payload, AsyncMqttClientMessageProp colorFromDecOrHexString(colPri, payloadStr); colorUpdated(CALL_MODE_DIRECT_CHANGE); } else if (strcmp_P(topic, PSTR("/api")) == 0) { - if (requestJSONBufferLock(15)) { + if (requestJSONBufferLock(JSON_LOCK_MQTT)) { if (payloadStr[0] == '{') { //JSON API deserializeJson(*pDoc, payloadStr); deserializeState(pDoc->as()); diff --git a/wled00/presets.cpp b/wled00/presets.cpp index fed2c1ed92..9023baf344 100644 --- a/wled00/presets.cpp +++ b/wled00/presets.cpp @@ -32,7 +32,7 @@ static void doSaveState() { unsigned long maxWait = millis() + strip.getFrameTime(); while (strip.isUpdating() && millis() < maxWait) delay(1); // wait for strip to finish updating, accessing FS during sendout causes glitches - if (!requestJSONBufferLock(10)) return; + if (!requestJSONBufferLock(JSON_LOCK_PRESET_SAVE)) return; initPresetsFile(); // just in case if someone deleted presets.json using /edit JsonObject sObj = pDoc->to(); @@ -86,7 +86,7 @@ static void doSaveState() { bool getPresetName(byte index, String& name) { - if (!requestJSONBufferLock(19)) return false; + if (!requestJSONBufferLock(JSON_LOCK_PRESET_NAME)) return false; bool presetExists = false; if (readObjectFromFileUsingId(getPresetsFileName(), index, pDoc)) { JsonObject fdo = pDoc->as(); @@ -152,7 +152,7 @@ void handlePresets() return; } - if (presetToApply == 0 || !requestJSONBufferLock(9)) return; // no preset waiting to apply, or JSON buffer is already allocated, return to loop until free + if (presetToApply == 0 || !requestJSONBufferLock(JSON_LOCK_PRESET_LOAD)) return; // no preset waiting to apply, or JSON buffer is already allocated, return to loop until free bool changePreset = false; uint8_t tmpPreset = presetToApply; // store temporary since deserializeState() may call applyPreset() diff --git a/wled00/remote.cpp b/wled00/remote.cpp index c5dd15d8ff..b5aaa5211c 100644 --- a/wled00/remote.cpp +++ b/wled00/remote.cpp @@ -120,7 +120,7 @@ static bool remoteJson(int button) char objKey[10]; bool parsed = false; - if (!requestJSONBufferLock(22)) return false; + if (!requestJSONBufferLock(JSON_LOCK_REMOTE)) return false; sprintf_P(objKey, PSTR("\"%d\":"), button); diff --git a/wled00/set.cpp b/wled00/set.cpp index db8b30bac8..92d64c24df 100644 --- a/wled00/set.cpp +++ b/wled00/set.cpp @@ -651,7 +651,7 @@ void handleSettingsSet(AsyncWebServerRequest *request, byte subPage) //USERMODS if (subPage == SUBPAGE_UM) { - if (!requestJSONBufferLock(5)) { + if (!requestJSONBufferLock(JSON_LOCK_SETTINGS)) { request->deferResponse(); return; } diff --git a/wled00/udp.cpp b/wled00/udp.cpp index a43742357b..f0e0ea7ea0 100644 --- a/wled00/udp.cpp +++ b/wled00/udp.cpp @@ -646,7 +646,7 @@ void handleNotifications() // API over UDP udpIn[packetSize] = '\0'; - if (requestJSONBufferLock(18)) { + if (requestJSONBufferLock(JSON_LOCK_NOTIFY)) { if (udpIn[0] >= 'A' && udpIn[0] <= 'Z') { //HTTP API String apireq = "win"; apireq += '&'; // reduce flash string usage apireq += (char*)udpIn; diff --git a/wled00/util.cpp b/wled00/util.cpp index 861f1ce4ff..bccb2d6920 100644 --- a/wled00/util.cpp +++ b/wled00/util.cpp @@ -561,7 +561,7 @@ void enumerateLedmaps() { ledMaps |= 1 << i; #ifndef ESP8266 - if (requestJSONBufferLock(21)) { + if (requestJSONBufferLock(JSON_LOCK_LEDMAP_ENUM)) { if (readObjectFromFile(fileName, nullptr, pDoc, &filter)) { size_t len = 0; JsonObject root = pDoc->as(); diff --git a/wled00/wled_serial.cpp b/wled00/wled_serial.cpp index a0e59c531f..7675976ba8 100644 --- a/wled00/wled_serial.cpp +++ b/wled00/wled_serial.cpp @@ -102,7 +102,7 @@ void handleSerial() else if (next == 'O') { continuousSendLED = true; } // Enable Continuous Serial Streaming else if (next == '{') { //JSON API bool verboseResponse = false; - if (!requestJSONBufferLock(16)) { + if (!requestJSONBufferLock(JSON_LOCK_SERIAL)) { Serial.printf_P(PSTR("{\"error\":%d}\n"), ERR_NOBUF); return; } diff --git a/wled00/wled_server.cpp b/wled00/wled_server.cpp index ffb259b858..ace5728347 100644 --- a/wled00/wled_server.cpp +++ b/wled00/wled_server.cpp @@ -395,7 +395,7 @@ void initServer() bool verboseResponse = false; bool isConfig = false; - if (!requestJSONBufferLock(14)) { + if (!requestJSONBufferLock(JSON_LOCK_SERVER)) { request->deferResponse(); return; } diff --git a/wled00/ws.cpp b/wled00/ws.cpp index 873261beec..6d74a5a0b8 100644 --- a/wled00/ws.cpp +++ b/wled00/ws.cpp @@ -42,7 +42,7 @@ void wsEvent(AsyncWebSocket * server, AsyncWebSocketClient * client, AwsEventTyp } bool verboseResponse = false; - if (!requestJSONBufferLock(11)) { + if (!requestJSONBufferLock(JSON_LOCK_WS_RECEIVE)) { client->text(F("{\"error\":3}")); // ERR_NOBUF return; } @@ -136,7 +136,7 @@ void sendDataWs(AsyncWebSocketClient * client) { if (!ws.count()) return; - if (!requestJSONBufferLock(12)) { + if (!requestJSONBufferLock(JSON_LOCK_WS_SEND)) { const char* error = PSTR("{\"error\":3}"); if (client) { client->text(FPSTR(error)); // ERR_NOBUF diff --git a/wled00/xml.cpp b/wled00/xml.cpp index eebce343ea..462e8d33d6 100644 --- a/wled00/xml.cpp +++ b/wled00/xml.cpp @@ -99,7 +99,7 @@ void appendGPIOinfo(Print& settingsScript) settingsScript.printf_P(PSTR(",%d,%d"), spi_mosi, spi_sclk); } // usermod pin reservations will become unnecessary when settings pages will read cfg.json directly - if (requestJSONBufferLock(6)) { + if (requestJSONBufferLock(JSON_LOCK_XML)) { // if we can't allocate JSON buffer ignore usermod pins JsonObject mods = pDoc->createNestedObject("um"); UsermodManager::addToConfig(mods); From f19d29cd64d4d784618c64cbd095223e79861b4b Mon Sep 17 00:00:00 2001 From: Damian Schneider Date: Fri, 30 Jan 2026 08:18:17 +0100 Subject: [PATCH 048/164] add json validation to file inputs in UI and minify before upload (#5248) * also updated edit.htm to do the same --- wled00/data/common.js | 20 +++++++++++++++----- wled00/data/edit.htm | 30 +++++++++++++----------------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/wled00/data/common.js b/wled00/data/common.js index 5f73c946d8..a6223daa7c 100644 --- a/wled00/data/common.js +++ b/wled00/data/common.js @@ -137,16 +137,26 @@ function showToast(text, error = false) { x.style.animation = 'none'; timeout = setTimeout(function(){ x.className = x.className.replace("show", ""); }, 2900); } -function uploadFile(fileObj, name) { +async function uploadFile(fileObj, name, callback) { + let file = fileObj.files?.[0]; // get first file, "?"" = optional chaining in case no file is selected + if (!file) { callback?.(false); return; } + if (/\.json$/i.test(name)) { // same as name.toLowerCase().endsWith('.json') + try { + const minified = JSON.stringify(JSON.parse(await file.text())); // validate and minify JSON + file = new Blob([minified], { type: file.type || "application/json" }); + } catch (err) { + if (!confirm("JSON invalid. Continue?")) { callback?.(false); return; } + // proceed with original file if invalid but user confirms + } + } var req = new XMLHttpRequest(); - req.addEventListener('load', function(){showToast(this.responseText,this.status >= 400)}); - req.addEventListener('error', function(e){showToast(e.stack,true);}); + req.addEventListener('load', function(){showToast(this.responseText,this.status >= 400); if(callback) callback(this.status < 400);}); + req.addEventListener('error', function(e){showToast("Upload failed",true); if(callback) callback(false);}); req.open("POST", "/upload"); var formData = new FormData(); - formData.append("data", fileObj.files[0], name); + formData.append("data", file, name); req.send(formData); fileObj.value = ''; - return false; } // connect to WebSocket, use parent WS or open new, callback function gets passed the new WS object function connectWs(onOpen) { diff --git a/wled00/data/edit.htm b/wled00/data/edit.htm index 2606e4e583..31a51e231a 100644 --- a/wled00/data/edit.htm +++ b/wled00/data/edit.htm @@ -134,6 +134,7 @@ }); }); }); + var QueuedRequester = function(){ this.q=[]; this.r=false; this.x=null; } QueuedRequester.prototype = { _request: function(req){ @@ -432,7 +433,7 @@ // Check filename from text field or current file var pathField = gId("filepath"); var filename = (pathField && pathField.value) ? pathField.value : currentFile; - aceEditor.session.setMode(filename && filename.toLowerCase().endsWith('.json') ? "ace/mode/json" : "ace/mode/text"); + aceEditor.session.setMode(filename && (/\.json$/i.test(filename)) ? "ace/mode/json" : "ace/mode/text"); // same as filename.toLowerCase().endsWith('.json') } // Try to initialize Ace editor if available @@ -488,7 +489,7 @@ var filename = pathField ? pathField.value : currentFile; var border = "2px solid #333"; - if (filename && filename.toLowerCase().endsWith('.json')) { + if (filename && (/\.json$/i.test(filename))) { // same as filename.toLowerCase().endsWith('.json') try { JSON.parse(ta.value); } catch(e) { @@ -499,23 +500,19 @@ }; function saveFile(filename,data){ - var finalData = data; - // Minify JSON files before upload - if (filename.toLowerCase().endsWith('.json')) { + var outdata = data; + if (/\.json$/i.test(filename)) { // same as filename.toLowerCase().endsWith('.json') try { - finalData = JSON.stringify(JSON.parse(data)); + outdata = JSON.stringify(JSON.parse(data)); // validate and minify } catch(e) { - alert("Invalid JSON! Please fix syntax."); + alert("Invalid JSON! Please fix."); return; } } - var fd=new FormData(); - fd.append("file",new Blob([finalData],{type:"text/plain"}),filename); - req.add("POST","/upload",fd,function(st,resp){ - if (st!=200) alert("ERROR "+st+": "+resp); - else { - showToast("File saved"); + uploadFile({files: [new Blob([outdata], {type:"text/plain"})]}, filename, function(s) { + if(s) { refreshTree(); + loadFile(filename); // (re)load if saved successfully to update formating or show file content } }); } @@ -526,9 +523,9 @@ gId("preview").style.display="none"; gId("editor").style.display="flex"; if (st==200) { - if (filename.toLowerCase().endsWith('.json')) { + if ((/\.json$/i.test(filename))) { // same as filename.toLowerCase().endsWith('.json') try { - setContent(filename.toLowerCase().includes('ledmap') ? prettyLedmap(resp) : JSON.stringify(JSON.parse(resp), null, 2)); + setContent(/ledmap/i.test(filename) ? prettyLedmap(resp) : JSON.stringify(JSON.parse(resp), null, 2)); // pretty-print ledmap files (i.e. if file name includes "ledmap" case-insensitive) } catch(e) { setContent(resp); } @@ -555,8 +552,7 @@ } if (!fn.startsWith("/")) fn = "/" + fn; currentFile = fn; // Update current file - saveFile(fn, getContent()); - loadFile(fn); + saveFile(fn, getContent()) }, loadText:function(fn){ currentFile=fn; From d4f365e7e58e0214a9f5bdb53f3882bb67b60400 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 08:50:11 +0000 Subject: [PATCH 049/164] Simplify prompt to 2 buttons + checkbox per feedback - Replace 4-button design with 2 buttons: "Report this update" and "Skip reporting" - Add checkbox "Save my choice for future updates" (checked by default) - When checkbox is checked: - "Report this update" enables automatic reporting for future upgrades - "Skip reporting" saves "never ask" preference - When unchecked, choice applies only to current prompt Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com> --- wled00/data/index.js | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/wled00/data/index.js b/wled00/data/index.js index a1c4df4c81..d3d11ff2f1 100644 --- a/wled00/data/index.js +++ b/wled00/data/index.js @@ -3395,11 +3395,15 @@ function showVersionUpgradePrompt(info, oldVersion, newVersion) {

Learn more about what data is collected and why

+
+ +
- - - - + +
`; @@ -3408,25 +3412,25 @@ function showVersionUpgradePrompt(info, oldVersion, newVersion) { // Add event listeners gId('versionReportYes').addEventListener('click', () => { - reportUpgradeEvent(info, oldVersion); + const saveChoice = gId('versionSaveChoice').checked; + reportUpgradeEvent(info, oldVersion, saveChoice); // Pass saveChoice for alwaysReport d.body.removeChild(overlay); - }); - - gId('versionReportAlways').addEventListener('click', () => { - reportUpgradeEvent(info, oldVersion, true); // Pass true for alwaysReport - d.body.removeChild(overlay); - showToast('Thank you! Future upgrades will be reported automatically.'); + if (saveChoice) { + showToast('Thank you! Future upgrades will be reported automatically.'); + } else { + showToast('Thank you for reporting!'); + } }); gId('versionReportNo').addEventListener('click', () => { - // Don't update version, will ask again on next load - d.body.removeChild(overlay); - }); - - gId('versionReportNever').addEventListener('click', () => { - updateVersionInfo(newVersion, true, false); + const saveChoice = gId('versionSaveChoice').checked; + if (saveChoice) { + // Save "never ask" preference + updateVersionInfo(newVersion, true, false); + showToast('You will not be asked again.'); + } + // Don't update version if not saving choice, will ask again on next load d.body.removeChild(overlay); - showToast('You will not be asked again.'); }); } From 50ef8db595220dae27793ac1b927930946619ea8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 08:53:30 +0000 Subject: [PATCH 050/164] Fix double toast messages and improve error handling - Remove duplicate toast messages when checkbox is checked - Show appropriate message based on saveChoice state - Ensure error toasts are visible after overlay is removed Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com> --- wled00/data/index.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/wled00/data/index.js b/wled00/data/index.js index d3d11ff2f1..8cf1ed525c 100644 --- a/wled00/data/index.js +++ b/wled00/data/index.js @@ -3413,24 +3413,20 @@ function showVersionUpgradePrompt(info, oldVersion, newVersion) { // Add event listeners gId('versionReportYes').addEventListener('click', () => { const saveChoice = gId('versionSaveChoice').checked; - reportUpgradeEvent(info, oldVersion, saveChoice); // Pass saveChoice for alwaysReport d.body.removeChild(overlay); - if (saveChoice) { - showToast('Thank you! Future upgrades will be reported automatically.'); - } else { - showToast('Thank you for reporting!'); - } + // Pass saveChoice as alwaysReport parameter + reportUpgradeEvent(info, oldVersion, saveChoice); }); gId('versionReportNo').addEventListener('click', () => { const saveChoice = gId('versionSaveChoice').checked; + d.body.removeChild(overlay); if (saveChoice) { // Save "never ask" preference updateVersionInfo(newVersion, true, false); showToast('You will not be asked again.'); } // Don't update version if not saving choice, will ask again on next load - d.body.removeChild(overlay); }); } @@ -3474,7 +3470,11 @@ function reportUpgradeEvent(info, oldVersion, alwaysReport) { }) .then(res => { if (res.ok) { - showToast('Thank you for reporting!'); + if (alwaysReport) { + showToast('Thank you! Future upgrades will be reported automatically.'); + } else { + showToast('Thank you for reporting!'); + } updateVersionInfo(info.ver, false, !!alwaysReport); } else { showToast('Report failed. Please try again later.', true); From 39bf31d3a1271212c59ab4f63391e657f6394429 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 08:57:31 +0000 Subject: [PATCH 051/164] Change button text from "Report this update" to "Report update" Per feedback, simplified button text since the checkbox already provides context about saving choices for future updates. Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com> --- wled00/data/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wled00/data/index.js b/wled00/data/index.js index 8cf1ed525c..bc0e814135 100644 --- a/wled00/data/index.js +++ b/wled00/data/index.js @@ -3402,7 +3402,7 @@ function showVersionUpgradePrompt(info, oldVersion, newVersion) {
- +
`; From 2c4ed4249d0f9fa3968213dc6c8e725017f0c393 Mon Sep 17 00:00:00 2001 From: Damian Schneider Date: Fri, 30 Jan 2026 20:35:15 +0100 Subject: [PATCH 052/164] New custom palettes editor (#5010) * full refactoring, added live preview, better minifying in cdata.js * update main UI buttons, support for gaps in cpal files, cpal UI cleanup * fixed some layout issues, added un-ordered cpal deletion * changed to tab indentation, paste button border color now holds stored color * fix preview to work properly and some other fixes in UI * always unfreeze * new approach to loading iro.js, add harmonic random palette, many fixes. * decoupling iro.j, update UI of cpal.htm - load iro.js sequentially - no parallel requests in cpal.htm - update UI buttons - fix showing sequential loading of palettes (using opacity) - better UX for mobile (larger markers, larger editor) - various fixes * small change to buttons * load iro.js dynamically, remove iro.js from index.htm, revert changes to cdata.js * improved visibility for very dark/black palettes and markers --- .gitignore | 1 + tools/cdata.js | 15 +- usermods/audioreactive/audio_reactive.cpp | 4 +- wled00/colors.cpp | 8 +- wled00/const.h | 1 + wled00/data/cpal/cpal.htm | 1546 ++++++++++++--------- wled00/data/index.htm | 8 +- wled00/data/index.js | 37 +- wled00/json.cpp | 14 +- wled00/wled_server.cpp | 6 + 10 files changed, 964 insertions(+), 676 deletions(-) diff --git a/.gitignore b/.gitignore index ec9d4efcc3..a3c1bfbdcc 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ wled-update.sh /wled00/Release /wled00/wled00.ino.cpp /wled00/html_*.h +/wled00/js_*.h diff --git a/tools/cdata.js b/tools/cdata.js index c9ae7eb659..3b2f3fafc4 100644 --- a/tools/cdata.js +++ b/tools/cdata.js @@ -26,7 +26,7 @@ const packageJson = require("../package.json"); // Export functions for testing module.exports = { isFileNewerThan, isAnyFileInFolderNewerThan }; -const output = ["wled00/html_ui.h", "wled00/html_pixart.h", "wled00/html_cpal.h", "wled00/html_edit.h", "wled00/html_pxmagic.h", "wled00/html_pixelforge.h", "wled00/html_settings.h", "wled00/html_other.h"] +const output = ["wled00/html_ui.h", "wled00/html_pixart.h", "wled00/html_cpal.h", "wled00/html_edit.h", "wled00/html_pxmagic.h", "wled00/html_pixelforge.h", "wled00/html_settings.h", "wled00/html_other.h", "wled00/js_iro.h"] // \x1b[34m is blue, \x1b[36m is cyan, \x1b[0m is reset const wledBanner = ` @@ -257,6 +257,19 @@ writeHtmlGzipped("wled00/data/pxmagic/pxmagic.htm", "wled00/html_pxmagic.h", 'px writeHtmlGzipped("wled00/data/pixelforge/pixelforge.htm", "wled00/html_pixelforge.h", 'pixelforge', false); // do not inline css //writeHtmlGzipped("wled00/data/edit.htm", "wled00/html_edit.h", 'edit'); +writeChunks( + "wled00/data/", + [ + { + file: "iro.js", + name: "JS_iro", + method: "gzip", + filter: "plain", // no minification, it is already minified + mangle: (s) => s.replace(/^\/\*![\s\S]*?\*\//, '') // remove license comment at the top + } + ], + "wled00/js_iro.h" +); writeChunks( "wled00/data", diff --git a/usermods/audioreactive/audio_reactive.cpp b/usermods/audioreactive/audio_reactive.cpp index d91e1bf2d3..7baa796894 100644 --- a/usermods/audioreactive/audio_reactive.cpp +++ b/usermods/audioreactive/audio_reactive.cpp @@ -1742,14 +1742,14 @@ class AudioReactive : public Usermod { } #endif } - if (root.containsKey(F("rmcpal")) && root[F("rmcpal")].as()) { + if (palettes > 0 && root.containsKey(F("rmcpal"))) { // handle removal of custom palettes from JSON call so we don't break things removeAudioPalettes(); } } void onStateChange(uint8_t callMode) override { - if (initDone && enabled && addPalettes && palettes==0 && customPalettes.size()<10) { + if (initDone && enabled && addPalettes && palettes==0 && customPalettes.size() pDoc; // barely enough to fit 72 numbers -> TODO: current format uses 214 bytes max per palette, why is this buffer so large? + unsigned emptyPaletteGap = 0; // count gaps in palette files to stop looking for more (each exists() call takes ~5ms) for (int index = 0; index < WLED_MAX_CUSTOM_PALETTES; index++) { char fileName[32]; sprintf_P(fileName, PSTR("/palette%d.json"), index); - - StaticJsonDocument<1536> pDoc; // barely enough to fit 72 numbers if (WLED_FS.exists(fileName)) { + emptyPaletteGap = 0; // reset gap counter if file exists DEBUGFX_PRINTF_P(PSTR("Reading palette from %s\n"), fileName); if (readObjectFromFile(fileName, nullptr, &pDoc)) { JsonArray pal = pDoc[F("palette")]; @@ -288,7 +289,8 @@ void loadCustomPalettes() { } } } else { - break; + emptyPaletteGap++; + if (emptyPaletteGap > WLED_MAX_CUSTOM_PALETTE_GAP) break; // stop looking for more palettes } } } diff --git a/wled00/const.h b/wled00/const.h index 333451ede4..9fa85dda05 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -15,6 +15,7 @@ constexpr size_t FIXED_PALETTE_COUNT = DYNAMIC_PALETTE_COUNT + FASTLED_PALETTE_C #else #define WLED_MAX_CUSTOM_PALETTES 10 // ESP8266: limit custom palettes to 10 #endif +#define WLED_MAX_CUSTOM_PALETTE_GAP 20 // max number of empty palette files in a row before stopping to look for more (20 takes 100ms) // You can define custom product info from build flags. // This is useful to allow API consumer to identify what type of WLED version diff --git a/wled00/data/cpal/cpal.htm b/wled00/data/cpal/cpal.htm index b8e0e08be1..144a0b5edf 100644 --- a/wled00/data/cpal/cpal.htm +++ b/wled00/data/cpal/cpal.htm @@ -1,646 +1,908 @@ - - - - - - WLED Custom Palette Editor - - - - - + + + WLED Palette Editor + + -
-
-

- - - - - - - WLED Palette Editor -

-
- -
-
-
-
- -
Custom palettes
-
-
- -
-
Click gradient to add. Box = color. Red = delete. Arrow = upload. Pencil = edit.
-
-
-
-
Static palettes
-
-
+
+

WLED Palette Editor

+ +
+
+
+ + + +
+
+ +
+
+ + +
+
+ + + +
+
+ +
+
+
+ + + +
+ +
+ Warning: Adding many custom palettes might cause stability issues, create backups +
+
+ +
+ +
+ +
+
+ +
+ +
by @dedehai
+
+ + + - - - + \ No newline at end of file diff --git a/wled00/data/index.htm b/wled00/data/index.htm index e37844f0c2..4d680cfbe2 100644 --- a/wled00/data/index.htm +++ b/wled00/data/index.htm @@ -10,7 +10,7 @@ WLED - +
Loading WLED UI...
@@ -129,8 +129,7 @@
- - +

Color palette

@@ -364,8 +363,9 @@ - + diff --git a/wled00/data/index.js b/wled00/data/index.js index df819a150a..14bf539265 100644 --- a/wled00/data/index.js +++ b/wled00/data/index.js @@ -8,6 +8,7 @@ var segLmax = 0; // size (in pixels) of largest selected segment var selectedFx = 0; var selectedPal = 0; var csel = 0; // selected color slot (0-2) +var cpick; // iro color picker var currentPreset = -1; var lastUpdate = 0; var segCount = 0, ledCount = 0, lowestUnused = 0, maxSeg = 0, lSeg = 0; @@ -42,16 +43,24 @@ var hol = [ [0, 0, 1, 1, "https://images.alphacoders.com/119/1198800.jpg"] // new year ]; -var cpick = new iro.ColorPicker("#picker", { - width: 260, - wheelLightness: false, - wheelAngle: 270, - wheelDirection: "clockwise", - layout: [{ - component: iro.ui.Wheel, - options: {} - }] -}); +// load iro.js sequentially to avoid 503 errors, retries until successful +(function loadIro() { + const l = d.createElement('script'); + l.src = 'iro.js'; + l.onload = () => { + cpick = new iro.ColorPicker("#picker", { + width: 260, + wheelLightness: false, + wheelAngle: 270, + wheelDirection: "clockwise", + layout: [{component: iro.ui.Wheel, options: {}}] + }); + d.readyState === 'complete' ? onLoad() : window.addEventListener('load', onLoad); + }; + l.onerror = () => setTimeout(loadIro, 100); + document.head.appendChild(l); +})(); + function handleVisibilityChange() {if (!d.hidden && new Date () - lastUpdate > 3000) requestJson();} function sCol(na, col) {d.documentElement.style.setProperty(na, col);} @@ -972,8 +981,6 @@ function populatePalettes() ); } } - if (li.cpalcount>0) gId("rmPal").classList.remove("hide"); - else gId("rmPal").classList.add("hide"); } function redrawPalPrev() @@ -1645,14 +1652,12 @@ function setEffectParameters(idx) paOnOff[0] = paOnOff[0].substring(0,dPos); } if (paOnOff.length>0 && paOnOff[0] != "!") text = paOnOff[0]; - gId("adPal").classList.remove("hide"); - if (lastinfo.cpalcount>0) gId("rmPal").classList.remove("hide"); + gId("editPal").classList.remove("hide"); } else { // disable palette list text += ' not used'; palw.style.display = "none"; - gId("adPal").classList.add("hide"); - gId("rmPal").classList.add("hide"); + gId("editPal").classList.add("hide"); // Close palette dialog if not available if (palw.lastElementChild.tagName == "DIALOG") { palw.lastElementChild.close(); diff --git a/wled00/json.cpp b/wled00/json.cpp index fd74e072c7..54bdd6d974 100644 --- a/wled00/json.cpp +++ b/wled00/json.cpp @@ -535,17 +535,15 @@ bool deserializeState(JsonObject root, byte callMode, byte presetId) else callMode = CALL_MODE_DIRECT_CHANGE; // possible bugfix for playlist only containing HTTP API preset FX=~ } - if (root.containsKey(F("rmcpal")) && root[F("rmcpal")].as()) { - if (customPalettes.size()) { - char fileName[32]; - sprintf_P(fileName, PSTR("/palette%d.json"), customPalettes.size()-1); - if (WLED_FS.exists(fileName)) WLED_FS.remove(fileName); - loadCustomPalettes(); - } + if (root.containsKey(F("rmcpal"))) { + char fileName[32]; + sprintf_P(fileName, PSTR("/palette%d.json"), root[F("rmcpal")].as()); + if (WLED_FS.exists(fileName)) WLED_FS.remove(fileName); + loadCustomPalettes(); } doAdvancePlaylist = root[F("np")] | doAdvancePlaylist; //advances to next preset in playlist when true - + JsonObject wifi = root[F("wifi")]; if (!wifi.isNull()) { bool apMode = getBoolVal(wifi[F("ap")], apActive); diff --git a/wled00/wled_server.cpp b/wled00/wled_server.cpp index ace5728347..687d734855 100644 --- a/wled00/wled_server.cpp +++ b/wled00/wled_server.cpp @@ -6,6 +6,7 @@ #include "html_ui.h" #include "html_settings.h" #include "html_other.h" +#include "js_iro.h" #ifdef WLED_ENABLE_PIXART #include "html_pixart.h" #endif @@ -36,6 +37,7 @@ static const char s_cache_control[] PROGMEM = "Cache-Control"; static const char s_no_store[] PROGMEM = "no-store"; static const char s_expires[] PROGMEM = "Expires"; static const char _common_js[] PROGMEM = "/common.js"; +static const char _iro_js[] PROGMEM = "/iro.js"; //Is this an IP? @@ -350,6 +352,10 @@ void initServer() handleStaticContent(request, FPSTR(_common_js), 200, FPSTR(CONTENT_TYPE_JAVASCRIPT), JS_common, JS_common_length); }); + server.on(_iro_js, HTTP_GET, [](AsyncWebServerRequest *request) { + handleStaticContent(request, FPSTR(_iro_js), 200, FPSTR(CONTENT_TYPE_JAVASCRIPT), JS_iro, JS_iro_length); + }); + //settings page server.on(F("/settings"), HTTP_GET, [](AsyncWebServerRequest *request){ serveSettings(request); From 354da8fdc04812dd9884c9318108f886edd35fb5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 11:26:02 +0000 Subject: [PATCH 053/164] Fix automatic reporting to preserve alwaysReport preference When automatic reporting was triggered, the third parameter (alwaysReport) was missing in the reportUpgradeEvent call. This caused the preference to be lost after the first automatic report, requiring the user to be prompted again on the next upgrade. Fixed by passing 'true' as the third parameter to maintain the user's preference for all future automatic reports. Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com> --- wled00/data/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wled00/data/index.js b/wled00/data/index.js index bc0e814135..754b006c1c 100644 --- a/wled00/data/index.js +++ b/wled00/data/index.js @@ -3346,7 +3346,7 @@ function checkVersionUpgrade(info) { // Version has changed if (versionInfo.alwaysReport) { // Automatically report if user opted in for always reporting - reportUpgradeEvent(info, storedVersion); + reportUpgradeEvent(info, storedVersion, true); } else { // Show upgrade prompt showVersionUpgradePrompt(info, storedVersion, currentVersion); From 1ca55e42af6e2a968c28a048e21cc0069f7e9bd1 Mon Sep 17 00:00:00 2001 From: Damian Schneider Date: Sat, 31 Jan 2026 17:40:53 +0100 Subject: [PATCH 054/164] fix relay not turning on at boot (#5315) These changes eliminate an elaborate race condition * add dedicated function to handle on/off and relay * add clarifying comment on output set order * add define for relay delay, honor forceOff in all cases --- usermods/deep_sleep/deep_sleep.cpp | 2 +- wled00/button.cpp | 17 +++++++++++------ wled00/const.h | 2 ++ wled00/fcn_declare.h | 1 + wled00/wled.cpp | 13 ++++++------- 5 files changed, 21 insertions(+), 14 deletions(-) diff --git a/usermods/deep_sleep/deep_sleep.cpp b/usermods/deep_sleep/deep_sleep.cpp index 65cebc5edf..cff40f86de 100644 --- a/usermods/deep_sleep/deep_sleep.cpp +++ b/usermods/deep_sleep/deep_sleep.cpp @@ -156,7 +156,7 @@ class DeepSleepUsermod : public Usermod { delay(1000); // just in case: give user a short ~10s window to turn LEDs on in UI (delaycounter is 10 by default) return; } - if (powerup == false && delaycounter) { // delay sleep in case a preset is being loaded and turnOnAtBoot is disabled (handleIO() does enable offMode temporarily in this case) + if (powerup == false && delaycounter) { // delay sleep in case a preset is being loaded and turnOnAtBoot is disabled (beginStrip() / handleIO() does enable offMode temporarily in this case) delaycounter--; if (delaycounter == 1 && offMode) { // force turn on, no matter the settings (device is bricked if user set sleepDelay=0, no bootup preset and turnOnAtBoot=false) if (briS == 0) bri = 10; // turn on and set low brightness to avoid automatic turn off diff --git a/wled00/button.cpp b/wled00/button.cpp index f6a07f5107..d544dd73ab 100644 --- a/wled00/button.cpp +++ b/wled00/button.cpp @@ -367,24 +367,29 @@ void handleIO() // if we want to control on-board LED (ESP8266) or relay we have to do it here as the final show() may not happen until // next loop() cycle - if (strip.getBrightness()) { + handleOnOff(); +} + +void handleOnOff(bool forceOff) +{ + if (strip.getBrightness() && !forceOff) { lastOnTime = millis(); if (offMode) { BusManager::on(); if (rlyPin>=0) { - pinMode(rlyPin, rlyOpenDrain ? OUTPUT_OPEN_DRAIN : OUTPUT); - digitalWrite(rlyPin, rlyMde); - delay(50); // wait for relay to switch and power to stabilize + // note: pinMode is set in first call to handleOnOff(true) in beginStrip() + digitalWrite(rlyPin, rlyMde); // set to on state + delay(RELAY_DELAY); // let power stabilize before sending LED data (#346 #812 #3581 #3955) } offMode = false; } - } else if (millis() - lastOnTime > 600 && !strip.needsUpdate()) { + } else if ((millis() - lastOnTime > 600 && !strip.needsUpdate()) || forceOff) { // for turning LED or relay off we need to wait until strip no longer needs updates (strip.trigger()) if (!offMode) { BusManager::off(); if (rlyPin>=0) { + digitalWrite(rlyPin, !rlyMde); // set output before disabling high-z state to avoid output glitches pinMode(rlyPin, rlyOpenDrain ? OUTPUT_OPEN_DRAIN : OUTPUT); - digitalWrite(rlyPin, !rlyMde); } offMode = true; } diff --git a/wled00/const.h b/wled00/const.h index 9fa85dda05..50eefe182b 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -114,6 +114,8 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); #endif #endif +#define RELAY_DELAY 50 // delay in ms between switching on relay and sending data to LEDs + #if defined(ESP8266) || defined(CONFIG_IDF_TARGET_ESP32S2) #define WLED_MAX_COLOR_ORDER_MAPPINGS 5 #else diff --git a/wled00/fcn_declare.h b/wled00/fcn_declare.h index 24c13aae84..3081a4930e 100644 --- a/wled00/fcn_declare.h +++ b/wled00/fcn_declare.h @@ -20,6 +20,7 @@ void longPressAction(uint8_t b=0); void doublePressAction(uint8_t b=0); bool isButtonPressed(uint8_t b=0); void handleButton(); +void handleOnOff(bool forceOff = false); void handleIO(); void IRAM_ATTR touchButtonISR(); diff --git a/wled00/wled.cpp b/wled00/wled.cpp index bb1befcdd6..df9a583332 100644 --- a/wled00/wled.cpp +++ b/wled00/wled.cpp @@ -591,6 +591,10 @@ void WLED::beginStrip() strip.setShowCallback(handleOverlayDraw); doInitBusses = false; + // init offMode and relay + offMode = false; // init to on state to allow proper relay init + handleOnOff(true); // init relay and force off + if (turnOnAtBoot) { if (briS > 0) bri = briS; else if (bri == 0) bri = 128; @@ -606,7 +610,8 @@ void WLED::beginStrip() } briLast = briS; bri = 0; strip.fill(BLACK); - strip.show(); + if (rlyPin < 0) + strip.show(); // ensure LEDs are off if no relay is used } colorUpdated(CALL_MODE_INIT); // will not send notification but will initiate transition if (bootPreset > 0) { @@ -614,12 +619,6 @@ void WLED::beginStrip() } strip.setTransition(transitionDelayDefault); // restore transitions - - // init relay pin - if (rlyPin >= 0) { - pinMode(rlyPin, rlyOpenDrain ? OUTPUT_OPEN_DRAIN : OUTPUT); - digitalWrite(rlyPin, (rlyMde ? bri : !bri)); - } } void WLED::initAP(bool resetAP) From 6d788a27b67d92984d811050e12aef656eb669ad Mon Sep 17 00:00:00 2001 From: Will Miles Date: Sat, 31 Jan 2026 14:03:11 -0500 Subject: [PATCH 055/164] Fix heap checks in bootloader update --- wled00/ota_update.cpp | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/wled00/ota_update.cpp b/wled00/ota_update.cpp index d8f64a141a..51e4112092 100644 --- a/wled00/ota_update.cpp +++ b/wled00/ota_update.cpp @@ -614,14 +614,13 @@ bool initBootloaderOTA(AsyncWebServerRequest *request) { strip.resetSegments(); // Check available heap before attempting allocation - size_t freeHeap = getFreeHeapSize(); - DEBUG_PRINTF_P(PSTR("Free heap before bootloader buffer allocation: %d bytes (need %d bytes)\n"), freeHeap, context->maxBootloaderSize); + DEBUG_PRINTF_P(PSTR("Free heap before bootloader buffer allocation: %d bytes (need %d bytes)\n"), getContiguousFreeHeap(), context->maxBootloaderSize); context->buffer = (uint8_t*)malloc(context->maxBootloaderSize); if (!context->buffer) { - size_t freeHeapNow = getFreeHeapSize(); - DEBUG_PRINTF_P(PSTR("Failed to allocate %d byte bootloader buffer! Free heap: %d bytes\n"), context->maxBootloaderSize, freeHeapNow); - context->errorMessage = "Out of memory! Free heap: " + String(freeHeapNow) + " bytes, need: " + String(context->maxBootloaderSize) + " bytes"; + size_t freeHeapNow = getContiguousFreeHeap(); + DEBUG_PRINTF_P(PSTR("Failed to allocate %d byte bootloader buffer! Contiguous heap: %d bytes\n"), context->maxBootloaderSize, freeHeapNow); + context->errorMessage = "Out of memory! Contiguous heap: " + String(freeHeapNow) + " bytes, need: " + String(context->maxBootloaderSize) + " bytes"; strip.resume(); #if WLED_WATCHDOG_TIMEOUT > 0 WLED::instance().enableWatchdog(); From 78166090bc942571b48ac281c40170d423575bd4 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Sat, 31 Jan 2026 14:03:22 -0500 Subject: [PATCH 056/164] Bootloader upload validation cleanup Co-authored-by: Codex --- wled00/ota_update.cpp | 153 +++++++++++------------------------------- 1 file changed, 41 insertions(+), 112 deletions(-) diff --git a/wled00/ota_update.cpp b/wled00/ota_update.cpp index 51e4112092..7638548f2f 100644 --- a/wled00/ota_update.cpp +++ b/wled00/ota_update.cpp @@ -18,9 +18,11 @@ constexpr size_t METADATA_OFFSET = 256; // ESP32: metadata appears afte #if defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32C6) constexpr size_t BOOTLOADER_OFFSET = 0x0000; // esp32-S3, esp32-C3 and (future support) esp32-c6 constexpr size_t BOOTLOADER_SIZE = 0x8000; // 32KB, typical bootloader size +#define BOOTLOADER_OTA_UNSUPPORTED // still needs validation on these platforms. #elif defined(CONFIG_IDF_TARGET_ESP32P4) || defined(CONFIG_IDF_TARGET_ESP32C5) constexpr size_t BOOTLOADER_OFFSET = 0x2000; // (future support) esp32-P4 and esp32-C5 constexpr size_t BOOTLOADER_SIZE = 0x8000; // 32KB, typical bootloader size +#define BOOTLOADER_OTA_UNSUPPORTED // still needs testing on these platforms #else constexpr size_t BOOTLOADER_OFFSET = 0x1000; // esp32 and esp32-s2 constexpr size_t BOOTLOADER_SIZE = 0x8000; // 32KB, typical bootloader size @@ -30,6 +32,7 @@ constexpr size_t BOOTLOADER_SIZE = 0x8000; // 32KB, typical bootloader size constexpr size_t METADATA_OFFSET = 0x1000; // ESP8266: metadata appears at 4KB offset #define UPDATE_ERROR getErrorString #endif + constexpr size_t METADATA_SEARCH_RANGE = 512; // bytes @@ -352,38 +355,24 @@ static void invalidateBootloaderSHA256Cache() { * @param bootloaderErrorMsg Pointer to String to store error message (must not be null) * @return true if validation passed, false otherwise */ -static bool verifyBootloaderImage(const uint8_t* &buffer, size_t &len, String* bootloaderErrorMsg) { - size_t availableLen = len; +static bool verifyBootloaderImage(const uint8_t* &buffer, size_t &len, String& bootloaderErrorMsg) { if (!bootloaderErrorMsg) { DEBUG_PRINTLN(F("bootloaderErrorMsg is null")); return false; } - // ESP32 image header structure (based on esp_image_format.h) - // Offset 0: magic (0xE9) - // Offset 1: segment_count - // Offset 2: spi_mode - // Offset 3: spi_speed (4 bits) + spi_size (4 bits) - // Offset 4-7: entry_addr (uint32_t) - // Offset 8: wp_pin - // Offset 9-11: spi_pin_drv[3] - // Offset 12-13: chip_id (uint16_t, little-endian) - // Offset 14: min_chip_rev - // Offset 15-22: reserved[8] - // Offset 23: hash_appended - - const size_t MIN_IMAGE_HEADER_SIZE = 24; + const size_t MIN_IMAGE_HEADER_SIZE = sizeof(esp_image_header_t); // 1. Validate minimum size for header if (len < MIN_IMAGE_HEADER_SIZE) { - *bootloaderErrorMsg = "Bootloader too small - invalid header"; + bootloaderErrorMsg = "Too small"; return false; } // Check if the bootloader starts at offset 0x1000 (common in partition table dumps) // This happens when someone uploads a complete flash dump instead of just the bootloader if (len > BOOTLOADER_OFFSET + MIN_IMAGE_HEADER_SIZE && - buffer[BOOTLOADER_OFFSET] == 0xE9 && - buffer[0] != 0xE9) { + buffer[BOOTLOADER_OFFSET] == ESP_IMAGE_HEADER_MAGIC && + buffer[0] != ESP_IMAGE_HEADER_MAGIC) { DEBUG_PRINTF_P(PSTR("Bootloader magic byte detected at offset 0x%04X - adjusting buffer\n"), BOOTLOADER_OFFSET); // Adjust buffer pointer to start at the actual bootloader buffer = buffer + BOOTLOADER_OFFSET; @@ -391,106 +380,43 @@ static bool verifyBootloaderImage(const uint8_t* &buffer, size_t &len, String* b // Re-validate size after adjustment if (len < MIN_IMAGE_HEADER_SIZE) { - *bootloaderErrorMsg = "Bootloader at offset 0x1000 too small - invalid header"; + bootloaderErrorMsg = "Too small"; return false; } } - // 2. Magic byte check (matches esp_image_verify step 1) - if (buffer[0] != 0xE9) { - *bootloaderErrorMsg = "Invalid bootloader magic byte (expected 0xE9, got 0x" + String(buffer[0], HEX) + ")"; - return false; - } - - // 3. Segment count validation (matches esp_image_verify step 2) - uint8_t segmentCount = buffer[1]; - if (segmentCount == 0 || segmentCount > 16) { - *bootloaderErrorMsg = "Invalid segment count: " + String(segmentCount); - return false; - } - - // 4. SPI mode validation (basic sanity check) - uint8_t spiMode = buffer[2]; - if (spiMode > 3) { // Valid modes are 0-3 (QIO, QOUT, DIO, DOUT) - *bootloaderErrorMsg = "Invalid SPI mode: " + String(spiMode); + size_t availableLen = len; + esp_image_header_t imageHeader{}; + memcpy(&imageHeader, buffer, sizeof(imageHeader)); + + // 2. Basic header sanity checks (matches early esp_image_verify checks) + if (imageHeader.magic != ESP_IMAGE_HEADER_MAGIC || + imageHeader.segment_count == 0 || imageHeader.segment_count > 16 || + imageHeader.spi_mode > 3 || + imageHeader.entry_addr < 0x40000000 || imageHeader.entry_addr > 0x50000000) { + bootloaderErrorMsg = "Invalid header"; return false; } - // 5. Chip ID validation (matches esp_image_verify step 3) - uint16_t chipId = buffer[12] | (buffer[13] << 8); // Little-endian - - // Known ESP32 chip IDs from ESP-IDF: - // 0x0000 = ESP32 - // 0x0002 = ESP32-S2 - // 0x0005 = ESP32-C3 - // 0x0009 = ESP32-S3 - // 0x000C = ESP32-C2 - // 0x000D = ESP32-C6 - // 0x0010 = ESP32-H2 - - #if defined(CONFIG_IDF_TARGET_ESP32) - if (chipId != 0x0000) { - *bootloaderErrorMsg = "Chip ID mismatch - expected ESP32 (0x0000), got 0x" + String(chipId, HEX); - return false; - } - #elif defined(CONFIG_IDF_TARGET_ESP32S2) - if (chipId != 0x0002) { - *bootloaderErrorMsg = "Chip ID mismatch - expected ESP32-S2 (0x0002), got 0x" + String(chipId, HEX); - return false; - } - #elif defined(CONFIG_IDF_TARGET_ESP32C3) - if (chipId != 0x0005) { - *bootloaderErrorMsg = "Chip ID mismatch - expected ESP32-C3 (0x0005), got 0x" + String(chipId, HEX); - return false; - } - *bootloaderErrorMsg = "ESP32-C3 update not supported yet"; - return false; - #elif defined(CONFIG_IDF_TARGET_ESP32S3) - if (chipId != 0x0009) { - *bootloaderErrorMsg = "Chip ID mismatch - expected ESP32-S3 (0x0009), got 0x" + String(chipId, HEX); - return false; - } - *bootloaderErrorMsg = "ESP32-S3 update not supported yet"; - return false; - #elif defined(CONFIG_IDF_TARGET_ESP32C6) - if (chipId != 0x000D) { - *bootloaderErrorMsg = "Chip ID mismatch - expected ESP32-C6 (0x000D), got 0x" + String(chipId, HEX); - return false; - } - *bootloaderErrorMsg = "ESP32-C6 update not supported yet"; - return false; - #else - // Generic validation - chip ID should be valid - if (chipId > 0x00FF) { - *bootloaderErrorMsg = "Invalid chip ID: 0x" + String(chipId, HEX); - return false; - } - *bootloaderErrorMsg = "Unknown ESP32 target - bootloader update not supported"; - return false; - #endif - - // 6. Entry point validation (should be in valid memory range) - uint32_t entryAddr = buffer[4] | (buffer[5] << 8) | (buffer[6] << 16) | (buffer[7] << 24); - // ESP32 bootloader entry points are typically in IRAM range (0x40000000 - 0x40400000) - // or ROM range (0x40000000 and above) - if (entryAddr < 0x40000000 || entryAddr > 0x50000000) { - *bootloaderErrorMsg = "Invalid entry address: 0x" + String(entryAddr, HEX); + // 3. Chip ID validation (matches esp_image_verify step 3) + if (imageHeader.chip_id != CONFIG_IDF_FIRMWARE_CHIP_ID) { + bootloaderErrorMsg = "Chip ID mismatch"; return false; } - // 7. Basic segment structure validation + // 4. Basic segment structure validation // Each segment has a header: load_addr (4 bytes) + data_len (4 bytes) size_t offset = MIN_IMAGE_HEADER_SIZE; size_t actualBootloaderSize = MIN_IMAGE_HEADER_SIZE; - for (uint8_t i = 0; i < segmentCount && offset + 8 <= len; i++) { + for (uint8_t i = 0; i < imageHeader.segment_count && offset + 8 <= len; i++) { uint32_t segmentSize = buffer[offset + 4] | (buffer[offset + 5] << 8) | (buffer[offset + 6] << 16) | (buffer[offset + 7] << 24); // Segment size sanity check // ESP32 classic bootloader segments can be larger, C3 are smaller if (segmentSize > 0x20000) { // 128KB max per segment (very generous) - *bootloaderErrorMsg = "Segment " + String(i) + " too large: " + String(segmentSize) + " bytes"; + bootloaderErrorMsg = "Segment " + String(i) + " too large: " + String(segmentSize) + " bytes"; return false; } @@ -499,29 +425,28 @@ static bool verifyBootloaderImage(const uint8_t* &buffer, size_t &len, String* b actualBootloaderSize = offset; - // 8. Check for appended SHA256 hash (byte 23 in header) + // 5. Check for appended SHA256 hash (byte 23 in header) // If hash_appended != 0, there's a 32-byte SHA256 hash after the segments - uint8_t hashAppended = buffer[23]; - if (hashAppended != 0) { + if (imageHeader.hash_appended != 0) { actualBootloaderSize += 32; if (actualBootloaderSize > availableLen) { - *bootloaderErrorMsg = "Bootloader missing SHA256 trailer"; + bootloaderErrorMsg = "Bootloader missing SHA256 trailer"; return false; } DEBUG_PRINTF_P(PSTR("Bootloader has appended SHA256 hash\n")); } - // 9. The image may also have a 1-byte checksum after segments/hash + // 6. The image may also have a 1-byte checksum after segments/hash // Check if there's at least one more byte available if (actualBootloaderSize + 1 <= availableLen) { // There's likely a checksum byte actualBootloaderSize += 1; } else if (actualBootloaderSize > availableLen) { - *bootloaderErrorMsg = "Bootloader truncated before checksum"; + bootloaderErrorMsg = "Bootloader truncated before checksum"; return false; } - // 10. Align to 16 bytes (ESP32 requirement for flash writes) + // 7. Align to 16 bytes (ESP32 requirement for flash writes) // The bootloader image must be 16-byte aligned if (actualBootloaderSize % 16 != 0) { size_t alignedSize = ((actualBootloaderSize + 15) / 16) * 16; @@ -532,16 +457,16 @@ static bool verifyBootloaderImage(const uint8_t* &buffer, size_t &len, String* b } DEBUG_PRINTF_P(PSTR("Bootloader validation: %d segments, actual size %d bytes (buffer size %d bytes, hash_appended=%d)\n"), - segmentCount, actualBootloaderSize, len, hashAppended); + imageHeader.segment_count, actualBootloaderSize, len, imageHeader.hash_appended); - // 11. Verify we have enough data for all segments + hash + checksum + // 8. Verify we have enough data for all segments + hash + checksum if (actualBootloaderSize > availableLen) { - *bootloaderErrorMsg = "Bootloader truncated - expected at least " + String(actualBootloaderSize) + " bytes, have " + String(availableLen) + " bytes"; + bootloaderErrorMsg = "Bootloader truncated - expected at least " + String(actualBootloaderSize) + " bytes, have " + String(availableLen) + " bytes"; return false; } if (offset > availableLen) { - *bootloaderErrorMsg = "Bootloader truncated - expected at least " + String(offset) + " bytes, have " + String(len) + " bytes"; + bootloaderErrorMsg = "Bootloader truncated - expected at least " + String(offset) + " bytes, have " + String(len) + " bytes"; return false; } @@ -601,10 +526,13 @@ bool initBootloaderOTA(AsyncWebServerRequest *request) { DEBUG_PRINTLN(F("Failed to allocate bootloader OTA context")); return false; } - request->_tempObject = context; request->onDisconnect([=]() { endBootloaderOTA(request); }); // ensures cleanup on disconnect +#ifdef BOOTLOADER_OTA_UNSUPPORTED + context->errorMessage = F("Bootloader update not supported on this chip"); + return false; +#else DEBUG_PRINTLN(F("Bootloader Update Start - initializing buffer")); #if WLED_WATCHDOG_TIMEOUT > 0 WLED::instance().disableWatchdog(); @@ -630,6 +558,7 @@ bool initBootloaderOTA(AsyncWebServerRequest *request) { context->bytesBuffered = 0; return true; +#endif } // Set bootloader OTA replied flag @@ -709,7 +638,7 @@ void handleBootloaderOTAData(AsyncWebServerRequest *request, size_t index, uint8 // Verify the complete bootloader image before flashing // Note: verifyBootloaderImage may adjust bootloaderData pointer and bootloaderSize // for validation purposes only - if (!verifyBootloaderImage(bootloaderData, bootloaderSize, &context->errorMessage)) { + if (!verifyBootloaderImage(bootloaderData, bootloaderSize, context->errorMessage)) { DEBUG_PRINTLN(F("Bootloader validation failed!")); // Error message already set by verifyBootloaderImage } else { From b51e7b65f954142d8ee7c70cf513b3fe4bb3d670 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Sat, 31 Jan 2026 14:02:08 -0500 Subject: [PATCH 057/164] Factor out bootloader size estimate Co-authored-by: Codex --- wled00/ota_update.cpp | 112 +++++++++++++++++++++--------------------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/wled00/ota_update.cpp b/wled00/ota_update.cpp index 7638548f2f..c818182ce4 100644 --- a/wled00/ota_update.cpp +++ b/wled00/ota_update.cpp @@ -347,6 +347,50 @@ static void invalidateBootloaderSHA256Cache() { bootloaderSHA256CacheValid = false; } +/** + * Compute bootloader size based on image header and segment layout. + * Returns total size in bytes when valid, or 0 when invalid. + */ +static size_t getBootloaderImageSize(const esp_image_header_t &header, + size_t availableLen) { + size_t offset = sizeof(esp_image_header_t); + size_t actualBootloaderSize = offset; + const uint8_t* buffer = reinterpret_cast(&header); + + if (offset + (header.segment_count*8) > availableLen) { + // Not enough space for segments + return 0; + } + + for (uint8_t i = 0; i < header.segment_count; i++) { + uint32_t segmentSize = buffer[offset + 4] | (buffer[offset + 5] << 8) | + (buffer[offset + 6] << 16) | (buffer[offset + 7] << 24); + + // Segment size sanity check + // ESP32 classic bootloader segments can be larger, C3 are smaller + if (segmentSize > 0x20000) { // 128KB max per segment (very generous) + return 0; + } + + offset += 8 + segmentSize; // Skip segment header and data + } + + actualBootloaderSize = offset; + + // Check for appended SHA256 hash (byte 23 in header) + // If hash_appended != 0, there's a 32-byte SHA256 hash after the segments + if (header.hash_appended != 0) { + actualBootloaderSize += 32; + } + + // Sometimes there is a checksum byte + if (availableLen > actualBootloaderSize) { + actualBootloaderSize += 1; + } + + return actualBootloaderSize; +} + /** * Verify complete buffered bootloader using ESP-IDF validation approach * This matches the key validation steps from esp_image_verify() in ESP-IDF @@ -404,71 +448,27 @@ static bool verifyBootloaderImage(const uint8_t* &buffer, size_t &len, String& b return false; } - // 4. Basic segment structure validation - // Each segment has a header: load_addr (4 bytes) + data_len (4 bytes) - size_t offset = MIN_IMAGE_HEADER_SIZE; - size_t actualBootloaderSize = MIN_IMAGE_HEADER_SIZE; - - for (uint8_t i = 0; i < imageHeader.segment_count && offset + 8 <= len; i++) { - uint32_t segmentSize = buffer[offset + 4] | (buffer[offset + 5] << 8) | - (buffer[offset + 6] << 16) | (buffer[offset + 7] << 24); - - // Segment size sanity check - // ESP32 classic bootloader segments can be larger, C3 are smaller - if (segmentSize > 0x20000) { // 128KB max per segment (very generous) - bootloaderErrorMsg = "Segment " + String(i) + " too large: " + String(segmentSize) + " bytes"; - return false; - } - - offset += 8 + segmentSize; // Skip segment header and data - } - - actualBootloaderSize = offset; - - // 5. Check for appended SHA256 hash (byte 23 in header) - // If hash_appended != 0, there's a 32-byte SHA256 hash after the segments - if (imageHeader.hash_appended != 0) { - actualBootloaderSize += 32; - if (actualBootloaderSize > availableLen) { - bootloaderErrorMsg = "Bootloader missing SHA256 trailer"; - return false; - } - DEBUG_PRINTF_P(PSTR("Bootloader has appended SHA256 hash\n")); - } - - // 6. The image may also have a 1-byte checksum after segments/hash - // Check if there's at least one more byte available - if (actualBootloaderSize + 1 <= availableLen) { - // There's likely a checksum byte - actualBootloaderSize += 1; - } else if (actualBootloaderSize > availableLen) { - bootloaderErrorMsg = "Bootloader truncated before checksum"; + // 4. Validate image size + size_t actualBootloaderSize = getBootloaderImageSize(imageHeader, availableLen); + if (actualBootloaderSize == 0) { + bootloaderErrorMsg = "Invalid image"; return false; } - - // 7. Align to 16 bytes (ESP32 requirement for flash writes) + + // 5. Align to 16 bytes (ESP32 requirement for flash writes) // The bootloader image must be 16-byte aligned if (actualBootloaderSize % 16 != 0) { - size_t alignedSize = ((actualBootloaderSize + 15) / 16) * 16; - // Make sure we don't exceed available data - if (alignedSize <= len) { - actualBootloaderSize = alignedSize; - } + actualBootloaderSize = ((actualBootloaderSize + 15) / 16) * 16; } - DEBUG_PRINTF_P(PSTR("Bootloader validation: %d segments, actual size %d bytes (buffer size %d bytes, hash_appended=%d)\n"), - imageHeader.segment_count, actualBootloaderSize, len, imageHeader.hash_appended); - - // 8. Verify we have enough data for all segments + hash + checksum - if (actualBootloaderSize > availableLen) { - bootloaderErrorMsg = "Bootloader truncated - expected at least " + String(actualBootloaderSize) + " bytes, have " + String(availableLen) + " bytes"; + if (actualBootloaderSize > len) { + // Same as above + bootloaderErrorMsg = "Too small"; return false; } - if (offset > availableLen) { - bootloaderErrorMsg = "Bootloader truncated - expected at least " + String(offset) + " bytes, have " + String(len) + " bytes"; - return false; - } + DEBUG_PRINTF_P(PSTR("Bootloader validation: %d segments, actual size %d bytes (buffer size %d bytes, hash_appended=%d)\n"), + imageHeader.segment_count, actualBootloaderSize, len, imageHeader.hash_appended); // Update len to reflect actual bootloader size (including hash and checksum, with alignment) // This is critical - we must write the complete image including checksums From 76c25da58e84e503f411841614efc9b6bbdf2c24 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Sat, 31 Jan 2026 14:11:33 -0500 Subject: [PATCH 058/164] Use bootloader size in hash calculation Co-authored-by: Codex --- wled00/ota_update.cpp | 103 +++++++++++++++++++++++------------------- 1 file changed, 56 insertions(+), 47 deletions(-) diff --git a/wled00/ota_update.cpp b/wled00/ota_update.cpp index c818182ce4..711134d6c7 100644 --- a/wled00/ota_update.cpp +++ b/wled00/ota_update.cpp @@ -289,6 +289,50 @@ void markOTAvalid() { } #if defined(ARDUINO_ARCH_ESP32) && !defined(WLED_DISABLE_OTA) +/** + * Compute bootloader size based on image header and segment layout. + * Returns total size in bytes when valid, or 0 when invalid. + */ +static size_t getBootloaderImageSize(const esp_image_header_t &header, + size_t availableLen) { + size_t offset = sizeof(esp_image_header_t); + size_t actualBootloaderSize = offset; + const uint8_t* buffer = reinterpret_cast(&header); + + if (offset + (header.segment_count*8) > availableLen) { + // Not enough space for segments + return 0; + } + + for (uint8_t i = 0; i < header.segment_count; i++) { + uint32_t segmentSize = buffer[offset + 4] | (buffer[offset + 5] << 8) | + (buffer[offset + 6] << 16) | (buffer[offset + 7] << 24); + + // Segment size sanity check + // ESP32 classic bootloader segments can be larger, C3 are smaller + if (segmentSize > 0x20000) { // 128KB max per segment (very generous) + return 0; + } + + offset += 8 + segmentSize; // Skip segment header and data + } + + actualBootloaderSize = offset; + + // Check for appended SHA256 hash (byte 23 in header) + // If hash_appended != 0, there's a 32-byte SHA256 hash after the segments + if (header.hash_appended != 0) { + actualBootloaderSize += 32; + } + + // Sometimes there is a checksum byte + if (availableLen > actualBootloaderSize) { + actualBootloaderSize += 1; + } + + return actualBootloaderSize; +} + static bool bootloaderSHA256CacheValid = false; static uint8_t bootloaderSHA256Cache[32]; @@ -303,11 +347,20 @@ static void calculateBootloaderSHA256() { mbedtls_sha256_starts(&ctx, 0); // 0 = SHA256 (not SHA224) const size_t chunkSize = 256; - uint8_t buffer[chunkSize]; + alignas(esp_image_header_t) uint8_t buffer[chunkSize]; + size_t bootloaderSize = BOOTLOADER_SIZE; - for (uint32_t offset = 0; offset < BOOTLOADER_SIZE; offset += chunkSize) { - size_t readSize = min((size_t)(BOOTLOADER_SIZE - offset), chunkSize); + for (uint32_t offset = 0; offset < bootloaderSize; offset += chunkSize) { + size_t readSize = min((size_t)(bootloaderSize - offset), chunkSize); if (esp_flash_read(NULL, buffer, BOOTLOADER_OFFSET + offset, readSize) == ESP_OK) { + if (offset == 0 && readSize >= sizeof(esp_image_header_t)) { + const esp_image_header_t& header = *reinterpret_cast(buffer); + size_t imageSize = getBootloaderImageSize(header, readSize); + if (imageSize > 0 && imageSize <= BOOTLOADER_SIZE) { + bootloaderSize = imageSize; + readSize = min(readSize, bootloaderSize); + } + } mbedtls_sha256_update(&ctx, buffer, readSize); } } @@ -347,50 +400,6 @@ static void invalidateBootloaderSHA256Cache() { bootloaderSHA256CacheValid = false; } -/** - * Compute bootloader size based on image header and segment layout. - * Returns total size in bytes when valid, or 0 when invalid. - */ -static size_t getBootloaderImageSize(const esp_image_header_t &header, - size_t availableLen) { - size_t offset = sizeof(esp_image_header_t); - size_t actualBootloaderSize = offset; - const uint8_t* buffer = reinterpret_cast(&header); - - if (offset + (header.segment_count*8) > availableLen) { - // Not enough space for segments - return 0; - } - - for (uint8_t i = 0; i < header.segment_count; i++) { - uint32_t segmentSize = buffer[offset + 4] | (buffer[offset + 5] << 8) | - (buffer[offset + 6] << 16) | (buffer[offset + 7] << 24); - - // Segment size sanity check - // ESP32 classic bootloader segments can be larger, C3 are smaller - if (segmentSize > 0x20000) { // 128KB max per segment (very generous) - return 0; - } - - offset += 8 + segmentSize; // Skip segment header and data - } - - actualBootloaderSize = offset; - - // Check for appended SHA256 hash (byte 23 in header) - // If hash_appended != 0, there's a 32-byte SHA256 hash after the segments - if (header.hash_appended != 0) { - actualBootloaderSize += 32; - } - - // Sometimes there is a checksum byte - if (availableLen > actualBootloaderSize) { - actualBootloaderSize += 1; - } - - return actualBootloaderSize; -} - /** * Verify complete buffered bootloader using ESP-IDF validation approach * This matches the key validation steps from esp_image_verify() in ESP-IDF From 642c99a6170e3045d34303d9676421e5652aeba3 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Sat, 31 Jan 2026 17:18:40 -0500 Subject: [PATCH 059/164] Stream bootloader size validation Use a stateful object to allow bootloader size calculation to operate on a stream of data blocks instead of requiring a single flat read. This allows it to work when calculating the bootloader hash as well as during update validation. Co-authored-by: Codex --- wled00/ota_update.cpp | 143 +++++++++++++++++++++++++++++------------- 1 file changed, 101 insertions(+), 42 deletions(-) diff --git a/wled00/ota_update.cpp b/wled00/ota_update.cpp index 711134d6c7..4a2b724dfa 100644 --- a/wled00/ota_update.cpp +++ b/wled00/ota_update.cpp @@ -289,49 +289,98 @@ void markOTAvalid() { } #if defined(ARDUINO_ARCH_ESP32) && !defined(WLED_DISABLE_OTA) -/** - * Compute bootloader size based on image header and segment layout. - * Returns total size in bytes when valid, or 0 when invalid. - */ -static size_t getBootloaderImageSize(const esp_image_header_t &header, - size_t availableLen) { - size_t offset = sizeof(esp_image_header_t); - size_t actualBootloaderSize = offset; - const uint8_t* buffer = reinterpret_cast(&header); +class BootloaderImageSizer { +public: - if (offset + (header.segment_count*8) > availableLen) { - // Not enough space for segments - return 0; - } + bool feed(const uint8_t* data, size_t len) { + if (error) return false; + + //DEBUG_PRINTF("Feed %d\n", len); + + if (imageSize == 0) { + // Parse header first + if (len < sizeof(esp_image_header_t)) { + error = true; + return false; + } - for (uint8_t i = 0; i < header.segment_count; i++) { - uint32_t segmentSize = buffer[offset + 4] | (buffer[offset + 5] << 8) | - (buffer[offset + 6] << 16) | (buffer[offset + 7] << 24); + esp_image_header_t header; + memcpy(&header, data, sizeof(esp_image_header_t)); - // Segment size sanity check - // ESP32 classic bootloader segments can be larger, C3 are smaller - if (segmentSize > 0x20000) { // 128KB max per segment (very generous) - return 0; + if (header.segment_count == 0) { + error = true; + return false; + } + + imageSize = sizeof(esp_image_header_t); + if (header.hash_appended) { + imageSize += 32; + } + segmentsLeft = header.segment_count; + data += sizeof(esp_image_header_t); + len -= sizeof(esp_image_header_t); + DEBUG_PRINTF("BLS parsed image header, segment count %d, is %d\n", segmentsLeft, imageSize); } - offset += 8 + segmentSize; // Skip segment header and data - } + while (len && segmentsLeft) { + if (segmentHeaderBytes < sizeof(esp_image_segment_header_t)) { + size_t headerBytes = std::min(len, sizeof(esp_image_segment_header_t) - segmentHeaderBytes); + memcpy(&segmentHeader, data, headerBytes); + segmentHeaderBytes += headerBytes; + if (segmentHeaderBytes < sizeof(esp_image_segment_header_t)) { + return true; // needs more bytes for the header + } + + DEBUG_PRINTF("BLS parsed segment [%08X %08X=%d], segment count %d, is %d\n", segmentHeader.load_addr, segmentHeader.data_len, segmentHeader.data_len, segmentsLeft, imageSize); - actualBootloaderSize = offset; + // Validate segment size + if (segmentHeader.data_len > BOOTLOADER_SIZE) { + error = true; + return false; + } + + data += headerBytes; + len -= headerBytes; + imageSize += sizeof(esp_image_segment_header_t) + segmentHeader.data_len; + --segmentsLeft; + if (segmentsLeft == 0) { + // all done, actually; we don't need to read any more + DEBUG_PRINTF("BLS complete, is %d\n", imageSize); + return false; + } + } + + // If we don't have enough bytes ... + if (len < segmentHeader.data_len) { + //DEBUG_PRINTF("Needs more bytes\n"); + segmentHeader.data_len -= len; + return true; // still in this segment + } + + // Segment complete + len -= segmentHeader.data_len; + data += segmentHeader.data_len; + segmentHeaderBytes = 0; + //DEBUG_PRINTF("Segment complete: len %d\n", len); + } - // Check for appended SHA256 hash (byte 23 in header) - // If hash_appended != 0, there's a 32-byte SHA256 hash after the segments - if (header.hash_appended != 0) { - actualBootloaderSize += 32; + return !error; } - // Sometimes there is a checksum byte - if (availableLen > actualBootloaderSize) { - actualBootloaderSize += 1; + bool hasError() const { return error; } + bool isSizeKnown() const { return !error && imageSize != 0 && segmentsLeft == 0; } + size_t totalSize() const { + if (!isSizeKnown()) return 0; + return imageSize; } - return actualBootloaderSize; -} +private: + size_t imageSize = 0; + size_t segmentsLeft = 0; + esp_image_segment_header_t segmentHeader; + size_t segmentHeaderBytes = 0; + bool error = false; +}; static bool bootloaderSHA256CacheValid = false; static uint8_t bootloaderSHA256Cache[32]; @@ -349,19 +398,27 @@ static void calculateBootloaderSHA256() { const size_t chunkSize = 256; alignas(esp_image_header_t) uint8_t buffer[chunkSize]; size_t bootloaderSize = BOOTLOADER_SIZE; + BootloaderImageSizer sizer; for (uint32_t offset = 0; offset < bootloaderSize; offset += chunkSize) { size_t readSize = min((size_t)(bootloaderSize - offset), chunkSize); if (esp_flash_read(NULL, buffer, BOOTLOADER_OFFSET + offset, readSize) == ESP_OK) { - if (offset == 0 && readSize >= sizeof(esp_image_header_t)) { - const esp_image_header_t& header = *reinterpret_cast(buffer); - size_t imageSize = getBootloaderImageSize(header, readSize); - if (imageSize > 0 && imageSize <= BOOTLOADER_SIZE) { - bootloaderSize = imageSize; - readSize = min(readSize, bootloaderSize); + sizer.feed(buffer, readSize); + + size_t hashLen = readSize; + if (sizer.isSizeKnown()) { + size_t totalSize = sizer.totalSize(); + if (totalSize > 0 && totalSize <= BOOTLOADER_SIZE) { + bootloaderSize = totalSize; + if (offset + readSize > totalSize) { + hashLen = (totalSize > offset) ? (totalSize - offset) : 0; + } } } - mbedtls_sha256_update(&ctx, buffer, readSize); + + if (hashLen > 0) { + mbedtls_sha256_update(&ctx, buffer, hashLen); + } } } @@ -426,7 +483,7 @@ static bool verifyBootloaderImage(const uint8_t* &buffer, size_t &len, String& b if (len > BOOTLOADER_OFFSET + MIN_IMAGE_HEADER_SIZE && buffer[BOOTLOADER_OFFSET] == ESP_IMAGE_HEADER_MAGIC && buffer[0] != ESP_IMAGE_HEADER_MAGIC) { - DEBUG_PRINTF_P(PSTR("Bootloader magic byte detected at offset 0x%04X - adjusting buffer\n"), BOOTLOADER_OFFSET); + DEBUG_PRINTF_P(PSTR("Bootloader detected at offset\n")); // Adjust buffer pointer to start at the actual bootloader buffer = buffer + BOOTLOADER_OFFSET; len = len - BOOTLOADER_OFFSET; @@ -458,11 +515,13 @@ static bool verifyBootloaderImage(const uint8_t* &buffer, size_t &len, String& b } // 4. Validate image size - size_t actualBootloaderSize = getBootloaderImageSize(imageHeader, availableLen); - if (actualBootloaderSize == 0) { + BootloaderImageSizer sizer; + sizer.feed(buffer, availableLen); + if (sizer.hasError() || !sizer.isSizeKnown()) { bootloaderErrorMsg = "Invalid image"; return false; } + size_t actualBootloaderSize = sizer.totalSize(); // 5. Align to 16 bytes (ESP32 requirement for flash writes) // The bootloader image must be 16-byte aligned From 03d0522cf19ea2618fd6e6d8e9c2ecaf6a197af4 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Sat, 31 Jan 2026 17:24:38 -0500 Subject: [PATCH 060/164] Fix null test --- wled00/ota_update.cpp | 4 ---- 1 file changed, 4 deletions(-) diff --git a/wled00/ota_update.cpp b/wled00/ota_update.cpp index 4a2b724dfa..179871d0fa 100644 --- a/wled00/ota_update.cpp +++ b/wled00/ota_update.cpp @@ -466,10 +466,6 @@ static void invalidateBootloaderSHA256Cache() { * @return true if validation passed, false otherwise */ static bool verifyBootloaderImage(const uint8_t* &buffer, size_t &len, String& bootloaderErrorMsg) { - if (!bootloaderErrorMsg) { - DEBUG_PRINTLN(F("bootloaderErrorMsg is null")); - return false; - } const size_t MIN_IMAGE_HEADER_SIZE = sizeof(esp_image_header_t); // 1. Validate minimum size for header From 2434a9624e355a2b4b3297394edd8d588b528c83 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Sat, 31 Jan 2026 18:59:14 -0500 Subject: [PATCH 061/164] Ensure bootloader hashes match Ensure that our calculation function returns the same value as the image internal hash. --- wled00/ota_update.cpp | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/wled00/ota_update.cpp b/wled00/ota_update.cpp index 179871d0fa..27ca692bad 100644 --- a/wled00/ota_update.cpp +++ b/wled00/ota_update.cpp @@ -289,6 +289,9 @@ void markOTAvalid() { } #if defined(ARDUINO_ARCH_ESP32) && !defined(WLED_DISABLE_OTA) + +// Class for computing the expected bootloader data size given a stream of the data. +// If the image includes an SHA256 appended after the data stream, we do not consider it here. class BootloaderImageSizer { public: @@ -313,9 +316,6 @@ class BootloaderImageSizer { } imageSize = sizeof(esp_image_header_t); - if (header.hash_appended) { - imageSize += 32; - } segmentsLeft = header.segment_count; data += sizeof(esp_image_header_t); len -= sizeof(esp_image_header_t); @@ -345,6 +345,11 @@ class BootloaderImageSizer { --segmentsLeft; if (segmentsLeft == 0) { // all done, actually; we don't need to read any more + + // Round up to nearest 16 bytes. + // Always add 1 to account for the checksum byte. + imageSize = ((imageSize/ 16) + 1) * 16; + DEBUG_PRINTF("BLS complete, is %d\n", imageSize); return false; } @@ -387,7 +392,13 @@ static uint8_t bootloaderSHA256Cache[32]; /** * Calculate and cache the bootloader SHA256 digest - * Reads the bootloader from flash at offset 0x1000 and computes SHA256 hash + * Reads the bootloader from flash and computes SHA256 hash + * + * Strictly speaking, most bootloader images already contain a hash at the end of the image; + * we could in theory just read it. The trouble is that we have to parse the structure anyways + * to find the actual endpoint, so we might as well always calculate it ourselves rather than + * handle a special case if the hash isn't stored. + * */ static void calculateBootloaderSHA256() { // Calculate SHA256 @@ -399,6 +410,7 @@ static void calculateBootloaderSHA256() { alignas(esp_image_header_t) uint8_t buffer[chunkSize]; size_t bootloaderSize = BOOTLOADER_SIZE; BootloaderImageSizer sizer; + size_t totalHashLen = 0; for (uint32_t offset = 0; offset < bootloaderSize; offset += chunkSize) { size_t readSize = min((size_t)(bootloaderSize - offset), chunkSize); @@ -417,6 +429,7 @@ static void calculateBootloaderSHA256() { } if (hashLen > 0) { + totalHashLen += hashLen; mbedtls_sha256_update(&ctx, buffer, hashLen); } } @@ -424,6 +437,7 @@ static void calculateBootloaderSHA256() { mbedtls_sha256_finish(&ctx, bootloaderSHA256Cache); mbedtls_sha256_free(&ctx); + bootloaderSHA256CacheValid = true; } @@ -513,18 +527,17 @@ static bool verifyBootloaderImage(const uint8_t* &buffer, size_t &len, String& b // 4. Validate image size BootloaderImageSizer sizer; sizer.feed(buffer, availableLen); - if (sizer.hasError() || !sizer.isSizeKnown()) { + if (!sizer.isSizeKnown()) { bootloaderErrorMsg = "Invalid image"; return false; } size_t actualBootloaderSize = sizer.totalSize(); - - // 5. Align to 16 bytes (ESP32 requirement for flash writes) - // The bootloader image must be 16-byte aligned - if (actualBootloaderSize % 16 != 0) { - actualBootloaderSize = ((actualBootloaderSize + 15) / 16) * 16; - } + // 5. SHA256 checksum (optional) + if (imageHeader.hash_appended == 1) { + actualBootloaderSize += 32; + } + if (actualBootloaderSize > len) { // Same as above bootloaderErrorMsg = "Too small"; From 761eb99e530d94f8980184310c8d5c0680670471 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Sat, 31 Jan 2026 19:40:54 -0500 Subject: [PATCH 062/164] Fix update UI Make sure the correct things are shown. --- wled00/data/update.htm | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/wled00/data/update.htm b/wled00/data/update.htm index e93a113fae..12107d2e86 100644 --- a/wled00/data/update.htm +++ b/wled00/data/update.htm @@ -29,9 +29,9 @@ if (data.arch == "esp8266") { toggle('rev'); } - const isESP32 = data.arch && (data.arch.toLowerCase() === 'esp32' || data.arch.toLowerCase() === 'esp32-s2'); - if (isESP32) { - gId('bootloader-section').style.display = 'block'; + const allowBl = data.arch && (data.arch.toLowerCase() === 'esp32' || data.arch.toLowerCase() === 'esp32-s2'); + if (allowBl) { + toggle('bootupd') if (data.bootloaderSHA256) { gId('bootloader-hash').innerText = 'Current bootloader SHA256: ' + data.bootloaderSHA256; } @@ -44,14 +44,18 @@ document.querySelector('.release-name').textContent = 'Unknown'; }); } + function hideforms() { + gId('bootupd').classList.toggle("hide"); + toggle('upd'); + } -

WLED Software Update

-
+

WLED Software Update

+ Installed version: Loading...
Release: Loading...
Download the latest binary: WLED Software Update
-
From 6f65b46b14ebc9a1ee8b4dbf8b80fc3877281255 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:24:25 +0000 Subject: [PATCH 104/164] Save version when skipping reporting to prevent re-prompting When users click "Skip reporting" without the checkbox, now saves the current version to version-info.json. This prevents the prompt from appearing again until the version actually changes. Behavior: - Skip without checkbox: Saves version, will prompt on next version - Skip with checkbox: Sets neverAsk=true, never prompts again Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com> --- wled00/data/index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/wled00/data/index.js b/wled00/data/index.js index 14a291787b..b42f2963ef 100644 --- a/wled00/data/index.js +++ b/wled00/data/index.js @@ -3425,8 +3425,10 @@ function showVersionUpgradePrompt(info, oldVersion, newVersion) { // Save "never ask" preference updateVersionInfo(newVersion, true, false); showToast('You will not be asked again.'); + } else { + // Save current version to prevent re-prompting until version changes + updateVersionInfo(newVersion, false, false); } - // Don't update version if not saving choice, will ask again on next load }); } From 1c2f70598a44df706d33bc734587d1676146e02d Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Fri, 27 Feb 2026 07:44:38 +0000 Subject: [PATCH 105/164] Update version from 16.0-alpha to 16.0.0-alpha --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index de017bc875..840dc5006f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wled", - "version": "16.0-alpha", + "version": "16.0.0-alpha", "description": "Tools for WLED project", "main": "tools/cdata.js", "directories": { From 3116e88cdcd5e576aec118199a93f21e61ad9cdc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:45:41 +0000 Subject: [PATCH 106/164] Bump minimatch from 10.2.2 to 10.2.4 Bumps [minimatch](https://github.com/isaacs/minimatch) from 10.2.2 to 10.2.4. - [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md) - [Commits](https://github.com/isaacs/minimatch/compare/v10.2.2...v10.2.4) --- updated-dependencies: - dependency-name: minimatch dependency-version: 10.2.4 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index b1313aa103..de0a4084f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wled", - "version": "16.0-alpha", + "version": "16.0.0-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wled", - "version": "16.0-alpha", + "version": "16.0.0-alpha", "license": "ISC", "dependencies": { "clean-css": "^5.3.3", @@ -523,9 +523,9 @@ } }, "node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.2" From b97b46ae12cdfc1b6c79ad827dc2fa50d96a949c Mon Sep 17 00:00:00 2001 From: Will Miles Date: Fri, 28 Nov 2025 09:23:49 -0500 Subject: [PATCH 107/164] Use new section for dynamic arrays Use a custom linker section to hold dynamic arrays such as the usermod list. This provides an extensible solution for wider use in the future (such as FX or Bus drivers), as well as IDF v5 compatibility. --- pio-scripts/dynarray.py | 12 ++++++++++++ pio-scripts/validate_modules.py | 2 +- platformio.ini | 1 + tools/dynarray.ld | 9 +++++++++ tools/esp8266_rodata.ld | 2 ++ wled00/fcn_declare.h | 2 +- wled00/um_manager.cpp | 4 ++-- 7 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 pio-scripts/dynarray.py create mode 100644 tools/dynarray.ld create mode 100644 tools/esp8266_rodata.ld diff --git a/pio-scripts/dynarray.py b/pio-scripts/dynarray.py new file mode 100644 index 0000000000..c8c1e6d2cc --- /dev/null +++ b/pio-scripts/dynarray.py @@ -0,0 +1,12 @@ +# Add a section to the linker script to store our dynamic arrays +# This is implemented as a pio post-script to ensure that our extra linker +# script fragments are processed last, after the base platform scripts have +# been loaded and all sections defined. +Import("env") + +if env.get("PIOPLATFORM") == "espressif8266": + # Use a shim on this platform so we can share the same output blocks + # names as used by later platforms (ESP32) + env.Append(LINKFLAGS=["-Ttools/esp8266_rodata.ld"]) + +env.Append(LINKFLAGS=["-Ttools/dynarray.ld"]) diff --git a/pio-scripts/validate_modules.py b/pio-scripts/validate_modules.py index d63b609ac8..b002af40be 100644 --- a/pio-scripts/validate_modules.py +++ b/pio-scripts/validate_modules.py @@ -41,7 +41,7 @@ def check_map_file_objects(map_file: list[str], dirs: Iterable[str]) -> set[str] def count_usermod_objects(map_file: list[str]) -> int: """ Returns the number of usermod objects in the usermod list """ # Count the number of entries in the usermods table section - return len([x for x in map_file if ".dtors.tbl.usermods.1" in x]) + return len([x for x in map_file if ".dynarray.usermods.1" in x]) def validate_map_file(source, target, env): diff --git a/platformio.ini b/platformio.ini index 60dedd473b..29949d33c0 100644 --- a/platformio.ini +++ b/platformio.ini @@ -135,6 +135,7 @@ extra_scripts = pre:pio-scripts/set_metadata.py post:pio-scripts/output_bins.py post:pio-scripts/strip-floats.py + post:pio-scripts/dynarray.py pre:pio-scripts/user_config_copy.py pre:pio-scripts/load_usermods.py pre:pio-scripts/build_ui.py diff --git a/tools/dynarray.ld b/tools/dynarray.ld new file mode 100644 index 0000000000..2c81217d35 --- /dev/null +++ b/tools/dynarray.ld @@ -0,0 +1,9 @@ +/* Linker script fragment to add dynamic array section to binary */ +SECTIONS +{ + .dynarray : + { + . = ALIGN(4); + KEEP(*(SORT_BY_INIT_PRIORITY(.dynarray.*))) + } > default_rodata_seg +} diff --git a/tools/esp8266_rodata.ld b/tools/esp8266_rodata.ld new file mode 100644 index 0000000000..0cb27df6d4 --- /dev/null +++ b/tools/esp8266_rodata.ld @@ -0,0 +1,2 @@ +/* Linker script fragment to shim ESP8266 section name to newer ESP32 standards */ +REGION_ALIAS("default_rodata_seg", iram1_0_seg) diff --git a/wled00/fcn_declare.h b/wled00/fcn_declare.h index 1c0b5a7a0d..e6a2112cd0 100644 --- a/wled00/fcn_declare.h +++ b/wled00/fcn_declare.h @@ -381,7 +381,7 @@ namespace UsermodManager { }; // Register usermods by building a static list via a linker section -#define REGISTER_USERMOD(x) Usermod* const um_##x __attribute__((__section__(".dtors.tbl.usermods.1"), used)) = &x +#define REGISTER_USERMOD(x) Usermod* const um_##x __attribute__((__section__(".dynarray.usermods.1"), used)) = &x //usermod.cpp void userSetup(); diff --git a/wled00/um_manager.cpp b/wled00/um_manager.cpp index 647757ad6f..2cefcdf347 100644 --- a/wled00/um_manager.cpp +++ b/wled00/um_manager.cpp @@ -10,8 +10,8 @@ // We stick them in the '.dtors' segment because it's always included by the linker scripts // even though it never gets called. Who calls exit() in an embedded program anyways? // If someone ever does, though, it'll explode as these aren't function pointers. -static Usermod * const _usermod_table_begin[0] __attribute__((__section__(".dtors.tbl.usermods.0"), unused)) = {}; -static Usermod * const _usermod_table_end[0] __attribute__((__section__(".dtors.tbl.usermods.99"), unused)) = {}; +static Usermod * const _usermod_table_begin[0] __attribute__((__section__(".dynarray.usermods.0"), unused)) = {}; +static Usermod * const _usermod_table_end[0] __attribute__((__section__(".dynarray.usermods.99"), unused)) = {}; static size_t getCount() { return &_usermod_table_end[0] - &_usermod_table_begin[0]; From 3bfbbab807cb9fb1c0755da4850d84130751005a Mon Sep 17 00:00:00 2001 From: Will Miles Date: Fri, 5 Dec 2025 14:25:27 -0500 Subject: [PATCH 108/164] Formalize dynarray system Break out the dynamic array macros to a re-usable component and fix the implementation. --- pio-scripts/dynarray.py | 25 +++++++++++------ pio-scripts/validate_modules.py | 7 +++-- tools/dynarray.ld | 9 ------ tools/dynarray_espressif32.ld | 10 +++++++ tools/esp8266_rodata.ld | 2 -- wled00/dynarray.h | 34 ++++++++++++++++++++++ wled00/fcn_declare.h | 3 +- wled00/um_manager.cpp | 50 ++++++++++++++++++--------------- 8 files changed, 93 insertions(+), 47 deletions(-) delete mode 100644 tools/dynarray.ld create mode 100644 tools/dynarray_espressif32.ld delete mode 100644 tools/esp8266_rodata.ld create mode 100644 wled00/dynarray.h diff --git a/pio-scripts/dynarray.py b/pio-scripts/dynarray.py index c8c1e6d2cc..2d3cfa90c5 100644 --- a/pio-scripts/dynarray.py +++ b/pio-scripts/dynarray.py @@ -1,12 +1,19 @@ # Add a section to the linker script to store our dynamic arrays -# This is implemented as a pio post-script to ensure that our extra linker -# script fragments are processed last, after the base platform scripts have -# been loaded and all sections defined. +# This is implemented as a pio post-script to ensure that we can +# place our linker script at the correct point in the command arguments. Import("env") +from pathlib import Path -if env.get("PIOPLATFORM") == "espressif8266": - # Use a shim on this platform so we can share the same output blocks - # names as used by later platforms (ESP32) - env.Append(LINKFLAGS=["-Ttools/esp8266_rodata.ld"]) - -env.Append(LINKFLAGS=["-Ttools/dynarray.ld"]) +platform = env.get("PIOPLATFORM") +script_file = Path(f"tools/dynarray_{platform}.ld") +if script_file.is_file(): + linker_script = f"-T{script_file}" + if platform == "espressif32": + # For ESP32, the script must be added at the right point in the list + linkflags = env.get("LINKFLAGS", []) + idx = linkflags.index("memory.ld") + linkflags.insert(idx+1, linker_script) + env.Replace(LINKFLAGS=linkflags) + else: + # For other platforms, put it in last + env.Append(LINKFLAGS=[linker_script]) diff --git a/pio-scripts/validate_modules.py b/pio-scripts/validate_modules.py index b002af40be..483a9324f7 100644 --- a/pio-scripts/validate_modules.py +++ b/pio-scripts/validate_modules.py @@ -4,7 +4,7 @@ from click import secho from SCons.Script import Action, Exit from platformio.builder.tools.piolib import LibBuilderBase - +Import("env") def is_wled_module(env, dep: LibBuilderBase) -> bool: """Returns true if the specified library is a wled module @@ -37,11 +37,13 @@ def check_map_file_objects(map_file: list[str], dirs: Iterable[str]) -> set[str] found.add(m) return found +DYNARRAY_SECTION = ".dtors" if env.get("PIOPLATFORM") == "espressif8266" else ".dynarray" +USERMODS_SECTION = f"{DYNARRAY_SECTION}.usermods.1" def count_usermod_objects(map_file: list[str]) -> int: """ Returns the number of usermod objects in the usermod list """ # Count the number of entries in the usermods table section - return len([x for x in map_file if ".dynarray.usermods.1" in x]) + return len([x for x in map_file if USERMODS_SECTION in x]) def validate_map_file(source, target, env): @@ -75,6 +77,5 @@ def validate_map_file(source, target, env): Exit(1) return None -Import("env") env.Append(LINKFLAGS=[env.subst("-Wl,--Map=${BUILD_DIR}/${PROGNAME}.map")]) env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", Action(validate_map_file, cmdstr='Checking linked optional modules (usermods) in map file')) diff --git a/tools/dynarray.ld b/tools/dynarray.ld deleted file mode 100644 index 2c81217d35..0000000000 --- a/tools/dynarray.ld +++ /dev/null @@ -1,9 +0,0 @@ -/* Linker script fragment to add dynamic array section to binary */ -SECTIONS -{ - .dynarray : - { - . = ALIGN(4); - KEEP(*(SORT_BY_INIT_PRIORITY(.dynarray.*))) - } > default_rodata_seg -} diff --git a/tools/dynarray_espressif32.ld b/tools/dynarray_espressif32.ld new file mode 100644 index 0000000000..70ce51f19c --- /dev/null +++ b/tools/dynarray_espressif32.ld @@ -0,0 +1,10 @@ +/* ESP32 linker script fragment to add dynamic array section to binary */ +SECTIONS +{ + .dynarray : + { + . = ALIGN(0x10); + KEEP(*(SORT_BY_INIT_PRIORITY(.dynarray.*))) + } > default_rodata_seg +} +INSERT AFTER .flash.rodata; diff --git a/tools/esp8266_rodata.ld b/tools/esp8266_rodata.ld deleted file mode 100644 index 0cb27df6d4..0000000000 --- a/tools/esp8266_rodata.ld +++ /dev/null @@ -1,2 +0,0 @@ -/* Linker script fragment to shim ESP8266 section name to newer ESP32 standards */ -REGION_ALIAS("default_rodata_seg", iram1_0_seg) diff --git a/wled00/dynarray.h b/wled00/dynarray.h new file mode 100644 index 0000000000..f9e6de19d4 --- /dev/null +++ b/wled00/dynarray.h @@ -0,0 +1,34 @@ +/* dynarray.h + +Macros for generating a "dynamic array", a static array of objects declared in different translation units + +*/ + +#pragma once + +// Declare the beginning and ending elements of a dynamic array of 'type'. +// This must be used in only one translation unit in your program for any given array. +#define DECLARE_DYNARRAY(type, array_name) \ + static type const DYNARRAY_BEGIN(array_name)[0] __attribute__((__section__(DYNARRAY_SECTION "." #array_name ".0"), unused)) = {}; \ + static type const DYNARRAY_END(array_name)[0] __attribute__((__section__(DYNARRAY_SECTION "." #array_name ".99999"), unused)) = {}; + +// Declare an object that is a member of a dynamic array. "member name" must be unique; "array_section" is an integer for ordering items. +// It is legal to define multiple items with the same section name; the order of those items will be up to the linker. +#define DYNARRAY_MEMBER(type, array_name, member_name, array_section) type const member_name __attribute__((__section__(DYNARRAY_SECTION "." #array_name "." #array_section), used)) + +#define DYNARRAY_BEGIN(array_name) array_name##_begin +#define DYNARRAY_END(array_name) array_name##_end +#define DYNARRAY_LENGTH(array_name) (&DYNARRAY_END(array_name)[0] - &DYNARRAY_BEGIN(array_name)[0]) + +#ifdef ESP8266 +// ESP8266 linker script cannot be extended with a unique section for dynamic arrays. +// We instead pack them in the ".dtors" section, as it's sorted and uploaded to the flash +// (but will never be used in the embedded system) +#define DYNARRAY_SECTION ".dtors" + +#else /* ESP8266 */ + +// Use a unique named section; the linker script must be extended to ensure it's correctly placed. +#define DYNARRAY_SECTION ".dynarray" + +#endif diff --git a/wled00/fcn_declare.h b/wled00/fcn_declare.h index e6a2112cd0..67958314b4 100644 --- a/wled00/fcn_declare.h +++ b/wled00/fcn_declare.h @@ -1,6 +1,7 @@ #pragma once #ifndef WLED_FCN_DECLARE_H #define WLED_FCN_DECLARE_H +#include /* * All globally accessible functions are declared here @@ -381,7 +382,7 @@ namespace UsermodManager { }; // Register usermods by building a static list via a linker section -#define REGISTER_USERMOD(x) Usermod* const um_##x __attribute__((__section__(".dynarray.usermods.1"), used)) = &x +#define REGISTER_USERMOD(x) DYNARRAY_MEMBER(Usermod*, usermods, um_##x, 1) = &x //usermod.cpp void userSetup(); diff --git a/wled00/um_manager.cpp b/wled00/um_manager.cpp index 2cefcdf347..ef21303f0c 100644 --- a/wled00/um_manager.cpp +++ b/wled00/um_manager.cpp @@ -10,76 +10,80 @@ // We stick them in the '.dtors' segment because it's always included by the linker scripts // even though it never gets called. Who calls exit() in an embedded program anyways? // If someone ever does, though, it'll explode as these aren't function pointers. -static Usermod * const _usermod_table_begin[0] __attribute__((__section__(".dynarray.usermods.0"), unused)) = {}; -static Usermod * const _usermod_table_end[0] __attribute__((__section__(".dynarray.usermods.99"), unused)) = {}; +DECLARE_DYNARRAY(Usermod*, usermods); static size_t getCount() { - return &_usermod_table_end[0] - &_usermod_table_begin[0]; + return DYNARRAY_LENGTH(usermods); } //Usermod Manager internals -void UsermodManager::setup() { for (auto mod = _usermod_table_begin; mod < _usermod_table_end; ++mod) (*mod)->setup(); } -void UsermodManager::connected() { for (auto mod = _usermod_table_begin; mod < _usermod_table_end; ++mod) (*mod)->connected(); } -void UsermodManager::loop() { for (auto mod = _usermod_table_begin; mod < _usermod_table_end; ++mod) (*mod)->loop(); } -void UsermodManager::handleOverlayDraw() { for (auto mod = _usermod_table_begin; mod < _usermod_table_end; ++mod) (*mod)->handleOverlayDraw(); } -void UsermodManager::appendConfigData(Print& dest) { for (auto mod = _usermod_table_begin; mod < _usermod_table_end; ++mod) (*mod)->appendConfigData(dest); } +void UsermodManager::setup() { for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) (*mod)->setup(); } +void UsermodManager::connected() { for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) (*mod)->connected(); } +void UsermodManager::loop() { for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) (*mod)->loop(); } +void UsermodManager::handleOverlayDraw() { for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) (*mod)->handleOverlayDraw(); } +void UsermodManager::appendConfigData(Print& dest) { for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) (*mod)->appendConfigData(dest); } bool UsermodManager::handleButton(uint8_t b) { bool overrideIO = false; - for (auto mod = _usermod_table_begin; mod < _usermod_table_end; ++mod) { + for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) { if ((*mod)->handleButton(b)) overrideIO = true; } return overrideIO; } bool UsermodManager::getUMData(um_data_t **data, uint8_t mod_id) { - for (auto mod = _usermod_table_begin; mod < _usermod_table_end; ++mod) { + for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) { if (mod_id > 0 && (*mod)->getId() != mod_id) continue; // only get data form requested usermod if provided if ((*mod)->getUMData(data)) return true; // if usermod does provide data return immediately (only one usermod can provide data at one time) } return false; } -void UsermodManager::addToJsonState(JsonObject& obj) { for (auto mod = _usermod_table_begin; mod < _usermod_table_end; ++mod) (*mod)->addToJsonState(obj); } +void UsermodManager::addToJsonState(JsonObject& obj) { for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) (*mod)->addToJsonState(obj); } void UsermodManager::addToJsonInfo(JsonObject& obj) { auto um_id_list = obj.createNestedArray("um"); - for (auto mod = _usermod_table_begin; mod < _usermod_table_end; ++mod) { + for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) { um_id_list.add((*mod)->getId()); (*mod)->addToJsonInfo(obj); } } -void UsermodManager::readFromJsonState(JsonObject& obj) { for (auto mod = _usermod_table_begin; mod < _usermod_table_end; ++mod) (*mod)->readFromJsonState(obj); } -void UsermodManager::addToConfig(JsonObject& obj) { for (auto mod = _usermod_table_begin; mod < _usermod_table_end; ++mod) (*mod)->addToConfig(obj); } +void UsermodManager::readFromJsonState(JsonObject& obj) { for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) (*mod)->readFromJsonState(obj); } +void UsermodManager::addToConfig(JsonObject& obj) { for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) (*mod)->addToConfig(obj); } bool UsermodManager::readFromConfig(JsonObject& obj) { - bool allComplete = true; - for (auto mod = _usermod_table_begin; mod < _usermod_table_end; ++mod) { + Serial.printf_P(PSTR("Mods: %d Begin: %08X End: %08X\n"), getCount(), (intptr_t) &DYNARRAY_BEGIN(usermods)[0], (intptr_t) &DYNARRAY_END(usermods)[0]); + Usermod** volatile x = (Usermod**) DYNARRAY_BEGIN(usermods); + Serial.printf_P(PSTR("X: %08X\n"), (intptr_t) x); + Serial.printf_P(PSTR("*X: %08X\n"), (intptr_t) *x); + + bool allComplete = true; + for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) { if (!(*mod)->readFromConfig(obj)) allComplete = false; } return allComplete; } #ifndef WLED_DISABLE_MQTT -void UsermodManager::onMqttConnect(bool sessionPresent) { for (auto mod = _usermod_table_begin; mod < _usermod_table_end; ++mod) (*mod)->onMqttConnect(sessionPresent); } +void UsermodManager::onMqttConnect(bool sessionPresent) { for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) (*mod)->onMqttConnect(sessionPresent); } bool UsermodManager::onMqttMessage(char* topic, char* payload) { - for (auto mod = _usermod_table_begin; mod < _usermod_table_end; ++mod) if ((*mod)->onMqttMessage(topic, payload)) return true; + for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) if ((*mod)->onMqttMessage(topic, payload)) return true; return false; } #endif #ifndef WLED_DISABLE_ESPNOW bool UsermodManager::onEspNowMessage(uint8_t* sender, uint8_t* payload, uint8_t len) { - for (auto mod = _usermod_table_begin; mod < _usermod_table_end; ++mod) if ((*mod)->onEspNowMessage(sender, payload, len)) return true; + for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) if ((*mod)->onEspNowMessage(sender, payload, len)) return true; return false; } #endif bool UsermodManager::onUdpPacket(uint8_t* payload, size_t len) { - for (auto mod = _usermod_table_begin; mod < _usermod_table_end; ++mod) if ((*mod)->onUdpPacket(payload, len)) return true; + for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) if ((*mod)->onUdpPacket(payload, len)) return true; return false; } -void UsermodManager::onUpdateBegin(bool init) { for (auto mod = _usermod_table_begin; mod < _usermod_table_end; ++mod) (*mod)->onUpdateBegin(init); } // notify usermods that update is to begin -void UsermodManager::onStateChange(uint8_t mode) { for (auto mod = _usermod_table_begin; mod < _usermod_table_end; ++mod) (*mod)->onStateChange(mode); } // notify usermods that WLED state changed +void UsermodManager::onUpdateBegin(bool init) { for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) (*mod)->onUpdateBegin(init); } // notify usermods that update is to begin +void UsermodManager::onStateChange(uint8_t mode) { for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) (*mod)->onStateChange(mode); } // notify usermods that WLED state changed /* * Enables usermods to lookup another Usermod. */ Usermod* UsermodManager::lookup(uint16_t mod_id) { - for (auto mod = _usermod_table_begin; mod < _usermod_table_end; ++mod) { + for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) { if ((*mod)->getId() == mod_id) { return *mod; } From 35d267109fe1174e9cdb414fa4d2c964f16b2a34 Mon Sep 17 00:00:00 2001 From: Frank <91616163+softhack007@users.noreply.github.com> Date: Sun, 1 Mar 2026 22:08:09 +0100 Subject: [PATCH 109/164] bugfix: right shift on signed char is unsafe Fix signed-byte handling in SHA256 hex conversion. When bootloaderSHA256Cache bytes >= 0x80 are assigned to a signed char, they sign-extend to negative values. Right-shifting negative values produces incorrect results (e.g., 0xFF becomes -1, which shifts incorrectly). This causes wrong hex digit output for non-ASCII hash bytes. --- wled00/ota_update.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wled00/ota_update.cpp b/wled00/ota_update.cpp index b38df08ed2..4aa7830bab 100644 --- a/wled00/ota_update.cpp +++ b/wled00/ota_update.cpp @@ -451,8 +451,8 @@ String getBootloaderSHA256Hex() { String result; result.reserve(65); for (int i = 0; i < 32; i++) { - char b1 = bootloaderSHA256Cache[i]; - char b2 = b1 >> 4; + unsigned char b1 = bootloaderSHA256Cache[i]; + unsigned char b2 = b1 >> 4; b1 &= 0x0F; b1 += '0'; b2 += '0'; if (b1 > '9') b1 += 39; From 7fa15761aedd6c73c2938488140bf8333d81901e Mon Sep 17 00:00:00 2001 From: Frank <91616163+softhack007@users.noreply.github.com> Date: Sun, 1 Mar 2026 22:30:04 +0100 Subject: [PATCH 110/164] bugfix: prevent array bounds violations due to short WS payload data Guard against zero-length binary payloads before dereferencing data[0] or data[1] --- wled00/ws.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/wled00/ws.cpp b/wled00/ws.cpp index 5c4edcd352..1d07432d21 100644 --- a/wled00/ws.cpp +++ b/wled00/ws.cpp @@ -80,10 +80,11 @@ void wsEvent(AsyncWebSocket * server, AsyncWebSocketClient * client, AwsEventTyp // force broadcast in 500ms after updating client //lastInterfaceUpdate = millis() - (INTERFACE_UPDATE_COOLDOWN -500); // ESP8266 does not like this } - }else if (info->opcode == WS_BINARY) { + } else if (info->opcode == WS_BINARY) { // first byte determines protocol. Note: since e131_packet_t is "packed", the compiler handles alignment issues //DEBUG_PRINTF_P(PSTR("WS binary message: len %u, byte0: %u\n"), len, data[0]); - int offset = 1; // offset to skip protocol byte + constexpr int offset = 1; // offset to skip protocol byte + if (!data || len < offset+1) return; // catch invalid / single-byte payload switch (data[0]) { case BINARY_PROTOCOL_E131: handleE131Packet((e131_packet_t*)&data[offset], client->remoteIP(), P_E131); From ac1a4dfbfde4dc6199a6650c54dd4941914438ba Mon Sep 17 00:00:00 2001 From: Will Miles Date: Sun, 1 Mar 2026 19:27:14 -0500 Subject: [PATCH 111/164] validate_modules: Remove obsolete code --- pio-scripts/validate_modules.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/pio-scripts/validate_modules.py b/pio-scripts/validate_modules.py index 483a9324f7..7b6ff6cc84 100644 --- a/pio-scripts/validate_modules.py +++ b/pio-scripts/validate_modules.py @@ -3,15 +3,8 @@ from typing import Iterable from click import secho from SCons.Script import Action, Exit -from platformio.builder.tools.piolib import LibBuilderBase Import("env") -def is_wled_module(env, dep: LibBuilderBase) -> bool: - """Returns true if the specified library is a wled module - """ - usermod_dir = Path(env["PROJECT_DIR"]).resolve() / "usermods" - return usermod_dir in Path(dep.src_dir).parents or str(dep.name).startswith("wled-") - def read_lines(p: Path): """ Read in the contents of a file for analysis """ From 19292675d8a29175879530e994d1158ad37e2475 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Sun, 1 Mar 2026 19:32:24 -0500 Subject: [PATCH 112/164] Support lib_deps syntax in custom_usermods Expand the parsing of custom_usermods to accept the same syntax as lib_deps. This allows external usermods to appear on the custom_usermods lines. Also includes comment handling. Co-Authored-By: Claude --- pio-scripts/load_usermods.py | 131 ++++++++++++++++++++++++++++++----- 1 file changed, 112 insertions(+), 19 deletions(-) diff --git a/pio-scripts/load_usermods.py b/pio-scripts/load_usermods.py index 38a08401e6..68b6d27a97 100644 --- a/pio-scripts/load_usermods.py +++ b/pio-scripts/load_usermods.py @@ -1,6 +1,8 @@ Import('env') from collections import deque from pathlib import Path # For OS-agnostic path manipulation +import re +from urllib.parse import urlparse from click import secho from SCons.Script import Exit from platformio.builder.tools.piolib import LibBuilderBase @@ -25,25 +27,115 @@ def find_usermod(mod: str) -> Path: return mp raise RuntimeError(f"Couldn't locate module {mod} in usermods directory!") -def is_wled_module(dep: LibBuilderBase) -> bool: - """Returns true if the specified library is a wled module +# Names of external/registry deps listed in custom_usermods. +# Populated during parsing below; read by is_wled_module() at configure time. +_custom_usermod_names: set[str] = set() + +# Matches any RFC-valid URL scheme (http, https, git, git+https, symlink, file, hg+ssh, etc.) +_URL_SCHEME_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9+.-]*://') +# SSH git URL: user@host:path (e.g. git@github.com:user/repo.git#tag) +_SSH_URL_RE = re.compile(r'^[^@\s]+@[^@:\s]+:[^:\s]') +# Explicit custom name: "LibName = " (PlatformIO [=] form) +_NAME_EQ_RE = re.compile(r'^([A-Za-z0-9_.-]+)\s*=\s*(\S.*)') + + +def _is_external_entry(line: str) -> bool: + """Return True if line is a lib_deps-style external/registry entry.""" + if _NAME_EQ_RE.match(line): # "LibName = " + return True + if _URL_SCHEME_RE.match(line): # https://, git://, symlink://, etc. + return True + if _SSH_URL_RE.match(line): # git@github.com:user/repo.git + return True + if '@' in line: # "owner/Name @ ^1.0.0" + return True + if re.match(r'^[^/\s]+/[^/\s]+$', line): # "owner/Name" + return True + return False + + +def _predict_dep_name(entry: str) -> str | None: + """Predict the library name PlatformIO will assign to this dep (best-effort). + + Accuracy relies on the library's manifest "name" matching the repo/package + name in the spec. This holds for well-authored libraries; the libArchive + check (which requires library.json) provides an early-failure safety net. """ - return usermod_dir in Path(dep.src_dir).parents or str(dep.name).startswith("wled-") - -## Script starts here -# Process usermod option -usermods = env.GetProjectOption("custom_usermods","") + entry = entry.strip() + # "LibName = " — name is given explicitly; always use it + m = _NAME_EQ_RE.match(entry) + if m: + return m.group(1).strip() + # URL scheme: extract name from path + if _URL_SCHEME_RE.match(entry): + parsed = urlparse(entry) + if parsed.netloc in ('github.com', 'gitlab.com', 'bitbucket.com'): + parts = [p for p in parsed.path.split('/') if p] + if len(parts) >= 2: + name = parts[1] + return name[:-4] if name.endswith('.git') else name + name = Path(parsed.path.rstrip('/')).name + return name.split('.')[0].strip() or None + # SSH git URL: git@github.com:user/repo.git#tag → repo + if _SSH_URL_RE.match(entry): + path_part = entry.split(':', 1)[1].split('#')[0].rstrip('/') + name = Path(path_part).name + return (name[:-4] if name.endswith('.git') else name) or None + # Versioned registry: "owner/Name @ version" → Name + if '@' in entry: + name_part = entry.split('@')[0].strip() + return name_part.split('/')[-1].strip() if '/' in name_part else name_part + # Plain registry: "owner/Name" → Name + if re.match(r'^[^/\s]+/[^/\s]+$', entry): + return entry.split('/')[-1].strip() + return None -# Handle "all usermods" case -if usermods == '*': - usermods = [f.name for f in usermod_dir.iterdir() if f.is_dir() and f.joinpath('library.json').exists()] -else: - usermods = usermods.split() -if usermods: - # Inject usermods in to project lib_deps - symlinks = [f"symlink://{find_usermod(mod).resolve()}" for mod in usermods] - env.GetProjectConfig().set("env:" + env['PIOENV'], 'lib_deps', env.GetProjectOption('lib_deps') + symlinks) +def is_wled_module(dep: LibBuilderBase) -> bool: + """Returns true if the specified library is a wled module.""" + return ( + usermod_dir in Path(dep.src_dir).parents + or str(dep.name).startswith("wled-") + or dep.name in _custom_usermod_names + ) + + +## Script starts here — parse custom_usermods +raw_usermods = env.GetProjectOption("custom_usermods", "") +usermods_libdeps: list[str] = [] + +for line in raw_usermods.splitlines(): + line = line.strip() + if not line or line.startswith('#') or line.startswith(';'): + continue + + if _is_external_entry(line): + # External URL or registry entry: pass through to lib_deps unchanged. + predicted = _predict_dep_name(line) + if predicted: + _custom_usermod_names.add(predicted) + else: + secho( + f"WARNING: Cannot determine library name for custom_usermods entry " + f"{line!r}. If it is not recognised as a WLED module at build time, " + f"ensure its library.json 'name' matches the repo name.", + fg="yellow", err=True) + usermods_libdeps.append(line) + else: + # Bare name(s): split on whitespace for backwards compatibility. + for token in line.split(): + if token == '*': + for mod_path in sorted(usermod_dir.iterdir()): + if mod_path.is_dir() and (mod_path / 'library.json').exists(): + _custom_usermod_names.add(mod_path.name) + usermods_libdeps.append(f"symlink://{mod_path.resolve()}") + else: + resolved = find_usermod(token) + _custom_usermod_names.add(resolved.name) + usermods_libdeps.append(f"symlink://{resolved.resolve()}") + +if usermods_libdeps: + env.GetProjectConfig().set("env:" + env['PIOENV'], 'lib_deps', env.GetProjectOption('lib_deps') + usermods_libdeps) # Utility function for assembling usermod include paths def cached_add_includes(dep, dep_cache: set, includes: deque): @@ -93,9 +185,10 @@ def wrapped_ConfigureProjectLibBuilder(xenv): if broken_usermods: broken_usermods = [usermod.name for usermod in broken_usermods] secho( - f"ERROR: libArchive=false is missing on usermod(s) {' '.join(broken_usermods)} -- modules will not compile in correctly", - fg="red", - err=True) + f"ERROR: libArchive=false is missing on usermod(s) {' '.join(broken_usermods)} -- " + f"modules will not compile in correctly. Add '\"build\": {{\"libArchive\": false}}' " + f"to their library.json.", + fg="red", err=True) Exit(1) # Save the depbuilders list for later validation From 210b4d8f54f50b56ef26581efc5641a4dd494d0b Mon Sep 17 00:00:00 2001 From: Will Miles Date: Sun, 1 Mar 2026 21:30:21 -0500 Subject: [PATCH 113/164] Add external usermod example --- platformio_override.sample.ini | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/platformio_override.sample.ini b/platformio_override.sample.ini index 2fc9aacfb4..e840eb4334 100644 --- a/platformio_override.sample.ini +++ b/platformio_override.sample.ini @@ -538,9 +538,21 @@ monitor_filters = esp32_exception_decoder # 433MHz RF remote example for esp32dev [env:esp32dev_usermod_RF433] extends = env:esp32dev -build_flags = ${env:esp32dev.build_flags} -D USERMOD_RF433 -lib_deps = ${env:esp32dev.lib_deps} - sui77/rc-switch @ 2.6.4 +custom_usermods = + ${env:esp32dev.custom_usermods} + RF433 + +# External usermod from a git repository. +# The library's `library.json` must include `"build": {"libArchive": false}`. +# The name PlatformIO assigns is taken from the library's `library.json` "name" field. +# If that name doesn't match the repo name in the URL, use the "LibName = URL" form +# shown in the commented-out line below to supply the name explicitly. +[env:esp32dev_external_usermod] +extends = env:esp32dev +custom_usermods = + ${env:esp32dev.custom_usermods} + https://github.com/wled/wled-usermod-example.git#main + # ------------------------------------------------------------------------------ # Hub75 examples From 5790309371f87afc30b50f376ca6657ed95f9a26 Mon Sep 17 00:00:00 2001 From: gustebeast Date: Mon, 2 Mar 2026 10:10:31 -0800 Subject: [PATCH 114/164] Introduce comet effect usermod with fire particle system (#5347) * Introduce comet effect usermod with fire particle system * Introduce comet effect usermod with fire particle system --- usermods/PS_Comet/PS_Comet.cpp | 123 +++++++++++++++++++++++++++++++++ usermods/PS_Comet/README.md | 25 +++++++ usermods/PS_Comet/library.json | 4 ++ 3 files changed, 152 insertions(+) create mode 100644 usermods/PS_Comet/PS_Comet.cpp create mode 100644 usermods/PS_Comet/README.md create mode 100644 usermods/PS_Comet/library.json diff --git a/usermods/PS_Comet/PS_Comet.cpp b/usermods/PS_Comet/PS_Comet.cpp new file mode 100644 index 0000000000..c96ba2594e --- /dev/null +++ b/usermods/PS_Comet/PS_Comet.cpp @@ -0,0 +1,123 @@ +#include "wled.h" +#include "FXparticleSystem.h" + +unsigned long nextCometCreationTime = 0; + +#define FX_FALLBACK_STATIC { SEGMENT.fill(SEGCOLOR(0)); return; } +// Use UINT32_MAX - 1 for the "no comet" case so we can add 1 later and not have it overflow +#define NULL_INDEX UINT32_MAX - 1 + +/////////////////////// +// Effect Function // +/////////////////////// + +void mode_pscomet() { + ParticleSystem2D *PartSys = nullptr; + uint32_t i; + + if (SEGMENT.call == 0) { // Initialization + // Try to allocate one comet for every column + if (!initParticleSystem2D(PartSys, SEGMENT.vWidth())) { + FX_FALLBACK_STATIC; // Allocation failed or not 2D + } + PartSys->setMotionBlur(170); // Enable motion blur + PartSys->setParticleSize(0); // Allow small comets to be a single pixel wide + } + else { + PartSys = reinterpret_cast(SEGENV.data); // If not first call, use existing data + } + if (PartSys == nullptr || SEGMENT.vHeight() < 2 || SEGMENT.vWidth() < 2) { + FX_FALLBACK_STATIC; + } + + PartSys->updateSystem(); // Update system properties (dimensions and data pointers) + + auto has_fallen_off_screen = [PartSys](uint32_t particleIndex) { + return particleIndex < PartSys->numSources + ? PartSys->sources[particleIndex].source.y < PartSys->maxY * -1 + : true; + }; + + // This will be SEGMENT.vWidth() unless the particle system had insufficient memory + uint32_t numComets = PartSys->numSources; + // Pick a random column for a new comet to spawn, but reset it to null if it's not time yet or there's already a + // comet nearby + uint32_t chosenIndex = hw_random(numComets); + if ( + strip.now < nextCometCreationTime + || !has_fallen_off_screen(chosenIndex - 1) + || !has_fallen_off_screen(chosenIndex) + || !has_fallen_off_screen(chosenIndex + 1) + ) { + chosenIndex = NULL_INDEX; + } else { + uint16_t cometFrequencyDelay = 2040 - (SEGMENT.intensity << 3); + nextCometCreationTime = strip.now + cometFrequencyDelay + hw_random16(cometFrequencyDelay); + } + uint8_t canLargeCometSpawn = + // Slider 3 determines % of large comets with extra particle sources on their sides + SEGMENT.custom1 > hw_random8(254) + && chosenIndex != 0 + && chosenIndex != numComets - 1; + uint8_t fallingSpeed = 1 + (SEGMENT.speed >> 2); + + // Update the comets + for (i = 0; i < numComets; i++) { + auto& source = PartSys->sources[i]; + auto& sourceParticle = source.source; + + if (!has_fallen_off_screen(i)) { + // Active comets fall downwards and emit flames + sourceParticle.y -= fallingSpeed; + source.vy = (SEGMENT.speed >> 5) - fallingSpeed; // Emitting speed (upwards) + PartSys->flameEmit(PartSys->sources[i]); + continue; + } + + bool isChosenComet = i == chosenIndex; + bool isChosenSideComet = + canLargeCometSpawn && + (i == chosenIndex - 1 || i == chosenIndex + 1); + + // Chosen comets respawn at the top + if (isChosenComet || isChosenSideComet) { + // Map the comet index into an output pixel index + sourceParticle.x = i * PartSys->maxX / (SEGMENT.vWidth() - 1); + // Spawn a bit above the top to avoid popping into view + sourceParticle.y = PartSys->maxY + (2 * fallingSpeed); + if (isChosenComet) { + // Slider 4 controls comet length via particle lifetime and fire intensity adjustments + source.maxLife = 16 + (SEGMENT.custom2 >> 2); + source.minLife = source.maxLife >> 1; + sourceParticle.ttl = 16 - (SEGMENT.custom2 >> 4); + } else { + // Side comets have fixed length + source.maxLife = 18; + source.minLife = 14; + sourceParticle.ttl = 16; + // Shift side comets up by 1 pixel + sourceParticle.y += 2 * PartSys->maxY / (SEGMENT.vHeight() - 1); + } + } + } + + // Slider 4 controls comet length via particle lifetime and fire intensity adjustments + PartSys->updateFire(max(255U - SEGMENT.custom2, 45U)); +} +static const char _data_FX_MODE_PSCOMET[] PROGMEM = "PS Comet@Falling Speed,Comet Frequency,Large Comet Probability,Comet Length;;!;2;pal=35,sx=128,ix=255,c1=32,c2=128"; + +///////////////////// +// UserMod Class // +///////////////////// + +class PSCometUsermod : public Usermod { + public: + void setup() override { + strip.addEffect(255, &mode_pscomet, _data_FX_MODE_PSCOMET); + } + + void loop() override {} +}; + +static PSCometUsermod ps_comet; +REGISTER_USERMOD(ps_comet); diff --git a/usermods/PS_Comet/README.md b/usermods/PS_Comet/README.md new file mode 100644 index 0000000000..67395e290b --- /dev/null +++ b/usermods/PS_Comet/README.md @@ -0,0 +1,25 @@ +## Description + +A 2D falling comet effect similar to "Matrix" but with a fire particle simulation to enhance the comet trail visuals. Works with custom color palettes, defaulting to "Fire". Supports "small" and "large" comets which are 1px and 3px wide respectively. + +Demo: [https://imgur.com/a/i1v5WAy](https://imgur.com/a/i1v5WAy) + +## Installation + +To activate the usermod, add the following line to your platformio_override.ini +```ini +custom_usermods = ps_comet +``` +Or if you are already using a usermod, append ps_comet to the list +```ini +custom_usermods = audioreactive ps_comet +``` + +You should now see "PS Comet" appear in your effect list. + +## Parameters + +1. **Falling Speed** sets how fast the comets fall +2. **Comet Frequency** determines how many comets are on screen at a time +3. **Large Comet Probability** determines how often large 3px wide comets spawn +4. **Comet Length** sets how far comet trails stretch vertically \ No newline at end of file diff --git a/usermods/PS_Comet/library.json b/usermods/PS_Comet/library.json new file mode 100644 index 0000000000..d6f569387c --- /dev/null +++ b/usermods/PS_Comet/library.json @@ -0,0 +1,4 @@ +{ + "name": "PS Comet", + "build": { "libArchive": false } +} \ No newline at end of file From bc229b8cb6eba81f49e64fd3e6fe2192b8563a4e Mon Sep 17 00:00:00 2001 From: gustebeast Date: Mon, 2 Mar 2026 10:11:26 -0800 Subject: [PATCH 115/164] Add flashing effect on line clear in TetrisAI_v2. (#5320) * Add flashing effect on line clear in TetrisAI_v2. See https://imgur.com/1dsNCVd for a demo. * Address feedback for tetris flashing effect. * Address width == 32 case for isLineFull --- usermods/TetrisAI_v2/TetrisAI_v2.cpp | 55 +++++++++++++++++++++++++--- usermods/TetrisAI_v2/gridbw.h | 48 ++++++++++++++++++++++-- usermods/TetrisAI_v2/gridcolor.h | 2 +- usermods/TetrisAI_v2/readme.md | 9 +++-- usermods/TetrisAI_v2/tetrisai.h | 2 +- 5 files changed, 102 insertions(+), 14 deletions(-) diff --git a/usermods/TetrisAI_v2/TetrisAI_v2.cpp b/usermods/TetrisAI_v2/TetrisAI_v2.cpp index d4fefe1040..f9a86f3049 100644 --- a/usermods/TetrisAI_v2/TetrisAI_v2.cpp +++ b/usermods/TetrisAI_v2/TetrisAI_v2.cpp @@ -5,9 +5,12 @@ #include "tetrisaigame.h" // By: muebau +bool noFlashOnClear = false; + typedef struct TetrisAI_data { unsigned long lastTime = 0; + unsigned long clearingStartTime = 0; TetrisAIGame tetris; uint8_t intelligence; uint8_t rotate; @@ -31,16 +34,27 @@ void drawGrid(TetrisAIGame* tetris, TetrisAI_data* tetrisai_data) //GRID for (auto index_y = 4; index_y < tetris->grid.height; index_y++) { + bool isRowClearing = tetris->grid.gridBW.clearingRows[index_y]; for (auto index_x = 0; index_x < tetris->grid.width; index_x++) { CRGB color; - if (*tetris->grid.getPixel(index_x, index_y) == 0) - { + uint8_t gridPixel = *tetris->grid.getPixel(index_x, index_y); + if (isRowClearing) { + if (noFlashOnClear) { + color = CRGB::Gray; + } else { + //flash color white and black every 200ms + color = (strip.now % 200) < 150 + ? CRGB::Gray + : CRGB::Black; + } + } + else if (gridPixel == 0) { //BG color color = SEGCOLOR(1); } //game over animation - else if(*tetris->grid.getPixel(index_x, index_y) == 254) + else if (gridPixel == 254) { //use fg color = SEGCOLOR(0); @@ -48,7 +62,7 @@ void drawGrid(TetrisAIGame* tetris, TetrisAI_data* tetrisai_data) else { //spread the color over the whole palette - uint8_t colorIndex = *tetris->grid.getPixel(index_x, index_y) * 32; + uint8_t colorIndex = gridPixel * 32; colorIndex += tetrisai_data->colorOffset; color = ColorFromPalette(SEGPALETTE, colorIndex, 255, NOBLEND); } @@ -170,6 +184,7 @@ void mode_2DTetrisAI() tetrisai_data->tetris = TetrisAIGame(gridWidth, gridHeight, nLookAhead, piecesData, numPieces); tetrisai_data->tetris.state = TetrisAIGame::States::INIT; + tetrisai_data->clearingStartTime = 0; SEGMENT.fill(SEGCOLOR(1)); } @@ -184,7 +199,21 @@ void mode_2DTetrisAI() tetrisai_data->tetris.ai.bumpiness = -0.184483f + dui; } - if (tetrisai_data->tetris.state == TetrisAIGame::ANIMATE_MOVE) + //end line clearing flashing effect if needed + if (tetrisai_data->tetris.grid.gridBW.hasClearingRows()) + { + if (tetrisai_data->clearingStartTime == 0) { + tetrisai_data->clearingStartTime = strip.now; + } + if (strip.now - tetrisai_data->clearingStartTime > 750) + { + tetrisai_data->tetris.grid.gridBW.clearedLinesReadyForRemoval = true; + tetrisai_data->tetris.grid.cleanupFullLines(); + tetrisai_data->clearingStartTime = 0; + } + drawGrid(&tetrisai_data->tetris, tetrisai_data); + } + else if (tetrisai_data->tetris.state == TetrisAIGame::ANIMATE_MOVE) { if (strip.now - tetrisai_data->lastTime > msDelayMove) @@ -229,6 +258,7 @@ class TetrisAIUsermod : public Usermod { private: + static const char _name[]; public: void setup() @@ -236,6 +266,20 @@ class TetrisAIUsermod : public Usermod strip.addEffect(255, &mode_2DTetrisAI, _data_FX_MODE_2DTETRISAI); } + void addToConfig(JsonObject& root) override + { + JsonObject top = root.createNestedObject(FPSTR(_name)); + top["noFlashOnClear"] = noFlashOnClear; + } + + bool readFromConfig(JsonObject& root) override + { + JsonObject top = root[FPSTR(_name)]; + bool configComplete = !top.isNull(); + configComplete &= getJsonValue(top["noFlashOnClear"], noFlashOnClear); + return configComplete; + } + void loop() { @@ -247,6 +291,7 @@ class TetrisAIUsermod : public Usermod } }; +const char TetrisAIUsermod::_name[] PROGMEM = "TetrisAI_v2"; static TetrisAIUsermod tetrisai_v2; REGISTER_USERMOD(tetrisai_v2); \ No newline at end of file diff --git a/usermods/TetrisAI_v2/gridbw.h b/usermods/TetrisAI_v2/gridbw.h index a96351749a..5db6e25a9b 100644 --- a/usermods/TetrisAI_v2/gridbw.h +++ b/usermods/TetrisAI_v2/gridbw.h @@ -25,11 +25,18 @@ class GridBW uint8_t width; uint8_t height; std::vector pixels; + // When a row fills, we mark it here first so it can flash before being + // fully removed. + std::vector clearingRows; + // True when a line clearing flashing effect is over and we're ready to + // fully clean up the lines + bool clearedLinesReadyForRemoval = false; GridBW(uint8_t width, uint8_t height): width(width), height(height), - pixels(height) + pixels(height), + clearingRows(height) { if (width > 32) { @@ -84,9 +91,26 @@ class GridBW piece->landingY = piece->landingY > 0 ? piece->landingY - 1 : 0; } + bool hasClearingRows() + { + for (bool rowClearing : clearingRows) + { + if (rowClearing) + { + return true; + } + } + return false; + } + void cleanupFullLines() { + // Skip cleanup if there are rows clearing + if (hasClearingRows() && !clearedLinesReadyForRemoval) { + return; + } uint8_t offset = 0; + bool doneRemovingClearedLines = false; //from "height - 1" to "0", so from bottom row to top for (uint8_t row = height; row-- > 0; ) @@ -94,8 +118,13 @@ class GridBW //full line? if (isLineFull(row)) { - offset++; - pixels[row] = 0x0; + if (clearedLinesReadyForRemoval) { + offset++; + pixels[row] = 0x0; + doneRemovingClearedLines = true; + } else { + clearingRows[row] = true; + } continue; } @@ -105,11 +134,20 @@ class GridBW pixels[row] = 0x0; } } + if (doneRemovingClearedLines) { + clearingRows.assign(height, false); + clearedLinesReadyForRemoval = false; + } } bool isLineFull(uint8_t y) { - return pixels[y] == (uint32_t)((1 << width) - 1); + return pixels[y] == (width >= 32 ? UINT32_MAX : (1U << width) - 1); + } + + bool isLineReadyForRemoval(uint8_t y) + { + return clearedLinesReadyForRemoval && isLineFull(y); } void reset() @@ -121,6 +159,8 @@ class GridBW pixels.clear(); pixels.resize(height); + clearingRows.assign(height, false); + clearedLinesReadyForRemoval = false; } }; diff --git a/usermods/TetrisAI_v2/gridcolor.h b/usermods/TetrisAI_v2/gridcolor.h index 815c2a5603..5f69027968 100644 --- a/usermods/TetrisAI_v2/gridcolor.h +++ b/usermods/TetrisAI_v2/gridcolor.h @@ -82,7 +82,7 @@ class GridColor //from "height - 1" to "0", so from bottom row to top for (uint8_t y = height; y-- > 0; ) { - if (gridBW.isLineFull(y)) + if (gridBW.isLineReadyForRemoval(y)) { offset++; for (uint8_t x = 0; x < width; x++) diff --git a/usermods/TetrisAI_v2/readme.md b/usermods/TetrisAI_v2/readme.md index 5ac8028967..7962a9010d 100644 --- a/usermods/TetrisAI_v2/readme.md +++ b/usermods/TetrisAI_v2/readme.md @@ -2,13 +2,16 @@ This usermod adds a self-playing Tetris game as an 'effect'. The mod requires version 0.14 or higher as it relies on matrix support. The effect was tested on an ESP32 4MB with a WS2812B 16x16 matrix. -Version 1.0 +PHOTOSENSITIVE EPILEPSY WARNING: By default the effect features a flashing animation on line clear. This can be disabled +from the usermod settings page in WLED. ## Installation -Just activate the usermod with `-D USERMOD_TETRISAI` and the effect will become available under the name 'Tetris AI'. If you are running out of flash memory, use a different memory layout (e.g. [WLED_ESP32_4MB_256KB_FS.csv](https://github.com/wled-dev/WLED/blob/main/tools/WLED_ESP32_4MB_256KB_FS.csv)). +To activate the usermod, add the following line to your platformio_override.ini +`custom_usermods = tetrisai_v2` +The effect will then become available under the name 'Tetris AI'. If you are running out of flash memory, use a different memory layout (e.g. [WLED_ESP32_4MB_256KB_FS.csv](https://github.com/wled-dev/WLED/blob/main/tools/WLED_ESP32_4MB_256KB_FS.csv)). -If needed simply add to `platformio_override.ini` (or `platformio_override.ini`): +If needed simply add to `platformio_override.ini`: ```ini board_build.partitions = tools/WLED_ESP32_4MB_256KB_FS.csv diff --git a/usermods/TetrisAI_v2/tetrisai.h b/usermods/TetrisAI_v2/tetrisai.h index ba4fe60e43..7bb1d9321a 100644 --- a/usermods/TetrisAI_v2/tetrisai.h +++ b/usermods/TetrisAI_v2/tetrisai.h @@ -68,7 +68,7 @@ class TetrisAI } //line full if all ones in mask :-) - if (grid.isLineFull(row)) + if (grid.isLineReadyForRemoval(row)) { rating->fullLines++; } From 5f28406f42b68ca193006ddb7434bb63ad0a0df6 Mon Sep 17 00:00:00 2001 From: BobLoeffler68 Date: Mon, 2 Mar 2026 11:16:43 -0700 Subject: [PATCH 116/164] Morse Code FX in the user_fx usermod (#5252) * Added Morse Code effect to the user_fx usermod * Added a few comments * Added punctuation and end-of-message codes, and changing them will force a re-draw. * Fixed mode name in addEffect * cosmetic changes * * removed PROGMEM from letters and numbers arrays. * changed 1024 to a constexpr named MORSECODE_MAX_PATTERN_SIZE. * added MORSECODE_MAX_PATTERN_SIZE to build_morsecode_pattern(). * added boundary checked when adding patterns to the array. * changed code to allocate per-segment storage for pattern. * More bounds checking added. * Added a lookup table for punctuation morse codes. * Removed PALETTE_MOVING_WRAP macro as it's not used in this effect. * Moved all static variables to SEGENV.data * Now using a bit array instead of a bool array * added a check to see if the pattern is empty * Added "color by word" option, moved Color modes to a slider and added a couple comments to top comment block. * Removed return FRAMETIME * A few changes suggested by coderabbit. * A few changes suggested by coderabbit --- usermods/user_fx/user_fx.cpp | 257 +++++++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) diff --git a/usermods/user_fx/user_fx.cpp b/usermods/user_fx/user_fx.cpp index bcff5a0190..8b6526ea25 100644 --- a/usermods/user_fx/user_fx.cpp +++ b/usermods/user_fx/user_fx.cpp @@ -2,6 +2,9 @@ // for information how FX metadata strings work see https://kno.wled.ge/interfaces/json-api/#effect-metadata +// paletteBlend: 0 - wrap when moving, 1 - always wrap, 2 - never wrap, 3 - none (undefined) +#define PALETTE_SOLID_WRAP (strip.paletteBlend == 1 || strip.paletteBlend == 3) + // static effect, used if an effect fails to initialize static void mode_static(void) { SEGMENT.fill(SEGCOLOR(0)); @@ -93,6 +96,259 @@ unsigned dataSize = cols * rows; // SEGLEN (virtual length) is equivalent to vW static const char _data_FX_MODE_DIFFUSIONFIRE[] PROGMEM = "Diffusion Fire@!,Spark rate,Diffusion Speed,Turbulence,,Use palette;;Color;;2;pal=35"; +/* +/ Morse Code by Bob Loeffler +* Adapted from code by automaticaddison.com and then optimized by claude.ai +* aux0 is the pattern offset for scrolling +* aux1 saves settings: check2 (1 bit), check3 (1 bit), text hash (4 bits) and pattern length (10 bits) +* The first slider (sx) selects the scrolling speed +* The second slider selects the color mode (lower half selects color wheel, upper half selects color palettes) +* Checkbox1 displays all letters in a word with the same color +* Checkbox2 displays punctuation or not +* Checkbox3 displays the End-of-message code or not +* We get the text from the SEGMENT.name and convert it to morse code +* This effect uses a bit array, instead of bool array, for efficient storage - 8x memory reduction (128 bytes vs 1024 bytes) +* +* Morse Code rules: +* - a dot is 1 pixel/LED; a dash is 3 pixels/LEDs +* - there is 1 space between each dot or dash that make up a letter/number/punctuation +* - there are 3 spaces between each letter/number/punctuation +* - there are 7 spaces between each word +*/ + +// Bit manipulation macros +#define SET_BIT8(arr, i) ((arr)[(i) >> 3] |= (1 << ((i) & 7))) +#define GET_BIT8(arr, i) (((arr)[(i) >> 3] & (1 << ((i) & 7))) != 0) + +// Build morse code pattern into a buffer +static void build_morsecode_pattern(const char *morse_code, uint8_t *pattern, uint8_t *wordIndex, uint16_t &index, uint8_t currentWord, int maxSize) { + const char *c = morse_code; + + // Build the dots and dashes into pattern array + while (*c != '\0') { + // it's a dot which is 1 pixel + if (*c == '.') { + if (index >= maxSize - 1) return; + SET_BIT8(pattern, index); + wordIndex[index] = currentWord; + index++; + } + else { // Must be a dash which is 3 pixels + if (index >= maxSize - 3) return; + SET_BIT8(pattern, index); + wordIndex[index] = currentWord; + index++; + SET_BIT8(pattern, index); + wordIndex[index] = currentWord; + index++; + SET_BIT8(pattern, index); + wordIndex[index] = currentWord; + index++; + } + + c++; + + // 1 space between parts of a letter/number/punctuation (but not after the last one) + if (*c != '\0') { + if (index >= maxSize) return; + wordIndex[index] = currentWord; + index++; + } + } + + // 3 spaces between two letters/numbers/punctuation + if (index >= maxSize - 2) return; + wordIndex[index] = currentWord; + index++; + if (index >= maxSize - 1) return; + wordIndex[index] = currentWord; + index++; + if (index >= maxSize) return; + wordIndex[index] = currentWord; + index++; +} + +static void mode_morsecode(void) { + if (SEGLEN < 1) FX_FALLBACK_STATIC; + + // A-Z in Morse Code + static const char * letters[] = {".-", "-...", "-.-.", "-..", ".", "..-.", "--.", "....", "..", ".---", "-.-", ".-..", "--", + "-.", "---", ".--.", "--.-", ".-.", "...", "-", "..-", "...-", ".--", "-..-", "-.--", "--.."}; + // 0-9 in Morse Code + static const char * numbers[] = {"-----", ".----", "..---", "...--", "....-", ".....", "-....", "--...", "---..", "----."}; + + // Punctuation in Morse Code + struct PunctuationMapping { + char character; + const char* code; + }; + + static const PunctuationMapping punctuation[] = { + {'.', ".-.-.-"}, {',', "--..--"}, {'?', "..--.."}, + {':', "---..."}, {'-', "-....-"}, {'!', "-.-.--"}, + {'&', ".-..."}, {'@', ".--.-."}, {')', "-.--.-"}, + {'(', "-.--."}, {'/', "-..-."}, {'\'', ".----."} + }; + + // Get the text to display + char text[WLED_MAX_SEGNAME_LEN+1] = {'\0'}; + size_t len = 0; + + if (SEGMENT.name) len = strlen(SEGMENT.name); + if (len == 0) { + strcpy_P(text, PSTR("I Love WLED!")); + } else { + strcpy(text, SEGMENT.name); + } + + // Convert to uppercase in place + for (char *p = text; *p; p++) { + *p = toupper(*p); + } + + // Allocate per-segment storage for pattern (1023 bits = 127 bytes) + word index array (1024 bytes) + word count (1 byte) + constexpr size_t MORSECODE_MAX_PATTERN_SIZE = 1023; + constexpr size_t MORSECODE_PATTERN_BYTES = (MORSECODE_MAX_PATTERN_SIZE + 7) / 8; // 128 bytes + constexpr size_t MORSECODE_WORD_INDEX_BYTES = MORSECODE_MAX_PATTERN_SIZE; // 1 byte per bit position + constexpr size_t MORSECODE_WORD_COUNT_BYTES = 1; // 1 byte for word count + if (!SEGENV.allocateData(MORSECODE_PATTERN_BYTES + MORSECODE_WORD_INDEX_BYTES + MORSECODE_WORD_COUNT_BYTES)) FX_FALLBACK_STATIC; + uint8_t* morsecodePattern = reinterpret_cast(SEGENV.data); + uint8_t* wordIndexArray = reinterpret_cast(SEGENV.data + MORSECODE_PATTERN_BYTES); + uint8_t* wordCountPtr = reinterpret_cast(SEGENV.data + MORSECODE_PATTERN_BYTES + MORSECODE_WORD_INDEX_BYTES); + + // SEGENV.aux1 stores: [bit 15: check2] [bit 14: check3] [bits 10-13: text hash (4 bits)] [bits 0-9: pattern length] + bool lastCheck2 = (SEGENV.aux1 & 0x8000) != 0; + bool lastCheck3 = (SEGENV.aux1 & 0x4000) != 0; + uint16_t lastHashBits = (SEGENV.aux1 >> 10) & 0xF; // 4 bits of hash + uint16_t patternLength = SEGENV.aux1 & 0x3FF; // Lower 10 bits for length (up to 1023) + + // Compute text hash + uint16_t textHash = 0; + for (char *p = text; *p; p++) { + textHash = ((textHash << 5) + textHash) + *p; + } + uint16_t currentHashBits = (textHash >> 12) & 0xF; // Use upper 4 bits of hash + + bool textChanged = (currentHashBits != lastHashBits) && (SEGENV.call > 0); + + // Check if we need to rebuild the pattern + bool needsRebuild = (SEGENV.call == 0) || textChanged || (SEGMENT.check2 != lastCheck2) || (SEGMENT.check3 != lastCheck3); + + // Initialize on first call or rebuild pattern + if (needsRebuild) { + patternLength = 0; + + // Clear the bit array and word index array first + memset(morsecodePattern, 0, MORSECODE_PATTERN_BYTES); + memset(wordIndexArray, 0, MORSECODE_WORD_INDEX_BYTES); + + // Track current word index + uint8_t currentWordIndex = 0; + + // Build complete morse code pattern + for (char *c = text; *c; c++) { + if (patternLength >= MORSECODE_MAX_PATTERN_SIZE - 10) break; + + if (*c >= 'A' && *c <= 'Z') { + build_morsecode_pattern(letters[*c - 'A'], morsecodePattern, wordIndexArray, patternLength, currentWordIndex, MORSECODE_MAX_PATTERN_SIZE); + } + else if (*c >= '0' && *c <= '9') { + build_morsecode_pattern(numbers[*c - '0'], morsecodePattern, wordIndexArray, patternLength, currentWordIndex, MORSECODE_MAX_PATTERN_SIZE); + } + else if (*c == ' ') { + // Space between words - increment word index for next word + currentWordIndex++; + // Add 4 additional spaces (7 total with the 3 after each letter) + for (int x = 0; x < 4; x++) { + if (patternLength >= MORSECODE_MAX_PATTERN_SIZE) break; + wordIndexArray[patternLength] = currentWordIndex; + patternLength++; + } + } + else if (SEGMENT.check2) { + const char *punctuationCode = nullptr; + for (const auto& p : punctuation) { + if (*c == p.character) { + punctuationCode = p.code; + break; + } + } + if (punctuationCode) { + build_morsecode_pattern(punctuationCode, morsecodePattern, wordIndexArray, patternLength, currentWordIndex, MORSECODE_MAX_PATTERN_SIZE); + } + } + } + + if (SEGMENT.check3) { + build_morsecode_pattern(".-.-.", morsecodePattern, wordIndexArray, patternLength, currentWordIndex, MORSECODE_MAX_PATTERN_SIZE); + } + + for (int x = 0; x < 7; x++) { + if (patternLength >= MORSECODE_MAX_PATTERN_SIZE) break; + wordIndexArray[patternLength] = currentWordIndex; + patternLength++; + } + + // Store the total number of words (currentWordIndex + 1 because it's 0-indexed) + *wordCountPtr = currentWordIndex + 1; + + // Store pattern length, checkbox states, and hash bits in aux1 + SEGENV.aux1 = patternLength | (currentHashBits << 10) | (SEGMENT.check2 ? 0x8000 : 0) | (SEGMENT.check3 ? 0x4000 : 0); + + // Reset the scroll offset + SEGENV.aux0 = 0; + } + + // if pattern is empty for some reason, display black background only + if (patternLength == 0) { + SEGMENT.fill(BLACK); + return; + } + + // Update offset to make the morse code scroll + // Use step for scroll timing only + uint32_t cycleTime = 50 + (255 - SEGMENT.speed)*3; + uint32_t it = strip.now / cycleTime; + if (SEGENV.step != it) { + SEGENV.aux0++; + SEGENV.step = it; + } + + // Clear background + SEGMENT.fill(BLACK); + + // Draw the scrolling pattern + int offset = SEGENV.aux0 % patternLength; + + // Get the word count and calculate color spacing + uint8_t wordCount = *wordCountPtr; + if (wordCount == 0) wordCount = 1; + uint8_t colorSpacing = 255 / wordCount; // Distribute colors evenly across color wheel/palette + + for (int i = 0; i < SEGLEN; i++) { + int patternIndex = (offset + i) % patternLength; + if (GET_BIT8(morsecodePattern, patternIndex)) { + uint8_t wordIdx = wordIndexArray[patternIndex]; + if (SEGMENT.check1) { // make each word a separate color + if (SEGMENT.custom3 < 16) + // use word index to select base color, add slight offset for animation + SEGMENT.setPixelColor(i, SEGMENT.color_wheel((wordIdx * colorSpacing) + (SEGENV.aux0 / 4))); + else + SEGMENT.setPixelColor(i, SEGMENT.color_from_palette(wordIdx * colorSpacing, true, PALETTE_SOLID_WRAP, 0)); + } + else { + if (SEGMENT.custom3 < 16) + SEGMENT.setPixelColor(i, SEGMENT.color_wheel(SEGENV.aux0 + i)); + else + SEGMENT.setPixelColor(i, SEGMENT.color_from_palette(i, true, PALETTE_SOLID_WRAP, 0)); + } + } + } +} +static const char _data_FX_MODE_MORSECODE[] PROGMEM = "Morse Code@Speed,,,,Color mode,Color by Word,Punctuation,EndOfMessage;;!;1;sx=192,c3=8,o1=1,o2=1"; + + + ///////////////////// // UserMod Class // ///////////////////// @@ -102,6 +358,7 @@ class UserFxUsermod : public Usermod { public: void setup() override { strip.addEffect(255, &mode_diffusionfire, _data_FX_MODE_DIFFUSIONFIRE); + strip.addEffect(255, &mode_morsecode, _data_FX_MODE_MORSECODE); //////////////////////////////////////// // add your effect function(s) here // From c604b99a4cd877ff835ed22384274c9d89e2857d Mon Sep 17 00:00:00 2001 From: GLEDOPTO Date: Tue, 3 Mar 2026 20:18:39 +0800 Subject: [PATCH 117/164] add 3 more standard buttons to ESP-NOW Remote (#5400) maps codes 20, 21 and 22 to presets 5, 6 and 7 --- wled00/remote.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/wled00/remote.cpp b/wled00/remote.cpp index 9685e52fe4..7b3375fa66 100644 --- a/wled00/remote.cpp +++ b/wled00/remote.cpp @@ -13,6 +13,9 @@ #define WIZMOTE_BUTTON_TWO 17 #define WIZMOTE_BUTTON_THREE 18 #define WIZMOTE_BUTTON_FOUR 19 +#define WIZMOTE_BUTTON_FIVE 20 +#define WIZMOTE_BUTTON_SIX 21 +#define WIZMOTE_BUTTON_SEVEN 22 #define WIZMOTE_BUTTON_BRIGHT_UP 9 #define WIZMOTE_BUTTON_BRIGHT_DOWN 8 @@ -217,6 +220,9 @@ void handleRemote() { case WIZMOTE_BUTTON_TWO : presetWithFallback(2, FX_MODE_BREATH, 0); break; case WIZMOTE_BUTTON_THREE : presetWithFallback(3, FX_MODE_FIRE_FLICKER, 0); break; case WIZMOTE_BUTTON_FOUR : presetWithFallback(4, FX_MODE_RAINBOW, 0); break; + case WIZMOTE_BUTTON_FIVE : presetWithFallback(5, FX_MODE_CANDLE, 0); break; + case WIZMOTE_BUTTON_SIX : presetWithFallback(6, FX_MODE_RANDOM_COLOR, 0); break; + case WIZMOTE_BUTTON_SEVEN : presetWithFallback(7, FX_MODE_FADE, 0); break; case WIZMOTE_BUTTON_NIGHT : activateNightMode(); break; case WIZMOTE_BUTTON_BRIGHT_UP : brightnessUp(); break; case WIZMOTE_BUTTON_BRIGHT_DOWN : brightnessDown(); break; From 5f5f468983ae506c79e0cbe09536eeb1ec13c1b3 Mon Sep 17 00:00:00 2001 From: BobLoeffler68 Date: Tue, 3 Mar 2026 09:13:55 -0700 Subject: [PATCH 118/164] Ants FX in the user_fx usermod (#5251) * Added the Ants effect to the user_fx usermod --- usermods/user_fx/user_fx.cpp | 191 ++++++++++++++++++++++++++++++++++- 1 file changed, 190 insertions(+), 1 deletion(-) diff --git a/usermods/user_fx/user_fx.cpp b/usermods/user_fx/user_fx.cpp index 8b6526ea25..d162c8c4a2 100644 --- a/usermods/user_fx/user_fx.cpp +++ b/usermods/user_fx/user_fx.cpp @@ -96,6 +96,195 @@ unsigned dataSize = cols * rows; // SEGLEN (virtual length) is equivalent to vW static const char _data_FX_MODE_DIFFUSIONFIRE[] PROGMEM = "Diffusion Fire@!,Spark rate,Diffusion Speed,Turbulence,,Use palette;;Color;;2;pal=35"; +/* +/ Ants (created by making modifications to the Rolling Balls code) - Bob Loeffler 2025 +* First slider is for the ants' speed. +* Second slider is for the # of ants. +* Third slider is for the Ants' size. +* Fourth slider (custom2) is for blurring the LEDs in the segment. +* Checkbox1 is for Gathering food (enabled if you want the ants to gather food, disabled if they are just walking). +* We will switch directions when they get to the beginning or end of the segment when gathering food. +* When gathering food, the Pass By option will automatically be enabled so they can drop off their food easier (and look for more food). +* Checkbox2 is for Smear mode (enabled is smear pixel colors, disabled is no smearing) +* Checkbox3 is for whether the ants will bump into each other (disabled) or just pass by each other (enabled) +*/ + +// Ant structure representing each ant's state +struct Ant { + unsigned long lastBumpUpdate; // the last time the ant bumped into another ant + bool hasFood; + float velocity; + float position; // (0.0 to 1.0 range) +}; + +constexpr unsigned MAX_ANTS = 32; +constexpr float MIN_COLLISION_TIME_MS = 2.0f; +constexpr float VELOCITY_MIN = 2.0f; +constexpr float VELOCITY_MAX = 10.0f; +constexpr unsigned ANT_SIZE_MIN = 1; +constexpr unsigned ANT_SIZE_MAX = 20; + +// Helper function to get food pixel color based on ant and background colors +static uint32_t getFoodColor(uint32_t antColor, uint32_t backgroundColor) { + if (antColor == WHITE) + return (backgroundColor == YELLOW) ? GRAY : YELLOW; + return (backgroundColor == WHITE) ? YELLOW : WHITE; +} + +// Helper function to handle ant boundary wrapping or bouncing +static void handleBoundary(Ant& ant, float& position, bool gatherFood, bool atStart, unsigned long currentTime) { + if (gatherFood) { + // Bounce mode: reverse direction and update food status + position = atStart ? 0.0f : 1.0f; + ant.velocity = -ant.velocity; + ant.lastBumpUpdate = currentTime; + ant.position = position; + ant.hasFood = atStart; // Has food when leaving start, drops it at end + } else { + // Wrap mode: teleport to opposite end + position = atStart ? 1.0f : 0.0f; + ant.lastBumpUpdate = currentTime; + ant.position = position; + } +} + +// Helper function to calculate ant color +static uint32_t getAntColor(int antIndex, int numAnts, bool usePalette) { + if (usePalette) + return SEGMENT.color_from_palette(antIndex * 255 / numAnts, false, (strip.paletteBlend == 1 || strip.paletteBlend == 3), 255); + // Alternate between two colors for default palette + return (antIndex % 3 == 1) ? SEGCOLOR(0) : SEGCOLOR(2); +} + +// Helper function to render a single ant pixel with food handling +static void renderAntPixel(int pixelIndex, int pixelOffset, int antSize, const Ant& ant, uint32_t antColor, uint32_t backgroundColor, bool gatherFood) { + bool isMovingBackward = (ant.velocity < 0); + bool isFoodPixel = gatherFood && ant.hasFood && ((isMovingBackward && pixelOffset == 0) || (!isMovingBackward && pixelOffset == antSize - 1)); + if (isFoodPixel) { + SEGMENT.setPixelColor(pixelIndex, getFoodColor(antColor, backgroundColor)); + } else { + SEGMENT.setPixelColor(pixelIndex, antColor); + } +} + +static void mode_ants(void) { + if (SEGLEN <= 1) FX_FALLBACK_STATIC; + + // Allocate memory for ant data + uint32_t backgroundColor = SEGCOLOR(1); + unsigned dataSize = sizeof(Ant) * MAX_ANTS; + if (!SEGENV.allocateData(dataSize)) FX_FALLBACK_STATIC; // Allocation failed + + Ant* ants = reinterpret_cast(SEGENV.data); + + // Extract configuration from segment settings + unsigned numAnts = min(1 + (SEGLEN * SEGMENT.intensity >> 12), MAX_ANTS); + bool gatherFood = SEGMENT.check1; + bool SmearMode = SEGMENT.check2; + bool passBy = SEGMENT.check3 || gatherFood; // global no‑collision when gathering food is enabled + unsigned antSize = map(SEGMENT.custom1, 0, 255, ANT_SIZE_MIN, ANT_SIZE_MAX) + (gatherFood ? 1 : 0); + + // Initialize ants on first call + if (SEGENV.call == 0) { + int confusedAntIndex = hw_random(0, numAnts); // the first random ant to go backwards + + for (int i = 0; i < MAX_ANTS; i++) { + ants[i].lastBumpUpdate = strip.now; + + // Random velocity + float velocity = VELOCITY_MIN + (VELOCITY_MAX - VELOCITY_MIN) * hw_random16(1000, 5000) / 5000.0f; + // One random ant moves in opposite direction + ants[i].velocity = (i == confusedAntIndex) ? -velocity : velocity; + // Random starting position (0.0 to 1.0) + ants[i].position = hw_random16(0, 10000) / 10000.0f; + // Ants don't have food yet + ants[i].hasFood = false; + } + } + + // Calculate time conversion factor based on speed slider + float timeConversionFactor = float(scale8(8, 255 - SEGMENT.speed) + 1) * 20000.0f; + + // Clear background if not in Smear mode + if (!SmearMode) SEGMENT.fill(backgroundColor); + + // Update and render each ant + for (int i = 0; i < numAnts; i++) { + float timeSinceLastUpdate = float(int(strip.now - ants[i].lastBumpUpdate)) / timeConversionFactor; + float newPosition = ants[i].position + ants[i].velocity * timeSinceLastUpdate; + + // Reset ants that wandered too far off-track (e.g., after intensity change) + if (newPosition < -0.5f || newPosition > 1.5f) { + newPosition = ants[i].position = hw_random16(0, 10000) / 10000.0f; + ants[i].lastBumpUpdate = strip.now; + } + + // Handle boundary conditions (bounce or wrap) + if (newPosition <= 0.0f && ants[i].velocity < 0.0f) { + handleBoundary(ants[i], newPosition, gatherFood, true, strip.now); + } else if (newPosition >= 1.0f && ants[i].velocity > 0.0f) { + handleBoundary(ants[i], newPosition, gatherFood, false, strip.now); + } + + // Handle collisions between ants (if not passing by) + if (!passBy) { + for (int j = i + 1; j < numAnts; j++) { + if (fabsf(ants[j].velocity - ants[i].velocity) < 0.001f) continue; // Moving in same direction at same speed; avoids tiny denominators + + // Calculate collision time using physics - collisionTime formula adapted from rolling_balls + float timeOffset = float(int(ants[j].lastBumpUpdate - ants[i].lastBumpUpdate)); + float collisionTime = (timeConversionFactor * (ants[i].position - ants[j].position) + ants[i].velocity * timeOffset) / (ants[j].velocity - ants[i].velocity); + + // Check if collision occurred in valid time window + float timeSinceJ = float(int(strip.now - ants[j].lastBumpUpdate)); + if (collisionTime > MIN_COLLISION_TIME_MS && collisionTime < timeSinceJ) { + // Update positions to collision point + float adjustedTime = (collisionTime + float(int(ants[j].lastBumpUpdate - ants[i].lastBumpUpdate))) / timeConversionFactor; + ants[i].position += ants[i].velocity * adjustedTime; + ants[j].position = ants[i].position; + + // Update collision time + unsigned long collisionMoment = static_cast(collisionTime + 0.5f) + ants[j].lastBumpUpdate; + ants[i].lastBumpUpdate = collisionMoment; + ants[j].lastBumpUpdate = collisionMoment; + + // Reverse the ant with greater speed magnitude + if (fabsf(ants[i].velocity) > fabsf(ants[j].velocity)) { + ants[i].velocity = -ants[i].velocity; + } else { + ants[j].velocity = -ants[j].velocity; + } + + // Recalculate position after collision + newPosition = ants[i].position + ants[i].velocity * float(int(strip.now - ants[i].lastBumpUpdate)) / timeConversionFactor; + } + } + } + + // Clamp position to valid range + newPosition = constrain(newPosition, 0.0f, 1.0f); + unsigned pixelPosition = roundf(newPosition * (SEGLEN - 1)); + + // Determine ant color + uint32_t antColor = getAntColor(i, numAnts, SEGMENT.palette != 0); + + // Render ant pixels + for (int pixelOffset = 0; pixelOffset < antSize; pixelOffset++) { + unsigned currentPixel = pixelPosition + pixelOffset; + if (currentPixel >= SEGLEN) break; + renderAntPixel(currentPixel, pixelOffset, antSize, ants[i], antColor, backgroundColor, gatherFood); + } + + // Update ant state + ants[i].lastBumpUpdate = strip.now; + ants[i].position = newPosition; + } + + SEGMENT.blur(SEGMENT.custom2>>1); +} +static const char _data_FX_MODE_ANTS[] PROGMEM = "Ants@Ant speed,# of ants,Ant size,Blur,,Gathering food,Smear,Pass by;!,!,!;!;1;sx=192,ix=255,c1=32,c2=0,o1=1,o3=1"; + + /* / Morse Code by Bob Loeffler * Adapted from code by automaticaddison.com and then optimized by claude.ai @@ -348,7 +537,6 @@ static void mode_morsecode(void) { static const char _data_FX_MODE_MORSECODE[] PROGMEM = "Morse Code@Speed,,,,Color mode,Color by Word,Punctuation,EndOfMessage;;!;1;sx=192,c3=8,o1=1,o2=1"; - ///////////////////// // UserMod Class // ///////////////////// @@ -358,6 +546,7 @@ class UserFxUsermod : public Usermod { public: void setup() override { strip.addEffect(255, &mode_diffusionfire, _data_FX_MODE_DIFFUSIONFIRE); + strip.addEffect(255, &mode_ants, _data_FX_MODE_ANTS); strip.addEffect(255, &mode_morsecode, _data_FX_MODE_MORSECODE); //////////////////////////////////////// From 0e1da4f0047b0b4488a947635a2a8148c2e9104c Mon Sep 17 00:00:00 2001 From: BobLoeffler68 Date: Tue, 3 Mar 2026 22:21:49 -0700 Subject: [PATCH 119/164] Magma FX in the user_fx usermod (#5360) * Added Magma effect to user_fx usermod * Get color from current palette for lava bombs --- usermods/user_fx/user_fx.cpp | 192 +++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/usermods/user_fx/user_fx.cpp b/usermods/user_fx/user_fx.cpp index d162c8c4a2..4112fdb245 100644 --- a/usermods/user_fx/user_fx.cpp +++ b/usermods/user_fx/user_fx.cpp @@ -96,6 +96,197 @@ unsigned dataSize = cols * rows; // SEGLEN (virtual length) is equivalent to vW static const char _data_FX_MODE_DIFFUSIONFIRE[] PROGMEM = "Diffusion Fire@!,Spark rate,Diffusion Speed,Turbulence,,Use palette;;Color;;2;pal=35"; +/* +/ Magma effect +* 2D magma/lava animation +* Adapted from FireLamp_JeeUI implementation (https://github.com/DmytroKorniienko/FireLamp_JeeUI/tree/dev) +* Original idea by SottNick, remastered by kostyamat +* Adapted to WLED by Bob Loeffler and claude.ai +* First slider (speed) is for the speed or flow rate of the moving magma. +* Second slider (intensity) is for the height of the magma. +* Third slider (lava bombs) is for the number of lava bombs (particles). The max # is 1/2 the number of columns on the 2D matrix. +* Fourth slider (gravity) is for how high the lava bombs will go. +* The checkbox (check2) is for whether the lava bombs can be seen in the magma or behind it. +*/ + +// Draw the magma +static void drawMagma(const uint16_t width, const uint16_t height, float *ff_y, float *ff_z, uint8_t *shiftHue) { + // Noise parameters - adjust these for different magma characteristics + // deltaValue: higher = more detailed/turbulent magma + // deltaHue: higher = taller magma structures + constexpr uint8_t magmaDeltaValue = 12U; + constexpr uint8_t magmaDeltaHue = 10U; + + uint16_t ff_y_int = (uint16_t)*ff_y; + uint16_t ff_z_int = (uint16_t)*ff_z; + + for (uint16_t i = 0; i < width; i++) { + for (uint16_t j = 0; j < height; j++) { + // Generate Perlin noise value (0-255) + uint8_t noise = perlin8(i * magmaDeltaValue, (j + ff_y_int + hw_random8(2)) * magmaDeltaHue, ff_z_int); + uint8_t paletteIndex = qsub8(noise, shiftHue[j]); // Apply the vertical fade gradient + CRGB col = SEGMENT.color_from_palette(paletteIndex, false, PALETTE_SOLID_WRAP, 0); // Get color from palette + SEGMENT.addPixelColorXY(i, height - 1 - j, col); // magma rises from bottom of display + } + } +} + +// Move and draw lava bombs (particles) +static void drawLavaBombs(const uint16_t width, const uint16_t height, float *particleData, float gravity, uint8_t particleCount) { + for (uint16_t i = 0; i < particleCount; i++) { + uint16_t idx = i * 4; + + particleData[idx + 3] -= gravity; + particleData[idx + 0] += particleData[idx + 2]; + particleData[idx + 1] += particleData[idx + 3]; + + float posX = particleData[idx + 0]; + float posY = particleData[idx + 1]; + + if (posY > height + height / 4) { + particleData[idx + 3] = -particleData[idx + 3] * 0.8f; + } + + if (posY < (float)(height / 8) - 1.0f || posX < 0 || posX >= width) { + particleData[idx + 0] = hw_random(0, width * 100) / 100.0f; + particleData[idx + 1] = hw_random(0, height * 25) / 100.0f; + particleData[idx + 2] = hw_random(-75, 75) / 100.0f; + + float baseVelocity = hw_random(60, 120) / 100.0f; + if (hw_random8() < 50) { + baseVelocity *= 1.6f; + } + particleData[idx + 3] = baseVelocity; + continue; + } + + int16_t xi = (int16_t)posX; + int16_t yi = (int16_t)posY; + + if (xi >= 0 && xi < width && yi >= 0 && yi < height) { + // Get a random color from the current palette + uint8_t randomIndex = hw_random8(64, 128); + CRGB pcolor = ColorFromPaletteWLED(SEGPALETTE, randomIndex, 255, LINEARBLEND); + + // Pre-calculate anti-aliasing weights + float xf = posX - xi; + float yf = posY - yi; + float ix = 1.0f - xf; + float iy = 1.0f - yf; + + uint8_t w0 = 255 * ix * iy; + uint8_t w1 = 255 * xf * iy; + uint8_t w2 = 255 * ix * yf; + uint8_t w3 = 255 * xf * yf; + + int16_t yFlipped = height - 1 - yi; // Flip Y coordinate + + SEGMENT.addPixelColorXY(xi, yFlipped, pcolor.scale8(w0)); + if (xi + 1 < width) + SEGMENT.addPixelColorXY(xi + 1, yFlipped, pcolor.scale8(w1)); + if (yFlipped - 1 >= 0) + SEGMENT.addPixelColorXY(xi, yFlipped - 1, pcolor.scale8(w2)); + if (xi + 1 < width && yFlipped - 1 >= 0) + SEGMENT.addPixelColorXY(xi + 1, yFlipped - 1, pcolor.scale8(w3)); + } + } +} + +static void mode_2D_magma(void) { + if (!strip.isMatrix || !SEGMENT.is2D()) FX_FALLBACK_STATIC; // not a 2D set-up + const uint16_t width = SEG_W; + const uint16_t height = SEG_H; + const uint8_t MAGMA_MAX_PARTICLES = width / 2; + if (MAGMA_MAX_PARTICLES < 2) FX_FALLBACK_STATIC; // matrix too narrow for lava bombs + constexpr size_t SETTINGS_SUM_BYTES = 4; // 4 bytes for settings sum + + // Allocate memory: particles (4 floats each) + 2 floats for noise counters + shiftHue cache + settingsSum + const uint16_t dataSize = (MAGMA_MAX_PARTICLES * 4 + 2) * sizeof(float) + height * sizeof(uint8_t) + SETTINGS_SUM_BYTES; + if (!SEGENV.allocateData(dataSize)) FX_FALLBACK_STATIC; // allocation failed + + float* particleData = reinterpret_cast(SEGENV.data); + float* ff_y = &particleData[MAGMA_MAX_PARTICLES * 4]; + float* ff_z = &particleData[MAGMA_MAX_PARTICLES * 4 + 1]; + uint32_t* settingsSumPtr = reinterpret_cast(&particleData[MAGMA_MAX_PARTICLES * 4 + 2]); + uint8_t* shiftHue = reinterpret_cast(reinterpret_cast(settingsSumPtr) + SETTINGS_SUM_BYTES); + + // Check if settings changed + uint32_t settingsKey = (uint32_t)SEGMENT.speed | ((uint32_t)SEGMENT.intensity << 8) | + ((uint32_t)SEGMENT.custom1 << 16) | ((uint32_t)SEGMENT.custom2 << 24); + bool settingsChanged = (*settingsSumPtr != settingsKey); + + if (SEGENV.call == 0 || settingsChanged) { + // Intensity slider controls magma height + uint16_t intensity = SEGMENT.intensity; + uint16_t fadeRange = map(intensity, 0, 255, height / 3, height); + + // shiftHue controls the vertical color gradient (magma fades out toward top) + for (uint16_t j = 0; j < height; j++) { + if (j < fadeRange) { + // prevent division issues and ensure smooth gradient + if (fadeRange > 1) { + shiftHue[j] = (uint8_t)(j * 255 / (fadeRange - 1)); + } else { + shiftHue[j] = 0; // Single row magma = no fade + } + } else { + shiftHue[j] = 255; + } + } + + // Initialize all particles + for (uint16_t i = 0; i < MAGMA_MAX_PARTICLES; i++) { + uint16_t idx = i * 4; + particleData[idx + 0] = hw_random(0, width * 100) / 100.0f; + particleData[idx + 1] = hw_random(0, height * 25) / 100.0f; + particleData[idx + 2] = hw_random(-75, 75) / 100.0f; + + float baseVelocity = hw_random(60, 120) / 100.0f; + if (hw_random8() < 50) { + baseVelocity *= 1.6f; + } + particleData[idx + 3] = baseVelocity; + } + *ff_y = 0.0f; + *ff_z = 0.0f; + *settingsSumPtr = settingsKey; + } + + if (!shiftHue) FX_FALLBACK_STATIC; // safety check + + // Speed control + float speedfactor = SEGMENT.speed / 255.0f; + speedfactor = speedfactor * speedfactor * 1.5f; + if (speedfactor < 0.001f) speedfactor = 0.001f; + + // Gravity control + float gravity = map(SEGMENT.custom2, 0, 255, 5, 20) / 100.0f; + + // Number of particles (lava bombs) + uint8_t particleCount = map(SEGMENT.custom1, 0, 255, 2, MAGMA_MAX_PARTICLES); + particleCount = constrain(particleCount, 2, MAGMA_MAX_PARTICLES); + + // Draw lava bombs in front of magma (or behind it) + if (SEGMENT.check2) { + drawMagma(width, height, ff_y, ff_z, shiftHue); + SEGMENT.fadeToBlackBy(70); // Dim the entire display to create trailing effect + drawLavaBombs(width, height, particleData, gravity, particleCount); + } + else { + drawLavaBombs(width, height, particleData, gravity, particleCount); + SEGMENT.fadeToBlackBy(70); // Dim the entire display to create trailing effect + drawMagma(width, height, ff_y, ff_z, shiftHue); + } + + // noise counters based on speed slider + *ff_y += speedfactor * 2.0f; + *ff_z += speedfactor; + + SEGENV.step++; +} +static const char _data_FX_MODE_2D_MAGMA[] PROGMEM = "Magma@Flow rate,Magma height,Lava bombs,Gravity,,,Bombs in front;;!;2;ix=192,c2=32,o2=1,pal=35"; + + /* / Ants (created by making modifications to the Rolling Balls code) - Bob Loeffler 2025 * First slider is for the ants' speed. @@ -546,6 +737,7 @@ class UserFxUsermod : public Usermod { public: void setup() override { strip.addEffect(255, &mode_diffusionfire, _data_FX_MODE_DIFFUSIONFIRE); + strip.addEffect(255, &mode_2D_magma, _data_FX_MODE_2D_MAGMA); strip.addEffect(255, &mode_ants, _data_FX_MODE_ANTS); strip.addEffect(255, &mode_morsecode, _data_FX_MODE_MORSECODE); From 278a1fb6c1fcc01daf76764d719e89b861786565 Mon Sep 17 00:00:00 2001 From: BobLoeffler68 Date: Wed, 4 Mar 2026 02:05:52 -0700 Subject: [PATCH 120/164] Lava Lamp FX in the user_fx usermod (#5253) * Added Lava Lamp effect to user_fx usermod --- usermods/user_fx/user_fx.cpp | 295 ++++++++++++++++++++++++++++++++++- 1 file changed, 291 insertions(+), 4 deletions(-) diff --git a/usermods/user_fx/user_fx.cpp b/usermods/user_fx/user_fx.cpp index 4112fdb245..ee1bde9fec 100644 --- a/usermods/user_fx/user_fx.cpp +++ b/usermods/user_fx/user_fx.cpp @@ -96,6 +96,292 @@ unsigned dataSize = cols * rows; // SEGLEN (virtual length) is equivalent to vW static const char _data_FX_MODE_DIFFUSIONFIRE[] PROGMEM = "Diffusion Fire@!,Spark rate,Diffusion Speed,Turbulence,,Use palette;;Color;;2;pal=35"; +/* +/ Lava Lamp 2D effect +* Uses particles to simulate rising blobs of "lava" or wax +* Particles slowly rise, merge to create organic flowing shapes, and then fall to the bottom to start again +* Created by Bob Loeffler using claude.ai +* The first slider sets the number of active blobs +* The second slider sets the size range of the blobs +* The third slider sets the damping value for horizontal blob movement +* The Attract checkbox sets the attraction of blobs (checked will make the blobs attract other close blobs horizontally) +* The Keep Color Ratio checkbox sets whether we preserve the color ratio when displaying pixels that are in 2 or more overlapping blobs +* aux0 keeps track of the blob size value +* aux1 keeps track of the number of blobs +*/ + +typedef struct LavaParticle { + float x, y; // Position + float vx, vy; // Velocity + float size; // Blob size + uint8_t hue; // Color + bool active; // will not be displayed if false + uint16_t delayTop; // number of frames to wait at top before falling again + bool idleTop; // sitting idle at the top + uint16_t delayBottom; // number of frames to wait at bottom before rising again + bool idleBottom; // sitting idle at the bottom +} LavaParticle; + +static void mode_2D_lavalamp(void) { + if (!strip.isMatrix || !SEGMENT.is2D()) FX_FALLBACK_STATIC; // not a 2D set-up + + const uint16_t cols = SEG_W; + const uint16_t rows = SEG_H; + constexpr float MAX_BLOB_RADIUS = 20.0f; // cap to prevent frame rate drops on large matrices + constexpr size_t MAX_LAVA_PARTICLES = 34; // increasing this value could cause slowness for large matrices + constexpr size_t MAX_TOP_FPS_DELAY = 900; // max delay when particles are at the top + constexpr size_t MAX_BOTTOM_FPS_DELAY = 1200; // max delay when particles are at the bottom + + // Allocate per-segment storage + if (!SEGENV.allocateData(sizeof(LavaParticle) * MAX_LAVA_PARTICLES)) FX_FALLBACK_STATIC; + LavaParticle* lavaParticles = reinterpret_cast(SEGENV.data); + + // Initialize particles on first call + if (SEGENV.call == 0) { + for (int i = 0; i < MAX_LAVA_PARTICLES; i++) { + lavaParticles[i].active = false; + } + } + + // Track particle size and particle count slider changes, re-initialize if either changes + uint8_t currentNumParticles = (SEGMENT.intensity >> 3) + 3; + uint8_t currentSize = SEGMENT.custom1; + if (currentNumParticles > MAX_LAVA_PARTICLES) currentNumParticles = MAX_LAVA_PARTICLES; + bool needsReinit = (currentSize != SEGENV.aux0) || (currentNumParticles != SEGENV.aux1); + + if (needsReinit) { + for (int i = 0; i < MAX_LAVA_PARTICLES; i++) { + lavaParticles[i].active = false; + } + SEGENV.aux0 = currentSize; + SEGENV.aux1 = currentNumParticles; + } + + uint8_t size = currentSize; + uint8_t numParticles = currentNumParticles; + + // blob size based on matrix width + const float minSize = cols * 0.15f; // Minimum 15% of width + const float maxSize = cols * 0.4f; // Maximum 40% of width + float sizeRange = (maxSize - minSize) * (size / 255.0f); + int rangeInt = max(1, (int)(sizeRange)); + + // calculate the spawning area for the particles + const float spawnXStart = cols * 0.20f; + const float spawnXWidth = cols * 0.60f; + int spawnX = max(1, (int)(spawnXWidth)); + + bool preserveColorRatio = SEGMENT.check3; + + // Spawn new particles at the bottom near the center + for (int i = 0; i < MAX_LAVA_PARTICLES; i++) { + if (!lavaParticles[i].active && hw_random8() < 32) { // spawn when slot available + // Spawn in the middle 60% of the matrix width + lavaParticles[i].x = spawnXStart + (float)hw_random16(spawnX); + lavaParticles[i].y = rows - 1; + lavaParticles[i].vx = (hw_random16(7) - 3) / 250.0f; + lavaParticles[i].vy = -(hw_random16(20) + 10) / 100.0f * 0.3f; + + lavaParticles[i].size = minSize + (float)hw_random16(rangeInt); + if (lavaParticles[i].size > MAX_BLOB_RADIUS) lavaParticles[i].size = MAX_BLOB_RADIUS; + + lavaParticles[i].hue = hw_random8(); + lavaParticles[i].active = true; + + // Set random delays when particles are at top and bottom + lavaParticles[i].delayTop = hw_random16(MAX_TOP_FPS_DELAY); + lavaParticles[i].delayBottom = hw_random16(MAX_BOTTOM_FPS_DELAY); + lavaParticles[i].idleBottom = true; + break; + } + } + + // Fade background slightly for trailing effect + SEGMENT.fadeToBlackBy(40); + + // Update and draw particles + int activeCount = 0; + unsigned long currentMillis = strip.now; + for (int i = 0; i < MAX_LAVA_PARTICLES; i++) { + if (!lavaParticles[i].active) continue; + activeCount++; + + // Keep particle count on target by deactivating excess particles + if (activeCount > numParticles) { + lavaParticles[i].active = false; + activeCount--; + continue; + } + + LavaParticle *p = &lavaParticles[i]; + + // Physics update + p->x += p->vx; + p->y += p->vy; + + // Optional particle/blob attraction + if (SEGMENT.check2) { + for (int j = 0; j < MAX_LAVA_PARTICLES; j++) { + if (i == j || !lavaParticles[j].active) continue; + + LavaParticle *other = &lavaParticles[j]; + + // Skip attraction if moving in same vertical direction (both up or both down) + if ((p->vy < 0 && other->vy < 0) || (p->vy > 0 && other->vy > 0)) continue; + + float dx = other->x - p->x; + float dy = other->y - p->y; + + // Apply weak horizontal attraction only + float attractRange = p->size + other->size; + float distSq = dx*dx + dy*dy; + float attractRangeSq = attractRange * attractRange; + if (distSq > 0 && distSq < attractRangeSq) { + float dist = sqrt(distSq); // Only compute sqrt when needed + float force = (1.0f - (dist / attractRange)) * 0.0001f; + p->vx += (dx / dist) * force; + } + } + } + + // Horizontal oscillation (makes it more organic) + float damping= map(SEGMENT.custom2, 0, 255, 97, 87) / 100.0f; + p->vx += sin((currentMillis / 1000.0f + i) * 0.5f) * 0.002f; // Reduced oscillation + p->vx *= damping; // damping for more or less horizontal drift + + // Bounce off sides (don't affect vertical velocity) + if (p->x < 0) { + p->x = 0; + p->vx = abs(p->vx); // reverse horizontal + } + if (p->x >= cols) { + p->x = cols - 1; + p->vx = -abs(p->vx); // reverse horizontal + } + + // Adjust rise/fall velocity depending on approx distance from heat source (at bottom) + // In top 1/4th of rows... + if (p->y < rows * .25f) { + if (p->vy >= 0) { // if going down, delay the particles so they won't go down immediately + if (p->delayTop > 0 && p->idleTop) { + p->vy = 0.0f; + p->delayTop--; + p->idleTop = true; + } else { + p->vy = 0.01f; + p->delayTop = hw_random16(MAX_TOP_FPS_DELAY); + p->idleTop = false; + } + } else if (p->vy <= 0) { // if going up, slow down the rise rate + p->vy = -0.03f; + } + } + + // In next 1/4th of rows... + if (p->y <= rows * .50f && p->y >= rows * .25f) { + if (p->vy > 0) { // if going down, speed up the fall rate + p->vy = 0.03f; + } else if (p->vy <= 0) { // if going up, speed up the rise rate a little more + p->vy = -0.05f; + } + } + + // In next 1/4th of rows... + if (p->y <= rows * .75f && p->y >= rows * .50f) { + if (p->vy > 0) { // if going down, speed up the fall rate a little more + p->vy = 0.04f; + } else if (p->vy <= 0) { // if going up, speed up the rise rate + p->vy = -0.03f; + } + } + + // In bottom 1/4th of rows... + if (p->y > rows * .75f) { + if (p->vy >= 0) { // if going down, slow down the fall rate + p->vy = 0.02f; + } else if (p->vy <= 0) { // if going up, delay the particles so they won't go up immediately + if (p->delayBottom > 0 && p->idleBottom) { + p->vy = 0.0f; + p->delayBottom--; + p->idleBottom = true; + } else { + p->vy = -0.01f; + p->delayBottom = hw_random16(MAX_BOTTOM_FPS_DELAY); + p->idleBottom = false; + } + } + } + + // Boundary handling with reversal of direction + // When reaching TOP (y=0 area), reverse to fall back down, but need to delay first + if (p->y <= 0.5f * p->size) { + p->y = 0.5f * p->size; + if (p->vy < 0) { + p->vy = 0.005f; // set to a tiny positive value to start falling very slowly + p->idleTop = true; + } + } + + // When reaching BOTTOM (y=rows-1 area), reverse to rise back up, but need to delay first + if (p->y >= rows - 0.5f * p->size) { + p->y = rows - 0.5f * p->size; + if (p->vy > 0) { + p->vy = -0.005f; // set to a tiny negative value to start rising very slowly + p->idleBottom = true; + } + } + + // Get color + uint32_t color; + color = SEGMENT.color_from_palette(p->hue, true, PALETTE_SOLID_WRAP, 0); + + // Extract RGB and apply life/opacity + uint8_t w = (W(color) * 255) >> 8; + uint8_t r = (R(color) * 255) >> 8; + uint8_t g = (G(color) * 255) >> 8; + uint8_t b = (B(color) * 255) >> 8; + + // Draw blob with sub-pixel accuracy using bilinear distribution + float sizeSq = p->size * p->size; + + // Get fractional offsets of particle center + float fracX = p->x - floorf(p->x); + float fracY = p->y - floorf(p->y); + int centerX = (int)floorf(p->x); + int centerY = (int)floorf(p->y); + + for (int dy = -(int)p->size - 1; dy <= (int)p->size + 1; dy++) { + for (int dx = -(int)p->size - 1; dx <= (int)p->size + 1; dx++) { + int px = centerX + dx; + int py = centerY + dy; + + if (px < 0 || px >= cols || py < 0 || py >= rows) continue; + + // Sub-pixel distance: measure from true float center to pixel center + float subDx = dx - fracX; // distance from true center to this pixel's center + float subDy = dy - fracY; + float distSq = subDx * subDx + subDy * subDy; + + if (distSq < sizeSq) { + float intensity = 1.0f - (distSq / sizeSq); + intensity = intensity * intensity; // smooth falloff + + uint8_t bw = (uint8_t)(w * intensity); + uint8_t br = (uint8_t)(r * intensity); + uint8_t bg = (uint8_t)(g * intensity); + uint8_t bb = (uint8_t)(b * intensity); + + uint32_t existing = SEGMENT.getPixelColorXY(px, py); + uint32_t newColor = RGBW32(br, bg, bb, bw); + SEGMENT.setPixelColorXY(px, py, color_add(existing, newColor, preserveColorRatio ? true : false)); + } + } + } + } +} +static const char _data_FX_MODE_2D_LAVALAMP[] PROGMEM = "Lava Lamp@,# of blobs,Blob size,H. Damping,,,Attract,Keep Color Ratio;;!;2;ix=64,c2=192,o2=1,o3=1,pal=47"; + + /* / Magma effect * 2D magma/lava animation @@ -263,17 +549,17 @@ static void mode_2D_magma(void) { float gravity = map(SEGMENT.custom2, 0, 255, 5, 20) / 100.0f; // Number of particles (lava bombs) - uint8_t particleCount = map(SEGMENT.custom1, 0, 255, 2, MAGMA_MAX_PARTICLES); - particleCount = constrain(particleCount, 2, MAGMA_MAX_PARTICLES); + uint8_t particleCount = map(SEGMENT.custom1, 0, 255, 0, MAGMA_MAX_PARTICLES); + particleCount = constrain(particleCount, 0, MAGMA_MAX_PARTICLES); // Draw lava bombs in front of magma (or behind it) if (SEGMENT.check2) { drawMagma(width, height, ff_y, ff_z, shiftHue); SEGMENT.fadeToBlackBy(70); // Dim the entire display to create trailing effect - drawLavaBombs(width, height, particleData, gravity, particleCount); + if (particleCount > 0) drawLavaBombs(width, height, particleData, gravity, particleCount); } else { - drawLavaBombs(width, height, particleData, gravity, particleCount); + if (particleCount > 0) drawLavaBombs(width, height, particleData, gravity, particleCount); SEGMENT.fadeToBlackBy(70); // Dim the entire display to create trailing effect drawMagma(width, height, ff_y, ff_z, shiftHue); } @@ -737,6 +1023,7 @@ class UserFxUsermod : public Usermod { public: void setup() override { strip.addEffect(255, &mode_diffusionfire, _data_FX_MODE_DIFFUSIONFIRE); + strip.addEffect(255, &mode_2D_lavalamp, _data_FX_MODE_2D_LAVALAMP); strip.addEffect(255, &mode_2D_magma, _data_FX_MODE_2D_MAGMA); strip.addEffect(255, &mode_ants, _data_FX_MODE_ANTS); strip.addEffect(255, &mode_morsecode, _data_FX_MODE_MORSECODE); From b07cd45642a45051bf9aa9cc69e8d59d3bdbe782 Mon Sep 17 00:00:00 2001 From: Damian Schneider Date: Wed, 4 Mar 2026 20:38:17 +0100 Subject: [PATCH 121/164] Bugfix in PS particle size for advanced particles perParticleSize was disabled by default which is wrong. Fixed some bugs regarding to this new parameter: it is now set by default in PS, adusted FX accordingly --- wled00/FX.cpp | 18 +++++++----------- wled00/FXparticleSystem.cpp | 3 ++- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/wled00/FX.cpp b/wled00/FX.cpp index bc5d895cd0..cfee0de1af 100644 --- a/wled00/FX.cpp +++ b/wled00/FX.cpp @@ -8376,7 +8376,6 @@ void mode_particlepit(void) { PartSys->perParticleSize = true; PartSys->advPartProps[i].size = hw_random16(SEGMENT.custom1); // set each particle to random size } else { - PartSys->perParticleSize = false; PartSys->setParticleSize(SEGMENT.custom1); // set global size PartSys->advPartProps[i].size = SEGMENT.custom1; // also set individual size for consistency } @@ -9649,6 +9648,7 @@ void mode_particleFireworks1D(void) { PartSys->setMotionBlur(SEGMENT.custom2); // anable motion blur int32_t gravity = (1 + (SEGMENT.speed >> 3)); // gravity value used for rocket speed calculation PartSys->setGravity(SEGMENT.speed ? gravity : 0); // set gravity + PartSys->setParticleSize(SEGMENT.check3); // 1 or 2 pixel rendering (global size, disables per particle size) if (PartSys->sources[0].sourceFlags.custom1 == 1) { // rocket is on standby PartSys->sources[0].source.ttl--; @@ -9670,7 +9670,6 @@ void mode_particleFireworks1D(void) { PartSys->sources[0].source.vx = min(speed, (uint32_t)127); PartSys->sources[0].source.ttl = 4000; PartSys->sources[0].sat = 30; // low saturation exhaust - PartSys->sources[0].size = SEGMENT.check3; // single or double pixel rendering PartSys->sources[0].sourceFlags.reversegrav = false ; // normal gravity if (SEGENV.aux0) { // inverted rockets launch from end @@ -9770,6 +9769,7 @@ void mode_particleSparkler(void) { numSparklers = PartSys->numSources; PartSys->setMotionBlur(SEGMENT.custom2); // anable motion blur/overlay //PartSys->setSmearBlur(SEGMENT.custom2); // anable smearing blur + PartSys->setParticleSize( SEGMENT.check3 ? 60 : 0); // single pixel or large particle rendering for (uint32_t i = 0; i < numSparklers; i++) { PartSys->sources[i].source.hue = hw_random16(); @@ -9782,7 +9782,6 @@ void mode_particleSparkler(void) { PartSys->sources[i].source.vx = PartSys->sources[i].source.vx > 0 ? speed : -speed; // update speed, do not change direction PartSys->sources[i].source.ttl = 400; // replenish its life (setting it perpetual uses more code) PartSys->sources[i].sat = SEGMENT.custom1; // color saturation - PartSys->sources[i].size = SEGMENT.check3 ? 120 : 0; if (SEGMENT.speed == 255) // random position at highest speed setting PartSys->sources[i].source.x = hw_random16(PartSys->maxX); else @@ -10156,7 +10155,6 @@ void mode_particleChase(void) { } } - PartSys->setParticleSize(SEGMENT.custom1); // if custom1 == 0 this sets rendering size to one pixel PartSys->update(); // update and render } static const char _data_FX_MODE_PS_CHASE[] PROGMEM = "PS Chase@!,Density,Size,Hue,Blur,Playful,,Position Color;,!;!;1;pal=11,sx=50,c2=5,c3=0"; @@ -10174,7 +10172,7 @@ void mode_particleStarburst(void) { FX_FALLBACK_STATIC; // allocation failed or is single pixel PartSys->setKillOutOfBounds(true); PartSys->enableParticleCollisions(true, 200); - PartSys->sources[0].source.ttl = 1; // set initial stanby time + PartSys->sources[0].source.ttl = 1; // set initial standby time PartSys->sources[0].sat = 0; // emitted particles start out white } else @@ -10191,12 +10189,11 @@ void mode_particleStarburst(void) { uint32_t explosionsize = 4 + hw_random16(SEGMENT.intensity >> 2); PartSys->sources[0].source.hue = hw_random16(); PartSys->sources[0].var = 10 + (explosionsize << 1); - PartSys->sources[0].minLife = 250; + PartSys->sources[0].minLife = 150; PartSys->sources[0].maxLife = 300; PartSys->sources[0].source.x = hw_random(PartSys->maxX); //random explosion position PartSys->sources[0].source.ttl = 10 + hw_random16(255 - SEGMENT.speed); PartSys->sources[0].size = SEGMENT.custom1; // Fragment size - PartSys->setParticleSize(SEGMENT.custom1); // enable advanced size rendering PartSys->sources[0].sourceFlags.collide = SEGMENT.check3; for (uint32_t e = 0; e < explosionsize; e++) { // emit particles if (SEGMENT.check2) @@ -10207,9 +10204,9 @@ void mode_particleStarburst(void) { //shrink all particles for (uint32_t i = 0; i < PartSys->usedParticles; i++) { if (PartSys->advPartProps[i].size) - PartSys->advPartProps[i].size--; - if (PartSys->advPartProps[i].sat < 251) - PartSys->advPartProps[i].sat += 1 + (SEGMENT.custom3 >> 2); //note: it should be >> 3, the >> 2 creates overflows resulting in blinking if custom3 > 27, which is a bonus feature + PartSys->advPartProps[i].size --; + if (PartSys->advPartProps[i].sat < 250) + PartSys->advPartProps[i].sat += 2 + (SEGMENT.custom3 >> 3); } if (SEGMENT.call % 5 == 0) { @@ -10533,7 +10530,6 @@ void mode_particle1DsonicBoom(void) { PartSys->sources[0].minLife = 200; PartSys->sources[0].maxLife = PartSys->sources[0].minLife + (((unsigned)SEGMENT.intensity * loudness * loudness) >> 13); PartSys->sources[0].source.hue = SEGMENT.aux0; - PartSys->sources[0].size = 1; //SEGMENT.speed>>3; uint32_t explosionsize = 4 + (PartSys->maxXpixel >> 2); explosionsize = hw_random16((explosionsize * loudness) >> 10); for (uint32_t e = 0; e < explosionsize; e++) { // emit explosion particles diff --git a/wled00/FXparticleSystem.cpp b/wled00/FXparticleSystem.cpp index fd6ce4c4ed..e2782bdd45 100644 --- a/wled00/FXparticleSystem.cpp +++ b/wled00/FXparticleSystem.cpp @@ -48,6 +48,7 @@ ParticleSystem2D::ParticleSystem2D(uint32_t width, uint32_t height, uint32_t num sources[i].source.ttl = 1; //set source alive sources[i].sourceFlags.asByte = 0; // all flags disabled } + perParticleSize = isadvanced; // enable per particle size by default if using advanced properties (FX can disable if needed) } @@ -1159,7 +1160,7 @@ ParticleSystem1D::ParticleSystem1D(uint32_t length, uint32_t numberofparticles, sources[i].source.ttl = 1; //set source alive sources[i].sourceFlags.asByte = 0; // all flags disabled } - + perParticleSize = isadvanced; // enable per particle size by default so FX do not need to set this explicitly. FX can disable by setting global size. if (isadvanced) { for (uint32_t i = 0; i < numParticles; i++) { advPartProps[i].sat = 255; // set full saturation From cd1c5ca6bda391152069093d311aeb280fd94fc8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 06:50:44 +0000 Subject: [PATCH 122/164] Initial plan From 9ae1ef506cb0431884f51fc7ef589fb246544b26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 07:01:10 +0000 Subject: [PATCH 123/164] Allow OTA upgrade between ESP32_V4 and ESP32 release names Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com> --- wled00/wled_metadata.cpp | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/wled00/wled_metadata.cpp b/wled00/wled_metadata.cpp index 19c83dda1c..f0a79b4ff4 100644 --- a/wled00/wled_metadata.cpp +++ b/wled00/wled_metadata.cpp @@ -125,13 +125,20 @@ bool findWledMetadata(const uint8_t* binaryData, size_t dataSize, wled_metadata_ /** - * Check if OTA should be allowed based on release compatibility using custom description - * @param binaryData Pointer to binary file data (not modified) - * @param dataSize Size of binary data in bytes - * @param errorMessage Buffer to store error message if validation fails - * @param errorMessageLen Maximum length of error message buffer - * @return true if OTA should proceed, false if it should be blocked + * Normalize a release name by removing the trailing "_V4" suffix (if present). + * This allows OTA compatibility checks to treat e.g. "ESP32" and "ESP32_V4" as equivalent. + * @param input Source release name string + * @param output Buffer to receive normalized name (must be at least WLED_RELEASE_NAME_MAX_LEN bytes) */ +static void normalizeReleaseName(const char* input, char* output) { + strncpy(output, input, WLED_RELEASE_NAME_MAX_LEN - 1); + output[WLED_RELEASE_NAME_MAX_LEN - 1] = '\0'; + size_t len = strlen(output); + // Strip "_V4" suffix to allow upgrading between IDF v4 and newer IDF builds + if (len >= 4 && strcmp(output + len - 3, "_V4") == 0) { + output[len - 3] = '\0'; + } +} bool shouldAllowOTA(const wled_metadata_t& firmwareDescription, char* errorMessage, size_t errorMessageLen) { // Clear error message @@ -150,12 +157,21 @@ bool shouldAllowOTA(const wled_metadata_t& firmwareDescription, char* errorMessa } if (strncmp_P(safeFirmwareRelease, releaseString, WLED_RELEASE_NAME_MAX_LEN) != 0) { - if (errorMessage && errorMessageLen > 0) { - snprintf_P(errorMessage, errorMessageLen, PSTR("Firmware compatibility mismatch: current='%s', uploaded='%s'."), - releaseString, safeFirmwareRelease); - errorMessage[errorMessageLen - 1] = '\0'; // Ensure null termination + // Exact match failed - check if the names are compatible after normalizing the "_V4" suffix. + // This allows upgrading between e.g. "ESP32_V4" (IDF v4 build) and "ESP32" (newer IDF build). + char normalizedFirmware[WLED_RELEASE_NAME_MAX_LEN]; + char normalizedCurrent[WLED_RELEASE_NAME_MAX_LEN]; + normalizeReleaseName(safeFirmwareRelease, normalizedFirmware); + normalizeReleaseName(releaseString, normalizedCurrent); + + if (strcmp(normalizedFirmware, normalizedCurrent) != 0) { + if (errorMessage && errorMessageLen > 0) { + snprintf_P(errorMessage, errorMessageLen, PSTR("Firmware compatibility mismatch: current='%s', uploaded='%s'."), + releaseString, safeFirmwareRelease); + errorMessage[errorMessageLen - 1] = '\0'; // Ensure null termination + } + return false; } - return false; } // TODO: additional checks go here From 05498f2ae4ef18dc4d14c9a5641b6c9237b469e5 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Fri, 6 Mar 2026 23:21:04 -0500 Subject: [PATCH 124/164] Apply fixes from code review h/t @coderabbitai --- pio-scripts/load_usermods.py | 8 +++++--- wled00/um_manager.cpp | 5 ----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/pio-scripts/load_usermods.py b/pio-scripts/load_usermods.py index 68b6d27a97..a3aa779c9d 100644 --- a/pio-scripts/load_usermods.py +++ b/pio-scripts/load_usermods.py @@ -73,9 +73,11 @@ def _predict_dep_name(entry: str) -> str | None: parts = [p for p in parsed.path.split('/') if p] if len(parts) >= 2: name = parts[1] - return name[:-4] if name.endswith('.git') else name - name = Path(parsed.path.rstrip('/')).name - return name.split('.')[0].strip() or None + else: + name = Path(parsed.path.rstrip('/')).name.strip() + if name.endswith('.git'): + name = name[:-4] + return name or None # SSH git URL: git@github.com:user/repo.git#tag → repo if _SSH_URL_RE.match(entry): path_part = entry.split(':', 1)[1].split('#')[0].rstrip('/') diff --git a/wled00/um_manager.cpp b/wled00/um_manager.cpp index ef21303f0c..504b5ba97c 100644 --- a/wled00/um_manager.cpp +++ b/wled00/um_manager.cpp @@ -48,11 +48,6 @@ void UsermodManager::addToJsonInfo(JsonObject& obj) { void UsermodManager::readFromJsonState(JsonObject& obj) { for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) (*mod)->readFromJsonState(obj); } void UsermodManager::addToConfig(JsonObject& obj) { for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) (*mod)->addToConfig(obj); } bool UsermodManager::readFromConfig(JsonObject& obj) { - Serial.printf_P(PSTR("Mods: %d Begin: %08X End: %08X\n"), getCount(), (intptr_t) &DYNARRAY_BEGIN(usermods)[0], (intptr_t) &DYNARRAY_END(usermods)[0]); - Usermod** volatile x = (Usermod**) DYNARRAY_BEGIN(usermods); - Serial.printf_P(PSTR("X: %08X\n"), (intptr_t) x); - Serial.printf_P(PSTR("*X: %08X\n"), (intptr_t) *x); - bool allComplete = true; for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) { if (!(*mod)->readFromConfig(obj)) allComplete = false; From 5e1ae9976756db2a77fb00ec4cbf7271a7f85771 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 09:58:05 +0000 Subject: [PATCH 125/164] Refactor normalizeReleaseName to use Arduino String for cleaner code Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com> --- wled00/wled_metadata.cpp | 44 ++++++++++++---------------------------- 1 file changed, 13 insertions(+), 31 deletions(-) diff --git a/wled00/wled_metadata.cpp b/wled00/wled_metadata.cpp index f0a79b4ff4..33e0aac08d 100644 --- a/wled00/wled_metadata.cpp +++ b/wled00/wled_metadata.cpp @@ -124,20 +124,10 @@ bool findWledMetadata(const uint8_t* binaryData, size_t dataSize, wled_metadata_ } -/** - * Normalize a release name by removing the trailing "_V4" suffix (if present). - * This allows OTA compatibility checks to treat e.g. "ESP32" and "ESP32_V4" as equivalent. - * @param input Source release name string - * @param output Buffer to receive normalized name (must be at least WLED_RELEASE_NAME_MAX_LEN bytes) - */ -static void normalizeReleaseName(const char* input, char* output) { - strncpy(output, input, WLED_RELEASE_NAME_MAX_LEN - 1); - output[WLED_RELEASE_NAME_MAX_LEN - 1] = '\0'; - size_t len = strlen(output); - // Strip "_V4" suffix to allow upgrading between IDF v4 and newer IDF builds - if (len >= 4 && strcmp(output + len - 3, "_V4") == 0) { - output[len - 3] = '\0'; - } +// Strip "_V4" suffix from a release name to allow upgrading between IDF v4 and newer IDF builds. +static String normalizeReleaseName(const String& name) { + if (name.endsWith("_V4")) return name.substring(0, name.length() - 3); + return name; } bool shouldAllowOTA(const wled_metadata_t& firmwareDescription, char* errorMessage, size_t errorMessageLen) { @@ -146,28 +136,20 @@ bool shouldAllowOTA(const wled_metadata_t& firmwareDescription, char* errorMessa errorMessage[0] = '\0'; } - // Validate compatibility using extracted release name - // We make a stack copy so we can print it safely - char safeFirmwareRelease[WLED_RELEASE_NAME_MAX_LEN]; - strncpy(safeFirmwareRelease, firmwareDescription.release_name, WLED_RELEASE_NAME_MAX_LEN - 1); - safeFirmwareRelease[WLED_RELEASE_NAME_MAX_LEN - 1] = '\0'; - - if (strlen(safeFirmwareRelease) == 0) { + const String uploadedRelease(firmwareDescription.release_name); + const String currentRelease(releaseString); + + if (uploadedRelease.isEmpty()) { return false; - } + } - if (strncmp_P(safeFirmwareRelease, releaseString, WLED_RELEASE_NAME_MAX_LEN) != 0) { + if (uploadedRelease != currentRelease) { // Exact match failed - check if the names are compatible after normalizing the "_V4" suffix. // This allows upgrading between e.g. "ESP32_V4" (IDF v4 build) and "ESP32" (newer IDF build). - char normalizedFirmware[WLED_RELEASE_NAME_MAX_LEN]; - char normalizedCurrent[WLED_RELEASE_NAME_MAX_LEN]; - normalizeReleaseName(safeFirmwareRelease, normalizedFirmware); - normalizeReleaseName(releaseString, normalizedCurrent); - - if (strcmp(normalizedFirmware, normalizedCurrent) != 0) { + if (normalizeReleaseName(uploadedRelease) != normalizeReleaseName(currentRelease)) { if (errorMessage && errorMessageLen > 0) { - snprintf_P(errorMessage, errorMessageLen, PSTR("Firmware compatibility mismatch: current='%s', uploaded='%s'."), - releaseString, safeFirmwareRelease); + snprintf_P(errorMessage, errorMessageLen, PSTR("Firmware compatibility mismatch: current='%s', uploaded='%s'."), + currentRelease.c_str(), uploadedRelease.c_str()); errorMessage[errorMessageLen - 1] = '\0'; // Ensure null termination } return false; From 7f44396f7f320f52fb937de1df25f9aa989029e1 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Sat, 7 Mar 2026 08:55:42 -0500 Subject: [PATCH 126/164] validate_modules: Support LTO When LTO is enabled, the map file no longer provides a positive indication of whether a given object file has contributed to the final binary. Instead use nm to parse the debug data in the .elf file. Co-Authored-By: Claude --- pio-scripts/load_usermods.py | 8 ++++ pio-scripts/validate_modules.py | 73 ++++++++++++++++++++++++++------- 2 files changed, 66 insertions(+), 15 deletions(-) diff --git a/pio-scripts/load_usermods.py b/pio-scripts/load_usermods.py index a3aa779c9d..18852ff30b 100644 --- a/pio-scripts/load_usermods.py +++ b/pio-scripts/load_usermods.py @@ -180,6 +180,14 @@ def wrapped_ConfigureProjectLibBuilder(xenv): # Add WLED's own dependencies for dir in extra_include_dirs: dep.env.PrependUnique(CPPPATH=str(dir)) + # Ensure debug info is emitted for this module's source files. + # validate_modules.py uses `nm --defined-only -l` on the final ELF to check + # that each module has at least one symbol placed in the binary. The -l flag + # reads DWARF debug sections to map placed symbols back to their original source + # files; without -g those sections are absent and the check cannot attribute any + # symbol to a specific module. We scope this to usermods only — the main WLED + # build and other libraries are unaffected. + dep.env.AppendUnique(CCFLAGS=["-g"]) # Enforce that libArchive is not set; we must link them directly to the executable if dep.lib_archive: broken_usermods.append(dep) diff --git a/pio-scripts/validate_modules.py b/pio-scripts/validate_modules.py index 7b6ff6cc84..ae02e1f80d 100644 --- a/pio-scripts/validate_modules.py +++ b/pio-scripts/validate_modules.py @@ -1,6 +1,7 @@ +import os import re +import subprocess from pathlib import Path # For OS-agnostic path manipulation -from typing import Iterable from click import secho from SCons.Script import Action, Exit Import("env") @@ -12,24 +13,64 @@ def read_lines(p: Path): return f.readlines() -def check_map_file_objects(map_file: list[str], dirs: Iterable[str]) -> set[str]: - """ Identify which dirs contributed to the final build +def _get_nm_path(env) -> str: + """ Derive the nm tool path from the build environment """ + if "NM" in env: + return env.subst("$NM") + # Derive from the C compiler: xtensa-esp32-elf-gcc → xtensa-esp32-elf-nm + cc = env.subst("$CC") + nm = re.sub(r'(gcc|g\+\+)$', 'nm', os.path.basename(cc)) + return os.path.join(os.path.dirname(cc), nm) - Returns the (sub)set of dirs that are found in the output ELF + +def check_elf_modules(elf_path: Path, env, module_lib_builders) -> set[str]: + """ Check which modules have at least one defined symbol placed in the ELF. + + The map file is not a reliable source for this: with LTO, original object + file paths are replaced by temporary ltrans.o partitions in all output + sections, making per-module attribution impossible from the map alone. + Instead we invoke nm --defined-only -l on the ELF, which uses DWARF debug + info to attribute each placed symbol to its original source file. + + Requires usermod libraries to be compiled with -g so that DWARF sections + are present in the ELF. load_usermods.py injects -g for all WLED modules + via dep.env.AppendUnique(CCFLAGS=["-g"]). + + Returns the set of build_dir basenames for confirmed modules. """ - # Pattern to match symbols in object directories - # Join directories into alternation - usermod_dir_regex = "|".join([re.escape(dir) for dir in dirs]) - # Matches nonzero address, any size, and any path in a matching directory - object_path_regex = re.compile(r"0x0*[1-9a-f][0-9a-f]*\s+0x[0-9a-f]+\s+\S+[/\\](" + usermod_dir_regex + r")[/\\]\S+\.o") + nm_path = _get_nm_path(env) + try: + result = subprocess.run( + [nm_path, "--defined-only", "-l", str(elf_path)], + capture_output=True, text=True, errors="ignore", timeout=120, + ) + nm_output = result.stdout + except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e: + secho(f"WARNING: nm failed ({e}); skipping per-module validation", fg="yellow", err=True) + return {Path(b.build_dir).name for b in module_lib_builders} # conservative pass + + # Build a filtered set of lines that have a nonzero address. + # nm --defined-only still includes debugging symbols (type 'N') such as the + # per-CU markers GCC emits in .debug_info (e.g. "usermod_example_cpp_6734d48d"). + # These live at address 0x00000000 in their debug section — not in any load + # segment — so filtering them out leaves only genuinely placed symbols. + placed_lines = [ + line for line in nm_output.splitlines() + if (parts := line.split(None, 1)) and parts[0].lstrip('0') + ] + placed_output = "\n".join(placed_lines) found = set() - for line in map_file: - matches = object_path_regex.findall(line) - for m in matches: - found.add(m) + for builder in module_lib_builders: + # builder.src_dir is the library source directory (used by is_wled_module() too) + src_dir = str(builder.src_dir).rstrip("/\\") + # Guard against prefix collisions (e.g. /path/to/mod vs /path/to/mod-extra) + # by requiring a path separator immediately after the directory name. + if re.search(re.escape(src_dir) + r'[/\\]', placed_output): + found.add(Path(builder.build_dir).name) return found + DYNARRAY_SECTION = ".dtors" if env.get("PIOPLATFORM") == "espressif8266" else ".dynarray" USERMODS_SECTION = f"{DYNARRAY_SECTION}.usermods.1" @@ -60,11 +101,13 @@ def validate_map_file(source, target, env): usermod_object_count = count_usermod_objects(map_file_contents) secho(f"INFO: {usermod_object_count} usermod object entries") - confirmed_modules = check_map_file_objects(map_file_contents, modules.keys()) + elf_path = build_dir / env.subst("${PROGNAME}.elf") + confirmed_modules = check_elf_modules(elf_path, env, module_lib_builders) + missing_modules = [modname for mdir, modname in modules.items() if mdir not in confirmed_modules] if missing_modules: secho( - f"ERROR: No object files from {missing_modules} found in linked output!", + f"ERROR: No symbols from {missing_modules} found in linked output!", fg="red", err=True) Exit(1) From 3bfdb736e3144e6bee80780401f22c02dc79057b Mon Sep 17 00:00:00 2001 From: Will Miles Date: Tue, 10 Mar 2026 09:56:39 -0400 Subject: [PATCH 127/164] Fix usermod validation portability Use proper path analysis instead of bare text matching. Co-authored-by: Claude --- pio-scripts/validate_modules.py | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/pio-scripts/validate_modules.py b/pio-scripts/validate_modules.py index ae02e1f80d..698ec7386f 100644 --- a/pio-scripts/validate_modules.py +++ b/pio-scripts/validate_modules.py @@ -49,25 +49,36 @@ def check_elf_modules(elf_path: Path, env, module_lib_builders) -> set[str]: secho(f"WARNING: nm failed ({e}); skipping per-module validation", fg="yellow", err=True) return {Path(b.build_dir).name for b in module_lib_builders} # conservative pass - # Build a filtered set of lines that have a nonzero address. + # Collect source file Paths from placed symbols (nonzero address only). # nm --defined-only still includes debugging symbols (type 'N') such as the # per-CU markers GCC emits in .debug_info (e.g. "usermod_example_cpp_6734d48d"). # These live at address 0x00000000 in their debug section — not in any load # segment — so filtering them out leaves only genuinely placed symbols. - placed_lines = [ - line for line in nm_output.splitlines() - if (parts := line.split(None, 1)) and parts[0].lstrip('0') - ] - placed_output = "\n".join(placed_lines) + # nm -l appends a tab-separated "file:lineno" location to each symbol line. + placed_paths: set[Path] = set() + for line in nm_output.splitlines(): + parts = line.split(None, 1) + if not (parts and parts[0].lstrip('0')): + continue # zero address — skip debug-section marker + if '\t' in line: + loc = line.rsplit('\t', 1)[1] + # Strip trailing :lineno (e.g. "/path/to/foo.cpp:42" → "/path/to/foo.cpp") + file_part = loc.rsplit(':', 1)[0] + placed_paths.add(Path(file_part)) found = set() for builder in module_lib_builders: # builder.src_dir is the library source directory (used by is_wled_module() too) - src_dir = str(builder.src_dir).rstrip("/\\") - # Guard against prefix collisions (e.g. /path/to/mod vs /path/to/mod-extra) - # by requiring a path separator immediately after the directory name. - if re.search(re.escape(src_dir) + r'[/\\]', placed_output): - found.add(Path(builder.build_dir).name) + src_dir = Path(str(builder.src_dir)) + # Path.is_relative_to() / relative_to() handles OS-specific separators + # correctly without any regex, avoiding Windows path escaping issues. + for p in placed_paths: + try: + p.relative_to(src_dir) + found.add(Path(builder.build_dir).name) + break + except ValueError: + pass return found From 2ab9659332dc55e1dc526fadb6ec3cda4d83049d Mon Sep 17 00:00:00 2001 From: Will Miles Date: Tue, 10 Mar 2026 10:11:23 -0400 Subject: [PATCH 128/164] Speed up usermod validation Avoid checking usermods we've already matched, and exit early if we've got everything. Co-authored-by: Claude --- pio-scripts/validate_modules.py | 42 ++++++++++++++++----------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/pio-scripts/validate_modules.py b/pio-scripts/validate_modules.py index 698ec7386f..ae098f43cc 100644 --- a/pio-scripts/validate_modules.py +++ b/pio-scripts/validate_modules.py @@ -49,36 +49,34 @@ def check_elf_modules(elf_path: Path, env, module_lib_builders) -> set[str]: secho(f"WARNING: nm failed ({e}); skipping per-module validation", fg="yellow", err=True) return {Path(b.build_dir).name for b in module_lib_builders} # conservative pass - # Collect source file Paths from placed symbols (nonzero address only). + # Match placed symbols against builders as we parse nm output, exiting early + # once all builders are accounted for. # nm --defined-only still includes debugging symbols (type 'N') such as the # per-CU markers GCC emits in .debug_info (e.g. "usermod_example_cpp_6734d48d"). # These live at address 0x00000000 in their debug section — not in any load # segment — so filtering them out leaves only genuinely placed symbols. # nm -l appends a tab-separated "file:lineno" location to each symbol line. - placed_paths: set[Path] = set() + remaining = {Path(str(b.src_dir)): Path(b.build_dir).name for b in module_lib_builders} + found = set() + for line in nm_output.splitlines(): - parts = line.split(None, 1) - if not (parts and parts[0].lstrip('0')): + if not remaining: + break # all builders matched + addr, _, _ = line.partition(' ') + if not addr.lstrip('0'): continue # zero address — skip debug-section marker - if '\t' in line: - loc = line.rsplit('\t', 1)[1] - # Strip trailing :lineno (e.g. "/path/to/foo.cpp:42" → "/path/to/foo.cpp") - file_part = loc.rsplit(':', 1)[0] - placed_paths.add(Path(file_part)) - - found = set() - for builder in module_lib_builders: - # builder.src_dir is the library source directory (used by is_wled_module() too) - src_dir = Path(str(builder.src_dir)) - # Path.is_relative_to() / relative_to() handles OS-specific separators - # correctly without any regex, avoiding Windows path escaping issues. - for p in placed_paths: - try: - p.relative_to(src_dir) - found.add(Path(builder.build_dir).name) + if '\t' not in line: + continue + loc = line.rsplit('\t', 1)[1] + # Strip trailing :lineno (e.g. "/path/to/foo.cpp:42" → "/path/to/foo.cpp") + src_path = Path(loc.rsplit(':', 1)[0]) + # Path.is_relative_to() handles OS-specific separators correctly without + # any regex, avoiding Windows path escaping issues. + for src_dir in list(remaining): + if src_path.is_relative_to(src_dir): + found.add(remaining.pop(src_dir)) break - except ValueError: - pass + return found From c35358c005e06d372888118cf0169952cd63f677 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Tue, 10 Mar 2026 19:07:19 +0000 Subject: [PATCH 129/164] Display relase on info page --- wled00/data/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wled00/data/index.js b/wled00/data/index.js index 6b313997c9..a267bd051f 100644 --- a/wled00/data/index.js +++ b/wled00/data/index.js @@ -697,7 +697,7 @@ function populateInfo(i) var vcn = "Kuuhaku"; if (i.cn) vcn = i.cn; - cn += `v${i.ver} "${vcn}"

+ cn += `v${i.ver} "${vcn}"${i.release ? '
('+i.release+')' : ''}

${urows} ${urows===""?'':''} ${i.opt&0x100?inforow("Debug",""):''} From 40442551efb5e81548ed54316d9928e1efd7f89e Mon Sep 17 00:00:00 2001 From: Joachim Dick <62520542+JoaDick@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:19:42 +0100 Subject: [PATCH 130/164] New smooth effect: Color Clouds (#5268) * New Effect: Color Clouds ColorClouds: Random start points for clouds and color ColorClouds: new config option 'More red' * ColorClouds: Incorporated review comments - Support for color palettes - Use perlin16() instead of inoise16() - Use cos8_t() instead of cos8() * ColorClouds: incorporated more review comments * ColorClouds: incorporated final review comment --- wled00/FX.cpp | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++- wled00/FX.h | 3 +- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/wled00/FX.cpp b/wled00/FX.cpp index cfee0de1af..a6caa471f8 100644 --- a/wled00/FX.cpp +++ b/wled00/FX.cpp @@ -4944,9 +4944,83 @@ void mode_aurora(void) { SEGMENT.setPixelColor(i, mixedRgb); } } - static const char _data_FX_MODE_AURORA[] PROGMEM = "Aurora@!,!;1,2,3;!;;sx=24,pal=50"; + +/** Softly floating colorful clouds. + * This is a very smooth effect that moves colorful clouds randomly around the LED strip. + * It was initially intended for rather unobtrusive ambient lights (with very slow speed settings). + * Nevertheless, it appears completely different and quite vibrant when the sliders are moved near + * to their limits. No matter in which direction or in which combination... + * Ported to WLED from https://github.com/JoaDick/EyeCandy/blob/master/ColorClouds.h + */ +void mode_ColorClouds() +{ + // Set random start points for clouds and color. + if (SEGENV.call == 0) { + SEGENV.aux0 = hw_random16(); + SEGENV.aux1 = hw_random16(); + } + const uint32_t volX0 = SEGENV.aux0; + const uint32_t hueX0 = SEGENV.aux1; + const uint8_t hueOffset0 = volX0 + hueX0; // derive a 3rd random number + + // Makes a very soft wraparound of the color palette by putting more emphasis on the begin & end + // of the palette (or on the red'ish colors in case of a rainbow spectrum). + // This gives the effect oftentimes an even more calm perception. + const bool cozy = SEGMENT.check3; + + // Higher values make the clouds move faster. + const uint32_t volSpeed = 1 + SEGMENT.speed; + + // Higher values make the color change faster. + const uint32_t hueSpeed = 1 + SEGMENT.intensity; + + // Higher values make more clouds (but smaller ones). + const uint32_t volSqueeze = 8 + SEGMENT.custom1; + + // Higher values make the clouds more colorful. + const uint32_t hueSqueeze = SEGMENT.custom2; + + // Higher values make larger gaps between the clouds. + const int32_t volCutoff = 12500 + SEGMENT.custom3 * 900; + const int32_t volSaturate = 52000; + // Note: When adjusting these calculations, ensure that volCutoff is always smaller than volSaturate. + + const uint32_t now = strip.now; + const uint32_t volT = now * volSpeed / 8; + const uint32_t hueT = now * hueSpeed / 8; + const uint8_t hueOffset = beat88(64) >> 8; + + for (int i = 0; i < SEGLEN; i++) { + const uint32_t volX = i * volSqueeze * 64; + int32_t vol = perlin16(volX0 + volX, volT); + vol = map(vol, volCutoff, volSaturate, 0, 255); + vol = constrain(vol, 0, 255); + + const uint32_t hueX = i * hueSqueeze * 8; + uint8_t hue = perlin16(hueX0 + hueX, hueT) >> 7; + hue += hueOffset0; + hue += hueOffset; + if (cozy) { + hue = cos8_t(128 + hue / 2); + } + + uint32_t pixel; + if (SEGMENT.palette) { pixel = SEGMENT.color_from_palette(hue, false, true, 0, vol); } + else { hsv2rgb(CHSV32(hue, 255, vol), pixel); } + + // Suppress extremely dark pixels to avoid flickering of plain r/g/b. + if (int(R(pixel)) + G(pixel) + B(pixel) <= 2) { + pixel = 0; + } + + SEGMENT.setPixelColor(i, pixel); + } +} +static const char _data_FX_MODE_COLORCLOUDS[] PROGMEM = "Color Clouds@!,!,Clouds,Colors,Distance,,,Cozy;;!;;sx=24,ix=32,c1=48,c2=64,c3=12,pal=0"; + + // WLED-SR effects ///////////////////////// @@ -10789,6 +10863,7 @@ void WS2812FX::setupEffectData() { addEffect(FX_MODE_COLOR_SWEEP_RANDOM, &mode_color_sweep_random, _data_FX_MODE_COLOR_SWEEP_RANDOM); addEffect(FX_MODE_RUNNING_COLOR, &mode_running_color, _data_FX_MODE_RUNNING_COLOR); addEffect(FX_MODE_AURORA, &mode_aurora, _data_FX_MODE_AURORA); + addEffect(FX_MODE_COLORCLOUDS, &mode_ColorClouds, _data_FX_MODE_COLORCLOUDS); addEffect(FX_MODE_RUNNING_RANDOM, &mode_running_random, _data_FX_MODE_RUNNING_RANDOM); addEffect(FX_MODE_LARSON_SCANNER, &mode_larson_scanner, _data_FX_MODE_LARSON_SCANNER); addEffect(FX_MODE_RAIN, &mode_rain, _data_FX_MODE_RAIN); diff --git a/wled00/FX.h b/wled00/FX.h index 9c5291665c..0919f8e8dc 100644 --- a/wled00/FX.h +++ b/wled00/FX.h @@ -379,7 +379,8 @@ extern byte realtimeMode; // used in getMappedPixelIndex() #define FX_MODE_PS1DSONICBOOM 215 #define FX_MODE_PS1DSPRINGY 216 #define FX_MODE_PARTICLEGALAXY 217 -#define MODE_COUNT 218 +#define FX_MODE_COLORCLOUDS 218 +#define MODE_COUNT 219 #define BLEND_STYLE_FADE 0x00 // universal From c7fa496fda02f6a400bcdd9e1a9255f6b006070a Mon Sep 17 00:00:00 2001 From: BobLoeffler68 Date: Wed, 11 Mar 2026 10:01:56 -0700 Subject: [PATCH 131/164] Spinning Wheel FX in the user_fx usermod (#5293) * Added the Spinning Wheel effect into the user_fx usermod * Fixed integer overflow when storing color scale in aux1. And added a comment about the velocity scaling. * Additions/changes: * Added Color Per Block checkbox. Enabled will set the spinner LEDs to the same color (instead of changing colors depending on the palette and LED position). * Added Sync Restart checkbox. Enabled means that all spinners will restart together (instead of individually) after they have all stopped spinning. * Added resizing the spinner slider (between 1 and 10 LEDs). * Changed how we do random speed and slowdown start time (sliders set to 0 = random). * tweaks here and there * One minor fix for the spinner colors * Changed the two analogRead() to hw_random16() * Changes from SEGLEN to vstripLen suggested by coderabbitai, but it's not working correctly now. Committing and pushing so coderabbitai can check the latest code. * Rolled back changes from vstripLen to SEGLEN as that is what works correctly. Also changed to the global paletteBlend. * Fixed a color issue by using ColorFromPaletteWLED() instead of color_from_palette(). Also removed color_wheel() and the Color Mode option as it's very similar to the new color function. And now using strips variable instead of SEGMENT.nrOfVStrips() after the initial assignment at the top of the code. * Added the ability to spin the wheel(s)/spinner(s) with a push button or the new Spin Me checkbox. * Set default of check1 to 1 so it will automatically spin. --- usermods/user_fx/user_fx.cpp | 247 +++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) diff --git a/usermods/user_fx/user_fx.cpp b/usermods/user_fx/user_fx.cpp index ee1bde9fec..e01c319fd8 100644 --- a/usermods/user_fx/user_fx.cpp +++ b/usermods/user_fx/user_fx.cpp @@ -5,6 +5,8 @@ // paletteBlend: 0 - wrap when moving, 1 - always wrap, 2 - never wrap, 3 - none (undefined) #define PALETTE_SOLID_WRAP (strip.paletteBlend == 1 || strip.paletteBlend == 3) +#define indexToVStrip(index, stripNr) ((index) | (int((stripNr)+1)<<16)) + // static effect, used if an effect fails to initialize static void mode_static(void) { SEGMENT.fill(SEGCOLOR(0)); @@ -96,6 +98,250 @@ unsigned dataSize = cols * rows; // SEGLEN (virtual length) is equivalent to vW static const char _data_FX_MODE_DIFFUSIONFIRE[] PROGMEM = "Diffusion Fire@!,Spark rate,Diffusion Speed,Turbulence,,Use palette;;Color;;2;pal=35"; +/* + * Spinning Wheel effect - LED animates around 1D strip (or each column in a 2D matrix), slows down and stops at random position + * Created by Bob Loeffler and claude.ai + * First slider (Spin speed) is for the speed of the moving/spinning LED (random number within a narrow speed range). + * If value is 0, a random speed will be selected from the full range of values. + * Second slider (Spin slowdown start time) is for how long before the slowdown phase starts (random number within a narrow time range). + * If value is 0, a random time will be selected from the full range of values. + * Third slider (Spinner size) is for the number of pixels that make up the spinner. + * Fourth slider (Spin delay) is for how long it takes for the LED to start spinning again after the previous spin. + * The first checkbox allows the spinner to spin. If it's enabled, the spinner will do its thing. If it's not enabled, it will wait for the user to enable + * it either by clicking the checkbox or by pressing a physical button (e.g. using a playlist to run a couple presets that have JSON API codes). + * The second checkbox sets "color per block" mode. Enabled means that each spinner block will be the same color no matter what its LED position is. + * The third checkbox enables synchronized restart (all spinners restart together instead of individually). + * aux0 stores the settings checksum to detect changes + * aux1 stores the color scale for performance + */ + +static void mode_spinning_wheel(void) { + if (SEGLEN < 1) FX_FALLBACK_STATIC; + + unsigned strips = SEGMENT.nrOfVStrips(); + if (strips == 0) FX_FALLBACK_STATIC; + + constexpr unsigned stateVarsPerStrip = 8; + unsigned dataSize = sizeof(uint32_t) * stateVarsPerStrip; + if (!SEGENV.allocateData(dataSize * strips)) FX_FALLBACK_STATIC; + uint32_t* state = reinterpret_cast(SEGENV.data); + // state[0] = current position (fixed point: upper 16 bits = position, lower 16 bits = fraction) + // state[1] = velocity (fixed point: pixels per frame * 65536) + // state[2] = phase (0=fast spin, 1=slowing, 2=wobble, 3=stopped) + // state[3] = stop time (when phase 3 was entered) + // state[4] = wobble step (0=at stop pos, 1=moved back, 2=returned to stop) + // state[5] = slowdown start time (when to transition from phase 0 to phase 1) + // state[6] = wobble timing (for 200ms / 400ms / 300ms delays) + // state[7] = store the stop position per strip + + // state[] index values for easier readability + constexpr unsigned CUR_POS_IDX = 0; // state[0] + constexpr unsigned VELOCITY_IDX = 1; + constexpr unsigned PHASE_IDX = 2; + constexpr unsigned STOP_TIME_IDX = 3; + constexpr unsigned WOBBLE_STEP_IDX = 4; + constexpr unsigned SLOWDOWN_TIME_IDX = 5; + constexpr unsigned WOBBLE_TIME_IDX = 6; + constexpr unsigned STOP_POS_IDX = 7; + + SEGMENT.fill(SEGCOLOR(1)); + + // Handle random seeding globally (outside the virtual strip) + if (SEGENV.call == 0) { + random16_set_seed(hw_random16()); + SEGENV.aux1 = (255 << 8) / SEGLEN; // Cache the color scaling + } + + // Check if settings changed (do this once, not per virtual strip) + uint32_t settingssum = SEGMENT.speed + SEGMENT.intensity + SEGMENT.custom1 + SEGMENT.custom3 + SEGMENT.check1 + SEGMENT.check3; + bool settingsChanged = (SEGENV.aux0 != settingssum); + if (settingsChanged) { + random16_add_entropy(hw_random16()); + SEGENV.aux0 = settingssum; + } + + // Check if all spinners are stopped and ready to restart (for synchronized restart) + bool allReadyToRestart = true; + if (SEGMENT.check3) { + uint8_t spinnerSize = map(SEGMENT.custom1, 0, 255, 1, 10); + uint16_t spin_delay = map(SEGMENT.custom3, 0, 31, 2000, 15000); + uint32_t now = strip.now; + + for (unsigned stripNr = 0; stripNr < strips; stripNr += spinnerSize) { + uint32_t* stripState = &state[stripNr * stateVarsPerStrip]; + // Check if this spinner is stopped AND has waited its delay + if (stripState[PHASE_IDX] != 3 || stripState[STOP_TIME_IDX] == 0) { + allReadyToRestart = false; + break; + } + // Check if delay has elapsed + if ((now - stripState[STOP_TIME_IDX]) < spin_delay) { + allReadyToRestart = false; + break; + } + } + } + + struct virtualStrip { + static void runStrip(uint16_t stripNr, uint32_t* state, bool settingsChanged, bool allReadyToRestart, unsigned strips) { + uint8_t phase = state[PHASE_IDX]; + uint32_t now = strip.now; + + // Check for restart conditions + bool needsReset = false; + if (SEGENV.call == 0) { + needsReset = true; + } else if (settingsChanged && SEGMENT.check1) { + needsReset = true; + } else if (phase == 3 && state[STOP_TIME_IDX] != 0) { + // If synchronized restart is enabled, only restart when all strips are ready + if (SEGMENT.check3) { + if (allReadyToRestart) { + needsReset = true; + } + } else { + // Normal mode: restart after individual strip delay + uint16_t spin_delay = map(SEGMENT.custom3, 0, 31, 2000, 15000); + if ((now - state[STOP_TIME_IDX]) >= spin_delay) { + needsReset = true; + } + } + } + + // Initialize or restart + if (needsReset && SEGMENT.check1) { // spin the spinner(s) only if the "Spin me!" checkbox is enabled + state[CUR_POS_IDX] = 0; + + // Set velocity + uint16_t speed = map(SEGMENT.speed, 0, 255, 300, 800); + if (speed == 300) { // random speed (user selected 0 on speed slider) + state[VELOCITY_IDX] = random16(200, 900) * 655; // fixed-point velocity scaling (approx. 65536/100) + } else { + state[VELOCITY_IDX] = random16(speed - 100, speed + 100) * 655; + } + + // Set slowdown start time + uint16_t slowdown = map(SEGMENT.intensity, 0, 255, 3000, 5000); + if (slowdown == 3000) { // random slowdown start time (user selected 0 on intensity slider) + state[SLOWDOWN_TIME_IDX] = now + random16(2000, 6000); + } else { + state[SLOWDOWN_TIME_IDX] = now + random16(slowdown - 1000, slowdown + 1000); + } + + state[PHASE_IDX] = 0; + state[STOP_TIME_IDX] = 0; + state[WOBBLE_STEP_IDX] = 0; + state[WOBBLE_TIME_IDX] = 0; + state[STOP_POS_IDX] = 0; // Initialize stop position + phase = 0; + } + + uint32_t pos_fixed = state[CUR_POS_IDX]; + uint32_t velocity = state[VELOCITY_IDX]; + + // Phase management + if (phase == 0) { + // Fast spinning phase + if ((int32_t)(now - state[SLOWDOWN_TIME_IDX]) >= 0) { + phase = 1; + state[PHASE_IDX] = 1; + } + } else if (phase == 1) { + // Slowing phase - apply deceleration + uint32_t decel = velocity / 80; + if (decel < 100) decel = 100; + + velocity = (velocity > decel) ? velocity - decel : 0; + state[VELOCITY_IDX] = velocity; + + // Check if stopped + if (velocity < 2000) { + velocity = 0; + state[VELOCITY_IDX] = 0; + phase = 2; + state[PHASE_IDX] = 2; + state[WOBBLE_STEP_IDX] = 0; + uint16_t stop_pos = (pos_fixed >> 16) % SEGLEN; + state[STOP_POS_IDX] = stop_pos; + state[WOBBLE_TIME_IDX] = now; + } + } else if (phase == 2) { + // Wobble phase (moves the LED back one and then forward one) + uint32_t wobble_step = state[WOBBLE_STEP_IDX]; + uint16_t stop_pos = state[STOP_POS_IDX]; + uint32_t elapsed = now - state[WOBBLE_TIME_IDX]; + + if (wobble_step == 0 && elapsed >= 200) { + // Move back one LED from stop position + uint16_t back_pos = (stop_pos == 0) ? SEGLEN - 1 : stop_pos - 1; + pos_fixed = ((uint32_t)back_pos) << 16; + state[CUR_POS_IDX] = pos_fixed; + state[WOBBLE_STEP_IDX] = 1; + state[WOBBLE_TIME_IDX] = now; + } else if (wobble_step == 1 && elapsed >= 400) { + // Move forward to the stop position + pos_fixed = ((uint32_t)stop_pos) << 16; + state[CUR_POS_IDX] = pos_fixed; + state[WOBBLE_STEP_IDX] = 2; + state[WOBBLE_TIME_IDX] = now; + } else if (wobble_step == 2 && elapsed >= 300) { + // Wobble complete, enter stopped phase + phase = 3; + state[PHASE_IDX] = 3; + state[STOP_TIME_IDX] = now; + } + } + + // Update position (phases 0 and 1 only) + if (phase == 0 || phase == 1) { + pos_fixed += velocity; + state[CUR_POS_IDX] = pos_fixed; + } + + // Draw LED for all phases + uint16_t pos = (pos_fixed >> 16) % SEGLEN; + + uint8_t spinnerSize = map(SEGMENT.custom1, 0, 255, 1, 10); + + // Calculate color once per spinner block (based on strip number, not position) + uint8_t hue; + if (SEGMENT.check2) { + // Each spinner block gets its own color based on strip number + uint16_t numSpinners = max(1U, (strips + spinnerSize - 1) / spinnerSize); + hue = (uint32_t)(255) * (stripNr / spinnerSize) / numSpinners; + } else { + // Color changes with position + hue = (SEGENV.aux1 * pos) >> 8; + } + + uint32_t color = ColorFromPaletteWLED(SEGPALETTE, hue, 255, LINEARBLEND); + + // Draw the spinner with configurable size (1-10 LEDs) + for (int8_t x = 0; x < spinnerSize; x++) { + for (uint8_t y = 0; y < spinnerSize; y++) { + uint16_t drawPos = (pos + y) % SEGLEN; + int16_t drawStrip = stripNr + x; + + // Wrap horizontally if needed, or skip if out of bounds + if (drawStrip >= 0 && drawStrip < strips) { + SEGMENT.setPixelColor(indexToVStrip(drawPos, drawStrip), color); + } + } + } + } + }; + + for (unsigned stripNr = 0; stripNr < strips; stripNr++) { + // Only run on strips that are multiples of spinnerSize to avoid overlap + uint8_t spinnerSize = map(SEGMENT.custom1, 0, 255, 1, 10); + if (stripNr % spinnerSize == 0) { + virtualStrip::runStrip(stripNr, &state[stripNr * stateVarsPerStrip], settingsChanged, allReadyToRestart, strips); + } + } +} +static const char _data_FX_MODE_SPINNINGWHEEL[] PROGMEM = "Spinning Wheel@Speed (0=random),Slowdown (0=random),Spinner size,,Spin delay,Spin me!,Color per block,Sync restart;!,!;!;;m12=1,c1=1,c3=8,o1=1,o3=1"; + + /* / Lava Lamp 2D effect * Uses particles to simulate rising blobs of "lava" or wax @@ -1023,6 +1269,7 @@ class UserFxUsermod : public Usermod { public: void setup() override { strip.addEffect(255, &mode_diffusionfire, _data_FX_MODE_DIFFUSIONFIRE); + strip.addEffect(255, &mode_spinning_wheel, _data_FX_MODE_SPINNINGWHEEL); strip.addEffect(255, &mode_2D_lavalamp, _data_FX_MODE_2D_LAVALAMP); strip.addEffect(255, &mode_2D_magma, _data_FX_MODE_2D_MAGMA); strip.addEffect(255, &mode_ants, _data_FX_MODE_ANTS); From e4cd730654a842321e21667da9d75400b4c96db4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20M=C3=B6hle?= <91616163+softhack007@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:07:19 +0100 Subject: [PATCH 132/164] Info page: Total LEDs, GitHub repo, minor re-styling (#5418) * Info page updates and minor re-styling * added GitHub repo (link) * added Total LEDs * removed lwip major version on esp32 * two horizontal lines for better readability * add rel="noopener noreferrer" for improved security * When using target="_blank", it's a security best practice to include rel="noopener noreferrer" to prevent the new page from accessing window.opener. --- wled00/data/index.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/wled00/data/index.js b/wled00/data/index.js index a267bd051f..a3d7d6f036 100644 --- a/wled00/data/index.js +++ b/wled00/data/index.js @@ -707,13 +707,17 @@ ${inforow("Uptime",getRuntimeStr(i.uptime))} ${inforow("Time",i.time)} ${inforow("Free heap",(i.freeheap/1024).toFixed(1)," kB")} ${i.psram?inforow("Free PSRAM",(i.psram/1024).toFixed(1)," kB"):""} + +${i.leds.count?inforow("Total LEDs",i.leds.count):""} ${inforow("Estimated current",pwru)} ${inforow("Average FPS",i.leds.fps)} + ${inforow("MAC address",i.mac)} ${inforow("CPU clock",i.clock," MHz")} ${inforow("Flash size",i.flash," MB")} ${inforow("Filesystem",i.fs.u + "/" + i.fs.t + " kB (" +Math.round(i.fs.u*100/i.fs.t) + "%)")} -${inforow("Environment",i.arch + " " + i.core + " (" + i.lwip + ")")} +${inforow("Environment",i.arch + " " + i.core + ( i.lwip ? " (" + i.lwip + ")" : ""))} +${i.repo?inforow("GitHub","" + i.repo + ""):""}



`; gId('kv').innerHTML = cn; // update all sliders in Info From f816b5fa9898bbcced0da9eddb14405188ceaeb8 Mon Sep 17 00:00:00 2001 From: Frank <91616163+softhack007@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:00:20 +0100 Subject: [PATCH 133/164] bugfix #5295 - change remaining references to strip.paletteBlend into paletteBlend these were leftover after refactoring paletteBlend into a global variable. see #5295 for details --- usermods/pixels_dice_tray/led_effects.h | 2 +- usermods/user_fx/user_fx.cpp | 4 ++-- wled00/FX.cpp | 6 +++--- wled00/FX.h | 3 +-- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/usermods/pixels_dice_tray/led_effects.h b/usermods/pixels_dice_tray/led_effects.h index 373f8e8bc5..542d86c29b 100644 --- a/usermods/pixels_dice_tray/led_effects.h +++ b/usermods/pixels_dice_tray/led_effects.h @@ -40,7 +40,7 @@ static pixels::RollEvent GetLastRollForSegment() { * Alternating pixels running function (copied static function). */ // paletteBlend: 0 - wrap when moving, 1 - always wrap, 2 - never wrap, 3 - none (undefined) -#define PALETTE_SOLID_WRAP (strip.paletteBlend == 1 || strip.paletteBlend == 3) +#define PALETTE_SOLID_WRAP (paletteBlend == 1 || paletteBlend == 3) static void running_copy(uint32_t color1, uint32_t color2, bool theatre = false) { int width = (theatre ? 3 : 1) + (SEGMENT.intensity >> 4); // window uint32_t cycleTime = 50 + (255 - SEGMENT.speed); diff --git a/usermods/user_fx/user_fx.cpp b/usermods/user_fx/user_fx.cpp index e01c319fd8..2258b8ad4f 100644 --- a/usermods/user_fx/user_fx.cpp +++ b/usermods/user_fx/user_fx.cpp @@ -3,7 +3,7 @@ // for information how FX metadata strings work see https://kno.wled.ge/interfaces/json-api/#effect-metadata // paletteBlend: 0 - wrap when moving, 1 - always wrap, 2 - never wrap, 3 - none (undefined) -#define PALETTE_SOLID_WRAP (strip.paletteBlend == 1 || strip.paletteBlend == 3) +#define PALETTE_SOLID_WRAP (paletteBlend == 1 || paletteBlend == 3) #define indexToVStrip(index, stripNr) ((index) | (int((stripNr)+1)<<16)) @@ -874,7 +874,7 @@ static void handleBoundary(Ant& ant, float& position, bool gatherFood, bool atSt // Helper function to calculate ant color static uint32_t getAntColor(int antIndex, int numAnts, bool usePalette) { if (usePalette) - return SEGMENT.color_from_palette(antIndex * 255 / numAnts, false, (strip.paletteBlend == 1 || strip.paletteBlend == 3), 255); + return SEGMENT.color_from_palette(antIndex * 255 / numAnts, false, (paletteBlend == 1 || paletteBlend == 3), 255); // Alternate between two colors for default palette return (antIndex % 3 == 1) ? SEGCOLOR(0) : SEGCOLOR(2); } diff --git a/wled00/FX.cpp b/wled00/FX.cpp index a6caa471f8..0b5ecdda94 100644 --- a/wled00/FX.cpp +++ b/wled00/FX.cpp @@ -65,8 +65,8 @@ #define IBN 5100 // paletteBlend: 0 - wrap when moving, 1 - always wrap, 2 - never wrap, 3 - none (undefined) -#define PALETTE_SOLID_WRAP (strip.paletteBlend == 1 || strip.paletteBlend == 3) -#define PALETTE_MOVING_WRAP !(strip.paletteBlend == 2 || (strip.paletteBlend == 0 && SEGMENT.speed == 0)) +#define PALETTE_SOLID_WRAP (paletteBlend == 1 || paletteBlend == 3) +#define PALETTE_MOVING_WRAP !(paletteBlend == 2 || (paletteBlend == 0 && SEGMENT.speed == 0)) #define indexToVStrip(index, stripNr) ((index) | (int((stripNr)+1)<<16)) @@ -3398,7 +3398,7 @@ void mode_glitter() counter = counter >> 8; } - bool noWrap = (strip.paletteBlend == 2 || (strip.paletteBlend == 0 && SEGMENT.speed == 0)); + bool noWrap = (paletteBlend == 2 || (paletteBlend == 0 && SEGMENT.speed == 0)); for (unsigned i = 0; i < SEGLEN; i++) { unsigned colorIndex = (i * 255 / SEGLEN) - counter; if (noWrap) colorIndex = map(colorIndex, 0, 255, 0, 240); //cut off blend at palette "end" diff --git a/wled00/FX.h b/wled00/FX.h index 0919f8e8dc..ce7a222237 100644 --- a/wled00/FX.h +++ b/wled00/FX.h @@ -834,7 +834,6 @@ class WS2812FX { public: WS2812FX() : - paletteBlend(0), now(millis()), timebase(0), isMatrix(false), @@ -936,7 +935,7 @@ class WS2812FX { inline bool isSuspended() const { return _suspend; } // returns true if strip.service() execution is suspended inline bool needsUpdate() const { return _triggered; } // returns true if strip received a trigger() request - uint8_t paletteBlend; + // uint8_t paletteBlend; // obsolete - use global paletteBlend instead of strip.paletteBlend uint8_t getActiveSegmentsNum() const; uint8_t getFirstSelectedSegId() const; uint8_t getLastActiveSegmentId() const; From 64f3aa96dd5eb54eff3f0fbdb81cf24519d0980e Mon Sep 17 00:00:00 2001 From: Frank <91616163+softhack007@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:01:43 +0100 Subject: [PATCH 134/164] replace depricated ADC constant according to espressif docs: `ADC_ATTEN_DB_12` is deprecated, it behaves the same as `ADC_ATTEN_DB_12` --- usermods/audioreactive/audio_source.h | 2 +- wled00/util.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/usermods/audioreactive/audio_source.h b/usermods/audioreactive/audio_source.h index 423ac2abbb..6f7f2ff93f 100644 --- a/usermods/audioreactive/audio_source.h +++ b/usermods/audioreactive/audio_source.h @@ -624,7 +624,7 @@ class I2SAdcSource : public I2SSource { } // see example in https://github.com/espressif/arduino-esp32/blob/master/libraries/ESP32/examples/I2S/HiFreq_ADC/HiFreq_ADC.ino - adc1_config_channel_atten(adc1_channel_t(channel), ADC_ATTEN_DB_11); // configure ADC input amplification + adc1_config_channel_atten(adc1_channel_t(channel), ADC_ATTEN_DB_12); // configure ADC input amplification #if defined(I2S_GRAB_ADC1_COMPLETELY) // according to docs from espressif, the ADC needs to be started explicitly diff --git a/wled00/util.cpp b/wled00/util.cpp index c41cf22884..c6fbb280b4 100644 --- a/wled00/util.cpp +++ b/wled00/util.cpp @@ -1180,7 +1180,7 @@ String generateDeviceFingerprint() { #else constexpr auto myBIT_WIDTH = ADC_WIDTH_BIT_12; #endif - esp_adc_cal_characterize(ADC_UNIT_1, ADC_ATTEN_DB_11, myBIT_WIDTH, 1100, &ch); + esp_adc_cal_characterize(ADC_UNIT_1, ADC_ATTEN_DB_12, myBIT_WIDTH, 1100, &ch); fp[0] ^= ch.coeff_a; fp[1] ^= ch.coeff_b; if (ch.low_curve) { From 3244d0f5935d892b942ce90998f35bfa932f1808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20M=C3=B6hle?= <91616163+softhack007@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:29:07 +0100 Subject: [PATCH 135/164] Clarify handling of generated html_*.h files Updated instructions regarding generated html_*.h files to clarify that they should not be committed, and they don't need to be re-generated in any PR. --- .github/copilot-instructions.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bc1f9761a9..26cb1ec7a7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -85,7 +85,7 @@ wled00/ # Main firmware source (C++) │ ├── settings*.htm # Settings pages │ └── *.js/*.css # Frontend resources ├── *.cpp/*.h # Firmware source files - └── html_*.h # Generated embedded web files (DO NOT EDIT) + └── html_*.h # Auto-generated embedded web files (DO NOT EDIT, DO NOT COMMIT) tools/ # Build tools (Node.js) ├── cdata.js # Web UI build script └── cdata-test.js # Test suite @@ -149,9 +149,11 @@ package.json # Node.js dependencies and scripts ## Important Notes - **DO NOT edit `wled00/html_*.h` files** - they are auto-generated -- **Always commit both source files AND generated html_*.h files** +- **Always commit source files** +- **do not commit generated html_*.h files** - **Web UI must be built before firmware compilation** - **Test web interface manually after any web UI changes** +- When reviewing a PR, the PR author does not need to update/commit generated html_*.h files - these files will be auto-generated when building the firmware binary. - **Use VS Code with PlatformIO extension for best development experience** - **Hardware builds require appropriate ESP32/ESP8266 development board** From 19c178d0f997268a2d86366ba01d998fe94e9ce3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20M=C3=B6hle?= <91616163+softhack007@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:36:26 +0100 Subject: [PATCH 136/164] Update development workflow instructions Clarify development workflow instructions and emphasize that the changes apply to agent mode only. --- .github/copilot-instructions.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 26cb1ec7a7..d8f73f54b1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -101,7 +101,7 @@ package.json # Node.js dependencies and scripts - `wled00/wled.h` - Main firmware configuration - `platformio.ini` - Hardware build targets and settings -### Development Workflow +### Development Workflow (applies to agent mode only) 1. **For web UI changes**: - Edit files in `wled00/data/` - Run `npm run build` to regenerate headers @@ -148,10 +148,10 @@ package.json # Node.js dependencies and scripts ## Important Notes -- **DO NOT edit `wled00/html_*.h` files** - they are auto-generated - **Always commit source files** +- **Web UI re-built is part of the platformio firmware compilation** - **do not commit generated html_*.h files** -- **Web UI must be built before firmware compilation** +- **DO NOT edit `wled00/html_*.h` files** - they are auto-generated - **Test web interface manually after any web UI changes** - When reviewing a PR, the PR author does not need to update/commit generated html_*.h files - these files will be auto-generated when building the firmware binary. - **Use VS Code with PlatformIO extension for best development experience** From 34722aa3710f95c86d12282bff20bdce4216bab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20M=C3=B6hle?= <91616163+softhack007@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:41:26 +0100 Subject: [PATCH 137/164] Update instructions for editing html_*.h files Clarified instructions regarding editing auto-generated HTML files. --- .github/copilot-instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d8f73f54b1..e3e11e10f2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -151,7 +151,7 @@ package.json # Node.js dependencies and scripts - **Always commit source files** - **Web UI re-built is part of the platformio firmware compilation** - **do not commit generated html_*.h files** -- **DO NOT edit `wled00/html_*.h` files** - they are auto-generated +- **DO NOT edit `wled00/html_*.h` files** - they are auto-generated. If needed, modify Web UI files in wled00/. - **Test web interface manually after any web UI changes** - When reviewing a PR, the PR author does not need to update/commit generated html_*.h files - these files will be auto-generated when building the firmware binary. - **Use VS Code with PlatformIO extension for best development experience** From b57d51ef193ddbc758d0a64037c6c1bf92f64dd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20M=C3=B6hle?= <91616163+softhack007@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:42:30 +0100 Subject: [PATCH 138/164] correction --- .github/copilot-instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e3e11e10f2..454e4d8404 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -151,7 +151,7 @@ package.json # Node.js dependencies and scripts - **Always commit source files** - **Web UI re-built is part of the platformio firmware compilation** - **do not commit generated html_*.h files** -- **DO NOT edit `wled00/html_*.h` files** - they are auto-generated. If needed, modify Web UI files in wled00/. +- **DO NOT edit `wled00/html_*.h` files** - they are auto-generated. If needed, modify Web UI files in `wled00/data/`. - **Test web interface manually after any web UI changes** - When reviewing a PR, the PR author does not need to update/commit generated html_*.h files - these files will be auto-generated when building the firmware binary. - **Use VS Code with PlatformIO extension for best development experience** From a63a307c6e8a29b5bd2da314d4ff126895442ad9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20M=C3=B6hle?= <91616163+softhack007@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:40:42 +0100 Subject: [PATCH 139/164] Update PR review instructions for generated html files Clarified instructions for PR authors regarding generated files and updating Web UI files. --- .github/copilot-instructions.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 454e4d8404..bdcbf99fe0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -153,7 +153,8 @@ package.json # Node.js dependencies and scripts - **do not commit generated html_*.h files** - **DO NOT edit `wled00/html_*.h` files** - they are auto-generated. If needed, modify Web UI files in `wled00/data/`. - **Test web interface manually after any web UI changes** -- When reviewing a PR, the PR author does not need to update/commit generated html_*.h files - these files will be auto-generated when building the firmware binary. +- When reviewing a PR: the PR author does not need to update/commit generated html_*.h files - these files will be auto-generated when building the firmware binary. +- If updating Web UI files in `wled00/data/`, make use of common functions availeable in `wled00/data/common.js` where possible. - **Use VS Code with PlatformIO extension for best development experience** - **Hardware builds require appropriate ESP32/ESP8266 development board** From 6f030e540f5f3ac3a9ab5d350cc2da1ef8a8d198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20M=C3=B6hle?= <91616163+softhack007@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:44:35 +0100 Subject: [PATCH 140/164] OTA update page restyling, automatically set download URL based on info.repo (#5419) * use same style as other settings pages * Hide "back" button while update is in progress * set "download latest binary" URL and badge based on info.repo; directly link to "latest" release * correct bad name of "Security & Updates" page * ensure that "update bootloader" section get hidden when not supported --- wled00/data/settings_sec.htm | 4 +-- wled00/data/update.htm | 55 ++++++++++++++++++++++++++---------- wled00/json.cpp | 2 +- 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/wled00/data/settings_sec.htm b/wled00/data/settings_sec.htm index cca6e000ad..b52e9a15e0 100644 --- a/wled00/data/settings_sec.htm +++ b/wled00/data/settings_sec.htm @@ -3,7 +3,7 @@ - Misc Settings + Security & Update Setup @@ -179,7 +182,11 @@

Wired DMX Input Pins

DMX TX: DI
DMX Enable: RE+DE
DMX Port:
-
+ +

This firmware build does not include DMX Input support.
diff --git a/wled00/set.cpp b/wled00/set.cpp index 3c6c72b7b3..a1e29840cc 100644 --- a/wled00/set.cpp +++ b/wled00/set.cpp @@ -473,7 +473,9 @@ void handleSettingsSet(AsyncWebServerRequest *request, byte subPage) arlsDisableGammaCorrection = request->hasArg(F("RG")); t = request->arg(F("WO")).toInt(); if (t >= -255 && t <= 255) arlsOffset = t; - +#ifdef WLED_ENABLE_DMX + dmxOutputPin = request->arg(F("IDMO")).toInt(); +#endif #ifdef WLED_ENABLE_DMX_INPUT dmxInputTransmitPin = request->arg(F("IDMT")).toInt(); dmxInputReceivePin = request->arg(F("IDMR")).toInt(); diff --git a/wled00/xml.cpp b/wled00/xml.cpp index dceebbdf09..8091569797 100644 --- a/wled00/xml.cpp +++ b/wled00/xml.cpp @@ -494,6 +494,7 @@ void getSettingsJS(byte subPage, Print& settingsScript) printSetFormValue(settingsScript,PSTR("EU"),e131Universe); #ifdef WLED_ENABLE_DMX settingsScript.print(SET_F("hideNoDMXOutput();")); // hide "not compiled in" message + printSetFormValue(settingsScript,SET_F("IDMO"), dmxOutputPin); #endif #ifndef WLED_ENABLE_DMX_INPUT settingsScript.print(SET_F("hideDMXInput();")); // hide "dmx input" settings From ec6435593acf13d992382481e9a6d7be6ccde5eb Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 10 Jan 2026 15:08:17 +0000 Subject: [PATCH 154/164] remove redundant code --- wled00/src/dependencies/dmx/ESPDMX.cpp | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/wled00/src/dependencies/dmx/ESPDMX.cpp b/wled00/src/dependencies/dmx/ESPDMX.cpp index ed78bd01e3..e6c9193d30 100644 --- a/wled00/src/dependencies/dmx/ESPDMX.cpp +++ b/wled00/src/dependencies/dmx/ESPDMX.cpp @@ -27,20 +27,10 @@ void DMXESPSerial::init(int sendPin) { dmxStarted = true; } - -// Function to read DMX data -uint8_t DMXESPSerial::read(int Channel) { - if (Channel < 1) Channel = 1; - if (Channel > dmxMaxChannel) Channel = dmxMaxChannel; - return(dmxDataStore[Channel]); -} - // Function to send DMX data void DMXESPSerial::write(int Channel, uint8_t value) { if (Channel < 1) Channel = 1; if (Channel > channelSize) Channel = channelSize; - if (value < 0) value = 0; - if (value > 255) value = 255; dmxDataStore[Channel] = value; } From 02274e19da9146acb1f7a41095e0f50e8171084a Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 10 Jan 2026 15:12:00 +0000 Subject: [PATCH 155/164] register pin with PinManager --- wled00/dmx_output.cpp | 6 ++++++ wled00/pin_manager.h | 1 + 2 files changed, 7 insertions(+) diff --git a/wled00/dmx_output.cpp b/wled00/dmx_output.cpp index a51c80d66e..7b831b4963 100644 --- a/wled00/dmx_output.cpp +++ b/wled00/dmx_output.cpp @@ -68,6 +68,12 @@ void handleDMXOutput() void initDMXOutput(int outputPin) { if (outputPin < 1) return; + const bool pinAllocated = PinManager::allocatePin(outputPin, true, PinOwner::DMX_OUTPUT); + if (!pinAllocated) { + DEBUG_PRINTF("DMXOutput: Error: Failed to allocate pins for DMX_OUTPUT. Pin already in use:\n"); + DEBUG_PRINTF("In use by: %s\n", PinManager::getPinOwner(outputPin)); + return; + } dmx.init(outputPin); // set output pin and initialize DMX output } diff --git a/wled00/pin_manager.h b/wled00/pin_manager.h index 5f774bb47d..b0c6ed8056 100644 --- a/wled00/pin_manager.h +++ b/wled00/pin_manager.h @@ -48,6 +48,7 @@ enum struct PinOwner : uint8_t { HW_SPI = 0x8C, // 'SPI' == hardware (V)SPI pins (13,14&15 on ESP8266, 5,18&23 on ESP32) DMX_INPUT = 0x8D, // 'DMX_INPUT' == DMX input via serial HUB75 = 0x8E, // 'Hub75' == Hub75 driver + DMX_OUTPUT = 0x8F, // 'DMX_OUTPUT' == DMX output via serial // Use UserMod IDs from const.h here UM_Unspecified = USERMOD_ID_UNSPECIFIED, // 0x01 UM_Example = USERMOD_ID_EXAMPLE, // 0x02 // Usermod "usermod_v2_example.h" From b8fcb1c1d871695055a7de1d961c1b82e6768cef Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 10 Jan 2026 15:17:52 +0000 Subject: [PATCH 156/164] default output pin to -1 --- wled00/wled.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wled00/wled.h b/wled00/wled.h index 517457cc5e..3e73446dc8 100644 --- a/wled00/wled.h +++ b/wled00/wled.h @@ -450,7 +450,7 @@ WLED_GLOBAL bool arlsDisableGammaCorrection _INIT(true); // activate if WLED_GLOBAL bool arlsForceMaxBri _INIT(false); // enable to force max brightness if source has very dark colors that would be black #ifdef WLED_ENABLE_DMX - WLED_GLOBAL int dmxOutputPin _INIT(2); + WLED_GLOBAL int dmxOutputPin _INIT(-1); // DMX output pin (use -1 for disabled) WLED_GLOBAL uint16_t e131ProxyUniverse _INIT(0); // output this E1.31 (sACN) / ArtNet universe via MAX485 (0 = disabled) // dmx CONFIG WLED_GLOBAL byte DMXChannels _INIT(7); // number of channels per fixture From 306ba83e506465558f54383689a0ebcec7c3638f Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 10 Jan 2026 15:19:05 +0000 Subject: [PATCH 157/164] move dmx definition back to original location --- wled00/wled.h | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/wled00/wled.h b/wled00/wled.h index 3e73446dc8..784e756ccb 100644 --- a/wled00/wled.h +++ b/wled00/wled.h @@ -450,6 +450,11 @@ WLED_GLOBAL bool arlsDisableGammaCorrection _INIT(true); // activate if WLED_GLOBAL bool arlsForceMaxBri _INIT(false); // enable to force max brightness if source has very dark colors that would be black #ifdef WLED_ENABLE_DMX + #if defined(ESP8266) + WLED_GLOBAL DMXESPSerial dmx; + #else + WLED_GLOBAL DMXOutput dmx; + #endif WLED_GLOBAL int dmxOutputPin _INIT(-1); // DMX output pin (use -1 for disabled) WLED_GLOBAL uint16_t e131ProxyUniverse _INIT(0); // output this E1.31 (sACN) / ArtNet universe via MAX485 (0 = disabled) // dmx CONFIG @@ -459,11 +464,6 @@ WLED_GLOBAL bool arlsForceMaxBri _INIT(false); // enable to f WLED_GLOBAL uint16_t DMXGap _INIT(10); // gap between the fixtures. makes addressing easier because you don't have to memorize odd numbers when climbing up onto a rig. WLED_GLOBAL uint16_t DMXStart _INIT(10); // start address of the first fixture WLED_GLOBAL uint16_t DMXStartLED _INIT(0); // LED from which DMX fixtures start - #if defined(ESP8266) - WLED_GLOBAL DMXESPSerial dmx; - #else - WLED_GLOBAL DMXOutput dmx; - #endif #endif #ifdef WLED_ENABLE_DMX_INPUT WLED_GLOBAL int dmxInputTransmitPin _INIT(0); From f5522e6c6b472f23be96cb56e556fbf1e3e28d1d Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 10 Jan 2026 15:21:05 +0000 Subject: [PATCH 158/164] minor cleanup --- wled00/dmx_output.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wled00/dmx_output.cpp b/wled00/dmx_output.cpp index 7b831b4963..bb4b07b945 100644 --- a/wled00/dmx_output.cpp +++ b/wled00/dmx_output.cpp @@ -70,7 +70,7 @@ void initDMXOutput(int outputPin) { if (outputPin < 1) return; const bool pinAllocated = PinManager::allocatePin(outputPin, true, PinOwner::DMX_OUTPUT); if (!pinAllocated) { - DEBUG_PRINTF("DMXOutput: Error: Failed to allocate pins for DMX_OUTPUT. Pin already in use:\n"); + DEBUG_PRINTF("DMXOutput: Error: Failed to allocate pin for DMX_OUTPUT. Pin already in use:\n"); DEBUG_PRINTF("In use by: %s\n", PinManager::getPinOwner(outputPin)); return; } @@ -78,11 +78,11 @@ void initDMXOutput(int outputPin) { } #if !defined(ESP8266) -void DMXOutput::init(uint8_t txPin) +void DMXOutput::init(uint8_t outputPin) { dmx_config_t config = DMX_CONFIG_DEFAULT; dmx_driver_install(dmxPort, &config, DMX_INTR_FLAGS_DEFAULT); - dmx_set_pin(dmxPort, txPin, -1, -1); + dmx_set_pin(dmxPort, outputPin, -1, -1); } void DMXOutput::write(uint8_t channel, uint8_t value) { From 38330b54ae971d58c74fde64f2ee98b39fa42854 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 10 Jan 2026 15:21:58 +0000 Subject: [PATCH 159/164] minor cleanup, code style --- wled00/dmx_output.cpp | 9 +++------ wled00/dmx_output.h | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/wled00/dmx_output.cpp b/wled00/dmx_output.cpp index bb4b07b945..f1457dfb59 100644 --- a/wled00/dmx_output.cpp +++ b/wled00/dmx_output.cpp @@ -78,18 +78,15 @@ void initDMXOutput(int outputPin) { } #if !defined(ESP8266) -void DMXOutput::init(uint8_t outputPin) -{ +void DMXOutput::init(uint8_t outputPin) { dmx_config_t config = DMX_CONFIG_DEFAULT; dmx_driver_install(dmxPort, &config, DMX_INTR_FLAGS_DEFAULT); dmx_set_pin(dmxPort, outputPin, -1, -1); } -void DMXOutput::write(uint8_t channel, uint8_t value) -{ +void DMXOutput::write(uint8_t channel, uint8_t value) { dmxdata[channel] = value; } -void DMXOutput::update() -{ +void DMXOutput::update() { dmx_send(dmxPort, DMX_PACKET_SIZE); } #endif diff --git a/wled00/dmx_output.h b/wled00/dmx_output.h index 59c14084d7..e292634cb3 100644 --- a/wled00/dmx_output.h +++ b/wled00/dmx_output.h @@ -17,7 +17,7 @@ class DMXOutput { public: - void init(uint8_t txPin); + void init(uint8_t outputPin); void write(uint8_t channel, uint8_t value); void update(); private: From daa222307849c97dffd4c7c6bb35859463e1505e Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sun, 11 Jan 2026 00:15:36 +0000 Subject: [PATCH 160/164] Fix naming --- wled00/cfg.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wled00/cfg.cpp b/wled00/cfg.cpp index be5950643f..4268c70a2f 100644 --- a/wled00/cfg.cpp +++ b/wled00/cfg.cpp @@ -608,7 +608,7 @@ bool deserializeConfig(JsonObject doc, bool fromFS) { if (tdd >= 0) realtimeTimeoutMs = tdd * 100; #ifdef WLED_ENABLE_DMX - CJSON(dmxOutputPin, if_live_dmx[F("outputPin")]); + CJSON(dmxOutputPin, if_live_dmx[F("dmxOutputPin")]); #endif #ifdef WLED_ENABLE_DMX_INPUT CJSON(dmxInputTransmitPin, if_live_dmx[F("inputRxPin")]); From e28774df1f9b1864dc804d3f3d380990df8878f1 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sun, 11 Jan 2026 00:19:52 +0000 Subject: [PATCH 161/164] write data --- wled00/dmx_output.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/wled00/dmx_output.cpp b/wled00/dmx_output.cpp index f1457dfb59..2be3371ee7 100644 --- a/wled00/dmx_output.cpp +++ b/wled00/dmx_output.cpp @@ -87,6 +87,7 @@ void DMXOutput::write(uint8_t channel, uint8_t value) { dmxdata[channel] = value; } void DMXOutput::update() { + dmx_write(dmxPort, dmxdata, DMX_PACKET_SIZE); dmx_send(dmxPort, DMX_PACKET_SIZE); } #endif From c20f59dc28e57e02a6222c3331f26107261df0e8 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Fri, 20 Mar 2026 08:45:20 +0000 Subject: [PATCH 162/164] DMXInput: try to assign pins before UART port and default back to 1 if requested port is 2 on device with only 1 --- wled00/dmx_input.cpp | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/wled00/dmx_input.cpp b/wled00/dmx_input.cpp index 83ab606688..6ab1d4af33 100644 --- a/wled00/dmx_input.cpp +++ b/wled00/dmx_input.cpp @@ -134,14 +134,6 @@ void DMXInput::init(uint8_t rxPin, uint8_t txPin, uint8_t enPin, uint8_t inputPo // } #endif - if (inputPortNum <= (SOC_UART_NUM - 1) && inputPortNum > 0) { - this->inputPortNum = inputPortNum; - } - else { - DEBUG_PRINTF("DMXInput: Error: invalid inputPortNum: %d\n", inputPortNum); - return; - } - if (rxPin > 0 && enPin > 0 && txPin > 0) { const managed_pin_type pins[] = { @@ -156,6 +148,15 @@ void DMXInput::init(uint8_t rxPin, uint8_t txPin, uint8_t enPin, uint8_t inputPo DEBUG_PRINTF("en in use by: %s\n", PinManager::getPinOwner(enPin)); return; } + if (inputPortNum <= (SOC_UART_NUM - 1) && inputPortNum > 0) { + this->inputPortNum = inputPortNum; + } + else { + DEBUG_PRINTF("DMXInput: Error: invalid inputPortNum: %d, default to 1\n", inputPortNum); + this->inputPortNum = 1; + return; + } + this->rxPin = rxPin; this->txPin = txPin; From ea3aba9d22de4673890cc230b92bf20edc4055a9 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Fri, 20 Mar 2026 08:46:11 +0000 Subject: [PATCH 163/164] DMXInput: No need to swap default pin for LED when DMX not hard-coded --- wled00/const.h | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/wled00/const.h b/wled00/const.h index 95e69d855b..aac2c232a6 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -650,13 +650,8 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); // Defaults pins, type and counts to configure LED output #if defined(ESP8266) || defined(CONFIG_IDF_TARGET_ESP32C3) - #ifdef WLED_ENABLE_DMX - #define DEFAULT_LED_PIN 1 - #warning "Compiling with DMX. The default LED pin has been changed to pin 1." - #else #define DEFAULT_LED_PIN 2 // GPIO2 (D4) on Wemos D1 mini compatible boards, safe to use on any board - #endif -#else + #else #if defined(WLED_USE_ETHERNET) #define DEFAULT_LED_PIN 4 // GPIO4 seems to be a "safe bet" for all known ethernet boards (issue #5155) //#warning "Compiling with Ethernet support. The default LED pin has been changed to pin 4." From ab632e9a00036d45b9d23d091bc8b60e85fb4505 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Fri, 20 Mar 2026 08:48:57 +0000 Subject: [PATCH 164/164] DMXInput: Move allocation of pin to initDMXOutput --- wled00/dmx_output.cpp | 15 ++++++++++++--- wled00/pin_manager.h | 3 +-- wled00/wled.cpp | 3 --- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/wled00/dmx_output.cpp b/wled00/dmx_output.cpp index 2be3371ee7..8e9fc541ee 100644 --- a/wled00/dmx_output.cpp +++ b/wled00/dmx_output.cpp @@ -68,20 +68,29 @@ void handleDMXOutput() void initDMXOutput(int outputPin) { if (outputPin < 1) return; - const bool pinAllocated = PinManager::allocatePin(outputPin, true, PinOwner::DMX_OUTPUT); + const bool pinAllocated = PinManager::allocatePin(outputPin, true, PinOwner::DMX); if (!pinAllocated) { DEBUG_PRINTF("DMXOutput: Error: Failed to allocate pin for DMX_OUTPUT. Pin already in use:\n"); DEBUG_PRINTF("In use by: %s\n", PinManager::getPinOwner(outputPin)); return; } + DEBUG_PRINTF("DMXOutput: init: pin %d\n", outputPin); dmx.init(outputPin); // set output pin and initialize DMX output } #if !defined(ESP8266) void DMXOutput::init(uint8_t outputPin) { dmx_config_t config = DMX_CONFIG_DEFAULT; - dmx_driver_install(dmxPort, &config, DMX_INTR_FLAGS_DEFAULT); - dmx_set_pin(dmxPort, outputPin, -1, -1); + const bool installOk = dmx_driver_install(dmxPort, &config, DMX_INTR_FLAGS_DEFAULT); + if (!installOk) { + DEBUG_PRINTF("DMXOutput: Error: Failed to install dmx driver\n"); + return; + } + const bool setPin = dmx_set_pin(dmxPort, outputPin, -1, -1); + if (!setPin) { + DEBUG_PRINTF("DMXOutput: Error: Failed to set DMX output pin\n"); + return; + } } void DMXOutput::write(uint8_t channel, uint8_t value) { dmxdata[channel] = value; diff --git a/wled00/pin_manager.h b/wled00/pin_manager.h index b0c6ed8056..cbc47ce2cc 100644 --- a/wled00/pin_manager.h +++ b/wled00/pin_manager.h @@ -43,12 +43,11 @@ enum struct PinOwner : uint8_t { Relay = 0x87, // 'Rly' == Relay pin from configuration SPI_RAM = 0x88, // 'SpiR' == SPI RAM DebugOut = 0x89, // 'Dbg' == debug output always IO1 - DMX = 0x8A, // 'DMX' == hard-coded to IO2 + DMX = 0x8A, // 'DMX' == DMX output via serial HW_I2C = 0x8B, // 'I2C' == hardware I2C pins (4&5 on ESP8266, 21&22 on ESP32) HW_SPI = 0x8C, // 'SPI' == hardware (V)SPI pins (13,14&15 on ESP8266, 5,18&23 on ESP32) DMX_INPUT = 0x8D, // 'DMX_INPUT' == DMX input via serial HUB75 = 0x8E, // 'Hub75' == Hub75 driver - DMX_OUTPUT = 0x8F, // 'DMX_OUTPUT' == DMX output via serial // Use UserMod IDs from const.h here UM_Unspecified = USERMOD_ID_UNSPECIFIED, // 0x01 UM_Example = USERMOD_ID_EXAMPLE, // 0x02 // Usermod "usermod_v2_example.h" diff --git a/wled00/wled.cpp b/wled00/wled.cpp index f66bd974ec..7d5fea8782 100644 --- a/wled00/wled.cpp +++ b/wled00/wled.cpp @@ -443,9 +443,6 @@ void WLED::setup() #if defined(WLED_DEBUG) && !defined(WLED_DEBUG_HOST) PinManager::allocatePin(hardwareTX, true, PinOwner::DebugOut); // TX (GPIO1 on ESP32) reserved for debug output #endif -#ifdef WLED_ENABLE_DMX //reserve GPIO2 as hardcoded DMX pin - PinManager::allocatePin(2, true, PinOwner::DMX); -#endif DEBUG_PRINTF_P(PSTR("heap %u\n"), getFreeHeapSize());