Voice PE assistant: How to change output source as an alternative to pairing Bluetooth speaker directly to VPE

As the Voice PE assistant cannot not pair directly to a BT speaker due to limitations:

I installed the following add-on/ app to create a media_player entity from my paired Bluetooth speaker:

I have tried to redirect to a different audio output see here esphome config:

substitutions:
  name: home-assistant-voice-xxx
  friendly_name: Small Bedroom assistant
packages:
  Nabu Casa.Home Assistant Voice PE: github://esphome/home-assistant-voice-pe/home-assistant-voice.yaml
esphome:
  name: ${name}
  name_add_mac_suffix: false
  friendly_name: ${friendly_name}
wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
esp32:
  # board: esp32_s3_korvo_2
  board: esp32-s3-devkitc-1
  toolchain: esp-idf
  framework:
    type: esp-idf
http_request:
  verify_ssl: true
  ca_certificate_path: rootCA.crt
  
globals:
  - id: route_to_external_speaker
    type: bool
    initial_value: 'false'
  # 1. ADD NEW VARIABLE: A string variable to store the selected speaker entity ID
  - id: chosen_speaker
    type: std::string
    initial_value: '""'

api:
  encryption:
   key: xxxxxx
  actions:
    - action: update_audio_target
      variables:
        selected_target: string
      then:
        - lambda: |-
            // Store the raw media_player string directly into new variable
            id(chosen_speaker) = selected_target;
            
            // If the selected target starts with "media_player.", activate external routing!
            if (selected_target.find("media_player.") == 0) {
              id(route_to_external_speaker) = true;
              ESP_LOGD("main", "ESPHome Routing Flag updated: EXTERNAL SPEAKER MODE ACTIVE (%s)", selected_target.c_str());
            } else {
              id(route_to_external_speaker) = false;
              ESP_LOGD("main", "ESPHome Routing Flag updated: LOCAL SPEAKER MODE ACTIVE");
            }

voice_assistant:
  id: va
  # (Your mic and speaker entries remain here)
  
  on_tts_start:
    - if:
        condition:
          lambda: 'return id(route_to_external_speaker);'
        then:
          - homeassistant.event:
              event: esphome.voice_pe_tts_redirect
              data:
                text: !lambda 'return x;'
                target_entity: !lambda 'return id(chosen_speaker);'
       
          - delay: 200ms
          - voice_assistant.stop

I have a helper selector with my BT speakers (beginning media_player. )

and I have an automation to pass to the chosen BT speaker this works. Automation:

alias: 'Voice PE: Redirect Audio Stream'
description: >-
  Catches the network event from ESPHome and routes the text to the selected
  speaker
triggers:
  - event_type: esphome.voice_pe_tts_redirect
    trigger: event
conditions: []
actions:
  - target:
      entity_id: tts.piper
    data:
      media_player_entity_id: '  {{ (media_player_id) }} '
      message: '{{ trigger.event.data.text }}'
    action: tts.speak
mode: queued
max: 10
variables:
  media_player_id: '{{ trigger.event.data.target_entity }}'

Here are the changed variables in the above automation:

this:
  entity_id: automation.voice_pe_redirect_audio_to_selected_output
  state: 'on'
  attributes:
    id: '1788880245831'
    last_triggered: '2026-09-08T21:53:43.763990+00:00'
    mode: queued
    current: 0
    max: 10
    friendly_name: 'Voice PE: Redirect Audio Stream'
  last_changed: '2026-09-08T21:49:05.224174+00:00'
  last_reported: '2026-09-08T21:53:43.767034+00:00'
  last_updated: '2026-09-08T21:53:43.767034+00:00'
  context:
    id: 01M21G6GAK83RCWN5VFGRTHAD1
    parent_id: 01M21G6GAJN0CGKNXBE2BD0PN4
    user_id: null
trigger:
  id: '0'
  idx: '0'
  alias: null
  platform: event
  event:
    event_type: esphome.voice_pe_tts_redirect
    data:
      device_id: xxxxxxxxx
      text: 10:17 AM
      target_entity: media_player.nux_mighty_bt_speaker_xxxxx_sendspin_bt_bridge_rc
    origin: LOCAL
    time_fired: '2026-09-09T09:17:34.810192+00:00'
    context:
      id: 01M22QANPTG0AWKMYQHBTX2NKC
      parent_id: null
      user_id: null
  description: event 'esphome.voice_pe_tts_redirect'
media_player_id: media_player.nux_mighty_bt_speaker_xxxxx_sendspin_bt_bridge_rc

The above works and redirects tts normally going to my voice PE assistant device to my chosen BT speaker bypassing the VPE.

However when I try using a sentence to trigger my music automation it doesn’t have

target_entity: media_player.nux_mighty_bt_speaker_xxxxx_sendspin_bt_bridge_rc

In the changed variables of the radio automation.

I tried adding:

triggers:
  - event_type: esphome.voice_pe_tts_redirect
    trigger: event

to the radio automation it triggers 2x but there is no trace I presume for the above trigger eventually.

I want this:

target_entity: media_player.nux_mighty_bt_speaker_xxxxx_sendspin_bt_bridge_rc

In the changed variables like it does when ‘automation.voice_pe_redirect_audio_to_selected_output’ is triggered

