diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 610292f0389..2dbab5e9409 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -11,9 +11,13 @@ import voluptuous as vol from homeassistant.components.media_player.const import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle @@ -23,8 +27,11 @@ from .const import ( COMMAND_RETRY_ATTEMPTS, COMMAND_RETRY_DELAY, DATA_CONTROLLER_MANAGER, + DATA_ENTITY_ID_MAP, + DATA_GROUP_MANAGER, DATA_SOURCE_MANAGER, DOMAIN, + SIGNAL_HEOS_PLAYER_ADDED, SIGNAL_HEOS_UPDATED, ) @@ -117,15 +124,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: source_manager = SourceManager(favorites, inputs) source_manager.connect_update(hass, controller) + group_manager = GroupManager(hass, controller) + hass.data[DOMAIN] = { DATA_CONTROLLER_MANAGER: controller_manager, + DATA_GROUP_MANAGER: group_manager, DATA_SOURCE_MANAGER: source_manager, MEDIA_PLAYER_DOMAIN: players, + # Maps player_id to entity_id. Populated by the individual HeosMediaPlayer entities. + DATA_ENTITY_ID_MAP: {}, } services.register(hass, controller) hass.config_entries.async_setup_platforms(entry, PLATFORMS) + group_manager.connect_update() + entry.async_on_unload(group_manager.disconnect_update) return True @@ -184,7 +198,7 @@ class ControllerManager: if event == heos_const.EVENT_PLAYERS_CHANGED: self.update_ids(data[heos_const.DATA_MAPPED_IDS]) # Update players - self._hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_HEOS_UPDATED) + async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) async def _heos_event(self, event): """Handle connection event.""" @@ -196,7 +210,7 @@ class ControllerManager: except HeosError as ex: _LOGGER.error("Unable to refresh players: %s", ex) # Update players - self._hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_HEOS_UPDATED) + async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) def update_ids(self, mapped_ids: dict[int, int]): """Update the IDs in the device and entity registry.""" @@ -223,6 +237,148 @@ class ControllerManager: _LOGGER.debug("Updated entity %s unique id to %s", entity_id, new_id) +class GroupManager: + """Class that manages HEOS groups.""" + + def __init__(self, hass, controller): + """Init group manager.""" + self._hass = hass + self._group_membership = {} + self._disconnect_player_added = None + self._initialized = False + self.controller = controller + + def _get_entity_id_to_player_id_map(self) -> dict: + """Return a dictionary which maps all HeosMediaPlayer entity_ids to player_ids.""" + return {v: k for k, v in self._hass.data[DOMAIN][DATA_ENTITY_ID_MAP].items()} + + async def async_get_group_membership(self): + """Return a dictionary which contains all group members for each player as entity_ids.""" + group_info_by_entity_id = { + player_entity_id: [] + for player_entity_id in self._get_entity_id_to_player_id_map() + } + + try: + groups = await self.controller.get_groups(refresh=True) + except HeosError as err: + _LOGGER.error("Unable to get HEOS group info: %s", err) + return group_info_by_entity_id + + player_id_to_entity_id_map = self._hass.data[DOMAIN][DATA_ENTITY_ID_MAP] + for group in groups.values(): + leader_entity_id = player_id_to_entity_id_map.get(group.leader.player_id) + member_entity_ids = [ + player_id_to_entity_id_map[member.player_id] + for member in group.members + if member.player_id in player_id_to_entity_id_map + ] + # Make sure the group leader is always the first element + group_info = [leader_entity_id, *member_entity_ids] + if leader_entity_id: + group_info_by_entity_id[leader_entity_id] = group_info + for member_entity_id in member_entity_ids: + group_info_by_entity_id[member_entity_id] = group_info + + return group_info_by_entity_id + + async def async_join_players( + self, leader_entity_id: str, member_entity_ids: list[str] + ) -> None: + """Create a group with `leader_entity_id` as group leader and `member_entity_ids` as member players.""" + entity_id_to_player_id_map = self._get_entity_id_to_player_id_map() + leader_id = entity_id_to_player_id_map.get(leader_entity_id) + if not leader_id: + raise HomeAssistantError( + f"The group leader {leader_entity_id} could not be resolved to a HEOS player." + ) + member_ids = [ + entity_id_to_player_id_map[member] + for member in member_entity_ids + if member in entity_id_to_player_id_map + ] + + try: + await self.controller.create_group(leader_id, member_ids) + except HeosError as err: + _LOGGER.error( + "Failed to group %s with %s: %s", + leader_entity_id, + member_entity_ids, + err, + ) + + async def async_unjoin_player(self, player_entity_id: str): + """Remove `player_entity_id` from any group.""" + player_id = self._get_entity_id_to_player_id_map().get(player_entity_id) + if not player_id: + raise HomeAssistantError( + f"The player {player_entity_id} could not be resolved to a HEOS player." + ) + + try: + await self.controller.create_group(player_id, []) + except HeosError as err: + _LOGGER.error( + "Failed to ungroup %s: %s", + player_entity_id, + err, + ) + + async def async_update_groups(self, event, data=None): + """Update the group membership from the controller.""" + if event in ( + heos_const.EVENT_GROUPS_CHANGED, + heos_const.EVENT_CONNECTED, + SIGNAL_HEOS_PLAYER_ADDED, + ): + groups = await self.async_get_group_membership() + if groups: + self._group_membership = groups + _LOGGER.debug("Groups updated due to change event") + # Let players know to update + async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) + else: + _LOGGER.debug("Groups empty") + + def connect_update(self): + """Connect listener for when groups change and signal player update.""" + self.controller.dispatcher.connect( + heos_const.SIGNAL_CONTROLLER_EVENT, self.async_update_groups + ) + self.controller.dispatcher.connect( + heos_const.SIGNAL_HEOS_EVENT, self.async_update_groups + ) + + # When adding a new HEOS player we need to update the groups. + async def _async_handle_player_added(): + # Avoid calling async_update_groups when `DATA_ENTITY_ID_MAP` has not been + # fully populated yet. This may only happen during early startup. + if ( + len(self._hass.data[DOMAIN][MEDIA_PLAYER_DOMAIN]) + <= len(self._hass.data[DOMAIN][DATA_ENTITY_ID_MAP]) + and not self._initialized + ): + self._initialized = True + await self.async_update_groups(SIGNAL_HEOS_PLAYER_ADDED) + + self._disconnect_player_added = async_dispatcher_connect( + self._hass, SIGNAL_HEOS_PLAYER_ADDED, _async_handle_player_added + ) + + @callback + def disconnect_update(self): + """Disconnect the listeners.""" + if self._disconnect_player_added: + self._disconnect_player_added() + self._disconnect_player_added = None + + @property + def group_membership(self): + """Provide access to group members for player entities.""" + return self._group_membership + + class SourceManager: """Class that manages sources for players.""" @@ -341,7 +497,7 @@ class SourceManager: self.source_list = self._build_source_list() _LOGGER.debug("Sources updated due to changed event") # Let players know to update - hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_HEOS_UPDATED) + async_dispatcher_send(hass, SIGNAL_HEOS_UPDATED) controller.dispatcher.connect( heos_const.SIGNAL_CONTROLLER_EVENT, update_sources diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py index 503df40ccd4..636751d150b 100644 --- a/homeassistant/components/heos/const.py +++ b/homeassistant/components/heos/const.py @@ -5,9 +5,12 @@ ATTR_USERNAME = "username" COMMAND_RETRY_ATTEMPTS = 2 COMMAND_RETRY_DELAY = 1 DATA_CONTROLLER_MANAGER = "controller" +DATA_ENTITY_ID_MAP = "entity_id_map" +DATA_GROUP_MANAGER = "group_manager" DATA_SOURCE_MANAGER = "source_manager" DATA_DISCOVERED_HOSTS = "heos_discovered_hosts" DOMAIN = "heos" SERVICE_SIGN_IN = "sign_in" SERVICE_SIGN_OUT = "sign_out" +SIGNAL_HEOS_PLAYER_ADDED = "heos_player_added" SIGNAL_HEOS_UPDATED = "heos_updated" diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index a562d8e3d7a..27d172198e4 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -15,6 +15,7 @@ from homeassistant.components.media_player.const import ( MEDIA_TYPE_PLAYLIST, MEDIA_TYPE_URL, SUPPORT_CLEAR_PLAYLIST, + SUPPORT_GROUPING, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -30,10 +31,21 @@ from homeassistant.components.media_player.const import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, STATE_PAUSED, STATE_PLAYING from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity import DeviceInfo from homeassistant.util.dt import utcnow -from .const import DATA_SOURCE_MANAGER, DOMAIN as HEOS_DOMAIN, SIGNAL_HEOS_UPDATED +from .const import ( + DATA_ENTITY_ID_MAP, + DATA_GROUP_MANAGER, + DATA_SOURCE_MANAGER, + DOMAIN as HEOS_DOMAIN, + SIGNAL_HEOS_PLAYER_ADDED, + SIGNAL_HEOS_UPDATED, +) BASE_SUPPORTED_FEATURES = ( SUPPORT_VOLUME_MUTE @@ -43,6 +55,7 @@ BASE_SUPPORTED_FEATURES = ( | SUPPORT_SHUFFLE_SET | SUPPORT_SELECT_SOURCE | SUPPORT_PLAY_MEDIA + | SUPPORT_GROUPING ) PLAY_STATE_TO_STATE = { @@ -97,6 +110,7 @@ class HeosMediaPlayer(MediaPlayerEntity): self._signals = [] self._supported_features = BASE_SUPPORTED_FEATURES self._source_manager = None + self._group_manager = None async def _player_update(self, player_id, event): """Handle player attribute updated.""" @@ -120,16 +134,24 @@ class HeosMediaPlayer(MediaPlayerEntity): ) # Update state when heos changes self._signals.append( - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_HEOS_UPDATED, self._heos_updated - ) + async_dispatcher_connect(self.hass, SIGNAL_HEOS_UPDATED, self._heos_updated) ) + # Register this player's entity_id so it can be resolved by the group manager + self.hass.data[HEOS_DOMAIN][DATA_ENTITY_ID_MAP][ + self._player.player_id + ] = self.entity_id + async_dispatcher_send(self.hass, SIGNAL_HEOS_PLAYER_ADDED) @log_command_error("clear playlist") async def async_clear_playlist(self): """Clear players playlist.""" await self._player.clear_queue() + @log_command_error("join_players") + async def async_join_players(self, group_members: list[str]) -> None: + """Join `group_members` as a player group with the current player.""" + await self._group_manager.async_join_players(self.entity_id, group_members) + @log_command_error("pause") async def async_media_pause(self): """Send pause command.""" @@ -238,9 +260,17 @@ class HeosMediaPlayer(MediaPlayerEntity): current_support = [CONTROL_TO_SUPPORT[control] for control in controls] self._supported_features = reduce(ior, current_support, BASE_SUPPORTED_FEATURES) + if self._group_manager is None: + self._group_manager = self.hass.data[HEOS_DOMAIN][DATA_GROUP_MANAGER] + if self._source_manager is None: self._source_manager = self.hass.data[HEOS_DOMAIN][DATA_SOURCE_MANAGER] + @log_command_error("unjoin_player") + async def async_unjoin_player(self): + """Remove this player from any group.""" + await self._group_manager.async_unjoin_player(self.entity_id) + async def async_will_remove_from_hass(self): """Disconnect the device when removed.""" for signal_remove in self._signals: @@ -274,6 +304,11 @@ class HeosMediaPlayer(MediaPlayerEntity): "media_type": self._player.now_playing_media.type, } + @property + def group_members(self) -> list[str]: + """List of players which are grouped together.""" + return self._group_manager.group_membership.get(self.entity_id, []) + @property def is_volume_muted(self) -> bool: """Boolean if volume is currently muted.""" diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 2c48b7fe8e1..693e665d1ee 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -4,7 +4,15 @@ from __future__ import annotations from typing import Sequence from unittest.mock import Mock, patch as patch -from pyheos import Dispatcher, Heos, HeosPlayer, HeosSource, InputSource, const +from pyheos import ( + Dispatcher, + Heos, + HeosGroup, + HeosPlayer, + HeosSource, + InputSource, + const, +) import pytest from homeassistant.components import ssdp @@ -24,7 +32,7 @@ def config_entry_fixture(): @pytest.fixture(name="controller") def controller_fixture( - players, favorites, input_sources, playlists, change_data, dispatcher + players, favorites, input_sources, playlists, change_data, dispatcher, group ): """Create a mock Heos controller fixture.""" mock_heos = Mock(Heos) @@ -40,6 +48,8 @@ def controller_fixture( mock_heos.is_signed_in = True mock_heos.signed_in_username = "user@user.com" mock_heos.connection_state = const.STATE_CONNECTED + mock_heos.get_groups.return_value = group + mock_heos.create_group.return_value = None mock = Mock(return_value=mock_heos) with patch("homeassistant.components.heos.Heos", new=mock), patch( @@ -56,35 +66,51 @@ def config_fixture(): @pytest.fixture(name="players") def player_fixture(quick_selects): - """Create a mock HeosPlayer.""" - player = Mock(HeosPlayer) - player.player_id = 1 - player.name = "Test Player" - player.model = "Test Model" - player.version = "1.0.0" - player.is_muted = False - player.available = True - player.state = const.PLAY_STATE_STOP - player.ip_address = "127.0.0.1" - player.network = "wired" - player.shuffle = False - player.repeat = const.REPEAT_OFF - player.volume = 25 - player.now_playing_media.supported_controls = const.CONTROLS_ALL - player.now_playing_media.album_id = 1 - player.now_playing_media.queue_id = 1 - player.now_playing_media.source_id = 1 - player.now_playing_media.station = "Station Name" - player.now_playing_media.type = "Station" - player.now_playing_media.album = "Album" - player.now_playing_media.artist = "Artist" - player.now_playing_media.media_id = "1" - player.now_playing_media.duration = None - player.now_playing_media.current_position = None - player.now_playing_media.image_url = "http://" - player.now_playing_media.song = "Song" - player.get_quick_selects.return_value = quick_selects - return {player.player_id: player} + """Create two mock HeosPlayers.""" + players = {} + for i in (1, 2): + player = Mock(HeosPlayer) + player.player_id = i + if i > 1: + player.name = f"Test Player {i}" + else: + player.name = "Test Player" + player.model = "Test Model" + player.version = "1.0.0" + player.is_muted = False + player.available = True + player.state = const.PLAY_STATE_STOP + player.ip_address = f"127.0.0.{i}" + player.network = "wired" + player.shuffle = False + player.repeat = const.REPEAT_OFF + player.volume = 25 + player.now_playing_media.supported_controls = const.CONTROLS_ALL + player.now_playing_media.album_id = 1 + player.now_playing_media.queue_id = 1 + player.now_playing_media.source_id = 1 + player.now_playing_media.station = "Station Name" + player.now_playing_media.type = "Station" + player.now_playing_media.album = "Album" + player.now_playing_media.artist = "Artist" + player.now_playing_media.media_id = "1" + player.now_playing_media.duration = None + player.now_playing_media.current_position = None + player.now_playing_media.image_url = "http://" + player.now_playing_media.song = "Song" + player.get_quick_selects.return_value = quick_selects + players[player.player_id] = player + return players + + +@pytest.fixture(name="group") +def group_fixture(players): + """Create a HEOS group consisting of two players.""" + group = Mock(HeosGroup) + group.leader = players[1] + group.members = [players[2]] + group.group_id = 999 + return {group.group_id: group} @pytest.fixture(name="favorites") diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 8b87acfd9fd..d134840d652 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -2,6 +2,7 @@ import asyncio from pyheos import CommandFailedError, const +from pyheos.error import HeosError from homeassistant.components.heos import media_player from homeassistant.components.heos.const import ( @@ -10,6 +11,7 @@ from homeassistant.components.heos.const import ( SIGNAL_HEOS_UPDATED, ) from homeassistant.components.media_player.const import ( + ATTR_GROUP_MEMBERS, ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, ATTR_MEDIA_ALBUM_NAME, @@ -29,8 +31,10 @@ from homeassistant.components.media_player.const import ( MEDIA_TYPE_PLAYLIST, MEDIA_TYPE_URL, SERVICE_CLEAR_PLAYLIST, + SERVICE_JOIN, SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, + SERVICE_UNJOIN, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -264,10 +268,10 @@ async def test_updates_from_players_changed_new_ids( await event.wait() # Assert device registry identifiers were updated - assert len(device_registry.devices) == 1 + assert len(device_registry.devices) == 2 assert device_registry.async_get_device({(DOMAIN, 101)}) # Assert entity registry unique id was updated - assert len(entity_registry.entities) == 1 + assert len(entity_registry.entities) == 2 assert ( entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "101") == "media_player.test_player" @@ -805,3 +809,99 @@ async def test_play_media_invalid_type(hass, config_entry, config, controller, c blocking=True, ) assert "Unable to play media: Unsupported media type 'Other'" in caplog.text + + +async def test_media_player_join_group(hass, config_entry, config, controller, caplog): + """Test grouping of media players through the join service.""" + await setup_platform(hass, config_entry, config) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_GROUP_MEMBERS: ["media_player.test_player_2"], + }, + blocking=True, + ) + controller.create_group.assert_called_once_with( + 1, + [ + 2, + ], + ) + assert "Failed to group media_player.test_player with" not in caplog.text + + controller.create_group.side_effect = HeosError("error") + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_GROUP_MEMBERS: ["media_player.test_player_2"], + }, + blocking=True, + ) + assert "Failed to group media_player.test_player with" in caplog.text + + +async def test_media_player_group_members( + hass, config_entry, config, controller, caplog +): + """Test group_members attribute.""" + await setup_platform(hass, config_entry, config) + await hass.async_block_till_done() + player_entity = hass.states.get("media_player.test_player") + assert player_entity.attributes[ATTR_GROUP_MEMBERS] == [ + "media_player.test_player", + "media_player.test_player_2", + ] + controller.get_groups.assert_called_once() + assert "Unable to get HEOS group info" not in caplog.text + + +async def test_media_player_group_members_error( + hass, config_entry, config, controller, caplog +): + """Test error in HEOS API.""" + controller.get_groups.side_effect = HeosError("error") + await setup_platform(hass, config_entry, config) + await hass.async_block_till_done() + assert "Unable to get HEOS group info" in caplog.text + player_entity = hass.states.get("media_player.test_player") + assert player_entity.attributes[ATTR_GROUP_MEMBERS] == [] + + +async def test_media_player_unjoin_group( + hass, config_entry, config, controller, caplog +): + """Test ungrouping of media players through the join service.""" + await setup_platform(hass, config_entry, config) + player = controller.players[1] + + player.heos.dispatcher.send( + const.SIGNAL_PLAYER_EVENT, + player.player_id, + const.EVENT_PLAYER_STATE_CHANGED, + ) + await hass.async_block_till_done() + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_UNJOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + }, + blocking=True, + ) + controller.create_group.assert_called_once_with(1, []) + assert "Failed to ungroup media_player.test_player" not in caplog.text + + controller.create_group.side_effect = HeosError("error") + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_UNJOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + }, + blocking=True, + ) + assert "Failed to ungroup media_player.test_player" in caplog.text