############################################################ # ESP Screens - CYD board profile (ESP32-2432S028, "Cheap Yellow Display") # Built-in ILI9341 320x240 display and XPT2046 touch, drawn with LVGL. # # This is the source profile. tools/generate_packages.py turns it into # packages/cyd.yaml, the package a screen builds from over GitHub; never edit # that file by hand. A screen gets its tiles from the ESP Screen Manager add-on # while it runs, so no Home Assistant entities live in here. # # What is in here: the hardware (display, touch, backlight, LED), the LVGL tree # the runtime fills (tiles, cards, overlays), the screen settings, and the # diagnostics a bench session uses. # # What to adapt before flashing: the secrets (api_encryption_key, ota_password, # Wi-Fi) and, for another CYD variant, the pins and the touch calibration. # Start at README.md; docs/ covers installing, calibrating and releasing. ############################################################ substitutions: SCREEN_FIRMWARE_VERSION: "0.2.54" # A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe. TOUCH_MOVE_LIMIT_PX: "56" # Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not. TOUCH_MIN_PRESS_MS: "60" DASHBOARD_INBOX_INTERNAL: "false" DEVICE_NAME: "cyd-new" DEVICE_FRIENDLY_NAME: "My CYD" CALIBRATION_ON_BOOT: "false" TOUCH_AFFINE_XX: "1.000000000" TOUCH_AFFINE_XY: "0.000000000" TOUCH_AFFINE_XC: "0.000000000" TOUCH_AFFINE_YX: "0.000000000" TOUCH_AFFINE_YY: "1.000000000" TOUCH_AFFINE_YC: "0.000000000" 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 # ORIENTATION # The panel is always driven at its native 240x320 (see display:); LVGL turns # both the picture and the touch coordinates. DISPLAY_W/H are the logical canvas # the layout uses. Turning a screen a quarter is a setting in ESP Screens, so # these values stay as they are. DISPLAY_W: "320" DISPLAY_H: "240" LVGL_ROTATION: "90" TOUCH_SWAP_XY: "false" # true on this board (resistive XPT2046) — was false on the 032c board # (capacitive GT911). This is the original upstream value for this panel. TOUCH_MIRROR_X: "true" TOUCH_MIRROR_Y: "false" # Measured on this board: median top-right raw=(523,3390), # bottom-left raw=(3487,482), at screen (299,20) and (20,219). # Extrapolated to panel edges; verify with the isolated corner test. TOUCH_CAL_X_MIN: "280" TOUCH_CAL_X_MAX: "3860" TOUCH_CAL_Y_MIN: "340" TOUCH_CAL_Y_MAX: "3860" GRID_COL1_X: "9" GRID_COL2_X: "164" GRID_ROW1_Y: "0" GRID_ROW2_Y: "56" GRID_ROW3_Y: "112" GRID_ROW4_Y: "168" GRID_ROW5_Y: "224" SCROLL_Y: "40" SCROLL_H: "164" TILE_W: "147" TILE_H: "52" HEADER_Y: "14" # Holding the top bar opens the settings page (0.2.44+). The whole strip counts on this board; # a finger that travels further than the drift limit of a tap is swiping, not holding. SETTINGS_HOLD_X: "0" SETTINGS_HOLD_W: "${DISPLAY_W}" SETTINGS_HOLD_DRIFT_PX: "24" OVERLAY_SLIDER_W: "96" OVERLAY_SLIDER_H: "160" OVERLAY_ICON_Y: "74" # Alert overlay (HA action show_alert), landscape 320x240: one card centred on LVGL's top layer. ALERT_CARD_W: "292" ALERT_CARD_H: "196" ALERT_ICON_X: "18" ALERT_ICON_Y: "16" ALERT_TEXT_X: "62" ALERT_TEXT_W: "212" ALERT_TITLE_Y: "16" ALERT_SUBTITLE_Y: "44" ALERT_SUBTITLE_H: "88" ALERT_BUTTON_W: "100" ALERT_BUTTON_H: "40" ALERT_BUTTON_INSET: "14" ALERT_TITLE_MAX: "48" # bytes; the label ends one line with an ellipsis ALERT_BUTTON_MAX: "12" # bytes on the OK button ALERT_SUBTITLE_MAX: "160" # bytes; wraps over the rows the card has TILE_ICON_X: "0" TILE_ICON_Y: "0" TILE_TITLE_X: "48" TILE_TITLE_Y: "4" TILE_VALUE_X: "48" TILE_VALUE_Y: "18" # ----- 90° — Portrait 240×320 (USB on bottom) ----- # ORIENTATION: "90" # DISPLAY_W: "240" # DISPLAY_H: "320" # LVGL_ROTATION: "180" # TOUCH_SWAP_XY: "false" # TOUCH_MIRROR_X: "true" # TOUCH_MIRROR_Y: "false" # TOUCH_CAL_X_MIN: "280" # TOUCH_CAL_X_MAX: "3860" # TOUCH_CAL_Y_MIN: "340" # TOUCH_CAL_Y_MAX: "3860" # GRID_COL1_X: "6" # GRID_COL2_X: "123" # GRID_ROW1_Y: "8" # GRID_ROW2_Y: "96" # GRID_ROW3_Y: "184" # GRID_ROW4_Y: "272" # GRID_ROW5_Y: "360" # SCROLL_Y: "44" # SCROLL_H: "276" # TILE_W: "111" # TILE_H: "80" # HEADER_Y: "14" # OVERLAY_SLIDER_W: "96" # OVERLAY_SLIDER_H: "200" # OVERLAY_ICON_Y: "94" # TILE_ICON_X: "0" # TILE_ICON_Y: "0" # TILE_TITLE_X: "4" # TILE_TITLE_Y: "38" # TILE_VALUE_X: "4" # TILE_VALUE_Y: "52" # ----- 180° — Landscape flipped 320×240 (USB on left) ----- # ORIENTATION: "180" # DISPLAY_W: "320" # DISPLAY_H: "240" # LVGL_ROTATION: "270" # TOUCH_SWAP_XY: "false" # TOUCH_MIRROR_X: "true" # TOUCH_MIRROR_Y: "false" # TOUCH_CAL_X_MIN: "280" # TOUCH_CAL_X_MAX: "3860" # TOUCH_CAL_Y_MIN: "340" # TOUCH_CAL_Y_MAX: "3860" # GRID_COL1_X: "9" # GRID_COL2_X: "164" # GRID_ROW1_Y: "8" # GRID_ROW2_Y: "68" # GRID_ROW3_Y: "128" # GRID_ROW4_Y: "188" # GRID_ROW5_Y: "248" # SCROLL_Y: "44" # SCROLL_H: "196" # TILE_W: "147" # TILE_H: "52" # HEADER_Y: "14" # OVERLAY_SLIDER_W: "96" # OVERLAY_SLIDER_H: "160" # OVERLAY_ICON_Y: "74" # TILE_ICON_X: "0" # TILE_ICON_Y: "0" # TILE_TITLE_X: "48" # TILE_TITLE_Y: "4" # TILE_VALUE_X: "48" # TILE_VALUE_Y: "18" # ----- 270° — Portrait flipped 240×320 (USB on top) ----- # ORIENTATION: "270" # DISPLAY_W: "240" # DISPLAY_H: "320" # LVGL_ROTATION: "0" # TOUCH_SWAP_XY: "false" # TOUCH_MIRROR_X: "true" # TOUCH_MIRROR_Y: "false" # TOUCH_CAL_X_MIN: "280" # TOUCH_CAL_X_MAX: "3860" # TOUCH_CAL_Y_MIN: "340" # TOUCH_CAL_Y_MAX: "3860" # GRID_COL1_X: "6" # GRID_COL2_X: "123" # GRID_ROW1_Y: "8" # GRID_ROW2_Y: "96" # GRID_ROW3_Y: "184" # GRID_ROW4_Y: "272" # GRID_ROW5_Y: "360" # SCROLL_Y: "44" # SCROLL_H: "276" # TILE_W: "111" # TILE_H: "80" # HEADER_Y: "14" # OVERLAY_SLIDER_W: "96" # OVERLAY_SLIDER_H: "200" # OVERLAY_ICON_Y: "94" # TILE_ICON_X: "0" # TILE_ICON_Y: "0" # TILE_TITLE_X: "4" # TILE_TITLE_Y: "38" # TILE_VALUE_X: "4" # TILE_VALUE_Y: "52" # ============================================================ # 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: [xpt2046, smart_display] esphome: min_version: 2026.6.2 project: name: maxgramser.esp_screen version: "${SCREEN_FIRMWARE_VERSION}" platformio_options: build_flags: - -DLV_USE_SWITCH=1 includes: - - - - name: ${DEVICE_NAME} friendly_name: ${DEVICE_FRIENDLY_NAME} on_boot: priority: -100 then: - lambda: |- // Per-panel affine correction; shared defaults are neutral. id(ts_touch).set_raw_correction(${TOUCH_AFFINE_XX}f, ${TOUCH_AFFINE_XY}f, ${TOUCH_AFFINE_XC}f, ${TOUCH_AFFINE_YX}f, ${TOUCH_AFFINE_YY}f, ${TOUCH_AFFINE_YC}f); cyd::touch_guard.configure(${TOUCH_MOVE_LIMIT_PX}, ${TOUCH_MIN_PRESS_MS}); id(last_touch_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::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::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_calibration), Paint::calibration); }; 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(); 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(); }; lv_obj_add_event_cb(id(home_page)->obj, [](lv_event_t *event) { if(!runtime_tiles::enabled || !runtime_tiles::swipe_pages || id(display_dimmed) || id(calibration_active) || !id(active_entity).empty() || (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root,LV_OBJ_FLAG_HIDDEN)))return; auto *input=lv_indev_active();if(!input)return; auto direction=lv_indev_get_gesture_dir(input); if(direction!=LV_DIR_LEFT && direction!=LV_DIR_RIGHT)return; cyd::touch_guard.consume(); lv_indev_wait_release(input); id(tile_page)+=direction==LV_DIR_LEFT?1:-1; id(show_tile_page).execute(); }, LV_EVENT_GESTURE, nullptr); 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); }; - lambda: |- if (!runtime_tiles::enabled) return; screen_calibration::apply = [](const screen_calibration::Calibration &cal) { id(ts_touch).set_raw_correction(cal.c[0],cal.c[1],cal.c[2],cal.c[3],cal.c[4],cal.c[5]); }; screen_calibration::isolation = [](bool active) { id(calibration_active) = active; }; screen_calibration::setup(id(home_page)->obj, id(sublabel)->get_lv_font(), id(headline)->get_lv_font(), ${TOUCH_CAL_X_MIN}, ${TOUCH_CAL_X_MAX}, ${TOUCH_CAL_Y_MIN}, ${TOUCH_CAL_Y_MAX}); # 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); }; 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", "USB calibration ready; no tile actions"); esp32: board: esp32dev framework: type: esp-idf advanced: # ESPHome recommends this itself in the boot log. Yields ~40KB extra IRAM # and a smaller binary, and every KB counts on this board. sram1_as_iram: true # minimum_chip_revision deliberately left out: that was here for the # 032c board (rev 3.1 confirmed), but this is a different physical board # with an unknown chip revision. With an unjustifiably high minimum # the firmware simply refuses to boot. version: recommended variant: esp32 logger: # 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: 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 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)), 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)); 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); } # 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; } 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: interval: - interval: 250ms then: - lambda: runtime_tiles::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 # 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. - 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() || !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_touch_ms)) > (uint32_t) runtime_tiles::auto_home_seconds * 1000UL; then: - script.execute: go_home 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 - id: last_touch_ms type: uint32_t restore_value: no initial_value: '0' # Debounce for do_tile_action: this resistive XPT2046 panel sometimes gives # 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: - file: "fonts/Roboto-500.ttf" id: time_label size: 18 bpp: 4 glyphs: ['0','1','2','3','4','5','6','7','8','9',':',' ', 'A', 'M', 'P'] - file: "fonts/Roboto-500.ttf" id: headline 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-500.ttf" id: watch_value size: 22 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: 24 bpp: 4 glyphs: [':','-','0','1','2','3','4','5','6','7','8','9'] # The climate card's setpoint, large enough to read across the room. - file: "fonts/Roboto-400.ttf" id: setpoint_digits size: 40 bpp: 4 glyphs: ['-','.','°','0','1','2','3','4','5','6','7','8','9'] - file: "fonts/Roboto-700.ttf" id: label size: 11 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: 11 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: 14 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: 28 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 - file: 'fonts/materialdesignicons-webfont.ttf' id: materialdesign_icons_mini size: 18 glyphs: *tile_icons - file: 'fonts/materialdesignicons-webfont.ttf' id: watch_icon size: 12 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: "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);' # Two SPI buses used by the CYD: # - lcd: built-in display # - my_touchscreen: built-in touch controller # Two SPI buses on the 2432S028: one for the display, a separate one for the # resistive XPT2046 touch (no I2C on this board, unlike the 032c). spi: - id: lcd clk_pin: GPIO14 mosi_pin: GPIO13 - id: my_touchscreen clk_pin: GPIO25 mosi_pin: GPIO32 miso_pin: GPIO39 output: - platform: ledc pin: GPIO21 id: gpio_backlight_pwm # ESPHome's ledc default is 1kHz. You don't see that while still (well # above what the eye perceives as flicker), but moving the screen reveals # motion blur turning the on/off pulses into streaks (the same effect as # a PWM LED that "judders" when you move it). 20kHz sits well above the # speed at which motion still makes that visible, and still leaves ample # duty-cycle resolution left over for smooth dimming. frequency: 20000Hz - platform: ledc id: output_red pin: GPIO4 inverted: true - platform: ledc id: output_green pin: GPIO16 inverted: true - platform: ledc id: output_blue pin: GPIO17 inverted: true 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: "Power Display Backlight" id: back_light internal: true restore_mode: ALWAYS_ON - platform: rgb name: LED red: output_red id: led green: output_green blue: output_blue restore_mode: ALWAYS_OFF # Touchscreen configuration (XPT2046, resistive, via SPI) # XPT2046 calibration is board-specific. Poll at 50Hz; LVGL rotates touch # with the display. GPIO36 IRQ is deliberately unused (driver requests a # pull-up that this input-only pin cannot provide). touchscreen: platform: xpt2046 id: ts_touch spi_id: my_touchscreen cs_pin: 33 # Polling avoids GPIO36 pull-up errors and noisy PENIRQ edges. update_interval: 20ms # Diagnosis caught a false corner contact at pressure 422. Deliberate # contacts measured 936-2643; reject the low-pressure tail on release. threshold: 800 calibration: x_min: ${TOUCH_CAL_X_MIN} x_max: ${TOUCH_CAL_X_MAX} y_min: ${TOUCH_CAL_Y_MIN} y_max: ${TOUCH_CAL_Y_MAX} 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(touch_down) = true; cyd::touch_guard.begin(millis(), touch.x, touch.y, touch.id); screen_calibration::press(); ESP_LOGI("touch", "press native=%u,%u raw=%d,%d pressure=%d calibration=%d", touch.x, touch.y, id(ts_touch).filtered_raw_x(), id(ts_touch).filtered_raw_y(), touch.z_raw, id(calibration_active)); on_update: - lambda: |- id(last_touch_ms) = millis(); // Only the contact that started the touch counts; the guard ignores other ids. for (const auto &point : touches) { cyd::touch_guard.update(point.x, point.y, point.id); } on_release: - lambda: |- id(last_touch_ms) = millis(); id(touch_down) = false; screen_calibration::release(id(ts_touch).filtered_raw_x(), id(ts_touch).filtered_raw_y()); # Display configuration (ILI9341) display: - platform: mipi_spi id: my_display spi_id: lcd # Standard single-Micro-USB CYD; 40MHz matches ESPHome's board preset. model: ILI9341 data_rate: 40MHz cs_pin: 15 dc_pin: 2 invert_colors: false update_interval: never auto_clear_enabled: false # Panel tuning, appended after ESPHome's built-in ILI9341 sequence. With the # defaults (line inversion, 79 Hz) the cheap CYD panel shows a fine vertical # stripe pattern that flickers when you move your eyes or the board: the # two polarities of adjacent columns differ in brightness. Frame inversion # (0xB4 = 0x07) removes the stripes; the maximum frame rate (0xB1 = 0x10, # 119 Hz) keeps the resulting whole-frame flicker above what the eye sees. # VCOM (0xC5) stayed at the default; sweeping it changed contrast, not the # stripes. Checked on the bench CYD on 2026-09-15. init_sequence: - [0xB4, 0x07] - [0xB1, 0x00, 0x10] # Native panel dimensions (portrait). Do NOT swap these per orientation and # do NOT add a display transform — rotation is handled by LVGL below. # ESPHome 2026.6+ rejects a swapped transform here with "Invalid offsets". dimensions: width: 240 height: 320 lvgl: on_draw_start: - lambda: 'id(rendered_frames)++;' displays: - my_display touchscreens: - touchscreen_id: ts_touch # The ESP32-WROOM-32 has no PSRAM; a full framebuffer (320x240x2 # = 153600 bytes) doesn't fit in DRAM. 25% is the usual value. # 12% instead of 25%: frees up ~20KB of extra contiguous memory, # and LVGL scales the strips of an ARGB layer along with the available # drawing space, so the requested allocations get smaller too. buffer_size: 12% # 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: 18 border_width: 1 border_opa: COVER pad_left: 8 pad_right: 8 pad_top: 8 pad_bottom: 8 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_calibration # ---------- 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} 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 - 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 - label: id: lbl_room x: 11 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: -11 y: ${HEADER_Y} text: "" styles: style_time # Six tiles on page 1, four on page 2. No dragging on resistive glass. - 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: 36 height: 36 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 x: ${TILE_TITLE_X} y: ${TILE_TITLE_Y} text: "Lamp" styles: style_title clickable: false - label: id: t1_value 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: 36 height: 36 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 x: ${TILE_TITLE_X} y: ${TILE_TITLE_Y} text: "Plug" styles: style_title clickable: false - label: id: t2_value 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: 36 height: 36 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 x: ${TILE_TITLE_X} y: ${TILE_TITLE_Y} text: "Evening" styles: style_title clickable: false - label: id: t3_value 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: 36 height: 36 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 x: ${TILE_TITLE_X} y: ${TILE_TITLE_Y} text: "All off" styles: style_title clickable: false - label: id: t4_value 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: 36 height: 36 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 x: ${TILE_TITLE_X} y: ${TILE_TITLE_Y} text: "AC" styles: style_title clickable: false - label: id: t5_value 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: 36 height: 36 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 x: ${TILE_TITLE_X} y: ${TILE_TITLE_Y} text: "Vacuum" styles: style_title clickable: false - label: id: t6_value x: ${TILE_VALUE_X} y: ${TILE_VALUE_Y} text: "—" styles: style_value clickable: false # ---------- TILE 7 — Table lamp ---------- # 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: 36 height: 36 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 x: ${TILE_TITLE_X} y: ${TILE_TITLE_Y} text: "Tile 7" styles: style_title clickable: false - label: id: t7_value x: ${TILE_VALUE_X} y: ${TILE_VALUE_Y} text: "—" styles: style_value clickable: false # ---------- TILE 8 — Mailbox 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: 36 height: 36 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 x: ${TILE_TITLE_X} y: ${TILE_TITLE_Y} text: "Tile 8" styles: style_title clickable: false - label: id: t8_value 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: 36 height: 36 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 x: ${TILE_TITLE_X} y: ${TILE_TITLE_Y} text: "Tile 9" styles: style_title clickable: false - label: id: t9_value 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: 36 height: 36 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 x: ${TILE_TITLE_X} y: ${TILE_TITLE_Y} text: "Tile 10" styles: style_title clickable: false - label: id: t10_value 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: 130 height: 32 pad_all: 0 radius: 0 bg_opa: TRANSP border_width: 0 scrollable: false pressed: styles: paint_page_pressed bg_opa: COVER on_short_click: - if: condition: lambda: 'return cyd::touch_guard.accept(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: 12 text: "< Previous" text_font: label styles: paint_ink clickable: false - label: id: page_number align: BOTTOM_MID y: 0 width: 60 height: 32 pad_top: 9 border_width: 0 text: "1 / 2" text_align: CENTER text_font: sublabel - obj: id: page_next align: BOTTOM_RIGHT x: 0 y: 0 width: 130 height: 32 pad_all: 0 radius: 0 bg_opa: TRANSP border_width: 0 scrollable: false pressed: styles: paint_page_pressed bg_opa: COVER on_short_click: - if: condition: lambda: 'return cyd::touch_guard.accept(millis(), 12);' then: - lambda: 'id(tile_page) += 1;' - script.execute: show_tile_page widgets: - label: align: RIGHT_MID x: -12 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: 10 y: 8 width: 40 height: 40 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: 19 text: "Light" styles: style_room - label: id: overlay_value align: TOP_MID y: 42 text: "100 %" styles: style_value_big - slider: id: overlay_slider align: CENTER y: 24 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: 28 # 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: 28 knob: bg_opa: 0% border_width: 0 shadow_width: 0 pad_all: 10 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: -13 y: -13 width: 44 height: 44 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: 40 height: 40 start_angle: 0 end_angle: 45 min_value: 0 max_value: 100 value: 0 adjustable: false arc_width: 10 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: 40 height: 40 start_angle: 45 end_angle: 90 min_value: 0 max_value: 100 value: 0 adjustable: false arc_width: 10 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: 40 height: 40 start_angle: 90 end_angle: 135 min_value: 0 max_value: 100 value: 0 adjustable: false arc_width: 10 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: 40 height: 40 start_angle: 135 end_angle: 180 min_value: 0 max_value: 100 value: 0 adjustable: false arc_width: 10 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: 40 height: 40 start_angle: 180 end_angle: 225 min_value: 0 max_value: 100 value: 0 adjustable: false arc_width: 10 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: 40 height: 40 start_angle: 225 end_angle: 270 min_value: 0 max_value: 100 value: 0 adjustable: false arc_width: 10 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: 40 height: 40 start_angle: 270 end_angle: 315 min_value: 0 max_value: 100 value: 0 adjustable: false arc_width: 10 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: 40 height: 40 start_angle: 315 end_angle: 360 min_value: 0 max_value: 100 value: 0 adjustable: false arc_width: 10 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: 10 y: 8 width: 40 height: 40 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: 210 long_mode: DOT text_align: CENTER align: TOP_MID y: 21 text: "Light Color" styles: style_room # ---------- CLIMATE DETAIL OVERLAY ---------- # Made for the small resistive panel: 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, so the SPI panel keeps up. - 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: 10 y: 8 width: 40 height: 40 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: -10 y: 8 width: 40 height: 40 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: 18 width: 210 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: 54 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: 80 width: 94% height: 92 radius: 16 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: 10 width: 72 height: 72 radius: 36 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: -10 width: 72 height: 72 radius: 36 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: -8 text: "--" text_font: setpoint_digits clickable: false - label: id: climate_set_label align: CENTER y: 26 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: 184 width: 94% height: 46 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: 6 widgets: - obj: id: cmkey_auto flex_grow: 1 height: 46 radius: 23 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: 46 radius: 23 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: 46 radius: 23 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: 46 radius: 23 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: 46 radius: 23 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: 46 radius: 23 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: 46 radius: 23 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 # ---------- 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: 21 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: 44 width: 98% height: 178 bg_opa: TRANSP border_width: 0 scrollable: false clickable: false pad_all: 3 layout: type: FLEX pad_row: 3 pad_column: 4 flex_flow: ROW_WRAP flex_align_main: CENTER flex_align_cross: CENTER flex_align_track: CENTER widgets: - obj: id: cmode_off width: 88 height: 26 radius: 13 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: 88 height: 26 radius: 13 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: 88 height: 26 radius: 13 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: 88 height: 26 radius: 13 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: 88 height: 26 radius: 13 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: 88 height: 26 radius: 13 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: 88 height: 26 radius: 13 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: 11 text: "Fan" text_font: sublabel styles: paint_subtle text_align: CENTER clickable: false - obj: id: cfan_0 width: 62 height: 22 radius: 11 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 clickable: false - obj: id: cfan_1 width: 62 height: 22 radius: 11 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 clickable: false - obj: id: cfan_2 width: 62 height: 22 radius: 11 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 clickable: false - obj: id: cfan_3 width: 62 height: 22 radius: 11 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 clickable: false - label: id: cswing_title width: 100% height: 11 text: "Swing" text_font: sublabel styles: paint_subtle text_align: CENTER clickable: false - obj: id: cswing_0 width: 62 height: 22 radius: 11 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 clickable: false - obj: id: cswing_1 width: 62 height: 22 radius: 11 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 clickable: false - obj: id: cswing_2 width: 62 height: 22 radius: 11 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 clickable: false - obj: id: cswing_3 width: 62 height: 22 radius: 11 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 clickable: false - obj: id: climate_mode_overlay_x align: TOP_LEFT x: 10 y: 8 width: 40 height: 40 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 - id: touch_cal_page styles: paint_calibration bg_opa: COVER pad_all: 0 border_width: 0 scrollable: false widgets: - label: align: TOP_MID y: 65 text: "Follow the calibration wizard" text_font: sublabel - obj: x: 10 y: 10 width: 20 height: 20 pad_all: 0 border_width: 0 bg_opa: TRANSP scrollable: false widgets: - label: align: CENTER text: "+" text_font: headline styles: paint_calibration - obj: x: 289 y: 10 width: 20 height: 20 pad_all: 0 border_width: 0 bg_opa: TRANSP scrollable: false widgets: - label: align: CENTER text: "+" text_font: headline styles: paint_calibration - obj: x: 289 y: 209 width: 20 height: 20 pad_all: 0 border_width: 0 bg_opa: TRANSP scrollable: false widgets: - label: align: CENTER text: "+" text_font: headline styles: paint_calibration - obj: x: 10 y: 209 width: 20 height: 20 pad_all: 0 border_width: 0 bg_opa: TRANSP scrollable: false widgets: - label: align: CENTER text: "+" text_font: headline styles: paint_calibration - obj: x: 150 y: 110 width: 20 height: 20 pad_all: 0 border_width: 0 bg_opa: TRANSP scrollable: false widgets: - label: align: CENTER text: "+" text_font: headline styles: paint_calibration script: # 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(); 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 = true; 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 - lvgl.widget.hide: brightness_overlay - lvgl.widget.show: color_detail_overlay - delay: 150ms - lvgl.widget.hide: color_detail_overlay - lvgl.widget.show: climate_detail_overlay - delay: 150ms - lvgl.widget.hide: climate_detail_overlay - lvgl.widget.show: climate_mode_overlay - delay: 150ms - 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); // 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; id(back_light).turn_on().set_transition_length(300).set_brightness(level / 100.0f).perform(); 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(); } 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 # ---------- 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(); 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(); ESP_LOGI("alert", "dismissed: %s", reason.c_str()); - lvgl.widget.hide: alert_overlay - script.stop: alert_timeout - script.stop: alert_flash - light.turn_on: id: back_light transition_length: 80ms brightness: !lambda "return screen_settings::current.brightness / 100.0f;" - 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: - light.turn_off: id: back_light transition_length: 0s - delay: 120ms - light.turn_on: id: back_light transition_length: 0s brightness: !lambda "return screen_settings::current.brightness / 100.0f;" - delay: 170ms - 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; - light.turn_on: id: back_light # Almost instant brightness-up. ESPHome's default is 1s, and that feels sluggish # when you touch the screen and are waiting on light. transition_length: 80ms brightness: !lambda "return screen_settings::current.brightness / 100.0f;" - lvgl.widget.hide: dim_wake_overlay - script.execute: close_cards # 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"}}; bool more = !id(active_climate_fan_modes).empty() || !id(active_climate_swing_modes).empty(); 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. Without keys the // setpoint card moves to the middle of the room below the status line. const bool show_keys = count > 1 || more; if (show_keys) lv_obj_remove_flag(id(climate_mode_keys), LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(id(climate_mode_keys), LV_OBJ_FLAG_HIDDEN); const bool landscape = atoi("${DISPLAY_W}") >= atoi("${DISPLAY_H}"); lv_obj_set_y(id(climate_setpoint_card), show_keys ? (landscape ? 80 : 96) : (landscape ? 108 : 140)); id(climate_power_refresh).execute(); # Opens the mode picker. Hides modes the entity doesn't support and marks # 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; - 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; - 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()); // A portrait panel (240 x 320) keeps the same card, a little lower and with more air. if (atoi("${DISPLAY_W}") < atoi("${DISPLAY_H}")) { lv_obj_set_y(id(climate_current_label), 60); lv_obj_set_y(id(climate_setpoint_card), 96); lv_obj_set_y(id(climate_mode_keys), 222); for (lv_obj_t *key : {id(climate_minus_button), id(climate_plus_button)}) { lv_obj_set_size(key, 56, 56); lv_obj_set_style_radius(key, 28, LV_PART_MAIN); } lv_obj_set_x(id(climate_minus_button), 6); lv_obj_set_x(id(climate_plus_button), -6); } 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: - platform: template name: "Calibrate touch" internal: ${DASHBOARD_INBOX_INTERNAL} entity_category: config on_press: - lambda: 'if (runtime_tiles::enabled) screen_calibration::begin();' # Wake and Sleep for Home Assistant automations (0.2.45+). Wake is a tap: it lights a dimmed # screen and counts the standby time from now. 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 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));'