Choose vacuum, vac & mop or mop on the vacuum card, and a climate card for the CYD (0.2.46)

App 0.2.46 / firmware 0.2.39:
- The vacuum's state message carries extras from its own device: the cleaning mode and water
  selects (found by translation key, then entity id ending, so localized ids work), the suction
  speeds to offer, the battery sensor (HA 2026.8 dropped battery_level), charging and the current
  room. Speeds and water levels a mode covers move into the mode row; Custom only while in use.
  The manager watches these entities and resends only the vacuum's message when one changes.
- Vacuum card redesign on both boards: robot with state, battery, charging bolt and room; Start
  (Pause, Resume) and Dock; then segmented rows for mode, suction and water, showing only the rows
  the chosen mode uses. Taps show at once and wait for Home Assistant; Locate is a round button in
  the Guition hero.
- CYD climate card without the arc: big setpoint between large -/+ keys (debounced, hold to
  repeat) and one row of mode keys in HA's order and colours, with a more key for fan and swing.
- Both profiles clear climate_edit_pending when the climate card is closed, instead of running
  ui_refresh every second while the flag stayed set.

Tested with the owner's Roborock S8 on HA 2026.9.1 (live data, OTA-flashed bench boards) and with
host renders of both boards; see docs/TEST_RESULTS_0246.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-15 15:22:28 +02:00
parent 4ff0d44312
commit aa2137f3f8
23 changed files with 1675 additions and 655 deletions
+10 -1
View File
@@ -77,6 +77,11 @@ The firmware and built-in CLI are tested with **ESPHome 2026.6.2**.
per tile; **None** keeps the regular card.
- **Weather card** with current weather, the coming hours and days including chance of
rain or millimeters; **climate card** with an on/off button, mode, fan, and swing settings.
On the CYD the climate card has big − / + keys and one row of mode keys instead of a dial.
- **Vacuum card** with the state, battery and charging, start and dock, and how the robot
cleans: **vacuum, vacuum and mop, or mop only** for robots that offer a cleaning mode in
Home Assistant (such as Roborock), then suction and water. Only the rows the chosen mode
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.
- **Top bar per screen** (firmware 0.2.32+): the name on the left, up to six
@@ -99,10 +104,14 @@ The firmware and built-in CLI are tested with **ESPHome 2026.6.2**.
<img src="docs/images/guition-light.png" width="32%" alt="Light control: color, color temperature and brightness">
</p>
<p align="center">
<img src="docs/images/guition-vacuum.png" width="32%" alt="Vacuum card: status, battery, start, return to dock, find the robot and suction power">
<img src="docs/images/guition-vacuum.png" width="32%" alt="Vacuum card: docked and charging, start and dock, the cleaning mode vacuum, vac and mop or mop, suction and water">
<img src="docs/images/guition-history.png" width="32%" alt="Sensor history of the last 24 hours">
<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">
</p>
Features depend on the capabilities Home Assistant reports for an entity.
The app must keep running to keep the screens supplied with current data.
+9 -8
View File
@@ -47,8 +47,9 @@ vertically, so the grid can be taller than the screen.
The background is a two-stop vertical gradient (`BG_TOP_COLOR` ->
`BG_BOTTOM_COLOR`), not an image. That saves ~150 KB of flash and removes an
image blit from every redraw, which is what makes scrolling usable on this
board. The same gradient is reused as the backdrop of the slider, color and
thermostat overlays through the `style_bg_grad` style.
board. The same gradient is reused as the backdrop of the slider and color
overlays through the `style_bg_grad` style; the climate card uses the plain
light background of the other cards.
## Accent colors are automatic
@@ -167,9 +168,9 @@ Only relevant when `TILE*_TYPE` is set to `"climate"`.
| Key | Default | Description |
|-----|---------|-------------|
| `TILE*_CLIMATE_MIN_TEMP` | `"5.0"` | Minimum target temperature shown in the thermostat overlay. |
| `TILE*_CLIMATE_MAX_TEMP` | `"30.0"` | Maximum target temperature shown in the thermostat overlay. |
| `TILE*_CLIMATE_STEP` | `"0.5"` | Step size for the thermostat ring and +/- buttons. Values below `0.5` are rounded up to `0.5`, so the UI always adjusts in whole or half-degree steps. |
| `TILE*_CLIMATE_MIN_TEMP` | `"5.0"` | Minimum target temperature on the climate card. |
| `TILE*_CLIMATE_MAX_TEMP` | `"30.0"` | Maximum target temperature on the climate card. |
| `TILE*_CLIMATE_STEP` | `"0.5"` | Step size of the − / + keys. Values below `0.5` are rounded up to `0.5`, so the UI always adjusts in whole or half-degree steps. |
| `TILE*_CLIMATE_CURRENT_TEMP_ATTRIBUTE` | `"current_temperature"` | Home Assistant attribute used for the current room temperature. |
| `TILE*_CLIMATE_TARGET_TEMP_ATTRIBUTE` | `"temperature"` | Home Assistant attribute used for the target temperature. |
| `TILE*_CLIMATE_HUMIDITY_ATTRIBUTE` | `"current_humidity"` | Home Assistant attribute used for humidity. Leave the default if the entity does not provide humidity. |
@@ -210,7 +211,7 @@ In all modes, the event `tileN_long_press` is always published to `sensor.smartd
For light tiles in `slider` mode, the brightness overlay automatically shows a color-ring button when `DIRECT_ACTIONS` is `"true"` and Home Assistant reports a supported color mode (`hs`, `rgb`, `rgbw`, `rgbww`, `xy`) or color temperature mode (`color_temp`). The color detail view can send `light.turn_on` with `color_name`, `color_temp_kelvin`, and the current brightness percentage. If the light only supports color or only supports color temperature, the Color/Temp tab switch is hidden and only the supported controls are shown. No extra tile substitutions are required.
For climate tiles in `slider` mode, the thermostat overlay sends `climate.set_temperature` with the configured target temperature. The min/max range, step size, and Home Assistant attribute names are configured through the `TILE*_CLIMATE_*` substitutions. The mode pill cycles through the climate entity's supported `hvac_modes` and sends `climate.set_hvac_mode` when `DIRECT_ACTIONS` is `"true"`.
For climate tiles in `slider` mode, the climate card shows the target temperature between big − / + keys: the number follows every tap at once, holding a key keeps stepping, and one `climate.set_temperature` goes out once the taps stop (0.7 s). The min/max range, step size, and Home Assistant attribute names are configured through the `TILE*_CLIMATE_*` substitutions. Below it, one key per supported `hvac_mode` (in Home Assistant's order, off is the power key) sends `climate.set_hvac_mode` when `DIRECT_ACTIONS` is `"true"`; the ··· key opens the picker with fan and swing modes.
---
@@ -390,7 +391,7 @@ TILE4_LONGPRESS_ACTION_SERVICE: ""
---
### Climate — thermostat ring on long press
### Climate — climate card on long press
```yaml
TILE5_ENTITY: "climate.living_room"
@@ -399,7 +400,7 @@ TILE5_TITLE: "Living"
TILE5_ICON: "\U000F0438" # mdi:radiator
TILE5_TYPE: "climate"
TILE5_TAP_ACTION: "auto" # no default tap action; use TAP_SERVICE if desired
TILE5_LONGPRESS: "slider" # long press opens thermostat ring
TILE5_LONGPRESS: "slider" # long press opens the climate card
TILE5_VALUE_MODE: "auto" # shows target temperature, or current temperature as fallback
TILE5_LABEL_OFF: "Off"
TILE5_LABEL_ON: "Heat"
+17
View File
@@ -31,6 +31,16 @@ inline std::string state_revision(const std::string &state, const std::string &a
}
struct Forecast { std::string day, condition; float high = NAN, low = NAN, rain = NAN, mm = NAN; };
struct Hour { std::string time, condition; float temp = NAN, rain = NAN, mm = NAN; };
// One row of choices on a vacuum card (firmware 0.2.39+), found by the manager on the robot's device:
// kind 'm' a cleaning mode select (Roborock: vacuum, mop or both), 'w' a water or mop intensity
// select, 's' the suction speeds of the vacuum itself. `values` go to Home Assistant, `labels` are
// shown; a cleaning mode carries one role letter per value (v vacuum only, m mop only, b both,
// a automatic: the robot or the app decides suction and water). `sent` is the value just tapped.
struct Choice {
char kind = 0;
std::string entity, current, roles, sent;
std::vector<std::string> values, labels;
};
struct Tile {
std::string entity, name, state, unit, modes, hvac_modes, fan_modes, swing_modes, fan_mode, swing_mode;
std::array<std::string, 4> fan_speeds;
@@ -66,6 +76,13 @@ struct Tile {
std::string sunrise, sunset, duration, remaining;
uint32_t timer_end = 0;
float battery = NAN, volume = NAN;
// Vacuum only: mode, water and suction rows (see Choice); an empty vector costs nothing elsewhere.
// `room` is where the robot is and `charging` whether it charges, both from sensors of its device.
std::vector<Choice> choices;
std::string room;
bool charging = false;
Choice *choice(char kind) { for (auto &c : choices) if (c.kind == kind) return &c; return nullptr; }
const Choice *choice(char kind) const { for (auto &c : choices) if (c.kind == kind) return &c; return nullptr; }
uint32_t supported = 0, background = 0;
bool transparent = false; // "Background: none": card fill and border hidden, contents unchanged.
std::string icon; // UTF-8 glyph of a chosen icon the icon fonts contain; empty keeps the domain icon.
+250 -58
View File
@@ -278,6 +278,8 @@ inline std::string receive(const std::string &payload) {
auto a = root["a"].as<JsonObject>();
// ArduinoJson clears its destination string: serialize attributes first, then add state.
std::string attributes; serializeJson(a, attributes);
// The extras count too: a vacuum's mode or water select changes only there.
if (!root["x"].isNull()) { std::string more; serializeJson(root["x"], more); attributes += more; }
std::string revision = state_revision(string(root["state"]), attributes);
bool was_confirmed=tile.confirmed;
tile.observe(revision);
@@ -369,6 +371,30 @@ inline std::string receive(const std::string &payload) {
if (tile.fan_speed_count == 4) break;
tile.fan_speeds[tile.fan_speed_count++] = string(speed, 48);
}
// Vacuum rows (app 0.2.46+): the cleaning mode and water selects of the robot's device and the
// suction speeds to offer, each at most six; the battery sensor when the vacuum has no attribute.
// A chip just tapped keeps its choice while Home Assistant is still busy with it, so another update
// of the robot (its battery, say) does not flip the row back for a moment.
std::vector<std::pair<char, std::string>> tapped;
if (tile.loading(esphome::millis())) for (auto &c : tile.choices) if (!c.sent.empty()) tapped.emplace_back(c.kind, c.sent);
tile.choices.clear();
auto choice = [&](const char *key, char kind) {
auto c = extra[key];
if (!c["o"].is<JsonArray>()) return;
Choice row; row.kind = kind;
row.entity = string(c["e"], 120); row.current = string(c["s"], 48); row.roles = string(c["r"], 6);
if (kind != 's' && !valid_entity(row.entity)) return;
for (JsonVariant value : c["o"].as<JsonArray>()) { if (row.values.size() == 6) break; row.values.push_back(string(value, 48)); }
if (c["l"].is<JsonArray>()) for (JsonVariant label : c["l"].as<JsonArray>()) {
if (row.labels.size() == row.values.size()) break; row.labels.push_back(string(label, 24)); }
while (row.labels.size() < row.values.size()) row.labels.push_back(row.values[row.labels.size()]);
if (!row.values.empty()) tile.choices.push_back(std::move(row));
};
if (tile.domain() == "vacuum") { choice("mode", 'm'); choice("water", 'w'); choice("fan", 's'); tile_controls::settle_suction(tile); }
for (auto &[kind, value] : tapped) if (auto *c = tile.choice(kind)) if (c->current != value) c->sent = value;
if (!std::isfinite(tile.battery)) tile.battery = number(extra["bat"]);
tile.charging = extra["chg"].is<int>() && extra["chg"].as<int>() == 1;
tile.room = string(extra["room"], 32);
// Home Assistant reports the edited value: the -/+ pill follows its state again.
if(std::isfinite(tile.edit_value) && tile.edit_sent && std::fabs(tile_controls::edit_target(tile)-tile.edit_value)<0.051f)tile.edit_value=NAN;
tile.received = true;
@@ -411,7 +437,7 @@ namespace runtime_tiles {
inline lv_obj_t *detail_root=nullptr;
inline unsigned detail_index=0;
inline const lv_font_t *detail_font=nullptr;
inline lv_obj_t *detail_actions[16]{};
inline lv_obj_t *detail_actions[32]{};
inline unsigned detail_action_count=0;
inline lv_obj_t *detail_status=nullptr;
inline lv_obj_t *detail_badge_status=nullptr;
@@ -477,6 +503,17 @@ inline void slider_event(lv_event_t *e){
if(changed && cyd::touch_guard.accept_slider(esphome::millis(),200+index))commit_slider(index,lv_slider_get_value(slider));
}
}
inline void show_detail(unsigned index);
// A chip on the vacuum card: the service call goes out, the chip shows the choice at once, and the card
// waits for Home Assistant like after its other buttons. The card is drawn again once this tap's event
// has finished, because a chosen mode can add or remove the suction and water rows.
inline void choose(Tile &t,Choice &row,const std::string &value){
if(value==row.current)return;
auto a=tile_controls::choice_action(t,row.kind,value);if(!a.valid())return;
action(a.service,row.kind=='s'?t.entity:row.entity,a.key,a.value);
row.sent=value;t.begin(esphome::millis());
lv_async_call([](void *){if(detail_root && !lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN))show_detail(detail_index);},nullptr);
}
inline lv_obj_t *detail_button(const char *text,int x,int y,int width,int height,int command){
auto *button=lv_obj_create(detail_root);lv_obj_remove_style_all(button);lv_obj_set_pos(button,x,y);lv_obj_set_size(button,width,height);
lv_obj_set_style_bg_color(button,lv_color_hex(command==0?0x009FE3:0xD9E6F0),0);lv_obj_set_style_bg_opa(button,LV_OPA_COVER,0);lv_obj_set_style_radius(button,12,0);lv_obj_add_flag(button,LV_OBJ_FLAG_CLICKABLE);
@@ -486,7 +523,10 @@ inline lv_obj_t *detail_button(const char *text,int x,int y,int width,int height
if(!fresh()||detail_index>=model.count || !allowed(esphome::millis(),300+cmd,"card button "+model.tiles[detail_index].entity))return;
auto &t=model.tiles[detail_index];if(!t.available()||t.loading(esphome::millis()))return;
if(cmd<4){const char *services[]={"vacuum.start","vacuum.pause","vacuum.return_to_base","vacuum.locate"};action(services[cmd],t.entity);}
if(cmd>=10 && cmd<14 && cmd-10<(int)t.fan_speed_count)action("vacuum.set_fan_speed",t.entity,"fan_speed",t.fan_speeds[cmd-10]);
// Vacuum rows: 10-15 suction, 50-55 cleaning mode, 60-65 water.
static const std::pair<int,char> rows[]={{10,'s'},{50,'m'},{60,'w'}};
for(const auto &[first,kind]:rows)
if(cmd>=first && cmd<first+6){auto *row=t.choice(kind);if(row && cmd-first<(int)row->values.size())choose(t,*row,row->values[cmd-first]);}
if(cmd==20)action("media_player.media_play_pause",t.entity);
if(cmd==21)action("media_player.media_previous_track",t.entity);
if(cmd==22)action("media_player.media_next_track",t.entity);
@@ -497,7 +537,7 @@ inline lv_obj_t *detail_button(const char *text,int x,int y,int width,int height
lv_obj_set_style_bg_color(button,lv_color_hex(0x0075B0),LV_STATE_PRESSED);
lv_obj_set_style_transform_width(button,-2,LV_STATE_PRESSED);lv_obj_set_style_transform_height(button,-2,LV_STATE_PRESSED);
lv_obj_set_style_opa(button,LV_OPA_50,LV_STATE_DISABLED);
if(command>=0 && detail_action_count<16)detail_actions[detail_action_count++]=button;
if(command>=0 && detail_action_count<32)detail_actions[detail_action_count++]=button;
return button;
}
@@ -600,6 +640,195 @@ inline void render_weather_detail(const Tile &t,bool large,int width,int height,
detail_text(days,std::isfinite(f.low)?degrees(f.low):"",temps_x+high_w,scy,low_w,small,LV_TEXT_ALIGN_RIGHT,muted);
}
}
// ---- Vacuum card ----
// A robot with its state and battery, the two commands used most, and one block that says how it cleans:
// the cleaning mode, then suction and water. Only the rows the chosen mode uses are shown, and they sit
// below the mode, so a tap never moves the control under the finger. Native shapes and labels only.
// Native shapes keep the robot crisp without image buffers or extra layers.
inline lv_obj_t *detail_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);
lv_obj_set_style_radius(o,radius,0);lv_obj_set_style_bg_color(o,lv_color_hex(color),0);lv_obj_set_style_bg_opa(o,LV_OPA_COVER,0);
lv_obj_remove_flag(o,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(o,LV_OBJ_FLAG_SCROLLABLE);return o;
}
inline int text_width(const std::string &text,const lv_font_t *font){
lv_point_t size;lv_text_get_size(&size,text.c_str(),font,0,0,LV_COORD_MAX,LV_TEXT_FLAG_NONE);return size.x;
}
// The state colour, as Home Assistant colours a vacuum: teal while cleaning, blue on the way back, amber
// when paused, red on an error; a docked or idle robot keeps the card's own blue. `tint` is the halo.
struct VacuumLook { uint32_t accent, tint; };
inline VacuumLook vacuum_look(const std::string &state){
if(state=="cleaning")return {0x009688,0xDCF0EE};
if(state=="returning")return {0x2196F3,0xE1EFFD};
if(state=="paused")return {0xFF9800,0xFFF0DA};
if(state=="error")return {0xF44336,0xFDE4E2};
return {0x00A6ED,0xEAF5FC};
}
// A robot seen from above on a halo: a white body with a rim, the laser turret, a light bar in the state colour.
inline void vacuum_robot(lv_obj_t *parent,int x,int y,int size,const VacuumLook &look){
auto *halo=detail_shape(parent,x,y,size,size,look.tint,size/2);
int r=size*72/100,rim=std::max(1,size/44);
auto *body=detail_shape(halo,(size-r)/2,(size-r)/2,r,r,0xFFFFFF,r/2);
lv_obj_set_style_border_width(body,rim,0);lv_obj_set_style_border_color(body,lv_color_hex(0xCEDDE6),0);
int c=r-2*rim;auto s=[&](int v){return std::max(1,v*c/100);};
detail_shape(body,s(32),s(10),s(36),s(36),0xE3EBEF,s(18));
detail_shape(body,s(41),s(19),s(18),s(18),0xA8BCC8,s(9));
detail_shape(body,s(31),s(70),s(38),std::max(3,s(7)),look.accent,s(4));
}
// A battery like a phone's: outline, level fill (red below 20 %) and a small cap.
inline void vacuum_battery(lv_obj_t *parent,int x,int y,int w,int h,float level){
int line=std::max(1,h/7),fill_w=w-4*line,fill=std::clamp((int)std::lround(fill_w*level/100.0f),0,fill_w);
auto *shell=detail_shape(parent,x,y,w,h,0xFFFFFF,std::max(2,h/4));
lv_obj_set_style_bg_opa(shell,LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(shell,line,0);lv_obj_set_style_border_color(shell,lv_color_hex(0x7D858D),0);
if(fill)detail_shape(shell,line,line,fill,h-4*line,level<20?0xE53935:0x46525E,std::max(1,h/9));
detail_shape(parent,x+w,y+h*3/10,std::max(2,line+1),h-2*(h*3/10),0x7D858D,1);
}
// Battery meter, level and a green bolt while charging, in one row from `x`; returns the row's width.
// Without a parent it only measures.
inline int vacuum_power(lv_obj_t *parent,const Tile &t,int x,int y,const lv_font_t *font,bool large){
const lv_font_t *bolt_font=large && watch_icon_font?watch_icon_font:mini_icon_font;
std::string percent=std::to_string((int)std::lround(t.battery))+"%";
int h=lv_font_get_line_height(font),meter_w=large?30:22,meter_h=large?15:11,gap=large?10:6,words=text_width(percent,font);
int bolt_w=t.charging && bolt_font?text_width("\U000F0241",bolt_font):0;
int width=meter_w+3+gap+words+(bolt_w?gap/2+bolt_w:0);
if(!parent)return width;
vacuum_battery(parent,x,y+(h-meter_h)/2,meter_w,meter_h,t.battery);
int px=x+meter_w+3+gap;
detail_text(parent,percent,px,y,words+2,font,LV_TEXT_ALIGN_LEFT,0x5F6368);
if(bolt_w)detail_text(parent,"\U000F0241",px+words+gap/2,y+(h-lv_font_get_line_height(bolt_font))/2,bolt_w+2,bolt_font,LV_TEXT_ALIGN_LEFT,0x43A047);
return width;
}
// Put a detail_button's words in `font`, sized to the words and centred (or at `x` when given).
inline lv_obj_t *button_words(lv_obj_t *button,const lv_font_t *font,uint32_t color,int available,int x=-1){
auto *label=lv_obj_get_child(button,0);
lv_obj_set_style_text_font(label,font,0);lv_obj_set_style_text_color(label,lv_color_hex(color),0);
lv_obj_set_size(label,std::min(available,text_width(lv_label_get_text(label),font)+2),lv_font_get_line_height(font));
if(x<0)lv_obj_center(label);else lv_obj_align(label,LV_ALIGN_LEFT_MID,x,0);
return label;
}
// A command button with its icon before the words, the two centred as one group.
inline lv_obj_t *vacuum_command(const char *icon,const char *text,int x,int y,int w,int h,int command,bool primary,
const lv_font_t *font,const lv_font_t *icon_font,int radius){
auto *button=detail_button(text,x,y,w,h,command);
uint32_t ink=primary?0xFFFFFF:0x1B1B1B;
lv_obj_set_style_radius(button,radius,0);
lv_obj_set_style_bg_color(button,lv_color_hex(primary?0x009FE3:0xFFFFFF),0);
if(!primary){
lv_obj_set_style_border_width(button,1,0);lv_obj_set_style_border_color(button,lv_color_hex(0xDDDDDD),0);
lv_obj_set_style_bg_color(button,lv_color_hex(0xEEEEEE),LV_STATE_PRESSED);
}
int gap=std::max(6,h/7),icon_w=text_width(icon,icon_font),words=text_width(text,font);
int group=icon_w+gap+words,left=std::max(4,(w-group)/2);
auto *glyph=lv_label_create(button);lv_label_set_text(glyph,icon);lv_obj_remove_flag(glyph,LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_text_font(glyph,icon_font,0);lv_obj_set_style_text_color(glyph,lv_color_hex(ink),0);
lv_obj_align(glyph,LV_ALIGN_LEFT_MID,left,0);
button_words(button,font,ink,w-left-icon_w-gap-4,left+icon_w+gap);
return button;
}
// A segmented control: one rounded track, each option as wide as its words plus an equal share of the
// room left, the chosen one filled in blue. Commands first..first+5.
inline void vacuum_segments(const Tile &t,const Choice &row,int first,int x,int y,int w,int h,uint32_t track,bool border,const lv_font_t *font){
int n=std::min<int>((int)row.values.size(),6);if(!n)return;
auto *bar=detail_shape(detail_root,x,y,w,h,track,h/2);
if(border){lv_obj_set_style_border_width(bar,1,0);lv_obj_set_style_border_color(bar,lv_color_hex(0xDDDDDD),0);}
int inset=std::max(3,h/12),room=w-2*inset,words=0;int widths[6];
for(int i=0;i<n;++i){widths[i]=text_width(row.labels[i],font);words+=widths[i];}
int share=(room-words)/n;
const std::string &chosen=tile_controls::shown_value(t,row,esphome::millis());
int sx=x+inset;
for(int i=0;i<n;++i){
int sw=i==n-1?x+w-inset-sx:(words<=room?widths[i]+share:room/n);
bool selected=row.values[i]==chosen;
auto *segment=detail_button(row.labels[i].c_str(),sx,y+inset,sw,h-2*inset,first+i);
lv_obj_set_style_radius(segment,(h-2*inset)/2,0);
lv_obj_set_style_bg_color(segment,lv_color_hex(0x009FE3),0);
lv_obj_set_style_bg_opa(segment,selected?LV_OPA_COVER:LV_OPA_TRANSP,0);
lv_obj_set_style_bg_opa(segment,LV_OPA_COVER,LV_STATE_PRESSED);
if(!selected)lv_obj_set_style_bg_color(segment,lv_color_hex(0xD5EEFC),LV_STATE_PRESSED);
button_words(segment,font,selected?0xFFFFFF:0x1B1B1B,sw-6);
sx+=sw;
}
}
inline void render_vacuum_detail(Tile &t,bool large,int width,int height,int pad){
uint32_t now=esphome::millis();
auto rows=tile_controls::vacuum_rows(t,now);
const Choice *mode=t.choice('m'),*suction=rows.suction?t.choice('s'):nullptr,*water=rows.water?t.choice('w'):nullptr;
bool automatic=mode && tile_controls::vacuum_role(t,now)=='a';
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;
const lv_font_t *icons=mini_icon_font?mini_icon_font:detail_font;
VacuumLook look=vacuum_look(t.state);
bool cleaning=t.state=="cleaning",paused=t.state=="paused";
bool battery=std::isfinite(t.battery),on_the_way=cleaning||paused||t.state=="returning";
int inner=width-2*pad,gap=large?12:6,radius=lv_obj_get_style_radius(widgets[0].tile,LV_PART_MAIN);
std::string state=t.awaiting_action(now)&&!t.confirmed?"Command sent...":detail_state(t);
// A robot with little to set gets a hero on the small screen too; one with mode and water rows uses a status row.
bool small_hero=!large && !mode && !t.choice('w');
int y=large?92:52;
if(large || small_hero){
int hero_h=large?108:60,robot=large?84:48,edge=large?12:6;
auto *hero=detail_card(pad,y,inner,hero_h);
vacuum_robot(hero,edge,(hero_h-robot)/2,robot,look);
// Locate, when the robot can do it (supported_features 512; unknown features keep the button).
bool locate=large && (!t.supported || (t.supported & 512));
int key=48,text_x=edge+robot+(large?18:12),text_w=inner-text_x-(locate?key+2*edge:edge);
int line=lv_font_get_line_height(big),text_h=lv_font_get_line_height(text),space=large?6:3;
bool room=on_the_way && !t.room.empty();
int block=line+(room?space+text_h:0)+(battery?space+text_h:0),ty=(hero_h-block)/2;
detail_badge_status=detail_text(hero,state,text_x,ty,text_w,big,LV_TEXT_ALIGN_LEFT,0x1B1B1B);
ty+=line;
if(room){ty+=space;detail_text(hero,t.room,text_x,ty,text_w,text,LV_TEXT_ALIGN_LEFT,0x5F6368);ty+=text_h;}
if(battery){ty+=space;vacuum_power(hero,t,text_x,ty,text,large);}
if(locate){
auto *find=detail_button("",pad+inner-edge-key-6,y+(hero_h-key)/2,key,key,3);
lv_obj_set_style_radius(find,LV_RADIUS_CIRCLE,0);lv_obj_set_style_bg_color(find,lv_color_hex(0xF1F1F1),0);
lv_obj_set_style_bg_color(find,lv_color_hex(0xDDDDDD),LV_STATE_PRESSED);
auto *glyph=lv_obj_get_child(find,0);lv_obj_set_style_text_font(glyph,icons,0);lv_label_set_text(glyph,"\U000F034E");
lv_obj_set_style_text_color(glyph,lv_color_hex(0x46525E),0);lv_obj_set_size(glyph,LV_SIZE_CONTENT,LV_SIZE_CONTENT);lv_obj_center(glyph);
}
y+=hero_h+gap;
}else{
// Status row: a dot in the state colour, the state (and the room on the way), the battery at the right.
int line=lv_font_get_line_height(text),dot=8,power_w=battery?vacuum_power(nullptr,t,0,0,text,false):0;
detail_shape(detail_root,pad+2,y+(line-dot)/2,dot,dot,look.accent,dot/2);
int words=inner-dot-10-power_w-12,state_w=std::min(words,text_width(state,text)+2);
detail_badge_status=detail_text(detail_root,state,pad+dot+10,y,state_w,text,LV_TEXT_ALIGN_LEFT,0x1B1B1B);
if(on_the_way && !t.room.empty() && words-state_w>24)
detail_text(detail_root," · "+t.room,pad+dot+10+state_w,y,words-state_w,text,LV_TEXT_ALIGN_LEFT,0x5F6368);
if(battery)vacuum_power(detail_root,t,pad+inner-power_w,y,text,false);
y+=line+(gap+4);
}
// Start (or pause, or resume) as the one blue button, dock beside it.
int action_h=large?54:(small_hero?40:36),start_w=large?(inner-gap)*2/3:(inner-gap)/2;
vacuum_command(cleaning?"\U000F03E4":"\U000F040A",cleaning?(large?"Pause cleaning":"Pause"):paused?"Resume":(large?"Start cleaning":"Clean"),
pad,y,start_w,action_h,cleaning?1:0,true,text,icons,large?radius:action_h/2);
vacuum_command("\U000F05F8","Dock",pad+start_w+gap,y,inner-start_w-gap,action_h,2,false,text,icons,large?radius:action_h/2);
y+=action_h+gap;
if(!mode && !suction && !water){
auto *note=detail_text(detail_root,"Automatic suction power",pad,y+gap,inner,text,LV_TEXT_ALIGN_CENTER,0x6B6B6B);(void)note;
return;
}
// How it cleans. The Guition groups the rows on one white card; the small screen has no room for a card.
int mode_h=large?48:34,row_h=large?44:32,step=large?10:6,edge=large?14:0,icon_w=large?40:26;
int note_h=lv_font_get_line_height(text);
int block=(mode?mode_h:0)+(suction?(mode?step:0)+row_h:0)+(water?((mode||suction)?step:0)+row_h:0)+(automatic?step+note_h+step:0);
uint32_t track=large?0xF1F1F1:0xFFFFFF;
if(large)detail_card(pad,y,inner,block+2*edge);
int x=pad+edge,w=inner-2*edge;
y+=edge;
if(mode){vacuum_segments(t,*mode,50,x,y,w,mode_h,track,!large,text);y+=mode_h+step;}
auto level=[&](const Choice &row,int first,const char *icon){
detail_text(detail_root,icon,x,y+(row_h-lv_font_get_line_height(icons))/2,icon_w,icons,LV_TEXT_ALIGN_LEFT,0x6B6B6B);
vacuum_segments(t,row,first,x+icon_w,y,w-icon_w,row_h,track,!large,text);
y+=row_h+step;
};
if(suction)level(*suction,10,"\U000F0210");
if(water)level(*water,60,"\U000F058C");
if(automatic){
bool smart=tile_controls::shown_value(t,*mode,now).find("smart")!=std::string::npos;
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);
}
}
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);
@@ -617,8 +846,8 @@ inline void show_detail(unsigned index){
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();
// The large vacuum card carries its state in the badge of the hero; a second line would crowd it.
if(!(d=="vacuum" && large)){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);}
// 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()){
detail_label(detail_root,"Tap to toggle",pad,top,width-2*pad);
detail_switch=lv_switch_create(detail_root);
@@ -641,50 +870,7 @@ inline void show_detail(unsigned index){
},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);
lv_obj_set_style_radius(o,radius,0);lv_obj_set_style_bg_color(o,lv_color_hex(color),0);lv_obj_set_style_bg_opa(o,LV_OPA_COVER,0);
lv_obj_remove_flag(o,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(o,LV_OBJ_FLAG_SCROLLABLE);return o;
};
uint32_t surface=0xFFFFFF, muted=0xEAF5FC;
if(large){
auto *hero=shape(detail_root,pad,100,width-2*pad,148,surface,24);
shape(hero,18,14,120,120,muted,60);
auto *robot=shape(hero,35,27,86,86,0xFFFFFF,43);
lv_obj_set_style_border_width(robot,2,0);lv_obj_set_style_border_color(robot,lv_color_hex(0xCEDDE6),0);
shape(robot,27,10,30,30,0xE3EBEF,15);shape(robot,35,18,14,14,0xA8BCC8,7);
shape(robot,29,59,26,5,0x00A6ED,3);
detail_label(hero,t.state=="cleaning"?"Working":t.state=="returning"?"Charging":t.state=="paused"?"Paused":"Ready for your home",154,24,260);
auto *badge=shape(hero,154,59,240,32,muted,16);
auto *status=detail_label(badge,t.awaiting_action(esphome::millis())?"Command sent...":state,12,5,218);
detail_badge_status=status;
lv_obj_set_style_text_color(status,lv_color_hex(0x087BA8),0);
detail_label(hero,std::isfinite(t.battery)?"Battery "+std::to_string((int)t.battery)+"%":"Connected via Home Assistant",154,107,260);
detail_button(t.state=="cleaning"?"Pause cleaning":"Start cleaning",pad,260,width-2*pad,58,t.state=="cleaning"?1:0);
detail_button("Return to dock",pad,330,cw,48,2);
detail_button("Find my robot",pad+cw+gap,330,cw,48,3);
detail_label(detail_root,"Suction power",pad,394,width-2*pad);
top=424;
}else{
auto *robot=shape(detail_root,pad,64,46,46,muted,23);
shape(robot,16,8,14,14,0xA8BCC8,7);shape(robot,15,32,16,3,0x00A6ED,2);
detail_label(detail_root,std::isfinite(t.battery)?"Battery "+std::to_string((int)t.battery)+"%":"Robot vacuum",pad+58,66,width-2*pad-58);
detail_label(detail_root,"Choose an action",pad+58,87,width-2*pad-58);
detail_button(t.state=="cleaning"?"Pause":"Clean",pad,120,cw,38,t.state=="cleaning"?1:0);
detail_button("To dock",pad+cw+gap,120,cw,38,2);
detail_label(detail_root,"Suction power",pad,168,width-2*pad);top=194;
}
if(!t.fan_speed_count)detail_label(detail_root,"Automatic suction power",pad,top,width-2*pad);
int count=std::max(1,(int)t.fan_speed_count),sw=(width-2*pad-gap*(count-1))/count;
for(unsigned i=0;i<t.fan_speed_count;++i){
std::string name=t.fan_speeds[i];
if(name=="quiet")name="Quiet";else if(name=="balanced")name="Normal";else if(name=="turbo")name="Turbo";else if(name=="max")name="Max";
auto *button=detail_button(name.c_str(),pad+i*(sw+gap),top,sw,large?36:30,10+i);
bool selected=t.fan_speed==t.fan_speeds[i];
lv_obj_set_style_bg_color(button,lv_color_hex(selected?0x009FE3:surface),0);
if(selected)lv_obj_set_style_text_color(lv_obj_get_child(button,0),lv_color_hex(0xFFFFFF),0);
}
render_vacuum_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;}
@@ -948,13 +1134,8 @@ inline void set_line_width(lv_obj_t *obj, int value) { if (lv_obj_get_style_line
inline uint32_t domain_accent(const Tile &t) {
auto d=t.domain();
if(d=="light" || d=="switch" || d=="input_boolean" || d=="binary_sensor")return 0xFFC107;
if(d=="climate"){
if(t.state=="cool")return 0x2196F3;
if(t.state=="fan_only")return 0x00BCD4;
if(t.state=="auto")return 0x4CAF50;
if(t.state=="heat_cool")return 0xFFC107;
return t.state=="heat"?0xFF6F22:0xFF9800;
}
// A climate card that is off or in an unknown mode keeps HA's orange climate circle.
if(d=="climate"){uint32_t c=tile_controls::mode_color(t.state);return c==0x9E9E9E?0xFF9800:c;}
if(d=="vacuum")return t.state=="error"?0xF44336:0x009688;
if(d=="fan")return 0x00BCD4;
if(d=="cover")return 0x926BC7;
@@ -2073,7 +2254,12 @@ inline void tick() {
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);
if(detail_status)label(detail_status,status+(!waiting && !t.unit.empty()?" "+t.unit:""));
if(detail_badge_status)label(detail_badge_status,status);
// 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.
if(detail_badge_status && t.domain()=="vacuum"){
status=waiting && !t.confirmed?"Command sent...":detail_state(t);
if(status!=lv_label_get_text(detail_badge_status))refresh_detail(detail_index);
}else 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);
@@ -2086,7 +2272,13 @@ inline void tick() {
// HA dropping or returning and the feed timing out change every card at once.
bool now_fresh=fresh();
if(now_fresh!=was_fresh){was_fresh=now_fresh;dirty_all=true;redraw=true;}
for(size_t i=0;i<model.tiles.size();++i){auto &t=model.tiles[i];if(t.pending && !t.loading(esphome::millis())){t.pending=false;card(i);}}
for(size_t i=0;i<model.tiles.size();++i){
auto &t=model.tiles[i];if(!t.pending || t.loading(esphome::millis()))continue;
t.pending=false;card(i);
// A vacuum chip Home Assistant never confirmed goes back to what the robot reports.
bool sent=false;for(auto &c:t.choices){sent=sent||!c.sent.empty();c.sent.clear();}
if(sent)refresh_detail(i);
}
// A -/+ edit goes out as one call once the finger rests; a value HA never reports is dropped after a while.
for(size_t i=0;i<model.count;++i){
auto &t=model.tiles[i];if(!std::isfinite(t.edit_value))continue;
+63
View File
@@ -44,6 +44,16 @@ inline std::string panel_kind(const Tile &t) {
return c;
}
inline bool has_mode(const std::string &hvac_modes, const char *mode) { return hvac_modes.find("\"" + std::string(mode) + "\"") != std::string::npos; }
// Home Assistant's colour for a climate mode (the card and its mode keys use it).
inline uint32_t mode_color(const std::string &mode) {
if (mode == "heat") return 0xFF6F22;
if (mode == "cool") return 0x2196F3;
if (mode == "heat_cool") return 0xFFC107;
if (mode == "auto") return 0x4CAF50;
if (mode == "fan_only") return 0x00BCD4;
if (mode == "dry") return 0xFF9800;
return 0x9E9E9E;
}
inline const char *mode_icon(const std::string &mode) {
if (mode == "off") return glyph::POWER;
if (mode == "heat") return glyph::FIRE;
@@ -226,6 +236,59 @@ inline Action key_action(const Tile &t, int command, const std::string &arg = ""
default: return {};
}
}
// ---- Vacuum card rows (firmware 0.2.39+) ----
// The value a row shows as chosen: the one just tapped while Home Assistant has not answered yet.
inline const std::string &shown_value(const Tile &t, const runtime_tiles::Choice &c, uint32_t now) {
return !c.sent.empty() && t.loading(now) ? c.sent : c.current;
}
// Role of the cleaning mode in use: 'v' vacuum only, 'm' mop only, 'b' both, 'a' automatic; 'b' when
// the robot has no mode select or reports a mode the manager did not know.
inline char vacuum_role(const Tile &t, uint32_t now) {
const auto *mode = t.choice('m');
if (!mode) return 'b';
const std::string &value = shown_value(t, *mode, now);
for (size_t i = 0; i < mode->values.size() && i < mode->roles.size(); ++i)
if (mode->values[i] == value) return mode->roles[i];
return 'b';
}
// Rows under the mode: suction unless the robot only mops, water (when the robot has a water select)
// unless it only vacuums; an automatic mode leaves both to the robot or its app.
struct VacuumRows { bool suction = false, water = false; };
inline VacuumRows vacuum_rows(const Tile &t, uint32_t now) {
char role = vacuum_role(t, now);
VacuumRows rows;
rows.suction = role != 'm' && role != 'a' && t.choice('s');
rows.water = role != 'v' && role != 'a' && t.choice('w');
return rows;
}
// Labels for a manager that sends no suction row (app before 0.2.46): the vacuum's own speed names.
inline std::string speed_label(const std::string &speed) {
if (speed == "quiet") return "Quiet";
if (speed == "balanced") return "Normal";
if (speed == "turbo") return "Turbo";
if (speed == "max") return "Max";
return speed;
}
// The suction row a vacuum card shows: the manager's (filtered, labelled) speeds, else the first four
// of the vacuum's own list. Its value is always the vacuum's fan_speed.
inline void settle_suction(Tile &t) {
auto *row = t.choice('s');
if (!row && t.fan_speed_count) {
runtime_tiles::Choice legacy; legacy.kind = 's';
for (unsigned i = 0; i < t.fan_speed_count; ++i) { legacy.values.push_back(t.fan_speeds[i]); legacy.labels.push_back(speed_label(t.fan_speeds[i])); }
t.choices.push_back(std::move(legacy));
row = &t.choices.back();
}
if (row) row->current = t.fan_speed;
}
// The service call behind a chip: a select option on the device, or the vacuum's own fan speed.
inline Action choice_action(const Tile &t, char kind, const std::string &value) {
if (kind == 's') return {"vacuum.set_fan_speed", "fan_speed", value};
const auto *row = t.choice(kind);
if (!row || row->entity.empty()) return {};
return {"select.select_option", "option", value};
}
// The debounced -/+ edit lands as one service call.
inline Action edit_action(const Tile &t, float value) {
if (!std::isfinite(value)) return {};
@@ -185,6 +185,7 @@ inline constexpr Named NAMES[] = {
{"weather-pouring", 0xF0596},
{"weather-snowy-rainy", 0xF067F},
{"weather-sunset-down", 0xF059B},
{"dots-horizontal", 0xF01D8},
};
inline constexpr size_t NAME_COUNT = sizeof(NAMES) / sizeof(NAMES[0]);
// Codepoint of a name the fonts carry; 0 for any other name.
+35
View File
@@ -176,6 +176,41 @@ 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.46 / firmware 0.2.39
Storage version, tile protocol (`v: 1`), preferences and keys are unchanged. Additive wire fields only.
- A vacuum's state message carries new extras in `x` (`core.vacuum_extras`): `mode` and `water`
(`e` select entity, `s` state, `o` options, `l` labels, `r` one role letter per mode: v/m/b/a),
`fan` (the suction speeds to offer, `o`/`l`), `bat` (0..100), `chg` (1 while charging) and
`room`. Options are capped at six, labels at 24 bytes. With a mode select, speeds and water
levels that a mode covers (`off`, `custom`, `smart_mode`, …) leave the rows, and `custom` leaves
the mode row unless it is the current mode. `a.fan_speed_list` stays capped at four, so firmware
before 0.2.39 draws its old suction row; firmware 0.2.39 with an older app does the same.
- `core.vacuum_related` finds the selects, the battery sensor (`device_class: battery`), the
charging binary sensor (`battery_charging`) and the room sensor on the vacuum's device, by
translation key first (entity ids follow the HA language) and entity id ending second; disabled
entities are skipped. `Manager.device_entries` indexes the registry per device (rebuilt with the
registry object), `related_entities` adds these ids to `watched_entities` and to the `sync_one`
reuse check, so a select change resends the vacuum's message.
- Firmware: `Tile::choices` (`Choice`: kind m/w/s, entity, current, roles, values, labels, `sent`),
`room`, `charging`; the state revision now includes `x`, so a select change confirms a pending
command. Chips send `select.select_option` (or `vacuum.set_fan_speed`), mark the vacuum tile
pending and redraw the card through `lv_async_call` after the event. `tile_controls::vacuum_rows`
/ `vacuum_role` / `shown_value` / `settle_suction` / `choice_action` are covered by
tests/test_tile_controls.cpp. Detail commands: 10-15 suction, 50-55 mode, 60-65 water;
`detail_actions` holds 32 buttons. `tick()` redraws an open vacuum card when its state text changes.
- CYD climate card (home-like-2432s028.yaml): the arc is gone. New widgets `climate_setpoint_card`,
`climate_mode_keys`, `cmkey_*`, font `setpoint_digits` (Roboto 400, 40 px, digits . - °), globals
`active_climate_current`, `active_climate_action`, `active_climate_target_known`, scripts
`climate_card_refresh` and `climate_target_send` (restart mode, 700 ms, entity and value as
parameters); `climate_target_preview`/`climate_target_commit` are removed. `tile_controls::mode_color`
holds HA's mode colours (also used by `domain_accent`). Glyph `dots-horizontal` (F01D8) joins
`tile_icons.FIXED`.
- Both profiles: the 1 s interval clears `climate_edit_pending` when the climate card is closed and,
on runtime tiles, replays the held-back state through `detail_update`, instead of running
`ui_refresh` every second while the flag stayed set.
### Compatibility 0.2.45 (firmware stays 0.2.38)
App only: storage version, tile protocol, preferences and keys are unchanged.
+64
View File
@@ -0,0 +1,64 @@
# Test results app 0.2.46 / firmware 0.2.39 (2026-09-15)
Cleaning mode, suction and water on the vacuum card, the vacuum card redesign on both boards, the
new CYD climate card and the cleared climate edit flag. See CHANGELOG 0.2.46 and RELEASING
"Compatibility 0.2.46".
## Automated
- Python (`.venv-portal`): 170 tests OK.
- New: `tests/test_vacuum_card.py` covers the extras on the owner's Roborock data (Dutch entity
ids), a robot without a mode select, a plain vacuum, entity id endings, disabled entities and
unknown modes (an Ecovacs-style `work_mode`), bounded options, and that a select change marks
the vacuum dirty and resends only its message.
- C++ (`clang++ -std=c++17 -Wall -Wextra -Werror -I . -I components/smart_display`): 13/13 PASS;
`test_tile_controls.cpp` adds `mode_color`, `vacuum_rows`, `vacuum_role`, `shown_value`,
`settle_suction` and `choice_action`.
- `generate_packages.py --check` green, `generate_icons.py --check` verified (150 pickable icons,
179 glyphs).
- ESPHome 2026.6.2 compile SUCCESS, one after another: `easy-cyd-device.yaml` (RAM 31.9%, flash
78.5%), `easy-guition-device.yaml` (RAM 32.8%, flash 18.8%), `guition-device.yaml` (RAM 32.9%,
flash 19.8%), `device.yaml` (RAM 32.0%, flash 83.2%). Packages carry `SCREEN_FIRMWARE_VERSION` 0.2.39.
## Home Assistant facts (owner's HA 2026.9.1, read only)
- Roborock S8 (`roborock.vacuum.a51`): `vacuum.s8` has no `battery_level`; its device holds
`select.woonkamer_s8_schoonmaakmodus` (translation key `cleaning_mode`: vacuum, vac_and_mop,
mop, custom), `select.s8_intensiteit_van_dweilen` (`mop_intensity`: off, mild, standard, intense,
custom), `sensor.s8_batterij` (battery), `binary_sensor.s8_opladen` (battery_charging) and
`sensor.s8_huidige_kamer` (`current_room`). Entity ids follow the UI language, so matching goes
by translation key.
- The Roborock cleaning mode select exists in HA 2026.8.0 and 2026.9.1 (`RoborockCleaningModeSelectEntity`).
- `core.extras` on this data: mode vacuum / vac_and_mop / mop (roles `vbm`), water mild / standard /
intense, suction quiet … max_plus, battery, charging and room; the message is 612 bytes.
## Bench boards (OTA)
- Both Easy Setup builds uploaded over the network; `device_info` reports project 0.2.39, compiled
2026-09-15 15:11:48 (CYD) and 15:12:20 (Guition), matching the build logs.
- The real `vacuum.s8` state with the new extras, built by the app code from live HA data, was sent
through `screen_message` and the card opened with `preview_runtime_card` on both boards. The
Guition capture (`capture_ui.py`) shows Docked, 83 % with the charging bolt, Start cleaning and
Dock, Vac & mop, Max and Intense. The owner looked at both screens.
- The owner tapped Vacuum on the CYD: Home Assistant switched `select.woonkamer_s8_schoonmaakmodus`
to `vacuum` (15:16:12) and the robot set its water to off and suction to balanced. The production
app was still 0.2.45 and resent the vacuum without the new extras, so the card fell back to
suction only, as described for mixed versions in the CHANGELOG.
## Renders (ESPHome host + SDL, both boards, this release's code)
- Vacuum card with the owner's data in ten states: docked and charging, cleaning with the room,
mop only, vacuum only, Custom (note instead of rows), returning, error, paused at 12 % (red
battery), a vacuum without selects (small hero on the CYD) and an Ecovacs-style robot with long
labels (Vac, then mop; Ultra high). No label is cut off; the mode row keeps its place.
- CYD climate card: heating with humidity, an air conditioner with five modes plus ···, and off
(grey setpoint). The README images `guition-vacuum.png`, `cyd-vacuum.png` and `cyd-climate.png`
come from these renders.
## Not tested / for the owner
- The vacuum rows with app 0.2.46 on the Yellow after the store update (live updates of mode,
water, battery and room while the robot cleans).
- The CYD climate card by hand with the real air conditioner (− / + taps, holding, mode keys, ···).
- Other brands only through the tests with their documented entity names; no Dreame or Ecovacs
robot was available.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 36 KiB

+12 -2
View File
@@ -1,7 +1,7 @@
# Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911.
# Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.38"
SCREEN_FIRMWARE_VERSION: "0.2.39"
# 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"
@@ -913,7 +913,16 @@ interval:
condition:
lambda: 'return id(climate_edit_pending) && !id(touch_down) && millis() - id(climate_edit_ms) >= 3000;'
then:
- lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();'
# The edit window is over: show what Home Assistant reports now. A closed card has
# nothing to show, and a flag left set would redraw every tile each second.
- lambda: |-
if (lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) { id(climate_edit_pending) = false; return; }
if (!runtime_tiles::enabled) { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); return; }
// Runtime tiles: a state that arrived during the window was held back.
id(climate_edit_pending) = false;
if (runtime_tiles::detail_update && runtime_tiles::active_index >= 0 &&
runtime_tiles::active_index < (int) runtime_tiles::model.count)
runtime_tiles::detail_update(runtime_tiles::model.tiles[runtime_tiles::active_index]);
- if:
condition:
lambda: |-
@@ -1353,6 +1362,7 @@ font:
- "\U000F0596" # weather-pouring
- "\U000F067F" # weather-snowy-rainy
- "\U000F059B" # weather-sunset-down
- "\U000F01D8" # dots-horizontal
- file: 'fonts/materialdesignicons-webfont.ttf'
id: materialdesign_icons_mini
+441 -289
View File
@@ -57,7 +57,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.38"
SCREEN_FIRMWARE_VERSION: "0.2.39"
# 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.
@@ -886,6 +886,7 @@ esphome:
light_controls::fallback.maximum = t.max_kelvin;
id(active_climate_fan_modes) = t.fan_modes; id(active_climate_fan_mode) = t.fan_mode;
id(active_climate_swing_modes) = t.swing_modes; id(active_climate_swing_mode) = t.swing_mode;
id(active_climate_action) = t.hvac_action;
id(open_value_overlay).execute(t.name, t.entity, t.domain(), "auto", t.active(), 0,
t.brightness, t.percentage, t.position, runtime_tiles::icon_for(t),
0xD5EEFC, 0x00A6ED, 0x1B1B1B, "Off", "On", t.modes,
@@ -898,22 +899,17 @@ esphome:
if (id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000) return;
id(climate_edit_pending) = false;
id(active_climate_mode) = t.state;
id(active_climate_action) = t.hvac_action;
id(active_climate_supported_modes) = t.hvac_modes;
lv_label_set_text(id(climate_mode_label), tile_controls::climate_mode_text(t.state));
id(climate_power_refresh).execute();
id(active_climate_fan_modes) = t.fan_modes; id(active_climate_fan_mode) = t.fan_mode;
id(active_climate_swing_modes) = t.swing_modes; id(active_climate_swing_mode) = t.swing_mode;
char buf[72];
if (std::isfinite(t.current)) snprintf(buf, sizeof(buf), "Now: %.1f°", t.current);
else snprintf(buf, sizeof(buf), "Now: --");
lv_label_set_text(id(climate_current_label), buf);
id(active_climate_current) = t.current;
id(active_climate_humidity) = std::isfinite(t.humidity) ? (int) lround(t.humidity) : -1;
if (std::isfinite(t.target)) {
int target = cyd::quantize_temperature(lround(t.target * 10), id(active_climate_min_x10), id(active_climate_max_x10), id(active_climate_step_x10));
id(active_climate_target_x10) = target;
snprintf(buf, sizeof(buf), "%.1f°", target / 10.0f);
lv_label_set_text(id(climate_target_label), buf);
lv_arc_set_value(id(climate_arc), target);
id(active_climate_target_x10) = cyd::quantize_temperature(lround(t.target * 10), id(active_climate_min_x10), id(active_climate_max_x10), id(active_climate_step_x10));
id(active_climate_target_known) = true;
}
id(climate_card_refresh).execute();
}
};
- lambda: |-
@@ -1107,7 +1103,16 @@ interval:
condition:
lambda: 'return id(climate_edit_pending) && !id(touch_down) && millis() - id(climate_edit_ms) >= 3000;'
then:
- lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();'
# The edit window is over: show what Home Assistant reports now. A closed card has
# nothing to show, and a flag left set would redraw every tile each second.
- lambda: |-
if (lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) { id(climate_edit_pending) = false; return; }
if (!runtime_tiles::enabled) { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); return; }
// Runtime tiles: a state that arrived during the window was held back.
id(climate_edit_pending) = false;
if (runtime_tiles::detail_update && runtime_tiles::active_index >= 0 &&
runtime_tiles::active_index < (int) runtime_tiles::model.count)
runtime_tiles::detail_update(runtime_tiles::model.tiles[runtime_tiles::active_index]);
- if:
condition:
lambda: |-
@@ -1294,6 +1299,22 @@ globals:
type: std::string
restore_value: no
# Room temperature (NAN when unknown), what the device does (hvac_action, runtime tiles only),
# and whether Home Assistant reported a setpoint at all (heat_cool ranges do not).
- id: active_climate_current
type: float
restore_value: no
initial_value: 'NAN'
- id: active_climate_action
type: std::string
restore_value: no
- id: active_climate_target_known
type: bool
restore_value: no
initial_value: 'false'
font:
- file: "fonts/Roboto-500.ttf"
id: time_label
@@ -1328,6 +1349,13 @@ font:
bpp: 4
glyphs: [':','-','0','1','2','3','4','5','6','7','8','9']
# The climate card's setpoint, large enough to read across the room.
- file: "fonts/Roboto-400.ttf"
id: setpoint_digits
size: 40
bpp: 4
glyphs: ['-','.','°','0','1','2','3','4','5','6','7','8','9']
- file: "fonts/Roboto-700.ttf"
id: label
size: 11
@@ -1542,6 +1570,7 @@ font:
- "\U000F0596" # weather-pouring
- "\U000F067F" # weather-snowy-rainy
- "\U000F059B" # weather-sunset-down
- "\U000F01D8" # dots-horizontal
- file: 'fonts/materialdesignicons-webfont.ttf'
id: materialdesign_icons_mini
@@ -4168,13 +4197,17 @@ lvgl:
# ---------- CLIMATE DETAIL OVERLAY ----------
# Made for the small resistive panel: nothing to drag. A setpoint card with big -/+ keys
# (the number changes at once, one call goes out once the taps stop) and one row of mode
# keys like Home Assistant's climate card; fan and swing stay in the picker behind "more".
# A tap redraws only a label or a key, never a dial, so the SPI panel keeps up.
- obj:
id: climate_detail_overlay
x: 0
y: 0
width: ${DISPLAY_W}
height: ${DISPLAY_H}
bg_color: 0xF7F7F7
bg_color: 0xE7E7E7
bg_opa: COVER
hidden: true
scrollable: false
@@ -4185,27 +4218,6 @@ lvgl:
pad_bottom: 0
border_width: 0
widgets:
- obj:
id: climate_detail_bg
x: 0
y: 0
width: ${DISPLAY_W}
height: ${DISPLAY_H}
styles: style_bg_grad
scrollable: false
clickable: false
- obj:
id: climate_detail_scrim
x: 0
y: 0
width: ${DISPLAY_W}
height: ${DISPLAY_H}
bg_color: 0xFFFFFF
bg_opa: 62%
border_width: 0
clickable: false
scrollable: false
- obj:
id: climate_close_button
align: TOP_LEFT
@@ -4256,156 +4268,306 @@ lvgl:
- label:
id: climate_detail_title
align: TOP_MID
y: 21
y: 18
width: 210
long_mode: DOT
text_align: CENTER
text: "Climate"
styles: style_room
# What the device does (or its mode), the room temperature and humidity.
- label:
id: climate_current_label
align: TOP_MID
y: 42
text: "Now: --"
y: 54
width: 94%
long_mode: DOT
text_align: CENTER
text: "--"
text_font: sublabel_big
text_color: 0x1B1B1B
text_opa: 85%
text_color: 0x616161
clickable: false
- arc:
id: climate_arc
align: CENTER
y: 20
width: 184
height: 184
# Without this the arc claims an invisible extra
# click area outside the 184x184 box — near the close
# button top-left this creates overlap.
ext_click_area: 0
start_angle: 135
end_angle: 45
min_value: 50
max_value: 300
value: 200
adjustable: true
arc_width: 20
arc_rounded: true
arc_color: 0x4B4B4F
arc_opa: 80%
- obj:
id: climate_setpoint_card
align: TOP_MID
y: 80
width: 94%
height: 92
radius: 16
bg_color: 0xFFFFFF
bg_opa: COVER
border_width: 1
border_color: 0xDDDDDD
pad_all: 0
clickable: false
scrollable: false
widgets:
# Every clean tap counts (a quick triple tap is three steps); holding keeps stepping.
- obj:
id: climate_minus_button
align: LEFT_MID
x: 10
width: 72
height: 72
radius: 36
bg_color: 0xF1F1F1
bg_opa: COVER
border_width: 0
pad_all: 0
clickable: true
scrollable: false
pressed:
bg_color: 0xD9D9D9
disabled:
opa: 40%
on_short_click:
- if:
condition:
lambda: 'return cyd::touch_guard.accept_repeat(millis(), 60);'
then:
- script.execute: { id: climate_adjust_target, direction: -1, held: false }
on_long_press_repeat:
- script.execute: { id: climate_adjust_target, direction: -1, held: true }
widgets:
- label:
id: climate_minus_icon
align: CENTER
text: "\U000F0374"
text_font: materialdesign_icons
text_color: 0x1B1B1B
clickable: false
- obj:
id: climate_plus_button
align: RIGHT_MID
x: -10
width: 72
height: 72
radius: 36
bg_color: 0xF1F1F1
bg_opa: COVER
border_width: 0
pad_all: 0
clickable: true
scrollable: false
pressed:
bg_color: 0xD9D9D9
disabled:
opa: 40%
on_short_click:
- if:
condition:
lambda: 'return cyd::touch_guard.accept_repeat(millis(), 61);'
then:
- script.execute: { id: climate_adjust_target, direction: 1, held: false }
on_long_press_repeat:
- script.execute: { id: climate_adjust_target, direction: 1, held: true }
widgets:
- label:
id: climate_plus_icon
align: CENTER
text: "\U000F0415"
text_font: materialdesign_icons
text_color: 0x1B1B1B
clickable: false
- label:
id: climate_target_label
align: CENTER
y: -8
text: "--"
text_font: setpoint_digits
text_color: 0x1B1B1B
clickable: false
- label:
id: climate_set_label
align: CENTER
y: 26
text: "Target"
text_font: sublabel
text_color: 0x6B6B6B
clickable: false
# One key per mode the device supports (off is the power key); the one in use is
# filled in Home Assistant's colour for it. "More" opens the picker with fan and swing.
- obj:
id: climate_mode_keys
align: TOP_MID
y: 184
width: 94%
height: 46
bg_opa: TRANSP
border_width: 0
indicator:
arc_color: 0xFEC600
arc_opa: COVER
arc_width: 20
arc_rounded: true
knob:
bg_color: 0x111111
bg_opa: COVER
border_width: 0
shadow_width: 0
on_value:
- script.execute:
id: climate_target_preview
value: !lambda "return x;"
on_release:
- script.execute:
id: climate_target_commit
value: !lambda "return lv_arc_get_value(id(climate_arc));"
- label:
id: climate_set_label
align: CENTER
y: -16
text: "Target"
text_font: sublabel_big
text_color: 0x1B1B1B
text_opa: 68%
pad_all: 0
clickable: false
- label:
id: climate_target_label
align: CENTER
y: 20
text: "20.0°"
text_font: headline
text_color: 0x1B1B1B
clickable: false
- obj:
id: climate_minus_button
align: BOTTOM_LEFT
x: 38
y: -16
width: 48
height: 36
radius: 18
bg_color: 0xEEEEEE
bg_opa: COVER
border_width: 1
border_color: 0xFFFFFF
border_opa: 16%
clickable: true
scrollable: false
on_click:
- script.execute:
id: climate_adjust_target
direction: -1
layout:
type: FLEX
flex_flow: ROW
flex_align_main: CENTER
flex_align_cross: CENTER
pad_column: 6
widgets:
- label:
align: CENTER
text: "-"
text_font: headline
text_color: 0x1B1B1B
clickable: false
- obj:
id: climate_plus_button
align: BOTTOM_RIGHT
x: -38
y: -16
width: 48
height: 36
radius: 18
bg_color: 0xEEEEEE
bg_opa: COVER
border_width: 1
border_color: 0xFFFFFF
border_opa: 16%
clickable: true
scrollable: false
on_click:
- script.execute:
id: climate_adjust_target
direction: 1
widgets:
- label:
align: CENTER
text: "+"
text_font: headline
text_color: 0x1B1B1B
clickable: false
- obj:
id: climate_mode_pill
align: BOTTOM_MID
y: -18
width: 126
height: 34
radius: 17
bg_color: 0xF0F0F0
bg_opa: 82%
border_width: 0
clickable: true
scrollable: false
on_click:
- script.execute: open_climate_mode_picker
widgets:
- label:
id: climate_mode_label
align: CENTER
text: "Auto"
text_font: sublabel_big
text_color: 0x1B1B1B
clickable: false
- obj:
id: cmkey_auto
flex_grow: 1
height: 46
radius: 23
bg_color: 0xFFFFFF
bg_opa: COVER
border_width: 1
border_color: 0xDDDDDD
pad_all: 0
clickable: true
scrollable: false
pressed:
bg_color: 0xEEEEEE
on_click:
- script.execute: { id: set_climate_mode, mode: "auto" }
widgets:
- label:
align: CENTER
text: "\U000F1B17"
text_font: materialdesign_icons
text_color: 0x46525E
clickable: false
- obj:
id: cmkey_heat_cool
flex_grow: 1
height: 46
radius: 23
bg_color: 0xFFFFFF
bg_opa: COVER
border_width: 1
border_color: 0xDDDDDD
pad_all: 0
clickable: true
scrollable: false
pressed:
bg_color: 0xEEEEEE
on_click:
- script.execute: { id: set_climate_mode, mode: "heat_cool" }
widgets:
- label:
align: CENTER
text: "\U000F1A79"
text_font: materialdesign_icons
text_color: 0x46525E
clickable: false
- obj:
id: cmkey_heat
flex_grow: 1
height: 46
radius: 23
bg_color: 0xFFFFFF
bg_opa: COVER
border_width: 1
border_color: 0xDDDDDD
pad_all: 0
clickable: true
scrollable: false
pressed:
bg_color: 0xEEEEEE
on_click:
- script.execute: { id: set_climate_mode, mode: "heat" }
widgets:
- label:
align: CENTER
text: "\U000F0238"
text_font: materialdesign_icons
text_color: 0x46525E
clickable: false
- obj:
id: cmkey_cool
flex_grow: 1
height: 46
radius: 23
bg_color: 0xFFFFFF
bg_opa: COVER
border_width: 1
border_color: 0xDDDDDD
pad_all: 0
clickable: true
scrollable: false
pressed:
bg_color: 0xEEEEEE
on_click:
- script.execute: { id: set_climate_mode, mode: "cool" }
widgets:
- label:
align: CENTER
text: "\U000F0717"
text_font: materialdesign_icons
text_color: 0x46525E
clickable: false
- obj:
id: cmkey_dry
flex_grow: 1
height: 46
radius: 23
bg_color: 0xFFFFFF
bg_opa: COVER
border_width: 1
border_color: 0xDDDDDD
pad_all: 0
clickable: true
scrollable: false
pressed:
bg_color: 0xEEEEEE
on_click:
- script.execute: { id: set_climate_mode, mode: "dry" }
widgets:
- label:
align: CENTER
text: "\U000F058E"
text_font: materialdesign_icons
text_color: 0x46525E
clickable: false
- obj:
id: cmkey_fan_only
flex_grow: 1
height: 46
radius: 23
bg_color: 0xFFFFFF
bg_opa: COVER
border_width: 1
border_color: 0xDDDDDD
pad_all: 0
clickable: true
scrollable: false
pressed:
bg_color: 0xEEEEEE
on_click:
- script.execute: { id: set_climate_mode, mode: "fan_only" }
widgets:
- label:
align: CENTER
text: "\U000F0210"
text_font: materialdesign_icons
text_color: 0x46525E
clickable: false
- obj:
id: cmkey_more
flex_grow: 1
height: 46
radius: 23
bg_color: 0xFFFFFF
bg_opa: COVER
border_width: 1
border_color: 0xDDDDDD
pad_all: 0
clickable: true
scrollable: false
pressed:
bg_color: 0xEEEEEE
on_click:
- script.execute: open_climate_mode_picker
widgets:
- label:
align: CENTER
text: "\U000F01D8"
text_font: materialdesign_icons
text_color: 0x46525E
clickable: false
# ---------- CLIMATE MODE PICKER ----------
# Replaces cycling through modes. Cycling sends a set_hvac_mode on every
# tap, so to get from 'cool' to 'dry' a heat pump actually passes through
@@ -5490,55 +5652,106 @@ script:
id: smartdisplay_action
state: ""
- id: climate_target_preview
# A -/+ tap on the climate card: the number follows at once, the call waits for the last tap.
# Holding a key steps three times a second.
- id: climate_adjust_target
parameters:
value: int
direction: int
held: bool
then:
- lambda: |-
if (held && millis() - id(climate_edit_ms) < 300) return;
int step_x10 = std::max(5, id(active_climate_step_x10));
int next = cyd::quantize_temperature(id(active_climate_target_x10) + direction * step_x10,
id(active_climate_min_x10), id(active_climate_max_x10), step_x10);
id(climate_edit_ms) = millis();
id(climate_edit_pending) = true;
int min_x10 = id(active_climate_min_x10);
int max_x10 = id(active_climate_max_x10);
value = cyd::quantize_temperature(value, min_x10, max_x10,
id(active_climate_step_x10));
id(active_climate_target_x10) = value;
if (next == id(active_climate_target_x10) && id(active_climate_target_known)) return;
id(active_climate_target_x10) = next;
id(active_climate_target_known) = true;
id(climate_card_refresh).execute();
id(climate_target_send).execute(id(active_entity), next / 10.0f);
char buf[16];
snprintf(buf, sizeof(buf), "%.1f°", value / 10.0f);
lv_label_set_text(id(climate_target_label), buf);
lv_arc_set_value(id(climate_arc), value);
- id: climate_target_commit
# One set_temperature for a series of taps: every tap restarts the wait with the newest value.
# Entity and value travel along, so leaving the card right after a tap still sends it.
- id: climate_target_send
mode: restart
parameters:
value: int
entity: string
value: float
then:
- script.execute:
id: climate_target_preview
value: !lambda "return value;"
- delay: 700ms
- if:
condition:
lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";'
lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && !entity.empty();'
then:
- homeassistant.action:
action: climate.set_temperature
data:
entity_id: !lambda "return id(active_entity);"
temperature: !lambda "return id(active_climate_target_x10) / 10.0f;"
entity_id: !lambda "return entity;"
temperature: !lambda "return value;"
- id: climate_adjust_target
parameters:
direction: int
# Draws the climate card from the active_climate_* globals: status line, setpoint, -/+ and mode keys.
- id: climate_card_refresh
then:
- lambda: |-
int step_x10 = id(active_climate_step_x10);
if (step_x10 < 5) step_x10 = 5;
int next = id(active_climate_target_x10) + (direction * step_x10);
if (next < id(active_climate_min_x10)) next = id(active_climate_min_x10);
if (next > id(active_climate_max_x10)) next = id(active_climate_max_x10);
id(active_climate_target_x10) = next;
- script.execute:
id: climate_target_commit
value: !lambda "return id(active_climate_target_x10);"
auto lower = [](std::string v) {
for (char &c : v) { if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); }
return v;
};
std::string mode = lower(id(active_climate_mode));
bool off = mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable";
// What the device does (Heating), else its mode (Heat); then the room.
std::string status = off ? std::string("Off") : std::string(tile_controls::climate_action_text(lower(id(active_climate_action))));
if (status.empty()) status = tile_controls::climate_mode_text(mode);
char part[32];
if (std::isfinite(id(active_climate_current))) { snprintf(part, sizeof(part), " · Now %.1f°", id(active_climate_current)); status += part; }
if (id(active_climate_humidity) >= 0) { snprintf(part, sizeof(part), " · %d%%", id(active_climate_humidity)); status += part; }
lv_label_set_text(id(climate_current_label), status.c_str());
int target = id(active_climate_target_x10);
std::string value = id(active_climate_target_known)
? tile_controls::format_value(target / 10.0f, id(active_climate_step_x10) / 10.0f, "°") : std::string("--");
lv_label_set_text(id(climate_target_label), value.c_str());
lv_obj_set_style_text_color(id(climate_target_label), lv_color_hex(off ? 0x9E9E9E : 0x1B1B1B), LV_PART_MAIN);
auto enable = [](lv_obj_t *key, bool on) {
if (on) lv_obj_remove_state(key, LV_STATE_DISABLED); else lv_obj_add_state(key, LV_STATE_DISABLED);
};
enable(id(climate_minus_button), !id(active_climate_target_known) || target > id(active_climate_min_x10));
enable(id(climate_plus_button), !id(active_climate_target_known) || target < id(active_climate_max_x10));
// One key per supported mode, in Home Assistant's order. Fan and swing add "more"; with it
// five mode keys fit, and when a sixth mode is in use it takes the last place.
std::string supported = lower(id(active_climate_supported_modes));
auto has = [&](const char *m) {
return supported.find(std::string("\"") + m + "\"") != std::string::npos ||
supported.find(std::string("'") + m + "'") != std::string::npos;
};
struct Key { lv_obj_t *obj; const char *mode; };
const Key keys[] = {{id(cmkey_auto), "auto"}, {id(cmkey_heat_cool), "heat_cool"}, {id(cmkey_heat), "heat"},
{id(cmkey_cool), "cool"}, {id(cmkey_dry), "dry"}, {id(cmkey_fan_only), "fan_only"}};
bool more = !id(active_climate_fan_modes).empty() || !id(active_climate_swing_modes).empty();
const Key *visible[6];
unsigned count = 0, room = more ? 5 : 6;
for (const auto &k : keys) if (has(k.mode)) visible[count++] = &k;
if (count > room) {
for (unsigned i = room; i < count; ++i) if (mode == visible[i]->mode) visible[room - 1] = visible[i];
count = room;
}
for (const auto &k : keys) {
bool show = false;
for (unsigned i = 0; i < count; ++i) show = show || visible[i] == &k;
if (show) lv_obj_remove_flag(k.obj, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(k.obj, LV_OBJ_FLAG_HIDDEN);
bool selected = show && !off && mode == k.mode;
uint32_t color = tile_controls::mode_color(k.mode);
lv_obj_set_style_bg_color(k.obj, lv_color_hex(selected ? color : 0xFFFFFF), LV_PART_MAIN);
lv_obj_set_style_border_color(k.obj, lv_color_hex(selected ? color : 0xDDDDDD), LV_PART_MAIN);
lv_obj_set_style_text_color(lv_obj_get_child(k.obj, 0), lv_color_hex(selected ? 0xFFFFFF : 0x46525E), LV_PART_MAIN);
}
if (more) lv_obj_remove_flag(id(cmkey_more), LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(id(cmkey_more), LV_OBJ_FLAG_HIDDEN);
if (count || more) lv_obj_remove_flag(id(climate_mode_keys), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(climate_mode_keys), LV_OBJ_FLAG_HIDDEN);
id(climate_power_refresh).execute();
# Opens the mode picker. Hides modes the entity doesn't support and marks
# the current one, so you see where you stand before you pick something.
@@ -5688,8 +5901,8 @@ script:
id(active_climate_power_on) = mode == "off" || mode.empty() || mode == "unknown";
id(climate_edit_ms) = millis();
id(climate_edit_pending) = true;
if (!id(active_climate_power_on)) { id(active_climate_mode) = "off"; lv_label_set_text(id(climate_mode_label), "Off"); }
id(climate_power_refresh).execute();
if (!id(active_climate_power_on)) id(active_climate_mode) = "off";
id(climate_card_refresh).execute();
- if:
condition:
lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && id(active_climate_power_on);'
@@ -5728,9 +5941,10 @@ script:
id(climate_edit_ms) = millis();
id(climate_edit_pending) = true;
id(active_climate_mode) = mode;
// The action line would still say what the old mode did.
id(active_climate_action).clear();
id(active_climate_power_on) = mode != "off";
id(climate_power_refresh).execute();
lv_label_set_text(id(climate_mode_label), tile_controls::climate_mode_text(mode));
id(climate_card_refresh).execute();
- lvgl.widget.hide: climate_mode_overlay
- if:
condition:
@@ -6285,6 +6499,8 @@ script:
}
id(active_climate_current_x10) = to_x10(climate_current_temperature, 0);
id(active_climate_current) = climate_current_temperature;
id(active_climate_target_known) = !isnan(climate_target_temperature);
int fallback_target = isnan(climate_current_temperature) ? 200 : id(active_climate_current_x10);
id(active_climate_target_x10) = to_x10(climate_target_temperature, fallback_target);
if (id(active_climate_target_x10) < id(active_climate_min_x10)) id(active_climate_target_x10) = id(active_climate_min_x10);
@@ -6297,41 +6513,19 @@ script:
id(active_climate_supported_modes) = climate_supported_modes;
lv_label_set_text(id(climate_detail_title), title.c_str());
char current_buf[48];
if (isnan(climate_current_temperature)) {
snprintf(current_buf, sizeof(current_buf), "Now: --");
} else if (id(active_climate_humidity) >= 0) {
snprintf(current_buf, sizeof(current_buf), "Now: %.1f° · %d%%", climate_current_temperature, id(active_climate_humidity));
} else {
snprintf(current_buf, sizeof(current_buf), "Now: %.1f°", climate_current_temperature);
}
lv_label_set_text(id(climate_current_label), current_buf);
char target_buf[16];
snprintf(target_buf, sizeof(target_buf), "%.1f°", id(active_climate_target_x10) / 10.0f);
lv_label_set_text(id(climate_target_label), target_buf);
lv_label_set_text(id(climate_mode_label), tile_controls::climate_mode_text(id(active_climate_mode)));
id(climate_power_refresh).execute();
lv_arc_set_range(id(climate_arc), id(active_climate_min_x10), id(active_climate_max_x10));
lv_arc_set_value(id(climate_arc), id(active_climate_target_x10));
// A portrait panel (240 x 320) keeps the same card, a little lower and with more air.
if (atoi("${DISPLAY_W}") < atoi("${DISPLAY_H}")) {
lv_obj_set_size(id(climate_arc), 196, 196);
lv_obj_set_y(id(climate_arc), 8);
lv_obj_set_y(id(climate_current_label), 48);
lv_obj_set_y(id(climate_minus_button), -62);
lv_obj_set_y(id(climate_plus_button), -62);
lv_obj_set_y(id(climate_mode_pill), -18);
} else {
lv_obj_set_size(id(climate_arc), 176, 176);
lv_obj_set_y(id(climate_arc), 18);
lv_obj_set_y(id(climate_current_label), 40);
lv_obj_set_y(id(climate_minus_button), -14);
lv_obj_set_y(id(climate_plus_button), -14);
lv_obj_set_y(id(climate_mode_pill), -15);
lv_obj_set_y(id(climate_current_label), 60);
lv_obj_set_y(id(climate_setpoint_card), 96);
lv_obj_set_y(id(climate_mode_keys), 222);
for (lv_obj_t *key : {id(climate_minus_button), id(climate_plus_button)}) {
lv_obj_set_size(key, 56, 56);
lv_obj_set_style_radius(key, 28, LV_PART_MAIN);
}
lv_obj_set_x(id(climate_minus_button), 6);
lv_obj_set_x(id(climate_plus_button), -6);
}
id(climate_card_refresh).execute();
lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN);
@@ -6657,66 +6851,24 @@ script:
if (!id(ha_state_tile10).has_state() || id(ha_state_tile10).state == "unknown" || id(ha_state_tile10).state == "unavailable")
set_lbl(id(t10_value), "Unavailable");
if (id(active_control_kind) == "climate_temperature" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) {
auto label_for_climate_mode = [](std::string mode) {
for (char &c : mode) {
if (c >= 'A' && c <= 'Z') c = (char) (c - 'A' + 'a');
}
if (mode == "off") return std::string("Off");
if (mode == "heat") return std::string("Heat");
if (mode == "cool") return std::string("Cool");
if (mode == "heat_cool") return std::string("Heat/Cool");
if (mode == "auto") return std::string("Auto");
if (mode == "dry") return std::string("Dry");
if (mode == "fan_only") return std::string("Fan");
return mode.empty() ? std::string("Auto") : mode;
};
auto sync_open_climate = [&](const std::string& entity,
float current_temperature,
float target_temperature,
float humidity,
const std::string& climate_mode) {
if (id(active_entity) != entity) return;
auto to_x10 = [](float value, int fallback) -> int {
if (isnan(value)) return fallback;
return (int) lround(value * 10.0f);
};
id(active_climate_current_x10) = to_x10(current_temperature, id(active_climate_current_x10));
id(active_climate_current) = current_temperature;
id(active_climate_humidity) = isnan(humidity) ? -1 : (int) lround(humidity);
if (!climate_mode.empty() && !id(touch_down) &&
!(id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000)) {
id(active_climate_mode) = climate_mode;
lv_label_set_text(id(climate_mode_label), label_for_climate_mode(climate_mode).c_str());
}
const bool editing = id(touch_down) ||
(id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000);
if (!editing) id(climate_edit_pending) = false;
int target_x10 = editing ? id(active_climate_target_x10) :
to_x10(target_temperature, id(active_climate_target_x10));
if (target_x10 < id(active_climate_min_x10)) target_x10 = id(active_climate_min_x10);
if (target_x10 > id(active_climate_max_x10)) target_x10 = id(active_climate_max_x10);
int step_x10 = id(active_climate_step_x10);
if (step_x10 < 5) step_x10 = 5;
target_x10 = id(active_climate_min_x10) + (int) lround((target_x10 - id(active_climate_min_x10)) / (float) step_x10) * step_x10;
if (target_x10 < id(active_climate_min_x10)) target_x10 = id(active_climate_min_x10);
if (target_x10 > id(active_climate_max_x10)) target_x10 = id(active_climate_max_x10);
id(active_climate_target_x10) = target_x10;
char current_buf[48];
if (isnan(current_temperature)) {
snprintf(current_buf, sizeof(current_buf), "Now: --");
} else if (id(active_climate_humidity) >= 0) {
snprintf(current_buf, sizeof(current_buf), "Now: %.1f° · %d%%", current_temperature, id(active_climate_humidity));
} else {
snprintf(current_buf, sizeof(current_buf), "Now: %.1f°", current_temperature);
if (!climate_mode.empty() && !editing) id(active_climate_mode) = climate_mode;
if (!editing && !isnan(target_temperature)) {
id(active_climate_target_x10) = cyd::quantize_temperature((int) lround(target_temperature * 10.0f),
id(active_climate_min_x10), id(active_climate_max_x10), std::max(5, id(active_climate_step_x10)));
id(active_climate_target_known) = true;
}
lv_label_set_text(id(climate_current_label), current_buf);
char target_buf[16];
snprintf(target_buf, sizeof(target_buf), "%.1f°", id(active_climate_target_x10) / 10.0f);
lv_label_set_text(id(climate_target_label), target_buf);
lv_arc_set_value(id(climate_arc), id(active_climate_target_x10));
id(climate_card_refresh).execute();
};
if (std::string("${TILE1_TYPE}") == "climate") sync_open_climate("${TILE1_ENTITY}", id(tile1_climate_current_temperature).state, id(tile1_climate_target_temperature).state, id(tile1_climate_humidity).state, std::string(id(tile1_climate_mode).state.c_str()));
+441 -289
View File
@@ -58,7 +58,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.38"
SCREEN_FIRMWARE_VERSION: "0.2.39"
# 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.
@@ -890,6 +890,7 @@ esphome:
light_controls::fallback.maximum = t.max_kelvin;
id(active_climate_fan_modes) = t.fan_modes; id(active_climate_fan_mode) = t.fan_mode;
id(active_climate_swing_modes) = t.swing_modes; id(active_climate_swing_mode) = t.swing_mode;
id(active_climate_action) = t.hvac_action;
id(open_value_overlay).execute(t.name, t.entity, t.domain(), "auto", t.active(), 0,
t.brightness, t.percentage, t.position, runtime_tiles::icon_for(t),
0xD5EEFC, 0x00A6ED, 0x1B1B1B, "Off", "On", t.modes,
@@ -902,22 +903,17 @@ esphome:
if (id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000) return;
id(climate_edit_pending) = false;
id(active_climate_mode) = t.state;
id(active_climate_action) = t.hvac_action;
id(active_climate_supported_modes) = t.hvac_modes;
lv_label_set_text(id(climate_mode_label), tile_controls::climate_mode_text(t.state));
id(climate_power_refresh).execute();
id(active_climate_fan_modes) = t.fan_modes; id(active_climate_fan_mode) = t.fan_mode;
id(active_climate_swing_modes) = t.swing_modes; id(active_climate_swing_mode) = t.swing_mode;
char buf[72];
if (std::isfinite(t.current)) snprintf(buf, sizeof(buf), "Now: %.1f°", t.current);
else snprintf(buf, sizeof(buf), "Now: --");
lv_label_set_text(id(climate_current_label), buf);
id(active_climate_current) = t.current;
id(active_climate_humidity) = std::isfinite(t.humidity) ? (int) lround(t.humidity) : -1;
if (std::isfinite(t.target)) {
int target = cyd::quantize_temperature(lround(t.target * 10), id(active_climate_min_x10), id(active_climate_max_x10), id(active_climate_step_x10));
id(active_climate_target_x10) = target;
snprintf(buf, sizeof(buf), "%.1f°", target / 10.0f);
lv_label_set_text(id(climate_target_label), buf);
lv_arc_set_value(id(climate_arc), target);
id(active_climate_target_x10) = cyd::quantize_temperature(lround(t.target * 10), id(active_climate_min_x10), id(active_climate_max_x10), id(active_climate_step_x10));
id(active_climate_target_known) = true;
}
id(climate_card_refresh).execute();
}
};
- lambda: |-
@@ -1096,7 +1092,16 @@ interval:
condition:
lambda: 'return id(climate_edit_pending) && !id(touch_down) && millis() - id(climate_edit_ms) >= 3000;'
then:
- lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();'
# The edit window is over: show what Home Assistant reports now. A closed card has
# nothing to show, and a flag left set would redraw every tile each second.
- lambda: |-
if (lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) { id(climate_edit_pending) = false; return; }
if (!runtime_tiles::enabled) { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); return; }
// Runtime tiles: a state that arrived during the window was held back.
id(climate_edit_pending) = false;
if (runtime_tiles::detail_update && runtime_tiles::active_index >= 0 &&
runtime_tiles::active_index < (int) runtime_tiles::model.count)
runtime_tiles::detail_update(runtime_tiles::model.tiles[runtime_tiles::active_index]);
- if:
condition:
lambda: |-
@@ -1283,6 +1288,22 @@ globals:
type: std::string
restore_value: no
# Room temperature (NAN when unknown), what the device does (hvac_action, runtime tiles only),
# and whether Home Assistant reported a setpoint at all (heat_cool ranges do not).
- id: active_climate_current
type: float
restore_value: no
initial_value: 'NAN'
- id: active_climate_action
type: std::string
restore_value: no
- id: active_climate_target_known
type: bool
restore_value: no
initial_value: 'false'
font:
- file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-500.ttf"
id: time_label
@@ -1317,6 +1338,13 @@ font:
bpp: 4
glyphs: [':','-','0','1','2','3','4','5','6','7','8','9']
# The climate card's setpoint, large enough to read across the room.
- file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-400.ttf"
id: setpoint_digits
size: 40
bpp: 4
glyphs: ['-','.','°','0','1','2','3','4','5','6','7','8','9']
- file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-700.ttf"
id: label
size: 11
@@ -1531,6 +1559,7 @@ font:
- "\U000F0596" # weather-pouring
- "\U000F067F" # weather-snowy-rainy
- "\U000F059B" # weather-sunset-down
- "\U000F01D8" # dots-horizontal
- file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf"
id: materialdesign_icons_mini
@@ -4099,13 +4128,17 @@ lvgl:
# ---------- CLIMATE DETAIL OVERLAY ----------
# Made for the small resistive panel: nothing to drag. A setpoint card with big -/+ keys
# (the number changes at once, one call goes out once the taps stop) and one row of mode
# keys like Home Assistant's climate card; fan and swing stay in the picker behind "more".
# A tap redraws only a label or a key, never a dial, so the SPI panel keeps up.
- obj:
id: climate_detail_overlay
x: 0
y: 0
width: ${DISPLAY_W}
height: ${DISPLAY_H}
bg_color: 0xF7F7F7
bg_color: 0xE7E7E7
bg_opa: COVER
hidden: true
scrollable: false
@@ -4116,27 +4149,6 @@ lvgl:
pad_bottom: 0
border_width: 0
widgets:
- obj:
id: climate_detail_bg
x: 0
y: 0
width: ${DISPLAY_W}
height: ${DISPLAY_H}
styles: style_bg_grad
scrollable: false
clickable: false
- obj:
id: climate_detail_scrim
x: 0
y: 0
width: ${DISPLAY_W}
height: ${DISPLAY_H}
bg_color: 0xFFFFFF
bg_opa: 62%
border_width: 0
clickable: false
scrollable: false
- obj:
id: climate_close_button
align: TOP_LEFT
@@ -4187,156 +4199,306 @@ lvgl:
- label:
id: climate_detail_title
align: TOP_MID
y: 21
y: 18
width: 210
long_mode: DOT
text_align: CENTER
text: "Climate"
styles: style_room
# What the device does (or its mode), the room temperature and humidity.
- label:
id: climate_current_label
align: TOP_MID
y: 42
text: "Now: --"
y: 54
width: 94%
long_mode: DOT
text_align: CENTER
text: "--"
text_font: sublabel_big
text_color: 0x1B1B1B
text_opa: 85%
text_color: 0x616161
clickable: false
- arc:
id: climate_arc
align: CENTER
y: 20
width: 184
height: 184
# Without this the arc claims an invisible extra
# click area outside the 184x184 box — near the close
# button top-left this creates overlap.
ext_click_area: 0
start_angle: 135
end_angle: 45
min_value: 50
max_value: 300
value: 200
adjustable: true
arc_width: 20
arc_rounded: true
arc_color: 0x4B4B4F
arc_opa: 80%
- obj:
id: climate_setpoint_card
align: TOP_MID
y: 80
width: 94%
height: 92
radius: 16
bg_color: 0xFFFFFF
bg_opa: COVER
border_width: 1
border_color: 0xDDDDDD
pad_all: 0
clickable: false
scrollable: false
widgets:
# Every clean tap counts (a quick triple tap is three steps); holding keeps stepping.
- obj:
id: climate_minus_button
align: LEFT_MID
x: 10
width: 72
height: 72
radius: 36
bg_color: 0xF1F1F1
bg_opa: COVER
border_width: 0
pad_all: 0
clickable: true
scrollable: false
pressed:
bg_color: 0xD9D9D9
disabled:
opa: 40%
on_short_click:
- if:
condition:
lambda: 'return cyd::touch_guard.accept_repeat(millis(), 60);'
then:
- script.execute: { id: climate_adjust_target, direction: -1, held: false }
on_long_press_repeat:
- script.execute: { id: climate_adjust_target, direction: -1, held: true }
widgets:
- label:
id: climate_minus_icon
align: CENTER
text: "\U000F0374"
text_font: materialdesign_icons
text_color: 0x1B1B1B
clickable: false
- obj:
id: climate_plus_button
align: RIGHT_MID
x: -10
width: 72
height: 72
radius: 36
bg_color: 0xF1F1F1
bg_opa: COVER
border_width: 0
pad_all: 0
clickable: true
scrollable: false
pressed:
bg_color: 0xD9D9D9
disabled:
opa: 40%
on_short_click:
- if:
condition:
lambda: 'return cyd::touch_guard.accept_repeat(millis(), 61);'
then:
- script.execute: { id: climate_adjust_target, direction: 1, held: false }
on_long_press_repeat:
- script.execute: { id: climate_adjust_target, direction: 1, held: true }
widgets:
- label:
id: climate_plus_icon
align: CENTER
text: "\U000F0415"
text_font: materialdesign_icons
text_color: 0x1B1B1B
clickable: false
- label:
id: climate_target_label
align: CENTER
y: -8
text: "--"
text_font: setpoint_digits
text_color: 0x1B1B1B
clickable: false
- label:
id: climate_set_label
align: CENTER
y: 26
text: "Target"
text_font: sublabel
text_color: 0x6B6B6B
clickable: false
# One key per mode the device supports (off is the power key); the one in use is
# filled in Home Assistant's colour for it. "More" opens the picker with fan and swing.
- obj:
id: climate_mode_keys
align: TOP_MID
y: 184
width: 94%
height: 46
bg_opa: TRANSP
border_width: 0
indicator:
arc_color: 0xFEC600
arc_opa: COVER
arc_width: 20
arc_rounded: true
knob:
bg_color: 0x111111
bg_opa: COVER
border_width: 0
shadow_width: 0
on_value:
- script.execute:
id: climate_target_preview
value: !lambda "return x;"
on_release:
- script.execute:
id: climate_target_commit
value: !lambda "return lv_arc_get_value(id(climate_arc));"
- label:
id: climate_set_label
align: CENTER
y: -16
text: "Target"
text_font: sublabel_big
text_color: 0x1B1B1B
text_opa: 68%
pad_all: 0
clickable: false
- label:
id: climate_target_label
align: CENTER
y: 20
text: "20.0°"
text_font: headline
text_color: 0x1B1B1B
clickable: false
- obj:
id: climate_minus_button
align: BOTTOM_LEFT
x: 38
y: -16
width: 48
height: 36
radius: 18
bg_color: 0xEEEEEE
bg_opa: COVER
border_width: 1
border_color: 0xFFFFFF
border_opa: 16%
clickable: true
scrollable: false
on_click:
- script.execute:
id: climate_adjust_target
direction: -1
layout:
type: FLEX
flex_flow: ROW
flex_align_main: CENTER
flex_align_cross: CENTER
pad_column: 6
widgets:
- label:
align: CENTER
text: "-"
text_font: headline
text_color: 0x1B1B1B
clickable: false
- obj:
id: climate_plus_button
align: BOTTOM_RIGHT
x: -38
y: -16
width: 48
height: 36
radius: 18
bg_color: 0xEEEEEE
bg_opa: COVER
border_width: 1
border_color: 0xFFFFFF
border_opa: 16%
clickable: true
scrollable: false
on_click:
- script.execute:
id: climate_adjust_target
direction: 1
widgets:
- label:
align: CENTER
text: "+"
text_font: headline
text_color: 0x1B1B1B
clickable: false
- obj:
id: climate_mode_pill
align: BOTTOM_MID
y: -18
width: 126
height: 34
radius: 17
bg_color: 0xF0F0F0
bg_opa: 82%
border_width: 0
clickable: true
scrollable: false
on_click:
- script.execute: open_climate_mode_picker
widgets:
- label:
id: climate_mode_label
align: CENTER
text: "Auto"
text_font: sublabel_big
text_color: 0x1B1B1B
clickable: false
- obj:
id: cmkey_auto
flex_grow: 1
height: 46
radius: 23
bg_color: 0xFFFFFF
bg_opa: COVER
border_width: 1
border_color: 0xDDDDDD
pad_all: 0
clickable: true
scrollable: false
pressed:
bg_color: 0xEEEEEE
on_click:
- script.execute: { id: set_climate_mode, mode: "auto" }
widgets:
- label:
align: CENTER
text: "\U000F1B17"
text_font: materialdesign_icons
text_color: 0x46525E
clickable: false
- obj:
id: cmkey_heat_cool
flex_grow: 1
height: 46
radius: 23
bg_color: 0xFFFFFF
bg_opa: COVER
border_width: 1
border_color: 0xDDDDDD
pad_all: 0
clickable: true
scrollable: false
pressed:
bg_color: 0xEEEEEE
on_click:
- script.execute: { id: set_climate_mode, mode: "heat_cool" }
widgets:
- label:
align: CENTER
text: "\U000F1A79"
text_font: materialdesign_icons
text_color: 0x46525E
clickable: false
- obj:
id: cmkey_heat
flex_grow: 1
height: 46
radius: 23
bg_color: 0xFFFFFF
bg_opa: COVER
border_width: 1
border_color: 0xDDDDDD
pad_all: 0
clickable: true
scrollable: false
pressed:
bg_color: 0xEEEEEE
on_click:
- script.execute: { id: set_climate_mode, mode: "heat" }
widgets:
- label:
align: CENTER
text: "\U000F0238"
text_font: materialdesign_icons
text_color: 0x46525E
clickable: false
- obj:
id: cmkey_cool
flex_grow: 1
height: 46
radius: 23
bg_color: 0xFFFFFF
bg_opa: COVER
border_width: 1
border_color: 0xDDDDDD
pad_all: 0
clickable: true
scrollable: false
pressed:
bg_color: 0xEEEEEE
on_click:
- script.execute: { id: set_climate_mode, mode: "cool" }
widgets:
- label:
align: CENTER
text: "\U000F0717"
text_font: materialdesign_icons
text_color: 0x46525E
clickable: false
- obj:
id: cmkey_dry
flex_grow: 1
height: 46
radius: 23
bg_color: 0xFFFFFF
bg_opa: COVER
border_width: 1
border_color: 0xDDDDDD
pad_all: 0
clickable: true
scrollable: false
pressed:
bg_color: 0xEEEEEE
on_click:
- script.execute: { id: set_climate_mode, mode: "dry" }
widgets:
- label:
align: CENTER
text: "\U000F058E"
text_font: materialdesign_icons
text_color: 0x46525E
clickable: false
- obj:
id: cmkey_fan_only
flex_grow: 1
height: 46
radius: 23
bg_color: 0xFFFFFF
bg_opa: COVER
border_width: 1
border_color: 0xDDDDDD
pad_all: 0
clickable: true
scrollable: false
pressed:
bg_color: 0xEEEEEE
on_click:
- script.execute: { id: set_climate_mode, mode: "fan_only" }
widgets:
- label:
align: CENTER
text: "\U000F0210"
text_font: materialdesign_icons
text_color: 0x46525E
clickable: false
- obj:
id: cmkey_more
flex_grow: 1
height: 46
radius: 23
bg_color: 0xFFFFFF
bg_opa: COVER
border_width: 1
border_color: 0xDDDDDD
pad_all: 0
clickable: true
scrollable: false
pressed:
bg_color: 0xEEEEEE
on_click:
- script.execute: open_climate_mode_picker
widgets:
- label:
align: CENTER
text: "\U000F01D8"
text_font: materialdesign_icons
text_color: 0x46525E
clickable: false
# ---------- CLIMATE MODE PICKER ----------
# Replaces cycling through modes. Cycling sends a set_hvac_mode on every
# tap, so to get from 'cool' to 'dry' a heat pump actually passes through
@@ -5421,55 +5583,106 @@ script:
id: smartdisplay_action
state: ""
- id: climate_target_preview
# A -/+ tap on the climate card: the number follows at once, the call waits for the last tap.
# Holding a key steps three times a second.
- id: climate_adjust_target
parameters:
value: int
direction: int
held: bool
then:
- lambda: |-
if (held && millis() - id(climate_edit_ms) < 300) return;
int step_x10 = std::max(5, id(active_climate_step_x10));
int next = cyd::quantize_temperature(id(active_climate_target_x10) + direction * step_x10,
id(active_climate_min_x10), id(active_climate_max_x10), step_x10);
id(climate_edit_ms) = millis();
id(climate_edit_pending) = true;
int min_x10 = id(active_climate_min_x10);
int max_x10 = id(active_climate_max_x10);
value = cyd::quantize_temperature(value, min_x10, max_x10,
id(active_climate_step_x10));
id(active_climate_target_x10) = value;
if (next == id(active_climate_target_x10) && id(active_climate_target_known)) return;
id(active_climate_target_x10) = next;
id(active_climate_target_known) = true;
id(climate_card_refresh).execute();
id(climate_target_send).execute(id(active_entity), next / 10.0f);
char buf[16];
snprintf(buf, sizeof(buf), "%.1f°", value / 10.0f);
lv_label_set_text(id(climate_target_label), buf);
lv_arc_set_value(id(climate_arc), value);
- id: climate_target_commit
# One set_temperature for a series of taps: every tap restarts the wait with the newest value.
# Entity and value travel along, so leaving the card right after a tap still sends it.
- id: climate_target_send
mode: restart
parameters:
value: int
entity: string
value: float
then:
- script.execute:
id: climate_target_preview
value: !lambda "return value;"
- delay: 700ms
- if:
condition:
lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";'
lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && !entity.empty();'
then:
- homeassistant.action:
action: climate.set_temperature
data:
entity_id: !lambda "return id(active_entity);"
temperature: !lambda "return id(active_climate_target_x10) / 10.0f;"
entity_id: !lambda "return entity;"
temperature: !lambda "return value;"
- id: climate_adjust_target
parameters:
direction: int
# Draws the climate card from the active_climate_* globals: status line, setpoint, -/+ and mode keys.
- id: climate_card_refresh
then:
- lambda: |-
int step_x10 = id(active_climate_step_x10);
if (step_x10 < 5) step_x10 = 5;
int next = id(active_climate_target_x10) + (direction * step_x10);
if (next < id(active_climate_min_x10)) next = id(active_climate_min_x10);
if (next > id(active_climate_max_x10)) next = id(active_climate_max_x10);
id(active_climate_target_x10) = next;
- script.execute:
id: climate_target_commit
value: !lambda "return id(active_climate_target_x10);"
auto lower = [](std::string v) {
for (char &c : v) { if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); }
return v;
};
std::string mode = lower(id(active_climate_mode));
bool off = mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable";
// What the device does (Heating), else its mode (Heat); then the room.
std::string status = off ? std::string("Off") : std::string(tile_controls::climate_action_text(lower(id(active_climate_action))));
if (status.empty()) status = tile_controls::climate_mode_text(mode);
char part[32];
if (std::isfinite(id(active_climate_current))) { snprintf(part, sizeof(part), " · Now %.1f°", id(active_climate_current)); status += part; }
if (id(active_climate_humidity) >= 0) { snprintf(part, sizeof(part), " · %d%%", id(active_climate_humidity)); status += part; }
lv_label_set_text(id(climate_current_label), status.c_str());
int target = id(active_climate_target_x10);
std::string value = id(active_climate_target_known)
? tile_controls::format_value(target / 10.0f, id(active_climate_step_x10) / 10.0f, "°") : std::string("--");
lv_label_set_text(id(climate_target_label), value.c_str());
lv_obj_set_style_text_color(id(climate_target_label), lv_color_hex(off ? 0x9E9E9E : 0x1B1B1B), LV_PART_MAIN);
auto enable = [](lv_obj_t *key, bool on) {
if (on) lv_obj_remove_state(key, LV_STATE_DISABLED); else lv_obj_add_state(key, LV_STATE_DISABLED);
};
enable(id(climate_minus_button), !id(active_climate_target_known) || target > id(active_climate_min_x10));
enable(id(climate_plus_button), !id(active_climate_target_known) || target < id(active_climate_max_x10));
// One key per supported mode, in Home Assistant's order. Fan and swing add "more"; with it
// five mode keys fit, and when a sixth mode is in use it takes the last place.
std::string supported = lower(id(active_climate_supported_modes));
auto has = [&](const char *m) {
return supported.find(std::string("\"") + m + "\"") != std::string::npos ||
supported.find(std::string("'") + m + "'") != std::string::npos;
};
struct Key { lv_obj_t *obj; const char *mode; };
const Key keys[] = {{id(cmkey_auto), "auto"}, {id(cmkey_heat_cool), "heat_cool"}, {id(cmkey_heat), "heat"},
{id(cmkey_cool), "cool"}, {id(cmkey_dry), "dry"}, {id(cmkey_fan_only), "fan_only"}};
bool more = !id(active_climate_fan_modes).empty() || !id(active_climate_swing_modes).empty();
const Key *visible[6];
unsigned count = 0, room = more ? 5 : 6;
for (const auto &k : keys) if (has(k.mode)) visible[count++] = &k;
if (count > room) {
for (unsigned i = room; i < count; ++i) if (mode == visible[i]->mode) visible[room - 1] = visible[i];
count = room;
}
for (const auto &k : keys) {
bool show = false;
for (unsigned i = 0; i < count; ++i) show = show || visible[i] == &k;
if (show) lv_obj_remove_flag(k.obj, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(k.obj, LV_OBJ_FLAG_HIDDEN);
bool selected = show && !off && mode == k.mode;
uint32_t color = tile_controls::mode_color(k.mode);
lv_obj_set_style_bg_color(k.obj, lv_color_hex(selected ? color : 0xFFFFFF), LV_PART_MAIN);
lv_obj_set_style_border_color(k.obj, lv_color_hex(selected ? color : 0xDDDDDD), LV_PART_MAIN);
lv_obj_set_style_text_color(lv_obj_get_child(k.obj, 0), lv_color_hex(selected ? 0xFFFFFF : 0x46525E), LV_PART_MAIN);
}
if (more) lv_obj_remove_flag(id(cmkey_more), LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(id(cmkey_more), LV_OBJ_FLAG_HIDDEN);
if (count || more) lv_obj_remove_flag(id(climate_mode_keys), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(climate_mode_keys), LV_OBJ_FLAG_HIDDEN);
id(climate_power_refresh).execute();
# Opens the mode picker. Hides modes the entity doesn't support and marks
# the current one, so you see where you stand before you pick something.
@@ -5619,8 +5832,8 @@ script:
id(active_climate_power_on) = mode == "off" || mode.empty() || mode == "unknown";
id(climate_edit_ms) = millis();
id(climate_edit_pending) = true;
if (!id(active_climate_power_on)) { id(active_climate_mode) = "off"; lv_label_set_text(id(climate_mode_label), "Off"); }
id(climate_power_refresh).execute();
if (!id(active_climate_power_on)) id(active_climate_mode) = "off";
id(climate_card_refresh).execute();
- if:
condition:
lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && id(active_climate_power_on);'
@@ -5659,9 +5872,10 @@ script:
id(climate_edit_ms) = millis();
id(climate_edit_pending) = true;
id(active_climate_mode) = mode;
// The action line would still say what the old mode did.
id(active_climate_action).clear();
id(active_climate_power_on) = mode != "off";
id(climate_power_refresh).execute();
lv_label_set_text(id(climate_mode_label), tile_controls::climate_mode_text(mode));
id(climate_card_refresh).execute();
- lvgl.widget.hide: climate_mode_overlay
- if:
condition:
@@ -6216,6 +6430,8 @@ script:
}
id(active_climate_current_x10) = to_x10(climate_current_temperature, 0);
id(active_climate_current) = climate_current_temperature;
id(active_climate_target_known) = !isnan(climate_target_temperature);
int fallback_target = isnan(climate_current_temperature) ? 200 : id(active_climate_current_x10);
id(active_climate_target_x10) = to_x10(climate_target_temperature, fallback_target);
if (id(active_climate_target_x10) < id(active_climate_min_x10)) id(active_climate_target_x10) = id(active_climate_min_x10);
@@ -6228,41 +6444,19 @@ script:
id(active_climate_supported_modes) = climate_supported_modes;
lv_label_set_text(id(climate_detail_title), title.c_str());
char current_buf[48];
if (isnan(climate_current_temperature)) {
snprintf(current_buf, sizeof(current_buf), "Now: --");
} else if (id(active_climate_humidity) >= 0) {
snprintf(current_buf, sizeof(current_buf), "Now: %.1f° · %d%%", climate_current_temperature, id(active_climate_humidity));
} else {
snprintf(current_buf, sizeof(current_buf), "Now: %.1f°", climate_current_temperature);
}
lv_label_set_text(id(climate_current_label), current_buf);
char target_buf[16];
snprintf(target_buf, sizeof(target_buf), "%.1f°", id(active_climate_target_x10) / 10.0f);
lv_label_set_text(id(climate_target_label), target_buf);
lv_label_set_text(id(climate_mode_label), tile_controls::climate_mode_text(id(active_climate_mode)));
id(climate_power_refresh).execute();
lv_arc_set_range(id(climate_arc), id(active_climate_min_x10), id(active_climate_max_x10));
lv_arc_set_value(id(climate_arc), id(active_climate_target_x10));
// A portrait panel (240 x 320) keeps the same card, a little lower and with more air.
if (atoi("${DISPLAY_W}") < atoi("${DISPLAY_H}")) {
lv_obj_set_size(id(climate_arc), 196, 196);
lv_obj_set_y(id(climate_arc), 8);
lv_obj_set_y(id(climate_current_label), 48);
lv_obj_set_y(id(climate_minus_button), -62);
lv_obj_set_y(id(climate_plus_button), -62);
lv_obj_set_y(id(climate_mode_pill), -18);
} else {
lv_obj_set_size(id(climate_arc), 176, 176);
lv_obj_set_y(id(climate_arc), 18);
lv_obj_set_y(id(climate_current_label), 40);
lv_obj_set_y(id(climate_minus_button), -14);
lv_obj_set_y(id(climate_plus_button), -14);
lv_obj_set_y(id(climate_mode_pill), -15);
lv_obj_set_y(id(climate_current_label), 60);
lv_obj_set_y(id(climate_setpoint_card), 96);
lv_obj_set_y(id(climate_mode_keys), 222);
for (lv_obj_t *key : {id(climate_minus_button), id(climate_plus_button)}) {
lv_obj_set_size(key, 56, 56);
lv_obj_set_style_radius(key, 28, LV_PART_MAIN);
}
lv_obj_set_x(id(climate_minus_button), 6);
lv_obj_set_x(id(climate_plus_button), -6);
}
id(climate_card_refresh).execute();
lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN);
@@ -6588,66 +6782,24 @@ script:
if (!id(ha_state_tile10).has_state() || id(ha_state_tile10).state == "unknown" || id(ha_state_tile10).state == "unavailable")
set_lbl(id(t10_value), "Unavailable");
if (id(active_control_kind) == "climate_temperature" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) {
auto label_for_climate_mode = [](std::string mode) {
for (char &c : mode) {
if (c >= 'A' && c <= 'Z') c = (char) (c - 'A' + 'a');
}
if (mode == "off") return std::string("Off");
if (mode == "heat") return std::string("Heat");
if (mode == "cool") return std::string("Cool");
if (mode == "heat_cool") return std::string("Heat/Cool");
if (mode == "auto") return std::string("Auto");
if (mode == "dry") return std::string("Dry");
if (mode == "fan_only") return std::string("Fan");
return mode.empty() ? std::string("Auto") : mode;
};
auto sync_open_climate = [&](const std::string& entity,
float current_temperature,
float target_temperature,
float humidity,
const std::string& climate_mode) {
if (id(active_entity) != entity) return;
auto to_x10 = [](float value, int fallback) -> int {
if (isnan(value)) return fallback;
return (int) lround(value * 10.0f);
};
id(active_climate_current_x10) = to_x10(current_temperature, id(active_climate_current_x10));
id(active_climate_current) = current_temperature;
id(active_climate_humidity) = isnan(humidity) ? -1 : (int) lround(humidity);
if (!climate_mode.empty() && !id(touch_down) &&
!(id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000)) {
id(active_climate_mode) = climate_mode;
lv_label_set_text(id(climate_mode_label), label_for_climate_mode(climate_mode).c_str());
}
const bool editing = id(touch_down) ||
(id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000);
if (!editing) id(climate_edit_pending) = false;
int target_x10 = editing ? id(active_climate_target_x10) :
to_x10(target_temperature, id(active_climate_target_x10));
if (target_x10 < id(active_climate_min_x10)) target_x10 = id(active_climate_min_x10);
if (target_x10 > id(active_climate_max_x10)) target_x10 = id(active_climate_max_x10);
int step_x10 = id(active_climate_step_x10);
if (step_x10 < 5) step_x10 = 5;
target_x10 = id(active_climate_min_x10) + (int) lround((target_x10 - id(active_climate_min_x10)) / (float) step_x10) * step_x10;
if (target_x10 < id(active_climate_min_x10)) target_x10 = id(active_climate_min_x10);
if (target_x10 > id(active_climate_max_x10)) target_x10 = id(active_climate_max_x10);
id(active_climate_target_x10) = target_x10;
char current_buf[48];
if (isnan(current_temperature)) {
snprintf(current_buf, sizeof(current_buf), "Now: --");
} else if (id(active_climate_humidity) >= 0) {
snprintf(current_buf, sizeof(current_buf), "Now: %.1f° · %d%%", current_temperature, id(active_climate_humidity));
} else {
snprintf(current_buf, sizeof(current_buf), "Now: %.1f°", current_temperature);
if (!climate_mode.empty() && !editing) id(active_climate_mode) = climate_mode;
if (!editing && !isnan(target_temperature)) {
id(active_climate_target_x10) = cyd::quantize_temperature((int) lround(target_temperature * 10.0f),
id(active_climate_min_x10), id(active_climate_max_x10), std::max(5, id(active_climate_step_x10)));
id(active_climate_target_known) = true;
}
lv_label_set_text(id(climate_current_label), current_buf);
char target_buf[16];
snprintf(target_buf, sizeof(target_buf), "%.1f°", id(active_climate_target_x10) / 10.0f);
lv_label_set_text(id(climate_target_label), target_buf);
lv_arc_set_value(id(climate_arc), id(active_climate_target_x10));
id(climate_card_refresh).execute();
};
if (std::string("${TILE1_TYPE}") == "climate") sync_open_climate("${TILE1_ENTITY}", id(tile1_climate_current_temperature).state, id(tile1_climate_target_temperature).state, id(tile1_climate_humidity).state, std::string(id(tile1_climate_mode).state.c_str()));
+12 -2
View File
@@ -2,7 +2,7 @@
# Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911.
# Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.38"
SCREEN_FIRMWARE_VERSION: "0.2.39"
# 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"
@@ -902,7 +902,16 @@ interval:
condition:
lambda: 'return id(climate_edit_pending) && !id(touch_down) && millis() - id(climate_edit_ms) >= 3000;'
then:
- lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();'
# The edit window is over: show what Home Assistant reports now. A closed card has
# nothing to show, and a flag left set would redraw every tile each second.
- lambda: |-
if (lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) { id(climate_edit_pending) = false; return; }
if (!runtime_tiles::enabled) { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); return; }
// Runtime tiles: a state that arrived during the window was held back.
id(climate_edit_pending) = false;
if (runtime_tiles::detail_update && runtime_tiles::active_index >= 0 &&
runtime_tiles::active_index < (int) runtime_tiles::model.count)
runtime_tiles::detail_update(runtime_tiles::model.tiles[runtime_tiles::active_index]);
- if:
condition:
lambda: |-
@@ -1342,6 +1351,7 @@ font:
- "\U000F0596" # weather-pouring
- "\U000F067F" # weather-snowy-rainy
- "\U000F059B" # weather-sunset-down
- "\U000F01D8" # dots-horizontal
- file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf"
id: materialdesign_icons_mini
+11
View File
@@ -1,3 +1,14 @@
## 0.2.46 (firmware 0.2.39)
Choose between vacuuming, mopping or both on the vacuum card, and a climate card made for the small CYD.
- **Vacuum, vacuum and mop, or mop only.** Robots that offer a cleaning mode in Home Assistant, such as a Roborock (HA 2026.8 or newer), get a row with Vacuum, Vac & mop and Mop on the vacuum card of both screens. ESP Screens finds the mode and the mop intensity on the robot's own device, whatever language your entity IDs are in; you don't have to add them as tiles. Custom (the per-room settings from the robot's app) shows up only while the robot uses it.
- **Only what the mode uses.** Below the mode sit suction power and water as rows of choices: vacuum only shows suction, mop only shows water, both show both. A tap shows your choice at once and the card waits for Home Assistant. Speeds that belong to a mode, such as suction off for mopping, move into the mode row, and Max+ now fits next to Max.
- **A new vacuum card.** The robot, its state and battery on top, with a green bolt while it charges and the room it is in while it cleans; then Start cleaning (or Pause, Resume) and Dock; then how it cleans. On the Guition the cleaning settings share one white card, and Locate became a round button beside the robot. Since HA 2026.8 a vacuum no longer reports its battery itself, so ESP Screens now reads the battery sensor of the robot.
- **Climate card for the CYD.** No more dial to drag on the small resistive screen: the target temperature sits big between two large − / + keys. The number follows every tap at once and one call goes out when you stop tapping; holding a key keeps stepping. One row of keys picks the mode (in Home Assistant's order and colours), and ··· opens fan and swing. A tap only redraws the number or a key, so the card responds right away. The Guition keeps its dial.
- **Less redrawing after a climate change.** A card closed within three seconds of a change left a flag behind that redrew every tile each second; that flag is now cleared.
- Firmware 0.2.39 for both boards; the app offers the update. Update the app and the screens together: with firmware 0.2.39 and an older app the vacuum card falls back to suction only.
## 0.2.45 (firmware 0.2.38)
One alert for every screen, a Claude skill, and a Settings page that clears up the main page.
+107 -3
View File
@@ -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.38'
FIRMWARE_VERSION = '0.2.39'
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())
# Attributes whose boolean value the screen needs; every other bool stays behind.
BOOL_ATTRS = frozenset(['is_volume_muted'])
@@ -369,10 +369,114 @@ def epoch(value):
moment = moment.replace(tzinfo=timezone.utc)
return int(moment.timestamp())
def extras(tile, states, forecast=None, tz=None, hourly=None, now=None):
"""Small, pre-computed values the firmware cannot derive itself (time zones, forecasts)."""
# Vacuum cards (app 0.2.46, firmware 0.2.39+): how a robot cleans is often a select on its own device,
# not an attribute. Roborock has "Cleaning mode" (vacuum, vac_and_mop, mop, custom, smart_mode) and
# "Mop intensity" since HA 2026.8; Ecovacs calls its mode work_mode. They are found by translation key
# or by the end of their entity id. HA 2026.8 also dropped battery_level from vacuum entities, so the
# battery comes from the device's battery sensor.
VACUUM_MODE_KEYS = ('cleaning_mode', 'work_mode', 'clean_mode')
VACUUM_WATER_KEYS = ('mop_intensity', 'water_flow', 'water_amount', 'water_flow_level', 'water_volume', 'mop_water_level')
# Short chip labels; anything else shows its own words.
VACUUM_LABELS = {
'vacuum': 'Vacuum', 'sweeping': 'Vacuum', 'vac_and_mop': 'Vac & mop', 'vacuum_and_mop': 'Vac & mop',
'sweeping_and_mopping': 'Vac & mop', 'mop_after_vacuum': 'Vac, then mop', 'mopping_after_sweeping': 'Vac, then mop',
'mop': 'Mop', 'mopping': 'Mop', 'custom': 'Custom', 'smart_mode': 'Smart',
'off': 'Off', 'min': 'Min', 'slight': 'Slight', 'low': 'Low', 'mild': 'Mild', 'medium': 'Medium', 'moderate': 'Moderate',
'standard': 'Standard', 'high': 'High', 'intense': 'Intense', 'extreme': 'Extreme', 'ultrahigh': 'Ultra high',
'quiet': 'Quiet', 'silent': 'Silent', 'gentle': 'Gentle', 'balanced': 'Normal', 'turbo': 'Turbo', 'strong': 'Strong',
'max': 'Max', 'max_plus': 'Max+', 'auto': 'Auto',
}
# What a cleaning mode does, one letter per option for the firmware: v vacuum only, m mop only, b both,
# a automatic (the robot or its app picks suction and water). Unknown modes count as both.
VACUUM_ROLES = {'vacuum': 'v', 'sweeping': 'v', 'mop': 'm', 'mopping': 'm', 'custom': 'a', 'smart_mode': 'a'}
# With a cleaning mode select these speeds and water levels belong to a mode: suction off is mop only,
# water off is vacuum only, and custom or smart settings are the Custom and Smart modes.
MODE_COVERED = frozenset(('off', 'off_raise_main_brush', 'custom', 'custom_water_flow', 'smart_mode', 'vac_followed_by_mop'))
VACUUM_CHOICES = 6
def vacuum_label(value):
return short(VACUUM_LABELS.get(value) or str(value).replace('_', ' ').capitalize(), 24)
def vacuum_related(entity, device, states):
"""Entities on the vacuum's device that its card reads, by role: the 'mode' and 'water' selects, the
'battery' and 'room' sensors and the 'charging' binary sensor; absent roles are left out.
`device` holds the registry entries of the vacuum's device. Entity ids follow Home Assistant's
language (select.s8_intensiteit_van_dweilen), so translation keys come first."""
found = {}
for item in device or ():
eid = item.get('entity_id') or ''
if eid == entity or item.get('disabled_by'):
continue
key = item.get('translation_key') or ''
attrs = states.get(eid, {}).get('attributes', {})
role = None
if eid.startswith('select.'):
if key in VACUUM_MODE_KEYS or eid.endswith(tuple('_' + k for k in VACUUM_MODE_KEYS)):
role = 'mode'
elif key in VACUUM_WATER_KEYS or eid.endswith(tuple('_' + k for k in VACUUM_WATER_KEYS)):
role = 'water'
elif eid.startswith('sensor.'):
if attrs.get('device_class') == 'battery':
role = 'battery'
elif key == 'current_room' or eid.endswith('_current_room'):
role = 'room'
elif eid.startswith('binary_sensor.') and attrs.get('device_class') == 'battery_charging':
role = 'charging'
if role and role not in found:
found[role] = eid
return found
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
(`chg`) and the room the robot is in (`room`)."""
entity = tile['entity']
attrs = states.get(entity, {}).get('attributes', {})
related = vacuum_related(entity, device, states)
result = {}
def row(values):
values = [short(v, 48) for v in values][:VACUUM_CHOICES]
return {'o': values, 'l': [vacuum_label(v) for v in values]} if values else None
def select(eid, keep):
state = states.get(eid) or {}
options = state.get('attributes', {}).get('options')
current = state.get('state') if isinstance(state.get('state'), str) else ''
found = row([o for o in options if isinstance(o, str) and keep(o, current)]) if isinstance(options, list) else None
return {'e': eid, 's': short(current, 48), **found} if found else None
# Custom runs the per-room settings made in the robot's app: listed only while it is in use.
mode = select(related['mode'], lambda o, current: o != 'custom' or o == current) if 'mode' in related else None
covered = MODE_COVERED if mode else frozenset()
if mode:
mode['r'] = ''.join(VACUUM_ROLES.get(v, 'b') for v in mode['o'])
result['mode'] = mode
water = select(related['water'], lambda o, current: o not in covered) if 'water' in related else None
if water:
result['water'] = water
speeds = attrs.get('fan_speed_list')
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
room = states.get(related.get('room'), {}).get('state')
if isinstance(room, str) and room not in ('', 'unknown', 'unavailable', 'none'):
result['room'] = short(room, 32)
return result or None
def extras(tile, states, forecast=None, tz=None, hourly=None, now=None, device=None):
"""Small, pre-computed values the firmware cannot derive itself (time zones, forecasts, a vacuum's device)."""
domain = tile['entity'].split('.')[0]
attrs = states.get(tile['entity'], {}).get('attributes', {})
if domain == 'vacuum':
return vacuum_extras(tile, states, device)
if domain == 'weather' and (forecast or hourly):
result = {}
days = []
+24 -2
View File
@@ -429,6 +429,25 @@ class Manager:
self._registry_source, self._registry_index = registry, {item['entity_id']: item for item in registry}
return self._registry_index
def device_entries(self, entity):
"""Registry entries on the device of `entity` (itself included); the index follows the registry object."""
registry = getattr(self.ha, 'registry', [])
if getattr(self, '_devices_source', None) is not registry:
by_device = {}
for item in registry:
if item.get('device_id'):
by_device.setdefault(item['device_id'], []).append(item)
self._devices_source, self._by_device = registry, by_device
device = self.registry_index().get(entity, {}).get('device_id')
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())
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))
@@ -518,6 +537,7 @@ class Manager:
watched = {tile['entity'] for layout in self.layouts.values() for tile in layout['tiles']}
watched |= {item['entity'] for layout in self.layouts.values() for item in header_items(layout) if item['type'] == 'entity'}
watched |= {item['entity_id'] for item in self.screen_registry()}
watched |= {eid for layout in self.layouts.values() for tile in layout['tiles'] for eid in self.related_entities(tile)}
self._watched_key, self._watched = key, watched
return set(self._watched)
@@ -541,7 +561,9 @@ class Manager:
forecast=await self.cached(self.forecasts, tile['entity'], FORECAST_SECONDS, lambda: self.ha.forecast(tile['entity']))
# Hourly forecasts feed the weather card's next-hours strip (0.2.23+); refreshed every half hour.
hourly=await self.cached(self.forecasts, (tile['entity'],'hourly'), FORECAST_SECONDS, lambda: self.ha.forecast(tile['entity'],'hourly'))
message=state_message(index,tile,self.ha.states,extras(tile,self.ha.states,forecast,getattr(self.ha,'time_zone',None),hourly))
# 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
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)
entry=self.histories.get((tile['entity'],hours))
@@ -593,7 +615,7 @@ class Manager:
header_msg = previous['header']
states = []
for i, tile in enumerate(tiles):
reuse = not full and i < len(previous['states']) and tile['entity'] not in dirty
reuse = not full and i < len(previous['states']) and tile['entity'] not in dirty and dirty.isdisjoint(self.related_entities(tile))
if reuse and tile['entity'].startswith('weather.') and self.forecast_due(tile['entity']):
reuse = False
states.append(previous['states'][i] if reuse else await self.tile_message(i, tile))
Binary file not shown.
+2
View File
@@ -216,6 +216,8 @@ FIXED = (
('weather-pouring', 'F0596'),
('weather-snowy-rainy', 'F067F'),
('weather-sunset-down', 'F059B'),
# "More" key on the CYD climate card: fan and swing (firmware 0.2.39+).
('dots-horizontal', 'F01D8'),
)
# Pickable icons by name, and every glyph the firmware fonts contain.
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.45"
version: "0.2.46"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen
+38
View File
@@ -100,5 +100,43 @@ int main() {
Tile lamp = make("light.lamp", "on"); lamp.controls = "brightness";
assert(is_slider(panel_kind(lamp)) && !is_key_row(panel_kind(lamp)));
assert(is_key_row("playback") && is_key_row("mode") && !is_key_row("setpoint"));
// Climate mode colours follow Home Assistant; anything else is grey.
assert(mode_color("heat") == 0xFF6F22 && mode_color("cool") == 0x2196F3 && mode_color("off") == 0x9E9E9E);
// Vacuum rows: the cleaning mode decides which of suction and water the card shows.
Tile pippa = make("vacuum.s8", "docked", 30524);
auto row = [](char kind, const char *entity, const char *current, std::vector<std::string> values, const char *roles = "") {
runtime_tiles::Choice c; c.kind = kind; c.entity = entity; c.current = current; c.values = values; c.labels = values; c.roles = roles; return c;
};
pippa.choices = {row('m', "select.woonkamer_s8_schoonmaakmodus", "vac_and_mop", {"vacuum", "vac_and_mop", "mop", "custom"}, "vbma"),
row('w', "select.s8_intensiteit_van_dweilen", "intense", {"mild", "standard", "intense"}),
row('s', "", "", {"quiet", "balanced", "turbo", "max", "max_plus"})};
pippa.fan_speed = "max";
settle_suction(pippa);
assert(pippa.choice('s')->current == "max" && pippa.choices.size() == 3);
auto rows = vacuum_rows(pippa, 0);
assert(rows.suction && rows.water && vacuum_role(pippa, 0) == 'b');
pippa.choice('m')->current = "mop"; rows = vacuum_rows(pippa, 0);
assert(!rows.suction && rows.water);
pippa.choice('m')->current = "vacuum"; rows = vacuum_rows(pippa, 0);
assert(rows.suction && !rows.water);
pippa.choice('m')->current = "custom"; rows = vacuum_rows(pippa, 0);
assert(!rows.suction && !rows.water && vacuum_role(pippa, 0) == 'a');
pippa.choice('m')->current = "something_new"; assert(vacuum_role(pippa, 0) == 'b');
// A tapped chip shows while Home Assistant is busy, and the old value once it gave up.
pippa.choice('m')->current = "vac_and_mop"; pippa.choice('m')->sent = "mop";
pippa.begin(1000);
assert(shown_value(pippa, *pippa.choice('m'), 1500) == "mop" && vacuum_rows(pippa, 1500).water && !vacuum_rows(pippa, 1500).suction);
assert(shown_value(pippa, *pippa.choice('m'), 9000) == "vac_and_mop");
assert(choice_action(pippa, 'm', "mop").service == "select.select_option" && choice_action(pippa, 'm', "mop").key == "option");
assert(choice_action(pippa, 's', "turbo").service == "vacuum.set_fan_speed" && choice_action(pippa, 's', "turbo").value == "turbo");
// An older manager sends no suction row: the vacuum's own four speeds, with the card's names.
Tile old_robot2 = make("vacuum.old", "docked", 30524);
old_robot2.fan_speed_count = 2; old_robot2.fan_speeds[0] = "quiet"; old_robot2.fan_speeds[1] = "balanced"; old_robot2.fan_speed = "balanced";
settle_suction(old_robot2);
assert(old_robot2.choice('s') && old_robot2.choice('s')->labels[1] == "Normal" && old_robot2.choice('s')->current == "balanced");
rows = vacuum_rows(old_robot2, 0);
assert(rows.suction && !rows.water && !choice_action(old_robot2, 'm', "mop").valid());
return 0;
}
+137
View File
@@ -0,0 +1,137 @@
"""Vacuum card rows (app 0.2.46 / firmware 0.2.39): the cleaning mode and water selects of the robot's
device, the suction speeds to offer and the battery sensor, as Home Assistant 2026.9 reports a Roborock."""
import importlib.util
from pathlib import Path
import sys
import tempfile
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app'))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from core import encode, extras, state_message, vacuum_related
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
if HAS_AIOHTTP:
from server import Manager
from test_scaling import fake_ha
VACUUM = 'vacuum.s8'
def roborock():
"""(states, device registry entries) of a Roborock S8 on HA 2026.9.1 in Dutch, as read from a real home:
entity ids in the UI language, no battery attribute on the vacuum, the mop settings as selects."""
states = {
VACUUM: {'state': 'docked', 'attributes': {'friendly_name': 'Pippa', 'fan_speed': 'max', 'supported_features': 30524,
'fan_speed_list': ['quiet', 'balanced', 'turbo', 'max', 'max_plus', 'off', 'custom']}},
'select.woonkamer_s8_schoonmaakmodus': {'state': 'vac_and_mop', 'attributes': {'options': ['vacuum', 'vac_and_mop', 'mop', 'custom']}},
'select.s8_intensiteit_van_dweilen': {'state': 'intense', 'attributes': {'options': ['off', 'mild', 'standard', 'intense', 'custom']}},
'select.s8_mop_mode': {'state': 'standard', 'attributes': {'options': ['standard', 'deep', 'deep_plus', 'fast', 'custom']}},
'select.s8_selected_map': {'state': 'Thuis', 'attributes': {'options': ['Thuis']}},
'sensor.s8_batterij': {'state': '78', 'attributes': {'device_class': 'battery', 'unit_of_measurement': '%'}},
'binary_sensor.s8_opladen': {'state': 'on', 'attributes': {'device_class': 'battery_charging'}},
'sensor.s8_huidige_kamer': {'state': 'Living room', 'attributes': {'device_class': 'enum', 'options': ['Corridor', 'Kitchen', 'Living room', 'Bedroom']}},
'sensor.s8_status': {'state': 'charging', 'attributes': {'device_class': 'enum'}},
'button.s8_vac_followed_by_mop': {'state': 'unknown', 'attributes': {}},
}
keys = {VACUUM: 'roborock', 'select.woonkamer_s8_schoonmaakmodus': 'cleaning_mode', 'select.s8_intensiteit_van_dweilen': 'mop_intensity',
'select.s8_mop_mode': 'mop_mode', 'select.s8_selected_map': 'selected_map', 'sensor.s8_batterij': None,
'binary_sensor.s8_opladen': None, 'sensor.s8_huidige_kamer': 'current_room', 'sensor.s8_status': 'status',
'button.s8_vac_followed_by_mop': None}
device = [{'entity_id': eid, 'platform': 'roborock', 'device_id': 'robot', 'translation_key': key} for eid, key in keys.items()]
return states, device
class VacuumExtras(unittest.TestCase):
def test_roborock_gets_mode_water_suction_battery_charging_and_room(self):
states, device = roborock()
extra = extras({'entity': VACUUM}, states, device=device)
self.assertEqual(extra['mode'], {'e': 'select.woonkamer_s8_schoonmaakmodus', 's': 'vac_and_mop', 'o': ['vacuum', 'vac_and_mop', 'mop'],
'l': ['Vacuum', 'Vac & mop', 'Mop'], 'r': 'vbm'}, 'Custom runs app settings: only listed while in use')
self.assertEqual(extra['water'], {'e': 'select.s8_intensiteit_van_dweilen', 's': 'intense', 'o': ['mild', 'standard', 'intense'],
'l': ['Mild', 'Standard', 'Intense']}, 'off and custom belong to the cleaning modes')
self.assertEqual(extra['fan'], {'o': ['quiet', 'balanced', 'turbo', 'max', 'max_plus'], 'l': ['Quiet', 'Normal', 'Turbo', 'Max', 'Max+']})
self.assertEqual((extra['bat'], extra['chg'], extra['room']), (78, 1, 'Living room'))
message = state_message(0, {'entity': VACUUM, 'name': 'Stofzuiger'}, states, extra)
self.assertEqual(message['a']['fan_speed_list'], ['quiet', 'balanced', 'turbo', 'max'], 'older firmware keeps its four speeds')
self.assertLess(len(encode(message)), 1024)
# Custom in use: listed, so the card shows what the robot does.
states['select.woonkamer_s8_schoonmaakmodus']['state'] = 'custom'
states['binary_sensor.s8_opladen']['state'] = 'off'
extra = extras({'entity': VACUUM}, states, device=device)
self.assertEqual((extra['mode']['o'], extra['mode']['r']), (['vacuum', 'vac_and_mop', 'mop', 'custom'], 'vbma'))
self.assertNotIn('chg', extra)
def test_without_a_mode_select_water_and_suction_keep_off(self):
states, device = roborock()
device = [d for d in device if d['translation_key'] != 'cleaning_mode']
extra = extras({'entity': VACUUM}, states, device=device)
self.assertNotIn('mode', extra)
self.assertEqual(extra['water']['o'], ['off', 'mild', 'standard', 'intense', 'custom'], 'water off is the only way to vacuum only')
self.assertEqual(extra['fan']['o'], ['quiet', 'balanced', 'turbo', 'max', 'max_plus', 'off'], 'suction off is the only way to mop only')
def test_a_plain_vacuum_gets_only_its_labelled_speeds(self):
states = {'vacuum.old': {'state': 'docked', 'attributes': {'battery_level': 64, 'fan_speed_list': ['silent', 'standard', 'strong']}}}
self.assertEqual(extras({'entity': 'vacuum.old'}, states), {'fan': {'o': ['silent', 'standard', 'strong'], 'l': ['Silent', 'Standard', 'Strong']}},
'no device, no rows; a battery attribute needs no sensor')
states['vacuum.old']['attributes'].pop('fan_speed_list')
self.assertIsNone(extras({'entity': 'vacuum.old'}, states))
def test_entity_id_endings_disabled_entities_and_unknown_modes(self):
states = {
'vacuum.deebot': {'state': 'cleaning', 'attributes': {}},
'select.deebot_work_mode': {'state': 'mop_after_vacuum', 'attributes': {'options': ['vacuum', 'mop', 'vacuum_and_mop', 'mop_after_vacuum', 'scrubbing']}},
'select.deebot_water_flow_level': {'state': 'high', 'attributes': {'options': ['low', 'medium', 'high', 'ultrahigh']}},
'select.old_cleaning_mode': {'state': 'x', 'attributes': {'options': ['x']}},
'sensor.deebot_battery': {'state': 'unavailable', 'attributes': {'device_class': 'battery'}},
}
device = [{'entity_id': 'vacuum.deebot', 'device_id': 'd'},
{'entity_id': 'select.old_cleaning_mode', 'device_id': 'd', 'translation_key': 'cleaning_mode', 'disabled_by': 'user'},
{'entity_id': 'select.deebot_work_mode', 'device_id': 'd'},
{'entity_id': 'select.deebot_water_flow_level', 'device_id': 'd'},
{'entity_id': 'sensor.deebot_battery', 'device_id': 'd'}]
self.assertEqual(vacuum_related('vacuum.deebot', device, states),
{'mode': 'select.deebot_work_mode', 'water': 'select.deebot_water_flow_level', 'battery': 'sensor.deebot_battery'})
extra = extras({'entity': 'vacuum.deebot'}, states, device=device)
self.assertEqual(extra['mode']['l'], ['Vacuum', 'Mop', 'Vac & mop', 'Vac, then mop', 'Scrubbing'])
self.assertEqual(extra['mode']['r'], 'vmbbb')
self.assertEqual(extra['water']['l'], ['Low', 'Medium', 'High', 'Ultra high'])
self.assertNotIn('bat', extra, 'an unavailable battery sends nothing')
def test_options_are_bounded(self):
states, device = roborock()
states['select.woonkamer_s8_schoonmaakmodus']['attributes']['options'] = [f'mode_{i}' for i in range(12)]
extra = extras({'entity': VACUUM}, states, device=device)
self.assertEqual(len(extra['mode']['o']), 6)
self.assertEqual(extra['mode']['l'][0], 'Mode 0')
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class VacuumSync(unittest.IsolatedAsyncioTestCase):
async def test_a_select_change_resends_the_vacuum_card(self):
with tempfile.TemporaryDirectory() as tmp:
ha = fake_ha()
states, device = roborock()
ha.states.update(states)
ha.registry = ha.registry + device
m = Manager(ha, Path(tmp) / 'screens.json')
m.save('text.screen', {'title': 'Office 1', 'tiles': [{'entity': VACUUM, 'name': ''}, {'entity': 'light.a', 'name': ''}]})
layout = m.layouts['text.screen']
watched = m.watched_entities()
self.assertTrue({'select.woonkamer_s8_schoonmaakmodus', 'select.s8_intensiteit_van_dweilen', 'sensor.s8_batterij', 'binary_sensor.s8_opladen', 'sensor.s8_huidige_kamer'} <= watched)
self.assertNotIn('select.s8_mop_mode', watched)
await m.sync_one('text.screen', layout, dirty=set())
sent = [msg for _, msg, _ in ha.messages if msg.get('entity') == VACUUM]
self.assertEqual(sent[0]['x']['mode']['s'], 'vac_and_mop')
ha.messages.clear()
# Mop only in the Roborock app: the select changes, the vacuum entity itself does not.
ha.states['select.woonkamer_s8_schoonmaakmodus']['state'] = 'mop'
self.assertTrue(await m.sync_one('text.screen', layout, dirty={'select.woonkamer_s8_schoonmaakmodus'}))
self.assertEqual([(msg['op'], msg.get('entity')) for _, msg, _ in ha.messages], [('state', VACUUM)])
self.assertEqual(ha.messages[0][1]['x']['mode']['s'], 'mop')
ha.messages.clear()
self.assertFalse(await m.sync_one('text.screen', layout, dirty={'select.s8_mop_mode'}), 'the route is not on the card')
if __name__ == '__main__':
unittest.main()