Web: replace ElegantOTA UI with native HTTPS firmware upload
OTA firmware updates previously required starting a separate plain-HTTP AsyncWebServer on port 80 via ElegantOTA. This meant stopping the redirect server, spinning up a new server instance, and transmitting the firmware binary unencrypted without authentication enforced. Add a POST /api/ota endpoint directly to WebPanelServer. The handler authenticates via the existing X-Auth-Token session token, resets the idle timeout via noteActivity(), and streams the request body in 4096-byte chunks into Update.write() (matching the ESP32 flash sector size). Update.begin() is called with the exact Content-Length so the library can verify partition space upfront. Update.abort() is called on any read or write error to leave the device on the current firmware. On success the handler sends a 200 response and reboots after 1s. The endpoint runs over the existing HTTPS server on port 443. Replace the "Start OTA" button in the actions bar with an inline "Firmware Update" card on the app page. The card contains a .bin file picker and an upload button. Upload progress is tracked via XMLHttpRequest's upload.onprogress event and shown with a progress bar. A 401 response redirects to the login page; other failures re-enable the file picker so the user can retry without reloading the page. The old ElegantOTA infrastructure (ESP32Board::startOTAUpdate, WebService::prepareForOTAStart, the /update redirect handler) is not yet removed and remains accessible via the `start ota` CLI command.
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
#include <esp_idf_version.h>
|
||||
#include <esp_system.h>
|
||||
#include <string.h>
|
||||
#include "Update.h"
|
||||
|
||||
#include "../mqtt/generated/WebPanelCert.h"
|
||||
|
||||
@@ -558,7 +559,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
.actions-bar { display:grid; grid-template-columns:1fr; gap:10px; }
|
||||
.actions-group { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.actions-group.left, .actions-group.center, .actions-group.right { justify-self:stretch; }
|
||||
.actions-group.center { grid-template-columns:repeat(3,minmax(0,1fr)); justify-items:stretch; }
|
||||
.actions-group.center { grid-template-columns:repeat(2,minmax(0,1fr)); justify-items:stretch; }
|
||||
.actions-group.right { grid-template-columns:repeat(3,minmax(0,1fr)); justify-items:stretch; }
|
||||
#actionsPanel .actions-group button { width:100%; }
|
||||
#actionsPanel .actions-group.center button { width:100%; min-width:0; }
|
||||
@@ -591,7 +592,6 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
</div>
|
||||
<div class="actions-group center">
|
||||
<button id="advertBtn" class="action-advert">Advert</button>
|
||||
<button id="otaBtn" class="action-dreamy">Start OTA</button>
|
||||
<div class="btn-split" id="refreshSplitGroup">
|
||||
<button id="refreshPageBtn" class="action-dreamy">Refresh</button>
|
||||
<button id="autoRefreshBtn" class="action-dreamy">↻</button>
|
||||
@@ -665,6 +665,25 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="otaPanel" style="display:none">
|
||||
<h2>Firmware Update</h2>
|
||||
<div class="stack">
|
||||
<div class="field-card">
|
||||
<label class="label" for="otaFile">Firmware binary (.bin)</label>
|
||||
<div class="fieldline">
|
||||
<input id="otaFile" type="file" accept=".bin" style="padding:8px">
|
||||
<button id="otaUploadBtn" class="savebtn" disabled>Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="otaProgress" style="display:none">
|
||||
<div style="background:var(--surface2);border-radius:8px;overflow:hidden;height:8px;margin-bottom:8px">
|
||||
<div id="otaProgressBar" style="height:100%;width:0%;background:var(--accent);transition:width .2s"></div>
|
||||
</div>
|
||||
<div id="otaStatusMsg" style="color:var(--text-muted);font-size:13px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="repeaterSettingsPanel" style="display:none">
|
||||
<h2>Repeater Settings</h2>
|
||||
<div class="stack">
|
||||
@@ -1036,7 +1055,6 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
const statsBtn = document.getElementById("statsPageBtn");
|
||||
const centerGroup = document.querySelector("#actionsPanel .actions-group.center");
|
||||
const advertBtn = document.getElementById("advertBtn");
|
||||
const otaBtn = document.getElementById("otaBtn");
|
||||
const refreshSplitGroup = document.getElementById("refreshSplitGroup");
|
||||
if (appBtn) {
|
||||
appBtn.classList.toggle("active", !isStatsPage);
|
||||
@@ -1049,7 +1067,6 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
statsBtn.onclick = () => window.location.assign("/stats");
|
||||
}
|
||||
if (advertBtn) advertBtn.style.display = isStatsPage ? "none" : "";
|
||||
if (otaBtn) otaBtn.style.display = isStatsPage ? "none" : "";
|
||||
if (refreshSplitGroup) refreshSplitGroup.style.display = isStatsPage ? "" : "none";
|
||||
}
|
||||
function toggleTheme() {
|
||||
@@ -2122,6 +2139,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
document.getElementById("statsPanel").style.display = show && !isStatsPage ? "none" : "none";
|
||||
document.getElementById("statsPagePanel").style.display = show && isStatsPage ? "block" : "none";
|
||||
document.getElementById("repeaterSettingsPanel").style.display = show && !isStatsPage ? "block" : "none";
|
||||
document.getElementById("otaPanel").style.display = show && !isStatsPage ? "block" : "none";
|
||||
if (!show) {
|
||||
if (mqttIataBanner) mqttIataBanner.classList.remove("visible");
|
||||
commandQueue = Promise.resolve();
|
||||
@@ -2588,13 +2606,54 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
await runCommand("advert");
|
||||
}
|
||||
};
|
||||
document.getElementById("otaBtn").onclick = async () => {
|
||||
if (confirm("Start OTA mode now?")) {
|
||||
const result = await runCommand("start ota");
|
||||
if (result && result.ok) {
|
||||
window.open(window.location.origin + "/update", "_blank");
|
||||
document.getElementById("otaFile").addEventListener("change", (e) => {
|
||||
document.getElementById("otaUploadBtn").disabled = !e.target.files || !e.target.files.length;
|
||||
});
|
||||
|
||||
document.getElementById("otaUploadBtn").onclick = () => {
|
||||
const file = document.getElementById("otaFile").files[0];
|
||||
if (!file) return;
|
||||
if (!confirm("Upload " + file.name + "? The device will reboot after flashing.")) return;
|
||||
|
||||
const progressEl = document.getElementById("otaProgress");
|
||||
const barEl = document.getElementById("otaProgressBar");
|
||||
const msgEl = document.getElementById("otaStatusMsg");
|
||||
progressEl.style.display = "block";
|
||||
document.getElementById("otaUploadBtn").disabled = true;
|
||||
document.getElementById("otaFile").disabled = true;
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/ota");
|
||||
xhr.setRequestHeader("X-Auth-Token", token);
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const pct = Math.round((e.loaded / e.total) * 100);
|
||||
barEl.style.width = pct + "%";
|
||||
msgEl.textContent = "Uploading... " + pct + "%";
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 401) {
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
if (xhr.status === 200) {
|
||||
barEl.style.width = "100%";
|
||||
msgEl.textContent = "Done. Device is rebooting...";
|
||||
} else {
|
||||
msgEl.textContent = "Failed: " + (xhr.responseText || String(xhr.status));
|
||||
document.getElementById("otaUploadBtn").disabled = false;
|
||||
document.getElementById("otaFile").disabled = false;
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
msgEl.textContent = "Network error during upload.";
|
||||
document.getElementById("otaUploadBtn").disabled = false;
|
||||
document.getElementById("otaFile").disabled = false;
|
||||
};
|
||||
xhr.send(file);
|
||||
};
|
||||
const AUTO_REFRESH_INTERVAL = 60;
|
||||
let autoRefreshCountdown = 0;
|
||||
@@ -2721,7 +2780,7 @@ bool WebPanelServer::start() {
|
||||
|
||||
httpd_ssl_config_t config = HTTPD_SSL_CONFIG_DEFAULT();
|
||||
config.httpd.max_open_sockets = 2;
|
||||
config.httpd.max_uri_handlers = 7;
|
||||
config.httpd.max_uri_handlers = 8;
|
||||
config.httpd.max_resp_headers = 4;
|
||||
config.httpd.backlog_conn = 0;
|
||||
config.httpd.recv_wait_timeout = 10;
|
||||
@@ -2755,6 +2814,7 @@ bool WebPanelServer::start() {
|
||||
httpd_uri_t command_uri = {.uri = "/api/command", .method = HTTP_POST, .handler = &WebPanelServer::handleCommand, .user_ctx = &_route_context};
|
||||
httpd_uri_t stats_uri = {.uri = "/api/stats", .method = HTTP_GET, .handler = &WebPanelServer::handleStats, .user_ctx = &_route_context};
|
||||
httpd_uri_t update = {.uri = "/update", .method = HTTP_GET, .handler = &WebPanelServer::handleOtaRedirect, .user_ctx = &_route_context};
|
||||
httpd_uri_t ota_uri = { .uri = "/api/ota", .method = HTTP_POST, .handler = &WebPanelServer::handleOtaUpload, .user_ctx = &_route_context };
|
||||
httpd_register_uri_handler(_server, &index_uri);
|
||||
httpd_register_uri_handler(_server, &app_uri);
|
||||
httpd_register_uri_handler(_server, &stats_page_uri);
|
||||
@@ -2762,6 +2822,7 @@ bool WebPanelServer::start() {
|
||||
httpd_register_uri_handler(_server, &command_uri);
|
||||
httpd_register_uri_handler(_server, &stats_uri);
|
||||
httpd_register_uri_handler(_server, &update);
|
||||
httpd_register_uri_handler(_server, &ota_uri);
|
||||
|
||||
httpd_config_t redirect_config = HTTPD_DEFAULT_CONFIG();
|
||||
redirect_config.server_port = 80;
|
||||
@@ -2913,6 +2974,59 @@ esp_err_t WebPanelServer::handleOtaRedirect(httpd_req_t* req) {
|
||||
return httpd_resp_send(req, "", 0);
|
||||
}
|
||||
|
||||
esp_err_t WebPanelServer::handleOtaUpload(httpd_req_t* req) {
|
||||
auto* ctx = static_cast<RouteContext*>(req->user_ctx);
|
||||
if (ctx == nullptr || ctx->self == nullptr) {
|
||||
return httpd_resp_send_500(req);
|
||||
}
|
||||
if (!ctx->self->isAuthorized(req)) {
|
||||
return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized");
|
||||
}
|
||||
|
||||
ctx->self->noteActivity();
|
||||
|
||||
int remaining = req->content_len;
|
||||
if (remaining <= 0) {
|
||||
return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "No content");
|
||||
}
|
||||
|
||||
constexpr size_t kChunkSize = 4096;
|
||||
char* buf = allocScratchBuffer(kChunkSize);
|
||||
if (buf == nullptr) {
|
||||
return httpd_resp_send_500(req);
|
||||
}
|
||||
|
||||
if (!Update.begin(static_cast<size_t>(remaining), U_FLASH)) {
|
||||
freeScratchBuffer(buf);
|
||||
return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Update begin failed");
|
||||
}
|
||||
|
||||
while (remaining > 0) {
|
||||
int to_read = (remaining < (int)kChunkSize) ? remaining : (int)kChunkSize;
|
||||
int n = httpd_req_recv(req, buf, to_read);
|
||||
if (n <= 0) {
|
||||
Update.abort();
|
||||
freeScratchBuffer(buf);
|
||||
return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Read error");
|
||||
}
|
||||
if (Update.write(reinterpret_cast<uint8_t*>(buf), n) != (size_t)n) {
|
||||
Update.abort();
|
||||
freeScratchBuffer(buf);
|
||||
return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Write error");
|
||||
}
|
||||
remaining -= n;
|
||||
}
|
||||
freeScratchBuffer(buf);
|
||||
|
||||
if (!Update.end()) {
|
||||
return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Update end failed");
|
||||
}
|
||||
|
||||
(void)httpd_resp_sendstr(req, "OK");
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
esp_restart();
|
||||
}
|
||||
|
||||
esp_err_t WebPanelServer::handleLogin(httpd_req_t* req) {
|
||||
auto* ctx = static_cast<RouteContext*>(req->user_ctx);
|
||||
if (ctx == nullptr || ctx->self == nullptr || ctx->self->_runner == nullptr) {
|
||||
|
||||
Reference in New Issue
Block a user