Skip to content

feat(ota): atomic update + filesystem version self-heal (kill the split-update footgun) - #38

Merged
bring42 merged 2 commits into
mainfrom
claude/ota-atomic-self-heal
Jul 24, 2026
Merged

feat(ota): atomic update + filesystem version self-heal (kill the split-update footgun)#38
bring42 merged 2 commits into
mainfrom
claude/ota-atomic-self-heal

Conversation

@bring42

@bring42 bring42 commented Jul 24, 2026

Copy link
Copy Markdown
Owner

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.py stamps the LittleFS image with its version at build → data/fsver (gitignored; packed into littlefs.bin).
  • The updater reads /fsver and computes appBehind / fsBehind independently; availability is appBehind || fsBehind.
  • A legacy image with no stamp reads as behind, so any device that only got a firmware update re-detects its stale UI on the next check and offers the update automatically — no manual flash. (This is what rescues the currently-stuck device once it's on this firmware.)

Atomic apply (can't half-update):

  • New requestUpdate() / POST /api/firmware/update flashes whatever's behind — filesystem first, then firmware — and reboots once. The two images move together and can never drift again.
  • Console UI collapses the two buttons into a single "Install Update."
  • Per-image requestAppUpdate/requestFsUpdate + /update/app,/update/fs remain for recovery/debug (now safe, since drift self-heals).

Verification

  • pio run -e seeed_xiao_esp32c3 compiles (90.2% flash).
  • 65 native tests pass.
  • data/fsver stamp confirmed written + gitignored + not tracked.

Notes / follow-ups

  • Can't hardware-test flashing from here — the download/verify/flash path is unchanged from the working per-image code; only sequencing + availability changed.
  • Euclid skin still uses the per-image endpoints (unchanged, still functional). Collapsing it can wait on the parked "euclid keep/cut/finish" decision.
  • The interrupted-update case (power loss mid-flash) is now also covered by self-heal.

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings July 24, 2026 10:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/update apply 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 thread src/network/updater.cpp
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>
@bring42
bring42 merged commit f84302f into main Jul 24, 2026
1 check passed
@bring42
bring42 deleted the claude/ota-atomic-self-heal branch July 24, 2026 10:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants