Guition_Silver_Puck_v2-modular v2026.10.04.57

Install Firmware

Connect your device via USB and click the button below to install.

Browser not supported.
ESP Web Tools requires a Chromium-based browser (Chrome, Edge) with Web Serial support. Please use one of these browsers, or follow the manual installation instructions below.
Browser-based installation requires HTTPS.
This page is not served over a secure connection, so the install button won't work. Use web.esphome.io to install the firmware instead.
Firmware Download
Configuration Download bundle
substitutions:
  name: silver-puck-v2
  friendly_name: Silver Puck v2

# User settings
  startup_sound_file: "Home_Connected" # (options are: available, Home_Connected, Home_Connected_Male, Computer_Ready)
  wakeup_sound: "popup" # (options are: popup, wake_word_triggered)
  imagemodel1: "Gwen_Black" # options are: below
  imagemodel2: "Alfred" # options are below

# AlbertQuackmore, Alita, Alfred, Astrobot, Buzz, Casita, Cybergirl, Dory, EVE, Eyes, Eyes2, GLaDOS, Girl1, Guy1, Guy2, Gwen, Gwen_Black, 
# HA-character, ha-girl, Hacker, Harley, Jarvis, Luffy, Mario, Max, Prime, Robochibi, Robocop, Robot, Robotgirl, Salem, Sarah, Shaun

  display_model: "JC3636W518V2" # JC3636W518 for v1, JC3636W518V2 for v2
  pdm_mic_type: "true" # false for v1, true for v2

# presence and light sensors
  sensor1_entity: "binary_sensor.room_1_presence"
  light1_entity: "binary_sensor.room_1_light"
  sensor1_label: "Room 1"

  sensor2_entity: "binary_sensor.room_2_presence"
  light2_entity: "binary_sensor.room_2_light"
  sensor2_label: "Room 2"

  sensor3_entity: "binary_sensor.room_3_presence"
  light3_entity: "binary_sensor.room_3_light"
  sensor3_label: "Room 3"

  sensor4_entity: "binary_sensor.room_4_presence"
  light4_entity: "binary_sensor.room_4_light"
  sensor4_label: "Room 4"

  sensor5_entity: "binary_sensor.room_5_presence"
  light5_entity: "binary_sensor.room_5_light"
  sensor5_label: "Room 5"

  sensor6_entity: "binary_sensor.room_6_presence"
  light6_entity: "binary_sensor.room_6_light"
  sensor6_label: "Room 6"

# pulldown control menu buttons
  pulldown1_entity: "light.ceiling"
  pulldown2_entity: "light.ceiling_2"
  pulldown3_entity: "light.hue_p45_7_light"
  pulldown4_entity: "light.color_light"

# display and touch rotation (defaults to 180)
  screen_swap_xy: false # 90=true, 180=false, 270=true
  screen_mirror_x: true # 90=true, 180=true, 270=false
  screen_mirror_y: true # 90=false, 180=true, 270=true

  touch_swap_xy: false # 90=true, 180=false, 270=true
  touch_mirror_x: true # 90=false 180=true, 270=true
  touch_mirror_y: true # 90=true, 180=true, 270=false
  
# Home Assistant weather entity.
  ha_weather_entity: weather.forecast_home

  imagewidth:  "360"
  imageheight: "360"

  screenwidth:  "360"
  screenheight: "360"

  media_sample_rate: 48000

  album_fade_time: "1500ms"

  ui_phase_standby: "0"
  ui_phase_player_controls: "1"
  ui_phase_now_playing: "2"
  ui_phase_playlist_menu: "3"
  ui_phase_pulldown: "4"
  ui_phase_pullup: "5"
  va_phase_idle: "0"
  va_phase_waiting_for_command: "1"
  va_phase_listening: "2"
  va_phase_thinking: "3"
  va_phase_replying: "4"
  va_phase_error: "5"
  va_phase_timer_ringing: "6"
  clock_style_digital: "0"
  clock_style_analog: "1"
  clock_style_rooms: "2"
  clock_style_retro: "3"
  clock_style_mario: "4"

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

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

esp32:
  variant: esp32s3
  flash_size: 16MB
  framework:
    type: esp-idf
#    advanced:
#      execute_from_psram: True
    sdkconfig_options:
#      CONFIG_SPIRAM_RODATA: "y"
      CONFIG_SPIRAM_USE_MALLOC: "y"
      CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL: "2048"

psram:
  mode: octal
  speed: 80MHz
  ignore_not_found: false

esphome:
  name: ${name}
  friendly_name: ${friendly_name}
  min_version: 2026.9.1
  name_add_mac_suffix: true
  project:
    name: realdeco.sendspin_guition
    version: "2026.9.1"
  on_boot:
    priority: -100
    then:
      - script.execute:
          id: set_ui_phase
          phase: ${ui_phase_standby}
      - script.execute:
          id: set_va_phase
          phase: ${va_phase_idle}
      - script.execute: refresh_playlist_labels
      - script.execute: refresh_pulldown_labels
      - script.execute: apply_accent_color_debounced
      - script.execute: led_ring_apply_state
      - lambda: 'id(timer_seconds_remaining).publish_state(0);'
      - script.execute:
          id: apply_clock_style
          style_mode: !lambda |-
            std::string opt = id(clock_style_choice).current_option();
            if (opt == "Analog Clock") return ${clock_style_analog};
            if (opt == "Rooms") return ${clock_style_rooms};
            if (opt == "Retro") return ${clock_style_retro};
            if (opt == "Mario") return ${clock_style_mario};
            return ${clock_style_digital};

logger:
  level: WARN

api:
  on_client_connected:
    - if:
        condition:
          lambda: 'return !id(boot_sound_played) && !id(boot_announce_active);'
        then:
          - lambda: 'id(boot_announce_active) = true;'
          - lvgl.label.update:
              id: boot_sound_label
              text: "Home Connected"
          - if:
              condition:
                switch.is_on: startup_sound_switch
              then:
                - lambda: 'id(play_sound)->execute(true, std::string("ready_sound"));'
                - delay: 3500ms
              else:
                - delay: 2s
          - lambda: |-
              id(boot_sound_played) = true;
              id(boot_announce_active) = false;
          - script.execute:
              id: set_ui_phase
              phase: !lambda 'return id(ui_phase);'
          - script.execute:
              id: apply_clock_style
              style_mode: !lambda 'return id(clock_style_mode);'
    - delay: 1s
    - script.execute: rooms_grid_apply
    - script.execute: refresh_pulldown_states

ota:
  - platform: esphome

wifi:
  on_connect:
    - delay: 1s
    - if:
        condition:
          switch.is_on: standby_background_enabled
        then:
          - script.execute: load_standby_background
  ap:

improv_serial:

captive_portal:

time:
  - platform: homeassistant
    id: homeassistant_time
    on_time_sync:
      - component.update: clock_hms
      - component.update: clock_date

interval:
  - interval: 1s
    then:
      - script.execute: refresh_now_playing_progress_bar
      - script.execute: analog_clock_redraw
      - script.execute: retro_clock_redraw
      - script.execute: mario_clock_tick

globals:
  - id: sendspin_album_art_valid
    type: bool
    restore_value: no
    initial_value: "false"
  - id: current_album_name
    type: std::string
    restore_value: no
    initial_value: '""'
  - id: previous_album_name
    type: std::string
    restore_value: no
    initial_value: '""'
  - id: confirmed_art_album_name
    type: std::string
    restore_value: no
    initial_value: '""'
  - id: last_album_art_valid
    type: bool
    restore_value: false
    initial_value: 'false'
  - id: album_art_awaiting_swipe_art
    type: bool
    restore_value: false
    initial_value: 'false'
  - id: pre_previous_swipe_title
    type: std::string
    restore_value: no
    initial_value: '""'
  - id: album_art_slide_active
    type: bool
    restore_value: no
    initial_value: "false"
  - id: artist_art_valid
    type: bool
    restore_value: no
    initial_value: "false"
  - id: now_playing_art_mode
    type: bool
    restore_value: no
    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: va_reply_active
    type: bool
    restore_value: no
    initial_value: "false"
  - id: va_phase
    type: int
    restore_value: no
    initial_value: "${va_phase_idle}"
  - id: active_image_set
    type: int
    restore_value: no
    initial_value: "1"
  - id: manual_va_slide_up
    type: bool
    restore_value: no
    initial_value: "false"
  - id: manual_va_active
    type: bool
    restore_value: no
    initial_value: "false"
  - id: manual_va_resume_media
    type: bool
    restore_value: no
    initial_value: "false"
  - id: timer_is_active
    type: bool
    restore_value: no
    initial_value: "false"
  - id: timer_seconds_left
    type: int
    restore_value: no
    initial_value: "0"
  - id: timer_total_seconds
    type: int
    restore_value: no
    initial_value: "0"
  - id: weather_available
    type: bool
    restore_value: no
    initial_value: "false"
  - id: ui_phase
    type: int
    restore_value: no
    initial_value: "${ui_phase_standby}"
  - id: touch_active
    type: bool
    restore_value: no
    initial_value: "false"
  - id: touch_swipe_detected
    type: bool
    restore_value: no
    initial_value: "false"
  - id: touch_start_x
    type: int
    restore_value: no
    initial_value: "0"
  - id: touch_start_y
    type: int
    restore_value: no
    initial_value: "0"
  - id: touch_last_x
    type: int
    restore_value: no
    initial_value: "0"
  - id: touch_last_y
    type: int
    restore_value: no
    initial_value: "0"
  - id: touch_start_ms
    type: uint32_t
    restore_value: no
    initial_value: "0"
  - id: pullup_return_phase
    type: int
    restore_value: no
    initial_value: "0"
  - id: pulldown_return_phase
    type: int
    restore_value: no
    initial_value: "0"
  - id: pullup_player_target
    type: int
    restore_value: yes
    initial_value: "0"
  - id: preferred_screen_brightness
    type: float
    restore_value: yes
    initial_value: "1.0"
  - id: screen_timeout_dimmed
    type: bool
    restore_value: no
    initial_value: "false"
  - id: track_progress_anchor_ms
    type: uint32_t
    restore_value: no
    initial_value: "0"
  - id: volume_ring_overlay
    type: lv_obj_t*
    restore_value: no
    initial_value: "nullptr"
  - id: volume_ring_label
    type: lv_obj_t*
    restore_value: no
    initial_value: "nullptr"
  - id: volume_percent_label
    type: lv_obj_t*
    restore_value: no
    initial_value: "nullptr"
  - id: timer_progress_arc
    type: lv_obj_t*
    restore_value: no
    initial_value: "nullptr"
  - id: analog_clock_canvas_widget
    type: lv_obj_t*
    restore_value: no
    initial_value: "nullptr"
  - id: retro_static_buf
    type: uint8_t*
    restore_value: no
    initial_value: "nullptr"
  - id: clock_style_mode
    type: int
    restore_value: no
    initial_value: "${clock_style_digital}"
  - id: accent_red
    type: int
    restore_value: yes
    initial_value: "255"
  - id: accent_green
    type: int
    restore_value: yes
    initial_value: "255"
  - id: accent_blue
    type: int
    restore_value: yes
    initial_value: "0"
  - id: accent2_red
    type: int
    restore_value: yes
    initial_value: "255"
  - id: accent2_green
    type: int
    restore_value: yes
    initial_value: "255"
  - id: accent2_blue
    type: int
    restore_value: yes
    initial_value: "255"
  - id: accent3_red
    type: int
    restore_value: yes
    initial_value: "102"
  - id: accent3_green
    type: int
    restore_value: yes
    initial_value: "204"
  - id: accent3_blue
    type: int
    restore_value: yes
    initial_value: "255"
  - id: accent4_red
    type: int
    restore_value: yes
    initial_value: "255"
  - id: accent4_green
    type: int
    restore_value: yes
    initial_value: "255"
  - id: accent4_blue
    type: int
    restore_value: yes
    initial_value: "255"
  - id: shuffle_state
    type: bool
    restore_value: yes
    initial_value: "false"
  - id: repeat_mode
    type: int
    restore_value: yes
    initial_value: "0"
  - id: mario_anim_active
    type: bool
    restore_value: no
    initial_value: "false"
  - id: mario_y_offset
    type: int
    restore_value: no
    initial_value: "0"
  - id: mario_direction_up
    type: bool
    restore_value: no
    initial_value: "true"
  - id: mario_last_minute
    type: int
    restore_value: no
    initial_value: "-1"
  - id: hourblock_offset
    type: int
    restore_value: no
    initial_value: "0"
  - id: hourblock_bouncing
    type: bool
    restore_value: no
    initial_value: "false"
  - id: minuteblock_offset
    type: int
    restore_value: no
    initial_value: "0"
  - id: minuteblock_bouncing
    type: bool
    restore_value: no
    initial_value: "false"
  - id: hourblock_going_up
    type: bool
    restore_value: no
    initial_value: "true"
  - id: minuteblock_going_up
    type: bool
    restore_value: no
    initial_value: "true"
  - id: mario_displayed_hour
    type: int
    restore_value: no
    initial_value: "0"
  - id: mario_displayed_minute
    type: int
    restore_value: no
    initial_value: "0"

image:
### model 1
  - platform: file
    file: https://raw.githubusercontent.com/RealDeco/xiaozhi-esphome/main/images/${imagemodel1}/360x360/playing.png
    id: fallback_playing_image
    resize: ${imagewidth}x${imageheight}
    type: RGB565
    dither: NONE

  - platform: file
    file: https://raw.githubusercontent.com/RealDeco/xiaozhi-esphome/main/images/${imagemodel1}/360x360/idle.png
    id: va_waiting_image1
    resize: ${imagewidth}x${imageheight}
    type: RGB565
    dither: NONE

  - platform: file
    file: https://raw.githubusercontent.com/RealDeco/xiaozhi-esphome/main/images/${imagemodel1}/360x360/idle.png
    id: pullup_va1_icon
    resize: 108x108
    type: RGB565
    dither: NONE

  - platform: file
    file: https://raw.githubusercontent.com/RealDeco/xiaozhi-esphome/main/images/${imagemodel1}/360x360/listening.png
    id: va_listening_image1
    resize: ${imagewidth}x${imageheight}
    type: RGB565
    dither: NONE

  - platform: file
    file: https://raw.githubusercontent.com/RealDeco/xiaozhi-esphome/main/images/${imagemodel1}/360x360/thinking.png
    id: va_thinking_image1
    resize: ${imagewidth}x${imageheight}
    type: RGB565
    dither: NONE

  - platform: file
    file: https://raw.githubusercontent.com/RealDeco/xiaozhi-esphome/main/images/${imagemodel1}/360x360/replying.png
    id: va_replying_image1
    resize: ${imagewidth}x${imageheight}
    type: RGB565
    dither: NONE

### model 2
  - platform: file
    file: https://raw.githubusercontent.com/RealDeco/xiaozhi-esphome/main/images/${imagemodel2}/360x360/idle.png
    id: va_waiting_image2
    resize: ${imagewidth}x${imageheight}
    type: RGB565
    dither: NONE

  - platform: file
    file: https://raw.githubusercontent.com/RealDeco/xiaozhi-esphome/main/images/${imagemodel2}/360x360/idle.png
    id: pullup_va2_icon
    resize: 108x108
    type: RGB565
    dither: NONE

  - platform: file
    file: https://raw.githubusercontent.com/RealDeco/xiaozhi-esphome/main/images/${imagemodel2}/360x360/listening.png
    id: va_listening_image2
    resize: ${imagewidth}x${imageheight}
    type: RGB565
    dither: NONE

  - platform: file
    file: https://raw.githubusercontent.com/RealDeco/xiaozhi-esphome/main/images/${imagemodel2}/360x360/thinking.png
    id: va_thinking_image2
    resize: ${imagewidth}x${imageheight}
    type: RGB565
    dither: NONE

  - platform: file
    file: https://raw.githubusercontent.com/RealDeco/xiaozhi-esphome/main/images/${imagemodel2}/360x360/replying.png
    id: va_replying_image2
    resize: ${imagewidth}x${imageheight}
    type: RGB565
    dither: NONE

### wallpaper

  - platform: online_image
    id: standby_background_image
    url: https://raw.githubusercontent.com/RealDeco/xiaozhi-esphome/main/images/Other/360x360/clock.png
    format: PNG
    type: RGB565
    resize: ${imagewidth}x${imageheight}
    update_interval: never
    on_download_finished:
      - if:
          condition:
            switch.is_on: standby_background_enabled
          then:
            - lvgl.image.update:
                id: standby_background_widget
                src: standby_background_image
            - lvgl.image.update:
                id: pulldown_background_widget
                src: standby_background_image
            - lvgl.image.update:
                id: pullup_background_widget
                src: standby_background_image
            - lambda: |-
                lv_obj_clear_flag(id(standby_background_widget), LV_OBJ_FLAG_HIDDEN);
                lv_obj_clear_flag(id(pulldown_background_widget), LV_OBJ_FLAG_HIDDEN);
                lv_obj_clear_flag(id(pullup_background_widget), LV_OBJ_FLAG_HIDDEN);
    
### sendspin album art

  - platform: sendspin
    id: album_slot
    format: JPEG
    type: RGB565
    resize: ${imagewidth}x${imageheight}
    display_offset: 1s
    source: ALBUM
    current_image:
      id: sendspin_album_art
    transition_image:
      id: sendspin_album_art_transition
    on_image_display:
      - lambda: |-
          id(last_album_art_valid) = id(sendspin_album_art_valid);
          id(sendspin_album_art_valid) = true;
          id(album_art_awaiting_swipe_art) = false;
          id(confirmed_art_album_name) = id(sendspin_album).get_state();
      - if:
          condition:
            lambda: 'return !id(album_art_slide_active);'
          then:
            - script.execute: album_art_crossfade_apply
          else:
            - sendspin.image.transition_finished: album_slot
      - lvgl.image.update:
          id: controls_album_art_widget
          src: sendspin_album_art
      - lvgl.image.update:
          id: playlist_album_art_widget
          src: sendspin_album_art
      - lvgl.image.update:
          id: pullup_album_art_widget
          src: sendspin_album_art
      - lvgl.image.update:
          id: pulldown_album_art_widget
          src: sendspin_album_art
    on_image_clear:
      - lambda: |-
          ESP_LOGI("album_art", "Album art cleared");
          id(last_album_art_valid) = id(sendspin_album_art_valid);
          id(sendspin_album_art_valid) = false;
      - script.execute: album_art_show_fallback
    on_image_error:
      - lambda: |-
          ESP_LOGI("album_art", "Album art error");
          id(last_album_art_valid) = id(sendspin_album_art_valid);
          id(sendspin_album_art_valid) = false;
      - script.execute: album_art_show_fallback

  - platform: sendspin
    id: artist_slot
    format: PNG
    type: RGB565
    resize: ${imagewidth}x${imageheight}
    source: artist
    current_image:
      id: artist_art
    on_image_display:
      - lambda: |-
          id(artist_art_valid) = true;
          id(refresh_artist_art_dot)->execute();
    on_image_clear:
      - lambda: |-
          ESP_LOGI("artist_art", "No artist art for this song");
          id(artist_art_valid) = false;
          id(refresh_artist_art_dot)->execute();
    on_image_error:
      - lambda: |-
          ESP_LOGI("artist_art", "failed to decode artist art");
          id(artist_art_valid) = false;
          id(refresh_artist_art_dot)->execute();

sendspin:
  id: sendspin_hub
  task_stack_in_psram: true

audio_file:
  - id: timer_finished_sound
    file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/timer_finished.flac
  - id: ready_sound
    file: https://github.com/RealDeco/xiaozhi-esphome/raw/main/sounds/${startup_sound_file}.flac
  - id: wake_word_sound
    file: https://github.com/RealDeco/xiaozhi-esphome/raw/main/sounds/${wakeup_sound}.flac

http_request:

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

spi:
  id: display_qspi
  type: quad
  clk_pin: GPIO9
  data_pins: [GPIO11, GPIO12, GPIO13, GPIO14]

i2c:
  id: touchscreen_bus
  sda: GPIO7
  scl: GPIO8

i2s_audio:
  - id: i2s_mic
    i2s_lrclk_pin:
      number: GPIO45
      ignore_strapping_warning: true
    i2s_bclk_pin: GPIO42

  - id: i2s_audio_bus
    i2s_lrclk_pin: GPIO16
    i2s_bclk_pin: GPIO18
    i2s_mclk_pin: GPIO21

microphone:
  - platform: i2s_audio
    id: box_mic
    i2s_audio_id: i2s_mic
    i2s_din_pin:
      number: GPIO46
      ignore_strapping_warning: true
    adc_type: external
    channel: left
    pdm: ${pdm_mic_type}
    sample_rate: 16000
    bits_per_sample: 16bit

speaker:
  - platform: i2s_audio
    id: box_speaker
    i2s_audio_id: i2s_audio_bus
    i2s_dout_pin: GPIO17
    dac_type: external
    channel: stereo
    buffer_duration: 300ms

  - platform: mixer
    id: mixing_speaker
    output_speaker: box_speaker
    num_channels: 2
    task_stack_in_psram: true
    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

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
    on_idle:
      - logger.log:
          format: "sendspin_group_player -> IDLE"
          level: INFO
      - if:
          condition:
            and:
              - not:
                  voice_assistant.is_running:
              - lambda: 'return id(ui_phase) != ${ui_phase_player_controls};'
              - lambda: 'return !id(manual_va_active);'
          then:
            - script.execute:
                id: set_ui_phase
                phase: ${ui_phase_standby}
    on_state:
      - logger.log:
          format: "sendspin_group_player state changed"
          level: INFO
    on_play:
      - logger.log:
          format: "sendspin_group_player -> PLAYING"
          level: INFO
    
  - 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:
        - if:
            condition:
              and:
                - lambda: 'return id(va_phase) == ${va_phase_idle};'
                - lambda: 'return !id(va_reply_active);'
                - not:
                    media_player.is_announcing:
                      id: external_media_player
            then:
              - script.execute: stop_wake_word
        - script.execute: led_ring_apply_state
        - if:
            condition:
              lambda: 'return id(va_phase) == ${va_phase_idle};'
            then:
              - if:
                  condition:
                    lambda: 'return id(ui_phase) != ${ui_phase_player_controls};'
                  then:
                    - script.execute:
                        id: set_ui_phase
                        phase: ${ui_phase_now_playing}

    on_idle:
      then:
        - if:
            condition:
              lambda: 'return id(va_reply_active);'
            then:
              - lambda: |-
                  id(va_reply_active) = false;
              - script.execute: finish_va_reply
            else:
              - if:
                  condition:
                    and:
                      - lambda: 'return id(va_phase) == ${va_phase_idle};'
                      - not:
                          media_player.is_announcing:
                            id: external_media_player
                  then:
                    - script.execute: start_wake_word
              - script.execute: led_ring_apply_state

    on_pause:
      then:
        - script.execute: led_ring_apply_state
        - if:
            condition:
              and:
                - lambda: 'return id(va_phase) == ${va_phase_idle};'
                - lambda: 'return !id(manual_va_active);'
            then:
              - script.execute: start_wake_word
              - script.execute:
                  id: set_ui_phase
                  phase: ${ui_phase_player_controls}

touchscreen:
  - platform: cst816
    id: my_touchscreen
    interrupt_pin: GPIO41
    reset_pin: GPIO40
    display: main_display
    i2c_id: touchscreen_bus
    transform:
      swap_xy: ${touch_swap_xy}
      mirror_x: ${touch_mirror_x}
      mirror_y: ${touch_mirror_y}
    on_touch:
      then:
        - script.execute: standby_screen_activity
        - lambda: |-
            id(touch_active) = true;
            id(touch_start_x) = touch.x;
            id(touch_start_y) = touch.y;
            id(touch_last_x) = touch.x;
            id(touch_last_y) = touch.y;
            id(touch_start_ms) = millis();
            id(touch_swipe_detected) = false;
    on_update:
      then:
        - lambda: |-
            if (!id(touch_active)) return;
            for (auto &touch : touches) {
              if (touch.state >= 1 && touch.state <= 2) {
                id(touch_last_x) = touch.x;
                id(touch_last_y) = touch.y;
                const int min_dim = std::min(id(main_display).get_width(), id(main_display).get_height());
                const int swipe_min_dist = std::max(16, min_dim / 8);
                if (std::abs(id(touch_last_x) - id(touch_start_x)) >= swipe_min_dist ||
                    std::abs(id(touch_last_y) - id(touch_start_y)) >= swipe_min_dist) {
                  id(touch_swipe_detected) = true;
                  lv_obj_remove_state(id(controls_mute_button), LV_STATE_PRESSED);
                  lv_obj_remove_state(id(controls_repeat_button), LV_STATE_PRESSED);
                  lv_obj_remove_state(id(controls_toggle_button), LV_STATE_PRESSED);
                  lv_obj_remove_state(id(controls_shuffle_button), LV_STATE_PRESSED);
                  lv_obj_remove_state(id(controls_volume_down_button), LV_STATE_PRESSED);
                  lv_obj_remove_state(id(controls_volume_up_button), LV_STATE_PRESSED);
                }
                break;
              }
            }
    on_release:
      then:
        - lambda: |-
            if (!id(touch_active)) return;
            id(touch_active) = false;

            const int dx = id(touch_last_x) - id(touch_start_x);
            const int dy = id(touch_last_y) - id(touch_start_y);
            const int abs_x = std::abs(dx);
            const int abs_y = std::abs(dy);
            const uint32_t elapsed = millis() - id(touch_start_ms);
            const int min_dim = std::min(id(main_display).get_width(), id(main_display).get_height());
            const int swipe_min_dist = std::max(16, min_dim / 8);
            const int tap_max_dist = std::max(14, min_dim / 12);

            if (id(va_phase) != ${va_phase_idle}) {
              id(stop_active_va)->execute();
              return;
            }

            if (elapsed <= 1500 && (abs_x >= swipe_min_dist || abs_y >= swipe_min_dist)) {
              if (id(ui_phase) == ${ui_phase_now_playing}) {
                if (abs_x >= abs_y) {
                  if (dx < 0) {
                    id(now_playing_swipe_left)->execute();
                  } else {
                    id(now_playing_swipe_right)->execute();
                  }
                } else if (dy < 0) {
                  if (id(now_playing_art_mode)) {
                    id(now_playing_show_album_art)->execute();
                  } else {
                    id(open_pullup_menu)->execute();
                  }
                } else {
                  if (id(artist_art_valid) && !id(now_playing_art_mode)) {
                    id(now_playing_show_artist_art)->execute();
                  } else {
                    id(open_pulldown_menu)->execute();
                  }
                }
              } else if (id(ui_phase) == ${ui_phase_player_controls}) {
                if (abs_x >= abs_y) {
                  if (dx < 0) {
                    id(player_controls_swipe_next)->execute();
                  } else {
                    id(player_controls_swipe_previous)->execute();
                  }
                } else if (dy < 0) {
                  id(open_pullup_menu)->execute();
                } else {
                  id(open_playlist_menu)->execute();
                }
              } else if (id(ui_phase) == ${ui_phase_playlist_menu}) {
                if (abs_x >= abs_y) {
                  if (dx < 0) {
                    id(player_controls_swipe_next)->execute();
                  } else {
                    id(player_controls_swipe_previous)->execute();
                  }
                } else if (dy < 0) {
                  id(close_playlist_menu)->execute();
                }
              } else if (id(ui_phase) == ${ui_phase_standby}) {
                if (abs_x >= abs_y) {
                  if (dx < 0) {
                    id(clock_style_choice).make_call().select_next(true).perform();
                  } else {
                    id(clock_style_choice).make_call().select_previous(true).perform();
                  }
                } else if (dy < 0) {
                  id(open_pullup_menu)->execute();
                } else {
                  id(open_pulldown_menu)->execute();
                }
              } else if (id(ui_phase) == ${ui_phase_pulldown}) {
                if (abs_x < abs_y && (dy < 0 || dy > 0)) {
                  id(close_pulldown_menu)->execute();
                }
              } else if (id(ui_phase) == ${ui_phase_pullup}) {
                if (abs_x < abs_y && dy > 0) {
                  id(close_pullup_menu)->execute();
                } else if (abs_x < abs_y && dy < 0) {
                  id(pullup_return_phase) = ${ui_phase_standby};
                  id(close_pullup_menu)->execute();
                }
              }
              return;
            }

            if (elapsed <= 700 && abs_x <= tap_max_dist && abs_y <= tap_max_dist && id(ui_phase) != ${ui_phase_pulldown} && id(ui_phase) != ${ui_phase_pullup} && id(ui_phase) != ${ui_phase_player_controls}) {
              id(set_ui_phase)->execute(${ui_phase_player_controls});
            }

