1091 lines
53 KiB
YAML
1091 lines
53 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:
|
|
id: 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
|
|
# The touch bus at a glance (firmware 0.2.83): the PCA9557's registers and whether a GT911 answers at 0x5D or 0x14.
|
|
- action: touch_probe
|
|
then:
|
|
- lambda: 'id(touch_reset).probe();'
|
|
# Elecrow's reset sequence with values of one's own, then the probe: the expander's configuration register during
|
|
# the reset (0 = every pin an output), its output register while the reset is held and after the release, the two
|
|
# waits in ms, and the configuration afterwards. Elecrow's own values: 0, 0, 1, 20, 100, 2.
|
|
- action: touch_reset
|
|
variables:
|
|
config: int
|
|
low: int
|
|
high: int
|
|
hold_ms: int
|
|
wait_ms: int
|
|
final_config: int
|
|
then:
|
|
- lambda: 'id(touch_reset).reset(config, low, high, hold_ms, wait_ms, final_config);'
|
|
# 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();
|