Compare commits

..

17 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
196f5798a1 Implement correct metadata validation logic as requested in review comments
Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com>
2025-09-20 00:29:55 +00:00
copilot-swe-agent[bot]
614984257c Fix file reverts and implement efficient metadata validation without platform ifdefs
Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com>
2025-09-20 00:24:30 +00:00
copilot-swe-agent[bot]
6676705428 Address final review feedback: revert unnecessary files, use F-strings, add length validation, implement deferred ESP8266 validation
Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com>
2025-09-17 18:19:52 +00:00
copilot-swe-agent[bot]
71ad0d99aa Address final review feedback: improve error handling, remove debug duplication, use HTTP API for release name
Co-authored-by: willmmiles <6540455+willmmiles@users.noreply.github.com>
2025-09-17 16:51:57 +00:00
copilot-swe-agent[bot]
70e8be6b4d Address review feedback: unify structures, fix C++11 compatibility, improve user messages
Co-authored-by: willmmiles <6540455+willmmiles@users.noreply.github.com>
2025-09-17 02:53:49 +00:00
copilot-swe-agent[bot]
f706c6cbed Rename ignoreRelease to skipValidation in OTA validation system
Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com>
2025-09-14 16:16:42 +00:00
copilot-swe-agent[bot]
18dfc7081c Address review feedback: unify structures, remove code duplication, improve efficiency
Co-authored-by: willmmiles <6540455+willmmiles@users.noreply.github.com>
2025-09-14 15:05:15 +00:00
copilot-swe-agent[bot]
5c0c84eb6b Add ESP8266 support to OTA release compatibility system using .ver_number section
Co-authored-by: willmmiles <6540455+willmmiles@users.noreply.github.com>
2025-09-14 14:34:31 +00:00
copilot-swe-agent[bot]
691c058ae8 Fix runtime release name replacement - move to build-time
Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com>
2025-09-14 14:14:11 +00:00
copilot-swe-agent[bot]
7d550baf94 Replace metadata header approach with ESP-IDF custom description section
Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com>
2025-09-14 13:51:48 +00:00
copilot-swe-agent[bot]
42ff73ffe7 Implement metadata-based OTA release checking system
Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com>
2025-09-14 13:11:24 +00:00
copilot-swe-agent[bot]
2d8edfcb24 Remove build artifacts from repository
Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com>
2025-09-14 12:56:12 +00:00
copilot-swe-agent[bot]
fb077ecadc Improve OTA release checking with scoring-based candidate selection
Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com>
2025-09-14 12:55:18 +00:00
copilot-swe-agent[bot]
caf3d900fd Fix OTA release checking to use flexible string search instead of hardcoded patterns
Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com>
2025-09-14 12:50:47 +00:00
copilot-swe-agent[bot]
e920d2e101 Complete OTA release compatibility system with comprehensive testing
Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com>
2025-09-14 12:32:52 +00:00
copilot-swe-agent[bot]
54746c9730 Implement OTA release compatibility checking system
Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com>
2025-09-14 12:28:47 +00:00
copilot-swe-agent[bot]
8225a2a07c Initial plan 2025-09-14 12:13:03 +00:00
14 changed files with 462 additions and 253 deletions

3
.github/FUNDING.yml vendored
View File

@@ -1,3 +1,2 @@
github: [Aircoookie,blazoncek,DedeHai,lost-hope,willmmiles]
github: [Aircoookie,blazoncek]
custom: ['https://paypal.me/Aircoookie','https://paypal.me/blazoncek']
thanks_dev: u/gh/netmindz

View File

