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:
Connect any Bluetooth speaker to Home Assistant as a native media_player, with optional MPD/Music Assistant support.
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