1a6308aba7
A test and code-analysis round of 0.2.72 to 0.2.76 (suites at every tag, sanitizers, ESP32 and GitHub builds, host harnesses on both boards, the editor in a browser, best practice checked against ESPHome, LVGL, Home Assistant and Vue) found these, fixed here with Max's choices for the product questions: - Firmware 0.2.65: the second hand of a single analog clock moves again; on a page that is one full on/off tile the waking push also switches it; on the Guition a flick is never a tap and a finger that slides off a tile doesn't tap it; Previous/Next react to a firm press; the same Go to page tile may sit on several pages; a layout the screen has no memory for is refused instead of crashing; 64-bit redraw mask for 48 tiles; Montserrat 14 no longer built. - App: Save refuses a layout too long for one message or too big for eight pages; an offline screen keeps its firmware version from the device registry; HA refusals reach the editor; 128 KB requests; changelog out of the live stream; cached hashed assets; tini as init and a clean stop; changelog parsing without asterisks or crashes. - Editor: clicking the open screen keeps unsaved edits, edits during a save stay unsaved, a Page row and phone dragging to other pages, repeated page tiles, limits from the server, HA's error messages, a type-checked build. - tools/check.sh + GitHub Actions + a CYD flash budget; docs match 48 tiles and the new editor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4968 lines
198 KiB
YAML
4968 lines
198 KiB
YAML
# ESP Screens - Guition ESP32-S3-4848S040 board profile: 4-inch EU wallbox, 480x480 IPS + GT911.
|
||
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
|
||
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
|
||
substitutions:
|
||
SCREEN_FIRMWARE_VERSION: "0.2.65"
|
||
# 0: no drift limit. A press that drifts is still a tap as long as the finger lets go on the tile
|
||
# (runtime_tiles::event checks where it let go: LVGL keeps the press on the tile and would click it
|
||
# anywhere), and a quick flick is LVGL's gesture, never a tap (the home_page gesture handler in on_boot).
|
||
# The CYD keeps a limit because its resistive panel jumps.
|
||
TOUCH_MOVE_LIMIT_PX: "0"
|
||
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
|
||
TOUCH_MIN_PRESS_MS: "20"
|
||
# Page swipe starts in this band along the left or right edge (about 5 mm) and needs
|
||
# this much travel inward (about 6 mm); a swipe from the middle of the screen does nothing.
|
||
EDGE_SWIPE_BAND_PX: "32"
|
||
EDGE_SWIPE_TRAVEL_PX: "40"
|
||
DASHBOARD_INBOX_INTERNAL: "false"
|
||
DEVICE_NAME: "guition-new"
|
||
DEVICE_FRIENDLY_NAME: "My Guition"
|
||
CALIBRATION_ON_BOOT: "false"
|
||
ROOM_NAME: "Choose tiles in HA" # shown top left until ESP Screens sends the screen name
|
||
|
||
## DIM settings
|
||
AUTO_DIM: "true" # true = enable auto dimming
|
||
AUTO_DIM_TIMEOUT: "600" # seconds after last touch
|
||
MAX_BRIGHTNESS: "100" # backlight brightness in percent
|
||
DIM_BRIGHTNESS: "20" # dimmed backlight brightness in percent
|
||
NIGHT_MODE: "true" # true = use separate dim level at night
|
||
NIGHT_START_HOUR: "22" # 0-23
|
||
NIGHT_END_HOUR: "7" # 0-23
|
||
NIGHT_DIM_BRIGHTNESS: "10" # backlight brightness in percent at night
|
||
|
||
DISPLAY_W: "480"
|
||
DISPLAY_H: "480"
|
||
LVGL_ROTATION: "0"
|
||
TOUCH_SWAP_XY: "false"
|
||
TOUCH_MIRROR_X: "false"
|
||
TOUCH_MIRROR_Y: "false"
|
||
GRID_COL1_X: "16"
|
||
GRID_COL2_X: "246"
|
||
GRID_ROW1_Y: "0"
|
||
GRID_ROW2_Y: "120"
|
||
GRID_ROW3_Y: "240"
|
||
GRID_ROW4_Y: "360"
|
||
GRID_ROW5_Y: "480"
|
||
SCROLL_Y: "72"
|
||
SCROLL_H: "348"
|
||
TILE_W: "218"
|
||
TILE_H: "108"
|
||
HEADER_Y: "22"
|
||
# Holding the top bar opens the settings page (0.2.44+). The two edge bands stay clear: a swipe
|
||
# that starts up there still changes the page.
|
||
SETTINGS_HOLD_X: "${EDGE_SWIPE_BAND_PX}"
|
||
SETTINGS_HOLD_W: "416"
|
||
SETTINGS_HOLD_DRIFT_PX: "32"
|
||
|
||
# Alert overlay (HA action show_alert), 480x480: one card centred on LVGL's top layer.
|
||
ALERT_CARD_W: "420"
|
||
ALERT_CARD_H: "320"
|
||
ALERT_ICON_X: "26"
|
||
ALERT_ICON_Y: "22"
|
||
ALERT_TEXT_X: "90"
|
||
ALERT_TEXT_W: "304"
|
||
ALERT_TITLE_Y: "26"
|
||
ALERT_TITLE_H: "32" # one line of headline; a DOT label only ends in an ellipsis with a height
|
||
ALERT_SUBTITLE_Y: "68"
|
||
ALERT_SUBTITLE_H: "150"
|
||
ALERT_BUTTON_W: "150"
|
||
ALERT_BUTTON_H: "60"
|
||
ALERT_BUTTON_INSET: "22"
|
||
ALERT_TITLE_MAX: "64" # bytes; the label ends one line with an ellipsis
|
||
ALERT_BUTTON_MAX: "16" # bytes on the OK button
|
||
ALERT_SUBTITLE_MAX: "240" # bytes; wraps over the rows the card has
|
||
# An alert with a camera image (firmware 0.2.57+): the image across the top of a taller card, the words under it.
|
||
ALERT_CARD_H_IMAGE: "452"
|
||
ALERT_IMAGE_INSET: "14"
|
||
ALERT_IMAGE_SHIFT: "236" # the icon, title and subtitle move down by the image and a gap
|
||
ALERT_SUBTITLE_H_IMAGE: "62" # two lines between the title and the button
|
||
# Camera images (firmware 0.2.57+): the boxes ESP Screen Manager sizes a snapshot for (camera_feed.BOXES).
|
||
CAMERA_FULL_W: "480"
|
||
CAMERA_FULL_H: "480"
|
||
CAMERA_THUMB_W: "392"
|
||
CAMERA_THUMB_H: "220"
|
||
OVERLAY_SLIDER_W: "144"
|
||
OVERLAY_SLIDER_H: "300"
|
||
OVERLAY_ICON_Y: "148"
|
||
TILE_ICON_X: "0"
|
||
TILE_ICON_Y: "12"
|
||
TILE_TITLE_X: "64"
|
||
TILE_TITLE_Y: "17"
|
||
TILE_VALUE_X: "64"
|
||
TILE_VALUE_Y: "45"
|
||
|
||
# ============================================================
|
||
# No need to edit below this line.
|
||
# All configuration is done via the substitutions above.
|
||
# Credentials are managed in secrets.yaml (see secrets.yaml.example).
|
||
# The Material Design Icons font is included at fonts/materialdesignicons-webfont.ttf (Apache 2.0).
|
||
# Source: https://github.com/Templarian/MaterialDesign-Webfont
|
||
# ============================================================
|
||
external_components:
|
||
- source:
|
||
type: local
|
||
path: components
|
||
components: [smart_display]
|
||
|
||
esphome:
|
||
min_version: 2026.6.2
|
||
project:
|
||
name: maxgramser.esp_screen
|
||
version: "${SCREEN_FIRMWARE_VERSION}"
|
||
platformio_options:
|
||
build_flags:
|
||
- -DLV_USE_SNAPSHOT=1
|
||
- -DLV_USE_SWITCH=1
|
||
includes:
|
||
- <esphome/components/smart_display/cyd_ui.h>
|
||
- <esphome/components/smart_display/light_controls.h>
|
||
- <esphome/components/smart_display/guition_diagnostics.h>
|
||
- <esphome/components/smart_display/alert_overlay.h>
|
||
- <esphome/components/smart_display/settings_screen.h>
|
||
name: ${DEVICE_NAME}
|
||
friendly_name: ${DEVICE_FRIENDLY_NAME}
|
||
on_boot:
|
||
priority: -100
|
||
then:
|
||
- lambda: |-
|
||
cyd::touch_guard.configure(${TOUCH_MOVE_LIMIT_PX}, ${TOUCH_MIN_PRESS_MS});
|
||
cyd::edge_swipe.configure(${DISPLAY_W}, ${DISPLAY_H}, ${EDGE_SWIPE_BAND_PX}, ${EDGE_SWIPE_TRAVEL_PX});
|
||
// The page and the tile container take no presses: the theme's pressed style would
|
||
// otherwise repaint nearly the whole screen twice per touch in the empty edge band,
|
||
// starving the touch polling for the whole length of a quick swipe.
|
||
lv_obj_remove_flag(id(home_page)->obj, LV_OBJ_FLAG_CLICKABLE);
|
||
lv_obj_remove_flag(id(tile_scroll), LV_OBJ_FLAG_CLICKABLE);
|
||
// A stray GT911 contact at exactly (0, 0) never reaches LVGL (cyd::GhostTouch): as the last
|
||
// sample of a drag it sent a slider to its end on release. LVGL still reads ESPHome's callback.
|
||
if (auto *indev = lv_indev_get_next(nullptr)) {
|
||
static lv_indev_read_cb_t esphome_read = nullptr;
|
||
static const lv_indev_read_cb_t filtered = [](lv_indev_t *input, lv_indev_data_t *data) {
|
||
esphome_read(input, data);
|
||
int32_t ghost_x = 0, ghost_y = 0;
|
||
id(screen_lvgl).rotate_coordinates(ghost_x, ghost_y);
|
||
const unsigned dropped = cyd::ghost_touch.dropped();
|
||
const auto read = cyd::ghost_touch.filter({data->state == LV_INDEV_STATE_PRESSED, (int) data->point.x, (int) data->point.y}, ghost_x, ghost_y);
|
||
if (cyd::ghost_touch.dropped() != dropped) ESP_LOGI("touch", "GT911 stray contact at (0,0) ignored");
|
||
data->state = read.pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED;
|
||
data->point.x = read.x;
|
||
data->point.y = read.y;
|
||
};
|
||
if (lv_indev_get_read_cb(indev) != filtered) {
|
||
esphome_read = lv_indev_get_read_cb(indev);
|
||
lv_indev_set_read_cb(indev, filtered);
|
||
}
|
||
}
|
||
id(last_touch_ms) = millis();
|
||
id(last_use_ms) = millis();
|
||
id(display_dimmed) = false;
|
||
- lambda: |-
|
||
auto &settings = screen_settings::current;
|
||
settings.standby_enabled = std::string("${AUTO_DIM}") == "true";
|
||
settings.standby_seconds = atoi("${AUTO_DIM_TIMEOUT}");
|
||
settings.brightness = atoi("${MAX_BRIGHTNESS}");
|
||
settings.standby_brightness = atoi("${DIM_BRIGHTNESS}");
|
||
settings.night_enabled = std::string("${NIGHT_MODE}") == "true";
|
||
settings.night_start = atoi("${NIGHT_START_HOUR}") * 60;
|
||
settings.night_end = atoi("${NIGHT_END_HOUR}") * 60;
|
||
settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}");
|
||
runtime_tiles::watch_font = id(headline)->get_lv_font();
|
||
runtime_tiles::watch_value_font = id(watch_value)->get_lv_font();
|
||
runtime_tiles::watch_icon_font = id(watch_icon)->get_lv_font();
|
||
runtime_tiles::clock_font = id(clock_digits)->get_lv_font();
|
||
runtime_tiles::now_time = []() { return id(homeassistant_time).now(); };
|
||
runtime_tiles::screen_awake = []() { return !id(display_dimmed); };
|
||
runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font();
|
||
runtime_tiles::big_icon_font = id(materialdesign_icons_big)->get_lv_font();
|
||
runtime_tiles::control_font = id(sublabel_big)->get_lv_font();
|
||
runtime_tiles::small_font = id(sublabel)->get_lv_font();
|
||
// Top bar (0.2.32+): values in the text font, icons in the small icon font; the clock
|
||
// label lends its margin and drawing order and stays hidden in runtime mode.
|
||
runtime_tiles::header_text_font = id(sublabel_big)->get_lv_font();
|
||
runtime_tiles::header_icon_font = id(materialdesign_icons_mini)->get_lv_font();
|
||
runtime_tiles::time_label = id(lbl_time);
|
||
runtime_tiles::enabled = true;
|
||
runtime_tiles::rotation_supported = true;
|
||
runtime_tiles::load_settings();
|
||
// Colours (firmware 0.2.54+): the styles of this profile take their colours from theme.h. The list runs now,
|
||
// in the look the screen saved, before anything is drawn, and again after every change of look.
|
||
theme::paints = []() {
|
||
using theme::Paint;
|
||
for (auto *style : {id(style_room), id(style_time), id(style_title), id(style_value_big), id(paint_ink)}) theme::fill(style, Paint::ink);
|
||
theme::fill(id(style_page), Paint::page);
|
||
theme::fill(id(style_tile), Paint::card);
|
||
theme::fill(id(style_icon_circle), Paint::accent_tint);
|
||
theme::fill(id(style_value), Paint::muted);
|
||
theme::fill(id(paint_page_soft), Paint::page_soft);
|
||
theme::fill(id(paint_page_pressed), Paint::page_pressed);
|
||
theme::fill(id(paint_card), Paint::card);
|
||
theme::fill(id(paint_raised), Paint::raised);
|
||
theme::fill(id(paint_key), Paint::key);
|
||
theme::fill(id(paint_key_pressed), Paint::key_pressed);
|
||
theme::fill(id(paint_track), Paint::track);
|
||
theme::fill(id(paint_ink_soft), Paint::ink_soft);
|
||
theme::fill(id(paint_muted), Paint::muted);
|
||
theme::fill(id(paint_subtle), Paint::subtle);
|
||
theme::fill(id(paint_slate), Paint::slate);
|
||
theme::fill(id(paint_scrim), Paint::scrim);
|
||
theme::fill(id(paint_veil), Paint::veil);
|
||
theme::fill(id(paint_accent_tint), Paint::accent_tint);
|
||
theme::fill(id(paint_accent_bright), Paint::accent_bright);
|
||
theme::fill(id(paint_button_dark), Paint::button_dark);
|
||
theme::fill(id(paint_button_dark_pressed), Paint::button_dark_pressed);
|
||
theme::fill(id(paint_camera), Paint::camera);
|
||
};
|
||
theme::paints();
|
||
theme::set_dark(settings_screen::dark_mode != 0);
|
||
runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); };
|
||
runtime_tiles::dismiss = []() {
|
||
runtime_tiles::hide_detail();
|
||
runtime_tiles::camera_close();
|
||
id(active_entity).clear();
|
||
lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN);
|
||
lv_obj_add_flag(id(color_detail_overlay), LV_OBJ_FLAG_HIDDEN);
|
||
lv_obj_add_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN);
|
||
lv_obj_add_flag(id(climate_mode_overlay), LV_OBJ_FLAG_HIDDEN);
|
||
};
|
||
runtime_tiles::layout_changed = []() { id(show_tile_page).execute(); };
|
||
// A quick flick or wipe over the tiles is never a tap (firmware 0.2.65+). LVGL reports it as a gesture that
|
||
// bubbles up to this page (a tile's slider keeps its own, runtime_tiles::bind): the tap under the finger is
|
||
// consumed and LVGL waits for the release. No page flip: pages change with the edge swipe (on_update). LVGL
|
||
// wants speed as well as 50 px of travel, so a firm press that drifts slowly is still a tap. Cards and the
|
||
// settings page keep their drags; in standby the push still wakes the screen, it only switches nothing.
|
||
lv_obj_add_event_cb(id(home_page)->obj, [](lv_event_t *) {
|
||
if (!runtime_tiles::enabled || id(calibration_active) || !id(active_entity).empty() || settings_screen::visible() ||
|
||
(runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN))) return;
|
||
cyd::touch_guard.consume();
|
||
ESP_LOGI("touch", "flick: no tap");
|
||
if (id(display_dimmed)) return;
|
||
if (auto *input = lv_indev_active()) lv_indev_wait_release(input);
|
||
}, LV_EVENT_GESTURE, nullptr);
|
||
// Camera images (firmware 0.2.57+): the two online_images and the alert's frame.
|
||
// set_url() forgets the ETag, so it is only called for a new link: an unchanged picture then answers 304.
|
||
runtime_tiles::camera_full.load = [](const std::string &url) {
|
||
static std::string current;
|
||
if (url != current) { id(camera_image).set_url(url); current = url; }
|
||
id(camera_image).update();
|
||
};
|
||
runtime_tiles::camera_full.release = []() { id(camera_image).release(); };
|
||
runtime_tiles::camera_full.source = []() { return id(camera_image).get_lv_image_dsc(); };
|
||
runtime_tiles::camera_thumb.load = [](const std::string &url) {
|
||
static std::string current;
|
||
if (url != current) { id(alert_image).set_url(url); current = url; }
|
||
id(alert_image).update();
|
||
};
|
||
runtime_tiles::camera_thumb.release = []() { id(alert_image).release(); };
|
||
runtime_tiles::camera_thumb.source = []() { return id(alert_image).get_lv_image_dsc(); };
|
||
runtime_tiles::alert_frame = id(alert_image_frame);
|
||
runtime_tiles::alert_frame_icon = id(alert_image_icon);
|
||
runtime_tiles::alert_room = [](bool image) {
|
||
const int shift = image ? ${ALERT_IMAGE_SHIFT} : 0;
|
||
lv_obj_set_height(id(alert_card), image ? ${ALERT_CARD_H_IMAGE} : ${ALERT_CARD_H});
|
||
lv_obj_set_y(id(alert_icon), ${ALERT_ICON_Y} + shift);
|
||
lv_obj_set_y(id(alert_title), ${ALERT_TITLE_Y} + shift);
|
||
lv_obj_set_y(id(alert_subtitle), ${ALERT_SUBTITLE_Y} + shift);
|
||
lv_obj_set_height(id(alert_subtitle), image ? ${ALERT_SUBTITLE_H_IMAGE} : ${ALERT_SUBTITLE_H});
|
||
};
|
||
runtime_tiles::refresh = []() { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); };
|
||
runtime_tiles::bind(0, id(tile1), id(t1_title), id(t1_value), id(tile1_icon_circle), id(tile1_icon_lbl));
|
||
runtime_tiles::bind(1, id(tile2), id(t2_title), id(t2_value), id(tile2_icon_circle), id(tile2_icon_lbl));
|
||
runtime_tiles::bind(2, id(tile3), id(t3_title), id(t3_value), id(tile3_icon_circle), id(tile3_icon_lbl));
|
||
runtime_tiles::bind(3, id(tile4), id(t4_title), id(t4_value), id(tile4_icon_circle), id(tile4_icon_lbl));
|
||
runtime_tiles::bind(4, id(tile5), id(t5_title), id(t5_value), id(tile5_icon_circle), id(tile5_icon_lbl));
|
||
runtime_tiles::bind(5, id(tile6), id(t6_title), id(t6_value), id(tile6_icon_circle), id(tile6_icon_lbl));
|
||
runtime_tiles::bind(6, id(tile7), id(t7_title), id(t7_value), id(tile7_icon_circle), id(tile7_icon_lbl));
|
||
runtime_tiles::bind(7, id(tile8), id(t8_title), id(t8_value), id(tile8_icon_circle), id(tile8_icon_lbl));
|
||
runtime_tiles::bind(8, id(tile9), id(t9_title), id(t9_value), id(tile9_icon_circle), id(tile9_icon_lbl));
|
||
runtime_tiles::bind(9, id(tile10), id(t10_title), id(t10_value), id(tile10_icon_circle), id(tile10_icon_lbl));
|
||
runtime_tiles::detail = [](runtime_tiles::Tile &t) {
|
||
light_controls::fallback = light_controls::State{};
|
||
light_controls::fallback.hue = t.hue;
|
||
light_controls::fallback.kelvin = t.kelvin;
|
||
light_controls::fallback.minimum = t.min_kelvin;
|
||
light_controls::fallback.maximum = t.max_kelvin;
|
||
id(active_climate_fan_modes) = t.extra().fan_modes; id(active_climate_fan_mode) = t.extra().fan_mode;
|
||
id(active_climate_swing_modes) = t.extra().swing_modes; id(active_climate_swing_mode) = t.extra().swing_mode;
|
||
id(active_climate_action) = t.extra().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),
|
||
"Off", "On", t.modes,
|
||
t.current, t.target, t.humidity, t.state, t.extra().hvac_modes,
|
||
std::to_string(t.minimum), std::to_string(t.maximum), std::to_string(t.step));
|
||
};
|
||
runtime_tiles::detail_update = [](runtime_tiles::Tile &t) {
|
||
if (id(active_entity) != t.entity || id(touch_down)) return;
|
||
if (t.domain() == "climate" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) {
|
||
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.extra().hvac_action;
|
||
id(active_climate_supported_modes) = t.extra().hvac_modes;
|
||
id(active_climate_fan_modes) = t.extra().fan_modes; id(active_climate_fan_mode) = t.extra().fan_mode;
|
||
id(active_climate_swing_modes) = t.extra().swing_modes; id(active_climate_swing_mode) = t.extra().swing_mode;
|
||
id(active_climate_current) = t.current;
|
||
id(active_climate_humidity) = std::isfinite(t.humidity) ? (int) lround(t.humidity) : -1;
|
||
if (std::isfinite(t.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: |-
|
||
light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}"), id(materialdesign_icons_mini)->get_lv_font());
|
||
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);
|
||
};
|
||
# The settings page the screen opens on its own (0.2.44+): the same values ESP Screen Manager
|
||
# writes, reachable by holding the top bar. Fonts and hooks only; the page itself is built
|
||
# when it opens and thrown away when it closes, so it costs nothing while it is closed.
|
||
- lambda: |-
|
||
if (!runtime_tiles::enabled) return;
|
||
settings_screen::title_font = id(headline)->get_lv_font();
|
||
settings_screen::row_font = id(sublabel_big)->get_lv_font();
|
||
settings_screen::icon_font = id(materialdesign_icons_mini)->get_lv_font();
|
||
settings_screen::store = []() { runtime_tiles::persist_settings(); };
|
||
settings_screen::apply = []() { id(apply_screen_settings).execute(); };
|
||
settings_screen::report = [](const char *key, int32_t value) { runtime_tiles::setting_event(key, value); };
|
||
settings_screen::name_text = []() { return std::string("${DEVICE_FRIENDLY_NAME}"); };
|
||
settings_screen::address_text = []() { return id(wifi_ip).state.empty() ? std::string("No network") : id(wifi_ip).state; };
|
||
settings_screen::firmware_text = []() { return std::string("${SCREEN_FIRMWARE_VERSION}"); };
|
||
settings_screen::link_text = []() { return std::string(esphome::api_is_connected() ? "Connected" : "Not connected"); };
|
||
settings_screen::restart_device = []() { ESP_LOGI("settings", "restart from the screen"); App.safe_reboot(); };
|
||
// A dimmed screen, a calibration or an alert owns the glass; the page waits.
|
||
settings_screen::may_open = []() {
|
||
return runtime_tiles::enabled && !id(display_dimmed) && !id(calibration_active) && !id(alert_active) && !runtime_tiles::camera_visible();
|
||
};
|
||
settings_screen::drift = []() { return cyd::touch_guard.distance(); };
|
||
settings_screen::before_open = []() { id(close_cards).execute(); };
|
||
settings_screen::hold_limit = ${SETTINGS_HOLD_DRIFT_PX};
|
||
settings_screen::tap_limit = ${TOUCH_MOVE_LIMIT_PX};
|
||
// What a change of look draws again, now that everything is bound: the tiles, the top bar and an open card,
|
||
// the colour card, the settings page, an alert and the climate cards.
|
||
theme::redraw = []() {
|
||
runtime_tiles::restyle();
|
||
light_controls::restyle();
|
||
settings_screen::restyle();
|
||
if (id(alert_active)) lv_obj_set_style_bg_color(id(alert_card), lv_color_hex(theme::surface(id(alert_card_color))), LV_PART_MAIN);
|
||
if (!lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) id(climate_card_refresh).execute();
|
||
if (!lv_obj_has_flag(id(climate_mode_overlay), LV_OBJ_FLAG_HIDDEN)) id(open_climate_mode_picker).execute();
|
||
};
|
||
// Under the cards: brightness_overlay is the first of them on the page, so the back button and
|
||
// the action at the top right of a card keep their taps.
|
||
settings_screen::attach_hold(id(home_page)->obj, ${SETTINGS_HOLD_X}, 0, ${SETTINGS_HOLD_W}, ${SCROLL_Y}, id(brightness_overlay));
|
||
- light.turn_on:
|
||
id: back_light
|
||
brightness: !lambda "return screen_settings::current.brightness / 100.0f;"
|
||
- script.execute: show_tile_page
|
||
- lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();'
|
||
- if:
|
||
condition:
|
||
lambda: 'return std::string("${CALIBRATION_ON_BOOT}") == "true";'
|
||
then:
|
||
- lambda: 'id(calibration_active) = true;'
|
||
- lvgl.page.show: touch_cal_page
|
||
- lambda: |-
|
||
lv_obj_invalidate(id(touch_cal_page)->obj);
|
||
lv_refr_now(nullptr);
|
||
ESP_LOGI("ui_diag", "GT911 touch test ready; no tile actions");
|
||
|
||
|
||
esp32:
|
||
board: esp32-s3-devkitc-1
|
||
variant: esp32s3
|
||
flash_size: 16MB
|
||
cpu_frequency: 240MHz
|
||
framework:
|
||
type: esp-idf
|
||
version: recommended
|
||
advanced:
|
||
execute_from_psram: true
|
||
|
||
psram:
|
||
mode: octal
|
||
speed: 80MHz
|
||
|
||
logger:
|
||
tx_buffer_size: 2048
|
||
hardware_uart: UART0
|
||
# Set to DEBUG or VERBOSE temporarily for diagnosis. Note: ESPHome logs
|
||
# its configuration lines at CONFIG level, which are invisible at INFO.
|
||
level: DEBUG
|
||
logs:
|
||
sensor: WARN
|
||
text_sensor: WARN
|
||
xpt2046: WARN
|
||
homeassistant.sensor: WARN
|
||
homeassistant.text_sensor: WARN
|
||
|
||
|
||
# Credentials are loaded from secrets.yaml — copy secrets.yaml.example to secrets.yaml and fill in your values.
|
||
api:
|
||
actions:
|
||
- action: ui_snapshot
|
||
then:
|
||
- script.execute: capture_ui_snapshot
|
||
- action: touch_diagnostics
|
||
then:
|
||
- lambda: 'id(calibration_active) = true;'
|
||
- script.execute: wake_display
|
||
- lvgl.page.show: touch_cal_page
|
||
- lambda: |-
|
||
lv_obj_invalidate(id(touch_cal_page)->obj);
|
||
lv_refr_now(nullptr);
|
||
ESP_LOGI("ui_diag", "calibration page active=%d", lv_screen_active() == id(touch_cal_page)->obj);
|
||
- action: end_touch_diagnostics
|
||
then:
|
||
- lambda: 'id(calibration_active) = false;'
|
||
- lvgl.page.show: home_page
|
||
- action: ui_state
|
||
then:
|
||
- lambda: |-
|
||
const auto &cfg = screen_settings::current;
|
||
ESP_LOGI("ui_diag", "settings standby=%d timeout=%d brightness=%d dim=%d night=%d start=%d end=%d night_dim=%d clock=%d clock24=%d home=%d idle_ms=%u use_idle_ms=%u dimmed=%d",
|
||
cfg.standby_enabled, cfg.standby_seconds, cfg.brightness, cfg.standby_brightness,
|
||
cfg.night_enabled, cfg.night_start, cfg.night_end, cfg.night_brightness,
|
||
cfg.show_clock, cfg.clock_24h, cfg.home_on_standby,
|
||
(unsigned)(millis() - id(last_touch_ms)), (unsigned)(millis() - id(last_use_ms)), id(display_dimmed));
|
||
ESP_LOGI("ui_diag", "navigation page=%d runtime_detail=%d", id(tile_page),
|
||
runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN));
|
||
ESP_LOGI("ui_diag", "rotation=%u actual=%u", (unsigned)runtime_tiles::rotation, (unsigned)id(screen_lvgl).get_rotation());
|
||
lv_area_t nav, grid;
|
||
lv_obj_update_layout(id(home_page)->obj);
|
||
lv_obj_get_coords(id(page_next), &nav);
|
||
lv_obj_get_coords(id(tile_scroll), &grid);
|
||
ESP_LOGI("ui_diag", "home=%d calibration=%d grid=%d,%d,%d,%d next=%d,%d,%d,%d",
|
||
lv_screen_active() == id(home_page)->obj, lv_screen_active() == id(touch_cal_page)->obj,
|
||
grid.x1, grid.y1, grid.x2, grid.y2, nav.x1, nav.y1, nav.x2, nav.y2);
|
||
- action: preview_runtime_card
|
||
variables:
|
||
index: int
|
||
then:
|
||
- lambda: |-
|
||
if (index < 0) { runtime_tiles::hide_detail(); if (runtime_tiles::dismiss) runtime_tiles::dismiss(); return; }
|
||
if (runtime_tiles::enabled && index < (int)runtime_tiles::model.count) {
|
||
runtime_tiles::active_index = index;
|
||
auto &tile = runtime_tiles::model.tiles[index];
|
||
auto domain = tile.domain();
|
||
// Lights, fans and climate open the board's own card, like a long press does; covers (firmware 0.2.50+)
|
||
// open the runtime card, as a tap does.
|
||
if ((domain == "light" || domain == "fan" || domain == "climate") && runtime_tiles::detail) {
|
||
tile.begin(millis(), true);
|
||
runtime_tiles::detail(tile);
|
||
return;
|
||
}
|
||
runtime_tiles::show_detail(index);
|
||
}
|
||
# A camera full screen from Home Assistant (firmware 0.2.57+), as a tap on its tile opens it; an empty entity closes
|
||
# it. Only a camera on the screen's tiles or in a recent alert gets an image from ESP Screens.
|
||
- action: preview_camera
|
||
variables:
|
||
entity: string
|
||
then:
|
||
- lambda: |-
|
||
if (entity.empty()) { runtime_tiles::camera_close(); return; }
|
||
id(last_use_ms) = millis();
|
||
id(wake_display).execute();
|
||
std::string name = entity;
|
||
for (size_t i = 0; i < runtime_tiles::model.count; ++i)
|
||
if (runtime_tiles::model.tiles[i].entity == entity) { name = runtime_tiles::model.tiles[i].name; break; }
|
||
runtime_tiles::camera_open(entity, name);
|
||
# The settings page from Home Assistant: `page` 0 is its menu, 1-4 open a group straight away
|
||
# (Brightness, Night, Screen, This screen) and a negative page closes it and goes back to page 1.
|
||
# Handy for support ("look at Screen -> Rotation") and for the render tests.
|
||
- action: open_settings
|
||
variables:
|
||
page: int
|
||
then:
|
||
- lambda: |-
|
||
if (page < 0) { id(go_home).execute(); return; }
|
||
// Opened for someone to look at: Back to page 1 counts from now, as after a touch.
|
||
id(last_use_ms) = millis();
|
||
id(wake_display).execute();
|
||
settings_screen::open((uint8_t) page);
|
||
ESP_LOGI("settings", "page %d open from Home Assistant", page);
|
||
|
||
# Diagnostics: the climate mode picker and the busy sheet of a tile, without touching the glass or HA.
|
||
- action: preview_climate_picker
|
||
then:
|
||
- script.execute: open_climate_mode_picker
|
||
- action: preview_busy
|
||
variables:
|
||
index: int
|
||
then:
|
||
- lambda: |-
|
||
if (runtime_tiles::enabled && index >= 0 && index < (int)runtime_tiles::model.count) {
|
||
runtime_tiles::model.tiles[index].begin(millis());
|
||
if (runtime_tiles::refresh) runtime_tiles::refresh();
|
||
}
|
||
- action: light_controls_preview
|
||
then:
|
||
- script.execute: light_controls_preview
|
||
- action: ui_self_test
|
||
then:
|
||
- script.execute: ui_self_test
|
||
# Home Assistant automations: esphome.<device>_show_alert puts a card over the whole
|
||
# screen (README, "Alert from an automation"); dismiss_alert removes it again.
|
||
- action: show_alert
|
||
variables:
|
||
title: string
|
||
subtitle: string
|
||
icon: string
|
||
color: string
|
||
button_text: string
|
||
timeout: int
|
||
flash: bool
|
||
then:
|
||
- script.execute:
|
||
id: alert_show
|
||
title: !lambda 'return title;'
|
||
subtitle: !lambda 'return subtitle;'
|
||
icon: !lambda 'return icon;'
|
||
color: !lambda 'return color;'
|
||
button_text: !lambda 'return button_text;'
|
||
timeout: !lambda 'return timeout;'
|
||
flash: !lambda 'return flash;'
|
||
- action: dismiss_alert
|
||
then:
|
||
- script.execute:
|
||
id: alert_dismiss
|
||
reason: "remote"
|
||
# ESP Screen Manager 0.2.39+ hands a whole tile message over in one call instead of
|
||
# base64 chunks in the text inbox; the inbox keeps showing the result.
|
||
# Firmware 0.2.49+ also answers a caller that asks for it (app 0.2.57+ does for its pings): the same
|
||
# status as the inbox, plus the revision of the layout on the screen. A call without a call id
|
||
# (older apps) gets no answer, so ESPHome has nothing to warn about.
|
||
- action: screen_message
|
||
supports_response: optional
|
||
variables:
|
||
message: string
|
||
then:
|
||
- lambda: 'id(dashboard_inbox).publish_state(runtime_tiles::receive(message));'
|
||
- if:
|
||
condition:
|
||
lambda: 'return call_id != 0;'
|
||
then:
|
||
- api.respond:
|
||
data: !lambda |-
|
||
root["status"] = id(dashboard_inbox).state;
|
||
root["rev"] = runtime_tiles::layout_rev;
|
||
encryption:
|
||
key: !secret api_encryption_key
|
||
|
||
ota:
|
||
- platform: esphome
|
||
password: !secret ota_password
|
||
|
||
wifi:
|
||
ssid: !secret wifi_ssid
|
||
password: !secret wifi_password
|
||
# Mains-powered panel: no modem sleep, so HA updates and OTA arrive without beacon latency.
|
||
power_save_mode: none
|
||
ap:
|
||
ssid: "Smartdisplay Fallback Hotspot"
|
||
password: !secret ap_password
|
||
|
||
captive_portal:
|
||
|
||
# Camera images (firmware 0.2.57+): ESP Screen Manager serves each image already sized for this screen, as an uncompressed
|
||
# BMP on its own port on the LAN (screen_manager/app/camera_feed.py). ESPHome decodes a BMP piece by piece as it arrives,
|
||
# 4 KB per loop, where a JPEG of the full screen held the loop for 0.6 s; touch and drawing keep their turn. One image full
|
||
# screen and one in an alert's frame; runtime_tiles.h decides when they load and frees them when they close. Plain HTTP
|
||
# on the LAN, no certificates.
|
||
http_request:
|
||
timeout: 4s
|
||
verify_ssl: false
|
||
|
||
# The top-level online_image form that ESPHome 2026.6.2 (min_version) knows; 2026.7+ still reads it and suggests
|
||
# `image: - platform: online_image`, which this profile can use once min_version reaches 2026.7.
|
||
online_image:
|
||
- id: camera_image
|
||
url: "http://127.0.0.1/camera.bmp" # set when a camera opens
|
||
format: BMP
|
||
type: RGB565
|
||
buffer_size: 4096
|
||
on_download_finished:
|
||
- lambda: 'runtime_tiles::camera_loaded(false, cached);'
|
||
on_error:
|
||
- lambda: 'runtime_tiles::camera_failed(false);'
|
||
- id: alert_image
|
||
url: "http://127.0.0.1/camera.bmp" # set when an alert brings an image
|
||
format: BMP
|
||
type: RGB565
|
||
buffer_size: 4096
|
||
on_download_finished:
|
||
- lambda: 'runtime_tiles::camera_loaded(true, cached);'
|
||
on_error:
|
||
- lambda: 'runtime_tiles::camera_failed(true);'
|
||
|
||
interval:
|
||
- interval: 250ms
|
||
then:
|
||
- lambda: |-
|
||
runtime_tiles::tick();
|
||
runtime_tiles::camera_tick();
|
||
- interval: 1s
|
||
then:
|
||
- if:
|
||
condition:
|
||
lambda: 'return id(climate_edit_pending) && !id(touch_down) && millis() - id(climate_edit_ms) >= 3000;'
|
||
then:
|
||
# The edit window is over: show what Home Assistant reports now. A closed card has
|
||
# nothing to show, and a flag left set would redraw every tile each second.
|
||
- lambda: |-
|
||
if (lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) { id(climate_edit_pending) = false; return; }
|
||
if (!runtime_tiles::enabled) { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); return; }
|
||
// Runtime tiles: a state that arrived during the window was held back.
|
||
id(climate_edit_pending) = false;
|
||
if (runtime_tiles::detail_update && runtime_tiles::active_index >= 0 &&
|
||
runtime_tiles::active_index < (int) runtime_tiles::model.count)
|
||
runtime_tiles::detail_update(runtime_tiles::model.tiles[runtime_tiles::active_index]);
|
||
- if:
|
||
condition:
|
||
lambda: |-
|
||
if (!screen_settings::current.standby_enabled) return false;
|
||
if (id(display_dimmed) || id(touch_down) || id(calibration_active) || id(alert_active)) return false;
|
||
return (millis() - id(last_touch_ms)) > (screen_settings::current.standby_seconds * 1000UL);
|
||
then:
|
||
- script.execute: dim_display
|
||
- script.execute: back_to_page_1_when_due
|
||
|
||
time:
|
||
- platform: homeassistant
|
||
id: homeassistant_time
|
||
# Refresh immediately on time sync; do not wait for an HA entity update.
|
||
update_interval: 60s
|
||
on_time_sync:
|
||
then:
|
||
- script.execute: apply_screen_settings
|
||
- lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();'
|
||
on_time:
|
||
- seconds: 0
|
||
minutes: /1
|
||
then:
|
||
- script.execute: apply_screen_settings
|
||
- lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();'
|
||
|
||
globals:
|
||
# Alert overlay (HA action show_alert): standby waits while one is up.
|
||
- id: alert_active
|
||
type: bool
|
||
initial_value: 'false'
|
||
- id: alert_title_text
|
||
type: std::string
|
||
# The alert's colour as the automation named it (a swatch's light value, 0 for the normal card).
|
||
- id: alert_card_color
|
||
type: uint32_t
|
||
initial_value: '0'
|
||
- id: alert_timeout_seconds
|
||
type: int
|
||
initial_value: '0'
|
||
- id: alert_flash_requested
|
||
type: bool
|
||
initial_value: 'false'
|
||
- id: rendered_frames
|
||
type: uint32_t
|
||
initial_value: '0'
|
||
- id: calibration_active
|
||
type: bool
|
||
initial_value: 'false'
|
||
- id: tile_page
|
||
type: int
|
||
initial_value: '0'
|
||
- id: touch_down
|
||
type: bool
|
||
initial_value: 'false'
|
||
- id: climate_edit_ms
|
||
type: uint32_t
|
||
initial_value: '0'
|
||
- id: climate_edit_pending
|
||
type: bool
|
||
initial_value: 'false'
|
||
- id: active_entity
|
||
type: std::string
|
||
restore_value: no
|
||
|
||
- id: active_control_kind
|
||
type: std::string
|
||
restore_value: no
|
||
|
||
- id: active_value_suffix
|
||
type: std::string
|
||
restore_value: no
|
||
|
||
# Two clocks. last_touch_ms is the standby time: a touch, Wake, an alert ending and Auto standby on restart it.
|
||
# last_use_ms is Back to page 1 (firmware 0.2.56+): only a touch, or Home Assistant opening the settings page.
|
||
- id: last_touch_ms
|
||
type: uint32_t
|
||
restore_value: no
|
||
initial_value: '0'
|
||
|
||
- id: last_use_ms
|
||
type: uint32_t
|
||
restore_value: no
|
||
initial_value: '0'
|
||
|
||
# Debounce for do_tile_action: protects against repeated events from
|
||
# multiple separate press/release cycles for one physical touch (noise in
|
||
# the resistive layer), causing the same HA action to fire multiple times
|
||
# within a few hundred ms (observed: script.ambient_morning 6x in 8s).
|
||
- id: last_action_entity
|
||
type: std::string
|
||
restore_value: no
|
||
|
||
- id: last_action_ms
|
||
type: uint32_t
|
||
restore_value: no
|
||
initial_value: '0'
|
||
|
||
- id: display_dimmed
|
||
type: bool
|
||
restore_value: no
|
||
initial_value: 'false'
|
||
|
||
# Sleep from Home Assistant (0.2.45+): standby that also holds with Auto standby off,
|
||
# until a tap, Wake or an alert wakes the screen.
|
||
- id: sleep_requested
|
||
type: bool
|
||
restore_value: no
|
||
initial_value: 'false'
|
||
|
||
- id: active_label_off
|
||
type: std::string
|
||
restore_value: no
|
||
|
||
- id: active_label_on
|
||
type: std::string
|
||
restore_value: no
|
||
|
||
- id: active_title
|
||
type: std::string
|
||
restore_value: no
|
||
|
||
- id: active_icon_text
|
||
type: std::string
|
||
restore_value: no
|
||
|
||
- id: active_brightness_pct
|
||
type: int
|
||
restore_value: no
|
||
initial_value: '100'
|
||
|
||
- id: active_supports_color
|
||
type: bool
|
||
restore_value: no
|
||
initial_value: 'false'
|
||
|
||
- id: active_supports_temperature
|
||
type: bool
|
||
restore_value: no
|
||
initial_value: 'false'
|
||
|
||
- id: active_color_detail_tab
|
||
type: int
|
||
restore_value: no
|
||
initial_value: '0'
|
||
|
||
- id: active_climate_current_x10
|
||
type: int
|
||
restore_value: no
|
||
initial_value: '0'
|
||
|
||
- id: active_climate_target_x10
|
||
type: int
|
||
restore_value: no
|
||
initial_value: '200'
|
||
|
||
- id: active_climate_humidity
|
||
type: int
|
||
restore_value: no
|
||
initial_value: '-1'
|
||
|
||
- id: active_climate_min_x10
|
||
type: int
|
||
restore_value: no
|
||
initial_value: '50'
|
||
|
||
- id: active_climate_max_x10
|
||
type: int
|
||
restore_value: no
|
||
initial_value: '300'
|
||
|
||
- id: active_climate_step_x10
|
||
type: int
|
||
restore_value: no
|
||
initial_value: '5'
|
||
|
||
- id: active_climate_mode
|
||
type: std::string
|
||
restore_value: no
|
||
|
||
- id: active_climate_power_on
|
||
type: bool
|
||
restore_value: no
|
||
initial_value: 'false'
|
||
|
||
- id: active_climate_fan_modes
|
||
type: std::string
|
||
restore_value: no
|
||
|
||
- id: active_climate_fan_mode
|
||
type: std::string
|
||
restore_value: no
|
||
|
||
- id: active_climate_swing_modes
|
||
type: std::string
|
||
restore_value: no
|
||
|
||
- id: active_climate_swing_mode
|
||
type: std::string
|
||
restore_value: no
|
||
|
||
- id: active_climate_supported_modes
|
||
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:
|
||
# 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: ['-','.','°','0','1','2','3','4','5','6','7','8','9']
|
||
- file: "fonts/Roboto-500.ttf"
|
||
id: time_label
|
||
size: 27
|
||
bpp: 4
|
||
glyphs: ['0','1','2','3','4','5','6','7','8','9',':',' ', 'A', 'M', 'P']
|
||
|
||
- file: "fonts/Roboto-500.ttf"
|
||
id: headline
|
||
size: 27
|
||
bpp: 4
|
||
glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°',
|
||
'0','1','2','3','4','5','6','7','8','9',
|
||
'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',
|
||
' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
|
||
'ä','ö','ü','Ä','Ö','Ü','ß','/','·']
|
||
|
||
- file: "fonts/Roboto-500.ttf"
|
||
id: watch_value
|
||
size: 38
|
||
bpp: 4
|
||
glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°',
|
||
'0','1','2','3','4','5','6','7','8','9',
|
||
'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',
|
||
' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
|
||
'ä','ö','ü','Ä','Ö','Ü','ß','/','·']
|
||
|
||
# Clock card digits only; the date line reuses the tile title font.
|
||
- file: "fonts/Roboto-500.ttf"
|
||
id: clock_digits
|
||
size: 48
|
||
bpp: 4
|
||
glyphs: [':','-','0','1','2','3','4','5','6','7','8','9']
|
||
|
||
- file: "fonts/Roboto-700.ttf"
|
||
id: label
|
||
size: 18
|
||
bpp: 4
|
||
glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°',
|
||
'0','1','2','3','4','5','6','7','8','9',
|
||
'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',
|
||
' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
|
||
'ä','ö','ü','Ä','Ö','Ü','ß','/','·']
|
||
|
||
- file: "fonts/Roboto-400.ttf"
|
||
id: sublabel
|
||
size: 16
|
||
bpp: 4
|
||
glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°',
|
||
'0','1','2','3','4','5','6','7','8','9',
|
||
'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',
|
||
' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
|
||
'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·']
|
||
|
||
- file: "fonts/Roboto-400.ttf"
|
||
id: sublabel_big
|
||
size: 21
|
||
bpp: 4
|
||
glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°',
|
||
'0','1','2','3','4','5','6','7','8','9',
|
||
'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',
|
||
' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
|
||
'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·',"'",'#','*','=',';','²','³','µ','é','è','ë','ê','ï','î','ó','ô','à','á','â','ç','ñ','ú','û','–']
|
||
|
||
# Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0)
|
||
# Source: https://github.com/Templarian/MaterialDesign-Webfont
|
||
- file: 'fonts/materialdesignicons-webfont.ttf'
|
||
id: materialdesign_icons
|
||
size: 42
|
||
bpp: 4
|
||
glyphs: &tile_icons # GENERATED by tools/generate_icons.py from screen_manager/app/tile_icons.py
|
||
- "\U000F0335" # lightbulb
|
||
- "\U000F1253" # lightbulb-group
|
||
- "\U000F0769" # ceiling-light
|
||
- "\U000F1793" # chandelier
|
||
- "\U000F06B5" # lamp
|
||
- "\U000F08DD" # floor-lamp
|
||
- "\U000F095F" # desk-lamp
|
||
- "\U000F091D" # wall-sconce-flat
|
||
- "\U000F179B" # light-recessed
|
||
- "\U000F04C9" # spotlight-beam
|
||
- "\U000F1051" # led-strip-variant
|
||
- "\U000F1054" # outdoor-lamp
|
||
- "\U000F02DC" # home
|
||
- "\U000F04B9" # sofa
|
||
- "\U000F02E3" # bed
|
||
- "\U000F0A70" # silverware-fork-knife
|
||
- "\U000F0B7C" # chef-hat
|
||
- "\U000F09A0" # shower
|
||
- "\U000F1818" # bathtub
|
||
- "\U000F09AB" # toilet
|
||
- "\U000F1239" # desk
|
||
- "\U000F06D9" # garage
|
||
- "\U000F0E2E" # fireplace
|
||
- "\U000F050F" # thermometer
|
||
- "\U000F0393" # thermostat
|
||
- "\U000F001B" # air-conditioner
|
||
- "\U000F0438" # radiator
|
||
- "\U000F1A43" # heat-pump
|
||
- "\U000F0238" # fire
|
||
- "\U000F0717" # snowflake
|
||
- "\U000F058E" # water-percent
|
||
- "\U000F0210" # fan
|
||
- "\U000F1797" # ceiling-fan
|
||
- "\U000F0D44" # air-purifier
|
||
- "\U000F07E4" # molecule-co2
|
||
- "\U000F0599" # weather-sunny
|
||
- "\U000F0595" # weather-partly-cloudy
|
||
- "\U000F0590" # weather-cloudy
|
||
- "\U000F0597" # weather-rainy
|
||
- "\U000F0598" # weather-snowy
|
||
- "\U000F059D" # weather-windy
|
||
- "\U000F0594" # weather-night
|
||
- "\U000F059C" # weather-sunset-up
|
||
- "\U000F0387" # music-note
|
||
- "\U000F075A" # music
|
||
- "\U000F0CB8" # playlist-music
|
||
- "\U000F0025" # album
|
||
- "\U000F099A" # record-player
|
||
- "\U000F04C7" # spotify
|
||
- "\U000F0439" # radio
|
||
- "\U000F0994" # podcast
|
||
- "\U000F04C3" # speaker
|
||
- "\U000F0D38" # speaker-multiple
|
||
- "\U000F17DB" # soundbar
|
||
- "\U000F02CB" # headphones
|
||
- "\U000F067D" # piano
|
||
- "\U000F0771" # guitar-acoustic
|
||
- "\U000F0502" # television
|
||
- "\U000F042E" # projector
|
||
- "\U000F0FCE" # movie-open
|
||
- "\U000F0118" # cast
|
||
- "\U000F0297" # gamepad-variant
|
||
- "\U000F0EC5" # remote-tv
|
||
- "\U000F033E" # lock
|
||
- "\U000F0FC6" # lock-open-variant
|
||
- "\U000F068A" # shield-home
|
||
- "\U000F078F" # alarm-light
|
||
- "\U000F009A" # bell
|
||
- "\U000F12E6" # doorbell
|
||
- "\U000F07AE" # cctv
|
||
- "\U000F0D91" # motion-sensor
|
||
- "\U000F081B" # door-closed
|
||
- "\U000F081C" # door-open
|
||
- "\U000F11DB" # window-closed-variant
|
||
- "\U000F0392" # smoke-detector
|
||
- "\U000F1502" # water-alert
|
||
- "\U000F072A" # washing-machine
|
||
- "\U000F0917" # tumble-dryer
|
||
- "\U000F0AAC" # dishwasher
|
||
- "\U000F0290" # fridge
|
||
- "\U000F04DE" # stove
|
||
- "\U000F0C99" # microwave
|
||
- "\U000F0CD3" # toaster-oven
|
||
- "\U000F109F" # coffee-maker
|
||
- "\U000F05FA" # kettle
|
||
- "\U000F070D" # robot-vacuum
|
||
- "\U000F11F7" # robot-mower
|
||
- "\U000F042B" # printer-3d
|
||
- "\U000F06A5" # power-plug
|
||
- "\U000F07E7" # power-socket-eu
|
||
- "\U000F0322" # laptop
|
||
- "\U000F0469" # router-wireless
|
||
- "\U000F0241" # flash
|
||
- "\U000F0A72" # solar-power
|
||
- "\U000F0D9B" # solar-panel
|
||
- "\U000F1901" # home-battery
|
||
- "\U000F12A3" # battery-high
|
||
- "\U000F05F1" # ev-station
|
||
- "\U000F0B6C" # car-electric
|
||
- "\U000F1A57" # meter-electric
|
||
- "\U000F1A59" # meter-gas
|
||
- "\U000F058C" # water
|
||
- "\U000F0F92" # water-boiler
|
||
- "\U000F029A" # gauge
|
||
- "\U000F111C" # window-shutter
|
||
- "\U000F111E" # window-shutter-open
|
||
- "\U000F00AC" # blinds
|
||
- "\U000F1011" # blinds-open
|
||
- "\U000F1A6C" # roller-shade-closed
|
||
- "\U000F1846" # curtains
|
||
- "\U000F1847" # curtains-closed
|
||
- "\U000F1B88" # awning-outline
|
||
- "\U000F024A" # flower
|
||
- "\U000F0531" # tree
|
||
- "\U000F0E66" # sprout
|
||
- "\U000F1510" # grass
|
||
- "\U000F105F" # sprinkler
|
||
- "\U000F0606" # pool
|
||
- "\U000F0E45" # grill
|
||
- "\U000F06EE" # mailbox
|
||
- "\U000F0A79" # trash-can
|
||
- "\U000F044C" # recycle
|
||
- "\U000F0A43" # dog
|
||
- "\U000F011B" # cat
|
||
- "\U000F0004" # account
|
||
- "\U000F0849" # account-group
|
||
- "\U000F0A89" # account-child
|
||
- "\U000F0826" # home-account
|
||
- "\U000F04B2" # sleep
|
||
- "\U000F070E" # run
|
||
- "\U000F00D6" # briefcase
|
||
- "\U000F010B" # car
|
||
- "\U000F00A3" # bike
|
||
- "\U000F001D" # airplane
|
||
- "\U000F034E" # map-marker
|
||
- "\U000F0425" # power
|
||
- "\U000F0521" # toggle-switch
|
||
- "\U000F12A8" # gesture-tap-button
|
||
- "\U000F040A" # play
|
||
- "\U000F0BC2" # script-text
|
||
- "\U000F03D8" # palette
|
||
- "\U000F0020" # alarm
|
||
- "\U000F0150" # clock-outline
|
||
- "\U000F051B" # timer-outline
|
||
- "\U000F00ED" # calendar
|
||
- "\U000F009E" # bell-ring
|
||
- "\U000F02D1" # heart
|
||
- "\U000F002A" # alert-outline
|
||
- "\U000F0493" # cog
|
||
- "\U000F00E2" # broom
|
||
- "\U000F05D6" # alert-circle-outline
|
||
- "\U000F012C" # check
|
||
- "\U000F03E4" # pause
|
||
- "\U000F04DB" # stop
|
||
- "\U000F04AD" # skip-next
|
||
- "\U000F04AE" # skip-previous
|
||
- "\U000F057E" # volume-high
|
||
- "\U000F0581" # volume-off
|
||
- "\U000F005D" # arrow-up
|
||
- "\U000F0045" # arrow-down
|
||
- "\U000F084E" # arrow-expand-horizontal
|
||
- "\U000F084C" # arrow-collapse-horizontal
|
||
- "\U000F05F8" # home-map-marker
|
||
- "\U000F0415" # plus
|
||
- "\U000F0374" # minus
|
||
- "\U000F0141" # chevron-left
|
||
- "\U000F0142" # chevron-right
|
||
- "\U000F0156" # close
|
||
- "\U000F004D" # arrow-left
|
||
- "\U000F1A79" # sun-snowflake-variant
|
||
- "\U000F1B17" # thermostat-auto
|
||
- "\U000F0591" # weather-fog
|
||
- "\U000F0592" # weather-hail
|
||
- "\U000F0593" # weather-lightning
|
||
- "\U000F067E" # weather-lightning-rainy
|
||
- "\U000F0596" # weather-pouring
|
||
- "\U000F067F" # weather-snowy-rainy
|
||
- "\U000F059B" # weather-sunset-down
|
||
- "\U000F01D8" # dots-horizontal
|
||
- "\U000F1C91" # arrow-oscillating
|
||
- "\U000F0379" # monitor
|
||
- "\U000F02FD" # information-outline
|
||
- "\U000F0709" # restart
|
||
- "\U000F0E4F" # lightbulb-off
|
||
- "\U000F0B53" # account-arrow-right
|
||
- "\U000F0D43" # air-filter
|
||
- "\U000F1099" # air-humidifier
|
||
- "\U000F1466" # air-humidifier-off
|
||
- "\U000F0028" # alert-circle
|
||
- "\U000F0937" # angle-acute
|
||
- "\U000F0042" # arrow-bottom-left
|
||
- "\U000F0043" # arrow-bottom-right
|
||
- "\U000F06C0" # arrow-down-box
|
||
- "\U000F0E73" # arrow-left-right
|
||
- "\U000F0054" # arrow-right
|
||
- "\U000F093C" # arrow-split-vertical
|
||
- "\U000F005B" # arrow-top-left
|
||
- "\U000F005C" # arrow-top-right
|
||
- "\U000F06C3" # arrow-up-box
|
||
- "\U000F093D" # audio-video
|
||
- "\U000F11B6" # audio-video-off
|
||
- "\U000F0079" # battery
|
||
- "\U000F007A" # battery-10
|
||
- "\U000F007B" # battery-20
|
||
- "\U000F007C" # battery-30
|
||
- "\U000F007D" # battery-40
|
||
- "\U000F007E" # battery-50
|
||
- "\U000F007F" # battery-60
|
||
- "\U000F0080" # battery-70
|
||
- "\U000F0081" # battery-80
|
||
- "\U000F0082" # battery-90
|
||
- "\U000F0083" # battery-alert
|
||
- "\U000F0084" # battery-charging
|
||
- "\U000F008E" # battery-outline
|
||
- "\U000F0091" # battery-unknown
|
||
- "\U000F1A2B" # blinds-horizontal
|
||
- "\U000F1A2C" # blinds-horizontal-closed
|
||
- "\U000F00AF" # bluetooth
|
||
- "\U000F00B1" # bluetooth-connect
|
||
- "\U000F00DE" # brightness-5
|
||
- "\U000F00E0" # brightness-7
|
||
- "\U000F1B50" # button-pointer
|
||
- "\U000F010C" # car-battery
|
||
- "\U000F1019" # car-coolant-level
|
||
- "\U000F0114" # cash
|
||
- "\U000F0119" # cast-connected
|
||
- "\U000F078A" # cast-off
|
||
- "\U000F05E0" # check-circle
|
||
- "\U000F05E1" # check-circle-outline
|
||
- "\U000F0C54" # check-network-outline
|
||
- "\U000F0133" # checkbox-marked-circle
|
||
- "\U000F0AA5" # circle-slice-8
|
||
- "\U000F0954" # clock
|
||
- "\U000F0155" # clock-start
|
||
- "\U000F015A" # close-circle-outline
|
||
- "\U000F0C5F" # close-network-outline
|
||
- "\U000F1382" # compass-rose
|
||
- "\U000F01A1" # crop-portrait
|
||
- "\U000F1136" # crosshairs-question
|
||
- "\U000F1480" # current-ac
|
||
- "\U000F01BC" # database
|
||
- "\U000F07C5" # ear-hearing
|
||
- "\U000F0208" # eye
|
||
- "\U000F0D04" # eye-check
|
||
- "\U000F081D" # fan-off
|
||
- "\U000F0279" # format-list-bulleted
|
||
- "\U000F06DA" # garage-open
|
||
- "\U000F0299" # gate
|
||
- "\U000F116A" # gate-open
|
||
- "\U000F06A1" # home-outline
|
||
- "\U000F0318" # lan-connect
|
||
- "\U000F0319" # lan-disconnect
|
||
- "\U000F140B" # lightning-bolt
|
||
- "\U000F08EE" # lock-alert
|
||
- "\U000F097F" # lock-clock
|
||
- "\U000F033F" # lock-open
|
||
- "\U000F0BAC" # molecule
|
||
- "\U000F12FE" # molecule-co
|
||
- "\U000F1435" # motion-sensor-off
|
||
- "\U000F038A" # music-note-off
|
||
- "\U000F03C3" # octagon
|
||
- "\U000F03D3" # package
|
||
- "\U000F03D5" # package-up
|
||
- "\U000F17C5" # ph
|
||
- "\U000F184D" # pipe-valve
|
||
- "\U000F06A6" # power-plug-off
|
||
- "\U000F0996" # progress-clock
|
||
- "\U000F1A23" # projector-off
|
||
- "\U000F043C" # radioactive
|
||
- "\U000F0445" # ray-vertex
|
||
- "\U000F1A6B" # roller-shade
|
||
- "\U000F1727" # script-text-play
|
||
- "\U000F0483" # security
|
||
- "\U000F0498" # shield
|
||
- "\U000F06BB" # shield-airplane
|
||
- "\U000F099D" # shield-lock
|
||
- "\U000F1828" # shield-moon
|
||
- "\U000F099E" # shield-off
|
||
- "\U000F0499" # shield-outline
|
||
- "\U000F095B" # sine-wave
|
||
- "\U000F192E" # smoke-detector-alert
|
||
- "\U000F180B" # smoke-detector-variant
|
||
- "\U000F1930" # smoke-detector-variant-alert
|
||
- "\U000F04C4" # speaker-off
|
||
- "\U000F1B73" # speaker-pause
|
||
- "\U000F1B72" # speaker-play
|
||
- "\U000F04C5" # speedometer
|
||
- "\U000F1429" # spoon-sugar
|
||
- "\U000F0E67" # sprout-outline
|
||
- "\U000F0764" # square
|
||
- "\U000F0763" # square-outline
|
||
- "\U000F1A75" # storage-tank
|
||
- "\U000F17FE" # sun-wireless
|
||
- "\U000F083B" # television-off
|
||
- "\U000F0F89" # television-pause
|
||
- "\U000F0ECF" # television-play
|
||
- "\U000F0FE6" # texture-box
|
||
- "\U000F0510" # thermometer-lines
|
||
- "\U000F1A25" # toggle-switch-variant
|
||
- "\U000F1A26" # toggle-switch-variant-off
|
||
- "\U000F0D3E" # transmission-tower
|
||
- "\U000F0566" # vibrate
|
||
- "\U000F11B4" # water-boiler-off
|
||
- "\U000F058D" # water-off
|
||
- "\U000F1855" # water-opacity
|
||
- "\U000F05A1" # weight
|
||
- "\U000F05A9" # wifi
|
||
- "\U000F05AE" # window-closed
|
||
- "\U000F05B1" # window-open
|
||
|
||
- file: 'fonts/materialdesignicons-webfont.ttf'
|
||
id: materialdesign_icons_mini
|
||
size: 26
|
||
bpp: 4
|
||
glyphs: *tile_icons
|
||
|
||
# The large icon of a card that takes the whole page (firmware 0.2.62+): the domain icons only,
|
||
# so the flash stays free for other things; a chosen icon outside this set keeps its usual size.
|
||
- file: 'fonts/materialdesignicons-webfont.ttf'
|
||
id: materialdesign_icons_big
|
||
size: 64
|
||
bpp: 4
|
||
glyphs: &tile_icons_big # GENERATED by tools/generate_icons.py from screen_manager/app/tile_icons.py
|
||
- "\U000F0335" # lightbulb
|
||
- "\U000F001B" # air-conditioner
|
||
- "\U000F070D" # robot-vacuum
|
||
- "\U000F0210" # fan
|
||
- "\U000F111C" # window-shutter
|
||
- "\U000F04B9" # sofa
|
||
- "\U000F029A" # gauge
|
||
- "\U000F051B" # timer-outline
|
||
- "\U000F0004" # account
|
||
- "\U000F07AE" # cctv
|
||
- "\U000F0150" # clock-outline
|
||
- "\U000F0493" # cog
|
||
- "\U000F0054" # arrow-right
|
||
- "\U000F0E4F" # lightbulb-off
|
||
- "\U000F0425" # power
|
||
- "\U000F0599" # weather-sunny
|
||
- "\U000F0594" # weather-night
|
||
- "\U000F0590" # weather-cloudy
|
||
- "\U000F0595" # weather-partly-cloudy
|
||
- "\U000F0597" # weather-rainy
|
||
- "\U000F0596" # weather-pouring
|
||
- "\U000F0598" # weather-snowy
|
||
- "\U000F067F" # weather-snowy-rainy
|
||
- "\U000F0591" # weather-fog
|
||
- "\U000F0592" # weather-hail
|
||
- "\U000F0593" # weather-lightning
|
||
- "\U000F067E" # weather-lightning-rainy
|
||
- "\U000F059D" # weather-windy
|
||
- "\U000F05D6" # alert-circle-outline
|
||
- "\U000F059C" # weather-sunset-up
|
||
- "\U000F059B" # weather-sunset-down
|
||
|
||
- file: 'fonts/materialdesignicons-webfont.ttf'
|
||
id: watch_icon
|
||
size: 18
|
||
bpp: 4
|
||
glyphs: *tile_icons
|
||
|
||
# Make memory visible: this board has no PSRAM, so the free heap determines
|
||
# how much UI still fits. Also shows up as an entity in Home Assistant.
|
||
debug:
|
||
# Heap figures every five minutes: enough to spot a leak, few recorder rows.
|
||
update_interval: 300s
|
||
|
||
sensor:
|
||
- platform: debug
|
||
free:
|
||
name: "Heap Free"
|
||
id: heap_free
|
||
on_value:
|
||
- lambda: 'ESP_LOGI("health", "free_heap=%.0f", x);'
|
||
fragmentation:
|
||
name: "Heap Fragmentation"
|
||
block:
|
||
name: "Heap Largest Block"
|
||
on_value:
|
||
- lambda: 'ESP_LOGI("health", "largest_block=%.0f", x);'
|
||
# The lowest free heap since boot: what the busiest moment (boot sync, a swipe) left over.
|
||
min_free:
|
||
name: "Heap Minimum Free"
|
||
on_value:
|
||
- lambda: 'ESP_LOGI("health", "min_free=%.0f", x);'
|
||
- platform: wifi_signal
|
||
name: "Wifi Signal"
|
||
update_interval: 600s
|
||
|
||
# When the screen booted, as a timestamp: one value per boot instead of a new recorder
|
||
# row every 15 s. A new entity on purpose: Home Assistant keeps the old Uptime's unit
|
||
# "s" in its registry and would refuse a timestamp under that name.
|
||
- platform: uptime
|
||
type: timestamp
|
||
name: "Last boot"
|
||
id: boot_time
|
||
|
||
|
||
binary_sensor:
|
||
- platform: status
|
||
name: "Node Status"
|
||
id: system_status
|
||
|
||
# ----------------------------------------------------------
|
||
# Text sensors
|
||
#
|
||
# smartdisplay_action:
|
||
# - Main touch event channel exported to Home Assistant
|
||
# - On short tap, publishes one of:
|
||
# tile1_press / tile2_press / tile3_press / tile4_press / tile5_press / tile6_press
|
||
# - The value is reset shortly after publishing
|
||
#
|
||
# tile*_preset:
|
||
# - Mirrors the Home Assistant preset_mode attribute for each tile entity
|
||
# - Used by the UI to display fan presets when applicable
|
||
# ----------------------------------------------------------
|
||
text_sensor:
|
||
- platform: template
|
||
name: "Guition screen type"
|
||
entity_category: diagnostic
|
||
update_interval: never
|
||
lambda: 'return {"guition"};'
|
||
- platform: template
|
||
name: "Screen firmware"
|
||
entity_category: diagnostic
|
||
lambda: 'return {"${SCREEN_FIRMWARE_VERSION}"};'
|
||
update_interval: 60s
|
||
# ESP Screen Manager matches the screen to its YAML profile and OTA address.
|
||
- platform: template
|
||
name: "Device name"
|
||
entity_category: diagnostic
|
||
lambda: 'return {"${DEVICE_NAME}"};'
|
||
update_interval: 60s
|
||
- platform: wifi_info
|
||
ip_address:
|
||
# `id` so the settings page on the screen can show the same address (0.2.44+).
|
||
id: wifi_ip
|
||
name: "IP address"
|
||
entity_category: diagnostic
|
||
|
||
switch:
|
||
- platform: restart
|
||
name: "Restart"
|
||
# The screen settings as Home Assistant entities (firmware 0.2.49+, Auto standby since 0.2.41). The screen
|
||
# owns them: the settings page, these entities and ESP Screens all change them through
|
||
# settings_screen::set(), which saves, applies and reports a real change only. The saved values keep
|
||
# their preference records. A template switch reads its lambda every loop and publishes a change itself;
|
||
# the numbers and times are published by apply_screen_settings. Auto standby off wakes a screen the
|
||
# standby time dimmed (a Sleep holds until a tap, Wake or an alert); on counts the standby time from that
|
||
# moment. Nothing runs at boot: every switch follows the saved setting.
|
||
- platform: template
|
||
id: setting_auto_standby
|
||
name: "Auto standby"
|
||
icon: "mdi:sleep"
|
||
entity_category: config
|
||
restore_mode: DISABLED
|
||
lambda: return screen_settings::current.standby_enabled;
|
||
turn_on_action:
|
||
- lambda: |-
|
||
id(last_touch_ms) = millis();
|
||
settings_screen::set("standby_enabled", 1);
|
||
turn_off_action:
|
||
- lambda: 'settings_screen::set("standby_enabled", 0);'
|
||
- platform: template
|
||
id: setting_night_mode
|
||
name: "Night mode"
|
||
icon: "mdi:weather-night"
|
||
entity_category: config
|
||
restore_mode: DISABLED
|
||
lambda: return screen_settings::current.night_enabled;
|
||
turn_on_action:
|
||
- lambda: 'settings_screen::set("night_enabled", 1);'
|
||
turn_off_action:
|
||
- lambda: 'settings_screen::set("night_enabled", 0);'
|
||
# The dark look for a screen beside a bed (firmware 0.2.54+): black page, graphite cards, soft white text.
|
||
- platform: template
|
||
id: setting_dark_mode
|
||
name: "Dark mode"
|
||
icon: "mdi:theme-light-dark"
|
||
entity_category: config
|
||
restore_mode: DISABLED
|
||
lambda: return settings_screen::dark_mode != 0;
|
||
turn_on_action:
|
||
- lambda: 'settings_screen::set("dark_mode", 1);'
|
||
turn_off_action:
|
||
- lambda: 'settings_screen::set("dark_mode", 0);'
|
||
- platform: template
|
||
id: setting_clock_24h
|
||
name: "24-hour clock"
|
||
icon: "mdi:clock-outline"
|
||
entity_category: config
|
||
restore_mode: DISABLED
|
||
lambda: return screen_settings::current.clock_24h;
|
||
turn_on_action:
|
||
- lambda: 'settings_screen::set("clock_24h", 1);'
|
||
turn_off_action:
|
||
- lambda: 'settings_screen::set("clock_24h", 0);'
|
||
- platform: template
|
||
id: setting_auto_home
|
||
name: "Back to page 1"
|
||
icon: "mdi:home-import-outline"
|
||
entity_category: config
|
||
restore_mode: DISABLED
|
||
lambda: return settings_screen::auto_home != 0;
|
||
turn_on_action:
|
||
- lambda: 'settings_screen::set("auto_home", 1);'
|
||
turn_off_action:
|
||
- lambda: 'settings_screen::set("auto_home", 0);'
|
||
- platform: template
|
||
id: setting_home_on_standby
|
||
name: "Back to page 1 on standby"
|
||
icon: "mdi:home-import-outline"
|
||
entity_category: config
|
||
restore_mode: DISABLED
|
||
lambda: return screen_settings::current.home_on_standby;
|
||
turn_on_action:
|
||
- lambda: 'settings_screen::set("home_on_standby", 1);'
|
||
turn_off_action:
|
||
- lambda: 'settings_screen::set("home_on_standby", 0);'
|
||
- platform: template
|
||
id: setting_swipe_pages
|
||
name: "Swipe between pages"
|
||
icon: "mdi:gesture-swipe-horizontal"
|
||
entity_category: config
|
||
restore_mode: DISABLED
|
||
lambda: return settings_screen::swipe_pages != 0;
|
||
turn_on_action:
|
||
- lambda: 'settings_screen::set("swipe_pages", 1);'
|
||
turn_off_action:
|
||
- lambda: 'settings_screen::set("swipe_pages", 0);'
|
||
|
||
# Night hours as Home Assistant times (firmware 0.2.49+); the screen keeps minutes since midnight.
|
||
datetime:
|
||
- platform: template
|
||
type: time
|
||
id: setting_night_start
|
||
name: "Night starts"
|
||
icon: "mdi:weather-sunset-down"
|
||
entity_category: config
|
||
update_interval: never
|
||
lambda: |-
|
||
ESPTime time{};
|
||
time.hour = screen_settings::current.night_start / 60;
|
||
time.minute = screen_settings::current.night_start % 60;
|
||
return time;
|
||
set_action:
|
||
- lambda: 'settings_screen::set("night_start", x.hour * 60 + x.minute);'
|
||
- platform: template
|
||
type: time
|
||
id: setting_night_end
|
||
name: "Night ends"
|
||
icon: "mdi:weather-sunset-up"
|
||
entity_category: config
|
||
update_interval: never
|
||
lambda: |-
|
||
ESPTime time{};
|
||
time.hour = screen_settings::current.night_end / 60;
|
||
time.minute = screen_settings::current.night_end % 60;
|
||
return time;
|
||
set_action:
|
||
- lambda: 'settings_screen::set("night_end", x.hour * 60 + x.minute);'
|
||
|
||
# Rotation as a Home Assistant choice (firmware 0.2.49+). Only the Guition turns; the CYD keeps its orientation.
|
||
select:
|
||
- platform: template
|
||
id: setting_rotation
|
||
name: "Rotation"
|
||
icon: "mdi:screen-rotation"
|
||
entity_category: config
|
||
options: ["0°", "90°", "180°", "270°"]
|
||
update_interval: never
|
||
lambda: |-
|
||
static const char *const names[] = {"0°", "90°", "180°", "270°"};
|
||
return std::string(names[std::clamp<int32_t>(settings_screen::rotation, 0, 270) / 90]);
|
||
set_action:
|
||
- lambda: |-
|
||
const std::string option(x.c_str(), x.size());
|
||
settings_screen::set("rotation", atoi(option.c_str()));
|
||
|
||
# Two SPI buses used by the CYD:
|
||
# - lcd: built-in display
|
||
# - my_touchscreen: built-in touch controller
|
||
# Pinout from ESPHome's GUITION-4848S040 preset. No relay outputs are configured.
|
||
spi:
|
||
id: lcd
|
||
clk_pin: GPIO48
|
||
mosi_pin: GPIO47
|
||
|
||
i2c:
|
||
id: touch_bus
|
||
sda: GPIO19
|
||
scl:
|
||
number: GPIO45
|
||
ignore_strapping_warning: true
|
||
scan: true
|
||
|
||
output:
|
||
- platform: ledc
|
||
pin: GPIO38
|
||
id: gpio_backlight_pwm
|
||
frequency: 20000Hz
|
||
|
||
light:
|
||
# The firmware owns the backlight: brightness, standby, night and alerts set it, and the minute tick
|
||
# restores it. Home Assistant uses the brightness numbers and the Wake and Sleep buttons (0.2.46+).
|
||
- platform: monochromatic
|
||
output: gpio_backlight_pwm
|
||
name: "Display Backlight"
|
||
id: back_light
|
||
internal: true
|
||
restore_mode: ALWAYS_ON
|
||
default_transition_length: 150ms
|
||
|
||
touchscreen:
|
||
platform: gt911
|
||
id: ts_touch
|
||
i2c_id: touch_bus
|
||
display: my_display
|
||
update_interval: 20ms
|
||
transform:
|
||
swap_xy: ${TOUCH_SWAP_XY}
|
||
mirror_x: ${TOUCH_MIRROR_X}
|
||
mirror_y: ${TOUCH_MIRROR_Y}
|
||
on_touch:
|
||
- lambda: |-
|
||
id(last_touch_ms) = millis();
|
||
id(last_use_ms) = millis();
|
||
id(touch_down) = true;
|
||
cyd::touch_guard.begin(millis(), touch.x, touch.y, touch.id);
|
||
cyd::edge_swipe.begin(touch.x, touch.y, static_cast<int>(id(screen_lvgl).get_rotation()));
|
||
ESP_LOGI("touch", "GT911 press x=%u y=%u id=%u test=%d", touch.x, touch.y, (unsigned) touch.id, id(calibration_active));
|
||
on_update:
|
||
- lambda: |-
|
||
id(last_touch_ms) = millis();
|
||
id(last_use_ms) = millis();
|
||
// Only the contact that started the touch counts; the guard ignores other ids.
|
||
for (const auto &point : touches) {
|
||
// Trace every sample of an edge touch: the log then shows how often the panel delivers.
|
||
if (cyd::edge_swipe.armed())
|
||
ESP_LOGI("touch", "swipe id=%u st=%u x=%u y=%u", (unsigned) point.id, (unsigned) point.state, point.x, point.y);
|
||
// The stray (0, 0) contact (cyd::GhostTouch) is not where the finger went.
|
||
if (point.x == 0 && point.y == 0) continue;
|
||
cyd::touch_guard.update(point.x, point.y, point.id);
|
||
if (point.id != cyd::touch_guard.contact()) continue;
|
||
// Swiping in from a side edge flips the page ("Swiping between pages"); the tap
|
||
// under the finger is consumed and LVGL waits for the release. LVGL 9.5 sends no
|
||
// PRESSING to the input device, hence the touchscreen trigger.
|
||
const int step = cyd::edge_swipe.update(point.x, point.y);
|
||
if (!step) continue;
|
||
const char *blocked = !runtime_tiles::enabled ? "no runtime tiles" : !runtime_tiles::swipe_pages ? "setting off"
|
||
: id(display_dimmed) ? "screen dimmed" : id(calibration_active) ? "calibration" : !id(active_entity).empty() ? "card open"
|
||
: (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)) ? "detail card open"
|
||
: runtime_tiles::camera_visible() ? "camera open" : nullptr;
|
||
if (blocked) { ESP_LOGI("touch", "edge swipe ignored: %s", blocked); continue; }
|
||
cyd::touch_guard.consume();
|
||
for (auto *indev = lv_indev_get_next(nullptr); indev; indev = lv_indev_get_next(indev)) lv_indev_wait_release(indev);
|
||
ESP_LOGI("touch", "edge swipe: page %d -> %d", id(tile_page), id(tile_page) + step);
|
||
id(tile_page) += step;
|
||
id(show_tile_page).execute();
|
||
}
|
||
on_release:
|
||
- lambda: |-
|
||
id(last_touch_ms) = millis();
|
||
id(last_use_ms) = millis();
|
||
id(touch_down) = false;
|
||
if (cyd::edge_swipe.armed() && cyd::edge_swipe.inward() > 0)
|
||
ESP_LOGI("touch", "edge swipe not fired: %d px inward, %d px vertical", cyd::edge_swipe.inward(), cyd::edge_swipe.sideways());
|
||
cyd::edge_swipe.end();
|
||
|
||
|
||
# Native ESPHome driver with the working manufacturer's LVGL demo timings.
|
||
# See docs/GUITION_FACTORY_REFERENCE.md; no custom RGB component is required.
|
||
display:
|
||
- platform: st7701s
|
||
id: my_display
|
||
dimensions: {width: 480, height: 480}
|
||
spi_id: lcd
|
||
spi_mode: MODE0
|
||
data_rate: 2MHz
|
||
color_order: RGB
|
||
invert_colors: false
|
||
cs_pin: 39
|
||
de_pin: 18
|
||
hsync_pin: 16
|
||
vsync_pin: 17
|
||
pclk_pin: 21
|
||
pclk_frequency: 16MHz
|
||
pclk_inverted: false
|
||
hsync_front_porch: 10
|
||
hsync_pulse_width: 8
|
||
hsync_back_porch: 50
|
||
vsync_front_porch: 10
|
||
vsync_pulse_width: 8
|
||
vsync_back_porch: 20
|
||
transform:
|
||
mirror_x: false
|
||
mirror_y: false
|
||
init_sequence:
|
||
- 1
|
||
# Manufacturer's 16-bit bus configuration and normal scan direction.
|
||
- [0xFF, 0x77, 0x01, 0x00, 0x00, 0x10]
|
||
- [0xCD, 0x00]
|
||
- [0xC7, 0x00]
|
||
# Return to the standard register bank before pixel format / sleep-out.
|
||
- [0xFF, 0x77, 0x01, 0x00, 0x00, 0x00]
|
||
- [0x3A, 0x60]
|
||
- [0x36, 0x00]
|
||
- [0x20]
|
||
- [0x11]
|
||
- delay 120ms
|
||
data_pins:
|
||
red: [11, 12, 13, 14, 0]
|
||
green: [8, 20, 3, 46, 9, 10]
|
||
blue: [4, 5, 6, 7, 15]
|
||
update_interval: never
|
||
auto_clear_enabled: false
|
||
|
||
|
||
lvgl:
|
||
id: screen_lvgl
|
||
# The theme's font for text without a font of its own. Every label names its font, so this only keeps
|
||
# ESPHome from building LVGL's Montserrat 14 for nothing (firmware 0.2.65+).
|
||
default_font: sublabel
|
||
on_draw_start:
|
||
- lambda: 'id(rendered_frames)++;'
|
||
displays:
|
||
- my_display
|
||
touchscreens:
|
||
- touchscreen_id: ts_touch
|
||
|
||
# S3 octal PSRAM holds RGB framebuffer and LVGL draw buffers.
|
||
buffer_size: 25%
|
||
|
||
# Direct local feedback on the WHOLE UI, not just the tiles.
|
||
# A theme sets the style per widget type, so this covers all
|
||
# buttons, tabs, sliders and the thermostat ring in one go. Non-clickable
|
||
# widgets never enter the pressed state, so those stay untouched.
|
||
theme:
|
||
obj:
|
||
pressed:
|
||
# bg_opa and not opa: opa would also fade the icon and text. Styles that
|
||
# make LVGL draw into a buffer of its own (opa_layered, rotation/scale,
|
||
# clip_corner, a slider fill rounded less than its track) cost KB's per
|
||
# redraw that the CYD's heap can't spare; tests/test_layer_free.py.
|
||
bg_opa: 45%
|
||
slider:
|
||
knob:
|
||
pressed:
|
||
bg_opa: 65%
|
||
arc:
|
||
knob:
|
||
pressed:
|
||
bg_opa: 65%
|
||
|
||
# LVGL owns the rotation (display + touch) since ESPHome 2026.4.
|
||
rotation: ${LVGL_ROTATION}
|
||
|
||
style_definitions:
|
||
# The page behind the tiles and the cards: one colour from the theme (theme.h PAGE), no wallpaper and no
|
||
# gradient, so a redraw is a plain fill.
|
||
- id: style_page
|
||
bg_opa: COVER
|
||
border_width: 0
|
||
radius: 0
|
||
|
||
- id: style_tile
|
||
bg_opa: COVER
|
||
radius: 22
|
||
border_width: 1
|
||
border_opa: COVER
|
||
pad_left: 12
|
||
pad_right: 12
|
||
pad_top: 12
|
||
pad_bottom: 12
|
||
shadow_width: 0
|
||
shadow_opa: 40%
|
||
|
||
- id: style_icon_circle
|
||
bg_opa: COVER
|
||
radius: 999
|
||
border_width: 0
|
||
|
||
- id: style_room
|
||
text_font: headline
|
||
text_opa: 100%
|
||
|
||
- id: style_time
|
||
text_font: time_label
|
||
text_opa: 100%
|
||
|
||
- id: style_title
|
||
text_font: label
|
||
text_opa: 100%
|
||
|
||
- id: style_value
|
||
text_font: sublabel
|
||
text_opa: 100%
|
||
|
||
- id: style_value_big
|
||
text_font: sublabel_big
|
||
text_opa: 80%
|
||
|
||
# Paints: shared styles without a colour of their own. theme.h fills them (the list in on_boot, theme::paints)
|
||
# and fills them again when Dark mode changes, so a widget names a paint where it used to write a colour.
|
||
- id: paint_page_soft
|
||
- id: paint_page_pressed
|
||
- id: paint_card
|
||
- id: paint_raised
|
||
- id: paint_key
|
||
- id: paint_key_pressed
|
||
- id: paint_track
|
||
- id: paint_ink
|
||
- id: paint_ink_soft
|
||
- id: paint_muted
|
||
- id: paint_subtle
|
||
- id: paint_slate
|
||
- id: paint_scrim
|
||
- id: paint_veil
|
||
- id: paint_accent_tint
|
||
- id: paint_accent_bright
|
||
- id: paint_button_dark
|
||
- id: paint_button_dark_pressed
|
||
- id: paint_camera
|
||
|
||
# ---------- ALERT OVERLAY (HA action show_alert) ----------
|
||
# LVGL's top layer sits above every page, card and the dim overlay; the card
|
||
# is filled by the alert_show script. Fixed positions, no layout engine.
|
||
top_layer:
|
||
widgets:
|
||
- obj:
|
||
id: alert_overlay
|
||
x: 0
|
||
y: 0
|
||
width: ${DISPLAY_W}
|
||
height: ${DISPLAY_H}
|
||
hidden: true
|
||
styles: paint_scrim
|
||
bg_opa: 60%
|
||
radius: 0
|
||
border_width: 0
|
||
pad_all: 0
|
||
clickable: true
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
# Same look when pressed: a tap next to the card must not repaint the screen.
|
||
pressed:
|
||
bg_opa: 60%
|
||
widgets:
|
||
- obj:
|
||
id: alert_card
|
||
align: CENTER
|
||
width: ${ALERT_CARD_W}
|
||
height: ${ALERT_CARD_H}
|
||
styles: paint_raised
|
||
bg_opa: COVER
|
||
radius: 18
|
||
border_width: 1
|
||
border_opa: COVER
|
||
shadow_width: 0
|
||
pad_all: 0
|
||
clickable: false
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
widgets:
|
||
- label:
|
||
id: alert_icon
|
||
x: ${ALERT_ICON_X}
|
||
y: ${ALERT_ICON_Y}
|
||
text: "\U000F002A"
|
||
text_font: materialdesign_icons
|
||
clickable: false
|
||
- label:
|
||
id: alert_title
|
||
x: ${ALERT_TEXT_X}
|
||
y: ${ALERT_TITLE_Y}
|
||
width: ${ALERT_TEXT_W}
|
||
height: ${ALERT_TITLE_H}
|
||
long_mode: DOT
|
||
text: "Notification"
|
||
text_font: headline
|
||
clickable: false
|
||
- label:
|
||
id: alert_subtitle
|
||
x: ${ALERT_TEXT_X}
|
||
y: ${ALERT_SUBTITLE_Y}
|
||
width: ${ALERT_TEXT_W}
|
||
height: ${ALERT_SUBTITLE_H}
|
||
long_mode: WRAP
|
||
text: ""
|
||
text_font: sublabel_big
|
||
styles: paint_ink_soft
|
||
clickable: false
|
||
# The alert's camera image (firmware 0.2.57+) across the top of the card, shown when the app announced
|
||
# one; a tap opens the camera full screen over the alert. runtime_tiles.h puts the image in it.
|
||
- obj:
|
||
id: alert_image_frame
|
||
x: ${ALERT_IMAGE_INSET}
|
||
y: ${ALERT_IMAGE_INSET}
|
||
width: ${CAMERA_THUMB_W}
|
||
height: ${CAMERA_THUMB_H}
|
||
hidden: true
|
||
styles: paint_camera
|
||
bg_opa: COVER
|
||
radius: 0
|
||
border_width: 0
|
||
shadow_width: 0
|
||
pad_all: 0
|
||
clickable: true
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
pressed:
|
||
bg_opa: COVER
|
||
on_click:
|
||
- if:
|
||
condition:
|
||
lambda: 'return cyd::touch_guard.accept(millis(), 14) && !runtime_tiles::alert_camera.empty();'
|
||
then:
|
||
- lambda: 'runtime_tiles::camera_open(runtime_tiles::alert_camera, id(alert_title_text));'
|
||
widgets:
|
||
- label:
|
||
id: alert_image_icon
|
||
align: CENTER
|
||
text: "\U000F07AE"
|
||
text_font: materialdesign_icons
|
||
clickable: false
|
||
- obj:
|
||
id: alert_ok
|
||
align: BOTTOM_RIGHT
|
||
x: -${ALERT_BUTTON_INSET}
|
||
y: -${ALERT_BUTTON_INSET}
|
||
width: ${ALERT_BUTTON_W}
|
||
height: ${ALERT_BUTTON_H}
|
||
radius: 999
|
||
styles: paint_button_dark
|
||
bg_opa: COVER
|
||
border_width: 0
|
||
shadow_width: 0
|
||
pad_all: 0
|
||
clickable: true
|
||
scrollable: false
|
||
pressed:
|
||
styles: paint_button_dark_pressed
|
||
bg_opa: COVER
|
||
on_click:
|
||
- if:
|
||
condition:
|
||
lambda: 'return cyd::touch_guard.accept(millis(), 13);'
|
||
then:
|
||
- script.execute:
|
||
id: alert_dismiss
|
||
reason: "ok"
|
||
widgets:
|
||
- label:
|
||
id: alert_ok_label
|
||
align: CENTER
|
||
text: "OK"
|
||
text_font: headline
|
||
clickable: false
|
||
|
||
pages:
|
||
- id: home_page
|
||
styles: style_page
|
||
pad_all: 0
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
widgets:
|
||
# Never shown: it only makes ESPHome compile LVGL's spinner, which the busy sheet on a tile draws.
|
||
- spinner:
|
||
id: busy_spinner_seed
|
||
hidden: true
|
||
width: 2
|
||
height: 2
|
||
spin_time: 1s
|
||
arc_length: 60deg
|
||
# Never shown either: it makes ESPHome compile LVGL's image widget, which a camera image is drawn with.
|
||
- image:
|
||
id: camera_image_seed
|
||
hidden: true
|
||
src: alert_image
|
||
- label:
|
||
id: lbl_room
|
||
x: 16
|
||
y: ${HEADER_Y}
|
||
text: "${ROOM_NAME}"
|
||
styles: style_room
|
||
|
||
# Enables LVGL's line widget for the runtime clock hands and sparklines.
|
||
- line:
|
||
id: runtime_line_enable
|
||
hidden: true
|
||
points:
|
||
- 0, 0
|
||
- 1, 1
|
||
|
||
- label:
|
||
id: lbl_time
|
||
align: TOP_RIGHT
|
||
x: -16
|
||
y: ${HEADER_Y}
|
||
text: ""
|
||
styles: style_time
|
||
|
||
# Six tiles on page 1, four on page 2. Fixed pages keep navigation predictable.
|
||
- obj:
|
||
id: tile_scroll
|
||
x: 0
|
||
y: ${SCROLL_Y}
|
||
width: ${DISPLAY_W}
|
||
height: ${SCROLL_H}
|
||
bg_opa: TRANSP
|
||
# A tap between two tiles presses this area: without its own pressed style the
|
||
# theme's 45 % lights it up as a pale panel behind the tiles.
|
||
pressed:
|
||
bg_opa: TRANSP
|
||
border_width: 0
|
||
pad_left: 0
|
||
pad_right: 0
|
||
pad_top: 0
|
||
pad_bottom: 0
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
widgets:
|
||
# ---------- TILE 1 ----------
|
||
- obj:
|
||
id: tile1
|
||
x: ${GRID_COL1_X}
|
||
y: ${GRID_ROW1_Y}
|
||
width: ${TILE_W}
|
||
height: ${TILE_H}
|
||
styles: style_tile
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
# Direct local feedback: LVGL applies this as soon as the finger
|
||
# touches the glass, in the same frame. Independent of Home Assistant.
|
||
pressed:
|
||
bg_opa: 45%
|
||
widgets:
|
||
- obj:
|
||
id: tile1_icon_circle
|
||
x: ${TILE_ICON_X}
|
||
y: ${TILE_ICON_Y}
|
||
width: 54
|
||
height: 54
|
||
styles: style_icon_circle
|
||
clickable: false
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
widgets:
|
||
- label:
|
||
id: tile1_icon_lbl
|
||
align: CENTER
|
||
text_font: materialdesign_icons
|
||
text: "\U000F001B"
|
||
clickable: false
|
||
- label:
|
||
id: t1_title
|
||
height: 24
|
||
width: 130
|
||
long_mode: DOT
|
||
x: ${TILE_TITLE_X}
|
||
y: ${TILE_TITLE_Y}
|
||
text: "Lamp"
|
||
styles: style_title
|
||
clickable: false
|
||
- label:
|
||
id: t1_value
|
||
height: 22
|
||
width: 130
|
||
long_mode: DOT
|
||
x: ${TILE_VALUE_X}
|
||
y: ${TILE_VALUE_Y}
|
||
text: "—"
|
||
styles: style_value
|
||
clickable: false
|
||
|
||
# ---------- TILE 2 ----------
|
||
- obj:
|
||
id: tile2
|
||
x: ${GRID_COL2_X}
|
||
y: ${GRID_ROW1_Y}
|
||
width: ${TILE_W}
|
||
height: ${TILE_H}
|
||
styles: style_tile
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
# Direct local feedback: LVGL applies this as soon as the finger
|
||
# touches the glass, in the same frame. Independent of Home Assistant.
|
||
pressed:
|
||
bg_opa: 45%
|
||
widgets:
|
||
- obj:
|
||
id: tile2_icon_circle
|
||
x: ${TILE_ICON_X}
|
||
y: ${TILE_ICON_Y}
|
||
width: 54
|
||
height: 54
|
||
styles: style_icon_circle
|
||
clickable: false
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
widgets:
|
||
- label:
|
||
id: tile2_icon_lbl
|
||
align: CENTER
|
||
text_font: materialdesign_icons
|
||
text: "\U000F0387"
|
||
clickable: false
|
||
- label:
|
||
id: t2_title
|
||
height: 24
|
||
width: 130
|
||
long_mode: DOT
|
||
x: ${TILE_TITLE_X}
|
||
y: ${TILE_TITLE_Y}
|
||
text: "Plug"
|
||
styles: style_title
|
||
clickable: false
|
||
- label:
|
||
id: t2_value
|
||
height: 22
|
||
width: 130
|
||
long_mode: DOT
|
||
x: ${TILE_VALUE_X}
|
||
y: ${TILE_VALUE_Y}
|
||
text: "—"
|
||
styles: style_value
|
||
clickable: false
|
||
|
||
# ---------- TILE 3 ----------
|
||
- obj:
|
||
id: tile3
|
||
x: ${GRID_COL1_X}
|
||
y: ${GRID_ROW2_Y}
|
||
width: ${TILE_W}
|
||
height: ${TILE_H}
|
||
styles: style_tile
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
# Direct local feedback: LVGL applies this as soon as the finger
|
||
# touches the glass, in the same frame. Independent of Home Assistant.
|
||
pressed:
|
||
bg_opa: 45%
|
||
widgets:
|
||
- obj:
|
||
id: tile3_icon_circle
|
||
x: ${TILE_ICON_X}
|
||
y: ${TILE_ICON_Y}
|
||
width: 54
|
||
height: 54
|
||
styles: style_icon_circle
|
||
clickable: false
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
widgets:
|
||
- label:
|
||
id: tile3_icon_lbl
|
||
align: CENTER
|
||
text_font: materialdesign_icons
|
||
text: "\U000F02E3"
|
||
clickable: false
|
||
- label:
|
||
id: t3_title
|
||
height: 24
|
||
width: 130
|
||
long_mode: DOT
|
||
x: ${TILE_TITLE_X}
|
||
y: ${TILE_TITLE_Y}
|
||
text: "Evening"
|
||
styles: style_title
|
||
clickable: false
|
||
- label:
|
||
id: t3_value
|
||
height: 22
|
||
width: 130
|
||
long_mode: DOT
|
||
x: ${TILE_VALUE_X}
|
||
y: ${TILE_VALUE_Y}
|
||
text: "—"
|
||
styles: style_value
|
||
clickable: false
|
||
|
||
# ---------- TILE 4 ----------
|
||
- obj:
|
||
id: tile4
|
||
x: ${GRID_COL2_X}
|
||
y: ${GRID_ROW2_Y}
|
||
width: ${TILE_W}
|
||
height: ${TILE_H}
|
||
styles: style_tile
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
# Direct local feedback: LVGL applies this as soon as the finger
|
||
# touches the glass, in the same frame. Independent of Home Assistant.
|
||
pressed:
|
||
bg_opa: 45%
|
||
widgets:
|
||
- obj:
|
||
id: tile4_icon_circle
|
||
x: ${TILE_ICON_X}
|
||
y: ${TILE_ICON_Y}
|
||
width: 54
|
||
height: 54
|
||
styles: style_icon_circle
|
||
clickable: false
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
widgets:
|
||
- label:
|
||
id: tile4_icon_lbl
|
||
align: CENTER
|
||
text_font: materialdesign_icons
|
||
text: "\U000F059C"
|
||
clickable: false
|
||
- label:
|
||
id: t4_title
|
||
height: 24
|
||
width: 130
|
||
long_mode: DOT
|
||
x: ${TILE_TITLE_X}
|
||
y: ${TILE_TITLE_Y}
|
||
text: "All off"
|
||
styles: style_title
|
||
clickable: false
|
||
- label:
|
||
id: t4_value
|
||
height: 22
|
||
width: 130
|
||
long_mode: DOT
|
||
x: ${TILE_VALUE_X}
|
||
y: ${TILE_VALUE_Y}
|
||
text: "—"
|
||
styles: style_value
|
||
clickable: false
|
||
|
||
# ---------- TILE 5 ----------
|
||
- obj:
|
||
id: tile5
|
||
x: ${GRID_COL1_X}
|
||
y: ${GRID_ROW3_Y}
|
||
width: ${TILE_W}
|
||
height: ${TILE_H}
|
||
styles: style_tile
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
# Direct local feedback: LVGL applies this as soon as the finger
|
||
# touches the glass, in the same frame. Independent of Home Assistant.
|
||
pressed:
|
||
bg_opa: 45%
|
||
widgets:
|
||
- obj:
|
||
id: tile5_icon_circle
|
||
x: ${TILE_ICON_X}
|
||
y: ${TILE_ICON_Y}
|
||
width: 54
|
||
height: 54
|
||
styles: style_icon_circle
|
||
clickable: false
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
widgets:
|
||
- label:
|
||
id: tile5_icon_lbl
|
||
align: CENTER
|
||
text_font: materialdesign_icons
|
||
text: "\U000F0425"
|
||
clickable: false
|
||
- label:
|
||
id: t5_title
|
||
height: 24
|
||
width: 130
|
||
long_mode: DOT
|
||
x: ${TILE_TITLE_X}
|
||
y: ${TILE_TITLE_Y}
|
||
text: "AC"
|
||
styles: style_title
|
||
clickable: false
|
||
- label:
|
||
id: t5_value
|
||
height: 22
|
||
width: 130
|
||
long_mode: DOT
|
||
x: ${TILE_VALUE_X}
|
||
y: ${TILE_VALUE_Y}
|
||
text: "—"
|
||
styles: style_value
|
||
clickable: false
|
||
|
||
# ---------- TILE 6 ----------
|
||
- obj:
|
||
id: tile6
|
||
x: ${GRID_COL2_X}
|
||
y: ${GRID_ROW3_Y}
|
||
width: ${TILE_W}
|
||
height: ${TILE_H}
|
||
styles: style_tile
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
# Direct local feedback: LVGL applies this as soon as the finger
|
||
# touches the glass, in the same frame. Independent of Home Assistant.
|
||
pressed:
|
||
bg_opa: 45%
|
||
widgets:
|
||
- obj:
|
||
id: tile6_icon_circle
|
||
x: ${TILE_ICON_X}
|
||
y: ${TILE_ICON_Y}
|
||
width: 54
|
||
height: 54
|
||
styles: style_icon_circle
|
||
clickable: false
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
widgets:
|
||
- label:
|
||
id: tile6_icon_lbl
|
||
align: CENTER
|
||
text_font: materialdesign_icons
|
||
text: "\U000F070D"
|
||
clickable: false
|
||
- label:
|
||
id: t6_title
|
||
height: 24
|
||
width: 130
|
||
long_mode: DOT
|
||
x: ${TILE_TITLE_X}
|
||
y: ${TILE_TITLE_Y}
|
||
text: "Vacuum"
|
||
styles: style_title
|
||
clickable: false
|
||
- label:
|
||
id: t6_value
|
||
height: 22
|
||
width: 130
|
||
long_mode: DOT
|
||
x: ${TILE_VALUE_X}
|
||
y: ${TILE_VALUE_Y}
|
||
text: "—"
|
||
styles: style_value
|
||
clickable: false
|
||
|
||
# ---------- TILE 7 — Tafellamp ----------
|
||
# Brightness-only lamp: deliberately no percentage/cover/climate
|
||
# sensors were created for this tile, those would only cost
|
||
# HA subscriptions and heap. The overlay therefore gets
|
||
# NAN / "" for the fields a light never uses.
|
||
- obj:
|
||
id: tile7
|
||
hidden: true
|
||
x: ${GRID_COL1_X}
|
||
y: ${GRID_ROW4_Y}
|
||
width: ${TILE_W}
|
||
height: ${TILE_H}
|
||
styles: style_tile
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
pressed:
|
||
bg_opa: 45%
|
||
widgets:
|
||
- obj:
|
||
id: tile7_icon_circle
|
||
x: ${TILE_ICON_X}
|
||
y: ${TILE_ICON_Y}
|
||
width: 54
|
||
height: 54
|
||
styles: style_icon_circle
|
||
clickable: false
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
widgets:
|
||
- label:
|
||
id: tile7_icon_lbl
|
||
align: CENTER
|
||
text_font: materialdesign_icons
|
||
text: "\U000F06B5"
|
||
clickable: false
|
||
- label:
|
||
id: t7_title
|
||
height: 24
|
||
width: 130
|
||
long_mode: DOT
|
||
x: ${TILE_TITLE_X}
|
||
y: ${TILE_TITLE_Y}
|
||
text: "Tile 7"
|
||
styles: style_title
|
||
clickable: false
|
||
- label:
|
||
id: t7_value
|
||
height: 22
|
||
width: 130
|
||
long_mode: DOT
|
||
x: ${TILE_VALUE_X}
|
||
y: ${TILE_VALUE_Y}
|
||
text: "—"
|
||
styles: style_value
|
||
clickable: false
|
||
|
||
# ---------- TILE 8 — Brievenbus reset ----------
|
||
# input_select with options Full/Empty. The tile lights up as soon as
|
||
# the status is TILE8_ACTIVE_STATE ("Full"); a short tap sends
|
||
# input_select.select_option with option: "Empty".
|
||
# No slider mode, so this tile only needs ha_state_tile8 —
|
||
# no other HA subscription at all.
|
||
- obj:
|
||
id: tile8
|
||
hidden: true
|
||
x: ${GRID_COL2_X}
|
||
y: ${GRID_ROW4_Y}
|
||
width: ${TILE_W}
|
||
height: ${TILE_H}
|
||
styles: style_tile
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
pressed:
|
||
bg_opa: 45%
|
||
widgets:
|
||
- obj:
|
||
id: tile8_icon_circle
|
||
x: ${TILE_ICON_X}
|
||
y: ${TILE_ICON_Y}
|
||
width: 54
|
||
height: 54
|
||
styles: style_icon_circle
|
||
clickable: false
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
widgets:
|
||
- label:
|
||
id: tile8_icon_lbl
|
||
align: CENTER
|
||
text_font: materialdesign_icons
|
||
text: "\U000F06EE"
|
||
clickable: false
|
||
- label:
|
||
id: t8_title
|
||
height: 24
|
||
width: 130
|
||
long_mode: DOT
|
||
x: ${TILE_TITLE_X}
|
||
y: ${TILE_TITLE_Y}
|
||
text: "Tile 8"
|
||
styles: style_title
|
||
clickable: false
|
||
- label:
|
||
id: t8_value
|
||
height: 22
|
||
width: 130
|
||
long_mode: DOT
|
||
x: ${TILE_VALUE_X}
|
||
y: ${TILE_VALUE_Y}
|
||
text: "—"
|
||
styles: style_value
|
||
clickable: false
|
||
|
||
# ---------- TILE 9 — Living room (moved from tile 2) ----------
|
||
- obj:
|
||
id: tile9
|
||
hidden: true
|
||
x: ${GRID_COL1_X}
|
||
y: ${GRID_ROW5_Y}
|
||
width: ${TILE_W}
|
||
height: ${TILE_H}
|
||
styles: style_tile
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
pressed:
|
||
bg_opa: 45%
|
||
widgets:
|
||
- obj:
|
||
id: tile9_icon_circle
|
||
x: ${TILE_ICON_X}
|
||
y: ${TILE_ICON_Y}
|
||
width: 54
|
||
height: 54
|
||
styles: style_icon_circle
|
||
clickable: false
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
widgets:
|
||
- label:
|
||
id: tile9_icon_lbl
|
||
align: CENTER
|
||
text_font: materialdesign_icons
|
||
text: "\U000F04B9"
|
||
clickable: false
|
||
- label:
|
||
id: t9_title
|
||
height: 24
|
||
width: 130
|
||
long_mode: DOT
|
||
x: ${TILE_TITLE_X}
|
||
y: ${TILE_TITLE_Y}
|
||
text: "Tile 9"
|
||
styles: style_title
|
||
clickable: false
|
||
- label:
|
||
id: t9_value
|
||
height: 22
|
||
width: 130
|
||
long_mode: DOT
|
||
x: ${TILE_VALUE_X}
|
||
y: ${TILE_VALUE_Y}
|
||
text: "—"
|
||
styles: style_value
|
||
clickable: false
|
||
|
||
# ---------- TILE 10 — Alarm on/off ----------
|
||
- obj:
|
||
id: tile10
|
||
hidden: true
|
||
x: ${GRID_COL2_X}
|
||
y: ${GRID_ROW5_Y}
|
||
width: ${TILE_W}
|
||
height: ${TILE_H}
|
||
styles: style_tile
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
pressed:
|
||
bg_opa: 45%
|
||
widgets:
|
||
- obj:
|
||
id: tile10_icon_circle
|
||
x: ${TILE_ICON_X}
|
||
y: ${TILE_ICON_Y}
|
||
width: 54
|
||
height: 54
|
||
styles: style_icon_circle
|
||
clickable: false
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
widgets:
|
||
- label:
|
||
id: tile10_icon_lbl
|
||
align: CENTER
|
||
text_font: materialdesign_icons
|
||
text: "\U000F002A"
|
||
clickable: false
|
||
- label:
|
||
id: t10_title
|
||
height: 24
|
||
width: 130
|
||
long_mode: DOT
|
||
x: ${TILE_TITLE_X}
|
||
y: ${TILE_TITLE_Y}
|
||
text: "Tile 10"
|
||
styles: style_title
|
||
clickable: false
|
||
- label:
|
||
id: t10_value
|
||
height: 22
|
||
width: 130
|
||
long_mode: DOT
|
||
x: ${TILE_VALUE_X}
|
||
y: ${TILE_VALUE_Y}
|
||
text: "—"
|
||
styles: style_value
|
||
clickable: false
|
||
|
||
# ---------- VALUE SLIDER OVERLAY ----------
|
||
# ---------- PAGE NAVIGATION: one full-width bar ----------
|
||
- obj:
|
||
id: page_prev
|
||
align: BOTTOM_LEFT
|
||
x: 0
|
||
y: 0
|
||
width: 200
|
||
height: 44
|
||
pad_all: 0
|
||
radius: 0
|
||
bg_opa: TRANSP
|
||
border_width: 0
|
||
scrollable: false
|
||
pressed:
|
||
styles: paint_page_pressed
|
||
bg_opa: COVER
|
||
# on_click: LVGL sends no short click after a press of 400 ms, so a firm or slow press did nothing
|
||
# (firmware 0.2.65+). The bar has no hold of its own; accept_repeat still drops bounce and a swipe.
|
||
on_click:
|
||
- if:
|
||
condition:
|
||
lambda: 'return cyd::touch_guard.accept_repeat(millis(), 11);'
|
||
then:
|
||
- lambda: 'id(tile_page) = std::max(0, id(tile_page) - 1);'
|
||
- script.execute: show_tile_page
|
||
widgets:
|
||
- label:
|
||
align: LEFT_MID
|
||
x: 24
|
||
text: "< Previous"
|
||
text_font: label
|
||
styles: paint_ink
|
||
clickable: false
|
||
- label:
|
||
id: page_number
|
||
align: BOTTOM_MID
|
||
y: 0
|
||
width: 80
|
||
height: 44
|
||
pad_top: 12
|
||
border_width: 0
|
||
text: "1 / 2"
|
||
text_align: CENTER
|
||
text_font: sublabel
|
||
- obj:
|
||
id: page_next
|
||
align: BOTTOM_RIGHT
|
||
x: 0
|
||
y: 0
|
||
width: 200
|
||
height: 44
|
||
pad_all: 0
|
||
radius: 0
|
||
bg_opa: TRANSP
|
||
border_width: 0
|
||
scrollable: false
|
||
pressed:
|
||
styles: paint_page_pressed
|
||
bg_opa: COVER
|
||
on_click:
|
||
- if:
|
||
condition:
|
||
lambda: 'return cyd::touch_guard.accept_repeat(millis(), 12);'
|
||
then:
|
||
- lambda: 'id(tile_page) += 1;'
|
||
- script.execute: show_tile_page
|
||
widgets:
|
||
- label:
|
||
align: RIGHT_MID
|
||
x: -24
|
||
text: "Next >"
|
||
text_font: label
|
||
styles: paint_ink
|
||
clickable: false
|
||
|
||
- obj:
|
||
id: brightness_overlay
|
||
x: 0
|
||
y: 0
|
||
width: ${DISPLAY_W}
|
||
height: ${DISPLAY_H}
|
||
bg_opa: TRANSP
|
||
hidden: true
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
pad_left: 0
|
||
pad_right: 0
|
||
pad_top: 0
|
||
pad_bottom: 0
|
||
border_width: 0
|
||
widgets:
|
||
- obj:
|
||
id: brightness_overlay_bg
|
||
x: 0
|
||
y: 0
|
||
width: ${DISPLAY_W}
|
||
height: ${DISPLAY_H}
|
||
styles: style_page
|
||
scrollable: false
|
||
clickable: true
|
||
on_click:
|
||
- script.execute: close_cards
|
||
|
||
- obj:
|
||
id: brightness_overlay_dim
|
||
x: 0
|
||
y: 0
|
||
width: ${DISPLAY_W}
|
||
height: ${DISPLAY_H}
|
||
styles: paint_veil
|
||
bg_opa: 18%
|
||
border_width: 0
|
||
clickable: false
|
||
scrollable: false
|
||
|
||
- obj:
|
||
id: overlay_back_button
|
||
align: TOP_LEFT
|
||
x: 16
|
||
y: 16
|
||
width: 60
|
||
height: 60
|
||
radius: 999
|
||
styles: paint_key
|
||
bg_opa: COVER
|
||
border_width: 0
|
||
clickable: true
|
||
scrollable: false
|
||
on_click:
|
||
- script.execute: close_cards
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "\U000F004D"
|
||
text_font: materialdesign_icons_mini
|
||
clickable: false
|
||
|
||
- label:
|
||
id: overlay_title
|
||
align: TOP_MID
|
||
y: 30
|
||
text: "Light"
|
||
styles: style_room
|
||
|
||
- label:
|
||
id: overlay_value
|
||
align: TOP_MID
|
||
y: 70
|
||
text: "100 %"
|
||
styles: style_value_big
|
||
|
||
- slider:
|
||
id: overlay_slider
|
||
align: CENTER
|
||
y: 36
|
||
width: ${OVERLAY_SLIDER_W}
|
||
height: ${OVERLAY_SLIDER_H}
|
||
min_value: 0
|
||
max_value: 100
|
||
value: 100
|
||
adv_hittest: false
|
||
|
||
styles: paint_accent_tint
|
||
bg_opa: 100%
|
||
radius: 42
|
||
# The only widget with BOTH a radius AND a child (overlay_icon).
|
||
# LVGL then clips children to the rounding, and that's only
|
||
# possible via a contiguous 96x160 = 46KB ARGB layer.
|
||
# If that allocation fails, LVGL keeps trying until the
|
||
# task watchdog restarts the board. The rounding stays, the
|
||
# clipping goes.
|
||
clip_corner: false
|
||
border_width: 0
|
||
|
||
indicator:
|
||
styles: paint_accent_bright
|
||
bg_opa: 100%
|
||
radius: 42
|
||
|
||
knob:
|
||
bg_opa: 0%
|
||
border_width: 0
|
||
shadow_width: 0
|
||
pad_all: 15
|
||
|
||
on_value:
|
||
- script.execute:
|
||
id: slider_preview
|
||
value: !lambda "return x;"
|
||
|
||
on_release:
|
||
- script.execute:
|
||
id: slider_commit
|
||
value: !lambda "return lv_slider_get_value(id(overlay_slider));"
|
||
|
||
|
||
# The icon used to be a CHILD in the slider. A widget with a radius
|
||
# and children has to clip those children to the rounding, and
|
||
# LVGL can only do that via an ARGB layer of tens of KB's. Without
|
||
# PSRAM that sometimes fails, after which LVGL keeps retrying until
|
||
# the watchdog restarts the board. As a sibling on top, no layer
|
||
# is needed and it looks identical.
|
||
- label:
|
||
id: overlay_icon
|
||
align: CENTER
|
||
y: ${OVERLAY_ICON_Y}
|
||
text_font: materialdesign_icons
|
||
styles: paint_ink
|
||
text: "\U000F001B"
|
||
clickable: false
|
||
|
||
- obj:
|
||
id: overlay_color_button
|
||
align: BOTTOM_RIGHT
|
||
x: -20
|
||
y: -26
|
||
width: 66
|
||
height: 66
|
||
radius: 999
|
||
hidden: true
|
||
clickable: true
|
||
bg_opa: TRANSP
|
||
border_width: 0
|
||
pad_left: 0
|
||
pad_right: 0
|
||
pad_top: 0
|
||
pad_bottom: 0
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
on_click:
|
||
- script.execute: open_color_detail
|
||
widgets:
|
||
- arc:
|
||
align: CENTER
|
||
width: 60
|
||
height: 60
|
||
start_angle: 0
|
||
end_angle: 45
|
||
min_value: 0
|
||
max_value: 100
|
||
value: 0
|
||
adjustable: false
|
||
arc_width: 15
|
||
arc_rounded: false
|
||
arc_color: 0xFF3B30
|
||
arc_opa: COVER
|
||
bg_opa: TRANSP
|
||
border_width: 0
|
||
clickable: false
|
||
indicator:
|
||
arc_opa: TRANSP
|
||
knob:
|
||
bg_opa: TRANSP
|
||
border_width: 0
|
||
- arc:
|
||
align: CENTER
|
||
width: 60
|
||
height: 60
|
||
start_angle: 45
|
||
end_angle: 90
|
||
min_value: 0
|
||
max_value: 100
|
||
value: 0
|
||
adjustable: false
|
||
arc_width: 15
|
||
arc_rounded: false
|
||
arc_color: 0xFF9500
|
||
arc_opa: COVER
|
||
bg_opa: TRANSP
|
||
border_width: 0
|
||
clickable: false
|
||
indicator:
|
||
arc_opa: TRANSP
|
||
knob:
|
||
bg_opa: TRANSP
|
||
border_width: 0
|
||
- arc:
|
||
align: CENTER
|
||
width: 60
|
||
height: 60
|
||
start_angle: 90
|
||
end_angle: 135
|
||
min_value: 0
|
||
max_value: 100
|
||
value: 0
|
||
adjustable: false
|
||
arc_width: 15
|
||
arc_rounded: false
|
||
arc_color: 0xFFD60A
|
||
arc_opa: COVER
|
||
bg_opa: TRANSP
|
||
border_width: 0
|
||
clickable: false
|
||
indicator:
|
||
arc_opa: TRANSP
|
||
knob:
|
||
bg_opa: TRANSP
|
||
border_width: 0
|
||
- arc:
|
||
align: CENTER
|
||
width: 60
|
||
height: 60
|
||
start_angle: 135
|
||
end_angle: 180
|
||
min_value: 0
|
||
max_value: 100
|
||
value: 0
|
||
adjustable: false
|
||
arc_width: 15
|
||
arc_rounded: false
|
||
arc_color: 0x30D158
|
||
arc_opa: COVER
|
||
bg_opa: TRANSP
|
||
border_width: 0
|
||
clickable: false
|
||
indicator:
|
||
arc_opa: TRANSP
|
||
knob:
|
||
bg_opa: TRANSP
|
||
border_width: 0
|
||
- arc:
|
||
align: CENTER
|
||
width: 60
|
||
height: 60
|
||
start_angle: 180
|
||
end_angle: 225
|
||
min_value: 0
|
||
max_value: 100
|
||
value: 0
|
||
adjustable: false
|
||
arc_width: 15
|
||
arc_rounded: false
|
||
arc_color: 0x32D6FF
|
||
arc_opa: COVER
|
||
bg_opa: TRANSP
|
||
border_width: 0
|
||
clickable: false
|
||
indicator:
|
||
arc_opa: TRANSP
|
||
knob:
|
||
bg_opa: TRANSP
|
||
border_width: 0
|
||
- arc:
|
||
align: CENTER
|
||
width: 60
|
||
height: 60
|
||
start_angle: 225
|
||
end_angle: 270
|
||
min_value: 0
|
||
max_value: 100
|
||
value: 0
|
||
adjustable: false
|
||
arc_width: 15
|
||
arc_rounded: false
|
||
arc_color: 0x0A84FF
|
||
arc_opa: COVER
|
||
bg_opa: TRANSP
|
||
border_width: 0
|
||
clickable: false
|
||
indicator:
|
||
arc_opa: TRANSP
|
||
knob:
|
||
bg_opa: TRANSP
|
||
border_width: 0
|
||
- arc:
|
||
align: CENTER
|
||
width: 60
|
||
height: 60
|
||
start_angle: 270
|
||
end_angle: 315
|
||
min_value: 0
|
||
max_value: 100
|
||
value: 0
|
||
adjustable: false
|
||
arc_width: 15
|
||
arc_rounded: false
|
||
arc_color: 0x5856D6
|
||
arc_opa: COVER
|
||
bg_opa: TRANSP
|
||
border_width: 0
|
||
clickable: false
|
||
indicator:
|
||
arc_opa: TRANSP
|
||
knob:
|
||
bg_opa: TRANSP
|
||
border_width: 0
|
||
- arc:
|
||
align: CENTER
|
||
width: 60
|
||
height: 60
|
||
start_angle: 315
|
||
end_angle: 360
|
||
min_value: 0
|
||
max_value: 100
|
||
value: 0
|
||
adjustable: false
|
||
arc_width: 15
|
||
arc_rounded: false
|
||
arc_color: 0xBF5AF2
|
||
arc_opa: COVER
|
||
bg_opa: TRANSP
|
||
border_width: 0
|
||
clickable: false
|
||
indicator:
|
||
arc_opa: TRANSP
|
||
knob:
|
||
bg_opa: TRANSP
|
||
border_width: 0
|
||
|
||
# ---------- LIGHT COLOR DETAIL OVERLAY ----------
|
||
- obj:
|
||
id: color_detail_overlay
|
||
x: 0
|
||
y: 0
|
||
width: ${DISPLAY_W}
|
||
height: ${DISPLAY_H}
|
||
styles: paint_page_soft
|
||
bg_opa: COVER
|
||
hidden: true
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
pad_left: 0
|
||
pad_right: 0
|
||
pad_top: 0
|
||
pad_bottom: 0
|
||
border_width: 0
|
||
widgets:
|
||
- obj:
|
||
id: color_close_button
|
||
align: TOP_LEFT
|
||
x: 16
|
||
y: 16
|
||
width: 60
|
||
height: 60
|
||
radius: 999
|
||
styles: paint_key
|
||
bg_opa: COVER
|
||
border_width: 0
|
||
clickable: true
|
||
scrollable: false
|
||
on_click:
|
||
- script.execute: close_cards
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "\U000F004D"
|
||
text_font: materialdesign_icons_mini
|
||
clickable: false
|
||
|
||
- label:
|
||
id: color_detail_title
|
||
width: 310
|
||
long_mode: DOT
|
||
text_align: CENTER
|
||
align: TOP_MID
|
||
y: 35
|
||
text: "Light Color"
|
||
styles: style_room
|
||
|
||
|
||
# ---------- 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}
|
||
styles: style_page
|
||
bg_opa: COVER
|
||
hidden: true
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
pad_left: 0
|
||
pad_right: 0
|
||
pad_top: 0
|
||
pad_bottom: 0
|
||
border_width: 0
|
||
widgets:
|
||
- obj:
|
||
id: climate_close_button
|
||
align: TOP_LEFT
|
||
x: 16
|
||
y: 16
|
||
width: 60
|
||
height: 60
|
||
radius: 999
|
||
styles: paint_key
|
||
bg_opa: COVER
|
||
border_width: 0
|
||
clickable: true
|
||
scrollable: false
|
||
on_click:
|
||
- script.execute: close_cards
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "\U000F004D"
|
||
text_font: materialdesign_icons_mini
|
||
clickable: false
|
||
|
||
- obj:
|
||
id: climate_power_button
|
||
align: TOP_RIGHT
|
||
x: -16
|
||
y: 16
|
||
width: 60
|
||
height: 60
|
||
radius: 999
|
||
bg_opa: COVER
|
||
border_width: 0
|
||
clickable: true
|
||
scrollable: false
|
||
on_click:
|
||
- script.execute: climate_power_toggle
|
||
widgets:
|
||
- label:
|
||
id: climate_power_icon
|
||
align: CENTER
|
||
text: "\U000F0425"
|
||
text_font: materialdesign_icons_mini
|
||
clickable: false
|
||
|
||
- label:
|
||
id: climate_detail_title
|
||
align: TOP_MID
|
||
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: 92
|
||
width: 94%
|
||
long_mode: DOT
|
||
text_align: CENTER
|
||
text: "--"
|
||
text_font: sublabel_big
|
||
styles: paint_muted
|
||
clickable: false
|
||
|
||
- obj:
|
||
id: climate_setpoint_card
|
||
align: TOP_MID
|
||
y: 136
|
||
width: 94%
|
||
height: 200
|
||
radius: 24
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
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
|
||
styles: paint_track
|
||
bg_opa: COVER
|
||
border_width: 0
|
||
pad_all: 0
|
||
clickable: true
|
||
scrollable: false
|
||
pressed:
|
||
styles: paint_key_pressed
|
||
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
|
||
clickable: false
|
||
- obj:
|
||
id: climate_plus_button
|
||
align: RIGHT_MID
|
||
x: -18
|
||
width: 116
|
||
height: 116
|
||
radius: 58
|
||
styles: paint_track
|
||
bg_opa: COVER
|
||
border_width: 0
|
||
pad_all: 0
|
||
clickable: true
|
||
scrollable: false
|
||
pressed:
|
||
styles: paint_key_pressed
|
||
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
|
||
clickable: false
|
||
- label:
|
||
id: climate_target_label
|
||
align: CENTER
|
||
y: -14
|
||
text: "--"
|
||
text_font: setpoint_digits
|
||
clickable: false
|
||
- label:
|
||
id: climate_set_label
|
||
align: CENTER
|
||
y: 46
|
||
text: "Target"
|
||
text_font: sublabel
|
||
styles: paint_subtle
|
||
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
|
||
pad_all: 0
|
||
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
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
pad_all: 0
|
||
clickable: true
|
||
scrollable: false
|
||
pressed:
|
||
styles: paint_key
|
||
on_click:
|
||
- script.execute: { id: set_climate_mode, mode: "auto" }
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "\U000F1B17"
|
||
text_font: materialdesign_icons
|
||
clickable: false
|
||
- obj:
|
||
id: cmkey_heat_cool
|
||
flex_grow: 1
|
||
height: 76
|
||
radius: 38
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
pad_all: 0
|
||
clickable: true
|
||
scrollable: false
|
||
pressed:
|
||
styles: paint_key
|
||
on_click:
|
||
- script.execute: { id: set_climate_mode, mode: "heat_cool" }
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "\U000F1A79"
|
||
text_font: materialdesign_icons
|
||
clickable: false
|
||
- obj:
|
||
id: cmkey_heat
|
||
flex_grow: 1
|
||
height: 76
|
||
radius: 38
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
pad_all: 0
|
||
clickable: true
|
||
scrollable: false
|
||
pressed:
|
||
styles: paint_key
|
||
on_click:
|
||
- script.execute: { id: set_climate_mode, mode: "heat" }
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "\U000F0238"
|
||
text_font: materialdesign_icons
|
||
clickable: false
|
||
- obj:
|
||
id: cmkey_cool
|
||
flex_grow: 1
|
||
height: 76
|
||
radius: 38
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
pad_all: 0
|
||
clickable: true
|
||
scrollable: false
|
||
pressed:
|
||
styles: paint_key
|
||
on_click:
|
||
- script.execute: { id: set_climate_mode, mode: "cool" }
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "\U000F0717"
|
||
text_font: materialdesign_icons
|
||
clickable: false
|
||
- obj:
|
||
id: cmkey_dry
|
||
flex_grow: 1
|
||
height: 76
|
||
radius: 38
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
pad_all: 0
|
||
clickable: true
|
||
scrollable: false
|
||
pressed:
|
||
styles: paint_key
|
||
on_click:
|
||
- script.execute: { id: set_climate_mode, mode: "dry" }
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "\U000F058E"
|
||
text_font: materialdesign_icons
|
||
clickable: false
|
||
- obj:
|
||
id: cmkey_fan_only
|
||
flex_grow: 1
|
||
height: 76
|
||
radius: 38
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
pad_all: 0
|
||
clickable: true
|
||
scrollable: false
|
||
pressed:
|
||
styles: paint_key
|
||
on_click:
|
||
- script.execute: { id: set_climate_mode, mode: "fan_only" }
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "\U000F0210"
|
||
text_font: materialdesign_icons
|
||
clickable: false
|
||
- obj:
|
||
id: cmkey_more
|
||
flex_grow: 1
|
||
height: 76
|
||
radius: 38
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
pad_all: 0
|
||
clickable: true
|
||
scrollable: false
|
||
pressed:
|
||
styles: paint_key
|
||
on_click:
|
||
- script.execute: open_climate_mode_picker
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "\U000F01D8"
|
||
text_font: materialdesign_icons
|
||
styles: paint_slate
|
||
clickable: false
|
||
# Fan and swing, each a row of choices behind its icon (climate_card.h fills the card).
|
||
- obj:
|
||
id: climate_settings_card
|
||
align: TOP_MID
|
||
y: 340
|
||
width: 94%
|
||
height: 116
|
||
radius: 24
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
pad_all: 0
|
||
hidden: true
|
||
clickable: false
|
||
scrollable: 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
|
||
# 'heat' and 'fan_only' along the way. Here you pick directly, and exactly
|
||
# one command is sent.
|
||
- obj:
|
||
id: climate_mode_overlay
|
||
x: 0
|
||
y: 0
|
||
width: ${DISPLAY_W}
|
||
height: ${DISPLAY_H}
|
||
hidden: true
|
||
styles: style_page
|
||
bg_opa: COVER
|
||
border_width: 0
|
||
clickable: true
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
widgets:
|
||
- label:
|
||
align: TOP_MID
|
||
y: 35
|
||
text: "Mode"
|
||
text_font: sublabel_big
|
||
clickable: false
|
||
|
||
# Flex ensures unsupported modes don't leave a gap:
|
||
# LVGL skips hidden children when arranging.
|
||
- obj:
|
||
id: climate_mode_list
|
||
align: TOP_MID
|
||
y: 92
|
||
width: 98%
|
||
height: 372
|
||
bg_opa: TRANSP
|
||
border_width: 0
|
||
scrollable: false
|
||
clickable: false
|
||
pad_all: 4
|
||
layout:
|
||
type: FLEX
|
||
pad_row: 6
|
||
pad_column: 6
|
||
flex_flow: ROW_WRAP
|
||
flex_align_main: CENTER
|
||
flex_align_cross: CENTER
|
||
flex_align_track: CENTER
|
||
widgets:
|
||
- obj:
|
||
id: cmode_off
|
||
width: 136
|
||
height: 58
|
||
radius: 22
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
clickable: true
|
||
scrollable: false
|
||
pad_all: 0
|
||
on_click:
|
||
- script.execute: { id: set_climate_mode, mode: "off" }
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "Off"
|
||
text_font: sublabel_big
|
||
clickable: false
|
||
- obj:
|
||
id: cmode_heat
|
||
width: 136
|
||
height: 58
|
||
radius: 22
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
clickable: true
|
||
scrollable: false
|
||
pad_all: 0
|
||
on_click:
|
||
- script.execute: { id: set_climate_mode, mode: "heat" }
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "Heat"
|
||
text_font: sublabel_big
|
||
clickable: false
|
||
- obj:
|
||
id: cmode_cool
|
||
width: 136
|
||
height: 58
|
||
radius: 22
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
clickable: true
|
||
scrollable: false
|
||
pad_all: 0
|
||
on_click:
|
||
- script.execute: { id: set_climate_mode, mode: "cool" }
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "Cool"
|
||
text_font: sublabel_big
|
||
clickable: false
|
||
- obj:
|
||
id: cmode_heat_cool
|
||
width: 136
|
||
height: 58
|
||
radius: 22
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
clickable: true
|
||
scrollable: false
|
||
pad_all: 0
|
||
on_click:
|
||
- script.execute: { id: set_climate_mode, mode: "heat_cool" }
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "Heat/Cool"
|
||
text_font: sublabel_big
|
||
clickable: false
|
||
- obj:
|
||
id: cmode_auto
|
||
width: 136
|
||
height: 58
|
||
radius: 22
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
clickable: true
|
||
scrollable: false
|
||
pad_all: 0
|
||
on_click:
|
||
- script.execute: { id: set_climate_mode, mode: "auto" }
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "Auto"
|
||
text_font: sublabel_big
|
||
clickable: false
|
||
- obj:
|
||
id: cmode_dry
|
||
width: 136
|
||
height: 58
|
||
radius: 22
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
clickable: true
|
||
scrollable: false
|
||
pad_all: 0
|
||
on_click:
|
||
- script.execute: { id: set_climate_mode, mode: "dry" }
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "Dry"
|
||
text_font: sublabel_big
|
||
clickable: false
|
||
- obj:
|
||
id: cmode_fan_only
|
||
width: 136
|
||
height: 58
|
||
radius: 22
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
clickable: true
|
||
scrollable: false
|
||
pad_all: 0
|
||
on_click:
|
||
- script.execute: { id: set_climate_mode, mode: "fan_only" }
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "Fan only"
|
||
text_font: sublabel_big
|
||
clickable: false
|
||
|
||
- label:
|
||
id: cfan_title
|
||
width: 100%
|
||
height: 24
|
||
text: "Fan"
|
||
text_font: sublabel
|
||
styles: paint_subtle
|
||
text_align: CENTER
|
||
clickable: false
|
||
- obj:
|
||
id: cfan_0
|
||
width: 98
|
||
height: 48
|
||
radius: 18
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
clickable: true
|
||
scrollable: false
|
||
pad_all: 0
|
||
on_click:
|
||
- script.execute: { id: set_climate_fan_mode, index: 0 }
|
||
widgets:
|
||
- label:
|
||
id: cfan_0_label
|
||
align: CENTER
|
||
text: "-"
|
||
text_font: sublabel_big
|
||
clickable: false
|
||
- obj:
|
||
id: cfan_1
|
||
width: 98
|
||
height: 48
|
||
radius: 18
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
clickable: true
|
||
scrollable: false
|
||
pad_all: 0
|
||
on_click:
|
||
- script.execute: { id: set_climate_fan_mode, index: 1 }
|
||
widgets:
|
||
- label:
|
||
id: cfan_1_label
|
||
align: CENTER
|
||
text: "-"
|
||
text_font: sublabel_big
|
||
clickable: false
|
||
- obj:
|
||
id: cfan_2
|
||
width: 98
|
||
height: 48
|
||
radius: 18
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
clickable: true
|
||
scrollable: false
|
||
pad_all: 0
|
||
on_click:
|
||
- script.execute: { id: set_climate_fan_mode, index: 2 }
|
||
widgets:
|
||
- label:
|
||
id: cfan_2_label
|
||
align: CENTER
|
||
text: "-"
|
||
text_font: sublabel_big
|
||
clickable: false
|
||
- obj:
|
||
id: cfan_3
|
||
width: 98
|
||
height: 48
|
||
radius: 18
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
clickable: true
|
||
scrollable: false
|
||
pad_all: 0
|
||
on_click:
|
||
- script.execute: { id: set_climate_fan_mode, index: 3 }
|
||
widgets:
|
||
- label:
|
||
id: cfan_3_label
|
||
align: CENTER
|
||
text: "-"
|
||
text_font: sublabel_big
|
||
clickable: false
|
||
- label:
|
||
id: cswing_title
|
||
width: 100%
|
||
height: 24
|
||
text: "Swing"
|
||
text_font: sublabel
|
||
styles: paint_subtle
|
||
text_align: CENTER
|
||
clickable: false
|
||
- obj:
|
||
id: cswing_0
|
||
width: 98
|
||
height: 48
|
||
radius: 18
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
clickable: true
|
||
scrollable: false
|
||
pad_all: 0
|
||
on_click:
|
||
- script.execute: { id: set_climate_swing_mode, index: 0 }
|
||
widgets:
|
||
- label:
|
||
id: cswing_0_label
|
||
align: CENTER
|
||
text: "-"
|
||
text_font: sublabel_big
|
||
clickable: false
|
||
- obj:
|
||
id: cswing_1
|
||
width: 98
|
||
height: 48
|
||
radius: 18
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
clickable: true
|
||
scrollable: false
|
||
pad_all: 0
|
||
on_click:
|
||
- script.execute: { id: set_climate_swing_mode, index: 1 }
|
||
widgets:
|
||
- label:
|
||
id: cswing_1_label
|
||
align: CENTER
|
||
text: "-"
|
||
text_font: sublabel_big
|
||
clickable: false
|
||
- obj:
|
||
id: cswing_2
|
||
width: 98
|
||
height: 48
|
||
radius: 18
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
clickable: true
|
||
scrollable: false
|
||
pad_all: 0
|
||
on_click:
|
||
- script.execute: { id: set_climate_swing_mode, index: 2 }
|
||
widgets:
|
||
- label:
|
||
id: cswing_2_label
|
||
align: CENTER
|
||
text: "-"
|
||
text_font: sublabel_big
|
||
clickable: false
|
||
- obj:
|
||
id: cswing_3
|
||
width: 98
|
||
height: 48
|
||
radius: 18
|
||
styles: paint_card
|
||
bg_opa: COVER
|
||
border_width: 1
|
||
clickable: true
|
||
scrollable: false
|
||
pad_all: 0
|
||
on_click:
|
||
- script.execute: { id: set_climate_swing_mode, index: 3 }
|
||
widgets:
|
||
- label:
|
||
id: cswing_3_label
|
||
align: CENTER
|
||
text: "-"
|
||
text_font: sublabel_big
|
||
clickable: false
|
||
- obj:
|
||
id: climate_mode_overlay_x
|
||
align: TOP_LEFT
|
||
x: 16
|
||
y: 16
|
||
width: 60
|
||
height: 60
|
||
radius: 999
|
||
styles: paint_key
|
||
bg_opa: COVER
|
||
border_width: 0
|
||
clickable: true
|
||
scrollable: false
|
||
on_click:
|
||
- lvgl.widget.hide: climate_mode_overlay
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "\U000F004D"
|
||
text_font: materialdesign_icons_mini
|
||
clickable: false
|
||
|
||
|
||
# ---------- VACUUM CONTROL ----------
|
||
# A vacuum is not a switch: start, pause, stop and return to
|
||
# the dock are separate commands, and suction power is a separate axis.
|
||
|
||
|
||
# ---------- DIM / WAKE OVERLAY ----------
|
||
- obj:
|
||
id: dim_wake_overlay
|
||
x: 0
|
||
y: 0
|
||
width: ${DISPLAY_W}
|
||
height: ${DISPLAY_H}
|
||
hidden: true
|
||
bg_opa: TRANSP
|
||
# The tap that wakes a dimmed screen must not light up a pale sheet over it.
|
||
pressed:
|
||
bg_opa: TRANSP
|
||
border_width: 0
|
||
scrollable: false
|
||
scrollbar_mode: 'OFF'
|
||
clickable: true
|
||
on_click:
|
||
- script.execute: wake_display
|
||
# A page that is one full tile switching something on or off switches with this push as well
|
||
# (firmware 0.2.65+, runtime_tiles::wake_tap); on every other page the first touch only wakes.
|
||
- lambda: 'runtime_tiles::wake_tap();'
|
||
|
||
- id: touch_cal_page
|
||
styles: paint_page_soft
|
||
bg_opa: COVER
|
||
pad_all: 0
|
||
border_width: 0
|
||
scrollable: false
|
||
widgets:
|
||
- label:
|
||
align: TOP_MID
|
||
y: 110
|
||
text: "GT911 touch check"
|
||
text_font: headline
|
||
- obj:
|
||
x: 12
|
||
y: 12
|
||
width: 24
|
||
height: 24
|
||
pad_all: 0
|
||
border_width: 0
|
||
bg_opa: TRANSP
|
||
scrollable: false
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "+"
|
||
text_font: headline
|
||
styles: paint_ink
|
||
- obj:
|
||
x: 443
|
||
y: 12
|
||
width: 24
|
||
height: 24
|
||
pad_all: 0
|
||
border_width: 0
|
||
bg_opa: TRANSP
|
||
scrollable: false
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "+"
|
||
text_font: headline
|
||
styles: paint_ink
|
||
- obj:
|
||
x: 443
|
||
y: 443
|
||
width: 24
|
||
height: 24
|
||
pad_all: 0
|
||
border_width: 0
|
||
bg_opa: TRANSP
|
||
scrollable: false
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "+"
|
||
text_font: headline
|
||
styles: paint_ink
|
||
- obj:
|
||
x: 12
|
||
y: 443
|
||
width: 24
|
||
height: 24
|
||
pad_all: 0
|
||
border_width: 0
|
||
bg_opa: TRANSP
|
||
scrollable: false
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "+"
|
||
text_font: headline
|
||
styles: paint_ink
|
||
- obj:
|
||
x: 228
|
||
y: 228
|
||
width: 24
|
||
height: 24
|
||
pad_all: 0
|
||
border_width: 0
|
||
bg_opa: TRANSP
|
||
scrollable: false
|
||
widgets:
|
||
- label:
|
||
align: CENTER
|
||
text: "+"
|
||
text_font: headline
|
||
styles: paint_ink
|
||
|
||
script:
|
||
- id: capture_ui_snapshot
|
||
mode: single
|
||
then:
|
||
- lambda: guition::snapshot_begin();
|
||
- repeat:
|
||
count: 240
|
||
then:
|
||
- lambda: guition::snapshot_next_row();
|
||
- delay: 100ms
|
||
# Render every overlay and both pages repeatedly without controlling devices.
|
||
- id: ui_self_test
|
||
mode: single
|
||
then:
|
||
- logger.log: "UI_TEST START: page/overlay render stress, no HA actions"
|
||
- lambda: 'ESP_LOGI("ui_test", "Light sliders: %s", light_controls::self_test() ? "PASS" : "FAIL");'
|
||
- lambda: 'id(calibration_active) = false;'
|
||
- lvgl.page.show: home_page
|
||
- script.execute: wake_display
|
||
- repeat:
|
||
count: 10
|
||
then:
|
||
- lambda: |-
|
||
id(last_touch_ms) = millis();
|
||
id(last_use_ms) = millis();
|
||
static unsigned test_page=0;
|
||
id(tile_page) = runtime_tiles::enabled ? test_page++ % runtime_tiles::page_count() : 1;
|
||
- script.execute: show_tile_page
|
||
- delay: 300ms
|
||
- lambda: |-
|
||
lv_obj_update_layout(id(tile_scroll));
|
||
const int count = runtime_tiles::model.count;
|
||
// Wide cards take two slots, so visibility follows the runtime packing.
|
||
const bool paginated = runtime_tiles::enabled ? runtime_tiles::page_count() > 1 : count > 6;
|
||
lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5),
|
||
id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)};
|
||
bool ok = guition::check_geometry(id(home_page)->obj);
|
||
for (int i = 0; i < 10; ++i) {
|
||
bool visible = runtime_tiles::enabled ? i<6 && runtime_tiles::widgets[i].index<runtime_tiles::model.count : i < count && (paginated ? i >= 6 : i < 6);
|
||
ok = ok && (lv_obj_has_flag(tiles[i], LV_OBJ_FLAG_HIDDEN) != visible);
|
||
}
|
||
for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)})
|
||
ok = ok && (lv_obj_has_flag(control, LV_OBJ_FLAG_HIDDEN) != paginated);
|
||
lv_area_t nav, grid;
|
||
lv_obj_get_coords(id(page_next), &nav);
|
||
lv_obj_get_coords(id(tile_scroll), &grid);
|
||
ok = ok && (!paginated || grid.y2 < nav.y1);
|
||
if(runtime_tiles::enabled)ok = runtime_tiles::check_tile_geometry() && ok;
|
||
ESP_LOGI("ui_test", "page_check=%s count=%d heap=%u frames=%u grid_bottom=%d nav_top=%d", ok ? "PASS" : "FAIL", count, (unsigned) esp_get_free_heap_size(), (unsigned) id(rendered_frames), grid.y2, nav.y1);
|
||
id(tile_page) = 0;
|
||
- script.execute: show_tile_page
|
||
- delay: 150ms
|
||
- lvgl.widget.show: brightness_overlay
|
||
- delay: 150ms
|
||
- lambda: guition::check_geometry(id(brightness_overlay));
|
||
- lvgl.widget.hide: brightness_overlay
|
||
- lvgl.widget.show: color_detail_overlay
|
||
- delay: 150ms
|
||
- lambda: guition::check_geometry(id(color_detail_overlay));
|
||
- lvgl.widget.hide: color_detail_overlay
|
||
- lvgl.widget.show: climate_detail_overlay
|
||
- delay: 150ms
|
||
- lambda: guition::check_geometry(id(climate_detail_overlay));
|
||
- lvgl.widget.hide: climate_detail_overlay
|
||
- lvgl.widget.show: climate_mode_overlay
|
||
- delay: 150ms
|
||
- lambda: guition::check_geometry(id(climate_mode_overlay));
|
||
- lvgl.widget.hide: climate_mode_overlay
|
||
- lvgl.widget.show: alert_overlay
|
||
- delay: 150ms
|
||
- lvgl.widget.hide: alert_overlay
|
||
- lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();'
|
||
- logger.log: "UI_TEST COMPLETE"
|
||
|
||
- id: show_tile_page
|
||
then:
|
||
- lambda: |-
|
||
if(runtime_tiles::enabled){
|
||
runtime_tiles::show_page(id(tile_page),id(page_prev),id(page_next),id(page_number));
|
||
return;
|
||
}
|
||
lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5),
|
||
id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)};
|
||
const int rows[] = {${GRID_ROW1_Y}, ${GRID_ROW2_Y}, ${GRID_ROW3_Y}};
|
||
const int count = runtime_tiles::model.count;
|
||
const bool paginated = count > 6;
|
||
if (!paginated) id(tile_page) = 0;
|
||
for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)}) {
|
||
if (paginated) lv_obj_clear_flag(control, LV_OBJ_FLAG_HIDDEN);
|
||
else lv_obj_add_flag(control, LV_OBJ_FLAG_HIDDEN);
|
||
}
|
||
for (int i = 0; i < 10; ++i) {
|
||
bool visible = i < count && (id(tile_page) == 0 ? i < 6 : i >= 6);
|
||
if (visible) {
|
||
lv_obj_set_y(tiles[i], rows[(i < 6 ? i : i - 6) / 2]);
|
||
lv_obj_clear_flag(tiles[i], LV_OBJ_FLAG_HIDDEN);
|
||
} else {
|
||
lv_obj_add_flag(tiles[i], LV_OBJ_FLAG_HIDDEN);
|
||
}
|
||
}
|
||
lv_label_set_text(id(page_number), id(tile_page) == 0 ? "1 / 2" : "2 / 2");
|
||
if (id(tile_page) == 0) {
|
||
lv_obj_add_state(id(page_prev), LV_STATE_DISABLED);
|
||
lv_obj_clear_state(id(page_next), LV_STATE_DISABLED);
|
||
} else {
|
||
lv_obj_clear_state(id(page_prev), LV_STATE_DISABLED);
|
||
lv_obj_add_state(id(page_next), LV_STATE_DISABLED);
|
||
}
|
||
|
||
# Runtime changes do not count as touches and do not reset the idle timer.
|
||
- id: apply_screen_settings
|
||
mode: restart
|
||
then:
|
||
- lambda: |-
|
||
const auto &settings = screen_settings::current;
|
||
// Dark mode (firmware 0.2.54+): a look the screen already has costs nothing.
|
||
theme::set_dark(settings_screen::dark_mode != 0);
|
||
if(runtime_tiles::enabled && static_cast<unsigned>(id(screen_lvgl).get_rotation())!=runtime_tiles::rotation){
|
||
cyd::touch_guard.consume();
|
||
if(lv_indev_get_act())lv_indev_wait_release(lv_indev_get_act());
|
||
id(screen_lvgl).set_rotation(static_cast<esphome::display::DisplayRotation>(runtime_tiles::rotation));
|
||
}
|
||
// Auto standby off wakes the screen, but not out of a Sleep pressed in Home Assistant.
|
||
if (id(display_dimmed) && !settings.standby_enabled && !id(sleep_requested)) {
|
||
id(display_dimmed) = false;
|
||
}
|
||
auto now = id(homeassistant_time).now();
|
||
const int minute = now.is_valid() ? now.hour * 60 + now.minute : -1;
|
||
const int level = id(display_dimmed) ? settings.dim_level(minute) : settings.brightness;
|
||
// Standby dims slowly (ESPHome's transition); waking is quick.
|
||
id(set_backlight).execute(level, id(display_dimmed) ? 1500 : 300);
|
||
if (id(display_dimmed)) { lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(id(dim_wake_overlay)); }
|
||
else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN);
|
||
// The setting numbers and times only publish a changed value (HA keeps identical states anyway); the
|
||
// switches publish their own changes. An open settings page shows a change from Home Assistant too.
|
||
// A number without a state publishes too: a night brightness of 0 equals the number's initial state and
|
||
// stayed "unknown" in Home Assistant before firmware 0.2.50.
|
||
for (auto [entity, value] : {std::make_pair(id(setting_brightness), settings.brightness),
|
||
std::make_pair(id(setting_standby_brightness), settings.standby_brightness),
|
||
std::make_pair(id(setting_night_brightness), settings.night_brightness),
|
||
std::make_pair(id(setting_standby_seconds), settings.standby_seconds),
|
||
std::make_pair(id(setting_auto_home_seconds), settings_screen::auto_home_seconds)}) {
|
||
if (!entity->has_state() || entity->state != value) entity->publish_state(value);
|
||
}
|
||
for (auto [entity, minutes] : {std::make_pair(id(setting_night_start), settings.night_start), std::make_pair(id(setting_night_end), settings.night_end)}) {
|
||
if (!entity->has_state() || entity->hour * 60 + entity->minute != minutes) entity->update();
|
||
}
|
||
const auto shown_rotation = id(setting_rotation)->active_index();
|
||
if (!id(setting_rotation)->has_state() || !shown_rotation.has_value() || *shown_rotation != (size_t) (settings_screen::rotation / 90)) id(setting_rotation)->update();
|
||
settings_screen::refresh();
|
||
if (!id(ui_refresh).is_running()) id(ui_refresh).execute();
|
||
|
||
# Every card closes through runtime_tiles::dismiss (set in on_boot): the runtime detail card, the light,
|
||
# colour and climate overlays, and active_entity, which the swipe guard reads.
|
||
- id: close_cards
|
||
then:
|
||
- lambda: 'if (runtime_tiles::dismiss) runtime_tiles::dismiss();'
|
||
|
||
# Everything back to the overview: the settings page, every card and the first page of tiles.
|
||
# Used by standby, by the "Back to page 1" timeout and by the Home Assistant action.
|
||
- id: go_home
|
||
then:
|
||
- lambda: 'settings_screen::close();'
|
||
- script.execute: close_cards
|
||
- lambda: 'id(tile_page) = 0;'
|
||
- script.execute: show_tile_page
|
||
|
||
# Back to page 1 by itself (0.2.44+). A card, a settings page or a second page left open keeps
|
||
# showing what someone looked at minutes ago; after this long the screen is a screen again.
|
||
# Independent of standby, which may be off or much later; paused while the screen is in standby.
|
||
# The time runs from the last touch (last_use_ms, firmware 0.2.56+): Wake, an alert or Auto standby
|
||
# light the screen and restart the standby time, but nobody used a page. Checked every second and by Wake.
|
||
- id: back_to_page_1_when_due
|
||
then:
|
||
- if:
|
||
condition:
|
||
lambda: |-
|
||
if (!runtime_tiles::auto_home || id(touch_down) || id(calibration_active) || id(alert_active)) return false;
|
||
if (id(display_dimmed)) return false;
|
||
bool away = settings_screen::visible() || runtime_tiles::camera_visible() || !id(active_entity).empty() || id(tile_page) != 0 ||
|
||
(runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN));
|
||
return away && (millis() - id(last_use_ms)) > (uint32_t) runtime_tiles::auto_home_seconds * 1000UL;
|
||
then:
|
||
- script.execute: go_home
|
||
|
||
# ---------- ALERT (HA action show_alert) ----------
|
||
# The screen wakes, keeps its normal brightness and shows the card until OK or the
|
||
# timeout (0: until OK); the standby interval waits on alert_active. A new alert
|
||
# replaces the current one; every ending reports itself as esphome.screen_alert.
|
||
- id: alert_show
|
||
mode: restart
|
||
parameters:
|
||
title: string
|
||
subtitle: string
|
||
icon: string
|
||
color: string
|
||
button_text: string
|
||
timeout: int
|
||
flash: bool
|
||
then:
|
||
- lambda: |-
|
||
if (id(alert_active)) id(alert_report).execute("replaced");
|
||
// An alert is the one thing that outranks the settings page, so that closes first.
|
||
settings_screen::close();
|
||
// An open camera closes; the card makes room for an image the app announced for this alert.
|
||
runtime_tiles::alert_prepare();
|
||
const auto alert = screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX});
|
||
id(alert_active) = true;
|
||
id(alert_title_text) = alert.title;
|
||
id(alert_timeout_seconds) = alert.timeout_seconds;
|
||
id(alert_flash_requested) = alert.flash;
|
||
lv_label_set_text(id(alert_icon), alert.icon.c_str());
|
||
lv_label_set_text(id(alert_title), alert.title.c_str());
|
||
lv_label_set_text(id(alert_subtitle), alert.subtitle.c_str());
|
||
lv_label_set_text(id(alert_ok_label), alert.button.c_str());
|
||
id(alert_card_color) = alert.color;
|
||
lv_obj_set_style_bg_color(id(alert_card), lv_color_hex(theme::surface(alert.color)), LV_PART_MAIN);
|
||
ESP_LOGI("alert", "show \"%s\" timeout=%d flash=%d", alert.title.c_str(), alert.timeout_seconds, (int) alert.flash);
|
||
- script.execute: wake_display
|
||
- lvgl.widget.show: alert_overlay
|
||
- script.stop: alert_timeout
|
||
- if:
|
||
condition:
|
||
lambda: 'return id(alert_flash_requested);'
|
||
then:
|
||
- script.execute: alert_flash
|
||
- if:
|
||
condition:
|
||
lambda: 'return id(alert_timeout_seconds) > 0;'
|
||
then:
|
||
- script.execute:
|
||
id: alert_timeout
|
||
seconds: !lambda 'return id(alert_timeout_seconds);'
|
||
|
||
- id: alert_dismiss
|
||
parameters:
|
||
reason: string
|
||
then:
|
||
- if:
|
||
condition:
|
||
lambda: 'return id(alert_active);'
|
||
then:
|
||
- lambda: |-
|
||
id(alert_active) = false;
|
||
id(last_touch_ms) = millis();
|
||
runtime_tiles::alert_clear();
|
||
ESP_LOGI("alert", "dismissed: %s", reason.c_str());
|
||
- lvgl.widget.hide: alert_overlay
|
||
- script.stop: alert_timeout
|
||
- script.stop: alert_flash
|
||
- lambda: 'id(set_backlight).execute(screen_settings::current.brightness, 80);'
|
||
- script.execute:
|
||
id: alert_report
|
||
action: !lambda 'return reason;'
|
||
|
||
# Four backlight blinks when the alert arrives; the screen then stays on as usual.
|
||
- id: alert_flash
|
||
mode: restart
|
||
then:
|
||
- repeat:
|
||
count: 4
|
||
then:
|
||
- lambda: 'id(set_backlight).execute(0, 60);'
|
||
- delay: 140ms
|
||
- lambda: 'id(set_backlight).execute(screen_settings::current.brightness, 60);'
|
||
- delay: 180ms
|
||
|
||
- id: alert_timeout
|
||
mode: restart
|
||
parameters:
|
||
seconds: int
|
||
then:
|
||
- delay: !lambda 'return static_cast<uint32_t>(seconds) * 1000UL;'
|
||
- script.execute:
|
||
id: alert_dismiss
|
||
reason: "timeout"
|
||
|
||
# Home Assistant adds device_id; action is ok, timeout, replaced or remote.
|
||
- id: alert_report
|
||
parameters:
|
||
action: string
|
||
then:
|
||
- homeassistant.event:
|
||
event: esphome.screen_alert
|
||
data:
|
||
action: !lambda 'return action;'
|
||
title: !lambda 'return id(alert_title_text);'
|
||
screen: !lambda 'return App.get_name();'
|
||
|
||
- id: wake_display
|
||
mode: restart
|
||
then:
|
||
- lambda: |-
|
||
id(last_touch_ms) = millis();
|
||
id(display_dimmed) = false;
|
||
id(sleep_requested) = false;
|
||
// Almost instant brightness-up. ESPHome's default is 1s, and that feels sluggish
|
||
// when you touch the screen and are waiting on light.
|
||
id(set_backlight).execute(screen_settings::current.brightness, 80);
|
||
- lvgl.widget.hide: dim_wake_overlay
|
||
- script.execute: close_cards
|
||
|
||
# One owner for the backlight level, through ESPHome's own light transition (firmware 0.2.57+). Firmware 0.2.13-0.2.56
|
||
# stepped the duty with the LEDC hardware fader by reaching into ESPHome's LEDC output, which ESPHome 2026.8 made final;
|
||
# a new call interrupts the running transition from where it is, as ESPHome does for any light.
|
||
- id: set_backlight
|
||
mode: restart
|
||
parameters:
|
||
level: int
|
||
ms: int
|
||
then:
|
||
- lambda: id(back_light).turn_on().set_transition_length(ms).set_brightness(level / 100.0f).perform();
|
||
|
||
# Standby: the standby time ran out (the 1 s interval checks Auto standby) or Sleep was pressed.
|
||
# The settings page and every card close; with "also on standby" the screen goes back to page 1 too.
|
||
- id: dim_display
|
||
mode: restart
|
||
then:
|
||
- lambda: |-
|
||
id(display_dimmed) = true;
|
||
id(apply_screen_settings).execute();
|
||
- if:
|
||
condition:
|
||
lambda: 'return screen_settings::current.home_on_standby;'
|
||
then:
|
||
- script.execute: go_home
|
||
else:
|
||
- lambda: 'settings_screen::close();'
|
||
- script.execute: close_cards
|
||
|
||
# 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:
|
||
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;
|
||
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);
|
||
|
||
# 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:
|
||
entity: string
|
||
value: float
|
||
then:
|
||
- delay: 700ms
|
||
- if:
|
||
condition:
|
||
lambda: 'return runtime_tiles::fresh() && !entity.empty();'
|
||
then:
|
||
- homeassistant.action:
|
||
action: climate.set_temperature
|
||
data:
|
||
entity_id: !lambda "return entity;"
|
||
temperature: !lambda "return value;"
|
||
|
||
# Draws the climate card from the active_climate_* globals: status line, setpoint, -/+ and mode keys.
|
||
- id: climate_card_refresh
|
||
then:
|
||
- lambda: |-
|
||
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), theme::color(off ? theme::OFF : theme::INK), 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, selected ? lv_color_hex(color) : theme::color(theme::CARD), LV_PART_MAIN);
|
||
lv_obj_set_style_border_color(k.obj, selected ? lv_color_hex(color) : theme::color(theme::LINE), LV_PART_MAIN);
|
||
lv_obj_set_style_text_color(lv_obj_get_child(k.obj, 0), theme::color(selected ? theme::ON_ACCENT : theme::SLATE), 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.
|
||
# Opens the vacuum controls and remembers which entity it concerns.
|
||
|
||
# A standalone command: start, pause, stop or return_to_base.
|
||
|
||
# Suction power is a separate axis, apart from start/stop.
|
||
|
||
- id: open_climate_mode_picker
|
||
mode: restart
|
||
then:
|
||
- lambda: |-
|
||
auto lower = [](std::string v) {
|
||
for (char &c : v) { if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); }
|
||
return v;
|
||
};
|
||
std::string supported = lower(id(active_climate_supported_modes));
|
||
std::string current = lower(id(active_climate_mode));
|
||
|
||
auto has = [&](const char* m) {
|
||
std::string mode(m);
|
||
if (supported.empty() || supported == "unknown" || supported == "unavailable") return false;
|
||
return supported.find("'" + mode + "'") != std::string::npos ||
|
||
supported.find("\"" + mode + "\"") != std::string::npos;
|
||
};
|
||
auto apply = [&](lv_obj_t* btn, const char* m) {
|
||
if (has(m)) lv_obj_clear_flag(btn, LV_OBJ_FLAG_HIDDEN);
|
||
else lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN);
|
||
bool selected = (current == std::string(m));
|
||
lv_obj_set_style_bg_color(btn, theme::color(selected ? theme::ACCENT_BRIGHT : theme::CARD), LV_PART_MAIN);
|
||
lv_obj_set_style_text_color(lv_obj_get_child(btn, 0), theme::color(selected ? theme::ON_ACCENT : theme::INK), LV_PART_MAIN);
|
||
};
|
||
apply(id(cmode_off), "off");
|
||
apply(id(cmode_heat), "heat");
|
||
apply(id(cmode_cool), "cool");
|
||
apply(id(cmode_heat_cool), "heat_cool");
|
||
apply(id(cmode_auto), "auto");
|
||
apply(id(cmode_dry), "dry");
|
||
apply(id(cmode_fan_only), "fan_only");
|
||
// Fan and swing rows: the entity's own mode names, at most four each, as Home Assistant reports them.
|
||
auto pretty = [](std::string raw) { if (!raw.empty() && raw[0] >= 'a' && raw[0] <= 'z') raw[0] = (char)(raw[0] - 'a' + 'A'); return raw; };
|
||
auto fill = [&](lv_obj_t *title, lv_obj_t *chips[4], const std::string &modes, const std::string ¤t_mode) {
|
||
unsigned shown = 0;
|
||
for (unsigned i = 0; i < 4; ++i) {
|
||
std::string raw = cyd::list_item(modes, i);
|
||
if (raw.empty()) { lv_obj_add_flag(chips[i], LV_OBJ_FLAG_HIDDEN); continue; }
|
||
lv_obj_clear_flag(chips[i], LV_OBJ_FLAG_HIDDEN); ++shown;
|
||
lv_label_set_text(lv_obj_get_child(chips[i], 0), pretty(raw).c_str());
|
||
lv_obj_set_style_bg_color(chips[i], theme::color(raw == current_mode ? theme::ACCENT_BRIGHT : theme::CARD), LV_PART_MAIN);
|
||
lv_obj_set_style_text_color(lv_obj_get_child(chips[i], 0), theme::color(raw == current_mode ? theme::ON_ACCENT : theme::INK), LV_PART_MAIN);
|
||
}
|
||
if (shown) lv_obj_clear_flag(title, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(title, LV_OBJ_FLAG_HIDDEN);
|
||
};
|
||
lv_obj_t *fan_chips[4] = {id(cfan_0), id(cfan_1), id(cfan_2), id(cfan_3)};
|
||
lv_obj_t *swing_chips[4] = {id(cswing_0), id(cswing_1), id(cswing_2), id(cswing_3)};
|
||
fill(id(cfan_title), fan_chips, id(active_climate_fan_modes), id(active_climate_fan_mode));
|
||
fill(id(cswing_title), swing_chips, id(active_climate_swing_modes), id(active_climate_swing_mode));
|
||
- lvgl.widget.show: climate_mode_overlay
|
||
|
||
# Sets exactly one mode. No intermediate steps, so no unwanted
|
||
# commands to the heat pump along the way.
|
||
# Fan and swing modes from the picker: one call with the entity's own mode name.
|
||
- id: set_climate_fan_mode
|
||
mode: restart
|
||
parameters:
|
||
index: int
|
||
then:
|
||
- lambda: |-
|
||
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:
|
||
lambda: 'return runtime_tiles::fresh() && !id(active_climate_fan_mode).empty();'
|
||
then:
|
||
- homeassistant.action:
|
||
action: climate.set_fan_mode
|
||
data:
|
||
entity_id: !lambda "return id(active_entity);"
|
||
fan_mode: !lambda "return id(active_climate_fan_mode);"
|
||
- id: set_climate_swing_mode
|
||
mode: restart
|
||
parameters:
|
||
index: int
|
||
then:
|
||
- lambda: |-
|
||
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:
|
||
lambda: 'return runtime_tiles::fresh() && !id(active_climate_swing_mode).empty();'
|
||
then:
|
||
- homeassistant.action:
|
||
action: climate.set_swing_mode
|
||
data:
|
||
entity_id: !lambda "return id(active_entity);"
|
||
swing_mode: !lambda "return id(active_climate_swing_mode);"
|
||
|
||
# Quick on/off in the climate card; climate.turn_on restores the last mode in Home Assistant.
|
||
- id: climate_power_toggle
|
||
mode: restart
|
||
then:
|
||
- lambda: |-
|
||
std::string mode = id(active_climate_mode);
|
||
for (char &c : mode) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a');
|
||
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";
|
||
id(climate_card_refresh).execute();
|
||
- if:
|
||
condition:
|
||
lambda: 'return runtime_tiles::fresh() && id(active_climate_power_on);'
|
||
then:
|
||
- homeassistant.action:
|
||
action: climate.turn_on
|
||
data:
|
||
entity_id: !lambda "return id(active_entity);"
|
||
- if:
|
||
condition:
|
||
lambda: 'return runtime_tiles::fresh() && !id(active_climate_power_on);'
|
||
then:
|
||
- homeassistant.action:
|
||
action: climate.turn_off
|
||
data:
|
||
entity_id: !lambda "return id(active_entity);"
|
||
|
||
# The power key lights up while the device runs in any mode.
|
||
- id: climate_power_refresh
|
||
mode: restart
|
||
then:
|
||
- lambda: |-
|
||
std::string mode = id(active_climate_mode);
|
||
for (char &c : mode) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a');
|
||
bool on = !(mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable");
|
||
if (id(climate_edit_pending)) on = id(active_climate_power_on);
|
||
lv_obj_set_style_bg_color(id(climate_power_button), theme::color(on ? theme::ACCENT_TINT : theme::KEY), LV_PART_MAIN);
|
||
lv_obj_set_style_text_color(id(climate_power_icon), theme::color(on ? theme::ACCENT_ICON : theme::ICON_OFF), LV_PART_MAIN);
|
||
|
||
- id: set_climate_mode
|
||
mode: restart
|
||
parameters:
|
||
mode: string
|
||
then:
|
||
- lambda: |-
|
||
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_card_refresh).execute();
|
||
- lvgl.widget.hide: climate_mode_overlay
|
||
- if:
|
||
condition:
|
||
lambda: 'return runtime_tiles::fresh();'
|
||
then:
|
||
- homeassistant.action:
|
||
action: climate.set_hvac_mode
|
||
data:
|
||
entity_id: !lambda "return id(active_entity);"
|
||
hvac_mode: !lambda "return id(active_climate_mode);"
|
||
|
||
# Isolated 40-second preview; every light commit is suppressed in the helper.
|
||
- id: light_controls_preview
|
||
mode: single
|
||
then:
|
||
- script.execute: wake_display
|
||
- lambda: |-
|
||
light_controls::demo = true;
|
||
light_controls::fallback.minimum = 2000;
|
||
light_controls::fallback.maximum = 6500;
|
||
light_controls::fallback.kelvin = 3000;
|
||
light_controls::fallback.hue = 200;
|
||
light_controls::open("diagnostic", true, true, 75);
|
||
lv_label_set_text(id(color_detail_title), "Example lamp");
|
||
- lvgl.widget.hide: color_close_button
|
||
- lvgl.widget.show: color_detail_overlay
|
||
- delay: 40s
|
||
- lvgl.widget.hide: color_detail_overlay
|
||
- lvgl.widget.show: color_close_button
|
||
- lambda: 'light_controls::demo = false; light_controls::fallback = light_controls::State{};'
|
||
|
||
- id: open_color_detail
|
||
mode: restart
|
||
then:
|
||
- lambda: |-
|
||
lv_label_set_text(id(color_detail_title), id(active_title).c_str());
|
||
light_controls::open(id(active_entity), id(active_supports_color), id(active_supports_temperature), id(active_brightness_pct));
|
||
- lvgl.widget.hide: brightness_overlay
|
||
- lvgl.widget.show: color_detail_overlay
|
||
|
||
- id: color_brightness_commit
|
||
parameters:
|
||
value: int
|
||
then:
|
||
- lambda: 'id(active_brightness_pct) = value;'
|
||
- if:
|
||
condition:
|
||
lambda: 'return runtime_tiles::fresh() && id(active_entity).rfind("light.", 0) == 0;'
|
||
then:
|
||
- homeassistant.action:
|
||
action: light.turn_on
|
||
data:
|
||
entity_id: !lambda "return id(active_entity);"
|
||
brightness_pct: !lambda "return value;"
|
||
|
||
- id: set_light_hue
|
||
parameters:
|
||
value: int
|
||
then:
|
||
- if:
|
||
condition:
|
||
lambda: 'return runtime_tiles::fresh() && id(active_supports_color) && id(active_entity).rfind("light.", 0) == 0;'
|
||
then:
|
||
- homeassistant.action:
|
||
action: light.turn_on
|
||
data:
|
||
entity_id: !lambda "return id(active_entity);"
|
||
data_template:
|
||
hs_color: "{{ [hue | int, 100] }}"
|
||
variables:
|
||
hue: !lambda "return light_controls::clamp(value, 0, 360);"
|
||
|
||
- id: set_light_color_temperature
|
||
parameters:
|
||
kelvin: int
|
||
then:
|
||
- if:
|
||
condition:
|
||
lambda: 'return runtime_tiles::fresh() && id(active_supports_temperature) && id(active_entity).rfind("light.", 0) == 0 && light_controls::active->temperature_ready();'
|
||
then:
|
||
- homeassistant.action:
|
||
action: light.turn_on
|
||
data:
|
||
entity_id: !lambda "return id(active_entity);"
|
||
color_temp_kelvin: !lambda "return light_controls::clamp(kelvin, light_controls::active->minimum, light_controls::active->maximum);"
|
||
|
||
- id: slider_preview
|
||
parameters:
|
||
value: int
|
||
then:
|
||
- lambda: |-
|
||
char buf[20];
|
||
snprintf(buf, sizeof(buf), "%d%s", value, id(active_value_suffix).c_str());
|
||
lv_label_set_text(id(overlay_value), buf);
|
||
int pct = value;
|
||
if (pct < 0) pct = 0;
|
||
if (pct > 100) pct = 100;
|
||
std::string value_text;
|
||
|
||
if (id(active_control_kind) == "cover_position") {
|
||
std::string closed_label = id(active_label_off).c_str();
|
||
std::string open_label = id(active_label_on).c_str();
|
||
|
||
if (pct <= 0) {
|
||
value_text = closed_label;
|
||
} else if (pct >= 100) {
|
||
value_text = open_label;
|
||
} else {
|
||
char buf[40];
|
||
snprintf(buf, sizeof(buf), "%s - %d%%", open_label.c_str(), pct);
|
||
value_text = buf;
|
||
}
|
||
} else {
|
||
if (pct <= 0) {
|
||
value_text = id(active_label_off).c_str();
|
||
} else {
|
||
char buf[20];
|
||
snprintf(buf, sizeof(buf), "%d%s", pct, id(active_value_suffix).c_str());
|
||
value_text = buf;
|
||
}
|
||
}
|
||
|
||
lv_label_set_text(id(overlay_value), value_text.c_str());
|
||
|
||
|
||
- id: slider_commit
|
||
parameters:
|
||
value: int
|
||
then:
|
||
- if:
|
||
condition:
|
||
lambda: |-
|
||
return id(active_control_kind) == "light_brightness";
|
||
then:
|
||
- homeassistant.action:
|
||
action: light.turn_on
|
||
data:
|
||
entity_id: !lambda "return id(active_entity);"
|
||
brightness_pct: !lambda "return value;"
|
||
else:
|
||
- if:
|
||
condition:
|
||
lambda: |-
|
||
return id(active_control_kind) == "fan_percentage";
|
||
then:
|
||
- if:
|
||
condition:
|
||
lambda: "return value <= 0;"
|
||
then:
|
||
- homeassistant.action:
|
||
action: fan.turn_off
|
||
data:
|
||
entity_id: !lambda "return id(active_entity);"
|
||
else:
|
||
- homeassistant.action:
|
||
action: fan.turn_on
|
||
data:
|
||
entity_id: !lambda "return id(active_entity);"
|
||
- delay: 150ms
|
||
- homeassistant.action:
|
||
action: fan.set_percentage
|
||
data:
|
||
entity_id: !lambda "return id(active_entity);"
|
||
percentage: !lambda "return value;"
|
||
else:
|
||
- if:
|
||
condition:
|
||
lambda: |-
|
||
return id(active_control_kind) == "cover_position";
|
||
then:
|
||
- homeassistant.action:
|
||
action: cover.set_cover_position
|
||
data:
|
||
entity_id: !lambda "return id(active_entity);"
|
||
position: !lambda "return value;"
|
||
|
||
# ----------------------------------------------------------
|
||
# do_tile_action
|
||
#
|
||
# Centralized handler for executing Home Assistant actions
|
||
# triggered by tile taps.
|
||
#
|
||
# Parameters
|
||
# - tile_type: light | fan | switch | scene | script | cover
|
||
# - tap_action: auto | toggle | activate | fan_toggle_preset
|
||
# - service: optional Home Assistant service override
|
||
# - entity_id: target entity
|
||
# - param_key / param_val: optional parameters for custom services
|
||
# - was_on: previous entity state (pass false for longpress actions)
|
||
#
|
||
# Behavior
|
||
#
|
||
# toggle
|
||
# -> toggles the entity
|
||
# light.toggle / fan.toggle / switch.toggle / cover.open_cover|close_cover (based on was_on)
|
||
#
|
||
# activate
|
||
# -> activates entity
|
||
# scene.turn_on / script.turn_on / light.turn_on / fan.turn_on / cover.open_cover
|
||
#
|
||
# auto
|
||
# -> resolves automatically based on tile_type:
|
||
# light/switch/fan/cover -> toggle
|
||
# scene/script -> activate
|
||
#
|
||
# fan_toggle_preset
|
||
# -> if fan was ON: fan.turn_off
|
||
# -> if fan was OFF:
|
||
# fan.turn_on
|
||
# wait 150ms
|
||
# fan.set_preset_mode(param_val)
|
||
#
|
||
# Custom service
|
||
# If "service" is set, it will be called directly.
|
||
# param_key / param_val may be used for parameters
|
||
# like preset_mode, brightness_pct, percentage, etc.
|
||
# ----------------------------------------------------------
|
||
|
||
- id: open_value_overlay
|
||
parameters:
|
||
title: string
|
||
entity: string
|
||
tile_type: string
|
||
longpress_mode: string
|
||
was_on: bool
|
||
off_value: int
|
||
brightness_value: float
|
||
percentage_value: float
|
||
position_value: float
|
||
icon_text: string
|
||
label_off: string
|
||
label_on: string
|
||
supported_color_modes: string
|
||
climate_current_temperature: float
|
||
climate_target_temperature: float
|
||
climate_humidity: float
|
||
climate_mode: string
|
||
climate_supported_modes: string
|
||
climate_min_temp: string
|
||
climate_max_temp: string
|
||
climate_step: string
|
||
then:
|
||
- lambda: |-
|
||
std::string mode = longpress_mode;
|
||
auto contains_mode = [](const std::string& modes, const std::string& needle) {
|
||
return modes.find(needle) != std::string::npos;
|
||
};
|
||
if (mode == "auto") {
|
||
if (tile_type == "light") mode = "brightness";
|
||
else if (tile_type == "fan") mode = "percentage";
|
||
else if (tile_type == "cover") mode = "cover_position";
|
||
else if (tile_type == "climate") mode = "climate_temperature";
|
||
else mode = "none";
|
||
}
|
||
|
||
if (mode == "none") return;
|
||
|
||
id(active_entity) = entity;
|
||
id(active_value_suffix) = " %";
|
||
id(active_label_off) = label_off;
|
||
id(active_label_on) = label_on;
|
||
id(active_title) = title;
|
||
id(active_icon_text) = icon_text;
|
||
id(active_supports_color) = false;
|
||
id(active_supports_temperature) = false;
|
||
lv_obj_add_flag(id(overlay_color_button), LV_OBJ_FLAG_HIDDEN);
|
||
|
||
if (mode == "climate_temperature") {
|
||
auto to_x10 = [](float value, int fallback) -> int {
|
||
if (isnan(value)) return fallback;
|
||
return (int) lround(value * 10.0f);
|
||
};
|
||
auto parse_x10 = [](const std::string& value, int fallback) -> int {
|
||
if (value.empty()) return fallback;
|
||
return (int) lround(atof(value.c_str()) * 10.0f);
|
||
};
|
||
id(climate_edit_pending) = false;
|
||
id(active_control_kind) = "climate_temperature";
|
||
id(active_climate_min_x10) = parse_x10(climate_min_temp, 50);
|
||
id(active_climate_max_x10) = parse_x10(climate_max_temp, 300);
|
||
id(active_climate_step_x10) = parse_x10(climate_step, 5);
|
||
if (id(active_climate_step_x10) < 5) id(active_climate_step_x10) = 5;
|
||
if (id(active_climate_max_x10) <= id(active_climate_min_x10)) {
|
||
id(active_climate_min_x10) = 50;
|
||
id(active_climate_max_x10) = 300;
|
||
}
|
||
|
||
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);
|
||
if (id(active_climate_target_x10) > id(active_climate_max_x10)) id(active_climate_target_x10) = id(active_climate_max_x10);
|
||
id(active_climate_target_x10) = id(active_climate_min_x10) + (int) lround((id(active_climate_target_x10) - id(active_climate_min_x10)) / (float) id(active_climate_step_x10)) * id(active_climate_step_x10);
|
||
if (id(active_climate_target_x10) < id(active_climate_min_x10)) id(active_climate_target_x10) = id(active_climate_min_x10);
|
||
if (id(active_climate_target_x10) > id(active_climate_max_x10)) id(active_climate_target_x10) = id(active_climate_max_x10);
|
||
id(active_climate_humidity) = isnan(climate_humidity) ? -1 : (int) lround(climate_humidity);
|
||
id(active_climate_mode) = climate_mode.empty() ? std::string("Auto") : climate_mode;
|
||
id(active_climate_supported_modes) = climate_supported_modes;
|
||
|
||
lv_label_set_text(id(climate_detail_title), title.c_str());
|
||
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);
|
||
return;
|
||
}
|
||
|
||
int pct = off_value;
|
||
|
||
if (mode == "brightness") {
|
||
id(active_control_kind) = "light_brightness";
|
||
if (was_on) {
|
||
if (isnan(brightness_value)) pct = 100;
|
||
else pct = (int) lround((brightness_value / 255.0f) * 100.0f);
|
||
}
|
||
} else if (mode == "percentage") {
|
||
id(active_control_kind) = "fan_percentage";
|
||
if (was_on) {
|
||
if (isnan(percentage_value)) pct = 0;
|
||
else pct = (int) lround(percentage_value);
|
||
}
|
||
} else if (mode == "cover_position") {
|
||
id(active_control_kind) = "cover_position";
|
||
if (isnan(position_value)) pct = was_on ? 100 : 0;
|
||
else pct = (int) lround(position_value);
|
||
} else {
|
||
return;
|
||
}
|
||
|
||
if (pct < 0) pct = 0;
|
||
if (pct > 100) pct = 100;
|
||
id(active_brightness_pct) = pct;
|
||
|
||
if (tile_type == "light") {
|
||
id(active_supports_color) =
|
||
contains_mode(supported_color_modes, "hs") ||
|
||
contains_mode(supported_color_modes, "rgb") ||
|
||
contains_mode(supported_color_modes, "rgbw") ||
|
||
contains_mode(supported_color_modes, "rgbww") ||
|
||
contains_mode(supported_color_modes, "xy");
|
||
id(active_supports_temperature) = contains_mode(supported_color_modes, "color_temp");
|
||
|
||
if (id(active_supports_color) || id(active_supports_temperature)) {
|
||
lv_obj_clear_flag(id(overlay_color_button), LV_OBJ_FLAG_HIDDEN);
|
||
}
|
||
}
|
||
|
||
// Track and fill are the pale blue and blue paints of overlay_slider.
|
||
lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_MAIN);
|
||
lv_obj_set_style_radius(id(overlay_slider), 28, LV_PART_MAIN);
|
||
lv_obj_set_style_radius(id(overlay_slider), 0, LV_PART_INDICATOR);
|
||
|
||
lv_label_set_text(id(overlay_title), title.c_str());
|
||
lv_label_set_text(id(overlay_icon), icon_text.c_str());
|
||
lv_slider_set_value(id(overlay_slider), pct, LV_ANIM_OFF);
|
||
|
||
std::string value_text;
|
||
|
||
if (tile_type == "cover") {
|
||
if (pct <= 0) {
|
||
value_text = label_off;
|
||
} else if (pct >= 100) {
|
||
value_text = label_on;
|
||
} else {
|
||
char buf[40];
|
||
snprintf(buf, sizeof(buf), "%s - %d%%", label_on.c_str(), pct);
|
||
value_text = buf;
|
||
}
|
||
} else {
|
||
if (pct <= 0) {
|
||
value_text = label_off;
|
||
} else {
|
||
char buf[20];
|
||
snprintf(buf, sizeof(buf), "%d%s", pct, id(active_value_suffix).c_str());
|
||
value_text = buf;
|
||
}
|
||
}
|
||
|
||
lv_label_set_text(id(overlay_value), value_text.c_str());
|
||
|
||
lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_INDICATOR);
|
||
|
||
- if:
|
||
condition:
|
||
lambda: 'return id(active_control_kind) != "climate_temperature";'
|
||
then:
|
||
- if:
|
||
condition:
|
||
lambda: 'return id(active_supports_color) || id(active_supports_temperature);'
|
||
then:
|
||
- script.execute: open_color_detail
|
||
else:
|
||
- lvgl.widget.show: brightness_overlay
|
||
|
||
# UI refresh (values + time + styles) + render
|
||
- id: ui_refresh
|
||
mode: single
|
||
then:
|
||
- delay: 100ms
|
||
- lambda: |-
|
||
if (runtime_tiles::enabled) {
|
||
// The name and the top bar (clock included) draw together.
|
||
runtime_tiles::render(id(lbl_room));
|
||
return;
|
||
}
|
||
|
||
# HA blueprint transport; never publishes the JSON command as an HA state.
|
||
text:
|
||
- platform: smart_display
|
||
id: dashboard_inbox
|
||
name: "Tile settings"
|
||
internal: ${DASHBOARD_INBOX_INTERNAL}
|
||
|
||
button:
|
||
# Wake and Sleep for Home Assistant automations (0.2.45+). Wake is light, not a tap (0.2.56+): it
|
||
# lights a dimmed screen and counts the standby time from now, while Back to page 1 keeps counting
|
||
# from the last touch, so Wake on every motion no longer holds a card or page 2 up. Sleep is the
|
||
# standby time running out, right away and also with Auto standby off; it holds until a tap, Wake
|
||
# or an alert, and closes an alert that is up. Neither saves anything, so an automation may press
|
||
# them on every motion. No entity_category: they are controls, not settings.
|
||
- platform: template
|
||
id: wake_button
|
||
name: "Wake"
|
||
icon: "mdi:gesture-tap"
|
||
on_press:
|
||
- lambda: 'ESP_LOGI("standby", "Wake from Home Assistant, screen %s", id(display_dimmed) ? "in standby" : "on");'
|
||
- if:
|
||
condition:
|
||
lambda: 'return id(display_dimmed);'
|
||
then:
|
||
- script.execute: wake_display
|
||
# A page whose time ran out during standby: light up on page 1, not on the old page first.
|
||
- script.execute: back_to_page_1_when_due
|
||
else:
|
||
- lambda: 'id(last_touch_ms) = millis();'
|
||
- platform: template
|
||
id: sleep_button
|
||
name: "Sleep"
|
||
icon: "mdi:power-sleep"
|
||
on_press:
|
||
- if:
|
||
condition:
|
||
lambda: 'return id(calibration_active);'
|
||
then:
|
||
- lambda: 'ESP_LOGI("standby", "Sleep from Home Assistant ignored: touch calibration is running");'
|
||
else:
|
||
- lambda: |-
|
||
ESP_LOGI("standby", "Sleep from Home Assistant");
|
||
id(sleep_requested) = true;
|
||
- script.execute:
|
||
id: alert_dismiss
|
||
reason: "remote"
|
||
- script.execute: dim_display
|
||
|
||
number:
|
||
# Firmware 0.2.49+: every number goes through settings_screen::set(), like the settings page, so a value the
|
||
# screen already has writes and reports nothing (an automation may set it on every light change).
|
||
- platform: template
|
||
id: setting_brightness
|
||
name: "Normal brightness"
|
||
entity_category: config
|
||
min_value: 5
|
||
max_value: 100
|
||
step: 1
|
||
unit_of_measurement: "%"
|
||
update_interval: never
|
||
lambda: return screen_settings::current.brightness;
|
||
set_action:
|
||
- lambda: 'settings_screen::set("brightness", (int32_t) lround(x));'
|
||
- platform: template
|
||
id: setting_standby_brightness
|
||
name: "Standby brightness"
|
||
entity_category: config
|
||
min_value: 0
|
||
max_value: 100
|
||
step: 1
|
||
unit_of_measurement: "%"
|
||
update_interval: never
|
||
lambda: return screen_settings::current.standby_brightness;
|
||
set_action:
|
||
- lambda: 'settings_screen::set("standby_brightness", (int32_t) lround(x));'
|
||
- platform: template
|
||
id: setting_night_brightness
|
||
name: "Night brightness"
|
||
entity_category: config
|
||
min_value: 0
|
||
max_value: 100
|
||
step: 1
|
||
unit_of_measurement: "%"
|
||
update_interval: never
|
||
lambda: return screen_settings::current.night_brightness;
|
||
set_action:
|
||
- lambda: 'settings_screen::set("night_brightness", (int32_t) lround(x));'
|
||
- platform: template
|
||
id: setting_standby_seconds
|
||
name: "Standby after"
|
||
entity_category: config
|
||
min_value: 60
|
||
max_value: 86400
|
||
step: 60
|
||
unit_of_measurement: "s"
|
||
update_interval: never
|
||
lambda: return screen_settings::current.standby_seconds;
|
||
set_action:
|
||
- lambda: 'settings_screen::set("standby_seconds", (int32_t) lround(x));'
|
||
- platform: template
|
||
id: setting_auto_home_seconds
|
||
name: "Back to page 1 after"
|
||
icon: "mdi:timer-outline"
|
||
entity_category: config
|
||
min_value: 30
|
||
max_value: 3600
|
||
step: 30
|
||
mode: box
|
||
unit_of_measurement: "s"
|
||
update_interval: never
|
||
lambda: return settings_screen::auto_home_seconds;
|
||
set_action:
|
||
- lambda: 'settings_screen::set("auto_home_seconds", (int32_t) lround(x));'
|