From 2667f41f6ff032b8010a46264727c1c3b8599d87 Mon Sep 17 00:00:00 2001 From: Max Gramser Date: Sun, 13 Sep 2026 15:02:15 +0200 Subject: [PATCH] Direct controls on wide cards, weather/climate cards, spinner busy state (0.2.23) App 0.2.23 / firmware 0.2.19. - Wide cards of climate, switch, light, fan, vacuum, cover, media_player, number, select, timer, scene, script and button show Home Assistant style controls on the right (keys, -/+ pill with debounced send, slider with handle, toggle, run key); per-tile choice `options.controls` in the editor with mockup preview; the add-on sends only the set a card really shows. - Weather card: now + next hours and coming days with rain, from daily and hourly forecasts; scenes/scripts/buttons show when they last ran. - Climate card: power key, tap-to-toggle option, fan and swing rows in a redesigned mode picker (grey backdrop, white chips, accent selection). - Busy state as a spinner on a translucent sheet; render guards so only changed styles reach LVGL; dark theme removed from the runtime code. - supported_features above 1e6 reaches the screen; middle dot in text fonts; 18 control glyphs in the icon fonts; diagnostics services preview_climate_picker and preview_busy; demo layout --controls. Co-Authored-By: Claude Fable 5.1 --- README.md | 11 + components/smart_display/cyd_ui.h | 12 + components/smart_display/light_controls.h | 4 +- components/smart_display/runtime_model.h | 20 +- components/smart_display/runtime_tiles.h | 482 ++++++++++++++++-- components/smart_display/tile_controls.h | 241 +++++++++ diagnostics/send_layout.py | 67 ++- docs/EASY_SETUP.md | 10 +- docs/RELEASING.md | 28 ++ docs/TEST_RESULTS_0223.md | 39 ++ guition-4848s040.yaml | 578 +++++++++++++++++----- home-like-2432s028.yaml | 576 ++++++++++++++++----- packages/cyd.yaml | 576 ++++++++++++++++----- packages/guition.yaml | 578 +++++++++++++++++----- screen_manager/CHANGELOG.md | 20 + screen_manager/app/core.py | 139 +++++- screen_manager/app/server.py | 15 +- screen_manager/app/static/app.js | 54 +- screen_manager/app/static/index.html | 6 +- screen_manager/app/static/style.css | 10 + screen_manager/app/static/tile-icons.woff | Bin 14500 -> 15460 bytes screen_manager/app/tile_icons.py | 28 +- screen_manager/config.yaml | 2 +- tests/test_cyd_ui.cpp | 6 + tests/test_rich_cards.py | 84 ++++ tests/test_tile_controls.cpp | 104 ++++ tests/test_tile_controls.py | 101 ++++ 27 files changed, 3247 insertions(+), 544 deletions(-) create mode 100644 components/smart_display/tile_controls.h create mode 100644 docs/TEST_RESULTS_0223.md create mode 100644 tests/test_rich_cards.py create mode 100644 tests/test_tile_controls.cpp create mode 100644 tests/test_tile_controls.py diff --git a/README.md b/README.md index 6ed2520..75f36cd 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,17 @@ De firmware en ingebouwde CLI zijn getest met **ESPHome 2026.6.2**. ondergang), een **kookwekker** (`timer.*`, tikken start of pauzeert) en **aanwezigheid** (`person.*`). Kies ze in de kiezer als elke andere tegel of sleep ze direct in de schermmockup; **Dubbelbreed** is een optie voor iedere tegel. +- **Directe bediening op dubbelbrede tegels** (firmware 0.2.19+), zoals de rijen in + Home Assistant: temperatuur − / + of modusknoppen (klimaat), een toggle (schakelaar, + lamp, ventilator), start/stop/dock (stofzuiger), open/stop/dicht of een + positieschuif (zonwering), volume met dempen of vorige/play/volgende (media), + − / + of een schuif (getallen), vorige/volgende (keuzelijst), start/pauze en + annuleren (kookwekker) en één knop voor scènes, scripts en knoppen. Per tegel + instelbaar; **Geen** houdt de gewone kaart. +- **Weerkaart** met actueel weer, de komende uren en dagen inclusief regenkans of + millimeters; **klimaatkaart** met aan/uit-knop, modus-, ventilator- en zwenkstanden. + Scènes, scripts en knoppen tonen wanneer ze voor het laatst liepen. Een tegel die op + Home Assistant wacht toont een klein spinnertje op een lichte laag. - **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/cyd_ui.h b/components/smart_display/cyd_ui.h index 4ebd739..aa64efd 100644 --- a/components/smart_display/cyd_ui.h +++ b/components/smart_display/cyd_ui.h @@ -2,8 +2,20 @@ #include #include #include +#include namespace cyd { +// The n-th quoted item of a JSON list such as ["auto","low","high"]; empty when absent. +inline std::string list_item(const std::string &json, unsigned index) { + size_t pos = 0; unsigned n = 0; + while ((pos = json.find('"', pos)) != std::string::npos) { + size_t end = json.find('"', pos + 1); + if (end == std::string::npos) return {}; + if (n++ == index) return json.substr(pos + 1, end - pos - 1); + pos = end + 1; + } + return {}; +} // Work in tenths of a degree and always clamp AFTER rounding. inline int quantize_temperature(int value, int minimum, int maximum, int step) { step = std::max(1, step); diff --git a/components/smart_display/light_controls.h b/components/smart_display/light_controls.h index 72dc221..9a57cc8 100644 --- a/components/smart_display/light_controls.h +++ b/components/smart_display/light_controls.h @@ -100,7 +100,7 @@ inline lv_obj_t *plain(lv_obj_t *parent, int x, int y, int w, int h) { lv_obj_set_pos(obj, x, y); lv_obj_set_size(obj, w, h); return obj; } -inline void setup(lv_obj_t *parent, const lv_font_t *font, int width, int height, bool light_theme = false) { +inline void setup(lv_obj_t *parent, const lv_font_t *font, int width, int height) { if (ready) return; ready = true; bool large = width >= 480; @@ -114,7 +114,7 @@ inline void setup(lv_obj_t *parent, const lv_font_t *font, int width, int height auto &row = rows[i]; row.index = i; row.box = plain(parent, margin, top + i * spacing, w, spacing - 2); lv_obj_set_style_text_font(row.box, font, 0); - lv_obj_set_style_text_color(row.box, lv_color_hex(light_theme ? 0x1B1B1B : 0xEEEEF2), 0); + lv_obj_set_style_text_color(row.box, lv_color_hex(0x1B1B1B), 0); auto *label = lv_label_create(row.box); lv_label_set_text(label, names[i]); lv_obj_set_pos(label, 2, 0); row.value = lv_label_create(row.box); lv_obj_align(row.value, LV_ALIGN_TOP_RIGHT, -2, 0); diff --git a/components/smart_display/runtime_model.h b/components/smart_display/runtime_model.h index 4ec0128..2b7e8e6 100644 --- a/components/smart_display/runtime_model.h +++ b/components/smart_display/runtime_model.h @@ -25,9 +25,10 @@ inline bool valid_entity(const std::string &entity) { 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 Forecast { std::string day, condition; float high = NAN, low = NAN, rain = NAN, mm = NAN; }; +struct Hour { std::string time, condition; float temp = NAN, rain = NAN, mm = NAN; }; struct Tile { - std::string entity, name, state, unit, modes, hvac_modes; + std::string entity, name, state, unit, modes, hvac_modes, fan_modes, swing_modes, fan_mode, swing_mode; std::array fan_speeds; unsigned fan_speed_count = 0; std::string fan_speed; @@ -39,12 +40,27 @@ struct Tile { int saturation = 0; std::string tap = "auto", display = "standard", inline_control = "none", media_title; bool wide = false; + // Direct control set on a wide card (firmware 0.2.19+); empty keeps the plain card. + std::string controls, device_class, hvac_action; + bool muted = false; + // A -/+ edit shows at once and is sent as one call after a short pause; the + // value stays until Home Assistant reports it (or a timeout clears it). + float edit_value = NAN; uint32_t edit_since = 0; bool edit_sent = false; + // Knob position a toggle shows while its command is under way. + bool optimistic_on = 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; + // Weather card details (0.2.19+): the next hours, wind and feels-like temperature. + std::array hours; + unsigned hour_count = 0; + float wind = NAN, feels = NAN; + std::string wind_unit; + // When a scene, script or button last ran (unix time), pre-computed by the manager. + uint32_t last_run = 0; std::string sunrise, sunset, duration, remaining; uint32_t timer_end = 0; float battery = NAN, volume = NAN; diff --git a/components/smart_display/runtime_tiles.h b/components/smart_display/runtime_tiles.h index 47574b2..850f280 100644 --- a/components/smart_display/runtime_tiles.h +++ b/components/smart_display/runtime_tiles.h @@ -6,6 +6,7 @@ #include "esphome/core/preferences.h" #include "cyd_ui.h" #include "light_controls.h" +#include "tile_controls.h" #include "esphome/components/json/json_util.h" #include "esphome/components/api/api_server.h" #include "esphome/core/hal.h" @@ -16,7 +17,6 @@ namespace runtime_tiles { inline bool enabled = false; -inline bool light_theme = false; inline bool swipe_pages = false; inline uint32_t rotation = 0; inline bool rotation_supported = false; @@ -29,6 +29,8 @@ 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; +// Text in the -/+ pill and the run key of direct controls; the board profile sets it. +inline const lv_font_t *control_font = nullptr; inline lv_obj_t *room_label = nullptr; // remembered by render() so page switches can render synchronously 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; } @@ -37,6 +39,7 @@ 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 std::string last_run_text(uint32_t epoch); inline int active_index = -1; inline uint32_t last_received = 0; inline std::function layout_changed, refresh, dismiss, settings_changed; @@ -79,6 +82,14 @@ struct Widgets { bool wide=false; int base_width=0; lv_obj_t *extra{}; std::string extra_mode; std::array parts{}; lv_point_precise_t *points{}; // Soft area under a polyline (graph, sun path), painted by the extra container's draw event. const lv_point_precise_t *fill_points{}; unsigned fill_count=0; int fill_x=0, fill_y=0, fill_base=0; lv_color_t fill_color{}; lv_opa_t fill_opa=0; + // Direct controls on a wide card: a panel at the right with pill keys, a -/+ pill, + // a slider or a toggle. Objects are rebuilt only when the control set changes. + lv_obj_t *panel{}; std::string panel_mode; bool panel_dirty=false; int panel_w=0; + std::array keys{}, key_icons{}; std::array key_commands{}; std::array key_args; std::array key_checked{}; + lv_obj_t *pill{}, *pill_value{}, *knob{}, *control_slider{}; int knob_on=-1; + lv_color_t panel_accent{}, panel_text{}; + // Busy sheet: a translucent white cover with a small spinner while a command is under way. + lv_obj_t *busy{}, *spinner{}; }; constexpr unsigned POINT_BUFFER = 128; inline std::array widgets; @@ -176,6 +187,8 @@ 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"; + // Direct controls (0.2.19+): the manager sends only the set a wide card really shows. + tile.controls = string(options["controls"], 16); // Width arrives with the state, after the layout: re-pack the pages when it changes. bool was_wide = tile.wide; tile.wide = string(options["size"]) == "wide"; @@ -187,7 +200,15 @@ inline std::string receive(const std::string &payload) { 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"]); + f.rain = number(day["p"]); f.mm = number(day["r"]); } + tile.hour_count = 0; + if (extra["hours"].is()) for (JsonVariant hour : extra["hours"].as()) { + if (tile.hour_count == tile.hours.size()) break; + auto &h = tile.hours[tile.hour_count++]; + h.time = string(hour["t"], 5); h.condition = string(hour["c"], 20); h.temp = number(hour["h"]); h.rain = number(hour["p"]); h.mm = number(hour["r"]); + } + tile.last_run = extra["last"].is() ? extra["last"].as() : 0; 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); @@ -202,6 +223,8 @@ inline std::string receive(const std::string &payload) { if (a["options"].is()) for(JsonVariant option:a["options"].as()) { if(tile.option_count==8)break;tile.options[tile.option_count++]=string(option,48); } tile.battery=number(a["battery_level"]);tile.volume=number(a["volume_level"]); + tile.muted=a["is_volume_muted"].is() && a["is_volume_muted"].as(); + tile.device_class=string(a["device_class"],24);tile.hvac_action=string(a["hvac_action"],24); tile.media_title=string(a["media_title"],80);tile.supported=a["supported_features"].as(); tile.name = string(root["name"], 80); tile.state = string(root["state"], 160); @@ -217,9 +240,14 @@ inline std::string receive(const std::string &payload) { tile.step = number(a["target_temp_step"], 0.5f); if(tile.domain()=="number" || tile.domain()=="input_number") { tile.minimum=number(a["min"],0);tile.maximum=number(a["max"],100);tile.step=number(a["step"],1); } - if(tile.domain()=="weather") {tile.current=number(a["temperature"]);tile.unit=string(a["temperature_unit"],12);} + if(tile.domain()=="weather") { + tile.current=number(a["temperature"]);tile.unit=string(a["temperature_unit"],12); + tile.humidity=number(a["humidity"]);tile.wind=number(a["wind_speed"]);tile.wind_unit=string(a["wind_speed_unit"],8);tile.feels=number(a["apparent_temperature"]); + } tile.modes = list(a["supported_color_modes"]); tile.hvac_modes = list(a["hvac_modes"]); + tile.fan_modes = list(a["fan_modes"]); tile.swing_modes = list(a["swing_modes"]); + tile.fan_mode = string(a["fan_mode"], 48); tile.swing_mode = string(a["swing_mode"], 48); float hue = number(a["hs_color"][0]); float saturation = number(a["hs_color"][1]); tile.has_hs_color = std::isfinite(hue) && std::isfinite(saturation); @@ -235,6 +263,8 @@ inline std::string receive(const std::string &payload) { if (tile.fan_speed_count == 4) break; tile.fan_speeds[tile.fan_speed_count++] = string(speed, 48); } + // Home Assistant reports the edited value: the -/+ pill follows its state again. + if(std::isfinite(tile.edit_value) && tile.edit_sent && std::fabs(tile_controls::edit_target(tile)-tile.edit_value)<0.051f)tile.edit_value=NAN; tile.received = true; for(auto &w:widgets)if(w.index==index)w.cached_active=-1; last_received = esphome::millis(); @@ -283,13 +313,14 @@ inline lv_obj_t *detail_badge_status=nullptr; inline lv_obj_t *detail_switch=nullptr; inline lv_obj_t *detail_label(lv_obj_t *parent,const std::string &text,int x,int y,int width) { auto *label=lv_label_create(parent);lv_label_set_text(label,text.c_str());lv_obj_set_pos(label,x,y);lv_obj_set_width(label,width); - lv_obj_set_style_text_font(label,detail_font,0);lv_obj_set_style_text_color(label,lv_color_hex(light_theme?0x202020:0xFFFFFF),0); + lv_obj_set_style_text_font(label,detail_font,0);lv_obj_set_style_text_color(label,lv_color_hex(0x202020),0); 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.domain()=="script"||t.domain()=="scene"||t.domain()=="button"||t.domain()=="input_button")return t.state=="on"?"Bezig...":last_run_text(t.last_run); if(t.state=="on")return "Aan"; if(t.state=="off")return "Uit"; if(t.state=="docked")return "In dock"; @@ -333,14 +364,14 @@ inline void slider_event(lv_event_t *e){ if(code==LV_EVENT_PRESS_LOST && captured_slider==slider){captured_slider=nullptr;slider_changed=false;} if(code==LV_EVENT_RELEASED && captured_slider==slider){ unsigned index=(uintptr_t)lv_event_get_user_data(e); - for(auto &w:widgets)if(w.slider==slider){index=w.index;break;} + for(auto &w:widgets)if(w.slider==slider || w.control_slider==slider){index=w.index;break;} bool changed=slider_changed;captured_slider=nullptr;slider_changed=false; if(changed && cyd::touch_guard.accept_slider(esphome::millis(),200+index))commit_slider(index,lv_slider_get_value(slider)); } } inline lv_obj_t *detail_button(const char *text,int x,int y,int width,int height,int command){ auto *button=lv_obj_create(detail_root);lv_obj_remove_style_all(button);lv_obj_set_pos(button,x,y);lv_obj_set_size(button,width,height); - lv_obj_set_style_bg_color(button,lv_color_hex(light_theme?(command==0?0x009FE3:0xD9E6F0):0x34495E),0);lv_obj_set_style_bg_opa(button,LV_OPA_COVER,0);lv_obj_set_style_radius(button,12,0);lv_obj_add_flag(button,LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_bg_color(button,lv_color_hex(command==0?0x009FE3:0xD9E6F0),0);lv_obj_set_style_bg_opa(button,LV_OPA_COVER,0);lv_obj_set_style_radius(button,12,0);lv_obj_add_flag(button,LV_OBJ_FLAG_CLICKABLE); auto *label=detail_label(button,text,6,0,width-12);lv_obj_center(label);lv_obj_set_style_text_align(label,LV_TEXT_ALIGN_CENTER,0);if(command==0)lv_obj_set_style_text_color(label,lv_color_hex(0xFFFFFF),0);lv_obj_remove_flag(label,LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(button,[](lv_event_t *e){ int cmd=(intptr_t)lv_event_get_user_data(e);if(cmd==-1){hide_detail();return;} @@ -361,12 +392,112 @@ inline lv_obj_t *detail_button(const char *text,int x,int y,int width,int height if(command>=0 && detail_action_count<16)detail_actions[detail_action_count++]=button; return button; } + +// ---- Weather card: now, the next hours and the coming days, with rain ---- +inline uint32_t weather_accent(const std::string &c) { + if(c=="sunny")return 0xFFB300; + if(c=="clear-night")return 0x6E41AB; + if(c=="rainy"||c=="pouring"||c=="lightning-rainy"||c=="snowy-rainy")return 0x1E88E5; + if(c=="snowy"||c=="hail")return 0x4FC3F7; + if(c=="lightning")return 0xFFA000; + if(c=="partlycloudy")return 0x7E9BB5; + return 0x78909C; +} +inline lv_obj_t *detail_text(lv_obj_t *parent,const std::string &text,int x,int y,int width,const lv_font_t *font,lv_text_align_t align,uint32_t color){ + auto *l=detail_label(parent,text,x,y,std::max(1,width));lv_obj_set_style_text_font(l,font,0);lv_obj_set_height(l,lv_font_get_line_height(font)); + lv_obj_set_style_text_align(l,align,0);lv_obj_set_style_text_color(l,lv_color_hex(color),0);return l; +} +// "30%", "30% · 1.7 mm" or "1.7 mm": whatever the provider reports; empty when dry. +inline std::string rain_text(float chance,float mm,bool with_mm){ + char b[32]; + if(std::isfinite(chance) && chance>=0){ + if(with_mm && std::isfinite(mm) && mm>=0.05f){snprintf(b,sizeof(b),"%d%% · %.1f mm",(int)std::lround(chance),mm);return b;} + snprintf(b,sizeof(b),"%d%%",(int)std::lround(chance));return b; + } + if(std::isfinite(mm) && mm>=0.05f){snprintf(b,sizeof(b),mm<10?"%.1f mm":"%.0f mm",mm);return b;} + return ""; +} +inline std::string degrees(float value){ if(!std::isfinite(value))return "--"; char b[16];snprintf(b,sizeof(b),"%.0f°",value);return b; } +inline lv_obj_t *detail_card(int x,int y,int w,int h){ + auto *card=lv_obj_create(detail_root);lv_obj_remove_style_all(card);lv_obj_remove_flag(card,LV_OBJ_FLAG_SCROLLABLE);lv_obj_remove_flag(card,LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_pos(card,x,y);lv_obj_set_size(card,w,h); + lv_obj_set_style_bg_color(card,lv_color_hex(0xFFFFFF),0);lv_obj_set_style_bg_opa(card,LV_OPA_COVER,0); + lv_obj_set_style_radius(card,lv_obj_get_style_radius(widgets[0].tile,LV_PART_MAIN),0); + lv_obj_set_style_border_width(card,1,0);lv_obj_set_style_border_color(card,lv_color_hex(0xDDDDDD),0); + return card; +} +// Two cards: "now" with the next hours, and the coming days. Bold highs, muted lows, +// rain in blue with a drop, so the eye finds temperature first and rain second. +inline void render_weather_detail(const Tile &t,bool large,int width,int height,int pad){ + if(detail_status){lv_obj_add_flag(detail_status,LV_OBJ_FLAG_HIDDEN);detail_status=nullptr;} + const lv_font_t *big=watch_value_font?watch_value_font:detail_font; + const lv_font_t *icon_font=widgets[0].icon_font?widgets[0].icon_font:detail_font; + const lv_font_t *mini=mini_icon_font?mini_icon_font:icon_font; + const lv_font_t *tiny=watch_icon_font?watch_icon_font:mini; + const lv_font_t *small=widgets[0].value?lv_obj_get_style_text_font(widgets[0].value,LV_PART_MAIN):detail_font; + uint32_t ink=0x202020,muted=0x6B6B6B,rain=0x1E88E5; + int text_h=lv_font_get_line_height(detail_font),small_h=lv_font_get_line_height(small),mini_h=lv_font_get_line_height(mini),tiny_h=lv_font_get_line_height(tiny); + int icon_h=lv_font_get_line_height(icon_font),big_h=lv_font_get_line_height(big),hero=std::max(icon_h,big_h); + int card_pad=large?14:7,inner=width-2*pad-2*card_pad; + unsigned columns=std::min(t.hour_count,6); + int hours_h=columns?small_h+mini_h+text_h+small_h+(large?16:6):0; + int card_a_h=card_pad+hero+(columns?(large?14:8)+hours_h:0)+card_pad; + int y=large?62:38; + auto *now=detail_card(pad,y,width-2*pad,card_a_h); + // Now: icon, temperature, condition, then feels-like / humidity / wind in one muted line. + int cy=card_pad;char b[48]; + detail_text(now,t.available()?weather_icon(t.state):"\U000F0595",card_pad,cy+(hero-icon_h)/2,icon_h+8,icon_font,LV_TEXT_ALIGN_LEFT,weather_accent(t.state)); + int temp_x=card_pad+icon_h+(large?14:6),temp_w=large?92:50; + detail_text(now,degrees(t.current),temp_x,cy+(hero-big_h)/2,temp_w,big,LV_TEXT_ALIGN_LEFT,ink); + int text_x=temp_x+temp_w+(large?4:2),text_w=width-2*pad-card_pad-text_x; + int lines_h=text_h+small_h+(large?2:0); + detail_text(now,t.available()?weather_text(t.state):"Niet beschikbaar",text_x,cy+(hero-lines_h)/2,text_w,detail_font,LV_TEXT_ALIGN_LEFT,ink); + std::string details; + if(std::isfinite(t.feels)){snprintf(b,sizeof(b),"Voelt als %.0f°",t.feels);details=b;} + if(std::isfinite(t.humidity)){snprintf(b,sizeof(b),"%d%%",(int)std::lround(t.humidity));details+=(details.empty()?"":" · ")+std::string(b);} + if(std::isfinite(t.wind)){snprintf(b,sizeof(b),"%.0f %s",t.wind,t.wind_unit.empty()?"km/h":t.wind_unit.c_str());details+=(details.empty()?"":" · ")+std::string(b);} + detail_text(now,details,text_x,cy+(hero-lines_h)/2+text_h+(large?2:0),text_w,small,LV_TEXT_ALIGN_LEFT,muted); + // Next hours inside the same card: time, icon, temperature, rain per column. + if(columns){ + int hy=cy+hero+(large?14:8),col=inner/(int)columns; + for(unsigned i=0;i=model.count)return;detail_index=index;auto &t=model.tiles[index]; if(!detail_font)detail_font=lv_obj_get_style_text_font(widgets[0].title,LV_PART_MAIN); if(!detail_root){detail_root=lv_obj_create(lv_screen_active());lv_obj_remove_style_all(detail_root);lv_obj_set_size(detail_root,lv_pct(100),lv_pct(100));lv_obj_remove_flag(detail_root,LV_OBJ_FLAG_SCROLLABLE);} detail_action_count=0;detail_status=nullptr;detail_badge_status=nullptr;detail_switch=nullptr;lv_obj_clean(detail_root);lv_obj_remove_flag(detail_root,LV_OBJ_FLAG_HIDDEN);lv_obj_move_foreground(detail_root); - lv_obj_set_style_bg_color(detail_root,lv_color_hex(light_theme?0xE7E7E7:0x202A38),0);lv_obj_set_style_bg_opa(detail_root,LV_OPA_COVER,0); + lv_obj_set_style_bg_color(detail_root,lv_color_hex(0xE7E7E7),0);lv_obj_set_style_bg_opa(detail_root,LV_OPA_COVER,0); int width=lv_display_get_horizontal_resolution(lv_display_get_default()), height=lv_display_get_vertical_resolution(lv_display_get_default()); bool large=width>=480;int pad=large?20:10, top=large?100:62, gap=large?12:6,bh=large?58:34,cw=(width-pad*2-gap)/2; auto *heading=detail_label(detail_root,t.name,pad,large?24:12,width-80);if(watch_font){lv_obj_set_style_text_font(heading,watch_font,0);lv_obj_set_height(heading,lv_font_get_line_height(watch_font));} @@ -402,7 +533,7 @@ inline void show_detail(unsigned index){ lv_obj_set_style_radius(o,radius,0);lv_obj_set_style_bg_color(o,lv_color_hex(color),0);lv_obj_set_style_bg_opa(o,LV_OPA_COVER,0); lv_obj_remove_flag(o,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(o,LV_OBJ_FLAG_SCROLLABLE);return o; }; - uint32_t surface=light_theme?0xFFFFFF:0x2B3B4D, muted=light_theme?0xEAF5FC:0x344D63; + uint32_t surface=0xFFFFFF, muted=0xEAF5FC; if(large){ auto *hero=shape(detail_root,pad,100,width-2*pad,148,surface,24); shape(hero,18,14,120,120,muted,60); @@ -414,7 +545,7 @@ inline void show_detail(unsigned index){ auto *badge=shape(hero,154,59,240,32,muted,16); auto *status=detail_label(badge,t.awaiting_action(esphome::millis())?"Opdracht verstuurd...":state,12,5,218); detail_badge_status=status; - lv_obj_set_style_text_color(status,lv_color_hex(light_theme?0x087BA8:0xFFFFFF),0); + lv_obj_set_style_text_color(status,lv_color_hex(0x087BA8),0); detail_label(hero,std::isfinite(t.battery)?"Batterij "+std::to_string((int)t.battery)+"%":"Verbonden via Home Assistant",154,107,260); detail_button(t.state=="cleaning"?"Pauzeer schoonmaken":"Start schoonmaken",pad,260,width-2*pad,58,t.state=="cleaning"?1:0); detail_button("Terug naar dock",pad,330,cw,48,2); @@ -461,13 +592,7 @@ 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 %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 now && now ? t.timer_end - now : 0); } if (t.state == "paused") return "Pauze " + countdown(duration_seconds(t.remaining)); @@ -582,6 +719,7 @@ 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=="climate" && tile.tap=="toggle" && code==LV_EVENT_SHORT_CLICKED){action("climate.toggle",tile.entity);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;} @@ -613,8 +751,8 @@ inline void bind(size_t index, lv_obj_t *tile, lv_obj_t *title, lv_obj_t *value, lv_obj_set_height(title,lv_font_get_line_height(lv_obj_get_style_text_font(title,LV_PART_MAIN))); lv_obj_set_height(value,lv_font_get_line_height(w.value_font)); lv_label_set_long_mode(title,LV_LABEL_LONG_DOT);lv_label_set_long_mode(value,LV_LABEL_LONG_DOT); - w.progress=lv_obj_create(tile);lv_obj_remove_style_all(w.progress);lv_obj_set_size(w.progress,0,3);lv_obj_align(w.progress,LV_ALIGN_BOTTOM_LEFT,0,0);lv_obj_set_style_bg_color(w.progress,lv_color_hex(0x00A6ED),0);lv_obj_set_style_bg_opa(w.progress,LV_OPA_COVER,0); - w.slider=lv_slider_create(tile);lv_obj_set_size(w.slider,lv_obj_get_width(tile)-24,lv_obj_get_height(tile)>80?28:10);lv_obj_align(w.slider,LV_ALIGN_BOTTOM_MID,0,-1);lv_slider_set_range(w.slider,0,1000); + w.progress=lv_obj_create(tile);lv_obj_remove_style_all(w.progress);lv_obj_set_size(w.progress,0,3);lv_obj_align(w.progress,LV_ALIGN_BOTTOM_LEFT,0,0);lv_obj_add_flag(w.progress,LV_OBJ_FLAG_HIDDEN); + w.slider=lv_slider_create(tile);lv_obj_set_size(w.slider,lv_obj_get_width(tile)-24,lv_obj_get_height(tile)>80?28:10);lv_obj_align(w.slider,LV_ALIGN_BOTTOM_MID,0,0);lv_slider_set_range(w.slider,0,1000); lv_obj_set_style_bg_color(w.slider,lv_color_hex(0x111111),LV_PART_KNOB);lv_obj_set_style_pad_hor(w.slider,lv_obj_get_height(tile)>80?-10:0,LV_PART_KNOB);lv_obj_set_style_pad_ver(w.slider,lv_obj_get_height(tile)>80?-5:1,LV_PART_KNOB);lv_obj_set_style_radius(w.slider,14,LV_PART_MAIN);lv_obj_set_style_radius(w.slider,14,LV_PART_INDICATOR);lv_obj_set_style_radius(w.slider,3,LV_PART_KNOB);lv_obj_set_style_bg_color(w.slider,lv_color_hex(0xFCE5B4),LV_PART_MAIN);lv_obj_set_style_bg_color(w.slider,lv_color_hex(0xFFB900),LV_PART_INDICATOR); lv_obj_set_style_opa(w.slider,LV_OPA_TRANSP,LV_PART_KNOB); lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN); @@ -626,6 +764,15 @@ inline void bind(size_t index, lv_obj_t *tile, lv_obj_t *title, lv_obj_t *value, inline void label(lv_obj_t *obj, const std::string &text) { if (text != lv_label_get_text(obj)) lv_label_set_text(obj, text.c_str()); } +// lv_obj_set_style_* always invalidates the object; these only do so on a real change, +// which keeps a one-second clock tick or a busy spinner from redrawing whole cards. +inline void set_font(lv_obj_t *obj, const lv_font_t *value) { if (lv_obj_get_style_text_font(obj, LV_PART_MAIN) != value) lv_obj_set_style_text_font(obj, value, 0); } +inline void set_text_align(lv_obj_t *obj, lv_text_align_t value) { if (lv_obj_get_style_text_align(obj, LV_PART_MAIN) != value) lv_obj_set_style_text_align(obj, value, 0); } +inline void pad_vertical(lv_obj_t *obj, int value) { + if (lv_obj_get_style_pad_top(obj, LV_PART_MAIN) != value) lv_obj_set_style_pad_top(obj, value, 0); + if (lv_obj_get_style_pad_bottom(obj, LV_PART_MAIN) != value) lv_obj_set_style_pad_bottom(obj, value, 0); +} +inline void set_line_width(lv_obj_t *obj, int value) { if (lv_obj_get_style_line_width(obj, LV_PART_MAIN) != value) lv_obj_set_style_line_width(obj, value, 0); } // HA's default domain/state palette. Pastel circles also identify inactive domains. // Custom Lovelace card/theme CSS is not an entity attribute and is not imported. inline uint32_t domain_accent(const Tile &t) { @@ -707,7 +854,7 @@ inline unsigned smooth(const lv_point_precise_t *in,unsigned n,lv_point_precise_ 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); + set_font(p,font);set_text_align(p,align); 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) { @@ -719,7 +866,7 @@ inline lv_obj_t *part_dot(Widgets &w,unsigned i,int x,int y,int size) { 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; + set_line_width(p,width);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") : "--:--"; @@ -869,8 +1016,7 @@ inline void render_sunpath(Widgets &w,const Tile &t,bool large,int width,int hei lv_point_precise_t sun={(lv_value_precise_t)(x0+(x1-x0)*fraction),(lv_value_precise_t)(day?horizon-sinf(sa)*amplitude:horizon+sinf(sa)*amplitude)}; travelled[filled+1]=sun; line[0]={(lv_value_precise_t)0,(lv_value_precise_t)horizon};line[1]={(lv_value_precise_t)(width-1),(lv_value_precise_t)horizon}; - bool dark_text=light_theme || t.background!=0; - uint32_t path=dark_text?0xCFD8DC:0x6B8299, accent=day?0xFF9800:0x5C6BC0, disc=day?0xFFB300:0xB0BEC5; + uint32_t path=0xCFD8DC, accent=day?0xFF9800:0x5C6BC0, disc=day?0xFFB300:0xB0BEC5; lv_obj_set_style_line_color(part_line(w,3,line,2,2),lv_color_hex(path),0); lv_obj_set_style_line_color(part_line(w,4,arc,segments+1,large?2:1),lv_color_hex(path),0); lv_obj_set_style_line_color(part_line(w,5,travelled,filled+2,large?4:3),lv_color_hex(accent),0); @@ -879,6 +1025,223 @@ inline void render_sunpath(Widgets &w,const Tile &t,bool large,int width,int hei lv_obj_set_style_bg_color(part_dot(w,7,int(sun.x)-size/2,int(sun.y)-size/2,size),lv_color_hex(disc),0); if(day){w.fill_points=travelled;w.fill_count=filled+2;w.fill_x=0;w.fill_y=0;w.fill_base=horizon;w.fill_color=lv_color_hex(0xFFB300);w.fill_opa=LV_OPA_20;} } + +// ---- Direct controls on wide cards (Home Assistant entity-row style) ---- +struct PanelMetrics { int key_w, key_h, radius, gap, pill_w, pill_key, slider_w, slider_h, toggle_w, toggle_h, run_pad, text_gap, ext; }; +inline PanelMetrics panel_metrics(bool large) { + return large ? PanelMetrics{60,46,14,8,196,52,140,44,76,40,22,8,4} : PanelMetrics{40,34,9,4,128,36,90,30,48,26,14,6,6}; +} +inline lv_obj_t *panel_obj(lv_obj_t *parent,bool clickable) { + auto *o=lv_obj_create(parent);lv_obj_remove_style_all(o);lv_obj_remove_flag(o,LV_OBJ_FLAG_SCROLLABLE); + if(clickable)lv_obj_add_flag(o,LV_OBJ_FLAG_CLICKABLE);else lv_obj_remove_flag(o,LV_OBJ_FLAG_CLICKABLE); + return o; +} +inline void end_panel(Widgets &w) { + if(!w.panel)return; + lv_obj_add_flag(w.panel,LV_OBJ_FLAG_HIDDEN);w.panel_w=0; + if(!w.panel_mode.empty()){ + if(captured_slider && captured_slider==w.control_slider)captured_slider=nullptr; + lv_obj_clean(w.panel);w.keys.fill(nullptr);w.key_icons.fill(nullptr);w.key_checked.fill(-1); + w.pill=w.pill_value=w.knob=w.control_slider=nullptr;w.knob_on=-1;w.panel_mode.clear(); + } +} +inline void control_event(lv_event_t *e); +// A pill key: rounded, pressed darker, checked in the accent, disabled faded. +inline lv_obj_t *panel_key(Widgets &w,unsigned n,lv_obj_t *parent,const PanelMetrics &m,int width,int height,bool transparent) { + auto *key=panel_obj(parent,true);lv_obj_set_size(key,width,height); + lv_obj_set_style_radius(key,transparent?LV_RADIUS_CIRCLE:m.radius,0); + lv_obj_set_style_bg_opa(key,transparent?LV_OPA_TRANSP:LV_OPA_COVER,0); + lv_obj_set_style_bg_opa(key,LV_OPA_COVER,LV_STATE_PRESSED); + lv_obj_set_style_bg_opa(key,LV_OPA_COVER,LV_STATE_CHECKED); + lv_obj_set_style_opa(key,LV_OPA_40,LV_STATE_DISABLED); + lv_obj_set_ext_click_area(key,m.ext); + size_t slot=&w-widgets.data(); + lv_obj_add_event_cb(key,control_event,LV_EVENT_SHORT_CLICKED,(void*)(uintptr_t)(slot*16+n)); + w.keys[n]=key;w.key_checked[n]=-1; + return key; +} +inline lv_obj_t *panel_icon(Widgets &w,unsigned n,const lv_font_t *font) { + auto *icon=lv_label_create(w.keys[n]);lv_obj_remove_flag(icon,LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_text_font(icon,font,0);lv_obj_center(icon);w.key_icons[n]=icon;return icon; +} +// Build (once per control set) and lay out the panel; returns the width it takes +// from the text, including the gap, or 0 when the card shows no panel. +inline int layout_panel(Widgets &w,const Tile &t,bool large,int content_w,int content_h) { + std::string mode=tile_controls::panel_kind(t); + const PanelMetrics m=panel_metrics(large); + const lv_font_t *icon_font=mini_icon_font?mini_icon_font:w.icon_font; + const lv_font_t *text_font=control_font?control_font:lv_obj_get_style_text_font(w.title,LV_PART_MAIN); + auto d=t.domain(); + if(!w.panel){w.panel=panel_obj(w.tile,false);} + if(w.panel_mode!=mode){ + end_panel(w);w.panel_mode=mode;w.panel_dirty=true; + if(tile_controls::is_key_row(mode)){ + for(unsigned n=0;n<3;++n){panel_key(w,n,w.panel,m,m.key_w,m.key_h,false);panel_icon(w,n,icon_font);} + }else if(mode=="setpoint"||mode=="stepper"){ + w.pill=panel_obj(w.panel,false);lv_obj_set_size(w.pill,m.pill_w,m.key_h+2); + lv_obj_set_style_radius(w.pill,LV_RADIUS_CIRCLE,0);lv_obj_set_style_bg_opa(w.pill,LV_OPA_COVER,0); + panel_key(w,0,w.pill,m,m.pill_key,m.key_h+2,true);panel_icon(w,0,icon_font);lv_label_set_text(w.key_icons[0],tile_controls::glyph::MINUS); + panel_key(w,1,w.pill,m,m.pill_key,m.key_h+2,true);panel_icon(w,1,icon_font);lv_label_set_text(w.key_icons[1],tile_controls::glyph::PLUS); + lv_obj_set_pos(w.keys[0],0,0);lv_obj_set_pos(w.keys[1],m.pill_w-m.pill_key,0); + w.pill_value=lv_label_create(w.pill);lv_obj_remove_flag(w.pill_value,LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_text_font(w.pill_value,text_font,0);lv_obj_set_style_text_align(w.pill_value,LV_TEXT_ALIGN_CENTER,0); + lv_label_set_long_mode(w.pill_value,LV_LABEL_LONG_CLIP); + lv_obj_set_size(w.pill_value,m.pill_w-2*m.pill_key,lv_font_get_line_height(text_font)); + lv_obj_set_pos(w.pill_value,m.pill_key,(m.key_h+2-lv_font_get_line_height(text_font))/2); + w.key_commands[0]=tile_controls::STEP_DOWN;w.key_commands[1]=tile_controls::STEP_UP; + }else if(tile_controls::is_slider(mode)){ + int h=mode=="volume"?m.slider_h:m.slider_h; + auto *slider=lv_slider_create(w.panel);w.control_slider=slider; + lv_obj_remove_flag(slider,LV_OBJ_FLAG_GESTURE_BUBBLE);lv_obj_remove_flag(slider,LV_OBJ_FLAG_SCROLLABLE); + lv_slider_set_range(slider,0,1000);lv_obj_set_size(slider,mode=="volume"?m.slider_w:m.pill_w,h); + lv_obj_set_style_pad_all(slider,0,LV_PART_MAIN); + lv_obj_set_style_radius(slider,LV_RADIUS_CIRCLE,LV_PART_MAIN);lv_obj_set_style_radius(slider,LV_RADIUS_CIRCLE,LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(slider,LV_OPA_COVER,LV_PART_MAIN);lv_obj_set_style_bg_opa(slider,LV_OPA_COVER,LV_PART_INDICATOR); + // The handle is a short white bar inside the fill, like Home Assistant's slider. + lv_obj_set_style_radius(slider,2,LV_PART_KNOB);lv_obj_set_style_bg_color(slider,lv_color_hex(0xFFFFFF),LV_PART_KNOB);lv_obj_set_style_bg_opa(slider,LV_OPA_COVER,LV_PART_KNOB); + lv_obj_set_style_pad_hor(slider,-(h/2-(large?3:2)),LV_PART_KNOB);lv_obj_set_style_pad_ver(slider,-(h/4),LV_PART_KNOB); + lv_obj_set_style_border_width(slider,0,LV_PART_KNOB);lv_obj_set_style_shadow_width(slider,0,LV_PART_KNOB); + lv_obj_set_ext_click_area(slider,m.ext+2); + lv_obj_add_event_cb(slider,slider_event,LV_EVENT_ALL,(void*)(uintptr_t)w.index); + if(mode=="volume"){panel_key(w,0,w.panel,m,m.key_h,m.key_h,false);panel_icon(w,0,icon_font);w.key_commands[0]=tile_controls::MEDIA_MUTE;} + }else if(mode=="toggle"){ + panel_key(w,0,w.panel,m,m.toggle_w,m.toggle_h,false);lv_obj_set_style_radius(w.keys[0],LV_RADIUS_CIRCLE,0); + w.knob=panel_obj(w.keys[0],false);lv_obj_set_size(w.knob,m.toggle_h-8,m.toggle_h-8);lv_obj_set_y(w.knob,4); + lv_obj_set_style_radius(w.knob,LV_RADIUS_CIRCLE,0);lv_obj_set_style_bg_opa(w.knob,LV_OPA_COVER,0);lv_obj_set_style_bg_color(w.knob,lv_color_hex(0xFFFFFF),0); + w.key_commands[0]=tile_controls::TOGGLE; + }else if(mode=="run"){ + const char *text=tile_controls::run_label(d); + lv_point_t size;lv_text_get_size(&size,text,text_font,0,0,LV_COORD_MAX,LV_TEXT_FLAG_EXPAND); + panel_key(w,0,w.panel,m,std::max(m.key_w*3/2,(int)size.x+2*m.run_pad),m.key_h,false);lv_obj_set_style_radius(w.keys[0],LV_RADIUS_CIRCLE,0); + panel_icon(w,0,text_font);lv_label_set_text(w.key_icons[0],text); + w.key_commands[0]=tile_controls::RUN; + }else{end_panel(w);return 0;} + } + // Per-render contents: which keys, their icons and states; then the panel size and place. + int panel_w=0,panel_h=m.key_h;uint32_t now=esphome::millis(); + auto set_checked=[&](unsigned n,bool checked){ + if(w.key_checked[n]==(int)checked)return;w.key_checked[n]=checked; + if(checked)lv_obj_add_state(w.keys[n],LV_STATE_CHECKED);else lv_obj_remove_state(w.keys[n],LV_STATE_CHECKED); + if(w.key_icons[n])lv_obj_set_style_text_color(w.key_icons[n],checked?lv_color_hex(0xFFFFFF):w.panel_text,0); + }; + auto set_disabled=[&](unsigned n,bool disabled){if(disabled)lv_obj_add_state(w.keys[n],LV_STATE_DISABLED);else lv_obj_remove_state(w.keys[n],LV_STATE_DISABLED);}; + if(tile_controls::is_key_row(mode)){ + std::array keys;unsigned count=tile_controls::keys_for(t,keys); + for(unsigned n=0;n<3;++n){ + if(n>=count){lv_obj_add_flag(w.keys[n],LV_OBJ_FLAG_HIDDEN);w.key_commands[n]=tile_controls::NONE;continue;} + lv_obj_remove_flag(w.keys[n],LV_OBJ_FLAG_HIDDEN);lv_obj_set_pos(w.keys[n],n*(m.key_w+m.gap),0); + label(w.key_icons[n],keys[n].icon);w.key_commands[n]=keys[n].command;w.key_args[n]=keys[n].arg; + // The active mode key carries the accent; "off" stays neutral grey. + if(keys[n].checked && w.key_checked[n]!=1)lv_obj_set_style_bg_color(w.keys[n],keys[n].arg=="off"?lv_color_hex(0x9E9E9E):w.panel_accent,LV_STATE_CHECKED); + set_checked(n,keys[n].checked);set_disabled(n,keys[n].disabled); + } + panel_w=count?count*m.key_w+(count-1)*m.gap:0; + }else if(mode=="setpoint"||mode=="stepper"){ + float shown=std::isfinite(t.edit_value)?t.edit_value:tile_controls::edit_target(t); + std::string suffix=d=="climate"?"°":t.unit.empty()?"":" "+t.unit; + label(w.pill_value,tile_controls::format_value(shown,tile_controls::edit_step(t),suffix.c_str())); + panel_w=m.pill_w;panel_h=m.key_h+2; + }else if(tile_controls::is_slider(mode)){ + bool has_slider=mode!="volume" || (t.supported & tile_controls::feature::MEDIA_VOLUME_SET); + if(has_slider){ + lv_obj_remove_flag(w.control_slider,LV_OBJ_FLAG_HIDDEN);lv_obj_set_pos(w.control_slider,0,(panel_h-m.slider_h)/2); + // Keep the dragged value while the command is under way; HA's report takes over afterwards. + if(!lv_obj_has_state(w.control_slider,LV_STATE_PRESSED) && !(t.pending && !t.confirmed))lv_slider_set_value(w.control_slider,slider_value(t),LV_ANIM_OFF); + panel_w=mode=="volume"?m.slider_w:m.pill_w; + }else lv_obj_add_flag(w.control_slider,LV_OBJ_FLAG_HIDDEN); + if(mode=="volume"){ + bool has_mute=t.supported & tile_controls::feature::MEDIA_VOLUME_MUTE; + if(has_mute){ + lv_obj_remove_flag(w.keys[0],LV_OBJ_FLAG_HIDDEN);lv_obj_set_pos(w.keys[0],panel_w?panel_w+m.gap:0,0); + label(w.key_icons[0],t.muted?tile_controls::glyph::MUTED:tile_controls::glyph::VOLUME); + if(t.muted && w.key_checked[0]!=1)lv_obj_set_style_bg_color(w.keys[0],w.panel_accent,LV_STATE_CHECKED); + set_checked(0,t.muted);panel_w+=(panel_w?m.gap:0)+m.key_h; + }else lv_obj_add_flag(w.keys[0],LV_OBJ_FLAG_HIDDEN); + } + }else if(mode=="toggle"){ + bool on=t.pending && !t.confirmed ? t.optimistic_on : t.state=="on"; + if(on && w.key_checked[0]!=1)lv_obj_set_style_bg_color(w.keys[0],w.panel_accent,LV_STATE_CHECKED); + set_checked(0,on); + if(w.knob_on!=(int)on){w.knob_on=on;lv_obj_set_x(w.knob,on?m.toggle_w-(m.toggle_h-8)-4:4);} + panel_w=m.toggle_w;panel_h=m.toggle_h; + }else if(mode=="run"){ + panel_w=lv_obj_get_width(w.keys[0]); + } + (void)now; + if(!panel_w){lv_obj_add_flag(w.panel,LV_OBJ_FLAG_HIDDEN);w.panel_w=0;return 0;} + lv_obj_remove_flag(w.panel,LV_OBJ_FLAG_HIDDEN); + lv_obj_set_size(w.panel,panel_w,panel_h);lv_obj_set_pos(w.panel,content_w-panel_w,std::max(0,(content_h-panel_h)/2)); + w.panel_w=panel_w+m.text_gap; + return w.panel_w; +} +// Colours follow the card palette; called with the rest of the palette when it changes. +inline void style_panel(Widgets &w,const Tile &t,lv_color_t accent,lv_color_t text) { + if(!w.panel || w.panel_mode.empty())return; + lv_color_t card=lv_color_hex(t.background?t.background:0xFFFFFF); + lv_color_t key_bg=lv_color_mix(card,lv_color_hex(0x000000),236); + lv_color_t key_pressed=lv_color_mix(card,lv_color_hex(0x000000),212); + w.panel_accent=accent;w.panel_text=text; + for(unsigned n=0;n<3;++n){ + auto *key=w.keys[n];if(!key)continue; + bool in_pill=w.pill && lv_obj_get_parent(key)==w.pill; + lv_obj_set_style_bg_color(key,in_pill?key_pressed:key_bg,0); + lv_obj_set_style_bg_color(key,in_pill?lv_color_mix(card,lv_color_hex(0x000000),190):key_pressed,LV_STATE_PRESSED); + lv_obj_set_style_bg_color(key,w.key_args[n]=="off" && w.panel_mode=="mode"?lv_color_hex(0x9E9E9E):accent,LV_STATE_CHECKED); + if(w.key_icons[n])lv_obj_set_style_text_color(w.key_icons[n],w.key_checked[n]==1?lv_color_hex(0xFFFFFF):text,0); + } + if(w.pill){lv_obj_set_style_bg_color(w.pill,key_bg,0);lv_obj_set_style_text_color(w.pill_value,text,0);} + if(w.control_slider){ + lv_obj_set_style_bg_color(w.control_slider,lv_color_mix(accent,card,76),LV_PART_MAIN); + lv_obj_set_style_bg_color(w.control_slider,accent,LV_PART_INDICATOR); + } + if(w.knob){lv_obj_set_style_bg_color(w.keys[0],lv_color_hex(0xD7DADF),0);lv_obj_set_style_bg_color(w.keys[0],lv_color_hex(0xC5C9CF),LV_STATE_PRESSED);} +} +inline void control_event(lv_event_t *e) { + unsigned code=(uintptr_t)lv_event_get_user_data(e);unsigned slot=code/16,n=code%16; + if(slot>=widgets.size() || n>=3)return; + auto &w=widgets[slot]; + if(!enabled || !fresh() || w.index>=model.count || !w.keys[n])return; + if(lv_obj_has_state(w.keys[n],LV_STATE_DISABLED))return; + uint32_t now=esphome::millis(); + if(!cyd::touch_guard.accept(now,400+slot*16+n))return; + auto &t=model.tiles[w.index]; + if(!t.available())return; + int command=w.key_commands[n]; + if(command==tile_controls::STEP_DOWN || command==tile_controls::STEP_UP){ + // Local at once; tick() sends the last value after a short pause. + float current=std::isfinite(t.edit_value)?t.edit_value:tile_controls::edit_target(t); + t.edit_value=tile_controls::step_value(current,tile_controls::edit_step(t),t.minimum,t.maximum,command==tile_controls::STEP_UP?1:-1); + t.edit_since=now;t.edit_sent=false; + if(w.pill_value){std::string suffix=t.domain()=="climate"?"°":t.unit.empty()?"":" "+t.unit;label(w.pill_value,tile_controls::format_value(t.edit_value,tile_controls::edit_step(t),suffix.c_str()));} + return; + } + if(t.awaiting_action(now))return; + if(command==tile_controls::TOGGLE)t.optimistic_on=t.state!="on"; + auto a=tile_controls::key_action(t,command,w.key_args[n]); + if(a.valid())action(a.service,t.entity,a.key,a.value); +} +// A busy card is covered by a translucent white sheet with a small spinner until +// Home Assistant confirms; the sheet also swallows taps meanwhile. +inline void set_busy(Widgets &w,bool busy,bool large){ + if(!busy){if(w.busy)lv_obj_add_flag(w.busy,LV_OBJ_FLAG_HIDDEN);return;} + if(!w.busy){ + w.busy=lv_obj_create(w.tile);lv_obj_remove_style_all(w.busy);lv_obj_remove_flag(w.busy,LV_OBJ_FLAG_SCROLLABLE);lv_obj_add_flag(w.busy,LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_bg_color(w.busy,lv_color_hex(0xFFFFFF),0);lv_obj_set_style_bg_opa(w.busy,LV_OPA_60,0); + lv_obj_set_style_radius(w.busy,lv_obj_get_style_radius(w.tile,LV_PART_MAIN),0); +#if LV_USE_SPINNER + w.spinner=lv_spinner_create(w.busy);lv_spinner_set_anim_params(w.spinner,900,200); + int size=large?30:20;lv_obj_set_size(w.spinner,size,size);lv_obj_center(w.spinner);lv_obj_remove_flag(w.spinner,LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_arc_width(w.spinner,large?4:3,LV_PART_MAIN);lv_obj_set_style_arc_width(w.spinner,large?4:3,LV_PART_INDICATOR); + lv_obj_set_style_arc_color(w.spinner,lv_color_hex(0xD9DDE2),LV_PART_MAIN);lv_obj_set_style_arc_color(w.spinner,lv_color_hex(0x1E88E5),LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(w.spinner,LV_OPA_TRANSP,LV_PART_KNOB);lv_obj_set_style_pad_all(w.spinner,0,LV_PART_KNOB); +#endif + } + if(!lv_obj_has_flag(w.busy,LV_OBJ_FLAG_HIDDEN))return; + // Cover the whole card, padding included. + lv_obj_set_pos(w.busy,-lv_obj_get_style_pad_left(w.tile,LV_PART_MAIN),-lv_obj_get_style_pad_top(w.tile,LV_PART_MAIN)); + lv_obj_set_size(w.busy,lv_obj_get_width(w.tile),lv_obj_get_height(w.tile)); + lv_obj_remove_flag(w.busy,LV_OBJ_FLAG_HIDDEN);lv_obj_move_foreground(w.busy); +} inline void render(lv_obj_t *room) { if (!enabled) return; room_label=room; @@ -899,6 +1262,7 @@ inline void render(lv_obj_t *room) { 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 (d == "script" || d == "scene" || d == "button" || d == "input_button") value = t.state == "on" ? "Bezig..." : last_run_text(t.last_run); else if (value == "on") value = "Aan"; else if (value == "off") value = "Uit"; else if (value == "cleaning") value = "Bezig"; @@ -907,24 +1271,26 @@ inline void render(lv_obj_t *room) { bool pending=t.loading(esphome::millis()); 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); + // Direct controls: only a wide card in the standard layout has room for the panel. + bool with_panel=w.wide && !t.controls.empty() && !t.builtin() && !watch && t.inline_control!="slider" && fresh() && t.available(); + if(with_panel){std::string status=tile_controls::status_text(t);if(!status.empty())value=status;} + label(w.value, value); bool mini=t.inline_control=="slider" && !watch && t.available(); bool large_tile=lv_obj_get_height(w.tile)>80; + set_busy(w,pending && !t.builtin(),large_tile); // 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 sunpath=d=="sun" && t.display=="sunpath" && w.wide && !t.sunrise.empty() && !t.sunset.empty() && fresh() && t.available(); bool graph=d=="sensor" && t.display=="graph" && t.has_history && !clock; bool custom=clock||forecast||sunpath; - 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); + if(!large_tile)pad_vertical(w.tile,watch||custom||graph?2:4); + set_font(w.value,watch && watch_value_font ? watch_value_font : w.value_font); 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); + lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN);end_panel(w); if(clock)render_clock(w,t,large_tile,content_width,content_height); else if(sunpath)render_sunpath(w,t,large_tile,content_width,content_height); else render_forecast(w,t,large_tile,content_width,content_height); @@ -935,12 +1301,14 @@ inline void render(lv_obj_t *room) { // 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 panel_w=with_panel && !graph?layout_panel(w,t,large_tile,content_width,content_height):0; + if(!panel_w)end_panel(w); 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||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||graph_strip) && mini_icon_font ? mini_icon_font : w.icon_font,0); - lv_obj_center(w.icon); + const lv_font_t *icon_font=watch && watch_icon_font ? watch_icon_font : (mini||graph_strip) && mini_icon_font ? mini_icon_font : w.icon_font; + if(lv_obj_get_style_text_font(w.icon,LV_PART_MAIN)!=icon_font){set_font(w.icon,icon_font);lv_obj_center(w.icon);} 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, @@ -948,7 +1316,7 @@ inline void render(lv_obj_t *room) { 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. - int text_room=content_width-chart_w-(graph_side?(large_tile?10:6):0); + int text_room=content_width-chart_w-(graph_side?(large_tile?10:6):0)-panel_w; 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){ @@ -968,7 +1336,6 @@ inline void render(lv_obj_t *room) { 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); lv_obj_set_size(w.slider,content_width,slider_height); - 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); @@ -978,32 +1345,32 @@ inline void render(lv_obj_t *room) { 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; + if (w.cached_active == palette_state && !w.panel_dirty) continue; + w.cached_active = palette_state;w.panel_dirty=false; uint32_t accent = domain_accent(t); 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); + auto circle_color=available?lv_color_mix(color,lv_color_hex(0xFFFFFF),38):lv_color_hex(0xF0F0F0); // Darken the foreground slightly: very pale bulbs still need a visible icon. - auto icon_color=available?lv_color_mix(color,lv_color_hex(dark_text?0x333333:0xFFFFFF),dark_text?205:185):lv_color_hex(0x9E9E9E); + auto icon_color=available?lv_color_mix(color,lv_color_hex(0x333333),205):lv_color_hex(0x9E9E9E); lv_obj_set_style_bg_color(w.slider,color,LV_PART_INDICATOR); - lv_obj_set_style_bg_color(w.slider,lv_color_mix(color,lv_color_hex(dark_text?0xFFFFFF:0x263B50),30),LV_PART_MAIN); - lv_obj_set_style_bg_color(w.tile, lv_color_hex(t.background ? t.background : (light_theme ? 0xFFFFFF : (on ? 0xF5F1E8 : 0x526C85))), 0); + lv_obj_set_style_bg_color(w.slider,lv_color_mix(color,lv_color_hex(0xFFFFFF),30),LV_PART_MAIN); + lv_obj_set_style_bg_color(w.tile, lv_color_hex(t.background ? t.background : 0xFFFFFF), 0); lv_obj_set_style_border_width(w.tile, 1, 0); // "Achtergrond: geen" hides only the card; geometry and padding stay identical, // and the pressed flash still shows because it lives on the PRESSED state. lv_obj_set_style_bg_opa(w.tile, t.transparent ? LV_OPA_TRANSP : LV_OPA_COVER, 0); lv_obj_set_style_border_opa(w.tile, t.transparent ? LV_OPA_TRANSP : LV_OPA_COVER, 0); - 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_border_color(w.tile, t.background ? lv_color_mix(lv_color_hex(t.background),lv_color_hex(0x000000),220) : lv_color_hex(0xDDDDDD), 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); + auto title_color=lv_color_hex(0x1B1B1B); + auto value_color=lv_color_hex(t.background ? 0x46525E : 0x616161); + lv_obj_set_style_text_color(w.unit,lv_color_hex(0x46525E),0); lv_obj_set_style_text_color(w.title, title_color, 0); lv_obj_set_style_text_color(w.value, value_color, 0); + style_panel(w,t,color,title_color); // Custom parts follow the card palette: text like the title, lines/dots in the accent. // The sun path sets its own colours on every render. w.fill_color=color; @@ -1064,6 +1431,18 @@ inline bool check_tile_geometry() { fits=fits && value.x2title.y2; } } + if(w.panel && !lv_obj_has_flag(w.panel,LV_OBJ_FLAG_HIDDEN)){ + // Direct controls stay inside the card, right of the name and status, and inside their panel. + lv_area_t panel;lv_obj_get_coords(w.panel,&panel); + bool inside=panel.x1>=content.x1 && panel.x2<=content.x2 && panel.y1>=content.y1 && panel.y2<=content.y2 && (custom || (title.x2=panel.x1 && part.x2<=panel.x2 && part.y1>=panel.y1 && part.y2<=panel.y2; + } + if(!inside)ESP_LOGE("ui_test","Panel bounds slot=%u mode=%s panel=%d,%d..%d,%d title_x2=%d content=%d,%d..%d,%d",(unsigned)w.index,w.panel_mode.c_str(),panel.x1,panel.y1,panel.x2,panel.y2,title.x2,content.x1,content.y1,content.x2,content.y2); + fits=fits && inside; + } 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); @@ -1117,7 +1496,7 @@ inline int place_page(int page) { for(size_t slot=0;slot1)lv_obj_remove_flag(control,LV_OBJ_FLAG_HIDDEN); @@ -1136,11 +1515,10 @@ inline void apply_page(int page) { } inline void skeleton_page(int page) { place_page(page); - uint32_t bg=light_theme?0xF4F5F7:0x4A6076, border=light_theme?0xE3E5E8:0x6F879C; + uint32_t bg=0xF4F5F7, border=0xE3E5E8; for(size_t slot=0;slot10000){t.edit_value=NAN;redraw=true;} + } // Clocks and running timers advance once per second without any HA traffic. uint32_t second=esphome::millis()/1000; if(second!=last_live_second){ diff --git a/components/smart_display/tile_controls.h b/components/smart_display/tile_controls.h new file mode 100644 index 0000000..d8d5674 --- /dev/null +++ b/components/smart_display/tile_controls.h @@ -0,0 +1,241 @@ +#pragma once +// Direct controls on the right half of a double-width card, like Home Assistant's +// own entity rows. Pure logic only: which keys a card shows, what they send, how +// a -/+ step lands on the entity's grid, and the status line beside them. The +// LVGL drawing lives in runtime_tiles.h; tests/test_tile_controls.cpp covers this. +#include "runtime_model.h" +#include +#include +#include +#include +#include + +namespace tile_controls { +// Home Assistant supported_features bits. +namespace feature { +constexpr uint32_t COVER_OPEN = 1, COVER_CLOSE = 2, COVER_POSITION = 4, COVER_STOP = 8; +constexpr uint32_t MEDIA_PAUSE = 1, MEDIA_VOLUME_SET = 4, MEDIA_VOLUME_MUTE = 8, MEDIA_PREVIOUS = 16, MEDIA_NEXT = 32, MEDIA_PLAY = 16384; +constexpr uint32_t VACUUM_TURN_ON = 1, VACUUM_TURN_OFF = 2, VACUUM_PAUSE = 4, VACUUM_STOP = 8, VACUUM_RETURN = 16, VACUUM_START = 8192; +} +// Material Design Icons glyphs the icon fonts carry (tile_icons.py FIXED). +namespace glyph { +constexpr const char *PLAY = "\U000F040A", *PAUSE = "\U000F03E4", *STOP = "\U000F04DB", *NEXT = "\U000F04AD", *PREVIOUS = "\U000F04AE"; +constexpr const char *VOLUME = "\U000F057E", *MUTED = "\U000F0581", *UP = "\U000F005D", *DOWN = "\U000F0045"; +constexpr const char *EXPAND = "\U000F084E", *COLLAPSE = "\U000F084C", *DOCK = "\U000F05F8", *PLUS = "\U000F0415", *MINUS = "\U000F0374"; +constexpr const char *LEFT = "\U000F0141", *RIGHT = "\U000F0142", *CLOSE = "\U000F0156", *POWER = "\U000F0425", *FIRE = "\U000F0238"; +constexpr const char *SNOWFLAKE = "\U000F0717", *HEAT_COOL = "\U000F1A79", *AUTO = "\U000F1B17", *DRY = "\U000F058E", *FAN = "\U000F0210"; +} +enum Command { + NONE = 0, COVER_OPEN, COVER_STOP, COVER_CLOSE, VACUUM_START, VACUUM_PAUSE, VACUUM_STOP, VACUUM_DOCK, + MEDIA_PREVIOUS, MEDIA_PLAY_PAUSE, MEDIA_NEXT, MEDIA_MUTE, TIMER_START, TIMER_PAUSE, TIMER_CANCEL, + HVAC_MODE, SELECT_PREVIOUS, SELECT_NEXT, RUN, TOGGLE, STEP_DOWN, STEP_UP +}; +struct Key { const char *icon = ""; int command = NONE; std::string arg; bool checked = false, disabled = false; }; +struct Action { std::string service, key, value; bool valid() const { return !service.empty(); } }; +using runtime_tiles::Tile; + +// Panel kinds: keys (a row of pill buttons), stepper (-/+ pill), slider, toggle, run. +inline bool is_key_row(const std::string &c) { return c == "buttons" || c == "mode" || c == "playback" || c == "chevrons"; } +inline bool is_slider(const std::string &c) { return c == "volume" || c == "brightness" || c == "speed" || c == "position" || c == "slider"; } +// A select's "stepper" is a pair of chevron keys; numbers and climate get the -/+ pill. +inline std::string panel_kind(const Tile &t) { + auto c = t.controls, d = t.domain(); + if (c == "stepper" && (d == "select" || d == "input_select")) return "chevrons"; + return c; +} +inline bool has_mode(const std::string &hvac_modes, const char *mode) { return hvac_modes.find("\"" + std::string(mode) + "\"") != std::string::npos; } +inline const char *mode_icon(const std::string &mode) { + if (mode == "off") return glyph::POWER; + if (mode == "heat") return glyph::FIRE; + if (mode == "cool") return glyph::SNOWFLAKE; + if (mode == "heat_cool") return glyph::HEAT_COOL; + if (mode == "auto") return glyph::AUTO; + if (mode == "dry") return glyph::DRY; + return glyph::FAN; +} +// Covers that move sideways get the horizontal arrows, like Home Assistant. +inline bool sideways_cover(const std::string &device_class) { + return device_class == "curtain" || device_class == "awning" || device_class == "door" || device_class == "gate"; +} +inline float numeric_state(const Tile &t) { + char *end; float value = std::strtof(t.state.c_str(), &end); + return end != t.state.c_str() && std::isfinite(value) ? value : NAN; +} +// Value the -/+ pill edits: the climate setpoint or the number itself. +inline float edit_target(const Tile &t) { return t.domain() == "climate" ? t.target : numeric_state(t); } +inline float edit_step(const Tile &t) { + float step = t.step; + if (!std::isfinite(step) || step <= 0) step = t.domain() == "climate" ? 0.5f : 1.0f; + return step; +} +// One step up or down, snapped to the entity's grid and kept inside its range. +inline float step_value(float current, float step, float minimum, float maximum, int direction) { + if (!std::isfinite(step) || step <= 0) step = 1; + bool has_min = std::isfinite(minimum), has_max = std::isfinite(maximum) && (!has_min || maximum >= minimum); + float origin = has_min ? minimum : 0; + if (!std::isfinite(current)) current = has_min ? minimum : 0; + float next = origin + std::round((current + direction * step - origin) / step) * step; + if (has_min) next = std::fmax(next, minimum); + if (has_max) next = std::fmin(next, maximum); + return next; +} +// Whole degrees when the entity steps by whole units, one decimal otherwise. +inline std::string format_value(float value, float step, const char *suffix) { + if (!std::isfinite(value)) return "--"; + char b[24]; snprintf(b, sizeof(b), step >= 1 ? "%.0f%s" : "%.1f%s", value, suffix); return b; +} +inline const char *climate_mode_text(const std::string &mode) { + if (mode == "off") return "Uit"; + if (mode == "heat") return "Verwarmen"; + if (mode == "cool") return "Koelen"; + if (mode == "heat_cool") return "Verwarmen/koelen"; + if (mode == "auto") return "Automatisch"; + if (mode == "dry") return "Drogen"; + if (mode == "fan_only") return "Ventileren"; + return mode.c_str(); +} +inline const char *climate_action_text(const std::string &action) { + if (action == "heating") return "Verwarmt"; + if (action == "cooling") return "Koelt"; + if (action == "idle") return "Inactief"; + if (action == "off") return "Uit"; + if (action == "drying") return "Droogt"; + if (action == "fan") return "Ventileert"; + if (action == "preheating") return "Voorverwarmen"; + if (action == "defrosting") return "Ontdooit"; + return ""; +} +inline const char *cover_state_text(const std::string &state) { + if (state == "open") return "Open"; + if (state == "closed") return "Dicht"; + if (state == "opening") return "Gaat open"; + if (state == "closing") return "Gaat dicht"; + return state.c_str(); +} +inline const char *media_state_text(const std::string &state) { + if (state == "playing") return "Speelt"; + if (state == "paused") return "Gepauzeerd"; + if (state == "idle") return "Inactief"; + if (state == "standby") return "Stand-by"; + if (state == "buffering") return "Laden"; + if (state == "on") return "Aan"; + if (state == "off") return "Uit"; + return state.c_str(); +} +// Status line beside a control panel: what Home Assistant shows under the name. +inline std::string status_text(const Tile &t) { + auto d = t.domain(); char b[48]; + if (d == "climate") { + std::string text = climate_action_text(t.hvac_action); + if (text.empty()) text = climate_mode_text(t.state); + if (std::isfinite(t.current)) { snprintf(b, sizeof(b), " · %.1f°", t.current); text += b; } + return text; + } + if (d == "cover") { + std::string text = cover_state_text(t.state); + if (std::isfinite(t.position)) { snprintf(b, sizeof(b), " · %d%%", (int) std::lround(t.position)); text += b; } + return text; + } + if (d == "media_player") { + std::string text = (t.state == "playing" || t.state == "paused") && !t.media_title.empty() ? t.media_title : media_state_text(t.state); + if (std::isfinite(t.volume) && (t.supported & feature::MEDIA_VOLUME_SET)) { snprintf(b, sizeof(b), " · %d%%", (int) std::lround(t.volume * 100)); text += b; } + return text; + } + return {}; +} +// The row of up to three pill keys for a key-row panel; returns how many. +inline unsigned keys_for(const Tile &t, std::array &out) { + auto d = t.domain(); auto c = panel_kind(t); unsigned n = 0; + auto add = [&](const char *icon, int command, bool disabled = false, bool checked = false, const std::string &arg = "") { + if (n < out.size()) out[n++] = Key{icon, command, arg, checked, disabled}; + }; + if (c == "buttons" && d == "cover") { + bool sideways = sideways_cover(t.device_class); + bool fully_open = std::isfinite(t.position) ? t.position >= 99.5f : t.state == "open"; + bool fully_closed = std::isfinite(t.position) ? t.position <= 0.5f : t.state == "closed"; + if (t.supported & feature::COVER_OPEN) add(sideways ? glyph::EXPAND : glyph::UP, COVER_OPEN, fully_open && t.state != "closing"); + if (t.supported & feature::COVER_STOP) add(glyph::STOP, COVER_STOP); + if (t.supported & feature::COVER_CLOSE) add(sideways ? glyph::COLLAPSE : glyph::DOWN, COVER_CLOSE, fully_closed && t.state != "opening"); + } else if (c == "buttons" && d == "vacuum") { + bool cleaning = t.state == "cleaning"; + if (cleaning && (t.supported & feature::VACUUM_PAUSE)) add(glyph::PAUSE, VACUUM_PAUSE); + else if (cleaning && (t.supported & feature::VACUUM_TURN_OFF) && !(t.supported & feature::VACUUM_START)) add(glyph::PAUSE, VACUUM_STOP); + else if (t.supported & (feature::VACUUM_START | feature::VACUUM_TURN_ON)) add(glyph::PLAY, VACUUM_START, cleaning); + if (t.supported & feature::VACUUM_STOP) add(glyph::STOP, VACUUM_STOP, !cleaning && t.state != "returning"); + if (t.supported & feature::VACUUM_RETURN) add(glyph::DOCK, VACUUM_DOCK, t.state == "docked" || t.state == "returning"); + } else if (c == "buttons" && d == "timer") { + bool active = t.state == "active"; + add(active ? glyph::PAUSE : glyph::PLAY, active ? TIMER_PAUSE : TIMER_START); + add(glyph::CLOSE, TIMER_CANCEL, t.state == "idle"); + } else if (c == "playback") { + if (t.supported & feature::MEDIA_PREVIOUS) add(glyph::PREVIOUS, MEDIA_PREVIOUS); + if (t.supported & (feature::MEDIA_PLAY | feature::MEDIA_PAUSE)) add(t.state == "playing" ? glyph::PAUSE : glyph::PLAY, MEDIA_PLAY_PAUSE); + if (t.supported & feature::MEDIA_NEXT) add(glyph::NEXT, MEDIA_NEXT); + } else if (c == "mode") { + // Three keys fit beside the name; Home Assistant lists modes in device order. + for (const char *mode : {"off", "heat", "cool", "heat_cool", "auto", "dry", "fan_only"}) + if (n < 3 && has_mode(t.hvac_modes, mode)) add(mode_icon(mode), HVAC_MODE, false, t.state == mode, mode); + } else if (c == "chevrons") { + add(glyph::LEFT, SELECT_PREVIOUS, t.option_count < 2); + add(glyph::RIGHT, SELECT_NEXT, t.option_count < 2); + } + return n; +} +inline std::string neighbour_option(const Tile &t, int direction) { + if (!t.option_count) return {}; + int current = 0; + for (unsigned i = 0; i < t.option_count; ++i) if (t.options[i] == t.state) current = i; + int next = (current + direction + (int) t.option_count) % (int) t.option_count; + return t.options[next]; +} +inline const char *run_label(const std::string &domain) { + if (domain == "scene") return "Activeren"; + if (domain == "script") return "Uitvoeren"; + return "Indrukken"; +} +// The Home Assistant action behind a key. STEP_* are handled locally (debounced) and return nothing. +inline Action key_action(const Tile &t, int command, const std::string &arg = "") { + auto d = t.domain(); + switch (command) { + case COVER_OPEN: return {"cover.open_cover", "", ""}; + case COVER_STOP: return {"cover.stop_cover", "", ""}; + case COVER_CLOSE: return {"cover.close_cover", "", ""}; + case VACUUM_START: return {(t.supported & feature::VACUUM_START) ? "vacuum.start" : "vacuum.turn_on", "", ""}; + case VACUUM_PAUSE: return {"vacuum.pause", "", ""}; + case VACUUM_STOP: return {(t.supported & feature::VACUUM_STOP) ? "vacuum.stop" : "vacuum.turn_off", "", ""}; + case VACUUM_DOCK: return {"vacuum.return_to_base", "", ""}; + case MEDIA_PREVIOUS: return {"media_player.media_previous_track", "", ""}; + case MEDIA_PLAY_PAUSE: return {"media_player.media_play_pause", "", ""}; + case MEDIA_NEXT: return {"media_player.media_next_track", "", ""}; + case MEDIA_MUTE: return {"media_player.volume_mute", "is_volume_muted", t.muted ? "false" : "true"}; + case TIMER_START: return {"timer.start", "", ""}; + case TIMER_PAUSE: return {"timer.pause", "", ""}; + case TIMER_CANCEL: return {"timer.cancel", "", ""}; + case HVAC_MODE: return arg.empty() ? Action{} : Action{"climate.set_hvac_mode", "hvac_mode", arg}; + case SELECT_PREVIOUS: case SELECT_NEXT: { + std::string option = neighbour_option(t, command == SELECT_NEXT ? 1 : -1); + return option.empty() ? Action{} : Action{d + ".select_option", "option", option}; + } + case RUN: + if (d == "scene" || d == "script") return {d + ".turn_on", "", ""}; + if (d == "button" || d == "input_button") return {d + ".press", "", ""}; + return {}; + case TOGGLE: + if (d == "light" || d == "switch" || d == "input_boolean" || d == "fan") return {d + (t.state == "on" ? ".turn_off" : ".turn_on"), "", ""}; + return {}; + default: return {}; + } +} +// The debounced -/+ edit lands as one service call. +inline Action edit_action(const Tile &t, float value) { + if (!std::isfinite(value)) return {}; + char b[24]; snprintf(b, sizeof(b), "%.2f", value); + std::string text = b; + while (text.size() > 1 && text.back() == '0') text.pop_back(); + if (text.back() == '.') text.pop_back(); + auto d = t.domain(); + if (d == "climate") return {"climate.set_temperature", "temperature", text}; + if (d == "number" || d == "input_number") return {d + ".set_value", "value", text}; + return {}; +} +} // namespace tile_controls diff --git a/diagnostics/send_layout.py b/diagnostics/send_layout.py index 5b51318..05be553 100644 --- a/diagnostics/send_layout.py +++ b/diagnostics/send_layout.py @@ -29,6 +29,52 @@ def demo_layout(): {'entity': 'weather.demo_standaard', 'name': 'Weer nu', 'options': {}}, ]}) +# Direct controls on wide cards (firmware 0.2.19+): one card per control set. +def controls_layout(): + return validate_layout({'title': 'Directe bediening', 'tiles': [ + {'entity': 'cover.demo_gordijn', 'name': 'Gordijnen Raambekleding', 'options': {'size': 'wide', 'icon': 'curtains'}}, + {'entity': 'climate.demo_airco', 'name': 'Airco', 'options': {'size': 'wide'}}, + {'entity': 'media_player.demo_sonos', 'name': 'Sonos', 'options': {'size': 'wide', 'icon': 'speaker'}}, + {'entity': 'climate.demo_cv', 'name': 'Verwarming', 'options': {'size': 'wide', 'controls': 'mode', 'icon': 'radiator'}}, + {'entity': 'media_player.demo_radio', 'name': 'Sonos slaapkamer', 'options': {'size': 'wide', 'controls': 'playback', 'icon': 'radio'}}, + {'entity': 'vacuum.demo_robot', 'name': 'Pippa', 'options': {'size': 'wide'}}, + {'entity': 'switch.demo_bureau', 'name': 'Bureau', 'options': {'size': 'wide', 'background': 'blue', 'icon': 'power-socket-eu'}}, + {'entity': 'light.demo_tafellamp', 'name': 'Tafellamp', 'options': {'size': 'wide', 'controls': 'brightness', 'icon': 'lamp'}}, + {'entity': 'number.demo_doel', 'name': 'Doel luchtvochtigheid', 'options': {'size': 'wide', 'icon': 'water-percent'}}, + {'entity': 'select.demo_stand', 'name': 'Verwarmingsstand', 'options': {'size': 'wide', 'icon': 'thermostat'}}, + {'entity': 'timer.demo_eieren', 'name': 'Eieren', 'options': {'size': 'wide', 'icon': 'chef-hat'}}, + {'entity': 'scene.demo_avond', 'name': 'Avond', 'options': {'size': 'wide', 'icon': 'sofa'}}, + {'entity': 'fan.demo_ventilator', 'name': 'Ventilator', 'options': {'size': 'wide'}}, + {'entity': 'cover.demo_rolluik', 'name': 'Rolluik', 'options': {'size': 'wide', 'controls': 'position', 'icon': 'window-shutter'}}, + {'entity': 'light.demo_plafond', 'name': 'Plafondlamp', 'options': {'size': 'wide', 'icon': 'ceiling-light'}}, + {'entity': 'weather.demo_buiten', 'name': 'Buiten', 'options': {'display': 'forecast'}}, + {'entity': 'script.demo_tv', 'name': 'TV aanzetten', 'options': {'icon': 'television'}}, + {'entity': 'scene.demo_ochtend', 'name': 'Ochtend', 'options': {'icon': 'weather-sunset-up'}}, + ]}) + +def controls_states(now): + end = now + timedelta(minutes=4, seconds=32) + return { + 'cover.demo_gordijn': {'state': 'open', 'attributes': {'current_position': 80, 'device_class': 'curtain', 'supported_features': 15}}, + 'climate.demo_airco': {'state': 'cool', 'attributes': {'current_temperature': 21.5, 'temperature': 20, 'min_temp': 16, 'max_temp': 32, 'target_temp_step': 1.0, 'hvac_modes': ['off', 'heat_cool', 'cool', 'heat', 'fan_only', 'dry'], 'fan_modes': ['auto', 'low', 'medium', 'high'], 'fan_mode': 'low', 'swing_modes': ['off', 'both', 'vertical', 'horizontal'], 'swing_mode': 'off', 'supported_features': 425}}, + 'media_player.demo_sonos': {'state': 'playing', 'attributes': {'volume_level': 0.17, 'is_volume_muted': False, 'media_title': 'TV', 'supported_features': 8321599}}, + 'climate.demo_cv': {'state': 'heat', 'attributes': {'current_temperature': 19.5, 'temperature': 21, 'hvac_action': 'heating', 'hvac_modes': ['off', 'heat', 'auto']}}, + 'media_player.demo_radio': {'state': 'playing', 'attributes': {'volume_level': 0.15, 'media_title': 'NPO Radio 2', 'supported_features': 8321599}}, + 'vacuum.demo_robot': {'state': 'docked', 'attributes': {'battery_level': 100, 'fan_speed': 'max', 'fan_speed_list': ['quiet', 'balanced', 'turbo', 'max'], 'supported_features': 30524}}, + 'switch.demo_bureau': {'state': 'on', 'attributes': {}}, + 'light.demo_tafellamp': {'state': 'on', 'attributes': {'brightness': 163}}, + 'number.demo_doel': {'state': '55', 'attributes': {'min': 30, 'max': 70, 'step': 5, 'unit_of_measurement': '%'}}, + 'select.demo_stand': {'state': 'Comfort', 'attributes': {'options': ['Eco', 'Comfort', 'Boost']}}, + 'timer.demo_eieren': {'state': 'active', 'attributes': {'finishes_at': end.isoformat(), 'duration': '0:05:00', 'remaining': '0:05:00'}}, + 'scene.demo_avond': {'state': (now - timedelta(hours=3)).isoformat(), 'attributes': {}}, + 'fan.demo_ventilator': {'state': 'off', 'attributes': {'percentage': 0}}, + 'cover.demo_rolluik': {'state': 'open', 'attributes': {'current_position': 35, 'supported_features': 15}}, + 'light.demo_plafond': {'state': 'off', 'attributes': {}}, + 'weather.demo_buiten': {'state': 'rainy', 'attributes': {'temperature': 18.4, 'temperature_unit': '°C', 'humidity': 92, 'wind_speed': 12.2, 'wind_speed_unit': 'km/h', 'apparent_temperature': 17.1}}, + 'script.demo_tv': {'state': 'off', 'attributes': {'last_triggered': (now - timedelta(hours=2, minutes=8)).isoformat()}}, + 'scene.demo_ochtend': {'state': (now - timedelta(days=1, hours=5)).isoformat(), 'attributes': {}}, + } + def demo_states(now): end = now + timedelta(minutes=4, seconds=32) return { @@ -44,12 +90,20 @@ def demo_states(now): 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} + return [{'datetime': (now + timedelta(days=i)).isoformat(), 'condition': conditions[i], 'temperature': 21 - i, 'templow': 11 + i, + 'precipitation': [0, 0, 4.2, 0.3, 11.5, 2][i], 'precipitation_probability': [5, 20, 80, 30, 95, 60][i]} for i in range(6)] -def messages(inbox, rotate=0, digital=False, wide=False): +def demo_hourly(now): + conditions = ['rainy', 'rainy', 'partlycloudy', 'partlycloudy', 'sunny', 'sunny', 'cloudy', 'lightning-rainy', 'rainy', 'cloudy'] + start = now.replace(minute=0, second=0, microsecond=0) + return [{'datetime': (start + timedelta(hours=i)).isoformat(), 'condition': conditions[i], 'temperature': 18.4 + i * 0.6, + 'precipitation': [0.4, 0.2, 0, 0, 0, 0, 0, 2.1, 1.0, 0][i], 'precipitation_probability': [70, 55, 10, 5, 0, 0, 15, 85, 60, 20][i]} + for i in range(10)] + +def messages(inbox, rotate=0, digital=False, wide=False, controls=False): now = datetime.now(timezone.utc) - layout, states = demo_layout(), demo_states(now) + layout, states = (controls_layout(), controls_states(now)) if controls else (demo_layout(), demo_states(now)) if digital: layout['tiles'][0]['options'] = {'display': 'digital'} elif wide: @@ -58,7 +112,8 @@ def messages(inbox, rotate=0, digital=False, wide=False): 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)) + hourly = demo_hourly(now) if tile['entity'].startswith('weather.') else None + message = state_message(i, tile, states, extras(tile, states, forecast, None, hourly, now)) 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) @@ -72,6 +127,7 @@ async def main(): 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 de analoge kalenderkaart zonder achtergrond') p.add_argument('--wide', action='store_true', help='Analoge klok dubbelbreed (wijzerplaat met digitale tijd en datum)') + p.add_argument('--controls', action='store_true', help='Dubbelbrede kaarten met directe bediening (firmware 0.2.19+), drie per pagina') 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) @@ -86,7 +142,8 @@ async def main(): 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, args.wide): + count = len(controls_layout()['tiles']) if args.controls else 9 + for message in messages(inbox.object_id if hasattr(inbox, 'object_id') else 'text.inbox', args.rotate % count, args.digital, args.wide, args.controls): for packet in packets(message): client.text_command(inbox.key, packet) await asyncio.sleep(0.05) diff --git a/docs/EASY_SETUP.md b/docs/EASY_SETUP.md index 9b356b8..08c9c1b 100644 --- a/docs/EASY_SETUP.md +++ b/docs/EASY_SETUP.md @@ -100,7 +100,15 @@ 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, een grote waarde, een grafiek (sensoren), een -weersvoorspelling (weer) of de breedte **Dubbelbreed**. De ingebouwde **Klok** +weersvoorspelling (weer) of de breedte **Dubbelbreed**. Een dubbelbrede tegel van +een klimaat, schakelaar, lamp, ventilator, stofzuiger, zonwering, mediaspeler, +getal, keuzelijst, kookwekker, scène, script of knop krijgt rechts **directe +bediening** zoals de rijen in Home Assistant (bijvoorbeeld temperatuur − / +, +open/stop/dicht, volume met dempen, een toggle); kies onder **Directe bediening +op de tegel** welke set, of **Geen** (firmware 0.2.19+). **Bediening openen** op +een weertegel toont de weerkaart met de komende uren en dagen (regen inbegrepen); +op een klimaattegel de kaart met aan/uit-knop en de modus-, ventilator- en +zwenkstanden. 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 en **Geen** laat de kaart weg, zodat de inhoud even groot diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 5a626c2..f3a28a2 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -177,6 +177,34 @@ staan iconen in de browser uit het midden); draai daarna `generate_packages.py`. De substituties `MDI_GLYPH_*` zijn vervallen: `TILEn_ICON` in handmatige profielen moet uit de set komen. Geen gewijzigde preferences of sleutels. +### Compatibiliteit 0.2.23 / firmware 0.2.19 + +Opslagversie en tegelprotocol blijven 1. `tiles[].options.controls` is additief: +`none` of een set uit `CONTROLS` in `core.py` per domein (climate `setpoint`/`mode`, +switch/light/fan `toggle` plus `brightness`/`speed`, vacuum/cover/timer `buttons`, +cover `position`, media_player `volume`/`playback`, number `stepper`/`slider`, +select `stepper`, scene/script/button `run`). Op de draad stuurt de manager in +`o.controls` alleen de set die de kaart werkelijk toont: uitsluitend dubbelbreed, +weergave standaard en zonder mini-schuif; zonder keuze de eerste set van het +domein, bij `none` niets. Statusberichten krijgen de attributen `device_class`, +`hvac_action` en `is_volume_muted` (de enige boolean die meegaat). Oudere +firmware negeert het veld en toont de gewone brede kaart, daarom geen +`min_firmware`; het tegelpaneel meldt vanaf welke firmware het werkt. Een oude +editor die `controls` weglaat behoudt de opgeslagen keuze (zoals `background`). +De firmware bouwt het paneel lui per set (`layout_panel`), stuurt −/+ na 700 ms +als één `set_temperature`/`set_value` en laat de lokale waarde staan tot HA hem +meldt (of 10 s). De iconfonts krijgen 18 vaste bedieningsglyphs +(`tile_icons.FIXED`). Geen gewijzigde preferences of sleutels. + +Ook additief in dezelfde release: `x.hours` (maximaal acht uren `t`, `c`, `h`, +`p`, `r`) en `p`/`r` per dag in `x.days` voor de weerkaart; `x.last` (unix-tijd) +voor scènes, scripts en knoppen; de attributen `humidity`, `wind_speed`, +`wind_speed_unit`, `apparent_temperature`, `fan_modes`, `swing_modes`, `fan_mode` +en `swing_mode`. `supported_features` mag boven de grens van een miljoen. Oudere +firmware negeert al deze velden. De bezig-status gebruikt LVGL's spinner; beide +bordprofielen bevatten daarvoor een verborgen `spinner` (`busy_spinner_seed`), +anders compileert ESPHome `LV_USE_SPINNER` als 0. + ### Compatibiliteit 0.2.12 / firmware 0.2.14 Opslagversie en tegelprotocol blijven 1. Additief: `tiles[].options.size` diff --git a/docs/TEST_RESULTS_0223.md b/docs/TEST_RESULTS_0223.md new file mode 100644 index 0000000..13be0d0 --- /dev/null +++ b/docs/TEST_RESULTS_0223.md @@ -0,0 +1,39 @@ +# Testresultaten app 0.2.23 / firmware 0.2.19 (2026-09-13) + +Directe bediening op dubbelbrede tegels, weerkaart met uren en dagen, klimaatkaart +met aan/uit-knop en ventilator-/zwenkstanden, "Laatst"-status voor scènes, scripts +en knoppen, bezig-status als spinner, lichter tekenen, donker thema verwijderd. + +## Geautomatiseerd + +- Python: `.venv-portal/bin/python -m unittest discover -s tests` → 82 tests OK + (nieuw: `test_tile_controls.py`, `test_rich_cards.py`). +- C++: alle `tests/*.cpp` met `c++ -std=c++17 -Wall -Wextra -pedantic -I.` → 11 PASS + (nieuw: `test_tile_controls.cpp`; `test_cyd_ui.cpp` dekt `cyd::list_item`). +- `tools/generate_icons.py --check` (177 glyphs) en `tools/generate_packages.py --check` groen. +- ESPHome 2026.6.2: `easy-guition-device.yaml` en `easy-cyd-device.yaml` compileren + (lokale componenten en fonts); beide via OTA geflasht, `device_info` meldt 0.2.19 (build 14:53). + +## Op de borden + +- Guition `ui_self_test` met de demo-indeling `send_layout.py --controls` (18 tegels, + 6 pagina's): 10/10 `page_check=PASS`, geen `Panel bounds`, `Tile geometry` of + `GEOMETRY FAIL`; heap stabiel rond 6,33 MB. +- CYD `ui_self_test` met dezelfde demo: 10/10 PASS, heap 20–26 KB vrij. +- LVGL-snapshots (Guition, `capture_ui.py`) bekeken: gordijnknoppen, temperatuur-pill, + volumeschuif met greep en dempknop, modusknoppen (actieve in accent), playback, + stofzuiger (stop/dock grijs in het dock), toggle op pastelkaart, helderheidsschuif, + −/+ voor getallen, keuze-chevrons, kookwekker, "Activeren"; weerkaart in twee kaarten + (nu + komende uren, komende dagen met regen en hoog/laag); "Laatst 12:09" en + "Gisteren 09:17"; spinner-laag op een bezige tegel; moduskiezer met drie chips per rij. +- De bezig-status wordt niet meer elke 250 ms herrenderd; stijlzetters in `render()` + raken LVGL alleen bij een echte wijziging. + +## Bekend / nog te doen + +- De productie-add-on (0.2.22) stuurt `controls`, `x.hours`, `x.last`, `fan_modes` enz. + nog niet; echte tegels tonen de nieuwe onderdelen pas na de add-on-update naar 0.2.23. +- De klimaatkaart-snapshot lukte niet in dezelfde run (de manager herstelde de echte + indeling); de moduskiezer wel. Fysieke controle door Max: knoppen, −/+ debounce, + spinner-vloeiendheid, weerkaart op de CYD. +- `capture_ui.py` mist af en toe enkele rijen (snapshot-stream); gewoon opnieuw draaien. diff --git a/guition-4848s040.yaml b/guition-4848s040.yaml index 3b3e0ae..892fc8b 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.18" + SCREEN_FIRMWARE_VERSION: "0.2.19" DYNAMIC_TILES: "false" DASHBOARD_INBOX_INTERNAL: "true" DEVICE_NAME: "guition-wallbox" @@ -603,13 +603,13 @@ esphome: settings.night_start = atoi("${NIGHT_START_HOUR}") * 60; settings.night_end = atoi("${NIGHT_END_HOUR}") * 60; settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}"); - runtime_tiles::light_theme = true; 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::control_font = id(sublabel_big)->get_lv_font(); runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true"; if (!runtime_tiles::enabled) return; runtime_tiles::rotation_supported = true; @@ -664,6 +664,8 @@ esphome: light_controls::fallback.kelvin = t.kelvin; light_controls::fallback.minimum = t.min_kelvin; light_controls::fallback.maximum = t.max_kelvin; + id(active_climate_fan_modes) = t.fan_modes; id(active_climate_fan_mode) = t.fan_mode; + id(active_climate_swing_modes) = t.swing_modes; id(active_climate_swing_mode) = t.swing_mode; id(open_value_overlay).execute(t.name, t.entity, t.domain(), "auto", t.active(), 0, t.brightness, t.percentage, t.position, runtime_tiles::icon_for(t), 0xD5EEFC, 0x00A6ED, 0x1B1B1B, "Uit", "Aan", t.modes, @@ -677,7 +679,10 @@ esphome: id(climate_edit_pending) = false; id(active_climate_mode) = t.state; id(active_climate_supported_modes) = t.hvac_modes; - lv_label_set_text(id(climate_mode_label), t.state.c_str()); + lv_label_set_text(id(climate_mode_label), tile_controls::climate_mode_text(t.state)); + id(climate_power_refresh).execute(); + id(active_climate_fan_modes) = t.fan_modes; id(active_climate_fan_mode) = t.fan_mode; + id(active_climate_swing_modes) = t.swing_modes; id(active_climate_swing_mode) = t.swing_mode; char buf[72]; if (std::isfinite(t.current)) snprintf(buf, sizeof(buf), "Nu: %.1f°", t.current); else snprintf(buf, sizeof(buf), "Nu: --"); @@ -694,7 +699,7 @@ esphome: - lambda: |- static const char *entities[] = {"${TILE1_ENTITY}", "${TILE2_ENTITY}", "${TILE3_ENTITY}", "${TILE4_ENTITY}", "${TILE5_ENTITY}", "${TILE6_ENTITY}", "${TILE7_ENTITY}", "${TILE9_ENTITY}"}; if (!runtime_tiles::enabled) light_controls::subscribe(entities, 8); - light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}"), true); + light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}")); light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); }; light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); }; light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); }; @@ -787,11 +792,32 @@ api: index: int then: - lambda: |- - if (index < 0) { runtime_tiles::hide_detail(); return; } + if (index < 0) { runtime_tiles::hide_detail(); if (runtime_tiles::dismiss) runtime_tiles::dismiss(); return; } if (runtime_tiles::enabled && index < (int)runtime_tiles::model.count) { runtime_tiles::active_index = index; + auto &tile = runtime_tiles::model.tiles[index]; + auto domain = tile.domain(); + // Lights, fans, covers and climate open the board's own card, like a long press does. + if ((domain == "light" || domain == "fan" || domain == "cover" || domain == "climate") && runtime_tiles::detail) { + tile.begin(millis(), true); + runtime_tiles::detail(tile); + return; + } runtime_tiles::show_detail(index); } + # Diagnostics: the climate mode picker and the busy sheet of a tile, without touching the glass or HA. + - action: preview_climate_picker + then: + - script.execute: open_climate_mode_picker + - action: preview_busy + variables: + index: int + then: + - lambda: |- + if (runtime_tiles::enabled && index >= 0 && index < (int)runtime_tiles::model.count) { + runtime_tiles::model.tiles[index].begin(millis()); + if (runtime_tiles::refresh) runtime_tiles::refresh(); + } - action: light_controls_preview then: - script.execute: light_controls_preview @@ -974,6 +1000,27 @@ globals: type: std::string restore_value: no + - id: active_climate_power_on + type: bool + restore_value: no + initial_value: 'false' + + - id: active_climate_fan_modes + type: std::string + restore_value: no + + - id: active_climate_fan_mode + type: std::string + restore_value: no + + - id: active_climate_swing_modes + type: std::string + restore_value: no + + - id: active_climate_swing_mode + type: std::string + restore_value: no + - id: active_climate_supported_modes type: std::string restore_value: no @@ -998,7 +1045,7 @@ font: '0','1','2','3','4','5','6','7','8','9', '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', ' ','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', - 'ä','ö','ü','Ä','Ö','Ü','ß','/'] + 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] - file: "fonts/Roboto-500.ttf" id: watch_value @@ -1008,7 +1055,7 @@ font: '0','1','2','3','4','5','6','7','8','9', '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', ' ','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" @@ -1025,7 +1072,7 @@ font: '0','1','2','3','4','5','6','7','8','9', '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', ' ','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', - 'ä','ö','ü','Ä','Ö','Ü','ß','/'] + 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] - file: "fonts/Roboto-400.ttf" id: sublabel @@ -1035,7 +1082,7 @@ font: '0','1','2','3','4','5','6','7','8','9', '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', ' ','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', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','…'] + 'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·'] - file: "fonts/Roboto-400.ttf" id: sublabel_big @@ -1045,7 +1092,7 @@ font: '0','1','2','3','4','5','6','7','8','9', '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', ' ','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', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','…'] + 'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·'] # Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0) # Source: https://github.com/Templarian/MaterialDesign-Webfont @@ -1205,6 +1252,24 @@ font: - "\U000F00E2" # broom - "\U000F05D6" # alert-circle-outline - "\U000F012C" # check + - "\U000F03E4" # pause + - "\U000F04DB" # stop + - "\U000F04AD" # skip-next + - "\U000F04AE" # skip-previous + - "\U000F057E" # volume-high + - "\U000F0581" # volume-off + - "\U000F005D" # arrow-up + - "\U000F0045" # arrow-down + - "\U000F084E" # arrow-expand-horizontal + - "\U000F084C" # arrow-collapse-horizontal + - "\U000F05F8" # home-map-marker + - "\U000F0415" # plus + - "\U000F0374" # minus + - "\U000F0141" # chevron-left + - "\U000F0142" # chevron-right + - "\U000F0156" # close + - "\U000F1A79" # sun-snowflake-variant + - "\U000F1B17" # thermostat-auto - "\U000F0591" # weather-fog - "\U000F0592" # weather-hail - "\U000F0593" # weather-lightning @@ -1994,6 +2059,14 @@ lvgl: scrollable: false scrollbar_mode: 'OFF' widgets: + # Never shown: it only makes ESPHome compile LVGL's spinner, which the busy sheet on a tile draws. + - spinner: + id: busy_spinner_seed + hidden: true + width: 2 + height: 2 + spin_time: 1s + arc_length: 60deg - label: id: lbl_room x: 16 @@ -3753,7 +3826,7 @@ lvgl: width: 54 height: 54 radius: 999 - bg_color: 0xFF9F0A + bg_color: 0xEEEEEE bg_opa: COVER border_width: 0 clickable: true @@ -3835,25 +3908,26 @@ lvgl: clickable: false - obj: - id: climate_done_button + id: climate_power_button align: TOP_RIGHT x: -15 y: 18 width: 66 height: 66 radius: 999 - bg_color: 0xD5EEFC + bg_color: 0xEEEEEE bg_opa: COVER border_width: 0 clickable: true scrollable: false on_click: - - lvgl.widget.hide: climate_detail_overlay + - script.execute: climate_power_toggle widgets: - label: + id: climate_power_icon align: CENTER - text: "\U000F012C" - text_font: materialdesign_icons + text: "\U000F0425" + text_font: materialdesign_icons_mini text_color: 0x1B1B1B clickable: false @@ -3868,7 +3942,7 @@ lvgl: id: climate_current_label align: TOP_MID y: 84 - text: "Current: --" + text: "Nu: --" text_font: sublabel_big text_color: 0x1B1B1B text_opa: 85% @@ -3919,7 +3993,7 @@ lvgl: id: climate_set_label align: CENTER y: -32 - text: "SET TO" + text: "Gewenst" text_font: sublabel_big text_color: 0x1B1B1B text_opa: 68% @@ -4022,8 +4096,8 @@ lvgl: width: ${DISPLAY_W} height: ${DISPLAY_H} hidden: true - bg_color: 0xFFFFFF - bg_opa: 90% + bg_color: 0xE7E7E7 + bg_opa: COVER border_width: 0 clickable: true scrollable: false @@ -4032,7 +4106,7 @@ lvgl: - label: align: TOP_MID y: 32 - text: "Mode" + text: "Modus" text_font: sublabel_big text_color: 0x1B1B1B clickable: false @@ -4042,15 +4116,18 @@ lvgl: - obj: id: climate_mode_list align: TOP_MID - y: 100 + y: 92 width: 98% - height: 356 + height: 372 bg_opa: TRANSP border_width: 0 scrollable: false + clickable: false pad_all: 4 layout: type: FLEX + pad_row: 6 + pad_column: 6 flex_flow: ROW_WRAP flex_align_main: CENTER flex_align_cross: CENTER @@ -4058,12 +4135,13 @@ lvgl: widgets: - obj: id: cmode_off - width: 198 - height: 76 - radius: 28 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 136 + height: 58 + radius: 22 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4072,18 +4150,19 @@ lvgl: widgets: - label: align: CENTER - text: "Off" + text: "Uit" text_font: sublabel_big text_color: 0x1B1B1B clickable: false - obj: id: cmode_heat - width: 198 - height: 76 - radius: 28 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 136 + height: 58 + radius: 22 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4092,18 +4171,19 @@ lvgl: widgets: - label: align: CENTER - text: "Heat" + text: "Verwarmen" text_font: sublabel_big text_color: 0x1B1B1B clickable: false - obj: id: cmode_cool - width: 198 - height: 76 - radius: 28 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 136 + height: 58 + radius: 22 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4112,18 +4192,19 @@ lvgl: widgets: - label: align: CENTER - text: "Cool" + text: "Koelen" text_font: sublabel_big text_color: 0x1B1B1B clickable: false - obj: id: cmode_heat_cool - width: 198 - height: 76 - radius: 28 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 136 + height: 58 + radius: 22 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4132,18 +4213,19 @@ lvgl: widgets: - label: align: CENTER - text: "Heat/Cool" + text: "Verw./koelen" text_font: sublabel_big text_color: 0x1B1B1B clickable: false - obj: id: cmode_auto - width: 198 - height: 76 - radius: 28 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 136 + height: 58 + radius: 22 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4158,12 +4240,13 @@ lvgl: clickable: false - obj: id: cmode_dry - width: 198 - height: 76 - radius: 28 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 136 + height: 58 + radius: 22 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4172,18 +4255,19 @@ lvgl: widgets: - label: align: CENTER - text: "Dry" + text: "Drogen" text_font: sublabel_big text_color: 0x1B1B1B clickable: false - obj: id: cmode_fan_only - width: 198 - height: 76 - radius: 28 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 136 + height: 58 + radius: 22 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4192,11 +4276,205 @@ lvgl: widgets: - label: align: CENTER - text: "Fan" + text: "Ventileren" text_font: sublabel_big text_color: 0x1B1B1B clickable: false + - label: + id: cfan_title + width: 100% + height: 24 + text: "Ventilator" + text_font: sublabel + text_color: 0x6B6B6B + text_align: CENTER + clickable: false + - obj: + id: cfan_0 + width: 98 + height: 48 + radius: 18 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_fan_mode, index: 0 } + widgets: + - label: + id: cfan_0_label + align: CENTER + text: "-" + text_font: sublabel_big + text_color: 0x1B1B1B + clickable: false + - obj: + id: cfan_1 + width: 98 + height: 48 + radius: 18 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_fan_mode, index: 1 } + widgets: + - label: + id: cfan_1_label + align: CENTER + text: "-" + text_font: sublabel_big + text_color: 0x1B1B1B + clickable: false + - obj: + id: cfan_2 + width: 98 + height: 48 + radius: 18 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_fan_mode, index: 2 } + widgets: + - label: + id: cfan_2_label + align: CENTER + text: "-" + text_font: sublabel_big + text_color: 0x1B1B1B + clickable: false + - obj: + id: cfan_3 + width: 98 + height: 48 + radius: 18 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_fan_mode, index: 3 } + widgets: + - label: + id: cfan_3_label + align: CENTER + text: "-" + text_font: sublabel_big + text_color: 0x1B1B1B + clickable: false + - label: + id: cswing_title + width: 100% + height: 24 + text: "Zwenken" + text_font: sublabel + text_color: 0x6B6B6B + text_align: CENTER + clickable: false + - obj: + id: cswing_0 + width: 98 + height: 48 + radius: 18 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_swing_mode, index: 0 } + widgets: + - label: + id: cswing_0_label + align: CENTER + text: "-" + text_font: sublabel_big + text_color: 0x1B1B1B + clickable: false + - obj: + id: cswing_1 + width: 98 + height: 48 + radius: 18 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_swing_mode, index: 1 } + widgets: + - label: + id: cswing_1_label + align: CENTER + text: "-" + text_font: sublabel_big + text_color: 0x1B1B1B + clickable: false + - obj: + id: cswing_2 + width: 98 + height: 48 + radius: 18 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_swing_mode, index: 2 } + widgets: + - label: + id: cswing_2_label + align: CENTER + text: "-" + text_font: sublabel_big + text_color: 0x1B1B1B + clickable: false + - obj: + id: cswing_3 + width: 98 + height: 48 + radius: 18 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_swing_mode, index: 3 } + widgets: + - label: + id: cswing_3_label + align: CENTER + text: "-" + text_font: sublabel_big + text_color: 0x1B1B1B + clickable: false - obj: id: climate_mode_overlay_x align: TOP_LEFT @@ -4205,11 +4483,11 @@ lvgl: width: 54 height: 54 radius: 999 - bg_color: 0xEEEEEE + bg_color: 0xFFFFFF bg_opa: COVER border_width: 1 - border_color: 0xFFFFFF - border_opa: 20% + border_color: 0xDDDDDD + border_opa: COVER clickable: true scrollable: false on_click: @@ -4222,28 +4500,6 @@ lvgl: text_color: 0x1B1B1B clickable: false - - obj: - id: climate_mode_overlay_ok - align: TOP_RIGHT - x: -15 - y: 18 - width: 54 - height: 54 - radius: 999 - bg_color: 0xD5EEFC - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - lvgl.widget.hide: climate_mode_overlay - widgets: - - label: - align: CENTER - text: "\U000F012C" - text_font: materialdesign_icons - text_color: 0x1B1B1B - clickable: false # ---------- VACUUM CONTROL ---------- # Een stofzuiger is geen schakelaar: start, pauze, stop en terug naar @@ -4663,7 +4919,7 @@ script: static unsigned test_page=0; id(tile_page) = runtime_tiles::enabled ? test_page++ % runtime_tiles::page_count() : 1; - script.execute: show_tile_page - - delay: 150ms + - delay: 300ms - 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); @@ -4960,8 +5216,8 @@ script: if (has(m)) lv_obj_clear_flag(btn, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN); bool selected = (current == std::string(m)); - lv_obj_set_style_bg_color(btn, - lv_color_hex(selected ? 0xD5EEFC : 0xF0F0F0), LV_PART_MAIN); + lv_obj_set_style_bg_color(btn, lv_color_hex(selected ? 0x00A6ED : 0xFFFFFF), LV_PART_MAIN); + lv_obj_set_style_text_color(lv_obj_get_child(btn, 0), lv_color_hex(selected ? 0xFFFFFF : 0x1B1B1B), LV_PART_MAIN); }; apply(id(cmode_off), "off"); apply(id(cmode_heat), "heat"); @@ -4970,10 +5226,109 @@ script: apply(id(cmode_auto), "auto"); apply(id(cmode_dry), "dry"); apply(id(cmode_fan_only), "fan_only"); + // Fan and swing rows: the entity's own mode names, at most four each, as Home Assistant reports them. + auto pretty = [](std::string raw) { if (!raw.empty() && raw[0] >= 'a' && raw[0] <= 'z') raw[0] = (char)(raw[0] - 'a' + 'A'); return raw; }; + auto fill = [&](lv_obj_t *title, lv_obj_t *chips[4], const std::string &modes, const std::string ¤t_mode) { + unsigned shown = 0; + for (unsigned i = 0; i < 4; ++i) { + std::string raw = cyd::list_item(modes, i); + if (raw.empty()) { lv_obj_add_flag(chips[i], LV_OBJ_FLAG_HIDDEN); continue; } + lv_obj_clear_flag(chips[i], LV_OBJ_FLAG_HIDDEN); ++shown; + lv_label_set_text(lv_obj_get_child(chips[i], 0), pretty(raw).c_str()); + lv_obj_set_style_bg_color(chips[i], lv_color_hex(raw == current_mode ? 0x00A6ED : 0xFFFFFF), LV_PART_MAIN); + lv_obj_set_style_text_color(lv_obj_get_child(chips[i], 0), lv_color_hex(raw == current_mode ? 0xFFFFFF : 0x1B1B1B), LV_PART_MAIN); + } + if (shown) lv_obj_clear_flag(title, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(title, LV_OBJ_FLAG_HIDDEN); + }; + lv_obj_t *fan_chips[4] = {id(cfan_0), id(cfan_1), id(cfan_2), id(cfan_3)}; + lv_obj_t *swing_chips[4] = {id(cswing_0), id(cswing_1), id(cswing_2), id(cswing_3)}; + fill(id(cfan_title), fan_chips, id(active_climate_fan_modes), id(active_climate_fan_mode)); + fill(id(cswing_title), swing_chips, id(active_climate_swing_modes), id(active_climate_swing_mode)); - lvgl.widget.show: climate_mode_overlay # Zet precies een modus. Geen tussenstappen, dus geen ongewenste # commando's naar de warmtepomp onderweg. + # Fan and swing modes from the picker: one call with the entity's own mode name. + - id: set_climate_fan_mode + mode: restart + parameters: + index: int + then: + - lambda: |- + id(active_climate_fan_mode) = cyd::list_item(id(active_climate_fan_modes), index); + id(climate_edit_ms) = millis(); + id(climate_edit_pending) = true; + - lvgl.widget.hide: climate_mode_overlay + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && !id(active_climate_fan_mode).empty();' + then: + - homeassistant.action: + action: climate.set_fan_mode + data: + entity_id: !lambda "return id(active_entity);" + fan_mode: !lambda "return id(active_climate_fan_mode);" + - id: set_climate_swing_mode + mode: restart + parameters: + index: int + then: + - lambda: |- + id(active_climate_swing_mode) = cyd::list_item(id(active_climate_swing_modes), index); + id(climate_edit_ms) = millis(); + id(climate_edit_pending) = true; + - lvgl.widget.hide: climate_mode_overlay + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && !id(active_climate_swing_mode).empty();' + then: + - homeassistant.action: + action: climate.set_swing_mode + data: + entity_id: !lambda "return id(active_entity);" + swing_mode: !lambda "return id(active_climate_swing_mode);" + + # Quick on/off in the climate card; climate.turn_on restores the last mode in Home Assistant. + - id: climate_power_toggle + mode: restart + then: + - lambda: |- + std::string mode = id(active_climate_mode); + for (char &c : mode) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); + id(active_climate_power_on) = mode == "off" || mode.empty() || mode == "unknown"; + id(climate_edit_ms) = millis(); + id(climate_edit_pending) = true; + if (!id(active_climate_power_on)) { id(active_climate_mode) = "off"; lv_label_set_text(id(climate_mode_label), "Off"); } + id(climate_power_refresh).execute(); + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && id(active_climate_power_on);' + then: + - homeassistant.action: + action: climate.turn_on + data: + entity_id: !lambda "return id(active_entity);" + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && !id(active_climate_power_on);' + then: + - homeassistant.action: + action: climate.turn_off + data: + entity_id: !lambda "return id(active_entity);" + + # The power key lights up while the device runs in any mode. + - id: climate_power_refresh + mode: restart + then: + - lambda: |- + std::string mode = id(active_climate_mode); + for (char &c : mode) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); + bool on = !(mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable"); + if (id(climate_edit_pending)) on = id(active_climate_power_on); + lv_obj_set_style_bg_color(id(climate_power_button), lv_color_hex(on ? 0xD5EEFC : 0xEEEEEE), LV_PART_MAIN); + lv_obj_set_style_text_color(id(climate_power_icon), lv_color_hex(on ? 0x0B6E99 : 0x8A8A8A), LV_PART_MAIN); + - id: set_climate_mode mode: restart parameters: @@ -4983,13 +5338,9 @@ script: id(climate_edit_ms) = millis(); id(climate_edit_pending) = true; id(active_climate_mode) = mode; - if (mode == "off") lv_label_set_text(id(climate_mode_label), "Off"); - if (mode == "heat") lv_label_set_text(id(climate_mode_label), "Heat"); - if (mode == "cool") lv_label_set_text(id(climate_mode_label), "Cool"); - if (mode == "heat_cool") lv_label_set_text(id(climate_mode_label), "Heat/Cool"); - if (mode == "auto") lv_label_set_text(id(climate_mode_label), "Auto"); - if (mode == "dry") lv_label_set_text(id(climate_mode_label), "Dry"); - if (mode == "fan_only") lv_label_set_text(id(climate_mode_label), "Fan"); + id(active_climate_power_on) = mode != "off"; + id(climate_power_refresh).execute(); + lv_label_set_text(id(climate_mode_label), tile_controls::climate_mode_text(mode)); - lvgl.widget.hide: climate_mode_overlay - if: condition: @@ -5561,18 +5912,19 @@ script: char current_buf[48]; if (isnan(climate_current_temperature)) { - snprintf(current_buf, sizeof(current_buf), "Current: --"); + snprintf(current_buf, sizeof(current_buf), "Nu: --"); } else if (id(active_climate_humidity) >= 0) { - snprintf(current_buf, sizeof(current_buf), "Current: %.1f° - Humidity: %d %%", climate_current_temperature, id(active_climate_humidity)); + snprintf(current_buf, sizeof(current_buf), "Nu: %.1f° · %d%%", climate_current_temperature, id(active_climate_humidity)); } else { - snprintf(current_buf, sizeof(current_buf), "Current: %.1f°", climate_current_temperature); + snprintf(current_buf, sizeof(current_buf), "Nu: %.1f°", climate_current_temperature); } lv_label_set_text(id(climate_current_label), current_buf); char target_buf[16]; snprintf(target_buf, sizeof(target_buf), "%.1f°", id(active_climate_target_x10) / 10.0f); lv_label_set_text(id(climate_target_label), target_buf); - lv_label_set_text(id(climate_mode_label), id(active_climate_mode).c_str()); + lv_label_set_text(id(climate_mode_label), tile_controls::climate_mode_text(id(active_climate_mode))); + id(climate_power_refresh).execute(); lv_arc_set_range(id(climate_arc), id(active_climate_min_x10), id(active_climate_max_x10)); lv_arc_set_value(id(climate_arc), id(active_climate_target_x10)); @@ -5968,11 +6320,11 @@ script: char current_buf[48]; if (isnan(current_temperature)) { - snprintf(current_buf, sizeof(current_buf), "Current: --"); + snprintf(current_buf, sizeof(current_buf), "Nu: --"); } else if (id(active_climate_humidity) >= 0) { - snprintf(current_buf, sizeof(current_buf), "Current: %.1f° - Humidity: %d %%", current_temperature, id(active_climate_humidity)); + snprintf(current_buf, sizeof(current_buf), "Nu: %.1f° · %d%%", current_temperature, id(active_climate_humidity)); } else { - snprintf(current_buf, sizeof(current_buf), "Current: %.1f°", current_temperature); + snprintf(current_buf, sizeof(current_buf), "Nu: %.1f°", current_temperature); } lv_label_set_text(id(climate_current_label), current_buf); diff --git a/home-like-2432s028.yaml b/home-like-2432s028.yaml index 3249217..3cef6b2 100644 --- a/home-like-2432s028.yaml +++ b/home-like-2432s028.yaml @@ -58,7 +58,7 @@ ############################################################ substitutions: - SCREEN_FIRMWARE_VERSION: "0.2.18" + SCREEN_FIRMWARE_VERSION: "0.2.19" DYNAMIC_TILES: "false" DASHBOARD_INBOX_INTERNAL: "true" DEVICE_NAME: "cyd-new" @@ -797,13 +797,13 @@ esphome: settings.night_start = atoi("${NIGHT_START_HOUR}") * 60; settings.night_end = atoi("${NIGHT_END_HOUR}") * 60; settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}"); - runtime_tiles::light_theme = true; 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::control_font = id(sublabel_big)->get_lv_font(); runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true"; if (!runtime_tiles::enabled) return; runtime_tiles::load_settings(); @@ -857,6 +857,8 @@ esphome: light_controls::fallback.kelvin = t.kelvin; light_controls::fallback.minimum = t.min_kelvin; light_controls::fallback.maximum = t.max_kelvin; + id(active_climate_fan_modes) = t.fan_modes; id(active_climate_fan_mode) = t.fan_mode; + id(active_climate_swing_modes) = t.swing_modes; id(active_climate_swing_mode) = t.swing_mode; id(open_value_overlay).execute(t.name, t.entity, t.domain(), "auto", t.active(), 0, t.brightness, t.percentage, t.position, runtime_tiles::icon_for(t), 0x565C66, 0xE4B23C, 0xFFFFFF, "Uit", "Aan", t.modes, @@ -870,7 +872,10 @@ esphome: id(climate_edit_pending) = false; id(active_climate_mode) = t.state; id(active_climate_supported_modes) = t.hvac_modes; - lv_label_set_text(id(climate_mode_label), t.state.c_str()); + lv_label_set_text(id(climate_mode_label), tile_controls::climate_mode_text(t.state)); + id(climate_power_refresh).execute(); + id(active_climate_fan_modes) = t.fan_modes; id(active_climate_fan_mode) = t.fan_mode; + id(active_climate_swing_modes) = t.swing_modes; id(active_climate_swing_mode) = t.swing_mode; char buf[72]; if (std::isfinite(t.current)) snprintf(buf, sizeof(buf), "Nu: %.1f°", t.current); else snprintf(buf, sizeof(buf), "Nu: --"); @@ -887,7 +892,7 @@ esphome: - lambda: |- static const char *entities[] = {"${TILE1_ENTITY}", "${TILE2_ENTITY}", "${TILE3_ENTITY}", "${TILE4_ENTITY}", "${TILE5_ENTITY}", "${TILE6_ENTITY}", "${TILE7_ENTITY}", "${TILE9_ENTITY}"}; if (!runtime_tiles::enabled) light_controls::subscribe(entities, 8); - light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}"), true); + light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}")); light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); }; light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); }; light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); }; @@ -982,11 +987,32 @@ api: index: int then: - lambda: |- - if (index < 0) { runtime_tiles::hide_detail(); return; } + if (index < 0) { runtime_tiles::hide_detail(); if (runtime_tiles::dismiss) runtime_tiles::dismiss(); return; } if (runtime_tiles::enabled && index < (int)runtime_tiles::model.count) { runtime_tiles::active_index = index; + auto &tile = runtime_tiles::model.tiles[index]; + auto domain = tile.domain(); + // Lights, fans, covers and climate open the board's own card, like a long press does. + if ((domain == "light" || domain == "fan" || domain == "cover" || domain == "climate") && runtime_tiles::detail) { + tile.begin(millis(), true); + runtime_tiles::detail(tile); + return; + } runtime_tiles::show_detail(index); } + # Diagnostics: the climate mode picker and the busy sheet of a tile, without touching the glass or HA. + - action: preview_climate_picker + then: + - script.execute: open_climate_mode_picker + - action: preview_busy + variables: + index: int + then: + - lambda: |- + if (runtime_tiles::enabled && index >= 0 && index < (int)runtime_tiles::model.count) { + runtime_tiles::model.tiles[index].begin(millis()); + if (runtime_tiles::refresh) runtime_tiles::refresh(); + } - action: light_controls_preview then: - script.execute: light_controls_preview @@ -1169,6 +1195,27 @@ globals: type: std::string restore_value: no + - id: active_climate_power_on + type: bool + restore_value: no + initial_value: 'false' + + - id: active_climate_fan_modes + type: std::string + restore_value: no + + - id: active_climate_fan_mode + type: std::string + restore_value: no + + - id: active_climate_swing_modes + type: std::string + restore_value: no + + - id: active_climate_swing_mode + type: std::string + restore_value: no + - id: active_climate_supported_modes type: std::string restore_value: no @@ -1188,7 +1235,7 @@ font: '0','1','2','3','4','5','6','7','8','9', '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', ' ','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', - 'ä','ö','ü','Ä','Ö','Ü','ß','/'] + 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] - file: "fonts/Roboto-500.ttf" id: watch_value @@ -1198,7 +1245,7 @@ font: '0','1','2','3','4','5','6','7','8','9', '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', ' ','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" @@ -1215,7 +1262,7 @@ font: '0','1','2','3','4','5','6','7','8','9', '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', ' ','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', - 'ä','ö','ü','Ä','Ö','Ü','ß','/'] + 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] - file: "fonts/Roboto-400.ttf" id: sublabel @@ -1225,7 +1272,7 @@ font: '0','1','2','3','4','5','6','7','8','9', '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', ' ','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', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','…'] + 'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·'] - file: "fonts/Roboto-400.ttf" id: sublabel_big @@ -1235,7 +1282,7 @@ font: '0','1','2','3','4','5','6','7','8','9', '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', ' ','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', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','…'] + 'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·'] # Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0) # Source: https://github.com/Templarian/MaterialDesign-Webfont @@ -1395,6 +1442,24 @@ font: - "\U000F00E2" # broom - "\U000F05D6" # alert-circle-outline - "\U000F012C" # check + - "\U000F03E4" # pause + - "\U000F04DB" # stop + - "\U000F04AD" # skip-next + - "\U000F04AE" # skip-previous + - "\U000F057E" # volume-high + - "\U000F0581" # volume-off + - "\U000F005D" # arrow-up + - "\U000F0045" # arrow-down + - "\U000F084E" # arrow-expand-horizontal + - "\U000F084C" # arrow-collapse-horizontal + - "\U000F05F8" # home-map-marker + - "\U000F0415" # plus + - "\U000F0374" # minus + - "\U000F0141" # chevron-left + - "\U000F0142" # chevron-right + - "\U000F0156" # close + - "\U000F1A79" # sun-snowflake-variant + - "\U000F1B17" # thermostat-auto - "\U000F0591" # weather-fog - "\U000F0592" # weather-hail - "\U000F0593" # weather-lightning @@ -2196,6 +2261,14 @@ lvgl: scrollable: false scrollbar_mode: 'OFF' widgets: + # Never shown: it only makes ESPHome compile LVGL's spinner, which the busy sheet on a tile draws. + - spinner: + id: busy_spinner_seed + hidden: true + width: 2 + height: 2 + spin_time: 1s + arc_length: 60deg - label: id: lbl_room x: 11 @@ -3895,7 +3968,7 @@ lvgl: width: 36 height: 36 radius: 999 - bg_color: 0xFF9F0A + bg_color: 0xEEEEEE bg_opa: COVER border_width: 0 clickable: true @@ -3977,25 +4050,26 @@ lvgl: clickable: false - obj: - id: climate_done_button + id: climate_power_button align: TOP_RIGHT x: -10 y: 9 width: 44 height: 44 radius: 999 - bg_color: 0xD5EEFC + bg_color: 0xEEEEEE bg_opa: COVER border_width: 0 clickable: true scrollable: false on_click: - - lvgl.widget.hide: climate_detail_overlay + - script.execute: climate_power_toggle widgets: - label: + id: climate_power_icon align: CENTER - text: "\U000F012C" - text_font: materialdesign_icons + text: "\U000F0425" + text_font: materialdesign_icons_mini text_color: 0x1B1B1B clickable: false @@ -4010,7 +4084,7 @@ lvgl: id: climate_current_label align: TOP_MID y: 42 - text: "Current: --" + text: "Nu: --" text_font: sublabel_big text_color: 0x1B1B1B text_opa: 85% @@ -4061,7 +4135,7 @@ lvgl: id: climate_set_label align: CENTER y: -16 - text: "SET TO" + text: "Gewenst" text_font: sublabel_big text_color: 0x1B1B1B text_opa: 68% @@ -4164,8 +4238,8 @@ lvgl: width: ${DISPLAY_W} height: ${DISPLAY_H} hidden: true - bg_color: 0xFFFFFF - bg_opa: 90% + bg_color: 0xE7E7E7 + bg_opa: COVER border_width: 0 clickable: true scrollable: false @@ -4174,7 +4248,7 @@ lvgl: - label: align: TOP_MID y: 16 - text: "Mode" + text: "Modus" text_font: sublabel_big text_color: 0x1B1B1B clickable: false @@ -4184,15 +4258,18 @@ lvgl: - obj: id: climate_mode_list align: TOP_MID - y: 50 + y: 44 width: 98% height: 178 bg_opa: TRANSP border_width: 0 scrollable: false + clickable: false pad_all: 3 layout: type: FLEX + pad_row: 3 + pad_column: 4 flex_flow: ROW_WRAP flex_align_main: CENTER flex_align_cross: CENTER @@ -4200,12 +4277,13 @@ lvgl: widgets: - obj: id: cmode_off - width: 132 - height: 38 - radius: 19 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 88 + height: 26 + radius: 13 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4214,18 +4292,19 @@ lvgl: widgets: - label: align: CENTER - text: "Off" + text: "Uit" text_font: sublabel_big text_color: 0x1B1B1B clickable: false - obj: id: cmode_heat - width: 132 - height: 38 - radius: 19 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 88 + height: 26 + radius: 13 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4234,18 +4313,19 @@ lvgl: widgets: - label: align: CENTER - text: "Heat" + text: "Verwarmen" text_font: sublabel_big text_color: 0x1B1B1B clickable: false - obj: id: cmode_cool - width: 132 - height: 38 - radius: 19 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 88 + height: 26 + radius: 13 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4254,18 +4334,19 @@ lvgl: widgets: - label: align: CENTER - text: "Cool" + text: "Koelen" text_font: sublabel_big text_color: 0x1B1B1B clickable: false - obj: id: cmode_heat_cool - width: 132 - height: 38 - radius: 19 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 88 + height: 26 + radius: 13 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4274,18 +4355,19 @@ lvgl: widgets: - label: align: CENTER - text: "Heat/Cool" + text: "Verw./koelen" text_font: sublabel_big text_color: 0x1B1B1B clickable: false - obj: id: cmode_auto - width: 132 - height: 38 - radius: 19 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 88 + height: 26 + radius: 13 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4300,12 +4382,13 @@ lvgl: clickable: false - obj: id: cmode_dry - width: 132 - height: 38 - radius: 19 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 88 + height: 26 + radius: 13 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4314,18 +4397,19 @@ lvgl: widgets: - label: align: CENTER - text: "Dry" + text: "Drogen" text_font: sublabel_big text_color: 0x1B1B1B clickable: false - obj: id: cmode_fan_only - width: 132 - height: 38 - radius: 19 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 88 + height: 26 + radius: 13 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4334,11 +4418,205 @@ lvgl: widgets: - label: align: CENTER - text: "Fan" + text: "Ventileren" text_font: sublabel_big text_color: 0x1B1B1B clickable: false + - label: + id: cfan_title + width: 100% + height: 11 + text: "Ventilator" + text_font: sublabel + text_color: 0x6B6B6B + text_align: CENTER + clickable: false + - obj: + id: cfan_0 + width: 62 + height: 22 + radius: 11 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_fan_mode, index: 0 } + widgets: + - label: + id: cfan_0_label + align: CENTER + text: "-" + text_font: sublabel + text_color: 0x1B1B1B + clickable: false + - obj: + id: cfan_1 + width: 62 + height: 22 + radius: 11 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_fan_mode, index: 1 } + widgets: + - label: + id: cfan_1_label + align: CENTER + text: "-" + text_font: sublabel + text_color: 0x1B1B1B + clickable: false + - obj: + id: cfan_2 + width: 62 + height: 22 + radius: 11 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_fan_mode, index: 2 } + widgets: + - label: + id: cfan_2_label + align: CENTER + text: "-" + text_font: sublabel + text_color: 0x1B1B1B + clickable: false + - obj: + id: cfan_3 + width: 62 + height: 22 + radius: 11 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_fan_mode, index: 3 } + widgets: + - label: + id: cfan_3_label + align: CENTER + text: "-" + text_font: sublabel + text_color: 0x1B1B1B + clickable: false + - label: + id: cswing_title + width: 100% + height: 11 + text: "Zwenken" + text_font: sublabel + text_color: 0x6B6B6B + text_align: CENTER + clickable: false + - obj: + id: cswing_0 + width: 62 + height: 22 + radius: 11 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_swing_mode, index: 0 } + widgets: + - label: + id: cswing_0_label + align: CENTER + text: "-" + text_font: sublabel + text_color: 0x1B1B1B + clickable: false + - obj: + id: cswing_1 + width: 62 + height: 22 + radius: 11 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_swing_mode, index: 1 } + widgets: + - label: + id: cswing_1_label + align: CENTER + text: "-" + text_font: sublabel + text_color: 0x1B1B1B + clickable: false + - obj: + id: cswing_2 + width: 62 + height: 22 + radius: 11 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_swing_mode, index: 2 } + widgets: + - label: + id: cswing_2_label + align: CENTER + text: "-" + text_font: sublabel + text_color: 0x1B1B1B + clickable: false + - obj: + id: cswing_3 + width: 62 + height: 22 + radius: 11 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_swing_mode, index: 3 } + widgets: + - label: + id: cswing_3_label + align: CENTER + text: "-" + text_font: sublabel + text_color: 0x1B1B1B + clickable: false - obj: id: climate_mode_overlay_x align: TOP_LEFT @@ -4347,11 +4625,11 @@ lvgl: width: 36 height: 36 radius: 999 - bg_color: 0xEEEEEE + bg_color: 0xFFFFFF bg_opa: COVER border_width: 1 - border_color: 0xFFFFFF - border_opa: 20% + border_color: 0xDDDDDD + border_opa: COVER clickable: true scrollable: false on_click: @@ -4364,28 +4642,6 @@ lvgl: text_color: 0x1B1B1B clickable: false - - obj: - id: climate_mode_overlay_ok - align: TOP_RIGHT - x: -10 - y: 9 - width: 36 - height: 36 - radius: 999 - bg_color: 0xD5EEFC - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - lvgl.widget.hide: climate_mode_overlay - widgets: - - label: - align: CENTER - text: "\U000F012C" - text_font: materialdesign_icons - text_color: 0x1B1B1B - clickable: false # ---------- VACUUM CONTROL ---------- # Een stofzuiger is geen schakelaar: start, pauze, stop en terug naar @@ -4796,7 +5052,7 @@ script: static unsigned test_page=0; id(tile_page) = runtime_tiles::enabled ? test_page++ % runtime_tiles::page_count() : 1; - script.execute: show_tile_page - - delay: 150ms + - delay: 300ms - 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); @@ -5070,8 +5326,8 @@ script: if (has(m)) lv_obj_clear_flag(btn, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN); bool selected = (current == std::string(m)); - lv_obj_set_style_bg_color(btn, - lv_color_hex(selected ? 0xFF9F0A : 0x2C2C30), LV_PART_MAIN); + lv_obj_set_style_bg_color(btn, lv_color_hex(selected ? 0x00A6ED : 0xFFFFFF), LV_PART_MAIN); + lv_obj_set_style_text_color(lv_obj_get_child(btn, 0), lv_color_hex(selected ? 0xFFFFFF : 0x1B1B1B), LV_PART_MAIN); }; apply(id(cmode_off), "off"); apply(id(cmode_heat), "heat"); @@ -5080,10 +5336,109 @@ script: apply(id(cmode_auto), "auto"); apply(id(cmode_dry), "dry"); apply(id(cmode_fan_only), "fan_only"); + // Fan and swing rows: the entity's own mode names, at most four each, as Home Assistant reports them. + auto pretty = [](std::string raw) { if (!raw.empty() && raw[0] >= 'a' && raw[0] <= 'z') raw[0] = (char)(raw[0] - 'a' + 'A'); return raw; }; + auto fill = [&](lv_obj_t *title, lv_obj_t *chips[4], const std::string &modes, const std::string ¤t_mode) { + unsigned shown = 0; + for (unsigned i = 0; i < 4; ++i) { + std::string raw = cyd::list_item(modes, i); + if (raw.empty()) { lv_obj_add_flag(chips[i], LV_OBJ_FLAG_HIDDEN); continue; } + lv_obj_clear_flag(chips[i], LV_OBJ_FLAG_HIDDEN); ++shown; + lv_label_set_text(lv_obj_get_child(chips[i], 0), pretty(raw).c_str()); + lv_obj_set_style_bg_color(chips[i], lv_color_hex(raw == current_mode ? 0x00A6ED : 0xFFFFFF), LV_PART_MAIN); + lv_obj_set_style_text_color(lv_obj_get_child(chips[i], 0), lv_color_hex(raw == current_mode ? 0xFFFFFF : 0x1B1B1B), LV_PART_MAIN); + } + if (shown) lv_obj_clear_flag(title, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(title, LV_OBJ_FLAG_HIDDEN); + }; + lv_obj_t *fan_chips[4] = {id(cfan_0), id(cfan_1), id(cfan_2), id(cfan_3)}; + lv_obj_t *swing_chips[4] = {id(cswing_0), id(cswing_1), id(cswing_2), id(cswing_3)}; + fill(id(cfan_title), fan_chips, id(active_climate_fan_modes), id(active_climate_fan_mode)); + fill(id(cswing_title), swing_chips, id(active_climate_swing_modes), id(active_climate_swing_mode)); - lvgl.widget.show: climate_mode_overlay # Zet precies een modus. Geen tussenstappen, dus geen ongewenste # commando's naar de warmtepomp onderweg. + # Fan and swing modes from the picker: one call with the entity's own mode name. + - id: set_climate_fan_mode + mode: restart + parameters: + index: int + then: + - lambda: |- + id(active_climate_fan_mode) = cyd::list_item(id(active_climate_fan_modes), index); + id(climate_edit_ms) = millis(); + id(climate_edit_pending) = true; + - lvgl.widget.hide: climate_mode_overlay + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && !id(active_climate_fan_mode).empty();' + then: + - homeassistant.action: + action: climate.set_fan_mode + data: + entity_id: !lambda "return id(active_entity);" + fan_mode: !lambda "return id(active_climate_fan_mode);" + - id: set_climate_swing_mode + mode: restart + parameters: + index: int + then: + - lambda: |- + id(active_climate_swing_mode) = cyd::list_item(id(active_climate_swing_modes), index); + id(climate_edit_ms) = millis(); + id(climate_edit_pending) = true; + - lvgl.widget.hide: climate_mode_overlay + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && !id(active_climate_swing_mode).empty();' + then: + - homeassistant.action: + action: climate.set_swing_mode + data: + entity_id: !lambda "return id(active_entity);" + swing_mode: !lambda "return id(active_climate_swing_mode);" + + # Quick on/off in the climate card; climate.turn_on restores the last mode in Home Assistant. + - id: climate_power_toggle + mode: restart + then: + - lambda: |- + std::string mode = id(active_climate_mode); + for (char &c : mode) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); + id(active_climate_power_on) = mode == "off" || mode.empty() || mode == "unknown"; + id(climate_edit_ms) = millis(); + id(climate_edit_pending) = true; + if (!id(active_climate_power_on)) { id(active_climate_mode) = "off"; lv_label_set_text(id(climate_mode_label), "Off"); } + id(climate_power_refresh).execute(); + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && id(active_climate_power_on);' + then: + - homeassistant.action: + action: climate.turn_on + data: + entity_id: !lambda "return id(active_entity);" + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && !id(active_climate_power_on);' + then: + - homeassistant.action: + action: climate.turn_off + data: + entity_id: !lambda "return id(active_entity);" + + # The power key lights up while the device runs in any mode. + - id: climate_power_refresh + mode: restart + then: + - lambda: |- + std::string mode = id(active_climate_mode); + for (char &c : mode) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); + bool on = !(mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable"); + if (id(climate_edit_pending)) on = id(active_climate_power_on); + lv_obj_set_style_bg_color(id(climate_power_button), lv_color_hex(on ? 0xD5EEFC : 0xEEEEEE), LV_PART_MAIN); + lv_obj_set_style_text_color(id(climate_power_icon), lv_color_hex(on ? 0x0B6E99 : 0x8A8A8A), LV_PART_MAIN); + - id: set_climate_mode mode: restart parameters: @@ -5093,13 +5448,9 @@ script: id(climate_edit_ms) = millis(); id(climate_edit_pending) = true; id(active_climate_mode) = mode; - if (mode == "off") lv_label_set_text(id(climate_mode_label), "Off"); - if (mode == "heat") lv_label_set_text(id(climate_mode_label), "Heat"); - if (mode == "cool") lv_label_set_text(id(climate_mode_label), "Cool"); - if (mode == "heat_cool") lv_label_set_text(id(climate_mode_label), "Heat/Cool"); - if (mode == "auto") lv_label_set_text(id(climate_mode_label), "Auto"); - if (mode == "dry") lv_label_set_text(id(climate_mode_label), "Dry"); - if (mode == "fan_only") lv_label_set_text(id(climate_mode_label), "Fan"); + id(active_climate_power_on) = mode != "off"; + id(climate_power_refresh).execute(); + lv_label_set_text(id(climate_mode_label), tile_controls::climate_mode_text(mode)); - lvgl.widget.hide: climate_mode_overlay - if: condition: @@ -5671,18 +6022,19 @@ script: char current_buf[48]; if (isnan(climate_current_temperature)) { - snprintf(current_buf, sizeof(current_buf), "Current: --"); + snprintf(current_buf, sizeof(current_buf), "Nu: --"); } else if (id(active_climate_humidity) >= 0) { - snprintf(current_buf, sizeof(current_buf), "Current: %.1f° - Humidity: %d %%", climate_current_temperature, id(active_climate_humidity)); + snprintf(current_buf, sizeof(current_buf), "Nu: %.1f° · %d%%", climate_current_temperature, id(active_climate_humidity)); } else { - snprintf(current_buf, sizeof(current_buf), "Current: %.1f°", climate_current_temperature); + snprintf(current_buf, sizeof(current_buf), "Nu: %.1f°", climate_current_temperature); } lv_label_set_text(id(climate_current_label), current_buf); char target_buf[16]; snprintf(target_buf, sizeof(target_buf), "%.1f°", id(active_climate_target_x10) / 10.0f); lv_label_set_text(id(climate_target_label), target_buf); - lv_label_set_text(id(climate_mode_label), id(active_climate_mode).c_str()); + lv_label_set_text(id(climate_mode_label), tile_controls::climate_mode_text(id(active_climate_mode))); + id(climate_power_refresh).execute(); lv_arc_set_range(id(climate_arc), id(active_climate_min_x10), id(active_climate_max_x10)); lv_arc_set_value(id(climate_arc), id(active_climate_target_x10)); @@ -6078,11 +6430,11 @@ script: char current_buf[48]; if (isnan(current_temperature)) { - snprintf(current_buf, sizeof(current_buf), "Current: --"); + snprintf(current_buf, sizeof(current_buf), "Nu: --"); } else if (id(active_climate_humidity) >= 0) { - snprintf(current_buf, sizeof(current_buf), "Current: %.1f° - Humidity: %d %%", current_temperature, id(active_climate_humidity)); + snprintf(current_buf, sizeof(current_buf), "Nu: %.1f° · %d%%", current_temperature, id(active_climate_humidity)); } else { - snprintf(current_buf, sizeof(current_buf), "Current: %.1f°", current_temperature); + snprintf(current_buf, sizeof(current_buf), "Nu: %.1f°", current_temperature); } lv_label_set_text(id(climate_current_label), current_buf); diff --git a/packages/cyd.yaml b/packages/cyd.yaml index 7a202c0..994370e 100644 --- a/packages/cyd.yaml +++ b/packages/cyd.yaml @@ -59,7 +59,7 @@ ############################################################ substitutions: - SCREEN_FIRMWARE_VERSION: "0.2.18" + SCREEN_FIRMWARE_VERSION: "0.2.19" DYNAMIC_TILES: "true" DASHBOARD_INBOX_INTERNAL: "false" DEVICE_NAME: "cyd-new" @@ -801,13 +801,13 @@ esphome: settings.night_start = atoi("${NIGHT_START_HOUR}") * 60; settings.night_end = atoi("${NIGHT_END_HOUR}") * 60; settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}"); - runtime_tiles::light_theme = true; 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::control_font = id(sublabel_big)->get_lv_font(); runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true"; if (!runtime_tiles::enabled) return; runtime_tiles::load_settings(); @@ -861,6 +861,8 @@ esphome: light_controls::fallback.kelvin = t.kelvin; light_controls::fallback.minimum = t.min_kelvin; light_controls::fallback.maximum = t.max_kelvin; + id(active_climate_fan_modes) = t.fan_modes; id(active_climate_fan_mode) = t.fan_mode; + id(active_climate_swing_modes) = t.swing_modes; id(active_climate_swing_mode) = t.swing_mode; id(open_value_overlay).execute(t.name, t.entity, t.domain(), "auto", t.active(), 0, t.brightness, t.percentage, t.position, runtime_tiles::icon_for(t), 0x565C66, 0xE4B23C, 0xFFFFFF, "Uit", "Aan", t.modes, @@ -874,7 +876,10 @@ esphome: id(climate_edit_pending) = false; id(active_climate_mode) = t.state; id(active_climate_supported_modes) = t.hvac_modes; - lv_label_set_text(id(climate_mode_label), t.state.c_str()); + lv_label_set_text(id(climate_mode_label), tile_controls::climate_mode_text(t.state)); + id(climate_power_refresh).execute(); + id(active_climate_fan_modes) = t.fan_modes; id(active_climate_fan_mode) = t.fan_mode; + id(active_climate_swing_modes) = t.swing_modes; id(active_climate_swing_mode) = t.swing_mode; char buf[72]; if (std::isfinite(t.current)) snprintf(buf, sizeof(buf), "Nu: %.1f°", t.current); else snprintf(buf, sizeof(buf), "Nu: --"); @@ -891,7 +896,7 @@ esphome: - lambda: |- static const char *entities[] = {"${TILE1_ENTITY}", "${TILE2_ENTITY}", "${TILE3_ENTITY}", "${TILE4_ENTITY}", "${TILE5_ENTITY}", "${TILE6_ENTITY}", "${TILE7_ENTITY}", "${TILE9_ENTITY}"}; if (!runtime_tiles::enabled) light_controls::subscribe(entities, 8); - light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}"), true); + light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}")); light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); }; light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); }; light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); }; @@ -986,11 +991,32 @@ api: index: int then: - lambda: |- - if (index < 0) { runtime_tiles::hide_detail(); return; } + if (index < 0) { runtime_tiles::hide_detail(); if (runtime_tiles::dismiss) runtime_tiles::dismiss(); return; } if (runtime_tiles::enabled && index < (int)runtime_tiles::model.count) { runtime_tiles::active_index = index; + auto &tile = runtime_tiles::model.tiles[index]; + auto domain = tile.domain(); + // Lights, fans, covers and climate open the board's own card, like a long press does. + if ((domain == "light" || domain == "fan" || domain == "cover" || domain == "climate") && runtime_tiles::detail) { + tile.begin(millis(), true); + runtime_tiles::detail(tile); + return; + } runtime_tiles::show_detail(index); } + # Diagnostics: the climate mode picker and the busy sheet of a tile, without touching the glass or HA. + - action: preview_climate_picker + then: + - script.execute: open_climate_mode_picker + - action: preview_busy + variables: + index: int + then: + - lambda: |- + if (runtime_tiles::enabled && index >= 0 && index < (int)runtime_tiles::model.count) { + runtime_tiles::model.tiles[index].begin(millis()); + if (runtime_tiles::refresh) runtime_tiles::refresh(); + } - action: light_controls_preview then: - script.execute: light_controls_preview @@ -1160,6 +1186,27 @@ globals: type: std::string restore_value: no + - id: active_climate_power_on + type: bool + restore_value: no + initial_value: 'false' + + - id: active_climate_fan_modes + type: std::string + restore_value: no + + - id: active_climate_fan_mode + type: std::string + restore_value: no + + - id: active_climate_swing_modes + type: std::string + restore_value: no + + - id: active_climate_swing_mode + type: std::string + restore_value: no + - id: active_climate_supported_modes type: std::string restore_value: no @@ -1179,7 +1226,7 @@ font: '0','1','2','3','4','5','6','7','8','9', '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', ' ','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', - 'ä','ö','ü','Ä','Ö','Ü','ß','/'] + 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-500.ttf" id: watch_value @@ -1189,7 +1236,7 @@ font: '0','1','2','3','4','5','6','7','8','9', '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', ' ','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" @@ -1206,7 +1253,7 @@ font: '0','1','2','3','4','5','6','7','8','9', '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', ' ','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', - 'ä','ö','ü','Ä','Ö','Ü','ß','/'] + 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-400.ttf" id: sublabel @@ -1216,7 +1263,7 @@ font: '0','1','2','3','4','5','6','7','8','9', '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', ' ','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', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','…'] + 'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·'] - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-400.ttf" id: sublabel_big @@ -1226,7 +1273,7 @@ font: '0','1','2','3','4','5','6','7','8','9', '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', ' ','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', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','…'] + 'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·'] # Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0) # Source: https://github.com/Templarian/MaterialDesign-Webfont @@ -1386,6 +1433,24 @@ font: - "\U000F00E2" # broom - "\U000F05D6" # alert-circle-outline - "\U000F012C" # check + - "\U000F03E4" # pause + - "\U000F04DB" # stop + - "\U000F04AD" # skip-next + - "\U000F04AE" # skip-previous + - "\U000F057E" # volume-high + - "\U000F0581" # volume-off + - "\U000F005D" # arrow-up + - "\U000F0045" # arrow-down + - "\U000F084E" # arrow-expand-horizontal + - "\U000F084C" # arrow-collapse-horizontal + - "\U000F05F8" # home-map-marker + - "\U000F0415" # plus + - "\U000F0374" # minus + - "\U000F0141" # chevron-left + - "\U000F0142" # chevron-right + - "\U000F0156" # close + - "\U000F1A79" # sun-snowflake-variant + - "\U000F1B17" # thermostat-auto - "\U000F0591" # weather-fog - "\U000F0592" # weather-hail - "\U000F0593" # weather-lightning @@ -2129,6 +2194,14 @@ lvgl: scrollable: false scrollbar_mode: 'OFF' widgets: + # Never shown: it only makes ESPHome compile LVGL's spinner, which the busy sheet on a tile draws. + - spinner: + id: busy_spinner_seed + hidden: true + width: 2 + height: 2 + spin_time: 1s + arc_length: 60deg - label: id: lbl_room x: 11 @@ -3828,7 +3901,7 @@ lvgl: width: 36 height: 36 radius: 999 - bg_color: 0xFF9F0A + bg_color: 0xEEEEEE bg_opa: COVER border_width: 0 clickable: true @@ -3910,25 +3983,26 @@ lvgl: clickable: false - obj: - id: climate_done_button + id: climate_power_button align: TOP_RIGHT x: -10 y: 9 width: 44 height: 44 radius: 999 - bg_color: 0xD5EEFC + bg_color: 0xEEEEEE bg_opa: COVER border_width: 0 clickable: true scrollable: false on_click: - - lvgl.widget.hide: climate_detail_overlay + - script.execute: climate_power_toggle widgets: - label: + id: climate_power_icon align: CENTER - text: "\U000F012C" - text_font: materialdesign_icons + text: "\U000F0425" + text_font: materialdesign_icons_mini text_color: 0x1B1B1B clickable: false @@ -3943,7 +4017,7 @@ lvgl: id: climate_current_label align: TOP_MID y: 42 - text: "Current: --" + text: "Nu: --" text_font: sublabel_big text_color: 0x1B1B1B text_opa: 85% @@ -3994,7 +4068,7 @@ lvgl: id: climate_set_label align: CENTER y: -16 - text: "SET TO" + text: "Gewenst" text_font: sublabel_big text_color: 0x1B1B1B text_opa: 68% @@ -4097,8 +4171,8 @@ lvgl: width: ${DISPLAY_W} height: ${DISPLAY_H} hidden: true - bg_color: 0xFFFFFF - bg_opa: 90% + bg_color: 0xE7E7E7 + bg_opa: COVER border_width: 0 clickable: true scrollable: false @@ -4107,7 +4181,7 @@ lvgl: - label: align: TOP_MID y: 16 - text: "Mode" + text: "Modus" text_font: sublabel_big text_color: 0x1B1B1B clickable: false @@ -4117,15 +4191,18 @@ lvgl: - obj: id: climate_mode_list align: TOP_MID - y: 50 + y: 44 width: 98% height: 178 bg_opa: TRANSP border_width: 0 scrollable: false + clickable: false pad_all: 3 layout: type: FLEX + pad_row: 3 + pad_column: 4 flex_flow: ROW_WRAP flex_align_main: CENTER flex_align_cross: CENTER @@ -4133,12 +4210,13 @@ lvgl: widgets: - obj: id: cmode_off - width: 132 - height: 38 - radius: 19 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 88 + height: 26 + radius: 13 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4147,18 +4225,19 @@ lvgl: widgets: - label: align: CENTER - text: "Off" + text: "Uit" text_font: sublabel_big text_color: 0x1B1B1B clickable: false - obj: id: cmode_heat - width: 132 - height: 38 - radius: 19 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 88 + height: 26 + radius: 13 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4167,18 +4246,19 @@ lvgl: widgets: - label: align: CENTER - text: "Heat" + text: "Verwarmen" text_font: sublabel_big text_color: 0x1B1B1B clickable: false - obj: id: cmode_cool - width: 132 - height: 38 - radius: 19 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 88 + height: 26 + radius: 13 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4187,18 +4267,19 @@ lvgl: widgets: - label: align: CENTER - text: "Cool" + text: "Koelen" text_font: sublabel_big text_color: 0x1B1B1B clickable: false - obj: id: cmode_heat_cool - width: 132 - height: 38 - radius: 19 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 88 + height: 26 + radius: 13 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4207,18 +4288,19 @@ lvgl: widgets: - label: align: CENTER - text: "Heat/Cool" + text: "Verw./koelen" text_font: sublabel_big text_color: 0x1B1B1B clickable: false - obj: id: cmode_auto - width: 132 - height: 38 - radius: 19 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 88 + height: 26 + radius: 13 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4233,12 +4315,13 @@ lvgl: clickable: false - obj: id: cmode_dry - width: 132 - height: 38 - radius: 19 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 88 + height: 26 + radius: 13 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4247,18 +4330,19 @@ lvgl: widgets: - label: align: CENTER - text: "Dry" + text: "Drogen" text_font: sublabel_big text_color: 0x1B1B1B clickable: false - obj: id: cmode_fan_only - width: 132 - height: 38 - radius: 19 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 88 + height: 26 + radius: 13 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4267,11 +4351,205 @@ lvgl: widgets: - label: align: CENTER - text: "Fan" + text: "Ventileren" text_font: sublabel_big text_color: 0x1B1B1B clickable: false + - label: + id: cfan_title + width: 100% + height: 11 + text: "Ventilator" + text_font: sublabel + text_color: 0x6B6B6B + text_align: CENTER + clickable: false + - obj: + id: cfan_0 + width: 62 + height: 22 + radius: 11 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_fan_mode, index: 0 } + widgets: + - label: + id: cfan_0_label + align: CENTER + text: "-" + text_font: sublabel + text_color: 0x1B1B1B + clickable: false + - obj: + id: cfan_1 + width: 62 + height: 22 + radius: 11 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_fan_mode, index: 1 } + widgets: + - label: + id: cfan_1_label + align: CENTER + text: "-" + text_font: sublabel + text_color: 0x1B1B1B + clickable: false + - obj: + id: cfan_2 + width: 62 + height: 22 + radius: 11 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_fan_mode, index: 2 } + widgets: + - label: + id: cfan_2_label + align: CENTER + text: "-" + text_font: sublabel + text_color: 0x1B1B1B + clickable: false + - obj: + id: cfan_3 + width: 62 + height: 22 + radius: 11 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_fan_mode, index: 3 } + widgets: + - label: + id: cfan_3_label + align: CENTER + text: "-" + text_font: sublabel + text_color: 0x1B1B1B + clickable: false + - label: + id: cswing_title + width: 100% + height: 11 + text: "Zwenken" + text_font: sublabel + text_color: 0x6B6B6B + text_align: CENTER + clickable: false + - obj: + id: cswing_0 + width: 62 + height: 22 + radius: 11 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_swing_mode, index: 0 } + widgets: + - label: + id: cswing_0_label + align: CENTER + text: "-" + text_font: sublabel + text_color: 0x1B1B1B + clickable: false + - obj: + id: cswing_1 + width: 62 + height: 22 + radius: 11 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_swing_mode, index: 1 } + widgets: + - label: + id: cswing_1_label + align: CENTER + text: "-" + text_font: sublabel + text_color: 0x1B1B1B + clickable: false + - obj: + id: cswing_2 + width: 62 + height: 22 + radius: 11 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_swing_mode, index: 2 } + widgets: + - label: + id: cswing_2_label + align: CENTER + text: "-" + text_font: sublabel + text_color: 0x1B1B1B + clickable: false + - obj: + id: cswing_3 + width: 62 + height: 22 + radius: 11 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_swing_mode, index: 3 } + widgets: + - label: + id: cswing_3_label + align: CENTER + text: "-" + text_font: sublabel + text_color: 0x1B1B1B + clickable: false - obj: id: climate_mode_overlay_x align: TOP_LEFT @@ -4280,11 +4558,11 @@ lvgl: width: 36 height: 36 radius: 999 - bg_color: 0xEEEEEE + bg_color: 0xFFFFFF bg_opa: COVER border_width: 1 - border_color: 0xFFFFFF - border_opa: 20% + border_color: 0xDDDDDD + border_opa: COVER clickable: true scrollable: false on_click: @@ -4297,28 +4575,6 @@ lvgl: text_color: 0x1B1B1B clickable: false - - obj: - id: climate_mode_overlay_ok - align: TOP_RIGHT - x: -10 - y: 9 - width: 36 - height: 36 - radius: 999 - bg_color: 0xD5EEFC - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - lvgl.widget.hide: climate_mode_overlay - widgets: - - label: - align: CENTER - text: "\U000F012C" - text_font: materialdesign_icons - text_color: 0x1B1B1B - clickable: false # ---------- VACUUM CONTROL ---------- # Een stofzuiger is geen schakelaar: start, pauze, stop en terug naar @@ -4729,7 +4985,7 @@ script: static unsigned test_page=0; id(tile_page) = runtime_tiles::enabled ? test_page++ % runtime_tiles::page_count() : 1; - script.execute: show_tile_page - - delay: 150ms + - delay: 300ms - 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); @@ -5003,8 +5259,8 @@ script: if (has(m)) lv_obj_clear_flag(btn, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN); bool selected = (current == std::string(m)); - lv_obj_set_style_bg_color(btn, - lv_color_hex(selected ? 0xFF9F0A : 0x2C2C30), LV_PART_MAIN); + lv_obj_set_style_bg_color(btn, lv_color_hex(selected ? 0x00A6ED : 0xFFFFFF), LV_PART_MAIN); + lv_obj_set_style_text_color(lv_obj_get_child(btn, 0), lv_color_hex(selected ? 0xFFFFFF : 0x1B1B1B), LV_PART_MAIN); }; apply(id(cmode_off), "off"); apply(id(cmode_heat), "heat"); @@ -5013,10 +5269,109 @@ script: apply(id(cmode_auto), "auto"); apply(id(cmode_dry), "dry"); apply(id(cmode_fan_only), "fan_only"); + // Fan and swing rows: the entity's own mode names, at most four each, as Home Assistant reports them. + auto pretty = [](std::string raw) { if (!raw.empty() && raw[0] >= 'a' && raw[0] <= 'z') raw[0] = (char)(raw[0] - 'a' + 'A'); return raw; }; + auto fill = [&](lv_obj_t *title, lv_obj_t *chips[4], const std::string &modes, const std::string ¤t_mode) { + unsigned shown = 0; + for (unsigned i = 0; i < 4; ++i) { + std::string raw = cyd::list_item(modes, i); + if (raw.empty()) { lv_obj_add_flag(chips[i], LV_OBJ_FLAG_HIDDEN); continue; } + lv_obj_clear_flag(chips[i], LV_OBJ_FLAG_HIDDEN); ++shown; + lv_label_set_text(lv_obj_get_child(chips[i], 0), pretty(raw).c_str()); + lv_obj_set_style_bg_color(chips[i], lv_color_hex(raw == current_mode ? 0x00A6ED : 0xFFFFFF), LV_PART_MAIN); + lv_obj_set_style_text_color(lv_obj_get_child(chips[i], 0), lv_color_hex(raw == current_mode ? 0xFFFFFF : 0x1B1B1B), LV_PART_MAIN); + } + if (shown) lv_obj_clear_flag(title, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(title, LV_OBJ_FLAG_HIDDEN); + }; + lv_obj_t *fan_chips[4] = {id(cfan_0), id(cfan_1), id(cfan_2), id(cfan_3)}; + lv_obj_t *swing_chips[4] = {id(cswing_0), id(cswing_1), id(cswing_2), id(cswing_3)}; + fill(id(cfan_title), fan_chips, id(active_climate_fan_modes), id(active_climate_fan_mode)); + fill(id(cswing_title), swing_chips, id(active_climate_swing_modes), id(active_climate_swing_mode)); - lvgl.widget.show: climate_mode_overlay # Zet precies een modus. Geen tussenstappen, dus geen ongewenste # commando's naar de warmtepomp onderweg. + # Fan and swing modes from the picker: one call with the entity's own mode name. + - id: set_climate_fan_mode + mode: restart + parameters: + index: int + then: + - lambda: |- + id(active_climate_fan_mode) = cyd::list_item(id(active_climate_fan_modes), index); + id(climate_edit_ms) = millis(); + id(climate_edit_pending) = true; + - lvgl.widget.hide: climate_mode_overlay + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && !id(active_climate_fan_mode).empty();' + then: + - homeassistant.action: + action: climate.set_fan_mode + data: + entity_id: !lambda "return id(active_entity);" + fan_mode: !lambda "return id(active_climate_fan_mode);" + - id: set_climate_swing_mode + mode: restart + parameters: + index: int + then: + - lambda: |- + id(active_climate_swing_mode) = cyd::list_item(id(active_climate_swing_modes), index); + id(climate_edit_ms) = millis(); + id(climate_edit_pending) = true; + - lvgl.widget.hide: climate_mode_overlay + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && !id(active_climate_swing_mode).empty();' + then: + - homeassistant.action: + action: climate.set_swing_mode + data: + entity_id: !lambda "return id(active_entity);" + swing_mode: !lambda "return id(active_climate_swing_mode);" + + # Quick on/off in the climate card; climate.turn_on restores the last mode in Home Assistant. + - id: climate_power_toggle + mode: restart + then: + - lambda: |- + std::string mode = id(active_climate_mode); + for (char &c : mode) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); + id(active_climate_power_on) = mode == "off" || mode.empty() || mode == "unknown"; + id(climate_edit_ms) = millis(); + id(climate_edit_pending) = true; + if (!id(active_climate_power_on)) { id(active_climate_mode) = "off"; lv_label_set_text(id(climate_mode_label), "Off"); } + id(climate_power_refresh).execute(); + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && id(active_climate_power_on);' + then: + - homeassistant.action: + action: climate.turn_on + data: + entity_id: !lambda "return id(active_entity);" + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && !id(active_climate_power_on);' + then: + - homeassistant.action: + action: climate.turn_off + data: + entity_id: !lambda "return id(active_entity);" + + # The power key lights up while the device runs in any mode. + - id: climate_power_refresh + mode: restart + then: + - lambda: |- + std::string mode = id(active_climate_mode); + for (char &c : mode) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); + bool on = !(mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable"); + if (id(climate_edit_pending)) on = id(active_climate_power_on); + lv_obj_set_style_bg_color(id(climate_power_button), lv_color_hex(on ? 0xD5EEFC : 0xEEEEEE), LV_PART_MAIN); + lv_obj_set_style_text_color(id(climate_power_icon), lv_color_hex(on ? 0x0B6E99 : 0x8A8A8A), LV_PART_MAIN); + - id: set_climate_mode mode: restart parameters: @@ -5026,13 +5381,9 @@ script: id(climate_edit_ms) = millis(); id(climate_edit_pending) = true; id(active_climate_mode) = mode; - if (mode == "off") lv_label_set_text(id(climate_mode_label), "Off"); - if (mode == "heat") lv_label_set_text(id(climate_mode_label), "Heat"); - if (mode == "cool") lv_label_set_text(id(climate_mode_label), "Cool"); - if (mode == "heat_cool") lv_label_set_text(id(climate_mode_label), "Heat/Cool"); - if (mode == "auto") lv_label_set_text(id(climate_mode_label), "Auto"); - if (mode == "dry") lv_label_set_text(id(climate_mode_label), "Dry"); - if (mode == "fan_only") lv_label_set_text(id(climate_mode_label), "Fan"); + id(active_climate_power_on) = mode != "off"; + id(climate_power_refresh).execute(); + lv_label_set_text(id(climate_mode_label), tile_controls::climate_mode_text(mode)); - lvgl.widget.hide: climate_mode_overlay - if: condition: @@ -5604,18 +5955,19 @@ script: char current_buf[48]; if (isnan(climate_current_temperature)) { - snprintf(current_buf, sizeof(current_buf), "Current: --"); + snprintf(current_buf, sizeof(current_buf), "Nu: --"); } else if (id(active_climate_humidity) >= 0) { - snprintf(current_buf, sizeof(current_buf), "Current: %.1f° - Humidity: %d %%", climate_current_temperature, id(active_climate_humidity)); + snprintf(current_buf, sizeof(current_buf), "Nu: %.1f° · %d%%", climate_current_temperature, id(active_climate_humidity)); } else { - snprintf(current_buf, sizeof(current_buf), "Current: %.1f°", climate_current_temperature); + snprintf(current_buf, sizeof(current_buf), "Nu: %.1f°", climate_current_temperature); } lv_label_set_text(id(climate_current_label), current_buf); char target_buf[16]; snprintf(target_buf, sizeof(target_buf), "%.1f°", id(active_climate_target_x10) / 10.0f); lv_label_set_text(id(climate_target_label), target_buf); - lv_label_set_text(id(climate_mode_label), id(active_climate_mode).c_str()); + lv_label_set_text(id(climate_mode_label), tile_controls::climate_mode_text(id(active_climate_mode))); + id(climate_power_refresh).execute(); lv_arc_set_range(id(climate_arc), id(active_climate_min_x10), id(active_climate_max_x10)); lv_arc_set_value(id(climate_arc), id(active_climate_target_x10)); @@ -6011,11 +6363,11 @@ script: char current_buf[48]; if (isnan(current_temperature)) { - snprintf(current_buf, sizeof(current_buf), "Current: --"); + snprintf(current_buf, sizeof(current_buf), "Nu: --"); } else if (id(active_climate_humidity) >= 0) { - snprintf(current_buf, sizeof(current_buf), "Current: %.1f° - Humidity: %d %%", current_temperature, id(active_climate_humidity)); + snprintf(current_buf, sizeof(current_buf), "Nu: %.1f° · %d%%", current_temperature, id(active_climate_humidity)); } else { - snprintf(current_buf, sizeof(current_buf), "Current: %.1f°", current_temperature); + snprintf(current_buf, sizeof(current_buf), "Nu: %.1f°", current_temperature); } lv_label_set_text(id(climate_current_label), current_buf); diff --git a/packages/guition.yaml b/packages/guition.yaml index 31089ff..1ae8120 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.18" + SCREEN_FIRMWARE_VERSION: "0.2.19" DYNAMIC_TILES: "true" DASHBOARD_INBOX_INTERNAL: "false" DEVICE_NAME: "guition-new" @@ -607,13 +607,13 @@ esphome: settings.night_start = atoi("${NIGHT_START_HOUR}") * 60; settings.night_end = atoi("${NIGHT_END_HOUR}") * 60; settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}"); - runtime_tiles::light_theme = true; 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::control_font = id(sublabel_big)->get_lv_font(); runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true"; if (!runtime_tiles::enabled) return; runtime_tiles::rotation_supported = true; @@ -668,6 +668,8 @@ esphome: light_controls::fallback.kelvin = t.kelvin; light_controls::fallback.minimum = t.min_kelvin; light_controls::fallback.maximum = t.max_kelvin; + id(active_climate_fan_modes) = t.fan_modes; id(active_climate_fan_mode) = t.fan_mode; + id(active_climate_swing_modes) = t.swing_modes; id(active_climate_swing_mode) = t.swing_mode; id(open_value_overlay).execute(t.name, t.entity, t.domain(), "auto", t.active(), 0, t.brightness, t.percentage, t.position, runtime_tiles::icon_for(t), 0xD5EEFC, 0x00A6ED, 0x1B1B1B, "Uit", "Aan", t.modes, @@ -681,7 +683,10 @@ esphome: id(climate_edit_pending) = false; id(active_climate_mode) = t.state; id(active_climate_supported_modes) = t.hvac_modes; - lv_label_set_text(id(climate_mode_label), t.state.c_str()); + lv_label_set_text(id(climate_mode_label), tile_controls::climate_mode_text(t.state)); + id(climate_power_refresh).execute(); + id(active_climate_fan_modes) = t.fan_modes; id(active_climate_fan_mode) = t.fan_mode; + id(active_climate_swing_modes) = t.swing_modes; id(active_climate_swing_mode) = t.swing_mode; char buf[72]; if (std::isfinite(t.current)) snprintf(buf, sizeof(buf), "Nu: %.1f°", t.current); else snprintf(buf, sizeof(buf), "Nu: --"); @@ -698,7 +703,7 @@ esphome: - lambda: |- static const char *entities[] = {"${TILE1_ENTITY}", "${TILE2_ENTITY}", "${TILE3_ENTITY}", "${TILE4_ENTITY}", "${TILE5_ENTITY}", "${TILE6_ENTITY}", "${TILE7_ENTITY}", "${TILE9_ENTITY}"}; if (!runtime_tiles::enabled) light_controls::subscribe(entities, 8); - light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}"), true); + light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}")); light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); }; light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); }; light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); }; @@ -791,11 +796,32 @@ api: index: int then: - lambda: |- - if (index < 0) { runtime_tiles::hide_detail(); return; } + if (index < 0) { runtime_tiles::hide_detail(); if (runtime_tiles::dismiss) runtime_tiles::dismiss(); return; } if (runtime_tiles::enabled && index < (int)runtime_tiles::model.count) { runtime_tiles::active_index = index; + auto &tile = runtime_tiles::model.tiles[index]; + auto domain = tile.domain(); + // Lights, fans, covers and climate open the board's own card, like a long press does. + if ((domain == "light" || domain == "fan" || domain == "cover" || domain == "climate") && runtime_tiles::detail) { + tile.begin(millis(), true); + runtime_tiles::detail(tile); + return; + } runtime_tiles::show_detail(index); } + # Diagnostics: the climate mode picker and the busy sheet of a tile, without touching the glass or HA. + - action: preview_climate_picker + then: + - script.execute: open_climate_mode_picker + - action: preview_busy + variables: + index: int + then: + - lambda: |- + if (runtime_tiles::enabled && index >= 0 && index < (int)runtime_tiles::model.count) { + runtime_tiles::model.tiles[index].begin(millis()); + if (runtime_tiles::refresh) runtime_tiles::refresh(); + } - action: light_controls_preview then: - script.execute: light_controls_preview @@ -965,6 +991,27 @@ globals: type: std::string restore_value: no + - id: active_climate_power_on + type: bool + restore_value: no + initial_value: 'false' + + - id: active_climate_fan_modes + type: std::string + restore_value: no + + - id: active_climate_fan_mode + type: std::string + restore_value: no + + - id: active_climate_swing_modes + type: std::string + restore_value: no + + - id: active_climate_swing_mode + type: std::string + restore_value: no + - id: active_climate_supported_modes type: std::string restore_value: no @@ -989,7 +1036,7 @@ font: '0','1','2','3','4','5','6','7','8','9', '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', ' ','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', - 'ä','ö','ü','Ä','Ö','Ü','ß','/'] + 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-500.ttf" id: watch_value @@ -999,7 +1046,7 @@ font: '0','1','2','3','4','5','6','7','8','9', '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', ' ','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" @@ -1016,7 +1063,7 @@ font: '0','1','2','3','4','5','6','7','8','9', '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', ' ','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', - 'ä','ö','ü','Ä','Ö','Ü','ß','/'] + 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-400.ttf" id: sublabel @@ -1026,7 +1073,7 @@ font: '0','1','2','3','4','5','6','7','8','9', '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', ' ','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', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','…'] + 'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·'] - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-400.ttf" id: sublabel_big @@ -1036,7 +1083,7 @@ font: '0','1','2','3','4','5','6','7','8','9', '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', ' ','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', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','…'] + 'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·'] # Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0) # Source: https://github.com/Templarian/MaterialDesign-Webfont @@ -1196,6 +1243,24 @@ font: - "\U000F00E2" # broom - "\U000F05D6" # alert-circle-outline - "\U000F012C" # check + - "\U000F03E4" # pause + - "\U000F04DB" # stop + - "\U000F04AD" # skip-next + - "\U000F04AE" # skip-previous + - "\U000F057E" # volume-high + - "\U000F0581" # volume-off + - "\U000F005D" # arrow-up + - "\U000F0045" # arrow-down + - "\U000F084E" # arrow-expand-horizontal + - "\U000F084C" # arrow-collapse-horizontal + - "\U000F05F8" # home-map-marker + - "\U000F0415" # plus + - "\U000F0374" # minus + - "\U000F0141" # chevron-left + - "\U000F0142" # chevron-right + - "\U000F0156" # close + - "\U000F1A79" # sun-snowflake-variant + - "\U000F1B17" # thermostat-auto - "\U000F0591" # weather-fog - "\U000F0592" # weather-hail - "\U000F0593" # weather-lightning @@ -1927,6 +1992,14 @@ lvgl: scrollable: false scrollbar_mode: 'OFF' widgets: + # Never shown: it only makes ESPHome compile LVGL's spinner, which the busy sheet on a tile draws. + - spinner: + id: busy_spinner_seed + hidden: true + width: 2 + height: 2 + spin_time: 1s + arc_length: 60deg - label: id: lbl_room x: 16 @@ -3686,7 +3759,7 @@ lvgl: width: 54 height: 54 radius: 999 - bg_color: 0xFF9F0A + bg_color: 0xEEEEEE bg_opa: COVER border_width: 0 clickable: true @@ -3768,25 +3841,26 @@ lvgl: clickable: false - obj: - id: climate_done_button + id: climate_power_button align: TOP_RIGHT x: -15 y: 18 width: 66 height: 66 radius: 999 - bg_color: 0xD5EEFC + bg_color: 0xEEEEEE bg_opa: COVER border_width: 0 clickable: true scrollable: false on_click: - - lvgl.widget.hide: climate_detail_overlay + - script.execute: climate_power_toggle widgets: - label: + id: climate_power_icon align: CENTER - text: "\U000F012C" - text_font: materialdesign_icons + text: "\U000F0425" + text_font: materialdesign_icons_mini text_color: 0x1B1B1B clickable: false @@ -3801,7 +3875,7 @@ lvgl: id: climate_current_label align: TOP_MID y: 84 - text: "Current: --" + text: "Nu: --" text_font: sublabel_big text_color: 0x1B1B1B text_opa: 85% @@ -3852,7 +3926,7 @@ lvgl: id: climate_set_label align: CENTER y: -32 - text: "SET TO" + text: "Gewenst" text_font: sublabel_big text_color: 0x1B1B1B text_opa: 68% @@ -3955,8 +4029,8 @@ lvgl: width: ${DISPLAY_W} height: ${DISPLAY_H} hidden: true - bg_color: 0xFFFFFF - bg_opa: 90% + bg_color: 0xE7E7E7 + bg_opa: COVER border_width: 0 clickable: true scrollable: false @@ -3965,7 +4039,7 @@ lvgl: - label: align: TOP_MID y: 32 - text: "Mode" + text: "Modus" text_font: sublabel_big text_color: 0x1B1B1B clickable: false @@ -3975,15 +4049,18 @@ lvgl: - obj: id: climate_mode_list align: TOP_MID - y: 100 + y: 92 width: 98% - height: 356 + height: 372 bg_opa: TRANSP border_width: 0 scrollable: false + clickable: false pad_all: 4 layout: type: FLEX + pad_row: 6 + pad_column: 6 flex_flow: ROW_WRAP flex_align_main: CENTER flex_align_cross: CENTER @@ -3991,12 +4068,13 @@ lvgl: widgets: - obj: id: cmode_off - width: 198 - height: 76 - radius: 28 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 136 + height: 58 + radius: 22 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4005,18 +4083,19 @@ lvgl: widgets: - label: align: CENTER - text: "Off" + text: "Uit" text_font: sublabel_big text_color: 0x1B1B1B clickable: false - obj: id: cmode_heat - width: 198 - height: 76 - radius: 28 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 136 + height: 58 + radius: 22 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4025,18 +4104,19 @@ lvgl: widgets: - label: align: CENTER - text: "Heat" + text: "Verwarmen" text_font: sublabel_big text_color: 0x1B1B1B clickable: false - obj: id: cmode_cool - width: 198 - height: 76 - radius: 28 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 136 + height: 58 + radius: 22 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4045,18 +4125,19 @@ lvgl: widgets: - label: align: CENTER - text: "Cool" + text: "Koelen" text_font: sublabel_big text_color: 0x1B1B1B clickable: false - obj: id: cmode_heat_cool - width: 198 - height: 76 - radius: 28 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 136 + height: 58 + radius: 22 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4065,18 +4146,19 @@ lvgl: widgets: - label: align: CENTER - text: "Heat/Cool" + text: "Verw./koelen" text_font: sublabel_big text_color: 0x1B1B1B clickable: false - obj: id: cmode_auto - width: 198 - height: 76 - radius: 28 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 136 + height: 58 + radius: 22 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4091,12 +4173,13 @@ lvgl: clickable: false - obj: id: cmode_dry - width: 198 - height: 76 - radius: 28 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 136 + height: 58 + radius: 22 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4105,18 +4188,19 @@ lvgl: widgets: - label: align: CENTER - text: "Dry" + text: "Drogen" text_font: sublabel_big text_color: 0x1B1B1B clickable: false - obj: id: cmode_fan_only - width: 198 - height: 76 - radius: 28 - bg_color: 0xF0F0F0 - bg_opa: 92% - border_width: 0 + width: 136 + height: 58 + radius: 22 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD clickable: true scrollable: false pad_all: 0 @@ -4125,11 +4209,205 @@ lvgl: widgets: - label: align: CENTER - text: "Fan" + text: "Ventileren" text_font: sublabel_big text_color: 0x1B1B1B clickable: false + - label: + id: cfan_title + width: 100% + height: 24 + text: "Ventilator" + text_font: sublabel + text_color: 0x6B6B6B + text_align: CENTER + clickable: false + - obj: + id: cfan_0 + width: 98 + height: 48 + radius: 18 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_fan_mode, index: 0 } + widgets: + - label: + id: cfan_0_label + align: CENTER + text: "-" + text_font: sublabel_big + text_color: 0x1B1B1B + clickable: false + - obj: + id: cfan_1 + width: 98 + height: 48 + radius: 18 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_fan_mode, index: 1 } + widgets: + - label: + id: cfan_1_label + align: CENTER + text: "-" + text_font: sublabel_big + text_color: 0x1B1B1B + clickable: false + - obj: + id: cfan_2 + width: 98 + height: 48 + radius: 18 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_fan_mode, index: 2 } + widgets: + - label: + id: cfan_2_label + align: CENTER + text: "-" + text_font: sublabel_big + text_color: 0x1B1B1B + clickable: false + - obj: + id: cfan_3 + width: 98 + height: 48 + radius: 18 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_fan_mode, index: 3 } + widgets: + - label: + id: cfan_3_label + align: CENTER + text: "-" + text_font: sublabel_big + text_color: 0x1B1B1B + clickable: false + - label: + id: cswing_title + width: 100% + height: 24 + text: "Zwenken" + text_font: sublabel + text_color: 0x6B6B6B + text_align: CENTER + clickable: false + - obj: + id: cswing_0 + width: 98 + height: 48 + radius: 18 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_swing_mode, index: 0 } + widgets: + - label: + id: cswing_0_label + align: CENTER + text: "-" + text_font: sublabel_big + text_color: 0x1B1B1B + clickable: false + - obj: + id: cswing_1 + width: 98 + height: 48 + radius: 18 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_swing_mode, index: 1 } + widgets: + - label: + id: cswing_1_label + align: CENTER + text: "-" + text_font: sublabel_big + text_color: 0x1B1B1B + clickable: false + - obj: + id: cswing_2 + width: 98 + height: 48 + radius: 18 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_swing_mode, index: 2 } + widgets: + - label: + id: cswing_2_label + align: CENTER + text: "-" + text_font: sublabel_big + text_color: 0x1B1B1B + clickable: false + - obj: + id: cswing_3 + width: 98 + height: 48 + radius: 18 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 1 + border_color: 0xDDDDDD + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_swing_mode, index: 3 } + widgets: + - label: + id: cswing_3_label + align: CENTER + text: "-" + text_font: sublabel_big + text_color: 0x1B1B1B + clickable: false - obj: id: climate_mode_overlay_x align: TOP_LEFT @@ -4138,11 +4416,11 @@ lvgl: width: 54 height: 54 radius: 999 - bg_color: 0xEEEEEE + bg_color: 0xFFFFFF bg_opa: COVER border_width: 1 - border_color: 0xFFFFFF - border_opa: 20% + border_color: 0xDDDDDD + border_opa: COVER clickable: true scrollable: false on_click: @@ -4155,28 +4433,6 @@ lvgl: text_color: 0x1B1B1B clickable: false - - obj: - id: climate_mode_overlay_ok - align: TOP_RIGHT - x: -15 - y: 18 - width: 54 - height: 54 - radius: 999 - bg_color: 0xD5EEFC - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - lvgl.widget.hide: climate_mode_overlay - widgets: - - label: - align: CENTER - text: "\U000F012C" - text_font: materialdesign_icons - text_color: 0x1B1B1B - clickable: false # ---------- VACUUM CONTROL ---------- # Een stofzuiger is geen schakelaar: start, pauze, stop en terug naar @@ -4596,7 +4852,7 @@ script: static unsigned test_page=0; id(tile_page) = runtime_tiles::enabled ? test_page++ % runtime_tiles::page_count() : 1; - script.execute: show_tile_page - - delay: 150ms + - delay: 300ms - 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); @@ -4893,8 +5149,8 @@ script: if (has(m)) lv_obj_clear_flag(btn, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN); bool selected = (current == std::string(m)); - lv_obj_set_style_bg_color(btn, - lv_color_hex(selected ? 0xD5EEFC : 0xF0F0F0), LV_PART_MAIN); + lv_obj_set_style_bg_color(btn, lv_color_hex(selected ? 0x00A6ED : 0xFFFFFF), LV_PART_MAIN); + lv_obj_set_style_text_color(lv_obj_get_child(btn, 0), lv_color_hex(selected ? 0xFFFFFF : 0x1B1B1B), LV_PART_MAIN); }; apply(id(cmode_off), "off"); apply(id(cmode_heat), "heat"); @@ -4903,10 +5159,109 @@ script: apply(id(cmode_auto), "auto"); apply(id(cmode_dry), "dry"); apply(id(cmode_fan_only), "fan_only"); + // Fan and swing rows: the entity's own mode names, at most four each, as Home Assistant reports them. + auto pretty = [](std::string raw) { if (!raw.empty() && raw[0] >= 'a' && raw[0] <= 'z') raw[0] = (char)(raw[0] - 'a' + 'A'); return raw; }; + auto fill = [&](lv_obj_t *title, lv_obj_t *chips[4], const std::string &modes, const std::string ¤t_mode) { + unsigned shown = 0; + for (unsigned i = 0; i < 4; ++i) { + std::string raw = cyd::list_item(modes, i); + if (raw.empty()) { lv_obj_add_flag(chips[i], LV_OBJ_FLAG_HIDDEN); continue; } + lv_obj_clear_flag(chips[i], LV_OBJ_FLAG_HIDDEN); ++shown; + lv_label_set_text(lv_obj_get_child(chips[i], 0), pretty(raw).c_str()); + lv_obj_set_style_bg_color(chips[i], lv_color_hex(raw == current_mode ? 0x00A6ED : 0xFFFFFF), LV_PART_MAIN); + lv_obj_set_style_text_color(lv_obj_get_child(chips[i], 0), lv_color_hex(raw == current_mode ? 0xFFFFFF : 0x1B1B1B), LV_PART_MAIN); + } + if (shown) lv_obj_clear_flag(title, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(title, LV_OBJ_FLAG_HIDDEN); + }; + lv_obj_t *fan_chips[4] = {id(cfan_0), id(cfan_1), id(cfan_2), id(cfan_3)}; + lv_obj_t *swing_chips[4] = {id(cswing_0), id(cswing_1), id(cswing_2), id(cswing_3)}; + fill(id(cfan_title), fan_chips, id(active_climate_fan_modes), id(active_climate_fan_mode)); + fill(id(cswing_title), swing_chips, id(active_climate_swing_modes), id(active_climate_swing_mode)); - lvgl.widget.show: climate_mode_overlay # Zet precies een modus. Geen tussenstappen, dus geen ongewenste # commando's naar de warmtepomp onderweg. + # Fan and swing modes from the picker: one call with the entity's own mode name. + - id: set_climate_fan_mode + mode: restart + parameters: + index: int + then: + - lambda: |- + id(active_climate_fan_mode) = cyd::list_item(id(active_climate_fan_modes), index); + id(climate_edit_ms) = millis(); + id(climate_edit_pending) = true; + - lvgl.widget.hide: climate_mode_overlay + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && !id(active_climate_fan_mode).empty();' + then: + - homeassistant.action: + action: climate.set_fan_mode + data: + entity_id: !lambda "return id(active_entity);" + fan_mode: !lambda "return id(active_climate_fan_mode);" + - id: set_climate_swing_mode + mode: restart + parameters: + index: int + then: + - lambda: |- + id(active_climate_swing_mode) = cyd::list_item(id(active_climate_swing_modes), index); + id(climate_edit_ms) = millis(); + id(climate_edit_pending) = true; + - lvgl.widget.hide: climate_mode_overlay + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && !id(active_climate_swing_mode).empty();' + then: + - homeassistant.action: + action: climate.set_swing_mode + data: + entity_id: !lambda "return id(active_entity);" + swing_mode: !lambda "return id(active_climate_swing_mode);" + + # Quick on/off in the climate card; climate.turn_on restores the last mode in Home Assistant. + - id: climate_power_toggle + mode: restart + then: + - lambda: |- + std::string mode = id(active_climate_mode); + for (char &c : mode) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); + id(active_climate_power_on) = mode == "off" || mode.empty() || mode == "unknown"; + id(climate_edit_ms) = millis(); + id(climate_edit_pending) = true; + if (!id(active_climate_power_on)) { id(active_climate_mode) = "off"; lv_label_set_text(id(climate_mode_label), "Off"); } + id(climate_power_refresh).execute(); + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && id(active_climate_power_on);' + then: + - homeassistant.action: + action: climate.turn_on + data: + entity_id: !lambda "return id(active_entity);" + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && !id(active_climate_power_on);' + then: + - homeassistant.action: + action: climate.turn_off + data: + entity_id: !lambda "return id(active_entity);" + + # The power key lights up while the device runs in any mode. + - id: climate_power_refresh + mode: restart + then: + - lambda: |- + std::string mode = id(active_climate_mode); + for (char &c : mode) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); + bool on = !(mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable"); + if (id(climate_edit_pending)) on = id(active_climate_power_on); + lv_obj_set_style_bg_color(id(climate_power_button), lv_color_hex(on ? 0xD5EEFC : 0xEEEEEE), LV_PART_MAIN); + lv_obj_set_style_text_color(id(climate_power_icon), lv_color_hex(on ? 0x0B6E99 : 0x8A8A8A), LV_PART_MAIN); + - id: set_climate_mode mode: restart parameters: @@ -4916,13 +5271,9 @@ script: id(climate_edit_ms) = millis(); id(climate_edit_pending) = true; id(active_climate_mode) = mode; - if (mode == "off") lv_label_set_text(id(climate_mode_label), "Off"); - if (mode == "heat") lv_label_set_text(id(climate_mode_label), "Heat"); - if (mode == "cool") lv_label_set_text(id(climate_mode_label), "Cool"); - if (mode == "heat_cool") lv_label_set_text(id(climate_mode_label), "Heat/Cool"); - if (mode == "auto") lv_label_set_text(id(climate_mode_label), "Auto"); - if (mode == "dry") lv_label_set_text(id(climate_mode_label), "Dry"); - if (mode == "fan_only") lv_label_set_text(id(climate_mode_label), "Fan"); + id(active_climate_power_on) = mode != "off"; + id(climate_power_refresh).execute(); + lv_label_set_text(id(climate_mode_label), tile_controls::climate_mode_text(mode)); - lvgl.widget.hide: climate_mode_overlay - if: condition: @@ -5494,18 +5845,19 @@ script: char current_buf[48]; if (isnan(climate_current_temperature)) { - snprintf(current_buf, sizeof(current_buf), "Current: --"); + snprintf(current_buf, sizeof(current_buf), "Nu: --"); } else if (id(active_climate_humidity) >= 0) { - snprintf(current_buf, sizeof(current_buf), "Current: %.1f° - Humidity: %d %%", climate_current_temperature, id(active_climate_humidity)); + snprintf(current_buf, sizeof(current_buf), "Nu: %.1f° · %d%%", climate_current_temperature, id(active_climate_humidity)); } else { - snprintf(current_buf, sizeof(current_buf), "Current: %.1f°", climate_current_temperature); + snprintf(current_buf, sizeof(current_buf), "Nu: %.1f°", climate_current_temperature); } lv_label_set_text(id(climate_current_label), current_buf); char target_buf[16]; snprintf(target_buf, sizeof(target_buf), "%.1f°", id(active_climate_target_x10) / 10.0f); lv_label_set_text(id(climate_target_label), target_buf); - lv_label_set_text(id(climate_mode_label), id(active_climate_mode).c_str()); + lv_label_set_text(id(climate_mode_label), tile_controls::climate_mode_text(id(active_climate_mode))); + id(climate_power_refresh).execute(); lv_arc_set_range(id(climate_arc), id(active_climate_min_x10), id(active_climate_max_x10)); lv_arc_set_value(id(climate_arc), id(active_climate_target_x10)); @@ -5901,11 +6253,11 @@ script: char current_buf[48]; if (isnan(current_temperature)) { - snprintf(current_buf, sizeof(current_buf), "Current: --"); + snprintf(current_buf, sizeof(current_buf), "Nu: --"); } else if (id(active_climate_humidity) >= 0) { - snprintf(current_buf, sizeof(current_buf), "Current: %.1f° - Humidity: %d %%", current_temperature, id(active_climate_humidity)); + snprintf(current_buf, sizeof(current_buf), "Nu: %.1f° · %d%%", current_temperature, id(active_climate_humidity)); } else { - snprintf(current_buf, sizeof(current_buf), "Current: %.1f°", current_temperature); + snprintf(current_buf, sizeof(current_buf), "Nu: %.1f°", current_temperature); } lv_label_set_text(id(climate_current_label), current_buf); diff --git a/screen_manager/CHANGELOG.md b/screen_manager/CHANGELOG.md index c96695e..4b4ea78 100644 --- a/screen_manager/CHANGELOG.md +++ b/screen_manager/CHANGELOG.md @@ -1,3 +1,23 @@ +## 0.2.23 (firmware 0.2.19) + +- **Directe bediening op dubbelbrede tegels**, zoals de entiteitsrijen in Home Assistant: rechts op de kaart staan knoppen of een schuif, links blijven icoon, naam en status. Per domein kies je in het tegelpaneel onder **Directe bediening op de tegel** welke set de tegel toont: + - klimaat: **Temperatuur − / +** (de gewenste temperatuur in een pill; tikken past direct aan, na een korte pauze gaat één opdracht naar HA) of **Uit, verwarmen, koelen** (maximaal drie modusknoppen uit `hvac_modes`, de actieve gekleurd); + - schakelaar, input_boolean, lamp en ventilator: **Aan/uit-schakelaar** (toggle die direct omklapt); lamp ook **Helderheidsschuif**, ventilator ook **Snelheidsschuif**; + - stofzuiger: **Start, stop, naar dock** (start wordt pauze tijdens het schoonmaken; dock en stop grijs als ze niet kunnen); + - zonwering: **Open, stop, dicht** (pijlen horizontaal voor gordijnen, deuren, poorten en zonneschermen; open/dicht grijs aan het eind van de slag; stop alleen als het apparaat het kan) of **Positieschuif**; + - mediaspeler: **Volume en dempen** (schuif met witte greep en dempknop) of **Vorige, play/pauze, volgende**; + - getal: **Waarde − / +** of **Schuif**; keuzelijst: **Vorige / volgende keuze**; kookwekker: **Start/pauze en annuleren**; scène, script en knop: één knop **Activeren**, **Uitvoeren** of **Indrukken**. +- Zonder keuze toont een dubbelbrede tegel van zo'n domein de eerste set; **Geen** houdt de gewone kaart. Enkele tegels veranderen niet. De statusregel volgt HA: `Koelen · 21.5°`, `Open · 80%`, `TV · 17%`. +- De mockup in de editor toont de gekozen bediening in het klein; de Inspector meldt de gekozen set. De add-on stuurt alleen de werkelijk getoonde set mee (`o.controls`), plus `device_class`, `hvac_action` en `is_volume_muted`. Firmware 0.2.18 en ouder negeert het veld; het paneel meldt dat. +- Firmware 0.2.19: het paneel wordt alleen opgebouwd voor tegels die het tonen en per set hergebruikt; de render-selftest controleert dat knoppen binnen de kaart en rechts van de tekst blijven. De drie icoonfonts bevatten 18 extra bedieningsglyphs (CYD +3 KB, Guition +5 KB) en het middenpunt `·` in de tekstfonts. +- **Bezig-status als spinner**: een tegel die op Home Assistant wacht krijgt een licht-witte laag met een klein draaiend cirkeltje in plaats van de blauwe voortgangsbalk en de tekst "Bezig...". De laag vangt tikken op zolang de opdracht loopt. +- **Weerkaart** (bediening openen op een weertegel), in twee kaarten: *nu* met groot icoon, temperatuur, omschrijving en een gedempte regel met gevoelstemperatuur, luchtvochtigheid en wind, daaronder de komende zes uren (tijd, icoon, temperatuur, regenkans in blauw); en *Komende dagen* met per dag icoon, omschrijving, regen met druppel (kans en millimeters) en de hoogste temperatuur vet naast de laagste gedempt. De add-on haalt naast de dagvoorspelling nu ook de uurvoorspelling op (`weather.get_forecasts` type `hourly`, elk half uur ververst) en stuurt `x.hours` plus regen (`p` kans, `r` mm) per dag en uur mee. Zonder uurvoorspelling blijft de kaart bij de dagen. +- **Klimaat**: het vinkje rechtsboven in de klimaatkaart is een **aan/uit-knop** geworden (`climate.turn_on`/`turn_off`, licht op als het apparaat draait); het kruisje sluit nog steeds. De moduskiezer toont naast de HVAC-modi ook de **ventilatorstanden** en **zwenkstanden** van het apparaat (`fan_modes`, `swing_modes`, maximaal vier per rij; Nederlands waar bekend). Bij aantikken van een klimaattegel kun je nu ook **Aan / uit** kiezen (`climate.toggle`). +- **Scènes, scripts en knoppen** tonen niet langer "Uit" maar wanneer ze voor het laatst liepen: `Laatst 14:32`, `Gisteren 14:32` of `Laatst 13 sep`, en `Bezig...` zolang een script draait; `Nog niet gestart` als het nog nooit liep. De add-on stuurt daarvoor `x.last` (unix-tijd uit `last_triggered` of de tijdstempel-status). +- **Moduskiezer van de klimaatkaart** opnieuw ingedeeld: titel *Modus*, de kiezer op het schermgrijs met witte chips met randje (contrast), Nederlandse HVAC-chips drie per rij, de actieve chip in het accentblauw met witte tekst, daaronder de rijen *Ventilator* en *Zwenken* met de standen zoals het apparaat ze meldt (alleen een hoofdletter, geen vertaaltabel: werkt met elk apparaat). Het overbodige vinkje is weg; een chip past de stand toe en sluit, het kruisje sluit. *Nu:* en *Gewenst* vervangen de Engelse labels in de kaart. +- **Lichter tekenen**: de firmware zet lettertypen, uitlijning, padding en lijndikte alleen nog als ze echt veranderen (elke `lv_obj_set_style_*` maakt anders het hele object ongeldig), en herrendert tijdens *bezig* niet meer elke 250 ms de hele pagina. Een tikkende klokkaart of een draaiende spinner tekent zo alleen nog zichzelf. Het donkere thema is uit de runtime-code verwijderd; het lichte palet is het enige palet. +- Getallen boven een miljoen gaan niet mee als weergavewaarde, behalve `supported_features` (een bitveld; mediaspelers zitten boven de 8 miljoen), anders hadden Sonos-tegels geen volume- of playbackknoppen. + ## 0.2.22 (firmware 0.2.18 blijft actueel) - De eventstream stuurt het eerste event direct bij openen in plaats van na 3 s. Gemeten op een Home Assistant Yellow na 0.2.21: `/api/inventory` 50 ms (was 7,2 s), lichte poll 2,6 KB, CPU in rust 0,5% van één core (was 8,7%). Alleen de app verandert. diff --git a/screen_manager/app/core.py b/screen_manager/app/core.py index 7b76436..da6c540 100644 --- a/screen_manager/app/core.py +++ b/screen_manager/app/core.py @@ -1,6 +1,6 @@ """Pure validation, firmware generation and bounded display protocol.""" import base64 -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone import json import math import re @@ -16,8 +16,10 @@ WEEKDAYS = ['ma', 'di', 'wo', 'do', 'vr', 'za', 'zo'] REPO = 'https://github.com/MaxGramser/homeassistant_espscreen' REFS = {'cyd': 'main', 'guition': 'main'} # Firmware shipped with this app release; screens below it get an update offer. -FIRMWARE_VERSION = '0.2.18' -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()) +FIRMWARE_VERSION = '0.2.19' +ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hvac_action hs_color color_temp_kelvin min_color_temp_kelvin max_color_temp_kelvin fan_speed_list unit_of_measurement battery_level fan_speed volume_level is_volume_muted media_title options min max step temperature_unit supported_features device_class next_rising next_setting finishes_at duration remaining humidity wind_speed wind_speed_unit apparent_temperature fan_modes swing_modes fan_mode swing_mode'.split()) +# Attributes whose boolean value the screen needs; every other bool stays behind. +BOOL_ATTRS = frozenset(['is_volume_muted']) TILE_BACKGROUNDS = { @@ -40,6 +42,43 @@ DISPLAYS = {'weather': ('standard', 'watch', 'forecast'), 'sensor': ('standard', # Displays that only work on a double-width card. WIDE_ONLY = ('forecast', 'sunpath') +# Direct controls on the right half of a double-width card (firmware 0.2.19+), like +# Home Assistant's own entity rows. The first choice is what a wide card shows +# when the tile has no explicit choice; 'none' keeps the plain card. +CONTROLS = { + 'climate': (('setpoint', 'Temperatuur − / +'), ('mode', 'Uit, verwarmen, koelen')), + 'switch': (('toggle', 'Aan/uit-schakelaar'),), + 'input_boolean': (('toggle', 'Aan/uit-schakelaar'),), + 'light': (('toggle', 'Aan/uit-schakelaar'), ('brightness', 'Helderheidsschuif')), + 'fan': (('toggle', 'Aan/uit-schakelaar'), ('speed', 'Snelheidsschuif')), + 'vacuum': (('buttons', 'Start, stop, naar dock'),), + 'cover': (('buttons', 'Open, stop, dicht'), ('position', 'Positieschuif')), + 'media_player': (('volume', 'Volume en dempen'), ('playback', 'Vorige, play/pauze, volgende')), + 'number': (('stepper', 'Waarde − / +'), ('slider', 'Schuif')), + 'input_number': (('stepper', 'Waarde − / +'), ('slider', 'Schuif')), + 'select': (('stepper', 'Vorige / volgende keuze'),), + 'input_select': (('stepper', 'Vorige / volgende keuze'),), + 'timer': (('buttons', 'Start/pauze en annuleren'),), + 'scene': (('run', 'Knop Activeren'),), + 'script': (('run', 'Knop Uitvoeren'),), + 'button': (('run', 'Knop Indrukken'),), + 'input_button': (('run', 'Knop Indrukken'),), +} + +def controls_catalogue(): + """Editor choices per domain: the default first, then 'none'.""" + return {domain: {'default': choices[0][0], 'choices': [{'key': key, 'label': label} for key, label in choices] + [{'key': 'none', 'label': 'Geen'}]} + for domain, choices in CONTROLS.items()} + +def resolve_controls(tile): + """Control set a card shows on the screen, or None: only wide cards in the standard layout have room for one.""" + options = tile.get('options', {}) + domain = tile['entity'].split('.')[0] + if domain not in CONTROLS or options.get('size') != 'wide' or options.get('display', 'standard') != 'standard' or options.get('inline') == 'slider': + return None + choice = options.get('controls', CONTROLS[domain][0][0]) + return None if choice == 'none' else choice + # Additive schema 1 extension. An absent object retains old firmware/YAML defaults. SETTING_RULES = { 'standby_enabled': (True, None, None), @@ -114,7 +153,7 @@ 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', 'size', 'icon'}: + if not isinstance(options, dict) or set(options) - {'tap', 'display', 'inline', 'history_hours', 'background', 'size', 'icon', 'controls'}: 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.') @@ -129,7 +168,7 @@ def validate_layout(data): # The five-day strip and the sun path only fit a double-width card. if options.get('display') in WIDE_ONLY: options = {**options, 'size': 'wide'} - if options.get('tap') == 'toggle' and domain not in {'light','switch','input_boolean','fan','media_player'}: + if options.get('tap') == 'toggle' and domain not in {'light','switch','input_boolean','fan','media_player','climate'}: 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'}: raise ValueError('Deze entiteit ondersteunt geen mini-schuif.') @@ -137,6 +176,10 @@ def validate_layout(data): raise ValueError('Geschiedenis: kies 1, 6 of 24 uur.') if options.get('display') == 'watch' and options.get('inline') == 'slider': raise ValueError('Kies grote waarde of mini-schuif.') + if 'controls' in options: + allowed = ('none',) + tuple(key for key, _ in CONTROLS.get(domain, ())) + if not isinstance(options['controls'], str) or options['controls'] not in allowed: + raise ValueError('Deze entiteit ondersteunt die directe bediening niet.') item['options'] = dict(options) clean.append(item) result = {'title': title.strip(), 'tiles': clean} @@ -154,26 +197,71 @@ def local_clock(value, tz): moment = moment.replace(tzinfo=timezone.utc) return moment.astimezone(tz or timezone.utc).strftime('%H:%M') -def extras(tile, states, forecast=None, tz=None): +def forecast_number(entry, name): + value = entry.get(name) + return round(value, 1) if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) else None + +def forecast_time(entry, tz): + try: + return datetime.fromisoformat(str(entry.get('datetime')).replace('Z', '+00:00')).astimezone(tz or timezone.utc) + except (ValueError, TypeError): + return None + +def epoch(value): + """Unix time of an ISO timestamp (a scene's state, a script's last_triggered); None when unusable.""" + try: + moment = datetime.fromisoformat(str(value).replace('Z', '+00:00')) + except (ValueError, TypeError): + return None + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + return int(moment.timestamp()) + +def extras(tile, states, forecast=None, tz=None, hourly=None, now=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: + if domain == 'weather' and (forecast or hourly): + result = {} days = [] - for entry in forecast[:5]: - if not isinstance(entry, dict): + for entry in forecast or []: + if not isinstance(entry, dict) or len(days) == 5: continue - try: - day = datetime.fromisoformat(str(entry.get('datetime')).replace('Z', '+00:00')).astimezone(tz or timezone.utc) - except (ValueError, TypeError): + day = forecast_time(entry, tz) + if day is None: 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) + # h/l: high and low; p: chance of rain in %; r: rain in the entity's unit (mm). + for key, name in (('h', 'temperature'), ('l', 'templow'), ('p', 'precipitation_probability'), ('r', 'precipitation')): + value = forecast_number(entry, name) + if value is not None: + item[key] = value days.append(item) - return {'days': days} if days else None + if days: + result['days'] = days + # The next eight hours from now, for the weather card's hourly strip. + hours = [] + # The running hour still counts: an entry stays until its hour has passed. + start = (now or datetime.now(timezone.utc)) - timedelta(minutes=59) + for entry in hourly or []: + if not isinstance(entry, dict) or len(hours) == 8: + continue + moment = forecast_time(entry, tz) + if moment is None or moment < start: + continue + item = {'t': moment.strftime('%H:%M'), 'c': short(entry.get('condition') or '', 20)} + for key, name in (('h', 'temperature'), ('p', 'precipitation_probability'), ('r', 'precipitation')): + value = forecast_number(entry, name) + if value is not None: + item[key] = value + hours.append(item) + if hours: + result['hours'] = hours + return result or None + if domain in ('scene', 'script', 'button', 'input_button'): + # When it last ran: scripts report last_triggered; scenes and buttons carry the time as their state. + last = epoch(attrs.get('last_triggered') if domain == 'script' else states.get(tile['entity'], {}).get('state')) + return {'last': last} if last 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 @@ -196,11 +284,15 @@ def tile_icon(tile, attrs): return tile_icons.ICONS[choice][0] if choice in tile_icons.ICONS else tile_icons.ha_icon(attrs) def screen_options(tile, attrs): - """Stored options on the wire; `icon` travels as the resolved codepoint (firmware 0.2.18+, ignored before).""" - options = {k: v for k, v in tile.get('options', {}).items() if k != 'icon'} + """Stored options on the wire; `icon` travels as the resolved codepoint (firmware 0.2.18+, ignored before) + and `controls` only as the set the card really shows (firmware 0.2.19+, ignored before).""" + options = {k: v for k, v in tile.get('options', {}).items() if k not in ('icon', 'controls')} icon = tile_icon(tile, attrs) if icon: options['icon'] = icon + controls = resolve_controls(tile) + if controls: + options['controls'] = controls return options if options or 'options' in tile else None def state_message(index, tile, states, extra=None): @@ -215,10 +307,15 @@ def state_message(index, tile, states, extra=None): bounded = {} for key in ATTRS: value = attrs.get(key) - if isinstance(value, bool) or value is None: + if isinstance(value, bool): + if key in BOOL_ATTRS: + bounded[key] = value + continue + if value is None: continue if isinstance(value, (int, float)): - if math.isfinite(value) and abs(value) <= 1000000: + # supported_features is a bit field (media players exceed 8 million); other numbers are display values. + if math.isfinite(value) and abs(value) <= (2**31 if key == 'supported_features' else 1000000): bounded[key] = value elif isinstance(value, str): bounded[key] = short(value, 48) diff --git a/screen_manager/app/server.py b/screen_manager/app/server.py index 2406c65..85b1ca5 100644 --- a/screen_manager/app/server.py +++ b/screen_manager/app/server.py @@ -14,7 +14,7 @@ import tile_icons from updates import Updater from aiohttp import ClientSession, ClientTimeout, WSMsgType, web -from core import BUILTIN, TILE_BACKGROUNDS, discover, extras, installation_yaml, min_firmware, packets, state_message, validate_layout, validate_settings +from core import BUILTIN, TILE_BACKGROUNDS, controls_catalogue, discover, extras, installation_yaml, min_firmware, packets, state_message, validate_layout, validate_settings from zoneinfo import ZoneInfo LOG = logging.getLogger('screen_manager') @@ -166,10 +166,10 @@ class HomeAssistant: await self.request('call_service', domain='text', service='set_value', service_data={'entity_id': inbox, 'value': packet}) - async def forecast(self, entity): + async def forecast(self, entity, kind='daily'): # 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) + service_data={'type': kind}, target={'entity_id': entity}, return_response=True) forecast = ((result or {}).get('response') or {}).get(entity, {}).get('forecast', []) return forecast if isinstance(forecast, list) else [] @@ -262,7 +262,7 @@ class Manager: old_tiles = {t['entity']:t for t in self.layouts.get(inbox,{}).get('tiles',[])} for tile in layout['tiles']: old_options=old_tiles.get(tile['entity'],{}).get('options',{}) - for key in ('background', 'icon'): + for key in ('background', 'icon', 'controls'): if 'options' in tile and key not in tile['options'] and key in old_options: tile['options'][key]=old_options[key] if 'options' not in tile and 'options' in old_tiles.get(tile['entity'],{}): @@ -319,10 +319,12 @@ class Manager: if screen.get('board')=='guition': messages[0]['rotation'] = layout['settings'].get('rotation',0) for i,tile in enumerate(layout['tiles']): - forecast=None + forecast=hourly=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))) + # Hourly forecasts feed the weather card's next-hours strip (0.2.23+); refreshed every half hour. + hourly=await self.cached(self.forecasts, (tile['entity'],'hourly'), 1800, lambda: self.ha.forecast(tile['entity'],'hourly')) + message=state_message(i,tile,self.ha.states,extras(tile,self.ha.states,forecast,getattr(self.ha,'time_zone',None),hourly)) if tile['entity'].startswith('sensor.') and hasattr(self.ha,'history'): hours=tile.get('options',{}).get('history_hours',24) key=(tile['entity'],hours) @@ -433,6 +435,7 @@ def create_app(manager, development=False): # The page polls the light form; entities, backgrounds and icons (~100 KB) only on demand. payload['entities'] = entities payload['backgrounds'] = TILE_BACKGROUNDS + payload['controls'] = controls_catalogue() payload['icons'] = tile_icons.editor() payload['builtin'] = [{'id': key, 'name': name, 'device': 'Ingebouwd op het scherm', 'area': '', 'state': 'ok'} for key, name in BUILTIN.items()] return web.json_response(payload) diff --git a/screen_manager/app/static/app.js b/screen_manager/app/static/app.js index ece665c..7280c7e 100644 --- a/screen_manager/app/static/app.js +++ b/screen_manager/app/static/app.js @@ -369,6 +369,40 @@ const domains = { person: ["Persoon", "☺", "#2f7d32", "#e1f2e2"], }; const displayNames = { standard: "standaard", watch: "grote waarde", forecast: "weersvoorspelling", graph: "grafiek", digital: "digitale klok", analog: "analoge klok", sunpath: "zonnebaan" }; +// Same rule as the add-on: only a wide card in the standard layout shows direct controls; +// without a choice the domain's first control set applies. +function effectiveControls(tile) { + const domain = tile.entity.split(".")[0], catalogue = inventory.controls?.[domain], o = tile.options || {}; + if (!catalogue || o.size !== "wide" || (o.display || "standard") !== "standard" || o.inline === "slider") return null; + const choice = o.controls ?? catalogue.default; + return choice === "none" ? null : choice; +} +function controlsLabel(tile) { + const key = effectiveControls(tile); + if (!key) return "geen"; + return inventory.controls?.[tile.entity.split(".")[0]]?.choices.find((c) => c.key === key)?.label.toLocaleLowerCase() || key; +} +// Miniature of the control set on the mockup card: the same shapes the screen draws. +function controlsPreview(tile) { + const key = effectiveControls(tile), domain = tile.entity.split(".")[0], cp = inventory.icons?.controls || {}; + if (!key) return null; + const box = node("span", undefined, "preview-controls"); + const buttons = (...names) => { for (const name of names) box.append(node("span", cp[name] ? glyph(cp[name]) : "", "preview-key mdi")); }; + const stepper = (text) => { const pill = node("span", undefined, "preview-stepper"); pill.append(node("span", cp.minus ? glyph(cp.minus) : "−", "mdi"), node("b", text), node("span", cp.plus ? glyph(cp.plus) : "+", "mdi")); box.append(pill); }; + const slider = () => box.append(node("span", "", "preview-range")); + if (key === "toggle") box.append(node("span", "", "preview-toggle")); + else if (key === "setpoint") stepper("20°"); + else if (key === "stepper") domain.endsWith("select") ? buttons("chevron-left", "chevron-right") : stepper("50"); + else if (key === "mode") buttons("power", "fire", "snowflake"); + else if (key === "volume") { slider(); buttons("volume-high"); } + else if (key === "playback") buttons("skip-previous", "play", "skip-next"); + else if (key === "buttons" && domain === "cover") buttons("arrow-expand-horizontal", "stop", "arrow-collapse-horizontal"); + else if (key === "buttons" && domain === "vacuum") buttons("play", "stop", "home-map-marker"); + else if (key === "buttons" && domain === "timer") buttons("play", "close"); + else if (key === "run") box.append(node("b", { scene: "Activeren", script: "Uitvoeren" }[domain] || "Indrukken", "preview-run")); + else slider(); + return box; +} // New tiles start with the card that shows the entity best. function defaultOptions(id) { const domain = id.split(".")[0]; @@ -474,6 +508,8 @@ function renderPreview() { remove.onclick = (e) => { e.stopPropagation(); removeTile(index); }; card.append(remove); if (tile.options?.inline === "slider") card.append(node("span", "", "preview-slider")); + const controls = controlsPreview(tile); + if (controls) card.append(controls); const display = tile.options?.display; if (display && display !== "standard") card.append(node("small", displayNames[display] || display)); grid.append(card); @@ -659,17 +695,27 @@ function renderTileSheet() { const current = (key, fallback) => tile.options?.[key] ?? fallback; const set = (key, value) => { tile.options = { ...tile.options, [key]: value }; - if (key === "display" && value === "watch") tile.options.inline = "none"; + // Direct controls need the standard layout without a mini slider, and vice versa. + if (key === "display" && value === "watch") { tile.options.inline = "none"; if (inventory.controls?.[domain]) tile.options.controls = "none"; } if (key === "display" && ["forecast", "sunpath"].includes(value)) tile.options.size = "wide"; - if (key === "inline" && value === "slider") tile.options.display = "standard"; + if (key === "inline" && value === "slider") { tile.options.display = "standard"; if (inventory.controls?.[domain]) tile.options.controls = "none"; } + if (key === "controls" && value !== "none") { tile.options.display = "standard"; tile.options.inline = "none"; } markDirty(); renderTiles(); }; body.append(field("Weergave", segmented(displays, current("display", domain === "screen" ? "digital" : "standard"), (v) => set("display", v)))); body.append(field("Breedte", segmented([["single", "Normaal"], ["wide", "Dubbelbreed"]], current("size", "single"), (v) => set("size", v)))); + const catalogue = inventory.controls?.[domain]; + if (catalogue && current("size", "single") === "wide") { + const wrap = field("Directe bediening op de tegel", segmented(catalogue.choices.map((c) => [c.key, c.label]), current("controls", catalogue.default), (v) => set("controls", v))); + wrap.append(node("small", supportsFirmware(0, 2, 19) + ? "Rechts op de dubbelbrede tegel, zoals de rijen in Home Assistant. Tikken op de naam werkt zoals hieronder ingesteld." + : "Het scherm toont directe bediening vanaf firmware 0.2.19; tot die tijd blijft de tegel zoals hij was.", "field-hint")); + body.append(wrap); + } if (domain !== "screen") { const taps = [["auto", "Automatisch"], ["detail", "Bediening openen"], ["none", "Alleen bekijken"]]; - if (["light", "switch", "input_boolean", "fan", "media_player"].includes(domain)) taps.push(["toggle", "Aan / uit"]); + if (["light", "switch", "input_boolean", "fan", "media_player", "climate"].includes(domain)) taps.push(["toggle", "Aan / uit"]); body.append(field("Bij aantikken", segmented(taps, current("tap", "auto"), (v) => set("tap", v)))); } if (["light", "fan", "cover", "number", "input_number", "media_player"].includes(domain)) @@ -1190,7 +1236,7 @@ async function inspect(entity) { card.append( node( "small", - `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"}`, + `Kleine slider: ${options.inline === "slider" ? "ja" : "nee"} · Weergave: ${displayNames[options.display || "standard"] || options.display} · Breedte: ${options.size === "wide" ? "dubbel" : "normaal"} · Bediening: ${controlsLabel({ entity: tile.entity, options })} · 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 c20fad1..44fdf80 100644 --- a/screen_manager/app/static/index.html +++ b/screen_manager/app/static/index.html @@ -55,7 +55,7 @@
  • 2 · Tegels kiezenSleep entiteiten uit de lijst rechts in de schermmockup, of tik - op +. Klik op een tegel voor weergave, breedte en kleur.
  • @@ -126,8 +126,8 @@ 0 / 10

    - Klik op een tegel voor naam, weergave, breedte en kleur; het - kruisje verwijdert. Sleep om te ordenen of sleep entiteiten uit + Klik op een tegel voor naam, weergave, breedte, directe + bediening en kleur; het kruisje verwijdert. Sleep om te ordenen of sleep entiteiten uit de lijst hierheen. 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 c7b056b..7465d22 100644 --- a/screen_manager/app/static/style.css +++ b/screen_manager/app/static/style.css @@ -912,6 +912,16 @@ pre { .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%); } +/* Miniature direct controls on a wide mockup card, in the shapes the screen draws. */ +.preview-controls { display: flex; align-items: center; gap: 4px; flex: none; margin-right: 18px; color: #1b1b1b; } +.preview-key { display: grid; place-items: center; width: 28px; height: 22px; border-radius: 7px; background: #eceef1; font-size: 14px; } +.preview-stepper { display: flex; align-items: center; gap: 4px; height: 22px; padding: 0 7px; border-radius: 11px; background: #eceef1; font-size: 12px; } +.preview-stepper b { font-size: 12px; font-weight: 600; } +.preview-range { width: 60px; height: 20px; border-radius: 10px; background: linear-gradient(to right, #2196f3 35%, #d3e8fb 35%); } +.preview-toggle { position: relative; width: 34px; height: 20px; border-radius: 10px; background: #2196f3; } +.preview-toggle::after { content: ""; position: absolute; top: 3px; right: 3px; width: 14px; height: 14px; border-radius: 50%; background: white; } +.preview-run { height: 22px; padding: 0 10px; border-radius: 11px; background: #eceef1; font-size: 12px; font-weight: 600; line-height: 22px; } +.field-hint { font-weight: 400; } .preview-tile small { flex-basis: 100%; } .result:hover:not(:disabled), .preview-tile:hover { border-color: #9eb6cf; box-shadow: 0 2px 7px #253f5010; } diff --git a/screen_manager/app/static/tile-icons.woff b/screen_manager/app/static/tile-icons.woff index 9233970ad3dde5d32676c6834d47a20b2eaca230..1edf2a8418eb0766a1e82cea2eebfbbe2c3c1650 100644 GIT binary patch delta 15339 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 A0RR91 delta 14372 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 UE|>;u{kPtPnw8aLLVk(=0f4b=d;kCd diff --git a/screen_manager/app/tile_icons.py b/screen_manager/app/tile_icons.py index bbab534..5f052c5 100644 --- a/screen_manager/app/tile_icons.py +++ b/screen_manager/app/tile_icons.py @@ -184,10 +184,29 @@ GROUPS = ( )), ) -# Glyphs the firmware draws itself (weather conditions, sun, checkmark) that the picker does not offer. +# Glyphs the firmware draws itself (weather conditions, sun, checkmark, direct controls) that the picker does not offer. FIXED = ( ('alert-circle-outline', 'F05D6'), ('check', 'F012C'), + # Direct controls on wide cards (firmware 0.2.19+). + ('pause', 'F03E4'), + ('stop', 'F04DB'), + ('skip-next', 'F04AD'), + ('skip-previous', 'F04AE'), + ('volume-high', 'F057E'), + ('volume-off', 'F0581'), + ('arrow-up', 'F005D'), + ('arrow-down', 'F0045'), + ('arrow-expand-horizontal', 'F084E'), + ('arrow-collapse-horizontal', 'F084C'), + ('home-map-marker', 'F05F8'), + ('plus', 'F0415'), + ('minus', 'F0374'), + ('chevron-left', 'F0141'), + ('chevron-right', 'F0142'), + ('close', 'F0156'), + ('sun-snowflake-variant', 'F1A79'), + ('thermostat-auto', 'F1B17'), ('weather-fog', 'F0591'), ('weather-hail', 'F0592'), ('weather-lightning', 'F0593'), @@ -206,6 +225,9 @@ DEFAULTS = {'light': 'lightbulb', 'climate': 'air-conditioner', 'vacuum': 'robot 'cover': 'window-shutter', 'scene': 'sofa', 'script': 'sofa', 'sensor': 'gauge', 'binary_sensor': 'gauge', 'timer': 'timer-outline', 'person': 'account', 'screen': 'clock-outline'} FALLBACK = 'power' +CONTROL_GLYPHS = ('play', 'pause', 'stop', 'skip-next', 'skip-previous', 'volume-high', 'volume-off', 'arrow-up', 'arrow-down', + 'arrow-expand-horizontal', 'arrow-collapse-horizontal', 'home-map-marker', 'plus', 'minus', 'chevron-left', + 'chevron-right', 'close', 'power', 'fire', 'snowflake') WEATHER = {'sunny': 'weather-sunny', 'clear-night': 'weather-night', 'cloudy': 'weather-cloudy', 'partlycloudy': 'weather-partly-cloudy', 'rainy': 'weather-rainy', 'pouring': 'weather-pouring', 'snowy': 'weather-snowy', 'snowy-rainy': 'weather-snowy-rainy', 'fog': 'weather-fog', 'hail': 'weather-hail', @@ -223,4 +245,6 @@ def editor(): for group, icons in GROUPS], 'defaults': {domain: GLYPHS[name] for domain, name in DEFAULTS.items()}, 'fallback': GLYPHS[FALLBACK], 'weather': {state: GLYPHS[name] for state, name in WEATHER.items()}, - 'sun': {'above_horizon': GLYPHS['weather-sunset-down'], 'below_horizon': GLYPHS['weather-sunset-up']}} + 'sun': {'above_horizon': GLYPHS['weather-sunset-down'], 'below_horizon': GLYPHS['weather-sunset-up']}, + # Glyphs of the direct controls on wide cards, so the mockup previews them. + 'controls': {name: GLYPHS[name] for name in CONTROL_GLYPHS}} diff --git a/screen_manager/config.yaml b/screen_manager/config.yaml index dbbc470..cd3c5e3 100644 --- a/screen_manager/config.yaml +++ b/screen_manager/config.yaml @@ -1,5 +1,5 @@ name: ESP Screen Manager -version: "0.2.22" +version: "0.2.23" 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_cyd_ui.cpp b/tests/test_cyd_ui.cpp index dcdafcc..1d23c64 100644 --- a/tests/test_cyd_ui.cpp +++ b/tests/test_cyd_ui.cpp @@ -3,6 +3,12 @@ #include int main() { using cyd::quantize_temperature; + // Fan and swing mode chips read the n-th name of a JSON list attribute. + assert(cyd::list_item("[\"auto\",\"low\",\"high\"]", 0) == "auto"); + assert(cyd::list_item("[\"auto\",\"low\",\"high\"]", 2) == "high"); + assert(cyd::list_item("[\"auto\",\"low\",\"high\"]", 3).empty()); + assert(cyd::list_item("", 0).empty()); + assert(cyd::list_item("[\"broken", 0).empty()); assert(quantize_temperature(223, 160, 300, 5) == 225); assert(quantize_temperature(221, 160, 300, 5) == 220); assert(quantize_temperature(-1, 160, 300, 5) == 160); diff --git a/tests/test_rich_cards.py b/tests/test_rich_cards.py new file mode 100644 index 0000000..9469b60 --- /dev/null +++ b/tests/test_rich_cards.py @@ -0,0 +1,84 @@ +"""Weather card data (hours, rain) and last-run times for scenes, scripts and buttons.""" +import asyncio +from datetime import datetime, timezone +from pathlib import Path +import sys +import tempfile +import unittest +from zoneinfo import ZoneInfo + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'screen_manager/app')) +from core import extras, packets, state_message +import test_portal + +TZ = ZoneInfo('Europe/Amsterdam') +NOW = datetime(2026, 9, 13, 11, 40, tzinfo=timezone.utc) + + +def hourly(): + return [{'datetime': f'2026-09-13T{h:02d}:00:00+00:00', 'condition': 'rainy' if h < 12 else 'partlycloudy', 'temperature': 18 + h / 10, + 'precipitation': 0.2 if h < 12 else 0.0, 'precipitation_probability': 60 if h < 12 else 5} for h in range(8, 30)] + + +def daily(): + return [{'datetime': f'2026-09-{13 + i}T10:00:00+00:00', 'condition': 'cloudy', 'temperature': 21 + i, 'templow': 12.1, 'precipitation': 1.7 if i else 0.0} + for i in range(6)] + + +class WeatherExtras(unittest.TestCase): + def test_hours_start_now_and_carry_rain(self): + states = {'weather.huis': {'state': 'rainy', 'attributes': {'temperature': 18.4, 'humidity': 92, 'wind_speed': 12.2, 'wind_speed_unit': 'km/h'}}} + extra = extras({'entity': 'weather.huis'}, states, daily(), TZ, hourly(), NOW) + self.assertEqual([h['t'] for h in extra['hours']], ['13:00', '14:00', '15:00', '16:00', '17:00', '18:00', '19:00', '20:00'], 'local time, from the current hour') + self.assertEqual(extra['hours'][0], {'t': '13:00', 'c': 'rainy', 'h': 19.1, 'p': 60, 'r': 0.2}) + self.assertEqual(extra['hours'][1]['r'], 0.0) + self.assertEqual(len(extra['days']), 5) + self.assertEqual(extra['days'][1], {'d': 'ma', 'c': 'cloudy', 'h': 22, 'l': 12.1, 'r': 1.7}, 'no probability when the provider gives none') + message = state_message(0, {'entity': 'weather.huis', 'name': 'Buiten', 'options': {'display': 'forecast'}}, states, extra) + self.assertEqual(message['a']['wind_speed_unit'], 'km/h') + self.assertEqual(message['a']['humidity'], 92) + self.assertLessEqual(len(packets(message)), 8, 'a full weather card still fits comfortably') + + def test_weather_without_forecasts_sends_nothing_extra(self): + self.assertIsNone(extras({'entity': 'weather.huis'}, {}, None, TZ, None, NOW)) + self.assertEqual(extras({'entity': 'weather.huis'}, {}, daily(), TZ, [], NOW).keys(), {'days'}) + + +class LastRun(unittest.TestCase): + def test_scripts_scenes_and_buttons_report_when_they_last_ran(self): + states = {'script.tv': {'state': 'off', 'attributes': {'last_triggered': '2026-09-13T08:10:49.403953+00:00'}}, + 'scene.avond': {'state': '2026-09-12T16:33:37.040291+00:00', 'attributes': {}}, + 'button.bel': {'state': '2026-09-13T09:00:00+00:00', 'attributes': {}}, + 'script.nieuw': {'state': 'off', 'attributes': {'last_triggered': None}}, + 'scene.nooit': {'state': 'unknown', 'attributes': {}}} + self.assertEqual(extras({'entity': 'script.tv'}, states, None, TZ), {'last': 1789287049}) + self.assertEqual(extras({'entity': 'scene.avond'}, states, None, TZ), {'last': 1789230817}) + self.assertEqual(extras({'entity': 'button.bel'}, states, None, TZ), {'last': 1789290000}) + self.assertIsNone(extras({'entity': 'script.nieuw'}, states, None, TZ)) + self.assertIsNone(extras({'entity': 'scene.nooit'}, states, None, TZ)) + message = state_message(0, {'entity': 'script.tv', 'name': ''}, states, extras({'entity': 'script.tv'}, states, None, TZ)) + self.assertEqual(message['x'], {'last': 1789287049}) + self.assertNotIn('last_triggered', message['a'], 'the raw timestamp stays behind; the firmware gets the epoch') + + +class HourlySync(unittest.IsolatedAsyncioTestCase): + async def test_manager_asks_for_daily_and_hourly_forecasts(self): + with tempfile.TemporaryDirectory() as tmp: + m = test_portal.ManagerTests().setup_manager(Path(tmp) / 'screens.json') + m.ha.states['weather.huis'] = {'state': 'rainy', 'attributes': {'friendly_name': 'Huis', 'temperature': 18.4}} + asked = [] + async def forecast(entity, kind='daily'): + asked.append(kind) + return daily() if kind == 'daily' else hourly() + m.ha.forecast = forecast + m.ha.registry.append({'entity_id': 'sensor.fw', 'platform': 'esphome', 'original_name': 'Schermfirmware'}) + m.ha.states['sensor.fw'] = {'state': '0.2.19'} + m.save('text.screen', {'title': 'Thuis', 'tiles': [{'entity': 'weather.huis', 'name': '', 'options': {'display': 'forecast'}}]}) + await m.sync_one('text.screen', m.layouts['text.screen']) + self.assertEqual(sorted(asked), ['daily', 'hourly']) + sent = m.ha.messages[1][1] + self.assertEqual(len(sent['x']['days']), 5) + self.assertTrue(sent['x']['hours']) + await m.sync_one('text.screen', m.layouts['text.screen']) + self.assertEqual(len(asked), 2, 'both forecasts are cached') diff --git a/tests/test_tile_controls.cpp b/tests/test_tile_controls.cpp new file mode 100644 index 0000000..a316c25 --- /dev/null +++ b/tests/test_tile_controls.cpp @@ -0,0 +1,104 @@ +// c++ -std=c++17 -Wall -Wextra -pedantic tests/test_tile_controls.cpp -o /tmp/test_tile_controls && /tmp/test_tile_controls +#include "../components/smart_display/tile_controls.h" +#include +#include +#include + +using namespace tile_controls; +using runtime_tiles::Tile; + +static Tile make(const char *entity, const char *state, uint32_t supported = 0) { + Tile t; t.entity = entity; t.state = state; t.supported = supported; t.received = true; return t; +} + +int main() { + // -/+ steps snap to the entity's grid and stay inside its range. + assert(step_value(20.0f, 0.5f, 16, 32, 1) == 20.5f); + assert(step_value(20.3f, 0.5f, 16, 32, -1) == 20.0f); + assert(step_value(31.8f, 1.0f, 16, 32, 1) == 32.0f); + assert(step_value(16.0f, 1.0f, 16, 32, -1) == 16.0f); + assert(step_value(NAN, 1.0f, 16, 32, 1) == 17.0f); + assert(step_value(5.0f, 0.0f, NAN, NAN, 1) == 6.0f); + assert(format_value(20.0f, 1.0f, "°") == "20°"); + assert(format_value(20.5f, 0.5f, "°") == "20.5°"); + assert(format_value(NAN, 0.5f, "°") == "--"); + + // Curtain: horizontal arrows, open disabled when fully open, stop only when supported. + Tile curtain = make("cover.gordijnen", "open", 15); curtain.device_class = "curtain"; curtain.position = 100; curtain.controls = "buttons"; + std::array keys; + assert(keys_for(curtain, keys) == 3); + assert(!strcmp(keys[0].icon, glyph::EXPAND) && keys[0].command == COVER_OPEN && keys[0].disabled); + assert(keys[1].command == COVER_STOP && !keys[1].disabled); + assert(!strcmp(keys[2].icon, glyph::COLLAPSE) && keys[2].command == COVER_CLOSE && !keys[2].disabled); + assert(key_action(curtain, COVER_CLOSE).service == "cover.close_cover"); + Tile shutter = make("cover.rolluik", "closed", 3); shutter.controls = "buttons"; + assert(keys_for(shutter, keys) == 2 && !strcmp(keys[0].icon, glyph::UP) && keys[1].disabled); + assert(status_text(curtain) == "Open · 100%"); + assert(status_text(shutter) == "Dicht"); + + // Vacuum: play while docked, pause while cleaning, dock disabled in the dock. + Tile robot = make("vacuum.s8", "docked", 30524); robot.controls = "buttons"; + assert(keys_for(robot, keys) == 3); + assert(!strcmp(keys[0].icon, glyph::PLAY) && keys[0].command == VACUUM_START && !keys[0].disabled); + assert(keys[1].command == VACUUM_STOP && keys[1].disabled); + assert(keys[2].command == VACUUM_DOCK && keys[2].disabled); + assert(key_action(robot, VACUUM_START).service == "vacuum.start"); + robot.state = "cleaning"; + assert(keys_for(robot, keys) == 3 && keys[0].command == VACUUM_PAUSE && !keys[1].disabled && !keys[2].disabled); + Tile old_robot = make("vacuum.old", "docked", feature::VACUUM_TURN_ON | feature::VACUUM_RETURN); old_robot.controls = "buttons"; + assert(keys_for(old_robot, keys) == 2 && key_action(old_robot, VACUUM_START).service == "vacuum.turn_on"); + + // Media: playback keys follow supported_features; mute flips is_volume_muted. + Tile sonos = make("media_player.sonos", "playing", 8321599); sonos.volume = 0.17f; sonos.media_title = "TV"; sonos.controls = "playback"; + assert(keys_for(sonos, keys) == 3 && !strcmp(keys[1].icon, glyph::PAUSE) && keys[1].command == MEDIA_PLAY_PAUSE); + assert(status_text(sonos) == "TV · 17%"); + Action mute = key_action(sonos, MEDIA_MUTE); + assert(mute.service == "media_player.volume_mute" && mute.key == "is_volume_muted" && mute.value == "true"); + sonos.muted = true; assert(key_action(sonos, MEDIA_MUTE).value == "false"); + Tile radio = make("media_player.radio", "idle", feature::MEDIA_PLAY | feature::MEDIA_PAUSE); radio.controls = "playback"; + assert(keys_for(radio, keys) == 1 && !strcmp(keys[0].icon, glyph::PLAY)); + assert(status_text(radio) == "Inactief"); + + // Climate: mode keys in priority order, at most three, the active one checked. + Tile ac = make("climate.airco", "cool"); ac.hvac_modes = "[\"off\",\"heat_cool\",\"cool\",\"heat\",\"fan_only\",\"dry\"]"; ac.controls = "mode"; ac.current = 21.5f; ac.target = 20; ac.step = 1; + assert(keys_for(ac, keys) == 3); + assert(keys[0].arg == "off" && keys[1].arg == "heat" && keys[2].arg == "cool" && keys[2].checked && !keys[1].checked); + Action mode = key_action(ac, HVAC_MODE, keys[1].arg); + assert(mode.service == "climate.set_hvac_mode" && mode.key == "hvac_mode" && mode.value == "heat"); + assert(status_text(ac) == "Koelen · 21.5°"); + ac.hvac_action = "cooling"; assert(status_text(ac) == "Koelt · 21.5°"); + assert(edit_target(ac) == 20 && edit_step(ac) == 1); + Action set = edit_action(ac, step_value(edit_target(ac), edit_step(ac), ac.minimum, ac.maximum, 1)); + assert(set.service == "climate.set_temperature" && set.key == "temperature" && set.value == "21"); + assert(edit_action(ac, 20.5f).value == "20.5"); + + // Numbers edit their own state; selects step through their options with wrap-around. + Tile number = make("number.doel", "55"); number.minimum = 0; number.maximum = 100; number.step = 5; + assert(edit_target(number) == 55 && edit_action(number, step_value(55, 5, 0, 100, -1)).value == "50"); + assert(edit_action(number, 50).service == "number.set_value"); + Tile select = make("select.stand", "Comfort"); select.options = {"Eco", "Comfort", "Boost"}; select.option_count = 3; select.controls = "stepper"; + assert(panel_kind(select) == "chevrons"); + assert(keys_for(select, keys) == 2 && !keys[0].disabled); + assert(key_action(select, SELECT_NEXT).value == "Boost" && key_action(select, SELECT_PREVIOUS).value == "Eco"); + select.state = "Boost"; assert(key_action(select, SELECT_NEXT).value == "Eco"); + select.option_count = 1; assert(keys_for(select, keys) == 2 && keys[0].disabled); + + // Timer, run buttons and toggles. + Tile timer = make("timer.eieren", "active"); timer.controls = "buttons"; + assert(keys_for(timer, keys) == 2 && keys[0].command == TIMER_PAUSE && !keys[1].disabled); + timer.state = "idle"; assert(keys_for(timer, keys) == 2 && keys[0].command == TIMER_START && keys[1].disabled); + assert(key_action(make("scene.avond", "unknown"), RUN).service == "scene.turn_on"); + assert(key_action(make("script.alles", "off"), RUN).service == "script.turn_on"); + assert(key_action(make("input_button.bel", "unknown"), RUN).service == "input_button.press"); + assert(!strcmp(run_label("scene"), "Activeren") && !strcmp(run_label("button"), "Indrukken")); + assert(key_action(make("switch.bureau", "on"), TOGGLE).service == "switch.turn_off"); + assert(key_action(make("light.lamp", "off"), TOGGLE).service == "light.turn_on"); + assert(!key_action(make("sensor.x", "1"), TOGGLE).valid()); + assert(!key_action(make("sensor.x", "1"), RUN).valid()); + + // Panel kinds. + Tile lamp = make("light.lamp", "on"); lamp.controls = "brightness"; + assert(is_slider(panel_kind(lamp)) && !is_key_row(panel_kind(lamp))); + assert(is_key_row("playback") && is_key_row("mode") && !is_key_row("setpoint")); + return 0; +} diff --git a/tests/test_tile_controls.py b/tests/test_tile_controls.py new file mode 100644 index 0000000..2e57160 --- /dev/null +++ b/tests/test_tile_controls.py @@ -0,0 +1,101 @@ +"""Direct controls on wide cards: editor choices, validation and what goes on the wire.""" +import importlib.util +from pathlib import Path +import re +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'screen_manager/app')) +from core import CONTROLS, controls_catalogue, min_firmware, packets, resolve_controls, screen_options, state_message, validate_layout +import tile_icons +import test_portal + +HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None + + +class ControlChoices(unittest.TestCase): + def test_catalogue_offers_each_domain_its_sets_plus_none(self): + catalogue = controls_catalogue() + self.assertEqual(catalogue['media_player']['default'], 'volume') + self.assertEqual([c['key'] for c in catalogue['media_player']['choices']], ['volume', 'playback', 'none']) + self.assertEqual([c['key'] for c in catalogue['climate']['choices']], ['setpoint', 'mode', 'none']) + for domain in ('climate', 'switch', 'vacuum', 'cover', 'media_player'): + self.assertIn(domain, catalogue) + self.assertNotIn('sensor', catalogue) + + def test_validation_accepts_own_sets_and_rejects_others(self): + layout = validate_layout({'title': 'T', 'tiles': [{'entity': 'cover.gordijn', 'options': {'size': 'wide', 'controls': 'position'}}]}) + self.assertEqual(layout['tiles'][0]['options']['controls'], 'position') + validate_layout({'title': 'T', 'tiles': [{'entity': 'light.a', 'options': {'controls': 'none'}}]}) + for entity, choice in (('cover.a', 'volume'), ('sensor.a', 'toggle'), ('switch.a', 'brightness'), ('climate.a', 42), ('light.a', 'run')): + with self.assertRaises(ValueError): + validate_layout({'title': 'T', 'tiles': [{'entity': entity, 'options': {'size': 'wide', 'controls': choice}}]}) + + def test_wide_cards_get_the_default_set_only_in_the_standard_layout(self): + self.assertEqual(resolve_controls({'entity': 'climate.a', 'options': {'size': 'wide'}}), 'setpoint') + self.assertEqual(resolve_controls({'entity': 'media_player.a', 'options': {'size': 'wide', 'controls': 'playback'}}), 'playback') + self.assertIsNone(resolve_controls({'entity': 'media_player.a', 'options': {'size': 'wide', 'controls': 'none'}})) + self.assertIsNone(resolve_controls({'entity': 'climate.a'}), 'single cards stay as they are') + self.assertIsNone(resolve_controls({'entity': 'climate.a', 'options': {'size': 'wide', 'display': 'watch'}})) + self.assertIsNone(resolve_controls({'entity': 'light.a', 'options': {'size': 'wide', 'inline': 'slider'}})) + self.assertIsNone(resolve_controls({'entity': 'sensor.a', 'options': {'size': 'wide'}})) + + def test_wire_carries_only_the_shown_set_and_the_attributes_the_panels_need(self): + states = {'media_player.sonos': {'state': 'playing', 'attributes': {'volume_level': 0.17, 'is_volume_muted': False, 'media_title': 'TV', 'supported_features': 8321599, 'assumed_state': True}}, + 'cover.gordijn': {'state': 'open', 'attributes': {'current_position': 80, 'device_class': 'curtain', 'supported_features': 15}}, + 'climate.airco': {'state': 'cool', 'attributes': {'hvac_action': 'cooling', 'hvac_modes': ['off', 'cool', 'heat'], 'temperature': 20, 'target_temp_step': 1.0}}} + msg = state_message(0, {'entity': 'media_player.sonos', 'name': '', 'options': {'size': 'wide'}}, states) + self.assertEqual(msg['o'], {'size': 'wide', 'controls': 'volume'}) + self.assertIs(msg['a']['is_volume_muted'], False) + self.assertNotIn('assumed_state', msg['a']) + msg = state_message(1, {'entity': 'cover.gordijn', 'name': '', 'options': {'size': 'wide', 'controls': 'none', 'background': 'blue'}}, states) + self.assertEqual(msg['o'], {'size': 'wide', 'background': 'blue'}, 'an explicit none is not sent; the stored choice stays') + self.assertEqual(msg['a']['device_class'], 'curtain') + msg = state_message(2, {'entity': 'climate.airco', 'name': '', 'options': {'size': 'wide', 'controls': 'mode'}}, states) + self.assertEqual(msg['o'], {'size': 'wide', 'controls': 'mode'}) + self.assertEqual(msg['a']['hvac_action'], 'cooling') + self.assertTrue(all(len(p) <= 255 for p in packets(msg))) + msg = state_message(3, {'entity': 'climate.airco', 'name': '', 'options': {'controls': 'mode'}}, states) + self.assertEqual(msg['o'], {}, 'a single card never shows a panel') + self.assertIsNone(min_firmware({'tiles': [{'entity': 'climate.airco', 'options': {'size': 'wide', 'controls': 'mode'}}]}), 'older firmware ignores the field') + + def test_screen_options_keeps_the_choice_out_of_the_wire_but_in_storage(self): + tile = {'entity': 'switch.bureau', 'name': '', 'options': {'size': 'wide', 'controls': 'toggle', 'icon': 'auto'}} + self.assertEqual(screen_options(tile, {}), {'size': 'wide', 'controls': 'toggle'}) + self.assertEqual(validate_layout({'title': 'T', 'tiles': [tile]})['tiles'][0]['options'], tile['options']) + + def test_control_glyphs_exist_in_the_icon_fonts_and_the_editor_font(self): + header = (ROOT / 'components/smart_display/tile_controls.h').read_text() + used = {code.upper() for code in re.findall(r'\\U000(F[0-9A-F]{4})', header)} + carried = {code for code in tile_icons.GLYPHS.values()} + self.assertTrue(used <= carried, used - carried) + self.assertTrue(set(tile_icons.CONTROL_GLYPHS) <= set(tile_icons.GLYPHS)) + self.assertEqual(set(tile_icons.editor()['controls']), set(tile_icons.CONTROL_GLYPHS)) + for name in ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/guition.yaml', 'packages/cyd.yaml'): + text = (ROOT / name).read_text() + for code in used: + self.assertIn(f'"\\U000{code}"', text, (name, code)) + self.assertIn('runtime_tiles::control_font = id(sublabel_big)->get_lv_font();', text, name) + + +@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests') +class ControlInventory(unittest.IsolatedAsyncioTestCase): + async def test_inventory_lists_the_catalogue_and_a_saved_choice_survives_an_old_editor(self): + with tempfile.TemporaryDirectory() as tmp: + m = test_portal.ManagerTests().setup_manager(Path(tmp) / 'screens.json') + self.assertEqual(m.inventory()[0][0]['id'], 'text.screen') + m.save('text.screen', {'title': 'Thuis', 'tiles': [{'entity': 'light.a', 'options': {'size': 'wide', 'controls': 'brightness'}}]}) + m.save('text.screen', {'title': 'Thuis', 'tiles': [{'entity': 'light.a', 'options': {'size': 'wide'}}]}) + self.assertEqual(m.layouts['text.screen']['tiles'][0]['options'], {'size': 'wide', 'controls': 'brightness'}) + await m.sync_one('text.screen', m.layouts['text.screen']) + self.assertEqual(m.ha.messages[1][1]['o'], {'size': 'wide', 'controls': 'brightness'}) + from aiohttp.test_utils import TestClient, TestServer + from server import create_app + async with TestClient(TestServer(create_app(m, development=True))) as client: + data = await (await client.get('/api/inventory')).json() + self.assertEqual(data['controls']['cover']['default'], 'buttons') + self.assertIn('controls', data['icons']) + light = await (await client.get('/api/inventory?light=1')).json() + self.assertNotIn('controls', light)