diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index d3749da318c..3a8275e98f0 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -65,6 +65,7 @@ class MusicCastDataUpdateCoordinator(DataUpdateCoordinator[MusicCastData]): self.musiccast = client super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + self.entities: list[MusicCastDeviceEntity] = [] async def _async_update_data(self) -> MusicCastData: """Update data via library.""" diff --git a/homeassistant/components/yamaha_musiccast/const.py b/homeassistant/components/yamaha_musiccast/const.py index 422f93e1562..b442a3135b9 100644 --- a/homeassistant/components/yamaha_musiccast/const.py +++ b/homeassistant/components/yamaha_musiccast/const.py @@ -1,4 +1,5 @@ """Constants for the MusicCast integration.""" + from homeassistant.components.media_player.const import ( REPEAT_MODE_ALL, REPEAT_MODE_OFF, @@ -16,6 +17,9 @@ ATTR_MODEL = "model" ATTR_PLAYLIST = "playlist" ATTR_PRESET = "preset" ATTR_SOFTWARE_VERSION = "sw_version" +ATTR_MC_LINK = "mc_link" +ATTR_MAIN_SYNC = "main_sync" +ATTR_MC_LINK_SOURCES = [ATTR_MC_LINK, ATTR_MAIN_SYNC] DEFAULT_ZONE = "main" HA_REPEAT_MODE_TO_MC_MAPPING = { @@ -24,6 +28,8 @@ HA_REPEAT_MODE_TO_MC_MAPPING = { REPEAT_MODE_ALL: "all", } +NULL_GROUP = "00000000000000000000000000000000" + INTERVAL_SECONDS = "interval_seconds" MC_REPEAT_MODE_TO_HA_MAPPING = { diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index aab2c8df3d2..88033cf68d1 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -3,12 +3,14 @@ from __future__ import annotations import logging +from aiomusiccast import MusicCastGroupException import voluptuous as vol from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity from homeassistant.components.media_player.const import ( REPEAT_MODE_OFF, SUPPORT_CLEAR_PLAYLIST, + SUPPORT_GROUPING, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -37,14 +39,18 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import DiscoveryInfoType, HomeAssistantType +from homeassistant.util import uuid from . import MusicCastDataUpdateCoordinator, MusicCastDeviceEntity from .const import ( + ATTR_MAIN_SYNC, + ATTR_MC_LINK, DEFAULT_ZONE, DOMAIN, HA_REPEAT_MODE_TO_MC_MAPPING, INTERVAL_SECONDS, MC_REPEAT_MODE_TO_HA_MAPPING, + NULL_GROUP, ) _LOGGER = logging.getLogger(__name__) @@ -64,6 +70,7 @@ MUSIC_PLAYER_SUPPORT = ( | SUPPORT_SELECT_SOUND_MODE | SUPPORT_SELECT_SOURCE | SUPPORT_STOP + | SUPPORT_GROUPING ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -146,12 +153,16 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): self._cur_track = 0 self._repeat = REPEAT_MODE_OFF + self.coordinator.entities.append(self) async def async_added_to_hass(self): """Run when this Entity has been added to HA.""" await super().async_added_to_hass() # Sensors should also register callbacks to HA when their state changes self.coordinator.musiccast.register_callback(self.async_write_ha_state) + self.coordinator.musiccast.register_group_update_callback( + self.update_all_mc_entities + ) async def async_will_remove_from_hass(self): """Entity being removed from hass.""" @@ -164,6 +175,16 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): """Push an update after each command.""" return False + @property + def ip_address(self): + """Return the ip address of the musiccast device.""" + return self.coordinator.musiccast.ip + + @property + def zone_id(self): + """Return the zone id of the musiccast device.""" + return self._zone_id + @property def _is_netusb(self): return ( @@ -288,6 +309,8 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): @property def media_image_url(self): """Return the image url of current playing media.""" + if self.is_client and self.group_server != self: + return self.group_server.coordinator.musiccast.media_image_url return self.coordinator.musiccast.media_image_url if self._is_netusb else None @property @@ -408,3 +431,364 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): return self.coordinator.data.netusb_play_time_updated return None + + # Group and MusicCast System specific functions/properties + + @property + def is_network_server(self) -> bool: + """Return only true if the current entity is a network server and not a main zone with an attached zone2.""" + return ( + self.coordinator.data.group_role == "server" + and self.coordinator.data.group_id != NULL_GROUP + and self._zone_id == self.coordinator.data.group_server_zone + ) + + @property + def other_zones(self) -> list[MusicCastMediaPlayer]: + """Return media player entities of the other zones of this device.""" + return [ + entity + for entity in self.coordinator.entities + if entity != self and isinstance(entity, MusicCastMediaPlayer) + ] + + @property + def is_server(self) -> bool: + """Return whether the media player is the server/host of the group. + + If the media player is not part of a group, False is returned. + """ + return self.is_network_server or ( + self._zone_id == DEFAULT_ZONE + and len( + [ + entity + for entity in self.other_zones + if entity.source == ATTR_MAIN_SYNC + ] + ) + > 0 + ) + + @property + def is_network_client(self) -> bool: + """Return True if the current entity is a network client and not just a main syncing entity.""" + return ( + self.coordinator.data.group_role == "client" + and self.coordinator.data.group_id != NULL_GROUP + and self.source == ATTR_MC_LINK + ) + + @property + def is_client(self) -> bool: + """Return whether the media player is the client of a group. + + If the media player is not part of a group, False is returned. + """ + return self.is_network_client or self.source == ATTR_MAIN_SYNC + + def get_all_mc_entities(self) -> list[MusicCastMediaPlayer]: + """Return all media player entities of the musiccast system.""" + entities = [] + for coordinator in self.hass.data[DOMAIN].values(): + entities += [ + entity + for entity in coordinator.entities + if isinstance(entity, MusicCastMediaPlayer) + ] + return entities + + def get_all_server_entities(self) -> list[MusicCastMediaPlayer]: + """Return all media player entities in the musiccast system, which are in server mode.""" + entities = self.get_all_mc_entities() + return [entity for entity in entities if entity.is_server] + + def get_distribution_num(self) -> int: + """Return the distribution_num (number of clients in the whole musiccast system).""" + return sum( + [ + len(server.coordinator.data.group_client_list) + for server in self.get_all_server_entities() + ] + ) + + def is_part_of_group(self, group_server) -> bool: + """Return True if the given server is the server of self's group.""" + return group_server != self and ( + ( + self.ip_address in group_server.coordinator.data.group_client_list + and self.coordinator.data.group_id + == group_server.coordinator.data.group_id + and self.ip_address != group_server.ip_address + and self.source == ATTR_MC_LINK + ) + or ( + self.ip_address == group_server.ip_address + and self.source == ATTR_MAIN_SYNC + ) + ) + + @property + def group_server(self): + """Return the server of the own group if present, self else.""" + for entity in self.get_all_server_entities(): + if self.is_part_of_group(entity): + return entity + return self + + @property + def group_members(self) -> list[str] | None: + """Return a list of entity_ids, which belong to the group of self.""" + return [entity.entity_id for entity in self.musiccast_group] + + @property + def musiccast_group(self) -> list[MusicCastMediaPlayer]: + """Return all media players of the current group, if the media player is server.""" + if self.is_client: + # If we are a client we can still share group information, but we will take them from the server. + server = self.group_server + if server != self: + return server.musiccast_group + + return [self] + if not self.is_server: + return [self] + entities = self.get_all_mc_entities() + clients = [entity for entity in entities if entity.is_part_of_group(self)] + return [self] + clients + + @property + def musiccast_zone_entity(self) -> MusicCastMediaPlayer: + """Return the the entity of the zone, which is using MusicCast at the moment, if there is one, self else. + + It is possible that multiple zones use MusicCast as client at the same time. In this case the first one is + returned. + """ + for entity in self.other_zones: + if entity.is_network_server or entity.is_network_client: + return entity + + return self + + async def update_all_mc_entities(self): + """Update the whole musiccast system when group data change.""" + for entity in self.get_all_mc_entities(): + if entity.is_server: + await entity.async_check_client_list() + entity.async_write_ha_state() + + # Services + + async def async_join_players(self, group_members): + """Add all clients given in entities to the group of the server. + + Creates a new group if necessary. Used for join service. + """ + _LOGGER.info( + "%s wants to add the following entities %s", + self.entity_id, + str(group_members), + ) + + entities = [ + entity + for entity in self.get_all_mc_entities() + if entity.entity_id in group_members + ] + + if not self.is_server and self.musiccast_zone_entity.is_server: + # The MusicCast Distribution Module of this device is already in use. To use it as a server, we first + # have to unjoin and wait until the servers are updated. + await self.musiccast_zone_entity.async_server_close_group() + elif self.musiccast_zone_entity.is_client: + await self.async_client_leave_group(True) + # Use existing group id if we are server, generate a new one else. + group = ( + self.coordinator.data.group_id + if self.is_server + else uuid.random_uuid_hex().upper() + ) + # First let the clients join + for client in entities: + if client != self: + try: + await client.async_client_join(group, self) + except MusicCastGroupException: + _LOGGER.warning( + "%s is struggling to update its group data. Will retry perform the update", + client.entity_id, + ) + await client.async_client_join(group, self) + + await self.coordinator.musiccast.mc_server_group_extend( + self._zone_id, + [ + entity.ip_address + for entity in entities + if entity.ip_address != self.ip_address + ], + group, + self.get_distribution_num(), + ) + _LOGGER.debug( + "%s added the following entities %s", self.entity_id, str(entities) + ) + _LOGGER.info( + "%s has now the following musiccast group %s", + self.entity_id, + str(self.musiccast_group), + ) + + await self.update_all_mc_entities() + + async def async_unjoin_player(self): + """Leave the group. + + Stops the distribution if device is server. Used for unjoin service. + """ + _LOGGER.debug("%s called service unjoin", self.entity_id) + if self.is_server: + await self.async_server_close_group() + + else: + await self.async_client_leave_group() + + await self.update_all_mc_entities() + + # Internal client functions + + async def async_client_join(self, group_id, server): + """Let the client join a group. + + If this client is a server, the server will stop distributing. If the client is part of a different group, + it will leave that group first. + """ + # If we should join the group, which is served by the main zone, we can simply select main_sync as input. + _LOGGER.debug("%s called service client join", self.entity_id) + if self.state == STATE_OFF: + await self.async_turn_on() + if self.ip_address == server.ip_address: + if server.zone == DEFAULT_ZONE: + await self.async_select_source(ATTR_MAIN_SYNC) + server.async_write_ha_state() + return + + # It is not possible to join a group hosted by zone2 from main zone. + raise Exception("Can not join a zone other than main of the same device.") + + if self.musiccast_zone_entity.is_server: + # If one of the zones of the device is a server, we need to unjoin first. + _LOGGER.info( + "%s is a server of a group and has to stop distribution " + "to use MusicCast for %s", + self.musiccast_zone_entity.entity_id, + self.entity_id, + ) + await self.musiccast_zone_entity.async_server_close_group() + + elif self.is_client: + if self.coordinator.data.group_id == server.coordinator.data.group_id: + _LOGGER.warning("%s is already part of the group", self.entity_id) + return + + _LOGGER.info( + "%s is client in a different group, will unjoin first", + self.entity_id, + ) + await self.async_client_leave_group() + + elif ( + self.ip_address in server.coordinator.data.group_client_list + and self.coordinator.data.group_id == server.coordinator.data.group_id + and self.coordinator.data.group_role == "client" + ): + # The device is already part of this group (e.g. main zone is also a client of this group). + # Just select mc_link as source + await self.async_select_source(ATTR_MC_LINK) + # As the musiccast group has changed, we need to trigger the servers ha state. + # In other cases this happens due to the callback after the dist updated message. + server.async_write_ha_state() + return + + _LOGGER.debug("%s will now join as a client", self.entity_id) + await self.coordinator.musiccast.mc_client_join( + server.ip_address, group_id, self._zone_id + ) + + # Ensure that mc link is selected. If main sync was selected previously, it's possible that this does not + # happen automatically + await self.async_select_source(ATTR_MC_LINK) + + async def async_client_leave_group(self, force=False): + """Make self leave the group. + + Should only be called for clients. + """ + _LOGGER.debug("%s client leave called", self.entity_id) + if not force and ( + self.source == ATTR_MAIN_SYNC + or len( + [entity for entity in self.other_zones if entity.source == ATTR_MC_LINK] + ) + > 0 + ): + # If we are only syncing to main or another zone is also using the musiccast module as client, don't + # kill the client session, just select a dummy source. + save_inputs = self.coordinator.musiccast.get_save_inputs(self._zone_id) + if len(save_inputs): + await self.async_select_source(save_inputs[0]) + # Then turn off the zone + await self.async_turn_off() + else: + servers = [ + server + for server in self.get_all_server_entities() + if server.coordinator.data.group_id == self.coordinator.data.group_id + ] + await self.coordinator.musiccast.mc_client_unjoin() + if len(servers): + await servers[0].coordinator.musiccast.mc_server_group_reduce( + servers[0].zone_id, [self.ip_address], self.get_distribution_num() + ) + + for server in self.get_all_server_entities(): + await server.async_check_client_list() + + # Internal server functions + + async def async_server_close_group(self): + """Close group of self. + + Should only be called for servers. + """ + _LOGGER.info("%s closes his group", self.entity_id) + for client in self.musiccast_group: + if client != self: + await client.async_client_leave_group() + await self.coordinator.musiccast.mc_server_group_close() + + async def async_check_client_list(self): + """Let the server check if all its clients are still part of his group.""" + _LOGGER.debug("%s updates his group members", self.entity_id) + client_ips_for_removal = [] + for expected_client_ip in self.coordinator.data.group_client_list: + if expected_client_ip not in [ + entity.ip_address for entity in self.musiccast_group + ]: + # The client is no longer part of the group. Prepare removal. + client_ips_for_removal.append(expected_client_ip) + + if len(client_ips_for_removal) > 0: + _LOGGER.info( + "%s says good bye to the following members %s", + self.entity_id, + str(client_ips_for_removal), + ) + await self.coordinator.musiccast.mc_server_group_reduce( + self._zone_id, client_ips_for_removal, self.get_distribution_num() + ) + if len(self.musiccast_group) < 2: + # The group is empty, stop distribution. + await self.async_server_close_group() + + self.async_write_ha_state()