fcdea12c44
packages/core.yaml sets wifi: power_save_mode: none. ESPHome merges it under the screen's own wifi: block, which keeps the network, the password and the hotspot, and the screen's own YAML still wins on the mode. The YAML ESP Screens writes has had the line since app 0.2.24; screens with an older or hand-written YAML ran ESPHome's ESP32 default light, and their messages from Home Assistant waited 76-117 ms on the bench. esphome config on the bench screens' Device Builder YAMLs: NONE for both without a line of their own, LIGHT when the YAML says light. The bench Guition, flashed from its Device Builder YAML: ping 15 ms average instead of 117 ms. tools/check.sh: 474 Python tests (a new one keeps the core's wifi: block to this one key), 19/19 C++, editor; firmware unchanged in size (CYD 1,678,816 B, 91.5 %; Guition 2,055,280 B), since the checkout profiles already had the line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1018 lines
47 KiB
YAML
1018 lines
47 KiB
YAML
# ESP Screens - Guition ESP32-S3-4848S040 board: 4-inch EU wallbox, 480x480 IPS + GT911 capacitive touch.
|
|
#
|
|
# What is in here: the hardware (display, touch, backlight, PSRAM), the sizes the shared tree takes from
|
|
# this board, the code the shared lambdas take from this board (hooks), and what only a Guition has: camera
|
|
# images, a screen that turns a quarter, a page swipe from the glass edge, a climate card with a fan and
|
|
# swing card, and the snapshot diagnostics. Everything else (the LVGL tree, the cards, the scripts, the
|
|
# entities) is packages/core.yaml, which every board shares; docs/PROFILES.md says how the files fit
|
|
# together, docs/GUITION.md what this board needs.
|
|
#
|
|
# Two entry files include this: guition-4848s040.yaml (a build on this computer) and packages/guition.yaml
|
|
# (the package a screen builds from over GitHub).
|
|
substitutions:
|
|
|
|
# 0: no drift limit. A press that drifts is still a tap as long as the finger lets go on the tile
|
|
# (runtime_tiles::event checks where it let go: LVGL keeps the press on the tile and would click it
|
|
# anywhere), and a quick flick is LVGL's gesture, never a tap (the home_page gesture handler in on_boot).
|
|
# The CYD keeps a limit because its resistive panel jumps.
|
|
TOUCH_MOVE_LIMIT_PX: "0"
|
|
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
|
|
TOUCH_MIN_PRESS_MS: "20"
|
|
# Page swipe starts in this band along the left or right edge (about 5 mm) and needs
|
|
# this much travel inward (about 6 mm); a swipe from the middle of the screen does nothing.
|
|
EDGE_SWIPE_BAND_PX: "32"
|
|
EDGE_SWIPE_TRAVEL_PX: "40"
|
|
# A slider let go this close to the left or right edge of the glass (about 1 cm) meant its end when
|
|
# that end lies in the band: the panel loses a finger swiped off the glass 30-50 px before the edge.
|
|
SLIDER_EDGE_SNAP_PX: "67"
|
|
DEVICE_NAME: "guition-new"
|
|
DEVICE_FRIENDLY_NAME: "My Guition"
|
|
|
|
DISPLAY_W: "480"
|
|
DISPLAY_H: "480"
|
|
LVGL_ROTATION: "0"
|
|
TOUCH_SWAP_XY: "false"
|
|
TOUCH_MIRROR_X: "false"
|
|
TOUCH_MIRROR_Y: "false"
|
|
GRID_COL1_X: "16"
|
|
GRID_COL2_X: "246"
|
|
GRID_ROW1_Y: "0"
|
|
GRID_ROW2_Y: "120"
|
|
GRID_ROW3_Y: "240"
|
|
GRID_ROW4_Y: "360"
|
|
GRID_ROW5_Y: "480"
|
|
SCROLL_Y: "72"
|
|
SCROLL_H: "348"
|
|
TILE_W: "218"
|
|
TILE_H: "108"
|
|
HEADER_Y: "22"
|
|
# Holding the top bar opens the settings page (0.2.44+). The two edge bands stay clear: a swipe
|
|
# that starts up there still changes the page.
|
|
SETTINGS_HOLD_X: "${EDGE_SWIPE_BAND_PX}"
|
|
SETTINGS_HOLD_W: "416"
|
|
SETTINGS_HOLD_DRIFT_PX: "32"
|
|
|
|
# Alert overlay (HA action show_alert), 480x480: one card centred on LVGL's top layer.
|
|
ALERT_CARD_W: "420"
|
|
ALERT_CARD_H: "320"
|
|
ALERT_ICON_X: "26"
|
|
ALERT_ICON_Y: "22"
|
|
ALERT_TEXT_X: "90"
|
|
ALERT_TEXT_W: "304"
|
|
ALERT_TITLE_Y: "26"
|
|
ALERT_TITLE_H: "32" # one line of headline; a DOT label only ends in an ellipsis with a height
|
|
ALERT_SUBTITLE_Y: "68"
|
|
ALERT_SUBTITLE_H: "150"
|
|
ALERT_BUTTON_W: "150"
|
|
ALERT_BUTTON_H: "60"
|
|
ALERT_BUTTON_INSET: "22"
|
|
ALERT_TITLE_MAX: "64" # bytes; the label ends one line with an ellipsis
|
|
ALERT_BUTTON_MAX: "16" # bytes on the OK button
|
|
ALERT_SUBTITLE_MAX: "240" # bytes; wraps over the rows the card has
|
|
# An alert with a camera image (firmware 0.2.57+): the image across the top of a taller card, the words under it.
|
|
ALERT_CARD_H_IMAGE: "452"
|
|
ALERT_IMAGE_INSET: "14"
|
|
ALERT_IMAGE_SHIFT: "236" # the icon, title and subtitle move down by the image and a gap
|
|
ALERT_SUBTITLE_H_IMAGE: "62" # two lines between the title and the button
|
|
# Camera images (firmware 0.2.57+): the boxes ESP Screen Manager sizes a snapshot for (camera_feed.BOXES).
|
|
CAMERA_FULL_W: "480"
|
|
CAMERA_FULL_H: "480"
|
|
CAMERA_THUMB_W: "392"
|
|
CAMERA_THUMB_H: "220"
|
|
OVERLAY_SLIDER_W: "144"
|
|
OVERLAY_SLIDER_H: "300"
|
|
OVERLAY_ICON_Y: "148"
|
|
TILE_ICON_X: "0"
|
|
TILE_ICON_Y: "12"
|
|
TILE_TITLE_X: "64"
|
|
TILE_TITLE_Y: "17"
|
|
TILE_VALUE_X: "64"
|
|
TILE_VALUE_Y: "45"
|
|
|
|
# Fonts: one size per role, the same files on every board
|
|
FONT_TIME_SIZE: "27" # the clock in the top bar
|
|
FONT_HEADLINE_SIZE: "27" # card titles
|
|
FONT_WATCH_VALUE_SIZE: "38"
|
|
FONT_CLOCK_DIGITS_SIZE: "48"
|
|
FONT_SETPOINT_SIZE: "64" # the climate card's setpoint
|
|
FONT_LABEL_SIZE: "18" # tile titles
|
|
FONT_SUBLABEL_SIZE: "16" # tile values and small text
|
|
FONT_SUBLABEL_BIG_SIZE: "21" # the top bar and settings rows
|
|
FONT_ICON_SIZE: "42" # tile icons
|
|
FONT_ICON_MINI_SIZE: "26" # the top bar and card keys
|
|
FONT_ICON_BIG_SIZE: "64" # full-page tiles
|
|
FONT_WATCH_ICON_SIZE: "18"
|
|
|
|
# Tiles and the top bar
|
|
TILE_RADIUS: "22" # corner of a tile
|
|
TILE_PAD: "12" # inside a tile
|
|
TILE_ICON_SIZE: "54" # the icon circle
|
|
HEADER_INSET: "16" # the room name and the clock from the glass edge
|
|
|
|
# The page bar under the tiles
|
|
PAGE_KEY_W: "240" # the previous/next keys under the tiles
|
|
PAGE_BAR_H: "60" # the page bar
|
|
PAGE_NUMBER_W: "160" # the dots in the middle
|
|
PAGE_CHEVRON_INSET: "24" # the chevrons from the outer edge
|
|
|
|
# Cards: the round keys and the brightness card
|
|
CARD_KEY_X: "16" # a card's round key from the side
|
|
CARD_KEY_Y: "16" # from the top
|
|
CARD_KEY_SIZE: "60" # its diameter
|
|
OVERLAY_TITLE_Y: "30" # the brightness card's title
|
|
OVERLAY_VALUE_Y: "70" # its percentage
|
|
OVERLAY_SLIDER_Y: "36" # its slider below the centre
|
|
OVERLAY_SLIDER_RADIUS: "42" # the slider's corners
|
|
OVERLAY_KNOB_PAD: "15" # the knob
|
|
COLOR_KEY_X: "-20" # the colour wheel key from the bottom right corner
|
|
COLOR_KEY_Y: "-26"
|
|
COLOR_KEY_SIZE: "66" # its diameter
|
|
COLOR_ARC_SIZE: "60" # the eight arcs in it
|
|
COLOR_ARC_W: "15"
|
|
COLOR_TITLE_W: "310" # the colour card's title
|
|
COLOR_TITLE_Y: "35"
|
|
|
|
# The climate card
|
|
CLIMATE_TITLE_Y: "30" # the title
|
|
CLIMATE_TITLE_W: "300"
|
|
CLIMATE_STATUS_Y: "92" # the status line
|
|
CLIMATE_CARD_Y: "136" # the setpoint card
|
|
CLIMATE_CARD_H: "200"
|
|
CLIMATE_CARD_RADIUS: "24"
|
|
CLIMATE_KEY_INSET: "18" # the -/+ keys from the card edge
|
|
CLIMATE_KEY_SIZE: "116" # their diameter
|
|
CLIMATE_KEY_RADIUS: "58"
|
|
CLIMATE_NUMBER_Y: "-14" # the setpoint number
|
|
CLIMATE_CAPTION_Y: "46" # the word under it
|
|
CLIMATE_MODES_Y: "364" # the row of mode keys
|
|
CLIMATE_MODES_H: "76"
|
|
CLIMATE_MODES_GAP: "10"
|
|
CLIMATE_MODE_RADIUS: "38"
|
|
|
|
# The mode picker
|
|
PICKER_TITLE_Y: "35" # the "Mode" title
|
|
PICKER_LIST_Y: "92" # the list of choices
|
|
PICKER_LIST_H: "372"
|
|
PICKER_PAD: "4"
|
|
PICKER_ROW_GAP: "6"
|
|
PICKER_COL_GAP: "6"
|
|
PICKER_MODE_W: "136" # a mode chip
|
|
PICKER_MODE_H: "58"
|
|
PICKER_MODE_RADIUS: "22"
|
|
PICKER_TITLE_H: "24" # the Fan and Swing captions
|
|
PICKER_CHIP_W: "98" # a fan or swing chip
|
|
PICKER_CHIP_H: "48"
|
|
PICKER_CHIP_RADIUS: "18"
|
|
PICKER_CHIP_FONT: "sublabel_big" # the words on them
|
|
|
|
# The touch test page
|
|
TOUCH_TEST_PAGE_PAINT: "paint_page_soft" # the page
|
|
TOUCH_TEST_TITLE: "GT911 touch check" # the words at the top
|
|
TOUCH_TEST_TITLE_FONT: "headline"
|
|
TOUCH_TEST_TITLE_Y: "110"
|
|
TOUCH_TEST_INSET: "12" # the corner crosses from the edge
|
|
TOUCH_TEST_FAR_X: "443" # the right crosses: width - inset - size - 1
|
|
TOUCH_TEST_FAR_Y: "443" # the bottom crosses
|
|
TOUCH_TEST_MID_X: "228" # the middle cross
|
|
TOUCH_TEST_MID_Y: "228"
|
|
TOUCH_TEST_SIZE: "24" # a cross
|
|
TOUCH_TEST_PAINT: "paint_ink" # the crosses
|
|
|
|
# What the shared scripts do differently on this board
|
|
LVGL_DEFAULT_FONT: "sublabel" # LVGL's default font: every label names its own, so this only keeps Montserrat out of the build
|
|
LVGL_BUFFER_SIZE: "25%" # LVGL's draw buffer as a share of the screen
|
|
PAGE_DOTS_LARGE: "true" # bigger page dots
|
|
EFFECTS_ROW_FONT: "sublabel" # the effects page's rows
|
|
ROTATION_SUPPORTED: "true" # the screen turns a quarter from the settings
|
|
STANDBY_DIM_MS: "1500" # how long the backlight takes to dim into standby (waking takes 300)
|
|
ALERT_FLASH_FADE_MS: "60" # an alert's flash: the fade of each step
|
|
ALERT_FLASH_DARK_MS: "140" # how long it stays dark
|
|
ALERT_FLASH_LIT_MS: "180" # how long it stays lit
|
|
|
|
# Code the shared lambdas of packages/core.yaml take from this board, at the place named ${...}: the
|
|
# exact lines the old profile had there. Empty when this board adds nothing at that place; every board
|
|
# must name every hook, or ESPHome stops at the undefined one.
|
|
# the first boot step: the touch guard, and what this board's panel needs
|
|
BOOT_TOUCH: |-
|
|
cyd::touch_guard.configure(${TOUCH_MOVE_LIMIT_PX}, ${TOUCH_MIN_PRESS_MS});
|
|
cyd::edge_swipe.configure(${DISPLAY_W}, ${DISPLAY_H}, ${EDGE_SWIPE_BAND_PX}, ${EDGE_SWIPE_TRAVEL_PX});
|
|
cyd::edge_snap_band = ${SLIDER_EDGE_SNAP_PX};
|
|
// The page and the tile container take no presses: the theme's pressed style would
|
|
// otherwise repaint nearly the whole screen twice per touch in the empty edge band,
|
|
// starving the touch polling for the whole length of a quick swipe.
|
|
lv_obj_remove_flag(id(home_page)->obj, LV_OBJ_FLAG_CLICKABLE);
|
|
lv_obj_remove_flag(id(tile_scroll), LV_OBJ_FLAG_CLICKABLE);
|
|
// A stray GT911 contact at exactly (0, 0) never reaches LVGL (cyd::GhostTouch): as the last
|
|
// sample of a drag it sent a slider to its end on release. LVGL still reads ESPHome's callback.
|
|
if (auto *indev = lv_indev_get_next(nullptr)) {
|
|
static lv_indev_read_cb_t esphome_read = nullptr;
|
|
static const lv_indev_read_cb_t filtered = [](lv_indev_t *input, lv_indev_data_t *data) {
|
|
esphome_read(input, data);
|
|
int32_t ghost_x = 0, ghost_y = 0;
|
|
id(screen_lvgl).rotate_coordinates(ghost_x, ghost_y);
|
|
const unsigned dropped = cyd::ghost_touch.dropped();
|
|
const auto read = cyd::ghost_touch.filter({data->state == LV_INDEV_STATE_PRESSED, (int) data->point.x, (int) data->point.y}, ghost_x, ghost_y);
|
|
if (cyd::ghost_touch.dropped() != dropped) ESP_LOGI("touch", "GT911 stray contact at (0,0) ignored");
|
|
data->state = read.pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED;
|
|
data->point.x = read.x;
|
|
data->point.y = read.y;
|
|
};
|
|
if (lv_indev_get_read_cb(indev) != filtered) {
|
|
esphome_read = lv_indev_get_read_cb(indev);
|
|
lv_indev_set_read_cb(indev, filtered);
|
|
}
|
|
}
|
|
id(last_touch_ms) = millis();
|
|
id(last_use_ms) = millis();
|
|
id(display_dimmed) = false;
|
|
# right after runtime_tiles::enabled = true, before the settings load
|
|
BOOT_ENABLED_HOOK: "runtime_tiles::rotation_supported = true;"
|
|
# the paint style only this board has (theme::paints)
|
|
BOARD_PAINT_FILL: "theme::fill(id(paint_camera), Paint::camera);"
|
|
# in runtime_tiles::dismiss, after the detail card closes
|
|
CLOSE_CARDS_HOOK: "runtime_tiles::camera_close();"
|
|
# what LVGL's gesture over the tiles does on this board
|
|
BOOT_PAGE_GESTURE: |-
|
|
// A quick flick or wipe over the tiles is never a tap (firmware 0.2.65+). LVGL reports it as a gesture that
|
|
// bubbles up to this page (a tile's slider keeps its own, runtime_tiles::bind): the tap under the finger is
|
|
// consumed and LVGL waits for the release. No page flip: pages change with the edge swipe (on_update). LVGL
|
|
// wants speed as well as 50 px of travel, so a firm press that drifts slowly is still a tap. Cards and the
|
|
// settings page keep their drags; in standby the push still wakes the screen, it only switches nothing.
|
|
lv_obj_add_event_cb(id(home_page)->obj, [](lv_event_t *) {
|
|
if (!runtime_tiles::enabled || id(calibration_active) || !id(active_entity).empty() || settings_screen::visible() ||
|
|
(runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN))) return;
|
|
// A quick drag on a slider is that slider's: the press stays with it (firmware 0.2.68+).
|
|
if (runtime_tiles::captured_slider) return;
|
|
cyd::touch_guard.consume();
|
|
ESP_LOGI("touch", "flick: no tap");
|
|
if (id(display_dimmed)) return;
|
|
if (auto *input = lv_indev_active()) lv_indev_wait_release(input);
|
|
}, LV_EVENT_GESTURE, nullptr);
|
|
# after the gesture: the camera images of a board that shows them
|
|
BOOT_CAMERA_HOOKS: |-
|
|
// Camera images (firmware 0.2.57+): the two online_images and the alert's frame.
|
|
// set_url() forgets the ETag, so it is only called for a new link: an unchanged picture then answers 304.
|
|
runtime_tiles::camera_full.load = [](const std::string &url) {
|
|
static std::string current;
|
|
if (url != current) { id(camera_image).set_url(url); current = url; }
|
|
id(camera_image).update();
|
|
};
|
|
runtime_tiles::camera_full.release = []() { id(camera_image).release(); };
|
|
runtime_tiles::camera_full.source = []() { return id(camera_image).get_lv_image_dsc(); };
|
|
runtime_tiles::camera_thumb.load = [](const std::string &url) {
|
|
static std::string current;
|
|
if (url != current) { id(alert_image).set_url(url); current = url; }
|
|
id(alert_image).update();
|
|
};
|
|
runtime_tiles::camera_thumb.release = []() { id(alert_image).release(); };
|
|
runtime_tiles::camera_thumb.source = []() { return id(alert_image).get_lv_image_dsc(); };
|
|
runtime_tiles::alert_frame = id(alert_image_frame);
|
|
runtime_tiles::alert_frame_icon = id(alert_image_icon);
|
|
runtime_tiles::alert_room = [](bool image) {
|
|
const int shift = image ? ${ALERT_IMAGE_SHIFT} : 0;
|
|
lv_obj_set_height(id(alert_card), image ? ${ALERT_CARD_H_IMAGE} : ${ALERT_CARD_H});
|
|
lv_obj_set_y(id(alert_icon), ${ALERT_ICON_Y} + shift);
|
|
lv_obj_set_y(id(alert_title), ${ALERT_TITLE_Y} + shift);
|
|
lv_obj_set_y(id(alert_subtitle), ${ALERT_SUBTITLE_Y} + shift);
|
|
lv_obj_set_height(id(alert_subtitle), image ? ${ALERT_SUBTITLE_H_IMAGE} : ${ALERT_SUBTITLE_H});
|
|
};
|
|
# after the cards' hooks, before the settings page's: the calibration wizard of a resistive panel, the fan and swing card of a big one
|
|
BOOT_CARDS_TAIL: |-
|
|
climate_card::look.card = id(climate_settings_card);
|
|
climate_card::look.font = id(label)->get_lv_font();
|
|
climate_card::look.icon_font = id(materialdesign_icons_mini)->get_lv_font();
|
|
climate_card::choose = [](char kind, int index) {
|
|
if (kind == 'f') id(set_climate_fan_mode).execute(index);
|
|
else id(set_climate_swing_mode).execute(index);
|
|
};
|
|
# what else keeps the settings page closed (inline, in the condition)
|
|
MAY_OPEN_EXTRA: " && !runtime_tiles::camera_visible()"
|
|
# the log line of a boot onto the touch test page
|
|
TOUCH_TEST_READY_LOG: "GT911 touch test ready; no tile actions"
|
|
# one more line of the ui_state diagnostics
|
|
UI_STATE_HOOK: "ESP_LOGI(\"ui_diag\", \"rotation=%u actual=%u\", (unsigned)runtime_tiles::rotation, (unsigned)id(screen_lvgl).get_rotation());"
|
|
# every 250 ms after runtime_tiles::tick()
|
|
TICK_HOOK: "runtime_tiles::camera_tick();"
|
|
# apply_screen_settings: turning the screen, before the standby logic
|
|
APPLY_ROTATION: |-
|
|
if(runtime_tiles::enabled && static_cast<unsigned>(id(screen_lvgl).get_rotation())!=runtime_tiles::rotation){
|
|
cyd::touch_guard.consume();
|
|
if(lv_indev_get_act())lv_indev_wait_release(lv_indev_get_act());
|
|
id(screen_lvgl).set_rotation(static_cast<esphome::display::DisplayRotation>(runtime_tiles::rotation));
|
|
}
|
|
# apply_screen_settings: how the backlight takes its level
|
|
APPLY_BACKLIGHT: |-
|
|
// Standby dims slowly (ESPHome's transition); waking is quick.
|
|
id(set_backlight).execute(level, id(display_dimmed) ? 1500 : 300);
|
|
# apply_screen_settings: the Rotation entity in Home Assistant follows
|
|
APPLY_ROTATION_ENTITY: |-
|
|
const auto shown_rotation = id(setting_rotation)->active_index();
|
|
if (!id(setting_rotation)->has_state() || !shown_rotation.has_value() || *shown_rotation != (size_t) (settings_screen::rotation / 90)) id(setting_rotation)->update();
|
|
# back_to_page_1_when_due: what else counts as away from page 1 (inline)
|
|
AWAY_EXTRA: " runtime_tiles::camera_visible() ||"
|
|
# alert_show: after the settings page closed, before the card is made
|
|
ALERT_SHOW_HOOK: |-
|
|
// An open camera closes; the card makes room for an image the app announced for this alert.
|
|
runtime_tiles::alert_prepare();
|
|
# set_climate_fan_mode and set_climate_swing_mode: after the choice is stored
|
|
CLIMATE_MODE_CHOSEN_HOOK: "id(climate_card_refresh).execute();"
|
|
# open_value_overlay: the climate card of a panel in portrait, before its refresh
|
|
OPEN_VALUE_OVERLAY_TAIL: ""
|
|
|
|
# ============================================================
|
|
# No need to edit below this line.
|
|
# ============================================================
|
|
|
|
esphome:
|
|
platformio_options:
|
|
build_flags:
|
|
- -DLV_USE_SNAPSHOT=1
|
|
includes:
|
|
- <esphome/components/smart_display/guition_diagnostics.h>
|
|
|
|
esp32:
|
|
board: esp32-s3-devkitc-1
|
|
variant: esp32s3
|
|
flash_size: 16MB
|
|
cpu_frequency: 240MHz
|
|
framework:
|
|
type: esp-idf
|
|
version: recommended
|
|
advanced:
|
|
execute_from_psram: true
|
|
|
|
psram:
|
|
mode: octal
|
|
speed: 80MHz
|
|
|
|
logger:
|
|
tx_buffer_size: 2048
|
|
hardware_uart: UART0
|
|
|
|
api:
|
|
actions:
|
|
- action: ui_snapshot
|
|
then:
|
|
- script.execute: capture_ui_snapshot
|
|
# A camera full screen from Home Assistant (firmware 0.2.57+), as a tap on its tile opens it; an empty entity closes
|
|
# it. Only a camera on the screen's tiles or in a recent alert gets an image from ESP Screens.
|
|
- action: preview_camera
|
|
variables:
|
|
entity: string
|
|
then:
|
|
- lambda: |-
|
|
if (entity.empty()) { runtime_tiles::camera_close(); return; }
|
|
id(last_use_ms) = millis();
|
|
id(wake_display).execute();
|
|
std::string name = entity;
|
|
for (size_t i = 0; i < runtime_tiles::model.count; ++i)
|
|
if (runtime_tiles::model.tiles[i].entity == entity) { name = runtime_tiles::model.tiles[i].name; break; }
|
|
runtime_tiles::camera_open(entity, name);
|
|
|
|
# Camera images (firmware 0.2.57+): ESP Screen Manager serves each image already sized for this screen, as an uncompressed
|
|
# BMP on its own port on the LAN (screen_manager/app/camera_feed.py). ESPHome decodes a BMP piece by piece as it arrives,
|
|
# 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; runtime_tiles.h decides when they load and frees them when they close. Plain HTTP on the LAN, no certificates.
|
|
http_request:
|
|
timeout: 4s
|
|
verify_ssl: false
|
|
|
|
# The top-level online_image form that ESPHome 2026.6.2 (min_version) knows; 2026.7+ still reads it and suggests
|
|
# `image: - platform: online_image`, which this profile can use once min_version reaches 2026.7.
|
|
online_image:
|
|
- id: camera_image
|
|
url: "http://127.0.0.1/camera.bmp" # set when a camera opens
|
|
format: BMP
|
|
type: RGB565
|
|
buffer_size: 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);'
|
|
|
|
text_sensor:
|
|
- platform: template
|
|
name: "Guition screen type"
|
|
entity_category: diagnostic
|
|
update_interval: never
|
|
lambda: 'return {"guition"};'
|
|
|
|
# Rotation as a Home Assistant choice (firmware 0.2.49+). Only the Guition turns; the CYD keeps its orientation.
|
|
select:
|
|
- platform: template
|
|
id: setting_rotation
|
|
name: "Rotation"
|
|
icon: "mdi:screen-rotation"
|
|
entity_category: config
|
|
options: ["0°", "90°", "180°", "270°"]
|
|
update_interval: never
|
|
lambda: |-
|
|
static const char *const names[] = {"0°", "90°", "180°", "270°"};
|
|
return std::string(names[std::clamp<int32_t>(settings_screen::rotation, 0, 270) / 90]);
|
|
set_action:
|
|
- lambda: |-
|
|
const std::string option(x.c_str(), x.size());
|
|
settings_screen::set("rotation", atoi(option.c_str()));
|
|
|
|
# Pinout from ESPHome's GUITION-4848S040 preset. No relay outputs are configured.
|
|
spi:
|
|
id: lcd
|
|
clk_pin: GPIO48
|
|
mosi_pin: GPIO47
|
|
|
|
i2c:
|
|
id: touch_bus
|
|
sda: GPIO19
|
|
scl:
|
|
number: GPIO45
|
|
ignore_strapping_warning: true
|
|
scan: true
|
|
|
|
output:
|
|
- platform: ledc
|
|
pin: GPIO38
|
|
id: gpio_backlight_pwm
|
|
frequency: 20000Hz
|
|
|
|
light:
|
|
# The firmware owns the backlight: brightness, standby, night and alerts set it, and the minute tick
|
|
# restores it. Home Assistant uses the brightness numbers and the Wake and Sleep buttons (0.2.46+).
|
|
- platform: monochromatic
|
|
output: gpio_backlight_pwm
|
|
name: "Display Backlight"
|
|
id: back_light
|
|
internal: true
|
|
restore_mode: ALWAYS_ON
|
|
default_transition_length: 150ms
|
|
|
|
touchscreen:
|
|
platform: gt911
|
|
id: ts_touch
|
|
i2c_id: touch_bus
|
|
display: my_display
|
|
update_interval: 20ms
|
|
transform:
|
|
swap_xy: ${TOUCH_SWAP_XY}
|
|
mirror_x: ${TOUCH_MIRROR_X}
|
|
mirror_y: ${TOUCH_MIRROR_Y}
|
|
on_touch:
|
|
- lambda: |-
|
|
id(last_touch_ms) = millis();
|
|
id(last_use_ms) = millis();
|
|
id(touch_down) = true;
|
|
cyd::touch_guard.begin(millis(), touch.x, touch.y, touch.id);
|
|
cyd::edge_swipe.begin(touch.x, touch.y, static_cast<int>(id(screen_lvgl).get_rotation()));
|
|
ESP_LOGI("touch", "GT911 press x=%u y=%u id=%u test=%d", touch.x, touch.y, (unsigned) touch.id, id(calibration_active));
|
|
on_update:
|
|
- lambda: |-
|
|
id(last_touch_ms) = millis();
|
|
id(last_use_ms) = millis();
|
|
// Only the contact that started the touch counts; the guard ignores other ids.
|
|
for (const auto &point : touches) {
|
|
// Trace every sample of an edge touch: the log then shows how often the panel delivers.
|
|
if (cyd::edge_swipe.armed())
|
|
ESP_LOGI("touch", "swipe id=%u st=%u x=%u y=%u", (unsigned) point.id, (unsigned) point.state, point.x, point.y);
|
|
// The stray (0, 0) contact (cyd::GhostTouch) is not where the finger went.
|
|
if (point.x == 0 && point.y == 0) continue;
|
|
cyd::touch_guard.update(point.x, point.y, point.id);
|
|
if (point.id != cyd::touch_guard.contact()) continue;
|
|
// Swiping in from a side edge flips the page ("Swiping between pages"); the tap
|
|
// under the finger is consumed and LVGL waits for the release. LVGL 9.5 sends no
|
|
// PRESSING to the input device, hence the touchscreen trigger.
|
|
const int step = cyd::edge_swipe.update(point.x, point.y);
|
|
if (!step) continue;
|
|
const char *blocked = !runtime_tiles::enabled ? "no runtime tiles" : !runtime_tiles::swipe_pages ? "setting off"
|
|
: id(display_dimmed) ? "screen dimmed" : id(calibration_active) ? "calibration" : !id(active_entity).empty() ? "card open"
|
|
: (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)) ? "detail card open"
|
|
: runtime_tiles::camera_visible() ? "camera open" : nullptr;
|
|
if (blocked) { ESP_LOGI("touch", "edge swipe ignored: %s", blocked); continue; }
|
|
cyd::touch_guard.consume();
|
|
for (auto *indev = lv_indev_get_next(nullptr); indev; indev = lv_indev_get_next(indev)) lv_indev_wait_release(indev);
|
|
ESP_LOGI("touch", "edge swipe: page %d -> %d", id(tile_page), id(tile_page) + step);
|
|
id(tile_page) += step;
|
|
id(show_tile_page).execute();
|
|
}
|
|
on_release:
|
|
- lambda: |-
|
|
id(last_touch_ms) = millis();
|
|
id(last_use_ms) = millis();
|
|
id(touch_down) = false;
|
|
if (cyd::edge_swipe.armed() && cyd::edge_swipe.inward() > 0)
|
|
ESP_LOGI("touch", "edge swipe not fired: %d px inward, %d px vertical", cyd::edge_swipe.inward(), cyd::edge_swipe.sideways());
|
|
cyd::edge_swipe.end();
|
|
|
|
|
|
# Native ESPHome driver with the working manufacturer's LVGL demo timings.
|
|
# See docs/GUITION_FACTORY_REFERENCE.md; no custom RGB component is required.
|
|
display:
|
|
- platform: st7701s
|
|
id: my_display
|
|
dimensions: {width: 480, height: 480}
|
|
spi_id: lcd
|
|
spi_mode: MODE0
|
|
data_rate: 2MHz
|
|
color_order: RGB
|
|
invert_colors: false
|
|
cs_pin: 39
|
|
de_pin: 18
|
|
hsync_pin: 16
|
|
vsync_pin: 17
|
|
pclk_pin: 21
|
|
pclk_frequency: 16MHz
|
|
pclk_inverted: false
|
|
hsync_front_porch: 10
|
|
hsync_pulse_width: 8
|
|
hsync_back_porch: 50
|
|
vsync_front_porch: 10
|
|
vsync_pulse_width: 8
|
|
vsync_back_porch: 20
|
|
transform:
|
|
mirror_x: false
|
|
mirror_y: false
|
|
init_sequence:
|
|
- 1
|
|
# Manufacturer's 16-bit bus configuration and normal scan direction.
|
|
- [0xFF, 0x77, 0x01, 0x00, 0x00, 0x10]
|
|
- [0xCD, 0x00]
|
|
- [0xC7, 0x00]
|
|
# Return to the standard register bank before pixel format / sleep-out.
|
|
- [0xFF, 0x77, 0x01, 0x00, 0x00, 0x00]
|
|
- [0x3A, 0x60]
|
|
- [0x36, 0x00]
|
|
- [0x20]
|
|
- [0x11]
|
|
- delay 120ms
|
|
data_pins:
|
|
red: [11, 12, 13, 14, 0]
|
|
green: [8, 20, 3, 46, 9, 10]
|
|
blue: [4, 5, 6, 7, 15]
|
|
update_interval: never
|
|
auto_clear_enabled: false
|
|
|
|
lvgl:
|
|
id: screen_lvgl
|
|
style_definitions:
|
|
# The alert's camera frame (theme::paints fills it: BOARD_PAINT_FILL).
|
|
- id: paint_camera
|
|
top_layer:
|
|
widgets:
|
|
- obj:
|
|
id: !extend alert_overlay
|
|
widgets:
|
|
- obj:
|
|
id: !extend alert_card
|
|
widgets:
|
|
# The alert's camera image (firmware 0.2.57+) across the top of the card, shown when the app announced
|
|
# one; a tap opens the camera full screen over the alert. runtime_tiles.h puts the image in it.
|
|
# 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 small panel keeps LVGL's content width).
|
|
- obj:
|
|
id: !extend tile_scroll
|
|
widgets:
|
|
- obj:
|
|
id: !extend tile1
|
|
widgets:
|
|
- label:
|
|
id: !extend t1_title
|
|
height: 24
|
|
width: 130
|
|
long_mode: DOT
|
|
- label:
|
|
id: !extend t1_value
|
|
height: 22
|
|
width: 130
|
|
long_mode: DOT
|
|
- obj:
|
|
id: !extend tile2
|
|
widgets:
|
|
- label:
|
|
id: !extend t2_title
|
|
height: 24
|
|
width: 130
|
|
long_mode: DOT
|
|
- label:
|
|
id: !extend t2_value
|
|
height: 22
|
|
width: 130
|
|
long_mode: DOT
|
|
- obj:
|
|
id: !extend tile3
|
|
widgets:
|
|
- label:
|
|
id: !extend t3_title
|
|
height: 24
|
|
width: 130
|
|
long_mode: DOT
|
|
- label:
|
|
id: !extend t3_value
|
|
height: 22
|
|
width: 130
|
|
long_mode: DOT
|
|
- obj:
|
|
id: !extend tile4
|
|
widgets:
|
|
- label:
|
|
id: !extend t4_title
|
|
height: 24
|
|
width: 130
|
|
long_mode: DOT
|
|
- label:
|
|
id: !extend t4_value
|
|
height: 22
|
|
width: 130
|
|
long_mode: DOT
|
|
- obj:
|
|
id: !extend tile5
|
|
widgets:
|
|
- label:
|
|
id: !extend t5_title
|
|
height: 24
|
|
width: 130
|
|
long_mode: DOT
|
|
- label:
|
|
id: !extend t5_value
|
|
height: 22
|
|
width: 130
|
|
long_mode: DOT
|
|
- obj:
|
|
id: !extend tile6
|
|
widgets:
|
|
- label:
|
|
id: !extend t6_title
|
|
height: 24
|
|
width: 130
|
|
long_mode: DOT
|
|
- label:
|
|
id: !extend t6_value
|
|
height: 22
|
|
width: 130
|
|
long_mode: DOT
|
|
- obj:
|
|
id: !extend tile7
|
|
widgets:
|
|
- label:
|
|
id: !extend t7_title
|
|
height: 24
|
|
width: 130
|
|
long_mode: DOT
|
|
- label:
|
|
id: !extend t7_value
|
|
height: 22
|
|
width: 130
|
|
long_mode: DOT
|
|
- obj:
|
|
id: !extend tile8
|
|
widgets:
|
|
- label:
|
|
id: !extend t8_title
|
|
height: 24
|
|
width: 130
|
|
long_mode: DOT
|
|
- label:
|
|
id: !extend t8_value
|
|
height: 22
|
|
width: 130
|
|
long_mode: DOT
|
|
- obj:
|
|
id: !extend tile9
|
|
widgets:
|
|
- label:
|
|
id: !extend t9_title
|
|
height: 24
|
|
width: 130
|
|
long_mode: DOT
|
|
- label:
|
|
id: !extend t9_value
|
|
height: 22
|
|
width: 130
|
|
long_mode: DOT
|
|
- obj:
|
|
id: !extend tile10
|
|
widgets:
|
|
- label:
|
|
id: !extend t10_title
|
|
height: 24
|
|
width: 130
|
|
long_mode: DOT
|
|
- label:
|
|
id: !extend t10_value
|
|
height: 22
|
|
width: 130
|
|
long_mode: DOT
|
|
- obj:
|
|
id: !extend climate_detail_overlay
|
|
widgets:
|
|
# Fan and swing, each a row of choices behind its icon (climate_card.h fills the card).
|
|
- obj:
|
|
id: climate_settings_card
|
|
align: TOP_MID
|
|
y: 340
|
|
width: 94%
|
|
height: 116
|
|
radius: 24
|
|
styles: paint_card
|
|
bg_opa: COVER
|
|
border_width: 1
|
|
pad_all: 0
|
|
hidden: true
|
|
clickable: false
|
|
scrollable: false
|
|
|
|
script:
|
|
- id: capture_ui_snapshot
|
|
mode: single
|
|
then:
|
|
- lambda: guition::snapshot_begin();
|
|
- repeat:
|
|
count: 240
|
|
then:
|
|
- lambda: guition::snapshot_next_row();
|
|
- delay: 100ms
|
|
# Render every overlay and both pages repeatedly without controlling devices.
|
|
- id: ui_self_test
|
|
mode: single
|
|
then:
|
|
- logger.log: "UI_TEST START: page/overlay render stress, no HA actions"
|
|
- lambda: 'ESP_LOGI("ui_test", "Light sliders: %s", light_controls::self_test() ? "PASS" : "FAIL");'
|
|
- lambda: 'id(calibration_active) = false;'
|
|
- lvgl.page.show: home_page
|
|
- script.execute: wake_display
|
|
- repeat:
|
|
count: 10
|
|
then:
|
|
- lambda: |-
|
|
id(last_touch_ms) = millis();
|
|
id(last_use_ms) = millis();
|
|
static unsigned test_page=0;
|
|
id(tile_page) = runtime_tiles::enabled ? test_page++ % runtime_tiles::page_count() : 1;
|
|
- script.execute: show_tile_page
|
|
- delay: 300ms
|
|
- lambda: |-
|
|
lv_obj_update_layout(id(tile_scroll));
|
|
const int count = runtime_tiles::model.count;
|
|
// Wide cards take two slots, so visibility follows the runtime packing.
|
|
const bool paginated = runtime_tiles::enabled ? runtime_tiles::page_count() > 1 : count > 6;
|
|
lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5),
|
|
id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)};
|
|
bool ok = guition::check_geometry(id(home_page)->obj);
|
|
for (int i = 0; i < 10; ++i) {
|
|
bool visible = runtime_tiles::enabled ? i<6 && runtime_tiles::widgets[i].index<runtime_tiles::model.count : i < count && (paginated ? i >= 6 : i < 6);
|
|
ok = ok && (lv_obj_has_flag(tiles[i], LV_OBJ_FLAG_HIDDEN) != visible);
|
|
}
|
|
// The bar shows with more than one page, unless the Page buttons setting is off (firmware 0.2.69+).
|
|
const bool bar = paginated && (!runtime_tiles::enabled || settings_screen::page_buttons);
|
|
for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)})
|
|
ok = ok && (lv_obj_has_flag(control, LV_OBJ_FLAG_HIDDEN) != bar);
|
|
lv_area_t nav, grid;
|
|
lv_obj_get_coords(id(page_next), &nav);
|
|
lv_obj_get_coords(id(tile_scroll), &grid);
|
|
ok = ok && (!bar || grid.y2 < nav.y1);
|
|
if(runtime_tiles::enabled)ok = runtime_tiles::check_tile_geometry() && ok;
|
|
ESP_LOGI("ui_test", "page_check=%s count=%d heap=%u frames=%u grid_bottom=%d nav_top=%d", ok ? "PASS" : "FAIL", count, (unsigned) esp_get_free_heap_size(), (unsigned) id(rendered_frames), grid.y2, nav.y1);
|
|
id(tile_page) = 0;
|
|
- script.execute: show_tile_page
|
|
- delay: 150ms
|
|
- lvgl.widget.show: brightness_overlay
|
|
- delay: 150ms
|
|
- lambda: guition::check_geometry(id(brightness_overlay));
|
|
- lvgl.widget.hide: brightness_overlay
|
|
- lvgl.widget.show: color_detail_overlay
|
|
- delay: 150ms
|
|
- lambda: guition::check_geometry(id(color_detail_overlay));
|
|
- lvgl.widget.hide: color_detail_overlay
|
|
- lvgl.widget.show: climate_detail_overlay
|
|
- delay: 150ms
|
|
- lambda: guition::check_geometry(id(climate_detail_overlay));
|
|
- lvgl.widget.hide: climate_detail_overlay
|
|
- lvgl.widget.show: climate_mode_overlay
|
|
- delay: 150ms
|
|
- lambda: guition::check_geometry(id(climate_mode_overlay));
|
|
- lvgl.widget.hide: climate_mode_overlay
|
|
- lvgl.widget.show: alert_overlay
|
|
- delay: 150ms
|
|
- lvgl.widget.hide: alert_overlay
|
|
- lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();'
|
|
- logger.log: "UI_TEST COMPLETE"
|
|
|
|
# The two scripts that drive the backlight through set_backlight; the CYD drives it as a light action.
|
|
- id: alert_dismiss
|
|
parameters:
|
|
reason: string
|
|
then:
|
|
- if:
|
|
condition:
|
|
lambda: 'return id(alert_active);'
|
|
then:
|
|
- lambda: |-
|
|
id(alert_active) = false;
|
|
id(last_touch_ms) = millis();
|
|
runtime_tiles::alert_clear();
|
|
ESP_LOGI("alert", "dismissed: %s", reason.c_str());
|
|
- lvgl.widget.hide: alert_overlay
|
|
- script.stop: alert_timeout
|
|
- script.stop: alert_flash
|
|
- lambda: 'id(set_backlight).execute(screen_settings::current.brightness, 80);'
|
|
- script.execute:
|
|
id: alert_report
|
|
action: !lambda 'return reason;'
|
|
|
|
# Four backlight blinks when the alert arrives; the screen then stays on as usual.
|
|
- id: alert_flash
|
|
mode: restart
|
|
then:
|
|
- repeat:
|
|
count: 4
|
|
then:
|
|
- lambda: 'id(set_backlight).execute(0, 60);'
|
|
- delay: 140ms
|
|
- lambda: 'id(set_backlight).execute(screen_settings::current.brightness, 60);'
|
|
- delay: 180ms
|
|
|
|
- id: wake_display
|
|
mode: restart
|
|
then:
|
|
- lambda: |-
|
|
id(last_touch_ms) = millis();
|
|
id(display_dimmed) = false;
|
|
id(sleep_requested) = false;
|
|
// Almost instant brightness-up. ESPHome's default is 1s, and that feels sluggish
|
|
// when you touch the screen and are waiting on light.
|
|
id(set_backlight).execute(screen_settings::current.brightness, 80);
|
|
- lvgl.widget.hide: dim_wake_overlay
|
|
- script.execute: close_cards
|
|
|
|
# One owner for the backlight level, through ESPHome's own light transition (firmware 0.2.57+). Firmware 0.2.13-0.2.56
|
|
# stepped the duty with the LEDC hardware fader by reaching into ESPHome's LEDC output, which ESPHome 2026.8 made final;
|
|
# a new call interrupts the running transition from where it is, as ESPHome does for any light.
|
|
- id: set_backlight
|
|
mode: restart
|
|
parameters:
|
|
level: int
|
|
ms: int
|
|
then:
|
|
- lambda: id(back_light).turn_on().set_transition_length(ms).set_brightness(level / 100.0f).perform();
|
|
|
|
# The climate card at this size, with the fan and swing card (the CYD has its own copy).
|
|
- id: climate_card_refresh
|
|
then:
|
|
- lambda: |-
|
|
auto lower = [](std::string v) {
|
|
for (char &c : v) { if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); }
|
|
return v;
|
|
};
|
|
std::string mode = lower(id(active_climate_mode));
|
|
bool off = mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable";
|
|
// What the device does (Heating), else its mode (Heat); then the room.
|
|
std::string status = off ? std::string("Off") : std::string(tile_controls::climate_action_text(lower(id(active_climate_action))));
|
|
if (status.empty()) status = tile_controls::climate_mode_text(mode);
|
|
char part[32];
|
|
if (std::isfinite(id(active_climate_current))) { snprintf(part, sizeof(part), " · Now %.1f°", id(active_climate_current)); status += part; }
|
|
if (id(active_climate_humidity) >= 0) { snprintf(part, sizeof(part), " · %d%%", id(active_climate_humidity)); status += part; }
|
|
lv_label_set_text(id(climate_current_label), status.c_str());
|
|
|
|
int target = id(active_climate_target_x10);
|
|
std::string value = id(active_climate_target_known)
|
|
? tile_controls::format_value(target / 10.0f, id(active_climate_step_x10) / 10.0f, "°") : std::string("--");
|
|
lv_label_set_text(id(climate_target_label), value.c_str());
|
|
lv_obj_set_style_text_color(id(climate_target_label), theme::color(off ? theme::OFF : theme::INK), LV_PART_MAIN);
|
|
auto enable = [](lv_obj_t *key, bool on) {
|
|
if (on) lv_obj_remove_state(key, LV_STATE_DISABLED); else lv_obj_add_state(key, LV_STATE_DISABLED);
|
|
};
|
|
enable(id(climate_minus_button), !id(active_climate_target_known) || target > id(active_climate_min_x10));
|
|
enable(id(climate_plus_button), !id(active_climate_target_known) || target < id(active_climate_max_x10));
|
|
|
|
// One key per supported mode, in Home Assistant's order. Fan and swing add "more"; with it
|
|
// five mode keys fit, and when a sixth mode is in use it takes the last place.
|
|
std::string supported = lower(id(active_climate_supported_modes));
|
|
auto has = [&](const char *m) {
|
|
return supported.find(std::string("\"") + m + "\"") != std::string::npos ||
|
|
supported.find(std::string("'") + m + "'") != std::string::npos;
|
|
};
|
|
struct Key { lv_obj_t *obj; const char *mode; };
|
|
const Key keys[] = {{id(cmkey_auto), "auto"}, {id(cmkey_heat_cool), "heat_cool"}, {id(cmkey_heat), "heat"},
|
|
{id(cmkey_cool), "cool"}, {id(cmkey_dry), "dry"}, {id(cmkey_fan_only), "fan_only"}};
|
|
// The Guition shows fan and swing on their own card below, so no "more" key.
|
|
bool more = false;
|
|
const Key *visible[6];
|
|
unsigned count = 0, room = more ? 5 : 6;
|
|
for (const auto &k : keys) if (has(k.mode)) visible[count++] = &k;
|
|
if (count > room) {
|
|
for (unsigned i = room; i < count; ++i) if (mode == visible[i]->mode) visible[room - 1] = visible[i];
|
|
count = room;
|
|
}
|
|
for (const auto &k : keys) {
|
|
bool show = false;
|
|
for (unsigned i = 0; i < count; ++i) show = show || visible[i] == &k;
|
|
if (show) lv_obj_remove_flag(k.obj, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(k.obj, LV_OBJ_FLAG_HIDDEN);
|
|
bool selected = show && !off && mode == k.mode;
|
|
uint32_t color = tile_controls::mode_color(k.mode);
|
|
lv_obj_set_style_bg_color(k.obj, selected ? lv_color_hex(color) : theme::color(theme::CARD), LV_PART_MAIN);
|
|
lv_obj_set_style_border_color(k.obj, selected ? lv_color_hex(color) : theme::color(theme::LINE), LV_PART_MAIN);
|
|
lv_obj_set_style_text_color(lv_obj_get_child(k.obj, 0), theme::color(selected ? theme::ON_ACCENT : theme::SLATE), LV_PART_MAIN);
|
|
}
|
|
if (more) lv_obj_remove_flag(id(cmkey_more), LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(id(cmkey_more), LV_OBJ_FLAG_HIDDEN);
|
|
// One mode is no choice: the power key already turns it on and off.
|
|
const bool show_keys = count > 1 || more;
|
|
if (show_keys) lv_obj_remove_flag(id(climate_mode_keys), LV_OBJ_FLAG_HIDDEN);
|
|
else lv_obj_add_flag(id(climate_mode_keys), LV_OBJ_FLAG_HIDDEN);
|
|
|
|
// Fan and swing rows, in the entity's own names and order.
|
|
climate_card::Row settings[2];
|
|
int rows = 0;
|
|
if (!cyd::list_item(id(active_climate_fan_modes), 0).empty())
|
|
settings[rows++] = {'f', "\U000F0210", id(active_climate_fan_modes), id(active_climate_fan_mode)};
|
|
if (!cyd::list_item(id(active_climate_swing_modes), 0).empty())
|
|
settings[rows++] = {'s', "\U000F1C91", id(active_climate_swing_modes), id(active_climate_swing_mode)};
|
|
// One layout per card: the setpoint gives room to the mode keys and the settings below it,
|
|
// and a card with nothing below keeps the setpoint centred.
|
|
static int laid_out = -1;
|
|
if (laid_out != rows * 2 + show_keys) {
|
|
laid_out = rows * 2 + show_keys;
|
|
struct Layout { int status, card_y, card_h, key, number_y, caption_y, modes_y, modes_h, settings_y, edge, row_h, gap; };
|
|
static const Layout layouts[6] = {
|
|
// no mode keys: 0, 1, 2 rows
|
|
{92, 180, 200, 116, -14, 46, 0, 0, 0, 14, 48, 10},
|
|
{92, 140, 200, 116, -14, 46, 0, 0, 364, 14, 48, 10},
|
|
{90, 130, 180, 108, -12, 42, 0, 0, 330, 14, 46, 10},
|
|
// mode keys: 0, 1, 2 rows
|
|
{92, 136, 200, 116, -14, 46, 364, 76, 0, 14, 48, 10},
|
|
{90, 126, 170, 108, -12, 42, 312, 68, 394, 12, 44, 8},
|
|
{86, 118, 140, 96, -10, 36, 270, 60, 342, 12, 42, 8},
|
|
};
|
|
const Layout &l = layouts[(show_keys ? 3 : 0) + rows];
|
|
lv_obj_set_y(id(climate_current_label), l.status);
|
|
lv_obj_set_y(id(climate_setpoint_card), l.card_y);
|
|
lv_obj_set_height(id(climate_setpoint_card), l.card_h);
|
|
for (lv_obj_t *button : {id(climate_minus_button), id(climate_plus_button)}) {
|
|
lv_obj_set_size(button, l.key, l.key);
|
|
lv_obj_set_style_radius(button, l.key / 2, LV_PART_MAIN);
|
|
}
|
|
lv_obj_set_y(id(climate_target_label), l.number_y);
|
|
lv_obj_set_y(id(climate_set_label), l.caption_y);
|
|
if (show_keys) {
|
|
lv_obj_set_y(id(climate_mode_keys), l.modes_y);
|
|
lv_obj_set_height(id(climate_mode_keys), l.modes_h);
|
|
for (lv_obj_t *mode_key : {id(cmkey_auto), id(cmkey_heat_cool), id(cmkey_heat), id(cmkey_cool), id(cmkey_dry), id(cmkey_fan_only), id(cmkey_more)}) {
|
|
lv_obj_set_height(mode_key, l.modes_h);
|
|
lv_obj_set_style_radius(mode_key, l.modes_h / 2, LV_PART_MAIN);
|
|
}
|
|
}
|
|
lv_obj_set_y(id(climate_settings_card), l.settings_y);
|
|
climate_card::look.edge = l.edge;
|
|
climate_card::look.row_h = l.row_h;
|
|
climate_card::look.gap = l.gap;
|
|
}
|
|
climate_card::show(settings, rows);
|
|
id(climate_power_refresh).execute();
|