diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 2830e70b3af..10fd2bfcff3 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -2,27 +2,17 @@ from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta -import logging -from typing import Any - -from pyheos import Heos, HeosError, HeosPlayer, const as heos_const from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr 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 . import services -from .const import DOMAIN, SIGNAL_HEOS_PLAYER_ADDED, SIGNAL_HEOS_UPDATED +from .const import DOMAIN from .coordinator import HeosCoordinator PLATFORMS = [Platform.MEDIA_PLAYER] @@ -31,19 +21,7 @@ MIN_UPDATE_SOURCES = timedelta(seconds=1) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -_LOGGER = logging.getLogger(__name__) - - -@dataclass -class HeosRuntimeData: - """Runtime data and coordinators for HEOS config entries.""" - - coordinator: HeosCoordinator - group_manager: GroupManager - players: dict[int, HeosPlayer] - - -type HeosConfigEntry = ConfigEntry[HeosRuntimeData] +type HeosConfigEntry = ConfigEntry[HeosCoordinator] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -72,16 +50,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool coordinator = HeosCoordinator(hass, entry) await coordinator.async_setup() - # Preserve existing logic until migrated into coordinator - controller = coordinator.heos - players = controller.players - - group_manager = GroupManager(hass, controller, players) - - entry.runtime_data = HeosRuntimeData(coordinator, group_manager, players) - - group_manager.connect_update() - entry.async_on_unload(group_manager.disconnect_update) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -91,130 +60,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool async def async_unload_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -class GroupManager: - """Class that manages HEOS groups.""" - - def __init__( - self, hass: HomeAssistant, controller: Heos, players: dict[int, HeosPlayer] - ) -> None: - """Init group manager.""" - self._hass = hass - self._group_membership: dict[str, list[str]] = {} - self._disconnect_player_added = None - self._initialized = False - self.controller = controller - self.players = players - self.entity_id_map: dict[int, str] = {} - - def _get_entity_id_to_player_id_map(self) -> dict: - """Return mapping of all HeosMediaPlayer entity_ids to player_ids.""" - return {v: k for k, v in self.entity_id_map.items()} - - async def async_get_group_membership(self) -> dict[str, list[str]]: - """Return all group members for each player as entity_ids.""" - group_info_by_entity_id: dict[str, list[str]] = { - player_entity_id: [] - for player_entity_id in self._get_entity_id_to_player_id_map() - } - - try: - groups = await self.controller.get_groups() - 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.entity_id_map - for group in groups.values(): - leader_entity_id = player_id_to_entity_id_map.get(group.lead_player_id) - member_entity_ids = [ - player_id_to_entity_id_map[member] - for member in group.member_player_ids - if member 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 # type: ignore[assignment] - for member_entity_id in member_entity_ids: - group_info_by_entity_id[member_entity_id] = group_info # type: ignore[assignment] - - return group_info_by_entity_id - - async def async_join_players( - self, leader_id: int, member_entity_ids: list[str] - ) -> None: - """Create a group a group leader and member players.""" - # Resolve HEOS player_id for each member entity_id - entity_id_to_player_id_map = self._get_entity_id_to_player_id_map() - member_ids: list[int] = [] - for member in member_entity_ids: - member_id = entity_id_to_player_id_map.get(member) - if not member_id: - raise HomeAssistantError( - f"The group member {member} could not be resolved to a HEOS player." - ) - member_ids.append(member_id) - - await self.controller.create_group(leader_id, member_ids) - - async def async_unjoin_player(self, player_id: int): - """Remove `player_entity_id` from any group.""" - await self.controller.create_group(player_id, []) - - async def async_update_groups(self) -> None: - """Update the group membership from the controller.""" - if groups := await self.async_get_group_membership(): - 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") - - @callback - def connect_update(self): - """Connect listener for when groups change and signal player update.""" - - async def _on_controller_event(event: str, data: Any | None) -> None: - if event == heos_const.EVENT_GROUPS_CHANGED: - await self.async_update_groups() - - self.controller.add_on_controller_event(_on_controller_event) - self.controller.add_on_connected(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 the entity_id map has not been - # fully populated yet. This may only happen during early startup. - if len(self.players) <= len(self.entity_id_map) and not self._initialized: - self._initialized = True - await self.async_update_groups() - - 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 - - @callback - def register_media_player(self, player_id: int, entity_id: str) -> CALLBACK_TYPE: - """Register a media player player_id with it's entity_id so it can be resolved later.""" - self.entity_id_map[player_id] = entity_id - return lambda: self.unregister_media_player(player_id) - - @callback - def unregister_media_player(self, player_id) -> None: - """Remove a media player player_id from the entity_id map.""" - self.entity_id_map.pop(player_id, None) - - @property - def group_membership(self): - """Provide access to group members for player entities.""" - return self._group_membership diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index 335b64977b8..18b8f1f7918 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -188,9 +188,7 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): entry: HeosConfigEntry = self._get_reauth_entry() if user_input is not None: assert entry.state is ConfigEntryState.LOADED - if await _validate_auth( - user_input, entry.runtime_data.coordinator.heos, errors - ): + if await _validate_auth(user_input, entry.runtime_data.heos, errors): return self.async_update_reload_and_abort(entry, options=user_input) return self.async_show_form( @@ -212,9 +210,7 @@ class HeosOptionsFlowHandler(OptionsFlow): errors: dict[str, str] = {} if user_input is not None: entry: HeosConfigEntry = self.config_entry - if await _validate_auth( - user_input, entry.runtime_data.coordinator.heos, errors - ): + if await _validate_auth(user_input, entry.runtime_data.heos, errors): return self.async_create_entry(data=user_input) return self.async_show_form( diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py index 9573306905f..7f03fa11e79 100644 --- a/homeassistant/components/heos/const.py +++ b/homeassistant/components/heos/const.py @@ -5,5 +5,3 @@ ATTR_USERNAME = "username" 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/coordinator.py b/homeassistant/components/heos/coordinator.py index c3c645ea1fa..8ed8449685a 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -5,6 +5,7 @@ The coordinator is responsible for refreshing data in response to system-wide ev entities to update. Entities subscribe to entity-specific updates within the entity class itself. """ +from collections.abc import Callable from datetime import datetime, timedelta import logging @@ -81,6 +82,7 @@ class HeosCoordinator(DataUpdateCoordinator[None]): "The HEOS System is not logged in: Enter credentials in the integration options to access favorites and streaming services" ) # Retrieve initial data + await self._async_update_groups() await self._async_update_sources() # Attach event callbacks self.heos.add_on_disconnected(self._async_on_disconnected) @@ -93,6 +95,13 @@ class HeosCoordinator(DataUpdateCoordinator[None]): await self.heos.disconnect() await super().async_shutdown() + def async_add_listener(self, update_callback, context=None) -> Callable[[], None]: + """Add a listener for the coordinator.""" + remove_listener = super().async_add_listener(update_callback, context) + # Update entities so group_member entity_ids fully populate. + self.async_update_listeners() + return remove_listener + async def _async_on_auth_failure(self) -> None: """Handle when the user credentials are no longer valid.""" assert self.config_entry is not None @@ -118,6 +127,8 @@ class HeosCoordinator(DataUpdateCoordinator[None]): assert data is not None if data.updated_player_ids: self._async_update_player_ids(data.updated_player_ids) + elif event == const.EVENT_GROUPS_CHANGED: + await self._async_update_players() elif ( event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED) and not self._update_sources_pending @@ -176,6 +187,13 @@ class HeosCoordinator(DataUpdateCoordinator[None]): ) _LOGGER.debug("Updated entity %s unique id to %s", entity_id, new_id) + async def _async_update_groups(self) -> None: + """Update group information.""" + try: + await self.heos.get_groups(refresh=True) + except HeosError as error: + _LOGGER.error("Unable to retrieve groups: %s", error) + async def _async_update_sources(self) -> None: """Build source list for entities.""" self._source_list.clear() diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index e5ce39a1773..d405b235f76 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -29,19 +29,17 @@ from homeassistant.components.media_player import ( RepeatMode, async_process_play_media_url, ) +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow -from . import GroupManager, HeosConfigEntry -from .const import DOMAIN as HEOS_DOMAIN, SIGNAL_HEOS_PLAYER_ADDED, SIGNAL_HEOS_UPDATED +from . import HeosConfigEntry +from .const import DOMAIN as HEOS_DOMAIN from .coordinator import HeosCoordinator PARALLEL_UPDATES = 0 @@ -92,14 +90,9 @@ async def async_setup_entry( hass: HomeAssistant, entry: HeosConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Add media players for a config entry.""" - players = entry.runtime_data.players devices = [ - HeosMediaPlayer( - entry.runtime_data.coordinator, - player, - entry.runtime_data.group_manager, - ) - for player in players.values() + HeosMediaPlayer(entry.runtime_data, player) + for player in entry.runtime_data.heos.players.values() ] async_add_entities(devices) @@ -139,16 +132,10 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): _attr_has_entity_name = True _attr_name = None - def __init__( - self, - coordinator: HeosCoordinator, - player: HeosPlayer, - group_manager: GroupManager, - ) -> None: + def __init__(self, coordinator: HeosCoordinator, player: HeosPlayer) -> None: """Initialize.""" self._media_position_updated_at = None self._player: HeosPlayer = player - self._group_manager = group_manager self._attr_unique_id = str(player.player_id) model_parts = player.model.split(maxsplit=1) manufacturer = model_parts[0] if len(model_parts) == 2 else "HEOS" @@ -162,7 +149,6 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): sw_version=player.version, ) super().__init__(coordinator, context=player.player_id) - self._update_attributes() async def _player_update(self, event): """Handle player attribute updated.""" @@ -176,8 +162,31 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): self._update_attributes() super()._handle_coordinator_update() + @callback + def _get_group_members(self) -> list[str] | None: + """Get group member entity IDs for the group.""" + if self._player.group_id is None: + return None + if not (group := self.coordinator.heos.groups.get(self._player.group_id)): + return None + player_ids = [group.lead_player_id, *group.member_player_ids] + # Resolve player_ids to entity_ids + entity_registry = er.async_get(self.hass) + entity_ids = [ + entity_id + for member_id in player_ids + if ( + entity_id := entity_registry.async_get_entity_id( + Platform.MEDIA_PLAYER, HEOS_DOMAIN, str(member_id) + ) + ) + ] + return entity_ids or None + + @callback def _update_attributes(self) -> None: """Update core attributes of the media player.""" + self._attr_group_members = self._get_group_members() self._attr_source_list = self.coordinator.async_get_source_list() self._attr_source = self.coordinator.async_get_current_source( self._player.now_playing_media @@ -197,20 +206,8 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): async def async_added_to_hass(self) -> None: """Device added to hass.""" # Update state when attributes of the player change + self._update_attributes() self.async_on_remove(self._player.add_on_player_event(self._player_update)) - # Update state when heos changes - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_HEOS_UPDATED, self._handle_coordinator_update - ) - ) - # Register this player's entity_id so it can be resolved by the group manager - self.async_on_remove( - self._group_manager.register_media_player( - self._player.player_id, self.entity_id - ) - ) - async_dispatcher_send(self.hass, SIGNAL_HEOS_PLAYER_ADDED) await super().async_added_to_hass() @catch_action_error("clear playlist") @@ -218,13 +215,6 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): """Clear players playlist.""" await self._player.clear_queue() - @catch_action_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._player.player_id, group_members - ) - @catch_action_error("pause") async def async_media_pause(self) -> None: """Send pause command.""" @@ -335,10 +325,45 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): """Set volume level, range 0..1.""" await self._player.set_volume(int(volume * 100)) + @catch_action_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.""" + player_ids: list[int] = [self._player.player_id] + # Resolve entity_ids to player_ids + entity_registry = er.async_get(self.hass) + for entity_id in group_members: + entity_entry = entity_registry.async_get(entity_id) + if entity_entry is None: + raise ServiceValidationError( + translation_domain=HEOS_DOMAIN, + translation_key="entity_not_found", + translation_placeholders={"entity_id": entity_id}, + ) + if entity_entry.platform != HEOS_DOMAIN: + raise ServiceValidationError( + translation_domain=HEOS_DOMAIN, + translation_key="not_heos_media_player", + translation_placeholders={"entity_id": entity_id}, + ) + player_id = int(entity_entry.unique_id) + if player_id not in player_ids: + player_ids.append(player_id) + await self.coordinator.heos.set_group(player_ids) + @catch_action_error("unjoin player") async def async_unjoin_player(self) -> None: """Remove this player from any group.""" - await self._group_manager.async_unjoin_player(self._player.player_id) + for group in self.coordinator.heos.groups.values(): + if group.lead_player_id == self._player.player_id: + # Player is the group leader, this effectively removes the group. + await self.coordinator.heos.set_group([self._player.player_id]) + return + if self._player.player_id in group.member_player_ids: + # Player is a group member, update the group to exclude it + new_members = [group.lead_player_id, *group.member_player_ids] + new_members.remove(self._player.player_id) + await self.coordinator.heos.set_group(new_members) + return @property def available(self) -> bool: @@ -356,11 +381,6 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], 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/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index 2cd0ccaf567..d48bcc492cd 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -5,7 +5,7 @@ rules: status: done comment: Integration is a local push integration brands: done - common-modules: todo + common-modules: done config-flow-test-coverage: done config-flow: status: done diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index 00be409869a..5a0105f830e 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -64,7 +64,7 @@ def _get_controller(hass: HomeAssistant) -> Heos: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="integration_not_loaded" ) - return entry.runtime_data.coordinator.heos + return entry.runtime_data.heos async def _sign_in_handler(service: ServiceCall) -> None: diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index e99d8f7e7fb..907804d10e1 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -94,9 +94,15 @@ "action_error": { "message": "Unable to {action}: {error}" }, + "entity_not_found": { + "message": "Entity {entity_id} was not found" + }, "integration_not_loaded": { "message": "The HEOS integration is not loaded" }, + "not_heos_media_player": { + "message": "Entity {entity_id} is not a HEOS media player entity" + }, "unknown_source": { "message": "Unknown source: {source}" } diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 1a363d64aeb..122467c6b02 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -87,7 +87,8 @@ async def controller_fixture( mock_heos.load_players = AsyncMock(return_value=change_data) mock_heos._signed_in_username = "user@user.com" mock_heos.get_groups = AsyncMock(return_value=group) - mock_heos.create_group = AsyncMock(return_value=None) + mock_heos._groups = group + mock_heos.set_group = AsyncMock(return_value=None) new_mock = Mock(return_value=mock_heos) mock_heos.new_mock = new_mock with ( @@ -104,6 +105,7 @@ def players_fixture(quick_selects: dict[int, str]) -> dict[int, HeosPlayer]: for i in (1, 2): player = HeosPlayer( player_id=i, + group_id=999, name="Test Player" if i == 1 else f"Test Player {i}", model="HEOS Drive HS2" if i == 1 else "Speaker", serial="123456", diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index b26652415df..2d9f69d764d 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -316,6 +316,41 @@ async def test_updates_from_user_changed( ] +async def test_updates_from_groups_changed( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test player updates from changes to groups.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Assert current state + assert hass.states.get("media_player.test_player").attributes[ + ATTR_GROUP_MEMBERS + ] == ["media_player.test_player", "media_player.test_player_2"] + assert hass.states.get("media_player.test_player_2").attributes[ + ATTR_GROUP_MEMBERS + ] == ["media_player.test_player", "media_player.test_player_2"] + + # Clear group information + controller._groups = {} + for player in controller.players.values(): + player.group_id = None + await controller.dispatcher.wait_send( + SignalType.CONTROLLER_EVENT, const.EVENT_GROUPS_CHANGED, None + ) + await hass.async_block_till_done() + + # Assert groups changed + assert ( + hass.states.get("media_player.test_player").attributes[ATTR_GROUP_MEMBERS] + is None + ) + assert ( + hass.states.get("media_player.test_player_2").attributes[ATTR_GROUP_MEMBERS] + is None + ) + + async def test_clear_playlist( hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: @@ -1119,8 +1154,20 @@ async def test_play_media_invalid_type( ) +@pytest.mark.parametrize( + ("members", "expected"), + [ + (["media_player.test_player_2"], [1, 2]), + (["media_player.test_player_2", "media_player.test_player"], [1, 2]), + (["media_player.test_player"], [1]), + ], +) async def test_media_player_join_group( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + members: list[str], + expected: tuple[int, list[int]], ) -> None: """Test grouping of media players through the join service.""" config_entry.add_to_hass(hass) @@ -1130,16 +1177,11 @@ async def test_media_player_join_group( SERVICE_JOIN, { ATTR_ENTITY_ID: "media_player.test_player", - ATTR_GROUP_MEMBERS: ["media_player.test_player_2"], + ATTR_GROUP_MEMBERS: members, }, blocking=True, ) - controller.create_group.assert_called_once_with( - 1, - [ - 2, - ], - ) + controller.set_group.assert_called_once_with(expected) async def test_media_player_join_group_error( @@ -1148,7 +1190,7 @@ async def test_media_player_join_group_error( """Test grouping of media players through the join service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - controller.create_group.side_effect = HeosError("error") + controller.set_group.side_effect = HeosError("error") with pytest.raises( HomeAssistantError, match=re.escape("Unable to join players: error"), @@ -1190,15 +1232,24 @@ async def test_media_player_group_members_error( ) -> None: """Test error in HEOS API.""" controller.get_groups.side_effect = HeosError("error") + controller._groups = {} config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) - assert "Unable to get HEOS group info" in caplog.text + assert "Unable to retrieve groups" in caplog.text player_entity = hass.states.get("media_player.test_player") - assert player_entity.attributes[ATTR_GROUP_MEMBERS] == [] + assert player_entity.attributes[ATTR_GROUP_MEMBERS] is None +@pytest.mark.parametrize( + ("entity_id", "expected_args"), + [("media_player.test_player", [1]), ("media_player.test_player_2", [1])], +) async def test_media_player_unjoin_group( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + entity_id: str, + expected_args: list[int], ) -> None: """Test ungrouping of media players through the unjoin service.""" config_entry.add_to_hass(hass) @@ -1207,11 +1258,11 @@ async def test_media_player_unjoin_group( MEDIA_PLAYER_DOMAIN, SERVICE_UNJOIN, { - ATTR_ENTITY_ID: "media_player.test_player", + ATTR_ENTITY_ID: entity_id, }, blocking=True, ) - controller.create_group.assert_called_once_with(1, []) + controller.set_group.assert_called_once_with(expected_args) async def test_media_player_unjoin_group_error( @@ -1220,7 +1271,7 @@ async def test_media_player_unjoin_group_error( """Test ungrouping of media players through the unjoin service error raises.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - controller.create_group.side_effect = HeosError("error") + controller.set_group.side_effect = HeosError("error") with pytest.raises( HomeAssistantError, match=re.escape("Unable to unjoin player: error"), @@ -1249,10 +1300,7 @@ async def test_media_player_group_fails_when_entity_removed( entity_registry.async_remove("media_player.test_player_2") # Attempt to group - with pytest.raises( - HomeAssistantError, - match="The group member media_player.test_player_2 could not be resolved to a HEOS player.", - ): + with pytest.raises(ServiceValidationError, match="was not found"): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_JOIN, @@ -1262,4 +1310,35 @@ async def test_media_player_group_fails_when_entity_removed( }, blocking=True, ) - controller.create_group.assert_not_called() + controller.set_group.assert_not_called() + + +async def test_media_player_group_fails_wrong_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + entity_registry: er.EntityRegistry, +) -> None: + """Test grouping fails when trying to join from the wrong integration.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + # Create an entity in another integration + entry = entity_registry.async_get_or_create( + "media_player", "Other", "test_player_2" + ) + + # Attempt to group + with pytest.raises( + ServiceValidationError, match="is not a HEOS media player entity" + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_GROUP_MEMBERS: [entry.entity_id], + }, + blocking=True, + ) + controller.set_group.assert_not_called()