Connect your device via USB and click the button below to install.
substitutions:
name: zero-display-button
friendly_name: Zero Display Button
ha_weather_entity: weather.forecast_home
## Startup sound, options are: Home_Connected or Home_Connected_Male
startup_sound_file: Home_Connected # Home_Connected, popup, available, Server_Connected
# Image model options are: AlbertQuackmore, Alita, Casita, Gwen, HA-character, ha-girl, Hacker, Salem, Sarah, Shaun
imagemodel: "HA-character"
### No DAC / no local speaker:
# display_state_player: sendspin_group_media_player
# api_connected_script: api_connected_actions
### DAC / local speaker enabled:
display_state_player: external_media_player
api_connected_script: api_connected_actions_audio
### DAC SETTINGS
### For a no-DAC build, remove or comment this packages block and use the
### no-DAC substitutions above.
packages:
audio:
url: https://github.com/RealDeco/SendspinZero.git
ref: main
refresh: 0s
files:
- configs/analog_audio_config.yaml
hw:
url: https://github.com/RealDeco/SendspinZero.git
ref: main
refresh: 0s
files:
- configs/analog_speaker_config.yaml
### END OF DAC SETTINGS
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
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:
- script.execute: update_status_leds
- script.execute: update_weather_display
- lvgl.page.show: idle_page
esp32:
variant: esp32s3
flash_size: 4MB
framework:
type: esp-idf
logger:
level: WARN
api:
on_client_connected:
- script.execute: ${api_connected_script}
ota:
- platform: esphome
wifi:
ap:
improv_serial:
psram:
mode: quad
speed: 80MHz
captive_portal:
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: media_menu_active
type: bool
restore_value: no
initial_value: "false"
- id: media_menu_index
type: int
restore_value: no
initial_value: "0"
- id: selected_media_name
type: std::string
restore_value: no
initial_value: '""'
- id: selected_media_url
type: std::string
restore_value: no
initial_value: '""'
- id: menu_button_hold_fired
type: bool
restore_value: no
initial_value: "false"
- id: menu_button_wait_for_release
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: 170x170
type: RGB565
dither: NONE
- platform: sendspin
id: album_slot
format: JPEG
type: RGB565
resize: 170x170
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
media_player:
- platform: sendspin
id: sendspin_group_media_player
name: Sendspin Group Media Player
on_play:
then:
- script.stop: mp_idle_debounce
- lvgl.page.show: sendspin_page
- script.execute: update_status_leds
on_announcement:
then:
- script.execute: update_status_leds
on_idle:
then:
- script.execute: mp_idle_debounce
- script.execute: update_status_leds
on_pause:
then:
- script.execute: update_status_leds
on_state:
then:
- script.execute: update_status_leds
sensor:
- platform: homeassistant
id: ha_weather_temperature
entity_id: ${ha_weather_entity}
attribute: temperature
on_value:
- script.execute: update_weather_display
- platform: homeassistant
id: ha_weather_wind_speed
entity_id: ${ha_weather_entity}
attribute: wind_speed
on_value:
- script.execute: update_weather_display
text_sensor:
- platform: homeassistant
id: ha_weather_condition
entity_id: ${ha_weather_entity}
on_value:
- lambda: |-
const char* icon = "\U000F0590";
auto c = x;
if (c == "sunny" || c == "clear") {
icon = "\U000F0599";
} else if (c == "clear-night") {
icon = "\U000F0594";
} else if (c == "partlycloudy") {
icon = "\U000F0595";
} else if (c == "cloudy") {
icon = "\U000F0590";
} else if (c == "fog") {
icon = "\U000F0591";
} 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_glow), icon);
lv_label_set_text(id(weather_icon), icon);
- script.execute: update_weather_display
- script.execute: fetch_daily_forecast
- platform: homeassistant
id: ha_weather_temperature_unit
entity_id: ${ha_weather_entity}
attribute: temperature_unit
on_value:
- script.execute: update_weather_display
- platform: homeassistant
id: ha_weather_wind_speed_unit
entity_id: ${ha_weather_entity}
attribute: wind_speed_unit
on_value:
- script.execute: update_weather_display
- platform: sendspin
id: sendspin_title
name: "Song Title"
type: title
on_value:
- lvgl.label.update:
id: now_playing_title_label
text: !lambda |-
if (x.empty()) return std::string(" ");
return x;
- platform: sendspin
id: sendspin_artist
name: "Song Artist"
type: artist
on_value:
- lvgl.label.update:
id: now_playing_artist_label
text: !lambda |-
if (x.empty()) return std::string(" ");
return x;
- 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
- lvgl.label.update:
id: now_playing_album_label
text: !lambda |-
if (x.empty()) return std::string(" ");
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;
font:
- file:
type: gfonts
family: Noto Sans
weight: 300
id: font_display
size: 16
glyphsets:
- GF_Latin_Core
- GF_Cyrillic_Core
- file:
type: gfonts
family: Figtree
weight: 300
id: font_date
size: 16
glyphsets:
- GF_Latin_Core
- file:
type: gfonts
family: Figtree
weight: 600
id: font_boot
size: 28
glyphsets:
- GF_Latin_Core
- file:
type: gfonts
family: Figtree
weight: 600
id: font_menu
size: 20
glyphsets:
- GF_Latin_Core
- file:
type: gfonts
family: Figtree
weight: 700
id: font_temp_big
size: 34
glyphs: "0123456789-"
- file:
type: gfonts
family: Figtree
weight: 700
id: font_temp_unit
size: 16
glyphs: "\u00B0CF-"
- file:
type: gfonts
family: Figtree
weight: 600
id: font_forecast
size: 14
glyphs: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz /:-\u00B0"
- file: https://raw.githubusercontent.com/rbtdev/spiroplot/master/fonts/DSEG7/Classic-MINI/DSEG7ClassicMini-Bold.ttf
id: font_7seg
size: 30
glyphs: "0123456789:- "
- file: https://github.com/Templarian/MaterialDesign-Webfont/raw/refs/heads/master/fonts/materialdesignicons-webfont.ttf
id: font_mdi_weather
size: 34
glyphs:
- "\U000F0590"
- "\U000F0591"
- "\U000F0592"
- "\U000F0593"
- "\U000F0594"
- "\U000F0595"
- "\U000F0596"
- "\U000F0597"
- "\U000F0598"
- "\U000F0599"
- file: https://github.com/Templarian/MaterialDesign-Webfont/raw/refs/heads/master/fonts/materialdesignicons-webfont.ttf
id: font_mdi_forecast
size: 22
glyphs:
- "\U000F0590"
- "\U000F0591"
- "\U000F0592"
- "\U000F0593"
- "\U000F0594"
- "\U000F0595"
- "\U000F0596"
- "\U000F0597"
- "\U000F0598"
- "\U000F0599"
script:
- id: api_connected_actions
then:
- delay: 5s
- script.execute: update_weather_display
- script.execute: fetch_daily_forecast
- id: fetch_daily_forecast
mode: restart
then:
- homeassistant.action:
action: weather.get_forecasts
data:
entity_id: ${ha_weather_entity}
type: daily
capture_response: true
on_success:
- lambda: |-
auto icon_for_condition = [](const std::string &condition) -> const char* {
if (condition == "sunny" || condition == "clear") return "\U000F0599";
if (condition == "clear-night") return "\U000F0594";
if (condition == "partlycloudy") return "\U000F0595";
if (condition == "cloudy") return "\U000F0590";
if (condition == "fog") return "\U000F0591";
if (condition == "rainy") return "\U000F0597";
if (condition == "pouring") return "\U000F0596";
if (condition == "snowy") return "\U000F0598";
if (condition == "lightning") return "\U000F0593";
if (condition == "hail") return "\U000F0592";
return "\U000F0590";
};
auto weekday_from_iso = [](const char *iso_value, int fallback_offset) -> const char* {
static const char* names[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
if (iso_value == nullptr || iso_value[0] == '\0') {
return names[(fallback_offset + 1) % 7];
}
int year = 0;
int month = 0;
int day = 0;
if (sscanf(iso_value, "%d-%d-%d", &year, &month, &day) != 3) {
return names[(fallback_offset + 1) % 7];
}
if (month < 1 || month > 12) {
return names[(fallback_offset + 1) % 7];
}
static const int offsets[] = {0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4};
if (month < 3) year -= 1;
int day_index = (year + year / 4 - year / 100 + year / 400 + offsets[month - 1] + day) % 7;
if (day_index < 0 || day_index > 6) {
return names[(fallback_offset + 1) % 7];
}
return names[day_index];
};
JsonArrayConst forecast = response["response"]["${ha_weather_entity}"]["forecast"].as<JsonArrayConst>();
if (forecast.isNull() || forecast.size() == 0) {
lv_obj_add_flag(id(forecast_strip), LV_OBJ_FLAG_HIDDEN);
ESP_LOGW("forecast", "No daily forecast data returned for ${ha_weather_entity}");
return;
}
lv_obj_t *day_labels[3] = {
id(forecast_day_1),
id(forecast_day_2),
id(forecast_day_3),
};
lv_obj_t *icon_labels[3] = {
id(forecast_icon_1),
id(forecast_icon_2),
id(forecast_icon_3),
};
lv_obj_t *temp_labels[3] = {
id(forecast_temp_1),
id(forecast_temp_2),
id(forecast_temp_3),
};
size_t start_index = forecast.size() >= 4 ? 1 : 0;
std::string tunit = id(ha_weather_temperature_unit).state.c_str();
if (tunit.empty()) tunit = "\u00B0C";
bool rendered_any = false;
for (size_t i = 0; i < 3; i++) {
size_t src_index = start_index + i;
if (src_index >= forecast.size()) {
lv_label_set_text(day_labels[i], "--");
lv_label_set_text(icon_labels[i], "\U000F0590");
lv_label_set_text(temp_labels[i], "--");
continue;
}
JsonObjectConst day = forecast[src_index].as<JsonObjectConst>();
const char *datetime_value = day["datetime"] | "";
const char *condition_value = day["condition"] | "cloudy";
float high_temp = day["temperature"] | NAN;
float low_temp = day["templow"] | high_temp;
lv_label_set_text(day_labels[i], weekday_from_iso(datetime_value, i));
lv_label_set_text(icon_labels[i], icon_for_condition(std::string(condition_value)));
if (std::isnan(high_temp)) {
lv_label_set_text(temp_labels[i], "--");
} else {
char temp_buf[24];
snprintf(
temp_buf,
sizeof(temp_buf),
"%.0f/%.0f%s",
high_temp,
std::isnan(low_temp) ? high_temp : low_temp,
tunit.c_str()
);
lv_label_set_text(temp_labels[i], temp_buf);
rendered_any = true;
}
}
if (rendered_any) {
lv_obj_clear_flag(id(forecast_strip), LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(id(forecast_strip), LV_OBJ_FLAG_HIDDEN);
}
on_error:
- lambda: |-
lv_obj_add_flag(id(forecast_strip), LV_OBJ_FLAG_HIDDEN);
ESP_LOGW("forecast", "weather.get_forecasts failed for ${ha_weather_entity}");
- id: update_weather_display
mode: restart
then:
- lambda: |-
if (!id(ha_weather_temperature).has_state()) {
lv_obj_add_flag(id(weather_header), LV_OBJ_FLAG_HIDDEN);
lv_label_set_text(id(weather_big_temp), "--");
lv_label_set_text(id(weather_big_temp_unit), "\u00B0C");
lv_label_set_text(id(weather_feels_like), "--");
return;
}
lv_obj_clear_flag(id(weather_header), LV_OBJ_FLAG_HIDDEN);
std::string tunit = id(ha_weather_temperature_unit).state.c_str();
if (tunit.empty()) tunit = "\u00B0C";
int ti = (int) lroundf(id(ha_weather_temperature).state);
char temp_buf[8];
snprintf(temp_buf, sizeof(temp_buf), "%d", ti);
lv_label_set_text(id(weather_big_temp), temp_buf);
lv_label_set_text(id(weather_big_temp_unit), tunit.c_str());
if (!id(ha_weather_wind_speed).has_state()) {
lv_label_set_text(id(weather_feels_like), "--");
lv_obj_set_style_text_color(id(weather_feels_like), lv_color_hex(0x8EDBFF), LV_PART_MAIN);
return;
}
std::string wunit = id(ha_weather_wind_speed_unit).state.c_str();
if (wunit.empty()) wunit = "m/s";
float temp_display = id(ha_weather_temperature).state;
float wind_display = id(ha_weather_wind_speed).state;
float temp_c = temp_display;
if (tunit == "\u00B0F" || tunit == "F") {
temp_c = (temp_display - 32.0f) * 5.0f / 9.0f;
}
float wind_ms = wind_display;
if (wunit == "km/h" || wunit == "kmh") {
wind_ms = wind_display / 3.6f;
} else if (wunit == "mph") {
wind_ms = wind_display * 0.44704f;
} else if (wunit == "kn" || wunit == "kt" || wunit == "kts" || wunit == "knots") {
wind_ms = wind_display * 0.514444f;
}
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;
}
float feels_display = feels_c;
if (tunit == "\u00B0F" || tunit == "F") {
feels_display = feels_c * 9.0f / 5.0f + 32.0f;
}
char feels_buf[32];
snprintf(feels_buf, sizeof(feels_buf), "Feels %.0f%s", feels_display, tunit.c_str());
lv_label_set_text(id(weather_feels_like), feels_buf);
float diff = feels_display - temp_display;
lv_color_t c = lv_color_hex(0x8EDBFF);
if (diff >= 2.0f) {
c = lv_color_hex(0xFF8A65);
} else if (diff <= -2.0f) {
c = lv_color_hex(0x66CCFF);
} else {
c = lv_color_hex(0xB0BEC5);
}
lv_obj_set_style_text_color(id(weather_feels_like), c, LV_PART_MAIN);
- 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:
media_player.is_idle: ${display_state_player}
then:
- lvgl.page.show: idle_page
- script.execute: led_red
- id: update_media_menu_display
then:
- lambda: |-
const char *names[6] = {
id(media_name_1).state.c_str(),
id(media_name_2).state.c_str(),
id(media_name_3).state.c_str(),
id(media_name_4).state.c_str(),
id(media_name_5).state.c_str(),
id(media_name_6).state.c_str(),
};
lv_obj_t *labels[6] = {
id(menu_item_1),
id(menu_item_2),
id(menu_item_3),
id(menu_item_4),
id(menu_item_5),
id(menu_item_6),
};
int selected = id(media_menu_index);
if (selected < 0 || selected > 5) {
selected = 0;
id(media_menu_index) = 0;
}
for (int i = 0; i < 6; i++) {
std::string label_text = names[i];
lv_label_set_text(labels[i], label_text.c_str());
lv_obj_set_style_text_color(
labels[i],
i == selected ? lv_color_hex(0xFFFF00) : lv_color_hex(0xB0BEC5),
LV_PART_MAIN
);
}
lv_obj_scroll_to_view(labels[selected], LV_ANIM_OFF);
- id: open_media_menu
then:
- lambda: |-
id(media_menu_active) = true;
id(media_menu_index) = 0;
- script.execute: update_media_menu_display
- lvgl.page.show: media_menu_page
- id: advance_media_menu
then:
- if:
condition:
lambda: 'return id(media_menu_active);'
then:
- lambda: |-
id(media_menu_index) = (id(media_menu_index) + 1) % 6;
- script.execute: update_media_menu_display
- id: close_media_menu
then:
- lambda: |-
id(media_menu_active) = false;
- if:
condition:
media_player.is_idle: ${display_state_player}
then:
- lvgl.page.show: idle_page
else:
- lvgl.page.show: sendspin_page
- id: open_menu_hold_watch
mode: restart
then:
- delay: 700ms
- if:
condition:
lambda: 'return !id(media_menu_active) && id(media_button).state;'
then:
- lambda: |-
id(menu_button_hold_fired) = true;
id(menu_button_wait_for_release) = true;
- script.execute: open_media_menu
- id: menu_button_hold_watch
mode: restart
then:
- delay: 450ms
- if:
condition:
lambda: 'return id(media_menu_active) && id(media_button).state;'
then:
- lambda: |-
id(menu_button_hold_fired) = true;
id(menu_button_wait_for_release) = true;
- script.execute: select_media_menu
- id: select_media_menu
then:
- if:
condition:
lambda: 'return id(media_menu_active);'
then:
- lambda: |-
const char *names[6] = {
id(media_name_1).state.c_str(),
id(media_name_2).state.c_str(),
id(media_name_3).state.c_str(),
id(media_name_4).state.c_str(),
id(media_name_5).state.c_str(),
id(media_name_6).state.c_str(),
};
const char *urls[6] = {
id(media_url_1).state.c_str(),
id(media_url_2).state.c_str(),
id(media_url_3).state.c_str(),
id(media_url_4).state.c_str(),
id(media_url_5).state.c_str(),
id(media_url_6).state.c_str(),
};
int selected = id(media_menu_index);
if (selected < 0 || selected > 5) {
selected = 0;
id(media_menu_index) = 0;
}
id(selected_media_name) = names[selected];
id(selected_media_url) = urls[selected];
- if:
condition:
lambda: |-
return !id(ha_media_player_entity).state.empty() &&
!id(selected_media_url).empty() &&
id(selected_media_url) != "unused";
then:
- homeassistant.action:
action: media_player.play_media
data_template:
entity_id: "{{ target_entity }}"
media_content_type: music
media_content_id: "{{ media_url }}"
variables:
target_entity: |-
return id(ha_media_player_entity).state;
media_url: |-
return id(selected_media_url);
else:
- logger.log:
level: WARN
format: "Selected media item or media player has no Home Assistant entity configured"
- script.execute: close_media_menu
- id: update_status_leds
then:
- lambda: |-
using media_player::MediaPlayerState;
auto state = id(${display_state_player}).state;
if (
state == MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING ||
state == MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING
) {
id(led_green).execute();
} else {
id(led_red).execute();
}
- id: led_red
then:
- light.turn_on: red_status_light
- light.turn_off: green_status_light
- id: led_green
then:
- light.turn_off: red_status_light
- light.turn_on: green_status_light
interval:
- interval: 10min
then:
- script.execute: fetch_daily_forecast
text:
- platform: template
id: ha_media_player_entity
name: Home Assistant Media Player Entity
icon: "mdi:speaker"
entity_category: config
optimistic: true
restore_value: true
initial_value: "media_player.mom_s_speakers"
max_length: 128
mode: text
- platform: template
id: media_name_1
name: Media 1 Name
icon: "mdi:music-box-outline"
entity_category: config
optimistic: true
restore_value: true
initial_value: "Neil Diamond"
max_length: 64
mode: text
- platform: template
id: media_url_1
name: Media 1 URL
icon: "mdi:link-variant"
entity_category: config
optimistic: true
restore_value: true
initial_value: "library://playlist/72"
max_length: 255
mode: text
- platform: template
id: media_name_2
name: Media 2 Name
icon: "mdi:music-box-outline"
entity_category: config
optimistic: true
restore_value: true
initial_value: "Tommy Steele"
max_length: 64
mode: text
- platform: template
id: media_url_2
name: Media 2 URL
icon: "mdi:link-variant"
entity_category: config
optimistic: true
restore_value: true
initial_value: "library://playlist/68"
max_length: 255
mode: text
- platform: template
id: media_name_3
name: Media 3 Name
icon: "mdi:music-box-outline"
entity_category: config
optimistic: true
restore_value: true
initial_value: "Carpenters"
max_length: 64
mode: text
- platform: template
id: media_url_3
name: Media 3 URL
icon: "mdi:link-variant"
entity_category: config
optimistic: true
restore_value: true
initial_value: "library://playlist/76"
max_length: 255
mode: text
- platform: template
id: media_name_4
name: Media 4 Name
icon: "mdi:music-box-outline"
entity_category: config
optimistic: true
restore_value: true
initial_value: "Random Mix"
max_length: 64
mode: text
- platform: template
id: media_url_4
name: Media 4 URL
icon: "mdi:link-variant"
entity_category: config
optimistic: true
restore_value: true
initial_value: "library://playlist/77"
max_length: 255
mode: text
- platform: template
id: media_name_5
name: Media 5 Name
icon: "mdi:music-box-outline"
entity_category: config
optimistic: true
restore_value: true
initial_value: "Radio: P5"
max_length: 64
mode: text
- platform: template
id: media_url_5
name: Media 5 URL
icon: "mdi:link-variant"
entity_category: config
optimistic: true
restore_value: true
initial_value: "https://drliveradio1.akamaized.net/hls/live/2097651/p5fyn/masterab.m3u8"
max_length: 255
mode: text
- platform: template
id: media_name_6
name: Media 6 Name
icon: "mdi:music-box-outline"
entity_category: config
optimistic: true
restore_value: true
initial_value: "Radio Vinyl"
max_length: 64
mode: text
- platform: template
id: media_url_6
name: Media 6 URL
icon: "mdi:link-variant"
entity_category: config
optimistic: true
restore_value: true
initial_value: "https://live-bauerdk.sharp-stream.com/Vinyl_dk.mp3"
max_length: 255
mode: text
button:
- platform: restart
id: restart_button
name: "Restart"
entity_category: config
icon: "mdi:restart"
output:
- platform: ledc
pin: GPIO11
id: backlight_output
inverted: false
- platform: ledc
id: red_led
pin: GPIO3
- platform: ledc
id: green_led
pin: GPIO2
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
- platform: monochromatic
id: red_status_light
name: Red Status LED
output: red_led
restore_mode: RESTORE_DEFAULT_ON
- platform: monochromatic
id: green_status_light
name: Green Status LED
output: green_led
restore_mode: RESTORE_DEFAULT_ON
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: 320
height: 170
offset_width: 0
offset_height: 35
pad_width: 35
pad_height: 0
transform:
swap_xy: true
mirror_x: true
mirror_y: false
invert_colors: true
auto_clear_enabled: false
update_interval: never
lvgl:
displays:
- main_display
buffer_size: 25%
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: 300
long_mode: WRAP
text_align: CENTER
- id: idle_page
bg_color: 0x000000
scrollable: false
widgets:
- label:
id: clock_label
align: TOP_MID
y: 8
width: 320
text: "--:--:--"
text_color: 0xFFFF00
text_font: font_7seg
text_letter_space: 0
text_align: CENTER
- label:
id: date_label
align: TOP_MID
y: 40
width: 320
text: "------"
text_color: 0xFFFFFF
text_font: font_date
text_align: CENTER
- obj:
id: weather_header
align: TOP_MID
y: 60
width: 320
height: 42
bg_opa: TRANSP
border_opa: TRANSP
pad_all: 0
scrollable: true
scrollbar_mode: ACTIVE
layout:
type: flex
flex_flow: row
flex_align_main: CENTER
flex_align_cross: CENTER
flex_align_track: CENTER
widgets:
- obj:
width: SIZE_CONTENT
height: SIZE_CONTENT
bg_opa: TRANSP
border_opa: TRANSP
pad_all: 0
layout:
type: flex
flex_flow: row
flex_align_main: CENTER
flex_align_cross: CENTER
widgets:
- obj:
width: SIZE_CONTENT
height: SIZE_CONTENT
bg_opa: TRANSP
border_opa: TRANSP
pad_all: 0
widgets:
- label:
id: weather_icon_glow
align: CENTER
x: 1
y: 1
text: "\U000F0590"
text_color: 0x404040
text_font: font_mdi_weather
- label:
id: weather_icon
align: CENTER
x: 0
y: 0
text: "\U000F0590"
text_color: 0xFFFFFF
text_font: font_mdi_weather
- label:
id: weather_big_temp
text: "--"
text_color: 0xFFFF00
text_font: font_temp_big
pad_left: 6
- label:
id: weather_big_temp_unit
text: "\u00B0C"
text_color: 0xFFFF00
text_font: font_temp_unit
pad_left: 2
- label:
id: weather_feels_like
text: "--"
text_color: 0x8EDBFF
text_font: font_forecast
pad_left: 14
text_align: CENTER
- obj:
id: forecast_strip
hidden: true
align: BOTTOM_MID
y: -4
width: 312
height: 58
radius: 12
pad_top: 3
pad_bottom: 3
pad_left: 3
pad_right: 3
scrollable: false
bg_color: 0x000000
bg_opa: 35%
border_opa: 35%
border_color: 0xFFFF00
border_width: 2
widgets:
- obj:
align: LEFT_MID
width: 100
height: 50
bg_opa: TRANSP
border_opa: TRANSP
pad_all: 0
scrollable: false
widgets:
- label:
id: forecast_day_1
align: TOP_MID
y: 0
text: "--"
text_color: 0xFFFF00
text_font: font_forecast
- label:
id: forecast_icon_1
align: CENTER
y: -1
text: "\U000F0590"
text_color: 0xFFFFFF
text_font: font_mdi_forecast
- label:
id: forecast_temp_1
align: BOTTOM_MID
y: 0
text: "--"
text_color: 0x8EDBFF
text_font: font_forecast
- obj:
align: CENTER
width: 100
height: 50
bg_opa: TRANSP
border_opa: TRANSP
pad_all: 0
scrollable: false
widgets:
- label:
id: forecast_day_2
align: TOP_MID
y: 0
text: "--"
text_color: 0xFFFF00
text_font: font_forecast
- label:
id: forecast_icon_2
align: CENTER
y: -1
text: "\U000F0590"
text_color: 0xFFFFFF
text_font: font_mdi_forecast
- label:
id: forecast_temp_2
align: BOTTOM_MID
y: 0
text: "--"
text_color: 0x8EDBFF
text_font: font_forecast
- obj:
align: RIGHT_MID
width: 100
height: 50
bg_opa: TRANSP
border_opa: TRANSP
pad_all: 0
scrollable: false
widgets:
- label:
id: forecast_day_3
align: TOP_MID
y: 0
text: "--"
text_color: 0xFFFF00
text_font: font_forecast
- label:
id: forecast_icon_3
align: CENTER
y: -1
text: "\U000F0590"
text_color: 0xFFFFFF
text_font: font_mdi_forecast
- label:
id: forecast_temp_3
align: BOTTOM_MID
y: 0
text: "--"
text_color: 0x8EDBFF
text_font: font_forecast
- id: sendspin_page
bg_color: 0x000000
widgets:
- image:
id: incoming_art
align: TOP_LEFT
x: 0
y: 0
src: fallback_playing_image
width: 170
height: 170
antialias: false
clickable: false
- image:
id: outgoing_art
align: TOP_LEFT
x: 0
y: 0
src: fallback_playing_image
width: 170
height: 170
antialias: false
clickable: false
- obj:
align: TOP_RIGHT
x: 0
y: 0
width: 150
height: 170
bg_opa: TRANSP
border_opa: TRANSP
pad_top: 10
pad_bottom: 10
pad_left: 8
pad_right: 8
scrollable: false
widgets:
- label:
id: now_playing_title_label
align: TOP_MID
y: 6
width: 134
height: 42
text: " "
text_color: 0xFFFF00
text_font: font_display
long_mode: SCROLL_CIRCULAR
text_align: CENTER
- label:
id: now_playing_album_label
align: TOP_MID
y: 62
width: 134
height: 42
text: " "
text_color: 0xFFFFFF
text_font: font_display
long_mode: SCROLL_CIRCULAR
text_align: CENTER
- label:
id: now_playing_artist_label
align: TOP_MID
y: 118
width: 134
height: 42
text: " "
text_color: 0x8EDBFF
text_font: font_display
long_mode: SCROLL_CIRCULAR
text_align: CENTER
- id: media_menu_page
bg_color: 0x000000
scrollable: false
widgets:
- label:
align: TOP_MID
y: 2
width: 320
text: "Media Menu"
text_color: 0xFFFFFF
text_font: font_menu
text_align: CENTER
- obj:
align: CENTER
y: 14
width: 284
height: 144
radius: 18
bg_color: 0x111111
bg_opa: 75%
border_color: 0x3A3A3A
border_width: 2
border_opa: 70%
pad_top: 8
pad_bottom: 8
pad_left: 12
pad_right: 12
scrollable: true
scrollbar_mode: ACTIVE
layout:
type: flex
flex_flow: column
flex_align_main: START
flex_align_cross: CENTER
flex_align_track: CENTER
widgets:
- label:
id: menu_item_1
width: 252
height: 28
text: "Media 1"
text_color: 0xFFFF00
text_font: font_menu
text_align: CENTER
long_mode: DOT
- label:
id: menu_item_2
width: 252
height: 28
text: "Media 2"
text_color: 0xB0BEC5
text_font: font_menu
text_align: CENTER
long_mode: DOT
- label:
id: menu_item_3
width: 252
height: 28
text: "Media 3"
text_color: 0xB0BEC5
text_font: font_menu
text_align: CENTER
long_mode: DOT
- label:
id: menu_item_4
width: 252
height: 28
text: "Media 4"
text_color: 0xB0BEC5
text_font: font_menu
text_align: CENTER
long_mode: DOT
- label:
id: menu_item_5
width: 252
height: 28
text: "Media 5"
text_color: 0xB0BEC5
text_font: font_menu
text_align: CENTER
long_mode: DOT
- label:
id: menu_item_6
width: 252
height: 28
text: "Media 6"
text_color: 0xB0BEC5
text_font: font_menu
text_align: CENTER
long_mode: DOT
binary_sensor:
- platform: gpio
pin:
number: 1
mode: INPUT_PULLUP
inverted: true
id: media_button
on_press:
- if:
condition:
lambda: 'return id(media_menu_active);'
then:
- if:
condition:
lambda: 'return !id(menu_button_wait_for_release);'
then:
- lambda: |-
id(menu_button_hold_fired) = false;
- script.execute: menu_button_hold_watch
else:
- lambda: |-
id(menu_button_hold_fired) = false;
- script.execute: open_menu_hold_watch
on_release:
- script.stop: open_menu_hold_watch
- if:
condition:
lambda: 'return id(media_menu_active);'
then:
- script.stop: menu_button_hold_watch
- if:
condition:
lambda: 'return id(menu_button_wait_for_release);'
then:
- lambda: |-
id(menu_button_wait_for_release) = false;
id(menu_button_hold_fired) = false;
else:
- if:
condition:
lambda: 'return !id(menu_button_hold_fired);'
then:
- script.execute: advance_media_menu
- lambda: |-
id(menu_button_hold_fired) = false;
else:
- if:
condition:
lambda: 'return id(menu_button_wait_for_release);'
then:
- lambda: |-
id(menu_button_wait_for_release) = false;
id(menu_button_hold_fired) = false;
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 351ms
then:
- if:
condition:
lambda: 'return !id(media_menu_active) && !id(menu_button_hold_fired) && !id(ha_media_player_entity).state.empty();'
then:
- homeassistant.action:
action: media_player.media_play_pause
data_template:
entity_id: "{{ target_entity }}"
variables:
target_entity: |-
return id(ha_media_player_entity).state;
- timing:
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at least 351ms
then:
- if:
condition:
lambda: 'return !id(media_menu_active) && !id(menu_button_hold_fired) && !id(ha_media_player_entity).state.empty();'
then:
- homeassistant.action:
action: media_player.media_next_track
data_template:
entity_id: "{{ target_entity }}"
variables:
target_entity: |-
return id(ha_media_player_entity).state;
- timing:
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at least 351ms
then:
- if:
condition:
lambda: 'return !id(media_menu_active) && !id(menu_button_hold_fired) && !id(ha_media_player_entity).state.empty();'
then:
- homeassistant.action:
action: media_player.media_previous_track
data_template:
entity_id: "{{ target_entity }}"
variables:
target_entity: |-
return id(ha_media_player_entity).state;
# Factory firmware - generated by ewt-gen
# Users should import Sendspin_Zero-Display_Button-Analog.yaml directly, not this file.
packages:
original: !include Sendspin_Zero-Display_Button-Analog.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_Button-Analog/manifest.json
http_request:
dashboard_import:
package_import_url: github://RealDeco/SendspinZero/Sendspin_Zero-Display_Button-Analog.yaml@main