From 3788608e633a447e6c023d6aca0f2a779d74e47c Mon Sep 17 00:00:00 2001 From: Nic Limper Date: Sat, 27 May 2023 19:25:28 +0200 Subject: [PATCH] first version of OTA firmware updates --- ESP32_AP-Flasher/data/www/index.html | 17 +- ESP32_AP-Flasher/data/www/main.css | 65 ++++- ESP32_AP-Flasher/data/www/main.js | 45 +++- ESP32_AP-Flasher/data/www/ota.js | 292 +++++++++++++++++++++ ESP32_AP-Flasher/data/www/upload-demo.html | 38 +++ ESP32_AP-Flasher/data/www/upload-test.html | 21 -- ESP32_AP-Flasher/esp32_squeeze_ota.csv | 7 + ESP32_AP-Flasher/include/contentmanager.h | 1 - ESP32_AP-Flasher/include/ota.h | 12 + ESP32_AP-Flasher/include/web.h | 2 +- ESP32_AP-Flasher/platformio.ini | 30 ++- ESP32_AP-Flasher/src/contentmanager.cpp | 22 +- ESP32_AP-Flasher/src/main.cpp | 6 +- ESP32_AP-Flasher/src/makeimage.cpp | 101 +++---- ESP32_AP-Flasher/src/ota.cpp | 244 +++++++++++++++++ ESP32_AP-Flasher/src/tag_db.cpp | 2 +- ESP32_AP-Flasher/src/web.cpp | 41 ++- 17 files changed, 813 insertions(+), 133 deletions(-) create mode 100644 ESP32_AP-Flasher/data/www/ota.js create mode 100644 ESP32_AP-Flasher/data/www/upload-demo.html delete mode 100644 ESP32_AP-Flasher/data/www/upload-test.html create mode 100644 ESP32_AP-Flasher/esp32_squeeze_ota.csv create mode 100644 ESP32_AP-Flasher/include/ota.h create mode 100644 ESP32_AP-Flasher/src/ota.cpp diff --git a/ESP32_AP-Flasher/data/www/index.html b/ESP32_AP-Flasher/data/www/index.html index c659869c..f116116f 100644 --- a/ESP32_AP-Flasher/data/www/index.html +++ b/ESP32_AP-Flasher/data/www/index.html @@ -112,7 +112,7 @@ Latency will be around 40 seconds.">

reboot AP download tagDB - + update

Github OpenEPaperLink @@ -121,21 +121,15 @@ Latency will be around 40 seconds.">

-

Firmware updates

-

- test -

-

- -

+

Update dashboard

