Add rich runtime tiles and integrated ESPHome firmware management

This commit is contained in:
Max Gramser
2026-09-12 15:41:54 +02:00
parent 535838989c
commit f898863b1f
23 changed files with 1346 additions and 89 deletions
+3 -2
View File
@@ -2,12 +2,13 @@
**Nieuw scherm installeren? Begin met [Easy Setup](docs/EASY_SETUP.md).**
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).
+2 -2
View File
@@ -135,11 +135,11 @@ inline void setup(lv_obj_t *parent, const lv_font_t *font, int width, int height
lv_obj_set_style_bg_opa(row.slider, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_TRANSP, LV_PART_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);
+14 -1
View File
@@ -15,7 +15,7 @@ inline bool valid_entity(const std::string &entity) {
if (i != dot && !(entity[i] >= 'a' && entity[i] <= 'z') &&
!(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<std::string, 8> options;
unsigned option_count = 0, history_hours = 24;
std::array<float,24> history{};
bool has_history = false;
float battery = NAN, volume = NAN;
uint32_t supported = 0;
std::string revision, pending_revision;
uint32_t pending_since = 0;
bool pending = false, confirmed = false, local_feedback = false;
bool loading(uint32_t now) const { return pending && (now-pending_since < 1000 || (!confirmed && !local_feedback && now-pending_since < 6000)); }
void begin(uint32_t now, bool local=false) { pending=true; pending_since=now; confirmed=false; local_feedback=local; pending_revision=revision; }
void observe(const std::string &next) { revision=next; if (pending && revision!=pending_revision) confirmed=true; }
std::string domain() const { return entity.substr(0, entity.find('.')); }
bool available() const { return received && state != "unknown" && state != "unavailable" && !state.empty(); }
bool active() const { return state == "on" || state == "cleaning" || (domain() == "climate" && available() && state != "off"); }
+183 -8
View File
@@ -15,6 +15,10 @@ namespace runtime_tiles {
inline bool enabled = false;
inline bool 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<void()> 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<void(Tile &)> detail, detail_update;
struct Widgets { lv_obj_t *tile{}, *title{}, *value{}, *circle{}, *icon{}; size_t index{}; int cached_active = -1; };
struct Widgets { lv_obj_t *tile{}, *title{}, *value{}, *circle{}, *icon{}; size_t index{}; int cached_active = -1; lv_obj_t *slider{}, *progress{}; int title_x=0,title_y=0,value_x=0,value_y=0; const lv_font_t *value_font{}; };
inline std::array<Widgets, MAX_TILES> widgets;
inline 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<const char *>() || entities.size() == MAX_TILES) return false;
entities.push_back(entity.as<std::string>());
}
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<JsonObject>();
std::string revision = string(root["state"]); serializeJson(a, revision);
tile.observe(revision);
auto options = root["o"];
tile.tap = string(options["tap"]); if (tile.tap.empty()) tile.tap="auto";
tile.display = string(options["display"]); if (tile.display.empty()) tile.display="standard";
tile.inline_control = string(options["inline"]); if (tile.inline_control.empty()) tile.inline_control="none";
tile.has_history=false;
if (root["history"]["values"].is<JsonArray>()) {
tile.history.fill(NAN);unsigned j=0;
for (JsonVariant value:root["history"]["values"].as<JsonArray>()) {
if(j==24) break; tile.history[j++]=number(value); }
tile.has_history=j>0;tile.history_hours=std::clamp(root["history"]["hours"].as<unsigned>(),1u,24u);
}
tile.option_count=0;
if (a["options"].is<JsonArray>()) for(JsonVariant option:a["options"].as<JsonArray>()) {
if(tile.option_count==8)break;tile.options[tile.option_count++]=string(option,48); }
tile.battery=number(a["battery_level"]);tile.volume=number(a["volume_level"]);
tile.media_title=string(a["media_title"],80);tile.supported=a["supported_features"].as<uint32_t>();
tile.name = string(root["name"], 80);
tile.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<int>(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;i<t.fan_speed_count;++i)detail_button(t.fan_speeds[i].c_str(),pad+(i%2)*(cw+gap),top+(i/2)*(large?42:24),cw,large?36:22,10+i);
}else if(d=="sensor"){
float minimum=INFINITY,maximum=-INFINITY;for(float value:t.history)if(t.has_history&&std::isfinite(value)){minimum=std::min(minimum,value);maximum=std::max(maximum,value);}
if(!std::isfinite(minimum)){detail_label(detail_root,"Geen numerieke HA-historie",pad,top,width-2*pad);return;}
char text[100];snprintf(text,sizeof(text),"%u uur · %.2f - %.2f %s",t.history_hours,minimum,maximum,t.unit.c_str());detail_label(detail_root,text,pad,top,width-2*pad);
int chart_y=top+(large?46:28),chart_h=height-chart_y-30,bar_w=(width-pad*2)/24;
for(unsigned i=0;i<24;++i){if(!std::isfinite(t.history[i]))continue;int h=maximum>minimum?8+(chart_h-8)*(t.history[i]-minimum)/(maximum-minimum):chart_h/2;
auto *bar=lv_obj_create(detail_root);lv_obj_remove_style_all(bar);lv_obj_set_pos(bar,pad+i*bar_w,chart_y+chart_h-h);lv_obj_set_size(bar,std::max(2,bar_w-2),h);lv_obj_set_style_bg_color(bar,lv_color_hex(0x16A5E6),0);lv_obj_set_style_bg_opa(bar,LV_OPA_COVER,0);}
detail_label(detail_root,"Eerder Nu",pad,height-24,width-2*pad);
}else if(d=="select"||d=="input_select"){
for(unsigned i=0;i<t.option_count;++i)detail_button(t.options[i].c_str(),pad+(i%2)*(cw+gap),top+(i/2)*(bh+gap),cw,bh,30+i);
}else if(d=="number"||d=="input_number"||d=="media_player"){
if(d=="media_player"){
detail_label(detail_root,t.media_title,pad,top,width-2*pad);top+=large?45:25;
int w=(width-pad*2-2*gap)/3;
detail_button("Vorige",pad,top,w,bh,21);detail_button("Play/pauze",pad+w+gap,top,w,bh,20);detail_button("Volgende",pad+2*(w+gap),top,w,bh,22);top+=bh+gap;
}
detail_label(detail_root,d=="media_player"?"Volume":"Waarde",pad,top,width-2*pad);
auto *slider=lv_slider_create(detail_root);lv_obj_set_pos(slider,pad+12,top+(large?52:34));lv_obj_set_size(slider,width-2*pad-24,large?24:16);lv_slider_set_range(slider,0,1000);lv_slider_set_value(slider,slider_value(t),LV_ANIM_OFF);lv_obj_set_style_bg_color(slider,lv_color_hex(0x111111),LV_PART_KNOB);
lv_obj_add_event_cb(slider,[](lv_event_t *e){if(cyd::touch_guard.accept(esphome::millis(),399))commit_slider(detail_index,lv_slider_get_value(lv_event_get_target_obj(e)));},LV_EVENT_RELEASED,nullptr);
}else if(d=="weather"){
char text[80];snprintf(text,sizeof(text),"%.1f %s",t.current,t.unit.c_str());auto *value=detail_label(detail_root,text,pad,top,width-2*pad);if(watch_font)lv_obj_set_style_text_font(value,watch_font,0);
}
}
}
namespace runtime_tiles {
inline void refresh_detail(unsigned index){
if(!detail_root || lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN) || detail_index!=index)return;
auto *input=lv_indev_get_next(nullptr);if(input && lv_indev_get_state(input)==LV_INDEV_STATE_PRESSED)return;
show_detail(index);
}
inline const char *icon_for(const Tile &tile) {
auto d = tile.domain();
if (d == "light") return "\U000F0335";
@@ -157,6 +294,7 @@ inline const char *icon_for(const Tile &tile) {
if (d == "fan") return "\U000F0210";
if (d == "cover") return "\U000F111C";
if (d == "scene" || d == "script") return "\U000F04B9";
if (d == "weather") return "\U000F029A";
if (d == "sensor" || d == "binary_sensor") return "\U000F029A";
return "\U000F0425";
}
@@ -169,13 +307,18 @@ inline void event(lv_event_t *event) {
auto &tile = model.tiles[w.index];
auto d = tile.domain();
// Scenes/scripts often have timestamps or 'off'; unavailable devices never act.
if (!tile.available()) return;
if (!tile.available() || tile.loading(esphome::millis()) || tile.tap=="none") return;
bool open = code == LV_EVENT_LONG_PRESSED || d == "climate" || d == "vacuum" || d == "cover";
if(code==LV_EVENT_SHORT_CLICKED && tile.tap=="detail")open=true;
if(code==LV_EVENT_SHORT_CLICKED && tile.tap=="toggle")open=false;
if(d=="media_player" && tile.tap=="toggle" && code==LV_EVENT_SHORT_CLICKED){action("media_player.toggle",tile.entity);return;}
if(d=="sensor" || d=="binary_sensor" || d=="weather" || d=="number" || d=="input_number" || d=="select" || d=="input_select" || d=="media_player" || d=="vacuum") { tile.begin(esphome::millis(),true); active_index=w.index; show_detail(w.index); return; }
if (open) {
if (d == "light" || d == "climate" || d == "vacuum" || d == "fan" || d == "cover") {
active_index = w.index;
tile.begin(esphome::millis(),true);
if (detail) detail(tile);
}
} else { active_index=w.index;tile.begin(esphome::millis(),true);show_detail(w.index); }
return;
}
if (d == "light" || d == "switch" || d == "input_boolean" || d == "fan") action(d + ".toggle", tile.entity);
@@ -183,7 +326,18 @@ inline void event(lv_event_t *event) {
if (d == "button" || d == "input_button") action(d + ".press", tile.entity);
}
inline void bind(size_t index, lv_obj_t *tile, lv_obj_t *title, lv_obj_t *value, lv_obj_t *circle, lv_obj_t *icon) {
lv_obj_update_layout(tile);
widgets[index] = {tile, title, value, circle, icon, index};
auto &w=widgets[index]; w.title_x=lv_obj_get_x(title);w.title_y=lv_obj_get_y(title);w.value_x=lv_obj_get_x(value);w.value_y=lv_obj_get_y(value);w.value_font=lv_obj_get_style_text_font(value,LV_PART_MAIN);
// Fixed one-line boxes prevent wrapped names from overlapping the state on both boards.
lv_obj_set_height(title,lv_font_get_line_height(lv_obj_get_style_text_font(title,LV_PART_MAIN)));
lv_obj_set_height(value,lv_font_get_line_height(w.value_font));
lv_label_set_long_mode(title,LV_LABEL_LONG_DOT);lv_label_set_long_mode(value,LV_LABEL_LONG_DOT);
w.progress=lv_obj_create(tile);lv_obj_remove_style_all(w.progress);lv_obj_set_size(w.progress,0,3);lv_obj_align(w.progress,LV_ALIGN_BOTTOM_LEFT,0,0);lv_obj_set_style_bg_color(w.progress,lv_color_hex(0x00A6ED),0);lv_obj_set_style_bg_opa(w.progress,LV_OPA_COVER,0);
w.slider=lv_slider_create(tile);lv_obj_set_size(w.slider,lv_obj_get_width(tile)-24,lv_obj_get_height(tile)>80?10:4);lv_obj_align(w.slider,LV_ALIGN_BOTTOM_MID,0,-1);lv_slider_set_range(w.slider,0,1000);
lv_obj_set_style_bg_color(w.slider,lv_color_hex(0x111111),LV_PART_KNOB);lv_obj_set_style_pad_all(w.slider,3,LV_PART_KNOB);lv_obj_set_style_bg_color(w.slider,lv_color_hex(0xFCE5B4),LV_PART_MAIN);lv_obj_set_style_bg_color(w.slider,lv_color_hex(0xFFB900),LV_PART_INDICATOR);
lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN);
lv_obj_add_event_cb(w.slider,[](lv_event_t *e){auto &w=*static_cast<Widgets*>(lv_event_get_user_data(e));if(!fresh() || !cyd::touch_guard.accept(esphome::millis(),200+w.index))return;commit_slider(w.index,lv_slider_get_value(w.slider));},LV_EVENT_RELEASED,&w);
lv_obj_add_event_cb(tile, event, LV_EVENT_SHORT_CLICKED, &widgets[index]);
lv_obj_add_event_cb(tile, event, LV_EVENT_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<int>(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<size_t>(active_index) >= model.count) return {};
auto &tile = model.tiles[active_index];
+8
View File
@@ -1,3 +1,11 @@
# Installeren vanuit ESP Screens (vanaf 0.2.0)
De makkelijkste route is nu één app: **ESP Screen Manager**. Deze kan zelf
ESPHome-profielen bewaren, bouwen en via USB of OTA installeren. Zie de
[complete stappen voor deze route](WHATS_NEW_020.md#firmware-zonder-een-tweede-beheerpagina).
De instructies hieronder blijven bruikbaar als je ESPHome Device Builder wilt
blijven gebruiken. Beide routes behouden dezelfde eigen YAML en secrets.
# Een nieuw scherm, van USB naar dagelijks gebruik
Met **ESP Screen Manager** kies je je tegels in Home Assistant. Je zoekt op
+16
View File
@@ -70,3 +70,19 @@ Firmware gebruikt een afzonderlijke preferences-key `0x53435231` met een vaste
versie-1 structuur (elf int32-velden en een uint32-versie). Wijzig key, structuur
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.
+71
View File
@@ -0,0 +1,71 @@
# ESP Screens 0.2.0
## Dagelijks bedienen
- Guition: iets donkerder lichtgrijze achtergrond, gekleurde accenten en zwarte
sliderknoppen. Namen en status hebben elk één eigen regel met afkapping.
- Actietegels tonen minimaal één seconde `Bezig...` en een voortgangsbalk.
Een ontvangen HA-statuswijziging beëindigt het wachten na die minimumtijd.
Zonder nieuwe status stopt de indicatie na zes seconden. Dit is feedback op
een statuswijziging, geen bewijs dat een fysieke actie geslaagd is.
- Tik op een sensor voor numerieke HA-geschiedenis. De app haalt 1, 6 of 24 uur
op en stuurt 24 tijdvakken; verversing maximaal eenmaal per vijf minuten.
Ontbrekende/niet-numerieke historie wordt aangegeven. Recorder moet de sensor
bewaren. Dit is een compacte grafiek, geen energieboekhouding.
- Vacuum: status, accu wanneer als attribuut beschikbaar, start/pauze/dock/zoeken
en maximaal vier zuigstanden. Media: vorige/play-pauze/volgende en volume.
- Getal-helpers en number-entiteiten krijgen een schuif binnen hun min/max/step;
select/input_select toont maximaal acht opties. Weather toont conditie en
temperatuur. Bestaande lamp-, RGB-, climate-, fan- en coverkaarten blijven.
Deze kaarten en tegelopties zijn beschikbaar voor Guition én CYD. De lichte
Guition-stijl is bordgebonden; de CYD behoudt zijn eigen compacte vormgeving.
## Instellingen per tegel
Open in ESP Screens **Tegelinstellingen** onder een gekozen entiteit:
- Bij aantikken: automatisch, bediening openen, alleen bekijken; aan/uit bij
daarvoor geschikte entiteiten.
- Weergave: normale kaart of **Grote waarde**, bijvoorbeeld temperatuur of kWh.
De kaart behoudt zijn plek in het raster; er komen geen kleinere aanraakvlakken.
- Mini-schuif: voor lampen, fans, covers, media en getalinstellingen. Een grotere
waarde en mini-schuif zijn alternatieven, zodat teksten niet overlappen.
- Sensor: geschiedenisperiode 1, 6 of 24 uur.
**Inspector** toont schermverbinding, firmware, laatste afleverstatus, entiteiten,
attributen die het scherm ontvangt en tegelopties. Geen wifi/API/OTA-sleutels.
## Instellingen bij het HA-apparaat
Bij **Instellingen → Apparaten & diensten → ESPHome → jouw scherm** staan onder
Configuratie de getalinstellingen **Helderheid normaal**, **Helderheid standby**,
**Helderheid nacht** en **Standby na** (seconden). Installeer firmware 0.2.0 en laat
HA opnieuw verbinden als ze ontbreken. De overige instellingen staan in ESP
Screens → Scherminstellingen. Wijzigingen via HA worden ook in ESP Screens
opgeslagen; sta HA-acties toe bij de ESPHome-integratie.
## Firmware zonder een tweede beheerpagina
Werk de app bij naar 0.2.0. Deze bevat de officiële ESPHome CLI 2026.6.2.
1. **Nieuw scherm** → bord/naam → **Bewaar profiel in ESP Screens**. Bij een
eerste installatie vul je wifi in. Bestaande secrets worden nooit vervangen.
Bewaar de getoonde YAML met de unieke sleutels als eigen back-up.
2. **Firmware & USB** → kies het profiel. De app leest dezelfde map
`/homeassistant/esphome` die in de HA-configschijf `esphome` heet.
3. Voor een eerste flash: scherm via USB aan de HA-Raspberry, kies expliciet de
USB-poort en **Bouwen & installeren**. Gebruik één bedoeld bord tegelijk.
4. Koppel het scherm via de gewone HA ESPHome-integratie. Gebruik de API-sleutel
uit je profiel en sta HA-acties toe. CYD kalibreert bij de eerste start.
5. Voor updates: hetzelfde profiel, **Wifi / OTA**, het IP-adres of de hostnaam
van dat scherm, **Bouwen & installeren**. Tegels en secrets blijven behouden.
De app voert één CLI-taak tegelijk uit. Validatie en bouwen uploaden niets.
Alleen installeren bouwt én uploadt naar het gekozen doel. Het log maskeert
secrets. De eerste download/build vraagt internet, schijfruimte en tijd. Bouwcache
staat in permanente appdata. Een browser sluiten stopt de build niet.
De bestaande ESPHome Device Builder mag blijven staan, maar is voor deze route
niet nodig. Werk niet gelijktijdig vanuit twee builders aan hetzelfde profiel.
Bestaande eigen YAML blijft leidend; een nieuwe YAML aanmaken is geen updateroute.
+98 -5
View File
@@ -554,8 +554,8 @@ substitutions:
OVERLAY_SLIDER_W: "144"
OVERLAY_SLIDER_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();
+96 -3
View File
@@ -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();
+96 -3
View File
@@ -781,7 +781,7 @@ esphome:
min_version: 2026.6.2
project:
name: maxgramser.esp_screen
version: "0.1.3"
version: "0.2.0"
includes:
- <esphome/components/smart_display/cyd_ui.h>
- <esphome/components/smart_display/light_controls.h>
@@ -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();
+98 -5
View File
@@ -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();
+10
View File
@@ -1,3 +1,13 @@
# 0.2.0
- ESPHome CLI in de app: bestaande profielen controleren, bouwen en via USB/OTA installeren.
- Nieuwe profielen met unieke sleutels; bestaande YAML, secrets en appdata blijven behouden.
- Tegelopties: klikgedrag, grote waarde, mini-schuif en sensorgeschiedenis.
- Media-, weather-, number- en select-kaarten en vernieuwde vacuumkaart op beide borden.
- Actiefeedback, vaste tekstregels en zwarte sliderknoppen.
- Inspector en helderheid/standby als instellingen bij het HA-apparaat.
- Native Guition ST7701S-configuratie en lichte kaartstijl. Duuracceptatie van het fysieke beeld blijft apart van firmwaretests.
# 0.1.2
- Per scherm standbyduur, normale/standby/nachthelderheid en nachturen instellen.
+11 -21
View File
@@ -1,24 +1,14 @@
# Aan de slag
# Installatie en dagelijks gebruik
Start de app en open de webinterface. Klik **Nieuw scherm**, kies het exacte bord
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.
+5 -3
View File
@@ -1,8 +1,10 @@
FROM ghcr.io/home-assistant/base:3.22
# Official ESPHome distribution: compiler, ESP-IDF, esptool and OTA CLI included.
FROM ghcr.io/esphome/esphome:2026.6.2
ARG BUILD_VERSION
ARG BUILD_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"]
+24 -4
View File
@@ -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()
+129
View File
@@ -0,0 +1,129 @@
"""Serialised ESPHome CLI jobs. YAML/secrets stay local; no shell or arbitrary commands."""
import asyncio
from collections import deque
import glob
import json
import os
from pathlib import Path
import re
import shutil
import signal
import time
import yaml
from core import installation_yaml
class Firmware:
def __init__(self, root, data):
self.root, self.data = Path(root).resolve(), Path(data)
self.job = None
self.task = None
self.logs = deque(maxlen=300)
self.process = None
def profiles(self):
if not self.root.exists(): return []
return [{'file': p.name} for p in sorted(self.root.glob('*.yaml'))
if p.name != 'secrets.yaml' and p.is_file() and not p.is_symlink()]
def ports(self):
return sorted(set(glob.glob('/dev/serial/by-id/*') or glob.glob('/dev/ttyUSB*') + glob.glob('/dev/ttyACM*')))
def profile(self, name):
if not isinstance(name,str) or not re.fullmatch(r'[a-zA-Z0-9_-]+\.yaml', name):
raise ValueError('Kies een bestaand YAML-profiel.')
p = self.root / name
if p.is_symlink() or p.resolve().parent != self.root or not p.is_file():
raise ValueError('Profiel bestaat niet in de ESPHome-map.')
return p
def status(self):
return {'available': bool(shutil.which('esphome')), 'profiles': self.profiles(),
'ports': self.ports(), 'job': self.job, 'logs': list(self.logs)}
def create(self, data):
content = installation_yaml(data)
self.root.mkdir(parents=True,exist_ok=True)
# Existing wifi secrets remain untouched. New owners can supply them once.
secret_path = self.root / 'secrets.yaml'
if not secret_path.exists():
ssid, password = data.get('wifi_ssid'), data.get('wifi_password')
if not isinstance(ssid,str) or not ssid.strip() or not isinstance(password,str):
raise ValueError('Vul wifi in voor deze eerste installatie.')
with secret_path.open('x') as f:
os.chmod(secret_path,0o600)
yaml.safe_dump({'wifi_ssid':ssid,'wifi_password':password}, f)
profile = self.root / (data['name']+'.yaml')
try:
with profile.open('x') as f:
os.chmod(profile,0o600);f.write(content)
except FileExistsError:
raise ValueError('Deze naam bestaat al. Gebruik het bestaande profiel voor updates.')
return {'file':profile.name, 'yaml':content}
def redact(self, text):
text = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', text)
for value in self._secret_values:
if len(value) >= 3: text=text.replace(value,'[redacted]')
return re.sub(r'(?i)((?:password|encryption.key|token|ssid)\s*[:=]\s*).+',r'\1[redacted]',text)[:1500]
def start(self, data):
if self.task and not self.task.done(): raise ValueError('Er loopt al een build of installatie.')
profile = self.profile(data.get('file'))
action = data.get('action')
if action not in ('validate','build','install'): raise ValueError('Onbekende firmwareactie.')
if not shutil.which('esphome'): raise ValueError('ESPHome CLI ontbreekt in deze installatie.')
target = data.get('target','')
if action == 'install':
if target.startswith('/dev/'):
if target not in self.ports(): raise ValueError('Kies de aangesloten USB-poort uit de lijst.')
elif not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}',target):
raise ValueError('Vul het IP-adres of de hostnaam van het bedoelde scherm in.')
self._secret_values=set()
# Collect scalar literals, including !secret values, without executing YAML tags.
def collect(node):
if isinstance(node,yaml.ScalarNode):
if node.value: self._secret_values.add(node.value)
elif isinstance(node,yaml.SequenceNode):
for child in node.value: collect(child)
elif isinstance(node,yaml.MappingNode):
for key,value in node.value:
if key.value in ('password','key','ssid','token') or profile.name=='secrets.yaml':collect(value)
elif isinstance(value,(yaml.MappingNode,yaml.SequenceNode)):collect(value)
collect(yaml.compose(profile.read_text()))
secret_file=self.root/'secrets.yaml'
if secret_file.exists():
node=yaml.compose(secret_file.read_text())
if isinstance(node,yaml.MappingNode):
for _,v in node.value:collect(v)
self.logs.clear()
self.job={'file':profile.name,'action':action,'target':target,'state':'running','started':time.time()}
self.task=asyncio.create_task(self.run(profile,action,target))
return dict(self.job)
async def run(self, profile, action, target):
env={**os.environ, 'PLATFORMIO_CORE_DIR':str(self.data/'platformio'), 'ESPHOME_BUILD_PATH':str(self.data/'build'/profile.stem), 'NO_COLOR':'1'}
try:
stages = ['config'] if action=='validate' else ['compile'] + (['upload'] if action=='install' else [])
for stage in stages:
cmd=['esphome']+(['--quiet'] if stage=='config' else [])+[stage,str(profile)]
if stage=='upload': cmd += ['--device',target]
self.logs.append('ESPHome: '+stage)
self.process=await asyncio.create_subprocess_exec(*cmd,cwd=self.root,env=env,stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.STDOUT,limit=1024*1024,start_new_session=True)
async with asyncio.timeout(7200):
async for line in self.process.stdout:
self.logs.append(self.redact(line.decode(errors='replace').rstrip()))
code=await self.process.wait()
if code: raise RuntimeError('ESPHome '+stage+' mislukt; bekijk het log.')
self.job['state']='success'; self.logs.append('Geslaagd: '+action)
except asyncio.CancelledError:
self.job['state']='interrupted'
raise
except Exception as error:
self.job['state']='failed';self.logs.append(self.redact(str(error)))
finally:
if self.process and self.process.returncode is None:
os.killpg(self.process.pid,signal.SIGTERM)
try: await asyncio.wait_for(self.process.wait(),10)
except TimeoutError: os.killpg(self.process.pid,signal.SIGKILL);await self.process.wait()
self.process=None
self.job['finished']=time.time()
+92 -3
View File
@@ -7,9 +7,12 @@ import os
from pathlib import Path
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 position<len(events) and events[position][0]<=boundary:
value=events[position][1];position+=1
samples.append(value)
return samples
class Manager:
def __init__(self, ha, path):
self.ha, self.path = ha, Path(path)
self.layouts, self.sent, self.status, self.last = {}, {}, {}, {}
self.histories = {}
self.firmware = Firmware(os.environ.get("ESPHOME_CONFIG", "/homeassistant/esphome"), self.path.parent)
if self.path.exists():
raw = json.loads(self.path.read_text())
# Versioned persistent data. Never silently overwrite an unknown schema.
@@ -133,6 +172,10 @@ class Manager:
# A still-open older UI may save tiles without the new optional settings.
if 'settings' not in layout and 'settings' in self.layouts.get(inbox, {}):
layout['settings'] = self.layouts[inbox]['settings'].copy()
old_tiles = {t['entity']:t for t in self.layouts.get(inbox,{}).get('tiles',[])}
for tile in layout['tiles']:
if 'options' not in tile and 'options' in old_tiles.get(tile['entity'],{}):
tile['options'] = old_tiles[tile['entity']]['options'].copy()
known = {e['id'] for e in self.inventory()[1]}
if any(t['entity'] not in known for t in layout['tiles']):
raise ValueError('Een gekozen entiteit bestaat niet meer. Zoek de nieuwe entiteit op.')
@@ -151,10 +194,21 @@ class Manager:
self.ha.changed.set()
async def sync_one(self, inbox, layout, force=False):
messages = [{'v': 1, 'op': 'layout', 'title': layout['title'], 'entities': [t['entity'] for t in layout['tiles']]}]
messages = [{'v': 1, 'op': 'layout', 'inbox': inbox, 'title': layout['title'], 'entities': [t['entity'] for t in layout['tiles']]}]
if 'settings' in layout:
messages[0]['settings'] = layout['settings']
messages += [state_message(i, tile, self.ha.states) for i, tile in enumerate(layout['tiles'])]
for i,tile in enumerate(layout['tiles']):
message=state_message(i,tile,self.ha.states)
if tile['entity'].startswith('sensor.') and hasattr(self.ha,'history'):
hours=tile.get('options',{}).get('history_hours',24)
key=(tile['entity'],hours)
cached=self.histories.get(key)
if not cached or time.monotonic()-cached[0]>300:
try: values=await self.ha.history(*key)
except Exception: values=[]
cached=(time.monotonic(),values);self.histories[key]=cached
message['history']={'hours':hours,'values':cached[1]}
messages.append(message)
previous = self.sent.get(inbox, [])
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)
+210 -1
View File
@@ -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);
}
};
+76 -27
View File
@@ -15,6 +15,7 @@
<strong>ESP Screens</strong><small>Je huis. Jouw bediening.</small>
</div>
</div>
<button id="open-firmware" class="quiet">Firmware & USB</button>
<button id="new-screen">+ Nieuw scherm</button>
</header>
<main>
@@ -99,6 +100,16 @@
Gelijke begin- en eindtijd schakelen het nachtvenster uit.
</p>
</details>
<details class="screen-settings">
<summary>
Inspector
<span class="hint"
>Verbinding, status en ondersteunde eigenschappen</span
>
</summary>
<button id="inspect" class="quiet">Lees actuele gegevens</button>
<pre id="inspection">Selecteer een scherm en lees de gegevens.</pre>
</details>
<footer class="savebar">
<div>
<strong id="dirty">Alles opgeslagen</strong
@@ -111,12 +122,9 @@
</section>
</div>
<p class="firmware-link">
<a href="/5c53de3b_esphome" target="_top"
>Firmware bijwerken in ESPHome ↗</a
><small
>Open je bestaande apparaat → Install → Wirelessly. Je tegels en
sleutels blijven behouden.</small
>
Firmware bouwen en via USB of wifi installeren kan via
<b>Firmware & USB</b> bovenaan. Je bestaande YAML en secrets blijven
bewaard.
</p>
<details class="help">
<summary>Hoe werkt het dagelijks?</summary>
@@ -125,12 +133,13 @@
<b>Opslaan & naar scherm</b>. 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.
</p>
<p>
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.
</p>
</details>
</main>
@@ -185,7 +194,21 @@
>Kleine letters, cijfers en streepjes. Elk scherm een andere
naam.</small
></label
><button type="submit" id="download">Maak installatie-YAML</button>
><label
>Wifi-naam (alleen bij eerste installatie)<input
name="wifi_ssid"
autocomplete="off"
/></label>
<label
>Wifiwachtwoord<input
name="wifi_password"
type="password"
autocomplete="new-password"
/></label>
<button type="button" id="create-profile">
Bewaar profiel in ESP Screens
</button>
<button type="submit" id="download">Maak installatie-YAML</button>
<p id="install-status" role="status"></p>
<div id="yaml-result" hidden>
<label
@@ -205,36 +228,62 @@
</div>
<ol class="steps">
<li>
<b>Open ESPHome Device Builder.</b> Voeg een nieuw apparaat toe, sla
de eerste installatie over en vervang de inhoud via Edit door het
gedownloade bestand.
<b>Bewaar je profiel.</b> Bestaande wifi-secrets worden behouden;
bij een eerste installatie vul je wifi hierboven in.
</li>
<li>
<b>Vul wifi eenmalig in bij Secrets.</b> Gebruik
<code>wifi_ssid</code> en <code>wifi_password</code>.
<b>Open Firmware & USB.</b> Kies dit profiel, sluit het scherm op je
Raspberry aan en selecteer de juiste USB-poort. Kies Bouwen &
installeren.
</li>
<li>
<b>Sluit USB aan op je Home Assistant Raspberry.</b> Klik Install →
Plug into the computer running ESPHome Device Builder en kies de
USB-poort.
<b>Koppel in HA → Apparaten & diensten → ESPHome.</b> Gebruik de
API-sleutel uit je eigen YAML en sta HA-acties toe.
</li>
<li>
<b>Koppel in Instellingen → Apparaten & diensten → ESPHome.</b>
Gebruik de <code>api.encryption.key</code> uit je bestand en sta
Home Assistant-acties toe bij Configureren.
<b>Kies hier je tegels.</b> CYD vraagt bij de eerste start om
kalibratie; Guition niet.
</li>
<li>
<b>Open deze pagina en kies je tegels.</b> CYD toont bij de eerste
start een kalibratie: tik ieder kruisje drie keer. Guition hoeft
niet gekalibreerd.
<b>Later updaten?</b> Kies hetzelfde profiel en het IP-adres van je
scherm in Firmware & USB.
</li>
</ol>
<p class="hint">
Bewaar je YAML met sleutels. Gebruik later Install → Wirelessly voor
firmware-updates. Tegels wijzigen doe je hier.
Bewaar je YAML met sleutels. Gebruik later Firmware & USB met het
IP-adres voor draadloze updates. Tegels wijzigen doe je hier.
</p>
</form>
</dialog>
<dialog id="firmware-dialog">
<div class="dialog-head">
<h2>Firmware & USB</h2>
<button id="close-firmware" class="quiet">Sluiten</button>
</div>
<p>
De ingebouwde ESPHome CLI gebruikt je eigen YAML en secrets. Tegels
blijven in ESP Screens staan. Op de Raspberry kan de eerste build enkele
minuten duren.
</p>
<label>ESPHome-profiel<select id="firmware-file"></select></label>
<label
>Installeren naar<select id="firmware-port">
<option value="ota">Wifi / OTA</option>
</select></label
>
<label id="firmware-host-label"
>IP-adres of hostnaam<input
id="firmware-host"
placeholder="scherm-woonkamer.local"
/></label>
<div class="yaml-actions">
<button id="firmware-validate" class="quiet">Controleren</button
><button id="firmware-build" class="quiet">Alleen bouwen</button
><button id="firmware-install">Bouwen & installeren</button>
</div>
<p id="firmware-status" role="status"></p>
<pre id="firmware-log"></pre>
</dialog>
<div id="toast" role="status" hidden></div>
</body>
</html>
+39
View File
@@ -616,3 +616,42 @@ textarea {
grid-template-columns: 1fr;
}
}
.tile-options {
margin-top: 10px;
font-size: 13px;
}
.tile-options label {
display: block;
margin: 8px 0;
}
select {
width: 100%;
padding: 10px;
border: 1px solid #d8dfe5;
border-radius: 9px;
background: white;
color: #152331;
font: inherit;
}
pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
max-height: 340px;
overflow: auto;
background: #f0f3f6;
border-radius: 12px;
padding: 16px;
font-size: 12px;
}
#firmware-dialog {
max-width: 780px;
width: 90vw;
}
.tile-actions {
align-self: start;
}
.tile .tile-options input,
.tile .tile-options select {
cursor: auto;
}
+6 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.1.2"
version: "0.2.0"
slug: esp_screen_manager
description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen.
url: https://github.com/MaxGramser/homeassistant_espscreen
@@ -15,3 +15,8 @@ panel_title: ESP Screens
panel_admin: true
options: {}
schema: {}
map:
- homeassistant_config:rw
uart: true
usb: true
+3
View File
@@ -0,0 +1,3 @@
#include "components/smart_display/runtime_model.h"
#include <cassert>
int main(){runtime_tiles::Tile t;t.revision="off";t.begin(100);assert(t.loading(200));t.observe("off");assert(!t.confirmed);t.observe("on");assert(t.confirmed);assert(t.loading(999));assert(!t.loading(1100));t.begin(2000);t.observe("on");assert(t.loading(7999));assert(!t.loading(8000));t.begin(0xFFFFFFF0);assert(t.loading(20));t.begin(100,true);assert(t.loading(1099));assert(!t.loading(1100));}
+56
View File
@@ -0,0 +1,56 @@
import asyncio
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'screen_manager/app'))
from core import validate_layout, state_message, packets
from firmware import Firmware
from test_portal import ManagerTests
class ExtendedTests(unittest.TestCase):
def test_options_validation_and_new_domains(self):
for domain in ['sensor','number','input_number','select','weather','media_player']:
layout=validate_layout({'title':'Test','tiles':[{'entity':domain+'.x','options':{'display':'watch'}}]})
self.assertEqual(layout['tiles'][0]['options']['display'],'watch')
for options in [{'inline':'slider'},{'tap':'toggle'},{'history_hours':True},{'history_hours':100},{'arbitrary':'run'}]:
with self.assertRaises(ValueError):validate_layout({'title':'Test','tiles':[{'entity':'sensor.x','options':options}]})
with self.assertRaises(ValueError):validate_layout({'title':'Test','tiles':[{'entity':'light.x','options':{'display':'watch','inline':'slider'}}]})
def test_attributes_and_history_fit_protocol(self):
tile={'entity':'sensor.a','name':'Temperatuur','options':{'history_hours':24,'display':'watch'}}
msg=state_message(0,tile,{'sensor.a':{'state':'21','attributes':{'unit_of_measurement':'°C','password':'hidden'}}})
msg['history']={'hours':24,'values':[None,21.5]*12}
self.assertNotIn('password',msg['a']);self.assertEqual(msg['o'],tile['options']);self.assertTrue(all(len(p)<=255 for p in packets(msg)))
def test_firmware_profiles_no_overwrite_or_traversal(self):
with tempfile.TemporaryDirectory() as tmp:
f=Firmware(Path(tmp)/'config',Path(tmp)/'data');data={'board':'cyd','name':'test','friendly_name':'Test','wifi_ssid':'local-wifi','wifi_password':'private-wifi'}
result=f.create(data);before=f.profile('test.yaml').read_text();secrets=(f.root/'secrets.yaml').read_text()
with self.assertRaises(ValueError):f.create(data)
self.assertEqual(before,f.profile('test.yaml').read_text());self.assertEqual(secrets,(f.root/'secrets.yaml').read_text())
for value in ['../secrets.yaml','/etc/passwd','test.yaml;echo']:
with self.assertRaises(ValueError):f.profile(value)
(f.root/'linked.yaml').symlink_to(f.profile('test.yaml'))
with self.assertRaises(ValueError):f.profile('linked.yaml')
self.assertEqual(f.profiles(),[{'file':'test.yaml'}])
class FirmwareJobs(unittest.IsolatedAsyncioTestCase):
async def test_cli_arguments_redaction_and_busy(self):
with tempfile.TemporaryDirectory() as tmp:
f=Firmware(tmp,tmp);f.create({'board':'guition','name':'test','friendly_name':'Test','wifi_ssid':'homewifi','wifi_password':'secretwifi'})
async def waiting(*args):await asyncio.sleep(20)
with patch('firmware.shutil.which',return_value='/bin/esphome'),patch.object(f,'run',side_effect=waiting):
with self.assertRaises(ValueError):f.start({'file':'test.yaml','action':'install','target':'1.2.3.4;evil'})
with self.assertRaises(ValueError):f.start({'file':'test.yaml','action':'install','target':'/dev/unknown'})
f.start({'file':'test.yaml','action':'validate'})
self.assertNotIn('secretwifi',f.redact('Using secretwifi on homewifi'))
with self.assertRaises(ValueError):f.start({'file':'test.yaml','action':'build'})
f.task.cancel()
try:await f.task
except asyncio.CancelledError:pass
async def test_old_browser_preserves_new_tile_options(self):
with tempfile.TemporaryDirectory() as tmp:
m=ManagerTests().setup_manager(Path(tmp)/'screens.json')
m.save('text.screen',{'title':'Home','tiles':[{'entity':'light.a','name':'Lamp','options':{'inline':'slider'}}]})
m.save('text.screen',{'title':'Nieuw','tiles':[{'entity':'light.a','name':'Lamp'}]})
self.assertEqual(m.layouts['text.screen']['tiles'][0]['options'],{'inline':'slider'})