Add message field to Music Assistant play announcement action (#179034)

Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
This commit is contained in:
Marcel van der Veldt
2026-09-02 00:02:07 +02:00
committed by GitHub
co-authored by Artur Pragacz
parent 80359bfb46
commit 086ea27638
7 changed files with 234 additions and 14 deletions
@@ -47,6 +47,8 @@ ATTR_MEDIA_ID = "media_id"
ATTR_ARTIST = "artist"
ATTR_ALBUM = "album"
ATTR_URL = "url"
ATTR_MESSAGE = "message"
ATTR_TTS_ENTITY_ID = "tts_entity_id"
ATTR_USE_PRE_ANNOUNCE = "use_pre_announce"
ATTR_ANNOUNCE_VOLUME = "announce_volume"
ATTR_PRE_ANNOUNCE_URL = "pre_announce_url"
@@ -1,7 +1,7 @@
{
"domain": "music_assistant",
"name": "Music Assistant",
"after_dependencies": ["media_source"],
"after_dependencies": ["media_source", "tts"],
"codeowners": ["@music-assistant", "@arturpragacz"],
"config_flow": true,
"dependencies": ["auth"],
@@ -23,7 +23,7 @@ from music_assistant_models.event import MassEvent
from music_assistant_models.media_items import ItemMapping, MediaItemType
from music_assistant_models.player_queue import PlayerQueue
from homeassistant.components import media_source
from homeassistant.components import media_source, tts
from homeassistant.components.media_player import (
ATTR_MEDIA_EXTRA,
BrowseMedia,
@@ -38,7 +38,7 @@ from homeassistant.components.media_player import (
SearchMediaQuery,
async_process_play_media_url,
)
from homeassistant.const import ATTR_NAME, STATE_OFF, Platform
from homeassistant.const import ATTR_NAME, STATE_OFF, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant, ServiceResponse
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
@@ -563,12 +563,32 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
@catch_musicassistant_error
async def _async_handle_play_announcement(
self,
url: str,
url: str | None = None,
message: str | None = None,
tts_entity_id: str | None = None,
use_pre_announce: bool | None = None,
pre_announce_url: str | None = None,
announce_volume: int | None = None,
) -> None:
"""Send the play_announcement command to the media player."""
if url is None:
if TYPE_CHECKING:
assert message is not None
assert tts_entity_id is not None
# a gone or unavailable entity would otherwise yield a url that plays nothing
tts_state = self.hass.states.get(tts_entity_id)
if tts_state is None or tts_state.state == STATE_UNAVAILABLE:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="tts_entity_not_available",
translation_placeholders={"entity_id": tts_entity_id},
)
sourced_media = await media_source.async_resolve_media(
self.hass,
tts.generate_media_source_id(self.hass, message, engine=tts_entity_id),
self.entity_id,
)
url = async_process_play_media_url(self.hass, sourced_media.url)
await self.mass.players.play_announcement(
self.player_id,
url,
@@ -9,6 +9,7 @@ from homeassistant.components.media_player import (
ATTR_MEDIA_ENQUEUE,
DOMAIN as MEDIA_PLAYER_DOMAIN,
)
from homeassistant.components.tts import DOMAIN as TTS_DOMAIN
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
from homeassistant.core import (
HomeAssistant,
@@ -36,6 +37,7 @@ from .const import (
ATTR_LIMIT,
ATTR_MEDIA_ID,
ATTR_MEDIA_TYPE,
ATTR_MESSAGE,
ATTR_OFFSET,
ATTR_ORDER_BY,
ATTR_PLAYLISTS,
@@ -49,6 +51,7 @@ from .const import (
ATTR_SEARCH_NAME,
ATTR_SOURCE_PLAYER,
ATTR_TRACKS,
ATTR_TTS_ENTITY_ID,
ATTR_URL,
ATTR_USE_PRE_ANNOUNCE,
ATTR_USERNAME,
@@ -150,12 +153,22 @@ def register_actions(hass: HomeAssistant) -> None:
DOMAIN,
SERVICE_PLAY_ANNOUNCEMENT,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema={
vol.Required(ATTR_URL): cv.string,
vol.Optional(ATTR_USE_PRE_ANNOUNCE): vol.Coerce(bool),
vol.Optional(ATTR_PRE_ANNOUNCE_URL): cv.string,
vol.Optional(ATTR_ANNOUNCE_VOLUME): vol.Coerce(int),
},
schema=vol.All(
cv.make_entity_service_schema(
{
vol.Optional(ATTR_URL): cv.string,
vol.Inclusive(ATTR_MESSAGE, "spoken_announcement"): cv.string,
vol.Inclusive(ATTR_TTS_ENTITY_ID, "spoken_announcement"): vol.All(
cv.entity_id, cv.entity_domain(TTS_DOMAIN)
),
vol.Optional(ATTR_USE_PRE_ANNOUNCE): vol.Coerce(bool),
vol.Optional(ATTR_PRE_ANNOUNCE_URL): cv.string,
vol.Optional(ATTR_ANNOUNCE_VOLUME): vol.Coerce(int),
}
),
cv.has_at_least_one_key(ATTR_URL, ATTR_MESSAGE),
cv.has_at_most_one_key(ATTR_URL, ATTR_MESSAGE),
),
func="_async_handle_play_announcement",
)
service.async_register_platform_entity_service(
@@ -62,8 +62,20 @@ play_announcement:
- media_player.MediaPlayerEntityFeature.PLAY_MEDIA
- media_player.MediaPlayerEntityFeature.MEDIA_ANNOUNCE
fields:
message:
required: false
example: "Dinner is ready!"
selector:
text:
multiline: true
tts_entity_id:
required: false
example: "tts.piper"
selector:
entity:
domain: tts
url:
required: true
required: false
example: "http://someremotesite.com/doorbell.mp3"
selector:
text:
@@ -262,6 +262,9 @@
"exceptions": {
"invalid_username": {
"message": "The username {username} does not exist on the Music Assistant server."
},
"tts_entity_not_available": {
"message": "The text-to-speech entity {entity_id} is not available."
}
},
"issues": {
@@ -387,12 +390,20 @@
"description": "Use a forced volume level for the announcement. Omit to use player default.",
"name": "Announce volume"
},
"message": {
"description": "Text to announce, spoken by the selected text-to-speech entity. Provide either a message or a URL.",
"name": "Message"
},
"pre_announce_url": {
"description": "URL to the pre-announcement sound.",
"name": "Pre-announce URL"
},
"tts_entity_id": {
"description": "Text-to-speech entity that speaks the message. Required when a message is given.",
"name": "Text-to-speech entity"
},
"url": {
"description": "URL to the notification sound.",
"description": "URL to the notification sound. Provide either a message or a URL.",
"name": "URL"
},
"use_pre_announce": {
@@ -15,6 +15,7 @@ from music_assistant_models.player import PlayerMedia
import pytest
from syrupy.assertion import SnapshotAssertion
from syrupy.filters import paths
import voluptuous as vol
from homeassistant.components.media_player import (
ATTR_GROUP_MEMBERS,
@@ -44,9 +45,11 @@ from homeassistant.components.music_assistant.const import (
ATTR_AUTO_PLAY,
ATTR_MEDIA_ID,
ATTR_MEDIA_TYPE,
ATTR_MESSAGE,
ATTR_PRE_ANNOUNCE_URL,
ATTR_RADIO_MODE,
ATTR_SOURCE_PLAYER,
ATTR_TTS_ENTITY_ID,
ATTR_URL,
ATTR_USE_PRE_ANNOUNCE,
ATTR_USERNAME,
@@ -59,7 +62,8 @@ from homeassistant.components.music_assistant.services import (
SERVICE_PLAY_MEDIA_ADVANCED,
SERVICE_TRANSFER_QUEUE,
)
from homeassistant.config_entries import HomeAssistantError
from homeassistant.components.tts import DATA_TTS_MANAGER
from homeassistant.config_entries import ConfigFlow, HomeAssistantError
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_MEDIA_NEXT_TRACK,
@@ -75,11 +79,14 @@ from homeassistant.const import (
SERVICE_VOLUME_MUTE,
SERVICE_VOLUME_SET,
SERVICE_VOLUME_UP,
STATE_UNAVAILABLE,
Platform,
)
from homeassistant.core import Context, HomeAssistant
from homeassistant.core_config import async_process_ha_core_config
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from .common import (
create_players_from_fixture,
@@ -88,7 +95,12 @@ from .common import (
trigger_subscription_callback,
)
from tests.common import AsyncMock, MockUser
from tests.common import AsyncMock, MockUser, mock_config_flow, mock_platform
from tests.components.tts.common import (
DEFAULT_LANG,
MockTTSEntity,
mock_config_entry_setup,
)
MOCK_TRACK = Track(
item_id="1",
@@ -96,6 +108,31 @@ MOCK_TRACK = Track(
name="Test Track",
provider_mappings={},
)
MOCK_TTS_ENTITY_ID = "tts.test"
MOCK_SECOND_TTS_ENTITY_ID = "tts.second"
class MockTTSConfigFlow(ConfigFlow):
"""Config flow for the mock text-to-speech integration."""
class MockSecondTTSEntity(MockTTSEntity):
"""Second mock text-to-speech entity."""
_attr_name = "Second"
@pytest.fixture(name="tts_entities")
async def tts_entities_fixture(hass: HomeAssistant) -> None:
"""Set up two text-to-speech entities, of which the first is the default engine."""
assert await async_setup_component(hass, "media_source", {})
for test_domain, tts_entity in (
("test", MockTTSEntity(DEFAULT_LANG)),
("test2", MockSecondTTSEntity(DEFAULT_LANG)),
):
mock_platform(hass, f"{test_domain}.config_flow")
with mock_config_flow(test_domain, MockTTSConfigFlow):
await mock_config_entry_setup(hass, tts_entity, test_domain=test_domain)
@pytest.mark.parametrize(
@@ -1007,6 +1044,131 @@ async def test_media_player_play_announcement_action(
)
@pytest.mark.parametrize(
"tts_entity_id",
[MOCK_TTS_ENTITY_ID, MOCK_SECOND_TTS_ENTITY_ID],
ids=["default tts entity", "non-default tts entity"],
)
@pytest.mark.usefixtures("mock_tts_cache_dir", "tts_entities")
async def test_media_player_play_announcement_action_with_message(
hass: HomeAssistant,
music_assistant_client: MagicMock,
tts_entity_id: str,
) -> None:
"""Test media_player play_announcement action speaks a message with the given entity."""
await async_process_ha_core_config(
hass, {"internal_url": "http://example.local:8123"}
)
await setup_integration_from_fixtures(hass, music_assistant_client)
entity_id = "media_player.test_player_1"
mass_player_id = "00:00:00:00:00:01"
await hass.services.async_call(
DOMAIN,
SERVICE_PLAY_ANNOUNCEMENT,
{
ATTR_ENTITY_ID: entity_id,
ATTR_MESSAGE: "Dinner is ready!",
ATTR_TTS_ENTITY_ID: tts_entity_id,
ATTR_USE_PRE_ANNOUNCE: True,
ATTR_ANNOUNCE_VOLUME: 50,
},
blocking=True,
)
assert music_assistant_client.send_command.call_count == 1
announcement_url = music_assistant_client.send_command.call_args.kwargs["url"]
assert announcement_url.startswith("http://example.local:8123/api/tts_proxy/")
stream = hass.data[DATA_TTS_MANAGER].token_to_stream[
announcement_url.rsplit("/", 1)[-1]
]
assert stream.engine == tts_entity_id
assert music_assistant_client.send_command.call_args == call(
"players/cmd/play_announcement",
require_schema=None,
player_id=mass_player_id,
url=announcement_url,
pre_announce=True,
volume_level=50,
pre_announce_url=None,
message=None,
tts_engine=None,
)
@pytest.mark.parametrize(
"announcement_data",
[
{},
{
ATTR_URL: "http://blah.com/announcement.mp3",
ATTR_MESSAGE: "Dinner is ready!",
ATTR_TTS_ENTITY_ID: MOCK_TTS_ENTITY_ID,
},
{ATTR_MESSAGE: "Dinner is ready!"},
{
ATTR_URL: "http://blah.com/announcement.mp3",
ATTR_TTS_ENTITY_ID: MOCK_TTS_ENTITY_ID,
},
{
ATTR_MESSAGE: "Dinner is ready!",
ATTR_TTS_ENTITY_ID: "media_player.test_player_2",
},
],
ids=[
"neither url nor message",
"both url and message",
"message without tts entity",
"tts entity without message",
"entity outside the tts domain",
],
)
async def test_media_player_play_announcement_action_invalid_input(
hass: HomeAssistant,
music_assistant_client: MagicMock,
announcement_data: dict[str, str],
) -> None:
"""Test play_announcement action requires either a url or a message with an entity."""
await setup_integration_from_fixtures(hass, music_assistant_client)
with pytest.raises(vol.Invalid):
await hass.services.async_call(
DOMAIN,
SERVICE_PLAY_ANNOUNCEMENT,
{
ATTR_ENTITY_ID: "media_player.test_player_1",
**announcement_data,
},
blocking=True,
)
assert music_assistant_client.send_command.call_count == 0
@pytest.mark.parametrize(
"tts_entity_id",
["tts.does_not_exist", MOCK_TTS_ENTITY_ID],
ids=["unknown tts entity", "unavailable tts entity"],
)
async def test_media_player_play_announcement_action_unusable_tts_entity(
hass: HomeAssistant,
music_assistant_client: MagicMock,
tts_entity_id: str,
) -> None:
"""Test play_announcement action reports a text-to-speech entity it cannot use."""
await setup_integration_from_fixtures(hass, music_assistant_client)
hass.states.async_set(MOCK_TTS_ENTITY_ID, STATE_UNAVAILABLE)
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
DOMAIN,
SERVICE_PLAY_ANNOUNCEMENT,
{
ATTR_ENTITY_ID: "media_player.test_player_1",
ATTR_MESSAGE: "Dinner is ready!",
ATTR_TTS_ENTITY_ID: tts_entity_id,
},
blocking=True,
)
assert exc_info.value.translation_key == "tts_entity_not_available"
assert music_assistant_client.send_command.call_count == 0
async def test_media_player_transfer_queue_action(
hass: HomeAssistant,
music_assistant_client: MagicMock,