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