feat(ota): atomic update + filesystem version self-heal (kill the split-update footgun) - #38
Merged
Merged
Conversation
Kills the split-update footgun: updating firmware alone would bump the running version to match the manifest, which flipped fsAvailable false and stranded the web UI at the old version with no way to pull it. Root cause: availability was isNewer(manifest, FIRMWARE_VERSION) with fsAvailable = that && hasFs — the device never knew its *filesystem* version, only the firmware's. Fix (two parts): - Self-heal: stamp the LittleFS image with its version at build (scripts/version.py -> data/fsver, gitignored) and read it back in the updater. Availability is now (appBehind || fsBehind), each compared independently. A legacy image with no stamp reads as behind, so a device that only got the firmware update re-detects its stale UI on the next check and offers the update automatically — no manual flash. - Atomic apply: new requestUpdate()/POST /api/firmware/update flashes whatever is behind (filesystem first, then firmware) and reboots ONCE, so the two images can never drift again. The console UI now shows a single 'Install Update' button. Per-image endpoints/functions remain for recovery/debug. Firmware compiles (seeed_xiao_esp32c3, 90.2% flash); 65 native tests pass. Euclid skin still uses the per-image endpoints (unchanged, still work). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes the OTA “split-update” failure mode by teaching the device to track the LittleFS (web UI) version independently from the firmware version, and by adding a single atomic update path that updates whichever image(s) are behind with one reboot.
Changes:
- Stamp LittleFS builds with a filesystem version (
/fsver) and report it via/api/firmware/status, so update availability is computed independently for firmware vs filesystem. - Add an atomic
POST /api/firmware/updateapply path (fs-first, then firmware; one reboot) and wire the console UI to a single “Install Update” action. - Keep the per-image update endpoints for recovery/debug, while shifting the primary UI flow to the atomic endpoint.
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| ui-concepts/console-euclid-live/index.html | Collapses OTA actions to a single “Install Update” button. |
| ui-concepts/console-euclid-live/app.js | Updates console OTA flow to use updateAvailable + a single atomic apply action. |
| ui-concepts/_engine/engine.js | Adds applyUpdate() calling the new atomic /api/firmware/update endpoint. |
| src/network/updater.h | Extends UpdateStatus with fsVersion and documents atomic update API. |
| src/network/updater.cpp | Reads /fsver, computes appBehind/fsBehind, implements atomic apply (fs→app, one reboot). |
| src/network/server.cpp | Registers the new /api/firmware/update endpoint (atomic path). |
| src/api/firmware.h | Documents the new atomic update endpoint and clarifies per-image endpoints as recovery. |
| src/api/firmware.cpp | Serializes fsVersion and adds handler for POST /api/firmware/update. |
| scripts/version.py | Writes data/fsver during device builds to stamp the LittleFS image version. |
| .gitignore | Ignores the generated data/fsver artifact. |
Comments suppressed due to low confidence (2)
src/network/updater.cpp:474
- doApplyFs() currently gates on g_target.available (appBehind || fsBehind). If only the firmware is behind, this condition still passes and the function will flash the filesystem even though it’s already up to date. Gate on fsBehind (and hasFs) to keep the per-image recovery path correct under the new split-version logic.
ESP.restart();
}
src/network/updater.cpp:571
- requestAppUpdate()/requestFsUpdate() still gate on g_target.available, which is now (appBehind || fsBehind). That means the per-image endpoints can return true even when the requested image is already current (e.g., fsBehind=true allows requestAppUpdate). Gate each function on its specific behind-flag to preserve the documented API contract and avoid unnecessary flashing.
bool requestAppUpdate() {
if (!g_cmdQueue) return false;
if (!g_target.available) return false;
Comment on lines
+420
to
+423
| 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"); |
…en fails Review finding: in the atomic path, fs is flashed in-place before the app. If fs succeeded but the app download/flash then failed, doApplyBoth returned without rebooting — leaving the old app running against a freshly-overwritten, now-inconsistent LittleFS mount (and updaterInProgress() went false, so the server resumed serving from it). Now: if the fs was flashed, reboot regardless of the app outcome. We come back on the old app (boot slot never switched) serving the new UI (the tolerated direction); the next check sees appBehind and re-offers the firmware. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The footgun
Update firmware first, and the device's running version now matches the manifest →
isNewer(manifest, FIRMWARE_VERSION)goes false →fsAvailable(which was gated on it) goes false too → "Up to date," web UI stranded at the old version with no way to pull it. Hit in real use after the v1.2.0 update.Root cause: the device only knew its firmware version, never its filesystem version.
Fix — atomic + self-heal (chosen scope)
Self-heal (know the FS version):
scripts/version.pystamps the LittleFS image with its version at build →data/fsver(gitignored; packed intolittlefs.bin)./fsverand computesappBehind/fsBehindindependently; availability isappBehind || fsBehind.Atomic apply (can't half-update):
requestUpdate()/POST /api/firmware/updateflashes whatever's behind — filesystem first, then firmware — and reboots once. The two images move together and can never drift again.requestAppUpdate/requestFsUpdate+/update/app,/update/fsremain for recovery/debug (now safe, since drift self-heals).Verification
pio run -e seeed_xiao_esp32c3compiles (90.2% flash).data/fsverstamp confirmed written + gitignored + not tracked.Notes / follow-ups
🤖 Generated with Claude Code