Add dashboard display media players to Music Assistant (#179386)

This commit is contained in:
Marvin Schenkel
2026-10-05 14:57:03 +02:00
committed by GitHub
parent e130747f6c
commit 711cd743e2
13 changed files with 2047 additions and 33 deletions
@@ -38,7 +38,7 @@ from homeassistant.helpers.issue_registry import (
async_delete_issue,
)
from .const import ATTR_CONF_EXPOSE_PLAYER_TO_HA, DOMAIN, LOGGER
from .const import ATTR_CONF_EXPOSE_PLAYER_TO_HA, DASHBOARD_ID_PREFIX, DOMAIN, LOGGER
from .helpers import get_music_assistant_client
from .services import register_actions
@@ -240,14 +240,20 @@ async def async_setup_entry( # noqa: C901
mass.subscribe(handle_player_config_updated, EventType.PLAYER_CONFIG_UPDATED)
)
# check if any playerconfigs have been removed while we were disconnected
# check if any playerconfigs have been removed while we were disconnected.
# never clean up dashboard devices: their registration is connection-scoped,
# so the cache may still be empty right after a reconnect.
all_player_configs = await mass.config.get_player_configs()
player_ids = {player.player_id for player in all_player_configs}
dev_reg = dr.async_get(hass)
dev_entries = dr.async_entries_for_config_entry(dev_reg, entry.entry_id)
for device in dev_entries:
for identifier in device.identifiers:
if identifier[0] == DOMAIN and identifier[1] not in player_ids:
if (
identifier[0] == DOMAIN
and not identifier[1].startswith(DASHBOARD_ID_PREFIX)
and identifier[1] not in player_ids
):
dev_reg.async_remove_device(device.id)
return True
@@ -297,7 +303,11 @@ async def async_remove_config_entry_device(
device_entry: dr.AnyDeviceEntry,
) -> bool:
"""Remove a config entry from a device."""
player_id = next(
if not isinstance(device_entry, dr.DeviceEntry):
# child devices cannot be removed on their own
return False
# the identifier is a player_id, or f"dashboard:{dashboard_id}" for displays
identifier_value = next(
(
identifier[1]
for identifier in device_entry.identifiers
@@ -305,16 +315,20 @@ async def async_remove_config_entry_device(
),
None,
)
if player_id is None:
if identifier_value is None:
# this should not be possible at all, but guard it anyways
return False
mass = get_music_assistant_client(hass, config_entry.entry_id)
if mass.players.get(player_id) is None:
# player is already removed on the server, this is an orphaned device
if identifier_value.startswith(DASHBOARD_ID_PREFIX):
dashboard_id = identifier_value.removeprefix(DASHBOARD_ID_PREFIX)
# the display endpoint is still live, refuse removal
return mass.dashboard.get(dashboard_id) is None
if mass.players.get(identifier_value) is None:
# player is already gone from the server, this is an orphaned device
return True
# try to remove the player from the server
try:
await mass.config.remove_player_config(player_id)
await mass.config.remove_player_config(identifier_value)
except ActionUnavailable:
return False
else:
@@ -80,4 +80,9 @@ ATTR_USERNAME = "username"
ATTR_CONF_EXPOSE_PLAYER_TO_HA = "expose_player_to_ha"
# prefix of dashboard device identifiers and entity unique ids, which tells
# them apart from player ids (a display may share its id with a player)
DASHBOARD_ID_PREFIX = "dashboard:"
DASHBOARD_DEVICE_MODEL = "Dashboard display"
LOGGER = logging.getLogger(__package__)
@@ -7,10 +7,11 @@ from music_assistant_models.event import MassEvent
from music_assistant_models.player import Player, PlayerOption
from homeassistant.const import EntityCategory
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
from .const import DASHBOARD_DEVICE_MODEL, DOMAIN
from .helpers import dashboard_identifier
if TYPE_CHECKING:
from music_assistant_client import MusicAssistantClient
@@ -29,7 +30,7 @@ class MusicAssistantEntity(Entity):
provider = self.mass.get_provider(self.player.provider)
if TYPE_CHECKING:
assert provider is not None
self._attr_device_info = DeviceInfo(
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, player_id)},
manufacturer=self.player.device_info.manufacturer or provider.name,
model=self.player.device_info.model or self.player.name,
@@ -129,3 +130,57 @@ class MusicAssistantPlayerOptionEntity(MusicAssistantEntity):
def on_player_option_update(self, player_option: PlayerOption) -> None:
"""Callback for player option updates."""
class MusicAssistantDashboardEntity(Entity):
"""Base entity for a Music Assistant dashboard display device."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(self, mass: MusicAssistantClient, dashboard_id: str) -> None:
"""Initialize MusicAssistantDashboardEntity."""
self.mass = mass
self.dashboard_id = dashboard_id
# this is only ever constructed for a dashboard_id known to the cache
dashboard = mass.dashboard.get(dashboard_id)
if TYPE_CHECKING:
assert dashboard is not None
self._attr_unique_id = dashboard_identifier(dashboard_id)
# namespaced: Fully Kiosk registers dashboard_id == player_id, and a bare
# id here would merge this device into the player's own device
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
name=dashboard.name,
manufacturer="Music Assistant",
model=DASHBOARD_DEVICE_MODEL,
)
@property
@override
def available(self) -> bool:
"""Return availability of entity."""
return self.mass.dashboard.get(self.dashboard_id) is not None and bool(
self.mass.connection.connected
)
@override
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
self.async_on_remove(
self.mass.subscribe(
self.__on_dashboards_updated, EventType.DASHBOARDS_UPDATED
)
)
async def __on_dashboards_updated(self, event: MassEvent) -> None:
"""Refresh availability, and the device name if it was re-registered."""
if (
(dashboard := self.mass.dashboard.get(self.dashboard_id)) is not None
and self.device_entry is not None
and self.device_entry.name != dashboard.name
):
dr.async_get(self.hass).async_update_device(
self.device_entry.id, name=dashboard.name
)
self.async_write_ha_state()
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from .const import DOMAIN
from .const import DASHBOARD_ID_PREFIX, DOMAIN
if TYPE_CHECKING:
from music_assistant_client import MusicAssistantClient
@@ -19,6 +19,11 @@ if TYPE_CHECKING:
from . import MusicAssistantConfigEntry
def dashboard_identifier(dashboard_id: str) -> str:
"""Return the device identifier and entity unique id of a dashboard display."""
return f"{DASHBOARD_ID_PREFIX}{dashboard_id}"
def catch_musicassistant_error[**_P, _R](
func: Callable[_P, Coroutine[Any, Any, _R]],
) -> Callable[_P, Coroutine[Any, Any, _R]]:
@@ -10,6 +10,7 @@ from music_assistant_client.helpers import LinkedUser
from music_assistant_models.auth import AuthProviderType
from music_assistant_models.constants import PLAYER_CONTROL_NONE
from music_assistant_models.enums import (
DashboardType,
EventType,
MediaType,
PlayerFeature,
@@ -18,7 +19,7 @@ from music_assistant_models.enums import (
QueueOption,
RepeatMode as MassRepeatMode,
)
from music_assistant_models.errors import MediaNotFoundError
from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError
from music_assistant_models.event import MassEvent
from music_assistant_models.media_items import ItemMapping, MediaItemType
from music_assistant_models.player_queue import PlayerQueue
@@ -26,7 +27,9 @@ from music_assistant_models.player_queue import PlayerQueue
from homeassistant.components import media_source, tts
from homeassistant.components.media_player import (
ATTR_MEDIA_EXTRA,
BrowseError,
BrowseMedia,
MediaClass,
MediaPlayerDeviceClass,
MediaPlayerEnqueue,
MediaPlayerEntity,
@@ -60,14 +63,20 @@ from .const import (
ATTR_REPEAT_MODE,
ATTR_SHUFFLE_ENABLED,
DOMAIN,
LOGGER,
)
from .entity import MusicAssistantDashboardEntity, MusicAssistantEntity
from .helpers import (
catch_musicassistant_error,
catch_user_not_found,
dashboard_identifier,
)
from .entity import MusicAssistantEntity
from .helpers import catch_musicassistant_error, catch_user_not_found
from .media_browser import async_browse_media, async_search_media
from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item
if TYPE_CHECKING:
from music_assistant_client.client import MusicAssistantClient
from music_assistant_models.dashboard import DashboardDevice
from music_assistant_models.player import Player
SUPPORTED_FEATURES_BASE = (
@@ -127,6 +136,15 @@ MASS_ICON_TO_MDI: Mapping[str, str] = {
}
MEDIA_CONTENT_TYPE_DASHBOARD = "dashboard"
NOW_PLAYING_ID_PREFIX = f"{DashboardType.NOW_PLAYING.value}/"
# the DashboardType value doubles as the provider domain for get_provider_icon
DASHBOARD_ICON_TYPES = frozenset({DashboardType.PARTY, DashboardType.MUSIC_QUIZ})
DASHBOARD_ICON_PROVIDER_DOMAINS = frozenset(
icon_type.value for icon_type in DASHBOARD_ICON_TYPES
)
def _get_mdi_icon(icon: str) -> str:
"""Return an MDI icon for a Music Assistant icon."""
if icon.startswith("mdi:"):
@@ -136,6 +154,22 @@ def _get_mdi_icon(icon: str) -> str:
return MASS_ICON_TO_MDI.get(icon, "mdi:speaker")
def _get_player_artwork_url(mass: MusicAssistantClient, player: Player) -> str | None:
"""Return the artwork URL for a player's current media, or None."""
if player.current_media and player.current_media.image_url:
# prefer player.current_media which reflects the live state
# (e.g. current track art from radio stream metadata)
return player.current_media.image_url
if (
player.active_source
and (queue := mass.player_queues.get(player.active_source))
and queue.current_item
):
# fallback to static media item image from queue
return mass.get_media_item_image_url(queue.current_item)
return None
async def async_setup_entry(
hass: HomeAssistant,
entry: MusicAssistantConfigEntry,
@@ -151,6 +185,46 @@ async def async_setup_entry(
# register callback to add players when they are discovered
entry.runtime_data.platform_handlers.setdefault(Platform.MEDIA_PLAYER, add_player)
# known_dashboard_ids is scoped to this setup call, so a reload starts
# fresh and re-adds whatever is in the dashboard cache.
known_dashboard_ids: set[str] = set()
entity_registry = er.async_get(hass)
def add_dashboards() -> None:
"""Add dashboard players for endpoints not yet known to HA.
An id already seen this run is skipped only while it still has a
registered entity - if its entity was removed in the meantime (e.g.
a stale device deleted via the UI), it's treated as new again so a
later re-registration doesn't leave it without an entity.
"""
new_entities: list[MusicAssistantDashboardPlayer] = []
for dashboard in mass.dashboard.dashboards:
if dashboard.dashboard_id in known_dashboard_ids and (
entity_registry.async_get_entity_id(
Platform.MEDIA_PLAYER,
DOMAIN,
dashboard_identifier(dashboard.dashboard_id),
)
):
continue
known_dashboard_ids.add(dashboard.dashboard_id)
new_entities.append(
MusicAssistantDashboardPlayer(mass, dashboard.dashboard_id)
)
if new_entities:
async_add_entities(new_entities)
def handle_dashboards_updated(event: MassEvent) -> None:
"""Handle the dashboard endpoint cache being refreshed."""
add_dashboards()
entry.async_on_unload(
mass.subscribe(handle_dashboards_updated, EventType.DASHBOARDS_UPDATED)
)
add_dashboards()
class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
"""Representation of MediaPlayerEntity from Music Assistant Player."""
@@ -275,7 +349,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
self._attr_volume_level = volume / 100 if volume is not None else None
self._attr_is_volume_muted = player.volume_muted
self._update_media_attributes(player, active_queue)
self._update_media_image_url(player, active_queue)
self._update_media_image_url(player)
@catch_musicassistant_error
@override
@@ -671,20 +745,9 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
query,
)
def _update_media_image_url(
self, player: Player, queue: PlayerQueue | None
) -> None:
def _update_media_image_url(self, player: Player) -> None:
"""Update image URL."""
image_url: str | None
if player.current_media and player.current_media.image_url:
# prefer player.current_media which reflects the live state
# (e.g. current track art from radio stream metadata)
image_url = player.current_media.image_url
elif queue and queue.current_item:
# fallback to static media item image from queue
image_url = self.mass.get_media_item_image_url(queue.current_item)
else:
image_url = None
image_url = _get_player_artwork_url(self.mass, player)
# check if the image is provided via music-assistant and therefore
# not accessible from the outside
@@ -768,3 +831,375 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
if PlayerFeature.SELECT_SOUND_MODE in self.player.supported_features:
supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
self._attr_supported_features = supported_features
class MusicAssistantDashboardPlayer(MusicAssistantDashboardEntity, MediaPlayerEntity):
"""Representation of a Music Assistant dashboard display device."""
_attr_name = None
_attr_device_class = MediaPlayerDeviceClass.TV
_attr_supported_features = (
MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.TURN_OFF
)
def __init__(self, mass: MusicAssistantClient, dashboard_id: str) -> None:
"""Initialize MusicAssistantDashboardPlayer."""
super().__init__(mass, dashboard_id)
# fetched provider icons per domain, None when the provider has none
self._provider_icon_cache: dict[str, tuple[bytes, str] | None] = {}
@override
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
await super().async_added_to_hass()
self._update_from_session()
self.async_on_remove(
self.mass.subscribe(
self.__on_session_updated, EventType.DASHBOARD_SESSIONS_UPDATED
)
)
# unscoped: the now_playing session's target player changes per session
self.async_on_remove(
self.mass.subscribe(
self.__on_player_or_queue_updated, EventType.PLAYER_UPDATED
)
)
self.async_on_remove(
self.mass.subscribe(
self.__on_player_or_queue_updated, EventType.QUEUE_UPDATED
)
)
@catch_musicassistant_error
@override
async def async_play_media(
self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None:
"""Show a dashboard on this display."""
if media_type != MEDIA_CONTENT_TYPE_DASHBOARD:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="dashboard_invalid_media_type",
translation_placeholders={
"expected": MEDIA_CONTENT_TYPE_DASHBOARD,
"media_type": str(media_type),
},
)
dashboard_type, player_id = self._parse_play_media_id(media_id)
await self.mass.dashboard.show(self.dashboard_id, dashboard_type, player_id)
@catch_musicassistant_error
@override
async def async_turn_off(self) -> None:
"""Hide the dashboard from this display."""
await self.mass.dashboard.hide(self.dashboard_id)
@override
async def async_browse_media(
self,
media_content_type: MediaType | str | None = None,
media_content_id: str | None = None,
) -> BrowseMedia:
"""Browse the dashboards this display can show."""
# the browse websocket path doesn't filter on availability
dashboard = self.mass.dashboard.get(self.dashboard_id)
if dashboard is None:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="dashboard_display_not_available",
translation_placeholders={"display": self.dashboard_id},
)
if media_content_id in (None, ""):
return self._build_root_listing(dashboard)
if (
media_content_id == DashboardType.NOW_PLAYING.value
and DashboardType.NOW_PLAYING in dashboard.supported_types
):
return self._build_now_playing_listing()
raise BrowseError(
translation_domain=DOMAIN,
translation_key="dashboard_media_not_found",
translation_placeholders={"media_content_id": str(media_content_id)},
)
@property
@override
def media_image_hash(self) -> str | None:
"""Hash for the active session's icon, invalidated when the session type changes."""
session = self.mass.dashboard.get_session(self.dashboard_id)
if session is not None and session.dashboard in DASHBOARD_ICON_TYPES:
return session.dashboard.value
# fall back to the base class hash of media_image_url, so its
# proxy path serves MA-hosted (non-remotely-accessible) artwork
return super().media_image_hash
@override
async def async_get_media_image(self) -> tuple[bytes | None, str | None]:
"""Fetch the provider icon for an active party/music_quiz session."""
session = self.mass.dashboard.get_session(self.dashboard_id)
if session is None or session.dashboard not in DASHBOARD_ICON_TYPES:
# let the base class fetch/proxy media_image_url itself
return await super().async_get_media_image()
return await self._fetch_provider_icon(session.dashboard.value)
@override
async def async_get_browse_image(
self,
media_content_type: MediaType | str,
media_content_id: str,
media_image_id: str | None = None,
) -> tuple[bytes | None, str | None]:
"""Fetch a provider icon for a browse tree leaf."""
if media_content_id not in DASHBOARD_ICON_PROVIDER_DOMAINS:
return None, None
return await self._fetch_provider_icon(media_content_id)
async def __on_session_updated(self, event: MassEvent) -> None:
"""Handle the dashboard's active session changing."""
self._update_from_session()
self.async_write_ha_state()
async def __on_player_or_queue_updated(self, event: MassEvent) -> None:
"""Refresh now_playing session attributes on player/queue updates."""
session = self.mass.dashboard.get_session(self.dashboard_id)
if session is None or session.dashboard != DashboardType.NOW_PLAYING:
return
player = self.mass.players.get(session.player_id) if session.player_id else None
if event.event == EventType.PLAYER_UPDATED:
matches = event.object_id == session.player_id
else:
matches = event.object_id in (
player.active_source if player else None,
player.active_group if player else None,
session.player_id,
)
if not matches:
return
previous_title = self._attr_media_title
previous_image_url = self._attr_media_image_url
self._update_from_session()
if (
self._attr_media_title != previous_title
or self._attr_media_image_url != previous_image_url
):
self.async_write_ha_state()
def _update_from_session(self) -> None:
"""Update state and media attributes from the active session."""
session = self.mass.dashboard.get_session(self.dashboard_id)
if session is None:
self._attr_state = MediaPlayerState.IDLE
self._attr_media_content_type = None
self._attr_media_content_id = None
self._attr_media_title = None
self._clear_media_image()
return
self._attr_state = MediaPlayerState.PLAYING
self._attr_media_content_type = MEDIA_CONTENT_TYPE_DASHBOARD
if session.dashboard == DashboardType.PARTY:
self._attr_media_content_id = DashboardType.PARTY.value
self._attr_media_title = "Party"
self._clear_media_image()
elif session.dashboard == DashboardType.MUSIC_QUIZ:
self._attr_media_content_id = DashboardType.MUSIC_QUIZ.value
self._attr_media_title = "Music quiz"
self._clear_media_image()
elif session.dashboard == DashboardType.NOW_PLAYING:
player_id = session.player_id or ""
self._attr_media_content_id = f"{NOW_PLAYING_ID_PREFIX}{player_id}"
player = self.mass.players.get(player_id) if player_id else None
player_label = player.name if player is not None else player_id
self._attr_media_title = f"Now playing: {player_label}"
self._update_session_player_image(player)
else:
# a dashboard type this client doesn't recognize (UNKNOWN)
self._attr_media_content_id = session.dashboard.value
self._attr_media_title = session.dashboard.value
self._clear_media_image()
def _valid_content_ids(self, dashboard: DashboardDevice) -> list[str]:
"""List this display's playable content ids, for error messages."""
ids = [
supported.value
for supported in dashboard.supported_types
if supported not in (DashboardType.UNKNOWN, DashboardType.NOW_PLAYING)
]
if DashboardType.NOW_PLAYING in dashboard.supported_types:
# not directly playable on its own; it always needs a player segment
ids.append(f"{NOW_PLAYING_ID_PREFIX}<player_id>")
return sorted(ids)
def _parse_play_media_id(
self, media_content_id: str
) -> tuple[DashboardType, str | None]:
"""Validate a play_media content id and split it into a type and player id."""
dashboard = self.mass.dashboard.get(self.dashboard_id)
if dashboard is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="dashboard_display_not_available",
translation_placeholders={"display": self.dashboard_id},
)
valid_ids = self._valid_content_ids(dashboard)
if media_content_id == DashboardType.NOW_PLAYING.value:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="dashboard_now_playing_requires_player",
translation_placeholders={
"dashboard": DashboardType.NOW_PLAYING.value,
"expected": f"{NOW_PLAYING_ID_PREFIX}<player_id>",
},
)
player_id: str | None = None
if media_content_id.startswith(NOW_PLAYING_ID_PREFIX):
dashboard_type = DashboardType.NOW_PLAYING
player_id = media_content_id.removeprefix(NOW_PLAYING_ID_PREFIX)
else:
dashboard_type = DashboardType(media_content_id)
if dashboard_type == DashboardType.UNKNOWN:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="dashboard_unknown_type",
translation_placeholders={
"dashboard": media_content_id,
"valid_ids": ", ".join(valid_ids),
},
)
if dashboard_type not in dashboard.supported_types:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="dashboard_type_not_supported",
translation_placeholders={
"display": dashboard.name,
"dashboard": dashboard_type.value,
"valid_ids": ", ".join(valid_ids),
},
)
if dashboard_type == DashboardType.NOW_PLAYING:
player = self.mass.players.get(player_id) if player_id else None
if player is None or not player.expose_to_ha:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="dashboard_player_not_available",
translation_placeholders={"player_id": str(player_id)},
)
return dashboard_type, player_id
def _clear_media_image(self) -> None:
"""Clear the media image, e.g. for sessions served by async_get_media_image."""
self._attr_media_image_url = None
self._attr_media_image_remotely_accessible = False
def _update_session_player_image(self, player: Player | None) -> None:
"""Update the media image url from the now_playing session's player."""
image_url = _get_player_artwork_url(self.mass, player) if player else None
self._attr_media_image_remotely_accessible = bool(
image_url and self.mass.server_url not in image_url
)
self._attr_media_image_url = image_url
async def _fetch_provider_icon(
self, provider_domain: str
) -> tuple[bytes | None, str | None]:
"""Fetch a provider icon, caching definite answers but not transient errors."""
if provider_domain in self._provider_icon_cache:
return self._provider_icon_cache[provider_domain] or (None, None)
try:
icon = await self.mass.get_provider_icon(provider_domain)
except MusicAssistantError:
LOGGER.debug(
"Failed to fetch provider icon for %s", provider_domain, exc_info=True
)
return None, None
self._provider_icon_cache[provider_domain] = icon
if icon is None:
LOGGER.debug("No provider icon available for %s", provider_domain)
return None, None
return icon
def _build_root_listing(self, dashboard: DashboardDevice) -> BrowseMedia:
"""Build the root browse listing, filtered to this display's dashboards."""
children: list[BrowseMedia] = []
if DashboardType.PARTY in dashboard.supported_types:
children.append(
BrowseMedia(
media_class=MediaClass.APP,
media_content_id=DashboardType.PARTY.value,
media_content_type=MEDIA_CONTENT_TYPE_DASHBOARD,
title="Party",
can_play=True,
can_expand=False,
thumbnail=self.get_browse_image_url(
MEDIA_CONTENT_TYPE_DASHBOARD, DashboardType.PARTY.value
),
)
)
if DashboardType.MUSIC_QUIZ in dashboard.supported_types:
children.append(
BrowseMedia(
media_class=MediaClass.APP,
media_content_id=DashboardType.MUSIC_QUIZ.value,
media_content_type=MEDIA_CONTENT_TYPE_DASHBOARD,
title="Music quiz",
can_play=True,
can_expand=False,
thumbnail=self.get_browse_image_url(
MEDIA_CONTENT_TYPE_DASHBOARD, DashboardType.MUSIC_QUIZ.value
),
)
)
if DashboardType.NOW_PLAYING in dashboard.supported_types:
children.append(
BrowseMedia(
media_class=MediaClass.DIRECTORY,
media_content_id=DashboardType.NOW_PLAYING.value,
media_content_type=MEDIA_CONTENT_TYPE_DASHBOARD,
title="Now playing",
can_play=False,
can_expand=True,
children_media_class=MediaClass.APP,
)
)
return BrowseMedia(
media_class=MediaClass.DIRECTORY,
media_content_id="",
media_content_type=MEDIA_CONTENT_TYPE_DASHBOARD,
title=dashboard.name,
can_play=False,
can_expand=True,
children=children,
)
def _build_now_playing_listing(self) -> BrowseMedia:
"""Build the now playing folder, one playable child per exposed player."""
players = sorted(
(player for player in self.mass.players if player.expose_to_ha),
key=lambda player: player.name,
)
return BrowseMedia(
media_class=MediaClass.DIRECTORY,
media_content_id=DashboardType.NOW_PLAYING.value,
media_content_type=MEDIA_CONTENT_TYPE_DASHBOARD,
title="Now playing",
can_play=False,
can_expand=True,
children_media_class=MediaClass.APP,
children=[
BrowseMedia(
media_class=MediaClass.APP,
media_content_id=f"{NOW_PLAYING_ID_PREFIX}{player.player_id}",
media_content_type=MEDIA_CONTENT_TYPE_DASHBOARD,
title=player.name,
can_play=True,
can_expand=False,
thumbnail=_get_player_artwork_url(self.mass, player),
)
for player in players
],
)
@@ -8,6 +8,7 @@ import probatio
from homeassistant.components.media_player import (
ATTR_MEDIA_ENQUEUE,
DOMAIN as MEDIA_PLAYER_DOMAIN,
MediaPlayerDeviceClass,
)
from homeassistant.components.tts import DOMAIN as TTS_DOMAIN
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
@@ -86,6 +87,9 @@ DEFAULT_OFFSET = 0
DEFAULT_LIMIT = 25
DEFAULT_SORT_ORDER = "name"
# player-only entity services; dashboard display entities don't implement them
PLAYER_ENTITY_DEVICE_CLASSES = [MediaPlayerDeviceClass.SPEAKER]
@callback
def register_actions(hass: HomeAssistant) -> None:
@@ -137,6 +141,7 @@ def register_actions(hass: HomeAssistant) -> None:
DOMAIN,
SERVICE_PLAY_MEDIA_ADVANCED,
entity_domain=MEDIA_PLAYER_DOMAIN,
entity_device_classes=PLAYER_ENTITY_DEVICE_CLASSES,
schema={
probatio.Required(ATTR_MEDIA_ID): probatio.All(
probatio.EnsureList(), [cv.string]
@@ -155,6 +160,7 @@ def register_actions(hass: HomeAssistant) -> None:
DOMAIN,
SERVICE_PLAY_ANNOUNCEMENT,
entity_domain=MEDIA_PLAYER_DOMAIN,
entity_device_classes=PLAYER_ENTITY_DEVICE_CLASSES,
schema=probatio.All(
cv.make_entity_service_schema(
{
@@ -178,6 +184,7 @@ def register_actions(hass: HomeAssistant) -> None:
DOMAIN,
SERVICE_TRANSFER_QUEUE,
entity_domain=MEDIA_PLAYER_DOMAIN,
entity_device_classes=PLAYER_ENTITY_DEVICE_CLASSES,
schema={
probatio.Optional(ATTR_SOURCE_PLAYER): cv.entity_id,
probatio.Optional(ATTR_AUTO_PLAY): probatio.Coerce(bool),
@@ -189,6 +196,7 @@ def register_actions(hass: HomeAssistant) -> None:
DOMAIN,
SERVICE_GET_QUEUE,
entity_domain=MEDIA_PLAYER_DOMAIN,
entity_device_classes=PLAYER_ENTITY_DEVICE_CLASSES,
schema=None,
func="_async_handle_get_queue",
supports_response=SupportsResponse.ONLY,
@@ -5,6 +5,7 @@ play_media:
entity:
domain: media_player
integration: music_assistant
device_class: speaker
supported_features:
- media_player.MediaPlayerEntityFeature.PLAY_MEDIA
fields:
@@ -58,6 +59,7 @@ play_announcement:
entity:
domain: media_player
integration: music_assistant
device_class: speaker
supported_features:
- media_player.MediaPlayerEntityFeature.PLAY_MEDIA
- media_player.MediaPlayerEntityFeature.MEDIA_ANNOUNCE
@@ -100,12 +102,14 @@ transfer_queue:
entity:
domain: media_player
integration: music_assistant
device_class: speaker
fields:
source_player:
selector:
entity:
domain: media_player
integration: music_assistant
device_class: speaker
auto_play:
example: "true"
selector:
@@ -116,6 +120,7 @@ get_queue:
entity:
domain: media_player
integration: music_assistant
device_class: speaker
supported_features:
- media_player.MediaPlayerEntityFeature.PLAY_MEDIA
@@ -258,6 +258,27 @@
}
},
"exceptions": {
"dashboard_display_not_available": {
"message": "The display {display} is not available."
},
"dashboard_invalid_media_type": {
"message": "Dashboard displays only accept the media content type {expected}, got {media_type}."
},
"dashboard_media_not_found": {
"message": "The media {media_content_id} was not found."
},
"dashboard_now_playing_requires_player": {
"message": "The dashboard {dashboard} requires a player, expected {expected}."
},
"dashboard_player_not_available": {
"message": "The player {player_id} is unknown or not exposed to Home Assistant."
},
"dashboard_type_not_supported": {
"message": "The display {display} does not support the dashboard {dashboard}, expected one of: {valid_ids}."
},
"dashboard_unknown_type": {
"message": "The dashboard {dashboard} is unknown, expected one of: {valid_ids}."
},
"invalid_username": {
"message": "The username {username} does not exist on the Music Assistant server."
},
+49 -2
View File
@@ -5,7 +5,8 @@ from typing import Any
from unittest.mock import AsyncMock, MagicMock
from music_assistant_models.api import MassEvent
from music_assistant_models.enums import EventType
from music_assistant_models.dashboard import DashboardDevice, DashboardSession
from music_assistant_models.enums import DashboardType, EventType
from music_assistant_models.media_items import (
Album,
Artist,
@@ -197,15 +198,61 @@ async def trigger_subscription_callback(
await hass.async_block_till_done()
def setup_dashboards(music_assistant_client: MagicMock) -> None:
"""Seed the mocked client with dashboard endpoints and two active sessions."""
music_assistant_client.dashboard._dashboards = {
"chromecast_kitchen": DashboardDevice(
dashboard_id="chromecast_kitchen",
name="Kitchen Display",
supported_types={DashboardType.PARTY, DashboardType.NOW_PLAYING},
provider_domain_hint="chromecast",
),
"fully_kiosk_hallway": DashboardDevice(
dashboard_id="fully_kiosk_hallway",
name="Hallway Display",
supported_types={
DashboardType.PARTY,
DashboardType.NOW_PLAYING,
DashboardType.MUSIC_QUIZ,
DashboardType.UNKNOWN,
},
provider_domain_hint="fully_kiosk",
),
"unmapped_player_display": DashboardDevice(
dashboard_id="unmapped_player_display",
name="Unmapped Player Display",
supported_types={DashboardType.NOW_PLAYING},
),
}
music_assistant_client.dashboard._sessions = {
"chromecast_kitchen": DashboardSession(
dashboard_id="chromecast_kitchen",
name="Kitchen Display",
dashboard=DashboardType.NOW_PLAYING,
player_id="00:00:00:00:00:01",
),
"unmapped_player_display": DashboardSession(
dashboard_id="unmapped_player_display",
name="Unmapped Player Display",
dashboard=DashboardType.NOW_PLAYING,
player_id="not-exposed-player",
),
}
def snapshot_music_assistant_entities(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
platform: Platform,
*,
unique_id_prefix: str | None = None,
) -> None:
"""Snapshot MusicAssistant entities."""
"""Snapshot MusicAssistant entities, optionally only those with a unique id prefix."""
entities = hass.states.async_all(platform)
for entity_state in entities:
entity_entry = entity_registry.async_get(entity_state.entity_id)
if unique_id_prefix and not entity_entry.unique_id.startswith(unique_id_prefix):
continue
assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry")
assert entity_state == snapshot(name=f"{entity_entry.entity_id}-state")
@@ -4,6 +4,7 @@ import asyncio
from collections.abc import AsyncGenerator, Generator
from unittest.mock import MagicMock, patch
from music_assistant_client.dashboard import Dashboard
from music_assistant_client.music import Music
from music_assistant_client.player_queues import PlayerQueues
from music_assistant_client.players import Players
@@ -68,6 +69,7 @@ async def music_assistant_client_fixture() -> AsyncGenerator[MagicMock]:
client.players = Players(client)
client.player_queues = PlayerQueues(client)
client.music = Music(client)
client.dashboard = Dashboard(client)
client.server_url = client.server_info.base_url
client.get_media_item_image_url = MagicMock(return_value=None)
client.config = MagicMock()
@@ -0,0 +1,166 @@
# serializer version: 1
# name: test_dashboard_media_player_snapshot[media_player.hallway_display-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'media_player',
'entity_category': None,
'entity_id': 'media_player.hallway_display',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': <MediaPlayerDeviceClass.TV: 'tv'>,
'original_icon': None,
'original_name': None,
'platform': 'music_assistant',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <MediaPlayerEntityFeature: 131840>,
'translation_key': None,
'unique_id': 'dashboard:fully_kiosk_hallway',
'unit_of_measurement': None,
})
# ---
# name: test_dashboard_media_player_snapshot[media_player.hallway_display-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'tv',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Hallway Display',
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <MediaPlayerEntityFeature: 131840>,
}),
'context': <ANY>,
'entity_id': 'media_player.hallway_display',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'idle',
})
# ---
# name: test_dashboard_media_player_snapshot[media_player.kitchen_display-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'media_player',
'entity_category': None,
'entity_id': 'media_player.kitchen_display',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': <MediaPlayerDeviceClass.TV: 'tv'>,
'original_icon': None,
'original_name': None,
'platform': 'music_assistant',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <MediaPlayerEntityFeature: 131840>,
'translation_key': None,
'unique_id': 'dashboard:chromecast_kitchen',
'unit_of_measurement': None,
})
# ---
# name: test_dashboard_media_player_snapshot[media_player.kitchen_display-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'tv',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Kitchen Display',
<MediaPlayerEntityStateAttribute.MEDIA_CONTENT_ID: 'media_content_id'>: 'now_playing/00:00:00:00:00:01',
<MediaPlayerEntityStateAttribute.MEDIA_CONTENT_TYPE: 'media_content_type'>: 'dashboard',
<MediaPlayerEntityStateAttribute.MEDIA_TITLE: 'media_title'>: 'Now playing: Test Player 1',
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <MediaPlayerEntityFeature: 131840>,
}),
'context': <ANY>,
'entity_id': 'media_player.kitchen_display',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'playing',
})
# ---
# name: test_dashboard_media_player_snapshot[media_player.unmapped_player_display-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'media_player',
'entity_category': None,
'entity_id': 'media_player.unmapped_player_display',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': <MediaPlayerDeviceClass.TV: 'tv'>,
'original_icon': None,
'original_name': None,
'platform': 'music_assistant',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <MediaPlayerEntityFeature: 131840>,
'translation_key': None,
'unique_id': 'dashboard:unmapped_player_display',
'unit_of_measurement': None,
})
# ---
# name: test_dashboard_media_player_snapshot[media_player.unmapped_player_display-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'tv',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Unmapped Player Display',
<MediaPlayerEntityStateAttribute.MEDIA_CONTENT_ID: 'media_content_id'>: 'now_playing/not-exposed-player',
<MediaPlayerEntityStateAttribute.MEDIA_CONTENT_TYPE: 'media_content_type'>: 'dashboard',
<MediaPlayerEntityStateAttribute.MEDIA_TITLE: 'media_title'>: 'Now playing: not-exposed-player',
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <MediaPlayerEntityFeature: 131840>,
}),
'context': <ANY>,
'entity_id': 'media_player.unmapped_player_display',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'playing',
})
# ---
File diff suppressed because it is too large Load Diff
+137 -2
View File
@@ -2,11 +2,13 @@
from unittest.mock import AsyncMock, MagicMock
from music_assistant_models.enums import EventType
from music_assistant_models.dashboard import DashboardDevice
from music_assistant_models.enums import DashboardType, EventType
from music_assistant_models.errors import ActionUnavailable, AuthenticationRequired
from homeassistant.components.music_assistant.const import (
ATTR_CONF_EXPOSE_PLAYER_TO_HA,
DASHBOARD_DEVICE_MODEL,
DOMAIN,
)
from homeassistant.config_entries import ConfigEntryState
@@ -18,7 +20,11 @@ from homeassistant.helpers import (
)
from homeassistant.setup import async_setup_component
from .common import setup_integration_from_fixtures, trigger_subscription_callback
from .common import (
setup_dashboards,
setup_integration_from_fixtures,
trigger_subscription_callback,
)
from tests.common import MockConfigEntry
from tests.typing import WebSocketGenerator
@@ -78,6 +84,135 @@ async def test_remove_config_entry_device(
assert response["success"] is True
async def test_remove_dashboard_device(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
music_assistant_client: MagicMock,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test dashboard device removal is refused while the endpoint is live."""
assert await async_setup_component(hass, "config", {})
setup_dashboards(music_assistant_client)
config_entry = await setup_integration_from_fixtures(hass, music_assistant_client)
client = await hass_ws_client(hass)
device_entry = device_registry.async_get_device_by_identifier(
(DOMAIN, "dashboard:chromecast_kitchen"), config_entry.entry_id
)
assert device_entry
# the endpoint is still live - removal must be refused
response = await client.remove_device(device_entry.id)
assert response["success"] is False
assert device_registry.async_get(device_entry.id)
# the endpoint is gone from the server for good - removal is now allowed
del music_assistant_client.dashboard._dashboards["chromecast_kitchen"]
response = await client.remove_device(device_entry.id)
assert response["success"] is True
assert not device_registry.async_get(device_entry.id)
# it comes back online later, without HA ever reloading in between -
# its entity must not stay missing (regression for a "ghost" bug)
music_assistant_client.dashboard._dashboards["chromecast_kitchen"] = (
DashboardDevice(
dashboard_id="chromecast_kitchen",
name="Kitchen Display",
supported_types={DashboardType.PARTY, DashboardType.NOW_PLAYING},
provider_domain_hint="chromecast",
)
)
await trigger_subscription_callback(
hass,
music_assistant_client,
EventType.DASHBOARDS_UPDATED,
data=[
dashboard.to_dict()
for dashboard in music_assistant_client.dashboard._dashboards.values()
],
)
assert hass.states.get("media_player.kitchen_display")
async def test_dashboard_device_survives_reload_with_empty_cache(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
music_assistant_client: MagicMock,
) -> None:
"""Test dashboard devices survive a reload with a momentarily empty cache.
Regression test: dashboard registrations are connection-scoped, so right
after an MA server restart + HA entry reload the dashboard cache can be
empty before the physical endpoints have re-registered. The startup
stale-device cleanup must not treat that as "gone for good".
"""
setup_dashboards(music_assistant_client)
config_entry = await setup_integration_from_fixtures(hass, music_assistant_client)
assert device_registry.async_get_device_by_identifier(
(DOMAIN, "dashboard:chromecast_kitchen"), config_entry.entry_id
)
# simulate an MA server restart: the dashboard cache is empty again,
# as if nothing had re-registered yet
music_assistant_client.dashboard._dashboards = {}
music_assistant_client.dashboard._sessions = {}
await hass.config_entries.async_reload(config_entry.entry_id)
await hass.async_block_till_done()
assert device_registry.async_get_device_by_identifier(
(DOMAIN, "dashboard:chromecast_kitchen"), config_entry.entry_id
)
state = hass.states.get("media_player.kitchen_display")
assert state
assert state.state == "unavailable"
async def test_dashboard_device_id_namespaced(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
music_assistant_client: MagicMock,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test a dashboard endpoint sharing a player's id gets its own device.
Fully Kiosk registers dashboard_id == player_id. If the dashboard
device identifier were not namespaced, the display would merge into
the player's device, and removing the player would delete the display.
"""
assert await async_setup_component(hass, "config", {})
setup_dashboards(music_assistant_client)
collision_id = "00:00:00:00:00:01"
music_assistant_client.dashboard._dashboards[collision_id] = DashboardDevice(
dashboard_id=collision_id,
name="Player Display",
supported_types={DashboardType.PARTY},
)
config_entry = await setup_integration_from_fixtures(hass, music_assistant_client)
client = await hass_ws_client(hass)
player_device = device_registry.async_get_device_by_identifier(
(DOMAIN, collision_id), config_entry.entry_id
)
dashboard_device = device_registry.async_get_device_by_identifier(
(DOMAIN, f"dashboard:{collision_id}"), config_entry.entry_id
)
assert player_device
assert dashboard_device
assert player_device.id != dashboard_device.id
assert player_device.model != DASHBOARD_DEVICE_MODEL
assert dashboard_device.model == DASHBOARD_DEVICE_MODEL
# removing the player device must not be refused because a dashboard
# endpoint happens to be live under the same bare id, and must not
# touch the (separate) display device
music_assistant_client.config.remove_player_config = AsyncMock()
response = await client.remove_device(player_device.id)
assert response["success"] is True
await hass.async_block_till_done()
assert not device_registry.async_get(player_device.id)
assert device_registry.async_get(dashboard_device.id)
async def test_player_config_expose_to_ha_toggle(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,