I built ESP Screens: beautiful, cheap touch screens for every room, managed from Home Assistant
Update, September 23: a lot has happened since I first posted this. ESP Screens now runs on five screens from 2.8 to 10.1 inch (lying down or standing up), speaks nine languages, shows your cameras and album covers, has Dark mode, full-page tiles, up to eight pages and much more. The post below is updated to match. Full list of changes: What’s new.
Hey all,
I want to share something I’ve been working on for a while now: ESP Screens, a Home Assistant app (add-on) that turns cheap ESP32 touch displays into proper little control screens. You pick your tiles in Home Assistant, hit save, and the screen on the wall updates. No YAML, no reflashing. I love it!
This really started because I wanted everyone in the house to be able to control things easily. Not everyone wants to open the app, find the right dashboard and scroll to the right light. I wanted a small screen next to the door or on the kitchen counter that just works.
So I had three wishes:
They have to look really good. Not a dev board with tiny buttons, but something you actually want in your living room.
They have to be easy to maintain. Changing a tile shouldn’t mean opening YAML, recompiling and walking to the screen with a USB cable.
They have to be cheap. Cheap enough to put one in every room.
I found lots of great ESPHome display configs, but always ended up editing YAML per screen and reflashing for every little change. So I built the thing I wanted.
What it actually does
Lay out your screen in Home Assistant. Search by entity, device or room and drag tiles into a live preview of the screen, with the values Home Assistant reports right now. Up to eight pages, as many tiles per page as the glass holds. Drag a whole page to reorder it. Save & send to screen and it’s there, without flashing.
Real controls, not just toggles. Brightness, colour and white temperature for lights (and an effects page for WLED), a thermostat card, vacuum with mode, suction and water, blinds and curtains with tilt, fans, a media card with album cover, numbers, selects, scenes and scripts. Double-width tiles get sliders and keys right in the tile, like the rows in Home Assistant.
Tap does what you want. Switch it, open its card, or run any action Home Assistant has for it (curtains to 50 %, for example). A tile can fill the whole page as one big switch you push without looking, or open another page.
Cameras. Any camera or doorbell snapshot as a tile, with a small live picture on the tile and full screen on a tap (every screen but the CYD).
Special tiles: a digital or analog clock with the date, a weather forecast, a history graph you can read with your finger, the sun path, a kitchen timer and who’s home.
A top bar per screen with the room name, a home key back to page 1, and whatever you want next to it: time, outdoor temperature, who’s home, or an open door that only shows up when it’s actually open.
Full-screen alerts from an automation. One event puts a card over the screen with an icon, colour, button and optionally the doorbell camera’s picture, wakes it up and flashes the backlight. An automation can also open a page, like the music page when something starts playing.
Dark mode and settings on the screen. Hold the top bar for brightness, Dark mode, standby and night hours. Every setting is also an entity in Home Assistant, so an automation can turn on Dark mode at bedtime.
In your language. English, Nederlands, Deutsch, Français, Italiano, Español, Português and Polski, taken from your Home Assistant, with your own date, time and number format.
Installing is one window. Plug the screen into your Home Assistant machine with USB, click New screen, give it a name, Install. HA in a VM or on a server? Download the firmware and flash it from your own computer with ESPHome Web. The ESPHome CLI is built in, so you don’t even need the ESPHome Device Builder.
Updates itself. New firmware shows up per screen with an Update button, or tick the box and it updates over Wi-Fi at night, one screen at a time. Your own Override YAML per screen survives every update.
Also: no MQTT, no blueprint and no long-lived token needed. It all goes through the normal ESPHome integration.
What it looks like
These are rendered from the firmware’s own LVGL code with a demo home, so this is exactly what you get on the screen.
More screenshots (cameras, media, Dark mode, all five boards to scale) are in the README.
Which screens?
Screen
Size
Tiles per page
CYD ESP32-2432S028 (ILI9341 + XPT2046)
2.8", 320 × 240, resistive touch
2 × 3
Guition ESP32-S3-4848S040
4", 480 × 480, capacitive touch
2 × 3
Waveshare ESP32-S3-Touch-LCD-4.3
4.3", 800 × 480, capacitive touch
3 × 3
Waveshare ESP32-S3-Touch-LCD-7 (experimental)
7", 800 × 480, capacitive touch
4 × 4
Guition JC8012P4A1 (ESP32-P4, new)
10.1", 1280 × 800, capacitive touch
5 × 4
A tile stays about the same size in millimetres, so the bigger the glass, the more fits on a page. Every screen that isn’t square can also be built standing up.
Please buy exactly these variants, there are lookalikes with different display controllers out there. The 4" Guition is my favourite: a really nice square screen made to go in a wall box, and several of them have been on my walls for months without a hiccup. I buy it here on AliExpress (if that link dies, search for “ESP32-S3-4848S040” and check for 480 × 480, ST7701S and GT911). The CYD is the cheapest way in at around €15.
⋮ top right → Repositories → add https://github.com/MaxGramser/homeassistant_espscreen
Install ESP Screen Manager, turn on Show in sidebar, start it and open ESP Screens
Plug your screen into the Home Assistant machine with a USB data cable, click New screen, pick the board and Install (or Download if HA can’t reach the screen over USB)
Pair the screen under Settings → Devices & services (ESP Screens shows you the key) and allow it to perform Home Assistant actions
Pick your tiles and Save & send to screen
Works on Home Assistant OS on aarch64 and amd64, and there’s a Docker route for Home Assistant Container. The first build takes a few minutes on a Raspberry Pi, after that it’s quick.
Full docs, the alert action, the top bar and troubleshooting are all in the repo.
Hi, congratulations on the project—it’s very well made and structured—but I have a small problem: the entity group selectors won’t “move.” I tried selecting them with the mouse and dragging them to set the others, but nothing moves.
Hello,
I got an Deja-Vu, when the display boots up the first time. The content is scrambled and unreadable.
In a previous project in ESPHome I ran into the same problem and got it solved by editing the display component. (ILI9342)
How does it handle sleep or being away?
– I am not keen on the display being on all the time, so will the screen autosleep after some time until a touch, notification, or something?
Does it have any ability to read onboard sensor if somehow is infront of the screen? (Or does that need to be triggered by HA)
Almost all options you mention are available, auto sleep, night mode, and control from home assistant available, I have a wake action linked to my motion sensor that I already have in the room. Then after 5 mins it goes to auto sleep.
I recently purchased the Hosyond 4in CYD screen before I realized it wasn’t supported.
So I worked out creating a new board config for it.
packages/boards/hosyond-esp32-4in.yaml
############################################################
# ESP Screens - Hosyond CYD board (ESP32-32E, "Cheap Yellow Display")
# Built-in ST7796 320x480 display and XPT2046 resistive touch.
#
# What is in here: the hardware (display, touch, backlight, LED), the sizes the
# shared tree takes from this board, the code the shared lambdas take from this
# board (hooks), and what only a CYD has: the calibration wizard for its
# resistive glass and a page swipe by LVGL's gesture. 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.
#
# Entry file include this: packages/hosyond.yaml (the package a screen builds from over GitHub).
############################################################
# The cards of this board's grid: one per cell, from the file for 6 cells
# (tools/generate_cells.py writes them; docs/RESPONSIVE.md says why).
packages:
cells: !include ../cells/6.yaml
substitutions:
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "56"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
TOUCH_MIN_PRESS_MS: "60"
# A slider let go this close to the left or right edge of the glass meant its end when that end lies in
# the band. The resistive panel reports a finger up to the bezel, so only the strip's last pixels and
# the margin beside them count (a capacitive Guition loses a fast finger 30-50 px before its edge).
SLIDER_EDGE_SNAP_PX: "20"
# The key of this board file; the firmware reports it so ESP Screens knows what board a screen is.
BOARD_ID: "cyd"
# This board's backlight takes levels (a PWM pin), so brightness is a percentage.
BACKLIGHT_DIMMABLE: "true"
# The screen can go dark: standby and night dim it, and Sleep and Wake exist.
CAN_STANDBY: "true"
DEVICE_NAME: "cyd-new"
DEVICE_FRIENDLY_NAME: "My CYD"
TOUCH_AFFINE_XX: "1.000000000"
TOUCH_AFFINE_XY: "0.000000000"
TOUCH_AFFINE_XC: "0.000000000"
TOUCH_AFFINE_YX: "0.000000000"
TOUCH_AFFINE_YY: "1.000000000"
TOUCH_AFFINE_YC: "0.000000000"
# ORIENTATION
# The panel's own pixels. The display block below always drives it at this size; LVGL turns the picture and
# the touch together, so nothing else on this board knows which way the glass hangs.
PANEL_W: "480"
PANEL_H: "320"
# The LVGL angle that lays this panel out lying down (320 x 480). Standing up is a quarter further.
ROTATION_LANDSCAPE: "90"
# Which way this screen hangs. ESP Screens writes it when the screen is built, the way it writes the language,
# and it is the only line that differs between a screen lying down and the same screen standing up.
LVGL_ROTATION: "${ROTATION_LANDSCAPE}"
TOUCH_SWAP_XY: "false"
# true on this board (resistive XPT2046) — was false on the 032c board
# (capacitive GT911). This is the original upstream value for this panel.
TOUCH_MIRROR_X: "false"
TOUCH_MIRROR_Y: "false"
TOUCH_CAL_X_MIN: "200"
TOUCH_CAL_X_MAX: "3900"
TOUCH_CAL_Y_MIN: "200"
TOUCH_CAL_Y_MAX: "3900"
# The tile grid: columns and rows of cells, the side margin and the gaps between cells. The shared tree places
# every cell from these (runtime_tiles::place_page), so a board with more room states more columns or rows.
GRID_COLS: "2"
GRID_ROWS: "3"
# The cells of a page when this screen stands up (firmware 0.2.92+).
# The 2.8-inch glass is 43 x 57 mm standing up: one column of four cards, each 39 x 10 mm.
GRID_COLS_PORTRAIT: "1"
GRID_ROWS_PORTRAIT: "4"
GRID_MARGIN: "9"
GRID_GAP_X: "8"
GRID_GAP_Y: "4"
DISPLAY_DPI: "143" # diagonal pixels / diagonal inches; the look's reference density on this board
LOOK: "compact"
SCROLL_Y: "40"
HEADER_Y: "14"
# Holding the top bar opens the settings page (0.2.44+). The whole strip counts on this board;
# a finger that travels further than the drift limit of a tap is swiping, not holding.
SETTINGS_HOLD_X: "0"
SETTINGS_HOLD_DRIFT_PX: "24"
ALERT_CARD_W: "292"
ALERT_CARD_H: "196"
ALERT_ICON_X: "18"
ALERT_ICON_Y: "16"
ALERT_TEXT_X: "62"
ALERT_TEXT_W: "212"
ALERT_TITLE_Y: "16"
ALERT_TITLE_H: "21" # one line of headline; a DOT label only ends in an ellipsis with a height
ALERT_SUBTITLE_Y: "44"
ALERT_SUBTITLE_H: "88"
ALERT_BUTTON_W: "100"
ALERT_BUTTON_H: "40"
ALERT_BUTTON_INSET: "14"
ALERT_TITLE_MAX: "48" # bytes; the label ends one line with an ellipsis
ALERT_BUTTON_MAX: "12" # bytes on the OK button
ALERT_SUBTITLE_MAX: "160" # bytes; wraps over the rows the card has
FONT_TIME_SIZE: "18" # the clock in the top bar
FONT_HEADLINE_SIZE: "18" # card titles
FONT_WATCH_VALUE_SIZE: "22"
FONT_CLOCK_DIGITS_SIZE: "24"
FONT_SETPOINT_SIZE: "40" # the climate card's setpoint
FONT_LABEL_SIZE: "11" # tile titles
FONT_SUBLABEL_SIZE: "11" # tile values and small text
FONT_SUBLABEL_BIG_SIZE: "14" # the top bar and settings rows
FONT_ICON_SIZE: "28" # tile icons
FONT_ICON_MINI_SIZE: "18" # the top bar and card keys
FONT_ICON_HOME_SIZE: "25" # the home key in the top bar
FONT_ICON_BIG_SIZE: "40" # full-page tiles
FONT_WATCH_ICON_SIZE: "12"
# Tiles and the top bar
TILE_RADIUS: "18" # corner of a tile
TILE_PAD: "8" # inside a tile
TILE_ICON_SIZE: "36" # the icon circle
HEADER_INSET: "11" # the room name and the clock from the glass edge
# The page bar under the tiles
PAGE_BAR_H: "36" # the page bar
PAGE_NUMBER_W: "104" # the dots in the middle
PAGE_CHEVRON_INSET: "14" # the chevrons from the outer edge
# Cards: the round keys and the brightness card
CARD_KEY_X: "10" # a card's round key from the side
CARD_KEY_Y: "8" # from the top
CARD_KEY_SIZE: "40" # its diameter
COLOR_TITLE_W: "210" # the colour card's title
COLOR_TITLE_Y: "21"
# The touch test page
TOUCH_TEST_PAGE_PAINT: "paint_calibration" # the page
TOUCH_TEST_TITLE: "calibration_title" # the words at the top
TOUCH_TEST_TITLE_FONT: "sublabel"
TOUCH_TEST_TITLE_Y: "65"
TOUCH_TEST_INSET: "10" # the corner crosses from the edge
TOUCH_TEST_SIZE: "20" # a cross
TOUCH_TEST_PAINT: "paint_calibration" # the crosses
# What the shared scripts do differently on this board
LVGL_DEFAULT_FONT: "sublabel_big" # LVGL's default font: every label names its own, so this only keeps Montserrat out of the build
LVGL_BUFFER_SIZE: "12%" # LVGL's draw buffer as a share of the screen
PAGE_DOTS_LARGE: "false" # bigger page dots
EFFECTS_ROW_FONT: "sublabel_big" # the effects page's rows
STANDBY_DIM_MS: "300" # how long the backlight takes to dim into standby (waking takes 300)
ALERT_FLASH_FADE_MS: "0" # an alert's flash: the fade of each step
ALERT_FLASH_DARK_MS: "120" # how long it stays dark
ALERT_FLASH_LIT_MS: "170" # 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: |-
// Per-panel affine correction; shared defaults are neutral.
id(ts_touch).set_raw_correction(${TOUCH_AFFINE_XX}f, ${TOUCH_AFFINE_XY}f, ${TOUCH_AFFINE_XC}f, ${TOUCH_AFFINE_YX}f, ${TOUCH_AFFINE_YY}f, ${TOUCH_AFFINE_YC}f);
cyd::touch_guard.configure(${TOUCH_MOVE_LIMIT_PX}, ${TOUCH_MIN_PRESS_MS});
cyd::edge_snap_band = ${SLIDER_EDGE_SNAP_PX};
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_calibration), Paint::calibration);"
# in runtime_tiles::dismiss, after the detail card closes
CLOSE_CARDS_HOOK: ""
# what LVGL's gesture over the tiles does on this board
BOOT_PAGE_GESTURE: |-
lv_obj_add_event_cb(id(home_page)->obj, [](lv_event_t *event) {
if(!runtime_tiles::enabled || !runtime_tiles::swipe_pages || id(display_dimmed) || id(calibration_active) ||
!id(active_entity).empty() || (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root,LV_OBJ_FLAG_HIDDEN)))return;
auto *input=lv_indev_active();if(!input)return;
auto direction=lv_indev_get_gesture_dir(input);
if(direction!=LV_DIR_LEFT && direction!=LV_DIR_RIGHT && direction!=LV_DIR_TOP)return;
// A quick drag on a slider is that slider's, never a page swipe (firmware 0.2.68+).
if(runtime_tiles::captured_slider){ESP_LOGI("touch","page swipe ignored: a slider is being dragged");return;}
cyd::touch_guard.consume();
lv_indev_wait_release(input);
// Up is the way home (firmware 0.2.100+); this board has no edge band, so a flick anywhere over the tiles
// does it, as left and right do here.
if(direction==LV_DIR_TOP){
ESP_LOGI("touch","swipe up: back to page 1");
runtime_tiles::swipe_glow(runtime_tiles::Edge::bottom);
if(runtime_tiles::back_home)runtime_tiles::back_home();
return;
}
// The edge the page comes from lights up for a moment (firmware 0.2.100+): swiping left brings in the next
// page from the right.
runtime_tiles::swipe_glow(direction==LV_DIR_LEFT?runtime_tiles::Edge::right:runtime_tiles::Edge::left);
ESP_LOGI("touch","page swipe: page %d -> %d",id(tile_page),id(tile_page)+(direction==LV_DIR_LEFT?1:-1));
id(tile_page)+=direction==LV_DIR_LEFT?1:-1;
id(show_tile_page).execute();
}, LV_EVENT_GESTURE, nullptr);
# after the gesture: the camera images of a board that shows them
BOOT_CAMERA_HOOKS: ""
# 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: |-
if (!runtime_tiles::enabled) return;
screen_calibration::apply = [](const screen_calibration::Calibration &cal) {
id(ts_touch).set_raw_correction(cal.c[0],cal.c[1],cal.c[2],cal.c[3],cal.c[4],cal.c[5]);
};
screen_calibration::isolation = [](bool active) { id(calibration_active) = active; };
screen_calibration::setup(id(home_page)->obj, id(sublabel)->get_lv_font(), id(headline)->get_lv_font(),
${TOUCH_CAL_X_MIN}, ${TOUCH_CAL_X_MAX}, ${TOUCH_CAL_Y_MIN}, ${TOUCH_CAL_Y_MAX});
// The wizard runs by itself the first time this panel starts; this is the way back to it (firmware 0.2.96+),
// the same one the Calibrate touch button in Home Assistant takes. Binding it is also what puts Calibrate
// touch on the settings page: a board that builds no wizard binds nothing and has no row, so the two can
// never disagree. The page closes first, as its own back arrow closes it: the wizard loads a screen of its
// own and leaves the home page behind it when it is done.
settings_screen::calibrate_touch = []() { settings_screen::close(); screen_calibration::begin(); };
# what else keeps the settings page closed (inline, in the condition)
MAY_OPEN_EXTRA: ""
# the log line of a boot onto the touch test page
TOUCH_TEST_READY_LOG: "USB calibration ready; no tile actions"
# one more line of the ui_state diagnostics
UI_STATE_HOOK: ""
# every 250 ms after runtime_tiles::tick()
TICK_HOOK: ""
# apply_screen_settings: how the backlight takes its level
APPLY_BACKLIGHT: "id(back_light).turn_on().set_transition_length(300).set_brightness(level / 100.0f).perform();"
# back_to_page_1_when_due: what else counts as away from page 1 (inline)
AWAY_EXTRA: ""
# alert_show: after the settings page closed, before the card is made
ALERT_SHOW_HOOK: ""
# ============================================================
# No need to edit below this line.
# ============================================================
esp32:
board: esp32dev
framework:
type: esp-idf
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
# ESPHome recommends this itself in the boot log. Yields ~40KB extra IRAM
# and a smaller binary, and every KB counts on this board.
sram1_as_iram: true
# minimum_chip_revision deliberately left out: that was here for the
# 032c board (rev 3.1 confirmed), but this is a different physical board
# with an unknown chip revision. With an unjustifiably high minimum
# the firmware simply refuses to boot.
version: recommended
variant: esp32
# Two SPI buses used by the CYD:
# - lcd: built-in display
# - my_touchscreen: built-in touch controller
# Two SPI buses on the 2432S028: one for the display, a separate one for the
# resistive XPT2046 touch (no I2C on this board, unlike the 032c).
spi:
- id: lcd
clk_pin: GPIO14
mosi_pin: GPIO13
miso_pin: GPIO12
output:
- platform: ledc
pin: GPIO27 # Updated for Hosyond board. Was GPIO21
id: gpio_backlight_pwm
# ESPHome's ledc default is 1kHz. You don't see that while still (well
# above what the eye perceives as flicker), but moving the screen reveals
# motion blur turning the on/off pulses into streaks (the same effect as
# a PWM LED that "judders" when you move it). 20kHz sits well above the
# speed at which motion still makes that visible, and still leaves ample
# duty-cycle resolution left over for smooth dimming.
frequency: 20000Hz
- platform: ledc
id: output_red
pin: GPIO4
inverted: true
- platform: ledc
id: output_green
pin: GPIO16
inverted: true
- platform: ledc
id: output_blue
pin: GPIO17
inverted: true
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: "Power Display Backlight"
id: back_light
internal: true
restore_mode: ALWAYS_ON
- platform: rgb
name: LED
red: output_red
id: led
green: output_green
blue: output_blue
restore_mode: ALWAYS_OFF
# Touchscreen configuration (XPT2046, resistive, via SPI)
# XPT2046 calibration is board-specific. Poll at 50Hz; LVGL rotates touch
# with the display. GPIO36 IRQ is deliberately unused (driver requests a
# pull-up that this input-only pin cannot provide).
touchscreen:
platform: xpt2046
id: ts_touch
spi_id: lcd
cs_pin: 33
# Polling avoids GPIO36 pull-up errors and noisy PENIRQ edges.
update_interval: 20ms
# Diagnosis caught a false corner contact at pressure 422. Deliberate
# contacts measured 936-2643; reject the low-pressure tail on release.
threshold: 800
calibration:
x_min: ${TOUCH_CAL_X_MIN}
x_max: ${TOUCH_CAL_X_MAX}
y_min: ${TOUCH_CAL_Y_MIN}
y_max: ${TOUCH_CAL_Y_MAX}
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);
// The point the screen itself works with, through ESPHome's own turn (the same call the tiles are hit
// with): the wizard fits against that, so it never has to work out what the turn does.
int screen_x = touch.x, screen_y = touch.y;
if (runtime_tiles::touch_input::to_screen) runtime_tiles::touch_input::to_screen(screen_x, screen_y);
screen_calibration::press(touch.id, screen_x, screen_y, id(ts_touch).filtered_raw_x(), id(ts_touch).filtered_raw_y());
ESP_LOGI("touch", "press native=%u,%u screen=%d,%d raw=%d,%d pressure=%d calibration=%d", touch.x, touch.y, screen_x, screen_y, id(ts_touch).filtered_raw_x(), id(ts_touch).filtered_raw_y(), touch.z_raw, 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) {
cyd::touch_guard.update(point.x, point.y, point.id);
// The wizard keeps both halves of a reading from the same instant (firmware 0.2.97+): where the screen
// puts this point, and what the panel is sending right now.
int screen_x = point.x, screen_y = point.y;
if (runtime_tiles::touch_input::to_screen) runtime_tiles::touch_input::to_screen(screen_x, screen_y);
screen_calibration::hold(point.id, screen_x, screen_y, id(ts_touch).filtered_raw_x(), id(ts_touch).filtered_raw_y());
}
on_release:
- lambda: |-
id(last_touch_ms) = millis();
id(last_use_ms) = millis();
id(touch_down) = false;
screen_calibration::release();
# Display configuration (ILI9341)
display:
- platform: mipi_spi
id: my_display
spi_id: lcd
model: ST7796
data_rate: 40MHz
cs_pin: GPIO15
dc_pin: GPIO2
invert_colors: false
update_interval: never
auto_clear_enabled: false
dimensions:
width: 320
height: 480
lvgl:
id: screen_lvgl
style_definitions:
# The calibration wizard's page and crosses (theme::paints fills it: BOARD_PAINT_FILL).
- id: paint_calibration
# Turning the screen as a Home Assistant choice (firmware 0.2.80+). This glass is not square, so it offers the half turn; a quarter turn would swap its width
# and height and change how many rows and columns fit. The shared tree applies the
# angle (packages/core.yaml); a list of options cannot come from a substitution, which is why every board carries this
# block and tools/check_packages.py keeps the three alike.
select:
- platform: template
id: setting_rotation
name: "Rotation"
icon: "mdi:screen-rotation"
entity_category: config
options: ["0°", "180°"]
update_interval: never
lambda: 'return std::to_string(settings_screen::rotation) + "°";'
set_action:
- lambda: |-
const std::string option(x.c_str(), x.size());
settings_screen::set("rotation", atoi(option.c_str()));
button:
- platform: template
name: "Calibrate touch"
internal: ${DASHBOARD_INBOX_INTERNAL}
entity_category: config
on_press:
- lambda: 'if (runtime_tiles::enabled) screen_calibration::begin();'
script:
# 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;
// One card per cell of this board's grid: it is on screen when the page has a tile for its cell.
bool ok = true;
for (auto &w : runtime_tiles::widgets) {
bool visible = w.index < runtime_tiles::model.count;
ok = ok && w.tile && (lv_obj_has_flag(w.tile, 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), (int) grid.y2, (int) nav.y1);
id(tile_page) = 0;
- script.execute: show_tile_page
- delay: 150ms
- lvgl.widget.show: color_detail_overlay
- delay: 150ms
- lvgl.widget.hide: color_detail_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 as a light action; the Guition drives it through its set_backlight script.
- 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();
ESP_LOGI("alert", "dismissed: %s", reason.c_str());
- lvgl.widget.hide: alert_overlay
- script.stop: alert_timeout
- script.stop: alert_flash
- light.turn_on:
id: back_light
transition_length: 80ms
brightness: !lambda "return screen_settings::current.brightness / 100.0f;"
- 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:
- light.turn_off:
id: back_light
transition_length: 0s
- delay: 120ms
- light.turn_on:
id: back_light
transition_length: 0s
brightness: !lambda "return screen_settings::current.brightness / 100.0f;"
- delay: 170ms
- id: wake_display
mode: restart
then:
- lambda: |-
id(last_touch_ms) = millis();
id(display_dimmed) = false;
id(sleep_requested) = false;
- light.turn_on:
id: back_light
# Almost instant brightness-up. ESPHome's default is 1s, and that feels sluggish
# when you touch the screen and are waiting on light.
transition_length: 80ms
brightness: !lambda "return screen_settings::current.brightness / 100.0f;"
- lvgl.widget.hide: dim_wake_overlay
- script.execute: close_cards
packages/hosyond.yaml
# ESP Screens - the package a Hosyond CYD (ESP32-32E) builds from over GitHub. ESP Screen Manager writes every
# screen's own YAML with `packages: display: {url: <this repository>, files: [packages/cyd.yaml]}` and adds the
# name, the Wi-Fi reference and the unique keys (docs/EASY_SETUP.md); nothing of that lives here.
#
# The screen is two packages: core.yaml, which every board shares (the LVGL tree, the cards, the scripts, the
# entities), and boards/hosyond-esp32-4in.yaml with this board's hardware and sizes. docs/PROFILES.md says how the
# files fit together.
substitutions:
FONT_DIR: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts"
packages:
core: !include core.yaml
board: !include boards/hosyond-esp32-4in.yaml
external_components:
- source:
type: git
url: https://github.com/MaxGramser/homeassistant_espscreen.git
ref: main
path: components
refresh: 0s
components: [xpt2046, smart_display]
With these new configs the screen is fully usable. I tried to go through and clean up the comments to reflect the new board.
Thanks, seems great!
Few questions please:
Initial connection is done via USB cable and later on it’s controlled/connected via WiFi?
So the only connection each monitor needs is to an elecricity?
Did you verify it’s works under docker? how the USB connection works in that case?
Initial connection via USB to flash: correct
then via wifi; only power needed. (also firmware updates go trough wifi)
docker is supported, more info on that is on the documentation on Github!