diff --git a/.gitignore b/.gitignore index 04a155d..bad7436 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ src/secrets.h # generated at buildfs/uploadfs/CI β€” not source data/**/*.gz +data/fsver diff --git a/scripts/version.py b/scripts/version.py index 9f6ff4d..f461093 100644 --- a/scripts/version.py +++ b/scripts/version.py @@ -67,6 +67,21 @@ def _clean_semver(raw): env.Append(CPPDEFINES=defines) +# Stamp the filesystem with its version so the running device can tell whether +# its UI (LittleFS image) is up to date INDEPENDENTLY of the firmware β€” the +# updater reads /fsver and compares it to the release manifest. Written for +# device builds only (native/host builds have no board_id). The value matches +# the firmware version above; the "1.0.0" fallback matches constants.h so an +# untagged local build stays self-consistent. data/fsver is a build artifact +# (gitignored); buildfs packs it into littlefs.bin (it is not gzipped). +if board_id: + project_dir = env.subst("$PROJECT_DIR") + data_dir = os.path.join(project_dir, "data") + if os.path.isdir(data_dir): + fs_version = version or "1.0.0" + with open(os.path.join(data_dir, "fsver"), "w") as fsver_file: + fsver_file.write(fs_version + "\n") + print( "πŸ”– version.py: env=%s version=%s hash=%s board_id=%s" % (pioenv, version or "(fallback)", build_hash, board_id or "(fallback)") diff --git a/src/api/firmware.cpp b/src/api/firmware.cpp index 3969431..fb9640c 100644 --- a/src/api/firmware.cpp +++ b/src/api/firmware.cpp @@ -18,8 +18,9 @@ static void serializeStatus(const lume::UpdateStatus& s, String& out) { JsonDocument doc; doc["phase"] = lume::updatePhaseName(s.phase); doc["current"] = s.current; + doc["fsVersion"] = s.fsVersion[0] ? s.fsVersion : (const char*)nullptr; doc["latest"] = s.latest[0] ? s.latest : (const char*)nullptr; - doc["updateAvailable"] = s.updateAvailable; // == appAvailable (compat) + doc["updateAvailable"] = s.updateAvailable; // app OR fs behind doc["appAvailable"] = s.appAvailable; doc["fsAvailable"] = s.fsAvailable; doc["notes"] = s.notes[0] ? s.notes : (const char*)nullptr; @@ -46,6 +47,21 @@ void handleApiFirmwareStatus(AsyncWebServerRequest* request) { request->send(200, "application/json", out); } +void handleApiFirmwareUpdate(AsyncWebServerRequest* request) { + if (!checkAuth(request)) { sendUnauthorized(request); return; } + lume::UpdateStatus s = lume::updaterStatus(); + if (!s.updateAvailable) { + request->send(400, "application/json", + "{\"error\":\"No update available; run /api/firmware/check first\"}"); + return; + } + if (!lume::requestUpdate()) { + request->send(409, "application/json", "{\"error\":\"Updater busy\"}"); + return; + } + request->send(202, "application/json", "{\"status\":\"updating\",\"target\":\"both\"}"); +} + void handleApiFirmwareUpdateApp(AsyncWebServerRequest* request) { if (!checkAuth(request)) { sendUnauthorized(request); return; } lume::UpdateStatus s = lume::updaterStatus(); diff --git a/src/api/firmware.h b/src/api/firmware.h index 4bdd075..255fe15 100644 --- a/src/api/firmware.h +++ b/src/api/firmware.h @@ -6,17 +6,20 @@ // // POST /api/firmware/check -> 202; async GitHub check (both images). // GET /api/firmware/status -> 200; updater state + last check result. -// POST /api/firmware/update/app -> 202; flash the firmware image only (async). -// POST /api/firmware/update/fs -> 202; flash the filesystem image only (async). +// POST /api/firmware/update -> 202; ATOMIC: flash whatever is behind +// (filesystem + firmware), one reboot. UI path. +// POST /api/firmware/update/app -> 202; flash the firmware image only (recovery). +// POST /api/firmware/update/fs -> 202; flash the filesystem image only (recovery). // -// A single check reports availability for BOTH the app and fs images, but the -// apply step is split into two fully independent operations β€” flashing one never -// triggers the other. Check/update are asynchronous because they do a blocking -// HTTPS transfer that must not run on the AsyncTCP task (same rationale as -// /api/prompt). The UI triggers, then polls /status (whose "stage" field says -// which target, if any, is in progress). +// A single check reports whether the app and/or fs are behind. The UI drives the +// atomic /update (app and fs are one versioned release, so they update together +// and can't half-update). The per-image endpoints remain for recovery/debug. +// Check/update are asynchronous because they do a blocking HTTPS transfer that +// must not run on the AsyncTCP task (same rationale as /api/prompt). The UI +// triggers, then polls /status (whose "stage" field says which target is active). void handleApiFirmwareCheck(AsyncWebServerRequest* request); void handleApiFirmwareStatus(AsyncWebServerRequest* request); +void handleApiFirmwareUpdate(AsyncWebServerRequest* request); void handleApiFirmwareUpdateApp(AsyncWebServerRequest* request); void handleApiFirmwareUpdateFs(AsyncWebServerRequest* request); diff --git a/src/network/server.cpp b/src/network/server.cpp index a511271..e1d8ce3 100644 --- a/src/network/server.cpp +++ b/src/network/server.cpp @@ -291,8 +291,9 @@ void setupServer() { // triggers the other. server.on("/api/firmware/check", HTTP_POST, handleApiFirmwareCheck); server.on("/api/firmware/status", HTTP_GET, handleApiFirmwareStatus); - server.on("/api/firmware/update/app", HTTP_POST, handleApiFirmwareUpdateApp); - server.on("/api/firmware/update/fs", HTTP_POST, handleApiFirmwareUpdateFs); + server.on("/api/firmware/update", HTTP_POST, handleApiFirmwareUpdate); // atomic (UI) + server.on("/api/firmware/update/app", HTTP_POST, handleApiFirmwareUpdateApp); // recovery + server.on("/api/firmware/update/fs", HTTP_POST, handleApiFirmwareUpdateFs); // recovery // =========================================================================== diff --git a/src/network/updater.cpp b/src/network/updater.cpp index 2b7f09d..fcef967 100644 --- a/src/network/updater.cpp +++ b/src/network/updater.cpp @@ -40,6 +40,7 @@ #include #include #include +#include #include #include #include @@ -80,9 +81,9 @@ const char* updatePhaseName(UpdatePhase p) { namespace { // ── Worker plumbing ────────────────────────────────────────────────────────── -// ApplyApp and ApplyFs are fully independent operations β€” neither triggers the -// other. This mirrors the dev flow (`pio run -t upload` vs `-t uploadfs`). -enum class Cmd : uint8_t { Check, ApplyApp, ApplyFs }; +// ApplyBoth is the normal atomic path (flash whichever images are behind, one +// reboot). ApplyApp/ApplyFs remain as low-level recovery/debug operations. +enum class Cmd : uint8_t { Check, ApplyBoth, ApplyApp, ApplyFs }; QueueHandle_t g_cmdQueue = nullptr; TaskHandle_t g_task = nullptr; @@ -102,7 +103,9 @@ struct Target { String appUrl, appSha; size_t appSize = 0; String fsUrl, fsSha; size_t fsSize = 0; bool hasFs = false; - bool available = false; + bool available = false; // appBehind || fsBehind + bool appBehind = false; // running firmware older than latest + bool fsBehind = false; // installed filesystem older than latest (or unstamped) } g_target; // ── Status helpers (locked) ────────────────────────────────────────────────── @@ -304,6 +307,19 @@ bool downloadVerifyFlash(const String& url, const String& expectedSha, return true; } +// Read the filesystem's own version stamp, written into the LittleFS image at +// build time (scripts/version.py -> data/fsver). Returns "" if the file is +// absent β€” which is the case for any image built before the stamp existed, so +// the caller treats "" as "older than any release" and self-heals it. +String readInstalledFsVersion() { + File f = LittleFS.open("/fsver", "r"); + if (!f) return String(); + String v = f.readStringUntil('\n'); + f.close(); + v.trim(); + return v; +} + // ── Check ──────────────────────────────────────────────────────────────────── bool doCheck(String& err) { setPhase(UpdatePhase::Checking); @@ -371,30 +387,40 @@ bool doCheck(String& err) { return false; } - bool avail = isNewer(g_target.latest, String(FIRMWARE_VERSION)); + // Compare BOTH installed images to the latest release independently. The app + // version is baked in (FIRMWARE_VERSION); the fs version is read from the + // running LittleFS image's /fsver stamp. An unstamped fs (legacy image) reads + // as "" and is treated as behind, so a device that only ever got a firmware + // update β€” the classic footgun β€” still self-heals its stale UI on next check. + const String fsInstalled = readInstalledFsVersion(); + + bool appBehind = isNewer(g_target.latest, String(FIRMWARE_VERSION)); + bool fsBehind; + if (!g_target.hasFs) fsBehind = false; // no fs image published + else if (fsInstalled.length() == 0) fsBehind = true; // unstamped legacy fs -> stale + else fsBehind = isNewer(g_target.latest, fsInstalled); + + bool avail = appBehind || fsBehind; + g_target.appBehind = appBehind; + g_target.fsBehind = fsBehind; g_target.available = avail; - // App and FS are versioned together (one release), so both become available - // when a newer version is published β€” but only the app is guaranteed to - // exist; the FS image is offered only when the manifest actually carries it. - bool appAvail = avail; - bool fsAvail = avail && g_target.hasFs; - const char* notes = doc["notes"] | ""; lock(); copyStr(g_status.latest, sizeof(g_status.latest), g_target.latest.c_str()); + copyStr(g_status.fsVersion, sizeof(g_status.fsVersion), fsInstalled.c_str()); copyStr(g_status.notes, sizeof(g_status.notes), notes); - g_status.updateAvailable = appAvail; - g_status.appAvailable = appAvail; - g_status.fsAvailable = fsAvail; + g_status.updateAvailable = avail; + g_status.appAvailable = appBehind; + g_status.fsAvailable = fsBehind; g_status.phase = avail ? UpdatePhase::Available : UpdatePhase::UpToDate; unlock(); - LOG_INFO(LogTag::OTA, "Updater: current=%s latest=%s -> %s (fs=%s)", - FIRMWARE_VERSION, g_target.latest.c_str(), - avail ? "UPDATE AVAILABLE" : "up to date", - fsAvail ? "yes" : "no"); + LOG_INFO(LogTag::OTA, "Updater: app=%s fs=%s latest=%s -> %s (app=%s fs=%s)", + FIRMWARE_VERSION, fsInstalled.length() ? fsInstalled.c_str() : "(unstamped)", + g_target.latest.c_str(), avail ? "UPDATE AVAILABLE" : "up to date", + appBehind ? "yes" : "no", fsBehind ? "yes" : "no"); return true; } @@ -447,6 +473,64 @@ void doApplyFs() { ESP.restart(); } +// ── Apply: atomic (filesystem + firmware) ──────────────────────────────────── +// The normal update path. Flashes only the images that are actually behind β€” +// filesystem first (single partition, in-place), then firmware (inactive A/B +// slot) β€” and reboots ONCE at the end so the two images move together. On any +// failure it stops and reports; a later check re-detects whatever is still +// behind (the fs stamp makes a half-finished update self-healing), so the user +// just retries. Order matters: if the app flash fails after the fs succeeded, +// we boot the old app on the new UI (tolerated); we never boot a new app on an +// old UI. +void doApplyBoth() { + if (!g_target.available) { + setError("No update available (run check first)"); + return; + } + LOG_INFO(LogTag::OTA, "Updater: applying update %s (fs=%s app=%s)", + g_target.latest.c_str(), + (g_target.fsBehind && g_target.hasFs) ? "yes" : "no", + g_target.appBehind ? "yes" : "no"); + + String err; + bool fsFlashed = false; + if (g_target.fsBehind && g_target.hasFs) { + if (!downloadVerifyFlash(g_target.fsUrl, g_target.fsSha, g_target.fsSize, + U_SPIFFS, "fs", err)) { + // fs partition may be partially written; stay up (no reboot) so the + // user can retry, matching doApplyFs's documented behavior. + setError(err); + return; + } + fsFlashed = true; + } + if (g_target.appBehind) { + if (!downloadVerifyFlash(g_target.appUrl, g_target.appSha, g_target.appSize, + U_FLASH, "app", err)) { + setError(err); + // If the fs was already flashed, the new UI is committed and live + // under a now-stale mount β€” we MUST reboot so it mounts cleanly. We + // come back on the OLD app (its boot slot was never switched) serving + // the NEW UI, which is the tolerated direction; the next check sees + // appBehind and re-offers the firmware (self-heal). Without the fs + // flash there's nothing destructive to recover from β€” just report. + if (fsFlashed) { + LOG_INFO(LogTag::OTA, + "Updater: app flash failed after fs; rebooting to mount new fs (firmware re-offered next check)"); + setPhase(UpdatePhase::Rebooting, "", 100); + delay(1500); + ESP.restart(); + } + return; + } + } + + setPhase(UpdatePhase::Rebooting, "", 100); + LOG_INFO(LogTag::OTA, "Updater: update complete, rebooting"); + delay(1500); // let the polling UI observe the "rebooting" status + ESP.restart(); +} + void workerTask(void*) { Cmd cmd; for (;;) { @@ -454,9 +538,10 @@ void workerTask(void*) { String err; switch (cmd) { - case Cmd::Check: if (!doCheck(err)) setError(err); break; - case Cmd::ApplyApp: doApplyApp(); break; // sets its own error / reboots - case Cmd::ApplyFs: doApplyFs(); break; // sets its own error / reboots + case Cmd::Check: if (!doCheck(err)) setError(err); break; + case Cmd::ApplyBoth: doApplyBoth(); break; // sets its own error / reboots + case Cmd::ApplyApp: doApplyApp(); break; // sets its own error / reboots + case Cmd::ApplyFs: doApplyFs(); break; // sets its own error / reboots } g_busy.store(false); } @@ -488,6 +573,16 @@ bool requestUpdateCheck() { return true; } +bool requestUpdate() { + if (!g_cmdQueue) return false; + if (!g_target.available) return false; + bool expected = false; + if (!g_busy.compare_exchange_strong(expected, true)) return false; + Cmd c = Cmd::ApplyBoth; + if (xQueueSend(g_cmdQueue, &c, 0) != pdTRUE) { g_busy.store(false); return false; } + return true; +} + bool requestAppUpdate() { if (!g_cmdQueue) return false; if (!g_target.available) return false; diff --git a/src/network/updater.h b/src/network/updater.h index 4cbf378..1d6e8e4 100644 --- a/src/network/updater.h +++ b/src/network/updater.h @@ -29,13 +29,16 @@ const char* updatePhaseName(UpdatePhase p); // worker's mutation of the live struct. struct UpdateStatus { UpdatePhase phase = UpdatePhase::Idle; - char current[24] = {0}; // running firmware version + char current[24] = {0}; // running firmware (app) version + char fsVersion[24] = {0}; // installed filesystem version (from /fsver; "" if unstamped) char latest[24] = {0}; // latest published version (after a check) - // A single check reports availability for BOTH images from one manifest. - // The apply step, by contrast, is split into two independent operations. - bool updateAvailable = false; // == appAvailable (kept for compatibility) - bool appAvailable = false; // newer firmware image is published - bool fsAvailable = false; // newer filesystem image is published + // A single check reports whether EITHER image is behind the latest release. + // updateAvailable is true if the app OR the filesystem needs updating, so a + // device whose two images have drifted (e.g. an interrupted update, or a + // legacy filesystem with no version stamp) still self-heals on the next check. + bool updateAvailable = false; // app OR fs behind + bool appAvailable = false; // running firmware is behind the latest + bool fsAvailable = false; // installed filesystem is behind the latest char notes[192] = {0}; // release notes (truncated) uint8_t percent = 0; // 0..100 within the active phase char stage[8] = {0}; // which target is in progress: "app"|"fs"|"" @@ -50,14 +53,21 @@ void initUpdater(); // One check populates both appAvailable and fsAvailable from the manifest. bool requestUpdateCheck(); +// Apply the update ATOMICALLY: flash whichever images are behind (filesystem +// first, then firmware) and reboot ONCE. This is the normal update path β€” the +// two images are versioned together as one release, so they're always updated +// together and can never drift into a half-updated state. Returns false if the +// worker is busy or nothing is available. +bool requestUpdate(); + // Apply the firmware (app-slot) image ONLY: download β†’ verify β†’ flash the -// inactive OTA slot β†’ reboot. A/B protected; a failure never bricks. Returns -// false if the worker is busy or no firmware update is available. +// inactive OTA slot β†’ reboot. Low-level recovery/debug path; the UI uses the +// atomic requestUpdate() instead. A/B protected; a failure never bricks. bool requestAppUpdate(); // Apply the filesystem (LittleFS) image ONLY: download β†’ verify β†’ flash the FS -// partition β†’ reboot. Independent of the firmware update β€” flashing one never -// triggers the other. Returns false if busy or no FS update is available. +// partition β†’ reboot. Low-level recovery/debug path; the UI uses the atomic +// requestUpdate() instead. Returns false if busy or no FS update is available. bool requestFsUpdate(); // Thread-safe snapshot of the current updater status. diff --git a/ui-concepts/_engine/engine.js b/ui-concepts/_engine/engine.js index 891e7b2..0b13fe3 100644 --- a/ui-concepts/_engine/engine.js +++ b/ui-concepts/_engine/engine.js @@ -883,7 +883,10 @@ }); } - // Two independent apply actions (mirror `pio run -t upload` / `-t uploadfs`). + // The normal path: one atomic update that flashes whatever is behind + // (filesystem + firmware) and reboots once β€” the device can't half-update. + function applyUpdate(onProgress) { return applyTarget("/api/firmware/update", onProgress); } + // Low-level per-image applies, kept for recovery/debug. function updateFirmware(onProgress) { return applyTarget("/api/firmware/update/app", onProgress); } function updateWebUi(onProgress) { return applyTarget("/api/firmware/update/fs", onProgress); } @@ -921,6 +924,7 @@ getWarmth: getWarmth, setWarmth: setWarmth, checkFirmware: checkFirmware, + applyUpdate: applyUpdate, updateFirmware: updateFirmware, updateWebUi: updateWebUi, firmwareStatus: firmwareStatus, diff --git a/ui-concepts/console-euclid-live/app.js b/ui-concepts/console-euclid-live/app.js index 87d1210..5efe113 100644 --- a/ui-concepts/console-euclid-live/app.js +++ b/ui-concepts/console-euclid-live/app.js @@ -1062,18 +1062,18 @@ $("#sacnUniverse").addEventListener("change", (e) => { }); }); -// OTA: real pull-based update, with the firmware and the web UI (filesystem) as -// two INDEPENDENT actions (mirroring `pio run -t upload` vs `-t uploadfs`). One -// unified check reveals what's available; each action then runs its own -// confirm β†’ progress β†’ reboot. The device does the work asynchronously (engine -// polls /api/firmware/status); this just drives the buttons/status/progress. +// OTA: real pull-based update. Firmware and web UI (filesystem) are one +// versioned release and update ATOMICALLY β€” a single check reveals whether +// anything is behind, and one "Install Update" flashes whatever's stale (fs + +// app) and reboots once. No half-updates. The device does the work +// asynchronously (engine polls /api/firmware/status); this drives the button, +// status text, and progress bar. (function wireOta() { const btn = $("#otaBtn"); if (!btn) return; const statusEl = $("#otaStatus"); const actions = $("#otaActions"); - const btnApp = $("#otaBtnApp"); - const btnFs = $("#otaBtnFs"); + const btnUpdate = $("#otaBtnUpdate"); const wrap = $("#otaProgressWrap"); const fill = $("#otaProgressFill"); let busy = false; @@ -1085,29 +1085,7 @@ $("#sacnUniverse").addEventListener("change", (e) => { function showActions(show) { if (actions) actions.style.display = show ? "flex" : "none"; } function setBusy(on) { busy = on; - btn.disabled = on; if (btnApp) btnApp.disabled = on; if (btnFs) btnFs.disabled = on; - } - - // Run one independent apply action (app or fs). `apply` is the engine method. - function runApply(kind, apply, label) { - setBusy(true); - if (statusEl) statusEl.textContent = "Installing " + label + "…"; - setProgress(0); - apply((st) => { - if (!st) return; - if (st.percent != null) setProgress(st.percent); - if (statusEl) statusEl.textContent = "Installing " + label + - (st.stage ? " (" + st.stage + ")" : "") + "… " + (st.percent || 0) + "%"; - }).then((final) => { - if (!final || final.phase === "rebooting") { - if (statusEl) statusEl.textContent = label + " installed β€” device rebooting. Reload in ~30 s."; - setProgress(100); - showToast(label + " updated β€” rebooting"); - } else { - if (statusEl) statusEl.textContent = label + " update failed" + (final.error ? ": " + final.error : ""); - setProgress(null); setBusy(false); - } - }); + btn.disabled = on; if (btnUpdate) btnUpdate.disabled = on; } btn.addEventListener("click", () => { @@ -1123,32 +1101,40 @@ $("#sacnUniverse").addEventListener("change", (e) => { if (statusEl) statusEl.textContent = "Check failed" + (s && s.error ? ": " + s.error : ""); return; } - if (!s.appAvailable && !s.fsAvailable) { + if (!s.updateAvailable) { if (statusEl) statusEl.textContent = "Up to date (v" + (s.current || "?") + "). Last checked just now."; return; } if (statusEl) statusEl.textContent = "Update available: v" + s.latest + - (s.notes ? " β€” " + s.notes : "") + ". Choose what to install."; - if (btnApp) btnApp.disabled = !s.appAvailable; - if (btnFs) btnFs.disabled = !s.fsAvailable; + (s.notes ? " β€” " + s.notes : "") + "."; showActions(true); }); }); - if (btnApp) btnApp.addEventListener("click", () => { - if (busy || btnApp.disabled) return; - if (!window.confirm("Update the device FIRMWARE now?\n\nThe device will reboot and be " + - "briefly offline. Firmware updates are A/B-protected β€” a failed download can't brick it. " + - "Do NOT power it off during the update.")) return; - runApply("app", engine.updateFirmware, "Firmware"); - }); + if (btnUpdate) btnUpdate.addEventListener("click", () => { + if (busy || btnUpdate.disabled) return; + if (!window.confirm("Install the update now?\n\nThis flashes the firmware and web UI together, then " + + "the device reboots and is briefly offline. Firmware is A/B-protected β€” a failed download can't " + + "brick it. Do NOT power it off during the update.")) return; - if (btnFs) btnFs.addEventListener("click", () => { - if (busy || btnFs.disabled) return; - if (!window.confirm("Update the WEB UI (filesystem) now?\n\nThe device will reboot and be " + - "briefly offline. If interrupted, the UI may need re-flashing (recoverable). " + - "Do NOT power it off during the update.")) return; - runApply("fs", engine.updateWebUi, "Web UI"); + setBusy(true); + if (statusEl) statusEl.textContent = "Installing update…"; + setProgress(0); + engine.applyUpdate((st) => { + if (!st) return; + if (st.percent != null) setProgress(st.percent); + if (statusEl) statusEl.textContent = "Installing update" + + (st.stage ? " (" + st.stage + ")" : "") + "… " + (st.percent || 0) + "%"; + }).then((final) => { + if (!final || final.phase === "rebooting") { + if (statusEl) statusEl.textContent = "Update installed β€” device rebooting. Reload in ~30 s."; + setProgress(100); + showToast("Update installed β€” rebooting"); + } else { + if (statusEl) statusEl.textContent = "Update failed" + (final.error ? ": " + final.error : ""); + setProgress(null); setBusy(false); + } + }); }); })(); diff --git a/ui-concepts/console-euclid-live/index.html b/ui-concepts/console-euclid-live/index.html index b55d4d2..2a42114 100644 --- a/ui-concepts/console-euclid-live/index.html +++ b/ui-concepts/console-euclid-live/index.html @@ -493,8 +493,7 @@