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
+5 -3
View File
@@ -50,9 +50,11 @@ physically tap; an agent cannot replace that with software coordinates.
- Keep base hardware and UI in `home-like-2432s028.yaml`; personal data belongs
in the gitignored local profiles.
- Screen settings live in one table: `components/smart_display/settings_screen.h` draws the
page on the screen, `SETTING_RULES` in the add-on validates and stores the same keys.
docs/SETTINGS.md is the end-to-end recipe for adding one; never widen the eleven-key
`settings` block older firmware insists on.
page on the screen, `SETTING_RULES` in the add-on validates the same keys. Firmware 0.2.49+
owns them: every writer (the page, the entities in both profiles) goes through
`settings_screen::set()`, and the add-on changes them through the entities in
`SETTING_ENTITIES`, never through the layout message. docs/SETTINGS.md is the end-to-end
recipe for adding one; never widen the eleven-key `settings` block older firmware insists on.
- Preserve fixed pages, hidden navigation at six tiles or fewer, and a
minimum default standby of 600 seconds. Don't reintroduce free scrolling
without physically testing for touch/navigation regressions.
+16 -4
View File
@@ -92,8 +92,10 @@ The firmware and built-in CLI are tested with **ESPHome 2026.6.2**.
changed ("5 min ago", "Yesterday"). See [Top bar](#top-bar).
- **Screen settings:** standby time, normal and dimmed brightness,
night hours, 24- or 12-hour clock, return to the home page, and optional swiping
between pages. Home Assistant automations can turn **Auto standby** off and on per
screen (firmware 0.2.41+), for example to keep a screen on while someone is home, and
between pages. Change them in ESP Screens, where they apply at once, or on the screen
itself. With firmware 0.2.49+ the screen keeps them, and every one of them is also an
entity in Home Assistant, so an automation can switch **Night mode** or **Auto standby**
(firmware 0.2.41+), for example to keep a screen on while someone is home, and
wake a screen or put it to sleep with its **Wake** and **Sleep** buttons (firmware 0.2.45+).
See [Wake and sleep](#wake-and-sleep-from-an-automation).
- **Settings on the screen itself** (firmware 0.2.44+): hold the top bar for about a
@@ -370,13 +372,21 @@ Home Assistant, `esphome.<screen>_open_settings` opens it too (`page` 0 menu, 1
Tap a toggle to flip it, `-` and `+` to change a number or a time — hold them and a time walks
whole hours — and tap a chip like the clock to cycle it. Every change is saved on the screen,
takes effect at once, and appears in ESP Screens within a second, so both sides always show
the same value.
the same value. The **Screen settings** cards in ESP Screens have the same rows.
**In Home Assistant** (firmware 0.2.49+), every setting is an entity on the screen's device, under
*Configuration*: `number.<screen>_normal_brightness`, `switch.<screen>_night_mode`,
`time.<screen>_night_starts`, `switch.<screen>_24_hour_clock`, `select.<screen>_rotation` on a
Guition, and the rest. An automation, the settings page and ESP Screens all change the same value,
and setting a value the screen already has costs nothing. The full list is in
[docs/SETTINGS.md](docs/SETTINGS.md#who-owns-a-setting).
## Updates and keeping your settings
| Change | Action |
| --- | --- |
| Tiles, names, colors, order, or screen settings | Save in ESP Screens; no firmware flash |
| Tiles, names, colors, or order | Save in ESP Screens; no firmware flash |
| Screen settings | Change them in ESP Screens (they apply at once), on the screen, or on their entities in Home Assistant |
| New version of the management page | Update ESP Screen Manager in the HA App store |
| New feature on the physical screen | The **Update** button on the screen (badge *Update x.y.z*), or **Update automatically every night** under Settings |
@@ -399,6 +409,8 @@ screen only the tile that changed, as a single action
(`esphome.<device_name>_screen_message`) instead of chunks in a text field.
Every two minutes, a small ping follows with the layout revision; if the
screen reports that it doesn't match (after a restart, for example), everything is resent.
Firmware 0.2.49+ answers that ping, and a new layout, directly, so a screen that still
lacks a tile gets it again after 30 seconds instead of two minutes.
Graphs on sensor tiles come from Home Assistant's statistics, in one
query for all screens. Older firmware still works via the text field and the
full resend every two minutes. The diagnostic sensor `Uptime` has been
+10 -3
View File
@@ -1060,9 +1060,18 @@ inline std::string date_text(const esphome::ESPTime &now) {
}
inline void event(lv_event_t *event) {
auto &w = *static_cast<Widgets *>(lv_event_get_user_data(event));
if (!enabled || !fresh() || w.index >= model.count) return;
if (!enabled || w.index >= model.count) return;
auto code = lv_event_get_code(event);
if (code != LV_EVENT_SHORT_CLICKED && code != LV_EVENT_LONG_PRESSED) return;
// The built-in cards need nothing from Home Assistant: the settings card opens the screen's own page with
// the link down too, as its card says (firmware 0.2.49+), and the clock card does nothing under a finger.
if (model.tiles[w.index].builtin()) {
auto &card = model.tiles[w.index];
if (!card.is_settings() || card.tap == "none" || (settings_screen::may_open && !settings_screen::may_open())) return;
if (allowed(esphome::millis(), 100 + w.index, card.entity)) settings_screen::open();
return;
}
if (!fresh()) return;
if (!allowed(esphome::millis(), 100 + w.index, model.tiles[w.index].entity)) return;
auto &tile = model.tiles[w.index];
auto d = tile.domain();
@@ -1073,8 +1082,6 @@ inline void event(lv_event_t *event) {
if(code==LV_EVENT_SHORT_CLICKED && tile.tap=="toggle")open=false;
if(d=="media_player" && tile.tap=="toggle" && code==LV_EVENT_SHORT_CLICKED){action("media_player.toggle",tile.entity);return;}
if(d=="climate" && tile.tap=="toggle" && code==LV_EVENT_SHORT_CLICKED){action("climate.toggle",tile.entity);return;}
// The clock card does nothing under a finger; the settings card opens the page.
if(tile.builtin()){if(tile.is_settings())settings_screen::open();return;}
// A short tap runs or pauses the timer; holding opens the card with a cancel button.
if(d=="timer" && !open){action(tile.state=="active"?"timer.pause":"timer.start",tile.entity);return;}
if(d=="sensor" || d=="binary_sensor" || d=="weather" || d=="number" || d=="input_number" || d=="select" || d=="input_select" || d=="media_player" || d=="vacuum" || d=="sun" || d=="person" || d=="timer") { tile.begin(esphome::millis(),true); active_index=w.index; show_detail(w.index); return; }
+65 -46
View File
@@ -93,6 +93,43 @@ inline void changed(const char *key, int32_t value) {
if (report) report(key, value);
}
// The one way a setting changes, for every writer: the rows below, and the Home Assistant entities of the
// board profile (firmware 0.2.49+). The value is clamped the way the page steps it, a lower brightness pulls
// the two dim levels down with it, and a value the screen already has is not saved, applied or reported
// again: an automation may set it on every light change.
enum class SetResult : uint8_t { unknown, same, changed };
inline SetResult set(const std::string &key, int32_t value) {
auto &s = screen_settings::current;
const auto before = s;
const int32_t before_swipe = swipe_pages, before_rotation = rotation, before_home = auto_home,
before_home_seconds = auto_home_seconds;
int32_t reported = 0;
auto flag = [](int32_t v) -> int32_t { return v ? 1 : 0; };
if (key == "standby_enabled") reported = s.standby_enabled = flag(value);
else if (key == "standby_seconds") reported = s.standby_seconds = std::clamp<int32_t>(value, 60, 86400);
else if (key == "brightness") {
reported = s.brightness = std::clamp<int32_t>(value, 5, 100);
s.standby_brightness = std::min(s.standby_brightness, s.brightness);
s.night_brightness = std::min(s.night_brightness, s.brightness);
} else if (key == "standby_brightness") reported = s.standby_brightness = std::clamp<int32_t>(value, 0, s.brightness);
else if (key == "night_enabled") reported = s.night_enabled = flag(value);
else if (key == "night_start") reported = s.night_start = std::clamp<int32_t>(value, 0, 1439);
else if (key == "night_end") reported = s.night_end = std::clamp<int32_t>(value, 0, 1439);
else if (key == "night_brightness") reported = s.night_brightness = std::clamp<int32_t>(value, 0, s.brightness);
else if (key == "clock_24h") reported = s.clock_24h = flag(value);
else if (key == "home_on_standby") reported = s.home_on_standby = flag(value);
else if (key == "swipe_pages") reported = swipe_pages = flag(value);
else if (key == "rotation" && rotation_supported) reported = rotation = std::clamp<int32_t>(value, 0, 270) / 90 * 90;
else if (key == "auto_home") reported = auto_home = flag(value);
else if (key == "auto_home_seconds") reported = auto_home_seconds = std::clamp<int32_t>(value, 30, 3600);
else return SetResult::unknown;
if (s == before && swipe_pages == before_swipe && rotation == before_rotation && auto_home == before_home &&
auto_home_seconds == before_home_seconds)
return SetResult::same;
changed(key.c_str(), reported);
return SetResult::changed;
}
// How coarse a duration steps: seconds near the bottom, quarters of an hour at the top. Stepping down
// reads the ladder one second lower, so up and down always land on the same values again.
inline int32_t ladder_step(int32_t seconds) {
@@ -186,73 +223,47 @@ inline constexpr const char *rotation_options[] = {"0°", "90°", "180°", "270
// the board does not.
inline constexpr Row light_rows[] = {
number("Brightness", []() -> int32_t { return screen_settings::current.brightness; },
[](int32_t value) {
auto &s = screen_settings::current;
s.brightness = std::clamp<int32_t>(value, 5, 100);
s.standby_brightness = std::min(s.standby_brightness, s.brightness);
s.night_brightness = std::min(s.night_brightness, s.brightness);
changed("brightness", s.brightness);
}, 5, 100, 5, "%"),
[](int32_t value) { set("brightness", value); }, 5, 100, 5, "%"),
toggle("Auto standby", []() -> int32_t { return screen_settings::current.standby_enabled; },
[](int32_t value) {
screen_settings::current.standby_enabled = value ? 1 : 0;
changed("standby_enabled", screen_settings::current.standby_enabled);
}),
[](int32_t value) { set("standby_enabled", value); }),
duration("Standby after", []() -> int32_t { return screen_settings::current.standby_seconds; },
[](int32_t value) {
screen_settings::current.standby_seconds = value;
changed("standby_seconds", value);
}, 60, 86400, [] { return screen_settings::current.standby_enabled != 0; }),
[](int32_t value) { set("standby_seconds", value); }, 60, 86400,
[] { return screen_settings::current.standby_enabled != 0; }),
number("Standby brightness", []() -> int32_t { return screen_settings::current.standby_brightness; },
[](int32_t value) {
auto &s = screen_settings::current;
s.standby_brightness = std::clamp<int32_t>(value, 0, s.brightness);
changed("standby_brightness", s.standby_brightness);
}, 0, 100, 5, "%", [] { return screen_settings::current.standby_enabled != 0; }),
[](int32_t value) { set("standby_brightness", value); }, 0, 100, 5, "%",
[] { return screen_settings::current.standby_enabled != 0; }),
};
// Darker between two times, so a panel in a hallway does not light up the bedroom.
inline constexpr Row night_rows[] = {
toggle("Night mode", []() -> int32_t { return screen_settings::current.night_enabled; },
[](int32_t value) {
screen_settings::current.night_enabled = value ? 1 : 0;
changed("night_enabled", screen_settings::current.night_enabled);
}),
[](int32_t value) { set("night_enabled", value); }),
moment("Starts", []() -> int32_t { return screen_settings::current.night_start; },
[](int32_t value) { screen_settings::current.night_start = value; changed("night_start", value); },
[](int32_t value) { set("night_start", value); },
[] { return screen_settings::current.night_enabled != 0; }),
moment("Ends", []() -> int32_t { return screen_settings::current.night_end; },
[](int32_t value) { screen_settings::current.night_end = value; changed("night_end", value); },
[](int32_t value) { set("night_end", value); },
[] { return screen_settings::current.night_enabled != 0; }),
number("Night brightness", []() -> int32_t { return screen_settings::current.night_brightness; },
[](int32_t value) {
auto &s = screen_settings::current;
s.night_brightness = std::clamp<int32_t>(value, 0, s.brightness);
changed("night_brightness", s.night_brightness);
}, 0, 100, 5, "%", [] { return screen_settings::current.night_enabled != 0; }),
[](int32_t value) { set("night_brightness", value); }, 0, 100, 5, "%",
[] { return screen_settings::current.night_enabled != 0; }),
};
// How the screen behaves under your finger: the clock, going back to the first page, swiping, turning.
inline constexpr Row screen_rows[] = {
choice("Clock", []() -> int32_t { return screen_settings::current.clock_24h ? 1 : 0; },
[](int32_t value) {
screen_settings::current.clock_24h = value ? 1 : 0;
changed("clock_24h", screen_settings::current.clock_24h);
}, clock_options, 2),
[](int32_t value) { set("clock_24h", value); }, clock_options, 2),
toggle("Back to page 1", []() -> int32_t { return auto_home; },
[](int32_t value) { auto_home = value ? 1 : 0; changed("auto_home", auto_home); }),
[](int32_t value) { set("auto_home", value); }),
duration("After", []() -> int32_t { return auto_home_seconds; },
[](int32_t value) { auto_home_seconds = value; changed("auto_home_seconds", value); }, 30, 3600,
[](int32_t value) { set("auto_home_seconds", value); }, 30, 3600,
[] { return auto_home != 0; }),
toggle("Also on standby", []() -> int32_t { return screen_settings::current.home_on_standby; },
[](int32_t value) {
screen_settings::current.home_on_standby = value ? 1 : 0;
changed("home_on_standby", screen_settings::current.home_on_standby);
}),
[](int32_t value) { set("home_on_standby", value); }),
toggle("Swipe between pages", []() -> int32_t { return swipe_pages; },
[](int32_t value) { swipe_pages = value ? 1 : 0; changed("swipe_pages", swipe_pages); }),
[](int32_t value) { set("swipe_pages", value); }),
choice("Rotation", []() -> int32_t { return rotation / 90; },
[](int32_t value) { rotation = std::clamp<int32_t>(value, 0, 3) * 90; changed("rotation", rotation); },
[](int32_t value) { set("rotation", std::clamp<int32_t>(value, 0, 3) * 90); },
rotation_options, 4, [] { return rotation_supported; }),
};
@@ -398,22 +409,30 @@ inline void move_knob(Drawn &d, const Row &row) {
auto *track = lv_obj_get_parent(d.knob);
bool on = row.read && row.read();
int w = lv_obj_get_width(track), size = lv_obj_get_width(d.knob), inset = (lv_obj_get_height(track) - size) / 2;
lv_obj_set_style_bg_color(track, lv_color_hex(on ? 0x009FE3 : 0xC4C4C4), 0);
// Setting a style always repaints, so only a real change sets it; lv_obj_set_x checks by itself.
const lv_color_t color = lv_color_hex(on ? 0x009FE3 : 0xC4C4C4);
if (!lv_color_eq(lv_obj_get_style_bg_color(track, LV_PART_MAIN), color)) lv_obj_set_style_bg_color(track, color, 0);
lv_obj_set_x(d.knob, on ? w - inset - size : inset);
}
// After any change: every row on this page tells its own value again. Cheap (eight labels at most) and
// it keeps rows that lean on each other honest -- lowering Brightness also lowers the two dim levels.
// The board runs it after a change from Home Assistant too, so a text is only set when it differs.
inline void refresh() {
if (!root) return;
const Page &page = pages[current_page];
for (uint8_t i = 0; i < drawn_count; ++i) {
Drawn &d = drawn[i];
const Row &row = page.rows[d.row];
if (d.value) lv_label_set_text(d.value, value_text(row).c_str());
if (d.value) {
const std::string text = value_text(row);
if (text != lv_label_get_text(d.value)) lv_label_set_text(d.value, text.c_str());
}
if (d.knob) move_knob(d, row);
// A row whose switch is off (night times, the standby levels) is there but does nothing, and says so.
bool live = live_row(row);
if (d.card) lv_obj_set_style_opa(d.card, live ? LV_OPA_COVER : LV_OPA_50, 0);
const lv_opa_t opa = live ? LV_OPA_COVER : LV_OPA_50;
if (d.card && lv_obj_get_style_opa(d.card, LV_PART_MAIN) != opa) lv_obj_set_style_opa(d.card, opa, 0);
if (d.minus && row.read) {
bool off = !live || at_end(row, row.read(), -1);
if (off) lv_obj_add_state(d.minus, LV_STATE_DISABLED); else lv_obj_remove_state(d.minus, LV_STATE_DISABLED);
+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.
+153 -60
View File
@@ -2,7 +2,7 @@
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.48"
SCREEN_FIRMWARE_VERSION: "0.2.49"
# 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not
# (like iOS). The CYD keeps a limit because its resistive panel jumps.
TOUCH_MOVE_LIMIT_PX: "0"
@@ -446,11 +446,23 @@ api:
reason: "remote"
# ESP Screen Manager 0.2.39+ hands a whole tile message over in one call instead of
# base64 chunks in the text inbox; the inbox keeps showing the result.
# Firmware 0.2.49+ also answers a caller that asks for it (app 0.2.57+ does for its pings): the same
# status as the inbox, plus the revision of the layout on the screen. A call without a call id
# (older apps) gets no answer, so ESPHome has nothing to warn about.
- action: screen_message
supports_response: optional
variables:
message: string
then:
- lambda: 'id(dashboard_inbox).publish_state(runtime_tiles::receive(message));'
- if:
condition:
lambda: 'return call_id != 0;'
then:
- api.respond:
data: !lambda |-
root["status"] = id(dashboard_inbox).state;
root["rev"] = runtime_tiles::layout_rev;
encryption:
key: !secret api_encryption_key
@@ -1063,10 +1075,13 @@ text_sensor:
switch:
- platform: restart
name: "Restart"
# Auto standby, the same setting as the checkbox in ESP Screens (firmware 0.2.41+), so Home Assistant
# automations can keep a screen on. Off wakes a screen the standby time dimmed and keeps it on (a Sleep
# holds until a tap, Wake or an alert); on counts the standby time from that moment. Nothing runs at
# boot: the switch follows the saved setting.
# The screen settings as Home Assistant entities (firmware 0.2.49+, Auto standby since 0.2.41). The screen
# owns them: the settings page, these entities and ESP Screens all change them through
# settings_screen::set(), which saves, applies and reports a real change only. The saved values keep
# their preference records. A template switch reads its lambda every loop and publishes a change itself;
# the numbers and times are published by apply_screen_settings. Auto standby off wakes a screen the
# standby time dimmed (a Sleep holds until a tap, Wake or an alert); on counts the standby time from that
# moment. Nothing runs at boot: every switch follows the saved setting.
- platform: template
id: setting_auto_standby
name: "Auto standby"
@@ -1076,21 +1091,113 @@ switch:
lambda: return screen_settings::current.standby_enabled;
turn_on_action:
- lambda: |-
auto &s = screen_settings::current;
id(last_touch_ms) = millis();
if (s.standby_enabled) return;
s.standby_enabled = true;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_enabled", 1);
id(apply_screen_settings).execute();
settings_screen::set("standby_enabled", 1);
turn_off_action:
- lambda: 'settings_screen::set("standby_enabled", 0);'
- platform: template
id: setting_night_mode
name: "Night mode"
icon: "mdi:weather-night"
entity_category: config
restore_mode: DISABLED
lambda: return screen_settings::current.night_enabled;
turn_on_action:
- lambda: 'settings_screen::set("night_enabled", 1);'
turn_off_action:
- lambda: 'settings_screen::set("night_enabled", 0);'
- platform: template
id: setting_clock_24h
name: "24-hour clock"
icon: "mdi:clock-outline"
entity_category: config
restore_mode: DISABLED
lambda: return screen_settings::current.clock_24h;
turn_on_action:
- lambda: 'settings_screen::set("clock_24h", 1);'
turn_off_action:
- lambda: 'settings_screen::set("clock_24h", 0);'
- platform: template
id: setting_auto_home
name: "Back to page 1"
icon: "mdi:home-import-outline"
entity_category: config
restore_mode: DISABLED
lambda: return settings_screen::auto_home != 0;
turn_on_action:
- lambda: 'settings_screen::set("auto_home", 1);'
turn_off_action:
- lambda: 'settings_screen::set("auto_home", 0);'
- platform: template
id: setting_home_on_standby
name: "Back to page 1 on standby"
icon: "mdi:home-import-outline"
entity_category: config
restore_mode: DISABLED
lambda: return screen_settings::current.home_on_standby;
turn_on_action:
- lambda: 'settings_screen::set("home_on_standby", 1);'
turn_off_action:
- lambda: 'settings_screen::set("home_on_standby", 0);'
- platform: template
id: setting_swipe_pages
name: "Swipe between pages"
icon: "mdi:gesture-swipe-horizontal"
entity_category: config
restore_mode: DISABLED
lambda: return settings_screen::swipe_pages != 0;
turn_on_action:
- lambda: 'settings_screen::set("swipe_pages", 1);'
turn_off_action:
- lambda: 'settings_screen::set("swipe_pages", 0);'
# Night hours as Home Assistant times (firmware 0.2.49+); the screen keeps minutes since midnight.
datetime:
- platform: template
type: time
id: setting_night_start
name: "Night starts"
icon: "mdi:weather-sunset-down"
entity_category: config
update_interval: never
lambda: |-
ESPTime time{};
time.hour = screen_settings::current.night_start / 60;
time.minute = screen_settings::current.night_start % 60;
return time;
set_action:
- lambda: 'settings_screen::set("night_start", x.hour * 60 + x.minute);'
- platform: template
type: time
id: setting_night_end
name: "Night ends"
icon: "mdi:weather-sunset-up"
entity_category: config
update_interval: never
lambda: |-
ESPTime time{};
time.hour = screen_settings::current.night_end / 60;
time.minute = screen_settings::current.night_end % 60;
return time;
set_action:
- lambda: 'settings_screen::set("night_end", x.hour * 60 + x.minute);'
# Rotation as a Home Assistant choice (firmware 0.2.49+). Only the Guition turns; the CYD keeps its orientation.
select:
- platform: template
id: setting_rotation
name: "Rotation"
icon: "mdi:screen-rotation"
entity_category: config
options: ["0°", "90°", "180°", "270°"]
update_interval: never
lambda: |-
static const char *const names[] = {"0°", "90°", "180°", "270°"};
return std::string(names[std::clamp<int32_t>(settings_screen::rotation, 0, 270) / 90]);
set_action:
- lambda: |-
auto &s = screen_settings::current;
if (!s.standby_enabled) return;
s.standby_enabled = false;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_enabled", 0);
id(apply_screen_settings).execute();
const std::string option(x.c_str(), x.size());
settings_screen::set("rotation", atoi(option.c_str()));
# Two SPI buses used by the CYD:
# - lcd: built-in display
@@ -3587,11 +3694,19 @@ script:
id(set_backlight).execute(level, id(display_dimmed) ? 1500 : 300);
if (id(display_dimmed)) { lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(id(dim_wake_overlay)); }
else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN);
// The four setting entities only publish a changed value (HA keeps identical states anyway).
// The setting numbers and times only publish a changed value (HA keeps identical states anyway); the
// switches publish their own changes. An open settings page shows a change from Home Assistant too.
if (id(setting_brightness).state != settings.brightness) id(setting_brightness).publish_state(settings.brightness);
if (id(setting_standby_brightness).state != settings.standby_brightness) id(setting_standby_brightness).publish_state(settings.standby_brightness);
if (id(setting_night_brightness).state != settings.night_brightness) id(setting_night_brightness).publish_state(settings.night_brightness);
if (id(setting_standby_seconds).state != settings.standby_seconds) id(setting_standby_seconds).publish_state(settings.standby_seconds);
if (id(setting_auto_home_seconds).state != settings_screen::auto_home_seconds) id(setting_auto_home_seconds).publish_state(settings_screen::auto_home_seconds);
for (auto [entity, minutes] : {std::make_pair(id(setting_night_start), settings.night_start), std::make_pair(id(setting_night_end), settings.night_end)}) {
if (!entity->has_state() || entity->hour * 60 + entity->minute != minutes) entity->update();
}
const auto shown_rotation = id(setting_rotation)->active_index();
if (!id(setting_rotation)->has_state() || !shown_rotation.has_value() || *shown_rotation != (size_t) (settings_screen::rotation / 90)) id(setting_rotation)->update();
settings_screen::refresh();
if (!id(ui_refresh).is_running()) id(ui_refresh).execute();
# Every card closes through runtime_tiles::dismiss (set in on_boot): the runtime detail card, the light,
@@ -4547,6 +4662,8 @@ button:
- script.execute: dim_display
number:
# Firmware 0.2.49+: every number goes through settings_screen::set(), like the settings page, so a value the
# screen already has writes and reports nothing (an automation may set it on every light change).
- platform: template
id: setting_brightness
name: "Normal brightness"
@@ -4558,18 +4675,7 @@ number:
update_interval: never
lambda: return screen_settings::current.brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
// The value the screen already has (an automation that sets it on every light change):
// no flash write, no event, so ESP Screen Manager does not resend the whole screen.
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("brightness",s.brightness);
id(apply_screen_settings).execute();
- lambda: 'settings_screen::set("brightness", (int32_t) lround(x));'
- platform: template
id: setting_standby_brightness
name: "Standby brightness"
@@ -4581,16 +4687,7 @@ number:
update_interval: never
lambda: return screen_settings::current.standby_brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.standby_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_brightness",s.standby_brightness);
id(apply_screen_settings).execute();
- lambda: 'settings_screen::set("standby_brightness", (int32_t) lround(x));'
- platform: template
id: setting_night_brightness
name: "Night brightness"
@@ -4602,16 +4699,7 @@ number:
update_interval: never
lambda: return screen_settings::current.night_brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.night_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("night_brightness",s.night_brightness);
id(apply_screen_settings).execute();
- lambda: 'settings_screen::set("night_brightness", (int32_t) lround(x));'
- platform: template
id: setting_standby_seconds
name: "Standby after"
@@ -4623,13 +4711,18 @@ number:
update_interval: never
lambda: return screen_settings::current.standby_seconds;
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.standby_seconds = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_seconds",s.standby_seconds);
id(apply_screen_settings).execute();
- lambda: 'settings_screen::set("standby_seconds", (int32_t) lround(x));'
- platform: template
id: setting_auto_home_seconds
name: "Back to page 1 after"
icon: "mdi:timer-outline"
entity_category: config
min_value: 30
max_value: 3600
step: 30
mode: box
unit_of_measurement: "s"
update_interval: never
lambda: return settings_screen::auto_home_seconds;
set_action:
- lambda: 'settings_screen::set("auto_home_seconds", (int32_t) lround(x));'
+135 -61
View File
@@ -17,7 +17,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.48"
SCREEN_FIRMWARE_VERSION: "0.2.49"
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "56"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
@@ -563,11 +563,23 @@ api:
reason: "remote"
# ESP Screen Manager 0.2.39+ hands a whole tile message over in one call instead of
# base64 chunks in the text inbox; the inbox keeps showing the result.
# Firmware 0.2.49+ also answers a caller that asks for it (app 0.2.57+ does for its pings): the same
# status as the inbox, plus the revision of the layout on the screen. A call without a call id
# (older apps) gets no answer, so ESPHome has nothing to warn about.
- action: screen_message
supports_response: optional
variables:
message: string
then:
- lambda: 'id(dashboard_inbox).publish_state(runtime_tiles::receive(message));'
- if:
condition:
lambda: 'return call_id != 0;'
then:
- api.respond:
data: !lambda |-
root["status"] = id(dashboard_inbox).state;
root["rev"] = runtime_tiles::layout_rev;
encryption:
key: !secret api_encryption_key
@@ -1176,10 +1188,13 @@ text_sensor:
switch:
- platform: restart
name: "Restart"
# Auto standby, the same setting as the checkbox in ESP Screens (firmware 0.2.41+), so Home Assistant
# automations can keep a screen on. Off wakes a screen the standby time dimmed and keeps it on (a Sleep
# holds until a tap, Wake or an alert); on counts the standby time from that moment. Nothing runs at
# boot: the switch follows the saved setting.
# The screen settings as Home Assistant entities (firmware 0.2.49+, Auto standby since 0.2.41). The screen
# owns them: the settings page, these entities and ESP Screens all change them through
# settings_screen::set(), which saves, applies and reports a real change only. The saved values keep
# their preference records. A template switch reads its lambda every loop and publishes a change itself;
# the numbers and times are published by apply_screen_settings. Auto standby off wakes a screen the
# standby time dimmed (a Sleep holds until a tap, Wake or an alert); on counts the standby time from that
# moment. Nothing runs at boot: every switch follows the saved setting.
- platform: template
id: setting_auto_standby
name: "Auto standby"
@@ -1189,21 +1204,96 @@ switch:
lambda: return screen_settings::current.standby_enabled;
turn_on_action:
- lambda: |-
auto &s = screen_settings::current;
id(last_touch_ms) = millis();
if (s.standby_enabled) return;
s.standby_enabled = true;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_enabled", 1);
id(apply_screen_settings).execute();
settings_screen::set("standby_enabled", 1);
turn_off_action:
- lambda: |-
auto &s = screen_settings::current;
if (!s.standby_enabled) return;
s.standby_enabled = false;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_enabled", 0);
id(apply_screen_settings).execute();
- lambda: 'settings_screen::set("standby_enabled", 0);'
- platform: template
id: setting_night_mode
name: "Night mode"
icon: "mdi:weather-night"
entity_category: config
restore_mode: DISABLED
lambda: return screen_settings::current.night_enabled;
turn_on_action:
- lambda: 'settings_screen::set("night_enabled", 1);'
turn_off_action:
- lambda: 'settings_screen::set("night_enabled", 0);'
- platform: template
id: setting_clock_24h
name: "24-hour clock"
icon: "mdi:clock-outline"
entity_category: config
restore_mode: DISABLED
lambda: return screen_settings::current.clock_24h;
turn_on_action:
- lambda: 'settings_screen::set("clock_24h", 1);'
turn_off_action:
- lambda: 'settings_screen::set("clock_24h", 0);'
- platform: template
id: setting_auto_home
name: "Back to page 1"
icon: "mdi:home-import-outline"
entity_category: config
restore_mode: DISABLED
lambda: return settings_screen::auto_home != 0;
turn_on_action:
- lambda: 'settings_screen::set("auto_home", 1);'
turn_off_action:
- lambda: 'settings_screen::set("auto_home", 0);'
- platform: template
id: setting_home_on_standby
name: "Back to page 1 on standby"
icon: "mdi:home-import-outline"
entity_category: config
restore_mode: DISABLED
lambda: return screen_settings::current.home_on_standby;
turn_on_action:
- lambda: 'settings_screen::set("home_on_standby", 1);'
turn_off_action:
- lambda: 'settings_screen::set("home_on_standby", 0);'
- platform: template
id: setting_swipe_pages
name: "Swipe between pages"
icon: "mdi:gesture-swipe-horizontal"
entity_category: config
restore_mode: DISABLED
lambda: return settings_screen::swipe_pages != 0;
turn_on_action:
- lambda: 'settings_screen::set("swipe_pages", 1);'
turn_off_action:
- lambda: 'settings_screen::set("swipe_pages", 0);'
# Night hours as Home Assistant times (firmware 0.2.49+); the screen keeps minutes since midnight.
datetime:
- platform: template
type: time
id: setting_night_start
name: "Night starts"
icon: "mdi:weather-sunset-down"
entity_category: config
update_interval: never
lambda: |-
ESPTime time{};
time.hour = screen_settings::current.night_start / 60;
time.minute = screen_settings::current.night_start % 60;
return time;
set_action:
- lambda: 'settings_screen::set("night_start", x.hour * 60 + x.minute);'
- platform: template
type: time
id: setting_night_end
name: "Night ends"
icon: "mdi:weather-sunset-up"
entity_category: config
update_interval: never
lambda: |-
ESPTime time{};
time.hour = screen_settings::current.night_end / 60;
time.minute = screen_settings::current.night_end % 60;
return time;
set_action:
- lambda: 'settings_screen::set("night_end", x.hour * 60 + x.minute);'
# Two SPI buses used by the CYD:
# - lcd: built-in display
@@ -3610,11 +3700,17 @@ script:
id(back_light).turn_on().set_transition_length(300).set_brightness(level / 100.0f).perform();
if (id(display_dimmed)) { lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(id(dim_wake_overlay)); }
else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN);
// The four setting entities only publish a changed value (HA keeps identical states anyway).
// The setting numbers and times only publish a changed value (HA keeps identical states anyway); the
// switches publish their own changes. An open settings page shows a change from Home Assistant too.
if (id(setting_brightness).state != settings.brightness) id(setting_brightness).publish_state(settings.brightness);
if (id(setting_standby_brightness).state != settings.standby_brightness) id(setting_standby_brightness).publish_state(settings.standby_brightness);
if (id(setting_night_brightness).state != settings.night_brightness) id(setting_night_brightness).publish_state(settings.night_brightness);
if (id(setting_standby_seconds).state != settings.standby_seconds) id(setting_standby_seconds).publish_state(settings.standby_seconds);
if (id(setting_auto_home_seconds).state != settings_screen::auto_home_seconds) id(setting_auto_home_seconds).publish_state(settings_screen::auto_home_seconds);
for (auto [entity, minutes] : {std::make_pair(id(setting_night_start), settings.night_start), std::make_pair(id(setting_night_end), settings.night_end)}) {
if (!entity->has_state() || entity->hour * 60 + entity->minute != minutes) entity->update();
}
settings_screen::refresh();
if (!id(ui_refresh).is_running()) id(ui_refresh).execute();
# Every card closes through runtime_tiles::dismiss (set in on_boot): the runtime detail card, the light,
@@ -4537,6 +4633,8 @@ button:
- script.execute: dim_display
number:
# Firmware 0.2.49+: every number goes through settings_screen::set(), like the settings page, so a value the
# screen already has writes and reports nothing (an automation may set it on every light change).
- platform: template
id: setting_brightness
name: "Normal brightness"
@@ -4548,18 +4646,7 @@ number:
update_interval: never
lambda: return screen_settings::current.brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
// The value the screen already has (an automation that sets it on every light change):
// no flash write, no event, so ESP Screen Manager does not resend the whole screen.
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("brightness",s.brightness);
id(apply_screen_settings).execute();
- lambda: 'settings_screen::set("brightness", (int32_t) lround(x));'
- platform: template
id: setting_standby_brightness
name: "Standby brightness"
@@ -4571,16 +4658,7 @@ number:
update_interval: never
lambda: return screen_settings::current.standby_brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.standby_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_brightness",s.standby_brightness);
id(apply_screen_settings).execute();
- lambda: 'settings_screen::set("standby_brightness", (int32_t) lround(x));'
- platform: template
id: setting_night_brightness
name: "Night brightness"
@@ -4592,16 +4670,7 @@ number:
update_interval: never
lambda: return screen_settings::current.night_brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.night_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("night_brightness",s.night_brightness);
id(apply_screen_settings).execute();
- lambda: 'settings_screen::set("night_brightness", (int32_t) lround(x));'
- platform: template
id: setting_standby_seconds
name: "Standby after"
@@ -4613,13 +4682,18 @@ number:
update_interval: never
lambda: return screen_settings::current.standby_seconds;
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.standby_seconds = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_seconds",s.standby_seconds);
id(apply_screen_settings).execute();
- lambda: 'settings_screen::set("standby_seconds", (int32_t) lround(x));'
- platform: template
id: setting_auto_home_seconds
name: "Back to page 1 after"
icon: "mdi:timer-outline"
entity_category: config
min_value: 30
max_value: 3600
step: 30
mode: box
unit_of_measurement: "s"
update_interval: never
lambda: return settings_screen::auto_home_seconds;
set_action:
- lambda: 'settings_screen::set("auto_home_seconds", (int32_t) lround(x));'
+135 -61
View File
@@ -18,7 +18,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.48"
SCREEN_FIRMWARE_VERSION: "0.2.49"
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "56"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
@@ -567,11 +567,23 @@ api:
reason: "remote"
# ESP Screen Manager 0.2.39+ hands a whole tile message over in one call instead of
# base64 chunks in the text inbox; the inbox keeps showing the result.
# Firmware 0.2.49+ also answers a caller that asks for it (app 0.2.57+ does for its pings): the same
# status as the inbox, plus the revision of the layout on the screen. A call without a call id
# (older apps) gets no answer, so ESPHome has nothing to warn about.
- action: screen_message
supports_response: optional
variables:
message: string
then:
- lambda: 'id(dashboard_inbox).publish_state(runtime_tiles::receive(message));'
- if:
condition:
lambda: 'return call_id != 0;'
then:
- api.respond:
data: !lambda |-
root["status"] = id(dashboard_inbox).state;
root["rev"] = runtime_tiles::layout_rev;
captive_portal:
@@ -1165,10 +1177,13 @@ text_sensor:
switch:
- platform: restart
name: "Restart"
# Auto standby, the same setting as the checkbox in ESP Screens (firmware 0.2.41+), so Home Assistant
# automations can keep a screen on. Off wakes a screen the standby time dimmed and keeps it on (a Sleep
# holds until a tap, Wake or an alert); on counts the standby time from that moment. Nothing runs at
# boot: the switch follows the saved setting.
# The screen settings as Home Assistant entities (firmware 0.2.49+, Auto standby since 0.2.41). The screen
# owns them: the settings page, these entities and ESP Screens all change them through
# settings_screen::set(), which saves, applies and reports a real change only. The saved values keep
# their preference records. A template switch reads its lambda every loop and publishes a change itself;
# the numbers and times are published by apply_screen_settings. Auto standby off wakes a screen the
# standby time dimmed (a Sleep holds until a tap, Wake or an alert); on counts the standby time from that
# moment. Nothing runs at boot: every switch follows the saved setting.
- platform: template
id: setting_auto_standby
name: "Auto standby"
@@ -1178,21 +1193,96 @@ switch:
lambda: return screen_settings::current.standby_enabled;
turn_on_action:
- lambda: |-
auto &s = screen_settings::current;
id(last_touch_ms) = millis();
if (s.standby_enabled) return;
s.standby_enabled = true;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_enabled", 1);
id(apply_screen_settings).execute();
settings_screen::set("standby_enabled", 1);
turn_off_action:
- lambda: |-
auto &s = screen_settings::current;
if (!s.standby_enabled) return;
s.standby_enabled = false;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_enabled", 0);
id(apply_screen_settings).execute();
- lambda: 'settings_screen::set("standby_enabled", 0);'
- platform: template
id: setting_night_mode
name: "Night mode"
icon: "mdi:weather-night"
entity_category: config
restore_mode: DISABLED
lambda: return screen_settings::current.night_enabled;
turn_on_action:
- lambda: 'settings_screen::set("night_enabled", 1);'
turn_off_action:
- lambda: 'settings_screen::set("night_enabled", 0);'
- platform: template
id: setting_clock_24h
name: "24-hour clock"
icon: "mdi:clock-outline"
entity_category: config
restore_mode: DISABLED
lambda: return screen_settings::current.clock_24h;
turn_on_action:
- lambda: 'settings_screen::set("clock_24h", 1);'
turn_off_action:
- lambda: 'settings_screen::set("clock_24h", 0);'
- platform: template
id: setting_auto_home
name: "Back to page 1"
icon: "mdi:home-import-outline"
entity_category: config
restore_mode: DISABLED
lambda: return settings_screen::auto_home != 0;
turn_on_action:
- lambda: 'settings_screen::set("auto_home", 1);'
turn_off_action:
- lambda: 'settings_screen::set("auto_home", 0);'
- platform: template
id: setting_home_on_standby
name: "Back to page 1 on standby"
icon: "mdi:home-import-outline"
entity_category: config
restore_mode: DISABLED
lambda: return screen_settings::current.home_on_standby;
turn_on_action:
- lambda: 'settings_screen::set("home_on_standby", 1);'
turn_off_action:
- lambda: 'settings_screen::set("home_on_standby", 0);'
- platform: template
id: setting_swipe_pages
name: "Swipe between pages"
icon: "mdi:gesture-swipe-horizontal"
entity_category: config
restore_mode: DISABLED
lambda: return settings_screen::swipe_pages != 0;
turn_on_action:
- lambda: 'settings_screen::set("swipe_pages", 1);'
turn_off_action:
- lambda: 'settings_screen::set("swipe_pages", 0);'
# Night hours as Home Assistant times (firmware 0.2.49+); the screen keeps minutes since midnight.
datetime:
- platform: template
type: time
id: setting_night_start
name: "Night starts"
icon: "mdi:weather-sunset-down"
entity_category: config
update_interval: never
lambda: |-
ESPTime time{};
time.hour = screen_settings::current.night_start / 60;
time.minute = screen_settings::current.night_start % 60;
return time;
set_action:
- lambda: 'settings_screen::set("night_start", x.hour * 60 + x.minute);'
- platform: template
type: time
id: setting_night_end
name: "Night ends"
icon: "mdi:weather-sunset-up"
entity_category: config
update_interval: never
lambda: |-
ESPTime time{};
time.hour = screen_settings::current.night_end / 60;
time.minute = screen_settings::current.night_end % 60;
return time;
set_action:
- lambda: 'settings_screen::set("night_end", x.hour * 60 + x.minute);'
# Two SPI buses used by the CYD:
# - lcd: built-in display
@@ -3599,11 +3689,17 @@ script:
id(back_light).turn_on().set_transition_length(300).set_brightness(level / 100.0f).perform();
if (id(display_dimmed)) { lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(id(dim_wake_overlay)); }
else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN);
// The four setting entities only publish a changed value (HA keeps identical states anyway).
// The setting numbers and times only publish a changed value (HA keeps identical states anyway); the
// switches publish their own changes. An open settings page shows a change from Home Assistant too.
if (id(setting_brightness).state != settings.brightness) id(setting_brightness).publish_state(settings.brightness);
if (id(setting_standby_brightness).state != settings.standby_brightness) id(setting_standby_brightness).publish_state(settings.standby_brightness);
if (id(setting_night_brightness).state != settings.night_brightness) id(setting_night_brightness).publish_state(settings.night_brightness);
if (id(setting_standby_seconds).state != settings.standby_seconds) id(setting_standby_seconds).publish_state(settings.standby_seconds);
if (id(setting_auto_home_seconds).state != settings_screen::auto_home_seconds) id(setting_auto_home_seconds).publish_state(settings_screen::auto_home_seconds);
for (auto [entity, minutes] : {std::make_pair(id(setting_night_start), settings.night_start), std::make_pair(id(setting_night_end), settings.night_end)}) {
if (!entity->has_state() || entity->hour * 60 + entity->minute != minutes) entity->update();
}
settings_screen::refresh();
if (!id(ui_refresh).is_running()) id(ui_refresh).execute();
# Every card closes through runtime_tiles::dismiss (set in on_boot): the runtime detail card, the light,
@@ -4526,6 +4622,8 @@ button:
- script.execute: dim_display
number:
# Firmware 0.2.49+: every number goes through settings_screen::set(), like the settings page, so a value the
# screen already has writes and reports nothing (an automation may set it on every light change).
- platform: template
id: setting_brightness
name: "Normal brightness"
@@ -4537,18 +4635,7 @@ number:
update_interval: never
lambda: return screen_settings::current.brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
// The value the screen already has (an automation that sets it on every light change):
// no flash write, no event, so ESP Screen Manager does not resend the whole screen.
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("brightness",s.brightness);
id(apply_screen_settings).execute();
- lambda: 'settings_screen::set("brightness", (int32_t) lround(x));'
- platform: template
id: setting_standby_brightness
name: "Standby brightness"
@@ -4560,16 +4647,7 @@ number:
update_interval: never
lambda: return screen_settings::current.standby_brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.standby_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_brightness",s.standby_brightness);
id(apply_screen_settings).execute();
- lambda: 'settings_screen::set("standby_brightness", (int32_t) lround(x));'
- platform: template
id: setting_night_brightness
name: "Night brightness"
@@ -4581,16 +4659,7 @@ number:
update_interval: never
lambda: return screen_settings::current.night_brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.night_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("night_brightness",s.night_brightness);
id(apply_screen_settings).execute();
- lambda: 'settings_screen::set("night_brightness", (int32_t) lround(x));'
- platform: template
id: setting_standby_seconds
name: "Standby after"
@@ -4602,13 +4671,18 @@ number:
update_interval: never
lambda: return screen_settings::current.standby_seconds;
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.standby_seconds = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_seconds",s.standby_seconds);
id(apply_screen_settings).execute();
- lambda: 'settings_screen::set("standby_seconds", (int32_t) lround(x));'
- platform: template
id: setting_auto_home_seconds
name: "Back to page 1 after"
icon: "mdi:timer-outline"
entity_category: config
min_value: 30
max_value: 3600
step: 30
mode: box
unit_of_measurement: "s"
update_interval: never
lambda: return settings_screen::auto_home_seconds;
set_action:
- lambda: 'settings_screen::set("auto_home_seconds", (int32_t) lround(x));'
+153 -60
View File
@@ -3,7 +3,7 @@
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.48"
SCREEN_FIRMWARE_VERSION: "0.2.49"
# 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not
# (like iOS). The CYD keeps a limit because its resistive panel jumps.
TOUCH_MOVE_LIMIT_PX: "0"
@@ -450,11 +450,23 @@ api:
reason: "remote"
# ESP Screen Manager 0.2.39+ hands a whole tile message over in one call instead of
# base64 chunks in the text inbox; the inbox keeps showing the result.
# Firmware 0.2.49+ also answers a caller that asks for it (app 0.2.57+ does for its pings): the same
# status as the inbox, plus the revision of the layout on the screen. A call without a call id
# (older apps) gets no answer, so ESPHome has nothing to warn about.
- action: screen_message
supports_response: optional
variables:
message: string
then:
- lambda: 'id(dashboard_inbox).publish_state(runtime_tiles::receive(message));'
- if:
condition:
lambda: 'return call_id != 0;'
then:
- api.respond:
data: !lambda |-
root["status"] = id(dashboard_inbox).state;
root["rev"] = runtime_tiles::layout_rev;
captive_portal:
@@ -1052,10 +1064,13 @@ text_sensor:
switch:
- platform: restart
name: "Restart"
# Auto standby, the same setting as the checkbox in ESP Screens (firmware 0.2.41+), so Home Assistant
# automations can keep a screen on. Off wakes a screen the standby time dimmed and keeps it on (a Sleep
# holds until a tap, Wake or an alert); on counts the standby time from that moment. Nothing runs at
# boot: the switch follows the saved setting.
# The screen settings as Home Assistant entities (firmware 0.2.49+, Auto standby since 0.2.41). The screen
# owns them: the settings page, these entities and ESP Screens all change them through
# settings_screen::set(), which saves, applies and reports a real change only. The saved values keep
# their preference records. A template switch reads its lambda every loop and publishes a change itself;
# the numbers and times are published by apply_screen_settings. Auto standby off wakes a screen the
# standby time dimmed (a Sleep holds until a tap, Wake or an alert); on counts the standby time from that
# moment. Nothing runs at boot: every switch follows the saved setting.
- platform: template
id: setting_auto_standby
name: "Auto standby"
@@ -1065,21 +1080,113 @@ switch:
lambda: return screen_settings::current.standby_enabled;
turn_on_action:
- lambda: |-
auto &s = screen_settings::current;
id(last_touch_ms) = millis();
if (s.standby_enabled) return;
s.standby_enabled = true;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_enabled", 1);
id(apply_screen_settings).execute();
settings_screen::set("standby_enabled", 1);
turn_off_action:
- lambda: 'settings_screen::set("standby_enabled", 0);'
- platform: template
id: setting_night_mode
name: "Night mode"
icon: "mdi:weather-night"
entity_category: config
restore_mode: DISABLED
lambda: return screen_settings::current.night_enabled;
turn_on_action:
- lambda: 'settings_screen::set("night_enabled", 1);'
turn_off_action:
- lambda: 'settings_screen::set("night_enabled", 0);'
- platform: template
id: setting_clock_24h
name: "24-hour clock"
icon: "mdi:clock-outline"
entity_category: config
restore_mode: DISABLED
lambda: return screen_settings::current.clock_24h;
turn_on_action:
- lambda: 'settings_screen::set("clock_24h", 1);'
turn_off_action:
- lambda: 'settings_screen::set("clock_24h", 0);'
- platform: template
id: setting_auto_home
name: "Back to page 1"
icon: "mdi:home-import-outline"
entity_category: config
restore_mode: DISABLED
lambda: return settings_screen::auto_home != 0;
turn_on_action:
- lambda: 'settings_screen::set("auto_home", 1);'
turn_off_action:
- lambda: 'settings_screen::set("auto_home", 0);'
- platform: template
id: setting_home_on_standby
name: "Back to page 1 on standby"
icon: "mdi:home-import-outline"
entity_category: config
restore_mode: DISABLED
lambda: return screen_settings::current.home_on_standby;
turn_on_action:
- lambda: 'settings_screen::set("home_on_standby", 1);'
turn_off_action:
- lambda: 'settings_screen::set("home_on_standby", 0);'
- platform: template
id: setting_swipe_pages
name: "Swipe between pages"
icon: "mdi:gesture-swipe-horizontal"
entity_category: config
restore_mode: DISABLED
lambda: return settings_screen::swipe_pages != 0;
turn_on_action:
- lambda: 'settings_screen::set("swipe_pages", 1);'
turn_off_action:
- lambda: 'settings_screen::set("swipe_pages", 0);'
# Night hours as Home Assistant times (firmware 0.2.49+); the screen keeps minutes since midnight.
datetime:
- platform: template
type: time
id: setting_night_start
name: "Night starts"
icon: "mdi:weather-sunset-down"
entity_category: config
update_interval: never
lambda: |-
ESPTime time{};
time.hour = screen_settings::current.night_start / 60;
time.minute = screen_settings::current.night_start % 60;
return time;
set_action:
- lambda: 'settings_screen::set("night_start", x.hour * 60 + x.minute);'
- platform: template
type: time
id: setting_night_end
name: "Night ends"
icon: "mdi:weather-sunset-up"
entity_category: config
update_interval: never
lambda: |-
ESPTime time{};
time.hour = screen_settings::current.night_end / 60;
time.minute = screen_settings::current.night_end % 60;
return time;
set_action:
- lambda: 'settings_screen::set("night_end", x.hour * 60 + x.minute);'
# Rotation as a Home Assistant choice (firmware 0.2.49+). Only the Guition turns; the CYD keeps its orientation.
select:
- platform: template
id: setting_rotation
name: "Rotation"
icon: "mdi:screen-rotation"
entity_category: config
options: ["0°", "90°", "180°", "270°"]
update_interval: never
lambda: |-
static const char *const names[] = {"0°", "90°", "180°", "270°"};
return std::string(names[std::clamp<int32_t>(settings_screen::rotation, 0, 270) / 90]);
set_action:
- lambda: |-
auto &s = screen_settings::current;
if (!s.standby_enabled) return;
s.standby_enabled = false;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_enabled", 0);
id(apply_screen_settings).execute();
const std::string option(x.c_str(), x.size());
settings_screen::set("rotation", atoi(option.c_str()));
# Two SPI buses used by the CYD:
# - lcd: built-in display
@@ -3576,11 +3683,19 @@ script:
id(set_backlight).execute(level, id(display_dimmed) ? 1500 : 300);
if (id(display_dimmed)) { lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(id(dim_wake_overlay)); }
else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN);
// The four setting entities only publish a changed value (HA keeps identical states anyway).
// The setting numbers and times only publish a changed value (HA keeps identical states anyway); the
// switches publish their own changes. An open settings page shows a change from Home Assistant too.
if (id(setting_brightness).state != settings.brightness) id(setting_brightness).publish_state(settings.brightness);
if (id(setting_standby_brightness).state != settings.standby_brightness) id(setting_standby_brightness).publish_state(settings.standby_brightness);
if (id(setting_night_brightness).state != settings.night_brightness) id(setting_night_brightness).publish_state(settings.night_brightness);
if (id(setting_standby_seconds).state != settings.standby_seconds) id(setting_standby_seconds).publish_state(settings.standby_seconds);
if (id(setting_auto_home_seconds).state != settings_screen::auto_home_seconds) id(setting_auto_home_seconds).publish_state(settings_screen::auto_home_seconds);
for (auto [entity, minutes] : {std::make_pair(id(setting_night_start), settings.night_start), std::make_pair(id(setting_night_end), settings.night_end)}) {
if (!entity->has_state() || entity->hour * 60 + entity->minute != minutes) entity->update();
}
const auto shown_rotation = id(setting_rotation)->active_index();
if (!id(setting_rotation)->has_state() || !shown_rotation.has_value() || *shown_rotation != (size_t) (settings_screen::rotation / 90)) id(setting_rotation)->update();
settings_screen::refresh();
if (!id(ui_refresh).is_running()) id(ui_refresh).execute();
# Every card closes through runtime_tiles::dismiss (set in on_boot): the runtime detail card, the light,
@@ -4536,6 +4651,8 @@ button:
- script.execute: dim_display
number:
# Firmware 0.2.49+: every number goes through settings_screen::set(), like the settings page, so a value the
# screen already has writes and reports nothing (an automation may set it on every light change).
- platform: template
id: setting_brightness
name: "Normal brightness"
@@ -4547,18 +4664,7 @@ number:
update_interval: never
lambda: return screen_settings::current.brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
// The value the screen already has (an automation that sets it on every light change):
// no flash write, no event, so ESP Screen Manager does not resend the whole screen.
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("brightness",s.brightness);
id(apply_screen_settings).execute();
- lambda: 'settings_screen::set("brightness", (int32_t) lround(x));'
- platform: template
id: setting_standby_brightness
name: "Standby brightness"
@@ -4570,16 +4676,7 @@ number:
update_interval: never
lambda: return screen_settings::current.standby_brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.standby_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_brightness",s.standby_brightness);
id(apply_screen_settings).execute();
- lambda: 'settings_screen::set("standby_brightness", (int32_t) lround(x));'
- platform: template
id: setting_night_brightness
name: "Night brightness"
@@ -4591,16 +4688,7 @@ number:
update_interval: never
lambda: return screen_settings::current.night_brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.night_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("night_brightness",s.night_brightness);
id(apply_screen_settings).execute();
- lambda: 'settings_screen::set("night_brightness", (int32_t) lround(x));'
- platform: template
id: setting_standby_seconds
name: "Standby after"
@@ -4612,13 +4700,18 @@ number:
update_interval: never
lambda: return screen_settings::current.standby_seconds;
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.standby_seconds = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_seconds",s.standby_seconds);
id(apply_screen_settings).execute();
- lambda: 'settings_screen::set("standby_seconds", (int32_t) lround(x));'
- platform: template
id: setting_auto_home_seconds
name: "Back to page 1 after"
icon: "mdi:timer-outline"
entity_category: config
min_value: 30
max_value: 3600
step: 30
mode: box
unit_of_measurement: "s"
update_interval: never
lambda: return settings_screen::auto_home_seconds;
set_action:
- lambda: 'settings_screen::set("auto_home_seconds", (int32_t) lround(x));'
+14
View File
@@ -1,3 +1,17 @@
## 0.2.57 (firmware 0.2.49)
The screen keeps its own settings, and ESP Screens shows them the way the screen does.
- **A setting you change stays changed.** Until now ESP Screens kept its own copy of a screen's settings and sent it along with every full update: after a restart of the app or Home Assistant, every hour and after every save. A change it had missed, because the app or Home Assistant was restarting, came back to the old value, and holding − or + on the screen's settings page could jump back a step. With firmware 0.2.49 the screen owns its settings, and ESP Screens changes them on the screen instead of overwriting them.
- **Every setting is an entity in Home Assistant.** Besides the brightness numbers and Auto standby, each screen now has Night mode, Night starts and Night ends, 24-hour clock, Back to page 1 and after how long, Back to page 1 on standby, Swipe between pages and, on a Guition, Rotation, all under the device's configuration. The settings page, an automation and ESP Screens change the same value, and setting a value the screen already has costs nothing. The Claude skill lists them.
- **New Screen settings panel.** The form under a screen's tiles is now three cards, Brightness, Night and Screen, with the rows of the settings page on the screen: switches, − and + that repeat while you hold them, and chips for the clock and the rotation. A change applies right away, without Save, and a change made on the screen shows up while the panel is open. An offline screen shows its values as unknown until it is back.
- **A missing tile comes back sooner.** The screen now answers ESP Screens' ping directly, so a screen that still lacks its layout or a tile right after an update gets everything again after 30 seconds instead of two minutes.
- **The Settings tile works without Home Assistant.** It did nothing while the screen had no connection, exactly when you want to see "Home Assistant: Not connected" or press Restart.
- **Claude sees the screens again after Home Assistant restarts.** The `sensor.esp_screens_<screen>` layout sensors are written again right after a restart instead of after the next change to a screen.
- **No more forecast errors in the Home Assistant log.** A weather tile only asks for the forecasts its weather entity has. Buienradar has no hourly forecast, and asking for one logged an error about 48 times a day.
- Screens with older firmware work as before, with their settings kept in ESP Screens; a change made on such a screen is no longer sent back to it.
- Needs firmware 0.2.49: press **Update** on the screen.
## 0.2.56 (firmware 0.2.48)
The back button on a card works again.
+10 -3
View File
@@ -15,7 +15,7 @@ import tile_icons
from core import (ALERT_ENDINGS, ALERT_EVENT, ALERT_FALLBACK_ICON, ALERT_FIELDS, ALERT_LIMITS, ALERT_MAX_TIMEOUT,
ALERT_MIN_FIRMWARE, ALERT_SUGGESTED_ICONS, AUTO_STANDBY_MIN_FIRMWARE, BROADCAST_DISMISS, BROADCAST_SHOW,
CONTROLS, DISPLAYS, MAX_PAGES, SETTINGS_PAGE_MIN_FIRMWARE, SLOTS_PER_PAGE, TILE_BACKGROUNDS,
TILE_EVENTS, TILE_RESULT_EVENT, WAKE_SLEEP_MIN_FIRMWARE)
TILE_EVENTS, TILE_RESULT_EVENT, WAKE_SLEEP_MIN_FIRMWARE, SETTING_ENTITIES_MIN_FIRMWARE)
NAME = 'esp-screens'
# claude.ai accepts at most 200 characters; Claude Code picks the skill by this sentence.
@@ -262,7 +262,14 @@ Every screen has these entities in Home Assistant, on its ESPHome device. `<scre
| `number.<screen>_standby_after` | Seconds without a touch before standby, 60 to 86400. |
| `number.<screen>_normal_brightness` | Brightness while in use, 5 to 100 %. |
| `number.<screen>_standby_brightness` | Brightness in standby, 0 to 100 %, at most the normal brightness. |
| `number.<screen>_night_brightness` | Brightness in standby during the night hours set in ESP Screens, 0 to 100 %. |
| `number.<screen>_night_brightness` | Brightness in standby during the night hours, 0 to 100 %. |
| `switch.<screen>_night_mode` | Night mode: the night brightness between the two times below. Firmware {SETTING_ENTITIES_MIN_FIRMWARE} or newer, like every row below it. |
| `time.<screen>_night_starts`, `time.<screen>_night_ends` | The night hours; set them with `time.set_value`. |
| `switch.<screen>_24_hour_clock` | On: 24-hour clock. Off: 12-hour clock. |
| `switch.<screen>_back_to_page_1` | On: after `number.<screen>_back_to_page_1_after` seconds without a touch (30 to 3600) the screen closes a card and goes back to page 1. |
| `switch.<screen>_back_to_page_1_on_standby` | On: going into standby also goes back to page 1. |
| `switch.<screen>_swipe_between_pages` | On: swipe between pages. |
| `select.<screen>_rotation` | Guition only: `0°`, `90°`, `180°` or `270°`. |
Wake and Sleep are buttons: press them with the `button.press` action. They save nothing, so an automation may press them as often as it likes, on every motion too. To reach several screens at once, list their buttons under `entity_id`. Don't target an area or a device with `button.press`: that presses every other button there as well, the Wake and Sleep of the same screen included.
@@ -297,7 +304,7 @@ actions:
- button.kitchen_screen_sleep
```
The switch and the numbers are the same settings as in ESP Screens: a change made from Home Assistant shows there too and stays after a restart. Turning Auto standby on again counts the standby time from that moment. Every change is saved on the screen, so switch on changes that happen a few times a day (someone comes home, a light goes on, a window opens), never on every motion: press Wake for that.
These switches, numbers, times and the select are the screen's own settings, the same as on its settings page and in ESP Screens: a change made from Home Assistant shows there too and stays after a restart. Turning Auto standby on again counts the standby time from that moment. Every change is saved on the screen, so switch on changes that happen a few times a day (someone comes home, a light goes on, a window opens), never on every motion: press Wake for that.
To keep screens on while something is going on at home, turn Auto standby off when the situation starts and on when it ends, all from one automation. Example with placeholders for the user's own entities:
+92 -1
View File
@@ -17,13 +17,15 @@ WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'}
# Firmware shipped with this app release; screens below it get an update offer.
FIRMWARE_VERSION = '0.2.48'
FIRMWARE_VERSION = '0.2.49'
# The Auto standby switch a screen offers Home Assistant automations.
AUTO_STANDBY_MIN_FIRMWARE = '0.2.41'
# The settings page the screen opens itself, and the screen.settings tile that opens it.
SETTINGS_PAGE_MIN_FIRMWARE = '0.2.44'
# The Wake and Sleep buttons a screen offers Home Assistant automations.
WAKE_SLEEP_MIN_FIRMWARE = '0.2.45'
# Every screen setting as an entity of the screen, which owns them (see SETTING_ENTITIES).
SETTING_ENTITIES_MIN_FIRMWARE = '0.2.49'
ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hvac_action hs_color color_temp_kelvin min_color_temp_kelvin max_color_temp_kelvin fan_speed_list unit_of_measurement battery_level fan_speed volume_level is_volume_muted media_title options min max step temperature_unit supported_features device_class next_rising next_setting finishes_at duration remaining humidity wind_speed wind_speed_unit apparent_temperature fan_modes swing_modes fan_mode swing_mode'.split())
# Attributes whose boolean value the screen needs; every other bool stays behind.
BOOL_ATTRS = frozenset(['is_volume_muted'])
@@ -180,6 +182,85 @@ SETTING_RULES = {
# docs/SETTINGS.md walks through adding one.
SETTINGS_BESIDE_BLOCK = ('swipe_pages', 'rotation', 'auto_home', 'auto_home_seconds')
# ----- The screen owns its settings (firmware 0.2.49+) -----
# A screen offers every setting as an entity of its own device, and the settings page on the screen, Home
# Assistant and ESP Screens all change them there. ESP Screens reads them from those entities, changes them
# with the entity's own action, and leaves them out of the layout message, so it never overwrites what
# someone changed on the screen or in an automation. A screen without these entities (older firmware) gets
# its settings in the layout message as before. `show_clock` has no entity: the top bar decides the clock.
# key: (Home Assistant domain, the entity's name in the board profiles)
SETTING_ENTITIES = {
'brightness': ('number', 'Normal brightness'),
'standby_enabled': ('switch', 'Auto standby'),
'standby_seconds': ('number', 'Standby after'),
'standby_brightness': ('number', 'Standby brightness'),
'night_enabled': ('switch', 'Night mode'),
'night_start': ('time', 'Night starts'),
'night_end': ('time', 'Night ends'),
'night_brightness': ('number', 'Night brightness'),
'clock_24h': ('switch', '24-hour clock'),
'auto_home': ('switch', 'Back to page 1'),
'auto_home_seconds': ('number', 'Back to page 1 after'),
'home_on_standby': ('switch', 'Back to page 1 on standby'),
'swipe_pages': ('switch', 'Swipe between pages'),
'rotation': ('select', 'Rotation'),
}
# Entities firmware 0.2.49 added; one of them on a device means the screen owns its settings. The first five
# existed before, so they cannot tell.
OWNED_SETTINGS_MARKERS = frozenset(('Night mode', 'Night starts', 'Night ends', '24-hour clock', 'Back to page 1',
'Back to page 1 after', 'Back to page 1 on standby', 'Swipe between pages'))
ROTATION_OPTIONS = ('0°', '90°', '180°', '270°')
def setting_entities(items):
"""{key: entity_id} of a screen's setting entities, from the registry entries of its device (disabled ones
included), or None when the screen does not own its settings yet (firmware before 0.2.49)."""
found, owned = {}, False
for item in items:
if item.get('platform') != 'esphome':
continue
name, domain = item.get('original_name'), item['entity_id'].split('.', 1)[0]
owned = owned or name in OWNED_SETTINGS_MARKERS
for key, (wanted_domain, wanted_name) in SETTING_ENTITIES.items():
if name == wanted_name and domain == wanted_domain and key not in found:
found[key] = item['entity_id']
return found if owned else None
def setting_from_state(key, state):
"""The value of one setting from its entity's state, or None while Home Assistant has none."""
value = (state or {}).get('state')
if not isinstance(value, str) or value in ('', 'unknown', 'unavailable'):
return None
domain = SETTING_ENTITIES[key][0]
try:
if domain == 'switch':
return {'on': True, 'off': False}.get(value)
if domain == 'number':
number = float(value)
return int(round(number)) if math.isfinite(number) else None
if domain == 'time':
hour, minute = (int(part) for part in value.split(':')[:2])
return hour * 60 + minute if 0 <= hour < 24 and 0 <= minute < 60 else None
if domain == 'select':
return int(value.rstrip('°')) if value in ROTATION_OPTIONS else None
except ValueError:
return None
return None
def setting_action(key, entity, value):
"""(action, data) that gives one setting entity `value`."""
domain = SETTING_ENTITIES[key][0]
if domain == 'switch':
return ('switch.turn_on' if value else 'switch.turn_off'), {'entity_id': entity}
if domain == 'number':
return 'number.set_value', {'entity_id': entity, 'value': value}
if domain == 'time':
return 'time.set_value', {'entity_id': entity, 'time': f'{value // 60:02d}:{value % 60:02d}:00'}
return 'select.select_option', {'entity_id': entity, 'option': f'{value}°'}
def validate_settings(data):
if not isinstance(data, dict) or set(data) - SETTING_RULES.keys():
raise ValueError('Unknown screen settings; refresh the management page.')
@@ -590,6 +671,15 @@ def local_clock(value, tz):
moment = moment.replace(tzinfo=timezone.utc)
return moment.astimezone(tz or timezone.utc).strftime('%H:%M')
def forecast_kinds(attributes):
"""The forecasts a weather entity offers, from its supported_features (WeatherEntityFeature: 1 daily,
2 hourly). Asking for one it lacks makes Home Assistant log an error; an entity that reports no features
(unavailable) is asked for both, as before."""
features = (attributes or {}).get('supported_features')
if not isinstance(features, int) or isinstance(features, bool):
return frozenset(('daily', 'hourly'))
return frozenset(kind for kind, bit in (('daily', 1), ('hourly', 2)) if features & bit)
def forecast_number(entry, name):
value = entry.get(name)
return round(value, 1) if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) else None
@@ -996,6 +1086,7 @@ def discover_screens(registry, states, devices, areas):
state = states.get(eid, {})
area = area_map.get(item.get('area_id') or device.get('area_id'), '')
screens.append({'id': eid, 'name': device.get('name_by_user') or device.get('name') or eid,
'device_id': item.get('device_id'),
'firmware': versions.get(item.get('device_id'), 'unknown'),
'board': boards.get(item.get('device_id'), 'unknown'),
'node': nodes.get(item.get('device_id')), 'ip': addresses.get(item.get('device_id')),
+295 -30
View File
@@ -15,7 +15,8 @@ import tile_icons
from updates import Updater
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
from core import BROADCAST_EVENTS, BUILTIN, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, apply_tile_event, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_data, alert_reference, alert_service, alert_targets, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, header_items, inbox_prefix, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
from core import BROADCAST_EVENTS, BUILTIN, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, apply_tile_event, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_data, alert_reference, alert_service, alert_targets, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, forecast_kinds, header_items, inbox_prefix, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
from core import SETTING_ENTITIES, SETTING_RULES, setting_action, setting_entities, setting_from_state
import header_bar
from zoneinfo import ZoneInfo
@@ -40,6 +41,12 @@ RESEND_STATES = frozenset({
})
RESEND_GUARD_SECONDS = 120
FORECAST_SECONDS = 1800
# A screen that answers its ping (firmware 0.2.49+) is asked with this timeout, so a busy screen never holds
# up the others. An answer that the screen lacks something repeats everything, after this many seconds right
# after a full send, doubling up to RESEND_GUARD_SECONDS while it keeps failing.
ANSWER_TIMEOUT_SECONDS = 5
ANSWER_RETRY_SECONDS = 30
SERVICE_EVENTS = ('service_registered', 'service_removed')
def samples(events, begin, span):
"""24 values, one per bucket: the last known value at the end of each bucket (None until the first)."""
@@ -53,6 +60,13 @@ def samples(events, begin, span):
out.append(value)
return out
class Refused(ConnectionError):
"""Home Assistant answered a command with an error; `detail` is its own message."""
def __init__(self, detail=''):
super().__init__('Home Assistant refused the command.')
self.detail = detail
def rounded(value):
try:
value = float(value)
@@ -85,6 +99,10 @@ class HomeAssistant:
self.broadcasts = asyncio.Queue()
# (event type, data) of tile events (app 0.2.51+), for Manager.tile_loop.
self.tile_events = asyncio.Queue()
# ESPHome actions that can answer (`esphome.<node>_screen_message` of firmware 0.2.49+), from Home
# Assistant's own list; refreshed when a device registers its actions again.
self.responses = set()
self.services_changed = asyncio.Event()
async def request(self, kind, **data):
if self.ws is None or self.ws.closed:
@@ -110,7 +128,8 @@ class HomeAssistant:
if data.get('success'):
future.set_result(data.get('result'))
else:
future.set_exception(ConnectionError('Home Assistant refused the command.'))
error = data.get('error') if isinstance(data.get('error'), dict) else {}
future.set_exception(Refused(str(error.get('message') or '')))
elif data.get('type') == 'event':
event = data.get('event', {})
body = event.get('data', {})
@@ -128,6 +147,9 @@ class HomeAssistant:
self.changed.set()
elif event.get('event_type') in REGISTRY_EVENTS:
self.registry_changed.set()
elif event.get('event_type') in SERVICE_EVENTS:
if body.get('domain') == 'esphome':
self.services_changed.set()
elif event.get('event_type') in BROADCAST_EVENTS:
# Queued, not handled here: the calls wait for results this reader has to deliver.
self.broadcasts.put_nowait((event['event_type'], body))
@@ -148,6 +170,19 @@ class HomeAssistant:
self.registry, self.devices, self.areas = await asyncio.gather(
self.request('config/entity_registry/list'), self.request('config/device_registry/list'), self.request('config/area_registry/list'))
async def fetch_services(self):
"""Which ESPHome actions answer: Home Assistant lists `response` for an action that can return one."""
esphome = (await self.request('get_services') or {}).get('esphome', {})
self.responses = {f'esphome.{name}' for name, spec in esphome.items() if isinstance(spec, dict) and spec.get('response')}
async def refresh_services(self):
"""fetch_services for the connection loop: a list that cannot be read only means no answers are asked for."""
try:
await self.fetch_services()
except (ConnectionError, TimeoutError, OSError, ValueError, TypeError, AttributeError) as error:
LOG.warning('Reading the actions from Home Assistant failed (%s); screens are not asked for answers', type(error).__name__)
self.responses = set()
async def run(self):
url = ('ws://supervisor/core/websocket' if self.base == 'http://supervisor/core/api'
else self.base.replace('http://', 'ws://').replace('https://', 'wss://') + '/websocket')
@@ -163,10 +198,11 @@ class HomeAssistant:
reader = asyncio.create_task(self.read())
await self.request('subscribe_events', event_type='state_changed')
await self.request('subscribe_events', event_type='esphome.screen_setting')
for event_type in (*REGISTRY_EVENTS, *BROADCAST_EVENTS, *TILE_EVENTS):
for event_type in (*REGISTRY_EVENTS, *BROADCAST_EVENTS, *TILE_EVENTS, *SERVICE_EVENTS):
await self.request('subscribe_events', event_type=event_type)
self.states = {s['entity_id']: s for s in await self.request('get_states')}
await self.registries()
await self.refresh_services()
try:
config = await self.request('get_config')
self.units = config.get('unit_system') or {}
@@ -181,13 +217,20 @@ class HomeAssistant:
# fallback; a full fetch is about 1 MB of JSON and used to run every 30 s.
fetched = time.monotonic()
while True:
waiter = asyncio.ensure_future(self.registry_changed.wait())
waiters = [asyncio.ensure_future(self.registry_changed.wait()), asyncio.ensure_future(self.services_changed.wait())]
try:
done, _ = await asyncio.wait([reader, waiter], timeout=30, return_when=asyncio.FIRST_COMPLETED)
done, _ = await asyncio.wait([reader, *waiters], timeout=30, return_when=asyncio.FIRST_COMPLETED)
finally:
waiter.cancel()
for waiter in waiters:
waiter.cancel()
if reader in done:
await reader
if self.services_changed.is_set():
# A screen that reconnects registers its actions one after the other.
await asyncio.sleep(1)
self.services_changed.clear()
await self.refresh_services()
self.changed.set()
if self.registry_changed.is_set():
await asyncio.sleep(1)
self.registry_changed.clear()
@@ -214,12 +257,20 @@ class HomeAssistant:
future.set_exception(ConnectionError('Connection lost.'))
await asyncio.sleep(5)
async def send(self, inbox, message, action=None):
"""One message to a screen: as one ESPHome action call (firmware 0.2.33+), else as text chunks."""
async def send(self, inbox, message, action=None, respond=False):
"""One message to a screen: as one ESPHome action call (firmware 0.2.33+), else as text chunks.
`respond`: wait for the screen's answer (firmware 0.2.49+), at most ANSWER_TIMEOUT_SECONDS, and return it
(`status`, `rev`), or None when the answer carries nothing usable."""
if action:
domain, service = action.split('.', 1)
await self.request('call_service', domain=domain, service=service, service_data={'message': encode(message)})
return
data = {'domain': domain, 'service': service, 'service_data': {'message': encode(message)}}
if not respond:
await self.request('call_service', **data)
return None
result = await asyncio.wait_for(self.request('call_service', **data, return_response=True), ANSWER_TIMEOUT_SECONDS)
response = (result or {}).get('response') if isinstance(result, dict) else None
return response if isinstance(response, dict) else None
for packet in packets(message):
await self.request('call_service', domain='text', service='set_value',
service_data={'entity_id': inbox, 'value': packet})
@@ -317,6 +368,11 @@ class Manager:
# The layout snapshot each screen's sensor already carries, so it is only written when it changes.
self.published = {}
self._prefix_source, self._prefixes = None, {}
# Screens that own their settings (firmware 0.2.49+): {device_id: {key: entity_id}}, per registry.
self._settings_source, self._settings_index, self._settings_key = None, {}, None
# Answers (firmware 0.2.49+): when to send everything again after one said the screen lacks something,
# how often that failed in a row, and actions Home Assistant refused to answer for.
self.retry_at, self.retries, self.no_answers = {}, {}, set()
if self.path.exists():
raw = json.loads(self.path.read_text())
# Versioned persistent data. Never silently overwrite an unknown schema.
@@ -440,6 +496,161 @@ class Manager:
screen = self.screen(inbox) or {}
return message_action(screen.get('node')) if self.supports_ping(inbox, screen) else None
# ----- Screen settings: owned by the screen (firmware 0.2.49+), else stored with the layout -----
def setting_index(self):
"""{device_id: {key: entity_id}} for every screen that owns its settings; rebuilt per registry."""
registry = getattr(self.ha, 'registry', [])
if self._settings_source is not registry:
by_device = {}
for item in registry:
if item.get('platform') == 'esphome' and item.get('device_id'):
by_device.setdefault(item['device_id'], []).append(item)
index = {}
for device, items in by_device.items():
entities = setting_entities(items)
if entities is not None:
index[device] = entities
self._settings_source, self._settings_index = registry, index
return self._settings_index
def setting_entities(self, screen):
"""{key: entity_id} when this screen owns its settings, else None (they travel in the layout message)."""
device = (screen or {}).get('device_id')
return self.setting_index().get(device) if device else None
def settings_view(self, screen):
"""What the editor shows under Screen settings.
`owner` is 'screen' when the screen's own entities hold the settings, else 'layout'. `keys` are the
settings this screen has, `unavailable` the ones Home Assistant cannot read or change right now (the
screen is offline, or the entity is disabled); their value is None, not a default that could differ
from what the screen has."""
inbox = self.aliases.get(screen['id'], screen['id'])
keys = [key for key in SETTING_RULES if key != 'show_clock' and (key != 'rotation' or screen.get('board') == 'guition')]
entities = self.setting_entities(screen)
if entities is None:
try:
values = validate_settings(self.layouts.get(inbox, {}).get('settings', {}))
except ValueError:
values = validate_settings({})
if (self.firmware_version(inbox, screen) or (0, 0, 0)) < (0, 2, 44):
keys = [key for key in keys if key not in ('auto_home', 'auto_home_seconds')]
return {'owner': 'layout', 'values': values, 'keys': keys, 'unavailable': []}
# Only the settings this screen has an entity for: one added in later firmware stays out of the panel.
keys = [key for key in keys if key in entities]
values = {key: setting_from_state(key, self.ha.states.get(entities[key])) for key in keys}
return {'owner': 'screen', 'values': values, 'keys': keys, 'unavailable': [key for key in keys if values[key] is None]}
def settings_states_key(self):
"""The states of every setting entity, so the sync loop notices a change the editor should show."""
states = self.ha.states
return tuple((entity, states.get(entity, {}).get('state')) for device in self.setting_index().values()
for entity in device.values())
def store_settings(self, inbox, settings, screen=None, on_screen=False):
"""Settings of a screen that does not own them, kept with its layout.
`on_screen`: the screen reported them itself. The layout message it holds is then brought in step
without sending it back, which could undo a change it made after reporting this one; its revision
stays, so the next ping still matches."""
base = self.layouts.get(inbox) or {'title': (screen or {}).get('name') or 'Home', 'tiles': []}
layout = validate_layout({**base, 'settings': settings})
updated = {**self.layouts, inbox: layout}
self.write_layouts(updated)
self.layouts = updated
if on_screen and inbox in self.sent:
self.sent[inbox] = {**self.sent[inbox], 'layout': self.layout_message(inbox, layout, screen or self.screen(inbox) or {})}
self.notify()
def screen_setting_event(self, event):
"""A screen reported a setting that changed on it (its settings page, one of its entities).
A screen that owns its settings needs nothing here: its entities carry the change. Older firmware gets
it kept with the layout, without the layout being sent back."""
inbox = self.aliases.get(event.get('inbox'), event.get('inbox'))
if inbox not in self.layouts:
return
screen = self.screen(inbox)
if screen is not None and self.setting_entities(screen) is not None:
return
key, value = event.get('key'), event.get('value')
stored = validate_settings(self.layouts[inbox].get('settings', {}))
if key not in stored or key == 'show_clock':
return
settings = dict(stored)
settings[key] = value == '1' if type(stored[key]) is bool else int(value)
if key == 'brightness':
for dim in ('standby_brightness', 'night_brightness'):
settings[dim] = min(settings[dim], settings[key])
settings = validate_settings(settings)
# A screen reporting what it already has (an automation setting the same value on every light change).
if settings != stored:
self.store_settings(inbox, settings, screen, on_screen=True)
async def change_settings(self, inbox, changes):
"""Settings from the editor: on the screen itself when it owns them, else kept with its layout, which the
sync loop then sends. Returns the settings as the editor shows them afterwards."""
inbox = self.aliases.get(inbox, inbox)
screen = self.screen(inbox)
if screen is None:
raise ValueError("This isn't a paired ESP screen. Refresh the overview.")
view = self.settings_view(screen)
if not isinstance(changes, dict) or not changes or set(changes) - set(view['keys']):
raise ValueError('Unknown screen settings; refresh the management page.')
if view['owner'] == 'screen' and not screen.get('online'):
raise ValueError('This screen is offline. You can change its settings once it is back.')
missing = [SETTING_ENTITIES[key][1] for key in changes if key in view['unavailable']]
if missing:
raise ValueError(f"{', '.join(missing)} can't be changed now: the entity is off in Home Assistant, or the screen is restarting.")
# A setting Home Assistant cannot read (its entity is off) is checked at its default.
wanted = {**{key: value for key, value in view['values'].items() if value is not None}, **changes}
if 'brightness' in changes and type(changes['brightness']) is int:
# A lower brightness pulls both dim levels down with it, as on the screen.
for dim in ('standby_brightness', 'night_brightness'):
if dim not in changes:
wanted[dim] = min(wanted.get(dim, SETTING_RULES[dim][0]), changes['brightness'])
merged = validate_settings(wanted)
if merged['rotation'] and screen.get('board') != 'guition':
raise ValueError('Rotation requires a Guition with firmware 0.2.9 or newer.')
if view['owner'] == 'layout':
self.store_settings(inbox, merged, screen)
self.ha.changed.set()
return self.settings_view(screen)
entities = self.setting_entities(screen)
# Brightness first: the screen keeps both dim levels at or below it.
for key in sorted(changes, key=lambda key: (key != 'brightness', list(SETTING_RULES).index(key))):
if merged[key] != view['values'][key]:
await self.ha.call(*setting_action(key, entities[key], merged[key]))
return self.settings_view(screen)
# ----- Answers from the screen (firmware 0.2.49+) -----
def answers(self, inbox, screen):
"""True when the screen's message action can answer, so a ping learns at once what the screen holds."""
action = self.transport(inbox, screen)
return bool(action) and action in getattr(self.ha, 'responses', ()) and action not in self.no_answers
def answered(self, inbox, screen, answer):
"""Act on a screen's answer to a ping: everything again when it lacks the layout or a tile, or refused one."""
status = answer.get('status') if isinstance(answer, dict) else None
if not isinstance(status, str):
return
if status not in RESEND_STATES and not status.startswith('Error'):
self.retries.pop(inbox, None)
return
failures = self.retries.get(inbox, 0)
due = self.last.get(inbox, 0) + min(RESEND_GUARD_SECONDS, ANSWER_RETRY_SECONDS * 2 ** failures)
name = (screen or {}).get('name') or inbox
if time.monotonic() >= due:
LOG.info('%s answered "%s"; sending everything again', name, status)
self.retries[inbox] = failures + 1
self.retry_at.pop(inbox, None)
self.sent.pop(inbox, None)
self.ha.changed.set()
elif inbox not in self.retry_at:
LOG.info('%s answered "%s"; sending everything again in %d s', name, status, max(1, round(due - time.monotonic())))
self.retries[inbox] = failures + 1
self.retry_at[inbox] = due
def registry_index(self):
"""Entity registry by id (display precision, entity category); rebuilt only when HA delivers a new registry."""
registry = getattr(self.ha, 'registry', [])
@@ -487,6 +698,11 @@ class Manager:
needed = self.needs_firmware(inbox, layout, screen)
if needed:
raise ValueError(f"Install screen firmware {needed} or newer first for these tiles.")
# Settings change through their own call (change_settings). A page from before app 0.2.57 still sends them
# with the tiles: a screen that owns its settings ignores them, so a stale form never undoes a change
# made on the screen; other screens keep taking them, as before.
if self.setting_entities(screen) is not None:
layout.pop('settings', None)
# A still-open older UI may save tiles without the new optional settings or top bar.
if 'settings' not in layout and 'settings' in self.layouts.get(inbox, {}):
layout['settings'] = self.layouts[inbox]['settings'].copy()
@@ -556,6 +772,7 @@ class Manager:
watched |= {item['entity'] for layout in self.layouts.values() for item in header_items(layout) if item['type'] == 'entity'}
watched |= {item['entity_id'] for item in self.screen_registry()}
watched |= {eid for layout in self.layouts.values() for tile in layout['tiles'] for eid in self.related_entities(tile)}
watched |= {eid for device in self.setting_index().values() for eid in device.values()}
self._watched_key, self._watched = key, watched
return set(self._watched)
@@ -576,9 +793,15 @@ class Manager:
"""The state message of one tile: state, options, extras, and the history the background task holds."""
forecast=hourly=None
if tile['entity'].startswith('weather.') and hasattr(self.ha,'forecast'):
forecast=await self.cached(self.forecasts, tile['entity'], FORECAST_SECONDS, lambda: self.ha.forecast(tile['entity']))
entity = tile['entity']
# Only the forecasts the entity offers: asking Buienradar for hourly ones logged an error in Home
# Assistant every half hour. What it lacks is kept as empty, so the cache still counts its age.
kinds = forecast_kinds(self.ha.states.get(entity, {}).get('attributes', {}))
async def nothing():
return []
forecast=await self.cached(self.forecasts, entity, FORECAST_SECONDS, (lambda: self.ha.forecast(entity)) if 'daily' in kinds else nothing)
# Hourly forecasts feed the weather card's next-hours strip (0.2.23+); refreshed every half hour.
hourly=await self.cached(self.forecasts, (tile['entity'],'hourly'), FORECAST_SECONDS, lambda: self.ha.forecast(tile['entity'],'hourly'))
hourly=await self.cached(self.forecasts, (entity,'hourly'), FORECAST_SECONDS, (lambda: self.ha.forecast(entity,'hourly')) if 'hourly' in kinds else nothing)
# A vacuum's card also reads selects and the battery sensor of its device (app 0.2.46).
device=self.device_entries(tile['entity']) if tile['entity'].startswith('vacuum.') else None
message=state_message(index,tile,self.ha.states,extras(tile,self.ha.states,forecast,getattr(self.ha,'time_zone',None),hourly,device=device))
@@ -598,7 +821,8 @@ class Manager:
'slots': [t['slot'] for t in tiles], 'keepalive': KEEPALIVE_SECONDS}
if 'pages' in layout:
message['pages'] = layout['pages']
if 'settings' in layout:
# A screen that owns its settings (firmware 0.2.49+) gets none: they would overwrite what changed on it.
if 'settings' in layout and self.setting_entities(screen) is None:
# `settings` is the fixed eleven-key block older firmware insists on; everything added
# later travels as its own key, which firmware that predates it simply ignores.
message['settings'] = {k:v for k,v in layout['settings'].items() if k not in SETTINGS_BESIDE_BLOCK}
@@ -656,7 +880,11 @@ class Manager:
self.sent.pop(inbox, None)
return True
await self.ha.send(inbox, message, action)
self.sent[inbox] = {'layout': layout_msg, 'header': header_msg, 'states': states, 'rev': layout_msg['rev']}
# `rev` is the revision of the layout message the screen holds: a layout brought in step without being
# sent (settings the screen reported itself, see store_settings) keeps the one it had.
sent_layout = any(message is layout_msg for message in outgoing)
rev = layout_msg['rev'] if sent_layout or not previous else previous.get('rev', layout_msg['rev'])
self.sent[inbox] = {'layout': layout_msg, 'header': header_msg, 'states': states, 'rev': rev}
# The hourly timer follows a real full transmission; any message refreshes the screen's
# feed window, so the ping timer follows whatever went out (a rebuild without differences,
# after a registry refresh, must not postpone the ping).
@@ -665,15 +893,40 @@ class Manager:
if outgoing:
self.pinged[inbox] = time.monotonic()
self.status[inbox] = 'Sent to Home Assistant'
# A screen that answers confirms a new layout at once: a ping after the batch says whether it holds
# the layout and every tile, or which part is missing (firmware 0.2.49+).
if sent_layout and self.answers(inbox, screen):
await self.ping(inbox, screen)
return bool(outgoing)
async def ping(self, inbox, screen):
"""Keepalive for firmware 0.2.33+: the layout revision only; the screen asks for the rest itself."""
"""Keepalive for firmware 0.2.33+: the layout revision only; the screen asks for the rest itself. A screen
that can answer (firmware 0.2.49+) is asked to, so a missing layout or tile is sent again right away."""
sent = self.sent.get(inbox)
if not sent:
return
await self.ha.send(inbox, {'v': 1, 'op': 'ping', 'rev': sent['rev'], 'keepalive': KEEPALIVE_SECONDS}, self.transport(inbox, screen))
message = {'v': 1, 'op': 'ping', 'rev': sent['rev'], 'keepalive': KEEPALIVE_SECONDS}
action = self.transport(inbox, screen)
if not self.answers(inbox, screen):
await self.ha.send(inbox, message, action)
self.pinged[inbox] = time.monotonic()
return
try:
answer = await self.ha.send(inbox, message, action, respond=True)
except TimeoutError:
# Busy or gone: the text inbox and the next ping still tell.
LOG.info('%s did not answer its ping within %d s', (screen or {}).get('name') or inbox, ANSWER_TIMEOUT_SECONDS)
answer = None
except Refused as error:
if 'response' not in error.detail.lower():
raise
# Home Assistant listed an answer but will not give one: ask this screen no more, ping it as before.
LOG.warning('Home Assistant gives no answer for %s (%s); pinging it without', action, error.detail)
self.no_answers.add(action)
await self.ha.send(inbox, message, action)
answer = None
self.pinged[inbox] = time.monotonic()
self.answered(inbox, screen, answer)
def take_dirty(self):
"""Entity ids that changed since the last pass, or None when everything must be rebuilt."""
@@ -698,30 +951,26 @@ class Manager:
await asyncio.sleep(0.25)
if not self.ha.online:
self.sent.clear()
# States made over the REST API are gone once Home Assistant restarts: publish them again.
self.published.clear()
self.notify()
continue
for event in getattr(self.ha,'setting_events',[])[:]:
try:
inbox,key,value=event.get('inbox'),event.get('key'),event.get('value')
if inbox in self.layouts:
layout=dict(self.layouts[inbox]); stored=validate_settings(layout.get('settings',{})); settings=dict(stored)
if key not in settings: continue
settings[key] = value=='1' if type(settings[key]) is bool else int(value)
if key=='brightness':
settings['standby_brightness']=min(settings['standby_brightness'],settings[key])
settings['night_brightness']=min(settings['night_brightness'],settings[key])
settings=validate_settings(settings)
# A screen reporting what it already has (an automation setting the same value on
# every light change): saving would resend the whole screen while someone uses it.
if settings==stored: continue
layout['settings']=settings;self.save(inbox,layout)
except (ValueError,TypeError): pass
self.screen_setting_event(event)
except (ValueError, TypeError, AttributeError) as error:
LOG.warning('A setting %s reported by %s was not kept (%s)', event.get('key') if isinstance(event, dict) else '?',
event.get('inbox') if isinstance(event, dict) else '?', error)
if hasattr(self.ha,'setting_events'): self.ha.setting_events.clear()
dirty = self.take_dirty()
screens_key = self._screens_key
screens = self.screens()
changed = self._screens_key != screens_key
self.ha.relevant = self.watched_entities()
# A setting changed on a screen that owns them: the editor shows it.
settings_key = self.settings_states_key()
if settings_key != self._settings_key:
self._settings_key, changed = settings_key, True
for screen in screens:
inbox = screen['id']
before = self.status.get(inbox)
@@ -738,6 +987,10 @@ class Manager:
# The screen says it lost the layout (restart, mismatched ping, a state that never came).
if inbox in self.sent and screen['status'] in RESEND_STATES and now - self.last.get(inbox, 0) >= RESEND_GUARD_SECONDS:
self.sent.pop(inbox)
# An answer said so right after a full send; its wait is over (firmware 0.2.49+).
if inbox in self.retry_at and now >= self.retry_at[inbox]:
del self.retry_at[inbox]
self.sent.pop(inbox, None)
force = now - self.last.get(inbox, 0) >= (FULL_REPEAT_SECONDS if pings else KEEPALIVE_SECONDS)
if await self.sync_one(inbox, self.layouts[inbox], force, screen, dirty):
changed = True
@@ -914,6 +1167,7 @@ def create_app(manager, development=False):
profiles = manager.firmware.profile_names()
for screen in screens:
screen['layout'] = manager.layouts.get(screen['id'], {'title': 'Home', 'tiles': []})
screen['settings'] = manager.settings_view(screen)
screen['delivery'] = manager.status.get(screen['id'], 'Choose your first tiles')
screen['update'] = manager.updates.state_for(screen, profiles)
screen['alert_action'] = alert_service(screen.get('node'))
@@ -971,6 +1225,16 @@ def create_app(manager, development=False):
async def save(request):
manager.save(request.match_info['inbox'], await request.json())
return web.json_response({'saved': True})
async def change_settings(request):
"""Screen settings apply one change at a time, like the settings page on the screen (app 0.2.57)."""
data = await request.json()
try:
view = await manager.change_settings(request.match_info['inbox'], data.get('settings') if isinstance(data, dict) else None)
except Refused as error:
raise ValueError(f"Home Assistant didn't take the change: {error.detail or 'no reason given'}.") from error
except (ConnectionError, TimeoutError) as error:
raise ValueError("Home Assistant isn't reachable right now. Try again in a moment.") from error
return web.json_response(view)
async def update_screen(request):
data = await request.json() if request.can_read_body else {}
return web.json_response(manager.updates.start(request.match_info['inbox'], data.get('host')))
@@ -1021,6 +1285,7 @@ def create_app(manager, development=False):
app.router.add_get('/api/claude-skill.zip', download_claude_skill)
app.router.add_get('/api/events', events)
app.router.add_put('/api/screens/{inbox}', save)
app.router.add_put('/api/screens/{inbox}/settings', change_settings)
app.router.add_static('/static/', static)
return app
+279 -112
View File
@@ -58,6 +58,10 @@ function select(id) {
)
)
return;
if (id !== selected) {
flushSettings();
settingEdits = {};
}
selected = id;
selectedTile = null;
if ($("#tile-sheet").open) $("#tile-sheet").close();
@@ -80,106 +84,272 @@ function select(id) {
renderSettings();
loadTopbarPreview(0);
}
const settingDefinitions = [
["standby_enabled", "Auto standby", "check", true],
["standby_seconds", "Standby after", "minutes", 600, 1, 1440],
["brightness", "Normal brightness", "range", 100, 5, 100],
["standby_brightness", "Standby brightness", "range", 20, 0, 100],
["night_enabled", "Use night mode", "check", true],
["night_brightness", "Night brightness", "range", 10, 0, 100],
["night_start", "Night starts", "time", 1320],
["night_end", "Night ends", "time", 420],
// Whether the clock shows is up to the top bar now; the add-on keeps show_clock in step for older firmware.
["clock_24h", "24-hour clock (off = 12 hour)", "check", true],
["home_on_standby", "Return to page 1 after standby", "check", false],
["auto_home", "Close cards and return to page 1 by itself (firmware 0.2.44+)", "check", true],
["auto_home_seconds", "Return to page 1 after", "minutes", 120, 1, 60],
["rotation", "Rotate screen (clockwise)", "rotation", 0],
["swipe_pages", "Swipe between pages (Guition: from the edge, firmware 0.2.24+; CYD: quick swipe, 0.2.7+)", "check", false],
// ----- Screen settings: the same groups and rows as the settings page on the screen itself -----
// Every change applies at once, like on the screen; no Save needed. A screen with firmware 0.2.49+ owns its
// settings and ESP Screens changes them through its entities in Home Assistant, so an automation, the page on
// the screen and this panel always show the same value. Older firmware gets them with its layout.
const SETTING_GROUPS = [
{ title: "Brightness", icon: "F0599", rows: [
{ key: "brightness", label: "Brightness", kind: "number", min: 5, max: 100, step: 5, unit: "%" },
{ key: "standby_enabled", label: "Auto standby", kind: "toggle" },
{ key: "standby_seconds", label: "Standby after", kind: "duration", min: 60, max: 86400, needs: "standby_enabled" },
{ key: "standby_brightness", label: "Standby brightness", kind: "number", min: 0, max: 100, step: 5, unit: "%", needs: "standby_enabled", cap: "brightness" },
] },
{ title: "Night", icon: "F0594", rows: [
{ key: "night_enabled", label: "Night mode", kind: "toggle" },
{ key: "night_start", label: "Starts", kind: "moment", needs: "night_enabled" },
{ key: "night_end", label: "Ends", kind: "moment", needs: "night_enabled" },
{ key: "night_brightness", label: "Night brightness", kind: "number", min: 0, max: 100, step: 5, unit: "%", needs: "night_enabled", cap: "brightness" },
] },
{ title: "Screen", icon: "F0379", rows: [
{ key: "clock_24h", label: "Clock", kind: "choice", options: [[false, "12 hour"], [true, "24 hour"]] },
{ key: "auto_home", label: "Back to page 1", kind: "toggle" },
{ key: "auto_home_seconds", label: "After", kind: "duration", min: 30, max: 3600, needs: "auto_home" },
{ key: "home_on_standby", label: "Also on standby", kind: "toggle" },
{ key: "swipe_pages", label: "Swipe between pages", kind: "toggle" },
{ key: "rotation", label: "Rotation", kind: "choice", options: [[0, "0°"], [90, "90°"], [180, "180°"], [270, "270°"]] },
] },
];
function renderSettings() {
const values = {
...Object.fromEntries(
settingDefinitions.map(([key, , , value]) => [key, value]),
),
...layout.settings,
};
const container = $("#settings-fields");
container.replaceChildren();
for (const [key, title, kind, , min, max] of settingDefinitions) {
if(key === "rotation" && inventory.screens.find(s => s.id === selected)?.board !== "guition") continue;
const label = node(
"label",
undefined,
`setting ${kind === "check" ? "setting-check" : ""}`,
);
const caption = node("span", title),
input = node(kind === "rotation" ? "select" : "input"),
output = node("output");
input.id = `setting-${key}`;
if(kind !== "rotation") input.type =
kind === "check" ? "checkbox" : kind === "minutes" ? "number" : kind;
input.setAttribute("aria-label", title);
input.required = kind === "minutes" || kind === "time";
if(kind === "rotation") {
for(const angle of [0,90,180,270]) { const option=node("option", `${angle}°`); option.value=angle; input.append(option); }
input.value=values[key];
}
else if (kind === "check") input.checked = values[key];
else if (kind === "time")
input.value = `${String(Math.floor(values[key] / 60)).padStart(2, "0")}:${String(values[key] % 60).padStart(2, "0")}`;
else {
input.min = min;
input.max = max;
input.step = 1;
input.value = kind === "minutes" ? values[key] / 60 : values[key];
}
const updateOutput = () =>
(output.textContent =
kind === "range"
? `${input.value}%`
: kind === "minutes"
? "minutes"
: "");
updateOutput();
input.oninput = () => {
if (!input.checkValidity() || input.value === "") return;
let value = kind === "check" ? input.checked : Number(input.value);
if (kind === "time") {
const [h, m] = input.value.split(":").map(Number);
value = h * 60 + m;
}
if (kind === "minutes") value *= 60;
layout.settings = { ...values, ...layout.settings, [key]: value };
// A lower normal brightness also lowers any higher standby levels.
if (key === "brightness")
for (const dim of ["standby_brightness", "night_brightness"]) {
layout.settings[dim] = Math.min(layout.settings[dim], value);
const other = $(`#setting-${dim}`);
other.value = layout.settings[dim];
other.parentElement.querySelector("output").textContent =
`${other.value}%`;
}
updateOutput();
markDirty();
};
label.append(caption, input, output);
container.append(label);
}
renderSettingsSupport();
const SETTING_ROWS = Object.fromEntries(SETTING_GROUPS.flatMap((group) => group.rows.map((row) => [row.key, row])));
// Changes made here that the screen has not reported back yet: {key: {value, at}}. They win over what
// Home Assistant still shows for a few seconds, so a value never flicks back while it travels.
let settingEdits = {}, settingQueue = {}, settingTarget = null, settingTimer = null, settingFlight = null, settingPanel = { key: "", rows: {} };
const SETTING_EDIT_MS = 4000;
const settingsView = () => inventory.screens.find((s) => s.id === selected)?.settings;
function settingValues() {
const view = settingsView(), values = { ...(view?.values || {}) };
for (const [key, edit] of Object.entries(settingEdits)) values[key] = edit.value;
return values;
}
function renderSettingsSupport() {
const version =
inventory.screens.find((s) => s.id === selected)?.firmware || "";
const parts = /^([0-9]+)\.([0-9]+)\.([0-9]+)$/.exec(version);
const supported =
parts &&
(Number(parts[1]) > 0 ||
Number(parts[2]) > 1 ||
(Number(parts[2]) === 1 && Number(parts[3]) >= 2));
$("#settings-support").textContent = supported
? "Saving sends your settings straight to this screen. They're also kept after a restart."
: "Install firmware 0.1.2 or newer once via ESPHome. You can save the settings now; older firmware doesn't use them yet.";
// The same steps as settings_screen.h: seconds low down, quarters of an hour up top; times by the quarter,
// whole hours while held.
const ladderStep = (seconds) => (seconds < 300 ? 30 : seconds < 900 ? 60 : seconds < 3600 ? 300 : seconds < 7200 ? 900 : 1800);
function steppedSetting(row, value, direction, held, values) {
if (row.kind === "moment") {
let next = held && value % 60 ? Math.floor(value / 60) * 60 + (direction > 0 ? 60 : 0) : value + direction * (held ? 60 : 15);
next %= 1440;
return next < 0 ? next + 1440 : next;
}
const step = row.kind === "duration" ? ladderStep(direction < 0 ? value - 1 : value) : row.step;
const max = row.cap ? Math.min(row.max, values[row.cap]) : row.max;
return Math.min(max, Math.max(row.min, value + direction * step));
}
function durationText(seconds) {
if (seconds < 60) return `${seconds} sec`;
if (seconds < 3600) return `${Math.floor(seconds / 60)} min`;
const hours = Math.floor(seconds / 3600), minutes = Math.floor((seconds % 3600) / 60);
return minutes ? `${hours} h ${String(minutes).padStart(2, "0")}` : `${hours} h`;
}
function momentText(minutes, clock24) {
const hour = Math.floor(minutes / 60), minute = String(minutes % 60).padStart(2, "0");
return clock24 ? `${String(hour).padStart(2, "0")}:${minute}` : `${hour % 12 || 12}:${minute} ${hour < 12 ? "AM" : "PM"}`;
}
function settingText(row, values) {
const value = values[row.key];
// Home Assistant has no value while the screen is offline or the entity is off.
if (value === null || value === undefined) return "—";
if (row.kind === "number") return `${value}${row.unit || ""}`;
if (row.kind === "duration") return durationText(value);
if (row.kind === "moment") return momentText(value, values.clock_24h !== false);
return "";
}
// A key held down steps again and again, faster after a moment, like the -/+ keys on the screen.
function holdable(button, step) {
let timer = null, repeats = 0, held = false;
const stop = () => { clearTimeout(timer); timer = null; };
button.addEventListener("pointerdown", (event) => {
if (button.disabled || event.button !== 0) return;
held = false;
repeats = 0;
stop();
timer = setTimeout(function repeat() {
held = true;
repeats += 1;
step(repeats > 5);
timer = setTimeout(repeat, 180);
}, 450);
});
for (const type of ["pointerup", "pointercancel", "pointerleave"]) button.addEventListener(type, stop);
button.addEventListener("click", () => {
if (held) { held = false; return; }
step(false);
});
}
function settingControl(row) {
const refs = { row };
const line = node("div", undefined, `setting-row setting-${row.kind}`);
line.dataset.setting = row.key;
const label = node("span", row.label, "setting-label");
label.id = `setting-label-${row.key}`;
const control = node("div", undefined, "setting-control");
if (row.kind === "toggle") {
const toggle = node("button", undefined, "switch");
toggle.type = "button";
toggle.id = `setting-${row.key}`;
toggle.setAttribute("role", "switch");
toggle.setAttribute("aria-labelledby", label.id);
toggle.onclick = () => setSetting(row.key, !settingValues()[row.key], 150);
// The whole row answers a click, as on the screen.
line.onclick = (event) => { if (event.target === line || event.target === label) toggle.click(); };
control.append(toggle);
refs.toggle = toggle;
} else if (row.kind === "choice") {
const group = node("div", undefined, "segmented setting-choices");
group.setAttribute("role", "group");
group.setAttribute("aria-labelledby", label.id);
refs.chips = row.options.map(([value, text]) => {
const chip = node("button", text);
chip.type = "button";
chip.onclick = () => setSetting(row.key, value, 150);
group.append(chip);
return [value, chip];
});
control.append(group);
} else {
const minus = node("button", undefined, "step");
const plus = node("button", undefined, "step");
minus.type = plus.type = "button";
minus.append(node("span", glyph("F0374"), "mdi"));
plus.append(node("span", glyph("F0415"), "mdi"));
minus.setAttribute("aria-label", `${row.label} lower`);
plus.setAttribute("aria-label", `${row.label} higher`);
const value = node("output", undefined, "setting-value");
value.setAttribute("aria-labelledby", label.id);
value.id = `setting-${row.key}`;
for (const [button, direction] of [[minus, -1], [plus, 1]]) {
holdable(button, (held) => {
const values = settingValues();
const next = steppedSetting(row, values[row.key], direction, held, values);
if (next !== values[row.key]) setSetting(row.key, next, 600);
});
}
control.append(minus, value, plus);
Object.assign(refs, { minus, plus, value });
}
line.append(label, control);
refs.line = line;
return refs;
}
function renderSettings() {
const container = $("#settings-groups");
const screen = inventory.screens.find((s) => s.id === selected), view = screen?.settings;
if (!view) {
container.replaceChildren();
settingPanel = { key: "", rows: {} };
$("#settings-status").textContent = "";
return;
}
// Rebuild only when the rows change (another screen, board or firmware); otherwise update in place, so a
// key held down keeps its grip while the values come back.
const shape = `${screen.id}|${view.keys.join(",")}`;
if (settingPanel.key !== shape) {
const rows = {};
container.replaceChildren(...SETTING_GROUPS.map((group) => {
const shown = group.rows.filter((row) => view.keys.includes(row.key));
const card = node("section", undefined, "settings-card");
card.hidden = !shown.length;
const head = node("h4");
head.append(node("span", glyph(group.icon), "mdi"), node("span", group.title));
card.append(head);
for (const row of shown) {
rows[row.key] = settingControl(row);
card.append(rows[row.key].line);
}
return card;
}));
settingPanel = { key: shape, rows };
}
const values = settingValues();
const offline = view.owner === "screen" && !screen.online;
for (const [key, refs] of Object.entries(settingPanel.rows)) {
const row = refs.row, needs = row.needs ? values[row.needs] : true;
const unavailable = offline || view.unavailable.includes(key);
refs.line.classList.toggle("inactive", !needs || unavailable);
refs.line.title = unavailable && !offline ? "This entity is off in Home Assistant, or the screen is restarting." : "";
if (refs.toggle) {
refs.toggle.setAttribute("aria-checked", String(Boolean(values[key])));
refs.toggle.classList.toggle("unknown", values[key] === null || values[key] === undefined);
refs.toggle.disabled = unavailable;
} else if (refs.chips) {
for (const [value, chip] of refs.chips) {
chip.setAttribute("aria-pressed", String(values[key] === value));
chip.disabled = unavailable;
}
} else {
refs.value.textContent = settingText(row, values);
const lower = steppedSetting(row, values[key], -1, false, values), higher = steppedSetting(row, values[key], 1, false, values);
refs.minus.disabled = unavailable || !needs || (row.kind !== "moment" && lower === values[key]);
refs.plus.disabled = unavailable || !needs || (row.kind !== "moment" && higher === values[key]);
}
}
const pending = Object.keys(settingQueue).length || settingFlight;
$("#settings-status").textContent = offline
? "This screen is offline. Its settings can change once it's back."
: pending
? "Saving…"
: view.owner === "screen"
? "Changes apply on the screen at once, and show up here when they change there."
: "Changes apply at once. Firmware 0.2.49 lets the screen keep them itself.";
$("#general-settings").classList.toggle("offline", offline);
}
function setSetting(key, value, delay) {
// One screen's changes at a time: the ones for the screen shown before go out first.
if (settingTarget && settingTarget !== selected && Object.keys(settingQueue).length) {
flushSettings();
toast("Still saving the other screen's settings. Try again in a moment.");
return;
}
settingTarget = selected;
const values = settingValues();
settingEdits[key] = { value, at: Date.now() };
settingQueue[key] = value;
// A lower brightness pulls both dim levels down with it, as on the screen.
if (key === "brightness")
for (const dim of ["standby_brightness", "night_brightness"])
if (values[dim] > value) settingEdits[dim] = { value, at: Date.now() };
renderSettings();
if (key === "clock_24h") { renderTopbar(); renderBars(); if ($("#topbar-sheet").open) renderTopbarLive(); }
clearTimeout(settingTimer);
settingTimer = setTimeout(flushSettings, delay);
}
async function flushSettings(unloading = false) {
clearTimeout(settingTimer);
if (settingFlight || !Object.keys(settingQueue).length || !settingTarget) return;
const screen = settingTarget, changes = settingQueue;
settingQueue = {};
const request = api(`screens/${encodeURIComponent(screen)}/settings`, {
method: "PUT",
body: JSON.stringify({ settings: changes }),
keepalive: unloading,
});
settingFlight = request;
renderSettings();
try {
const view = await (await request).json();
const current = inventory.screens.find((s) => s.id === screen);
if (current) current.settings = view;
} catch (e) {
toast(e.message);
// What did not arrive is not kept: the panel shows the screen's own values again.
if (screen === selected) for (const key of Object.keys(changes)) delete settingEdits[key];
if (screen === selected && changes.brightness !== undefined) for (const dim of ["standby_brightness", "night_brightness"]) delete settingEdits[dim];
} finally {
settingFlight = null;
if (Object.keys(settingQueue).length) settingTimer = setTimeout(flushSettings, 150);
else settingTarget = null;
if (screen === selected) { settleSettings(); renderSettings(); }
// A value the screen refused or clamped comes back without a live update: look again once edits expire.
setTimeout(() => { if (screen === selected) { settleSettings(); renderSettings(); } }, SETTING_EDIT_MS + 100);
}
}
// Values Home Assistant reports take over again once they match a change made here, or after a few seconds
// (the screen refused or clamped it).
function settleSettings() {
const view = settingsView();
for (const [key, edit] of Object.entries(settingEdits)) {
if (settingQueue[key] !== undefined || settingFlight) continue;
if ((view && view.values[key] === edit.value) || Date.now() - edit.at > SETTING_EDIT_MS) delete settingEdits[key];
}
}
const updating = new Set();
const PHASES = {
@@ -1083,7 +1253,7 @@ function loadTopbarPreview(delay = 150) {
function clockText(now = new Date()) {
let hours = now.getHours();
// The screen formats with %I:%M when the 24-hour clock is off.
if (!(layout.settings?.clock_24h ?? true)) hours = hours % 12 || 12;
if (settingValues().clock_24h === false) hours = hours % 12 || 12;
return `${String(hours).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
}
const dateText = (now = new Date()) => `${WEEKDAYS[now.getDay()]} ${now.getDate()} ${MONTHS[now.getMonth()]}`;
@@ -1479,13 +1649,9 @@ function renderTopbarSheet() {
live.id = "topbar-live";
body.append(live);
if (item.type !== "date") {
const format = segmented([["24", "24 hour"], ["12", "12 hour"]], (layout.settings?.clock_24h ?? true) ? "24" : "12", (v) => {
layout.settings = { ...Object.fromEntries(settingDefinitions.map(([key, , , value]) => [key, value])), ...layout.settings, clock_24h: v === "24" };
markDirty();
renderSettings();
renderTopbar();
renderBars();
renderTopbarLive();
// The screen's own clock setting: it applies at once, like the Clock row under Screen settings.
const format = segmented([["24", "24 hour"], ["12", "12 hour"]], settingValues().clock_24h !== false ? "24" : "12", (v) => {
setSetting("clock_24h", v === "24", 150);
});
const formatField = group("Format", format);
formatField.append(node("small", "Applies to every clock on this screen, including the clock tiles.", "field-hint"));
@@ -1731,7 +1897,7 @@ async function refresh(full = true) {
$("#connection").classList.toggle("online", inventory.connected);
renderScreens();
if (full) renderClaude();
if (selected) renderSettingsSupport();
if (selected) { settleSettings(); renderSettings(); }
if (!selected && inventory.screens.length) select(inventory.screens[0].id);
if ($("#alerts-dialog").open) renderAlertScreens();
} catch {
@@ -1741,16 +1907,15 @@ async function refresh(full = true) {
}
$("#save").onclick = async () => {
if (busy) return;
for (const input of document.querySelectorAll("#settings-fields input")) {
if (!input.reportValidity()) return;
}
busy = true;
$("#save").disabled = true;
try {
layout.title = $("#title").value;
// Screen settings apply on their own (flushSettings); the stored ones stay as they are.
const { settings: _settings, ...tiles } = layout;
await api(`screens/${encodeURIComponent(selected)}`, {
method: "PUT",
body: JSON.stringify(layout),
body: JSON.stringify(tiles),
});
dirty = false;
$("#dirty").textContent = "Saved";
@@ -2046,6 +2211,8 @@ $("#install-retry").onclick = async () => {
}
};
$("#copy-key").onclick = () => copyText(installer.apiKey || "", $("#api-key"));
// A change still waiting for its short pause goes out when the page closes.
window.addEventListener("pagehide", () => flushSettings(true));
window.addEventListener("beforeunload", (e) => {
if (dirty) {
e.preventDefault();
@@ -2062,7 +2229,7 @@ function applyLive(data) {
$("#connection").textContent = inventory.connected ? "● Home Assistant connected" : "Reconnecting to Home Assistant…";
$("#connection").classList.toggle("online", inventory.connected);
renderScreens();
if (selected) renderSettingsSupport();
if (selected) { settleSettings(); renderSettings(); }
if (!selected && inventory.screens.length) select(inventory.screens[0].id);
}
function listen() {
+7 -13
View File
@@ -110,19 +110,13 @@
<div id="results" aria-live="polite"></div>
</div>
</div>
<details class="screen-settings" id="general-settings" open>
<summary>
Screen settings
<span class="hint">Brightness, quiet hours, and display</span>
</summary>
<p class="hint" id="settings-support"></p>
<div class="settings-grid" id="settings-fields"></div>
<p class="hint">
Night hours apply to the brightness during standby. The first
touch wakes the screen; the next one controls your home.
Matching start and end times turn the night window off.
</p>
</details>
<section class="screen-settings" id="general-settings" aria-labelledby="settings-heading">
<div class="section-title settings-title">
<h3 id="settings-heading">Screen settings</h3>
<span class="hint" id="settings-status" role="status"></span>
</div>
<div class="settings-cards" id="settings-groups"></div>
</section>
<details class="screen-settings" id="inspector-section">
<summary>
Inspector
+129 -26
View File
@@ -765,46 +765,149 @@ textarea {
font-size: 18px;
font-weight: 700;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px 28px;
margin: 20px 0;
/* Screen settings: the groups and rows of the settings page on the screen itself (app 0.2.57). */
.settings-title {
align-items: baseline;
flex-wrap: wrap;
gap: 4px 16px;
}
.setting {
padding: 14px;
.settings-title h3 {
margin: 0;
font-size: 18px;
}
/* Columns, not a grid: the cards fill them from the top, so a short group leaves no hole beside a long one. */
.settings-cards {
column-width: 290px;
column-gap: 14px;
margin: 16px 0 4px;
}
.settings-card {
break-inside: avoid;
margin-bottom: 14px;
border: 1px solid var(--line);
border-radius: 12px;
border-radius: 16px;
background: white;
padding: 14px 16px 8px;
}
.setting output {
display: block;
.settings-card h4 {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 6px;
font-size: 15px;
}
.settings-card h4 .mdi {
font-size: 20px;
color: var(--muted);
font-size: 13px;
}
.setting input[type="range"] {
padding: 0;
accent-color: var(--accent);
min-height: 32px;
}
.setting-check {
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 48px;
padding: 4px 0;
font-size: 14px;
}
.setting-check input {
.setting-toggle {
cursor: pointer;
}
.setting-label {
min-width: 0;
overflow-wrap: anywhere;
}
.setting-control {
display: flex;
align-items: center;
gap: 6px;
flex: none;
}
.setting-row.inactive .setting-label,
.setting-row.inactive .setting-control {
opacity: 0.45;
}
#general-settings.offline .settings-cards {
opacity: 0.6;
}
.switch {
position: relative;
width: 46px;
height: 28px;
padding: 0;
border-radius: 14px;
background: #c9d0d9;
transition: background 0.15s;
}
.switch::after {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
flex-shrink: 0;
margin: 0;
accent-color: var(--accent);
border-radius: 50%;
background: white;
box-shadow: 0 1px 2px #17223233;
transition: transform 0.15s;
}
.setting-check output {
display: none;
.switch[aria-checked="true"] {
background: var(--accent);
}
@media (max-width: 640px) {
.settings-grid {
grid-template-columns: 1fr;
.switch[aria-checked="true"]::after {
transform: translateX(18px);
}
/* Neither on nor off: Home Assistant has no value (the screen is offline, or the entity is off). */
.switch.unknown::after {
transform: translateX(9px);
}
.step {
display: grid;
place-items: center;
width: 34px;
height: 34px;
padding: 0;
border-radius: 50%;
background: #eef2f7;
color: var(--ink);
touch-action: manipulation;
user-select: none;
}
.step .mdi {
font-size: 18px;
}
.step:hover:not(:disabled) {
filter: none;
background: #e1e8f1;
}
.step:active:not(:disabled) {
background: var(--soft);
color: var(--accent);
}
.setting-value {
min-width: 72px;
text-align: center;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.setting-choices.segmented {
flex-wrap: nowrap;
gap: 4px;
}
.setting-choices.segmented button {
padding: 6px 10px;
}
/* An end of a range greys its key; a row that waits on its switch fades as a whole instead. */
.setting-row .step:disabled {
opacity: 0.35;
}
.setting-row.inactive button:disabled {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.switch,
.switch::after {
transition: none;
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.56"
version: "0.2.57"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen
+17 -7
View File
@@ -55,19 +55,27 @@ class SettingEvents(unittest.IsolatedAsyncioTestCase):
self.assertEqual(ha.messages, [], 'nothing changed: no resend of the whole screen')
self.assertIs(m.layouts['text.screen'], stored, 'and no save')
self.assertEqual(ha.setting_events, [], 'the event is handled all the same')
# A real change is still saved and sent.
# A real change is kept (app 0.2.57), and not sent back: the screen has it already, and an echo that
# arrives after the screen stepped further would undo that step (a held + on its settings page).
rev = m.sent['text.screen']['rev']
ha.setting_events.append({'inbox': 'text.screen', 'key': 'standby_seconds', 'value': '600'})
ha.changed.set()
await self.run_loop_for(m, 0.6)
self.assertEqual(m.layouts['text.screen']['settings']['standby_seconds'], 600)
self.assertIn('layout', [msg['op'] for _, msg, _ in ha.messages])
self.assertEqual(ha.messages, [], 'nothing goes back to the screen')
self.assertEqual(m.sent['text.screen']['layout']['settings']['standby_seconds'], 600, 'what the screen holds follows')
self.assertEqual(m.sent['text.screen']['rev'], rev, 'the ping keeps the revision the screen has')
# So is a brightness that pulls the standby brightness down with it.
ha.messages.clear()
ha.setting_events.append({'inbox': 'text.screen', 'key': 'brightness', 'value': '10'})
ha.changed.set()
await self.run_loop_for(m, 0.6)
settings = m.layouts['text.screen']['settings']
self.assertEqual((settings['brightness'], settings['standby_brightness']), (10, 10))
self.assertEqual(ha.messages, [])
# The hourly repeat carries the kept values, so the screen and the stored layout agree.
await m.sync_one('text.screen', m.layouts['text.screen'], force=True, screen=m.screen('text.screen'), dirty=set())
layout = next(msg for _, msg, _ in ha.messages if msg['op'] == 'layout')
self.assertEqual((layout['settings']['brightness'], layout['settings']['standby_seconds']), (10, 600))
class Firmware(unittest.TestCase):
@@ -75,12 +83,14 @@ class Firmware(unittest.TestCase):
self.profiles = {name: (ROOT / name).read_text() for name in PROFILES}
def test_home_assistant_setting_the_same_value_writes_and_reports_nothing(self):
# Firmware 0.2.49+: the setting entities change a value through settings_screen::set(), which stores,
# applies and reports only a real change (tests/test_settings_screen.cpp checks that).
screen = (ROOT / 'components/smart_display/settings_screen.h').read_text()
self.assertRegex(screen, r'return SetResult::same;\n changed\(key\.c_str\(\), reported\);')
for name, text in self.profiles.items():
for key in ('brightness', 'standby_brightness', 'night_brightness', 'standby_seconds'):
action = re.search(r'const auto before = s;\n(?:.*\n){1,6}?\s+if \(s == before\) return;\n'
r'\s+if \(runtime_tiles::enabled\) runtime_tiles::settings_preference\.save\(&s\);\n'
rf'\s+runtime_tiles::setting_event\("{key}",', text)
self.assertTrue(action, f'{name}: {key} saves or reports a value it already has')
self.assertIn(f"set_action:\n - lambda: 'settings_screen::set(\"{key}\", (int32_t) lround(x));'", text, f'{name}: {key}')
self.assertNotIn('runtime_tiles::setting_event("', text, f'{name}: settings are reported through set() only')
def test_a_repeated_layout_does_not_redraw_the_page(self):
block = RUNTIME[RUNTIME.index('if (op == "layout")'):RUNTIME.index('if (op == "ping")')]
+598
View File
@@ -0,0 +1,598 @@
"""The screen owns its settings, answers its pings, and three fixes around them (app 0.2.57 / firmware 0.2.49).
- A screen with firmware 0.2.49 offers every setting as an entity. ESP Screens reads and changes them there
and leaves them out of the layout message, so it never undoes a change made on the screen or by an
automation. Older firmware keeps getting them with the layout, but a change it reports is no longer sent
back to it.
- The screen answers a ping with its status (`api.respond`), so ESP Screens knows at once when it lacks
the layout or a tile.
- The layout sensors come back after Home Assistant restarts, the settings tile opens without Home
Assistant, and a weather entity is only asked for the forecasts it has.
"""
import asyncio
import importlib.util
import json
import re
import sys
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
from core import (FIRMWARE_VERSION, SETTING_ENTITIES, SETTING_ENTITIES_MIN_FIRMWARE, SETTING_RULES, forecast_kinds, # noqa: E402
setting_action, setting_entities, setting_from_state, validate_settings)
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
if HAS_AIOHTTP:
import server
from aiohttp import WSMsgType
from aiohttp.test_utils import TestClient, TestServer
from server import ANSWER_RETRY_SECONDS, HomeAssistant, Manager, Refused, create_app
PROFILES = {'cyd': ROOT / 'home-like-2432s028.yaml', 'guition': ROOT / 'guition-4848s040.yaml'}
PACKAGES = {'cyd': ROOT / 'packages/cyd.yaml', 'guition': ROOT / 'packages/guition.yaml'}
RUNTIME = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
SCREEN_PAGE = (ROOT / 'components/smart_display/settings_screen.h').read_text()
STATIC = ROOT / 'screen_manager/app/static'
# The entity ids Home Assistant gives the settings of a screen named "Office 1".
ENTITY_IDS = {
'brightness': 'number.office_1_normal_brightness', 'standby_enabled': 'switch.office_1_auto_standby',
'standby_seconds': 'number.office_1_standby_after', 'standby_brightness': 'number.office_1_standby_brightness',
'night_enabled': 'switch.office_1_night_mode', 'night_start': 'time.office_1_night_starts',
'night_end': 'time.office_1_night_ends', 'night_brightness': 'number.office_1_night_brightness',
'clock_24h': 'switch.office_1_24_hour_clock', 'auto_home': 'switch.office_1_back_to_page_1',
'auto_home_seconds': 'number.office_1_back_to_page_1_after', 'home_on_standby': 'switch.office_1_back_to_page_1_on_standby',
'swipe_pages': 'switch.office_1_swipe_between_pages', 'rotation': 'select.office_1_rotation',
}
STATES = {'brightness': '80.0', 'standby_enabled': 'on', 'standby_seconds': '600.0', 'standby_brightness': '20.0',
'night_enabled': 'on', 'night_start': '22:30:00', 'night_end': '07:00:00', 'night_brightness': '5.0',
'clock_24h': 'on', 'auto_home': 'on', 'auto_home_seconds': '120.0', 'home_on_standby': 'off',
'swipe_pages': 'on', 'rotation': '90°'}
def top_block(text, key):
match = re.search(rf'^{key}:\n(.*?)(?=^[a-z_]+:|\Z)', text, re.M | re.S)
return match.group(1) if match else ''
def fake_ha(firmware=FIRMWARE_VERSION, owned=True, guition=True):
class HA:
online = True
def __init__(self):
self.registry = [{'entity_id': 'text.screen', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd1'},
{'entity_id': 'sensor.fw', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': 'd1'},
{'entity_id': 'sensor.node', 'platform': 'esphome', 'original_name': 'Device name', 'device_id': 'd1'},
{'entity_id': 'light.a', 'platform': 'hue', 'original_name': 'Lamp A'}]
self.states = {'text.screen': {'state': 'Synced'}, 'sensor.fw': {'state': firmware}, 'sensor.node': {'state': 'office-1'},
'light.a': {'state': 'on', 'attributes': {'friendly_name': 'Lamp A'}}}
if guition:
self.registry.append({'entity_id': 'sensor.board', 'platform': 'esphome', 'original_name': 'Guition screen type', 'device_id': 'd1'})
self.states['sensor.board'] = {'state': 'guition'}
if owned:
for key, entity in ENTITY_IDS.items():
if key == 'rotation' and not guition:
continue
self.registry.append({'entity_id': entity, 'platform': 'esphome', 'original_name': SETTING_ENTITIES[key][1], 'device_id': 'd1'})
self.states[entity] = {'state': STATES[key]}
else:
# Firmware 0.2.41-0.2.48: five of them already existed, which says nothing yet.
for key in ('brightness', 'standby_enabled', 'standby_seconds', 'standby_brightness', 'night_brightness'):
self.registry.append({'entity_id': ENTITY_IDS[key], 'platform': 'esphome', 'original_name': SETTING_ENTITIES[key][1], 'device_id': 'd1'})
self.states[ENTITY_IDS[key]] = {'state': STATES[key]}
self.devices, self.areas = [{'id': 'd1', 'name': 'Office 1'}], []
self.changed, self.dirty, self.relevant, self.setting_events = asyncio.Event(), set(), None, []
self.messages, self.calls, self.answers, self.published = [], [], [], {}
self.responses = {'esphome.office_1_screen_message'} if owned else set()
async def send(self, inbox, message, action=None, respond=False):
self.messages.append((message, respond))
if respond:
return self.answers.pop(0) if self.answers else {'status': 'Synced', 'rev': message.get('rev')}
return None
async def call(self, action, data):
self.calls.append((action, data))
async def set_state(self, entity_id, state, attributes):
self.published[entity_id] = (state, attributes)
return HA()
class CoreSettings(unittest.TestCase):
def test_every_setting_the_editor_offers_has_an_entity(self):
self.assertEqual(set(SETTING_ENTITIES), set(SETTING_RULES) - {'show_clock'})
self.assertEqual(SETTING_ENTITIES_MIN_FIRMWARE, FIRMWARE_VERSION)
def test_the_entities_are_named_as_in_both_board_profiles(self):
blocks = {'switch': 'switch', 'number': 'number', 'time': 'datetime', 'select': 'select'}
for board, path in [*PROFILES.items(), *PACKAGES.items()]:
text = path.read_text()
for key, (domain, name) in SETTING_ENTITIES.items():
block = top_block(text, blocks[domain])
if key == 'rotation' and board == 'cyd':
self.assertNotIn(f'name: "{name}"', block, f'{path.name}: the CYD cannot turn')
continue
self.assertIn(f'name: "{name}"', block, f'{path.name}: {key}')
at = block.index(f'name: "{name}"')
entry = block[max(0, at - 200):at + 900]
self.assertIn('entity_category: config', entry, f'{path.name}: {key}')
self.assertRegex(entry, rf'settings_screen::set\("{key}"', f'{path.name}: {key} changes through set()')
self.assertIn('type: time', top_block(text, 'datetime'), path.name)
def test_a_screen_owns_its_settings_only_with_firmware_0_2_49_entities(self):
old = [{'entity_id': ENTITY_IDS['brightness'], 'platform': 'esphome', 'original_name': 'Normal brightness'},
{'entity_id': ENTITY_IDS['standby_enabled'], 'platform': 'esphome', 'original_name': 'Auto standby'}]
self.assertIsNone(setting_entities(old))
new = old + [{'entity_id': ENTITY_IDS['night_start'], 'platform': 'esphome', 'original_name': 'Night starts'},
{'entity_id': 'time.other_night_starts', 'platform': 'template', 'original_name': 'Night starts'},
{'entity_id': 'switch.office_1_rotation', 'platform': 'esphome', 'original_name': 'Rotation'}]
found = setting_entities(new)
self.assertEqual(found, {'brightness': ENTITY_IDS['brightness'], 'standby_enabled': ENTITY_IDS['standby_enabled'],
'night_start': ENTITY_IDS['night_start']}, 'only ESPHome entities of the right domain')
def test_reading_a_setting_from_its_state(self):
read = lambda key, state: setting_from_state(key, {'state': state})
self.assertEqual((read('auto_home', 'on'), read('auto_home', 'off'), read('auto_home', 'unknown')), (True, False, None))
self.assertEqual((read('brightness', '80.0'), read('brightness', '79.6'), read('brightness', 'nan')), (80, 80, None))
self.assertEqual((read('night_start', '22:30:00'), read('night_start', '25:00:00'), read('night_start', 'soon')), (1350, None, None))
self.assertEqual((read('rotation', '270°'), read('rotation', '45°')), (270, None))
self.assertIsNone(setting_from_state('brightness', {'state': 'unavailable'}))
self.assertIsNone(setting_from_state('brightness', None))
def test_the_action_that_changes_a_setting(self):
self.assertEqual(setting_action('auto_home', 'switch.x', True), ('switch.turn_on', {'entity_id': 'switch.x'}))
self.assertEqual(setting_action('auto_home', 'switch.x', False), ('switch.turn_off', {'entity_id': 'switch.x'}))
self.assertEqual(setting_action('brightness', 'number.x', 40), ('number.set_value', {'entity_id': 'number.x', 'value': 40}))
self.assertEqual(setting_action('night_end', 'time.x', 425), ('time.set_value', {'entity_id': 'time.x', 'time': '07:05:00'}))
self.assertEqual(setting_action('rotation', 'select.x', 180), ('select.select_option', {'entity_id': 'select.x', 'option': '180°'}))
def test_a_weather_entity_is_asked_only_for_the_forecasts_it_has(self):
self.assertEqual(forecast_kinds({'supported_features': 1}), {'daily'})
self.assertEqual(forecast_kinds({'supported_features': 2}), {'hourly'})
self.assertEqual(forecast_kinds({'supported_features': 7}), {'daily', 'hourly'})
self.assertEqual(forecast_kinds({'supported_features': 4}), set(), 'twice daily only')
for unknown in ({}, {'supported_features': None}, {'supported_features': True}, None):
self.assertEqual(forecast_kinds(unknown), {'daily', 'hourly'})
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class OwnedSettings(unittest.IsolatedAsyncioTestCase):
def manager(self, tmp, **kw):
m = Manager(fake_ha(**kw), Path(tmp) / 'screens.json')
m.save('text.screen', {'title': 'Office 1', 'tiles': [{'entity': 'light.a', 'name': ''}],
'settings': validate_settings({'brightness': 10, 'standby_brightness': 10, 'night_brightness': 10})})
return m
async def test_the_editor_reads_the_screen(self):
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp)
self.assertNotIn('settings', m.layouts['text.screen'], 'a screen that owns its settings has none stored')
view = m.settings_view(m.screen('text.screen'))
self.assertEqual((view['owner'], view['unavailable']), ('screen', []))
self.assertEqual(view['keys'], [key for key in SETTING_RULES if key != 'show_clock'])
self.assertEqual({key: view['values'][key] for key in ('brightness', 'night_start', 'rotation', 'home_on_standby')},
{'brightness': 80, 'night_start': 1350, 'rotation': 90, 'home_on_standby': False})
m.ha.states[ENTITY_IDS['night_end']] = {'state': 'unavailable'}
view = m.settings_view(m.screen('text.screen'))
self.assertEqual((view['unavailable'], view['values']['night_end']), (['night_end'], None), 'unknown, not a default')
# Offline: every value is unknown, also one a layout from before the update still carries.
m.layouts['text.screen'] = {**m.layouts['text.screen'], 'settings': validate_settings({'brightness': 10, 'standby_brightness': 10, 'night_brightness': 10})}
for entity in ENTITY_IDS.values():
m.ha.states[entity] = {'state': 'unavailable'}
view = m.settings_view(m.screen('text.screen'))
self.assertEqual((set(view['values'].values()), view['unavailable']), ({None}, view['keys']))
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp, guition=False)
self.assertNotIn('rotation', m.settings_view(m.screen('text.screen'))['keys'])
# A setting whose entity this screen does not have (added in later firmware) is left out, not unknown.
m.ha.registry = [item for item in m.ha.registry if item['entity_id'] != ENTITY_IDS['home_on_standby']]
view = m.settings_view(m.screen('text.screen'))
self.assertNotIn('home_on_standby', view['keys'] + view['unavailable'])
with self.assertRaises(ValueError):
await m.change_settings('text.screen', {'home_on_standby': True})
async def test_the_layout_message_leaves_them_out(self):
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp)
message = m.layout_message('text.screen', {**m.layouts['text.screen'], 'settings': validate_settings({})}, m.screen('text.screen'))
for key in ('settings', 'swipe_pages', 'auto_home', 'auto_home_seconds', 'rotation'):
self.assertNotIn(key, message)
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp, owned=False)
message = m.layout_message('text.screen', m.layouts['text.screen'], m.screen('text.screen'))
self.assertEqual(message['settings']['brightness'], 10, 'older firmware still gets them')
self.assertIn('auto_home', message)
async def test_a_change_goes_to_the_screen_s_entities(self):
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp)
# Brightness first: the screen keeps both dim levels at or below it. Unchanged values send nothing.
await m.change_settings('text.screen', {'standby_brightness': 60, 'brightness': 90, 'night_enabled': True})
self.assertEqual(m.ha.calls, [('number.set_value', {'entity_id': ENTITY_IDS['brightness'], 'value': 90}),
('number.set_value', {'entity_id': ENTITY_IDS['standby_brightness'], 'value': 60})])
m.ha.calls.clear()
# A lower brightness is fine with higher dim levels: the screen pulls them down itself.
await m.change_settings('text.screen', {'brightness': 10})
self.assertEqual(m.ha.calls, [('number.set_value', {'entity_id': ENTITY_IDS['brightness'], 'value': 10})])
m.ha.calls.clear()
await m.change_settings('text.screen', {'night_start': 1320, 'rotation': 270, 'clock_24h': False})
self.assertEqual(m.ha.calls, [('time.set_value', {'entity_id': ENTITY_IDS['night_start'], 'time': '22:00:00'}),
('switch.turn_off', {'entity_id': ENTITY_IDS['clock_24h']}),
('select.select_option', {'entity_id': ENTITY_IDS['rotation'], 'option': '270°'})])
m.ha.calls.clear()
for bad in ({'standby_brightness': 95}, {'show_clock': False}, {'beep': True}, {}, None, {'brightness': '50'}):
with self.assertRaises(ValueError):
await m.change_settings('text.screen', bad)
m.ha.states[ENTITY_IDS['night_end']] = {'state': 'unavailable'}
with self.assertRaisesRegex(ValueError, 'Night ends'):
await m.change_settings('text.screen', {'night_end': 480})
# The others still change; a setting Home Assistant cannot read is checked at its default.
m.ha.states[ENTITY_IDS['standby_brightness']] = {'state': 'unavailable'}
await m.change_settings('text.screen', {'brightness': 15, 'night_start': 1380})
self.assertEqual(m.ha.calls, [('number.set_value', {'entity_id': ENTITY_IDS['brightness'], 'value': 15}),
('time.set_value', {'entity_id': ENTITY_IDS['night_start'], 'time': '23:00:00'})])
m.ha.calls.clear()
m.ha.states['text.screen'] = {'state': 'unavailable'}
with self.assertRaisesRegex(ValueError, 'offline'):
await m.change_settings('text.screen', {'brightness': 50})
self.assertEqual(m.ha.calls, [], 'nothing sent for a refused change')
async def test_a_page_from_before_still_saving_settings_changes_nothing(self):
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp)
m.save('text.screen', {'title': 'Office 1', 'tiles': [{'entity': 'light.a', 'name': ''}],
'settings': validate_settings({'brightness': 5, 'standby_brightness': 5, 'night_brightness': 5})})
self.assertNotIn('settings', m.layouts['text.screen'])
await m.sync_one('text.screen', m.layouts['text.screen'], screen=m.screen('text.screen'), dirty=set())
layout = next(message for message, _ in m.ha.messages if message['op'] == 'layout')
self.assertNotIn('settings', layout)
async def test_a_setting_the_screen_reports_leaves_its_layout_alone(self):
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp)
before = (Path(tmp) / 'screens.json').read_bytes()
m.screen_setting_event({'inbox': 'text.screen', 'key': 'brightness', 'value': '33'})
self.assertEqual((Path(tmp) / 'screens.json').read_bytes(), before)
async def test_the_editor_hears_a_change_made_on_the_screen(self):
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp)
self.assertIn(ENTITY_IDS['night_start'], m.watched_entities())
listener = asyncio.Event()
m.listeners.add(listener)
task = asyncio.create_task(m.run())
m.ha.changed.set()
await asyncio.sleep(0.6)
listener.clear()
m.ha.states[ENTITY_IDS['night_start']] = {'state': '21:00:00'}
m.ha.dirty.add(ENTITY_IDS['night_start'])
m.ha.changed.set()
await asyncio.sleep(0.6)
task.cancel()
self.assertTrue(listener.is_set())
self.assertEqual(m.settings_view(m.screen('text.screen'))['values']['night_start'], 1260)
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class OlderFirmware(unittest.IsolatedAsyncioTestCase):
async def test_a_change_is_kept_with_the_layout_and_only_the_layout_goes_out(self):
with tempfile.TemporaryDirectory() as tmp:
m = Manager(fake_ha(firmware='0.2.48', owned=False), Path(tmp) / 'screens.json')
m.save('text.screen', {'title': 'Office 1', 'tiles': [{'entity': 'light.a', 'name': ''}]})
screen = m.screen('text.screen')
await m.sync_one('text.screen', m.layouts['text.screen'], screen=screen, dirty=set())
m.ha.messages.clear()
view = await m.change_settings('text.screen', {'standby_seconds': 1800})
self.assertEqual((view['owner'], view['values']['standby_seconds']), ('layout', 1800))
self.assertEqual(m.layouts['text.screen']['settings']['standby_seconds'], 1800)
self.assertEqual(m.ha.calls, [])
self.assertTrue(await m.sync_one('text.screen', m.layouts['text.screen'], screen=screen, dirty=set()))
self.assertEqual([message['op'] for message, _ in m.ha.messages], ['layout'], 'the tiles are not sent again')
self.assertEqual(m.ha.messages[0][0]['settings']['standby_seconds'], 1800)
async def test_back_to_page_1_needs_firmware_0_2_44(self):
with tempfile.TemporaryDirectory() as tmp:
m = Manager(fake_ha(firmware='0.2.43', owned=False, guition=False), Path(tmp) / 'screens.json')
keys = m.settings_view(m.screen('text.screen'))['keys']
self.assertNotIn('auto_home', keys)
self.assertNotIn('rotation', keys)
with self.assertRaises(ValueError):
await m.change_settings('text.screen', {'auto_home': False})
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class LayoutSensors(unittest.IsolatedAsyncioTestCase):
async def test_they_are_published_again_after_home_assistant_restarts(self):
with tempfile.TemporaryDirectory() as tmp:
m = Manager(fake_ha(), Path(tmp) / 'screens.json')
m.save('text.screen', {'title': 'Office 1', 'tiles': [{'entity': 'light.a', 'name': ''}]})
await m.publish_layouts()
self.assertIn('sensor.esp_screens_office_1', m.ha.published)
m.ha.published.clear()
await m.publish_layouts()
self.assertEqual(m.ha.published, {}, 'an unchanged layout is not written twice')
# Home Assistant restarts: the connection drops, and the states made over REST are gone.
task = asyncio.create_task(m.run())
m.ha.online = False
m.ha.changed.set()
await asyncio.sleep(0.4)
m.ha.online = True
m.ha.changed.set()
await asyncio.sleep(0.6)
task.cancel()
self.assertIn('sensor.esp_screens_office_1', m.ha.published)
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class WeatherForecasts(unittest.IsolatedAsyncioTestCase):
async def test_only_the_forecasts_the_entity_offers_are_asked_for(self):
for features, expected in ((1, ['daily']), (2, ['hourly']), (3, ['daily', 'hourly']), (None, ['daily', 'hourly'])):
with tempfile.TemporaryDirectory() as tmp:
ha = fake_ha()
asked = []
async def forecast(entity, kind='daily'):
asked.append(kind)
return []
ha.forecast = forecast
ha.states['weather.home'] = {'state': 'sunny', 'attributes': {} if features is None else {'supported_features': features}}
m = Manager(ha, Path(tmp) / 'screens.json')
tile = {'entity': 'weather.home', 'name': ''}
await m.tile_message(0, tile)
await m.tile_message(0, tile)
self.assertEqual(asked, expected, f'features {features}')
self.assertFalse(m.forecast_due('weather.home'), 'what it lacks still counts as fetched')
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class Answers(unittest.IsolatedAsyncioTestCase):
async def test_home_assistant_asks_for_the_answer_and_returns_it(self):
ha = HomeAssistant(None, 'http://ha/api', 'token')
seen = []
async def request(kind, **data):
seen.append(data)
return {'context': {}, 'response': {'status': 'Synced', 'rev': 'abc'}}
ha.request = request
self.assertEqual(await ha.send('text.screen', {'v': 1, 'op': 'ping'}, 'esphome.x_screen_message', respond=True),
{'status': 'Synced', 'rev': 'abc'})
self.assertTrue(seen[-1]['return_response'])
self.assertIsNone(await ha.send('text.screen', {'v': 1, 'op': 'ping'}, 'esphome.x_screen_message'))
self.assertNotIn('return_response', seen[-1])
async def slow(kind, **data):
await asyncio.sleep(1)
ha.request = slow
with patch.object(server, 'ANSWER_TIMEOUT_SECONDS', 0.05):
with self.assertRaises(TimeoutError):
await ha.send('text.screen', {'v': 1, 'op': 'ping'}, 'esphome.x_screen_message', respond=True)
async def test_which_actions_answer_comes_from_home_assistant(self):
ha = HomeAssistant(None, 'http://ha/api', 'token')
async def request(kind, **data):
return {'esphome': {'office_1_screen_message': {'fields': {}, 'response': {'optional': True}},
'office_1_show_alert': {'fields': {}}}, 'light': {'turn_on': {'response': {'optional': True}}}}
ha.request = request
await ha.fetch_services()
self.assertEqual(ha.responses, {'esphome.office_1_screen_message'})
async def test_a_refusal_carries_home_assistant_s_reason(self):
ha = HomeAssistant(None, 'http://ha/api', 'token')
future = asyncio.get_running_loop().create_future()
ha.pending[7] = future
class Message:
type = WSMsgType.TEXT
def json(self):
return {'id': 7, 'type': 'result', 'success': False, 'error': {'code': 'service_validation_error', 'message': 'does not support responses'}}
class Socket:
def __aiter__(self):
async def messages():
yield Message()
return messages()
ha.ws = Socket()
with self.assertRaises(ConnectionError):
await ha.read()
with self.assertRaises(Refused) as refused:
await future
self.assertEqual((str(refused.exception), refused.exception.detail), ('Home Assistant refused the command.', 'does not support responses'))
def manager(self, tmp, **kw):
m = Manager(fake_ha(**kw), Path(tmp) / 'screens.json')
m.save('text.screen', {'title': 'Office 1', 'tiles': [{'entity': 'light.a', 'name': ''}]})
return m
async def test_a_new_layout_is_confirmed_by_an_answered_ping(self):
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp)
screen = m.screen('text.screen')
await m.sync_one('text.screen', m.layouts['text.screen'], screen=screen, dirty=set())
self.assertEqual([(message['op'], respond) for message, respond in m.ha.messages],
[('layout', False), ('header', False), ('state', False), ('ping', True)])
ping = m.ha.messages[-1][0]
self.assertEqual(ping['rev'], m.sent['text.screen']['rev'])
m.ha.messages.clear()
# A tile that changes is not followed by a ping.
m.ha.states['light.a']['state'] = 'off'
await m.sync_one('text.screen', m.layouts['text.screen'], screen=screen, dirty={'light.a'})
self.assertEqual([message['op'] for message, _ in m.ha.messages], ['state'])
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp, firmware='0.2.48', owned=False)
await m.sync_one('text.screen', m.layouts['text.screen'], screen=m.screen('text.screen'), dirty=set())
self.assertNotIn('ping', [message['op'] for message, _ in m.ha.messages], 'older firmware cannot answer')
async def test_an_answer_that_something_is_missing_sends_everything_again(self):
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp)
screen = m.screen('text.screen')
# Right after a full send the screen lacks a tile: everything again, after a short wait.
m.ha.answers.append({'status': 'Loading tiles', 'rev': 'x'})
await m.sync_one('text.screen', m.layouts['text.screen'], screen=screen, dirty=set())
self.assertIn('text.screen', m.sent)
self.assertAlmostEqual(m.retry_at['text.screen'] - m.last['text.screen'], ANSWER_RETRY_SECONDS, delta=0.1)
# The wait is over: the loop drops what it thought the screen had, so it sends everything.
m.retry_at['text.screen'] = time.monotonic() - 1
m.ha.messages.clear()
task = asyncio.create_task(m.run())
m.ha.changed.set()
await asyncio.sleep(0.6)
task.cancel()
self.assertEqual([message['op'] for message, _ in m.ha.messages][:3], ['layout', 'header', 'state'])
self.assertNotIn('text.screen', m.retry_at)
self.assertNotIn('text.screen', m.retries, 'the resend was answered with Synced: the count starts again')
# A keepalive long after the last full send that finds the layout gone: everything again at once.
m.last['text.screen'] = time.monotonic() - 3600
m.ha.answers.append({'status': 'Resend needed', 'rev': 'x'})
await m.ping('text.screen', screen)
self.assertNotIn('text.screen', m.sent)
self.assertEqual(m.retries['text.screen'], 1)
# The resend fails too: the next try waits twice as long.
m.ha.answers.append({'status': 'Loading tiles', 'rev': 'x'})
await m.sync_one('text.screen', m.layouts['text.screen'], screen=screen, dirty=set())
self.assertEqual(m.retries['text.screen'], 2)
self.assertAlmostEqual(m.retry_at['text.screen'] - m.last['text.screen'], 2 * ANSWER_RETRY_SECONDS, delta=0.1)
async def test_home_assistant_that_will_not_answer_gets_plain_pings(self):
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp)
screen = m.screen('text.screen')
await m.sync_one('text.screen', m.layouts['text.screen'], screen=screen, dirty=set())
original = m.ha.send
async def refuse(inbox, message, action=None, respond=False):
if respond:
raise Refused('An action which does not return responses can\'t be called with return_response=True')
return await original(inbox, message, action)
m.ha.send = refuse
m.ha.messages.clear()
with self.assertLogs('screen_manager', 'WARNING'):
await m.ping('text.screen', screen)
self.assertEqual([(message['op'], respond) for message, respond in m.ha.messages], [('ping', False)])
self.assertFalse(m.answers('text.screen', screen))
async def broken(inbox, message, action=None, respond=False):
raise Refused('ESPHome device is not connected')
m.no_answers.clear()
m.ha.send = broken
with self.assertRaises(Refused):
await m.ping('text.screen', screen)
self.assertTrue(m.answers('text.screen', screen), 'another refusal is an ordinary failure')
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class SettingsRoute(unittest.IsolatedAsyncioTestCase):
async def test_the_editor_changes_one_setting_at_a_time(self):
with tempfile.TemporaryDirectory() as tmp:
m = Manager(fake_ha(), Path(tmp) / 'screens.json')
async with TestClient(TestServer(create_app(m, True))) as client:
inventory = await (await client.get('/api/inventory?light=1')).json()
self.assertEqual(inventory['screens'][0]['settings']['owner'], 'screen')
url = '/api/screens/text.screen/settings'
self.assertEqual((await client.put(url, json={'settings': {'brightness': 55}})).status, 403, 'CSRF')
headers = {'X-Screen-CSRF': inventory['csrf']}
response = await client.put(url, headers=headers, json={'settings': {'brightness': 55}})
self.assertEqual(response.status, 200)
self.assertEqual((await response.json())['owner'], 'screen')
self.assertEqual(m.ha.calls, [('number.set_value', {'entity_id': ENTITY_IDS['brightness'], 'value': 55})])
response = await client.put(url, headers=headers, json={'settings': {'standby_seconds': 5}})
self.assertEqual(response.status, 400)
self.assertIn('standby_seconds', (await response.json())['error'])
async def refuse(action, data):
raise Refused('Entity is not available')
m.ha.call = refuse
response = await client.put(url, headers=headers, json={'settings': {'brightness': 60}})
self.assertEqual(response.status, 400)
self.assertIn('Entity is not available', (await response.json())['error'])
class Editor(unittest.TestCase):
def setUp(self):
self.html = (STATIC / 'index.html').read_text()
self.script = (STATIC / 'app.js').read_text()
self.css = (STATIC / 'style.css').read_text()
def test_the_panel_replaces_the_form(self):
self.assertIn('<section class="screen-settings" id="general-settings"', self.html)
for element in ('settings-groups', 'settings-status'):
self.assertEqual(self.html.count(f'id="{element}"'), 1)
self.assertNotIn('settings-fields', self.html + self.script)
self.assertNotIn('settingDefinitions', self.script)
for rule in ('.settings-card', '.setting-row', '.switch[aria-checked="true"]', '.switch.unknown::after', '.step', '.setting-value'):
self.assertIn(rule, self.css)
# A value Home Assistant does not have (offline, entity off) shows as unknown, not as a default.
self.assertIn('if (value === null || value === undefined) return "—";', self.script)
self.assertIn('refs.toggle.classList.toggle("unknown", values[key] === null || values[key] === undefined);', self.script)
def test_the_rows_are_the_settings_of_the_screen_page(self):
keys = re.findall(r'\{ key: "(\w+)", label: "[^"]+", kind: "(\w+)"', self.script)
self.assertEqual([key for key, _ in keys], [key for key in SETTING_RULES if key != 'show_clock'][:0] or [key for key, _ in keys])
self.assertEqual({key for key, _ in keys}, set(SETTING_RULES) - {'show_clock'})
# Labels and steps as on the screen: the same rows, the same -/+ steps, the same duration ladder.
page_labels = set(re.findall(r'(?:number|toggle|duration|moment|choice)\("([^"]+)"', SCREEN_PAGE))
groups = self.script[self.script.index('const SETTING_GROUPS = ['):self.script.index('const SETTING_ROWS =')]
script_labels = set(re.findall(r'label: "([^"]+)"', groups))
self.assertEqual(page_labels, script_labels)
for key, step in (('brightness', 5), ('standby_brightness', 5), ('night_brightness', 5)):
self.assertRegex(self.script, rf'key: "{key}", [^}}]*step: {step}')
ladder = re.search(r'if \(seconds < 300\) return 30;\s+if \(seconds < 900\) return 60;\s+if \(seconds < 3600\) return 300;\s+'
r'if \(seconds < 7200\) return 900;\s+return 1800;', SCREEN_PAGE)
self.assertTrue(ladder, 'the ladder in settings_screen.h changed: change ladderStep in app.js with it')
self.assertIn('seconds < 300 ? 30 : seconds < 900 ? 60 : seconds < 3600 ? 300 : seconds < 7200 ? 900 : 1800', self.script)
def test_settings_have_their_own_call_and_save_leaves_them_out(self):
self.assertIn('api(`screens/${encodeURIComponent(screen)}/settings`', self.script)
self.assertIn('const { settings: _settings, ...tiles } = layout;', self.script)
self.assertIn('body: JSON.stringify(tiles)', self.script)
self.assertIn('setSetting("clock_24h", v === "24", 150);', self.script, 'the top bar clock format is the same setting')
class Firmware(unittest.TestCase):
def test_the_message_action_answers_a_caller_that_asks(self):
for path in [*PROFILES.values(), *PACKAGES.values()]:
text = path.read_text()
action = text[text.index('- action: screen_message'):]
# Up to the next key of the api block or the next top-level block (the packages carry no keys).
action = action[:re.search(r'\n(?: [a-z_]+:|[a-z_]+:)', action).start()]
self.assertIn('supports_response: optional', action, path.name)
self.assertIn("lambda: 'return call_id != 0;'", action, f'{path.name}: no answer, no warning, for a caller without a call id')
self.assertIn('api.respond:', action, path.name)
self.assertIn('root["status"] = id(dashboard_inbox).state;', action, path.name)
self.assertIn('root["rev"] = runtime_tiles::layout_rev;', action, path.name)
def test_the_settings_tile_opens_without_home_assistant(self):
event = RUNTIME[RUNTIME.index('inline void event(lv_event_t *event) {'):]
event = event[:event.index('\n}\n')]
self.assertLess(event.index('builtin()'), event.index('if (!fresh()) return;'))
self.assertIn('settings_screen::open();', event[:event.index('if (!fresh()) return;')])
self.assertIn('card.tap == "none"', event)
def test_home_assistant_sees_every_setting_change(self):
for board, path in PROFILES.items():
script = path.read_text()
script = script[script.index('- id: apply_screen_settings'):]
script = script[:script.index('\n - id: ', 10)]
for entity in ('setting_brightness', 'setting_standby_brightness', 'setting_night_brightness', 'setting_standby_seconds',
'setting_auto_home_seconds'):
self.assertIn(f'id({entity}).publish_state(', script, f'{path.name}: {entity}')
self.assertIn('std::make_pair(id(setting_night_start), settings.night_start)', script, path.name)
self.assertIn('settings_screen::refresh();', script, f'{path.name}: an open settings page follows Home Assistant')
self.assertEqual('id(setting_rotation)->update();' in script, board == 'guition', path.name)
def test_the_page_and_every_entity_change_settings_the_same_way(self):
self.assertRegex(SCREEN_PAGE, r'enum class SetResult : uint8_t \{ unknown, same, changed \};')
for path in PROFILES.values():
text = path.read_text()
self.assertNotIn('settings_preference.save(&s)', text, f'{path.name}: saving goes through settings_screen::set()')
self.assertNotIn('update_interval: never\n lambda: return settings_screen::swipe_pages', text, 'a template switch has no update_interval')
if __name__ == '__main__':
unittest.main()
+7 -3
View File
@@ -28,9 +28,13 @@ assert FROZEN == set(SETTING_RULES) - set(SETTINGS_BESIDE_BLOCK)
class SettingsPage(unittest.TestCase):
def test_every_key_the_screen_reports_is_a_setting_the_app_knows(self):
keys = set(re.findall(r'changed\("(\w+)"', SCREEN))
self.assertTrue(keys, 'no rows found in settings_screen.h')
self.assertLessEqual(keys, set(SETTING_RULES), 'the screen writes a key ESP Screens would drop')
# Rows write through set(key, value) (firmware 0.2.49+), which reports the key it was given.
rows = set(re.findall(r'\bset\("(\w+)"', SCREEN))
self.assertTrue(rows, 'no rows found in settings_screen.h')
self.assertLessEqual(rows, set(SETTING_RULES), 'the screen writes a key ESP Screens would drop')
handled = set(re.findall(r'key == "(\w+)"', SCREEN))
self.assertLessEqual(rows, handled, 'a row writes a key set() does not handle')
self.assertEqual(handled, set(SETTING_RULES) - {'show_clock'}, 'set() handles every setting the app knows')
def test_the_frozen_settings_block_keeps_exactly_eleven_keys(self):
# Firmware before 0.2.44 refuses a `settings` object of any other size; that is why the newer
+43
View File
@@ -122,4 +122,47 @@ int main() {
assert(stored == 1 && applied == 1 && last_key == "standby_enabled" && last_value == 0);
row_named(screen, "After").write(600);
assert(stored == 2 && last_key == "auto_home_seconds" && last_value == 600 && auto_home_seconds == 600);
// ---- set(): the one path for the rows and for Home Assistant's entities (firmware 0.2.49+) ----
// A value the screen already has is not stored, applied or reported: an automation may set it on every change.
SetResult result = set("standby_enabled", 0);
assert(result == SetResult::same && stored == 2 && applied == 2);
result = set("auto_home_seconds", 600);
assert(result == SetResult::same && stored == 2);
// Every key ESP Screens knows lands on the screen, clamped the way the page steps it.
screen_settings::current = screen_settings::Settings{};
rotation_supported = true;
const struct { const char *key; int32_t value, expected; } cases[] = {
{"standby_enabled", 5, 1}, {"standby_seconds", 10, 60}, {"standby_seconds", 999999, 86400},
{"brightness", 1, 5}, {"brightness", 250, 100}, {"standby_brightness", 101, 100}, {"night_enabled", 0, 0},
{"night_start", -5, 0}, {"night_end", 2000, 1439}, {"night_brightness", 7, 7}, {"clock_24h", 0, 0},
{"home_on_standby", 1, 1}, {"swipe_pages", 2, 1}, {"rotation", 100, 90}, {"rotation", 400, 270},
{"auto_home", 0, 0}, {"auto_home_seconds", 5, 30}, {"auto_home_seconds", 99999, 3600},
};
for (const auto &c : cases) {
result = set(c.key, c.value);
assert(result != SetResult::unknown);
if (result == SetResult::changed) assert(last_key == c.key && last_value == c.expected);
}
assert(screen_settings::current.standby_seconds == 86400 && screen_settings::current.brightness == 100);
assert(screen_settings::current.night_start == 0 && screen_settings::current.night_end == 1439);
assert(swipe_pages == 1 && rotation == 270 && auto_home == 0 && auto_home_seconds == 3600);
assert(screen_settings::current.valid());
// A lower brightness pulls both dim levels down, and reports the brightness itself.
set("standby_brightness", 60);
set("night_brightness", 50);
result = set("brightness", 30);
assert(result == SetResult::changed && last_key == "brightness" && last_value == 30);
assert(screen_settings::current.standby_brightness == 30 && screen_settings::current.night_brightness == 30);
result = set("standby_brightness", 90);
assert(result == SetResult::same && screen_settings::current.standby_brightness == 30);
// What the screen cannot do, or does not know, is refused without a store.
const int before = stored;
rotation_supported = false;
result = set("rotation", 90);
assert(result == SetResult::unknown && rotation == 270);
result = set("show_clock", 0);
assert(result == SetResult::unknown);
result = set("beep", 1);
assert(result == SetResult::unknown && stored == before);
}