From aa2137f3f856ef859ffed186ac1863762a745c13 Mon Sep 17 00:00:00 2001 From: Max Gramser Date: Tue, 15 Sep 2026 15:22:28 +0200 Subject: [PATCH] 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 --- README.md | 11 +- TILE_CONFIGURATION.md | 17 +- components/smart_display/runtime_model.h | 17 + components/smart_display/runtime_tiles.h | 308 +++++++-- components/smart_display/tile_controls.h | 63 ++ components/smart_display/tile_icon_names.h | 1 + docs/RELEASING.md | 35 + docs/TEST_RESULTS_0246.md | 64 ++ docs/images/cyd-climate.png | Bin 0 -> 14607 bytes docs/images/cyd-vacuum.png | Bin 0 -> 18891 bytes docs/images/guition-vacuum.png | Bin 31548 -> 37230 bytes guition-4848s040.yaml | 14 +- home-like-2432s028.yaml | 730 +++++++++++++-------- packages/cyd.yaml | 730 +++++++++++++-------- packages/guition.yaml | 14 +- screen_manager/CHANGELOG.md | 11 + screen_manager/app/core.py | 110 +++- screen_manager/app/server.py | 26 +- screen_manager/app/static/tile-icons.woff | Bin 15484 -> 15516 bytes screen_manager/app/tile_icons.py | 2 + screen_manager/config.yaml | 2 +- tests/test_tile_controls.cpp | 38 ++ tests/test_vacuum_card.py | 137 ++++ 23 files changed, 1675 insertions(+), 655 deletions(-) create mode 100644 docs/TEST_RESULTS_0246.md create mode 100644 docs/images/cyd-climate.png create mode 100644 docs/images/cyd-vacuum.png create mode 100644 tests/test_vacuum_card.py diff --git a/README.md b/README.md index 88f41bd..55b7711 100644 --- a/README.md +++ b/README.md @@ -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**. Light control: color, color temperature and brightness

- Vacuum card: status, battery, start, return to dock, find the robot and suction power + Vacuum card: docked and charging, start and dock, the cleaning mode vacuum, vac and mop or mop, suction and water Sensor history of the last 24 hours Curtains with open, stop and close, a kitchen timer counting down, a scene and the sun path

+

+ The vacuum card on the CYD: state, battery and charging, clean and dock, the cleaning mode, suction and water + The climate card on the CYD: the target temperature with big minus and plus keys and one row of mode keys +

