From 57c82fdd125d0cab05eceaadda442e0b00474c12 Mon Sep 17 00:00:00 2001 From: Max Gramser Date: Tue, 15 Sep 2026 15:55:48 +0200 Subject: [PATCH] Bring the big climate card to the Guition, with fan and swing on their own card (0.2.47) App 0.2.47 / firmware 0.2.40: - guition-4848s040.yaml gets the CYD's climate card: the setpoint between large -/+ keys (debounced, hold to repeat) and one row of mode keys in HA's order and colours; the arc, scrim, mode pill and climate_number font are gone. - Fan and swing modes sit right on the card as segmented rows behind their icons, drawn by the new climate_card.h (async rebuild, taps through the touch guard and a profile hook). The card is laid out per case from one table: no, one or two settings rows, with or without mode keys. - Both boards hide a mode key row that would hold a single mode and centre the setpoint instead. - Glyph arrow-oscillating (Home Assistant's swing icon) joins the icon set. Verified on the Guition over OTA with the owner's air conditioner and with host renders of both boards; see docs/TEST_RESULTS_0247.md. Co-Authored-By: Claude Opus 5 --- README.md | 5 +- components/smart_display/climate_card.h | 136 ++++ components/smart_display/tile_icon_names.h | 1 + docs/RELEASING.md | 19 + docs/TEST_RESULTS_0247.md | 40 ++ docs/images/guition-climate.png | Bin 24383 -> 30562 bytes guition-4848s040.yaml | 785 +++++++++++++-------- home-like-2432s028.yaml | 10 +- packages/cyd.yaml | 10 +- packages/guition.yaml | 785 +++++++++++++-------- screen_manager/CHANGELOG.md | 9 + screen_manager/app/core.py | 2 +- screen_manager/app/static/tile-icons.woff | Bin 15516 -> 15596 bytes screen_manager/app/tile_icons.py | 2 + screen_manager/config.yaml | 2 +- 15 files changed, 1214 insertions(+), 592 deletions(-) create mode 100644 components/smart_display/climate_card.h create mode 100644 docs/TEST_RESULTS_0247.md diff --git a/README.md b/README.md index 55b7711..c05b674 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,8 @@ 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. + The target temperature sits big between − / + keys with one row of mode keys below; the + Guition shows fan and swing right away on a card of their own, the CYD behind ···. - **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 @@ -100,7 +101,7 @@ The firmware and built-in CLI are tested with **ESPHome 2026.6.2**.

Weather card: current weather, the coming hours and the coming days with chance of rain - Climate card: target temperature dial, current temperature and humidity, and the mode + Climate card: the target temperature between big minus and plus keys, the mode keys, and fan and swing choices Light control: color, color temperature and brightness

diff --git a/components/smart_display/climate_card.h b/components/smart_display/climate_card.h new file mode 100644 index 0000000..59ff250 --- /dev/null +++ b/components/smart_display/climate_card.h @@ -0,0 +1,136 @@ +#pragma once +// Fan and swing modes on the Guition's climate card (firmware 0.2.40+): one white card below the mode +// keys with a segmented row per setting, each behind its icon, where the CYD keeps them behind "more". +// The board profile owns the card and its scripts; this draws the rows into the card and reports a tap +// as the setting ('f' fan, 's' swing) and the index in the entity's own list. +#include "cyd_ui.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "lvgl.h" +#include +#include +#include + +namespace climate_card { +constexpr unsigned MAX_CHOICES = 6; +struct Row { char kind = 0; const char *icon = ""; std::string modes, current; }; +struct Look { + lv_obj_t *card = nullptr; + const lv_font_t *font = nullptr, *icon_font = nullptr; + int row_h = 44, gap = 10, edge = 14, icon_w = 42; +}; +inline Look look; +inline std::function choose; +inline Row rows[2]; +inline int row_count = 0; +inline bool queued = false; + +// Home Assistant's raw mode names read as words: "fan_only" -> "Fan only", "high" -> "High". +inline std::string pretty(std::string raw) { + for (char &c : raw) if (c == '_') c = ' '; + if (!raw.empty() && raw[0] >= 'a' && raw[0] <= 'z') raw[0] = static_cast(raw[0] - 'a' + 'A'); + return raw; +} +inline unsigned choices(const std::string &modes) { + unsigned n = 0; + while (n < MAX_CHOICES && !cyd::list_item(modes, n).empty()) ++n; + return n; +} +// Height of the card for `count` rows, so the profile can place the rest of the card before it is drawn. +inline int height(int count) { return count ? 2 * look.edge + count * look.row_h + (count - 1) * look.gap : 0; } +inline int card_width() { + // The card is 94 % wide, like the setpoint card above it. + return lv_display_get_horizontal_resolution(lv_display_get_default()) * 94 / 100; +} +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; +} +inline lv_obj_t *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 void tapped(lv_event_t *e) { + const auto code = static_cast(reinterpret_cast(lv_event_get_user_data(e))); + const char kind = static_cast(code >> 8); + const int index = static_cast(code & 0xFF); + if (!cyd::touch_guard.accept(esphome::millis(), 500 + static_cast(code & 0xFFF))) { + ESP_LOGI("touch", "tap on climate %c row ignored: %s", kind, cyd::touch_guard.reason().c_str()); + return; + } + if (choose) choose(kind, index); +} +// Draws the rows: an icon, then one rounded track with every choice as wide as its word plus an equal +// share of the room left; the chosen one is filled in the cards' blue. +inline void build() { + queued = false; + auto *card = look.card; + if (!card || !look.font || !look.icon_font) return; + lv_obj_clean(card); + if (!row_count) { lv_obj_add_flag(card, LV_OBJ_FLAG_HIDDEN); return; } + lv_obj_set_height(card, height(row_count)); + lv_obj_remove_flag(card, LV_OBJ_FLAG_HIDDEN); + const int width = card_width(), track_x = look.edge + look.icon_w, track_w = width - 2 * look.edge - look.icon_w; + const int inset = std::max(3, look.row_h / 12), room = track_w - 2 * inset, icon_h = lv_font_get_line_height(look.icon_font); + int y = look.edge; + for (int r = 0; r < row_count; ++r) { + const Row &row = rows[r]; + auto *icon = lv_label_create(card); + lv_label_set_text(icon, row.icon); + lv_obj_set_style_text_font(icon, look.icon_font, 0); + lv_obj_set_style_text_color(icon, lv_color_hex(0x6B6B6B), 0); + lv_obj_set_pos(icon, look.edge, y + (look.row_h - icon_h) / 2); + shape(card, track_x, y, track_w, look.row_h, 0xF1F1F1, look.row_h / 2); + const unsigned n = choices(row.modes); + std::string labels[MAX_CHOICES]; + int widths[MAX_CHOICES], words = 0; + for (unsigned i = 0; i < n; ++i) { + labels[i] = pretty(cyd::list_item(row.modes, i)); + widths[i] = text_width(labels[i], look.font); + words += widths[i]; + } + const int share = n ? (room - words) / static_cast(n) : 0; + int sx = track_x + inset; + for (unsigned i = 0; i < n; ++i) { + const int sw = i + 1 == n ? track_x + track_w - inset - sx : (words <= room ? widths[i] + share : room / static_cast(n)); + const bool selected = cyd::list_item(row.modes, i) == row.current; + const int sh = look.row_h - 2 * inset; + auto *segment = shape(card, sx, y + inset, sw, sh, 0x009FE3, sh / 2); + lv_obj_add_flag(segment, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_bg_opa(segment, selected ? LV_OPA_COVER : LV_OPA_TRANSP, 0); + lv_obj_set_style_bg_opa(segment, LV_OPA_COVER, LV_STATE_PRESSED); + lv_obj_set_style_bg_color(segment, lv_color_hex(selected ? 0x0075B0 : 0xD5EEFC), LV_STATE_PRESSED); + auto *label = lv_label_create(segment); + lv_label_set_text(label, labels[i].c_str()); + lv_label_set_long_mode(label, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_font(label, look.font, 0); + lv_obj_set_style_text_color(label, lv_color_hex(selected ? 0xFFFFFF : 0x1B1B1B), 0); + lv_obj_set_width(label, std::min(sw - 6, widths[i] + 2)); + lv_obj_remove_flag(label, LV_OBJ_FLAG_CLICKABLE); + lv_obj_center(label); + const uintptr_t code = (static_cast(static_cast(row.kind)) << 8) | i; + lv_obj_add_event_cb(segment, tapped, LV_EVENT_SHORT_CLICKED, reinterpret_cast(code)); + sx += sw; + } + y += look.row_h + look.gap; + } +} +// Called from the card's refresh, also while a row's own tap is still being handled: the rows are drawn +// once LVGL has finished that event, never under the finger that is lifting. +inline void show(const Row *list, int count) { + row_count = std::clamp(count, 0, 2); + for (int i = 0; i < row_count; ++i) rows[i] = list[i]; + if (queued) return; + queued = true; + lv_async_call([](void *) { build(); }, nullptr); +} +} // namespace climate_card diff --git a/components/smart_display/tile_icon_names.h b/components/smart_display/tile_icon_names.h index e8931e3..ae9e05d 100644 --- a/components/smart_display/tile_icon_names.h +++ b/components/smart_display/tile_icon_names.h @@ -186,6 +186,7 @@ inline constexpr Named NAMES[] = { {"weather-snowy-rainy", 0xF067F}, {"weather-sunset-down", 0xF059B}, {"dots-horizontal", 0xF01D8}, + {"arrow-oscillating", 0xF1C91}, }; 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 2e19f76..03dde3d 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -176,6 +176,25 @@ 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.47 / firmware 0.2.40 + +Firmware and board profiles; the app only raises `FIRMWARE_VERSION`. Storage, protocol, preferences and +keys are unchanged. + +- guition-4848s040.yaml gets the CYD's climate card (0.2.46): the arc, `climate_detail_bg`/`_scrim`, + `climate_mode_pill` and the `climate_number` font are gone; `setpoint_digits` is Roboto 400 at 64 px. + The scripts `climate_adjust_target`, `climate_target_send` and `climate_card_refresh` match the CYD, + except that the Guition's refresh hides the ··· key and lays the card out per case (no, one or two + settings rows, with or without mode keys) from one table, applied only when that case changes. +- New `components/smart_display/climate_card.h`: `climate_card::show(rows, count)` draws the fan + ('f') and swing ('s') rows into `climate_settings_card` through `lv_async_call`, so a row can redraw + itself from its own tap; taps go through `cyd::touch_guard` and the profile's `climate_card::choose` + hook, which runs `set_climate_fan_mode` / `set_climate_swing_mode` with the index in the entity's list + (`cyd::list_item`). At most six choices per row. Both scripts now refresh the card at once. +- Both profiles hide the mode key row when a device offers at most one mode besides off (and no ···), + and centre the setpoint card instead. +- Glyph `arrow-oscillating` (F1C91), Home Assistant's swing icon, joins `tile_icons.FIXED`. + ### Compatibility 0.2.46 / firmware 0.2.39 Storage version, tile protocol (`v: 1`), preferences and keys are unchanged. Additive wire fields only. diff --git a/docs/TEST_RESULTS_0247.md b/docs/TEST_RESULTS_0247.md new file mode 100644 index 0000000..43d313c --- /dev/null +++ b/docs/TEST_RESULTS_0247.md @@ -0,0 +1,40 @@ +# Test results app 0.2.47 / firmware 0.2.40 (2026-09-15) + +The new climate card on the Guition with fan and swing on a card of their own, and no single mode +key on either board. See CHANGELOG 0.2.47 and RELEASING "Compatibility 0.2.47". + +## Automated + +- Python (`.venv-portal`): 170 tests OK (the icon tests cover the new `arrow-oscillating` glyph). +- C++ (`clang++ -std=c++17 -Wall -Wextra -Werror -I . -I components/smart_display`): 13/13 PASS. + `climate_card.h` draws LVGL objects and is covered by the host renders below, not by a unit test. +- `generate_packages.py --check` green, `generate_icons.py --check` verified (150 pickable icons, + 180 glyphs). +- ESPHome 2026.6.2 compile SUCCESS, one after another: `easy-guition-device.yaml` (RAM 32.9%, flash + 18.9%), `easy-cyd-device.yaml` (RAM 32.0%, flash 78.5%), `guition-device.yaml` (RAM 33.0%, flash 20.0%) + and `device.yaml` (RAM 32.0%, flash 83.3%). Packages carry `SCREEN_FIRMWARE_VERSION` 0.2.40. + +## Bench boards (OTA) + +- Both Easy Setup builds uploaded over the network; `device_info` reports project 0.2.40, compiled + 2026-09-15 15:42:18 (Guition) and 15:44:03 (CYD), matching the build logs. ESP Screens reports both + screens Synced on 0.2.40. +- `preview_runtime_card` opened the climate card of the owner's `climate.airco_remeha_diva_ac` on + the Guition with live data from app 0.2.46. The capture (`capture_ui.py`) shows Off with a grey 19°, + the five mode keys (heat/cool, heat, cool, dry, fan only), Fan with Low chosen and Swing with Off + chosen. The vacuum card of 0.2.46 is unchanged. + +## Renders (ESPHome host + SDL, both boards, this release's code) + +- Guition climate card in four cases: heating with three modes and a fan row, an air conditioner with + five modes, fan and swing (the owner's attributes), the same with two swing modes, and a heat-only + thermostat without fan or swing (no mode keys, setpoint centred). Nothing runs off the screen; the + card with two rows ends 10 px above the bottom edge. +- CYD climate card: unchanged apart from the heat-only thermostat, which now shows the centred setpoint + without a mode key. +- `docs/images/guition-climate.png` comes from the air conditioner render. + +## Not tested / for the owner + +- The Guition climate card by hand: − / + taps and holding, mode keys, fan and swing choices with the + real air conditioner (tapping sends real commands). diff --git a/docs/images/guition-climate.png b/docs/images/guition-climate.png index e2d0e632040d35dd4b646036ce1ca81bcfb11a65..277df1fdf0a4d853aa11e0d4ec2ee54007f8c7d1 100644 GIT binary patch literal 30562 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 literal 24383 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 + climate_card::look.icon_font = id(materialdesign_icons_mini)->get_lv_font(); + climate_card::choose = [](char kind, int index) { + if (kind == 'f') id(set_climate_fan_mode).execute(index); + else id(set_climate_swing_mode).execute(index); + }; - light.turn_on: id: back_light brightness: !lambda "return screen_settings::current.brightness / 100.0f;" @@ -1109,12 +1112,29 @@ 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-700.ttf" - id: climate_number - size: 46 + # The climate card's setpoint, large enough to read across the room. + - file: "fonts/Roboto-400.ttf" + id: setpoint_digits + size: 64 bpp: 4 - glyphs: "0123456789.-° " + glyphs: ['-','.','°','0','1','2','3','4','5','6','7','8','9'] - file: "fonts/Roboto-500.ttf" id: time_label size: 27 @@ -1363,6 +1383,7 @@ font: - "\U000F067F" # weather-snowy-rainy - "\U000F059B" # weather-sunset-down - "\U000F01D8" # dots-horizontal + - "\U000F1C91" # arrow-oscillating - file: 'fonts/materialdesignicons-webfont.ttf' id: materialdesign_icons_mini @@ -4059,13 +4080,17 @@ lvgl: # ---------- CLIMATE DETAIL OVERLAY ---------- + # The CYD's climate card at the Guition's size: 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. - 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 @@ -4076,27 +4101,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 @@ -4147,156 +4151,322 @@ lvgl: - label: id: climate_detail_title align: TOP_MID - y: 35 + y: 30 + width: 300 + 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: 84 - text: "Now: --" + y: 92 + 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: 40 - width: 276 - height: 276 - # 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: 30 - arc_rounded: true - arc_color: 0xE5E5E5 - arc_opa: 80% + - obj: + id: climate_setpoint_card + align: TOP_MID + y: 136 + width: 94% + height: 200 + radius: 24 + 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: 18 + width: 116 + height: 116 + radius: 58 + 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: -18 + width: 116 + height: 116 + radius: 58 + 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: -14 + text: "--" + text_font: setpoint_digits + text_color: 0x1B1B1B + clickable: false + - label: + id: climate_set_label + align: CENTER + y: 46 + 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: 364 + width: 94% + height: 76 bg_opa: TRANSP border_width: 0 - indicator: - arc_color: 0x00A6ED - arc_opa: COVER - arc_width: 30 - 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: -32 - 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: 40 - text: "20.0°" - text_font: climate_number - text_color: 0x1B1B1B - clickable: false - + scrollable: false + layout: + type: FLEX + flex_flow: ROW + flex_align_main: CENTER + flex_align_cross: CENTER + pad_column: 10 + widgets: + - obj: + id: cmkey_auto + flex_grow: 1 + height: 76 + radius: 38 + 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: 76 + radius: 38 + 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: 76 + radius: 38 + 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: 76 + radius: 38 + 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: 76 + radius: 38 + 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: 76 + radius: 38 + 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: 76 + radius: 38 + 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 + # Fan and swing, each a row of choices behind its icon (climate_card.h fills the card). - obj: - id: climate_minus_button - align: BOTTOM_LEFT - x: 57 - y: -32 - width: 72 - height: 72 - radius: 22 - bg_color: 0xEEEEEE + id: climate_settings_card + align: TOP_MID + y: 340 + width: 94% + height: 116 + radius: 24 + bg_color: 0xFFFFFF bg_opa: COVER border_width: 1 - border_color: 0xFFFFFF - border_opa: 16% - clickable: true + border_color: 0xDDDDDD + pad_all: 0 + hidden: true + clickable: false 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_plus_button - align: BOTTOM_RIGHT - x: -57 - y: -32 - width: 72 - height: 72 - radius: 22 - 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: -36 - width: 189 - height: 68 - radius: 26 - 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 # ---------- 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 @@ -5405,55 +5575,157 @@ 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"}}; + // The Guition shows fan and swing on their own card below, so no "more" key. + bool more = false; + 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); + // One mode is no choice: the power key already turns it on and off. + const bool show_keys = count > 1 || more; + if (show_keys) 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); + + // Fan and swing rows, in the entity's own names and order. + climate_card::Row settings[2]; + int rows = 0; + if (!cyd::list_item(id(active_climate_fan_modes), 0).empty()) + settings[rows++] = {'f', "\U000F0210", id(active_climate_fan_modes), id(active_climate_fan_mode)}; + if (!cyd::list_item(id(active_climate_swing_modes), 0).empty()) + settings[rows++] = {'s', "\U000F1C91", id(active_climate_swing_modes), id(active_climate_swing_mode)}; + // One layout per card: the setpoint gives room to the mode keys and the settings below it, + // and a card with nothing below keeps the setpoint centred. + static int laid_out = -1; + if (laid_out != rows * 2 + show_keys) { + laid_out = rows * 2 + show_keys; + struct Layout { int status, card_y, card_h, key, number_y, caption_y, modes_y, modes_h, settings_y, edge, row_h, gap; }; + static const Layout layouts[6] = { + // no mode keys: 0, 1, 2 rows + {92, 180, 200, 116, -14, 46, 0, 0, 0, 14, 48, 10}, + {92, 140, 200, 116, -14, 46, 0, 0, 364, 14, 48, 10}, + {90, 130, 180, 108, -12, 42, 0, 0, 330, 14, 46, 10}, + // mode keys: 0, 1, 2 rows + {92, 136, 200, 116, -14, 46, 364, 76, 0, 14, 48, 10}, + {90, 126, 170, 108, -12, 42, 312, 68, 394, 12, 44, 8}, + {86, 118, 140, 96, -10, 36, 270, 60, 342, 12, 42, 8}, + }; + const Layout &l = layouts[(show_keys ? 3 : 0) + rows]; + lv_obj_set_y(id(climate_current_label), l.status); + lv_obj_set_y(id(climate_setpoint_card), l.card_y); + lv_obj_set_height(id(climate_setpoint_card), l.card_h); + for (lv_obj_t *button : {id(climate_minus_button), id(climate_plus_button)}) { + lv_obj_set_size(button, l.key, l.key); + lv_obj_set_style_radius(button, l.key / 2, LV_PART_MAIN); + } + lv_obj_set_y(id(climate_target_label), l.number_y); + lv_obj_set_y(id(climate_set_label), l.caption_y); + if (show_keys) { + lv_obj_set_y(id(climate_mode_keys), l.modes_y); + lv_obj_set_height(id(climate_mode_keys), l.modes_h); + for (lv_obj_t *mode_key : {id(cmkey_auto), id(cmkey_heat_cool), id(cmkey_heat), id(cmkey_cool), id(cmkey_dry), id(cmkey_fan_only), id(cmkey_more)}) { + lv_obj_set_height(mode_key, l.modes_h); + lv_obj_set_style_radius(mode_key, l.modes_h / 2, LV_PART_MAIN); + } + } + lv_obj_set_y(id(climate_settings_card), l.settings_y); + climate_card::look.edge = l.edge; + climate_card::look.row_h = l.row_h; + climate_card::look.gap = l.gap; + } + climate_card::show(settings, rows); + 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. @@ -5563,6 +5835,7 @@ script: id(active_climate_fan_mode) = cyd::list_item(id(active_climate_fan_modes), index); id(climate_edit_ms) = millis(); id(climate_edit_pending) = true; + id(climate_card_refresh).execute(); - lvgl.widget.hide: climate_mode_overlay - if: condition: @@ -5582,6 +5855,7 @@ script: id(active_climate_swing_mode) = cyd::list_item(id(active_climate_swing_modes), index); id(climate_edit_ms) = millis(); id(climate_edit_pending) = true; + id(climate_card_refresh).execute(); - lvgl.widget.hide: climate_mode_overlay - if: condition: @@ -5603,8 +5877,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);' @@ -5643,9 +5917,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: @@ -6200,6 +6475,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); @@ -6212,41 +6489,7 @@ 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)); - - if (atoi("${DISPLAY_W}") < atoi("${DISPLAY_H}")) { - lv_obj_set_size(id(climate_arc), 294, 294); - lv_obj_set_y(id(climate_arc), 16); - lv_obj_set_y(id(climate_current_label), 96); - lv_obj_set_y(id(climate_minus_button), -124); - lv_obj_set_y(id(climate_plus_button), -124); - lv_obj_set_y(id(climate_mode_pill), -36); - } else { - lv_obj_set_size(id(climate_arc), 264, 264); - lv_obj_set_y(id(climate_arc), 36); - lv_obj_set_y(id(climate_current_label), 80); - lv_obj_set_y(id(climate_minus_button), -28); - lv_obj_set_y(id(climate_plus_button), -28); - lv_obj_set_y(id(climate_mode_pill), -30); - } + 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); @@ -6572,66 +6815,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/home-like-2432s028.yaml b/home-like-2432s028.yaml index 7cac781..63fd92d 100644 --- a/home-like-2432s028.yaml +++ b/home-like-2432s028.yaml @@ -57,7 +57,7 @@ ############################################################ substitutions: - SCREEN_FIRMWARE_VERSION: "0.2.39" + SCREEN_FIRMWARE_VERSION: "0.2.40" # 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. @@ -1571,6 +1571,7 @@ font: - "\U000F067F" # weather-snowy-rainy - "\U000F059B" # weather-sunset-down - "\U000F01D8" # dots-horizontal + - "\U000F1C91" # arrow-oscillating - file: 'fonts/materialdesignicons-webfont.ttf' id: materialdesign_icons_mini @@ -5749,8 +5750,13 @@ script: 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); + // One mode is no choice: the power key already turns it on and off. Without keys the + // setpoint card moves to the middle of the room below the status line. + const bool show_keys = count > 1 || more; + if (show_keys) 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); + const bool landscape = atoi("${DISPLAY_W}") >= atoi("${DISPLAY_H}"); + lv_obj_set_y(id(climate_setpoint_card), show_keys ? (landscape ? 80 : 96) : (landscape ? 108 : 140)); id(climate_power_refresh).execute(); # Opens the mode picker. Hides modes the entity doesn't support and marks diff --git a/packages/cyd.yaml b/packages/cyd.yaml index 4eba3ca..5a98fd8 100644 --- a/packages/cyd.yaml +++ b/packages/cyd.yaml @@ -58,7 +58,7 @@ ############################################################ substitutions: - SCREEN_FIRMWARE_VERSION: "0.2.39" + SCREEN_FIRMWARE_VERSION: "0.2.40" # 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. @@ -1560,6 +1560,7 @@ font: - "\U000F067F" # weather-snowy-rainy - "\U000F059B" # weather-sunset-down - "\U000F01D8" # dots-horizontal + - "\U000F1C91" # arrow-oscillating - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf" id: materialdesign_icons_mini @@ -5680,8 +5681,13 @@ script: 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); + // One mode is no choice: the power key already turns it on and off. Without keys the + // setpoint card moves to the middle of the room below the status line. + const bool show_keys = count > 1 || more; + if (show_keys) 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); + const bool landscape = atoi("${DISPLAY_W}") >= atoi("${DISPLAY_H}"); + lv_obj_set_y(id(climate_setpoint_card), show_keys ? (landscape ? 80 : 96) : (landscape ? 108 : 140)); id(climate_power_refresh).execute(); # Opens the mode picker. Hides modes the entity doesn't support and marks diff --git a/packages/guition.yaml b/packages/guition.yaml index 93eeb8a..7beca29 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.39" + SCREEN_FIRMWARE_VERSION: "0.2.40" # 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" @@ -698,6 +698,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, @@ -710,22 +711,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: |- @@ -735,6 +731,13 @@ esphome: light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); }; light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); }; light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); }; + climate_card::look.card = id(climate_settings_card); + climate_card::look.font = id(label)->get_lv_font(); + climate_card::look.icon_font = id(materialdesign_icons_mini)->get_lv_font(); + climate_card::choose = [](char kind, int index) { + if (kind == 'f') id(set_climate_fan_mode).execute(index); + else id(set_climate_swing_mode).execute(index); + }; - light.turn_on: id: back_light brightness: !lambda "return screen_settings::current.brightness / 100.0f;" @@ -1098,12 +1101,29 @@ 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-700.ttf" - id: climate_number - size: 46 + # 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: 64 bpp: 4 - glyphs: "0123456789.-° " + glyphs: ['-','.','°','0','1','2','3','4','5','6','7','8','9'] - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-500.ttf" id: time_label size: 27 @@ -1352,6 +1372,7 @@ font: - "\U000F067F" # weather-snowy-rainy - "\U000F059B" # weather-sunset-down - "\U000F01D8" # dots-horizontal + - "\U000F1C91" # arrow-oscillating - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf" id: materialdesign_icons_mini @@ -3990,13 +4011,17 @@ lvgl: # ---------- CLIMATE DETAIL OVERLAY ---------- + # The CYD's climate card at the Guition's size: 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. - 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 @@ -4007,27 +4032,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 @@ -4078,156 +4082,322 @@ lvgl: - label: id: climate_detail_title align: TOP_MID - y: 35 + y: 30 + width: 300 + 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: 84 - text: "Now: --" + y: 92 + 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: 40 - width: 276 - height: 276 - # 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: 30 - arc_rounded: true - arc_color: 0xE5E5E5 - arc_opa: 80% + - obj: + id: climate_setpoint_card + align: TOP_MID + y: 136 + width: 94% + height: 200 + radius: 24 + 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: 18 + width: 116 + height: 116 + radius: 58 + 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: -18 + width: 116 + height: 116 + radius: 58 + 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: -14 + text: "--" + text_font: setpoint_digits + text_color: 0x1B1B1B + clickable: false + - label: + id: climate_set_label + align: CENTER + y: 46 + 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: 364 + width: 94% + height: 76 bg_opa: TRANSP border_width: 0 - indicator: - arc_color: 0x00A6ED - arc_opa: COVER - arc_width: 30 - 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: -32 - 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: 40 - text: "20.0°" - text_font: climate_number - text_color: 0x1B1B1B - clickable: false - + scrollable: false + layout: + type: FLEX + flex_flow: ROW + flex_align_main: CENTER + flex_align_cross: CENTER + pad_column: 10 + widgets: + - obj: + id: cmkey_auto + flex_grow: 1 + height: 76 + radius: 38 + 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: 76 + radius: 38 + 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: 76 + radius: 38 + 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: 76 + radius: 38 + 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: 76 + radius: 38 + 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: 76 + radius: 38 + 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: 76 + radius: 38 + 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 + # Fan and swing, each a row of choices behind its icon (climate_card.h fills the card). - obj: - id: climate_minus_button - align: BOTTOM_LEFT - x: 57 - y: -32 - width: 72 - height: 72 - radius: 22 - bg_color: 0xEEEEEE + id: climate_settings_card + align: TOP_MID + y: 340 + width: 94% + height: 116 + radius: 24 + bg_color: 0xFFFFFF bg_opa: COVER border_width: 1 - border_color: 0xFFFFFF - border_opa: 16% - clickable: true + border_color: 0xDDDDDD + pad_all: 0 + hidden: true + clickable: false 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_plus_button - align: BOTTOM_RIGHT - x: -57 - y: -32 - width: 72 - height: 72 - radius: 22 - 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: -36 - width: 189 - height: 68 - radius: 26 - 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 # ---------- 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 @@ -5336,55 +5506,157 @@ 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"}}; + // The Guition shows fan and swing on their own card below, so no "more" key. + bool more = false; + 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); + // One mode is no choice: the power key already turns it on and off. + const bool show_keys = count > 1 || more; + if (show_keys) 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); + + // Fan and swing rows, in the entity's own names and order. + climate_card::Row settings[2]; + int rows = 0; + if (!cyd::list_item(id(active_climate_fan_modes), 0).empty()) + settings[rows++] = {'f', "\U000F0210", id(active_climate_fan_modes), id(active_climate_fan_mode)}; + if (!cyd::list_item(id(active_climate_swing_modes), 0).empty()) + settings[rows++] = {'s', "\U000F1C91", id(active_climate_swing_modes), id(active_climate_swing_mode)}; + // One layout per card: the setpoint gives room to the mode keys and the settings below it, + // and a card with nothing below keeps the setpoint centred. + static int laid_out = -1; + if (laid_out != rows * 2 + show_keys) { + laid_out = rows * 2 + show_keys; + struct Layout { int status, card_y, card_h, key, number_y, caption_y, modes_y, modes_h, settings_y, edge, row_h, gap; }; + static const Layout layouts[6] = { + // no mode keys: 0, 1, 2 rows + {92, 180, 200, 116, -14, 46, 0, 0, 0, 14, 48, 10}, + {92, 140, 200, 116, -14, 46, 0, 0, 364, 14, 48, 10}, + {90, 130, 180, 108, -12, 42, 0, 0, 330, 14, 46, 10}, + // mode keys: 0, 1, 2 rows + {92, 136, 200, 116, -14, 46, 364, 76, 0, 14, 48, 10}, + {90, 126, 170, 108, -12, 42, 312, 68, 394, 12, 44, 8}, + {86, 118, 140, 96, -10, 36, 270, 60, 342, 12, 42, 8}, + }; + const Layout &l = layouts[(show_keys ? 3 : 0) + rows]; + lv_obj_set_y(id(climate_current_label), l.status); + lv_obj_set_y(id(climate_setpoint_card), l.card_y); + lv_obj_set_height(id(climate_setpoint_card), l.card_h); + for (lv_obj_t *button : {id(climate_minus_button), id(climate_plus_button)}) { + lv_obj_set_size(button, l.key, l.key); + lv_obj_set_style_radius(button, l.key / 2, LV_PART_MAIN); + } + lv_obj_set_y(id(climate_target_label), l.number_y); + lv_obj_set_y(id(climate_set_label), l.caption_y); + if (show_keys) { + lv_obj_set_y(id(climate_mode_keys), l.modes_y); + lv_obj_set_height(id(climate_mode_keys), l.modes_h); + for (lv_obj_t *mode_key : {id(cmkey_auto), id(cmkey_heat_cool), id(cmkey_heat), id(cmkey_cool), id(cmkey_dry), id(cmkey_fan_only), id(cmkey_more)}) { + lv_obj_set_height(mode_key, l.modes_h); + lv_obj_set_style_radius(mode_key, l.modes_h / 2, LV_PART_MAIN); + } + } + lv_obj_set_y(id(climate_settings_card), l.settings_y); + climate_card::look.edge = l.edge; + climate_card::look.row_h = l.row_h; + climate_card::look.gap = l.gap; + } + climate_card::show(settings, rows); + 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. @@ -5494,6 +5766,7 @@ script: id(active_climate_fan_mode) = cyd::list_item(id(active_climate_fan_modes), index); id(climate_edit_ms) = millis(); id(climate_edit_pending) = true; + id(climate_card_refresh).execute(); - lvgl.widget.hide: climate_mode_overlay - if: condition: @@ -5513,6 +5786,7 @@ script: id(active_climate_swing_mode) = cyd::list_item(id(active_climate_swing_modes), index); id(climate_edit_ms) = millis(); id(climate_edit_pending) = true; + id(climate_card_refresh).execute(); - lvgl.widget.hide: climate_mode_overlay - if: condition: @@ -5534,8 +5808,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);' @@ -5574,9 +5848,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: @@ -6131,6 +6406,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); @@ -6143,41 +6420,7 @@ 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)); - - if (atoi("${DISPLAY_W}") < atoi("${DISPLAY_H}")) { - lv_obj_set_size(id(climate_arc), 294, 294); - lv_obj_set_y(id(climate_arc), 16); - lv_obj_set_y(id(climate_current_label), 96); - lv_obj_set_y(id(climate_minus_button), -124); - lv_obj_set_y(id(climate_plus_button), -124); - lv_obj_set_y(id(climate_mode_pill), -36); - } else { - lv_obj_set_size(id(climate_arc), 264, 264); - lv_obj_set_y(id(climate_arc), 36); - lv_obj_set_y(id(climate_current_label), 80); - lv_obj_set_y(id(climate_minus_button), -28); - lv_obj_set_y(id(climate_plus_button), -28); - lv_obj_set_y(id(climate_mode_pill), -30); - } + 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); @@ -6503,66 +6746,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/screen_manager/CHANGELOG.md b/screen_manager/CHANGELOG.md index 178b6db..3096434 100644 --- a/screen_manager/CHANGELOG.md +++ b/screen_manager/CHANGELOG.md @@ -1,3 +1,12 @@ +## 0.2.47 (firmware 0.2.40) + +The new climate card comes to the Guition, with fan and swing right on the card. + +- **Climate card on the Guition.** The dial is gone here too: the target temperature sits big between two large − / + keys, the number follows every tap and one call goes out when you stop tapping, and holding a key keeps stepping. One row of keys picks the mode in Home Assistant's order and colours. +- **Fan and swing at a glance.** Below the mode keys a white card shows the fan speeds and the swing modes as rows of choices, each behind its icon, like the cleaning settings on the vacuum card. Tap a choice and it shows at once. The card makes room for whatever the device has: with fan and swing everything moves a little closer together, and a thermostat without them keeps a larger setpoint. +- **One mode is no choice.** A thermostat that can only heat no longer shows a single mode key; its setpoint sits in the middle of the card, on both boards. The power key still turns it on and off. +- Firmware 0.2.40 for both boards; the app offers the update. The CYD keeps fan and swing behind ···. + ## 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. diff --git a/screen_manager/app/core.py b/screen_manager/app/core.py index a96613a..403ce10 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.39' +FIRMWARE_VERSION = '0.2.40' 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']) diff --git a/screen_manager/app/static/tile-icons.woff b/screen_manager/app/static/tile-icons.woff index 6bace9406ce3791c8fcb603ebba9ed96b7974669..a4caa368a0469c5c204ff59a7962c9159e9063e1 100644 GIT binary patch delta 1225 zcmbPJ`KHpO+~3WOfsp|SY~C<%gXqi#2p_~o1@6J2PC#`BCYq?5RxmIy13BzVgl<*v zrsq_q0ofW145qaV46#yDpROOwNKH&(U|{3`8es;)M}%)2n>azfek)KgPyDp#)tvm~ zL`7$)m6nu(v9aQ47i1|}be zGpsE6F8S`4cFxk6`AS3EjoDREZ-3NW+B40zK3el<{YUH4mkq1#i>h_i|Jk%* z{fFJbti3Nw9D}XmEi0;aEl}O-VW4+cr%10=oHh5Q*~HD-IpSNETeHf(T(300)t+_k zlwD?y;kVRZ__4E8FJ)i7>tB$$`m?kIGA3rRuYrc4i9lEN5=2e3=sX_WrS%O_MKIvv6Pf&9Z!nKZn>df6h(+dLzFd^Oc-G zt?$^hp7WmNT<84%i|Kg&7uE6oFC62LnI#l6V&=aSyEEaR z;*N=zCROH1o~dxP^4(w*tFgl}R^`OJ+(?}rrhg*~q*G(oKloO%=s{n}%mkHMw}SLt zkpK=zu<$jzOdIj_T9Dfo=}=MU%kR6R{g}jYyZx-8XyJeUUwL{^?b;UJ4ezTD^FAaL@;>rRB{E z-`(H49en+A#e?1wp9#s|zjEI5Dn9>R_r#CfQ!Q43d%J(NvsQ+Eov(i4-url^3jJ8U z3VT_>8VAW2qEBv`ZE*V_m|8IR!Q=motctHXHg96hVPw+l-K@ZFVCt`G^%Kq#&a(XYf|-tM6AD*3TI)F7+n`%7!?V4)WZSJb=3HVgH1Gxz14^0XOuUiw5w zMQxc9bvt7B${^m2+dST82X5tCI&D#z)e67Rz}>5Kh4u-#s0d9onfi#CcfLoBlHJew z%P0SN_gSs@{YlIBzvmS)BzU;GHcDDbm>zkhEbF2Q>uUoDu8}xPeA(n~pV&CdT z>&m+pn9fzp@D|r#_xBesSt8DPY{_D!=Rp;o7pCTkTJAn%=2*VVIl#YO^ry}r%@+$z zN;#`8Gdjjwsoc=Ejkx-C`Z7yH^T+?b@Hi^lh6l(dFHewJdQDlvC%rRkURE*7*-4{m)?J6QTMxT#+BW!b^6FY^xazLam0 z)|8$3yzisrdY|Q7VN1JN%$Ikw-rOwf&9Z%&J$K&Xo=lEBe|3&`9@DwoE~m4WFI(KS z`_gpQ)tAdzXI?*7*|etkx!1v2B~u^VD~Xu!^?i*?LH#b@4-8AQTN843MSV#4wex}E zsT;jbce<19Mb~)O3p}}Tw|+BAX}{ z{MXyDFYp@osyfvZdn5Jw7 C8|l0N diff --git a/screen_manager/app/tile_icons.py b/screen_manager/app/tile_icons.py index 65f4a34..d935057 100644 --- a/screen_manager/app/tile_icons.py +++ b/screen_manager/app/tile_icons.py @@ -218,6 +218,8 @@ FIXED = ( ('weather-sunset-down', 'F059B'), # "More" key on the CYD climate card: fan and swing (firmware 0.2.39+). ('dots-horizontal', 'F01D8'), + # Swing row on the Guition climate card, Home Assistant's icon for swing modes (firmware 0.2.40+). + ('arrow-oscillating', 'F1C91'), ) # Pickable icons by name, and every glyph the firmware fonts contain. diff --git a/screen_manager/config.yaml b/screen_manager/config.yaml index 11acf30..2a2c529 100644 --- a/screen_manager/config.yaml +++ b/screen_manager/config.yaml @@ -1,5 +1,5 @@ name: ESP Screen Manager -version: "0.2.46" +version: "0.2.47" slug: esp_screen_manager description: Choose tiles for your CYD and Guition touchscreen, without reflashing. url: https://github.com/MaxGramser/homeassistant_espscreen