Files
homeassistant_espscreen/docs/RELEASING.md
T
Max Gramser 93fa7e2712 Let a card's back button take its taps again (0.2.56)
Since firmware 0.2.44 the back arrow of the brightness, colour and climate
cards barely responded, and so did the button at the top right of the
climate card. on_boot created the settings hold strip after the YAML cards
on home_page, so the transparent strip lay on top of them and LVGL's hit
test gave it the taps in the top bar region.

attach_hold now takes the first card as `below` and moves the strip and its
fill line to that index, under every card. Holding the top bar opens the
settings page from the tile pages only. The dim wake overlay was never
affected: apply_screen_settings moves it to the foreground when dimmed.

Checked with 215 Python and 14 C++ tests, generate_packages --check, and
ESP32 builds of both Easy profiles. Firmware 0.2.48.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:34:21 +02:00

56 KiB
Raw Blame History

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.
  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:

python3 -m venv .venv-portal
.venv-portal/bin/pip install aiohttp PyYAML
.venv-portal/bin/python -m unittest discover -s tests

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 static/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.56 / firmware 0.2.48

components/smart_display/settings_screen.h and both board profiles; the app only raises FIRMWARE_VERSION. Storage, tile protocol, preferences and keys are unchanged.

  • settings_screen::attach_hold(page, x, y, w, h, below) moves the hold strip and its fill line to the index of below, directly under it. Both profiles pass id(brightness_overlay), the first of the YAML cards on home_page (brightness, colour, climate, climate mode, then the dim wake overlay). Before, on_boot created the strip after them, so LVGL's hit test found the transparent strip (x 32-448 on the Guition, 72 px high) before a card's back button (x 16-76) or its action at the top right. The dim wake overlay was not affected: apply_screen_settings moves it to the foreground whenever the screen is dimmed. The runtime detail card and the settings page move themselves to the foreground and were not affected either.

Compatibility 0.2.55 / firmware 0.2.47

