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 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-15 15:55:48 +02:00
parent aa2137f3f8
commit 57c82fdd12
15 changed files with 1214 additions and 592 deletions
+3 -2
View File
@@ -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**.
<p align="center">
<img src="docs/images/guition-weather.png" width="32%" alt="Weather card: current weather, the coming hours and the coming days with chance of rain">
<img src="docs/images/guition-climate.png" width="32%" alt="Climate card: target temperature dial, current temperature and humidity, and the mode">
<img src="docs/images/guition-climate.png" width="32%" alt="Climate card: the target temperature between big minus and plus keys, the mode keys, and fan and swing choices">
<img src="docs/images/guition-light.png" width="32%" alt="Light control: color, color temperature and brightness">
</p>
<p align="center">
+136
View File
@@ -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 <algorithm>
#include <functional>
#include <string>
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<void(char, int)> 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<char>(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<unsigned>(reinterpret_cast<uintptr_t>(lv_event_get_user_data(e)));
const char kind = static_cast<char>(code >> 8);
const int index = static_cast<int>(code & 0xFF);
if (!cyd::touch_guard.accept(esphome::millis(), 500 + static_cast<int>(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<int>(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<int>(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<uintptr_t>(static_cast<unsigned char>(row.kind)) << 8) | i;
lv_obj_add_event_cb(segment, tapped, LV_EVENT_SHORT_CLICKED, reinterpret_cast<void *>(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
@@ -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.
+19
View File
@@ -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.
+40
View File
@@ -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).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 30 KiB

+493 -292
View File
@@ -1,7 +1,7 @@
# Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911.
# Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.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()));
+8 -2
View File
@@ -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
+8 -2
View File
@@ -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
+493 -292
View File
@@ -2,7 +2,7 @@
# Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911.
# Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.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()));
+9
View File
@@ -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.
+1 -1
View File
@@ -17,7 +17,7 @@ WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'}
# Firmware shipped with this app release; screens below it get an update offer.
FIRMWARE_VERSION = '0.2.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'])
Binary file not shown.
+2
View File
@@ -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.
+1 -1
View File
@@ -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