Zero-VA-Sendspin v2026.06.16.9

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
---
substitutions:
  name: zero-va-sendspin
  friendly_name: Zero-VA-Sendspin

esphome:
  name: ${name}
  friendly_name: ${friendly_name}
  min_version: 2026.5.0
  name_add_mac_suffix: true
  project:
    name: realdeco.zero_va
    version: "2026.5.0"

esp32:
  variant: esp32s3
  flash_size: 4MB
  cpu_frequency: 240MHz
  framework:
    type: esp-idf
    sdkconfig_options:
      CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240: "y"
      CONFIG_ESP32S3_DATA_CACHE_64KB: "y"
      CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y"

psram:
  mode: quad
  speed: 80MHz

api:
  on_client_connected:
    - script.execute: stop_wake_word
    - delay: 2000ms
    - lambda: |-
        if (!id(boot_sound_played)) {
          id(boot_sound_played) = true;
          if (id(startup_sound_switch).state) {
            id(play_sound)->execute(true, std::string("ready_sound"));
          }
        }

ota:
  - platform: esphome
    id: ota_esphome

logger:
  level: WARN

wifi:
  ap:

improv_serial:

captive_portal:

globals:
  - id: playing_internal_sound
    type: bool
    restore_value: no
    initial_value: "false"

  - id: boot_sound_played
    type: bool
    restore_value: no
    initial_value: "false"

button:
  - platform: factory_reset
    id: factory_reset_btn
    internal: true

binary_sensor:
  - platform: gpio
    pin:
      number: GPIO0
      ignore_strapping_warning: true
      mode: INPUT_PULLUP
      inverted: true
    id: center_button
    internal: true
    on_multi_click:
      - timing:
          - ON for at least 50ms
          - OFF for at least 50ms
        then:
          - script.execute: reset_led
          - if:
              condition:
                switch.is_on: timer_ringing
              then:
                - switch.turn_off: timer_ringing
              else:
                - if:
                    condition:
                      not:
                        voice_assistant.is_running:
                    then:
                      - voice_assistant.start:
                    else:
                      - voice_assistant.stop:

      - timing:
          - ON for at least 10s
        then:
          - button.press: factory_reset_btn

i2c:
  - id: i2c_adc_dac
    scl: GPIO8
    sda: GPIO7

i2s_audio:
  - id: i2s_audio_bus
    i2s_lrclk_pin:
      number: GPIO3
      ignore_strapping_warning: true
    i2s_bclk_pin: GPIO5
    i2s_mclk_pin: GPIO6

audio_dac:
  - platform: es8311
    i2c_id: i2c_adc_dac
    id: es8311_dac
    bits_per_sample: 16bit
    sample_rate: 48000

microphone:
  - platform: i2s_audio
    id: box_mic
    sample_rate: 16000
    i2s_din_pin: GPIO4
    bits_per_sample: 16bit
    adc_type: external

speaker:
  - platform: i2s_audio
    id: box_speaker
    i2s_dout_pin: GPIO2
    dac_type: external
    sample_rate: 48000
    bits_per_sample: 16bit
    channel: left
    audio_dac: es8311_dac
    buffer_duration: 100ms

  - 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: 48000
    bits_per_sample: 16

  - platform: resampler
    id: media_resampling_speaker
    output_speaker: media_mixing_input
    sample_rate: 48000

sendspin:
  id: sendspin_hub
  task_stack_in_psram: false

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/popup.flac

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

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

  - platform: speaker_source
    name: None
    id: speaker_media_player

    announcement_pipeline:
      format: FLAC
      sample_rate: 48000
      num_channels: 1
      speaker: announcement_resampling_speaker
      sources:
        - audio_file_announcement_source
        - http_announcement_source

    media_pipeline:
      format: FLAC
      sample_rate: 48000
      num_channels: 2
      speaker: media_resampling_speaker
      sources:
        - http_media_source
        - sendspin_media_source
    volume_increment: 0.05
    volume_min: 0.4
    volume_max: 0.85

    on_announcement:
      - script.stop: mp_idle_debounce
      - if:
          condition:
            - microphone.is_capturing:
          then:
            - script.execute: stop_wake_word
            - if:
                condition:
                  - lambda: return id(wake_word_engine_location).current_option() == "In Home Assistant";
                then:
                  - wait_until:
                      - not:
                          voice_assistant.is_running:

    on_play:
      - script.stop: mp_idle_debounce
      - if:
          condition:
            - microphone.is_capturing:
          then:
            - script.execute: stop_wake_word
            - if:
                condition:
                  - lambda: return id(wake_word_engine_location).current_option() == "In Home Assistant";
                then:
                  - wait_until:
                      condition:
                        - not:
                            voice_assistant.is_running:
            - light.turn_on:
                id: led
                blue: 0%
                red: 0%
                green: 100%
                effect: "Slow Pulse"
      - if:
          condition:
            lambda: 'return id(playing_internal_sound);'
          then:
            - lambda: 'id(playing_internal_sound) = false;'

    on_idle:
      - script.execute: mp_idle_debounce

