# 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. 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 the source profiles and shared components. Run `python3 tools/generate_packages.py`; never edit `packages/*.yaml` directly. The editor is the Vue app in `web/`: after a change under `web/src`, run `cd web && npm ci && npm run build` and commit `screen_manager/app/static` with it (that folder is the build output; never edit it by hand). 2. Run all Python tests with aiohttp installed, all `tests/*.cpp`, the generator with `--check`, and compile both Easy Setup profiles. Do that sequentially: profiles with the same `DEVICE_NAME` share one build folder, and a parallel build can make an upload pick the wrong `firmware.bin`. Check that no secrets are in Git. 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. 5. Commit and push main (the only release branch). Create an immutable tag `screens-vX.Y.Z` from the same commit. 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: existing ESPHome device → Install → Wirelessly. 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 .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 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`. 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. 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 both board profiles 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); run `generate_packages.py` afterward. The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual profiles must come from the set. No changed preferences or keys. ### 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. - 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 `