From 517b10278620f6cf021f62717607b127abf4ce21 Mon Sep 17 00:00:00 2001 From: Max Gramser Date: Sun, 13 Sep 2026 02:09:50 +0200 Subject: [PATCH] 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 --- AGENTS.md | 3 + README.md | 6 + components/smart_display/runtime_model.h | 33 +- components/smart_display/runtime_tiles.h | 406 ++++++++++++++++++++--- diagnostics/send_layout.py | 98 ++++++ docs/EASY_SETUP.md | 4 +- docs/RELEASING.md | 14 + docs/TEST_RESULTS_0214.md | 63 ++++ guition-4848s040.yaml | 80 ++++- home-like-2432s028.yaml | 80 ++++- packages/cyd.yaml | 80 ++++- packages/guition.yaml | 80 ++++- screen_manager/CHANGELOG.md | 7 + screen_manager/app/core.py | 88 ++++- screen_manager/app/server.py | 68 +++- screen_manager/app/static/app.js | 134 +++++--- screen_manager/app/static/index.html | 5 +- screen_manager/app/static/style.css | 2 + screen_manager/config.yaml | 2 +- tests/test_extended.py | 51 +++ tests/test_runtime_model.cpp | 27 ++ 21 files changed, 1185 insertions(+), 146 deletions(-) create mode 100644 diagnostics/send_layout.py create mode 100644 docs/TEST_RESULTS_0214.md diff --git a/AGENTS.md b/AGENTS.md index eac2505..0547682 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 82c83c6..f2d92c7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/components/smart_display/runtime_model.h b/components/smart_display/runtime_model.h index 34bf316..f46edc9 100644 --- a/components/smart_display/runtime_model.h +++ b/components/smart_display/runtime_model.h @@ -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 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 options; unsigned option_count = 0, history_hours = 24; std::array history{}; bool has_history = false; + std::array 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 &tiles, size_t count, std::array &out) { + unsigned position = 0; + for (size_t i = 0; i < count && i < MAX_TILES; ++i) { + if (tiles[i].wide && position % 2 == 1) ++position; + out[i] = {static_cast(position / SLOTS_PER_PAGE), static_cast(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 tiles; size_t count = 0; diff --git a/components/smart_display/runtime_tiles.h b/components/smart_display/runtime_tiles.h index 67164ad..87d55a5 100644 --- a/components/smart_display/runtime_tiles.h +++ b/components/smart_display/runtime_tiles.h @@ -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 #include @@ -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 now_time; +inline uint32_t now_epoch() { if (!now_time) return 0; auto t = now_time(); return t.is_valid() ? static_cast(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 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 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 parts{}; lv_point_precise_t *points{}; +}; inline std::array 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()) for (JsonVariant day : extra["days"].as()) { + 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() ? extra["end"].as() : 0; + tile.duration = string(extra["dur"], 16); tile.remaining = string(extra["rem"], 16); tile.has_history=false; if (root["history"]["values"].is()) { 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(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= 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(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=kminimum?(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(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(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=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=content.x1 && circle.x2=content.y1; - if(mini)fits=fits && circle.y280?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=content.x1 && circle.x2=content.y1; + if(mini)fits=fits && circle.y280?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.x2title.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.x2title.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 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; + 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;slot1)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 || 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) { diff --git a/diagnostics/send_layout.py b/diagnostics/send_layout.py new file mode 100644 index 0000000..a6d659d --- /dev/null +++ b/diagnostics/send_layout.py @@ -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()) diff --git a/docs/EASY_SETUP.md b/docs/EASY_SETUP.md index 00a43d8..1f84eef 100644 --- a/docs/EASY_SETUP.md +++ b/docs/EASY_SETUP.md @@ -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. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 025ee76..f8a032b 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -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. diff --git a/docs/TEST_RESULTS_0214.md b/docs/TEST_RESULTS_0214.md new file mode 100644 index 0000000..cfbf569 --- /dev/null +++ b/docs/TEST_RESULTS_0214.md @@ -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. diff --git a/guition-4848s040.yaml b/guition-4848s040.yaml index 0bf75b4..d4ace4e 100644 --- a/guition-4848s040.yaml +++ b/guition-4848s040.yaml @@ -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= 6 : i < 6); + bool visible = runtime_tiles::enabled ? i<6 && runtime_tiles::widgets[i].index= 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)}) diff --git a/home-like-2432s028.yaml b/home-like-2432s028.yaml index 58c6f2f..b2cdc84 100644 --- a/home-like-2432s028.yaml +++ b/home-like-2432s028.yaml @@ -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= 6 : i < 6); + bool visible = runtime_tiles::enabled ? i<6 && runtime_tiles::widgets[i].index= 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)}) diff --git a/packages/cyd.yaml b/packages/cyd.yaml index 4777937..03141b7 100644 --- a/packages/cyd.yaml +++ b/packages/cyd.yaml @@ -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= 6 : i < 6); + bool visible = runtime_tiles::enabled ? i<6 && runtime_tiles::widgets[i].index= 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)}) diff --git a/packages/guition.yaml b/packages/guition.yaml index 6b9d475..ca54fb3 100644 --- a/packages/guition.yaml +++ b/packages/guition.yaml @@ -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= 6 : i < 6); + bool visible = runtime_tiles::enabled ? i<6 && runtime_tiles::widgets[i].index= 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)}) diff --git a/screen_manager/CHANGELOG.md b/screen_manager/CHANGELOG.md index 9330bdc..df03e4b 100644 --- a/screen_manager/CHANGELOG.md +++ b/screen_manager/CHANGELOG.md @@ -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. diff --git a/screen_manager/app/core.py b/screen_manager/app/core.py index ad8a522..eac384a 100644 --- a/screen_manager/app/core.py +++ b/screen_manager/app/core.py @@ -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() diff --git a/screen_manager/app/server.py b/screen_manager/app/server.py index 19b489c..7a63d64 100644 --- a/screen_manager/app/server.py +++ b/screen_manager/app/server.py @@ -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}) diff --git a/screen_manager/app/static/app.js b/screen_manager/app/static/app.js index 361d986..2bf3a07 100644 --- a/screen_manager/app/static/app.js +++ b/screen_manager/app/static/app.js @@ -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); diff --git a/screen_manager/app/static/index.html b/screen_manager/app/static/index.html index 0037fa7..4d05fd2 100644 --- a/screen_manager/app/static/index.html +++ b/screen_manager/app/static/index.html @@ -74,8 +74,9 @@ 0 / 10

- 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.

    diff --git a/screen_manager/app/static/style.css b/screen_manager/app/static/style.css index 92a293e..8c43908 100644 --- a/screen_manager/app/static/style.css +++ b/screen_manager/app/static/style.css @@ -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%; } diff --git a/screen_manager/config.yaml b/screen_manager/config.yaml index cde30db..d88d7ad 100644 --- a/screen_manager/config.yaml +++ b/screen_manager/config.yaml @@ -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 diff --git a/tests/test_extended.py b/tests/test_extended.py index 4cf9cb6..674f61e 100644 --- a/tests/test_extended.py +++ b/tests/test_extended.py @@ -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']) diff --git a/tests/test_runtime_model.cpp b/tests/test_runtime_model.cpp index 2d710b5..5472539 100644 --- a/tests/test_runtime_model.cpp +++ b/tests/test_runtime_model.cpp @@ -30,3 +30,30 @@ int main() { for(size_t i=0;i 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;