## SendspinZero Display Analog version
substitutions:
  name: sendspinzero-display
  friendly_name: Sendspin Zero Display

## SETTINGS ######################################################################################################################################

  # Startup sound, options are: Home_Connected or Home_Connected_Male
  startup_sound_file: Home_Connected

  # Image model options are: AlbertQuackmore, Alita, Casita, Gwen, HA-character, ha-girl, Hacker, Salem, Sarah, Shaun
  imagemodel: "HA-character"

  # your home assistant weather entity, in most cases the same.
  ha_weather_entity: weather.forecast_home

  media_sample_rate: 48000

  # Rotating the display, uncomment the block you need (default 0 degree)

  # 0 degree
  display_swap_xy: "false"
  display_mirror_x: "false"
  display_mirror_y: "false"
  display_offset_width: "0"
  display_offset_height: "0"

  # 180 degree
  # display_swap_xy: "false"
  # display_mirror_x: "true"
  # display_mirror_y: "true"
  # display_offset_width: "0"
  # display_offset_height: "80"

## SENDSPIN SETTINGS #############################################################################################################################

packages:
  extcomp:
    url: https://github.com/RealDeco/xiaozhi-esphome.git
    ref: main
    refresh: 0s
    files:
      - devices/Under_Development/Modular/HW/sendspin-external_components_2026.7.3.yaml

#packages: # local
#  extcomp: !include localtest/HW/sendspin-external_components_2026.7.3.yaml

## END OF SETTINGS ###############################################################################################################################

esphome:
  name: ${name}
  friendly_name: ${friendly_name}
  min_version: 2026.7.4
  name_add_mac_suffix: true
  project:
    name: realdeco.sendspin_zero
    version: "2026.7.4"
  on_boot:
    priority: -100
    then:
      - lvgl.page.show: idle_page

esp32:
  variant: esp32s3
  flash_size: 4MB
  framework:
    type: esp-idf

logger:
  level: WARN

api:
  on_client_connected:
    - lambda: |-
        if (!id(boot_sound_played)) {
          id(boot_sound_played) = true;
          if (id(startup_sound_switch).state) {
            id(play_sound)->execute(true, std::string("ready_sound"));
          }
        }
ota:
  - platform: esphome

wifi:
  ap:

improv_serial:

psram:
  mode: quad
  speed: 80MHz

captive_portal:

button:
  - platform: restart
    id: restart_button
    name: "Restart"
    entity_category: config
    icon: "mdi:restart"
  
time:
  - platform: homeassistant
    id: homeassistant_time

globals:
  - id: sendspin_album_art_valid
    type: bool
    restore_value: no
    initial_value: "false"
  - id: last_album_art_valid
    type: bool
    restore_value: false
    initial_value: 'false'
  - id: boot_sound_played
    type: bool
    restore_value: no
    initial_value: "false"
  - id: boot_announce_active
    type: bool
    restore_value: no
    initial_value: "false"
  - id: weather_available
    type: bool
    restore_value: no
    initial_value: "false"

image:
  - platform: file
    file: https://raw.githubusercontent.com/RealDeco/xiaozhi-esphome/main/images/${imagemodel}/240x240/playing.png
    id: fallback_playing_image
    resize: 216x216
    type: RGB565
    dither: NONE

  - platform: sendspin
    id: album_slot
    format: JPEG
    type: RGB565
    resize: 216x216
    display_offset: 1s
    current_image:
      id: album_art
    transition_image:
      id: album_art_transition
    on_image_display:
      - script.stop: album_art_timeout_fallback
      - lambda: |-
          id(sendspin_album_art_valid) = true;
      - if:
          condition:
            lambda: 'return id(last_album_art_valid);'
          then:
            - lvgl.image.update:
                id: outgoing_art
                src: album_art_transition
          else:
            - lvgl.image.update:
                id: outgoing_art
                src: fallback_playing_image
      - lvgl.image.update:
          id: incoming_art
          src: album_art
      - lvgl.animation.start: album_art_crossfade
    on_image_clear:
      - logger.log:
          format: "Album art cleared"
          level: WARN
    on_image_error:
      - logger.log:
          format: "Album art error"
          level: WARN
      - lambda: |-
          id(sendspin_album_art_valid) = false;

