Skip to content

Automatic updates: a per-frame channel (off / stable / latest), stable by default with a 24 h stability window - #457

Closed
mariusandra wants to merge 10 commits into
mainfrom
auto-update-toggle
Closed

Automatic updates: a per-frame channel (off / stable / latest), stable by default with a 24 h stability window#457
mariusandra wants to merge 10 commits into
mainfrom
auto-update-toggle

Conversation

@mariusandra

@mariusandra mariusandra commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Why

Only one plane updated itself: the ESP32 firmware ran a 24 h OTA poll unconditionally on backend-managed frames, while the Pi runtime, the cloud-managed ESP32 and the cloud only ever acted on a button or a notify_update_available nudge. That was inconsistent, and "always install the newest release the moment it lands" is also the wrong default when releases ship often and carry bugs.

This PR replaces it with one explicit per-frame channel, auto_update, that means the same thing on every plane:

  • stable (default): once a day the device checks its own signed release channel and installs the release only once it has been the latest for 24 hours. A fix published within that day replaces it as latest and resets the clock, so the release the fix was for is never installed.
  • latest: install every release as soon as it is published.
  • off: never check. The manual paths (the Upgrade button, the console's ota, notify_update_available) work on every channel.

A provider can only pick the channel, never name a release or a URL, so the signed-OTA doctrine is unchanged: the device fetches and verifies every image itself.

Which release a frame follows

  • Frame with a self-hosted backend (frame.json names a serverHost): the release the backend runs, never GitHub's latest. The backend deploys its own version, so a frame ahead of it would be downgraded by the next deploy and climb back the next night. The Pi asks GET /api/frameos/release (bearer = its server API key) and fetches that release by tag; the ESP32's OTA manifest and download are pinned to the same tag. A backend too old to answer (before 2026.9.11) is an error, never a fallback to latest.
  • Cloud-managed or standalone frame: GitHub's latest (the cloud is always on main, so its manifest relays latest).

What

  • Contractauto_update ("off" | "stable" | "latest") on both profiles from 2026.9.11, fixtures, regenerated Nim/C/TS tables.
  • Pi runtimeautoUpdate in frame.json (absent/true → stable, false → off); new frameos/src/frameos/auto_updater.nim thread: daily at 04:20–04:59 local (minute hashed per frame, never the first twenty minutes of the hour because the backend's reboot cron fires on the hour), resolveFrameOSRelease() (backend pin, else latest), releaseQualifies() on the release's published_at, then the same detached scheduleFrameOSUpgrade() the admin button and the cloud nudge use, pinned to the qualified version. Skips with a logged frameos:auto_update reason on compiled scenes (any compiled-in scene other than default), an unversioned binary, an unsupported target, or a release still inside the stability window. Re-reads the channel every pass. frameos upgrade --version=X pins a release.
  • ESP32fos_auto_update_t; the periodic OTA task is started only by fos_ota_sync_periodic_task() when the channel is not off and re-checks it every tick. The channel rides NVS, the backend settings poll (autoUpdate), the cloud set_settings verb, the console (set auto_update off|stable|latest), the local HTTP API and the status JSON. The manifest's new publishedAt gates the periodic run on stable; an unknown publish time or an unsynced clock waits. The periodic check falls back to the cloud manifest when no backend is configured, so the channel works on cloud-only frames too.
  • Backendframe.auto_update String column + migration (NULL = stable), normalized on the way in and out, schema validator (booleans still map), sync label, backup restore list, ESP32 settings pull and provisioning line, the SD-image stub frame. frame.json carries "off" for frames that cannot take a generic release (frame_can_auto_update: rpios/buildroot must be on the precompiled build with no compiled scenes). GET /api/frameos/release; the OTA manifest/download pinned to the backend's release and carrying publishedAt.
  • Cloud — the firmware manifest route relays publishedAt (null for a dev image); floor pairs in frames.ts; MCP frame_settings_update description.
  • SPA — a three-way Select in a new Updates section on backend and on-device frames, and in the cloud settings block. It renders disabled with the reason on compiled/source-built Pis, below the firmware floor, or on an ESP32 layout without an OTA slot. Docs updated on both cloud-frames.md pages and the ESP32 README.

Behaviour change to note in release notes

Every frame is on stable once it runs this release: Pi/Buildroot frames on the precompiled build start checking daily, ESP32 frames poll their control plane daily. Backend-managed frames follow their backend's version, so updating the backend is what moves them. Set the channel to off on a frame that must not move.

Verified locally (on the rebased branch)

  • Nim: full runtime type-checks; config, upgrade, hub verb, contract fixture and the new test_auto_updater tests pass.
  • ESP32: firmware builds; contract walker host test passes the fixtures.
  • Backend: 258 tests across the touched files pass (the wasm render test fails on a missing local fixture, unrelated).
  • Cloud: 104 unit tests incl. the firmware route; the firmware-manifest and frames integration tests pass against the test database; frontend tsc clean.

Rebased on main; CI's snapshot commit was dropped so the next run refreshes the visual snapshots against the new base.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WdmCbU4h6hRRaciZ1yxnNX

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

ESP32 firmware size

Built from ea47bd1 by this run; compared with the latest release v2026.9.11.

Image This PR v2026.9.11 Δ
esp32-s3 app (OTA image) 3,158,832
87.6% of 3520K slot, 435 KB free
3,157,552 +1,280 (+0.0%)
esp32-s3 merged flash image 3,224,368 3,223,088 +1,280 (+0.0%)
esp32-s3-32mb app (OTA image) 3,158,832
76.5% of 4032K slot, 947 KB free
3,157,536 +1,296 (+0.0%)
esp32-s3-32mb merged flash image 3,289,904 3,288,608 +1,296 (+0.0%)

Breakdown by subsystem — esp32-s3

Subsystem This PR v2026.9.11 Δ
FrameOS core (Nim) 404,278 404,262 +16 (+0.0%)
QuickJS 356,328 356,328 ±0
FrameOS apps (Nim) 317,919 317,919 ±0
pixie 301,065 301,065 ±0
Wi-Fi stack 295,300 295,300 ±0
Nim stdlib 248,937 248,937 ±0
ESP-IDF misc 178,045 178,045 ±0
Embedded font 146,358 146,358 ±0
mbedTLS + certificates 132,539 132,539 ±0
String pool (attributed to efuse) 132,195 131,977 +218 (+0.2%)
lwIP / HTTP / WebSocket 126,594 126,594 ±0
fos_* firmware shell (C) 116,957 115,920 +1,037 (+0.9%)
libc / libm / newlib 116,259 116,259 ±0
Storage (SPIFFS / FatFS / SD / NVS) 94,136 94,136 ±0
Nim packages (chrono, zippy, chroma, qrgen, ...) 72,031 72,031 ±0
Display drivers (C) 63,416 63,416 ±0
Crypto (monocypher) 35,149 35,149 ±0
Total mapped flash 3,137,506 3,136,235 +1,271 (+0.0%)
Inside each subsystem

FrameOS core (Nim) — 404,278

Part This PR v2026.9.11 Δ
frameos/js_runtime 102,114 102,114 ±0
frameos/interpreter.nim 97,352 97,352 ±0
frameos/utils 89,574 89,574 ±0
embedded_main.nim 18,347 18,347 ±0
lib/tz.nim 16,875 16,875 ±0
embedded_runtime.nim 15,056 15,056 ±0
frameos/planner.nim 9,605 9,605 ±0
frameos_nim_glue.c.obj 9,569 9,569 ±0
frameos/types.nim 7,909 7,893 +16 (+0.2%)
frameos/values.nim 6,901 6,901 ±0
frameos/apps.nim 6,046 6,046 ±0
frameos/spool.nim 5,678 5,678 ±0
embedded_scene.nim 4,945 4,945 ±0
frameos/app_config.nim 2,871 2,871 ±0
frameos/reboot_reason.nim 2,628 2,628 ±0
frameos/node_config.nim 2,590 2,590 ±0
frameos/app_capabilities.nim 2,493 2,493 ±0
fos_netguard.c.obj 2,092 2,092 ±0
frameos/runtime_diagnostics.nim 763 763 ±0
fos_version.c.obj 324 324 ±0
frameos/cloud 255 255 ±0
frameos/ids.nim 133 133 ±0
frameos/channels.nim 118 118 ±0
frameos/hal 40 40 ±0

QuickJS — 356,328

Part This PR v2026.9.11 Δ
quickjs.c.obj 283,813 283,813 ±0
libunicode.c.obj 50,174 50,174 ±0
libregexp.c.obj 13,995 13,995 ±0
dtoa.c.obj 5,681 5,681 ±0
cutils.c.obj 2,141 2,141 ±0
fos_qjs_glue.c.obj 449 449 ±0
fos_quickjs_tz.c.obj 75 75 ±0

FrameOS apps (Nim) — 317,919

Part This PR v2026.9.11 Δ
apps/data/icalJson 50,265 50,265 ±0
apps/render/calendar 30,666 30,666 ±0
apps/render/chart 21,541 21,541 ±0
apps/data/immich 17,538 17,538 ±0
apps/data/wikicommons 17,114 17,114 ±0
apps/data/openaiImage 13,926 13,926 ±0
apps/data/unsplash 11,056 11,056 ±0
apps/data/beRecycle 10,749 10,749 ±0
apps/data/weather 10,210 10,210 ±0
apps/render/split 9,648 9,648 ±0
apps/data/localImage 9,190 9,190 ±0
apps/data/eventsToAgenda 9,088 9,088 ±0
apps/data/googlePhotos 9,068 9,068 ±0
apps/apps.nim 8,636 8,636 ±0
apps/render/zoomPan 8,552 8,552 ±0
apps/data/openaiText 7,353 7,353 ±0
apps/data/haSensor 7,291 7,291 ±0
apps/data/xmlToJson 5,420 5,420 ±0
apps/render/text 5,415 5,415 ±0
apps/render/svg 5,176 5,176 ±0
apps/render/image 4,510 4,510 ±0
apps/data/rotateImage 3,457 3,457 ±0
apps/logic/setAsState 3,441 3,441 ±0
apps/data/downloadImage 3,333 3,333 ±0
apps/render/gradient 3,173 3,173 ±0
apps/data/qr 3,114 3,114 ±0
apps/data/downloadUrl 3,065 3,065 ±0
apps/data/frameOSGallery 3,006 3,006 ±0
apps/render/color 2,804 2,804 ±0
apps/data/newImage 2,636 2,636 ±0
apps/data/resizeImage 2,534 2,534 ±0
apps/data/log 2,283 2,283 ±0
apps/render/opacity 2,272 2,272 ±0
apps/data/prettyJson 2,213 2,213 ±0
apps/data/parseJson 1,844 1,844 ±0
apps/data/clock 1,839 1,839 ±0
apps/logic/nextSleepDuration 1,639 1,639 ±0
apps/logic/ifElse 1,476 1,476 ±0
apps/logic/breakIfRendering 1,378 1,378 ±0

pixie — 301,065

Part This PR v2026.9.11 Δ
pixie/fontformats/opentype.nim 55,507 55,507 ±0
pixie/fileformats/webp.nim 37,525 37,525 ±0
pixie/fileformats/svg.nim 37,203 37,203 ±0
pixie/paths.nim 32,570 32,570 ±0
pixie/fileformats/jpeg.nim 25,338 25,338 ±0
pixie/fileformats/png.nim 22,849 22,849 ±0
pixie/images.nim 15,787 15,787 ±0
pixie/fonts.nim 11,019 11,019 ±0
pixie/paints.nim 9,889 9,889 ±0
pixie/fileformats/bmp.nim 8,234 8,234 ±0
pixie/common.nim 7,416 7,416 ±0
pixie/blends.nim 6,540 6,540 ±0
pixie/fileformats/gif.nim 5,929 5,929 ±0
pixie/inflatestream.nim 5,325 5,325 ±0
pixie.nim 5,248 5,248 ±0
pixie/fileformats/webp_vp8_tables.nim 3,881 3,881 ±0
pixie/fileformats/ppm.nim 3,681 3,681 ±0
pixie/contexts.nim 3,038 3,038 ±0
pixie/fileformats/qoi.nim 2,686 2,686 ±0
pixie/internal.nim 754 754 ±0
pixie/rgb565.nim 381 381 ±0
pixie/fontformats/svgfont.nim 215 215 ±0
pixie/decodebudget.nim 50 50 ±0

Wi-Fi stack — 295,300

Part This PR v2026.9.11 Δ
libnet80211.a 136,168 136,168 ±0
libpp.a 62,146 62,146 ±0
libwpa_supplicant.a 54,900 54,900 ±0
libphy.a 34,176 34,176 ±0
libesp_wifi.a 4,788 4,788 ±0
libesp_phy.a 2,541 2,541 ±0
libesp_coex.a 291 291 ±0
libcore.a 287 287 ±0
libespnow.a 3 3 ±0

Nim stdlib — 248,937

Part This PR v2026.9.11 Δ
pure/collections/tables.nim 70,556 70,556 ±0
system.nim 46,665 46,665 ±0
pure/unicode.nim 17,405 17,405 ±0
pure/times.nim 16,984 16,984 ±0
pure/json.nim 13,414 13,414 ±0
std/private/dragonbox.nim 10,351 10,351 ±0
pure/strutils.nim 8,473 8,473 ±0
pure/parsexml.nim 5,975 5,975 ±0
pure/algorithm.nim 5,368 5,368 ±0
pure/base64.nim 5,302 5,302 ±0
pure/hashes.nim 4,114 4,114 ±0
pure/xmlparser.nim 3,567 3,567 ±0
pure/parsejson.nim 3,553 3,553 ±0
std/formatfloat.nim 3,508 3,508 ±0
pure/uri.nim 3,225 3,225 ±0
pure/collections/sets.nim 3,211 3,211 ±0
pure/streams.nim 2,820 2,820 ±0
pure/strformat.nim 2,641 2,641 ±0
std/syncio.nim 2,355 2,355 ±0
pure/xmltree.nim 2,051 2,051 ±0
pure/lexbase.nim 1,805 1,805 ±0
pure/parseutils.nim 1,757 1,757 ±0
system/exceptions.nim 1,598 1,598 ±0
pure/strtabs.nim 1,568 1,568 ±0
pure/pathnorm.nim 1,332 1,332 ±0
pure/options.nim 1,118 1,118 ±0
std/private/ospaths2.nim 1,032 1,032 ±0
pure/collections/lists.nim 983 983 ±0
system/dollars.nim 891 891 ±0
pure/random.nim 835 835 ±0
std/private/digitsutils.nim 651 651 ±0
pure/math.nim 650 650 ±0
std/oserrors.nim 637 637 ±0
std/private/osdirs.nim 580 580 ±0
std/monotimes.nim 401 401 ±0
std/private/oscommon.nim 329 329 ±0
std/envvars.nim 250 250 ±0
std/private/decode_helpers.nim 240 240 ±0
std/private/ossymlinks.nim 187 187 ±0
pure/bitops.nim 162 162 ±0
pure/os.nim 99 99 ±0
std/private/osfiles.nim 96 96 ±0
std/assertions.nim 87 87 ±0
core/macros.nim 69 69 ±0
system/iterators.nim 33 33 ±0
system/ctypes.nim 9 9 ±0

ESP-IDF misc — 178,045

Part This PR v2026.9.11 Δ
libesp_hw_support.a 37,650 37,650 ±0
libhal.a 21,464 21,464 ±0
libfreertos.a 17,194 17,194 ±0
libesp_system.a 13,780 13,780 ±0
libesp_driver_uart.a 11,627 11,627 ±0
libheap.a 8,656 8,656 ±0
libesp_driver_i2c.a 8,062 8,062 ±0
libesp_driver_spi.a 7,756 7,756 ±0
libconsole.a 5,733 5,733 ±0
libjson.a 4,940 4,940 ±0
libesp_driver_gpio.a 3,890 3,890 ±0
libbootloader_support.a 3,813 3,813 ±0
libxtensa.a 3,691 3,691 ±0
libesp_driver_usb_serial_jtag.a 3,408 3,408 ±0
libesp_ringbuf.a 3,322 3,322 ±0
libesp_event.a 3,241 3,241 ±0
libesp_mm.a 2,451 2,451 ±0
libapp_update.a 2,422 2,422 ±0
libesp_psram.a 2,329 2,329 ±0
libesp_timer.a 2,326 2,326 ±0
libsoc.a 2,315 2,315 ±0
libesp_adc.a 2,289 2,289 ±0
liblog.a 1,375 1,375 ±0
libpthread.a 1,365 1,365 ±0
libesp_security.a 1,050 1,050 ±0
libesp_rom.a 902 902 ±0
libesp_app_format.a 507 507 ±0
libxt_hal.a 437 437 ±0
libesp_common.a 26 26 ±0
libesp_pm.a 24 24 ±0

Embedded font — 146,358

Part This PR v2026.9.11 Δ
assets/fonts.nim 146,358 146,358 ±0

mbedTLS + certificates — 132,539

Part This PR v2026.9.11 Δ
libmbedcrypto.a 71,391 71,391 ±0
libmbedtls.a 45,803 45,803 ±0
libmbedx509.a 7,084 7,084 ±0
libesp-tls.a 6,709 6,709 ±0
libesp_https_server.a 1,552 1,552 ±0

String pool (attributed to efuse) — 132,195

Part This PR v2026.9.11 Δ
esp_efuse_utility.c.obj 131,149 130,931 +218 (+0.2%)
esp_efuse_rtc_calib.c.obj 566 566 ±0
esp_efuse_table.c.obj 268 268 ±0
esp_efuse_api.c.obj 151 151 ±0
esp_efuse_startup.c.obj 61 61 ±0

lwIP / HTTP / WebSocket — 126,594

Part This PR v2026.9.11 Δ
liblwip.a 78,301 78,301 ±0
libhttp_parser.a 10,350 10,350 ±0
libesp_http_server.a 10,016 10,016 ±0
libtcp_transport.a 8,237 8,237 ±0
libespressif__esp_websocket_client.a 6,826 6,826 ±0
libesp_http_client.a 6,620 6,620 ±0
libesp_netif.a 6,244 6,244 ±0

fos_ firmware shell (C)* — 116,957

Part This PR v2026.9.11 Δ
fos_http.c.obj 22,162 21,968 +194 (+0.9%)
fos_cloud.c.obj 17,877 17,837 +40 (+0.2%)
fos_console.c.obj 13,576 13,498 +78 (+0.6%)
fos_client.c.obj 10,483 10,483 ±0
fos_scenes.c.obj 7,459 7,459 ±0
fos_settings.c.obj 6,611 6,511 +100 (+1.5%)
fos_ota.c.obj 6,076 5,775 +301 (+5.2%)
fos_config.c.obj 6,022 5,755 +267 (+4.6%)
fos_assets.c.obj 3,282 3,282 ±0
fos_wifi.c.obj 3,201 3,201 ±0
fos_cloud_contract.c.obj 2,955 2,896 +59 (+2.0%)
fos_sd_probe.c.obj 2,753 2,753 ±0
fos_assets_sd.c.obj 2,480 2,480 ±0
fos_schedule.c.obj 2,339 2,339 ±0
fos_buttons.c.obj 2,139 2,139 ±0
fos_status_screen.c.obj 2,049 2,049 ±0
main.c.obj 1,671 1,673 -2 (-0.1%)
fos_tz.c.obj 1,381 1,381 ±0
fos_battery.c.obj 1,140 1,140 ±0
fos_framebuffer.c.obj 483 483 ±0
fos_power.c.obj 241 241 ±0
fos_battery_filter.c.obj 214 214 ±0
fos_wake.c.obj 184 184 ±0
fos_board.c.obj 179 179 ±0

libc / libm / newlib — 116,259

Part This PR v2026.9.11 Δ
libc.a 76,467 76,467 ±0
libm.a 31,563 31,563 ±0
libnewlib.a 6,609 6,609 ±0
libstdc++.a 1,476 1,476 ±0
libgcc.a 98 98 ±0
libcxx.a 46 46 ±0

Storage (SPIFFS / FatFS / SD / NVS) — 94,136

Part This PR v2026.9.11 Δ
libspiffs.a 20,380 20,380 ±0
libfatfs.a 20,133 20,133 ±0
libspi_flash.a 14,168 14,168 ±0
libnvs_flash.a 13,745 13,745 ±0
libsdmmc.a 13,013 13,013 ±0
libvfs.a 6,196 6,196 ±0
libesp_driver_sdspi.a 3,858 3,858 ±0
libesp_partition.a 1,960 1,960 ±0
libesp_vfs_console.a 678 678 ±0
libnvs_sec_provider.a 5 5 ±0

Nim packages (chrono, zippy, chroma, qrgen, ...) — 72,031

Part This PR v2026.9.11 Δ
QRgen 3.1.0 22,036 22,036 ±0
chrono 0.3.1 15,514 15,514 ±0
zippy 0.10.19 15,272 15,272 ±0
chroma 1.0.0 14,916 14,916 ±0
checksums 0.2.1 3,371 3,371 ±0
flatty 0.3.4 533 533 ±0
jsony 1.1.5 272 272 ±0
bumpy 1.1.3 117 117 ±0

Display drivers (C) — 63,416

Part This PR v2026.9.11 Δ
(everything else) 20,633 20,623 +10 (+0.0%)
frameos_panel_table.c.obj 6,685 6,685 ±0
EPD_13in3e.c.obj 1,792 1,796 -4 (-0.2%)
EPD_3in7.c.obj 1,508 1,508 ±0
EPD_5in79.c.obj 1,445 1,445 ±0
EPD_2in7.c.obj 1,259 1,259 ±0
EPD_4in2.c.obj 1,246 1,246 ±0
EPD_7in3e.c.obj 1,208 1,208 ±0
DEV_Config_esp.c.obj 1,180 1,180 ±0
EPD_4in2_V2.c.obj 1,172 1,172 ±0
DEV_Debug.c.obj 1,159 1,159 ±0
EPD_4in26.c.obj 1,110 1,110 ±0
30 smaller rows 23,019 23,025 -6 (-0.0%)

Crypto (monocypher) — 35,149

Part This PR v2026.9.11 Δ
monocypher.c.obj 31,920 31,920 ±0
monocypher-ed25519.c.obj 3,229 3,229 ±0
Biggest movers (per object)
Object Δ This PR Baseline
fos_ota.c.obj +301 6,076 5,775
fos_config.c.obj +267 6,022 5,755
esp_efuse_utility.c.obj +218 131,149 130,931
fos_http.c.obj +194 22,162 21,968
fos_settings.c.obj +100 6,611 6,511
fos_console.c.obj +78 13,576 13,498
fos_cloud.c.obj +40 17,877 17,837
frameos/types.nim +16 7,909 7,893
Largest 30 objects
Object Subsystem This PR v2026.9.11 Δ
quickjs.c.obj QuickJS 283,813 283,813 ±0
assets/fonts.nim Embedded font 146,358 146,358 ±0
esp_efuse_utility.c.obj String pool (attributed to efuse) 131,149 130,931 +218 (+0.2%)
frameos/interpreter.nim FrameOS core (Nim) 97,352 97,352 ±0
nim/lib/pure/collections/tables.nim Nim stdlib 70,556 70,556 ±0
frameos/js_runtime/app_runtime.nim FrameOS core (Nim) 61,502 61,502 ±0
pkgs/pixie-6.1.0/pixie/fontformats/opentype.nim pixie 55,507 55,507 ±0
libunicode.c.obj QuickJS 50,174 50,174 ±0
nim/lib/system.nim Nim stdlib 46,665 46,665 ±0
apps/data/icalJson/ical.nim FrameOS apps (Nim) 44,568 44,568 ±0
pkgs/pixie-6.1.0/pixie/fileformats/webp.nim pixie 37,525 37,525 ±0
pkgs/pixie-6.1.0/pixie/fileformats/svg.nim pixie 37,203 37,203 ±0
frameos/utils/image.nim FrameOS core (Nim) 34,374 34,374 ±0
pkgs/pixie-6.1.0/pixie/paths.nim pixie 32,570 32,570 ±0
monocypher.c.obj Crypto (monocypher) 31,920 31,920 ±0
frameos/js_runtime/runtime.nim FrameOS core (Nim) 30,094 30,094 ±0
pkgs/pixie-6.1.0/pixie/fileformats/jpeg.nim pixie 25,338 25,338 ±0
apps/render/calendar/app.nim FrameOS apps (Nim) 23,618 23,618 ±0
pkgs/pixie-6.1.0/pixie/fileformats/png.nim pixie 22,849 22,849 ±0
fos_http.c.obj fos_* firmware shell (C) 22,162 21,968 +194 (+0.9%)
apps/render/chart/app.nim FrameOS apps (Nim) 18,940 18,940 ±0
embedded_main.nim FrameOS core (Nim) 18,347 18,347 ±0
x509_crt_bundle.S.obj mbedTLS + certificates 17,928 17,928 ±0
frameos/utils/status_screen.nim FrameOS core (Nim) 17,923 17,923 ±0
fos_cloud.c.obj fos_* firmware shell (C) 17,877 17,837 +40 (+0.2%)
nim/lib/pure/unicode.nim Nim stdlib 17,405 17,405 ±0
nim/lib/pure/times.nim Nim stdlib 16,984 16,984 ±0
lib/tz.nim FrameOS core (Nim) 16,875 16,875 ±0
apps/data/immich/app.nim FrameOS apps (Nim) 15,991 15,991 ±0
pkgs/pixie-6.1.0/pixie/images.nim pixie 15,787 15,787 ±0

Flash = .text + .rodata from the linker map via esp_idf_size; "Inside each subsystem" splits every bucket one level down (the Nim core by directory, apps by app, packages by package, ESP-IDF by archive) over all objects, not just the largest. "String pool (attributed to efuse)" is the linker's merged string-literal pool for the whole image, not efuse code — see docs/esp32-image-size.md.

@mariusandra mariusandra changed the title Automatic updates become a per-frame switch (auto_update), off by default Automatic updates: a per-frame channel (off / stable / latest), stable by default with a 24 h stability window Sep 8, 2026
mariusandra and others added 7 commits September 8, 2026 10:01
…ess auto_update is on

Only one plane updated itself: the ESP32 firmware ran a 24 h OTA poll
unconditionally on backend-managed frames, while the Pi runtime, the
cloud-managed ESP32 and the cloud only ever acted on a button or a nudge.
One boolean now covers every plane, off by default.

- Contract: `auto_update` on both profiles from 2026.9.11 (+ fixtures,
  regenerated Nim/C/TS tables).
- Pi runtime: `autoUpdate` in frame.json; frameos/auto_updater.nim runs a
  quiet daily GitHub check (04:xx local, minute hashed per frame) and only
  then the same detached signed upgrade the admin button uses. Skips with a
  logged reason on compiled scenes, an unversioned binary, or an
  unsupported target. Re-reads the switch every pass, no restart.
- ESP32: the periodic OTA task starts only via fos_ota_sync_periodic_task()
  when the switch is on and re-checks it every tick; the key rides NVS, the
  backend poll, the cloud verb, the console and the local HTTP API. The
  periodic check now falls back to the cloud manifest on a cloud-only frame.
- Backend: frame.auto_update column + migration, schema, sync label, backup
  restore list, ESP32 settings pull and provisioning line. frame.json only
  carries the key for frames that can take a generic release
  (frame_can_auto_update).
- Cloud + SPA: floor pairs, frameDiffKeys, an Updates section on backend
  and on-device frames, a switch in the cloud settings block — disabled with
  the reason on compiled/source-built Pis, below the floor, or on an ESP32
  layout without an OTA slot. MCP tool description and docs updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdmCbU4h6hRRaciZ1yxnNX
…a 24 h stability window

Automatic updates are now on by default, on the `stable` channel: the
device installs the latest signed release only once it has been the latest
for a day. A fix published within that day replaces it as latest and resets
the clock, so the release the fix was for is never installed. `latest`
installs every release as it lands; `off` never checks.

- Contract: `auto_update` is a string enum on both profiles (fixtures,
  regenerated tables).
- Pi runtime: FrameConfig.autoUpdate is the channel (absent/true → stable,
  false → off); upgrade.nim carries GitHub's published_at; auto_updater.nim
  decides with releaseQualifies() and logs waiting_for_stable /
  publish_time_unknown instead of scheduling.
- ESP32: fos_auto_update_t + parse/name helpers; every settings surface
  takes the three names (booleans still map); the manifest's `publishedAt`
  (unix seconds) gates the PERIODIC run on stable — manual `ota`, the
  backend's action and notify_update_available install at once; an unknown
  publish time or an unsynced clock waits.
- Backend: the column is a String channel (NULL = stable), normalized on
  the way in and out; the OTA manifest carries publishedAt.
- Cloud: the manifest route relays publishedAt (null for a dev image).
- SPA: a three-way Select in every place the switch was; docs + MCP text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdmCbU4h6hRRaciZ1yxnNX
… of the hour

The backend's reboot schedule (/etc/cron.d/frameos-reboot) fires on the hour,
04:00 by default, and a check at 04:00 or 04:01 could start an upgrade the
reboot then kills half-staged, every day. The per-frame slot now lands in
the last forty minutes of the hour: a just-rebooted frame runs its check
fresh, and an upgrade started at :20 is done long before the next hour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdmCbU4h6hRRaciZ1yxnNX
…pdate / publishedAt

tools/buildroot-images' ReleaseImageFrame hand-lists every frame column and
had no auto_update, so get_frame_json raised while composing a card; it now
ships "stable", and the three readers of the column go through getattr so
a stub frame without it reads as the default. The cloud firmware route's
unit test pins the exact manifest shape: publishedAt joins it (unix seconds
from the release, null for a dev image).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdmCbU4h6hRRaciZ1yxnNX
…ate joins both profiles

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdmCbU4h6hRRaciZ1yxnNX
… latest

A self-hosted backend deploys ITS version (the precompiled release of
versions.json). Both device types were pulling GitHub's latest instead —
the ESP32 manifest relay always had, the new Pi auto-updater as well — so a
frame climbed past its backend and the next deploy downgraded it again.

- Backend: GET /api/frameos/release (bearer = the frame's server API key)
  answers {"version": <this backend's release>}. The ESP32 OTA manifest and
  download are pinned to that release's tag; GitHub's latest still feeds
  the browser flasher and SD-image listing only.
- Pi runtime: upgrade.nim resolveFrameOSRelease() asks the backend when
  frame.json names one (backendPinnedVersion, same host/port/TLS rule as the
  log uploader) and fetches that release by tag; no backend → GitHub's
  latest. The auto-updater, the admin panel's Upgrade button/status and the
  cloud nudge all resolve through it; `frameos upgrade --version=X` pins a
  release, and the auto-updater passes the one it just qualified. A backend
  that cannot answer (unreachable, or older than 2026.9.11) is an error,
  never a fallback to latest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdmCbU4h6hRRaciZ1yxnNX
mariusandra and others added 3 commits September 8, 2026 13:13
…t its parent

After the rebase two migrations revised d4e6f8a0b2c4 and alembic refused
'head' — every CI job that boots the backend died on it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdmCbU4h6hRRaciZ1yxnNX
…ESP32 checks ten minutes after boot

- 2026.9.11 shipped without this PR, so the contract floor for auto_update
  (and every mirror of it: SPA/cloud gates, tests, docs, the backend's
  "update the backend first" message) moves to 2026.9.12.
- effective_auto_update(frame | frame dict): the channel the DEVICE is
  told — "off" when the frame cannot take a generic release. frame.json,
  the shell-less sync push and the sync drawer all go through it, so a
  Pi with a compiled scene no longer shows "Automatic updates: stable →
  off" as a pending change forever, and the push never tells such a frame
  to update. A device that reports no channel at all (firmware before
  2026.9.12) is not a choice either.
- ESP32: the periodic task's first check comes ten minutes after boot
  instead of a full interval later — a frame that restarts daily never
  checked. The `stable` wait is passed into ota_run_signed explicitly
  (unattended) instead of read off a global by every caller.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdmCbU4h6hRRaciZ1yxnNX
@mariusandra

Copy link
Copy Markdown
Collaborator Author

Closing this right now. it's gone stale and the actual fix will also have to take into account on-frame migrations (e.g. moving a nim app to a js app). The decision there is that each version could contain the upgrade script from the previous version, and we'll force the upgrades to not go straight to lateset, but also run certain migration versions in the mean while.

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.

1 participant