+ Updates are fetched directly from the Github repo. +
+ @@ -149,6 +143,7 @@ Latency will be around 40 seconds.">
Currently active tags:
+
loading
AP config
edit littleFS
diff --git a/ESP32_AP-Flasher/data/www/main.css b/ESP32_AP-Flasher/data/www/main.css index ef22168f..2657d249 100644 --- a/ESP32_AP-Flasher/data/www/main.css +++ b/ESP32_AP-Flasher/data/www/main.css @@ -70,6 +70,7 @@ label { text-decoration: none; color: black; cursor: pointer; + white-space: nowrap; } .columns div { @@ -112,8 +113,8 @@ select { #configbox, #apconfigbox, #apupdatebox { display: none; position: fixed; - top: 80px; - left: 50px; + top: 65px; + left: 15px; width: 380px; padding: 15px; background-color: #f0e6d3; @@ -168,7 +169,10 @@ select { } #apupdatebox { - background-color: #f0e0d0; + background-color: #f0d0c8; + width: 700px; + padding-bottom: 20px; + border: 1px solid #d0b0a8; } #cfgdelete { @@ -414,6 +418,54 @@ ul.messages li.new { border: solid 1px #666666; } +/* updatescreens */ + +#releasetable { + margin: 10px 0px; +} + +#releasetable table { + border-spacing: 1px; +} + +#releasetable th { + text-align: left; + background-color: #ffffff; + padding: 1px 5px; +} + +#releasetable td { + background-color: #ffffff; + padding: 1px 5px; + min-width: 70px; +} + +#releasetable button { + padding: 3px 10px; + background-color: #e0e0e0; +} + +#releasetable button:hover { + background-color: #a0a0a0; +} + +.console { + width: 100%; + background-color: black; + font-family: 'lucida console','ui-monospace'; + color: white; + padding: 5px 10px; + margin: 20px 0px; + padding-bottom: 25px; + height: 400px; + overflow-y: scroll; + white-space: break-spaces; +} +.console div { + word-break: break-all; +} + +/* media */ @media(max-width: 460px) { .messages li div, ul.messages li div.date, ul.messages li div.message { @@ -452,7 +504,7 @@ ul.messages li.new { /* styles for mobile devices in portrait mode */ body { - font-size: 14px; + font-size: 13px; } .tagcard { @@ -489,4 +541,7 @@ ul.messages li.new { text-align: center; } -} \ No newline at end of file + .actionbox>div { + gap: 5px; + } +} diff --git a/ESP32_AP-Flasher/data/www/main.js b/ESP32_AP-Flasher/data/www/main.js index 0a979ae6..fda0f506 100644 --- a/ESP32_AP-Flasher/data/www/main.js +++ b/ESP32_AP-Flasher/data/www/main.js @@ -15,26 +15,39 @@ const displaySizeLookup = { 0: [152, 152], 1: [128, 296], 2: [400, 300] }; displaySizeLookup[17] = [128, 296]; const colorTable = { 0: [255, 255, 255], 1: [0, 0, 0], 2: [255, 0, 0], 3: [150, 150, 150] }; +const apstate = [ + { state: "offline", color: "red" }, + { state: "online", color: "green" }, + { state: "flashing", color: "orange" }, + { state: "wait for reset", color: "blue" }, + { state: "requires power cycle", color: "purple" }, + { state: "failed", color: "red" }, + { state: "coming online", color: "yellow" } +]; + const imageQueue = []; let isProcessing = false; let servertimediff = 0; let paintLoaded = false, paintShow = false; var cardconfig; +let otamodule; window.addEventListener("load", function () { - fetch("/get_ap_list") + fetch("/get_ap_config") .then(response => response.json()) .then(data => { if (data.alias) { $(".logo").innerHTML = data.alias; this.document.title = data.alias; } - }) - fetch('/content_cards.json') + }); + fetch('/content_cards.json') .then(response => response.json()) .then(data => { cardconfig = data; loadTags(0); + connect(); + setInterval(updatecards, 1000); }) .catch(error => { console.error('Error:', error); @@ -43,8 +56,6 @@ window.addEventListener("load", function () { }); let socket; -connect(); -setInterval(updatecards, 1000); function loadTags(pos) { fetch("/get_db?pos=" + pos) @@ -77,6 +88,10 @@ function connect() { } if (msg.sys) { $('#sysinfo').innerHTML = 'free heap: ' + msg.sys.heap + ' bytes ┇ db size: ' + msg.sys.dbsize + ' bytes ┇ db record count: ' + msg.sys.recordcount + ' ┇ littlefs free: ' + msg.sys.littlefsfree + ' bytes'; + if (msg.sys.apstate) { + $("#apstatecolor").style.color = apstate[msg.sys.apstate].color; + $("#apstate").innerHTML = apstate[msg.sys.apstate].state; + } servertimediff = (Date.now() / 1000) - msg.sys.currtime; } if (msg.apitem) { @@ -87,6 +102,16 @@ function connect() { row.insertCell(3).innerHTML = msg.apitem.channel; row.insertCell(4).innerHTML = msg.apitem.version; } + if (msg.console) { + console.log(otamodule); + if (otamodule && typeof(otamodule.print) === "function") { + let color = "#c0c0c0"; + if (msg.console.startsWith("Fail") || msg.console.startsWith("Err")) { + color = "red"; + } + otamodule.print(msg.console, color); + } + } }); socket.addEventListener("close", (event) => { @@ -346,7 +371,7 @@ $('#apconfigbutton').onclick = function () { for (var i = rowCount - 1; i > 0; i--) { table.deleteRow(i); } - fetch("/get_ap_list") + fetch("/get_ap_config") .then(response => response.json()) .then(data => { $('#apcfgalias').value = data.alias; @@ -381,7 +406,12 @@ $('#apcfgsave').onclick = function () { $('#updatebutton').onclick = function () { $('#apconfigbox').style.display = 'none'; $('#apupdatebox').style.display = 'block'; - //https://api.github.com/repos/jjwbruijn/OpenEPaperLink/commits + loadOTA(); +} + +async function loadOTA() { + otamodule = await import('./ota.js?v=' + Date.now()); + otamodule.initUpdate(); } $('#paintbutton').onclick = function () { @@ -578,3 +608,4 @@ function sortGrid() { }); gridItems.forEach((item) => sortableGrid.appendChild(item)); } + diff --git a/ESP32_AP-Flasher/data/www/ota.js b/ESP32_AP-Flasher/data/www/ota.js new file mode 100644 index 00000000..b46c7f11 --- /dev/null +++ b/ESP32_AP-Flasher/data/www/ota.js @@ -0,0 +1,292 @@ +const repoUrl = 'https://api.github.com/repos/jonasniesner/OpenEPaperLink/releases'; + +const $ = document.querySelector.bind(document); + +let running = false; +let errors = 0; +let env = ''; +let buttonState = false; + +export function initUpdate() { + if (!$("#updateconsole")) { + const consoleDiv = document.createElement('div'); + consoleDiv.classList.add('console'); + consoleDiv.id = "updateconsole"; + $('#apupdatebox').appendChild(consoleDiv); + } + $("#updateconsole").innerHTML = ""; + + fetch("/sysinfo") + .then(response => { + if (response.status != 200) { + print("Error fetching sysinfo: " + response.status, "red"); + if (response.status == 404) { + print("Your current firmware version is not yet capable of updating OTA."); + print("Update it manually one last time."); + disableButtons(true); + } + return "{}"; + } else { + return response.json(); + } + }) + .then(data => { + if (data.env) { + print(`env: ${data.env}`); + print(`build date: ${formatEpoch(data.buildtime)}`); + print(`version: ${data.buildversion}`); + print(`sha: ${data.sha}`); + print(`psram size: ${data.psramsize}`); + print(`flash size: ${data.flashsize}`); + print("--------------------------","gray"); + env = data.env; + if (data.rollback) $("#rollbackOption").display = 'block'; + } + }) + .catch(error => { + print('Error fetching sysinfo: ' + error, "red"); + }); + + fetch(repoUrl) + .then(response => response.json()) + .then(data => { + const releaseDetails = data.map(release => { + const assets = release.assets; + let fileUrl = null; + const filesJsonAsset = assets.find(asset => asset.name === 'files.json'); + if (filesJsonAsset) { + fileUrl = filesJsonAsset.browser_download_url; + return { + html_url: release.html_url, + tag_name: release.tag_name, + name: release.name, + date: formatDateTime(release.published_at), + author: release.author.login, + file_url: fileUrl + } + }; + }); + + const table = document.createElement('table'); + const tableHeader = document.createElement('tr'); + tableHeader.innerHTML = 'ReleaseDateNameAuthorUpdate:'; + table.appendChild(tableHeader); + + releaseDetails.forEach(release => { + const tableRow = document.createElement('tr'); + tableRow.innerHTML = `${release.tag_name}${release.date}${release.name}${release.author}`; + table.appendChild(tableRow); + }); + + $('#releasetable').innerHTML = ""; + $('#releasetable').appendChild(table); + disableButtons(buttonState); + }) + .catch(error => { + print('Error fetching releases:' + error, "red"); + }); +} + +export function updateWebpage(fileUrl) { + if (running) return; + if (!confirm("Confirm updating the littleFS storage")) return; + + disableButtons(true); + running = true; + errors = 0; + consoleDiv.scrollTop = consoleDiv.scrollHeight; + + print("Updating littleFS partition..."); + + fetch("/getexturl?url=" + fileUrl) + .then(response => response.json()) + .then(data => { + checkfiles(data.files); + }) + .catch(error => { + print('Error fetching data:' + error, "red"); + }); + + const checkfiles = async (files) => { + for (const file of files) { + try { + const url = "/check_file?path=" + encodeURIComponent(file.path); + const response = await fetch(url); + if (response.ok) { + const data = await response.json(); + if (data.filesize == file.size && data.md5 == file.md5) { + print(`file ${file.path} is up to date`, "green"); + } else if (data.filesize == 0) { + await fetchAndPost(file.url, file.name, file.path); + } else { + await fetchAndPost(file.url, file.name, file.path); + } + } else { + print(`error checking file ${file.path}: ${response.status}`, "red"); + errors++; + } + } catch (error) { + console.error(`error checking file ${file.path}:` + error, "red"); + errors++; + } + } + running = false; + if (errors) { + print("------", "gray"); + print(`Finished updating with ${errors} errors.`, "red"); + } else { + print("------", "gray"); + print("Update succesfull."); + } + disableButtons(false); + + }; +} + +export async function updateESP(fileUrl) { + if (running) return; + if (!confirm("Confirm updating the microcontroller")) return; + + disableButtons(true); + running = true; + errors = 0; + consoleDiv.scrollTop = consoleDiv.scrollHeight; + + print("Updating firmware..."); + + let binurl, binmd5, binsize; + + try { + const response = await fetch("/getexturl?url=" + fileUrl); + const data = await response.json(); + const file = data.binaries.find((entry) => entry.name == env + '.bin'); + if (file) { + binurl = file.url; + binmd5 = file.md5; + binsize = file.size; + console.log(`URL for "${file.name}": ${binurl}`); + + try { + const response = await fetch('/update_ota', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + url: binurl, + md5: binmd5, + size: binsize + }) + }); + + if (response.ok) { + const result = await response.text(); + print('OTA update initiated.'); + } else { + print('Failed to initiate OTA update: ' + response.status, "red"); + } + } catch (error) { + print('Error during OTA update: ' + error, "red"); + } + } else { + print(`File "${fileName}" not found.`, "red"); + } + } catch (error) { + print('Error: ' + error, "red"); + } + + running = false; + disableButtons(false); +} + +$('#rollbackBtn').onclick = function () { + if (running) return; + if (!confirm("Confirm switching to previeous firmware")) return; + + disableButtons(true); + running = true; + errors = 0; + consoleDiv.scrollTop = consoleDiv.scrollHeight; + + print("Rolling back..."); + + fetch("/rollback", { + method: "POST", + body: formData + }) + + running = false; + disableButtons(false); + +} + +export function print(line, color = "white") { + const consoleDiv = document.getElementById('updateconsole'); + if (consoleDiv) { + const isScrolledToBottom = consoleDiv.scrollHeight - consoleDiv.clientHeight <= consoleDiv.scrollTop; + const newLine = document.createElement('div'); + newLine.style.color = color; + newLine.textContent = line; + consoleDiv.appendChild(newLine); + if (isScrolledToBottom) { + consoleDiv.scrollTop = consoleDiv.scrollHeight; + } + } +} + +function formatEpoch(epochTime) { + const date = new Date(epochTime * 1000); // Convert seconds to milliseconds + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); // Months are zero-based + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + + return `${year}-${month}-${day} ${hours}:${minutes}`; +} + +function formatDateTime(utcDateString) { + const date = new Date(utcDateString); + + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + const hours = String(date.getUTCHours()).padStart(2, '0'); + const minutes = String(date.getUTCMinutes()).padStart(2, '0'); + + const formattedDate = `${year}-${month}-${day} ${hours}:${minutes}`; + return formattedDate; +} + +const fetchAndPost = async (url, name, path) => { + try { + print("updating " + path); + const response = await fetch(url); + const fileContent = await response.blob(); + + const formData = new FormData(); + formData.append('path', path); + formData.append('file', fileContent, name); + + const uploadResponse = await fetch('/littlefs_put', { + method: 'POST', + body: formData + }); + + if (!uploadResponse.ok) { + print(`${response.status} ${response.body}`, "red"); + errors++; + } + } catch (error) { + print('error: ' + error, "red"); + errors++; + } +}; + +function disableButtons(active) { + $("#apupdatebox").querySelectorAll('button').forEach(button => { + button.disabled = active; + }); + buttonState = active; +} diff --git a/ESP32_AP-Flasher/data/www/upload-demo.html b/ESP32_AP-Flasher/data/www/upload-demo.html new file mode 100644 index 00000000..8e45ec2b --- /dev/null +++ b/ESP32_AP-Flasher/data/www/upload-demo.html @@ -0,0 +1,38 @@ + + + + + + Image Upload Form + + + +

demo upload form

+

You can use this as an example how to push images to a tag by an external server/script.

+

+

+ +

+
+ +

+ +

+
+ +

+ +

+
+ +

+ +

+ +

+ +
+

+ + + diff --git a/ESP32_AP-Flasher/data/www/upload-test.html b/ESP32_AP-Flasher/data/www/upload-test.html deleted file mode 100644 index 5e2164ca..00000000 --- a/ESP32_AP-Flasher/data/www/upload-test.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - Image Upload Form - - - -
-
-
-
-
-
-
- -
- - - diff --git a/ESP32_AP-Flasher/esp32_squeeze_ota.csv b/ESP32_AP-Flasher/esp32_squeeze_ota.csv new file mode 100644 index 00000000..f3bcf0db --- /dev/null +++ b/ESP32_AP-Flasher/esp32_squeeze_ota.csv @@ -0,0 +1,7 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x150000, +app1, app, ota_1, 0x160000,0x150000, +spiffs, data, spiffs, 0x2B0000,0x140000, +coredump, data, coredump,0x3F0000,0x10000, \ No newline at end of file diff --git a/ESP32_AP-Flasher/include/contentmanager.h b/ESP32_AP-Flasher/include/contentmanager.h index 2af05e3e..8de24db7 100644 --- a/ESP32_AP-Flasher/include/contentmanager.h +++ b/ESP32_AP-Flasher/include/contentmanager.h @@ -1,6 +1,5 @@ #include #include -#define DISABLE_ALL_LIBRARY_WARNINGS #include #include "makeimage.h" diff --git a/ESP32_AP-Flasher/include/ota.h b/ESP32_AP-Flasher/include/ota.h new file mode 100644 index 00000000..443d3733 --- /dev/null +++ b/ESP32_AP-Flasher/include/ota.h @@ -0,0 +1,12 @@ +#include + +#include "web.h" + +void handleSysinfoRequest(AsyncWebServerRequest* request); +void handleCheckFile(AsyncWebServerRequest* request); +void handleGetExtUrl(AsyncWebServerRequest* request); +void handleLittleFSUpload(AsyncWebServerRequest* request, String filename, size_t index, uint8_t* data, size_t len, bool final); +void handleUpdateOTA(AsyncWebServerRequest* request); +void firmwareUpdateTask(void* parameter); +void updateFirmware(const char* url, const char* expectedMd5, size_t size); +void handleRollback(AsyncWebServerRequest* request); diff --git a/ESP32_AP-Flasher/include/web.h b/ESP32_AP-Flasher/include/web.h index c6c07728..8ee39c39 100644 --- a/ESP32_AP-Flasher/include/web.h +++ b/ESP32_AP-Flasher/include/web.h @@ -12,9 +12,9 @@ void wsErr(String text); void wsSendTaginfo(uint8_t *mac, uint8_t syncMode); void wsSendSysteminfo(); void wsSendAPitem(struct APlist* apitem); +void wsSerial(String text); uint8_t wsClientCount(); extern AsyncWebSocket ws; extern SemaphoreHandle_t wsMutex; -extern TaskHandle_t websocketUpdater; \ No newline at end of file diff --git a/ESP32_AP-Flasher/platformio.ini b/ESP32_AP-Flasher/platformio.ini index d0c54f8d..dcc2263d 100644 --- a/ESP32_AP-Flasher/platformio.ini +++ b/ESP32_AP-Flasher/platformio.ini @@ -29,10 +29,6 @@ board_build.f_cpu = 240000000L ;upload_port = COM30 ;monitor_port = COM30 -build_flags = - -D BUILD_ENV_NAME=$PIOENV - -D BUILD_TIME=$UNIX_TIME - [env:OpenEPaperLink_Mini_AP] platform = https://github.com/platformio/platform-espressif32.git board=lolin_s2_mini @@ -40,6 +36,8 @@ board_build.partitions = default.csv build_unflags = -D CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC=y build_flags = + -D BUILD_ENV_NAME=$PIOENV + -D BUILD_TIME=$UNIX_TIME -D OPENEPAPERLINK_MINI_AP_PCB -D ARDUINO_USB_MODE=0 -D CONFIG_SPIRAM_USE_MALLOC=1 @@ -62,6 +60,11 @@ build_flags = -D FLASHER_LED=15 -D FLASHER_RGB_LED=33 + -D USER_SETUP_LOADED + -D DISABLE_ALL_LIBRARY_WARNINGS + -D ILI9341_DRIVER + -D SMOOTH_FONT + -D LOAD_FONT2 build_src_filter = +<*>-- @@ -86,6 +89,8 @@ build_unflags = -D ARDUINO_USB_MODE=1 -D CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC=y build_flags = + -D BUILD_ENV_NAME=$PIOENV + -D BUILD_TIME=$UNIX_TIME -D OPENEPAPERLINK_PCB -D ARDUINO_USB_MODE=0 -D CONFIG_ESP32S3_SPIRAM_SUPPORT=1 @@ -127,6 +132,12 @@ build_flags = -D FLASHER_LED=21 -D FLASHER_RGB_LED=48 + -D USER_SETUP_LOADED + -D DISABLE_ALL_LIBRARY_WARNINGS + -D ILI9341_DRIVER + -D SMOOTH_FONT + -D LOAD_FONT2 + board_build.flash_mode=qio board_build.arduino.memory_type = qio_opi board_build.psram_type=qspi_opi @@ -140,6 +151,8 @@ board = esp32dev board_build.partitions = default.csv build_flags = + -D BUILD_ENV_NAME=$PIOENV + -D BUILD_TIME=$UNIX_TIME -D CORE_DEBUG_LEVEL=0 -D SIMPLE_AP @@ -153,9 +166,14 @@ build_flags = -D FLASHER_AP_TEST=-1 -D FLASHER_AP_TXD=17 -D FLASHER_AP_RXD=16 - -D FLASHER_LED=22 + -D USER_SETUP_LOADED + -D DISABLE_ALL_LIBRARY_WARNINGS + -D ILI9341_DRIVER + -D SMOOTH_FONT + -D LOAD_FONT2 + build_src_filter = +<*>-- @@ -168,6 +186,8 @@ board = esp32dev board_build.partitions = no_ota.csv build_flags = + -D BUILD_ENV_NAME=$PIOENV + -D BUILD_TIME=$UNIX_TIME -D ALTERNATIVE_PCB -D FLASHER_AP_SS=22 -D FLASHER_AP_CLK=13 diff --git a/ESP32_AP-Flasher/src/contentmanager.cpp b/ESP32_AP-Flasher/src/contentmanager.cpp index 4db48a59..37a3f6b0 100644 --- a/ESP32_AP-Flasher/src/contentmanager.cpp +++ b/ESP32_AP-Flasher/src/contentmanager.cpp @@ -30,9 +30,12 @@ #include "web.h" #include "language.h" -#define PAL_BLACK 0 -#define PAL_WHITE 9 -#define PAL_RED 2 +// #define PAL_BLACK 0 +// #define PAL_WHITE 9 +// #define PAL_RED 2 +#define PAL_BLACK TFT_BLACK +#define PAL_WHITE TFT_WHITE +#define PAL_RED TFT_RED enum contentModes { Image, @@ -105,7 +108,7 @@ void drawNew(uint8_t mac[8], bool buttonPressed, tagRecord *&taginfo) { imgParam imageParams; imageParams.hasRed = false; imageParams.dataType = DATATYPE_IMG_RAW_1BPP; - imageParams.dither = true; + imageParams.dither = false; if (taginfo->hasCustomLUT) imageParams.grayLut = true; imageParams.invert = false; @@ -115,7 +118,7 @@ void drawNew(uint8_t mac[8], bool buttonPressed, tagRecord *&taginfo) { case Image: if (cfgobj["filename"].as() && cfgobj["filename"].as() != "null" && !cfgobj["#fetched"].as()) { - if (cfgobj["dither"] && cfgobj["dither"] == "0") imageParams.dither = false; + if (cfgobj["dither"] && cfgobj["dither"] == "1") imageParams.dither = true; jpg2buffer(cfgobj["filename"].as(), filename, imageParams); if (imageParams.hasRed) imageParams.dataType = DATATYPE_IMG_RAW_2BPP; if (prepareDataAvail(&filename, imageParams.dataType, mac, cfgobj["timetolive"].as())) { @@ -283,13 +286,16 @@ void drawString(TFT_eSprite &spr, String content, uint16_t posx, uint16_t posy, } void initSprite(TFT_eSprite &spr, int w, int h) { - spr.setColorDepth(4); // 4 bits per pixel, uses indexed color + // spr.setColorDepth(4); // 4 bits per pixel, uses indexed color + spr.setColorDepth(8); spr.createSprite(w, h); + /* uint16_t cmap[16]; cmap[PAL_BLACK] = TFT_BLACK; cmap[PAL_RED] = TFT_RED; cmap[PAL_WHITE] = TFT_WHITE; spr.createPalette(cmap, 16); + */ if (spr.getPointer() == nullptr) { wsErr("Failed to create sprite"); } @@ -815,7 +821,7 @@ bool getRssFeed(String &filename, String URL, String title, tagRecord *&taginfo, struct tm timeInfo; char header[32]; getLocalTime(&timeInfo); - sprintf(header, "%02d-%02d-%04d %02d:%02d", timeInfo.tm_mday, timeInfo.tm_mon + 1, timeInfo.tm_year + 1900, timeInfo.tm_hour, timeInfo.tm_min); + //sprintf(header, "%02d-%02d-%04d %02d:%02d", timeInfo.tm_mday, timeInfo.tm_mon + 1, timeInfo.tm_year + 1900, timeInfo.tm_hour, timeInfo.tm_min); const char *url = URL.c_str(); const char *tag = "title"; @@ -1161,7 +1167,7 @@ void prepareNFCReq(uint8_t *dst, const char *url) { void prepareLUTreq(uint8_t *dst, String input) { const char *delimiters = ", \t"; - const int maxValues = 70; + const int maxValues = 76; uint8_t waveform[maxValues]; char *ptr = strtok(const_cast(input.c_str()), delimiters); int i = 0; diff --git a/ESP32_AP-Flasher/src/main.cpp b/ESP32_AP-Flasher/src/main.cpp index 597a2cf9..4babd32b 100644 --- a/ESP32_AP-Flasher/src/main.cpp +++ b/ESP32_AP-Flasher/src/main.cpp @@ -27,12 +27,12 @@ void timeTask(void* parameter) { if (!getLocalTime(&tm)) { Serial.println("Waiting for valid time from NTP-server"); } else { - if (now % 5 == 0) { + if (now % 5 == 0 || apInfo.state != AP_STATE_ONLINE) { wsSendSysteminfo(); } if (now % 300 == 6) saveDB("/current/tagDB.json"); - contentRunner(); + if (apInfo.isOnline) contentRunner(); } vTaskDelay(1000 / portTICK_PERIOD_MS); } @@ -76,6 +76,7 @@ void setup() { heap_caps_malloc_extmem_enable(64); #endif + /* Serial.println("\n\n##################################"); Serial.printf("Internal Total heap %d, internal Free Heap %d\n", ESP.getHeapSize(), ESP.getFreeHeap()); Serial.printf("SPIRam Total heap %d, SPIRam Free Heap %d\n", ESP.getPsramSize(), ESP.getFreePsram()); @@ -99,6 +100,7 @@ void setup() { p->type, p->subtype, p->address, p->size, p->label); } while (pi = (esp_partition_next(pi))); } + */ #ifdef HAS_USB // We'll need to start the 'usbflasher' task for boards with a second (USB) port. This can be used as a 'flasher' interface, using a python script on the host diff --git a/ESP32_AP-Flasher/src/makeimage.cpp b/ESP32_AP-Flasher/src/makeimage.cpp index 168af98f..b27ff2d8 100644 --- a/ESP32_AP-Flasher/src/makeimage.cpp +++ b/ESP32_AP-Flasher/src/makeimage.cpp @@ -30,7 +30,7 @@ void jpg2buffer(String filein, String fileout, imgParam &imageParams) { } Serial.println("jpeg conversion " + String(w) + "x" + String(h)); - spr.setColorDepth(8); + spr.setColorDepth(16); spr.createSprite(w, h); if (spr.getPointer() == nullptr) { //no heap space for 8bpp, fallback to 1bpp @@ -62,32 +62,11 @@ struct Error { float b; }; -// Gamma brightness lookup table -// gamma = 1.50 steps = 256 range = 0-255 -const uint8_t gamma_lut[256] PROGMEM = { - 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, - 4, 4, 5, 5, 6, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, - 11, 12, 12, 13, 14, 14, 15, 15, 16, 16, 17, 18, 18, 19, 20, 20, - 21, 21, 22, 23, 23, 24, 25, 26, 26, 27, 28, 28, 29, 30, 31, 31, - 32, 33, 34, 34, 35, 36, 37, 37, 38, 39, 40, 41, 41, 42, 43, 44, - 45, 46, 46, 47, 48, 49, 50, 51, 52, 53, 53, 54, 55, 56, 57, 58, - 59, 60, 61, 62, 63, 64, 65, 65, 66, 67, 68, 69, 70, 71, 72, 73, - 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 88, 89, 90, - 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 102, 103, 104, 105, 106, 107, - 108, 109, 110, 112, 113, 114, 115, 116, 117, 119, 120, 121, 122, 123, 124, 126, - 127, 128, 129, 130, 132, 133, 134, 135, 136, 138, 139, 140, 141, 142, 144, 145, - 146, 147, 149, 150, 151, 152, 154, 155, 156, 158, 159, 160, 161, 163, 164, 165, - 167, 168, 169, 171, 172, 173, 174, 176, 177, 178, 180, 181, 182, 184, 185, 187, - 188, 189, 191, 192, 193, 195, 196, 197, 199, 200, 202, 203, 204, 206, 207, 209, - 210, 211, 213, 214, 216, 217, 218, 220, 221, 223, 224, 226, 227, 228, 230, 231, - 233, 234, 236, 237, 239, 240, 242, 243, 245, 246, 248, 249, 251, 252, 254, 255, -}; - uint32_t colorDistance(const Color &c1, const Color &c2, const Error &e1) { - float r_diff = gamma_lut[c1.r] + e1.r - gamma_lut[c2.r]; - float g_diff = gamma_lut[c1.g] + e1.g - gamma_lut[c2.g]; - float b_diff = gamma_lut[c1.b] + e1.b - gamma_lut[c2.b]; - return round(0.26 * r_diff * r_diff + 0.70 * g_diff * g_diff + 0.04 * b_diff * b_diff); + int32_t r_diff = c1.r + e1.r - c2.r; + int32_t g_diff = c1.g + e1.g - c2.g; + int32_t b_diff = c1.b + e1.b - c2.b; + return 3 * r_diff * r_diff + 6 * g_diff * g_diff + 1 * b_diff * b_diff; } void spr2buffer(TFT_eSprite &spr, String &fileout, imgParam &imageParams) { @@ -117,14 +96,14 @@ void spr2buffer(TFT_eSprite &spr, String &fileout, imgParam &imageParams) { {255, 0, 0} // Red }; if (imageParams.grayLut) { - Color newColor = {150, 150, 150}; + Color newColor = {160, 160, 160}; palette.push_back(newColor); Serial.println("rendering with gray"); } int num_colors = palette.size(); Color color; - Error *error_bufferold = new Error[bufw]; - Error *error_buffernew = new Error[bufw]; + Error *error_bufferold = new Error[bufw + 4]; + Error *error_buffernew = new Error[bufw + 4]; memset(error_bufferold, 0, bufw * sizeof(Error)); for (uint16_t y = 0; y < bufh; y++) { @@ -146,7 +125,7 @@ void spr2buffer(TFT_eSprite &spr, String &fileout, imgParam &imageParams) { } } - uint16_t bitIndex = 7 - (x % 8); + uint8_t bitIndex = 7 - (x % 8); uint16_t byteIndex = (y * bufw + x) / 8; // this looks a bit ugly, but it's performing better than shorter notations @@ -164,46 +143,42 @@ void spr2buffer(TFT_eSprite &spr, String &fileout, imgParam &imageParams) { imageParams.hasRed = true; break; } - /* - alt 1: - - if (best_color_index & 1) { - blackBuffer[byteIndex] |= (1 << bitIndex); - } - if (best_color_index & 2) { - imageParams.hasRed = true; - redBuffer[byteIndex] |= (1 << bitIndex); - } - - alt 2: - - blackBuffer[byteIndex] |= ((best_color_index & 1) << bitIndex); - redBuffer[byteIndex] |= ((best_color_index & 2) << bitIndex); - imageParams.hasRed |= (best_color_index & 2); - */ if (imageParams.dither) { Error error = { - ((float)color.r + error_bufferold[x].r - palette[best_color_index].r) / 16.0f, - ((float)color.g + error_bufferold[x].g - palette[best_color_index].g) / 16.0f, - ((float)color.b + error_bufferold[x].b - palette[best_color_index].b) / 16.0f}; + static_cast(color.r) + error_bufferold[x].r - static_cast(palette[best_color_index].r), + static_cast(color.g) + error_bufferold[x].g - static_cast(palette[best_color_index].g), + static_cast(color.b) + error_bufferold[x].b - static_cast(palette[best_color_index].b) }; - error_buffernew[x].r += error.r * 5.0f; - error_buffernew[x].g += error.g * 5.0f; - error_buffernew[x].b += error.b * 5.0f; + // Burkes Dithering + error_buffernew[x].r += error.r / 4.0f; + error_buffernew[x].g += error.g / 4.0f; + error_buffernew[x].b += error.b / 4.0f; if (x > 0) { - error_buffernew[x - 1].r += error.r * 3.0f; - error_buffernew[x - 1].g += error.g * 3.0f; - error_buffernew[x - 1].b += error.b * 3.0f; + error_buffernew[x - 1].r += error.r / 8.0f; + error_buffernew[x - 1].g += error.g / 8.0f; + error_buffernew[x - 1].b += error.b / 8.0f; } - if (x < bufw - 1) { - error_buffernew[x + 1].r += error.r * 1.0f; - error_buffernew[x + 1].g += error.g * 1.0f; - error_buffernew[x + 1].b += error.b * 1.0f; - error_bufferold[x + 1].r += error.r * 7.0f; - error_bufferold[x + 1].g += error.g * 7.0f; - error_bufferold[x + 1].b += error.b * 7.0f; + if (x > 1) { + error_buffernew[x - 2].r += error.r / 16.0f; + error_buffernew[x - 2].g += error.g / 16.0f; + error_buffernew[x - 2].b += error.b / 16.0f; } + error_buffernew[x + 1].r += error.r / 8.0f; + error_buffernew[x + 1].g += error.g / 8.0f; + error_buffernew[x + 1].b += error.b / 8.0f; + + error_bufferold[x + 1].r += error.r / 4.0f; + error_bufferold[x + 1].g += error.g / 4.0f; + error_bufferold[x + 1].b += error.b / 4.0f; + + error_buffernew[x + 2].r += error.r / 16.0f; + error_buffernew[x + 2].g += error.g / 16.0f; + error_buffernew[x + 2].b += error.b / 16.0f; + + error_bufferold[x + 2].r += error.r / 8.0f; + error_bufferold[x + 2].g += error.g / 8.0f; + error_bufferold[x + 2].b += error.b / 8.0f; } } memcpy(error_bufferold, error_buffernew, bufw * sizeof(Error)); diff --git a/ESP32_AP-Flasher/src/ota.cpp b/ESP32_AP-Flasher/src/ota.cpp new file mode 100644 index 00000000..dfb78626 --- /dev/null +++ b/ESP32_AP-Flasher/src/ota.cpp @@ -0,0 +1,244 @@ +#include "ota.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "tag_db.h" +#include "web.h" + +#ifndef BUILD_ENV_NAME +#define BUILD_ENV_NAME unknown +#endif +#ifndef BUILD_TIME +#define BUILD_TIME 0 +#endif +#ifndef BUILD_VERSION +#define BUILD_VERSION custom +#endif +#ifndef SHA +#define SHA 0 +#endif + +#define STR_IMPL(x) #x +#define STR(x) STR_IMPL(x) + +void handleSysinfoRequest(AsyncWebServerRequest* request) { + StaticJsonDocument<250> doc; + doc["alias"] = config.alias; + doc["env"] = STR(BUILD_ENV_NAME); + doc["buildtime"] = STR(BUILD_TIME); + doc["buildversion"] = STR(BUILD_VERSION); + doc["sha"] = STR(SHA); + doc["psramsize"] = ESP.getPsramSize(); + doc["flashsize"] = ESP.getFlashChipSize(); + doc["rollback"] = Update.canRollBack(); + + size_t bufferSize = measureJson(doc) + 1; + AsyncResponseStream *response = request->beginResponseStream("application/json", bufferSize); + serializeJson(doc, *response); + request->send(response); +}; + +void handleCheckFile(AsyncWebServerRequest *request) { + if (!request->hasParam("path")) { + request->send(400); + return; + } + + String filePath = request->getParam("path")->value(); + File file = LittleFS.open(filePath, "r"); + if (!file) { + StaticJsonDocument<64> doc; + doc["filesize"] = 0; + doc["md5"] = ""; + String jsonResponse; + serializeJson(doc, jsonResponse); + request->send(200, "application/json", jsonResponse); + return; + } + + size_t fileSize = file.size(); + + MD5Builder md5; + md5.begin(); + md5.addStream(file, fileSize); + md5.calculate(); + String md5Hash = md5.toString(); + + file.close(); + + StaticJsonDocument<128> doc; + doc["filesize"] = fileSize; + doc["md5"] = md5Hash; + String jsonResponse; + serializeJson(doc, jsonResponse); + + request->send(200, "application/json", jsonResponse); +} + +void handleGetExtUrl(AsyncWebServerRequest* request) { + if (request->hasParam("url")) { + String url = request->getParam("url")->value(); + HTTPClient http; + http.begin(url); + http.setConnectTimeout(4000); + http.setFollowRedirects(HTTPC_FORCE_FOLLOW_REDIRECTS); + int httpResponseCode = http.GET(); + if (httpResponseCode > 0) { + Serial.println(httpResponseCode); + String contentType = http.header("Content-Type"); + size_t contentLength = http.getSize(); + if (contentLength > 0) { + String content = http.getString(); + AsyncWebServerResponse* response = request->beginResponse(200, contentType, content); + request->send(response); + } else { + request->send(500, "text/plain", "no size header"); + } + } else { + request->send(httpResponseCode, "text/plain", "Failed to fetch URL"); + } + http.end(); + } else { + request->send(400, "text/plain", "Missing 'url' parameter"); + } +} + +void handleLittleFSUpload(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { + bool error = false; + if (!index) { + String path; + if (!request->hasParam("path", true)) { + path = "/temp/null.bin"; + final = true; + error = true; + } else { + path = request->getParam("path", true)->value(); + Serial.println("update " + path); + request->_tempFile = LittleFS.open(path, "w", true); + } + } + if (len) { + if (!request->_tempFile.write(data, len)) { + error = true; + final = true; + } + } + if (final) { + request->_tempFile.close(); + if (error) { + request->send(507, "text/plain", "Error. Disk full?"); + } else { + request->send(200, "text/plain", "Ok, file written"); + } + } +} + +struct FirmwareUpdateParams { + String url; + String md5; + size_t size; +}; + +void handleUpdateOTA(AsyncWebServerRequest* request) { + if (request->hasParam("url", true) && request->hasParam("md5", true) && request->hasParam("size", true)) { + saveDB("/current/tagDB.json"); + + FirmwareUpdateParams* params = new FirmwareUpdateParams; + params->url = request->getParam("url", true)->value(); + params->md5 = request->getParam("md5", true)->value(); + params->size = request->getParam("size", true)->value().toInt(); + + xTaskCreatePinnedToCore(firmwareUpdateTask, "OTAUpdateTask", 8192, params, 1, NULL, 1); + + request->send(200, "text/plain", "In progress"); + } else { + request->send(400, "Bad request"); + } +} + +void firmwareUpdateTask(void* parameter) { + FirmwareUpdateParams* params = reinterpret_cast(parameter); + + const char* url = params->url.c_str(); + const char* md5 = params->md5.c_str(); + size_t size = params->size; + updateFirmware(url, md5, size); + + delete params; + vTaskDelete(NULL); +} + +void updateFirmware(const char* url, const char* expectedMd5, size_t size) { + HTTPClient httpClient; + + wsSerial("start downloading"); + wsSerial(url); + + httpClient.begin(url); + httpClient.setFollowRedirects(HTTPC_FORCE_FOLLOW_REDIRECTS); + int httpCode = httpClient.GET(); + + if (httpCode == HTTP_CODE_OK) { + if (Update.begin(size)) { + Update.setMD5(expectedMd5); + + unsigned long progressTimer = millis(); + Update.onProgress([&progressTimer](size_t progress, size_t total) { + if (millis() - progressTimer > 500 || progress == total) { + char buffer[50]; + sprintf(buffer, "Progress: %u%% %d %d\r\n", progress * 100 / total, progress, total); + wsSerial(String(buffer)); + progressTimer = millis(); + } + }); + + size_t written = Update.writeStream(httpClient.getStream()); + if (written == httpClient.getSize()) { + if (Update.end(true)) { + wsSerial("Firmware update successful"); + wsSerial("Restarting system..."); + vTaskDelay(1000 / portTICK_PERIOD_MS); + ESP.restart(); + } else { + wsSerial("Error updating firmware:"); + wsSerial(Update.errorString()); + } + } else { + wsSerial("Error writing firmware data:"); + wsSerial(Update.errorString()); + } + } else { + wsSerial("Failed to begin firmware update"); + wsSerial(Update.errorString()); + } + } else { + wsSerial("Failed to download firmware file (HTTP code " + String(httpCode) + ")"); + } + + httpClient.end(); +} + +void handleRollback(AsyncWebServerRequest* request) { + if (Update.canRollBack()) { + bool rollbackSuccess = Update.rollBack(); + if (rollbackSuccess) { + request->send(200, "Rollback successfull"); + wsSerial("Rollback successfull"); + wsSerial("Restarting system..."); + vTaskDelay(1000 / portTICK_PERIOD_MS); + ESP.restart(); + } else { + wsSerial("Rollback failed"); + request->send(400, "Rollback failed"); + } + } else { + wsSerial("Rollback not allowed"); + request->send(400, "Rollback not allowed"); + } +} \ No newline at end of file diff --git a/ESP32_AP-Flasher/src/tag_db.cpp b/ESP32_AP-Flasher/src/tag_db.cpp index 490c0797..6e84af58 100644 --- a/ESP32_AP-Flasher/src/tag_db.cpp +++ b/ESP32_AP-Flasher/src/tag_db.cpp @@ -256,7 +256,7 @@ void initAPconfig() { } configFile.close(); } - config.channel = APconfig["channel"] | 25; + config.channel = APconfig["channel"] | 0; if (APconfig["alias"]) strlcpy(config.alias, APconfig["alias"], sizeof(config.alias)); config.led = APconfig["led"] | 255; config.language = APconfig["language"] | getDefaultLanguage(); diff --git a/ESP32_AP-Flasher/src/web.cpp b/ESP32_AP-Flasher/src/web.cpp index 0f70efd0..9f4600ce 100644 --- a/ESP32_AP-Flasher/src/web.cpp +++ b/ESP32_AP-Flasher/src/web.cpp @@ -15,6 +15,8 @@ #include "language.h" #include "leds.h" #include "newproto.h" +#include "ota.h" +#include "serialap.h" #include "settings.h" #include "tag_db.h" #include "udp.h" @@ -27,10 +29,8 @@ AsyncWebServer server(80); AsyncWebSocket ws("/ws"); SemaphoreHandle_t wsMutex; -TaskHandle_t websocketUpdater; void webSocketSendProcess(void *parameter) { - websocketUpdater = xTaskGetCurrentTaskHandle(); wsMutex = xSemaphoreCreateMutex(); while (true) { ws.cleanupClients(); @@ -45,7 +45,6 @@ void onEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType switch (type) { case WS_EVT_CONNECT: ets_printf("ws[%s][%u] connect\n", server->url(), client->id()); - xTaskNotify(websocketUpdater, 2, eSetBits); // client->ping(); break; case WS_EVT_DISCONNECT: @@ -59,6 +58,7 @@ void onEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType ets_printf("ws[%s][%u] pong[%u]: %s\n", server->url(), client->id(), len, (len) ? (char *)data : ""); break; case WS_EVT_DATA: + /* AwsFrameInfo *info = (AwsFrameInfo *)arg; if (info->final && info->index == 0 && info->len == len) { // the whole message is in a single frame and we got all of it's data @@ -105,13 +105,13 @@ void onEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType client->binary("{\"status\":\"received\"}"); } } - } + } */ break; } } void wsLog(String text) { - StaticJsonDocument<500> doc; + StaticJsonDocument<250> doc; doc["logMsg"] = text; if (wsMutex) xSemaphoreTake(wsMutex, portMAX_DELAY); ws.textAll(doc.as()); @@ -119,7 +119,7 @@ void wsLog(String text) { } void wsErr(String text) { - StaticJsonDocument<500> doc; + StaticJsonDocument<250> doc; doc["errMsg"] = text; if (wsMutex) xSemaphoreTake(wsMutex, portMAX_DELAY); ws.textAll(doc.as()); @@ -127,7 +127,7 @@ void wsErr(String text) { } void wsSendSysteminfo() { - DynamicJsonDocument doc(250); + DynamicJsonDocument doc(150); JsonObject sys = doc.createNestedObject("sys"); time_t now; time(&now); @@ -136,6 +136,7 @@ void wsSendSysteminfo() { sys["recordcount"] = tagDB.size(); sys["dbsize"] = tagDB.size() * sizeof(tagRecord); sys["littlefsfree"] = LittleFS.totalBytes() - LittleFS.usedBytes(); + sys["apstate"] = apInfo.state; xSemaphoreTake(wsMutex, portMAX_DELAY); ws.textAll(doc.as()); @@ -199,6 +200,15 @@ void wsSendAPitem(struct APlist *apitem) { if (wsMutex) xSemaphoreGive(wsMutex); } +void wsSerial(String text) { + StaticJsonDocument<250> doc; + doc["console"] = text; + Serial.print(text); + if (wsMutex) xSemaphoreTake(wsMutex, portMAX_DELAY); + ws.textAll(doc.as()); + if (wsMutex) xSemaphoreGive(wsMutex); +} + uint8_t wsClientCount() { return ws.count(); } @@ -214,6 +224,7 @@ void init_web() { } WiFi.mode(WIFI_STA); + WiFiManager wm; bool res; res = wm.autoConnect("OpenEPaperLink Setup"); @@ -328,7 +339,7 @@ void init_web() { } }); - server.on("/get_ap_list", HTTP_GET, [](AsyncWebServerRequest *request) { + server.on("/get_ap_config", HTTP_GET, [](AsyncWebServerRequest *request) { UDPcomm udpsync; udpsync.getAPList(); File configFile = LittleFS.open("/current/apconfig.json", "r"); @@ -378,6 +389,20 @@ void init_web() { file.close(); }); + server.on("/sysinfo", HTTP_GET, handleSysinfoRequest); + server.on("/check_file", HTTP_GET, handleCheckFile); + server.on("/getexturl", HTTP_GET, handleGetExtUrl); + server.on("/update_ota", HTTP_POST, [](AsyncWebServerRequest *request) { + handleUpdateOTA(request); + }); + server.on("/rollback", HTTP_POST, handleRollback); + + server.on( + "/littlefs_put", HTTP_POST, [](AsyncWebServerRequest *request) { + request->send(200); + }, + handleLittleFSUpload); + server.onNotFound([](AsyncWebServerRequest *request) { if (request->url() == "/" || request->url() == "index.htm") { request->send(200, "text/html", "index.html not found. Did you forget to upload the littlefs partition?");