Adopted-card bench: the empty deploy drawer, and Reboot device on the frame's own page - #490
Conversation
… frame's own page
Two findings from the 2026-09-13 bench on frame 14 (a generic Buildroot card
adopted into the 2026.9.15 Home Assistant add-on), plus the todo bookkeeping
that closes the whole non-ESP32 side of the list.
**The deploy button was dirty with an empty drawer.** Right after adoption the
button said there was something to save and the drawer listed nothing.
`sanitizeFrame` seeds the form with `buildroot.compilationMode: ''` whether or
not the row has that key — it does the same for `rpios`, which HAS
`normalizeRpiosForComparison` to undo it, while `buildroot` had no normalizer
at all — so the key read as an unsaved edit forever. It took an adopted card
to make it visible: adoption writes `buildroot` as `{adopted: true}` with no
compilationMode, and `buildroot` is in `SHELL_LESS_BACKEND_ONLY_KEYS`, which
`computeChangeDetails` hides on a shell-less frame while `unsavedChanges`
still counted it. Dirty button, nothing to show.
- frameLogic.ts: `normalizeBuildrootForComparison`, mirroring the rpios one
- a shared-spa regression test, verified red without the fix
**"Reboot device" on the frame's own admin page.** It works from the backend,
so it should work from the frame. The device did not serve
`POST /api/frames/@id/reboot` — the backend reaches a shell-less frame's
reboot through `POST /event/reboot` (`_shell_less_control_event`) — so rather
than special-case the SPA, the device now answers that path with the same
`reboot` control event, mirroring `/restart`. `framesModel.rebootFrame` then
works unchanged on both control planes. `reboot` was already a ControlEvent
gated on an admin session or the serverApiKey bearer, so no new authorization
surface. Restart FrameOS takes ArrowUturnUp so the power icon means the bigger
hammer.
**docs/manual-testing-todo.md** — the non-ESP32 boxes are closed:
- Standalone on-device admin round three PASSED on 2026.9.15. The runtime
going down after the first save was NOT a crash: the card's logs show
`POST /api/frames/1` -> `event:restart` -> back in 7 s, with
`frameos-last-exit` reading `exitCode=exited exitStatus=0` and
`boot_guard.json` at `crashesWithoutRender: 0`. A save restarts by design
when a `frameConfigRestartKeys` value differs.
- Recorded that `boot:guard crashesWithoutRender: N` means "this boot has not
rendered yet", not "it crashed N times" — `registerBootCrash()` runs on
every startup and logs previous+1 before the first render. That misreading
is what made the 2026-09-14 sighting look like a crash too.
- Adopted card round two/three PASSED: pending changes opened clean (the
2026-09-09 network-secrets regression is gone), restart and reboot worked
with no shell, and a second backend adopting the card logged `server:changed`
to the first.
- "Generic image still adopts with no Remote on it" CLOSED as obsolete. It was
written 2026-09-04 for a world where the backend SSHed in and installed
Remote; since remote lite (#485) a generic card has no shell at all and
`frame_has_shell_access()` routes it down the admin-API path.
Only the ESP32 bench is left, and every box there needs hardware.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkGsPJHBxQApJoLE9d9hr2
ESP32 firmware sizeBuilt from c22e9db by this run; compared with the latest release v2026.9.15.
Breakdown by subsystem —
|
| Subsystem | This PR | v2026.9.15 | Δ |
|---|---|---|---|
| FrameOS core (Nim) | 404,620 | 404,620 | ±0 |
| QuickJS | 356,328 | 356,328 | ±0 |
| FrameOS apps (Nim) | 316,786 | 316,786 | ±0 |
| pixie | 300,949 | 300,949 | ±0 |
| Wi-Fi stack | 295,300 | 295,300 | ±0 |
| Nim stdlib | 249,026 | 249,026 | ±0 |
| ESP-IDF misc | 178,065 | 178,065 | ±0 |
| Embedded font | 146,358 | 146,358 | ±0 |
| String pool (attributed to efuse) | 132,857 | 132,857 | ±0 |
| mbedTLS + certificates | 132,539 | 132,539 | ±0 |
| lwIP / HTTP / WebSocket | 126,644 | 126,644 | ±0 |
| fos_* firmware shell (C) | 116,897 | 116,897 | ±0 |
| libc / libm / newlib | 116,259 | 116,259 | ±0 |
| Storage (SPIFFS / FatFS / SD / NVS) | 94,132 | 94,132 | ±0 |
| Nim packages (chrono, zippy, chroma, qrgen, ...) | 72,043 | 72,043 | ±0 |
| Display drivers (C) | 64,934 | 64,934 | ±0 |
| Crypto (monocypher) | 35,149 | 35,149 | ±0 |
| Total mapped flash | 3,138,886 | 3,138,886 | ±0 |
Inside each subsystem
FrameOS core (Nim) — 404,620
| Part | This PR | v2026.9.15 | Δ |
|---|---|---|---|
frameos/js_runtime |
102,097 | 102,097 | ±0 |
frameos/interpreter.nim |
97,276 | 97,276 | ±0 |
frameos/utils |
89,928 | 89,928 | ±0 |
embedded_main.nim |
18,319 | 18,319 | ±0 |
lib/tz.nim |
16,751 | 16,751 | ±0 |
embedded_runtime.nim |
15,056 | 15,056 | ±0 |
frameos_nim_glue.c.obj |
9,814 | 9,814 | ±0 |
frameos/planner.nim |
9,605 | 9,605 | ±0 |
frameos/types.nim |
7,913 | 7,913 | ±0 |
frameos/values.nim |
6,873 | 6,873 | ±0 |
frameos/apps.nim |
6,054 | 6,054 | ±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,489 | 2,489 | ±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 |
137 | 137 | ±0 |
frameos/channels.nim |
122 | 122 | ±0 |
frameos/hal |
40 | 40 | ±0 |
QuickJS — 356,328
| Part | This PR | v2026.9.15 | Δ |
|---|---|---|---|
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) — 316,786
| Part | This PR | v2026.9.15 | Δ |
|---|---|---|---|
apps/data/icalJson |
50,265 | 50,265 | ±0 |
apps/render/calendar |
30,707 | 30,707 | ±0 |
apps/render/chart |
21,541 | 21,541 | ±0 |
apps/data/immich |
17,695 | 17,695 | ±0 |
apps/data/wikicommons |
17,110 | 17,110 | ±0 |
apps/data/openaiImage |
13,970 | 13,970 | ±0 |
apps/data/unsplash |
11,084 | 11,084 | ±0 |
apps/data/weather |
10,193 | 10,193 | ±0 |
apps/data/beRecycle |
9,918 | 9,918 | ±0 |
apps/render/split |
9,648 | 9,648 | ±0 |
apps/data/localImage |
9,202 | 9,202 | ±0 |
apps/data/eventsToAgenda |
9,060 | 9,060 | ±0 |
apps/data/googlePhotos |
9,044 | 9,044 | ±0 |
apps/apps.nim |
8,636 | 8,636 | ±0 |
apps/render/zoomPan |
8,544 | 8,544 | ±0 |
apps/data/haSensor |
7,287 | 7,287 | ±0 |
apps/data/openaiText |
6,762 | 6,762 | ±0 |
apps/data/xmlToJson |
5,420 | 5,420 | ±0 |
apps/render/text |
5,419 | 5,419 | ±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,345 | 3,345 | ±0 |
apps/render/gradient |
3,173 | 3,173 | ±0 |
apps/data/qr |
3,118 | 3,118 | ±0 |
apps/data/frameOSGallery |
3,046 | 3,046 | ±0 |
apps/data/downloadUrl |
3,045 | 3,045 | ±0 |
apps/render/color |
2,804 | 2,804 | ±0 |
apps/data/newImage |
2,676 | 2,676 | ±0 |
apps/data/resizeImage |
2,550 | 2,550 | ±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,835 | 1,835 | ±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 — 300,949
| Part | This PR | v2026.9.15 | Δ |
|---|---|---|---|
pixie/fontformats/opentype.nim |
55,507 | 55,507 | ±0 |
pixie/fileformats/webp.nim |
37,525 | 37,525 | ±0 |
pixie/fileformats/svg.nim |
37,143 | 37,143 | ±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,783 | 15,783 | ±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,520 | 6,520 | ±0 |
pixie/fileformats/gif.nim |
5,925 | 5,925 | ±0 |
pixie/inflatestream.nim |
5,325 | 5,325 | ±0 |
pixie.nim |
5,224 | 5,224 | ±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,682 | 2,682 | ±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.15 | Δ |
|---|---|---|---|
libnet80211.a |
136,168 | 136,168 | ±0 |
libpp.a |
62,154 | 62,154 | ±0 |
libwpa_supplicant.a |
54,900 | 54,900 | ±0 |
libphy.a |
34,176 | 34,176 | ±0 |
libesp_wifi.a |
4,780 | 4,780 | ±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 — 249,026
| Part | This PR | v2026.9.15 | Δ |
|---|---|---|---|
pure/collections/tables.nim |
70,556 | 70,556 | ±0 |
system.nim |
46,659 | 46,659 | ±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,520 | 8,520 | ±0 |
pure/parsexml.nim |
5,999 | 5,999 | ±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,356 | 1,356 | ±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,065
| Part | This PR | v2026.9.15 | Δ |
|---|---|---|---|
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,660 | 8,660 | ±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,257 | 3,257 | ±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.15 | Δ |
|---|---|---|---|
assets/fonts.nim |
146,358 | 146,358 | ±0 |
String pool (attributed to efuse) — 132,857
| Part | This PR | v2026.9.15 | Δ |
|---|---|---|---|
esp_efuse_utility.c.obj |
131,811 | 131,811 | ±0 |
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 |
mbedTLS + certificates — 132,539
| Part | This PR | v2026.9.15 | Δ |
|---|---|---|---|
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 |
lwIP / HTTP / WebSocket — 126,644
| Part | This PR | v2026.9.15 | Δ |
|---|---|---|---|
liblwip.a |
78,305 | 78,305 | ±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,666 | 6,666 | ±0 |
libesp_netif.a |
6,244 | 6,244 | ±0 |
fos_ firmware shell (C)* — 116,897
| Part | This PR | v2026.9.15 | Δ |
|---|---|---|---|
fos_http.c.obj |
22,202 | 22,202 | ±0 |
fos_cloud.c.obj |
16,975 | 16,975 | ±0 |
fos_console.c.obj |
13,601 | 13,601 | ±0 |
fos_client.c.obj |
10,499 | 10,499 | ±0 |
fos_scenes.c.obj |
7,551 | 7,551 | ±0 |
fos_settings.c.obj |
6,511 | 6,511 | ±0 |
fos_ota.c.obj |
5,039 | 5,039 | ±0 |
fos_config.c.obj |
4,298 | 4,298 | ±0 |
fos_wifi.c.obj |
3,201 | 3,201 | ±0 |
fos_assets.c.obj |
3,124 | 3,124 | ±0 |
fos_cloud_contract.c.obj |
2,896 | 2,896 | ±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,351 | 2,351 | ±0 |
fos_buttons.c.obj |
2,139 | 2,139 | ±0 |
fos_status_screen.c.obj |
2,049 | 2,049 | ±0 |
main.c.obj |
1,667 | 1,667 | ±0 |
fos_config_parse.c.obj |
1,467 | 1,467 | ±0 |
fos_url_guard.c.obj |
1,397 | 1,397 | ±0 |
fos_tz.c.obj |
1,381 | 1,381 | ±0 |
fos_battery.c.obj |
1,140 | 1,140 | ±0 |
fos_minisig.c.obj |
497 | 497 | ±0 |
fos_framebuffer.c.obj |
483 | 483 | ±0 |
fos_assets_path.c.obj |
259 | 259 | ±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 |
fos_json_guard.c.obj |
119 | 119 | ±0 |
libc / libm / newlib — 116,259
| Part | This PR | v2026.9.15 | Δ |
|---|---|---|---|
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,132
| Part | This PR | v2026.9.15 | Δ |
|---|---|---|---|
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,741 | 13,741 | ±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,043
| Part | This PR | v2026.9.15 | Δ |
|---|---|---|---|
QRgen 3.1.0 |
22,036 | 22,036 | ±0 |
chrono 0.3.1 |
15,510 | 15,510 | ±0 |
zippy 0.10.19 |
15,280 | 15,280 | ±0 |
chroma 1.0.0 |
14,924 | 14,924 | ±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) — 64,934
| Part | This PR | v2026.9.15 | Δ |
|---|---|---|---|
(everything else) |
21,290 | 21,290 | ±0 |
frameos_panel_table.c.obj |
6,685 | 6,685 | ±0 |
EPD_13in3e.c.obj |
1,792 | 1,792 | ±0 |
EPD_3in7.c.obj |
1,564 | 1,564 | ±0 |
EPD_5in79.c.obj |
1,481 | 1,481 | ±0 |
DEV_Debug.c.obj |
1,309 | 1,309 | ±0 |
EPD_2in7.c.obj |
1,292 | 1,292 | ±0 |
EPD_4in2.c.obj |
1,262 | 1,262 | ±0 |
EPD_7in3e.c.obj |
1,208 | 1,208 | ±0 |
EPD_4in2_V2.c.obj |
1,184 | 1,184 | ±0 |
DEV_Config_esp.c.obj |
1,180 | 1,180 | ±0 |
EPD_4in26.c.obj |
1,123 | 1,123 | ±0 |
| 29 smaller rows | 23,564 | 23,564 | ±0 |
Crypto (monocypher) — 35,149
| Part | This PR | v2026.9.15 | Δ |
|---|---|---|---|
monocypher.c.obj |
31,916 | 31,916 | ±0 |
monocypher-ed25519.c.obj |
3,233 | 3,233 | ±0 |
Largest 30 objects
| Object | Subsystem | This PR | v2026.9.15 | Δ |
|---|---|---|---|---|
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,811 | 131,811 | ±0 |
frameos/interpreter.nim |
FrameOS core (Nim) | 97,276 | 97,276 | ±0 |
nim/lib/pure/collections/tables.nim |
Nim stdlib | 70,556 | 70,556 | ±0 |
frameos/js_runtime/app_runtime.nim |
FrameOS core (Nim) | 61,493 | 61,493 | ±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,659 | 46,659 | ±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,143 | 37,143 | ±0 |
frameos/utils/image.nim |
FrameOS core (Nim) | 34,490 | 34,490 | ±0 |
pkgs/pixie-6.1.0/pixie/paths.nim |
pixie | 32,570 | 32,570 | ±0 |
monocypher.c.obj |
Crypto (monocypher) | 31,916 | 31,916 | ±0 |
frameos/js_runtime/runtime.nim |
FrameOS core (Nim) | 30,086 | 30,086 | ±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,659 | 23,659 | ±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,202 | 22,202 | ±0 |
apps/render/chart/app.nim |
FrameOS apps (Nim) | 18,940 | 18,940 | ±0 |
embedded_main.nim |
FrameOS core (Nim) | 18,319 | 18,319 | ±0 |
frameos/utils/status_screen.nim |
FrameOS core (Nim) | 18,111 | 18,111 | ±0 |
x509_crt_bundle.S.obj |
mbedTLS + certificates | 17,928 | 17,928 | ±0 |
nim/lib/pure/unicode.nim |
Nim stdlib | 17,405 | 17,405 | ±0 |
nim/lib/pure/times.nim |
Nim stdlib | 16,984 | 16,984 | ±0 |
fos_cloud.c.obj |
fos_* firmware shell (C) | 16,975 | 16,975 | ±0 |
lib/tz.nim |
FrameOS core (Nim) | 16,751 | 16,751 | ±0 |
apps/data/immich/app.nim |
FrameOS apps (Nim) | 16,148 | 16,148 | ±0 |
pkgs/pixie-6.1.0/pixie/images.nim |
pixie | 15,783 | 15,783 | ±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.
…urface, and a clean backend disconnect
Bench follow-ups from the 2026-09-13 session, mostly small, three of them not.
**Settings page (standalone admin).** The cloud box is a tag and two switches
now: each switch's paragraph moved behind the (i) it always should have been,
and only a blocker still takes a line of its own. "Clear serverHost in
frame.json first" was advice with no destination — it is a link to Backend
host under "Backend access" (scrollToFrameHttpApiSection generalised to
scrollToFrameSettingsSection). The WiFi country hint became a tooltip. The
"Service secrets" boxes collapse (a dozen services of key fields pushed every
other setting off the page; the summary row still says Saved/Missing). HTTPS
moved directly under the HTTP API block it secures, rather than two sections
below it. "Frame admin panel (BETA)" lost the tag and the paragraph; how to
reach it, and its URL, are in an (i) on the heading. The cert textareas are
three rows. And the form no longer butts against the cloud box above it — its
first heading's margin was collapsing through the form element.
**The status screen is listed on every surface.** `system/index` is a real
scene every Linux frame draws on request, but only the on-device page listed
it, so from a backend or the cloud there was no way to put it on the panel at
all. It is in the scene list everywhere now, behind a new "Show status screen"
switch in the list's display menu (stored per frame per surface, default on,
next to the two that were already there). Not offered for ESP32 frames, whose
firmware draws its own boot screen in C.
**Upgrade progress reaches the frame's own log.** `frameos upgrade` runs
detached and reports only through its status file, and the only thing watching
that file lived in the cloud hub client — so an upgrade started from the
frame's admin page showed a spinner and then silence. The watch is a
UpgradeLogWatcher in frameos/upgrade now, driven from the runner's message
loop, and the hub client's copy is gone: one implementation, and the lines
ride the ordinary log shipper to every surface, the cloud included.
**A frame leaving a backend says so.** Clearing the server URL on the device
was the one move that produced no notice at all: the backend kept the frame,
stopped hearing from it, and then offered a frame.json merge for its own
address ("Server Host — Backend: 10.4.0.47 / Frame: Not set", two equally
wrong answers). `backendChangeNotice` now returns a `server:detached` notice
for it, delivered to the backend being left exactly as `server:changed` is,
and the four `server_*` keys became FRAME_SYNC_BACKEND_OWNED_KEYS so they
never reach the sync diff again (the admin-API deploy still carries them, via
FRAME_PUSH_EXTRA_KEYS — that push is how an adopted card learns where to send
its logs).
**The first save on a fresh card no longer restarts the runtime.** Predicted
in the last commit's todo entry and duly hit on the bench. `apiDeviceConfig`
serves the typed device config when frame.json has none, renaming
httpUploadUrl to uploadUrl and dropping the -1 "driver default" pins; the SPA
posts that back and `deviceConfig` read as changed exactly once.
`canonicalDeviceConfig` compares both sides in one spelling.
Also: the status screen's blurb is plain English and one em dash shorter; the
index scene says nothing about GPIO buttons when none are wired up, and drops
the Inputs row entirely when nothing can drive the frame; a cloud-managed
frame's "Remote control" row says "enabled (no shell access)"; "Reboot device"
sits in the frame admin menu (the device answers POST /api/frames/@id/reboot
now, mirroring /restart); a loading frame image gets a corner spinner instead
of pulsing the whole picture in and out; and the AI prompt box on
scenes.frameos.net is two lines tall below 640px, where its placeholder never
fit on one.
Not fixed, written down instead (docs/todo.md): a frame that joins the cloud
arrives with no scenes. The hub protocol has no device → cloud scene path at
all — importing a device's scenes means minting store-scene records for
scenes that were never in anyone's library. A feature, not a fix.
Verified: cloud `pnpm verify` 28/28, frontend `tsc --noEmit` clean, backend
`pytest app/api/tests app/tasks/tests` 1089 passed, and the Nim suites for
the API, the frame API routes, the server, the portal, the index scene and
the upgrade helpers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkGsPJHBxQApJoLE9d9hr2
…in sync The drift guard added two commits ago failed on its first real PR, correctly in spirit and wrongly in fact: builtinApps.ts embeds every app's app_loader.nim, those files are generated AND gitignored, and a clean CI checkout has none of them — so the regenerated catalog was always missing them and could never match what a dev machine committed. Generate them first, exactly as the Nim job does (`FRAMEOS_ROOT_DIR=. python3 tools/makeapploaders.py`, no third-party deps), and say so in the error message for whoever hits this next. Verified locally: regenerating the loaders and then the catalog leaves the tree clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkGsPJHBxQApJoLE9d9hr2
… moved behind an (i)
Both from listing the status screen outside the on-device panel and slimming
the cloud box, caught by the frontend-visual suite.
**`system/index` 404'd on every surface but the device.** It is a scene id
with a slash in it, and both control planes routed scene images through a
single path segment — FastAPI's `{scene_id}` and Next's `[sceneId]` — so the
tile's image request never reached a handler. The device got this right
already (a literal route registered before the `@sceneId` one, because mummy
matches `@` as one segment); the other two now match the rest of the path.
Nothing longer lives under either GET prefix, so swallowing it is exactly
right. The backend's POST stays single-segment: nothing posts this id, the
backend pulls the picture off the device.
This is what the e2e suite was actually reporting — every "Unexpected frontend
errors" failure was one `Failed to load resource: 404`, on each workspace
route that drew the tile.
**The cloud box's two sentences are behind the (i) now**, so the tests that
asserted them on the page open the tooltip first. The status tooltip has an
explicit label ("What this cloud status means") so the assertion targets it
rather than one of the page's many bare (i) buttons.
Verified locally against the real suite (`FRONTEND_VISUAL_MAX_DIFF=1`, and
`pnpm --dir frameos/frontend run build` first — the frame-admin specs serve
the ON-FRAME bundle, not frontend/dist): frame-admin 7/7 and frontend-e2e
13/13, both previously failing. Backend `pytest app/api/tests app/tasks/tests`
1090 passed, cloud `pnpm verify` 28/28.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkGsPJHBxQApJoLE9d9hr2
The built-in status screen (`system/index`) was a tile in the scene list — a scene you cannot edit, rename or delete, sitting first among the ones you can. It is a frame action, not a scene, so it moves to the frame's actions menu as "Render status screen", which sends the activation outright instead of opening a drawer first. `framesModel.renderStatusScreen` sends it, not controlLogic: the frames home draws a menu per card, and mounting a keyed controlLogic for each would fetch every frame's state (the same reason FrameSceneTile uses sceneUpdatesLogic). It is the same `setCurrentScene` event a scene tile sends, so both control planes answer it unchanged — the cloud's shim keeps the plain select for an id nothing is assigned to. With the tile gone, "Show status screen" in the list's display menu has nothing to switch, so it and its stored flag go too. The status-screen drawer stays: a restored `?drawer=scene&sceneId=system/index` still lands on it. The scene list keeps its current layout and A-Z order here — the one-row rewrite and "sort by recently used" are #491's. Verified: `frontend npx tsc --noEmit` and `kea-typegen check --inline` clean, `cloud pnpm verify` 28/28. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0192oKE2E4j6dVcQmEmFSP4i
efb4884 to
ba2997f
Compare
Conflicts: - docs/manual-testing-todo.md — main (#490) closed the whole non-ESP32 side and left only the ESP32 bench; this branch had added a Preview drawer box main never saw. Took main's closures and carried the Preview drawer box over under its Backend (self-hosted) bench heading, header line reworded so it no longer claims the non-ESP32 side is empty. - four frame-scenes--expanded-scene snapshots — took main's baseline (#490's real UI change). CI re-renders these with --update-snapshots and the bot job commits the result, so this branch's own diffs come back from CI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019tywARkZTUQNozKsXMHEq9
Two findings from the 2026-09-13 bench on frame 14 — a generic Buildroot card adopted into the 2026.9.15 Home Assistant add-on — plus the todo bookkeeping that closes the whole non-ESP32 side of the manual testing list.
The deploy button was dirty with an empty drawer
Right after adoption the button said there was something to save and the drawer listed nothing.
sanitizeFrameseeds the form withbuildroot.compilationMode: ''whether or not the row has that key. It does the same forrpios— butrpioshasnormalizeRpiosForComparisonto undo it andbuildroothad no normalizer at all, so the key read as an unsaved edit forever.It took an adopted card to make it visible, because two things had to line up: adoption writes
buildrootas{adopted: true}with nocompilationMode, andbuildrootis inSHELL_LESS_BACKEND_ONLY_KEYS— whichcomputeChangeDetailsdeliberately hides on a shell-less frame whileunsavedChangesstill counted it. Dirty button, nothing to show.normalizeBuildrootForComparisoninframeLogic.ts, mirroring the rpios one"Reboot device" on the frame's own admin page
It works from the backend, so it should work from the frame. The device did not serve
POST /api/frames/@id/reboot— the backend reaches a shell-less frame's reboot throughPOST /event/reboot(_shell_less_control_eventintasks/restart_frame.py). Rather than special-case the SPA, the device now answers that path with the samerebootcontrol event, mirroring/restart, soframesModel.rebootFrameworks unchanged on both control planes.rebootwas already aControlEventgated on an admin session or the serverApiKey bearer, so this adds no authorization surface. The menu now reads Save / Re-render / Reload runtime / Restart FrameOS / Reboot device, with a confirm; Restart takesArrowUturnUpIconso the power icon means the bigger hammer.The "crash" after the first save was not a crash
Pulled the card and read its logs. The runtime went down and came back because the save asked it to:
runtime/frameos-last-exitreadsserviceResult=success exitCode=exited exitStatus=0,state/boot_guard.jsonreadscrashesWithoutRender: 0, and the whole session's log holds exactly one restart and zero errors. A save restarts by design when aframeConfigRestartKeysvalue differs (classifyFrameConfigChange).Worth writing down:
boot:guard crashesWithoutRender: Nmeans "this boot has not rendered yet", not "it crashed N times" —registerBootCrash()runs on every startup and logs previous+1 before the first render. That misreading is what made the 2026-09-14 activate-then-restart sighting look like a crash too.docs/manual-testing-todo.md
Every non-ESP32 box is now closed:
server:changedto the first, naming the new server.frame_has_shell_access()says outright that the SSH/Remote deploy "cannot even connect". Nothing to install, andframeos.servicestaysUser=frameos(verified on the card).Only the ESP32 bench is left, and every box there needs hardware that was not to hand.
The status screen is a frame action, not a scene
system/indexwas the first tile in every frame's scene list: a scene youcannot edit, rename or delete, taking a slot from the ones you can. It moves to
the frame's actions menu as Render status screen, which sends the activation
outright — no drawer, one click.
framesModel.renderStatusScreensends it rather thancontrolLogic: the frameshome draws a menu per card, and mounting a keyed
controlLogicfor each wouldfetch every frame's state (the same reason
FrameSceneTileusessceneUpdatesLogic). It is the samesetCurrentSceneevent a scene tile sends,so both control planes answer it unchanged — the cloud shim keeps the plain
set_current_sceneselect for an id nothing is assigned to. With the tile gone,"Show status screen" in the list's display menu has nothing to switch, so it and
its stored flag go too. The drawer itself stays: a restored
?drawer=scene&sceneId=system/indexstill lands on it.The scene list keeps its current layout and A-Z order here — the one-row rewrite
and "sort by recently used" are #491's, and this branch no longer carries them.
The two still both touch
FrameDashboardSurface.tsx, but only in the block thatrenders the tile, so whichever lands second should resolve cleanly.
Verification
cloud pnpm verify— 28/28frontend npx tsc --noEmit— cleannim c -r src/frameos/server/tests/test_frame_api_routes_behavior.nim— 13/13, including the new/rebootassertions and its 404 on a mismatched frame idfrontend npx kea-typegen check --inline— clean🤖 Generated with Claude Code
https://claude.ai/code/session_01JkGsPJHBxQApJoLE9d9hr2