mirror of
https://github.com/home-assistant/core.git
synced 2025-07-18 10:47:10 +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 __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import timedelta
|
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.config_entries import ConfigEntry
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
from homeassistant.helpers import device_registry as dr
|
from homeassistant.helpers import device_registry as dr
|
||||||
import homeassistant.helpers.config_validation as cv
|
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.helpers.typing import ConfigType
|
||||||
|
|
||||||
from . import services
|
from . import services
|
||||||
from .const import DOMAIN, SIGNAL_HEOS_PLAYER_ADDED, SIGNAL_HEOS_UPDATED
|
from .const import DOMAIN
|
||||||
from .coordinator import HeosCoordinator
|
from .coordinator import HeosCoordinator
|
||||||
|
|
||||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||||
@ -31,19 +21,7 @@ MIN_UPDATE_SOURCES = timedelta(seconds=1)
|
|||||||
|
|
||||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
type HeosConfigEntry = ConfigEntry[HeosCoordinator]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class HeosRuntimeData:
|
|
||||||
"""Runtime data and coordinators for HEOS config entries."""
|
|
||||||
|
|
||||||
coordinator: HeosCoordinator
|
|
||||||
group_manager: GroupManager
|
|
||||||
players: dict[int, HeosPlayer]
|
|
||||||
|
|
||||||
|
|
||||||
type HeosConfigEntry = ConfigEntry[HeosRuntimeData]
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
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)
|
coordinator = HeosCoordinator(hass, entry)
|
||||||
await coordinator.async_setup()
|
await coordinator.async_setup()
|
||||||
# Preserve existing logic until migrated into coordinator
|
entry.runtime_data = 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)
|
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
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:
|
async def async_unload_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
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()
|
entry: HeosConfigEntry = self._get_reauth_entry()
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
assert entry.state is ConfigEntryState.LOADED
|
assert entry.state is ConfigEntryState.LOADED
|
||||||
if await _validate_auth(
|
if await _validate_auth(user_input, entry.runtime_data.heos, errors):
|
||||||
user_input, entry.runtime_data.coordinator.heos, errors
|
|
||||||
):
|
|
||||||
return self.async_update_reload_and_abort(entry, options=user_input)
|
return self.async_update_reload_and_abort(entry, options=user_input)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
@ -212,9 +210,7 @@ class HeosOptionsFlowHandler(OptionsFlow):
|
|||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
entry: HeosConfigEntry = self.config_entry
|
entry: HeosConfigEntry = self.config_entry
|
||||||
if await _validate_auth(
|
if await _validate_auth(user_input, entry.runtime_data.heos, errors):
|
||||||
user_input, entry.runtime_data.coordinator.heos, errors
|
|
||||||
):
|
|
||||||
return self.async_create_entry(data=user_input)
|
return self.async_create_entry(data=user_input)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
|
@ -5,5 +5,3 @@ ATTR_USERNAME = "username"
|
|||||||
DOMAIN = "heos"
|
DOMAIN = "heos"
|
||||||
SERVICE_SIGN_IN = "sign_in"
|
SERVICE_SIGN_IN = "sign_in"
|
||||||
SERVICE_SIGN_OUT = "sign_out"
|
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.
|
entities to update. Entities subscribe to entity-specific updates within the entity class itself.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import logging
|
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"
|
"The HEOS System is not logged in: Enter credentials in the integration options to access favorites and streaming services"
|
||||||
)
|
)
|
||||||
# Retrieve initial data
|
# Retrieve initial data
|
||||||
|
await self._async_update_groups()
|
||||||
await self._async_update_sources()
|
await self._async_update_sources()
|
||||||
# Attach event callbacks
|
# Attach event callbacks
|
||||||
self.heos.add_on_disconnected(self._async_on_disconnected)
|
self.heos.add_on_disconnected(self._async_on_disconnected)
|
||||||
@ -93,6 +95,13 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
|
|||||||
await self.heos.disconnect()
|
await self.heos.disconnect()
|
||||||
await super().async_shutdown()
|
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:
|
async def _async_on_auth_failure(self) -> None:
|
||||||
"""Handle when the user credentials are no longer valid."""
|
"""Handle when the user credentials are no longer valid."""
|
||||||
assert self.config_entry is not None
|
assert self.config_entry is not None
|
||||||
@ -118,6 +127,8 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
|
|||||||
assert data is not None
|
assert data is not None
|
||||||
if data.updated_player_ids:
|
if data.updated_player_ids:
|
||||||
self._async_update_player_ids(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 (
|
elif (
|
||||||
event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED)
|
event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED)
|
||||||
and not self._update_sources_pending
|
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)
|
_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:
|
async def _async_update_sources(self) -> None:
|
||||||
"""Build source list for entities."""
|
"""Build source list for entities."""
|
||||||
self._source_list.clear()
|
self._source_list.clear()
|
||||||
|
@ -29,19 +29,17 @@ from homeassistant.components.media_player import (
|
|||||||
RepeatMode,
|
RepeatMode,
|
||||||
async_process_play_media_url,
|
async_process_play_media_url,
|
||||||
)
|
)
|
||||||
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant, callback
|
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.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.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
from homeassistant.util.dt import utcnow
|
from homeassistant.util.dt import utcnow
|
||||||
|
|
||||||
from . import GroupManager, HeosConfigEntry
|
from . import HeosConfigEntry
|
||||||
from .const import DOMAIN as HEOS_DOMAIN, SIGNAL_HEOS_PLAYER_ADDED, SIGNAL_HEOS_UPDATED
|
from .const import DOMAIN as HEOS_DOMAIN
|
||||||
from .coordinator import HeosCoordinator
|
from .coordinator import HeosCoordinator
|
||||||
|
|
||||||
PARALLEL_UPDATES = 0
|
PARALLEL_UPDATES = 0
|
||||||
@ -92,14 +90,9 @@ async def async_setup_entry(
|
|||||||
hass: HomeAssistant, entry: HeosConfigEntry, async_add_entities: AddEntitiesCallback
|
hass: HomeAssistant, entry: HeosConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add media players for a config entry."""
|
"""Add media players for a config entry."""
|
||||||
players = entry.runtime_data.players
|
|
||||||
devices = [
|
devices = [
|
||||||
HeosMediaPlayer(
|
HeosMediaPlayer(entry.runtime_data, player)
|
||||||
entry.runtime_data.coordinator,
|
for player in entry.runtime_data.heos.players.values()
|
||||||
player,
|
|
||||||
entry.runtime_data.group_manager,
|
|
||||||
)
|
|
||||||
for player in players.values()
|
|
||||||
]
|
]
|
||||||
async_add_entities(devices)
|
async_add_entities(devices)
|
||||||
|
|
||||||
@ -139,16 +132,10 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
|
|||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
_attr_name = None
|
_attr_name = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, coordinator: HeosCoordinator, player: HeosPlayer) -> None:
|
||||||
self,
|
|
||||||
coordinator: HeosCoordinator,
|
|
||||||
player: HeosPlayer,
|
|
||||||
group_manager: GroupManager,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize."""
|
"""Initialize."""
|
||||||
self._media_position_updated_at = None
|
self._media_position_updated_at = None
|
||||||
self._player: HeosPlayer = player
|
self._player: HeosPlayer = player
|
||||||
self._group_manager = group_manager
|
|
||||||
self._attr_unique_id = str(player.player_id)
|
self._attr_unique_id = str(player.player_id)
|
||||||
model_parts = player.model.split(maxsplit=1)
|
model_parts = player.model.split(maxsplit=1)
|
||||||
manufacturer = model_parts[0] if len(model_parts) == 2 else "HEOS"
|
manufacturer = model_parts[0] if len(model_parts) == 2 else "HEOS"
|
||||||
@ -162,7 +149,6 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
|
|||||||
sw_version=player.version,
|
sw_version=player.version,
|
||||||
)
|
)
|
||||||
super().__init__(coordinator, context=player.player_id)
|
super().__init__(coordinator, context=player.player_id)
|
||||||
self._update_attributes()
|
|
||||||
|
|
||||||
async def _player_update(self, event):
|
async def _player_update(self, event):
|
||||||
"""Handle player attribute updated."""
|
"""Handle player attribute updated."""
|
||||||
@ -176,8 +162,31 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
|
|||||||
self._update_attributes()
|
self._update_attributes()
|
||||||
super()._handle_coordinator_update()
|
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:
|
def _update_attributes(self) -> None:
|
||||||
"""Update core attributes of the media player."""
|
"""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_list = self.coordinator.async_get_source_list()
|
||||||
self._attr_source = self.coordinator.async_get_current_source(
|
self._attr_source = self.coordinator.async_get_current_source(
|
||||||
self._player.now_playing_media
|
self._player.now_playing_media
|
||||||
@ -197,20 +206,8 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
|
|||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Device added to hass."""
|
"""Device added to hass."""
|
||||||
# Update state when attributes of the player change
|
# Update state when attributes of the player change
|
||||||
|
self._update_attributes()
|
||||||
self.async_on_remove(self._player.add_on_player_event(self._player_update))
|
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()
|
await super().async_added_to_hass()
|
||||||
|
|
||||||
@catch_action_error("clear playlist")
|
@catch_action_error("clear playlist")
|
||||||
@ -218,13 +215,6 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
|
|||||||
"""Clear players playlist."""
|
"""Clear players playlist."""
|
||||||
await self._player.clear_queue()
|
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")
|
@catch_action_error("pause")
|
||||||
async def async_media_pause(self) -> None:
|
async def async_media_pause(self) -> None:
|
||||||
"""Send pause command."""
|
"""Send pause command."""
|
||||||
@ -335,10 +325,45 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
|
|||||||
"""Set volume level, range 0..1."""
|
"""Set volume level, range 0..1."""
|
||||||
await self._player.set_volume(int(volume * 100))
|
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")
|
@catch_action_error("unjoin player")
|
||||||
async def async_unjoin_player(self) -> None:
|
async def async_unjoin_player(self) -> None:
|
||||||
"""Remove this player from any group."""
|
"""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
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
@ -356,11 +381,6 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
|
|||||||
"media_type": self._player.now_playing_media.type,
|
"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
|
@property
|
||||||
def is_volume_muted(self) -> bool:
|
def is_volume_muted(self) -> bool:
|
||||||
"""Boolean if volume is currently muted."""
|
"""Boolean if volume is currently muted."""
|
||||||
|
@ -5,7 +5,7 @@ rules:
|
|||||||
status: done
|
status: done
|
||||||
comment: Integration is a local push integration
|
comment: Integration is a local push integration
|
||||||
brands: done
|
brands: done
|
||||||
common-modules: todo
|
common-modules: done
|
||||||
config-flow-test-coverage: done
|
config-flow-test-coverage: done
|
||||||
config-flow:
|
config-flow:
|
||||||
status: done
|
status: done
|
||||||
|
@ -64,7 +64,7 @@ def _get_controller(hass: HomeAssistant) -> Heos:
|
|||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
translation_domain=DOMAIN, translation_key="integration_not_loaded"
|
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:
|
async def _sign_in_handler(service: ServiceCall) -> None:
|
||||||
|
@ -94,9 +94,15 @@
|
|||||||
"action_error": {
|
"action_error": {
|
||||||
"message": "Unable to {action}: {error}"
|
"message": "Unable to {action}: {error}"
|
||||||
},
|
},
|
||||||
|
"entity_not_found": {
|
||||||
|
"message": "Entity {entity_id} was not found"
|
||||||
|
},
|
||||||
"integration_not_loaded": {
|
"integration_not_loaded": {
|
||||||
"message": "The HEOS integration is 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": {
|
"unknown_source": {
|
||||||
"message": "Unknown source: {source}"
|
"message": "Unknown source: {source}"
|
||||||
}
|
}
|
||||||
|
@ -87,7 +87,8 @@ async def controller_fixture(
|
|||||||
mock_heos.load_players = AsyncMock(return_value=change_data)
|
mock_heos.load_players = AsyncMock(return_value=change_data)
|
||||||
mock_heos._signed_in_username = "user@user.com"
|
mock_heos._signed_in_username = "user@user.com"
|
||||||
mock_heos.get_groups = AsyncMock(return_value=group)
|
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)
|
new_mock = Mock(return_value=mock_heos)
|
||||||
mock_heos.new_mock = new_mock
|
mock_heos.new_mock = new_mock
|
||||||
with (
|
with (
|
||||||
@ -104,6 +105,7 @@ def players_fixture(quick_selects: dict[int, str]) -> dict[int, HeosPlayer]:
|
|||||||
for i in (1, 2):
|
for i in (1, 2):
|
||||||
player = HeosPlayer(
|
player = HeosPlayer(
|
||||||
player_id=i,
|
player_id=i,
|
||||||
|
group_id=999,
|
||||||
name="Test Player" if i == 1 else f"Test Player {i}",
|
name="Test Player" if i == 1 else f"Test Player {i}",
|
||||||
model="HEOS Drive HS2" if i == 1 else "Speaker",
|
model="HEOS Drive HS2" if i == 1 else "Speaker",
|
||||||
serial="123456",
|
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(
|
async def test_clear_playlist(
|
||||||
hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos
|
hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos
|
||||||
) -> None:
|
) -> 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(
|
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:
|
) -> None:
|
||||||
"""Test grouping of media players through the join service."""
|
"""Test grouping of media players through the join service."""
|
||||||
config_entry.add_to_hass(hass)
|
config_entry.add_to_hass(hass)
|
||||||
@ -1130,16 +1177,11 @@ async def test_media_player_join_group(
|
|||||||
SERVICE_JOIN,
|
SERVICE_JOIN,
|
||||||
{
|
{
|
||||||
ATTR_ENTITY_ID: "media_player.test_player",
|
ATTR_ENTITY_ID: "media_player.test_player",
|
||||||
ATTR_GROUP_MEMBERS: ["media_player.test_player_2"],
|
ATTR_GROUP_MEMBERS: members,
|
||||||
},
|
},
|
||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
controller.create_group.assert_called_once_with(
|
controller.set_group.assert_called_once_with(expected)
|
||||||
1,
|
|
||||||
[
|
|
||||||
2,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_media_player_join_group_error(
|
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."""
|
"""Test grouping of media players through the join service raises error."""
|
||||||
config_entry.add_to_hass(hass)
|
config_entry.add_to_hass(hass)
|
||||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
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(
|
with pytest.raises(
|
||||||
HomeAssistantError,
|
HomeAssistantError,
|
||||||
match=re.escape("Unable to join players: error"),
|
match=re.escape("Unable to join players: error"),
|
||||||
@ -1190,15 +1232,24 @@ async def test_media_player_group_members_error(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Test error in HEOS API."""
|
"""Test error in HEOS API."""
|
||||||
controller.get_groups.side_effect = HeosError("error")
|
controller.get_groups.side_effect = HeosError("error")
|
||||||
|
controller._groups = {}
|
||||||
config_entry.add_to_hass(hass)
|
config_entry.add_to_hass(hass)
|
||||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
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")
|
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(
|
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:
|
) -> None:
|
||||||
"""Test ungrouping of media players through the unjoin service."""
|
"""Test ungrouping of media players through the unjoin service."""
|
||||||
config_entry.add_to_hass(hass)
|
config_entry.add_to_hass(hass)
|
||||||
@ -1207,11 +1258,11 @@ async def test_media_player_unjoin_group(
|
|||||||
MEDIA_PLAYER_DOMAIN,
|
MEDIA_PLAYER_DOMAIN,
|
||||||
SERVICE_UNJOIN,
|
SERVICE_UNJOIN,
|
||||||
{
|
{
|
||||||
ATTR_ENTITY_ID: "media_player.test_player",
|
ATTR_ENTITY_ID: entity_id,
|
||||||
},
|
},
|
||||||
blocking=True,
|
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(
|
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."""
|
"""Test ungrouping of media players through the unjoin service error raises."""
|
||||||
config_entry.add_to_hass(hass)
|
config_entry.add_to_hass(hass)
|
||||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
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(
|
with pytest.raises(
|
||||||
HomeAssistantError,
|
HomeAssistantError,
|
||||||
match=re.escape("Unable to unjoin player: error"),
|
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")
|
entity_registry.async_remove("media_player.test_player_2")
|
||||||
|
|
||||||
# Attempt to group
|
# Attempt to group
|
||||||
with pytest.raises(
|
with pytest.raises(ServiceValidationError, match="was not found"):
|
||||||
HomeAssistantError,
|
|
||||||
match="The group member media_player.test_player_2 could not be resolved to a HEOS player.",
|
|
||||||
):
|
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
MEDIA_PLAYER_DOMAIN,
|
MEDIA_PLAYER_DOMAIN,
|
||||||
SERVICE_JOIN,
|
SERVICE_JOIN,
|
||||||
@ -1262,4 +1310,35 @@ async def test_media_player_group_fails_when_entity_removed(
|
|||||||
},
|
},
|
||||||
blocking=True,
|
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