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.
+
Alles opgeslagen
Hoe werkt het dagelijks? @@ -125,12 +133,13 @@ Opslaan & naar scherm. De app houdt de status bij. Voor een kleurenlamp: houd de tegel ingedrukt voor kleur, temperatuur en 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.

- Alleen nieuwe firmware vraagt om een installatie via ESPHome Device - Builder. Je tegels blijven in deze app bewaard. Een offline scherm - krijgt de wijzigingen zodra het terug is. + Nieuwe firmware bouw en installeer je hier met de ingebouwde ESPHome + CLI. Je tegels blijven in deze app bewaard. Een offline scherm krijgt + de wijzigingen zodra het terug is.

@@ -185,7 +194,21 @@ >Kleine letters, cijfers en streepjes. Elk scherm een andere naam. + > + + +