Another problem is I must trigger a change on the selector for it to use my chosen speaker after Voice PE device is rebooted or powered of then on, I guess I could resolve this using an automation but it is not ideal.

This is a snippet of the “radio” automation, I managed to get it to output music via the automation by getting the selector state instead of getting it from the VPE only issue is volume control, stop, etc won’t work for the selected media_player:

alias: Radio Player
description: ''
triggers:
  - trigger: conversation
      command:
        - '[Clay] [Play] [Plays] [Radio] Caprice'
      id: Radio Caprice
actions:
  - variables:
      audio_output_target: '{{ states(''input_select.house_audio_source'') }}'
  - action: input_button.press
    metadata: {}
    target:
      entity_id: input_button.check_music_assistant
    data: {}
  - delay:
      hours: 0
      minutes: 0
      seconds: 5
      milliseconds: 0
  - if:
      - condition: state
        entity_id: binary_sensor.music_assistant_addon_running
        state:
          - 'off'
          - unavailable
          - unknown
    then:
      - action: assist_satellite.ask_question
        metadata: {}
        data:
          question: Would you like to start music assistant?
          preannounce: true
          answers:
            - id: MA_START
              sentences:
                - 'yes'
                - 'no'
          entity_id: ' {{ assist_satellite_device }} '
        response_variable: music_assistant_startup
      - if:
          - condition: template
            value_template: '{{ ''yes'' in music_assistant_startup.sentence }}'
        then:
          - action: hassio.addon_start
            metadata: {}
            data:
              addon: d5369777_music_assistant_beta
          - set_conversation_response: Starting music assistant
          - action: assist_satellite.announce
            metadata: {}
            target:
              entity_id: '  {{ assist_satellite_device }} '
            data:
              message: Starting music assistant
              preannounce: false
          - repeat:
              until:
                - condition: template
                  value_template: >-
                    {{is_state("binary_sensor.music_assistant_addon_running",
                    "on")}}
              sequence:
                - action: input_button.press
                  metadata: {}
                  target:
                    entity_id: input_button.check_music_assistant
                  data: {}
      - delay:
          hours: 0
          minutes: 0
          seconds: 15
          milliseconds: 0
  - choose:
      - conditions:
          - condition: trigger
            id:
              - Play Radio Caprice
        sequence:
          - action: media_player.play_media
            target:
              entity_id: ' {{ (voice_assistant_device) }} '
            data:
              media:
                media_content_id: library://radio/1
                media_content_type: music
                metadata:
                  title: Radio Caprice - Retrowave
                  thumbnail: http://radcap.ru/apple-touch-icon.png
                  media_class: music
                  children_media_class: null
                  navigateIds:
                    - {}
                    - media_content_type: music_assistant
                      media_content_id: radio
            enabled: true

variables:
  audio_output_target: '{{ states(''input_select.house_audio_source'') }}'
  voice_assistant_device: ' {{ (audio_output_target) }} '
mode: parallel
max: 3

Below is the media_player controller for volume/player/ pause etc.

alias: Voice assistant controls
description: ''
triggers:
  - trigger: conversation
    command:
      - (stop|top|shut up)
      - Top [playing] [media] [player]
      - Stop [playing] [media] [player]
    id: Stop
  - trigger: conversation
    command:
      - Pause [(media|player)]
    id: Pause
  - trigger: conversation
    command:
      - '[Set] (volume|sound) [to] (1|5|10|15|20|30|40|50|60|70|80|90|100)'
    id: Volume
    variables:
      volume_level_sentence: ' {{ trigger.sentence }} '
      volume_level: ' {{ (volume_level_sentence) |regex_findall(''\d+'')|first|int|multiply(0.01) }} '
      volume: ' {{ (volume_level) }} '
  - trigger: conversation
    command:
      - (Mute|nute)
    id: mute
  - trigger: conversation
    command:
      - (unmute|unnute)
    id: unmute
  - trigger: conversation
    command:
      - (start)
    id: Start
conditions: []
actions:
  - choose:
      - conditions:
          - condition: trigger
            id:
              - Stop
        sequence:
          - action: media_player.media_stop
            metadata: {}
            data: {}
            target:
              entity_id: ' {{ (voice_assistant_device) }} '
      - conditions:
          - condition: trigger
            id:
              - Pause
        sequence:
          - action: media_player.media_pause
            metadata: {}
            data: {}
            target:
              entity_id: ' {{ (voice_assistant_device) }} '
      - conditions:
          - condition: trigger
            id:
              - Volume
        sequence:
          - action: media_player.volume_set
            metadata: {}
            data:
              volume_level: ' {{ (volume) }} '
            target:
              entity_id: ' {{ (voice_assistant_device) }}  '
            enabled: true
      - conditions:
          - condition: trigger
            id:
              - mute
        sequence:
          - action: media_player.volume_mute
            metadata: {}
            data:
              is_volume_muted: true
            target:
              entity_id: ' {{ (voice_assistant_device) }} '
            enabled: true
      - conditions:
          - condition: trigger
            id:
              - unmute
        sequence:
          - action: media_player.volume_mute
            metadata: {}
            data:
              is_volume_muted: false
            target:
              entity_id: ' {{ (voice_assistant_device) }} '
            enabled: true
      - conditions:
          - condition: trigger
            id:
              - Start
        sequence:
          - action: media_player.media_play
            metadata: {}
            target:
              entity_id: ' {{ (voice_assistant_device) }} '
            data: {}
