diff --git a/wled00/ota_update.cpp b/wled00/ota_update.cpp index f108c06d..eb70e72e 100644 --- a/wled00/ota_update.cpp +++ b/wled00/ota_update.cpp @@ -1,5 +1,3 @@ -#if defined(ARDUINO_ARCH_ESP32) && !defined(WLED_DISABLE_OTA) // WLEDMM we only want getBootloaderSHA256Hex() - #include "ota_update.h" #include "wled.h" @@ -7,6 +5,14 @@ #include #include #include + +#if !(ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 0, 0)) +// Shim for V3 IDF. We only access the default flash anyways, so we can strip off the first argument. +#define esp_flash_read(chip, buffer, address, length) spi_flash_read(address, buffer, length) +#define esp_flash_erase_region(chip, start, length) spi_flash_erase_range(start, length) +#define esp_flash_write(chip, buffer, address, length) spi_flash_write(address, buffer, length) +#endif + #endif // Platform-specific metadata locations @@ -19,9 +25,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 @@ -31,11 +39,12 @@ 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 -#endif +// ------------------------------------- +#if 0 // WLEDMM unused -#if 0 // WLEDMM not needed - we only want getBootloaderSHA256Hex(); /** * Check if OTA should be allowed based on release compatibility using custom description @@ -80,7 +89,7 @@ struct UpdateContext { // Buffer to hold block data across posts, if needed std::vector releaseMetadataBuffer; -} +}; static void endOTA(AsyncWebServerRequest *request) { @@ -111,7 +120,7 @@ static void endOTA(AsyncWebServerRequest *request) { } delete context; } -} +}; static bool beginOTA(AsyncWebServerRequest *request, UpdateContext* context) { @@ -276,55 +285,499 @@ void handleOTAData(AsyncWebServerRequest *request, size_t index, uint8_t *data, } } -#endif + +#endif // WLEDMM unused +// ------------------------------------- + #if defined(ARDUINO_ARCH_ESP32) && !defined(WLED_DISABLE_OTA) -static String bootloaderSHA256HexCache = ""; -// Calculate and cache the bootloader SHA256 digest as hex string +// 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: + + 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; + } + + esp_image_header_t header; + memcpy(&header, data, sizeof(esp_image_header_t)); + + if (header.segment_count == 0) { + error = true; + return false; + } + + imageSize = sizeof(esp_image_header_t); + 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); + } + + 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(reinterpret_cast(&segmentHeader) + segmentHeaderBytes, 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); + + // 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 + + // 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; + } + } + + // 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); + } + + return !error; + } + + bool hasError() const { return error; } + bool isSizeKnown() const { return !error && imageSize != 0 && segmentsLeft == 0; } + size_t totalSize() const { + if (!isSizeKnown()) return 0; + return imageSize; + } + +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]; + +/** + * Calculate and cache the bootloader SHA256 digest + * 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() { - if (!bootloaderSHA256HexCache.isEmpty()) return; - // Calculate SHA256 - uint8_t sha256[32]; 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]; + 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 < BOOTLOADER_SIZE; offset += chunkSize) { - size_t readSize = min((size_t)(BOOTLOADER_SIZE - offset), chunkSize); -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 4, 0) - if (esp_flash_read(NULL, buffer, BOOTLOADER_OFFSET + offset, readSize) == ESP_OK) { // use esp_flash_read for V4 framework (-S2, -S3, -C3) -#else - if (spi_flash_read(BOOTLOADER_OFFSET + offset, buffer, readSize) == ESP_OK) { // use spi_flash_read for old V3 framework (legacy esp32) -#endif - mbedtls_sha256_update(&ctx, buffer, readSize); + 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) { + 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; + } + } + } + + if (hashLen > 0) { + totalHashLen += hashLen; + mbedtls_sha256_update(&ctx, buffer, hashLen); + } } } - 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 = 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 += 39; + if (b2 > '9') b2 += 39; + result.concat(b2); + result.concat(b1); + } + return result; } -// Invalidate cached bootloader SHA256 (call after bootloader update) +/** + * Invalidate cached bootloader SHA256 (call after bootloader update) + * Forces recalculation on next call to calculateBootloaderSHA256 or getBootloaderSHA256Hex + */ static void invalidateBootloaderSHA256Cache() { - bootloaderSHA256HexCache = ""; + bootloaderSHA256CacheValid = false; } -#endif + + +#if 0 // WLEDMM unused + +/** + * 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) { + 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 = "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] == ESP_IMAGE_HEADER_MAGIC && + buffer[0] != ESP_IMAGE_HEADER_MAGIC) { + 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; + + // Re-validate size after adjustment + if (len < MIN_IMAGE_HEADER_SIZE) { + bootloaderErrorMsg = "Too small"; + return false; + } + } + + 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; + } + + // 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; + } + + // 4. Validate image size + BootloaderImageSizer sizer; + sizer.feed(buffer, availableLen); + if (!sizer.isSizeKnown()) { + bootloaderErrorMsg = "Invalid image"; + return false; + } + size_t actualBootloaderSize = sizer.totalSize(); + + // 5. SHA256 checksum (optional) + if (imageHeader.hash_appended == 1) { + actualBootloaderSize += 32; + } + + if (actualBootloaderSize > len) { + // Same as above + bootloaderErrorMsg = "Too small"; + 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 + len = actualBootloaderSize; + + return true; +} + +// Bootloader OTA context structure +struct BootloaderUpdateContext { + // State flags + bool replySent = false; + bool uploadComplete = false; + String errorMessage; + + // Buffer to hold bootloader data + uint8_t* buffer = nullptr; + size_t bytesBuffered = 0; + const uint32_t bootloaderOffset = 0x1000; + const uint32_t maxBootloaderSize = 0x10000; // 64KB buffer size +}; + +// Cleanup bootloader OTA context +static void endBootloaderOTA(AsyncWebServerRequest *request) { + BootloaderUpdateContext* context = reinterpret_cast(request->_tempObject); + request->_tempObject = nullptr; + + DEBUG_PRINTF_P(PSTR("EndBootloaderOTA %x --> %x\n"), (uintptr_t)request, (uintptr_t)context); + if (context) { + if (context->buffer) { + free(context->buffer); + context->buffer = nullptr; + } + + // If update failed, restore system state + if (!context->uploadComplete || !context->errorMessage.isEmpty()) { + strip.resume(); + #if WLED_WATCHDOG_TIMEOUT > 0 + WLED::instance().enableWatchdog(); + #endif + } + + delete context; + } +} + +// Initialize bootloader OTA context +bool initBootloaderOTA(AsyncWebServerRequest *request) { + if (request->_tempObject) { + return true; // Already initialized + } + + BootloaderUpdateContext* context = new BootloaderUpdateContext(); + if (!context) { + 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(); + #endif + lastEditTime = millis(); // make sure PIN does not lock during update + strip.suspend(); + strip.resetSegments(); + + // Check available heap before attempting allocation + 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 = 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(); + #endif + return false; + } + + context->bytesBuffered = 0; + return true; +#endif +} + +// Set bootloader OTA replied flag +void setBootloaderOTAReplied(AsyncWebServerRequest *request) { + BootloaderUpdateContext* context = reinterpret_cast(request->_tempObject); + if (context) { + context->replySent = true; + } +} + +// Get bootloader OTA result +std::pair getBootloaderOTAResult(AsyncWebServerRequest *request) { + BootloaderUpdateContext* context = reinterpret_cast(request->_tempObject); + + if (!context) { + return std::make_pair(true, String(F("Internal error: No bootloader OTA context"))); + } + + bool needsReply = !context->replySent; + String errorMsg = context->errorMessage; + + // If upload was successful, return empty string and trigger reboot + if (context->uploadComplete && errorMsg.isEmpty()) { + doReboot = true; + endBootloaderOTA(request); + return std::make_pair(needsReply, String()); + } + + // If there was an error, return it + if (!errorMsg.isEmpty()) { + endBootloaderOTA(request); + return std::make_pair(needsReply, errorMsg); + } + + // Should never happen + return std::make_pair(true, String(F("Internal software failure"))); +} + +// Handle bootloader OTA data +void handleBootloaderOTAData(AsyncWebServerRequest *request, size_t index, uint8_t *data, size_t len, bool isFinal) { + BootloaderUpdateContext* context = reinterpret_cast(request->_tempObject); + + if (!context) { + DEBUG_PRINTLN(F("No bootloader OTA context - ignoring data")); + return; + } + + if (!context->errorMessage.isEmpty()) { + return; + } + + // Buffer the incoming data + if (context->buffer && context->bytesBuffered + len <= context->maxBootloaderSize) { + memcpy(context->buffer + context->bytesBuffered, data, len); + context->bytesBuffered += len; + DEBUG_PRINTF_P(PSTR("Bootloader buffer progress: %d / %d bytes\n"), context->bytesBuffered, context->maxBootloaderSize); + } else if (!context->buffer) { + DEBUG_PRINTLN(F("Bootloader buffer not allocated!")); + context->errorMessage = "Internal error: Bootloader buffer not allocated"; + return; + } else { + size_t totalSize = context->bytesBuffered + len; + DEBUG_PRINTLN(F("Bootloader size exceeds maximum!")); + context->errorMessage = "Bootloader file too large: " + String(totalSize) + " bytes (max: " + String(context->maxBootloaderSize) + " bytes)"; + return; + } + + // Only write to flash when upload is complete + if (isFinal) { + DEBUG_PRINTLN(F("Bootloader Upload Complete - validating and flashing")); + + if (context->buffer && context->bytesBuffered > 0) { + // Prepare pointers for verification (may be adjusted if bootloader at offset) + const uint8_t* bootloaderData = context->buffer; + size_t bootloaderSize = context->bytesBuffered; + + // 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)) { + DEBUG_PRINTLN(F("Bootloader validation failed!")); + // Error message already set by verifyBootloaderImage + } else { + // Calculate offset to write to flash + // If bootloaderData was adjusted (partition table detected), we need to skip it in flash too + size_t flashOffset = context->bootloaderOffset; + const uint8_t* dataToWrite = context->buffer; + size_t bytesToWrite = context->bytesBuffered; + + // If validation adjusted the pointer, it means we have a partition table at the start + // In this case, we should skip writing the partition table and write bootloader at 0x1000 + if (bootloaderData != context->buffer) { + // bootloaderData was adjusted - skip partition table in our data + size_t partitionTableSize = bootloaderData - context->buffer; + dataToWrite = bootloaderData; + bytesToWrite = bootloaderSize; + DEBUG_PRINTF_P(PSTR("Skipping %d bytes of partition table data\n"), partitionTableSize); + } + + DEBUG_PRINTF_P(PSTR("Bootloader validation passed - writing %d bytes to flash at 0x%04X\n"), + bytesToWrite, flashOffset); + + // Calculate erase size (must be multiple of 4KB) + size_t eraseSize = ((bytesToWrite + 0xFFF) / 0x1000) * 0x1000; + if (eraseSize > context->maxBootloaderSize) { + eraseSize = context->maxBootloaderSize; + } + + // Erase bootloader region + DEBUG_PRINTF_P(PSTR("Erasing %d bytes at 0x%04X...\n"), eraseSize, flashOffset); + esp_err_t err = esp_flash_erase_region(NULL, flashOffset, eraseSize); + if (err != ESP_OK) { + DEBUG_PRINTF_P(PSTR("Bootloader erase error: %d\n"), err); + context->errorMessage = "Flash erase failed (error code: " + String(err) + ")"; + } else { + // Write the validated bootloader data to flash + err = esp_flash_write(NULL, dataToWrite, flashOffset, bytesToWrite); + if (err != ESP_OK) { + DEBUG_PRINTF_P(PSTR("Bootloader flash write error: %d\n"), err); + context->errorMessage = "Flash write failed (error code: " + String(err) + ")"; + } else { + DEBUG_PRINTF_P(PSTR("Bootloader Update Success - %d bytes written to 0x%04X\n"), + bytesToWrite, flashOffset); + // Invalidate cached bootloader hash + invalidateBootloaderSHA256Cache(); + context->uploadComplete = true; + } + } + } + } else if (context->bytesBuffered == 0) { + context->errorMessage = "No bootloader data received"; + } + } +} + +#endif // WLEDMM unused +// ------------------------------------- + + +#endif \ No newline at end of file diff --git a/wled00/ota_update.h b/wled00/ota_update.h index 1181e43f..e2239ebd 100644 --- a/wled00/ota_update.h +++ b/wled00/ota_update.h @@ -56,11 +56,6 @@ void handleOTAData(AsyncWebServerRequest *request, size_t index, uint8_t *data, #endif #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 - */ -static void calculateBootloaderSHA256(); /** * Get bootloader SHA256 as hex string @@ -68,9 +63,37 @@ static void calculateBootloaderSHA256(); */ String getBootloaderSHA256Hex(); +#if 0 // WLEDMM not used /** - * Invalidate cached bootloader SHA256 (call after bootloader update) - * Forces recalculation on next call to calculateBootloaderSHA256 or getBootloaderSHA256Hex + * Create a bootloader OTA context object on an AsyncWebServerRequest + * @param request Pointer to web request object + * @return true if allocation was successful, false if not */ -static void invalidateBootloaderSHA256Cache(); +bool initBootloaderOTA(AsyncWebServerRequest *request); + +/** + * Indicate to the bootloader OTA subsystem that a reply has already been generated + * @param request Pointer to web request object + */ +void setBootloaderOTAReplied(AsyncWebServerRequest *request); + +/** + * Retrieve the bootloader OTA result. + * @param request Pointer to web request object + * @return bool indicating if a reply is necessary; string with error message if the update failed. + */ +std::pair getBootloaderOTAResult(AsyncWebServerRequest *request); + +/** + * Process a block of bootloader OTA data. This is a passthrough of an ArUploadHandlerFunction. + * Requires that initBootloaderOTA be called on the handler object before any work will be done. + * @param request Pointer to web request object + * @param index Offset in to uploaded file + * @param data New data bytes + * @param len Length of new data bytes + * @param isFinal Indicates that this is the last block + */ +void handleBootloaderOTAData(AsyncWebServerRequest *request, size_t index, uint8_t *data, size_t len, bool isFinal); +#endif // WLEDMM not used + #endif