micro_wake_word:
  id: mww
  models:
    - okay_nabu
    - hey_mycroft
    - hey_jarvis
  on_wake_word_detected:
    - voice_assistant.start:
        wake_word: !lambda return wake_word;

voice_assistant:
  id: va
  microphone: box_mic
  media_player: speaker_media_player
  micro_wake_word: mww
  noise_suppression_level: 2
  auto_gain: 31dBFS
  volume_multiplier: 2.0
  on_listening:
    - if:
        condition:
          - switch.is_on: use_listen_light
        then:
          - light.turn_on:
              id: led
              blue: 100%
              red: 0%
              green: 0%
              effect: "Slow Pulse"
  on_stt_vad_end:
    - if:
        condition:
          - switch.is_on: use_listen_light
        then:
          - light.turn_on:
              id: led
              blue: 100%
              red: 0%
              green: 0%
              effect: "Fast Pulse"
  on_tts_start:
    - if:
        condition:
          - switch.is_on: use_listen_light
        then:
          - light.turn_on:
              id: led
              blue: 100%
              red: 0%
              green: 0%
              brightness: 100%
              effect: none

  on_end:
    - wait_until:
        condition:
          media_player.is_announcing:
            id: speaker_media_player
        timeout: 0.5s

    - wait_until:
        - and:
            - not:
                media_player.is_announcing:
                  id: speaker_media_player
            - not:
                speaker.is_playing:
                  id: box_speaker
    - 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

  on_error:
    - if:
        condition:
          switch.is_off: mute
        then:
          - script.execute: start_wake_word
    - if:
        condition:
          - switch.is_on: use_listen_light
        then:
          - light.turn_on:
              id: led
              red: 100%
              green: 0%
              blue: 0%
              brightness: 100%
              effect: none
    - delay: 2s
    - script.execute: reset_led

  on_client_connected:
    - script.execute: start_wake_word

  on_client_disconnected:
    - script.execute: stop_wake_word

  on_timer_finished:
    - switch.turn_on: timer_ringing
    - if:
        condition:
          - switch.is_on: use_listen_light
        then:
          - light.turn_on:
              id: led
              red: 0%
              green: 100%
              blue: 0%
              brightness: 100%
              effect: "Fast Pulse"
    - wait_until:
        media_player.is_announcing:
          id: speaker_media_player
    - if:
        condition:
          - switch.is_on: use_listen_light
        then:
          - light.turn_on:
              id: led
              blue: 0%
              red: 100%
              green: 0%
              effect: "Fast Pulse"

