Put ESP Screens in English and keep layouts when entity IDs change (0.2.40)

Everything a user sees or reads is now English: both boards' labels,
status replies and logs, the ESP Screens editor and Alerts cheatsheet,
and the README and guides (docs/TILES.md, CALIBRATING.md, ACCEPTANCE.md
and TROUBLESHOOTING.md replace the Dutch file names). Labels follow
Home Assistant's own English names (Heat/Cool modes, Heating/Cooling
actions, Away, Returning to dock, weather conditions), dates use English
abbreviations and the top bar writes numbers the English way. Docs use
HA 2026.9's menu names (Settings -> Apps, App store, Settings -> Tools).
Unused legacy files (home-like.yaml, buttons.yaml, a.out) are removed.

Firmware 0.2.34 names its entities in English. Home Assistant registers
a renamed ESPHome entity under a new entity ID, so the inbox
text.<screen>_tegelinstellingen becomes text.<screen>_tile_settings and
the layout stored under the old ID would be lost. The manager now
recognises the screen by its device, during a run or after a restart,
and moves its layout and update history along; the Update button and
the nightly round follow the screen through the rename. Screens that
still run 0.2.33 or older keep working: both languages are matched.

Main is the only release branch from now on.

Tested on a Home Assistant Yellow (HA 2026.9.1) with both bench boards:
see docs/TEST_RESULTS_0240.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-15 02:08:43 +02:00
parent f60eed3ff6
commit 3e959b3df9
87 changed files with 5049 additions and 11167 deletions
+98 -98
View File
@@ -123,7 +123,7 @@ inline bool fresh() { return model.ready() && ha_connected() && feed_alive(); }
// instead of guessed: moved too far, too short, already used by this contact, or bounce.
inline bool allowed(uint32_t now, int tile, const std::string &what) {
if (cyd::touch_guard.accept(now, tile)) return true;
ESP_LOGI("touch", "tik op %s genegeerd: %s", what.c_str(), cyd::touch_guard.reason().c_str());
ESP_LOGI("touch", "tap on %s ignored: %s", what.c_str(), cyd::touch_guard.reason().c_str());
return false;
}
inline float number(JsonVariant value, float fallback = NAN) {
@@ -147,18 +147,18 @@ inline std::string list(JsonVariant value) {
return out.size() <= 512 ? out : "";
}
inline std::string receive(const std::string &payload) {
if (!enabled) return "Gebruik het Easy Setup-profiel";
if (payload.size() > 4096) return "Fout: bericht te groot";
std::string result = "Fout: ongeldig bericht";
if (!enabled) return "Use the Easy Setup profile";
if (payload.size() > 4096) return "Error: message too large";
std::string result = "Error: invalid message";
esphome::json::parse_json(payload, [&](JsonObject root) -> bool {
if (root["v"].as<int>() != 1) { result = "Fout: protocolversie"; return false; }
if (root["v"].as<int>() != 1) { result = "Error: protocol version"; return false; }
auto op = string(root["op"]);
if (op == "layout") {
if (!root["entities"].is<JsonArray>() || !root["title"].is<const char *>()) return false;
auto settings = screen_settings::current;
if (!root["settings"].isNull() && (!root["settings"].is<JsonObject>() ||
!parse_settings(root["settings"].as<JsonObject>(), settings))) {
result = "Fout: scherminstellingen"; return false;
result = "Error: screen settings"; return false;
}
std::vector<std::string> entities;
for (JsonVariant entity : root["entities"].as<JsonArray>()) {
@@ -198,14 +198,14 @@ inline std::string receive(const std::string &payload) {
}
if(rotation_changed && settings_changed)settings_changed();
if (changed) { active_index = -1; for (auto &w : widgets) w.cached_active = -1; if (dismiss) dismiss(); }
if (moved) ESP_LOGI("runtime", "tegels verplaatst: pagina's opnieuw ingedeeld");
if (moved) ESP_LOGI("runtime", "tiles moved: pages rearranged");
if (root["keepalive"].is<unsigned>()) keepalive_seconds = root["keepalive"].as<unsigned>();
layout_rev = string(root["rev"], 16);
last_received = esphome::millis();
if (layout_changed) layout_changed();
if (refresh) refresh();
// A repeat of the same layout keeps the inbox state as it is: no new recorder row.
result = changed || moved || !was_configured ? "Indeling ontvangen" : model.ready() ? "Gesynchroniseerd" : "Tegels laden";
result = changed || moved || !was_configured ? "Layout received" : model.ready() ? "Synced" : "Loading tiles";
return true;
}
if (op == "ping") {
@@ -215,8 +215,8 @@ inline std::string receive(const std::string &payload) {
root["keepalive"].as<unsigned>()<5 || root["keepalive"].as<unsigned>()>3600))return false;
if (root["keepalive"].is<unsigned>()) keepalive_seconds = root["keepalive"].as<unsigned>();
last_received = esphome::millis();
if (!model.configured || layout_rev != string(root["rev"], 16)) { result = "Indeling opnieuw nodig"; return true; }
result = model.ready() ? "Gesynchroniseerd" : "Tegels laden";
if (!model.configured || layout_rev != string(root["rev"], 16)) { result = "Resend needed"; return true; }
result = model.ready() ? "Synced" : "Loading tiles";
return true;
}
if (op == "header") {
@@ -243,13 +243,13 @@ inline std::string receive(const std::string &payload) {
last_received = esphome::millis();
if (!same && refresh) refresh();
// The same status as a tile state, so a changing value never flips the inbox entity.
result = model.ready() ? "Gesynchroniseerd" : "Tegels laden";
result = model.ready() ? "Synced" : "Loading tiles";
return true;
}
if (op != "state" || !root["i"].is<unsigned>() || !root["a"].is<JsonObject>()) return false;
unsigned index = root["i"].as<unsigned>();
std::string entity = string(root["entity"], 120);
if (!model.accepts(index, entity)) { result = "Fout: verouderde tegel"; return false; }
if (!model.accepts(index, entity)) { result = "Error: outdated tile"; return false; }
Tile &tile = model.tiles[index];
auto a = root["a"].as<JsonObject>();
// ArduinoJson clears its destination string: serialize attributes first, then add state.
@@ -354,7 +354,7 @@ inline std::string receive(const std::string &payload) {
if (refresh) refresh();
if (active_index == static_cast<int>(index) && detail_update) detail_update(tile);
refresh_detail(index);
result = model.ready() ? "Gesynchroniseerd" : "Tegels laden";
result = model.ready() ? "Synced" : "Loading tiles";
return true;
});
return result;
@@ -399,19 +399,19 @@ 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()=="person")return t.state=="home"?"Home":t.state=="not_home"?"Away":t.state;
if(t.domain()=="sun")return t.state=="above_horizon"?"Above the horizon":"Below the horizon";
if(t.domain()=="timer")return timer_text(t);
if(t.domain()=="script"||t.domain()=="scene"||t.domain()=="button"||t.domain()=="input_button")return t.state=="on"?"Bezig...":last_run_text(t.last_run);
if(t.state=="on")return "Aan";
if(t.state=="off")return "Uit";
if(t.state=="docked")return "In dock";
if(t.state=="cleaning")return "Bezig met schoonmaken";
if(t.state=="paused")return "Gepauzeerd";
if(t.state=="returning")return "Onderweg naar dock";
if(t.state=="idle")return "Klaar";
if(t.state=="error")return "Controleer de robot in HA";
if(!t.available())return "Niet beschikbaar";
if(t.domain()=="script"||t.domain()=="scene"||t.domain()=="button"||t.domain()=="input_button")return t.state=="on"?"Running...":last_run_text(t.last_run);
if(t.state=="on")return "On";
if(t.state=="off")return "Off";
if(t.state=="docked")return "Docked";
if(t.state=="cleaning")return "Cleaning";
if(t.state=="paused")return "Paused";
if(t.state=="returning")return "Returning to dock";
if(t.state=="idle")return "Idle";
if(t.state=="error")return "Check the robot in HA";
if(!t.available())return "Unavailable";
return t.state;
}
inline void hide_detail(){if(detail_root)lv_obj_add_flag(detail_root,LV_OBJ_FLAG_HIDDEN);}
@@ -457,7 +457,7 @@ inline lv_obj_t *detail_button(const char *text,int x,int y,int width,int height
auto *label=detail_label(button,text,6,0,width-12);lv_obj_center(label);lv_obj_set_style_text_align(label,LV_TEXT_ALIGN_CENTER,0);if(command==0)lv_obj_set_style_text_color(label,lv_color_hex(0xFFFFFF),0);lv_obj_remove_flag(label,LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(button,[](lv_event_t *e){
int cmd=(intptr_t)lv_event_get_user_data(e);if(cmd==-1){hide_detail();return;}
if(!fresh()||detail_index>=model.count || !allowed(esphome::millis(),300+cmd,"kaartknop "+model.tiles[detail_index].entity))return;
if(!fresh()||detail_index>=model.count || !allowed(esphome::millis(),300+cmd,"card button "+model.tiles[detail_index].entity))return;
auto &t=model.tiles[detail_index];if(!t.available()||t.loading(esphome::millis()))return;
if(cmd<4){const char *services[]={"vacuum.start","vacuum.pause","vacuum.return_to_base","vacuum.locate"};action(services[cmd],t.entity);}
if(cmd>=10 && cmd<14 && cmd-10<(int)t.fan_speed_count)action("vacuum.set_fan_speed",t.entity,"fan_speed",t.fan_speeds[cmd-10]);
@@ -533,9 +533,9 @@ inline void render_weather_detail(const Tile &t,bool large,int width,int height,
detail_text(now,degrees(t.current),temp_x,cy+(hero-big_h)/2,temp_w,big,LV_TEXT_ALIGN_LEFT,ink);
int text_x=temp_x+temp_w+(large?4:2),text_w=width-2*pad-card_pad-text_x;
int lines_h=text_h+small_h+(large?2:0);
detail_text(now,t.available()?weather_text(t.state):"Niet beschikbaar",text_x,cy+(hero-lines_h)/2,text_w,detail_font,LV_TEXT_ALIGN_LEFT,ink);
detail_text(now,t.available()?weather_text(t.state):"Unavailable",text_x,cy+(hero-lines_h)/2,text_w,detail_font,LV_TEXT_ALIGN_LEFT,ink);
std::string details;
if(std::isfinite(t.feels)){snprintf(b,sizeof(b),"Voelt als %.0f°",t.feels);details=b;}
if(std::isfinite(t.feels)){snprintf(b,sizeof(b),"Feels like %.0f°",t.feels);details=b;}
if(std::isfinite(t.humidity)){snprintf(b,sizeof(b),"%d%%",(int)std::lround(t.humidity));details+=(details.empty()?"":" · ")+std::string(b);}
if(std::isfinite(t.wind)){snprintf(b,sizeof(b),"%.0f %s",t.wind,t.wind_unit.empty()?"km/h":t.wind_unit.c_str());details+=(details.empty()?"":" · ")+std::string(b);}
detail_text(now,details,text_x,cy+(hero-lines_h)/2+text_h+(large?2:0),text_w,small,LV_TEXT_ALIGN_LEFT,muted);
@@ -552,8 +552,8 @@ inline void render_weather_detail(const Tile &t,bool large,int width,int height,
}
y+=card_a_h+(large?12:6);
// Coming days: a heading and a card with one row per day.
if(!t.forecast.size()){detail_text(detail_root,"Geen dagvoorspelling van Home Assistant",pad,y,width-2*pad,small,LV_TEXT_ALIGN_LEFT,muted);return;}
if(large){detail_text(detail_root,"Komende dagen",pad+4,y,width-2*pad,detail_font,LV_TEXT_ALIGN_LEFT,muted);y+=text_h+8;}
if(!t.forecast.size()){detail_text(detail_root,"No daily forecast from Home Assistant",pad,y,width-2*pad,small,LV_TEXT_ALIGN_LEFT,muted);return;}
if(large){detail_text(detail_root,"Coming days",pad+4,y,width-2*pad,detail_font,LV_TEXT_ALIGN_LEFT,muted);y+=text_h+8;}
int card_b_h=height-y-(large?10:4);
auto *days=detail_card(pad,y,width-2*pad,card_b_h);
int row_pad=large?8:4,row=(card_b_h-2*row_pad)/(int)t.forecast.size();
@@ -588,7 +588,7 @@ inline void show_detail(unsigned index){
detail_status=detail_label(detail_root,state+(t.unit.empty()?"":" "+t.unit),pad,large?60:35,width-2*pad);
auto d=t.domain();
if(t.is_switch()){
detail_label(detail_root,"Tik om te schakelen",pad,top,width-2*pad);
detail_label(detail_root,"Tap to toggle",pad,top,width-2*pad);
detail_switch=lv_switch_create(detail_root);
lv_obj_set_size(detail_switch,large?240:140,large?112:64);
lv_obj_set_pos(detail_switch,(width-(large?240:140))/2,top+(large?65:30));
@@ -623,31 +623,31 @@ inline void show_detail(unsigned index){
lv_obj_set_style_border_width(robot,2,0);lv_obj_set_style_border_color(robot,lv_color_hex(0xCEDDE6),0);
shape(robot,27,10,30,30,0xE3EBEF,15);shape(robot,35,18,14,14,0xA8BCC8,7);
shape(robot,29,59,26,5,0x00A6ED,3);
detail_label(hero,t.state=="cleaning"?"Aan het werk":t.state=="returning"?"Even opladen":t.state=="paused"?"Even pauze":"Klaar voor je huis",154,24,260);
detail_label(hero,t.state=="cleaning"?"Working":t.state=="returning"?"Charging":t.state=="paused"?"Paused":"Ready for your home",154,24,260);
auto *badge=shape(hero,154,59,240,32,muted,16);
auto *status=detail_label(badge,t.awaiting_action(esphome::millis())?"Opdracht verstuurd...":state,12,5,218);
auto *status=detail_label(badge,t.awaiting_action(esphome::millis())?"Command sent...":state,12,5,218);
detail_badge_status=status;
lv_obj_set_style_text_color(status,lv_color_hex(0x087BA8),0);
detail_label(hero,std::isfinite(t.battery)?"Batterij "+std::to_string((int)t.battery)+"%":"Verbonden via Home Assistant",154,107,260);
detail_button(t.state=="cleaning"?"Pauzeer schoonmaken":"Start schoonmaken",pad,260,width-2*pad,58,t.state=="cleaning"?1:0);
detail_button("Terug naar dock",pad,330,cw,48,2);
detail_button("Vind mijn robot",pad+cw+gap,330,cw,48,3);
detail_label(detail_root,"Zuigkracht",pad,394,width-2*pad);
detail_label(hero,std::isfinite(t.battery)?"Battery "+std::to_string((int)t.battery)+"%":"Connected via Home Assistant",154,107,260);
detail_button(t.state=="cleaning"?"Pause cleaning":"Start cleaning",pad,260,width-2*pad,58,t.state=="cleaning"?1:0);
detail_button("Return to dock",pad,330,cw,48,2);
detail_button("Find my robot",pad+cw+gap,330,cw,48,3);
detail_label(detail_root,"Suction power",pad,394,width-2*pad);
top=424;
}else{
auto *robot=shape(detail_root,pad,64,46,46,muted,23);
shape(robot,16,8,14,14,0xA8BCC8,7);shape(robot,15,32,16,3,0x00A6ED,2);
detail_label(detail_root,std::isfinite(t.battery)?"Batterij "+std::to_string((int)t.battery)+"%":"Robotstofzuiger",pad+58,66,width-2*pad-58);
detail_label(detail_root,"Kies een actie",pad+58,87,width-2*pad-58);
detail_button(t.state=="cleaning"?"Pauzeren":"Schoonmaken",pad,120,cw,38,t.state=="cleaning"?1:0);
detail_button("Naar dock",pad+cw+gap,120,cw,38,2);
detail_label(detail_root,"Zuigkracht",pad,168,width-2*pad);top=194;
detail_label(detail_root,std::isfinite(t.battery)?"Battery "+std::to_string((int)t.battery)+"%":"Robot vacuum",pad+58,66,width-2*pad-58);
detail_label(detail_root,"Choose an action",pad+58,87,width-2*pad-58);
detail_button(t.state=="cleaning"?"Pause":"Clean",pad,120,cw,38,t.state=="cleaning"?1:0);
detail_button("To dock",pad+cw+gap,120,cw,38,2);
detail_label(detail_root,"Suction power",pad,168,width-2*pad);top=194;
}
if(!t.fan_speed_count)detail_label(detail_root,"Automatische zuigkracht",pad,top,width-2*pad);
if(!t.fan_speed_count)detail_label(detail_root,"Automatic suction power",pad,top,width-2*pad);
int count=std::max(1,(int)t.fan_speed_count),sw=(width-2*pad-gap*(count-1))/count;
for(unsigned i=0;i<t.fan_speed_count;++i){
std::string name=t.fan_speeds[i];
if(name=="quiet")name="Stil";else if(name=="balanced")name="Normaal";else if(name=="turbo")name="Turbo";else if(name=="max")name="Max";
if(name=="quiet")name="Quiet";else if(name=="balanced")name="Normal";else if(name=="turbo")name="Turbo";else if(name=="max")name="Max";
auto *button=detail_button(name.c_str(),pad+i*(sw+gap),top,sw,large?36:30,10+i);
bool selected=t.fan_speed==t.fan_speeds[i];
lv_obj_set_style_bg_color(button,lv_color_hex(selected?0x009FE3:surface),0);
@@ -655,33 +655,33 @@ inline void show_detail(unsigned index){
}
}else if(d=="sensor"){
float minimum=INFINITY,maximum=-INFINITY;for(float value:t.history)if(t.has_history&&std::isfinite(value)){minimum=std::min(minimum,value);maximum=std::max(maximum,value);}
if(!std::isfinite(minimum)){detail_label(detail_root,"Geen numerieke HA-historie",pad,top,width-2*pad);return;}
char text[100];snprintf(text,sizeof(text),"%u uur / %.2f - %.2f %s",t.history_hours,minimum,maximum,t.unit.c_str());detail_label(detail_root,text,pad,top,width-2*pad);
if(!std::isfinite(minimum)){detail_label(detail_root,"No numeric HA history",pad,top,width-2*pad);return;}
char text[100];snprintf(text,sizeof(text),"%u hours / %.2f - %.2f %s",t.history_hours,minimum,maximum,t.unit.c_str());detail_label(detail_root,text,pad,top,width-2*pad);
int chart_y=top+(large?46:28),chart_h=height-chart_y-30,bar_w=(width-pad*2)/24;
for(unsigned i=0;i<24;++i){if(!std::isfinite(t.history[i]))continue;int h=maximum>minimum?8+(chart_h-8)*(t.history[i]-minimum)/(maximum-minimum):chart_h/2;
auto *bar=lv_obj_create(detail_root);lv_obj_remove_style_all(bar);lv_obj_set_pos(bar,pad+i*bar_w,chart_y+chart_h-h);lv_obj_set_size(bar,std::max(2,bar_w-2),h);lv_obj_set_style_bg_color(bar,lv_color_hex(0x16A5E6),0);lv_obj_set_style_bg_opa(bar,LV_OPA_COVER,0);}
detail_label(detail_root,std::to_string(t.history_hours)+" uur geleden",pad,height-24,(width-2*pad)/2);
auto *now=detail_label(detail_root,"Nu",width/2,height-24,width/2-pad);lv_obj_set_style_text_align(now,LV_TEXT_ALIGN_RIGHT,0);
detail_label(detail_root,std::to_string(t.history_hours)+" hours ago",pad,height-24,(width-2*pad)/2);
auto *now=detail_label(detail_root,"Now",width/2,height-24,width/2-pad);lv_obj_set_style_text_align(now,LV_TEXT_ALIGN_RIGHT,0);
}else if(d=="select"||d=="input_select"){
for(unsigned i=0;i<t.option_count;++i)detail_button(t.options[i].c_str(),pad+(i%2)*(cw+gap),top+(i/2)*(bh+gap),cw,bh,30+i);
}else if(d=="number"||d=="input_number"||d=="media_player"){
if(d=="media_player"){
detail_label(detail_root,t.media_title,pad,top,width-2*pad);top+=large?45:25;
int w=(width-pad*2-2*gap)/3;
detail_button("Vorige",pad,top,w,bh,21);detail_button("Play/pauze",pad+w+gap,top,w,bh,20);detail_button("Volgende",pad+2*(w+gap),top,w,bh,22);top+=bh+gap;
detail_button("Previous",pad,top,w,bh,21);detail_button("Play/pause",pad+w+gap,top,w,bh,20);detail_button("Next",pad+2*(w+gap),top,w,bh,22);top+=bh+gap;
}
detail_label(detail_root,d=="media_player"?"Volume":"Waarde",pad,top,width-2*pad);
detail_label(detail_root,d=="media_player"?"Volume":"Value",pad,top,width-2*pad);
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"){
render_weather_detail(t,large,width,height,pad);
}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);
detail_label(detail_root,t.state=="active"?"Running":t.state=="paused"?"Paused":"Stopped",pad,top,width-2*pad);
detail_button(t.state=="active"?"Pause":"Start",pad,top+(large?50:30),cw,bh,40);
detail_button("Cancel",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);
detail_label(detail_root,"Sunrise "+t.sunrise,pad,top,width-2*pad);
detail_label(detail_root,"Sunset "+t.sunset,pad,top+lv_font_get_line_height(detail_font)+(large?10:4),width-2*pad);
}
}
}
@@ -711,20 +711,20 @@ inline const char *weather_icon(const std::string &condition) {
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";
if (condition == "sunny") return "Sunny";
if (condition == "clear-night") return "Clear, night";
if (condition == "cloudy") return "Cloudy";
if (condition == "partlycloudy") return "Partly cloudy";
if (condition == "rainy") return "Rainy";
if (condition == "pouring") return "Pouring";
if (condition == "snowy") return "Snowy";
if (condition == "snowy-rainy") return "Snowy, rainy";
if (condition == "fog") return "Fog";
if (condition == "hail") return "Hail";
if (condition == "lightning") return "Lightning";
if (condition == "lightning-rainy") return "Lightning, rainy";
if (condition == "windy" || condition == "windy-variant") return "Windy";
if (condition == "exceptional") return "Exceptional";
return condition.c_str();
}
inline const char *icon_for(const Tile &tile) {
@@ -757,33 +757,33 @@ inline std::string countdown(uint32_t seconds) {
else snprintf(b, sizeof(b), "%u:%02u", seconds / 60, seconds % 60);
return b;
}
// "Laatst 14:32" today, "Gisteren 14:32", else "Laatst 13 sep"; scripts and scenes have no useful on/off.
// "Last 14:32" today, "Yesterday 14:32", else "Last 13 Sep"; scripts and scenes have no useful on/off.
inline std::string month_short(const esphome::ESPTime &now);
inline std::string last_run_text(uint32_t epoch) {
if (!epoch) return "Nog niet gestart";
if (!epoch) return "Never run";
auto when = esphome::ESPTime::from_epoch_local(epoch);
auto now = now_time ? now_time() : esphome::ESPTime{};
if (!when.is_valid()) return "Nog niet gestart";
if (!when.is_valid()) return "Never run";
char clock[8]; snprintf(clock, sizeof(clock), "%02d:%02d", when.hour, when.minute);
if (now.is_valid() && now.year == when.year && now.day_of_year == when.day_of_year) return std::string("Laatst ") + clock;
if (now.is_valid() && now.year == when.year && now.day_of_year == when.day_of_year + 1) return std::string("Gisteren ") + clock;
return "Laatst " + std::to_string(when.day_of_month) + " " + month_short(when);
if (now.is_valid() && now.year == when.year && now.day_of_year == when.day_of_year) return std::string("Last ") + clock;
if (now.is_valid() && now.year == when.year && now.day_of_year == when.day_of_year + 1) return std::string("Yesterday ") + clock;
return "Last " + std::to_string(when.day_of_month) + " " + month_short(when);
}
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));
if (t.state == "paused") return "Paused " + countdown(duration_seconds(t.remaining));
return t.duration.empty() ? "Off" : countdown(duration_seconds(t.duration));
}
inline std::string weekday_text(const esphome::ESPTime &now) {
static const char *days[] = {"zondag", "maandag", "dinsdag", "woensdag", "donderdag", "vrijdag", "zaterdag"};
static const char *days[] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"};
return now.is_valid() && now.day_of_week >= 1 && now.day_of_week <= 7 ? days[now.day_of_week - 1] : "";
}
inline std::string month_short(const esphome::ESPTime &now) {
static const char *months[] = {"jan", "feb", "mrt", "apr", "mei", "jun", "jul", "aug", "sep", "okt", "nov", "dec"};
static const char *months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
return now.is_valid() && now.month >= 1 && now.month <= 12 ? months[now.month - 1] : "";
}
inline std::string date_text(const esphome::ESPTime &now) {
static const char *months[] = {"januari", "februari", "maart", "april", "mei", "juni", "juli", "augustus", "september", "oktober", "november", "december"};
static const char *months[] = {"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"};
if (!now.is_valid() || now.day_of_week < 1 || now.day_of_week > 7 || now.month < 1 || now.month > 12) return "";
return weekday_text(now) + " " + std::to_string(now.day_of_month) + " " + months[now.month - 1];
}
@@ -803,7 +803,7 @@ inline void event(lv_event_t *event) {
if(d=="media_player" && tile.tap=="toggle" && code==LV_EVENT_SHORT_CLICKED){action("media_player.toggle",tile.entity);return;}
if(d=="climate" && tile.tap=="toggle" && code==LV_EVENT_SHORT_CLICKED){action("climate.toggle",tile.entity);return;}
if(tile.builtin())return;
// A short tap runs or pauses the kitchen timer; holding opens the card with a cancel button.
// A short tap runs or pauses the 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) {
@@ -1080,9 +1080,9 @@ inline void render_sunpath(Widgets &w,const Tile &t,bool large,int width,int hei
const lv_font_t *title_font=lv_obj_get_style_text_font(w.title,LV_PART_MAIN);
int title_h=lv_font_get_line_height(title_font),text_h=lv_font_get_line_height(w.value_font);
int horizon=height-text_h-(large?4:2),top=title_h+(large?4:2),x0=large?14:8,x1=width-x0;
part_label(w,0,title_font,0,0,width,LV_TEXT_ALIGN_LEFT,t.name.empty()?"Zon":t.name);
part_label(w,1,w.value_font,0,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_LEFT,"op "+t.sunrise);
part_label(w,2,w.value_font,width/2,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_RIGHT,"onder "+t.sunset);
part_label(w,0,title_font,0,0,width,LV_TEXT_ALIGN_LEFT,t.name.empty()?"Sun":t.name);
part_label(w,1,w.value_font,0,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_LEFT,"rise "+t.sunrise);
part_label(w,2,w.value_font,width/2,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_RIGHT,"set "+t.sunset);
auto now=now_time?now_time():esphome::ESPTime{};
int rise=minutes_of(t.sunrise),set=minutes_of(t.sunset),minute=now.is_valid()?now.hour*60+now.minute:-1;
bool day=t.state=="above_horizon";float fraction=0.5f;
@@ -1296,8 +1296,8 @@ inline void control_event(lv_event_t *e) {
bool step=command==tile_controls::STEP_DOWN || command==tile_controls::STEP_UP;
bool held=lv_event_get_code(e)==LV_EVENT_LONG_PRESSED_REPEAT;
if(held){ if(!step || now-t.edit_since<300)return; } // three steps a second while holding
else if(step){ if(!cyd::touch_guard.accept_repeat(now,400+slot*16+n)){ESP_LOGI("touch","tik op bediening %u genegeerd: %s",(unsigned)slot,cyd::touch_guard.reason().c_str());return;} }
else if(!allowed(now,400+slot*16+n,"bediening "+std::to_string(slot)))return;
else if(step){ if(!cyd::touch_guard.accept_repeat(now,400+slot*16+n)){ESP_LOGI("touch","tap on control %u ignored: %s",(unsigned)slot,cyd::touch_guard.reason().c_str());return;} }
else if(!allowed(now,400+slot*16+n,"control "+std::to_string(slot)))return;
if(!t.available())return;
if(step){
// Local at once, tap after tap; tick() sends the last value after a short pause.
@@ -1337,7 +1337,7 @@ inline void set_busy(Widgets &w,bool busy,bool large){
inline void render(lv_obj_t *room) {
if (!enabled) return;
room_label=room;
label(room, !model.configured ? "Kies tegels in HA" : !model.ready() ? "Tegels laden..." : !ha_connected() ? "HA niet verbonden" : !feed_alive() ? "ESP Screens niet actief" : model.title);
label(room, !model.configured ? "Choose tiles in HA" : !model.ready() ? "Loading tiles..." : !ha_connected() ? "HA not connected" : !feed_alive() ? "ESP Screens not active" : model.title);
render_header();
for (size_t slot = 0; slot < 6; ++slot) {
auto &w=widgets[slot];
@@ -1349,17 +1349,17 @@ inline void render(lv_obj_t *room) {
bool watch=t.display=="watch";
std::string unit=watch?t.unit:"";
std::string value = t.state;
if (!fresh() || !t.available()) value = "Niet beschikbaar";
if (!fresh() || !t.available()) value = "Unavailable";
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 == "person") value = t.state=="home"?"Home":t.state=="not_home"?"Away":t.state;
else if (d == "sun") value = !t.sunrise.empty() && !t.sunset.empty() ? t.sunrise+" - "+t.sunset : t.state=="above_horizon"?"Above the horizon":"Below the horizon";
else if (d == "timer") value = timer_text(t);
else if (d == "script" || d == "scene" || d == "button" || d == "input_button") value = t.state == "on" ? "Bezig..." : last_run_text(t.last_run);
else if (value == "on") value = "Aan";
else if (value == "off") value = "Uit";
else if (value == "cleaning") value = "Bezig";
else if (value == "docked") value = "In dock";
else if (d == "script" || d == "scene" || d == "button" || d == "input_button") value = t.state == "on" ? "Running..." : last_run_text(t.last_run);
else if (value == "on") value = "On";
else if (value == "off") value = "Off";
else if (value == "cleaning") value = "Cleaning";
else if (value == "docked") value = "Docked";
else if (!t.unit.empty() && !watch) value += " " + t.unit;
bool pending=t.loading(esphome::millis());
if(d=="weather" && std::isfinite(t.current)) {char b[32];snprintf(b,sizeof(b),"%.1f %s",t.current,t.unit.c_str());value=b;if(watch){snprintf(b,sizeof(b),"%.1f",t.current);value=b;}}
@@ -1451,7 +1451,7 @@ inline void render(lv_obj_t *room) {
lv_obj_set_style_bg_color(w.slider,lv_color_mix(color,lv_color_hex(0xFFFFFF),30),LV_PART_MAIN);
lv_obj_set_style_bg_color(w.tile, lv_color_hex(t.background ? t.background : 0xFFFFFF), 0);
lv_obj_set_style_border_width(w.tile, 1, 0);
// "Achtergrond: geen" hides only the card; geometry and padding stay identical,
// "Background: none" hides only the card; geometry and padding stay identical,
// and the pressed flash still shows because it lives on the PRESSED state.
lv_obj_set_style_bg_opa(w.tile, t.transparent ? LV_OPA_TRANSP : LV_OPA_COVER, 0);
lv_obj_set_style_border_opa(w.tile, t.transparent ? LV_OPA_TRANSP : LV_OPA_COVER, 0);
@@ -1821,7 +1821,7 @@ 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());
for(unsigned i=0;i<detail_action_count;++i){if(waiting||!fresh()||!t.available())lv_obj_add_state(detail_actions[i],LV_STATE_DISABLED);else lv_obj_remove_state(detail_actions[i],LV_STATE_DISABLED);}
std::string status=waiting?(t.confirmed?"Bevestigd door Home Assistant":"Opdracht verstuurd..."):detail_state(t);
std::string status=waiting?(t.confirmed?"Confirmed by Home Assistant":"Command sent..."):detail_state(t);
if(detail_status)label(detail_status,status+(!waiting && !t.unit.empty()?" "+t.unit:""));
if(detail_badge_status)label(detail_badge_status,status);
if(detail_switch && !lv_obj_has_state(detail_switch,LV_STATE_PRESSED)){