# 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.66" # 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: - - - - - 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._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(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(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= 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(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(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(seconds) * 1000UL;' - script.execute: id: alert_dismiss reason: "timeout" # Home Assistant adds device_id; action is ok, timeout, replaced or remote. - id: alert_report parameters: action: string then: - homeassistant.event: event: esphome.screen_alert data: action: !lambda 'return action;' title: !lambda 'return id(alert_title_text);' screen: !lambda 'return App.get_name();' - id: wake_display mode: restart then: - lambda: |- id(last_touch_ms) = millis(); id(display_dimmed) = false; id(sleep_requested) = false; // Almost instant brightness-up. ESPHome's default is 1s, and that feels sluggish // when you touch the screen and are waiting on light. id(set_backlight).execute(screen_settings::current.brightness, 80); - lvgl.widget.hide: dim_wake_overlay - script.execute: close_cards # One owner for the backlight level, through ESPHome's own light transition (firmware 0.2.57+). Firmware 0.2.13-0.2.56 # stepped the duty with the LEDC hardware fader by reaching into ESPHome's LEDC output, which ESPHome 2026.8 made final; # a new call interrupts the running transition from where it is, as ESPHome does for any light. - id: set_backlight mode: restart parameters: level: int ms: int then: - lambda: id(back_light).turn_on().set_transition_length(ms).set_brightness(level / 100.0f).perform(); # Standby: the standby time ran out (the 1 s interval checks Auto standby) or Sleep was pressed. # The settings page and every card close; with "also on standby" the screen goes back to page 1 too. - id: dim_display mode: restart then: - lambda: |- id(display_dimmed) = true; id(apply_screen_settings).execute(); - if: condition: lambda: 'return screen_settings::current.home_on_standby;' then: - script.execute: go_home else: - lambda: 'settings_screen::close();' - script.execute: close_cards # A -/+ tap on the climate card: the number follows at once, the call waits for the last tap. # Holding a key steps three times a second. - id: climate_adjust_target parameters: direction: int held: bool then: - lambda: |- if (held && millis() - id(climate_edit_ms) < 300) return; int step_x10 = std::max(5, id(active_climate_step_x10)); int next = cyd::quantize_temperature(id(active_climate_target_x10) + direction * step_x10, id(active_climate_min_x10), id(active_climate_max_x10), step_x10); id(climate_edit_ms) = millis(); id(climate_edit_pending) = true; if (next == id(active_climate_target_x10) && id(active_climate_target_known)) return; id(active_climate_target_x10) = next; id(active_climate_target_known) = true; id(climate_card_refresh).execute(); id(climate_target_send).execute(id(active_entity), next / 10.0f); # One set_temperature for a series of taps: every tap restarts the wait with the newest value. # Entity and value travel along, so leaving the card right after a tap still sends it. - id: climate_target_send mode: restart parameters: entity: string value: float then: - delay: 700ms - if: condition: lambda: 'return runtime_tiles::fresh() && !entity.empty();' then: - homeassistant.action: action: climate.set_temperature data: entity_id: !lambda "return entity;" temperature: !lambda "return value;" # Draws the climate card from the active_climate_* globals: status line, setpoint, -/+ and mode keys. - id: climate_card_refresh then: - lambda: |- auto lower = [](std::string v) { for (char &c : v) { if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); } return v; }; std::string mode = lower(id(active_climate_mode)); bool off = mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable"; // What the device does (Heating), else its mode (Heat); then the room. std::string status = off ? std::string("Off") : std::string(tile_controls::climate_action_text(lower(id(active_climate_action)))); if (status.empty()) status = tile_controls::climate_mode_text(mode); char part[32]; if (std::isfinite(id(active_climate_current))) { snprintf(part, sizeof(part), " · Now %.1f°", id(active_climate_current)); status += part; } if (id(active_climate_humidity) >= 0) { snprintf(part, sizeof(part), " · %d%%", id(active_climate_humidity)); status += part; } lv_label_set_text(id(climate_current_label), status.c_str()); int target = id(active_climate_target_x10); std::string value = id(active_climate_target_known) ? tile_controls::format_value(target / 10.0f, id(active_climate_step_x10) / 10.0f, "°") : std::string("--"); lv_label_set_text(id(climate_target_label), value.c_str()); lv_obj_set_style_text_color(id(climate_target_label), theme::color(off ? theme::OFF : theme::INK), LV_PART_MAIN); auto enable = [](lv_obj_t *key, bool on) { if (on) lv_obj_remove_state(key, LV_STATE_DISABLED); else lv_obj_add_state(key, LV_STATE_DISABLED); }; enable(id(climate_minus_button), !id(active_climate_target_known) || target > id(active_climate_min_x10)); enable(id(climate_plus_button), !id(active_climate_target_known) || target < id(active_climate_max_x10)); // One key per supported mode, in Home Assistant's order. Fan and swing add "more"; with it // five mode keys fit, and when a sixth mode is in use it takes the last place. std::string supported = lower(id(active_climate_supported_modes)); auto has = [&](const char *m) { return supported.find(std::string("\"") + m + "\"") != std::string::npos || supported.find(std::string("'") + m + "'") != std::string::npos; }; struct Key { lv_obj_t *obj; const char *mode; }; const Key keys[] = {{id(cmkey_auto), "auto"}, {id(cmkey_heat_cool), "heat_cool"}, {id(cmkey_heat), "heat"}, {id(cmkey_cool), "cool"}, {id(cmkey_dry), "dry"}, {id(cmkey_fan_only), "fan_only"}}; // The Guition shows fan and swing on their own card below, so no "more" key. bool more = false; const Key *visible[6]; unsigned count = 0, room = more ? 5 : 6; for (const auto &k : keys) if (has(k.mode)) visible[count++] = &k; if (count > room) { for (unsigned i = room; i < count; ++i) if (mode == visible[i]->mode) visible[room - 1] = visible[i]; count = room; } for (const auto &k : keys) { bool show = false; for (unsigned i = 0; i < count; ++i) show = show || visible[i] == &k; if (show) lv_obj_remove_flag(k.obj, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(k.obj, LV_OBJ_FLAG_HIDDEN); bool selected = show && !off && mode == k.mode; uint32_t color = tile_controls::mode_color(k.mode); lv_obj_set_style_bg_color(k.obj, selected ? lv_color_hex(color) : theme::color(theme::CARD), LV_PART_MAIN); lv_obj_set_style_border_color(k.obj, selected ? lv_color_hex(color) : theme::color(theme::LINE), LV_PART_MAIN); lv_obj_set_style_text_color(lv_obj_get_child(k.obj, 0), theme::color(selected ? theme::ON_ACCENT : theme::SLATE), LV_PART_MAIN); } if (more) lv_obj_remove_flag(id(cmkey_more), LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(id(cmkey_more), LV_OBJ_FLAG_HIDDEN); // One mode is no choice: the power key already turns it on and off. const bool show_keys = count > 1 || more; if (show_keys) lv_obj_remove_flag(id(climate_mode_keys), LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(id(climate_mode_keys), LV_OBJ_FLAG_HIDDEN); // Fan and swing rows, in the entity's own names and order. climate_card::Row settings[2]; int rows = 0; if (!cyd::list_item(id(active_climate_fan_modes), 0).empty()) settings[rows++] = {'f', "\U000F0210", id(active_climate_fan_modes), id(active_climate_fan_mode)}; if (!cyd::list_item(id(active_climate_swing_modes), 0).empty()) settings[rows++] = {'s', "\U000F1C91", id(active_climate_swing_modes), id(active_climate_swing_mode)}; // One layout per card: the setpoint gives room to the mode keys and the settings below it, // and a card with nothing below keeps the setpoint centred. static int laid_out = -1; if (laid_out != rows * 2 + show_keys) { laid_out = rows * 2 + show_keys; struct Layout { int status, card_y, card_h, key, number_y, caption_y, modes_y, modes_h, settings_y, edge, row_h, gap; }; static const Layout layouts[6] = { // no mode keys: 0, 1, 2 rows {92, 180, 200, 116, -14, 46, 0, 0, 0, 14, 48, 10}, {92, 140, 200, 116, -14, 46, 0, 0, 364, 14, 48, 10}, {90, 130, 180, 108, -12, 42, 0, 0, 330, 14, 46, 10}, // mode keys: 0, 1, 2 rows {92, 136, 200, 116, -14, 46, 364, 76, 0, 14, 48, 10}, {90, 126, 170, 108, -12, 42, 312, 68, 394, 12, 44, 8}, {86, 118, 140, 96, -10, 36, 270, 60, 342, 12, 42, 8}, }; const Layout &l = layouts[(show_keys ? 3 : 0) + rows]; lv_obj_set_y(id(climate_current_label), l.status); lv_obj_set_y(id(climate_setpoint_card), l.card_y); lv_obj_set_height(id(climate_setpoint_card), l.card_h); for (lv_obj_t *button : {id(climate_minus_button), id(climate_plus_button)}) { lv_obj_set_size(button, l.key, l.key); lv_obj_set_style_radius(button, l.key / 2, LV_PART_MAIN); } lv_obj_set_y(id(climate_target_label), l.number_y); lv_obj_set_y(id(climate_set_label), l.caption_y); if (show_keys) { lv_obj_set_y(id(climate_mode_keys), l.modes_y); lv_obj_set_height(id(climate_mode_keys), l.modes_h); for (lv_obj_t *mode_key : {id(cmkey_auto), id(cmkey_heat_cool), id(cmkey_heat), id(cmkey_cool), id(cmkey_dry), id(cmkey_fan_only), id(cmkey_more)}) { lv_obj_set_height(mode_key, l.modes_h); lv_obj_set_style_radius(mode_key, l.modes_h / 2, LV_PART_MAIN); } } lv_obj_set_y(id(climate_settings_card), l.settings_y); climate_card::look.edge = l.edge; climate_card::look.row_h = l.row_h; climate_card::look.gap = l.gap; } climate_card::show(settings, rows); id(climate_power_refresh).execute(); # Opens the mode picker. Hides modes the entity doesn't support and marks # the current one, so you see where you stand before you pick something. # Opens the vacuum controls and remembers which entity it concerns. # A standalone command: start, pause, stop or return_to_base. # Suction power is a separate axis, apart from start/stop. - id: open_climate_mode_picker mode: restart then: - lambda: |- auto lower = [](std::string v) { for (char &c : v) { if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); } return v; }; std::string supported = lower(id(active_climate_supported_modes)); std::string current = lower(id(active_climate_mode)); auto has = [&](const char* m) { std::string mode(m); if (supported.empty() || supported == "unknown" || supported == "unavailable") return false; return supported.find("'" + mode + "'") != std::string::npos || supported.find("\"" + mode + "\"") != std::string::npos; }; auto apply = [&](lv_obj_t* btn, const char* m) { if (has(m)) lv_obj_clear_flag(btn, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN); bool selected = (current == std::string(m)); lv_obj_set_style_bg_color(btn, theme::color(selected ? theme::ACCENT_BRIGHT : theme::CARD), LV_PART_MAIN); lv_obj_set_style_text_color(lv_obj_get_child(btn, 0), theme::color(selected ? theme::ON_ACCENT : theme::INK), LV_PART_MAIN); }; apply(id(cmode_off), "off"); apply(id(cmode_heat), "heat"); apply(id(cmode_cool), "cool"); apply(id(cmode_heat_cool), "heat_cool"); apply(id(cmode_auto), "auto"); apply(id(cmode_dry), "dry"); apply(id(cmode_fan_only), "fan_only"); // Fan and swing rows: the entity's own mode names, at most four each, as Home Assistant reports them. auto pretty = [](std::string raw) { if (!raw.empty() && raw[0] >= 'a' && raw[0] <= 'z') raw[0] = (char)(raw[0] - 'a' + 'A'); return raw; }; auto fill = [&](lv_obj_t *title, lv_obj_t *chips[4], const std::string &modes, const std::string ¤t_mode) { unsigned shown = 0; for (unsigned i = 0; i < 4; ++i) { std::string raw = cyd::list_item(modes, i); if (raw.empty()) { lv_obj_add_flag(chips[i], LV_OBJ_FLAG_HIDDEN); continue; } lv_obj_clear_flag(chips[i], LV_OBJ_FLAG_HIDDEN); ++shown; lv_label_set_text(lv_obj_get_child(chips[i], 0), pretty(raw).c_str()); lv_obj_set_style_bg_color(chips[i], theme::color(raw == current_mode ? theme::ACCENT_BRIGHT : theme::CARD), LV_PART_MAIN); lv_obj_set_style_text_color(lv_obj_get_child(chips[i], 0), theme::color(raw == current_mode ? theme::ON_ACCENT : theme::INK), LV_PART_MAIN); } if (shown) lv_obj_clear_flag(title, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(title, LV_OBJ_FLAG_HIDDEN); }; lv_obj_t *fan_chips[4] = {id(cfan_0), id(cfan_1), id(cfan_2), id(cfan_3)}; lv_obj_t *swing_chips[4] = {id(cswing_0), id(cswing_1), id(cswing_2), id(cswing_3)}; fill(id(cfan_title), fan_chips, id(active_climate_fan_modes), id(active_climate_fan_mode)); fill(id(cswing_title), swing_chips, id(active_climate_swing_modes), id(active_climate_swing_mode)); - lvgl.widget.show: climate_mode_overlay # Sets exactly one mode. No intermediate steps, so no unwanted # commands to the heat pump along the way. # Fan and swing modes from the picker: one call with the entity's own mode name. - id: set_climate_fan_mode mode: restart parameters: index: int then: - lambda: |- id(active_climate_fan_mode) = cyd::list_item(id(active_climate_fan_modes), index); id(climate_edit_ms) = millis(); id(climate_edit_pending) = true; id(climate_card_refresh).execute(); - lvgl.widget.hide: climate_mode_overlay - if: condition: lambda: 'return runtime_tiles::fresh() && !id(active_climate_fan_mode).empty();' then: - homeassistant.action: action: climate.set_fan_mode data: entity_id: !lambda "return id(active_entity);" fan_mode: !lambda "return id(active_climate_fan_mode);" - id: set_climate_swing_mode mode: restart parameters: index: int then: - lambda: |- id(active_climate_swing_mode) = cyd::list_item(id(active_climate_swing_modes), index); id(climate_edit_ms) = millis(); id(climate_edit_pending) = true; id(climate_card_refresh).execute(); - lvgl.widget.hide: climate_mode_overlay - if: condition: lambda: 'return runtime_tiles::fresh() && !id(active_climate_swing_mode).empty();' then: - homeassistant.action: action: climate.set_swing_mode data: entity_id: !lambda "return id(active_entity);" swing_mode: !lambda "return id(active_climate_swing_mode);" # Quick on/off in the climate card; climate.turn_on restores the last mode in Home Assistant. - id: climate_power_toggle mode: restart then: - lambda: |- std::string mode = id(active_climate_mode); for (char &c : mode) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); id(active_climate_power_on) = mode == "off" || mode.empty() || mode == "unknown"; id(climate_edit_ms) = millis(); id(climate_edit_pending) = true; if (!id(active_climate_power_on)) id(active_climate_mode) = "off"; id(climate_card_refresh).execute(); - if: condition: lambda: 'return runtime_tiles::fresh() && id(active_climate_power_on);' then: - homeassistant.action: action: climate.turn_on data: entity_id: !lambda "return id(active_entity);" - if: condition: lambda: 'return runtime_tiles::fresh() && !id(active_climate_power_on);' then: - homeassistant.action: action: climate.turn_off data: entity_id: !lambda "return id(active_entity);" # The power key lights up while the device runs in any mode. - id: climate_power_refresh mode: restart then: - lambda: |- std::string mode = id(active_climate_mode); for (char &c : mode) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); bool on = !(mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable"); if (id(climate_edit_pending)) on = id(active_climate_power_on); lv_obj_set_style_bg_color(id(climate_power_button), theme::color(on ? theme::ACCENT_TINT : theme::KEY), LV_PART_MAIN); lv_obj_set_style_text_color(id(climate_power_icon), theme::color(on ? theme::ACCENT_ICON : theme::ICON_OFF), LV_PART_MAIN); - id: set_climate_mode mode: restart parameters: mode: string then: - lambda: |- id(climate_edit_ms) = millis(); id(climate_edit_pending) = true; id(active_climate_mode) = mode; // The action line would still say what the old mode did. id(active_climate_action).clear(); id(active_climate_power_on) = mode != "off"; id(climate_card_refresh).execute(); - lvgl.widget.hide: climate_mode_overlay - if: condition: lambda: 'return runtime_tiles::fresh();' then: - homeassistant.action: action: climate.set_hvac_mode data: entity_id: !lambda "return id(active_entity);" hvac_mode: !lambda "return id(active_climate_mode);" # Isolated 40-second preview; every light commit is suppressed in the helper. - id: light_controls_preview mode: single then: - script.execute: wake_display - lambda: |- light_controls::demo = true; light_controls::fallback.minimum = 2000; light_controls::fallback.maximum = 6500; light_controls::fallback.kelvin = 3000; light_controls::fallback.hue = 200; light_controls::open("diagnostic", true, true, 75); lv_label_set_text(id(color_detail_title), "Example lamp"); - lvgl.widget.hide: color_close_button - lvgl.widget.show: color_detail_overlay - delay: 40s - lvgl.widget.hide: color_detail_overlay - lvgl.widget.show: color_close_button - lambda: 'light_controls::demo = false; light_controls::fallback = light_controls::State{};' - id: open_color_detail mode: restart then: - lambda: |- lv_label_set_text(id(color_detail_title), id(active_title).c_str()); light_controls::open(id(active_entity), id(active_supports_color), id(active_supports_temperature), id(active_brightness_pct)); - lvgl.widget.hide: brightness_overlay - lvgl.widget.show: color_detail_overlay - id: color_brightness_commit parameters: value: int then: - lambda: 'id(active_brightness_pct) = value;' - if: condition: lambda: 'return runtime_tiles::fresh() && id(active_entity).rfind("light.", 0) == 0;' then: - homeassistant.action: action: light.turn_on data: entity_id: !lambda "return id(active_entity);" brightness_pct: !lambda "return value;" - id: set_light_hue parameters: value: int then: - if: condition: lambda: 'return runtime_tiles::fresh() && id(active_supports_color) && id(active_entity).rfind("light.", 0) == 0;' then: - homeassistant.action: action: light.turn_on data: entity_id: !lambda "return id(active_entity);" data_template: hs_color: "{{ [hue | int, 100] }}" variables: hue: !lambda "return light_controls::clamp(value, 0, 360);" - id: set_light_color_temperature parameters: kelvin: int then: - if: condition: lambda: 'return runtime_tiles::fresh() && id(active_supports_temperature) && id(active_entity).rfind("light.", 0) == 0 && light_controls::active->temperature_ready();' then: - homeassistant.action: action: light.turn_on data: entity_id: !lambda "return id(active_entity);" color_temp_kelvin: !lambda "return light_controls::clamp(kelvin, light_controls::active->minimum, light_controls::active->maximum);" - id: slider_preview parameters: value: int then: - lambda: |- char buf[20]; snprintf(buf, sizeof(buf), "%d%s", value, id(active_value_suffix).c_str()); lv_label_set_text(id(overlay_value), buf); int pct = value; if (pct < 0) pct = 0; if (pct > 100) pct = 100; std::string value_text; if (id(active_control_kind) == "cover_position") { std::string closed_label = id(active_label_off).c_str(); std::string open_label = id(active_label_on).c_str(); if (pct <= 0) { value_text = closed_label; } else if (pct >= 100) { value_text = open_label; } else { char buf[40]; snprintf(buf, sizeof(buf), "%s - %d%%", open_label.c_str(), pct); value_text = buf; } } else { if (pct <= 0) { value_text = id(active_label_off).c_str(); } else { char buf[20]; snprintf(buf, sizeof(buf), "%d%s", pct, id(active_value_suffix).c_str()); value_text = buf; } } lv_label_set_text(id(overlay_value), value_text.c_str()); - id: slider_commit parameters: value: int then: - if: condition: lambda: |- return id(active_control_kind) == "light_brightness"; then: - homeassistant.action: action: light.turn_on data: entity_id: !lambda "return id(active_entity);" brightness_pct: !lambda "return value;" else: - if: condition: lambda: |- return id(active_control_kind) == "fan_percentage"; then: - if: condition: lambda: "return value <= 0;" then: - homeassistant.action: action: fan.turn_off data: entity_id: !lambda "return id(active_entity);" else: - homeassistant.action: action: fan.turn_on data: entity_id: !lambda "return id(active_entity);" - delay: 150ms - homeassistant.action: action: fan.set_percentage data: entity_id: !lambda "return id(active_entity);" percentage: !lambda "return value;" else: - if: condition: lambda: |- return id(active_control_kind) == "cover_position"; then: - homeassistant.action: action: cover.set_cover_position data: entity_id: !lambda "return id(active_entity);" position: !lambda "return value;" # ---------------------------------------------------------- # do_tile_action # # Centralized handler for executing Home Assistant actions # triggered by tile taps. # # Parameters # - tile_type: light | fan | switch | scene | script | cover # - tap_action: auto | toggle | activate | fan_toggle_preset # - service: optional Home Assistant service override # - entity_id: target entity # - param_key / param_val: optional parameters for custom services # - was_on: previous entity state (pass false for longpress actions) # # Behavior # # toggle # -> toggles the entity # light.toggle / fan.toggle / switch.toggle / cover.open_cover|close_cover (based on was_on) # # activate # -> activates entity # scene.turn_on / script.turn_on / light.turn_on / fan.turn_on / cover.open_cover # # auto # -> resolves automatically based on tile_type: # light/switch/fan/cover -> toggle # scene/script -> activate # # fan_toggle_preset # -> if fan was ON: fan.turn_off # -> if fan was OFF: # fan.turn_on # wait 150ms # fan.set_preset_mode(param_val) # # Custom service # If "service" is set, it will be called directly. # param_key / param_val may be used for parameters # like preset_mode, brightness_pct, percentage, etc. # ---------------------------------------------------------- - id: open_value_overlay parameters: title: string entity: string tile_type: string longpress_mode: string was_on: bool off_value: int brightness_value: float percentage_value: float position_value: float icon_text: string label_off: string label_on: string supported_color_modes: string climate_current_temperature: float climate_target_temperature: float climate_humidity: float climate_mode: string climate_supported_modes: string climate_min_temp: string climate_max_temp: string climate_step: string then: - lambda: |- std::string mode = longpress_mode; auto contains_mode = [](const std::string& modes, const std::string& needle) { return modes.find(needle) != std::string::npos; }; if (mode == "auto") { if (tile_type == "light") mode = "brightness"; else if (tile_type == "fan") mode = "percentage"; else if (tile_type == "cover") mode = "cover_position"; else if (tile_type == "climate") mode = "climate_temperature"; else mode = "none"; } if (mode == "none") return; id(active_entity) = entity; id(active_value_suffix) = " %"; id(active_label_off) = label_off; id(active_label_on) = label_on; id(active_title) = title; id(active_icon_text) = icon_text; id(active_supports_color) = false; id(active_supports_temperature) = false; lv_obj_add_flag(id(overlay_color_button), LV_OBJ_FLAG_HIDDEN); if (mode == "climate_temperature") { auto to_x10 = [](float value, int fallback) -> int { if (isnan(value)) return fallback; return (int) lround(value * 10.0f); }; auto parse_x10 = [](const std::string& value, int fallback) -> int { if (value.empty()) return fallback; return (int) lround(atof(value.c_str()) * 10.0f); }; id(climate_edit_pending) = false; id(active_control_kind) = "climate_temperature"; id(active_climate_min_x10) = parse_x10(climate_min_temp, 50); id(active_climate_max_x10) = parse_x10(climate_max_temp, 300); id(active_climate_step_x10) = parse_x10(climate_step, 5); if (id(active_climate_step_x10) < 5) id(active_climate_step_x10) = 5; if (id(active_climate_max_x10) <= id(active_climate_min_x10)) { id(active_climate_min_x10) = 50; id(active_climate_max_x10) = 300; } id(active_climate_current_x10) = to_x10(climate_current_temperature, 0); id(active_climate_current) = climate_current_temperature; id(active_climate_target_known) = !isnan(climate_target_temperature); int fallback_target = isnan(climate_current_temperature) ? 200 : id(active_climate_current_x10); id(active_climate_target_x10) = to_x10(climate_target_temperature, fallback_target); if (id(active_climate_target_x10) < id(active_climate_min_x10)) id(active_climate_target_x10) = id(active_climate_min_x10); if (id(active_climate_target_x10) > id(active_climate_max_x10)) id(active_climate_target_x10) = id(active_climate_max_x10); id(active_climate_target_x10) = id(active_climate_min_x10) + (int) lround((id(active_climate_target_x10) - id(active_climate_min_x10)) / (float) id(active_climate_step_x10)) * id(active_climate_step_x10); if (id(active_climate_target_x10) < id(active_climate_min_x10)) id(active_climate_target_x10) = id(active_climate_min_x10); if (id(active_climate_target_x10) > id(active_climate_max_x10)) id(active_climate_target_x10) = id(active_climate_max_x10); id(active_climate_humidity) = isnan(climate_humidity) ? -1 : (int) lround(climate_humidity); id(active_climate_mode) = climate_mode.empty() ? std::string("Auto") : climate_mode; id(active_climate_supported_modes) = climate_supported_modes; lv_label_set_text(id(climate_detail_title), title.c_str()); id(climate_card_refresh).execute(); lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_clear_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN); return; } int pct = off_value; if (mode == "brightness") { id(active_control_kind) = "light_brightness"; if (was_on) { if (isnan(brightness_value)) pct = 100; else pct = (int) lround((brightness_value / 255.0f) * 100.0f); } } else if (mode == "percentage") { id(active_control_kind) = "fan_percentage"; if (was_on) { if (isnan(percentage_value)) pct = 0; else pct = (int) lround(percentage_value); } } else if (mode == "cover_position") { id(active_control_kind) = "cover_position"; if (isnan(position_value)) pct = was_on ? 100 : 0; else pct = (int) lround(position_value); } else { return; } if (pct < 0) pct = 0; if (pct > 100) pct = 100; id(active_brightness_pct) = pct; if (tile_type == "light") { id(active_supports_color) = contains_mode(supported_color_modes, "hs") || contains_mode(supported_color_modes, "rgb") || contains_mode(supported_color_modes, "rgbw") || contains_mode(supported_color_modes, "rgbww") || contains_mode(supported_color_modes, "xy"); id(active_supports_temperature) = contains_mode(supported_color_modes, "color_temp"); if (id(active_supports_color) || id(active_supports_temperature)) { lv_obj_clear_flag(id(overlay_color_button), LV_OBJ_FLAG_HIDDEN); } } // Track and fill are the pale blue and blue paints of overlay_slider. lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(id(overlay_slider), 28, LV_PART_MAIN); lv_obj_set_style_radius(id(overlay_slider), 0, LV_PART_INDICATOR); lv_label_set_text(id(overlay_title), title.c_str()); lv_label_set_text(id(overlay_icon), icon_text.c_str()); lv_slider_set_value(id(overlay_slider), pct, LV_ANIM_OFF); std::string value_text; if (tile_type == "cover") { if (pct <= 0) { value_text = label_off; } else if (pct >= 100) { value_text = label_on; } else { char buf[40]; snprintf(buf, sizeof(buf), "%s - %d%%", label_on.c_str(), pct); value_text = buf; } } else { if (pct <= 0) { value_text = label_off; } else { char buf[20]; snprintf(buf, sizeof(buf), "%d%s", pct, id(active_value_suffix).c_str()); value_text = buf; } } lv_label_set_text(id(overlay_value), value_text.c_str()); lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_INDICATOR); - if: condition: lambda: 'return id(active_control_kind) != "climate_temperature";' then: - if: condition: lambda: 'return id(active_supports_color) || id(active_supports_temperature);' then: - script.execute: open_color_detail else: - lvgl.widget.show: brightness_overlay # UI refresh (values + time + styles) + render - id: ui_refresh mode: single then: - delay: 100ms - lambda: |- if (runtime_tiles::enabled) { // The name and the top bar (clock included) draw together. runtime_tiles::render(id(lbl_room)); return; } # HA blueprint transport; never publishes the JSON command as an HA state. text: - platform: smart_display id: dashboard_inbox name: "Tile settings" internal: ${DASHBOARD_INBOX_INTERNAL} button: # Wake and Sleep for Home Assistant automations (0.2.45+). Wake is light, not a tap (0.2.56+): it # lights a dimmed screen and counts the standby time from now, while Back to page 1 keeps counting # from the last touch, so Wake on every motion no longer holds a card or page 2 up. Sleep is the # standby time running out, right away and also with Auto standby off; it holds until a tap, Wake # or an alert, and closes an alert that is up. Neither saves anything, so an automation may press # them on every motion. No entity_category: they are controls, not settings. - platform: template id: wake_button name: "Wake" icon: "mdi:gesture-tap" on_press: - lambda: 'ESP_LOGI("standby", "Wake from Home Assistant, screen %s", id(display_dimmed) ? "in standby" : "on");' - if: condition: lambda: 'return id(display_dimmed);' then: - script.execute: wake_display # A page whose time ran out during standby: light up on page 1, not on the old page first. - script.execute: back_to_page_1_when_due else: - lambda: 'id(last_touch_ms) = millis();' - platform: template id: sleep_button name: "Sleep" icon: "mdi:power-sleep" on_press: - if: condition: lambda: 'return id(calibration_active);' then: - lambda: 'ESP_LOGI("standby", "Sleep from Home Assistant ignored: touch calibration is running");' else: - lambda: |- ESP_LOGI("standby", "Sleep from Home Assistant"); id(sleep_requested) = true; - script.execute: id: alert_dismiss reason: "remote" - script.execute: dim_display number: # Firmware 0.2.49+: every number goes through settings_screen::set(), like the settings page, so a value the # screen already has writes and reports nothing (an automation may set it on every light change). - platform: template id: setting_brightness name: "Normal brightness" entity_category: config min_value: 5 max_value: 100 step: 1 unit_of_measurement: "%" update_interval: never lambda: return screen_settings::current.brightness; set_action: - lambda: 'settings_screen::set("brightness", (int32_t) lround(x));' - platform: template id: setting_standby_brightness name: "Standby brightness" entity_category: config min_value: 0 max_value: 100 step: 1 unit_of_measurement: "%" update_interval: never lambda: return screen_settings::current.standby_brightness; set_action: - lambda: 'settings_screen::set("standby_brightness", (int32_t) lround(x));' - platform: template id: setting_night_brightness name: "Night brightness" entity_category: config min_value: 0 max_value: 100 step: 1 unit_of_measurement: "%" update_interval: never lambda: return screen_settings::current.night_brightness; set_action: - lambda: 'settings_screen::set("night_brightness", (int32_t) lround(x));' - platform: template id: setting_standby_seconds name: "Standby after" entity_category: config min_value: 60 max_value: 86400 step: 60 unit_of_measurement: "s" update_interval: never lambda: return screen_settings::current.standby_seconds; set_action: - lambda: 'settings_screen::set("standby_seconds", (int32_t) lround(x));' - platform: template id: setting_auto_home_seconds name: "Back to page 1 after" icon: "mdi:timer-outline" entity_category: config min_value: 30 max_value: 3600 step: 30 mode: box unit_of_measurement: "s" update_interval: never lambda: return settings_screen::auto_home_seconds; set_action: - lambda: 'settings_screen::set("auto_home_seconds", (int32_t) lround(x));'