From 30a3fe69bca467f47f38919aa84220f2706a9480 Mon Sep 17 00:00:00 2001 From: Greg Dowling Date: Tue, 27 Oct 2020 20:50:20 +0000 Subject: [PATCH] Add platform services to Roon media player to allow grouping, ungrouping and transfer between players (#42133) --- homeassistant/components/roon/media_player.py | 136 ++++++++++++++++++ homeassistant/components/roon/server.py | 9 ++ homeassistant/components/roon/services.yaml | 29 ++++ 3 files changed, 174 insertions(+) create mode 100644 homeassistant/components/roon/services.yaml diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index 1c56e858d84..beac635fdba 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -1,6 +1,8 @@ """MediaPlayer platform for Roon integration.""" import logging +import voluptuous as vol + from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.components.media_player.const import ( SUPPORT_BROWSE_MEDIA, @@ -26,6 +28,7 @@ from homeassistant.const import ( STATE_PLAYING, ) from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -55,12 +58,38 @@ SUPPORT_ROON = ( _LOGGER = logging.getLogger(__name__) +SERVICE_JOIN = "join" +SERVICE_UNJOIN = "unjoin" +SERVICE_TRANSFER = "transfer" + +ATTR_JOIN = "join_ids" +ATTR_UNJOIN = "unjoin_ids" +ATTR_TRANSFER = "transfer_id" + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Roon MediaPlayer from Config Entry.""" roon_server = hass.data[DOMAIN][config_entry.entry_id] media_players = set() + # Register entity services + platform = entity_platform.current_platform.get() + platform.async_register_entity_service( + SERVICE_JOIN, + {vol.Required(ATTR_JOIN): vol.All(cv.ensure_list, [cv.entity_id])}, + "join", + ) + platform.async_register_entity_service( + SERVICE_UNJOIN, + {vol.Optional(ATTR_UNJOIN): vol.All(cv.ensure_list, [cv.entity_id])}, + "unjoin", + ) + platform.async_register_entity_service( + SERVICE_TRANSFER, + {vol.Required(ATTR_TRANSFER): cv.entity_id}, + "async_transfer", + ) + @callback def async_update_media_player(player_data): """Add or update Roon MediaPlayer.""" @@ -118,6 +147,7 @@ class RoonDevice(MediaPlayerEntity): self.async_update_callback, ) ) + self._server.add_player_id(self.entity_id, self.name) @callback def async_update_callback(self, player_data): @@ -478,6 +508,112 @@ class RoonDevice(MediaPlayerEntity): media_id, ) + def join(self, join_ids): + """Add another Roon player to this player's join group.""" + + zone_data = self._server.roonapi.zone_by_output_id(self._output_id) + if zone_data is None: + _LOGGER.error("No zone data for %s", self.name) + return + + sync_available = {} + for zone in self._server.zones.values(): + for output in zone["outputs"]: + if ( + zone["display_name"] != self.name + and output["output_id"] + in self.player_data["can_group_with_output_ids"] + and zone["display_name"] not in sync_available.keys() + ): + sync_available[zone["display_name"]] = output["output_id"] + + names = [] + for entity_id in join_ids: + name = self._server.roon_name(entity_id) + if name is None: + _LOGGER.error("No roon player found for %s", entity_id) + return + if name not in sync_available.keys(): + _LOGGER.error( + "Can't join player %s with %s because it's not in the join available list %s", + name, + self.name, + list(sync_available.keys()), + ) + return + names.append(name) + + _LOGGER.info("Joining %s to %s", names, self.name) + self._server.roonapi.group_outputs( + [self._output_id] + [sync_available[name] for name in names] + ) + + def unjoin(self, unjoin_ids=None): + """Remove a Roon player to this player's join group.""" + + zone_data = self._server.roonapi.zone_by_output_id(self._output_id) + if zone_data is None: + _LOGGER.error("No zone data for %s", self.name) + return + + join_group = { + output["display_name"]: output["output_id"] + for output in zone_data["outputs"] + if output["display_name"] != self.name + } + + if unjoin_ids is None: + # unjoin everything + names = list(join_group.keys()) + else: + names = [] + for entity_id in unjoin_ids: + name = self._server.roon_name(entity_id) + if name is None: + _LOGGER.error("No roon player found for %s", entity_id) + return + + if name not in join_group.keys(): + _LOGGER.error( + "Can't unjoin player %s from %s because it's not in the joined group %s", + name, + self.name, + list(join_group.keys()), + ) + return + names.append(name) + + _LOGGER.info("Unjoining %s from %s", names, self.name) + self._server.roonapi.ungroup_outputs([join_group[name] for name in names]) + + async def async_transfer(self, transfer_id): + """Transfer playback from this roon player to another.""" + + name = self._server.roon_name(transfer_id) + if name is None: + _LOGGER.error("No roon player found for %s", transfer_id) + return + + zone_ids = { + output["display_name"]: output["zone_id"] + for output in self._server.zones.values() + if output["display_name"] != self.name + } + + transfer_id = zone_ids.get(name) + if transfer_id is None: + _LOGGER.error( + "Can't transfer from %s to %s because destination is not known %s", + self.name, + transfer_id, + list(zone_ids.keys()), + ) + + _LOGGER.info("Transferring from %s to %s", self.name, name) + await self.hass.async_add_executor_job( + self._server.roonapi.transfer_zone, self._zone_id, transfer_id + ) + async def async_browse_media(self, media_content_type=None, media_content_id=None): """Implement the websocket media browsing helper.""" return await self.hass.async_add_executor_job( diff --git a/homeassistant/components/roon/server.py b/homeassistant/components/roon/server.py index 57adf00a9ac..c2766c648c4 100644 --- a/homeassistant/components/roon/server.py +++ b/homeassistant/components/roon/server.py @@ -26,6 +26,7 @@ class RoonServer: self.all_playlists = [] self.offline_devices = set() self._exit = False + self._roon_name_by_id = {} @property def host(self): @@ -69,6 +70,14 @@ class RoonServer: """Return list of zones.""" return self.roonapi.zones + def add_player_id(self, entity_id, roon_name): + """Register a roon player.""" + self._roon_name_by_id[entity_id] = roon_name + + def roon_name(self, entity_id): + """Get the name of the roon player from entity_id.""" + return self._roon_name_by_id[entity_id] + def stop_roon(self): """Stop background worker.""" self.roonapi.stop() diff --git a/homeassistant/components/roon/services.yaml b/homeassistant/components/roon/services.yaml new file mode 100644 index 00000000000..ec096effe5b --- /dev/null +++ b/homeassistant/components/roon/services.yaml @@ -0,0 +1,29 @@ +join: + description: Group players together. + fields: + entity_id: + description: id of the player that will be the master of the group. + example: "media_player.study" + join_ids: + description: id(s) of the players that will join the master. + example: "['media_player.bedroom', 'media_player.kitchen']" + +unjoin: + description: Remove players from a group. + fields: + entity_id: + description: id of the player that is the master of the group.. + example: "media_player.study" + unjoin_ids: + description: Optional id(s) of the players that will be unjoined from the group. If not specified, all players will be unjoined from the master. + example: "['media_player.bedroom', 'media_player.kitchen']" + +transfer: + description: Transfer playback from one player to another. + fields: + entity_id: + description: id of the source player. + example: "media_player.bedroom" + transfer_id: + description: id of the destination player. + example: "media_player.study"