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:
Dan Klaffenbach 2021-11-21 12:57:31 +01:00 committed by GitHub
parent 0dece582e4
commit 56e93ff0ec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 362 additions and 42 deletions

View file

@ -11,9 +11,13 @@ import voluptuous as vol
from homeassistant.components.media_player.const import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
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.util import Throttle
@ -23,8 +27,11 @@ from .const import (
COMMAND_RETRY_ATTEMPTS,
COMMAND_RETRY_DELAY,
DATA_CONTROLLER_MANAGER,
DATA_ENTITY_ID_MAP,
DATA_GROUP_MANAGER,
DATA_SOURCE_MANAGER,
DOMAIN,
SIGNAL_HEOS_PLAYER_ADDED,
SIGNAL_HEOS_UPDATED,
)
@ -117,15 +124,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
source_manager = SourceManager(favorites, inputs)
source_manager.connect_update(hass, controller)
group_manager = GroupManager(hass, controller)
hass.data[DOMAIN] = {
DATA_CONTROLLER_MANAGER: controller_manager,
DATA_GROUP_MANAGER: group_manager,
DATA_SOURCE_MANAGER: source_manager,
MEDIA_PLAYER_DOMAIN: players,
# Maps player_id to entity_id. Populated by the individual HeosMediaPlayer entities.
DATA_ENTITY_ID_MAP: {},
}
services.register(hass, controller)
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
group_manager.connect_update()
entry.async_on_unload(group_manager.disconnect_update)
return True
@ -184,7 +198,7 @@ class ControllerManager:
if event == heos_const.EVENT_PLAYERS_CHANGED:
self.update_ids(data[heos_const.DATA_MAPPED_IDS])
# 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):
"""Handle connection event."""
@ -196,7 +210,7 @@ class ControllerManager:
except HeosError as ex:
_LOGGER.error("Unable to refresh players: %s", ex)
# 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]):
"""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)
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 that manages sources for players."""
@ -341,7 +497,7 @@ class SourceManager:
self.source_list = self._build_source_list()
_LOGGER.debug("Sources updated due to changed event")
# Let players know to update
hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_HEOS_UPDATED)
async_dispatcher_send(hass, SIGNAL_HEOS_UPDATED)
controller.dispatcher.connect(
heos_const.SIGNAL_CONTROLLER_EVENT, update_sources

View file

@ -5,9 +5,12 @@ ATTR_USERNAME = "username"
COMMAND_RETRY_ATTEMPTS = 2
COMMAND_RETRY_DELAY = 1
DATA_CONTROLLER_MANAGER = "controller"
DATA_ENTITY_ID_MAP = "entity_id_map"
DATA_GROUP_MANAGER = "group_manager"
DATA_SOURCE_MANAGER = "source_manager"
DATA_DISCOVERED_HOSTS = "heos_discovered_hosts"
DOMAIN = "heos"
SERVICE_SIGN_IN = "sign_in"
SERVICE_SIGN_OUT = "sign_out"
SIGNAL_HEOS_PLAYER_ADDED = "heos_player_added"
SIGNAL_HEOS_UPDATED = "heos_updated"

View file

@ -15,6 +15,7 @@ from homeassistant.components.media_player.const import (
MEDIA_TYPE_PLAYLIST,
MEDIA_TYPE_URL,
SUPPORT_CLEAR_PLAYLIST,
SUPPORT_GROUPING,
SUPPORT_NEXT_TRACK,
SUPPORT_PAUSE,
SUPPORT_PLAY,
@ -30,10 +31,21 @@ from homeassistant.components.media_player.const import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_IDLE, STATE_PAUSED, STATE_PLAYING
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import DeviceInfo
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 = (
SUPPORT_VOLUME_MUTE
@ -43,6 +55,7 @@ BASE_SUPPORTED_FEATURES = (
| SUPPORT_SHUFFLE_SET
| SUPPORT_SELECT_SOURCE
| SUPPORT_PLAY_MEDIA
| SUPPORT_GROUPING
)
PLAY_STATE_TO_STATE = {
@ -97,6 +110,7 @@ class HeosMediaPlayer(MediaPlayerEntity):
self._signals = []
self._supported_features = BASE_SUPPORTED_FEATURES
self._source_manager = None
self._group_manager = None
async def _player_update(self, player_id, event):
"""Handle player attribute updated."""
@ -120,16 +134,24 @@ class HeosMediaPlayer(MediaPlayerEntity):
)
# Update state when heos changes
self._signals.append(
self.hass.helpers.dispatcher.async_dispatcher_connect(
SIGNAL_HEOS_UPDATED, self._heos_updated
)
async_dispatcher_connect(self.hass, 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")
async def async_clear_playlist(self):
"""Clear players playlist."""
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")
async def async_media_pause(self):
"""Send pause command."""
@ -238,9 +260,17 @@ class HeosMediaPlayer(MediaPlayerEntity):
current_support = [CONTROL_TO_SUPPORT[control] for control in controls]
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:
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):
"""Disconnect the device when removed."""
for signal_remove in self._signals:
@ -274,6 +304,11 @@ class HeosMediaPlayer(MediaPlayerEntity):
"media_type": self._player.now_playing_media.type,
}
@property
def group_members(self) -> list[str]:
"""List of players which are grouped together."""
return self._group_manager.group_membership.get(self.entity_id, [])
@property
def is_volume_muted(self) -> bool:
"""Boolean if volume is currently muted."""

View file

@ -4,7 +4,15 @@ from __future__ import annotations
from typing import Sequence
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
from homeassistant.components import ssdp
@ -24,7 +32,7 @@ def config_entry_fixture():
@pytest.fixture(name="controller")
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."""
mock_heos = Mock(Heos)
@ -40,6 +48,8 @@ def controller_fixture(
mock_heos.is_signed_in = True
mock_heos.signed_in_username = "user@user.com"
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)
with patch("homeassistant.components.heos.Heos", new=mock), patch(
@ -56,35 +66,51 @@ def config_fixture():
@pytest.fixture(name="players")
def player_fixture(quick_selects):
"""Create a mock HeosPlayer."""
player = Mock(HeosPlayer)
player.player_id = 1
player.name = "Test Player"
player.model = "Test Model"
player.version = "1.0.0"
player.is_muted = False
player.available = True
player.state = const.PLAY_STATE_STOP
player.ip_address = "127.0.0.1"
player.network = "wired"
player.shuffle = False
player.repeat = const.REPEAT_OFF
player.volume = 25
player.now_playing_media.supported_controls = const.CONTROLS_ALL
player.now_playing_media.album_id = 1
player.now_playing_media.queue_id = 1
player.now_playing_media.source_id = 1
player.now_playing_media.station = "Station Name"
player.now_playing_media.type = "Station"
player.now_playing_media.album = "Album"
player.now_playing_media.artist = "Artist"
player.now_playing_media.media_id = "1"
player.now_playing_media.duration = None
player.now_playing_media.current_position = None
player.now_playing_media.image_url = "http://"
player.now_playing_media.song = "Song"
player.get_quick_selects.return_value = quick_selects
return {player.player_id: player}
"""Create two mock HeosPlayers."""
players = {}
for i in (1, 2):
player = Mock(HeosPlayer)
player.player_id = i
if i > 1:
player.name = f"Test Player {i}"
else:
player.name = "Test Player"
player.model = "Test Model"
player.version = "1.0.0"
player.is_muted = False
player.available = True
player.state = const.PLAY_STATE_STOP
player.ip_address = f"127.0.0.{i}"
player.network = "wired"
player.shuffle = False
player.repeat = const.REPEAT_OFF
player.volume = 25
player.now_playing_media.supported_controls = const.CONTROLS_ALL
player.now_playing_media.album_id = 1
player.now_playing_media.queue_id = 1
player.now_playing_media.source_id = 1
player.now_playing_media.station = "Station Name"
player.now_playing_media.type = "Station"
player.now_playing_media.album = "Album"
player.now_playing_media.artist = "Artist"
player.now_playing_media.media_id = "1"
player.now_playing_media.duration = None
player.now_playing_media.current_position = None
player.now_playing_media.image_url = "http://"
player.now_playing_media.song = "Song"
player.get_quick_selects.return_value = quick_selects
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")

View file

@ -2,6 +2,7 @@
import asyncio
from pyheos import CommandFailedError, const
from pyheos.error import HeosError
from homeassistant.components.heos import media_player
from homeassistant.components.heos.const import (
@ -10,6 +11,7 @@ from homeassistant.components.heos.const import (
SIGNAL_HEOS_UPDATED,
)
from homeassistant.components.media_player.const import (
ATTR_GROUP_MEMBERS,
ATTR_INPUT_SOURCE,
ATTR_INPUT_SOURCE_LIST,
ATTR_MEDIA_ALBUM_NAME,
@ -29,8 +31,10 @@ from homeassistant.components.media_player.const import (
MEDIA_TYPE_PLAYLIST,
MEDIA_TYPE_URL,
SERVICE_CLEAR_PLAYLIST,
SERVICE_JOIN,
SERVICE_PLAY_MEDIA,
SERVICE_SELECT_SOURCE,
SERVICE_UNJOIN,
SUPPORT_NEXT_TRACK,
SUPPORT_PAUSE,
SUPPORT_PLAY,
@ -264,10 +268,10 @@ async def test_updates_from_players_changed_new_ids(
await event.wait()
# 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 entity registry unique id was updated
assert len(entity_registry.entities) == 1
assert len(entity_registry.entities) == 2
assert (
entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "101")
== "media_player.test_player"
@ -805,3 +809,99 @@ async def test_play_media_invalid_type(hass, config_entry, config, controller, c
blocking=True,
)
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