# Publishing updates without replacing user configuration ## Layout of the code and data - `main` is the only distribution branch for the app **and both** firmware packages. The old Guition branch is no longer updated; all board work goes to main. - `screen_manager/config.yaml` holds the app version. Bump it on every app release. A Git push alone isn't enough to offer an existing app an update. - The app image contains only code (and the CHANGELOG, for the Update badge's What's new). Layouts live in `/data/screens.json` (`version: 1`, `screens: {...}`). An update/rebuild preserves this volume data. - The device's own ESPHome YAML contains the name, Wi-Fi references, and unique API/OTA keys. Shared packages contain no secrets, fixed owner entities, or Wi-Fi. - CYD calibration lives in ESP32 preferences. Preserve the preference key, structure, and partition layout, or write an explicit migration. - The tile protocol uses `v: 1`. Keep older fields and domains usable. A changed storage version must get a tested migration with a backup. The app refuses unknown versions instead of overwriting the data blank. ## For every release 1. Update `packages/core.yaml` (shared by every board), the board files under `packages/boards/` and the shared components; docs/PROFILES.md says what goes where. `python3 tools/check_packages.py` checks that the boards define every name the core uses (tools/check.sh runs it). The editor is the Vue app in `web/`: after a change under `web/src`, run `cd web && npm ci && npm test && npm run check && npm run build` and commit `screen_manager/app/static` with it (that folder is the build output; never edit it by hand). 2. Run `tools/check.sh` (with `PYTHON=.venv-portal/bin/python` on a development machine). It runs all Python tests with aiohttp, PyYAML, Pillow and fontTools installed, every `tests/*.cpp` with `clang++ -std=c++17 -Wall -Wextra -Werror -I.`, `tools/check_packages.py`, `tools/generate_icons.py --check`, and the editor's `npm ci`, `npm test`, `npm run check` and `npm run build`, and fails when that fresh build differs from the `screen_manager/app/static` in Git (committed or staged). For a firmware change, `tools/check.sh --firmware` compiles both board profiles with placeholder secrets from a temporary folder (never the real `secrets.yaml`) and applies the flash budget below; `--all` does both. CI (`.github/workflows/ci.yml`) runs the same script on every push and pull request to main. Compile sequentially: profiles with the same `DEVICE_NAME` share one build folder, and a parallel build can make an upload pick the wrong `firmware.bin` (the check builds are called `check-cyd` and `check-guition` and build under `.esphome/check`, apart from the bench profiles). Check that no secrets are in Git. **Flash budget of the CYD** (Max's rule, app 0.2.78). The CYD has 4 MB of flash and two update slots of 1,835,008 bytes; the Guition's 16 MB leave it far from any limit. Measure the CYD on the build users get: the YAML `core.installation_yaml()` writes has the Wi-Fi fallback access point (`wifi: ap:`) and `captive_portal:`, about 84 KB more than a profile without them. The board profiles carry both, so `tools/check.sh --firmware` measures that shape; it reads the slot from the build's `partitions.csv` (`app0`, `ota_0`) and the image from `firmware.ota.bin`, and `--baseline ` prints the delta against the last release. The check-profile tables in earlier test results left both out and read about 84 KB low (0.2.72: 1,453,647 bytes, 79.2 %, where the user-shaped build was 1,538,032 bytes, 83.8 %). Build with the add-on's pinned ESPHome (`screen_manager/Dockerfile`) and, when the ESPHome Device Builder ships a newer ESPHome, with that one too (`ESPHOME=`), because users build their updates there. | CYD image, share of 1,835,008 bytes | Rule | |---|---| | up to 90 % | normal | | 90-93 % | tight: every release states its flash delta; a delta over 8 KB needs a matching saving or Max's OK | | 93-95 % | only fixes ship | | over 95 % | never: that keeps about 90 KB for ESPHome upgrades and users' own overrides | 3. Test app start, saving, restarting/updating with existing layouts, reconnecting to HA, and an ESP restart. Test a new card on real hardware. A good build doesn't replace physical touch acceptance. 4. Bump the app version and firmware project version; write the CHANGELOG and concrete test results. Only publish compatible changes directly to main. `tests/test_release_lint.py` (part of `tools/check.sh`) holds `config.yaml`'s version, the first CHANGELOG heading and its firmware against `FIRMWARE_VERSION`, keeps the CHANGELOG headings unique and newest first, and checks that every `fonts/...` file the packages fetch from GitHub is in the tree. 5. Commit and push main (the only release branch). Create an immutable tag `screens-vX.Y.Z` from the same commit, and a GitHub release on that tag with the release notes in English (`gh release create screens-vX.Y.Z --notes-file ...`). Test the remote YAML in an empty folder: all components/fonts must be fetchable via GitHub. 6. The user checks the App store for updates and updates ESP Screen Manager. For new screen features: **Update** on the screen in ESP Screens (or the nightly round); ESPHome Device Builder's Install → Wirelessly on the existing device works too. The existing YAML stays in place; `refresh: 0s` fetches current code on every build. ## Local development Copy only `screen_manager/` to the shared `addons/esp_screen_manager/`. Reload the App store, install the local version, and rebuild after code changes. A local test version has a different add-on identity than the GitHub version; the data doesn't move over automatically. For the final installation, test the GitHub version and turn off the local version to avoid two writers. For backend tests on a development machine: ```sh python3 -m venv .venv-portal .venv-portal/bin/pip install aiohttp PyYAML Pillow fonttools .venv-portal/bin/python -m unittest discover -s tests ``` For the editor (`web/`, Vue 3 + Vite + TypeScript): `cd web && npm ci`, then `npm run dev` serves http://localhost:5173 with hot reload and proxies `/api` to a server on 127.0.0.1:8099 (SCREEN_DEV or a demo home). `npm test` runs the Vitest suite in `web/tests` (grid and top bar rules, the store, the components in jsdom), `npm run check` type-checks, `npm run build` writes the page into `screen_manager/app/static` (clearing `assets/` first). Vite names every file after a hash of its content, which is what keeps a browser from combining an old script with a new page; `server.py` serves `index.html` as built and `/assets/`. Everything the page asks for is a relative URL (`api/...`, `./assets/...`), so it works under Home Assistant's ingress path as well as on a bare localhost. The Python tests read the source through `tests/editor_sources.py`. A temporary development server supports `SCREEN_DEV=1`, `HA_API` (ending in `/api`), `HA_TOKEN_FILE`, and `SCREEN_DATA`. It only binds on localhost. Never put a token in source code, URLs, or Git. Production uses Supervisor and only accepts the Ingress proxy address; there is no additional public port. ### Settings compatibility 0.1.2 `settings` is an optional object within a screen layout and within the existing `v: 1, op: layout` message. A missing object preserves prior behavior. Old firmware ignores this extra field and keeps receiving tiles. The management page reports that new firmware is needed. An old browser that only saves tiles doesn't wipe saved settings. Unknown settings are refused. Firmware uses a separate preferences key `0x53435231` with a fixed version-1 structure (eleven int32 fields and a uint32 version). Don't change the key, structure, or version without a migration. Only changed settings are saved; periodic repeats cause no flash writes and no new idle timer. ### Compatibility 0.2.0 Storage version and tile protocol stay 1. Optional `tiles[].options`, `o` in status messages, `history`, and `inbox` in the layout message are additive. An older browser that doesn't send options keeps the existing options for the same entity. Older firmware ignores extra fields. New entity domains do require firmware 0.2.0; when rolling back to an older app, restore the data backup first, since that app can't yet load new domains. Don't silently overwrite old storage. Existing preference keys stay unchanged. The app now uses the official ESPHome container, maps `homeassistant_config` to `/homeassistant`, and requests UART/USB for the port the user chose. Config/secrets stay in the device's own HA config folder; builds in `/data`. App updates don't replace these folders. Native number settings report changes via `esphome.screen_setting`; the manager validates the inbox, key, and value. The built-in CLI stores re-downloadable caches under `/data/build` and `/data/platformio` (0.2.89 adds `/data/esphome` and `/data/idf`, see Compatibility 0.2.89). Only these folders are excluded from app backups via `backup_exclude`. `/data/screens.json` and the device's own ESPHome configurations remain backup data; never exclude `/data` as a whole. ### Compatibility 0.2.7 Twenty runtime tiles require firmware 0.2.7+. The manager checks that version before saving and sending larger layouts. Existing layouts are preserved on older firmware. Old app versions don't support more than ten; first bring the layout back down to ten, or restore their data backup on rollback. `settings.swipe_pages` is additive in storage version 1. On the wire, it sits outside the unchanged eleven-field settings object. Existing preferences stay the same; only swipe uses a new, separate uint32 key `0x53575031`, off by default. ### Compatibility 0.2.9 Guition rotation is additive as `settings.rotation` in app data, restricted to 0/90/180/270. Old management pages that omit the field keep the stored angle. On the wire, `rotation` sits alongside the unchanged eleven-field settings object. The firmware stores the angle as a separate uint32 at key `0x524F5431`; the existing Settings structure and CYD calibration stay unchanged. Only the Guition activates the rotation callback. The diagnostic entity `Guition schermtype` makes this capability recognizable, even when the screen is offline or renamed. ### Compatibility 0.2.10 `tiles[].options.background` is an optional palette name within storage version 1. On the wire, this sits in the existing `o` object. Older firmware ignores the field; newer firmware falls back to the normal colors for unknown names. The app only accepts the light palette from `TILE_BACKGROUNDS`. An old editor that omits `background` keeps the stored color for the same entity; explicit `auto` restores the default. No changed preference structure or keys, no new firmware flash needed for later color choices. An old app release from before 0.2.10 doesn't know this option field. When rolling back the app, restore the corresponding data backup; don't wipe or ignore unknown options just to open an old storage file anyway. ### Compatibility app 0.2.15 / firmware 0.2.16 Storage version and tile protocol stay 1. `tiles[].options.background` gets the extra palette name `none` ("None"): no card behind the tile content. Older firmware doesn't know the name and falls back to the normal card; because that fails invisibly for the user, the manager only sends such a layout once firmware 0.2.16 or newer is detected (`min_firmware`), holding it back in the meantime. The firmware only hides the fill and border (`bg_opa`/`border_opa`); sizes, padding, and the pressed feedback on the PRESSED state stay the same. The analog clock only changes the drawing (ticks, numerals, calendar block on single tiles); no new field is needed for it. No changed preferences or keys. ### Compatibility 0.2.16 / firmware 0.2.17 `FIRMWARE_VERSION` in `screen_manager/app/core.py` is the firmware that belongs to this app; `tests/test_updates.py` requires it to equal `SCREEN_FIRMWARE_VERSION` in both board profiles and packages (since app 0.2.84 one line in `packages/core.yaml` that every board and package takes). Bump them together. A screen with a lower `Schermfirmware` gets an update offer; a build fetches `main`, so publish firmware and app in the same commit. Firmware 0.2.17 adds the diagnostic text sensors `Apparaatnaam` (`${DEVICE_NAME}`) and `IP-adres` (`wifi_info`). The app links a screen to the profile with the same `esphome.name` via `Apparaatnaam`; older firmware falls back to a single profile with a matching `friendly_name` and a manually entered address. Don't rename these sensors without also updating `core.discover`. Update status lives in `/data/updates.json` (`version: 1`: `auto`, `hosts`, `results`, `last_round`), separate from `screens.json`. Unknown versions are refused. The round runs one screen at a time, waits for `Schermfirmware >=` target version and one minute of stability, and stops on the first error. Only the nightly round writes a `persistent_notification` in HA. ### Compatibility 0.2.18 / firmware 0.2.18 Storage version and tile protocol stay 1. `tiles[].options.icon` is additive: `auto` or a name from `screen_manager/app/tile_icons.py`. On the wire, the manager sends only the resolved codepoint in hex (`F06B5`) in `o.icon`: the chosen name, or, for `auto`, the `mdi:` icon from the HA attributes if it's in the set. Older firmware ignores the field; newer firmware checks whether the glyph is in the font and otherwise falls back to the domain icon. Hence no `min_firmware`. An old editor that omits `icon` keeps the stored choice. `tile_icons.py` is the single source list. `tools/generate_icons.py` writes the glyphs as a YAML anchor into the three MDI fonts of `packages/core.yaml` (both boards build from it) and builds `web/src/assets/tile-icons.woff` for the editor (with left bearing equal to xMin, otherwise icons sit off-center in the browser). The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual profiles must come from the set. No changed preferences or keys. ### Compatibility 0.2.89 / firmware 0.2.75 ESPHome 2026.9.0 in the add-on (`screen_manager/Dockerfile`, `requirements.txt`); the packages' `min_version` stays 2026.6.2. No protocol, storage, preference or key change; the firmware behaves the same apart from its log level and the timer countdown. - **Staged on purpose.** Screens build their firmware from the packages on main (`refresh: 0s`), whatever ESPHome builds them: this add-on, an add-on not yet updated (2026.6.2), or the owner's ESPHome Device Builder (2026.8.1 on Max's HA that day). A `min_version` of 2026.9.0 would have failed all of those builds, the nightly round included, until each owner updated. So this release uses nothing the packages' `min_version` lacks: the Guition keeps the top-level `online_image:` and new screens keep an OTA password. The next step is a release of its own: raise `min_version` to 2026.9.0, move to `image: - platform: online_image` (ESPHome 2027.1 removes the top-level form) and write `ota: - platform: esphome` with `encryption: {}` in `installation_yaml()` (ESPHome 2026.9 takes the api key; about 0.9 KB less on the CYD). CI (`.github/workflows/ci.yml`) builds both boards with the add-on's ESPHome and with the packages' `min_version`, so a form newer than `min_version` fails there. - **Toolchain.** ESPHome 2026.7+ builds an ESP32 with ESP-IDF itself (native toolchain) instead of PlatformIO, and uses ccache when it is on the PATH (the ESPHome image ships it). Its ESP-IDF, tools and ccache go in the user cache folder unless `ESPHOME_ESP_IDF_PREFIX` says otherwise; inside the add-on's container that folder is gone at every restart. - **Where the add-on's CLI keeps things** (`Firmware.build_env`): `ESPHOME_BUILD_PATH=/data/build/` as before, `ESPHOME_DATA_DIR=/data/esphome` (ESPHome's storage JSON, `packages/` and `external_components/` clones; before, this was `/homeassistant/esphome/.esphome`, which the ESPHome Device Builder app `rm -rf`s on every start, and a missing storage JSON makes ESPHome wipe the build folder), `ESPHOME_ESP_IDF_PREFIX=/data/idf` (as the official ESPHome app does with `/data/cache/idf`), `CCACHE_MAXSIZE=1G` unless the environment sets one, and `PLATFORMIO_CORE_DIR=/data/platformio` for whatever ESPHome still builds with PlatformIO. `backup_exclude` adds `*/esphome/` and `*/idf/`. `ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT` is the machine's core count (unless the environment sets one), the parallelism PlatformIO used: ESP-IDF's ninja defaults to cores + 2, and each compiler takes a few hundred MB next to Home Assistant on a small Raspberry Pi (the add-on showed 1.2 GB in use while the Yellow built with ninja's default). - **The first build after the update** has no storage JSON in `/data/esphome` and a new toolchain, so ESPHome wipes that screen's build folder (`storage_should_clean`, `clean_build(full=True)`) and builds it completely, once per screen; the old PlatformIO build trees go with it. Before the first build with ESP-IDF (no `/data/idf` yet), `Firmware.retire_platformio()` removes `/data/platformio`: PlatformIO's ESP32 toolchains and ESP-IDF, unused from now on. A check of the YAML (validate) leaves it. `tests/test_build_env.py` covers the environment, the backup list and the one-time removal. - **Firmware size** (`packages/boards/*.yaml`, `esp32: framework: advanced: assertion_level: SILENT` in every board, checked by `tests/test_easy_package.py`): ESP-IDF's assert messages are left out (49.0 KB on the CYD, measured alone on 2026.6.2); an assert still aborts, and the backtrace still decodes to the line. `CONFIG_ESP_ERR_TO_NAME_LOOKUP` stays on: without it `esp_err_to_name()` returns "UNKNOWN ERROR", and ESPHome's own error lines (30 in its Wi-Fi code) print only that name, not the number. `logger: level: INFO` in the core (8.9 KB): `ESP_LOGCONFIG` and `ESP_LOGD` compile away; the UI self-test's `logger.log` lines say `level: INFO`. A screen's Override YAML may still set `logger: level: DEBUG`. - **sscanf.** `duration_seconds()` and `minutes_of()` moved from `runtime_tiles.h` to `runtime_model.h` and read their numbers with `clock_parts()` instead of `sscanf` (9.5 KB of newlib's scanf on the CYD). The same answers as the `sscanf` forms for any text, white space before a number included (compared on two million random strings); `tests/test_runtime_model.cpp` holds the cases. - **Timer countdown.** `timer_left()` (`runtime_model.h`) caps a running timer's countdown at its `duration`: the screen's clock (Home Assistant's whole `epoch_seconds`, synced every minute) runs up to a second behind, so `finishes_at - now` could read one more than the timer lasts. - **Build flags stay under `platformio_options`.** ESPHome 2026.9 warns that `esphome: platformio_options: build_flags` is deprecated (removed in 2026.12) in favour of `esphome: build_flags`, but its LVGL component reads only the former when it writes `lv_conf.h` (`generate_lv_conf_h`, since ESPHome PR #16362) and writes `#define LV_USE_SWITCH 0` over `-DLV_USE_SWITCH=1` given the new way: `lv_switch_create` is then undeclared. Move `LV_USE_SWITCH` (core) and `LV_USE_SNAPSHOT` (Guition) once ESPHome's LVGL reads `esphome: build_flags`; `tools/check.sh --firmware` fails if the move is early. - **OTA.** Profiles keep `password:`. ESPHome 2026.9's CLI tries the api key first and falls back to the password for firmware that doesn't offer encryption yet (both bench screens: "The device did not offer OTA encryption; continuing in plaintext"); every 2026.9 build with an api key offers it, and ESPHome 2026.6.2's uploader still gets in with the password. ESPHome removes the fallback in 2027.3; a password profile then uploads in plaintext with its password. ESPHome 2026.9 warns about the password at every build of such a profile. - **Tooling.** Repository builds need Python 3.12-3.14 for ESPHome 2026.9 (`.esphome/venv313` on the owner's Mac); CI caches `~/.cache/esphome` (ESP-IDF and ccache) instead of `~/.platformio` and installs ccache. Details and measurements: docs/TEST_RESULTS_0289.md. ### Compatibility 0.2.88 / firmware 0.2.74 Firmware only, one setting: `packages/core.yaml` has `wifi: power_save_mode: none`, the first `wifi:` key in a package. ESPHome merges it under the screen's own `wifi:` block, which keeps the network, the password and the fallback hotspot, and the screen's own YAML wins on the mode as on any key (`merge_config`: the main config over every package). The YAML ESP Screens writes has had the same line since app 0.2.24; screens with an older or hand-written YAML ran ESPHome's ESP32 default `light` (`WIFI_PS_MIN_MODEM`). The app's Override YAML still refuses `wifi:` (`PROTECTED_OVERRIDE_KEYS`). No protocol, storage, preference or key change. `tests/test_easy_package.py` keeps the core's `wifi:` block to that one key and every other package without one. Details: docs/TEST_RESULTS_0288.md. ### Compatibility 0.2.87 / firmware 0.2.73 Firmware only, plus README pictures and docs/CAMERA.md. No protocol, storage, preference or key change: an older app drives this firmware as before, and the camera messages are the same; only when the screen sends and loads changed. - Starting screen: while `!model.configured`, `render()` calls `boot_status()`, which keeps a text in `watch_font` (paint `ink`) and a spinner as one block in the middle of the page, at the room label's index in the drawing order; the room label is empty meanwhile. The first layout deletes the block (`boot_panel`), spinner and all. `ROOM_NAME` defaults to "" (was "Choose tiles in HA"). - `spinner_create(parent, size, arc)` makes every spinner: the busy sheet (30/20 px, unchanged), the starting screen and the camera (48/32 px). - Camera full screen: `camera_open()` asks at once (awake and fresh); `camera_answer()` loads a new link at once through `camera_load()`, which like `camera_tick()` never starts under a finger. `camera_tick()` still does the 4 s refresh and the retries. `camera_spinner` turns until the first picture or a note; its ring is the new theme role `CAMERA_TRACK` (0x393D42 in both looks). "Loading image" is gone. - Guition `online_image` `buffer_size` 16384 (was 4096) for `camera_image` and `alert_image`: the full picture in about 1.8 s instead of 2.8 s on the bench; a loop over 50 ms is rare with Wi-Fi awake. 32 KB held a loop 0.1 s. - Alert picture: `alert_image_frame` has the alert card's radius (18) and `camera_show()` gives the alert's picture the frame's radius. LVGL 9.5 clips an image to its own radius row by row (`radius_only` in `lv_draw_sw_img.c`) without a layer, so `clip_corner` stays out (`tests/test_layer_free.py`). About 5 % more render time for the picture on the host, only when it is drawn. - Tests: `test_camera` (the first picture waits for no tick), `test_header_bar` (the starting screen; `render_header()` anywhere in `render()`), `test_theme` (the spinner paint in `spinner_create()`). Details: docs/TEST_RESULTS_0287.md. ### Compatibility 0.2.85 / firmware 0.2.71 Tile colours as Home Assistant draws them. No protocol, storage or editor change: the state messages are the same as 0.2.84's, and the firmware works the colours out from what it already receives. - Firmware: `Tile::active()` is a port of Home Assistant's `stateActive()` for the domains a tile shows, and `render_slot` greys every tile that is not active (`Tile::slider_active()` keeps a closed blind's slider coloured). Only `Tile::lights_up()` tiles, those that can be off, light up over a whole page. `tile_controls::accent()` replaces `runtime_tiles::domain_accent()` and follows `stateColorCss()`; `theme.h` gained the extra Home Assistant colours. An airco that is off reads "Off", with the current temperature when it reports one. - App: `header_bar.ALARM_CLASSES` is Home Assistant's list of eleven red classes, the same list the firmware uses. - The editor's mockup still draws icons in one colour. Details: docs/TEST_RESULTS_0285.md. ### Compatibility 0.2.84 (firmware stays 0.2.70) The two board profiles became `packages/core.yaml` plus a board file under `packages/boards/`, put together by the entry files (docs/PROFILES.md). No firmware, protocol or storage change: `packages/cyd.yaml` and `packages/guition.yaml` keep their names and places, so every screen's own YAML (`files: [packages/.yaml]`) builds as before, to the same code (the one-to-one check in docs/TEST_RESULTS_0284.md). `SCREEN_FIRMWARE_VERSION` is one line in the core. `tools/check_packages.py` replaces `tools/generate_packages.py`, and `tools/profiles.py` gives the tests and tools the three files as one text. ### Compatibility 0.2.83 / firmware 0.2.70 A light's effects page. Additive in protocol version 1; storage unchanged. - Wire: a light's state carries `effect`, and its `x` block `rows` (the select entities of the light's device: `e`, `n`, `s`, `c`, `i`) and `nums` (its number entities: `e`, `n`, `lo`, `hi`, `st`, `v`, `i`), built by `light_effects.rows` from what Home Assistant lists for the device. Older firmware ignores them. - A picker asks for its names with the event `esphome.screen_options` (`entity`, `page`). The app answers only then, and only for a light on that screen's layout or a select on its device, with `{op: options, e, i, n, o}`: one page of names per message, under the 4096-byte limit. Firmware before 0.2.70 never asks, so it never gets an `options` message. - Firmware: `effects_page.h`; `Extra` gains `effect`, `option_rows` and `number_rows`. A hidden `roller_seed` makes ESPHome compile LVGL's roller (CYD +20,288 bytes). Details: docs/TEST_RESULTS_0283.md. ### Compatibility 0.2.82 / firmware 0.2.69 Page buttons (GitHub issue #9): a setting the screen owns, `page_buttons`, on by default. It has a preference record of its own (`0x50474231`), the switch "Page buttons" in both profiles, and `SETTING_RULES`, `SETTING_ENTITIES` and `SETTINGS_BESIDE_BLOCK` entries in the app. Like Dark mode it never travels in the layout message, and a screen without the entity shows no row for it. - Firmware: `place_page` places the three rows itself (`runtime_tiles::rows`). With the bar on screen (more than one page and `page_buttons` on) they keep the profile's places (`Widgets::base_y`, `base_height`). Without it they share the room down to the bottom edge, keeping the side margin: Guition 108 → 122 px, CYD 52 → 61 px. `tile_scroll` grows with them. A large card keeps its profile places for the circle, name and state, lowered by half the growth; the single analog clock keeps the profile's dial. `page_buttons_changed()` (called from `apply_screen_settings`) places the page again when the bar comes or goes. `check_tile_geometry` checks that every card lies inside the tile area, clear of the bar and within the bottom margin. - Page bar: `page_prev` and `page_next` are the two halves of the band under the tiles, running from `SCROLL_Y + SCROLL_H` to the bottom (Guition 240×60, CYD 160×36). Each holds a chevron in `materialdesign_icons_mini` (F0141/F0142). `page_number` is a non-clickable `obj` that holds the dots (`settings_screen::page_dots`), so LVGL's hit test gives every point of the band to one of the halves. The settings page's pager uses the same chevrons and dots, with a dimmed chevron where it leads nowhere. - Editor: `strandedPages()` (`model/layout.ts`) and `pageReachWarning()` (`store.ts`) warn when page buttons and swiping are both off and the Go to page tiles leave a page out of reach, or give it no way back to page 1. The warning shows above the pages and under the Screen card. ### Compatibility 0.2.79 to 0.2.81 / firmware 0.2.66 to 0.2.68 Firmware only (the app raises `FIRMWARE_VERSION`); no protocol or storage change, and an older app drives this firmware as before. 0.2.79 fills a blind's small slider with its closed part, on the screen and in the editor's mockup. 0.2.80 takes a slider let go within `SLIDER_EDGE_SNAP_PX` of the left or right glass edge as its end (a size in the board file: Guition 67 px, CYD 20 px since 0.2.81; 0 turns it off). 0.2.81 gives a compact card's whole area to its small slider, keeps a slider's drag from turning the page, and no longer drops a second slider send within the 600 ms tap window. The CHANGELOG has the details. ### Compatibility 0.2.78 / firmware 0.2.65 Repeated navigation tiles: the same `screen.page_` may sit on several pages of one screen. Firmware 0.2.65 accepts repeats of `screen.page_*` only (`Model::set_layout`, `page_entity()`); every Home Assistant entity still appears once. The app sends a repeat only to 0.2.65+ (`PAGE_TILE_REPEAT_MIN_FIRMWARE` in `min_firmware`); an older screen gets "Install screen firmware 0.2.65 or newer first". Tile events from Claude in HA pick a copy by `slot`/`page` (move: `from_slot`/`from_page`); without one they act on the first copy in slot order, and the answer names its page and slot. - Firmware: `dirty_tiles` is 64 bits (`tile_bit`, `mark_tile`, `static_assert(MAX_TILES <= 64)`). `set_layout` refuses a layout whose tile list does not fit the largest free block (`tile_room`, ESP32 only) and reports it through `Model::refusal`; `TileAllocator` logs and aborts on a null block instead of writing through it. The second hand moves on `analog` and `calendar` dials while part 18 is an `lv_line` (`lv_obj_check_type`). Guition: an `LV_EVENT_GESTURE` handler on `home_page` consumes the contact (no page flip; LVGL needs speed and 50 px), and `runtime_tiles::event` drops a tap or hold released outside the tile (`lv_obj_hit_test`); LVGL's `PRESS_LOCK` stays so a finger that slides on to a slider never drags it. `dim_wake_overlay` calls `runtime_tiles::wake_tap()` after `wake_display`: on a page that is one full tile whose tap is `.toggle`, the waking push also taps it. Page bars (tile pages and the settings page) react to `on_click`/`LV_EVENT_CLICKED`. `lvgl: default_font` is a Roboto font of each board, so ESPHome no longer builds Montserrat 14 (CYD -13.9 KB). - App: `Manager.save` encodes the real layout message and refuses one over 4096 bytes; `core.packed_slots` refuses tiles that don't fit eight pages. `discover_screens` adds `firmware_known`: the "Screen firmware" sensor when it holds a strict X.Y.Z, else the device registry's `sw_version` in the form `X.Y.Z (ESPHome …)`. Every screen in the inventory carries `firmware_known`, `tile_limit`, `full_page` and `page_tiles_repeat`; the changelog moved from the update summary (and the live stream) to the full inventory's top-level `changelog`. HA refusals answer 400 and an unreachable HA 503, both JSON `{"error"}`. `client_max_size` 128 KB. Hashed `/assets/` files get `private, max-age=31536000, immutable`; the page and `/api` keep `no-store`. `config.yaml` drops `init: false` (tini is PID 1 again) and the server uses aiohttp's `handle_signals`. - Editor: `npm run build` type-checks first (`vue-tsc --noEmit`), `devEngines` pins Node. Storage unchanged. ### Compatibility 0.2.77 / firmware 0.2.64 The media card. Additive in protocol version 1; storage unchanged. - Wire: a media player's `x` block carries `artist`, `album`, `dur` and `pos` (seconds), `at` (the epoch of `media_position_updated_at`) and `pic`, a short mark of the picture (`core.media_extras`). Older firmware ignores them. - A Guition on 0.2.64+ asks for a cover with the event `esphome.screen_camera` plus `size` (48-320 px) and `bg` (six hex digits of the colour behind the rounded corners). The app answers `{op: camera, t: cover, e, u}` with a link on port 8098, or an empty `u` for a player without a picture. It serves covers only to a Guition on 0.2.64+ (`COVER_MIN_FIRMWARE`) and only for players on that screen's layout; the CYD never asks. - Firmware: `media_card.h` (the card's geometry, free of LVGL), `render_media_detail` and `render_media_full`, and one image buffer shared by the camera, the alert's picture and the cover, so one picture loads at a time. Details: docs/TEST_RESULTS_0277.md and docs/CAMERA.md. ### Compatibility 0.2.75 and 0.2.76 / firmware 0.2.63 0.2.75 draws the icon fonts at four bits per pixel (`bpp: 4`), like the text fonts: firmware only, 108 KB more flash on the CYD. 0.2.76 changes only the editor's mockup, which now draws a tile's icon on the left as the screens do. No protocol or storage change. ### Compatibility 0.2.74 / firmware 0.2.62 Forty-eight tiles per screen, a tile over the whole page (`size: full`) and navigation tiles (`screen.page_1` to `screen.page_8`). The app sends any of the three only to firmware 0.2.62+ (`FULL_PAGE_MIN_FIRMWARE`); an older screen gets "Install screen firmware 0.2.62 or newer first". Older apps keep working with this firmware: they never send `full` or a page tile, and the firmware still packs `wide` as before. Downgrade: an app older than 0.2.74 stops at startup on a stored layout with more than 20 tiles or a page tile (`validate_layout` raises "Choose at most 20 tiles." or "This entity isn't supported." even with `stored=True`); `screens.json` stays untouched. Before going back, trim the layout to 20 normal tiles, or restore the app backup (the Supervisor restores the version and its data together). - Firmware: `MAX_TILES` is `MAX_SLOTS` (48). `Model::tiles` is a `std::vector` sized to the layout (a `TileAllocator` over ESPHome's `RAMAllocator`, so a Guition keeps the list in PSRAM; the CYD's falls back to its heap); nothing may index `tiles` beyond `count`. A `Tile` has `full` next to `wide` (a full tile is also wide) and `cells()`; `pack()` and `place()` give a full tile a page of its own (`slot - slot % 6`). `screen.page_` is a built-in entity (`Tile::is_page()`, `page_target()`); a tap calls `go_to_page`, which drives the profile's `tile_page` through `show_page`. `Widgets` carries `full`, `base_height`, `title_font`, `panel_full` and 36 parts (the forecast's hours). `render_full()` draws the page-sized card: one big button (128/64 px circle, the domain icon from the new `materialdesign_icons_big` font, 64 px Guition / 40 px CYD, `tile_icons.BIG_GLYPHS` only), or the wide card's head with the small slider, the control panel (`panel_metrics_full`, bottom-centred) or the graph at the bottom; a full card that is on takes `theme::tint(state, 51)` as its surface. `check_tile_geometry` knows the full layout. - Wire: a state message's `o.size` may be `full`. Built-in tiles now send their options through `screen_options`, so a chosen icon travels as its codepoint (older firmware ignored the name it got before). No `page` option: the page a navigation tile opens is its entity. - App: `core.MAX_TILES` 48 (`LEGACY_MAX_TILES` 20 for `min_firmware`), `tile_size()`/`footprint(slot, size)` replace the boolean `is_wide` in placement, `BUILTIN` gains the eight page tiles, `layout_snapshot` reports `size` and `to_page`, `TILE_SIZES` accepts "full screen"/"whole page". The editor's tile limit is 48 from firmware 0.2.62 (`tileLimit()`), the tile sheet has Size Normal / Double-width / Full page and, for a page tile, "Goes to page" (which swaps the entity). Storage unchanged: `size` is one more value of an existing option. ### Compatibility 0.2.73 (firmware stays 0.2.61) The editor became a Vue 3 + Vite app in `web/`, built into `screen_manager/app/static` (see Local development). The add-on's API only gained `GET /api/states` (the mockup's live values, at most sixty entities per request), `POST /api/screens/{inbox}/identify` and `POST /api/alerts/test`, both through a screen's own `show_alert`; data and firmware are unchanged. The built files carry a hash of their content in their names, so a browser never combines an old script with a new page. ### Compatibility 0.2.72 / firmware 0.2.61 Firmware only (both board profiles; `cyd_ui.h` gains a comment); the app raises `FIRMWARE_VERSION`. No protocol, storage or editor change, and an older app drives this firmware as before. - The `< Previous` and `Next >` bars on `home_page` call `cyd::touch_guard.accept_repeat(millis(), 11 / 12)` instead of `accept()`. `accept()` keeps a 600 ms window per tile so one contact can never fire a tile's action twice; on the page bar that window dropped the second and third tap of Next, Next, Next, so page 4 took three waits of about half a second. `accept_repeat`, the -/+ keys' rule since 0.2.25, still drops a contact shorter than 40 ms (20 ms on the Guition), a contact that moved, a contact that already counted, and a tap within 150 ms of the last accepted tap on the same button (bounce). `show_page` already drops a fill still under way when the next page is asked for, so nothing else changed. `tests/test_cyd_ui.cpp` covers the page buttons. - On the CYD a lift-off bounce is stopped first by the XPT2046 filter (`components/xpt2046/touchscreen/touch_filter.h`: three matching samples start a contact, two empty samples release it, one empty sample is ignored); the 40 ms minimum and the 150 ms same-button gap of `accept_repeat` are backup nets behind it (noted 2026-09-18). - The settings page's own pager (`settings_screen.h`) never used the guard and is unchanged. Tile taps, cards, keys, chips and sliders keep `accept()` and its window. ### Compatibility 0.2.71 / firmware 0.2.60 Firmware only (`runtime_model.h`, `runtime_tiles.h`); the app raises `FIRMWARE_VERSION`. No protocol, storage or editor change, and an older app drives this firmware as before. - `Tile::hold_slider(now, value)`, called from `commit_slider` for a light (brightness 0-255), fan (percent) and media player (volume 0-1), writes the sent value into the attribute (`slider_field`) and keeps what Home Assistant reported in `slider_real`. A cover is left out: its position slider follows the blind as it moves. On an off light or fan it also calls `optimistic(true)`, like a tap. - `Tile::slider_reported(now)` runs in the state ingestion right after the attribute is parsed. It ends the hold when the report is within 3 % of the target (`slider_span`), when `slider_active()` is false (off, unavailable), or on a refusal; otherwise it keeps the sent value in front and notes `slider_state_at` when the reported value moved. - `Tile::slider_holding(now)`: no report yet, hold while `waiting(now)` or once `answered_at` is set; after a report, hold for `SLIDER_SETTLE` (1500 ms) since the last movement; never beyond `SLIDER_HOLD_CAP` (8000 ms) from the send. The once-a-second tick calls `release_slider()` (puts `slider_real` back) when a hold has run out, and the refusal callback does the same next to `undo_optimistic`. - `slider_value(t)` and the tile's `%` label read the attribute, so every slider and label agrees without a change of its own. Firmware before 0.2.60 keeps snapping to each report; the app sends nothing new. ### Compatibility 0.2.70 / firmware 0.2.59 Firmware only (`runtime_model.h`, `runtime_tiles.h`, `tile_controls.h`); the app raises `FIRMWARE_VERSION`. No protocol, storage or editor change, and an older app drives this firmware as before. - `Tile::waiting(now)` replaces `awaiting_action`: pending, not confirmed, not `local_feedback`, and within `BUSY_CAP` (3000 ms), or within `BUSY_AFTER_ANSWER` (800 ms) of Home Assistant's "it worked" answer (`answered_at`, set by the action-response callback). It guards what a finger may do: a second tap, a slider, a key, a chip, the -/+ edit. - `Tile::loading(now)` is what gets drawn: `waiting` once `BUSY_GRACE` (400 ms) has passed. Home Assistant answered every command in 342-599 ms on Max's installation, so a command that lands shows nothing at all; the busy sheet, the card's "Command sent..." and its greyed keys only appear for a slow one. The old rule (always busy for 1000 ms, 150 ms for a switch, then up to 6000 ms without confirmation) is gone, and with it the switch's own window. - `Tile::optimistic(on)` writes the new stand into `state` and keeps the old one in `optimistic_prev_on`, as Home Assistant's `ha-control-switch` flips before the command goes out. A tap that sends `.toggle` on a light, switch, input boolean or fan uses it, and so does a card's switch. `observe` clears the flag, so a state message always wins; `undo_optimistic` puts the old stand back on a refusal or when the wait runs out (`end_wait`, which also drops a chip's sent value). - Between the manager's messages nothing redraws a card, so `watch_busy` creates one 200 ms LVGL timer while any tile waits: it brings the sheet up after the grace, ends waits that ran out, and deletes itself. `Widgets::busy_drawn` keeps a card from being drawn again for nothing. - `action()` asks for an answer by default now, so the cards' keys, sliders, chips and the -/+ edit get the same refusal and "it worked" handling a tap has had since 0.2.67. At most four answers are watched at a time, as before. - Firmware before 0.2.59 keeps its own timing; the app sends nothing new, so any app drives either firmware. ### Compatibility 0.2.67 / firmware 0.2.58 App (new `ha_catalogue.py`, `core.py`, `server.py`, `header_bar.py`, `history_card.py`, `tile_icons.py`, the editor and the Claude skill), `runtime_model.h`, `tile_controls.h`, `runtime_tiles.h` and `text.py`; the app raises `FIRMWARE_VERSION` and both profiles' icon fonts grow. The storage version and the tile protocol stay 1. A tile gains the tap choice `action` with the option `action`; its state message gains the options `act` and `x.w`, and `o.icon` now carries most tiles' icon. Without Home Assistant's answers everything behaves as before. Capabilities (what the editor offers): - What the editor offers per entity comes from Home Assistant. `HomeAssistant.fetch_services` keeps the whole `get_services` answer (it kept only `esphome`), and every `service_registered`/`service_removed` refreshes it after the usual second; only an `esphome` one still wakes the sync loop. `HomeAssistant.entity_actions` asks `get_services_for_target` per entity (Home Assistant 2025.12+, core PR 157334) and keeps the answer until the action list, the registry or the entity's `supported_features`/`device_class` change. A Home Assistant that answers "Unknown command" is remembered, and `ha_catalogue.local_actions` evaluates the `target.entity` filters of the descriptions instead: every condition of a filter must hold, a `supported_features` entry is a mask the entity must support completely, any entry will do. On Max's Home Assistant 2026.9.2 both answers were identical for 30 of 30 entities. - `ha_catalogue.capabilities` turns the action list into what the editor may offer: `toggle` (`.toggle`), `inline` and `controls` (the Home Assistant action each of our widgets sends, `INLINE`/`CONTROLS`, with a field that must pass its `filter`: a light's small slider needs `light.turn_on` with `brightness_pct`), and `displays` (graph for a sensor `history_card.kind` draws as a line, forecast for weather with the daily bit or no features yet). `GET /api/capabilities?entity=...` returns it for up to 40 entities, null when unknown (no action list, no state). - `Manager.check_supported` runs before `Manager.save` for the editor's PUT and for tile events. It refuses a tap, slider, control or display Home Assistant doesn't support only when that setting is new or changed for the tile; what a tile already has keeps saving, and nothing is refused while the capabilities are unknown. - `validate_layout` no longer limits `tap: toggle` to six domains (only `screen.*` has none); `check_supported` takes that place. `validate_layout(data, stored=True)` loads the app's own data leniently: a tile whose options fail validation (a newer app's setting after a downgrade) keeps them as stored instead of stopping the app. The editor and tile events still validate strictly. - Firmware: `tile_controls::tap_route` decides what a tap or hold does and `runtime_tiles::event` carries it out. `tap == "toggle"` on a short tap sends `.toggle` for every domain before the domain's own card; before, the 13 runtime-card domains opened their card first, so a cover could not toggle. `tests/test_tile_controls.cpp` transcribes `event()` of firmware 0.2.56 and checks 800 combinations (22 domains x 4 tap choices x 5 states x hold): every one routes as before, including `busy`; only a short tap with `toggle` on the 16 domains that could not have it changes. - Firmware before 0.2.58 opens the card for `toggle` on those domains; the editor says so under On tap and keeps the choice. The editor keeps showing a stored choice that Home Assistant no longer supports, with a warning, and hides it otherwise; a tile that becomes wide gets the first supported direct control when the domain's default isn't there. Hints under a field sit beside the label (`withHint`): a click inside a `