display:
  - platform: mipi_spi
    id: main_display
    model: ${display_model}
    transform:
      swap_xy: ${screen_swap_xy}
      mirror_x: ${screen_mirror_x}
      mirror_y: ${screen_mirror_y}
    auto_clear_enabled: false
    update_interval: never
    dimensions:
        height: ${screenheight}
        width: ${screenwidth}

switch:
  - platform: template
    id: sendspin_delay_adjustable
    name: "Sendspin adjustable delay"
    entity_category: config
    optimistic: true
    restore_mode: RESTORE_DEFAULT_OFF
    turn_on_action:
      - sendspin.media_source.enable_static_delay_adjustment:
    turn_off_action:
      - sendspin.media_source.disable_static_delay_adjustment:

  - platform: gpio
    name: Speaker Enable
    icon: "mdi:speaker"
    entity_category: config
    pin: GPIO48
    restore_mode: RESTORE_DEFAULT_ON

  - 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: wake_word_sound_switch
    name: Wake word sound
    icon: "mdi:ear-hearing"
    entity_category: config
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON

  - platform: template
    id: mute
    name: Mute microphone
    icon: "mdi:microphone-off"
    entity_category: config
    optimistic: true
    restore_mode: RESTORE_DEFAULT_OFF
    on_turn_off:
      - microphone.unmute:
      - script.execute: start_wake_word
    on_turn_on:
      - script.execute: stop_wake_word
      - microphone.mute:

  - platform: template
    id: timer_ringing
    optimistic: true
    internal: true
    restore_mode: ALWAYS_OFF
    on_turn_off:
      - micro_wake_word.disable_model: stop
      - script.execute:
          id: set_va_phase
          phase: ${va_phase_idle}
      - script.execute: disable_repeat
      - if:
          condition:
            media_player.is_announcing:
              id: external_media_player
          then:
            - media_player.stop:
                announcement: true
                id: external_media_player
      - script.execute: led_ring_apply_state
    on_turn_on:
      - micro_wake_word.enable_model: stop
      - script.execute:
          id: set_va_phase
          phase: ${va_phase_timer_ringing}
      - script.execute: ring_timer
      - script.execute: led_ring_apply_state
      - delay: 15min
      - switch.turn_off: timer_ringing

  - platform: template
    id: standby_screen_timeout_enabled
    name: Standby screen timeout
    icon: "mdi:monitor-off"
    entity_category: config
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON
    on_turn_on:
      - script.execute: standby_screen_activity
    on_turn_off:
      - script.execute: standby_screen_activity

  - platform: template
    id: standby_background_enabled
    name: Standby clock background
    icon: "mdi:image-outline"
    entity_category: config
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON
    on_turn_on:
      - script.execute: load_standby_background
    on_turn_off:
      - lambda: |-
          lv_obj_add_flag(id(standby_background_widget), LV_OBJ_FLAG_HIDDEN);
          lv_obj_add_flag(id(pulldown_background_widget), LV_OBJ_FLAG_HIDDEN);
          lv_obj_add_flag(id(pullup_background_widget), LV_OBJ_FLAG_HIDDEN);
      - online_image.release: standby_background_image

  - platform: template
    id: clock_12h_format
    name: 12-Hour Clock
    icon: "mdi:clock-time-four-outline"
    entity_category: config
    optimistic: true
    restore_mode: RESTORE_DEFAULT_OFF
    on_turn_on:
      - component.update: clock_hms
      - script.execute: retro_clock_redraw
      - script.execute:
          id: mario_draw_scene
          full_redraw: true
    on_turn_off:
      - component.update: clock_hms
      - script.execute: retro_clock_redraw
      - script.execute:
          id: mario_draw_scene
          full_redraw: true

  - platform: template
    id: clock_show_date
    name: Show Date
    icon: "mdi:calendar-blank-outline"
    entity_category: config
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON
    on_turn_on:
      - if:
          condition:
            and:
              - lvgl.page.is_showing: idle_page
              - lambda: 'return id(clock_style_mode) == ${clock_style_digital};'
          then:
            - lvgl.widget.show: date_label
      - script.execute: analog_clock_redraw
    on_turn_off:
      - lvgl.widget.hide: date_label
      - script.execute: analog_clock_redraw

  - platform: template
    id: clock_show_seconds
    name: Show Seconds
    icon: "mdi:timer-outline"
    entity_category: config
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON
    on_turn_on:
      - component.update: clock_hms
      - script.execute: analog_clock_redraw
      - script.execute: retro_clock_redraw
    on_turn_off:
      - component.update: clock_hms
      - script.execute: analog_clock_redraw
      - script.execute: retro_clock_redraw

  - platform: template
    id: clock_show_gfx
    name: Show GFX
    icon: "mdi:radar"
    entity_category: config
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON
    on_turn_on:
      - script.execute: analog_clock_redraw
    on_turn_off:
      - script.execute: analog_clock_redraw

  - platform: template
    id: clock_show_weather
    name: Show Weather
    icon: "mdi:weather-partly-cloudy"
    entity_category: config
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON
    on_turn_on:
      - component.update: weather_line
    on_turn_off:
      - component.update: weather_line

  - platform: template
    id: clock_show_feels_like
    name: Show Feels Like
    icon: "mdi:thermometer-lines"
    entity_category: config
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON
    on_turn_on:
      - component.update: weather_line
    on_turn_off:
      - component.update: weather_line

number:
  - platform: template
    id: player_controls_timeout_seconds
    name: Player controls timeout delay
    unit_of_measurement: "s"
    icon: "mdi:timer-outline"
    entity_category: config
    optimistic: true
    restore_value: true
    mode: box
    min_value: 1
    max_value: 60
    step: 1
    initial_value: 5
    set_action:
      - if:
          condition:
            lambda: 'return id(ui_phase) == ${ui_phase_player_controls};'
          then:
            - script.execute: player_controls_timeout

  - platform: template
    id: standby_screen_timeout_seconds
    name: Standby screen timeout delay
    unit_of_measurement: "s"
    icon: "mdi:timer-outline"
    entity_category: config
    optimistic: true
    restore_value: true
    mode: box
    min_value: 15
    max_value: 3600
    step: 5
    initial_value: 60
    set_action:
      - script.execute: standby_screen_activity

select:
  - platform: template
    id: wake_word_engine_location
    name: Wake word engine location
    icon: "mdi:account-voice"
    entity_category: config
    optimistic: true
    restore_value: true
    initial_option: On device
    options:
      - In Home Assistant
      - On device
    set_action:
      - script.execute: stop_wake_word
      - delay: 500ms
      - if:
          condition:
            switch.is_off: mute
          then:
            - script.execute: start_wake_word

  - platform: template
    id: standby_background_choice
    name: Standby clock background image
    icon: "mdi:image-multiple-outline"
    entity_category: config
    optimistic: true
    restore_value: true
    initial_option: clock
    options:
      - chip
      - clock
      - hacker
      - mission
      - neonwaves
      - redmarble
      - starrysky
      - tron
      - waterdrops
      - waves
    set_action:
      - script.execute: standby_screen_activity
      - if:
          condition:
            switch.is_on: standby_background_enabled
          then:
            - online_image.set_url:
                id: standby_background_image
                url: !lambda |-
                  return "https://raw.githubusercontent.com/RealDeco/xiaozhi-esphome/main/images/Other/360x360/" + x + ".png";

  - platform: template
    id: clock_style_choice
    name: Clock Style
    icon: "mdi:clock-outline"
    entity_category: config
    optimistic: true
    restore_value: true
    initial_option: Digital Clock
    options:
      - Digital Clock
      - Analog Clock
      - Rooms
      - Retro
      - Mario
    set_action:
      - script.execute:
          id: apply_clock_style
          style_mode: !lambda |-
            if (x == "Analog Clock") return ${clock_style_analog};
            if (x == "Rooms") return ${clock_style_rooms};
            if (x == "Retro") return ${clock_style_retro};
            if (x == "Mario") return ${clock_style_mario};
            return ${clock_style_digital};

  - platform: template
    id: accent_color_preset
    name: Accent color preset
    icon: "mdi:palette"
    entity_category: config
    optimistic: true
    restore_value: true
    initial_option: Pitch Black
    options:
      - Pitch Black
      - Burning Red
      - Cyber Blue
    set_action:
      - if:
          condition:
            lambda: return x == "Pitch Black";
          then:
            - light.turn_on:
                id: accent_color_picker
                brightness: 100%
                red: 100%
                green: 100%
                blue: 0%
            - light.turn_on:
                id: accent_color_picker_2
                brightness: 100%
                red: 100%
                green: 100%
                blue: 100%
            - light.turn_on:
                id: accent_color_picker_3
                brightness: 100%
                red: 40%
                green: 80%
                blue: 100%
            - light.turn_on:
                id: accent_color_picker_4
                brightness: 100%
                red: 100%
                green: 100%
                blue: 100%
      - if:
          condition:
            lambda: return x == "Burning Red";
          then:
            - light.turn_on:
                id: accent_color_picker
                brightness: 100%
                red: 100%
                green: 0%
                blue: 0%
            - light.turn_on:
                id: accent_color_picker_2
                brightness: 100%
                red: 100%
                green: 100%
                blue: 0%
            - light.turn_on:
                id: accent_color_picker_3
                brightness: 100%
                red: 100%
                green: 100%
                blue: 100%
            - light.turn_on:
                id: accent_color_picker_4
                brightness: 100%
                red: 100%
                green: 100%
                blue: 100%
      - if:
          condition:
            lambda: return x == "Cyber Blue";
          then:
            - light.turn_on:
                id: accent_color_picker
                brightness: 100%
                red: 40%
                green: 80%
                blue: 100%
            - light.turn_on:
                id: accent_color_picker_2
                brightness: 100%
                red: 0%
                green: 100%
                blue: 40%
            - light.turn_on:
                id: accent_color_picker_3
                brightness: 100%
                red: 100%
                green: 100%
                blue: 0%
            - light.turn_on:
                id: accent_color_picker_4
                brightness: 100%
                red: 100%
                green: 100%
                blue: 100%

text:
  - platform: template
    id: wake_word_1_label
    name: Wake word 1 label
    entity_category: config
    optimistic: true
    restore_value: true
    initial_value: "Okay Nabu"
    mode: text

  - platform: template
    id: wake_word_2_label
    name: Wake word 2 label
    entity_category: config
    optimistic: true
    restore_value: true
    initial_value: "Hey Jarvis"
    mode: text

  - platform: template
    id: playlist_media_player_entity
    name: Playlist media player entity
    entity_category: config
    optimistic: true
    restore_value: true
    initial_value: ""
    mode: text
  - platform: template
    id: playlist_1_name
    name: Playlist 1 name
    entity_category: config
    optimistic: true
    restore_value: true
    initial_value: My playlist 1
    mode: text
    on_value:
      - script.execute: refresh_playlist_labels
  - platform: template
    id: playlist_1_uri
    name: Playlist 1 URI
    entity_category: config
    optimistic: true
    restore_value: true
    initial_value: ""
    mode: text
  - platform: template
    id: playlist_2_name
    name: Playlist 2 name
    entity_category: config
    optimistic: true
    restore_value: true
    initial_value: My playlist 2
    mode: text
    on_value:
      - script.execute: refresh_playlist_labels
  - platform: template
    id: playlist_2_uri
    name: Playlist 2 URI
    entity_category: config
    optimistic: true
    restore_value: true
    initial_value: ""
    mode: text
  - platform: template
    id: playlist_3_name
    name: Playlist 3 name
    entity_category: config
    optimistic: true
    restore_value: true
    initial_value: My playlist 3
    mode: text
    on_value:
      - script.execute: refresh_playlist_labels
  - platform: template
    id: playlist_3_uri
    name: Playlist 3 URI
    entity_category: config
    optimistic: true
    restore_value: true
    initial_value: ""
    mode: text
  - platform: template
    id: playlist_4_name
    name: Playlist 4 name
    entity_category: config
    optimistic: true
    restore_value: true
    initial_value: My playlist 4
    mode: text
    on_value:
      - script.execute: refresh_playlist_labels
  - platform: template
    id: playlist_4_uri
    name: Playlist 4 URI
    entity_category: config
    optimistic: true
    restore_value: true
    initial_value: ""
    mode: text
  - platform: template
    id: playlist_5_name
    name: Playlist 5 name
    entity_category: config
    optimistic: true
    restore_value: true
    initial_value: My playlist 5
    mode: text
    on_value:
      - script.execute: refresh_playlist_labels
  - platform: template
    id: playlist_5_uri
    name: Playlist 5 URI
    entity_category: config
    optimistic: true
    restore_value: true
    initial_value: ""
    mode: text
  - platform: template
    id: playlist_6_name
    name: Playlist 6 name
    entity_category: config
    optimistic: true
    restore_value: true
    initial_value: My playlist 6
    mode: text
    on_value:
      - script.execute: refresh_playlist_labels
  - platform: template
    id: playlist_6_uri
    name: Playlist 6 URI
    entity_category: config
    optimistic: true
    restore_value: true
    initial_value: ""
    mode: text
  - platform: template
    id: pulldown1_label
    name: Pulldown button 1 label
    entity_category: config
    optimistic: true
    restore_value: true
    initial_value: Light 1
    mode: text
    on_value:
      - script.execute: refresh_pulldown_labels
  - platform: template
    id: pulldown2_label
    name: Pulldown button 2 label
    entity_category: config
    optimistic: true
    restore_value: true
    initial_value: Light 2
    mode: text
    on_value:
      - script.execute: refresh_pulldown_labels
  - platform: template
    id: pulldown3_label
    name: Pulldown button 3 label
    entity_category: config
    optimistic: true
    restore_value: true
    initial_value: Light 3
    mode: text
    on_value:
      - script.execute: refresh_pulldown_labels
  - platform: template
    id: pulldown4_label
    name: Pulldown button 4 label
    entity_category: config
    optimistic: true
    restore_value: true
    initial_value: Light 4
    mode: text
    on_value:
      - script.execute: refresh_pulldown_labels

micro_wake_word:
  id: mww
  stop_after_detection: false
  task_stack_in_psram: true
  models:
    - model: okay_nabu
      id: okay_nabu
    - model: hey_jarvis
      id: hey_jarvis
#    - model: https://raw.githubusercontent.com/TaterTotterson/microWakeWords/refs/heads/main/microWakeWordsV3/hey_gwen.json
#      id: hey_gwen
#    - model: https://raw.githubusercontent.com/TaterTotterson/microWakeWords/refs/heads/main/microWakeWordsV3/hey_alfred.json
#      id: hey_alfred

### other wakewords to use at own risk.

#    - alexa
#      id: alexa
#    - hey_mycroft
#      id: hey_mycroft
#    - model: https://raw.githubusercontent.com/RealDeco/xiaozhi-esphome/refs/heads/main/MicroWakeWords/models/v2/hey_sarah.json
#      id: hey_sarah
#    - model: https://raw.githubusercontent.com/JohnnyPrimus/Custom_V2_MicroWakeWords/refs/heads/main/models/computer/computer.json
#      id: computer
#    - model: https://raw.githubusercontent.com/JohnnyPrimus/Custom_V2_MicroWakeWords/refs/heads/main/models/hey_friday/hey_friday.json
#      id: hey_friday
#    - model: https://github.com/kahrendt/microWakeWord/releases/download/okay_nabu_20241226.3/okay_nabu.json
#      id: okay_nabu
#    - model: https://raw.githubusercontent.com/formatBCE/Respeaker-Lite-ESPHome-integration/refs/heads/main/microwakeword/models/v2/kenobi.json
#      id: kenobi
#    - model: https://raw.githubusercontent.com/esphome/micro-wake-word-models/refs/heads/main/models/v2/experiments/choo_choo_homie.json
#      id: choo_choo_homie
#    - model: https://raw.githubusercontent.com/esphome/micro-wake-word-models/refs/heads/main/models/v2/experiments/hey_home_assistant.json
#      id: hey_home_assistant
#    - model: https://raw.githubusercontent.com/esphome/micro-wake-word-models/refs/heads/main/models/v2/experiments/hey_peppa_pig.json
#      id: hey_peppa_pig
#    - model: https://raw.githubusercontent.com/esphome/micro-wake-word-models/refs/heads/main/models/v2/experiments/okay_computer.json
#      id: okay_computer

    - model: https://github.com/kahrendt/microWakeWord/releases/download/stop/stop.json
      id: stop
      internal: true
  on_wake_word_detected:
    - lambda: |-
        ESP_LOGW("va", "micro wake word detected: %s", wake_word.c_str());
        if (wake_word == id(wake_word_1_label).state) {
          id(active_image_set) = 1;
        } else if (wake_word == id(wake_word_2_label).state) {
          id(active_image_set) = 2;
        } else {
          id(active_image_set) = 1;
          ESP_LOGW("va", "wake word did not match configured labels, falling back to image set 1");
        }
        ESP_LOGW("va", "using image set %d for wake word: %s", id(active_image_set), wake_word.c_str());
    - if:
        condition:
          switch.is_on: wake_word_sound_switch
        then:
          - lambda: 'id(play_sound)->execute(false, std::string("wake_word_sound"));'
    - if:
        condition:
          switch.is_off: mute
        then:
          - if:
              condition:
                switch.is_on: timer_ringing
              then:
                - switch.turn_off: timer_ringing
              else:
                - if:
                    condition:
                      voice_assistant.is_running:
                    then:
                      - voice_assistant.stop:
                    else:
                      - if:
                          condition:
                            media_player.is_announcing:
                              id: external_media_player
                          then:
                            - media_player.stop:
                                announcement: true
                                id: external_media_player
                          else:
                            - voice_assistant.start:
                                wake_word: !lambda return wake_word;

voice_assistant:
  id: va
  microphone: box_mic
  media_player: external_media_player
  micro_wake_word: mww
  noise_suppression_level: 2
  auto_gain: 31dBFS
  volume_multiplier: 2.0
  on_start:
    - lambda: |-
        ESP_LOGW("va", "assist pipeline started");
    - text_sensor.template.publish:
        id: text_request
        state: "..."
    - text_sensor.template.publish:
        id: text_response
        state: "..."
    - script.execute:
        id: set_va_phase
        phase: ${va_phase_waiting_for_command}
  on_listening:
    - lambda: |-
        ESP_LOGW("va", "listening");
    - script.execute:
        id: set_va_phase
        phase: ${va_phase_waiting_for_command}
  on_stt_vad_start:
    - lambda: |-
        ESP_LOGW("va", "speech detected");
    - script.execute:
        id: set_va_phase
        phase: ${va_phase_listening}
  on_stt_vad_end:
    - lambda: |-
        ESP_LOGW("va", "speech ended, sending to STT");
    - script.execute:
        id: set_va_phase
        phase: ${va_phase_thinking}
  on_stt_end:
    - lambda: |-
        ESP_LOGW("va", "STT result: %s", x.c_str());
    - text_sensor.template.publish:
        id: text_request
        state: !lambda return x;
  on_tts_start:
    - lambda: |-
        ESP_LOGW("va", "TTS text: %s", x.c_str());
    - text_sensor.template.publish:
        id: text_response
        state: !lambda return x;
    - script.execute:
        id: set_va_phase
        phase: ${va_phase_replying}
    - script.execute: activate_stop_word_once
  on_tts_end:
    - lambda: |-
        ESP_LOGW("va", "TTS URL: %s", x.c_str());
    - script.execute:
        id: set_va_phase
        phase: ${va_phase_replying}
    - lambda: |-
        id(va_reply_active) = true;
  on_end:
    - lambda: |-
        ESP_LOGW("va", "assist pipeline ended");
    - if:
        condition:
          lambda: return !id(va_reply_active);
        then:
          - script.execute: finish_va_reply
  on_idle:
    - lambda: |-
        ESP_LOGW("va", "idle");
  on_error:
    - lambda: |-
        ESP_LOGW("va", "error: %s - %s", code.c_str(), message.c_str());
    - script.execute:
        id: set_va_phase
        phase: ${va_phase_error}
    - delay: 3s
    - if:
        condition:
          lambda: return id(va_phase) == ${va_phase_error};
        then:
          - script.execute:
              id: set_va_phase
              phase: ${va_phase_idle}
          - if:
              condition:
                switch.is_off: mute
              then:
                - script.execute: start_wake_word
  on_client_connected:
    - script.execute: start_wake_word
  on_client_disconnected:
    - script.execute: stop_wake_word
  on_timer_started:
    - delay: 250ms
    - script.execute: refresh_timer_display
    - script.execute: standby_screen_activity
  on_timer_updated:
    - script.execute: refresh_timer_display
    - script.execute: standby_screen_activity
  on_timer_tick:
    - script.execute: refresh_timer_display
    - script.execute: standby_screen_activity
  on_timer_cancelled:
    - script.execute: refresh_timer_display
    - script.execute: standby_screen_activity
  on_timer_finished:
    - script.execute: refresh_timer_display
    - switch.turn_on: timer_ringing

debug:

binary_sensor:
  - platform: homeassistant
    id: room1_presence
    entity_id: ${sensor1_entity}
    on_state:
      - script.execute: rooms_grid_apply
  - platform: homeassistant
    id: room1_light
    entity_id: ${light1_entity}
    on_state:
      - script.execute: rooms_grid_apply

  - platform: homeassistant
    id: room2_presence
    entity_id: ${sensor2_entity}
    on_state:
      - script.execute: rooms_grid_apply
  - platform: homeassistant
    id: room2_light
    entity_id: ${light2_entity}
    on_state:
      - script.execute: rooms_grid_apply

  - platform: homeassistant
    id: room3_presence
    entity_id: ${sensor3_entity}
    on_state:
      - script.execute: rooms_grid_apply
  - platform: homeassistant
    id: room3_light
    entity_id: ${light3_entity}
    on_state:
      - script.execute: rooms_grid_apply

  - platform: homeassistant
    id: room4_presence
    entity_id: ${sensor4_entity}
    on_state:
      - script.execute: rooms_grid_apply
  - platform: homeassistant
    id: room4_light
    entity_id: ${light4_entity}
    on_state:
      - script.execute: rooms_grid_apply

  - platform: homeassistant
    id: room5_presence
    entity_id: ${sensor5_entity}
    on_state:
      - script.execute: rooms_grid_apply
  - platform: homeassistant
    id: room5_light
    entity_id: ${light5_entity}
    on_state:
      - script.execute: rooms_grid_apply

  - platform: homeassistant
    id: room6_presence
    entity_id: ${sensor6_entity}
    on_state:
      - script.execute: rooms_grid_apply
  - platform: homeassistant
    id: room6_light
    entity_id: ${light6_entity}
    on_state:
      - script.execute: rooms_grid_apply

  - platform: homeassistant
    id: pulldown1_state
    entity_id: ${pulldown1_entity}
    on_state:
      - script.execute: refresh_pulldown_states
  - platform: homeassistant
    id: pulldown2_state
    entity_id: ${pulldown2_entity}
    on_state:
      - script.execute: refresh_pulldown_states
  - platform: homeassistant
    id: pulldown3_state
    entity_id: ${pulldown3_entity}
    on_state:
      - script.execute: refresh_pulldown_states
  - platform: homeassistant
    id: pulldown4_state
    entity_id: ${pulldown4_entity}
    on_state:
      - script.execute: refresh_pulldown_states

sensor:
  - platform: debug
    free:
      name: "SRAM Free Memory"
    block:
      name: "SRAM Largest Contiguous Block"
    psram:
      name: "PSRAM Free Memory"

  - platform: homeassistant
    id: ha_weather_temperature
    entity_id: ${ha_weather_entity}
    attribute: temperature
    on_value:
      - component.update: weather_temp_big
      - component.update: weather_line
  - platform: homeassistant
    id: ha_weather_wind_speed
    entity_id: ${ha_weather_entity}
    attribute: wind_speed
    on_value:
      - component.update: weather_line
  - platform: sendspin
    id: sendspin_track_progress
    type: track_progress
    on_value:
      - lambda: 'id(track_progress_anchor_ms) = millis();'
      - script.execute: refresh_now_playing_progress_bar
  - platform: sendspin
    id: sendspin_track_duration
    type: track_duration
  - platform: template
    id: timer_seconds_remaining
    name: "Timer Seconds Remaining"
    unit_of_measurement: "s"
    icon: "mdi:timer"
    state_class: measurement
    accuracy_decimals: 0

