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 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-18 08:44:44 +02:00
parent 00c9a4e4f3
commit bb25353089
29 changed files with 948 additions and 128 deletions
+8 -2
View File
@@ -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
<img src="docs/images/guition-controls.png" width="32%" alt="Double-width tiles with direct control: the heating setpoint, a dimmer and the Sonos volume">
<img src="docs/images/guition-page-4.png" width="32%" alt="An energy graph, the robot vacuum, a coffee machine, a fan and a good-night script with pastel backgrounds">
</p>
<p align="center">
<img src="docs/images/guition-full-light.png" width="32%" alt="A full-page tile: one big amber light switch that fills the screen, so you push anywhere without looking">
<img src="docs/images/guition-full-menu.png" width="32%" alt="Navigation tiles: Heating, Blinds and Weather each open their own page, next to a person and a lamp">
<img src="docs/images/guition-full-climate.png" width="32%" alt="A full-page heating tile: the room temperature big in the middle, the mode keys at the bottom">
</p>
<p align="center">
<img src="docs/images/guition-weather.png" width="32%" alt="Weather card: current weather, the coming hours and the coming days with chance of rain">
<img src="docs/images/guition-climate.png" width="32%" alt="Climate card: the target temperature between big minus and plus keys, the mode keys, and fan and swing choices">
+18 -3
View File
@@ -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.
<p align="center">
<img src="docs/images/guition-full-light.png" width="32%" alt="A full-page tile on the Guition: one big amber light switch">
<img src="docs/images/guition-full-menu.png" width="32%" alt="Navigation tiles on the Guition: Heating, Blinds and Weather open their own pages">
<img src="docs/images/guition-full-fan.png" width="32%" alt="A full-page fan tile with its speed slider along the bottom">
</p>
<p align="center">
<img src="docs/images/cyd-full-light.png" width="32%" alt="The same full-page light switch on the CYD">
<img src="docs/images/cyd-full-menu.png" width="32%" alt="Navigation tiles on the CYD">
<img src="docs/images/cyd-full-climate.png" width="32%" alt="A full-page heating tile on the CYD with the mode keys at the bottom">
</p>
- **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
+57 -14
View File
@@ -7,13 +7,24 @@
#include <vector>
#include <cmath>
#include <cstdint>
// 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_<n> (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<char>('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_<n>, 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<Tile, MAX_TILES> &tiles, size_t count, std::array<Placement, MAX_TILES> &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<class T> struct TileAllocator {
using value_type = T;
TileAllocator() = default;
template<class U> TileAllocator(const TileAllocator<U> &) {}
T *allocate(size_t n) { return esphome::RAMAllocator<T>().allocate(n); }
void deallocate(T *p, size_t n) { esphome::RAMAllocator<T>().deallocate(p, n); }
bool operator==(const TileAllocator &) const { return true; }
bool operator!=(const TileAllocator &) const { return false; }
};
using TileList = std::vector<Tile, TileAllocator<Tile>>;
#else
using TileList = std::vector<Tile>;
#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<Placement, MAX_TILES> &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<uint8_t>(position / SLOTS_PER_PAGE), static_cast<uint8_t>(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<Tile, MAX_TILES> &tiles, size_t count, std
struct Model;
inline unsigned place(const Model &m, std::array<Placement, MAX_TILES> &out);
struct Model {
std::array<Tile, MAX_TILES> 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<uint8_t, MAX_TILES> 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<Placement, MAX_TILES> &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<uint8_t>(slot / SLOTS_PER_PAGE), static_cast<uint8_t>(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<unsigned>(m.pages, MAX_PAGES)});
+221 -32
View File
@@ -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<lv_obj_t *, 20> 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<lv_obj_t *, 36> 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<lv_obj_t *,3> keys{}, key_icons{}; std::array<int,3> key_commands{}; std::array<std::string,3> key_args; std::array<int,3> 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<const unsigned char *>(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<size_t>(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(j<hours)continue;
for(unsigned k=18+3*j;k<21+3*j;++k)if(w.parts[k])lv_obj_add_flag(w.parts[k],LV_OBJ_FLAG_HIDDEN);
}
if(hours){
int column=width/hours,y0=height-hours_h;
for(unsigned j=0;j<hours;++j){
const auto &h=t.extra().hours[j];int x=j*column;
char temp[16];if(std::isfinite(h.temp))snprintf(temp,sizeof(temp),"%.0f°",h.temp);else temp[0]=0;
for(unsigned k=18+3*j;k<21+3*j;++k)if(w.parts[k])lv_obj_remove_flag(w.parts[k],LV_OBJ_FLAG_HIDDEN);
part_label(w,18+3*j,day_icon,x,y0+day_h,column,LV_TEXT_ALIGN_CENTER,weather_icon(h.condition));
part_label(w,19+3*j,w.value_font,x,y0+day_h+icon_col,column,LV_TEXT_ALIGN_CENTER,temp);
part_label(w,20+3*j,title_font,x,y0,column,LV_TEXT_ALIGN_CENTER,h.time);
}
}
height=top_h;
int block=std::max(icon_h,temp_h)+2+text_h,y=std::max(0,(height-block)/2);
char b[24];snprintf(b,sizeof(b),"%.0f°",t.current);
auto *icon=part_label(w,0,w.icon_font,0,y+(std::max(icon_h,temp_h)-icon_h)/2,icon_h+4,LV_TEXT_ALIGN_LEFT,t.available()?weather_icon(t.state):"\U000F0595");
lv_obj_set_width(icon,lv_font_get_line_height(w.icon_font)+4);
part_label(w,1,temp_font,icon_h+6,y+(std::max(icon_h,temp_h)-temp_h)/2,left-icon_h-6,LV_TEXT_ALIGN_LEFT,std::isfinite(t.current)?b:"");
part_label(w,2,w.value_font,0,y+std::max(icon_h,temp_h)+2,left-4,LV_TEXT_ALIGN_LEFT,weather_text(t.state));
int column=(width-left)/5,day_h=lv_font_get_line_height(title_font),icon_col=lv_font_get_line_height(day_icon);
int column=(width-left)/5;
for(unsigned k=0;k<5;++k){
static const Forecast no_day;int x=left+k*column;bool has=k<t.extra().forecast.size();const auto &f=has?t.extra().forecast[k]:no_day;
char temps[24];if(has && std::isfinite(f.high))snprintf(temps,sizeof(temps),std::isfinite(f.low)?"%.0f/%.0f":"%.0f",f.high,f.low);else temps[0]=0;
@@ -2295,6 +2352,10 @@ struct PanelMetrics { int key_w, key_h, radius, gap, pill_w, pill_key, slider_w,
inline PanelMetrics panel_metrics(bool large) {
return large ? PanelMetrics{60,46,14,8,196,52,140,44,76,40,22,8,4} : PanelMetrics{40,34,9,4,128,36,90,30,48,26,14,6,6};
}
// The same controls at the bottom of a full-page card (firmware 0.2.62+): keys a thumb finds without looking.
inline PanelMetrics panel_metrics_full(bool big) {
return big ? PanelMetrics{120,84,24,16,300,84,400,56,120,60,30,8,4} : PanelMetrics{80,44,12,8,200,48,260,34,76,40,18,6,4};
}
inline lv_obj_t *panel_obj(lv_obj_t *parent,bool clickable) {
auto *o=lv_obj_create(parent);lv_obj_remove_style_all(o);lv_obj_remove_flag(o,LV_OBJ_FLAG_SCROLLABLE);
if(clickable)lv_obj_add_flag(o,LV_OBJ_FLAG_CLICKABLE);else lv_obj_remove_flag(o,LV_OBJ_FLAG_CLICKABLE);
@@ -2339,13 +2400,13 @@ inline lv_obj_t *panel_icon(Widgets &w,unsigned n,const lv_font_t *font) {
// from the text, including the gap, or 0 when the card shows no panel.
inline int layout_panel(Widgets &w,const Tile &t,bool large,int content_w,int content_h) {
std::string mode=tile_controls::panel_kind(t);
const PanelMetrics m=panel_metrics(large);
const lv_font_t *icon_font=mini_icon_font?mini_icon_font:w.icon_font;
const PanelMetrics m=w.full?panel_metrics_full(w.base_height>80):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<int>(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<int>(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<mid_font_h)return;
set_font(w.unit,mid_font);set_text_align(w.unit,LV_TEXT_ALIGN_CENTER);label(w.unit,middle);
lv_obj_set_pos(w.unit,0,mid_top+std::max(0,(mid_h-mid_font_h)/2));lv_obj_set_size(w.unit,content_w,mid_font_h);
lv_obj_remove_flag(w.unit,LV_OBJ_FLAG_HIDDEN);
}
// One card: name, status, icon, layout and palette. Everything reads the model; nothing is created
// unless the card's custom parts or controls change kind.
inline void render_slot(size_t slot) {
@@ -2543,6 +2687,7 @@ inline void render_slot(size_t slot) {
std::string value = t.state;
// Nothing in Home Assistant stands behind the settings card, so it says the same with the link down.
if (t.is_settings()) value = "Tap to open";
else if (t.is_page()) value = "Page " + std::to_string(t.page_target());
else if (!fresh() || !t.available()) value = "Unavailable";
else if (t.refused_at && esphome::millis() - t.refused_at < 4000) value = "Refused";
else if (d == "light" && t.state == "on" && std::isfinite(t.brightness)) value = std::to_string(static_cast<int>(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<value.y1 && value.y2<=content.y2;
lv_area_t circle;lv_obj_get_coords(w.circle,&circle);
fits=fits && circle.x1>=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<track.y1 && track.y2<=content.y2;
}
}else if(!custom){
fits=fits && title.x1>=content.x1 && title.x2<=content.x2 &&
value.x1>=content.x1 && value.x2<=content.x2 && title.y2<value.y1 && value.y2<=content.y2;
if(!lv_obj_has_flag(w.slider,LV_OBJ_FLAG_HIDDEN)){
@@ -2966,14 +3144,16 @@ inline bool check_tile_geometry() {
}
}
if(!lv_obj_has_flag(w.unit,LV_OBJ_FLAG_HIDDEN)){
// A large value's unit sits under the name beside the number; a navigation tile's chevron beside both.
lv_area_t unit;lv_obj_get_coords(w.unit,&unit);
fits=fits && value.x2<unit.x1 && unit.x2<=content.x2 && unit.y2<=content.y2 && unit.y1>title.y2;
bool chevron=w.index<model.count && model.tiles[w.index].is_page();
fits=fits && value.x2<unit.x1 && unit.x2<=content.x2 && unit.y2<=content.y2 && unit.y1>=content.y1 && (chevron ? title.x2<unit.x1 : unit.y1>title.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<panel.x1 && value.x2<panel.x1));
bool inside=panel.x1>=content.x1 && panel.x2<=content.x2 && panel.y1>=content.y1 && panel.y2<=content.y2 && (custom || (w.full ? value.y2<panel.y1 : title.x2<panel.x1 && value.x2<panel.x1));
for(uint32_t i=0;i<lv_obj_get_child_count(w.panel);++i){
auto *child=lv_obj_get_child(w.panel,i);if(lv_obj_has_flag(child,LV_OBJ_FLAG_HIDDEN))continue;
lv_area_t part;lv_obj_get_coords(child,&part);
@@ -2992,7 +3172,7 @@ inline bool check_tile_geometry() {
bool inside=part.x1>=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<widgets.size();++slot){widgets[slot].index=MAX_TILES;widgets[slot].wide=false;widgets[slot].cached_active=-1;}
for(size_t i=0;i<model.count;++i)if(placement[i].page==page){auto &w=widgets[placement[i].slot];w.index=i;w.wide=model.tiles[i].wide;}
// A full card (firmware 0.2.62+) reaches from the first row to the end of the third.
int full_height=widgets[0].tile && widgets[4].tile ? lv_obj_get_y(widgets[4].tile)-lv_obj_get_y(widgets[0].tile)+widgets[0].base_height : 3*widgets[0].base_height;
for(size_t slot=0;slot<widgets.size();++slot){widgets[slot].index=MAX_TILES;widgets[slot].wide=false;widgets[slot].full=false;widgets[slot].cached_active=-1;}
for(size_t i=0;i<model.count;++i)if(placement[i].page==page){auto &w=widgets[placement[i].slot];w.index=i;w.wide=model.tiles[i].wide;w.full=model.tiles[i].full;}
for(size_t slot=0;slot<widgets.size();++slot){
auto &w=widgets[slot];if(!w.tile)continue;
if(slot<SLOTS_PER_PAGE && w.index<model.count){lv_obj_set_width(w.tile,w.wide?wide_width:w.base_width);lv_obj_remove_flag(w.tile,LV_OBJ_FLAG_HIDDEN);}
if(slot<SLOTS_PER_PAGE && w.index<model.count){lv_obj_set_size(w.tile,w.wide?wide_width:w.base_width,w.full?full_height:w.base_height);lv_obj_remove_flag(w.tile,LV_OBJ_FLAG_HIDDEN);}
else{lv_obj_add_flag(w.tile,LV_OBJ_FLAG_HIDDEN);hide_extra(w);hide_panel(w);}
}
for(auto *control:{nav_prev,nav_next,nav_number})set_hidden(control,pages<=1);
@@ -3103,7 +3285,6 @@ inline void apply_page(int page) {
for(auto &w:widgets)drop_veil(w);
if(room_label){dirty_all=true;render(room_label);}else refresh_all();
}
inline int *shown_page=nullptr;
inline void show_page(int &page, lv_obj_t *previous, lv_obj_t *next, lv_obj_t *number) {
nav_prev=previous;nav_next=next;nav_number=number;shown_page=&page;
int requested=page;
@@ -3133,6 +3314,12 @@ inline void show_page(int &page, lv_obj_t *previous, lv_obj_t *next, lv_obj_t *n
}
lv_timer_resume(fill_timer);
}
// A navigation tile (screen.page, firmware 0.2.62+): the page it names, kept within the pages the screen has.
inline void go_to_page(int page) {
if(!shown_page || !nav_number)return;
*shown_page=std::clamp(page,0,int(page_count())-1);
show_page(*shown_page,nav_prev,nav_next,nav_number);
}
#ifdef SWIPE_PROFILE
inline unsigned swipe_test_left=0, swipe_test_back=0;
@@ -3231,8 +3418,10 @@ inline void tick() {
auto &w=widgets[slot];if(!w.tile || w.index>=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();
+26
View File
@@ -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_<n>` 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,
+111
View File
@@ -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_<n>` 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-<board>-device.yaml
--device <ip>`), 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 <scene> [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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+40 -1
View File
@@ -2,7 +2,7 @@
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.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"
@@ -170,6 +170,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
@@ -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
+40 -1
View File
@@ -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
+40 -1
View File
@@ -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
+40 -1
View File
@@ -3,7 +3,7 @@
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.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
+10
View File
@@ -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.
+1 -1
View File
@@ -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.
+9 -5
View File
@@ -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_<device name>`: 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_<device name>`: 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_<n>` 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
+111 -45
View File
@@ -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_<n> 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_<n>, 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 <domain>.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', {})
+9 -1
View File
@@ -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')
+1 -1
View File
@@ -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
+154
View File
@@ -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()
+1 -1
View File
@@ -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'],[])
+27 -3
View File
@@ -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<std::string> 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<m.count;++i){m.tiles[i].received=true;m.tiles[i].state="off";}
assert(m.ready());
}
@@ -63,6 +67,17 @@ static void test_domains_and_packing() {
assert(pack(m.tiles, 3, p) == 1 && p[0].slot == 0 && p[1].slot == 2 && p[2].slot == 4);
m.tiles[3].wide = true;
assert(pack(m.tiles, 4, p) == 2 && p[3].page == 1 && p[3].slot == 0);
// A full tile (firmware 0.2.62+) takes a page of its own: it starts one when its page is in use, and the
// tiles after it start the next page. Tiles at page starts keep their page.
for (auto &t : m.tiles) { t.wide = false; t.full = false; }
m.tiles[1].full = true; m.tiles[1].wide = true;
assert(m.tiles[1].cells() == 6 && m.tiles[0].cells() == 1);
assert(pack(m.tiles, 3, p) == 3 && p[0].page == 0 && p[1].page == 1 && p[1].slot == 0 && p[2].page == 2 && p[2].slot == 0);
m.tiles[0].full = true; m.tiles[0].wide = true;
assert(pack(m.tiles, 3, p) == 3 && p[0].page == 0 && p[0].slot == 0 && p[1].page == 1 && p[2].page == 2);
m.tiles[1].full = m.tiles[1].wide = false;
assert(pack(m.tiles, 3, p) == 2 && p[1].page == 1 && p[1].slot == 0 && p[2].page == 1 && p[2].slot == 1);
for (auto &t : m.tiles) { t.wide = false; t.full = false; }
}
struct RunExtra { RunExtra() { test_domains_and_packing(); } } run_extra;
// Explicit grid positions (0.2.26+): gaps stay empty, a wide card starts in the left column.
@@ -104,6 +119,15 @@ static void test_explicit_slots() {
assert(place(m, p) == 1 && p[0].slot == 0 && p[1].slot == 1 && p[2].slot == 2);
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {}, moved));
assert(!changed && !moved);
// A full tile (firmware 0.2.62+) on any slot of a page snaps to that page's first slot and fills the page.
assert(valid_entity("screen.page_1") && valid_entity("screen.page_8") && !valid_entity("screen.page_9") && !valid_entity("screen.page") && !valid_entity("screen.page_"));
{ Tile nav; nav.entity = "screen.page_3"; assert(nav.is_page() && nav.page_target() == 3 && nav.builtin() && nav.available()); }
m.tiles[1].full = true; m.tiles[1].wide = true;
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {0, 9, 12}, moved));
assert(!changed && moved && place(m, p) == 3 && p[1].page == 1 && p[1].slot == 0 && p[2].page == 2 && p[2].slot == 0);
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {0, 47, 1}, moved));
assert(place(m, p) == 8 && p[1].page == 7 && p[1].slot == 0);
m.tiles[1].full = m.tiles[1].wide = false;
// Changing the tiles resets everything, including the positions.
assert(m.set_layout({"light.a", "light.z"}, "Home", changed, {1, 6}, moved));
assert(changed && !moved && !m.ready() && place(m, p) == 2 && p[0].slot == 1 && p[1].page == 1);
+2 -2
View File
@@ -93,8 +93,8 @@ class AddAndUpdate(unittest.TestCase):
({'entity': 'light.a', 'page': 1, 'column': 'middle'}, 'column is left or right')):
with self.assertRaisesRegex(ValueError, message):
apply_tile_event(layout(), 'add', data)
full = layout(*[tile(f'light.a{n}', n) for n in range(20)])
with self.assertRaisesRegex(ValueError, 'twenty tiles'):
full = layout(*[tile(f'light.a{n}', n) for n in range(48)])
with self.assertRaisesRegex(ValueError, '48 tiles'):
apply_tile_event(full, 'add', {'entity': 'light.extra'})
+11 -7
View File
@@ -23,10 +23,14 @@ class IconSetTests(unittest.TestCase):
for name in ('home-like-2432s028.yaml', 'guition-4848s040.yaml', 'packages/cyd.yaml', 'packages/guition.yaml'):
text = (ROOT / name).read_text()
fonts = re.findall(r'materialdesignicons-webfont\.ttf["\']\n id: (\w+)\n size: \d+\n glyphs: (.*)\n', text)
self.assertEqual([font for font, _ in fonts], ['materialdesign_icons', 'materialdesign_icons_mini', 'watch_icon'], name)
self.assertTrue(fonts[0][1].startswith('&tile_icons') and all(g == '*tile_icons' for _, g in fonts[1:]), name)
block = text.split('glyphs: &tile_icons', 1)[1].split('\n\n', 1)[0]
self.assertEqual([font for font, _ in fonts], ['materialdesign_icons', 'materialdesign_icons_mini', 'materialdesign_icons_big', 'watch_icon'], name)
self.assertTrue(fonts[0][1].startswith('&tile_icons ') and all(g == '*tile_icons' for font, g in fonts[1:] if font != 'materialdesign_icons_big'), name)
block = text.split('glyphs: &tile_icons ', 1)[1].split('\n\n', 1)[0]
self.assertEqual(re.findall(r'- "(\\U000F[0-9A-F]{4})"', block), wanted, name)
# The big font of the full-page card (firmware 0.2.62+) carries the subset the screen draws on its own.
self.assertEqual(fonts[2][1].split(' #')[0].strip(), '&tile_icons_big', name)
big = text.split('glyphs: &tile_icons_big', 1)[1].split('\n\n', 1)[0]
self.assertEqual(re.findall(r'- "(\\U000F[0-9A-F]{4})"', big), [f'\\U000{tile_icons.GLYPHS[n]}' for n in tile_icons.BIG_GLYPHS], name)
self.assertNotIn('MDI_GLYPH', text, name)
self.assertTrue((ROOT / 'web/src/assets/tile-icons.woff').exists())
@@ -48,11 +52,11 @@ class IconSetTests(unittest.TestCase):
for domains, code in re.findall(r'if \((d == "\w+"(?: \|\| d == "\w+")*)\) return "\\U000(F[0-9A-F]{4})";', body):
for domain in re.findall(r'"(\w+)"', domains):
native[domain] = code
# The built-in cards pick per entity, not per domain: a clock or the settings page.
settings, clock = re.search(
r'if \(d == "screen"\) return tile\.is_settings\(\) \? "\\U000(F[0-9A-F]{4})" : "\\U000(F[0-9A-F]{4})";', body).groups()
# The built-in cards pick per entity, not per domain: a clock, the settings page or a page to go to.
settings, page, clock = re.search(
r'if \(d == "screen"\) return tile\.is_settings\(\) \? "\\U000(F[0-9A-F]{4})" : tile\.is_page\(\) \? "\\U000(F[0-9A-F]{4})" : "\\U000(F[0-9A-F]{4})";', body).groups()
builtin = {entity: tile_icons.GLYPHS[name] for entity, name in tile_icons.BUILTIN_TILES.items()}
self.assertEqual({'screen.settings': settings, 'screen.clock': clock}, builtin)
self.assertEqual({'screen.settings': settings, 'screen.clock': clock, **{f'screen.page_{n}': page for n in range(1, 9)}}, builtin)
self.assertEqual(tile_icons.editor()['builtin'], builtin)
self.assertEqual(tile_icons.GLYPHS[tile_icons.DEFAULTS['screen']], clock)
expected = {domain: tile_icons.GLYPHS[name] for domain, name in tile_icons.DEFAULTS.items() if domain != 'screen'}
+11 -7
View File
@@ -2,7 +2,8 @@
The list lives in screen_manager/app/tile_icons.py. Every Material Design Icons
font of a board profile gets exactly those glyphs (the first font carries the
list as a YAML anchor, the smaller sizes reuse it), and the editor gets a subset
list as a YAML anchor, the smaller sizes reuse it); the big font of the full-page
card (id ending in _big) gets tile_icons.BIG_GLYPHS as its own anchor, and the editor gets a subset
of the same TTF so its mockup shows the glyphs the screen draws. The top bar
mockup also gets Roboto 400/500 with the glyphs of header_bar.GLYPHS, so text
widths in the editor match the screen. Run tools/generate_packages.py afterwards;
@@ -45,18 +46,21 @@ def verify():
if len(tile_icons.ICONS) != sum(len(icons) for _, icons in tile_icons.GROUPS):
raise SystemExit('An icon appears in more than one group')
def glyph_list():
lines = [' glyphs: &tile_icons # GENERATED by tools/generate_icons.py from screen_manager/app/tile_icons.py']
lines += [f' - "\\U000{code}" # {name}' for name, code in tile_icons.GLYPHS.items()]
def glyph_list(anchor='tile_icons', names=None):
lines = [f' glyphs: &{anchor} # GENERATED by tools/generate_icons.py from screen_manager/app/tile_icons.py']
chosen = tile_icons.GLYPHS if names is None else {name: tile_icons.GLYPHS[name] for name in names}
lines += [f' - "\\U000{code}" # {name}' for name, code in chosen.items()]
return '\n'.join(lines) + '\n'
def profile(text):
blocks = list(FONT_BLOCK.finditer(text))
if len(blocks) != 3:
raise SystemExit(f'Expected three icon fonts, found {len(blocks)}')
if len(blocks) != 4:
raise SystemExit(f'Expected four icon fonts, found {len(blocks)}')
out, last = [], 0
for index, block in enumerate(blocks):
out += [text[last:block.start()], block[1], glyph_list() if index == 0 else ' glyphs: *tile_icons\n']
big = re.search(r'id: \w+_big\n', block[1])
glyphs = glyph_list('tile_icons_big', tile_icons.BIG_GLYPHS) if big else glyph_list() if index == 0 else ' glyphs: *tile_icons\n'
out += [text[last:block.start()], block[1], glyphs]
last = block.end()
return ''.join(out) + text[last:]