From b83164d61a10d491421f52f30d714265cc989a4d Mon Sep 17 00:00:00 2001 From: Max Gramser Date: Wed, 16 Sep 2026 10:05:53 +0200 Subject: [PATCH] Change a screen's settings on the screen itself (0.2.52) Holding the top bar for about a second and a half opens a settings page on the screen, with a blue line filling along the top edge while you hold. A built-in screen.settings tile and the esphome._open_settings action open it too. The page is one constexpr table of rows in settings_screen.h: Brightness, Night, Screen (clock, back to page 1, swiping, rotation) and This screen (name, address, firmware, Home Assistant, Restart). Toggles flip on a tap, numbers and times step with -/+ (a held time walks whole hours), a choice cycles in a chip, and a row that depends on a switch above it is greyed out. The LVGL objects exist only while the page is open: +608 bytes of RAM and +10.7 KB of flash on the CYD. A change is saved, applied and reported through the existing screen_setting event, so ESP Screens shows the same value within a second. New setting auto_home (default on, 2 minutes): an open card, the settings page or a later page goes back to page 1 after that long without a touch, not only on standby. It travels beside the frozen eleven-key settings block with its own preference, so older firmware and older apps keep working. docs/SETTINGS.md is the recipe for adding a setting; the Claude skill explains the page. Firmware 0.2.44. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 4 + README.md | 29 + components/smart_display/runtime_model.h | 5 +- components/smart_display/runtime_tiles.h | 55 +- components/smart_display/settings_screen.h | 733 +++++++++++++++++++++ components/smart_display/tile_icon_names.h | 3 + docs/RELEASING.md | 38 ++ docs/SETTINGS.md | 98 +++ docs/TEST_RESULTS_0252.md | 70 ++ guition-4848s040.yaml | 77 ++- home-like-2432s028.yaml | 77 ++- packages/cyd.yaml | 77 ++- packages/guition.yaml | 77 ++- screen_manager/CHANGELOG.md | 11 + screen_manager/DOCS.md | 4 + screen_manager/app/claude_skill.py | 20 +- screen_manager/app/core.py | 17 +- screen_manager/app/server.py | 8 +- screen_manager/app/static/app.js | 3 + screen_manager/app/static/tile-icons.woff | Bin 15596 -> 15792 bytes screen_manager/app/tile_icons.py | 7 + screen_manager/config.yaml | 2 +- tests/test_claude_skill.py | 2 +- tests/test_header_bar.py | 2 +- tests/test_portal.py | 6 +- tests/test_settings_page.py | 112 ++++ tests/test_settings_screen.cpp | 125 ++++ tests/test_tile_icons.py | 10 +- 28 files changed, 1645 insertions(+), 27 deletions(-) create mode 100644 components/smart_display/settings_screen.h create mode 100644 docs/SETTINGS.md create mode 100644 docs/TEST_RESULTS_0252.md create mode 100644 tests/test_settings_page.py create mode 100644 tests/test_settings_screen.cpp diff --git a/AGENTS.md b/AGENTS.md index d63a6d8..2fef891 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,10 @@ 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. - 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. diff --git a/README.md b/README.md index f1b938e..1f49deb 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,11 @@ The firmware and built-in CLI are tested with **ESPHome 2026.6.2**. 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. +- **Settings on the screen itself** (firmware 0.2.44+): hold the top bar for about a + second and a half and the screen opens its own settings page — brightness, night, + the clock, swiping, rotation, and what this screen is (name, IP address, firmware, + whether Home Assistant is connected, and Restart). Changes show up in ESP Screens + within a second. See [Settings on the screen](#settings-on-the-screen). - **Guition rotation:** 0°, 90°, 180°, or 270°, directly from the management page. Native LVGL rotation turns the display and touch together. The CYD keeps its fixed orientation and its own calibration. @@ -313,6 +318,30 @@ If not everything fits next to the name, the name gets an ellipsis and the scree leading items; the editor marks those with dashes. Until updated, older firmware shows only the name and the time (if that's in the bar). +## Settings on the screen + +Everything you would want to change while standing in front of the panel is on the screen +itself (firmware 0.2.44+). Tiles, the top bar and the pages stay in ESP Screens, where you +have a mouse. + +**Opening it:** hold the top bar — the strip with the screen's name and the clock — until the +blue line along the top edge is full, about a second and a half. Letting go early cancels. +Rather have a button? Put the built-in **Settings** card on a page like any other tile. From +Home Assistant, `esphome._open_settings` opens it too (`page` 0 menu, 1 Brightness, +2 Night, 3 Screen, 4 This screen, -1 closes it). + +| Group | What is on it | +|---|---| +| Brightness | Brightness, Auto standby, Standby after, Standby brightness | +| Night | Night mode, Starts, Ends, Night brightness | +| Screen | 12/24-hour clock, back to page 1 by itself and after how long, also on standby, swiping between pages, rotation (boards that turn) | +| This screen | Name, IP address, firmware version, Home Assistant connected, Restart | + +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. + ## Updates and keeping your settings | Change | Action | diff --git a/components/smart_display/runtime_model.h b/components/smart_display/runtime_model.h index 738dff8..1f2fc2e 100644 --- a/components/smart_display/runtime_model.h +++ b/components/smart_display/runtime_model.h @@ -22,7 +22,7 @@ inline bool valid_entity(const std::string &entity) { !(entity[i] >= '0' && entity[i] <= '9') && entity[i] != '_') return false; std::string domain = entity.substr(0, dot); // screen.* are built-in cards without a Home Assistant entity behind them. - if (domain == "screen") return entity == "screen.clock"; + if (domain == "screen") return entity == "screen.clock" || entity == "screen.settings"; for (const auto *allowed : {"light", "switch", "input_boolean", "scene", "script", "climate", "vacuum", "fan", "cover", "sensor", "binary_sensor", "input_select", "select", "number", "input_number", "weather", "media_player", "button", "input_button", "sun", "timer", "person"}) if (domain == allowed) return true; return false; @@ -146,6 +146,9 @@ struct Tile { void observe(uint32_t next) { revision=next; if (pending && revision!=pending_revision) confirmed=true; } std::string domain() const { return entity.substr(0, entity.find('.')); } bool builtin() const { return domain() == "screen"; } + // Two built-in cards, and only one of them is a clock that has to be redrawn every minute. + bool is_clock() const { return entity == "screen.clock"; } + bool is_settings() const { return entity == "screen.settings"; } bool available() const { return builtin() || (received && state != "unknown" && state != "unavailable" && !state.empty()); } bool active() const { return state == "on" || state == "cleaning" || state == "active" || (domain() == "person" && state == "home") || diff --git a/components/smart_display/runtime_tiles.h b/components/smart_display/runtime_tiles.h index d478c13..0ef7277 100644 --- a/components/smart_display/runtime_tiles.h +++ b/components/smart_display/runtime_tiles.h @@ -4,6 +4,7 @@ #include "tile_palette.h" #include "tile_icon.h" #include "screen_settings.h" +#include "settings_screen.h" #include "esphome/core/preferences.h" #include "cyd_ui.h" #include "light_controls.h" @@ -20,11 +21,16 @@ namespace runtime_tiles { inline bool enabled = false; -inline bool swipe_pages = false; -inline uint32_t rotation = 0; -inline bool rotation_supported = false; +// Swiping, rotation and going back to page 1 live in settings_screen, next to the other settings the +// screen can change itself; these names stay as the way the rest of the firmware reaches them. +using settings_screen::swipe_pages; +using settings_screen::rotation; +using settings_screen::rotation_supported; +using settings_screen::auto_home; +using settings_screen::auto_home_seconds; inline esphome::ESPPreferenceObject rotation_preference; inline esphome::ESPPreferenceObject swipe_preference; +inline esphome::ESPPreferenceObject home_preference; inline Model model; inline std::string inbox; inline const lv_font_t *watch_font = nullptr; @@ -69,19 +75,36 @@ inline uint32_t keepalive_seconds = 120; inline std::string layout_rev; inline std::function layout_changed, refresh, dismiss, settings_changed; inline esphome::ESPPreferenceObject settings_preference; +// The two extra values of 0.2.44+ in one record: going back to page 1 by itself, and after how long. +struct HomeTimeout { uint32_t enabled = 1, seconds = 120; }; inline void load_settings() { settings_preference = esphome::global_preferences->make_preference(0x53435231); swipe_preference = esphome::global_preferences->make_preference(0x53575031); + home_preference = esphome::global_preferences->make_preference(0x484F4D31); uint32_t swipe_saved=0; if(swipe_preference.load(&swipe_saved))swipe_pages=swipe_saved==1; + HomeTimeout home; + if(home_preference.load(&home) && home.seconds>=30 && home.seconds<=3600){ + auto_home=home.enabled?1:0;auto_home_seconds=(int32_t)home.seconds; + } if(rotation_supported){ rotation_preference=esphome::global_preferences->make_preference(0x524F5431); uint32_t saved=0; - if(rotation_preference.load(&saved) && saved<=270 && saved%90==0)rotation=saved; + if(rotation_preference.load(&saved) && saved<=270 && saved%90==0)rotation=(int32_t)saved; } screen_settings::Settings saved; if (settings_preference.load(&saved) && saved.valid()) screen_settings::current = saved; } +// Everything the screen remembers, written in one go. ESPHome batches the flash writes, so a row of +// taps on -/+ costs one write, and a value that did not change costs nothing. +inline void persist_settings() { + if (screen_settings::current.valid()) settings_preference.save(&screen_settings::current); + uint32_t swipe = swipe_pages ? 1 : 0; + swipe_preference.save(&swipe); + HomeTimeout home{(uint32_t) (auto_home ? 1 : 0), (uint32_t) std::clamp(auto_home_seconds, 30, 3600)}; + home_preference.save(&home); + if (rotation_supported) { uint32_t turned = (uint32_t) rotation; rotation_preference.save(&turned); } +} inline bool parse_settings(JsonObject obj, screen_settings::Settings &s) { // Require the complete known schema; validate before touching any runtime state. if (obj.size() != 11) return false; @@ -181,6 +204,9 @@ inline std::string receive(const std::string &payload) { entities.push_back(entity.as()); } if(!root["swipe_pages"].isNull() && !root["swipe_pages"].is())return false; + if(!root["auto_home"].isNull() && !root["auto_home"].is())return false; + if(!root["auto_home_seconds"].isNull() && (!root["auto_home_seconds"].is() || + root["auto_home_seconds"].as()<30 || root["auto_home_seconds"].as()>3600))return false; if(!root["rotation"].isNull() && (!root["rotation"].is() || root["rotation"].as()>270 || root["rotation"].as()%90!=0))return false; if(!root["keepalive"].isNull() && (!root["keepalive"].is() || @@ -202,6 +228,12 @@ inline std::string receive(const std::string &payload) { if(root["swipe_pages"].is() && swipe_pages!=root["swipe_pages"].as()){ swipe_pages=root["swipe_pages"].as();uint32_t saved=swipe_pages?1:0;swipe_preference.save(&saved); } + if((root["auto_home"].is() && auto_home!=root["auto_home"].as()) || + (root["auto_home_seconds"].is() && auto_home_seconds!=(int32_t)root["auto_home_seconds"].as())){ + if(root["auto_home"].is())auto_home=root["auto_home"].as(); + if(root["auto_home_seconds"].is())auto_home_seconds=(int32_t)root["auto_home_seconds"].as(); + HomeTimeout home{(uint32_t)(auto_home?1:0),(uint32_t)auto_home_seconds};home_preference.save(&home); + } bool rotation_changed=false; if(rotation_supported && root["rotation"].is() && rotation!=root["rotation"].as()){ rotation=root["rotation"].as();rotation_preference.save(&rotation);rotation_changed=true; @@ -961,7 +993,7 @@ inline const char *icon_for(const Tile &tile) { if (d == "sun") return tile.state == "above_horizon" ? "\U000F059B" : "\U000F059C"; if (d == "timer") return "\U000F051B"; if (d == "person") return "\U000F0004"; - if (d == "screen") return "\U000F0150"; + if (d == "screen") return tile.is_settings() ? "\U000F0493" : "\U000F0150"; return "\U000F0425"; } // HA duration strings ("0:05:00") to seconds; 0 when unusable. @@ -1023,7 +1055,8 @@ 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;} - if(tile.builtin())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; } @@ -1653,7 +1686,9 @@ inline void render_slot(size_t slot) { bool watch=t.display=="watch"; std::string unit=watch?t.unit:""; std::string value = t.state; - if (!fresh() || !t.available()) value = "Unavailable"; + // Nothing in Home Assistant stands behind the settings card, so it says the same with the link down. + if (t.is_settings()) value = "Tap to open"; + else if (!fresh() || !t.available()) value = "Unavailable"; else if (d == "light" && t.state == "on" && std::isfinite(t.brightness)) value = std::to_string(static_cast(std::lround(std::clamp(t.brightness, 0.0f, 255.0f) * 100 / 255))) + " %"; else if (d == "climate" && std::isfinite(t.target)) { char b[32]; snprintf(b, sizeof(b), "%.1f°", t.target); value = b; } else if (d == "person") value = t.state=="home"?"Home":t.state=="not_home"?"Away":t.state; @@ -1676,7 +1711,7 @@ inline void render_slot(size_t slot) { bool large_tile=lv_obj_get_height(w.tile)>80; lap(swipe_profile::TEXT); // Cards that replace the name/status layout entirely. - bool clock=t.builtin(), forecast=d=="weather" && t.display=="forecast" && w.wide && t.extra().forecast.size()>0 && fresh() && t.available(); + bool clock=t.is_clock(), forecast=d=="weather" && t.display=="forecast" && w.wide && t.extra().forecast.size()>0 && fresh() && t.available(); bool sunpath=d=="sun" && t.display=="sunpath" && w.wide && !t.extra().sunrise.empty() && !t.extra().sunset.empty() && fresh() && t.available(); bool graph=d=="sensor" && t.display=="graph" && t.has_history && !clock; bool custom=clock||forecast||sunpath; @@ -2306,9 +2341,9 @@ inline void tick() { for(size_t slot=0;slot=model.count || lv_obj_has_flag(w.tile,LV_OBJ_FLAG_HIDDEN))continue; const auto &t=model.tiles[w.index]; - if((t.builtin() && new_minute) || (t.domain()=="timer" && t.state=="active") || (t.domain()=="sun" && second%60==0))card(w.index); + if((t.is_clock() && new_minute) || (t.domain()=="timer" && t.state=="active") || (t.domain()=="sun" && second%60==0))card(w.index); // The second hand moves on its own: only its line is redrawn, and it hides during standby. - else if(w.parts[18] && w.points && t.builtin() && t.display=="analog")second_hand(w,now); + else if(w.parts[18] && w.points && t.is_clock() && t.display=="analog")second_hand(w,now); } } if(redraw && refresh)refresh(); diff --git a/components/smart_display/settings_screen.h b/components/smart_display/settings_screen.h new file mode 100644 index 0000000..0a67701 --- /dev/null +++ b/components/smart_display/settings_screen.h @@ -0,0 +1,733 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include "screen_settings.h" + +// The settings page on the screen itself (firmware 0.2.44+). It changes the same values ESP Screen +// Manager writes, for the moments you stand in front of the panel instead of behind a browser. +// +// Everything the page shows is one table: `pages[]` of `Row`s. A row knows how to read and write its +// value and what kind of control it needs, so a new setting is one line in that table -- no drawing +// code, no layout. docs/SETTINGS.md walks through adding one end to end. +// +// The model (rows, stepping, the texts) is free of LVGL so tests/test_settings_screen.cpp covers it +// on a PC; the drawing below it is behind SETTINGS_SCREEN_TEST. +namespace settings_screen { + +// Values that are not part of the persisted `screen_settings::Settings` block, because that format is +// frozen at version 1: each of these keeps its own preference, so a new option never rewrites the +// old ones. The manager sends them as their own keys in the layout message. +inline int32_t swipe_pages = 0, rotation = 0, auto_home = 1, auto_home_seconds = 120; +inline bool rotation_supported = false; + +// ------------------------------------------------------------------ the table +enum class Kind : uint8_t { page, toggle, number, duration, moment, choice, info, action }; +using Read = int32_t (*)(); +using Write = void (*)(int32_t); +using Text = std::string (*)(); +using Shown = bool (*)(); +using Run = void (*)(); + +struct Row { + Kind kind = Kind::info; + const char *label = ""; + const char *icon = ""; // page rows and actions; a Material Design Icons glyph + Read read = nullptr; // toggle, number, duration, moment, choice + Write write = nullptr; + int32_t low = 0, high = 0, step = 0; // number: fixed step; duration: seconds, step from the ladder + const char *unit = ""; // straight after a number ("%") + const char *const *options = nullptr; // choice + uint8_t option_count = 0; + Text text = nullptr; // info + Run run = nullptr; // action + Shown shown = nullptr; // absent rows: rotation on a board that cannot turn + Shown enabled = nullptr; // greyed out while the switch it depends on is off + uint8_t opens = 0; // page rows: the page they open +}; + +constexpr Row page_row(const char *label, const char *icon, uint8_t opens) { + Row r{}; r.kind = Kind::page; r.label = label; r.icon = icon; r.opens = opens; return r; +} +constexpr Row toggle(const char *label, Read read, Write write, Shown shown = nullptr) { + Row r{}; r.kind = Kind::toggle; r.label = label; r.read = read; r.write = write; r.shown = shown; return r; +} +constexpr Row number(const char *label, Read read, Write write, int32_t low, int32_t high, int32_t step, + const char *unit = "", Shown enabled = nullptr) { + Row r{}; r.kind = Kind::number; r.label = label; r.read = read; r.write = write; + r.low = low; r.high = high; r.step = step; r.unit = unit; r.enabled = enabled; return r; +} +constexpr Row duration(const char *label, Read read, Write write, int32_t low, int32_t high, + Shown enabled = nullptr) { + Row r{}; r.kind = Kind::duration; r.label = label; r.read = read; r.write = write; + r.low = low; r.high = high; r.enabled = enabled; return r; +} +constexpr Row moment(const char *label, Read read, Write write, Shown enabled = nullptr) { + Row r{}; r.kind = Kind::moment; r.label = label; r.read = read; r.write = write; + r.low = 0; r.high = 1439; r.step = 15; r.enabled = enabled; return r; +} +constexpr Row choice(const char *label, Read read, Write write, const char *const *options, uint8_t count, + Shown shown = nullptr) { + Row r{}; r.kind = Kind::choice; r.label = label; r.read = read; r.write = write; + r.options = options; r.option_count = count; r.shown = shown; return r; +} +constexpr Row info(const char *label, Text text) { + Row r{}; r.kind = Kind::info; r.label = label; r.text = text; return r; +} +constexpr Row action(const char *label, const char *icon, Run run) { + Row r{}; r.kind = Kind::action; r.label = label; r.icon = icon; r.run = run; return r; +} + +// ------------------------------------------------------------------ values +// One place where a change is kept, put into effect and sent to the manager, so the add-on's page +// shows what the screen shows. The same path the Home Assistant number entities already take. +inline void (*store)() = nullptr; // save the preferences +inline void (*apply)() = nullptr; // backlight, rotation, night +inline void (*report)(const char *, int32_t) = nullptr; // esphome.screen_setting event +inline void changed(const char *key, int32_t value) { + if (store) store(); + if (apply) apply(); + if (report) report(key, value); +} + +// 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) { + if (seconds < 300) return 30; + if (seconds < 900) return 60; + if (seconds < 3600) return 300; + if (seconds < 7200) return 900; + return 1800; +} +// The value one press of - or + gives. Moments wrap around midnight; everything else stops at its end. +// `held` is a finger that has been on the key for a while: a time then walks whole hours instead of +// quarters, so setting night from 22:00 to 07:00 is one hold and not thirty-six taps. +inline int32_t stepped(const Row &row, int32_t value, int direction, bool held = false) { + if (row.kind == Kind::moment) { + int32_t step = held ? 60 : row.step; + // From a hold, land on the whole hour first; after that every step is an hour. + int32_t next = held && value % 60 ? (value / 60) * 60 + (direction > 0 ? 60 : 0) : value + direction * step; + int32_t span = row.high + 1; + next %= span; + return next < 0 ? next + span : next; + } + int32_t step = row.kind == Kind::duration ? ladder_step(direction < 0 ? value - 1 : value) : row.step; + return std::clamp(value + direction * step, row.low, row.high); +} +inline bool at_end(const Row &row, int32_t value, int direction) { + if (row.kind == Kind::moment) return false; + return stepped(row, value, direction) == value; +} + +inline std::string duration_text(int32_t seconds) { + char buffer[24]; + if (seconds < 60) { snprintf(buffer, sizeof(buffer), "%d sec", (int) seconds); return buffer; } + if (seconds < 3600) { snprintf(buffer, sizeof(buffer), "%d min", (int) (seconds / 60)); return buffer; } + int hours = seconds / 3600, minutes = (seconds % 3600) / 60; + if (minutes) snprintf(buffer, sizeof(buffer), "%d h %02d", hours, minutes); + else snprintf(buffer, sizeof(buffer), "%d h", hours); + return buffer; +} +// Minutes since midnight as the clock on this screen shows them. +inline std::string moment_text(int32_t minutes, bool clock_24h) { + char buffer[16]; + int hour = minutes / 60, minute = minutes % 60; + if (clock_24h) snprintf(buffer, sizeof(buffer), "%02d:%02d", hour, minute); + else snprintf(buffer, sizeof(buffer), "%d:%02d %s", hour % 12 ? hour % 12 : 12, minute, hour < 12 ? "AM" : "PM"); + return buffer; +} +// What the right-hand side of a row says. A toggle draws a switch instead, its text is for the tests. +inline std::string value_text(const Row &row) { + switch (row.kind) { + case Kind::toggle: return row.read && row.read() ? "On" : "Off"; + case Kind::number: { + char buffer[24]; + snprintf(buffer, sizeof(buffer), "%d%s", row.read ? (int) row.read() : 0, row.unit); + return buffer; + } + case Kind::duration: return duration_text(row.read ? row.read() : 0); + case Kind::moment: return moment_text(row.read ? row.read() : 0, screen_settings::current.clock_24h != 0); + case Kind::choice: { + int32_t index = row.read ? row.read() : 0; + return index >= 0 && index < row.option_count ? row.options[index] : ""; + } + case Kind::info: return row.text ? row.text() : ""; + default: return ""; + } +} +inline bool visible_row(const Row &row) { return !row.shown || row.shown(); } +inline bool live_row(const Row &row) { return !row.enabled || row.enabled(); } + +struct Page { + const char *title; + const Row *rows; + uint8_t count; +}; + +// How many rows fit, and whether the page therefore needs its pager. Reserving the pager only when it +// is really needed keeps a group that just fits on one page. +inline uint8_t fitting_rows(int span, int row_height, int gap, int pager, uint8_t count, bool &paged) { + int loose = std::max(1, (span + gap) / (row_height + gap)); + paged = count > loose; + if (!paged) return count; + return (uint8_t) std::max(1, (span - pager + gap) / (row_height + gap)); +} + +// ------------------------------------------------------------------ the pages +inline constexpr const char *clock_options[] = {"12 hour", "24 hour"}; +inline constexpr const char *rotation_options[] = {"0°", "90°", "180°", "270°"}; + +// Brightness, and when the screen dims by itself. +// Every reader says `-> int32_t` out loud: on the ESP32 that is `long`, and a lambda that returns a +// plain `int` (a ternary, say) then does not convert to Read at all. The Mac's host build accepts it, +// 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(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, "%"), + 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); + }), + 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; }), + number("Standby brightness", []() -> int32_t { return screen_settings::current.standby_brightness; }, + [](int32_t value) { + auto &s = screen_settings::current; + s.standby_brightness = std::clamp(value, 0, s.brightness); + changed("standby_brightness", s.standby_brightness); + }, 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); + }), + moment("Starts", []() -> int32_t { return screen_settings::current.night_start; }, + [](int32_t value) { screen_settings::current.night_start = value; changed("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); }, + [] { 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(value, 0, s.brightness); + changed("night_brightness", s.night_brightness); + }, 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), + toggle("Back to page 1", []() -> int32_t { return auto_home; }, + [](int32_t value) { auto_home = value ? 1 : 0; changed("auto_home", auto_home); }), + duration("After", []() -> int32_t { return auto_home_seconds; }, + [](int32_t value) { auto_home_seconds = value; changed("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); + }), + toggle("Swipe between pages", []() -> int32_t { return swipe_pages; }, + [](int32_t value) { swipe_pages = value ? 1 : 0; changed("swipe_pages", swipe_pages); }), + choice("Rotation", []() -> int32_t { return rotation / 90; }, + [](int32_t value) { rotation = std::clamp(value, 0, 3) * 90; changed("rotation", rotation); }, + rotation_options, 4, [] { return rotation_supported; }), +}; + +// Read-only facts plus the one action: what you want when something is stuck. +inline std::string (*name_text)() = nullptr; +inline std::string (*address_text)() = nullptr; +inline std::string (*firmware_text)() = nullptr; +inline std::string (*link_text)() = nullptr; +inline void (*restart_device)() = nullptr; +inline constexpr Row about_rows[] = { + info("Screen", [] { return name_text ? name_text() : std::string(); }), + info("Address", [] { return address_text ? address_text() : std::string(); }), + info("Firmware", [] { return firmware_text ? firmware_text() : std::string(); }), + info("Home Assistant", [] { return link_text ? link_text() : std::string(); }), + action("Restart", "\U000F0709", [] { if (restart_device) restart_device(); }), +}; + +inline constexpr Row menu_rows[] = { + page_row("Brightness", "\U000F0599", 1), + page_row("Night", "\U000F0594", 2), + page_row("Screen", "\U000F0379", 3), + page_row("This screen", "\U000F02FD", 4), +}; + +inline constexpr Page pages[] = { + {"Settings", menu_rows, (uint8_t) std::size(menu_rows)}, + {"Brightness", light_rows, (uint8_t) std::size(light_rows)}, + {"Night", night_rows, (uint8_t) std::size(night_rows)}, + {"Screen", screen_rows, (uint8_t) std::size(screen_rows)}, + {"This screen", about_rows, (uint8_t) std::size(about_rows)}, +}; +constexpr uint8_t PAGE_COUNT = (uint8_t) std::size(pages); + +} // namespace settings_screen + +#ifndef SETTINGS_SCREEN_TEST +#include "lvgl.h" +#include + +namespace settings_screen { + +// Bound by the board profile on boot: the fonts of the board and what the page may do to it. +inline const lv_font_t *title_font = nullptr, *row_font = nullptr, *icon_font = nullptr; +inline std::function may_open; // awake, no calibration, runtime tiles alive +inline std::function drift; // how far the finger travelled during this touch +inline std::function before_open; // close whatever card is open first +// Holding the top bar opens the page; a finger that travels further than this is swiping, not holding. +inline uint32_t hold_ms = 1500; +inline int hold_limit = 20; +// How far a tap on a row may drift, the same figure the board's own touch guard uses for tiles +// (a centimetre on the resistive CYD, no limit on the capacitive Guition). 0 is no limit. +inline int tap_limit = 0; + +// The page while it is on screen; nothing of it exists when it is closed, which is how a 320x240 +// board with 60 KB of free heap can afford a settings page at all. +inline lv_obj_t *root = nullptr, *hold_area = nullptr, *hold_bar = nullptr; +inline uint8_t current_page = 0, first_row = 0; +inline int confirm_row = -1; // the action row that asked "tap again" +inline lv_timer_t *confirm_timer = nullptr; + +struct Drawn { + lv_obj_t *card = nullptr, *value = nullptr, *knob = nullptr, *minus = nullptr, *plus = nullptr, *label = nullptr; + uint8_t row = 0; +}; +inline std::array drawn{}; +inline uint8_t drawn_count = 0; + +inline bool visible() { return root != nullptr; } + +struct Metrics { + bool large; + int width, height, pad, radius, inset, bar, bar_y, rows_y, row_h, gap, pager, bottom, pill_w, pill_h, switch_w, switch_h; +}; +inline Metrics metrics() { + int width = lv_display_get_horizontal_resolution(lv_display_get_default()); + int height = lv_display_get_vertical_resolution(lv_display_get_default()); + bool large = width >= 480; + return Metrics{large, width, height, + large ? 20 : 10, large ? 18 : 10, large ? 18 : 10, + large ? 56 : 36, large ? 16 : 7, + large ? 92 : 50, large ? 52 : 32, large ? 8 : 5, + large ? 40 : 26, large ? 16 : 10, + large ? 44 : 30, large ? 40 : 26, + large ? 62 : 40, large ? 34 : 22}; +} + +inline lv_obj_t *plain(lv_obj_t *parent, int x, int y, int w, int h) { + auto *obj = lv_obj_create(parent); + lv_obj_remove_style_all(obj); + lv_obj_remove_flag(obj, static_cast(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE)); + lv_obj_set_pos(obj, x, y); + lv_obj_set_size(obj, w, h); + return obj; +} +inline lv_obj_t *text(lv_obj_t *parent, const std::string &value, const lv_font_t *font, uint32_t color, + lv_text_align_t align = LV_TEXT_ALIGN_LEFT) { + auto *label = lv_label_create(parent); + lv_label_set_text(label, value.c_str()); + lv_label_set_long_mode(label, LV_LABEL_LONG_DOT); + lv_obj_remove_flag(label, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_text_font(label, font, 0); + lv_obj_set_style_text_color(label, lv_color_hex(color), 0); + lv_obj_set_style_text_align(label, align, 0); + lv_obj_set_height(label, lv_font_get_line_height(font)); + return label; +} +inline void draw(); + +// ---- the controls on a row ---- +// A round key like the -/+ on the climate card: the finger sees the press in the same frame. +inline lv_obj_t *key(lv_obj_t *parent, const char *glyph, int x, int y, int w, int h, lv_event_cb_t handler, + void *data) { + auto *button = plain(parent, x, y, w, h); + lv_obj_add_flag(button, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_bg_opa(button, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(button, lv_color_hex(0xE9EFF4), 0); + lv_obj_set_style_bg_color(button, lv_color_hex(0xC3D5E3), LV_STATE_PRESSED); + lv_obj_set_style_radius(button, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_opa(button, LV_OPA_40, LV_STATE_DISABLED); + auto *label = text(button, glyph, icon_font ? icon_font : row_font, 0x1B1B1B); + lv_obj_set_width(label, LV_SIZE_CONTENT); + lv_obj_center(label); + lv_obj_add_event_cb(button, handler, LV_EVENT_SHORT_CLICKED, data); + lv_obj_add_event_cb(button, handler, LV_EVENT_LONG_PRESSED_REPEAT, data); + return button; +} +// Not an lv_switch: the row itself is the target, so the pill only has to show the state and can +// never swallow half the taps that land next to it. +inline lv_obj_t *pill(lv_obj_t *parent, int x, int y, int w, int h, bool on) { + auto *track = plain(parent, x, y, w, h); + lv_obj_set_style_bg_opa(track, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(track, lv_color_hex(on ? 0x009FE3 : 0xC4C4C4), 0); + lv_obj_set_style_radius(track, LV_RADIUS_CIRCLE, 0); + int inset = std::max(2, h / 10), size = h - 2 * inset; + auto *knob = plain(track, on ? w - inset - size : inset, inset, size, size); + lv_obj_set_style_bg_opa(knob, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(knob, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_radius(knob, LV_RADIUS_CIRCLE, 0); + return knob; +} +inline void move_knob(Drawn &d, const Row &row) { + if (!d.knob) return; + 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); + 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. +inline void refresh() { + 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.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); + 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); + } + if (d.plus && row.read) { + bool off = !live || at_end(row, row.read(), 1); + if (off) lv_obj_add_state(d.plus, LV_STATE_DISABLED); else lv_obj_remove_state(d.plus, LV_STATE_DISABLED); + } + } +} + +inline void forget_confirm() { + if (confirm_timer) { lv_timer_delete(confirm_timer); confirm_timer = nullptr; } + confirm_row = -1; +} + +// ---- events ---- +inline bool steady(int limit) { return !drift || limit <= 0 || drift() <= limit; } +// Holding - or + repeats, but at the pace of the climate card (five a second) instead of LVGL's ten. +// After a second of holding the steps grow; a single tap is always the small step. +inline uint32_t last_repeat = 0; +inline uint8_t repeats = 0; +constexpr uint8_t REPEATS_BEFORE_FAST = 5; +inline void step_event(lv_event_t *event) { + if (!steady(tap_limit)) return; + bool held = false; + if (lv_event_get_code(event) == LV_EVENT_LONG_PRESSED_REPEAT) { + uint32_t now = lv_tick_get(); + if (now - last_repeat < 200) return; + last_repeat = now; + if (repeats < 255) ++repeats; + held = repeats > REPEATS_BEFORE_FAST; + } else { + repeats = 0; + } + int data = (int) (intptr_t) lv_event_get_user_data(event); + const Page &page = pages[current_page]; + uint8_t index = (uint8_t) (data >> 1); + if (index >= page.count) return; + const Row &row = page.rows[index]; + if (!row.read || !row.write || !live_row(row)) return; + int32_t value = row.read(); + int32_t next = stepped(row, value, data & 1 ? 1 : -1, held); + if (next == value) return; + row.write(next); + refresh(); +} +inline void row_event(lv_event_t *event) { + if (!steady(tap_limit)) return; + int index = (int) (intptr_t) lv_event_get_user_data(event); + const Page &page = pages[current_page]; + if (index < 0 || index >= page.count) return; + const Row &row = page.rows[index]; + if (!live_row(row)) return; + if (row.kind == Kind::page) { current_page = row.opens; first_row = 0; forget_confirm(); draw(); return; } + if (row.kind == Kind::toggle && row.read && row.write) { row.write(row.read() ? 0 : 1); refresh(); return; } + if (row.kind == Kind::choice && row.read && row.write && row.option_count) { + row.write((row.read() + 1) % row.option_count); + refresh(); + return; + } + if (row.kind == Kind::action) { + // Nothing on this page is worth a dialog, except the one row that takes the screen away for ten + // seconds: it asks once, in place, and forgets the question after five. + if (confirm_row == index) { forget_confirm(); if (row.run) row.run(); return; } + forget_confirm(); + confirm_row = index; + confirm_timer = lv_timer_create([](lv_timer_t *) { forget_confirm(); draw(); }, 5000, nullptr); + lv_timer_set_repeat_count(confirm_timer, 1); + draw(); + } +} +inline void close(); +inline void back_event(lv_event_t *) { + forget_confirm(); + if (current_page == 0) { close(); return; } + current_page = 0; + first_row = 0; + draw(); +} +inline void pager_event(lv_event_t *event) { + int direction = (int) (intptr_t) lv_event_get_user_data(event); + int next = (int) first_row + direction; + if (next < 0) return; + first_row = (uint8_t) next; + forget_confirm(); + draw(); +} + +// ---- drawing ---- +inline void draw() { + if (!root) return; + Metrics m = metrics(); + lv_obj_clean(root); + drawn_count = 0; + + const Page &page = pages[current_page]; + // Rows a board does not have (rotation on the CYD) leave the table out of sight entirely. + std::array shown{}; + uint8_t count = 0; + for (uint8_t i = 0; i < page.count && count < shown.size(); ++i) + if (visible_row(page.rows[i])) shown[count++] = i; + + // Top bar: the same round back arrow and centred name as the cards of a tile. + auto *back = plain(root, m.pad, m.bar_y, m.bar, m.bar); + lv_obj_add_flag(back, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_bg_opa(back, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(back, lv_color_hex(0xEEEEEE), 0); + lv_obj_set_style_bg_color(back, lv_color_hex(0xDADADA), LV_STATE_PRESSED); + lv_obj_set_style_radius(back, LV_RADIUS_CIRCLE, 0); + auto *arrow = text(back, "\U000F004D", icon_font ? icon_font : row_font, 0x1B1B1B); + lv_obj_set_width(arrow, LV_SIZE_CONTENT); + lv_obj_center(arrow); + lv_obj_add_event_cb(back, back_event, LV_EVENT_SHORT_CLICKED, nullptr); + const lv_font_t *heading = title_font ? title_font : row_font; + auto *title = text(root, page.title, heading, 0x1B1B1B, LV_TEXT_ALIGN_CENTER); + lv_obj_set_width(title, m.width - 2 * (m.pad + m.bar + 8)); + lv_obj_set_pos(title, m.pad + m.bar + 8, m.bar_y + (m.bar - lv_font_get_line_height(heading)) / 2); + + int span = m.height - m.rows_y - m.bottom; + bool paged = false; + uint8_t per_page = std::min(fitting_rows(span, m.row_h, m.gap, m.pager, count, paged), drawn.size()); + // Always start a page on a page boundary, and never scroll a group that fits. + first_row = paged && first_row < count ? (uint8_t) ((first_row / per_page) * per_page) : 0; + + int label_h = lv_font_get_line_height(row_font); + for (uint8_t slot = 0; slot < per_page && first_row + slot < count; ++slot) { + uint8_t index = shown[first_row + slot]; + const Row &row = page.rows[index]; + Drawn &d = drawn[slot]; + d = Drawn{}; + d.row = index; + bool tappable = row.kind == Kind::page || row.kind == Kind::toggle || row.kind == Kind::choice || + row.kind == Kind::action; + bool asking = row.kind == Kind::action && confirm_row == index; + + d.card = plain(root, m.pad, m.rows_y + slot * (m.row_h + m.gap), m.width - 2 * m.pad, m.row_h); + lv_obj_set_style_bg_opa(d.card, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(d.card, lv_color_hex(asking ? 0x009FE3 : 0xFFFFFF), 0); + lv_obj_set_style_radius(d.card, m.radius, 0); + lv_obj_set_style_border_width(d.card, 1, 0); + lv_obj_set_style_border_color(d.card, lv_color_hex(asking ? 0x009FE3 : 0xDDDDDD), 0); + if (tappable) { + lv_obj_add_flag(d.card, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_bg_color(d.card, lv_color_hex(asking ? 0x0083BC : 0xF1F1F1), LV_STATE_PRESSED); + lv_obj_add_event_cb(d.card, row_event, LV_EVENT_SHORT_CLICKED, (void *) (intptr_t) index); + } + + int left = m.inset; + if (row.icon && *row.icon) { + auto *glyph = text(d.card, row.icon, icon_font ? icon_font : row_font, asking ? 0xFFFFFF : 0x4A4A4A); + int icon_h = lv_font_get_line_height(icon_font ? icon_font : row_font); + lv_obj_set_width(glyph, LV_SIZE_CONTENT); + lv_obj_set_pos(glyph, left, (m.row_h - icon_h) / 2); + left += icon_h + (m.large ? 12 : 8); + } + d.label = text(d.card, asking ? "Tap again to restart" : row.label, row_font, asking ? 0xFFFFFF : 0x1B1B1B); + lv_obj_set_pos(d.label, left, (m.row_h - label_h) / 2); + + int right = m.width - 2 * m.pad - m.inset; // free space from the right edge of the card + if (row.kind == Kind::page) { + auto *chevron = text(d.card, "\U000F0142", icon_font ? icon_font : row_font, 0xB0B0B0); + int icon_h = lv_font_get_line_height(icon_font ? icon_font : row_font); + lv_obj_set_width(chevron, LV_SIZE_CONTENT); + lv_obj_set_pos(chevron, right - icon_h, (m.row_h - icon_h) / 2); + lv_obj_set_width(d.label, right - icon_h - left - 6); + } else if (row.kind == Kind::toggle) { + d.knob = pill(d.card, right - m.switch_w, (m.row_h - m.switch_h) / 2, m.switch_w, m.switch_h, row.read && row.read()); + lv_obj_set_width(d.label, right - m.switch_w - left - 6); + } else if (row.kind == Kind::number || row.kind == Kind::duration || row.kind == Kind::moment) { + int y = (m.row_h - m.pill_h) / 2, value_w = m.large ? 116 : 74; + d.plus = key(d.card, "\U000F0415", right - m.pill_w, y, m.pill_w, m.pill_h, step_event, + (void *) (intptr_t) (index * 2 + 1)); + d.minus = key(d.card, "\U000F0374", right - m.pill_w - value_w - m.pill_w, y, m.pill_w, m.pill_h, step_event, + (void *) (intptr_t) (index * 2)); + d.value = text(d.card, value_text(row), row_font, 0x1B1B1B, LV_TEXT_ALIGN_CENTER); + lv_obj_set_width(d.value, value_w); + lv_obj_set_pos(d.value, right - m.pill_w - value_w, (m.row_h - label_h) / 2); + lv_obj_set_width(d.label, right - 2 * m.pill_w - value_w - left - 6); + } else if (row.kind == Kind::choice) { + int value_w = m.large ? 150 : 96, chip_h = m.pill_h; + auto *chip = plain(d.card, right - value_w, (m.row_h - chip_h) / 2, value_w, chip_h); + lv_obj_set_style_bg_opa(chip, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(chip, lv_color_hex(0xE9EFF4), 0); + lv_obj_set_style_radius(chip, LV_RADIUS_CIRCLE, 0); + d.value = text(chip, value_text(row), row_font, 0x1B1B1B, LV_TEXT_ALIGN_CENTER); + lv_obj_set_width(d.value, value_w - 8); + lv_obj_set_pos(d.value, 4, (chip_h - label_h) / 2); + lv_obj_set_width(d.label, right - value_w - left - 6); + } else if (row.kind == Kind::info) { + d.value = text(d.card, value_text(row), row_font, 0x616161, LV_TEXT_ALIGN_RIGHT); + int value_w = (m.width - 2 * m.pad) / 2; + lv_obj_set_width(d.value, value_w); + lv_obj_set_pos(d.value, right - value_w, (m.row_h - label_h) / 2); + lv_obj_set_width(d.label, right - value_w - left - 6); + } else { + lv_obj_set_width(d.label, right - left); + } + drawn_count = (uint8_t) (slot + 1); + } + refresh(); + + if (!paged) return; + // The same pager as the tile pages, in the same place, so it is the one that is already learned. + uint8_t page_number = (uint8_t) (first_row / per_page + 1); + uint8_t page_total = (uint8_t) ((count + per_page - 1) / per_page); + int y = m.height - m.pager - m.bottom / 2, half = (m.width - 2 * m.pad) / 2 - 20; + for (int side = 0; side < 2; ++side) { + bool enabled = side ? page_number < page_total : page_number > 1; + if (!enabled) continue; + auto *bar = plain(root, side ? m.width - m.pad - half : m.pad, y, half, m.pager); + lv_obj_add_flag(bar, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_bg_opa(bar, LV_OPA_TRANSP, 0); + lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, LV_STATE_PRESSED); + lv_obj_set_style_bg_color(bar, lv_color_hex(0xDCDCDC), LV_STATE_PRESSED); + lv_obj_set_style_radius(bar, m.radius, 0); + auto *label = text(bar, side ? "Next >" : "< Previous", row_font, 0x1B1B1B, + side ? LV_TEXT_ALIGN_RIGHT : LV_TEXT_ALIGN_LEFT); + lv_obj_set_width(label, half - 12); + lv_obj_set_pos(label, 6, (m.pager - label_h) / 2); + lv_obj_add_event_cb(bar, pager_event, LV_EVENT_SHORT_CLICKED, + (void *) (intptr_t) (side ? per_page : -per_page)); + } + auto *counter = text(root, std::to_string(page_number) + " / " + std::to_string(page_total), row_font, 0x616161, + LV_TEXT_ALIGN_CENTER); + lv_obj_set_width(counter, m.width); + lv_obj_set_pos(counter, 0, y + (m.pager - label_h) / 2); +} + +inline void hide_hold_bar() { + if (!hold_bar) return; + lv_anim_delete(hold_bar, nullptr); + lv_obj_add_flag(hold_bar, LV_OBJ_FLAG_HIDDEN); +} + +inline void open(uint8_t page = 0) { + hide_hold_bar(); + current_page = page < PAGE_COUNT ? page : 0; + first_row = 0; + forget_confirm(); + if (before_open) before_open(); + if (!root) { + root = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(root); + lv_obj_set_size(root, lv_pct(100), lv_pct(100)); + lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(root, LV_OBJ_FLAG_CLICKABLE); // nothing leaks through to the tiles below + lv_obj_set_style_bg_color(root, lv_color_hex(0xE7E7E7), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + } + lv_obj_move_foreground(root); + draw(); +} + +inline void close() { + forget_confirm(); + drawn_count = 0; + if (root) { lv_obj_delete(root); root = nullptr; } +} + +// ---- holding the top bar opens the page ---- +// Not a tile, not a button in view: a press of `hold_ms` on the strip with the screen name. A line +// grows along the top edge while you hold, so the gesture explains itself the first time it happens. +inline void hold_step(void *object, int32_t value) { + auto *bar = static_cast(object); + int width = lv_display_get_horizontal_resolution(lv_display_get_default()); + if (drift && drift() > hold_limit) { hide_hold_bar(); return; } + // The first quarter second stays invisible: a tap must not flash a line across the screen. + if (value < 250) return; + lv_obj_remove_flag(bar, LV_OBJ_FLAG_HIDDEN); + lv_obj_set_width(bar, std::max(1, (int32_t) width * (value - 250) / (int32_t) (hold_ms - 250))); +} + +inline void hold_event(lv_event_t *event) { + auto code = lv_event_get_code(event); + if (!hold_bar) return; + if (code == LV_EVENT_PRESSED) { + if (visible() || (may_open && !may_open())) return; + lv_obj_set_width(hold_bar, 1); + lv_anim_t anim; + lv_anim_init(&anim); + lv_anim_set_var(&anim, hold_bar); + lv_anim_set_values(&anim, 0, (int32_t) hold_ms); + lv_anim_set_duration(&anim, (uint32_t) hold_ms); + lv_anim_set_exec_cb(&anim, hold_step); + lv_anim_set_completed_cb(&anim, [](lv_anim_t *) { + hide_hold_bar(); + if (drift && drift() > hold_limit) return; + if (may_open && !may_open()) return; + open(); + }); + lv_anim_start(&anim); + return; + } + hide_hold_bar(); +} + +// A transparent strip over the top bar plus the line that fills while it is held. Both belong to the +// page below, so they survive every redraw of the tiles. +inline void attach_hold(lv_obj_t *page, int x, int y, int width, int height) { + bool large = lv_display_get_horizontal_resolution(lv_display_get_default()) >= 480; + hold_area = plain(page, x, y, width, height); + lv_obj_add_flag(hold_area, LV_OBJ_FLAG_CLICKABLE); + // No pressed style at all: a repaint of a strip this wide costs a frame that the touch polling on a + // resistive panel needs (the lesson of firmware 0.2.35). + lv_obj_add_event_cb(hold_area, hold_event, LV_EVENT_PRESSED, nullptr); + lv_obj_add_event_cb(hold_area, hold_event, LV_EVENT_RELEASED, nullptr); + lv_obj_add_event_cb(hold_area, hold_event, LV_EVENT_PRESS_LOST, nullptr); + hold_bar = plain(page, 0, 0, 1, large ? 5 : 3); + lv_obj_set_style_bg_opa(hold_bar, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(hold_bar, lv_color_hex(0x009FE3), 0); + lv_obj_add_flag(hold_bar, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(hold_bar); +} + +} // namespace settings_screen +#endif diff --git a/components/smart_display/tile_icon_names.h b/components/smart_display/tile_icon_names.h index ae9e05d..e7bb406 100644 --- a/components/smart_display/tile_icon_names.h +++ b/components/smart_display/tile_icon_names.h @@ -187,6 +187,9 @@ inline constexpr Named NAMES[] = { {"weather-sunset-down", 0xF059B}, {"dots-horizontal", 0xF01D8}, {"arrow-oscillating", 0xF1C91}, + {"monitor", 0xF0379}, + {"information-outline", 0xF02FD}, + {"restart", 0xF0709}, }; inline constexpr size_t NAME_COUNT = sizeof(NAMES) / sizeof(NAMES[0]); // Codepoint of a name the fonts carry; 0 for any other name. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 1d7caca..4e6e675 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -176,6 +176,44 @@ 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.52 / firmware 0.2.44 + +App and firmware. Storage version and tile protocol stay 1; everything is additive, so an old screen +with this app, and a new screen with an older app, both keep working. + +- **Two settings, beside the frozen block.** `settings.auto_home` (bool, default `true`) and + `settings.auto_home_seconds` (30-3600, default 120) are new keys in `SETTING_RULES`, stored in + storage version 1. On the wire they travel as their own top-level keys of the layout message, next to + `swipe_pages` and `rotation`; the eleven-field `settings` object is unchanged, because firmware before + 0.2.44 refuses any other size. `core.SETTINGS_BESIDE_BLOCK` is the one list of such keys and + `layout_message` uses it. Older firmware ignores the two keys. +- **Firmware storage.** A new preference key `0x484F4D31` holds `HomeTimeout` (two uint32: enabled, + seconds). The Settings structure `0x53435231`, swipe `0x53575031`, rotation `0x524F5431` and the CYD + calibration are untouched. `runtime_tiles::persist_settings()` saves all of them; ESPHome compares each + blob with NVS before writing, so unchanged records cost no flash write. +- **Settings page on the screen.** `components/smart_display/settings_screen.h`: one `constexpr` table of + pages and rows, drawn into a root object created on open and deleted on close. Opened by holding the + top bar (`attach_hold`, substitutions `SETTINGS_HOLD_X/W/DRIFT_PX`; on the Guition the strip keeps + clear of both swipe bands), by the `screen.settings` tile, or by the API action `open_settings` + (`page` 0-4, negative closes and goes home). A change is saved, applied (`apply_screen_settings`) and + reported with the existing `esphome.screen_setting` event; the manager's handler accepts every key in + `SETTING_RULES`, the new ones included. Row taps use the board's own drift limit + (`TOUCH_MOVE_LIMIT_PX`), the hold its own. +- **Back to page 1 by itself.** The 1 s interval runs the new `go_home` script (settings page closed, + cards closed, page 1) when `auto_home` is on and nothing touched the screen for `auto_home_seconds`, + while a card, the settings page or a later page is showing. Standby now always closes the settings page; + an alert closes it too. +- **`screen.settings` tile.** A second built-in entity next to `screen.clock`. `min_firmware` asks for + 0.2.44, so the manager does not send a layout with it to older firmware. `Tile::is_clock()` now marks + the clock-only paths (minute redraw, second hand) that used `builtin()`; the editor mockup takes the + icon from `tile_icons.BUILTIN_TILES`. +- **Glyphs.** `monitor`, `information-outline` and `restart` join `tile_icons.FIXED` (183 glyphs). +- **API.** `open_settings` is a new ESPHome action; Home Assistant lists it as + `esphome._open_settings` after the device reconnects with 0.2.44. The `IP address` text sensor + gained `id: wifi_ip`; its entity is unchanged. +- **Docs.** docs/SETTINGS.md is the recipe for adding a setting; the Claude skill gains a section on the + page (one more YAML example), so an installed skill shows as outdated until it is installed again. + ### Compatibility 0.2.51 / firmware 0.2.43 App only; no firmware change, no protocol change, no storage change. Layouts keep their shape, so an diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md new file mode 100644 index 0000000..c89b794 --- /dev/null +++ b/docs/SETTINGS.md @@ -0,0 +1,98 @@ +# Settings: on the screen 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. + +## 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._open_settings`. + +The page is a menu of groups, each of which opens a page of its own: + +| Group | Rows | +|---|---| +| Brightness | Brightness, Auto standby, Standby after, Standby brightness | +| Night | Night mode, Starts, Ends, Night brightness | +| 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. + +## The rules the page follows + +- **Two levels, never three.** A group page is the deepest place a setting can live. +- **No free scrolling.** A group that does not fit gets the same `< Previous / Next >` pager as the tile + pages. A 320x240 board shows five rows, a 480x480 board six. +- **A row is a control, not a form.** Toggles flip on tap, numbers and times have `-` and `+`, a choice + cycles through its options in a chip on the right. +- **A row that depends on a switch above it is greyed out, not hidden**, so the page never jumps around. +- **Only settings that belong to this piece of glass.** Tiles, the top bar and pages belong to the editor, + which has a mouse and a keyboard; the screen gets what you want to change while standing in front of it. + +## Adding a setting + +The example below adds "Beep on touch" (`beep`), a switch. + +### 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 + `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. + +```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 row + +One line in the table of the group it belongs to, in `settings_screen.h`: + +```cpp +toggle("Beep on touch", [] { return beep; }, + [](int32_t value) { beep = value ? 1 : 0; changed("beep", beep); }), +``` + +`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` +(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 + +- `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. + +### 4. 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. +- Render it before believing it: `.esphome/readme-render/host_build.py --compile` builds the real + firmware for the Mac and `render_settings.py ` drives it over the API and saves PNGs of every + page, including the hold gesture, the time picker and the settings tile. diff --git a/docs/TEST_RESULTS_0252.md b/docs/TEST_RESULTS_0252.md new file mode 100644 index 0000000..1331fe7 --- /dev/null +++ b/docs/TEST_RESULTS_0252.md @@ -0,0 +1,70 @@ +# Test results app 0.2.52 / firmware 0.2.44 (2026-09-16) + +The settings page on the screen itself, the `screen.settings` card, and "back to page 1 by itself". +Firmware and app both change; the protocol grows two optional keys and the stored layouts gain two +settings with defaults, so an old screen with the new app and a new screen with an old app both keep +working. See CHANGELOG 0.2.52 and docs/SETTINGS.md. + +## Automated + +- Python (`.venv-portal`): 200 tests OK. New `tests/test_settings_page.py` (7 tests): every key the + firmware reports back (`changed("...")` in settings_screen.h) is a key the add-on validates; the + `settings` block the screen receives still holds exactly its eleven frozen keys while `auto_home` + and `auto_home_seconds` ride beside it; the defaults and the refusals of the two new settings; + `screen.settings` as a built-in tile that asks for firmware 0.2.44; and both board profiles carrying + the page, the hold strip (clear of the Guition's swipe bands), the `open_settings` action and the + `go_home` script. +- C++ (`clang++ -std=c++17 -Wall -Wextra -Werror -I.`): 14/14 PASS, including new + `tests/test_settings_screen.cpp`: the duration ladder and its symmetry, a number stopping at its + ends, a time wrapping around midnight and walking whole hours while a key is held, every text + ("45 sec", "1 h 30", "22:00", "10:00 PM"), the rows reading and writing the real settings (including + a lower Brightness pulling the two dim levels down), a row hidden on a board that cannot turn, the + table itself (every menu row opens a real page, every control has a reader and a writer), how many + rows fit per board and when the pager appears, and that one change stores, applies and reports once. +- `generate_packages.py --check` green, `generate_icons.py --check` green (183 glyphs: three new). +- `esphome config` green for both board profiles. + +## Builds (ESPHome 2026.6.2) + +Full board profiles compiled for the ESP32, not just the host: + +| Profile | RAM | Flash | +|---|---|---| +| home-like-2432s028.yaml (CYD) | 23.0% (75,492 B) | 80.6% (1,479,167 B of 1,835,008) | +| guition-4848s040.yaml | 24.0% (78,732 B) | 19.4% (1,576,143 B of 8,126,464) | +| easy-cyd-device.yaml (package, local components) | 23.0% (75,276 B) | 76.0% (1,395,143 B) | +| easy-guition-device.yaml (package, local components) | 24.0% (78,508 B) | 18.4% (1,494,075 B) | + +The two Easy profiles were compiled one after the other, as RELEASING asks, from packages generated +out of the board profiles above. + +What the page costs, measured by building the same CYD profile at 0.2.51 (firmware 0.2.43) in a clean +worktree: **+608 bytes of RAM** (74,884 -> 75,492) and **+10.7 KB of flash** (1,468,191 -> 1,479,167). +The rows are a `constexpr` table in flash and the LVGL objects are built when the page opens and +deleted when it closes, like the cards of a tile, so a closed page costs only that handful of bytes. + +The ESP32 build also caught what the Mac could not: on xtensa `int32_t` is `long`, so a row reader +that returns a plain `int` does not convert to `Read` at all. Every reader now says `-> int32_t`. + +## Rendered on the real firmware (host build, SDL) + +`.esphome/readme-render/host_build.py --compile` builds the actual firmware for the Mac and +`render_settings.py ` drives it over its own API. Both boards were rendered and looked at: + +- Every page on both boards: the menu, Brightness, Night, Screen, This screen. +- Holding the top bar: the blue line is halfway after 0.9 s of a 1.5 s hold and disappears on release. +- The time picker worked like a finger: four taps on `+` moved 22:00 to 23:00 (quarters), then holding + walked whole hours to 03:00. Switching the clock to 12 hour turned the same times into 3:00 AM and + 7:00 AM. +- A switch row turning its dependent rows live and grey again (Night mode, Auto standby). +- The `screen.settings` card on the overview, and a tap on it opening the menu. +- "Back to page 1 by itself": with the last touch aged past the timeout, the open page closed and the + overview came back on page 1. + +## Not tested / for the owner + +- Hardware. Nothing was flashed: the hold gesture on real glass (resistive CYD and capacitive Guition), + whether 1.5 s feels right, and whether a hold that becomes a swipe cancels cleanly. +- The write-back to a running ESP Screens (`esphome.screen_setting` event), which the firmware already + used for the Home Assistant number entities and which this page reuses unchanged. +- `Restart` from the page, and `esphome._open_settings` from Home Assistant. diff --git a/guition-4848s040.yaml b/guition-4848s040.yaml index 9003346..7e88196 100644 --- a/guition-4848s040.yaml +++ b/guition-4848s040.yaml @@ -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.43" + SCREEN_FIRMWARE_VERSION: "0.2.44" # 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" @@ -66,6 +66,11 @@ substitutions: TILE_W: "218" TILE_H: "108" HEADER_Y: "22" + # Holding the top bar opens the settings page (0.2.44+). The two edge bands stay clear: a swipe + # that starts up there still changes the page. + SETTINGS_HOLD_X: "${EDGE_SWIPE_BAND_PX}" + SETTINGS_HOLD_W: "416" + SETTINGS_HOLD_DRIFT_PX: "32" # Alert overlay (HA action show_alert), 480x480: one card centred on LVGL's top layer. ALERT_CARD_W: "420" @@ -123,6 +128,7 @@ esphome: - - - + - name: ${DEVICE_NAME} friendly_name: ${DEVICE_FRIENDLY_NAME} on_boot: @@ -231,6 +237,31 @@ esphome: if (kind == 'f') id(set_climate_fan_mode).execute(index); else id(set_climate_swing_mode).execute(index); }; + # The settings page the screen opens on its own (0.2.44+): the same values ESP Screen Manager + # writes, reachable by holding the top bar. Fonts and hooks only; the page itself is built + # when it opens and thrown away when it closes, so it costs nothing while it is closed. + - lambda: |- + if (!runtime_tiles::enabled) return; + settings_screen::title_font = id(headline)->get_lv_font(); + settings_screen::row_font = id(sublabel_big)->get_lv_font(); + settings_screen::icon_font = id(materialdesign_icons_mini)->get_lv_font(); + settings_screen::store = []() { runtime_tiles::persist_settings(); }; + settings_screen::apply = []() { id(apply_screen_settings).execute(); }; + settings_screen::report = [](const char *key, int32_t value) { runtime_tiles::setting_event(key, value); }; + settings_screen::name_text = []() { return std::string("${DEVICE_FRIENDLY_NAME}"); }; + settings_screen::address_text = []() { return id(wifi_ip).state.empty() ? std::string("No network") : id(wifi_ip).state; }; + settings_screen::firmware_text = []() { return std::string("${SCREEN_FIRMWARE_VERSION}"); }; + settings_screen::link_text = []() { return std::string(esphome::api_is_connected() ? "Connected" : "Not connected"); }; + settings_screen::restart_device = []() { ESP_LOGI("settings", "restart from the screen"); App.safe_reboot(); }; + // A dimmed screen, a calibration or an alert owns the glass; the page waits. + settings_screen::may_open = []() { + return runtime_tiles::enabled && !id(display_dimmed) && !id(calibration_active) && !id(alert_active); + }; + settings_screen::drift = []() { return cyd::touch_guard.distance(); }; + settings_screen::before_open = []() { id(close_cards).execute(); }; + settings_screen::hold_limit = ${SETTINGS_HOLD_DRIFT_PX}; + settings_screen::tap_limit = ${TOUCH_MOVE_LIMIT_PX}; + settings_screen::attach_hold(id(home_page)->obj, ${SETTINGS_HOLD_X}, 0, ${SETTINGS_HOLD_W}, ${SCROLL_Y}); - light.turn_on: id: back_light brightness: !lambda "return screen_settings::current.brightness / 100.0f;" @@ -333,6 +364,19 @@ api: } runtime_tiles::show_detail(index); } + # The settings page from Home Assistant: `page` 0 is its menu, 1-4 open a group straight away + # (Brightness, Night, Screen, This screen) and a negative page closes it and goes back to page 1. + # Handy for support ("look at Screen -> Rotation") and for the render tests. + - action: open_settings + variables: + page: int + then: + - lambda: |- + if (page < 0) { id(go_home).execute(); return; } + id(wake_display).execute(); + settings_screen::open((uint8_t) page); + ESP_LOGI("settings", "page %d open from Home Assistant", page); + # Diagnostics: the climate mode picker and the busy sheet of a tile, without touching the glass or HA. - action: preview_climate_picker then: @@ -431,6 +475,19 @@ interval: return (millis() - id(last_touch_ms)) > (screen_settings::current.standby_seconds * 1000UL); then: - script.execute: dim_display + # Back to page 1 by itself (0.2.44+). A card, a settings page or a second page left open keeps + # showing what someone looked at minutes ago; after this long the screen is a screen again. + # Independent of standby, which may be off or much later. + - if: + condition: + lambda: |- + if (!runtime_tiles::auto_home || id(touch_down) || id(calibration_active) || id(alert_active)) return false; + if (id(display_dimmed)) return false; + bool away = settings_screen::visible() || !id(active_entity).empty() || id(tile_page) != 0 || + (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)); + return away && (millis() - id(last_touch_ms)) > (uint32_t) runtime_tiles::auto_home_seconds * 1000UL; + then: + - script.execute: go_home time: - platform: homeassistant @@ -881,6 +938,9 @@ font: - "\U000F059B" # weather-sunset-down - "\U000F01D8" # dots-horizontal - "\U000F1C91" # arrow-oscillating + - "\U000F0379" # monitor + - "\U000F02FD" # information-outline + - "\U000F0709" # restart - file: 'fonts/materialdesignicons-webfont.ttf' id: materialdesign_icons_mini @@ -966,6 +1026,8 @@ text_sensor: update_interval: 60s - platform: wifi_info ip_address: + # `id` so the settings page on the screen can show the same address (0.2.44+). + id: wifi_ip name: "IP address" entity_category: diagnostic @@ -3513,6 +3575,15 @@ script: - lvgl.widget.hide: climate_detail_overlay - lvgl.widget.hide: climate_mode_overlay + # Everything back to the overview: the settings page, every card and the first page of tiles. + # Used by standby, by the "Back to page 1" timeout and by the Home Assistant action. + - id: go_home + then: + - lambda: 'settings_screen::close();' + - script.execute: close_cards + - lambda: 'id(tile_page) = 0;' + - script.execute: show_tile_page + # ---------- ALERT (HA action show_alert) ---------- # The screen wakes, keeps its normal brightness and shows the card until OK or the # timeout (0: until OK); the standby interval waits on alert_active. A new alert @@ -3530,6 +3601,8 @@ script: then: - lambda: |- if (id(alert_active)) id(alert_report).execute("replaced"); + // An alert is the one thing that outranks the settings page, so that closes first. + settings_screen::close(); const auto alert = screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX}); id(alert_active) = true; id(alert_title_text) = alert.title; @@ -3648,6 +3721,8 @@ script: - lambda: |- id(display_dimmed) = true; id(apply_screen_settings).execute(); + // A settings page behind a dark screen helps nobody; it always closes. + settings_screen::close(); if (screen_settings::current.home_on_standby) { // Runtime detail cards live above the tile grid, so paging alone cannot close them. runtime_tiles::hide_detail(); diff --git a/home-like-2432s028.yaml b/home-like-2432s028.yaml index 6ebaca8..3682149 100644 --- a/home-like-2432s028.yaml +++ b/home-like-2432s028.yaml @@ -17,7 +17,7 @@ ############################################################ substitutions: - SCREEN_FIRMWARE_VERSION: "0.2.43" + SCREEN_FIRMWARE_VERSION: "0.2.44" # 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. @@ -97,6 +97,11 @@ substitutions: TILE_W: "147" TILE_H: "52" HEADER_Y: "14" + # Holding the top bar opens the settings page (0.2.44+). The whole strip counts on this board; + # a finger that travels further than the drift limit of a tap is swiping, not holding. + SETTINGS_HOLD_X: "0" + SETTINGS_HOLD_W: "${DISPLAY_W}" + SETTINGS_HOLD_DRIFT_PX: "24" OVERLAY_SLIDER_W: "96" OVERLAY_SLIDER_H: "160" OVERLAY_ICON_Y: "74" @@ -259,6 +264,7 @@ esphome: - - - + - name: ${DEVICE_NAME} friendly_name: ${DEVICE_FRIENDLY_NAME} on_boot: @@ -374,6 +380,31 @@ esphome: screen_calibration::isolation = [](bool active) { id(calibration_active) = active; }; screen_calibration::setup(id(home_page)->obj, id(sublabel)->get_lv_font(), id(headline)->get_lv_font(), ${TOUCH_CAL_X_MIN}, ${TOUCH_CAL_X_MAX}, ${TOUCH_CAL_Y_MIN}, ${TOUCH_CAL_Y_MAX}); + # The settings page the screen opens on its own (0.2.44+): the same values ESP Screen Manager + # writes, reachable by holding the top bar. Fonts and hooks only; the page itself is built + # when it opens and thrown away when it closes, so it costs nothing while it is closed. + - lambda: |- + if (!runtime_tiles::enabled) return; + settings_screen::title_font = id(headline)->get_lv_font(); + settings_screen::row_font = id(sublabel_big)->get_lv_font(); + settings_screen::icon_font = id(materialdesign_icons_mini)->get_lv_font(); + settings_screen::store = []() { runtime_tiles::persist_settings(); }; + settings_screen::apply = []() { id(apply_screen_settings).execute(); }; + settings_screen::report = [](const char *key, int32_t value) { runtime_tiles::setting_event(key, value); }; + settings_screen::name_text = []() { return std::string("${DEVICE_FRIENDLY_NAME}"); }; + settings_screen::address_text = []() { return id(wifi_ip).state.empty() ? std::string("No network") : id(wifi_ip).state; }; + settings_screen::firmware_text = []() { return std::string("${SCREEN_FIRMWARE_VERSION}"); }; + settings_screen::link_text = []() { return std::string(esphome::api_is_connected() ? "Connected" : "Not connected"); }; + settings_screen::restart_device = []() { ESP_LOGI("settings", "restart from the screen"); App.safe_reboot(); }; + // A dimmed screen, a calibration or an alert owns the glass; the page waits. + settings_screen::may_open = []() { + return runtime_tiles::enabled && !id(display_dimmed) && !id(calibration_active) && !id(alert_active); + }; + settings_screen::drift = []() { return cyd::touch_guard.distance(); }; + settings_screen::before_open = []() { id(close_cards).execute(); }; + settings_screen::hold_limit = ${SETTINGS_HOLD_DRIFT_PX}; + settings_screen::tap_limit = ${TOUCH_MOVE_LIMIT_PX}; + settings_screen::attach_hold(id(home_page)->obj, ${SETTINGS_HOLD_X}, 0, ${SETTINGS_HOLD_W}, ${SCROLL_Y}); - light.turn_on: id: back_light brightness: !lambda "return screen_settings::current.brightness / 100.0f;" @@ -470,6 +501,19 @@ api: } runtime_tiles::show_detail(index); } + # The settings page from Home Assistant: `page` 0 is its menu, 1-4 open a group straight away + # (Brightness, Night, Screen, This screen) and a negative page closes it and goes back to page 1. + # Handy for support ("look at Screen -> Rotation") and for the render tests. + - action: open_settings + variables: + page: int + then: + - lambda: |- + if (page < 0) { id(go_home).execute(); return; } + id(wake_display).execute(); + settings_screen::open((uint8_t) page); + ESP_LOGI("settings", "page %d open from Home Assistant", page); + # Diagnostics: the climate mode picker and the busy sheet of a tile, without touching the glass or HA. - action: preview_climate_picker then: @@ -568,6 +612,19 @@ interval: return (millis() - id(last_touch_ms)) > (screen_settings::current.standby_seconds * 1000UL); then: - script.execute: dim_display + # Back to page 1 by itself (0.2.44+). A card, a settings page or a second page left open keeps + # showing what someone looked at minutes ago; after this long the screen is a screen again. + # Independent of standby, which may be off or much later. + - if: + condition: + lambda: |- + if (!runtime_tiles::auto_home || id(touch_down) || id(calibration_active) || id(alert_active)) return false; + if (id(display_dimmed)) return false; + bool away = settings_screen::visible() || !id(active_entity).empty() || id(tile_page) != 0 || + (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)); + return away && (millis() - id(last_touch_ms)) > (uint32_t) runtime_tiles::auto_home_seconds * 1000UL; + then: + - script.execute: go_home time: - platform: homeassistant @@ -1019,6 +1076,9 @@ font: - "\U000F059B" # weather-sunset-down - "\U000F01D8" # dots-horizontal - "\U000F1C91" # arrow-oscillating + - "\U000F0379" # monitor + - "\U000F02FD" # information-outline + - "\U000F0709" # restart - file: 'fonts/materialdesignicons-webfont.ttf' id: materialdesign_icons_mini @@ -1099,6 +1159,8 @@ text_sensor: update_interval: 60s - platform: wifi_info ip_address: + # `id` so the settings page on the screen can show the same address (0.2.44+). + id: wifi_ip name: "IP address" entity_category: diagnostic @@ -3558,6 +3620,15 @@ script: - lvgl.widget.hide: climate_detail_overlay - lvgl.widget.hide: climate_mode_overlay + # Everything back to the overview: the settings page, every card and the first page of tiles. + # Used by standby, by the "Back to page 1" timeout and by the Home Assistant action. + - id: go_home + then: + - lambda: 'settings_screen::close();' + - script.execute: close_cards + - lambda: 'id(tile_page) = 0;' + - script.execute: show_tile_page + # ---------- ALERT (HA action show_alert) ---------- # The screen wakes, keeps its normal brightness and shows the card until OK or the # timeout (0: until OK); the standby interval waits on alert_active. A new alert @@ -3575,6 +3646,8 @@ script: then: - lambda: |- if (id(alert_active)) id(alert_report).execute("replaced"); + // An alert is the one thing that outranks the settings page, so that closes first. + settings_screen::close(); const auto alert = screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX}); id(alert_active) = true; id(alert_title_text) = alert.title; @@ -3689,6 +3762,8 @@ script: - lambda: |- id(display_dimmed) = true; id(apply_screen_settings).execute(); + // A settings page behind a dark screen helps nobody; it always closes. + settings_screen::close(); if (screen_settings::current.home_on_standby) { // Runtime detail cards live above the tile grid, so paging alone cannot close them. runtime_tiles::hide_detail(); diff --git a/packages/cyd.yaml b/packages/cyd.yaml index b7a3e05..e562ef3 100644 --- a/packages/cyd.yaml +++ b/packages/cyd.yaml @@ -18,7 +18,7 @@ ############################################################ substitutions: - SCREEN_FIRMWARE_VERSION: "0.2.43" + SCREEN_FIRMWARE_VERSION: "0.2.44" # 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. @@ -98,6 +98,11 @@ substitutions: TILE_W: "147" TILE_H: "52" HEADER_Y: "14" + # Holding the top bar opens the settings page (0.2.44+). The whole strip counts on this board; + # a finger that travels further than the drift limit of a tap is swiping, not holding. + SETTINGS_HOLD_X: "0" + SETTINGS_HOLD_W: "${DISPLAY_W}" + SETTINGS_HOLD_DRIFT_PX: "24" OVERLAY_SLIDER_W: "96" OVERLAY_SLIDER_H: "160" OVERLAY_ICON_Y: "74" @@ -263,6 +268,7 @@ esphome: - - - + - name: ${DEVICE_NAME} friendly_name: ${DEVICE_FRIENDLY_NAME} on_boot: @@ -378,6 +384,31 @@ esphome: screen_calibration::isolation = [](bool active) { id(calibration_active) = active; }; screen_calibration::setup(id(home_page)->obj, id(sublabel)->get_lv_font(), id(headline)->get_lv_font(), ${TOUCH_CAL_X_MIN}, ${TOUCH_CAL_X_MAX}, ${TOUCH_CAL_Y_MIN}, ${TOUCH_CAL_Y_MAX}); + # The settings page the screen opens on its own (0.2.44+): the same values ESP Screen Manager + # writes, reachable by holding the top bar. Fonts and hooks only; the page itself is built + # when it opens and thrown away when it closes, so it costs nothing while it is closed. + - lambda: |- + if (!runtime_tiles::enabled) return; + settings_screen::title_font = id(headline)->get_lv_font(); + settings_screen::row_font = id(sublabel_big)->get_lv_font(); + settings_screen::icon_font = id(materialdesign_icons_mini)->get_lv_font(); + settings_screen::store = []() { runtime_tiles::persist_settings(); }; + settings_screen::apply = []() { id(apply_screen_settings).execute(); }; + settings_screen::report = [](const char *key, int32_t value) { runtime_tiles::setting_event(key, value); }; + settings_screen::name_text = []() { return std::string("${DEVICE_FRIENDLY_NAME}"); }; + settings_screen::address_text = []() { return id(wifi_ip).state.empty() ? std::string("No network") : id(wifi_ip).state; }; + settings_screen::firmware_text = []() { return std::string("${SCREEN_FIRMWARE_VERSION}"); }; + settings_screen::link_text = []() { return std::string(esphome::api_is_connected() ? "Connected" : "Not connected"); }; + settings_screen::restart_device = []() { ESP_LOGI("settings", "restart from the screen"); App.safe_reboot(); }; + // A dimmed screen, a calibration or an alert owns the glass; the page waits. + settings_screen::may_open = []() { + return runtime_tiles::enabled && !id(display_dimmed) && !id(calibration_active) && !id(alert_active); + }; + settings_screen::drift = []() { return cyd::touch_guard.distance(); }; + settings_screen::before_open = []() { id(close_cards).execute(); }; + settings_screen::hold_limit = ${SETTINGS_HOLD_DRIFT_PX}; + settings_screen::tap_limit = ${TOUCH_MOVE_LIMIT_PX}; + settings_screen::attach_hold(id(home_page)->obj, ${SETTINGS_HOLD_X}, 0, ${SETTINGS_HOLD_W}, ${SCROLL_Y}); - light.turn_on: id: back_light brightness: !lambda "return screen_settings::current.brightness / 100.0f;" @@ -474,6 +505,19 @@ api: } runtime_tiles::show_detail(index); } + # The settings page from Home Assistant: `page` 0 is its menu, 1-4 open a group straight away + # (Brightness, Night, Screen, This screen) and a negative page closes it and goes back to page 1. + # Handy for support ("look at Screen -> Rotation") and for the render tests. + - action: open_settings + variables: + page: int + then: + - lambda: |- + if (page < 0) { id(go_home).execute(); return; } + id(wake_display).execute(); + settings_screen::open((uint8_t) page); + ESP_LOGI("settings", "page %d open from Home Assistant", page); + # Diagnostics: the climate mode picker and the busy sheet of a tile, without touching the glass or HA. - action: preview_climate_picker then: @@ -557,6 +601,19 @@ interval: return (millis() - id(last_touch_ms)) > (screen_settings::current.standby_seconds * 1000UL); then: - script.execute: dim_display + # Back to page 1 by itself (0.2.44+). A card, a settings page or a second page left open keeps + # showing what someone looked at minutes ago; after this long the screen is a screen again. + # Independent of standby, which may be off or much later. + - if: + condition: + lambda: |- + if (!runtime_tiles::auto_home || id(touch_down) || id(calibration_active) || id(alert_active)) return false; + if (id(display_dimmed)) return false; + bool away = settings_screen::visible() || !id(active_entity).empty() || id(tile_page) != 0 || + (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)); + return away && (millis() - id(last_touch_ms)) > (uint32_t) runtime_tiles::auto_home_seconds * 1000UL; + then: + - script.execute: go_home time: - platform: homeassistant @@ -1008,6 +1065,9 @@ font: - "\U000F059B" # weather-sunset-down - "\U000F01D8" # dots-horizontal - "\U000F1C91" # arrow-oscillating + - "\U000F0379" # monitor + - "\U000F02FD" # information-outline + - "\U000F0709" # restart - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf" id: materialdesign_icons_mini @@ -1088,6 +1148,8 @@ text_sensor: update_interval: 60s - platform: wifi_info ip_address: + # `id` so the settings page on the screen can show the same address (0.2.44+). + id: wifi_ip name: "IP address" entity_category: diagnostic @@ -3547,6 +3609,15 @@ script: - lvgl.widget.hide: climate_detail_overlay - lvgl.widget.hide: climate_mode_overlay + # Everything back to the overview: the settings page, every card and the first page of tiles. + # Used by standby, by the "Back to page 1" timeout and by the Home Assistant action. + - id: go_home + then: + - lambda: 'settings_screen::close();' + - script.execute: close_cards + - lambda: 'id(tile_page) = 0;' + - script.execute: show_tile_page + # ---------- ALERT (HA action show_alert) ---------- # The screen wakes, keeps its normal brightness and shows the card until OK or the # timeout (0: until OK); the standby interval waits on alert_active. A new alert @@ -3564,6 +3635,8 @@ script: then: - lambda: |- if (id(alert_active)) id(alert_report).execute("replaced"); + // An alert is the one thing that outranks the settings page, so that closes first. + settings_screen::close(); const auto alert = screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX}); id(alert_active) = true; id(alert_title_text) = alert.title; @@ -3678,6 +3751,8 @@ script: - lambda: |- id(display_dimmed) = true; id(apply_screen_settings).execute(); + // A settings page behind a dark screen helps nobody; it always closes. + settings_screen::close(); if (screen_settings::current.home_on_standby) { // Runtime detail cards live above the tile grid, so paging alone cannot close them. runtime_tiles::hide_detail(); diff --git a/packages/guition.yaml b/packages/guition.yaml index c747069..44c230f 100644 --- a/packages/guition.yaml +++ b/packages/guition.yaml @@ -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.43" + SCREEN_FIRMWARE_VERSION: "0.2.44" # 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" @@ -67,6 +67,11 @@ substitutions: TILE_W: "218" TILE_H: "108" HEADER_Y: "22" + # Holding the top bar opens the settings page (0.2.44+). The two edge bands stay clear: a swipe + # that starts up there still changes the page. + SETTINGS_HOLD_X: "${EDGE_SWIPE_BAND_PX}" + SETTINGS_HOLD_W: "416" + SETTINGS_HOLD_DRIFT_PX: "32" # Alert overlay (HA action show_alert), 480x480: one card centred on LVGL's top layer. ALERT_CARD_W: "420" @@ -127,6 +132,7 @@ esphome: - - - + - name: ${DEVICE_NAME} friendly_name: ${DEVICE_FRIENDLY_NAME} on_boot: @@ -235,6 +241,31 @@ esphome: if (kind == 'f') id(set_climate_fan_mode).execute(index); else id(set_climate_swing_mode).execute(index); }; + # The settings page the screen opens on its own (0.2.44+): the same values ESP Screen Manager + # writes, reachable by holding the top bar. Fonts and hooks only; the page itself is built + # when it opens and thrown away when it closes, so it costs nothing while it is closed. + - lambda: |- + if (!runtime_tiles::enabled) return; + settings_screen::title_font = id(headline)->get_lv_font(); + settings_screen::row_font = id(sublabel_big)->get_lv_font(); + settings_screen::icon_font = id(materialdesign_icons_mini)->get_lv_font(); + settings_screen::store = []() { runtime_tiles::persist_settings(); }; + settings_screen::apply = []() { id(apply_screen_settings).execute(); }; + settings_screen::report = [](const char *key, int32_t value) { runtime_tiles::setting_event(key, value); }; + settings_screen::name_text = []() { return std::string("${DEVICE_FRIENDLY_NAME}"); }; + settings_screen::address_text = []() { return id(wifi_ip).state.empty() ? std::string("No network") : id(wifi_ip).state; }; + settings_screen::firmware_text = []() { return std::string("${SCREEN_FIRMWARE_VERSION}"); }; + settings_screen::link_text = []() { return std::string(esphome::api_is_connected() ? "Connected" : "Not connected"); }; + settings_screen::restart_device = []() { ESP_LOGI("settings", "restart from the screen"); App.safe_reboot(); }; + // A dimmed screen, a calibration or an alert owns the glass; the page waits. + settings_screen::may_open = []() { + return runtime_tiles::enabled && !id(display_dimmed) && !id(calibration_active) && !id(alert_active); + }; + settings_screen::drift = []() { return cyd::touch_guard.distance(); }; + settings_screen::before_open = []() { id(close_cards).execute(); }; + settings_screen::hold_limit = ${SETTINGS_HOLD_DRIFT_PX}; + settings_screen::tap_limit = ${TOUCH_MOVE_LIMIT_PX}; + settings_screen::attach_hold(id(home_page)->obj, ${SETTINGS_HOLD_X}, 0, ${SETTINGS_HOLD_W}, ${SCROLL_Y}); - light.turn_on: id: back_light brightness: !lambda "return screen_settings::current.brightness / 100.0f;" @@ -337,6 +368,19 @@ api: } runtime_tiles::show_detail(index); } + # The settings page from Home Assistant: `page` 0 is its menu, 1-4 open a group straight away + # (Brightness, Night, Screen, This screen) and a negative page closes it and goes back to page 1. + # Handy for support ("look at Screen -> Rotation") and for the render tests. + - action: open_settings + variables: + page: int + then: + - lambda: |- + if (page < 0) { id(go_home).execute(); return; } + id(wake_display).execute(); + settings_screen::open((uint8_t) page); + ESP_LOGI("settings", "page %d open from Home Assistant", page); + # Diagnostics: the climate mode picker and the busy sheet of a tile, without touching the glass or HA. - action: preview_climate_picker then: @@ -420,6 +464,19 @@ interval: return (millis() - id(last_touch_ms)) > (screen_settings::current.standby_seconds * 1000UL); then: - script.execute: dim_display + # Back to page 1 by itself (0.2.44+). A card, a settings page or a second page left open keeps + # showing what someone looked at minutes ago; after this long the screen is a screen again. + # Independent of standby, which may be off or much later. + - if: + condition: + lambda: |- + if (!runtime_tiles::auto_home || id(touch_down) || id(calibration_active) || id(alert_active)) return false; + if (id(display_dimmed)) return false; + bool away = settings_screen::visible() || !id(active_entity).empty() || id(tile_page) != 0 || + (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)); + return away && (millis() - id(last_touch_ms)) > (uint32_t) runtime_tiles::auto_home_seconds * 1000UL; + then: + - script.execute: go_home time: - platform: homeassistant @@ -870,6 +927,9 @@ font: - "\U000F059B" # weather-sunset-down - "\U000F01D8" # dots-horizontal - "\U000F1C91" # arrow-oscillating + - "\U000F0379" # monitor + - "\U000F02FD" # information-outline + - "\U000F0709" # restart - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf" id: materialdesign_icons_mini @@ -955,6 +1015,8 @@ text_sensor: update_interval: 60s - platform: wifi_info ip_address: + # `id` so the settings page on the screen can show the same address (0.2.44+). + id: wifi_ip name: "IP address" entity_category: diagnostic @@ -3502,6 +3564,15 @@ script: - lvgl.widget.hide: climate_detail_overlay - lvgl.widget.hide: climate_mode_overlay + # Everything back to the overview: the settings page, every card and the first page of tiles. + # Used by standby, by the "Back to page 1" timeout and by the Home Assistant action. + - id: go_home + then: + - lambda: 'settings_screen::close();' + - script.execute: close_cards + - lambda: 'id(tile_page) = 0;' + - script.execute: show_tile_page + # ---------- ALERT (HA action show_alert) ---------- # The screen wakes, keeps its normal brightness and shows the card until OK or the # timeout (0: until OK); the standby interval waits on alert_active. A new alert @@ -3519,6 +3590,8 @@ script: then: - lambda: |- if (id(alert_active)) id(alert_report).execute("replaced"); + // An alert is the one thing that outranks the settings page, so that closes first. + settings_screen::close(); const auto alert = screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX}); id(alert_active) = true; id(alert_title_text) = alert.title; @@ -3637,6 +3710,8 @@ script: - lambda: |- id(display_dimmed) = true; id(apply_screen_settings).execute(); + // A settings page behind a dark screen helps nobody; it always closes. + settings_screen::close(); if (screen_settings::current.home_on_standby) { // Runtime detail cards live above the tile grid, so paging alone cannot close them. runtime_tiles::hide_detail(); diff --git a/screen_manager/CHANGELOG.md b/screen_manager/CHANGELOG.md index 467a72d..4211f95 100644 --- a/screen_manager/CHANGELOG.md +++ b/screen_manager/CHANGELOG.md @@ -1,3 +1,14 @@ +## 0.2.52 (firmware 0.2.44) + +Change a screen's settings on the screen itself. + +- **A settings page on the glass.** Hold the top bar of the overview for about a second and a half -- a blue line fills along the top edge while you hold -- and the screen opens its own settings: Brightness, Night, Screen and This screen. Toggles flip on a tap, numbers and times have `-` and `+` (hold them and a time walks whole hours), and a choice like the clock cycles in a chip. A 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. +- **Rotation and swiping are on it too.** Everything you would want to change standing in front of the panel: brightness, standby, night mode and its hours, the 12/24-hour clock, swiping between pages and, on boards that can, the rotation. Tiles and the top bar stay in the editor, where there is a mouse. +- **This screen.** Its name, IP address, firmware version, whether Home Assistant is connected, and a Restart that asks once before it does it. +- **Back to page 1 by itself.** New setting, on by default at two minutes: a card someone opened, or a second page they left behind, closes by itself after that long without a touch. Until now that only happened when the screen went into standby, so with standby off or far away a card could stay up all day. Set the time in ESP Screens or on the screen, or switch it off there. +- **A Settings tile.** Next to the clock card there is now a `Settings` card you can put on a page, for a screen where holding the bar is not obvious. Needs firmware 0.2.44. +- **For Home Assistant.** `esphome._open_settings` opens the page (0 menu, 1 Brightness, 2 Night, 3 Screen, 4 This screen, -1 closes it and goes back to page 1), and the Claude skill explains all of it. + ## 0.2.51 (firmware 0.2.43) Ask Claude in Home Assistant to put something on a screen. diff --git a/screen_manager/DOCS.md b/screen_manager/DOCS.md index ae3a441..2945c53 100644 --- a/screen_manager/DOCS.md +++ b/screen_manager/DOCS.md @@ -26,3 +26,7 @@ After pairing via the HA ESPHome integration, choose the tiles in ESP Screens. **Tile settings** has click behavior, larger values and mini-sliders. **Screen settings** has brightness and standby. **Inspector** helps with missing attributes or an offline screen. New cards require firmware 0.2.0. + +The screen carries the same settings itself (firmware 0.2.44 or newer): hold the top bar for +about a second and a half, or put the built-in **Settings** card on a page. What is changed +there appears here within a second, and the other way around. diff --git a/screen_manager/app/claude_skill.py b/screen_manager/app/claude_skill.py index 502e19f..1718866 100644 --- a/screen_manager/app/claude_skill.py +++ b/screen_manager/app/claude_skill.py @@ -14,7 +14,8 @@ import zipfile 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, SLOTS_PER_PAGE, TILE_BACKGROUNDS, TILE_EVENTS, TILE_RESULT_EVENT) + CONTROLS, DISPLAYS, MAX_PAGES, SETTINGS_PAGE_MIN_FIRMWARE, SLOTS_PER_PAGE, TILE_BACKGROUNDS, + TILE_EVENTS, TILE_RESULT_EVENT) NAME = 'esp-screens' # claude.ai accepts at most 200 characters; Claude Code picks the skill by this sentence. @@ -298,6 +299,23 @@ actions: ``` Replace the person, light, window and screen with the real entity IDs (ask which screens when there are several), and leave out conditions the user did not ask for. + +## The settings page on the screen itself + +Firmware {SETTINGS_PAGE_MIN_FIRMWARE} or newer carries a settings page the user can open at the panel: hold the top bar (the strip with the screen's name and the clock) for about a second and a half, until the blue line along the top edge is full. A screen can also carry a tile for it: put the entity `screen.settings` on a screen like any other tile. + +It holds Brightness, Night, Screen (clock, going back to page 1, swiping, rotation) and This screen (name, address, firmware, whether Home Assistant is connected, and Restart). A change there is saved on the screen and shows up in ESP Screens within a second, exactly like a change made from Home Assistant. + +You can open it for someone who is standing at the panel: + +```yaml +actions: + - action: esphome.kitchen_screen_open_settings + data: + page: 1 +``` + +`page` 0 is the menu, 1 Brightness, 2 Night, 3 Screen, 4 This screen, and -1 closes the page and puts the screen back on page 1. Use it to walk someone through a setting ("I opened Brightness on the kitchen screen"), not to change something yourself: for that, use the entities above, which do the same thing without anyone at the panel. ''' def status(directory): diff --git a/screen_manager/app/core.py b/screen_manager/app/core.py index 49afc5b..c42ae34 100644 --- a/screen_manager/app/core.py +++ b/screen_manager/app/core.py @@ -11,15 +11,17 @@ import tile_icons DOMAINS = frozenset('light switch input_boolean scene script climate vacuum fan cover sensor binary_sensor input_select select number input_number weather media_player button input_button sun timer person screen'.split()) # Built-in cards without a Home Assistant entity; firmware 0.2.14+ renders them. -BUILTIN = {'screen.clock': 'Clock'} +BUILTIN = {'screen.clock': 'Clock', 'screen.settings': 'Settings'} NEW_DOMAINS = frozenset('sun timer person screen'.split()) 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.43' +FIRMWARE_VERSION = '0.2.44' # 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' 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']) @@ -165,7 +167,16 @@ SETTING_RULES = { 'home_on_standby': (False, None, None), 'swipe_pages': (False, None, None), 'rotation': (0, 0, 270), + # Back to page 1 by itself (firmware 0.2.44+): closes an open card and any page but the first + # after this many seconds without a touch. Older firmware ignores both keys. + 'auto_home': (True, None, None), + 'auto_home_seconds': (120, 30, 3600), } +# Firmware before 0.2.44 accepts a `settings` object with exactly its own eleven keys and refuses any +# other size, so everything added after it travels as its own key in the layout message. Old firmware +# ignores a key it does not know; a new screen with an old add-on keeps what it saved itself. +# docs/SETTINGS.md walks through adding one. +SETTINGS_BESIDE_BLOCK = ('swipe_pages', 'rotation', 'auto_home', 'auto_home_seconds') def validate_settings(data): if not isinstance(data, dict) or set(data) - SETTING_RULES.keys(): @@ -250,6 +261,8 @@ def validate_header(data): def min_firmware(layout): """Oldest firmware that still accepts this layout; None when any version works.""" + if any(t['entity'] == 'screen.settings' for t in layout['tiles']): + return (0, 2, 44) if any(t.get('options', {}).get('background') == 'none' for t in layout['tiles']): return (0, 2, 16) if any(t['entity'].split('.')[0] in NEW_DOMAINS for t in layout['tiles']): diff --git a/screen_manager/app/server.py b/screen_manager/app/server.py index 37d6029..0ca5353 100644 --- a/screen_manager/app/server.py +++ b/screen_manager/app/server.py @@ -15,7 +15,7 @@ import tile_icons from updates import Updater from aiohttp import ClientSession, ClientTimeout, WSMsgType, web -from core import BROADCAST_EVENTS, BUILTIN, 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, header_items, inbox_prefix, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings import header_bar from zoneinfo import ZoneInfo @@ -599,8 +599,12 @@ class Manager: if 'pages' in layout: message['pages'] = layout['pages'] if 'settings' in layout: - message['settings'] = {k:v for k,v in layout['settings'].items() if k not in ('swipe_pages','rotation')} + # `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} message['swipe_pages'] = layout['settings'].get('swipe_pages',False) + message['auto_home'] = layout['settings'].get('auto_home',True) + message['auto_home_seconds'] = layout['settings'].get('auto_home_seconds',120) if screen.get('board')=='guition': message['rotation'] = layout['settings'].get('rotation',0) # The revision the screen echoes on every ping (firmware 0.2.33+; older firmware ignores it). diff --git a/screen_manager/app/static/app.js b/screen_manager/app/static/app.js index 234c580..a950dd0 100644 --- a/screen_manager/app/static/app.js +++ b/screen_manager/app/static/app.js @@ -92,6 +92,8 @@ const settingDefinitions = [ // 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], ]; @@ -647,6 +649,7 @@ function iconNamed(name) { // What the firmware draws without a choice: Home Assistant's own icon, else the domain icon. function automaticIcon(id) { const icons = inventory.icons, entity = inventory.entities.find((e) => e.id === id), domain = id.split(".")[0]; + if (icons.builtin?.[id]) return icons.builtin[id]; if (entity?.icon) return entity.icon; if (domain === "weather") return icons.weather[entity?.state] || icons.weather.partlycloudy; if (domain === "sun") return icons.sun[entity?.state] || icons.sun.below_horizon; diff --git a/screen_manager/app/static/tile-icons.woff b/screen_manager/app/static/tile-icons.woff index a4caa368a0469c5c204ff59a7962c9159e9063e1..05b2e52be11837b4221ca6ef4240659ffa5b29bd 100644 GIT binary patch delta 15273 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ȤP=|koQk;%y&QkYR zsyb9m(-~jnwzoZx_WoA-Mx(%}9HH@zL6rineB@Rk?7 z0DCQ3fUWt<7jXlnU!=LMuNv`| z&F)rPo}D3BzsqSA$W54ZOQrx6YXBjm@x*eFS7U0PI>JwUf2-$EH$|=%h}Z=O=4z6z z^1p(9>MQrd&nS9;_~5VmU^Si!?>Njnt6>b&zqizp3qtF=`XNGf6fl_$)g*x4?rkMk z$D37+&$O#S*McMt8l)}aCP;3FFI?-nU5=+D--xv${ELp0`48+5T z;?**w38p-#R!~=)5qQXKzrC>4q%V`60J>u!(&K(i(TcP9Mv;WF-G&Wz@6J%M&fyGg zD2dOaQPIs{gRg17dHYx#(^oobOu?4#O*eQ6CIHKCKRyl;J^UWmlqi#u@J*@ z(UFr&)ZAY#y5^#~bwgw2ah8^*2dWv>GSW>^z~l%OYP;cyj4udo4Nbg9&v8Nc4IK&^ z;&HRa5obR0vKwJDiWG(XhOUhh?t6waLJA}!cQvVhi-Rc$zF;Sy!!;N**r?Z`mOTsg z7W|ZC+=!jd5xA$4s3!s^sHaI}-ab9Gtz-pA9>tHCk9>QzirkJE;`RQaZO6T31{cd^ zLkLmB@USXc5Kl9c@E*7{MQKch(vSMp7Bq!~7body-x@0QI9-3dB0`#_HCC_lD$Gfn zoJBY6Zodc9yk<_DB?h?zKQ9P6zMud569r5SaEEs2E7(YOTnz;iripcHLcLh-qX`eP z3!LnJH9<0jJ&-_pdqeih^o0aMO_pfy3wF@0oIkGOzf>1iD|GXDH5kh9n6;Nkc*Lt|- znAxCYwr8x+nC0tyecw5SgwuZdY`mDIBc+=0H7&hx`WZx8P6MCO7aCxz!(Hwo3u$%pt5{?vof~ z1U;sOemnzG8Ma(*$rxGLrVcPWgt{*@Vx<|M3ftOEQ$L%P=WR=IsMwQhTg6%>K5C9P zk2}%%x-TacB9O7k4hn2~o;83wC{zqORRLNIF=E3wEP=7We`| zC-tfn2C#IK(c_yd=Z6U^ASJ7ZuDp~!Nn4$fjDin^=MYHebk_$ca zm*0guRla=#bO{6OcBPE3grF;cf4TAz;Ju=y&PN?f{YuMUdN`l#8Yx>7dI+pZf=J6K zMx0jt$zVs&eFjM4M*Cj|U|udJJ@MEEoPr;Oh&$J7J4MPy9@>eA1~}zURQSuxQCA>? z1J=QXFVU#M5ZJNHXNA^?A|UqPzvaV9?#5|&>JE)xcbIlD1o9Ro(4?i{Mp@J*zeI%3 zBTTk1F)=Lfg;`FD?}X^W!8B~SNCk2RoM!Ktr^NUEP4s?PEhZeiP7U?urL>&KNX0wjkco4;n+0;@#@;kwYk)Zy z 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 delta 15040 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 +#include + +using namespace settings_screen; + +// The row of a page by its label, so a test says what it means. +static const Row &row_named(const Page &page, const std::string &label) { + for (uint8_t i = 0; i < page.count; ++i) + if (label == page.rows[i].label) return page.rows[i]; + assert(false); + return page.rows[0]; +} + +int main() { + // ---- the duration ladder: seconds low down, quarters of an hour up top, and symmetric ---- + assert(ladder_step(60) == 30 && ladder_step(600) == 60 && ladder_step(1800) == 300); + assert(ladder_step(3600) == 900 && ladder_step(36000) == 1800); + Row timeout = duration("After", nullptr, nullptr, 30, 3600); + assert(stepped(timeout, 120, 1) == 150 && stepped(timeout, 120, -1) == 90); + assert(stepped(timeout, 300, 1) == 360 && stepped(timeout, 360, -1) == 300); + assert(stepped(timeout, 300, -1) == 270 && stepped(timeout, 270, 1) == 300); + assert(stepped(timeout, 30, -1) == 30 && stepped(timeout, 3600, 1) == 3600); + assert(at_end(timeout, 30, -1) && at_end(timeout, 3600, 1) && !at_end(timeout, 120, -1)); + + // ---- a number stops at its ends, a moment walks around midnight ---- + Row percent = number("Brightness", nullptr, nullptr, 5, 100, 5, "%"); + assert(stepped(percent, 100, 1) == 100 && stepped(percent, 5, -1) == 5 && stepped(percent, 95, 1) == 100); + Row start = moment("Starts", nullptr, nullptr); + assert(stepped(start, 1320, 1) == 1335 && stepped(start, 1425, 1) == 0 && stepped(start, 0, -1) == 1425); + assert(!at_end(start, 0, -1)); + // Holding the key walks whole hours, and first lands on the hour it is in. + assert(stepped(start, 1320, 1, true) == 1380 && stepped(start, 1320, -1, true) == 1260); + assert(stepped(start, 1327, 1, true) == 1380 && stepped(start, 1327, -1, true) == 1320); + assert(stepped(start, 1380, 1, true) == 1440 % 1440); // 23:00 + 1 h wraps to 00:00 + assert(stepped(start, 0, -1, true) == 1380); // 00:00 - 1 h is 23:00 + + // ---- texts ---- + assert(duration_text(45) == "45 sec" && duration_text(60) == "1 min" && duration_text(120) == "2 min"); + assert(duration_text(3600) == "1 h" && duration_text(5400) == "1 h 30" && duration_text(86400) == "24 h"); + assert(moment_text(1320, true) == "22:00" && moment_text(0, true) == "00:00"); + assert(moment_text(1320, false) == "10:00 PM" && moment_text(0, false) == "12:00 AM"); + assert(moment_text(750, false) == "12:30 PM" && moment_text(60, false) == "1:00 AM"); + + // ---- rows read and write the real settings ---- + screen_settings::current = screen_settings::Settings{}; + const Page &light = pages[1]; + const Row &brightness = row_named(light, "Brightness"); + assert(value_text(brightness) == "100%"); + brightness.write(40); + assert(screen_settings::current.brightness == 40 && value_text(brightness) == "40%"); + // Lowering the normal brightness pulls the two dim levels down with it, exactly like the add-on. + screen_settings::current.standby_brightness = 60; + brightness.write(30); + assert(screen_settings::current.standby_brightness == 30); + const Row &standby = row_named(light, "Standby brightness"); + standby.write(90); + assert(screen_settings::current.standby_brightness == 30); // never above the normal brightness + assert(screen_settings::current.valid()); + + const Page &screen = pages[3]; + const Row &clock = row_named(screen, "Clock"); + assert(clock.option_count == 2 && value_text(clock) == "24 hour"); + clock.write(0); + assert(screen_settings::current.clock_24h == 0 && value_text(clock) == "12 hour"); + const Row &home = row_named(screen, "Back to page 1"); + home.write(0); + assert(auto_home == 0 && value_text(home) == "Off"); + const Row &rotate = row_named(screen, "Rotation"); + rotation_supported = false; + assert(!visible_row(rotate)); // a board that cannot turn never shows the row + rotation_supported = true; + assert(visible_row(rotate)); + rotate.write(2); + assert(rotation == 180 && value_text(rotate) == "180°"); + rotate.write(3); + assert(rotation == 270); + rotate.write(0); + assert(rotation == 0); + + // ---- every page hangs together: menu rows open a real page, controls can be worked ---- + assert(PAGE_COUNT == 5); + for (uint8_t p = 0; p < PAGE_COUNT; ++p) { + const Page &page = pages[p]; + assert(page.title && page.count && page.count <= 8); + for (uint8_t i = 0; i < page.count; ++i) { + const Row &row = page.rows[i]; + assert(row.label && *row.label); + if (row.kind == Kind::page) assert(row.opens > 0 && row.opens < PAGE_COUNT && *row.icon); + if (row.kind == Kind::toggle || row.kind == Kind::choice || row.kind == Kind::number || + row.kind == Kind::duration || row.kind == Kind::moment) + assert(row.read && row.write); + if (row.kind == Kind::number) assert(row.step > 0 && row.high > row.low); + if (row.kind == Kind::duration) assert(row.high > row.low); + if (row.kind == Kind::choice) assert(row.options && row.option_count); + if (row.kind == Kind::info) assert(row.text); + if (row.kind == Kind::action) assert(row.run && *row.icon); + } + } + // Every page is reachable from the menu. + bool reached[PAGE_COUNT] = {true}; + for (uint8_t i = 0; i < pages[0].count; ++i) reached[pages[0].rows[i].opens] = true; + for (bool page : reached) assert(page); + + // ---- how many rows fit, and when the pager appears ---- + bool paged = false; + assert(fitting_rows(180, 34, 4, 26, 4, paged) == 4 && !paged); // CYD: four rows, no pager + assert(fitting_rows(180, 34, 4, 26, 6, paged) == 4 && paged); // six rows: 4 + 2 + assert(fitting_rows(372, 52, 8, 40, 6, paged) == 6 && !paged); // Guition: the whole group at once + assert(fitting_rows(372, 52, 8, 40, 9, paged) == 5 && paged); + assert(fitting_rows(40, 34, 4, 26, 5, paged) >= 1); // never zero rows + + // ---- a change is stored, applied and reported once ---- + static int stored = 0, applied = 0; + static std::string last_key; + static int32_t last_value = 0; + store = [] { ++stored; }; + apply = [] { ++applied; }; + report = [](const char *key, int32_t value) { last_key = key; last_value = value; }; + row_named(light, "Auto standby").write(0); + 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); +} diff --git a/tests/test_tile_icons.py b/tests/test_tile_icons.py index 675dd83..39aaceb 100644 --- a/tests/test_tile_icons.py +++ b/tests/test_tile_icons.py @@ -48,7 +48,15 @@ class IconSetTests(unittest.TestCase): for domains, code in re.findall(r'if \((d == "\w+"(?: \|\| d == "\w+")*)\) return "\\U000(F[0-9A-F]{4})";', body): for domain in re.findall(r'"(\w+)"', domains): native[domain] = code - self.assertEqual(native, {domain: tile_icons.GLYPHS[name] for domain, name in tile_icons.DEFAULTS.items()}) + # The built-in cards pick per entity, not per domain: a clock or the settings page. + settings, clock = re.search( + r'if \(d == "screen"\) return tile\.is_settings\(\) \? "\\U000(F[0-9A-F]{4})" : "\\U000(F[0-9A-F]{4})";', body).groups() + builtin = {entity: tile_icons.GLYPHS[name] for entity, name in tile_icons.BUILTIN_TILES.items()} + self.assertEqual({'screen.settings': settings, 'screen.clock': clock}, builtin) + self.assertEqual(tile_icons.editor()['builtin'], builtin) + self.assertEqual(tile_icons.GLYPHS[tile_icons.DEFAULTS['screen']], clock) + expected = {domain: tile_icons.GLYPHS[name] for domain, name in tile_icons.DEFAULTS.items() if domain != 'screen'} + self.assertEqual(native, expected) self.assertIn(f'return "\\U000{tile_icons.GLYPHS[tile_icons.FALLBACK]}";', body) weather = HEADER.split('inline const char *weather_icon(const std::string &condition) {', 1)[1].split('\n}', 1)[0] for state, name in tile_icons.WEATHER.items():