# Waveshare ESP32-S3-Touch-LCD-1.85C-V2 — voice satellite + status display. # # See firmware/esp32-s3-touch-lcd-1.85c/README.md before flashing this: it needs # the V2 hardware revision specifically (V1 has no echo-cancellation circuit and # different audio pins), and two entity IDs in secrets.yaml filled in before the # display shows anything real. # # Hardware bring-up (esp32/psram/i2c/pca9554/spi/display/touchscreen/i2s_audio # sections) is adapted from the community-verified config at # https://github.com/ulsmith/home-assistant-esphome-esp32-s3-touch-lcd-185c — # the register-level display init sequence in particular is copied close to # verbatim rather than re-derived, since getting it wrong just shows static. # Everything voice/UI-behaviour related below that is new, built for this # project's specific ask: media-with-cover-art takes priority over an idle # weather/time/date cycle, with a voice-state visualizer overlaid on top of # whichever of those is showing. substitutions: # REQUIRED per physical unit — see README.md's "Multiple rooms" section. # This is what makes the device identifiable as belonging to one specific # room in both Home Assistant's device list and this file's own hostname; # it does NOT by itself make media_player_entity_id (a secret, below) # point at the right room's media — that's a separate, equally required # step covered in the same README section. room: "living-room" # lowercase, hyphens only — becomes part of the hostname friendly_name: "Voice display (Living room)" # Substitution (not a plain !secret) specifically so it can be interpolated # inside larger strings and lambda string literals below, e.g. # "${ha_base_url}/local/no_art.png" — !secret alone can't be embedded that way. ha_base_url: !secret ha_base_url esphome: name: voice-display-${room} friendly_name: ${friendly_name} # ESP-IDF (not Arduino) is required for octal PSRAM + this QSPI display driver. esp32: board: esp32-s3-devkitc-1 flash_size: 16MB framework: type: esp-idf sdkconfig_options: CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240: "y" CONFIG_ESP32S3_DATA_CACHE_64KB: "y" CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y" CONFIG_SPIRAM_FETCH_INSTRUCTIONS: "y" CONFIG_SPIRAM_RODATA: "y" psram: mode: octal speed: 80MHz logger: level: INFO api: encryption: key: !secret api_encryption_key ota: - platform: esphome password: !secret ota_password wifi: ssid: !secret wifi_ssid password: !secret wifi_password ap: ssid: "voice-display-${room}" password: !secret ap_password captive_portal: # Required by online_image below (cover-art fetch) — LAN-only in practice, # since ha_base_url always points at the household's own Home Assistant. http_request: useragent: esphome/voice-display-${room} timeout: 10s # Home Assistant's own clock — this device never needs to be right on its own. time: - platform: homeassistant id: ha_time on_time: - seconds: 0 then: - lvgl.label.update: id: lbl_time text: !lambda "return id(ha_time).now().strftime(\"%H:%M\");" - lvgl.label.update: id: lbl_date text: !lambda "return id(ha_time).now().strftime(\"%a %d %b\");" # --------------------------------------------------------------------------- # Display + touch (ST77916 360x360 round QSPI, CST816 touch) — Phase 13-style # "copied from a verified source, not re-derived" note applies to the whole # i2c/pca9554/spi/display block below. # --------------------------------------------------------------------------- i2c: sda: GPIO11 scl: GPIO10 scan: true # I/O expander that carries the display's reset line — the V2 variant # specifically resets via EXIO2 (pca9554 pin 2), not a plain GPIO. pca9554: - id: pca9554_device address: 0x20 output: - platform: ledc pin: GPIO5 id: backlight_pwm light: - platform: monochromatic output: backlight_pwm id: display_backlight name: "Display backlight" restore_mode: ALWAYS_ON spi: id: display_qspi type: quad clk_pin: GPIO40 data_pins: [GPIO46, GPIO45, GPIO42, GPIO41] display: - platform: qspi_dbi model: CUSTOM id: main_display spi_id: display_qspi cs_pin: GPIO21 reset_pin: pca9554: pca9554_device number: 2 # EXIO2 — V2-variant-specific, see README data_rate: 80MHz dimensions: width: 360 height: 360 color_order: rgb invert_colors: true auto_clear_enabled: false update_interval: never # Two lines to change if the unit needs mounting upside down (see README): # rotation: 180° and touchscreen.transform.mirror_x/mirror_y: true below. rotation: 0° # Official Waveshare V2 init sequence — do not reorder or trim these lines. init_sequence: - [0xF0, 0x28] - [0xF2, 0x28] - [0x73, 0xF0] - [0x7C, 0xD1] - [0x83, 0xE0] - [0x84, 0x61] - [0xF2, 0x82] - [0xF0, 0x00] - [0xF0, 0x01] - [0xF1, 0x01] - [0xB0, 0x56] - [0xB1, 0x4D] - [0xB2, 0x24] - [0xB4, 0x87] - [0xB5, 0x44] - [0xB6, 0x8B] - [0xB7, 0x40] - [0xB8, 0x86] - [0xBA, 0x00] - [0xBB, 0x08] - [0xBC, 0x08] - [0xBD, 0x00] - [0xC0, 0x80] - [0xC1, 0x10] - [0xC2, 0x37] - [0xC3, 0x80] - [0xC4, 0x10] - [0xC5, 0x37] - [0xC6, 0xA9] - [0xC7, 0x41] - [0xC8, 0x01] - [0xC9, 0xA9] - [0xCA, 0x41] - [0xCB, 0x01] - [0xD0, 0x91] - [0xD1, 0x68] - [0xD2, 0x68] - [0xF5, 0x00, 0xA5] - [0xDD, 0x4F] - [0xDE, 0x4F] - [0xF1, 0x10] - [0xF0, 0x00] - [0xF0, 0x02] - [0xE0, 0xF0, 0x0A, 0x10, 0x09, 0x09, 0x36, 0x35, 0x33, 0x4A, 0x29, 0x15, 0x15, 0x2E, 0x34] - [0xE1, 0xF0, 0x0A, 0x0F, 0x08, 0x08, 0x05, 0x34, 0x33, 0x4A, 0x39, 0x15, 0x15, 0x2D, 0x33] - [0xF0, 0x10] - [0xF3, 0x10] - [0xE0, 0x07] - [0xE1, 0x00] - [0xE2, 0x00] - [0xE3, 0x00] - [0xE4, 0xE0] - [0xE5, 0x06] - [0xE6, 0x21] - [0xE7, 0x01] - [0xE8, 0x05] - [0xE9, 0x02] - [0xEA, 0xDA] - [0xEB, 0x00] - [0xEC, 0x00] - [0xED, 0x0F] - [0xEE, 0x00] - [0xEF, 0x00] - [0xF8, 0x00] - [0xF9, 0x00] - [0xFA, 0x00] - [0xFB, 0x00] - [0xFC, 0x00] - [0xFD, 0x00] - [0xFE, 0x00] - [0xFF, 0x00] - [0x60, 0x40] - [0x61, 0x04] - [0x62, 0x00] - [0x63, 0x42] - [0x64, 0xD9] - [0x65, 0x00] - [0x66, 0x00] - [0x67, 0x00] - [0x68, 0x00] - [0x69, 0x00] - [0x6A, 0x00] - [0x6B, 0x00] - [0x70, 0x40] - [0x71, 0x03] - [0x72, 0x00] - [0x73, 0x42] - [0x74, 0xD8] - [0x75, 0x00] - [0x76, 0x00] - [0x77, 0x00] - [0x78, 0x00] - [0x79, 0x00] - [0x7A, 0x00] - [0x7B, 0x00] - [0x80, 0x48] - [0x81, 0x00] - [0x82, 0x06] - [0x83, 0x02] - [0x84, 0xD6] - [0x85, 0x04] - [0x86, 0x00] - [0x87, 0x00] - [0x88, 0x48] - [0x89, 0x00] - [0x8A, 0x08] - [0x8B, 0x02] - [0x8C, 0xD8] - [0x8D, 0x04] - [0x8E, 0x00] - [0x8F, 0x00] - [0x90, 0x48] - [0x91, 0x00] - [0x92, 0x0A] - [0x93, 0x02] - [0x94, 0xDA] - [0x95, 0x04] - [0x96, 0x00] - [0x97, 0x00] - [0x98, 0x48] - [0x99, 0x00] - [0x9A, 0x0C] - [0x9B, 0x02] - [0x9C, 0xDC] - [0x9D, 0x04] - [0x9E, 0x00] - [0x9F, 0x00] - [0xA0, 0x48] - [0xA1, 0x00] - [0xA2, 0x05] - [0xA3, 0x02] - [0xA4, 0xD5] - [0xA5, 0x04] - [0xA6, 0x00] - [0xA7, 0x00] - [0xA8, 0x48] - [0xA9, 0x00] - [0xAA, 0x07] - [0xAB, 0x02] - [0xAC, 0xD7] - [0xAD, 0x04] - [0xAE, 0x00] - [0xAF, 0x00] - [0xB0, 0x48] - [0xB1, 0x00] - [0xB2, 0x09] - [0xB3, 0x02] - [0xB4, 0xD9] - [0xB5, 0x04] - [0xB6, 0x00] - [0xB7, 0x00] - [0xB8, 0x48] - [0xB9, 0x00] - [0xBA, 0x0B] - [0xBB, 0x02] - [0xBC, 0xDB] - [0xBD, 0x04] - [0xBE, 0x00] - [0xBF, 0x00] - [0xC0, 0x10] - [0xC1, 0x47] - [0xC2, 0x56] - [0xC3, 0x65] - [0xC4, 0x74] - [0xC5, 0x88] - [0xC6, 0x99] - [0xC7, 0x01] - [0xC8, 0xBB] - [0xC9, 0xAA] - [0xD0, 0x10] - [0xD1, 0x47] - [0xD2, 0x56] - [0xD3, 0x65] - [0xD4, 0x74] - [0xD5, 0x88] - [0xD6, 0x99] - [0xD7, 0x01] - [0xD8, 0xBB] - [0xD9, 0xAA] - [0xF3, 0x01] - [0xF0, 0x00] - [0x3A, 0x55] # RGB565 - [0x36, 0x00] # MADCTL - [0x2A, 0x00, 0x00, 0x01, 0x67] # column address 0-359 - [0x2B, 0x00, 0x00, 0x01, 0x67] # row address 0-359 - [0x21, 0x00] # display inversion on - [0x11, 0x00] # sleep out - delay 120ms - [0x29, 0x00] # display on - delay 20ms touchscreen: platform: cst816 id: my_touchscreen address: 0x15 interrupt_pin: GPIO4 transform: mirror_x: false mirror_y: false on_touch: then: - light.turn_on: id: display_backlight brightness: 100% - lvgl.resume: # Narrow edge strips for swipe-to-navigate between the auto-managed idle/media # pages and the two manual pages (settings, device info) — same shape as the # reference config's own swipe zones. binary_sensor: - platform: touchscreen touchscreen_id: my_touchscreen id: swipe_left internal: true x_min: 0 x_max: 60 y_min: 100 y_max: 260 on_press: then: - lvgl.page.previous: - platform: touchscreen touchscreen_id: my_touchscreen id: swipe_right internal: true x_min: 300 x_max: 360 y_min: 100 y_max: 260 on_press: then: - lvgl.page.next: # --------------------------------------------------------------------------- # Audio: ES7210 mic ADC (with the board's echo-cancellation reference path) # + ES8311 speaker DAC, wired for on-device wake word (micro_wake_word) plus # the existing Phase 3 Assist pipeline for everything after the wake word. # --------------------------------------------------------------------------- i2s_audio: - id: i2s_in i2s_lrclk_pin: GPIO2 i2s_bclk_pin: GPIO15 - id: i2s_out i2s_lrclk_pin: GPIO38 i2s_bclk_pin: GPIO48 microphone: - platform: i2s_audio id: i2s_microphone i2s_audio_id: i2s_in i2s_din_pin: GPIO39 adc_type: external pdm: false channel: right sample_rate: 16000 bits_per_sample: 16bit speaker: - platform: i2s_audio id: i2s_speaker i2s_audio_id: i2s_out i2s_dout_pin: GPIO47 dac_type: external i2s_mode: primary # On-device wake-word spotting (TFLite, runs on the ESP32-S3 itself) — matches # this project's Phase 11.8 principle that wake-word detection happens locally, # not by streaming continuously to Home Assistant. "okay_nabu" is the same # phrase as the thin client's VOICE_WAKE_WORD, so the household has one # consistent wake word regardless of which kind of satellite answers. micro_wake_word: microphone: microphone: i2s_microphone channels: 0 models: - model: okay_nabu id: okay_nabu_model on_wake_word_detected: - micro_wake_word.stop: - voice_assistant.start: wake_word: !lambda "return wake_word;" voice_assistant: id: va microphone: i2s_microphone speaker: i2s_speaker use_wake_word: false # micro_wake_word above starts a session explicitly instead noise_suppression_level: 2 auto_gain: 31dBFS volume_multiplier: 2.0 on_listening: - light.turn_on: id: display_backlight brightness: 100% - lvgl.resume: - script.execute: show_visualizer_listening on_stt_end: - script.execute: show_visualizer_thinking on_tts_start: - script.execute: show_visualizer_replying on_tts_end: - script.execute: hide_visualizer on_end: - script.execute: hide_visualizer - micro_wake_word.start: on_error: - script.execute: show_visualizer_error - delay: 2s - script.execute: hide_visualizer - micro_wake_word.start: # --------------------------------------------------------------------------- # Home Assistant data this device mirrors — both entity IDs are placeholders, # see README.md. Neither is guessed at; an unset value just means that part of # the idle page/media page stays blank, same "don't build against a guess" # rule this repo already applies to Phase 12/13's own unresolved data sources. # --------------------------------------------------------------------------- sensor: - platform: homeassistant id: weather_temperature entity_id: !secret weather_entity_id attribute: temperature on_value: then: - lvgl.label.update: id: lbl_weather_temp text: !lambda "return (str_sprintf(\"%.0f\", x) + \"°\");" - platform: wifi_signal id: wifi_signal_sensor update_interval: 60s on_value: then: - lvgl.label.update: id: lbl_wifi_signal text: !lambda "return (\"WiFi: \" + std::to_string((int) x) + \" dBm\");" - platform: uptime id: uptime_sensor update_interval: 60s on_value: then: - lvgl.label.update: id: lbl_uptime text: !lambda |- int s = (int) x; return "Uptime: " + std::to_string(s / 3600) + "h " + std::to_string((s % 3600) / 60) + "m"; globals: - id: media_is_playing type: bool restore_value: no initial_value: "false" - id: idle_showing_weather type: bool restore_value: no initial_value: "false" text_sensor: - platform: homeassistant id: weather_condition entity_id: !secret weather_entity_id on_value: then: - lvgl.label.update: id: lbl_weather_cond text: !lambda "return x;" - platform: homeassistant id: media_state entity_id: !secret media_player_entity_id on_value: then: - if: condition: lambda: 'return x == "playing";' then: - globals.set: id: media_is_playing value: "true" - lvgl.page.show: media_page else: - globals.set: id: media_is_playing value: "false" - lvgl.page.show: idle_page - platform: homeassistant id: media_title entity_id: !secret media_player_entity_id attribute: media_title on_value: then: - lvgl.label.update: id: lbl_media_title text: !lambda "return x;" - platform: homeassistant id: media_artist entity_id: !secret media_player_entity_id attribute: media_artist on_value: then: - lvgl.label.update: id: lbl_media_artist text: !lambda "return x;" # entity_picture from Home Assistant is a path relative to the HA instance # (e.g. "/api/media_player_proxy/...") — this device fetches it directly over # HTTP, not through the HA connection, so ha_base_url has to be prepended. - platform: homeassistant id: media_picture entity_id: !secret media_player_entity_id attribute: entity_picture on_value: then: - online_image.set_url: id: media_cover_image url: !lambda |- if (x.rfind("http", 0) == 0) return x; return std::string("${ha_base_url}") + x; - platform: wifi_info ip_address: id: wifi_ip on_value: then: - lvgl.label.update: id: lbl_ip text: !lambda "return (\"IP: \" + x);" # Must be the online_image platform (not a plain local file/mdi image) — # that's the only image type online_image.set_url (used above) can retarget # at runtime. The initial URL points at Home Assistant's own /local/ # (config/www/) static folder rather than anywhere external — nothing else # this device talks to leaves the LAN, and it stays that way here too. It # 404s harmlessly (shows no image, not a broken canvas) until either the # first real track plays or you drop a no_art.png into config/www/ yourself # — see README.md. image: - platform: online_image id: media_cover_image url: "${ha_base_url}/local/no_art.png" format: PNG type: RGB565 resize: 220x220 # --------------------------------------------------------------------------- # Screen content # --------------------------------------------------------------------------- font: - file: "gfonts://Montserrat" id: font_time size: 54 - file: "gfonts://Montserrat" id: font_date size: 20 - file: "gfonts://Montserrat" id: font_weather size: 32 - file: "gfonts://Montserrat" id: font_weather_cond size: 18 - file: "gfonts://Montserrat" id: font_media size: 20 - file: "gfonts://Montserrat" id: font_small size: 14 # Idle-page auto-cycle: only ever touches idle_page's own two widget groups, # and only while media isn't playing — the priority automation above already # forces media_page the instant something starts, so this never fights it. interval: - interval: 8s then: - if: condition: lambda: "return !id(media_is_playing);" then: - globals.set: id: idle_showing_weather value: !lambda "return !id(idle_showing_weather);" - if: condition: lambda: "return id(idle_showing_weather);" then: - lvgl.widget.hide: idle_clock_group - lvgl.widget.show: idle_weather_group else: - lvgl.widget.show: idle_clock_group - lvgl.widget.hide: idle_weather_group script: - id: show_visualizer_listening then: - lvgl.spinner.update: id: voice_visualizer arc_color: 0x003300 indicator: arc_color: 0x00FF00 - lvgl.widget.show: voice_visualizer - id: show_visualizer_thinking then: - lvgl.spinner.update: id: voice_visualizer arc_color: 0x332200 indicator: arc_color: 0xFF9900 - lvgl.widget.show: voice_visualizer - id: show_visualizer_replying then: - lvgl.spinner.update: id: voice_visualizer arc_color: 0x001a33 indicator: arc_color: 0x00AAFF - lvgl.widget.show: voice_visualizer - id: show_visualizer_error then: - lvgl.spinner.update: id: voice_visualizer arc_color: 0x330000 indicator: arc_color: 0xFF0000 - lvgl.widget.show: voice_visualizer - id: hide_visualizer then: - lvgl.widget.hide: voice_visualizer lvgl: displays: - main_display touchscreens: - touchscreen_id: my_touchscreen buffer_size: 25% color_depth: 16 bg_color: 0x000000 on_idle: timeout: 30s then: - lvgl.pause: - light.turn_off: id: display_backlight transition_length: 5s # Voice-state visualizer: a spinning ring covering the whole screen, # regardless of which page is active underneath — "a visualizer when # speaking" as an overlay concern, not a page of its own. Hidden by # default; the voice_assistant scripts above are the only things that # show/hide or recolour it. top_layer: widgets: - spinner: id: voice_visualizer align: CENTER width: 356 height: 356 arc_color: 0x003300 arc_width: 6 spin_time: 1200ms arc_length: 60 indicator: arc_color: 0x00FF00 arc_width: 6 hidden: true pages: - id: idle_page widgets: - obj: id: idle_clock_group width: 100% height: 100% widgets: - label: id: lbl_time align: CENTER y: -20 text_font: font_time text_color: 0xFFFFFF text: "--:--" - label: id: lbl_date align: CENTER y: 35 text_font: font_date text_color: 0xAAAAAA text: "--- -- ---" - obj: id: idle_weather_group width: 100% height: 100% hidden: true widgets: - label: id: lbl_weather_temp align: CENTER y: -20 text_font: font_weather text_color: 0x00BFFF text: "--°" - label: id: lbl_weather_cond align: CENTER y: 25 text_font: font_weather_cond text_color: 0x87CEEB text: "Weather loading..." - id: media_page widgets: - image: id: img_media_cover src: media_cover_image align: CENTER y: -40 - label: id: lbl_media_title align: CENTER y: 90 text_font: font_media text_color: 0xFFFFFF text: "" - label: id: lbl_media_artist align: CENTER y: 118 text_font: font_small text_color: 0xAAAAAA text: "" - id: settings_page widgets: - label: align: TOP_MID y: 50 text_font: font_weather_cond text_color: 0xFFFFFF text: "Settings" - label: align: CENTER x: -70 y: 0 text_font: font_small text_color: 0xFFFFFF text: "Mute mic" - switch: id: mute_switch_widget align: CENTER x: 60 y: 0 # Delegates to the HA-exposed template switch below rather than # touching micro_wake_word directly — that switch's turn_on_action/ # turn_off_action already does the real stop/start and reports its # new state back to this widget via lvgl.widget.update, so this is # the only place that pairing needs to be defined. on_click: then: - switch.toggle: mute_switch - id: info_page widgets: - label: align: TOP_MID y: 50 text_font: font_weather_cond text_color: 0xFFFFFF text: "Device info" - label: id: lbl_ip align: CENTER y: -20 text_font: font_small text_color: 0x00FF00 text: "IP: connecting..." - label: id: lbl_wifi_signal align: CENTER y: 10 text_font: font_small text_color: 0x00FF00 text: "WiFi: --" - label: id: lbl_uptime align: CENTER y: 40 text_font: font_small text_color: 0x00FFFF text: "Uptime: --" button: - platform: restart name: "Restart" switch: - platform: template name: "Mute microphone" id: mute_switch icon: "mdi:microphone-off" optimistic: true turn_on_action: - micro_wake_word.stop: - lvgl.widget.update: id: mute_switch_widget state: checked: true turn_off_action: - micro_wake_word.start: - lvgl.widget.update: id: mute_switch_widget state: checked: false