@@ -10,12 +10,10 @@
</p>
# Welcome to WLED! ✨
# Welcome to my project WLED! ✨
A fast and feature-rich implementation of an ESP32 and ESP8266 webserver to control NeoPixel (WS2812B, WS2811, SK6812) LEDs or also SPI based chipsets like the WS2801 and APA102!
Originally created by [Aircoookie](https://github.com/Aircoookie)
## ⚙️ Features
- WS2812FX library with more than 100 special effects
- FastLED noise effects and 50 palettes
@@ -34,7 +32,7 @@ Originally created by [Aircoookie](https://github.com/Aircoookie)
- Filesystem-based config for easier backup of presets and settings
## 💡 Supported light control interfaces
- WLED app for [Android](https://play.google.com/store/apps/details?id=ca.cgagnier.wlednativeandroid) and [iOS](https://apps.apple.com/gb/app/wled-native/id6446207239)
- WLED app for [Android](https://play.google.com/store/apps/details?id=com.aircoookie.WLED) and [iOS](https://apps.apple.com/us/app/wled/id1475695033)
- JSON and HTTP request APIs
- MQTT
- E1.31, Art-Net, DDP and TPM2.net

View File

@@ -126,20 +126,6 @@ async function minify(str, type = "plain") {
throw new Error("Unknown filter: " + type);
}
/**
* Inline-depends, minifies, gzip-compresses an HTML source and writes a C header array.
*
* Reads the HTML at sourceFile, inlines referenced resources, replaces repo/version placeholders,
* minifies the HTML, compresses it with gzip, converts the compressed bytes to a C-style hex array,
* and writes a header file to resultFile that defines:
* - const uint16_t PAGE_<page>_length = <length>;
* - const uint8_t PAGE_<page>[] PROGMEM = { ... };
*
* @param {string} sourceFile - Path to the source HTML file to inline and compress.
* @param {string} resultFile - Path where the generated C header file will be written.
* @param {string} page - Identifier used to name the generated symbols (e.g., "index", "pixart").
* @throws {Error} If inlining the HTML fails (propagates the inline error).
*/
async function writeHtmlGzipped(sourceFile, resultFile, page) {
console.info("Reading " + sourceFile);
inline.html({
@@ -157,7 +143,7 @@ async function writeHtmlGzipped(sourceFile, resultFile, page) {
console.info("Minified and compressed " + sourceFile + " from " + originalLength + " to " + result.length + " bytes");
const array = hexdump(result);
let src = singleHeader;
src += `const uint16_t PAGE_${page}_length = ${result.length};\n`;
src += `const uint16_t PAGE_${page}_L = ${result.length};\n`;
src += `const uint8_t PAGE_${page}[] PROGMEM = {\n${array}\n};\n\n`;
console.info("Writing " + resultFile);
fs.writeFileSync(resultFile, src);
@@ -258,22 +244,9 @@ if (isAlreadyBuilt("wled00/data") && process.argv[2] !== '--force' && process.ar
writeHtmlGzipped("wled00/data/index.htm", "wled00/html_ui.h", 'index');
writeHtmlGzipped("wled00/data/pixart/pixart.htm", "wled00/html_pixart.h", 'pixart');
//writeHtmlGzipped("wled00/data/cpal/cpal.htm", "wled00/html_cpal.h", 'cpal');
writeHtmlGzipped("wled00/data/cpal/cpal.htm", "wled00/html_cpal.h", 'cpal');
writeHtmlGzipped("wled00/data/pxmagic/pxmagic.htm", "wled00/html_pxmagic.h", 'pxmagic');
writeChunks(
"wled00/data/cpal",
[
{
file: "cpal.htm",
name: "PAGE_cpal",
method: "gzip",
filter: "html-minify"
}
],
"wled00/html_cpal.h"
);
writeChunks(
"wled00/data",
[

View File

@@ -11,6 +11,7 @@
*/
#include "wled.h"
#include "FXparticleSystem.h" // TODO: better define the required function (mem service) in FX.h?
#include "palettes.h"
/*
Custom per-LED mapping has moved!
@@ -199,15 +200,12 @@ void Segment::deallocateData() {
}
/**
* @brief Clear pending runtime state when a segment is marked for reset.
*
* If this segment's reset flag is set and the segment is active, zeroes the
* segment's runtime data buffer (to avoid heap fragmentation), clears the
* pixel buffer, resets timing/step/call/aux counters, clears the reset flag,
* and, when GIF playback support is enabled, ends any ongoing image playback.
*
* Note: this routine is safe to call only when no effect mode is currently
* running for the segment — effect code may access the data/pixel buffers. */
* If reset of this segment was requested, clears runtime
* settings of this segment.
* Must not be called while an effect mode function is running
* because it could access the data buffer and this method
* may free that data buffer.
*/
void Segment::resetIfRequired() {
if (!reset || !isActive()) return;
//DEBUG_PRINTF_P(PSTR("-- Segment reset: %p\n"), this);
@@ -220,31 +218,9 @@ void Segment::resetIfRequired() {
#endif
}
/**
* @brief Selects and loads a palette into the supplied CRGBPalette16.
*
* Loads a palette identified by the numeric index `pal` into `targetPalette`.
* Supported palette sources (by index):
* - 0: the effect/default palette (resolved from _default_palette)
* - 1: runtime-random palette
* - 25: palettes derived from this segment's color slots (primary/secondary/tertiary combinations)
* - fastLED and built-in gradient palettes: mapped from the next contiguous index range
* - custom palettes: addressed from the high end (255, 254, ...) and stored in customPalettes
*
* If `pal` is outside the valid range for built-in/gradient/fastled indices it will be treated as 0
* (the default palette). When a custom palette index is selected it is loaded from customPalettes.
*
* @param targetPalette Palette object to populate (returned by reference).
* @param pal Numeric palette index selecting the source and layout (see summary above).
* @return CRGBPalette16& Reference to the populated `targetPalette`.
*/
CRGBPalette16 &Segment::loadPalette(CRGBPalette16 &targetPalette, uint8_t pal) {
// there is one randomy generated palette (1) followed by 4 palettes created from segment colors (2-5)
// those are followed by 7 fastled palettes (6-12) and 59 gradient palettes (13-71)
// then come the custom palettes (255,254,...) growing downwards from 255 (255 being 1st custom palette)
// palette 0 is a varying palette depending on effect and may be replaced by segment's color if so
// instructed in color_from_palette()
if (pal > FIXED_PALETTE_COUNT && pal < 255-customPalettes.size()+1) pal = 0; // out of bounds palette
if (pal < 245 && pal > GRADIENT_PALETTE_COUNT+13) pal = 0;
if (pal > 245 && (customPalettes.size() == 0 || 255U-pal > customPalettes.size()-1)) pal = 0;
//default palette. Differs depending on effect
if (pal == 0) pal = _default_palette; // _default_palette is set in setMode()
switch (pal) {
@@ -280,13 +256,13 @@ CRGBPalette16 &Segment::loadPalette(CRGBPalette16 &targetPalette, uint8_t pal) {
}
break;}
default: //progmem palettes
if (pal > 255 - customPalettes.size()) {
if (pal>245) {
targetPalette = customPalettes[255-pal]; // we checked bounds above
} else if (pal < DYNAMIC_PALETTE_COUNT+FASTLED_PALETTE_COUNT+1) { // palette 6 - 12, fastled palettes
targetPalette = *fastledPalettes[pal-DYNAMIC_PALETTE_COUNT-1];
} else if (pal < 13) { // palette 6 - 12, fastled palettes
targetPalette = *fastledPalettes[pal-6];
} else {
byte tcp[72];
memcpy_P(tcp, (byte*)pgm_read_dword(&(gGradientPalettes[pal-(DYNAMIC_PALETTE_COUNT+FASTLED_PALETTE_COUNT)-1])), 72);
memcpy_P(tcp, (byte*)pgm_read_dword(&(gGradientPalettes[pal-13])), 72);
targetPalette.loadDynamicGradientPalette(tcp);
}
break;
@@ -553,24 +529,6 @@ Segment &Segment::setOption(uint8_t n, bool val) {
return *this;
}
/**
* @brief Set the effect (mode) for this segment.
*
* Sets the segment's mode to the first non-reserved effect at or after the
* provided index, optionally loading the effect's default parameters. If the
* new mode differs from the current one, a transition is started (a segment
* copy is created), the mode-specific defaults and palette are applied when
* requested, and the segment is marked for reset and state broadcast.
*
* @param fx Index of the desired effect/mode. If this index points to a
* reserved mode the next non-reserved mode is used. If the index is
* out of range the solid mode (index 0) is selected.
* @param loadDefaults When true, extract and apply the effect's default
* parameters (speed, intensity, custom values, mapping
* flags, sound simulation, mirror/reverse flags, etc.)
* and set the palette default when present.
* @return Segment& Reference to this segment (allows chaining).
*/
Segment &Segment::setMode(uint8_t fx, bool loadDefaults) {
// skip reserved
while (fx < strip.getModeCount() && strncmp_P("RSVD", strip.getModeData(fx), 4) == 0) fx++;
@@ -607,21 +565,9 @@ Segment &Segment::setMode(uint8_t fx, bool loadDefaults) {
return *this;
}
/**
* @brief Set the segment's palette by index.
*
* Validates the supplied palette index and, if it differs from the current
* palette, begins a palette transition and marks the segment's state as
* changed so the new palette is propagated to clients/hardware.
*
* If the provided index is outside the range of built-in or custom palettes,
* it is normalized to 0.
*
* @param pal Palette index (may be adjusted to a valid value).
* @return Segment& Reference to this segment (for chaining).
*/
Segment &Segment::setPalette(uint8_t pal) {
if (pal <= 255-customPalettes.size() && pal > FIXED_PALETTE_COUNT) pal = 0; // not built in palette or custom palette
if (pal < 245 && pal > GRADIENT_PALETTE_COUNT+13) pal = 0; // built in palettes
if (pal > 245 && (customPalettes.size() == 0 || 255U-pal > customPalettes.size()-1)) pal = 0; // custom palettes
if (pal != palette) {
//DEBUG_PRINTF_P(PSTR("- Starting palette transition: %d\n"), pal);
startTransition(strip.getTransition(), blendingStyle != BLEND_STYLE_FADE); // start transition prior to change (no need to copy segment)

View File

@@ -248,29 +248,11 @@ CRGBPalette16 generateRandomPalette() // generate fully random palette
CHSV(hw_random8(), hw_random8(160, 255), hw_random8(128, 255)));
}
/**
* @brief Loads user-defined color palettes from filesystem into runtime storage.
*
* Scans for files named "/palette0.json", "/palette1.json", ... up to
* WLED_MAX_CUSTOM_PALETTES and builds dynamic gradient palettes from any valid
* JSON found. Existing in-memory custom palettes are cleared before loading.
*
* Supported JSON formats for the "palette" array:
* - Pairs of [index, hexString] (e.g. [0, "FF0000", 128, "00FF00", ...]) where
* each pair is an index (0255) followed by an RRGGBB or RRGGBBWW hex color.
* - Quads of [index, R, G, B] (e.g. [0, 255, 0, 0, 128, 0, 255, 0, ...]) where
* each group of four values is an index (0255) followed by red/green/blue bytes.
*
* For each palette file the function converts the supplied entries into a
* temporary gradient table (supporting up to 18 color stops) and appends the
* resulting CRGBPalette16 to customPalettes. The loader stops at the first
* missing palette file.
*/
void loadCustomPalettes() {
byte tcp[72]; //support gradient palettes with up to 18 entries
CRGBPalette16 targetPalette;
customPalettes.clear(); // start fresh
for (int index = 0; index < WLED_MAX_CUSTOM_PALETTES; index++) {
for (int index = 0; index<10; index++) {
char fileName[32];
sprintf_P(fileName, PSTR("/palette%d.json"), index);

View File

@@ -123,21 +123,7 @@ CRGBPalette16 generateHarmonicRandomPalette(const CRGBPalette16 &basepalette);
CRGBPalette16 generateRandomPalette();
void loadCustomPalettes();
extern std::vector<CRGBPalette16> customPalettes;
/**
* Get the total number of available palettes (built-in fixed palettes plus user-defined custom palettes).
*
* @return Total palette count (FIXED_PALETTE_COUNT + customPalettes.size()).
*/
inline size_t getPaletteCount() { return FIXED_PALETTE_COUNT + customPalettes.size(); }
/**
* Pack an RGBW byte array into a 32-bit color value.
*
* The input must point to at least four bytes in order: R, G, B, W.
* Returns a uint32_t with layout 0xWWRRGGBB (white in the highest byte).
*
* @param rgbw Pointer to 4 bytes: {R, G, B, W}.
* @return 32-bit packed color in 0xWWRRGGBB format.
*/
inline size_t getPaletteCount() { return 13 + GRADIENT_PALETTE_COUNT + customPalettes.size(); }
inline uint32_t colorFromRgbw(byte* rgbw) { return uint32_t((byte(rgbw[3]) << 24) | (byte(rgbw[0]) << 16) | (byte(rgbw[1]) << 8) | (byte(rgbw[2]))); }
void hsv2rgb(const CHSV32& hsv, uint32_t& rgb);
void colorHStoRGB(uint16_t hue, byte sat, byte* rgb);
@@ -155,8 +141,4 @@ void setRandomColor(byte* rgb);
[[gnu::hot, gnu::pure]] uint32_t color_fade(uint32_t c1, uint8_t amount, bool video = false);
// palettes
extern const TProgmemRGBPalette16* const fastledPalettes[];
extern const uint8_t* const gGradientPalettes[];
#endif

View File

@@ -1025,23 +1025,6 @@ function redrawPalPrev()
});
}
/**
* Generate a CSS background rule showing a horizontal gradient preview for a palette.
*
* Uses the global `palettesData` array for palette entries. Each palette entry may be:
* - an array [posByte, r, g, b] where `posByte` is 0..255 mapped to 0..100%,
* - the literal 'r' to insert a random RGB color, or
* - a reference value whose second character is treated as a 1-based index into the DOM color-slot list (element with id "csl") and reads its `data-r`, `data-g`, `data-b` attributes.
*
* Special cases:
* - If the palette contains a single color, the function duplicates it to produce a two-color gradient.
* - If `palettesData` is not defined the function returns undefined.
* - If the requested palette id is not found the function returns the string `'display: none'`.
*
* @param {number|string} id - Palette identifier (index or key) into `palettesData`.
* @return {string|undefined} CSS declaration for a left-to-right linear-gradient (e.g. `"background: linear-gradient(to right, ...);"`),
* `'display: none'` when the palette is missing, or `undefined` if `palettesData` is not available.
*/
function genPalPrevCss(id)
{
if (!palettesData) return;
@@ -1059,7 +1042,8 @@ function genPalPrevCss(id)
}
var gradient = [];
paletteData.forEach((e,j) => {
for (let j = 0; j < paletteData.length; j++) {
const e = paletteData[j];
let r, g, b;
let index = false;
if (Array.isArray(e)) {
@@ -1081,8 +1065,9 @@ function genPalPrevCss(id)
if (index === false) {
index = Math.round(j / paletteData.length * 100);
}
gradient.push(`rgb(${r},${g},${b}) ${index}%`);
});
}
return `background: linear-gradient(to right,${gradient.join()});`;
}
@@ -3101,29 +3086,15 @@ let iSlide = 0, x0 = null, scrollS = 0, locked = false;
function unify(e) { return e.changedTouches ? e.changedTouches[0] : e; }
/**
* Return true if any class name in the provided list starts with "Iro".
*
* @param {Iterable<string>} classList - An iterable of class name strings (e.g., Element.classList or an array).
* @returns {boolean} True when at least one class name begins with "Iro", otherwise false.
*/
function hasIroClass(classList)
{
let found = false;
classList.forEach((e)=>{ if (e.startsWith('Iro')) found = true; });
return found;
for (var i = 0; i < classList.length; i++) {
var element = classList[i];
if (element.startsWith('Iro')) return true;
}
/**
* Handle touch/drag start to lock page scrolling and initiate horizontal slide gestures.
*
* If the app is in PC mode or simplified UI, or the event target (or its parent) is marked
* to skip sliding (has class `noslide` or contains iro-related classes), the function returns
* without side effects. Otherwise it records the initial pointer X position and current
* scrollTop into globals used by the gesture handler, sets the global `locked` flag, and
* toggles the container's `smooth` class accordingly.
*
* @param {Event} e - Pointer/touch event from rangetouch (the originating target is inspected).
*/
return false;
}
//required by rangetouch.js
function lock(e)
{
if (pcMode || simplifiedUI) return;

View File

@@ -17,7 +17,27 @@
}
window.open(getURL("/update?revert"),"_self");
}
function GetV() {/*injected values here*/}
function GetV() {
// Fetch device info via JSON API instead of compiling it in
fetch('/json/info')
.then(response => response.json())
.then(data => {
if (data.release) {
var releaseSpan = document.querySelector('.release-name');
if (releaseSpan) {
releaseSpan.textContent = data.release;
}
}
})
.catch(error => {
console.log('Could not fetch device info:', error);
// Fallback to compiled-in value if API call fails
var releaseSpan = document.querySelector('.release-name');
if (releaseSpan && releaseSpan.textContent === 'Loading...') {
releaseSpan.textContent = 'Unknown';
}
});
}
</script>
<style>
@import url("style.css");
@@ -28,10 +48,13 @@
<h2>WLED Software Update</h2>
<form method='POST' action='./update' id='upd' enctype='multipart/form-data' onsubmit="toggle('upd')">
Installed version: <span class="sip">WLED ##VERSION##</span><br>
Release: <span class="sip release-name">Loading...</span><br>
Download the latest binary: <a href="https://github.com/wled-dev/WLED/releases" target="_blank"
style="vertical-align: text-bottom; display: inline-flex;">
<img src="https://img.shields.io/github/release/wled-dev/WLED.svg?style=flat-square"></a><br>
<input type='file' name='update' required><br> <!--should have accept='.bin', but it prevents file upload from android app-->
<input type='checkbox' name='skipValidation' id='skipValidation'>
<label for='skipValidation'>Ignore firmware validation</label><br>
<button type="submit">Update!</button><br>
<hr class="sml">
<button id="rev" type="button" onclick="cR()">Revert update</button><br>

View File

@@ -1,5 +1,7 @@
#include "wled.h"
#include "palettes.h"
#define JSON_PATH_STATE 1
#define JSON_PATH_INFO 2
#define JSON_PATH_STATE_INFO 3
@@ -687,20 +689,6 @@ void serializeState(JsonObject root, bool forPreset, bool includeBri, bool segme
}
}
/**
* @brief Populate a JSON object with device and runtime information.
*
* Fills the provided JsonObject with system, hardware, network, and LED subsystem
* metadata used by the JSON API (version, build, LED counts and capabilities,
* palettes and modes counts, WiFi and filesystem stats, uptime, time, and usermod
* additions).
*
* The function writes several nested objects and arrays (for example "leds",
* "wifi", "fs", "maps") and a set of top-level fields consumed by clients.
*
* @param root JsonObject to populate. Must be a valid writable JSON object;
* the function will create nested objects/arrays inside it.
*/
void serializeInfo(JsonObject root)
{
root[F("ver")] = versionString;
@@ -783,7 +771,6 @@ void serializeInfo(JsonObject root)
root[F("fxcount")] = strip.getModeCount();
root[F("palcount")] = getPaletteCount();
root[F("cpalcount")] = customPalettes.size(); //number of custom palettes
root[F("cpalmax")] = WLED_MAX_CUSTOM_PALETTES; // maximum number of custom palettes
JsonArray ledmaps = root.createNestedArray(F("maps"));
for (size_t i=0; i<WLED_MAX_LEDMAPS; i++) {

View File

@@ -0,0 +1,111 @@
#include "ota_release_check.h"
#include "wled.h"
#ifdef ESP32
#include <esp_app_format.h>
#include <esp_ota_ops.h>
#endif
bool extractWledCustomDesc(const uint8_t* binaryData, size_t dataSize, wled_custom_desc_t* extractedDesc) {
if (!binaryData || !extractedDesc || dataSize < 64) {
return false;
}
// Search in first 8KB only. This range was chosen because:
// - ESP32 .rodata.wled_desc sections appear early in the binary (typically within first 2-4KB)
// - ESP8266 .ver_number sections also appear early (typically within first 1-2KB)
// - 8KB provides ample coverage for metadata discovery while minimizing processing time
// - Larger firmware files (>1MB) would take significantly longer to process with full search
// - Analysis of typical WLED binary layouts shows metadata appears well within this range
const size_t search_limit = min(dataSize, (size_t)8192);
for (size_t offset = 0; offset <= search_limit - sizeof(wled_custom_desc_t); offset++) {
const wled_custom_desc_t* custom_desc = (const wled_custom_desc_t*)(binaryData + offset);
// Check for magic number
if (custom_desc->magic == WLED_CUSTOM_DESC_MAGIC) {
// Found potential match, validate version
if (custom_desc->version != WLED_CUSTOM_DESC_VERSION) {
DEBUG_PRINTF_P(PSTR("Found WLED structure at offset %u but version mismatch: %u\n"),
offset, custom_desc->version);
continue;
}
// Validate hash using runtime function
uint32_t expected_hash = djb2_hash_runtime(custom_desc->release_name);
if (custom_desc->crc32 != expected_hash) {
DEBUG_PRINTF_P(PSTR("Found WLED structure at offset %u but hash mismatch\n"), offset);
continue;
}
// Valid structure found - copy entire structure
memcpy(extractedDesc, custom_desc, sizeof(wled_custom_desc_t));
DEBUG_PRINTF_P(PSTR("Extracted WLED structure at offset %u: '%s'\n"),
offset, extractedDesc->release_name);
return true;
}
}
DEBUG_PRINTLN(F("No WLED custom description found in binary"));
return false;
}
bool validateReleaseCompatibility(const char* extractedRelease) {
if (!extractedRelease) {
return false;
}
// Ensure extractedRelease is properly null terminated (guard against fixed-length buffer issues)
char safeRelease[WLED_RELEASE_NAME_MAX_LEN];
strncpy(safeRelease, extractedRelease, WLED_RELEASE_NAME_MAX_LEN - 1);
safeRelease[WLED_RELEASE_NAME_MAX_LEN - 1] = '\0';
if (strlen(safeRelease) == 0) {
return false;
}
// Simple string comparison - releases must match exactly
bool match = strcmp(releaseString, safeRelease) == 0;
DEBUG_PRINTF_P(PSTR("Release compatibility check: current='%s', uploaded='%s', match=%s\n"),
releaseString, safeRelease, match ? "YES" : "NO");
return match;
}
bool shouldAllowOTA(const uint8_t* binaryData, size_t dataSize, char* errorMessage, size_t errorMessageLen) {
// Clear error message
if (errorMessage && errorMessageLen > 0) {
errorMessage[0] = '\0';
}
// Ensure our custom description structure is referenced (prevents optimization)
const wled_custom_desc_t* local_desc = getWledCustomDesc();
(void)local_desc; // Suppress unused variable warning
// Try to extract WLED structure directly from binary data
wled_custom_desc_t extractedDesc;
bool hasCustomDesc = extractWledCustomDesc(binaryData, dataSize, &extractedDesc);
if (!hasCustomDesc) {
// No custom description - this could be a legacy binary
if (errorMessage && errorMessageLen > 0) {
strncpy_P(errorMessage, PSTR("This firmware file is missing compatibility metadata. Enable 'Ignore firmware validation' to proceed anyway."), errorMessageLen - 1);
errorMessage[errorMessageLen - 1] = '\0';
}
return false;
}
// Validate compatibility using extracted release name
if (!validateReleaseCompatibility(extractedDesc.release_name)) {
if (errorMessage && errorMessageLen > 0) {
snprintf_P(errorMessage, errorMessageLen, PSTR("Firmware compatibility mismatch: current='%s', uploaded='%s'. Enable 'Ignore firmware validation' to proceed anyway."),
releaseString, extractedDesc.release_name);
errorMessage[errorMessageLen - 1] = '\0'; // Ensure null termination
}
return false;
}
return true;
}

View File

@@ -0,0 +1,90 @@
#ifndef WLED_OTA_RELEASE_CHECK_H
#define WLED_OTA_RELEASE_CHECK_H
/*
* OTA Release Compatibility Checking using ESP-IDF Custom Description Section
* Functions to extract and validate release names from uploaded binary files using embedded metadata
*/
#include <Arduino.h>
#ifdef ESP32
#include <esp_app_format.h>
#endif
#define WLED_CUSTOM_DESC_MAGIC 0x57535453 // "WSTS" (WLED System Tag Structure)
#define WLED_CUSTOM_DESC_VERSION 1
#define WLED_RELEASE_NAME_MAX_LEN 48
// Platform-specific metadata offset in binary file
#ifdef ESP32
#define METADATA_OFFSET 0 // ESP32: metadata appears at beginning
#elif defined(ESP8266)
#define METADATA_OFFSET 0x1000 // ESP8266: metadata appears at 4KB offset
#endif
/**
* DJB2 hash function (C++11 compatible constexpr)
* Used for compile-time hash computation of release names
*/
constexpr uint32_t djb2_hash_constexpr(const char* str, uint32_t hash = 5381) {
return (*str == '\0') ? hash : djb2_hash_constexpr(str + 1, ((hash << 5) + hash) + *str);
}
/**
* Runtime DJB2 hash function for validation
*/
inline uint32_t djb2_hash_runtime(const char* str) {
uint32_t hash = 5381;
while (*str) {
hash = ((hash << 5) + hash) + *str++;
}
return hash;
}
/**
* WLED Custom Description Structure
* This structure is embedded in platform-specific sections at a fixed offset
* in ESP32/ESP8266 binaries, allowing extraction without modifying the binary format
*/
typedef struct {
uint32_t magic; // Magic number to identify WLED custom description
uint32_t version; // Structure version for future compatibility
char release_name[WLED_RELEASE_NAME_MAX_LEN]; // Release name (null-terminated)
uint32_t crc32; // CRC32 of the above fields for integrity check
} __attribute__((packed)) wled_custom_desc_t;
/**
* Extract WLED custom description structure from binary
* @param binaryData Pointer to binary file data
* @param dataSize Size of binary data in bytes
* @param extractedDesc Buffer to store extracted custom description structure
* @return true if structure was found and extracted, false otherwise
*/
bool extractWledCustomDesc(const uint8_t* binaryData, size_t dataSize, wled_custom_desc_t* extractedDesc);
/**
* Validate if extracted release name matches current release
* @param extractedRelease Release name from uploaded binary
* @return true if releases match (OTA should proceed), false if they don't match
*/
bool validateReleaseCompatibility(const char* extractedRelease);
/**
* 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
*/
bool shouldAllowOTA(const uint8_t* binaryData, size_t dataSize, char* errorMessage, size_t errorMessageLen);
/**
* Get pointer to the embedded custom description structure
* This ensures the structure is referenced and not optimized out
* @return pointer to the custom description structure
*/
const wled_custom_desc_t* getWledCustomDesc();
#endif // WLED_OTA_RELEASE_CHECK_H

View File

@@ -210,24 +210,7 @@ void releaseJSONBufferLock()
// extracts effect mode (or palette) name from names serialized string
/**
* @brief Extracts the display name for a mode or palette into a caller-provided buffer.
*
* When src is JSON_mode_names or nullptr, the name is read from the built-in mode data
* (strip.getModeData). When src is JSON_palette_names and the mode index refers to a
* custom palette (mode > 255 - customPalettes.size()), a formatted "~ Custom N ~"
* name is written. Otherwise, the function parses a PROGMEM JSON-like string pointed
* to by src to locate the mode's quoted name (handles commas and quoted fields) and
* stops if an SR-extension marker '@' is encountered for that mode.
*
* The function always NUL-terminates dest and will truncate the name to fit maxLen.
*
* @param mode Index of the mode or palette to extract.
* @param src PROGMEM string source to parse, or JSON_mode_names / JSON_palette_names / nullptr.
* @param dest Caller-provided buffer to receive the NUL-terminated name (must be large enough).
* @param maxLen Maximum number of bytes to write into dest (including the terminating NUL).
* @return uint8_t Length of the resulting string written into dest (excluding the terminating NUL).
*/
// caller must provide large enough buffer for name (including SR extensions)!
uint8_t extractModeName(uint8_t mode, const char *src, char *dest, uint8_t maxLen)
{
if (src == JSON_mode_names || src == nullptr) {
@@ -247,7 +230,7 @@ uint8_t extractModeName(uint8_t mode, const char *src, char *dest, uint8_t maxLe
} else return 0;
}
if (src == JSON_palette_names && mode > 255-customPalettes.size()) {
if (src == JSON_palette_names && mode > (GRADIENT_PALETTE_COUNT + 13)) {
snprintf_P(dest, maxLen, PSTR("~ Custom %d ~"), 255-mode);
dest[maxLen-1] = '\0';
return strlen(dest);

View File

@@ -0,0 +1,29 @@
#include "ota_release_check.h"
#include "wled.h"
// Platform-specific section definition
#ifdef ESP32
#define WLED_CUSTOM_DESC_SECTION ".rodata.wled_desc"
#elif defined(ESP8266)
#define WLED_CUSTOM_DESC_SECTION ".ver_number"
#endif
// Single structure definition for both platforms
const wled_custom_desc_t __attribute__((section(WLED_CUSTOM_DESC_SECTION))) wled_custom_description = {
WLED_CUSTOM_DESC_MAGIC, // magic
WLED_CUSTOM_DESC_VERSION, // version
WLED_RELEASE_NAME, // release_name
djb2_hash_constexpr(WLED_RELEASE_NAME) // crc32 - computed at compile time
};
// Compile-time validation that release name doesn't exceed maximum length
static_assert(sizeof(WLED_RELEASE_NAME) <= WLED_RELEASE_NAME_MAX_LEN,
"WLED_RELEASE_NAME exceeds maximum length of WLED_RELEASE_NAME_MAX_LEN characters");
// Single reference to ensure it's not optimized away
const wled_custom_desc_t* __attribute__((used)) wled_custom_desc_ref = &wled_custom_description;
// Function to ensure the structure is referenced by code
const wled_custom_desc_t* getWledCustomDesc() {
return &wled_custom_description;
}

View File

@@ -6,6 +6,7 @@
#else
#include <Update.h>
#endif
#include "ota_release_check.h"
#endif
#include "html_ui.h"
#include "html_settings.h"
@@ -244,24 +245,6 @@ static bool captivePortal(AsyncWebServerRequest *request)
return false;
}
/**
* @brief Initialize and configure the HTTP server routes and handlers.
*
* Registers CORS/default headers and all web endpoints used by the device web UI and API,
* including static content routes, settings UI, JSON API (/json), file upload (/upload),
* OTA update endpoints (/update), optional pages (DMX, PixArt, PxMagic, CPAL, live views),
* WebSocket attachment, captive portal handling and a NotFound handler that routes API calls
* or serves a 404 page. Also installs the filesystem editor route (or an Access Denied
* stub) via createEditHandler and attaches an AsyncJsonWebHandler for JSON POSTs.
*
* Side effects:
* - Adds default HTTP headers (CORS).
* - Registers many server routes and their callbacks with global state handlers.
* - May set flags such as doReboot and configNeedsWrite from request handlers.
* - Enforces PIN/OTA lock and subnet restrictions inside sensitive endpoints (OTA, settings, cfg).
*
* This function does not return a value.
*/
void initServer()
{
//CORS compatiblity
@@ -442,23 +425,176 @@ void initServer()
return;
}
if (!correctPIN || otaLock) return;
// Static variables to track release check status across chunks
static bool releaseCheckPassed = false;
static bool releaseCheckPending = true;
static size_t totalBytesReceived = 0;
static uint8_t* metadataBuffer = nullptr;
static size_t metadataBufferUsed = 0;
if(!index){
DEBUG_PRINTLN(F("OTA Update Start"));
// Reset validation state for new update
releaseCheckPassed = false;
releaseCheckPending = true;
totalBytesReceived = 0;
// Free any existing metadata buffer
if (metadataBuffer) {
free(metadataBuffer);
metadataBuffer = nullptr;
metadataBufferUsed = 0;
}
// Check if user wants to skip validation
bool skipValidation = request->hasParam("skipValidation", true);
// If user chose to skip validation, proceed without compatibility check
if (skipValidation) {
DEBUG_PRINTLN(F("OTA validation skipped by user"));
releaseCheckPassed = true;
releaseCheckPending = false;
}
DEBUG_PRINTLN(F("Release check passed, starting OTA update"));
#if WLED_WATCHDOG_TIMEOUT > 0
WLED::instance().disableWatchdog();
#endif
UsermodManager::onUpdateBegin(true); // notify usermods that update is about to begin (some may require task de-init)
lastEditTime = millis(); // make sure PIN does not lock during update
// Start the actual OTA update
strip.suspend();
backupConfig(); // backup current config in case the update ends badly
strip.resetSegments(); // free as much memory as you can
#ifdef ESP8266
Update.runAsync(true);
#endif
Update.begin((ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000);
// Begin update with the firmware size from content length
size_t updateSize = request->contentLength() > 0 ? request->contentLength() : ((ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000);
if (!Update.begin(updateSize)) {
DEBUG_PRINTF_P(PSTR("OTA Failed to begin: %s\n"), Update.getErrorString().c_str());
strip.resume();
UsermodManager::onUpdateBegin(false);
#if WLED_WATCHDOG_TIMEOUT > 0
WLED::instance().enableWatchdog();
#endif
#ifdef ESP32
request->send(500, FPSTR(CONTENT_TYPE_PLAIN), String("Update.begin failed: ") + Update.errorString());
#else
request->send(500, FPSTR(CONTENT_TYPE_PLAIN), String("Update.begin failed: ") + Update.getErrorString());
#endif
return;
}
if(!Update.hasError()) Update.write(data, len);
}
// Track total bytes received across all chunks
size_t chunkStartOffset = totalBytesReceived;
totalBytesReceived += len;
// Perform validation if we haven't done it yet and we have reached the metadata offset
if (releaseCheckPending && totalBytesReceived > METADATA_OFFSET) {
if (chunkStartOffset <= METADATA_OFFSET) {
// Current chunk contains the metadata offset
size_t offsetInChunk = METADATA_OFFSET - chunkStartOffset;
size_t availableDataAfterOffset = len - offsetInChunk;
if (availableDataAfterOffset >= sizeof(wled_custom_desc_t)) {
// We have enough data in this chunk to validate immediately
char errorMessage[128];
releaseCheckPassed = shouldAllowOTA(data + offsetInChunk, availableDataAfterOffset,
errorMessage, sizeof(errorMessage));
releaseCheckPending = false;
if (!releaseCheckPassed) {
DEBUG_PRINTF_P(PSTR("OTA declined: %s\n"), errorMessage);
request->send(400, FPSTR(CONTENT_TYPE_PLAIN), errorMessage);
return;
} else {
DEBUG_PRINTLN(F("OTA allowed: Release compatibility check passed"));
}
} else {
// Not enough data in this chunk, buffer it for the next chunk
metadataBuffer = (uint8_t*)malloc(len - offsetInChunk);
if (!metadataBuffer) {
DEBUG_PRINTLN(F("OTA failed: Could not allocate metadata buffer"));
request->send(500, FPSTR(CONTENT_TYPE_PLAIN), F("Out of memory for validation"));
return;
}
memcpy(metadataBuffer, data + offsetInChunk, len - offsetInChunk);
metadataBufferUsed = len - offsetInChunk;
DEBUG_PRINTF_P(PSTR("Buffered %u bytes of metadata for next chunk\n"), metadataBufferUsed);
}
} else if (metadataBuffer && metadataBufferUsed > 0) {
// We have buffered metadata from previous chunk, combine with current chunk
size_t totalMetadataSize = metadataBufferUsed + len;
if (totalMetadataSize >= sizeof(wled_custom_desc_t)) {
// Create combined buffer for validation
uint8_t* combinedBuffer = (uint8_t*)malloc(totalMetadataSize);
if (!combinedBuffer) {
DEBUG_PRINTLN(F("OTA failed: Could not allocate combined buffer"));
request->send(500, FPSTR(CONTENT_TYPE_PLAIN), F("Out of memory for validation"));
return;
}
memcpy(combinedBuffer, metadataBuffer, metadataBufferUsed);
memcpy(combinedBuffer + metadataBufferUsed, data, len);
char errorMessage[128];
releaseCheckPassed = shouldAllowOTA(combinedBuffer, totalMetadataSize,
errorMessage, sizeof(errorMessage));
releaseCheckPending = false;
// Clean up buffers
free(combinedBuffer);
free(metadataBuffer);
metadataBuffer = nullptr;
metadataBufferUsed = 0;
if (!releaseCheckPassed) {
DEBUG_PRINTF_P(PSTR("OTA declined: %s\n"), errorMessage);
request->send(400, FPSTR(CONTENT_TYPE_PLAIN), errorMessage);
return;
} else {
DEBUG_PRINTLN(F("OTA allowed: Release compatibility check passed"));
}
}
}
}
// Write chunk data to OTA update (only if release check passed or still pending)
if ((releaseCheckPassed || releaseCheckPending) && !Update.hasError()) {
if (Update.write(data, len) != len) {
DEBUG_PRINTF_P(PSTR("OTA write failed on chunk %zu: %s\n"), index, Update.getErrorString().c_str());
}
}
if(isFinal){
DEBUG_PRINTLN(F("OTA Update End"));
// Clean up metadata buffer if still allocated
if (metadataBuffer) {
free(metadataBuffer);
metadataBuffer = nullptr;
metadataBufferUsed = 0;
}
// Check if validation was still pending (shouldn't happen normally)
if (releaseCheckPending) {
DEBUG_PRINTLN(F("OTA failed: Validation never completed"));
request->send(400, FPSTR(CONTENT_TYPE_PLAIN), F("Firmware validation incomplete"));
return;
}
if (releaseCheckPassed) {
if(Update.end(true)){
DEBUG_PRINTLN(F("Update Success"));
} else {
@@ -470,6 +606,7 @@ void initServer()
#endif
}
}
}
});
#else
const auto notSupported = [](AsyncWebServerRequest *request){
@@ -488,31 +625,29 @@ void initServer()
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
if (captivePortal(request)) return;
if (!showWelcomePage || request->hasArg(F("sliders"))) {
handleStaticContent(request, F("/index.htm"), 200, FPSTR(CONTENT_TYPE_HTML), PAGE_index, PAGE_index_length);
handleStaticContent(request, F("/index.htm"), 200, FPSTR(CONTENT_TYPE_HTML), PAGE_index, PAGE_index_L);
} else {
serveSettings(request);
}
});
#ifndef WLED_DISABLE_2D
#ifdef WLED_ENABLE_PIXART
static const char _pixart_htm[] PROGMEM = "/pixart.htm";
server.on(_pixart_htm, HTTP_GET, [](AsyncWebServerRequest *request) {
handleStaticContent(request, FPSTR(_pixart_htm), 200, FPSTR(CONTENT_TYPE_HTML), PAGE_pixart, PAGE_pixart_length);
handleStaticContent(request, FPSTR(_pixart_htm), 200, FPSTR(CONTENT_TYPE_HTML), PAGE_pixart, PAGE_pixart_L);
});
#endif
#ifndef WLED_DISABLE_PXMAGIC
static const char _pxmagic_htm[] PROGMEM = "/pxmagic.htm";
server.on(_pxmagic_htm, HTTP_GET, [](AsyncWebServerRequest *request) {
handleStaticContent(request, FPSTR(_pxmagic_htm), 200, FPSTR(CONTENT_TYPE_HTML), PAGE_pxmagic, PAGE_pxmagic_length);
handleStaticContent(request, FPSTR(_pxmagic_htm), 200, FPSTR(CONTENT_TYPE_HTML), PAGE_pxmagic, PAGE_pxmagic_L);
});
#endif
#endif
static const char _cpal_htm[] PROGMEM = "/cpal.htm";
server.on(_cpal_htm, HTTP_GET, [](AsyncWebServerRequest *request) {
handleStaticContent(request, FPSTR(_cpal_htm), 200, FPSTR(CONTENT_TYPE_HTML), PAGE_cpal, PAGE_cpal_length);
handleStaticContent(request, FPSTR(_cpal_htm), 200, FPSTR(CONTENT_TYPE_HTML), PAGE_cpal, PAGE_cpal_L);
});
#ifdef WLED_ENABLE_WEBSOCKETS