Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
30fbf55
Initial plan
Copilot Oct 5, 2025
93908e7
Add ESP32 bootloader upgrade functionality with JSON API support
Copilot Oct 5, 2025
a18a661
Add esp_flash.h include for ESP32 bootloader flash operations
Copilot Oct 5, 2025
c3e1890
Improve bootloader flash implementation with proper erase and write o…
Copilot Oct 5, 2025
042ed39
Fix: Remove static keyword from getBootloaderSHA256Hex() to match dec…
Copilot Oct 5, 2025
f5f3fc3
Fix: Cast min() arguments to size_t for ESP32-C3 compatibility
Copilot Oct 5, 2025
d79b023
Fix: Move bootloader JavaScript to separate script block to avoid Get…
Copilot Oct 5, 2025
62c78fc
Refactor bootloader upload to buffer entire file in RAM before flash …
Copilot Nov 8, 2025
f4b98c4
Add ESP-IDF bootloader image validation before flash operations
Copilot Nov 8, 2025
da1d53c
Enhance bootloader validation to match esp_image_verify() checks comp…
Copilot Nov 8, 2025
76bb3f7
Initial plan
Copilot Oct 5, 2025
5f33c69
Fix copilot-instructions.md to require mandatory build validation
Copilot Oct 5, 2025
e2b8f91
Reference Hardware Compilation section for common environments list
Copilot Oct 5, 2025
2acf731
Update platformio.ini
wled-compile Oct 5, 2025
f0182eb
safety check for bootloop action tracker: bring it back on track if o…
DedeHai Oct 9, 2025
186c4a7
fix low brightness gradient "jumpyness"
DedeHai Oct 12, 2025
1dd338c
Fix blank area issue with Twinkle (#5005)
benjamw Oct 17, 2025
4973fd5
Adding DDP over WS, moving duplicate WS-connection to common.js (#4997)
DedeHai Oct 21, 2025
eb80fdf
Game of Life Rework
Brandon502 Oct 9, 2025
0391488
Game of Life Optimizations
Brandon502 Oct 15, 2025
7e1992f
adding function to check if a backup exists
DedeHai Oct 19, 2025
46ff438
check config backup as welcome page gate
DedeHai Oct 19, 2025
0f06535
Include audioreactive for hub75 examples
netmindz Sep 25, 2025
8e00e71
Include audioreactive for hub75 examples - MOONHUB audio
netmindz Sep 26, 2025
5fb3713
Include esp32 debug build
netmindz Nov 7, 2025
acd415c
fix release name for esp32
netmindz Nov 7, 2025
c623b82
improve esp32_dev env
netmindz Nov 8, 2025
1afd72c
Initial plan
Copilot Oct 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion wled00/data/update.htm
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,26 @@
}
function GetV() {/*injected values here*/}
</script>
<script>
var isESP32 = false;
function checkESP32() {
fetch(getURL('/json/info')).then(r=>r.json()).then(d=>{
isESP32 = d.arch && d.arch.startsWith('esp32');
if (isESP32) {
gId('bootloader-section').style.display = 'block';
if (d.bootloaderSHA256) {
gId('bootloader-hash').innerText = 'Current bootloader SHA256: ' + d.bootloaderSHA256;
}
}
}).catch(e=>console.error(e));
}
</script>
<style>
@import url("style.css");
</style>
</head>

<body onload="GetV()">
<body onload="GetV(); checkESP32();">
<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>
Expand All @@ -37,6 +51,16 @@ <h2>WLED Software Update</h2>
<button id="rev" type="button" onclick="cR()">Revert update</button><br>
<button type="button" onclick="B()">Back</button>
</form>
<div id="bootloader-section" style="display:none;">
<hr class="sml">
<h2>ESP32 Bootloader Update</h2>
<div id="bootloader-hash" class="sip" style="margin-bottom:8px;"></div>
<form method='POST' action='./updatebootloader' id='bootupd' enctype='multipart/form-data' onsubmit="toggle('bootupd')">
<b>Warning:</b> Only upload verified ESP32 bootloader files!<br>
<input type='file' name='update' required><br>
<button type="submit">Update Bootloader</button>
</form>
</div>
<div id="Noupd" class="hide"><b>Updating...</b><br>Please do not close or refresh the page :)</div>
</body>
</html>
3 changes: 3 additions & 0 deletions wled00/fcn_declare.h
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,9 @@ void serveMessage(AsyncWebServerRequest* request, uint16_t code, const String& h
void serveJsonError(AsyncWebServerRequest* request, uint16_t code, uint16_t error);
void serveSettings(AsyncWebServerRequest* request, bool post = false);
void serveSettingsJS(AsyncWebServerRequest* request);
#if defined(ARDUINO_ARCH_ESP32) && !defined(WLED_DISABLE_OTA)
String getBootloaderSHA256Hex();
#endif

//ws.cpp
void handleWs();
Expand Down
3 changes: 3 additions & 0 deletions wled00/json.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,9 @@ void serializeInfo(JsonObject root)
root[F("resetReason1")] = (int)rtc_get_reset_reason(1);
#endif
root[F("lwip")] = 0; //deprecated
#ifndef WLED_DISABLE_OTA
root[F("bootloaderSHA256")] = getBootloaderSHA256Hex();
#endif
#else
root[F("arch")] = "esp8266";
root[F("core")] = ESP.getCoreVersion();
Expand Down
169 changes: 169 additions & 0 deletions wled00/wled_server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@
#endif
#include "html_cpal.h"

#if defined(ARDUINO_ARCH_ESP32) && !defined(WLED_DISABLE_OTA)
#include <esp_partition.h>
#include <esp_ota_ops.h>
#include <esp_flash.h>
#include <bootloader_common.h>
#include <mbedtls/sha256.h>
#endif

// define flash strings once (saves flash memory)
static const char s_redirecting[] PROGMEM = "Redirecting...";
static const char s_content_enc[] PROGMEM = "Content-Encoding";
Expand All @@ -28,6 +36,12 @@ static const char s_notimplemented[] PROGMEM = "Not implemented";
static const char s_accessdenied[] PROGMEM = "Access Denied";
static const char _common_js[] PROGMEM = "/common.js";

#if defined(ARDUINO_ARCH_ESP32) && !defined(WLED_DISABLE_OTA)
// Cache for bootloader SHA256 digest
static uint8_t bootloaderSHA256[32];
static bool bootloaderSHA256Cached = false;
#endif

//Is this an IP?
static bool isIp(const String &str) {
for (size_t i = 0; i < str.length(); i++) {
Expand Down Expand Up @@ -176,6 +190,61 @@ static String msgProcessor(const String& var)
return String();
}

#if defined(ARDUINO_ARCH_ESP32) && !defined(WLED_DISABLE_OTA)
// Calculate and cache the bootloader SHA256 digest
static void calculateBootloaderSHA256() {
if (bootloaderSHA256Cached) return;

// Bootloader is at fixed offset 0x1000 (4KB) and is typically 32KB
const uint32_t bootloaderOffset = 0x1000;
const uint32_t bootloaderSize = 0x8000; // 32KB, typical bootloader size

mbedtls_sha256_context ctx;
mbedtls_sha256_init(&ctx);
mbedtls_sha256_starts(&ctx, 0); // 0 = SHA256 (not SHA224)

const size_t chunkSize = 256;
uint8_t buffer[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, bootloaderOffset + offset, readSize) == ESP_OK) {
mbedtls_sha256_update(&ctx, buffer, readSize);
}
}

mbedtls_sha256_finish(&ctx, bootloaderSHA256);
mbedtls_sha256_free(&ctx);
bootloaderSHA256Cached = true;
}