variables:
  media_player_id: ' {{ trigger.device_id }} '
  media_player: ' {{ device_entities(media_player_id) |select(''search'',''^media_player.'')|first }} '
  friendly_name: ' {{ states[media_player][''attributes''][''friendly_name''] }} '
  prepend: media_player.
  voice_assistant_device-old: ' {{ prepend ~ (friendly_name) | slugify}} '
  audio_output_target: '{{ states(''input_select.house_audio_source'') }}'
  voice_assistant_device: ' {{ (audio_output_target) }} '
mode: parallel
max: 5

This VPE config works better. It retrieves on booting the current set media_player in the helper selector and works after a power off then power on.

TTS ouputs to the chosen_speaker and the “radio player” automation works too.

substitutions:
  name: home-assistant-voice-xxxx
  friendly_name: Small Bedroom assistant
packages:
  Nabu Casa.Home Assistant Voice PE: github://esphome/home-assistant-voice-pe/home-assistant-voice.yaml
wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
esp32:
  # board: esp32_s3_korvo_2
  board: esp32-s3-devkitc-1
  toolchain: esp-idf
  framework:
    type: esp-idf
http_request:
  verify_ssl: true
  ca_certificate_path: rootCA.crt
  
esphome:
  name: ${name}
  name_add_mac_suffix: false
  friendly_name: ${friendly_name}
  on_boot:
    priority: -100
    then:
      - lambda: |-
          ESP_LOGI("main", "System booted. Waiting for Home Assistant text sensor synchronization...");

# --- GLOBALS ---
globals:
  - id: route_to_external_speaker
    type: bool
    initial_value: 'false'
    restore_value: yes # Remembers the routing state across restarts
  - id: chosen_speaker
    type: std::string
    initial_value: '""'
    restore_value: yes # Permanently stores the entity string in memory

# --- TEXT SENSORS ---
text_sensor:
  - platform: homeassistant
    id: helper_selector_state
    entity_id: input_select.house_audio_source
    internal: false 
    # Automatically triggers at boot connection AND whenever the dropdown updates
    on_value:
      then:
        - lambda: |-
            id(chosen_speaker) = x;
            if (x.rfind("media_player.", 0) == 0) {
              id(route_to_external_speaker) = true;
              ESP_LOGD("main", "Routing updated: EXTERNAL SPEAKER ACTIVE (%s)", x.c_str());
            } else {
              id(route_to_external_speaker) = false;
              ESP_LOGD("main", "Routing updated: LOCAL SPEAKER ACTIVE");
            }

# --- API SERVICES ---
api:
  encryption:
    key: xxxxx
  actions:
    - action: update_audio_target
      variables:
        selected_target: string
      then:
        - lambda: |-
            id(chosen_speaker) = selected_target;
            if (selected_target.find("media_player.") == 0) {
              id(route_to_external_speaker) = true;
              ESP_LOGD("main", "ESPHome Routing Flag updated: EXTERNAL SPEAKER MODE ACTIVE (%s)", selected_target.c_str());
            } else {
              id(route_to_external_speaker) = false;
              ESP_LOGD("main", "ESPHome Routing Flag updated: LOCAL SPEAKER MODE ACTIVE");
            }

# --- VOICE ASSISTANT CONTROL ---
voice_assistant:
  id: va
  # (Your existing mic and hardware settings stay here)
  
  on_tts_start:
    - if:
        condition:
          lambda: 'return id(route_to_external_speaker);'
        then:
          - homeassistant.event:
              event: esphome.voice_pe_tts_redirect
              data:
                text: !lambda 'return x;'
                target_entity: !lambda 'return id(chosen_speaker);'
       
          - delay: 200ms
          - voice_assistant.stop

This is the helper selector that text_sensor uses in the above espHome VPE config:

entity_id: input_select.house_audio_source
state: media_player.nux_mighty_bt_speaker_85b1ecde_sendspin_bt_bridge_rc
last_changed: '2026-09-09T20:44:31.768Z'
last_updated: '2026-09-09T22:34:58.282Z'
attributes:
  options:
    - media_player.nux_mighty_bt_speaker_xxx_sendspin_bt_bridge_rc
    - media_player.small_bedroom_assistant_media_player
    - media_player.home_assistant_voice_xxxx_media_player
    - media_player.living_room_tv_2
  editable: true
  icon: mdi:audio-input-stereo-minijack
  friendly_name: Audio_Source

I am unable to test TTS to the VPE itself as self-signed certificates are currently broken. I believe a fix is imminent for this.

Great job on your solution, you’ve found a real workaround!

The firmware limitation still exists. The Voice PE can’t send its response elsewhere. You’ve bypassed it by stopping the Voice PE response and then playing it back on another speaker.

The text is turned into speech a second time, by Piper, through your automation. So the speaker will always speak with the Piper voice, even if your pipeline uses a different voice engine, and there is a bit more delay.