sendspin:
  id: sendspin_hub

i2s_audio:
  - id: i2s_audio_bus
    i2s_lrclk_pin: GPIO4
    i2s_bclk_pin: GPIO5

speaker:
  - platform: i2s_audio
    id: box_speaker
    i2s_audio_id: i2s_audio_bus
    i2s_dout_pin: GPIO6
    dac_type: external
    channel: stereo

  - platform: mixer
    id: mixing_speaker
    output_speaker: box_speaker
    num_channels: 2
    source_speakers:
      - id: announcement_mixing_input
      - id: media_mixing_input

  - platform: resampler
    id: announcement_resampling_speaker
    output_speaker: announcement_mixing_input
    sample_rate: ${media_sample_rate}
    bits_per_sample: 16

  - platform: resampler
    id: media_resampling_speaker
    output_speaker: media_mixing_input
    sample_rate: ${media_sample_rate}
    bits_per_sample: 16

audio_file:
  - id: ready_sound
    file: https://github.com/RealDeco/xiaozhi-esphome/raw/main/sounds/${startup_sound_file}.flac

media_source:
  - platform: audio_file
    id: audio_file_announcement_source
  - platform: audio_http
    id: http_announcement_source
  - platform: audio_http
    id: http_media_source
  - platform: sendspin
    id: sendspin_media_source

media_player:
  - platform: sendspin
    id: sendspin_group_media_player
    name: Sendspin Group Media Player

  - platform: speaker_source
    id: external_media_player
    name: Media Player

    announcement_pipeline:
      format: FLAC
      num_channels: 1
      sample_rate: ${media_sample_rate}
      speaker: announcement_resampling_speaker
      sources:
        - audio_file_announcement_source
        - http_announcement_source

    media_pipeline:
      format: FLAC
      num_channels: 2
      sample_rate: ${media_sample_rate}
      speaker: media_resampling_speaker
      sources:
        - http_media_source
        - sendspin_media_source
    volume_increment: 0.05
    volume_min: 0.4
    volume_max: 0.85

    on_play:
      then:
        - script.stop: mp_idle_debounce
        - lvgl.page.show: sendspin_page

    on_announcement:
      then: 
        - script.stop: mp_idle_debounce

    on_idle:
      then:
        - script.execute: mp_idle_debounce

    on_pause:
      then:
        - script.execute: mp_idle_debounce
        
sensor:
  - platform: homeassistant
    id: ha_weather_temperature
    entity_id: ${ha_weather_entity}
    attribute: temperature
  - platform: homeassistant
    id: ha_weather_wind_speed
    entity_id: ${ha_weather_entity}
    attribute: wind_speed

