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