SpotPear "DeepSeek Voice Chat" config

Hello community,

I recently picked up a cheap little module from Ali Express, called a “DeepSeek XiaoZhi AI Voice Chat Robot ESP32-S3 1.28 inch LCD N16R8 Development Board Astronaut Clock Desktop Ornament” for about 30 bucks CAD. It’s a nice little module, an ESP32-S3-WROOM-1 module, a 240x240 color LCD display (no touch, sadly), a max98357a DAC and speaker, and an INMP441 microphone. They’re nice little displays, and I’ve got a small config here that sets it up as a clock/weather display, it’ll show the info about the current track I’m listening to on my owntone server, and it will act as a voice assistant (similar to the S3-BOX-3).

You can find the product page here, but information is scarce. It took me quite a while to figure out the correct pinouts for all the connections, and the on-board LED eludes me still.

Here’s the config, as it currently stands. I’m still working on the on-device wake word detection, it’s not quite there yet, so the back button on the left side is currently used to trigger the voice assistant.

substitutions:
  mediaplayer: media_player.owntone_server
  current_weather: weather.my_weather_entity
  screensaver: 30min
  # Pin Connections for the SpotPear Esp32 S3 N16R8 board
  dcpin: GPIO10
  cspin: GPIO13
  clpin: GPIO14
  mopin: GPIO17
  repin: GPIO18
  bkpin: GPIO3
  btnpin: GPIO0
  mic_ws: GPIO4
  mic_sclk: GPIO5
  mic_sd: GPIO6
  spk_din: GPIO7
  spk_blck: GPIO15
  spk_lrc: GPIO16

  loading_illustration_file: https://github.com/jptrsn/wake-word-voice-assistants/raw/main/casita/loading_240_240.png
  idle_illustration_file: https://github.com/jptrsn/wake-word-voice-assistants/raw/main/casita/idle_240_240.png
  listening_illustration_file: https://github.com/jptrsn/wake-word-voice-assistants/raw/main/casita/listening_240_240.png
  thinking_illustration_file: https://github.com/jptrsn/wake-word-voice-assistants/raw/main/casita/thinking_240_240.png
  replying_illustration_file: https://github.com/jptrsn/wake-word-voice-assistants/raw/main/casita/replying_240_240.png
  error_illustration_file: https://github.com/jptrsn/wake-word-voice-assistants/raw/main/casita/error_240_240.png
  timer_finished_illustration_file: https://github.com/jptrsn/wake-word-voice-assistants/raw/main/casita/timer_finished_240_240.png

esphome:
  name: mini-clock
  friendly_name: Mini Clock
  platformio_options:
    build_flags: "-DBOARD_HAS_PSRAM"  
    board_build.arduino.memory_type: qio_opi

esp32:
  board: esp32-s3-devkitc-1
  variant: esp32s3
  flash_size: 16MB
  framework:
    type: arduino
    version: recommended
    

# Enable logging
logger:

# Enable Home Assistant API
api:
  encryption:
    key: "pfedbcHEI7Zbd7QXXrA/CdvmJDI8uDz5vlwj7isjQbs="

ota:
  - platform: esphome
    password: "2dfdacbb0b9b403513cbec31dcfb0616"

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Mini-Clock Fallback Hotspot"
    password: "tMUPeiAXc2G3"

captive_portal:
    
spi:
  clk_pin: $clpin
  mosi_pin: $mopin

http_request:
  verify_ssl: false

i2s_audio:
  - id: speaker_i2s
    i2s_lrclk_pin: $spk_lrc
    i2s_bclk_pin: $spk_blck
  - id: mic_i2s
    i2s_lrclk_pin: $mic_ws
    i2s_bclk_pin: $mic_sclk

microphone:
  - platform: i2s_audio
    i2s_audio_id: mic_i2s
    adc_type: external
    i2s_din_pin: $mic_sd
    id: adc_mic
    pdm: false
    bits_per_sample: 16bit
    channel: left
    # on_data:
    #   - logger.log: 
    #       format: "Received %d bytes"
    #       args: ['x.size()']

media_player:
  - platform: i2s_audio
    i2s_audio_id: speaker_i2s
    dac_type: external
    i2s_dout_pin: $spk_din
    name: "ESP32 Media Player"
    mode: mono
    id: media_out

