From bb25353089248c3294c80f7734fea9ce1506e65c Mon Sep 17 00:00:00 2001 From: Max Gramser Date: Fri, 18 Sep 2026 08:44:44 +0200 Subject: [PATCH] Forty-eight tiles, a tile over the whole page and tiles that go to a page (0.2.74) Max asked for many more tiles, a tile that takes the whole page as a light switch you push without looking, and tiles that go to a page. The limit is one tile per slot of the eight pages, 48, and the firmware keeps its tile list on the heap sized to the layout (PSRAM on a Guition). A tile's size can be `full`: one big button that lights up in the state colour while on, or the double-width card's head with its small slider, direct controls or graph at the bottom and the value the double-width card had no room for in between; the clock, the forecast (with an hours strip) and the sun path fill the page. Navigation tiles are the built-in entities screen.page_1 to screen.page_8: an arrow or a chosen icon, the name and "Page n", a tap opens that page. The app sends the three only to firmware 0.2.62+, the editor (the Vue port of this round by the editor session, 00c9a4e) offers Normal / Double-width / Full page and "Goes to page", and Claude in Home Assistant gets size: full and the page tiles. Built-in tiles now send a chosen icon as its codepoint. Found and fixed by the new host harness before release: the one-second tick moved the analog clock's second hand (part 18) on a full forecast's hour label during a page switch and corrupted it. Firmware 0.2.62; both C++ and Python suites, host harness 37/37 on both boards, hardware pass on both bench boards in docs/TEST_RESULTS_0274.md. Co-Authored-By: Claude Fable 5.1 --- README.md | 10 +- README_EXTENDED.md | 21 +- components/smart_display/runtime_model.h | 71 +++++-- components/smart_display/runtime_tiles.h | 253 ++++++++++++++++++++--- docs/RELEASING.md | 26 +++ docs/TEST_RESULTS_0274.md | 111 ++++++++++ docs/images/cyd-full-climate.png | Bin 0 -> 11687 bytes docs/images/cyd-full-light.png | Bin 0 -> 9437 bytes docs/images/cyd-full-menu.png | Bin 0 -> 15859 bytes docs/images/guition-full-climate.png | Bin 0 -> 21189 bytes docs/images/guition-full-fan.png | Bin 0 -> 16458 bytes docs/images/guition-full-light.png | Bin 0 -> 17032 bytes docs/images/guition-full-menu.png | Bin 0 -> 27115 bytes guition-4848s040.yaml | 41 +++- home-like-2432s028.yaml | 41 +++- packages/cyd.yaml | 41 +++- packages/guition.yaml | 41 +++- screen_manager/CHANGELOG.md | 10 + screen_manager/README.md | 2 +- screen_manager/app/claude_skill.py | 14 +- screen_manager/app/core.py | 156 ++++++++++---- screen_manager/app/tile_icons.py | 10 +- screen_manager/config.yaml | 2 +- tests/test_full_page_tiles.py | 154 ++++++++++++++ tests/test_portal.py | 2 +- tests/test_runtime_model.cpp | 30 ++- tests/test_tile_events.py | 4 +- tests/test_tile_icons.py | 18 +- tools/generate_icons.py | 18 +- 29 files changed, 948 insertions(+), 128 deletions(-) create mode 100644 docs/TEST_RESULTS_0274.md create mode 100644 docs/images/cyd-full-climate.png create mode 100644 docs/images/cyd-full-light.png create mode 100644 docs/images/cyd-full-menu.png create mode 100644 docs/images/guition-full-climate.png create mode 100644 docs/images/guition-full-fan.png create mode 100644 docs/images/guition-full-light.png create mode 100644 docs/images/guition-full-menu.png create mode 100644 tests/test_full_page_tiles.py diff --git a/README.md b/README.md index ebee90c..665932c 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,9 @@ panel costs a fraction of that, sits on a table or in a wall box, and is always lights, the heating or the vacuum. **What.** Firmware for two affordable panels, the 2.8-inch CYD and the 4-inch Guition, with up to -twenty tiles over four pages: lights, climate, blinds and curtains, the vacuum, media, the weather, -history graphs, clocks and timers. An automation can put an alert on every screen when someone rings +48 tiles over eight pages: lights, climate, blinds and curtains, the vacuum, media, the weather, +history graphs, clocks and timers. A tile can take the whole page, one big switch you push without +looking, and a tile can go to another page. An automation can put an alert on every screen when someone rings the bell, and a Guition shows who is there with the doorbell camera's picture. A tap can run any action Home Assistant has for a tile, and Dark mode suits a screen beside the bed. @@ -31,6 +32,11 @@ also be changed on the screen or by an automation, and your own YAML for one scr Double-width tiles with direct control: the heating setpoint, a dimmer and the Sonos volume An energy graph, the robot vacuum, a coffee machine, a fan and a good-night script with pastel backgrounds

+

+ A full-page tile: one big amber light switch that fills the screen, so you push anywhere without looking + Navigation tiles: Heating, Blinds and Weather each open their own page, next to a person and a lamp + A full-page heating tile: the room temperature big in the middle, the mode keys at the bottom +

Weather card: current weather, the coming hours and the coming days with chance of rain Climate card: the target temperature between big minus and plus keys, the mode keys, and fan and swing choices diff --git a/README_EXTENDED.md b/README_EXTENDED.md index 7664803..0171263 100644 --- a/README_EXTENDED.md +++ b/README_EXTENDED.md @@ -15,8 +15,8 @@ on the screen itself, and how updates work. ## What you can configure -- **Up to twenty tiles**, spread across up to four fixed pages of six - tiles each. Search by entity, device, or room, and drag to reorder. +- **Up to 48 tiles**, spread across up to eight fixed pages of six + tiles each (firmware 0.2.62+; twenty before). Search by entity, device, or room, and drag to reorder. - **Per-tile settings:** a custom name, click behavior, a small slider where supported, or a large value for things like temperature and power usage. From firmware 0.2.13, the large value shows a small domain icon next to the @@ -41,7 +41,22 @@ on the screen itself, and how updates work. a **sun path** (`sun.sun`: horizon with the sun between sunrise and sunset), a **timer** (`timer.*`, tapping starts or pauses it), and **presence** (`person.*`). Pick them in the picker like any other tile, or - drag them straight into the screen mockup; **Double-width** is an option for every tile. + drag them straight into the screen mockup; **Double-width** is an option for every tile, and so is + **Full page** (firmware 0.2.62+): the tile takes the whole page and is one big button that lights up + in its state colour while on, so a screen by the door switches the light when you push anywhere on it. + Its small slider, direct controls or graph sit at the bottom of the page. A **Go to page** tile + (`screen.page_2` to `screen.page_8`) opens another page: a full-page light switch on page 1, a menu on page 2. + +

+ A full-page tile on the Guition: one big amber light switch + Navigation tiles on the Guition: Heating, Blinds and Weather open their own pages + A full-page fan tile with its speed slider along the bottom +

+

+ The same full-page light switch on the CYD + Navigation tiles on the CYD + A full-page heating tile on the CYD with the mode keys at the bottom +