script:
  - id: mp_idle_debounce
    mode: restart
    then:
      - delay: 1s
      - if:
          condition:
            and:
              - media_player.is_idle: speaker_media_player
              - not:
                  voice_assistant.is_running:
          then:
            - script.execute: reset_led
            - script.execute: start_wake_word
  - id: reset_led
    then:
      - if:
          condition:
            - lambda: return id(wake_word_engine_location).current_option() == "On device";
            - switch.is_on: use_listen_light
          then:
            - light.turn_on:
                id: led
                red: 100%
                green: 89%
                blue: 71%
                brightness: 30%
                effect: none
          else:
            - if:
                condition:
                  - lambda: return id(wake_word_engine_location).current_option() != "On device";
                  - switch.is_on: use_listen_light
                then:
                  - light.turn_on:
                      id: led
                      red: 0%
                      green: 100%
                      blue: 100%
                      brightness: 30%
                      effect: none
                else:
                  - light.turn_off: led
  - id: start_wake_word
    then:
      - if:
          condition:
            and:
              - switch.is_off: mute
              - not:
                  - voice_assistant.is_running:
              - not:
                  - micro_wake_word.is_running:
              - lambda: return id(wake_word_engine_location).current_option() == "On device";
          then:
            - lambda: id(va).set_use_wake_word(false);
            - micro_wake_word.start:
      - if:
          condition:
            and:
              - switch.is_off: mute
              - not:
                  - voice_assistant.is_running:
              - lambda: return id(wake_word_engine_location).current_option() == "In Home Assistant";
          then:
            - lambda: id(va).set_use_wake_word(true);
            - voice_assistant.start_continuous:
  - id: stop_wake_word
    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: ring_timer
    then:
      - script.execute: enable_repeat_one
      - script.execute:
          id: play_sound
          priority: true
          sound_file: "timer_finished_sound"
  # Script executed when the timer is ringing, to repeat the timer finished sound.
  - id: enable_repeat_one
    then:
      - media_player.repeat_one:
          id: speaker_media_player
          announcement: true
      # Turn on the repeat mode and pause for 500 ms between playlist items/repeats
      - speaker_source.set_playlist_delay:
          id: speaker_media_player
          pipeline: announcement
          delay: 500ms

  # Script execute when the timer is done ringing, to disable repeat mode.
  - id: disable_repeat
    then:
      # Turn off the repeat mode and pause for 0 ms between playlist items/repeats
      - media_player.repeat_off:
          id: speaker_media_player
          announcement: true
      - speaker_source.set_playlist_delay:
          id: speaker_media_player
          pipeline: announcement
          delay: 0ms
  - id: play_sound
    parameters:
      priority: bool
      sound_file: string
    then:
      - lambda: |-
          id(playing_internal_sound) = true;
      - if:
          condition:
            lambda: return priority;
          then:
            - media_player.stop:
                id: speaker_media_player
                announcement: true
      - lambda: |-
          if ( (id(speaker_media_player).state != media_player::MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING ) || priority) {
            id(speaker_media_player)
              ->make_call()
              .set_media_url("audio-file://" + sound_file)
              .set_announcement(true)
              .perform();
          }

switch:
  - platform: template
    id: startup_sound_switch
    name: Startup sound
    icon: "mdi:card-text-outline"
    entity_category: config
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON
  - platform: template
    name: Use listen light
    id: use_listen_light
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON
    entity_category: config
    on_turn_on:
      - script.execute: reset_led
    on_turn_off:
      - script.execute: reset_led
  - platform: gpio
    name: Speaker Enable
    pin:
      number: GPIO1
    restore_mode: RESTORE_DEFAULT_ON
    entity_category: config
    disabled_by_default: true
  - platform: template
    name: Mute
    id: mute
    icon: "mdi:microphone-off"
    optimistic: true
    restore_mode: RESTORE_DEFAULT_OFF
    entity_category: config
    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:
      - script.execute: disable_repeat
      - if:
          condition:
            media_player.is_announcing:
              id: speaker_media_player
          then:
            media_player.stop:
              announcement: true
              id: speaker_media_player
    on_turn_on:
      - script.execute: ring_timer
      - delay: 15min
      - switch.turn_off: timer_ringing

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

light:
  - platform: esp32_rmt_led_strip
    id: led
    name: LED
    disabled_by_default: false
    entity_category: config
    pin: GPIO21
    default_transition_length: 0s
    chipset: WS2812
    num_leds: 1
    rgb_order: grb
    effects:
      - pulse:
          name: "Slow Pulse"
          transition_length: 250ms
          update_interval: 250ms
          min_brightness: 50%
          max_brightness: 100%
      - pulse:
          name: "Fast Pulse"
          transition_length: 100ms
          update_interval: 100ms
          min_brightness: 50%
          max_brightness: 100%
OTA extension Download
# Factory firmware - generated by ewt-gen
# Users should import Zero-VA-Sendspin.yaml directly, not this file.

packages:
  original: !include Zero-VA-Sendspin.yaml

esphome:
  project:
    version: "2026.06.16.9"

ota:
  - platform: http_request
    id: ota_http_request

update:
  - platform: http_request
    id: update_http_request
    name: Firmware
    source: https://realdeco.github.io/Zero-VA/Zero-VA-Sendspin/manifest.json

http_request:

dashboard_import:
  package_import_url: github://RealDeco/Zero-VA/Zero-VA-Sendspin.yaml@main