Your entity name contains sendspin_bt_bridge_rc. Are you now using the Sendspin Bluetooth Bridge? That’s not a problem; your method works with any media player.

Your setup is exactly what ESPHome issue #320 requests. You should post it there; it could help advance the real solution in the firmware. Link: [REQUEST] Support choosing response media_player in config · Issue #320 · esphome/home-assistant-voice-pe · GitHub

Yes I am trying a variety of add-ons/ apps all valid, your is nice and lightweight and therefore responds faster, and more stable but I am using RC version of Sendspin BT Bridge. I don’t currently have a particular use for Sendspin BT Bridge as yet but now I can redirect the output it’s an nice option to have.

I am expecting a BT mic/ speaker soon only BT4.2 though to test multiple audio outputs and to test assist_satellite add-on as well.

I have posted to the discussion you linked above. Hopefully it is of some use. Perhaps it needs a bit of optimisation as the controls I guess are essentially “hardcoded locally” and it may not be practical to have it control which ever is chosen the the helper, I haven’t checked but I don’t see a problem other than hardware limitations of the VPE

Thanks for the feedback, I’m interested in your results with multiple speakers.

Yes “joining” multiple speakers works using join/ unjoin media_player action now .. I think it’s a Music Assistant feature, but they are slightly out of sink, not a problem for me as I will have them in different rooms. I have kinda got it to follow me around the home it switches in the VPE logs as I get close to any presence device in each room, trying to get it to redirect on the fly whilst MA is streaming the current music that has been chosen. There maybe a simpler way to do this. I am now tempted to repurpose any echo devices I have discarded in a box somewhere.

Thanks for the follow-up, glad the join/unjoin approach works for you. The slight sync gap makes sense, these are two independent Bluetooth links, not something built for tight synchronization. The presence-based follow-me setup sounds like a fun project, good luck with it.

Here is a potential solution that works (albeit rough around the edges). Currently I have 2 VPE devices with 2 sets of selectors that lists all speakers that are available in HASSIO Music Assistant add-on/ app as media_player entities:

The following add-on/ app is being used to add Bluetooth speakers as media-player entities:

VPE config (adopt/ take control VPE into espHOME Builder):

substitutions:
  name: home-assistant-voice-xxxxab
  friendly_name: Small Bedroom assistant
packages:
  Nabu Casa.Home Assistant Voice PE: github://esphome/home-assistant-voice-pe/home-assistant-voice.yaml

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
esp32:
  board: esp32-s3-devkitc-1
  toolchain: esp-idf
  framework:
    type: esp-idf
  
esphome:
  name: ${name}
  name_add_mac_suffix: false
  friendly_name: ${friendly_name}
  # FIXED: Removed the call to the non-existent script
  on_boot:
    priority: -100
    then:
      - lambda: |-
          ESP_LOGI("main", "System booted. Waiting for Home Assistant text sensor synchronization...");

# --- GLOBALS ---
globals:
  - id: route_to_external_speaker
    type: bool
    initial_value: 'false'
    restore_value: yes # Remembers the routing state across restarts
  - id: chosen_speaker
    type: std::string
    initial_value: '""'
    restore_value: yes # Permanently stores the entity string in memory

# --- TEXT SENSORS ---
text_sensor:
  - platform: homeassistant
    id: helper_selector_state
    entity_id: input_text.music_assistant_target_holder_2
    internal: false 
    on_value:
      then:
        - lambda: |-
            id(chosen_speaker) = x;
            
            // Convert string to lowercase for bulletproof verification matching
            std::string lower_x = x;
            std::transform(lower_x.begin(), lower_x.end(), lower_x.begin(), ::tolower);

            // 1. CRASH FIX: Detect if the target points back to itself
            bool is_self = (lower_x.find("dining") != std::string::npos || 
                            lower_x.find("assistant") != std::string::npos || 
                            lower_x.find("local") != std::string::npos);

            // 2. CRASH FIX: Detect if Music Assistant marked the player offline
            bool is_unavailable = (lower_x == "unavailable" || lower_x == "unknown" || lower_x.find("none") != std::string::npos);

            // 3. Routing condition: Only route if it has "speaker" and isn't self/dead
            bool has_speaker = (lower_x.find("speaker") != std::string::npos);

            if (has_speaker && !is_self && !is_unavailable) {
              id(route_to_external_speaker) = true;
              ESP_LOGD("main", "Routing updated: EXTERNAL SPEAKER ACTIVE (%s)", x.c_str());
            } else {
              id(route_to_external_speaker) = false;
              ESP_LOGD("main", "Routing updated: LOCAL SPEAKER ACTIVE (Self/Unavailable/Non-speaker blocked)");
            }