App, board profiles and components/smart_display. Storage, tile protocol, preferences and keys are unchanged.

  • The app: Manager.run() compares the validated settings of an esphome.screen_setting event with the stored ones and skips save() when they are equal. Before, every event saved the layout, save() dropped sent[inbox], and the next pass resent layout, header and every tile state.
  • The four setting numbers (setting_brightness, setting_standby_brightness, setting_night_brightness, setting_standby_seconds) return before settings_preference.save and setting_event when screen_settings::current did not change (Settings::operator==). The Auto standby switch already did. No state is published in that case: apply_screen_settings publishes numbers only when they differ.
  • runtime_tiles::receive, op layout: layout_changed() and refresh_all() run only for changed or moved tiles, the first layout, a rotation or settings change, or another pages count or title. A repeat still sets inbox, keepalive_seconds, layout_rev and last_received; the tile states after it redraw their own tiles through refresh_tile.
  • Guition on_boot wraps the read callback of the LVGL touch input (ESPHome's LVTouchListener): cyd::GhostTouch repeats the previous read when a pressed read lands exactly on the native (0, 0), mapped with LvglComponent::rotate_coordinates, and logs touch: GT911 stray contact at (0,0) ignored. on_update skips (0, 0) before touch_guard and edge_swipe. The CYD keeps its own XPT2046 TouchFilter.
  • cyd::release_jump logs (tag slider, WARN) when LVGL's recalculation on release (update_knob_pos(obj, false) for RELEASED in LVGL 9.5) moves a dragged slider to an end, more than a quarter of its range away from where it was held: the three colour-card rows and the tile sliders.
  • Measured on Studio 1 (firmware 0.2.43, 2026-09-16): automation.studio_1_scherm_wakker_houden_bij_licht, a state trigger on a group of six lamps without to:, set number.studio_1_standby_after to its current value after every colour change. The resulting esphome.screen_setting event triggered a full resync (layout plus 13 states) 0.34 s later. Both red commits (hs_color [0, 100]) left 75-85 ms after such a resync reached the screen. Earlier Studio 1 logs show a stray (0, 0) as the last sample in 4 of 90 traced edge touches. ESPHome 2026.6.2's GT911 driver writes 0 to the status register before it reads the coordinates; that ordering is a candidate for an upstream fix.

Compatibility 0.2.54 / firmware 0.2.46

Board profiles; the app raises FIRMWARE_VERSION and adjusts the Auto standby row of the Claude skill. Storage, tile protocol, preferences and keys are unchanged.

  • The Auto standby switch no longer clears sleep_requested; wake_display is the only place that does. The one rule lives in apply_screen_settings: a dimmed screen wakes when standby is off unless sleep_requested is set, whichever path turned standby off (the switch, ESP Screens, the settings page).
  • dim_display has no condition any more; its callers decide (the 1 s interval checks standby_enabled, the Sleep button sets the flag). It sets display_dimmed, runs apply_screen_settings, then go_home with home_on_standby on, otherwise settings_screen::close() and close_cards. Runtime detail cards now close on standby in both cases, as the light, colour and climate overlays already did.
  • close_cards is runtime_tiles::dismiss(), which on_boot sets on both boards (hide the detail card, clear active_entity, hide the four overlays). The fallback after it and the four lvgl.widget.hide actions ran for nothing: a return in a lambda does not skip the actions that follow it.
  • The back_light light is internal: true, so Home Assistant loses light.<screen>_display_backlight (CYD: light.<screen>_power_display_backlight); it stays in the registry as unavailable until deleted. Nothing in the app, the tools or the tests used it; the firmware keeps the light under the same id.
  • apply_screen_settings loses the !runtime_tiles::enabled clock-label branch: on_boot sets enabled before anything can run the script. The runtime_tiles::enabled checks before preference saves stay, because load_settings() makes that preference right after enabled is set.

Compatibility 0.2.53 / firmware 0.2.45

Firmware and board profiles; the app raises FIRMWARE_VERSION and extends the Claude skill. Storage, tile protocol, preferences and keys are unchanged.

  • Both profiles add two template buttons, wake_button ("Wake", mdi:gesture-tap) and sleep_button ("Sleep", mdi:power-sleep), without an entity category: they are controls. Wake runs wake_display on a dimmed screen (the path the tap on dim_wake_overlay takes) and only sets last_touch_ms on a screen that is on. Sleep is ignored during a touch calibration; otherwise it sets the new global sleep_requested, runs alert_dismiss (reason remote, a no-op without an alert) and runs dim_display. Both log a line with tag standby at INFO.
  • sleep_requested (bool, not restored) lets dim_display dim with standby_enabled off and stops apply_screen_settings, which runs every minute, from waking a dimmed screen while standby is off. wake_display clears it (a tap, Wake, an alert, open_settings, touch_diagnostics), and so does the Auto standby switch when it really turns standby off; a switch that was already off leaves it. Nothing is saved, so the buttons cost no flash writes.
  • core.WAKE_SLEEP_MIN_FIRMWARE is 0.2.45. claude_skill.text() lists both buttons in "Standby and brightness" with one more YAML example (nine in total), so an installed skill shows as outdated until it is installed again. The skill description stays under 200 bytes.

Compatibility 0.2.52 / firmware 0.2.44

App and firmware. Storage version and tile protocol stay 1; everything is additive, so an old screen with this app, and a new screen with an older app, both keep working.

  • Two settings, beside the frozen block. settings.auto_home (bool, default true) and settings.auto_home_seconds (30-3600, default 120) are new keys in SETTING_RULES, stored in storage version 1. On the wire they travel as their own top-level keys of the layout message, next to swipe_pages and rotation; the eleven-field settings object is unchanged, because firmware before 0.2.44 refuses any other size. core.SETTINGS_BESIDE_BLOCK is the one list of such keys and layout_message uses it. Older firmware ignores the two keys.
  • Firmware storage. A new preference key 0x484F4D31 holds HomeTimeout (two uint32: enabled, seconds). The Settings structure 0x53435231, swipe 0x53575031, rotation 0x524F5431 and the CYD calibration are untouched. runtime_tiles::persist_settings() saves all of them; ESPHome compares each blob with NVS before writing, so unchanged records cost no flash write.
  • Settings page on the screen. components/smart_display/settings_screen.h: one constexpr table of pages and rows, drawn into a root object created on open and deleted on close. Opened by holding the top bar (attach_hold, substitutions SETTINGS_HOLD_X/W/DRIFT_PX; on the Guition the strip keeps clear of both swipe bands), by the screen.settings tile, or by the API action open_settings (page 0-4, negative closes and goes home). A change is saved, applied (apply_screen_settings) and reported with the existing esphome.screen_setting event; the manager's handler accepts every key in SETTING_RULES, the new ones included. Row taps use the board's own drift limit (TOUCH_MOVE_LIMIT_PX), the hold its own.
  • Back to page 1 by itself. The 1 s interval runs the new go_home script (settings page closed, cards closed, page 1) when auto_home is on and nothing touched the screen for auto_home_seconds, while a card, the settings page or a later page is showing. Standby now always closes the settings page; an alert closes it too.
  • screen.settings tile. A second built-in entity next to screen.clock. min_firmware asks for 0.2.44, so the manager does not send a layout with it to older firmware. Tile::is_clock() now marks the clock-only paths (minute redraw, second hand) that used builtin(); the editor mockup takes the icon from tile_icons.BUILTIN_TILES.
  • Glyphs. monitor, information-outline and restart join tile_icons.FIXED (183 glyphs).
  • API. open_settings is a new ESPHome action; Home Assistant lists it as esphome.<screen>_open_settings after the device reconnects with 0.2.44. The IP address text sensor gained id: wifi_ip; its entity is unchanged.
  • Docs. docs/SETTINGS.md is the recipe for adding a setting; the Claude skill gains a section on the page (one more YAML example), so an installed skill shows as outdated until it is installed again.

Compatibility 0.2.51 / firmware 0.2.43

App only; no firmware change, no protocol change, no storage change. Layouts keep their shape, so an older app reads what this one writes.

  • core.TILE_EVENTS maps four Home Assistant events to an action: esp_screens_add_tile (add or change), esp_screens_remove_tile, esp_screens_move_tile and esp_screens_order_tiles. apply_tile_event(layout, action, data) returns the new layout and raises ValueError with the sentence the log and the answer carry; match_screen finds the screen by device name, the name Home Assistant shows, the device, the area or the layout title, and needs no name when one screen is paired. tile_options turns the event's fields into tile settings (color is the pastel background) and makes a tile with a control, a forecast or a sun path double-width. event_page/event_slot accept page (from 1), row (1-3) and column (left/right), or slot as the editor counts it. place_tile, free_slot and pack_page keep a double-width tile in the left column and leave gaps alone.
  • HomeAssistant subscribes to those events, queues them in tile_events, and gained fire() (the websocket fire_event) and set_state() (the REST API, the only way an app can publish a state). Manager.tile_loop handles one event at a time, saves through Manager.save so validation, the entity check and the push are the editor's, and answers with TILE_RESULT_EVENT (ok, screen, entity, error).
  • Manager.publish_layouts writes sensor.esp_screens_<node> per screen after every sync pass and after every tile event: the state is the tile count, the attributes carry title, pages and tiles (entity, name, page, row, column, slot, size, controls, display). States made over the REST API disappear when Home Assistant restarts; the sync loop writes them again.
  • The Claude skill gains "Tiles on a screen" with the four events, the fields, the controls and displays per domain (generated from CONTROLS/DISPLAYS), how to read the sensor first, and the rule to confirm before changing a screen. Its description changed, so an installed skill shows as outdated until it is installed again. tests/test_tile_events.py covers the logic and one event end to end; tests/test_claude_skill.py checks the section and the two new YAML examples.

Compatibility 0.2.50 / firmware 0.2.43

Firmware, board profiles and the package generator; the app only raises FIRMWARE_VERSION. Storage, protocol, preferences and keys are unchanged.

  • Slider fills: runtime_tiles::slider_handle and the light card rows in light_controls.h give LV_PART_INDICATOR the track's radius (height * 12 / 42, round at 20 px or less). A smaller fill radius makes LVGL 9.5's lv_bar draw the fill into an ARGB8888 layer of its own size on every redraw and clip it to the track (a 128×30 fill: 15 KB). When that allocation failed, the refresh retried until the task watchdog reset the CYD (log: lv_draw_buf_create_ex: No memory: 115x30, cf: 16). tests/test_layer_free.py rejects layer styles (opa_layered, transform rotation/scale/skew, blend mode, bitmap masks, clip_corner, drop shadow, blur) and a fill radius that differs from its track in both profiles, both packages and the firmware headers. opa itself makes no layer in LVGL 9.5.
  • runtime_model.h: Tile keeps the shared fields inline. Extra holds climate modes and action, select options, forecast/hours/wind/feels, sunrise/sunset/timer, media title, vacuum speeds, choices, room and charging. It lives on the heap only while a state message fills something in (Tile::set_extra, extra(), edit_extra(), extra_ptr()). options and fan_speeds are vectors (no option_count/fan_speed_count), and history holds 24 samples or none. revision/pending_revision are FNV-1a fingerprints (Fingerprint, also an ArduinoJson writer) instead of copies of state, attributes and extras. On the ESP32 runtime_tiles::model shrinks from 24,296 to 8,216 bytes.
  • The old manual profile is gone from both board profiles: the 80 homeassistant sensor and text sensors of the fixed tiles, smartdisplay_action, the tile tap and long-press handlers, publish_action, do_tile_action, the manual refresh in ui_refresh, the YAML vacuum card with open_vacuum_overlay/vacuum_action/vacuum_fan_speed, light_controls::subscribe, the 331 TILE* substitutions, and DYNAMIC_TILES/TILE_COUNT/DIRECT_ACTIONS/TIME_24H/ORIENTATION. runtime_tiles::enabled is set true at boot and the DIRECT_ACTIONS guards became runtime_tiles::fresh(). The runtime detail hook lost its unreachable vacuum branch. Deleted: device.example.yaml, guition-device.example.yaml, tools/new_device.py, tools/export_bundle.py, TILE_CONFIGURATION.md, docs/TILES.md, docs/ACCEPTANCE.md, CYD_STABILITY.md, TEST_RESULTS.md, and the five stale header copies in the repository root (cyd_ui.h, light_controls.h, alert_overlay.h, backlight_fade.h, guition_diagnostics.h) that had drifted from components/smart_display/; tests/test_cyd_ui.cpp and tests/test_light_controls.cpp were compiling those copies and now include the component headers, as both profiles do. tools/generate_packages.py no longer strips or substitutes anything for the manual profile: the board profile's own defaults are the package's, and a homeassistant sensor in a profile is refused. The entity "SmartDisplay Action" disappears from every screen. The USB touch calibration (tools/calibrate.py, docs/CALIBRATING.md, calibration.example.yaml, CALIBRATION_ON_BOOT, the measurement page) and the on-screen calibration are unchanged.
  • esphome config of both Easy profiles is byte-identical before and after this cleanup, apart from two reordered lines: the firmware does the same thing, with the same calibration.
  • tile_scroll and dim_wake_overlay (and the manual vacuum rows) get pressed: bg_opa: TRANSP. The theme's obj pressed style (bg_opa 45%) lit up the transparent area on a tap between tiles and on the wake tap.
  • Both profiles add the debug sensor min_free ("Heap Minimum Free": heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL), every 300 s).

