Connect your device via USB and click the button below to install.
## SendspinZero Display Digital version
substitutions:
name: sendspinzero-display
friendly_name: Sendspin Zero Display
## SETTINGS ######################################################################################################################################
# Startup sound, options are: Home_Connected or Home_Connected_Male
startup_sound_file: Home_Connected
# Image model options are: AlbertQuackmore, Alita, Casita, Gwen, HA-character, ha-girl, Hacker, Salem, Sarah, Shaun
imagemodel: "HA-character"
# your home assistant weather entity, in most cases the same.
ha_weather_entity: weather.forecast_home
media_sample_rate: 44100
# Rotating the display, uncomment the block you need (default 0 degree)
# 0 degree
display_swap_xy: "false"
display_mirror_x: "false"
display_mirror_y: "false"
display_offset_width: "0"
display_offset_height: "0"
# 180 degree
# display_swap_xy: "false"
# display_mirror_x: "true"
# display_mirror_y: "true"
# display_offset_width: "0"
# display_offset_height: "80"
## SENDSPIN SETTINGS #############################################################################################################################
packages:
extcomp:
url: https://github.com/RealDeco/xiaozhi-esphome.git
ref: main
refresh: 0s
files:
- devices/Under_Development/Modular/HW/sendspin-external_components_2026.7.3.yaml
#packages: # local
# extcomp: !include localtest/HW/sendspin-external_components_2026.7.3.yaml
## END OF SETTINGS ###############################################################################################################################
esphome:
name: ${name}
friendly_name: ${friendly_name}
min_version: 2026.7.4
name_add_mac_suffix: true
project:
name: realdeco.sendspin_zero
version: "2026.7.4"
on_boot:
priority: -100
then:
- lvgl.page.show: idle_page
esp32:
variant: esp32s3
flash_size: 4MB
framework:
type: esp-idf
logger:
level: WARN
api:
on_client_connected:
- lambda: |-
if (!id(boot_sound_played)) {
id(boot_sound_played) = true;
if (id(startup_sound_switch).state) {
id(play_sound)->execute(true, std::string("ready_sound"));
}
}
ota:
- platform: esphome
wifi:
ap:
improv_serial:
psram:
mode: quad
speed: 80MHz
captive_portal:
button:
- platform: restart
id: restart_button
name: "Restart"
entity_category: config
icon: "mdi:restart"
time:
- platform: homeassistant
id: homeassistant_time
globals:
- id: sendspin_album_art_valid
type: bool
restore_value: no
initial_value: "false"
- id: last_album_art_valid
type: bool
restore_value: false
initial_value: 'false'
- id: boot_sound_played
type: bool
restore_value: no
initial_value: "false"
- id: boot_announce_active
type: bool
restore_value: no
initial_value: "false"
- id: weather_available
type: bool
restore_value: no
initial_value: "false"
image:
- platform: file
file: https://raw.githubusercontent.com/RealDeco/xiaozhi-esphome/main/images/${imagemodel}/240x240/playing.png
id: fallback_playing_image
resize: 216x216
type: RGB565
dither: NONE
- platform: sendspin
id: album_slot
format: JPEG
type: RGB565
resize: 216x216
display_offset: 1s
current_image:
id: album_art
transition_image:
id: album_art_transition
on_image_display:
- script.stop: album_art_timeout_fallback
- lambda: |-
id(sendspin_album_art_valid) = true;
- if:
condition:
lambda: 'return id(last_album_art_valid);'
then:
- lvgl.image.update:
id: outgoing_art
src: album_art_transition
else:
- lvgl.image.update:
id: outgoing_art
src: fallback_playing_image
- lvgl.image.update:
id: incoming_art
src: album_art
- lvgl.animation.start: album_art_crossfade
on_image_clear:
- logger.log:
format: "Album art cleared"
level: WARN
on_image_error:
- logger.log:
format: "Album art error"
level: WARN
- lambda: |-
id(sendspin_album_art_valid) = false;
sendspin:
id: sendspin_hub
i2s_audio:
- id: i2s_audio_bus
speaker:
- platform: i2s_audio
id: box_speaker
sample_rate: ${media_sample_rate}
i2s_dout_pin: GPIO4
spdif_mode: true
i2s_audio_id: i2s_audio_bus
dac_type: external
channel: stereo
use_apll: true
- platform: mixer
id: mixing_speaker
output_speaker: box_speaker
num_channels: 2
source_speakers:
- id: announcement_mixing_input
- id: media_mixing_input
- platform: resampler
id: announcement_resampling_speaker
output_speaker: announcement_mixing_input
sample_rate: ${media_sample_rate}
bits_per_sample: 16
- platform: resampler
id: media_resampling_speaker
output_speaker: media_mixing_input
sample_rate: ${media_sample_rate}
bits_per_sample: 16
audio_file:
- id: ready_sound
file: https://github.com/RealDeco/xiaozhi-esphome/raw/main/sounds/${startup_sound_file}.flac
media_source:
- platform: audio_file
id: audio_file_announcement_source
- platform: audio_http
id: http_announcement_source
- platform: audio_http
id: http_media_source
- platform: sendspin
id: sendspin_media_source
media_player:
- platform: sendspin
id: sendspin_group_media_player
name: Sendspin Group Media Player
- platform: speaker_source
id: external_media_player
name: Media Player
announcement_pipeline:
format: FLAC
num_channels: 1
sample_rate: ${media_sample_rate}
speaker: announcement_resampling_speaker
sources:
- audio_file_announcement_source
- http_announcement_source
media_pipeline:
format: FLAC
num_channels: 2
sample_rate: ${media_sample_rate}
speaker: media_resampling_speaker
sources:
- http_media_source
- sendspin_media_source
volume_increment: 0.05
volume_min: 0.4
volume_max: 0.85
on_play:
then:
- script.stop: mp_idle_debounce
- lvgl.page.show: sendspin_page
on_announcement:
then:
- script.stop: mp_idle_debounce
on_idle:
then:
- script.execute: mp_idle_debounce
on_pause:
then:
- script.execute: mp_idle_debounce
sensor:
- platform: homeassistant
id: ha_weather_temperature
entity_id: ${ha_weather_entity}
attribute: temperature
- platform: homeassistant
id: ha_weather_wind_speed
entity_id: ${ha_weather_entity}
attribute: wind_speed
text_sensor:
- platform: homeassistant
id: ha_weather_condition
entity_id: ${ha_weather_entity}
on_value:
- lambda: |-
const char* icon = "\U000F0590";
auto c = id(ha_weather_condition).state;
if (c == "sunny" || c == "clear") {
icon = "\U000F0599";
} else if (c == "partlycloudy") {
icon = "\U000F0595";
} else if (c == "cloudy") {
icon = "\U000F0590";
} else if (c == "rainy") {
icon = "\U000F0597";
} else if (c == "pouring") {
icon = "\U000F0596";
} else if (c == "snowy") {
icon = "\U000F0598";
} else if (c == "lightning") {
icon = "\U000F0593";
} else if (c == "hail") {
icon = "\U000F0592";
}
lv_label_set_text(id(weather_icon), icon);
- platform: homeassistant
id: ha_weather_temperature_unit
entity_id: ${ha_weather_entity}
attribute: temperature_unit
- platform: homeassistant
id: ha_weather_wind_speed_unit
entity_id: ${ha_weather_entity}
attribute: wind_speed_unit
- platform: sendspin
id: sendspin_title
name: "Song Title"
type: title
on_value:
- component.update: now_playing_text
- platform: sendspin
id: sendspin_artist
name: "Song Artist"
type: artist
on_value:
- component.update: now_playing_text
- platform: sendspin
id: sendspin_album
name: "Album Name"
type: album
on_value:
- lambda:
id(last_album_art_valid) = id(sendspin_album_art_valid);
- lambda: |-
id(sendspin_album_art_valid) = false;
- script.execute: album_art_timeout_fallback
- component.update: now_playing_text
- platform: template
id: now_playing_text
update_interval: never
lambda: |-
std::string s = id(sendspin_title).get_state();
auto a = id(sendspin_artist).get_state();
if (!a.empty()) {
if (!s.empty()) s += " - ";
s += a;
}
return s;
on_value:
- lvgl.label.update:
id: now_playing_label
text: !lambda return x;
- platform: template
id: clock_hms
update_interval: 1s
lambda: |-
auto t = id(homeassistant_time).now();
if (!t.is_valid()) return std::string("--:--:--");
bool hide_colons = ((t.second % 2) == 0);
char buf[9];
if (hide_colons) snprintf(buf, sizeof(buf), "%02d %02d %02d", t.hour, t.minute, t.second);
else snprintf(buf, sizeof(buf), "%02d:%02d:%02d", t.hour, t.minute, t.second);
return std::string(buf);
on_value:
- if:
condition:
lvgl.page.is_showing: idle_page
then:
- lvgl.label.update:
id: clock_label
text: !lambda return x;
- platform: template
id: clock_date
update_interval: 30s
lambda: |-
auto t = id(homeassistant_time).now();
if (!t.is_valid()) return std::string("------");
static const char* months[] = {
"January","February","March","April","May","June",
"July","August","September","October","November","December"
};
const char* mon = (t.month >= 1 && t.month <= 12) ? months[t.month - 1] : "???";
char buf[32];
snprintf(buf, sizeof(buf), "%d %s", t.day_of_month, mon);
return std::string(buf);
on_value:
- if:
condition:
lvgl.page.is_showing: idle_page
then:
- lvgl.label.update:
id: date_label
text: !lambda return x;
- platform: template
id: weather_temp_big
update_interval: 15s
lambda: |-
if (!id(ha_weather_temperature).has_state()) return std::string("--");
std::string tunit = id(ha_weather_temperature_unit).state.c_str();
if (tunit.empty()) tunit = "∞C";
lv_label_set_text(id(weather_big_temp_unit), tunit.c_str());
int ti = (int) lroundf(id(ha_weather_temperature).state);
char buf[8];
snprintf(buf, sizeof(buf), "%d", ti);
return std::string(buf);
on_value:
- if:
condition:
lvgl.page.is_showing: idle_page
then:
- lvgl.label.update:
id: weather_big_temp
text: !lambda return x;
- platform: template
id: weather_line
update_interval: 15s
lambda: |-
if (!id(ha_weather_temperature).has_state() || !id(ha_weather_wind_speed).has_state()) {
return std::string("Weather unavailable");
}
std::string tunit = id(ha_weather_temperature_unit).state.c_str();
if (tunit.empty()) tunit = "∞C";
std::string wunit = id(ha_weather_wind_speed_unit).state.c_str();
if (wunit.empty()) wunit = "m/s";
float temp_c = id(ha_weather_temperature).state;
float wind_ms = id(ha_weather_wind_speed).state;
float feels_c = temp_c;
if (temp_c <= 10.0f && wind_ms > 1.3f) {
float wind_kmh = wind_ms * 3.6f;
float v16 = powf(wind_kmh, 0.16f);
feels_c = 13.12f + 0.6215f * temp_c - 11.37f * v16 + 0.3965f * temp_c * v16;
}
char buf[160];
snprintf(buf, sizeof(buf),
"Temp: %.1f%s - Feels like: %.1f%s - Wind speed: %.1f %s",
temp_c, tunit.c_str(),
feels_c, tunit.c_str(),
wind_ms, wunit.c_str());
return std::string(buf);
on_value:
- lambda: |-
id(weather_available) = (x != "Weather unavailable");
- if:
condition:
and:
- lvgl.page.is_showing: idle_page
- lambda: return id(weather_available);
then:
- lvgl.widget.show: weather_header
- lvgl.widget.show: weather_label
- lvgl.label.update:
id: weather_label
text: !lambda return x;
else:
- if:
condition:
lvgl.page.is_showing: idle_page
then:
- lvgl.widget.hide: weather_header
- lvgl.widget.hide: weather_label
font:
- file:
type: gfonts
family: Noto Sans
weight: 300
id: font_display
size: 18
glyphsets:
- GF_Latin_Core
- GF_Cyrillic_Core
- file:
type: gfonts
family: Figtree
weight: 300
id: font_date
size: 22
glyphsets:
- GF_Latin_Core
- file:
type: gfonts
family: Figtree
weight: 600
id: font_boot
size: 34
glyphsets:
- GF_Latin_Core
- file:
type: gfonts
family: Figtree
weight: 700
id: font_temp_big
size: 58
glyphs: "0123456789-"
- file:
type: gfonts
family: Figtree
weight: 700
id: font_temp_unit
size: 22
glyphs: "°CF"
- file: https://raw.githubusercontent.com/rbtdev/spiroplot/master/fonts/DSEG7/Classic-MINI/DSEG7ClassicMini-Bold.ttf
id: font_7seg
size: 42
glyphs: "0123456789:- "
- file: https://github.com/Templarian/MaterialDesign-Webfont/raw/refs/heads/master/fonts/materialdesignicons-webfont.ttf
id: font_mdi_weather
size: 56
glyphs:
- "\U000F0590"
- "\U000F0592"
- "\U000F0593"
- "\U000F0595"
- "\U000F0596"
- "\U000F0597"
- "\U000F0598"
- "\U000F0599"
script:
- id: album_art_timeout_fallback
mode: restart
then:
- delay: 2000ms
- if:
condition:
lambda: 'return !id(sendspin_album_art_valid);'
then:
- lvgl.image.update:
id: incoming_art
src: fallback_playing_image
- lvgl.animation.start: album_art_crossfade
- id: mp_idle_debounce
mode: restart
then:
- delay: 1200ms
- if:
condition:
and:
- media_player.is_idle: external_media_player
then:
- lvgl.page.show: idle_page
- id: play_sound
parameters:
priority: bool
sound_file: string
then:
- if:
condition:
lambda: 'return sound_file == "ready_sound";'
then:
- lambda: 'id(boot_announce_active) = true;'
- delay: 2s
- lvgl.page.show: boot_sound_page
- lambda: 'id(boot_announce_active) = false;'
- if:
condition:
lambda: return priority;
then:
- media_player.stop:
id: external_media_player
announcement: true
- lambda: |-
if ((id(external_media_player).state != media_player::MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING) || priority) {
id(external_media_player)
->make_call()
.set_media_url("audio-file://" + sound_file)
.set_announcement(true)
.perform();
}
switch:
- platform: template
id: startup_sound_switch
name: Startup sound
icon: "mdi:card-text-outline"
entity_category: config
optimistic: true
restore_mode: RESTORE_DEFAULT_ON
output:
- platform: ledc
pin: GPIO11
id: backlight_output
inverted: false
light:
- platform: monochromatic
id: Sled
name: Screen
icon: "mdi:television"
entity_category: config
output: backlight_output
restore_mode: RESTORE_DEFAULT_ON
default_transition_length: 250ms
spi:
- id: spi_bus
clk_pin: 7
mosi_pin: 8
display:
- platform: mipi_spi
id: main_display
spi_id: spi_bus
model: ST7789V
data_rate: 40MHz
spi_mode: MODE3
dc_pin: 10
reset_pin: 9
dimensions:
width: 240
height: 240
offset_width: ${display_offset_width}
offset_height: ${display_offset_height}
transform:
swap_xy: ${display_swap_xy}
mirror_x: ${display_mirror_x}
mirror_y: ${display_mirror_y}
invert_colors: true
auto_clear_enabled: false
update_interval: never
lvgl:
displays:
- main_display
buffer_size: 12%
color_depth: 16
bg_color: 0x000000
default_font: font_display
animations:
- id: album_art_crossfade
duration: 1500ms
widgets:
- id: outgoing_art
opa:
from: 100%
to: 0%
on_stop:
- sendspin.image.transition_finished: album_slot
pages:
- id: boot_sound_page
bg_color: 0x000000
widgets:
- label:
align: CENTER
text: "Home Connected"
text_color: 0xFFFFFF
text_font: font_boot
width: 230
long_mode: WRAP
text_align: CENTER
- id: idle_page
bg_color: 0x000000
widgets:
- obj:
id: idle_center_block
align: CENTER
width: 240
height: 230
bg_opa: TRANSP
border_opa: TRANSP
pad_all: 0
scrollable: false
widgets:
- label:
id: clock_label
align: TOP_MID
y: 6
width: 240
text: "--:--:--"
text_color: 0xFFFF00
text_font: font_7seg
text_letter_space: 0
text_align: CENTER
- label:
id: date_label
align: TOP_MID
y: 58
width: 240
text: "------"
text_color: 0xFFFFFF
text_font: font_date
text_align: CENTER
- obj:
id: weather_header
align: TOP_MID
y: 90
width: 240
height: 70
bg_opa: TRANSP
border_opa: TRANSP
pad_all: 0
scrollable: false
widgets:
- label:
id: weather_icon
align: CENTER
x: -37
y: 0
text: "\U000F0590"
text_color: 0xFFFFFF
text_font: font_mdi_weather
- label:
id: weather_big_temp
align: CENTER
x: 36
y: 2
text: "--"
text_color: 0xFFFF00
text_font: font_temp_big
- label:
id: weather_big_temp_unit
align: CENTER
x: 76
y: -14
text: "∞C"
text_color: 0xFFFF00
text_font: font_temp_unit
- label:
id: weather_label
align: TOP_MID
x: 0
y: 167
width: 220
text: "Weather..."
text_color: 0x66CCFF
text_font: font_display
long_mode: SCROLL_CIRCULAR
text_align: CENTER
- id: sendspin_page
bg_color: 0x000000
widgets:
- image:
id: incoming_art
align: TOP_MID
x: 0
y: 0
src: fallback_playing_image
width: 216
height: 216
antialias: false
clickable: false
- image:
id: outgoing_art
align: TOP_MID
x: 0
y: 0
src: fallback_playing_image
width: 216
height: 216
antialias: false
clickable: false
- label:
id: now_playing_label
align: BOTTOM_MID
y: -3
width: 232
text: ""
text_color: 0xFFFFFF
long_mode: SCROLL_CIRCULAR
text_align: CENTER
# Factory firmware - generated by ewt-gen
# Users should import Sendspin_Zero-Display-Digital.yaml directly, not this file.
packages:
original: !include Sendspin_Zero-Display-Digital.yaml
esphome:
project:
version: "2026.08.08.42"
ota:
- platform: http_request
id: ota_http_request
update:
- platform: http_request
id: update_http_request
name: Firmware
source: https://realdeco.github.io/SendspinZero/Sendspin_Zero-Display-Digital/manifest.json
http_request:
dashboard_import:
package_import_url: github://RealDeco/SendspinZero/Sendspin_Zero-Display-Digital.yaml@main