From 086ea27638041b08a9424f41050ea4a5b45fea93 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 2 Sep 2026 00:02:07 +0200 Subject: [PATCH 1/2] Add message field to Music Assistant play announcement action (#179034) Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- .../components/music_assistant/const.py | 2 + .../components/music_assistant/manifest.json | 2 +- .../music_assistant/media_player.py | 26 ++- .../components/music_assistant/services.py | 25 ++- .../components/music_assistant/services.yaml | 14 +- .../components/music_assistant/strings.json | 13 +- .../music_assistant/test_media_player.py | 166 +++++++++++++++++- 7 files changed, 234 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py index 675337caa305d9..7278e3aef1b748 100644 --- a/homeassistant/components/music_assistant/const.py +++ b/homeassistant/components/music_assistant/const.py @@ -47,6 +47,8 @@ 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" diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index 48258e5ea9a27c..baad5c756b81aa 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -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"], diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index a43656ced570f4..176fb34bc3c941 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -23,7 +23,7 @@ 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 @@ 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 @@ async def _async_handle_play_media( @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, diff --git a/homeassistant/components/music_assistant/services.py b/homeassistant/components/music_assistant/services.py index 1bb9c1a8df5bb4..631c2132afcc2d 100644 --- a/homeassistant/components/music_assistant/services.py +++ b/homeassistant/components/music_assistant/services.py @@ -9,6 +9,7 @@ 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 @@ ATTR_LIMIT, ATTR_MEDIA_ID, ATTR_MEDIA_TYPE, + ATTR_MESSAGE, ATTR_OFFSET, ATTR_ORDER_BY, ATTR_PLAYLISTS, @@ -49,6 +51,7 @@ 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( diff --git a/homeassistant/components/music_assistant/services.yaml b/homeassistant/components/music_assistant/services.yaml index 45033c7224402f..6e7e1df214c4db 100644 --- a/homeassistant/components/music_assistant/services.yaml +++ b/homeassistant/components/music_assistant/services.yaml @@ -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: diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index 9f228d3b70a500..f403a2b37cc781 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -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": { diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index b3e7e549654b31..3557f6f516a539 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -15,6 +15,7 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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, From 17be34523df3720709f1fa084eca8833f29a6a3f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:45:18 +0200 Subject: [PATCH 2/2] Update uv to 0.12.6 (#181047) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 912427b1350766..8c184b6954d91d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -72,7 +72,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.16.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.12.5 +uv==0.12.6 webrtc-models==0.3.0 yarl==1.24.5 zeroconf==0.151.1 diff --git a/pyproject.toml b/pyproject.toml index c9c2e46164d28c..85e74c8facf1cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "typing-extensions>=4.16.0,<5.0", "ulid-transform==2.2.9", "urllib3>=2.0", - "uv==0.12.5", + "uv==0.12.6", "probatio==0.11.4", "yarl==1.24.5", "webrtc-models==0.3.0", diff --git a/requirements.txt b/requirements.txt index 6731b5d5b6ec5b..674ee602aa81db 100644 --- a/requirements.txt +++ b/requirements.txt @@ -57,7 +57,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.16.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.12.5 +uv==0.12.6 webrtc-models==0.3.0 yarl==1.24.5 zeroconf==0.151.1