Incorporate GroupManager into HEOS Coordinator (#136462)

* Incorporate GroupManager

* Update quality scale

* Fix group params

* Revert quality scale change

* Rename varaible

* Move group action implementaton out of coordinator

* Fix get_group_members hass access

* entity -> entity_id
This commit is contained in:
Andrew Sayre 2025-01-25 11:04:33 -06:00 committed by GitHub
parent 2db301fab9
commit 2fb85aab8e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 202 additions and 241 deletions

View File

@ -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

View File

@ -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(

View File

@ -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"

View File

@ -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()

View File

@ -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."""

View File

@ -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

View File

@ -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:

View File

@ -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}"
}

View File

@ -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",

View File

@ -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()