Files
homeassistant_espscreen/packages/boards/guition-4848s040.yaml
T
Max Gramser d76461d774 Build every screen from one shared core plus a small file per board, the same firmware as before (0.2.84)
The two board profiles (5,000 lines each, 87 % the same text) become packages/core.yaml and one file per
board under packages/boards/: the hardware, a table of 84 sizes and font sizes, 18 hooks with the lines of C++
only that board adds inside a shared lambda, and the board's own sections. packages/cyd.yaml and
packages/guition.yaml keep their names and places (a screen installed from ESP Screens builds as before);
home-like-2432s028.yaml and guition-4848s040.yaml become the same entries for a checkout.

The firmware stays 0.2.70: the C++ ESPHome generates for each board, through the checkout profile and
through the GitHub package route, was compared line by line with the old profiles' (docs/TEST_RESULTS_0284.md).
tools/check_packages.py replaces tools/generate_packages.py; tools/profiles.py gives the tests and tools the
three files as one; docs/PROFILES.md says how the files fit together and how a board is added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 22:57:14 +02:00

1015 lines
46 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,
# 4 KB per loop, where a JPEG of the full screen held the loop for 0.6 s; touch and drawing keep their turn. One image full
# screen and one in an alert's frame; runtime_tiles.h decides when they load and frees them when they close. Plain HTTP
# on the LAN, no certificates.
http_request:
timeout: 4s
verify_ssl: false
# The top-level online_image form that ESPHome 2026.6.2 (min_version) knows; 2026.7+ still reads it and suggests
# `image: - platform: online_image`, which this profile can use once min_version reaches 2026.7.
online_image:
- id: camera_image
url: "http://127.0.0.1/camera.bmp" # set when a camera opens
format: BMP
type: RGB565
buffer_size: 4096
on_download_finished:
- lambda: 'runtime_tiles::camera_loaded(false, cached);'
on_error:
- lambda: 'runtime_tiles::camera_failed(false);'
- id: alert_image
url: "http://127.0.0.1/camera.bmp" # set when an alert brings an image
format: BMP
type: RGB565
buffer_size: 4096
on_download_finished:
- lambda: 'runtime_tiles::camera_loaded(true, cached);'
on_error:
- lambda: 'runtime_tiles::camera_failed(true);'
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.
- obj:
id: alert_image_frame
x: ${ALERT_IMAGE_INSET}
y: ${ALERT_IMAGE_INSET}
width: ${CAMERA_THUMB_W}
height: ${CAMERA_THUMB_H}
hidden: true
styles: paint_camera
bg_opa: COVER
radius: 0
border_width: 0
shadow_width: 0
pad_all: 0
clickable: true
scrollable: false
scrollbar_mode: 'OFF'
pressed:
bg_opa: COVER
on_click:
- if:
condition:
lambda: 'return cyd::touch_guard.accept(millis(), 14) && !runtime_tiles::alert_camera.empty();'
then:
- lambda: 'runtime_tiles::camera_open(runtime_tiles::alert_camera, id(alert_title_text));'
widgets:
- label:
id: alert_image_icon
align: CENTER
text: "\U000F07AE"
text_font: materialdesign_icons
clickable: false
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();