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 e2d0e63..277df1f 100644 Binary files a/docs/images/guition-climate.png and b/docs/images/guition-climate.png differ diff --git a/guition-4848s040.yaml b/guition-4848s040.yaml index d77089c..6af41e8 100644 --- a/guition-4848s040.yaml +++ b/guition-4848s040.yaml @@ -1,7 +1,7 @@ # Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911. # Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile. substitutions: - SCREEN_FIRMWARE_VERSION: "0.2.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" @@ -694,6 +694,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, @@ -706,22 +707,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: |- @@ -731,6 +727,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;" @@ -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 6bace94..a4caa36 100644 Binary files a/screen_manager/app/static/tile-icons.woff and b/screen_manager/app/static/tile-icons.woff differ 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