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