Files
homeassistant_espscreen/guition-4848s040.yaml
T
Max Gramser 1a6308aba7 Fix what a full test round of today's releases found: a ticking single clock, a switch that works in standby, no taps from a swipe (0.2.78)
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>
2026-09-18 17:25:23 +02:00

4968 lines
198 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 &current_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));'