Compatibility 0.2.49 / firmware 0.2.42

Firmware and the CYD board profile; the app only raises FIRMWARE_VERSION. Storage, protocol, preferences and keys are unchanged.

  • home-like-2432s028.yaml adds init_sequence to the mipi_spi display: 0xB4 0x07 (frame inversion) and 0xB1 0x00 0x10 (119 Hz). ESPHome appends these after its built-in ILI9341 sequence, so the panel init otherwise stays the ESPHome default. Found on 2026-09-15 by sending panel commands at runtime on the bench CYD; VCOM (0xC5) sweeps changed contrast, not the stripes, so it stays default.
  • guition-4848s040.yaml only raises SCREEN_FIRMWARE_VERSION.

Compatibility 0.2.48 / firmware 0.2.41

Storage, protocol, preferences and keys are unchanged.

  • Both profiles add a template switch setting_auto_standby ("Auto standby", entity category config, restore_mode: DISABLED, state from screen_settings::current.standby_enabled through its lambda). Its actions change standby_enabled, save the settings preference, send the existing esphome.screen_setting event (standby_enabled, 1/0; the app already stores bool settings from it) and run apply_screen_settings, which wakes a dimmed screen when standby is off. Turning it on sets last_touch_ms, so the standby time counts from then.
  • claude_skill.text() gains "Standby and brightness" (the switch, the four numbers, an example automation); core.AUTO_STANDBY_MIN_FIRMWARE is 0.2.41. The skill description stays under 200 bytes. An installed skill shows as outdated until it is installed again.