text_sensor:
  - platform: homeassistant
    id: ha_weather_condition
    entity_id: ${ha_weather_entity}
    on_value:
      - lambda: |-
          const char* icon = "\U000F0590";
          auto c = id(ha_weather_condition).state;
          if (c == "sunny" || c == "clear") {
            icon = "\U000F0599";
          } else if (c == "partlycloudy") {
            icon = "\U000F0595";
          } else if (c == "cloudy") {
            icon = "\U000F0590";
          } else if (c == "rainy") {
            icon = "\U000F0597";
          } else if (c == "pouring") {
            icon = "\U000F0596";
          } else if (c == "snowy") {
            icon = "\U000F0598";
          } else if (c == "lightning") {
            icon = "\U000F0593";
          } else if (c == "hail") {
            icon = "\U000F0592";
          }
          lv_label_set_text(id(weather_icon), icon);
  - platform: homeassistant
    id: ha_weather_temperature_unit
    entity_id: ${ha_weather_entity}
    attribute: temperature_unit

  - platform: homeassistant
    id: ha_weather_wind_speed_unit
    entity_id: ${ha_weather_entity}
    attribute: wind_speed_unit

  - platform: sendspin
    id: sendspin_title
    name: "Song Title"
    type: title
    on_value:
      - component.update: now_playing_text

  - platform: sendspin
    id: sendspin_artist
    name: "Song Artist"
    type: artist
    on_value:
      - component.update: now_playing_text

  - platform: sendspin
    id: sendspin_album
    name: "Album Name"
    type: album
    on_value:
      - lambda:
          id(last_album_art_valid) = id(sendspin_album_art_valid);
      - lambda: |-
          id(sendspin_album_art_valid) = false;
      - script.execute: album_art_timeout_fallback
      - component.update: now_playing_text

  - platform: template
    id: now_playing_text
    update_interval: never
    lambda: |-
      std::string s = id(sendspin_title).get_state();
      auto a = id(sendspin_artist).get_state();
      if (!a.empty()) {
        if (!s.empty()) s += " - ";
        s += a;
      }
      return s;
    on_value:
      - lvgl.label.update:
          id: now_playing_label
          text: !lambda return x;

  - platform: template
    id: clock_hms
    update_interval: 1s
    lambda: |-
      auto t = id(homeassistant_time).now();
      if (!t.is_valid()) return std::string("--:--:--");
      bool hide_colons = ((t.second % 2) == 0);
      char buf[9];
      if (hide_colons) snprintf(buf, sizeof(buf), "%02d %02d %02d", t.hour, t.minute, t.second);
      else snprintf(buf, sizeof(buf), "%02d:%02d:%02d", t.hour, t.minute, t.second);
      return std::string(buf);
    on_value:
      - if:
          condition:
            lvgl.page.is_showing: idle_page
          then:
            - lvgl.label.update:
                id: clock_label
                text: !lambda return x;

  - platform: template
    id: clock_date
    update_interval: 30s
    lambda: |-
      auto t = id(homeassistant_time).now();
      if (!t.is_valid()) return std::string("------");
      static const char* months[] = {
        "January","February","March","April","May","June",
        "July","August","September","October","November","December"
      };
      const char* mon = (t.month >= 1 && t.month <= 12) ? months[t.month - 1] : "???";
      char buf[32];
      snprintf(buf, sizeof(buf), "%d %s", t.day_of_month, mon);
      return std::string(buf);
    on_value:
      - if:
          condition:
            lvgl.page.is_showing: idle_page
          then:
            - lvgl.label.update:
                id: date_label
                text: !lambda return x;

  - platform: template
    id: weather_temp_big
    update_interval: 15s
    lambda: |-
      if (!id(ha_weather_temperature).has_state()) return std::string("--");
      std::string tunit = id(ha_weather_temperature_unit).state.c_str();
      if (tunit.empty()) tunit = "∞C";
      lv_label_set_text(id(weather_big_temp_unit), tunit.c_str());
      int ti = (int) lroundf(id(ha_weather_temperature).state);
      char buf[8];
      snprintf(buf, sizeof(buf), "%d", ti);
      return std::string(buf);
    on_value:
      - if:
          condition:
            lvgl.page.is_showing: idle_page
          then:
            - lvgl.label.update:
                id: weather_big_temp
                text: !lambda return x;

  - platform: template
    id: weather_line
    update_interval: 15s
    lambda: |-
      if (!id(ha_weather_temperature).has_state() || !id(ha_weather_wind_speed).has_state()) {
        return std::string("Weather unavailable");
      }
      std::string tunit = id(ha_weather_temperature_unit).state.c_str();
      if (tunit.empty()) tunit = "∞C";
      std::string wunit = id(ha_weather_wind_speed_unit).state.c_str();
      if (wunit.empty()) wunit = "m/s";
      float temp_c = id(ha_weather_temperature).state;
      float wind_ms = id(ha_weather_wind_speed).state;
      float feels_c = temp_c;
      if (temp_c <= 10.0f && wind_ms > 1.3f) {
        float wind_kmh = wind_ms * 3.6f;
        float v16 = powf(wind_kmh, 0.16f);
        feels_c = 13.12f + 0.6215f * temp_c - 11.37f * v16 + 0.3965f * temp_c * v16;
      }
      char buf[160];
      snprintf(buf, sizeof(buf),
               "Temp: %.1f%s  -  Feels like: %.1f%s  -  Wind speed: %.1f %s",
               temp_c, tunit.c_str(),
               feels_c, tunit.c_str(),
               wind_ms, wunit.c_str());
      return std::string(buf);
    on_value:
      - lambda: |-
          id(weather_available) = (x != "Weather unavailable");
      - if:
          condition:
            and:
              - lvgl.page.is_showing: idle_page
              - lambda: return id(weather_available);
          then:
            - lvgl.widget.show: weather_header
            - lvgl.widget.show: weather_label
            - lvgl.label.update:
                id: weather_label
                text: !lambda return x;
          else:
            - if:
                condition:
                  lvgl.page.is_showing: idle_page
                then:
                  - lvgl.widget.hide: weather_header
                  - lvgl.widget.hide: weather_label

