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:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 = []
|
||||
|
||||
@@ -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.
@@ -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,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user