Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ src/secrets.h

# generated at buildfs/uploadfs/CI — not source
data/**/*.gz
data/fsver
15 changes: 15 additions & 0 deletions scripts/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
18 changes: 17 additions & 1 deletion src/api/firmware.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand Down
19 changes: 11 additions & 8 deletions src/api/firmware.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
5 changes: 3 additions & 2 deletions src/network/server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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

// ===========================================================================

Expand Down
137 changes: 116 additions & 21 deletions src/network/updater.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <Update.h>
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
Expand Down Expand Up @@ -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;
Expand All @@ -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) ──────────────────────────────────────────────────
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Comment on lines +420 to +423
return true;
}

Expand Down Expand Up @@ -447,16 +473,75 @@ 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 (;;) {
if (xQueueReceive(g_cmdQueue, &cmd, portMAX_DELAY) != pdTRUE) continue;

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);
}
Expand Down Expand Up @@ -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;
Expand Down
30 changes: 20 additions & 10 deletions src/network/updater.h
Original file line number Diff line number Diff line change
Expand Up @@ -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"|""
Expand All @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion ui-concepts/_engine/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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); }

Expand Down Expand Up @@ -921,6 +924,7 @@
getWarmth: getWarmth,
setWarmth: setWarmth,
checkFirmware: checkFirmware,
applyUpdate: applyUpdate,
updateFirmware: updateFirmware,
updateWebUi: updateWebUi,
firmwareStatus: firmwareStatus,
Expand Down
Loading
Loading