Files
homeassistant_espscreen/packages/boards/cyd-2432s028.yaml
T
Max Gramser 3b15e7f7a3 Build the screens with ESPHome 2026.9 and keep its build memory in the app's own storage, give the CYD 94 KB more room, and stop a timer from counting one second too many (0.2.89)
The add-on ships ESPHome 2026.9.0: it builds an ESP32 with ESP-IDF itself
and keeps a compiler cache. firmware.py gives the CLI its own storage in
/data (ESPHOME_DATA_DIR /data/esphome, which the ESPHome Device Builder no
longer wipes on start; ESP-IDF and ccache in /data/idf, at most 1 GB;
compilers limited to the core count as PlatformIO ran them), removes the
unused PlatformIO once before the first ESP-IDF build, and leaves the new
folders out of backups.

Firmware 0.2.75: assertion_level SILENT in every board's esp32 block, the
core logs from INFO, and clock_parts() in runtime_model.h replaces sscanf.
timer_left() caps a running timer at its duration (the screen's clock runs
up to a second behind Home Assistant's). The packages keep min_version
2026.6.2, the top-level online_image and OTA passwords for new screens, so
an add-on or Device Builder that isn't updated yet keeps building every
screen; CI now builds both boards with the add-on's ESPHome and with
min_version. ESP-IDF's error names stay on, and build_flags stay under
platformio_options, because ESPHome 2026.9's LVGL reads only those.

tools/check.sh: 478 Python tests, 19/19 C++, editor; --firmware PASS with
ESPHome 2026.6.2 (CYD 87.7 %), 2026.8.1 (88.3 %) and 2026.9.0 (86.4 %,
was 91.5 %). Both bench screens OTA'd and watched for half an hour;
ESP Screens as a local add-on on the Yellow built both profiles
(CYD 1,037 s first build, 41 s unchanged). Details:
docs/TEST_RESULTS_0289.md.

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

747 lines
32 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
############################################################
# ESP Screens - CYD board (ESP32-2432S028, "Cheap Yellow Display")
# Built-in ILI9341 320x240 display and XPT2046 resistive touch.
#
# What is in here: the hardware (display, touch, backlight, LED), the sizes the
# shared tree takes from this board, the code the shared lambdas take from this
# board (hooks), and what only a CYD has: the calibration wizard for its
# resistive glass and a page swipe by LVGL's gesture. 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.
#
# Two entry files include this: home-like-2432s028.yaml (a build on this computer)
# and packages/cyd.yaml (the package a screen builds from over GitHub).
############################################################
substitutions:
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "56"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
TOUCH_MIN_PRESS_MS: "60"
# A slider let go this close to the left or right edge of the glass meant its end when that end lies in
# the band. The resistive panel reports a finger up to the bezel, so only the strip's last pixels and
# the margin beside them count (a capacitive Guition loses a fast finger 30-50 px before its edge).
SLIDER_EDGE_SNAP_PX: "20"
DEVICE_NAME: "cyd-new"
DEVICE_FRIENDLY_NAME: "My CYD"
TOUCH_AFFINE_XX: "1.000000000"
TOUCH_AFFINE_XY: "0.000000000"
TOUCH_AFFINE_XC: "0.000000000"
TOUCH_AFFINE_YX: "0.000000000"
TOUCH_AFFINE_YY: "1.000000000"
TOUCH_AFFINE_YC: "0.000000000"
# ORIENTATION
# The panel is always driven at its native 240x320 (see display:); LVGL turns
# both the picture and the touch coordinates. DISPLAY_W/H are the logical canvas
# the layout uses. Turning a screen a quarter is a setting in ESP Screens, so
# these values stay as they are.
DISPLAY_W: "320"
DISPLAY_H: "240"
LVGL_ROTATION: "90"
TOUCH_SWAP_XY: "false"
# true on this board (resistive XPT2046) — was false on the 032c board
# (capacitive GT911). This is the original upstream value for this panel.
TOUCH_MIRROR_X: "true"
TOUCH_MIRROR_Y: "false"
# Measured on this board: median top-right raw=(523,3390),
# bottom-left raw=(3487,482), at screen (299,20) and (20,219).
# Extrapolated to panel edges; verify with the isolated corner test.
TOUCH_CAL_X_MIN: "280"
TOUCH_CAL_X_MAX: "3860"
TOUCH_CAL_Y_MIN: "340"
TOUCH_CAL_Y_MAX: "3860"
GRID_COL1_X: "9"
GRID_COL2_X: "164"
GRID_ROW1_Y: "0"
GRID_ROW2_Y: "56"
GRID_ROW3_Y: "112"
GRID_ROW4_Y: "168"
GRID_ROW5_Y: "224"
SCROLL_Y: "40"
SCROLL_H: "164"
TILE_W: "147"
TILE_H: "52"
HEADER_Y: "14"
# Holding the top bar opens the settings page (0.2.44+). The whole strip counts on this board;
# a finger that travels further than the drift limit of a tap is swiping, not holding.
SETTINGS_HOLD_X: "0"
SETTINGS_HOLD_W: "${DISPLAY_W}"
SETTINGS_HOLD_DRIFT_PX: "24"
OVERLAY_SLIDER_W: "96"
OVERLAY_SLIDER_H: "160"
OVERLAY_ICON_Y: "74"
# Alert overlay (HA action show_alert), landscape 320x240: one card centred on LVGL's top layer.
ALERT_CARD_W: "292"
ALERT_CARD_H: "196"
ALERT_ICON_X: "18"
ALERT_ICON_Y: "16"
ALERT_TEXT_X: "62"
ALERT_TEXT_W: "212"
ALERT_TITLE_Y: "16"
ALERT_TITLE_H: "21" # one line of headline; a DOT label only ends in an ellipsis with a height
ALERT_SUBTITLE_Y: "44"
ALERT_SUBTITLE_H: "88"
ALERT_BUTTON_W: "100"
ALERT_BUTTON_H: "40"
ALERT_BUTTON_INSET: "14"
ALERT_TITLE_MAX: "48" # bytes; the label ends one line with an ellipsis
ALERT_BUTTON_MAX: "12" # bytes on the OK button
ALERT_SUBTITLE_MAX: "160" # bytes; wraps over the rows the card has
TILE_ICON_X: "0"
TILE_ICON_Y: "0"
TILE_TITLE_X: "48"
TILE_TITLE_Y: "4"
TILE_VALUE_X: "48"
TILE_VALUE_Y: "18"
# ----- 90° — Portrait 240×320 (USB on bottom) -----
# ORIENTATION: "90"
# DISPLAY_W: "240"
# DISPLAY_H: "320"
# LVGL_ROTATION: "180"
# TOUCH_SWAP_XY: "false"
# TOUCH_MIRROR_X: "true"
# TOUCH_MIRROR_Y: "false"
# TOUCH_CAL_X_MIN: "280"
# TOUCH_CAL_X_MAX: "3860"
# TOUCH_CAL_Y_MIN: "340"
# TOUCH_CAL_Y_MAX: "3860"
# GRID_COL1_X: "6"
# GRID_COL2_X: "123"
# GRID_ROW1_Y: "8"
# GRID_ROW2_Y: "96"
# GRID_ROW3_Y: "184"
# GRID_ROW4_Y: "272"
# GRID_ROW5_Y: "360"
# SCROLL_Y: "44"
# SCROLL_H: "276"
# TILE_W: "111"
# TILE_H: "80"
# HEADER_Y: "14"
# OVERLAY_SLIDER_W: "96"
# OVERLAY_SLIDER_H: "200"
# OVERLAY_ICON_Y: "94"
# TILE_ICON_X: "0"
# TILE_ICON_Y: "0"
# TILE_TITLE_X: "4"
# TILE_TITLE_Y: "38"
# TILE_VALUE_X: "4"
# TILE_VALUE_Y: "52"
# ----- 180° — Landscape flipped 320×240 (USB on left) -----
# ORIENTATION: "180"
# DISPLAY_W: "320"
# DISPLAY_H: "240"
# LVGL_ROTATION: "270"
# TOUCH_SWAP_XY: "false"
# TOUCH_MIRROR_X: "true"
# TOUCH_MIRROR_Y: "false"
# TOUCH_CAL_X_MIN: "280"
# TOUCH_CAL_X_MAX: "3860"
# TOUCH_CAL_Y_MIN: "340"
# TOUCH_CAL_Y_MAX: "3860"
# GRID_COL1_X: "9"
# GRID_COL2_X: "164"
# GRID_ROW1_Y: "8"
# GRID_ROW2_Y: "68"
# GRID_ROW3_Y: "128"
# GRID_ROW4_Y: "188"
# GRID_ROW5_Y: "248"
# SCROLL_Y: "44"
# SCROLL_H: "196"
# TILE_W: "147"
# TILE_H: "52"
# HEADER_Y: "14"
# OVERLAY_SLIDER_W: "96"
# OVERLAY_SLIDER_H: "160"
# OVERLAY_ICON_Y: "74"
# TILE_ICON_X: "0"
# TILE_ICON_Y: "0"
# TILE_TITLE_X: "48"
# TILE_TITLE_Y: "4"
# TILE_VALUE_X: "48"
# TILE_VALUE_Y: "18"
# ----- 270° — Portrait flipped 240×320 (USB on top) -----
# ORIENTATION: "270"
# DISPLAY_W: "240"
# DISPLAY_H: "320"
# LVGL_ROTATION: "0"
# TOUCH_SWAP_XY: "false"
# TOUCH_MIRROR_X: "true"
# TOUCH_MIRROR_Y: "false"
# TOUCH_CAL_X_MIN: "280"
# TOUCH_CAL_X_MAX: "3860"
# TOUCH_CAL_Y_MIN: "340"
# TOUCH_CAL_Y_MAX: "3860"
# GRID_COL1_X: "6"
# GRID_COL2_X: "123"
# GRID_ROW1_Y: "8"
# GRID_ROW2_Y: "96"
# GRID_ROW3_Y: "184"
# GRID_ROW4_Y: "272"
# GRID_ROW5_Y: "360"
# SCROLL_Y: "44"
# SCROLL_H: "276"
# TILE_W: "111"
# TILE_H: "80"
# HEADER_Y: "14"
# OVERLAY_SLIDER_W: "96"
# OVERLAY_SLIDER_H: "200"
# OVERLAY_ICON_Y: "94"
# TILE_ICON_X: "0"
# TILE_ICON_Y: "0"
# TILE_TITLE_X: "4"
# TILE_TITLE_Y: "38"
# TILE_VALUE_X: "4"
# TILE_VALUE_Y: "52"
# Fonts: one size per role, the same files on every board
FONT_TIME_SIZE: "18" # the clock in the top bar
FONT_HEADLINE_SIZE: "18" # card titles
FONT_WATCH_VALUE_SIZE: "22"
FONT_CLOCK_DIGITS_SIZE: "24"
FONT_SETPOINT_SIZE: "40" # the climate card's setpoint
FONT_LABEL_SIZE: "11" # tile titles
FONT_SUBLABEL_SIZE: "11" # tile values and small text
FONT_SUBLABEL_BIG_SIZE: "14" # the top bar and settings rows
FONT_ICON_SIZE: "28" # tile icons
FONT_ICON_MINI_SIZE: "18" # the top bar and card keys
FONT_ICON_BIG_SIZE: "40" # full-page tiles
FONT_WATCH_ICON_SIZE: "12"
# Tiles and the top bar
TILE_RADIUS: "18" # corner of a tile
TILE_PAD: "8" # inside a tile
TILE_ICON_SIZE: "36" # the icon circle
HEADER_INSET: "11" # the room name and the clock from the glass edge
# The page bar under the tiles
PAGE_KEY_W: "160" # the previous/next keys under the tiles
PAGE_BAR_H: "36" # the page bar
PAGE_NUMBER_W: "104" # the dots in the middle
PAGE_CHEVRON_INSET: "14" # the chevrons from the outer edge
# Cards: the round keys and the brightness card
CARD_KEY_X: "10" # a card's round key from the side
CARD_KEY_Y: "8" # from the top
CARD_KEY_SIZE: "40" # its diameter
OVERLAY_TITLE_Y: "19" # the brightness card's title
OVERLAY_VALUE_Y: "42" # its percentage
OVERLAY_SLIDER_Y: "24" # its slider below the centre
OVERLAY_SLIDER_RADIUS: "28" # the slider's corners
OVERLAY_KNOB_PAD: "10" # the knob
COLOR_KEY_X: "-13" # the colour wheel key from the bottom right corner
COLOR_KEY_Y: "-13"
COLOR_KEY_SIZE: "44" # its diameter
COLOR_ARC_SIZE: "40" # the eight arcs in it
COLOR_ARC_W: "10"
COLOR_TITLE_W: "210" # the colour card's title
COLOR_TITLE_Y: "21"
# The climate card
CLIMATE_TITLE_Y: "18" # the title
CLIMATE_TITLE_W: "210"
CLIMATE_STATUS_Y: "54" # the status line
CLIMATE_CARD_Y: "80" # the setpoint card
CLIMATE_CARD_H: "92"
CLIMATE_CARD_RADIUS: "16"
CLIMATE_KEY_INSET: "10" # the -/+ keys from the card edge
CLIMATE_KEY_SIZE: "72" # their diameter
CLIMATE_KEY_RADIUS: "36"
CLIMATE_NUMBER_Y: "-8" # the setpoint number
CLIMATE_CAPTION_Y: "26" # the word under it
CLIMATE_MODES_Y: "184" # the row of mode keys
CLIMATE_MODES_H: "46"
CLIMATE_MODES_GAP: "6"
CLIMATE_MODE_RADIUS: "23"
# The mode picker
PICKER_TITLE_Y: "21" # the "Mode" title
PICKER_LIST_Y: "44" # the list of choices
PICKER_LIST_H: "178"
PICKER_PAD: "3"
PICKER_ROW_GAP: "3"
PICKER_COL_GAP: "4"
PICKER_MODE_W: "88" # a mode chip
PICKER_MODE_H: "26"
PICKER_MODE_RADIUS: "13"
PICKER_TITLE_H: "11" # the Fan and Swing captions
PICKER_CHIP_W: "62" # a fan or swing chip
PICKER_CHIP_H: "22"
PICKER_CHIP_RADIUS: "11"
PICKER_CHIP_FONT: "sublabel" # the words on them
# The touch test page
TOUCH_TEST_PAGE_PAINT: "paint_calibration" # the page
TOUCH_TEST_TITLE: "Follow the calibration wizard"# the words at the top
TOUCH_TEST_TITLE_FONT: "sublabel"
TOUCH_TEST_TITLE_Y: "65"
TOUCH_TEST_INSET: "10" # the corner crosses from the edge
TOUCH_TEST_FAR_X: "289" # the right crosses: width - inset - size - 1
TOUCH_TEST_FAR_Y: "209" # the bottom crosses
TOUCH_TEST_MID_X: "150" # the middle cross
TOUCH_TEST_MID_Y: "110"
TOUCH_TEST_SIZE: "20" # a cross
TOUCH_TEST_PAINT: "paint_calibration" # the crosses
# What the shared scripts do differently on this board
LVGL_DEFAULT_FONT: "sublabel_big" # LVGL's default font: every label names its own, so this only keeps Montserrat out of the build
LVGL_BUFFER_SIZE: "12%" # LVGL's draw buffer as a share of the screen
PAGE_DOTS_LARGE: "false" # bigger page dots
EFFECTS_ROW_FONT: "sublabel_big" # the effects page's rows
ROTATION_SUPPORTED: "false" # the screen turns a quarter from the settings
STANDBY_DIM_MS: "300" # how long the backlight takes to dim into standby (waking takes 300)
ALERT_FLASH_FADE_MS: "0" # an alert's flash: the fade of each step
ALERT_FLASH_DARK_MS: "120" # how long it stays dark
ALERT_FLASH_LIT_MS: "170" # 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: |-
// Per-panel affine correction; shared defaults are neutral.
id(ts_touch).set_raw_correction(${TOUCH_AFFINE_XX}f, ${TOUCH_AFFINE_XY}f, ${TOUCH_AFFINE_XC}f, ${TOUCH_AFFINE_YX}f, ${TOUCH_AFFINE_YY}f, ${TOUCH_AFFINE_YC}f);
cyd::touch_guard.configure(${TOUCH_MOVE_LIMIT_PX}, ${TOUCH_MIN_PRESS_MS});
cyd::edge_snap_band = ${SLIDER_EDGE_SNAP_PX};
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: ""
# the paint style only this board has (theme::paints)
BOARD_PAINT_FILL: "theme::fill(id(paint_calibration), Paint::calibration);"
# in runtime_tiles::dismiss, after the detail card closes
CLOSE_CARDS_HOOK: ""
# what LVGL's gesture over the tiles does on this board
BOOT_PAGE_GESTURE: |-
lv_obj_add_event_cb(id(home_page)->obj, [](lv_event_t *event) {
if(!runtime_tiles::enabled || !runtime_tiles::swipe_pages || id(display_dimmed) || id(calibration_active) ||
!id(active_entity).empty() || (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root,LV_OBJ_FLAG_HIDDEN)))return;
auto *input=lv_indev_active();if(!input)return;
auto direction=lv_indev_get_gesture_dir(input);
if(direction!=LV_DIR_LEFT && direction!=LV_DIR_RIGHT)return;
// A quick drag on a slider is that slider's, never a page swipe (firmware 0.2.68+).
if(runtime_tiles::captured_slider){ESP_LOGI("touch","page swipe ignored: a slider is being dragged");return;}
cyd::touch_guard.consume();
lv_indev_wait_release(input);
ESP_LOGI("touch","page swipe: page %d -> %d",id(tile_page),id(tile_page)+(direction==LV_DIR_LEFT?1:-1));
id(tile_page)+=direction==LV_DIR_LEFT?1:-1;
id(show_tile_page).execute();
}, LV_EVENT_GESTURE, nullptr);
# after the gesture: the camera images of a board that shows them
BOOT_CAMERA_HOOKS: ""
# 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: |-
if (!runtime_tiles::enabled) return;
screen_calibration::apply = [](const screen_calibration::Calibration &cal) {
id(ts_touch).set_raw_correction(cal.c[0],cal.c[1],cal.c[2],cal.c[3],cal.c[4],cal.c[5]);
};
screen_calibration::isolation = [](bool active) { id(calibration_active) = active; };
screen_calibration::setup(id(home_page)->obj, id(sublabel)->get_lv_font(), id(headline)->get_lv_font(),
${TOUCH_CAL_X_MIN}, ${TOUCH_CAL_X_MAX}, ${TOUCH_CAL_Y_MIN}, ${TOUCH_CAL_Y_MAX});
# what else keeps the settings page closed (inline, in the condition)
MAY_OPEN_EXTRA: ""
# the log line of a boot onto the touch test page
TOUCH_TEST_READY_LOG: "USB calibration ready; no tile actions"
# one more line of the ui_state diagnostics
UI_STATE_HOOK: ""
# every 250 ms after runtime_tiles::tick()
TICK_HOOK: ""
# apply_screen_settings: turning the screen, before the standby logic
APPLY_ROTATION: ""
# apply_screen_settings: how the backlight takes its level
APPLY_BACKLIGHT: "id(back_light).turn_on().set_transition_length(300).set_brightness(level / 100.0f).perform();"
# apply_screen_settings: the Rotation entity in Home Assistant follows
APPLY_ROTATION_ENTITY: ""
# back_to_page_1_when_due: what else counts as away from page 1 (inline)
AWAY_EXTRA: ""
# alert_show: after the settings page closed, before the card is made
ALERT_SHOW_HOOK: ""
# set_climate_fan_mode and set_climate_swing_mode: after the choice is stored
CLIMATE_MODE_CHOSEN_HOOK: ""
# open_value_overlay: the climate card of a panel in portrait, before its refresh
OPEN_VALUE_OVERLAY_TAIL: |-
// A portrait panel (240 x 320) keeps the same card, a little lower and with more air.
if (atoi("${DISPLAY_W}") < atoi("${DISPLAY_H}")) {
lv_obj_set_y(id(climate_current_label), 60);
lv_obj_set_y(id(climate_setpoint_card), 96);
lv_obj_set_y(id(climate_mode_keys), 222);
for (lv_obj_t *key : {id(climate_minus_button), id(climate_plus_button)}) {
lv_obj_set_size(key, 56, 56);
lv_obj_set_style_radius(key, 28, LV_PART_MAIN);
}
lv_obj_set_x(id(climate_minus_button), 6);
lv_obj_set_x(id(climate_plus_button), -6);
}
# ============================================================
# No need to edit below this line.
# ============================================================
esp32:
board: esp32dev
framework:
type: esp-idf
advanced:
# ESP-IDF's assert texts (file, line and condition of every check) stay out of the firmware (0.2.75+), 49 KB on
# the CYD: a failed assert still stops and restarts the screen, and its backtrace still points at the line.
# Every board sets it (tests/test_easy_package.py). ESP-IDF's error names stay: without them ESPHome's own
# error lines only say "UNKNOWN ERROR".
assertion_level: SILENT
# ESPHome recommends this itself in the boot log. Yields ~40KB extra IRAM
# and a smaller binary, and every KB counts on this board.
sram1_as_iram: true
# minimum_chip_revision deliberately left out: that was here for the
# 032c board (rev 3.1 confirmed), but this is a different physical board
# with an unknown chip revision. With an unjustifiably high minimum
# the firmware simply refuses to boot.
version: recommended
variant: esp32
# Two SPI buses used by the CYD:
# - lcd: built-in display
# - my_touchscreen: built-in touch controller
# Two SPI buses on the 2432S028: one for the display, a separate one for the
# resistive XPT2046 touch (no I2C on this board, unlike the 032c).
spi:
- id: lcd
clk_pin: GPIO14
mosi_pin: GPIO13
- id: my_touchscreen
clk_pin: GPIO25
mosi_pin: GPIO32
miso_pin: GPIO39
output:
- platform: ledc
pin: GPIO21
id: gpio_backlight_pwm
# ESPHome's ledc default is 1kHz. You don't see that while still (well
# above what the eye perceives as flicker), but moving the screen reveals
# motion blur turning the on/off pulses into streaks (the same effect as
# a PWM LED that "judders" when you move it). 20kHz sits well above the
# speed at which motion still makes that visible, and still leaves ample
# duty-cycle resolution left over for smooth dimming.
frequency: 20000Hz
- platform: ledc
id: output_red
pin: GPIO4
inverted: true
- platform: ledc
id: output_green
pin: GPIO16
inverted: true
- platform: ledc
id: output_blue
pin: GPIO17
inverted: true
light:
# The firmware owns the backlight: brightness, standby, night and alerts set it, and the minute tick
# restores it. Home Assistant uses the brightness numbers and the Wake and Sleep buttons (0.2.46+).
- platform: monochromatic
output: gpio_backlight_pwm
name: "Power Display Backlight"
id: back_light
internal: true
restore_mode: ALWAYS_ON
- platform: rgb
name: LED
red: output_red
id: led
green: output_green
blue: output_blue
restore_mode: ALWAYS_OFF
# Touchscreen configuration (XPT2046, resistive, via SPI)
# XPT2046 calibration is board-specific. Poll at 50Hz; LVGL rotates touch
# with the display. GPIO36 IRQ is deliberately unused (driver requests a
# pull-up that this input-only pin cannot provide).
touchscreen:
platform: xpt2046
id: ts_touch
spi_id: my_touchscreen
cs_pin: 33
# Polling avoids GPIO36 pull-up errors and noisy PENIRQ edges.
update_interval: 20ms
# Diagnosis caught a false corner contact at pressure 422. Deliberate
# contacts measured 936-2643; reject the low-pressure tail on release.
threshold: 800
calibration:
x_min: ${TOUCH_CAL_X_MIN}
x_max: ${TOUCH_CAL_X_MAX}
y_min: ${TOUCH_CAL_Y_MIN}
y_max: ${TOUCH_CAL_Y_MAX}
transform:
swap_xy: ${TOUCH_SWAP_XY}
mirror_x: ${TOUCH_MIRROR_X}
mirror_y: ${TOUCH_MIRROR_Y}
on_touch:
- lambda: |-
id(last_touch_ms) = millis();
id(last_use_ms) = millis();
id(touch_down) = true;
cyd::touch_guard.begin(millis(), touch.x, touch.y, touch.id);
screen_calibration::press();
ESP_LOGI("touch", "press native=%u,%u raw=%d,%d pressure=%d calibration=%d", touch.x, touch.y, id(ts_touch).filtered_raw_x(), id(ts_touch).filtered_raw_y(), touch.z_raw, id(calibration_active));
on_update:
- lambda: |-
id(last_touch_ms) = millis();
id(last_use_ms) = millis();
// Only the contact that started the touch counts; the guard ignores other ids.
for (const auto &point : touches) {
cyd::touch_guard.update(point.x, point.y, point.id);
}
on_release:
- lambda: |-
id(last_touch_ms) = millis();
id(last_use_ms) = millis();
id(touch_down) = false;
screen_calibration::release(id(ts_touch).filtered_raw_x(), id(ts_touch).filtered_raw_y());
# Display configuration (ILI9341)
display:
- platform: mipi_spi
id: my_display
spi_id: lcd
# Standard single-Micro-USB CYD; 40MHz matches ESPHome's board preset.
model: ILI9341
data_rate: 40MHz
cs_pin: 15
dc_pin: 2
invert_colors: false
update_interval: never
auto_clear_enabled: false
# Panel tuning, appended after ESPHome's built-in ILI9341 sequence. With the
# defaults (line inversion, 79 Hz) the cheap CYD panel shows a fine vertical
# stripe pattern that flickers when you move your eyes or the board: the
# two polarities of adjacent columns differ in brightness. Frame inversion
# (0xB4 = 0x07) removes the stripes; the maximum frame rate (0xB1 = 0x10,
# 119 Hz) keeps the resulting whole-frame flicker above what the eye sees.
# VCOM (0xC5) stayed at the default; sweeping it changed contrast, not the
# stripes. Checked on the bench CYD on 2026-09-15.
init_sequence:
- [0xB4, 0x07]
- [0xB1, 0x00, 0x10]
# Native panel dimensions (portrait). Do NOT swap these per orientation and
# do NOT add a display transform — rotation is handled by LVGL below.
# ESPHome 2026.6+ rejects a swapped transform here with "Invalid offsets".
dimensions:
width: 240
height: 320
lvgl:
style_definitions:
# The calibration wizard's page and crosses (theme::paints fills it: BOARD_PAINT_FILL).
- id: paint_calibration
button:
- platform: template
name: "Calibrate touch"
internal: ${DASHBOARD_INBOX_INTERNAL}
entity_category: config
on_press:
- lambda: 'if (runtime_tiles::enabled) screen_calibration::begin();'
script:
# Render every overlay and both pages repeatedly without controlling devices.
- id: ui_self_test
mode: single
then:
- logger.log:
format: "UI_TEST START: page/overlay render stress, no HA actions"
level: INFO # the core logs from INFO; logger.log is DEBUG by default
- 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 = true;
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
- lvgl.widget.hide: brightness_overlay
- lvgl.widget.show: color_detail_overlay
- delay: 150ms
- lvgl.widget.hide: color_detail_overlay
- lvgl.widget.show: climate_detail_overlay
- delay: 150ms
- lvgl.widget.hide: climate_detail_overlay
- lvgl.widget.show: climate_mode_overlay
- delay: 150ms
- lvgl.widget.hide: climate_mode_overlay
- lvgl.widget.show: alert_overlay
- delay: 150ms
- lvgl.widget.hide: alert_overlay
- lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();'
- logger.log:
format: "UI_TEST COMPLETE"
level: INFO
# The two scripts that drive the backlight as a light action; the Guition drives it through its set_backlight script.
- id: alert_dismiss
parameters:
reason: string
then:
- if:
condition:
lambda: 'return id(alert_active);'
then:
- lambda: |-
id(alert_active) = false;
id(last_touch_ms) = millis();
ESP_LOGI("alert", "dismissed: %s", reason.c_str());
- lvgl.widget.hide: alert_overlay
- script.stop: alert_timeout
- script.stop: alert_flash
- light.turn_on:
id: back_light
transition_length: 80ms
brightness: !lambda "return screen_settings::current.brightness / 100.0f;"
- script.execute:
id: alert_report
action: !lambda 'return reason;'
# Four backlight blinks when the alert arrives; the screen then stays on as usual.
- id: alert_flash
mode: restart
then:
- repeat:
count: 4
then:
- light.turn_off:
id: back_light
transition_length: 0s
- delay: 120ms
- light.turn_on:
id: back_light
transition_length: 0s
brightness: !lambda "return screen_settings::current.brightness / 100.0f;"
- delay: 170ms
- id: wake_display
mode: restart
then:
- lambda: |-
id(last_touch_ms) = millis();
id(display_dimmed) = false;
id(sleep_requested) = false;
- light.turn_on:
id: back_light
# Almost instant brightness-up. ESPHome's default is 1s, and that feels sluggish
# when you touch the screen and are waiting on light.
transition_length: 80ms
brightness: !lambda "return screen_settings::current.brightness / 100.0f;"
- lvgl.widget.hide: dim_wake_overlay
- script.execute: close_cards
# The climate card as the small panel lays it out: the Guition has its own copy with a fan and swing card.
- id: climate_card_refresh
then:
- lambda: |-
auto lower = [](std::string v) {
for (char &c : v) { if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); }
return v;
};
std::string mode = lower(id(active_climate_mode));
bool off = mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable";
// What the device does (Heating), else its mode (Heat); then the room.
std::string status = off ? std::string("Off") : std::string(tile_controls::climate_action_text(lower(id(active_climate_action))));
if (status.empty()) status = tile_controls::climate_mode_text(mode);
char part[32];
if (std::isfinite(id(active_climate_current))) { snprintf(part, sizeof(part), " · Now %.1f°", id(active_climate_current)); status += part; }
if (id(active_climate_humidity) >= 0) { snprintf(part, sizeof(part), " · %d%%", id(active_climate_humidity)); status += part; }
lv_label_set_text(id(climate_current_label), status.c_str());
int target = id(active_climate_target_x10);
std::string value = id(active_climate_target_known)
? tile_controls::format_value(target / 10.0f, id(active_climate_step_x10) / 10.0f, "°") : std::string("--");
lv_label_set_text(id(climate_target_label), value.c_str());
lv_obj_set_style_text_color(id(climate_target_label), theme::color(off ? theme::OFF : theme::INK), LV_PART_MAIN);
auto enable = [](lv_obj_t *key, bool on) {
if (on) lv_obj_remove_state(key, LV_STATE_DISABLED); else lv_obj_add_state(key, LV_STATE_DISABLED);
};
enable(id(climate_minus_button), !id(active_climate_target_known) || target > id(active_climate_min_x10));
enable(id(climate_plus_button), !id(active_climate_target_known) || target < id(active_climate_max_x10));
// One key per supported mode, in Home Assistant's order. Fan and swing add "more"; with it
// five mode keys fit, and when a sixth mode is in use it takes the last place.
std::string supported = lower(id(active_climate_supported_modes));
auto has = [&](const char *m) {
return supported.find(std::string("\"") + m + "\"") != std::string::npos ||
supported.find(std::string("'") + m + "'") != std::string::npos;
};
struct Key { lv_obj_t *obj; const char *mode; };
const Key keys[] = {{id(cmkey_auto), "auto"}, {id(cmkey_heat_cool), "heat_cool"}, {id(cmkey_heat), "heat"},
{id(cmkey_cool), "cool"}, {id(cmkey_dry), "dry"}, {id(cmkey_fan_only), "fan_only"}};
bool more = !id(active_climate_fan_modes).empty() || !id(active_climate_swing_modes).empty();
const Key *visible[6];
unsigned count = 0, room = more ? 5 : 6;
for (const auto &k : keys) if (has(k.mode)) visible[count++] = &k;
if (count > room) {
for (unsigned i = room; i < count; ++i) if (mode == visible[i]->mode) visible[room - 1] = visible[i];
count = room;
}
for (const auto &k : keys) {
bool show = false;
for (unsigned i = 0; i < count; ++i) show = show || visible[i] == &k;
if (show) lv_obj_remove_flag(k.obj, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(k.obj, LV_OBJ_FLAG_HIDDEN);
bool selected = show && !off && mode == k.mode;
uint32_t color = tile_controls::mode_color(k.mode);
lv_obj_set_style_bg_color(k.obj, selected ? lv_color_hex(color) : theme::color(theme::CARD), LV_PART_MAIN);
lv_obj_set_style_border_color(k.obj, selected ? lv_color_hex(color) : theme::color(theme::LINE), LV_PART_MAIN);
lv_obj_set_style_text_color(lv_obj_get_child(k.obj, 0), theme::color(selected ? theme::ON_ACCENT : theme::SLATE), LV_PART_MAIN);
}
if (more) lv_obj_remove_flag(id(cmkey_more), LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(id(cmkey_more), LV_OBJ_FLAG_HIDDEN);
// One mode is no choice: the power key already turns it on and off. Without keys the
// setpoint card moves to the middle of the room below the status line.
const bool show_keys = count > 1 || more;
if (show_keys) lv_obj_remove_flag(id(climate_mode_keys), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(climate_mode_keys), LV_OBJ_FLAG_HIDDEN);
const bool landscape = atoi("${DISPLAY_W}") >= atoi("${DISPLAY_H}");
lv_obj_set_y(id(climate_setpoint_card), show_keys ? (landscape ? 80 : 96) : (landscape ? 108 : 140));
id(climate_power_refresh).execute();