Files
homeassistant_espscreen/packages/boards/guition-4848s040.yaml
T
Max Gramser fcdea12c44 Keep every screen's Wi-Fi awake from the shared core, so Home Assistant's messages no longer wait for the router's next beacon (0.2.88)
packages/core.yaml sets wifi: power_save_mode: none. ESPHome merges it
under the screen's own wifi: block, which keeps the network, the password
and the hotspot, and the screen's own YAML still wins on the mode. The
YAML ESP Screens writes has had the line since app 0.2.24; screens with
an older or hand-written YAML ran ESPHome's ESP32 default light, and
their messages from Home Assistant waited 76-117 ms on the bench.

esphome config on the bench screens' Device Builder YAMLs: NONE for both
without a line of their own, LIGHT when the YAML says light. The bench
Guition, flashed from its Device Builder YAML: ping 15 ms average instead
of 117 ms. tools/check.sh: 474 Python tests (a new one keeps the core's
wifi: block to this one key), 19/19 C++, editor; firmware unchanged in
size (CYD 1,678,816 B, 91.5 %; Guition 2,055,280 B), since the checkout
profiles already had the line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 09:06:53 +02:00

1018 lines
47 KiB
YAML

# 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<unsigned>(id(screen_lvgl).get_rotation())!=runtime_tiles::rotation){
cyd::touch_guard.consume();
if(lv_indev_get_act())lv_indev_wait_release(lv_indev_get_act());
id(screen_lvgl).set_rotation(static_cast<esphome::display::DisplayRotation>(runtime_tiles::rotation));
}
# 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:
- <esphome/components/smart_display/guition_diagnostics.h>
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<int32_t>(settings_screen::rotation, 0, 270) / 90]);
set_action:
- lambda: |-
const std::string option(x.c_str(), x.size());
settings_screen::set("rotation", atoi(option.c_str()));
# Pinout from ESPHome's GUITION-4848S040 preset. No relay outputs are configured.
spi:
id: lcd
clk_pin: GPIO48
mosi_pin: GPIO47
i2c:
id: touch_bus
sda: GPIO19
scl:
number: GPIO45
ignore_strapping_warning: true
scan: true
output:
- platform: ledc
pin: GPIO38
id: gpio_backlight_pwm
frequency: 20000Hz
light:
# The firmware owns the backlight: brightness, standby, night and alerts set it, and the minute tick
# restores it. Home Assistant uses the brightness numbers and the Wake and Sleep buttons (0.2.46+).
- platform: monochromatic
output: gpio_backlight_pwm
name: "Display Backlight"
id: back_light
internal: true
restore_mode: ALWAYS_ON
default_transition_length: 150ms
touchscreen:
platform: gt911
id: ts_touch
i2c_id: touch_bus
display: my_display
update_interval: 20ms
transform:
swap_xy: ${TOUCH_SWAP_XY}
mirror_x: ${TOUCH_MIRROR_X}
mirror_y: ${TOUCH_MIRROR_Y}
on_touch:
- lambda: |-
id(last_touch_ms) = millis();
id(last_use_ms) = millis();
id(touch_down) = true;
cyd::touch_guard.begin(millis(), touch.x, touch.y, touch.id);
cyd::edge_swipe.begin(touch.x, touch.y, static_cast<int>(id(screen_lvgl).get_rotation()));
ESP_LOGI("touch", "GT911 press x=%u y=%u id=%u test=%d", touch.x, touch.y, (unsigned) touch.id, id(calibration_active));
on_update:
- lambda: |-
id(last_touch_ms) = millis();
id(last_use_ms) = millis();
// Only the contact that started the touch counts; the guard ignores other ids.
for (const auto &point : touches) {
// Trace every sample of an edge touch: the log then shows how often the panel delivers.
if (cyd::edge_swipe.armed())
ESP_LOGI("touch", "swipe id=%u st=%u x=%u y=%u", (unsigned) point.id, (unsigned) point.state, point.x, point.y);
// The stray (0, 0) contact (cyd::GhostTouch) is not where the finger went.
if (point.x == 0 && point.y == 0) continue;
cyd::touch_guard.update(point.x, point.y, point.id);
if (point.id != cyd::touch_guard.contact()) continue;
// Swiping in from a side edge flips the page ("Swiping between pages"); the tap
// under the finger is consumed and LVGL waits for the release. LVGL 9.5 sends no
// PRESSING to the input device, hence the touchscreen trigger.
const int step = cyd::edge_swipe.update(point.x, point.y);
if (!step) continue;
const char *blocked = !runtime_tiles::enabled ? "no runtime tiles" : !runtime_tiles::swipe_pages ? "setting off"
: id(display_dimmed) ? "screen dimmed" : id(calibration_active) ? "calibration" : !id(active_entity).empty() ? "card open"
: (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)) ? "detail card open"
: runtime_tiles::camera_visible() ? "camera open" : nullptr;
if (blocked) { ESP_LOGI("touch", "edge swipe ignored: %s", blocked); continue; }
cyd::touch_guard.consume();
for (auto *indev = lv_indev_get_next(nullptr); indev; indev = lv_indev_get_next(indev)) lv_indev_wait_release(indev);
ESP_LOGI("touch", "edge swipe: page %d -> %d", id(tile_page), id(tile_page) + step);
id(tile_page) += step;
id(show_tile_page).execute();
}
on_release:
- lambda: |-
id(last_touch_ms) = millis();
id(last_use_ms) = millis();
id(touch_down) = false;
if (cyd::edge_swipe.armed() && cyd::edge_swipe.inward() > 0)
ESP_LOGI("touch", "edge swipe not fired: %d px inward, %d px vertical", cyd::edge_swipe.inward(), cyd::edge_swipe.sideways());
cyd::edge_swipe.end();
# Native ESPHome driver with the working manufacturer's LVGL demo timings.
# See docs/GUITION_FACTORY_REFERENCE.md; no custom RGB component is required.
display:
- platform: st7701s
id: my_display
dimensions: {width: 480, height: 480}
spi_id: lcd
spi_mode: MODE0
data_rate: 2MHz
color_order: RGB
invert_colors: false
cs_pin: 39
de_pin: 18
hsync_pin: 16
vsync_pin: 17
pclk_pin: 21
pclk_frequency: 16MHz
pclk_inverted: false
hsync_front_porch: 10
hsync_pulse_width: 8
hsync_back_porch: 50
vsync_front_porch: 10
vsync_pulse_width: 8
vsync_back_porch: 20
transform:
mirror_x: false
mirror_y: false
init_sequence:
- 1
# Manufacturer's 16-bit bus configuration and normal scan direction.
- [0xFF, 0x77, 0x01, 0x00, 0x00, 0x10]
- [0xCD, 0x00]
- [0xC7, 0x00]
# Return to the standard register bank before pixel format / sleep-out.
- [0xFF, 0x77, 0x01, 0x00, 0x00, 0x00]
- [0x3A, 0x60]
- [0x36, 0x00]
- [0x20]
- [0x11]
- delay 120ms
data_pins:
red: [11, 12, 13, 14, 0]
green: [8, 20, 3, 46, 9, 10]
blue: [4, 5, 6, 7, 15]
update_interval: never
auto_clear_enabled: false
lvgl:
id: screen_lvgl
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<runtime_tiles::model.count : i < count && (paginated ? i >= 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();