mirror of
https://github.com/home-assistant/core.git
synced 2025-07-16 17:57:11 +00:00
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:
parent
2db301fab9
commit
2fb85aab8e
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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"
|
||||
|
@ -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()
|
||||
|
@ -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."""
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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}"
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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()
|
||||
|
Loading…
x
Reference in New Issue
Block a user