mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
Add more dlna_dmr media_player services and attributes (#57827)
This commit is contained in:
parent
3f50e444ca
commit
6cdc372dcb
@ -5,6 +5,8 @@ from collections.abc import Mapping
|
|||||||
import logging
|
import logging
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
|
from async_upnp_client.profiles.dlna import PlayMode as _PlayMode
|
||||||
|
|
||||||
from homeassistant.components.media_player import const as _mp_const
|
from homeassistant.components.media_player import const as _mp_const
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__package__)
|
LOGGER = logging.getLogger(__package__)
|
||||||
@ -58,3 +60,114 @@ MEDIA_TYPE_MAP: Mapping[str, str] = {
|
|||||||
"object.container.storageFolder": _mp_const.MEDIA_TYPE_PLAYLIST,
|
"object.container.storageFolder": _mp_const.MEDIA_TYPE_PLAYLIST,
|
||||||
"object.container.bookmarkFolder": _mp_const.MEDIA_TYPE_PLAYLIST,
|
"object.container.bookmarkFolder": _mp_const.MEDIA_TYPE_PLAYLIST,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Map media_player media_content_type to UPnP class. Not everything will map
|
||||||
|
# directly, in which case it's not specified and other defaults will be used.
|
||||||
|
MEDIA_UPNP_CLASS_MAP: Mapping[str, str] = {
|
||||||
|
_mp_const.MEDIA_TYPE_ALBUM: "object.container.album.musicAlbum",
|
||||||
|
_mp_const.MEDIA_TYPE_ARTIST: "object.container.person.musicArtist",
|
||||||
|
_mp_const.MEDIA_TYPE_CHANNEL: "object.item.videoItem.videoBroadcast",
|
||||||
|
_mp_const.MEDIA_TYPE_CHANNELS: "object.container.channelGroup",
|
||||||
|
_mp_const.MEDIA_TYPE_COMPOSER: "object.container.person.musicArtist",
|
||||||
|
_mp_const.MEDIA_TYPE_CONTRIBUTING_ARTIST: "object.container.person.musicArtist",
|
||||||
|
_mp_const.MEDIA_TYPE_EPISODE: "object.item.epgItem.videoProgram",
|
||||||
|
_mp_const.MEDIA_TYPE_GENRE: "object.container.genre",
|
||||||
|
_mp_const.MEDIA_TYPE_IMAGE: "object.item.imageItem",
|
||||||
|
_mp_const.MEDIA_TYPE_MOVIE: "object.item.videoItem.movie",
|
||||||
|
_mp_const.MEDIA_TYPE_MUSIC: "object.item.audioItem.musicTrack",
|
||||||
|
_mp_const.MEDIA_TYPE_PLAYLIST: "object.item.playlistItem",
|
||||||
|
_mp_const.MEDIA_TYPE_PODCAST: "object.item.audioItem.audioBook",
|
||||||
|
_mp_const.MEDIA_TYPE_SEASON: "object.item.epgItem.videoProgram",
|
||||||
|
_mp_const.MEDIA_TYPE_TRACK: "object.item.audioItem.musicTrack",
|
||||||
|
_mp_const.MEDIA_TYPE_TVSHOW: "object.item.videoItem.videoBroadcast",
|
||||||
|
_mp_const.MEDIA_TYPE_URL: "object.item.bookmarkItem",
|
||||||
|
_mp_const.MEDIA_TYPE_VIDEO: "object.item.videoItem",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Translation of MediaMetadata keys to DIDL-Lite keys.
|
||||||
|
# See https://developers.google.com/cast/docs/reference/messages#MediaData via
|
||||||
|
# https://www.home-assistant.io/integrations/media_player/ for HA keys.
|
||||||
|
# See http://www.upnp.org/specs/av/UPnP-av-ContentDirectory-v4-Service.pdf for
|
||||||
|
# DIDL-Lite keys.
|
||||||
|
MEDIA_METADATA_DIDL: Mapping[str, str] = {
|
||||||
|
"subtitle": "longDescription",
|
||||||
|
"releaseDate": "date",
|
||||||
|
"studio": "publisher",
|
||||||
|
"season": "episodeSeason",
|
||||||
|
"episode": "episodeNumber",
|
||||||
|
"albumName": "album",
|
||||||
|
"trackNumber": "originalTrackNumber",
|
||||||
|
}
|
||||||
|
|
||||||
|
# For (un)setting repeat mode, map a combination of shuffle & repeat to a list
|
||||||
|
# of play modes in order of suitability. Fall back to _PlayMode.NORMAL in any
|
||||||
|
# case. NOTE: This list is slightly different to that in SHUFFLE_PLAY_MODES,
|
||||||
|
# due to fallback behaviour when turning on repeat modes.
|
||||||
|
REPEAT_PLAY_MODES: Mapping[tuple[bool, str], list[_PlayMode]] = {
|
||||||
|
(False, _mp_const.REPEAT_MODE_OFF): [
|
||||||
|
_PlayMode.NORMAL,
|
||||||
|
],
|
||||||
|
(False, _mp_const.REPEAT_MODE_ONE): [
|
||||||
|
_PlayMode.REPEAT_ONE,
|
||||||
|
_PlayMode.REPEAT_ALL,
|
||||||
|
_PlayMode.NORMAL,
|
||||||
|
],
|
||||||
|
(False, _mp_const.REPEAT_MODE_ALL): [
|
||||||
|
_PlayMode.REPEAT_ALL,
|
||||||
|
_PlayMode.REPEAT_ONE,
|
||||||
|
_PlayMode.NORMAL,
|
||||||
|
],
|
||||||
|
(True, _mp_const.REPEAT_MODE_OFF): [
|
||||||
|
_PlayMode.SHUFFLE,
|
||||||
|
_PlayMode.RANDOM,
|
||||||
|
_PlayMode.NORMAL,
|
||||||
|
],
|
||||||
|
(True, _mp_const.REPEAT_MODE_ONE): [
|
||||||
|
_PlayMode.REPEAT_ONE,
|
||||||
|
_PlayMode.RANDOM,
|
||||||
|
_PlayMode.SHUFFLE,
|
||||||
|
_PlayMode.NORMAL,
|
||||||
|
],
|
||||||
|
(True, _mp_const.REPEAT_MODE_ALL): [
|
||||||
|
_PlayMode.RANDOM,
|
||||||
|
_PlayMode.REPEAT_ALL,
|
||||||
|
_PlayMode.SHUFFLE,
|
||||||
|
_PlayMode.NORMAL,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# For (un)setting shuffle mode, map a combination of shuffle & repeat to a list
|
||||||
|
# of play modes in order of suitability. Fall back to _PlayMode.NORMAL in any
|
||||||
|
# case.
|
||||||
|
SHUFFLE_PLAY_MODES: Mapping[tuple[bool, str], list[_PlayMode]] = {
|
||||||
|
(False, _mp_const.REPEAT_MODE_OFF): [
|
||||||
|
_PlayMode.NORMAL,
|
||||||
|
],
|
||||||
|
(False, _mp_const.REPEAT_MODE_ONE): [
|
||||||
|
_PlayMode.REPEAT_ONE,
|
||||||
|
_PlayMode.REPEAT_ALL,
|
||||||
|
_PlayMode.NORMAL,
|
||||||
|
],
|
||||||
|
(False, _mp_const.REPEAT_MODE_ALL): [
|
||||||
|
_PlayMode.REPEAT_ALL,
|
||||||
|
_PlayMode.REPEAT_ONE,
|
||||||
|
_PlayMode.NORMAL,
|
||||||
|
],
|
||||||
|
(True, _mp_const.REPEAT_MODE_OFF): [
|
||||||
|
_PlayMode.SHUFFLE,
|
||||||
|
_PlayMode.RANDOM,
|
||||||
|
_PlayMode.NORMAL,
|
||||||
|
],
|
||||||
|
(True, _mp_const.REPEAT_MODE_ONE): [
|
||||||
|
_PlayMode.RANDOM,
|
||||||
|
_PlayMode.SHUFFLE,
|
||||||
|
_PlayMode.REPEAT_ONE,
|
||||||
|
_PlayMode.NORMAL,
|
||||||
|
],
|
||||||
|
(True, _mp_const.REPEAT_MODE_ALL): [
|
||||||
|
_PlayMode.RANDOM,
|
||||||
|
_PlayMode.SHUFFLE,
|
||||||
|
_PlayMode.REPEAT_ALL,
|
||||||
|
_PlayMode.NORMAL,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
|
import contextlib
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import functools
|
import functools
|
||||||
from typing import Any, Callable, TypeVar, cast
|
from typing import Any, Callable, TypeVar, cast
|
||||||
@ -10,7 +11,7 @@ from typing import Any, Callable, TypeVar, cast
|
|||||||
from async_upnp_client import UpnpService, UpnpStateVariable
|
from async_upnp_client import UpnpService, UpnpStateVariable
|
||||||
from async_upnp_client.const import NotificationSubType
|
from async_upnp_client.const import NotificationSubType
|
||||||
from async_upnp_client.exceptions import UpnpError, UpnpResponseError
|
from async_upnp_client.exceptions import UpnpError, UpnpResponseError
|
||||||
from async_upnp_client.profiles.dlna import DmrDevice, TransportState
|
from async_upnp_client.profiles.dlna import DmrDevice, PlayMode, TransportState
|
||||||
from async_upnp_client.utils import async_get_local_ip
|
from async_upnp_client.utils import async_get_local_ip
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
@ -18,12 +19,19 @@ from homeassistant import config_entries
|
|||||||
from homeassistant.components import ssdp
|
from homeassistant.components import ssdp
|
||||||
from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity
|
from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity
|
||||||
from homeassistant.components.media_player.const import (
|
from homeassistant.components.media_player.const import (
|
||||||
|
ATTR_MEDIA_EXTRA,
|
||||||
|
REPEAT_MODE_ALL,
|
||||||
|
REPEAT_MODE_OFF,
|
||||||
|
REPEAT_MODE_ONE,
|
||||||
SUPPORT_NEXT_TRACK,
|
SUPPORT_NEXT_TRACK,
|
||||||
SUPPORT_PAUSE,
|
SUPPORT_PAUSE,
|
||||||
SUPPORT_PLAY,
|
SUPPORT_PLAY,
|
||||||
SUPPORT_PLAY_MEDIA,
|
SUPPORT_PLAY_MEDIA,
|
||||||
SUPPORT_PREVIOUS_TRACK,
|
SUPPORT_PREVIOUS_TRACK,
|
||||||
|
SUPPORT_REPEAT_SET,
|
||||||
SUPPORT_SEEK,
|
SUPPORT_SEEK,
|
||||||
|
SUPPORT_SELECT_SOUND_MODE,
|
||||||
|
SUPPORT_SHUFFLE_SET,
|
||||||
SUPPORT_STOP,
|
SUPPORT_STOP,
|
||||||
SUPPORT_VOLUME_MUTE,
|
SUPPORT_VOLUME_MUTE,
|
||||||
SUPPORT_VOLUME_SET,
|
SUPPORT_VOLUME_SET,
|
||||||
@ -51,7 +59,11 @@ from .const import (
|
|||||||
CONF_POLL_AVAILABILITY,
|
CONF_POLL_AVAILABILITY,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
LOGGER as _LOGGER,
|
LOGGER as _LOGGER,
|
||||||
|
MEDIA_METADATA_DIDL,
|
||||||
MEDIA_TYPE_MAP,
|
MEDIA_TYPE_MAP,
|
||||||
|
MEDIA_UPNP_CLASS_MAP,
|
||||||
|
REPEAT_PLAY_MODES,
|
||||||
|
SHUFFLE_PLAY_MODES,
|
||||||
)
|
)
|
||||||
from .data import EventListenAddr, get_domain_data
|
from .data import EventListenAddr, get_domain_data
|
||||||
|
|
||||||
@ -250,11 +262,9 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
|||||||
if self._bootid is not None and self._bootid == bootid:
|
if self._bootid is not None and self._bootid == bootid:
|
||||||
# Store the new value (because our old value matches) so that we
|
# Store the new value (because our old value matches) so that we
|
||||||
# can ignore subsequent ssdp:alive messages
|
# can ignore subsequent ssdp:alive messages
|
||||||
try:
|
with contextlib.suppress(KeyError, ValueError):
|
||||||
next_bootid_str = info[ssdp.ATTR_SSDP_NEXTBOOTID]
|
next_bootid_str = info[ssdp.ATTR_SSDP_NEXTBOOTID]
|
||||||
self._bootid = int(next_bootid_str, 10)
|
self._bootid = int(next_bootid_str, 10)
|
||||||
except (KeyError, ValueError):
|
|
||||||
pass
|
|
||||||
# Nothing left to do until ssdp:alive comes through
|
# Nothing left to do until ssdp:alive comes through
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -445,7 +455,21 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
|||||||
if not state_variables:
|
if not state_variables:
|
||||||
# Indicates a failure to resubscribe, check if device is still available
|
# Indicates a failure to resubscribe, check if device is still available
|
||||||
self.check_available = True
|
self.check_available = True
|
||||||
self.async_write_ha_state()
|
|
||||||
|
force_refresh = False
|
||||||
|
|
||||||
|
if service.service_id == "urn:upnp-org:serviceId:AVTransport":
|
||||||
|
for state_variable in state_variables:
|
||||||
|
# Force a state refresh when player begins or pauses playback
|
||||||
|
# to update the position info.
|
||||||
|
if (
|
||||||
|
state_variable.name == "TransportState"
|
||||||
|
and state_variable.value
|
||||||
|
in (TransportState.PLAYING, TransportState.PAUSED_PLAYBACK)
|
||||||
|
):
|
||||||
|
force_refresh = True
|
||||||
|
|
||||||
|
self.async_schedule_update_ha_state(force_refresh)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
@ -515,6 +539,15 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
|||||||
if self._device.can_seek_rel_time:
|
if self._device.can_seek_rel_time:
|
||||||
supported_features |= SUPPORT_SEEK
|
supported_features |= SUPPORT_SEEK
|
||||||
|
|
||||||
|
play_modes = self._device.valid_play_modes
|
||||||
|
if play_modes & {PlayMode.RANDOM, PlayMode.SHUFFLE}:
|
||||||
|
supported_features |= SUPPORT_SHUFFLE_SET
|
||||||
|
if play_modes & {PlayMode.REPEAT_ONE, PlayMode.REPEAT_ALL}:
|
||||||
|
supported_features |= SUPPORT_REPEAT_SET
|
||||||
|
|
||||||
|
if self._device.has_presets:
|
||||||
|
supported_features |= SUPPORT_SELECT_SOUND_MODE
|
||||||
|
|
||||||
return supported_features
|
return supported_features
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -575,23 +608,44 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Play a piece of media."""
|
"""Play a piece of media."""
|
||||||
_LOGGER.debug("Playing media: %s, %s, %s", media_type, media_id, kwargs)
|
_LOGGER.debug("Playing media: %s, %s, %s", media_type, media_id, kwargs)
|
||||||
title = "Home Assistant"
|
|
||||||
|
|
||||||
assert self._device is not None
|
assert self._device is not None
|
||||||
|
extra: dict[str, Any] = kwargs.get(ATTR_MEDIA_EXTRA) or {}
|
||||||
|
metadata: dict[str, Any] = extra.get("metadata") or {}
|
||||||
|
|
||||||
|
title = extra.get("title") or metadata.get("title") or "Home Assistant"
|
||||||
|
thumb = extra.get("thumb")
|
||||||
|
if thumb:
|
||||||
|
metadata["album_art_uri"] = thumb
|
||||||
|
|
||||||
|
# Translate metadata keys from HA names to DIDL-Lite names
|
||||||
|
for hass_key, didl_key in MEDIA_METADATA_DIDL.items():
|
||||||
|
if hass_key in metadata:
|
||||||
|
metadata[didl_key] = metadata.pop(hass_key)
|
||||||
|
|
||||||
|
# Create metadata specific to the given media type; different fields are
|
||||||
|
# available depending on what the upnp_class is.
|
||||||
|
upnp_class = MEDIA_UPNP_CLASS_MAP.get(media_type)
|
||||||
|
didl_metadata = await self._device.construct_play_media_metadata(
|
||||||
|
media_url=media_id,
|
||||||
|
media_title=title,
|
||||||
|
override_upnp_class=upnp_class,
|
||||||
|
meta_data=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
# Stop current playing media
|
# Stop current playing media
|
||||||
if self._device.can_stop:
|
if self._device.can_stop:
|
||||||
await self.async_media_stop()
|
await self.async_media_stop()
|
||||||
|
|
||||||
# Queue media
|
# Queue media
|
||||||
await self._device.async_set_transport_uri(media_id, title)
|
await self._device.async_set_transport_uri(media_id, title, didl_metadata)
|
||||||
await self._device.async_wait_for_can_play()
|
|
||||||
|
|
||||||
# If already playing, no need to call Play
|
# If already playing, or don't want to autoplay, no need to call Play
|
||||||
if self._device.transport_state == TransportState.PLAYING:
|
autoplay = extra.get("autoplay", True)
|
||||||
|
if self._device.transport_state == TransportState.PLAYING or not autoplay:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Play it
|
# Play it
|
||||||
|
await self._device.async_wait_for_can_play()
|
||||||
await self.async_media_play()
|
await self.async_media_play()
|
||||||
|
|
||||||
@catch_request_errors
|
@catch_request_errors
|
||||||
@ -606,6 +660,98 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
|||||||
assert self._device is not None
|
assert self._device is not None
|
||||||
await self._device.async_next()
|
await self._device.async_next()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def shuffle(self) -> bool | None:
|
||||||
|
"""Boolean if shuffle is enabled."""
|
||||||
|
if not self._device:
|
||||||
|
return None
|
||||||
|
|
||||||
|
play_mode = self._device.play_mode
|
||||||
|
if not play_mode:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if play_mode == PlayMode.VENDOR_DEFINED:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return play_mode in (PlayMode.SHUFFLE, PlayMode.RANDOM)
|
||||||
|
|
||||||
|
@catch_request_errors
|
||||||
|
async def async_set_shuffle(self, shuffle: bool) -> None:
|
||||||
|
"""Enable/disable shuffle mode."""
|
||||||
|
assert self._device is not None
|
||||||
|
|
||||||
|
repeat = self.repeat or REPEAT_MODE_OFF
|
||||||
|
potential_play_modes = SHUFFLE_PLAY_MODES[(shuffle, repeat)]
|
||||||
|
|
||||||
|
valid_play_modes = self._device.valid_play_modes
|
||||||
|
|
||||||
|
for mode in potential_play_modes:
|
||||||
|
if mode in valid_play_modes:
|
||||||
|
await self._device.async_set_play_mode(mode)
|
||||||
|
return
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Couldn't find a suitable mode for shuffle=%s, repeat=%s", shuffle, repeat
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def repeat(self) -> str | None:
|
||||||
|
"""Return current repeat mode."""
|
||||||
|
if not self._device:
|
||||||
|
return None
|
||||||
|
|
||||||
|
play_mode = self._device.play_mode
|
||||||
|
if not play_mode:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if play_mode == PlayMode.VENDOR_DEFINED:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if play_mode == PlayMode.REPEAT_ONE:
|
||||||
|
return REPEAT_MODE_ONE
|
||||||
|
|
||||||
|
if play_mode in (PlayMode.REPEAT_ALL, PlayMode.RANDOM):
|
||||||
|
return REPEAT_MODE_ALL
|
||||||
|
|
||||||
|
return REPEAT_MODE_OFF
|
||||||
|
|
||||||
|
@catch_request_errors
|
||||||
|
async def async_set_repeat(self, repeat: str) -> None:
|
||||||
|
"""Set repeat mode."""
|
||||||
|
assert self._device is not None
|
||||||
|
|
||||||
|
shuffle = self.shuffle or False
|
||||||
|
potential_play_modes = REPEAT_PLAY_MODES[(shuffle, repeat)]
|
||||||
|
|
||||||
|
valid_play_modes = self._device.valid_play_modes
|
||||||
|
|
||||||
|
for mode in potential_play_modes:
|
||||||
|
if mode in valid_play_modes:
|
||||||
|
await self._device.async_set_play_mode(mode)
|
||||||
|
return
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Couldn't find a suitable mode for shuffle=%s, repeat=%s", shuffle, repeat
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sound_mode(self) -> str | None:
|
||||||
|
"""Name of the current sound mode, not supported by DLNA."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sound_mode_list(self) -> list[str] | None:
|
||||||
|
"""List of available sound modes."""
|
||||||
|
if not self._device:
|
||||||
|
return None
|
||||||
|
return self._device.preset_names
|
||||||
|
|
||||||
|
@catch_request_errors
|
||||||
|
async def async_select_sound_mode(self, sound_mode: str) -> None:
|
||||||
|
"""Select sound mode."""
|
||||||
|
assert self._device is not None
|
||||||
|
await self._device.async_select_preset(sound_mode)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def media_title(self) -> str | None:
|
def media_title(self) -> str | None:
|
||||||
"""Title of current playing media."""
|
"""Title of current playing media."""
|
||||||
@ -705,12 +851,10 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
|||||||
not self._device.media_season_number
|
not self._device.media_season_number
|
||||||
or self._device.media_season_number == "0"
|
or self._device.media_season_number == "0"
|
||||||
) and self._device.media_episode_number:
|
) and self._device.media_episode_number:
|
||||||
try:
|
with contextlib.suppress(ValueError):
|
||||||
episode = int(self._device.media_episode_number, 10)
|
episode = int(self._device.media_episode_number, 10)
|
||||||
if episode > 100:
|
if episode > 100:
|
||||||
return str(episode // 100)
|
return str(episode // 100)
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return self._device.media_season_number
|
return self._device.media_season_number
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -723,12 +867,10 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
|||||||
not self._device.media_season_number
|
not self._device.media_season_number
|
||||||
or self._device.media_season_number == "0"
|
or self._device.media_season_number == "0"
|
||||||
) and self._device.media_episode_number:
|
) and self._device.media_episode_number:
|
||||||
try:
|
with contextlib.suppress(ValueError):
|
||||||
episode = int(self._device.media_episode_number, 10)
|
episode = int(self._device.media_episode_number, 10)
|
||||||
if episode > 100:
|
if episode > 100:
|
||||||
return str(episode % 100)
|
return str(episode % 100)
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return self._device.media_episode_number
|
return self._device.media_episode_number
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -737,3 +879,10 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
|||||||
if not self._device:
|
if not self._device:
|
||||||
return None
|
return None
|
||||||
return self._device.media_channel_name
|
return self._device.media_channel_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_playlist(self) -> str | None:
|
||||||
|
"""Title of Playlist currently playing."""
|
||||||
|
if not self._device:
|
||||||
|
return None
|
||||||
|
return self._device.media_playlist_title
|
||||||
|
@ -8,12 +8,13 @@ from types import MappingProxyType
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import ANY, DEFAULT, Mock, patch
|
from unittest.mock import ANY, DEFAULT, Mock, patch
|
||||||
|
|
||||||
|
from async_upnp_client import UpnpService, UpnpStateVariable
|
||||||
from async_upnp_client.exceptions import (
|
from async_upnp_client.exceptions import (
|
||||||
UpnpConnectionError,
|
UpnpConnectionError,
|
||||||
UpnpError,
|
UpnpError,
|
||||||
UpnpResponseError,
|
UpnpResponseError,
|
||||||
)
|
)
|
||||||
from async_upnp_client.profiles.dlna import TransportState
|
from async_upnp_client.profiles.dlna import PlayMode, TransportState
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant import const as ha_const
|
from homeassistant import const as ha_const
|
||||||
@ -67,6 +68,16 @@ async def setup_mock_component(hass: HomeAssistant, mock_entry: MockConfigEntry)
|
|||||||
return entity_id
|
return entity_id
|
||||||
|
|
||||||
|
|
||||||
|
async def get_attrs(hass: HomeAssistant, entity_id: str) -> Mapping[str, Any]:
|
||||||
|
"""Get updated device attributes."""
|
||||||
|
await async_update_entity(hass, entity_id)
|
||||||
|
entity_state = hass.states.get(entity_id)
|
||||||
|
assert entity_state is not None
|
||||||
|
attrs = entity_state.attributes
|
||||||
|
assert attrs is not None
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def mock_entity_id(
|
async def mock_entity_id(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@ -335,9 +346,7 @@ async def test_setup_entry_with_options(
|
|||||||
|
|
||||||
|
|
||||||
async def test_event_subscribe_failure(
|
async def test_event_subscribe_failure(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant, config_entry_mock: MockConfigEntry, dmr_device_mock: Mock
|
||||||
config_entry_mock: MockConfigEntry,
|
|
||||||
dmr_device_mock: Mock,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test _device_connect aborts when async_subscribe_services fails."""
|
"""Test _device_connect aborts when async_subscribe_services fails."""
|
||||||
dmr_device_mock.async_subscribe_services.side_effect = UpnpError
|
dmr_device_mock.async_subscribe_services.side_effect = UpnpError
|
||||||
@ -389,9 +398,7 @@ async def test_event_subscribe_rejected(
|
|||||||
|
|
||||||
|
|
||||||
async def test_available_device(
|
async def test_available_device(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str
|
||||||
dmr_device_mock: Mock,
|
|
||||||
mock_entity_id: str,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test a DlnaDmrEntity with a connected DmrDevice."""
|
"""Test a DlnaDmrEntity with a connected DmrDevice."""
|
||||||
# Check hass device information is filled in
|
# Check hass device information is filled in
|
||||||
@ -429,19 +436,63 @@ async def test_available_device(
|
|||||||
assert entity_state is not None
|
assert entity_state is not None
|
||||||
assert entity_state.state == ha_const.STATE_UNAVAILABLE
|
assert entity_state.state == ha_const.STATE_UNAVAILABLE
|
||||||
|
|
||||||
dmr_device_mock.profile_device.available = True
|
|
||||||
await async_update_entity(hass, mock_entity_id)
|
|
||||||
|
|
||||||
|
async def test_feature_flags(
|
||||||
|
hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str
|
||||||
|
) -> None:
|
||||||
|
"""Test feature flags of a connected DlnaDmrEntity."""
|
||||||
|
# Check supported feature flags, one at a time.
|
||||||
|
FEATURE_FLAGS: list[tuple[str, int]] = [
|
||||||
|
("has_volume_level", mp_const.SUPPORT_VOLUME_SET),
|
||||||
|
("has_volume_mute", mp_const.SUPPORT_VOLUME_MUTE),
|
||||||
|
("can_play", mp_const.SUPPORT_PLAY),
|
||||||
|
("can_pause", mp_const.SUPPORT_PAUSE),
|
||||||
|
("can_stop", mp_const.SUPPORT_STOP),
|
||||||
|
("can_previous", mp_const.SUPPORT_PREVIOUS_TRACK),
|
||||||
|
("can_next", mp_const.SUPPORT_NEXT_TRACK),
|
||||||
|
("has_play_media", mp_const.SUPPORT_PLAY_MEDIA),
|
||||||
|
("can_seek_rel_time", mp_const.SUPPORT_SEEK),
|
||||||
|
("has_presets", mp_const.SUPPORT_SELECT_SOUND_MODE),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Clear all feature properties
|
||||||
|
dmr_device_mock.valid_play_modes = set()
|
||||||
|
for feat_prop, _ in FEATURE_FLAGS:
|
||||||
|
setattr(dmr_device_mock, feat_prop, False)
|
||||||
|
attrs = await get_attrs(hass, mock_entity_id)
|
||||||
|
assert attrs[ha_const.ATTR_SUPPORTED_FEATURES] == 0
|
||||||
|
|
||||||
|
# Test the properties cumulatively
|
||||||
|
expected_features = 0
|
||||||
|
for feat_prop, flag in FEATURE_FLAGS:
|
||||||
|
setattr(dmr_device_mock, feat_prop, True)
|
||||||
|
expected_features |= flag
|
||||||
|
attrs = await get_attrs(hass, mock_entity_id)
|
||||||
|
assert attrs[ha_const.ATTR_SUPPORTED_FEATURES] == expected_features
|
||||||
|
|
||||||
|
# shuffle and repeat features depend on the available play modes
|
||||||
|
PLAY_MODE_FEATURE_FLAGS: list[tuple[PlayMode, int]] = [
|
||||||
|
(PlayMode.NORMAL, 0),
|
||||||
|
(PlayMode.SHUFFLE, mp_const.SUPPORT_SHUFFLE_SET),
|
||||||
|
(PlayMode.REPEAT_ONE, mp_const.SUPPORT_REPEAT_SET),
|
||||||
|
(PlayMode.REPEAT_ALL, mp_const.SUPPORT_REPEAT_SET),
|
||||||
|
(PlayMode.RANDOM, mp_const.SUPPORT_SHUFFLE_SET),
|
||||||
|
(PlayMode.DIRECT_1, 0),
|
||||||
|
(PlayMode.INTRO, 0),
|
||||||
|
(PlayMode.VENDOR_DEFINED, 0),
|
||||||
|
]
|
||||||
|
for play_modes, flag in PLAY_MODE_FEATURE_FLAGS:
|
||||||
|
dmr_device_mock.valid_play_modes = {play_modes}
|
||||||
|
attrs = await get_attrs(hass, mock_entity_id)
|
||||||
|
assert attrs[ha_const.ATTR_SUPPORTED_FEATURES] == expected_features | flag
|
||||||
|
|
||||||
|
|
||||||
|
async def test_attributes(
|
||||||
|
hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str
|
||||||
|
) -> None:
|
||||||
|
"""Test attributes of a connected DlnaDmrEntity."""
|
||||||
# Check attributes come directly from the device
|
# Check attributes come directly from the device
|
||||||
async def get_attrs() -> Mapping[str, Any]:
|
attrs = await get_attrs(hass, mock_entity_id)
|
||||||
await async_update_entity(hass, mock_entity_id)
|
|
||||||
entity_state = hass.states.get(mock_entity_id)
|
|
||||||
assert entity_state is not None
|
|
||||||
attrs = entity_state.attributes
|
|
||||||
assert attrs is not None
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
attrs = await get_attrs()
|
|
||||||
assert attrs[mp_const.ATTR_MEDIA_VOLUME_LEVEL] is dmr_device_mock.volume_level
|
assert attrs[mp_const.ATTR_MEDIA_VOLUME_LEVEL] is dmr_device_mock.volume_level
|
||||||
assert attrs[mp_const.ATTR_MEDIA_VOLUME_MUTED] is dmr_device_mock.is_volume_muted
|
assert attrs[mp_const.ATTR_MEDIA_VOLUME_MUTED] is dmr_device_mock.is_volume_muted
|
||||||
assert attrs[mp_const.ATTR_MEDIA_DURATION] is dmr_device_mock.media_duration
|
assert attrs[mp_const.ATTR_MEDIA_DURATION] is dmr_device_mock.media_duration
|
||||||
@ -459,68 +510,65 @@ async def test_available_device(
|
|||||||
assert attrs[mp_const.ATTR_MEDIA_SEASON] is dmr_device_mock.media_season_number
|
assert attrs[mp_const.ATTR_MEDIA_SEASON] is dmr_device_mock.media_season_number
|
||||||
assert attrs[mp_const.ATTR_MEDIA_EPISODE] is dmr_device_mock.media_episode_number
|
assert attrs[mp_const.ATTR_MEDIA_EPISODE] is dmr_device_mock.media_episode_number
|
||||||
assert attrs[mp_const.ATTR_MEDIA_CHANNEL] is dmr_device_mock.media_channel_name
|
assert attrs[mp_const.ATTR_MEDIA_CHANNEL] is dmr_device_mock.media_channel_name
|
||||||
|
assert attrs[mp_const.ATTR_SOUND_MODE_LIST] is dmr_device_mock.preset_names
|
||||||
|
|
||||||
# Entity picture is cached, won't correspond to remote image
|
# Entity picture is cached, won't correspond to remote image
|
||||||
assert isinstance(attrs[ha_const.ATTR_ENTITY_PICTURE], str)
|
assert isinstance(attrs[ha_const.ATTR_ENTITY_PICTURE], str)
|
||||||
|
|
||||||
# media_title depends on what is available
|
# media_title depends on what is available
|
||||||
assert attrs[mp_const.ATTR_MEDIA_TITLE] is dmr_device_mock.media_program_title
|
assert attrs[mp_const.ATTR_MEDIA_TITLE] is dmr_device_mock.media_program_title
|
||||||
dmr_device_mock.media_program_title = None
|
dmr_device_mock.media_program_title = None
|
||||||
attrs = await get_attrs()
|
attrs = await get_attrs(hass, mock_entity_id)
|
||||||
assert attrs[mp_const.ATTR_MEDIA_TITLE] is dmr_device_mock.media_title
|
assert attrs[mp_const.ATTR_MEDIA_TITLE] is dmr_device_mock.media_title
|
||||||
|
|
||||||
# media_content_type is mapped from UPnP class to MediaPlayer type
|
# media_content_type is mapped from UPnP class to MediaPlayer type
|
||||||
dmr_device_mock.media_class = "object.item.audioItem.musicTrack"
|
dmr_device_mock.media_class = "object.item.audioItem.musicTrack"
|
||||||
attrs = await get_attrs()
|
attrs = await get_attrs(hass, mock_entity_id)
|
||||||
assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == mp_const.MEDIA_TYPE_MUSIC
|
assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == mp_const.MEDIA_TYPE_MUSIC
|
||||||
dmr_device_mock.media_class = "object.item.videoItem.movie"
|
dmr_device_mock.media_class = "object.item.videoItem.movie"
|
||||||
attrs = await get_attrs()
|
attrs = await get_attrs(hass, mock_entity_id)
|
||||||
assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == mp_const.MEDIA_TYPE_MOVIE
|
assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == mp_const.MEDIA_TYPE_MOVIE
|
||||||
dmr_device_mock.media_class = "object.item.videoItem.videoBroadcast"
|
dmr_device_mock.media_class = "object.item.videoItem.videoBroadcast"
|
||||||
attrs = await get_attrs()
|
attrs = await get_attrs(hass, mock_entity_id)
|
||||||
assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == mp_const.MEDIA_TYPE_TVSHOW
|
assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == mp_const.MEDIA_TYPE_TVSHOW
|
||||||
|
|
||||||
# media_season & media_episode have a special case
|
# media_season & media_episode have a special case
|
||||||
dmr_device_mock.media_season_number = "0"
|
dmr_device_mock.media_season_number = "0"
|
||||||
dmr_device_mock.media_episode_number = "123"
|
dmr_device_mock.media_episode_number = "123"
|
||||||
attrs = await get_attrs()
|
attrs = await get_attrs(hass, mock_entity_id)
|
||||||
assert attrs[mp_const.ATTR_MEDIA_SEASON] == "1"
|
assert attrs[mp_const.ATTR_MEDIA_SEASON] == "1"
|
||||||
assert attrs[mp_const.ATTR_MEDIA_EPISODE] == "23"
|
assert attrs[mp_const.ATTR_MEDIA_EPISODE] == "23"
|
||||||
dmr_device_mock.media_season_number = "0"
|
dmr_device_mock.media_season_number = "0"
|
||||||
dmr_device_mock.media_episode_number = "S1E23" # Unexpected and not parsed
|
dmr_device_mock.media_episode_number = "S1E23" # Unexpected and not parsed
|
||||||
attrs = await get_attrs()
|
attrs = await get_attrs(hass, mock_entity_id)
|
||||||
assert attrs[mp_const.ATTR_MEDIA_SEASON] == "0"
|
assert attrs[mp_const.ATTR_MEDIA_SEASON] == "0"
|
||||||
assert attrs[mp_const.ATTR_MEDIA_EPISODE] == "S1E23"
|
assert attrs[mp_const.ATTR_MEDIA_EPISODE] == "S1E23"
|
||||||
|
|
||||||
# Check supported feature flags, one at a time.
|
# shuffle and repeat is based on device's play mode
|
||||||
# tuple(async_upnp_client feature check property, HA feature flag)
|
for play_mode, shuffle, repeat in [
|
||||||
FEATURE_FLAGS: list[tuple[str, int]] = [
|
(PlayMode.NORMAL, False, mp_const.REPEAT_MODE_OFF),
|
||||||
("has_volume_level", mp_const.SUPPORT_VOLUME_SET),
|
(PlayMode.SHUFFLE, True, mp_const.REPEAT_MODE_OFF),
|
||||||
("has_volume_mute", mp_const.SUPPORT_VOLUME_MUTE),
|
(PlayMode.REPEAT_ONE, False, mp_const.REPEAT_MODE_ONE),
|
||||||
("can_play", mp_const.SUPPORT_PLAY),
|
(PlayMode.REPEAT_ALL, False, mp_const.REPEAT_MODE_ALL),
|
||||||
("can_pause", mp_const.SUPPORT_PAUSE),
|
(PlayMode.RANDOM, True, mp_const.REPEAT_MODE_ALL),
|
||||||
("can_stop", mp_const.SUPPORT_STOP),
|
(PlayMode.DIRECT_1, False, mp_const.REPEAT_MODE_OFF),
|
||||||
("can_previous", mp_const.SUPPORT_PREVIOUS_TRACK),
|
(PlayMode.INTRO, False, mp_const.REPEAT_MODE_OFF),
|
||||||
("can_next", mp_const.SUPPORT_NEXT_TRACK),
|
]:
|
||||||
("has_play_media", mp_const.SUPPORT_PLAY_MEDIA),
|
dmr_device_mock.play_mode = play_mode
|
||||||
("can_seek_rel_time", mp_const.SUPPORT_SEEK),
|
attrs = await get_attrs(hass, mock_entity_id)
|
||||||
]
|
assert attrs[mp_const.ATTR_MEDIA_SHUFFLE] is shuffle
|
||||||
# Clear all feature properties
|
assert attrs[mp_const.ATTR_MEDIA_REPEAT] == repeat
|
||||||
for feat_prop, _ in FEATURE_FLAGS:
|
for bad_play_mode in [None, PlayMode.VENDOR_DEFINED]:
|
||||||
setattr(dmr_device_mock, feat_prop, False)
|
dmr_device_mock.play_mode = bad_play_mode
|
||||||
await async_update_entity(hass, mock_entity_id)
|
attrs = await get_attrs(hass, mock_entity_id)
|
||||||
entity_state = hass.states.get(mock_entity_id)
|
assert mp_const.ATTR_MEDIA_SHUFFLE not in attrs
|
||||||
assert entity_state is not None
|
assert mp_const.ATTR_MEDIA_REPEAT not in attrs
|
||||||
assert entity_state.attributes[ha_const.ATTR_SUPPORTED_FEATURES] == 0
|
|
||||||
# Test the properties cumulatively
|
|
||||||
expected_features = 0
|
|
||||||
for feat_prop, flag in FEATURE_FLAGS:
|
|
||||||
setattr(dmr_device_mock, feat_prop, True)
|
|
||||||
expected_features |= flag
|
|
||||||
await async_update_entity(hass, mock_entity_id)
|
|
||||||
entity_state = hass.states.get(mock_entity_id)
|
|
||||||
assert entity_state is not None
|
|
||||||
assert (
|
|
||||||
entity_state.attributes[ha_const.ATTR_SUPPORTED_FEATURES]
|
|
||||||
== expected_features
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
async def test_services(
|
||||||
|
hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str
|
||||||
|
) -> None:
|
||||||
|
"""Test service calls of a connected DlnaDmrEntity."""
|
||||||
# Check interface methods interact directly with the device
|
# Check interface methods interact directly with the device
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
MP_DOMAIN,
|
MP_DOMAIN,
|
||||||
@ -578,15 +626,22 @@ async def test_available_device(
|
|||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
dmr_device_mock.async_seek_rel_time.assert_awaited_once_with(timedelta(seconds=33))
|
dmr_device_mock.async_seek_rel_time.assert_awaited_once_with(timedelta(seconds=33))
|
||||||
|
await hass.services.async_call(
|
||||||
|
MP_DOMAIN,
|
||||||
|
mp_const.SERVICE_SELECT_SOUND_MODE,
|
||||||
|
{ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_SOUND_MODE: "Default"},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
dmr_device_mock.async_select_preset.assert_awaited_once_with("Default")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_play_media_stopped(
|
||||||
|
hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str
|
||||||
|
) -> None:
|
||||||
|
"""Test play_media, starting from stopped and the device can stop."""
|
||||||
# play_media performs a few calls to the device for setup and play
|
# play_media performs a few calls to the device for setup and play
|
||||||
# Start from stopped, and device can stop too
|
|
||||||
dmr_device_mock.can_stop = True
|
dmr_device_mock.can_stop = True
|
||||||
dmr_device_mock.transport_state = TransportState.STOPPED
|
dmr_device_mock.transport_state = TransportState.STOPPED
|
||||||
dmr_device_mock.async_stop.reset_mock()
|
|
||||||
dmr_device_mock.async_set_transport_uri.reset_mock()
|
|
||||||
dmr_device_mock.async_wait_for_can_play.reset_mock()
|
|
||||||
dmr_device_mock.async_play.reset_mock()
|
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
MP_DOMAIN,
|
MP_DOMAIN,
|
||||||
mp_const.SERVICE_PLAY_MEDIA,
|
mp_const.SERVICE_PLAY_MEDIA,
|
||||||
@ -598,20 +653,27 @@ async def test_available_device(
|
|||||||
},
|
},
|
||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
dmr_device_mock.construct_play_media_metadata.assert_awaited_once_with(
|
||||||
|
media_url="http://192.88.99.20:8200/MediaItems/17621.mp3",
|
||||||
|
media_title="Home Assistant",
|
||||||
|
override_upnp_class="object.item.audioItem.musicTrack",
|
||||||
|
meta_data={},
|
||||||
|
)
|
||||||
dmr_device_mock.async_stop.assert_awaited_once_with()
|
dmr_device_mock.async_stop.assert_awaited_once_with()
|
||||||
dmr_device_mock.async_set_transport_uri.assert_awaited_once_with(
|
dmr_device_mock.async_set_transport_uri.assert_awaited_once_with(
|
||||||
"http://192.88.99.20:8200/MediaItems/17621.mp3", "Home Assistant"
|
"http://192.88.99.20:8200/MediaItems/17621.mp3", "Home Assistant", ANY
|
||||||
)
|
)
|
||||||
dmr_device_mock.async_wait_for_can_play.assert_awaited_once_with()
|
dmr_device_mock.async_wait_for_can_play.assert_awaited_once_with()
|
||||||
dmr_device_mock.async_play.assert_awaited_once_with()
|
dmr_device_mock.async_play.assert_awaited_once_with()
|
||||||
|
|
||||||
# play_media again, while the device is already playing and can't stop
|
|
||||||
|
async def test_play_media_playing(
|
||||||
|
hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str
|
||||||
|
) -> None:
|
||||||
|
"""Test play_media, device is already playing and can't stop."""
|
||||||
dmr_device_mock.can_stop = False
|
dmr_device_mock.can_stop = False
|
||||||
dmr_device_mock.transport_state = TransportState.PLAYING
|
dmr_device_mock.transport_state = TransportState.PLAYING
|
||||||
dmr_device_mock.async_stop.reset_mock()
|
|
||||||
dmr_device_mock.async_set_transport_uri.reset_mock()
|
|
||||||
dmr_device_mock.async_wait_for_can_play.reset_mock()
|
|
||||||
dmr_device_mock.async_play.reset_mock()
|
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
MP_DOMAIN,
|
MP_DOMAIN,
|
||||||
mp_const.SERVICE_PLAY_MEDIA,
|
mp_const.SERVICE_PLAY_MEDIA,
|
||||||
@ -623,14 +685,232 @@ async def test_available_device(
|
|||||||
},
|
},
|
||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
dmr_device_mock.construct_play_media_metadata.assert_awaited_once_with(
|
||||||
|
media_url="http://192.88.99.20:8200/MediaItems/17621.mp3",
|
||||||
|
media_title="Home Assistant",
|
||||||
|
override_upnp_class="object.item.audioItem.musicTrack",
|
||||||
|
meta_data={},
|
||||||
|
)
|
||||||
dmr_device_mock.async_stop.assert_not_awaited()
|
dmr_device_mock.async_stop.assert_not_awaited()
|
||||||
dmr_device_mock.async_set_transport_uri.assert_awaited_once_with(
|
dmr_device_mock.async_set_transport_uri.assert_awaited_once_with(
|
||||||
"http://192.88.99.20:8200/MediaItems/17621.mp3", "Home Assistant"
|
"http://192.88.99.20:8200/MediaItems/17621.mp3", "Home Assistant", ANY
|
||||||
)
|
)
|
||||||
dmr_device_mock.async_wait_for_can_play.assert_awaited_once_with()
|
dmr_device_mock.async_wait_for_can_play.assert_not_awaited()
|
||||||
dmr_device_mock.async_play.assert_not_awaited()
|
dmr_device_mock.async_play.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_play_media_no_autoplay(
|
||||||
|
hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str
|
||||||
|
) -> None:
|
||||||
|
"""Test play_media with autoplay=False."""
|
||||||
|
# play_media performs a few calls to the device for setup and play
|
||||||
|
dmr_device_mock.can_stop = True
|
||||||
|
dmr_device_mock.transport_state = TransportState.STOPPED
|
||||||
|
await hass.services.async_call(
|
||||||
|
MP_DOMAIN,
|
||||||
|
mp_const.SERVICE_PLAY_MEDIA,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: mock_entity_id,
|
||||||
|
mp_const.ATTR_MEDIA_CONTENT_TYPE: mp_const.MEDIA_TYPE_MUSIC,
|
||||||
|
mp_const.ATTR_MEDIA_CONTENT_ID: "http://192.88.99.20:8200/MediaItems/17621.mp3",
|
||||||
|
mp_const.ATTR_MEDIA_ENQUEUE: False,
|
||||||
|
mp_const.ATTR_MEDIA_EXTRA: {"autoplay": False},
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
dmr_device_mock.construct_play_media_metadata.assert_awaited_once_with(
|
||||||
|
media_url="http://192.88.99.20:8200/MediaItems/17621.mp3",
|
||||||
|
media_title="Home Assistant",
|
||||||
|
override_upnp_class="object.item.audioItem.musicTrack",
|
||||||
|
meta_data={},
|
||||||
|
)
|
||||||
|
dmr_device_mock.async_stop.assert_awaited_once_with()
|
||||||
|
dmr_device_mock.async_set_transport_uri.assert_awaited_once_with(
|
||||||
|
"http://192.88.99.20:8200/MediaItems/17621.mp3", "Home Assistant", ANY
|
||||||
|
)
|
||||||
|
dmr_device_mock.async_wait_for_can_play.assert_not_awaited()
|
||||||
|
dmr_device_mock.async_play.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_play_media_metadata(
|
||||||
|
hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str
|
||||||
|
) -> None:
|
||||||
|
"""Test play_media constructs useful metadata from user params."""
|
||||||
|
await hass.services.async_call(
|
||||||
|
MP_DOMAIN,
|
||||||
|
mp_const.SERVICE_PLAY_MEDIA,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: mock_entity_id,
|
||||||
|
mp_const.ATTR_MEDIA_CONTENT_TYPE: mp_const.MEDIA_TYPE_MUSIC,
|
||||||
|
mp_const.ATTR_MEDIA_CONTENT_ID: "http://192.88.99.20:8200/MediaItems/17621.mp3",
|
||||||
|
mp_const.ATTR_MEDIA_ENQUEUE: False,
|
||||||
|
mp_const.ATTR_MEDIA_EXTRA: {
|
||||||
|
"title": "Mock song",
|
||||||
|
"thumb": "http://192.88.99.20:8200/MediaItems/17621.jpg",
|
||||||
|
"metadata": {"artist": "Mock artist", "album": "Mock album"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
dmr_device_mock.construct_play_media_metadata.assert_awaited_once_with(
|
||||||
|
media_url="http://192.88.99.20:8200/MediaItems/17621.mp3",
|
||||||
|
media_title="Mock song",
|
||||||
|
override_upnp_class="object.item.audioItem.musicTrack",
|
||||||
|
meta_data={
|
||||||
|
"artist": "Mock artist",
|
||||||
|
"album": "Mock album",
|
||||||
|
"album_art_uri": "http://192.88.99.20:8200/MediaItems/17621.jpg",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check again for a different media type
|
||||||
|
dmr_device_mock.construct_play_media_metadata.reset_mock()
|
||||||
|
await hass.services.async_call(
|
||||||
|
MP_DOMAIN,
|
||||||
|
mp_const.SERVICE_PLAY_MEDIA,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: mock_entity_id,
|
||||||
|
mp_const.ATTR_MEDIA_CONTENT_TYPE: mp_const.MEDIA_TYPE_TVSHOW,
|
||||||
|
mp_const.ATTR_MEDIA_CONTENT_ID: "http://192.88.99.20:8200/MediaItems/123.mkv",
|
||||||
|
mp_const.ATTR_MEDIA_ENQUEUE: False,
|
||||||
|
mp_const.ATTR_MEDIA_EXTRA: {
|
||||||
|
"title": "Mock show",
|
||||||
|
"metadata": {"season": 1, "episode": 12},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
dmr_device_mock.construct_play_media_metadata.assert_awaited_once_with(
|
||||||
|
media_url="http://192.88.99.20:8200/MediaItems/123.mkv",
|
||||||
|
media_title="Mock show",
|
||||||
|
override_upnp_class="object.item.videoItem.videoBroadcast",
|
||||||
|
meta_data={"episodeSeason": 1, "episodeNumber": 12},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_shuffle_repeat_modes(
|
||||||
|
hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str
|
||||||
|
) -> None:
|
||||||
|
"""Test setting repeat and shuffle modes."""
|
||||||
|
# Test shuffle with all variations of existing play mode
|
||||||
|
dmr_device_mock.valid_play_modes = {mode.value for mode in PlayMode}
|
||||||
|
for init_mode, shuffle_set, expect_mode in [
|
||||||
|
(PlayMode.NORMAL, False, PlayMode.NORMAL),
|
||||||
|
(PlayMode.SHUFFLE, False, PlayMode.NORMAL),
|
||||||
|
(PlayMode.REPEAT_ONE, False, PlayMode.REPEAT_ONE),
|
||||||
|
(PlayMode.REPEAT_ALL, False, PlayMode.REPEAT_ALL),
|
||||||
|
(PlayMode.RANDOM, False, PlayMode.REPEAT_ALL),
|
||||||
|
(PlayMode.NORMAL, True, PlayMode.SHUFFLE),
|
||||||
|
(PlayMode.SHUFFLE, True, PlayMode.SHUFFLE),
|
||||||
|
(PlayMode.REPEAT_ONE, True, PlayMode.RANDOM),
|
||||||
|
(PlayMode.REPEAT_ALL, True, PlayMode.RANDOM),
|
||||||
|
(PlayMode.RANDOM, True, PlayMode.RANDOM),
|
||||||
|
]:
|
||||||
|
dmr_device_mock.play_mode = init_mode
|
||||||
|
await hass.services.async_call(
|
||||||
|
MP_DOMAIN,
|
||||||
|
ha_const.SERVICE_SHUFFLE_SET,
|
||||||
|
{ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_SHUFFLE: shuffle_set},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
dmr_device_mock.async_set_play_mode.assert_awaited_with(expect_mode)
|
||||||
|
|
||||||
|
# Test repeat with all variations of existing play mode
|
||||||
|
for init_mode, repeat_set, expect_mode in [
|
||||||
|
(PlayMode.NORMAL, mp_const.REPEAT_MODE_OFF, PlayMode.NORMAL),
|
||||||
|
(PlayMode.SHUFFLE, mp_const.REPEAT_MODE_OFF, PlayMode.SHUFFLE),
|
||||||
|
(PlayMode.REPEAT_ONE, mp_const.REPEAT_MODE_OFF, PlayMode.NORMAL),
|
||||||
|
(PlayMode.REPEAT_ALL, mp_const.REPEAT_MODE_OFF, PlayMode.NORMAL),
|
||||||
|
(PlayMode.RANDOM, mp_const.REPEAT_MODE_OFF, PlayMode.SHUFFLE),
|
||||||
|
(PlayMode.NORMAL, mp_const.REPEAT_MODE_ONE, PlayMode.REPEAT_ONE),
|
||||||
|
(PlayMode.SHUFFLE, mp_const.REPEAT_MODE_ONE, PlayMode.REPEAT_ONE),
|
||||||
|
(PlayMode.REPEAT_ONE, mp_const.REPEAT_MODE_ONE, PlayMode.REPEAT_ONE),
|
||||||
|
(PlayMode.REPEAT_ALL, mp_const.REPEAT_MODE_ONE, PlayMode.REPEAT_ONE),
|
||||||
|
(PlayMode.RANDOM, mp_const.REPEAT_MODE_ONE, PlayMode.REPEAT_ONE),
|
||||||
|
(PlayMode.NORMAL, mp_const.REPEAT_MODE_ALL, PlayMode.REPEAT_ALL),
|
||||||
|
(PlayMode.SHUFFLE, mp_const.REPEAT_MODE_ALL, PlayMode.RANDOM),
|
||||||
|
(PlayMode.REPEAT_ONE, mp_const.REPEAT_MODE_ALL, PlayMode.REPEAT_ALL),
|
||||||
|
(PlayMode.REPEAT_ALL, mp_const.REPEAT_MODE_ALL, PlayMode.REPEAT_ALL),
|
||||||
|
(PlayMode.RANDOM, mp_const.REPEAT_MODE_ALL, PlayMode.RANDOM),
|
||||||
|
]:
|
||||||
|
dmr_device_mock.play_mode = init_mode
|
||||||
|
await hass.services.async_call(
|
||||||
|
MP_DOMAIN,
|
||||||
|
ha_const.SERVICE_REPEAT_SET,
|
||||||
|
{ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_REPEAT: repeat_set},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
dmr_device_mock.async_set_play_mode.assert_awaited_with(expect_mode)
|
||||||
|
|
||||||
|
# Test shuffle when the device doesn't support the desired play mode.
|
||||||
|
# Trying to go from RANDOM -> REPEAT_MODE_ALL, but nothing in the list is supported.
|
||||||
|
dmr_device_mock.async_set_play_mode.reset_mock()
|
||||||
|
dmr_device_mock.play_mode = PlayMode.RANDOM
|
||||||
|
dmr_device_mock.valid_play_modes = {PlayMode.SHUFFLE, PlayMode.RANDOM}
|
||||||
|
await hass.services.async_call(
|
||||||
|
MP_DOMAIN,
|
||||||
|
ha_const.SERVICE_SHUFFLE_SET,
|
||||||
|
{ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_SHUFFLE: False},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
dmr_device_mock.async_set_play_mode.assert_not_awaited()
|
||||||
|
|
||||||
|
# Test repeat when the device doesn't support the desired play mode.
|
||||||
|
# Trying to go from RANDOM -> SHUFFLE, but nothing in the list is supported.
|
||||||
|
dmr_device_mock.async_set_play_mode.reset_mock()
|
||||||
|
dmr_device_mock.play_mode = PlayMode.RANDOM
|
||||||
|
dmr_device_mock.valid_play_modes = {PlayMode.REPEAT_ONE, PlayMode.REPEAT_ALL}
|
||||||
|
await hass.services.async_call(
|
||||||
|
MP_DOMAIN,
|
||||||
|
ha_const.SERVICE_REPEAT_SET,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: mock_entity_id,
|
||||||
|
mp_const.ATTR_MEDIA_REPEAT: mp_const.REPEAT_MODE_OFF,
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
dmr_device_mock.async_set_play_mode.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_playback_update_state(
|
||||||
|
hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str
|
||||||
|
) -> None:
|
||||||
|
"""Test starting or pausing playback causes the state to be refreshed.
|
||||||
|
|
||||||
|
This is necessary for responsive updates of the current track position and
|
||||||
|
total track time.
|
||||||
|
"""
|
||||||
|
on_event = dmr_device_mock.on_event
|
||||||
|
mock_service = Mock(UpnpService)
|
||||||
|
mock_service.service_id = "urn:upnp-org:serviceId:AVTransport"
|
||||||
|
mock_state_variable = Mock(UpnpStateVariable)
|
||||||
|
mock_state_variable.name = "TransportState"
|
||||||
|
|
||||||
|
# Event update that device has started playing, device should get polled
|
||||||
|
mock_state_variable.value = TransportState.PLAYING
|
||||||
|
on_event(mock_service, [mock_state_variable])
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
dmr_device_mock.async_update.assert_awaited_once_with(do_ping=False)
|
||||||
|
|
||||||
|
# Event update that device has paused playing, device should get polled
|
||||||
|
dmr_device_mock.async_update.reset_mock()
|
||||||
|
mock_state_variable.value = TransportState.PAUSED_PLAYBACK
|
||||||
|
on_event(mock_service, [mock_state_variable])
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
dmr_device_mock.async_update.assert_awaited_once_with(do_ping=False)
|
||||||
|
|
||||||
|
# Different service shouldn't do anything
|
||||||
|
dmr_device_mock.async_update.reset_mock()
|
||||||
|
mock_service.service_id = "urn:upnp-org:serviceId:RenderingControl"
|
||||||
|
on_event(mock_service, [mock_state_variable])
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
dmr_device_mock.async_update.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
async def test_unavailable_device(
|
async def test_unavailable_device(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
domain_data_mock: Mock,
|
domain_data_mock: Mock,
|
||||||
@ -691,6 +971,7 @@ async def test_unavailable_device(
|
|||||||
|
|
||||||
assert attrs[ha_const.ATTR_FRIENDLY_NAME] == MOCK_DEVICE_NAME
|
assert attrs[ha_const.ATTR_FRIENDLY_NAME] == MOCK_DEVICE_NAME
|
||||||
assert attrs[ha_const.ATTR_SUPPORTED_FEATURES] == 0
|
assert attrs[ha_const.ATTR_SUPPORTED_FEATURES] == 0
|
||||||
|
assert mp_const.ATTR_SOUND_MODE_LIST not in attrs
|
||||||
|
|
||||||
# Check service calls do nothing
|
# Check service calls do nothing
|
||||||
SERVICES: list[tuple[str, dict]] = [
|
SERVICES: list[tuple[str, dict]] = [
|
||||||
@ -710,6 +991,9 @@ async def test_unavailable_device(
|
|||||||
mp_const.ATTR_MEDIA_ENQUEUE: False,
|
mp_const.ATTR_MEDIA_ENQUEUE: False,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
(mp_const.SERVICE_SELECT_SOUND_MODE, {mp_const.ATTR_SOUND_MODE: "Default"}),
|
||||||
|
(ha_const.SERVICE_SHUFFLE_SET, {mp_const.ATTR_MEDIA_SHUFFLE: True}),
|
||||||
|
(ha_const.SERVICE_REPEAT_SET, {mp_const.ATTR_MEDIA_REPEAT: "all"}),
|
||||||
]
|
]
|
||||||
for service, data in SERVICES:
|
for service, data in SERVICES:
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
@ -1312,6 +1596,9 @@ async def test_disappearing_device(
|
|||||||
# media_image_url is normally hidden by entity_picture, but we want a direct check
|
# media_image_url is normally hidden by entity_picture, but we want a direct check
|
||||||
assert entity.media_image_url is None
|
assert entity.media_image_url is None
|
||||||
|
|
||||||
|
# Check attributes that are normally pre-checked
|
||||||
|
assert entity.sound_mode_list is None
|
||||||
|
|
||||||
# Test service calls
|
# Test service calls
|
||||||
await entity.async_set_volume_level(0.1)
|
await entity.async_set_volume_level(0.1)
|
||||||
await entity.async_mute_volume(True)
|
await entity.async_mute_volume(True)
|
||||||
@ -1322,6 +1609,9 @@ async def test_disappearing_device(
|
|||||||
await entity.async_play_media("", "")
|
await entity.async_play_media("", "")
|
||||||
await entity.async_media_previous_track()
|
await entity.async_media_previous_track()
|
||||||
await entity.async_media_next_track()
|
await entity.async_media_next_track()
|
||||||
|
await entity.async_set_shuffle(True)
|
||||||
|
await entity.async_set_repeat(mp_const.REPEAT_MODE_ALL)
|
||||||
|
await entity.async_select_sound_mode("Default")
|
||||||
|
|
||||||
|
|
||||||
async def test_resubscribe_failure(
|
async def test_resubscribe_failure(
|
||||||
@ -1335,7 +1625,8 @@ async def test_resubscribe_failure(
|
|||||||
dmr_device_mock.async_update.reset_mock()
|
dmr_device_mock.async_update.reset_mock()
|
||||||
|
|
||||||
on_event = dmr_device_mock.on_event
|
on_event = dmr_device_mock.on_event
|
||||||
on_event(None, [])
|
mock_service = Mock(UpnpService)
|
||||||
|
on_event(mock_service, [])
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
await async_update_entity(hass, mock_entity_id)
|
await async_update_entity(hass, mock_entity_id)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user