Compatibility 0.2.47 / firmware 0.2.40

Firmware and board profiles; the app only raises FIRMWARE_VERSION. Storage, protocol, preferences and keys are unchanged.

  • guition-4848s040.yaml gets the CYD's climate card (0.2.46): the arc, climate_detail_bg/_scrim, climate_mode_pill and the climate_number font are gone; setpoint_digits is Roboto 400 at 64 px. The scripts climate_adjust_target, climate_target_send and climate_card_refresh match the CYD, except that the Guition's refresh hides the ··· key and lays the card out per case (no, one or two settings rows, with or without mode keys) from one table, applied only when that case changes.
  • New components/smart_display/climate_card.h: climate_card::show(rows, count) draws the fan ('f') and swing ('s') rows into climate_settings_card through lv_async_call, so a row can redraw itself from its own tap; taps go through cyd::touch_guard and the profile's climate_card::choose hook, which runs set_climate_fan_mode / set_climate_swing_mode with the index in the entity's list (cyd::list_item). At most six choices per row. Both scripts now refresh the card at once.
  • Both profiles hide the mode key row when a device offers at most one mode besides off (and no ···), and centre the setpoint card instead.
  • Glyph arrow-oscillating (F1C91), Home Assistant's swing icon, joins tile_icons.FIXED.

Compatibility 0.2.46 / firmware 0.2.39

Storage version, tile protocol (v: 1), preferences and keys are unchanged. Additive wire fields only.

  • A vacuum's state message carries new extras in x (core.vacuum_extras): mode and water (e select entity, s state, o options, l labels, r one role letter per mode: v/m/b/a), fan (the suction speeds to offer, o/l), bat (0..100), chg (1 while charging) and room. Options are capped at six, labels at 24 bytes. With a mode select, speeds and water levels that a mode covers (off, custom, smart_mode, …) leave the rows, and custom leaves the mode row unless it is the current mode. a.fan_speed_list stays capped at four, so firmware before 0.2.39 draws its old suction row; firmware 0.2.39 with an older app does the same.
  • core.vacuum_related finds the selects, the battery sensor (device_class: battery), the charging binary sensor (battery_charging) and the room sensor on the vacuum's device, by translation key first (entity ids follow the HA language) and entity id ending second; disabled entities are skipped. Manager.device_entries indexes the registry per device (rebuilt with the registry object), related_entities adds these ids to watched_entities and to the sync_one reuse check, so a select change resends the vacuum's message.
  • Firmware: Tile::choices (Choice: kind m/w/s, entity, current, roles, values, labels, sent), room, charging; the state revision now includes x, so a select change confirms a pending command. Chips send select.select_option (or vacuum.set_fan_speed), mark the vacuum tile pending and redraw the card through lv_async_call after the event. tile_controls::vacuum_rows / vacuum_role / shown_value / settle_suction / choice_action are covered by tests/test_tile_controls.cpp. Detail commands: 10-15 suction, 50-55 mode, 60-65 water; detail_actions holds 32 buttons. tick() redraws an open vacuum card when its state text changes.
  • CYD climate card (home-like-2432s028.yaml): the arc is gone. New widgets climate_setpoint_card, climate_mode_keys, cmkey_*, font setpoint_digits (Roboto 400, 40 px, digits . - °), globals active_climate_current, active_climate_action, active_climate_target_known, scripts climate_card_refresh and climate_target_send (restart mode, 700 ms, entity and value as parameters); climate_target_preview/climate_target_commit are removed. tile_controls::mode_color holds HA's mode colours (also used by domain_accent). Glyph dots-horizontal (F01D8) joins tile_icons.FIXED.
  • Both profiles: the 1 s interval clears climate_edit_pending when the climate card is closed and, on runtime tiles, replays the held-back state through detail_update, instead of running ui_refresh every second while the flag stayed set.

Compatibility 0.2.45 (firmware stays 0.2.38)

