Add definitions for grouping media players (#41193)

* Add definitions for grouping media players

See https://github.com/home-assistant/architecture/issues/364

* Fix Google Assistant tests

* Define sync versions of async_join_players/async_unjoin

* Don't use async API in synchronous test methods

* Fix tests and make pylint happy

The method name `unjoin` is used by another component, so let's use
`unjoin_player` instead.

* Fix emulated_hue tests

The new media player entity in the `demo` component requires a tiny
adjustment in the emulated_hue tests.

* Use "target:" in service description

* Also use "name:" in service descriptions

Co-authored-by: Franck Nijhof <git@frenck.dev>
This commit is contained in:
Dan Klaffenbach 2021-03-18 18:19:28 +01:00 committed by GitHub
parent 9fca001eed
commit 5174f63fd8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 143 additions and 5 deletions

View File

@ -6,6 +6,7 @@ from homeassistant.components.media_player.const import (
MEDIA_TYPE_TVSHOW,
REPEAT_MODE_OFF,
SUPPORT_CLEAR_PLAYLIST,
SUPPORT_GROUPING,
SUPPORT_NEXT_TRACK,
SUPPORT_PAUSE,
SUPPORT_PLAY,
@ -40,6 +41,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
"Bedroom", "kxopViU98Xo", "Epic sax guy 10 hours", 360000
),
DemoMusicPlayer(),
DemoMusicPlayer("Kitchen"),
DemoTVShowPlayer(),
]
)
@ -73,6 +75,7 @@ MUSIC_PLAYER_SUPPORT = (
| SUPPORT_TURN_ON
| SUPPORT_TURN_OFF
| SUPPORT_CLEAR_PLAYLIST
| SUPPORT_GROUPING
| SUPPORT_PLAY
| SUPPORT_SHUFFLE_SET
| SUPPORT_REPEAT_SET
@ -291,7 +294,7 @@ class DemoYoutubePlayer(AbstractDemoPlayer):
class DemoMusicPlayer(AbstractDemoPlayer):
"""A Demo media player that only supports YouTube."""
"""A Demo media player."""
# We only implement the methods that we support
@ -318,12 +321,18 @@ class DemoMusicPlayer(AbstractDemoPlayer):
),
]
def __init__(self):
def __init__(self, name="Walkman"):
"""Initialize the demo device."""
super().__init__("Walkman")
super().__init__(name)
self._cur_track = 0
self._group_members = []
self._repeat = REPEAT_MODE_OFF
@property
def group_members(self):
"""List of players which are currently grouped together."""
return self._group_members
@property
def media_content_id(self):
"""Return the content ID of current playing media."""
@ -398,6 +407,18 @@ class DemoMusicPlayer(AbstractDemoPlayer):
self._repeat = repeat
self.schedule_update_ha_state()
def join_players(self, group_members):
"""Join `group_members` as a player group with the current player."""
self._group_members = [
self.entity_id,
] + group_members
self.schedule_update_ha_state()
def unjoin_player(self):
"""Remove this player from any group."""
self._group_members = []
self.schedule_update_ha_state()
class DemoTVShowPlayer(AbstractDemoPlayer):
"""A Demo media player that only supports YouTube."""

View File