# --- API SERVICES ---
api:
  encryption:
    key: xxxxxxxxx
  actions:
    - action: update_audio_target
      variables:
        selected_target: string
      then:
        - lambda: |-
            id(chosen_speaker) = selected_target;
            
            std::string lower_target = selected_target;
            std::transform(lower_target.begin(), lower_target.end(), lower_target.begin(), ::tolower);

            // 1. CRASH FIX: Detect if the target points back to itself
            bool is_self = (lower_target.find("dining") != std::string::npos || 
                            lower_target.find("assistant") != std::string::npos || 
                            lower_target.find("local") != std::string::npos);

            // 2. CRASH FIX: Detect if player is offline
            bool is_unavailable = (lower_target == "unavailable" || lower_target == "unknown");

            // 3. Routing condition
            bool has_speaker = (lower_target.find("speaker") != std::string::npos);

            if (has_speaker && !is_self && !is_unavailable) {
              id(route_to_external_speaker) = true;
              ESP_LOGD("main", "ESPHome Routing Flag updated: EXTERNAL SPEAKER MODE ACTIVE (%s)", selected_target.c_str());
            } else {
              id(route_to_external_speaker) = false;
              ESP_LOGD("main", "ESPHome Routing Flag updated: LOCAL SPEAKER MODE ACTIVE (Self/Unavailable/Non-speaker blocked)");
            }

        # FIXED VOLUME SYNC: NATURALLY INTERPOLATES TO SYNCHRONIZE LED LIGHT SECTIONS
    - action: sync_local_volume
      variables:
        volume_level: float
      then: 
        - if: 
            condition:
              lambda: |-
                return (id(va).is_running() == false);
            then:
              # 1. Update the underlying audio state safely
              - media_player.volume_set:
                  id: external_media_player
                  volume: !lambda |-
                    return volume_level;
              
              # 2. Force the internal package UI variables to update and time out naturally
              - lambda: |-
                  id(dial_touched) = true;
              
              # 3. Force Nabu Casa's script runner to catch the change and animate the light arc
              - script.execute:
                  id: control_leds
              
              # 4. Briefly wait to display the level to the room, then cleanly trigger the timeout fade-out
              - delay: 1.5s
              - lambda: |-
                  id(dial_touched) = false;
              - script.execute:
                  id: control_leds
                  
              - lambda: |-
                  ESP_LOGD("hardware", "Synced absolute state and forced normal fading LED update: %f", volume_level);
                  
            
# --- VOICE ASSISTANT CONTROL ---
voice_assistant:
  id: va
  # (Your existing mic and hardware settings stay here)
  
  on_tts_start:
    - if:
        condition:
          lambda: |-
            // Only redirect if external routing is true AND it is NOT trying to play to itself
            std::string lower_speaker = id(chosen_speaker);
            std::transform(lower_speaker.begin(), lower_speaker.end(), lower_speaker.begin(), ::tolower);
            
            bool is_self = (lower_speaker.find("dining") != std::string::npos || 
                            lower_speaker.find("assistant") != std::string::npos || 
                            lower_speaker.find("local") != std::string::npos ||
                            lower_speaker.empty());

            return id(route_to_external_speaker) && !is_self;
        then:
          # This block ONLY runs when routing to a true external speaker
          - homeassistant.event:
              event: esphome.voice_pe_tts_redirect
              data:
                text: !lambda 'return x;'
                target_entity: !lambda 'return id(chosen_speaker);'
          - delay: 200ms
          - voice_assistant.stop
        else:
          # When playing to itself, we leave the pipeline open so the media streams normally
          - lambda: |-
              ESP_LOGD("main", "Local playback allowed. Bypassing TTS redirect/stop pipeline.");

# --- HARDWARE OVERRIDES VIA !EXTEND ---
# Merges custom routing rules into the existing VPE physical hardware keys

binary_sensor:
  - id: !extend center_button
    on_click:
      then:
        - if:
            condition:
              lambda: |-
                return id(route_to_external_speaker);
            then:
              # Stop ESPHome voice listening
              - voice_assistant.stop:

              # Stop playback on the selected external speaker
              - homeassistant.action:
                  action: media_player.media_stop
                  data:
                    entity_id: !lambda |-
                      return id(chosen_speaker).c_str();

              - lambda: |-
                  ESP_LOGD(
                    "hardware",
                    "Stopped voice assistant and playback on %s",
                    id(chosen_speaker).c_str()
                  );


sensor:
  - id: !extend dial
    on_clockwise:
      - if:
          condition:
            lambda: 'return id(route_to_external_speaker);'
          then:
            # 1. Block incoming software sync loops immediately by pretending the voice assistant is busy
            - lambda: |-
                id(dial_touched) = true;
            - homeassistant.action:
                action: media_player.volume_up
                data:
                  entity_id: !lambda 'return id(chosen_speaker);'
            - lambda: ESP_LOGD("hardware", "Intercepted clockwise turn. Volume Up sent to %s", id(chosen_speaker).c_str());
            # 2. Automatically release the block after 2 seconds of stillness
            - delay: 2s
            - lambda: |-
                id(dial_touched) = false;
                
    on_anticlockwise:
      - if:
          condition:
            lambda: 'return id(route_to_external_speaker);'
          then:
            # 1. Block incoming software sync loops immediately
            - lambda: |-
                id(dial_touched) = true;
            - homeassistant.action:
                action: media_player.volume_down
                data:
                  entity_id: !lambda 'return id(chosen_speaker);'
            - lambda: ESP_LOGD("hardware", "Intercepted anti-clockwise turn. Volume Down sent to %s", id(chosen_speaker).c_str());
            # 2. Automatically release the block after 2 seconds of stillness
            - delay: 2s
            - lambda: |-
                id(dial_touched) = false;

Create Text Helper to store selected Speaker entity_id:

