mirror of
https://github.com/home-assistant/core.git
synced 2026-09-24 23:41:48 -05:00
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:
co-authored by
Artur Pragacz
parent
80359bfb46
commit
086ea27638
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user