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 a4caa36..05b2e52 100644 Binary files a/screen_manager/app/static/tile-icons.woff and b/screen_manager/app/static/tile-icons.woff differ diff --git a/screen_manager/app/tile_icons.py b/screen_manager/app/tile_icons.py index d935057..deba148 100644 --- a/screen_manager/app/tile_icons.py +++ b/screen_manager/app/tile_icons.py @@ -220,6 +220,10 @@ FIXED = ( ('dots-horizontal', 'F01D8'), # Swing row on the Guition climate card, Home Assistant's icon for swing modes (firmware 0.2.40+). ('arrow-oscillating', 'F1C91'), + # The settings page on the screen itself (firmware 0.2.44+): its menu and the restart row. + ('monitor', 'F0379'), + ('information-outline', 'F02FD'), + ('restart', 'F0709'), ) # Pickable icons by name, and every glyph the firmware fonts contain. @@ -230,6 +234,8 @@ GLYPHS = {**{name: codepoint for name, (codepoint, _) in ICONS.items()}, **dict( DEFAULTS = {'light': 'lightbulb', 'climate': 'air-conditioner', 'vacuum': 'robot-vacuum', 'fan': 'fan', 'cover': 'window-shutter', 'scene': 'sofa', 'script': 'sofa', 'sensor': 'gauge', 'binary_sensor': 'gauge', 'timer': 'timer-outline', 'person': 'account', 'screen': 'clock-outline'} +# The cards the screen brings itself: one icon per entity, not per domain. +BUILTIN_TILES = {'screen.clock': 'clock-outline', 'screen.settings': 'cog'} FALLBACK = 'power' CONTROL_GLYPHS = ('play', 'pause', 'stop', 'skip-next', 'skip-previous', 'volume-high', 'volume-off', 'arrow-up', 'arrow-down', 'arrow-expand-horizontal', 'arrow-collapse-horizontal', 'home-map-marker', 'plus', 'minus', 'chevron-left', @@ -250,6 +256,7 @@ def editor(): return {'groups': [{'label': group, 'icons': [{'name': name, 'cp': codepoint, 'label': label} for name, codepoint, label in icons]} for group, icons in GROUPS], 'defaults': {domain: GLYPHS[name] for domain, name in DEFAULTS.items()}, 'fallback': GLYPHS[FALLBACK], + 'builtin': {entity: GLYPHS[name] for entity, name in BUILTIN_TILES.items()}, 'weather': {state: GLYPHS[name] for state, name in WEATHER.items()}, 'sun': {'above_horizon': GLYPHS['weather-sunset-down'], 'below_horizon': GLYPHS['weather-sunset-up']}, # Glyphs of the direct controls on wide cards, so the mockup previews them. diff --git a/screen_manager/config.yaml b/screen_manager/config.yaml index 488d191..0295aa0 100644 --- a/screen_manager/config.yaml +++ b/screen_manager/config.yaml @@ -1,5 +1,5 @@ name: ESP Screen Manager -version: "0.2.51" +version: "0.2.52" slug: esp_screen_manager description: Choose tiles for your CYD and Guition touchscreen, without reflashing. url: https://github.com/MaxGramser/homeassistant_espscreen diff --git a/tests/test_claude_skill.py b/tests/test_claude_skill.py index aeea3bd..60f594e 100644 --- a/tests/test_claude_skill.py +++ b/tests/test_claude_skill.py @@ -72,7 +72,7 @@ class SkillText(unittest.TestCase): def test_every_yaml_example_parses(self): import yaml blocks = re.findall(r'```yaml\n(.*?)```', claude_skill.text(), re.S) - self.assertEqual(len(blocks), 7) + self.assertEqual(len(blocks), 8) parsed = [yaml.safe_load(block) for block in blocks] # Tiles first (0.2.51): putting one on a screen, and ordering a page. self.assertEqual(parsed[0]['actions'][0]['event'], 'esp_screens_add_tile') diff --git a/tests/test_header_bar.py b/tests/test_header_bar.py index fc84bdd..d572232 100644 --- a/tests/test_header_bar.py +++ b/tests/test_header_bar.py @@ -233,7 +233,7 @@ class ParityTests(unittest.TestCase): self.assertIn('set_hidden(p,!(awake() && now.is_valid()));', second_hand) self.assertIn('part_line(w,18,w.points+28,2,w.hand_width)', second_hand) tick = TILES.split('inline void tick() {', 1)[1] - self.assertIn('t.builtin() && t.display=="analog")second_hand(w,now);', tick) + self.assertIn('t.is_clock() && t.display=="analog")second_hand(w,now);', tick) HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None if HAS_AIOHTTP: diff --git a/tests/test_portal.py b/tests/test_portal.py index 477ecd0..d06edb7 100644 --- a/tests/test_portal.py +++ b/tests/test_portal.py @@ -8,7 +8,7 @@ import tempfile import unittest sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app')) -from core import discover, installation_yaml, packets, state_message, validate_layout, validate_settings +from core import SETTINGS_BESIDE_BLOCK, discover, installation_yaml, packets, state_message, validate_layout, validate_settings class ProtocolTests(unittest.TestCase): def test_unicode_chunks_and_limits(self): @@ -125,11 +125,11 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase): with self.assertRaises(ValueError): fresh.save('text.screen',{**saved,'settings':{'brightness':0}}) self.assertEqual(path.read_bytes(),before) await fresh.sync_one('text.screen',saved) - self.assertEqual(fresh.ha.messages[0][1]['settings'],{k:v for k,v in settings.items() if k not in ('swipe_pages','rotation')}) + self.assertEqual(fresh.ha.messages[0][1]['settings'],{k:v for k,v in settings.items() if k not in SETTINGS_BESIDE_BLOCK}) await fresh.sync_one('text.screen',saved) self.assertEqual(len(fresh.ha.messages),2) await fresh.sync_one('text.screen',saved,True) - self.assertEqual(fresh.ha.messages[2][1]['settings'],{k:v for k,v in settings.items() if k not in ('swipe_pages','rotation')}) + self.assertEqual(fresh.ha.messages[2][1]['settings'],{k:v for k,v in settings.items() if k not in SETTINGS_BESIDE_BLOCK}) async def test_reorder_aborts_old_batch(self): with tempfile.TemporaryDirectory() as temp: diff --git a/tests/test_settings_page.py b/tests/test_settings_page.py new file mode 100644 index 0000000..c65d6a9 --- /dev/null +++ b/tests/test_settings_page.py @@ -0,0 +1,112 @@ +"""The settings page on the screen itself (app 0.2.52, firmware 0.2.44). + +Two halves have to agree without ever seeing each other: the row table in settings_screen.h writes keys +that only mean something if ESP Screens knows them, and the eleven-key `settings` block older firmware +insists on may never grow. Both are checked here, against the real files. +""" +import importlib.util +import json +import re +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'screen_manager/app')) +import test_portal +from core import (BUILTIN, SETTING_RULES, SETTINGS_BESIDE_BLOCK, entity_id, min_firmware, validate_layout, + validate_settings) + +SCREEN = (ROOT / 'components/smart_display/settings_screen.h').read_text() +RUNTIME = (ROOT / 'components/smart_display/runtime_tiles.h').read_text() +# The keys the firmware's own block still carries; everything newer travels as its own key. +FROZEN = {'standby_enabled', 'standby_seconds', 'brightness', 'standby_brightness', 'night_enabled', + 'night_start', 'night_end', 'night_brightness', 'show_clock', 'clock_24h', 'home_on_standby'} +assert FROZEN == set(SETTING_RULES) - set(SETTINGS_BESIDE_BLOCK) + + +class SettingsPage(unittest.TestCase): + def test_every_key_the_screen_reports_is_a_setting_the_app_knows(self): + keys = set(re.findall(r'changed\("(\w+)"', SCREEN)) + self.assertTrue(keys, 'no rows found in settings_screen.h') + self.assertLessEqual(keys, set(SETTING_RULES), 'the screen writes a key ESP Screens would drop') + + def test_the_frozen_settings_block_keeps_exactly_eleven_keys(self): + # Firmware before 0.2.44 refuses a `settings` object of any other size; that is why the newer + # settings travel as their own keys. + self.assertEqual(re.search(r'if \(obj\.size\(\) != (\d+)\) return false;', RUNTIME).group(1), '11') + self.assertEqual(FROZEN, set(SETTING_RULES) & FROZEN) + + def test_new_settings_default_and_validate(self): + clean = validate_settings({}) + self.assertEqual(clean['auto_home'], True) + self.assertEqual(clean['auto_home_seconds'], 120) + self.assertEqual(validate_settings({'auto_home_seconds': 30})['auto_home_seconds'], 30) + for bad in ({'auto_home_seconds': 29}, {'auto_home_seconds': 3601}, {'auto_home': 1}, + {'auto_home_seconds': True}): + with self.assertRaises(ValueError): + validate_settings(bad) + + def test_the_settings_tile_is_a_builtin_that_needs_new_firmware(self): + self.assertIn('screen.settings', BUILTIN) + self.assertTrue(entity_id('screen.settings')) + self.assertEqual(min_firmware({'tiles': [{'entity': 'screen.settings'}]}), (0, 2, 44)) + self.assertLess(min_firmware({'tiles': [{'entity': 'screen.clock'}]}), (0, 2, 44)) + layout = validate_layout({'title': 'Home', 'tiles': [{'entity': 'screen.settings', 'name': 'Settings'}]}) + self.assertEqual(layout['tiles'][0]['entity'], 'screen.settings') + + +@unittest.skipUnless(importlib.util.find_spec('aiohttp'), 'Run using .venv-portal/bin/python for server tests') +class LayoutMessage(unittest.IsolatedAsyncioTestCase): + async def test_the_new_keys_ride_beside_the_frozen_block(self): + with tempfile.TemporaryDirectory() as temp: + manager = test_portal.ManagerTests().setup_manager(Path(temp) / 'screens.json') + layout = {'title': 'Home', 'tiles': [{'entity': 'light.a'}], + 'settings': validate_settings({'auto_home': False, 'auto_home_seconds': 600})} + manager.save('text.screen', layout) + message = manager.layout_message('text.screen', manager.layouts['text.screen'], {}) + self.assertEqual(set(message['settings']), FROZEN, 'older firmware refuses any other size') + self.assertEqual(message['auto_home'], False) + self.assertEqual(message['auto_home_seconds'], 600) + # A screen that never stored settings still gets the defaults, not a missing key. + plain = manager.layout_message('text.screen', {**manager.layouts['text.screen'], + 'settings': validate_settings({})}, {}) + self.assertEqual((plain['auto_home'], plain['auto_home_seconds']), (True, 120)) + self.assertLess(len(json.dumps(message)), 4096, 'the screen refuses a message over 4 KB') + + +class Firmware(unittest.TestCase): + """The board profiles have to carry the page, its way in, and the timer that closes it.""" + + def setUp(self): + self.profiles = {name: (ROOT / name).read_text() + for name in ('home-like-2432s028.yaml', 'guition-4848s040.yaml')} + + def test_both_boards_wire_the_page_and_its_gesture(self): + for name, text in self.profiles.items(): + self.assertIn('settings_screen::attach_hold', text, name) + self.assertIn('settings_screen::report', text, name) + self.assertIn('settings_screen::may_open', text, name) + self.assertIn('- action: open_settings', text, name) + self.assertIn('- id: go_home', text, name) + self.assertIn('runtime_tiles::auto_home', text, name) + self.assertIn('SCREEN_FIRMWARE_VERSION: "0.2.44"', text, name) + + def test_the_hold_strip_covers_the_top_bar_and_no_more(self): + for name, text in self.profiles.items(): + values = dict(re.findall(r'^ (\w+): "([^"]*)"', text, re.M)) + width, scroll = int(values['DISPLAY_W']), int(values['SCROLL_Y']) + x = int(values['SETTINGS_HOLD_X'].replace('${EDGE_SWIPE_BAND_PX}', values.get('EDGE_SWIPE_BAND_PX', '0'))) + hold = values['SETTINGS_HOLD_W'].replace('${DISPLAY_W}', values['DISPLAY_W']) + self.assertLessEqual(x + int(hold), width, name) + self.assertGreater(scroll, 20, name) + if 'EDGE_SWIPE_BAND_PX' in values: + # The page swipe owns both edge bands; the hold may not start there. + band = int(values['EDGE_SWIPE_BAND_PX']) + self.assertGreaterEqual(x, band, name) + self.assertLessEqual(x + int(hold), width - band, name) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_settings_screen.cpp b/tests/test_settings_screen.cpp new file mode 100644 index 0000000..7180cef --- /dev/null +++ b/tests/test_settings_screen.cpp @@ -0,0 +1,125 @@ +#define SETTINGS_SCREEN_TEST +#include "../components/smart_display/settings_screen.h" +#include +#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():