Add a card for blinds and curtains, and fix what Studio 1 showed after 0.2.57 (0.2.58)
A tap on a cover now opens a card like Home Assistant's own, in the style of the climate and vacuum cards: a tall position slider on which the blind hangs from the top, a tilt slider over slats for venetian blinds, and open, stop and close keys. The key of the direction the cover moves is filled; a key that cannot move it further is disabled and stays out of the keys the card's tick enables. Sliders show their value while dragged and send it on release. The card offers only what supported_features allows. A battery-powered blind shows its battery at the top right: the app sends the device's battery sensor as the extra the vacuum card uses, and adds current_tilt_position to the attributes. Checking Studio 1 after its update to firmware 0.2.49 found that a setting number equal to its initial state 0 (night brightness 0 %) was never published, so Home Assistant kept it unknown and ESP Screens could not change it; apply_screen_settings now publishes a number without a state. The app also took "unavailable" for the device name during the restart and wrote sensor.esp_screens_unavailable; diagnostics ignore unknown and unavailable. The editor redraws its tiles, top bar and entity list when the live stream opened a screen before the first full inventory (a busy add-on), and loads app.js and style.css by content stamp after Safari kept an old script. Checked with 254 Python and 14 C++ tests, generate_packages --check, ESP32 builds of both profiles, and 20 API checks of the card on host builds of both boards. Firmware 0.2.50. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,10 @@ The firmware and built-in CLI are tested with **ESPHome 2026.6.2**.
|
||||
uses are shown (app 0.2.46 / firmware 0.2.39).
|
||||
Scenes, scripts, and buttons show when they last ran. A tile that's waiting on
|
||||
Home Assistant shows a small spinner on a light overlay.
|
||||
- **Cover card** for blinds, curtains, shutters, and garage doors, like Home Assistant's own:
|
||||
a tall position slider on which the blind hangs from the top, a tilt slider over slats for
|
||||
venetian blinds, open, stop, and close, and the battery of a battery-powered blind
|
||||
(such as Motionblinds). A cover shows only what it supports (app 0.2.58 / firmware 0.2.50).
|
||||
- **Top bar per screen** (firmware 0.2.32+): the name on the left, up to six
|
||||
items of your choice on the right: the time, an analog clock, the date, or an
|
||||
entity from Home Assistant with an icon, such as temperature, humidity, power usage, a door
|
||||
@@ -120,8 +124,14 @@ The firmware and built-in CLI are tested with **ESPHome 2026.6.2**.
|
||||
<img src="docs/images/guition-page-3.png" width="32%" alt="Curtains with open, stop and close, a kitchen timer counting down, a scene and the sun path">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/images/cyd-vacuum.png" width="40%" alt="The vacuum card on the CYD: state, battery and charging, clean and dock, the cleaning mode, suction and water">
|
||||
<img src="docs/images/cyd-climate.png" width="40%" alt="The climate card on the CYD: the target temperature with big minus and plus keys and one row of mode keys">
|
||||
<img src="docs/images/guition-blind.png" width="32%" alt="Cover card for a venetian blind: its battery, the position slider with the blind hanging from the top, the tilt slider over slats, and open, stop and close">
|
||||
<img src="docs/images/guition-curtains.png" width="32%" alt="Cover card for curtains: the position slider and the sideways open, stop and close keys">
|
||||
<img src="docs/images/guition-garage.png" width="32%" alt="Cover card for a garage door that only opens and closes: its icon and the open, stop and close keys, close disabled while it is closed">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/images/cyd-vacuum.png" width="32%" alt="The vacuum card on the CYD: state, battery and charging, clean and dock, the cleaning mode, suction and water">
|
||||
<img src="docs/images/cyd-climate.png" width="32%" alt="The climate card on the CYD: the target temperature with big minus and plus keys and one row of mode keys">
|
||||
<img src="docs/images/cyd-blind.png" width="32%" alt="The cover card on the CYD: the position and tilt sliders with their values beside them, the battery, and open, stop and close">
|
||||
</p>
|
||||
|
||||
Features depend on the capabilities Home Assistant reports for an entity.
|
||||
|
||||
@@ -77,13 +77,15 @@ struct Extra {
|
||||
std::vector<Choice> choices;
|
||||
std::string room;
|
||||
bool charging = false;
|
||||
// Cover (firmware 0.2.50+): the tilt of its slats, 0 closed to 100 open.
|
||||
float tilt = NAN;
|
||||
Choice *choice(char kind) { for (auto &c : choices) if (c.kind == kind) return &c; return nullptr; }
|
||||
bool empty() const {
|
||||
return hvac_modes.empty() && fan_modes.empty() && swing_modes.empty() && fan_mode.empty() && swing_mode.empty() &&
|
||||
hvac_action.empty() && options.empty() && forecast.empty() && hours.empty() && std::isnan(wind) &&
|
||||
std::isnan(feels) && wind_unit.empty() && sunrise.empty() && sunset.empty() && duration.empty() &&
|
||||
remaining.empty() && !timer_end && media_title.empty() && fan_speeds.empty() && fan_speed.empty() &&
|
||||
choices.empty() && room.empty() && !charging;
|
||||
choices.empty() && room.empty() && !charging && std::isnan(tilt);
|
||||
}
|
||||
};
|
||||
// The Extra of a tile on the heap, copied along with the tile like an ordinary member.
|
||||
|
||||
@@ -65,6 +65,8 @@ inline const char *weather_icon(const std::string &condition);
|
||||
inline const char *weather_text(const std::string &condition);
|
||||
inline std::string timer_text(const Tile &t);
|
||||
inline std::string last_run_text(uint32_t epoch);
|
||||
inline const char *icon_for(const Tile &tile);
|
||||
inline void label(lv_obj_t *obj, const std::string &text);
|
||||
inline int active_index = -1;
|
||||
inline uint32_t last_received = 0;
|
||||
// Seconds between the manager's full repeats; every layout message declares it (app
|
||||
@@ -380,6 +382,7 @@ inline std::string receive(const std::string &payload) {
|
||||
tile.brightness = number(a["brightness"]);
|
||||
tile.percentage = number(a["percentage"]);
|
||||
tile.position = number(a["current_position"]);
|
||||
next.tilt = number(a["current_tilt_position"]);
|
||||
tile.current = number(a["current_temperature"]);
|
||||
tile.target = number(a["temperature"]);
|
||||
tile.humidity = number(a["current_humidity"]);
|
||||
@@ -581,6 +584,8 @@ inline lv_obj_t *detail_button(const char *text,int x,int y,int width,int height
|
||||
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.extra().options.size())action(t.domain()+".select_option",t.entity,"option",t.extra().options[cmd-30]);
|
||||
// Cover keys: 70 + tile_controls::Command (open, stop, close and the tilt keys).
|
||||
if(cmd>=70 && cmd<130){auto a=tile_controls::key_action(t,cmd-70);if(a.valid()&&t.domain()=="cover")action(a.service,t.entity,a.key,a.value);}
|
||||
if(cmd==40)action(t.state=="active"?"timer.pause":"timer.start",t.entity);
|
||||
if(cmd==41)action("timer.cancel",t.entity);
|
||||
},LV_EVENT_SHORT_CLICKED,(void*)(intptr_t)command);
|
||||
@@ -880,6 +885,159 @@ inline void render_vacuum_detail(Tile &t,bool large,int width,int height,int pad
|
||||
detail_text(detail_root,smart?"The robot chooses suction and water":"Suction and water as set per room",x,y,w,text,LV_TEXT_ALIGN_CENTER,0x6B6B6B);
|
||||
}
|
||||
}
|
||||
// ---- Cover card (firmware 0.2.50+): Home Assistant's cover dialog in the style of the vacuum and climate cards ----
|
||||
// A tall slider per movement: the position as a blind hanging from the top (a fully open cover shows none of it)
|
||||
// and the tilt as a handle over slats, each with its value below, like Home Assistant's own sliders. Open, stop
|
||||
// and close as a row of pill keys under them; the key of the direction the cover moves is filled. A slider
|
||||
// sends its value when the finger lifts, and shows it while it moves.
|
||||
inline constexpr uint32_t COVER_ACCENT = 0x926BC7, COVER_TRACK = 0xEFE8F7, COVER_SLATS = 0xDDD0EF;
|
||||
inline lv_obj_t *cover_values[2]{};
|
||||
inline std::string cover_status_line(const Tile &t){return t.available()?tile_controls::cover_card_status(t):"Unavailable";}
|
||||
inline void cover_slider_event(lv_event_t *e){
|
||||
auto *slider=lv_event_get_target_obj(e);auto code=lv_event_get_code(e);
|
||||
const bool tilt=(uintptr_t)lv_event_get_user_data(e)==1;
|
||||
if(code==LV_EVENT_VALUE_CHANGED){
|
||||
// The range reaches past 0 and 1000 only to keep the handle inside the track.
|
||||
int raw=lv_slider_get_value(slider);
|
||||
if(raw<0||raw>1000){raw=std::clamp(raw,0,1000);lv_slider_set_value(slider,raw,LV_ANIM_OFF);}
|
||||
int percent=(int)std::lround(raw/10.0f);
|
||||
if(auto *value=cover_values[tilt?1:0])label(value,std::to_string(tilt?percent:100-percent)+"%");
|
||||
return;
|
||||
}
|
||||
if(code!=LV_EVENT_RELEASED||detail_index>=model.count)return;
|
||||
auto &t=model.tiles[detail_index];
|
||||
if(!fresh()||!t.available()||!cyd::touch_guard.accept_slider(esphome::millis(),390+(tilt?1:0)))return;
|
||||
int percent=(int)std::lround(std::clamp<int>(lv_slider_get_value(slider),0,1000)/10.0f);
|
||||
if(tilt)action("cover.set_cover_tilt_position",t.entity,"tilt_position",std::to_string(percent));
|
||||
else action("cover.set_cover_position",t.entity,"position",std::to_string(100-percent));
|
||||
}
|
||||
// One slider. The position fills from the top by how far the cover is closed; the tilt has no fill and its
|
||||
// handle moves over slats drawn behind it, thicker towards the closed end as in Home Assistant.
|
||||
inline lv_obj_t *cover_slider(lv_obj_t *parent,int x,int y,int w,int h,float value,bool tilt){
|
||||
int radius=std::max(8,w/7),handle_h=std::max(4,w/18),handle_w=tilt?w*3/5:w*2/5,inset=std::max(6,w/9);
|
||||
if(tilt){
|
||||
auto *slats=detail_shape(parent,x,y,w,h,COVER_TRACK,radius);
|
||||
const int count=10,pitch=(h-2*radius/2)/count;
|
||||
for(int i=0;i<count;++i){
|
||||
int thick=std::max(2,pitch*(20+60*i/(count-1))/100);
|
||||
detail_shape(slats,inset,radius/2+i*pitch+(pitch-thick)/2,w-2*inset,thick,COVER_SLATS,thick/2);
|
||||
}
|
||||
}
|
||||
auto *slider=lv_slider_create(parent);lv_obj_remove_style_all(slider);
|
||||
lv_obj_set_pos(slider,x,y);lv_obj_set_size(slider,w,h);lv_slider_set_orientation(slider,LV_SLIDER_ORIENTATION_VERTICAL);
|
||||
lv_obj_set_style_radius(slider,radius,LV_PART_MAIN);lv_obj_set_style_radius(slider,radius,LV_PART_INDICATOR);
|
||||
lv_obj_set_style_bg_color(slider,lv_color_hex(COVER_TRACK),LV_PART_MAIN);lv_obj_set_style_bg_opa(slider,tilt?LV_OPA_TRANSP:LV_OPA_COVER,LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(slider,lv_color_hex(COVER_ACCENT),LV_PART_INDICATOR);lv_obj_set_style_bg_opa(slider,tilt?LV_OPA_TRANSP:LV_OPA_COVER,LV_PART_INDICATOR);
|
||||
lv_obj_set_style_bg_color(slider,lv_color_hex(tilt?COVER_ACCENT:0xFFFFFF),LV_PART_KNOB);lv_obj_set_style_bg_opa(slider,LV_OPA_COVER,LV_PART_KNOB);
|
||||
lv_obj_set_style_radius(slider,LV_RADIUS_CIRCLE,LV_PART_KNOB);
|
||||
lv_obj_set_style_opa(slider,LV_OPA_50,LV_STATE_DISABLED);
|
||||
// LVGL centres the knob on the end of the fill in a square as wide as the slider; the pads shrink it to a
|
||||
// handle bar. The position's handle sits inside the blind, an inset above its edge; the tilt's is centred.
|
||||
int side=-(w-handle_w)/2;
|
||||
lv_obj_set_style_pad_left(slider,side,LV_PART_KNOB);lv_obj_set_style_pad_right(slider,side,LV_PART_KNOB);
|
||||
if(tilt){
|
||||
int squeeze=-(w-2*handle_h)/2;
|
||||
lv_obj_set_style_pad_top(slider,squeeze,LV_PART_KNOB);lv_obj_set_style_pad_bottom(slider,squeeze,LV_PART_KNOB);
|
||||
// A margin past both ends keeps the handle on the track at 0 and 100 %.
|
||||
int margin=1000*(inset+handle_h)/std::max(1,h-2*(inset+handle_h));
|
||||
lv_slider_set_range(slider,-margin,1000+margin);
|
||||
lv_slider_set_value(slider,std::isfinite(value)?(int)std::lround(std::clamp(value,0.0f,100.0f)*10):500,LV_ANIM_OFF);
|
||||
}else{
|
||||
lv_obj_set_style_pad_top(slider,inset+handle_h-w/2,LV_PART_KNOB);lv_obj_set_style_pad_bottom(slider,1-inset-w/2,LV_PART_KNOB);
|
||||
// Reversed, the fill hangs from the top; the stub past 0 holds the handle of a cover that is fully open.
|
||||
int stub=inset+handle_h+inset,below=1000*stub/std::max(1,h-stub);
|
||||
lv_slider_set_range(slider,1000,-below);
|
||||
lv_slider_set_value(slider,std::isfinite(value)?(int)std::lround((100.0f-std::clamp(value,0.0f,100.0f))*10):0,LV_ANIM_OFF);
|
||||
}
|
||||
lv_obj_add_event_cb(slider,cover_slider_event,LV_EVENT_VALUE_CHANGED,(void*)(uintptr_t)(tilt?1:0));
|
||||
lv_obj_add_event_cb(slider,cover_slider_event,LV_EVENT_RELEASED,(void*)(uintptr_t)(tilt?1:0));
|
||||
if(detail_action_count<32)detail_actions[detail_action_count++]=slider;
|
||||
return slider;
|
||||
}
|
||||
// A row of pill keys with an icon each, as the climate card's modes. A key that cannot move the cover further
|
||||
// is drawn disabled and left out of the keys the card enables again once Home Assistant answers.
|
||||
inline void cover_key_row(const std::array<tile_controls::Key,3> &keys,unsigned count,int x,int y,int w,int h,int gap,const lv_font_t *icons){
|
||||
int key_w=count?(w-gap*((int)count-1))/(int)count:0;
|
||||
for(unsigned i=0;i<count;++i){
|
||||
const auto &key=keys[i];
|
||||
auto *button=detail_button("",x+(int)i*(key_w+gap),y,key_w,h,70+key.command);
|
||||
lv_obj_set_style_radius(button,h/2,0);
|
||||
lv_obj_set_style_bg_color(button,lv_color_hex(key.checked?COVER_ACCENT:0xFFFFFF),0);
|
||||
lv_obj_set_style_bg_color(button,lv_color_hex(key.checked?0x7552A8:0xEEEEEE),LV_STATE_PRESSED);
|
||||
lv_obj_set_style_border_width(button,key.checked?0:1,0);lv_obj_set_style_border_color(button,lv_color_hex(0xDDDDDD),0);
|
||||
auto *glyph=lv_obj_get_child(button,0);
|
||||
lv_obj_set_style_text_font(glyph,icons,0);lv_label_set_text(glyph,key.icon);
|
||||
lv_obj_set_style_text_color(glyph,lv_color_hex(key.checked?0xFFFFFF:0x1B1B1B),0);
|
||||
lv_obj_set_size(glyph,LV_SIZE_CONTENT,LV_SIZE_CONTENT);lv_obj_center(glyph);
|
||||
if(key.disabled){lv_obj_add_state(button,LV_STATE_DISABLED);if(detail_action_count && detail_actions[detail_action_count-1]==button)--detail_action_count;}
|
||||
}
|
||||
}
|
||||
// A battery-powered cover shows its level at the top right, across from the back key: the meter over the
|
||||
// percentage, red below 20 % (the battery sensor of its device, app 0.2.58+).
|
||||
inline void cover_battery(const Tile &t,bool large,int width){
|
||||
if(!std::isfinite(t.battery))return;
|
||||
const lv_font_t *font=large?detail_font:(control_font?control_font:detail_font);
|
||||
int bar=large?60:40,bar_x=large?16:10,bar_y=large?16:8,meter_w=large?30:22,meter_h=large?15:11,gap=large?4:2;
|
||||
int level=(int)std::lround(std::clamp(t.battery,0.0f,100.0f));
|
||||
int line=lv_font_get_line_height(font),block=meter_h+gap+line,cx=width-bar_x-bar/2,y=bar_y+(bar-block)/2;
|
||||
vacuum_battery(detail_root,cx-meter_w/2-1,y,meter_w,meter_h,t.battery);
|
||||
detail_text(detail_root,std::to_string(level)+"%",cx-bar/2-8,y+meter_h+gap,bar+16,font,LV_TEXT_ALIGN_CENTER,0x5F6368);
|
||||
}
|
||||
inline void render_cover_detail(Tile &t,bool large,int width,int height,int pad){
|
||||
auto card=tile_controls::cover_card(t);
|
||||
cover_battery(t,large,width);
|
||||
const lv_font_t *text=large?detail_font:(control_font?control_font:detail_font);
|
||||
const lv_font_t *big=watch_font?watch_font:detail_font;
|
||||
cover_values[0]=cover_values[1]=nullptr;
|
||||
int inner=width-2*pad,gap=large?12:6,key_h=large?64:38,bottom=height-(large?18:6);
|
||||
// The tile icons' own size where it fits the keys (42 px on the Guition, 28 on the CYD).
|
||||
const lv_font_t *key_icons=widgets[0].icon_font && lv_font_get_line_height(widgets[0].icon_font)<=key_h-6?widgets[0].icon_font:(mini_icon_font?mini_icon_font:detail_font);
|
||||
std::array<tile_controls::Key,3> keys,tilt_keys;
|
||||
unsigned key_count=card.keys?tile_controls::cover_keys(t,keys):0,tilt_count=card.tilt_keys?tile_controls::cover_tilt_keys(t,tilt_keys):0;
|
||||
int rows=(key_count?1:0)+(tilt_count?1:0);
|
||||
int keys_y=bottom-rows*key_h-(rows>1?gap:0),top=large?108:72;
|
||||
int box_h=keys_y-gap-top;
|
||||
if(card.position||card.tilt){
|
||||
detail_card(pad,top,inner,box_h);
|
||||
struct Part{bool tilt;float value;const char *caption;};
|
||||
Part parts[2];int n=0;
|
||||
if(card.position)parts[n++]={false,t.position,"Position"};
|
||||
if(card.tilt)parts[n++]={true,t.extra().tilt,"Tilt"};
|
||||
int value_h=lv_font_get_line_height(big),caption_h=lv_font_get_line_height(text);
|
||||
auto percent=[](float value){return std::isfinite(value)?std::to_string((int)std::lround(std::clamp(value,0.0f,100.0f)))+"%":std::string("--");};
|
||||
if(large){
|
||||
// Columns: the slider with its value and name below.
|
||||
int edge=16,slider_h=box_h-2*edge-value_h-caption_h+4,slider_w=n==2?120:140,column_gap=48;
|
||||
int x=pad+(inner-(n*slider_w+(n-1)*column_gap))/2;
|
||||
for(int i=0;i<n;++i,x+=slider_w+column_gap){
|
||||
cover_slider(detail_root,x,top+edge,slider_w,slider_h,parts[i].value,parts[i].tilt);
|
||||
int ty=top+edge+slider_h+2;
|
||||
cover_values[parts[i].tilt?1:0]=detail_text(detail_root,percent(parts[i].value),x-30,ty,slider_w+60,big,LV_TEXT_ALIGN_CENTER,0x1B1B1B);
|
||||
detail_text(detail_root,parts[i].caption,x-30,ty+value_h,slider_w+60,text,LV_TEXT_ALIGN_CENTER,0x6B6B6B);
|
||||
}
|
||||
}else{
|
||||
// The small screen puts the value and name beside each slider.
|
||||
int edge=6,slider_h=box_h-2*edge,slider_w=n==2?48:56,label_w=n==2?76:96,column_gap=n==2?16:0;
|
||||
int column=slider_w+8+label_w,x=pad+(inner-(n*column+(n-1)*column_gap))/2;
|
||||
for(int i=0;i<n;++i,x+=column+column_gap){
|
||||
cover_slider(detail_root,x,top+edge,slider_w,slider_h,parts[i].value,parts[i].tilt);
|
||||
int ty=top+(box_h-value_h-caption_h)/2;
|
||||
cover_values[parts[i].tilt?1:0]=detail_text(detail_root,percent(parts[i].value),x+slider_w+8,ty,label_w,big,LV_TEXT_ALIGN_LEFT,0x1B1B1B);
|
||||
detail_text(detail_root,parts[i].caption,x+slider_w+8,ty+value_h,label_w,text,LV_TEXT_ALIGN_LEFT,0x6B6B6B);
|
||||
}
|
||||
}
|
||||
}else{
|
||||
// Open and close only (a garage door, a gate): the cover's icon on a halo and its state, as the vacuum's hero.
|
||||
detail_card(pad,top,inner,box_h);
|
||||
const lv_font_t *icon_font=widgets[0].icon_font?widgets[0].icon_font:mini_icon_font;
|
||||
int halo=std::min(box_h-24,large?120:72);
|
||||
auto *ring=detail_shape(detail_root,pad+(inner-halo)/2,top+(box_h-halo)/2,halo,halo,COVER_TRACK,halo/2);
|
||||
if(icon_font){auto *icon=detail_text(ring,icon_for(t),0,(halo-lv_font_get_line_height(icon_font))/2,halo,icon_font,LV_TEXT_ALIGN_CENTER,COVER_ACCENT);(void)icon;}
|
||||
}
|
||||
int y=keys_y;
|
||||
if(key_count){cover_key_row(keys,key_count,pad,y,inner,key_h,gap,key_icons);y+=key_h+gap;}
|
||||
if(tilt_count)cover_key_row(tilt_keys,tilt_count,pad,y,inner,key_h,gap,key_icons);
|
||||
}
|
||||
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);
|
||||
@@ -895,8 +1053,8 @@ inline void show_detail(unsigned index){
|
||||
const lv_font_t *title_font=watch_font?watch_font:detail_font;
|
||||
auto *heading=detail_label(detail_root,t.name,bar_x+bar+8,bar_y+(bar-lv_font_get_line_height(title_font))/2,width-2*(bar_x+bar+8));
|
||||
lv_obj_set_style_text_font(heading,title_font,0);lv_obj_set_height(heading,lv_font_get_line_height(title_font));lv_obj_set_style_text_align(heading,LV_TEXT_ALIGN_CENTER,0);
|
||||
std::string state=detail_state(t);
|
||||
auto d=t.domain();
|
||||
std::string state=d=="cover"?cover_status_line(t):detail_state(t);
|
||||
// The vacuum card draws its own state (hero or status row) with the battery beside it.
|
||||
if(d!="vacuum"){detail_status=detail_label(detail_root,state+(t.unit.empty()?"":" "+t.unit),pad,large?80:50,width-2*pad);lv_obj_set_style_text_align(detail_status,LV_TEXT_ALIGN_CENTER,0);lv_obj_set_style_text_color(detail_status,lv_color_hex(0x616161),0);}
|
||||
if(t.is_switch()){
|
||||
@@ -922,6 +1080,9 @@ inline void show_detail(unsigned index){
|
||||
detail_actions[detail_action_count++]=detail_switch;
|
||||
}else if(d=="vacuum"){
|
||||
render_vacuum_detail(t,large,width,height,pad);
|
||||
}else if(d=="cover"){
|
||||
if(detail_status && control_font){lv_obj_set_style_text_font(detail_status,control_font,0);lv_obj_set_height(detail_status,lv_font_get_line_height(control_font));}
|
||||
render_cover_detail(t,large,width,height,pad);
|
||||
}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,"No numeric HA history",pad,top,width-2*pad);return;}
|
||||
@@ -1084,9 +1245,9 @@ inline void event(lv_event_t *event) {
|
||||
if(d=="climate" && tile.tap=="toggle" && code==LV_EVENT_SHORT_CLICKED){action("climate.toggle",tile.entity);return;}
|
||||
// A short tap runs or pauses the timer; holding opens the card with a cancel button.
|
||||
if(d=="timer" && !open){action(tile.state=="active"?"timer.pause":"timer.start",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" || d=="sun" || d=="person" || d=="timer") { tile.begin(esphome::millis(),true); active_index=w.index; show_detail(w.index); return; }
|
||||
if(d=="sensor" || d=="binary_sensor" || d=="weather" || d=="number" || d=="input_number" || d=="select" || d=="input_select" || d=="media_player" || d=="vacuum" || d=="cover" || d=="sun" || d=="person" || d=="timer") { tile.begin(esphome::millis(),true); active_index=w.index; show_detail(w.index); return; }
|
||||
if (open) {
|
||||
if (d == "light" || d == "climate" || d == "vacuum" || d == "fan" || d == "cover") {
|
||||
if (d == "light" || d == "climate" || d == "vacuum" || d == "fan") {
|
||||
active_index = w.index;
|
||||
tile.begin(esphome::millis(),true);
|
||||
if (detail) detail(tile);
|
||||
@@ -2318,7 +2479,7 @@ inline void tick() {
|
||||
if(detail_root && !lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN) && detail_index<model.count){
|
||||
auto &t=model.tiles[detail_index];bool waiting=t.awaiting_action(esphome::millis());
|
||||
for(unsigned i=0;i<detail_action_count;++i){if(waiting||!fresh()||!t.available())lv_obj_add_state(detail_actions[i],LV_STATE_DISABLED);else lv_obj_remove_state(detail_actions[i],LV_STATE_DISABLED);}
|
||||
std::string status=waiting?(t.confirmed?"Confirmed by Home Assistant":"Command sent..."):detail_state(t);
|
||||
std::string status=waiting?(t.confirmed?"Confirmed by Home Assistant":"Command sent..."):t.domain()=="cover"?cover_status_line(t):detail_state(t);
|
||||
if(detail_status)label(detail_status,status+(!waiting && !t.unit.empty()?" "+t.unit:""));
|
||||
// The vacuum card lays its state out with the room and battery beside it, so a new text draws the
|
||||
// card again; once Home Assistant answered, the new state says enough.
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace tile_controls {
|
||||
// Home Assistant supported_features bits.
|
||||
namespace feature {
|
||||
constexpr uint32_t COVER_OPEN = 1, COVER_CLOSE = 2, COVER_POSITION = 4, COVER_STOP = 8;
|
||||
constexpr uint32_t COVER_OPEN_TILT = 16, COVER_CLOSE_TILT = 32, COVER_STOP_TILT = 64, COVER_TILT_POSITION = 128;
|
||||
constexpr uint32_t MEDIA_PAUSE = 1, MEDIA_VOLUME_SET = 4, MEDIA_VOLUME_MUTE = 8, MEDIA_PREVIOUS = 16, MEDIA_NEXT = 32, MEDIA_PLAY = 16384;
|
||||
constexpr uint32_t VACUUM_TURN_ON = 1, VACUUM_TURN_OFF = 2, VACUUM_PAUSE = 4, VACUUM_STOP = 8, VACUUM_RETURN = 16, VACUUM_START = 8192;
|
||||
}
|
||||
@@ -24,11 +25,13 @@ constexpr const char *VOLUME = "\U000F057E", *MUTED = "\U000F0581", *UP = "\U000
|
||||
constexpr const char *EXPAND = "\U000F084E", *COLLAPSE = "\U000F084C", *DOCK = "\U000F05F8", *PLUS = "\U000F0415", *MINUS = "\U000F0374";
|
||||
constexpr const char *LEFT = "\U000F0141", *RIGHT = "\U000F0142", *CLOSE = "\U000F0156", *POWER = "\U000F0425", *FIRE = "\U000F0238";
|
||||
constexpr const char *SNOWFLAKE = "\U000F0717", *HEAT_COOL = "\U000F1A79", *AUTO = "\U000F1B17", *DRY = "\U000F058E", *FAN = "\U000F0210";
|
||||
constexpr const char *BLINDS_OPEN = "\U000F1011", *BLINDS = "\U000F00AC";
|
||||
}
|
||||
enum Command {
|
||||
NONE = 0, COVER_OPEN, COVER_STOP, COVER_CLOSE, VACUUM_START, VACUUM_PAUSE, VACUUM_STOP, VACUUM_DOCK,
|
||||
MEDIA_PREVIOUS, MEDIA_PLAY_PAUSE, MEDIA_NEXT, MEDIA_MUTE, TIMER_START, TIMER_PAUSE, TIMER_CANCEL,
|
||||
HVAC_MODE, SELECT_PREVIOUS, SELECT_NEXT, RUN, TOGGLE, STEP_DOWN, STEP_UP
|
||||
HVAC_MODE, SELECT_PREVIOUS, SELECT_NEXT, RUN, TOGGLE, STEP_DOWN, STEP_UP,
|
||||
COVER_OPEN_TILT, COVER_STOP_TILT, COVER_CLOSE_TILT
|
||||
};
|
||||
struct Key { const char *icon = ""; int command = NONE; std::string arg; bool checked = false, disabled = false; };
|
||||
struct Action { std::string service, key, value; bool valid() const { return !service.empty(); } };
|
||||
@@ -154,6 +157,49 @@ inline std::string status_text(const Tile &t) {
|
||||
}
|
||||
return {};
|
||||
}
|
||||
// The cover card (firmware 0.2.50+) offers what Home Assistant's own dialog does: a position slider when the
|
||||
// cover reports positions, a tilt slider for slats, open, stop and close, and the tilt keys of slats that
|
||||
// tilt without a position. A cover whose features are not known yet gets the three keys.
|
||||
struct CoverCard { bool position = false, tilt = false, keys = false, tilt_keys = false; };
|
||||
inline CoverCard cover_card(const Tile &t) {
|
||||
CoverCard card;
|
||||
const uint32_t f = t.supported;
|
||||
if (!f) { card.keys = true; return card; }
|
||||
card.position = f & feature::COVER_POSITION;
|
||||
card.tilt = f & feature::COVER_TILT_POSITION;
|
||||
card.keys = f & (feature::COVER_OPEN | feature::COVER_CLOSE | feature::COVER_STOP);
|
||||
card.tilt_keys = !card.tilt && (f & (feature::COVER_OPEN_TILT | feature::COVER_CLOSE_TILT | feature::COVER_STOP_TILT));
|
||||
return card;
|
||||
}
|
||||
// The card's status line: the state, the position, and the tilt where the cover has one.
|
||||
inline std::string cover_card_status(const Tile &t) {
|
||||
std::string text = status_text(t);
|
||||
if ((t.supported & feature::COVER_TILT_POSITION) && std::isfinite(t.extra().tilt)) {
|
||||
char b[24]; snprintf(b, sizeof(b), " · Tilt %d%%", (int) std::lround(t.extra().tilt)); text += b;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
// Open, stop and close as the cover supports them (all three while its features are unknown). A key that
|
||||
// cannot move the cover further is disabled, unless the cover is on its way the other way.
|
||||
inline unsigned cover_keys(const Tile &t, std::array<Key, 3> &out) {
|
||||
unsigned n = 0;
|
||||
const uint32_t f = t.supported ? t.supported : feature::COVER_OPEN | feature::COVER_STOP | feature::COVER_CLOSE;
|
||||
bool sideways = sideways_cover(t.device_class);
|
||||
bool fully_open = std::isfinite(t.position) ? t.position >= 99.5f : t.state == "open";
|
||||
bool fully_closed = std::isfinite(t.position) ? t.position <= 0.5f : t.state == "closed";
|
||||
if (f & feature::COVER_OPEN) out[n++] = Key{sideways ? glyph::EXPAND : glyph::UP, COVER_OPEN, "", t.state == "opening", fully_open && t.state != "closing"};
|
||||
if (f & feature::COVER_STOP) out[n++] = Key{glyph::STOP, COVER_STOP, "", false, false};
|
||||
if (f & feature::COVER_CLOSE) out[n++] = Key{sideways ? glyph::COLLAPSE : glyph::DOWN, COVER_CLOSE, "", t.state == "closing", fully_closed && t.state != "opening"};
|
||||
return n;
|
||||
}
|
||||
inline unsigned cover_tilt_keys(const Tile &t, std::array<Key, 3> &out) {
|
||||
unsigned n = 0;
|
||||
const float tilt = t.extra().tilt;
|
||||
if (t.supported & feature::COVER_OPEN_TILT) out[n++] = Key{glyph::BLINDS_OPEN, COVER_OPEN_TILT, "", false, std::isfinite(tilt) && tilt >= 99.5f};
|
||||
if (t.supported & feature::COVER_STOP_TILT) out[n++] = Key{glyph::STOP, COVER_STOP_TILT, "", false, false};
|
||||
if (t.supported & feature::COVER_CLOSE_TILT) out[n++] = Key{glyph::BLINDS, COVER_CLOSE_TILT, "", false, std::isfinite(tilt) && tilt <= 0.5f};
|
||||
return n;
|
||||
}
|
||||
// The row of up to three pill keys for a key-row panel; returns how many.
|
||||
inline unsigned keys_for(const Tile &t, std::array<Key, 3> &out) {
|
||||
auto d = t.domain(); auto c = panel_kind(t); unsigned n = 0;
|
||||
@@ -161,12 +207,8 @@ inline unsigned keys_for(const Tile &t, std::array<Key, 3> &out) {
|
||||
if (n < out.size()) out[n++] = Key{icon, command, arg, checked, disabled};
|
||||
};
|
||||
if (c == "buttons" && d == "cover") {
|
||||
bool sideways = sideways_cover(t.device_class);
|
||||
bool fully_open = std::isfinite(t.position) ? t.position >= 99.5f : t.state == "open";
|
||||
bool fully_closed = std::isfinite(t.position) ? t.position <= 0.5f : t.state == "closed";
|
||||
if (t.supported & feature::COVER_OPEN) add(sideways ? glyph::EXPAND : glyph::UP, COVER_OPEN, fully_open && t.state != "closing");
|
||||
if (t.supported & feature::COVER_STOP) add(glyph::STOP, COVER_STOP);
|
||||
if (t.supported & feature::COVER_CLOSE) add(sideways ? glyph::COLLAPSE : glyph::DOWN, COVER_CLOSE, fully_closed && t.state != "opening");
|
||||
// The tile keys are the card's keys, without the direction a moving cover shows there.
|
||||
if (t.supported) for (unsigned i = 0, count = cover_keys(t, out); i < count; ++i) { out[i].checked = false; ++n; }
|
||||
} else if (c == "buttons" && d == "vacuum") {
|
||||
bool cleaning = t.state == "cleaning";
|
||||
if (cleaning && (t.supported & feature::VACUUM_PAUSE)) add(glyph::PAUSE, VACUUM_PAUSE);
|
||||
@@ -211,6 +253,9 @@ inline Action key_action(const Tile &t, int command, const std::string &arg = ""
|
||||
case COVER_OPEN: return {"cover.open_cover", "", ""};
|
||||
case COVER_STOP: return {"cover.stop_cover", "", ""};
|
||||
case COVER_CLOSE: return {"cover.close_cover", "", ""};
|
||||
case COVER_OPEN_TILT: return {"cover.open_cover_tilt", "", ""};
|
||||
case COVER_STOP_TILT: return {"cover.stop_cover_tilt", "", ""};
|
||||
case COVER_CLOSE_TILT: return {"cover.close_cover_tilt", "", ""};
|
||||
case VACUUM_START: return {(t.supported & feature::VACUUM_START) ? "vacuum.start" : "vacuum.turn_on", "", ""};
|
||||
case VACUUM_PAUSE: return {"vacuum.pause", "", ""};
|
||||
case VACUUM_STOP: return {(t.supported & feature::VACUUM_STOP) ? "vacuum.stop" : "vacuum.turn_off", "", ""};
|
||||
|
||||
@@ -176,6 +176,37 @@ icons sit off-center in the browser); run `generate_packages.py` afterward.
|
||||
The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual
|
||||
profiles must come from the set. No changed preferences or keys.
|
||||
|
||||
### Compatibility 0.2.58 / firmware 0.2.50
|
||||
|
||||
App, both board profiles and `components/smart_display`. Storage, tile protocol, preferences and keys are
|
||||
unchanged; the state message gains one attribute and one extra key that older firmware ignores.
|
||||
|
||||
- Cover card: `runtime_tiles::event` sends a tap on a cover to `show_detail`, like the other runtime cards, instead
|
||||
of `detail()` (the board's value overlay in `cover_position` mode, which stays in the profiles for now). The
|
||||
`preview_runtime_card` action of both profiles does the same. `render_cover_detail` draws what
|
||||
`tile_controls::cover_card` offers from `supported_features` (position 4, tilt position 128, open/close/stop
|
||||
1/2/8, tilt open/close/stop 16/32/64; unknown features get the three keys): a vertical `lv_slider` per movement
|
||||
in a white card, its value and name below it (beside it on the CYD), and a row of pill keys. The position
|
||||
slider's range is reversed (1000 down to a stub below 0) so the fill hangs from the top by how far the cover is
|
||||
closed; the tilt slider has a transparent fill, a margin past both ends for its handle, and slats drawn behind
|
||||
it. Track and fill share one radius, so neither draws into a layer. A slider sends `cover.set_cover_position` or
|
||||
`cover.set_cover_tilt_position` on RELEASED after `touch_guard.accept_slider`. Keys use detail commands
|
||||
70 + `tile_controls::Command` through `key_action`; a disabled key is left out of `detail_actions`, so the
|
||||
card's second tick does not enable it again. The tick keeps `cover_status_line` ("Open · 60% · Tilt 40%").
|
||||
- `Extra::tilt` holds `current_tilt_position`; the app adds it to `ATTRS`. A cover's device battery travels as
|
||||
the extra `bat` (and `chg`) the vacuum card introduced: `core.device_power` and `cover_related` pick the
|
||||
device's `battery` and `battery_charging` sensors, `Manager.related_entities` watches them, and `tile_message`
|
||||
passes the device entries for covers too. The card draws the battery at the top right with `vacuum_battery`.
|
||||
- `apply_screen_settings` publishes a setting number that has no state yet: `number::Number::state` starts at
|
||||
0, so a standby or night brightness of 0 never differed from it and Home Assistant kept "unknown". Seen on
|
||||
Studio 1 (night brightness) after updating to 0.2.49.
|
||||
- `discover_screens` ignores `unknown` and `unavailable` in the diagnostic sensors: the device name pattern
|
||||
accepted "unavailable" while a screen restarted, and `publish_layouts` wrote `sensor.esp_screens_unavailable`.
|
||||
- Editor: the first full inventory redraws the top bar, tiles and entity list when the live stream opened a
|
||||
screen before the catalogue arrived. `index.html` is served with `?v=<content hash>` on `static/app.js` and
|
||||
`static/style.css`. The responses already carried `Cache-Control: no-store`, yet Safari showed an old script
|
||||
with the new page after the update to 0.2.57.
|
||||
|
||||
### Compatibility 0.2.57 / firmware 0.2.49
|
||||
|
||||
App, both board profiles and `components/smart_display`. Storage version 1, tile protocol `v: 1`, preference
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Test results app 0.2.58 / firmware 0.2.50 (2026-09-16)
|
||||
|
||||
Max asked for a detail card for blinds (his three Motionblinds venetian blinds report every cover feature and a
|
||||
battery sensor on their device) in the style of the climate and vacuum cards. Checking Studio 1 twenty minutes
|
||||
after its update to 0.2.49 found two bugs, and opening ESP Screens during that update a third. See CHANGELOG
|
||||
0.2.58 and the compatibility note in docs/RELEASING.md.
|
||||
|
||||
## Automated
|
||||
|
||||
- Python (`.venv-portal`): 254 tests OK. New: `tests/test_cover_card.py` (the tilt and the device battery in
|
||||
the state message, the battery sensor watched, no layout sensor or node name "unavailable" while a screen
|
||||
restarts, the tap and preview routing to the cover card, commits on release, the tick's status line,
|
||||
disabled keys kept out of the tick, one radius for track and fill, every setting number published when it
|
||||
has no state), `tests/test_editor_startup.py` (the first full inventory redraws the tiles, top bar and
|
||||
entity list) and `tests/test_page_assets.py` (script and styles by content stamp).
|
||||
- C++ (`clang++ -std=c++17 -Wall -Wextra -Werror -I. -I components/smart_display`): 14/14 PASS.
|
||||
`test_tile_controls` adds the cover card: what a venetian blind (255), curtains (15), a garage door (11),
|
||||
slats that tilt without a position (51) and a cover without features get, the status line with the tilt, keys filled while
|
||||
moving and disabled at the ends, and the tilt actions.
|
||||
- `generate_packages.py --check` green.
|
||||
|
||||
## Builds (ESPHome 2026.6.2)
|
||||
|
||||
Check profiles with the generated packages and local components, the same as for 0.2.57.
|
||||
|
||||
| Profile | RAM | Flash | vs 0.2.57 |
|
||||
|---|---|---|---|
|
||||
| CYD | 23.4% (76,548 B) | 76.9% (1,411,823 B) | +8 B RAM, +5,596 B flash |
|
||||
| Guition | 24.4% (79,924 B) | 18.6% (1,515,263 B) | +8 B RAM, +4,708 B flash |
|
||||
|
||||
The first ESP32 build failed on `std::clamp(lv_slider_get_value(...), 0, 1000)`: `int32_t` is a `long` on the
|
||||
ESP32 toolchain, so the host builds cannot catch this. Fixed with `std::clamp<int>` before any release.
|
||||
|
||||
## Firmware on the Mac (host builds, driven over the ESPHome API)
|
||||
|
||||
- Cover card, Guition 20/20 and CYD 20/20 (the CYD run three times): a venetian blind with the demo home's
|
||||
state opens its card with "Open · 60% · Tilt 40%", both sliders and three keys; the position slider shows
|
||||
75% while it moves and sends `cover.set_cover_position` 75 on release, the tilt slider
|
||||
`cover.set_cover_tilt_position` 80; open, stop and close send their actions; a closing state at 30 % redraws
|
||||
the card with the close key filled, and the tick keeps the status line; fully open disables the open key
|
||||
after the tick and a tap on it sends nothing; unavailable disables sliders and keys; curtains get one slider,
|
||||
a garage door only keys with close disabled while closed, and its open key works; a tap on the blind's tile
|
||||
opens the card; no errors in the log.
|
||||
- A timing flake in the first CYD run (a key pressed 1.2 s after Home Assistant's answer, while the card's
|
||||
once-a-second tick still showed the keys disabled) was the test waiting too briefly; the card behaves as the
|
||||
vacuum card does.
|
||||
- Renders of the blind, curtains and garage door cards on both boards; the README shows them.
|
||||
|
||||
## Studio 1 after updating to 0.2.57 / firmware 0.2.49 (16:20, read-only through Home Assistant)
|
||||
|
||||
- The update succeeded ("Updated to firmware 0.2.49"): one restart at 16:10, back online within seconds on the
|
||||
same IP address, "Synced" within a second of reconnecting, no repairs, no add-on warnings; Home Assistant
|
||||
lists `esphome.studio_1_screen_message` as answering.
|
||||
- Every setting entity had its value except `number.studio_1_night_brightness` ("unknown", so the panel showed
|
||||
it as unknown and refused changes): the number fix above.
|
||||
- `sensor.esp_screens_unavailable` next to `sensor.esp_screens_studio_1`, written at the restart: the node fix
|
||||
above. Home Assistant drops the stale sensor at its next restart.
|
||||
- An empty Screen settings panel in Safari was an old cached script (the served files matched the release);
|
||||
the page now loads its files by content stamp.
|
||||
|
||||
## Not tested / for the owner
|
||||
|
||||
- Real hardware and real blinds: the Motionblinds were unavailable during this work. On Studio 1 after the
|
||||
update: open a blind's card, drag both sliders and use the keys, check the battery, and that
|
||||
`number.studio_1_night_brightness` has a value in Home Assistant.
|
||||
- Free heap on a CYD with the cover card open.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
+13
-8
@@ -2,7 +2,7 @@
|
||||
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
|
||||
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
|
||||
substitutions:
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.49"
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.50"
|
||||
# 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not
|
||||
# (like iOS). The CYD keeps a limit because its resistive panel jumps.
|
||||
TOUCH_MOVE_LIMIT_PX: "0"
|
||||
@@ -378,8 +378,9 @@ api:
|
||||
runtime_tiles::active_index = index;
|
||||
auto &tile = runtime_tiles::model.tiles[index];
|
||||
auto domain = tile.domain();
|
||||
// Lights, fans, covers and climate open the board's own card, like a long press does.
|
||||
if ((domain == "light" || domain == "fan" || domain == "cover" || domain == "climate") && runtime_tiles::detail) {
|
||||
// Lights, fans and climate open the board's own card, like a long press does; covers (firmware 0.2.50+)
|
||||
// open the runtime card, as a tap does.
|
||||
if ((domain == "light" || domain == "fan" || domain == "climate") && runtime_tiles::detail) {
|
||||
tile.begin(millis(), true);
|
||||
runtime_tiles::detail(tile);
|
||||
return;
|
||||
@@ -3696,11 +3697,15 @@ script:
|
||||
else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN);
|
||||
// The setting numbers and times only publish a changed value (HA keeps identical states anyway); the
|
||||
// switches publish their own changes. An open settings page shows a change from Home Assistant too.
|
||||
if (id(setting_brightness).state != settings.brightness) id(setting_brightness).publish_state(settings.brightness);
|
||||
if (id(setting_standby_brightness).state != settings.standby_brightness) id(setting_standby_brightness).publish_state(settings.standby_brightness);
|
||||
if (id(setting_night_brightness).state != settings.night_brightness) id(setting_night_brightness).publish_state(settings.night_brightness);
|
||||
if (id(setting_standby_seconds).state != settings.standby_seconds) id(setting_standby_seconds).publish_state(settings.standby_seconds);
|
||||
if (id(setting_auto_home_seconds).state != settings_screen::auto_home_seconds) id(setting_auto_home_seconds).publish_state(settings_screen::auto_home_seconds);
|
||||
// A number without a state publishes too: a night brightness of 0 equals the number's initial state and
|
||||
// stayed "unknown" in Home Assistant before firmware 0.2.50.
|
||||
for (auto [entity, value] : {std::make_pair(id(setting_brightness), settings.brightness),
|
||||
std::make_pair(id(setting_standby_brightness), settings.standby_brightness),
|
||||
std::make_pair(id(setting_night_brightness), settings.night_brightness),
|
||||
std::make_pair(id(setting_standby_seconds), settings.standby_seconds),
|
||||
std::make_pair(id(setting_auto_home_seconds), settings_screen::auto_home_seconds)}) {
|
||||
if (!entity->has_state() || entity->state != value) entity->publish_state(value);
|
||||
}
|
||||
for (auto [entity, minutes] : {std::make_pair(id(setting_night_start), settings.night_start), std::make_pair(id(setting_night_end), settings.night_end)}) {
|
||||
if (!entity->has_state() || entity->hour * 60 + entity->minute != minutes) entity->update();
|
||||
}
|
||||
|
||||
+13
-8
@@ -17,7 +17,7 @@
|
||||
############################################################
|
||||
|
||||
substitutions:
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.49"
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.50"
|
||||
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
|
||||
TOUCH_MOVE_LIMIT_PX: "56"
|
||||
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
|
||||
@@ -495,8 +495,9 @@ api:
|
||||
runtime_tiles::active_index = index;
|
||||
auto &tile = runtime_tiles::model.tiles[index];
|
||||
auto domain = tile.domain();
|
||||
// Lights, fans, covers and climate open the board's own card, like a long press does.
|
||||
if ((domain == "light" || domain == "fan" || domain == "cover" || domain == "climate") && runtime_tiles::detail) {
|
||||
// Lights, fans and climate open the board's own card, like a long press does; covers (firmware 0.2.50+)
|
||||
// open the runtime card, as a tap does.
|
||||
if ((domain == "light" || domain == "fan" || domain == "climate") && runtime_tiles::detail) {
|
||||
tile.begin(millis(), true);
|
||||
runtime_tiles::detail(tile);
|
||||
return;
|
||||
@@ -3702,11 +3703,15 @@ script:
|
||||
else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN);
|
||||
// The setting numbers and times only publish a changed value (HA keeps identical states anyway); the
|
||||
// switches publish their own changes. An open settings page shows a change from Home Assistant too.
|
||||
if (id(setting_brightness).state != settings.brightness) id(setting_brightness).publish_state(settings.brightness);
|
||||
if (id(setting_standby_brightness).state != settings.standby_brightness) id(setting_standby_brightness).publish_state(settings.standby_brightness);
|
||||
if (id(setting_night_brightness).state != settings.night_brightness) id(setting_night_brightness).publish_state(settings.night_brightness);
|
||||
if (id(setting_standby_seconds).state != settings.standby_seconds) id(setting_standby_seconds).publish_state(settings.standby_seconds);
|
||||
if (id(setting_auto_home_seconds).state != settings_screen::auto_home_seconds) id(setting_auto_home_seconds).publish_state(settings_screen::auto_home_seconds);
|
||||
// A number without a state publishes too: a night brightness of 0 equals the number's initial state and
|
||||
// stayed "unknown" in Home Assistant before firmware 0.2.50.
|
||||
for (auto [entity, value] : {std::make_pair(id(setting_brightness), settings.brightness),
|
||||
std::make_pair(id(setting_standby_brightness), settings.standby_brightness),
|
||||
std::make_pair(id(setting_night_brightness), settings.night_brightness),
|
||||
std::make_pair(id(setting_standby_seconds), settings.standby_seconds),
|
||||
std::make_pair(id(setting_auto_home_seconds), settings_screen::auto_home_seconds)}) {
|
||||
if (!entity->has_state() || entity->state != value) entity->publish_state(value);
|
||||
}
|
||||
for (auto [entity, minutes] : {std::make_pair(id(setting_night_start), settings.night_start), std::make_pair(id(setting_night_end), settings.night_end)}) {
|
||||
if (!entity->has_state() || entity->hour * 60 + entity->minute != minutes) entity->update();
|
||||
}
|
||||
|
||||
+13
-8
@@ -18,7 +18,7 @@
|
||||
############################################################
|
||||
|
||||
substitutions:
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.49"
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.50"
|
||||
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
|
||||
TOUCH_MOVE_LIMIT_PX: "56"
|
||||
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
|
||||
@@ -499,8 +499,9 @@ api:
|
||||
runtime_tiles::active_index = index;
|
||||
auto &tile = runtime_tiles::model.tiles[index];
|
||||
auto domain = tile.domain();
|
||||
// Lights, fans, covers and climate open the board's own card, like a long press does.
|
||||
if ((domain == "light" || domain == "fan" || domain == "cover" || domain == "climate") && runtime_tiles::detail) {
|
||||
// Lights, fans and climate open the board's own card, like a long press does; covers (firmware 0.2.50+)
|
||||
// open the runtime card, as a tap does.
|
||||
if ((domain == "light" || domain == "fan" || domain == "climate") && runtime_tiles::detail) {
|
||||
tile.begin(millis(), true);
|
||||
runtime_tiles::detail(tile);
|
||||
return;
|
||||
@@ -3691,11 +3692,15 @@ script:
|
||||
else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN);
|
||||
// The setting numbers and times only publish a changed value (HA keeps identical states anyway); the
|
||||
// switches publish their own changes. An open settings page shows a change from Home Assistant too.
|
||||
if (id(setting_brightness).state != settings.brightness) id(setting_brightness).publish_state(settings.brightness);
|
||||
if (id(setting_standby_brightness).state != settings.standby_brightness) id(setting_standby_brightness).publish_state(settings.standby_brightness);
|
||||
if (id(setting_night_brightness).state != settings.night_brightness) id(setting_night_brightness).publish_state(settings.night_brightness);
|
||||
if (id(setting_standby_seconds).state != settings.standby_seconds) id(setting_standby_seconds).publish_state(settings.standby_seconds);
|
||||
if (id(setting_auto_home_seconds).state != settings_screen::auto_home_seconds) id(setting_auto_home_seconds).publish_state(settings_screen::auto_home_seconds);
|
||||
// A number without a state publishes too: a night brightness of 0 equals the number's initial state and
|
||||
// stayed "unknown" in Home Assistant before firmware 0.2.50.
|
||||
for (auto [entity, value] : {std::make_pair(id(setting_brightness), settings.brightness),
|
||||
std::make_pair(id(setting_standby_brightness), settings.standby_brightness),
|
||||
std::make_pair(id(setting_night_brightness), settings.night_brightness),
|
||||
std::make_pair(id(setting_standby_seconds), settings.standby_seconds),
|
||||
std::make_pair(id(setting_auto_home_seconds), settings_screen::auto_home_seconds)}) {
|
||||
if (!entity->has_state() || entity->state != value) entity->publish_state(value);
|
||||
}
|
||||
for (auto [entity, minutes] : {std::make_pair(id(setting_night_start), settings.night_start), std::make_pair(id(setting_night_end), settings.night_end)}) {
|
||||
if (!entity->has_state() || entity->hour * 60 + entity->minute != minutes) entity->update();
|
||||
}
|
||||
|
||||
+13
-8
@@ -3,7 +3,7 @@
|
||||
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
|
||||
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
|
||||
substitutions:
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.49"
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.50"
|
||||
# 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not
|
||||
# (like iOS). The CYD keeps a limit because its resistive panel jumps.
|
||||
TOUCH_MOVE_LIMIT_PX: "0"
|
||||
@@ -382,8 +382,9 @@ api:
|
||||
runtime_tiles::active_index = index;
|
||||
auto &tile = runtime_tiles::model.tiles[index];
|
||||
auto domain = tile.domain();
|
||||
// Lights, fans, covers and climate open the board's own card, like a long press does.
|
||||
if ((domain == "light" || domain == "fan" || domain == "cover" || domain == "climate") && runtime_tiles::detail) {
|
||||
// Lights, fans and climate open the board's own card, like a long press does; covers (firmware 0.2.50+)
|
||||
// open the runtime card, as a tap does.
|
||||
if ((domain == "light" || domain == "fan" || domain == "climate") && runtime_tiles::detail) {
|
||||
tile.begin(millis(), true);
|
||||
runtime_tiles::detail(tile);
|
||||
return;
|
||||
@@ -3685,11 +3686,15 @@ script:
|
||||
else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN);
|
||||
// The setting numbers and times only publish a changed value (HA keeps identical states anyway); the
|
||||
// switches publish their own changes. An open settings page shows a change from Home Assistant too.
|
||||
if (id(setting_brightness).state != settings.brightness) id(setting_brightness).publish_state(settings.brightness);
|
||||
if (id(setting_standby_brightness).state != settings.standby_brightness) id(setting_standby_brightness).publish_state(settings.standby_brightness);
|
||||
if (id(setting_night_brightness).state != settings.night_brightness) id(setting_night_brightness).publish_state(settings.night_brightness);
|
||||
if (id(setting_standby_seconds).state != settings.standby_seconds) id(setting_standby_seconds).publish_state(settings.standby_seconds);
|
||||
if (id(setting_auto_home_seconds).state != settings_screen::auto_home_seconds) id(setting_auto_home_seconds).publish_state(settings_screen::auto_home_seconds);
|
||||
// A number without a state publishes too: a night brightness of 0 equals the number's initial state and
|
||||
// stayed "unknown" in Home Assistant before firmware 0.2.50.
|
||||
for (auto [entity, value] : {std::make_pair(id(setting_brightness), settings.brightness),
|
||||
std::make_pair(id(setting_standby_brightness), settings.standby_brightness),
|
||||
std::make_pair(id(setting_night_brightness), settings.night_brightness),
|
||||
std::make_pair(id(setting_standby_seconds), settings.standby_seconds),
|
||||
std::make_pair(id(setting_auto_home_seconds), settings_screen::auto_home_seconds)}) {
|
||||
if (!entity->has_state() || entity->state != value) entity->publish_state(value);
|
||||
}
|
||||
for (auto [entity, minutes] : {std::make_pair(id(setting_night_start), settings.night_start), std::make_pair(id(setting_night_end), settings.night_end)}) {
|
||||
if (!entity->has_state() || entity->hour * 60 + entity->minute != minutes) entity->update();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
## 0.2.58 (firmware 0.2.50)
|
||||
|
||||
A card for blinds, curtains and garage doors, and three fixes found right after updating to 0.2.57.
|
||||
|
||||
- **Cover card.** Tapping a blind, curtain, shutter or garage door opens a card like Home Assistant's own, in the style of the climate and vacuum cards: a tall position slider on which the blind hangs from the top, a tilt slider over slats for venetian blinds, and open, stop and close keys. The key of the direction the cover is moving is filled, and a key that can't move it further is greyed out. A slider shows its value while you drag and sends it when you let go. A battery-powered blind, such as Motionblinds, shows its battery at the top right. The card shows only what the cover supports: a garage door that only opens and closes gets just the keys.
|
||||
- **Night brightness 0 % now shows in Home Assistant.** A brightness setting whose value is 0 stayed "unknown" in Home Assistant, so ESP Screens could not change it on a screen with firmware 0.2.49.
|
||||
- **No second layout sensor while a screen restarts.** During a restart ESP Screens took "unavailable" for the screen's device name and wrote `sensor.esp_screens_unavailable`. Home Assistant drops that sensor at its next restart.
|
||||
- **The entity list is filled when the page opens.** When ESP Screens was busy, such as while building firmware, the page could open a screen before its entities arrived: "No entities found" until you picked a filter, and tile names showing as entity IDs.
|
||||
- **No stale page after an update.** A browser that keeps old files, as Safari did, could combine the new page with the old script and show an empty Screen settings panel. The page now always loads the script and styles of the version that is running.
|
||||
- Needs firmware 0.2.50 for the cover card and the brightness fix: press **Update** on the screen.
|
||||
|
||||
## 0.2.57 (firmware 0.2.49)
|
||||
|
||||
The screen keeps its own settings, and ESP Screens shows them the way the screen does.
|
||||
|
||||
+27
-12
@@ -17,7 +17,7 @@ WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
|
||||
REFS = {'cyd': 'main', 'guition': 'main'}
|
||||
# Firmware shipped with this app release; screens below it get an update offer.
|
||||
FIRMWARE_VERSION = '0.2.49'
|
||||
FIRMWARE_VERSION = '0.2.50'
|
||||
# The Auto standby switch a screen offers Home Assistant automations.
|
||||
AUTO_STANDBY_MIN_FIRMWARE = '0.2.41'
|
||||
# The settings page the screen opens itself, and the screen.settings tile that opens it.
|
||||
@@ -26,7 +26,7 @@ SETTINGS_PAGE_MIN_FIRMWARE = '0.2.44'
|
||||
WAKE_SLEEP_MIN_FIRMWARE = '0.2.45'
|
||||
# Every screen setting as an entity of the screen, which owns them (see SETTING_ENTITIES).
|
||||
SETTING_ENTITIES_MIN_FIRMWARE = '0.2.49'
|
||||
ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hvac_action 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 is_volume_muted media_title options min max step temperature_unit supported_features device_class next_rising next_setting finishes_at duration remaining humidity wind_speed wind_speed_unit apparent_temperature fan_modes swing_modes fan_mode swing_mode'.split())
|
||||
ATTRS = frozenset('brightness percentage current_position current_tilt_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hvac_action 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 is_volume_muted media_title options min max step temperature_unit supported_features device_class next_rising next_setting finishes_at duration remaining humidity wind_speed wind_speed_unit apparent_temperature fan_modes swing_modes fan_mode swing_mode'.split())
|
||||
# Attributes whose boolean value the screen needs; every other bool stays behind.
|
||||
BOOL_ATTRS = frozenset(['is_volume_muted'])
|
||||
|
||||
@@ -758,6 +758,26 @@ def vacuum_related(entity, device, states):
|
||||
found[role] = eid
|
||||
return found
|
||||
|
||||
def device_power(attrs, related, states):
|
||||
"""`bat` from the device's battery sensor when the entity has no battery_level of its own, and `chg` while its
|
||||
charging sensor is on; empty without them."""
|
||||
result = {}
|
||||
if not isinstance(attrs.get('battery_level'), (int, float)) and 'battery' in related:
|
||||
try:
|
||||
level = float(states[related['battery']].get('state'))
|
||||
except (TypeError, ValueError, KeyError):
|
||||
level = math.nan
|
||||
if math.isfinite(level):
|
||||
result['bat'] = max(0, min(100, round(level)))
|
||||
if states.get(related.get('charging'), {}).get('state') == 'on':
|
||||
result['chg'] = 1
|
||||
return result
|
||||
|
||||
def cover_related(entity, device, states):
|
||||
"""A cover's battery and charging sensors on its device (app 0.2.58): battery-powered blinds such as
|
||||
Motionblinds report the battery there."""
|
||||
return {role: eid for role, eid in vacuum_related(entity, device, states).items() if role in ('battery', 'charging')}
|
||||
|
||||
def vacuum_extras(tile, states, device):
|
||||
"""The vacuum card's rows and details: the mode and water selects (`e` entity, `s` state, `o` options,
|
||||
`l` labels, `r` a role per mode), the suction speeds to offer (`fan`), the battery (`bat`), charging
|
||||
@@ -788,15 +808,7 @@ def vacuum_extras(tile, states, device):
|
||||
fan = row([s for s in speeds if isinstance(s, str) and s not in covered]) if isinstance(speeds, list) else None
|
||||
if fan:
|
||||
result['fan'] = fan
|
||||
if not isinstance(attrs.get('battery_level'), (int, float)) and 'battery' in related:
|
||||
try:
|
||||
level = float(states[related['battery']].get('state'))
|
||||
except (TypeError, ValueError, KeyError):
|
||||
level = math.nan
|
||||
if math.isfinite(level):
|
||||
result['bat'] = max(0, min(100, round(level)))
|
||||
if states.get(related.get('charging'), {}).get('state') == 'on':
|
||||
result['chg'] = 1
|
||||
result.update(device_power(attrs, related, states))
|
||||
room = states.get(related.get('room'), {}).get('state')
|
||||
if isinstance(room, str) and room not in ('', 'unknown', 'unavailable', 'none'):
|
||||
result['room'] = short(room, 32)
|
||||
@@ -808,6 +820,8 @@ def extras(tile, states, forecast=None, tz=None, hourly=None, now=None, device=N
|
||||
attrs = states.get(tile['entity'], {}).get('attributes', {})
|
||||
if domain == 'vacuum':
|
||||
return vacuum_extras(tile, states, device)
|
||||
if domain == 'cover':
|
||||
return device_power(attrs, cover_related(tile['entity'], device, states), states) or None
|
||||
if domain == 'weather' and (forecast or hourly):
|
||||
result = {}
|
||||
days = []
|
||||
@@ -1072,7 +1086,8 @@ def discover_screens(registry, states, devices, areas):
|
||||
for item in registry:
|
||||
if item.get('platform') == 'esphome' and item.get('original_name') in names:
|
||||
value = states.get(item['entity_id'], {}).get('state', '')
|
||||
if isinstance(value, str) and re.fullmatch(pattern, value):
|
||||
# A screen that restarts reports "unavailable", which reads like a device name.
|
||||
if isinstance(value, str) and value not in ('unknown', 'unavailable') and re.fullmatch(pattern, value):
|
||||
found[item.get('device_id')] = value
|
||||
return found
|
||||
nodes = diagnostic(NAME_DEVICE_NAME, r'[a-z0-9][a-z0-9-]{0,30}')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""HA Ingress app. HA writes: text.set_value on discovered inboxes (or the screen_message action on firmware 0.2.33+), each screen's alert actions when an alert event for every screen fires, and one persistent notification when a nightly update stops."""
|
||||
import asyncio
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -671,11 +672,14 @@ class Manager:
|
||||
return self._by_device.get(device, []) if device else []
|
||||
|
||||
def related_entities(self, tile):
|
||||
"""Entities a card reads besides its own: a vacuum's cleaning mode and water selects and its battery sensor."""
|
||||
if not tile['entity'].startswith('vacuum.'):
|
||||
return ()
|
||||
from core import vacuum_related
|
||||
return tuple(vacuum_related(tile['entity'], self.device_entries(tile['entity']), self.ha.states).values())
|
||||
"""Entities a card reads besides its own: a vacuum's cleaning mode and water selects and its battery sensor, a
|
||||
cover's battery sensor."""
|
||||
from core import cover_related, vacuum_related
|
||||
if tile['entity'].startswith('vacuum.'):
|
||||
return tuple(vacuum_related(tile['entity'], self.device_entries(tile['entity']), self.ha.states).values())
|
||||
if tile['entity'].startswith('cover.'):
|
||||
return tuple(cover_related(tile['entity'], self.device_entries(tile['entity']), self.ha.states).values())
|
||||
return ()
|
||||
|
||||
def header_message(self, layout):
|
||||
return header_bar.message(layout, self.ha.states, self.registry_index(), getattr(self.ha, 'units', {}), getattr(self.ha, 'time_zone', None))
|
||||
@@ -802,8 +806,8 @@ class Manager:
|
||||
forecast=await self.cached(self.forecasts, entity, FORECAST_SECONDS, (lambda: self.ha.forecast(entity)) if 'daily' in kinds else nothing)
|
||||
# Hourly forecasts feed the weather card's next-hours strip (0.2.23+); refreshed every half hour.
|
||||
hourly=await self.cached(self.forecasts, (entity,'hourly'), FORECAST_SECONDS, (lambda: self.ha.forecast(entity,'hourly')) if 'hourly' in kinds else nothing)
|
||||
# A vacuum's card also reads selects and the battery sensor of its device (app 0.2.46).
|
||||
device=self.device_entries(tile['entity']) if tile['entity'].startswith('vacuum.') else None
|
||||
# A vacuum's card also reads selects and the battery sensor of its device (app 0.2.46), a cover's card its battery (0.2.58).
|
||||
device=self.device_entries(tile['entity']) if tile['entity'].startswith(('vacuum.', 'cover.')) else None
|
||||
message=state_message(index,tile,self.ha.states,extras(tile,self.ha.states,forecast,getattr(self.ha,'time_zone',None),hourly,device=device))
|
||||
if tile['entity'].startswith('sensor.') and hasattr(self.ha,'history'):
|
||||
hours=tile.get('options',{}).get('history_hours',24)
|
||||
@@ -1158,8 +1162,12 @@ def create_app(manager, development=False):
|
||||
app = web.Application(middlewares=[guard], client_max_size=16*1024)
|
||||
static = Path(__file__).parent / 'static'
|
||||
|
||||
# The page asks for its script and styles with a stamp of their content, so a browser that keeps old copies
|
||||
# anyway (Safari did after an update) never runs an old script against a new page.
|
||||
stamp = hashlib.sha1(b''.join((static / name).read_bytes() for name in ('app.js', 'style.css'))).hexdigest()[:12]
|
||||
page = (static / 'index.html').read_text().replace('"static/app.js"', f'"static/app.js?v={stamp}"').replace('"static/style.css"', f'"static/style.css?v={stamp}"')
|
||||
async def index(request):
|
||||
return web.FileResponse(static / 'index.html')
|
||||
return web.Response(text=page, content_type='text/html')
|
||||
def light_payload(screens=None):
|
||||
"""Screens and update status: everything that changes while the page is open."""
|
||||
if screens is None:
|
||||
|
||||
@@ -1889,6 +1889,9 @@ function renderResults() {
|
||||
async function refresh(full = true) {
|
||||
try {
|
||||
const data = await (await api(full ? "inventory" : "inventory?light=1")).json();
|
||||
// The live stream can open a screen before the first full inventory arrives (an add-on busy building
|
||||
// firmware answers it first); that screen was drawn without entity names and with an empty entity list.
|
||||
const firstCatalogue = full && !inventory.entities?.length;
|
||||
// A light poll carries only screens and update status; keep the catalogues we have.
|
||||
inventory = full ? data : { ...inventory, ...data };
|
||||
$("#connection").textContent = inventory.connected
|
||||
@@ -1898,6 +1901,7 @@ async function refresh(full = true) {
|
||||
renderScreens();
|
||||
if (full) renderClaude();
|
||||
if (selected) { settleSettings(); renderSettings(); }
|
||||
if (firstCatalogue && selected && layout && !drag.active && !chipDrag.active) { renderTopbar(); renderTiles(); renderResults(); }
|
||||
if (!selected && inventory.screens.length) select(inventory.screens[0].id);
|
||||
if ($("#alerts-dialog").open) renderAlertScreens();
|
||||
} catch {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: ESP Screen Manager
|
||||
version: "0.2.57"
|
||||
version: "0.2.58"
|
||||
slug: esp_screen_manager
|
||||
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
|
||||
url: https://github.com/MaxGramser/homeassistant_espscreen
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""The cover card (app 0.2.58 / firmware 0.2.50), and two fixes found on Studio 1 after updating to 0.2.57.
|
||||
|
||||
- A tap on a cover opens a card like Home Assistant's own: a position slider, a tilt slider for slats, open,
|
||||
stop and close, and the battery of a battery-powered blind (Motionblinds keep it on a sensor of the device).
|
||||
The layout and the drawing are checked on the Mac host builds; tests/test_tile_controls.cpp covers the logic.
|
||||
- A number whose value equals its initial state (a night brightness of 0) was never published, so Home
|
||||
Assistant showed "unknown" and ESP Screens could not change it.
|
||||
- While a screen restarts its device name sensor reads "unavailable", which the app took for the device name.
|
||||
"""
|
||||
import importlib.util
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
from core import ATTRS, cover_related, discover_screens, extras, state_message # noqa: E402
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
RUNTIME = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
|
||||
PROFILES = {name: (ROOT / name).read_text() for name in ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/guition.yaml', 'packages/cyd.yaml')}
|
||||
BLIND = 'cover.venetianblind_0001'
|
||||
|
||||
|
||||
def motionblinds():
|
||||
"""(states, device registry entries) of a Motionblinds venetian blind as Home Assistant 2026.9.1 reports it in Dutch."""
|
||||
states = {
|
||||
BLIND: {'state': 'open', 'attributes': {'friendly_name': 'Blind Links', 'current_position': 60, 'current_tilt_position': 40,
|
||||
'device_class': 'blind', 'supported_features': 255}},
|
||||
'sensor.venetianblind_0001_batterij': {'state': '85', 'attributes': {'device_class': 'battery', 'unit_of_measurement': '%'}},
|
||||
'sensor.venetianblind_0001_signaalsterkte': {'state': '-60', 'attributes': {}},
|
||||
}
|
||||
device = [{'entity_id': eid, 'platform': 'motion_blinds', 'device_id': 'blind'} for eid in states]
|
||||
return states, device
|
||||
|
||||
|
||||
class CoverMessages(unittest.TestCase):
|
||||
def test_the_card_gets_the_tilt_and_the_battery_of_the_device(self):
|
||||
states, device = motionblinds()
|
||||
self.assertIn('current_tilt_position', ATTRS)
|
||||
extra = extras({'entity': BLIND}, states, device=device)
|
||||
self.assertEqual(extra, {'bat': 85})
|
||||
self.assertEqual(cover_related(BLIND, device, states), {'battery': 'sensor.venetianblind_0001_batterij'})
|
||||
message = state_message(0, {'entity': BLIND, 'name': ''}, states, extra)
|
||||
self.assertEqual((message['a']['current_position'], message['a']['current_tilt_position'], message['a']['supported_features']), (60, 40, 255))
|
||||
self.assertEqual(message['x'], {'bat': 85})
|
||||
# No battery sensor, or a cover that reports battery_level itself: nothing extra.
|
||||
self.assertIsNone(extras({'entity': BLIND}, states, device=device[:1]))
|
||||
states[BLIND]['attributes']['battery_level'] = 90
|
||||
self.assertIsNone(extras({'entity': BLIND}, states, device=device))
|
||||
states['sensor.venetianblind_0001_batterij']['state'] = 'unavailable'
|
||||
del states[BLIND]['attributes']['battery_level']
|
||||
self.assertIsNone(extras({'entity': BLIND}, states, device=device))
|
||||
|
||||
def test_a_restarting_screen_is_not_named_unavailable(self):
|
||||
registry = [{'entity_id': 'text.studio_1_tile_settings', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd1'},
|
||||
{'entity_id': 'sensor.studio_1_device_name', 'platform': 'esphome', 'original_name': 'Device name', 'device_id': 'd1'},
|
||||
{'entity_id': 'sensor.studio_1_ip_address', 'platform': 'esphome', 'original_name': 'IP address', 'device_id': 'd1'}]
|
||||
devices = [{'id': 'd1', 'name': 'Studio 1'}]
|
||||
for state in ('unavailable', 'unknown'):
|
||||
states = {'text.studio_1_tile_settings': {'state': 'unavailable'}, 'sensor.studio_1_device_name': {'state': state},
|
||||
'sensor.studio_1_ip_address': {'state': 'unavailable'}}
|
||||
screen = discover_screens(registry, states, devices, [])[0]
|
||||
self.assertEqual((screen['node'], screen['ip']), (None, None), state)
|
||||
states['sensor.studio_1_device_name']['state'] = 'studio-1'
|
||||
self.assertEqual(discover_screens(registry, states, devices, [])[0]['node'], 'studio-1')
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class CoverSync(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_a_battery_change_reaches_the_card_and_restarts_publish_no_second_sensor(self):
|
||||
import test_scaling
|
||||
from server import Manager
|
||||
states, device = motionblinds()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
ha = test_scaling.fake_ha(firmware='0.2.50')
|
||||
ha.registry = ha.registry + [{**item} for item in device]
|
||||
ha.states.update(states)
|
||||
m = Manager(ha, Path(tmp) / 'screens.json')
|
||||
m.save('text.screen', {'title': 'Office 1', 'tiles': [{'entity': BLIND, 'name': ''}]})
|
||||
self.assertIn('sensor.venetianblind_0001_batterij', m.watched_entities(), 'a battery change wakes the sync')
|
||||
self.assertEqual(m.related_entities({'entity': BLIND}), ('sensor.venetianblind_0001_batterij',))
|
||||
message = await m.tile_message(0, m.layouts['text.screen']['tiles'][0])
|
||||
self.assertEqual(message['x'], {'bat': 85})
|
||||
# The screen restarts: its device name reads unavailable, and no layout sensor goes out under that name.
|
||||
published = []
|
||||
|
||||
async def set_state(entity_id, state, attributes):
|
||||
published.append(entity_id)
|
||||
ha.set_state = set_state
|
||||
ha.states['text.node'] = {'state': 'unavailable'}
|
||||
await m.publish_layouts()
|
||||
self.assertEqual(published, [])
|
||||
ha.states['text.node'] = {'state': 'office-1'}
|
||||
await m.publish_layouts()
|
||||
self.assertEqual(published, ['sensor.esp_screens_office_1'])
|
||||
|
||||
|
||||
class Firmware(unittest.TestCase):
|
||||
def test_a_tap_on_a_cover_opens_the_cover_card(self):
|
||||
event = RUNTIME[RUNTIME.index('inline void event(lv_event_t *event) {'):]
|
||||
event = event[:event.index('\n}\n')]
|
||||
show = re.search(r'if\(d=="sensor" \|\|.*?show_detail\(w\.index\); return; \}', event)
|
||||
self.assertTrue(show and 'd=="cover"' in show[0], 'covers go to show_detail with the other runtime cards')
|
||||
self.assertIn('if (d == "light" || d == "climate" || d == "vacuum" || d == "fan") {', event, 'not to the board\'s value overlay any more')
|
||||
self.assertIn('}else if(d=="cover"){', RUNTIME)
|
||||
self.assertIn('render_cover_detail(t,large,width,height,pad);', RUNTIME)
|
||||
for name, text in PROFILES.items():
|
||||
self.assertIn('if ((domain == "light" || domain == "fan" || domain == "climate") && runtime_tiles::detail) {', text, f'{name}: the preview opens the new card')
|
||||
|
||||
def test_the_card_commits_on_release_and_keeps_its_status_line(self):
|
||||
self.assertIn('action("cover.set_cover_position",t.entity,"position",std::to_string(100-percent));', RUNTIME)
|
||||
self.assertIn('action("cover.set_cover_tilt_position",t.entity,"tilt_position",std::to_string(percent));', RUNTIME)
|
||||
self.assertIn('if(cmd>=70 && cmd<130){auto a=tile_controls::key_action(t,cmd-70);', RUNTIME)
|
||||
# The once-a-second tick of an open card must not replace "Open · 60% · Tilt 40%" with the raw state.
|
||||
tick = RUNTIME[RUNTIME.index('inline void tick() {'):]
|
||||
self.assertIn('t.domain()=="cover"?cover_status_line(t):detail_state(t)', tick[:tick.index('if(!enabled)return;')])
|
||||
# A key the cover cannot use stays disabled: it is left out of the keys the tick enables again.
|
||||
self.assertIn('if(key.disabled){lv_obj_add_state(button,LV_STATE_DISABLED);if(detail_action_count && detail_actions[detail_action_count-1]==button)--detail_action_count;}', RUNTIME)
|
||||
# Track and fill share one radius: no layer per redraw on the CYD.
|
||||
self.assertIn('lv_obj_set_style_radius(slider,radius,LV_PART_MAIN);lv_obj_set_style_radius(slider,radius,LV_PART_INDICATOR);', RUNTIME)
|
||||
self.assertIn('next.tilt = number(a["current_tilt_position"]);', RUNTIME)
|
||||
|
||||
def test_every_setting_number_publishes_a_value_home_assistant_lacks(self):
|
||||
for name, text in PROFILES.items():
|
||||
script = text[text.index('- id: apply_screen_settings'):]
|
||||
script = script[:script.index('\n - id: ', 10)]
|
||||
self.assertIn('if (!entity->has_state() || entity->state != value) entity->publish_state(value);', script, name)
|
||||
for number in ('setting_brightness', 'setting_standby_brightness', 'setting_night_brightness', 'setting_standby_seconds', 'setting_auto_home_seconds'):
|
||||
self.assertIn(f'std::make_pair(id({number}),', script, f'{name}: {number}')
|
||||
self.assertNotRegex(script, r'if \(id\(setting_\w+\)\.state != ', name)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Opening ESP Screens while the add-on is busy (app 0.2.58).
|
||||
|
||||
The page asks for the full inventory and opens the live stream at the same time. An add-on that is building
|
||||
firmware answers the stream first, whose light payload has screens but no entities, so the first screen was
|
||||
opened with an empty catalogue: the entity list said "No entities found" and the tiles showed entity ids until
|
||||
a filter chip was pressed. The first full inventory now draws those parts again.
|
||||
"""
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = (ROOT / 'screen_manager/app/static/app.js').read_text()
|
||||
|
||||
|
||||
def function(name):
|
||||
start = SCRIPT.index(f'function {name}(')
|
||||
return SCRIPT[start:SCRIPT.index('\n}\n', start)]
|
||||
|
||||
|
||||
class Startup(unittest.TestCase):
|
||||
def test_the_live_stream_can_open_a_screen_before_the_catalogue(self):
|
||||
# The race exists: the light payload selects the first screen, and it carries no entities.
|
||||
self.assertIn('if (!selected && inventory.screens.length) select(inventory.screens[0].id);', function('applyLive'))
|
||||
self.assertRegex(function('select'), r'renderTopbar\(\);\s+renderTiles\(\);\s+renderResults\(\);')
|
||||
|
||||
def test_the_first_full_inventory_draws_what_needs_entities_again(self):
|
||||
body = function('refresh')
|
||||
first = body.index('const firstCatalogue = full && !inventory.entities?.length;')
|
||||
self.assertLess(first, body.index('inventory = full ? data'), 'decided before the catalogue is replaced')
|
||||
redraw = re.search(r'if \(firstCatalogue && selected && layout && !drag\.active && !chipDrag\.active\) '
|
||||
r'\{ renderTopbar\(\); renderTiles\(\); renderResults\(\); \}', body)
|
||||
self.assertTrue(redraw, 'tiles, top bar and entity list are drawn again, never in the middle of a drag')
|
||||
self.assertLess(redraw.start(), body.index('if (!selected && inventory.screens.length) select('))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""The page loads its own script and styles by content stamp (app 0.2.58).
|
||||
|
||||
After updating to 0.2.57, Safari showed an empty Screen settings panel: it kept a cached script from before
|
||||
the update. The page now asks for app.js and style.css with a stamp of their content, so a new version is
|
||||
always fetched and an old one is never combined with a new page.
|
||||
"""
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STATIC = ROOT / 'screen_manager/app/static'
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class Assets(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_script_and_styles_carry_a_stamp_of_their_content(self):
|
||||
import sys
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
import test_scaling
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from server import Manager, create_app
|
||||
stamp = hashlib.sha1((STATIC / 'app.js').read_bytes() + (STATIC / 'style.css').read_bytes()).hexdigest()[:12]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
manager = Manager(test_scaling.fake_ha(), Path(tmp) / 'screens.json')
|
||||
async with TestClient(TestServer(create_app(manager, True))) as client:
|
||||
response = await client.get('/')
|
||||
page = await response.text()
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIn(f'<script src="static/app.js?v={stamp}" defer></script>', page)
|
||||
self.assertIn(f'<link rel="stylesheet" href="static/style.css?v={stamp}" />', page)
|
||||
self.assertEqual(response.headers['Cache-Control'], 'no-store')
|
||||
self.assertIn('text/html', response.headers['Content-Type'])
|
||||
script = await client.get(f'/static/app.js?v={stamp}')
|
||||
self.assertEqual((script.status, await script.read()), (200, (STATIC / 'app.js').read_bytes()))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -106,7 +106,8 @@ def fake_ha(firmware=FIRMWARE_VERSION, owned=True, guition=True):
|
||||
class CoreSettings(unittest.TestCase):
|
||||
def test_every_setting_the_editor_offers_has_an_entity(self):
|
||||
self.assertEqual(set(SETTING_ENTITIES), set(SETTING_RULES) - {'show_clock'})
|
||||
self.assertEqual(SETTING_ENTITIES_MIN_FIRMWARE, FIRMWARE_VERSION)
|
||||
self.assertEqual(SETTING_ENTITIES_MIN_FIRMWARE, '0.2.49')
|
||||
self.assertLessEqual(tuple(map(int, SETTING_ENTITIES_MIN_FIRMWARE.split('.'))), tuple(map(int, FIRMWARE_VERSION.split('.'))))
|
||||
|
||||
def test_the_entities_are_named_as_in_both_board_profiles(self):
|
||||
blocks = {'switch': 'switch', 'number': 'number', 'time': 'datetime', 'select': 'select'}
|
||||
@@ -581,7 +582,9 @@ class Firmware(unittest.TestCase):
|
||||
script = script[:script.index('\n - id: ', 10)]
|
||||
for entity in ('setting_brightness', 'setting_standby_brightness', 'setting_night_brightness', 'setting_standby_seconds',
|
||||
'setting_auto_home_seconds'):
|
||||
self.assertIn(f'id({entity}).publish_state(', script, f'{path.name}: {entity}')
|
||||
# One loop publishes every number that lacks a state or changed (firmware 0.2.50, see test_cover_card).
|
||||
self.assertIn(f'std::make_pair(id({entity}),', script, f'{path.name}: {entity}')
|
||||
self.assertIn('if (!entity->has_state() || entity->state != value) entity->publish_state(value);', script, path.name)
|
||||
self.assertIn('std::make_pair(id(setting_night_start), settings.night_start)', script, path.name)
|
||||
self.assertIn('settings_screen::refresh();', script, f'{path.name}: an open settings page follows Home Assistant')
|
||||
self.assertEqual('id(setting_rotation)->update();' in script, board == 'guition', path.name)
|
||||
|
||||
@@ -36,6 +36,38 @@ int main() {
|
||||
assert(status_text(curtain) == "Open · 100%");
|
||||
assert(status_text(shutter) == "Closed");
|
||||
|
||||
// The cover card (firmware 0.2.50+). Motionblinds venetian blinds report every feature (255).
|
||||
Tile blind = make("cover.venetianblind_0001", "open", 255); blind.device_class = "blind"; blind.position = 60; blind.edit_extra().tilt = 40;
|
||||
CoverCard card = cover_card(blind);
|
||||
assert(card.position && card.tilt && card.keys && !card.tilt_keys);
|
||||
assert(cover_card_status(blind) == "Open · 60% · Tilt 40%");
|
||||
assert(cover_keys(blind, keys) == 3 && !strcmp(keys[0].icon, glyph::UP) && !keys[0].disabled && !keys[2].disabled && !keys[2].checked);
|
||||
blind.state = "closing";
|
||||
assert(cover_keys(blind, keys) == 3 && keys[2].checked && !keys[0].checked);
|
||||
assert(cover_tilt_keys(blind, keys) == 3 && keys[0].command == COVER_OPEN_TILT && keys[2].command == COVER_CLOSE_TILT);
|
||||
assert(key_action(blind, COVER_OPEN_TILT).service == "cover.open_cover_tilt");
|
||||
assert(key_action(blind, COVER_STOP_TILT).service == "cover.stop_cover_tilt" && key_action(blind, COVER_CLOSE_TILT).service == "cover.close_cover_tilt");
|
||||
CoverCard curtain_card = cover_card(curtain);
|
||||
assert(curtain_card.position && !curtain_card.tilt && curtain_card.keys && !curtain_card.tilt_keys);
|
||||
assert(cover_card_status(curtain) == "Open · 100%");
|
||||
// A garage door: open, stop and close only; close is disabled while it is closed.
|
||||
Tile garage = make("cover.garage", "closed", 11); garage.device_class = "garage";
|
||||
CoverCard garage_card = cover_card(garage);
|
||||
assert(!garage_card.position && !garage_card.tilt && garage_card.keys && !garage_card.tilt_keys);
|
||||
assert(cover_keys(garage, keys) == 3 && keys[2].disabled && !keys[0].disabled);
|
||||
// Slats that tilt without a position get tilt keys; a key that cannot tilt further is disabled.
|
||||
Tile slats = make("cover.slats", "open", 3 | 16 | 32); slats.edit_extra().tilt = 100;
|
||||
CoverCard slats_card = cover_card(slats);
|
||||
assert(slats_card.tilt_keys && !slats_card.tilt && !slats_card.position);
|
||||
assert(cover_tilt_keys(slats, keys) == 2 && keys[0].disabled && !keys[1].disabled);
|
||||
assert(cover_card_status(slats) == "Open");
|
||||
// Features not reported yet: the card offers the three keys, a tile's key row stays empty as before.
|
||||
Tile unknown = make("cover.new", "unknown", 0);
|
||||
CoverCard unknown_card = cover_card(unknown);
|
||||
assert(unknown_card.keys && !unknown_card.position && cover_keys(unknown, keys) == 3);
|
||||
unknown.controls = "buttons";
|
||||
assert(keys_for(unknown, keys) == 0);
|
||||
|
||||
// Vacuum: play while docked, pause while cleaning, dock disabled in the dock.
|
||||
Tile robot = make("vacuum.s8", "docked", 30524); robot.controls = "buttons";
|
||||
assert(keys_for(robot, keys) == 3);
|
||||
|
||||
Reference in New Issue
Block a user