Helpers > Create Helper > Text.

  • Give it a name e.g. “Music Assistant Target Holder 2”
  • Go to “more options” change maximum length to “255”

Create the Speaker template Selector helper:

Helpers > Create Helper > Template > Select

  • Give it a name e.g music_assistant_bedroom

Put the following in “state”

{{ states('input_text.music_assistant_target_holder_2') | replace('\"', '') | replace('\'', '') | trim }}

Put the following in “Available options”

{{ states.media_player    
|selectattr('attributes.mass_player_type', 'defined')
   | selectattr('state', 'ne', 'unavailable')

   | map(attribute='entity_id') | list }}

Put the following in “Actions on Select” (edit in yaml)

action: input_text.set_value
metadata: {}
target:
  entity_id: input_text.music_assistant_target_holder_2
data:
  value: '{{ option }}'

Automations:

Sync VPE Volume with selected Speaker

alias: 'VPE: Sync External Volume (On Change or Boot)'
description: >-
  Triggers when target audio source changes or when the VPE boots up to keep
  volume levels perfectly aligned.
triggers:
  - entity_id:
      - select.music_assistant_rooms
    id: source_changed
    trigger: state
    enabled: false
  - entity_id:
      - input_text.music_assistant_target_holder_1
    id: source_changed_dining_room
    trigger: state
  - entity_id:
      - input_text.music_assistant_target_holder_2
    id: source_changed_bedroom
    trigger: state
  - entity_id:
      - media_player.bluetooth_speaker
    id: volume_adjusted_bt_speaker
    trigger: state
  - entity_id:
      - media_player.speaker_1
    id: volume_adjusted_speaker_1
    trigger: state
  - entity_id:
      - media_player.small_bedroom_assistant_media_player
    from:
      - 'off'
      - unavailable
    to:
      - 'on'
      - idle
    id: Small bedroom vpe
    trigger: state
    enabled: true
  - entity_id:
      - media_player.dining_room_assistant_media_player
    from:
      - 'off'
      - unavailable
    to:
      - 'on'
      - idle
    id: Dining room vpe
    trigger: state
  - trigger: state
    entity_id:
      - media_player.living_room_tv_2
conditions:
  - condition: template
    value_template: '{{ is_state(''input_text.music_assistant_target_holder_2'')}}'
    enabled: false
actions:
  - choose:
      - conditions:
          - condition: trigger
            id:
              - volume_adjusted_bt_speaker
              - Small bedroom vpe
              - source_changed_bedroom
        sequence:
          - action: esphome.home_assistant_voice_xxxx1_sync_local_volume
            data:
              volume_level: >-
                {% set target =
                states('input_text.music_assistant_target_holder_2') %} {{
                state_attr(target, 'volume_level') | float(0.5) }}
      - conditions:
          - condition: trigger
            id:
              - volume_adjusted_speaker_1
              - Dining room vpe
              - source_changed_dining_room
        sequence:
          - action: esphome.home_assistant_voice_xxxx_sync_local_volume
            data:
              volume_level: >-
                {% set target =
                states('input_text.music_assistant_target_holder') %} {{
                state_attr(target, 'volume_level') | float(0.5) }}
variables:
  audio_output_target: '{{ states(''input_text.music_assistant_target_holder_2") }}'
mode: restart

Pushes dropdown selections to the ESP32 global variable instantly

alias: 'Voice PE: Sync Selected Audio State'
description: Pushes dropdown selections to the ESP32 global variable instantly
triggers:
  - entity_id:
      - select.music_assistant_rooms
    trigger: state
    enabled: false
  - entity_id:
      - input_text.music_assistant_target_holder
      - input_text.music_assistant_target_holder_2
    trigger: state
    enabled: true
actions:
  - action: |-
      {{ integration_entities('esphome') 
         | select('search', 'sync_local_volume') 

         | first | default('esphome.home_assistant_voice_xxxxab_sync_local_volume') }}
    data:
      selected_target: '{{ trigger.to_state.state }}'
    enabled: false
  - action: |-
      {{ integration_entities('esphome') 
         | select('search', 'sync_local_volume') 

         | first | default('esphome.home_assistant_voice_xxxab_sync_local_volume') }}
    data:
      selected_target: '{{ trigger.to_state.state }}'
    enabled: false
  - action: input_text.set_value
    target:
      entity_id: >-
        {% set entities = integration_entities('esphome') | select('search',
        'sync_local_volume') | list %} {{ entities | first if entities | length
        > 0 else audio_output_target }}
    data:
      value: '{{ trigger.to_state.state }}'
variables:
  audio_output_target: '{{ states(''input_text.music_assistant_target_holder_2'') }}'

Dashboard Card. HACS “Mini Media Player”, “Config Template Card” must be installed:

type: grid
square: false
columns: 1
cards:
  - type: custom:config-template-card
    entities:
      - input_text.music_assistant_target_holder_2
    card:
      type: media-control
      entity: ${states['input_text.music_assistant_target_holder_2'].state}
      name: Now Playing - Small Bedroom VPE
  - type: custom:config-template-card
    entities:
      - input_text.music_assistant_target_holder_2
    card:
      type: tile
      entity: ${states['input_text.music_assistant_target_holder_2'].state}
      features:
        - type: media-player-volume-slider
  - type: entities
    title: Speaker Selection Bedroom
    show_header_toggle: false
    entities:
      - entity: select.music_assistant_bedroom
        name: Active Output

