Let the screen own its settings, answer its pings, and show them as cards (0.2.57)

A code check found that a setting changed on a screen could jump back.
ESP Screen Manager kept its own copy and sent it with every full update:
after a restart of the app or Home Assistant, every hour and after every
save. A change it missed while it or Home Assistant restarted came back
to the old value, a reported setting was lost when save() refused a
layout with a removed entity, and every step of a held - or + on the
screen resent the whole screen.

Firmware 0.2.49 offers every setting as an entity over the existing
preference records (no migration): Night mode, Night starts/ends,
24-hour clock, Back to page 1 and after how long, Back to page 1 on
standby, Swipe between pages and, on the Guition, Rotation, next to the
four numbers and Auto standby. The settings page and every entity write
through settings_screen::set(), which clamps like the page and stores,
applies and reports only a real change. screen_message answers a caller
that asks with its status and layout revision (api.respond). The
settings tile opens without Home Assistant.

The app reads and changes the settings of such a screen through its
entities and leaves them out of the layout message. Older firmware keeps
them with the layout; a setting it reports is stored without the tile
check and not sent back. Pings to screens that can answer learn at once
whether a tile is missing and resend after 30 s instead of 120 s. Layout
sensors come back after a Home Assistant restart, and weather tiles only
ask for the forecasts the entity supports. The editor's settings form is
now three cards with the rows of the screen's own page; a change applies
at once, and an offline screen shows its values as unknown.

