diff --git a/homeassistant/components/dlna_dmr/const.py b/homeassistant/components/dlna_dmr/const.py index 7b081469ca8..f3217fdafff 100644 --- a/homeassistant/components/dlna_dmr/const.py +++ b/homeassistant/components/dlna_dmr/const.py @@ -1,8 +1,12 @@ """Constants for the DLNA DMR component.""" +from __future__ import annotations +from collections.abc import Mapping import logging from typing import Final +from homeassistant.components.media_player import const as _mp_const + LOGGER = logging.getLogger(__package__) DOMAIN: Final = "dlna_dmr" @@ -14,3 +18,43 @@ CONF_POLL_AVAILABILITY: Final = "poll_availability" DEFAULT_NAME: Final = "DLNA Digital Media Renderer" CONNECT_TIMEOUT: Final = 10 + +# Map UPnP class to media_player media_content_type +MEDIA_TYPE_MAP: Mapping[str, str] = { + "object": _mp_const.MEDIA_TYPE_URL, + "object.item": _mp_const.MEDIA_TYPE_URL, + "object.item.imageItem": _mp_const.MEDIA_TYPE_IMAGE, + "object.item.imageItem.photo": _mp_const.MEDIA_TYPE_IMAGE, + "object.item.audioItem": _mp_const.MEDIA_TYPE_MUSIC, + "object.item.audioItem.musicTrack": _mp_const.MEDIA_TYPE_MUSIC, + "object.item.audioItem.audioBroadcast": _mp_const.MEDIA_TYPE_MUSIC, + "object.item.audioItem.audioBook": _mp_const.MEDIA_TYPE_PODCAST, + "object.item.videoItem": _mp_const.MEDIA_TYPE_VIDEO, + "object.item.videoItem.movie": _mp_const.MEDIA_TYPE_MOVIE, + "object.item.videoItem.videoBroadcast": _mp_const.MEDIA_TYPE_TVSHOW, + "object.item.videoItem.musicVideoClip": _mp_const.MEDIA_TYPE_VIDEO, + "object.item.playlistItem": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.item.textItem": _mp_const.MEDIA_TYPE_URL, + "object.item.bookmarkItem": _mp_const.MEDIA_TYPE_URL, + "object.item.epgItem": _mp_const.MEDIA_TYPE_EPISODE, + "object.item.epgItem.audioProgram": _mp_const.MEDIA_TYPE_EPISODE, + "object.item.epgItem.videoProgram": _mp_const.MEDIA_TYPE_EPISODE, + "object.container": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.container.person": _mp_const.MEDIA_TYPE_ARTIST, + "object.container.person.musicArtist": _mp_const.MEDIA_TYPE_ARTIST, + "object.container.playlistContainer": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.container.album": _mp_const.MEDIA_TYPE_ALBUM, + "object.container.album.musicAlbum": _mp_const.MEDIA_TYPE_ALBUM, + "object.container.album.photoAlbum": _mp_const.MEDIA_TYPE_ALBUM, + "object.container.genre": _mp_const.MEDIA_TYPE_GENRE, + "object.container.genre.musicGenre": _mp_const.MEDIA_TYPE_GENRE, + "object.container.genre.movieGenre": _mp_const.MEDIA_TYPE_GENRE, + "object.container.channelGroup": _mp_const.MEDIA_TYPE_CHANNELS, + "object.container.channelGroup.audioChannelGroup": _mp_const.MEDIA_TYPE_CHANNELS, + "object.container.channelGroup.videoChannelGroup": _mp_const.MEDIA_TYPE_CHANNELS, + "object.container.epgContainer": _mp_const.MEDIA_TYPE_TVSHOW, + "object.container.storageSystem": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.container.storageVolume": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.container.storageFolder": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.container.bookmarkFolder": _mp_const.MEDIA_TYPE_PLAYLIST, +} diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 2e802ee876f..002228e28b3 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -3,7 +3,7 @@ "name": "DLNA Digital Media Renderer", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", - "requirements": ["async-upnp-client==0.22.3"], + "requirements": ["async-upnp-client==0.22.4"], "dependencies": ["network", "ssdp"], "ssdp": [ { diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index d7db104ee42..8542464e41e 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -37,7 +37,6 @@ from homeassistant.const import ( STATE_ON, STATE_PAUSED, STATE_PLAYING, - STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry, entity_registry @@ -51,6 +50,7 @@ from .const import ( DEFAULT_NAME, DOMAIN, LOGGER as _LOGGER, + MEDIA_TYPE_MAP, ) from .data import EventListenAddr, get_domain_data @@ -389,11 +389,6 @@ class DlnaDmrEntity(MediaPlayerEntity): domain_data = get_domain_data(self.hass) await domain_data.async_release_event_notifier(self._event_addr) - @property - def available(self) -> bool: - """Device is available when we have a connection to it.""" - return self._device is not None and self._device.profile_device.available - async def async_update(self) -> None: """Retrieve the latest data.""" if not self._device: @@ -426,6 +421,44 @@ class DlnaDmrEntity(MediaPlayerEntity): self.check_available = True self.schedule_update_ha_state() + @property + def available(self) -> bool: + """Device is available when we have a connection to it.""" + return self._device is not None and self._device.profile_device.available + + @property + def unique_id(self) -> str: + """Report the UDN (Unique Device Name) as this entity's unique ID.""" + return self.udn + + @property + def usn(self) -> str: + """Get the USN based on the UDN (Unique Device Name) and device type.""" + return f"{self.udn}::{self.device_type}" + + @property + def state(self) -> str | None: + """State of the player.""" + if not self._device or not self.available: + return STATE_OFF + if self._device.transport_state is None: + return STATE_ON + if self._device.transport_state in ( + TransportState.PLAYING, + TransportState.TRANSITIONING, + ): + return STATE_PLAYING + if self._device.transport_state in ( + TransportState.PAUSED_PLAYBACK, + TransportState.PAUSED_RECORDING, + ): + return STATE_PAUSED + if self._device.transport_state == TransportState.VENDOR_DEFINED: + # Unable to map this state to anything reasonable, so it's "Unknown" + return None + + return STATE_IDLE + @property def supported_features(self) -> int: """Flag media player features that are supported at this moment. @@ -552,7 +585,8 @@ class DlnaDmrEntity(MediaPlayerEntity): """Title of current playing media.""" if not self._device: return None - return self._device.media_title + # Use the best available title + return self._device.media_program_title or self._device.media_title @property def media_image_url(self) -> str | None: @@ -562,26 +596,18 @@ class DlnaDmrEntity(MediaPlayerEntity): return self._device.media_image_url @property - def state(self) -> str: - """State of the player.""" - if not self._device or not self.available: - return STATE_OFF - if self._device.transport_state is None: - return STATE_ON - if self._device.transport_state in ( - TransportState.PLAYING, - TransportState.TRANSITIONING, - ): - return STATE_PLAYING - if self._device.transport_state in ( - TransportState.PAUSED_PLAYBACK, - TransportState.PAUSED_RECORDING, - ): - return STATE_PAUSED - if self._device.transport_state == TransportState.VENDOR_DEFINED: - return STATE_UNKNOWN + def media_content_id(self) -> str | None: + """Content ID of current playing media.""" + if not self._device: + return None + return self._device.current_track_uri - return STATE_IDLE + @property + def media_content_type(self) -> str | None: + """Content type of current playing media.""" + if not self._device or not self._device.media_class: + return None + return MEDIA_TYPE_MAP.get(self._device.media_class) @property def media_duration(self) -> int | None: @@ -608,11 +634,80 @@ class DlnaDmrEntity(MediaPlayerEntity): return self._device.media_position_updated_at @property - def unique_id(self) -> str: - """Report the UDN (Unique Device Name) as this entity's unique ID.""" - return self.udn + def media_artist(self) -> str | None: + """Artist of current playing media, music track only.""" + if not self._device: + return None + return self._device.media_artist @property - def usn(self) -> str: - """Get the USN based on the UDN (Unique Device Name) and device type.""" - return f"{self.udn}::{self.device_type}" + def media_album_name(self) -> str | None: + """Album name of current playing media, music track only.""" + if not self._device: + return None + return self._device.media_album_name + + @property + def media_album_artist(self) -> str | None: + """Album artist of current playing media, music track only.""" + if not self._device: + return None + return self._device.media_album_artist + + @property + def media_track(self) -> int | None: + """Track number of current playing media, music track only.""" + if not self._device: + return None + return self._device.media_track_number + + @property + def media_series_title(self) -> str | None: + """Title of series of current playing media, TV show only.""" + if not self._device: + return None + return self._device.media_series_title + + @property + def media_season(self) -> str | None: + """Season number, starting at 1, of current playing media, TV show only.""" + if not self._device: + return None + # Some DMRs, like Kodi, leave this as 0 and encode the season & episode + # in the episode_number metadata, as {season:d}{episode:02d} + if ( + not self._device.media_season_number + or self._device.media_season_number == "0" + ) and self._device.media_episode_number: + try: + episode = int(self._device.media_episode_number, 10) + if episode > 100: + return str(episode // 100) + except ValueError: + pass + return self._device.media_season_number + + @property + def media_episode(self) -> str | None: + """Episode number of current playing media, TV show only.""" + if not self._device: + return None + # Complement to media_season math above + if ( + not self._device.media_season_number + or self._device.media_season_number == "0" + ) and self._device.media_episode_number: + try: + episode = int(self._device.media_episode_number, 10) + if episode > 100: + return str(episode % 100) + except ValueError: + pass + return self._device.media_episode_number + + @property + def media_channel(self) -> str | None: + """Channel name currently playing.""" + if not self._device: + return None + return self._device.media_channel_name diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 6590e6fa756..3a6531fcacb 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -2,7 +2,7 @@ "domain": "ssdp", "name": "Simple Service Discovery Protocol (SSDP)", "documentation": "https://www.home-assistant.io/integrations/ssdp", - "requirements": ["async-upnp-client==0.22.3"], + "requirements": ["async-upnp-client==0.22.4"], "dependencies": ["network"], "after_dependencies": ["zeroconf"], "codeowners": [], diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index fb5912657c0..6ab3896cfdb 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -3,7 +3,7 @@ "name": "UPnP/IGD", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upnp", - "requirements": ["async-upnp-client==0.22.3"], + "requirements": ["async-upnp-client==0.22.4"], "dependencies": ["network", "ssdp"], "codeowners": ["@StevenLooman","@ehendrix23"], "ssdp": [ diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 3ecece7414f..163a718c0bb 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,7 +2,7 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.7.5", "async-upnp-client==0.22.3"], + "requirements": ["yeelight==0.7.5", "async-upnp-client==0.22.4"], "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"], "config_flow": true, "dependencies": ["network"], diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8b0802b26a8..13867fb7afe 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodiscover==1.4.2 aiohttp==3.7.4.post0 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.22.3 +async-upnp-client==0.22.4 async_timeout==3.0.1 attrs==21.2.0 awesomeversion==21.8.1 diff --git a/requirements_all.txt b/requirements_all.txt index e907682bcb2..1a6b64cce2b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -330,7 +330,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.22.3 +async-upnp-client==0.22.4 # homeassistant.components.supla asyncpysupla==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 53aa50dbe7f..aca2471d108 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -224,7 +224,7 @@ arcam-fmj==0.7.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.22.3 +async-upnp-client==0.22.4 # homeassistant.components.aurora auroranoaa==0.0.2 diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py index 99bdc14b553..4c27de1be67 100644 --- a/tests/components/dlna_dmr/test_media_player.py +++ b/tests/components/dlna_dmr/test_media_player.py @@ -2,9 +2,10 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncIterable +from collections.abc import AsyncIterable, Mapping from datetime import timedelta from types import MappingProxyType +from typing import Any from unittest.mock import ANY, DEFAULT, Mock, patch from async_upnp_client.exceptions import UpnpConnectionError, UpnpError @@ -73,6 +74,8 @@ async def mock_entity_id( """ entity_id = await setup_mock_component(hass, config_entry_mock) + assert dmr_device_mock.async_subscribe_services.await_count == 1 + yield entity_id # Unload config entry to clean up @@ -97,6 +100,8 @@ async def mock_disconnected_entity_id( entity_id = await setup_mock_component(hass, config_entry_mock) + assert dmr_device_mock.async_subscribe_services.await_count == 0 + yield entity_id # Unload config entry to clean up @@ -239,7 +244,6 @@ async def test_setup_entry_with_options( async def test_event_subscribe_failure( hass: HomeAssistant, - domain_data_mock: Mock, config_entry_mock: MockConfigEntry, dmr_device_mock: Mock, ) -> None: @@ -310,11 +314,15 @@ async def test_available_device( await async_update_entity(hass, mock_entity_id) # Check attributes come directly from the device - entity_state = hass.states.get(mock_entity_id) - assert entity_state is not None - attrs = entity_state.attributes - assert attrs is not None + async def get_attrs() -> Mapping[str, Any]: + 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_MUTED] is dmr_device_mock.is_volume_muted assert attrs[mp_const.ATTR_MEDIA_DURATION] is dmr_device_mock.media_duration @@ -323,9 +331,43 @@ async def test_available_device( attrs[mp_const.ATTR_MEDIA_POSITION_UPDATED_AT] is dmr_device_mock.media_position_updated_at ) - assert attrs[mp_const.ATTR_MEDIA_TITLE] is dmr_device_mock.media_title + assert attrs[mp_const.ATTR_MEDIA_CONTENT_ID] is dmr_device_mock.current_track_uri + assert attrs[mp_const.ATTR_MEDIA_ARTIST] is dmr_device_mock.media_artist + assert attrs[mp_const.ATTR_MEDIA_ALBUM_NAME] is dmr_device_mock.media_album_name + assert attrs[mp_const.ATTR_MEDIA_ALBUM_ARTIST] is dmr_device_mock.media_album_artist + assert attrs[mp_const.ATTR_MEDIA_TRACK] is dmr_device_mock.media_track_number + assert attrs[mp_const.ATTR_MEDIA_SERIES_TITLE] is dmr_device_mock.media_series_title + 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_CHANNEL] is dmr_device_mock.media_channel_name # Entity picture is cached, won't correspond to remote image assert isinstance(attrs[ha_const.ATTR_ENTITY_PICTURE], str) + # media_title depends on what is available + assert attrs[mp_const.ATTR_MEDIA_TITLE] is dmr_device_mock.media_program_title + dmr_device_mock.media_program_title = None + attrs = await get_attrs() + assert attrs[mp_const.ATTR_MEDIA_TITLE] is dmr_device_mock.media_title + # media_content_type is mapped from UPnP class to MediaPlayer type + dmr_device_mock.media_class = "object.item.audioItem.musicTrack" + attrs = await get_attrs() + assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == mp_const.MEDIA_TYPE_MUSIC + dmr_device_mock.media_class = "object.item.videoItem.movie" + attrs = await get_attrs() + assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == mp_const.MEDIA_TYPE_MOVIE + dmr_device_mock.media_class = "object.item.videoItem.videoBroadcast" + attrs = await get_attrs() + assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == mp_const.MEDIA_TYPE_TVSHOW + # media_season & media_episode have a special case + dmr_device_mock.media_season_number = "0" + dmr_device_mock.media_episode_number = "123" + attrs = await get_attrs() + assert attrs[mp_const.ATTR_MEDIA_SEASON] == "1" + assert attrs[mp_const.ATTR_MEDIA_EPISODE] == "23" + dmr_device_mock.media_season_number = "0" + dmr_device_mock.media_episode_number = "S1E23" # Unexpected and not parsed + attrs = await get_attrs() + assert attrs[mp_const.ATTR_MEDIA_SEASON] == "0" + assert attrs[mp_const.ATTR_MEDIA_EPISODE] == "S1E23" # Check supported feature flags, one at a time. # tuple(async_upnp_client feature check property, HA feature flag) @@ -688,7 +730,6 @@ async def test_multiple_ssdp_alive( domain_data_mock: Mock, ssdp_scanner_mock: Mock, mock_disconnected_entity_id: str, - dmr_device_mock: Mock, ) -> None: """Test multiple SSDP alive notifications is ok, only connects to device once.""" domain_data_mock.upnp_factory.async_create_device.reset_mock() @@ -1028,7 +1069,6 @@ async def test_ssdp_bootid( async def test_become_unavailable( hass: HomeAssistant, - domain_data_mock: Mock, mock_entity_id: str, dmr_device_mock: Mock, ) -> None: @@ -1226,7 +1266,6 @@ async def test_config_update_connect_failure( hass: HomeAssistant, domain_data_mock: Mock, config_entry_mock: MockConfigEntry, - dmr_device_mock: Mock, mock_entity_id: str, ) -> None: """Test DlnaDmrEntity gracefully handles connect failure after config change."""