Radio Player. This example uses Music Assistant add-on/app, the entity_id in each option in this automation was selected manually then doesn’t mater so long as it is a Music Assistant entity (in this case) and then the entity id is replaced with the variable

entity_id: ' {{ (voice_assistant_device) }} '  

Automation:

alias: Radio Player
description: ''
triggers:
  - trigger: conversation
    command:
      - '[Clay] [Play] [Plays] [A] classic [(FM|SM)]'
    id: Play Classic F-M
  - trigger: conversation
    command:
      - >-
        [Clay] [Play] [Plays] [A] [Classic] [(FM|SM)]
        (calm|call|carn|car|card|FM-calm|FM-call|FM-carm|FM-carn|FM-car|FM-card)
    id: Classic FM calm
  - trigger: conversation
    command:
      - '[Clay] [Play] [Plays] [A] [Classic] [(FM|SM)] (movies)'
    id: Classic FM movies
  - trigger: conversation
    command:
      - '[Clay] [Play] [Plays] Trance'
    id: Trance
conditions: []
actions:
  - action: input_button.press
    metadata: {}
    target:
      entity_id: input_button.check_music_assistant
    data: {}
  - delay:
      hours: 0
      minutes: 0
      seconds: 5
      milliseconds: 0
  - if:
      - condition: state
        entity_id: binary_sensor.music_assistant_addon_running
        state:
          - 'off'
          - unavailable
          - unknown
    then:
      - action: assist_satellite.ask_question
        metadata: {}
        data:
          question: Would you like to start music assistant?
          preannounce: true
          answers:
            - id: MA_START
              sentences:
                - 'yes'
                - 'no'
          entity_id: ' {{ assist_satellite_device }} '
        response_variable: music_assistant_startup
      - if:
          - condition: template
            value_template: '{{ ''yes'' in music_assistant_startup.sentence }}'
        then:
          - action: hassio.addon_start
            metadata: {}
            data:
              addon: d5369777_music_assistant_beta
          - set_conversation_response: Starting music assistant
          - action: assist_satellite.announce
            metadata: {}
            target:
              entity_id: '  {{ assist_satellite_device }} '
            data:
              message: Starting music assistant
              preannounce: false
          - repeat:
              until:
                - condition: template
                  value_template: >-
                    {{is_state("binary_sensor.music_assistant_addon_running",
                    "on")}}
              sequence:
                - action: input_button.press
                  metadata: {}
                  target:
                    entity_id: input_button.check_music_assistant
                  data: {}
      - delay:
          hours: 0
          minutes: 0
          seconds: 15
          milliseconds: 0
  - choose:
      - conditions:
          - condition: trigger
            id:
              - Play Classic F-M
        sequence:
          - action: media_player.play_media
            target:
              entity_id: ' {{ (voice_assistant_device) }} '
            data:
              media:
                media_content_id: library://radio/1
                media_content_type: music
                metadata:
                  title: Classic FM (UK)
                  thumbnail: >-
                    https://www.classicfm.com/assets_v4r/classic/img/favicon-196x196.png
                  media_class: music
                  children_media_class: null
                  navigateIds:
                    - {}
                    - media_content_type: music_assistant
                      media_content_id: radio
            enabled: true
      - conditions:
          - condition: trigger
            id:
              - Classic FM calm
        sequence:
          - action: media_player.play_media
            target:
              entity_id: ' {{ (voice_assistant_device) }} '
            data:
              media:
                media_content_id: library://radio/2
                media_content_type: music
                metadata:
                  title: Classic FM Calm
                  thumbnail: >-
                    https://www.classicfm.com/assets_v4r/classic/img/favicon-196x196.png
                  media_class: music
                  children_media_class: null
                  navigateIds:
                    - {}
                    - media_content_type: music_assistant
                      media_content_id: radio
      - conditions:
          - condition: trigger
            id:
              - Classic FM movies
        sequence:
          - action: media_player.play_media
            target:
              entity_id: ' {{ (voice_assistant_device) }} '
            data:
              media:
                media_content_id: library://radio/8
                media_content_type: music
                metadata:
                  title: Classic FM Movies
                  thumbnail: >-
                    https://www.classicfm.com/assets_v4r/classic/img/favicon-196x196.png
                  media_class: music
                  children_media_class: null
                  navigateIds:
                    - {}
                    - media_content_type: music_assistant
                      media_content_id: radio
      - conditions:
          - condition: trigger
            id:
              - Trance
        sequence:
          - action: media_player.play_media
            metadata: {}
            target:
              entity_id: '  {{ (voice_assistant_device) }} '
            data:
              media:
                media_content_id: library://radio/13
                media_content_type: music
                metadata:
                  title: 90s90s Trance
                  thumbnail: null
                  media_class: music
                  children_media_class: null
                  navigateIds:
                    - {}
                    - media_content_type: music_assistant
                      media_content_id: radio
                  browse_entity_id: media_player.dining_room_assistant_media_player
              announce: false
