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 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-13 15:02:15 +02:00
parent c8e1fb8f72
commit 2667f41f6f
27 changed files with 3247 additions and 544 deletions
+11
View File
@@ -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 ondergang), een **kookwekker** (`timer.*`, tikken start of pauzeert) en
**aanwezigheid** (`person.*`). Kies ze in de kiezer als elke andere tegel of **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. 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, - **Scherminstellingen:** standby-tijd, normale en gedimde helderheid,
nachturen, klok, terug naar de hoofdpagina en optioneel swipen tussen pagina's. nachturen, klok, terug naar de hoofdpagina en optioneel swipen tussen pagina's.
- **Guition-rotatie:** 0°, 90°, 180° of 270°, direct vanuit de beheerpagina. - **Guition-rotatie:** 0°, 90°, 180° of 270°, direct vanuit de beheerpagina.
+12
View File
@@ -2,8 +2,20 @@
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <cstdint> #include <cstdint>
#include <string>
namespace cyd { 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. // Work in tenths of a degree and always clamp AFTER rounding.
inline int quantize_temperature(int value, int minimum, int maximum, int step) { inline int quantize_temperature(int value, int minimum, int maximum, int step) {
step = std::max(1, step); step = std::max(1, step);
+2 -2
View File
@@ -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); lv_obj_set_pos(obj, x, y); lv_obj_set_size(obj, w, h);
return obj; 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; if (ready) return;
ready = true; ready = true;
bool large = width >= 480; 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; auto &row = rows[i]; row.index = i;
row.box = plain(parent, margin, top + i * spacing, w, spacing - 2); 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_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]); auto *label = lv_label_create(row.box); lv_label_set_text(label, names[i]);
lv_obj_set_pos(label, 2, 0); 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); row.value = lv_label_create(row.box); lv_obj_align(row.value, LV_ALIGN_TOP_RIGHT, -2, 0);
+18 -2
View File
@@ -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) { inline std::string state_revision(const std::string &state, const std::string &attributes) {
return state + "\n" + 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 { 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<std::string, 4> fan_speeds; std::array<std::string, 4> fan_speeds;
unsigned fan_speed_count = 0; unsigned fan_speed_count = 0;
std::string fan_speed; std::string fan_speed;
@@ -39,12 +40,27 @@ struct Tile {
int saturation = 0; int saturation = 0;
std::string tap = "auto", display = "standard", inline_control = "none", media_title; std::string tap = "auto", display = "standard", inline_control = "none", media_title;
bool wide = false; 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<std::string, 8> options; std::array<std::string, 8> options;
unsigned option_count = 0, history_hours = 24; unsigned option_count = 0, history_hours = 24;
std::array<float,24> history{}; std::array<float,24> history{};
bool has_history = false; bool has_history = false;
std::array<Forecast, 5> forecast; std::array<Forecast, 5> forecast;
unsigned forecast_count = 0; unsigned forecast_count = 0;
// Weather card details (0.2.19+): the next hours, wind and feels-like temperature.
std::array<Hour, 8> 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; std::string sunrise, sunset, duration, remaining;
uint32_t timer_end = 0; uint32_t timer_end = 0;
float battery = NAN, volume = NAN; float battery = NAN, volume = NAN;
+435 -47
View File
@@ -6,6 +6,7 @@
#include "esphome/core/preferences.h" #include "esphome/core/preferences.h"
#include "cyd_ui.h" #include "cyd_ui.h"
#include "light_controls.h" #include "light_controls.h"
#include "tile_controls.h"
#include "esphome/components/json/json_util.h" #include "esphome/components/json/json_util.h"
#include "esphome/components/api/api_server.h" #include "esphome/components/api/api_server.h"
#include "esphome/core/hal.h" #include "esphome/core/hal.h"
@@ -16,7 +17,6 @@
namespace runtime_tiles { namespace runtime_tiles {
inline bool enabled = false; inline bool enabled = false;
inline bool light_theme = false;
inline bool swipe_pages = false; inline bool swipe_pages = false;
inline uint32_t rotation = 0; inline uint32_t rotation = 0;
inline bool rotation_supported = false; 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; 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. // Big digits for the clock card; the board profile sets it with the local time source.
inline const lv_font_t *clock_font = nullptr; inline 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 lv_obj_t *room_label = nullptr; // remembered by render() so page switches can render synchronously
inline std::function<esphome::ESPTime()> now_time; inline std::function<esphome::ESPTime()> now_time;
inline uint32_t now_epoch() { if (!now_time) return 0; auto t = now_time(); return t.is_valid() ? static_cast<uint32_t>(t.timestamp) : 0; } inline uint32_t now_epoch() { if (!now_time) return 0; auto t = now_time(); return t.is_valid() ? static_cast<uint32_t>(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_icon(const std::string &condition);
inline const char *weather_text(const std::string &condition); inline const char *weather_text(const std::string &condition);
inline std::string timer_text(const Tile &t); inline std::string timer_text(const Tile &t);
inline std::string last_run_text(uint32_t epoch);
inline int active_index = -1; inline int active_index = -1;
inline uint32_t last_received = 0; inline uint32_t last_received = 0;
inline std::function<void()> layout_changed, refresh, dismiss, settings_changed; inline std::function<void()> 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<lv_obj_t *, 20> parts{}; lv_point_precise_t *points{}; bool wide=false; int base_width=0; lv_obj_t *extra{}; std::string extra_mode; std::array<lv_obj_t *, 20> parts{}; lv_point_precise_t *points{};
// Soft area under a polyline (graph, sun path), painted by the extra container's draw event. // 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; 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<lv_obj_t *,3> keys{}, key_icons{}; std::array<int,3> key_commands{}; std::array<std::string,3> key_args; std::array<int,3> key_checked{};
lv_obj_t *pill{}, *pill_value{}, *knob{}, *control_slider{}; int knob_on=-1;
lv_color_t panel_accent{}, panel_text{};
// 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; constexpr unsigned POINT_BUFFER = 128;
inline std::array<Widgets, 10> widgets; inline std::array<Widgets, 10> 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.tap = string(options["tap"]); if (tile.tap.empty()) tile.tap="auto";
tile.display = string(options["display"]); if (tile.display.empty()) tile.display="standard"; 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"; 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. // Width arrives with the state, after the layout: re-pack the pages when it changes.
bool was_wide = tile.wide; bool was_wide = tile.wide;
tile.wide = string(options["size"]) == "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; if (tile.forecast_count == tile.forecast.size()) break;
auto &f = tile.forecast[tile.forecast_count++]; 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.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<JsonArray>()) for (JsonVariant hour : extra["hours"].as<JsonArray>()) {
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<unsigned>() ? extra["last"].as<uint32_t>() : 0;
tile.sunrise = string(extra["rise"], 5); tile.sunset = string(extra["set"], 5); tile.sunrise = string(extra["rise"], 5); tile.sunset = string(extra["set"], 5);
tile.timer_end = extra["end"].is<unsigned>() ? extra["end"].as<uint32_t>() : 0; tile.timer_end = extra["end"].is<unsigned>() ? extra["end"].as<uint32_t>() : 0;
tile.duration = string(extra["dur"], 16); tile.remaining = string(extra["rem"], 16); tile.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<JsonArray>()) for(JsonVariant option:a["options"].as<JsonArray>()) { if (a["options"].is<JsonArray>()) for(JsonVariant option:a["options"].as<JsonArray>()) {
if(tile.option_count==8)break;tile.options[tile.option_count++]=string(option,48); } 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.battery=number(a["battery_level"]);tile.volume=number(a["volume_level"]);
tile.muted=a["is_volume_muted"].is<bool>() && a["is_volume_muted"].as<bool>();
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<uint32_t>(); tile.media_title=string(a["media_title"],80);tile.supported=a["supported_features"].as<uint32_t>();
tile.name = string(root["name"], 80); tile.name = string(root["name"], 80);
tile.state = string(root["state"], 160); 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); tile.step = number(a["target_temp_step"], 0.5f);
if(tile.domain()=="number" || tile.domain()=="input_number") { 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); } 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.modes = list(a["supported_color_modes"]);
tile.hvac_modes = list(a["hvac_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 hue = number(a["hs_color"][0]);
float saturation = number(a["hs_color"][1]); float saturation = number(a["hs_color"][1]);
tile.has_hs_color = std::isfinite(hue) && std::isfinite(saturation); 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; if (tile.fan_speed_count == 4) break;
tile.fan_speeds[tile.fan_speed_count++] = string(speed, 48); 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; tile.received = true;
for(auto &w:widgets)if(w.index==index)w.cached_active=-1; for(auto &w:widgets)if(w.index==index)w.cached_active=-1;
last_received = esphome::millis(); 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_switch=nullptr;
inline lv_obj_t *detail_label(lv_obj_t *parent,const std::string &text,int x,int y,int width) { 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); 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; 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){ 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()=="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()=="sun")return t.state=="above_horizon"?"Boven de horizon":"Onder de horizon";
if(t.domain()=="timer")return timer_text(t); 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=="on")return "Aan";
if(t.state=="off")return "Uit"; if(t.state=="off")return "Uit";
if(t.state=="docked")return "In dock"; 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_PRESS_LOST && captured_slider==slider){captured_slider=nullptr;slider_changed=false;}
if(code==LV_EVENT_RELEASED && captured_slider==slider){ if(code==LV_EVENT_RELEASED && captured_slider==slider){
unsigned index=(uintptr_t)lv_event_get_user_data(e); 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; 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)); 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){ 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); 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); 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){ 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;} 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; if(command>=0 && detail_action_count<16)detail_actions[detail_action_count++]=button;
return 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<unsigned>(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<columns;++i){
const auto &h=t.hours[i];int x=card_pad+i*col;
detail_text(now,h.time,x,hy,col,small,LV_TEXT_ALIGN_CENTER,muted);
detail_text(now,weather_icon(h.condition),x,hy+small_h+(large?4:1),col,mini,LV_TEXT_ALIGN_CENTER,weather_accent(h.condition));
detail_text(now,std::isfinite(h.temp)?degrees(h.temp):"",x,hy+small_h+mini_h+(large?8:2),col,detail_font,LV_TEXT_ALIGN_CENTER,ink);
detail_text(now,rain_text(h.rain,h.mm,false),x,hy+small_h+mini_h+text_h+(large?8:3),col,small,LV_TEXT_ALIGN_CENTER,rain);
}
}
y+=card_a_h+(large?12:6);
// Coming days: a heading and a card with one row per day.
if(!t.forecast_count){detail_text(detail_root,"Geen dagvoorspelling van Home Assistant",pad,y,width-2*pad,small,LV_TEXT_ALIGN_LEFT,muted);return;}
if(large){detail_text(detail_root,"Komende dagen",pad+4,y,width-2*pad,detail_font,LV_TEXT_ALIGN_LEFT,muted);y+=text_h+8;}
int card_b_h=height-y-(large?10:4);
auto *days=detail_card(pad,y,width-2*pad,card_b_h);
int row_pad=large?8:4,row=(card_b_h-2*row_pad)/(int)t.forecast_count;
int day_w=large?46:26,icon_x=card_pad+day_w,cond_x=icon_x+mini_h+(large?12:5);
int high_w=large?52:30,low_w=large?46:28,rain_w=large?120:60,drop_w=tiny_h+(large?4:2);
int temps_x=width-2*pad-card_pad-high_w-low_w,rain_x=temps_x-(large?14:6)-rain_w;
for(unsigned i=0;i<t.forecast_count;++i){
const auto &f=t.forecast[i];int ry=row_pad+i*row,tcy=ry+(row-text_h)/2,scy=ry+(row-small_h)/2;
detail_text(days,f.day,card_pad,tcy,day_w,detail_font,LV_TEXT_ALIGN_LEFT,ink);
detail_text(days,weather_icon(f.condition),icon_x,ry+(row-mini_h)/2,mini_h+6,mini,LV_TEXT_ALIGN_LEFT,weather_accent(f.condition));
detail_text(days,weather_text(f.condition),cond_x,scy,std::max(1,rain_x-cond_x-4),small,LV_TEXT_ALIGN_LEFT,muted);
std::string wet=rain_text(f.rain,f.mm,large);
if(!wet.empty()){
detail_text(days,"\U000F058E",rain_x,ry+(row-tiny_h)/2,drop_w,tiny,LV_TEXT_ALIGN_LEFT,rain);
detail_text(days,wet,rain_x+drop_w,scy,rain_w-drop_w,small,LV_TEXT_ALIGN_LEFT,rain);
}
detail_text(days,std::isfinite(f.high)?degrees(f.high):"",temps_x,tcy,high_w,detail_font,LV_TEXT_ALIGN_RIGHT,ink);
detail_text(days,std::isfinite(f.low)?degrees(f.low):"",temps_x+high_w,scy,low_w,small,LV_TEXT_ALIGN_RIGHT,muted);
}
}
inline void show_detail(unsigned index){ inline void show_detail(unsigned index){
if(index>=model.count)return;detail_index=index;auto &t=model.tiles[index]; if(index>=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_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);} 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); 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()); 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; 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));} 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_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; 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){ if(large){
auto *hero=shape(detail_root,pad,100,width-2*pad,148,surface,24); auto *hero=shape(detail_root,pad,100,width-2*pad,148,surface,24);
shape(hero,18,14,120,120,muted,60); 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 *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); auto *status=detail_label(badge,t.awaiting_action(esphome::millis())?"Opdracht verstuurd...":state,12,5,218);
detail_badge_status=status; 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_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(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); 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); 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); lv_obj_add_event_cb(slider,slider_event,LV_EVENT_ALL,(void*)(uintptr_t)index);
}else if(d=="weather"){ }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));} render_weather_detail(t,large,width,height,pad);
int y=top+(large?60:30),step=lv_font_get_line_height(detail_font)+(large?10:4);
for(unsigned i=0;i<t.forecast_count;++i){
const auto &f=t.forecast[i];char line[96];
snprintf(line,sizeof(line),"%s %s %s%.0f / %.0f %s",f.day.c_str(),weather_text(f.condition),std::isfinite(f.high)?"":"-",std::isfinite(f.high)?f.high:0.0f,std::isfinite(f.low)?f.low:0.0f,t.unit.c_str());
detail_label(detail_root,line,pad,y,width-2*pad);y+=step;
}
}else if(d=="timer"){ }else if(d=="timer"){
detail_label(detail_root,t.state=="active"?"Loopt":t.state=="paused"?"Gepauzeerd":"Staat stil",pad,top,width-2*pad); detail_label(detail_root,t.state=="active"?"Loopt":t.state=="paused"?"Gepauzeerd":"Staat stil",pad,top,width-2*pad);
detail_button(t.state=="active"?"Pauzeer":"Start",pad,top+(large?50:30),cw,bh,40); detail_button(t.state=="active"?"Pauzeer":"Start",pad,top+(large?50:30),cw,bh,40);
@@ -550,6 +675,18 @@ inline std::string countdown(uint32_t seconds) {
else snprintf(b, sizeof(b), "%u:%02u", seconds / 60, seconds % 60); else snprintf(b, sizeof(b), "%u:%02u", seconds / 60, seconds % 60);
return b; return b;
} }
// "Laatst 14:32" today, "Gisteren 14:32", else "Laatst 13 sep"; scripts and scenes have no useful on/off.
inline std::string month_short(const esphome::ESPTime &now);
inline std::string last_run_text(uint32_t epoch) {
if (!epoch) return "Nog niet gestart";
auto when = esphome::ESPTime::from_epoch_local(epoch);
auto now = now_time ? now_time() : esphome::ESPTime{};
if (!when.is_valid()) return "Nog niet gestart";
char clock[8]; snprintf(clock, sizeof(clock), "%02d:%02d", when.hour, when.minute);
if (now.is_valid() && now.year == when.year && now.day_of_year == when.day_of_year) return std::string("Laatst ") + clock;
if (now.is_valid() && now.year == when.year && now.day_of_year == when.day_of_year + 1) return std::string("Gisteren ") + clock;
return "Laatst " + std::to_string(when.day_of_month) + " " + month_short(when);
}
inline std::string timer_text(const Tile &t) { inline std::string timer_text(const Tile &t) {
if (t.state == "active") { uint32_t now = now_epoch(); return countdown(t.timer_end > now && now ? t.timer_end - now : 0); } if (t.state == "active") { uint32_t now = now_epoch(); return countdown(t.timer_end > now && now ? t.timer_end - now : 0); }
if (t.state == "paused") return "Pauze " + countdown(duration_seconds(t.remaining)); 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=="detail")open=true;
if(code==LV_EVENT_SHORT_CLICKED && tile.tap=="toggle")open=false; 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=="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; if(tile.builtin())return;
// A short tap runs or pauses the kitchen timer; holding opens the card with a cancel button. // A short tap runs or pauses the kitchen timer; holding opens the card with a cancel button.
if(d=="timer" && !open){action(tile.state=="active"?"timer.pause":"timer.start",tile.entity);return;} if(d=="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(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_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); 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.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,-1);lv_slider_set_range(w.slider,0,1000); 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_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_set_style_opa(w.slider,LV_OPA_TRANSP,LV_PART_KNOB);
lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN); 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) { 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()); 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. // 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. // Custom Lovelace card/theme CSS is not an entity attribute and is not imported.
inline uint32_t domain_accent(const Tile &t) { 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) { 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]; 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);} 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; 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) { 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) { 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]; 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);} 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) { inline std::string time_text(esphome::ESPTime now) {
return now.is_valid() ? now.strftime(screen_settings::current.clock_24h ? "%H:%M" : "%I:%M") : "--:--"; 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)}; 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; 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}; 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=0xCFD8DC, accent=day?0xFF9800:0x5C6BC0, disc=day?0xFFB300:0xB0BEC5;
uint32_t path=dark_text?0xCFD8DC:0x6B8299, 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,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,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); 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); 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;} 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<tile_controls::Key,3> 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) { inline void render(lv_obj_t *room) {
if (!enabled) return; if (!enabled) return;
room_label=room; 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 == "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 == "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 == "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 == "on") value = "Aan";
else if (value == "off") value = "Uit"; else if (value == "off") value = "Uit";
else if (value == "cleaning") value = "Bezig"; else if (value == "cleaning") value = "Bezig";
@@ -907,24 +1271,26 @@ inline void render(lv_obj_t *room) {
bool pending=t.loading(esphome::millis()); 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=="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)+"%"; if(d=="vacuum" && std::isfinite(t.battery))value += " / "+std::to_string((int)t.battery)+"%";
label(w.value, pending ? "Bezig..." : value); // Direct controls: only a wide card in the standard layout has room for the panel.
lv_obj_set_width(w.progress,pending ? (esphome::millis()/120%5+1)*(lv_obj_get_width(w.tile)-24)/5 : 0); bool with_panel=w.wide && !t.controls.empty() && !t.builtin() && !watch && t.inline_control!="slider" && fresh() && t.available();
lv_obj_set_style_text_opa(w.icon,pending ? LV_OPA_40 : LV_OPA_COVER,0); 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 mini=t.inline_control=="slider" && !watch && t.available();
bool large_tile=lv_obj_get_height(w.tile)>80; 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. // 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 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 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 graph=d=="sensor" && t.display=="graph" && t.has_history && !clock;
bool custom=clock||forecast||sunpath; 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);} if(!large_tile)pad_vertical(w.tile,watch||custom||graph?2:4);
lv_obj_set_style_text_font(w.value,watch && watch_value_font ? watch_value_font : w.value_font,0); 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_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); 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); 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);} 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){ 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); 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 if(sunpath)render_sunpath(w,t,large_tile,content_width,content_height);
else render_forecast(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. // 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; bool graph_strip=graph && !w.wide, graph_side=graph && w.wide;
int chart_w=graph_side?content_width*55/100:0; 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 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 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); 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_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); 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;
lv_obj_center(w.icon); 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; 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.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, 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); 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 // Use the requested coordinates: LVGL getters still return the previous
// layout until its next pass when a slot changes from watch/slider to normal. // 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.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))); lv_obj_set_width(w.value,std::max(1,text_room-(watch?0:(mini||graph_strip)?text_x:w.value_x)));
if(watch){ 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); 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); }else lv_obj_add_flag(w.unit,LV_OBJ_FLAG_HIDDEN);
lv_obj_set_size(w.slider,content_width,slider_height); 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);} 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); 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); 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 on = fresh() && t.active();
bool available=fresh() && t.available(); bool available=fresh() && t.available();
int palette_state=(available?2:0)|(on?1:0); int palette_state=(available?2:0)|(on?1:0);
if (w.cached_active == palette_state) continue; if (w.cached_active == palette_state && !w.panel_dirty) continue;
w.cached_active = palette_state; w.cached_active = palette_state;w.panel_dirty=false;
uint32_t accent = domain_accent(t); uint32_t accent = domain_accent(t);
auto color=lv_color_hex((t.is_switch()||d=="person"||d=="timer") && !on ? 0x9E9E9E : accent); auto color=lv_color_hex((t.is_switch()||d=="person"||d=="timer") && !on ? 0x9E9E9E : accent);
if(d=="light" && on && t.has_hs_color) if(d=="light" && on && t.has_hs_color)
color=lv_color_hsv_to_rgb(t.hue%360,t.saturation,100); 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(0xFFFFFF),38):lv_color_hex(0xF0F0F0);
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);
// Darken the foreground slightly: very pale bulbs still need a visible icon. // 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,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.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 : (light_theme ? 0xFFFFFF : (on ? 0xF5F1E8 : 0x526C85))), 0); 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); lv_obj_set_style_border_width(w.tile, 1, 0);
// "Achtergrond: geen" hides only the card; geometry and padding stay identical, // "Achtergrond: geen" hides only the card; geometry and padding stay identical,
// and the pressed flash still shows because it lives on the PRESSED state. // 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_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_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_bg_color(w.circle,circle_color,0);
lv_obj_set_style_text_color(w.icon,icon_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 title_color=lv_color_hex(0x1B1B1B);
auto value_color=lv_color_hex(t.background ? 0x46525E : (light_theme ? 0x616161 : (on ? 0x46525E : 0xF0F4F8))); auto value_color=lv_color_hex(t.background ? 0x46525E : 0x616161);
lv_obj_set_style_text_color(w.unit,lv_color_hex(dark_text?0x46525E:(on?0x46525E:0xF0F4F8)),0); lv_obj_set_style_text_color(w.unit,lv_color_hex(0x46525E),0);
lv_obj_set_style_text_color(w.title, title_color, 0); lv_obj_set_style_text_color(w.title, title_color, 0);
lv_obj_set_style_text_color(w.value, value_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. // 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. // The sun path sets its own colours on every render.
w.fill_color=color; w.fill_color=color;
@@ -1064,6 +1431,18 @@ inline bool check_tile_geometry() {
fits=fits && value.x2<unit.x1 && unit.x2<=content.x2 && unit.y2<=content.y2 && unit.y1>title.y2; fits=fits && value.x2<unit.x1 && unit.x2<=content.x2 && unit.y2<=content.y2 && unit.y1>title.y2;
} }
} }
if(w.panel && !lv_obj_has_flag(w.panel,LV_OBJ_FLAG_HIDDEN)){
// Direct controls stay inside the card, right of the name and status, and inside their panel.
lv_area_t panel;lv_obj_get_coords(w.panel,&panel);
bool inside=panel.x1>=content.x1 && panel.x2<=content.x2 && panel.y1>=content.y1 && panel.y2<=content.y2 && (custom || (title.x2<panel.x1 && value.x2<panel.x1));
for(uint32_t i=0;i<lv_obj_get_child_count(w.panel);++i){
auto *child=lv_obj_get_child(w.panel,i);if(lv_obj_has_flag(child,LV_OBJ_FLAG_HIDDEN))continue;
lv_area_t part;lv_obj_get_coords(child,&part);
inside=inside && part.x1>=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)){ 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. // Custom parts stay inside the card; a graph never runs into the text.
lv_area_t extra;lv_obj_get_coords(w.extra,&extra); 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;slot<widgets.size();++slot){ for(size_t slot=0;slot<widgets.size();++slot){
auto &w=widgets[slot];if(!w.tile)continue; auto &w=widgets[slot];if(!w.tile)continue;
if(slot<SLOTS_PER_PAGE && w.index<model.count){lv_obj_set_width(w.tile,w.wide?wide_width:w.base_width);lv_obj_remove_flag(w.tile,LV_OBJ_FLAG_HIDDEN);} if(slot<SLOTS_PER_PAGE && w.index<model.count){lv_obj_set_width(w.tile,w.wide?wide_width:w.base_width);lv_obj_remove_flag(w.tile,LV_OBJ_FLAG_HIDDEN);}
else{lv_obj_add_flag(w.tile,LV_OBJ_FLAG_HIDDEN);end_extra(w);} else{lv_obj_add_flag(w.tile,LV_OBJ_FLAG_HIDDEN);end_extra(w);end_panel(w);}
} }
for(auto *control:{nav_prev,nav_next,nav_number}){ for(auto *control:{nav_prev,nav_next,nav_number}){
if(pages>1)lv_obj_remove_flag(control,LV_OBJ_FLAG_HIDDEN); if(pages>1)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) { inline void skeleton_page(int page) {
place_page(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;slot<SLOTS_PER_PAGE;++slot){ for(size_t slot=0;slot<SLOTS_PER_PAGE;++slot){
auto &w=widgets[slot];if(!w.tile || lv_obj_has_flag(w.tile,LV_OBJ_FLAG_HIDDEN))continue; auto &w=widgets[slot];if(!w.tile || lv_obj_has_flag(w.tile,LV_OBJ_FLAG_HIDDEN))continue;
for(auto *o:{w.title,w.value,w.circle,w.unit,w.slider,w.extra})if(o)lv_obj_add_flag(o,LV_OBJ_FLAG_HIDDEN); for(auto *o:{w.title,w.value,w.circle,w.unit,w.slider,w.extra,w.panel,w.busy})if(o)lv_obj_add_flag(o,LV_OBJ_FLAG_HIDDEN);
lv_obj_set_width(w.progress,0);
// A card-less tile shows no skeleton frame either. // A card-less tile shows no skeleton frame either.
bool bare=w.index<model.count && model.tiles[w.index].transparent; bool bare=w.index<model.count && model.tiles[w.index].transparent;
lv_obj_set_style_bg_opa(w.tile,bare?LV_OPA_TRANSP:LV_OPA_COVER,0); lv_obj_set_style_bg_opa(w.tile,bare?LV_OPA_TRANSP:LV_OPA_COVER,0);
@@ -1175,7 +1553,17 @@ inline void tick() {
} }
if(!enabled)return; if(!enabled)return;
bool redraw=false; bool redraw=false;
for(auto &t:model.tiles)if(t.pending){redraw=true;if(!t.loading(esphome::millis()))t.pending=false;} for(auto &t:model.tiles)if(t.pending && !t.loading(esphome::millis())){t.pending=false;redraw=true;}
// A -/+ edit goes out as one call once the finger rests; a value HA never reports is dropped after a while.
for(size_t i=0;i<model.count;++i){
auto &t=model.tiles[i];if(!std::isfinite(t.edit_value))continue;
uint32_t now=esphome::millis();
if(!t.edit_sent){
if(now-t.edit_since<700 || t.awaiting_action(now))continue;
auto a=tile_controls::edit_action(t,t.edit_value);
if(a.valid()){t.edit_sent=true;t.edit_since=now;action(a.service,t.entity,a.key,a.value);}else t.edit_value=NAN;
}else if(now-t.edit_since>10000){t.edit_value=NAN;redraw=true;}
}
// Clocks and running timers advance once per second without any HA traffic. // Clocks and running timers advance once per second without any HA traffic.
uint32_t second=esphome::millis()/1000; uint32_t second=esphome::millis()/1000;
if(second!=last_live_second){ if(second!=last_live_second){
+241
View File
@@ -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 <array>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <string>
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<Key, 3> &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
+62 -5
View File
@@ -29,6 +29,52 @@ def demo_layout():
{'entity': 'weather.demo_standaard', 'name': 'Weer nu', 'options': {}}, {'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): def demo_states(now):
end = now + timedelta(minutes=4, seconds=32) end = now + timedelta(minutes=4, seconds=32)
return { return {
@@ -44,12 +90,20 @@ def demo_states(now):
def demo_forecast(now): def demo_forecast(now):
conditions = ['sunny', 'partlycloudy', 'rainy', 'cloudy', 'lightning-rainy', 'snowy'] 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)] 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) 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: if digital:
layout['tiles'][0]['options'] = {'display': 'digital'} layout['tiles'][0]['options'] = {'display': 'digital'}
elif wide: 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']]}] 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']): for i, tile in enumerate(layout['tiles']):
forecast = demo_forecast(now) if tile['entity'].startswith('weather.') else None 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.'): 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)]} 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) 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('--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('--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('--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() args = p.parse_args()
client = APIClient(args.host, 6053, noise_psk=yaml.safe_load(args.secrets.read_text())['api_encryption_key'], 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) client_info='Demo layout', expected_name=args.name)
@@ -86,7 +142,8 @@ async def main():
replies.append(update.state) replies.append(update.state)
client.subscribe_states(state) client.subscribe_states(state)
started = time.monotonic() 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): for packet in packets(message):
client.text_command(inbox.key, packet) client.text_command(inbox.key, packet)
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
+9 -1
View File
@@ -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 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**: een eigen naam, de pijltjes voor volgorde en **Bediening & weergave instellen**:
klikgedrag, een mini-slider, een grote waarde, een grafiek (sensoren), een 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 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 **Pastel achtergrond** een eigen kleur met donkere tekst; **Standaard** herstelt
de normale weergave en **Geen** laat de kaart weg, zodat de inhoud even groot de normale weergave en **Geen** laat de kaart weg, zodat de inhoud even groot
+28
View File
@@ -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 De substituties `MDI_GLYPH_*` zijn vervallen: `TILEn_ICON` in handmatige
profielen moet uit de set komen. Geen gewijzigde preferences of sleutels. 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 ### Compatibiliteit 0.2.12 / firmware 0.2.14
Opslagversie en tegelprotocol blijven 1. Additief: `tiles[].options.size` Opslagversie en tegelprotocol blijven 1. Additief: `tiles[].options.size`
+39
View File
@@ -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.
+465 -113
View File
@@ -1,7 +1,7 @@
# Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911. # 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. # Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile.
substitutions: substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.18" SCREEN_FIRMWARE_VERSION: "0.2.19"
DYNAMIC_TILES: "false" DYNAMIC_TILES: "false"
DASHBOARD_INBOX_INTERNAL: "true" DASHBOARD_INBOX_INTERNAL: "true"
DEVICE_NAME: "guition-wallbox" DEVICE_NAME: "guition-wallbox"
@@ -603,13 +603,13 @@ esphome:
settings.night_start = atoi("${NIGHT_START_HOUR}") * 60; settings.night_start = atoi("${NIGHT_START_HOUR}") * 60;
settings.night_end = atoi("${NIGHT_END_HOUR}") * 60; settings.night_end = atoi("${NIGHT_END_HOUR}") * 60;
settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}"); settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}");
runtime_tiles::light_theme = true;
runtime_tiles::watch_font = id(headline)->get_lv_font(); runtime_tiles::watch_font = id(headline)->get_lv_font();
runtime_tiles::watch_value_font = id(watch_value)->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::watch_icon_font = id(watch_icon)->get_lv_font();
runtime_tiles::clock_font = id(clock_digits)->get_lv_font(); runtime_tiles::clock_font = id(clock_digits)->get_lv_font();
runtime_tiles::now_time = []() { return id(homeassistant_time).now(); }; runtime_tiles::now_time = []() { return id(homeassistant_time).now(); };
runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font(); 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"; runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true";
if (!runtime_tiles::enabled) return; if (!runtime_tiles::enabled) return;
runtime_tiles::rotation_supported = true; runtime_tiles::rotation_supported = true;
@@ -664,6 +664,8 @@ esphome:
light_controls::fallback.kelvin = t.kelvin; light_controls::fallback.kelvin = t.kelvin;
light_controls::fallback.minimum = t.min_kelvin; light_controls::fallback.minimum = t.min_kelvin;
light_controls::fallback.maximum = t.max_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, 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), t.brightness, t.percentage, t.position, runtime_tiles::icon_for(t),
0xD5EEFC, 0x00A6ED, 0x1B1B1B, "Uit", "Aan", t.modes, 0xD5EEFC, 0x00A6ED, 0x1B1B1B, "Uit", "Aan", t.modes,
@@ -677,7 +679,10 @@ esphome:
id(climate_edit_pending) = false; id(climate_edit_pending) = false;
id(active_climate_mode) = t.state; id(active_climate_mode) = t.state;
id(active_climate_supported_modes) = t.hvac_modes; 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]; char buf[72];
if (std::isfinite(t.current)) snprintf(buf, sizeof(buf), "Nu: %.1f°", t.current); if (std::isfinite(t.current)) snprintf(buf, sizeof(buf), "Nu: %.1f°", t.current);
else snprintf(buf, sizeof(buf), "Nu: --"); else snprintf(buf, sizeof(buf), "Nu: --");
@@ -694,7 +699,7 @@ esphome:
- lambda: |- - lambda: |-
static const char *entities[] = {"${TILE1_ENTITY}", "${TILE2_ENTITY}", "${TILE3_ENTITY}", "${TILE4_ENTITY}", "${TILE5_ENTITY}", "${TILE6_ENTITY}", "${TILE7_ENTITY}", "${TILE9_ENTITY}"}; 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); 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[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[1] = [](int value) { id(set_light_color_temperature).execute(value); };
light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); }; light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); };
@@ -787,11 +792,32 @@ api:
index: int index: int
then: then:
- lambda: |- - 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) { if (runtime_tiles::enabled && index < (int)runtime_tiles::model.count) {
runtime_tiles::active_index = index; 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); 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 - action: light_controls_preview
then: then:
- script.execute: light_controls_preview - script.execute: light_controls_preview
@@ -974,6 +1000,27 @@ globals:
type: std::string type: std::string
restore_value: no 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 - id: active_climate_supported_modes
type: std::string type: std::string
restore_value: no restore_value: no
@@ -998,7 +1045,7 @@ font:
'0','1','2','3','4','5','6','7','8','9', '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',
' ','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" - file: "fonts/Roboto-500.ttf"
id: watch_value id: watch_value
@@ -1008,7 +1055,7 @@ font:
'0','1','2','3','4','5','6','7','8','9', '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',
' ','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. # Clock card digits only; the date line reuses the tile title font.
- file: "fonts/Roboto-500.ttf" - file: "fonts/Roboto-500.ttf"
@@ -1025,7 +1072,7 @@ font:
'0','1','2','3','4','5','6','7','8','9', '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',
' ','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" - file: "fonts/Roboto-400.ttf"
id: sublabel id: sublabel
@@ -1035,7 +1082,7 @@ font:
'0','1','2','3','4','5','6','7','8','9', '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',
' ','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" - file: "fonts/Roboto-400.ttf"
id: sublabel_big id: sublabel_big
@@ -1045,7 +1092,7 @@ font:
'0','1','2','3','4','5','6','7','8','9', '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',
' ','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) # Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0)
# Source: https://github.com/Templarian/MaterialDesign-Webfont # Source: https://github.com/Templarian/MaterialDesign-Webfont
@@ -1205,6 +1252,24 @@ font:
- "\U000F00E2" # broom - "\U000F00E2" # broom
- "\U000F05D6" # alert-circle-outline - "\U000F05D6" # alert-circle-outline
- "\U000F012C" # check - "\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 - "\U000F0591" # weather-fog
- "\U000F0592" # weather-hail - "\U000F0592" # weather-hail
- "\U000F0593" # weather-lightning - "\U000F0593" # weather-lightning
@@ -1994,6 +2059,14 @@ lvgl:
scrollable: false scrollable: false
scrollbar_mode: 'OFF' scrollbar_mode: 'OFF'
widgets: 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: - label:
id: lbl_room id: lbl_room
x: 16 x: 16
@@ -3753,7 +3826,7 @@ lvgl:
width: 54 width: 54
height: 54 height: 54
radius: 999 radius: 999
bg_color: 0xFF9F0A bg_color: 0xEEEEEE
bg_opa: COVER bg_opa: COVER
border_width: 0 border_width: 0
clickable: true clickable: true
@@ -3835,25 +3908,26 @@ lvgl:
clickable: false clickable: false
- obj: - obj:
id: climate_done_button id: climate_power_button
align: TOP_RIGHT align: TOP_RIGHT
x: -15 x: -15
y: 18 y: 18
width: 66 width: 66
height: 66 height: 66
radius: 999 radius: 999
bg_color: 0xD5EEFC bg_color: 0xEEEEEE
bg_opa: COVER bg_opa: COVER
border_width: 0 border_width: 0
clickable: true clickable: true
scrollable: false scrollable: false
on_click: on_click:
- lvgl.widget.hide: climate_detail_overlay - script.execute: climate_power_toggle
widgets: widgets:
- label: - label:
id: climate_power_icon
align: CENTER align: CENTER
text: "\U000F012C" text: "\U000F0425"
text_font: materialdesign_icons text_font: materialdesign_icons_mini
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
@@ -3868,7 +3942,7 @@ lvgl:
id: climate_current_label id: climate_current_label
align: TOP_MID align: TOP_MID
y: 84 y: 84
text: "Current: --" text: "Nu: --"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
text_opa: 85% text_opa: 85%
@@ -3919,7 +3993,7 @@ lvgl:
id: climate_set_label id: climate_set_label
align: CENTER align: CENTER
y: -32 y: -32
text: "SET TO" text: "Gewenst"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
text_opa: 68% text_opa: 68%
@@ -4022,8 +4096,8 @@ lvgl:
width: ${DISPLAY_W} width: ${DISPLAY_W}
height: ${DISPLAY_H} height: ${DISPLAY_H}
hidden: true hidden: true
bg_color: 0xFFFFFF bg_color: 0xE7E7E7
bg_opa: 90% bg_opa: COVER
border_width: 0 border_width: 0
clickable: true clickable: true
scrollable: false scrollable: false
@@ -4032,7 +4106,7 @@ lvgl:
- label: - label:
align: TOP_MID align: TOP_MID
y: 32 y: 32
text: "Mode" text: "Modus"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
@@ -4042,15 +4116,18 @@ lvgl:
- obj: - obj:
id: climate_mode_list id: climate_mode_list
align: TOP_MID align: TOP_MID
y: 100 y: 92
width: 98% width: 98%
height: 356 height: 372
bg_opa: TRANSP bg_opa: TRANSP
border_width: 0 border_width: 0
scrollable: false scrollable: false
clickable: false
pad_all: 4 pad_all: 4
layout: layout:
type: FLEX type: FLEX
pad_row: 6
pad_column: 6
flex_flow: ROW_WRAP flex_flow: ROW_WRAP
flex_align_main: CENTER flex_align_main: CENTER
flex_align_cross: CENTER flex_align_cross: CENTER
@@ -4058,12 +4135,13 @@ lvgl:
widgets: widgets:
- obj: - obj:
id: cmode_off id: cmode_off
width: 198 width: 136
height: 76 height: 58
radius: 28 radius: 22
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4072,18 +4150,19 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Off" text: "Uit"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
- obj: - obj:
id: cmode_heat id: cmode_heat
width: 198 width: 136
height: 76 height: 58
radius: 28 radius: 22
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4092,18 +4171,19 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Heat" text: "Verwarmen"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
- obj: - obj:
id: cmode_cool id: cmode_cool
width: 198 width: 136
height: 76 height: 58
radius: 28 radius: 22
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4112,18 +4192,19 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Cool" text: "Koelen"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
- obj: - obj:
id: cmode_heat_cool id: cmode_heat_cool
width: 198 width: 136
height: 76 height: 58
radius: 28 radius: 22
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4132,18 +4213,19 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Heat/Cool" text: "Verw./koelen"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
- obj: - obj:
id: cmode_auto id: cmode_auto
width: 198 width: 136
height: 76 height: 58
radius: 28 radius: 22
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4158,12 +4240,13 @@ lvgl:
clickable: false clickable: false
- obj: - obj:
id: cmode_dry id: cmode_dry
width: 198 width: 136
height: 76 height: 58
radius: 28 radius: 22
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4172,18 +4255,19 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Dry" text: "Drogen"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
- obj: - obj:
id: cmode_fan_only id: cmode_fan_only
width: 198 width: 136
height: 76 height: 58
radius: 28 radius: 22
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4192,11 +4276,205 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Fan" text: "Ventileren"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false 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: - obj:
id: climate_mode_overlay_x id: climate_mode_overlay_x
align: TOP_LEFT align: TOP_LEFT
@@ -4205,11 +4483,11 @@ lvgl:
width: 54 width: 54
height: 54 height: 54
radius: 999 radius: 999
bg_color: 0xEEEEEE bg_color: 0xFFFFFF
bg_opa: COVER bg_opa: COVER
border_width: 1 border_width: 1
border_color: 0xFFFFFF border_color: 0xDDDDDD
border_opa: 20% border_opa: COVER
clickable: true clickable: true
scrollable: false scrollable: false
on_click: on_click:
@@ -4222,28 +4500,6 @@ lvgl:
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false 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 ---------- # ---------- VACUUM CONTROL ----------
# Een stofzuiger is geen schakelaar: start, pauze, stop en terug naar # Een stofzuiger is geen schakelaar: start, pauze, stop en terug naar
@@ -4663,7 +4919,7 @@ script:
static unsigned test_page=0; static unsigned test_page=0;
id(tile_page) = runtime_tiles::enabled ? test_page++ % runtime_tiles::page_count() : 1; id(tile_page) = runtime_tiles::enabled ? test_page++ % runtime_tiles::page_count() : 1;
- script.execute: show_tile_page - script.execute: show_tile_page
- delay: 150ms - delay: 300ms
- lambda: |- - lambda: |-
lv_obj_update_layout(id(tile_scroll)); lv_obj_update_layout(id(tile_scroll));
const int count = runtime_tiles::enabled ? runtime_tiles::model.count : std::clamp(atoi("${TILE_COUNT}"), 1, 10); const 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); if (has(m)) lv_obj_clear_flag(btn, LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN);
bool selected = (current == std::string(m)); bool selected = (current == std::string(m));
lv_obj_set_style_bg_color(btn, lv_obj_set_style_bg_color(btn, lv_color_hex(selected ? 0x00A6ED : 0xFFFFFF), LV_PART_MAIN);
lv_color_hex(selected ? 0xD5EEFC : 0xF0F0F0), 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_off), "off");
apply(id(cmode_heat), "heat"); apply(id(cmode_heat), "heat");
@@ -4970,10 +5226,109 @@ script:
apply(id(cmode_auto), "auto"); apply(id(cmode_auto), "auto");
apply(id(cmode_dry), "dry"); apply(id(cmode_dry), "dry");
apply(id(cmode_fan_only), "fan_only"); 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 &current_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 - lvgl.widget.show: climate_mode_overlay
# Zet precies een modus. Geen tussenstappen, dus geen ongewenste # Zet precies een modus. Geen tussenstappen, dus geen ongewenste
# commando's naar de warmtepomp onderweg. # 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 - id: set_climate_mode
mode: restart mode: restart
parameters: parameters:
@@ -4983,13 +5338,9 @@ script:
id(climate_edit_ms) = millis(); id(climate_edit_ms) = millis();
id(climate_edit_pending) = true; id(climate_edit_pending) = true;
id(active_climate_mode) = mode; id(active_climate_mode) = mode;
if (mode == "off") lv_label_set_text(id(climate_mode_label), "Off"); id(active_climate_power_on) = mode != "off";
if (mode == "heat") lv_label_set_text(id(climate_mode_label), "Heat"); id(climate_power_refresh).execute();
if (mode == "cool") lv_label_set_text(id(climate_mode_label), "Cool"); lv_label_set_text(id(climate_mode_label), tile_controls::climate_mode_text(mode));
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");
- lvgl.widget.hide: climate_mode_overlay - lvgl.widget.hide: climate_mode_overlay
- if: - if:
condition: condition:
@@ -5561,18 +5912,19 @@ script:
char current_buf[48]; char current_buf[48];
if (isnan(climate_current_temperature)) { 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) { } 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 { } 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); lv_label_set_text(id(climate_current_label), current_buf);
char target_buf[16]; char target_buf[16];
snprintf(target_buf, sizeof(target_buf), "%.1f°", id(active_climate_target_x10) / 10.0f); 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_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_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)); lv_arc_set_value(id(climate_arc), id(active_climate_target_x10));
@@ -5968,11 +6320,11 @@ script:
char current_buf[48]; char current_buf[48];
if (isnan(current_temperature)) { 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) { } 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 { } 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); lv_label_set_text(id(climate_current_label), current_buf);
+464 -112
View File
@@ -58,7 +58,7 @@
############################################################ ############################################################
substitutions: substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.18" SCREEN_FIRMWARE_VERSION: "0.2.19"
DYNAMIC_TILES: "false" DYNAMIC_TILES: "false"
DASHBOARD_INBOX_INTERNAL: "true" DASHBOARD_INBOX_INTERNAL: "true"
DEVICE_NAME: "cyd-new" DEVICE_NAME: "cyd-new"
@@ -797,13 +797,13 @@ esphome:
settings.night_start = atoi("${NIGHT_START_HOUR}") * 60; settings.night_start = atoi("${NIGHT_START_HOUR}") * 60;
settings.night_end = atoi("${NIGHT_END_HOUR}") * 60; settings.night_end = atoi("${NIGHT_END_HOUR}") * 60;
settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}"); settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}");
runtime_tiles::light_theme = true;
runtime_tiles::watch_font = id(headline)->get_lv_font(); runtime_tiles::watch_font = id(headline)->get_lv_font();
runtime_tiles::watch_value_font = id(watch_value)->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::watch_icon_font = id(watch_icon)->get_lv_font();
runtime_tiles::clock_font = id(clock_digits)->get_lv_font(); runtime_tiles::clock_font = id(clock_digits)->get_lv_font();
runtime_tiles::now_time = []() { return id(homeassistant_time).now(); }; runtime_tiles::now_time = []() { return id(homeassistant_time).now(); };
runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font(); 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"; runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true";
if (!runtime_tiles::enabled) return; if (!runtime_tiles::enabled) return;
runtime_tiles::load_settings(); runtime_tiles::load_settings();
@@ -857,6 +857,8 @@ esphome:
light_controls::fallback.kelvin = t.kelvin; light_controls::fallback.kelvin = t.kelvin;
light_controls::fallback.minimum = t.min_kelvin; light_controls::fallback.minimum = t.min_kelvin;
light_controls::fallback.maximum = t.max_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, 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), t.brightness, t.percentage, t.position, runtime_tiles::icon_for(t),
0x565C66, 0xE4B23C, 0xFFFFFF, "Uit", "Aan", t.modes, 0x565C66, 0xE4B23C, 0xFFFFFF, "Uit", "Aan", t.modes,
@@ -870,7 +872,10 @@ esphome:
id(climate_edit_pending) = false; id(climate_edit_pending) = false;
id(active_climate_mode) = t.state; id(active_climate_mode) = t.state;
id(active_climate_supported_modes) = t.hvac_modes; 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]; char buf[72];
if (std::isfinite(t.current)) snprintf(buf, sizeof(buf), "Nu: %.1f°", t.current); if (std::isfinite(t.current)) snprintf(buf, sizeof(buf), "Nu: %.1f°", t.current);
else snprintf(buf, sizeof(buf), "Nu: --"); else snprintf(buf, sizeof(buf), "Nu: --");
@@ -887,7 +892,7 @@ esphome:
- lambda: |- - lambda: |-
static const char *entities[] = {"${TILE1_ENTITY}", "${TILE2_ENTITY}", "${TILE3_ENTITY}", "${TILE4_ENTITY}", "${TILE5_ENTITY}", "${TILE6_ENTITY}", "${TILE7_ENTITY}", "${TILE9_ENTITY}"}; 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); 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[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[1] = [](int value) { id(set_light_color_temperature).execute(value); };
light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); }; light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); };
@@ -982,11 +987,32 @@ api:
index: int index: int
then: then:
- lambda: |- - 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) { if (runtime_tiles::enabled && index < (int)runtime_tiles::model.count) {
runtime_tiles::active_index = index; 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); 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 - action: light_controls_preview
then: then:
- script.execute: light_controls_preview - script.execute: light_controls_preview
@@ -1169,6 +1195,27 @@ globals:
type: std::string type: std::string
restore_value: no 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 - id: active_climate_supported_modes
type: std::string type: std::string
restore_value: no restore_value: no
@@ -1188,7 +1235,7 @@ font:
'0','1','2','3','4','5','6','7','8','9', '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',
' ','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" - file: "fonts/Roboto-500.ttf"
id: watch_value id: watch_value
@@ -1198,7 +1245,7 @@ font:
'0','1','2','3','4','5','6','7','8','9', '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',
' ','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. # Clock card digits only; the date line reuses the tile title font.
- file: "fonts/Roboto-500.ttf" - file: "fonts/Roboto-500.ttf"
@@ -1215,7 +1262,7 @@ font:
'0','1','2','3','4','5','6','7','8','9', '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',
' ','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" - file: "fonts/Roboto-400.ttf"
id: sublabel id: sublabel
@@ -1225,7 +1272,7 @@ font:
'0','1','2','3','4','5','6','7','8','9', '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',
' ','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" - file: "fonts/Roboto-400.ttf"
id: sublabel_big id: sublabel_big
@@ -1235,7 +1282,7 @@ font:
'0','1','2','3','4','5','6','7','8','9', '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',
' ','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) # Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0)
# Source: https://github.com/Templarian/MaterialDesign-Webfont # Source: https://github.com/Templarian/MaterialDesign-Webfont
@@ -1395,6 +1442,24 @@ font:
- "\U000F00E2" # broom - "\U000F00E2" # broom
- "\U000F05D6" # alert-circle-outline - "\U000F05D6" # alert-circle-outline
- "\U000F012C" # check - "\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 - "\U000F0591" # weather-fog
- "\U000F0592" # weather-hail - "\U000F0592" # weather-hail
- "\U000F0593" # weather-lightning - "\U000F0593" # weather-lightning
@@ -2196,6 +2261,14 @@ lvgl:
scrollable: false scrollable: false
scrollbar_mode: 'OFF' scrollbar_mode: 'OFF'
widgets: 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: - label:
id: lbl_room id: lbl_room
x: 11 x: 11
@@ -3895,7 +3968,7 @@ lvgl:
width: 36 width: 36
height: 36 height: 36
radius: 999 radius: 999
bg_color: 0xFF9F0A bg_color: 0xEEEEEE
bg_opa: COVER bg_opa: COVER
border_width: 0 border_width: 0
clickable: true clickable: true
@@ -3977,25 +4050,26 @@ lvgl:
clickable: false clickable: false
- obj: - obj:
id: climate_done_button id: climate_power_button
align: TOP_RIGHT align: TOP_RIGHT
x: -10 x: -10
y: 9 y: 9
width: 44 width: 44
height: 44 height: 44
radius: 999 radius: 999
bg_color: 0xD5EEFC bg_color: 0xEEEEEE
bg_opa: COVER bg_opa: COVER
border_width: 0 border_width: 0
clickable: true clickable: true
scrollable: false scrollable: false
on_click: on_click:
- lvgl.widget.hide: climate_detail_overlay - script.execute: climate_power_toggle
widgets: widgets:
- label: - label:
id: climate_power_icon
align: CENTER align: CENTER
text: "\U000F012C" text: "\U000F0425"
text_font: materialdesign_icons text_font: materialdesign_icons_mini
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
@@ -4010,7 +4084,7 @@ lvgl:
id: climate_current_label id: climate_current_label
align: TOP_MID align: TOP_MID
y: 42 y: 42
text: "Current: --" text: "Nu: --"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
text_opa: 85% text_opa: 85%
@@ -4061,7 +4135,7 @@ lvgl:
id: climate_set_label id: climate_set_label
align: CENTER align: CENTER
y: -16 y: -16
text: "SET TO" text: "Gewenst"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
text_opa: 68% text_opa: 68%
@@ -4164,8 +4238,8 @@ lvgl:
width: ${DISPLAY_W} width: ${DISPLAY_W}
height: ${DISPLAY_H} height: ${DISPLAY_H}
hidden: true hidden: true
bg_color: 0xFFFFFF bg_color: 0xE7E7E7
bg_opa: 90% bg_opa: COVER
border_width: 0 border_width: 0
clickable: true clickable: true
scrollable: false scrollable: false
@@ -4174,7 +4248,7 @@ lvgl:
- label: - label:
align: TOP_MID align: TOP_MID
y: 16 y: 16
text: "Mode" text: "Modus"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
@@ -4184,15 +4258,18 @@ lvgl:
- obj: - obj:
id: climate_mode_list id: climate_mode_list
align: TOP_MID align: TOP_MID
y: 50 y: 44
width: 98% width: 98%
height: 178 height: 178
bg_opa: TRANSP bg_opa: TRANSP
border_width: 0 border_width: 0
scrollable: false scrollable: false
clickable: false
pad_all: 3 pad_all: 3
layout: layout:
type: FLEX type: FLEX
pad_row: 3
pad_column: 4
flex_flow: ROW_WRAP flex_flow: ROW_WRAP
flex_align_main: CENTER flex_align_main: CENTER
flex_align_cross: CENTER flex_align_cross: CENTER
@@ -4200,12 +4277,13 @@ lvgl:
widgets: widgets:
- obj: - obj:
id: cmode_off id: cmode_off
width: 132 width: 88
height: 38 height: 26
radius: 19 radius: 13
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4214,18 +4292,19 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Off" text: "Uit"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
- obj: - obj:
id: cmode_heat id: cmode_heat
width: 132 width: 88
height: 38 height: 26
radius: 19 radius: 13
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4234,18 +4313,19 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Heat" text: "Verwarmen"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
- obj: - obj:
id: cmode_cool id: cmode_cool
width: 132 width: 88
height: 38 height: 26
radius: 19 radius: 13
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4254,18 +4334,19 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Cool" text: "Koelen"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
- obj: - obj:
id: cmode_heat_cool id: cmode_heat_cool
width: 132 width: 88
height: 38 height: 26
radius: 19 radius: 13
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4274,18 +4355,19 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Heat/Cool" text: "Verw./koelen"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
- obj: - obj:
id: cmode_auto id: cmode_auto
width: 132 width: 88
height: 38 height: 26
radius: 19 radius: 13
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4300,12 +4382,13 @@ lvgl:
clickable: false clickable: false
- obj: - obj:
id: cmode_dry id: cmode_dry
width: 132 width: 88
height: 38 height: 26
radius: 19 radius: 13
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4314,18 +4397,19 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Dry" text: "Drogen"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
- obj: - obj:
id: cmode_fan_only id: cmode_fan_only
width: 132 width: 88
height: 38 height: 26
radius: 19 radius: 13
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4334,11 +4418,205 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Fan" text: "Ventileren"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false 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: - obj:
id: climate_mode_overlay_x id: climate_mode_overlay_x
align: TOP_LEFT align: TOP_LEFT
@@ -4347,11 +4625,11 @@ lvgl:
width: 36 width: 36
height: 36 height: 36
radius: 999 radius: 999
bg_color: 0xEEEEEE bg_color: 0xFFFFFF
bg_opa: COVER bg_opa: COVER
border_width: 1 border_width: 1
border_color: 0xFFFFFF border_color: 0xDDDDDD
border_opa: 20% border_opa: COVER
clickable: true clickable: true
scrollable: false scrollable: false
on_click: on_click:
@@ -4364,28 +4642,6 @@ lvgl:
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false 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 ---------- # ---------- VACUUM CONTROL ----------
# Een stofzuiger is geen schakelaar: start, pauze, stop en terug naar # Een stofzuiger is geen schakelaar: start, pauze, stop en terug naar
@@ -4796,7 +5052,7 @@ script:
static unsigned test_page=0; static unsigned test_page=0;
id(tile_page) = runtime_tiles::enabled ? test_page++ % runtime_tiles::page_count() : 1; id(tile_page) = runtime_tiles::enabled ? test_page++ % runtime_tiles::page_count() : 1;
- script.execute: show_tile_page - script.execute: show_tile_page
- delay: 150ms - delay: 300ms
- lambda: |- - lambda: |-
lv_obj_update_layout(id(tile_scroll)); lv_obj_update_layout(id(tile_scroll));
const int count = runtime_tiles::enabled ? runtime_tiles::model.count : std::clamp(atoi("${TILE_COUNT}"), 1, 10); const 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); if (has(m)) lv_obj_clear_flag(btn, LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN);
bool selected = (current == std::string(m)); bool selected = (current == std::string(m));
lv_obj_set_style_bg_color(btn, lv_obj_set_style_bg_color(btn, lv_color_hex(selected ? 0x00A6ED : 0xFFFFFF), LV_PART_MAIN);
lv_color_hex(selected ? 0xFF9F0A : 0x2C2C30), 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_off), "off");
apply(id(cmode_heat), "heat"); apply(id(cmode_heat), "heat");
@@ -5080,10 +5336,109 @@ script:
apply(id(cmode_auto), "auto"); apply(id(cmode_auto), "auto");
apply(id(cmode_dry), "dry"); apply(id(cmode_dry), "dry");
apply(id(cmode_fan_only), "fan_only"); 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 &current_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 - lvgl.widget.show: climate_mode_overlay
# Zet precies een modus. Geen tussenstappen, dus geen ongewenste # Zet precies een modus. Geen tussenstappen, dus geen ongewenste
# commando's naar de warmtepomp onderweg. # 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 - id: set_climate_mode
mode: restart mode: restart
parameters: parameters:
@@ -5093,13 +5448,9 @@ script:
id(climate_edit_ms) = millis(); id(climate_edit_ms) = millis();
id(climate_edit_pending) = true; id(climate_edit_pending) = true;
id(active_climate_mode) = mode; id(active_climate_mode) = mode;
if (mode == "off") lv_label_set_text(id(climate_mode_label), "Off"); id(active_climate_power_on) = mode != "off";
if (mode == "heat") lv_label_set_text(id(climate_mode_label), "Heat"); id(climate_power_refresh).execute();
if (mode == "cool") lv_label_set_text(id(climate_mode_label), "Cool"); lv_label_set_text(id(climate_mode_label), tile_controls::climate_mode_text(mode));
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");
- lvgl.widget.hide: climate_mode_overlay - lvgl.widget.hide: climate_mode_overlay
- if: - if:
condition: condition:
@@ -5671,18 +6022,19 @@ script:
char current_buf[48]; char current_buf[48];
if (isnan(climate_current_temperature)) { 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) { } 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 { } 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); lv_label_set_text(id(climate_current_label), current_buf);
char target_buf[16]; char target_buf[16];
snprintf(target_buf, sizeof(target_buf), "%.1f°", id(active_climate_target_x10) / 10.0f); 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_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_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)); lv_arc_set_value(id(climate_arc), id(active_climate_target_x10));
@@ -6078,11 +6430,11 @@ script:
char current_buf[48]; char current_buf[48];
if (isnan(current_temperature)) { 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) { } 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 { } 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); lv_label_set_text(id(climate_current_label), current_buf);
+464 -112
View File
@@ -59,7 +59,7 @@
############################################################ ############################################################
substitutions: substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.18" SCREEN_FIRMWARE_VERSION: "0.2.19"
DYNAMIC_TILES: "true" DYNAMIC_TILES: "true"
DASHBOARD_INBOX_INTERNAL: "false" DASHBOARD_INBOX_INTERNAL: "false"
DEVICE_NAME: "cyd-new" DEVICE_NAME: "cyd-new"
@@ -801,13 +801,13 @@ esphome:
settings.night_start = atoi("${NIGHT_START_HOUR}") * 60; settings.night_start = atoi("${NIGHT_START_HOUR}") * 60;
settings.night_end = atoi("${NIGHT_END_HOUR}") * 60; settings.night_end = atoi("${NIGHT_END_HOUR}") * 60;
settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}"); settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}");
runtime_tiles::light_theme = true;
runtime_tiles::watch_font = id(headline)->get_lv_font(); runtime_tiles::watch_font = id(headline)->get_lv_font();
runtime_tiles::watch_value_font = id(watch_value)->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::watch_icon_font = id(watch_icon)->get_lv_font();
runtime_tiles::clock_font = id(clock_digits)->get_lv_font(); runtime_tiles::clock_font = id(clock_digits)->get_lv_font();
runtime_tiles::now_time = []() { return id(homeassistant_time).now(); }; runtime_tiles::now_time = []() { return id(homeassistant_time).now(); };
runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font(); 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"; runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true";
if (!runtime_tiles::enabled) return; if (!runtime_tiles::enabled) return;
runtime_tiles::load_settings(); runtime_tiles::load_settings();
@@ -861,6 +861,8 @@ esphome:
light_controls::fallback.kelvin = t.kelvin; light_controls::fallback.kelvin = t.kelvin;
light_controls::fallback.minimum = t.min_kelvin; light_controls::fallback.minimum = t.min_kelvin;
light_controls::fallback.maximum = t.max_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, 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), t.brightness, t.percentage, t.position, runtime_tiles::icon_for(t),
0x565C66, 0xE4B23C, 0xFFFFFF, "Uit", "Aan", t.modes, 0x565C66, 0xE4B23C, 0xFFFFFF, "Uit", "Aan", t.modes,
@@ -874,7 +876,10 @@ esphome:
id(climate_edit_pending) = false; id(climate_edit_pending) = false;
id(active_climate_mode) = t.state; id(active_climate_mode) = t.state;
id(active_climate_supported_modes) = t.hvac_modes; 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]; char buf[72];
if (std::isfinite(t.current)) snprintf(buf, sizeof(buf), "Nu: %.1f°", t.current); if (std::isfinite(t.current)) snprintf(buf, sizeof(buf), "Nu: %.1f°", t.current);
else snprintf(buf, sizeof(buf), "Nu: --"); else snprintf(buf, sizeof(buf), "Nu: --");
@@ -891,7 +896,7 @@ esphome:
- lambda: |- - lambda: |-
static const char *entities[] = {"${TILE1_ENTITY}", "${TILE2_ENTITY}", "${TILE3_ENTITY}", "${TILE4_ENTITY}", "${TILE5_ENTITY}", "${TILE6_ENTITY}", "${TILE7_ENTITY}", "${TILE9_ENTITY}"}; 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); 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[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[1] = [](int value) { id(set_light_color_temperature).execute(value); };
light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); }; light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); };
@@ -986,11 +991,32 @@ api:
index: int index: int
then: then:
- lambda: |- - 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) { if (runtime_tiles::enabled && index < (int)runtime_tiles::model.count) {
runtime_tiles::active_index = index; 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); 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 - action: light_controls_preview
then: then:
- script.execute: light_controls_preview - script.execute: light_controls_preview
@@ -1160,6 +1186,27 @@ globals:
type: std::string type: std::string
restore_value: no 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 - id: active_climate_supported_modes
type: std::string type: std::string
restore_value: no restore_value: no
@@ -1179,7 +1226,7 @@ font:
'0','1','2','3','4','5','6','7','8','9', '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',
' ','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" - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-500.ttf"
id: watch_value id: watch_value
@@ -1189,7 +1236,7 @@ font:
'0','1','2','3','4','5','6','7','8','9', '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',
' ','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. # 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" - 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', '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',
' ','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" - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-400.ttf"
id: sublabel id: sublabel
@@ -1216,7 +1263,7 @@ font:
'0','1','2','3','4','5','6','7','8','9', '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',
' ','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" - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-400.ttf"
id: sublabel_big id: sublabel_big
@@ -1226,7 +1273,7 @@ font:
'0','1','2','3','4','5','6','7','8','9', '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',
' ','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) # Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0)
# Source: https://github.com/Templarian/MaterialDesign-Webfont # Source: https://github.com/Templarian/MaterialDesign-Webfont
@@ -1386,6 +1433,24 @@ font:
- "\U000F00E2" # broom - "\U000F00E2" # broom
- "\U000F05D6" # alert-circle-outline - "\U000F05D6" # alert-circle-outline
- "\U000F012C" # check - "\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 - "\U000F0591" # weather-fog
- "\U000F0592" # weather-hail - "\U000F0592" # weather-hail
- "\U000F0593" # weather-lightning - "\U000F0593" # weather-lightning
@@ -2129,6 +2194,14 @@ lvgl:
scrollable: false scrollable: false
scrollbar_mode: 'OFF' scrollbar_mode: 'OFF'
widgets: 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: - label:
id: lbl_room id: lbl_room
x: 11 x: 11
@@ -3828,7 +3901,7 @@ lvgl:
width: 36 width: 36
height: 36 height: 36
radius: 999 radius: 999
bg_color: 0xFF9F0A bg_color: 0xEEEEEE
bg_opa: COVER bg_opa: COVER
border_width: 0 border_width: 0
clickable: true clickable: true
@@ -3910,25 +3983,26 @@ lvgl:
clickable: false clickable: false
- obj: - obj:
id: climate_done_button id: climate_power_button
align: TOP_RIGHT align: TOP_RIGHT
x: -10 x: -10
y: 9 y: 9
width: 44 width: 44
height: 44 height: 44
radius: 999 radius: 999
bg_color: 0xD5EEFC bg_color: 0xEEEEEE
bg_opa: COVER bg_opa: COVER
border_width: 0 border_width: 0
clickable: true clickable: true
scrollable: false scrollable: false
on_click: on_click:
- lvgl.widget.hide: climate_detail_overlay - script.execute: climate_power_toggle
widgets: widgets:
- label: - label:
id: climate_power_icon
align: CENTER align: CENTER
text: "\U000F012C" text: "\U000F0425"
text_font: materialdesign_icons text_font: materialdesign_icons_mini
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
@@ -3943,7 +4017,7 @@ lvgl:
id: climate_current_label id: climate_current_label
align: TOP_MID align: TOP_MID
y: 42 y: 42
text: "Current: --" text: "Nu: --"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
text_opa: 85% text_opa: 85%
@@ -3994,7 +4068,7 @@ lvgl:
id: climate_set_label id: climate_set_label
align: CENTER align: CENTER
y: -16 y: -16
text: "SET TO" text: "Gewenst"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
text_opa: 68% text_opa: 68%
@@ -4097,8 +4171,8 @@ lvgl:
width: ${DISPLAY_W} width: ${DISPLAY_W}
height: ${DISPLAY_H} height: ${DISPLAY_H}
hidden: true hidden: true
bg_color: 0xFFFFFF bg_color: 0xE7E7E7
bg_opa: 90% bg_opa: COVER
border_width: 0 border_width: 0
clickable: true clickable: true
scrollable: false scrollable: false
@@ -4107,7 +4181,7 @@ lvgl:
- label: - label:
align: TOP_MID align: TOP_MID
y: 16 y: 16
text: "Mode" text: "Modus"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
@@ -4117,15 +4191,18 @@ lvgl:
- obj: - obj:
id: climate_mode_list id: climate_mode_list
align: TOP_MID align: TOP_MID
y: 50 y: 44
width: 98% width: 98%
height: 178 height: 178
bg_opa: TRANSP bg_opa: TRANSP
border_width: 0 border_width: 0
scrollable: false scrollable: false
clickable: false
pad_all: 3 pad_all: 3
layout: layout:
type: FLEX type: FLEX
pad_row: 3
pad_column: 4
flex_flow: ROW_WRAP flex_flow: ROW_WRAP
flex_align_main: CENTER flex_align_main: CENTER
flex_align_cross: CENTER flex_align_cross: CENTER
@@ -4133,12 +4210,13 @@ lvgl:
widgets: widgets:
- obj: - obj:
id: cmode_off id: cmode_off
width: 132 width: 88
height: 38 height: 26
radius: 19 radius: 13
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4147,18 +4225,19 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Off" text: "Uit"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
- obj: - obj:
id: cmode_heat id: cmode_heat
width: 132 width: 88
height: 38 height: 26
radius: 19 radius: 13
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4167,18 +4246,19 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Heat" text: "Verwarmen"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
- obj: - obj:
id: cmode_cool id: cmode_cool
width: 132 width: 88
height: 38 height: 26
radius: 19 radius: 13
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4187,18 +4267,19 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Cool" text: "Koelen"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
- obj: - obj:
id: cmode_heat_cool id: cmode_heat_cool
width: 132 width: 88
height: 38 height: 26
radius: 19 radius: 13
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4207,18 +4288,19 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Heat/Cool" text: "Verw./koelen"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
- obj: - obj:
id: cmode_auto id: cmode_auto
width: 132 width: 88
height: 38 height: 26
radius: 19 radius: 13
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4233,12 +4315,13 @@ lvgl:
clickable: false clickable: false
- obj: - obj:
id: cmode_dry id: cmode_dry
width: 132 width: 88
height: 38 height: 26
radius: 19 radius: 13
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4247,18 +4330,19 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Dry" text: "Drogen"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
- obj: - obj:
id: cmode_fan_only id: cmode_fan_only
width: 132 width: 88
height: 38 height: 26
radius: 19 radius: 13
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4267,11 +4351,205 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Fan" text: "Ventileren"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false 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: - obj:
id: climate_mode_overlay_x id: climate_mode_overlay_x
align: TOP_LEFT align: TOP_LEFT
@@ -4280,11 +4558,11 @@ lvgl:
width: 36 width: 36
height: 36 height: 36
radius: 999 radius: 999
bg_color: 0xEEEEEE bg_color: 0xFFFFFF
bg_opa: COVER bg_opa: COVER
border_width: 1 border_width: 1
border_color: 0xFFFFFF border_color: 0xDDDDDD
border_opa: 20% border_opa: COVER
clickable: true clickable: true
scrollable: false scrollable: false
on_click: on_click:
@@ -4297,28 +4575,6 @@ lvgl:
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false 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 ---------- # ---------- VACUUM CONTROL ----------
# Een stofzuiger is geen schakelaar: start, pauze, stop en terug naar # Een stofzuiger is geen schakelaar: start, pauze, stop en terug naar
@@ -4729,7 +4985,7 @@ script:
static unsigned test_page=0; static unsigned test_page=0;
id(tile_page) = runtime_tiles::enabled ? test_page++ % runtime_tiles::page_count() : 1; id(tile_page) = runtime_tiles::enabled ? test_page++ % runtime_tiles::page_count() : 1;
- script.execute: show_tile_page - script.execute: show_tile_page
- delay: 150ms - delay: 300ms
- lambda: |- - lambda: |-
lv_obj_update_layout(id(tile_scroll)); lv_obj_update_layout(id(tile_scroll));
const int count = runtime_tiles::enabled ? runtime_tiles::model.count : std::clamp(atoi("${TILE_COUNT}"), 1, 10); const 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); if (has(m)) lv_obj_clear_flag(btn, LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN);
bool selected = (current == std::string(m)); bool selected = (current == std::string(m));
lv_obj_set_style_bg_color(btn, lv_obj_set_style_bg_color(btn, lv_color_hex(selected ? 0x00A6ED : 0xFFFFFF), LV_PART_MAIN);
lv_color_hex(selected ? 0xFF9F0A : 0x2C2C30), 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_off), "off");
apply(id(cmode_heat), "heat"); apply(id(cmode_heat), "heat");
@@ -5013,10 +5269,109 @@ script:
apply(id(cmode_auto), "auto"); apply(id(cmode_auto), "auto");
apply(id(cmode_dry), "dry"); apply(id(cmode_dry), "dry");
apply(id(cmode_fan_only), "fan_only"); 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 &current_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 - lvgl.widget.show: climate_mode_overlay
# Zet precies een modus. Geen tussenstappen, dus geen ongewenste # Zet precies een modus. Geen tussenstappen, dus geen ongewenste
# commando's naar de warmtepomp onderweg. # 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 - id: set_climate_mode
mode: restart mode: restart
parameters: parameters:
@@ -5026,13 +5381,9 @@ script:
id(climate_edit_ms) = millis(); id(climate_edit_ms) = millis();
id(climate_edit_pending) = true; id(climate_edit_pending) = true;
id(active_climate_mode) = mode; id(active_climate_mode) = mode;
if (mode == "off") lv_label_set_text(id(climate_mode_label), "Off"); id(active_climate_power_on) = mode != "off";
if (mode == "heat") lv_label_set_text(id(climate_mode_label), "Heat"); id(climate_power_refresh).execute();
if (mode == "cool") lv_label_set_text(id(climate_mode_label), "Cool"); lv_label_set_text(id(climate_mode_label), tile_controls::climate_mode_text(mode));
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");
- lvgl.widget.hide: climate_mode_overlay - lvgl.widget.hide: climate_mode_overlay
- if: - if:
condition: condition:
@@ -5604,18 +5955,19 @@ script:
char current_buf[48]; char current_buf[48];
if (isnan(climate_current_temperature)) { 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) { } 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 { } 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); lv_label_set_text(id(climate_current_label), current_buf);
char target_buf[16]; char target_buf[16];
snprintf(target_buf, sizeof(target_buf), "%.1f°", id(active_climate_target_x10) / 10.0f); 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_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_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)); lv_arc_set_value(id(climate_arc), id(active_climate_target_x10));
@@ -6011,11 +6363,11 @@ script:
char current_buf[48]; char current_buf[48];
if (isnan(current_temperature)) { 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) { } 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 { } 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); lv_label_set_text(id(climate_current_label), current_buf);
+465 -113
View File
@@ -2,7 +2,7 @@
# Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911. # 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. # Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile.
substitutions: substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.18" SCREEN_FIRMWARE_VERSION: "0.2.19"
DYNAMIC_TILES: "true" DYNAMIC_TILES: "true"
DASHBOARD_INBOX_INTERNAL: "false" DASHBOARD_INBOX_INTERNAL: "false"
DEVICE_NAME: "guition-new" DEVICE_NAME: "guition-new"
@@ -607,13 +607,13 @@ esphome:
settings.night_start = atoi("${NIGHT_START_HOUR}") * 60; settings.night_start = atoi("${NIGHT_START_HOUR}") * 60;
settings.night_end = atoi("${NIGHT_END_HOUR}") * 60; settings.night_end = atoi("${NIGHT_END_HOUR}") * 60;
settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}"); settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}");
runtime_tiles::light_theme = true;
runtime_tiles::watch_font = id(headline)->get_lv_font(); runtime_tiles::watch_font = id(headline)->get_lv_font();
runtime_tiles::watch_value_font = id(watch_value)->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::watch_icon_font = id(watch_icon)->get_lv_font();
runtime_tiles::clock_font = id(clock_digits)->get_lv_font(); runtime_tiles::clock_font = id(clock_digits)->get_lv_font();
runtime_tiles::now_time = []() { return id(homeassistant_time).now(); }; runtime_tiles::now_time = []() { return id(homeassistant_time).now(); };
runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font(); 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"; runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true";
if (!runtime_tiles::enabled) return; if (!runtime_tiles::enabled) return;
runtime_tiles::rotation_supported = true; runtime_tiles::rotation_supported = true;
@@ -668,6 +668,8 @@ esphome:
light_controls::fallback.kelvin = t.kelvin; light_controls::fallback.kelvin = t.kelvin;
light_controls::fallback.minimum = t.min_kelvin; light_controls::fallback.minimum = t.min_kelvin;
light_controls::fallback.maximum = t.max_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, 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), t.brightness, t.percentage, t.position, runtime_tiles::icon_for(t),
0xD5EEFC, 0x00A6ED, 0x1B1B1B, "Uit", "Aan", t.modes, 0xD5EEFC, 0x00A6ED, 0x1B1B1B, "Uit", "Aan", t.modes,
@@ -681,7 +683,10 @@ esphome:
id(climate_edit_pending) = false; id(climate_edit_pending) = false;
id(active_climate_mode) = t.state; id(active_climate_mode) = t.state;
id(active_climate_supported_modes) = t.hvac_modes; 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]; char buf[72];
if (std::isfinite(t.current)) snprintf(buf, sizeof(buf), "Nu: %.1f°", t.current); if (std::isfinite(t.current)) snprintf(buf, sizeof(buf), "Nu: %.1f°", t.current);
else snprintf(buf, sizeof(buf), "Nu: --"); else snprintf(buf, sizeof(buf), "Nu: --");
@@ -698,7 +703,7 @@ esphome:
- lambda: |- - lambda: |-
static const char *entities[] = {"${TILE1_ENTITY}", "${TILE2_ENTITY}", "${TILE3_ENTITY}", "${TILE4_ENTITY}", "${TILE5_ENTITY}", "${TILE6_ENTITY}", "${TILE7_ENTITY}", "${TILE9_ENTITY}"}; 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); 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[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[1] = [](int value) { id(set_light_color_temperature).execute(value); };
light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); }; light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); };
@@ -791,11 +796,32 @@ api:
index: int index: int
then: then:
- lambda: |- - 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) { if (runtime_tiles::enabled && index < (int)runtime_tiles::model.count) {
runtime_tiles::active_index = index; 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); 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 - action: light_controls_preview
then: then:
- script.execute: light_controls_preview - script.execute: light_controls_preview
@@ -965,6 +991,27 @@ globals:
type: std::string type: std::string
restore_value: no 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 - id: active_climate_supported_modes
type: std::string type: std::string
restore_value: no restore_value: no
@@ -989,7 +1036,7 @@ font:
'0','1','2','3','4','5','6','7','8','9', '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',
' ','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" - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-500.ttf"
id: watch_value id: watch_value
@@ -999,7 +1046,7 @@ font:
'0','1','2','3','4','5','6','7','8','9', '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',
' ','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. # 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" - 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', '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',
' ','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" - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-400.ttf"
id: sublabel id: sublabel
@@ -1026,7 +1073,7 @@ font:
'0','1','2','3','4','5','6','7','8','9', '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',
' ','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" - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-400.ttf"
id: sublabel_big id: sublabel_big
@@ -1036,7 +1083,7 @@ font:
'0','1','2','3','4','5','6','7','8','9', '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',
' ','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) # Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0)
# Source: https://github.com/Templarian/MaterialDesign-Webfont # Source: https://github.com/Templarian/MaterialDesign-Webfont
@@ -1196,6 +1243,24 @@ font:
- "\U000F00E2" # broom - "\U000F00E2" # broom
- "\U000F05D6" # alert-circle-outline - "\U000F05D6" # alert-circle-outline
- "\U000F012C" # check - "\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 - "\U000F0591" # weather-fog
- "\U000F0592" # weather-hail - "\U000F0592" # weather-hail
- "\U000F0593" # weather-lightning - "\U000F0593" # weather-lightning
@@ -1927,6 +1992,14 @@ lvgl:
scrollable: false scrollable: false
scrollbar_mode: 'OFF' scrollbar_mode: 'OFF'
widgets: 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: - label:
id: lbl_room id: lbl_room
x: 16 x: 16
@@ -3686,7 +3759,7 @@ lvgl:
width: 54 width: 54
height: 54 height: 54
radius: 999 radius: 999
bg_color: 0xFF9F0A bg_color: 0xEEEEEE
bg_opa: COVER bg_opa: COVER
border_width: 0 border_width: 0
clickable: true clickable: true
@@ -3768,25 +3841,26 @@ lvgl:
clickable: false clickable: false
- obj: - obj:
id: climate_done_button id: climate_power_button
align: TOP_RIGHT align: TOP_RIGHT
x: -15 x: -15
y: 18 y: 18
width: 66 width: 66
height: 66 height: 66
radius: 999 radius: 999
bg_color: 0xD5EEFC bg_color: 0xEEEEEE
bg_opa: COVER bg_opa: COVER
border_width: 0 border_width: 0
clickable: true clickable: true
scrollable: false scrollable: false
on_click: on_click:
- lvgl.widget.hide: climate_detail_overlay - script.execute: climate_power_toggle
widgets: widgets:
- label: - label:
id: climate_power_icon
align: CENTER align: CENTER
text: "\U000F012C" text: "\U000F0425"
text_font: materialdesign_icons text_font: materialdesign_icons_mini
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
@@ -3801,7 +3875,7 @@ lvgl:
id: climate_current_label id: climate_current_label
align: TOP_MID align: TOP_MID
y: 84 y: 84
text: "Current: --" text: "Nu: --"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
text_opa: 85% text_opa: 85%
@@ -3852,7 +3926,7 @@ lvgl:
id: climate_set_label id: climate_set_label
align: CENTER align: CENTER
y: -32 y: -32
text: "SET TO" text: "Gewenst"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
text_opa: 68% text_opa: 68%
@@ -3955,8 +4029,8 @@ lvgl:
width: ${DISPLAY_W} width: ${DISPLAY_W}
height: ${DISPLAY_H} height: ${DISPLAY_H}
hidden: true hidden: true
bg_color: 0xFFFFFF bg_color: 0xE7E7E7
bg_opa: 90% bg_opa: COVER
border_width: 0 border_width: 0
clickable: true clickable: true
scrollable: false scrollable: false
@@ -3965,7 +4039,7 @@ lvgl:
- label: - label:
align: TOP_MID align: TOP_MID
y: 32 y: 32
text: "Mode" text: "Modus"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
@@ -3975,15 +4049,18 @@ lvgl:
- obj: - obj:
id: climate_mode_list id: climate_mode_list
align: TOP_MID align: TOP_MID
y: 100 y: 92
width: 98% width: 98%
height: 356 height: 372
bg_opa: TRANSP bg_opa: TRANSP
border_width: 0 border_width: 0
scrollable: false scrollable: false
clickable: false
pad_all: 4 pad_all: 4
layout: layout:
type: FLEX type: FLEX
pad_row: 6
pad_column: 6
flex_flow: ROW_WRAP flex_flow: ROW_WRAP
flex_align_main: CENTER flex_align_main: CENTER
flex_align_cross: CENTER flex_align_cross: CENTER
@@ -3991,12 +4068,13 @@ lvgl:
widgets: widgets:
- obj: - obj:
id: cmode_off id: cmode_off
width: 198 width: 136
height: 76 height: 58
radius: 28 radius: 22
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4005,18 +4083,19 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Off" text: "Uit"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
- obj: - obj:
id: cmode_heat id: cmode_heat
width: 198 width: 136
height: 76 height: 58
radius: 28 radius: 22
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4025,18 +4104,19 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Heat" text: "Verwarmen"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
- obj: - obj:
id: cmode_cool id: cmode_cool
width: 198 width: 136
height: 76 height: 58
radius: 28 radius: 22
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4045,18 +4125,19 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Cool" text: "Koelen"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
- obj: - obj:
id: cmode_heat_cool id: cmode_heat_cool
width: 198 width: 136
height: 76 height: 58
radius: 28 radius: 22
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4065,18 +4146,19 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Heat/Cool" text: "Verw./koelen"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
- obj: - obj:
id: cmode_auto id: cmode_auto
width: 198 width: 136
height: 76 height: 58
radius: 28 radius: 22
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4091,12 +4173,13 @@ lvgl:
clickable: false clickable: false
- obj: - obj:
id: cmode_dry id: cmode_dry
width: 198 width: 136
height: 76 height: 58
radius: 28 radius: 22
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4105,18 +4188,19 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Dry" text: "Drogen"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false clickable: false
- obj: - obj:
id: cmode_fan_only id: cmode_fan_only
width: 198 width: 136
height: 76 height: 58
radius: 28 radius: 22
bg_color: 0xF0F0F0 bg_color: 0xFFFFFF
bg_opa: 92% bg_opa: COVER
border_width: 0 border_width: 1
border_color: 0xDDDDDD
clickable: true clickable: true
scrollable: false scrollable: false
pad_all: 0 pad_all: 0
@@ -4125,11 +4209,205 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Fan" text: "Ventileren"
text_font: sublabel_big text_font: sublabel_big
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false 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: - obj:
id: climate_mode_overlay_x id: climate_mode_overlay_x
align: TOP_LEFT align: TOP_LEFT
@@ -4138,11 +4416,11 @@ lvgl:
width: 54 width: 54
height: 54 height: 54
radius: 999 radius: 999
bg_color: 0xEEEEEE bg_color: 0xFFFFFF
bg_opa: COVER bg_opa: COVER
border_width: 1 border_width: 1
border_color: 0xFFFFFF border_color: 0xDDDDDD
border_opa: 20% border_opa: COVER
clickable: true clickable: true
scrollable: false scrollable: false
on_click: on_click:
@@ -4155,28 +4433,6 @@ lvgl:
text_color: 0x1B1B1B text_color: 0x1B1B1B
clickable: false 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 ---------- # ---------- VACUUM CONTROL ----------
# Een stofzuiger is geen schakelaar: start, pauze, stop en terug naar # Een stofzuiger is geen schakelaar: start, pauze, stop en terug naar
@@ -4596,7 +4852,7 @@ script:
static unsigned test_page=0; static unsigned test_page=0;
id(tile_page) = runtime_tiles::enabled ? test_page++ % runtime_tiles::page_count() : 1; id(tile_page) = runtime_tiles::enabled ? test_page++ % runtime_tiles::page_count() : 1;
- script.execute: show_tile_page - script.execute: show_tile_page
- delay: 150ms - delay: 300ms
- lambda: |- - lambda: |-
lv_obj_update_layout(id(tile_scroll)); lv_obj_update_layout(id(tile_scroll));
const int count = runtime_tiles::enabled ? runtime_tiles::model.count : std::clamp(atoi("${TILE_COUNT}"), 1, 10); const 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); if (has(m)) lv_obj_clear_flag(btn, LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN);
bool selected = (current == std::string(m)); bool selected = (current == std::string(m));
lv_obj_set_style_bg_color(btn, lv_obj_set_style_bg_color(btn, lv_color_hex(selected ? 0x00A6ED : 0xFFFFFF), LV_PART_MAIN);
lv_color_hex(selected ? 0xD5EEFC : 0xF0F0F0), 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_off), "off");
apply(id(cmode_heat), "heat"); apply(id(cmode_heat), "heat");
@@ -4903,10 +5159,109 @@ script:
apply(id(cmode_auto), "auto"); apply(id(cmode_auto), "auto");
apply(id(cmode_dry), "dry"); apply(id(cmode_dry), "dry");
apply(id(cmode_fan_only), "fan_only"); 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 &current_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 - lvgl.widget.show: climate_mode_overlay
# Zet precies een modus. Geen tussenstappen, dus geen ongewenste # Zet precies een modus. Geen tussenstappen, dus geen ongewenste
# commando's naar de warmtepomp onderweg. # 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 - id: set_climate_mode
mode: restart mode: restart
parameters: parameters:
@@ -4916,13 +5271,9 @@ script:
id(climate_edit_ms) = millis(); id(climate_edit_ms) = millis();
id(climate_edit_pending) = true; id(climate_edit_pending) = true;
id(active_climate_mode) = mode; id(active_climate_mode) = mode;
if (mode == "off") lv_label_set_text(id(climate_mode_label), "Off"); id(active_climate_power_on) = mode != "off";
if (mode == "heat") lv_label_set_text(id(climate_mode_label), "Heat"); id(climate_power_refresh).execute();
if (mode == "cool") lv_label_set_text(id(climate_mode_label), "Cool"); lv_label_set_text(id(climate_mode_label), tile_controls::climate_mode_text(mode));
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");
- lvgl.widget.hide: climate_mode_overlay - lvgl.widget.hide: climate_mode_overlay
- if: - if:
condition: condition:
@@ -5494,18 +5845,19 @@ script:
char current_buf[48]; char current_buf[48];
if (isnan(climate_current_temperature)) { 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) { } 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 { } 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); lv_label_set_text(id(climate_current_label), current_buf);
char target_buf[16]; char target_buf[16];
snprintf(target_buf, sizeof(target_buf), "%.1f°", id(active_climate_target_x10) / 10.0f); 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_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_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)); lv_arc_set_value(id(climate_arc), id(active_climate_target_x10));
@@ -5901,11 +6253,11 @@ script:
char current_buf[48]; char current_buf[48];
if (isnan(current_temperature)) { 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) { } 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 { } 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); lv_label_set_text(id(climate_current_label), current_buf);
+20
View File
@@ -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) ## 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. - 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.
+118 -21
View File
@@ -1,6 +1,6 @@
"""Pure validation, firmware generation and bounded display protocol.""" """Pure validation, firmware generation and bounded display protocol."""
import base64 import base64
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
import json import json
import math import math
import re import re
@@ -16,8 +16,10 @@ WEEKDAYS = ['ma', 'di', 'wo', 'do', 'vr', 'za', 'zo']
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen' REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'} REFS = {'cyd': 'main', 'guition': 'main'}
# Firmware shipped with this app release; screens below it get an update offer. # Firmware shipped with this app release; screens below it get an update offer.
FIRMWARE_VERSION = '0.2.18' 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 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()) 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 = { TILE_BACKGROUNDS = {
@@ -40,6 +42,43 @@ DISPLAYS = {'weather': ('standard', 'watch', 'forecast'), 'sensor': ('standard',
# Displays that only work on a double-width card. # Displays that only work on a double-width card.
WIDE_ONLY = ('forecast', 'sunpath') 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. # Additive schema 1 extension. An absent object retains old firmware/YAML defaults.
SETTING_RULES = { SETTING_RULES = {
'standby_enabled': (True, None, None), 'standby_enabled': (True, None, None),
@@ -114,7 +153,7 @@ def validate_layout(data):
item = {'entity': tile['entity'], 'name': name.strip()} item = {'entity': tile['entity'], 'name': name.strip()}
if 'options' in tile: if 'options' in tile:
options = tile['options'] 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.') raise ValueError('Onbekende tegelinstellingen.')
if 'background' in options and (not isinstance(options['background'],str) or options['background'] not in TILE_BACKGROUNDS): 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.') 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. # The five-day strip and the sun path only fit a double-width card.
if options.get('display') in WIDE_ONLY: if options.get('display') in WIDE_ONLY:
options = {**options, 'size': 'wide'} 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.') 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'}: 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.') 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.') raise ValueError('Geschiedenis: kies 1, 6 of 24 uur.')
if options.get('display') == 'watch' and options.get('inline') == 'slider': if options.get('display') == 'watch' and options.get('inline') == 'slider':
raise ValueError('Kies grote waarde of mini-schuif.') 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) item['options'] = dict(options)
clean.append(item) clean.append(item)
result = {'title': title.strip(), 'tiles': clean} result = {'title': title.strip(), 'tiles': clean}
@@ -154,26 +197,71 @@ def local_clock(value, tz):
moment = moment.replace(tzinfo=timezone.utc) moment = moment.replace(tzinfo=timezone.utc)
return moment.astimezone(tz or timezone.utc).strftime('%H:%M') 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).""" """Small, pre-computed values the firmware cannot derive itself (time zones, forecasts)."""
domain = tile['entity'].split('.')[0] domain = tile['entity'].split('.')[0]
attrs = states.get(tile['entity'], {}).get('attributes', {}) attrs = states.get(tile['entity'], {}).get('attributes', {})
if domain == 'weather' and forecast: if domain == 'weather' and (forecast or hourly):
result = {}
days = [] days = []
for entry in forecast[:5]: for entry in forecast or []:
if not isinstance(entry, dict): if not isinstance(entry, dict) or len(days) == 5:
continue continue
try: day = forecast_time(entry, tz)
day = datetime.fromisoformat(str(entry.get('datetime')).replace('Z', '+00:00')).astimezone(tz or timezone.utc) if day is None:
except (ValueError, TypeError):
continue continue
item = {'d': WEEKDAYS[day.weekday()], 'c': short(entry.get('condition') or '', 20)} item = {'d': WEEKDAYS[day.weekday()], 'c': short(entry.get('condition') or '', 20)}
for key, name in (('h', 'temperature'), ('l', 'templow')): # h/l: high and low; p: chance of rain in %; r: rain in the entity's unit (mm).
value = entry.get(name) for key, name in (('h', 'temperature'), ('l', 'templow'), ('p', 'precipitation_probability'), ('r', 'precipitation')):
if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value): value = forecast_number(entry, name)
item[key] = round(value, 1) if value is not None:
item[key] = value
days.append(item) 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': if domain == 'sun':
rise, down = local_clock(attrs.get('next_rising'), tz), local_clock(attrs.get('next_setting'), tz) 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 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) return tile_icons.ICONS[choice][0] if choice in tile_icons.ICONS else tile_icons.ha_icon(attrs)
def screen_options(tile, attrs): def screen_options(tile, attrs):
"""Stored options on the wire; `icon` travels as the resolved codepoint (firmware 0.2.18+, ignored before).""" """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'} 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) icon = tile_icon(tile, attrs)
if icon: if icon:
options['icon'] = icon options['icon'] = icon
controls = resolve_controls(tile)
if controls:
options['controls'] = controls
return options if options or 'options' in tile else None return options if options or 'options' in tile else None
def state_message(index, tile, states, extra=None): def state_message(index, tile, states, extra=None):
@@ -215,10 +307,15 @@ def state_message(index, tile, states, extra=None):
bounded = {} bounded = {}
for key in ATTRS: for key in ATTRS:
value = attrs.get(key) 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 continue
if isinstance(value, (int, float)): 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 bounded[key] = value
elif isinstance(value, str): elif isinstance(value, str):
bounded[key] = short(value, 48) bounded[key] = short(value, 48)
+9 -6
View File
@@ -14,7 +14,7 @@ import tile_icons
from updates import Updater from updates import Updater
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web 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 from zoneinfo import ZoneInfo
LOG = logging.getLogger('screen_manager') LOG = logging.getLogger('screen_manager')
@@ -166,10 +166,10 @@ class HomeAssistant:
await self.request('call_service', domain='text', service='set_value', await self.request('call_service', domain='text', service='set_value',
service_data={'entity_id': inbox, 'value': packet}) 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. # Forecasts left the weather attributes in HA 2024.4; ask the service instead.
result = await self.request('call_service', domain='weather', service='get_forecasts', 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', []) forecast = ((result or {}).get('response') or {}).get(entity, {}).get('forecast', [])
return forecast if isinstance(forecast, list) else [] 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',[])} old_tiles = {t['entity']:t for t in self.layouts.get(inbox,{}).get('tiles',[])}
for tile in layout['tiles']: for tile in layout['tiles']:
old_options=old_tiles.get(tile['entity'],{}).get('options',{}) 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: if 'options' in tile and key not in tile['options'] and key in old_options:
tile['options'][key]=old_options[key] tile['options'][key]=old_options[key]
if 'options' not in tile and 'options' in old_tiles.get(tile['entity'],{}): if 'options' not in tile and 'options' in old_tiles.get(tile['entity'],{}):
@@ -319,10 +319,12 @@ class Manager:
if screen.get('board')=='guition': if screen.get('board')=='guition':
messages[0]['rotation'] = layout['settings'].get('rotation',0) messages[0]['rotation'] = layout['settings'].get('rotation',0)
for i,tile in enumerate(layout['tiles']): for i,tile in enumerate(layout['tiles']):
forecast=None forecast=hourly=None
if tile['entity'].startswith('weather.') and hasattr(self.ha,'forecast'): 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'])) 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'): if tile['entity'].startswith('sensor.') and hasattr(self.ha,'history'):
hours=tile.get('options',{}).get('history_hours',24) hours=tile.get('options',{}).get('history_hours',24)
key=(tile['entity'],hours) 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. # The page polls the light form; entities, backgrounds and icons (~100 KB) only on demand.
payload['entities'] = entities payload['entities'] = entities
payload['backgrounds'] = TILE_BACKGROUNDS payload['backgrounds'] = TILE_BACKGROUNDS
payload['controls'] = controls_catalogue()
payload['icons'] = tile_icons.editor() 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()] 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) return web.json_response(payload)
+50 -4
View File
@@ -369,6 +369,40 @@ const domains = {
person: ["Persoon", "☺", "#2f7d32", "#e1f2e2"], person: ["Persoon", "☺", "#2f7d32", "#e1f2e2"],
}; };
const displayNames = { standard: "standaard", watch: "grote waarde", forecast: "weersvoorspelling", graph: "grafiek", digital: "digitale klok", analog: "analoge klok", sunpath: "zonnebaan" }; 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. // New tiles start with the card that shows the entity best.
function defaultOptions(id) { function defaultOptions(id) {
const domain = id.split(".")[0]; const domain = id.split(".")[0];
@@ -474,6 +508,8 @@ function renderPreview() {
remove.onclick = (e) => { e.stopPropagation(); removeTile(index); }; remove.onclick = (e) => { e.stopPropagation(); removeTile(index); };
card.append(remove); card.append(remove);
if (tile.options?.inline === "slider") card.append(node("span", "", "preview-slider")); 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; const display = tile.options?.display;
if (display && display !== "standard") card.append(node("small", displayNames[display] || display)); if (display && display !== "standard") card.append(node("small", displayNames[display] || display));
grid.append(card); grid.append(card);
@@ -659,17 +695,27 @@ function renderTileSheet() {
const current = (key, fallback) => tile.options?.[key] ?? fallback; const current = (key, fallback) => tile.options?.[key] ?? fallback;
const set = (key, value) => { const set = (key, value) => {
tile.options = { ...tile.options, [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 === "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(); markDirty();
renderTiles(); renderTiles();
}; };
body.append(field("Weergave", segmented(displays, current("display", domain === "screen" ? "digital" : "standard"), (v) => set("display", v)))); 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)))); 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") { if (domain !== "screen") {
const taps = [["auto", "Automatisch"], ["detail", "Bediening openen"], ["none", "Alleen bekijken"]]; 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)))); 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)) if (["light", "fan", "cover", "number", "input_number", "media_player"].includes(domain))
@@ -1190,7 +1236,7 @@ async function inspect(entity) {
card.append( card.append(
node( node(
"small", "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); $("#inspection-summary").append(card);
+3 -3
View File
@@ -55,7 +55,7 @@
<li> <li>
<b>2 · Tegels kiezen</b><span <b>2 · Tegels kiezen</b><span
>Sleep entiteiten uit de lijst rechts in de schermmockup, of tik >Sleep entiteiten uit de lijst rechts in de schermmockup, of tik
op +. Klik op een tegel voor weergave, breedte en kleur.</span op +. Klik op een tegel voor weergave, breedte, bediening en kleur.</span
> >
</li> </li>
<li> <li>
@@ -126,8 +126,8 @@
<span id="count">0 / 10</span> <span id="count">0 / 10</span>
</div> </div>
<p class="hint"> <p class="hint">
Klik op een tegel voor naam, weergave, breedte en kleur; het Klik op een tegel voor naam, weergave, breedte, directe
kruisje verwijdert. Sleep om te ordenen of sleep entiteiten uit bediening en kleur; het kruisje verwijdert. Sleep om te ordenen of sleep entiteiten uit
de lijst hierheen. Zes vakjes per pagina; een dubbelbrede tegel de lijst hierheen. Zes vakjes per pagina; een dubbelbrede tegel
telt voor twee en begint altijd links. telt voor twee en begint altijd links.
</p> </p>
+10
View File
@@ -912,6 +912,16 @@ pre {
.preview-gap { min-height: 90px; border: 1px dashed #d0d4d9; border-radius: 15px; } .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-tile.vacant { background: #f4f4f4; border-style: dashed; color: #727982; justify-content: center; }
.preview-slider { height: 13px; flex-basis: 100%; border-radius: 10px; background: linear-gradient(to right, #ffbf38 60%, #fff1d3 60%); } .preview-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%; } .preview-tile small { flex-basis: 100%; }
.result:hover:not(:disabled), .preview-tile:hover { border-color: #9eb6cf; box-shadow: 0 2px 7px #253f5010; } .result:hover:not(:disabled), .preview-tile:hover { border-color: #9eb6cf; box-shadow: 0 2px 7px #253f5010; }
Binary file not shown.
+26 -2
View File
@@ -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 = ( FIXED = (
('alert-circle-outline', 'F05D6'), ('alert-circle-outline', 'F05D6'),
('check', 'F012C'), ('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-fog', 'F0591'),
('weather-hail', 'F0592'), ('weather-hail', 'F0592'),
('weather-lightning', 'F0593'), ('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', 'cover': 'window-shutter', 'scene': 'sofa', 'script': 'sofa', 'sensor': 'gauge', 'binary_sensor': 'gauge',
'timer': 'timer-outline', 'person': 'account', 'screen': 'clock-outline'} 'timer': 'timer-outline', 'person': 'account', 'screen': 'clock-outline'}
FALLBACK = 'power' 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', WEATHER = {'sunny': 'weather-sunny', 'clear-night': 'weather-night', 'cloudy': 'weather-cloudy',
'partlycloudy': 'weather-partly-cloudy', 'rainy': 'weather-rainy', 'pouring': 'weather-pouring', 'partlycloudy': 'weather-partly-cloudy', 'rainy': 'weather-rainy', 'pouring': 'weather-pouring',
'snowy': 'weather-snowy', 'snowy-rainy': 'weather-snowy-rainy', 'fog': 'weather-fog', 'hail': 'weather-hail', '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], for group, icons in GROUPS],
'defaults': {domain: GLYPHS[name] for domain, name in DEFAULTS.items()}, 'fallback': GLYPHS[FALLBACK], 'defaults': {domain: GLYPHS[name] for domain, name in DEFAULTS.items()}, 'fallback': GLYPHS[FALLBACK],
'weather': {state: GLYPHS[name] for state, name in WEATHER.items()}, '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}}
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager name: ESP Screen Manager
version: "0.2.22" version: "0.2.23"
slug: esp_screen_manager slug: esp_screen_manager
description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen. description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen.
url: https://github.com/MaxGramser/homeassistant_espscreen url: https://github.com/MaxGramser/homeassistant_espscreen
+6
View File
@@ -3,6 +3,12 @@
#include <limits> #include <limits>
int main() { int main() {
using cyd::quantize_temperature; 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(223, 160, 300, 5) == 225);
assert(quantize_temperature(221, 160, 300, 5) == 220); assert(quantize_temperature(221, 160, 300, 5) == 220);
assert(quantize_temperature(-1, 160, 300, 5) == 160); assert(quantize_temperature(-1, 160, 300, 5) == 160);
+84
View File
@@ -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')
+104
View File
@@ -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 <cassert>
#include <cmath>
#include <cstring>
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<Key, 3> 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;
}
+101
View File
@@ -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)