mirror of
https://github.com/home-assistant/core.git
synced 2025-07-25 22:27:07 +00:00
Add support for HEOS groups (#32568)
* Add support for grouping HEOS media players * Update homeassistant/components/heos/media_player.py Co-authored-by: Andrew Sayre (he/his/him) <6730289+andrewsayre@users.noreply.github.com> * Update homeassistant/components/heos/media_player.py Co-authored-by: Andrew Sayre (he/his/him) <6730289+andrewsayre@users.noreply.github.com> * Update homeassistant/components/heos/media_player.py Co-authored-by: Andrew Sayre (he/his/him) <6730289+andrewsayre@users.noreply.github.com> * Update homeassistant/components/heos/media_player.py Co-authored-by: Andrew Sayre (he/his/him) <6730289+andrewsayre@users.noreply.github.com> * Update homeassistant/components/heos/media_player.py Co-authored-by: Andrew Sayre (he/his/him) <6730289+andrewsayre@users.noreply.github.com> * Update homeassistant/components/heos/media_player.py Co-authored-by: Andrew Sayre (he/his/him) <6730289+andrewsayre@users.noreply.github.com> * Handle groups at controller level, refine tests. Co-authored-by: Andrew Sayre (he/his/him) <6730289+andrewsayre@users.noreply.github.com> * Fix linting issues * Update homeassistant/components/heos/media_player.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update homeassistant/components/heos/media_player.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Rename variables and improve resolving of entity_ids Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Don't patch internal methods Use the pytest fixtures which have already been defined for this. * Fix linting issues * Remove unused property * Ignore groups with unknown leader This makes sure that the group_members attribute won't contain a `None` value as a leader entity_id. * Don't call force_update_groups() from tests * Don't pass `None` player ids to HEOS API * Use signal for group manager communication * Use imports for async_dispatcher_send/async_dispatcher_connect * Raise exception when leader/player could not be resolved * Disconnect signal handlers, avoid calling async_update_groups too early * Update homeassistant/components/heos/__init__.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> Co-authored-by: Andrew Sayre (he/his/him) <6730289+andrewsayre@users.noreply.github.com> Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
0dece582e4
commit
56e93ff0ec
@ -11,9 +11,13 @@ import voluptuous as vol
|
|||||||
from homeassistant.components.media_player.const import DOMAIN as MEDIA_PLAYER_DOMAIN
|
from homeassistant.components.media_player.const import DOMAIN as MEDIA_PLAYER_DOMAIN
|
||||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||||
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP
|
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||||
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 homeassistant.util import Throttle
|
from homeassistant.util import Throttle
|
||||||
|
|
||||||
@ -23,8 +27,11 @@ from .const import (
|
|||||||
COMMAND_RETRY_ATTEMPTS,
|
COMMAND_RETRY_ATTEMPTS,
|
||||||
COMMAND_RETRY_DELAY,
|
COMMAND_RETRY_DELAY,
|
||||||
DATA_CONTROLLER_MANAGER,
|
DATA_CONTROLLER_MANAGER,
|
||||||
|
DATA_ENTITY_ID_MAP,
|
||||||
|
DATA_GROUP_MANAGER,
|
||||||
DATA_SOURCE_MANAGER,
|
DATA_SOURCE_MANAGER,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
SIGNAL_HEOS_PLAYER_ADDED,
|
||||||
SIGNAL_HEOS_UPDATED,
|
SIGNAL_HEOS_UPDATED,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -117,15 +124,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
source_manager = SourceManager(favorites, inputs)
|
source_manager = SourceManager(favorites, inputs)
|
||||||
source_manager.connect_update(hass, controller)
|
source_manager.connect_update(hass, controller)
|
||||||
|
|
||||||
|
group_manager = GroupManager(hass, controller)
|
||||||
|
|
||||||
hass.data[DOMAIN] = {
|
hass.data[DOMAIN] = {
|
||||||
DATA_CONTROLLER_MANAGER: controller_manager,
|
DATA_CONTROLLER_MANAGER: controller_manager,
|
||||||
|
DATA_GROUP_MANAGER: group_manager,
|
||||||
DATA_SOURCE_MANAGER: source_manager,
|
DATA_SOURCE_MANAGER: source_manager,
|
||||||
MEDIA_PLAYER_DOMAIN: players,
|
MEDIA_PLAYER_DOMAIN: players,
|
||||||
|
# Maps player_id to entity_id. Populated by the individual HeosMediaPlayer entities.
|
||||||
|
DATA_ENTITY_ID_MAP: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
services.register(hass, controller)
|
services.register(hass, controller)
|
||||||
|
|
||||||
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
||||||
|
group_manager.connect_update()
|
||||||
|
entry.async_on_unload(group_manager.disconnect_update)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -184,7 +198,7 @@ class ControllerManager:
|
|||||||
if event == heos_const.EVENT_PLAYERS_CHANGED:
|
if event == heos_const.EVENT_PLAYERS_CHANGED:
|
||||||
self.update_ids(data[heos_const.DATA_MAPPED_IDS])
|
self.update_ids(data[heos_const.DATA_MAPPED_IDS])
|
||||||
# Update players
|
# Update players
|
||||||
self._hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_HEOS_UPDATED)
|
async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED)
|
||||||
|
|
||||||
async def _heos_event(self, event):
|
async def _heos_event(self, event):
|
||||||
"""Handle connection event."""
|
"""Handle connection event."""
|
||||||
@ -196,7 +210,7 @@ class ControllerManager:
|
|||||||
except HeosError as ex:
|
except HeosError as ex:
|
||||||
_LOGGER.error("Unable to refresh players: %s", ex)
|
_LOGGER.error("Unable to refresh players: %s", ex)
|
||||||
# Update players
|
# Update players
|
||||||
self._hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_HEOS_UPDATED)
|
async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED)
|
||||||
|
|
||||||
def update_ids(self, mapped_ids: dict[int, int]):
|
def update_ids(self, mapped_ids: dict[int, int]):
|
||||||
"""Update the IDs in the device and entity registry."""
|
"""Update the IDs in the device and entity registry."""
|
||||||
@ -223,6 +237,148 @@ class ControllerManager:
|
|||||||
_LOGGER.debug("Updated entity %s unique id to %s", entity_id, new_id)
|
_LOGGER.debug("Updated entity %s unique id to %s", entity_id, new_id)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupManager:
|
||||||
|
"""Class that manages HEOS groups."""
|
||||||
|
|
||||||
|
def __init__(self, hass, controller):
|
||||||
|
"""Init group manager."""
|
||||||
|
self._hass = hass
|
||||||
|
self._group_membership = {}
|
||||||
|
self._disconnect_player_added = None
|
||||||
|
self._initialized = False
|
||||||
|
self.controller = controller
|
||||||
|
|
||||||
|
def _get_entity_id_to_player_id_map(self) -> dict:
|
||||||
|
"""Return a dictionary which maps all HeosMediaPlayer entity_ids to player_ids."""
|
||||||
|
return {v: k for k, v in self._hass.data[DOMAIN][DATA_ENTITY_ID_MAP].items()}
|
||||||
|
|
||||||
|
async def async_get_group_membership(self):
|
||||||
|
"""Return a dictionary which contains all group members for each player as entity_ids."""
|
||||||
|
group_info_by_entity_id = {
|
||||||
|
player_entity_id: []
|
||||||
|
for player_entity_id in self._get_entity_id_to_player_id_map()
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
groups = await self.controller.get_groups(refresh=True)
|
||||||
|
except HeosError as err:
|
||||||
|
_LOGGER.error("Unable to get HEOS group info: %s", err)
|
||||||
|
return group_info_by_entity_id
|
||||||
|
|
||||||
|
player_id_to_entity_id_map = self._hass.data[DOMAIN][DATA_ENTITY_ID_MAP]
|
||||||
|
for group in groups.values():
|
||||||
|
leader_entity_id = player_id_to_entity_id_map.get(group.leader.player_id)
|
||||||
|
member_entity_ids = [
|
||||||
|
player_id_to_entity_id_map[member.player_id]
|
||||||
|
for member in group.members
|
||||||
|
if member.player_id in player_id_to_entity_id_map
|
||||||
|
]
|
||||||
|
# Make sure the group leader is always the first element
|
||||||
|
group_info = [leader_entity_id, *member_entity_ids]
|
||||||
|
if leader_entity_id:
|
||||||
|
group_info_by_entity_id[leader_entity_id] = group_info
|
||||||
|
for member_entity_id in member_entity_ids:
|
||||||
|
group_info_by_entity_id[member_entity_id] = group_info
|
||||||
|
|
||||||
|
return group_info_by_entity_id
|
||||||
|
|
||||||
|
async def async_join_players(
|
||||||
|
self, leader_entity_id: str, member_entity_ids: list[str]
|
||||||
|
) -> None:
|
||||||
|
"""Create a group with `leader_entity_id` as group leader and `member_entity_ids` as member players."""
|
||||||
|
entity_id_to_player_id_map = self._get_entity_id_to_player_id_map()
|
||||||
|
leader_id = entity_id_to_player_id_map.get(leader_entity_id)
|
||||||
|
if not leader_id:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
f"The group leader {leader_entity_id} could not be resolved to a HEOS player."
|
||||||
|
)
|
||||||
|
member_ids = [
|
||||||
|
entity_id_to_player_id_map[member]
|
||||||
|
for member in member_entity_ids
|
||||||
|
if member in entity_id_to_player_id_map
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.controller.create_group(leader_id, member_ids)
|
||||||
|
except HeosError as err:
|
||||||
|
_LOGGER.error(
|
||||||
|
"Failed to group %s with %s: %s",
|
||||||
|
leader_entity_id,
|
||||||
|
member_entity_ids,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_unjoin_player(self, player_entity_id: str):
|
||||||
|
"""Remove `player_entity_id` from any group."""
|
||||||
|
player_id = self._get_entity_id_to_player_id_map().get(player_entity_id)
|
||||||
|
if not player_id:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
f"The player {player_entity_id} could not be resolved to a HEOS player."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.controller.create_group(player_id, [])
|
||||||
|
except HeosError as err:
|
||||||
|
_LOGGER.error(
|
||||||
|
"Failed to ungroup %s: %s",
|
||||||
|
player_entity_id,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_update_groups(self, event, data=None):
|
||||||
|
"""Update the group membership from the controller."""
|
||||||
|
if event in (
|
||||||
|
heos_const.EVENT_GROUPS_CHANGED,
|
||||||
|
heos_const.EVENT_CONNECTED,
|
||||||
|
SIGNAL_HEOS_PLAYER_ADDED,
|
||||||
|
):
|
||||||
|
groups = await self.async_get_group_membership()
|
||||||
|
if groups:
|
||||||
|
self._group_membership = groups
|
||||||
|
_LOGGER.debug("Groups updated due to change event")
|
||||||
|
# Let players know to update
|
||||||
|
async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED)
|
||||||
|
else:
|
||||||
|
_LOGGER.debug("Groups empty")
|
||||||
|
|
||||||
|
def connect_update(self):
|
||||||
|
"""Connect listener for when groups change and signal player update."""
|
||||||
|
self.controller.dispatcher.connect(
|
||||||
|
heos_const.SIGNAL_CONTROLLER_EVENT, self.async_update_groups
|
||||||
|
)
|
||||||
|
self.controller.dispatcher.connect(
|
||||||
|
heos_const.SIGNAL_HEOS_EVENT, self.async_update_groups
|
||||||
|
)
|
||||||
|
|
||||||
|
# When adding a new HEOS player we need to update the groups.
|
||||||
|
async def _async_handle_player_added():
|
||||||
|
# Avoid calling async_update_groups when `DATA_ENTITY_ID_MAP` has not been
|
||||||
|
# fully populated yet. This may only happen during early startup.
|
||||||
|
if (
|
||||||
|
len(self._hass.data[DOMAIN][MEDIA_PLAYER_DOMAIN])
|
||||||
|
<= len(self._hass.data[DOMAIN][DATA_ENTITY_ID_MAP])
|
||||||
|
and not self._initialized
|
||||||
|
):
|
||||||
|
self._initialized = True
|
||||||
|
await self.async_update_groups(SIGNAL_HEOS_PLAYER_ADDED)
|
||||||
|
|
||||||
|
self._disconnect_player_added = async_dispatcher_connect(
|
||||||
|
self._hass, SIGNAL_HEOS_PLAYER_ADDED, _async_handle_player_added
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def disconnect_update(self):
|
||||||
|
"""Disconnect the listeners."""
|
||||||
|
if self._disconnect_player_added:
|
||||||
|
self._disconnect_player_added()
|
||||||
|
self._disconnect_player_added = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def group_membership(self):
|
||||||
|
"""Provide access to group members for player entities."""
|
||||||
|
return self._group_membership
|
||||||
|
|
||||||
|
|
||||||
class SourceManager:
|
class SourceManager:
|
||||||
"""Class that manages sources for players."""
|
"""Class that manages sources for players."""
|
||||||
|
|
||||||
@ -341,7 +497,7 @@ class SourceManager:
|
|||||||
self.source_list = self._build_source_list()
|
self.source_list = self._build_source_list()
|
||||||
_LOGGER.debug("Sources updated due to changed event")
|
_LOGGER.debug("Sources updated due to changed event")
|
||||||
# Let players know to update
|
# Let players know to update
|
||||||
hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_HEOS_UPDATED)
|
async_dispatcher_send(hass, SIGNAL_HEOS_UPDATED)
|
||||||
|
|
||||||
controller.dispatcher.connect(
|
controller.dispatcher.connect(
|
||||||
heos_const.SIGNAL_CONTROLLER_EVENT, update_sources
|
heos_const.SIGNAL_CONTROLLER_EVENT, update_sources
|
||||||
|
@ -5,9 +5,12 @@ ATTR_USERNAME = "username"
|
|||||||
COMMAND_RETRY_ATTEMPTS = 2
|
COMMAND_RETRY_ATTEMPTS = 2
|
||||||
COMMAND_RETRY_DELAY = 1
|
COMMAND_RETRY_DELAY = 1
|
||||||
DATA_CONTROLLER_MANAGER = "controller"
|
DATA_CONTROLLER_MANAGER = "controller"
|
||||||
|
DATA_ENTITY_ID_MAP = "entity_id_map"
|
||||||
|
DATA_GROUP_MANAGER = "group_manager"
|
||||||
DATA_SOURCE_MANAGER = "source_manager"
|
DATA_SOURCE_MANAGER = "source_manager"
|
||||||
DATA_DISCOVERED_HOSTS = "heos_discovered_hosts"
|
DATA_DISCOVERED_HOSTS = "heos_discovered_hosts"
|
||||||
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"
|
SIGNAL_HEOS_UPDATED = "heos_updated"
|
||||||
|
@ -15,6 +15,7 @@ from homeassistant.components.media_player.const import (
|
|||||||
MEDIA_TYPE_PLAYLIST,
|
MEDIA_TYPE_PLAYLIST,
|
||||||
MEDIA_TYPE_URL,
|
MEDIA_TYPE_URL,
|
||||||
SUPPORT_CLEAR_PLAYLIST,
|
SUPPORT_CLEAR_PLAYLIST,
|
||||||
|
SUPPORT_GROUPING,
|
||||||
SUPPORT_NEXT_TRACK,
|
SUPPORT_NEXT_TRACK,
|
||||||
SUPPORT_PAUSE,
|
SUPPORT_PAUSE,
|
||||||
SUPPORT_PLAY,
|
SUPPORT_PLAY,
|
||||||
@ -30,10 +31,21 @@ from homeassistant.components.media_player.const import (
|
|||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import STATE_IDLE, STATE_PAUSED, STATE_PLAYING
|
from homeassistant.const import STATE_IDLE, STATE_PAUSED, STATE_PLAYING
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.dispatcher import (
|
||||||
|
async_dispatcher_connect,
|
||||||
|
async_dispatcher_send,
|
||||||
|
)
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
from homeassistant.util.dt import utcnow
|
from homeassistant.util.dt import utcnow
|
||||||
|
|
||||||
from .const import DATA_SOURCE_MANAGER, DOMAIN as HEOS_DOMAIN, SIGNAL_HEOS_UPDATED
|
from .const import (
|
||||||
|
DATA_ENTITY_ID_MAP,
|
||||||
|
DATA_GROUP_MANAGER,
|
||||||
|
DATA_SOURCE_MANAGER,
|
||||||
|
DOMAIN as HEOS_DOMAIN,
|
||||||
|
SIGNAL_HEOS_PLAYER_ADDED,
|
||||||
|
SIGNAL_HEOS_UPDATED,
|
||||||
|
)
|
||||||
|
|
||||||
BASE_SUPPORTED_FEATURES = (
|
BASE_SUPPORTED_FEATURES = (
|
||||||
SUPPORT_VOLUME_MUTE
|
SUPPORT_VOLUME_MUTE
|
||||||
@ -43,6 +55,7 @@ BASE_SUPPORTED_FEATURES = (
|
|||||||
| SUPPORT_SHUFFLE_SET
|
| SUPPORT_SHUFFLE_SET
|
||||||
| SUPPORT_SELECT_SOURCE
|
| SUPPORT_SELECT_SOURCE
|
||||||
| SUPPORT_PLAY_MEDIA
|
| SUPPORT_PLAY_MEDIA
|
||||||
|
| SUPPORT_GROUPING
|
||||||
)
|
)
|
||||||
|
|
||||||
PLAY_STATE_TO_STATE = {
|
PLAY_STATE_TO_STATE = {
|
||||||
@ -97,6 +110,7 @@ class HeosMediaPlayer(MediaPlayerEntity):
|
|||||||
self._signals = []
|
self._signals = []
|
||||||
self._supported_features = BASE_SUPPORTED_FEATURES
|
self._supported_features = BASE_SUPPORTED_FEATURES
|
||||||
self._source_manager = None
|
self._source_manager = None
|
||||||
|
self._group_manager = None
|
||||||
|
|
||||||
async def _player_update(self, player_id, event):
|
async def _player_update(self, player_id, event):
|
||||||
"""Handle player attribute updated."""
|
"""Handle player attribute updated."""
|
||||||
@ -120,16 +134,24 @@ class HeosMediaPlayer(MediaPlayerEntity):
|
|||||||
)
|
)
|
||||||
# Update state when heos changes
|
# Update state when heos changes
|
||||||
self._signals.append(
|
self._signals.append(
|
||||||
self.hass.helpers.dispatcher.async_dispatcher_connect(
|
async_dispatcher_connect(self.hass, SIGNAL_HEOS_UPDATED, self._heos_updated)
|
||||||
SIGNAL_HEOS_UPDATED, self._heos_updated
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
# Register this player's entity_id so it can be resolved by the group manager
|
||||||
|
self.hass.data[HEOS_DOMAIN][DATA_ENTITY_ID_MAP][
|
||||||
|
self._player.player_id
|
||||||
|
] = self.entity_id
|
||||||
|
async_dispatcher_send(self.hass, SIGNAL_HEOS_PLAYER_ADDED)
|
||||||
|
|
||||||
@log_command_error("clear playlist")
|
@log_command_error("clear playlist")
|
||||||
async def async_clear_playlist(self):
|
async def async_clear_playlist(self):
|
||||||
"""Clear players playlist."""
|
"""Clear players playlist."""
|
||||||
await self._player.clear_queue()
|
await self._player.clear_queue()
|
||||||
|
|
||||||
|
@log_command_error("join_players")
|
||||||
|
async def async_join_players(self, group_members: list[str]) -> None:
|
||||||
|
"""Join `group_members` as a player group with the current player."""
|
||||||
|
await self._group_manager.async_join_players(self.entity_id, group_members)
|
||||||
|
|
||||||
@log_command_error("pause")
|
@log_command_error("pause")
|
||||||
async def async_media_pause(self):
|
async def async_media_pause(self):
|
||||||
"""Send pause command."""
|
"""Send pause command."""
|
||||||
@ -238,9 +260,17 @@ class HeosMediaPlayer(MediaPlayerEntity):
|
|||||||
current_support = [CONTROL_TO_SUPPORT[control] for control in controls]
|
current_support = [CONTROL_TO_SUPPORT[control] for control in controls]
|
||||||
self._supported_features = reduce(ior, current_support, BASE_SUPPORTED_FEATURES)
|
self._supported_features = reduce(ior, current_support, BASE_SUPPORTED_FEATURES)
|
||||||
|
|
||||||
|
if self._group_manager is None:
|
||||||
|
self._group_manager = self.hass.data[HEOS_DOMAIN][DATA_GROUP_MANAGER]
|
||||||
|
|
||||||
if self._source_manager is None:
|
if self._source_manager is None:
|
||||||
self._source_manager = self.hass.data[HEOS_DOMAIN][DATA_SOURCE_MANAGER]
|
self._source_manager = self.hass.data[HEOS_DOMAIN][DATA_SOURCE_MANAGER]
|
||||||
|
|
||||||
|
@log_command_error("unjoin_player")
|
||||||
|
async def async_unjoin_player(self):
|
||||||
|
"""Remove this player from any group."""
|
||||||
|
await self._group_manager.async_unjoin_player(self.entity_id)
|
||||||
|
|
||||||
async def async_will_remove_from_hass(self):
|
async def async_will_remove_from_hass(self):
|
||||||
"""Disconnect the device when removed."""
|
"""Disconnect the device when removed."""
|
||||||
for signal_remove in self._signals:
|
for signal_remove in self._signals:
|
||||||
@ -274,6 +304,11 @@ class HeosMediaPlayer(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."""
|
||||||
|
@ -4,7 +4,15 @@ from __future__ import annotations
|
|||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
from unittest.mock import Mock, patch as patch
|
from unittest.mock import Mock, patch as patch
|
||||||
|
|
||||||
from pyheos import Dispatcher, Heos, HeosPlayer, HeosSource, InputSource, const
|
from pyheos import (
|
||||||
|
Dispatcher,
|
||||||
|
Heos,
|
||||||
|
HeosGroup,
|
||||||
|
HeosPlayer,
|
||||||
|
HeosSource,
|
||||||
|
InputSource,
|
||||||
|
const,
|
||||||
|
)
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components import ssdp
|
from homeassistant.components import ssdp
|
||||||
@ -24,7 +32,7 @@ def config_entry_fixture():
|
|||||||
|
|
||||||
@pytest.fixture(name="controller")
|
@pytest.fixture(name="controller")
|
||||||
def controller_fixture(
|
def controller_fixture(
|
||||||
players, favorites, input_sources, playlists, change_data, dispatcher
|
players, favorites, input_sources, playlists, change_data, dispatcher, group
|
||||||
):
|
):
|
||||||
"""Create a mock Heos controller fixture."""
|
"""Create a mock Heos controller fixture."""
|
||||||
mock_heos = Mock(Heos)
|
mock_heos = Mock(Heos)
|
||||||
@ -40,6 +48,8 @@ def controller_fixture(
|
|||||||
mock_heos.is_signed_in = True
|
mock_heos.is_signed_in = True
|
||||||
mock_heos.signed_in_username = "user@user.com"
|
mock_heos.signed_in_username = "user@user.com"
|
||||||
mock_heos.connection_state = const.STATE_CONNECTED
|
mock_heos.connection_state = const.STATE_CONNECTED
|
||||||
|
mock_heos.get_groups.return_value = group
|
||||||
|
mock_heos.create_group.return_value = None
|
||||||
mock = Mock(return_value=mock_heos)
|
mock = Mock(return_value=mock_heos)
|
||||||
|
|
||||||
with patch("homeassistant.components.heos.Heos", new=mock), patch(
|
with patch("homeassistant.components.heos.Heos", new=mock), patch(
|
||||||
@ -56,16 +66,21 @@ def config_fixture():
|
|||||||
|
|
||||||
@pytest.fixture(name="players")
|
@pytest.fixture(name="players")
|
||||||
def player_fixture(quick_selects):
|
def player_fixture(quick_selects):
|
||||||
"""Create a mock HeosPlayer."""
|
"""Create two mock HeosPlayers."""
|
||||||
|
players = {}
|
||||||
|
for i in (1, 2):
|
||||||
player = Mock(HeosPlayer)
|
player = Mock(HeosPlayer)
|
||||||
player.player_id = 1
|
player.player_id = i
|
||||||
|
if i > 1:
|
||||||
|
player.name = f"Test Player {i}"
|
||||||
|
else:
|
||||||
player.name = "Test Player"
|
player.name = "Test Player"
|
||||||
player.model = "Test Model"
|
player.model = "Test Model"
|
||||||
player.version = "1.0.0"
|
player.version = "1.0.0"
|
||||||
player.is_muted = False
|
player.is_muted = False
|
||||||
player.available = True
|
player.available = True
|
||||||
player.state = const.PLAY_STATE_STOP
|
player.state = const.PLAY_STATE_STOP
|
||||||
player.ip_address = "127.0.0.1"
|
player.ip_address = f"127.0.0.{i}"
|
||||||
player.network = "wired"
|
player.network = "wired"
|
||||||
player.shuffle = False
|
player.shuffle = False
|
||||||
player.repeat = const.REPEAT_OFF
|
player.repeat = const.REPEAT_OFF
|
||||||
@ -84,7 +99,18 @@ def player_fixture(quick_selects):
|
|||||||
player.now_playing_media.image_url = "http://"
|
player.now_playing_media.image_url = "http://"
|
||||||
player.now_playing_media.song = "Song"
|
player.now_playing_media.song = "Song"
|
||||||
player.get_quick_selects.return_value = quick_selects
|
player.get_quick_selects.return_value = quick_selects
|
||||||
return {player.player_id: player}
|
players[player.player_id] = player
|
||||||
|
return players
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="group")
|
||||||
|
def group_fixture(players):
|
||||||
|
"""Create a HEOS group consisting of two players."""
|
||||||
|
group = Mock(HeosGroup)
|
||||||
|
group.leader = players[1]
|
||||||
|
group.members = [players[2]]
|
||||||
|
group.group_id = 999
|
||||||
|
return {group.group_id: group}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="favorites")
|
@pytest.fixture(name="favorites")
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from pyheos import CommandFailedError, const
|
from pyheos import CommandFailedError, const
|
||||||
|
from pyheos.error import HeosError
|
||||||
|
|
||||||
from homeassistant.components.heos import media_player
|
from homeassistant.components.heos import media_player
|
||||||
from homeassistant.components.heos.const import (
|
from homeassistant.components.heos.const import (
|
||||||
@ -10,6 +11,7 @@ from homeassistant.components.heos.const import (
|
|||||||
SIGNAL_HEOS_UPDATED,
|
SIGNAL_HEOS_UPDATED,
|
||||||
)
|
)
|
||||||
from homeassistant.components.media_player.const import (
|
from homeassistant.components.media_player.const import (
|
||||||
|
ATTR_GROUP_MEMBERS,
|
||||||
ATTR_INPUT_SOURCE,
|
ATTR_INPUT_SOURCE,
|
||||||
ATTR_INPUT_SOURCE_LIST,
|
ATTR_INPUT_SOURCE_LIST,
|
||||||
ATTR_MEDIA_ALBUM_NAME,
|
ATTR_MEDIA_ALBUM_NAME,
|
||||||
@ -29,8 +31,10 @@ from homeassistant.components.media_player.const import (
|
|||||||
MEDIA_TYPE_PLAYLIST,
|
MEDIA_TYPE_PLAYLIST,
|
||||||
MEDIA_TYPE_URL,
|
MEDIA_TYPE_URL,
|
||||||
SERVICE_CLEAR_PLAYLIST,
|
SERVICE_CLEAR_PLAYLIST,
|
||||||
|
SERVICE_JOIN,
|
||||||
SERVICE_PLAY_MEDIA,
|
SERVICE_PLAY_MEDIA,
|
||||||
SERVICE_SELECT_SOURCE,
|
SERVICE_SELECT_SOURCE,
|
||||||
|
SERVICE_UNJOIN,
|
||||||
SUPPORT_NEXT_TRACK,
|
SUPPORT_NEXT_TRACK,
|
||||||
SUPPORT_PAUSE,
|
SUPPORT_PAUSE,
|
||||||
SUPPORT_PLAY,
|
SUPPORT_PLAY,
|
||||||
@ -264,10 +268,10 @@ async def test_updates_from_players_changed_new_ids(
|
|||||||
await event.wait()
|
await event.wait()
|
||||||
|
|
||||||
# Assert device registry identifiers were updated
|
# Assert device registry identifiers were updated
|
||||||
assert len(device_registry.devices) == 1
|
assert len(device_registry.devices) == 2
|
||||||
assert device_registry.async_get_device({(DOMAIN, 101)})
|
assert device_registry.async_get_device({(DOMAIN, 101)})
|
||||||
# Assert entity registry unique id was updated
|
# Assert entity registry unique id was updated
|
||||||
assert len(entity_registry.entities) == 1
|
assert len(entity_registry.entities) == 2
|
||||||
assert (
|
assert (
|
||||||
entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "101")
|
entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "101")
|
||||||
== "media_player.test_player"
|
== "media_player.test_player"
|
||||||
@ -805,3 +809,99 @@ async def test_play_media_invalid_type(hass, config_entry, config, controller, c
|
|||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
assert "Unable to play media: Unsupported media type 'Other'" in caplog.text
|
assert "Unable to play media: Unsupported media type 'Other'" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_media_player_join_group(hass, config_entry, config, controller, caplog):
|
||||||
|
"""Test grouping of media players through the join service."""
|
||||||
|
await setup_platform(hass, config_entry, config)
|
||||||
|
await hass.services.async_call(
|
||||||
|
MEDIA_PLAYER_DOMAIN,
|
||||||
|
SERVICE_JOIN,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: "media_player.test_player",
|
||||||
|
ATTR_GROUP_MEMBERS: ["media_player.test_player_2"],
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
controller.create_group.assert_called_once_with(
|
||||||
|
1,
|
||||||
|
[
|
||||||
|
2,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert "Failed to group media_player.test_player with" not in caplog.text
|
||||||
|
|
||||||
|
controller.create_group.side_effect = HeosError("error")
|
||||||
|
await hass.services.async_call(
|
||||||
|
MEDIA_PLAYER_DOMAIN,
|
||||||
|
SERVICE_JOIN,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: "media_player.test_player",
|
||||||
|
ATTR_GROUP_MEMBERS: ["media_player.test_player_2"],
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
assert "Failed to group media_player.test_player with" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_media_player_group_members(
|
||||||
|
hass, config_entry, config, controller, caplog
|
||||||
|
):
|
||||||
|
"""Test group_members attribute."""
|
||||||
|
await setup_platform(hass, config_entry, config)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
player_entity = hass.states.get("media_player.test_player")
|
||||||
|
assert player_entity.attributes[ATTR_GROUP_MEMBERS] == [
|
||||||
|
"media_player.test_player",
|
||||||
|
"media_player.test_player_2",
|
||||||
|
]
|
||||||
|
controller.get_groups.assert_called_once()
|
||||||
|
assert "Unable to get HEOS group info" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_media_player_group_members_error(
|
||||||
|
hass, config_entry, config, controller, caplog
|
||||||
|
):
|
||||||
|
"""Test error in HEOS API."""
|
||||||
|
controller.get_groups.side_effect = HeosError("error")
|
||||||
|
await setup_platform(hass, config_entry, config)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert "Unable to get HEOS group info" in caplog.text
|
||||||
|
player_entity = hass.states.get("media_player.test_player")
|
||||||
|
assert player_entity.attributes[ATTR_GROUP_MEMBERS] == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_media_player_unjoin_group(
|
||||||
|
hass, config_entry, config, controller, caplog
|
||||||
|
):
|
||||||
|
"""Test ungrouping of media players through the join service."""
|
||||||
|
await setup_platform(hass, config_entry, config)
|
||||||
|
player = controller.players[1]
|
||||||
|
|
||||||
|
player.heos.dispatcher.send(
|
||||||
|
const.SIGNAL_PLAYER_EVENT,
|
||||||
|
player.player_id,
|
||||||
|
const.EVENT_PLAYER_STATE_CHANGED,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
await hass.services.async_call(
|
||||||
|
MEDIA_PLAYER_DOMAIN,
|
||||||
|
SERVICE_UNJOIN,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: "media_player.test_player",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
controller.create_group.assert_called_once_with(1, [])
|
||||||
|
assert "Failed to ungroup media_player.test_player" not in caplog.text
|
||||||
|
|
||||||
|
controller.create_group.side_effect = HeosError("error")
|
||||||
|
await hass.services.async_call(
|
||||||
|
MEDIA_PLAYER_DOMAIN,
|
||||||
|
SERVICE_UNJOIN,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: "media_player.test_player",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
assert "Failed to ungroup media_player.test_player" in caplog.text
|
||||||
|
Loading…
x
Reference in New Issue
Block a user