############################################################ # SmartDisplay (Cheap Yellow Display / CYD) – ESPHome + LVGL lockscreen UI # # Target hardware # - Cheap Yellow Display (CYD) # (ESP32 + ILI9341 320x240 display + XPT2046 touchscreen) # # Purpose # - LVGL renders a simple “lockscreen” UI: time/date + 4 toggle buttons. # # Touch input → Home Assistant # - Every touch publishes an “action” string via a text_sensor: # sensor.smartdisplay_action = "btn1_press" / "btn2_press" / "btn3_press" / "btn4_press" # - The sensor is auto-reset to "" after each press so repeated presses trigger reliably. # # Two supported control modes (choose what fits your setup) # 1) Direct control (DEFAULT, simplest) # - DIRECT_ACTIONS: "true" (default) # - Each touch will: # • publish the action string AND # • directly call a Home Assistant service # (default: light.toggle on BTN*_ENTITY) # - Use this if you simply want to toggle lights without creating automations. # # 2) Automation-only control (custom logic) # - DIRECT_ACTIONS: "false" # - Each touch will ONLY publish the action string. # - You implement the behavior in Home Assistant automations using: # Trigger → State → sensor.smartdisplay_action → To: btn1_press (etc.) # # If you do NOT want to toggle lights # - Either change the homeassistant.action blocks to a different service # (e.g. switch.toggle, script.turn_on, scene.turn_on, ...) # - Or set DIRECT_ACTIONS to "false" and handle everything via HA automations. # # UI updates # - On boot, every minute, and whenever the configured HA entities change # (mirrored into ESPHome to keep LVGL button states in sync). # # What you must adapt before flashing # 1) Secrets: api.encryption.key, ota.password, WiFi credentials (prefer secrets.yaml) # 2) BTN*_ENTITY: Home Assistant entity IDs you want to control/mirror # 3) Touch calibration + transform: # - calibration is panel-specific # - touchscreen.transform MUST match display.transform exactly # # Home Assistant note # - In the ESPHome device settings, enable: # “Allow the device to perform Home Assistant actions” # Otherwise homeassistant.action calls are blocked. ############################################################ # ========================================================== # USER CONFIG (edit this section to match your HA setup) # # - DIRECT_ACTIONS: # "true" -> touch publishes action + calls HA service (light.toggle by default) # "false" -> touch only publishes action (all logic handled in HA) # # - BTN*_ENTITY: # The Home Assistant entity to control and mirror for the button state. # Must exist in Home Assistant. # # - BTN*_LABEL / BTN*_ICON: # Only affects what is shown on the display. # ========================================================== substitutions: # Enable/disable direct Home Assistant service calls on touch. # - "true" -> touching a button also calls light.toggle (plug & play) # - "false" -> touching a button ONLY publishes the action text_sensor DIRECT_ACTIONS: "true" # Button 1 BTN1_ENTITY: "light.desk_lamp" BTN1_LABEL: "Desk" BTN1_ICON: "\U000F095F" # Button 2 BTN2_ENTITY: "light.ceiling_light" BTN2_LABEL: "Ceiling" BTN2_ICON: "\U000F0769" # Button 3 BTN3_ENTITY: "light.globe_lamp" BTN3_LABEL: "Globe" BTN3_ICON: "\U000F08DD" # Button 4 BTN4_ENTITY: "light.ambient_light" BTN4_LABEL: "Ambient" BTN4_ICON: "\U000F1051" # ========================================================== # ============================================================ # No need to edit below this line. # All configuration is done via the substitutions above. # Credentials are managed in secrets.yaml (see secrets.yaml.example). # ============================================================ esphome: name: smartdisplay friendly_name: SmartDisplay on_boot: priority: 600 then: - script.execute: ui_refresh esp32: board: esp32dev framework: type: arduino logger: # Credentials are loaded from secrets.yaml — copy secrets.yaml.example to secrets.yaml and fill in your values. api: encryption: key: !secret smartdisplay_api_key ota: - platform: esphome password: !secret ota_password wifi: ssid: !secret wifi_ssid password: !secret wifi_password ap: ssid: "Smartdisplay Fallback Hotspot" password: !secret wifi_ap_password captive_portal: # Use Home Assistant time so the display always matches your HA timezone. time: - platform: homeassistant id: homeassistant_time on_time: - seconds: 0 minutes: /1 then: - script.execute: ui_refresh font: - file: "gfonts://Roboto@500" id: big_text size: 60 bpp: 4 glyphs: ['&','@','!',',','.','?','"','%','(',')','+','-','_',':','°','0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z','å','Ä','ä','Ö','ö','Ü','ü','/'] - file: "gfonts://Roboto@300" id: notice_text size: 16 bpp: 4 glyphs: ['&','@','!',',','.','?','"','%','(',')','+','-','_',':','°','0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z','å','Ä','ä','Ö','ö','Ü','ü','/'] - file: "gfonts://Roboto@400" id: label size: 13 bpp: 4 glyphs: ['&','@','!',',','.','?','"','%','(',')','+','-','_',':','°','0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z','å','Ä','ä','Ö','ö','Ü','ü','/'] # Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0) # Source: https://github.com/Templarian/MaterialDesign-Webfont - file: 'fonts/materialdesignicons-webfont.ttf' id: materialdesign_icons size: 35 glyphs: ["\U000F095F", "\U000F0769", "\U000F08DD", "\U000F1051"] color: - id: ACTIVE hex: "FEC600" - id: INACTIVE hex: "808080" sensor: - platform: wifi_signal name: "Wifi Signal" update_interval: 600s - platform: uptime name: "Uptime" id: uptime_s update_interval: 15s binary_sensor: - platform: status name: "Node Status" id: system_status # Mirror HA entities (used to render the "checked" state of each LVGL button) # These are read-only "views" of HA state for UI sync. - platform: homeassistant id: ha_state_btn1 entity_id: ${BTN1_ENTITY} on_state: then: - script.execute: ui_refresh - platform: homeassistant id: ha_state_btn2 entity_id: ${BTN2_ENTITY} on_state: then: - script.execute: ui_refresh - platform: homeassistant id: ha_state_btn3 entity_id: ${BTN3_ENTITY} on_state: then: - script.execute: ui_refresh - platform: homeassistant id: ha_state_btn4 entity_id: ${BTN4_ENTITY} on_state: then: - script.execute: ui_refresh text_sensor: # ---------------------------------------------------------- # SmartDisplay Action # # This is the main "touch trigger" exported to Home Assistant: # - On touch, we publish one of: # btn1_press / btn2_press / btn3_press / btn4_press # - Then we auto-reset to "" so repeated presses still trigger. # # In HA automations, use a simple state trigger: # - Entity: sensor.smartdisplay_action # - To: btn1_press (etc.) # ---------------------------------------------------------- - platform: template id: smartdisplay_action name: "SmartDisplay Action" icon: mdi:gesture-tap-button - platform: template name: "Uptime (formatted)" lambda: |- uint32_t dur = (uint32_t) id(uptime_s).state; int dys = 0; int hrs = 0; int mnts = 0; if (dur >= 86400) { dys = dur / 86400; dur = dur - (dys * 86400); } if (dur >= 3600) { hrs = dur / 3600; dur = dur - (hrs * 3600); } if (dur >= 60) { mnts = dur / 60; dur = dur - (mnts * 60); } static char buffer[20]; snprintf(buffer, sizeof(buffer), "%dd %02dh %02dm %02ds", dys, hrs, mnts, dur); return {buffer}; icon: mdi:clock-start update_interval: 60s switch: - platform: restart name: "Restart" # Two SPI buses: # - lcd: display # - my_touchscreen: touch controller spi: - id: lcd clk_pin: GPIO14 mosi_pin: GPIO13 - id: my_touchscreen clk_pin: GPIO25 mosi_pin: GPIO32 miso_pin: GPIO39 output: - platform: ledc pin: GPIO21 id: gpio_backlight_pwm - 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: - platform: monochromatic output: gpio_backlight_pwm name: "Power Display Backlight" id: back_light 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) # IMPORTANT: # - calibration values are device-specific # - touchscreen.transform must match display.transform exactly touchscreen: platform: xpt2046 id: ts_touch spi_id: my_touchscreen cs_pin: 33 interrupt_pin: 36 update_interval: 50ms threshold: 400 calibration: x_min: 280 x_max: 3860 y_min: 340 y_max: 3860 transform: swap_xy: true mirror_x: false mirror_y: false # Display configuration (ILI9341) display: - platform: mipi_spi id: my_display spi_id: lcd model: ILI9341 cs_pin: 15 dc_pin: 2 invert_colors: false update_interval: never auto_clear_enabled: false transform: swap_xy: true mirror_x: false mirror_y: false dimensions: width: 320 height: 240 lvgl: displays: - my_display touchscreens: - touchscreen_id: ts_touch theme: obj: bg_color: 0x000000 bg_opa: COVER button: radius: 30 width: 60 height: 60 border_width: 0 shadow_width: 0 bg_color: !lambda return id(INACTIVE); bg_opa: COVER checked: bg_color: 0xFFFFFF bg_opa: COVER pages: - id: lockscreen_page bg_color: 0x000000 bg_opa: COVER widgets: - label: id: lbl_date x: 0 y: 30 width: 320 text_align: CENTER text_font: notice_text text_color: 0xFFFFFF text: "" - label: id: lbl_time x: 0 y: 55 width: 320 text_align: CENTER text_font: big_text text_color: 0xFFFFFF text: "" # --------------------------------------------------- # LVGL Button 1 # - ALWAYS publishes a HA-friendly action: btn1_press # - Optional direct action: light.toggle for BTN1_ENTITY # --------------------------------------------------- - button: id: btn1 x: 25 y: 140 checkable: true widgets: - label: align: CENTER text_font: materialdesign_icons text_color: !lambda return id(ACTIVE); text: "${BTN1_ICON}" on_click: # ALWAYS: publish action to Home Assistant (state trigger friendly) - script.execute: id: publish_action action: "btn1_press" # OPTIONAL: direct HA service call (plug & play) - if: condition: lambda: |- return std::string("${DIRECT_ACTIONS}") == "true"; then: - homeassistant.action: action: light.toggle data: entity_id: ${BTN1_ENTITY} - label: x: 25 y: 205 width: 60 text_align: CENTER text_font: label text_color: 0xFFFFFF text: "${BTN1_LABEL}" # --------------------------------------------------- # LVGL Button 2 # - ALWAYS publishes a HA-friendly action: btn2_press # --------------------------------------------------- - button: id: btn2 x: 95 y: 140 checkable: true widgets: - label: align: CENTER text_font: materialdesign_icons text_color: !lambda return id(ACTIVE); text: "${BTN2_ICON}" on_click: - script.execute: id: publish_action action: "btn2_press" - if: condition: lambda: |- return std::string("${DIRECT_ACTIONS}") == "true"; then: - homeassistant.action: action: light.toggle data: entity_id: ${BTN2_ENTITY} - label: x: 95 y: 205 width: 60 text_align: CENTER text_font: label text_color: 0xFFFFFF text: "${BTN2_LABEL}" # --------------------------------------------------- # LVGL Button 3 # - ALWAYS publishes a HA-friendly action: btn3_press # --------------------------------------------------- - button: id: btn3 x: 165 y: 140 checkable: true widgets: - label: align: CENTER text_font: materialdesign_icons text_color: !lambda return id(ACTIVE); text: "${BTN3_ICON}" on_click: - script.execute: id: publish_action action: "btn3_press" - if: condition: lambda: |- return std::string("${DIRECT_ACTIONS}") == "true"; then: - homeassistant.action: action: light.toggle data: entity_id: ${BTN3_ENTITY} - label: x: 165 y: 205 width: 60 text_align: CENTER text_font: label text_color: 0xFFFFFF text: "${BTN3_LABEL}" # --------------------------------------------------- # LVGL Button 4 # - ALWAYS publishes a HA-friendly action: btn4_press # --------------------------------------------------- - button: id: btn4 x: 235 y: 140 checkable: true widgets: - label: align: CENTER text_font: materialdesign_icons text_color: !lambda return id(ACTIVE); text: "${BTN4_ICON}" on_click: - script.execute: id: publish_action action: "btn4_press" - if: condition: lambda: |- return std::string("${DIRECT_ACTIONS}") == "true"; then: - homeassistant.action: action: light.toggle data: entity_id: ${BTN4_ENTITY} - label: x: 235 y: 205 width: 60 text_align: CENTER text_font: label text_color: 0xFFFFFF text: "${BTN4_LABEL}" script: # ---------------------------------------------------------- # publish_action # # Publishes a string to the "SmartDisplay Action" text_sensor and # then clears it again. # ---------------------------------------------------------- - id: publish_action mode: queued parameters: action: string then: - text_sensor.template.publish: id: smartdisplay_action state: !lambda |- return action; - delay: 200ms - text_sensor.template.publish: id: smartdisplay_action state: "" - id: ui_refresh mode: queued then: # Sync LVGL "checked" state with the mirrored HA entity states - lvgl.widget.update: id: btn1 state: checked: !lambda return id(ha_state_btn1).state; - lvgl.widget.update: id: btn2 state: checked: !lambda return id(ha_state_btn2).state; - lvgl.widget.update: id: btn3 state: checked: !lambda return id(ha_state_btn3).state; - lvgl.widget.update: id: btn4 state: checked: !lambda return id(ha_state_btn4).state; # Render current time (HH:MM) - lvgl.label.update: id: lbl_time text: !lambda |- auto now = id(homeassistant_time).now(); static char buf[6]; snprintf(buf, sizeof(buf), "%02d:%02d", now.hour, now.minute); return std::string(buf); # Render localized date in German (adjust to your language if needed) - lvgl.label.update: id: lbl_date text: !lambda |- static const char* days[] = {"", "Sonntag","Montag","Dienstag","Mittwoch","Donnerstag","Freitag","Samstag"}; static const char* months[] = {"","Januar","Februar","März","April","Mai","Juni","Juli","August","September","Oktober","November","Dezember"}; auto now = id(homeassistant_time).now(); static char buf[40]; snprintf(buf, sizeof(buf), "%s, %d. %s", days[now.day_of_week], now.day_of_month, months[now.month]); return std::string(buf);