diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index 2ea360ebb81..ea315707dea 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -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.""" diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 1e0a9b9f6bb..8e3ffe5dd0d 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -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. diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index 87ccca75d36..67f4331aa60 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -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 diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index eaca8483be1..e2a260dc80f 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -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: diff --git a/tests/components/demo/test_media_player.py b/tests/components/demo/test_media_player.py index a32a99bbc63..4ed41ce9c83 100644 --- a/tests/components/demo/test_media_player.py +++ b/tests/components/demo/test_media_player.py @@ -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) == [] diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index e3f965616f9..3c322c0b613 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -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()} diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 9047e175ae6..0fe89d0fa7b 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -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"},