App only: storage version, tile protocol, preferences and keys are unchanged.

  • Alerts for every screen: on connect the app also subscribes to the Home Assistant events esp_screens_show_alert and esp_screens_dismiss_alert. The reader only queues them; Manager.alert_loop handles them in arrival order, apart from the sync loop, and calls esphome.<node>_show_alert / _dismiss_alert at once on every screen from discover_screens that is online, has a device name and reports firmware 0.2.31+ (one call per node). core.alert_data types the seven fields the way Home Assistant validates ESPHome actions (text, an int clamped to 0..86400, a bool) and leaves an unusable value empty instead of failing the call. The log line <event>: N of M screens (not: …) names skipped and failed screens.
  • claude_skill.py builds SKILL.md from the alert reference and tile_icons, byte for byte the same on every call. POST /api/claude-skill writes $HA_CONFIG/.claude/skills/esp-screens/SKILL.md (default /homeassistant, the start folder of the Claude Code apps) only on request; GET /api/claude-skill.zip returns esp-screens/SKILL.md for claude.ai, whose description limit is 200 characters. The full inventory's claude_skill compares the file with the current text.
  • Editor: the header tools and the updates block moved into #settings-view, shown for the hash #settings; element ids and their handlers are unchanged.

Compatibility 0.2.44 / firmware 0.2.38

Firmware only: show_detail() creates no detail_status for a large vacuum card (the hero badge carries the state; tick() already guards on a null status).

Compatibility 0.2.43 / firmware 0.2.37

Firmware only: storage version, tile protocol, preferences and keys are unchanged.

  • runtime_tiles::screen_awake is a hook both profiles set to !id(display_dimmed); without it the runtime counts as awake. second_hand() draws part 18 (points 28-29) of an analog clock card and tick() moves it once a second while awake, hiding it otherwise; the minute redraw stays as it was.
  • slider_handle() now sets the track and fill radii (12/42 and 8/42 of the height, round ends under 20 px), a handle of 4 px (2 px on small strips) an eighth of the height from the end, and a shortest fill of a third of the height (slider_stub). slider_bar() hides fill and knob for an off light or fan (slider_bar_shown); tracks are the fill colour at 20 % over the card. light_controls::Row::off does the same for the colour card's brightness row.
  • commit_slider floors a light's brightness at 3 (1 %); the card tap turns it off.
  • Overlay top bar: the profiles' *_close_button, climate_mode_overlay_x, vacuum_overlay_x and the new overlay_back_button are one round arrow-left (glyph F004D, added to tile_icons.FIXED) button at the top left, 60/40 px (Guition/CYD) at (16,16)/(10,8); climate_power_button is the same size at the top right. color_done_button and vacuum_overlay_ok are gone; show_detail() draws the same bar for the runtime detail card.
  • render() shows "Connecting to Home Assistant..." / "Waiting for ESP Screens..." until the first layout arrives.

Compatibility 0.2.42 / firmware 0.2.36

Firmware only: storage version, tile protocol, preferences and keys are unchanged. What moved inside runtime_tiles.h (measurements in docs/SWIPE_PROFILE.md):

  • render() is split into render_slot(). A page switch (show_page to another page) places the page, shows every card as a skeleton (a sheet in the card colour over its content area, Widgets::veil) and draws two cards per LVGL refresh (fill_cards, FILL_STEP_CARDS, LV_EVENT_REFR_READY, 40 ms fallback); a new switch cancels the fill. apply_page (same page: keepalives, re-packing) still draws everything at once. check_tile_geometry finishes a fill still under way and logs page fill still under way at the check.
  • refresh_tile(index), refresh_header_only() and refresh_all() mark what the next render() draws; a plain refresh() from the board YAML draws every card, as before.
  • A slot hides custom parts and control panels instead of deleting them and reuses them for the same kind (hide_extra, hide_panel); end_extra/end_panel still delete on a kind change.
  • Guarded style setters set_color/set_number; card content sizes from the styles (tile_width, content_width, content_height) instead of lv_obj_update_layout().
  • Card sliders: slider_handle() narrows the knob and moves it back into the fill and sets the range to -below..1000, so 0 still shows one round end; slider_event keeps values at 0 or above, commit_slider clamps as before. Off cards get a grey fill.
  • The busy sheet is sized from the requested card width and kept as the tile's last child.
  • show_page ignores a request past the first or last page when that page is already shown.
  • tick() redraws clock cards when the minute changes.
  • light_controls::setup(parent, font, width, height, icon_font): the icon font is new and optional; both board profiles pass materialdesign_icons_mini (the glyphs palette, thermometer and lightbulb are already in the tile icon set). Rows, sliders, open() and self_test() keep their behaviour; the brightness range starts below 1 for the same round end.
  • swipe_profile.h and the swipe_test message exist only with -DSWIPE_PROFILE=1; release builds and packages never set it.

Compatibility 0.2.41 / firmware 0.2.35

Storage version and tile protocol stay 1. Firmware 0.2.35 only raises SCREEN_FIRMWARE_VERSION, so the owner can test the update path from 0.2.34: no new entities, names, preferences or keys. App 0.2.41 changes the README, the App store description (screen_manager/README.md, images through absolute raw.githubusercontent.com URLs because the App store does not resolve relative paths) and FIRMWARE_VERSION.

Compatibility 0.2.40 / firmware 0.2.34