voice_assistant:
  microphone: adc_mic
  media_player: media_out
  use_wake_word: false
  noise_suppression_level: 2
  auto_gain: 31dBFS
  volume_multiplier: 2.0
  id: assist
  conversation_timeout: 30s
  on_start:
    then:
      - script.execute: show_assistant
      - lvgl.image.update:
          id: assistant_img_widget
          src: casita_initializing
  on_listening: 
    then:
      - lvgl.image.update:
          id: assistant_img_widget
          src: casita_listening
      - lvgl.label.update:
          id: text_request
          text: "..."
          hidden: true
      - lvgl.label.update:
          id: text_response
          text: "..."
          hidden: true
  on_stt_vad_end:
    then:
      - lvgl.image.update:
          id: assistant_img_widget
          src: casita_thinking
  # on_stt_end:
  #   then:
  #     - lvgl.label.update:
  #         id: text_request
  #         hidden: false
  #         text: !lambda return x;
  on_tts_start:
    then:
      - lvgl.image.update:
          id: assistant_img_widget
          src: casita_replying
      - lvgl.label.update:
          id: text_response
          hidden: false
          text: !lambda return x;
  on_tts_end:
    then:
      - lvgl.image.update:
          id: assistant_img_widget
          src: casita_idle
  on_end:
    then:
      - delay: 15s
      - media_player.stop:
          id: media_out
          announcement: true
      - script.execute: show_clock_page
      - lvgl.label.update:
          id: text_response
          hidden: true
      - lvgl.label.update:
          id: text_request
          hidden: true

# micro_wake_word:
#   vad:
#   models:
#     - model: okay_nabu
#   on_wake_word_detected:
#     then:
#       - voice_assistant.start:
#           wake_word: !lambda return wake_word;

output:
  - platform: ledc
    pin: $bkpin
    id: backlight_pwm

light:
  - platform: monochromatic
    output: backlight_pwm
    name: "Clock Backlight"
    id: back_light
    restore_mode: ALWAYS_ON
    on_turn_off: 
      then:
        - lvgl.pause:
            show_snow: true
    on_turn_on: 
      then:
        - lvgl.resume:

text_sensor:
  - platform: wifi_info
    ip_address:
      name: "IP Address"
  - platform: homeassistant
    id: music_state
    entity_id: $mediaplayer
  - platform: homeassistant
    id: weather_state
    entity_id: $current_weather
    on_value:
        - script.execute: update_current_weather_icon
  - platform: homeassistant
    id: track_name
    entity_id: $mediaplayer
    attribute: media_title
    on_value:
      then:
        - lvgl.label.update:
            id: track_name_label
            text: !lambda return x;
  - platform: homeassistant
    id: track_artist
    entity_id: $mediaplayer
    attribute: media_artist
    on_value:
      then:
        - lvgl.label.update:
            id: track_artist_label
            text: !lambda return x;
  - platform: homeassistant
    id: track_album
    entity_id: $mediaplayer
    attribute: media_album_name
    on_value:
      then:
        - lvgl.label.update:
            id: track_album_label
            text: !lambda return x;
            
  - platform: template
    id: outside_condition_icon
    on_value:
      then:
        - lvgl.label.update:
            id: forecast_label
            text: !lambda return x;
  - platform: homeassistant
    id: track_path
    entity_id: $mediaplayer
    attribute: entity_picture
    on_value:
      then:
        - online_image.set_url:
            id: cover_art
            url: !lambda 'return "local_ip_address_and_port_here" + id(track_path).state;'
        - component.update: cover_art              

sensor:
  - platform: homeassistant
    id: outdoor_temperature
    entity_id: $current_weather
    attribute: temperature
    on_value: 
      then:
        - lvgl.label.update:
            id: temp_widget
            text: 
              format: "%2.1f°"
              args: [ 'x' ]

binary_sensor:
  - platform: gpio
    pin: 
      number: $btnpin
      inverted: true
    id: back_button
    on_release:
      then:
        # - switch.toggle: clock_face_simple
        - voice_assistant.start
  - platform: template
    id: is_playing
    lambda: 'return id(music_state).state == "playing";'
    filters:
      - delayed_off: 2500ms
    on_press:
      then:
        - script.execute: show_music_data
    on_release:
      then:
        - script.execute: hide_music_data
    