Features depend on the capabilities Home Assistant reports for an entity. The app must keep running to keep the screens supplied with current data. diff --git a/TILE_CONFIGURATION.md b/TILE_CONFIGURATION.md index c0f99da..5098ed5 100644 --- a/TILE_CONFIGURATION.md +++ b/TILE_CONFIGURATION.md @@ -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" diff --git a/components/smart_display/runtime_model.h b/components/smart_display/runtime_model.h index 2271542..1cfb64b 100644 --- a/components/smart_display/runtime_model.h +++ b/components/smart_display/runtime_model.h @@ -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 values, labels; +}; struct Tile { std::string entity, name, state, unit, modes, hvac_modes, fan_modes, swing_modes, fan_mode, swing_mode; std::array 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 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. diff --git a/components/smart_display/runtime_tiles.h b/components/smart_display/runtime_tiles.h index 0ed4179..ee9a336 100644 --- a/components/smart_display/runtime_tiles.h +++ b/components/smart_display/runtime_tiles.h @@ -278,6 +278,8 @@ inline std::string receive(const std::string &payload) { auto a = root["a"].as(); // 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> 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()) 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()) { if (row.values.size() == 6) break; row.values.push_back(string(value, 48)); } + if (c["l"].is()) for (JsonVariant label : c["l"].as()) { + 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() && extra["chg"].as() == 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 rows[]={{10,'s'},{50,'m'},{60,'w'}}; + for(const auto &[first,kind]:rows) + if(cmd>=first && cmdvalues.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)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;i24) + 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;ivalues.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 {}; diff --git a/components/smart_display/tile_icon_names.h b/components/smart_display/tile_icon_names.h index 4892c25..e8931e3 100644 --- a/components/smart_display/tile_icon_names.h +++ b/components/smart_display/tile_icon_names.h @@ -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. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index a8f29f4..2e19f76 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -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. diff --git a/docs/TEST_RESULTS_0246.md b/docs/TEST_RESULTS_0246.md new file mode 100644 index 0000000..6c6d800 --- /dev/null +++ b/docs/TEST_RESULTS_0246.md @@ -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. diff --git a/docs/images/cyd-climate.png b/docs/images/cyd-climate.png new file mode 100644 index 0000000000000000000000000000000000000000..a3b264885cbb0202b0f796769b552090f351369f GIT binary patch literal 14607 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 P=m@}-wUi2Pnm_qpWqm*{ literal 0 HcmV?d00001 diff --git a/docs/images/cyd-vacuum.png b/docs/images/cyd-vacuum.png new file mode 100644 index 0000000000000000000000000000000000000000..226f2e8735b542a9e7a589ac566e3590abe87eb4 GIT binary patch literal 18891 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 N002ovPDHLkV1n+0>EZwY literal 0 HcmV?d00001 diff --git a/docs/images/guition-vacuum.png b/docs/images/guition-vacuum.png index d59540475e02daa8a17fef01822cd40cbaa1a896..fecd26def0bb7e200df59c2d7e252a6fe3433680 100644 GIT binary patch literal 37230 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%0DX!TIUlsbh& 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 MZU;!e`(61z00r5gX#fBK literal 31548 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 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 diff --git a/home-like-2432s028.yaml b/home-like-2432s028.yaml index 21f4d22..7cac781 100644 --- a/home-like-2432s028.yaml +++ b/home-like-2432s028.yaml @@ -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())); diff --git a/packages/cyd.yaml b/packages/cyd.yaml index d46ce9e..4eba3ca 100644 --- a/packages/cyd.yaml +++ b/packages/cyd.yaml @@ -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())); diff --git a/packages/guition.yaml b/packages/guition.yaml index 1d19baf..93eeb8a 100644 --- a/packages/guition.yaml +++ b/packages/guition.yaml @@ -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 diff --git a/screen_manager/CHANGELOG.md b/screen_manager/CHANGELOG.md index 44b89c2..178b6db 100644 --- a/screen_manager/CHANGELOG.md +++ b/screen_manager/CHANGELOG.md @@ -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. diff --git a/screen_manager/app/core.py b/screen_manager/app/core.py index 86971df..a96613a 100644 --- a/screen_manager/app/core.py +++ b/screen_manager/app/core.py @@ -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 = [] diff --git a/screen_manager/app/server.py b/screen_manager/app/server.py index f20aad5..ab32c4f 100644 --- a/screen_manager/app/server.py +++ b/screen_manager/app/server.py @@ -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)) diff --git a/screen_manager/app/static/tile-icons.woff b/screen_manager/app/static/tile-icons.woff index 93ede61559353f182264b4d990807adc560a0d40..6bace9406ce3791c8fcb603ebba9ed96b7974669 100644 GIT binary patch delta 12256 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 delta 12224 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 D{9o{# diff --git a/screen_manager/app/tile_icons.py b/screen_manager/app/tile_icons.py index d0cf96c..65f4a34 100644 --- a/screen_manager/app/tile_icons.py +++ b/screen_manager/app/tile_icons.py @@ -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. diff --git a/screen_manager/config.yaml b/screen_manager/config.yaml index 9eb6def..11acf30 100644 --- a/screen_manager/config.yaml +++ b/screen_manager/config.yaml @@ -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 diff --git a/tests/test_tile_controls.cpp b/tests/test_tile_controls.cpp index efe2eef..251278f 100644 --- a/tests/test_tile_controls.cpp +++ b/tests/test_tile_controls.cpp @@ -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 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; } diff --git a/tests/test_vacuum_card.py b/tests/test_vacuum_card.py new file mode 100644 index 0000000..023ae37 --- /dev/null +++ b/tests/test_vacuum_card.py @@ -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()