text_sensor:
  - platform: template
    id: text_request
    name: Last Voice Request
    icon: "mdi:account-voice"

  - platform: template
    id: text_response
    name: Last Voice Response
    icon: "mdi:assistant"
    on_value:
      lambda: |-
        if (x.length() > 255) {
          id(text_response).publish_state(x.substr(0, 253) + "..");
        }

  - platform: template
    id: va_phase_text
    name: Voice assistant phase
    icon: "mdi:account-voice"
    entity_category: diagnostic
    update_interval: never
    lambda: |-
      switch (id(va_phase)) {
        case ${va_phase_waiting_for_command}: return std::string("waiting_for_command");
        case ${va_phase_listening}: return std::string("listening");
        case ${va_phase_thinking}: return std::string("thinking");
        case ${va_phase_replying}: return std::string("replying");
        case ${va_phase_error}: return std::string("error");
        case ${va_phase_timer_ringing}: return std::string("timer_ringing");
        default: return std::string("idle");
      }

  - 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_glow), icon);
          lv_label_set_text(id(weather_icon), icon);
          lv_label_set_text(id(analog_weather_icon), icon);
  - platform: homeassistant
    id: ha_weather_temperature_unit
    entity_id: ${ha_weather_entity}
    attribute: temperature_unit
    on_value:
      - component.update: weather_temp_big
      - component.update: weather_line

  - platform: homeassistant
    id: ha_weather_wind_speed_unit
    entity_id: ${ha_weather_entity}
    attribute: wind_speed_unit
    on_value:
      - component.update: weather_line

  - platform: sendspin
    id: sendspin_title
    name: "Song Title"
    type: title
    on_value:
      - component.update: now_playing_text
      - lambda: |-
          id(artist_art_valid) = false;
          id(now_playing_art_mode) = false;
          id(refresh_artist_art_dot)->execute();
      - lambda: |-
          id(previous_album_name) = id(sendspin_album).get_state();
      - delay: 100ms
      - lambda: |-
          id(current_album_name) = id(sendspin_album).get_state();
      - if:
          condition:
            and:
              - lambda: 'return id(album_art_awaiting_swipe_art);'
              - lambda: 'return id(current_album_name) == id(previous_album_name);'
              - lambda: 'return id(current_album_name) == id(confirmed_art_album_name);'
          then:
            - lambda: |-
                id(sendspin_album_art_valid) = true;
                id(album_art_awaiting_swipe_art) = false;
            - script.execute: album_art_crossfade_apply
          else:
            - lambda: |-
                id(album_art_awaiting_swipe_art) = false;
      - lvgl.label.update:
          id: controls_title_label
          text: !lambda return x;

  - platform: sendspin
    id: sendspin_artist
    name: "Song Artist"
    type: artist
    on_value:
      - component.update: now_playing_text
      - lvgl.label.update:
          id: controls_artist_label
          text: !lambda return x;

  - platform: sendspin
    id: sendspin_album
    name: "Album Name"
    type: album
    on_value:
      - component.update: now_playing_text
      - lvgl.label.update:
          id: controls_album_label
          text: !lambda return x;

  - 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 show_seconds = id(clock_show_seconds).state;
      int hour_disp = t.hour;
      if (id(clock_12h_format).state) {
        hour_disp = t.hour % 12;
        if (hour_disp == 0) hour_disp = 12;
      }
      bool hide_colons = show_seconds && ((t.second % 2) == 0);
      char buf[16];
      if (show_seconds) {
        if (hide_colons) snprintf(buf, sizeof(buf), "%02d %02d %02d", hour_disp, t.minute, t.second);
        else snprintf(buf, sizeof(buf), "%02d:%02d:%02d", hour_disp, t.minute, t.second);
      } else {
        if (hide_colons) snprintf(buf, sizeof(buf), "%02d %02d", hour_disp, t.minute);
        else snprintf(buf, sizeof(buf), "%02d:%02d", hour_disp, t.minute);
      }
      return std::string(buf);
    on_value:
      - if:
          condition:
            and:
              - lvgl.page.is_showing: idle_page
              - lambda: 'return id(clock_style_mode) == ${clock_style_digital};'
          then:
            - lvgl.label.update:
                id: clock_label
                text: !lambda return x;
            - lambda: |-
                if (!id(clock_12h_format).state) {
                  lv_obj_add_flag(id(clock_ampm_label), LV_OBJ_FLAG_HIDDEN);
                  return;
                }
                auto t = id(homeassistant_time).now();
                lv_label_set_text(id(clock_ampm_label), (t.is_valid() && t.hour >= 12) ? "P" : "A");
                lv_point_t txt_size;
                lv_text_get_size(&txt_size, x.c_str(), id(font_7seg)->get_lv_font(), 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE);
                lv_area_t clock_area;
                lv_obj_get_coords(id(clock_label), &clock_area);
                int32_t label_cx = (clock_area.x1 + clock_area.x2) / 2;
                int32_t label_bottom = clock_area.y2;
                lv_obj_set_pos(id(clock_ampm_label), label_cx + txt_size.x / 2 + 4, label_bottom - 26);
                lv_obj_clear_flag(id(clock_ampm_label), LV_OBJ_FLAG_HIDDEN);
      - if:
          condition:
            and:
              - lvgl.page.is_showing: idle_page
              - lambda: 'return id(clock_style_mode) == ${clock_style_rooms};'
          then:
            - lvgl.label.update:
                id: rooms_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:
            and:
              - lvgl.page.is_showing: idle_page
              - lambda: 'return id(clock_style_mode) == ${clock_style_digital};'
          then:
            - lvgl.label.update:
                id: date_label
                text: !lambda return x;
      - if:
          condition:
            and:
              - lvgl.page.is_showing: idle_page
              - lambda: 'return id(clock_style_mode) == ${clock_style_rooms};'
          then:
            - lvgl.label.update:
                id: rooms_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);
      char abuf[16];
      snprintf(abuf, sizeof(abuf), "%d%s", ti, tunit.c_str());
      lv_label_set_text(id(analog_weather_temp), abuf);
      return std::string(buf);
    on_value:
      - if:
          condition:
            and:
              - lvgl.page.is_showing: idle_page
              - lambda: 'return id(clock_style_mode) == ${clock_style_digital};'
          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_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),
               "Wind speed: %.1f %s\nFeels like: %.1f%s",
               wind_ms, wunit.c_str(),
               feels_c, tunit.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(clock_style_mode) == ${clock_style_digital};'
          then:
            - if:
                condition:
                  and:
                    - lambda: return id(weather_available);
                    - lambda: return id(clock_show_weather).state;
                then:
                  - lvgl.widget.show: weather_header
                else:
                  - lvgl.widget.hide: weather_header
            - if:
                condition:
                  and:
                    - lambda: return id(weather_available);
                    - lambda: return id(clock_show_feels_like).state;
                then:
                  - lvgl.widget.show: weather_label
                  - lvgl.label.update:
                      id: weather_label
                      text: !lambda return x;
                else:
                  - lvgl.widget.hide: weather_label
          else:
            - lvgl.widget.hide: weather_header
            - lvgl.widget.hide: weather_label
      - if:
          condition:
            and:
              - lvgl.page.is_showing: idle_page
              - lambda: 'return id(clock_style_mode) == ${clock_style_analog};'
              - lambda: return id(weather_available);
              - lambda: return id(clock_show_weather).state;
          then:
            - lvgl.widget.show: analog_weather_icon
            - lvgl.widget.show: analog_weather_temp
          else:
            - lvgl.widget.hide: analog_weather_icon
            - lvgl.widget.hide: analog_weather_temp

font:
  - file:
      type: gfonts
      family: Noto Sans
      weight: 300
    id: font_display
    size: 22
    glyphsets:
      - GF_Latin_Core
      - GF_Cyrillic_Core
    glyphs: "\u00B7"

  - file:
      type: gfonts
      family: Noto Sans
      weight: 300
    id: font_display_medium
    size: 26
    glyphsets:
      - GF_Latin_Core
      - GF_Cyrillic_Core

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

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

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

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

  - file:
      type: gfonts
      family: Figtree
      weight: 700
    id: font_temp_unit
    size: 30
    glyphs: "\u00B0CF"

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

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

  - file: https://raw.githubusercontent.com/rbtdev/spiroplot/master/fonts/DSEG7/Classic-MINI/DSEG7ClassicMini-Bold.ttf
    id: font_ampm
    size: 22
    glyphs: "AP"

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

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

  - file: https://github.com/Templarian/MaterialDesign-Webfont/raw/refs/heads/master/fonts/materialdesignicons-webfont.ttf
    id: font_mdi_controls
    size: 44
    glyphs:
      - "\U000F03E3"
      - "\U000F040E"
      - "\U000F04AD"
      - "\U000F04AE"
      - "\U000F057E"
      - "\U000F075D"
      - "\U000F075E"
      - "\U000F075F"
      - "\U000F0456"
      - "\U000F0457"
      - "\U000F0458"
      - "\U000F049D"
      - "\U000F049E"

  - file: https://github.com/Templarian/MaterialDesign-Webfont/raw/refs/heads/master/fonts/materialdesignicons-webfont.ttf
    id: font_mdi_pullup
    size: 64
    glyphs:
      - "\U000F036C"
      - "\U000F036D"
      - "\U000F0004"
      - "\U000F0849"

  - file: https://github.com/Templarian/MaterialDesign-Webfont/raw/refs/heads/master/fonts/materialdesignicons-webfont.ttf
    id: font_mdi_status
    size: 140
    glyphs:
      - "\U000F0026"
      - "\U000F0020"

  - file: https://github.com/Templarian/MaterialDesign-Webfont/raw/refs/heads/master/fonts/materialdesignicons-webfont.ttf
    id: font_mdi_rooms
    size: 32
    glyphs:
      - "\U000F0004"

  - file:
      type: gfonts
      family: Silkscreen
      weight: 400
    id: font_retro_numeral
    size: 24
    glyphs: "12369"

  - file:
      type: gfonts
      family: Silkscreen
      weight: 400
    id: font_retro_small
    size: 16
    glyphs: "0123456789:RETO"

  - file:
      type: gfonts
      family: Silkscreen
      weight: 400
    id: font_mario_digits
    size: 36
    glyphs: "0123456789"