font:
  - file:
      type: gfonts
      family: Noto Sans
      weight: 300
    id: font_display
    size: 18
    glyphsets:
      - GF_Latin_Core
      - GF_Cyrillic_Core

  - file:
      type: gfonts
      family: Figtree
      weight: 300
    id: font_date
    size: 22
    glyphsets:
      - GF_Latin_Core

  - file:
      type: gfonts
      family: Figtree
      weight: 600
    id: font_boot
    size: 34
    glyphsets:
      - GF_Latin_Core

  - file:
      type: gfonts
      family: Figtree
      weight: 700
    id: font_temp_big
    size: 58
    glyphs: "0123456789-"

  - file:
      type: gfonts
      family: Figtree
      weight: 700
    id: font_temp_unit
    size: 22
    glyphs: "°CF"

  - file: https://raw.githubusercontent.com/rbtdev/spiroplot/master/fonts/DSEG7/Classic-MINI/DSEG7ClassicMini-Bold.ttf
    id: font_7seg
    size: 42
    glyphs: "0123456789:- "

  - file: https://github.com/Templarian/MaterialDesign-Webfont/raw/refs/heads/master/fonts/materialdesignicons-webfont.ttf
    id: font_mdi_weather
    size: 56
    glyphs:
      - "\U000F0590"
      - "\U000F0592"
      - "\U000F0593"
      - "\U000F0595"
      - "\U000F0596"
      - "\U000F0597"
      - "\U000F0598"
      - "\U000F0599"

script:
  - id: album_art_timeout_fallback
    mode: restart
    then:
      - delay: 2000ms
      - if:
          condition:
            lambda: 'return !id(sendspin_album_art_valid);'
          then:
            - lvgl.image.update:
                id: incoming_art
                src: fallback_playing_image
            - lvgl.animation.start: album_art_crossfade

  - id: mp_idle_debounce
    mode: restart
    then:
      - delay: 1200ms
      - if:
          condition:
            and:
              - media_player.is_idle: external_media_player
          then:
            - lvgl.page.show: idle_page

  - id: play_sound
    parameters:
      priority: bool
      sound_file: string
    then:
      - if:
          condition:
            lambda: 'return sound_file == "ready_sound";'
          then:
            - lambda: 'id(boot_announce_active) = true;'
            - delay: 2s
            - lvgl.page.show: boot_sound_page
            - lambda: 'id(boot_announce_active) = false;'

      - if:
          condition:
            lambda: return priority;
          then:
            - media_player.stop:
                id: external_media_player
                announcement: true

      - lambda: |-
          if ((id(external_media_player).state != media_player::MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING) || priority) {
            id(external_media_player)
              ->make_call()
              .set_media_url("audio-file://" + sound_file)
              .set_announcement(true)
              .perform();
          }

switch:
  - platform: template
    id: startup_sound_switch
    name: Startup sound
    icon: "mdi:card-text-outline"
    entity_category: config
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON

output:
  - platform: ledc
    pin: GPIO11
    id: backlight_output
    inverted: false

