Fix switch confirmation and details; retain compact slider icons

This commit is contained in:
Max Gramser
2026-09-13 00:47:49 +02:00
parent ba9ba414a5
commit c316d99fd7
10 changed files with 301 additions and 26 deletions
+81 -19
View File
@@ -23,6 +23,7 @@ inline esphome::ESPPreferenceObject swipe_preference;
inline Model model;
inline std::string inbox;
inline const lv_font_t *watch_font = nullptr;
inline const lv_font_t *mini_icon_font = nullptr;
inline void tick();
inline void refresh_detail(unsigned index);
inline int active_index = -1;
@@ -60,7 +61,7 @@ inline bool parse_settings(JsonObject obj, screen_settings::Settings &s) {
return s.valid();
}
inline std::function<void(Tile &)> detail, detail_update;
struct Widgets { lv_obj_t *tile{}, *title{}, *value{}, *circle{}, *icon{}; size_t index{}; int cached_active = -1; lv_obj_t *slider{}, *progress{}; int title_x=0,title_y=0,value_x=0,value_y=0; const lv_font_t *value_font{}; };
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{}, *icon_font{}; };
inline std::array<Widgets, 10> widgets;
inline bool fresh() { return model.ready() && esphome::millis() - last_received < 95000; }
inline float number(JsonVariant value, float fallback = NAN) {
@@ -134,8 +135,13 @@ inline std::string receive(const std::string &payload) {
if (!model.accepts(index, entity)) { result = "Fout: verouderde tegel"; return false; }
Tile &tile = model.tiles[index];
auto a = root["a"].as<JsonObject>();
std::string revision = string(root["state"]); serializeJson(a, revision);
// ArduinoJson clears its destination string: serialize attributes first, then add state.
std::string attributes; serializeJson(a, attributes);
std::string revision = state_revision(string(root["state"]), attributes);
bool was_confirmed=tile.confirmed;
tile.observe(revision);
if(tile.pending && !tile.local_feedback && !was_confirmed && tile.confirmed)
ESP_LOGI("runtime_action","HA state received entity=%s elapsed=%u ms",entity.c_str(),(unsigned)(esphome::millis()-tile.pending_since));
auto options = root["o"];
tile.background = tile_palette::color(string(options["background"],16));
tile.tap = string(options["tap"]); if (tile.tap.empty()) tile.tap="auto";
@@ -208,6 +214,8 @@ inline void action(const std::string &service, const std::string &entity, const
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);
ESP_LOGI("runtime_action","Sent service=%s entity=%s",service.c_str(),entity.c_str());
if(refresh)refresh();
}
inline void setting_event(const std::string &key, int value) {
if(inbox.empty())return;
@@ -227,12 +235,15 @@ inline lv_obj_t *detail_actions[16]{};
inline unsigned detail_action_count=0;
inline lv_obj_t *detail_status=nullptr;
inline lv_obj_t *detail_badge_status=nullptr;
inline lv_obj_t *detail_switch=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 std::string detail_state(const Tile &t){
if(t.state=="on")return "Aan";
if(t.state=="off")return "Uit";
if(t.state=="docked")return "In dock";
if(t.state=="cleaning")return "Bezig met schoonmaken";
if(t.state=="paused")return "Gepauzeerd";
@@ -304,7 +315,7 @@ 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[0].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);}
detail_action_count=0;detail_status=nullptr;detail_badge_status=nullptr;lv_obj_clean(detail_root);lv_obj_remove_flag(detail_root,LV_OBJ_FLAG_HIDDEN);lv_obj_move_foreground(detail_root);
detail_action_count=0;detail_status=nullptr;detail_badge_status=nullptr;detail_switch=nullptr;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?0xE7E7E7: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;
@@ -313,7 +324,28 @@ inline void show_detail(unsigned index){
std::string state=detail_state(t);
detail_status=detail_label(detail_root,state+(t.unit.empty()?"":" "+t.unit),pad,large?60:35,width-2*pad);
auto d=t.domain();
if(d=="vacuum"){
if(t.is_switch()){
detail_label(detail_root,"Tik om te schakelen",pad,top,width-2*pad);
detail_switch=lv_switch_create(detail_root);
lv_obj_set_size(detail_switch,large?240:140,large?112:64);
lv_obj_set_pos(detail_switch,(width-(large?240:140))/2,top+(large?65:30));
lv_obj_set_style_bg_color(detail_switch,lv_color_hex(0xB0B0B0),LV_PART_MAIN);
lv_obj_set_style_bg_color(detail_switch,lv_color_hex(0xFFB900),LV_PART_INDICATOR|LV_STATE_CHECKED);
lv_obj_set_style_bg_color(detail_switch,lv_color_hex(0xFFFFFF),LV_PART_KNOB);
lv_obj_set_style_opa(detail_switch,LV_OPA_50,LV_STATE_DISABLED);
if(t.state=="on")lv_obj_add_state(detail_switch,LV_STATE_CHECKED);
lv_obj_add_event_cb(detail_switch,[](lv_event_t *e){
if(detail_index>=model.count)return;
auto &tile=model.tiles[detail_index];auto *control=lv_event_get_target_obj(e);
bool allowed=fresh() && tile.available() && !tile.awaiting_action(esphome::millis()) &&
cyd::touch_guard.accept(esphome::millis(),350);
bool requested_on=lv_obj_has_state(control,LV_STATE_CHECKED);
// Only HA's reported state is authoritative, including a refused/failed command.
if(tile.state=="on")lv_obj_add_state(control,LV_STATE_CHECKED);else lv_obj_remove_state(control,LV_STATE_CHECKED);
if(allowed)action(tile.domain()+(requested_on?".turn_on":".turn_off"),tile.entity);
},LV_EVENT_VALUE_CHANGED,nullptr);
detail_actions[detail_action_count++]=detail_switch;
}else if(d=="vacuum"){
// Native shapes keep the robot crisp without image buffers or extra layers.
auto shape=[&](lv_obj_t *parent,int x,int y,int w,int h,uint32_t color,int radius){
auto *o=lv_obj_create(parent);lv_obj_remove_style_all(o);lv_obj_set_pos(o,x,y);lv_obj_set_size(o,w,h);
@@ -422,7 +454,7 @@ inline void event(lv_event_t *event) {
active_index = w.index;
tile.begin(esphome::millis(),true);
if (detail) detail(tile);
} else { active_index=w.index;tile.begin(esphome::millis(),true);show_detail(w.index); }
} else { active_index=w.index;show_detail(w.index); }
return;
}
if (d == "light" || d == "switch" || d == "input_boolean" || d == "fan") action(d + ".toggle", tile.entity);
@@ -432,9 +464,11 @@ inline void event(lv_event_t *event) {
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) {
// Compact cards need room for two text lines and a separate dimmer track.
if(lv_obj_get_height(tile)<=80){lv_obj_set_style_pad_top(tile,4,0);lv_obj_set_style_pad_bottom(tile,4,0);}
lv_obj_set_style_border_width(tile,1,0);
lv_obj_update_layout(tile);
widgets[index] = {tile, title, value, circle, icon, index};
auto &w=widgets[index]; w.title_x=lv_obj_get_x(title);w.title_y=lv_obj_get_y(title);w.value_x=lv_obj_get_x(value);w.value_y=lv_obj_get_y(value);w.value_font=lv_obj_get_style_text_font(value,LV_PART_MAIN);
w.icon_font=lv_obj_get_style_text_font(icon,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));
@@ -505,22 +539,32 @@ inline void render(lv_obj_t *room) {
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);
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();
bool large_tile=lv_obj_get_height(w.tile)>80;
lv_obj_set_y(w.title,mini?0:w.title_y);
if(mini){lv_obj_set_y(w.value,large_tile?24:lv_obj_get_height(w.title)+1);lv_obj_add_flag(w.circle,LV_OBJ_FLAG_HIDDEN);lv_obj_set_x(w.title,0);lv_obj_set_x(w.value,0);}
// Labels default to content-sized widths. Explicit bounds are necessary for
// LVGL ellipsis, and must expand again when the icon is hidden for a slider.
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_update_layout(w.tile);
int content_width=lv_obj_get_content_width(w.tile);
lv_obj_set_width(w.title,std::max(1,content_width-(int)lv_obj_get_x(w.title)));
lv_obj_set_width(w.value,std::max(1,content_width-(int)lv_obj_get_x(w.value)));
lv_obj_set_size(w.slider,content_width,large_tile?28:8);
int content_width=lv_obj_get_content_width(w.tile),content_height=lv_obj_get_content_height(w.tile);
int title_height=lv_obj_get_height(w.title),value_height=lv_obj_get_height(w.value);
int line_gap=large_tile?2:1,text_height=title_height+line_gap+value_height;
int slider_height=large_tile?28:8;
int header_height=mini?content_height-slider_height-(large_tile?6:3):content_height;
int text_y=std::max(0,(header_height-text_height)/2);
int circle_size=mini?(large_tile?36:24):(large_tile?54:36);
lv_obj_set_size(w.circle,circle_size,circle_size);
lv_obj_set_style_text_font(w.icon,mini && mini_icon_font ? mini_icon_font : w.icon_font,0);
lv_obj_center(w.icon);
if(watch)lv_obj_add_flag(w.circle,LV_OBJ_FLAG_HIDDEN);else lv_obj_remove_flag(w.circle,LV_OBJ_FLAG_HIDDEN);
int text_x=watch?0:mini?circle_size+(large_tile?8:6):w.title_x;
lv_obj_set_pos(w.title,text_x,watch?0:(mini || !large_tile)?text_y:w.title_y);
lv_obj_set_pos(w.value,watch?0:mini?text_x:w.value_x,
watch?(large_tile?42:19):(mini || !large_tile)?text_y+title_height+line_gap:w.value_y);
lv_obj_set_pos(w.circle,0,(mini || !large_tile)?std::max(0,(header_height-circle_size)/2):12);
// Use the requested coordinates: LVGL getters still return the previous
// layout until its next pass when a slot changes from watch/slider to normal.
lv_obj_set_width(w.title,std::max(1,content_width-text_x));
lv_obj_set_width(w.value,std::max(1,content_width-(watch?0:mini?text_x:w.value_x)));
lv_obj_set_size(w.slider,content_width,slider_height);
lv_obj_align(w.slider,LV_ALIGN_BOTTOM_MID,0,0);
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);
@@ -530,7 +574,7 @@ inline void render(lv_obj_t *room) {
if (w.cached_active == palette_state) continue;
w.cached_active = palette_state;
uint32_t accent = domain_accent(t);
auto color=lv_color_hex(accent);
auto color=lv_color_hex(t.is_switch() && !on ? 0x9E9E9E : accent);
if(t.domain()=="light" && on && t.has_hs_color)
color=lv_color_hsv_to_rgb(t.hue%360,t.saturation,100);
bool dark_text=light_theme || t.background!=0;
@@ -565,6 +609,20 @@ inline bool check_tile_geometry() {
lv_obj_get_coords(w.slider,&track);
fits=fits && value.y2<track.y1 && track.y2<=content.y2;
}
if(!lv_obj_has_flag(w.circle,LV_OBJ_FLAG_HIDDEN)){
lv_area_t circle;lv_obj_get_coords(w.circle,&circle);
bool mini=!lv_obj_has_flag(w.slider,LV_OBJ_FLAG_HIDDEN);
fits=fits && circle.x1>=content.x1 && circle.x2<title.x1 && circle.y1>=content.y1;
if(mini)fits=fits && circle.y2<track.y1;
else fits=fits && circle.y2<=content.y2;
if(!fits)ESP_LOGE("ui_test","Icon bounds slot=%u circle=%d,%d..%d,%d title_x=%d content=%d,%d..%d,%d",(unsigned)w.index,circle.x1,circle.y1,circle.x2,circle.y2,title.x1,content.x1,content.y1,content.x2,content.y2);
if(mini || lv_obj_get_height(w.tile)<=80){
int header_bottom=mini?track.y1-(lv_obj_get_height(w.tile)>80?6:3)-1:content.y2;
int center_twice=content.y1+header_bottom;
fits=fits && std::abs(circle.y1+circle.y2-center_twice)<=2 &&
std::abs(title.y1+value.y2-center_twice)<=2;
}
}
if(!fits)ESP_LOGE("ui_test","Tile geometry FAIL slot=%u title_y=%d..%d value_y=%d..%d content_y=%d..%d",(unsigned)w.index,title.y1,title.y2,value.y1,value.y2,content.y1,content.y2);
if(w.index<model.count && model.tiles[w.index].background){
bool palette_ok=lv_color_eq(lv_obj_get_style_bg_color(w.tile,LV_PART_MAIN),lv_color_hex(model.tiles[w.index].background)) &&
@@ -603,6 +661,10 @@ inline void tick() {
std::string status=waiting?(t.confirmed?"Bevestigd door Home Assistant":"Opdracht verstuurd..."):detail_state(t);
if(detail_status)label(detail_status,status+(!waiting && !t.unit.empty()?" "+t.unit:""));
if(detail_badge_status)label(detail_badge_status,status);
if(detail_switch && !lv_obj_has_state(detail_switch,LV_STATE_PRESSED)){
if(t.state=="on")lv_obj_add_state(detail_switch,LV_STATE_CHECKED);
else lv_obj_remove_state(detail_switch,LV_STATE_CHECKED);
}
}
if(!enabled)return;
bool redraw=false;