// Get bootloader SHA256 as hex string
String getBootloaderSHA256Hex() {
calculateBootloaderSHA256();

char hex[65];
for (int i = 0; i < 32; i++) {
sprintf(hex + (i * 2), "%02x", bootloaderSHA256[i]);
}
hex[64] = '\0';
return String(hex);
}

// Verify if uploaded data is a valid ESP32 bootloader
static bool isValidBootloader(const uint8_t* data, size_t len) {
if (len < 32) return false;

// Check for ESP32 bootloader magic byte (0xE9)
if (data[0] != 0xE9) return false;

// Additional validation: check segment count is reasonable
uint8_t segmentCount = data[1];
if (segmentCount > 16) return false;

return true;
}
#endif

static void handleUpload(AsyncWebServerRequest *request, const String& filename, size_t index, uint8_t *data, size_t len, bool isFinal) {
if (!correctPIN) {
if (isFinal) request->send(401, FPSTR(CONTENT_TYPE_PLAIN), FPSTR(s_unlock_cfg));
Expand Down Expand Up @@ -466,6 +535,106 @@ void initServer()
server.on(_update, HTTP_POST, notSupported, [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool isFinal){});
#endif

#if defined(ARDUINO_ARCH_ESP32) && !defined(WLED_DISABLE_OTA)
// ESP32 bootloader update endpoint
server.on(F("/updatebootloader"), HTTP_POST, [](AsyncWebServerRequest *request){
if (!correctPIN) {
serveSettings(request, true); // handle PIN page POST request
return;
}
if (otaLock) {
serveMessage(request, 401, FPSTR(s_accessdenied), FPSTR(s_unlock_ota), 254);
return;
}
if (Update.hasError()) {
serveMessage(request, 500, F("Bootloader update failed!"), F("Please check your file and retry!"), 254);
} else {
serveMessage(request, 200, F("Bootloader updated successfully!"), FPSTR(s_rebooting), 131);
doReboot = true;
}
},[](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool isFinal){
IPAddress client = request->client()->remoteIP();
if (((otaSameSubnet && !inSameSubnet(client)) && !strlen(settingsPIN)) || (!otaSameSubnet && !inLocalSubnet(client))) {
DEBUG_PRINTLN(F("Attempted bootloader update from different/non-local subnet!"));
request->send(401, FPSTR(CONTENT_TYPE_PLAIN), FPSTR(s_accessdenied));
return;
}
if (!correctPIN || otaLock) return;

static size_t bootloaderBytesWritten = 0;
static bool bootloaderErased = false;
const uint32_t bootloaderOffset = 0x1000;
const uint32_t maxBootloaderSize = 0x8000; // 32KB max

if (!index) {
DEBUG_PRINTLN(F("Bootloader Update Start"));
#if WLED_WATCHDOG_TIMEOUT > 0
WLED::instance().disableWatchdog();
#endif
lastEditTime = millis(); // make sure PIN does not lock during update
strip.suspend();
strip.resetSegments();
bootloaderBytesWritten = 0;
bootloaderErased = false;

// Verify bootloader magic on first chunk
if (!isValidBootloader(data, len)) {
DEBUG_PRINTLN(F("Invalid bootloader file!"));
strip.resume();
#if WLED_WATCHDOG_TIMEOUT > 0
WLED::instance().enableWatchdog();
#endif
Update.abort();
return;
}

// Erase bootloader region (32KB)
DEBUG_PRINTLN(F("Erasing bootloader region..."));
esp_err_t err = esp_flash_erase_region(NULL, bootloaderOffset, maxBootloaderSize);
Copy link
Member

Choose a reason for hiding this comment

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

It's unsafe to erase anything while the upload is incomplete. The bootloader should fit in a buffer in RAM; ensure the data is fully is ready before making any irreversible changes to the flash.

Copy link
Member

Choose a reason for hiding this comment

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

I share the concerns that @willmmiles mentions. The bootloader should be buffered first, then validated (correct architecture, version, hash, etc). If everything is good, try to disable any other active task, and write the bootloader to flash.
Without these safety steps, an interrupted or corrupted upload will brick the esp32, and a reflash via USB will be needed to bring it back to life.

Copy link
Member

Choose a reason for hiding this comment

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

Is there a standard part of the IDF that can perform this validation @copilot ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, the ESP-IDF provides validation functions through the bootloader support component. Key functions include:

  • esp_image_verify() - Verifies bootloader image integrity (checks magic bytes, segments, checksums)
  • bootloader_common_check_chip_validity() - Validates the image is for the correct chip architecture
  • esp_image_get_metadata() - Extracts and validates image metadata including segments

The proper approach would be:

  1. Buffer the entire bootloader upload (max 32KB) in RAM
  2. Use esp_image_verify() to validate before any flash operations
  3. Only proceed with esp_flash_erase_region() and esp_flash_write() after successful validation

This would require refactoring the current implementation to accumulate all data first rather than streaming writes.

Copy link
Member

Choose a reason for hiding this comment

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

Please apply these changes @copilot

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Implemented in commit 62c78fc (buffering) and the latest commit. The bootloader upload now includes:

Buffering (commit 62c78fc):

  • Allocates 32KB RAM buffer
  • Buffers all data before any flash operations

Validation (latest commit):

  • Added esp_image_format.h include for ESP-IDF image validation
  • Implemented verifyBootloaderImage() function that validates:
    • Magic byte (0xE9)
    • Segment count (≀16)
    • Chip ID in bootloader header
    • Overall image structure
  • Flash operations only proceed after complete buffering AND validation

This prevents device bricking from incomplete, corrupted, or structurally invalid uploads. Both ESP32 and ESP32-C3 builds compile successfully.

if (err != ESP_OK) {
DEBUG_PRINTF_P(PSTR("Bootloader erase error: %d\n"), err);
strip.resume();
#if WLED_WATCHDOG_TIMEOUT > 0
WLED::instance().enableWatchdog();
#endif
Update.abort();
return;
}
bootloaderErased = true;
}

// Write data to flash at bootloader offset
if (bootloaderErased && bootloaderBytesWritten + len <= maxBootloaderSize) {
esp_err_t err = esp_flash_write(NULL, data, bootloaderOffset + bootloaderBytesWritten, len);
if (err != ESP_OK) {
DEBUG_PRINTF_P(PSTR("Bootloader flash write error: %d\n"), err);
Update.abort();
} else {
bootloaderBytesWritten += len;
}
} else if (!bootloaderErased) {
DEBUG_PRINTLN(F("Bootloader region not erased!"));
Update.abort();
} else {
DEBUG_PRINTLN(F("Bootloader size exceeds maximum!"));
Update.abort();
}

if (isFinal) {
if (!Update.hasError() && bootloaderBytesWritten > 0) {
DEBUG_PRINTF_P(PSTR("Bootloader Update Success - %d bytes written\n"), bootloaderBytesWritten);
bootloaderSHA256Cached = false; // Invalidate cached bootloader hash
} else {
DEBUG_PRINTLN(F("Bootloader Update Failed"));
strip.resume();
#if WLED_WATCHDOG_TIMEOUT > 0
WLED::instance().enableWatchdog();
#endif
}
}
});
#endif

#ifdef WLED_ENABLE_DMX
server.on(F("/dmxmap"), HTTP_GET, [](AsyncWebServerRequest *request){
request->send_P(200, FPSTR(CONTENT_TYPE_HTML), PAGE_dmxmap, dmxProcessor);
Expand Down