Storage version and tile protocol stay 1; no new message fields, preferences or keys. Firmware 0.2.34 changes only text: every on-screen label, log line and status reply is English, and so are the entity names. The status replies the manager reacts to are now Ready for tile configuration, Loading tiles, Resend needed, Synced and Error: …; RESEND_STATES in server.py and diagnostics/*.py accept both languages, so screens on firmware 0.2.33 and older keep working unchanged.

The entity names are part of the compatibility contract. Home Assistant's ESPHome integration builds the unique ID from the entity name (<MAC>/0/text_info/Tile settings), so a renamed entity gets a new entity ID and the old one is removed as soon as the screen reconnects (seen on HA 2026.9.1: text.cyd_2_8in_display_tegelinstellingen → text.cyd_2_8in_display_tile_settings within the same second). Renamed: Tegelinstellingen → Tile settings, Schermfirmware → Screen firmware, Apparaatnaam → Device name, IP-adres → IP address, Guition schermtype → Guition screen type, Opgestart → Last boot, Helderheid normaal/standby/nacht → Normal/Standby/Night brightness, Standby na → Standby after, Touch kalibreren → Calibrate touch.

core.discover_screens matches both names (NAME_* pairs). Because the manager keys screens.json and updates.json by the inbox entity ID, Manager.follow_renamed_inboxes (run whenever the screen list is rebuilt) moves a screen's layout, update address and last result to its new inbox ID: first by the HA device an inbox was seen on during this run, otherwise (after a restart) when a stored ID's device part (core.inbox_prefix) matches the entity ID prefix of one of the device's ESPHome entities (core.device_prefixes; the unchanged Node Status, Restart and Wifi Signal entities keep the old prefix). An existing layout on the new ID is never overwritten; layouts of other screens are never taken. Manager.aliases maps old to new IDs so save(), the inspector and the updater (Updater.current_id, Updater.renamed) follow a screen that is renamed mid-update: the Update button and the nightly round verify the new ID and record success there. Don't rename these entities again without keeping this path. Tests: tests/test_renamed_inbox.py.

Compatibility 0.2.38 / firmware 0.2.32

Storage version and tile protocol stay 1. A layout may have an optional object header: {items: [...]} (six max): {type: clock|analog|date} or {type: entity, entity, content: state|last_changed, icon: auto|none|<name>, show: always|active}. Without header, the firmware's prior always-on behavior applies (the clock from show_clock); an old editor that omits header keeps the stored bar. When saving, settings.show_clock follows the clock in the bar, so older firmware only shows the time if it's in the bar. Besides the tile domains, entities in the bar may also be device_tracker, zone, lock, alarm_control_panel, counter, event, input_datetime, input_text, water_heater, and humidifier; the inventory marks those with tile: false so the tile picker skips them.

On the wire, right after the layout message, the manager sends {v:1, op: header, items} with, per item, k (clock, analog, date, text, ago), i (codepoint), t (text, already formatted — in English since app 0.2.40 — and limited to the glyphs of sublabel_big), e (unix time for ago), and c (accent color). Items with show: active that aren't active are not sent by the manager. Only firmware 0.2.32+ gets this message (HEADER_MIN_FIRMWARE, based on the reported screen firmware): older firmware would refuse op: header with "Fout: ongeldig bericht" (that firmware still reports in Dutch). The message is compared separately; a changed value sends only the bar, not the full layout. The firmware replies with the same status as a tile status, so the inbox entity doesn't flip on every value.

Firmware: header_bar.h (LVGL-free: parsing, "5 min ago", date, distances, placement) and runtime_tiles::draw_header(). The profile's time label still exists as a reference (right margin, draw order) but is hidden in runtime mode. Values use sublabel_big, icons materialdesign_icons_mini; sublabel_big gained ', #, *, =, ;, ², ³, µ, –, and common accented characters; tests/test_header_bar.py keeps header_bar.GLYPHS matching that list. The editor (barLayout in app.js) and the firmware (header_bar::gaps/place) share the same integer math; the test checks that. tools/render_topbar.py renders the real draw_header() on a Mac/Linux machine (ESPHome host + SDL2) to a PNG, without a screen. No changed preferences or keys.

Compatibility 0.2.35 / firmware 0.2.30

Firmware only (Guition). on_boot removes LV_OBJ_FLAG_CLICKABLE from home_page and tile_scroll: a press outside the tiles no longer has an LVGL object (no pressed style, no redraw, no events); the edge swipe runs via the touchscreen triggers and doesn't need that, lv_indev_wait_release also works without an active object. Trace lines swipe … (veeg … before firmware 0.2.34) at INFO only while edge_swipe.armed(). Otherwise unchanged.

Compatibility 0.2.34 / firmware 0.2.29

Firmware only. LVGL 9.5.0's send_event() in lv_indev.c sends the input device PRESSED, RELEASED, CLICKED, LONG_PRESSED, and KEY, but no PRESSING; an lv_indev_add_event_cb therefore gets no position updates during a press. cyd::EdgeSwipe therefore works again on the native touch points from on_touch/on_update/on_release (configure(width, height, band, travel), begin(x, y, rotation) with ESPHome's rotation mapping, update(), end()); the indev callback is gone. Otherwise unchanged.

Compatibility 0.2.33 / firmware 0.2.28

Firmware only. TouchGuard::configure(0, ...) disables the movement limit (moved_ is then never set; LVGL's press-lost decides whether a tap goes through). The Guition profile sets TOUCH_MOVE_LIMIT_PX to 0, the CYD keeps 56. Otherwise unchanged.

Compatibility 0.2.32 / firmware 0.2.27

Firmware only. cyd::EdgeSwipe works on LVGL pointer coordinates: configure(band, travel), begin(x, y, width) with lv_display_get_horizontal_resolution(), update() with the 45° rule, plus inward()/sideways() for the log. The Guition registers a single lv_indev_add_event_cb (PRESSED/PRESSING/RELEASED) in on_boot; the touchscreen triggers now only feed the TouchGuard. Both board profiles get the close_cards script (calls runtime_tiles::dismiss(), otherwise hiding overlays and clearing active_entity); the cards' close buttons and background taps, and wake_display, use it. Protocol, storage, preferences, and keys unchanged.

Compatibility 0.2.31 / firmware 0.2.26

Storage version and tile protocol stay 1. tiles[].slot is additive: the absolute slot of a tile (page × 6 + row × 2 + column, 0–47, MAX_SLOTS in core.py); a double-width tile sits on an even slot and also covers the slot to its right. Empty slots are allowed. validate_layout requires slots for all tiles or for none: without slots (old editor, old storage), the layout gets the slots from the old order-based packing (pack_slots, the same as pack() in the firmware), so nothing changes on the screen; with slots, overlap, odd double-width, and out-of-range are refused, and the tiles are sorted by slot. save() repeats the packing after restoring saved options (which can widen a tile). pages (1–8) is an optional, additive field: pages the user wants to keep empty.

On the wire, the layout message gets slots (one slot per entity, in the same order) and pages. Firmware 0.2.26 (Model::set_layout with positions, place() alongside the old pack(), MAX_PAGES 8) draws the tiles at those slots; a change to slots only (moved) repositions the pages without clearing tile statuses. Older firmware ignores both fields and packs the entities in order; since the entities are sorted by slot, that's the old gap-free display, hence no min_firmware. The editor reports this (has_gaps/hasGaps) for firmware < 0.2.26. No changed preferences or keys.

Compatibility 0.2.30 / firmware 0.2.25

Editor only: the status dot in the screen list is its own span.dot (green with .online). Firmware only gets a new version number; protocol, storage, preferences, and keys unchanged.

Compatibility 0.2.29 / firmware 0.2.24

Firmware only (Guition). cyd::EdgeSwipe (in cyd_ui.h, tested in test_cyd_ui.cpp) replaces the LVGL gesture on home_page on the Guition: begin() in on_touch with the LVGL rotation, update() in on_update for the first contact; on a hit, touch_guard.consume(), lv_indev_wait_release() on all indevs, and show_tile_page. Substitutions EDGE_SWIPE_BAND_PX/EDGE_SWIPE_TRAVEL_PX; DISPLAY_W/DISPLAY_H determine the rotation mapping (ESPHome: 90° x=y, y=W-x-1, 270° x=H-y-1, y=x, 180° mirrors both). The CYD keeps the LVGL gesture. The swipe_pages setting remains the toggle; protocol, storage, and keys unchanged.

Compatibility 0.2.28 / firmware 0.2.23

Firmware only. cyd::TouchGuard gets configure(move_limit_px, min_press_ms) (called in on_boot from the substitutions TOUCH_MOVE_LIMIT_PX and TOUCH_MIN_PRESS_MS; without the call, the old 18 px and 60 ms apply), tracks only the contact id the touch started with, measures displacement as distance to a reference point settled over four measurements, and reports via reason() why a tap was rejected; runtime_tiles::allowed() logs that at INFO with tag touch. Protocol, storage, preferences, and keys unchanged.

Compatibility 0.2.27 (firmware stays 0.2.22)

App only. POST /api/install (YAML download) has been removed; POST /api/firmware/profiles writes the profile, fills in missing Wi-Fi keys in secrets.yaml (only the missing lines, verified by re-parsing the result; an invalid file is left untouched), and, given target (a reported USB port), immediately starts install. Port and build slot are checked before writing. The response contains api_key; the page shows it once for the HA pairing. The job object gets stage (config, compile, upload). profile_meta provides extra screen (profile uses this project's board package) and api_key; /api/inventory gets the additive list pending, with such profiles that have no paired screen. Storage, protocol, preferences, and keys unchanged.

Compatibility 0.2.26 / firmware 0.2.22

Storage version and tile protocol stay 1. The layout message gets the additive field keepalive (seconds, 5–3600): the interval at which the app repeats the entire layout (KEEPALIVE_SECONDS in server.py, 120 s since 0.2.20). The firmware derives its data-flow monitoring from that (two rounds plus 60 s) and then reports "ESP Screens not active"; "HA not connected" now comes from ESPHome's own API connection status (api_is_connected()), no longer from the age of the last message. Without the field, firmware 0.2.22 assumes 120 s; firmware up to 0.2.21 ignores the field and keeps its fixed 95 s, which, with a 120 s keepalive, causes the known "HA not connected" flicker. So only change KEEPALIVE_SECONDS together with a firmware flash of existing screens, or keep it under 40 s. No changed preferences or keys.

Compatibility 0.2.24 / firmware 0.2.20

Internal only: Tile::forecast and Tile::hours are vectors (previously fixed arrays in each tile); protocol and storage unchanged. The wizard YAML gets power_save_mode: none under wifi:; the shared packages contain no Wi-Fi block, so existing screens only change once the user adds the line themselves.

Compatibility 0.2.23 / firmware 0.2.19

Storage version and tile protocol stay 1. tiles[].options.controls is additive: none or a set from CONTROLS in core.py per domain (climate setpoint/mode, switch/light/fan toggle plus brightness/speed, vacuum/cover/timer buttons, cover position, media_player volume/playback, number stepper/slider, select stepper, scene/script/button run). On the wire, the manager sends in o.controls only the set the card actually shows: double-width only, default display, and without a mini-slider; without a choice, the domain's first set; with none, nothing. Status messages get the attributes device_class, hvac_action, and is_volume_muted (the only boolean included). Older firmware ignores the field and shows the regular wide card, hence no min_firmware; the tile panel reports from which firmware it works. An old editor that omits controls keeps the stored choice (like background). The firmware builds the panel lazily per set (layout_panel), sends −/+ after 700 ms as a single set_temperature/set_value, and leaves the local value in place until HA reports it (or 10 s). The icon fonts get 18 fixed control glyphs (tile_icons.FIXED). No changed preferences or keys.

Also additive in the same release: x.hours (up to eight hours of t, c, h, p, r) and p/r per day in x.days for the weather card; x.last (unix time) for scenes, scripts, and buttons; the attributes humidity, wind_speed, wind_speed_unit, apparent_temperature, fan_modes, swing_modes, fan_mode, and swing_mode. supported_features may exceed the one-million limit. Older firmware ignores all these fields. The busy status uses LVGL's spinner; both board profiles include a hidden spinner for that (busy_spinner_seed), otherwise ESPHome compiles LV_USE_SPINNER as 0.

Compatibility 0.2.12 / firmware 0.2.14

Storage version and tile protocol stay 1. Additive: tiles[].options.size (single/wide), new display values (forecast for weather, graph for sensors, digital/analog for screen.clock), the built-in entity screen.clock, and the HA domains sun, timer, and person. Status messages get an optional object x with values computed by the manager (forecast days, sun times in the HA timezone, timer end time as epoch). Older firmware ignores size, new displays, and x, but refuses unknown domains across the whole layout; the manager therefore only sends such a layout once firmware 0.2.14 or newer is detected (min_firmware), holding it back in the meantime. Forecasts come via weather.get_forecasts with return_response; without a response, the weather card stays the regular card. No changed preferences or keys.

Compatibility 0.2.39 / firmware 0.2.33

Storage version and tile protocol stay 1; no new fields in screens.json, no changed preferences or keys. Everything is additive and, per screen, gated on the reported screen firmware:

  • Transport. Firmware 0.2.33 has the API action screen_message (one string message, the complete JSON message, 4096 bytes max). The manager calls esphome.<device_name>_screen_message as soon as the screen reports 0.2.33+ (TRANSPORT_MIN_FIRMWARE in core.py, Manager.transport); below that, the base64 chunks in the inbox text entity are still used. The action publishes the result to the same inbox entity, so the status on the management page stays the same. ESPHome's API frame on the ESP32 is 32 KiB; receive() refuses anything above 4096.
  • Keepalive. The layout message gets rev (twelve hex characters, core.revision of the layout message without rev); older firmware ignores the field. Firmware 0.2.33 stores the revision and replies to {v:1, op:"ping", rev, keepalive} with Synced/Loading tiles (revision matches) or Resend needed (otherwise, or after a restart; firmware before 0.2.34 says Gesynchroniseerd/Tegels laden/Indeling opnieuw nodig). A ping refreshes the feed window (last_received) just like a layout does. The manager pings every KEEPALIVE_SECONDS (120 s) and only repeats everything after FULL_REPEAT_SECONDS (3600 s), or immediately when the inbox reports a status from RESEND_STATES (Ready for tile configuration, Resend needed, Loading tiles, and the Dutch originals of older firmware), with a 120 s guard after the last full send. Firmware below 0.2.33 still gets the full repeat every 120 s as before. A repeated, unchanged layout message now replies Synced instead of Layout received; diagnostics/send_layout.py accepts both, in both languages.
  • Incremental. HomeAssistant.dirty collects the changed entity IDs; Manager.sync_one(..., dirty=...) builds only those tiles and, if one of them is in the top bar, the bar too. dirty=None (first round, new registry, was offline) builds everything and sends the diffs; force=True sends everything. sent stores {'layout','header','states','rev'} per screen. Manager.screens() replaces inventory()[0] in the loop and the updater: only the ESPHome screen entities are read, cached on registry identity plus the status of those entities. inventory() (full) remains for the editor and save().
  • History. Manager.history_loop (its own task in main()) fetches one recorder/statistics_during_period call per window (1/6/24 hours) (hour for 24 hours, 5minute below that, types: [mean, state]; mean, otherwise state), and, per sensor with no rows, falls back to GET /history/period, marking changed entities dirty. The history field in the status message is unchanged (24 values, None until the first). A sensor tile goes out the door without history the first time and gets it seconds later; the firmware then shows the graph.
  • Diagnostics (firmware). The Uptime sensor (seconds, every 15 s) is gone; replaced by Opgestart (Last boot since firmware 0.2.34; platform: uptime, type: timestamp, device class timestamp, one value per boot; time: is already present). Deliberately a new entity: Home Assistant's entity registry keeps the unit s for the old one and then refuses a timestamp ("has a unit of measurement ... non-numeric device class: timestamp", seen on 2026-09-14). The ESPHome integration removes the old sensor.<screen>_uptime itself as soon as the screen connects with the new firmware. Also debug: update_interval 300 s, and the four template numbers set to update_interval: never, published from apply_screen_settings when the value differs from their last state (so also once after boot).