diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index 35bd35a2245..b0b2e1a95f5 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -38,6 +38,8 @@ async def async_setup_entry( DemoMusicPlayer(), DemoMusicPlayer("Kitchen"), DemoTVShowPlayer(), + DemoBrowsePlayer("Browse"), + DemoGroupPlayer("Group"), ] ) @@ -90,6 +92,8 @@ NETFLIX_PLAYER_SUPPORT = ( | MediaPlayerEntityFeature.STOP ) +BROWSE_PLAYER_SUPPORT = MediaPlayerEntityFeature.BROWSE_MEDIA + class AbstractDemoPlayer(MediaPlayerEntity): """A demo media players.""" @@ -379,3 +383,19 @@ class DemoTVShowPlayer(AbstractDemoPlayer): """Set the input source.""" self._attr_source = source self.schedule_update_ha_state() + + +class DemoBrowsePlayer(AbstractDemoPlayer): + """A Demo media player that supports browse.""" + + _attr_supported_features = BROWSE_PLAYER_SUPPORT + + +class DemoGroupPlayer(AbstractDemoPlayer): + """A Demo media player that supports grouping.""" + + _attr_supported_features = ( + YOUTUBE_PLAYER_SUPPORT + | MediaPlayerEntityFeature.GROUPING + | MediaPlayerEntityFeature.TURN_OFF + ) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index a45127d7b86..706539664ec 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -12,7 +12,7 @@ import hashlib from http import HTTPStatus import logging import secrets -from typing import Any, Final, Required, TypedDict, final +from typing import TYPE_CHECKING, Any, Final, Required, TypedDict, final from urllib.parse import quote, urlparse from aiohttp import web @@ -131,6 +131,11 @@ from .const import ( # noqa: F401 ) from .errors import BrowseError +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -455,7 +460,43 @@ class MediaPlayerEntityDescription(EntityDescription, frozen_or_thawed=True): volume_step: float | None = None -class MediaPlayerEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", + "state", + "volume_level", + "volume_step", + "is_volume_muted", + "media_content_id", + "media_content_type", + "media_duration", + "media_position", + "media_position_updated_at", + "media_image_url", + "media_image_remotely_accessible", + "media_title", + "media_artist", + "media_album_name", + "media_album_artist", + "media_track", + "media_series_title", + "media_season", + "media_episode", + "media_channel", + "media_playlist", + "app_id", + "app_name", + "source", + "source_list", + "sound_mode", + "sound_mode_list", + "shuffle", + "repeat", + "group_members", + "supported_features", +} + + +class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ABC for media player entities.""" _entity_component_unrecorded_attributes = frozenset( @@ -507,7 +548,7 @@ class MediaPlayerEntity(Entity): _attr_volume_step: float # Implement these for your media player - @property + @cached_property def device_class(self) -> MediaPlayerDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -516,7 +557,7 @@ class MediaPlayerEntity(Entity): return self.entity_description.device_class return None - @property + @cached_property def state(self) -> MediaPlayerState | None: """State of the player.""" return self._attr_state @@ -528,12 +569,12 @@ class MediaPlayerEntity(Entity): self._access_token = secrets.token_hex(32) return self._access_token - @property + @cached_property def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" return self._attr_volume_level - @property + @cached_property def volume_step(self) -> float: """Return the step to be used by the volume_up and volume_down services.""" if hasattr(self, "_attr_volume_step"): @@ -545,32 +586,32 @@ class MediaPlayerEntity(Entity): return volume_step return 0.1 - @property + @cached_property def is_volume_muted(self) -> bool | None: """Boolean if volume is currently muted.""" return self._attr_is_volume_muted - @property + @cached_property def media_content_id(self) -> str | None: """Content ID of current playing media.""" return self._attr_media_content_id - @property + @cached_property def media_content_type(self) -> MediaType | str | None: """Content type of current playing media.""" return self._attr_media_content_type - @property + @cached_property def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" return self._attr_media_duration - @property + @cached_property def media_position(self) -> int | None: """Position of current playing media in seconds.""" return self._attr_media_position - @property + @cached_property def media_position_updated_at(self) -> dt.datetime | None: """When was the position of the current playing media valid. @@ -578,12 +619,12 @@ class MediaPlayerEntity(Entity): """ return self._attr_media_position_updated_at - @property + @cached_property def media_image_url(self) -> str | None: """Image url of current playing media.""" return self._attr_media_image_url - @property + @cached_property def media_image_remotely_accessible(self) -> bool: """If the image url is remotely accessible.""" return self._attr_media_image_remotely_accessible @@ -618,102 +659,102 @@ class MediaPlayerEntity(Entity): """ return None, None - @property + @cached_property def media_title(self) -> str | None: """Title of current playing media.""" return self._attr_media_title - @property + @cached_property def media_artist(self) -> str | None: """Artist of current playing media, music track only.""" return self._attr_media_artist - @property + @cached_property def media_album_name(self) -> str | None: """Album name of current playing media, music track only.""" return self._attr_media_album_name - @property + @cached_property def media_album_artist(self) -> str | None: """Album artist of current playing media, music track only.""" return self._attr_media_album_artist - @property + @cached_property def media_track(self) -> int | None: """Track number of current playing media, music track only.""" return self._attr_media_track - @property + @cached_property def media_series_title(self) -> str | None: """Title of series of current playing media, TV show only.""" return self._attr_media_series_title - @property + @cached_property def media_season(self) -> str | None: """Season of current playing media, TV show only.""" return self._attr_media_season - @property + @cached_property def media_episode(self) -> str | None: """Episode of current playing media, TV show only.""" return self._attr_media_episode - @property + @cached_property def media_channel(self) -> str | None: """Channel currently playing.""" return self._attr_media_channel - @property + @cached_property def media_playlist(self) -> str | None: """Title of Playlist currently playing.""" return self._attr_media_playlist - @property + @cached_property def app_id(self) -> str | None: """ID of the current running app.""" return self._attr_app_id - @property + @cached_property def app_name(self) -> str | None: """Name of the current running app.""" return self._attr_app_name - @property + @cached_property def source(self) -> str | None: """Name of the current input source.""" return self._attr_source - @property + @cached_property def source_list(self) -> list[str] | None: """List of available input sources.""" return self._attr_source_list - @property + @cached_property def sound_mode(self) -> str | None: """Name of the current sound mode.""" return self._attr_sound_mode - @property + @cached_property def sound_mode_list(self) -> list[str] | None: """List of available sound modes.""" return self._attr_sound_mode_list - @property + @cached_property def shuffle(self) -> bool | None: """Boolean if shuffle is enabled.""" return self._attr_shuffle - @property + @cached_property def repeat(self) -> RepeatMode | str | None: """Return current repeat mode.""" return self._attr_repeat - @property + @cached_property def group_members(self) -> list[str] | None: """List of members which are currently grouped together.""" return self._attr_group_members - @property + @cached_property def supported_features(self) -> MediaPlayerEntityFeature: """Flag media player features that are supported.""" return self._attr_supported_features diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 98f99349cac..3febc42730b 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -95,6 +95,8 @@ ENTITY_IDS_BY_NUMBER = { "24": "media_player.kitchen", "25": "light.office_rgbw_lights", "26": "light.living_room_rgbww_lights", + "27": "media_player.group", + "28": "media_player.browse", } ENTITY_NUMBERS_BY_ID = {v: k for k, v in ENTITY_IDS_BY_NUMBER.items()} diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 2122818bbb4..6fc1c9f580d 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -237,6 +237,26 @@ DEMO_DEVICES = [ "type": "action.devices.types.SETTOP", "willReportState": False, }, + { + "id": "media_player.browse", + "name": {"name": "Browse"}, + "traits": ["action.devices.traits.MediaState", "action.devices.traits.OnOff"], + "type": "action.devices.types.SETTOP", + "willReportState": False, + }, + { + "id": "media_player.group", + "name": {"name": "Group"}, + "traits": [ + "action.devices.traits.OnOff", + "action.devices.traits.Volume", + "action.devices.traits.Modes", + "action.devices.traits.TransportControl", + "action.devices.traits.MediaState", + ], + "type": "action.devices.types.SETTOP", + "willReportState": False, + }, { "id": "fan.living_room_fan", "name": {"name": "Living Room Fan"}, diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index b7bf35ab2f8..377cdd32748 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -10,7 +10,6 @@ from homeassistant.components.media_player import ( BrowseMedia, MediaClass, MediaPlayerEnqueue, - MediaPlayerEntityFeature, ) from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF @@ -159,9 +158,6 @@ async def test_media_browse( client = await hass_ws_client(hass) with patch( - "homeassistant.components.demo.media_player.MediaPlayerEntity.supported_features", - MediaPlayerEntityFeature.BROWSE_MEDIA, - ), patch( "homeassistant.components.media_player.MediaPlayerEntity.async_browse_media", return_value=BrowseMedia( media_class=MediaClass.DIRECTORY, @@ -176,7 +172,7 @@ async def test_media_browse( { "id": 5, "type": "media_player/browse_media", - "entity_id": "media_player.bedroom", + "entity_id": "media_player.browse", "media_content_type": "album", "media_content_id": "abcd", } @@ -202,9 +198,6 @@ async def test_media_browse( assert mock_browse_media.mock_calls[0][1] == ("album", "abcd") with patch( - "homeassistant.components.demo.media_player.MediaPlayerEntity.supported_features", - MediaPlayerEntityFeature.BROWSE_MEDIA, - ), patch( "homeassistant.components.media_player.MediaPlayerEntity.async_browse_media", return_value={"bla": "yo"}, ): @@ -212,7 +205,7 @@ async def test_media_browse( { "id": 6, "type": "media_player/browse_media", - "entity_id": "media_player.bedroom", + "entity_id": "media_player.browse", } ) @@ -231,19 +224,14 @@ async def test_group_members_available_when_off(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - # Fake group support for DemoYoutubePlayer - with patch( - "homeassistant.components.demo.media_player.MediaPlayerEntity.supported_features", - MediaPlayerEntityFeature.GROUPING | MediaPlayerEntityFeature.TURN_OFF, - ): - await hass.services.async_call( - "media_player", - "turn_off", - {ATTR_ENTITY_ID: "media_player.bedroom"}, - blocking=True, - ) + await hass.services.async_call( + "media_player", + "turn_off", + {ATTR_ENTITY_ID: "media_player.group"}, + blocking=True, + ) - state = hass.states.get("media_player.bedroom") + state = hass.states.get("media_player.group") assert state.state == STATE_OFF assert "group_members" in state.attributes