script:
  - id: led_ring_apply_state
    mode: restart
    then:
      - lambda: |-
          return;

  - id: analog_clock_ensure_created
    then:
      - lambda: |-
          if (id(analog_clock_canvas_widget) != nullptr) return;
          const int32_t SIZE = std::min(${screenwidth}, ${screenheight});
          lv_obj_t *canvas = lv_canvas_create(id(idle_page)->obj);
          lv_draw_buf_t *buf = lv_draw_buf_create(SIZE, SIZE, LV_COLOR_FORMAT_ARGB8888, LV_STRIDE_AUTO);
          lv_canvas_set_draw_buf(canvas, buf);
          lv_obj_set_style_bg_opa(canvas, LV_OPA_TRANSP, 0);
          lv_obj_center(canvas);
          lv_obj_clear_flag(canvas, LV_OBJ_FLAG_CLICKABLE);
          lv_obj_clear_flag(canvas, LV_OBJ_FLAG_SCROLLABLE);
          lv_obj_add_flag(canvas, LV_OBJ_FLAG_HIDDEN);
          id(analog_clock_canvas_widget) = canvas;

  - id: apply_clock_style
    mode: restart
    parameters:
      style_mode: int
    then:
      - lambda: |-
          id(clock_style_mode) = style_mode;
      - if:
          condition:
            lambda: 'return style_mode == ${clock_style_analog};'
          then:
            - script.execute: analog_clock_ensure_created
            - lambda: |-
                lv_obj_add_flag(id(clock_label), LV_OBJ_FLAG_HIDDEN);
                lv_obj_add_flag(id(clock_ampm_label), LV_OBJ_FLAG_HIDDEN);
                lv_obj_add_flag(id(date_label), LV_OBJ_FLAG_HIDDEN);
                lv_obj_add_flag(id(weather_header), LV_OBJ_FLAG_HIDDEN);
                lv_obj_add_flag(id(weather_label), LV_OBJ_FLAG_HIDDEN);
                lv_obj_clear_flag(id(analog_clock_canvas_widget), LV_OBJ_FLAG_HIDDEN);
                lv_obj_add_flag(id(rooms_clock_label), LV_OBJ_FLAG_HIDDEN);
                lv_obj_add_flag(id(rooms_date_label), LV_OBJ_FLAG_HIDDEN);
                for (auto box : {id(room1_box), id(room2_box), id(room3_box), id(room4_box), id(room5_box), id(room6_box)}) {
                  lv_obj_add_flag(box, LV_OBJ_FLAG_HIDDEN);
                }
            - script.execute: analog_clock_redraw
            - component.update: clock_hms
            - component.update: clock_date
            - component.update: weather_temp_big
            - component.update: weather_line
          else:
            - if:
                condition:
                  lambda: 'return style_mode == ${clock_style_rooms};'
                then:
                  - lambda: |-
                      lv_obj_add_flag(id(clock_label), LV_OBJ_FLAG_HIDDEN);
                      lv_obj_add_flag(id(clock_ampm_label), LV_OBJ_FLAG_HIDDEN);
                      lv_obj_add_flag(id(date_label), LV_OBJ_FLAG_HIDDEN);
                      lv_obj_add_flag(id(weather_header), LV_OBJ_FLAG_HIDDEN);
                      lv_obj_add_flag(id(weather_label), LV_OBJ_FLAG_HIDDEN);
                      lv_obj_add_flag(id(analog_weather_icon), LV_OBJ_FLAG_HIDDEN);
                      lv_obj_add_flag(id(analog_weather_temp), LV_OBJ_FLAG_HIDDEN);
                      if (id(analog_clock_canvas_widget) != nullptr) {
                        lv_obj_add_flag(id(analog_clock_canvas_widget), LV_OBJ_FLAG_HIDDEN);
                      }
                      lv_obj_clear_flag(id(rooms_clock_label), LV_OBJ_FLAG_HIDDEN);
                      lv_obj_clear_flag(id(rooms_date_label), LV_OBJ_FLAG_HIDDEN);
                      for (auto box : {id(room1_box), id(room2_box), id(room3_box), id(room4_box), id(room5_box), id(room6_box)}) {
                        lv_obj_clear_flag(box, LV_OBJ_FLAG_HIDDEN);
                      }
                  - script.execute: rooms_grid_apply
                  - component.update: clock_hms
                  - component.update: clock_date
                  - component.update: weather_temp_big
                  - component.update: weather_line
                else:
                  - if:
                      condition:
                        lambda: 'return style_mode == ${clock_style_retro};'
                      then:
                        - script.execute: analog_clock_ensure_created
                        - lambda: |-
                            lv_obj_add_flag(id(clock_label), LV_OBJ_FLAG_HIDDEN);
                            lv_obj_add_flag(id(clock_ampm_label), LV_OBJ_FLAG_HIDDEN);
                            lv_obj_add_flag(id(date_label), LV_OBJ_FLAG_HIDDEN);
                            lv_obj_add_flag(id(weather_header), LV_OBJ_FLAG_HIDDEN);
                            lv_obj_add_flag(id(weather_label), LV_OBJ_FLAG_HIDDEN);
                            lv_obj_add_flag(id(analog_weather_icon), LV_OBJ_FLAG_HIDDEN);
                            lv_obj_add_flag(id(analog_weather_temp), LV_OBJ_FLAG_HIDDEN);
                            lv_obj_clear_flag(id(analog_clock_canvas_widget), LV_OBJ_FLAG_HIDDEN);
                            lv_obj_add_flag(id(rooms_clock_label), LV_OBJ_FLAG_HIDDEN);
                            lv_obj_add_flag(id(rooms_date_label), LV_OBJ_FLAG_HIDDEN);
                            for (auto box : {id(room1_box), id(room2_box), id(room3_box), id(room4_box), id(room5_box), id(room6_box)}) {
                              lv_obj_add_flag(box, LV_OBJ_FLAG_HIDDEN);
                            }
                        - script.execute: retro_clock_redraw
                        - component.update: clock_hms
                        - component.update: clock_date
                        - component.update: weather_temp_big
                        - component.update: weather_line
                      else:
                        - if:
                            condition:
                              lambda: 'return style_mode == ${clock_style_mario};'
                            then:
                              - script.execute: analog_clock_ensure_created
                              - lambda: |-
                                  lv_obj_add_flag(id(clock_label), LV_OBJ_FLAG_HIDDEN);
                                  lv_obj_add_flag(id(clock_ampm_label), LV_OBJ_FLAG_HIDDEN);
                                  lv_obj_add_flag(id(date_label), LV_OBJ_FLAG_HIDDEN);
                                  lv_obj_add_flag(id(weather_header), LV_OBJ_FLAG_HIDDEN);
                                  lv_obj_add_flag(id(weather_label), LV_OBJ_FLAG_HIDDEN);
                                  lv_obj_add_flag(id(analog_weather_icon), LV_OBJ_FLAG_HIDDEN);
                                  lv_obj_add_flag(id(analog_weather_temp), LV_OBJ_FLAG_HIDDEN);
                                  lv_obj_clear_flag(id(analog_clock_canvas_widget), LV_OBJ_FLAG_HIDDEN);
                                  lv_obj_add_flag(id(rooms_clock_label), LV_OBJ_FLAG_HIDDEN);
                                  lv_obj_add_flag(id(rooms_date_label), LV_OBJ_FLAG_HIDDEN);
                                  for (auto box : {id(room1_box), id(room2_box), id(room3_box), id(room4_box), id(room5_box), id(room6_box)}) {
                                    lv_obj_add_flag(box, LV_OBJ_FLAG_HIDDEN);
                                  }
                                  id(mario_anim_active) = false;
                                  id(mario_y_offset) = 0;
                                  id(hourblock_offset) = 0;
                                  id(hourblock_bouncing) = false;
                                  id(hourblock_going_up) = true;
                                  id(minuteblock_offset) = 0;
                                  id(minuteblock_bouncing) = false;
                                  id(minuteblock_going_up) = true;
                              - script.execute:
                                  id: mario_draw_scene
                                  full_redraw: true
                              - component.update: clock_hms
                              - component.update: clock_date
                              - component.update: weather_temp_big
                              - component.update: weather_line
                            else:
                              - lambda: |-
                                  lv_obj_clear_flag(id(clock_label), LV_OBJ_FLAG_HIDDEN);
                                  if (id(clock_show_date).state) {
                                    lv_obj_clear_flag(id(date_label), LV_OBJ_FLAG_HIDDEN);
                                  } else {
                                    lv_obj_add_flag(id(date_label), LV_OBJ_FLAG_HIDDEN);
                                  }
                                  if (id(analog_clock_canvas_widget) != nullptr) {
                                    lv_obj_add_flag(id(analog_clock_canvas_widget), LV_OBJ_FLAG_HIDDEN);
                                  }
                                  lv_obj_add_flag(id(analog_weather_icon), LV_OBJ_FLAG_HIDDEN);
                                  lv_obj_add_flag(id(analog_weather_temp), LV_OBJ_FLAG_HIDDEN);
                                  lv_obj_add_flag(id(rooms_clock_label), LV_OBJ_FLAG_HIDDEN);
                                  lv_obj_add_flag(id(rooms_date_label), LV_OBJ_FLAG_HIDDEN);
                                  for (auto box : {id(room1_box), id(room2_box), id(room3_box), id(room4_box), id(room5_box), id(room6_box)}) {
                                    lv_obj_add_flag(box, LV_OBJ_FLAG_HIDDEN);
                                  }
                              - component.update: clock_hms
                              - component.update: clock_date
                              - component.update: weather_temp_big
                              - component.update: weather_line

  - id: rooms_grid_apply
    then:
      - lambda: |-
          auto apply_box = [&](lv_obj_t *box, lv_obj_t *icon, lv_color_t on_color, bool light_on, bool presence_on) {
            if (light_on) {
              lv_obj_set_style_bg_color(box, on_color, 0);
              lv_obj_set_style_bg_opa(box, LV_OPA_70, 0);
            } else {
              lv_obj_set_style_bg_opa(box, LV_OPA_TRANSP, 0);
            }
            lv_obj_set_style_text_color(icon, lv_color_hex(0xFFFFFF), 0);
            if (presence_on) {
              lv_obj_clear_flag(icon, LV_OBJ_FLAG_HIDDEN);
            } else {
              lv_obj_add_flag(icon, LV_OBJ_FLAG_HIDDEN);
            }
          };
          apply_box(id(room1_box), id(room1_icon), lv_color_hex(0x4FC3F7), id(room1_light).state, id(room1_presence).state);
          apply_box(id(room2_box), id(room2_icon), lv_color_hex(0xE53935), id(room2_light).state, id(room2_presence).state);
          apply_box(id(room3_box), id(room3_icon), lv_color_hex(0xFDD835), id(room3_light).state, id(room3_presence).state);
          apply_box(id(room4_box), id(room4_icon), lv_color_hex(0xBA68C8), id(room4_light).state, id(room4_presence).state);
          apply_box(id(room5_box), id(room5_icon), lv_color_hex(0x1A237E), id(room5_light).state, id(room5_presence).state);
          apply_box(id(room6_box), id(room6_icon), lv_color_hex(0x81C784), id(room6_light).state, id(room6_presence).state);

  - id: analog_clock_redraw
    mode: restart
    then:
      - if:
          condition:
            and:
              - lvgl.page.is_showing: idle_page
              - lambda: 'return id(clock_style_mode) == ${clock_style_analog};'
              - lambda: 'return id(analog_clock_canvas_widget) != nullptr;'
          then:
            - lambda: |-
                lv_obj_t *canvas = id(analog_clock_canvas_widget);
                const int32_t SIZE = std::min(${screenwidth}, ${screenheight});
                const int32_t CX = SIZE / 2;
                const int32_t CY = SIZE / 2;
                const int32_t R = SIZE / 2 - 10;

                lv_canvas_fill_bg(canvas, lv_color_hex(0x000000), LV_OPA_TRANSP);

                lv_layer_t layer;
                lv_canvas_init_layer(canvas, &layer);

                const uint32_t accent = ((uint32_t) id(accent_red) << 16) | ((uint32_t) id(accent_green) << 8) | (uint32_t) id(accent_blue);
                const uint32_t accent2 = ((uint32_t) id(accent2_red) << 16) | ((uint32_t) id(accent2_green) << 8) | (uint32_t) id(accent2_blue);
                const uint32_t accent3 = ((uint32_t) id(accent3_red) << 16) | ((uint32_t) id(accent3_green) << 8) | (uint32_t) id(accent3_blue);
                lv_color_t fg = lv_color_hex(accent);
                lv_color_t fg2 = lv_color_hex(accent2);
                lv_color_t fg3 = lv_color_hex(accent3);
                const bool show_seconds = id(clock_show_seconds).state;

                lv_draw_line_dsc_t line_dsc;
                lv_draw_line_dsc_init(&line_dsc);
                line_dsc.round_start = true;
                line_dsc.round_end = true;

                if (id(clock_show_gfx).state) {
                  for (int i = 0; i < 60; i++) {
                    float a = (i / 60.0f) * 6.2831853f - 1.5707963f;
                    bool major = (i % 5 == 0);
                    int32_t r_out = R;
                    int32_t r_in = major ? R - 16 : R - 8;
                    line_dsc.p1.x = CX + (int32_t) (cosf(a) * r_out);
                    line_dsc.p1.y = CY + (int32_t) (sinf(a) * r_out);
                    line_dsc.p2.x = CX + (int32_t) (cosf(a) * r_in);
                    line_dsc.p2.y = CY + (int32_t) (sinf(a) * r_in);
                    line_dsc.color = major ? fg : fg2;
                    line_dsc.width = major ? 3 : 1;
                    lv_draw_line(&layer, &line_dsc);
                  }
                }

                if (id(clock_show_date).state) {
                  std::string date_str = id(clock_date).state;
                  lv_draw_label_dsc_t label_dsc;
                  lv_draw_label_dsc_init(&label_dsc);
                  label_dsc.color = fg2;
                  label_dsc.font = id(font_date)->get_lv_font();
                  label_dsc.align = LV_TEXT_ALIGN_CENTER;
                  label_dsc.text = date_str.c_str();
                  label_dsc.text_local = true;
                  const int32_t date_y = CY + (int32_t) (R * 0.55f);
                  lv_area_t label_area = {CX - R, date_y, CX + R, date_y + 40};
                  lv_draw_label(&layer, &label_dsc, &label_area);
                }

                auto t = id(homeassistant_time).now();
                int hh = t.is_valid() ? (t.hour % 12) : 0;
                int mm = t.is_valid() ? t.minute : 0;
                int ss = t.is_valid() ? t.second : 0;

                float s_pos = ss / 60.0f;
                float m_pos = (mm + s_pos) / 60.0f;
                float h_pos = (hh + m_pos) / 12.0f;

                auto draw_hand = [&](float pos, int32_t len, int32_t width, lv_color_t color) {
                  float a = pos * 6.2831853f - 1.5707963f;
                  line_dsc.p1.x = CX;
                  line_dsc.p1.y = CY;
                  line_dsc.p2.x = CX + (int32_t) (cosf(a) * len);
                  line_dsc.p2.y = CY + (int32_t) (sinf(a) * len);
                  line_dsc.color = color;
                  line_dsc.width = width;
                  lv_draw_line(&layer, &line_dsc);
                };

                draw_hand(h_pos, (int32_t) (R * 0.5f), 6, fg3);
                draw_hand(m_pos, (int32_t) (R * 0.72f), 4, fg3);
                if (show_seconds) {
                  draw_hand(s_pos, (int32_t) (R * 0.85f), 1, fg);
                }

                lv_draw_rect_dsc_t dot_dsc;
                lv_draw_rect_dsc_init(&dot_dsc);
                dot_dsc.bg_color = fg;
                dot_dsc.bg_opa = LV_OPA_COVER;
                dot_dsc.radius = LV_RADIUS_CIRCLE;
                lv_area_t dot_area = {CX - 6, CY - 6, CX + 6, CY + 6};
                lv_draw_rect(&layer, &dot_dsc, &dot_area);

                lv_canvas_finish_layer(canvas, &layer);
                lv_obj_invalidate(canvas);

  - id: retro_clock_redraw
    mode: restart
    then:
      - if:
          condition:
            and:
              - lvgl.page.is_showing: idle_page
              - lambda: 'return id(clock_style_mode) == ${clock_style_retro};'
              - lambda: 'return id(analog_clock_canvas_widget) != nullptr;'
          then:
            - lambda: |-
                lv_obj_t *canvas = id(analog_clock_canvas_widget);
                const int32_t SIZE = std::min(${screenwidth}, ${screenheight});
                const int32_t CX = SIZE / 2;
                const int32_t CY = SIZE / 2;
                const float scale = SIZE / 240.0f;
                auto S = [&](float v) -> int32_t { return (int32_t) roundf(v * scale); };

                const lv_color_t c_matte = lv_color_hex(0x080808);
                const lv_color_t c_matte_dark = lv_color_hex(0x333333);
                const lv_color_t c_charcoal = lv_color_hex(0x181818);
                const lv_color_t c_white = lv_color_hex(0xFFFFFF);
                const lv_color_t c_soft_white = lv_color_hex(0xD6D3D6);
                const lv_color_t c_dim_white = lv_color_hex(0x7B7D7B);
                const lv_color_t c_shadow = lv_color_hex(0x000000);
                const lv_color_t c_orange = lv_color_hex(0xFFA600);
                const lv_color_t c_orange_dark = lv_color_hex(0xB56100);
                const lv_color_t c_ring_inner = lv_color_hex(0x101010);
                const lv_color_t c_tick_minor = lv_color_hex(0x525152);

                lv_draw_buf_t *canvas_buf = lv_canvas_get_draw_buf(canvas);

                // The bezel/rings/tick-marks/numerals/caption never change - redrawing
                // all ~75 of those shapes every second (on top of the moving hands) was
                // cheap enough on the 360x360 reference device but expensive enough on
                // this larger canvas to intermittently starve the touchscreen driver's
                // own timing and make touch stop registering. Draw the static face once,
                // cache a raw snapshot of the canvas buffer, and on every later call just
                // memcpy it back (near-instant) before drawing only the moving parts.
                if (id(retro_static_buf) == nullptr) {
                  lv_canvas_fill_bg(canvas, lv_color_hex(0x000000), LV_OPA_COVER);

                  lv_layer_t static_layer;
                  lv_canvas_init_layer(canvas, &static_layer);

                  auto fill_circle_at = [&](int32_t cx, int32_t cy, int32_t r, lv_color_t color) {
                    lv_draw_rect_dsc_t dsc;
                    lv_draw_rect_dsc_init(&dsc);
                    dsc.bg_color = color;
                    dsc.bg_opa = LV_OPA_COVER;
                    dsc.border_opa = LV_OPA_TRANSP;
                    dsc.radius = LV_RADIUS_CIRCLE;
                    lv_area_t area = {cx - r, cy - r, cx + r, cy + r};
                    lv_draw_rect(&static_layer, &dsc, &area);
                  };
                  auto fill_circle = [&](int32_t r, lv_color_t color) {
                    fill_circle_at(CX, CY, r, color);
                  };
                  auto stroke_circle = [&](int32_t r, lv_color_t color, int32_t width) {
                    lv_draw_rect_dsc_t dsc;
                    lv_draw_rect_dsc_init(&dsc);
                    dsc.bg_opa = LV_OPA_TRANSP;
                    dsc.border_color = color;
                    dsc.border_width = width;
                    dsc.border_opa = LV_OPA_COVER;
                    dsc.radius = LV_RADIUS_CIRCLE;
                    lv_area_t area = {CX - r, CY - r, CX + r, CY + r};
                    lv_draw_rect(&static_layer, &dsc, &area);
                  };

                  fill_circle(S(118.0f), c_matte_dark);
                  fill_circle(S(111.0f), c_matte);

                  for (int r = 110; r >= 72; r -= 8) {
                    stroke_circle(S((float) r), (r == 110) ? c_charcoal : c_ring_inner, 1);
                  }
                  stroke_circle(S(117.0f), c_dim_white, 1);
                  stroke_circle(S(116.0f), c_charcoal, 1);
                  stroke_circle(S(105.0f), c_charcoal, 1);

                  lv_draw_line_dsc_t static_line_dsc;
                  lv_draw_line_dsc_init(&static_line_dsc);
                  static_line_dsc.round_start = true;
                  static_line_dsc.round_end = true;

                  for (int i = 0; i < 60; i++) {
                    float a = (i / 60.0f) * 6.2831853f - 1.5707963f;
                    bool major = (i % 5 == 0);
                    int32_t r_out = S(major ? 107.0f : 104.0f);
                    int32_t r_in = S(major ? 91.0f : 99.0f);
                    static_line_dsc.p1.x = CX + (int32_t) (cosf(a) * r_out);
                    static_line_dsc.p1.y = CY + (int32_t) (sinf(a) * r_out);
                    static_line_dsc.p2.x = CX + (int32_t) (cosf(a) * r_in);
                    static_line_dsc.p2.y = CY + (int32_t) (sinf(a) * r_in);
                    static_line_dsc.color = major ? c_soft_white : c_tick_minor;
                    static_line_dsc.width = major ? 2 : 1;
                    lv_draw_line(&static_layer, &static_line_dsc);
                  }

                  lv_draw_label_dsc_t numeral_dsc;
                  lv_draw_label_dsc_init(&numeral_dsc);
                  numeral_dsc.color = c_soft_white;
                  numeral_dsc.font = id(font_retro_numeral)->get_lv_font();
                  numeral_dsc.align = LV_TEXT_ALIGN_CENTER;
                  numeral_dsc.text_local = true;

                  auto draw_numeral = [&](const char *text, int32_t x, int32_t y) {
                    numeral_dsc.text = text;
                    lv_area_t area = {x - S(30.0f), y - S(14.0f), x + S(30.0f), y + S(14.0f)};
                    lv_draw_label(&static_layer, &numeral_dsc, &area);
                  };

                  draw_numeral("12", CX, CY - S(78.0f));
                  draw_numeral("3", CX + S(78.0f), CY);
                  draw_numeral("6", CX, CY + S(78.0f));
                  draw_numeral("9", CX - S(78.0f), CY);

                  lv_draw_label_dsc_t caption_dsc;
                  lv_draw_label_dsc_init(&caption_dsc);
                  caption_dsc.color = c_dim_white;
                  caption_dsc.font = id(font_retro_small)->get_lv_font();
                  caption_dsc.align = LV_TEXT_ALIGN_CENTER;
                  caption_dsc.text = "RETRO";
                  caption_dsc.text_local = true;
                  int32_t caption_y = CY - S(34.0f);
                  lv_area_t caption_area = {CX - S(50.0f), caption_y - S(10.0f), CX + S(50.0f), caption_y + S(10.0f)};
                  lv_draw_label(&static_layer, &caption_dsc, &caption_area);

                  lv_canvas_finish_layer(canvas, &static_layer);

                  uint8_t *snapshot = (uint8_t *) malloc(canvas_buf->data_size);
                  if (snapshot != nullptr) {
                    memcpy(snapshot, canvas_buf->data, canvas_buf->data_size);
                    id(retro_static_buf) = snapshot;
                  }
                } else {
                  memcpy(canvas_buf->data, id(retro_static_buf), canvas_buf->data_size);
                }

                lv_layer_t layer;
                lv_canvas_init_layer(canvas, &layer);

                lv_draw_line_dsc_t line_dsc;
                lv_draw_line_dsc_init(&line_dsc);
                line_dsc.round_start = true;
                line_dsc.round_end = true;

                auto t = id(homeassistant_time).now();
                int hh24 = t.is_valid() ? t.hour : 0;
                int hh = hh24 % 12;
                int mm = t.is_valid() ? t.minute : 0;
                int ss = t.is_valid() ? t.second : 0;

                float s_pos = ss / 60.0f;
                float m_pos = (mm + s_pos) / 60.0f;
                float h_pos = (hh + m_pos) / 12.0f;

                auto draw_hand = [&](float pos, int32_t len, int32_t width, lv_color_t color, bool shadow) {
                  float a = pos * 6.2831853f - 1.5707963f;
                  int32_t ex = CX + (int32_t) (cosf(a) * len);
                  int32_t ey = CY + (int32_t) (sinf(a) * len);
                  if (shadow) {
                    line_dsc.p1.x = CX + S(3.0f);
                    line_dsc.p1.y = CY + S(3.0f);
                    line_dsc.p2.x = ex + S(3.0f);
                    line_dsc.p2.y = ey + S(3.0f);
                    line_dsc.color = c_shadow;
                    line_dsc.width = width;
                    lv_draw_line(&layer, &line_dsc);
                  }
                  line_dsc.p1.x = CX;
                  line_dsc.p1.y = CY;
                  line_dsc.p2.x = ex;
                  line_dsc.p2.y = ey;
                  line_dsc.color = color;
                  line_dsc.width = width;
                  lv_draw_line(&layer, &line_dsc);
                };

                draw_hand(h_pos, S(53.0f), S(5.0f), c_white, true);
                draw_hand(m_pos, S(76.0f), S(3.0f), c_soft_white, true);

                if (id(clock_show_seconds).state) {
                  draw_hand(s_pos, S(92.0f), S(1.0f), c_orange, true);

                  float tail_pos = s_pos + 0.5f;
                  if (tail_pos >= 1.0f) tail_pos -= 1.0f;
                  draw_hand(tail_pos, S(18.0f), 1, c_orange_dark, false);
                }

                auto fill_circle_dyn = [&](int32_t cx, int32_t cy, int32_t r, lv_color_t color) {
                  lv_draw_rect_dsc_t dsc;
                  lv_draw_rect_dsc_init(&dsc);
                  dsc.bg_color = color;
                  dsc.bg_opa = LV_OPA_COVER;
                  dsc.border_opa = LV_OPA_TRANSP;
                  dsc.radius = LV_RADIUS_CIRCLE;
                  lv_area_t area = {cx - r, cy - r, cx + r, cy + r};
                  lv_draw_rect(&layer, &dsc, &area);
                };
                auto stroke_circle_dyn = [&](int32_t r, lv_color_t color, int32_t width) {
                  lv_draw_rect_dsc_t dsc;
                  lv_draw_rect_dsc_init(&dsc);
                  dsc.bg_opa = LV_OPA_TRANSP;
                  dsc.border_color = color;
                  dsc.border_width = width;
                  dsc.border_opa = LV_OPA_COVER;
                  dsc.radius = LV_RADIUS_CIRCLE;
                  lv_area_t area = {CX - r, CY - r, CX + r, CY + r};
                  lv_draw_rect(&layer, &dsc, &area);
                };

                fill_circle_dyn(CX + S(3.0f), CY + S(3.0f), S(7.0f), c_shadow);
                fill_circle_dyn(CX, CY, S(7.0f), c_matte_dark);
                stroke_circle_dyn(S(7.0f), c_soft_white, 1);
                fill_circle_dyn(CX, CY, S(3.0f), c_orange);

                lv_draw_label_dsc_t digital_dsc;
                lv_draw_label_dsc_init(&digital_dsc);
                digital_dsc.color = c_dim_white;
                digital_dsc.font = id(font_retro_small)->get_lv_font();
                digital_dsc.align = LV_TEXT_ALIGN_CENTER;
                digital_dsc.text_local = true;
                int display_hour = hh24;
                if (id(clock_12h_format).state) {
                  display_hour = hh24 % 12;
                  if (display_hour == 0) display_hour = 12;
                }
                char time_buf[8];
                snprintf(time_buf, sizeof(time_buf), "%02d:%02d", display_hour, mm);
                digital_dsc.text = time_buf;
                int32_t digital_y = CY + S(46.0f);
                lv_area_t digital_area = {CX - S(50.0f), digital_y - S(10.0f), CX + S(50.0f), digital_y + S(10.0f)};
                lv_draw_label(&layer, &digital_dsc, &digital_area);

                lv_canvas_finish_layer(canvas, &layer);
                lv_obj_invalidate(canvas);


  - id: mario_draw_scene
    mode: restart
    parameters:
      full_redraw: bool
    then:
      - if:
          condition:
            and:
              - lambda: 'return id(clock_style_mode) == ${clock_style_mario};'
              - lambda: 'return id(analog_clock_canvas_widget) != nullptr;'
              - or:
                  # A full_redraw is a deliberate one-time "prepare this style now" paint (called
                  # when entering Mario style) and must happen even if idle_page isn't the currently
                  # shown page yet (e.g. boot_sound_page is still showing during startup) - otherwise
                  # the canvas is left with whatever garbage was in its freshly-allocated buffer until
                  # something else happens to trigger a redraw. Animation-frame calls (full_redraw:
                  # false) keep the visibility check, since there's no point animating unseen content.
                  - lambda: 'return full_redraw;'
                  - lvgl.page.is_showing: idle_page
          then:
            - lambda: |-
                lv_obj_t *canvas = id(analog_clock_canvas_widget);
                lv_display_t *mario_disp = lv_obj_get_display(canvas);
                lv_display_enable_invalidation(mario_disp, false);
                const int32_t SIZE = std::min(${screenwidth}, ${screenheight});
                // Fills the entire canvas (not just an inset square) - on a round display this means
                // decorative corners (hill tip, cloud, far ground-row ends) may clip against the bezel;
                // the core gameplay area (Mario, both blocks) stays well within the visible circle.
                const float SCALE = (float) SIZE / 64.0f;
                const int32_t OFFSET_X = (SIZE - (int32_t) roundf(64.0f * SCALE)) / 2;
                const int32_t OFFSET_Y = (SIZE - (int32_t) roundf(64.0f * SCALE)) / 2;

                static const uint16_t MASK_COLOR = 0x000E;

                static const uint16_t BLOCK_SPRITE[361] = {
                  0x000E, 0x9A40, 0x9A40, 0x9A40, 0x9A40, 0x9A40, 0x9A40, 0x9A40, 0x9A40, 0x9A40, 0x9A40, 0x9A40, 0x9A40, 0x9A40, 0x9A40, 0x9A40,
                  0x9A40, 0x9A40, 0x000E, 0x9A40, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4,
                  0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0x0000, 0x9A40, 0xE4E4, 0x0000, 0x0000, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4,
                  0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0x0000, 0x0000, 0xE4E4, 0x0000, 0x9A40, 0xE4E4, 0x0000, 0x0000, 0xE4E4, 0xE4E4, 0xE4E4,
                  0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0x0000, 0x0000, 0xE4E4, 0x0000, 0x9A40, 0xE4E4, 0xE4E4, 0xE4E4,
                  0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0x0000, 0x9A40,
                  0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4,
                  0xE4E4, 0x0000, 0x9A40, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4,
                  0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0x0000, 0x9A40, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4,
                  0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0x0000, 0x9A40, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4,
                  0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0x0000, 0x9A40, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4,
                  0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0x0000, 0x9A40, 0xE4E4,
                  0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4,
                  0x0000, 0x9A40, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4,
                  0xE4E4, 0xE4E4, 0xE4E4, 0x0000, 0x9A40, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4,
                  0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0x0000, 0x9A40, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4,
                  0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0x0000, 0x9A40, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4,
                  0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0x0000, 0x9A40, 0xE4E4, 0x0000,
                  0x0000, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0x0000, 0x0000, 0xE4E4, 0x0000,
                  0x9A40, 0xE4E4, 0x0000, 0x0000, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0x0000,
                  0x0000, 0xE4E4, 0x0000, 0x9A40, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4,
                  0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0xE4E4, 0x0000, 0x000E, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000
                };

                static const uint16_t BUSH_SPRITE[189] = {
                  0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x0000, 0x0000, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E,
                  0x000E, 0x0000, 0x0000, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x0000, 0xBFE3, 0xBFE3, 0x0000,
                  0x000E, 0x0000, 0x000E, 0x000E, 0x000E, 0x0000, 0xBFE3, 0xBFE3, 0x0000, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E,
                  0x0000, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0x0000, 0xBFE3, 0x0000, 0x000E, 0x0000, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0x0000, 0x000E,
                  0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x0000, 0xBFE3, 0xBFE3, 0xBFE3, 0x0560, 0xBFE3, 0xBFE3, 0x0000, 0x000E, 0x0000, 0xBFE3,
                  0xBFE3, 0xBFE3, 0x0560, 0xBFE3, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x0000, 0xBFE3, 0x0560, 0x0560, 0xBFE3, 0xBFE3, 0x0560,
                  0xBFE3, 0xBFE3, 0x0000, 0xBFE3, 0x0560, 0x0560, 0xBFE3, 0xBFE3, 0x0560, 0x000E, 0x000E, 0x000E, 0x0000, 0x0000, 0xBFE3, 0x0560,
                  0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0x0560, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0x000E, 0x000E,
                  0x0000, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3,
                  0xBFE3, 0xBFE3, 0xBFE3, 0x000E, 0x000E, 0x0000, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3,
                  0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0x000E, 0x0000, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3,
                  0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3, 0xBFE3
                };

                static const uint16_t CLOUD1_SPRITE[156] = {
                  0x000E, 0x0000, 0x0000, 0x0000, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x0000, 0xFFFF, 0xFFFF,
                  0xFFFF, 0x0000, 0x0000, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x0000, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF,
                  0x0000, 0x000E, 0x0000, 0x000E, 0x000E, 0x000E, 0x000E, 0xFFFF, 0x3DFF, 0xFFFF, 0xFFFF, 0x3DFF, 0xFFFF, 0xFFFF, 0x0000, 0xFFFF,
                  0x0000, 0x000E, 0x000E, 0x000E, 0x3DFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0x0000, 0x000E,
                  0x000E, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0x0000, 0x000E, 0xFFFF, 0xFFFF,
                  0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0x0000, 0x000E, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF,
                  0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0x0000, 0x000E, 0x000E, 0xFFFF, 0xFFFF, 0xFFFF, 0x3DFF, 0x3DFF, 0xFFFF, 0x3DFF, 0xFFFF,
                  0xFFFF, 0xFFFF, 0xFFFF, 0x0000, 0x000E, 0x3DFF, 0x3DFF, 0x3DFF, 0xFFFF, 0xFFFF, 0x3DFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0x0000,
                  0x000E, 0x000E, 0xFFFF, 0xFFFF, 0x0000, 0xFFFF, 0xFFFF, 0xFFFF, 0x0000, 0x0000, 0x0000, 0x000E, 0x000E, 0x000E, 0x000E, 0x0000,
                  0x0000, 0x000E, 0x0000, 0x0000, 0x0000, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E
                };

                static const uint16_t CLOUD2_SPRITE[156] = {
                  0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x0000, 0x0000, 0x0000, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E,
                  0x000E, 0x000E, 0x000E, 0x0000, 0xFFFF, 0xFFFF, 0xFFFF, 0x0000, 0x0000, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E,
                  0x0000, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0x0000, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x0000, 0xFFFF, 0x3DFF, 0xFFFF,
                  0xFFFF, 0x3DFF, 0xFFFF, 0xFFFF, 0x000E, 0x000E, 0x000E, 0x0000, 0x0000, 0xFFFF, 0x3DFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF,
                  0xFFFF, 0x000E, 0x000E, 0x0000, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0x000E, 0x0000,
                  0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0x000E, 0xFFFF, 0xFFFF, 0xFFFF, 0x3DFF,
                  0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0x000E, 0x000E, 0x0000, 0xFFFF, 0xFFFF, 0x3DFF, 0xFFFF, 0xFFFF,
                  0xFFFF, 0x3DFF, 0x3DFF, 0xFFFF, 0x3DFF, 0x000E, 0x000E, 0x000E, 0x0000, 0xFFFF, 0xFFFF, 0x3DFF, 0x3DFF, 0x3DFF, 0xFFFF, 0xFFFF,
                  0x3DFF, 0xFFFF, 0x000E, 0x000E, 0x000E, 0x000E, 0x0000, 0x0000, 0xFFFF, 0xFFFF, 0x0000, 0xFFFF, 0xFFFF, 0xFFFF, 0x0000, 0x000E,
                  0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x0000, 0x0000, 0x000E, 0x0000, 0x0000, 0x0000, 0x000E
                };

                static const uint16_t GROUND_SPRITE[64] = {
                  0xE2C2, 0xF6B6, 0xF6B6, 0xF6B6, 0x0000, 0xE2C2, 0xF6B6, 0xE2C2, 0xF6B6, 0xE2C2, 0xE2C2, 0xE2C2, 0x0000, 0xF6B6, 0xE2C2, 0x0000,
                  0xF6B6, 0xE2C2, 0xE2C2, 0xE2C2, 0x0000, 0xE2C2, 0x0000, 0xE2C2, 0x0000, 0xE2C2, 0xE2C2, 0xE2C2, 0x0000, 0xF6B6, 0xF6B6, 0x0000,
                  0xF6B6, 0x0000, 0x0000, 0xE2C2, 0x0000, 0xF6B6, 0xE2C2, 0x0000, 0xF6B6, 0xF6B6, 0xF6B6, 0x0000, 0xF6B6, 0xE2C2, 0xE2C2, 0x0000,
                  0xF6B6, 0xE2C2, 0xE2C2, 0xF6B6, 0xE2C2, 0xE2C2, 0xE2C2, 0x0000, 0xE2C2, 0x0000, 0x0000, 0xF6B6, 0x0000, 0x0000, 0x0000, 0xE2C2
                };

                static const uint16_t HILL_SPRITE[440] = {
                  0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E,
                  0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E,
                  0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E,
                  0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E,
                  0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E,
                  0x0000, 0x0000, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E,
                  0x000E, 0x000E, 0x000E, 0x000E, 0x0560, 0x0560, 0x0000, 0x0000, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E,
                  0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x0560, 0x0560, 0x0560, 0x0560, 0x0000, 0x000E, 0x000E, 0x000E,
                  0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x0560, 0x0560, 0x0000, 0x0560,
                  0x0560, 0x0000, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E,
                  0x0560, 0x0560, 0x0000, 0x0560, 0x0560, 0x0560, 0x0000, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E,
                  0x000E, 0x000E, 0x000E, 0x000E, 0x0000, 0x0560, 0x0000, 0x0560, 0x0560, 0x0560, 0x0560, 0x0000, 0x000E, 0x000E, 0x000E, 0x000E,
                  0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x0000, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560,
                  0x0000, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x0560, 0x0560, 0x0560, 0x0560,
                  0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0000, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E,
                  0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0000, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E,
                  0x000E, 0x000E, 0x000E, 0x000E, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0000,
                  0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560,
                  0x0560, 0x0560, 0x0560, 0x0560, 0x0000, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x0560, 0x0560, 0x0560, 0x0560,
                  0x0560, 0x0560, 0x0000, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0000, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E,
                  0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0000, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0000, 0x000E,
                  0x000E, 0x000E, 0x000E, 0x000E, 0x0560, 0x0560, 0x0560, 0x0560, 0x0000, 0x0560, 0x0000, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560,
                  0x0560, 0x0560, 0x0560, 0x0000, 0x000E, 0x000E, 0x000E, 0x000E, 0x0560, 0x0560, 0x0560, 0x0560, 0x0000, 0x0560, 0x0560, 0x0560,
                  0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0000, 0x000E, 0x000E, 0x000E, 0x0560, 0x0560, 0x0560, 0x0560,
                  0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0000, 0x000E, 0x000E,
                  0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560,
                  0x0560, 0x0560, 0x0000, 0x000E, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560,
                  0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560, 0x0560
                };

                static const uint16_t MARIO_IDLE_SPRITE[208] = {
                  0x000E, 0x000E, 0x000E, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0xF801,
                  0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0x000E, 0x000E, 0x000E, 0x0000, 0x0000, 0x0000, 0xfd28,
                  0xfd28, 0x0000, 0xfd28, 0xfd28, 0x000E, 0x000E, 0x000E, 0x000E, 0x0000, 0xfd28, 0x0000, 0xfd28, 0xfd28, 0xfd28, 0x0000, 0xfd28,
                  0xfd28, 0xfd28, 0xfd28, 0x000E, 0x000E, 0x0000, 0xfd28, 0x0000, 0x0000, 0xfd28, 0xfd28, 0xfd28, 0x0000, 0xfd28, 0xfd28, 0xfd28,
                  0xfd28, 0x000E, 0x0000, 0x0000, 0xfd28, 0xfd28, 0xfd28, 0xfd28, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x000E, 0x000E, 0x000E,
                  0x000E, 0xfd28, 0xfd28, 0xfd28, 0xfd28, 0xfd28, 0xfd28, 0xfd28, 0xfd28, 0x000E, 0x000E, 0x000E, 0x000E, 0x7BCF, 0x7BCF, 0xF801,
                  0x7BCF, 0x7BCF, 0x7BCF, 0x7BCF, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x7BCF, 0x7BCF, 0x7BCF, 0xF801, 0x7BCF, 0x7BCF, 0xF801,
                  0x7BCF, 0x7BCF, 0x7BCF, 0x7BCF, 0x000E, 0x7BCF, 0x7BCF, 0x7BCF, 0x7BCF, 0xF801, 0xF801, 0xF801, 0xF801, 0x7BCF, 0x7BCF, 0x7BCF,
                  0x7BCF, 0x7BCF, 0xfd28, 0xfd28, 0x7BCF, 0xF801, 0xfd28, 0xF801, 0xF801, 0xfd28, 0xF801, 0x7BCF, 0xfd28, 0xfd28, 0xfd28, 0xfd28,
                  0xfd28, 0xfd28, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xfd28, 0xfd28, 0xfd28, 0xfd28, 0xfd28, 0xfd28, 0xF801, 0xF801,
                  0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xfd28, 0xfd28, 0xfd28, 0x000E, 0x000E, 0xF801, 0xF801, 0xF801, 0xF801, 0x000E,
                  0xF801, 0xF801, 0xF801, 0xF801, 0x000E, 0x000E, 0x000E, 0xC300, 0xC300, 0xC300, 0xC300, 0x000E, 0x000E, 0x000E, 0xC300, 0xC300,
                  0xC300, 0xC300, 0x000E, 0xC300, 0xC300, 0xC300, 0xC300, 0xC300, 0x000E, 0x000E, 0x000E, 0xC300, 0xC300, 0xC300, 0xC300, 0xC300
                };

                static const uint16_t MARIO_JUMP_SPRITE[272] = {
                  0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0xfd28, 0xfd28, 0xfd28,
                  0xfd28, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0x000E, 0xfd28, 0xfd28,
                  0xfd28, 0xfd28, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801,
                  0xfd28, 0xfd28, 0xfd28, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x0000, 0x0000, 0x0000, 0xfd28, 0xfd28, 0x0000, 0xfd28, 0xfd28,
                  0x7BCF, 0x7BCF, 0x7BCF, 0x7BCF, 0x000E, 0x000E, 0x000E, 0x000E, 0x0000, 0xfd28, 0x0000, 0xfd28, 0xfd28, 0xfd28, 0x0000, 0xfd28,
                  0xfd28, 0x7BCF, 0x7BCF, 0x7BCF, 0x7BCF, 0x000E, 0x000E, 0x000E, 0x000E, 0x0000, 0xfd28, 0x0000, 0x0000, 0xfd28, 0xfd28, 0xfd28,
                  0x0000, 0xfd28, 0xfd28, 0xfd28, 0x7BCF, 0x7BCF, 0x000E, 0x000E, 0x000E, 0x000E, 0x0000, 0x0000, 0xfd28, 0xfd28, 0xfd28, 0xfd28,
                  0x0000, 0x0000, 0x0000, 0x0000, 0x7BCF, 0x7BCF, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0xfd28, 0xfd28, 0xfd28,
                  0xfd28, 0xfd28, 0xfd28, 0xfd28, 0x7BCF, 0x7BCF, 0x000E, 0x000E, 0x000E, 0x000E, 0x7BCF, 0x7BCF, 0x7BCF, 0x7BCF, 0x7BCF, 0xF801,
                  0x7BCF, 0x7BCF, 0x7BCF, 0xF801, 0x7BCF, 0x7BCF, 0x000E, 0x000E, 0x000E, 0x000E, 0x7BCF, 0x7BCF, 0x7BCF, 0x7BCF, 0x7BCF, 0x7BCF,
                  0x7BCF, 0xF801, 0x7BCF, 0x7BCF, 0x7BCF, 0xF801, 0xF801, 0x000E, 0xC300, 0xC300, 0xfd28, 0xfd28, 0x7BCF, 0x7BCF, 0x7BCF, 0x7BCF,
                  0x7BCF, 0x7BCF, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0x000E, 0xC300, 0xC300, 0xfd28, 0xfd28, 0xfd28, 0xfd28, 0xF801,
                  0xF801, 0x7BCF, 0xF801, 0xF801, 0xfd28, 0xF801, 0xF801, 0xfd28, 0xF801, 0xC300, 0xC300, 0xC300, 0x000E, 0xfd28, 0xfd28, 0xC300,
                  0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xC300, 0xC300, 0xC300, 0x000E, 0x000E, 0xC300,
                  0xC300, 0xC300, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xC300, 0xC300, 0xC300, 0x000E, 0xC300,
                  0xC300, 0xC300, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E,
                  0xC300, 0xC300, 0x000E, 0xF801, 0xF801, 0xF801, 0xF801, 0xF801, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E, 0x000E
                };

                auto rgb565_to_hex = [](uint16_t v) -> uint32_t {
                  uint8_t r5 = (v >> 11) & 0x1F;
                  uint8_t g6 = (v >> 5) & 0x3F;
                  uint8_t b5 = v & 0x1F;
                  uint8_t r8 = (r5 << 3) | (r5 >> 2);
                  uint8_t g8 = (g6 << 2) | (g6 >> 4);
                  uint8_t b8 = (b5 << 3) | (b5 >> 2);
                  return ((uint32_t) r8 << 16) | ((uint32_t) g8 << 8) | b8;
                };

                // SCALE is fractional now (filling the full canvas, not a clean integer multiple of 64),
                // so each source pixel maps to a variable-sized block: its screen-space bounds are found
                // by rounding both edges independently, which tiles without gaps or overlaps.
                auto blit = [&](const uint16_t *data, int sw, int sh, int32_t gx, int32_t gy) {
                  for (int sy = 0; sy < sh; sy++) {
                    int32_t by0 = OFFSET_Y + (int32_t) roundf((gy + sy) * SCALE);
                    int32_t by1 = OFFSET_Y + (int32_t) roundf((gy + sy + 1) * SCALE);
                    for (int sx = 0; sx < sw; sx++) {
                      uint16_t px = data[sy * sw + sx];
                      if (px == MASK_COLOR) continue;
                      lv_color_t color = lv_color_hex(rgb565_to_hex(px));
                      int32_t bx0 = OFFSET_X + (int32_t) roundf((gx + sx) * SCALE);
                      int32_t bx1 = OFFSET_X + (int32_t) roundf((gx + sx + 1) * SCALE);
                      for (int32_t y = by0; y < by1; y++) {
                        for (int32_t x = bx0; x < bx1; x++) {
                          lv_canvas_set_px(canvas, x, y, color, LV_OPA_COVER);
                        }
                      }
                    }
                  }
                };

                if (full_redraw) {
                  // Sky fill (whole canvas, so it extends to fill the round bezel)
                  lv_canvas_fill_bg(canvas, lv_color_hex(rgb565_to_hex(MASK_COLOR)), LV_OPA_COVER);

                  // Background scenery (static, matches the original's one-time setup() draw calls) -
                  // only needs to be painted once per style-entry, not every animation frame.
                  blit(HILL_SPRITE, 20, 22, 0, 34);
                  blit(CLOUD1_SPRITE, 13, 12, 0, 21);
                  blit(CLOUD2_SPRITE, 13, 12, 51, 7);
                  blit(BUSH_SPRITE, 21, 9, 43, 47);
                  for (int i = 0; i < 8; i++) {
                    blit(GROUND_SPRITE, 8, 8, i * 8, 56);
                  }
                } else {
                  // Animation-frame fast path: only erase the small regions Mario/the blocks can occupy
                  // (their entire travel range sits over plain sky in the original layout, so a flat
                  // sky-colored rect fill is a safe, much cheaper stand-in for a full scene redraw).
                  // This uses its own layer that's finished (flushed to the canvas buffer) immediately -
                  // lv_draw_rect only queues a draw task, it doesn't render until lv_canvas_finish_layer
                  // runs, so sharing one layer with the sprite blits below (which write immediately via
                  // lv_canvas_set_px) would let the queued erase flush AFTER the blits and wipe them out.
                  lv_layer_t erase_layer;
                  lv_canvas_init_layer(canvas, &erase_layer);
                  auto erase_rect = [&](int32_t gx, int32_t gy, int32_t gw, int32_t gh) {
                    lv_draw_rect_dsc_t erase_dsc;
                    lv_draw_rect_dsc_init(&erase_dsc);
                    erase_dsc.bg_color = lv_color_hex(rgb565_to_hex(MASK_COLOR));
                    erase_dsc.bg_opa = LV_OPA_COVER;
                    erase_dsc.border_opa = LV_OPA_TRANSP;
                    int32_t bx0 = OFFSET_X + (int32_t) roundf(gx * SCALE);
                    int32_t bx1 = OFFSET_X + (int32_t) roundf((gx + gw) * SCALE);
                    int32_t by0 = OFFSET_Y + (int32_t) roundf(gy * SCALE);
                    int32_t by1 = OFFSET_Y + (int32_t) roundf((gy + gh) * SCALE);
                    lv_area_t area = {bx0, by0, bx1 - 1, by1 - 1};
                    lv_draw_rect(&erase_layer, &erase_dsc, &area);
                  };
                  erase_rect(23, 25, 17, 31);
                  erase_rect(13, 7, 19, 23);
                  erase_rect(32, 7, 19, 23);
                  lv_canvas_finish_layer(canvas, &erase_layer);
                }

                // Blocks (hour/minute), each with its own current bounce offset
                blit(BLOCK_SPRITE, 19, 19, 13, 11 + id(hourblock_offset));
                blit(BLOCK_SPRITE, 19, 19, 32, 11 + id(minuteblock_offset));

                // Mario, idle or jumping sprite depending on current animation state
                if (id(mario_anim_active)) {
                  blit(MARIO_JUMP_SPRITE, 17, 16, 23, 40 + id(mario_y_offset));
                } else {
                  blit(MARIO_IDLE_SPRITE, 13, 16, 23, 40);
                }

                // Hour/minute digits, drawn on top of each block (matches the source's black text over the block sprite).
                // The source displays 24-hour, non-zero-padded hour (e.g. "22:39"), zero-padded minute.
                // The displayed value is only ever refreshed at the moment a block is actually hit (see
                // mario_jump_animate's collision checks) or when first entering the style - not read live
                // every frame - so the digits change exactly when the bounce happens, not the instant the
                // jump starts.
                if (full_redraw) {
                  auto boot_t = id(homeassistant_time).now();
                  if (boot_t.is_valid()) {
                    id(mario_displayed_hour) = boot_t.hour;
                    id(mario_displayed_minute) = boot_t.minute;
                  }
                }
                int hh24 = id(mario_displayed_hour);
                int mm = id(mario_displayed_minute);
                int display_hour = hh24;
                if (id(clock_12h_format).state) {
                  display_hour = hh24 % 12;
                  if (display_hour == 0) display_hour = 12;
                }
                char hbuf[4];
                char mbuf[4];
                snprintf(hbuf, sizeof(hbuf), "%d", display_hour);
                snprintf(mbuf, sizeof(mbuf), "%02d", mm);

                lv_layer_t layer;
                lv_canvas_init_layer(canvas, &layer);

                lv_draw_label_dsc_t digit_dsc;
                lv_draw_label_dsc_init(&digit_dsc);
                digit_dsc.color = lv_color_hex(0x000000);
                digit_dsc.font = id(font_mario_digits)->get_lv_font();
                digit_dsc.align = LV_TEXT_ALIGN_CENTER;
                digit_dsc.text_local = true;

                // lv_draw_label only centers horizontally (align: LEFT/RIGHT/CENTER) - it top-aligns
                // vertically within whatever area is given, so the area height must tightly match the
                // font's actual rendered height or the text lands above center instead of on it.
                const int32_t font_h = id(font_mario_digits)->get_height();

                const int32_t label_half_w = (int32_t) roundf(SCALE * 10.0f);

                digit_dsc.text = hbuf;
                int32_t hour_cx = OFFSET_X + (int32_t) roundf((13 + 9) * SCALE);
                int32_t hour_cy = OFFSET_Y + (int32_t) roundf((11 + id(hourblock_offset) + 9) * SCALE);
                lv_area_t hour_area = {hour_cx - label_half_w, hour_cy - font_h / 2, hour_cx + label_half_w, hour_cy - font_h / 2 + font_h};
                lv_draw_label(&layer, &digit_dsc, &hour_area);

                digit_dsc.text = mbuf;
                int32_t minute_cx = OFFSET_X + (int32_t) roundf((32 + 9) * SCALE);
                int32_t minute_cy = OFFSET_Y + (int32_t) roundf((11 + id(minuteblock_offset) + 9) * SCALE);
                lv_area_t minute_area = {minute_cx - label_half_w, minute_cy - font_h / 2, minute_cx + label_half_w, minute_cy - font_h / 2 + font_h};
                lv_draw_label(&layer, &digit_dsc, &minute_area);

                lv_canvas_finish_layer(canvas, &layer);
                lv_display_enable_invalidation(mario_disp, true);
                lv_obj_invalidate(canvas);

  - id: mario_clock_tick
    mode: restart
    then:
      - if:
          condition:
            and:
              - lvgl.page.is_showing: idle_page
              - lambda: 'return id(clock_style_mode) == ${clock_style_mario};'
              - lambda: 'return !id(mario_anim_active);'
              - lambda: |-
                  auto t = id(homeassistant_time).now();
                  return t.is_valid() && t.second == 0 && t.minute != id(mario_last_minute);
          then:
            - script.execute: mario_jump_animate

  - id: mario_jump_animate
    mode: restart
    then:
      - lambda: |-
          id(mario_anim_active) = true;
          auto t = id(homeassistant_time).now();
          if (t.is_valid()) id(mario_last_minute) = t.minute;
          id(mario_y_offset) = 0;
          id(mario_direction_up) = true;
          id(hourblock_offset) = 0;
          id(hourblock_bouncing) = false;
          id(hourblock_going_up) = true;
          id(minuteblock_offset) = 0;
          id(minuteblock_bouncing) = false;
          id(minuteblock_going_up) = true;
      - while:
          condition:
            lambda: 'return id(mario_anim_active);'
          then:
            - lambda: |-
                const int MARIO_PACE = 5;
                const int MARIO_JUMP_HEIGHT = 14;
                const int MOVE_PACE = 2;
                const int MAX_MOVE_HEIGHT = 4;

                if (id(mario_direction_up)) {
                  id(mario_y_offset) -= MARIO_PACE;
                  if (-id(mario_y_offset) >= MARIO_JUMP_HEIGHT) {
                    id(mario_direction_up) = false;
                  }
                } else {
                  id(mario_y_offset) += MARIO_PACE;
                }

                const int mario_x = 23, mario_w = 17, mario_h = 16;
                const int mario_y = 40 + id(mario_y_offset);

                auto overlaps = [&](int bx, int by, int bw, int bh) {
                  return mario_x < bx + bw && mario_x + mario_w > bx &&
                         mario_y < by + bh && mario_y + mario_h > by;
                };

                if (!id(hourblock_bouncing) && overlaps(13, 11 + id(hourblock_offset), 19, 19)) {
                  id(hourblock_bouncing) = true;
                  id(hourblock_going_up) = true;
                  id(mario_direction_up) = false;
                  auto hit_t = id(homeassistant_time).now();
                  if (hit_t.is_valid()) {
                    id(mario_displayed_hour) = hit_t.hour;
                    id(mario_displayed_minute) = hit_t.minute;
                  }
                }
                if (id(hourblock_bouncing)) {
                  if (id(hourblock_going_up)) {
                    id(hourblock_offset) -= MOVE_PACE;
                    if (id(hourblock_offset) <= -MAX_MOVE_HEIGHT) {
                      id(hourblock_offset) = -MAX_MOVE_HEIGHT;
                      id(hourblock_going_up) = false;
                    }
                  } else {
                    id(hourblock_offset) += MOVE_PACE;
                    if (id(hourblock_offset) >= 0) {
                      id(hourblock_offset) = 0;
                      id(hourblock_bouncing) = false;
                    }
                  }
                }

                if (!id(minuteblock_bouncing) && overlaps(32, 11 + id(minuteblock_offset), 19, 19)) {
                  id(minuteblock_bouncing) = true;
                  id(minuteblock_going_up) = true;
                  id(mario_direction_up) = false;
                  auto hit_t2 = id(homeassistant_time).now();
                  if (hit_t2.is_valid()) {
                    id(mario_displayed_hour) = hit_t2.hour;
                    id(mario_displayed_minute) = hit_t2.minute;
                  }
                }
                if (id(minuteblock_bouncing)) {
                  if (id(minuteblock_going_up)) {
                    id(minuteblock_offset) -= MOVE_PACE;
                    if (id(minuteblock_offset) <= -MAX_MOVE_HEIGHT) {
                      id(minuteblock_offset) = -MAX_MOVE_HEIGHT;
                      id(minuteblock_going_up) = false;
                    }
                  } else {
                    id(minuteblock_offset) += MOVE_PACE;
                    if (id(minuteblock_offset) >= 0) {
                      id(minuteblock_offset) = 0;
                      id(minuteblock_bouncing) = false;
                    }
                  }
                }

                if (mario_y + mario_h >= 56) {
                  id(mario_y_offset) = 0;
                  id(mario_anim_active) = false;
                }
            - script.execute:
                id: mario_draw_scene
                full_redraw: false
            - delay: 25ms
      - script.execute:
          id: mario_draw_scene
          full_redraw: false

  - id: finish_va_reply
    mode: restart
    then:
      - delay: 1500ms
      - if:
          condition:
            lambda: 'return id(va_phase) == ${va_phase_replying};'
          then:
            - if:
                condition:
                  lambda: return id(manual_va_active);
                then:
                  - lambda: id(manual_va_active) = false;
                  - script.execute:
                      id: set_va_phase
                      phase: ${va_phase_idle}
                  - if:
                      condition:
                        lambda: return id(manual_va_resume_media);
                      then:
                        - lambda: id(manual_va_resume_media) = false;
                        - media_player.toggle: sendspin_group_media_player
                else:
                  - script.execute:
                      id: set_va_phase
                      phase: ${va_phase_idle}
            - if:
                condition:
                  lambda: return id(wake_word_engine_location).current_option() == "On device";
                then:
                  - lambda: id(va).set_use_wake_word(false);
                  - script.execute: start_wake_word
          else:
            - lambda: |-
                ESP_LOGW("va", "finish_va_reply: conversation continued, skipping reset");

  - id: set_va_phase
    mode: restart
    parameters:
      phase: int
    then:
      - lambda: |-
          id(va_phase) = phase;
          const char *name = "idle";
          switch (phase) {
            case ${va_phase_waiting_for_command}: name = "waiting_for_command"; break;
            case ${va_phase_listening}: name = "listening"; break;
            case ${va_phase_thinking}: name = "thinking"; break;
            case ${va_phase_replying}: name = "replying"; break;
            case ${va_phase_error}: name = "error"; break;
            case ${va_phase_timer_ringing}: name = "timer_ringing"; break;
          }
          ESP_LOGW("va", "phase: %s", name);
      - if:
          condition:
            lambda: return phase == ${va_phase_idle};
          then:
            - script.execute:
                id: set_ui_phase
                phase: !lambda return id(ui_phase);
      - if:
          condition:
            lambda: return phase == ${va_phase_waiting_for_command};
          then:
            - lambda: |-
                lv_img_set_src(id(va_phase_image_widget), id(active_image_set) == 2 ? id(va_waiting_image2) : id(va_waiting_image1));
                lv_obj_add_flag(id(va_status_block), LV_OBJ_FLAG_HIDDEN);
                lv_obj_clear_flag(id(va_phase_image_widget), LV_OBJ_FLAG_HIDDEN);
            - if:
                condition:
                  lambda: return id(manual_va_slide_up);
                then:
                  - lvgl.page.show:
                      id: va_page
                      animation: MOVE_TOP
                      time: 160ms
                  - lambda: id(manual_va_slide_up) = false;
                else:
                  - lvgl.page.show: va_page
      - if:
          condition:
            lambda: return phase == ${va_phase_listening};
          then:
            - lambda: |-
                lv_img_set_src(id(va_phase_image_widget), id(active_image_set) == 2 ? id(va_listening_image2) : id(va_listening_image1));
                lv_obj_add_flag(id(va_status_block), LV_OBJ_FLAG_HIDDEN);
                lv_obj_clear_flag(id(va_phase_image_widget), LV_OBJ_FLAG_HIDDEN);
            - lvgl.page.show: va_page
      - if:
          condition:
            lambda: return phase == ${va_phase_thinking};
          then:
            - lambda: |-
                lv_img_set_src(id(va_phase_image_widget), id(active_image_set) == 2 ? id(va_thinking_image2) : id(va_thinking_image1));
                lv_obj_add_flag(id(va_status_block), LV_OBJ_FLAG_HIDDEN);
                lv_obj_clear_flag(id(va_phase_image_widget), LV_OBJ_FLAG_HIDDEN);
            - lvgl.page.show: va_page
      - if:
          condition:
            lambda: return phase == ${va_phase_replying};
          then:
            - lambda: |-
                lv_img_set_src(id(va_phase_image_widget), id(active_image_set) == 2 ? id(va_replying_image2) : id(va_replying_image1));
                lv_obj_add_flag(id(va_status_block), LV_OBJ_FLAG_HIDDEN);
                lv_obj_clear_flag(id(va_phase_image_widget), LV_OBJ_FLAG_HIDDEN);
            - lvgl.page.show: va_page
      - if:
          condition:
            lambda: return phase == ${va_phase_error};
          then:
            - lambda: |-
                lv_label_set_text(id(va_status_icon), "\U000F0026");
                lv_label_set_text(id(va_status_icon_glow), "\U000F0026");
                lv_obj_set_style_text_color(id(va_status_icon), lv_color_hex(0xFF6666), 0);
                lv_obj_set_style_text_color(id(va_status_icon_glow), lv_color_hex(0x803333), 0);
                lv_label_set_text(id(va_status_title), "ERROR");
                lv_obj_set_style_text_color(id(va_status_title), lv_color_hex(0xFF6666), 0);
                lv_obj_clear_flag(id(va_status_subtitle), LV_OBJ_FLAG_HIDDEN);
                lv_label_set_text(id(va_status_subtitle), "Something went wrong");
                lv_obj_add_flag(id(va_phase_image_widget), LV_OBJ_FLAG_HIDDEN);
                lv_obj_clear_flag(id(va_status_block), LV_OBJ_FLAG_HIDDEN);
            - lvgl.page.show: va_page
      - if:
          condition:
            lambda: return phase == ${va_phase_timer_ringing};
          then:
            - lambda: |-
                lv_label_set_text(id(va_status_icon), "\U000F0020");
                lv_label_set_text(id(va_status_icon_glow), "\U000F0020");
                lv_obj_set_style_text_color(id(va_status_icon), lv_color_hex(0xFFA030), 0);
                lv_obj_set_style_text_color(id(va_status_icon_glow), lv_color_hex(0x805020), 0);
                lv_label_set_text(id(va_status_title), "TIMER FINISHED");
                lv_obj_set_style_text_color(id(va_status_title), lv_color_hex(0xFFA030), 0);
                lv_obj_add_flag(id(va_status_subtitle), LV_OBJ_FLAG_HIDDEN);
                lv_obj_add_flag(id(va_phase_image_widget), LV_OBJ_FLAG_HIDDEN);
                lv_obj_clear_flag(id(va_status_block), LV_OBJ_FLAG_HIDDEN);
            - lvgl.page.show: va_page
      - if:
          condition:
            lambda: return phase != ${va_phase_idle};
          then:
            - script.stop: player_controls_timeout
            - script.execute: standby_screen_activity
      - component.update: va_phase_text
      - script.execute: led_ring_apply_state

  - id: start_manual_va
    mode: restart
    parameters:
      image_set: int
    then:
      - script.stop: player_controls_timeout
      - script.stop: pullup_timeout
      - lambda: |-
          id(manual_va_active) = true;
          id(manual_va_resume_media) = id(external_media_player).state == media_player::MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING;
      - if:
          condition:
            media_player.is_playing: external_media_player
          then:
            - media_player.pause: external_media_player
            - wait_until:
                condition:
                  not:
                    media_player.is_playing: external_media_player
                timeout: 1s
      - script.execute: stop_wake_word
      - delay: 100ms
      - lambda: |-
          id(active_image_set) = image_set;
      - script.execute:
          id: set_va_phase
          phase: ${va_phase_waiting_for_command}
      - voice_assistant.start:
          wake_word: !lambda |-
            return image_set == 2 ? id(wake_word_2_label).state : id(wake_word_1_label).state;

  - id: stop_active_va
    mode: restart
    then:
      - if:
          condition:
            switch.is_on: timer_ringing
          then:
            - switch.turn_off: timer_ringing
          else:
            - if:
                condition:
                  media_player.is_announcing:
                    id: external_media_player
                then:
                  - media_player.stop:
                      announcement: true
                      id: external_media_player
            - if:
                condition:
                  voice_assistant.is_running:
                then:
                  - voice_assistant.stop:
            - script.execute:
                id: set_va_phase
                phase: ${va_phase_idle}
            - if:
                condition:
                  lambda: return id(manual_va_active);
                then:
                  - lambda: id(manual_va_active) = false;
                  - if:
                      condition:
                        lambda: return id(manual_va_resume_media);
                      then:
                        - lambda: id(manual_va_resume_media) = false;
                        - media_player.toggle: sendspin_group_media_player
            - delay: 300ms
            - script.execute: start_wake_word

  - id: start_wake_word
    mode: restart
    then:
      - if:
          condition:
            and:
              - switch.is_off: mute
              - not:
                  voice_assistant.is_running:
              - not:
                  micro_wake_word.is_running:
              - not:
                  media_player.is_playing: external_media_player
              - not:
                  media_player.is_playing: sendspin_group_media_player
              - lambda: return id(wake_word_engine_location).current_option() == "On device";
          then:
            - lambda: id(va).set_use_wake_word(false);
            - lambda: |-
                ESP_LOGW("va", "starting on-device wake word");
            - micro_wake_word.start:
      - if:
          condition:
            and:
              - switch.is_off: mute
              - not:
                  voice_assistant.is_running:
              - not:
                  media_player.is_playing: external_media_player
              - not:
                  media_player.is_playing: sendspin_group_media_player
              - lambda: return id(wake_word_engine_location).current_option() == "In Home Assistant";
          then:
            - lambda: id(va).set_use_wake_word(true);
            - lambda: |-
                ESP_LOGW("va", "starting HA wake word continuous mode");
            - voice_assistant.start_continuous:

  - id: stop_wake_word
    mode: restart
    then:
      - if:
          condition:
            lambda: return id(wake_word_engine_location).current_option() == "In Home Assistant";
          then:
            - lambda: id(va).set_use_wake_word(false);
            - voice_assistant.stop:
      - if:
          condition:
            lambda: return id(wake_word_engine_location).current_option() == "On device";
          then:
            - micro_wake_word.stop:

  - id: refresh_now_playing_progress_bar
    mode: restart
    then:
      - lvgl.bar.update:
          id: now_playing_progress_bar
          animated: false
          value: !lambda |-
            if (!id(sendspin_track_duration).has_state() || id(sendspin_track_duration).state <= 0) return 0;
            float progress = id(sendspin_track_progress).has_state() ? id(sendspin_track_progress).state : 0.0f;
            if (id(external_media_player).state == media_player::MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING) {
              progress += (float) (millis() - id(track_progress_anchor_ms));
            }
            float pct = progress / id(sendspin_track_duration).state * 100.0f;
            return (int) std::max(0.0f, std::min(100.0f, pct));

  - id: apply_accent_color_debounced
    mode: restart
    then:
      - delay: 40ms
      - script.execute: apply_accent_color

  - id: apply_accent_color
    mode: restart
    then:
      - lambda: |-
          const int r = std::max(0, std::min(255, id(accent_red)));
          const int g = std::max(0, std::min(255, id(accent_green)));
          const int b = std::max(0, std::min(255, id(accent_blue)));
          const uint32_t accent_u32 = ((uint32_t) r << 16) | ((uint32_t) g << 8) | (uint32_t) b;
          const uint32_t dark_u32 = ((uint32_t) (r / 10) << 16) | ((uint32_t) (g / 10) << 8) | (uint32_t) (b / 10);
          const lv_color_t accent = lv_color_hex(accent_u32);
          const lv_color_t accent_dark = lv_color_hex(dark_u32);

          const int r2 = std::max(0, std::min(255, id(accent2_red)));
          const int g2 = std::max(0, std::min(255, id(accent2_green)));
          const int b2 = std::max(0, std::min(255, id(accent2_blue)));
          const uint32_t accent2_u32 = ((uint32_t) r2 << 16) | ((uint32_t) g2 << 8) | (uint32_t) b2;
          const lv_color_t accent2 = lv_color_hex(accent2_u32);

          const int r3 = std::max(0, std::min(255, id(accent3_red)));
          const int g3 = std::max(0, std::min(255, id(accent3_green)));
          const int b3 = std::max(0, std::min(255, id(accent3_blue)));
          const uint32_t accent3_u32 = ((uint32_t) r3 << 16) | ((uint32_t) g3 << 8) | (uint32_t) b3;
          const lv_color_t accent3 = lv_color_hex(accent3_u32);

          const int r4 = std::max(0, std::min(255, id(accent4_red)));
          const int g4 = std::max(0, std::min(255, id(accent4_green)));
          const int b4 = std::max(0, std::min(255, id(accent4_blue)));
          const uint32_t accent4_u32 = ((uint32_t) r4 << 16) | ((uint32_t) g4 << 8) | (uint32_t) b4;
          const lv_color_t accent4 = lv_color_hex(accent4_u32);

          lv_obj_set_style_text_color(id(clock_label), accent, 0);
          lv_obj_set_style_text_color(id(clock_ampm_label), accent, 0);
          lv_obj_set_style_text_color(id(idle_timer_countdown), accent, 0);
          lv_obj_set_style_text_color(id(weather_big_temp), accent, 0);
          lv_obj_set_style_text_color(id(weather_big_temp_unit), accent, 0);
          lv_obj_set_style_text_color(id(analog_weather_temp), accent, 0);
          lv_obj_set_style_bg_color(id(controls_toggle_button), accent, 0);
          lv_obj_set_style_border_color(id(controls_toggle_button), accent, LV_STATE_PRESSED);
          lv_obj_set_style_bg_color(id(now_playing_progress_bar), accent, LV_PART_INDICATOR);
          lv_obj_set_style_text_color(id(date_label), accent2, 0);
          lv_obj_set_style_text_color(id(weather_icon), accent2, 0);
          lv_obj_set_style_text_color(id(analog_weather_icon), accent2, 0);
          lv_obj_set_style_text_color(id(weather_label), accent3, 0);
          lv_obj_set_style_text_color(id(controls_title_label), accent2, 0);
          lv_obj_set_style_text_color(id(controls_artist_label), accent3, 0);
          lv_obj_set_style_text_color(id(controls_album_label), accent3, 0);
          lv_obj_set_style_text_color(id(controls_mute_icon), accent4, 0);
          lv_obj_set_style_text_color(id(controls_volume_down_icon), accent4, 0);
          lv_obj_set_style_text_color(id(controls_volume_up_icon), accent4, 0);
          lv_obj_set_style_text_color(id(controls_group_label), accent4, 0);

          lv_obj_t *playlist_buttons[] = {
            id(playlist_1_button), id(playlist_2_button), id(playlist_3_button),
            id(playlist_4_button), id(playlist_5_button), id(playlist_6_button)
          };
          for (auto *button : playlist_buttons) {
            lv_obj_set_style_bg_color(button, accent_dark, 0);
            lv_obj_set_style_border_color(button, accent, 0);
            lv_obj_set_style_shadow_color(button, accent, 0);
            lv_obj_set_style_bg_color(button, accent, LV_STATE_PRESSED);
          }

          lv_obj_t *playlist_labels[] = {
            id(playlist_1_label), id(playlist_2_label), id(playlist_3_label),
            id(playlist_4_label), id(playlist_5_label), id(playlist_6_label)
          };
          for (auto *label : playlist_labels) {
            lv_obj_set_style_text_color(label, accent2, 0);
          }

          lv_obj_set_style_text_color(id(rooms_clock_label), accent3, 0);
          lv_obj_set_style_text_color(id(pulldown_title_label), accent3, 0);
          lv_obj_set_style_text_color(id(pullup_title_label), accent3, 0);
          lv_obj_t *pullup_buttons[] = {
            id(pullup_va1_button), id(pullup_va2_button), id(pullup_mute_button), id(pullup_target_button)
          };
          for (auto *btn : pullup_buttons) {
            lv_obj_set_style_border_color(btn, accent, 0);
            lv_obj_set_style_shadow_color(btn, accent, 0);
          }
          lv_obj_set_style_text_color(id(rooms_date_label), accent2, 0);
          lv_obj_t *room_boxes[] = {
            id(room1_box), id(room2_box), id(room3_box),
            id(room4_box), id(room5_box), id(room6_box)
          };
          for (auto *box : room_boxes) {
            lv_obj_set_style_border_color(box, accent, 0);
          }
      - script.execute: refresh_shuffle_repeat_buttons
      - script.execute: analog_clock_redraw
      - script.execute: rooms_grid_apply
      - script.execute: refresh_pulldown_states

  - id: set_ui_phase
    mode: restart
    parameters:
      phase: int
    then:
      - script.stop: player_controls_timeout
      - script.stop: pulldown_timeout
      - script.stop: pullup_timeout
      - lambda: |-
          id(ui_phase) = phase;
      - script.execute: standby_screen_activity
      - if:
          condition:
            lambda: 'return id(boot_sound_played) && phase == ${ui_phase_standby};'
          then:
            - lvgl.page.show: idle_page
            - component.update: weather_line
      - if:
          condition:
            lambda: 'return id(boot_sound_played) && phase == ${ui_phase_player_controls};'
          then:
            - lvgl.page.show: player_controls_page
            - lambda: |-
                lv_label_set_text(id(controls_mute_icon), (id(pullup_player_target) == 1 ? id(sendspin_group_media_player).is_muted() : id(external_media_player).is_muted()) ? "\xF3\xB0\x9D\x9F" : "\xF3\xB0\x95\xBE");
            - script.execute: player_controls_timeout
      - if:
          condition:
            lambda: 'return id(boot_sound_played) && phase == ${ui_phase_now_playing};'
          then:
            - lvgl.page.show: sendspin_page
      - if:
          condition:
            lambda: 'return id(boot_sound_played) && phase == ${ui_phase_playlist_menu};'
          then:
            - lvgl.page.show: playlist_menu_page
            - script.execute: player_controls_timeout
      - if:
          condition:
            lambda: 'return id(boot_sound_played) && phase == ${ui_phase_pulldown};'
          then:
            - lvgl.page.show: pulldown_page
            - script.execute: pulldown_timeout
      - if:
          condition:
            lambda: 'return id(boot_sound_played) && phase == ${ui_phase_pullup};'
          then:
            - lvgl.page.show: pullup_page
            - script.execute: refresh_pullup_state
            - script.execute: pullup_timeout

  - id: player_controls_timeout
    mode: restart
    then:
      - delay: !lambda |-
          return (uint32_t) (id(player_controls_timeout_seconds).state * 1000.0f);
      - if:
          condition:
            lambda: 'return id(va_phase) == ${va_phase_idle};'
          then:
            - if:
                condition:
                  media_player.is_idle: external_media_player
                then:
                  - script.execute:
                      id: set_ui_phase
                      phase: ${ui_phase_standby}
                else:
                  - script.execute:
                      id: set_ui_phase
                      phase: ${ui_phase_now_playing}

  - id: open_playlist_menu
    then:
      - script.stop: player_controls_timeout
      - lambda: |-
          id(ui_phase) = ${ui_phase_playlist_menu};
      - lvgl.page.show:
          id: playlist_menu_page
          animation: MOVE_BOTTOM
          time: 160ms
      - script.execute: player_controls_timeout

  - id: close_playlist_menu
    then:
      - script.stop: player_controls_timeout
      - lambda: |-
          id(ui_phase) = ${ui_phase_player_controls};
      - lvgl.page.show:
          id: player_controls_page
          animation: MOVE_TOP
          time: 160ms
      - script.execute: player_controls_timeout

  - id: close_playlist_menu_to_now_playing
    then:
      - script.stop: player_controls_timeout
      - lambda: |-
          id(ui_phase) = ${ui_phase_now_playing};
      - lvgl.page.show: sendspin_page

  - id: refresh_playlist_labels
    mode: restart
    then:
      - lvgl.label.update:
          id: playlist_1_label
          text: !lambda return id(playlist_1_name).state;
      - lvgl.label.update:
          id: playlist_2_label
          text: !lambda return id(playlist_2_name).state;
      - lvgl.label.update:
          id: playlist_3_label
          text: !lambda return id(playlist_3_name).state;
      - lvgl.label.update:
          id: playlist_4_label
          text: !lambda return id(playlist_4_name).state;
      - lvgl.label.update:
          id: playlist_5_label
          text: !lambda return id(playlist_5_name).state;
      - lvgl.label.update:
          id: playlist_6_label
          text: !lambda return id(playlist_6_name).state;

  - id: refresh_pulldown_labels
    mode: restart
    then:
      - lvgl.label.update:
          id: pulldown1_button_label
          text: !lambda return id(pulldown1_label).state;
      - lvgl.label.update:
          id: pulldown2_button_label
          text: !lambda return id(pulldown2_label).state;
      - lvgl.label.update:
          id: pulldown3_button_label
          text: !lambda return id(pulldown3_label).state;
      - lvgl.label.update:
          id: pulldown4_button_label
          text: !lambda return id(pulldown4_label).state;

  - id: refresh_pulldown_states
    then:
      - lambda: |-
          const uint32_t accent_u32 = ((uint32_t) id(accent_red) << 16) | ((uint32_t) id(accent_green) << 8) | (uint32_t) id(accent_blue);
          const lv_color_t accent = lv_color_hex(accent_u32);
          const uint32_t accent2_u32 = ((uint32_t) id(accent2_red) << 16) | ((uint32_t) id(accent2_green) << 8) | (uint32_t) id(accent2_blue);
          const lv_color_t accent2 = lv_color_hex(accent2_u32);
          auto apply_button = [&](lv_obj_t *btn, lv_obj_t *label, bool on) {
            if (on) {
              lv_obj_set_style_bg_color(btn, accent, 0);
              lv_obj_set_style_bg_opa(btn, LV_OPA_40, 0);
            } else {
              lv_obj_set_style_bg_color(btn, lv_color_hex(0x000000), 0);
              lv_obj_set_style_bg_opa(btn, 148, 0);
            }
            lv_obj_set_style_border_color(btn, accent, 0);
            lv_obj_set_style_text_color(label, accent2, 0);
          };
          apply_button(id(pulldown1_button), id(pulldown1_button_label), id(pulldown1_state).state);
          apply_button(id(pulldown2_button), id(pulldown2_button_label), id(pulldown2_state).state);
          apply_button(id(pulldown3_button), id(pulldown3_button_label), id(pulldown3_state).state);
          apply_button(id(pulldown4_button), id(pulldown4_button_label), id(pulldown4_state).state);

  - id: open_pulldown_menu
    then:
      - script.stop: player_controls_timeout
      - script.stop: pulldown_timeout
      - lambda: |-
          id(pulldown_return_phase) = id(ui_phase);
          id(ui_phase) = ${ui_phase_pulldown};
          bool show_album_art = id(pulldown_return_phase) == ${ui_phase_now_playing};
          if (show_album_art) {
            lv_obj_add_flag(id(pulldown_background_widget), LV_OBJ_FLAG_HIDDEN);
            lv_obj_clear_flag(id(pulldown_album_art_widget), LV_OBJ_FLAG_HIDDEN);
            lv_obj_clear_flag(id(pulldown_album_art_dim), LV_OBJ_FLAG_HIDDEN);
          } else {
            lv_obj_clear_flag(id(pulldown_background_widget), LV_OBJ_FLAG_HIDDEN);
            lv_obj_add_flag(id(pulldown_album_art_widget), LV_OBJ_FLAG_HIDDEN);
            lv_obj_add_flag(id(pulldown_album_art_dim), LV_OBJ_FLAG_HIDDEN);
          }
      - script.execute: standby_screen_activity
      - lvgl.page.show:
          id: pulldown_page
          animation: MOVE_BOTTOM
          time: 220ms
      - script.execute: refresh_pulldown_states
      - script.execute: pulldown_timeout

  - id: close_pulldown_menu
    then:
      - script.stop: pulldown_timeout
      - lambda: |-
          id(ui_phase) = id(pulldown_return_phase);
      - script.execute: standby_screen_activity
      - if:
          condition:
            lambda: 'return id(pulldown_return_phase) == ${ui_phase_now_playing};'
          then:
            - lvgl.page.show:
                id: sendspin_page
                animation: MOVE_TOP
                time: 220ms
            - script.execute: now_playing_show_album_art
          else:
            - lvgl.page.show:
                id: idle_page
                animation: MOVE_TOP
                time: 220ms
            - component.update: weather_line

  - id: pulldown_timeout
    mode: restart
    then:
      - delay: 6s
      - script.execute: close_pulldown_menu

  - id: open_pullup_menu
    then:
      - script.stop: player_controls_timeout
      - script.stop: pulldown_timeout
      - script.stop: pullup_timeout
      - lambda: |-
          id(pullup_return_phase) = id(ui_phase);
          id(ui_phase) = ${ui_phase_pullup};
          bool show_album_art = id(pullup_return_phase) == ${ui_phase_player_controls} || id(pullup_return_phase) == ${ui_phase_now_playing};
          if (show_album_art) {
            lv_obj_add_flag(id(pullup_background_widget), LV_OBJ_FLAG_HIDDEN);
            lv_obj_clear_flag(id(pullup_album_art_widget), LV_OBJ_FLAG_HIDDEN);
            lv_obj_clear_flag(id(pullup_album_art_dim), LV_OBJ_FLAG_HIDDEN);
          } else {
            lv_obj_clear_flag(id(pullup_background_widget), LV_OBJ_FLAG_HIDDEN);
            lv_obj_add_flag(id(pullup_album_art_widget), LV_OBJ_FLAG_HIDDEN);
            lv_obj_add_flag(id(pullup_album_art_dim), LV_OBJ_FLAG_HIDDEN);
          }
      - script.execute: standby_screen_activity
      - lvgl.page.show:
          id: pullup_page
          animation: MOVE_TOP
          time: 220ms
      - script.execute: refresh_pullup_state
      - script.execute: pullup_timeout

  - id: close_pullup_menu
    then:
      - script.stop: pullup_timeout
      - lambda: |-
          id(ui_phase) = id(pullup_return_phase);
      - script.execute: standby_screen_activity
      - if:
          condition:
            lambda: 'return id(pullup_return_phase) == ${ui_phase_player_controls};'
          then:
            - lvgl.page.show:
                id: player_controls_page
                animation: MOVE_BOTTOM
                time: 220ms
            - lambda: |-
                lv_label_set_text(id(controls_mute_icon), (id(pullup_player_target) == 1 ? id(sendspin_group_media_player).is_muted() : id(external_media_player).is_muted()) ? "\xF3\xB0\x9D\x9F" : "\xF3\xB0\x95\xBE");
            - script.execute: player_controls_timeout
          else:
            - if:
                condition:
                  lambda: 'return id(pullup_return_phase) == ${ui_phase_now_playing};'
                then:
                  - lvgl.page.show:
                      id: sendspin_page
                      animation: MOVE_BOTTOM
                      time: 220ms
                else:
                  - lvgl.page.show:
                      id: idle_page
                      animation: MOVE_BOTTOM
                      time: 220ms
                  - component.update: weather_line

  - id: pullup_timeout
    mode: restart
    then:
      - delay: 6s
      - script.execute: close_pullup_menu

  - id: refresh_pullup_state
    then:
      - lambda: |-
          bool mic_muted = id(mute).state;
          lv_label_set_text(id(pullup_mute_label), mic_muted ? "\U000F036D" : "\U000F036C");
          lv_obj_set_style_bg_color(id(pullup_mute_button), lv_color_hex(mic_muted ? 0xFF3333 : 0x00CC66), 0);
          lv_obj_set_style_bg_opa(id(pullup_mute_button), LV_OPA_40, 0);

          bool is_group = id(pullup_player_target) == 1;
          lv_label_set_text(id(pullup_target_label), is_group ? "\U000F0849" : "\U000F0004");
          lv_obj_set_style_bg_color(id(pullup_target_button), lv_color_hex(is_group ? 0xFFAA00 : 0x3399FF), 0);
          lv_obj_set_style_bg_opa(id(pullup_target_button), LV_OPA_40, 0);

  - id: play_playlist
    parameters:
      uri: string
    then:
      - if:
          condition:
            lambda: 'return !id(touch_swipe_detected) && !id(playlist_media_player_entity).state.empty() && !uri.empty();'
          then:
            - homeassistant.action:
                action: media_player.play_media
                data:
                  media_content_type: music
                data_template:
                  entity_id: "{{ media_player_entity }}"
                  media_content_id: "{{ media_url }}"
                variables:
                  media_player_entity: !lambda |-
                    return id(playlist_media_player_entity).state;
                  media_url: !lambda |-
                    return uri;

  - id: load_standby_background
    mode: restart
    then:
      - online_image.set_url:
          id: standby_background_image
          url: !lambda |-
            return "https://raw.githubusercontent.com/RealDeco/xiaozhi-esphome/main/images/Other/360x360/" + id(standby_background_choice).current_option().str() + ".png";

  - id: standby_screen_activity
    mode: restart
    then:
      - script.stop: standby_screen_timeout
      - if:
          condition:
            lambda: 'return id(screen_timeout_dimmed);'
          then:
            - light.turn_on:
                id: Sled
                brightness: !lambda 'return id(preferred_screen_brightness);'
            - lambda: |-
                id(screen_timeout_dimmed) = false;
      - if:
          condition:
            and:
              - switch.is_on: standby_screen_timeout_enabled
              - lambda: 'return id(ui_phase) == ${ui_phase_standby};'
              - lambda: 'return id(va_phase) == ${va_phase_idle};'
              - lambda: 'return !id(timer_is_active);'
          then:
            - script.execute: standby_screen_timeout

  - id: standby_screen_timeout
    mode: restart
    then:
      - delay: !lambda |-
          return (uint32_t) (id(standby_screen_timeout_seconds).state * 1000.0f / 3.0f);
      - if:
          condition:
            and:
              - switch.is_on: standby_screen_timeout_enabled
              - lambda: 'return id(ui_phase) == ${ui_phase_standby};'
              - lambda: 'return id(va_phase) == ${va_phase_idle};'
              - lambda: 'return !id(timer_is_active);'
          then:
            - lambda: |-
                id(preferred_screen_brightness) = id(Sled).remote_values.get_brightness();
                id(screen_timeout_dimmed) = true;
            - light.turn_on:
                id: Sled
                brightness: !lambda 'return id(preferred_screen_brightness) * 0.60f;'
          else:
            - script.stop: standby_screen_timeout
      - delay: !lambda |-
          return (uint32_t) (id(standby_screen_timeout_seconds).state * 1000.0f / 3.0f);
      - if:
          condition:
            and:
              - switch.is_on: standby_screen_timeout_enabled
              - lambda: 'return id(ui_phase) == ${ui_phase_standby};'
              - lambda: 'return id(va_phase) == ${va_phase_idle};'
              - lambda: 'return !id(timer_is_active);'
          then:
            - light.turn_on:
                id: Sled
                brightness: !lambda 'return id(preferred_screen_brightness) * 0.25f;'
          else:
            - script.stop: standby_screen_timeout
      - delay: !lambda |-
          return (uint32_t) (id(standby_screen_timeout_seconds).state * 1000.0f / 3.0f);
      - if:
          condition:
            and:
              - switch.is_on: standby_screen_timeout_enabled
              - lambda: 'return id(ui_phase) == ${ui_phase_standby};'
              - lambda: 'return id(va_phase) == ${va_phase_idle};'
              - lambda: 'return !id(timer_is_active);'
          then:
            - light.turn_off: Sled

  - id: now_playing_swipe_left
    then:
      - script.execute:
          id: album_art_slide
          direction: -1
      - media_player.next: sendspin_group_media_player

  - id: now_playing_swipe_right
    then:
      - script.execute:
          id: album_art_slide
          direction: 1
      - script.execute: media_player_previous_with_restart_check

  - id: player_controls_keep_open
    then:
      - script.execute:
          id: set_ui_phase
          phase: ${ui_phase_player_controls}

  - id: player_controls_toggle
    then:
      - delay: 75ms
      - if:
          condition:
            lambda: 'return !id(touch_swipe_detected);'
          then:
            - media_player.toggle: sendspin_group_media_player
            - script.execute:
                id: set_ui_phase
                phase: ${ui_phase_now_playing}

  - id: player_controls_swipe_previous
    then:
      - script.execute:
          id: player_controls_slide_to_now_playing
          direction: 1
      - script.execute: media_player_previous_with_restart_check

  - id: player_controls_swipe_next
    then:
      - script.execute:
          id: player_controls_slide_to_now_playing
          direction: -1
      - media_player.next: sendspin_group_media_player

  - id: player_controls_shuffle_toggle
    mode: restart
    then:
      - lambda: 'id(shuffle_state) = !id(shuffle_state);'
      - script.execute: refresh_shuffle_repeat_buttons
      - script.execute: player_controls_keep_open
      - if:
          condition:
            lambda: 'return !id(playlist_media_player_entity).state.empty();'
          then:
            - if:
                condition:
                  lambda: 'return id(shuffle_state);'
                then:
                  - homeassistant.action:
                      action: media_player.shuffle_set
                      data:
                        shuffle: "true"
                      data_template:
                        entity_id: "{{ media_player_entity }}"
                      variables:
                        media_player_entity: !lambda |-
                          return id(playlist_media_player_entity).state;
                else:
                  - homeassistant.action:
                      action: media_player.shuffle_set
                      data:
                        shuffle: "false"
                      data_template:
                        entity_id: "{{ media_player_entity }}"
                      variables:
                        media_player_entity: !lambda |-
                          return id(playlist_media_player_entity).state;

  - id: player_controls_repeat_cycle
    mode: restart
    then:
      - lambda: 'id(repeat_mode) = (id(repeat_mode) + 1) % 3;'
      - script.execute: refresh_shuffle_repeat_buttons
      - script.execute: player_controls_keep_open
      - if:
          condition:
            lambda: 'return !id(playlist_media_player_entity).state.empty();'
          then:
            - if:
                condition:
                  lambda: 'return id(repeat_mode) == 0;'
                then:
                  - homeassistant.action:
                      action: media_player.repeat_set
                      data:
                        repeat: "off"
                      data_template:
                        entity_id: "{{ media_player_entity }}"
                      variables:
                        media_player_entity: !lambda |-
                          return id(playlist_media_player_entity).state;
            - if:
                condition:
                  lambda: 'return id(repeat_mode) == 1;'
                then:
                  - homeassistant.action:
                      action: media_player.repeat_set
                      data:
                        repeat: "all"
                      data_template:
                        entity_id: "{{ media_player_entity }}"
                      variables:
                        media_player_entity: !lambda |-
                          return id(playlist_media_player_entity).state;
            - if:
                condition:
                  lambda: 'return id(repeat_mode) == 2;'
                then:
                  - homeassistant.action:
                      action: media_player.repeat_set
                      data:
                        repeat: "one"
                      data_template:
                        entity_id: "{{ media_player_entity }}"
                      variables:
                        media_player_entity: !lambda |-
                          return id(playlist_media_player_entity).state;

  - id: refresh_shuffle_repeat_buttons
    mode: restart
    then:
      - lambda: |-
          const uint32_t accent_u32 = ((uint32_t) id(accent_red) << 16) | ((uint32_t) id(accent_green) << 8) | (uint32_t) id(accent_blue);
          const lv_color_t accent = lv_color_hex(accent_u32);
          const uint32_t accent4_u32 = ((uint32_t) id(accent4_red) << 16) | ((uint32_t) id(accent4_green) << 8) | (uint32_t) id(accent4_blue);
          const lv_color_t inactive_color = lv_color_hex(accent4_u32);

          if (id(shuffle_state)) {
            lv_label_set_text(id(controls_shuffle_label), "\U000F049D");
            lv_obj_set_style_text_color(id(controls_shuffle_label), accent, 0);
          } else {
            lv_label_set_text(id(controls_shuffle_label), "\U000F049E");
            lv_obj_set_style_text_color(id(controls_shuffle_label), inactive_color, 0);
          }

          switch (id(repeat_mode)) {
            case 1:
              lv_label_set_text(id(controls_repeat_label), "\U000F0456");
              lv_obj_set_style_text_color(id(controls_repeat_label), accent, 0);
              break;
            case 2:
              lv_label_set_text(id(controls_repeat_label), "\U000F0458");
              lv_obj_set_style_text_color(id(controls_repeat_label), accent, 0);
              break;
            default:
              lv_label_set_text(id(controls_repeat_label), "\U000F0457");
              lv_obj_set_style_text_color(id(controls_repeat_label), inactive_color, 0);
              break;
          }

  - id: player_controls_volume_down
    then:
      - delay: 75ms
      - if:
          condition:
            lambda: 'return !id(touch_swipe_detected);'
          then:
            - lambda: |-
                if (id(pullup_player_target) == 1) {
                  id(sendspin_group_media_player).make_call().set_command(media_player::MEDIA_PLAYER_COMMAND_VOLUME_DOWN).perform();
                } else {
                  id(external_media_player).make_call().set_command(media_player::MEDIA_PLAYER_COMMAND_VOLUME_DOWN).perform();
                }
            - script.execute: show_volume_ring
            - script.execute: player_controls_keep_open

  - id: player_controls_volume_up
    then:
      - delay: 75ms
      - if:
          condition:
            lambda: 'return !id(touch_swipe_detected);'
          then:
            - lambda: |-
                if (id(pullup_player_target) == 1) {
                  id(sendspin_group_media_player).make_call().set_command(media_player::MEDIA_PLAYER_COMMAND_VOLUME_UP).perform();
                } else {
                  id(external_media_player).make_call().set_command(media_player::MEDIA_PLAYER_COMMAND_VOLUME_UP).perform();
                }
            - script.execute: show_volume_ring
            - script.execute: player_controls_keep_open

  - id: player_controls_mute
    then:
      - delay: 75ms
      - if:
          condition:
            lambda: 'return !id(touch_swipe_detected);'
          then:
            - lambda: |-
                bool new_muted;
                if (id(pullup_player_target) == 1) {
                  new_muted = !id(sendspin_group_media_player).is_muted();
                  auto call = id(sendspin_group_media_player).make_call();
                  call.set_command(new_muted ? media_player::MEDIA_PLAYER_COMMAND_MUTE : media_player::MEDIA_PLAYER_COMMAND_UNMUTE);
                  call.perform();
                } else {
                  new_muted = !id(external_media_player).is_muted();
                  auto call = id(external_media_player).make_call();
                  call.set_command(new_muted ? media_player::MEDIA_PLAYER_COMMAND_MUTE : media_player::MEDIA_PLAYER_COMMAND_UNMUTE);
                  call.perform();
                }
                lv_label_set_text(id(controls_mute_icon), new_muted ? "\xF3\xB0\x9D\x9F" : "\xF3\xB0\x95\xBE");
            - script.execute: player_controls_timeout

  - id: player_controls_slide_to_now_playing
    mode: restart
    parameters:
      direction: int
    then:
      - script.stop: player_controls_timeout
      - lambda: |-
          id(ui_phase) = ${ui_phase_now_playing};
      - lvgl.page.show: sendspin_page
      - script.execute:
          id: album_art_slide
          direction: !lambda return direction;

  - id: album_art_slide
    mode: restart
    parameters:
      direction: int
    then:
      - lvgl.animation.stop: album_art_crossfade
      - lambda: |-
          id(album_art_slide_active) = true;
          id(sendspin_album_art_valid) = false;
          id(last_album_art_valid) = false;
          lv_obj_t *outgoing = id(slide_outgoing_art);
          lv_obj_t *incoming = id(slide_incoming_art);
          lv_anim_del(outgoing, nullptr);
          lv_anim_del(incoming, nullptr);
          lv_img_set_src(outgoing, lv_img_get_src(id(incoming_art)));
          lv_img_set_zoom(outgoing, LV_ZOOM_NONE);
          lv_img_set_src(incoming, id(fallback_playing_image));
          lv_img_set_zoom(incoming, LV_ZOOM_NONE);
          lv_obj_set_x(outgoing, 0);
          lv_obj_set_x(incoming, -direction * ${screenwidth});
          lv_obj_clear_flag(outgoing, LV_OBJ_FLAG_HIDDEN);
          lv_obj_clear_flag(incoming, LV_OBJ_FLAG_HIDDEN);

          lv_anim_t current_slide;
          lv_anim_init(&current_slide);
          lv_anim_set_var(&current_slide, outgoing);
          lv_anim_set_values(&current_slide, 0, direction * ${screenwidth});
          lv_anim_set_time(&current_slide, 300);
          lv_anim_set_exec_cb(&current_slide, [](void *obj, int32_t x) {
            lv_obj_set_x((lv_obj_t *) obj, x);
          });
          lv_anim_start(&current_slide);

          lv_anim_t incoming_slide;
          lv_anim_init(&incoming_slide);
          lv_anim_set_var(&incoming_slide, incoming);
          lv_anim_set_values(&incoming_slide, -direction * ${screenwidth}, 0);
          lv_anim_set_time(&incoming_slide, 300);
          lv_anim_set_exec_cb(&incoming_slide, [](void *obj, int32_t x) {
            lv_obj_set_x((lv_obj_t *) obj, x);
          });
          lv_anim_start(&incoming_slide);
      - delay: 300ms
      - lambda: |-
          lv_obj_add_flag(id(slide_outgoing_art), LV_OBJ_FLAG_HIDDEN);
          lv_obj_add_flag(id(slide_incoming_art), LV_OBJ_FLAG_HIDDEN);
          lv_img_set_src(id(incoming_art), id(fallback_playing_image));
          lv_img_set_zoom(id(incoming_art), LV_ZOOM_NONE);
          id(album_art_slide_active) = false;
          id(album_art_awaiting_swipe_art) = true;

  - id: refresh_artist_art_dot
    then:
      - lambda: |-
          if (id(artist_art_valid) && !id(now_playing_art_mode)) {
            lv_obj_clear_flag(id(artist_art_dot), LV_OBJ_FLAG_HIDDEN);
          } else {
            lv_obj_add_flag(id(artist_art_dot), LV_OBJ_FLAG_HIDDEN);
          }

  - id: now_playing_show_artist_art
    mode: restart
    then:
      - lvgl.animation.stop: album_art_crossfade
      - lambda: |-
          lv_obj_set_style_opa(id(outgoing_art), LV_OPA_TRANSP, 0);
          lv_obj_t *outgoing = id(slide_outgoing_art);
          lv_obj_t *incoming = id(slide_incoming_art);
          lv_anim_del(outgoing, nullptr);
          lv_anim_del(incoming, nullptr);
          lv_img_set_src(outgoing, lv_img_get_src(id(incoming_art)));
          lv_img_set_zoom(outgoing, LV_ZOOM_NONE);
          lv_img_set_src(incoming, id(artist_art)->get_lv_image_dsc());
          lv_img_set_zoom(incoming, LV_ZOOM_NONE);
          lv_obj_set_x(outgoing, 0);
          lv_obj_set_x(incoming, 0);
          lv_obj_set_y(outgoing, 0);
          lv_obj_set_y(incoming, -${screenheight});
          lv_obj_clear_flag(outgoing, LV_OBJ_FLAG_HIDDEN);
          lv_obj_clear_flag(incoming, LV_OBJ_FLAG_HIDDEN);

          lv_anim_t current_slide;
          lv_anim_init(&current_slide);
          lv_anim_set_var(&current_slide, outgoing);
          lv_anim_set_values(&current_slide, 0, ${screenheight});
          lv_anim_set_time(&current_slide, 300);
          lv_anim_set_exec_cb(&current_slide, [](void *obj, int32_t y) {
            lv_obj_set_y((lv_obj_t *) obj, y);
          });
          lv_anim_start(&current_slide);

          lv_anim_t incoming_slide;
          lv_anim_init(&incoming_slide);
          lv_anim_set_var(&incoming_slide, incoming);
          lv_anim_set_values(&incoming_slide, -${screenheight}, 0);
          lv_anim_set_time(&incoming_slide, 300);
          lv_anim_set_exec_cb(&incoming_slide, [](void *obj, int32_t y) {
            lv_obj_set_y((lv_obj_t *) obj, y);
          });
          lv_anim_start(&incoming_slide);
          id(now_playing_art_mode) = true;
          id(refresh_artist_art_dot)->execute();
      - delay: 300ms
      - lambda: |-
          lv_obj_add_flag(id(slide_outgoing_art), LV_OBJ_FLAG_HIDDEN);
          lv_obj_add_flag(id(slide_incoming_art), LV_OBJ_FLAG_HIDDEN);
          lv_img_set_src(id(incoming_art), id(artist_art)->get_lv_image_dsc());
          lv_img_set_zoom(id(incoming_art), LV_ZOOM_NONE);

  - id: now_playing_show_album_art
    mode: restart
    then:
      - lvgl.animation.stop: album_art_crossfade
      - lambda: |-
          lv_obj_set_style_opa(id(outgoing_art), LV_OPA_TRANSP, 0);
          lv_obj_t *outgoing = id(slide_outgoing_art);
          lv_obj_t *incoming = id(slide_incoming_art);
          lv_anim_del(outgoing, nullptr);
          lv_anim_del(incoming, nullptr);
          lv_img_set_src(outgoing, lv_img_get_src(id(incoming_art)));
          lv_img_set_zoom(outgoing, LV_ZOOM_NONE);
          lv_img_set_src(incoming, id(sendspin_album_art_valid) ? id(sendspin_album_art)->get_lv_image_dsc() : id(fallback_playing_image)->get_lv_image_dsc());
          lv_img_set_zoom(incoming, LV_ZOOM_NONE);
          lv_obj_set_x(outgoing, 0);
          lv_obj_set_x(incoming, 0);
          lv_obj_set_y(outgoing, 0);
          lv_obj_set_y(incoming, ${screenheight});
          lv_obj_clear_flag(outgoing, LV_OBJ_FLAG_HIDDEN);
          lv_obj_clear_flag(incoming, LV_OBJ_FLAG_HIDDEN);

          lv_anim_t current_slide;
          lv_anim_init(&current_slide);
          lv_anim_set_var(&current_slide, outgoing);
          lv_anim_set_values(&current_slide, 0, -${screenheight});
          lv_anim_set_time(&current_slide, 300);
          lv_anim_set_exec_cb(&current_slide, [](void *obj, int32_t y) {
            lv_obj_set_y((lv_obj_t *) obj, y);
          });
          lv_anim_start(&current_slide);

          lv_anim_t incoming_slide;
          lv_anim_init(&incoming_slide);
          lv_anim_set_var(&incoming_slide, incoming);
          lv_anim_set_values(&incoming_slide, ${screenheight}, 0);
          lv_anim_set_time(&incoming_slide, 300);
          lv_anim_set_exec_cb(&incoming_slide, [](void *obj, int32_t y) {
            lv_obj_set_y((lv_obj_t *) obj, y);
          });
          lv_anim_start(&incoming_slide);
          id(now_playing_art_mode) = false;
          id(refresh_artist_art_dot)->execute();
      - delay: 300ms
      - lambda: |-
          lv_obj_add_flag(id(slide_outgoing_art), LV_OBJ_FLAG_HIDDEN);
          lv_obj_add_flag(id(slide_incoming_art), LV_OBJ_FLAG_HIDDEN);
          lv_img_set_src(id(incoming_art), id(sendspin_album_art_valid) ? id(sendspin_album_art)->get_lv_image_dsc() : id(fallback_playing_image)->get_lv_image_dsc());
          lv_img_set_zoom(id(incoming_art), LV_ZOOM_NONE);

  - id: media_player_previous_with_restart_check
    then:
      - lambda: |-
          id(pre_previous_swipe_title) = id(sendspin_title).state;
      - media_player.previous: sendspin_group_media_player
      - script.execute: restore_art_if_same_title

  - id: restore_art_if_same_title
    mode: restart
    then:
      - delay: 1200ms
      - if:
          condition:
            lambda: 'return id(sendspin_title).state == id(pre_previous_swipe_title);'
          then:
            - lambda: |-
                id(sendspin_album_art_valid) = true;
                id(album_art_awaiting_swipe_art) = false;
            - script.execute: album_art_crossfade_apply

  - id: album_art_crossfade_apply
    mode: restart
    then:
      - lambda: |-
          id(now_playing_art_mode) = false;
          id(refresh_artist_art_dot)->execute();
      - if:
          condition:
            lambda: 'return id(last_album_art_valid);'
          then:
            - lvgl.image.update:
                id: outgoing_art
                src: sendspin_album_art_transition
          else:
            - lvgl.image.update:
                id: outgoing_art
                src: fallback_playing_image
      - lvgl.image.update:
          id: incoming_art
          src: sendspin_album_art
      - lvgl.animation.start: album_art_crossfade


  - id: refresh_timer_display
    mode: restart
    then:
      - lambda: |-
          bool found = false;
          int left = 0;
          int total = 0;
          for (auto &timer : id(va).get_timers()) {
            if (!timer.is_active) continue;
            if (!found || timer.seconds_left < left) {
              found = true;
              left = timer.seconds_left;
              total = timer.total_seconds;
            }
          }
          id(timer_is_active) = found;
          id(timer_seconds_left) = found ? left : 0;
          id(timer_total_seconds) = found && total > 0 ? total : 0;
          id(timer_seconds_remaining).publish_state(id(timer_seconds_left));

          static constexpr int TIMER_SEGMENTS = 144;

          if (id(timer_progress_arc) == nullptr) {
            id(timer_progress_arc) = lv_obj_create(lv_layer_top());
            lv_obj_remove_style_all(id(timer_progress_arc));
            lv_obj_set_size(id(timer_progress_arc), ${screenwidth}, ${screenheight});
            lv_obj_center(id(timer_progress_arc));
            lv_obj_clear_flag(id(timer_progress_arc), LV_OBJ_FLAG_CLICKABLE);
            lv_obj_clear_flag(id(timer_progress_arc), LV_OBJ_FLAG_SCROLLABLE);
            const float cx = ${screenwidth} / 2.0f;
            const float cy = ${screenheight} / 2.0f;
            const float radius = 169.0f;
            for (int i = 0; i < TIMER_SEGMENTS; i++) {
              const float angle = (-90.0f + (360.0f * i / TIMER_SEGMENTS)) * (float) M_PI / 180.0f;
              lv_obj_t *seg = lv_obj_create(id(timer_progress_arc));
              lv_obj_remove_style_all(seg);
              lv_obj_set_size(seg, 6, 6);
              lv_obj_set_style_radius(seg, LV_RADIUS_CIRCLE, 0);
              lv_obj_set_style_bg_opa(seg, LV_OPA_COVER, 0);
              lv_obj_set_pos(seg, (int) lroundf(cx + cosf(angle) * radius - 3.0f),
                             (int) lroundf(cy + sinf(angle) * radius - 3.0f));
              lv_obj_clear_flag(seg, LV_OBJ_FLAG_CLICKABLE);
              lv_obj_clear_flag(seg, LV_OBJ_FLAG_SCROLLABLE);
            }
          }

          if (!found || total <= 0) {
            lv_obj_add_flag(id(timer_progress_arc), LV_OBJ_FLAG_HIDDEN);
            lv_obj_add_flag(id(hourglass_canvas), LV_OBJ_FLAG_HIDDEN);
            lv_obj_add_flag(id(idle_timer_block), LV_OBJ_FLAG_HIDDEN);
            lv_obj_clear_flag(id(idle_center_block), LV_OBJ_FLAG_HIDDEN);
            if (id(standby_background_enabled).state) {
              lv_obj_clear_flag(id(standby_background_widget), LV_OBJ_FLAG_HIDDEN);
              lv_obj_move_background(id(standby_background_widget));
            }
            return;
          }

          float remain = (float) left / (float) total;
          if (remain < 0.0f) remain = 0.0f;
          if (remain > 1.0f) remain = 1.0f;

          int active_segments = (int) lroundf((float) left * (float) TIMER_SEGMENTS / (float) total);
          active_segments = std::max(0, std::min(TIMER_SEGMENTS, active_segments));
          for (int i = 0; i < TIMER_SEGMENTS; i++) {
            lv_obj_t *seg = lv_obj_get_child(id(timer_progress_arc), i);
            if (seg == nullptr) continue;
            const bool active = i < active_segments;
            lv_obj_set_style_bg_color(seg, active ? lv_color_hex(0xFFFFFF) : lv_color_hex(0x242424), 0);
            lv_obj_set_style_bg_opa(seg, active ? LV_OPA_COVER : 115, 0);
          }
          lv_obj_clear_flag(id(timer_progress_arc), LV_OBJ_FLAG_HIDDEN);
          lv_obj_move_foreground(id(timer_progress_arc));

          lv_obj_t *canvas = id(hourglass_canvas);
          lv_canvas_fill_bg(canvas, lv_color_hex(0x000000), LV_OPA_TRANSP);

          lv_layer_t layer;
          lv_canvas_init_layer(canvas, &layer);

          auto fill_rect = [&](int x, int y, int w, int h, lv_color_t color, int radius) {
            lv_draw_rect_dsc_t dsc;
            lv_draw_rect_dsc_init(&dsc);
            dsc.bg_color = color;
            dsc.bg_opa = LV_OPA_COVER;
            dsc.border_width = 0;
            dsc.radius = radius;
            lv_area_t area{x, y, x + w - 1, y + h - 1};
            lv_draw_rect(&layer, &dsc, &area);
          };
          auto fill_circle = [&](int cx, int cy, int r, lv_color_t color) {
            fill_rect(cx - r, cy - r, r * 2 + 1, r * 2 + 1, color, LV_RADIUS_CIRCLE);
          };
          auto thick_line = [&](int x1, int y1, int x2, int y2, lv_color_t color, int width) {
            lv_draw_line_dsc_t dsc;
            lv_draw_line_dsc_init(&dsc);
            dsc.color = color;
            dsc.width = width;
            dsc.opa = LV_OPA_COVER;
            dsc.round_start = true;
            dsc.round_end = true;
            dsc.p1.x = x1; dsc.p1.y = y1;
            dsc.p2.x = x2; dsc.p2.y = y2;
            lv_draw_line(&layer, &dsc);
          };

          const lv_color_t outline_c  = lv_color_hex(0xFFFFFF);
          const lv_color_t sand_c     = lv_color_hex(0xFFC107);
          const lv_color_t sand_dim_c = lv_color_hex(0xB88812);

          const int hourglass_w = 100, hourglass_h = 190;
          const int hg_x = 64;
          const int hg_y = (360 - hourglass_h) / 2;
          const int hg_cx = hg_x + hourglass_w / 2;
          const int hg_top = hg_y;
          const int hg_mid = hg_y + hourglass_h / 2;
          const int hg_bot = hg_y + hourglass_h;
          const int rim_h = 6;
          const int throat_w = 8;
          const int chamber_pad = 8;
          const int top_inner_y = hg_top + rim_h + 2;
          const int bot_inner_y = hg_bot - rim_h - 2;
          const int line_thick = 2;
          const int drop_radius = 3;

          fill_rect(hg_x, hg_top, hourglass_w, rim_h, outline_c, 0);
          fill_rect(hg_x, hg_bot - rim_h, hourglass_w, rim_h, outline_c, 0);

          thick_line(hg_x + chamber_pad, top_inner_y, hg_cx - throat_w / 2, hg_mid, outline_c, line_thick);
          thick_line(hg_x + hourglass_w - chamber_pad, top_inner_y, hg_cx + throat_w / 2, hg_mid, outline_c, line_thick);
          thick_line(hg_cx - throat_w / 2, hg_mid, hg_x + chamber_pad, bot_inner_y, outline_c, line_thick);
          thick_line(hg_cx + throat_w / 2, hg_mid, hg_x + hourglass_w - chamber_pad, bot_inner_y, outline_c, line_thick);

          const int top_chamber_h = std::max(1, hg_mid - top_inner_y - 3);
          const int bot_chamber_h = std::max(1, bot_inner_y - hg_mid - 3);
          const int top_surface_y = top_inner_y + (int) lroundf(top_chamber_h * (1.0f - remain));
          const int bot_fill_h    = (int) lroundf(bot_chamber_h * (1.0f - remain));
          const int wide = hourglass_w / 2 - chamber_pad;
          const int neck = std::max(throat_w / 2, 2);

          if (remain > 0.0f) {
            const int y_end   = hg_mid - 4;
            const int y_start = std::min(std::max(top_surface_y, top_inner_y), y_end);
            for (int y = y_start; y <= y_end; ++y) {
              float t = (float) (y - top_inner_y) / (float) std::max(1, hg_mid - top_inner_y);
              t = std::min(1.0f, std::max(0.0f, t));
              int hw = wide - (int) lroundf((wide - neck) * t);
              lv_color_t col = (y == y_start) ? sand_c : sand_dim_c;
              fill_rect(hg_cx - hw, y, hw * 2 + 1, 1, col, 0);
            }
          }

          if (bot_fill_h > 0) {
            const int y_end   = bot_inner_y;
            const int y_start = std::max(hg_mid + 4, bot_inner_y - bot_fill_h);
            for (int y = y_start; y <= y_end; ++y) {
              float t = (float) (y - hg_mid) / (float) std::max(1, bot_inner_y - hg_mid);
              t = std::min(1.0f, std::max(0.0f, t));
              int hw = neck + (int) lroundf((wide - neck) * t);
              lv_color_t col = (y > y_start + (y_end - y_start) / 2) ? sand_dim_c : sand_c;
              fill_rect(hg_cx - hw, y, hw * 2 + 1, 1, col, 0);
            }
          }

          if (remain > 0.0f) {
            const int drop_top = hg_mid + 3;
            const int bottom_surface_y = (bot_fill_h > 0) ? std::max(hg_mid + 5, bot_inner_y - bot_fill_h) : bot_inner_y;
            const int drop_bot   = std::max(drop_top + 1, bottom_surface_y - 2);
            const int drop_steps = std::max(4, std::min(10, (drop_bot - drop_top) / 3));
            const uint32_t elapsed_seconds = total > left ? (uint32_t) (total - left) : 0;
            const int drop_step        = elapsed_seconds % drop_steps;
            const int second_drop_step = (drop_step + drop_steps / 2) % drop_steps;
            const int drop_y1 = drop_top + ((drop_bot - drop_top) * drop_step)        / std::max(1, drop_steps - 1);
            const int drop_y2 = drop_top + ((drop_bot - drop_top) * second_drop_step) / std::max(1, drop_steps - 1);
            fill_circle(hg_cx, drop_y1, drop_radius, sand_c);
            fill_circle(hg_cx, drop_y2, drop_radius, sand_dim_c);
          }

          lv_canvas_finish_layer(canvas, &layer);
          lv_obj_clear_flag(id(hourglass_canvas), LV_OBJ_FLAG_HIDDEN);

          char text[16];
          if (left >= 3600) {
            snprintf(text, sizeof(text), "%d:%02d:%02d", left / 3600, (left / 60) % 60, left % 60);
          } else {
            snprintf(text, sizeof(text), "%d:%02d", left / 60, left % 60);
          }
          lv_label_set_text(id(idle_timer_countdown), text);

          if (id(va_phase) == ${va_phase_idle}) {
            lv_obj_add_flag(id(standby_background_widget), LV_OBJ_FLAG_HIDDEN);
            lv_obj_add_flag(id(idle_center_block), LV_OBJ_FLAG_HIDDEN);
            lv_obj_clear_flag(id(idle_timer_block), LV_OBJ_FLAG_HIDDEN);
            lv_obj_move_foreground(id(idle_timer_block));
          } else {
            lv_obj_add_flag(id(idle_timer_block), LV_OBJ_FLAG_HIDDEN);
          }

  - id: show_volume_ring
    mode: restart
    then:
      - script.stop: hide_volume_ring
      - lambda: |-
          if (id(volume_ring_overlay) == nullptr) {
            id(volume_ring_overlay) = lv_obj_create(lv_layer_top());
            lv_obj_remove_style_all(id(volume_ring_overlay));
            lv_obj_set_size(id(volume_ring_overlay), 150, 150);
            lv_obj_center(id(volume_ring_overlay));
            lv_obj_set_style_bg_color(id(volume_ring_overlay), lv_color_hex(0x000000), 0);
            lv_obj_set_style_bg_opa(id(volume_ring_overlay), 230, 0);
            lv_obj_set_style_border_width(id(volume_ring_overlay), 3, 0);
            lv_obj_set_style_radius(id(volume_ring_overlay), LV_RADIUS_CIRCLE, 0);
            lv_obj_clear_flag(id(volume_ring_overlay), LV_OBJ_FLAG_CLICKABLE);
            lv_obj_clear_flag(id(volume_ring_overlay), LV_OBJ_FLAG_SCROLLABLE);

            id(volume_ring_label) = lv_label_create(id(volume_ring_overlay));
            lv_obj_set_width(id(volume_ring_label), 130);
            lv_obj_center(id(volume_ring_label));
            lv_obj_set_style_text_align(id(volume_ring_label), LV_TEXT_ALIGN_CENTER, 0);
            lv_obj_set_style_text_font(id(volume_ring_label), id(font_7seg), 0);
          }

          const float raw_volume = (id(pullup_player_target) == 1) ? id(sendspin_group_media_player).volume : id(external_media_player).volume;
          const float volume = std::max(0.0f, std::min(1.0f, raw_volume));
          const int pct = (int) lroundf(volume * 100.0f);
          const uint32_t accent_u32 = ((uint32_t) id(accent_red) << 16) | ((uint32_t) id(accent_green) << 8) | (uint32_t) id(accent_blue);
          const lv_color_t accent = lv_color_hex(accent_u32);
          const uint32_t accent4_u32 = ((uint32_t) id(accent4_red) << 16) | ((uint32_t) id(accent4_green) << 8) | (uint32_t) id(accent4_blue);
          const lv_color_t accent4 = lv_color_hex(accent4_u32);
          lv_obj_set_style_border_color(id(volume_ring_overlay), accent, 0);
          lv_obj_set_style_text_color(id(volume_ring_label), accent4, 0);
          char text[8];
          snprintf(text, sizeof(text), "%d", pct);
          lv_label_set_text(id(volume_ring_label), text);
          lv_obj_set_style_opa(id(volume_ring_overlay), LV_OPA_COVER, 0);
          lv_obj_clear_flag(id(volume_ring_overlay), LV_OBJ_FLAG_HIDDEN);
          lv_obj_move_foreground(id(volume_ring_overlay));
      - script.execute: hide_volume_ring

  - id: hide_volume_ring
    mode: restart
    then:
      - delay: 900ms
      - lambda: |-
          if (id(volume_ring_overlay) == nullptr) return;
          lv_anim_del(id(volume_ring_overlay), nullptr);
          lv_anim_t fade;
          lv_anim_init(&fade);
          lv_anim_set_var(&fade, id(volume_ring_overlay));
          lv_anim_set_values(&fade, LV_OPA_COVER, LV_OPA_TRANSP);
          lv_anim_set_time(&fade, 260);
          lv_anim_set_exec_cb(&fade, [](void *obj, int32_t opacity) {
            lv_obj_set_style_opa((lv_obj_t *) obj, opacity, 0);
          });
          lv_anim_start(&fade);
      - delay: 280ms
      - lambda: |-
          if (id(volume_ring_overlay) != nullptr) {
            lv_obj_add_flag(id(volume_ring_overlay), LV_OBJ_FLAG_HIDDEN);
          }


  - id: album_art_show_fallback
    mode: restart
    then:
      - if:
          condition:
            lambda: 'return !id(album_art_slide_active);'
          then:
            - if:
                condition:
                  lambda: 'return id(last_album_art_valid);'
                then:
                  - lvgl.image.update:
                      id: outgoing_art
                      src: sendspin_album_art_transition
                else:
                  - lvgl.image.update:
                      id: outgoing_art
                      src: fallback_playing_image
            - lvgl.image.update:
                id: incoming_art
                src: fallback_playing_image
            - lvgl.animation.start: album_art_crossfade
            - lvgl.image.update:
                id: controls_album_art_widget
                src: fallback_playing_image
            - lvgl.image.update:
                id: playlist_album_art_widget
                src: fallback_playing_image
            - lvgl.image.update:
                id: pullup_album_art_widget
                src: fallback_playing_image
            - lvgl.image.update:
                id: pulldown_album_art_widget
                src: fallback_playing_image

  - id: ring_timer
    then:
      - script.execute: enable_repeat_one
      - script.execute:
          id: play_sound
          priority: true
          sound_file: "timer_finished_sound"

  - id: enable_repeat_one
    then:
      - media_player.repeat_one:
          id: external_media_player
          announcement: true
      - speaker_source.set_playlist_delay:
          id: external_media_player
          pipeline: announcement
          delay: 500ms

  - id: disable_repeat
    then:
      - media_player.repeat_off:
          id: external_media_player
          announcement: true
      - speaker_source.set_playlist_delay:
          id: external_media_player
          pipeline: announcement
          delay: 0ms

  - id: activate_stop_word_once
    mode: restart
    then:
      - wait_until:
          condition:
            media_player.is_announcing:
              id: external_media_player
          timeout: 5s
      - delay: 1s
      - if:
          condition:
            switch.is_off: timer_ringing
          then:
            - micro_wake_word.enable_model: stop
      - wait_until:
          not:
            media_player.is_announcing:
              id: external_media_player
      - if:
          condition:
            switch.is_off: timer_ringing
          then:
            - micro_wake_word.disable_model: stop

  - 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;'

      - 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();
          }

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

  - platform: template
    id: accent_color_r
    type: float
    write_action:
      - lambda: |-
          id(accent_red) = (int) lroundf(std::max(0.0f, std::min(1.0f, state)) * 255.0f);
      - script.execute: apply_accent_color_debounced

  - platform: template
    id: accent_color_g
    type: float
    write_action:
      - lambda: |-
          id(accent_green) = (int) lroundf(std::max(0.0f, std::min(1.0f, state)) * 255.0f);
      - script.execute: apply_accent_color_debounced

  - platform: template
    id: accent_color_b
    type: float
    write_action:
      - lambda: |-
          id(accent_blue) = (int) lroundf(std::max(0.0f, std::min(1.0f, state)) * 255.0f);
      - script.execute: apply_accent_color_debounced

  - platform: template
    id: accent_color2_r
    type: float
    write_action:
      - lambda: |-
          id(accent2_red) = (int) lroundf(std::max(0.0f, std::min(1.0f, state)) * 255.0f);
      - script.execute: apply_accent_color_debounced

  - platform: template
    id: accent_color2_g
    type: float
    write_action:
      - lambda: |-
          id(accent2_green) = (int) lroundf(std::max(0.0f, std::min(1.0f, state)) * 255.0f);
      - script.execute: apply_accent_color_debounced

  - platform: template
    id: accent_color2_b
    type: float
    write_action:
      - lambda: |-
          id(accent2_blue) = (int) lroundf(std::max(0.0f, std::min(1.0f, state)) * 255.0f);
      - script.execute: apply_accent_color_debounced

  - platform: template
    id: accent_color3_r
    type: float
    write_action:
      - lambda: |-
          id(accent3_red) = (int) lroundf(std::max(0.0f, std::min(1.0f, state)) * 255.0f);
      - script.execute: apply_accent_color_debounced

  - platform: template
    id: accent_color3_g
    type: float
    write_action:
      - lambda: |-
          id(accent3_green) = (int) lroundf(std::max(0.0f, std::min(1.0f, state)) * 255.0f);
      - script.execute: apply_accent_color_debounced

  - platform: template
    id: accent_color3_b
    type: float
    write_action:
      - lambda: |-
          id(accent3_blue) = (int) lroundf(std::max(0.0f, std::min(1.0f, state)) * 255.0f);
      - script.execute: apply_accent_color_debounced

  - platform: template
    id: accent_color4_r
    type: float
    write_action:
      - lambda: |-
          id(accent4_red) = (int) lroundf(std::max(0.0f, std::min(1.0f, state)) * 255.0f);
      - script.execute: apply_accent_color_debounced

  - platform: template
    id: accent_color4_g
    type: float
    write_action:
      - lambda: |-
          id(accent4_green) = (int) lroundf(std::max(0.0f, std::min(1.0f, state)) * 255.0f);
      - script.execute: apply_accent_color_debounced

  - platform: template
    id: accent_color4_b
    type: float
    write_action:
      - lambda: |-
          id(accent4_blue) = (int) lroundf(std::max(0.0f, std::min(1.0f, state)) * 255.0f);
      - script.execute: apply_accent_color_debounced