light:
  - platform: monochromatic
    id: Sled
    name: Screen
    icon: "mdi:television"
    entity_category: config
    output: backlight_output
    restore_mode: RESTORE_DEFAULT_ON
    default_transition_length: 250ms

spi:
  - id: spi_bus
    clk_pin: 7
    mosi_pin: 8

display:
  - platform: mipi_spi
    id: main_display
    spi_id: spi_bus
    model: ST7789V
    data_rate: 40MHz
    spi_mode: MODE3
    dc_pin: 10
    reset_pin: 9
    dimensions:
      width: 240
      height: 240
      offset_width: ${display_offset_width}
      offset_height: ${display_offset_height}
    transform:
      swap_xy: ${display_swap_xy}
      mirror_x: ${display_mirror_x}
      mirror_y: ${display_mirror_y}
    invert_colors: true
    auto_clear_enabled: false
    update_interval: never

lvgl:
  displays:
    - main_display
  buffer_size: 12%
  color_depth: 16
  bg_color: 0x000000
  default_font: font_display

  animations:
    - id: album_art_crossfade
      duration: 1500ms
      widgets:
        - id: outgoing_art
          opa:
            from: 100%
            to: 0%
      on_stop:
        - sendspin.image.transition_finished: album_slot

  pages:
    - id: boot_sound_page
      bg_color: 0x000000
      widgets:
        - label:
            align: CENTER
            text: "Home Connected"
            text_color: 0xFFFFFF
            text_font: font_boot
            width: 230
            long_mode: WRAP
            text_align: CENTER

    - id: idle_page
      bg_color: 0x000000
      widgets:
        - obj:
            id: idle_center_block
            align: CENTER
            width: 240
            height: 230
            bg_opa: TRANSP
            border_opa: TRANSP
            pad_all: 0
            scrollable: false
            widgets:
              - label:
                  id: clock_label
                  align: TOP_MID
                  y: 6
                  width: 240
                  text: "--:--:--"
                  text_color: 0xFFFF00
                  text_font: font_7seg
                  text_letter_space: 0
                  text_align: CENTER

              - label:
                  id: date_label
                  align: TOP_MID
                  y: 58
                  width: 240
                  text: "------"
                  text_color: 0xFFFFFF
                  text_font: font_date
                  text_align: CENTER

              - obj:
                  id: weather_header
                  align: TOP_MID
                  y: 90
                  width: 240
                  height: 70
                  bg_opa: TRANSP
                  border_opa: TRANSP
                  pad_all: 0
                  scrollable: false
                  widgets:
                    - label:
                        id: weather_icon
                        align: CENTER
                        x: -37
                        y: 0
                        text: "\U000F0590"
                        text_color: 0xFFFFFF
                        text_font: font_mdi_weather

                    - label:
                        id: weather_big_temp
                        align: CENTER
                        x: 36
                        y: 2
                        text: "--"
                        text_color: 0xFFFF00
                        text_font: font_temp_big

                    - label:
                        id: weather_big_temp_unit
                        align: CENTER
                        x: 76
                        y: -14
                        text: "∞C"
                        text_color: 0xFFFF00
                        text_font: font_temp_unit

              - label:
                  id: weather_label
                  align: TOP_MID
                  x: 0
                  y: 167
                  width: 220
                  text: "Weather..."
                  text_color: 0x66CCFF
                  text_font: font_display
                  long_mode: SCROLL_CIRCULAR
                  text_align: CENTER

    - id: sendspin_page
      bg_color: 0x000000
      widgets:
        - image:
            id: incoming_art
            align: TOP_MID
            x: 0
            y: 0
            src: fallback_playing_image
            width: 216
            height: 216
            antialias: false
            clickable: false

        - image:
            id: outgoing_art
            align: TOP_MID
            x: 0
            y: 0
            src: fallback_playing_image
            width: 216
            height: 216
            antialias: false
            clickable: false

        - label:
            id: now_playing_label
            align: BOTTOM_MID
            y: -3
            width: 232
            text: ""
            text_color: 0xFFFFFF
            long_mode: SCROLL_CIRCULAR
            text_align: CENTER
