Introducing ESP Screens: beautiful, cheap ESP32 touch screens for every room, managed from Home Assistant

I built ESP Screens: beautiful, cheap touch screens for every room, managed from Home Assistant :pager::sparkles:

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!

Why I built this

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:

  • :artist_palette: They have to look really good. Not a dev board with tiny buttons, but something you actually want in your living room.
  • :wrench: 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.
  • :money_with_wings: 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

  • :puzzle_piece: 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.
  • :control_knobs: 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.
  • :backhand_index_pointing_up: 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.
  • :camera: 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).
  • :sun_behind_small_cloud: 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.
  • :bar_chart: 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.
  • :bell: 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.
  • :crescent_moon: 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.
  • :globe_showing_europe_africa: 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.
  • :electric_plug: 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.
  • :counterclockwise_arrows_button: 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.

And on the tiny 2.8" CYD:

The ESP Screens page inside Home Assistant:

And the vacuum card on the real thing:

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.

Install it

  1. Settings → Apps → Install app (older HA: Settings → Add-ons → Add-on Store)
  2. ⋮ top right → Repositories → add https://github.com/MaxGramser/homeassistant_espscreen
  3. Install ESP Screen Manager, turn on Show in sidebar, start it and open ESP Screens
  4. 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)
  5. Pair the screen under Settings → Devices & services (ESP Screens shows you the key) and allow it to perform Home Assistant actions
  6. 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.

:backhand_index_pointing_right: GitHub - MaxGramser/homeassistant_espscreen: Beautiful, incredibly easy touch screens for Home Assistant on cheap ESP32 panels, 2.8 to 10.1 inch (CYD, Guition, Waveshare): drag-and-drop editor, no YAML, no flashing, no MQTT. · GitHub

I’d love to hear what you think, and which tiles or screens you’d want next!


It looks really handy and user-friendly. Will there be support for other ESP touchscreens in the future, such as the 3.5-inch model?

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.

Hi. Nice project!
Is it multilingual? Or english only? Can it render other characters?

It is now multilingual.

i will look into this! thanks

If you can find a better link with real specs i can look into it!

is there a way for me to have a clean link so i can shop one of these screens? difficult to figure out whether i am buying the right thing or not

Perfect, it works like a charm now. Thanks.

I run several of these ones now!

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)

Any ideas to get this working?

This is a part (SPIs, Display, Touch) of a working YAML for my kind of CYD:


display:
  - platform: mipi_spi
    id: my_display
    model: ILI9342
    spi_id: lcd
    cs_pin: 
      number: GPIO15
      ignore_strapping_warning: true
    dc_pin: 
      number: GPIO2
      ignore_strapping_warning: true
    auto_clear_enabled: false
    invert_colors: false
    dimensions:
      width: 240
      height: 320
      offset_width: 0
      offset_height: 0    
    transform:
      swap_xy: true
      mirror_x: true
      mirror_y: true

touchscreen:
  platform: xpt2046
  id: ts_touch
  spi_id: my_touchscreen
  cs_pin: GPIO33
  interrupt_pin: 36
  update_interval: 20ms
  threshold: 300
  calibration:
    x_min: 340
    x_max: 3860
    y_min: 280
    y_max: 3860
  transform:
    swap_xy: false
    mirror_x: true
    mirror_y: false
		
spi:
  - id: lcd
    clk_pin: GPIO14
    mosi_pin: GPIO13
  - id: my_touchscreen
    clk_pin: GPIO25
    mosi_pin: GPIO32
    miso_pin: GPIO39

ILI9342 vs ILI9341 ???

It looks like quite an interesting project.

2 questions:

  • 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)

Hey! You can look at the issues section on the GitHub link, people have the same issues and have solutions!

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.

Great !

Adding:


substitutions:
  LVGL_ROTATION: "0"

display:
  - id: !extend my_display
    data_rate: 20MHz
    rotation: 0

    dimensions:
      width: 320
      height: 240
      offset_width: 0
      offset_height: 0
      pad_width: 0
      pad_height: 0

to the YAML did it, Thanks for the hint with the GitHub-Issues.

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.

-Eric

Thats amazing! Can you add this as an “issue” on GitHub? than it’s on my list to officially support it

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!

Thanks a lot, appriciated!