light:
  - platform: rgb
    id: accent_color_picker
    name: Accent color 1
    icon: mdi:palette
    entity_category: config
    red: accent_color_r
    green: accent_color_g
    blue: accent_color_b
    restore_mode: RESTORE_DEFAULT_ON
    default_transition_length: 0s

  - platform: rgb
    id: accent_color_picker_2
    name: Accent color 2
    icon: mdi:palette
    entity_category: config
    red: accent_color2_r
    green: accent_color2_g
    blue: accent_color2_b
    restore_mode: RESTORE_DEFAULT_ON
    default_transition_length: 0s

  - platform: rgb
    id: accent_color_picker_3
    name: Accent color 3
    icon: mdi:palette
    entity_category: config
    red: accent_color3_r
    green: accent_color3_g
    blue: accent_color3_b
    restore_mode: RESTORE_DEFAULT_ON
    default_transition_length: 0s

  - platform: rgb
    id: accent_color_picker_4
    name: Accent color 4
    icon: mdi:palette
    entity_category: config
    red: accent_color4_r
    green: accent_color4_g
    blue: accent_color4_b
    restore_mode: RESTORE_DEFAULT_ON
    default_transition_length: 0s

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

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

  animations:
    - id: album_art_crossfade
      duration: ${album_fade_time}
      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:
            id: boot_sound_label
            align: CENTER
            text: "Loading..."
            text_color: 0xFFFFFF
            text_font: font_boot
            width: 230
            long_mode: WRAP
            text_align: CENTER

    - id: va_page
      bg_color: 0x000000
      widgets:
        - image:
            id: va_phase_image_widget
            align: TOP_MID
            x: 0
            y: 0
            src: va_waiting_image1
            width: ${imagewidth}
            height: ${imageheight}
            antialias: false
            clickable: false


        - obj:
            id: va_status_block
            align: TOP_MID
            x: 0
            y: 0
            width: ${imagewidth}
            height: ${imageheight}
            bg_color: 0x000000
            bg_opa: COVER
            border_opa: TRANSP
            pad_all: 0
            scrollable: false
            hidden: true
            widgets:
              - label:
                  id: va_status_icon_glow
                  align: CENTER
                  x: 1
                  y: -61
                  text: "\U000F0026"
                  text_color: 0x803333
                  text_font: font_mdi_status

              - label:
                  id: va_status_icon
                  align: CENTER
                  y: -62
                  text: "\U000F0026"
                  text_color: 0xFF6666
                  text_font: font_mdi_status

              - label:
                  id: va_status_title
                  align: CENTER
                  y: 80
                  width: 280
                  text: "ERROR"
                  text_color: 0xFF6666
                  text_font: font_boot
                  text_align: CENTER

              - label:
                  id: va_status_subtitle
                  align: CENTER
                  y: 116
                  width: 260
                  text: "Something went wrong"
                  text_color: 0xCCCCCC
                  text_font: font_display
                  text_align: CENTER

    - id: idle_page
      bg_color: 0x000000
      widgets:
        - image:
            id: standby_background_widget
            align: TOP_MID
            x: 0
            y: 0
            src: standby_background_image
            width: ${imagewidth}
            height: ${imageheight}
            antialias: false
            clickable: false
            hidden: true

        - obj:
            id: idle_center_block
            align: CENTER
            y: 18
            width: 310
            height: 300
            bg_opa: TRANSP
            border_opa: TRANSP
            pad_all: 0
            scrollable: false
            widgets:
              - label:
                  id: clock_label
                  align: TOP_MID
                  y: 18
                  width: 310
                  text: "--:--:--"
                  text_color: 0xFFFF00
                  text_font: font_7seg
                  text_letter_space: 0
                  text_align: CENTER

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

              - obj:
                  id: weather_header
                  align: TOP_MID
                  y: 130
                  width: 310
                  height: 100
                  bg_opa: TRANSP
                  border_opa: TRANSP
                  pad_all: 0
                  scrollable: false
                  widgets:
                    - label:
                        id: weather_icon_glow
                        align: CENTER
                        x: -54
                        y: 1
                        text: "\U000F0590"
                        text_color: 0x404040
                        text_font: font_mdi_weather

                    - label:
                        id: weather_icon
                        align: CENTER
                        x: -55
                        y: 0
                        text: "\U000F0590"
                        text_color: 0xFFFFFF
                        text_font: font_mdi_weather

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

                    - label:
                        id: weather_big_temp_unit
                        align: CENTER
                        x: 102
                        y: -20
                        text: "\u00B0C"
                        text_color: 0xFFFF00
                        text_font: font_temp_unit

              - label:
                  id: weather_label
                  align: TOP_MID
                  x: 0
                  y: 224
                  width: 285
                  height: 56
                  text: "Weather..."
                  text_color: 0x66CCFF
                  text_font: font_display
                  long_mode: WRAP
                  text_align: CENTER

        - label:
            id: clock_ampm_label
            x: 0
            y: 0
            text: ""
            text_color: 0xFFFF00
            text_font: font_ampm
            hidden: true

        - label:
            id: analog_weather_icon
            align: CENTER
            x: -105
            y: 0
            text: ""
            text_color: 0xFFFFFF
            text_font: font_mdi_weather_small
            hidden: true

        - label:
            id: analog_weather_temp
            align: CENTER
            x: 105
            y: 0
            text: ""
            text_color: 0xFFFF00
            text_font: font_display_medium
            text_align: CENTER
            hidden: true

        - label:
            id: rooms_clock_label
            align: TOP_MID
            y: 24
            width: 310
            text: "--:--"
            text_color: 0x66CCFF
            text_font: font_7seg_small
            text_letter_space: 0
            text_align: CENTER
            hidden: true

        - label:
            id: rooms_date_label
            align: TOP_MID
            y: 278
            width: 280
            text: "------"
            text_color: 0xFFFFFF
            text_font: font_date
            text_align: CENTER
            hidden: true

        - obj:
            id: room1_box
            align: TOP_LEFT
            x: 50
            y: 85
            width: 100
            height: 90
            radius: 12
            border_width: 2
            border_color: 0xFFFF00
            bg_color: 0x4FC3F7
            bg_opa: TRANSP
            pad_all: 0
            scrollable: false
            clickable: false
            hidden: true
            widgets:
              - label:
                  id: room1_icon
                  align: CENTER
                  text: "\U000F0004"
                  text_color: 0xFFFFFF
                  text_font: font_mdi_rooms
                  hidden: true

        - obj:
            id: room2_box
            align: TOP_LEFT
            x: 50
            y: 180
            width: 100
            height: 90
            radius: 12
            border_width: 2
            border_color: 0xFFFF00
            bg_color: 0xE53935
            bg_opa: TRANSP
            pad_all: 0
            scrollable: false
            clickable: false
            hidden: true
            widgets:
              - label:
                  id: room2_icon
                  align: CENTER
                  text: "\U000F0004"
                  text_color: 0xFFFFFF
                  text_font: font_mdi_rooms
                  hidden: true

        - obj:
            id: room3_box
            align: TOP_LEFT
            x: 156
            y: 85
            width: 58
            height: 185
            radius: 12
            border_width: 2
            border_color: 0xFFFF00
            bg_color: 0xFDD835
            bg_opa: TRANSP
            pad_all: 0
            scrollable: false
            clickable: false
            hidden: true
            widgets:
              - label:
                  id: room3_icon
                  align: CENTER
                  text: "\U000F0004"
                  text_color: 0xFFFFFF
                  text_font: font_mdi_rooms
                  hidden: true

        - obj:
            id: room4_box
            align: TOP_LEFT
            x: 220
            y: 85
            width: 90
            height: 58
            radius: 12
            border_width: 2
            border_color: 0xFFFF00
            bg_color: 0xBA68C8
            bg_opa: TRANSP
            pad_all: 0
            scrollable: false
            clickable: false
            hidden: true
            widgets:
              - label:
                  id: room4_icon
                  align: CENTER
                  text: "\U000F0004"
                  text_color: 0xFFFFFF
                  text_font: font_mdi_rooms
                  hidden: true

        - obj:
            id: room5_box
            align: TOP_LEFT
            x: 220
            y: 148
            width: 90
            height: 58
            radius: 12
            border_width: 2
            border_color: 0xFFFF00
            bg_color: 0x1A237E
            bg_opa: TRANSP
            pad_all: 0
            scrollable: false
            clickable: false
            hidden: true
            widgets:
              - label:
                  id: room5_icon
                  align: CENTER
                  text: "\U000F0004"
                  text_color: 0xFFFFFF
                  text_font: font_mdi_rooms
                  hidden: true

        - obj:
            id: room6_box
            align: TOP_LEFT
            x: 220
            y: 211
            width: 90
            height: 59
            radius: 12
            border_width: 2
            border_color: 0xFFFF00
            bg_color: 0x81C784
            bg_opa: TRANSP
            pad_all: 0
            scrollable: false
            clickable: false
            hidden: true
            widgets:
              - label:
                  id: room6_icon
                  align: CENTER
                  text: "\U000F0004"
                  text_color: 0xFFFFFF
                  text_font: font_mdi_rooms
                  hidden: true

        - canvas:
            id: hourglass_canvas
            align: TOP_MID
            x: 0
            y: 0
            width: ${imagewidth}
            height: ${imageheight}
            transparent: true
            clickable: false
            scrollable: false
            hidden: true

        - obj:
            id: idle_timer_block
            align: CENTER
            x: 70
            y: 0
            width: 160
            height: 60
            bg_opa: TRANSP
            border_opa: TRANSP
            pad_all: 0
            scrollable: false
            hidden: true
            widgets:
              - label:
                  id: idle_timer_countdown
                  align: CENTER
                  width: 160
                  text: "0:00"
                  text_color: 0xFFFF00
                  text_font: font_7seg
                  text_letter_space: 0
                  text_align: CENTER

    - id: player_controls_page
      bg_color: 0x000000
      widgets:
        - image:
            id: controls_album_art_widget
            align: TOP_MID
            x: 0
            y: 0
            src: fallback_playing_image
            width: ${imagewidth}
            height: ${imageheight}
            antialias: false
            clickable: false

        - obj:
            align: CENTER
            width: 360
            height: 360
            bg_color: 0x000000
            bg_opa: 58%
            border_opa: TRANSP
            radius: 0
            scrollable: false

        - label:
            id: controls_title_label
            align: TOP_MID
            y: 30
            width: 285
            text: ""
            text_color: 0xFFFFFF
            text_font: font_date
            long_mode: SCROLL_CIRCULAR
            text_align: CENTER

        - label:
            id: controls_artist_label
            align: TOP_MID
            y: 66
            width: 265
            text: ""
            text_color: 0x66CCFF
            text_font: font_display
            long_mode: SCROLL_CIRCULAR
            text_align: CENTER

        - label:
            id: controls_album_label
            align: TOP_MID
            y: 92
            width: 250
            text: ""
            text_color: 0x8EDBFF
            text_font: font_display
            long_mode: SCROLL_CIRCULAR
            text_align: CENTER

        - button:
            id: controls_mute_button
            align: BOTTOM_MID
            y: -65
            width: 58
            height: 48
            radius: 24
            bg_color: 0x202020
            bg_opa: 85%
            border_color: 0x505050
            border_width: 1
            pressed:
              bg_color: 0x66CCFF
              bg_opa: COVER
              border_color: 0xFFFFFF
              border_width: 3
            on_click:
              - script.execute: player_controls_mute
            widgets:
              - label:
                  id: controls_mute_icon
                  align: CENTER
                  text: "\U000F057E"
                  text_color: 0xFFFFFF
                  text_font: font_mdi_controls

        - button:
            id: controls_repeat_button
            align: CENTER
            x: 104
            y: 0
            width: 84
            height: 84
            radius: 42
            bg_color: 0x202020
            bg_opa: 85%
            border_color: 0x505050
            border_width: 1
            pressed:
              bg_color: 0x66CCFF
              bg_opa: COVER
              border_color: 0xFFFFFF
              border_width: 3
            on_click:
              - script.execute: player_controls_repeat_cycle
            widgets:
              - label:
                  id: controls_repeat_label
                  align: CENTER
                  text: "\U000F0457"
                  text_color: 0xFFFFFF
                  text_font: font_mdi_controls

        - button:
            id: controls_toggle_button
            align: CENTER
            y: 0
            width: 104
            height: 104
            radius: 52
            bg_color: 0xFFFF00
            bg_opa: COVER
            border_color: 0xFFFFFF
            border_width: 2
            pressed:
              bg_color: 0xFFFFFF
              border_color: 0x66CCFF
              border_width: 4
            on_click:
              - script.execute: player_controls_toggle
            widgets:
              - label:
                  align: CENTER
                  text: "\U000F040E"
                  text_color: 0x101010
                  text_font: font_mdi_controls

        - button:
            id: controls_shuffle_button
            align: CENTER
            x: -104
            y: 0
            width: 84
            height: 84
            radius: 42
            bg_color: 0x202020
            bg_opa: 85%
            border_color: 0x505050
            border_width: 1
            pressed:
              bg_color: 0x66CCFF
              bg_opa: COVER
              border_color: 0xFFFFFF
              border_width: 3
            on_click:
              - script.execute: player_controls_shuffle_toggle
            widgets:
              - label:
                  id: controls_shuffle_label
                  align: CENTER
                  text: "\U000F049E"
                  text_color: 0xFFFFFF
                  text_font: font_mdi_controls

        - button:
            id: controls_volume_down_button
            align: BOTTOM_MID
            x: -72
            y: -65
            width: 58
            height: 48
            radius: 24
            bg_color: 0x202020
            bg_opa: 85%
            border_color: 0x505050
            border_width: 1
            pressed:
              bg_color: 0x66CCFF
              bg_opa: COVER
              border_color: 0xFFFFFF
              border_width: 3
            on_click:
              - script.execute: player_controls_volume_down
            on_long_press_repeat:
              - script.execute: player_controls_volume_down
            widgets:
              - label:
                  id: controls_volume_down_icon
                  align: CENTER
                  text: "\U000F075E"
                  text_color: 0xFFFFFF
                  text_font: font_mdi_controls

        - button:
            id: controls_volume_up_button
            align: BOTTOM_MID
            x: 72
            y: -65
            width: 58
            height: 48
            radius: 24
            bg_color: 0x202020
            bg_opa: 85%
            border_color: 0x505050
            border_width: 1
            pressed:
              bg_color: 0x66CCFF
              bg_opa: COVER
              border_color: 0xFFFFFF
              border_width: 3
            on_click:
              - script.execute: player_controls_volume_up
            on_long_press_repeat:
              - script.execute: player_controls_volume_up
            widgets:
              - label:
                  id: controls_volume_up_icon
                  align: CENTER
                  text: "\U000F075D"
                  text_color: 0xFFFFFF
                  text_font: font_mdi_controls

        - button:
            id: controls_group_button
            align: BOTTOM_MID
            x: 0
            y: -10
            width: 160
            height: 38
            radius: 12
            bg_color: 0x202020
            bg_opa: 85%
            border_color: 0x505050
            border_width: 1
            pressed:
              bg_color: 0x66CCFF
              bg_opa: COVER
              border_color: 0xFFFFFF
              border_width: 3
            on_click:
              - sendspin.switch:
            widgets:
              - label:
                  id: controls_group_label
                  align: CENTER
                  text: "GROUP"
                  text_color: 0xFFFFFF
                  text_font: font_display

    - id: sendspin_page
      bg_color: 0x000000
      widgets:
        - image:
            id: incoming_art
            align: TOP_MID
            x: 0
            y: 0
            src: fallback_playing_image
            width: ${imagewidth}
            height: ${imageheight}
            antialias: false
            clickable: false

        - image:
            id: outgoing_art
            align: TOP_MID
            x: 0
            y: 0
            src: fallback_playing_image
            width: ${imagewidth}
            height: ${imageheight}
            antialias: false
            clickable: false

        - obj:
            id: artist_art_dot
            align: TOP_MID
            x: 0
            y: 20
            width: 14
            height: 14
            radius: 7
            bg_color: 0xFF3B30
            bg_opa: COVER
            border_opa: TRANSP
            pad_all: 0
            scrollable: false
            clickable: false
            hidden: true

        - image:
            id: slide_outgoing_art
            align: TOP_MID
            x: 0
            y: 0
            src: fallback_playing_image
            width: ${imagewidth}
            height: ${imageheight}
            antialias: false
            clickable: false
            hidden: true

        - image:
            id: slide_incoming_art
            align: TOP_MID
            x: 0
            y: 0
            src: fallback_playing_image
            width: ${imagewidth}
            height: ${imageheight}
            antialias: false
            clickable: false
            hidden: true

        - bar:
            id: now_playing_progress_bar
            align: BOTTOM_MID
            x: 0
            y: -70
            width: 220
            height: 8
            bg_color: 0x000000
            bg_opa: 40%
            border_width: 1
            border_color: 0xFFFFFF
            border_opa: 70%
            radius: 4
            min_value: 0
            max_value: 100
            value: 0
            animated: false
            indicator:
              bg_color: 0xFFFFFF
              bg_opa: COVER
              radius: 4

        - obj:
            id: now_playing_bar
            align: BOTTOM_MID
            y: -28
            width: 300
            height: 36
            bg_color: 0x000000
            bg_opa: 75%
            border_opa: TRANSP
            radius: 4
            pad_all: 0
            scrollable: false
            widgets:
              - label:
                  id: now_playing_label
                  align: CENTER
                  width: 284
                  text: ""
                  text_color: 0xFFFFFF
                  text_font: font_display
                  long_mode: SCROLL_CIRCULAR
                  text_align: CENTER

    - id: playlist_menu_page
      bg_color: 0x000000
      widgets:
        - image:
            id: playlist_album_art_widget
            align: TOP_MID
            x: 0
            y: 0
            src: fallback_playing_image
            width: ${imagewidth}
            height: ${imageheight}
            antialias: false
            clickable: false

        - obj:
            align: CENTER
            width: 360
            height: 360
            bg_color: 0x000000
            bg_opa: 58%
            border_opa: TRANSP
            radius: 0
            scrollable: false

        - button:
            id: playlist_1_button
            align: TOP_MID
            x: -74
            y: 28
            width: 138
            height: 86
            radius: 18
            bg_color: 0x181800
            bg_opa: 94%
            border_color: 0xFFFF00
            border_width: 4
            shadow_color: 0xFFFF00
            shadow_width: 18
            shadow_opa: 45%
            pressed:
              bg_color: 0xFFFF00
              bg_opa: COVER
              border_color: 0xFFFFFF
              border_width: 5
              shadow_color: 0xFFFFFF
              shadow_width: 24
              shadow_opa: 80%
            on_click:
              - script.execute:
                  id: play_playlist
                  uri: !lambda return id(playlist_1_uri).state;
            widgets:
              - label:
                  id: playlist_1_label
                  align: CENTER
                  width: 116
                  text: "My playlist 1"
                  text_color: 0xFFFFFF
                  text_font: font_playlist
                  text_align: CENTER

        - button:
            id: playlist_2_button
            align: TOP_MID
            x: 74
            y: 28
            width: 138
            height: 86
            radius: 18
            bg_color: 0x181800
            bg_opa: 94%
            border_color: 0xFFFF00
            border_width: 4
            shadow_color: 0xFFFF00
            shadow_width: 18
            shadow_opa: 45%
            pressed:
              bg_color: 0xFFFF00
              bg_opa: COVER
              border_color: 0xFFFFFF
              border_width: 5
              shadow_color: 0xFFFFFF
              shadow_width: 24
              shadow_opa: 80%
            on_click:
              - script.execute:
                  id: play_playlist
                  uri: !lambda return id(playlist_2_uri).state;
            widgets:
              - label:
                  id: playlist_2_label
                  align: CENTER
                  width: 116
                  text: "My playlist 2"
                  text_color: 0xFFFFFF
                  text_font: font_playlist
                  text_align: CENTER

        - button:
            id: playlist_3_button
            align: TOP_MID
            x: -74
            y: 134
            width: 138
            height: 86
            radius: 18
            bg_color: 0x181800
            bg_opa: 94%
            border_color: 0xFFFF00
            border_width: 4
            shadow_color: 0xFFFF00
            shadow_width: 18
            shadow_opa: 45%
            pressed:
              bg_color: 0xFFFF00
              bg_opa: COVER
              border_color: 0xFFFFFF
              border_width: 5
              shadow_color: 0xFFFFFF
              shadow_width: 24
              shadow_opa: 80%
            on_click:
              - script.execute:
                  id: play_playlist
                  uri: !lambda return id(playlist_3_uri).state;
            widgets:
              - label:
                  id: playlist_3_label
                  align: CENTER
                  width: 116
                  text: "My playlist 3"
                  text_color: 0xFFFFFF
                  text_font: font_playlist
                  text_align: CENTER

        - button:
            id: playlist_4_button
            align: TOP_MID
            x: 74
            y: 134
            width: 138
            height: 86
            radius: 18
            bg_color: 0x181800
            bg_opa: 94%
            border_color: 0xFFFF00
            border_width: 4
            shadow_color: 0xFFFF00
            shadow_width: 18
            shadow_opa: 45%
            pressed:
              bg_color: 0xFFFF00
              bg_opa: COVER
              border_color: 0xFFFFFF
              border_width: 5
              shadow_color: 0xFFFFFF
              shadow_width: 24
              shadow_opa: 80%
            on_click:
              - script.execute:
                  id: play_playlist
                  uri: !lambda return id(playlist_4_uri).state;
            widgets:
              - label:
                  id: playlist_4_label
                  align: CENTER
                  width: 116
                  text: "My playlist 4"
                  text_color: 0xFFFFFF
                  text_font: font_playlist
                  text_align: CENTER

        - button:
            id: playlist_5_button
            align: TOP_MID
            x: -74
            y: 240
            width: 138
            height: 86
            radius: 18
            bg_color: 0x181800
            bg_opa: 94%
            border_color: 0xFFFF00
            border_width: 4
            shadow_color: 0xFFFF00
            shadow_width: 18
            shadow_opa: 45%
            pressed:
              bg_color: 0xFFFF00
              bg_opa: COVER
              border_color: 0xFFFFFF
              border_width: 5
              shadow_color: 0xFFFFFF
              shadow_width: 24
              shadow_opa: 80%
            on_click:
              - script.execute:
                  id: play_playlist
                  uri: !lambda return id(playlist_5_uri).state;
            widgets:
              - label:
                  id: playlist_5_label
                  align: CENTER
                  width: 116
                  text: "My playlist 5"
                  text_color: 0xFFFFFF
                  text_font: font_playlist
                  text_align: CENTER

        - button:
            id: playlist_6_button
            align: TOP_MID
            x: 74
            y: 240
            width: 138
            height: 86
            radius: 18
            bg_color: 0x181800
            bg_opa: 94%
            border_color: 0xFFFF00
            border_width: 4
            shadow_color: 0xFFFF00
            shadow_width: 18
            shadow_opa: 45%
            pressed:
              bg_color: 0xFFFF00
              bg_opa: COVER
              border_color: 0xFFFFFF
              border_width: 5
              shadow_color: 0xFFFFFF
              shadow_width: 24
              shadow_opa: 80%
            on_click:
              - script.execute:
                  id: play_playlist
                  uri: !lambda return id(playlist_6_uri).state;
            widgets:
              - label:
                  id: playlist_6_label
                  align: CENTER
                  width: 116
                  text: "My playlist 6"
                  text_color: 0xFFFFFF
                  text_font: font_playlist
                  text_align: CENTER

    - id: pulldown_page
      bg_color: 0x000000
      widgets:
        - image:
            id: pulldown_background_widget
            align: TOP_MID
            x: 0
            y: 0
            src: standby_background_image
            width: ${imagewidth}
            height: ${imageheight}
            antialias: false
            clickable: false
            hidden: true

        - image:
            id: pulldown_album_art_widget
            align: TOP_MID
            x: 0
            y: 0
            src: fallback_playing_image
            width: ${imagewidth}
            height: ${imageheight}
            antialias: false
            clickable: false
            hidden: true

        - obj:
            id: pulldown_album_art_dim
            align: CENTER
            width: 360
            height: 360
            bg_color: 0x000000
            bg_opa: 58%
            border_opa: TRANSP
            radius: 0
            scrollable: false
            hidden: true

        - label:
            id: pulldown_title_label
            align: TOP_MID
            y: 16
            width: 310
            text: "Lights"
            text_color: 0x66CCFF
            text_font: font_display_medium
            text_align: CENTER

        - button:
            id: pulldown1_button
            align: TOP_MID
            x: -72
            y: 55
            width: 118
            height: 118
            radius: 20
            bg_opa: TRANSP
            border_color: 0xFFFF00
            border_width: 4
            shadow_color: 0xFFFF00
            shadow_width: 18
            shadow_opa: 45%
            on_click:
              - homeassistant.action:
                  action: homeassistant.toggle
                  data:
                    entity_id: ${pulldown1_entity}
              - script.execute: pulldown_timeout
            widgets:
              - label:
                  id: pulldown1_button_label
                  align: CENTER
                  width: 108
                  text: "Light 1"
                  text_color: 0xFFFFFF
                  text_font: font_playlist
                  text_align: CENTER

        - button:
            id: pulldown2_button
            align: TOP_MID
            x: 72
            y: 55
            width: 118
            height: 118
            radius: 20
            bg_opa: TRANSP
            border_color: 0xFFFF00
            border_width: 4
            shadow_color: 0xFFFF00
            shadow_width: 18
            shadow_opa: 45%
            on_click:
              - homeassistant.action:
                  action: homeassistant.toggle
                  data:
                    entity_id: ${pulldown2_entity}
              - script.execute: pulldown_timeout
            widgets:
              - label:
                  id: pulldown2_button_label
                  align: CENTER
                  width: 108
                  text: "Light 2"
                  text_color: 0xFFFFFF
                  text_font: font_playlist
                  text_align: CENTER

        - button:
            id: pulldown3_button
            align: TOP_MID
            x: -72
            y: 187
            width: 118
            height: 118
            radius: 20
            bg_opa: TRANSP
            border_color: 0xFFFF00
            border_width: 4
            shadow_color: 0xFFFF00
            shadow_width: 18
            shadow_opa: 45%
            on_click:
              - homeassistant.action:
                  action: homeassistant.toggle
                  data:
                    entity_id: ${pulldown3_entity}
              - script.execute: pulldown_timeout
            widgets:
              - label:
                  id: pulldown3_button_label
                  align: CENTER
                  width: 108
                  text: "Light 3"
                  text_color: 0xFFFFFF
                  text_font: font_playlist
                  text_align: CENTER

        - button:
            id: pulldown4_button
            align: TOP_MID
            x: 72
            y: 187
            width: 118
            height: 118
            radius: 20
            bg_opa: TRANSP
            border_color: 0xFFFF00
            border_width: 4
            shadow_color: 0xFFFF00
            shadow_width: 18
            shadow_opa: 45%
            on_click:
              - homeassistant.action:
                  action: homeassistant.toggle
                  data:
                    entity_id: ${pulldown4_entity}
              - script.execute: pulldown_timeout
            widgets:
              - label:
                  id: pulldown4_button_label
                  align: CENTER
                  width: 108
                  text: "Light 4"
                  text_color: 0xFFFFFF
                  text_font: font_playlist
                  text_align: CENTER

    - id: pullup_page
      bg_color: 0x000000
      widgets:
        - image:
            id: pullup_background_widget
            align: TOP_MID
            x: 0
            y: 0
            src: standby_background_image
            width: ${imagewidth}
            height: ${imageheight}
            antialias: false
            clickable: false
            hidden: true

        - image:
            id: pullup_album_art_widget
            align: TOP_MID
            x: 0
            y: 0
            src: fallback_playing_image
            width: ${imagewidth}
            height: ${imageheight}
            antialias: false
            clickable: false
            hidden: true

        - obj:
            id: pullup_album_art_dim
            align: CENTER
            width: 360
            height: 360
            bg_color: 0x000000
            bg_opa: 58%
            border_opa: TRANSP
            radius: 0
            scrollable: false
            hidden: true

        - label:
            id: pullup_title_label
            align: TOP_MID
            y: 16
            width: 310
            text: "Control"
            text_color: 0x66CCFF
            text_font: font_display_medium
            text_align: CENTER

        - button:
            id: pullup_va1_button
            align: TOP_MID
            x: -72
            y: 55
            width: 118
            height: 118
            radius: 20
            bg_opa: TRANSP
            clip_corner: true
            border_color: 0xFFFF00
            border_width: 4
            shadow_color: 0xFFFF00
            shadow_width: 18
            shadow_opa: 45%
            on_click:
              - script.execute:
                  id: start_manual_va
                  image_set: 1
            widgets:
              - image:
                  id: pullup_va1_icon_widget
                  align: CENTER
                  src: pullup_va1_icon
                  width: 108
                  height: 108
                  radius: 15
                  clip_corner: true
                  antialias: false
                  clickable: false

        - button:
            id: pullup_va2_button
            align: TOP_MID
            x: 72
            y: 55
            width: 118
            height: 118
            radius: 20
            bg_opa: TRANSP
            clip_corner: true
            border_color: 0xFFFF00
            border_width: 4
            shadow_color: 0xFFFF00
            shadow_width: 18
            shadow_opa: 45%
            on_click:
              - script.execute:
                  id: start_manual_va
                  image_set: 2
            widgets:
              - image:
                  id: pullup_va2_icon_widget
                  align: CENTER
                  src: pullup_va2_icon
                  width: 108
                  height: 108
                  radius: 15
                  clip_corner: true
                  antialias: false
                  clickable: false

        - button:
            id: pullup_mute_button
            align: TOP_MID
            x: -72
            y: 187
            width: 118
            height: 118
            radius: 20
            bg_opa: TRANSP
            border_color: 0xFFFF00
            border_width: 4
            shadow_color: 0xFFFF00
            shadow_width: 18
            shadow_opa: 45%
            on_click:
              - switch.toggle: mute
              - script.execute: refresh_pullup_state
              - script.execute: pullup_timeout
            widgets:
              - label:
                  id: pullup_mute_label
                  align: CENTER
                  text: "\U000F036C"
                  text_color: 0xFFFFFF
                  text_font: font_mdi_pullup
                  text_align: CENTER

        - button:
            id: pullup_target_button
            align: TOP_MID
            x: 72
            y: 187
            width: 118
            height: 118
            radius: 20
            bg_opa: TRANSP
            border_color: 0xFFFF00
            border_width: 4
            shadow_color: 0xFFFF00
            shadow_width: 18
            shadow_opa: 45%
            on_click:
              - lambda: |-
                  id(pullup_player_target) = id(pullup_player_target) == 1 ? 0 : 1;
              - script.execute: refresh_pullup_state
              - script.execute: pullup_timeout
            widgets:
              - label:
                  id: pullup_target_label
                  align: CENTER
                  text: "\U000F0004"
                  text_color: 0xFFFFFF
                  text_font: font_mdi_pullup
                  text_align: CENTER
OTA extension Download
# Factory firmware - generated by ewt-gen
# Users should import Guition_Silver_Puck_v2-modular.yaml directly, not this file.

packages:
  original: !include Guition_Silver_Puck_v2-modular.yaml

esphome:
  project:
    version: "2026.10.04.57"

ota:
  - platform: http_request
    id: ota_http_request

update:
  - platform: http_request
    id: update_http_request
    name: Firmware
    source: https://realdeco.github.io/sendspin-guition/Guition_Silver_Puck_v2-modular/manifest.json

http_request:

dashboard_import:
  package_import_url: github://RealDeco/sendspin-guition/Guition_Silver_Puck_v2-modular.yaml@main