4ec8c7a935
The 2 ms reset of ESPHome's expander support (0.2.81) left the chip silent: "Calibration error" with the PCA9557 answering at 0x18. A component of smart_display's (elecrow_touch_reset.h, setup priority 700: after the I2C bus, before the GT911 driver) writes the expander as Elecrow's V3.0 ESPHome example does: every pin an output and low, 20 ms, the reset released, 100 ms, the interrupt line back to an input. The driver's reset and interrupt pins are gone.
1073 lines
52 KiB
YAML
1073 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 GT911 out of reset through the V3.0's PCA9557 before the driver runs (elecrow_touch_reset.h, firmware 0.2.82).
|
|
elecrow_touch_reset:
|
|
i2c_id: touch_bus
|
|
address: 0x18
|
|
|
|
# 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 their V3.0 ESPHome example. 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: on a unit tested here the driver reported "Calibration error" at setup
|
|
# (firmware 0.2.79 to 0.2.81, the last one through ESPHome's own expander support, whose 2 ms reset the chip ignored).
|
|
# elecrow_touch_reset (below) runs Elecrow's own sequence before the driver looks for the chip; a V2.0 without the
|
|
# expander boots its GT911 by the pull-up as before, and the driver 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 with `logger: level: DEBUG` in a screen's Override YAML: 0x18 is the
|
|
# expander; the GT911 is still in reset at that moment and shows up in the driver's own line instead.
|
|
scan: true
|
|
|
|
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
|
|
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();
|