switch:
  - platform: template
    id: clock_face_simple
    optimistic: true
    turn_on_action:
      - script.execute: show_simple_time
    turn_off_action:
      - script.execute: show_full_time

display:
  - platform: ili9xxx
    model: GC9A01A #ST7789V
    dc_pin: $dcpin
    reset_pin: $repin
    cs_pin: $cspin
    invert_colors: true
    dimensions: 
      height: 240
      width: 240
    rotation: 0

font:
  - file: "gfonts://Roboto"
    id: roboto
    size: 14
    glyphsets:
      - GF_Latin_Kernel
    glyphs: [
      "-"
    ]
  - file: "gfonts://Material+Symbols+Outlined"
    id: icons_med
    size: 24
    glyphs: [
      "\U0000e2bd", #cloud
      "\U0000e818", #foggy
      "\U0000f157", #clear day
      "\U0000f67f", #weather-hail
      "\U0000ebdb", #thunderstorm
      "\U0000f172", #partly cloudy day
      "\U0000f176", #rainy
      "\U0000f61f", #rainy-heavy
      "\U0000f61d", #rainy-snow
      "\U0000e80f", #snowing
      "\U0000e2cd", #snowy
      "\U0000e81a", #sunny
      "\U0000e29c", #airwave
      "\U0000efd8", #air
      "\U0000e51c", #darkmode
      "\U0000e002", #warning
      "\U0000eabd", #unknown med
      "\U0000e043", #shuffle
      "\U0000e040", #repeat
      "\U0000e8b5", #schedule
    ]
  - file: "gfonts://Dancing+Script"
    id: dancing_script
    size: 32
    glyphs: ["0123456789"]

online_image:
  - id: cover_art
    format: jpeg
    url: "http://local_ip_address_and_port_here/api/media_player_proxy/media_player.owntone_server"
    resize: 240x240
    type: RGB565
    on_download_finished:
      then:
        - lvgl.image.update:
            id: cover_art_widget
            src: cover_art
        - lvgl.widget.show:
            id: cover_art_widget
    on_error:
      then:
        - online_image.release: cover_art
        - lvgl.widget.hide:
            id: cover_art_widget

image:
  - file: $loading_illustration_file
    type: RGB565
    id: casita_initializing
    resize: 240x240
  - file: $idle_illustration_file
    type: RGB565
    id: casita_idle
    resize: 240x240
  - file: $listening_illustration_file
    type: RGB565
    id: casita_listening
    resize: 240x240
  - file: $thinking_illustration_file
    type: RGB565
    id: casita_thinking
    resize: 240x240
  - file: $replying_illustration_file
    type: RGB565
    id: casita_replying
    resize: 240x240
  - file: $error_illustration_file
    type: RGB565
    id: casita_error
    resize: 240x240
  - file: $timer_finished_illustration_file
    type: RGB565
    id: casita_timer_finished
    resize: 240x240

time:
  - platform: sntp
    timezone: "America/Toronto"
    id: esptime
    on_time_sync: 
      then:
        - script.execute: update_date
        - script.execute: update_hours
        - script.execute: update_minutes
        - script.execute: update_seconds
        - lvgl.widget.show: clock_hands
    on_time:
      - cron: '* * * * * *' # every second
        then:
          - script.execute: update_seconds
      - seconds: 0 # every minute at zero seconds
        then:
          - script.execute: update_minutes
      - minutes: 0 # every hour at zero minutes
        then:
          - script.execute: update_hours
          - script.execute: update_date

