Files
homeassistant_espscreen/packages/boards/elecrow-crowpanel7.yaml
T
Xandrossian da28d19961
CI / checks (push) Waiting to run
CI / firmware (add-on) (push) Blocked by required conditions
CI / firmware (min_version) (push) Blocked by required conditions
Bring the Elecrow's GT911 out of reset through the V3.0's PCA9557 (0.2.95, firmware 0.2.81)
A unit's touch never answered: "touchscreen is marked FAILED: Calibration error" at setup, the GT911 still in reset
behind the PCA9557 (I2C 0x18) of the V3.0 board. The board file declares the expander as ESPHome's pca9554 (the same
register map) and hands the GT911 driver its reset (IO0) and interrupt (IO1) pins on it, so the driver runs Elecrow's
own sequence: reset low with the interrupt line low, reset high, 56 ms, address 0x5D. A V2.0 without the expander boots
its GT911 by the pull-up as before. The bus is scanned at boot for the log.
2026-09-21 12:54:18 -07:00

1083 lines
52 KiB
YAML

# ESP Screens - Elecrow CrowPanel 7.0" board (DIS08070H): 7-inch 800x480 IPS + GT911 capacitive touch, ESP32-S3-WROOM-1-N4R8.
#
# What is in here: the hardware (the RGB panel, touch, backlight, PSRAM), the sizes the shared tree takes from
# this board, the code the shared lambdas take from this board (hooks), and what a big panel has: camera
# images, 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/ELECROW.md what this board
# needs. The board is the Guition's kind (a big RGB panel with capacitive touch) in a wider, landscape form:
# the vertical sizes are the Guition's, the tiles and cards take the extra width. Two things differ in kind:
# the ESP32-S3-WROOM-1-N4R8 has 4 MB of flash (the CYD's budget, not the Guition's 16 MB), and the panel is not
# square, so it does not turn a quarter (no Rotation entity).
#
# Two entry files include this: elecrow-crowpanel7.yaml (a build on this computer) and packages/elecrow.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: "elecrow-new"
DEVICE_FRIENDLY_NAME: "My CrowPanel"
DISPLAY_W: "800"
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: "406"
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: "378"
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: "736"
SETTINGS_HOLD_DRIFT_PX: "32"
# Alert overlay (HA action show_alert), 800x480: one card centred on LVGL's top layer, the Guition's rows in a wider card.
ALERT_CARD_W: "560"
ALERT_CARD_H: "320"
ALERT_ICON_X: "26"
ALERT_ICON_Y: "22"
ALERT_TEXT_X: "90"
ALERT_TEXT_W: "444"
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: "800"
CAMERA_FULL_H: "480"
CAMERA_THUMB_W: "532"
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: "400" # the previous/next keys under the tiles, each half of the band
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: "520" # the colour card's title
COLOR_TITLE_Y: "35"
# The climate card
CLIMATE_TITLE_Y: "30" # the title
CLIMATE_TITLE_W: "520"
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: "calibration_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: "763" # the right crosses: width - inset - size - 1
TOUCH_TEST_FAR_Y: "443" # the bottom crosses
TOUCH_TEST_MID_X: "388" # 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: "15%" # LVGL's draw buffer as a share of the screen: the Guition's 25 % of 480x480 in pixels
PAGE_DOTS_LARGE: "true" # bigger page dots
EFFECTS_ROW_FONT: "sublabel" # the effects page's rows
ROTATION_SUPPORTED: "false" # a landscape panel: the layout is 800 wide and does not turn
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: |-
// The USB pins carry the touch I2C (elecrow_usb_phy.h): the Wi-Fi PHY started before this and may have taken them.
if (elecrow::usb_pads_taken()) ESP_LOGW("touch", "USB PHY held GPIO19/20 at boot; released");
elecrow::release_usb_pads();
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: ""
# 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(); };
// The live pictures of a page's camera tiles (firmware 0.2.77+): one strip in the third online_image.
runtime_tiles::camera_live.load = [](const std::string &url) {
static std::string current;
if (url != current) { id(tile_image).set_url(url); current = url; }
id(tile_image).update();
};
runtime_tiles::camera_live.release = []() { id(tile_image).release(); };
runtime_tiles::camera_live.source = []() { return id(tile_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: ""
# every 250 ms after runtime_tiles::tick()
TICK_HOOK: "runtime_tiles::camera_tick();"
# apply_screen_settings: turning the screen, before the standby logic (this panel does not turn)
APPLY_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 (none on this board)
APPLY_ROTATION_ENTITY: ""
# 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: # not `build_flags:` yet, see packages/core.yaml
build_flags:
- -DLV_USE_SNAPSHOT=1
# GPIO19 and GPIO20, the touch I2C, are the ESP32-S3's USB pins: the ROM leaves the USB PHY on them and the I2C
# driver cannot take them back ("I2C software timeout", no touch). The flag turns on elecrow_usb_phy.h, which
# lets the pins go before ESPHome starts (docs/ELECROW.md; esphome/esphome#15356). Only this board sets it.
- -DESP_SCREENS_FREE_USB_PADS=1
includes:
- <esphome/components/smart_display/elecrow_usb_phy.h>
- <esphome/components/smart_display/guition_diagnostics.h>
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
# The ESP32-S3-WROOM-1-N4R8: 4 MB of flash (16 MB boot-loops with no log) and 8 MB of octal PSRAM. Two update slots
# of 1,835,008 bytes, like the CYD: tools/check.sh --firmware measures this board against the same budget.
flash_size: 4MB
cpu_frequency: 240MHz
framework:
type: esp-idf
version: recommended
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
execute_from_psram: true
sdkconfig_options:
# The camera images' HTTP client links mbedTLS; nothing on the screen serves TLS (7 KB of flash).
CONFIG_MBEDTLS_TLS_CLIENT_ONLY: y
# The Wi-Fi PHY library keeps the USB PHY on by default on an ESP32-S3, which takes GPIO19/20, the touch I2C,
# back the moment Wi-Fi starts (elecrow_usb_phy.h). Off: it leaves the USB PHY disabled, as this board needs.
CONFIG_ESP_PHY_ENABLE_USB: n
psram:
mode: octal
speed: 80MHz
# Flash (docs/ELECROW.md). The firmware is the Guition's with camera images, which ESPHome's own partition table for
# 4 MB (two slots of 1,792 KB, 448 KB of NVS) does not hold: with the Guition's fonts it is 2,028 KB. Three things
# make it fit with room for updates, measured with ESPHome 2026.9.0 (tools/check.sh --firmware applies the CYD's
# budget to this board too):
# - two update slots of 1,984 KB and 64 KB of NVS (smart_display: partitions, components/smart_display/__init__.py);
# - the icon fonts at 2 bits a pixel (font: below), 156 KB less;
# - the TLS client only (sdkconfig_options above), 7 KB less.
# That is 1,865 KB, 91.8 % of a slot. Without camera images (the online_image, http_request and preview_camera
# sections, the alert frame and the seed image below, and the camera hooks BOOT_CAMERA_HOOKS, BOARD_PAINT_FILL,
# CLOSE_CARDS_HOOK, MAY_OPEN_EXTRA, TICK_HOOK, AWAY_EXTRA and ALERT_SHOW_HOOK emptied) it is 1,754 KB, 86.4 %.
smart_display:
partitions: 4mb_wide_slots
# The icon fonts at 2 bits a pixel (four levels of edge smoothing instead of sixteen) where the text keeps its four:
# the three sizes of the 314 tile icons are 312 KB at 4 bits, half that at 2. The text fonts are the sharper for it.
font:
- id: !extend materialdesign_icons
bpp: 2
- id: !extend materialdesign_icons_mini
bpp: 2
- id: !extend materialdesign_icons_big
bpp: 2
- id: !extend watch_icon
bpp: 2
logger:
tx_buffer_size: 2048
# The USB-C port is a CH340 on UART0; the S3's own USB pins carry the touch I2C (elecrow_usb_phy.h).
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, and one strip with the live pictures of a page's camera tiles (firmware 0.2.77+); 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`, and 2027.1 drops it: move with the min_version raise (docs/RELEASING.md, 0.2.89).
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);'
# The live pictures of a page's camera tiles (firmware 0.2.77+): one strip of small squares, at most ~70 KB.
- id: tile_image
url: "http://127.0.0.1/camera.bmp" # set when a page with live camera tiles is on screen
format: BMP
type: RGB565
buffer_size: 16384
on_download_finished:
- lambda: 'runtime_tiles::live_loaded(cached);'
on_error:
- lambda: 'runtime_tiles::live_failed();'
# The touch bus's watchdog (elecrow_usb_phy.h): whatever hands GPIO19/20 back to the USB PHY, the pads are released
# again within a second, with one log line per event.
interval:
- interval: 1s
then:
- lambda: |-
if (elecrow::usb_pads_taken()) {
elecrow::release_usb_pads();
ESP_LOGW("touch", "USB PHY took GPIO19/20 back; released again");
}
text_sensor:
# The sensor every large board has; ESP Screens 0.2.93+ reads the board from its value ("elecrow": a big panel that
# draws images and does not turn), so it publishes like the core's diagnostics. An older app only looks for the sensor
# and takes any screen that has it for a Guition, the nearest thing.
- platform: template
name: "Guition screen type"
entity_category: diagnostic
update_interval: 60s
lambda: 'return {"elecrow"};'
# Pinout of the CrowPanel 7.0" (DIS08070H, V2.0 and V3.0): Elecrow's wiki and the ESPHome devices page. The V3.0 has a
# PCA9557 I/O expander at I2C 0x18 on the GT911's reset (IO0) and interrupt (IO1) lines, and the GT911 stays in reset
# until something drives them (firmware 0.2.81: a unit whose touch never answered, "Calibration error" at setup). The
# expander gets the same register map as the PCA9554 ESPHome knows, and the GT911 driver does Elecrow's own reset
# through it: reset low with the interrupt line low (that picks address 0x5D), reset high, 56 ms. On a V2.0 without
# the expander its setup fails, the GT911 boots by its pull-up as before, and the driver still finds it (it tries 0x14
# by itself when 0x5D does not answer).
i2c:
id: touch_bus
sda: GPIO19
scl: GPIO20
frequency: 400kHz
# The scan logs what answers on the bus (0x18 the expander, 0x5D or 0x14 the GT911), with `logger: level: DEBUG` in
# a screen's Override YAML: which revision a screen is, and whether the bus is alive at all. It runs before the
# GT911's reset, so it upsets nothing.
scan: true
pca9554:
- id: touch_expander
i2c_id: touch_bus
address: 0x18
pin_count: 8
output:
- platform: ledc
pin: GPIO2
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
# The reset through the expander picks 0x5D; the driver tries 0x14 by itself when that does not answer.
address: 0x5D
reset_pin:
pca9554: touch_expander
number: 0
interrupt_pin:
pca9554: touch_expander
number: 1
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.
# ESPHome's own RGB driver (mipi_rgb): the panel takes the pixels straight from the ESP32-S3's LCD peripheral, with no
# controller to initialise (the RPI model), so the timings are the whole configuration. They are the ones the
# ESPHome devices page measured for this panel; pclk_inverted is required, or the picture tears.
display:
- platform: mipi_rgb
model: RPI
id: my_display
dimensions: {width: 800, height: 480}
color_order: RGB
de_pin: 41
hsync_pin: 39
vsync_pin: 40
pclk_pin: 0
pclk_frequency: 15MHz
pclk_inverted: true
hsync_front_porch: 40
hsync_pulse_width: 48
hsync_back_porch: 13
vsync_front_porch: 1
vsync_pulse_width: 31
vsync_back_porch: 13
data_pins:
red: [14, 21, 47, 48, 45]
green: [9, 46, 3, 8, 16, 1]
blue: [15, 7, 6, 5, 4]
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 width of a 378 px tile (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: 290
long_mode: DOT
- label:
id: !extend t1_value
height: 22
width: 290
long_mode: DOT
- obj:
id: !extend tile2
widgets:
- label:
id: !extend t2_title
height: 24
width: 290
long_mode: DOT
- label:
id: !extend t2_value
height: 22
width: 290
long_mode: DOT
- obj:
id: !extend tile3
widgets:
- label:
id: !extend t3_title
height: 24
width: 290
long_mode: DOT
- label:
id: !extend t3_value
height: 22
width: 290
long_mode: DOT
- obj:
id: !extend tile4
widgets:
- label:
id: !extend t4_title
height: 24
width: 290
long_mode: DOT
- label:
id: !extend t4_value
height: 22
width: 290
long_mode: DOT
- obj:
id: !extend tile5
widgets:
- label:
id: !extend t5_title
height: 24
width: 290
long_mode: DOT
- label:
id: !extend t5_value
height: 22
width: 290
long_mode: DOT
- obj:
id: !extend tile6
widgets:
- label:
id: !extend t6_title
height: 24
width: 290
long_mode: DOT
- label:
id: !extend t6_value
height: 22
width: 290
long_mode: DOT
- obj:
id: !extend tile7
widgets:
- label:
id: !extend t7_title
height: 24
width: 290
long_mode: DOT
- label:
id: !extend t7_value
height: 22
width: 290
long_mode: DOT
- obj:
id: !extend tile8
widgets:
- label:
id: !extend t8_title
height: 24
width: 290
long_mode: DOT
- label:
id: !extend t8_value
height: 22
width: 290
long_mode: DOT
- obj:
id: !extend tile9
widgets:
- label:
id: !extend t9_title
height: 24
width: 290
long_mode: DOT
- label:
id: !extend t9_value
height: 22
width: 290
long_mode: DOT
- obj:
id: !extend tile10
widgets:
- label:
id: !extend t10_title
height: 24
width: 290
long_mode: DOT
- label:
id: !extend t10_value
height: 22
width: 290
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:
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 = 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:
format: "UI_TEST COMPLETE"
level: INFO
# 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(screen_text::tr(screen_text::txt::ha_off)) : std::string(tile_controls::climate_action_text(lower(id(active_climate_action))));
if (status.empty()) status = tile_controls::climate_mode_text(mode);
if (std::isfinite(id(active_climate_current))) status += " · " + screen_text::fill(screen_text::txt::climate_now, "value", screen_text::decimal(id(active_climate_current), 1) + "°");
if (id(active_climate_humidity) >= 0) status += " · " + screen_text::percent(id(active_climate_humidity));
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();