@ -64,6 +64,7 @@ from homeassistant.loader import bind_hass
from .const import (
ATTR_APP_ID,
ATTR_APP_NAME,
ATTR_GROUP_MEMBERS,
ATTR_INPUT_SOURCE,
ATTR_INPUT_SOURCE_LIST,
ATTR_MEDIA_ALBUM_ARTIST,
@ -94,11 +95,14 @@ from .const import (
MEDIA_CLASS_DIRECTORY,
REPEAT_MODES,
SERVICE_CLEAR_PLAYLIST,
SERVICE_JOIN,
SERVICE_PLAY_MEDIA,
SERVICE_SELECT_SOUND_MODE,
SERVICE_SELECT_SOURCE,
SERVICE_UNJOIN,
SUPPORT_BROWSE_MEDIA,
SUPPORT_CLEAR_PLAYLIST,
SUPPORT_GROUPING,
SUPPORT_NEXT_TRACK,
SUPPORT_PAUSE,
SUPPORT_PLAY,
@ -299,6 +303,12 @@ async def async_setup(hass, config):
"async_media_seek",
[SUPPORT_SEEK],
)
component.async_register_entity_service(
SERVICE_JOIN,
{vol.Required(ATTR_GROUP_MEMBERS): list},
"async_join_players",
[SUPPORT_GROUPING],
)
component.async_register_entity_service(
SERVICE_SELECT_SOURCE,
{vol.Required(ATTR_INPUT_SOURCE): cv.string},
@ -330,6 +340,9 @@ async def async_setup(hass, config):
"async_set_shuffle",
[SUPPORT_SHUFFLE_SET],
)
component.async_register_entity_service(
SERVICE_UNJOIN, {}, "async_unjoin_player", [SUPPORT_GROUPING]
)
component.async_register_entity_service(
SERVICE_REPEAT_SET,
@ -537,6 +550,11 @@ class MediaPlayerEntity(Entity):
"""Return current repeat mode."""
return None
@property
def group_members(self):
"""List of members which are currently grouped together."""
return None
@property
def supported_features(self):
"""Flag media player features that are supported."""
@ -738,6 +756,11 @@ class MediaPlayerEntity(Entity):
"""Boolean if shuffle is supported."""
return bool(self.supported_features & SUPPORT_SHUFFLE_SET)
@property
def support_grouping(self):
"""Boolean if player grouping is supported."""
return bool(self.supported_features & SUPPORT_GROUPING)
async def async_toggle(self):
"""Toggle the power on the media player."""
if hasattr(self, "toggle"):
@ -846,6 +869,9 @@ class MediaPlayerEntity(Entity):
if self.media_image_remotely_accessible:
state_attr["entity_picture_local"] = self.media_image_local
if self.support_grouping:
state_attr[ATTR_GROUP_MEMBERS] = self.group_members
return state_attr
async def async_browse_media(
@ -860,6 +886,22 @@ class MediaPlayerEntity(Entity):
"""
raise NotImplementedError()
def join_players(self, group_members):
"""Join `group_members` as a player group with the current player."""
raise NotImplementedError()
async def async_join_players(self, group_members):
"""Join `group_members` as a player group with the current player."""
await self.hass.async_add_executor_job(self.join_players, group_members)
def unjoin_player(self):
"""Remove this player from any group."""
raise NotImplementedError()
async def async_unjoin_player(self):
"""Remove this player from any group."""
await self.hass.async_add_executor_job(self.unjoin_player)
async def _async_fetch_image_from_cache(self, url):
"""Fetch image.

View File

@ -2,6 +2,7 @@
ATTR_APP_ID = "app_id"
ATTR_APP_NAME = "app_name"
ATTR_GROUP_MEMBERS = "group_members"
ATTR_INPUT_SOURCE = "source"
ATTR_INPUT_SOURCE_LIST = "source_list"
ATTR_MEDIA_ALBUM_ARTIST = "media_album_artist"
@ -75,9 +76,11 @@ MEDIA_TYPE_URL = "url"
MEDIA_TYPE_VIDEO = "video"
SERVICE_CLEAR_PLAYLIST = "clear_playlist"
SERVICE_JOIN = "join"
SERVICE_PLAY_MEDIA = "play_media"
SERVICE_SELECT_SOUND_MODE = "select_sound_mode"
SERVICE_SELECT_SOURCE = "select_source"
SERVICE_UNJOIN = "unjoin"
REPEAT_MODE_ALL = "all"
REPEAT_MODE_OFF = "off"
@ -103,3 +106,4 @@ SUPPORT_SHUFFLE_SET = 32768
SUPPORT_SELECT_SOUND_MODE = 65536
SUPPORT_BROWSE_MEDIA = 131072
SUPPORT_REPEAT_SET = 262144
SUPPORT_GROUPING = 524288

View File

@ -118,8 +118,8 @@ play_media:
media_content_type:
name: Content type
description:
The type of the content to play. Like image, music, tvshow,
video, episode, channel or playlist.
The type of the content to play. Like image, music, tvshow, video,
episode, channel or playlist.
required: true
example: "music"
selector:
@ -184,3 +184,24 @@ repeat_set:
- "off"
- "all"
- "one"
join:
description:
Group players together. Only works on platforms with support for player
groups.
name: Join
target:
fields:
group_members:
description:
The players which will be synced with the target player.
example:
- "media_player.multiroom_player2"
- "media_player.multiroom_player3"
unjoin:
description:
Unjoin the player from a group. Only works on platforms with support for
player groups.
name: Unjoin
target:

View File

@ -442,3 +442,39 @@ async def test_media_image_proxy(hass, hass_client):
req = await client.get(state.attributes.get(ATTR_ENTITY_PICTURE))
assert req.status == 200
assert await req.text() == fake_picture_data
async def test_grouping(hass):
"""Test the join/unjoin services."""
walkman = "media_player.walkman"
kitchen = "media_player.kitchen"
assert await async_setup_component(
hass, mp.DOMAIN, {"media_player": {"platform": "demo"}}
)
await hass.async_block_till_done()
state = hass.states.get(walkman)
assert state.attributes.get(mp.ATTR_GROUP_MEMBERS) == []
await hass.services.async_call(
mp.DOMAIN,
mp.SERVICE_JOIN,
{
ATTR_ENTITY_ID: walkman,
mp.ATTR_GROUP_MEMBERS: [
kitchen,
],
},
blocking=True,
)
state = hass.states.get(walkman)
assert state.attributes.get(mp.ATTR_GROUP_MEMBERS) == [walkman, kitchen]
await hass.services.async_call(
mp.DOMAIN,
mp.SERVICE_UNJOIN,
{ATTR_ENTITY_ID: walkman},
blocking=True,
)
state = hass.states.get(walkman)
assert state.attributes.get(mp.ATTR_GROUP_MEMBERS) == []

View File

@ -90,6 +90,7 @@ ENTITY_IDS_BY_NUMBER = {
"21": "humidifier.hygrostat",
"22": "scene.light_on",
"23": "scene.light_off",
"24": "media_player.kitchen",
}
ENTITY_NUMBERS_BY_ID = {v: k for k, v in ENTITY_IDS_BY_NUMBER.items()}

View File

@ -192,6 +192,19 @@ DEMO_DEVICES = [
"type": "action.devices.types.SETTOP",
"willReportState": False,
},
{
"id": "media_player.kitchen",
"name": {"name": "Kitchen"},
"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": "media_player.living_room",
"name": {"name": "Living Room"},