Connect your device via USB and click the button below to install.
substitutions:
name: zero-control
friendly_name: Sendspin Zero Control
## SETTINGS ######################################################################################################################################
## Startup sound, options are: Home_Connected or Home_Connected_Male
startup_sound_file: popup # Home_Connected, popup, available, Server_Connected
## Sample rate for DAC
media_sample_rate: 44100
## ROTATION: THIS PART IS ONLY NEEDED IF YOU TURNED DISPLAY AROUND IN CASE #######################################################################
## 0 degree (default)
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"));
}
}
- delay: 5s
- script.execute: fetch_daily_forecast
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: boot_sound_played
type: bool
restore_value: no
initial_value: "false"
- id: knob_popup_active
type: bool
restore_value: no
initial_value: "false"
- id: knob_control_pct
type: int
restore_value: no
initial_value: "0"
- id: volume_control_pct
type: int
restore_value: no
initial_value: "0"
- id: popup_mode_light
type: bool
restore_value: no
initial_value: "false"
- id: control_menu_active
type: bool
restore_value: no
initial_value: "false"
- id: selected_control_index
type: int
restore_value: yes
initial_value: "1"
- id: volume_control_target_group
type: bool
restore_value: yes
initial_value: "false"
- id: control_menu_cursor
type: int
restore_value: no
initial_value: "1"
- id: control_menu_turn_halfstep
type: bool
restore_value: no
initial_value: "false"
- id: media_menu_active
type: bool
restore_value: no
initial_value: "false"
- id: media_menu_cursor
type: int
restore_value: no
initial_value: "1"
- id: media_menu_turn_halfstep
type: bool
restore_value: no
initial_value: "false"
- id: group_play_active
type: bool
restore_value: no
initial_value: "false"
- id: media_button_click_count
type: int
restore_value: no
initial_value: "0"
- id: media_button_hold_fired
type: bool
restore_value: no
initial_value: "false"
image:
- platform: file
file: https://raw.githubusercontent.com/RealDeco/xiaozhi-esphome/main/images/Gwen_Black/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
volume_max: 0.90
on_play:
then:
- script.stop: mp_idle_debounce
- script.execute: hide_media_menu
- lvgl.page.show: sendspin_page
- if:
condition:
lambda: 'return id(control_menu_active);'
then:
- script.execute: render_control_menu
else:
- if:
condition:
lambda: 'return (!lv_obj_has_flag(id(popup_idle), LV_OBJ_FLAG_HIDDEN)) || (!lv_obj_has_flag(id(popup_sendspin), LV_OBJ_FLAG_HIDDEN));'
then:
- script.execute: render_popup
on_idle:
then:
- script.execute: mp_idle_debounce
sensor:
- platform: rotary_encoder
id: rotary_encoder_sensor
internal: true
pin_a:
number: GPIO12
mode:
input: true
pullup: true
pin_b:
number: GPIO13
mode:
input: true
pullup: true
resolution: 2
restore_mode: ALWAYS_ZERO
on_clockwise:
- script.execute: rotary_cw
on_anticlockwise:
- script.execute: rotary_ccw
- platform: template
id: ha_weather_temperature
internal: true
- platform: template
id: ha_weather_wind_speed
internal: true
text_sensor:
- platform: template
id: ha_weather_condition
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);
- platform: template
id: ha_weather_temperature_unit
internal: true
lambda: |-
return std::string("\u00B0C");
- platform: template
id: ha_weather_wind_speed_unit
internal: true
lambda: |-
return std::string("m/s");
- 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 " ";
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 " ";
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 " ";
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 = "\u00B0C";
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 = "\u00B0C";
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 buf[160];
snprintf(buf, sizeof(buf),
"Temp: %.1f%s - Feels like: %.1f%s - Wind speed: %.1f %s",
temp_display, tunit.c_str(),
feels_display, tunit.c_str(),
wind_display, wunit.c_str());
return std::string(buf);
on_value:
- if:
condition:
and:
- lvgl.page.is_showing: idle_page
- lambda: return x != "Weather unavailable";
then:
- lvgl.widget.show: weather_header
- lvgl.widget.show: weather_label
- lvgl.widget.show: weather_label_glow
- lvgl.label.update:
id: weather_label_glow
text: !lambda return x;
- 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
- lvgl.widget.hide: weather_label_glow
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: Noto Sans
weight: 700
id: font_display_bold
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: "\u00B0CF-"
- file:
type: gfonts
family: Figtree
weight: 600
id: font_forecast
size: 14
glyphsets:
- GF_Latin_Core
- 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"
- "\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: 28
glyphs:
- "\U000F0590"
- "\U000F0591"
- "\U000F0592"
- "\U000F0593"
- "\U000F0594"
- "\U000F0595"
- "\U000F0596"
- "\U000F0597"
- "\U000F0598"
- "\U000F0599"
script:
- id: fetch_daily_forecast
mode: restart
then:
- if:
condition:
lambda: 'return !id(ha_weather_entity).state.empty();'
then:
- homeassistant.action:
action: weather.get_forecasts
data_template:
entity_id: "{{ weather_entity }}"
type: daily
variables:
weather_entity: |-
return id(ha_weather_entity).state;
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];
};
std::string weather_entity = id(ha_weather_entity).state;
JsonArrayConst forecast = response["response"][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 %s", weather_entity.c_str());
return;
}
JsonObjectConst current = forecast[0].as<JsonObjectConst>();
if (!current.isNull()) {
const char *condition_value = current["condition"] | "";
float current_temp = current["temperature"] | NAN;
float current_wind = current["wind_speed"] | NAN;
if (condition_value[0] != '\0') id(ha_weather_condition).publish_state(condition_value);
if (!std::isnan(current_temp)) id(ha_weather_temperature).publish_state(current_temp);
if (!std::isnan(current_wind)) id(ha_weather_wind_speed).publish_state(current_wind);
}
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 %s", id(ha_weather_entity).state.c_str());
else:
- lambda: |-
lv_obj_add_flag(id(forecast_strip), LV_OBJ_FLAG_HIDDEN);
ESP_LOGW("forecast", "No weather entity configured");
- id: rotary_cw
mode: restart
then:
- if:
condition:
lambda: 'return id(media_menu_active);'
then:
- lambda: |-
int max_items = (int) lroundf(id(media_item_count).state);
if (max_items < 1) max_items = 1;
if (max_items > 10) max_items = 10;
max_items += 1; // +1 for the Group Play row
if (id(media_menu_cursor) < 1 || id(media_menu_cursor) > max_items) {
id(media_menu_cursor) = 1;
}
if (id(media_menu_turn_halfstep)) {
if (id(menu_scroll_direction_switch).state) {
id(media_menu_cursor) += 1;
if (id(media_menu_cursor) > max_items) id(media_menu_cursor) = 1;
} else {
id(media_menu_cursor) -= 1;
if (id(media_menu_cursor) < 1) id(media_menu_cursor) = max_items;
}
}
id(media_menu_turn_halfstep) = !id(media_menu_turn_halfstep);
- script.execute: render_media_menu
else:
- if:
condition:
lambda: 'return id(control_menu_active);'
then:
- lambda: |-
if (id(control_menu_turn_halfstep)) {
if (id(menu_scroll_direction_switch).state) {
id(control_menu_cursor) += 1;
if (id(control_menu_cursor) > 8) id(control_menu_cursor) = 1;
} else {
id(control_menu_cursor) -= 1;
if (id(control_menu_cursor) < 1) id(control_menu_cursor) = 8;
}
}
id(control_menu_turn_halfstep) = !id(control_menu_turn_halfstep);
- script.execute: render_control_menu
else:
- if:
condition:
lambda: 'return id(knob_popup_active);'
then:
- lambda: |-
id(knob_control_pct) += 5;
if (id(knob_control_pct) > 100) id(knob_control_pct) = 100;
id(popup_mode_light) = true;
- script.execute: update_popup_widgets
- script.execute: apply_knob_to_light
- script.execute: popup_autoclose
else:
- lambda: |-
auto *volume_target = id(volume_control_target_group) ? static_cast<media_player::MediaPlayer *>(id(sendspin_group_media_player)) : static_cast<media_player::MediaPlayer *>(id(external_media_player));
const bool popup_visible =
(!lv_obj_has_flag(id(popup_idle), LV_OBJ_FLAG_HIDDEN)) ||
(!lv_obj_has_flag(id(popup_sendspin), LV_OBJ_FLAG_HIDDEN));
if (popup_visible) {
// Keep using the local step counter while the volume popup is active.
} else {
id(volume_control_pct) = (int) lroundf(volume_target->volume * 100.0f / 0.90f);
}
id(volume_control_pct) += 5;
if (id(volume_control_pct) > 100) id(volume_control_pct) = 100;
id(popup_mode_light) = false;
volume_target->make_call().set_volume(id(volume_control_pct) * 0.009f).perform();
- script.execute: show_volume_popup
- script.execute: popup_autoclose
- id: rotary_ccw
mode: restart
then:
- if:
condition:
lambda: 'return id(media_menu_active);'
then:
- lambda: |-
int max_items = (int) lroundf(id(media_item_count).state);
if (max_items < 1) max_items = 1;
if (max_items > 10) max_items = 10;
max_items += 1; // +1 for the Group Play row
if (id(media_menu_cursor) < 1 || id(media_menu_cursor) > max_items) {
id(media_menu_cursor) = 1;
}
if (id(media_menu_turn_halfstep)) {
if (id(menu_scroll_direction_switch).state) {
id(media_menu_cursor) -= 1;
if (id(media_menu_cursor) < 1) id(media_menu_cursor) = max_items;
} else {
id(media_menu_cursor) += 1;
if (id(media_menu_cursor) > max_items) id(media_menu_cursor) = 1;
}
}
id(media_menu_turn_halfstep) = !id(media_menu_turn_halfstep);
- script.execute: render_media_menu
else:
- if:
condition:
lambda: 'return id(control_menu_active);'
then:
- lambda: |-
if (id(control_menu_turn_halfstep)) {
if (id(menu_scroll_direction_switch).state) {
id(control_menu_cursor) -= 1;
if (id(control_menu_cursor) < 1) id(control_menu_cursor) = 8;
} else {
id(control_menu_cursor) += 1;
if (id(control_menu_cursor) > 8) id(control_menu_cursor) = 1;
}
}
id(control_menu_turn_halfstep) = !id(control_menu_turn_halfstep);
- script.execute: render_control_menu
else:
- if:
condition:
lambda: 'return id(knob_popup_active);'
then:
- lambda: |-
id(knob_control_pct) -= 5;
if (id(knob_control_pct) < 0) id(knob_control_pct) = 0;
id(popup_mode_light) = true;
- script.execute: update_popup_widgets
- script.execute: apply_knob_to_light
- script.execute: popup_autoclose
else:
- lambda: |-
auto *volume_target = id(volume_control_target_group) ? static_cast<media_player::MediaPlayer *>(id(sendspin_group_media_player)) : static_cast<media_player::MediaPlayer *>(id(external_media_player));
const bool popup_visible =
(!lv_obj_has_flag(id(popup_idle), LV_OBJ_FLAG_HIDDEN)) ||
(!lv_obj_has_flag(id(popup_sendspin), LV_OBJ_FLAG_HIDDEN));
if (popup_visible) {
// Keep using the local step counter while the volume popup is active.
} else {
id(volume_control_pct) = (int) lroundf(volume_target->volume * 100.0f / 0.90f);
}
id(volume_control_pct) -= 5;
if (id(volume_control_pct) < 0) id(volume_control_pct) = 0;
id(popup_mode_light) = false;
volume_target->make_call().set_volume(id(volume_control_pct) * 0.009f).perform();
- script.execute: show_volume_popup
- script.execute: popup_autoclose
- 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
- if:
condition:
lambda: 'return id(control_menu_active);'
then:
- script.execute: render_control_menu
else:
- if:
condition:
lambda: 'return (!lv_obj_has_flag(id(popup_idle), LV_OBJ_FLAG_HIDDEN)) || (!lv_obj_has_flag(id(popup_sendspin), LV_OBJ_FLAG_HIDDEN));'
then:
- script.execute: render_popup
- id: popup_autoclose
mode: restart
then:
- delay: 3s
- script.execute: hide_popup
- id: update_popup_widgets
mode: restart
then:
- if:
condition:
lambda: 'return id(popup_mode_light);'
then:
- lvgl.label.update:
id: popup_title_idle
text: !lambda |-
const char *names[6] = {
id(control_devicename_1).state.c_str(),
id(control_devicename_2).state.c_str(),
id(control_devicename_3).state.c_str(),
id(control_devicename_4).state.c_str(),
id(control_devicename_5).state.c_str(),
id(control_devicename_6).state.c_str(),
};
int index = id(selected_control_index);
if (index < 1 || index > 6) index = 1;
return std::string(names[index - 1]);
- lvgl.slider.update:
id: popup_slider_idle
value: !lambda |-
return id(knob_control_pct);
- lvgl.label.update:
id: popup_title_sendspin
text: !lambda |-
const char *names[6] = {
id(control_devicename_1).state.c_str(),
id(control_devicename_2).state.c_str(),
id(control_devicename_3).state.c_str(),
id(control_devicename_4).state.c_str(),
id(control_devicename_5).state.c_str(),
id(control_devicename_6).state.c_str(),
};
int index = id(selected_control_index);
if (index < 1 || index > 6) index = 1;
return std::string(names[index - 1]);
- lvgl.slider.update:
id: popup_slider_sendspin
value: !lambda |-
return id(knob_control_pct);
- lambda: |-
const auto indicator_color = lv_color_hex(0xFFFF00);
const auto knob_color = id(knob_control_pct) <= 0 ? lv_color_hex(0xFF0000) : lv_color_hex(0xFFFFFF);
lv_obj_set_style_border_color(id(popup_idle), indicator_color, LV_PART_MAIN);
lv_obj_set_style_border_color(id(popup_sendspin), indicator_color, LV_PART_MAIN);
lv_obj_set_style_bg_color(id(popup_slider_idle), indicator_color, LV_PART_INDICATOR);
lv_obj_set_style_bg_color(id(popup_slider_sendspin), indicator_color, LV_PART_INDICATOR);
lv_obj_set_style_bg_color(id(popup_slider_idle), knob_color, LV_PART_KNOB);
lv_obj_set_style_bg_color(id(popup_slider_sendspin), knob_color, LV_PART_KNOB);
else:
- lvgl.label.update:
id: popup_title_idle
text: "Volume"
- lvgl.slider.update:
id: popup_slider_idle
value: !lambda |-
return id(volume_control_pct);
- lvgl.label.update:
id: popup_title_sendspin
text: "Volume"
- lvgl.slider.update:
id: popup_slider_sendspin
value: !lambda |-
return id(volume_control_pct);
- lambda: |-
const auto indicator_color = lv_color_hex(0x66CCFF);
const auto knob_color = id(volume_control_pct) <= 0 ? lv_color_hex(0xFF0000) : lv_color_hex(0xFFFFFF);
lv_obj_set_style_border_color(id(popup_idle), indicator_color, LV_PART_MAIN);
lv_obj_set_style_border_color(id(popup_sendspin), indicator_color, LV_PART_MAIN);
lv_obj_set_style_bg_color(id(popup_slider_idle), indicator_color, LV_PART_INDICATOR);
lv_obj_set_style_bg_color(id(popup_slider_sendspin), indicator_color, LV_PART_INDICATOR);
lv_obj_set_style_bg_color(id(popup_slider_idle), knob_color, LV_PART_KNOB);
lv_obj_set_style_bg_color(id(popup_slider_sendspin), knob_color, LV_PART_KNOB);
- id: render_popup
mode: restart
then:
- script.execute: update_popup_widgets
- if:
condition:
lvgl.page.is_showing: idle_page
then:
- lvgl.widget.show: popup_idle
- lvgl.widget.hide: popup_sendspin
else:
- if:
condition:
lvgl.page.is_showing: sendspin_page
then:
- lvgl.widget.show: popup_sendspin
- lvgl.widget.hide: popup_idle
- id: show_knob_popup
mode: restart
then:
- lambda: |-
id(knob_popup_active) = true;
id(popup_mode_light) = true;
if (id(selected_control_index) < 1 || id(selected_control_index) > 6) {
id(selected_control_index) = 1;
}
if (id(knob_control_pct) < 0 || id(knob_control_pct) > 100) {
id(knob_control_pct) = 0;
}
- script.execute: render_popup
- script.execute: popup_autoclose
- id: show_volume_popup
mode: restart
then:
- lambda: |-
id(knob_popup_active) = false;
id(popup_mode_light) = false;
auto *volume_target = id(volume_control_target_group) ? static_cast<media_player::MediaPlayer *>(id(sendspin_group_media_player)) : static_cast<media_player::MediaPlayer *>(id(external_media_player));
id(volume_control_pct) = (int) lroundf(volume_target->volume * 100.0f / 0.90f);
- script.execute: render_popup
- id: show_selected_control_popup
mode: restart
then:
- script.execute: show_knob_popup
- id: hide_popup
mode: restart
then:
- script.stop: popup_autoclose
- lambda: |-
id(knob_popup_active) = false;
- lvgl.widget.hide: popup_idle
- lvgl.widget.hide: popup_sendspin
- id: toggle_selected_control_popup
mode: restart
then:
- if:
condition:
lambda: 'return (!lv_obj_has_flag(id(popup_idle), LV_OBJ_FLAG_HIDDEN)) || (!lv_obj_has_flag(id(popup_sendspin), LV_OBJ_FLAG_HIDDEN));'
then:
- if:
condition:
lambda: 'return id(popup_mode_light);'
then:
- script.execute: hide_popup
else:
- script.execute: show_selected_control_popup
else:
- script.execute: show_selected_control_popup
- id: render_control_menu
mode: restart
then:
- lambda: |-
const char *labels[6] = {
id(control_devicename_1).state.c_str(),
id(control_devicename_2).state.c_str(),
id(control_devicename_3).state.c_str(),
id(control_devicename_4).state.c_str(),
id(control_devicename_5).state.c_str(),
id(control_devicename_6).state.c_str(),
};
lv_obj_t *idle_items[6] = {
id(control_menu_idle_item_1),
id(control_menu_idle_item_2),
id(control_menu_idle_item_3),
id(control_menu_idle_item_4),
id(control_menu_idle_item_5),
id(control_menu_idle_item_6),
};
lv_obj_t *sendspin_items[6] = {
id(control_menu_sendspin_item_1),
id(control_menu_sendspin_item_2),
id(control_menu_sendspin_item_3),
id(control_menu_sendspin_item_4),
id(control_menu_sendspin_item_5),
id(control_menu_sendspin_item_6),
};
for (int i = 0; i < 6; i++) {
const bool cursor_here = ((i + 1) == id(control_menu_cursor));
const bool is_active = ((i + 1) == id(selected_control_index));
char item_text[64];
snprintf(item_text, sizeof(item_text), " %s%s", labels[i], is_active ? " *" : "");
lv_label_set_text(idle_items[i], item_text);
lv_label_set_text(sendspin_items[i], item_text);
if (cursor_here) {
lv_obj_set_style_bg_color(idle_items[i], lv_color_hex(0x66CCFF), LV_PART_MAIN);
lv_obj_set_style_bg_opa(idle_items[i], LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_text_color(idle_items[i], lv_color_hex(0x000000), LV_PART_MAIN);
lv_obj_set_style_radius(idle_items[i], 6, LV_PART_MAIN);
lv_obj_set_style_bg_color(sendspin_items[i], lv_color_hex(0x66CCFF), LV_PART_MAIN);
lv_obj_set_style_bg_opa(sendspin_items[i], LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_text_color(sendspin_items[i], lv_color_hex(0x000000), LV_PART_MAIN);
lv_obj_set_style_radius(sendspin_items[i], 6, LV_PART_MAIN);
} else {
lv_obj_set_style_bg_opa(idle_items[i], LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_text_color(idle_items[i], lv_color_hex(0xFFFFFF), LV_PART_MAIN);
lv_obj_set_style_bg_opa(sendspin_items[i], LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_text_color(sendspin_items[i], lv_color_hex(0xFFFFFF), LV_PART_MAIN);
}
}
struct VolumeRow {
lv_obj_t *idle;
lv_obj_t *sendspin;
const char *label;
bool is_active;
int cursor_value;
};
VolumeRow volume_rows[2] = {
{ id(control_menu_idle_volume_internal), id(control_menu_sendspin_volume_internal), "Internal Volume", !id(volume_control_target_group), 7 },
{ id(control_menu_idle_volume_group), id(control_menu_sendspin_volume_group), "Group Volume", id(volume_control_target_group), 8 },
};
for (int i = 0; i < 2; i++) {
const bool cursor_here = (volume_rows[i].cursor_value == id(control_menu_cursor));
char item_text[64];
snprintf(item_text, sizeof(item_text), " %s%s", volume_rows[i].label, volume_rows[i].is_active ? " *" : "");
lv_label_set_text(volume_rows[i].idle, item_text);
lv_label_set_text(volume_rows[i].sendspin, item_text);
if (cursor_here) {
lv_obj_set_style_bg_color(volume_rows[i].idle, lv_color_hex(0x66CCFF), LV_PART_MAIN);
lv_obj_set_style_bg_opa(volume_rows[i].idle, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_text_color(volume_rows[i].idle, lv_color_hex(0x000000), LV_PART_MAIN);
lv_obj_set_style_radius(volume_rows[i].idle, 6, LV_PART_MAIN);
lv_obj_set_style_bg_color(volume_rows[i].sendspin, lv_color_hex(0x66CCFF), LV_PART_MAIN);
lv_obj_set_style_bg_opa(volume_rows[i].sendspin, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_text_color(volume_rows[i].sendspin, lv_color_hex(0x000000), LV_PART_MAIN);
lv_obj_set_style_radius(volume_rows[i].sendspin, 6, LV_PART_MAIN);
} else {
lv_obj_set_style_bg_opa(volume_rows[i].idle, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_text_color(volume_rows[i].idle, lv_color_hex(0x33CC66), LV_PART_MAIN);
lv_obj_set_style_bg_opa(volume_rows[i].sendspin, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_text_color(volume_rows[i].sendspin, lv_color_hex(0x33CC66), LV_PART_MAIN);
}
}
- if:
condition:
lvgl.page.is_showing: idle_page
then:
- lvgl.widget.show: control_menu_idle
- lvgl.widget.hide: control_menu_sendspin
else:
- if:
condition:
lvgl.page.is_showing: sendspin_page
then:
- lvgl.widget.show: control_menu_sendspin
- lvgl.widget.hide: control_menu_idle
- id: hide_control_menu
mode: restart
then:
- lambda: |-
id(control_menu_active) = false;
- lvgl.widget.hide: control_menu_idle
- lvgl.widget.hide: control_menu_sendspin
- id: show_control_menu
mode: restart
then:
- lambda: |-
id(control_menu_active) = true;
if (id(selected_control_index) < 1 || id(selected_control_index) > 6) {
id(selected_control_index) = 1;
}
id(control_menu_cursor) = id(selected_control_index);
id(control_menu_turn_halfstep) = false;
- script.execute: hide_popup
- script.execute: render_control_menu
- id: select_control_menu_item
mode: restart
then:
- lambda: |-
const int cursor = id(control_menu_cursor);
if (cursor >= 1 && cursor <= 6) {
id(selected_control_index) = cursor;
} else if (cursor == 7) {
id(volume_control_target_group) = false;
} else if (cursor == 8) {
id(volume_control_target_group) = true;
}
- script.execute: render_control_menu
- id: render_media_menu
mode: restart
then:
- lambda: |-
int count = (int) lroundf(id(media_item_count).state);
if (count < 1) count = 1;
if (count > 10) count = 10;
int total_items = count + 1; // +1 for the Group Play row
if (id(media_menu_cursor) < 1 || id(media_menu_cursor) > total_items) {
id(media_menu_cursor) = 1;
}
lv_obj_t *group_idle = id(media_menu_idle_group_play);
lv_obj_t *group_sendspin = id(media_menu_sendspin_group_play);
const bool group_selected = (id(media_menu_cursor) == 1);
const char *group_text = id(group_play_active) ? " Exit Group" : " Group Play";
lv_label_set_text(group_idle, group_text);
lv_label_set_text(group_sendspin, group_text);
if (group_selected) {
lv_obj_set_style_bg_color(group_idle, lv_color_hex(0xFF9900), LV_PART_MAIN);
lv_obj_set_style_bg_opa(group_idle, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_text_color(group_idle, lv_color_hex(0x000000), LV_PART_MAIN);
lv_obj_set_style_radius(group_idle, 6, LV_PART_MAIN);
lv_obj_set_style_bg_color(group_sendspin, lv_color_hex(0xFF9900), LV_PART_MAIN);
lv_obj_set_style_bg_opa(group_sendspin, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_text_color(group_sendspin, lv_color_hex(0x000000), LV_PART_MAIN);
lv_obj_set_style_radius(group_sendspin, 6, LV_PART_MAIN);
lv_obj_scroll_to_view(group_idle, LV_ANIM_ON);
lv_obj_scroll_to_view(group_sendspin, LV_ANIM_ON);
} else {
lv_obj_set_style_bg_opa(group_idle, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_text_color(group_idle, lv_color_hex(0xFF9900), LV_PART_MAIN);
lv_obj_set_style_bg_opa(group_sendspin, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_text_color(group_sendspin, lv_color_hex(0xFF9900), LV_PART_MAIN);
}
const char *labels[10] = {
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(),
id(media_name_7).state.c_str(), id(media_name_8).state.c_str(), id(media_name_9).state.c_str(),
id(media_name_10).state.c_str()
};
lv_obj_t *idle_items[10] = {
id(media_menu_idle_item_1), id(media_menu_idle_item_2), id(media_menu_idle_item_3),
id(media_menu_idle_item_4), id(media_menu_idle_item_5), id(media_menu_idle_item_6),
id(media_menu_idle_item_7), id(media_menu_idle_item_8), id(media_menu_idle_item_9),
id(media_menu_idle_item_10)
};
lv_obj_t *sendspin_items[10] = {
id(media_menu_sendspin_item_1), id(media_menu_sendspin_item_2), id(media_menu_sendspin_item_3),
id(media_menu_sendspin_item_4), id(media_menu_sendspin_item_5), id(media_menu_sendspin_item_6),
id(media_menu_sendspin_item_7), id(media_menu_sendspin_item_8), id(media_menu_sendspin_item_9),
id(media_menu_sendspin_item_10)
};
for (int i = 0; i < 10; i++) {
if (i < count) {
lv_obj_clear_flag(idle_items[i], LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(sendspin_items[i], LV_OBJ_FLAG_HIDDEN);
const bool selected = ((i + 2) == id(media_menu_cursor)); // +1 for 0-index, +1 for the Group Play row
char item_text[64];
snprintf(item_text, sizeof(item_text), " %s", labels[i]);
lv_label_set_text(idle_items[i], item_text);
lv_label_set_text(sendspin_items[i], item_text);
if (selected) {
lv_obj_set_style_bg_color(idle_items[i], lv_color_hex(0xFFFFFF), LV_PART_MAIN);
lv_obj_set_style_bg_opa(idle_items[i], LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_text_color(idle_items[i], lv_color_hex(0x000000), LV_PART_MAIN);
lv_obj_set_style_radius(idle_items[i], 6, LV_PART_MAIN);
lv_obj_set_style_bg_color(sendspin_items[i], lv_color_hex(0xFFFFFF), LV_PART_MAIN);
lv_obj_set_style_bg_opa(sendspin_items[i], LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_text_color(sendspin_items[i], lv_color_hex(0x000000), LV_PART_MAIN);
lv_obj_set_style_radius(sendspin_items[i], 6, LV_PART_MAIN);
// scroll to ensure selected item is visible
lv_obj_scroll_to_view(idle_items[i], LV_ANIM_ON);
lv_obj_scroll_to_view(sendspin_items[i], LV_ANIM_ON);
} else {
lv_obj_set_style_bg_opa(idle_items[i], LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_text_color(idle_items[i], lv_color_hex(0xFFFFFF), LV_PART_MAIN);
lv_obj_set_style_bg_opa(sendspin_items[i], LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_text_color(sendspin_items[i], lv_color_hex(0xFFFFFF), LV_PART_MAIN);
}
} else {
lv_obj_add_flag(idle_items[i], LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(sendspin_items[i], LV_OBJ_FLAG_HIDDEN);
}
}
- if:
condition:
lvgl.page.is_showing: idle_page
then:
- lvgl.widget.show: media_menu_idle
- lvgl.widget.hide: media_menu_sendspin
else:
- if:
condition:
lvgl.page.is_showing: sendspin_page
then:
- lvgl.widget.show: media_menu_sendspin
- lvgl.widget.hide: media_menu_idle
- id: hide_media_menu
mode: restart
then:
- lambda: |-
id(media_menu_active) = false;
lv_obj_scroll_to_y(id(media_menu_idle), 0, LV_ANIM_OFF);
lv_obj_scroll_to_y(id(media_menu_sendspin), 0, LV_ANIM_OFF);
- lvgl.widget.hide: media_menu_idle
- lvgl.widget.hide: media_menu_sendspin
- id: show_media_menu
mode: restart
then:
- if:
condition:
lambda: 'return id(control_menu_active) || (!lv_obj_has_flag(id(popup_idle), LV_OBJ_FLAG_HIDDEN)) || (!lv_obj_has_flag(id(popup_sendspin), LV_OBJ_FLAG_HIDDEN));'
then:
- delay: 0s
else:
- lambda: |-
id(media_menu_active) = true;
id(media_menu_cursor) = 1;
id(media_menu_turn_halfstep) = false;
lv_obj_scroll_to_y(id(media_menu_idle), 0, LV_ANIM_OFF);
lv_obj_scroll_to_y(id(media_menu_sendspin), 0, LV_ANIM_OFF);
- script.execute: hide_popup
- script.execute: render_media_menu
- id: media_button_hold_action
mode: restart
then:
- if:
condition:
lambda: 'return id(control_menu_active) || (!lv_obj_has_flag(id(popup_idle), LV_OBJ_FLAG_HIDDEN)) || (!lv_obj_has_flag(id(popup_sendspin), LV_OBJ_FLAG_HIDDEN));'
then:
- delay: 0s
else:
- if:
condition:
lambda: |-
return id(media_menu_active) ||
(!lv_obj_has_flag(id(media_menu_idle), LV_OBJ_FLAG_HIDDEN)) ||
(!lv_obj_has_flag(id(media_menu_sendspin), LV_OBJ_FLAG_HIDDEN));
then:
- script.execute: hide_media_menu
else:
- script.execute: show_media_menu
- id: media_button_hold_check
mode: restart
then:
- delay: 700ms
- if:
condition:
binary_sensor.is_on: media_button
then:
- lambda: |-
id(media_button_hold_fired) = true;
id(media_button_click_count) = 0;
- script.execute: media_button_hold_action
- id: media_button_finalize_clicks
mode: restart
then:
- delay: 351ms
- if:
condition:
lambda: 'return !id(media_button_hold_fired);'
then:
- if:
condition:
lambda: 'return id(control_menu_active) || (!lv_obj_has_flag(id(popup_idle), LV_OBJ_FLAG_HIDDEN)) || (!lv_obj_has_flag(id(popup_sendspin), LV_OBJ_FLAG_HIDDEN));'
then:
- lambda: |-
id(media_button_click_count) = 0;
else:
- if:
condition:
lambda: 'return id(media_button_click_count) >= 4;'
then:
- lambda: |-
id(media_button_click_count) = 0;
else:
- if:
condition:
lambda: 'return id(media_button_click_count) == 3;'
then:
- media_player.previous: sendspin_group_media_player
- lambda: |-
id(media_button_click_count) = 0;
else:
- if:
condition:
lambda: 'return id(media_button_click_count) == 2;'
then:
- media_player.next: sendspin_group_media_player
- lambda: |-
id(media_button_click_count) = 0;
else:
- if:
condition:
lambda: 'return id(media_button_click_count) == 1;'
then:
- if:
condition:
lambda: 'return id(media_menu_active);'
then:
- script.execute: select_media_menu_item
else:
- if:
condition:
media_player.is_playing: sendspin_group_media_player
then:
- media_player.stop: sendspin_group_media_player
- lvgl.page.show: idle_page
else:
- media_player.play: sendspin_group_media_player
- lambda: |-
id(media_button_click_count) = 0;
- lambda: |-
id(media_button_hold_fired) = false;
else:
- lambda: |-
id(media_button_hold_fired) = false;
id(media_button_click_count) = 0;
- id: select_media_menu_item
mode: restart
then:
- if:
condition:
lambda: 'return id(media_menu_cursor) == 1;'
then:
- sendspin.switch:
- lambda: |-
id(group_play_active) = !id(group_play_active);
else:
- if:
condition:
lambda: |-
const char *urls[10] = {
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(),
id(media_url_7).state.c_str(), id(media_url_8).state.c_str(), id(media_url_9).state.c_str(),
id(media_url_10).state.c_str()
};
int index = id(media_menu_cursor) - 1; // -1 for the Group Play row
if (index < 1 || index > 10) return false;
std::string url = urls[index - 1];
std::string player = id(ha_media_player_entity).state;
return !player.empty() && !url.empty() && 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: |-
const char *urls[10] = {
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(),
id(media_url_7).state.c_str(), id(media_url_8).state.c_str(), id(media_url_9).state.c_str(),
id(media_url_10).state.c_str()
};
int index = id(media_menu_cursor) - 1; // -1 for the Group Play row
if (index < 1 || index > 10) return std::string("");
return std::string(urls[index - 1]);
else:
- logger.log:
level: WARN
format: "Selected media item or media player has no Home Assistant entity configured"
- script.execute: hide_media_menu
- id: apply_knob_to_light
mode: restart
then:
- if:
condition:
lambda: |-
const char *entities[6] = {
id(control_device_1).state.c_str(),
id(control_device_2).state.c_str(),
id(control_device_3).state.c_str(),
id(control_device_4).state.c_str(),
id(control_device_5).state.c_str(),
id(control_device_6).state.c_str(),
};
int index = id(selected_control_index);
if (index < 1 || index > 6) index = 1;
return std::string(entities[index - 1]) != "";
then:
- if:
condition:
lambda: 'return id(knob_control_pct) <= 0;'
then:
- homeassistant.action:
action: light.turn_off
data_template:
entity_id: "{{ target_entity }}"
variables:
target_entity: |-
const char *entities[6] = {
id(control_device_1).state.c_str(),
id(control_device_2).state.c_str(),
id(control_device_3).state.c_str(),
id(control_device_4).state.c_str(),
id(control_device_5).state.c_str(),
id(control_device_6).state.c_str(),
};
int index = id(selected_control_index);
if (index < 1 || index > 6) index = 1;
return std::string(entities[index - 1]);
else:
- homeassistant.action:
action: light.turn_on
data_template:
entity_id: "{{ target_entity }}"
brightness: "{{ brightness_value }}"
variables:
target_entity: |-
const char *entities[6] = {
id(control_device_1).state.c_str(),
id(control_device_2).state.c_str(),
id(control_device_3).state.c_str(),
id(control_device_4).state.c_str(),
id(control_device_5).state.c_str(),
id(control_device_6).state.c_str(),
};
int index = id(selected_control_index);
if (index < 1 || index > 6) index = 1;
return std::string(entities[index - 1]);
brightness_value: |-
int bri = (int) lroundf(id(knob_control_pct) * 255.0f / 100.0f);
if (bri < 1) bri = 1;
if (bri > 255) bri = 255;
return bri;
else:
- logger.log:
level: WARN
format: "Selected control item has no Home Assistant entity configured"
- id: play_sound
parameters:
priority: bool
sound_file: string
then:
- if:
condition:
lambda: 'return sound_file == "ready_sound";'
then:
- delay: 2s
- lvgl.page.show: boot_sound_page
- 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();
}
interval:
- interval: 10min
then:
- script.execute: fetch_daily_forecast
number:
- platform: template
id: media_item_count
name: Media Item Count
icon: "mdi:playlist-music"
entity_category: config
optimistic: true
restore_value: true
initial_value: 6
min_value: 1
max_value: 10
step: 1
text:
- platform: template
id: ha_weather_entity
name: Home Assistant Weather Entity
icon: "mdi:weather-partly-cloudy"
entity_category: config
optimistic: true
restore_value: true
initial_value: "weather.forecast_home"
max_length: 128
mode: text
on_value:
- script.execute: fetch_daily_forecast
- 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.sensspin_zero_control"
max_length: 128
mode: text
- platform: template
id: control_devicename_1
name: Control 1 Name
icon: "mdi:label-outline"
entity_category: config
optimistic: true
restore_value: true
initial_value: "Ceiling 1"
max_length: 64
mode: text
- platform: template
id: control_device_1
name: Control 1 Entity
icon: "mdi:lightbulb-outline"
entity_category: config
optimistic: true
restore_value: true
initial_value: "light.ceiling"
max_length: 128
mode: text
- platform: template
id: control_devicename_2
name: Control 2 Name
icon: "mdi:label-outline"
entity_category: config
optimistic: true
restore_value: true
initial_value: "Ceiling 2"
max_length: 64
mode: text
- platform: template
id: control_device_2
name: Control 2 Entity
icon: "mdi:lightbulb-outline"
entity_category: config
optimistic: true
restore_value: true
initial_value: "light.ceiling_2"
max_length: 128
mode: text
- platform: template
id: control_devicename_3
name: Control 3 Name
icon: "mdi:label-outline"
entity_category: config
optimistic: true
restore_value: true
initial_value: "Desk Light"
max_length: 64
mode: text
- platform: template
id: control_device_3
name: Control 3 Entity
icon: "mdi:lightbulb-outline"
entity_category: config
optimistic: true
restore_value: true
initial_value: "light.desklight_light"
max_length: 128
mode: text
- platform: template
id: control_devicename_4
name: Control 4 Name
icon: "mdi:label-outline"
entity_category: config
optimistic: true
restore_value: true
initial_value: "Prt. Light"
max_length: 64
mode: text
- platform: template
id: control_device_4
name: Control 4 Entity
icon: "mdi:lightbulb-outline"
entity_category: config
optimistic: true
restore_value: true
initial_value: "light.hue_p45_7_light"
max_length: 128
mode: text
- platform: template
id: control_devicename_5
name: Control 5 Name
icon: "mdi:label-outline"
entity_category: config
optimistic: true
restore_value: true
initial_value: "Blue Lamp"
max_length: 64
mode: text
- platform: template
id: control_device_5
name: Control 5 Entity
icon: "mdi:lightbulb-outline"
entity_category: config
optimistic: true
restore_value: true
initial_value: "light.hue_p45_8_light"
max_length: 128
mode: text
- platform: template
id: control_devicename_6
name: Control 6 Name
icon: "mdi:label-outline"
entity_category: config
optimistic: true
restore_value: true
initial_value: "All Light"
max_length: 64
mode: text
- platform: template
id: control_device_6
name: Control 6 Entity
icon: "mdi:lightbulb-outline"
entity_category: config
optimistic: true
restore_value: true
initial_value: "light.livingroom_all_lights"
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: "Random 500"
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/45"
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: "Neil Diamond"
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/72"
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: "Tommy Steele"
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/68"
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: "Radio: P5"
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: "https://live-icy.gss.dr.dk/A/A25L.mp3"
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: Boost"
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: "http://6434.cloudrad.io:8066/live"
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: Smooth Chill"
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://ice-sov.musicradio.com/SmoothChillMP3"
max_length: 255
mode: text
- platform: template
id: media_name_7
name: Media 7 Name
icon: "mdi:music-box-outline"
entity_category: config
optimistic: true
restore_value: true
initial_value: "Unused 7"
max_length: 64
mode: text
- platform: template
id: media_url_7
name: Media 7 URL
icon: "mdi:link-variant"
entity_category: config
optimistic: true
restore_value: true
initial_value: "unused"
max_length: 255
mode: text
- platform: template
id: media_name_8
name: Media 8 Name
icon: "mdi:music-box-outline"
entity_category: config
optimistic: true
restore_value: true
initial_value: "Unused 8"
max_length: 64
mode: text
- platform: template
id: media_url_8
name: Media 8 URL
icon: "mdi:link-variant"
entity_category: config
optimistic: true
restore_value: true
initial_value: "unused"
max_length: 255
mode: text
- platform: template
id: media_name_9
name: Media 9 Name
icon: "mdi:music-box-outline"
entity_category: config
optimistic: true
restore_value: true
initial_value: "Unused 9"
max_length: 64
mode: text
- platform: template
id: media_url_9
name: Media 9 URL
icon: "mdi:link-variant"
entity_category: config
optimistic: true
restore_value: true
initial_value: "unused"
max_length: 255
mode: text
- platform: template
id: media_name_10
name: Media 10 Name
icon: "mdi:music-box-outline"
entity_category: config
optimistic: true
restore_value: true
initial_value: "Unused 10"
max_length: 64
mode: text
- platform: template
id: media_url_10
name: Media 10 URL
icon: "mdi:link-variant"
entity_category: config
optimistic: true
restore_value: true
initial_value: "unused"
max_length: 255
mode: text
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
- platform: template
id: menu_scroll_direction_switch
name: Invert Menu Scroll
icon: "mdi:swap-vertical"
entity_category: config
optimistic: true
restore_mode: RESTORE_DEFAULT_OFF
button:
- platform: restart
id: restart_button
name: "Restart"
entity_category: config
icon: "mdi:restart"
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: 320
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
scrollable: false
widgets:
- obj:
id: idle_center_block
align: TOP_MID
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: 96
width: 240
height: 70
bg_opa: TRANSP
border_opa: TRANSP
pad_all: 0
scrollable: false
widgets:
- label:
id: weather_icon_glow
align: CENTER
x: -36
y: 1
text: "\U000F0590"
text_color: 0x404040
text_font: font_mdi_weather
- 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: "--"
text_color: 0xFFFF00
text_font: font_temp_unit
- label:
id: weather_label_glow
align: TOP_MID
x: 1
y: 186
width: 220
text: "Weather..."
text_color: 0x1AA6C8
text_font: font_display
long_mode: SCROLL_CIRCULAR
text_align: CENTER
- label:
id: weather_label
align: TOP_MID
x: 0
y: 185
width: 220
text: "Weather..."
text_color: 0x66CCFF
text_font: font_display
long_mode: SCROLL_CIRCULAR
text_align: CENTER
- obj:
id: forecast_strip
hidden: true
align: BOTTOM_MID
y: -10
width: 234
height: 74
radius: 18
pad_top: 6
pad_bottom: 6
pad_left: 4
pad_right: 4
scrollable: false
bg_color: 0x000000
bg_opa: 35%
border_opa: 15%
border_color: 0xFFFFFF
border_width: 1
widgets:
- obj:
align: LEFT_MID
width: 74
height: 62
bg_opa: TRANSP
border_opa: TRANSP
pad_all: 0
scrollable: false
widgets:
- label:
id: forecast_day_1
align: TOP_MID
y: -1
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: 1
text: "--"
text_color: 0x8EDBFF
text_font: font_forecast
- obj:
align: CENTER
width: 74
height: 62
bg_opa: TRANSP
border_opa: TRANSP
pad_all: 0
scrollable: false
widgets:
- label:
id: forecast_day_2
align: TOP_MID
y: -1
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: 1
text: "--"
text_color: 0x8EDBFF
text_font: font_forecast
- obj:
align: RIGHT_MID
width: 74
height: 62
bg_opa: TRANSP
border_opa: TRANSP
pad_all: 0
scrollable: false
widgets:
- label:
id: forecast_day_3
align: TOP_MID
y: -1
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: 1
text: "--"
text_color: 0x8EDBFF
text_font: font_forecast
- obj:
id: popup_idle
hidden: true
align: CENTER
width: 124
height: 240
radius: 18
pad_top: 12
pad_bottom: 12
pad_left: 10
pad_right: 10
scrollable: false
bg_color: 0x202020
bg_opa: COVER
border_color: 0x66CCFF
border_width: 2
widgets:
- label:
id: popup_title_idle
align: TOP_MID
y: 0
width: 100
text: "Volume"
text_color: 0xFFFFFF
text_align: CENTER
- slider:
id: popup_slider_idle
align: TOP_MID
y: 58
width: 36
height: 135
min_value: 0
max_value: 100
value: 0
bg_color: 0x3A3A3A
indicator:
bg_color: 0x66CCFF
knob:
bg_color: 0xFFFFFF
radius: 10
pad_all: 8
- obj:
id: control_menu_idle
hidden: true
align: CENTER
width: 220
height: 280
radius: 18
pad_top: 12
pad_bottom: 12
pad_left: 12
pad_right: 12
scrollable: true
scrollbar_mode: ACTIVE
bg_color: 0x202020
bg_opa: COVER
border_color: 0x66CCFF
border_width: 2
widgets:
- label:
align: TOP_MID
y: 0
width: 196
text: "Select Control"
text_color: 0xFFFF00
text_align: CENTER
- label:
id: control_menu_idle_item_1
align: TOP_LEFT
y: 38
width: 196
text: " Control 1"
text_color: 0xFFFFFF
- label:
id: control_menu_idle_item_2
align: TOP_LEFT
y: 66
width: 196
text: " Control 2"
text_color: 0xFFFFFF
- label:
id: control_menu_idle_item_3
align: TOP_LEFT
y: 94
width: 196
text: " Control 3"
text_color: 0xFFFFFF
- label:
id: control_menu_idle_item_4
align: TOP_LEFT
y: 122
width: 196
text: " Control 4"
text_color: 0xFFFFFF
- label:
id: control_menu_idle_item_5
align: TOP_LEFT
y: 150
width: 196
text: " Control 5"
text_color: 0xFFFFFF
- label:
id: control_menu_idle_item_6
align: TOP_LEFT
y: 178
width: 196
text: " Control 6"
text_color: 0xFFFFFF
- obj:
align: TOP_LEFT
y: 202
width: 196
height: 2
bg_color: 0x555555
bg_opa: COVER
border_opa: TRANSP
pad_all: 0
scrollable: false
- label:
id: control_menu_idle_volume_internal
align: TOP_LEFT
y: 212
width: 196
text: " Internal Volume"
text_color: 0x33CC66
- label:
id: control_menu_idle_volume_group
align: TOP_LEFT
y: 240
width: 196
text: " Group Volume"
text_color: 0x33CC66
- obj:
id: media_menu_idle
hidden: true
align: CENTER
width: 220
height: 290
radius: 18
pad_top: 12
pad_bottom: 12
pad_left: 12
pad_right: 12
scrollable: true
scrollbar_mode: ACTIVE
bg_color: 0x202020
bg_opa: COVER
border_color: 0xFFFF00
border_width: 2
widgets:
- label:
align: TOP_MID
y: 0
width: 196
text: "Select Music"
text_color: 0xFFFF00
text_align: CENTER
- label:
id: media_menu_idle_group_play
align: TOP_LEFT
y: 38
width: 196
text: " Group Play"
text_color: 0xFF9900
- obj:
align: TOP_LEFT
y: 62
width: 196
height: 2
bg_color: 0x555555
bg_opa: COVER
border_opa: TRANSP
pad_all: 0
scrollable: false
- label:
id: media_menu_idle_item_1
align: TOP_LEFT
y: 72
width: 196
text: " Media 1"
text_color: 0xFFFFFF
- label:
id: media_menu_idle_item_2
align: TOP_LEFT
y: 100
width: 196
text: " Media 2"
text_color: 0xFFFFFF
- label:
id: media_menu_idle_item_3
align: TOP_LEFT
y: 128
width: 196
text: " Media 3"
text_color: 0xFFFFFF
- label:
id: media_menu_idle_item_4
align: TOP_LEFT
y: 156
width: 196
text: " Media 4"
text_color: 0xFFFFFF
- label:
id: media_menu_idle_item_5
align: TOP_LEFT
y: 184
width: 196
text: " Media 5"
text_color: 0xFFFFFF
- label:
id: media_menu_idle_item_6
align: TOP_LEFT
y: 212
width: 196
text: " Media 6"
text_color: 0xFFFFFF
- label:
id: media_menu_idle_item_7
align: TOP_LEFT
y: 240
width: 196
text: " Media 7"
text_color: 0xFFFFFF
- label:
id: media_menu_idle_item_8
align: TOP_LEFT
y: 268
width: 196
text: " Media 8"
text_color: 0xFFFFFF
- label:
id: media_menu_idle_item_9
align: TOP_LEFT
y: 296
width: 196
text: " Media 9"
text_color: 0xFFFFFF
- label:
id: media_menu_idle_item_10
align: TOP_LEFT
y: 324
width: 196
text: " Media 10"
text_color: 0xFFFFFF
- id: sendspin_page
bg_color: 0x000000
scrollable: false
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_title_label
align: TOP_MID
y: 224
width: 232
height: 32
text: ""
text_color: 0xFFFF00
text_font: font_display_bold
long_mode: SCROLL_CIRCULAR
text_align: CENTER
- label:
id: now_playing_album_label
align: TOP_MID
y: 254
width: 232
text: ""
text_color: 0xFFFFFF
long_mode: SCROLL_CIRCULAR
text_align: CENTER
- label:
id: now_playing_artist_label
align: TOP_MID
y: 284
width: 232
text: ""
text_color: 0xFFFFFF
long_mode: SCROLL_CIRCULAR
text_align: CENTER
- obj:
id: popup_sendspin
hidden: true
align: CENTER
width: 124
height: 240
radius: 18
pad_top: 12
pad_bottom: 12
pad_left: 10
pad_right: 10
scrollable: false
bg_color: 0x202020
bg_opa: COVER
border_color: 0x66CCFF
border_width: 2
widgets:
- label:
id: popup_title_sendspin
align: TOP_MID
y: 0
width: 100
text: "Volume"
text_color: 0xFFFFFF
text_align: CENTER
- slider:
id: popup_slider_sendspin
align: TOP_MID
y: 58
width: 36
height: 135
min_value: 0
max_value: 100
value: 0
bg_color: 0x3A3A3A
indicator:
bg_color: 0x66CCFF
knob:
bg_color: 0xFFFFFF
radius: 10
pad_all: 8
- obj:
id: control_menu_sendspin
hidden: true
align: CENTER
width: 220
height: 280
radius: 18
pad_top: 12
pad_bottom: 12
pad_left: 12
pad_right: 12
scrollable: true
scrollbar_mode: ACTIVE
bg_color: 0x202020
bg_opa: COVER
border_color: 0x66CCFF
border_width: 2
widgets:
- label:
align: TOP_MID
y: 0
width: 196
text: "Select Control"
text_color: 0xFFFF00
text_align: CENTER
- label:
id: control_menu_sendspin_item_1
align: TOP_LEFT
y: 38
width: 196
text: " Control 1"
text_color: 0xFFFFFF
- label:
id: control_menu_sendspin_item_2
align: TOP_LEFT
y: 66
width: 196
text: " Control 2"
text_color: 0xFFFFFF
- label:
id: control_menu_sendspin_item_3
align: TOP_LEFT
y: 94
width: 196
text: " Control 3"
text_color: 0xFFFFFF
- label:
id: control_menu_sendspin_item_4
align: TOP_LEFT
y: 122
width: 196
text: " Control 4"
text_color: 0xFFFFFF
- label:
id: control_menu_sendspin_item_5
align: TOP_LEFT
y: 150
width: 196
text: " Control 5"
text_color: 0xFFFFFF
- label:
id: control_menu_sendspin_item_6
align: TOP_LEFT
y: 178
width: 196
text: " Control 6"
text_color: 0xFFFFFF
- obj:
align: TOP_LEFT
y: 202
width: 196
height: 2
bg_color: 0x555555
bg_opa: COVER
border_opa: TRANSP
pad_all: 0
scrollable: false
- label:
id: control_menu_sendspin_volume_internal
align: TOP_LEFT
y: 212
width: 196
text: " Internal Volume"
text_color: 0x33CC66
- label:
id: control_menu_sendspin_volume_group
align: TOP_LEFT
y: 240
width: 196
text: " Group Volume"
text_color: 0x33CC66
- obj:
id: media_menu_sendspin
hidden: true
align: CENTER
width: 220
height: 290
radius: 18
pad_top: 12
pad_bottom: 12
pad_left: 12
pad_right: 12
scrollable: true
scrollbar_mode: ACTIVE
bg_color: 0x202020
bg_opa: COVER
border_color: 0xFFFF00
border_width: 2
widgets:
- label:
align: TOP_MID
y: 0
width: 196
text: "Select Music"
text_color: 0xFFFF00
text_align: CENTER
- label:
id: media_menu_sendspin_group_play
align: TOP_LEFT
y: 38
width: 196
text: " Group Play"
text_color: 0xFF9900
- obj:
align: TOP_LEFT
y: 62
width: 196
height: 2
bg_color: 0x555555
bg_opa: COVER
border_opa: TRANSP
pad_all: 0
scrollable: false
- label:
id: media_menu_sendspin_item_1
align: TOP_LEFT
y: 72
width: 196
text: " Media 1"
text_color: 0xFFFFFF
- label:
id: media_menu_sendspin_item_2
align: TOP_LEFT
y: 100
width: 196
text: " Media 2"
text_color: 0xFFFFFF
- label:
id: media_menu_sendspin_item_3
align: TOP_LEFT
y: 128
width: 196
text: " Media 3"
text_color: 0xFFFFFF
- label:
id: media_menu_sendspin_item_4
align: TOP_LEFT
y: 156
width: 196
text: " Media 4"
text_color: 0xFFFFFF
- label:
id: media_menu_sendspin_item_5
align: TOP_LEFT
y: 184
width: 196
text: " Media 5"
text_color: 0xFFFFFF
- label:
id: media_menu_sendspin_item_6
align: TOP_LEFT
y: 212
width: 196
text: " Media 6"
text_color: 0xFFFFFF
- label:
id: media_menu_sendspin_item_7
align: TOP_LEFT
y: 240
width: 196
text: " Media 7"
text_color: 0xFFFFFF
- label:
id: media_menu_sendspin_item_8
align: TOP_LEFT
y: 268
width: 196
text: " Media 8"
text_color: 0xFFFFFF
- label:
id: media_menu_sendspin_item_9
align: TOP_LEFT
y: 296
width: 196
text: " Media 9"
text_color: 0xFFFFFF
- label:
id: media_menu_sendspin_item_10
align: TOP_LEFT
y: 324
width: 196
text: " Media 10"
text_color: 0xFFFFFF
binary_sensor:
- platform: gpio
pin:
number: 43
mode: INPUT_PULLUP
inverted: true
id: rotary_button
filters:
- delayed_on_off: 20ms
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 50ms
then:
- if:
condition:
lambda: 'return id(media_menu_active);'
then:
- delay: 0s
else:
- if:
condition:
lambda: 'return id(control_menu_active);'
then:
- script.execute: select_control_menu_item
else:
- script.execute: toggle_selected_control_popup
- timing:
- ON for at least 700ms
then:
- if:
condition:
lambda: 'return id(media_menu_active);'
then:
- delay: 0s
else:
- if:
condition:
lambda: 'return id(control_menu_active);'
then:
- script.execute: hide_control_menu
else:
- if:
condition:
lambda: 'return (!lv_obj_has_flag(id(popup_idle), LV_OBJ_FLAG_HIDDEN)) || (!lv_obj_has_flag(id(popup_sendspin), LV_OBJ_FLAG_HIDDEN));'
then:
- script.execute: hide_popup
else:
- script.execute: show_control_menu
- platform: gpio
pin:
number: 44
mode: INPUT_PULLUP
inverted: true
id: media_button
filters:
- delayed_on_off: 20ms
on_press:
then:
- lambda: |-
id(media_button_hold_fired) = false;
- script.execute: media_button_hold_check
on_release:
then:
- script.stop: media_button_hold_check
- if:
condition:
lambda: 'return id(media_button_hold_fired);'
then:
- lambda: |-
id(media_button_hold_fired) = false;
id(media_button_click_count) = 0;
else:
- lambda: |-
id(media_button_click_count) += 1;
- script.execute: media_button_finalize_clicks
# Factory firmware - generated by ewt-gen
# Users should import Sendspin_Zero-Control-Digital.yaml directly, not this file.
packages:
original: !include Sendspin_Zero-Control-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-Control-Digital/manifest.json
http_request:
dashboard_import:
package_import_url: github://RealDeco/SendspinZero/Sendspin_Zero-Control-Digital.yaml@main