Add rich runtime tiles and integrated ESPHome firmware management

This commit is contained in:
Max Gramser
2026-09-12 15:41:54 +02:00
parent 535838989c
commit f898863b1f
23 changed files with 1346 additions and 89 deletions
+3 -2
View File
@@ -2,12 +2,13 @@
**Nieuw scherm installeren? Begin met [Easy Setup](docs/EASY_SETUP.md).** **Nieuw scherm installeren? Begin met [Easy Setup](docs/EASY_SETUP.md).**
Installeer ESP Screen Manager in Home Assistant, download de YAML voor CYD of Installeer ESP Screen Manager in Home Assistant, download de YAML voor CYD of
Guition, flash eenmaal via USB op je Raspberry en kies je tegels in de beheerpagina. Guition, bouw en flash vanuit dezelfde app via USB op je Raspberry en kies je tegels in de beheerpagina.
Zoeken op entiteit/apparaat/ruimte, slepen, opslaan — zonder opnieuw flashen. Zoeken op entiteit/apparaat/ruimte, slepen, opslaan — zonder opnieuw flashen.
Updates van de app behouden je indelingen. Nieuwe firmware installeer je via Updates van de app behouden je indelingen. Nieuwe firmware installeer je via
ESPHome OTA met behoud van je eigen naam, wifi en sleutels. ESPHome OTA met behoud van je eigen naam, wifi en sleutels.
Zie [updates publiceren](docs/RELEASING.md). Zie [de nieuwe kaarten, inspector en firmwarepagina](docs/WHATS_NEW_020.md) en
[updates publiceren](docs/RELEASING.md).
De handleiding hieronder blijft beschikbaar voor handmatige installatie via een De handleiding hieronder blijft beschikbaar voor handmatige installatie via een
computer. Voor de handmatige Guition-route: [Guition](docs/GUITION.md). computer. Voor de handmatige Guition-route: [Guition](docs/GUITION.md).
+2 -2
View File
@@ -135,11 +135,11 @@ inline void setup(lv_obj_t *parent, const lv_font_t *font, int width, int height
lv_obj_set_style_bg_opa(row.slider, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_bg_opa(row.slider, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_TRANSP, LV_PART_INDICATOR); lv_obj_set_style_bg_opa(row.slider, LV_OPA_TRANSP, LV_PART_INDICATOR);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_COVER, LV_PART_KNOB); lv_obj_set_style_bg_opa(row.slider, LV_OPA_COVER, LV_PART_KNOB);
lv_obj_set_style_bg_color(row.slider, lv_color_hex(0xFFFFFF), LV_PART_KNOB); lv_obj_set_style_bg_color(row.slider, lv_color_hex(0x111111), LV_PART_KNOB);
lv_obj_set_style_radius(row.slider, LV_RADIUS_CIRCLE, LV_PART_KNOB); lv_obj_set_style_radius(row.slider, LV_RADIUS_CIRCLE, LV_PART_KNOB);
lv_obj_set_style_pad_all(row.slider, large ? 5 : 6, LV_PART_KNOB); lv_obj_set_style_pad_all(row.slider, large ? 5 : 6, LV_PART_KNOB);
lv_obj_set_style_border_width(row.slider, 2, LV_PART_KNOB); lv_obj_set_style_border_width(row.slider, 2, LV_PART_KNOB);
lv_obj_set_style_border_color(row.slider, lv_color_hex(0xFFFFFF), LV_PART_KNOB); lv_obj_set_style_border_color(row.slider, lv_color_hex(0x111111), LV_PART_KNOB);
lv_slider_set_range(row.slider, i == 0 ? 0 : i == 1 ? 2000 : 1, i == 0 ? 360 : i == 1 ? 6500 : 100); lv_slider_set_range(row.slider, i == 0 ? 0 : i == 1 ? 2000 : 1, i == 0 ? 360 : i == 1 ? 6500 : 100);
lv_obj_add_event_cb(row.slider, event, LV_EVENT_ALL, &row); lv_obj_add_event_cb(row.slider, event, LV_EVENT_ALL, &row);
preview(row); preview(row);
+14 -1
View File
@@ -15,7 +15,7 @@ inline bool valid_entity(const std::string &entity) {
if (i != dot && !(entity[i] >= 'a' && entity[i] <= 'z') && if (i != dot && !(entity[i] >= 'a' && entity[i] <= 'z') &&
!(entity[i] >= '0' && entity[i] <= '9') && entity[i] != '_') return false; !(entity[i] >= '0' && entity[i] <= '9') && entity[i] != '_') return false;
std::string domain = entity.substr(0, dot); 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", "button", "input_button"}) 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"})
if (domain == allowed) return true; if (domain == allowed) return true;
return false; return false;
} }
@@ -27,6 +27,19 @@ struct Tile {
float current = NAN, target = NAN, humidity = NAN, minimum = 7, maximum = 35, step = 0.5f; float current = NAN, target = NAN, humidity = NAN, minimum = 7, maximum = 35, step = 0.5f;
int hue = 0, kelvin = 3000, min_kelvin = 0, max_kelvin = 0; int hue = 0, kelvin = 3000, min_kelvin = 0, max_kelvin = 0;
bool received = false; bool received = false;
std::string tap = "auto", display = "standard", inline_control = "none", media_title;
std::array<std::string, 8> options;
unsigned option_count = 0, history_hours = 24;
std::array<float,24> history{};
bool has_history = false;
float battery = NAN, volume = NAN;
uint32_t supported = 0;
std::string revision, pending_revision;
uint32_t pending_since = 0;
bool pending = false, confirmed = false, local_feedback = false;
bool loading(uint32_t now) const { return pending && (now-pending_since < 1000 || (!confirmed && !local_feedback && now-pending_since < 6000)); }
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('.')); } std::string domain() const { return entity.substr(0, entity.find('.')); }
bool available() const { return received && state != "unknown" && state != "unavailable" && !state.empty(); } 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 active() const { return state == "on" || state == "cleaning" || (domain() == "climate" && available() && state != "off"); }
+183 -8
View File
@@ -15,6 +15,10 @@ namespace runtime_tiles {
inline bool enabled = false; inline bool enabled = false;
inline bool light_theme = false; inline bool light_theme = false;
inline Model model; inline Model model;
inline std::string inbox;
inline const lv_font_t *watch_font = nullptr;
inline void tick();
inline void refresh_detail(unsigned index);
inline int active_index = -1; inline int active_index = -1;
inline uint32_t last_received = 0; inline uint32_t last_received = 0;
inline std::function<void()> layout_changed, refresh, dismiss, settings_changed; inline std::function<void()> layout_changed, refresh, dismiss, settings_changed;
@@ -42,7 +46,7 @@ inline bool parse_settings(JsonObject obj, screen_settings::Settings &s) {
return s.valid(); return s.valid();
} }
inline std::function<void(Tile &)> detail, detail_update; 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; }; struct Widgets { lv_obj_t *tile{}, *title{}, *value{}, *circle{}, *icon{}; size_t index{}; int cached_active = -1; lv_obj_t *slider{}, *progress{}; int title_x=0,title_y=0,value_x=0,value_y=0; const lv_font_t *value_font{}; };
inline std::array<Widgets, MAX_TILES> widgets; inline std::array<Widgets, MAX_TILES> widgets;
inline bool fresh() { return model.ready() && esphome::millis() - last_received < 95000; } inline bool fresh() { return model.ready() && esphome::millis() - last_received < 95000; }
inline float number(JsonVariant value, float fallback = NAN) { inline float number(JsonVariant value, float fallback = NAN) {
@@ -84,6 +88,7 @@ inline std::string receive(const std::string &payload) {
if (!entity.is<const char *>() || entities.size() == MAX_TILES) return false; if (!entity.is<const char *>() || entities.size() == MAX_TILES) return false;
entities.push_back(entity.as<std::string>()); entities.push_back(entity.as<std::string>());
} }
inbox = string(root["inbox"], 160);
bool changed = false; bool changed = false;
if (!model.set_layout(entities, string(root["title"], 96), changed)) return false; if (!model.set_layout(entities, string(root["title"], 96), changed)) return false;
if (!(settings == screen_settings::current)) { if (!(settings == screen_settings::current)) {
@@ -104,6 +109,24 @@ inline std::string receive(const std::string &payload) {
if (!model.accepts(index, entity)) { result = "Fout: verouderde tegel"; return false; } if (!model.accepts(index, entity)) { result = "Fout: verouderde tegel"; return false; }
Tile &tile = model.tiles[index]; Tile &tile = model.tiles[index];
auto a = root["a"].as<JsonObject>(); auto a = root["a"].as<JsonObject>();
std::string revision = string(root["state"]); serializeJson(a, revision);
tile.observe(revision);
auto options = root["o"];
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";
tile.has_history=false;
if (root["history"]["values"].is<JsonArray>()) {
tile.history.fill(NAN);unsigned j=0;
for (JsonVariant value:root["history"]["values"].as<JsonArray>()) {
if(j==24) break; tile.history[j++]=number(value); }
tile.has_history=j>0;tile.history_hours=std::clamp(root["history"]["hours"].as<unsigned>(),1u,24u);
}
tile.option_count=0;
if (a["options"].is<JsonArray>()) for(JsonVariant option:a["options"].as<JsonArray>()) {
if(tile.option_count==8)break;tile.options[tile.option_count++]=string(option,48); }
tile.battery=number(a["battery_level"]);tile.volume=number(a["volume_level"]);
tile.media_title=string(a["media_title"],80);tile.supported=a["supported_features"].as<uint32_t>();
tile.name = string(root["name"], 80); tile.name = string(root["name"], 80);
tile.state = string(root["state"], 160); tile.state = string(root["state"], 160);
tile.unit = string(a["unit_of_measurement"], 20); tile.unit = string(a["unit_of_measurement"], 20);
@@ -116,6 +139,9 @@ inline std::string receive(const std::string &payload) {
tile.minimum = number(a["min_temp"], 7); tile.minimum = number(a["min_temp"], 7);
tile.maximum = number(a["max_temp"], 35); tile.maximum = number(a["max_temp"], 35);
tile.step = number(a["target_temp_step"], 0.5f); tile.step = number(a["target_temp_step"], 0.5f);
if(tile.domain()=="number" || tile.domain()=="input_number") {
tile.minimum=number(a["min"],0);tile.maximum=number(a["max"],100);tile.step=number(a["step"],1); }
if(tile.domain()=="weather") {tile.current=number(a["temperature"]);tile.unit=string(a["temperature_unit"],12);}
tile.modes = list(a["supported_color_modes"]); tile.modes = list(a["supported_color_modes"]);
tile.hvac_modes = list(a["hvac_modes"]); tile.hvac_modes = list(a["hvac_modes"]);
float hue = number(a["hs_color"][0]); float hue = number(a["hs_color"][0]);
@@ -130,25 +156,136 @@ inline std::string receive(const std::string &payload) {
tile.fan_speeds[tile.fan_speed_count++] = string(speed, 48); tile.fan_speeds[tile.fan_speed_count++] = string(speed, 48);
} }
tile.received = true; tile.received = true;
widgets[index].cached_active=-1;
last_received = esphome::millis(); last_received = esphome::millis();
if (refresh) refresh(); if (refresh) refresh();
if (active_index == static_cast<int>(index) && detail_update) detail_update(tile); if (active_index == static_cast<int>(index) && detail_update) detail_update(tile);
refresh_detail(index);
result = model.ready() ? "Gesynchroniseerd" : "Tegels laden"; result = model.ready() ? "Gesynchroniseerd" : "Tegels laden";
return true; return true;
}); });
return result; return result;
} }
inline void action(const std::string &service, const std::string &entity) { inline void action(const std::string &service, const std::string &entity, const std::string &key="", const std::string &value="") {
if (!fresh() || !valid_entity(entity)) return; if (!fresh() || !valid_entity(entity)) return;
esphome::api::HomeassistantActionRequest request; esphome::api::HomeassistantActionRequest request;
request.service = esphome::StringRef(service); request.service = esphome::StringRef(service);
request.data.init(1); request.data.init(key.empty() ? 1 : 2);
esphome::api::HomeassistantServiceMap entry; esphome::api::HomeassistantServiceMap entry;
entry.key = esphome::StringRef("entity_id"); entry.key = esphome::StringRef("entity_id");
entry.value = esphome::StringRef(entity); entry.value = esphome::StringRef(entity);
request.data.push_back(entry); request.data.push_back(entry);
if(!key.empty()) {esphome::api::HomeassistantServiceMap param;param.key=esphome::StringRef(key);param.value=esphome::StringRef(value);request.data.push_back(param);}
for(auto &tile:model.tiles) if(tile.entity==entity)tile.begin(esphome::millis());
esphome::api::global_api_server->send_homeassistant_action(request); esphome::api::global_api_server->send_homeassistant_action(request);
} }
inline void setting_event(const std::string &key, int value) {
if(inbox.empty())return;
esphome::api::HomeassistantActionRequest request;request.service=esphome::StringRef("esphome.screen_setting");request.is_event=true;
std::string number=std::to_string(value);request.data.init(3);
const std::string keys[]={"inbox","key","value"},values[]={inbox,key,number};
for(int i=0;i<3;++i){esphome::api::HomeassistantServiceMap entry;entry.key=esphome::StringRef(keys[i]);entry.value=esphome::StringRef(values[i]);request.data.push_back(entry);}
esphome::api::global_api_server->send_homeassistant_action(request);
}
} // namespace runtime_tiles
// Small shared native-LVGL detail cards. No images, canvas buffers or free scrolling.
namespace runtime_tiles {
inline lv_obj_t *detail_root=nullptr;
inline unsigned detail_index=0;
inline const lv_font_t *detail_font=nullptr;
inline lv_obj_t *detail_label(lv_obj_t *parent,const std::string &text,int x,int y,int width) {
auto *label=lv_label_create(parent);lv_label_set_text(label,text.c_str());lv_obj_set_pos(label,x,y);lv_obj_set_width(label,width);
lv_obj_set_style_text_font(label,detail_font,0);lv_obj_set_style_text_color(label,lv_color_hex(light_theme?0x202020:0xFFFFFF),0);
lv_label_set_long_mode(label,LV_LABEL_LONG_DOT);lv_obj_set_height(label,lv_font_get_line_height(detail_font));return label;
}
inline void hide_detail(){if(detail_root)lv_obj_add_flag(detail_root,LV_OBJ_FLAG_HIDDEN);}
inline int slider_value(const Tile &t){
auto d=t.domain();float value=0;
if(d=="light")value=std::isfinite(t.brightness)?t.brightness/255:0;
if(d=="fan")value=std::isfinite(t.percentage)?t.percentage/100:0;
if(d=="cover")value=std::isfinite(t.position)?t.position/100:0;
if(d=="media_player")value=std::isfinite(t.volume)?t.volume:0;
if(d=="number"||d=="input_number") {char *end;float state=strtof(t.state.c_str(),&end);if(end!=t.state.c_str() && t.maximum>t.minimum)value=(state-t.minimum)/(t.maximum-t.minimum);}
return std::clamp((int)std::lround(value*1000),0,1000);
}
inline void commit_slider(unsigned i,int raw){
if(i>=model.count || !fresh())return;auto &t=model.tiles[i];if(!t.available() || t.loading(esphome::millis()))return;
float value=std::clamp(raw,0,1000)/1000.0f;auto d=t.domain();
if(d=="light")action("light.turn_on",t.entity,"brightness",std::to_string((int)std::lround(value*255)));
if(d=="fan")action("fan.set_percentage",t.entity,"percentage",std::to_string((int)std::lround(value*100)));
if(d=="cover")action("cover.set_cover_position",t.entity,"position",std::to_string((int)std::lround(value*100)));
if(d=="media_player")action("media_player.volume_set",t.entity,"volume_level",std::to_string(value));
if(d=="number"||d=="input_number") {
if(!std::isfinite(t.minimum)||!std::isfinite(t.maximum)||t.maximum<=t.minimum||t.step<=0)return;
value=std::clamp(t.minimum+std::round(value*(t.maximum-t.minimum)/t.step)*t.step,t.minimum,t.maximum);
action(d+".set_value",t.entity,"value",std::to_string(value)); }
}
inline void detail_button(const char *text,int x,int y,int width,int height,int command){
auto *button=lv_obj_create(detail_root);lv_obj_remove_style_all(button);lv_obj_set_pos(button,x,y);lv_obj_set_size(button,width,height);
lv_obj_set_style_bg_color(button,lv_color_hex(light_theme?0xE7EFF5:0x34495E),0);lv_obj_set_style_bg_opa(button,LV_OPA_COVER,0);lv_obj_set_style_radius(button,12,0);lv_obj_add_flag(button,LV_OBJ_FLAG_CLICKABLE);
auto *label=detail_label(button,text,6,0,width-12);lv_obj_center(label);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 || !cyd::touch_guard.accept(esphome::millis(),300+cmd))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]);
if(cmd==20)action("media_player.media_play_pause",t.entity);
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]);
},LV_EVENT_SHORT_CLICKED,(void*)(intptr_t)command);
}
inline void show_detail(unsigned index){
if(index>=model.count)return;detail_index=index;auto &t=model.tiles[index];
if(!detail_font)detail_font=lv_obj_get_style_text_font(widgets[index].title,LV_PART_MAIN);
if(!detail_root){detail_root=lv_obj_create(lv_screen_active());lv_obj_remove_style_all(detail_root);lv_obj_set_size(detail_root,lv_pct(100),lv_pct(100));lv_obj_remove_flag(detail_root,LV_OBJ_FLAG_SCROLLABLE);}
lv_obj_clean(detail_root);lv_obj_remove_flag(detail_root,LV_OBJ_FLAG_HIDDEN);lv_obj_move_foreground(detail_root);
lv_obj_set_style_bg_color(detail_root,lv_color_hex(light_theme?0xEFEFEF:0x202A38),0);lv_obj_set_style_bg_opa(detail_root,LV_OPA_COVER,0);
int width=lv_display_get_horizontal_resolution(lv_display_get_default()), height=lv_display_get_vertical_resolution(lv_display_get_default());
bool large=width>=480;int pad=large?20:10, top=large?100:62, gap=large?12:6,bh=large?58:34,cw=(width-pad*2-gap)/2;
detail_label(detail_root,t.name,pad,large?24:12,width-80);
detail_button("X",width-58,8,48,40,-1);
detail_label(detail_root,t.state+(t.unit.empty()?"":" "+t.unit),pad,large?60:35,width-2*pad);
auto d=t.domain();
if(d=="vacuum"){
std::string summary=std::isfinite(t.battery)?"Accu "+std::to_string((int)t.battery)+"%":"Robotstofzuiger";
detail_label(detail_root,summary,pad,top,width-2*pad);top+=large?42:25;
const char *names[]={"Start","Pauze","Naar dock","Zoeken"};
for(int i=0;i<4;++i)detail_button(names[i],pad+(i%2)*(cw+gap),top+(i/2)*(bh+gap),cw,bh,i);
top+=2*(bh+gap);
for(unsigned i=0;i<t.fan_speed_count;++i)detail_button(t.fan_speeds[i].c_str(),pad+(i%2)*(cw+gap),top+(i/2)*(large?42:24),cw,large?36:22,10+i);
}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);
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,"Eerder Nu",pad,height-24,width-2*pad);
}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_label(detail_root,d=="media_player"?"Volume":"Waarde",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,[](lv_event_t *e){if(cyd::touch_guard.accept(esphome::millis(),399))commit_slider(detail_index,lv_slider_get_value(lv_event_get_target_obj(e)));},LV_EVENT_RELEASED,nullptr);
}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);
}
}
}
namespace runtime_tiles {
inline void refresh_detail(unsigned index){
if(!detail_root || lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN) || detail_index!=index)return;
auto *input=lv_indev_get_next(nullptr);if(input && lv_indev_get_state(input)==LV_INDEV_STATE_PRESSED)return;
show_detail(index);
}
inline const char *icon_for(const Tile &tile) { inline const char *icon_for(const Tile &tile) {
auto d = tile.domain(); auto d = tile.domain();
if (d == "light") return "\U000F0335"; if (d == "light") return "\U000F0335";
@@ -157,6 +294,7 @@ inline const char *icon_for(const Tile &tile) {
if (d == "fan") return "\U000F0210"; if (d == "fan") return "\U000F0210";
if (d == "cover") return "\U000F111C"; if (d == "cover") return "\U000F111C";
if (d == "scene" || d == "script") return "\U000F04B9"; if (d == "scene" || d == "script") return "\U000F04B9";
if (d == "weather") return "\U000F029A";
if (d == "sensor" || d == "binary_sensor") return "\U000F029A"; if (d == "sensor" || d == "binary_sensor") return "\U000F029A";
return "\U000F0425"; return "\U000F0425";
} }
@@ -169,13 +307,18 @@ inline void event(lv_event_t *event) {
auto &tile = model.tiles[w.index]; auto &tile = model.tiles[w.index];
auto d = tile.domain(); auto d = tile.domain();
// Scenes/scripts often have timestamps or 'off'; unavailable devices never act. // Scenes/scripts often have timestamps or 'off'; unavailable devices never act.
if (!tile.available()) return; if (!tile.available() || tile.loading(esphome::millis()) || tile.tap=="none") return;
bool open = code == LV_EVENT_LONG_PRESSED || d == "climate" || d == "vacuum" || d == "cover"; bool open = code == LV_EVENT_LONG_PRESSED || d == "climate" || d == "vacuum" || d == "cover";
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 (open) { if (open) {
if (d == "light" || d == "climate" || d == "vacuum" || d == "fan" || d == "cover") { if (d == "light" || d == "climate" || d == "vacuum" || d == "fan" || d == "cover") {
active_index = w.index; active_index = w.index;
tile.begin(esphome::millis(),true);
if (detail) detail(tile); if (detail) detail(tile);
} } else { active_index=w.index;tile.begin(esphome::millis(),true);show_detail(w.index); }
return; return;
} }
if (d == "light" || d == "switch" || d == "input_boolean" || d == "fan") action(d + ".toggle", tile.entity); if (d == "light" || d == "switch" || d == "input_boolean" || d == "fan") action(d + ".toggle", tile.entity);
@@ -183,7 +326,18 @@ inline void event(lv_event_t *event) {
if (d == "button" || d == "input_button") action(d + ".press", tile.entity); if (d == "button" || d == "input_button") action(d + ".press", tile.entity);
} }
inline void bind(size_t index, lv_obj_t *tile, lv_obj_t *title, lv_obj_t *value, lv_obj_t *circle, lv_obj_t *icon) { inline void bind(size_t index, lv_obj_t *tile, lv_obj_t *title, lv_obj_t *value, lv_obj_t *circle, lv_obj_t *icon) {
lv_obj_update_layout(tile);
widgets[index] = {tile, title, value, circle, icon, index}; 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);
// Fixed one-line boxes prevent wrapped names from overlapping the state on both boards.
lv_obj_set_height(title,lv_font_get_line_height(lv_obj_get_style_text_font(title,LV_PART_MAIN)));
lv_obj_set_height(value,lv_font_get_line_height(w.value_font));
lv_label_set_long_mode(title,LV_LABEL_LONG_DOT);lv_label_set_long_mode(value,LV_LABEL_LONG_DOT);
w.progress=lv_obj_create(tile);lv_obj_remove_style_all(w.progress);lv_obj_set_size(w.progress,0,3);lv_obj_align(w.progress,LV_ALIGN_BOTTOM_LEFT,0,0);lv_obj_set_style_bg_color(w.progress,lv_color_hex(0x00A6ED),0);lv_obj_set_style_bg_opa(w.progress,LV_OPA_COVER,0);
w.slider=lv_slider_create(tile);lv_obj_set_size(w.slider,lv_obj_get_width(tile)-24,lv_obj_get_height(tile)>80?10:4);lv_obj_align(w.slider,LV_ALIGN_BOTTOM_MID,0,-1);lv_slider_set_range(w.slider,0,1000);
lv_obj_set_style_bg_color(w.slider,lv_color_hex(0x111111),LV_PART_KNOB);lv_obj_set_style_pad_all(w.slider,3,LV_PART_KNOB);lv_obj_set_style_bg_color(w.slider,lv_color_hex(0xFCE5B4),LV_PART_MAIN);lv_obj_set_style_bg_color(w.slider,lv_color_hex(0xFFB900),LV_PART_INDICATOR);
lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN);
lv_obj_add_event_cb(w.slider,[](lv_event_t *e){auto &w=*static_cast<Widgets*>(lv_event_get_user_data(e));if(!fresh() || !cyd::touch_guard.accept(esphome::millis(),200+w.index))return;commit_slider(w.index,lv_slider_get_value(w.slider));},LV_EVENT_RELEASED,&w);
lv_obj_add_event_cb(tile, event, LV_EVENT_SHORT_CLICKED, &widgets[index]); lv_obj_add_event_cb(tile, event, LV_EVENT_SHORT_CLICKED, &widgets[index]);
lv_obj_add_event_cb(tile, event, LV_EVENT_LONG_PRESSED, &widgets[index]); lv_obj_add_event_cb(tile, event, LV_EVENT_LONG_PRESSED, &widgets[index]);
} }
@@ -206,7 +360,22 @@ inline void render(lv_obj_t *room) {
else if (value == "cleaning") value = "Bezig"; else if (value == "cleaning") value = "Bezig";
else if (value == "docked") value = "In dock"; else if (value == "docked") value = "In dock";
else if (!t.unit.empty()) value += " " + t.unit; else if (!t.unit.empty()) value += " " + t.unit;
label(w.value, value); 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(t.domain()=="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 watch=t.display=="watch";
lv_obj_set_style_text_font(w.value,watch && watch_font ? watch_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_set_pos(w.value,watch?0:w.value_x,watch?(lv_obj_get_height(w.tile)>80?42:19):w.value_y);
lv_obj_set_width(w.value,watch?lv_obj_get_width(w.tile)-24:lv_obj_get_width(w.title));
if(watch)lv_obj_add_flag(w.circle,LV_OBJ_FLAG_HIDDEN);else lv_obj_remove_flag(w.circle,LV_OBJ_FLAG_HIDDEN);
lv_obj_set_x(w.title,watch?0:w.title_x);
bool mini=t.inline_control=="slider" && !watch && t.available();
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);
bool on = fresh() && t.active(); bool on = fresh() && t.active();
if (w.cached_active == static_cast<int>(on)) continue; if (w.cached_active == static_cast<int>(on)) continue;
w.cached_active = on; w.cached_active = on;
@@ -215,12 +384,18 @@ inline void render(lv_obj_t *room) {
lv_obj_set_style_border_width(w.tile, 1, 0); lv_obj_set_style_border_width(w.tile, 1, 0);
lv_obj_set_style_border_opa(w.tile, LV_OPA_COVER, 0); lv_obj_set_style_border_opa(w.tile, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(w.tile, lv_color_hex(light_theme ? 0xDDDDDD : (on ? 0xF5F1E8 : 0x9CB3C8)), 0); lv_obj_set_style_border_color(w.tile, lv_color_hex(light_theme ? 0xDDDDDD : (on ? 0xF5F1E8 : 0x9CB3C8)), 0);
lv_obj_set_style_bg_color(w.circle, lv_color_hex(light_theme ? (on ? 0xD5EEFC : 0xF0F0F0) : (on ? accent : 0x263B50)), 0); lv_obj_set_style_bg_color(w.circle, lv_color_hex(light_theme ? (on ? (t.domain()=="light" ? 0xFFF1D2 : t.domain()=="climate" ? 0xFFE8D5 : 0xD5EEFC) : 0xF0F0F0) : (on ? accent : 0x263B50)), 0);
lv_obj_set_style_text_color(w.icon, lv_color_hex(light_theme ? (on ? 0x009FE3 : 0x9E9E9E) : (on ? 0x172232 : 0xF3F5F7)), 0); lv_obj_set_style_text_color(w.icon, lv_color_hex(light_theme ? (on ? (t.domain()=="light" ? 0xFFB900 : t.domain()=="climate" ? 0xED8A3B : 0x009FE3) : 0x9E9E9E) : (on ? 0x172232 : 0xF3F5F7)), 0);
lv_obj_set_style_text_color(w.title, lv_color_hex(light_theme ? 0x1B1B1B : (on ? 0x172232 : 0xF3F5F7)), 0); lv_obj_set_style_text_color(w.title, lv_color_hex(light_theme ? 0x1B1B1B : (on ? 0x172232 : 0xF3F5F7)), 0);
lv_obj_set_style_text_color(w.value, lv_color_hex(light_theme ? 0x616161 : (on ? 0x46525E : 0xF0F4F8)), 0); lv_obj_set_style_text_color(w.value, lv_color_hex(light_theme ? 0x616161 : (on ? 0x46525E : 0xF0F4F8)), 0);
} }
} }
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;}
if(redraw && refresh)refresh();
}
inline std::string vacuum_option(unsigned index) { inline std::string vacuum_option(unsigned index) {
if (active_index < 0 || static_cast<size_t>(active_index) >= model.count) return {}; if (active_index < 0 || static_cast<size_t>(active_index) >= model.count) return {};
auto &tile = model.tiles[active_index]; auto &tile = model.tiles[active_index];
+8
View File
@@ -1,3 +1,11 @@
# Installeren vanuit ESP Screens (vanaf 0.2.0)
De makkelijkste route is nu één app: **ESP Screen Manager**. Deze kan zelf
ESPHome-profielen bewaren, bouwen en via USB of OTA installeren. Zie de
[complete stappen voor deze route](WHATS_NEW_020.md#firmware-zonder-een-tweede-beheerpagina).
De instructies hieronder blijven bruikbaar als je ESPHome Device Builder wilt
blijven gebruiken. Beide routes behouden dezelfde eigen YAML en secrets.
# Een nieuw scherm, van USB naar dagelijks gebruik # Een nieuw scherm, van USB naar dagelijks gebruik
Met **ESP Screen Manager** kies je je tegels in Home Assistant. Je zoekt op Met **ESP Screen Manager** kies je je tegels in Home Assistant. Je zoekt op
+16
View File
@@ -70,3 +70,19 @@ Firmware gebruikt een afzonderlijke preferences-key `0x53435231` met een vaste
versie-1 structuur (elf int32-velden en een uint32-versie). Wijzig key, structuur versie-1 structuur (elf int32-velden en een uint32-versie). Wijzig key, structuur
of versie niet zonder migratie. Alleen gewijzigde instellingen worden opgeslagen; of versie niet zonder migratie. Alleen gewijzigde instellingen worden opgeslagen;
periodieke herhaling veroorzaakt geen flashwrites en geen nieuwe idle-timer. periodieke herhaling veroorzaakt geen flashwrites en geen nieuwe idle-timer.
### Compatibiliteit 0.2.0
Opslagversie en tegelprotocol blijven 1. Optionele `tiles[].options`, `o` in
statusberichten, `history` en `inbox` in het layoutbericht zijn additief. Een
oudere browser die opties niet meestuurt behoudt de bestaande opties voor dezelfde
entiteit. Oudere firmware negeert extra velden. Nieuwe entiteitsdomeinen vereisen
wel firmware 0.2.0; bij terugrollen naar een oudere app eerst de gegevensback-up
herstellen, omdat die app nieuwe domeinen nog niet kan laden. Geen oude opslag
stilzwijgend overschrijven. Bestaande preference-keys blijven ongewijzigd.
De app gebruikt nu de officiële ESPHome-container, mapped `homeassistant_config`
naar `/homeassistant` en vraagt UART/USB voor de door de gebruiker gekozen poort.
Config/secrets blijven in de eigen HA-configmap; builds in `/data`. App-updates
vervangen deze mappen niet. Native getalinstellingen melden wijzigingen via
`esphome.screen_setting`; de manager valideert inbox, sleutel en waarde.
+71
View File
@@ -0,0 +1,71 @@
# ESP Screens 0.2.0
## Dagelijks bedienen
- Guition: iets donkerder lichtgrijze achtergrond, gekleurde accenten en zwarte
sliderknoppen. Namen en status hebben elk één eigen regel met afkapping.
- Actietegels tonen minimaal één seconde `Bezig...` en een voortgangsbalk.
Een ontvangen HA-statuswijziging beëindigt het wachten na die minimumtijd.
Zonder nieuwe status stopt de indicatie na zes seconden. Dit is feedback op
een statuswijziging, geen bewijs dat een fysieke actie geslaagd is.
- Tik op een sensor voor numerieke HA-geschiedenis. De app haalt 1, 6 of 24 uur
op en stuurt 24 tijdvakken; verversing maximaal eenmaal per vijf minuten.
Ontbrekende/niet-numerieke historie wordt aangegeven. Recorder moet de sensor
bewaren. Dit is een compacte grafiek, geen energieboekhouding.
- Vacuum: status, accu wanneer als attribuut beschikbaar, start/pauze/dock/zoeken
en maximaal vier zuigstanden. Media: vorige/play-pauze/volgende en volume.
- Getal-helpers en number-entiteiten krijgen een schuif binnen hun min/max/step;
select/input_select toont maximaal acht opties. Weather toont conditie en
temperatuur. Bestaande lamp-, RGB-, climate-, fan- en coverkaarten blijven.
Deze kaarten en tegelopties zijn beschikbaar voor Guition én CYD. De lichte
Guition-stijl is bordgebonden; de CYD behoudt zijn eigen compacte vormgeving.
## Instellingen per tegel
Open in ESP Screens **Tegelinstellingen** onder een gekozen entiteit:
- Bij aantikken: automatisch, bediening openen, alleen bekijken; aan/uit bij
daarvoor geschikte entiteiten.
- Weergave: normale kaart of **Grote waarde**, bijvoorbeeld temperatuur of kWh.
De kaart behoudt zijn plek in het raster; er komen geen kleinere aanraakvlakken.
- Mini-schuif: voor lampen, fans, covers, media en getalinstellingen. Een grotere
waarde en mini-schuif zijn alternatieven, zodat teksten niet overlappen.
- Sensor: geschiedenisperiode 1, 6 of 24 uur.
**Inspector** toont schermverbinding, firmware, laatste afleverstatus, entiteiten,
attributen die het scherm ontvangt en tegelopties. Geen wifi/API/OTA-sleutels.
## Instellingen bij het HA-apparaat
Bij **Instellingen → Apparaten & diensten → ESPHome → jouw scherm** staan onder
Configuratie de getalinstellingen **Helderheid normaal**, **Helderheid standby**,
**Helderheid nacht** en **Standby na** (seconden). Installeer firmware 0.2.0 en laat
HA opnieuw verbinden als ze ontbreken. De overige instellingen staan in ESP
Screens → Scherminstellingen. Wijzigingen via HA worden ook in ESP Screens
opgeslagen; sta HA-acties toe bij de ESPHome-integratie.
## Firmware zonder een tweede beheerpagina
Werk de app bij naar 0.2.0. Deze bevat de officiële ESPHome CLI 2026.6.2.
1. **Nieuw scherm** → bord/naam → **Bewaar profiel in ESP Screens**. Bij een
eerste installatie vul je wifi in. Bestaande secrets worden nooit vervangen.
Bewaar de getoonde YAML met de unieke sleutels als eigen back-up.
2. **Firmware & USB** → kies het profiel. De app leest dezelfde map
`/homeassistant/esphome` die in de HA-configschijf `esphome` heet.
3. Voor een eerste flash: scherm via USB aan de HA-Raspberry, kies expliciet de
USB-poort en **Bouwen & installeren**. Gebruik één bedoeld bord tegelijk.
4. Koppel het scherm via de gewone HA ESPHome-integratie. Gebruik de API-sleutel
uit je profiel en sta HA-acties toe. CYD kalibreert bij de eerste start.
5. Voor updates: hetzelfde profiel, **Wifi / OTA**, het IP-adres of de hostnaam
van dat scherm, **Bouwen & installeren**. Tegels en secrets blijven behouden.
De app voert één CLI-taak tegelijk uit. Validatie en bouwen uploaden niets.
Alleen installeren bouwt én uploadt naar het gekozen doel. Het log maskeert
secrets. De eerste download/build vraagt internet, schijfruimte en tijd. Bouwcache
staat in permanente appdata. Een browser sluiten stopt de build niet.
De bestaande ESPHome Device Builder mag blijven staan, maar is voor deze route
niet nodig. Werk niet gelijktijdig vanuit twee builders aan hetzelfde profiel.
Bestaande eigen YAML blijft leidend; een nieuwe YAML aanmaken is geen updateroute.
+98 -5
View File
@@ -554,8 +554,8 @@ substitutions:
OVERLAY_SLIDER_W: "144" OVERLAY_SLIDER_W: "144"
OVERLAY_SLIDER_H: "300" OVERLAY_SLIDER_H: "300"
OVERLAY_ICON_Y: "148" OVERLAY_ICON_Y: "148"
BG_TOP_COLOR: "0xF7F7F7" BG_TOP_COLOR: "0xECECEC"
BG_BOTTOM_COLOR: "0xF7F7F7" BG_BOTTOM_COLOR: "0xECECEC"
TILE_ICON_X: "0" TILE_ICON_X: "0"
TILE_ICON_Y: "12" TILE_ICON_Y: "12"
TILE_TITLE_X: "64" TILE_TITLE_X: "64"
@@ -582,7 +582,7 @@ esphome:
min_version: 2026.6.2 min_version: 2026.6.2
project: project:
name: maxgramser.esp_screen name: maxgramser.esp_screen
version: "0.1.3" version: "0.2.0"
platformio_options: platformio_options:
build_flags: build_flags:
- -DLV_USE_SNAPSHOT=1 - -DLV_USE_SNAPSHOT=1
@@ -613,11 +613,13 @@ esphome:
settings.night_end = atoi("${NIGHT_END_HOUR}") * 60; settings.night_end = atoi("${NIGHT_END_HOUR}") * 60;
settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}"); settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}");
runtime_tiles::light_theme = true; runtime_tiles::light_theme = true;
runtime_tiles::watch_font = id(headline)->get_lv_font();
runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true"; runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true";
if (!runtime_tiles::enabled) return; if (!runtime_tiles::enabled) return;
runtime_tiles::load_settings(); runtime_tiles::load_settings();
runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); }; runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); };
runtime_tiles::dismiss = []() { runtime_tiles::dismiss = []() {
runtime_tiles::hide_detail();
id(active_entity).clear(); id(active_entity).clear();
lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(id(color_detail_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(id(color_detail_overlay), LV_OBJ_FLAG_HIDDEN);
@@ -769,6 +771,16 @@ api:
ESP_LOGI("ui_diag", "home=%d calibration=%d grid=%d,%d,%d,%d next=%d,%d,%d,%d", ESP_LOGI("ui_diag", "home=%d calibration=%d grid=%d,%d,%d,%d next=%d,%d,%d,%d",
lv_screen_active() == id(home_page)->obj, lv_screen_active() == id(touch_cal_page)->obj, lv_screen_active() == id(home_page)->obj, lv_screen_active() == id(touch_cal_page)->obj,
grid.x1, grid.y1, grid.x2, grid.y2, nav.x1, nav.y1, nav.x2, nav.y2); grid.x1, grid.y1, grid.x2, grid.y2, nav.x1, nav.y1, nav.x2, nav.y2);
- action: preview_runtime_card
variables:
index: int
then:
- lambda: |-
if (index < 0) { runtime_tiles::hide_detail(); return; }
if (runtime_tiles::enabled && index < (int)runtime_tiles::model.count) {
runtime_tiles::active_index = index;
runtime_tiles::show_detail(index);
}
- action: light_controls_preview - action: light_controls_preview
then: then:
- script.execute: light_controls_preview - script.execute: light_controls_preview
@@ -792,6 +804,9 @@ wifi:
captive_portal: captive_portal:
interval: interval:
- interval: 250ms
then:
- lambda: runtime_tiles::tick();
- interval: 1s - interval: 1s
then: then:
- if: - if:
@@ -1308,7 +1323,7 @@ text_sensor:
- platform: template - platform: template
name: "Schermfirmware" name: "Schermfirmware"
entity_category: diagnostic entity_category: diagnostic
lambda: 'return {"0.1.3"};' lambda: 'return {"0.2.0"};'
update_interval: 60s update_interval: 60s
- platform: template - platform: template
id: smartdisplay_action id: smartdisplay_action
@@ -4518,7 +4533,7 @@ script:
const int minute = now.is_valid() ? now.hour * 60 + now.minute : -1; const int minute = now.is_valid() ? now.hour * 60 + now.minute : -1;
const int level = id(display_dimmed) ? settings.dim_level(minute) : settings.brightness; const int level = id(display_dimmed) ? settings.dim_level(minute) : settings.brightness;
id(back_light).turn_on().set_transition_length(300).set_brightness(level / 100.0f).perform(); id(back_light).turn_on().set_transition_length(300).set_brightness(level / 100.0f).perform();
if (id(display_dimmed)) lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); if (id(display_dimmed)) { lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(id(dim_wake_overlay)); }
else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN);
if (settings.show_clock) lv_obj_clear_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN); if (settings.show_clock) lv_obj_clear_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
@@ -5892,3 +5907,81 @@ text:
id: dashboard_inbox id: dashboard_inbox
name: "Tegelinstellingen" name: "Tegelinstellingen"
internal: ${DASHBOARD_INBOX_INTERNAL} internal: ${DASHBOARD_INBOX_INTERNAL}
number:
- platform: template
id: setting_brightness
name: "Helderheid normaal"
entity_category: config
min_value: 5
max_value: 100
step: 1
unit_of_measurement: "%"
update_interval: 2s
lambda: return screen_settings::current.brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
s.brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("brightness",s.brightness);
id(apply_screen_settings).execute();
- platform: template
id: setting_standby_brightness
name: "Helderheid standby"
entity_category: config
min_value: 0
max_value: 100
step: 1
unit_of_measurement: "%"
update_interval: 2s
lambda: return screen_settings::current.standby_brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
s.standby_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_brightness",s.standby_brightness);
id(apply_screen_settings).execute();
- platform: template
id: setting_night_brightness
name: "Helderheid nacht"
entity_category: config
min_value: 0
max_value: 100
step: 1
unit_of_measurement: "%"
update_interval: 2s
lambda: return screen_settings::current.night_brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
s.night_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("night_brightness",s.night_brightness);
id(apply_screen_settings).execute();
- platform: template
id: setting_standby_seconds
name: "Standby na"
entity_category: config
min_value: 60
max_value: 86400
step: 60
unit_of_measurement: "s"
update_interval: 2s
lambda: return screen_settings::current.standby_seconds;
set_action:
- lambda: |-
auto &s = screen_settings::current;
s.standby_seconds = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_seconds",s.standby_seconds);
id(apply_screen_settings).execute();
+96 -3
View File
@@ -777,7 +777,7 @@ esphome:
min_version: 2026.6.2 min_version: 2026.6.2
project: project:
name: maxgramser.esp_screen name: maxgramser.esp_screen
version: "0.1.3" version: "0.2.0"
includes: includes:
- cyd_ui.h - cyd_ui.h
- light_controls.h - light_controls.h
@@ -805,11 +805,13 @@ esphome:
settings.night_start = atoi("${NIGHT_START_HOUR}") * 60; settings.night_start = atoi("${NIGHT_START_HOUR}") * 60;
settings.night_end = atoi("${NIGHT_END_HOUR}") * 60; settings.night_end = atoi("${NIGHT_END_HOUR}") * 60;
settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}"); settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}");
runtime_tiles::watch_font = id(headline)->get_lv_font();
runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true"; runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true";
if (!runtime_tiles::enabled) return; if (!runtime_tiles::enabled) return;
runtime_tiles::load_settings(); runtime_tiles::load_settings();
runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); }; runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); };
runtime_tiles::dismiss = []() { runtime_tiles::dismiss = []() {
runtime_tiles::hide_detail();
id(active_entity).clear(); id(active_entity).clear();
lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(id(color_detail_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(id(color_detail_overlay), LV_OBJ_FLAG_HIDDEN);
@@ -964,6 +966,16 @@ api:
ESP_LOGI("ui_diag", "home=%d calibration=%d grid=%d,%d,%d,%d next=%d,%d,%d,%d", ESP_LOGI("ui_diag", "home=%d calibration=%d grid=%d,%d,%d,%d next=%d,%d,%d,%d",
lv_screen_active() == id(home_page)->obj, lv_screen_active() == id(touch_cal_page)->obj, lv_screen_active() == id(home_page)->obj, lv_screen_active() == id(touch_cal_page)->obj,
grid.x1, grid.y1, grid.x2, grid.y2, nav.x1, nav.y1, nav.x2, nav.y2); grid.x1, grid.y1, grid.x2, grid.y2, nav.x1, nav.y1, nav.x2, nav.y2);
- action: preview_runtime_card
variables:
index: int
then:
- lambda: |-
if (index < 0) { runtime_tiles::hide_detail(); return; }
if (runtime_tiles::enabled && index < (int)runtime_tiles::model.count) {
runtime_tiles::active_index = index;
runtime_tiles::show_detail(index);
}
- action: light_controls_preview - action: light_controls_preview
then: then:
- script.execute: light_controls_preview - script.execute: light_controls_preview
@@ -987,6 +999,9 @@ wifi:
captive_portal: captive_portal:
interval: interval:
- interval: 250ms
then:
- lambda: runtime_tiles::tick();
- interval: 1s - interval: 1s
then: then:
- if: - if:
@@ -1498,7 +1513,7 @@ text_sensor:
- platform: template - platform: template
name: "Schermfirmware" name: "Schermfirmware"
entity_category: diagnostic entity_category: diagnostic
lambda: 'return {"0.1.3"};' lambda: 'return {"0.2.0"};'
update_interval: 60s update_interval: 60s
- platform: template - platform: template
id: smartdisplay_action id: smartdisplay_action
@@ -4648,7 +4663,7 @@ script:
const int minute = now.is_valid() ? now.hour * 60 + now.minute : -1; const int minute = now.is_valid() ? now.hour * 60 + now.minute : -1;
const int level = id(display_dimmed) ? settings.dim_level(minute) : settings.brightness; const int level = id(display_dimmed) ? settings.dim_level(minute) : settings.brightness;
id(back_light).turn_on().set_transition_length(300).set_brightness(level / 100.0f).perform(); id(back_light).turn_on().set_transition_length(300).set_brightness(level / 100.0f).perform();
if (id(display_dimmed)) lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); if (id(display_dimmed)) { lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(id(dim_wake_overlay)); }
else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN);
if (settings.show_clock) lv_obj_clear_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN); if (settings.show_clock) lv_obj_clear_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
@@ -6031,3 +6046,81 @@ button:
entity_category: config entity_category: config
on_press: on_press:
- lambda: 'if (runtime_tiles::enabled) screen_calibration::begin();' - lambda: 'if (runtime_tiles::enabled) screen_calibration::begin();'
number:
- platform: template
id: setting_brightness
name: "Helderheid normaal"
entity_category: config
min_value: 5
max_value: 100
step: 1
unit_of_measurement: "%"
update_interval: 2s
lambda: return screen_settings::current.brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
s.brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("brightness",s.brightness);
id(apply_screen_settings).execute();
- platform: template
id: setting_standby_brightness
name: "Helderheid standby"
entity_category: config
min_value: 0
max_value: 100
step: 1
unit_of_measurement: "%"
update_interval: 2s
lambda: return screen_settings::current.standby_brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
s.standby_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_brightness",s.standby_brightness);
id(apply_screen_settings).execute();
- platform: template
id: setting_night_brightness
name: "Helderheid nacht"
entity_category: config
min_value: 0
max_value: 100
step: 1
unit_of_measurement: "%"
update_interval: 2s
lambda: return screen_settings::current.night_brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
s.night_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("night_brightness",s.night_brightness);
id(apply_screen_settings).execute();
- platform: template
id: setting_standby_seconds
name: "Standby na"
entity_category: config
min_value: 60
max_value: 86400
step: 60
unit_of_measurement: "s"
update_interval: 2s
lambda: return screen_settings::current.standby_seconds;
set_action:
- lambda: |-
auto &s = screen_settings::current;
s.standby_seconds = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_seconds",s.standby_seconds);
id(apply_screen_settings).execute();
+96 -3
View File
@@ -781,7 +781,7 @@ esphome:
min_version: 2026.6.2 min_version: 2026.6.2
project: project:
name: maxgramser.esp_screen name: maxgramser.esp_screen
version: "0.1.3" version: "0.2.0"
includes: includes:
- <esphome/components/smart_display/cyd_ui.h> - <esphome/components/smart_display/cyd_ui.h>
- <esphome/components/smart_display/light_controls.h> - <esphome/components/smart_display/light_controls.h>
@@ -809,11 +809,13 @@ esphome:
settings.night_start = atoi("${NIGHT_START_HOUR}") * 60; settings.night_start = atoi("${NIGHT_START_HOUR}") * 60;
settings.night_end = atoi("${NIGHT_END_HOUR}") * 60; settings.night_end = atoi("${NIGHT_END_HOUR}") * 60;
settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}"); settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}");
runtime_tiles::watch_font = id(headline)->get_lv_font();
runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true"; runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true";
if (!runtime_tiles::enabled) return; if (!runtime_tiles::enabled) return;
runtime_tiles::load_settings(); runtime_tiles::load_settings();
runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); }; runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); };
runtime_tiles::dismiss = []() { runtime_tiles::dismiss = []() {
runtime_tiles::hide_detail();
id(active_entity).clear(); id(active_entity).clear();
lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(id(color_detail_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(id(color_detail_overlay), LV_OBJ_FLAG_HIDDEN);
@@ -968,6 +970,16 @@ api:
ESP_LOGI("ui_diag", "home=%d calibration=%d grid=%d,%d,%d,%d next=%d,%d,%d,%d", ESP_LOGI("ui_diag", "home=%d calibration=%d grid=%d,%d,%d,%d next=%d,%d,%d,%d",
lv_screen_active() == id(home_page)->obj, lv_screen_active() == id(touch_cal_page)->obj, lv_screen_active() == id(home_page)->obj, lv_screen_active() == id(touch_cal_page)->obj,
grid.x1, grid.y1, grid.x2, grid.y2, nav.x1, nav.y1, nav.x2, nav.y2); grid.x1, grid.y1, grid.x2, grid.y2, nav.x1, nav.y1, nav.x2, nav.y2);
- action: preview_runtime_card
variables:
index: int
then:
- lambda: |-
if (index < 0) { runtime_tiles::hide_detail(); return; }
if (runtime_tiles::enabled && index < (int)runtime_tiles::model.count) {
runtime_tiles::active_index = index;
runtime_tiles::show_detail(index);
}
- action: light_controls_preview - action: light_controls_preview
then: then:
- script.execute: light_controls_preview - script.execute: light_controls_preview
@@ -978,6 +990,9 @@ api:
captive_portal: captive_portal:
interval: interval:
- interval: 250ms
then:
- lambda: runtime_tiles::tick();
- interval: 1s - interval: 1s
then: then:
- if: - if:
@@ -1451,7 +1466,7 @@ text_sensor:
- platform: template - platform: template
name: "Schermfirmware" name: "Schermfirmware"
entity_category: diagnostic entity_category: diagnostic
lambda: 'return {"0.1.3"};' lambda: 'return {"0.2.0"};'
update_interval: 60s update_interval: 60s
- platform: template - platform: template
id: smartdisplay_action id: smartdisplay_action
@@ -4581,7 +4596,7 @@ script:
const int minute = now.is_valid() ? now.hour * 60 + now.minute : -1; const int minute = now.is_valid() ? now.hour * 60 + now.minute : -1;
const int level = id(display_dimmed) ? settings.dim_level(minute) : settings.brightness; const int level = id(display_dimmed) ? settings.dim_level(minute) : settings.brightness;
id(back_light).turn_on().set_transition_length(300).set_brightness(level / 100.0f).perform(); id(back_light).turn_on().set_transition_length(300).set_brightness(level / 100.0f).perform();
if (id(display_dimmed)) lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); if (id(display_dimmed)) { lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(id(dim_wake_overlay)); }
else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN);
if (settings.show_clock) lv_obj_clear_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN); if (settings.show_clock) lv_obj_clear_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
@@ -5964,3 +5979,81 @@ button:
entity_category: config entity_category: config
on_press: on_press:
- lambda: 'if (runtime_tiles::enabled) screen_calibration::begin();' - lambda: 'if (runtime_tiles::enabled) screen_calibration::begin();'
number:
- platform: template
id: setting_brightness
name: "Helderheid normaal"
entity_category: config
min_value: 5
max_value: 100
step: 1
unit_of_measurement: "%"
update_interval: 2s
lambda: return screen_settings::current.brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
s.brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("brightness",s.brightness);
id(apply_screen_settings).execute();
- platform: template
id: setting_standby_brightness
name: "Helderheid standby"
entity_category: config
min_value: 0
max_value: 100
step: 1
unit_of_measurement: "%"
update_interval: 2s
lambda: return screen_settings::current.standby_brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
s.standby_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_brightness",s.standby_brightness);
id(apply_screen_settings).execute();
- platform: template
id: setting_night_brightness
name: "Helderheid nacht"
entity_category: config
min_value: 0
max_value: 100
step: 1
unit_of_measurement: "%"
update_interval: 2s
lambda: return screen_settings::current.night_brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
s.night_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("night_brightness",s.night_brightness);
id(apply_screen_settings).execute();
- platform: template
id: setting_standby_seconds
name: "Standby na"
entity_category: config
min_value: 60
max_value: 86400
step: 60
unit_of_measurement: "s"
update_interval: 2s
lambda: return screen_settings::current.standby_seconds;
set_action:
- lambda: |-
auto &s = screen_settings::current;
s.standby_seconds = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_seconds",s.standby_seconds);
id(apply_screen_settings).execute();
+98 -5
View File
@@ -555,8 +555,8 @@ substitutions:
OVERLAY_SLIDER_W: "144" OVERLAY_SLIDER_W: "144"
OVERLAY_SLIDER_H: "300" OVERLAY_SLIDER_H: "300"
OVERLAY_ICON_Y: "148" OVERLAY_ICON_Y: "148"
BG_TOP_COLOR: "0xF7F7F7" BG_TOP_COLOR: "0xECECEC"
BG_BOTTOM_COLOR: "0xF7F7F7" BG_BOTTOM_COLOR: "0xECECEC"
TILE_ICON_X: "0" TILE_ICON_X: "0"
TILE_ICON_Y: "12" TILE_ICON_Y: "12"
TILE_TITLE_X: "64" TILE_TITLE_X: "64"
@@ -586,7 +586,7 @@ esphome:
min_version: 2026.6.2 min_version: 2026.6.2
project: project:
name: maxgramser.esp_screen name: maxgramser.esp_screen
version: "0.1.3" version: "0.2.0"
platformio_options: platformio_options:
build_flags: build_flags:
- -DLV_USE_SNAPSHOT=1 - -DLV_USE_SNAPSHOT=1
@@ -617,11 +617,13 @@ esphome:
settings.night_end = atoi("${NIGHT_END_HOUR}") * 60; settings.night_end = atoi("${NIGHT_END_HOUR}") * 60;
settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}"); settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}");
runtime_tiles::light_theme = true; runtime_tiles::light_theme = true;
runtime_tiles::watch_font = id(headline)->get_lv_font();
runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true"; runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true";
if (!runtime_tiles::enabled) return; if (!runtime_tiles::enabled) return;
runtime_tiles::load_settings(); runtime_tiles::load_settings();
runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); }; runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); };
runtime_tiles::dismiss = []() { runtime_tiles::dismiss = []() {
runtime_tiles::hide_detail();
id(active_entity).clear(); id(active_entity).clear();
lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(id(color_detail_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(id(color_detail_overlay), LV_OBJ_FLAG_HIDDEN);
@@ -773,6 +775,16 @@ api:
ESP_LOGI("ui_diag", "home=%d calibration=%d grid=%d,%d,%d,%d next=%d,%d,%d,%d", ESP_LOGI("ui_diag", "home=%d calibration=%d grid=%d,%d,%d,%d next=%d,%d,%d,%d",
lv_screen_active() == id(home_page)->obj, lv_screen_active() == id(touch_cal_page)->obj, lv_screen_active() == id(home_page)->obj, lv_screen_active() == id(touch_cal_page)->obj,
grid.x1, grid.y1, grid.x2, grid.y2, nav.x1, nav.y1, nav.x2, nav.y2); grid.x1, grid.y1, grid.x2, grid.y2, nav.x1, nav.y1, nav.x2, nav.y2);
- action: preview_runtime_card
variables:
index: int
then:
- lambda: |-
if (index < 0) { runtime_tiles::hide_detail(); return; }
if (runtime_tiles::enabled && index < (int)runtime_tiles::model.count) {
runtime_tiles::active_index = index;
runtime_tiles::show_detail(index);
}
- action: light_controls_preview - action: light_controls_preview
then: then:
- script.execute: light_controls_preview - script.execute: light_controls_preview
@@ -783,6 +795,9 @@ api:
captive_portal: captive_portal:
interval: interval:
- interval: 250ms
then:
- lambda: runtime_tiles::tick();
- interval: 1s - interval: 1s
then: then:
- if: - if:
@@ -1261,7 +1276,7 @@ text_sensor:
- platform: template - platform: template
name: "Schermfirmware" name: "Schermfirmware"
entity_category: diagnostic entity_category: diagnostic
lambda: 'return {"0.1.3"};' lambda: 'return {"0.2.0"};'
update_interval: 60s update_interval: 60s
- platform: template - platform: template
id: smartdisplay_action id: smartdisplay_action
@@ -4451,7 +4466,7 @@ script:
const int minute = now.is_valid() ? now.hour * 60 + now.minute : -1; const int minute = now.is_valid() ? now.hour * 60 + now.minute : -1;
const int level = id(display_dimmed) ? settings.dim_level(minute) : settings.brightness; const int level = id(display_dimmed) ? settings.dim_level(minute) : settings.brightness;
id(back_light).turn_on().set_transition_length(300).set_brightness(level / 100.0f).perform(); id(back_light).turn_on().set_transition_length(300).set_brightness(level / 100.0f).perform();
if (id(display_dimmed)) lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); if (id(display_dimmed)) { lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(id(dim_wake_overlay)); }
else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN);
if (settings.show_clock) lv_obj_clear_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN); if (settings.show_clock) lv_obj_clear_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
@@ -5825,3 +5840,81 @@ text:
id: dashboard_inbox id: dashboard_inbox
name: "Tegelinstellingen" name: "Tegelinstellingen"
internal: ${DASHBOARD_INBOX_INTERNAL} internal: ${DASHBOARD_INBOX_INTERNAL}
number:
- platform: template
id: setting_brightness
name: "Helderheid normaal"
entity_category: config
min_value: 5
max_value: 100
step: 1
unit_of_measurement: "%"
update_interval: 2s
lambda: return screen_settings::current.brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
s.brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("brightness",s.brightness);
id(apply_screen_settings).execute();
- platform: template
id: setting_standby_brightness
name: "Helderheid standby"
entity_category: config
min_value: 0
max_value: 100
step: 1
unit_of_measurement: "%"
update_interval: 2s
lambda: return screen_settings::current.standby_brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
s.standby_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_brightness",s.standby_brightness);
id(apply_screen_settings).execute();
- platform: template
id: setting_night_brightness
name: "Helderheid nacht"
entity_category: config
min_value: 0
max_value: 100
step: 1
unit_of_measurement: "%"
update_interval: 2s
lambda: return screen_settings::current.night_brightness;
set_action:
- lambda: |-
auto &s = screen_settings::current;
s.night_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("night_brightness",s.night_brightness);
id(apply_screen_settings).execute();
- platform: template
id: setting_standby_seconds
name: "Standby na"
entity_category: config
min_value: 60
max_value: 86400
step: 60
unit_of_measurement: "s"
update_interval: 2s
lambda: return screen_settings::current.standby_seconds;
set_action:
- lambda: |-
auto &s = screen_settings::current;
s.standby_seconds = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_seconds",s.standby_seconds);
id(apply_screen_settings).execute();
+10
View File
@@ -1,3 +1,13 @@
# 0.2.0
- ESPHome CLI in de app: bestaande profielen controleren, bouwen en via USB/OTA installeren.
- Nieuwe profielen met unieke sleutels; bestaande YAML, secrets en appdata blijven behouden.
- Tegelopties: klikgedrag, grote waarde, mini-schuif en sensorgeschiedenis.
- Media-, weather-, number- en select-kaarten en vernieuwde vacuumkaart op beide borden.
- Actiefeedback, vaste tekstregels en zwarte sliderknoppen.
- Inspector en helderheid/standby als instellingen bij het HA-apparaat.
- Native Guition ST7701S-configuratie en lichte kaartstijl. Duuracceptatie van het fysieke beeld blijft apart van firmwaretests.
# 0.1.2 # 0.1.2
- Per scherm standbyduur, normale/standby/nachthelderheid en nachturen instellen. - Per scherm standbyduur, normale/standby/nachthelderheid en nachturen instellen.
+11 -21
View File
@@ -1,24 +1,14 @@
# Aan de slag # Installatie en dagelijks gebruik
Start de app en open de webinterface. Klik **Nieuw scherm**, kies het exacte bord Zie [de volledige 0.2.0-handleiding](https://github.com/MaxGramser/homeassistant_espscreen/blob/main/docs/WHATS_NEW_020.md).
en download je eigen YAML. De wizard legt de eerste USB-flash via ESPHome Device
Builder, wifi, HA-koppeling en kalibratie uit. Kies daarna je tegels in deze app.
De volledige handleiding staat op Open **Nieuw scherm** om een eigen profiel te bewaren. Open **Firmware & USB**
[GitHub: Easy Setup](https://github.com/MaxGramser/homeassistant_espscreen/blob/main/docs/EASY_SETUP.md). om een profiel te controleren, bouwen of installeren. Bestaande ESPHome-profielen
in de HA-configmap worden automatisch gevonden. Eerste flash: USB aan de Raspberry
en expliciet de bijbehorende poort kiezen. Later: hetzelfde profiel plus het
IP-adres van het scherm voor OTA. Bestaande secrets worden niet vervangen.
## Updates Na koppeling via de HA ESPHome-integratie kies je in ESP Screens de tegels.
Onder **Tegelinstellingen** staan klikgedrag, grotere waarden en mini-schuiven.
Update de app via de appwinkel. Indelingen staan in de permanente `/data`-map en Onder **Scherminstellingen** staan helderheid en standby. **Inspector** helpt bij
blijven bij een update behouden. Verwijder de app niet om een update te installeren. ontbrekende attributen of een offline scherm. Nieuwe kaarten vereisen firmware 0.2.0.
Voor nieuwe schermfuncties kies je in ESPHome bij je **bestaande apparaat**:
Install → Wirelessly. Houd je eigen YAML, apparaatnaam en sleutels gelijk.
Maak vóór updates een HA-back-up inclusief deze app en ESPHome Device Builder.
## Werking
De app leest entiteiten, apparaten, ruimtes en status uit jouw HA en schrijft
uitsluitend configuratieberichten naar de ESPHome-tekstentiteit Tegelinstellingen.
Het scherm voert de gekozen HA-acties uit. Geef het daarvoor toestemming in de
ESPHome-integratie. De app moet draaien om actuele gegevens te blijven versturen.
+5 -3
View File
@@ -1,8 +1,10 @@
FROM ghcr.io/home-assistant/base:3.22 # Official ESPHome distribution: compiler, ESP-IDF, esptool and OTA CLI included.
FROM ghcr.io/esphome/esphome:2026.6.2
ARG BUILD_VERSION ARG BUILD_VERSION
ARG BUILD_ARCH ARG BUILD_ARCH
LABEL io.hass.version="${BUILD_VERSION}" io.hass.type="app" io.hass.arch="${BUILD_ARCH}" LABEL io.hass.version="${BUILD_VERSION}" io.hass.type="app" io.hass.arch="${BUILD_ARCH}"
RUN apk add --no-cache python3 py3-aiohttp py3-yaml RUN pip install --no-cache-dir 'aiohttp>=3.12,<4' PyYAML
COPY app /app COPY app /app
WORKDIR /app WORKDIR /app
CMD ["/command/with-contenv", "python3", "/app/server.py"] ENTRYPOINT []
CMD ["python3", "/app/server.py"]
+24 -4
View File
@@ -5,10 +5,10 @@ import math
import re import re
import secrets import secrets
DOMAINS = frozenset('light switch input_boolean scene script climate vacuum fan cover sensor binary_sensor input_select 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'.split())
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen' REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'} 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'.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'.split())
# Additive schema 1 extension. An absent object retains old firmware/YAML defaults. # Additive schema 1 extension. An absent object retains old firmware/YAML defaults.
SETTING_RULES = { SETTING_RULES = {
@@ -66,7 +66,26 @@ def validate_layout(data):
if not isinstance(name, str) or len(name.encode()) > 80: if not isinstance(name, str) or len(name.encode()) > 80:
raise ValueError('Een tegelnaam mag maximaal 80 bytes bevatten.') raise ValueError('Een tegelnaam mag maximaal 80 bytes bevatten.')
seen.add(tile['entity']) seen.add(tile['entity'])
clean.append({'entity': tile['entity'], 'name': name.strip()}) 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'}:
raise ValueError('Onbekende tegelinstellingen.')
choices = {'tap': ('auto', 'detail', 'toggle', 'none'), 'display': ('standard', 'watch'), 'inline': ('none', 'slider')}
domain = tile['entity'].split('.')[0]
for key, allowed in choices.items():
if key in options and options[key] not in allowed:
raise ValueError('Ongeldige tegelinstelling: ' + key)
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'}:
raise ValueError('Deze entiteit ondersteunt geen mini-schuif.')
if 'history_hours' in options and (type(options['history_hours']) is not int or options['history_hours'] not in (1,6,24)):
raise ValueError('Geschiedenis: kies 1, 6 of 24 uur.')
if options.get('display') == 'watch' and options.get('inline') == 'slider':
raise ValueError('Kies grote waarde of mini-schuif.')
item['options'] = dict(options)
clean.append(item)
result = {'title': title.strip(), 'tiles': clean} result = {'title': title.strip(), 'tiles': clean}
if 'settings' in data: if 'settings' in data:
result['settings'] = validate_settings(data['settings']) result['settings'] = validate_settings(data['settings'])
@@ -92,7 +111,8 @@ def state_message(index, tile, states):
if isinstance(v, str) or isinstance(v, (float, int)) and math.isfinite(v) and abs(v) <= 1000000] if isinstance(v, str) or isinstance(v, (float, int)) and math.isfinite(v) and abs(v) <= 1000000]
return {'v': 1, 'op': 'state', 'i': index, 'entity': tile['entity'], return {'v': 1, 'op': 'state', 'i': index, 'entity': tile['entity'],
'name': short(tile['name'] or attrs.get('friendly_name') or tile['entity'], 80), 'name': short(tile['name'] or attrs.get('friendly_name') or tile['entity'], 80),
'state': short(state.get('state', 'unavailable'), 160), 'a': bounded} 'state': short(state.get('state', 'unavailable'), 160), 'a': bounded,
**({'o': tile['options']} if 'options' in tile else {})}
def packets(message, token=None): def packets(message, token=None):
raw = json.dumps(message, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode() raw = json.dumps(message, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode()
+129
View File
@@ -0,0 +1,129 @@
"""Serialised ESPHome CLI jobs. YAML/secrets stay local; no shell or arbitrary commands."""
import asyncio
from collections import deque
import glob
import json
import os
from pathlib import Path
import re
import shutil
import signal
import time
import yaml
from core import installation_yaml
class Firmware:
def __init__(self, root, data):
self.root, self.data = Path(root).resolve(), Path(data)
self.job = None
self.task = None
self.logs = deque(maxlen=300)
self.process = None
def profiles(self):
if not self.root.exists(): return []
return [{'file': p.name} for p in sorted(self.root.glob('*.yaml'))
if p.name != 'secrets.yaml' and p.is_file() and not p.is_symlink()]
def ports(self):
return sorted(set(glob.glob('/dev/serial/by-id/*') or glob.glob('/dev/ttyUSB*') + glob.glob('/dev/ttyACM*')))
def profile(self, name):
if not isinstance(name,str) or not re.fullmatch(r'[a-zA-Z0-9_-]+\.yaml', name):
raise ValueError('Kies een bestaand YAML-profiel.')
p = self.root / name
if p.is_symlink() or p.resolve().parent != self.root or not p.is_file():
raise ValueError('Profiel bestaat niet in de ESPHome-map.')
return p
def status(self):
return {'available': bool(shutil.which('esphome')), 'profiles': self.profiles(),
'ports': self.ports(), 'job': self.job, 'logs': list(self.logs)}
def create(self, data):
content = installation_yaml(data)
self.root.mkdir(parents=True,exist_ok=True)
# Existing wifi secrets remain untouched. New owners can supply them once.
secret_path = self.root / 'secrets.yaml'
if not secret_path.exists():
ssid, password = data.get('wifi_ssid'), data.get('wifi_password')
if not isinstance(ssid,str) or not ssid.strip() or not isinstance(password,str):
raise ValueError('Vul wifi in voor deze eerste installatie.')
with secret_path.open('x') as f:
os.chmod(secret_path,0o600)
yaml.safe_dump({'wifi_ssid':ssid,'wifi_password':password}, f)
profile = self.root / (data['name']+'.yaml')
try:
with profile.open('x') as f:
os.chmod(profile,0o600);f.write(content)
except FileExistsError:
raise ValueError('Deze naam bestaat al. Gebruik het bestaande profiel voor updates.')
return {'file':profile.name, 'yaml':content}
def redact(self, text):
text = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', text)
for value in self._secret_values:
if len(value) >= 3: text=text.replace(value,'[redacted]')
return re.sub(r'(?i)((?:password|encryption.key|token|ssid)\s*[:=]\s*).+',r'\1[redacted]',text)[:1500]
def start(self, data):
if self.task and not self.task.done(): raise ValueError('Er loopt al een build of installatie.')
profile = self.profile(data.get('file'))
action = data.get('action')
if action not in ('validate','build','install'): raise ValueError('Onbekende firmwareactie.')
if not shutil.which('esphome'): raise ValueError('ESPHome CLI ontbreekt in deze installatie.')
target = data.get('target','')
if action == 'install':
if target.startswith('/dev/'):
if target not in self.ports(): raise ValueError('Kies de aangesloten USB-poort uit de lijst.')
elif not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}',target):
raise ValueError('Vul het IP-adres of de hostnaam van het bedoelde scherm in.')
self._secret_values=set()
# Collect scalar literals, including !secret values, without executing YAML tags.
def collect(node):
if isinstance(node,yaml.ScalarNode):
if node.value: self._secret_values.add(node.value)
elif isinstance(node,yaml.SequenceNode):
for child in node.value: collect(child)
elif isinstance(node,yaml.MappingNode):
for key,value in node.value:
if key.value in ('password','key','ssid','token') or profile.name=='secrets.yaml':collect(value)
elif isinstance(value,(yaml.MappingNode,yaml.SequenceNode)):collect(value)
collect(yaml.compose(profile.read_text()))
secret_file=self.root/'secrets.yaml'
if secret_file.exists():
node=yaml.compose(secret_file.read_text())
if isinstance(node,yaml.MappingNode):
for _,v in node.value:collect(v)
self.logs.clear()
self.job={'file':profile.name,'action':action,'target':target,'state':'running','started':time.time()}
self.task=asyncio.create_task(self.run(profile,action,target))
return dict(self.job)
async def run(self, profile, action, target):
env={**os.environ, 'PLATFORMIO_CORE_DIR':str(self.data/'platformio'), 'ESPHOME_BUILD_PATH':str(self.data/'build'/profile.stem), 'NO_COLOR':'1'}
try:
stages = ['config'] if action=='validate' else ['compile'] + (['upload'] if action=='install' else [])
for stage in stages:
cmd=['esphome']+(['--quiet'] if stage=='config' else [])+[stage,str(profile)]
if stage=='upload': cmd += ['--device',target]
self.logs.append('ESPHome: '+stage)
self.process=await asyncio.create_subprocess_exec(*cmd,cwd=self.root,env=env,stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.STDOUT,limit=1024*1024,start_new_session=True)
async with asyncio.timeout(7200):
async for line in self.process.stdout:
self.logs.append(self.redact(line.decode(errors='replace').rstrip()))
code=await self.process.wait()
if code: raise RuntimeError('ESPHome '+stage+' mislukt; bekijk het log.')
self.job['state']='success'; self.logs.append('Geslaagd: '+action)
except asyncio.CancelledError:
self.job['state']='interrupted'
raise
except Exception as error:
self.job['state']='failed';self.logs.append(self.redact(str(error)))
finally:
if self.process and self.process.returncode is None:
os.killpg(self.process.pid,signal.SIGTERM)
try: await asyncio.wait_for(self.process.wait(),10)
except TimeoutError: os.killpg(self.process.pid,signal.SIGKILL);await self.process.wait()
self.process=None
self.job['finished']=time.time()
+92 -3
View File
@@ -7,9 +7,12 @@ import os
from pathlib import Path from pathlib import Path
import secrets import secrets
import time import time
from datetime import datetime, timedelta, timezone
import math
from firmware import Firmware
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
from core import discover, installation_yaml, packets, state_message, validate_layout from core import discover, installation_yaml, packets, state_message, validate_layout, validate_settings
LOG = logging.getLogger('screen_manager') LOG = logging.getLogger('screen_manager')
@@ -23,6 +26,7 @@ class HomeAssistant:
self.registry, self.devices, self.areas = [], [], [] self.registry, self.devices, self.areas = [], [], []
self.online = False self.online = False
self.changed = asyncio.Event() self.changed = asyncio.Event()
self.setting_events = []
async def request(self, kind, **data): async def request(self, kind, **data):
if self.ws is None or self.ws.closed: if self.ws is None or self.ws.closed:
@@ -52,6 +56,9 @@ class HomeAssistant:
elif data.get('type') == 'event': elif data.get('type') == 'event':
event = data.get('event', {}) event = data.get('event', {})
body = event.get('data', {}) body = event.get('data', {})
if event.get('event_type') == 'esphome.screen_setting':
self.setting_events.append(body)
self.changed.set()
if event.get('event_type') == 'state_changed': if event.get('event_type') == 'state_changed':
eid = body.get('entity_id') eid = body.get('entity_id')
if body.get('new_state'): if body.get('new_state'):
@@ -79,6 +86,7 @@ class HomeAssistant:
raise ConnectionError('Home Assistant-authenticatie mislukt.') raise ConnectionError('Home Assistant-authenticatie mislukt.')
reader = asyncio.create_task(self.read()) reader = asyncio.create_task(self.read())
await self.request('subscribe_events', event_type='state_changed') await self.request('subscribe_events', event_type='state_changed')
await self.request('subscribe_events', event_type='esphome.screen_setting')
self.states = {s['entity_id']: s for s in await self.request('get_states')} self.states = {s['entity_id']: s for s in await self.request('get_states')}
await self.registries() await self.registries()
self.online = True self.online = True
@@ -112,10 +120,41 @@ class HomeAssistant:
await self.request('call_service', domain='text', service='set_value', await self.request('call_service', domain='text', service='set_value',
service_data={'entity_id': inbox, 'value': packet}) service_data={'entity_id': inbox, 'value': packet})
async def history(self, entity, hours):
start = (datetime.now(timezone.utc)-timedelta(hours=hours)).isoformat()
async with self.session.get(self.base+'/history/period/'+start,
params={'filter_entity_id':entity,'minimal_response':'','no_attributes':''},
headers={'Authorization':'Bearer '+self.token}) as response:
response.raise_for_status()
raw = bytearray()
async for chunk in response.content.iter_chunked(65536):
raw.extend(chunk)
if len(raw)>2*1024*1024: raise ValueError('Geschiedenis te groot.')
rows=json.loads(raw)
samples=[]; events=[]
begin=datetime.fromisoformat(start).timestamp(); span=hours*3600
for row in rows[0] if rows else []:
try:
timestamp=datetime.fromisoformat(row.get('last_changed',row.get('last_updated','')).replace('Z','+00:00')).timestamp()
except (ValueError,TypeError): continue
try:
value=float(row['state']); value=round(value,3) if math.isfinite(value) else None
except (ValueError,TypeError,KeyError): value=None
events.append((timestamp,value))
events.sort(key=lambda pair:pair[0]);position=0;value=None
for bucket in range(24):
boundary=begin+(bucket+1)*span/24
while position<len(events) and events[position][0]<=boundary:
value=events[position][1];position+=1
samples.append(value)
return samples
class Manager: class Manager:
def __init__(self, ha, path): def __init__(self, ha, path):
self.ha, self.path = ha, Path(path) self.ha, self.path = ha, Path(path)
self.layouts, self.sent, self.status, self.last = {}, {}, {}, {} self.layouts, self.sent, self.status, self.last = {}, {}, {}, {}
self.histories = {}
self.firmware = Firmware(os.environ.get("ESPHOME_CONFIG", "/homeassistant/esphome"), self.path.parent)
if self.path.exists(): if self.path.exists():
raw = json.loads(self.path.read_text()) raw = json.loads(self.path.read_text())
# Versioned persistent data. Never silently overwrite an unknown schema. # Versioned persistent data. Never silently overwrite an unknown schema.
@@ -133,6 +172,10 @@ class Manager:
# A still-open older UI may save tiles without the new optional settings. # 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, {}): if 'settings' not in layout and 'settings' in self.layouts.get(inbox, {}):
layout['settings'] = self.layouts[inbox]['settings'].copy() layout['settings'] = self.layouts[inbox]['settings'].copy()
old_tiles = {t['entity']:t for t in self.layouts.get(inbox,{}).get('tiles',[])}
for tile in layout['tiles']:
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]}
if any(t['entity'] not in known for t in layout['tiles']): 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.') raise ValueError('Een gekozen entiteit bestaat niet meer. Zoek de nieuwe entiteit op.')
@@ -151,10 +194,21 @@ class Manager:
self.ha.changed.set() self.ha.changed.set()
async def sync_one(self, inbox, layout, force=False): async def sync_one(self, inbox, layout, force=False):
messages = [{'v': 1, 'op': 'layout', 'title': layout['title'], 'entities': [t['entity'] for t in layout['tiles']]}] messages = [{'v': 1, 'op': 'layout', 'inbox': inbox, 'title': layout['title'], 'entities': [t['entity'] for t in layout['tiles']]}]
if 'settings' in layout: if 'settings' in layout:
messages[0]['settings'] = layout['settings'] messages[0]['settings'] = layout['settings']
messages += [state_message(i, tile, self.ha.states) for i, tile in enumerate(layout['tiles'])] for i,tile in enumerate(layout['tiles']):
message=state_message(i,tile,self.ha.states)
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]}
messages.append(message)
previous = self.sent.get(inbox, []) previous = self.sent.get(inbox, [])
force = force or not previous or messages[0] != previous[0] force = force or not previous or messages[0] != previous[0]
for i, message in enumerate(messages): for i, message in enumerate(messages):
@@ -179,6 +233,19 @@ class Manager:
if not self.ha.online: if not self.ha.online:
self.sent.clear() self.sent.clear()
continue continue
for event in getattr(self.ha,'setting_events',[])[:]:
try:
inbox,key,value=event.get('inbox'),event.get('key'),event.get('value')
if inbox in self.layouts:
layout=dict(self.layouts[inbox]); settings=validate_settings(layout.get('settings',{}))
if key not in settings: continue
settings[key] = value=='1' if type(settings[key]) is bool else int(value)
if key=='brightness':
settings['standby_brightness']=min(settings['standby_brightness'],settings[key])
settings['night_brightness']=min(settings['night_brightness'],settings[key])
layout['settings']=validate_settings(settings);self.save(inbox,layout)
except (ValueError,TypeError): pass
if hasattr(self.ha,'setting_events'): self.ha.setting_events.clear()
for screen in self.inventory()[0]: for screen in self.inventory()[0]:
inbox = screen['id'] inbox = screen['id']
if not screen['online']: if not screen['online']:
@@ -238,6 +305,28 @@ def create_app(manager, development=False):
data = await request.json() data = await request.json()
content = installation_yaml(data) content = installation_yaml(data)
return web.Response(text=content, content_type='text/yaml', headers={'Content-Disposition': f'attachment; filename="{data["name"]}.yaml"'}) return web.Response(text=content, content_type='text/yaml', headers={'Content-Disposition': f'attachment; filename="{data["name"]}.yaml"'})
async def inspector(request):
inbox=request.match_info['inbox']
if inbox not in {s['id'] for s in manager.inventory()[0]}: raise ValueError('Onbekend scherm.')
layout=manager.layouts.get(inbox,{'tiles':[]})
return web.json_response({'screen':next(s for s in manager.inventory()[0] if s['id']==inbox),
'delivery':manager.status.get(inbox), 'layout':layout,
'tiles':[{'entity':t['entity'],'state':manager.ha.states.get(t['entity'],{}).get('state'),
'attributes':state_message(i,t,manager.ha.states)['a'],
'options':t.get('options',{})} for i,t in enumerate(layout['tiles'])]})
async def firmware_status(request): return web.json_response(manager.firmware.status())
async def firmware_start(request): return web.json_response(manager.firmware.start(await request.json()))
async def firmware_create(request): return web.json_response(manager.firmware.create(await request.json()))
async def shutdown(app):
task=manager.firmware.task
if task and not task.done():
task.cancel()
with contextlib.suppress(asyncio.CancelledError): await task
app.on_cleanup.append(shutdown)
app.router.add_get('/api/screens/{inbox}/inspect', inspector)
app.router.add_get('/api/firmware', firmware_status)
app.router.add_post('/api/firmware/jobs', firmware_start)
app.router.add_post('/api/firmware/profiles', firmware_create)
app.router.add_get('/', index) app.router.add_get('/', index)
app.router.add_get('/api/inventory', inventory) app.router.add_get('/api/inventory', inventory)
app.router.add_put('/api/screens/{inbox}', save) app.router.add_put('/api/screens/{inbox}', save)
+210 -1
View File
@@ -201,7 +201,7 @@ function renderTiles() {
const li = node("li", undefined, "tile"); const li = node("li", undefined, "tile");
li.draggable = true; li.draggable = true;
li.ondragstart = (e) => { li.ondragstart = (e) => {
if (e.target instanceof HTMLInputElement) { if (e.target.closest("input, select, button")) {
e.preventDefault(); e.preventDefault();
return; return;
} }
@@ -255,6 +255,94 @@ function renderTiles() {
b.onclick = action; b.onclick = action;
actions.append(b); actions.append(b);
} }
const options = node("details", undefined, "tile-options");
options.append(node("summary", "Tegelinstellingen"));
const domain = tile.entity.split(".")[0];
const fields = [
[
"tap",
"Bij aantikken",
[
["auto", "Automatisch"],
["detail", "Bediening openen"],
["none", "Alleen bekijken"],
],
],
[
"display",
"Weergave",
[
["standard", "Naam en status"],
["watch", "Grote waarde"],
],
],
];
if (
["light", "switch", "input_boolean", "fan", "media_player"].includes(
domain,
)
)
fields[0][2].push(["toggle", "Aan / uit"]);
if (
[
"light",
"fan",
"cover",
"number",
"input_number",
"media_player",
].includes(domain)
)
fields.push([
"inline",
"Op de tegel",
[
["none", "Geen extra bediening"],
["slider", "Mini-schuif"],
],
]);
if (domain === "sensor")
fields.push([
"history_hours",
"Geschiedenis",
[
[1, "1 uur"],
[6, "6 uur"],
[24, "24 uur"],
],
]);
for (const [key, title, choices] of fields) {
const label = node("label", title),
input = node("select");
for (const [value, text] of choices) {
const option = node("option", text);
option.value = value;
input.append(option);
}
input.value =
tile.options?.[key] ??
(key === "tap"
? "auto"
: key === "display"
? "standard"
: key === "history_hours"
? 24
: "none");
input.onchange = () => {
tile.options = {
...tile.options,
[key]: key === "history_hours" ? Number(input.value) : input.value,
};
if (key === "display" && input.value === "watch")
tile.options.inline = "none";
if (key === "inline" && input.value === "slider")
tile.options.display = "standard";
markDirty();
};
label.append(input);
options.append(label);
}
content.append(options);
li.append(node("span", "⠿", "grip"), content, actions); li.append(node("span", "⠿", "grip"), content, actions);
$("#tiles").append(li); $("#tiles").append(li);
}); });
@@ -356,6 +444,10 @@ for (const [value, label] of [
["scene", "Scènes"], ["scene", "Scènes"],
["vacuum", "Vacuum"], ["vacuum", "Vacuum"],
["sensor", "Sensoren"], ["sensor", "Sensoren"],
["media_player", "Media"],
["weather", "Weer"],
["number", "Waarden"],
["select", "Keuzelijsten"],
]) { ]) {
const b = node("button", label, value === "" ? "active" : ""); const b = node("button", label, value === "" ? "active" : "");
b.onclick = () => { b.onclick = () => {
@@ -430,3 +522,120 @@ window.addEventListener("beforeunload", (e) => {
}); });
refresh(); refresh();
setInterval(refresh, 10000); setInterval(refresh, 10000);
// Shared firmware workspace; always select a concrete profile and upload target.
let firmwarePoll;
async function firmwareRefresh(initial = false) {
try {
const data = await (await api("firmware")).json();
if (initial) {
$("#firmware-file").replaceChildren(
...data.profiles.map((p) => {
const o = node("option", p.file);
o.value = p.file;
return o;
}),
);
$("#firmware-port").replaceChildren(
...[["ota", "Wifi / OTA"], ...data.ports.map((p) => [p, p])].map(
([value, text]) => {
const o = node("option", text);
o.value = value;
return o;
},
),
);
}
const running = data.job?.state === "running";
for (const id of ["validate", "build", "install"])
$("#firmware-" + id).disabled =
running || !data.available || !data.profiles.length;
$("#firmware-status").textContent = !data.available
? "ESPHome CLI ontbreekt. Werk de app bij naar 0.2.0."
: data.job
? `${data.job.file} · ${data.job.action} · ${data.job.state}`
: "Kies het bedoelde profiel en een USB-poort of IP-adres.";
$("#firmware-log").textContent = data.logs.join("\n");
} catch (e) {
toast(e.message);
}
}
$("#open-firmware").onclick = () => {
$("#firmware-dialog").showModal();
firmwareRefresh(true);
clearInterval(firmwarePoll);
firmwarePoll = setInterval(() => firmwareRefresh(), 3000);
};
$("#close-firmware").onclick = () => {
$("#firmware-dialog").close();
clearInterval(firmwarePoll);
};
$("#firmware-dialog").addEventListener("close", () =>
clearInterval(firmwarePoll),
);
$("#firmware-port").onchange = () =>
($("#firmware-host-label").hidden = $("#firmware-port").value !== "ota");
$("#firmware-file").onchange = () => {
$("#firmware-host").placeholder = $("#firmware-file").value.replace(
/\.yaml$/,
".local",
);
};
for (const action of ["validate", "build", "install"])
$("#firmware-" + action).onclick = async () => {
try {
await api("firmware/jobs", {
method: "POST",
body: JSON.stringify({
file: $("#firmware-file").value,
action:
action === "validate"
? "validate"
: action === "build"
? "build"
: "install",
target:
$("#firmware-port").value === "ota"
? $("#firmware-host").value.trim()
: $("#firmware-port").value,
}),
});
await firmwareRefresh();
} catch (e) {
toast(e.message);
}
};
$("#create-profile").onclick = async () => {
const form = $("#install-form");
if (!form.reportValidity()) return;
try {
const data = await (
await api("firmware/profiles", {
method: "POST",
body: JSON.stringify(Object.fromEntries(new FormData(form))),
})
).json();
generatedName = data.file.replace(/\.yaml$/, " ").trim();
$("#yaml-output").value = data.yaml;
$("#yaml-result").hidden = false;
$("#install-status").textContent =
`${data.file} bewaard. Open Firmware & USB om te installeren.`;
form.elements.wifi_password.value = "";
} catch (e) {
toast(e.message);
}
};
$("#inspect").onclick = async () => {
if (!selected) return;
try {
$("#inspection").textContent = JSON.stringify(
await (
await api(`screens/${encodeURIComponent(selected)}/inspect`)
).json(),
null,
2,
);
} catch (e) {
toast(e.message);
}
};
+76 -27
View File
@@ -15,6 +15,7 @@
<strong>ESP Screens</strong><small>Je huis. Jouw bediening.</small> <strong>ESP Screens</strong><small>Je huis. Jouw bediening.</small>
</div> </div>
</div> </div>
<button id="open-firmware" class="quiet">Firmware & USB</button>
<button id="new-screen">+ Nieuw scherm</button> <button id="new-screen">+ Nieuw scherm</button>
</header> </header>
<main> <main>
@@ -99,6 +100,16 @@
Gelijke begin- en eindtijd schakelen het nachtvenster uit. Gelijke begin- en eindtijd schakelen het nachtvenster uit.
</p> </p>
</details> </details>
<details class="screen-settings">
<summary>
Inspector
<span class="hint"
>Verbinding, status en ondersteunde eigenschappen</span
>
</summary>
<button id="inspect" class="quiet">Lees actuele gegevens</button>
<pre id="inspection">Selecteer een scherm en lees de gegevens.</pre>
</details>
<footer class="savebar"> <footer class="savebar">
<div> <div>
<strong id="dirty">Alles opgeslagen</strong <strong id="dirty">Alles opgeslagen</strong
@@ -111,12 +122,9 @@
</section> </section>
</div> </div>
<p class="firmware-link"> <p class="firmware-link">
<a href="/5c53de3b_esphome" target="_top" Firmware bouwen en via USB of wifi installeren kan via
>Firmware bijwerken in ESPHome ↗</a <b>Firmware & USB</b> bovenaan. Je bestaande YAML en secrets blijven
><small bewaard.
>Open je bestaande apparaat → Install → Wirelessly. Je tegels en
sleutels blijven behouden.</small
>
</p> </p>
<details class="help"> <details class="help">
<summary>Hoe werkt het dagelijks?</summary> <summary>Hoe werkt het dagelijks?</summary>
@@ -125,12 +133,13 @@
<b>Opslaan & naar scherm</b>. De app houdt de status bij. Voor een <b>Opslaan & naar scherm</b>. De app houdt de status bij. Voor een
kleurenlamp: houd de tegel ingedrukt voor kleur, temperatuur en kleurenlamp: houd de tegel ingedrukt voor kleur, temperatuur en
helderheid. Climate, vacuum en cover openen hun kaart direct. Sensoren helderheid. Climate, vacuum en cover openen hun kaart direct. Sensoren
tonen alleen hun waarde. openen hun HA-geschiedenis met één tik. Per tegel kies je een grote
waarde, mini-schuif of klikgedrag.
</p> </p>
<p> <p>
Alleen nieuwe firmware vraagt om een installatie via ESPHome Device Nieuwe firmware bouw en installeer je hier met de ingebouwde ESPHome
Builder. Je tegels blijven in deze app bewaard. Een offline scherm CLI. Je tegels blijven in deze app bewaard. Een offline scherm krijgt
krijgt de wijzigingen zodra het terug is. de wijzigingen zodra het terug is.
</p> </p>
</details> </details>
</main> </main>
@@ -185,7 +194,21 @@
>Kleine letters, cijfers en streepjes. Elk scherm een andere >Kleine letters, cijfers en streepjes. Elk scherm een andere
naam.</small naam.</small
></label ></label
><button type="submit" id="download">Maak installatie-YAML</button> ><label
>Wifi-naam (alleen bij eerste installatie)<input
name="wifi_ssid"
autocomplete="off"
/></label>
<label
>Wifiwachtwoord<input
name="wifi_password"
type="password"
autocomplete="new-password"
/></label>
<button type="button" id="create-profile">
Bewaar profiel in ESP Screens
</button>
<button type="submit" id="download">Maak installatie-YAML</button>
<p id="install-status" role="status"></p> <p id="install-status" role="status"></p>
<div id="yaml-result" hidden> <div id="yaml-result" hidden>
<label <label
@@ -205,36 +228,62 @@
</div> </div>
<ol class="steps"> <ol class="steps">
<li> <li>
<b>Open ESPHome Device Builder.</b> Voeg een nieuw apparaat toe, sla <b>Bewaar je profiel.</b> Bestaande wifi-secrets worden behouden;
de eerste installatie over en vervang de inhoud via Edit door het bij een eerste installatie vul je wifi hierboven in.
gedownloade bestand.
</li> </li>
<li> <li>
<b>Vul wifi eenmalig in bij Secrets.</b> Gebruik <b>Open Firmware & USB.</b> Kies dit profiel, sluit het scherm op je
<code>wifi_ssid</code> en <code>wifi_password</code>. Raspberry aan en selecteer de juiste USB-poort. Kies Bouwen &
installeren.
</li> </li>
<li> <li>
<b>Sluit USB aan op je Home Assistant Raspberry.</b> Klik Install → <b>Koppel in HA → Apparaten & diensten → ESPHome.</b> Gebruik de
Plug into the computer running ESPHome Device Builder en kies de API-sleutel uit je eigen YAML en sta HA-acties toe.
USB-poort.
</li> </li>
<li> <li>
<b>Koppel in Instellingen → Apparaten & diensten → ESPHome.</b> <b>Kies hier je tegels.</b> CYD vraagt bij de eerste start om
Gebruik de <code>api.encryption.key</code> uit je bestand en sta kalibratie; Guition niet.
Home Assistant-acties toe bij Configureren.
</li> </li>
<li> <li>
<b>Open deze pagina en kies je tegels.</b> CYD toont bij de eerste <b>Later updaten?</b> Kies hetzelfde profiel en het IP-adres van je
start een kalibratie: tik ieder kruisje drie keer. Guition hoeft scherm in Firmware & USB.
niet gekalibreerd.
</li> </li>
</ol> </ol>
<p class="hint"> <p class="hint">
Bewaar je YAML met sleutels. Gebruik later Install → Wirelessly voor Bewaar je YAML met sleutels. Gebruik later Firmware & USB met het
firmware-updates. Tegels wijzigen doe je hier. IP-adres voor draadloze updates. Tegels wijzigen doe je hier.
</p> </p>
</form> </form>
</dialog> </dialog>
<dialog id="firmware-dialog">
<div class="dialog-head">
<h2>Firmware & USB</h2>
<button id="close-firmware" class="quiet">Sluiten</button>
</div>
<p>
De ingebouwde ESPHome CLI gebruikt je eigen YAML en secrets. Tegels
blijven in ESP Screens staan. Op de Raspberry kan de eerste build enkele
minuten duren.
</p>
<label>ESPHome-profiel<select id="firmware-file"></select></label>
<label
>Installeren naar<select id="firmware-port">
<option value="ota">Wifi / OTA</option>
</select></label
>
<label id="firmware-host-label"
>IP-adres of hostnaam<input
id="firmware-host"
placeholder="scherm-woonkamer.local"
/></label>
<div class="yaml-actions">
<button id="firmware-validate" class="quiet">Controleren</button
><button id="firmware-build" class="quiet">Alleen bouwen</button
><button id="firmware-install">Bouwen & installeren</button>
</div>
<p id="firmware-status" role="status"></p>
<pre id="firmware-log"></pre>
</dialog>
<div id="toast" role="status" hidden></div> <div id="toast" role="status" hidden></div>
</body> </body>
</html> </html>
+39
View File
@@ -616,3 +616,42 @@ textarea {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
.tile-options {
margin-top: 10px;
font-size: 13px;
}
.tile-options label {
display: block;
margin: 8px 0;
}
select {
width: 100%;
padding: 10px;
border: 1px solid #d8dfe5;
border-radius: 9px;
background: white;
color: #152331;
font: inherit;
}
pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
max-height: 340px;
overflow: auto;
background: #f0f3f6;
border-radius: 12px;
padding: 16px;
font-size: 12px;
}
#firmware-dialog {
max-width: 780px;
width: 90vw;
}
.tile-actions {
align-self: start;
}
.tile .tile-options input,
.tile .tile-options select {
cursor: auto;
}
+6 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager name: ESP Screen Manager
version: "0.1.2" version: "0.2.0"
slug: esp_screen_manager slug: esp_screen_manager
description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen. description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen.
url: https://github.com/MaxGramser/homeassistant_espscreen url: https://github.com/MaxGramser/homeassistant_espscreen
@@ -15,3 +15,8 @@ panel_title: ESP Screens
panel_admin: true panel_admin: true
options: {} options: {}
schema: {} schema: {}
map:
- homeassistant_config:rw
uart: true
usb: true
+3
View File
@@ -0,0 +1,3 @@
#include "components/smart_display/runtime_model.h"
#include <cassert>
int main(){runtime_tiles::Tile t;t.revision="off";t.begin(100);assert(t.loading(200));t.observe("off");assert(!t.confirmed);t.observe("on");assert(t.confirmed);assert(t.loading(999));assert(!t.loading(1100));t.begin(2000);t.observe("on");assert(t.loading(7999));assert(!t.loading(8000));t.begin(0xFFFFFFF0);assert(t.loading(20));t.begin(100,true);assert(t.loading(1099));assert(!t.loading(1100));}
+56
View File
@@ -0,0 +1,56 @@
import asyncio
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'screen_manager/app'))
from core import validate_layout, state_message, packets
from firmware import Firmware
from test_portal import ManagerTests
class ExtendedTests(unittest.TestCase):
def test_options_validation_and_new_domains(self):
for domain in ['sensor','number','input_number','select','weather','media_player']:
layout=validate_layout({'title':'Test','tiles':[{'entity':domain+'.x','options':{'display':'watch'}}]})
self.assertEqual(layout['tiles'][0]['options']['display'],'watch')
for options in [{'inline':'slider'},{'tap':'toggle'},{'history_hours':True},{'history_hours':100},{'arbitrary':'run'}]:
with self.assertRaises(ValueError):validate_layout({'title':'Test','tiles':[{'entity':'sensor.x','options':options}]})
with self.assertRaises(ValueError):validate_layout({'title':'Test','tiles':[{'entity':'light.x','options':{'display':'watch','inline':'slider'}}]})
def test_attributes_and_history_fit_protocol(self):
tile={'entity':'sensor.a','name':'Temperatuur','options':{'history_hours':24,'display':'watch'}}
msg=state_message(0,tile,{'sensor.a':{'state':'21','attributes':{'unit_of_measurement':'°C','password':'hidden'}}})
msg['history']={'hours':24,'values':[None,21.5]*12}
self.assertNotIn('password',msg['a']);self.assertEqual(msg['o'],tile['options']);self.assertTrue(all(len(p)<=255 for p in packets(msg)))
def test_firmware_profiles_no_overwrite_or_traversal(self):
with tempfile.TemporaryDirectory() as tmp:
f=Firmware(Path(tmp)/'config',Path(tmp)/'data');data={'board':'cyd','name':'test','friendly_name':'Test','wifi_ssid':'local-wifi','wifi_password':'private-wifi'}
result=f.create(data);before=f.profile('test.yaml').read_text();secrets=(f.root/'secrets.yaml').read_text()
with self.assertRaises(ValueError):f.create(data)
self.assertEqual(before,f.profile('test.yaml').read_text());self.assertEqual(secrets,(f.root/'secrets.yaml').read_text())
for value in ['../secrets.yaml','/etc/passwd','test.yaml;echo']:
with self.assertRaises(ValueError):f.profile(value)
(f.root/'linked.yaml').symlink_to(f.profile('test.yaml'))
with self.assertRaises(ValueError):f.profile('linked.yaml')
self.assertEqual(f.profiles(),[{'file':'test.yaml'}])
class FirmwareJobs(unittest.IsolatedAsyncioTestCase):
async def test_cli_arguments_redaction_and_busy(self):
with tempfile.TemporaryDirectory() as tmp:
f=Firmware(tmp,tmp);f.create({'board':'guition','name':'test','friendly_name':'Test','wifi_ssid':'homewifi','wifi_password':'secretwifi'})
async def waiting(*args):await asyncio.sleep(20)
with patch('firmware.shutil.which',return_value='/bin/esphome'),patch.object(f,'run',side_effect=waiting):
with self.assertRaises(ValueError):f.start({'file':'test.yaml','action':'install','target':'1.2.3.4;evil'})
with self.assertRaises(ValueError):f.start({'file':'test.yaml','action':'install','target':'/dev/unknown'})
f.start({'file':'test.yaml','action':'validate'})
self.assertNotIn('secretwifi',f.redact('Using secretwifi on homewifi'))
with self.assertRaises(ValueError):f.start({'file':'test.yaml','action':'build'})
f.task.cancel()
try:await f.task
except asyncio.CancelledError:pass
async def test_old_browser_preserves_new_tile_options(self):
with tempfile.TemporaryDirectory() as tmp:
m=ManagerTests().setup_manager(Path(tmp)/'screens.json')
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'})