SmartestHome/firmware/esp32-s3-touch-lcd-1.85c/voice-display.yaml

865 lines
24 KiB
YAML

# 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