- **Direct control on double-width tiles** (firmware 0.2.19+), like the rows in Home Assistant: temperature − / + or mode buttons (climate), a toggle (switch, light, fan), start/stop/dock (vacuum), open/stop/close or a diff --git a/components/smart_display/runtime_model.h b/components/smart_display/runtime_model.h index f92e846..0f475b7 100644 --- a/components/smart_display/runtime_model.h +++ b/components/smart_display/runtime_model.h @@ -7,13 +7,24 @@ #include #include #include +// The tiles of a screen live on the heap, as many as the layout has (firmware 0.2.62+): a screen with twelve +// tiles pays for twelve. On a board with PSRAM ESPHome's allocator puts them there, so the internal RAM the +// WiFi stack and LVGL need stays free; without PSRAM it falls back to the internal heap. The host tests +// and the host render build have neither, so they take the standard allocator. +#if __has_include("esphome/core/defines.h") +#include "esphome/core/defines.h" +#endif +#ifdef USE_ESP32 +#include "esphome/core/helpers.h" +#endif namespace runtime_tiles { -constexpr size_t MAX_TILES = 20; +// Eight pages of six slots: a screen holds at most one tile per slot (firmware 0.2.62+; twenty before). constexpr size_t SLOTS_PER_PAGE = 6; // Explicit grid positions (0.2.26+) address at most eight pages of six slots. constexpr size_t MAX_PAGES = 8; constexpr size_t MAX_SLOTS = MAX_PAGES * SLOTS_PER_PAGE; +constexpr size_t MAX_TILES = MAX_SLOTS; inline bool valid_entity(const std::string &entity) { if (entity.size() > 120) return false; auto dot = entity.find('.'); @@ -22,8 +33,12 @@ inline bool valid_entity(const std::string &entity) { if (i != dot && !(entity[i] >= 'a' && entity[i] <= 'z') && !(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" || entity == "screen.settings"; + // screen.* are built-in cards without a Home Assistant entity behind them; screen.page_ (firmware 0.2.62+) + // only goes to page n, one such tile per page, so an entity still appears once on a screen. + if (domain == "screen") { + if (entity == "screen.clock" || entity == "screen.settings") return true; + return entity.size() == 13 && entity.compare(0, 12, "screen.page_") == 0 && entity[12] >= '1' && entity[12] <= static_cast('0' + MAX_PAGES); + } 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", "camera", "image"}) if (domain == allowed) return true; return false; @@ -123,7 +138,8 @@ struct Tile { bool has_hs_color = false; int saturation = 0; std::string tap = "auto", display = "standard", inline_control = "none"; - bool wide = false; + // Double width takes a row; full (firmware 0.2.62+) takes the whole page, all six slots, and is also wide. + bool wide = false, full = false; // Direct control set on a wide card (firmware 0.2.19+); empty keeps the plain card. std::string controls, device_class; bool muted = false; @@ -237,6 +253,11 @@ struct Tile { // 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"; } + // A navigation tile (screen.page_, firmware 0.2.62+) and the page it goes to, counted from one. + bool is_page() const { return entity.size() == 13 && entity.compare(0, 12, "screen.page_") == 0; } + int page_target() const { return is_page() ? entity[12] - '0' : 0; } + // Slots a tile takes: one, a row of two, or the six of a page. + unsigned cells() const { return full ? SLOTS_PER_PAGE : wide ? 2u : 1u; } // A scene, button or input button that never ran is "unknown" in Home Assistant, which still lets you press it // (hui-button-entity-row disables only an unavailable one): its state is the moment it last ran (firmware 0.2.58+). bool available() const { @@ -263,14 +284,32 @@ struct Tile { }; // Slot position of a tile within the fixed two-column, three-row pages. struct Placement { uint8_t page = 0, slot = 0; }; -// Wide tiles start in the left column and take the whole row; a right-column -// gap before them stays empty. Returns the page count (at least one). -inline unsigned pack(const std::array &tiles, size_t count, std::array &out) { +#ifdef USE_ESP32 +// ESPHome's allocator prefers PSRAM and falls back to the internal heap; this wrapper only gives it the +// comparison std::vector wants. +template struct TileAllocator { + using value_type = T; + TileAllocator() = default; + template TileAllocator(const TileAllocator &) {} + T *allocate(size_t n) { return esphome::RAMAllocator().allocate(n); } + void deallocate(T *p, size_t n) { esphome::RAMAllocator().deallocate(p, n); } + bool operator==(const TileAllocator &) const { return true; } + bool operator!=(const TileAllocator &) const { return false; } +}; +using TileList = std::vector>; +#else +using TileList = std::vector; +#endif +// Wide tiles start in the left column and take the whole row; a right-column gap before them stays +// empty. A full tile starts a page of its own; the slots it leaves behind stay empty. Returns the page +// count (at least one). +inline unsigned pack(const TileList &tiles, size_t count, std::array &out) { unsigned position = 0; for (size_t i = 0; i < count && i < MAX_TILES; ++i) { - if (tiles[i].wide && position % 2 == 1) ++position; + if (tiles[i].full && position % SLOTS_PER_PAGE) position += SLOTS_PER_PAGE - position % SLOTS_PER_PAGE; + else if (tiles[i].wide && position % 2 == 1) ++position; out[i] = {static_cast(position / SLOTS_PER_PAGE), static_cast(position % SLOTS_PER_PAGE)}; - position += tiles[i].wide ? 2 : 1; + position += tiles[i].cells(); } unsigned pages = (position + SLOTS_PER_PAGE - 1) / SLOTS_PER_PAGE; return pages ? pages : 1; @@ -281,7 +320,7 @@ inline unsigned pack(const std::array &tiles, size_t count, std struct Model; inline unsigned place(const Model &m, std::array &out); struct Model { - std::array tiles; + TileList tiles; // Absolute grid slot per tile when the manager sent `slots` (0.2.26+): gaps stay // empty and a tile keeps its place. Without them the tiles pack in order. std::array slots{}; @@ -312,13 +351,16 @@ struct Model { } } changed = !configured || count != entities.size(); - for (size_t i = 0; i < entities.size(); ++i) if (tiles[i].entity != entities[i]) changed = true; + for (size_t i = 0; i < entities.size() && i < tiles.size(); ++i) if (tiles[i].entity != entities[i]) changed = true; moved = !changed && (explicit_slots != !positions.empty()); for (size_t i = 0; i < positions.size() && !changed; ++i) if (slots[i] != positions[i]) moved = true; // A title-only update must not interrupt an open control card. title = name.empty() ? "Home" : name; if (changed) { - for (auto &tile : tiles) tile = Tile{}; + // Freed before the new list is made, so the heap never holds both. + tiles.clear(); + tiles.shrink_to_fit(); + tiles.resize(entities.size()); count = entities.size(); for (size_t i = 0; i < count; ++i) tiles[i].entity = entities[i]; } @@ -342,9 +384,10 @@ inline unsigned place(const Model &m, std::array &out) { unsigned last = 0; for (size_t i = 0; i < m.count && i < MAX_TILES; ++i) { unsigned slot = m.slots[i]; - if (m.tiles[i].wide) slot &= ~1u; + if (m.tiles[i].full) slot -= slot % SLOTS_PER_PAGE; + else if (m.tiles[i].wide) slot &= ~1u; out[i] = {static_cast(slot / SLOTS_PER_PAGE), static_cast(slot % SLOTS_PER_PAGE)}; - last = std::max(last, slot + (m.tiles[i].wide ? 2u : 1u)); + last = std::max(last, slot + m.tiles[i].cells()); } unsigned pages = (last + SLOTS_PER_PAGE - 1) / SLOTS_PER_PAGE; return std::max({pages, 1u, std::min(m.pages, MAX_PAGES)}); diff --git a/components/smart_display/runtime_tiles.h b/components/smart_display/runtime_tiles.h index 5b42bf7..221d464 100644 --- a/components/smart_display/runtime_tiles.h +++ b/components/smart_display/runtime_tiles.h @@ -39,6 +39,11 @@ inline Model model; inline std::string inbox; inline const lv_font_t *watch_font = nullptr; inline const lv_font_t *mini_icon_font = nullptr; +// The large icon font of a full-page card (firmware 0.2.62+): the domain icons only, so the flash stays free. +inline const lv_font_t *big_icon_font = nullptr; +// Page shown by the navigation bar and the swipe (the profile's tile_page), remembered by show_page. +inline int *shown_page = nullptr; +inline void go_to_page(int page); inline const lv_font_t *watch_value_font = nullptr, *watch_icon_font = nullptr; // Big digits for the clock card; the board profile sets it with the local time source. inline const lv_font_t *clock_font = nullptr; @@ -172,14 +177,17 @@ struct Widgets { lv_obj_t *tile{}, *title{}, *value{}, *circle{}, *icon{}; size_t index{}; int cached_active = -1; lv_obj_t *slider{}, *progress{}, *unit{}; int title_x=0,title_y=0,value_x=0,value_y=0; const lv_font_t *value_font{}, *icon_font{}; // Wide cards span both columns; custom cards (clock, forecast, graph) draw into `extra`. - bool wide=false; int base_width=0; lv_obj_t *extra{}; std::string extra_mode; std::array parts{}; lv_point_precise_t *points{}; + // Full (firmware 0.2.62+) takes the whole page: the double-width card's head on top, a control at the bottom. + bool wide=false, full=false; int base_width=0, base_height=0; const lv_font_t *title_font{}; + // `extra_full`: the size the parts were built for; a slot that changes between full and double width rebuilds them. + lv_obj_t *extra{}; std::string extra_mode; bool extra_full=false; std::array parts{}; lv_point_precise_t *points{}; // Analog clock: centre and radius of the dial, so the second hand can move without a card redraw. int hand_cx=0, hand_cy=0, hand_r=0, hand_width=1; // Soft area under a polyline (graph, sun path), painted by the extra container's draw event. const lv_point_precise_t *fill_points{}; unsigned fill_count=0; int fill_x=0, fill_y=0, fill_base=0; lv_color_t fill_color{}; lv_opa_t fill_opa=0; // Direct controls on a wide card: a panel at the right with pill keys, a -/+ pill, // a slider or a toggle. Objects are rebuilt only when the control set changes. - lv_obj_t *panel{}; std::string panel_mode; bool panel_dirty=false; int panel_w=0; + lv_obj_t *panel{}; std::string panel_mode; bool panel_dirty=false, panel_full=false; int panel_w=0; std::array keys{}, key_icons{}; std::array key_commands{}; std::array key_args; std::array key_checked{}; lv_obj_t *pill{}, *pill_value{}, *knob{}, *control_slider{}; int knob_on=-1; lv_color_t panel_accent{}, panel_text{}; @@ -195,6 +203,13 @@ inline bool has_icon_glyph(uint32_t codepoint) { for (auto &w : widgets) if (w.icon_font) { lv_font_glyph_dsc_t dsc; return lv_font_get_glyph_dsc(w.icon_font, &dsc, codepoint, 0); } return false; } +// Whether `font` draws the glyph a label's UTF-8 text starts with (a four-byte Material Design icon). +inline bool font_has(const lv_font_t *font, const std::string &utf8) { + if (!font || utf8.size() < 4) return false; + const auto *b = reinterpret_cast(utf8.data()); + uint32_t cp = (uint32_t(b[0] & 7) << 18) | (uint32_t(b[1] & 0x3F) << 12) | (uint32_t(b[2] & 0x3F) << 6) | (b[3] & 0x3F); + lv_font_glyph_dsc_t dsc; return lv_font_get_glyph_dsc(font, &dsc, cp, 0); +} // Home Assistant's API link as ESPHome itself tracks it: gone the moment the socket drops, // back the moment HA reconnects, no guessing from message age. inline bool ha_connected() { return esphome::api_is_connected(); } @@ -478,9 +493,11 @@ inline std::string receive(const std::string &payload) { // Direct controls (0.2.19+): the manager sends only the set a wide card really shows. tile.controls = string(options["controls"], 16); // Width arrives with the state, after the layout: re-pack the pages when it changes. - bool was_wide = tile.wide; - tile.wide = string(options["size"]) == "wide"; - bool repack = was_wide != tile.wide; + bool was_wide = tile.wide, was_full = tile.full; + std::string size = string(options["size"]); + tile.full = size == "full"; + tile.wide = tile.full || size == "wide"; + bool repack = was_wide != tile.wide || was_full != tile.full; // Pre-computed extras: the manager converts time zones and fetches forecasts. What only some tiles // carry is collected in `next` and replaces the tile's Extra at the end (see Tile::set_extra). auto extra = root["x"]; @@ -1829,7 +1846,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 tile.is_settings() ? "\U000F0493" : "\U000F0150"; + if (d == "screen") return tile.is_settings() ? "\U000F0493" : tile.is_page() ? "\U000F0054" : "\U000F0150"; if (d == "camera" || d == "image") return "\U000F07AE"; return "\U000F0425"; } @@ -1886,6 +1903,12 @@ inline void event(lv_event_t *event) { // the link down too, as its card says (firmware 0.2.49+), and the clock card does nothing under a finger. if (model.tiles[w.index].builtin()) { auto &card = model.tiles[w.index]; + if (card.is_page()) { + // A navigation tile goes to its page on a short tap, with the link down too; holding does nothing. + if (code != LV_EVENT_SHORT_CLICKED || card.tap == "none" || card.page_target() < 1) return; + if (allowed(esphome::millis(), 100 + w.index, card.entity)) go_to_page(card.page_target() - 1); + return; + } if (!card.is_settings() || card.tap == "none" || (settings_screen::may_open && !settings_screen::may_open())) return; if (allowed(esphome::millis(), 100 + w.index, card.entity)) settings_screen::open(); return; @@ -1973,11 +1996,15 @@ inline int tile_width(const Widgets &w) { int32_t width = lv_obj_get_style_width(w.tile, LV_PART_MAIN); return LV_COORD_IS_PX(width) ? width : lv_obj_get_width(w.tile); } +inline int tile_height(const Widgets &w) { + int32_t height = lv_obj_get_style_height(w.tile, LV_PART_MAIN); + return LV_COORD_IS_PX(height) ? height : lv_obj_get_height(w.tile); +} inline int content_width(const Widgets &w) { return tile_width(w) - lv_obj_get_style_space_left(w.tile, LV_PART_MAIN) - lv_obj_get_style_space_right(w.tile, LV_PART_MAIN); } inline int content_height(const Widgets &w) { - return lv_obj_get_height(w.tile) - lv_obj_get_style_space_top(w.tile, LV_PART_MAIN) - lv_obj_get_style_space_bottom(w.tile, LV_PART_MAIN); + return tile_height(w) - lv_obj_get_style_space_top(w.tile, LV_PART_MAIN) - lv_obj_get_style_space_bottom(w.tile, LV_PART_MAIN); } inline void bind(size_t index, lv_obj_t *tile, lv_obj_t *title, lv_obj_t *value, lv_obj_t *circle, lv_obj_t *icon) { lv_obj_update_layout(tile); @@ -1987,9 +2014,11 @@ inline void bind(size_t index, lv_obj_t *tile, lv_obj_t *title, lv_obj_t *value, lv_obj_update_layout(tile); widgets[index] = {tile, title, value, circle, icon, index}; auto &w=widgets[index]; w.title_x=lv_obj_get_x(title);w.title_y=lv_obj_get_y(title);w.value_x=lv_obj_get_x(value);w.value_y=lv_obj_get_y(value);w.value_font=lv_obj_get_style_text_font(value,LV_PART_MAIN); - w.base_width=lv_obj_get_width(tile); + w.base_width=lv_obj_get_width(tile);w.base_height=lv_obj_get_height(tile); + w.title_font=lv_obj_get_style_text_font(title,LV_PART_MAIN); w.icon_font=lv_obj_get_style_text_font(icon,LV_PART_MAIN); w.unit=lv_label_create(tile);lv_obj_set_style_text_font(w.unit,w.value_font,0);lv_obj_remove_flag(w.unit,LV_OBJ_FLAG_CLICKABLE);lv_obj_add_flag(w.unit,LV_OBJ_FLAG_HIDDEN); + lv_label_set_long_mode(w.unit,LV_LABEL_LONG_DOT); // Fixed one-line boxes prevent wrapped names from overlapping the state on both boards. lv_obj_set_height(title,lv_font_get_line_height(lv_obj_get_style_text_font(title,LV_PART_MAIN))); lv_obj_set_height(value,lv_font_get_line_height(w.value_font)); @@ -2082,7 +2111,7 @@ inline void begin_extra(Widgets &w,const char *mode,int width,int height) { w.extra=lv_obj_create(w.tile);lv_obj_remove_style_all(w.extra);lv_obj_remove_flag(w.extra,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(w.extra,LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(w.extra,extra_draw,LV_EVENT_DRAW_MAIN,&w); } - if(w.extra_mode!=mode){end_extra(w);w.extra_mode=mode;w.points=new lv_point_precise_t[POINT_BUFFER];w.cached_active=-1;} + if(w.extra_mode!=mode || w.extra_full!=w.full){end_extra(w);w.extra_mode=mode;w.extra_full=w.full;w.points=new lv_point_precise_t[POINT_BUFFER];w.cached_active=-1;} w.fill_points=nullptr;w.fill_count=0; // Parts that were hidden kept the colours of the card they last showed. if(lv_obj_has_flag(w.extra,LV_OBJ_FLAG_HIDDEN)){w.cached_active=-1;lv_obj_remove_flag(w.extra,LV_OBJ_FLAG_HIDDEN);} @@ -2151,7 +2180,8 @@ inline void render_clock(Widgets &w,const Tile &t,bool large,int width,int heigh return; } // The dial keeps the same size with or without a card behind it. - int dial=std::min(height,width),cx=dial/2,cy=height/2,outer=dial/2-1,radius=dial/2-(large?4:2); + // A full-page card centres its dial; the digital time and date beside a wide dial stay off it. + int dial=std::min(height,width),cx=(w.full?(width-dial)/2:0)+dial/2,cy=height/2,outer=dial/2-1,radius=dial/2-(large?4:2); for(int i=0;i<12;++i){ float a=i*3.14159265f/6;bool cardinal=i%3==0; if(cardinal && large){ @@ -2177,6 +2207,11 @@ inline void render_clock(Widgets &w,const Tile &t,bool large,int width,int heigh second_hand(w,now); if(new_hand)lv_obj_move_to_index(w.parts[18],lv_obj_get_index(w.parts[14])); std::string day=now.is_valid()?std::to_string(now.day_of_month):"--"; + if(w.full){ + part_label(w,15,big,0,0,1,LV_TEXT_ALIGN_CENTER,""); + part_label(w,16,small,0,0,1,LV_TEXT_ALIGN_CENTER,""); + return; + } if(w.wide){ int x=dial+(large?16:8),y=std::max(0,(height-text_h)/2); part_label(w,15,big,x,y,width-x,LV_TEXT_ALIGN_CENTER,time_text(now)); @@ -2203,19 +2238,41 @@ inline void render_clock(Widgets &w,const Tile &t,bool large,int width,int heigh int month_y=day_y+(big->line_height-big->base_line)-(small->line_height-small->base_line); part_label(w,17,small,sx+day_size.x+gap,std::max(0,month_y),std::max(1,int(x+room-(sx+day_size.x+gap))),LV_TEXT_ALIGN_LEFT,month); } +inline int block_min(int icon_h,int temp_h,int text_h){return std::max(icon_h,temp_h)+2+text_h;} // Current conditions on the left, five day columns on the right (wide cards only). inline void render_forecast(Widgets &w,const Tile &t,bool large,int width,int height) { begin_extra(w,"forecast",width,height); const lv_font_t *title_font=lv_obj_get_style_text_font(w.title,LV_PART_MAIN); const lv_font_t *temp_font=watch_value_font?watch_value_font:w.value_font,*day_icon=mini_icon_font?mini_icon_font:w.icon_font; int left=large?150:96,icon_h=lv_font_get_line_height(w.icon_font),temp_h=lv_font_get_line_height(temp_font),text_h=lv_font_get_line_height(w.value_font); + // A full-page card (firmware 0.2.62+) adds the next hours under the days: time, icon and temperature per column. + int day_h=lv_font_get_line_height(title_font),icon_col=lv_font_get_line_height(day_icon); + unsigned hours=w.full?std::min(t.extra().hours.size(),large?6:4):0; + int hours_h=hours?day_h+icon_col+text_h:0; + int top_h=hours?std::max(block_min(icon_h,temp_h,text_h),height-hours_h-(large?12:6)):height; + for(unsigned j=0;j<6;++j){ + if(j80):panel_metrics(large); + const lv_font_t *icon_font=w.full?w.icon_font:mini_icon_font?mini_icon_font:w.icon_font; const lv_font_t *text_font=control_font?control_font:lv_obj_get_style_text_font(w.title,LV_PART_MAIN); auto d=t.domain(); if(!w.panel){w.panel=panel_obj(w.tile,false);} - if(w.panel_mode!=mode){ - end_panel(w);w.panel_mode=mode;w.panel_dirty=true; + if(w.panel_mode!=mode || w.panel_full!=w.full){ + end_panel(w);w.panel_mode=mode;w.panel_full=w.full;w.panel_dirty=true; if(tile_controls::is_key_row(mode)){ for(unsigned n=0;n<3;++n){panel_key(w,n,w.panel,m,m.key_w,m.key_h,false);panel_icon(w,n,icon_font);} }else if(mode=="setpoint"||mode=="stepper"){ @@ -2444,7 +2505,9 @@ inline int layout_panel(Widgets &w,const Tile &t,bool large,int content_w,int co if(!panel_w){hide_panel(w);return 0;} // A panel shown again kept the colours of the card it last served. if(lv_obj_has_flag(w.panel,LV_OBJ_FLAG_HIDDEN)){lv_obj_remove_flag(w.panel,LV_OBJ_FLAG_HIDDEN);w.panel_dirty=true;} - lv_obj_set_size(w.panel,panel_w,panel_h);lv_obj_set_pos(w.panel,content_w-panel_w,std::max(0,(content_h-panel_h)/2)); + lv_obj_set_size(w.panel,panel_w,panel_h); + if(w.full)lv_obj_set_pos(w.panel,std::max(0,(content_w-panel_w)/2),std::max(0,content_h-panel_h)); + else lv_obj_set_pos(w.panel,content_w-panel_w,std::max(0,(content_h-panel_h)/2)); w.panel_w=panel_w+m.text_gap; return w.panel_w; } @@ -2522,11 +2585,92 @@ inline void set_busy(Widgets &w,bool busy,bool large){ // Cover the whole card, padding included, at the width the card asks for: a slot that just turned // wide is still single width in LVGL's own coordinates. Unchanged sizes cost LVGL nothing. lv_obj_set_pos(w.busy,-lv_obj_get_style_pad_left(w.tile,LV_PART_MAIN),-lv_obj_get_style_pad_top(w.tile,LV_PART_MAIN)); - lv_obj_set_size(w.busy,tile_width(w),lv_obj_get_height(w.tile)); + lv_obj_set_size(w.busy,tile_width(w),tile_height(w)); lv_obj_remove_flag(w.busy,LV_OBJ_FLAG_HIDDEN); // Controls and custom parts created after the sheet would otherwise paint over its right side. if(lv_obj_get_index(w.busy)!=(int32_t)lv_obj_get_child_count(w.tile)-1)lv_obj_move_foreground(w.busy); } +// The card that takes a whole page (firmware 0.2.62+). Without a control it is one big button: the icon in a +// large circle with the name and the state under it, and the whole card lights up in the state colour while +// on (see the palette below), so a wall switch reads from across the room and a push anywhere works. With a +// small slider, direct controls or a graph the double-width card's head stays on top and the control takes +// a strip at the bottom, so a tap anywhere else still does what a tap on the tile does. The built-in cards +// (clock, forecast, sun path) simply get the whole page. +inline void render_full(Widgets &w,const Tile &t,bool custom,bool clock,bool sunpath,bool graph,bool mini,bool with_panel,bool large, + const std::string &value,const std::string &unit,int content_w,int content_h) { + const bool big=w.base_height>80; // the board: a Guition card is large, a CYD card small + lv_obj_add_flag(w.unit,LV_OBJ_FLAG_HIDDEN);lv_obj_add_flag(w.progress,LV_OBJ_FLAG_HIDDEN); + if(custom){ + lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN);hide_panel(w); + // The forecast and the sun path keep their board's layout (the CYD's two-row days); a taller dial is fine. + if(clock)render_clock(w,t,large,content_w,content_h); + else if(sunpath)render_sunpath(w,t,big,content_w,content_h); + else render_forecast(w,t,big,content_w,content_h); + return; + } + int value_h=lv_font_get_line_height(lv_obj_get_style_text_font(w.value,LV_PART_MAIN)); + int gap=big?12:6; + if(!mini && !with_panel && !graph){ + hide_extra(w);hide_panel(w);lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN); + const lv_font_t *name_font=room_label?lv_obj_get_style_text_font(room_label,LV_PART_MAIN):w.title_font; + int name_h=lv_font_get_line_height(name_font),circle=big?128:64; + int block=circle+gap+name_h+2+value_h,top=std::max(0,(content_h-block)/2); + lv_obj_set_size(w.circle,circle,circle);lv_obj_set_pos(w.circle,std::max(0,(content_w-circle)/2),top); + // The big icon font carries the domain icons; another chosen icon keeps its usual size in the big circle. + const lv_font_t *icon_font=big_icon_font && font_has(big_icon_font,icon_for(t))?big_icon_font:w.icon_font; + if(lv_obj_get_style_text_font(w.icon,LV_PART_MAIN)!=icon_font){set_font(w.icon,icon_font);lv_obj_center(w.icon);} + set_font(w.title,name_font);set_text_align(w.title,LV_TEXT_ALIGN_CENTER);set_text_align(w.value,LV_TEXT_ALIGN_CENTER); + lv_obj_set_height(w.title,name_h); + lv_obj_set_pos(w.title,0,top+circle+gap);lv_obj_set_width(w.title,content_w); + lv_obj_set_pos(w.value,0,top+circle+gap+name_h+2);lv_obj_set_width(w.value,content_w); + if(!unit.empty())label(w.value,value+" "+unit); + return; + } + // The head as on the double-width card: the circle at the left, name and state beside it. + set_font(w.title,w.title_font);set_text_align(w.title,LV_TEXT_ALIGN_LEFT);set_text_align(w.value,LV_TEXT_ALIGN_LEFT); + int title_h=lv_font_get_line_height(w.title_font); + lv_obj_set_height(w.title,title_h); + int head_h=std::max(1,w.base_height-lv_obj_get_style_space_top(w.tile,LV_PART_MAIN)-lv_obj_get_style_space_bottom(w.tile,LV_PART_MAIN)); + int circle=big?54:36,line_gap=big?2:1,text_y=std::max(0,(head_h-(title_h+line_gap+value_h))/2); + lv_obj_set_size(w.circle,circle,circle); + if(lv_obj_get_style_text_font(w.icon,LV_PART_MAIN)!=w.icon_font){set_font(w.icon,w.icon_font);lv_obj_center(w.icon);} + lv_obj_set_pos(w.circle,0,big?12:std::max(0,(head_h-circle)/2)); + lv_obj_set_pos(w.title,w.title_x,big?w.title_y:text_y); + lv_obj_set_pos(w.value,w.value_x,big?w.value_y:text_y+title_h+line_gap); + lv_obj_set_width(w.title,std::max(1,content_w-w.title_x));lv_obj_set_width(w.value,std::max(1,content_w-w.value_x)); + if(mini){ + // The small slider becomes a strip a thumb finds at the bottom; the room above it is the button. + hide_extra(w);hide_panel(w); + int strip=big?64:40; + lv_obj_set_size(w.slider,content_w,strip); + slider_handle(w.slider,content_w,strip); + lv_obj_remove_flag(w.slider,LV_OBJ_FLAG_HIDDEN); + if(!lv_obj_has_state(w.slider,LV_STATE_PRESSED) && lv_slider_get_value(w.slider)!=slider_value(t))lv_slider_set_value(w.slider,slider_value(t),LV_ANIM_OFF); + return; + } + lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN); + if(graph){ + hide_panel(w); + render_graph(w,t,large,0,head_h+gap,content_w,std::max(8,content_h-head_h-gap)); + return; + } + // Direct controls at the bottom, centred; the room between shows what the double-width card had no room for. + hide_extra(w); + if(!layout_panel(w,t,large,content_w,content_h)){hide_panel(w);return;} + int panel_h=lv_obj_get_style_height(w.panel,LV_PART_MAIN); + int mid_top=head_h,mid_h=content_h-head_h-gap-panel_h; + // The large value font carries digits, the degree sign and the percent sign; the clock font only digits and a colon. + std::string middle;const lv_font_t *mid_font=watch_value_font?watch_value_font:w.value_font; + auto d=t.domain(); + if(d=="climate" && std::isfinite(t.current)){char b[32];snprintf(b,sizeof(b),"%.1f°",t.current);middle=b;} + else if(d=="cover" && std::isfinite(t.position)){middle=std::to_string(static_cast(std::lround(t.position)))+" %";} + else if(d=="media_player" && !t.extra().media_title.empty()){middle=t.extra().media_title;mid_font=room_label?lv_obj_get_style_text_font(room_label,LV_PART_MAIN):w.title_font;} + int mid_font_h=lv_font_get_line_height(mid_font); + if(middle.empty() || mid_h(std::lround(std::clamp(t.brightness, 0.0f, 255.0f) * 100 / 255))) + " %"; @@ -2569,7 +2714,7 @@ inline void render_slot(size_t slot) { if(with_panel){std::string status=tile_controls::status_text(t);if(!status.empty())value=status;} label(w.value, value); bool mini=t.inline_control=="slider" && !watch && t.available(); - bool large_tile=lv_obj_get_height(w.tile)>80; + bool large_tile=tile_height(w)>80; lap(swipe_profile::TEXT); // Cards that replace the name/status layout entirely. bool clock=t.is_clock(), forecast=d=="weather" && t.display=="forecast" && w.wide && t.extra().forecast.size()>0 && fresh() && t.available(); @@ -2588,7 +2733,11 @@ inline void render_slot(size_t slot) { set_busy(w,w.busy_drawn,large_tile); lap(swipe_profile::BUSY); for(auto *o:{w.title,w.value,w.circle,w.unit})set_hidden(o,custom); - if(custom){ + if(w.full){ + lap(swipe_profile::GEOMETRY); + render_full(w,t,custom,clock,sunpath,graph,mini,with_panel,large_tile,value,unit,content_w,content_h); + lap(swipe_profile::CUSTOM); + }else if(custom){ lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN);hide_panel(w); lap(swipe_profile::GEOMETRY); if(clock)render_clock(w,t,large_tile,content_w,content_h); @@ -2596,6 +2745,9 @@ inline void render_slot(size_t slot) { else render_forecast(w,t,large_tile,content_w,content_h); lap(swipe_profile::CUSTOM); }else{ + // A slot that just held a full card gets its own name font, one-line box and left-aligned text back. + set_font(w.title,w.title_font);set_text_align(w.title,LV_TEXT_ALIGN_LEFT);set_text_align(w.value,LV_TEXT_ALIGN_LEFT); + title_height=lv_font_get_line_height(w.title_font);lv_obj_set_height(w.title,title_height); int line_gap=large_tile?2:1,text_height=title_height+line_gap+value_height; int slider_height=large_tile?28:8; // A single-width graph takes the slider strip; a wide graph takes the right half. @@ -2619,6 +2771,10 @@ inline void render_slot(size_t slot) { // Use the requested coordinates: LVGL getters still return the previous // layout until its next pass when a slot changes from watch/slider to normal. int text_room=content_w-chart_w-(graph_side?(large_tile?10:6):0)-panel_w; + // A navigation tile ends in a chevron, as a row in Home Assistant's settings does. + const lv_font_t *chevron_font=mini_icon_font?mini_icon_font:w.icon_font; + int chevron_w=t.is_page() && !watch?lv_font_get_line_height(chevron_font):0; + if(chevron_w)text_room-=chevron_w+(large_tile?8:4); lv_obj_set_width(w.title,std::max(1,text_room-text_x)); lv_obj_set_width(w.value,std::max(1,text_room-(watch?0:(mini||graph_strip)?text_x:w.value_x))); if(watch){ @@ -2628,6 +2784,7 @@ inline void render_slot(size_t slot) { lv_obj_set_pos(w.circle,0,group_y+(header-circle_size)/2); lv_obj_set_pos(w.title,circle_size+(large_tile?6:4),group_y+(header-title_height)/2); lv_obj_set_width(w.title,text_room-circle_size-(large_tile?6:4)); + set_font(w.unit,w.value_font);set_text_align(w.unit,LV_TEXT_ALIGN_LEFT); label(w.unit,unit); lv_point_t size;lv_text_get_size(&size,unit.c_str(),w.value_font,0,0,LV_COORD_MAX,LV_TEXT_FLAG_EXPAND); int unit_width=unit.empty()?0:std::min((int)size.x,text_room-20); @@ -2636,6 +2793,10 @@ inline void render_slot(size_t slot) { lv_obj_set_pos(w.unit,text_room-unit_width,value_y+value_height-lv_font_get_line_height(w.value_font)); lv_obj_set_size(w.unit,unit_width,lv_font_get_line_height(w.value_font)); set_hidden(w.unit,!unit_width); + }else if(chevron_w){ + set_font(w.unit,chevron_font);set_text_align(w.unit,LV_TEXT_ALIGN_RIGHT);label(w.unit,"\U000F0142"); + lv_obj_set_pos(w.unit,content_w-chevron_w,std::max(0,(header_height-chevron_w)/2));lv_obj_set_size(w.unit,chevron_w,chevron_w); + lv_obj_remove_flag(w.unit,LV_OBJ_FLAG_HIDDEN); }else lv_obj_add_flag(w.unit,LV_OBJ_FLAG_HIDDEN); lv_obj_set_size(w.slider,content_w,slider_height); slider_handle(w.slider,content_w,slider_height); @@ -2669,18 +2830,20 @@ inline void render_slot(size_t slot) { set_color(w.slider,LV_STYLE_BG_COLOR,fill_color,LV_PART_INDICATOR); set_color(w.slider,LV_STYLE_BG_COLOR,lv_color_hex(theme::tint(fill,51)),LV_PART_MAIN); slider_bar(w.slider,slider_bar_shown(t,slider_on)); - set_color(w.tile,LV_STYLE_BG_COLOR,lv_color_hex(theme::surface(t.background))); + // A full-page card lights up in its state colour while on (firmware 0.2.62+): an amber lamp, a purple scene. + bool lit=w.full && on && available && !t.transparent; + set_color(w.tile,LV_STYLE_BG_COLOR,lv_color_hex(lit?theme::tint(state_color,51):theme::surface(t.background))); set_number(w.tile,LV_STYLE_BORDER_WIDTH,1); // "Background: none" hides only the card; geometry and padding stay identical, // and the pressed flash still shows because it lives on the PRESSED state. set_number(w.tile,LV_STYLE_BG_OPA,t.transparent ? LV_OPA_TRANSP : LV_OPA_COVER); set_number(w.tile,LV_STYLE_BORDER_OPA,t.transparent ? LV_OPA_TRANSP : LV_OPA_COVER); - set_color(w.tile,LV_STYLE_BORDER_COLOR,lv_color_hex(theme::outline(t.background))); + set_color(w.tile,LV_STYLE_BORDER_COLOR,lv_color_hex(lit?theme::tint(state_color,110):theme::outline(t.background))); set_color(w.circle,LV_STYLE_BG_COLOR,circle_color); set_color(w.icon,LV_STYLE_TEXT_COLOR,icon_color); auto title_color=theme::color(theme::INK); auto value_color=theme::color(t.background ? theme::SLATE : theme::MUTED); - set_color(w.unit,LV_STYLE_TEXT_COLOR,theme::color(theme::SLATE)); + set_color(w.unit,LV_STYLE_TEXT_COLOR,theme::color(t.is_page()?theme::CHEVRON:theme::SLATE)); set_color(w.title,LV_STYLE_TEXT_COLOR,title_color); set_color(w.value,LV_STYLE_TEXT_COLOR,value_color); style_panel(w,t,color,title_color); @@ -2942,7 +3105,22 @@ inline bool check_tile_geometry() { fits=lv_obj_get_width(w.tile)==expected; if(!fits)ESP_LOGE("ui_test","Wide width FAIL slot=%u width=%d expected=%d",(unsigned)w.index,lv_obj_get_width(w.tile),expected); } - if(!custom){ + if(w.full && widgets[4].tile){ + // A full card ends exactly where the third row ends. + int expected=lv_obj_get_y(widgets[4].tile)-lv_obj_get_y(widgets[0].tile)+w.base_height; + if(lv_obj_get_height(w.tile)!=expected){fits=false;ESP_LOGE("ui_test","Full height FAIL slot=%u height=%d expected=%d",(unsigned)w.index,lv_obj_get_height(w.tile),expected);} + } + if(!custom && w.full){ + // Everything inside the card, the name above the state, a slider or the controls below them. + fits=fits && title.x1>=content.x1 && title.x2<=content.x2 && value.x1>=content.x1 && value.x2<=content.x2 && + title.y1>=content.y1 && title.y2=content.x1 && circle.x2<=content.x2 && circle.y1>=content.y1 && circle.y2<=content.y2; + if(!lv_obj_has_flag(w.slider,LV_OBJ_FLAG_HIDDEN)){ + lv_obj_get_coords(w.slider,&track); + fits=fits && value.y2=content.x1 && title.x2<=content.x2 && value.x1>=content.x1 && value.x2<=content.x2 && title.y2title.y2; + bool chevron=w.index=content.y1 && (chevron ? title.x2title.y2); } } if(w.panel && !lv_obj_has_flag(w.panel,LV_OBJ_FLAG_HIDDEN)){ // Direct controls stay inside the card, right of the name and status, and inside their panel. lv_area_t panel;lv_obj_get_coords(w.panel,&panel); - bool inside=panel.x1>=content.x1 && panel.x2<=content.x2 && panel.y1>=content.y1 && panel.y2<=content.y2 && (custom || (title.x2=content.x1 && panel.x2<=content.x2 && panel.y1>=content.y1 && panel.y2<=content.y2 && (custom || (w.full ? value.y2=content.x1 && part.x2<=content.x2 && part.y1>=content.y1 && part.y2<=content.y2; if(!inside)ESP_LOGE("ui_test","Part bounds slot=%u mode=%s part=%d,%d..%d,%d content=%d,%d..%d,%d",(unsigned)w.index,w.extra_mode.c_str(),part.x1,part.y1,part.x2,part.y2,content.x1,content.y1,content.x2,content.y2); fits=fits && inside; - if(w.extra_mode=="graph" && !custom)fits=fits && (w.wide?part.x1>value.x2:part.y1>value.y2); + if(w.extra_mode=="graph" && !custom)fits=fits && (w.wide && !w.full?part.x1>value.x2:part.y1>value.y2); } } if(!fits)ESP_LOGE("ui_test","Tile geometry FAIL slot=%u mode=%s wide=%d title_y=%d..%d value_y=%d..%d content_y=%d..%d",(unsigned)w.index,w.extra_mode.c_str(),w.wide,title.y1,title.y2,value.y1,value.y2,content.y1,content.y2); @@ -3031,11 +3211,13 @@ inline int place_page(int page) { int pages=place(model,placement); page=std::clamp(page,0,pages-1); int wide_width=widgets[0].tile && widgets[1].tile ? lv_obj_get_x(widgets[1].tile)-lv_obj_get_x(widgets[0].tile)+widgets[0].base_width : 2*widgets[0].base_width; - 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.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.is_clock() && t.display=="analog")second_hand(w,now); + // The second hand moves on its own: only its line is redrawn, and it hides during standby. Only while the + // slot's parts are a dial: right after a page switch the slot already names the clock while its parts still + // belong to the card drawn before (a forecast's hour labels take part 18 too), until the fill draws the dial. + else if(w.parts[18] && w.points && w.extra_mode=="analog" && t.is_clock() && t.display=="analog")second_hand(w,now); } } if(redraw && refresh)refresh(); diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 622a632..a9dbe7f 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -189,6 +189,32 @@ 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.74 / firmware 0.2.62 + +Forty-eight tiles per screen, a tile over the whole page (`size: full`) and navigation tiles (`screen.page_1` to +`screen.page_8`). The app sends any of the three only to firmware 0.2.62+ (`FULL_PAGE_MIN_FIRMWARE`); an older screen +gets "Install screen firmware 0.2.62 or newer first". Older apps keep working with this firmware: they never send +`full` or a page tile, and the firmware still packs `wide` as before. + +- Firmware: `MAX_TILES` is `MAX_SLOTS` (48). `Model::tiles` is a `std::vector` sized to the layout (a `TileAllocator` + over ESPHome's `RAMAllocator`, so a Guition keeps the list in PSRAM; the CYD's falls back to its heap); nothing may + index `tiles` beyond `count`. A `Tile` has `full` next to `wide` (a full tile is also wide) and `cells()`; `pack()` + and `place()` give a full tile a page of its own (`slot - slot % 6`). `screen.page_` is a built-in entity + (`Tile::is_page()`, `page_target()`); a tap calls `go_to_page`, which drives the profile's `tile_page` through + `show_page`. `Widgets` carries `full`, `base_height`, `title_font`, `panel_full` and 36 parts (the forecast's hours). + `render_full()` draws the page-sized card: one big button (128/64 px circle, the domain icon from the new + `materialdesign_icons_big` font, 64 px Guition / 40 px CYD, `tile_icons.BIG_GLYPHS` only), or the wide card's head with + the small slider, the control panel (`panel_metrics_full`, bottom-centred) or the graph at the bottom; a full card + that is on takes `theme::tint(state, 51)` as its surface. `check_tile_geometry` knows the full layout. +- Wire: a state message's `o.size` may be `full`. Built-in tiles now send their options through `screen_options`, so a + chosen icon travels as its codepoint (older firmware ignored the name it got before). No `page` option: the page a + navigation tile opens is its entity. +- App: `core.MAX_TILES` 48 (`LEGACY_MAX_TILES` 20 for `min_firmware`), `tile_size()`/`footprint(slot, size)` replace the + boolean `is_wide` in placement, `BUILTIN` gains the eight page tiles, `layout_snapshot` reports `size` and + `to_page`, `TILE_SIZES` accepts "full screen"/"whole page". The editor's tile limit is 48 from firmware 0.2.62 + (`tileLimit()`), the tile sheet has Size Normal / Double-width / Full page and, for a page tile, "Goes to page" + (which swaps the entity). Storage unchanged: `size` is one more value of an existing option. + ### Compatibility 0.2.72 / firmware 0.2.61 Firmware only (both board profiles; `cyd_ui.h` gains a comment); the app raises `FIRMWARE_VERSION`. No protocol, diff --git a/docs/TEST_RESULTS_0274.md b/docs/TEST_RESULTS_0274.md new file mode 100644 index 0000000..9f9f2e5 --- /dev/null +++ b/docs/TEST_RESULTS_0274.md @@ -0,0 +1,111 @@ +# Test results app 0.2.74 / firmware 0.2.62 (2026-09-18) + +Max: "zou het lukken om nog veel meer tiles toe te staan?", then a full-page tile ("soms willen mensen op pagina 1 een +tile die het hele scherm beslaat ... zodat als ze hem hebben hangen als lichtknop, wil je dat hij meteen werkt zonder te +kijken") and navigation tiles, with the designs approved first (option B "oplichtend", navigation tile N1) and the +instruction "test dit echt uitvoerig, het is zo belangrijk dat dit goed werkt op beide borden op alle manieren". See +CHANGELOG 0.2.74 and the compatibility note in docs/RELEASING.md. Built on 0.2.73 (`1ed42dc`, the Vue editor). + +## What changed, in one breath + +- 48 tiles per screen (one per slot of the eight pages), the firmware's tile list on the heap and sized to the layout + (PSRAM on a Guition), so a screen with twelve tiles pays for twelve. +- Tile size `full`: the whole page. One big button (the domain icon in a 128 / 64 px circle from a new icon font that + carries only the domain icons, the name and the state centred) that lights up in the state colour while on; with a + small slider, direct controls or a graph the double-width card's head stays on top and the control takes a strip + at the bottom, with the value the double-width card had no room for (room temperature, blind position, media + title) in between. Clock, forecast (with an hours strip) and sun path fill the page. +- Navigation tiles `screen.page_1` … `screen.page_8`: arrow (or a chosen icon), name, "Page n" and a chevron; a tap + opens that page, holding does nothing. One per page it goes to, so an entity still appears once on a screen. +- Editor: Size Normal / Double-width / Full page, a "Goes to page" sheet, 48 from firmware 0.2.62, a full tile moves + the other tiles of its page to the first free cells after it. + +## Automated + +- Python (`.venv-portal`): 396 tests OK, including the new `tests/test_full_page_tiles.py` (sizes and footprints, + in-order packing, free slots, validation of full and page tiles, the firmware they need, the layout sensor, the wire + form of built-in tiles, tile events for Claude in Home Assistant, the 48 limit). +- C++ (`c++ -std=c++17 -Wall -Wextra -pedantic`): 17/17 PASS. `tests/test_runtime_model.cpp` now covers 48 tiles, a + list sized to the layout, `screen.page_` validation and page targets, and full tiles in `pack()` and `place()` + (a page of their own, snapping to the page start, the page count). +- `tools/generate_icons.py --check` and `tools/generate_packages.py` green; the new fourth icon font carries the + 31-glyph subset of `tile_icons.BIG_GLYPHS` in both profiles. +- ESP32 check builds of both bench profiles compile (the first attempt caught one `std::max(int, long)`). + +## Firmware on the Mac (host builds, driven over the ESPHome API with a virtual finger) + +`.esphome/readme-render/fullpage/fullpage_host.py guition|cyd --compile` (git-ignored tooling) builds the board's +package as a host program, feeds it the full-page demo home (`demo_home_fullpage.py` on 8098, the real add-on with +screens that report firmware 0.2.62) and drives it with `fp_probe` (geometry, colours, texts, slider and key +positions, the page shown, `check_tile_geometry()`), `fp_finger` (a pointer indev, so taps, holds and drags run through +LVGL and the touch guard) and `fp_close`. + +Results: Guition 37/37 checks, CYD 37/37 checks (the CYD layout has a media player where the Guition has a blind, so +the harness follows each board's demo layout). What the checks cover: + +| Page | Checks | +|---|---| +| Full light, no control | page size (448 × 348 / 302 × 164), geometry, lit amber while on with "59 %", centred 128 / 64 px circle, a tap in the middle and one low in a corner send `light.toggle`, the card drops the lit colour at once, a hold opens the light card | +| Menu | three navigation tiles with "Page n" and a chevron (one double width), a tap on the first and on the wide one opens their pages, a hold does nothing | +| Full climate, mode keys | head on top, three keys bottom-centred, "20.5°" between, the third key sends `climate.set_hvac_mode cool`, a tap between head and keys opens the climate card | +| Full graph | head on top, no slider or panel, "21.4 °C", geometry, a tap asks for the history card | +| Full blind (Guition) / full media player (CYD) | three keys and "60 %" / "Blue in Green" in the middle, the first key sends `cover.open_cover` / the middle key `media_player.media_play_pause`, a tap between head and keys opens the card | +| Full forecast | geometry with the hours strip, a tap opens the weather card, six forecast/clock page flips with the second-hand tick running | +| Full clock | geometry, a tap does nothing | +| Full fan, small slider | a 64 / 40 px strip at the bottom, a tap above it sends `fan.toggle`, a drag from 30 % to 70 % sends `fan.set_percentage` near 70 | +| 48 tiles | eight pages of six with every name in place, Next seven times reaches page 8, the last tile still answers a tap, 42 singles plus a full tile with a slider on page 8, and the same slot back as a single tile with its own geometry | + +### What the harness found before the release + +- **A crash on a page switch from a full forecast to a full clock.** The forecast's hour labels are parts 18 and up, + and part 18 is the analog clock's second hand. Right after a page switch the slot already names the clock while its + parts still belong to the forecast, and the one-second tick called `part_line` on an hour label: + `lv_line_set_points` on a label overwrites the label's text pointer, and the next `lv_obj_clean` freed a pointer + into the point buffer ("pointer being freed was not allocated", the macOS crash report Max pasted). Fixed: the tick + only moves the second hand while the slot's parts are a dial (`w.extra_mode == "analog"`), and a slot rebuilds its + parts when it changes between full and double width (a CYD dial is large on a full card and small on a wide one, + which swaps labels for lines at the same indices). Six forecast/clock page flips are part of the harness now. +- A full-page tile got the domain's default direct control (a toggle on a light) instead of being the big button; + `resolve_controls` now gives a full tile no control unless one was chosen, and the editor shows "None" as chosen. +- The middle value of a full climate or cover card used the clock font, which has no '.' or '°' (it drew "20□5□"); + it uses the large value font now. +- On the CYD the full forecast took the Guition's three-row day columns and ran them together; a full forecast and + sun path keep their board's layout. + +## Hardware + +Both bench boards were OTA-flashed with firmware 0.2.62 from the Mac (`esphome upload easy--device.yaml +--device `), the Guition first, and report 0.2.62 in Home Assistant. Heap right after boot (HA sensors): Guition +89 KB free / 60 KB minimum / 45 KB largest block (0.2.59: 76 / 57 / 33), CYD 116 KB / 107 KB / 59 KB (0.2.59: 106 / +96 / 51). + +- `hw_push.py [cyd]` pushes one full-page scene straight to a board's `screen_message` action and, on the + Guition, snapshots the LVGL render through `ui_snapshot`: the big button (lit amber, in Max's dark look a brown + amber), the menu of navigation tiles with chevrons, the climate card with the room temperature and the mode keys, + the graph, the blind with its keys and position, the analog clock filling the page, the fan with its slider strip; + all as on the host. The CYD has no snapshot action; it answered `device_info` after every scene. +- `hw_push48.py guition|cyd [flat]` pushed 43 tiles (seven full pages of singles and a full tile with a slider on + page 8) and 48 single tiles; both boards accepted them. +- `hw_transition.py guition forecast,clock,forecast,button,clock,graph,forecast,fan,climate,clock` re-uses one slot for + one card kind after another over one API connection; the board stayed up throughout. +- The home add-on (0.2.69) restores the real layout within about half a minute after every push, so the snapshots are + taken right after a push. + +## Editor (the real add-on on the demo home in the browser pane) + +Checked twice: first in the previous editor (static/app.js, where the round was built), then in the Vue editor that +0.2.73 brought and into which session -75 ported the round (branch editor-fullpage, `00c9a4e`, with Vitest tests). +Previous editor: "12 / 48"; a full-page tile fills its page on the mockup; the tile sheet offers Normal / Double-width / +Full page with the hint; choosing Full page for Sam on a page with four other tiles kept Sam there and moved the other +four to the first free cells of page 8; ArrowDown moved the full tile a page down and swapped the full climate tile up; +"Go to page 4" from the picker landed on the free cell of page 8; the navigation-tile sheet shows "Goes to page" 1 … 8, +Size Normal / Double-width, the icon and the colours; Save & send stored `size: full` and the page tiles at their slots. +Vue editor: "8 pages · 12 / 48 tiles", the full tile drawn over its page, "Page 3 ›" on the navigation tiles, eight +"Go to page n" rows in the library, the tile drawer with Size Normal / Double-width / Full page (selected, with the +hint) and direct control None with the "at the bottom" hint, the navigation tile's drawer with Goes to page 1 … 8, +Size Normal / Double-width and the colours. + +## Not tested + +- A real finger on the glass with the new firmware (Max's visual check after the release, [[max-visual-check-after-flash]]). +- The add-on release on Max's Home Assistant Yellow: the add-on there is 0.2.69 until he updates it. diff --git a/docs/images/cyd-full-climate.png b/docs/images/cyd-full-climate.png new file mode 100644 index 0000000000000000000000000000000000000000..9d87c814bf6806897103f2e08c63bceb2c993711 GIT binary patch literal 11687 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 literal 0 HcmV?d00001 diff --git a/docs/images/cyd-full-light.png b/docs/images/cyd-full-light.png new file mode 100644 index 0000000000000000000000000000000000000000..1c06211f1b7ac4e249b620e2d739591f6a57f144 GIT binary patch literal 9437 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 literal 0 HcmV?d00001 diff --git a/docs/images/guition-full-climate.png b/docs/images/guition-full-climate.png new file mode 100644 index 0000000000000000000000000000000000000000..3a960f9bf81dd52c50c13f5f1c2807b12dc9bed1 GIT binary patch literal 21189 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 A1ONa4 literal 0 HcmV?d00001 diff --git a/docs/images/guition-full-light.png b/docs/images/guition-full-light.png new file mode 100644 index 0000000000000000000000000000000000000000..c0382fef6ec4bd5dbea040dc40c0b43ecd27e4cd GIT binary patch literal 17032 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 E09iZEWdHyG literal 0 HcmV?d00001 diff --git a/docs/images/guition-full-menu.png b/docs/images/guition-full-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..38f279669cba2f527be4622de2771716bb144410 GIT binary patch literal 27115 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 + runtime_tiles::big_icon_font = id(materialdesign_icons_big)->get_lv_font(); runtime_tiles::control_font = id(sublabel_big)->get_lv_font(); runtime_tiles::small_font = id(sublabel)->get_lv_font(); // Top bar (0.2.32+): values in the text font, icons in the small icon font; the clock @@ -1220,6 +1221,44 @@ font: size: 26 glyphs: *tile_icons + # The large icon of a card that takes the whole page (firmware 0.2.62+): the domain icons only, + # so the flash stays free for other things; a chosen icon outside this set keeps its usual size. + - file: 'fonts/materialdesignicons-webfont.ttf' + id: materialdesign_icons_big + size: 64 + glyphs: &tile_icons_big # GENERATED by tools/generate_icons.py from screen_manager/app/tile_icons.py + - "\U000F0335" # lightbulb + - "\U000F001B" # air-conditioner + - "\U000F070D" # robot-vacuum + - "\U000F0210" # fan + - "\U000F111C" # window-shutter + - "\U000F04B9" # sofa + - "\U000F029A" # gauge + - "\U000F051B" # timer-outline + - "\U000F0004" # account + - "\U000F07AE" # cctv + - "\U000F0150" # clock-outline + - "\U000F0493" # cog + - "\U000F0054" # arrow-right + - "\U000F0E4F" # lightbulb-off + - "\U000F0425" # power + - "\U000F0599" # weather-sunny + - "\U000F0594" # weather-night + - "\U000F0590" # weather-cloudy + - "\U000F0595" # weather-partly-cloudy + - "\U000F0597" # weather-rainy + - "\U000F0596" # weather-pouring + - "\U000F0598" # weather-snowy + - "\U000F067F" # weather-snowy-rainy + - "\U000F0591" # weather-fog + - "\U000F0592" # weather-hail + - "\U000F0593" # weather-lightning + - "\U000F067E" # weather-lightning-rainy + - "\U000F059D" # weather-windy + - "\U000F05D6" # alert-circle-outline + - "\U000F059C" # weather-sunset-up + - "\U000F059B" # weather-sunset-down + - file: 'fonts/materialdesignicons-webfont.ttf' id: watch_icon size: 18 diff --git a/home-like-2432s028.yaml b/home-like-2432s028.yaml index fc2d40d..9507e75 100644 --- a/home-like-2432s028.yaml +++ b/home-like-2432s028.yaml @@ -17,7 +17,7 @@ ############################################################ substitutions: - SCREEN_FIRMWARE_VERSION: "0.2.61" + SCREEN_FIRMWARE_VERSION: "0.2.62" # 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. @@ -267,6 +267,7 @@ esphome: runtime_tiles::now_time = []() { return id(homeassistant_time).now(); }; runtime_tiles::screen_awake = []() { return !id(display_dimmed); }; runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font(); + runtime_tiles::big_icon_font = id(materialdesign_icons_big)->get_lv_font(); runtime_tiles::control_font = id(sublabel_big)->get_lv_font(); runtime_tiles::small_font = id(sublabel)->get_lv_font(); // Top bar (0.2.32+): values in the text font, icons in the small icon font; the clock @@ -1249,6 +1250,44 @@ font: size: 18 glyphs: *tile_icons + # The large icon of a card that takes the whole page (firmware 0.2.62+): the domain icons only, + # so the flash stays free for other things; a chosen icon outside this set keeps its usual size. + - file: 'fonts/materialdesignicons-webfont.ttf' + id: materialdesign_icons_big + size: 40 + glyphs: &tile_icons_big # GENERATED by tools/generate_icons.py from screen_manager/app/tile_icons.py + - "\U000F0335" # lightbulb + - "\U000F001B" # air-conditioner + - "\U000F070D" # robot-vacuum + - "\U000F0210" # fan + - "\U000F111C" # window-shutter + - "\U000F04B9" # sofa + - "\U000F029A" # gauge + - "\U000F051B" # timer-outline + - "\U000F0004" # account + - "\U000F07AE" # cctv + - "\U000F0150" # clock-outline + - "\U000F0493" # cog + - "\U000F0054" # arrow-right + - "\U000F0E4F" # lightbulb-off + - "\U000F0425" # power + - "\U000F0599" # weather-sunny + - "\U000F0594" # weather-night + - "\U000F0590" # weather-cloudy + - "\U000F0595" # weather-partly-cloudy + - "\U000F0597" # weather-rainy + - "\U000F0596" # weather-pouring + - "\U000F0598" # weather-snowy + - "\U000F067F" # weather-snowy-rainy + - "\U000F0591" # weather-fog + - "\U000F0592" # weather-hail + - "\U000F0593" # weather-lightning + - "\U000F067E" # weather-lightning-rainy + - "\U000F059D" # weather-windy + - "\U000F05D6" # alert-circle-outline + - "\U000F059C" # weather-sunset-up + - "\U000F059B" # weather-sunset-down + - file: 'fonts/materialdesignicons-webfont.ttf' id: watch_icon size: 12 diff --git a/packages/cyd.yaml b/packages/cyd.yaml index a204295..1c74eeb 100644 --- a/packages/cyd.yaml +++ b/packages/cyd.yaml @@ -18,7 +18,7 @@ ############################################################ substitutions: - SCREEN_FIRMWARE_VERSION: "0.2.61" + SCREEN_FIRMWARE_VERSION: "0.2.62" # 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. @@ -271,6 +271,7 @@ esphome: runtime_tiles::now_time = []() { return id(homeassistant_time).now(); }; runtime_tiles::screen_awake = []() { return !id(display_dimmed); }; runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font(); + runtime_tiles::big_icon_font = id(materialdesign_icons_big)->get_lv_font(); runtime_tiles::control_font = id(sublabel_big)->get_lv_font(); runtime_tiles::small_font = id(sublabel)->get_lv_font(); // Top bar (0.2.32+): values in the text font, icons in the small icon font; the clock @@ -1238,6 +1239,44 @@ font: size: 18 glyphs: *tile_icons + # The large icon of a card that takes the whole page (firmware 0.2.62+): the domain icons only, + # so the flash stays free for other things; a chosen icon outside this set keeps its usual size. + - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf" + id: materialdesign_icons_big + size: 40 + glyphs: &tile_icons_big # GENERATED by tools/generate_icons.py from screen_manager/app/tile_icons.py + - "\U000F0335" # lightbulb + - "\U000F001B" # air-conditioner + - "\U000F070D" # robot-vacuum + - "\U000F0210" # fan + - "\U000F111C" # window-shutter + - "\U000F04B9" # sofa + - "\U000F029A" # gauge + - "\U000F051B" # timer-outline + - "\U000F0004" # account + - "\U000F07AE" # cctv + - "\U000F0150" # clock-outline + - "\U000F0493" # cog + - "\U000F0054" # arrow-right + - "\U000F0E4F" # lightbulb-off + - "\U000F0425" # power + - "\U000F0599" # weather-sunny + - "\U000F0594" # weather-night + - "\U000F0590" # weather-cloudy + - "\U000F0595" # weather-partly-cloudy + - "\U000F0597" # weather-rainy + - "\U000F0596" # weather-pouring + - "\U000F0598" # weather-snowy + - "\U000F067F" # weather-snowy-rainy + - "\U000F0591" # weather-fog + - "\U000F0592" # weather-hail + - "\U000F0593" # weather-lightning + - "\U000F067E" # weather-lightning-rainy + - "\U000F059D" # weather-windy + - "\U000F05D6" # alert-circle-outline + - "\U000F059C" # weather-sunset-up + - "\U000F059B" # weather-sunset-down + - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf" id: watch_icon size: 12 diff --git a/packages/guition.yaml b/packages/guition.yaml index b80a47d..66b2fb3 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.61" + SCREEN_FIRMWARE_VERSION: "0.2.62" # 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" @@ -174,6 +174,7 @@ esphome: runtime_tiles::now_time = []() { return id(homeassistant_time).now(); }; runtime_tiles::screen_awake = []() { return !id(display_dimmed); }; runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font(); + runtime_tiles::big_icon_font = id(materialdesign_icons_big)->get_lv_font(); runtime_tiles::control_font = id(sublabel_big)->get_lv_font(); runtime_tiles::small_font = id(sublabel)->get_lv_font(); // Top bar (0.2.32+): values in the text font, icons in the small icon font; the clock @@ -1209,6 +1210,44 @@ font: size: 26 glyphs: *tile_icons + # The large icon of a card that takes the whole page (firmware 0.2.62+): the domain icons only, + # so the flash stays free for other things; a chosen icon outside this set keeps its usual size. + - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf" + id: materialdesign_icons_big + size: 64 + glyphs: &tile_icons_big # GENERATED by tools/generate_icons.py from screen_manager/app/tile_icons.py + - "\U000F0335" # lightbulb + - "\U000F001B" # air-conditioner + - "\U000F070D" # robot-vacuum + - "\U000F0210" # fan + - "\U000F111C" # window-shutter + - "\U000F04B9" # sofa + - "\U000F029A" # gauge + - "\U000F051B" # timer-outline + - "\U000F0004" # account + - "\U000F07AE" # cctv + - "\U000F0150" # clock-outline + - "\U000F0493" # cog + - "\U000F0054" # arrow-right + - "\U000F0E4F" # lightbulb-off + - "\U000F0425" # power + - "\U000F0599" # weather-sunny + - "\U000F0594" # weather-night + - "\U000F0590" # weather-cloudy + - "\U000F0595" # weather-partly-cloudy + - "\U000F0597" # weather-rainy + - "\U000F0596" # weather-pouring + - "\U000F0598" # weather-snowy + - "\U000F067F" # weather-snowy-rainy + - "\U000F0591" # weather-fog + - "\U000F0592" # weather-hail + - "\U000F0593" # weather-lightning + - "\U000F067E" # weather-lightning-rainy + - "\U000F059D" # weather-windy + - "\U000F05D6" # alert-circle-outline + - "\U000F059C" # weather-sunset-up + - "\U000F059B" # weather-sunset-down + - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf" id: watch_icon size: 18 diff --git a/screen_manager/CHANGELOG.md b/screen_manager/CHANGELOG.md index b7b4307..7bb4b91 100644 --- a/screen_manager/CHANGELOG.md +++ b/screen_manager/CHANGELOG.md @@ -1,3 +1,13 @@ +## 0.2.74 (firmware 0.2.62) + +Forty-eight tiles, a tile over the whole page, and tiles that go to a page. + +- **Up to 48 tiles per screen**, one for every slot of the eight pages (twenty before). A screen only pays for the tiles it has: the firmware keeps a list as long as the layout instead of a fixed twenty, and on a Guition that list lives in PSRAM. +- **Full page.** A tile's size can now be *Full page*: it takes all six slots of a page and is one big button, so a screen on the wall next to the door switches the light when you push anywhere on it, without looking. While the light is on the whole card lights up in the state colour (amber for a lamp, purple for a scene, blue for a blind), so you see from across the room whether it is on. Holding it still opens the card. A small slider, direct controls or a graph sit at the bottom of the page, big enough for a thumb; the room above them is still the button. The clock, the weather (now with the next hours under the days) and the sun path fill the page. A tile you make full-page keeps its page: the other tiles there move to the first free spots after it. +- **Go to page.** A new built-in tile, *Go to page*, opens the page you choose: an arrow (or an icon of your own), its name and the page number, with a chevron like the rows in Home Assistant's settings. Put a full-page light switch on page 1 and a menu of *Heating*, *Blinds* and *Vacuum* on page 2, each with its own page. +- Claude in Home Assistant can do the same: `size: full` on a tile, and `screen.page` with `to_page` for a navigation tile. The screen's sensor reports both. +- Needs firmware 0.2.62: press **Update** on the screen. Includes everything from 0.2.73. + ## 0.2.73 (firmware 0.2.61) A new editor, built as a Vue app, with the same firmware. diff --git a/screen_manager/README.md b/screen_manager/README.md index 7183ef5..9172287 100644 --- a/screen_manager/README.md +++ b/screen_manager/README.md @@ -1,6 +1,6 @@ # ESP Screen Manager -Find your Home Assistant entities, arrange up to twenty tiles, and push changes +Find your Home Assistant entities, arrange up to 48 tiles, and push changes to a CYD or Guition screen without reflashing. Includes installation help, a unique per-device YAML, and support for multiple screens. diff --git a/screen_manager/app/claude_skill.py b/screen_manager/app/claude_skill.py index c608b0a..d062258 100644 --- a/screen_manager/app/claude_skill.py +++ b/screen_manager/app/claude_skill.py @@ -14,9 +14,11 @@ import zipfile import tile_icons from core import (ALERT_CAMERA_FIELD, ALERT_ENDINGS, ALERT_EVENT, ALERT_FALLBACK_ICON, ALERT_FIELDS, ALERT_LIMITS, ALERT_MAX_TIMEOUT, ALERT_MIN_FIRMWARE, ALERT_SUGGESTED_ICONS, AUTO_STANDBY_MIN_FIRMWARE, BROADCAST_DISMISS, BROADCAST_SHOW, - CONTROLS, DISPLAYS, MAX_PAGES, SETTINGS_PAGE_MIN_FIRMWARE, SLOTS_PER_PAGE, TILE_BACKGROUNDS, + CONTROLS, DISPLAYS, FULL_PAGE_MIN_FIRMWARE, MAX_PAGES, MAX_TILES, SETTINGS_PAGE_MIN_FIRMWARE, SLOTS_PER_PAGE, TILE_BACKGROUNDS, TILE_EVENTS, TILE_RESULT_EVENT, WAKE_SLEEP_MIN_FIRMWARE, SETTING_ENTITIES_MIN_FIRMWARE, DARK_MODE_MIN_FIRMWARE) +# Full-page tiles, navigation tiles and forty-eight tiles per screen. +FULL_PAGE_VERSION = '.'.join(str(part) for part in FULL_PAGE_MIN_FIRMWARE) NAME = 'esp-screens' # claude.ai accepts at most 200 characters; Claude Code picks the skill by this sentence. @@ -65,7 +67,7 @@ An alert is a card over the whole screen with an icon, a title, a subtitle and o ## Tiles on a screen -A screen shows tiles: two columns and {SLOTS_PER_PAGE // 2} rows per page, at most {MAX_PAGES} pages and twenty tiles. A tile is single or double-width; a double-width one starts in the left column and takes two spots. +A screen shows tiles: two columns and {SLOTS_PER_PAGE // 2} rows per page, at most {MAX_PAGES} pages and {MAX_TILES} tiles (twenty on firmware before {FULL_PAGE_VERSION}). A tile is single, double-width or full-page: a double-width one starts in the left column and takes two spots, a full-page one takes a whole page of its own and is one big button, so someone can switch a light by pushing anywhere on the screen without looking. A `controls` choice, a small slider or a graph sits at the bottom of that page (a full-page tile shows no control unless you choose one). Fire one of these events and ESP Screens changes that screen and sends it right away, the same way its own editor does. @@ -90,7 +92,7 @@ actions: | `name` | A name of your own on the tile; leave it out to keep Home Assistant's. | | `page` | Page, counted from 1. Without a spot the tile takes the first free one on that page. | | `row`, `column` | An exact spot on that page: row 1 to {SLOTS_PER_PAGE // 2}, column `left` or `right`. | -| `size` | `single` or `wide`. | +| `size` | `single`, `wide` or `full` (the whole page; firmware {FULL_PAGE_VERSION} or newer). | | `controls` | What you can operate on the tile itself (see below). | | `display` | How the tile draws itself (see below). | | `icon`, `color` | An icon from the list further down, and one of the pastel colors. | @@ -100,6 +102,8 @@ actions: A tile with a control, a forecast or a sun path is drawn double-width on its own; you don't have to ask for that. +A navigation tile goes to another page: add the entity `screen.page_3` (for page 3, `screen.page_1` to `screen.page_{MAX_PAGES}`) with a `name` such as "Heating"; it shows an arrow (or an `icon`), its name and the page number, and a tap opens that page. Handy as a menu on page 2 when page 1 is one full-page light switch. One such tile per page it goes to; it is single or double width, never full-page. + ### What a tile can do, per kind of entity | Entity | `controls` | @@ -114,7 +118,7 @@ Everything else shows its name and state, and opens a card of its own on a long ### Reading a screen first -Every screen also publishes what it shows, as `sensor.esp_screens_`: the state is the number of tiles, and the attributes hold `title`, `pages` and `tiles` with `entity`, `name`, `page`, `row`, `column`, `size`, `controls` and `display` per tile. Read that before moving things around, so you know what is already there and where. +Every screen also publishes what it shows, as `sensor.esp_screens_`: the state is the number of tiles, and the attributes hold `title`, `pages` and `tiles` with `entity`, `name`, `page`, `row`, `column`, `size`, `controls` and `display` per tile (and `to_page` for a `screen.page_` tile). Read that before moving things around, so you know what is already there and where. Ordering a page means naming the tiles that are on it, in the order you want: @@ -131,7 +135,7 @@ A tile from another page has to be moved there first (`esp_screens_move_tile` wi ### What comes back -ESP Screens answers every event with `{TILE_RESULT_EVENT}`, carrying `ok`, the `screen`, the `entity` and, when it refused, an `error` that says why ("No screen called ...", "That spot is taken by ...", "This screen already has twenty tiles"). The add-on log says the same. Nothing changes on a refused event. +ESP Screens answers every event with `{TILE_RESULT_EVENT}`, carrying `ok`, the `screen`, the `entity` and, when it refused, an `error` that says why ("No screen called ...", "That spot is taken by ...", "This screen already has {MAX_TILES} tiles", "No page is empty; a full-page tile needs a page of its own"). The add-on log says the same. Nothing changes on a refused event. ### How to work diff --git a/screen_manager/app/core.py b/screen_manager/app/core.py index cc69e06..9336fcc 100644 --- a/screen_manager/app/core.py +++ b/screen_manager/app/core.py @@ -11,16 +11,26 @@ 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 camera image'.split()) # Built-in cards without a Home Assistant entity; firmware 0.2.14+ renders them. -BUILTIN = {'screen.clock': 'Clock', 'screen.settings': 'Settings'} +BUILTIN = {'screen.clock': 'Clock', 'screen.settings': 'Settings', **{f'screen.page_{n}': f'Go to page {n}' for n in range(1, 9)}} +# A navigation tile (firmware 0.2.62+): screen.page_ goes to page n; one per page it goes to, so an entity still appears once. +PAGE_TILE = 'screen.page_' + +def page_target(entity): + """The page a navigation tile opens, counted from one; 0 for any other entity.""" + return int(entity[len(PAGE_TILE):]) if isinstance(entity, str) and entity in BUILTIN and entity.startswith(PAGE_TILE) else 0 NEW_DOMAINS = frozenset('sun timer person screen'.split()) # A camera or an image entity opens full screen on a Guition with firmware 0.2.57+ (camera_feed.py). CAMERA_DOMAINS = frozenset(('camera', 'image')) CAMERA_MIN_FIRMWARE = (0, 2, 57) +# Forty-eight tiles (one per slot), a tile over the whole page and the screen.page tile (firmware 0.2.62+). +MAX_TILES = 48 +LEGACY_MAX_TILES = 20 +FULL_PAGE_MIN_FIRMWARE = (0, 2, 62) 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.61' +FIRMWARE_VERSION = '0.2.62' # 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. @@ -63,22 +73,44 @@ SLOTS_PER_PAGE = 6 MAX_PAGES = 8 MAX_SLOTS = MAX_PAGES * SLOTS_PER_PAGE -def is_wide(tile): - return tile.get('options', {}).get('size') == 'wide' +TILE_SIZES_ON_SCREEN = ('single', 'wide', 'full') -def footprint(slot, wide): - return (slot, slot + 1) if wide else (slot,) +def tile_size(tile): + """'single', 'wide' (a row) or 'full' (the whole page, firmware 0.2.62+).""" + size = tile.get('options', {}).get('size', 'single') + return size if size in TILE_SIZES_ON_SCREEN else 'single' + +def is_wide(tile): + """Double width or the whole page: the card spans both columns.""" + return tile_size(tile) != 'single' + +def is_full(tile): + return tile_size(tile) == 'full' + +def cells_of(size): + return SLOTS_PER_PAGE if size == 'full' else 2 if size in ('wide', True) else 1 + +def page_start(slot): + return slot - slot % SLOTS_PER_PAGE + +def footprint(slot, size): + """The cells a tile of `size` takes from `slot` (True still means wide).""" + if size == 'full': + return tuple(range(page_start(slot), page_start(slot) + SLOTS_PER_PAGE)) + return (slot, slot + 1) if size in ('wide', True) else (slot,) def pack_slots(tiles): """In-order packing, the rule before explicit positions and what firmware without - `slots` still does: fill left to right, a wide card starts a new row.""" + `slots` still does: fill left to right, a wide card starts a new row, a full one a new page.""" position, slots = 0, [] for tile in tiles: - wide = is_wide(tile) - if wide and position % 2: + size = tile_size(tile) + if size == 'full' and position % SLOTS_PER_PAGE: + position += SLOTS_PER_PAGE - position % SLOTS_PER_PAGE + elif size == 'wide' and position % 2: position += 1 slots.append(position) - position += 2 if wide else 1 + position += cells_of(size) return slots def has_gaps(tiles): @@ -118,9 +150,10 @@ def resolve_controls(tile): """Control set a card shows on the screen, or None: only wide cards in the standard layout have room for one.""" options = tile.get('options', {}) domain = tile['entity'].split('.')[0] - if domain not in CONTROLS or options.get('size') != 'wide' or options.get('display', 'standard') != 'standard' or options.get('inline') == 'slider': + if domain not in CONTROLS or options.get('size') not in ('wide', 'full') or options.get('display', 'standard') != 'standard' or options.get('inline') == 'slider': return None - choice = options.get('controls', CONTROLS[domain][0][0]) + # A full-page card is one big button unless a control was chosen for it; a wide card shows its usual one. + choice = options.get('controls', 'none' if options.get('size') == 'full' else CONTROLS[domain][0][0]) return None if choice == 'none' else choice # Diagnostic entities every ESP Screens firmware exposes; the manager watches them for screens. @@ -353,6 +386,8 @@ def validate_header(data): def min_firmware(layout): """Oldest firmware that still accepts this layout; None when any version works.""" + if len(layout['tiles']) > LEGACY_MAX_TILES or any(is_full(t) or page_target(t['entity']) for t in layout['tiles']): + return FULL_PAGE_MIN_FIRMWARE if any(t['entity'].split('.')[0] in CAMERA_DOMAINS for t in layout['tiles']): return CAMERA_MIN_FIRMWARE if any(t['entity'] == 'screen.settings' for t in layout['tiles']): @@ -381,7 +416,8 @@ TILE_RESULT_EVENT = 'esp_screens_tile_result' TILE_EVENT_OPTIONS = {'size': 'size', 'controls': 'controls', 'display': 'display', 'icon': 'icon', 'color': 'background', 'background': 'background', 'tap': 'tap', 'inline': 'inline', 'history_hours': 'history_hours'} -TILE_SIZES = {'wide': 'wide', 'double': 'wide', 'large': 'wide', 'big': 'wide', +TILE_SIZES = {'full': 'full', 'fullscreen': 'full', 'full screen': 'full', 'full-screen': 'full', 'page': 'full', 'whole page': 'full', + 'wide': 'wide', 'double': 'wide', 'large': 'wide', 'big': 'wide', 'single': 'single', 'small': 'single', 'normal': 'single'} def loose(text): @@ -412,34 +448,43 @@ def occupied_cells(tiles, skip=None): for tile in tiles: if tile is skip or 'slot' not in tile: continue - cells.update(footprint(tile['slot'], is_wide(tile))) + cells.update(footprint(tile['slot'], tile_size(tile))) return cells -def free_slot(tiles, wide, page=None, skip=None): - """The first cell a tile of this width fits in, on `page` or anywhere; None when there is no room.""" +def free_slot(tiles, size, page=None, skip=None): + """The first cell a tile of this size fits in, on `page` or anywhere; None when there is no room.""" cells = occupied_cells(tiles, skip) + size = 'wide' if size is True else size first = 0 if page is None else page * SLOTS_PER_PAGE last = MAX_SLOTS if page is None else min(MAX_SLOTS, first + SLOTS_PER_PAGE) for slot in range(first, last): - if wide and (slot % 2 or slot + 1 >= last): + if size == 'full' and slot % SLOTS_PER_PAGE: continue - if not set(footprint(slot, wide)) & cells: + if size == 'wide' and (slot % 2 or slot + 1 >= last): + continue + if not set(footprint(slot, size)) & cells: return slot return None def place_tile(tile, tiles, page=None, slot=None): """Give a tile its cell: the one asked for when it is free, else the first free one (on `page`).""" - wide = is_wide(tile) + size = tile_size(tile) if slot is not None: - if wide and slot % 2: + if size == 'full': + slot = page_start(slot) + elif size == 'wide' and slot % 2: slot -= 1 - taken = next((t for t in tiles if t is not tile and slot in footprint(t.get('slot', -9), is_wide(t))), None) + wanted = set(footprint(slot, size)) + taken = next((t for t in tiles if t is not tile and 'slot' in t and wanted & set(footprint(t['slot'], tile_size(t)))), None) if taken: raise ValueError(f"That spot is taken by {taken['entity']}; give another spot or move that one first.") tile['slot'] = slot return - free = free_slot(tiles, wide, page, skip=tile) + free = free_slot(tiles, size, page, skip=tile) if free is None: + if size == 'full': + raise ValueError(f'Page {page + 1} is not empty; a full-page tile needs a page of its own.' if page is not None + else 'No page is empty; a full-page tile needs a page of its own.') raise ValueError(f'Page {page + 1} is full.' if page is not None else 'This screen has no room left.') tile['slot'] = free @@ -462,7 +507,7 @@ def tile_options(data, current=None): options['action'] = {'action': str(data['action']).strip(), **({'data': data['data']} if isinstance(data.get('data'), dict) and data['data'] else {})} if data.get('tap') in (None, ''): options['tap'] = 'action' - if options.get('controls', 'none') != 'none' or options.get('display') in WIDE_ONLY: + if (options.get('controls', 'none') != 'none' or options.get('display') in WIDE_ONLY) and options.get('size') != 'full': options['size'] = 'wide' return {key: value for key, value in options.items() if value not in (None, '')} @@ -501,13 +546,15 @@ def pack_page(tiles, page): position = page * SLOTS_PER_PAGE last = position + SLOTS_PER_PAGE for tile in tiles: - wide = is_wide(tile) - if wide and position % 2: + size = tile_size(tile) + if size == 'full' and (position != page * SLOTS_PER_PAGE or len(tiles) > 1): + raise ValueError(f'A full-page tile takes all of page {page + 1}; nothing else fits there.') + if size == 'wide' and position % 2: position += 1 - if position + (2 if wide else 1) > last: + if position + cells_of(size) > last: raise ValueError(f'That does not fit on page {page + 1}; a double-width tile takes two spots.') tile['slot'] = position - position += 2 if wide else 1 + position += cells_of(size) def apply_tile_event(layout, action, data): """The layout after one tile event. Raises ValueError with the sentence the log and the answer show.""" @@ -559,7 +606,7 @@ def apply_tile_event(layout, action, data): else: if not entity_id(entity) and entity not in BUILTIN: raise ValueError(f'{entity} cannot go on a screen.') - was_wide, had_slot = bool(found) and is_wide(found), (found or {}).get('slot') + was_size, had_slot = tile_size(found) if found else 'single', (found or {}).get('slot') options = tile_options(data, (found or {}).get('options')) tile = found or {'entity': entity, 'name': ''} if data.get('name') not in (None, ''): @@ -569,17 +616,25 @@ def apply_tile_event(layout, action, data): elif found: tile.pop('options', None) if found is None: - if len(tiles) >= 20: - raise ValueError('This screen already has twenty tiles; remove one first.') + if len(tiles) >= MAX_TILES: + raise ValueError(f'This screen already has {MAX_TILES} tiles; remove one first.') tiles.append(tile) - wide = is_wide(tile) + size = tile_size(tile) move = found is None or had_slot is None or page is not None or slot is not None - if not move and wide != was_wide: - # A tile that just grew keeps its spot when the cell beside it is free. - move = (wide and had_slot % 2) or bool(set(footprint(had_slot, wide)) & occupied_cells([t for t in tiles if t is not tile])) + if not move and size != was_size: + # A tile that just grew keeps its spot when the cells it needs are free. + start = page_start(had_slot) if size == 'full' else had_slot - had_slot % 2 if size == 'wide' else had_slot + move = start != had_slot or bool(set(footprint(start, size)) & occupied_cells([t for t in tiles if t is not tile])) if move: tile.pop('slot', None) - place_tile(tile, tiles, page, slot) + if size == 'full' and had_slot is not None and page is None and slot is None: + # A tile that grew to the whole page stays on its page when the page is otherwise empty. + try: + place_tile(tile, tiles, had_slot // SLOTS_PER_PAGE) + except ValueError: + place_tile(tile, tiles) + else: + place_tile(tile, tiles, page, slot) result['tiles'] = tiles return result @@ -594,7 +649,8 @@ def layout_snapshot(screen, layout): 'column': 'right' if slot % 2 else 'left', 'slot': slot, 'size': options.get('size', 'single'), 'controls': options.get('controls', ''), 'display': options.get('display', 'standard'), 'tap': options.get('tap', 'auto'), - **({'action': options['action']} if options.get('tap') == 'action' and 'action' in options else {})}) + **({'action': options['action']} if options.get('tap') == 'action' and 'action' in options else {}), + **({'to_page': page_target(tile['entity'])} if page_target(tile['entity']) else {})}) return {'screen': screen.get('name', ''), 'node': screen.get('node') or '', 'title': layout.get('title', ''), 'pages': max([tile['page'] for tile in tiles], default=1), 'tiles': tiles} @@ -653,8 +709,8 @@ def validate_layout(data, stored=False): title, tiles = data.get('title'), data.get('tiles') if not isinstance(title, str) or not title.strip() or len(title.encode()) > 96: raise ValueError('Give the screen a title of at most 96 bytes.') - if not isinstance(tiles, list) or len(tiles) > 20: - raise ValueError('Choose at most 20 tiles.') + if not isinstance(tiles, list) or len(tiles) > MAX_TILES: + raise ValueError(f'Choose at most {MAX_TILES} tiles.') clean, seen = [], set() for tile in tiles: if not isinstance(tile, dict) or not entity_id(tile.get('entity')): @@ -678,18 +734,23 @@ def validate_layout(data, stored=False): options = tile['options'] if not isinstance(options, dict) or set(options) - {'tap', 'display', 'inline', 'history_hours', 'background', 'size', 'icon', 'controls', 'action'}: raise ValueError('Unknown tile settings.') + # A navigation tile (screen.page_, firmware 0.2.62+) has a name, an icon, a colour and a width; never the page. + if page_target(tile['entity']): + if options.get('size') == 'full': + raise ValueError('A navigation tile is single or double width.') + options = {k: v for k, v in options.items() if k not in ('display', 'inline', 'controls', 'history_hours')} if 'background' in options and (not isinstance(options['background'],str) or options['background'] not in TILE_BACKGROUNDS): raise ValueError('Choose a pastel background color from the palette.') if 'icon' in options and not (options['icon'] == 'auto' or isinstance(options['icon'], str) and options['icon'] in tile_icons.ICONS): raise ValueError('Choose an icon from the list.') domain = tile['entity'].split('.')[0] displays = DISPLAYS.get(domain, ('standard', 'watch')) - choices = {'tap': ('auto', 'detail', 'toggle', 'none', 'action'), 'display': displays, 'inline': ('none', 'slider'), 'size': ('single', 'wide')} + choices = {'tap': ('auto', 'detail', 'toggle', 'none', 'action'), 'display': displays, 'inline': ('none', 'slider'), 'size': TILE_SIZES_ON_SCREEN} for key, allowed in choices.items(): if key in options and options[key] not in allowed: raise ValueError('Invalid tile setting: ' + key) - # The five-day strip and the sun path only fit a double-width card. - if options.get('display') in WIDE_ONLY: + # The five-day strip and the sun path only fit a double-width card (or the whole page). + if options.get('display') in WIDE_ONLY and options.get('size') != 'full': options = {**options, 'size': 'wide'} # On / off sends .toggle. Whether Home Assistant offers that for the entity is checked when saving # (Manager.check_supported, app 0.2.67); the built-in cards have nothing to switch. @@ -721,9 +782,12 @@ def validate_layout(data, stored=False): for item, slot in zip(clean, given): if type(slot) is not int or not 0 <= slot < MAX_SLOTS: raise ValueError('Invalid tile position; refresh the management page.') - if is_wide(item) and slot % 2: + size = tile_size(item) + if size == 'full' and slot % SLOTS_PER_PAGE: + raise ValueError('A full-page tile starts at the top of its page.') + if size == 'wide' and slot % 2: raise ValueError('A double-width tile starts in the left column.') - for cell in footprint(slot, is_wide(item)): + for cell in footprint(slot, size): if cell in occupied: raise ValueError('Two tiles are in the same spot.') occupied.add(cell) @@ -1040,8 +1104,10 @@ def state_message(index, tile, states, extra=None, precision=None, entry=None): if tile['entity'] in BUILTIN: message = {'v': 1, 'op': 'state', 'i': index, 'entity': tile['entity'], 'name': short(tile['name'] or BUILTIN[tile['entity']], 80), 'state': 'ok', 'a': {}} - if 'options' in tile: - message['o'] = tile['options'] + # The same wire form as any tile, so a chosen icon travels as its codepoint (app 0.2.74+). + options = screen_options(tile, {}) + if options is not None: + message['o'] = options return message state = states.get(tile['entity'], {}) attrs = state.get('attributes', {}) diff --git a/screen_manager/app/tile_icons.py b/screen_manager/app/tile_icons.py index e6a5489..02e1874 100644 --- a/screen_manager/app/tile_icons.py +++ b/screen_manager/app/tile_icons.py @@ -371,8 +371,16 @@ DEFAULTS = {'light': 'lightbulb', 'climate': 'air-conditioner', 'vacuum': 'robot 'cover': 'window-shutter', 'scene': 'sofa', 'script': 'sofa', 'sensor': 'gauge', 'binary_sensor': 'gauge', 'timer': 'timer-outline', 'person': 'account', 'camera': 'cctv', 'image': 'cctv', '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'} +BUILTIN_TILES = {'screen.clock': 'clock-outline', 'screen.settings': 'cog', **{f'screen.page_{n}': 'arrow-right' for n in range(1, 9)}} FALLBACK = 'power' +# The large icon font of a card that takes the whole page (firmware 0.2.62+): what the screen draws on its own for a +# domain, a state or a built-in card, at 64 px on the Guition and 40 px on the CYD. Kept to these so the CYD's flash +# stays free; a chosen icon outside this set shows at its usual size in the big circle. +BIG_GLYPHS = tuple(dict.fromkeys(list(DEFAULTS.values()) + list(BUILTIN_TILES.values()) + ['lightbulb-off', FALLBACK] + + ['weather-sunny', 'weather-night', 'weather-cloudy', 'weather-partly-cloudy', 'weather-rainy', + 'weather-pouring', 'weather-snowy', 'weather-snowy-rainy', 'weather-fog', 'weather-hail', + 'weather-lightning', 'weather-lightning-rainy', 'weather-windy', 'alert-circle-outline', + 'weather-sunset-up', 'weather-sunset-down'])) 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', 'chevron-right', 'close', 'power', 'fire', 'snowflake') diff --git a/screen_manager/config.yaml b/screen_manager/config.yaml index f0c15ff..1f23fc0 100644 --- a/screen_manager/config.yaml +++ b/screen_manager/config.yaml @@ -1,5 +1,5 @@ name: ESP Screen Manager -version: "0.2.73" +version: "0.2.74" 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_full_page_tiles.py b/tests/test_full_page_tiles.py new file mode 100644 index 0000000..6159cca --- /dev/null +++ b/tests/test_full_page_tiles.py @@ -0,0 +1,154 @@ +"""Full-page tiles, navigation tiles and forty-eight tiles per screen (app 0.2.74, firmware 0.2.62).""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app')) +from core import (BUILTIN, FULL_PAGE_MIN_FIRMWARE, MAX_PAGES, MAX_TILES, SLOTS_PER_PAGE, apply_tile_event, # noqa: E402 + footprint, free_slot, layout_snapshot, min_firmware, pack_slots, page_target, place_tile, resolve_controls, + state_message, tile_size, validate_layout) +import tile_icons # noqa: E402 + + +def tile(entity, slot=None, **options): + item = {'entity': entity, 'name': ''} + if slot is not None: + item['slot'] = slot + if options: + item['options'] = options + return item + + +class Sizes(unittest.TestCase): + def test_footprints(self): + self.assertEqual(tile_size(tile('light.a')), 'single') + self.assertEqual(tile_size(tile('light.a', size='wide')), 'wide') + self.assertEqual(tile_size(tile('light.a', size='full')), 'full') + self.assertEqual(tile_size(tile('light.a', size='huge')), 'single') + self.assertEqual(footprint(3, 'single'), (3,)) + self.assertEqual(footprint(2, 'wide'), (2, 3)) + self.assertEqual(footprint(2, True), (2, 3)) + # A full tile takes its page whatever cell it is named by. + self.assertEqual(footprint(9, 'full'), (6, 7, 8, 9, 10, 11)) + + def test_in_order_packing_gives_a_full_tile_its_own_page(self): + tiles = [tile('light.a'), tile('light.b', size='full'), tile('light.c'), tile('light.d', size='wide')] + self.assertEqual(pack_slots(tiles), [0, 6, 12, 14]) + tiles = [tile('light.b', size='full'), tile('light.c')] + self.assertEqual(pack_slots(tiles), [0, 6]) + + def test_free_slot_for_a_full_tile_is_an_empty_page(self): + tiles = [tile('light.a', 0), tile('light.b', 7)] + self.assertEqual(free_slot(tiles, 'full'), 12) + self.assertIsNone(free_slot(tiles, 'full', page=1)) + self.assertEqual(free_slot(tiles, 'full', page=2), 12) + self.assertEqual(free_slot(tiles, 'wide', page=0), 2) + + def test_place_tile_snaps_a_full_tile_to_its_page_start(self): + tiles = [tile('light.a', 0)] + full = tile('light.b', size='full') + tiles.append(full) + place_tile(full, tiles, slot=9) + self.assertEqual(full['slot'], 6) + with self.assertRaisesRegex(ValueError, 'taken by light.b'): + place_tile(tile('light.c'), tiles, slot=10) + with self.assertRaisesRegex(ValueError, 'page of its own'): + place_tile(tile('light.d', size='full'), tiles, page=0) + + +class Layouts(unittest.TestCase): + def test_forty_eight_tiles_and_the_firmware_they_need(self): + forty_eight = validate_layout({'title': 'Home', 'tiles': [tile(f'light.a{i}') for i in range(MAX_TILES)]}) + self.assertEqual(len(forty_eight['tiles']), 48) + self.assertEqual([t['slot'] for t in forty_eight['tiles']][-1], 47) + self.assertEqual(min_firmware(forty_eight), FULL_PAGE_MIN_FIRMWARE) + twenty = validate_layout({'title': 'Home', 'tiles': [tile(f'light.a{i}') for i in range(20)]}) + self.assertEqual(min_firmware(twenty), (0, 2, 7)) + with self.assertRaisesRegex(ValueError, 'at most 48'): + validate_layout({'title': 'Home', 'tiles': [tile(f'light.a{i}') for i in range(49)]}) + + def test_full_tile_positions(self): + layout = validate_layout({'title': 'Home', 'tiles': [tile('light.a', 0), tile('light.b', 6, size='full'), tile('light.c', 12)]}) + self.assertEqual([t['slot'] for t in layout['tiles']], [0, 6, 12]) + self.assertEqual(min_firmware(layout), FULL_PAGE_MIN_FIRMWARE) + with self.assertRaisesRegex(ValueError, 'top of its page'): + validate_layout({'title': 'Home', 'tiles': [tile('light.b', 8, size='full')]}) + with self.assertRaisesRegex(ValueError, 'same spot'): + validate_layout({'title': 'Home', 'tiles': [tile('light.a', 6, size='full'), tile('light.c', 11)]}) + # A full-page card is one big button unless a control was chosen; a wide card shows its usual one. + self.assertIsNone(resolve_controls(tile('light.a', 0, size='full'))) + self.assertEqual(resolve_controls(tile('light.a', 0, size='full', controls='brightness')), 'brightness') + self.assertEqual(resolve_controls(tile('light.a', 0, size='wide')), 'toggle') + # A forecast keeps a full tile full; on a single tile it still widens to a row. + full = validate_layout({'title': 'Home', 'tiles': [tile('weather.home', 0, display='forecast', size='full')]}) + self.assertEqual(full['tiles'][0]['options']['size'], 'full') + wide = validate_layout({'title': 'Home', 'tiles': [tile('weather.home', 0, display='forecast')]}) + self.assertEqual(wide['tiles'][0]['options']['size'], 'wide') + + def test_navigation_tile(self): + # One built-in entity per page it goes to, so an entity still appears once on a screen. + self.assertEqual(BUILTIN['screen.page_3'], 'Go to page 3') + self.assertEqual([page_target(e) for e in ('screen.page_1', 'screen.page_8', 'screen.page_9', 'screen.page', 'light.a')], [1, 8, 0, 0, 0]) + layout = validate_layout({'title': 'Home', 'tiles': [tile('screen.page_3', 0, icon='radiator'), tile('screen.page_5', 1)]}) + self.assertEqual(layout['tiles'][0]['options'], {'icon': 'radiator'}) + self.assertEqual(min_firmware(layout), FULL_PAGE_MIN_FIRMWARE) + # A display or control makes no sense on it and is dropped; the whole page is not for it. + layout = validate_layout({'title': 'Home', 'tiles': [tile('screen.page_2', 0, display='digital', size='wide')]}) + self.assertEqual(layout['tiles'][0]['options'], {'size': 'wide'}) + with self.assertRaisesRegex(ValueError, 'single or double width'): + validate_layout({'title': 'Home', 'tiles': [tile('screen.page_2', 0, size='full')]}) + for entity in ('screen.page_9', 'screen.page', 'screen.page_0'): + with self.assertRaisesRegex(ValueError, "isn't supported"): + validate_layout({'title': 'Home', 'tiles': [tile(entity, 0)]}) + with self.assertRaisesRegex(ValueError, 'only appear once'): + validate_layout({'title': 'Home', 'tiles': [tile('screen.page_2', 0), tile('screen.page_2', 1)]}) + + def test_snapshot_and_wire(self): + layout = validate_layout({'title': 'Home', 'tiles': [tile('screen.page_3', 0, icon='radiator'), tile('light.a', 6, size='full')]}) + snapshot = layout_snapshot({'name': 'Hall', 'node': 'hall'}, layout) + self.assertEqual(snapshot['tiles'][0]['to_page'], 3) + self.assertEqual(snapshot['tiles'][1]['size'], 'full') + self.assertNotIn('to_page', snapshot['tiles'][1]) + self.assertEqual(snapshot['pages'], 2) + # A built-in tile's chosen icon travels as its codepoint, like every other tile's, and the page with it. + message = state_message(0, layout['tiles'][0], {}) + self.assertEqual(message['o'], {'icon': tile_icons.ICONS['radiator'][0]}) + self.assertEqual(message['name'], 'Go to page 3') + # Without a chosen icon nothing travels: the screen draws the arrow itself, the editor shows the same one. + self.assertNotIn('o', state_message(0, tile('screen.page_2', 0), {})) + self.assertEqual(tile_icons.editor()['builtin']['screen.page_2'], tile_icons.GLYPHS['arrow-right']) + # The big icon font carries the domain icons and the built-in cards' icons, every name a real glyph. + for name in tile_icons.BIG_GLYPHS: + self.assertIn(name, tile_icons.GLYPHS) + self.assertIn(tile_icons.BUILTIN_TILES['screen.page_1'], tile_icons.BIG_GLYPHS) + self.assertIn('lightbulb-off', tile_icons.BIG_GLYPHS) + + +class Events(unittest.TestCase): + def test_add_a_full_tile_and_a_navigation_tile(self): + layout = validate_layout({'title': 'Home', 'tiles': [tile('light.a', 0), tile('light.b', 1)]}) + result = apply_tile_event(layout, 'add', {'entity': 'light.c', 'size': 'full screen'}) + self.assertEqual(next(t for t in result['tiles'] if t['entity'] == 'light.c'), {'entity': 'light.c', 'name': '', 'options': {'size': 'full'}, 'slot': 6}) + result = apply_tile_event(result, 'add', {'entity': 'screen.page_2', 'name': 'Heating', 'page': 1}) + page_tile = next(t for t in result['tiles'] if t['entity'] == 'screen.page_2') + self.assertEqual((page_tile['name'], page_tile.get('options'), page_tile['slot']), ('Heating', None, 2)) + with self.assertRaisesRegex(ValueError, 'cannot go on a screen'): + apply_tile_event(result, 'add', {'entity': 'screen.page_9'}) + # A tile that grows to the whole page keeps its page when nothing else is there, else moves to an empty page. + result = apply_tile_event(result, 'add', {'entity': 'light.a', 'size': 'full'}) + self.assertEqual(next(t for t in result['tiles'] if t['entity'] == 'light.a')['slot'], 12) + with self.assertRaisesRegex(ValueError, 'not empty'): + apply_tile_event(result, 'move', {'entity': 'light.c', 'page': 1}) + # Ordering the page of a full tile keeps it at the page start. + result = apply_tile_event(result, 'order', {'page': 2, 'entities': ['light.c']}) + self.assertEqual(next(t for t in result['tiles'] if t['entity'] == 'light.c')['slot'], 6) + + def test_forty_eight_is_the_limit(self): + layout = validate_layout({'title': 'Home', 'tiles': [tile(f'light.a{i}', i) for i in range(MAX_TILES)]}) + with self.assertRaisesRegex(ValueError, f'{MAX_TILES} tiles'): + apply_tile_event(layout, 'add', {'entity': 'light.extra'}) + self.assertEqual(SLOTS_PER_PAGE * MAX_PAGES, MAX_TILES) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_portal.py b/tests/test_portal.py index d06edb7..a2a3a71 100644 --- a/tests/test_portal.py +++ b/tests/test_portal.py @@ -20,7 +20,7 @@ class ProtocolTests(unittest.TestCase): with self.assertRaises(ValueError): packets({'huge':'x'*4096}) def test_layout_rejects_duplicates_unsupported_and_overflow(self): - for tiles in [[{'entity':'lock.front'}],[{'entity':'light.a'}]*2,[{'entity':f'light.a{i}'} for i in range(21)],[{'entity':'light.a;restart'}]]: + for tiles in [[{'entity':'lock.front'}],[{'entity':'light.a'}]*2,[{'entity':f'light.a{i}'} for i in range(49)],[{'entity':'light.a;restart'}]]: with self.assertRaises(ValueError): validate_layout({'title':'Home','tiles':tiles}) self.assertEqual(validate_layout({'title':'Home','tiles':[]})['tiles'],[]) diff --git a/tests/test_runtime_model.cpp b/tests/test_runtime_model.cpp index 07f0795..376cf14 100644 --- a/tests/test_runtime_model.cpp +++ b/tests/test_runtime_model.cpp @@ -23,10 +23,14 @@ int main() { assert(!m.accepts(0, "light.a")); assert(m.set_layout({}, "Empty", changed)); assert(changed && m.ready() && m.count == 0); std::vector many; - for (int i = 0; i < 21; ++i) many.push_back("light.a" + std::to_string(i)); + // Eight pages of six slots (firmware 0.2.62+): 48 tiles fit, a 49th is refused. + for (int i = 0; i < 49; ++i) many.push_back("light.a" + std::to_string(i)); assert(!m.set_layout(many, "Too many", changed)); assert(m.count == 0); - many.pop_back(); assert(m.set_layout(many,"Twenty",changed)); - assert(m.count==20 && m.accepts(19,"light.a19")); + many.pop_back(); assert(m.set_layout(many,"Forty-eight",changed)); + assert(m.count==48 && m.tiles.size()==48 && m.accepts(47,"light.a47")); + // A screen pays only for the tiles it has: twelve tiles, twelve on the heap. + assert(m.set_layout({"light.a0","light.a1","light.a2","light.a3","light.a4","light.a5","light.a6","light.a7","light.a8","light.a9","light.a10","light.a11"},"Twelve",changed)); + assert(changed && m.count==12 && m.tiles.size()==12); for(size_t i=0;i