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

View File

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

View File

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

View File

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

View File

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