# ESP Screens - Guition ESP32-S3-4848S040 board: 4-inch EU wallbox, 480x480 IPS + GT911 capacitive touch. # # What is in here: the hardware (display, touch, backlight, PSRAM), the sizes the shared tree takes from # this board, the code the shared lambdas take from this board (hooks), and what only a Guition has: camera # images, a screen that turns a quarter, a page swipe from the glass edge, a climate card with a fan and # swing card, and the snapshot diagnostics. Everything else (the LVGL tree, the cards, the scripts, the # entities) is packages/core.yaml, which every board shares; docs/PROFILES.md says how the files fit # together, docs/GUITION.md what this board needs. # # Two entry files include this: guition-4848s040.yaml (a build on this computer) and packages/guition.yaml # (the package a screen builds from over GitHub). substitutions: # 0: no drift limit. A press that drifts is still a tap as long as the finger lets go on the tile # (runtime_tiles::event checks where it let go: LVGL keeps the press on the tile and would click it # anywhere), and a quick flick is LVGL's gesture, never a tap (the home_page gesture handler in on_boot). # The CYD keeps a limit because its resistive panel jumps. TOUCH_MOVE_LIMIT_PX: "0" # Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not. TOUCH_MIN_PRESS_MS: "20" # Page swipe starts in this band along the left or right edge (about 5 mm) and needs # this much travel inward (about 6 mm); a swipe from the middle of the screen does nothing. EDGE_SWIPE_BAND_PX: "32" EDGE_SWIPE_TRAVEL_PX: "40" # A slider let go this close to the left or right edge of the glass (about 1 cm) meant its end when # that end lies in the band: the panel loses a finger swiped off the glass 30-50 px before the edge. SLIDER_EDGE_SNAP_PX: "67" DEVICE_NAME: "guition-new" DEVICE_FRIENDLY_NAME: "My Guition" DISPLAY_W: "480" DISPLAY_H: "480" LVGL_ROTATION: "0" TOUCH_SWAP_XY: "false" TOUCH_MIRROR_X: "false" TOUCH_MIRROR_Y: "false" GRID_COL1_X: "16" GRID_COL2_X: "246" GRID_ROW1_Y: "0" GRID_ROW2_Y: "120" GRID_ROW3_Y: "240" GRID_ROW4_Y: "360" GRID_ROW5_Y: "480" SCROLL_Y: "72" SCROLL_H: "348" TILE_W: "218" TILE_H: "108" HEADER_Y: "22" # Holding the top bar opens the settings page (0.2.44+). The two edge bands stay clear: a swipe # that starts up there still changes the page. SETTINGS_HOLD_X: "${EDGE_SWIPE_BAND_PX}" SETTINGS_HOLD_W: "416" SETTINGS_HOLD_DRIFT_PX: "32" # Alert overlay (HA action show_alert), 480x480: one card centred on LVGL's top layer. ALERT_CARD_W: "420" ALERT_CARD_H: "320" ALERT_ICON_X: "26" ALERT_ICON_Y: "22" ALERT_TEXT_X: "90" ALERT_TEXT_W: "304" ALERT_TITLE_Y: "26" ALERT_TITLE_H: "32" # one line of headline; a DOT label only ends in an ellipsis with a height ALERT_SUBTITLE_Y: "68" ALERT_SUBTITLE_H: "150" ALERT_BUTTON_W: "150" ALERT_BUTTON_H: "60" ALERT_BUTTON_INSET: "22" ALERT_TITLE_MAX: "64" # bytes; the label ends one line with an ellipsis ALERT_BUTTON_MAX: "16" # bytes on the OK button ALERT_SUBTITLE_MAX: "240" # bytes; wraps over the rows the card has # An alert with a camera image (firmware 0.2.57+): the image across the top of a taller card, the words under it. ALERT_CARD_H_IMAGE: "452" ALERT_IMAGE_INSET: "14" ALERT_IMAGE_SHIFT: "236" # the icon, title and subtitle move down by the image and a gap ALERT_SUBTITLE_H_IMAGE: "62" # two lines between the title and the button # Camera images (firmware 0.2.57+): the boxes ESP Screen Manager sizes a snapshot for (camera_feed.BOXES). CAMERA_FULL_W: "480" CAMERA_FULL_H: "480" CAMERA_THUMB_W: "392" CAMERA_THUMB_H: "220" OVERLAY_SLIDER_W: "144" OVERLAY_SLIDER_H: "300" OVERLAY_ICON_Y: "148" TILE_ICON_X: "0" TILE_ICON_Y: "12" TILE_TITLE_X: "64" TILE_TITLE_Y: "17" TILE_VALUE_X: "64" TILE_VALUE_Y: "45" # Fonts: one size per role, the same files on every board FONT_TIME_SIZE: "27" # the clock in the top bar FONT_HEADLINE_SIZE: "27" # card titles FONT_WATCH_VALUE_SIZE: "38" FONT_CLOCK_DIGITS_SIZE: "48" FONT_SETPOINT_SIZE: "64" # the climate card's setpoint FONT_LABEL_SIZE: "18" # tile titles FONT_SUBLABEL_SIZE: "16" # tile values and small text FONT_SUBLABEL_BIG_SIZE: "21" # the top bar and settings rows FONT_ICON_SIZE: "42" # tile icons FONT_ICON_MINI_SIZE: "26" # the top bar and card keys FONT_ICON_BIG_SIZE: "64" # full-page tiles FONT_WATCH_ICON_SIZE: "18" # Tiles and the top bar TILE_RADIUS: "22" # corner of a tile TILE_PAD: "12" # inside a tile TILE_ICON_SIZE: "54" # the icon circle HEADER_INSET: "16" # the room name and the clock from the glass edge # The page bar under the tiles PAGE_KEY_W: "240" # the previous/next keys under the tiles PAGE_BAR_H: "60" # the page bar PAGE_NUMBER_W: "160" # the dots in the middle PAGE_CHEVRON_INSET: "24" # the chevrons from the outer edge # Cards: the round keys and the brightness card CARD_KEY_X: "16" # a card's round key from the side CARD_KEY_Y: "16" # from the top CARD_KEY_SIZE: "60" # its diameter OVERLAY_TITLE_Y: "30" # the brightness card's title OVERLAY_VALUE_Y: "70" # its percentage OVERLAY_SLIDER_Y: "36" # its slider below the centre OVERLAY_SLIDER_RADIUS: "42" # the slider's corners OVERLAY_KNOB_PAD: "15" # the knob COLOR_KEY_X: "-20" # the colour wheel key from the bottom right corner COLOR_KEY_Y: "-26" COLOR_KEY_SIZE: "66" # its diameter COLOR_ARC_SIZE: "60" # the eight arcs in it COLOR_ARC_W: "15" COLOR_TITLE_W: "310" # the colour card's title COLOR_TITLE_Y: "35" # The climate card CLIMATE_TITLE_Y: "30" # the title CLIMATE_TITLE_W: "300" CLIMATE_STATUS_Y: "92" # the status line CLIMATE_CARD_Y: "136" # the setpoint card CLIMATE_CARD_H: "200" CLIMATE_CARD_RADIUS: "24" CLIMATE_KEY_INSET: "18" # the -/+ keys from the card edge CLIMATE_KEY_SIZE: "116" # their diameter CLIMATE_KEY_RADIUS: "58" CLIMATE_NUMBER_Y: "-14" # the setpoint number CLIMATE_CAPTION_Y: "46" # the word under it CLIMATE_MODES_Y: "364" # the row of mode keys CLIMATE_MODES_H: "76" CLIMATE_MODES_GAP: "10" CLIMATE_MODE_RADIUS: "38" # The mode picker PICKER_TITLE_Y: "35" # the "Mode" title PICKER_LIST_Y: "92" # the list of choices PICKER_LIST_H: "372" PICKER_PAD: "4" PICKER_ROW_GAP: "6" PICKER_COL_GAP: "6" PICKER_MODE_W: "136" # a mode chip PICKER_MODE_H: "58" PICKER_MODE_RADIUS: "22" PICKER_TITLE_H: "24" # the Fan and Swing captions PICKER_CHIP_W: "98" # a fan or swing chip PICKER_CHIP_H: "48" PICKER_CHIP_RADIUS: "18" PICKER_CHIP_FONT: "sublabel_big" # the words on them # The touch test page TOUCH_TEST_PAGE_PAINT: "paint_page_soft" # the page TOUCH_TEST_TITLE: "GT911 touch check" # the words at the top TOUCH_TEST_TITLE_FONT: "headline" TOUCH_TEST_TITLE_Y: "110" TOUCH_TEST_INSET: "12" # the corner crosses from the edge TOUCH_TEST_FAR_X: "443" # the right crosses: width - inset - size - 1 TOUCH_TEST_FAR_Y: "443" # the bottom crosses TOUCH_TEST_MID_X: "228" # the middle cross TOUCH_TEST_MID_Y: "228" TOUCH_TEST_SIZE: "24" # a cross TOUCH_TEST_PAINT: "paint_ink" # the crosses # What the shared scripts do differently on this board LVGL_DEFAULT_FONT: "sublabel" # LVGL's default font: every label names its own, so this only keeps Montserrat out of the build LVGL_BUFFER_SIZE: "25%" # LVGL's draw buffer as a share of the screen PAGE_DOTS_LARGE: "true" # bigger page dots EFFECTS_ROW_FONT: "sublabel" # the effects page's rows ROTATION_SUPPORTED: "true" # the screen turns a quarter from the settings STANDBY_DIM_MS: "1500" # how long the backlight takes to dim into standby (waking takes 300) ALERT_FLASH_FADE_MS: "60" # an alert's flash: the fade of each step ALERT_FLASH_DARK_MS: "140" # how long it stays dark ALERT_FLASH_LIT_MS: "180" # how long it stays lit # Code the shared lambdas of packages/core.yaml take from this board, at the place named ${...}: the # exact lines the old profile had there. Empty when this board adds nothing at that place; every board # must name every hook, or ESPHome stops at the undefined one. # the first boot step: the touch guard, and what this board's panel needs BOOT_TOUCH: |- cyd::touch_guard.configure(${TOUCH_MOVE_LIMIT_PX}, ${TOUCH_MIN_PRESS_MS}); cyd::edge_swipe.configure(${DISPLAY_W}, ${DISPLAY_H}, ${EDGE_SWIPE_BAND_PX}, ${EDGE_SWIPE_TRAVEL_PX}); cyd::edge_snap_band = ${SLIDER_EDGE_SNAP_PX}; // The page and the tile container take no presses: the theme's pressed style would // otherwise repaint nearly the whole screen twice per touch in the empty edge band, // starving the touch polling for the whole length of a quick swipe. lv_obj_remove_flag(id(home_page)->obj, LV_OBJ_FLAG_CLICKABLE); lv_obj_remove_flag(id(tile_scroll), LV_OBJ_FLAG_CLICKABLE); // A stray GT911 contact at exactly (0, 0) never reaches LVGL (cyd::GhostTouch): as the last // sample of a drag it sent a slider to its end on release. LVGL still reads ESPHome's callback. if (auto *indev = lv_indev_get_next(nullptr)) { static lv_indev_read_cb_t esphome_read = nullptr; static const lv_indev_read_cb_t filtered = [](lv_indev_t *input, lv_indev_data_t *data) { esphome_read(input, data); int32_t ghost_x = 0, ghost_y = 0; id(screen_lvgl).rotate_coordinates(ghost_x, ghost_y); const unsigned dropped = cyd::ghost_touch.dropped(); const auto read = cyd::ghost_touch.filter({data->state == LV_INDEV_STATE_PRESSED, (int) data->point.x, (int) data->point.y}, ghost_x, ghost_y); if (cyd::ghost_touch.dropped() != dropped) ESP_LOGI("touch", "GT911 stray contact at (0,0) ignored"); data->state = read.pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; data->point.x = read.x; data->point.y = read.y; }; if (lv_indev_get_read_cb(indev) != filtered) { esphome_read = lv_indev_get_read_cb(indev); lv_indev_set_read_cb(indev, filtered); } } id(last_touch_ms) = millis(); id(last_use_ms) = millis(); id(display_dimmed) = false; # right after runtime_tiles::enabled = true, before the settings load BOOT_ENABLED_HOOK: "runtime_tiles::rotation_supported = true;" # the paint style only this board has (theme::paints) BOARD_PAINT_FILL: "theme::fill(id(paint_camera), Paint::camera);" # in runtime_tiles::dismiss, after the detail card closes CLOSE_CARDS_HOOK: "runtime_tiles::camera_close();" # what LVGL's gesture over the tiles does on this board BOOT_PAGE_GESTURE: |- // A quick flick or wipe over the tiles is never a tap (firmware 0.2.65+). LVGL reports it as a gesture that // bubbles up to this page (a tile's slider keeps its own, runtime_tiles::bind): the tap under the finger is // consumed and LVGL waits for the release. No page flip: pages change with the edge swipe (on_update). LVGL // wants speed as well as 50 px of travel, so a firm press that drifts slowly is still a tap. Cards and the // settings page keep their drags; in standby the push still wakes the screen, it only switches nothing. lv_obj_add_event_cb(id(home_page)->obj, [](lv_event_t *) { if (!runtime_tiles::enabled || id(calibration_active) || !id(active_entity).empty() || settings_screen::visible() || (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN))) return; // A quick drag on a slider is that slider's: the press stays with it (firmware 0.2.68+). if (runtime_tiles::captured_slider) return; cyd::touch_guard.consume(); ESP_LOGI("touch", "flick: no tap"); if (id(display_dimmed)) return; if (auto *input = lv_indev_active()) lv_indev_wait_release(input); }, LV_EVENT_GESTURE, nullptr); # after the gesture: the camera images of a board that shows them BOOT_CAMERA_HOOKS: |- // Camera images (firmware 0.2.57+): the two online_images and the alert's frame. // set_url() forgets the ETag, so it is only called for a new link: an unchanged picture then answers 304. runtime_tiles::camera_full.load = [](const std::string &url) { static std::string current; if (url != current) { id(camera_image).set_url(url); current = url; } id(camera_image).update(); }; runtime_tiles::camera_full.release = []() { id(camera_image).release(); }; runtime_tiles::camera_full.source = []() { return id(camera_image).get_lv_image_dsc(); }; runtime_tiles::camera_thumb.load = [](const std::string &url) { static std::string current; if (url != current) { id(alert_image).set_url(url); current = url; } id(alert_image).update(); }; runtime_tiles::camera_thumb.release = []() { id(alert_image).release(); }; runtime_tiles::camera_thumb.source = []() { return id(alert_image).get_lv_image_dsc(); }; runtime_tiles::alert_frame = id(alert_image_frame); runtime_tiles::alert_frame_icon = id(alert_image_icon); runtime_tiles::alert_room = [](bool image) { const int shift = image ? ${ALERT_IMAGE_SHIFT} : 0; lv_obj_set_height(id(alert_card), image ? ${ALERT_CARD_H_IMAGE} : ${ALERT_CARD_H}); lv_obj_set_y(id(alert_icon), ${ALERT_ICON_Y} + shift); lv_obj_set_y(id(alert_title), ${ALERT_TITLE_Y} + shift); lv_obj_set_y(id(alert_subtitle), ${ALERT_SUBTITLE_Y} + shift); lv_obj_set_height(id(alert_subtitle), image ? ${ALERT_SUBTITLE_H_IMAGE} : ${ALERT_SUBTITLE_H}); }; # after the cards' hooks, before the settings page's: the calibration wizard of a resistive panel, the fan and swing card of a big one BOOT_CARDS_TAIL: |- climate_card::look.card = id(climate_settings_card); climate_card::look.font = id(label)->get_lv_font(); climate_card::look.icon_font = id(materialdesign_icons_mini)->get_lv_font(); climate_card::choose = [](char kind, int index) { if (kind == 'f') id(set_climate_fan_mode).execute(index); else id(set_climate_swing_mode).execute(index); }; # what else keeps the settings page closed (inline, in the condition) MAY_OPEN_EXTRA: " && !runtime_tiles::camera_visible()" # the log line of a boot onto the touch test page TOUCH_TEST_READY_LOG: "GT911 touch test ready; no tile actions" # one more line of the ui_state diagnostics UI_STATE_HOOK: "ESP_LOGI(\"ui_diag\", \"rotation=%u actual=%u\", (unsigned)runtime_tiles::rotation, (unsigned)id(screen_lvgl).get_rotation());" # every 250 ms after runtime_tiles::tick() TICK_HOOK: "runtime_tiles::camera_tick();" # apply_screen_settings: turning the screen, before the standby logic APPLY_ROTATION: |- if(runtime_tiles::enabled && static_cast(id(screen_lvgl).get_rotation())!=runtime_tiles::rotation){ cyd::touch_guard.consume(); if(lv_indev_get_act())lv_indev_wait_release(lv_indev_get_act()); id(screen_lvgl).set_rotation(static_cast(runtime_tiles::rotation)); } # apply_screen_settings: how the backlight takes its level APPLY_BACKLIGHT: |- // Standby dims slowly (ESPHome's transition); waking is quick. id(set_backlight).execute(level, id(display_dimmed) ? 1500 : 300); # apply_screen_settings: the Rotation entity in Home Assistant follows APPLY_ROTATION_ENTITY: |- const auto shown_rotation = id(setting_rotation)->active_index(); if (!id(setting_rotation)->has_state() || !shown_rotation.has_value() || *shown_rotation != (size_t) (settings_screen::rotation / 90)) id(setting_rotation)->update(); # back_to_page_1_when_due: what else counts as away from page 1 (inline) AWAY_EXTRA: " runtime_tiles::camera_visible() ||" # alert_show: after the settings page closed, before the card is made ALERT_SHOW_HOOK: |- // An open camera closes; the card makes room for an image the app announced for this alert. runtime_tiles::alert_prepare(); # set_climate_fan_mode and set_climate_swing_mode: after the choice is stored CLIMATE_MODE_CHOSEN_HOOK: "id(climate_card_refresh).execute();" # open_value_overlay: the climate card of a panel in portrait, before its refresh OPEN_VALUE_OVERLAY_TAIL: "" # ============================================================ # No need to edit below this line. # ============================================================ esphome: platformio_options: build_flags: - -DLV_USE_SNAPSHOT=1 includes: - esp32: board: esp32-s3-devkitc-1 variant: esp32s3 flash_size: 16MB cpu_frequency: 240MHz framework: type: esp-idf version: recommended advanced: execute_from_psram: true psram: mode: octal speed: 80MHz logger: tx_buffer_size: 2048 hardware_uart: UART0 api: actions: - action: ui_snapshot then: - script.execute: capture_ui_snapshot # A camera full screen from Home Assistant (firmware 0.2.57+), as a tap on its tile opens it; an empty entity closes # it. Only a camera on the screen's tiles or in a recent alert gets an image from ESP Screens. - action: preview_camera variables: entity: string then: - lambda: |- if (entity.empty()) { runtime_tiles::camera_close(); return; } id(last_use_ms) = millis(); id(wake_display).execute(); std::string name = entity; for (size_t i = 0; i < runtime_tiles::model.count; ++i) if (runtime_tiles::model.tiles[i].entity == entity) { name = runtime_tiles::model.tiles[i].name; break; } runtime_tiles::camera_open(entity, name); # Camera images (firmware 0.2.57+): ESP Screen Manager serves each image already sized for this screen, as an uncompressed # BMP on its own port on the LAN (screen_manager/app/camera_feed.py). ESPHome decodes a BMP piece by piece as it arrives, # one `buffer_size` per loop, where a JPEG of the full screen held the loop for 0.6 s; touch and drawing keep their turn. # 16 KB a loop (firmware 0.2.73+): the full picture came in 1.8 s instead of 2.8 s with 4 KB. A loop waited over 50 ms # for it in one of eleven opens with Wi-Fi awake (0.1 s), in five of twelve with power save; 32 KB saved 0.3 s more but # held a loop 0.1 s more often (docs/TEST_RESULTS_0287.md, 0288). One image full screen and one in an alert's # frame; runtime_tiles.h decides when they load and frees them when they close. Plain HTTP on the LAN, no certificates. http_request: timeout: 4s verify_ssl: false # The top-level online_image form that ESPHome 2026.6.2 (min_version) knows; 2026.7+ still reads it and suggests # `image: - platform: online_image`, which this profile can use once min_version reaches 2026.7. online_image: - id: camera_image url: "http://127.0.0.1/camera.bmp" # set when a camera opens format: BMP type: RGB565 buffer_size: 16384 on_download_finished: - lambda: 'runtime_tiles::camera_loaded(false, cached);' on_error: - lambda: 'runtime_tiles::camera_failed(false);' - id: alert_image url: "http://127.0.0.1/camera.bmp" # set when an alert brings an image format: BMP type: RGB565 buffer_size: 16384 on_download_finished: - lambda: 'runtime_tiles::camera_loaded(true, cached);' on_error: - lambda: 'runtime_tiles::camera_failed(true);' text_sensor: - platform: template name: "Guition screen type" entity_category: diagnostic update_interval: never lambda: 'return {"guition"};' # Rotation as a Home Assistant choice (firmware 0.2.49+). Only the Guition turns; the CYD keeps its orientation. select: - platform: template id: setting_rotation name: "Rotation" icon: "mdi:screen-rotation" entity_category: config options: ["0°", "90°", "180°", "270°"] update_interval: never lambda: |- static const char *const names[] = {"0°", "90°", "180°", "270°"}; return std::string(names[std::clamp(settings_screen::rotation, 0, 270) / 90]); set_action: - lambda: |- const std::string option(x.c_str(), x.size()); settings_screen::set("rotation", atoi(option.c_str())); # Pinout from ESPHome's GUITION-4848S040 preset. No relay outputs are configured. spi: id: lcd clk_pin: GPIO48 mosi_pin: GPIO47 i2c: id: touch_bus sda: GPIO19 scl: number: GPIO45 ignore_strapping_warning: true scan: true output: - platform: ledc pin: GPIO38 id: gpio_backlight_pwm frequency: 20000Hz light: # The firmware owns the backlight: brightness, standby, night and alerts set it, and the minute tick # restores it. Home Assistant uses the brightness numbers and the Wake and Sleep buttons (0.2.46+). - platform: monochromatic output: gpio_backlight_pwm name: "Display Backlight" id: back_light internal: true restore_mode: ALWAYS_ON default_transition_length: 150ms touchscreen: platform: gt911 id: ts_touch i2c_id: touch_bus display: my_display update_interval: 20ms transform: swap_xy: ${TOUCH_SWAP_XY} mirror_x: ${TOUCH_MIRROR_X} mirror_y: ${TOUCH_MIRROR_Y} on_touch: - lambda: |- id(last_touch_ms) = millis(); id(last_use_ms) = millis(); id(touch_down) = true; cyd::touch_guard.begin(millis(), touch.x, touch.y, touch.id); cyd::edge_swipe.begin(touch.x, touch.y, static_cast(id(screen_lvgl).get_rotation())); ESP_LOGI("touch", "GT911 press x=%u y=%u id=%u test=%d", touch.x, touch.y, (unsigned) touch.id, id(calibration_active)); on_update: - lambda: |- id(last_touch_ms) = millis(); id(last_use_ms) = millis(); // Only the contact that started the touch counts; the guard ignores other ids. for (const auto &point : touches) { // Trace every sample of an edge touch: the log then shows how often the panel delivers. if (cyd::edge_swipe.armed()) ESP_LOGI("touch", "swipe id=%u st=%u x=%u y=%u", (unsigned) point.id, (unsigned) point.state, point.x, point.y); // The stray (0, 0) contact (cyd::GhostTouch) is not where the finger went. if (point.x == 0 && point.y == 0) continue; cyd::touch_guard.update(point.x, point.y, point.id); if (point.id != cyd::touch_guard.contact()) continue; // Swiping in from a side edge flips the page ("Swiping between pages"); the tap // under the finger is consumed and LVGL waits for the release. LVGL 9.5 sends no // PRESSING to the input device, hence the touchscreen trigger. const int step = cyd::edge_swipe.update(point.x, point.y); if (!step) continue; const char *blocked = !runtime_tiles::enabled ? "no runtime tiles" : !runtime_tiles::swipe_pages ? "setting off" : id(display_dimmed) ? "screen dimmed" : id(calibration_active) ? "calibration" : !id(active_entity).empty() ? "card open" : (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)) ? "detail card open" : runtime_tiles::camera_visible() ? "camera open" : nullptr; if (blocked) { ESP_LOGI("touch", "edge swipe ignored: %s", blocked); continue; } cyd::touch_guard.consume(); for (auto *indev = lv_indev_get_next(nullptr); indev; indev = lv_indev_get_next(indev)) lv_indev_wait_release(indev); ESP_LOGI("touch", "edge swipe: page %d -> %d", id(tile_page), id(tile_page) + step); id(tile_page) += step; id(show_tile_page).execute(); } on_release: - lambda: |- id(last_touch_ms) = millis(); id(last_use_ms) = millis(); id(touch_down) = false; if (cyd::edge_swipe.armed() && cyd::edge_swipe.inward() > 0) ESP_LOGI("touch", "edge swipe not fired: %d px inward, %d px vertical", cyd::edge_swipe.inward(), cyd::edge_swipe.sideways()); cyd::edge_swipe.end(); # Native ESPHome driver with the working manufacturer's LVGL demo timings. # See docs/GUITION_FACTORY_REFERENCE.md; no custom RGB component is required. display: - platform: st7701s id: my_display dimensions: {width: 480, height: 480} spi_id: lcd spi_mode: MODE0 data_rate: 2MHz color_order: RGB invert_colors: false cs_pin: 39 de_pin: 18 hsync_pin: 16 vsync_pin: 17 pclk_pin: 21 pclk_frequency: 16MHz pclk_inverted: false hsync_front_porch: 10 hsync_pulse_width: 8 hsync_back_porch: 50 vsync_front_porch: 10 vsync_pulse_width: 8 vsync_back_porch: 20 transform: mirror_x: false mirror_y: false init_sequence: - 1 # Manufacturer's 16-bit bus configuration and normal scan direction. - [0xFF, 0x77, 0x01, 0x00, 0x00, 0x10] - [0xCD, 0x00] - [0xC7, 0x00] # Return to the standard register bank before pixel format / sleep-out. - [0xFF, 0x77, 0x01, 0x00, 0x00, 0x00] - [0x3A, 0x60] - [0x36, 0x00] - [0x20] - [0x11] - delay 120ms data_pins: red: [11, 12, 13, 14, 0] green: [8, 20, 3, 46, 9, 10] blue: [4, 5, 6, 7, 15] update_interval: never auto_clear_enabled: false lvgl: id: screen_lvgl style_definitions: # The alert's camera frame (theme::paints fills it: BOARD_PAINT_FILL). - id: paint_camera top_layer: widgets: - obj: id: !extend alert_overlay widgets: - obj: id: !extend alert_card widgets: # The alert's camera image (firmware 0.2.57+) across the top of the card, shown when the app announced # one; a tap opens the camera full screen over the alert. runtime_tiles.h puts the image in it. # Rounded like the alert card itself (firmware 0.2.73+); the image takes this radius (camera_loaded). - obj: id: alert_image_frame x: ${ALERT_IMAGE_INSET} y: ${ALERT_IMAGE_INSET} width: ${CAMERA_THUMB_W} height: ${CAMERA_THUMB_H} hidden: true styles: paint_camera bg_opa: COVER radius: 18 border_width: 0 shadow_width: 0 pad_all: 0 clickable: true scrollable: false scrollbar_mode: 'OFF' pressed: bg_opa: COVER on_click: - if: condition: lambda: 'return cyd::touch_guard.accept(millis(), 14) && !runtime_tiles::alert_camera.empty();' then: - lambda: 'runtime_tiles::camera_open(runtime_tiles::alert_camera, id(alert_title_text));' widgets: - label: id: alert_image_icon align: CENTER text: "\U000F07AE" text_font: materialdesign_icons clickable: false pages: - id: !extend home_page widgets: # Never shown either: it makes ESPHome compile LVGL's image widget, which a camera image is drawn with. - image: id: camera_image_seed hidden: true src: alert_image # The tile labels get a width and an ellipsis on this board (the small panel keeps LVGL's content width). - obj: id: !extend tile_scroll widgets: - obj: id: !extend tile1 widgets: - label: id: !extend t1_title height: 24 width: 130 long_mode: DOT - label: id: !extend t1_value height: 22 width: 130 long_mode: DOT - obj: id: !extend tile2 widgets: - label: id: !extend t2_title height: 24 width: 130 long_mode: DOT - label: id: !extend t2_value height: 22 width: 130 long_mode: DOT - obj: id: !extend tile3 widgets: - label: id: !extend t3_title height: 24 width: 130 long_mode: DOT - label: id: !extend t3_value height: 22 width: 130 long_mode: DOT - obj: id: !extend tile4 widgets: - label: id: !extend t4_title height: 24 width: 130 long_mode: DOT - label: id: !extend t4_value height: 22 width: 130 long_mode: DOT - obj: id: !extend tile5 widgets: - label: id: !extend t5_title height: 24 width: 130 long_mode: DOT - label: id: !extend t5_value height: 22 width: 130 long_mode: DOT - obj: id: !extend tile6 widgets: - label: id: !extend t6_title height: 24 width: 130 long_mode: DOT - label: id: !extend t6_value height: 22 width: 130 long_mode: DOT - obj: id: !extend tile7 widgets: - label: id: !extend t7_title height: 24 width: 130 long_mode: DOT - label: id: !extend t7_value height: 22 width: 130 long_mode: DOT - obj: id: !extend tile8 widgets: - label: id: !extend t8_title height: 24 width: 130 long_mode: DOT - label: id: !extend t8_value height: 22 width: 130 long_mode: DOT - obj: id: !extend tile9 widgets: - label: id: !extend t9_title height: 24 width: 130 long_mode: DOT - label: id: !extend t9_value height: 22 width: 130 long_mode: DOT - obj: id: !extend tile10 widgets: - label: id: !extend t10_title height: 24 width: 130 long_mode: DOT - label: id: !extend t10_value height: 22 width: 130 long_mode: DOT - obj: id: !extend climate_detail_overlay widgets: # Fan and swing, each a row of choices behind its icon (climate_card.h fills the card). - obj: id: climate_settings_card align: TOP_MID y: 340 width: 94% height: 116 radius: 24 styles: paint_card bg_opa: COVER border_width: 1 pad_all: 0 hidden: true clickable: false scrollable: false script: - id: capture_ui_snapshot mode: single then: - lambda: guition::snapshot_begin(); - repeat: count: 240 then: - lambda: guition::snapshot_next_row(); - delay: 100ms # Render every overlay and both pages repeatedly without controlling devices. - id: ui_self_test mode: single then: - logger.log: "UI_TEST START: page/overlay render stress, no HA actions" - lambda: 'ESP_LOGI("ui_test", "Light sliders: %s", light_controls::self_test() ? "PASS" : "FAIL");' - lambda: 'id(calibration_active) = false;' - lvgl.page.show: home_page - script.execute: wake_display - repeat: count: 10 then: - lambda: |- id(last_touch_ms) = millis(); id(last_use_ms) = millis(); static unsigned test_page=0; id(tile_page) = runtime_tiles::enabled ? test_page++ % runtime_tiles::page_count() : 1; - script.execute: show_tile_page - delay: 300ms - lambda: |- lv_obj_update_layout(id(tile_scroll)); const int count = runtime_tiles::model.count; // Wide cards take two slots, so visibility follows the runtime packing. const bool paginated = runtime_tiles::enabled ? runtime_tiles::page_count() > 1 : count > 6; lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5), id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)}; bool ok = guition::check_geometry(id(home_page)->obj); for (int i = 0; i < 10; ++i) { bool visible = runtime_tiles::enabled ? i<6 && runtime_tiles::widgets[i].index= 6 : i < 6); ok = ok && (lv_obj_has_flag(tiles[i], LV_OBJ_FLAG_HIDDEN) != visible); } // The bar shows with more than one page, unless the Page buttons setting is off (firmware 0.2.69+). const bool bar = paginated && (!runtime_tiles::enabled || settings_screen::page_buttons); 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) != bar); lv_area_t nav, grid; lv_obj_get_coords(id(page_next), &nav); lv_obj_get_coords(id(tile_scroll), &grid); ok = ok && (!bar || grid.y2 < nav.y1); if(runtime_tiles::enabled)ok = runtime_tiles::check_tile_geometry() && ok; ESP_LOGI("ui_test", "page_check=%s count=%d heap=%u frames=%u grid_bottom=%d nav_top=%d", ok ? "PASS" : "FAIL", count, (unsigned) esp_get_free_heap_size(), (unsigned) id(rendered_frames), grid.y2, nav.y1); id(tile_page) = 0; - script.execute: show_tile_page - delay: 150ms - lvgl.widget.show: brightness_overlay - delay: 150ms - lambda: guition::check_geometry(id(brightness_overlay)); - lvgl.widget.hide: brightness_overlay - lvgl.widget.show: color_detail_overlay - delay: 150ms - lambda: guition::check_geometry(id(color_detail_overlay)); - lvgl.widget.hide: color_detail_overlay - lvgl.widget.show: climate_detail_overlay - delay: 150ms - lambda: guition::check_geometry(id(climate_detail_overlay)); - lvgl.widget.hide: climate_detail_overlay - lvgl.widget.show: climate_mode_overlay - delay: 150ms - lambda: guition::check_geometry(id(climate_mode_overlay)); - lvgl.widget.hide: climate_mode_overlay - lvgl.widget.show: alert_overlay - delay: 150ms - lvgl.widget.hide: alert_overlay - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' - logger.log: "UI_TEST COMPLETE" # The two scripts that drive the backlight through set_backlight; the CYD drives it as a light action. - id: alert_dismiss parameters: reason: string then: - if: condition: lambda: 'return id(alert_active);' then: - lambda: |- id(alert_active) = false; id(last_touch_ms) = millis(); runtime_tiles::alert_clear(); ESP_LOGI("alert", "dismissed: %s", reason.c_str()); - lvgl.widget.hide: alert_overlay - script.stop: alert_timeout - script.stop: alert_flash - lambda: 'id(set_backlight).execute(screen_settings::current.brightness, 80);' - script.execute: id: alert_report action: !lambda 'return reason;' # Four backlight blinks when the alert arrives; the screen then stays on as usual. - id: alert_flash mode: restart then: - repeat: count: 4 then: - lambda: 'id(set_backlight).execute(0, 60);' - delay: 140ms - lambda: 'id(set_backlight).execute(screen_settings::current.brightness, 60);' - delay: 180ms - id: wake_display mode: restart then: - lambda: |- id(last_touch_ms) = millis(); id(display_dimmed) = false; id(sleep_requested) = false; // Almost instant brightness-up. ESPHome's default is 1s, and that feels sluggish // when you touch the screen and are waiting on light. id(set_backlight).execute(screen_settings::current.brightness, 80); - lvgl.widget.hide: dim_wake_overlay - script.execute: close_cards # One owner for the backlight level, through ESPHome's own light transition (firmware 0.2.57+). Firmware 0.2.13-0.2.56 # stepped the duty with the LEDC hardware fader by reaching into ESPHome's LEDC output, which ESPHome 2026.8 made final; # a new call interrupts the running transition from where it is, as ESPHome does for any light. - id: set_backlight mode: restart parameters: level: int ms: int then: - lambda: id(back_light).turn_on().set_transition_length(ms).set_brightness(level / 100.0f).perform(); # The climate card at this size, with the fan and swing card (the CYD has its own copy). - id: climate_card_refresh then: - lambda: |- auto lower = [](std::string v) { for (char &c : v) { if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); } return v; }; std::string mode = lower(id(active_climate_mode)); bool off = mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable"; // What the device does (Heating), else its mode (Heat); then the room. std::string status = off ? std::string("Off") : std::string(tile_controls::climate_action_text(lower(id(active_climate_action)))); if (status.empty()) status = tile_controls::climate_mode_text(mode); char part[32]; if (std::isfinite(id(active_climate_current))) { snprintf(part, sizeof(part), " · Now %.1f°", id(active_climate_current)); status += part; } if (id(active_climate_humidity) >= 0) { snprintf(part, sizeof(part), " · %d%%", id(active_climate_humidity)); status += part; } lv_label_set_text(id(climate_current_label), status.c_str()); int target = id(active_climate_target_x10); std::string value = id(active_climate_target_known) ? tile_controls::format_value(target / 10.0f, id(active_climate_step_x10) / 10.0f, "°") : std::string("--"); lv_label_set_text(id(climate_target_label), value.c_str()); lv_obj_set_style_text_color(id(climate_target_label), theme::color(off ? theme::OFF : theme::INK), LV_PART_MAIN); auto enable = [](lv_obj_t *key, bool on) { if (on) lv_obj_remove_state(key, LV_STATE_DISABLED); else lv_obj_add_state(key, LV_STATE_DISABLED); }; enable(id(climate_minus_button), !id(active_climate_target_known) || target > id(active_climate_min_x10)); enable(id(climate_plus_button), !id(active_climate_target_known) || target < id(active_climate_max_x10)); // One key per supported mode, in Home Assistant's order. Fan and swing add "more"; with it // five mode keys fit, and when a sixth mode is in use it takes the last place. std::string supported = lower(id(active_climate_supported_modes)); auto has = [&](const char *m) { return supported.find(std::string("\"") + m + "\"") != std::string::npos || supported.find(std::string("'") + m + "'") != std::string::npos; }; struct Key { lv_obj_t *obj; const char *mode; }; const Key keys[] = {{id(cmkey_auto), "auto"}, {id(cmkey_heat_cool), "heat_cool"}, {id(cmkey_heat), "heat"}, {id(cmkey_cool), "cool"}, {id(cmkey_dry), "dry"}, {id(cmkey_fan_only), "fan_only"}}; // The Guition shows fan and swing on their own card below, so no "more" key. bool more = false; const Key *visible[6]; unsigned count = 0, room = more ? 5 : 6; for (const auto &k : keys) if (has(k.mode)) visible[count++] = &k; if (count > room) { for (unsigned i = room; i < count; ++i) if (mode == visible[i]->mode) visible[room - 1] = visible[i]; count = room; } for (const auto &k : keys) { bool show = false; for (unsigned i = 0; i < count; ++i) show = show || visible[i] == &k; if (show) lv_obj_remove_flag(k.obj, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(k.obj, LV_OBJ_FLAG_HIDDEN); bool selected = show && !off && mode == k.mode; uint32_t color = tile_controls::mode_color(k.mode); lv_obj_set_style_bg_color(k.obj, selected ? lv_color_hex(color) : theme::color(theme::CARD), LV_PART_MAIN); lv_obj_set_style_border_color(k.obj, selected ? lv_color_hex(color) : theme::color(theme::LINE), LV_PART_MAIN); lv_obj_set_style_text_color(lv_obj_get_child(k.obj, 0), theme::color(selected ? theme::ON_ACCENT : theme::SLATE), LV_PART_MAIN); } if (more) lv_obj_remove_flag(id(cmkey_more), LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(id(cmkey_more), LV_OBJ_FLAG_HIDDEN); // One mode is no choice: the power key already turns it on and off. const bool show_keys = count > 1 || more; if (show_keys) lv_obj_remove_flag(id(climate_mode_keys), LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(id(climate_mode_keys), LV_OBJ_FLAG_HIDDEN); // Fan and swing rows, in the entity's own names and order. climate_card::Row settings[2]; int rows = 0; if (!cyd::list_item(id(active_climate_fan_modes), 0).empty()) settings[rows++] = {'f', "\U000F0210", id(active_climate_fan_modes), id(active_climate_fan_mode)}; if (!cyd::list_item(id(active_climate_swing_modes), 0).empty()) settings[rows++] = {'s', "\U000F1C91", id(active_climate_swing_modes), id(active_climate_swing_mode)}; // One layout per card: the setpoint gives room to the mode keys and the settings below it, // and a card with nothing below keeps the setpoint centred. static int laid_out = -1; if (laid_out != rows * 2 + show_keys) { laid_out = rows * 2 + show_keys; struct Layout { int status, card_y, card_h, key, number_y, caption_y, modes_y, modes_h, settings_y, edge, row_h, gap; }; static const Layout layouts[6] = { // no mode keys: 0, 1, 2 rows {92, 180, 200, 116, -14, 46, 0, 0, 0, 14, 48, 10}, {92, 140, 200, 116, -14, 46, 0, 0, 364, 14, 48, 10}, {90, 130, 180, 108, -12, 42, 0, 0, 330, 14, 46, 10}, // mode keys: 0, 1, 2 rows {92, 136, 200, 116, -14, 46, 364, 76, 0, 14, 48, 10}, {90, 126, 170, 108, -12, 42, 312, 68, 394, 12, 44, 8}, {86, 118, 140, 96, -10, 36, 270, 60, 342, 12, 42, 8}, }; const Layout &l = layouts[(show_keys ? 3 : 0) + rows]; lv_obj_set_y(id(climate_current_label), l.status); lv_obj_set_y(id(climate_setpoint_card), l.card_y); lv_obj_set_height(id(climate_setpoint_card), l.card_h); for (lv_obj_t *button : {id(climate_minus_button), id(climate_plus_button)}) { lv_obj_set_size(button, l.key, l.key); lv_obj_set_style_radius(button, l.key / 2, LV_PART_MAIN); } lv_obj_set_y(id(climate_target_label), l.number_y); lv_obj_set_y(id(climate_set_label), l.caption_y); if (show_keys) { lv_obj_set_y(id(climate_mode_keys), l.modes_y); lv_obj_set_height(id(climate_mode_keys), l.modes_h); for (lv_obj_t *mode_key : {id(cmkey_auto), id(cmkey_heat_cool), id(cmkey_heat), id(cmkey_cool), id(cmkey_dry), id(cmkey_fan_only), id(cmkey_more)}) { lv_obj_set_height(mode_key, l.modes_h); lv_obj_set_style_radius(mode_key, l.modes_h / 2, LV_PART_MAIN); } } lv_obj_set_y(id(climate_settings_card), l.settings_y); climate_card::look.edge = l.edge; climate_card::look.row_h = l.row_h; climate_card::look.gap = l.gap; } climate_card::show(settings, rows); id(climate_power_refresh).execute();