Checked with 245 Python and 14 C++ tests, generate_packages --check,
ESP32 builds of both profiles, host-build API tests on both boards, and
a live run against Home Assistant 2026.9.1 with a temporary host device.
Firmware 0.2.49.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-16 15:50:41 +02:00
parent 93fa7e2712
commit b58f0cde25
23 changed files with 2419 additions and 534 deletions
+58
View File
@@ -176,6 +176,64 @@ 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.57 / firmware 0.2.49
App, both board profiles and `components/smart_display`. Storage version 1, tile protocol `v: 1`, preference
keys and records are unchanged, and the eleven-key `settings` block is not widened.
- Firmware 0.2.49 offers every setting as a template entity (`entity_category: config`) over the existing
preference records (`0x53435231` settings, `0x53575031` swipe, `0x484F4D31` back to page 1, `0x524F5431`
rotation), so a screen keeps its values across the update without a migration. New: switches Night mode,
24-hour clock, Back to page 1, Back to page 1 on standby and Swipe between pages (`restore_mode: DISABLED`,
state from a lambda); number Back to page 1 after (30-3600 s, step 30); datetimes of type time Night starts
and Night ends; select Rotation (`0°` to `270°`, Guition only). The four numbers and Auto standby are
unchanged entities. `restore_value` cannot be combined with a lambda, which is why the entities keep no copy.
- Every writer goes through `settings_screen::set(key, value)`: the rows of the settings page and the
`set_action`s, `turn_on_action`s and `turn_off_action`s of the entities. It clamps the way the page steps,
pulls both dim levels down with the brightness, and stores, applies and reports (`changed()`) only a real
change; it returns `SetResult::same` or `SetResult::unknown` otherwise. The switches publish from their
lambda in `loop()`. `apply_screen_settings` publishes the numbers, times and the select when they differ; it
runs after every change, every minute and on every Home Assistant time sync, which the API requests right
after a client connects, so the entities have a value before Home Assistant subscribes to states. A host
build with `time: platform: host` gets no such sync and publishes them at the next whole minute.
- `screen_message` has `supports_response: optional`. With a call id (a caller that sets `return_response`)
it answers `{"status": <inbox state>, "rev": <layout revision>}` through `api.respond`; without one it
neither answers nor logs "Cannot send response". The layout message still takes `settings` and the extra
keys, so app 0.2.56 and older keep working with firmware 0.2.49 as before.
- `runtime_tiles::event` handles the built-in cards before the `fresh()` check: the settings card opens without
Home Assistant, the clock card still does nothing. `settings_screen::refresh()` only writes a label text, knob
colour or card opacity that differs, so a value from Home Assistant does not redraw unchanged rows.
- The app finds the setting entities per ESPHome device in the entity registry (`setting_entities`, by domain
and `original_name`, disabled entries included). A device with one of `OWNED_SETTINGS_MARKERS` owns its
settings; the five older entities cannot tell. For such a screen `layout_message` leaves out `settings`,
`swipe_pages`, `auto_home`, `auto_home_seconds` and `rotation`; `save()` ignores `settings` from the editor
(values stored before the update stay unused in `screens.json`); `esphome.screen_setting` events are
ignored; `settings_view` reads the values from Home Assistant states (None while unavailable, keys only for
entities the device has); `PUT /api/screens/{inbox}/settings` calls `number.set_value`,
`switch.turn_on`/`turn_off`, `time.set_value` (`HH:MM:00`) and `select.select_option` (`90°`), brightness
first, and only for values that differ.
- Older firmware keeps its settings in the layout. A reported setting is stored with
`store_settings(..., on_screen=True)`, without the tile check of `save()` that refused a layout with a
removed entity; `sent[inbox]['layout']` follows the stored settings and `sent[inbox]['rev']` keeps the
revision the screen holds, so nothing goes back and the next ping still matches. `sync_one` keeps that
revision while it does not send the layout message. Failures in the event loop are logged.
- Answers: `HomeAssistant.fetch_services` collects the ESPHome actions for which `get_services` lists
`response`, again after `service_registered` or `service_removed` in the `esphome` domain. For those screens
the keepalive ping, and one ping right after a batch that sent a layout, wait up to 5 s for the answer.
A status from `RESEND_STATES` or starting with `Error` schedules a full resend 30 s after the last full send,
doubling per failure up to the 120 s guard; any other status resets the count. A timeout waits for the next
ping; a refusal whose message names `response` makes that action ping without answers from then on. The
text entity keeps its state for older apps and for the screen list. Home Assistant 2026.9.1 lists the
action with `response: {optional: true}` and passes the answer back.
- `Manager.run()` clears `published` together with `sent` while Home Assistant is offline, so the layout
sensors made over REST are written again after a restart. `tile_message` asks `weather.get_forecasts` only
for the kinds in `supported_features` (1 daily, 2 hourly; twice daily is not used); an entity without the
attribute is asked for both, as before.
- Editor: `SETTING_GROUPS` in `app.js` mirrors the groups, labels, steps and duration ladder of
`settings_screen.h` (a test compares them). Changes are debounced (150 ms for switches and chips, 600 ms
for steps) and sent as one PUT per screen; the panel keeps its own value for up to 4 s or until Home
Assistant reports it. Save no longer sends `settings`. The top bar's clock format is the `clock_24h` setting.
### Compatibility 0.2.56 / firmware 0.2.48
`components/smart_display/settings_screen.h` and both board profiles; the app only raises `FIRMWARE_VERSION`.
+105 -40
View File
@@ -1,14 +1,47 @@
# Settings: on the screen and in ESP Screens
# Settings: on the screen, in Home Assistant and in ESP Screens
Every screen setting lives in two places at once: the **Screen settings** block of the add-on editor, and
the **settings page on the screen itself** (firmware 0.2.44+). This page says what that page is made of,
and exactly what adding one more setting takes.
Every screen setting can be changed in three places: the **settings page on the screen itself**
(firmware 0.2.44+), its **entity in Home Assistant** (firmware 0.2.49+), and the **Screen settings**
panel of the add-on editor. This page says who owns a value, what the page is made of, and exactly what
adding one more setting takes.
## Who owns a setting
**Firmware 0.2.49 and newer: the screen.** It keeps every value in its own preferences and offers each one
as an entity in the *config* category of its ESPHome device. The settings page, an automation and ESP Screens
all change a value through `settings_screen::set()`, so they never disagree, and nothing has to catch up
after ESP Screens or Home Assistant was away. ESP Screens reads the values from Home Assistant's states,
changes them with the entity's own action, and leaves them out of the layout message.
| Row on the screen | Entity | Key in ESP Screens |
|---|---|---|
| Brightness | `number.<screen>_normal_brightness` | `brightness` |
| Auto standby | `switch.<screen>_auto_standby` | `standby_enabled` |
| Standby after | `number.<screen>_standby_after` | `standby_seconds` |
| Standby brightness | `number.<screen>_standby_brightness` | `standby_brightness` |
| Night mode | `switch.<screen>_night_mode` | `night_enabled` |
| Starts, Ends | `time.<screen>_night_starts`, `time.<screen>_night_ends` | `night_start`, `night_end` |
| Night brightness | `number.<screen>_night_brightness` | `night_brightness` |
| Clock | `switch.<screen>_24_hour_clock` | `clock_24h` |
| Back to page 1, After | `switch.<screen>_back_to_page_1`, `number.<screen>_back_to_page_1_after` | `auto_home`, `auto_home_seconds` |
| Also on standby | `switch.<screen>_back_to_page_1_on_standby` | `home_on_standby` |
| Swipe between pages | `switch.<screen>_swipe_between_pages` | `swipe_pages` |
| Rotation (Guition) | `select.<screen>_rotation` | `rotation` |
The first four entities and Auto standby existed before 0.2.49; ESP Screens recognizes a screen that owns
its settings by one of the others (`OWNED_SETTINGS_MARKERS` in `core.py`).
**Older firmware: ESP Screens.** The values travel in the layout message: `settings`, the frozen block of
eleven keys, with `swipe_pages`, `auto_home`, `auto_home_seconds` and `rotation` as keys of their own. A
change on the screen comes back as an `esphome.screen_setting` event, which ESP Screens stores with the
layout without sending it back.
## What the user sees
Holding the top bar of the overview for about one and a half seconds opens the page; a blue line grows
along the top edge while you hold, and letting go before it finishes cancels. A screen can also carry a
`screen.settings` tile, and Home Assistant can open it with `esphome.<screen>_open_settings`.
`screen.settings` tile, which opens the page with or without Home Assistant, and Home Assistant can open it
with `esphome.<screen>_open_settings`.
The page is a menu of groups, each of which opens a page of its own:
@@ -19,9 +52,11 @@ The page is a menu of groups, each of which opens a page of its own:
| Screen | Clock, Back to page 1, After, Also on standby, Swipe between pages, Rotation (boards that turn) |
| This screen | Screen, Address, Firmware, Home Assistant, Restart |
Every change is saved on the screen, applied at once, and sent to the add-on as an
`esphome.screen_setting` event, so the editor shows the same value within a second. The add-on remains
the owner: its next layout message repeats every setting, and a screen that was offline catches up then.
Every change is stored on the screen, applied at once and published on its entity, so Home Assistant and
ESP Screens show it within a second. The editor's **Screen settings** panel has the first three groups as
cards with the same rows: a switch for a toggle, `-` and `+` that repeat while held, chips for the clock
and the rotation. A change there applies at once, without Save. An offline screen shows its values as
unknown and takes no changes until it is back.
## The rules the page follows
@@ -36,63 +71,93 @@ the owner: its next layout message repeats every setting, and a screen that was
## Adding a setting
The example below adds "Beep on touch" (`beep`), a switch.
The example below adds "Beep on touch" (`beep`), a switch. Every firmware that has it also owns its
settings, so a new setting never travels in the layout message.
### 1. Where the value lives
Two homes, and the choice is fixed by history:
- `screen_settings::Settings` in `components/smart_display/screen_settings.h` is the **frozen** block of
eleven values that ESP Screens sends as `settings`. Its format is version 1 and changing it needs a
preference migration *and* firmware-version gating in the add-on, because older firmware refuses a
eleven values that ESP Screens sends older firmware as `settings`. Its format is version 1 and changing it
needs a preference migration *and* firmware-version gating in the add-on, because older firmware refuses a
`settings` object that does not have exactly its own keys. Don't.
- Anything new is a plain value in `settings_screen.h` next to `swipe_pages`, `rotation` and `auto_home`,
with its own preference and its own key in the layout message. That is backward and forward
compatible in both directions: old firmware ignores the key, a new screen with an old add-on keeps its
saved value.
with a preference record of its own: load it in `runtime_tiles::load_settings()` and write it in
`persist_settings()`.
```cpp
// settings_screen.h, next to the others
inline int32_t swipe_pages = 0, rotation = 0, auto_home = 1, auto_home_seconds = 120, beep = 0;
```
Storing it (`components/smart_display/runtime_tiles.h`): extend `HomeTimeout`-style records or add one of
your own, load it in `load_settings()`, write it in `persist_settings()`, and accept the key in
`receive()` next to `auto_home` — validate first, then save.
### 2. The one write path, and the row
### 2. The row
One line in the table of the group it belongs to, in `settings_screen.h`:
`settings_screen::set(key, value)` is where every writer ends up. Give the key a branch that clamps it the
way the page steps it, and add the value to the before/after comparison at the bottom, so a value the screen
already has is not stored, applied or reported again:
```cpp
toggle("Beep on touch", [] { return beep; },
[](int32_t value) { beep = value ? 1 : 0; changed("beep", beep); }),
else if (key == "beep") reported = beep = flag(value);
```
`changed(key, value)` saves, applies and reports in one go; `key` must be the add-on's key exactly.
The row kinds are `toggle`, `number` (fixed step, optional unit), `duration` (seconds, steps grow with
the value), `moment` (minutes since midnight, quarters by tap and hours while held), `choice`, `info`
and `action`. A row can carry `shown` (leave it out on boards that lack the hardware) and `enabled`
Then one line in the table of the group it belongs to:
```cpp
toggle("Beep on touch", [] { return beep; }, [](int32_t value) { set("beep", value); }),
```
`set()` calls `changed(key, value)`, which stores, applies and reports in one go; `key` must be the add-on's
key exactly. The row kinds are `toggle`, `number` (fixed step, optional unit), `duration` (seconds, steps
grow with the value), `moment` (minutes since midnight, quarters by tap and hours while held), `choice`,
`info` and `action`. A row can carry `shown` (leave it out on boards that lack the hardware) and `enabled`
(grey while the switch it depends on is off).
Watch the count: a group of more than five rows gets a pager on a 320x240 board. Six is the maximum
that still fits a 480x480 board in one go.
### 3. The add-on
### 3. The entity
- `screen_manager/app/core.py`: one line in `SETTING_RULES` (`'beep': (False, None, None)`), which gives
you validation, storage and the write-back from the screen for free.
- `screen_manager/app/server.py`: add the key to `extra` in `layout_message` and send it as its own key,
next to `auto_home`. Never let it into the `settings` block.
- `screen_manager/app/static/app.js`: one line in `settingDefinitions` for the editor's own control.
- Needs new firmware? `min_firmware()` in `core.py` keeps the screen from being told about something it
cannot do.
In both board profiles, with the same name as the row:
### 4. Tests and proof
```yaml
switch:
- platform: template
id: setting_beep
name: "Beep on touch"
entity_category: config
restore_mode: DISABLED
lambda: 'return settings_screen::beep != 0;'
turn_on_action:
- lambda: 'settings_screen::set("beep", 1);'
turn_off_action:
- lambda: 'settings_screen::set("beep", 0);'
```
The screen's preferences hold the value, so the entity reads it in a lambda instead of keeping a copy
(`restore_value` cannot be combined with a lambda). A switch publishes its own changes. A number, time or
select takes `update_interval: never` and one line in `apply_screen_settings`, which publishes it when it
differs; that script runs after every change and on every Home Assistant connection (the time sync), so the
entity has a value before Home Assistant reads the states. Then run `python3 tools/generate_packages.py`.
### 4. The add-on
- `screen_manager/app/core.py`: one line in `SETTING_RULES` (`'beep': (False, None, None)`) for validation,
one in `SETTING_ENTITIES` (`'beep': ('switch', 'Beep on touch')`, the entity name exactly as in the
profiles), and the key in `SETTINGS_BESIDE_BLOCK` so it never enters the frozen block.
- `screen_manager/app/server.py`: `settings_view` already leaves a key out for a screen whose device has no
entity for it. Leave it out for a screen that does not own its settings too (`owner` `'layout'`), like
`auto_home` below firmware 0.2.44: such firmware cannot have it.
- `screen_manager/app/static/app.js`: one row in `SETTING_GROUPS`, with the label the screen uses.
- `screen_manager/app/claude_skill.py`: a row in the table of screen entities.
### 5. Tests and proof
- `tests/test_settings_screen.cpp` (`clang++ -std=c++17 -Wall -Wextra -Werror -I.`) walks every page and
every row: a new row is checked by the table test as soon as it exists.
- `tests/test_settings_view.py` and `tests/test_layout.py` cover the add-on side.
every row, and checks `set()`: add the key to its table of clamps.
- `tests/test_screen_owned_settings.py` checks the entities in both profiles, `SETTING_ENTITIES`, the editor
rows (labels, steps and the duration ladder against `settings_screen.h`) and the add-on's calls;
`tests/test_settings_view.py` and `tests/test_layout.py` cover the rest of the add-on side.
- Render it before believing it: `.esphome/readme-render/host_build.py <board> --compile` builds the real
firmware for the Mac and `render_settings.py <board>` drives it over the API and saves PNGs of every
page, including the hold gesture, the time picker and the settings tile.
page, including the hold gesture, the time picker and the settings tile. The host build takes its time
from the Mac instead of Home Assistant, so without a connection-time sync its numbers, times and select
get their first value at the next whole minute.
+92
View File
@@ -0,0 +1,92 @@
# Test results app 0.2.57 / firmware 0.2.49 (2026-09-16)
A code check (2026-09-16) found that settings changed on a screen could jump back: ESP Screens sent its own
copy with every full update, missed changes while it or Home Assistant restarted, refused to store a
reported setting when a tile's entity was gone, and resent the whole screen for every step of a held − or +.
This release makes the screen the owner of its settings (entities in Home Assistant), lets the screen answer
the add-on's pings, fixes the settings tile without Home Assistant, the layout sensors after a Home Assistant
restart and the hourly forecast request, and replaces the editor's settings form with cards. See CHANGELOG
0.2.57 and the compatibility note in docs/RELEASING.md.
## Automated
- Python (`.venv-portal`): 245 tests OK. New: `tests/test_screen_owned_settings.py` (entities in both
profiles and packages, `SETTING_ENTITIES` against the entity names, the editor view and calls for a screen
that owns its settings, unknown values while offline or without an entity, the layout message without
settings, answers and their retries, the `Refused` fallback, forecast kinds, layout sensors after a
restart, the settings tile before `fresh()`, the editor panel against `settings_screen.h`).
`tests/test_quiet_resync.py` now expects a reported change to be kept without a resend and with the held
revision; `tests/test_settings_page.py` checks every row writes through `set()`.
- C++ (`clang++ -std=c++17 -Wall -Wextra -Werror -I. -I components/smart_display`): 14/14 PASS.
`test_settings_screen` adds `set()`: an unchanged value stores nothing, every key clamps like the page,
brightness pulls both dim levels down, rotation is refused where the board cannot turn.
- `generate_packages.py --check` green.
- Caught by these tests before release: with a reported setting stored without a resend (new in this
version), `sync_one` would have recorded the new layout's revision although the screen still held the old
one, and the next ping would have caused a full resend after all. `sync_one` now keeps the held revision
when the layout message is not sent.
## Builds (ESPHome 2026.6.2)
Check profiles with the generated packages and local components (same substitutions, API and Wi-Fi blocks
for both versions of the CYD).
| Profile | RAM | Flash | vs 0.2.56 |
|---|---|---|---|
| CYD | 23.4% (76,540 B) | 76.6% (1,406,227 B) | +1,056 B RAM, +9,492 B flash (same check profile at 93fa7e2) |
| Guition | 24.4% (79,916 B) | 18.6% (1,510,555 B) | about +1.2 KB RAM, +13.7 KB flash (against the 0.2.56 Easy build) |
Eight new entities on the CYD, nine on the Guition, each a component object on the heap at boot. Free heap
on real hardware was not measured.
## Firmware on the Mac (host builds, driven over the ESPHome API)
- Settings, Guition 25/25 and CYD 24/24: every setting entity offered in the config category, Rotation only on
the Guition, the defaults on a fresh screen, numbers, times and switches publish; a number from Home
Assistant lands through `set()` and is reported once, the same value again reports nothing, standby
brightness clamps to the brightness, a lower brightness pulls both dim levels down; switches, time, number
and select land and Home Assistant sees them back; an open settings page shows a change from Home
Assistant; `screen_message` answers a ping and a layout with status and revision, answers nothing to a
caller without a call id and logs no warning; the settings tile opens while a tile never got its state;
a restart keeps what Home Assistant set.
- Wake and Sleep regression (the 0.2.53 host test), Guition 35/35 and CYD 35/35.
## Editor
The real server on a demo home (fake Home Assistant), in a browser: a change sends one PUT and applies,
several quick changes go out in one request, a held + repeats, a screen with firmware 0.2.48 shows the note
about firmware 0.2.49 and still saves with the layout, the top bar's clock format is the same setting, Save
sends no settings, and the cards stack on a phone-sized window.
## Live with Home Assistant 2026.9.1
The Guition firmware ran on the Mac and was added to the owner's Home Assistant through the ESPHome config
flow for the test, then removed. The add-on's own `HomeAssistant` and `Manager` classes ran against it, with
the registry narrowed to the test device so no real screen was touched.
- 34/34 checks: Home Assistant lists `esphome.<node>_screen_message` with `response: {optional: true}`, all 14
setting entities with the ids the Claude skill names, all in the config category; the editor view reads
the defaults; a sent layout is confirmed by an answered ping (`Synced` and the revision, 16 ms); a stale
ping answers `Resend needed` and schedules one retry within 30 s; the keepalive ping is answered; a ping
without an answer logs no warning; Home Assistant refuses `return_response` for an action without answers
with "An action which does not return responses can't be called with return_response=True", which the
fallback recognizes; brightness, the dim-level pull-down and eleven settings at once (switches, times,
numbers, the select) reach the screen and come back as states; the same values again call nothing;
invalid values are refused; − and + on the screen's own page reach Home Assistant, the editor view and
one `esphome.screen_setting` event; no errors in the screen's log.
- The editor against this Home Assistant: a switch and a step in the panel reached the screen in one PUT; two
presses of − on the screen's page showed in the open panel without a reload; with the screen stopped the
panel said offline and disabled every control; back online it showed the kept values again.
- Found and fixed during this check: the panel showed default values for an offline screen. It now shows
them as unknown (— and a switch between on and off).
- Restart: with the Home Assistant time source of the real profiles, every number, time and the select had
its value at the moment Home Assistant reconnected, before the next minute. The host test profile, which
takes its time from the Mac, showed them as unknown until that minute; that is the host build only.
## Not tested / for the owner
- Real hardware. Nothing was flashed; Studio 1 updates through Home Assistant. After updating the add-on and
the screen: the settings page and the new entities agree, a change in ESP Screens shows on the screen and
one made on the screen shows in ESP Screens, the Settings tile opens, and the screen's settings survive the
firmware update itself.
- A CYD on firmware 0.2.49 (compiled and host-tested only), including its free heap after boot.