mirror of
https://github.com/home-assistant/core.git
synced 2025-09-07 22:01:34 +02:00
Implement Snapcast grouping with standard HA actions (#146855)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
@@ -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
|
||||
|
@@ -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()
|
||||
|
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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."""
|
||||
|
@@ -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>,
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user