script:
  - id: update_seconds
    then:
      - lvgl.indicator.update:
          id: second_hand
          value: !lambda |-
            return id(esptime).now().second;
  - id: update_minutes
    then:
      - lvgl.indicator.update:
          id: minute_hand
          value: !lambda |-
            return id(esptime).now().minute;
  - id: update_hours
    then:
      - lvgl.indicator.update:
          id: hour_hand
          value: !lambda |-
            auto now = id(esptime).now();
            return std::fmod(now.hour, 12) * 60 + now.minute;
  - id: update_date
    then:
      - lvgl.label.update:
          id: date_label
          text: 
            time_format: "%b %d"
            time: esptime
      - lvgl.label.update:
          id: day_label
          text:
            time_format: "%a"
            time: esptime
  - id: show_simple_time
    then:
      - light.dim_relative:
          id: back_light
          relative_brightness: -75%
          transition_length: 1s
      - delay: 1s
      - lvgl.widget.hide: clock_face
      - lvgl.widget.hide: day_label
      - lvgl.widget.hide: date_label
      - lvgl.widget.hide: forecast_label
      - lvgl.widget.hide: temp_widget
  - id: show_full_time
    then:
      - script.stop: show_simple_time
      - lvgl.widget.show: clock_face
      - lvgl.widget.show: day_label
      - lvgl.widget.show: date_label
      - lvgl.widget.show: forecast_label
      - lvgl.widget.show: temp_widget
      - light.control:
          id: back_light
          brightness: 100%
          state: on
          transition_length: 1s
  - id: update_current_weather_icon
    then:
      - lambda: |-
          if (id(weather_state).state == "clear-night") {
            id(outside_condition_icon).publish_state("\U0000e51c");
          } else if (id(weather_state).state == "cloudy") {
            id(outside_condition_icon).publish_state("\U0000e2bd");
          } else if (id(weather_state).state == "fog") {
            id(outside_condition_icon).publish_state("\U0000e818");
          } else if (id(weather_state).state == "hail") {
            id(outside_condition_icon).publish_state("\U0000f67f");
          } else if (id(weather_state).state == "lightning") {
            id(outside_condition_icon).publish_state("\U0000ebdb");
          } else if (id(weather_state).state == "lightning-rainy") {
            id(outside_condition_icon).publish_state("\U0000ebdb");
          } else if (id(weather_state).state == "partlycloudy") {
            id(outside_condition_icon).publish_state("\U0000f172");
          } else if (id(weather_state).state == "pouring") {
            id(outside_condition_icon).publish_state("\U0000f61f");
          } else if (id(weather_state).state == "rainy") {
            id(outside_condition_icon).publish_state("\U0000f176");
          } else if (id(weather_state).state == "snowy") {
            id(outside_condition_icon).publish_state("\U0000e2cd");
          } else if (id(weather_state).state == "snowy-rainy") {
            id(outside_condition_icon).publish_state("\U0000f61d");
          } else if (id(weather_state).state == "sunny") {
            id(outside_condition_icon).publish_state("\U0000e81a");
          } else if (id(weather_state).state == "windy") {
            id(outside_condition_icon).publish_state("\U0000e29c");
          } else if (id(weather_state).state == "windy-variant") {
            id(outside_condition_icon).publish_state("\efd8");
          } else if (id(weather_state).state == "exceptional") {
            id(outside_condition_icon).publish_state("\U0000e002");
          } else {
            id(outside_condition_icon).publish_state("");
          }
  - id: show_clock_page
    then:
      - lvgl.page.show:
          id: clock_page
          time: 250ms
          animation: "FADE_OUT"
  - id: show_music_data
    then:
      - lvgl.page.show:
          id: music_page
          time: 250ms
          animation: "FADE_OUT"
  - id: hide_music_data
    then:
      - script.execute: show_clock_page
      - online_image.release: cover_art
  - id: show_assistant
    then:
      - lvgl.page.show:
          id: assistant_page
          time: 250ms
          animation: "OVER_LEFT"

