Skip to content

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:

  1. Say "Okay Nabu" followed by "Set a timer for 1 minute"
  2. Verify the timer countdown appears at the bottom of the display showing words (e.g., "one minute remaining")
  3. Wait for timer to complete or say "Cancel the timer"
  4. Verify the alarm plays and can be dismissed by touching the screen
  5. 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-qmi8658 is 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").