Implement Snapcast grouping with standard HA actions (#146855)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Tucker Kern
2025-08-12 10:07:29 -06:00
committed by GitHub
parent ad3174f6e6
commit ca290ee631
6 changed files with 546 additions and 184 deletions

View File

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

View File

@@ -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_known_ids(known_ids, ids) -> tuple[set[str], set[str]]:
ids_to_add = ids - known_ids
ids_to_remove = known_ids - 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()}
# Update known IDs
ids_to_add = snapcast_ids - known_ids
ids_to_remove = known_ids - snapcast_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
)
# 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()

View File

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

View File

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

View File

@@ -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': <MediaPlayerDeviceClass.SPEAKER: 'speaker'>,
'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': <MediaPlayerEntityFeature: 2060>,
'supported_features': <MediaPlayerEntityFeature: 526348>,
'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': <MediaPlayerEntityFeature: 2060>,
'supported_features': <MediaPlayerEntityFeature: 526348>,
'volume_level': 0.48,
}),
'context': <ANY>,
'entity_id': 'media_player.test_client_snapcast_client',
'entity_id': 'media_player.test_client_1_snapcast_client',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <MediaPlayerDeviceClass.SPEAKER: 'speaker'>,
'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': <MediaPlayerEntityFeature: 526348>,
'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': <MediaType.MUSIC: 'music'>,
'source': 'test_stream_2',
'source_list': list([
'Test Stream 1',
'Test Stream 2',
]),
'supported_features': <MediaPlayerEntityFeature: 526348>,
'volume_level': 1.0,
}),
'context': <ANY>,
'entity_id': 'media_player.test_client_2_snapcast_client',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <MediaPlayerDeviceClass.SPEAKER: 'speaker'>,
'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': <ANY>,
'entity_id': 'media_player.test_group_snapcast_group',
'entity_id': 'media_player.test_group_1_snapcast_group',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <MediaPlayerDeviceClass.SPEAKER: 'speaker'>,
'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': <MediaPlayerEntityFeature: 2060>,
'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': <MediaType.MUSIC: 'music'>,
'source': 'test_stream_2',
'source_list': list([
'Test Stream 1',
'Test Stream 2',
]),
'supported_features': <MediaPlayerEntityFeature: 2060>,
'volume_level': 0.48,
}),
'context': <ANY>,
'entity_id': 'media_player.test_client_snapcast_client',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <MediaPlayerDeviceClass.SPEAKER: 'speaker'>,
'original_icon': None,
'original_name': 'test_group Snapcast Group',
'platform': 'snapcast',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <MediaPlayerEntityFeature: 2060>,
'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': <MediaType.MUSIC: 'music'>,
'source': 'test_stream_2',
@@ -259,10 +265,10 @@
'Test Stream 2',
]),
'supported_features': <MediaPlayerEntityFeature: 2060>,
'volume_level': 0.48,
'volume_level': 0.65,
}),
'context': <ANY>,
'entity_id': 'media_player.test_group_snapcast_group',
'entity_id': 'media_player.test_group_2_snapcast_group',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,

View File

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