Add clock, forecast, graph, sun, timer and person cards with wide tiles

Any tile can now be double width: pack() in runtime_model.h places wide
tiles in the left column and the manager preview follows the same rules.
New cards: built-in clock (digital or analog, screen.clock), five-day
weather forecast on a wide weather card fed by weather.get_forecasts,
history sparkline for sensors, sunrise/sunset (sun.sun), live kitchen
timer (timer.*, tap starts or pauses) and presence (person.*). The
manager pre-computes time-zone and forecast extras in a new 'x' object
and refuses layouts with new domains until the screen runs 0.2.14.

The self-test follows the packing, checks custom card parts and wide
widths, and diagnostics/send_layout.py pushes a demo layout of every
card type for render checks without Home Assistant. App 0.2.12,
firmware 0.2.14.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-13 02:09:50 +02:00
parent 53bf1bc228
commit 517b102786
21 changed files with 1185 additions and 146 deletions
+3
View File
@@ -60,6 +60,9 @@ fysiek tikken; een agent kan dat niet vervangen door softwarecoördinaten.
Firmwaretests en hardwareacceptatie zijn verschillende controles.
- `diagnostics/run_ui_test.py` rendert zonder HA-acties; raak tijdens die test
het scherm niet aan. Gebruik `--name` voor de verwachte apparaatidentiteit.
`diagnostics/send_layout.py` zet een demo-indeling met alle kaarttypen op een
scherm via de API-inbox (geen HA-acties); de manager herstelt de echte
indeling binnen ~25 s. Guition: `capture_ui.py` bewaart de LVGL-render als PNG.
- Deel via `tools/export_bundle.py` of Git. Stage geen secrets, metingen,
binaries, logs, buildcache of lokale apparaatprofielen.
- Geen automatische firmware-upload naar een willekeurige aangesloten poort.
+6
View File
@@ -35,6 +35,12 @@ De firmware en ingebouwde CLI zijn getest met **ESPHome 2026.6.2**.
- **Meer kaarten:** climate, vacuum, fan, cover, media player, sensoren,
select/input_select, number/input_number, schakelaars, scènes, scripts en
buttons. Een sensor kan een historiekaart voor 1, 6 of 24 uur openen.
- **Speciale kaarten (firmware 0.2.14+):** een **klok** (digitaal of analoog)
als ingebouwde tegel, een **weersvoorspelling** met vijf dagen op een
dubbelbrede kaart, een **grafiek** van de sensorgeschiedenis in de tegel,
**zonsopgang/-ondergang** (`sun.sun`), een **kookwekker** (`timer.*`, tikken
start of pauzeert) en **aanwezigheid** (`person.*`). Kies ze in de kiezer als
elke andere tegel; **Dubbelbreed** is een optie voor iedere tegel.
- **Scherminstellingen:** standby-tijd, normale en gedimde helderheid,
nachturen, klok, terug naar de hoofdpagina en optioneel swipen tussen pagina's.
- **Guition-rotatie:** 0°, 90°, 180° of 270°, direct vanuit de beheerpagina.
+30 -3
View File
@@ -7,6 +7,7 @@
namespace runtime_tiles {
constexpr size_t MAX_TILES = 20;
constexpr size_t SLOTS_PER_PAGE = 6;
inline bool valid_entity(const std::string &entity) {
if (entity.size() > 120) return false;
auto dot = entity.find('.');
@@ -15,13 +16,16 @@ 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);
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"})
// screen.* are built-in cards without a Home Assistant entity behind them.
if (domain == "screen") return entity == "screen.clock";
for (const auto *allowed : {"light", "switch", "input_boolean", "scene", "script", "climate", "vacuum", "fan", "cover", "sensor", "binary_sensor", "input_select", "select", "number", "input_number", "weather", "media_player", "button", "input_button", "sun", "timer", "person"})
if (domain == allowed) return true;
return false;
}
inline std::string state_revision(const std::string &state, const std::string &attributes) {
return state + "\n" + attributes;
}
struct Forecast { std::string day, condition; float high = NAN, low = NAN; };
struct Tile {
std::string entity, name, state, unit, modes, hvac_modes;
std::array<std::string, 4> fan_speeds;
@@ -34,10 +38,15 @@ struct Tile {
bool has_hs_color = false;
int saturation = 0;
std::string tap = "auto", display = "standard", inline_control = "none", media_title;
bool wide = false;
std::array<std::string, 8> options;
unsigned option_count = 0, history_hours = 24;
std::array<float,24> history{};
bool has_history = false;
std::array<Forecast, 5> forecast;
unsigned forecast_count = 0;
std::string sunrise, sunset, duration, remaining;
uint32_t timer_end = 0;
float battery = NAN, volume = NAN;
uint32_t supported = 0, background = 0;
std::string revision, pending_revision;
@@ -49,9 +58,27 @@ struct Tile {
void begin(uint32_t now, bool local=false) { pending=true; pending_since=now; confirmed=false; local_feedback=local; pending_revision=revision; }
void observe(const std::string &next) { revision=next; if (pending && revision!=pending_revision) confirmed=true; }
std::string domain() const { return entity.substr(0, entity.find('.')); }
bool available() const { return received && state != "unknown" && state != "unavailable" && !state.empty(); }
bool active() const { return state == "on" || state == "cleaning" || (domain() == "climate" && available() && state != "off"); }
bool builtin() const { return domain() == "screen"; }
bool available() const { return builtin() || (received && state != "unknown" && state != "unavailable" && !state.empty()); }
bool active() const {
return state == "on" || state == "cleaning" || state == "active" || (domain() == "person" && state == "home") ||
(domain() == "sun" && state == "above_horizon") || (domain() == "climate" && available() && state != "off");
}
};
// 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) {
unsigned position = 0;
for (size_t i = 0; i < count && i < MAX_TILES; ++i) {
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;
}
unsigned pages = (position + SLOTS_PER_PAGE - 1) / SLOTS_PER_PAGE;
return pages ? pages : 1;
}
struct Model {
std::array<Tile, MAX_TILES> tiles;
size_t count = 0;
+352 -54
View File
@@ -8,6 +8,7 @@
#include "esphome/components/json/json_util.h"
#include "esphome/components/api/api_server.h"
#include "esphome/core/hal.h"
#include "esphome/core/time.h"
#include "lvgl.h"
#include <functional>
#include <algorithm>
@@ -25,8 +26,15 @@ inline std::string inbox;
inline const lv_font_t *watch_font = nullptr;
inline const lv_font_t *mini_icon_font = nullptr;
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;
inline std::function<esphome::ESPTime()> now_time;
inline uint32_t now_epoch() { if (!now_time) return 0; auto t = now_time(); return t.is_valid() ? static_cast<uint32_t>(t.timestamp) : 0; }
inline void tick();
inline void refresh_detail(unsigned index);
inline const char *weather_icon(const std::string &condition);
inline const char *weather_text(const std::string &condition);
inline std::string timer_text(const Tile &t);
inline int active_index = -1;
inline uint32_t last_received = 0;
inline std::function<void()> layout_changed, refresh, dismiss, settings_changed;
@@ -62,7 +70,12 @@ inline bool parse_settings(JsonObject obj, screen_settings::Settings &s) {
return s.valid();
}
inline std::function<void(Tile &)> detail, detail_update;
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{}; };
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{};
};
inline std::array<Widgets, 10> widgets;
inline bool fresh() { return model.ready() && esphome::millis() - last_received < 95000; }
inline float number(JsonVariant value, float fallback = NAN) {
@@ -148,6 +161,21 @@ inline std::string receive(const std::string &payload) {
tile.tap = string(options["tap"]); if (tile.tap.empty()) tile.tap="auto";
tile.display = string(options["display"]); if (tile.display.empty()) tile.display="standard";
tile.inline_control = string(options["inline"]); if (tile.inline_control.empty()) tile.inline_control="none";
// 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;
// Pre-computed extras: the manager converts time zones and fetches forecasts.
auto extra = root["x"];
tile.forecast_count = 0;
if (extra["days"].is<JsonArray>()) for (JsonVariant day : extra["days"].as<JsonArray>()) {
if (tile.forecast_count == tile.forecast.size()) break;
auto &f = tile.forecast[tile.forecast_count++];
f.day = string(day["d"], 3); f.condition = string(day["c"], 20); f.high = number(day["h"]); f.low = number(day["l"]);
}
tile.sunrise = string(extra["rise"], 5); tile.sunset = string(extra["set"], 5);
tile.timer_end = extra["end"].is<unsigned>() ? extra["end"].as<uint32_t>() : 0;
tile.duration = string(extra["dur"], 16); tile.remaining = string(extra["rem"], 16);
tile.has_history=false;
if (root["history"]["values"].is<JsonArray>()) {
tile.history.fill(NAN);unsigned j=0;
@@ -195,6 +223,7 @@ inline std::string receive(const std::string &payload) {
tile.received = true;
for(auto &w:widgets)if(w.index==index)w.cached_active=-1;
last_received = esphome::millis();
if (repack && layout_changed) layout_changed();
if (refresh) refresh();
if (active_index == static_cast<int>(index) && detail_update) detail_update(tile);
refresh_detail(index);
@@ -243,6 +272,9 @@ inline lv_obj_t *detail_label(lv_obj_t *parent,const std::string &text,int x,int
lv_label_set_long_mode(label,LV_LABEL_LONG_DOT);lv_obj_set_height(label,lv_font_get_line_height(detail_font));return label;
}
inline std::string detail_state(const Tile &t){
if(t.domain()=="person")return t.state=="home"?"Thuis":t.state=="not_home"?"Niet thuis":t.state;
if(t.domain()=="sun")return t.state=="above_horizon"?"Boven de horizon":"Onder de horizon";
if(t.domain()=="timer")return timer_text(t);
if(t.state=="on")return "Aan";
if(t.state=="off")return "Uit";
if(t.state=="docked")return "In dock";
@@ -305,6 +337,8 @@ inline lv_obj_t *detail_button(const char *text,int x,int y,int width,int height
if(cmd==21)action("media_player.media_previous_track",t.entity);
if(cmd==22)action("media_player.media_next_track",t.entity);
if(cmd>=30 && cmd<38 && cmd-30<(int)t.option_count)action(t.domain()+".select_option",t.entity,"option",t.options[cmd-30]);
if(cmd==40)action(t.state=="active"?"timer.pause":"timer.start",t.entity);
if(cmd==41)action("timer.cancel",t.entity);
},LV_EVENT_SHORT_CLICKED,(void*)(intptr_t)command);
lv_obj_set_style_bg_color(button,lv_color_hex(0x0075B0),LV_STATE_PRESSED);
lv_obj_set_style_transform_width(button,-2,LV_STATE_PRESSED);lv_obj_set_style_transform_height(button,-2,LV_STATE_PRESSED);
@@ -412,7 +446,20 @@ inline void show_detail(unsigned index){
auto *slider=lv_slider_create(detail_root);lv_obj_set_pos(slider,pad+12,top+(large?52:34));lv_obj_set_size(slider,width-2*pad-24,large?24:16);lv_slider_set_range(slider,0,1000);lv_slider_set_value(slider,slider_value(t),LV_ANIM_OFF);lv_obj_set_style_bg_color(slider,lv_color_hex(0x111111),LV_PART_KNOB);
lv_obj_add_event_cb(slider,slider_event,LV_EVENT_ALL,(void*)(uintptr_t)index);
}else if(d=="weather"){
char text[80];snprintf(text,sizeof(text),"%.1f %s",t.current,t.unit.c_str());auto *value=detail_label(detail_root,text,pad,top,width-2*pad);if(watch_font){lv_obj_set_style_text_font(value,watch_font,0);lv_obj_set_height(value,lv_font_get_line_height(watch_font));}
char text[80];snprintf(text,sizeof(text),"%.1f %s %s",t.current,t.unit.c_str(),weather_text(t.state));auto *value=detail_label(detail_root,text,pad,top,width-2*pad);if(watch_font){lv_obj_set_style_text_font(value,watch_font,0);lv_obj_set_height(value,lv_font_get_line_height(watch_font));}
int y=top+(large?60:30),step=lv_font_get_line_height(detail_font)+(large?10:4);
for(unsigned i=0;i<t.forecast_count;++i){
const auto &f=t.forecast[i];char line[96];
snprintf(line,sizeof(line),"%s %s %s%.0f / %.0f %s",f.day.c_str(),weather_text(f.condition),std::isfinite(f.high)?"":"-",std::isfinite(f.high)?f.high:0.0f,std::isfinite(f.low)?f.low:0.0f,t.unit.c_str());
detail_label(detail_root,line,pad,y,width-2*pad);y+=step;
}
}else if(d=="timer"){
detail_label(detail_root,t.state=="active"?"Loopt":t.state=="paused"?"Gepauzeerd":"Staat stil",pad,top,width-2*pad);
detail_button(t.state=="active"?"Pauzeer":"Start",pad,top+(large?50:30),cw,bh,40);
detail_button("Annuleer",pad+cw+gap,top+(large?50:30),cw,bh,41);
}else if(d=="sun"){
detail_label(detail_root,"Zonsopgang "+t.sunrise,pad,top,width-2*pad);
detail_label(detail_root,"Zonsondergang "+t.sunset,pad,top+lv_font_get_line_height(detail_font)+(large?10:4),width-2*pad);
}
}
}
@@ -423,6 +470,41 @@ inline void refresh_detail(unsigned index){
auto *input=lv_indev_get_next(nullptr);if(input && lv_indev_get_state(input)==LV_INDEV_STATE_PRESSED)return;
show_detail(index);
}
// Home Assistant weather conditions mapped to Material Design Icons glyphs.
inline const char *weather_icon(const std::string &condition) {
if (condition == "sunny") return "\U000F0599";
if (condition == "clear-night") return "\U000F0594";
if (condition == "cloudy") return "\U000F0590";
if (condition == "partlycloudy") return "\U000F0595";
if (condition == "rainy") return "\U000F0597";
if (condition == "pouring") return "\U000F0596";
if (condition == "snowy") return "\U000F0598";
if (condition == "snowy-rainy") return "\U000F067F";
if (condition == "fog") return "\U000F0591";
if (condition == "hail") return "\U000F0592";
if (condition == "lightning") return "\U000F0593";
if (condition == "lightning-rainy") return "\U000F067E";
if (condition == "windy" || condition == "windy-variant") return "\U000F059D";
if (condition == "exceptional") return "\U000F05D6";
return "\U000F0595";
}
inline const char *weather_text(const std::string &condition) {
if (condition == "sunny") return "Zonnig";
if (condition == "clear-night") return "Heldere nacht";
if (condition == "cloudy") return "Bewolkt";
if (condition == "partlycloudy") return "Half bewolkt";
if (condition == "rainy") return "Regen";
if (condition == "pouring") return "Stortregen";
if (condition == "snowy") return "Sneeuw";
if (condition == "snowy-rainy") return "Natte sneeuw";
if (condition == "fog") return "Mist";
if (condition == "hail") return "Hagel";
if (condition == "lightning") return "Onweer";
if (condition == "lightning-rainy") return "Onweer en regen";
if (condition == "windy" || condition == "windy-variant") return "Winderig";
if (condition == "exceptional") return "Bijzonder weer";
return condition.c_str();
}
inline const char *icon_for(const Tile &tile) {
auto d = tile.domain();
if (d == "light") return "\U000F0335";
@@ -431,10 +513,38 @@ inline const char *icon_for(const Tile &tile) {
if (d == "fan") return "\U000F0210";
if (d == "cover") return "\U000F111C";
if (d == "scene" || d == "script") return "\U000F04B9";
if (d == "weather") return "\U000F029A";
if (d == "weather") return tile.available() ? weather_icon(tile.state) : "\U000F0595";
if (d == "sensor" || d == "binary_sensor") return "\U000F029A";
if (d == "sun") return tile.state == "above_horizon" ? "\U000F059B" : "\U000F059C";
if (d == "timer") return "\U000F051B";
if (d == "person") return "\U000F0004";
if (d == "screen") return "\U000F0150";
return "\U000F0425";
}
// HA duration strings ("0:05:00") to seconds; 0 when unusable.
inline uint32_t duration_seconds(const std::string &text) {
unsigned h = 0, m = 0, s = 0;
if (sscanf(text.c_str(), "%u:%u:%u", &h, &m, &s) == 3) return h * 3600 + m * 60 + s;
if (sscanf(text.c_str(), "%u:%u", &m, &s) == 2) return m * 60 + s;
return 0;
}
inline std::string countdown(uint32_t seconds) {
char b[16];
if (seconds >= 3600) snprintf(b, sizeof(b), "%u:%02u:%02u", seconds / 3600, seconds / 60 % 60, seconds % 60);
else snprintf(b, sizeof(b), "%u:%02u", seconds / 60, seconds % 60);
return b;
}
inline std::string timer_text(const Tile &t) {
if (t.state == "active") { uint32_t now = now_epoch(); return countdown(t.timer_end > now && now ? t.timer_end - now : 0); }
if (t.state == "paused") return "Pauze " + countdown(duration_seconds(t.remaining));
return t.duration.empty() ? "Uit" : countdown(duration_seconds(t.duration));
}
inline std::string date_text(const esphome::ESPTime &now) {
static const char *days[] = {"zondag", "maandag", "dinsdag", "woensdag", "donderdag", "vrijdag", "zaterdag"};
static const char *months[] = {"januari", "februari", "maart", "april", "mei", "juni", "juli", "augustus", "september", "oktober", "november", "december"};
if (!now.is_valid() || now.day_of_week < 1 || now.day_of_week > 7 || now.month < 1 || now.month > 12) return "";
return std::string(days[now.day_of_week - 1]) + " " + std::to_string(now.day_of_month) + " " + months[now.month - 1];
}
inline void event(lv_event_t *event) {
auto &w = *static_cast<Widgets *>(lv_event_get_user_data(event));
if (!enabled || !fresh() || w.index >= model.count) return;
@@ -449,7 +559,10 @@ inline void event(lv_event_t *event) {
if(code==LV_EVENT_SHORT_CLICKED && tile.tap=="detail")open=true;
if(code==LV_EVENT_SHORT_CLICKED && tile.tap=="toggle")open=false;
if(d=="media_player" && tile.tap=="toggle" && code==LV_EVENT_SHORT_CLICKED){action("media_player.toggle",tile.entity);return;}
if(d=="sensor" || d=="binary_sensor" || d=="weather" || d=="number" || d=="input_number" || d=="select" || d=="input_select" || d=="media_player" || d=="vacuum") { tile.begin(esphome::millis(),true); active_index=w.index; show_detail(w.index); return; }
if(tile.builtin())return;
// A short tap runs or pauses the kitchen timer; holding opens the card with a cancel button.
if(d=="timer" && !open){action(tile.state=="active"?"timer.pause":"timer.start",tile.entity);return;}
if(d=="sensor" || d=="binary_sensor" || d=="weather" || d=="number" || d=="input_number" || d=="select" || d=="input_select" || d=="media_player" || d=="vacuum" || d=="sun" || d=="person" || d=="timer") { tile.begin(esphome::millis(),true); active_index=w.index; show_detail(w.index); return; }
if (open) {
if (d == "light" || d == "climate" || d == "vacuum" || d == "fan" || d == "cover") {
active_index = w.index;
@@ -470,6 +583,7 @@ 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.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);
// Fixed one-line boxes prevent wrapped names from overlapping the state on both boards.
@@ -509,6 +623,10 @@ inline uint32_t domain_accent(const Tile &t) {
if(d=="select" || d=="input_select")return 0x3F51B5;
if(d=="number" || d=="input_number")return 0x009688;
if(d=="weather")return t.state=="sunny"?0xFFC107:t.state=="clear-night"?0x6E41AB:0x03A9F4;
if(d=="sun")return t.state=="above_horizon"?0xFF9800:0x6E41AB;
if(d=="timer")return t.state=="active"?0x009688:t.state=="paused"?0xFF9800:0x9E9E9E;
if(d=="person")return t.state=="home"?0x4CAF50:0x9E9E9E;
if(d=="screen")return 0x2196F3;
if(d=="sensor"){
if(t.unit=="lx")return 0xFFC107;
if(t.unit=="°C" || t.unit=="°F")return 0xFF6F22;
@@ -517,6 +635,114 @@ inline uint32_t domain_accent(const Tile &t) {
}
return 0x2196F3;
}
// Custom cards draw into a transparent `extra` container; parts are rebuilt only
// when a slot changes mode, so paging keeps RAM use flat on the CYD.
inline void end_extra(Widgets &w) {
if(!w.extra)return;
lv_obj_add_flag(w.extra,LV_OBJ_FLAG_HIDDEN);
if(!w.extra_mode.empty()){lv_obj_clean(w.extra);w.parts.fill(nullptr);w.extra_mode.clear();delete[] w.points;w.points=nullptr;}
}
inline void begin_extra(Widgets &w,const char *mode,int width,int height) {
if(!w.extra){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);}
if(w.extra_mode!=mode){end_extra(w);w.extra_mode=mode;w.points=new lv_point_precise_t[28];w.cached_active=-1;}
lv_obj_set_pos(w.extra,0,0);lv_obj_set_size(w.extra,width,height);lv_obj_remove_flag(w.extra,LV_OBJ_FLAG_HIDDEN);
}
inline lv_obj_t *part_label(Widgets &w,unsigned i,const lv_font_t *font,int x,int y,int width,lv_text_align_t align,const std::string &text) {
auto *&p=w.parts[i];
if(!p){p=lv_label_create(w.extra);lv_label_set_long_mode(p,LV_LABEL_LONG_CLIP);lv_obj_remove_flag(p,LV_OBJ_FLAG_CLICKABLE);}
lv_obj_set_style_text_font(p,font,0);lv_obj_set_style_text_align(p,align,0);
lv_obj_set_pos(p,x,y);lv_obj_set_size(p,std::max(1,width),lv_font_get_line_height(font));label(p,text);return p;
}
inline lv_obj_t *part_dot(Widgets &w,unsigned i,int x,int y,int size) {
auto *&p=w.parts[i];
if(!p){p=lv_obj_create(w.extra);lv_obj_remove_style_all(p);lv_obj_set_style_bg_opa(p,LV_OPA_COVER,0);lv_obj_set_style_radius(p,LV_RADIUS_CIRCLE,0);lv_obj_remove_flag(p,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(p,LV_OBJ_FLAG_SCROLLABLE);}
lv_obj_set_pos(p,x,y);lv_obj_set_size(p,size,size);return p;
}
// Points are relative to (x,y): the line object then covers only its own rectangle.
inline lv_obj_t *part_line(Widgets &w,unsigned i,lv_point_precise_t *points,unsigned count,int width,int x=0,int y=0) {
auto *&p=w.parts[i];
if(!p){p=lv_line_create(w.extra);lv_obj_remove_flag(p,LV_OBJ_FLAG_CLICKABLE);lv_obj_set_style_line_rounded(p,true,0);}
lv_obj_set_style_line_width(p,width,0);lv_line_set_points(p,points,count);lv_obj_set_pos(p,x,y);return p;
}
inline std::string time_text(esphome::ESPTime now) {
return now.is_valid() ? now.strftime(screen_settings::current.clock_24h ? "%H:%M" : "%I:%M") : "--:--";
}
// Digital: big time over the date. Analog: dotted dial with hour and minute hands;
// a wide card adds the digital time next to the dial.
inline void render_clock(Widgets &w,const Tile &t,bool large,int width,int height) {
bool analog=t.display=="analog";
begin_extra(w,analog?"analog":"digital",width,height);
auto now=now_time?now_time():esphome::ESPTime{};
const lv_font_t *big=clock_font?clock_font:watch_value_font?watch_value_font:w.value_font;
const lv_font_t *small=lv_obj_get_style_text_font(w.title,LV_PART_MAIN);
// The date line only appears when both lines fit the card height.
bool with_date=lv_font_get_line_height(big)+2+lv_font_get_line_height(small)<=height;
int text_h=lv_font_get_line_height(big)+(with_date?2+lv_font_get_line_height(small):0);
if(!analog){
int y=std::max(0,(height-text_h)/2);
part_label(w,15,big,0,y,width,LV_TEXT_ALIGN_CENTER,time_text(now));
part_label(w,16,small,0,with_date?y+lv_font_get_line_height(big)+2:y,width,LV_TEXT_ALIGN_CENTER,with_date?date_text(now):"");
return;
}
int dial=std::min(height,width),cx=dial/2,cy=height/2,dot=large?4:2,radius=dial/2-dot;
for(int i=0;i<12;++i){
float a=i*3.14159265f/6;int size=i%3==0?dot+(large?2:1):dot;
part_dot(w,i,cx+std::lround(radius*sinf(a))-size/2,cy-std::lround(radius*cosf(a))-size/2,size);
}
float hour=((now.is_valid()?now.hour%12:0)+(now.is_valid()?now.minute:0)/60.0f)*3.14159265f/6, minute=(now.is_valid()?now.minute:0)*3.14159265f/30;
w.points[0]={(lv_value_precise_t)cx,(lv_value_precise_t)cy};w.points[1]={(lv_value_precise_t)(cx+radius*0.52f*sinf(hour)),(lv_value_precise_t)(cy-radius*0.52f*cosf(hour))};
w.points[2]={(lv_value_precise_t)cx,(lv_value_precise_t)cy};w.points[3]={(lv_value_precise_t)(cx+radius*0.82f*sinf(minute)),(lv_value_precise_t)(cy-radius*0.82f*cosf(minute))};
part_line(w,12,w.points,2,large?5:3);part_line(w,13,w.points+2,2,large?3:2);
int center=large?8:4;part_dot(w,14,cx-center/2,cy-center/2,center);
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));
part_label(w,16,small,x,with_date?y+lv_font_get_line_height(big)+2:y,width-x,LV_TEXT_ALIGN_CENTER,with_date?date_text(now):"");
}
}
// 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);
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,icon_for(t));
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);
for(unsigned k=0;k<5;++k){
int x=left+k*column;bool has=k<t.forecast_count;const auto &f=t.forecast[k];
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;
if(large){
int rows=day_h+icon_col+text_h,top=std::max(0,(height-rows)/2);
part_label(w,3+k*3,title_font,x,top,column,LV_TEXT_ALIGN_CENTER,has?f.day:"");
part_label(w,4+k*3,day_icon,x,top+day_h,column,LV_TEXT_ALIGN_CENTER,has?weather_icon(f.condition):"");
part_label(w,5+k*3,w.value_font,x,top+day_h+icon_col,column,LV_TEXT_ALIGN_CENTER,temps);
}else{
// Two rows on the CYD: day beside its icon, then the high/low pair.
int rows=std::max(day_h,icon_col)+text_h,top=std::max(0,(height-rows)/2),day_w=column-icon_col-2;
part_label(w,3+k*3,title_font,x,top+(std::max(day_h,icon_col)-day_h)/2,day_w,LV_TEXT_ALIGN_RIGHT,has?f.day:"");
part_label(w,4+k*3,day_icon,x+day_w+2,top,icon_col,LV_TEXT_ALIGN_LEFT,has?weather_icon(f.condition):"");
part_label(w,5+k*3,w.value_font,x,top+std::max(day_h,icon_col),column,LV_TEXT_ALIGN_CENTER,temps);
}
}
}
// Sparkline of the manager's 24 history samples in the given rectangle.
inline void render_graph(Widgets &w,const Tile &t,bool large,int x,int y,int width,int height) {
begin_extra(w,"graph",x+width,y+height);
float minimum=INFINITY,maximum=-INFINITY;for(float v:t.history)if(std::isfinite(v)){minimum=std::min(minimum,v);maximum=std::max(maximum,v);}
unsigned n=0;int stroke=large?3:2;
for(unsigned i=0;i<24;++i){
if(!std::isfinite(t.history[i]))continue;
float level=maximum>minimum?(t.history[i]-minimum)/(maximum-minimum):0.5f;
w.points[n++]={(lv_value_precise_t)(stroke/2+i*(width-stroke-1)/23),(lv_value_precise_t)(height-stroke/2-1-level*(height-stroke-2))};
}
if(n==1){w.points[1]=w.points[0];w.points[1].x=(lv_value_precise_t)(width-1);n=2;}
part_line(w,0,w.points,n,stroke,x,y);
}
inline void render(lv_obj_t *room) {
if (!enabled) return;
label(room, !model.configured ? "Kies tegels in HA" : !model.ready() ? "Tegels laden..." : !fresh() ? "HA niet verbonden" : model.title);
@@ -524,64 +750,80 @@ inline void render(lv_obj_t *room) {
auto &w=widgets[slot];
if(!w.tile || w.index>=model.count)continue;
const auto &t = model.tiles[w.index];
auto d=t.domain();
label(w.title, t.name.empty() ? t.entity : t.name);
label(w.icon, icon_for(t));
bool watch=t.display=="watch";
std::string unit=watch?t.unit:"";
std::string value = t.state;
if (!fresh() || !t.available()) value = "Niet beschikbaar";
else if (t.domain() == "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))) + " %";
else if (t.domain() == "climate" && std::isfinite(t.target)) { char b[32]; snprintf(b, sizeof(b), "%.1f°", t.target); value = b; }
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))) + " %";
else if (d == "climate" && std::isfinite(t.target)) { char b[32]; snprintf(b, sizeof(b), "%.1f°", t.target); value = b; }
else if (d == "person") value = t.state=="home"?"Thuis":t.state=="not_home"?"Weg":t.state;
else if (d == "sun") value = !t.sunrise.empty() && !t.sunset.empty() ? t.sunrise+" - "+t.sunset : t.state=="above_horizon"?"Boven de horizon":"Onder de horizon";
else if (d == "timer") value = timer_text(t);
else if (value == "on") value = "Aan";
else if (value == "off") value = "Uit";
else if (value == "cleaning") value = "Bezig";
else if (value == "docked") value = "In dock";
else if (!t.unit.empty() && !watch) value += " " + t.unit;
bool pending=t.loading(esphome::millis());
if(t.domain()=="weather" && std::isfinite(t.current)) {char b[32];snprintf(b,sizeof(b),"%.1f %s",t.current,t.unit.c_str());value=b;if(watch){snprintf(b,sizeof(b),"%.1f",t.current);value=b;}}
if(t.domain()=="vacuum" && std::isfinite(t.battery))value += " / "+std::to_string((int)t.battery)+"%";
if(d=="weather" && std::isfinite(t.current)) {char b[32];snprintf(b,sizeof(b),"%.1f %s",t.current,t.unit.c_str());value=b;if(watch){snprintf(b,sizeof(b),"%.1f",t.current);value=b;}}
if(d=="vacuum" && std::isfinite(t.battery))value += " / "+std::to_string((int)t.battery)+"%";
label(w.value, pending ? "Bezig..." : value);
lv_obj_set_width(w.progress,pending ? (esphome::millis()/120%5+1)*(lv_obj_get_width(w.tile)-24)/5 : 0);
lv_obj_set_style_text_opa(w.icon,pending ? LV_OPA_40 : LV_OPA_COVER,0);
bool mini=t.inline_control=="slider" && !watch && t.available();
bool large_tile=lv_obj_get_height(w.tile)>80;
if(!large_tile){lv_obj_set_style_pad_top(w.tile,watch?2:4,0);lv_obj_set_style_pad_bottom(w.tile,watch?2:4,0);}
// Cards that replace the name/status layout entirely.
bool clock=t.builtin(), forecast=d=="weather" && t.display=="forecast" && w.wide && t.forecast_count>0 && fresh() && t.available();
bool graph=d=="sensor" && t.display=="graph" && t.has_history && !clock;
bool custom=clock||forecast;
if(!large_tile){lv_obj_set_style_pad_top(w.tile,watch||custom||graph?2:4,0);lv_obj_set_style_pad_bottom(w.tile,watch||custom||graph?2:4,0);}
lv_obj_set_style_text_font(w.value,watch && watch_value_font ? watch_value_font : w.value_font,0);
lv_obj_set_height(w.value,lv_font_get_line_height(lv_obj_get_style_text_font(w.value,LV_PART_MAIN)));
lv_obj_update_layout(w.tile);
int content_width=lv_obj_get_content_width(w.tile),content_height=lv_obj_get_content_height(w.tile);
for(auto *o:{w.title,w.value,w.circle,w.unit}){if(custom)lv_obj_add_flag(o,LV_OBJ_FLAG_HIDDEN);else lv_obj_remove_flag(o,LV_OBJ_FLAG_HIDDEN);}
if(custom){
lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN);
if(clock)render_clock(w,t,large_tile,content_width,content_height);else render_forecast(w,t,large_tile,content_width,content_height);
}else{
int title_height=lv_obj_get_height(w.title),value_height=lv_obj_get_height(w.value);
int line_gap=large_tile?2:1,text_height=title_height+line_gap+value_height;
int slider_height=large_tile?28:8;
int header_height=mini?content_height-slider_height-(large_tile?6:3):content_height;
// A single-width graph takes the slider strip; a wide graph takes the right half.
bool graph_strip=graph && !w.wide, graph_side=graph && w.wide;
int chart_w=graph_side?content_width*55/100:0;
int header_height=(mini||graph_strip)?content_height-slider_height-(large_tile?6:3):content_height;
int text_y=std::max(0,(header_height-text_height)/2);
int circle_size=watch?(large_tile?26:18):mini?(large_tile?36:24):(large_tile?54:36);
int circle_size=watch?(large_tile?26:18):(mini||graph_strip)?(large_tile?36:24):(large_tile?54:36);
lv_obj_set_size(w.circle,circle_size,circle_size);
lv_obj_set_style_text_font(w.icon,watch && watch_icon_font ? watch_icon_font : mini && mini_icon_font ? mini_icon_font : w.icon_font,0);
lv_obj_set_style_text_font(w.icon,watch && watch_icon_font ? watch_icon_font : (mini||graph_strip) && mini_icon_font ? mini_icon_font : w.icon_font,0);
lv_obj_center(w.icon);
lv_obj_remove_flag(w.circle,LV_OBJ_FLAG_HIDDEN);
int text_x=watch?0:mini?circle_size+(large_tile?8:6):w.title_x;
lv_obj_set_pos(w.title,text_x,watch?0:(mini || !large_tile)?text_y:w.title_y);
lv_obj_set_pos(w.value,watch?0:mini?text_x:w.value_x,
watch?(large_tile?42:19):(mini || !large_tile)?text_y+title_height+line_gap:w.value_y);
lv_obj_set_pos(w.circle,0,(mini || !large_tile)?std::max(0,(header_height-circle_size)/2):12);
int text_x=watch?0:(mini||graph_strip)?circle_size+(large_tile?8:6):w.title_x;
lv_obj_set_pos(w.title,text_x,watch?0:(mini||graph_strip||!large_tile)?text_y:w.title_y);
lv_obj_set_pos(w.value,watch?0:(mini||graph_strip)?text_x:w.value_x,
watch?(large_tile?42:19):(mini||graph_strip||!large_tile)?text_y+title_height+line_gap:w.value_y);
lv_obj_set_pos(w.circle,0,(mini||graph_strip||!large_tile)?std::max(0,(header_height-circle_size)/2):12);
// Use the requested coordinates: LVGL getters still return the previous
// layout until its next pass when a slot changes from watch/slider to normal.
lv_obj_set_width(w.title,std::max(1,content_width-text_x));
lv_obj_set_width(w.value,std::max(1,content_width-(watch?0:mini?text_x:w.value_x)));
int text_room=content_width-chart_w-(graph_side?(large_tile?10:6):0);
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){
int gap=large_tile?6:2,header=std::max(circle_size,title_height);
int group_y=std::max(0,(content_height-header-gap-value_height)/2);
int value_y=group_y+header+gap;
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,content_width-circle_size-(large_tile?6:4));
lv_obj_set_width(w.title,text_room-circle_size-(large_tile?6:4));
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,content_width-20);
int number_width=content_width-(unit_width?unit_width+(large_tile?6:3):0);
int unit_width=unit.empty()?0:std::min((int)size.x,text_room-20);
int number_width=text_room-(unit_width?unit_width+(large_tile?6:3):0);
lv_obj_set_pos(w.value,0,value_y);lv_obj_set_width(w.value,number_width);
lv_obj_set_pos(w.unit,content_width-unit_width,value_y+value_height-lv_font_get_line_height(w.value_font));
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));
if(unit_width)lv_obj_remove_flag(w.unit,LV_OBJ_FLAG_HIDDEN);else lv_obj_add_flag(w.unit,LV_OBJ_FLAG_HIDDEN);
}else lv_obj_add_flag(w.unit,LV_OBJ_FLAG_HIDDEN);
@@ -589,14 +831,18 @@ inline void render(lv_obj_t *room) {
lv_obj_align(w.slider,LV_ALIGN_BOTTOM_MID,0,0);
if(mini){lv_obj_remove_flag(w.slider,LV_OBJ_FLAG_HIDDEN);if(!lv_obj_has_state(w.slider,LV_STATE_PRESSED))lv_slider_set_value(w.slider,slider_value(t),LV_ANIM_OFF);}
else lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN);
if(graph_strip)render_graph(w,t,large_tile,0,content_height-slider_height,content_width,slider_height);
else if(graph_side)render_graph(w,t,large_tile,content_width-chart_w,0,chart_w,content_height);
else end_extra(w);
}
bool on = fresh() && t.active();
bool available=fresh() && t.available();
int palette_state=(available?2:0)|(on?1:0);
if (w.cached_active == palette_state) continue;
w.cached_active = palette_state;
uint32_t accent = domain_accent(t);
auto color=lv_color_hex(t.is_switch() && !on ? 0x9E9E9E : accent);
if(t.domain()=="light" && on && t.has_hs_color)
auto color=lv_color_hex((t.is_switch()||d=="person"||d=="timer") && !on ? 0x9E9E9E : accent);
if(d=="light" && on && t.has_hs_color)
color=lv_color_hsv_to_rgb(t.hue%360,t.saturation,100);
bool dark_text=light_theme || t.background!=0;
auto circle_color=available?lv_color_mix(color,lv_color_hex(dark_text?0xFFFFFF:0x263B50),dark_text?38:65):lv_color_hex(dark_text?0xF0F0F0:0x263B50);
@@ -610,9 +856,18 @@ inline void render(lv_obj_t *room) {
lv_obj_set_style_border_color(w.tile, t.background ? lv_color_mix(lv_color_hex(t.background),lv_color_hex(0x000000),220) : lv_color_hex(light_theme ? 0xDDDDDD : (on ? 0xF5F1E8 : 0x9CB3C8)), 0);
lv_obj_set_style_bg_color(w.circle,circle_color,0);
lv_obj_set_style_text_color(w.icon,icon_color,0);
auto title_color=lv_color_hex(dark_text ? 0x1B1B1B : (on ? 0x172232 : 0xF3F5F7));
auto value_color=lv_color_hex(t.background ? 0x46525E : (light_theme ? 0x616161 : (on ? 0x46525E : 0xF0F4F8)));
lv_obj_set_style_text_color(w.unit,lv_color_hex(dark_text?0x46525E:(on?0x46525E:0xF0F4F8)),0);
lv_obj_set_style_text_color(w.title, lv_color_hex(dark_text ? 0x1B1B1B : (on ? 0x172232 : 0xF3F5F7)), 0);
lv_obj_set_style_text_color(w.value, lv_color_hex(t.background ? 0x46525E : (light_theme ? 0x616161 : (on ? 0x46525E : 0xF0F4F8))), 0);
lv_obj_set_style_text_color(w.title, title_color, 0);
lv_obj_set_style_text_color(w.value, value_color, 0);
// Custom parts follow the card palette: text like the title, lines/dots in the accent.
for(unsigned i=0;i<w.parts.size();++i){
auto *p=w.parts[i];if(!p)continue;
if(lv_obj_check_type(p,&lv_label_class))lv_obj_set_style_text_color(p,w.extra_mode=="forecast" && i>=2 && i%3==2 ? value_color : i==16 ? value_color : title_color,0);
else if(lv_obj_check_type(p,&lv_line_class))lv_obj_set_style_line_color(p,w.extra_mode=="graph"?color:i==13?icon_color:title_color,0);
else lv_obj_set_style_bg_color(p,i==14?icon_color:value_color,0);
}
}
}
@@ -625,32 +880,57 @@ inline bool check_tile_geometry() {
lv_area_t title,value,track,content;
lv_obj_get_content_coords(w.tile,&content);
lv_obj_get_coords(w.title,&title);lv_obj_get_coords(w.value,&value);
bool 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)){
lv_obj_get_coords(w.slider,&track);
fits=fits && value.y2<track.y1 && track.y2<=content.y2;
bool custom=lv_obj_has_flag(w.title,LV_OBJ_FLAG_HIDDEN);
bool fits=true;
if(w.wide && widgets[1].tile){
// A wide card ends exactly where the right column ends.
lv_area_t left,right;lv_obj_get_coords(w.tile,&left);lv_obj_get_coords(widgets[1].tile,&right);
int expected=lv_obj_get_x(widgets[1].tile)-lv_obj_get_x(widgets[0].tile)+w.base_width;
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(!lv_obj_has_flag(w.circle,LV_OBJ_FLAG_HIDDEN)){
lv_area_t circle;lv_obj_get_coords(w.circle,&circle);
bool mini=!lv_obj_has_flag(w.slider,LV_OBJ_FLAG_HIDDEN);
fits=fits && circle.x1>=content.x1 && circle.x2<title.x1 && circle.y1>=content.y1;
if(mini)fits=fits && circle.y2<track.y1;
else fits=fits && circle.y2<=content.y2;
if(!fits)ESP_LOGE("ui_test","Icon bounds slot=%u circle=%d,%d..%d,%d title_x=%d content=%d,%d..%d,%d",(unsigned)w.index,circle.x1,circle.y1,circle.x2,circle.y2,title.x1,content.x1,content.y1,content.x2,content.y2);
bool watch=w.index<model.count && model.tiles[w.index].display=="watch";
if(!watch && (mini || lv_obj_get_height(w.tile)<=80)){
int header_bottom=mini?track.y1-(lv_obj_get_height(w.tile)>80?6:3)-1:content.y2;
int center_twice=content.y1+header_bottom;
fits=fits && std::abs(circle.y1+circle.y2-center_twice)<=2 &&
std::abs(title.y1+value.y2-center_twice)<=2;
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)){
lv_obj_get_coords(w.slider,&track);
fits=fits && value.y2<track.y1 && track.y2<=content.y2;
}
if(!lv_obj_has_flag(w.circle,LV_OBJ_FLAG_HIDDEN)){
lv_area_t circle;lv_obj_get_coords(w.circle,&circle);
bool mini=!lv_obj_has_flag(w.slider,LV_OBJ_FLAG_HIDDEN);
fits=fits && circle.x1>=content.x1 && circle.x2<title.x1 && circle.y1>=content.y1;
if(mini)fits=fits && circle.y2<track.y1;
else fits=fits && circle.y2<=content.y2;
if(!fits)ESP_LOGE("ui_test","Icon bounds slot=%u circle=%d,%d..%d,%d title_x=%d content=%d,%d..%d,%d",(unsigned)w.index,circle.x1,circle.y1,circle.x2,circle.y2,title.x1,content.x1,content.y1,content.x2,content.y2);
bool watch=w.index<model.count && model.tiles[w.index].display=="watch";
bool graph=w.extra_mode=="graph";
if(!watch && !graph && (mini || lv_obj_get_height(w.tile)<=80)){
int header_bottom=mini?track.y1-(lv_obj_get_height(w.tile)>80?6:3)-1:content.y2;
int center_twice=content.y1+header_bottom;
fits=fits && std::abs(circle.y1+circle.y2-center_twice)<=2 &&
std::abs(title.y1+value.y2-center_twice)<=2;
}
}
if(!lv_obj_has_flag(w.unit,LV_OBJ_FLAG_HIDDEN)){
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;
}
}
if(!lv_obj_has_flag(w.unit,LV_OBJ_FLAG_HIDDEN)){
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;
if(w.extra && !lv_obj_has_flag(w.extra,LV_OBJ_FLAG_HIDDEN)){
// Custom parts stay inside the card; a graph never runs into the text.
lv_area_t extra;lv_obj_get_coords(w.extra,&extra);
fits=fits && extra.x1>=content.x1 && extra.x2<=content.x2 && extra.y1>=content.y1 && extra.y2<=content.y2;
for(auto *p:w.parts){
if(!p || lv_obj_has_flag(p,LV_OBJ_FLAG_HIDDEN))continue;
lv_area_t part;lv_obj_get_coords(p,&part);
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(!fits)ESP_LOGE("ui_test","Tile geometry FAIL slot=%u title_y=%d..%d value_y=%d..%d content_y=%d..%d",(unsigned)w.index,title.y1,title.y2,value.y1,value.y2,content.y1,content.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);
if(w.index<model.count && model.tiles[w.index].background){
bool palette_ok=lv_color_eq(lv_obj_get_style_bg_color(w.tile,LV_PART_MAIN),lv_color_hex(model.tiles[w.index].background)) &&
lv_color_eq(lv_obj_get_style_text_color(w.title,LV_PART_MAIN),lv_color_hex(0x1B1B1B));
@@ -662,14 +942,21 @@ inline bool check_tile_geometry() {
return ok;
}
inline unsigned page_count() {
std::array<Placement,MAX_TILES> placement;
return pack(model.tiles,model.count,placement);
}
inline void show_page(int &page, lv_obj_t *previous, lv_obj_t *next, lv_obj_t *number) {
int pages=std::max(1,(int(model.count)+5)/6);
std::array<Placement,MAX_TILES> placement;
int pages=pack(model.tiles,model.count,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;}
for(size_t slot=0;slot<widgets.size();++slot){
auto &w=widgets[slot];if(!w.tile)continue;
if(slot<6){w.index=page*6+slot;w.cached_active=-1;}
if(slot<6 && w.index<model.count)lv_obj_remove_flag(w.tile,LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(w.tile,LV_OBJ_FLAG_HIDDEN);
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);}
else{lv_obj_add_flag(w.tile,LV_OBJ_FLAG_HIDDEN);end_extra(w);}
}
for(auto *control:{previous,next,number}){
if(pages>1)lv_obj_remove_flag(control,LV_OBJ_FLAG_HIDDEN);
@@ -681,6 +968,7 @@ inline void show_page(int &page, lv_obj_t *previous, lv_obj_t *next, lv_obj_t *n
if(refresh)refresh();
}
inline uint32_t last_live_second=0;
inline void tick() {
if(detail_root && !lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN) && detail_index<model.count){
auto &t=model.tiles[detail_index];bool waiting=t.awaiting_action(esphome::millis());
@@ -696,6 +984,16 @@ inline void tick() {
if(!enabled)return;
bool redraw=false;
for(auto &t:model.tiles)if(t.pending){redraw=true;if(!t.loading(esphome::millis()))t.pending=false;}
// Clocks and running timers advance once per second without any HA traffic.
uint32_t second=esphome::millis()/1000;
if(second!=last_live_second){
last_live_second=second;
for(size_t slot=0;slot<SLOTS_PER_PAGE;++slot){
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.builtin() || (t.domain()=="timer" && t.state=="active"))redraw=true;
}
}
if(redraw && refresh)refresh();
}
inline std::string vacuum_option(unsigned index) {
+98
View File
@@ -0,0 +1,98 @@
"""Push a demo layout with every card type straight into a screen's inbox.
Uses the same message builder as ESP Screen Manager, but with synthetic states,
so rendering can be checked without touching Home Assistant. The running
manager restores the real layout on its next keepalive (about 25 seconds).
"""
import argparse
import asyncio
from datetime import datetime, timedelta, timezone
from pathlib import Path
import sys
import time
import yaml
from aioesphomeapi import APIClient
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app'))
from core import extras, packets, state_message, validate_layout # noqa: E402
def demo_layout():
return validate_layout({'title': 'Demo kaarten', 'tiles': [
{'entity': 'screen.clock', 'name': '', 'options': {'display': 'analog', 'size': 'wide'}},
{'entity': 'weather.demo', 'name': 'Buiten', 'options': {'display': 'forecast'}},
{'entity': 'sensor.demo_temperatuur', 'name': 'Woonkamer', 'options': {'display': 'graph'}},
{'entity': 'person.demo', 'name': 'Max', 'options': {}},
{'entity': 'timer.demo', 'name': 'Eieren', 'options': {}},
{'entity': 'sun.sun', 'name': 'Zon', 'options': {}},
{'entity': 'sensor.demo_energie', 'name': 'Verbruik vandaag', 'options': {'display': 'graph', 'size': 'wide'}},
{'entity': 'light.demo', 'name': 'Tafellamp', 'options': {'display': 'watch', 'size': 'wide'}},
{'entity': 'weather.demo_standaard', 'name': 'Weer nu', 'options': {}},
]})
def demo_states(now):
end = now + timedelta(minutes=4, seconds=32)
return {
'weather.demo': {'state': 'partlycloudy', 'attributes': {'temperature': 18.4, 'temperature_unit': '°C'}},
'weather.demo_standaard': {'state': 'rainy', 'attributes': {'temperature': 12.0, 'temperature_unit': '°C'}},
'sensor.demo_temperatuur': {'state': '21.5', 'attributes': {'unit_of_measurement': '°C'}},
'sensor.demo_energie': {'state': '7.42', 'attributes': {'unit_of_measurement': 'kWh'}},
'person.demo': {'state': 'home', 'attributes': {}},
'timer.demo': {'state': 'active', 'attributes': {'finishes_at': end.isoformat(), 'duration': '0:05:00', 'remaining': '0:05:00'}},
'sun.sun': {'state': 'above_horizon', 'attributes': {'next_rising': (now + timedelta(hours=9)).isoformat(), 'next_setting': (now + timedelta(hours=2)).isoformat()}},
'light.demo': {'state': 'on', 'attributes': {'brightness': 180}},
}
def demo_forecast(now):
conditions = ['sunny', 'partlycloudy', 'rainy', 'cloudy', 'lightning-rainy', 'snowy']
return [{'datetime': (now + timedelta(days=i)).isoformat(), 'condition': conditions[i], 'temperature': 21 - i, 'templow': 11 + i}
for i in range(6)]
def messages(inbox, rotate=0, digital=False):
now = datetime.now(timezone.utc)
layout, states = demo_layout(), demo_states(now)
if digital:
layout['tiles'][0]['options'] = {'display': 'digital'}
layout['tiles'] = layout['tiles'][rotate:] + layout['tiles'][:rotate]
out = [{'v': 1, 'op': 'layout', 'inbox': inbox, 'title': layout['title'], 'entities': [t['entity'] for t in layout['tiles']]}]
for i, tile in enumerate(layout['tiles']):
forecast = demo_forecast(now) if tile['entity'].startswith('weather.') else None
message = state_message(i, tile, states, extras(tile, states, forecast, None))
if tile['entity'].startswith('sensor.'):
message['history'] = {'hours': 24, 'values': [round(18 + 4 * ((k * 7) % 11) / 10, 2) if k % 5 else None for k in range(24)]}
out.append(message)
return out
async def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--host', required=True)
p.add_argument('--name', required=True, help='Verwachte DEVICE_NAME')
p.add_argument('--secrets', type=Path, default=Path(__file__).resolve().parents[1] / 'secrets.yaml')
p.add_argument('--rotate', type=int, default=0, help='Zet tegel N vooraan, zodat latere pagina\'s op pagina 1 komen')
p.add_argument('--digital', action='store_true', help='Digitale klok op een enkele tegel in plaats van analoog dubbelbreed')
args = p.parse_args()
client = APIClient(args.host, 6053, noise_psk=yaml.safe_load(args.secrets.read_text())['api_encryption_key'],
client_info='Demo layout', expected_name=args.name)
await client.connect(login=True)
try:
entities, _ = await client.list_entities_services()
inbox = next(e for e in entities if type(e).__name__ == 'TextInfo' and e.name == 'Tegelinstellingen')
# The firmware answers through the inbox entity's state, not the log.
replies = []
def state(update):
if getattr(update, 'key', None) == inbox.key and getattr(update, 'state', None):
replies.append(update.state)
client.subscribe_states(state)
started = time.monotonic()
for message in messages(inbox.object_id if hasattr(inbox, 'object_id') else 'text.inbox', args.rotate % 9, args.digital):
for packet in packets(message):
client.text_command(inbox.key, packet)
await asyncio.sleep(0.05)
await asyncio.sleep(1.5)
print(f'Demo-indeling verstuurd in {time.monotonic() - started:.1f}s; inbox meldt: {" / ".join(dict.fromkeys(replies)) or "(geen statuswijziging)"}')
if not any(r in ('Indeling ontvangen', 'Gesynchroniseerd', 'Tegels laden') for r in replies):
raise SystemExit('De firmware accepteerde de demo-indeling niet.')
finally:
await client.disconnect()
if __name__ == '__main__':
asyncio.run(main())
+3 -1
View File
@@ -99,7 +99,9 @@ domeinfilters met gekleurde iconen. Je kunt maximaal twintig tegels toevoegen.
Het schermvoorbeeld toont hun plaats: twee kolommen, zes tegels per pagina.
Sleep een tegel naar een andere tegel om te ordenen. Klik op een tegel voor
een eigen naam, de pijltjes voor volgorde en **Bediening & weergave instellen**:
klikgedrag, een mini-slider of een grote waarde waar ondersteund. Kies bij
klikgedrag, een mini-slider, een grote waarde, een grafiek (sensoren), een
weersvoorspelling (weer) of de breedte **Dubbelbreed**. De ingebouwde **Klok**
staat bovenaan de kiezer; zon, kookwekkers en personen vind je via de filters. Kies bij
**Pastel achtergrond** een eigen kleur met donkere tekst; **Standaard** herstelt
de normale weergave. Hiervoor is firmware 0.2.10+ nodig. Een lege
tegel brengt je naar de zoekbalk; toevoegen vult de eerstvolgende vrije positie.
+14
View File
@@ -127,3 +127,17 @@ sleutels, geen nieuwe firmwareflash nodig voor latere kleurkeuzes.
Een oude apprelease van vóór 0.2.10 kent dit optieveld niet. Bij terugrollen van
de app herstel je daarom de bijbehorende gegevensback-up; wis of negeer geen
onbekende opties om een oud opslagbestand toch te openen.
### Compatibiliteit 0.2.12 / firmware 0.2.14
Opslagversie en tegelprotocol blijven 1. Additief: `tiles[].options.size`
(`single`/`wide`), nieuwe `display`-waarden (`forecast` voor weer, `graph` voor
sensoren, `digital`/`analog` voor `screen.clock`), de ingebouwde entity
`screen.clock` en de HA-domeinen `sun`, `timer` en `person`. Statusberichten
krijgen een optioneel object `x` met door de manager berekende waarden
(voorspellingsdagen, zontijden in de HA-tijdzone, timer-eindtijd als epoch).
Oudere firmware negeert `size`, nieuwe displays en `x`, maar weigert onbekende
domeinen in de hele indeling; de manager stuurt zo'n indeling daarom pas na
firmware 0.2.14 (`min_firmware`) en bewaart haar ondertussen. Voorspellingen
komen via `weather.get_forecasts` met `return_response`; zonder antwoord blijft
de weerkaart de gewone kaart. Geen gewijzigde preferences of sleutels.
+63
View File
@@ -0,0 +1,63 @@
# App 0.2.12 / firmware 0.2.14 — speciale kaarten en dubbelbrede tegels
## Wijzigingen
- **Dubbelbreed** (`options.size: wide`) voor elke tegel. `runtime_model.h`
bevat het pak-algoritme (`pack`): een dubbelbrede tegel begint links en telt
voor twee vakjes; een lege rechterplek ervoor blijft leeg. De app gebruikt
dezelfde regels voor het schermvoorbeeld en de paginagrenzen.
- **Klok** (`screen.clock`, ingebouwd, geen HA-entity): digitaal (grote tijd +
datum) of analoog (wijzerplaat met stippen, uur- en minuutwijzer; dubbelbreed
met de digitale tijd ernaast). Eigen cijferfont (60 px Guition, 28 px CYD).
- **Weersvoorspelling** (`display: forecast`, altijd dubbelbreed): actueel
icoon, temperatuur en omschrijving links; vijf dagen met dagnaam, icoon en
hoog/laag rechts. De manager haalt `weather.get_forecasts` (dagelijks) op en
cachet dat 30 minuten. De gewone weerkaart toont nu ook het conditie-icoon.
- **Grafiek** (`display: graph`, sensoren): sparkline van de bestaande
24-punts historie; enkelbreed in de sliderstrook, dubbelbreed rechts naast
de tekst.
- **Zon** (`sun.sun`): opkomst en ondergang in de HA-tijdzone; **kookwekker**
(`timer.*`): live aftellen op het scherm, tikken start/pauzeert, lang
indrukken opent een kaart met annuleren; **aanwezigheid** (`person.*`):
Thuis/Weg met groen accent.
- Statusberichten krijgen een optioneel object `x` met berekende waarden.
Protocol, opslagversie en preferences ongewijzigd; oudere firmware krijgt
indelingen met nieuwe domeinen pas na een update (`min_firmware`).
## Tests
- 55 Python-tests geslaagd (nieuw: opties/domeinen, `extras` met tijdzone,
voorspellingstrimmen, ingebouwde klokstatus, firmwareguard in de manager).
Alle negen C++-testprogramma's geslaagd; `test_runtime_model.cpp` test de
nieuwe domeinen en het pak-algoritme inclusief paginagrenzen.
- Beide Easy Setup-profielen compileren met ESPHome 2026.6.2 (CYD-flash 72,7 %).
Voor `lv_line` is een verborgen `line`-widget aan beide profielen toegevoegd,
omdat ESPHome LVGL-widgets alleen inschakelt die in de YAML voorkomen.
- USB-flash op beide vooraf geïdentificeerde borden; `device_info` meldt 0.2.14
met de verwachte compileertijden (Guition 02:00:44, CYD 02:01:01).
- Nieuw: `diagnostics/send_layout.py` zet een demo-indeling met alle kaarttypen
(analoge klok dubbelbreed, voorspelling, grafiek enkel- en dubbelbreed,
persoon, kookwekker, zon, grote waarde dubbelbreed, gewone weerkaart; drie
pagina's) rechtstreeks in de inbox. Daarna de renderdiagnose: op **beide**
apparaten tien paginacontroles en vijftig overlaycycli geslaagd met
`count=9`, inclusief de nieuwe controles op onderdelen binnen de kaart,
dubbele breedte en de grafiek naast/onder de tekst. Guition-heap ~6,46 MB;
CYD 43,8–53,9 kB vrij (klok- en voorspellingsonderdelen kosten RAM; de
gewone indeling houdt ~52 kB).
- Guition LVGL-snapshots (lokaal in `diagnostics/guition-0214-demo-p*.png`)
gecontroleerd: klok met wijzers, tijd en datum; vijf voorspellingskolommen
met iconen en hoog/laag; sparkline; timer die aftelt; zon; persoon.
- Daarna op beide apparaten de renderdiagnose op de echte indeling van de
eigenaar geslaagd (Guition `count=10`, CYD `count=6`).
- Gevonden en verholpen tijdens de proef: de datumregel onder het 60px-klokfont
viel buiten de kaart (nu 48/24 px met fallback zonder datum); het
sparkline-object begon op de kaartoorsprong (lijn staat nu op eigen positie);
de breedte-optie komt met het statusbericht na de indeling, dus de pagina's
worden opnieuw gepakt zodra `size` wijzigt; de zelftest in de YAML rekende
nog met zes tegels per pagina.
De productie-add-on op HA is de GitHub-versie; na de push verschijnt app
0.2.12 in de winkel. De demo-indeling wordt door de lopende manager binnen
~25 s weer vervangen door de echte indeling. Er is geen langdurige
stabiliteitsproef uitgevoerd; de fysieke bevestiging van de nieuwe kaarten in
de eigen HA-indeling volgt na de add-on-update.
+76 -4
View File
@@ -1,7 +1,7 @@
# Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911.
# Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.13"
SCREEN_FIRMWARE_VERSION: "0.2.14"
DYNAMIC_TILES: "false"
DASHBOARD_INBOX_INTERNAL: "true"
DEVICE_NAME: "guition-wallbox"
@@ -619,6 +619,8 @@ esphome:
runtime_tiles::watch_font = id(headline)->get_lv_font();
runtime_tiles::watch_value_font = id(watch_value)->get_lv_font();
runtime_tiles::watch_icon_font = id(watch_icon)->get_lv_font();
runtime_tiles::clock_font = id(clock_digits)->get_lv_font();
runtime_tiles::now_time = []() { return id(homeassistant_time).now(); };
runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font();
runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true";
if (!runtime_tiles::enabled) return;
@@ -1020,6 +1022,13 @@ font:
' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
'ä','ö','ü','Ä','Ö','Ü','ß','/']
# Clock card digits only; the date line reuses the tile title font.
- file: "fonts/Roboto-500.ttf"
id: clock_digits
size: 48
bpp: 4
glyphs: [':','-','0','1','2','3','4','5','6','7','8','9']
- file: "fonts/Roboto-700.ttf"
id: label
size: 18
@@ -1071,6 +1080,24 @@ font:
- "${MDI_GLYPH_9}"
- "${MDI_GLYPH_10}"
- "\U000F012C" # check
- "\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
- "\U000F059B" # weather-sunset-down
- "\U000F0150" # clock-outline
- "\U000F051B" # timer-outline
- "\U000F0004" # account
- file: 'fonts/materialdesignicons-webfont.ttf'
id: materialdesign_icons_mini
@@ -1091,6 +1118,24 @@ font:
- "${MDI_GLYPH_9}"
- "${MDI_GLYPH_10}"
- "\U000F012C" # check
- "\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
- "\U000F059B" # weather-sunset-down
- "\U000F0150" # clock-outline
- "\U000F051B" # timer-outline
- "\U000F0004" # account
- file: 'fonts/materialdesignicons-webfont.ttf'
id: watch_icon
@@ -1111,6 +1156,24 @@ font:
- "${MDI_GLYPH_9}"
- "${MDI_GLYPH_10}"
- "\U000F012C" # check
- "\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
- "\U000F059B" # weather-sunset-down
- "\U000F0150" # clock-outline
- "\U000F051B" # timer-outline
- "\U000F0004" # account
# Geheugen zichtbaar maken: dit bord heeft geen PSRAM, dus de vrije heap
# bepaalt hoeveel UI erbij kan. Verschijnt ook als entity in Home Assistant.
@@ -1880,6 +1943,14 @@ lvgl:
text: "${ROOM_NAME}"
styles: style_room
# Enables LVGL's line widget for the runtime clock hands and sparklines.
- line:
id: runtime_line_enable
hidden: true
points:
- 0, 0
- 1, 1
- label:
id: lbl_time
align: TOP_RIGHT
@@ -4520,18 +4591,19 @@ script:
- lambda: |-
id(last_touch_ms) = millis();
static unsigned test_page=0;
id(tile_page) = runtime_tiles::enabled ? test_page++ % std::max(1,(int(runtime_tiles::model.count)+5)/6) : 1;
id(tile_page) = runtime_tiles::enabled ? test_page++ % runtime_tiles::page_count() : 1;
- script.execute: show_tile_page
- delay: 150ms
- lambda: |-
lv_obj_update_layout(id(tile_scroll));
const int count = runtime_tiles::enabled ? runtime_tiles::model.count : std::clamp(atoi("${TILE_COUNT}"), 1, 10);
const bool paginated = count > 6;
// Wide cards take two slots, so visibility follows the runtime packing.
const bool paginated = runtime_tiles::enabled ? runtime_tiles::page_count() > 1 : count > 6;
lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5),
id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)};
bool ok = guition::check_geometry(id(home_page)->obj);
for (int i = 0; i < 10; ++i) {
bool visible = runtime_tiles::enabled ? i<6 && i+id(tile_page)*6<count : i < count && (paginated ? i >= 6 : i < 6);
bool visible = runtime_tiles::enabled ? i<6 && runtime_tiles::widgets[i].index<runtime_tiles::model.count : i < count && (paginated ? i >= 6 : i < 6);
ok = ok && (lv_obj_has_flag(tiles[i], LV_OBJ_FLAG_HIDDEN) != visible);
}
for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)})
+76 -4
View File
@@ -58,7 +58,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.13"
SCREEN_FIRMWARE_VERSION: "0.2.14"
DYNAMIC_TILES: "false"
DASHBOARD_INBOX_INTERNAL: "true"
DEVICE_NAME: "cyd-new"
@@ -812,6 +812,8 @@ esphome:
runtime_tiles::watch_font = id(headline)->get_lv_font();
runtime_tiles::watch_value_font = id(watch_value)->get_lv_font();
runtime_tiles::watch_icon_font = id(watch_icon)->get_lv_font();
runtime_tiles::clock_font = id(clock_digits)->get_lv_font();
runtime_tiles::now_time = []() { return id(homeassistant_time).now(); };
runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font();
runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true";
if (!runtime_tiles::enabled) return;
@@ -1209,6 +1211,13 @@ font:
' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
'ä','ö','ü','Ä','Ö','Ü','ß','/']
# Clock card digits only; the date line reuses the tile title font.
- file: "fonts/Roboto-500.ttf"
id: clock_digits
size: 24
bpp: 4
glyphs: [':','-','0','1','2','3','4','5','6','7','8','9']
- file: "fonts/Roboto-700.ttf"
id: label
size: 11
@@ -1260,6 +1269,24 @@ font:
- "${MDI_GLYPH_9}"
- "${MDI_GLYPH_10}"
- "\U000F012C" # check
- "\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
- "\U000F059B" # weather-sunset-down
- "\U000F0150" # clock-outline
- "\U000F051B" # timer-outline
- "\U000F0004" # account
- file: 'fonts/materialdesignicons-webfont.ttf'
id: materialdesign_icons_mini
@@ -1280,6 +1307,24 @@ font:
- "${MDI_GLYPH_9}"
- "${MDI_GLYPH_10}"
- "\U000F012C" # check
- "\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
- "\U000F059B" # weather-sunset-down
- "\U000F0150" # clock-outline
- "\U000F051B" # timer-outline
- "\U000F0004" # account
- file: 'fonts/materialdesignicons-webfont.ttf'
id: watch_icon
@@ -1300,6 +1345,24 @@ font:
- "${MDI_GLYPH_9}"
- "${MDI_GLYPH_10}"
- "\U000F012C" # check
- "\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
- "\U000F059B" # weather-sunset-down
- "\U000F0150" # clock-outline
- "\U000F051B" # timer-outline
- "\U000F0004" # account
# Geheugen zichtbaar maken: dit bord heeft geen PSRAM, dus de vrije heap
# bepaalt hoeveel UI erbij kan. Verschijnt ook als entity in Home Assistant.
@@ -2077,6 +2140,14 @@ lvgl:
text: "${ROOM_NAME}"
styles: style_room
# Enables LVGL's line widget for the runtime clock hands and sparklines.
- line:
id: runtime_line_enable
hidden: true
points:
- 0, 0
- 1, 1
- label:
id: lbl_time
align: TOP_RIGHT
@@ -4648,18 +4719,19 @@ script:
- lambda: |-
id(last_touch_ms) = millis();
static unsigned test_page=0;
id(tile_page) = runtime_tiles::enabled ? test_page++ % std::max(1,(int(runtime_tiles::model.count)+5)/6) : 1;
id(tile_page) = runtime_tiles::enabled ? test_page++ % runtime_tiles::page_count() : 1;
- script.execute: show_tile_page
- delay: 150ms
- lambda: |-
lv_obj_update_layout(id(tile_scroll));
const int count = runtime_tiles::enabled ? runtime_tiles::model.count : std::clamp(atoi("${TILE_COUNT}"), 1, 10);
const bool paginated = count > 6;
// Wide cards take two slots, so visibility follows the runtime packing.
const bool paginated = runtime_tiles::enabled ? runtime_tiles::page_count() > 1 : count > 6;
lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5),
id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)};
bool ok = true;
for (int i = 0; i < 10; ++i) {
bool visible = runtime_tiles::enabled ? i<6 && i+id(tile_page)*6<count : i < count && (paginated ? i >= 6 : i < 6);
bool visible = runtime_tiles::enabled ? i<6 && runtime_tiles::widgets[i].index<runtime_tiles::model.count : i < count && (paginated ? i >= 6 : i < 6);
ok = ok && (lv_obj_has_flag(tiles[i], LV_OBJ_FLAG_HIDDEN) != visible);
}
for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)})
+76 -4
View File
@@ -59,7 +59,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.13"
SCREEN_FIRMWARE_VERSION: "0.2.14"
DYNAMIC_TILES: "true"
DASHBOARD_INBOX_INTERNAL: "false"
DEVICE_NAME: "cyd-new"
@@ -816,6 +816,8 @@ esphome:
runtime_tiles::watch_font = id(headline)->get_lv_font();
runtime_tiles::watch_value_font = id(watch_value)->get_lv_font();
runtime_tiles::watch_icon_font = id(watch_icon)->get_lv_font();
runtime_tiles::clock_font = id(clock_digits)->get_lv_font();
runtime_tiles::now_time = []() { return id(homeassistant_time).now(); };
runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font();
runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true";
if (!runtime_tiles::enabled) return;
@@ -1200,6 +1202,13 @@ font:
' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
'ä','ö','ü','Ä','Ö','Ü','ß','/']
# Clock card digits only; the date line reuses the tile title font.
- file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-500.ttf"
id: clock_digits
size: 24
bpp: 4
glyphs: [':','-','0','1','2','3','4','5','6','7','8','9']
- file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-700.ttf"
id: label
size: 11
@@ -1251,6 +1260,24 @@ font:
- "${MDI_GLYPH_9}"
- "${MDI_GLYPH_10}"
- "\U000F012C" # check
- "\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
- "\U000F059B" # weather-sunset-down
- "\U000F0150" # clock-outline
- "\U000F051B" # timer-outline
- "\U000F0004" # account
- file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf"
id: materialdesign_icons_mini
@@ -1271,6 +1298,24 @@ font:
- "${MDI_GLYPH_9}"
- "${MDI_GLYPH_10}"
- "\U000F012C" # check
- "\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
- "\U000F059B" # weather-sunset-down
- "\U000F0150" # clock-outline
- "\U000F051B" # timer-outline
- "\U000F0004" # account
- file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf"
id: watch_icon
@@ -1291,6 +1336,24 @@ font:
- "${MDI_GLYPH_9}"
- "${MDI_GLYPH_10}"
- "\U000F012C" # check
- "\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
- "\U000F059B" # weather-sunset-down
- "\U000F0150" # clock-outline
- "\U000F051B" # timer-outline
- "\U000F0004" # account
# Geheugen zichtbaar maken: dit bord heeft geen PSRAM, dus de vrije heap
# bepaalt hoeveel UI erbij kan. Verschijnt ook als entity in Home Assistant.
@@ -2010,6 +2073,14 @@ lvgl:
text: "${ROOM_NAME}"
styles: style_room
# Enables LVGL's line widget for the runtime clock hands and sparklines.
- line:
id: runtime_line_enable
hidden: true
points:
- 0, 0
- 1, 1
- label:
id: lbl_time
align: TOP_RIGHT
@@ -4581,18 +4652,19 @@ script:
- lambda: |-
id(last_touch_ms) = millis();
static unsigned test_page=0;
id(tile_page) = runtime_tiles::enabled ? test_page++ % std::max(1,(int(runtime_tiles::model.count)+5)/6) : 1;
id(tile_page) = runtime_tiles::enabled ? test_page++ % runtime_tiles::page_count() : 1;
- script.execute: show_tile_page
- delay: 150ms
- lambda: |-
lv_obj_update_layout(id(tile_scroll));
const int count = runtime_tiles::enabled ? runtime_tiles::model.count : std::clamp(atoi("${TILE_COUNT}"), 1, 10);
const bool paginated = count > 6;
// Wide cards take two slots, so visibility follows the runtime packing.
const bool paginated = runtime_tiles::enabled ? runtime_tiles::page_count() > 1 : count > 6;
lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5),
id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)};
bool ok = true;
for (int i = 0; i < 10; ++i) {
bool visible = runtime_tiles::enabled ? i<6 && i+id(tile_page)*6<count : i < count && (paginated ? i >= 6 : i < 6);
bool visible = runtime_tiles::enabled ? i<6 && runtime_tiles::widgets[i].index<runtime_tiles::model.count : i < count && (paginated ? i >= 6 : i < 6);
ok = ok && (lv_obj_has_flag(tiles[i], LV_OBJ_FLAG_HIDDEN) != visible);
}
for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)})
+76 -4
View File
@@ -2,7 +2,7 @@
# Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911.
# Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.13"
SCREEN_FIRMWARE_VERSION: "0.2.14"
DYNAMIC_TILES: "true"
DASHBOARD_INBOX_INTERNAL: "false"
DEVICE_NAME: "guition-new"
@@ -623,6 +623,8 @@ esphome:
runtime_tiles::watch_font = id(headline)->get_lv_font();
runtime_tiles::watch_value_font = id(watch_value)->get_lv_font();
runtime_tiles::watch_icon_font = id(watch_icon)->get_lv_font();
runtime_tiles::clock_font = id(clock_digits)->get_lv_font();
runtime_tiles::now_time = []() { return id(homeassistant_time).now(); };
runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font();
runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true";
if (!runtime_tiles::enabled) return;
@@ -1011,6 +1013,13 @@ font:
' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
'ä','ö','ü','Ä','Ö','Ü','ß','/']
# Clock card digits only; the date line reuses the tile title font.
- file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-500.ttf"
id: clock_digits
size: 48
bpp: 4
glyphs: [':','-','0','1','2','3','4','5','6','7','8','9']
- file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-700.ttf"
id: label
size: 18
@@ -1062,6 +1071,24 @@ font:
- "${MDI_GLYPH_9}"
- "${MDI_GLYPH_10}"
- "\U000F012C" # check
- "\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
- "\U000F059B" # weather-sunset-down
- "\U000F0150" # clock-outline
- "\U000F051B" # timer-outline
- "\U000F0004" # account
- file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf"
id: materialdesign_icons_mini
@@ -1082,6 +1109,24 @@ font:
- "${MDI_GLYPH_9}"
- "${MDI_GLYPH_10}"
- "\U000F012C" # check
- "\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
- "\U000F059B" # weather-sunset-down
- "\U000F0150" # clock-outline
- "\U000F051B" # timer-outline
- "\U000F0004" # account
- file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf"
id: watch_icon
@@ -1102,6 +1147,24 @@ font:
- "${MDI_GLYPH_9}"
- "${MDI_GLYPH_10}"
- "\U000F012C" # check
- "\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
- "\U000F059B" # weather-sunset-down
- "\U000F0150" # clock-outline
- "\U000F051B" # timer-outline
- "\U000F0004" # account
# Geheugen zichtbaar maken: dit bord heeft geen PSRAM, dus de vrije heap
# bepaalt hoeveel UI erbij kan. Verschijnt ook als entity in Home Assistant.
@@ -1813,6 +1876,14 @@ lvgl:
text: "${ROOM_NAME}"
styles: style_room
# Enables LVGL's line widget for the runtime clock hands and sparklines.
- line:
id: runtime_line_enable
hidden: true
points:
- 0, 0
- 1, 1
- label:
id: lbl_time
align: TOP_RIGHT
@@ -4453,18 +4524,19 @@ script:
- lambda: |-
id(last_touch_ms) = millis();
static unsigned test_page=0;
id(tile_page) = runtime_tiles::enabled ? test_page++ % std::max(1,(int(runtime_tiles::model.count)+5)/6) : 1;
id(tile_page) = runtime_tiles::enabled ? test_page++ % runtime_tiles::page_count() : 1;
- script.execute: show_tile_page
- delay: 150ms
- lambda: |-
lv_obj_update_layout(id(tile_scroll));
const int count = runtime_tiles::enabled ? runtime_tiles::model.count : std::clamp(atoi("${TILE_COUNT}"), 1, 10);
const bool paginated = count > 6;
// Wide cards take two slots, so visibility follows the runtime packing.
const bool paginated = runtime_tiles::enabled ? runtime_tiles::page_count() > 1 : count > 6;
lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5),
id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)};
bool ok = guition::check_geometry(id(home_page)->obj);
for (int i = 0; i < 10; ++i) {
bool visible = runtime_tiles::enabled ? i<6 && i+id(tile_page)*6<count : i < count && (paginated ? i >= 6 : i < 6);
bool visible = runtime_tiles::enabled ? i<6 && runtime_tiles::widgets[i].index<runtime_tiles::model.count : i < count && (paginated ? i >= 6 : i < 6);
ok = ok && (lv_obj_has_flag(tiles[i], LV_OBJ_FLAG_HIDDEN) != visible);
}
for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)})
+7
View File
@@ -1,3 +1,10 @@
## 0.2.12 (firmware 0.2.14)
- Nieuwe kaarten in de kiezer: **Klok** (digitaal of analoog, ingebouwd), **weersvoorspelling** met vijf dagen op een dubbelbrede weerkaart, **grafiek** van de sensorgeschiedenis in de tegel, **zon** (`sun.sun`, opkomst en ondergang in je eigen tijdzone), **kookwekker** (`timer.*`, live aftellen; tikken start of pauzeert, lang indrukken annuleert) en **aanwezigheid** (`person.*`).
- **Dubbelbreed** als breedte-optie voor elke tegel. Een dubbelbrede tegel begint links en telt voor twee vakjes; het schermvoorbeeld toont ook de lege plek ervoor.
- De weerkaart toont het icoon van de actuele weersituatie. Voorspellingen komen van `weather.get_forecasts` en worden elk half uur ververst.
- Nieuwe domeinen en de klok vereisen schermfirmware 0.2.14; de app weigert opslaan voor oudere firmware en bewaart je bestaande indeling. Breedte en weergave-opties negeert oudere firmware gewoon.
## Firmware 0.2.13 (app 0.2.11 blijft bruikbaar)
- Grote waarde: klein domeinicoon naast de titel, titel en waarde verticaal gecentreerd, groter cijferfont (38 px Guition, 22 px CYD). Een te lange waarde krijgt puntjes; de eenheid staat altijd rechts naast het getal.
+81 -7
View File
@@ -1,14 +1,19 @@
"""Pure validation, firmware generation and bounded display protocol."""
import base64
from datetime import datetime, timezone
import json
import math
import re
import secrets
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'.split())
DOMAINS = frozenset('light switch input_boolean scene script climate vacuum fan cover sensor binary_sensor input_select select number input_number weather media_player button input_button sun timer person screen'.split())
# Built-in cards without a Home Assistant entity; firmware 0.2.14+ renders them.
BUILTIN = {'screen.clock': 'Klok'}
NEW_DOMAINS = frozenset('sun timer person screen'.split())
WEEKDAYS = ['ma', 'di', 'wo', 'do', 'vr', 'za', 'zo']
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'}
ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hs_color color_temp_kelvin min_color_temp_kelvin max_color_temp_kelvin fan_speed_list unit_of_measurement battery_level fan_speed volume_level media_title options min max step temperature_unit supported_features'.split())
ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hs_color color_temp_kelvin min_color_temp_kelvin max_color_temp_kelvin fan_speed_list unit_of_measurement battery_level fan_speed volume_level media_title options min max step temperature_unit supported_features next_rising next_setting finishes_at duration remaining'.split())
TILE_BACKGROUNDS = {
@@ -24,6 +29,9 @@ TILE_BACKGROUNDS = {
'gray': {'label': 'Grijs', 'color': '#E5E7EB'},
}
# Display modes per domain; everything else offers standard and watch (large value).
DISPLAYS = {'weather': ('standard', 'watch', 'forecast'), 'sensor': ('standard', 'watch', 'graph'), 'screen': ('digital', 'analog')}
# Additive schema 1 extension. An absent object retains old firmware/YAML defaults.
SETTING_RULES = {
'standby_enabled': (True, None, None),
@@ -60,7 +68,17 @@ def validate_settings(data):
return clean
def entity_id(value):
return isinstance(value, str) and len(value) <= 120 and re.fullmatch(r'[a-z0-9_]+\.[a-z0-9_]+', value) and value.split('.')[0] in DOMAINS
if not (isinstance(value, str) and len(value) <= 120 and re.fullmatch(r'[a-z0-9_]+\.[a-z0-9_]+', value)):
return False
return value in BUILTIN if value.startswith('screen.') else value.split('.')[0] in DOMAINS
def min_firmware(layout):
"""Oldest firmware that still accepts this layout; None when any version works."""
if any(t['entity'].split('.')[0] in NEW_DOMAINS for t in layout['tiles']):
return (0, 2, 14)
if len(layout['tiles']) > 10:
return (0, 2, 7)
return None
def short(value, limit):
return str(value).encode('utf-8')[:limit].decode('utf-8', errors='ignore')
@@ -86,15 +104,19 @@ def validate_layout(data):
item = {'entity': tile['entity'], 'name': name.strip()}
if 'options' in tile:
options = tile['options']
if not isinstance(options, dict) or set(options) - {'tap', 'display', 'inline', 'history_hours', 'background'}:
if not isinstance(options, dict) or set(options) - {'tap', 'display', 'inline', 'history_hours', 'background', 'size'}:
raise ValueError('Onbekende tegelinstellingen.')
if 'background' in options and (not isinstance(options['background'],str) or options['background'] not in TILE_BACKGROUNDS):
raise ValueError('Kies een pastel achtergrondkleur uit het palet.')
choices = {'tap': ('auto', 'detail', 'toggle', 'none'), 'display': ('standard', 'watch'), 'inline': ('none', 'slider')}
domain = tile['entity'].split('.')[0]
displays = DISPLAYS.get(domain, ('standard', 'watch'))
choices = {'tap': ('auto', 'detail', 'toggle', 'none'), 'display': displays, 'inline': ('none', 'slider'), 'size': ('single', 'wide')}
for key, allowed in choices.items():
if key in options and options[key] not in allowed:
raise ValueError('Ongeldige tegelinstelling: ' + key)
# The five-day strip only fits a double-width card.
if options.get('display') == 'forecast':
options = {**options, 'size': 'wide'}
if options.get('tap') == 'toggle' and domain not in {'light','switch','input_boolean','fan','media_player'}:
raise ValueError('Deze entiteit ondersteunt geen aan/uit-actie.')
if options.get('inline') == 'slider' and domain not in {'light','fan','cover','number','input_number','media_player'}:
@@ -110,7 +132,59 @@ def validate_layout(data):
result['settings'] = validate_settings(data['settings'])
return result
def state_message(index, tile, states):
def local_clock(value, tz):
"""HH:MM in the home's time zone for an ISO timestamp; '' when unusable."""
try:
moment = datetime.fromisoformat(str(value).replace('Z', '+00:00'))
except (ValueError, TypeError):
return ''
if moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
return moment.astimezone(tz or timezone.utc).strftime('%H:%M')
def extras(tile, states, forecast=None, tz=None):
"""Small, pre-computed values the firmware cannot derive itself (time zones, forecasts)."""
domain = tile['entity'].split('.')[0]
attrs = states.get(tile['entity'], {}).get('attributes', {})
if domain == 'weather' and forecast:
days = []
for entry in forecast[:5]:
if not isinstance(entry, dict):
continue
try:
day = datetime.fromisoformat(str(entry.get('datetime')).replace('Z', '+00:00')).astimezone(tz or timezone.utc)
except (ValueError, TypeError):
continue
item = {'d': WEEKDAYS[day.weekday()], 'c': short(entry.get('condition') or '', 20)}
for key, name in (('h', 'temperature'), ('l', 'templow')):
value = entry.get(name)
if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value):
item[key] = round(value, 1)
days.append(item)
return {'days': days} if days else None
if domain == 'sun':
rise, down = local_clock(attrs.get('next_rising'), tz), local_clock(attrs.get('next_setting'), tz)
return {'rise': rise, 'set': down} if rise or down else None
if domain == 'timer':
result = {}
try:
end = datetime.fromisoformat(str(attrs.get('finishes_at')).replace('Z', '+00:00'))
result['end'] = int(end.timestamp())
except (ValueError, TypeError):
pass
for key, name in (('dur', 'duration'), ('rem', 'remaining')):
if isinstance(attrs.get(name), str):
result[key] = short(attrs[name], 16)
return result or None
return None
def state_message(index, tile, states, extra=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']
return message
state = states.get(tile['entity'], {})
attrs = state.get('attributes', {})
bounded = {}
@@ -131,7 +205,7 @@ def state_message(index, tile, states):
return {'v': 1, 'op': 'state', 'i': index, 'entity': tile['entity'],
'name': short(tile['name'] or attrs.get('friendly_name') or tile['entity'], 80),
'state': short(state.get('state', 'unavailable'), 160), 'a': bounded,
**({'o': tile['options']} if 'options' in tile else {})}
**({'o': tile['options']} if 'options' in tile else {}), **({'x': extra} if extra else {})}
def packets(message, token=None):
raw = json.dumps(message, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode()
+51 -17
View File
@@ -12,7 +12,8 @@ import math
from firmware import Firmware
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
from core import TILE_BACKGROUNDS, discover, installation_yaml, packets, state_message, validate_layout, validate_settings
from core import BUILTIN, TILE_BACKGROUNDS, discover, extras, installation_yaml, min_firmware, packets, state_message, validate_layout, validate_settings
from zoneinfo import ZoneInfo
LOG = logging.getLogger('screen_manager')
@@ -27,6 +28,7 @@ class HomeAssistant:
self.online = False
self.changed = asyncio.Event()
self.setting_events = []
self.time_zone = None
async def request(self, kind, **data):
if self.ws is None or self.ws.closed:
@@ -89,6 +91,10 @@ class HomeAssistant:
await self.request('subscribe_events', event_type='esphome.screen_setting')
self.states = {s['entity_id']: s for s in await self.request('get_states')}
await self.registries()
try:
self.time_zone = ZoneInfo((await self.request('get_config')).get('time_zone') or 'UTC')
except Exception:
self.time_zone = timezone.utc
self.online = True
self.changed.set()
LOG.info('Home Assistant verbonden')
@@ -120,6 +126,13 @@ class HomeAssistant:
await self.request('call_service', domain='text', service='set_value',
service_data={'entity_id': inbox, 'value': packet})
async def forecast(self, entity):
# Forecasts left the weather attributes in HA 2024.4; ask the service instead.
result = await self.request('call_service', domain='weather', service='get_forecasts',
service_data={'type': 'daily'}, target={'entity_id': entity}, return_response=True)
forecast = ((result or {}).get('response') or {}).get(entity, {}).get('forecast', [])
return forecast if isinstance(forecast, list) else []
async def history(self, entity, hours):
start = (datetime.now(timezone.utc)-timedelta(hours=hours)).isoformat()
async with self.session.get(self.base+'/history/period/'+start,
@@ -154,6 +167,7 @@ class Manager:
self.ha, self.path = ha, Path(path)
self.layouts, self.sent, self.status, self.last = {}, {}, {}, {}
self.histories = {}
self.forecasts = {}
self.firmware = Firmware(os.environ.get("ESPHOME_CONFIG", "/homeassistant/esphome"), self.path.parent)
if self.path.exists():
raw = json.loads(self.path.read_text())
@@ -165,20 +179,32 @@ class Manager:
def inventory(self):
return discover(self.ha.registry, self.ha.states, self.ha.devices, self.ha.areas)
def supports_twenty(self, inbox):
def firmware_version(self, inbox):
screen=next((s for s in self.inventory()[0] if s['id']==inbox), {})
try:
version=tuple(int(part) for part in screen.get('firmware','').split('.'))
return len(version)==3 and version >= (0,2,7)
return version if len(version)==3 else None
except (ValueError, TypeError):
return False
return None
def supports_twenty(self, inbox):
version=self.firmware_version(inbox)
return bool(version) and version >= (0,2,7)
def needs_firmware(self, inbox, layout):
"""Version string the screen must run first, or None when the layout can be sent."""
needed=min_firmware(layout)
if needed and not ((self.firmware_version(inbox) or (0,0,0)) >= needed):
return '.'.join(str(part) for part in needed)
return None
def save(self, inbox, data):
if inbox not in {s['id'] for s in self.inventory()[0]}:
raise ValueError('Dit is geen gekoppeld ESP-scherm. Vernieuw het overzicht.')
layout = validate_layout(data)
if len(layout["tiles"])>10 and not self.supports_twenty(inbox):
raise ValueError("Installeer eerst schermfirmware 0.2.7 of nieuwer voor meer dan tien tegels.")
needed = self.needs_firmware(inbox, layout)
if needed:
raise ValueError(f"Installeer eerst schermfirmware {needed} of nieuwer voor deze tegels.")
# A still-open older UI may save tiles without the new optional settings.
if 'settings' not in layout and 'settings' in self.layouts.get(inbox, {}):
layout['settings'] = self.layouts[inbox]['settings'].copy()
@@ -196,7 +222,7 @@ class Manager:
tile['options']['background']=old_options['background']
if 'options' not in tile and 'options' in old_tiles.get(tile['entity'],{}):
tile['options'] = old_tiles[tile['entity']]['options'].copy()
known = {e['id'] for e in self.inventory()[1]}
known = {e['id'] for e in self.inventory()[1]} | set(BUILTIN)
if any(t['entity'] not in known for t in layout['tiles']):
raise ValueError('Een gekozen entiteit bestaat niet meer. Zoek de nieuwe entiteit op.')
updated = {**self.layouts, inbox: layout}
@@ -213,9 +239,18 @@ class Manager:
self.status[inbox] = 'Opgeslagen; wacht op synchronisatie'
self.ha.changed.set()
async def cached(self, store, key, ttl, fetch):
entry=store.get(key)
if not entry or time.monotonic()-entry[0]>ttl:
try: value=await fetch()
except Exception: value=[]
entry=(time.monotonic(),value);store[key]=entry
return entry[1]
async def sync_one(self, inbox, layout, force=False):
if len(layout["tiles"])>10 and not self.supports_twenty(inbox):
self.status[inbox]="Indeling bewaard; firmware 0.2.7+ nodig voor meer dan tien tegels"
needed = self.needs_firmware(inbox, layout)
if needed:
self.status[inbox]=f"Indeling bewaard; firmware {needed}+ nodig voor deze tegels"
return
messages = [{'v': 1, 'op': 'layout', 'inbox': inbox, 'title': layout['title'], 'entities': [t['entity'] for t in layout['tiles']]}]
if 'settings' in layout:
@@ -224,16 +259,14 @@ class Manager:
if next((s.get('board') for s in self.inventory()[0] if s['id']==inbox),None)=='guition':
messages[0]['rotation'] = layout['settings'].get('rotation',0)
for i,tile in enumerate(layout['tiles']):
message=state_message(i,tile,self.ha.states)
forecast=None
if tile['entity'].startswith('weather.') and hasattr(self.ha,'forecast'):
forecast=await self.cached(self.forecasts, tile['entity'], 1800, lambda: self.ha.forecast(tile['entity']))
message=state_message(i,tile,self.ha.states,extras(tile,self.ha.states,forecast,getattr(self.ha,'time_zone',None)))
if tile['entity'].startswith('sensor.') and hasattr(self.ha,'history'):
hours=tile.get('options',{}).get('history_hours',24)
key=(tile['entity'],hours)
cached=self.histories.get(key)
if not cached or time.monotonic()-cached[0]>300:
try: values=await self.ha.history(*key)
except Exception: values=[]
cached=(time.monotonic(),values);self.histories[key]=cached
message['history']={'hours':hours,'values':cached[1]}
message['history']={'hours':hours,'values':await self.cached(self.histories, key, 300, lambda: self.ha.history(*key))}
messages.append(message)
previous = self.sent.get(inbox, [])
force = force or not previous or messages[0] != previous[0]
@@ -323,7 +356,8 @@ def create_app(manager, development=False):
for screen in screens:
screen['layout'] = manager.layouts.get(screen['id'], {'title': 'Thuis', 'tiles': []})
screen['delivery'] = manager.status.get(screen['id'], 'Kies je eerste tegels')
return web.json_response({'csrf': csrf, 'connected': manager.ha.online, 'screens': screens, 'entities': entities, 'backgrounds': TILE_BACKGROUNDS})
builtin = [{'id': key, 'name': name, 'device': 'Ingebouwd op het scherm', 'area': '', 'state': 'ok'} for key, name in BUILTIN.items()]
return web.json_response({'csrf': csrf, 'connected': manager.ha.online, 'screens': screens, 'entities': entities, 'backgrounds': TILE_BACKGROUNDS, 'builtin': builtin})
async def save(request):
manager.save(request.match_info['inbox'], await request.json())
return web.json_response({'saved': True})
+89 -45
View File
@@ -220,7 +220,31 @@ const domains = {
select: ["Keuze", "≡", "#5862af", "#eaecfa"],
input_select: ["Keuze", "≡", "#5862af", "#eaecfa"],
button: ["Actie", "↗", "#5862af", "#eaecfa"],
screen: ["Klok", "◷", "#25282c", "#e9ecf1"],
sun: ["Zon", "☼", "#c86620", "#ffebdc"],
timer: ["Kookwekker", "⏱", "#008577", "#def3ed"],
person: ["Persoon", "☺", "#2f7d32", "#e1f2e2"],
};
const displayNames = { standard: "standaard", watch: "grote waarde", forecast: "weersvoorspelling", graph: "grafiek", digital: "digitale klok", analog: "analoge klok" };
// Same packing as the firmware: wide tiles start in the left column and take a whole row.
function packTiles(tiles) {
let position = 0;
const placement = tiles.map((tile) => {
const wide = tile.options?.size === "wide";
if (wide && position % 2 === 1) position++;
const slot = { page: Math.floor(position / 6), slot: position % 6, wide };
position += wide ? 2 : 1;
return slot;
});
return { placement, pages: Math.max(1, Math.ceil(position / 6)) };
}
function supportsFirmware(major, minor, patch) {
const version = inventory.screens.find((s) => s.id === selected)?.firmware || "";
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
if (!match) return false;
const [a, b, c] = match.slice(1).map(Number);
return a > major || (a === major && (b > minor || (b === minor && c >= patch)));
}
function domainBadge(id) {
const [title, symbol, color, background] = domains[id.split(".")[0]] || ["Entiteit", "◇", "#637184", "#edf0f4"];
const badge = node("span", symbol, "domain-icon");
@@ -236,43 +260,54 @@ function tileLimit() {
if(!match)return 10;
return Number(match[1])>0 || Number(match[2])>2 || Number(match[2])===2 && Number(match[3])>=7 ? 20 : 10;
}
function entityName(id) {
return inventory.entities.find((e) => e.id === id)?.name || inventory.builtin?.find((e) => e.id === id)?.name || id;
}
function renderPreview() {
const root = $("#layout-preview");
root.replaceChildren();
const pages = Math.max(1, Math.ceil(layout.tiles.length / 6));
const { placement, pages } = packTiles(layout.tiles);
for (let page = 0; page < pages; page++) {
const frame = node("section", undefined, "screen-preview");
frame.append(node("small", `Pagina ${page + 1} · ${$("#title").value || "Thuis"}`, "preview-heading"));
const grid = node("div", undefined, "preview-grid");
for (let slot = page * 6; slot < Math.min(page * 6 + 6, tileLimit()); slot++) {
const tile = layout.tiles[slot];
let filled = 0;
layout.tiles.forEach((tile, index) => {
const place = placement[index];
if (place.page !== page) return;
// Keep the grid honest: an empty right column before a wide tile is a real gap on the screen.
for (; filled < place.slot; filled++) grid.append(node("span", "", "preview-gap"));
const card = node("button", undefined, "preview-tile");
if (tile) {
const name = tile.name || inventory.entities.find(e => e.id === tile.entity)?.name || tile.entity;
const background=inventory.backgrounds?.[tile.options?.background]?.color;
if(background)card.style.backgroundColor=background;
card.append(domainBadge(tile.entity), node("strong", name));
card.draggable = true;
card.ondragstart = (e) => { dragIndex = slot; e.dataTransfer.setData("text/plain", String(slot)); };
card.ondragend = () => { dragIndex = -1; };
card.ondragover = (e) => { if (dragIndex >= 0) e.preventDefault(); };
card.ondrop = (e) => { e.preventDefault(); if (dragIndex >= 0) move(dragIndex, slot); };
card.classList.toggle("chosen", selectedTile === tile.entity);
card.setAttribute("aria-label", `Tegel ${slot + 1}: ${name}, instellen`);
card.onclick = () => {
selectedTile = tile.entity;
renderTiles();
document.querySelectorAll("#tiles > .tile")[slot]?.scrollIntoView({behavior: "smooth", block: "center"});
};
if (tile.options?.inline === "slider") card.append(node("span", "", "preview-slider"));
if (tile.options?.display === "watch") card.append(node("small", "Grote waarde"));
} else {
card.classList.add("vacant");
const name = tile.name || entityName(tile.entity);
const background=inventory.backgrounds?.[tile.options?.background]?.color;
if(background)card.style.backgroundColor=background;
if (place.wide) card.classList.add("wide");
card.append(domainBadge(tile.entity), node("strong", name));
card.draggable = true;
card.ondragstart = (e) => { dragIndex = index; e.dataTransfer.setData("text/plain", String(index)); };
card.ondragend = () => { dragIndex = -1; };
card.ondragover = (e) => { if (dragIndex >= 0) e.preventDefault(); };
card.ondrop = (e) => { e.preventDefault(); if (dragIndex >= 0) move(dragIndex, index); };
card.classList.toggle("chosen", selectedTile === tile.entity);
card.setAttribute("aria-label", `Tegel ${index + 1}: ${name}, instellen`);
card.onclick = () => {
selectedTile = tile.entity;
renderTiles();
document.querySelectorAll("#tiles > .tile")[index]?.scrollIntoView({behavior: "smooth", block: "center"});
};
if (tile.options?.inline === "slider") card.append(node("span", "", "preview-slider"));
const display = tile.options?.display;
if (display && display !== "standard") card.append(node("small", displayNames[display] || display));
grid.append(card);
filled += place.wide ? 2 : 1;
});
if (layout.tiles.length < tileLimit())
for (; filled < 6; filled++) {
const card = node("button", undefined, "preview-tile vacant");
card.append(node("span", "+"), node("small", "Tegel toevoegen"));
card.onclick = () => { $("#search").focus(); $("#search").scrollIntoView({behavior:"smooth", block:"center"}); };
grid.append(card);
}
grid.append(card);
}
frame.append(grid);
root.append(frame);
}
@@ -282,8 +317,9 @@ function renderTiles() {
$("#tiles").replaceChildren();
$("#count").textContent = `${layout.tiles.length} / ${tileLimit()}${tileLimit()===10?" · update firmware voor 20":""}`;
$("#no-tiles").hidden = layout.tiles.length > 0;
const { placement } = packTiles(layout.tiles);
layout.tiles.forEach((tile, i) => {
if (i === 6) $("#tiles").append(node("li", "Pagina 2", "page-break"));
if (i > 0 && placement[i].page !== placement[i - 1].page) $("#tiles").append(node("li", `Pagina ${placement[i].page + 1}`, "page-break"));
const li = node("li", undefined, "tile");
li.classList.toggle("selected-tile", selectedTile === tile.entity);
li.draggable = true;
@@ -310,8 +346,7 @@ function renderTiles() {
const content = node("div"),
input = node("input");
input.value = tile.name;
input.placeholder =
inventory.entities.find((e) => e.id === tile.entity)?.name || tile.entity;
input.placeholder = entityName(tile.entity);
input.maxLength = 60;
input.setAttribute("aria-label", `Naam voor tegel ${i + 1}`);
input.oninput = () => {
@@ -383,6 +418,11 @@ function renderTiles() {
options.append(palette);
const controls = {};
const domain = tile.entity.split(".")[0];
const displays = domain === "screen"
? [["digital", "Digitale klok"], ["analog", "Analoge klok"]]
: [["standard", "Naam en status"], ["watch", "Grote waarde"]];
if (domain === "weather") displays.push(["forecast", "Weersvoorspelling (dubbelbreed)"]);
if (domain === "sensor") displays.push(["graph", "Grafiek van de geschiedenis"]);
const fields = [
[
"tap",
@@ -393,15 +433,10 @@ function renderTiles() {
["none", "Alleen bekijken"],
],
],
[
"display",
"Weergave",
[
["standard", "Naam en status"],
["watch", "Grote waarde"],
],
],
["display", "Weergave", displays],
["size", "Breedte (firmware 0.2.14+)", [["single", "Normaal"], ["wide", "Dubbelbreed"]]],
];
if (domain === "screen") fields.shift();
if (
["light", "switch", "input_boolean", "fan", "media_player"].includes(
domain,
@@ -449,10 +484,12 @@ function renderTiles() {
(key === "tap"
? "auto"
: key === "display"
? "standard"
? (domain === "screen" ? "digital" : "standard")
: key === "history_hours"
? 24
: "none");
: key === "size"
? "single"
: "none");
controls[key] = input;
input.onchange = () => {
tile.options = {
@@ -461,6 +498,8 @@ function renderTiles() {
};
if (key === "display" && input.value === "watch")
tile.options.inline = "none";
if (key === "display" && input.value === "forecast")
tile.options.size = "wide";
if (key === "inline" && input.value === "slider")
tile.options.display = "standard";
for (const [field, control] of Object.entries(controls)) {
@@ -473,9 +512,11 @@ function renderTiles() {
label.append(input);
options.append(label);
}
const inspectTile = node("button", "Inspecteer deze tegel", "quiet");
inspectTile.onclick = () => inspect(tile.entity);
options.append(inspectTile);
if (domain !== "screen") {
const inspectTile = node("button", "Inspecteer deze tegel", "quiet");
inspectTile.onclick = () => inspect(tile.entity);
options.append(inspectTile);
}
content.append(options);
li.append(domainBadge(tile.entity), content, actions);
$("#tiles").append(li);
@@ -485,10 +526,10 @@ function renderResults() {
if (!layout) return;
const query = $("#search").value.toLocaleLowerCase();
const chosen = new Set(layout.tiles.map((t) => t.entity));
const matches = inventory.entities.filter(
const matches = [...(inventory.builtin || []), ...inventory.entities].filter(
(e) =>
(!filter || e.id.startsWith(filter + ".") ||
({switch:"input_boolean", number:"input_number", select:"input_select"}[filter] === e.id.split(".")[0])) &&
({switch:"input_boolean", number:"input_number", select:"input_select", weather:"sun"}[filter] === e.id.split(".")[0])) &&
`${e.name} ${e.id} ${e.device} ${e.area}`
.toLocaleLowerCase()
.includes(query),
@@ -592,6 +633,9 @@ for (const [value, label] of [
["weather", "Weer"],
["number", "Waarden"],
["select", "Keuzelijsten"],
["person", "Personen"],
["timer", "Kookwekkers"],
["screen", "Klok"],
]) {
const b = node("button", label, value === "" ? "active" : "");
if (value) b.prepend(domainBadge(value + "."));
@@ -826,7 +870,7 @@ async function inspect(entity) {
card.append(
node(
"small",
`Kleine slider: ${options.inline === "slider" ? "ja" : "nee"} · Weergave: ${options.display === "watch" ? "grote waarde" : "standaard"} · Achtergrond: ${inventory.backgrounds?.[options.background || "auto"]?.label || "Standaard"}`,
`Kleine slider: ${options.inline === "slider" ? "ja" : "nee"} · Weergave: ${displayNames[options.display || "standard"] || options.display} · Breedte: ${options.size === "wide" ? "dubbel" : "normaal"} · Achtergrond: ${inventory.backgrounds?.[options.background || "auto"]?.label || "Standaard"}`,
),
);
$("#inspection-summary").append(card);
+3 -2
View File
@@ -74,8 +74,9 @@
<span id="count">0 / 10</span>
</div>
<p class="hint">
Klik op een tegel om bediening en weergave te kiezen. Sleep om
te ordenen. Zes tegels per pagina.
Klik op een tegel om bediening, weergave en breedte te kiezen.
Sleep om te ordenen. Zes vakjes per pagina; een dubbelbrede
tegel telt voor twee en begint altijd links.
</p>
<div id="layout-preview" aria-label="Schermindeling"></div>
<ol id="tiles"></ol>
+2
View File
@@ -711,6 +711,8 @@ pre {
.preview-tile strong { flex: 1; min-width: 0; font-size: 13px; overflow-wrap: anywhere; }
.preview-tile .domain-icon { width: 30px; height: 30px; flex-basis: 30px; font-size: 20px; }
.preview-tile.chosen { outline: 2px solid var(--accent); outline-offset: 1px; }
.preview-tile.wide { grid-column: span 2; }
.preview-gap { min-height: 90px; border: 1px dashed #d0d4d9; border-radius: 15px; }
.preview-tile.vacant { background: #f4f4f4; border-style: dashed; color: #727982; justify-content: center; }
.preview-slider { height: 13px; flex-basis: 100%; border-radius: 10px; background: linear-gradient(to right, #ffbf38 60%, #fff1d3 60%); }
.preview-tile small { flex-basis: 100%; }
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.11"
version: "0.2.12"
slug: esp_screen_manager
description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen.
url: https://github.com/MaxGramser/homeassistant_espscreen
+51
View File
@@ -54,3 +54,54 @@ class FirmwareJobs(unittest.IsolatedAsyncioTestCase):
m.save('text.screen',{'title':'Home','tiles':[{'entity':'light.a','name':'Lamp','options':{'inline':'slider'}}]})
m.save('text.screen',{'title':'Nieuw','tiles':[{'entity':'light.a','name':'Lamp'}]})
self.assertEqual(m.layouts['text.screen']['tiles'][0]['options'],{'inline':'slider'})
class SpecialTiles(unittest.TestCase):
def test_new_domains_displays_and_wide_option(self):
from core import extras, min_firmware
from zoneinfo import ZoneInfo
layout=validate_layout({'title':'T','tiles':[{'entity':'screen.clock','options':{'display':'analog','size':'wide'}},{'entity':'sun.sun'},{'entity':'timer.egg'},{'entity':'person.max'},
{'entity':'weather.home','options':{'display':'forecast'}},{'entity':'sensor.t','options':{'display':'graph','size':'wide'}}]})
self.assertEqual(layout['tiles'][4]['options'],{'display':'forecast','size':'wide'},'forecast forces a wide card')
self.assertEqual(layout['tiles'][0]['options']['display'],'analog')
for tile in [{'entity':'screen.other'},{'entity':'light.a','options':{'size':'double'}},{'entity':'light.a','options':{'display':'graph'}},
{'entity':'sensor.a','options':{'display':'forecast'}},{'entity':'screen.clock','options':{'display':'watch'}}]:
with self.assertRaises(ValueError):validate_layout({'title':'T','tiles':[tile]})
self.assertEqual(min_firmware(layout),(0,2,14))
self.assertEqual(min_firmware({'tiles':[{'entity':'light.a'}]*11}),(0,2,7))
self.assertIsNone(min_firmware({'tiles':[{'entity':'light.a'}]}))
tz=ZoneInfo('Europe/Amsterdam')
states={'sun.sun':{'state':'above_horizon','attributes':{'next_rising':'2026-09-14T05:15:00+00:00','next_setting':'2026-09-13T17:50:12.000000+00:00'}},
'timer.egg':{'state':'active','attributes':{'finishes_at':'2026-09-13T12:00:00+00:00','duration':'0:05:00','remaining':'0:05:00'}},
'weather.home':{'state':'sunny','attributes':{'temperature':21.5,'temperature_unit':'°C'}}}
self.assertEqual(extras({'entity':'sun.sun'},states,None,tz),{'rise':'07:15','set':'19:50'})
self.assertEqual(extras({'entity':'timer.egg'},states,None,tz),{'end':1789300800,'dur':'0:05:00','rem':'0:05:00'})
forecast=[{'datetime':f'2026-09-{13+i}T10:00:00+00:00','condition':'partlycloudy','temperature':20+i,'templow':11.5} for i in range(7)]+['junk']
days=extras({'entity':'weather.home'},states,forecast,tz)['days']
self.assertEqual([d['d'] for d in days],['zo','ma','di','wo','do'])
self.assertEqual(days[0],{'d':'zo','c':'partlycloudy','h':20,'l':11.5})
self.assertIsNone(extras({'entity':'light.a'},states,None,tz))
msg=state_message(0,{'entity':'screen.clock','name':'','options':{'display':'analog'}},{})
self.assertEqual((msg['state'],msg['name'],msg['o']),('ok','Klok',{'display':'analog'}))
msg=state_message(1,{'entity':'weather.home','name':''},states,extras({'entity':'weather.home'},states,forecast,tz))
self.assertEqual(len(msg['x']['days']),5);self.assertTrue(all(len(p)<=255 for p in packets(msg)))
class SpecialTileSync(unittest.IsolatedAsyncioTestCase):
async def test_new_domains_need_firmware_and_send_extras(self):
with tempfile.TemporaryDirectory() as tmp:
m=ManagerTests().setup_manager(Path(tmp)/'screens.json')
layout={'title':'Home','tiles':[{'entity':'screen.clock','name':''},{'entity':'sun.sun','name':''}]}
m.ha.states['sun.sun']={'state':'above_horizon','attributes':{'friendly_name':'Zon','next_rising':'2026-09-14T05:15:00+00:00','next_setting':'2026-09-13T17:50:12+00:00'}}
with self.assertRaises(ValueError):m.save('text.screen',layout)
m.ha.registry.append({'entity_id':'sensor.screen_firmware','platform':'esphome','original_name':'Schermfirmware'})
m.ha.states['sensor.screen_firmware']={'state':'0.2.13'}
with self.assertRaises(ValueError):m.save('text.screen',layout)
m.ha.states['sensor.screen_firmware']={'state':'0.2.14'}
m.save('text.screen',layout)
await m.sync_one('text.screen',m.layouts['text.screen'])
sent=[message for _,message in m.ha.messages]
self.assertEqual(sent[0]['entities'],['screen.clock','sun.sun'])
self.assertEqual((sent[1]['state'],sent[1]['name']),('ok','Klok'))
self.assertEqual(sent[2]['x'],{'rise':'05:15','set':'17:50'},'fake HA has no time zone: UTC')
m.ha.states['sensor.screen_firmware']={'state':'0.2.13'};m.ha.messages.clear()
await m.sync_one('text.screen',m.layouts['text.screen'])
self.assertEqual(m.ha.messages,[]);self.assertIn('0.2.14',m.status['text.screen'])
+27
View File
@@ -30,3 +30,30 @@ int main() {
for(size_t i=0;i<m.count;++i){m.tiles[i].received=true;m.tiles[i].state="off";}
assert(m.ready());
}
// Built-in and new Home Assistant domains, plus wide-tile packing.
static void test_domains_and_packing() {
using namespace runtime_tiles;
Model m; bool changed = false;
assert(valid_entity("screen.clock") && !valid_entity("screen.other"));
assert(valid_entity("sun.sun") && valid_entity("timer.kitchen") && valid_entity("person.max"));
assert(m.set_layout({"screen.clock", "weather.home", "light.a", "sensor.b", "person.max", "timer.egg", "sun.sun"}, "Thuis", changed));
assert(m.tiles[0].available() && !m.tiles[1].available()); // built-in cards need no HA state
m.tiles[4].state = "home"; assert(m.tiles[4].active());
m.tiles[5].state = "active"; assert(m.tiles[5].active());
m.tiles[5].state = "idle"; assert(!m.tiles[5].active());
std::array<Placement, MAX_TILES> p;
assert(pack(m.tiles, 0, p) == 1);
assert(pack(m.tiles, m.count, p) == 2 && p[6].page == 1 && p[6].slot == 0);
// A wide tile after a left-column tile skips the right column.
m.tiles[1].wide = true;
assert(pack(m.tiles, m.count, p) == 2);
assert(p[0].slot == 0 && p[1].slot == 2 && p[2].slot == 4 && p[3].slot == 5);
assert(p[4].page == 1 && p[4].slot == 0 && p[6].page == 1 && p[6].slot == 2);
// Wide tiles ending exactly on a page boundary do not open an empty page.
for (auto &t : m.tiles) t.wide = false;
m.tiles[0].wide = m.tiles[2].wide = true;
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);
}
struct RunExtra { RunExtra() { test_domains_and_packing(); } } run_extra;