lvgl:
  buffer_size: 25%
  height: 240
  width: 240
  bg_color: 0x000000
  style_definitions:
    - id: date_style
      text_font: unscii_8
      align: center
      text_color: 0xD0D0D0
      bg_color: 0x000000
      bg_opa: 25%
      radius: 4
      pad_all: 2
    - id: icon_style
      text_font: icons_med
      align: center
      text_color: 0x707070
    - id: clock_data
      text_font: roboto
      align: center
      text_color: 0xA0A0A0
      radius: 12
      pad_all: 3
      bg_opa: 25%
      bg_color: 0x000000
    - id: clock_media
      text_font: roboto
      text_color: 0xFFFFFF
      bg_color: 0x000000
      pad_all: 6
      radius: 18
      bg_opa: 75%

  pages:
    - id: clock_page
      bg_color: 0x000000
      width: 240
      height: 240
      scrollbar_mode: "OFF"
      widgets:
        - obj:
            id: clock_face
            bg_color: 0x000000
            border_width: 0
            align: CENTER
            width: 240
            height: 240
            scrollbar_mode: "OFF"
            widgets:
              - meter: #clock face
                  id: clock_face_fancy
                  height: 240
                  width: 240
                  align: CENTER
                  bg_opa: TRANSP
                  border_width: 0
                  text_color: 0xD00000
                  text_font: dancing_script
                  scales:
                    - range_from: 0 # minutes scale
                      range_to: 60
                      angle_range: 360
                      rotation: 270
                      ticks:
                        width: 2
                        count: 61
                        length: 2
                        color: 0x0A0A0A
                    - range_from: 0 # hours scale for labels
                      range_to: 11
                      angle_range: 330
                      rotation: 270
                      ticks:
                        width: 2
                        count: 12
                        length: 2
                        major:
                          stride: 3
                          width: 0
                          length: 0
                          color: 0x5F0606
                          label_gap: 3
        - meter: # clock hands
            height: 240
            width: 240
            id: clock_hands
            hidden: true
            align: CENTER
            bg_opa: TRANSP
            border_width: 0
            text_color: 0xFFFFFF
            scales:
              - range_from: 0 # minutes scale
                range_to: 60
                angle_range: 360
                rotation: 270
                ticks:
                  count: 0
                indicators:
                  - line:
                      id: minute_hand
                      width: 3
                      color: 0xA0A0A0
                      r_mod: -20
                      value: 0
                  - line:
                      id: second_hand
                      width: 2
                      color: 0xff1000
                      r_mod: -12
              - range_from: 0 # hi-res hours scale for hand
                range_to: 720
                angle_range: 360
                rotation: 270
                ticks:
                  count: 0
                indicators:
                  - line:
                      id: hour_hand
                      width: 5
                      color: 0xa6a6a6
                      r_mod: -45
                      value: 0
        - label:
            styles: date_style
            id: day_label
            y: 52
        - label:
            id: date_label
            styles: date_style
            y: 65
        - label:
            align: CENTER
            id: temp_widget
            y: 32
            x: -40
            styles: clock_data
            text: "---"
        - label:
            align: CENTER
            id: forecast_label
            x: 45
            y: 32
            styles: icon_style
            text: "\U0000eabd"
        # - spinner:
        #     id: loading_spinner
        #     arc_length: 36
        #     arc_rounded: true
        #     spin_time: 2s
        #     align: CENTER
    - id: music_page
      bg_color: 0x000000
      width: 240
      height: 240
      scrollbar_mode: "OFF"
      widgets:
        - image:
            align: CENTER
            src: cover_art
            id: cover_art_widget
            antialias: true
            hidden: true
            width: 240
        - obj:
            align: CENTER
            width: 240
            height: 240
            pad_all: 3
            bg_opa: transp
            border_opa: transp
            layout:
              type: FLEX
              flex_flow: COLUMN
              flex_align_main: CENTER
              flex_align_cross: CENTER
              flex_align_track: CENTER
            widgets:
              - label:
                  styles: clock_media
                  id: track_name_label
              - label:
                  styles: clock_media
                  id: track_artist_label
              - label:
                  styles: clock_media
                  id: track_album_label
    - id: assistant_page
      bg_color: 0x000000
      width: 240
      height: 240
      scrollbar_mode: "OFF"
      widgets:
        - image:
            align: CENTER
            src: casita_idle
            id: assistant_img_widget
            width: 240
            height: 240
            scrollbar_mode: "OFF"
        - label:
            styles: clock_media
            id: text_request
            align: BOTTOM_MID
            y: -55
        - label:
            styles: clock_media
            id: text_response
            align: BOTTOM_MID
            y: -35

Adding it here would be great https://devices.esphome.io/

Does yours have a V4 sticker on the bottom? I am curious if mine is a different configuration hardware wise than yours. Your config doesn’t work on mine OOTB. Not a complaint ofc

I’m not sure if it had that sticker, I removed them when the device first arrived. You can use a little heat on the adhesive around the edge of the screen to open it up, mine has this PCB inside.


I would recommend pruning everything except the hardware config, and test if the display shows the LVGL test screen when no pages are defined. That would help you diagnose if it’s different hardware or just a difference in our respective home assistant environments.

1 Like