From f898863b1f226d9086e5a657bf4dee1fbc0061fb Mon Sep 17 00:00:00 2001
From: Max Gramser
Date: Sat, 12 Sep 2026 15:41:54 +0200
Subject: [PATCH] Add rich runtime tiles and integrated ESPHome firmware
management
---
README.md | 5 +-
components/smart_display/light_controls.h | 4 +-
components/smart_display/runtime_model.h | 15 +-
components/smart_display/runtime_tiles.h | 191 +++++++++++++++++++-
docs/EASY_SETUP.md | 8 +
docs/RELEASING.md | 16 ++
docs/WHATS_NEW_020.md | 71 ++++++++
guition-4848s040.yaml | 103 ++++++++++-
home-like-2432s028.yaml | 99 +++++++++-
packages/cyd.yaml | 99 +++++++++-
packages/guition.yaml | 103 ++++++++++-
screen_manager/CHANGELOG.md | 10 +
screen_manager/DOCS.md | 32 ++--
screen_manager/Dockerfile | 8 +-
screen_manager/app/core.py | 28 ++-
screen_manager/app/firmware.py | 129 +++++++++++++
screen_manager/app/server.py | 95 +++++++++-
screen_manager/app/static/app.js | 211 +++++++++++++++++++++-
screen_manager/app/static/index.html | 103 ++++++++---
screen_manager/app/static/style.css | 39 ++++
screen_manager/config.yaml | 7 +-
tests/test_action_feedback.cpp | 3 +
tests/test_extended.py | 56 ++++++
23 files changed, 1346 insertions(+), 89 deletions(-)
create mode 100644 docs/WHATS_NEW_020.md
create mode 100644 screen_manager/app/firmware.py
create mode 100644 tests/test_action_feedback.cpp
create mode 100644 tests/test_extended.py
diff --git a/README.md b/README.md
index a6e09e0..0203173 100644
--- a/README.md
+++ b/README.md
@@ -2,12 +2,13 @@
**Nieuw scherm installeren? Begin met [Easy Setup](docs/EASY_SETUP.md).**
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.
Updates van de app behouden je indelingen. Nieuwe firmware installeer je via
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
computer. Voor de handmatige Guition-route: [Guition](docs/GUITION.md).
diff --git a/components/smart_display/light_controls.h b/components/smart_display/light_controls.h
index 3d3f3f4..9d78aad 100644
--- a/components/smart_display/light_controls.h
+++ b/components/smart_display/light_controls.h
@@ -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_INDICATOR);
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_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_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_obj_add_event_cb(row.slider, event, LV_EVENT_ALL, &row);
preview(row);
diff --git a/components/smart_display/runtime_model.h b/components/smart_display/runtime_model.h
index 6df9fdf..c84a72a 100644
--- a/components/smart_display/runtime_model.h
+++ b/components/smart_display/runtime_model.h
@@ -15,7 +15,7 @@ inline bool valid_entity(const std::string &entity) {
if (i != dot && !(entity[i] >= 'a' && entity[i] <= 'z') &&
!(entity[i] >= '0' && entity[i] <= '9') && entity[i] != '_') return false;
std::string domain = entity.substr(0, dot);
- for (const auto *allowed : {"light", "switch", "input_boolean", "scene", "script", "climate", "vacuum", "fan", "cover", "sensor", "binary_sensor", "input_select", "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;
return false;
}
@@ -27,6 +27,19 @@ struct Tile {
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;
bool received = false;
+ std::string tap = "auto", display = "standard", inline_control = "none", media_title;
+ std::array options;
+ unsigned option_count = 0, history_hours = 24;
+ std::array 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('.')); }
bool available() const { return received && state != "unknown" && state != "unavailable" && !state.empty(); }
bool active() const { return state == "on" || state == "cleaning" || (domain() == "climate" && available() && state != "off"); }
diff --git a/components/smart_display/runtime_tiles.h b/components/smart_display/runtime_tiles.h
index 61e09a7..a84d103 100644
--- a/components/smart_display/runtime_tiles.h
+++ b/components/smart_display/runtime_tiles.h
@@ -15,6 +15,10 @@ namespace runtime_tiles {
inline bool enabled = false;
inline bool light_theme = false;
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 uint32_t last_received = 0;
inline std::function layout_changed, refresh, dismiss, settings_changed;
@@ -42,7 +46,7 @@ inline bool parse_settings(JsonObject obj, screen_settings::Settings &s) {
return s.valid();
}
inline std::function 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;
inline bool fresh() { return model.ready() && esphome::millis() - last_received < 95000; }
inline float number(JsonVariant value, float fallback = NAN) {
@@ -84,6 +88,7 @@ inline std::string receive(const std::string &payload) {
if (!entity.is() || entities.size() == MAX_TILES) return false;
entities.push_back(entity.as());
}
+ inbox = string(root["inbox"], 160);
bool changed = false;
if (!model.set_layout(entities, string(root["title"], 96), changed)) return false;
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; }
Tile &tile = model.tiles[index];
auto a = root["a"].as();
+ 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()) {
+ tile.history.fill(NAN);unsigned j=0;
+ for (JsonVariant value:root["history"]["values"].as()) {
+ if(j==24) break; tile.history[j++]=number(value); }
+ tile.has_history=j>0;tile.history_hours=std::clamp(root["history"]["hours"].as(),1u,24u);
+ }
+ tile.option_count=0;
+ if (a["options"].is()) for(JsonVariant option:a["options"].as()) {
+ if(tile.option_count==8)break;tile.options[tile.option_count++]=string(option,48); }
+ tile.battery=number(a["battery_level"]);tile.volume=number(a["volume_level"]);
+ tile.media_title=string(a["media_title"],80);tile.supported=a["supported_features"].as();
tile.name = string(root["name"], 80);
tile.state = string(root["state"], 160);
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.maximum = number(a["max_temp"], 35);
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.hvac_modes = list(a["hvac_modes"]);
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.received = true;
+ widgets[index].cached_active=-1;
last_received = esphome::millis();
if (refresh) refresh();
if (active_index == static_cast(index) && detail_update) detail_update(tile);
+ refresh_detail(index);
result = model.ready() ? "Gesynchroniseerd" : "Tegels laden";
return true;
});
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;
esphome::api::HomeassistantActionRequest request;
request.service = esphome::StringRef(service);
- request.data.init(1);
+ request.data.init(key.empty() ? 1 : 2);
esphome::api::HomeassistantServiceMap entry;
entry.key = esphome::StringRef("entity_id");
entry.value = esphome::StringRef(entity);
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);
}
+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;iminimum?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;i80?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(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_LONG_PRESSED, &widgets[index]);
}
@@ -206,7 +360,22 @@ inline void render(lv_obj_t *room) {
else if (value == "cleaning") value = "Bezig";
else if (value == "docked") value = "In dock";
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();
if (w.cached_active == static_cast(on)) continue;
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_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_bg_color(w.circle, lv_color_hex(light_theme ? (on ? 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_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 ? (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.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) {
if (active_index < 0 || static_cast(active_index) >= model.count) return {};
auto &tile = model.tiles[active_index];
diff --git a/docs/EASY_SETUP.md b/docs/EASY_SETUP.md
index e654141..c77db6e 100644
--- a/docs/EASY_SETUP.md
+++ b/docs/EASY_SETUP.md
@@ -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
Met **ESP Screen Manager** kies je je tegels in Home Assistant. Je zoekt op
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
index bebebd2..c67f3b2 100644
--- a/docs/RELEASING.md
+++ b/docs/RELEASING.md
@@ -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
of versie niet zonder migratie. Alleen gewijzigde instellingen worden opgeslagen;
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.
diff --git a/docs/WHATS_NEW_020.md b/docs/WHATS_NEW_020.md
new file mode 100644
index 0000000..bda284c
--- /dev/null
+++ b/docs/WHATS_NEW_020.md
@@ -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.
diff --git a/guition-4848s040.yaml b/guition-4848s040.yaml
index a6e4468..2ff348d 100644
--- a/guition-4848s040.yaml
+++ b/guition-4848s040.yaml
@@ -554,8 +554,8 @@ substitutions:
OVERLAY_SLIDER_W: "144"
OVERLAY_SLIDER_H: "300"
OVERLAY_ICON_Y: "148"
- BG_TOP_COLOR: "0xF7F7F7"
- BG_BOTTOM_COLOR: "0xF7F7F7"
+ BG_TOP_COLOR: "0xECECEC"
+ BG_BOTTOM_COLOR: "0xECECEC"
TILE_ICON_X: "0"
TILE_ICON_Y: "12"
TILE_TITLE_X: "64"
@@ -582,7 +582,7 @@ esphome:
min_version: 2026.6.2
project:
name: maxgramser.esp_screen
- version: "0.1.3"
+ version: "0.2.0"
platformio_options:
build_flags:
- -DLV_USE_SNAPSHOT=1
@@ -613,11 +613,13 @@ esphome:
settings.night_end = atoi("${NIGHT_END_HOUR}") * 60;
settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}");
runtime_tiles::light_theme = true;
+ runtime_tiles::watch_font = id(headline)->get_lv_font();
runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true";
if (!runtime_tiles::enabled) return;
runtime_tiles::load_settings();
runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); };
runtime_tiles::dismiss = []() {
+ runtime_tiles::hide_detail();
id(active_entity).clear();
lv_obj_add_flag(id(brightness_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",
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);
+ - 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
then:
- script.execute: light_controls_preview
@@ -792,6 +804,9 @@ wifi:
captive_portal:
interval:
+ - interval: 250ms
+ then:
+ - lambda: runtime_tiles::tick();
- interval: 1s
then:
- if:
@@ -1308,7 +1323,7 @@ text_sensor:
- platform: template
name: "Schermfirmware"
entity_category: diagnostic
- lambda: 'return {"0.1.3"};'
+ lambda: 'return {"0.2.0"};'
update_interval: 60s
- platform: template
id: smartdisplay_action
@@ -4518,7 +4533,7 @@ script:
const int minute = now.is_valid() ? now.hour * 60 + now.minute : -1;
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();
- 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);
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);
@@ -5892,3 +5907,81 @@ text:
id: dashboard_inbox
name: "Tegelinstellingen"
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();
diff --git a/home-like-2432s028.yaml b/home-like-2432s028.yaml
index 9f0b25f..07fbc1a 100644
--- a/home-like-2432s028.yaml
+++ b/home-like-2432s028.yaml
@@ -777,7 +777,7 @@ esphome:
min_version: 2026.6.2
project:
name: maxgramser.esp_screen
- version: "0.1.3"
+ version: "0.2.0"
includes:
- cyd_ui.h
- light_controls.h
@@ -805,11 +805,13 @@ esphome:
settings.night_start = atoi("${NIGHT_START_HOUR}") * 60;
settings.night_end = atoi("${NIGHT_END_HOUR}") * 60;
settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}");
+ runtime_tiles::watch_font = id(headline)->get_lv_font();
runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true";
if (!runtime_tiles::enabled) return;
runtime_tiles::load_settings();
runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); };
runtime_tiles::dismiss = []() {
+ runtime_tiles::hide_detail();
id(active_entity).clear();
lv_obj_add_flag(id(brightness_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",
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);
+ - 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
then:
- script.execute: light_controls_preview
@@ -987,6 +999,9 @@ wifi:
captive_portal:
interval:
+ - interval: 250ms
+ then:
+ - lambda: runtime_tiles::tick();
- interval: 1s
then:
- if:
@@ -1498,7 +1513,7 @@ text_sensor:
- platform: template
name: "Schermfirmware"
entity_category: diagnostic
- lambda: 'return {"0.1.3"};'
+ lambda: 'return {"0.2.0"};'
update_interval: 60s
- platform: template
id: smartdisplay_action
@@ -4648,7 +4663,7 @@ script:
const int minute = now.is_valid() ? now.hour * 60 + now.minute : -1;
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();
- 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);
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);
@@ -6031,3 +6046,81 @@ button:
entity_category: config
on_press:
- 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();
diff --git a/packages/cyd.yaml b/packages/cyd.yaml
index ed605f9..3e7a860 100644
--- a/packages/cyd.yaml
+++ b/packages/cyd.yaml
@@ -781,7 +781,7 @@ esphome:
min_version: 2026.6.2
project:
name: maxgramser.esp_screen
- version: "0.1.3"
+ version: "0.2.0"
includes:
-
-
@@ -809,11 +809,13 @@ esphome:
settings.night_start = atoi("${NIGHT_START_HOUR}") * 60;
settings.night_end = atoi("${NIGHT_END_HOUR}") * 60;
settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}");
+ runtime_tiles::watch_font = id(headline)->get_lv_font();
runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true";
if (!runtime_tiles::enabled) return;
runtime_tiles::load_settings();
runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); };
runtime_tiles::dismiss = []() {
+ runtime_tiles::hide_detail();
id(active_entity).clear();
lv_obj_add_flag(id(brightness_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",
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);
+ - 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
then:
- script.execute: light_controls_preview
@@ -978,6 +990,9 @@ api:
captive_portal:
interval:
+ - interval: 250ms
+ then:
+ - lambda: runtime_tiles::tick();
- interval: 1s
then:
- if:
@@ -1451,7 +1466,7 @@ text_sensor:
- platform: template
name: "Schermfirmware"
entity_category: diagnostic
- lambda: 'return {"0.1.3"};'
+ lambda: 'return {"0.2.0"};'
update_interval: 60s
- platform: template
id: smartdisplay_action
@@ -4581,7 +4596,7 @@ script:
const int minute = now.is_valid() ? now.hour * 60 + now.minute : -1;
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();
- 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);
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);
@@ -5964,3 +5979,81 @@ button:
entity_category: config
on_press:
- 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();
diff --git a/packages/guition.yaml b/packages/guition.yaml
index f87052c..0934848 100644
--- a/packages/guition.yaml
+++ b/packages/guition.yaml
@@ -555,8 +555,8 @@ substitutions:
OVERLAY_SLIDER_W: "144"
OVERLAY_SLIDER_H: "300"
OVERLAY_ICON_Y: "148"
- BG_TOP_COLOR: "0xF7F7F7"
- BG_BOTTOM_COLOR: "0xF7F7F7"
+ BG_TOP_COLOR: "0xECECEC"
+ BG_BOTTOM_COLOR: "0xECECEC"
TILE_ICON_X: "0"
TILE_ICON_Y: "12"
TILE_TITLE_X: "64"
@@ -586,7 +586,7 @@ esphome:
min_version: 2026.6.2
project:
name: maxgramser.esp_screen
- version: "0.1.3"
+ version: "0.2.0"
platformio_options:
build_flags:
- -DLV_USE_SNAPSHOT=1
@@ -617,11 +617,13 @@ esphome:
settings.night_end = atoi("${NIGHT_END_HOUR}") * 60;
settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}");
runtime_tiles::light_theme = true;
+ runtime_tiles::watch_font = id(headline)->get_lv_font();
runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true";
if (!runtime_tiles::enabled) return;
runtime_tiles::load_settings();
runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); };
runtime_tiles::dismiss = []() {
+ runtime_tiles::hide_detail();
id(active_entity).clear();
lv_obj_add_flag(id(brightness_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",
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);
+ - 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
then:
- script.execute: light_controls_preview
@@ -783,6 +795,9 @@ api:
captive_portal:
interval:
+ - interval: 250ms
+ then:
+ - lambda: runtime_tiles::tick();
- interval: 1s
then:
- if:
@@ -1261,7 +1276,7 @@ text_sensor:
- platform: template
name: "Schermfirmware"
entity_category: diagnostic
- lambda: 'return {"0.1.3"};'
+ lambda: 'return {"0.2.0"};'
update_interval: 60s
- platform: template
id: smartdisplay_action
@@ -4451,7 +4466,7 @@ script:
const int minute = now.is_valid() ? now.hour * 60 + now.minute : -1;
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();
- 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);
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);
@@ -5825,3 +5840,81 @@ text:
id: dashboard_inbox
name: "Tegelinstellingen"
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();
diff --git a/screen_manager/CHANGELOG.md b/screen_manager/CHANGELOG.md
index 15b5fef..88bfd97 100644
--- a/screen_manager/CHANGELOG.md
+++ b/screen_manager/CHANGELOG.md
@@ -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
- Per scherm standbyduur, normale/standby/nachthelderheid en nachturen instellen.
diff --git a/screen_manager/DOCS.md b/screen_manager/DOCS.md
index 3a74fa9..c8bec95 100644
--- a/screen_manager/DOCS.md
+++ b/screen_manager/DOCS.md
@@ -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
-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.
+Zie [de volledige 0.2.0-handleiding](https://github.com/MaxGramser/homeassistant_espscreen/blob/main/docs/WHATS_NEW_020.md).
-De volledige handleiding staat op
-[GitHub: Easy Setup](https://github.com/MaxGramser/homeassistant_espscreen/blob/main/docs/EASY_SETUP.md).
+Open **Nieuw scherm** om een eigen profiel te bewaren. Open **Firmware & USB**
+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
-
-Update de app via de appwinkel. Indelingen staan in de permanente `/data`-map en
-blijven bij een update behouden. Verwijder de app niet om een update te installeren.
-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.
+Na koppeling via de HA ESPHome-integratie kies je in ESP Screens de tegels.
+Onder **Tegelinstellingen** staan klikgedrag, grotere waarden en mini-schuiven.
+Onder **Scherminstellingen** staan helderheid en standby. **Inspector** helpt bij
+ontbrekende attributen of een offline scherm. Nieuwe kaarten vereisen firmware 0.2.0.
diff --git a/screen_manager/Dockerfile b/screen_manager/Dockerfile
index c8c9fcf..a6e2f08 100644
--- a/screen_manager/Dockerfile
+++ b/screen_manager/Dockerfile
@@ -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_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
WORKDIR /app
-CMD ["/command/with-contenv", "python3", "/app/server.py"]
+ENTRYPOINT []
+CMD ["python3", "/app/server.py"]
diff --git a/screen_manager/app/core.py b/screen_manager/app/core.py
index ce53bb1..2735567 100644
--- a/screen_manager/app/core.py
+++ b/screen_manager/app/core.py
@@ -5,10 +5,10 @@ import math
import re
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'
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.
SETTING_RULES = {
@@ -66,7 +66,26 @@ def validate_layout(data):
if not isinstance(name, str) or len(name.encode()) > 80:
raise ValueError('Een tegelnaam mag maximaal 80 bytes bevatten.')
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}
if 'settings' in data:
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]
return {'v': 1, 'op': 'state', 'i': index, 'entity': tile['entity'],
'name': short(tile['name'] or attrs.get('friendly_name') or tile['entity'], 80),
- 'state': short(state.get('state', 'unavailable'), 160), 'a': bounded}
+ 'state': short(state.get('state', 'unavailable'), 160), 'a': bounded,
+ **({'o': tile['options']} if 'options' in tile else {})}
def packets(message, token=None):
raw = json.dumps(message, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode()
diff --git a/screen_manager/app/firmware.py b/screen_manager/app/firmware.py
new file mode 100644
index 0000000..73a7c0a
--- /dev/null
+++ b/screen_manager/app/firmware.py
@@ -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()
diff --git a/screen_manager/app/server.py b/screen_manager/app/server.py
index e9aed39..a69579a 100644
--- a/screen_manager/app/server.py
+++ b/screen_manager/app/server.py
@@ -7,9 +7,12 @@ import os
from pathlib import Path
import secrets
import time
+from datetime import datetime, timedelta, timezone
+import math
+from firmware import Firmware
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')
@@ -23,6 +26,7 @@ class HomeAssistant:
self.registry, self.devices, self.areas = [], [], []
self.online = False
self.changed = asyncio.Event()
+ self.setting_events = []
async def request(self, kind, **data):
if self.ws is None or self.ws.closed:
@@ -52,6 +56,9 @@ class HomeAssistant:
elif data.get('type') == 'event':
event = data.get('event', {})
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':
eid = body.get('entity_id')
if body.get('new_state'):
@@ -79,6 +86,7 @@ class HomeAssistant:
raise ConnectionError('Home Assistant-authenticatie mislukt.')
reader = asyncio.create_task(self.read())
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')}
await self.registries()
self.online = True
@@ -112,10 +120,41 @@ class HomeAssistant:
await self.request('call_service', domain='text', service='set_value',
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 position300:
+ 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, [])
force = force or not previous or messages[0] != previous[0]
for i, message in enumerate(messages):
@@ -179,6 +233,19 @@ class Manager:
if not self.ha.online:
self.sent.clear()
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]:
inbox = screen['id']
if not screen['online']:
@@ -238,6 +305,28 @@ def create_app(manager, development=False):
data = await request.json()
content = installation_yaml(data)
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('/api/inventory', inventory)
app.router.add_put('/api/screens/{inbox}', save)
diff --git a/screen_manager/app/static/app.js b/screen_manager/app/static/app.js
index c769329..ff7ecb3 100644
--- a/screen_manager/app/static/app.js
+++ b/screen_manager/app/static/app.js
@@ -201,7 +201,7 @@ function renderTiles() {
const li = node("li", undefined, "tile");
li.draggable = true;
li.ondragstart = (e) => {
- if (e.target instanceof HTMLInputElement) {
+ if (e.target.closest("input, select, button")) {
e.preventDefault();
return;
}
@@ -255,6 +255,94 @@ function renderTiles() {
b.onclick = action;
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);
$("#tiles").append(li);
});
@@ -356,6 +444,10 @@ for (const [value, label] of [
["scene", "Scènes"],
["vacuum", "Vacuum"],
["sensor", "Sensoren"],
+ ["media_player", "Media"],
+ ["weather", "Weer"],
+ ["number", "Waarden"],
+ ["select", "Keuzelijsten"],
]) {
const b = node("button", label, value === "" ? "active" : "");
b.onclick = () => {
@@ -430,3 +522,120 @@ window.addEventListener("beforeunload", (e) => {
});
refresh();
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);
+ }
+};
diff --git a/screen_manager/app/static/index.html b/screen_manager/app/static/index.html
index a7d6ca2..cafc8e0 100644
--- a/screen_manager/app/static/index.html
+++ b/screen_manager/app/static/index.html
@@ -15,6 +15,7 @@
ESP ScreensJe huis. Jouw bediening.
+
@@ -99,6 +100,16 @@
Gelijke begin- en eindtijd schakelen het nachtvenster uit.
+
+
+ Inspector
+ Verbinding, status en ondersteunde eigenschappen
+
+
+ Selecteer een scherm en lees de gegevens.
+