Waveshare ESP32-S3 Touch-LCD 1.83 Voice Assistant¶
Complete configuration for the Waveshare ESP32-S3 Touch-LCD 1.83" with persistent Home Assistant timers.
Hardware Overview¶
| Feature | Value |
|---|---|
| Board | ESP32-S3 with PSRAM |
| Display | 240x284 ST7789 LCD |
| Touch | CST816 Capacitive |
| Audio | ES7210 ADC + ES8311 DAC |
| Wake Word | On-device micro_wake_word |
| IMU | QMI8658 Accelerometer/Gyroscope |
| Additional | Power button, boot button, orientation detection |
Key Features¶
- Compact Form Factor: Small 1.83" display ideal for bedside or desk placement
- Orientation Detection: QMI8658 IMU detects portrait/landscape orientation
- Touch to Interact: Single-touch interface for stopping alarms, starting voice commands
- Timer Display: Number-to-words countdown (e.g., "forty-five minutes remaining")
Prerequisites¶
- ESPHome 2025.5.0 or newer
- Home Assistant with:
- Timer entity matching your area (e.g.,
timer.bedroom) - Template sensor for timer remaining seconds
- Intent scripts for timer control
- Timer finished automation
Complete Configuration¶
Download the complete ESPHome configuration file:
Download waveshare-touch-lcd-183-voice-assistant.yaml
Configuration¶
Substitutions¶
# file: esphome/examples/waveshare-touch-lcd-183-voice-assistant.yaml
# section: substitutions
substitutions:
device_name: "${timer_area}-voice-assistant"
friendly_name: "${timer_area} Voice Assistant"
device_description: "Waveshare ESP32-S3-Touch-LCD-1.83"
# REQUIRED: Set this to match your HA area
timer_area: "bedroom"
# Generic voice assistant images (240x240 for this display)
loading_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/casita/loading_320_240.png
idle_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/casita/idle_320_240.png
listening_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/casita/listening_320_240.png
thinking_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/casita/thinking_320_240.png
replying_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/casita/replying_320_240.png
error_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/casita/error_320_240.png
timer_finished_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/casita/timer_finished_320_240.png
# Background colors
loading_illustration_background_color: "000000"
idle_illustration_background_color: "000000"
listening_illustration_background_color: "FFFFFF"
thinking_illustration_background_color: "FFFFFF"
replying_illustration_background_color: "FFFFFF"
error_illustration_background_color: "000000"
timer_finished_illustration_background_color: "FFFFFF"
# Voice assistant phase IDs
voice_assist_idle_phase_id: "1"
voice_assist_listening_phase_id: "2"
voice_assist_thinking_phase_id: "3"
voice_assist_replying_phase_id: "4"
voice_assist_not_ready_phase_id: "10"
voice_assist_error_phase_id: "11"
voice_assist_muted_phase_id: "12"
voice_assist_timer_finished_phase_id: "20"
voice_assist_ota_phase_id: "30"
# Display dimensions for Waveshare 1.83" LCD
display_width: "240"
display_height: "284"
# Font configuration
font_glyphsets: "GF_Latin_Core"
font_family: Figtree
# Hardware pin definitions
i2s_mclk_pin: GPIO16
i2s_bclk_pin: GPIO9
i2s_lrclk_pin: GPIO45
i2s_din_pin: GPIO10
i2s_dout_pin: GPIO8
audio_i2c_sda: GPIO15
audio_i2c_scl: GPIO14
pa_enable_pin: GPIO46
display_clk_pin: GPIO6
display_mosi_pin: GPIO7
display_cs_pin: GPIO5
display_dc_pin: GPIO4
display_rst_pin: GPIO38
display_backlight_pin: GPIO40
touch_int_pin: GPIO13
touch_rst_pin: GPIO39
boot_button_pin: GPIO0
power_button_pin: GPIO41
ESPHome Core¶
# file: esphome/examples/waveshare-touch-lcd-183-voice-assistant.yaml
# section: esphome
esphome:
name: ${device_name}
friendly_name: ${friendly_name}
comment: ${device_description}
min_version: 2025.5.0
name_add_mac_suffix: false
on_boot:
priority: 600
then:
- script.execute: draw_display
- delay: 30s
- if:
condition:
lambda: return id(init_in_progress);
then:
- lambda: id(init_in_progress) = false;
- script.execute: draw_display
ESP32 Platform¶
# file: esphome/examples/waveshare-touch-lcd-183-voice-assistant.yaml
# section: esp32
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
flash_size: 16MB
cpu_frequency: 240MHz
framework:
type: esp-idf
version: recommended
sdkconfig_options:
CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240: "y"
CONFIG_ESP32S3_DATA_CACHE_64KB: "y"
CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y"
CONFIG_ESP32S3_INSTRUCTION_CACHE_32KB: "y"
CONFIG_SPIRAM_RODATA: "y"
CONFIG_SPIRAM_FETCH_INSTRUCTIONS: "y"
external_components:
- source: github://Djelibeybi/esphome-qmi8658@main
components: [qmi8658]
refresh: 10min
psram:
mode: octal
speed: 80MHz
API Services¶
# file: esphome/examples/waveshare-touch-lcd-183-voice-assistant.yaml
# section: api
api:
on_client_connected:
- script.execute: draw_display
on_client_disconnected:
- script.execute: draw_display
services:
- service: timer_finished
then:
- logger.log: "Timer finished! Playing alarm..."
- switch.turn_on: timer_ringing
- service: timer_started
variables:
duration: int
then:
- logger.log:
format: "Timer started with duration: %d seconds"
args: ["duration"]
- script.execute: draw_display
- service: timer_cancelled
then:
- logger.log: "Timer cancelled"
- switch.turn_off: timer_ringing
- script.execute: draw_display
- service: stop_alarm
then:
- switch.turn_off: timer_ringing
OTA, Logger, WiFi, Time¶
# file: esphome/examples/waveshare-touch-lcd-183-voice-assistant.yaml
# section: ota
ota:
- platform: esphome
id: ota_esphome
on_begin:
- script.execute: stop_wake_word
- lambda: |-
id(voice_assistant_phase) = ${voice_assist_ota_phase_id};
id(ota_progress) = 0;
- display.page.show: ota_page
- component.update: lcd_display
on_progress:
- lambda: id(ota_progress) = (int)x;
- component.update: lcd_display
on_end:
- lambda: id(ota_progress) = 100;
- component.update: lcd_display
on_error:
- lambda: id(ota_progress) = -1;
- display.page.show: error_page
- component.update: lcd_display
- delay: 5s
- script.execute: draw_display
logger:
level: INFO
hardware_uart: USB_SERIAL_JTAG
logs:
text_sensor: WARN
sensor: WARN
component: ERROR
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
on_connect:
- script.execute: draw_display
on_disconnect:
- script.execute: draw_display
time:
- platform: sntp
id: sntp_time
servers: !secret ntp_servers
timezone: !secret timezone
Timer Sync Intervals¶
# file: esphome/examples/waveshare-touch-lcd-183-voice-assistant.yaml
# section: interval
interval:
# 30-second sync ensures display matches timer state
- interval: 30s
then:
- lambda: |-
if (id(voice_assistant_phase) == ${voice_assist_idle_phase_id} ||
id(voice_assistant_phase) == ${voice_assist_muted_phase_id}) {
std::string state = id(timer_state).state;
if (state == "active" || state == "paused") {
ESP_LOGD("timer_sync", "Timer is %s but display is idle - triggering redraw", state.c_str());
id(draw_display).execute();
}
}
# Fast display update for final 60 seconds
- interval: 1s
then:
- lambda: |-
std::string state = id(timer_state).state;
float remaining = id(timer_remaining).state;
if ((state == "active" || state == "paused") && !std::isnan(remaining) && remaining <= 60) {
id(draw_display).execute();
}
Buttons¶
# file: esphome/examples/waveshare-touch-lcd-183-voice-assistant.yaml
# section: button
button:
- platform: restart
id: restart_btn
name: Restart
- platform: factory_reset
id: factory_reset_btn
internal: true
I2C and IMU Configuration¶
# file: esphome/examples/waveshare-touch-lcd-183-voice-assistant.yaml
# section: i2c
i2c:
- id: internal_i2c
sda: ${audio_i2c_sda}
scl: ${audio_i2c_scl}
scan: true
frequency: 400kHz
qmi8658:
id: imu_sensor
address: 0x6B
update_interval: 100ms
on_orientation_change:
then:
- lambda: |-
ESP_LOGD("orientation", "Orientation changed to: %s", orientation.c_str());
if (orientation == "portrait_inverted") {
id(landscape_mode) = false;
id(lcd_display).set_rotation(esphome::display::DISPLAY_ROTATION_180_DEGREES);
id(draw_display).execute();
} else if (orientation == "portrait") {
id(landscape_mode) = false;
id(lcd_display).set_rotation(esphome::display::DISPLAY_ROTATION_0_DEGREES);
id(draw_display).execute();
} else if (orientation == "landscape_left" || orientation == "landscape_right") {
id(landscape_mode) = true;
id(lcd_display).show_page(landscape_warning_page);
id(lcd_display).update();
}
Home Assistant Timer Sensors¶
# file: esphome/examples/waveshare-touch-lcd-183-voice-assistant.yaml
# section: sensor
sensor:
- platform: template
name: "Voice Assistant Phase"
id: voice_assistant_phase_sensor
lambda: |-
return (float)id(voice_assistant_phase);
update_interval: 500ms
- platform: homeassistant
id: timer_remaining
name: "Timer remaining"
entity_id: sensor.${timer_area}_timer_remaining_seconds
unit_of_measurement: "s"
device_class: "duration"
on_value:
then:
- script.execute: draw_display
- platform: homeassistant
id: timer_duration
name: "Timer duration"
entity_id: sensor.${timer_area}_timer_remaining_seconds
attribute: duration_seconds
unit_of_measurement: "s"
device_class: "duration"
- platform: homeassistant
id: timer_progress
entity_id: sensor.${timer_area}_timer_remaining_seconds
attribute: progress_percent
internal: true
- platform: wifi_signal
name: "WiFi db"
id: wifi_signal_db
update_interval: 30s
- platform: copy
source_id: wifi_signal_db
name: "WiFi Signal"
id: wifi_percent
filters:
- lambda: return min(max(2 * (x + 100.0), 0.0), 100.0);
unit_of_measurement: "%"
entity_category: "diagnostic"
Text Sensors¶
# file: esphome/examples/waveshare-touch-lcd-183-voice-assistant.yaml
# section: text_sensor
text_sensor:
- platform: template
name: "Voice Assistant State"
id: voice_assistant_state_sensor
lambda: |-
int phase = id(voice_assistant_phase);
switch(phase) {
case ${voice_assist_idle_phase_id}: return {"idle"};
case ${voice_assist_listening_phase_id}: return {"listening"};
case ${voice_assist_thinking_phase_id}: return {"thinking"};
case ${voice_assist_replying_phase_id}: return {"replying"};
case ${voice_assist_error_phase_id}: return {"error"};
case ${voice_assist_not_ready_phase_id}: return {"not_ready"};
case ${voice_assist_muted_phase_id}: return {"muted"};
case ${voice_assist_timer_finished_phase_id}: return {"timer_finished"};
default: return {"unknown"};
}
update_interval: 500ms
- platform: homeassistant
id: timer_state
entity_id: sensor.${timer_area}_timer_remaining_seconds
attribute: timer_state
internal: true
on_value:
then:
- script.execute: draw_display
- id: text_request
platform: template
on_value:
lambda: |-
if(id(text_request).state.length()>32) {
std::string name = id(text_request).state.c_str();
std::string truncated = esphome::str_truncate(name.c_str(),31);
id(text_request).state = (truncated+"...").c_str();
}
- id: text_response
platform: template
on_value:
lambda: |-
if(id(text_response).state.length()>32) {
std::string name = id(text_response).state.c_str();
std::string truncated = esphome::str_truncate(name.c_str(),31);
id(text_response).state = (truncated+"...").c_str();
}
SPI and Audio Configuration¶
# file: esphome/examples/waveshare-touch-lcd-183-voice-assistant.yaml
# section: spi
spi:
- id: spi_bus
clk_pin: ${display_clk_pin}
mosi_pin: ${display_mosi_pin}
# file: esphome/examples/waveshare-touch-lcd-183-voice-assistant.yaml
# section: audio
i2s_audio:
- id: i2s_audio_bus
i2s_lrclk_pin: ${i2s_lrclk_pin}
i2s_bclk_pin: ${i2s_bclk_pin}
i2s_mclk_pin: ${i2s_mclk_pin}
audio_adc:
- platform: es7210
id: es7210_adc
bits_per_sample: 16bit
sample_rate: 16000
i2c_id: internal_i2c
audio_dac:
- platform: es8311
id: es8311_dac
bits_per_sample: 16bit
sample_rate: 48000
i2c_id: internal_i2c
microphone:
- platform: i2s_audio
id: box_mic
sample_rate: 16000
i2s_din_pin: ${i2s_din_pin}
bits_per_sample: 16bit
adc_type: external
speaker:
- id: i2s_audio_speaker
platform: i2s_audio
i2s_audio_id: i2s_audio_bus
i2s_dout_pin: ${i2s_dout_pin}
dac_type: external
sample_rate: 48000
bits_per_sample: 16bit
channel: left
audio_dac: es8311_dac
buffer_duration: 100ms
media_player:
- platform: speaker
name: None
id: speaker_media_player
volume_min: 0.5
volume_max: 0.8
task_stack_in_psram: true
announcement_pipeline:
speaker: i2s_audio_speaker
format: FLAC
sample_rate: 48000
num_channels: 1
files:
- id: timer_finished_sound
file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/timer_finished.flac
- id: wake_word_triggered_sound_file
file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/wake_word_triggered.flac
on_announcement:
- lambda: id(announcement_in_progress) = true;
- script.execute: track_announcement_lifecycle
- if:
condition:
- microphone.is_capturing:
then:
- script.execute: stop_wake_word
- if:
condition:
and:
- not:
voice_assistant.is_running:
- switch.is_off: timer_ringing
then:
- lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id};
- script.execute: draw_display
on_idle:
- delay: 100ms
- if:
condition:
and:
- not:
voice_assistant.is_running:
- switch.is_off: timer_ringing
- not:
media_player.is_announcing:
- lambda: return !id(announcement_in_progress);
then:
- script.execute: start_wake_word
- script.execute: set_idle_or_mute_phase
- script.execute: draw_display
Wake Word and Voice Assistant¶
# file: esphome/examples/waveshare-touch-lcd-183-voice-assistant.yaml
# section: wake_word
micro_wake_word:
id: mww
models:
- model: okay_nabu
id: okay_nabu
vad:
model: github://esphome/micro-wake-word-models/models/v2/vad.json
on_wake_word_detected:
- voice_assistant.start:
wake_word: !lambda return wake_word;
voice_assistant:
id: va
microphone: box_mic
media_player: speaker_media_player
micro_wake_word: mww
noise_suppression_level: 2
auto_gain: 31dBFS
volume_multiplier: 2.0
on_listening:
- lambda: id(voice_assistant_phase) = ${voice_assist_listening_phase_id};
- text_sensor.template.publish:
id: text_request
state: "..."
- text_sensor.template.publish:
id: text_response
state: "..."
- script.execute: draw_display
on_stt_vad_end:
- lambda: id(voice_assistant_phase) = ${voice_assist_thinking_phase_id};
- script.execute: draw_display
on_stt_end:
- text_sensor.template.publish:
id: text_request
state: !lambda return x;
- script.execute: draw_display
on_tts_start:
- text_sensor.template.publish:
id: text_response
state: !lambda return x;
- lambda: id(voice_assistant_phase) = ${voice_assist_replying_phase_id};
- script.execute: draw_display
on_end:
- if:
condition:
- lambda: return id(announcement_in_progress);
then:
- logger.log: "on_end: Skipping - announcement in progress"
else:
- wait_until:
condition:
- media_player.is_announcing:
timeout: 3s
- wait_until:
- and:
- not:
media_player.is_announcing:
- not:
speaker.is_playing:
- lambda: id(va).set_use_wake_word(false);
- micro_wake_word.start:
- script.execute: set_idle_or_mute_phase
- text_sensor.template.publish:
id: text_request
state: ""
- text_sensor.template.publish:
id: text_response
state: ""
- script.execute: draw_display
on_error:
- if:
condition:
lambda: return !id(init_in_progress);
then:
- lambda: id(voice_assistant_phase) = ${voice_assist_error_phase_id};
- script.execute: draw_display
- delay: 1s
- if:
condition:
switch.is_off: mute
then:
- lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id};
else:
- lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id};
- script.execute: draw_display
on_client_connected:
- lambda: id(init_in_progress) = false;
- script.execute: start_wake_word
- script.execute: set_idle_or_mute_phase
- script.execute: draw_display
on_client_disconnected:
- script.execute: stop_wake_word
- lambda: id(voice_assistant_phase) = ${voice_assist_not_ready_phase_id};
- script.execute: draw_display
# Timer Event Stubs - HA handles actual timer logic
on_timer_started:
- logger.log:
format: "Timer started (handled by HA): %s"
args: ["timer.id.c_str()"]
on_timer_finished:
- logger.log: "Timer finished event received (handled by HA automation)"
on_timer_cancelled:
- logger.log: "Timer cancelled (handled by HA)"
on_timer_updated:
- logger.log: "Timer updated (handled by HA)"
on_timer_tick:
- lambda: return;
Touchscreen and Binary Sensors¶
# file: esphome/examples/waveshare-touch-lcd-183-voice-assistant.yaml
# section: touchscreen
touchscreen:
- platform: cst816
i2c_id: internal_i2c
id: cst816_touchscreen
interrupt_pin: ${touch_int_pin}
reset_pin: ${touch_rst_pin}
# file: esphome/examples/waveshare-touch-lcd-183-voice-assistant.yaml
# section: binary_sensor
binary_sensor:
- platform: touchscreen
touchscreen_id: cst816_touchscreen
id: touch_area
x_min: 0
x_max: ${display_width}
y_min: 0
y_max: ${display_height}
on_press:
then:
- if:
condition:
lambda: return !id(init_in_progress);
then:
- if:
condition:
switch.is_on: timer_ringing
then:
- switch.turn_off: timer_ringing
else:
- if:
condition:
voice_assistant.is_running:
then:
- voice_assistant.stop:
else:
- if:
condition:
media_player.is_announcing:
then:
media_player.stop:
announcement: true
else:
- if:
condition:
media_player.is_playing:
then:
- media_player.pause:
else:
- if:
condition:
and:
- switch.is_off: mute
- not: voice_assistant.is_running
then:
- media_player.speaker.play_on_device_media_file:
media_file: wake_word_triggered_sound_file
announcement: true
- wait_until:
- not:
- media_player.is_announcing:
- voice_assistant.start:
- platform: gpio
pin:
number: ${power_button_pin}
mode: INPUT_PULLUP
inverted: true
id: power_button
internal: true
on_multi_click:
- timing:
- ON for at least 50ms
- OFF for at least 50ms
then:
- switch.toggle: mute
- platform: gpio
pin:
number: ${boot_button_pin}
mode: INPUT_PULLUP
inverted: true
id: boot_button
internal: true
on_multi_click:
- timing:
- ON for at least 50ms
- OFF for at least 50ms
then:
- switch.turn_off: timer_ringing
- timing:
- ON for at least 10s
then:
- button.press: factory_reset_btn
Output, Light, and Switches¶
# file: esphome/examples/waveshare-touch-lcd-183-voice-assistant.yaml
# section: switch
output:
- platform: ledc
pin: ${display_backlight_pin}
id: backlight_output
light:
- platform: monochromatic
id: led
name: Screen
icon: "mdi:television"
entity_category: config
output: backlight_output
restore_mode: RESTORE_DEFAULT_ON
default_transition_length: 250ms
switch:
- platform: gpio
name: Speaker Enable
pin: ${pa_enable_pin}
restore_mode: RESTORE_DEFAULT_ON
entity_category: config
disabled_by_default: true
- platform: template
name: Mute
id: mute
icon: "mdi:microphone-off"
optimistic: true
restore_mode: RESTORE_DEFAULT_OFF
entity_category: config
on_turn_off:
- microphone.unmute:
- lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id};
- script.execute: draw_display
on_turn_on:
- microphone.mute:
- lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id};
- script.execute: draw_display
- platform: template
id: timer_ringing
name: "Timer Ringing"
optimistic: true
restore_mode: ALWAYS_OFF
icon: "mdi:bell-ring-outline"
on_turn_off:
- lambda: |-
id(speaker_media_player)
->make_call()
.set_command(media_player::MediaPlayerCommand::MEDIA_PLAYER_COMMAND_REPEAT_OFF)
.set_announcement(true)
.perform();
id(speaker_media_player)->set_playlist_delay_ms(speaker::AudioPipelineType::ANNOUNCEMENT, 0);
- media_player.stop:
announcement: true
- script.execute: set_idle_or_mute_phase
- script.execute: draw_display
on_turn_on:
- lambda: id(voice_assistant_phase) = ${voice_assist_timer_finished_phase_id};
- script.execute: draw_display
- lambda: |-
id(speaker_media_player)
->make_call()
.set_command(media_player::MediaPlayerCommand::MEDIA_PLAYER_COMMAND_REPEAT_ONE)
.set_announcement(true)
.perform();
id(speaker_media_player)->set_playlist_delay_ms(speaker::AudioPipelineType::ANNOUNCEMENT, 1000);
- media_player.speaker.play_on_device_media_file:
media_file: timer_finished_sound
announcement: true
- delay: 15min
- switch.turn_off: timer_ringing
Global Variables¶
# file: esphome/examples/waveshare-touch-lcd-183-voice-assistant.yaml
# section: globals
globals:
- id: init_in_progress
type: bool
restore_value: false
initial_value: "true"
- id: voice_assistant_phase
type: int
restore_value: false
initial_value: ${voice_assist_not_ready_phase_id}
- id: ota_progress
type: int
restore_value: false
initial_value: "0"
- id: announcement_in_progress
type: bool
restore_value: false
initial_value: "false"
- id: landscape_mode
type: bool
restore_value: false
initial_value: "false"
Scripts¶
# file: esphome/examples/waveshare-touch-lcd-183-voice-assistant.yaml
# section: script
script:
- id: draw_display
then:
- if:
condition:
lambda: return !id(init_in_progress);
then:
- if:
condition:
lambda: return id(landscape_mode);
then:
- display.page.show: landscape_warning_page
- component.update: lcd_display
else:
- if:
condition:
wifi.connected:
then:
- if:
condition:
api.connected:
then:
- lambda: |
switch(id(voice_assistant_phase)) {
case ${voice_assist_listening_phase_id}:
id(lcd_display).show_page(listening_page);
id(lcd_display).update();
break;
case ${voice_assist_thinking_phase_id}:
id(lcd_display).show_page(thinking_page);
id(lcd_display).update();
break;
case ${voice_assist_replying_phase_id}:
id(lcd_display).show_page(replying_page);
id(lcd_display).update();
break;
case ${voice_assist_error_phase_id}:
id(lcd_display).show_page(error_page);
id(lcd_display).update();
break;
case ${voice_assist_muted_phase_id}:
id(lcd_display).show_page(muted_page);
id(lcd_display).update();
break;
case ${voice_assist_not_ready_phase_id}:
id(lcd_display).show_page(no_ha_page);
id(lcd_display).update();
break;
case ${voice_assist_timer_finished_phase_id}:
id(lcd_display).show_page(timer_finished_page);
id(lcd_display).update();
break;
case ${voice_assist_ota_phase_id}:
id(lcd_display).show_page(ota_page);
id(lcd_display).update();
break;
default:
id(lcd_display).show_page(idle_page);
id(lcd_display).update();
}
else:
- display.page.show: no_ha_page
- component.update: lcd_display
else:
- display.page.show: no_wifi_page
- component.update: lcd_display
else:
- display.page.show: initializing_page
- component.update: lcd_display
# Number-to-words status bar for timer display
- id: draw_status_bar
then:
- lambda: |
const char* ones[] = {"", "one", "two", "three", "four", "five", "six", "seven",
"eight", "nine", "ten", "eleven", "twelve", "thirteen",
"fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen"};
const char* tens[] = {"", "", "twenty", "thirty", "forty", "fifty",
"sixty", "seventy", "eighty", "ninety"};
auto num_to_words = [&](int n, char* buf, size_t buf_size) {
if (n == 0) {
snprintf(buf, buf_size, "zero");
return;
}
std::string result;
if (n >= 100) {
int hundreds = n / 100;
result += ones[hundreds];
result += " hundred";
n %= 100;
if (n > 0) result += " ";
}
if (n >= 20) {
result += tens[n / 10];
if (n % 10 != 0) {
result += "-";
result += ones[n % 10];
}
} else if (n > 0) {
result += ones[n];
}
snprintf(buf, buf_size, "%s", result.c_str());
};
std::string state = id(timer_state).state;
int remaining = (int)id(timer_remaining).state;
int duration = (int)id(timer_duration).state;
if ((state == "active" || state == "paused") && remaining > 0) {
id(lcd_display).filled_rectangle(0, 200, 240, 84, Color::BLACK);
char num_word[32];
char unit_text[32];
if (remaining >= 60) {
int mins = (remaining + 59) / 60;
num_to_words(mins, num_word, sizeof(num_word));
snprintf(unit_text, sizeof(unit_text), "minute%s remaining", (mins == 1) ? "" : "s");
} else {
num_to_words(remaining, num_word, sizeof(num_word));
snprintf(unit_text, sizeof(unit_text), "second%s remaining", (remaining == 1) ? "" : "s");
}
id(lcd_display).printf(120, 207, id(font_timer_large), Color::WHITE, TextAlign::TOP_CENTER, "%s", num_word);
id(lcd_display).printf(120, 235, id(font_timer_large), Color::WHITE, TextAlign::TOP_CENTER, "%s", unit_text);
if (state == "paused") {
id(lcd_display).printf(120, 263, id(font_status), Color::WHITE, TextAlign::TOP_CENTER, "(paused)");
}
}
- id: start_wake_word
then:
- if:
condition:
not:
- voice_assistant.is_running:
then:
- lambda: id(va).set_use_wake_word(false);
- micro_wake_word.start:
- id: stop_wake_word
then:
- micro_wake_word.stop:
- id: set_idle_or_mute_phase
then:
- if:
condition:
switch.is_off: mute
then:
- lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id};
else:
- lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id};
- id: track_announcement_lifecycle
mode: restart
then:
- logger.log: "Announcement lifecycle: waiting for audio to start..."
- wait_until:
condition:
- media_player.is_announcing:
timeout: 30s
- if:
condition:
- not:
media_player.is_announcing:
then:
- logger.log: "Announcement lifecycle: timed out waiting for audio"
- lambda: id(announcement_in_progress) = false;
else:
- logger.log: "Announcement lifecycle: audio playing, waiting for completion..."
- wait_until:
condition:
- not:
media_player.is_announcing:
timeout: 10min
- logger.log: "Announcement lifecycle: complete"
- lambda: id(announcement_in_progress) = false;
Images, Fonts, Colors¶
# file: esphome/examples/waveshare-touch-lcd-183-voice-assistant.yaml
# section: image
image:
- file: ${error_illustration_file}
id: casita_error
resize: 240x240
type: RGB
transparency: alpha_channel
- file: ${idle_illustration_file}
id: casita_idle
resize: 240x240
type: RGB
transparency: alpha_channel
- file: ${listening_illustration_file}
id: casita_listening
resize: 240x240
type: RGB
transparency: alpha_channel
- file: ${thinking_illustration_file}
id: casita_thinking
resize: 240x240
type: RGB
transparency: alpha_channel
- file: ${replying_illustration_file}
id: casita_replying
resize: 240x240
type: RGB
transparency: alpha_channel
- file: ${timer_finished_illustration_file}
id: casita_timer_finished
resize: 240x240
type: RGB
transparency: alpha_channel
- file: ${loading_illustration_file}
id: casita_initializing
resize: 240x240
type: RGB
transparency: alpha_channel
- file: https://github.com/esphome/wake-word-voice-assistants/raw/main/error_box_illustrations/error-no-wifi.png
id: error_no_wifi
resize: 240x240
type: RGB
transparency: alpha_channel
- file: https://github.com/esphome/wake-word-voice-assistants/raw/main/error_box_illustrations/error-no-ha.png
id: error_no_ha
resize: 240x240
type: RGB
transparency: alpha_channel
# file: esphome/examples/waveshare-touch-lcd-183-voice-assistant.yaml
# section: font
font:
- file:
type: gfonts
family: ${font_family}
weight: 300
italic: true
id: font_request
size: 13
glyphsets:
- ${font_glyphsets}
- file:
type: gfonts
family: ${font_family}
weight: 300
id: font_response
size: 13
glyphsets:
- ${font_glyphsets}
- file:
type: gfonts
family: ${font_family}
weight: 300
id: font_timer
size: 26
glyphsets:
- ${font_glyphsets}
- file:
type: gfonts
family: ${font_family}
weight: 700
id: font_ota
size: 20
glyphsets:
- ${font_glyphsets}
- file:
type: gfonts
family: ${font_family}
weight: 400
id: font_status
size: 14
glyphsets:
- ${font_glyphsets}
- file:
type: gfonts
family: ${font_family}
weight: 400
id: font_timer_large
size: 24
glyphsets:
- ${font_glyphsets}
- file:
type: gfonts
family: ${font_family}
weight: 700
id: font_warning_title
size: 28
glyphsets:
- ${font_glyphsets}
- file:
type: gfonts
family: ${font_family}
weight: 400
id: font_warning_body
size: 16
glyphsets:
- ${font_glyphsets}
# file: esphome/examples/waveshare-touch-lcd-183-voice-assistant.yaml
# section: color
color:
- id: idle_color
hex: ${idle_illustration_background_color}
- id: listening_color
hex: ${listening_illustration_background_color}
- id: thinking_color
hex: ${thinking_illustration_background_color}
- id: replying_color
hex: ${replying_illustration_background_color}
- id: loading_color
hex: ${loading_illustration_background_color}
- id: error_color
hex: ${error_illustration_background_color}
- id: timer_finished_color
hex: ${timer_finished_illustration_background_color}
- id: muted_color
hex: "000000"
- id: ota_progress_color
hex: "ff6600"
- id: warning_red
hex: "FF3030"
- id: warning_orange
hex: "FF6A00"
Display¶
# file: esphome/examples/waveshare-touch-lcd-183-voice-assistant.yaml
# section: display
display:
- platform: st7789v
id: lcd_display
model: Custom
height: ${display_height}
width: ${display_width}
rotation: 180
offset_height: 0
offset_width: 0
cs_pin: ${display_cs_pin}
dc_pin: ${display_dc_pin}
reset_pin: ${display_rst_pin}
eightbitcolor: false
update_interval: never
pages:
- id: idle_page
lambda: |-
it.fill(id(idle_color));
it.image(120, 0, id(casita_idle), ImageAlign::TOP_CENTER);
std::string state = id(timer_state).state;
bool timer_active = (state == "active" || state == "paused");
if (timer_active) {
id(draw_status_bar).execute();
} else {
it.printf(120, 230, id(font_status), Color::WHITE, TextAlign::TOP_CENTER, "Say 'Okay Nabu'");
it.printf(120, 250, id(font_status), Color::WHITE, TextAlign::TOP_CENTER, "to get started");
}
- id: listening_page
lambda: |-
it.fill(id(listening_color));
it.image(120, 0, id(casita_listening), ImageAlign::TOP_CENTER);
std::string state = id(timer_state).state;
bool timer_active = (state == "active" || state == "paused");
if (timer_active) {
id(draw_status_bar).execute();
} else {
it.printf(120, 240, id(font_status), Color::BLACK, TextAlign::TOP_CENTER, "Listening...");
}
- id: thinking_page
lambda: |-
it.fill(id(thinking_color));
it.image(120, 0, id(casita_thinking), ImageAlign::TOP_CENTER);
it.filled_rectangle(10, 15, 220, 25, Color::WHITE);
it.rectangle(10, 15, 220, 25, Color::BLACK);
it.printf(15, 18, id(font_request), Color::BLACK, "%s", id(text_request).state.c_str());
std::string state = id(timer_state).state;
bool timer_active = (state == "active" || state == "paused");
if (timer_active) {
id(draw_status_bar).execute();
} else {
it.printf(120, 240, id(font_status), Color::BLACK, TextAlign::TOP_CENTER, "Thinking...");
}
- id: replying_page
lambda: |-
it.fill(id(replying_color));
it.image(120, 0, id(casita_replying), ImageAlign::TOP_CENTER);
it.filled_rectangle(10, 15, 220, 25, Color::WHITE);
it.rectangle(10, 15, 220, 25, Color::BLACK);
it.printf(15, 18, id(font_request), Color::BLACK, "%s", id(text_request).state.c_str());
std::string state = id(timer_state).state;
bool timer_active = (state == "active" || state == "paused");
if (timer_active) {
id(draw_status_bar).execute();
}
- id: timer_finished_page
lambda: |-
it.fill(id(timer_finished_color));
it.image(120, 0, id(casita_timer_finished), ImageAlign::TOP_CENTER);
it.printf(120, 240, id(font_timer_large), Color::BLACK, TextAlign::TOP_CENTER, "Timer finished!");
it.printf(120, 265, id(font_status), Color::BLACK, TextAlign::TOP_CENTER, "Tap to dismiss");
- id: error_page
lambda: |-
it.fill(id(error_color));
it.image(120, 0, id(casita_error), ImageAlign::TOP_CENTER);
std::string state = id(timer_state).state;
bool timer_active = (state == "active" || state == "paused");
if (timer_active) {
id(draw_status_bar).execute();
} else {
it.printf(120, 240, id(font_status), Color::WHITE, TextAlign::TOP_CENTER, "An error occurred");
}
- id: no_ha_page
lambda: |-
it.fill(Color::BLACK);
it.image(120, 0, id(error_no_ha), ImageAlign::TOP_CENTER);
id(draw_status_bar).execute();
- id: no_wifi_page
lambda: |-
it.fill(Color::BLACK);
it.image(120, 0, id(error_no_wifi), ImageAlign::TOP_CENTER);
id(draw_status_bar).execute();
- id: initializing_page
lambda: |-
it.fill(id(loading_color));
it.image(120, 0, id(casita_initializing), ImageAlign::TOP_CENTER);
it.printf(120, 240, id(font_status), Color::WHITE, TextAlign::TOP_CENTER, "Initializing...");
- id: muted_page
lambda: |-
it.fill(id(muted_color));
std::string state = id(timer_state).state;
bool timer_active = (state == "active" || state == "paused");
if (timer_active) {
id(draw_status_bar).execute();
} else {
it.printf(120, 142, id(font_status), Color::WHITE, TextAlign::CENTER, "Microphone muted");
}
- id: ota_page
lambda: |-
it.fill(id(error_color));
it.image(120, 0, id(casita_error), ImageAlign::TOP_CENTER);
it.filled_rectangle(0, 200, 240, 84, Color::BLACK);
it.filled_rectangle(10, 208, 220, 16, Color(0x202020));
it.rectangle(10, 208, 220, 16, Color::WHITE);
int progress_width = (id(ota_progress) * 216) / 100;
if (progress_width > 0) {
it.filled_rectangle(12, 210, progress_width, 12, id(ota_progress_color));
}
if (id(ota_progress) >= 0) {
it.printf(120, 232, id(font_status), Color::WHITE, TextAlign::TOP_CENTER, "Upgrading: %d%%", id(ota_progress));
} else {
it.printf(120, 232, id(font_status), Color::WHITE, TextAlign::TOP_CENTER, "Update Failed!");
}
- id: landscape_warning_page
lambda: |-
int w = it.get_width();
int h = it.get_height();
int cx = w / 2;
it.fill(Color::BLACK);
// Hazard stripes
for (int i = 0; i < w; i += 20) {
it.filled_rectangle(i, 0, 10, 8, id(warning_orange));
it.filled_rectangle(i, h - 8, 10, 8, id(warning_orange));
}
it.printf(cx, 40, id(font_warning_title), id(warning_red), TextAlign::TOP_CENTER, "WARNING");
it.horizontal_line(20, 75, w - 40, id(warning_orange));
it.printf(cx, 90, id(font_warning_body), Color::WHITE, TextAlign::TOP_CENTER, "Device orientation");
it.printf(cx, 115, id(font_warning_body), Color::WHITE, TextAlign::TOP_CENTER, "not supported");
it.horizontal_line(20, 150, w - 40, id(warning_orange));
it.printf(cx, 170, id(font_status), id(warning_orange), TextAlign::TOP_CENTER, "Please rotate to");
it.printf(cx, 190, id(font_status), id(warning_orange), TextAlign::TOP_CENTER, "portrait mode");
Testing¶
After flashing:
- Say "Okay Nabu" followed by "Set a timer for 1 minute"
- Verify the timer countdown appears at the bottom of the display showing words (e.g., "one minute remaining")
- Wait for timer to complete or say "Cancel the timer"
- Verify the alarm plays and can be dismissed by touching the screen
- Test orientation by tilting the device to landscape - a warning should appear
Notes¶
- This device uses the QMI8658 IMU for orientation detection. An external component from
github://Djelibeybi/esphome-qmi8658is required. - The 240x284 display has a 44-pixel status bar at the bottom for timer information.
- Timer countdown uses a natural language format (e.g., "forty-five minutes remaining" instead of "45:00").