variables:
  media_player_id: ' {{ trigger.device_id }} '
  media_player: ' {{ device_entities(media_player_id) |select(''search'',''^media_player.'')|first }} '
  friendly_name: ' {{ states[media_player][''attributes''][''friendly_name''] }} '
  media_player_redact: ' {{ (media_player) | regex_replace(find=''media_player'', replace='''', ignorecase=False) }} '
  prepend1: media_player.
  prepend2: assist_satellite
  itself: ' {{ prepend1 ~ (friendly_name) | slugify }} '
  audio_output_target: |-
    {% if itself == 'media_player.small_bedroom_assistant_media_player' %}
      {{ states('input_text.music_assistant_target_holder_2') }}
    {% elif itself == 'media_player.dining_room_assistant_media_player' %}
      {{ states('input_text.music_assistant_target_holder') }}
    {% else %}
      ''
    {% endif %}
  assist_satellite_device: ' {{ trigger.satellite_id }} '
  voice_assistant_device: ' {{ (audio_output_target) }} '
  voice_assistant_device-old: >-
    {% set lower_target = audio_output_target | lower %} {% if
    audio_output_target in ['unavailable', 'unknown', 'local'] 
          or 'dining room' in lower_target 
          or 'assistant' in lower_target
          or 'speaker' not in lower_target %}
      {{ itself }}
    {% else %}
      {{ audio_output_target }}
    {% endif %}
mode: parallel
max: 3

Player controller for stop, mute, volume control etc:

alias: Voice assistant controls
description: ''
triggers:
  - trigger: conversation
    command:
      - (stop|top|shut up)
      - Top [playing] [media] [player]
      - Stop [playing] [media] [player]
    id: Stop
  - trigger: conversation
    command:
      - Pause [(media|player)]
    id: Pause
  - trigger: conversation
    command:
      - '[Set] (volume|sound) [to] (1|5|10|15|20|30|40|50|60|70|80|90|100)'
    id: Volume
    variables:
      volume_level_sentence: ' {{ trigger.sentence }} '
      volume_level: ' {{ (volume_level_sentence) |regex_findall(''\d+'')|first|int|multiply(0.01) }} '
      volume: ' {{ (volume_level) }} '
  - trigger: conversation
    command:
      - (Mute|nute)
    id: mute
  - trigger: conversation
    command:
      - (unmute|unnute)
    id: unmute
  - trigger: conversation
    command:
      - (start)
    id: Start
conditions: []
actions:
  - choose:
      - conditions:
          - condition: trigger
            id:
              - Stop
        sequence:
          - action: media_player.media_stop
            metadata: {}
            data: {}
            target:
              entity_id: ' {{ (voice_assistant_device) }} '
          - if:
              - condition: and
                conditions:
                  - condition: state
                    entity_id: binary_sensor.music_assistant_addon_running
                    state:
                      - 'on'
                  - condition: time
                    after: '21:00:00'
                    before: '07:30:00'
            then:
              - action: hassio.addon_stop
                metadata: {}
                data:
                  addon: d5369777_music_assistant_beta
            enabled: false
          - set_conversation_response: .
            enabled: false
      - conditions:
          - condition: trigger
            id:
              - Pause
        sequence:
          - action: media_player.media_pause
            metadata: {}
            data: {}
            target:
              entity_id: ' {{ (voice_assistant_device) }} '
          - set_conversation_response: .
      - conditions:
          - condition: trigger
            id:
              - Volume
        sequence:
          - action: media_player.volume_set
            metadata: {}
            data:
              volume_level: ' {{ (volume) }} '
            target:
              entity_id: ' {{ (voice_assistant_device) }}  '
            enabled: true
      - conditions:
          - condition: trigger
            id:
              - mute
        sequence:
          - action: media_player.volume_mute
            metadata: {}
            data:
              is_volume_muted: true
            target:
              entity_id: ' {{ (voice_assistant_device) }} '
            enabled: true
      - conditions:
          - condition: trigger
            id:
              - unmute
        sequence:
          - action: media_player.volume_mute
            metadata: {}
            data:
              is_volume_muted: false
            target:
              entity_id: ' {{ (voice_assistant_device) }} '
            enabled: true
      - conditions:
          - condition: trigger
            id:
              - Start
        sequence:
          - action: media_player.media_play
            metadata: {}
            target:
              entity_id: ' {{ (voice_assistant_device) }} '
            data: {}
           enabled: true
variables:
  media_player_id: ' {{ trigger.device_id }} '
  media_player: ' {{ device_entities(media_player_id) |select(''search'',''^media_player.'')|first }} '
  friendly_name: ' {{ states[media_player][''attributes''][''friendly_name''] }} '
  prepend1: media_player.
  itself: ' {{ prepend1 ~ (friendly_name) | slugify }} '
  audio_output_target: |-
    {% if itself == 'media_player.small_bedroom_assistant_media_player' %}
      {{ states('input_text.music_assistant_target_holder_2') }}
    {% elif itself == 'media_player.dining_room_assistant_media_player' %}
      {{ states('input_text.music_assistant_target_holder') }}
    {% else %}
      ''
    {% endif %}
  voice_assistant_device: ' {{ (audio_output_target) }} '
mode: parallel
max: 5