mirror of
https://github.com/home-assistant/core.git
synced 2026-10-06 22:38:02 -04:00
Add dashboard display media players to Music Assistant (#179386)
This commit is contained in:
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user