# 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: - - esp32: board: esp32-s3-devkitc-1 variant: esp32s3 # The ESP32-S3-WROOM-1-N4R8: 4 MB of flash (16 MB boot-loops with no log) and 8 MB of octal PSRAM. Two update slots # of 1,835,008 bytes, like the CYD: tools/check.sh --firmware measures this board against the same budget. flash_size: 4MB cpu_frequency: 240MHz framework: type: esp-idf version: recommended advanced: # ESP-IDF's assert texts (file, line and condition of every check) stay out of the firmware (0.2.75+), 49 KB on # the CYD: a failed assert still stops and restarts the screen, and its backtrace still points at the line. # Every board sets it (tests/test_easy_package.py). ESP-IDF's error names stay: without them ESPHome's own # error lines only say "UNKNOWN ERROR". assertion_level: SILENT execute_from_psram: true sdkconfig_options: # The camera images' HTTP client links mbedTLS; nothing on the screen serves TLS (7 KB of flash). CONFIG_MBEDTLS_TLS_CLIENT_ONLY: y # The Wi-Fi PHY library keeps the USB PHY on by default on an ESP32-S3, which takes GPIO19/20, the touch I2C, # back the moment Wi-Fi starts (elecrow_usb_phy.h). Off: it leaves the USB PHY disabled, as this board needs. CONFIG_ESP_PHY_ENABLE_USB: n psram: mode: octal speed: 80MHz # Flash (docs/ELECROW.md). The firmware is the Guition's with camera images, which ESPHome's own partition table for # 4 MB (two slots of 1,792 KB, 448 KB of NVS) does not hold: with the Guition's fonts it is 2,028 KB. Three things # make it fit with room for updates, measured with ESPHome 2026.9.0 (tools/check.sh --firmware applies the CYD's # budget to this board too): # - two update slots of 1,984 KB and 64 KB of NVS (smart_display: partitions, components/smart_display/__init__.py); # - the icon fonts at 2 bits a pixel (font: below), 156 KB less; # - the TLS client only (sdkconfig_options above), 7 KB less. # That is 1,865 KB, 91.8 % of a slot. Without camera images (the online_image, http_request and preview_camera # sections, the alert frame and the seed image below, and the camera hooks BOOT_CAMERA_HOOKS, BOARD_PAINT_FILL, # CLOSE_CARDS_HOOK, MAY_OPEN_EXTRA, TICK_HOOK, AWAY_EXTRA and ALERT_SHOW_HOOK emptied) it is 1,754 KB, 86.4 %. smart_display: partitions: 4mb_wide_slots # The icon fonts at 2 bits a pixel (four levels of edge smoothing instead of sixteen) where the text keeps its four: # the three sizes of the 314 tile icons are 312 KB at 4 bits, half that at 2. The text fonts are the sharper for it. font: - id: !extend materialdesign_icons bpp: 2 - id: !extend materialdesign_icons_mini bpp: 2 - id: !extend materialdesign_icons_big bpp: 2 - id: !extend watch_icon bpp: 2 logger: tx_buffer_size: 2048 # The USB-C port is a CH340 on UART0; the S3's own USB pins carry the touch I2C (elecrow_usb_phy.h). hardware_uart: UART0 api: actions: - action: ui_snapshot then: - script.execute: capture_ui_snapshot # A camera full screen from Home Assistant (firmware 0.2.57+), as a tap on its tile opens it; an empty entity closes # it. Only a camera on the screen's tiles or in a recent alert gets an image from ESP Screens. - action: preview_camera variables: entity: string then: - lambda: |- if (entity.empty()) { runtime_tiles::camera_close(); return; } id(last_use_ms) = millis(); id(wake_display).execute(); std::string name = entity; for (size_t i = 0; i < runtime_tiles::model.count; ++i) if (runtime_tiles::model.tiles[i].entity == entity) { name = runtime_tiles::model.tiles[i].name; break; } runtime_tiles::camera_open(entity, name); # Camera images (firmware 0.2.57+): ESP Screen Manager serves each image already sized for this screen, as an uncompressed # BMP on its own port on the LAN (screen_manager/app/camera_feed.py). ESPHome decodes a BMP piece by piece as it arrives, # one `buffer_size` per loop, where a JPEG of the full screen held the loop for 0.6 s; touch and drawing keep their turn. # 16 KB a loop (firmware 0.2.73+): the full picture came in 1.8 s instead of 2.8 s with 4 KB. A loop waited over 50 ms # for it in one of eleven opens with Wi-Fi awake (0.1 s), in five of twelve with power save; 32 KB saved 0.3 s more but # held a loop 0.1 s more often (docs/TEST_RESULTS_0287.md, 0288). One image full screen and one in an alert's # frame, and one strip with the live pictures of a page's camera tiles (firmware 0.2.77+); runtime_tiles.h decides when # they load and frees them when they close. Plain HTTP on the LAN, no certificates. http_request: timeout: 4s verify_ssl: false # The top-level online_image form that ESPHome 2026.6.2 (min_version) knows; 2026.7+ still reads it and suggests # `image: - platform: online_image`, and 2027.1 drops it: move with the min_version raise (docs/RELEASING.md, 0.2.89). online_image: - id: camera_image url: "http://127.0.0.1/camera.bmp" # set when a camera opens format: BMP type: RGB565 buffer_size: 16384 on_download_finished: - lambda: 'runtime_tiles::camera_loaded(false, cached);' on_error: - lambda: 'runtime_tiles::camera_failed(false);' - id: alert_image url: "http://127.0.0.1/camera.bmp" # set when an alert brings an image format: BMP type: RGB565 buffer_size: 16384 on_download_finished: - lambda: 'runtime_tiles::camera_loaded(true, cached);' on_error: - lambda: 'runtime_tiles::camera_failed(true);' # The live pictures of a page's camera tiles (firmware 0.2.77+): one strip of small squares, at most ~70 KB. - id: tile_image url: "http://127.0.0.1/camera.bmp" # set when a page with live camera tiles is on screen format: BMP type: RGB565 buffer_size: 16384 on_download_finished: - lambda: 'runtime_tiles::live_loaded(cached);' on_error: - lambda: 'runtime_tiles::live_failed();' # The touch bus's watchdog (elecrow_usb_phy.h): whatever hands GPIO19/20 back to the USB PHY, the pads are released # again within a second, with one log line per event. interval: - interval: 1s then: - lambda: |- if (elecrow::usb_pads_taken()) { elecrow::release_usb_pads(); ESP_LOGW("touch", "USB PHY took GPIO19/20 back; released again"); } text_sensor: # The sensor every large board has; ESP Screens 0.2.93+ reads the board from its value ("elecrow": a big panel that # draws images and does not turn), so it publishes like the core's diagnostics. An older app only looks for the sensor # and takes any screen that has it for a Guition, the nearest thing. - platform: template name: "Guition screen type" entity_category: diagnostic update_interval: 60s lambda: 'return {"elecrow"};' # Pinout of the CrowPanel 7.0" (DIS08070H, V2.0 and V3.0): Elecrow's wiki and the ESPHome devices page. The V3.0 has a # PCA9557 I/O expander at I2C 0x18 on the GT911's reset (IO0) and interrupt (IO1) lines, and the GT911 stays in reset # until something drives them (firmware 0.2.81: a unit whose touch never answered, "Calibration error" at setup). The # expander gets the same register map as the PCA9554 ESPHome knows, and the GT911 driver does Elecrow's own reset # through it: reset low with the interrupt line low (that picks address 0x5D), reset high, 56 ms. On a V2.0 without # the expander its setup fails, the GT911 boots by its pull-up as before, and the driver still finds it (it tries 0x14 # by itself when 0x5D does not answer). i2c: id: touch_bus sda: GPIO19 scl: GPIO20 frequency: 400kHz # The scan logs what answers on the bus (0x18 the expander, 0x5D or 0x14 the GT911), with `logger: level: DEBUG` in # a screen's Override YAML: which revision a screen is, and whether the bus is alive at all. It runs before the # GT911's reset, so it upsets nothing. scan: true pca9554: - id: touch_expander i2c_id: touch_bus address: 0x18 pin_count: 8 output: - platform: ledc pin: GPIO2 id: gpio_backlight_pwm frequency: 20000Hz light: # The firmware owns the backlight: brightness, standby, night and alerts set it, and the minute tick # restores it. Home Assistant uses the brightness numbers and the Wake and Sleep buttons (0.2.46+). - platform: monochromatic output: gpio_backlight_pwm name: "Display Backlight" id: back_light internal: true restore_mode: ALWAYS_ON default_transition_length: 150ms touchscreen: platform: gt911 id: ts_touch i2c_id: touch_bus # The reset through the expander picks 0x5D; the driver tries 0x14 by itself when that does not answer. address: 0x5D reset_pin: pca9554: touch_expander number: 0 interrupt_pin: pca9554: touch_expander number: 1 display: my_display update_interval: 20ms transform: swap_xy: ${TOUCH_SWAP_XY} mirror_x: ${TOUCH_MIRROR_X} mirror_y: ${TOUCH_MIRROR_Y} on_touch: - lambda: |- id(last_touch_ms) = millis(); id(last_use_ms) = millis(); id(touch_down) = true; cyd::touch_guard.begin(millis(), touch.x, touch.y, touch.id); cyd::edge_swipe.begin(touch.x, touch.y, static_cast(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= 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();