From ca290ee6313209d0f0be89a6a053c6fe54873631 Mon Sep 17 00:00:00 2001 From: Tucker Kern Date: Tue, 12 Aug 2025 10:07:29 -0600 Subject: [PATCH] Implement Snapcast grouping with standard HA actions (#146855) Co-authored-by: Joost Lekkerkerker --- .../components/snapcast/coordinator.py | 7 + .../components/snapcast/media_player.py | 239 +++++++++++++----- .../components/snapcast/strings.json | 10 + tests/components/snapcast/conftest.py | 89 +++++-- .../snapcast/snapshots/test_media_player.ambr | 184 +++++++------- .../components/snapcast/test_media_player.py | 201 ++++++++++++++- 6 files changed, 546 insertions(+), 184 deletions(-) diff --git a/homeassistant/components/snapcast/coordinator.py b/homeassistant/components/snapcast/coordinator.py index 4c2f0cb81b7..963f12887fc 100644 --- a/homeassistant/components/snapcast/coordinator.py +++ b/homeassistant/components/snapcast/coordinator.py @@ -39,6 +39,8 @@ class SnapcastUpdateCoordinator(DataUpdateCoordinator[None]): self._server.set_on_connect_callback(self._on_connect) self._server.set_on_disconnect_callback(self._on_disconnect) + self._host_id = f"{host}:{port}" + def _on_update(self) -> None: """Snapserver on_update callback.""" # Assume availability if an update is received. @@ -77,3 +79,8 @@ class SnapcastUpdateCoordinator(DataUpdateCoordinator[None]): def server(self) -> Snapserver: """Get the Snapserver object.""" return self._server + + @property + def host_id(self) -> str: + """Get the host ID.""" + return self._host_id diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 8e3f787e71d..ccb9d4c4c46 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping import logging from typing import Any @@ -19,13 +19,13 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import ( config_validation as cv, entity_platform, entity_registry as er, + issue_registry as ir, ) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -52,6 +52,12 @@ STREAM_STATUS = { "unknown": None, } +_SUPPORTED_FEATURES = ( + MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.SELECT_SOURCE +) + _LOGGER = logging.getLogger(__name__) @@ -82,106 +88,91 @@ async def async_setup_entry( # Fetch coordinator from global data coordinator: SnapcastUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - # Create an ID for the Snapserver - host = config_entry.data[CONF_HOST] - port = config_entry.data[CONF_PORT] - host_id = f"{host}:{port}" - register_services() _known_group_ids: set[str] = set() _known_client_ids: set[str] = set() @callback - def _check_entities() -> None: - nonlocal _known_group_ids, _known_client_ids + def _update_entities( + entity_class: type[SnapcastClientDevice | SnapcastGroupDevice], + known_ids: set[str], + get_device: Callable[[str], Snapclient | Snapgroup], + get_devices: Callable[[], list[Snapclient] | list[Snapgroup]], + ) -> None: + # Get IDs of current devices on server + snapcast_ids = {d.identifier for d in get_devices()} - def _update_known_ids(known_ids, ids) -> tuple[set[str], set[str]]: - ids_to_add = ids - known_ids - ids_to_remove = known_ids - ids + # Update known IDs + ids_to_add = snapcast_ids - known_ids + ids_to_remove = known_ids - snapcast_ids - # Update known IDs - known_ids.difference_update(ids_to_remove) - known_ids.update(ids_to_add) - - return ids_to_add, ids_to_remove - - group_ids = {g.identifier for g in coordinator.server.groups} - groups_to_add, groups_to_remove = _update_known_ids(_known_group_ids, group_ids) - - client_ids = {c.identifier for c in coordinator.server.clients} - clients_to_add, clients_to_remove = _update_known_ids( - _known_client_ids, client_ids - ) + known_ids.difference_update(ids_to_remove) + known_ids.update(ids_to_add) # Exit early if no changes - if not (groups_to_add | groups_to_remove | clients_to_add | clients_to_remove): + if not (ids_to_add | ids_to_remove): return _LOGGER.debug( - "New clients: %s", - str([coordinator.server.client(c).friendly_name for c in clients_to_add]), + "New %s: %s", + entity_class, + str([get_device(d).friendly_name for d in ids_to_add]), ) _LOGGER.debug( - "New groups: %s", - str([coordinator.server.group(g).friendly_name for g in groups_to_add]), - ) - _LOGGER.debug( - "Remove client IDs: %s", - str([list(clients_to_remove)]), - ) - _LOGGER.debug( - "Remove group IDs: %s", - str(list(groups_to_remove)), + "Remove %s IDs: %s", + entity_class, + str([list(ids_to_remove)]), ) # Add new entities async_add_entities( [ - SnapcastGroupDevice( - coordinator, coordinator.server.group(group_id), host_id - ) - for group_id in groups_to_add - ] - + [ - SnapcastClientDevice( - coordinator, coordinator.server.client(client_id), host_id - ) - for client_id in clients_to_add + entity_class(coordinator, get_device(snapcast_id)) + for snapcast_id in ids_to_add ] ) # Remove stale entities entity_registry = er.async_get(hass) - for group_id in groups_to_remove: + for snapcast_id in ids_to_remove: if entity_id := entity_registry.async_get_entity_id( MEDIA_PLAYER_DOMAIN, DOMAIN, - SnapcastGroupDevice.get_unique_id(host_id, group_id), + entity_class.get_unique_id(coordinator.host_id, snapcast_id), ): entity_registry.async_remove(entity_id) - for client_id in clients_to_remove: - if entity_id := entity_registry.async_get_entity_id( - MEDIA_PLAYER_DOMAIN, - DOMAIN, - SnapcastClientDevice.get_unique_id(host_id, client_id), - ): - entity_registry.async_remove(entity_id) + def _update_clients() -> None: + _update_entities( + SnapcastClientDevice, + _known_client_ids, + coordinator.server.client, + lambda: coordinator.server.clients, + ) - coordinator.async_add_listener(_check_entities) - _check_entities() + # Create client entities and add listener to update clients on server update + _update_clients() + coordinator.async_add_listener(_update_clients) + + def _update_groups() -> None: + _update_entities( + SnapcastGroupDevice, + _known_group_ids, + coordinator.server.group, + lambda: coordinator.server.groups, + ) + + # Create group entities and add listener to update groups on server update + _update_groups() + coordinator.async_add_listener(_update_groups) class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity): """Base class representing a Snapcast device.""" _attr_should_poll = False - _attr_supported_features = ( - MediaPlayerEntityFeature.VOLUME_MUTE - | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.SELECT_SOURCE - ) + _attr_supported_features = _SUPPORTED_FEATURES _attr_media_content_type = MediaType.MUSIC _attr_device_class = MediaPlayerDeviceClass.SPEAKER @@ -189,13 +180,14 @@ class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity): self, coordinator: SnapcastUpdateCoordinator, device: Snapgroup | Snapclient, - host_id: str, ) -> None: """Initialize the base device.""" super().__init__(coordinator) self._device = device - self._attr_unique_id = self.get_unique_id(host_id, device.identifier) + self._attr_unique_id = self.get_unique_id( + coordinator.host_id, device.identifier + ) @classmethod def get_unique_id(cls, host, id) -> str: @@ -279,6 +271,19 @@ class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity): """Handle the unjoin service.""" raise NotImplementedError + def _async_create_grouping_deprecation_issue(self) -> None: + """Create an issue for deprecated grouping actions.""" + ir.async_create_issue( + self.hass, + DOMAIN, + "deprecated_grouping_actions", + breaks_in_ha_version="2026.2.0", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_grouping_actions", + ) + @property def metadata(self) -> Mapping[str, Any]: """Get metadata from the current stream.""" @@ -389,11 +394,62 @@ class SnapcastGroupDevice(SnapcastBaseDevice): """Handle the unjoin service.""" raise ServiceValidationError("Entity is not a client. Can only unjoin clients.") + def _async_create_group_deprecation_issue(self) -> None: + """Create an issue for deprecated group entities.""" + ir.async_create_issue( + self.hass, + DOMAIN, + "deprecated_group_entities", + breaks_in_ha_version="2026.2.0", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_group_entities", + ) + + async def async_select_source(self, source: str) -> None: + """Set input source.""" + # Groups are deprecated, create an issue when used + self._async_create_group_deprecation_issue() + + await super().async_select_source(source) + + async def async_mute_volume(self, mute: bool) -> None: + """Send the mute command.""" + # Groups are deprecated, create an issue when used + self._async_create_group_deprecation_issue() + + await super().async_mute_volume(mute) + + async def async_set_volume_level(self, volume: float) -> None: + """Set the volume level.""" + # Groups are deprecated, create an issue when used + self._async_create_group_deprecation_issue() + + await super().async_set_volume_level(volume) + + def snapshot(self) -> None: + """Snapshot the group state.""" + # Groups are deprecated, create an issue when used + self._async_create_group_deprecation_issue() + + super().snapshot() + + async def async_restore(self) -> None: + """Restore the group state.""" + # Groups are deprecated, create an issue when used + self._async_create_group_deprecation_issue() + + await super().async_restore() + class SnapcastClientDevice(SnapcastBaseDevice): """Representation of a Snapcast client device.""" _device: Snapclient + _attr_supported_features = ( + _SUPPORTED_FEATURES | MediaPlayerEntityFeature.GROUPING + ) # Clients support grouping @classmethod def get_unique_id(cls, host, id) -> str: @@ -439,6 +495,9 @@ class SnapcastClientDevice(SnapcastBaseDevice): async def async_join(self, master) -> None: """Join the group of the master player.""" + # Action is deprecated, create an issue + self._async_create_grouping_deprecation_issue() + entity_registry = er.async_get(self.hass) master_entity = entity_registry.async_get(master) if master_entity is None: @@ -463,5 +522,53 @@ class SnapcastClientDevice(SnapcastBaseDevice): async def async_unjoin(self) -> None: """Unjoin the group the player is currently in.""" + # Action is deprecated, create an issue + self._async_create_grouping_deprecation_issue() + + await self._current_group.remove_client(self._device.identifier) + self.async_write_ha_state() + + @property + def group_members(self) -> list[str] | None: + """List of player entities which are currently grouped together for synchronous playback.""" + entity_registry = er.async_get(self.hass) + return [ + entity_id + for client_id in self._current_group.clients + if ( + entity_id := entity_registry.async_get_entity_id( + MEDIA_PLAYER_DOMAIN, + DOMAIN, + self.get_unique_id(self.coordinator.host_id, client_id), + ) + ) + ] + + async def async_join_players(self, group_members: list[str]) -> None: + """Add `group_members` to this client's current group.""" + # Get the client entity for each group member excluding self + entity_registry = er.async_get(self.hass) + clients = [ + entity + for entity_id in group_members + if (entity := entity_registry.async_get(entity_id)) + and entity.unique_id != self.unique_id + ] + + for client in clients: + # Valid entity is a snapcast client + if not client.unique_id.startswith(CLIENT_PREFIX): + raise ServiceValidationError( + f"Entity '{client.entity_id}' is not a Snapcast client device." + ) + + # Extract client ID and join it to the current group + identifier = client.unique_id.split("_")[-1] + await self._current_group.add_client(identifier) + + self.async_write_ha_state() + + async def async_unjoin_player(self) -> None: + """Remove this client from it's current group.""" await self._current_group.remove_client(self._device.identifier) self.async_write_ha_state() diff --git a/homeassistant/components/snapcast/strings.json b/homeassistant/components/snapcast/strings.json index 685b4a0dd11..9336b1fac86 100644 --- a/homeassistant/components/snapcast/strings.json +++ b/homeassistant/components/snapcast/strings.json @@ -58,5 +58,15 @@ } } } + }, + "issues": { + "deprecated_grouping_actions": { + "title": "Snapcast Actions Deprecated", + "description": "Actions 'snapcast.join' and 'snapcast.unjoin' are deprecated and will be removed in 2026.2. Use the 'media_player.join' and 'media_player.unjoin' actions instead." + }, + "deprecated_group_entities": { + "title": "Snapcast Groups Entities Deprecated", + "description": "Snapcast group entities are deprecated and will be removed in 2026.2. Please use the 'media_player.join' and 'media_player.unjoin' actions instead." + } } } diff --git a/tests/components/snapcast/conftest.py b/tests/components/snapcast/conftest.py index c2c4ffa7997..282429b110a 100644 --- a/tests/components/snapcast/conftest.py +++ b/tests/components/snapcast/conftest.py @@ -36,8 +36,10 @@ def mock_server(mock_create_server: AsyncMock) -> Generator[AsyncMock]: @pytest.fixture def mock_create_server( - mock_group: AsyncMock, - mock_client: AsyncMock, + mock_group_1: AsyncMock, + mock_group_2: AsyncMock, + mock_client_1: AsyncMock, + mock_client_2: AsyncMock, mock_stream_1: AsyncMock, mock_stream_2: AsyncMock, ) -> Generator[AsyncMock]: @@ -46,16 +48,26 @@ def mock_create_server( "homeassistant.components.snapcast.coordinator.Snapserver", autospec=True ) as mock_snapserver: mock_server = mock_snapserver.return_value - mock_server.groups = [mock_group] - mock_server.clients = [mock_client] + mock_server.groups = [mock_group_1, mock_group_2] + mock_server.clients = [mock_client_1, mock_client_2] mock_server.streams = [mock_stream_1, mock_stream_2] - mock_server.group.return_value = mock_group - mock_server.client.return_value = mock_client def get_stream(identifier: str) -> AsyncMock: return {s.identifier: s for s in mock_server.streams}[identifier] + def get_group(identifier: str) -> AsyncMock: + return {s.identifier: s for s in mock_server.groups}[identifier] + + def get_client(identifier: str) -> AsyncMock: + return {s.identifier: s for s in mock_server.clients}[identifier] + mock_server.stream = get_stream + mock_server.group = get_group + mock_server.client = get_client + + mock_client_1.groups_available = lambda: mock_server.groups + mock_client_2.groups_available = lambda: mock_server.groups + yield mock_server @@ -74,34 +86,66 @@ async def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_group(stream: str, streams: dict[str, AsyncMock]) -> AsyncMock: +def mock_group_1(mock_stream_1: AsyncMock, streams: dict[str, AsyncMock]) -> AsyncMock: """Create a mock Snapgroup.""" group = AsyncMock(spec=Snapgroup) group.identifier = "4dcc4e3b-c699-a04b-7f0c-8260d23c43e1" - group.name = "test_group" - group.friendly_name = "test_group" - group.stream = stream + group.name = "test_group_1" + group.friendly_name = "Test Group 1" + group.stream = mock_stream_1.identifier group.muted = False - group.stream_status = streams[stream].status + group.stream_status = mock_stream_1.status group.volume = 48 group.streams_by_name.return_value = {s.friendly_name: s for s in streams.values()} return group @pytest.fixture -def mock_client(mock_group: AsyncMock) -> AsyncMock: +def mock_group_2(mock_stream_2: AsyncMock, streams: dict[str, AsyncMock]) -> AsyncMock: + """Create a mock Snapgroup.""" + group = AsyncMock(spec=Snapgroup) + group.identifier = "4dcc4e3b-c699-a04b-7f0c-8260d23c43e2" + group.name = "test_group_2" + group.friendly_name = "Test Group 2" + group.stream = mock_stream_2.identifier + group.muted = False + group.stream_status = mock_stream_2.status + group.volume = 65 + group.streams_by_name.return_value = {s.friendly_name: s for s in streams.values()} + return group + + +@pytest.fixture +def mock_client_1(mock_group_1: AsyncMock) -> AsyncMock: """Create a mock Snapclient.""" client = AsyncMock(spec=Snapclient) - client.identifier = "00:21:6a:7d:74:fc#2" - client.friendly_name = "test_client" + client.identifier = "00:21:6a:7d:74:fc#1" + client.friendly_name = "test_client_1" client.version = "0.10.0" client.connected = True - client.name = "Snapclient" + client.name = "Snapclient 1" client.latency = 6 client.muted = False client.volume = 48 - client.group = mock_group - mock_group.clients = [client.identifier] + client.group = mock_group_1 + mock_group_1.clients = [client.identifier] + return client + + +@pytest.fixture +def mock_client_2(mock_group_2: AsyncMock) -> AsyncMock: + """Create a mock Snapclient.""" + client = AsyncMock(spec=Snapclient) + client.identifier = "00:21:6a:7d:74:fc#2" + client.friendly_name = "test_client_2" + client.version = "0.10.0" + client.connected = True + client.name = "Snapclient 2" + client.latency = 6 + client.muted = False + client.volume = 100 + client.group = mock_group_2 + mock_group_2.clients = [client.identifier] return client @@ -149,17 +193,6 @@ def mock_stream_2() -> AsyncMock: return stream -@pytest.fixture( - params=[ - "test_stream_1", - "test_stream_2", - ] -) -def stream(request: pytest.FixtureRequest) -> Generator[str]: - """Return every device.""" - return request.param - - @pytest.fixture def streams(mock_stream_1: AsyncMock, mock_stream_2: AsyncMock) -> dict[str, AsyncMock]: """Return a dictionary of mock streams.""" diff --git a/tests/components/snapcast/snapshots/test_media_player.ambr b/tests/components/snapcast/snapshots/test_media_player.ambr index c497cdd861b..3e408a0f14e 100644 --- a/tests/components/snapcast/snapshots/test_media_player.ambr +++ b/tests/components/snapcast/snapshots/test_media_player.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_state[test_stream_1][media_player.test_client_snapcast_client-entry] +# name: test_state[media_player.test_client_1_snapcast_client-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17,7 +17,7 @@ 'disabled_by': None, 'domain': 'media_player', 'entity_category': None, - 'entity_id': 'media_player.test_client_snapcast_client', + 'entity_id': 'media_player.test_client_1_snapcast_client', 'has_entity_name': False, 'hidden_by': None, 'icon': None, @@ -29,22 +29,25 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'test_client Snapcast Client', + 'original_name': 'test_client_1 Snapcast Client', 'platform': 'snapcast', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, - 'unique_id': 'snapcast_client_127.0.0.1:1705_00:21:6a:7d:74:fc#2', + 'unique_id': 'snapcast_client_127.0.0.1:1705_00:21:6a:7d:74:fc#1', 'unit_of_measurement': None, }) # --- -# name: test_state[test_stream_1][media_player.test_client_snapcast_client-state] +# name: test_state[media_player.test_client_1_snapcast_client-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', - 'entity_picture': '/api/media_player_proxy/media_player.test_client_snapcast_client?token=mock_token&cache=6e2dee674d9d1dc7', - 'friendly_name': 'test_client Snapcast Client', + 'entity_picture': '/api/media_player_proxy/media_player.test_client_1_snapcast_client?token=mock_token&cache=6e2dee674d9d1dc7', + 'friendly_name': 'test_client_1 Snapcast Client', + 'group_members': list([ + 'media_player.test_client_1_snapcast_client', + ]), 'is_volume_muted': False, 'latency': 6, 'media_album_artist': 'Test Album Artist 1, Test Album Artist 2', @@ -60,18 +63,18 @@ 'Test Stream 1', 'Test Stream 2', ]), - 'supported_features': , + 'supported_features': , 'volume_level': 0.48, }), 'context': , - 'entity_id': 'media_player.test_client_snapcast_client', + 'entity_id': 'media_player.test_client_1_snapcast_client', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'playing', }) # --- -# name: test_state[test_stream_1][media_player.test_group_snapcast_group-entry] +# name: test_state[media_player.test_client_2_snapcast_client-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -89,7 +92,7 @@ 'disabled_by': None, 'domain': 'media_player', 'entity_category': None, - 'entity_id': 'media_player.test_group_snapcast_group', + 'entity_id': 'media_player.test_client_2_snapcast_client', 'has_entity_name': False, 'hidden_by': None, 'icon': None, @@ -101,7 +104,74 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'test_group Snapcast Group', + 'original_name': 'test_client_2 Snapcast Client', + 'platform': 'snapcast', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'snapcast_client_127.0.0.1:1705_00:21:6a:7d:74:fc#2', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[media_player.test_client_2_snapcast_client-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'test_client_2 Snapcast Client', + 'group_members': list([ + 'media_player.test_client_2_snapcast_client', + ]), + 'is_volume_muted': False, + 'latency': 6, + 'media_content_type': , + 'source': 'test_stream_2', + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + 'supported_features': , + 'volume_level': 1.0, + }), + 'context': , + 'entity_id': 'media_player.test_client_2_snapcast_client', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_state[media_player.test_group_1_snapcast_group-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_group_1_snapcast_group', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Test Group 1 Snapcast Group', 'platform': 'snapcast', 'previous_unique_id': None, 'suggested_object_id': None, @@ -111,12 +181,12 @@ 'unit_of_measurement': None, }) # --- -# name: test_state[test_stream_1][media_player.test_group_snapcast_group-state] +# name: test_state[media_player.test_group_1_snapcast_group-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', - 'entity_picture': '/api/media_player_proxy/media_player.test_group_snapcast_group?token=mock_token&cache=6e2dee674d9d1dc7', - 'friendly_name': 'test_group Snapcast Group', + 'entity_picture': '/api/media_player_proxy/media_player.test_group_1_snapcast_group?token=mock_token&cache=6e2dee674d9d1dc7', + 'friendly_name': 'Test Group 1 Snapcast Group', 'is_volume_muted': False, 'media_album_artist': 'Test Album Artist 1, Test Album Artist 2', 'media_album_name': 'Test Album', @@ -135,14 +205,14 @@ 'volume_level': 0.48, }), 'context': , - 'entity_id': 'media_player.test_group_snapcast_group', + 'entity_id': 'media_player.test_group_1_snapcast_group', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'playing', }) # --- -# name: test_state[test_stream_2][media_player.test_client_snapcast_client-entry] +# name: test_state[media_player.test_group_2_snapcast_group-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -160,7 +230,7 @@ 'disabled_by': None, 'domain': 'media_player', 'entity_category': None, - 'entity_id': 'media_player.test_client_snapcast_client', + 'entity_id': 'media_player.test_group_2_snapcast_group', 'has_entity_name': False, 'hidden_by': None, 'icon': None, @@ -172,85 +242,21 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'test_client Snapcast Client', + 'original_name': 'Test Group 2 Snapcast Group', 'platform': 'snapcast', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'snapcast_client_127.0.0.1:1705_00:21:6a:7d:74:fc#2', + 'unique_id': 'snapcast_group_127.0.0.1:1705_4dcc4e3b-c699-a04b-7f0c-8260d23c43e2', 'unit_of_measurement': None, }) # --- -# name: test_state[test_stream_2][media_player.test_client_snapcast_client-state] +# name: test_state[media_player.test_group_2_snapcast_group-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', - 'friendly_name': 'test_client Snapcast Client', - 'is_volume_muted': False, - 'latency': 6, - 'media_content_type': , - 'source': 'test_stream_2', - 'source_list': list([ - 'Test Stream 1', - 'Test Stream 2', - ]), - 'supported_features': , - 'volume_level': 0.48, - }), - 'context': , - 'entity_id': 'media_player.test_client_snapcast_client', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'idle', - }) -# --- -# name: test_state[test_stream_2][media_player.test_group_snapcast_group-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'source_list': list([ - 'Test Stream 1', - 'Test Stream 2', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'media_player', - 'entity_category': None, - 'entity_id': 'media_player.test_group_snapcast_group', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'test_group Snapcast Group', - 'platform': 'snapcast', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': 'snapcast_group_127.0.0.1:1705_4dcc4e3b-c699-a04b-7f0c-8260d23c43e1', - 'unit_of_measurement': None, - }) -# --- -# name: test_state[test_stream_2][media_player.test_group_snapcast_group-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'speaker', - 'friendly_name': 'test_group Snapcast Group', + 'friendly_name': 'Test Group 2 Snapcast Group', 'is_volume_muted': False, 'media_content_type': , 'source': 'test_stream_2', @@ -259,10 +265,10 @@ 'Test Stream 2', ]), 'supported_features': , - 'volume_level': 0.48, + 'volume_level': 0.65, }), 'context': , - 'entity_id': 'media_player.test_group_snapcast_group', + 'entity_id': 'media_player.test_group_2_snapcast_group', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/snapcast/test_media_player.py b/tests/components/snapcast/test_media_player.py index 57a8a865ddf..35605cb74ab 100644 --- a/tests/components/snapcast/test_media_player.py +++ b/tests/components/snapcast/test_media_player.py @@ -2,11 +2,23 @@ from unittest.mock import AsyncMock, patch +import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.media_player import ( + ATTR_GROUP_MEMBERS, + ATTR_MEDIA_VOLUME_LEVEL, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + SERVICE_UNJOIN, + SERVICE_VOLUME_SET, +) +from homeassistant.components.snapcast.const import ATTR_MASTER, DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er, issue_registry as ir from . import setup_integration @@ -28,3 +40,190 @@ async def test_state( assert mock_config_entry.state is ConfigEntryState.LOADED await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("members"), + [ + ["media_player.test_client_2_snapcast_client"], + [ + "media_player.test_client_1_snapcast_client", + "media_player.test_client_2_snapcast_client", + ], + ], +) +async def test_join( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_server: AsyncMock, + mock_group_1: AsyncMock, + mock_client_2: AsyncMock, + members: list[str], +) -> None: + """Test grouping of media players through the join service.""" + + # Setup and verify the integration is loaded + with patch("secrets.token_hex", return_value="mock_token"): + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_client_1_snapcast_client", + ATTR_GROUP_MEMBERS: members, + }, + blocking=True, + ) + mock_group_1.add_client.assert_awaited_once_with(mock_client_2.identifier) + + +async def test_unjoin( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_server: AsyncMock, + mock_client_1: AsyncMock, + mock_group_1: AsyncMock, +) -> None: + """Test the unjoin service removes the client from the group.""" + + # Setup and verify the integration is loaded + with patch("secrets.token_hex", return_value="mock_token"): + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_UNJOIN, + { + ATTR_ENTITY_ID: "media_player.test_client_1_snapcast_client", + }, + blocking=True, + ) + + mock_group_1.remove_client.assert_awaited_once_with(mock_client_1.identifier) + + +async def test_join_exception( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_create_server: AsyncMock, + mock_group_1: AsyncMock, +) -> None: + """Test join service throws an exception when trying to add a non-Snapcast client.""" + + # Create a dummy media player entity + entity_registry.async_get_or_create( + MEDIA_PLAYER_DOMAIN, + "dummy", + "media_player_1", + ) + await hass.async_block_till_done() + + # Setup and verify the integration is loaded + with patch("secrets.token_hex", return_value="mock_token"): + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_client_1_snapcast_client", + ATTR_GROUP_MEMBERS: ["media_player.dummy_media_player_1"], + }, + blocking=True, + ) + + # Ensure that the group did not attempt to add a non-Snapcast client + mock_group_1.add_client.assert_not_awaited() + + +async def test_legacy_join_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_config_entry: MockConfigEntry, + mock_create_server: AsyncMock, +) -> None: + """Test the legacy grouping services create issues when used.""" + + # Setup and verify the integration is loaded + with patch("secrets.token_hex", return_value="mock_token"): + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Call the legacy join service + await hass.services.async_call( + DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_client_2_snapcast_client", + ATTR_MASTER: "media_player.test_client_1_snapcast_client", + }, + blocking=True, + ) + + # Verify the issue is created + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id="deprecated_grouping_actions", + ) + assert issue is not None + + # Clear existing issue + issue_registry.async_delete( + domain=DOMAIN, + issue_id="deprecated_grouping_actions", + ) + + # Call legacy unjoin service + await hass.services.async_call( + DOMAIN, + SERVICE_UNJOIN, + { + ATTR_ENTITY_ID: "media_player.test_client_2_snapcast_client", + }, + blocking=True, + ) + + # Verify the issue is created again + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id="deprecated_grouping_actions", + ) + assert issue is not None + + +async def test_deprecated_group_entity_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_config_entry: MockConfigEntry, + mock_create_server: AsyncMock, +) -> None: + """Test the legacy group entities create issues when used.""" + + # Setup and verify the integration is loaded + with patch("secrets.token_hex", return_value="mock_token"): + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Call a servuce that uses a group entity + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + service_data={ + ATTR_ENTITY_ID: "media_player.test_group_1_snapcast_group", + ATTR_MEDIA_VOLUME_LEVEL: 0.5, + }, + blocking=True, + ) + + # Verify the issue is created + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id="deprecated_group_entities", + ) + assert issue is not None