mirror of
https://github.com/home-assistant/core.git
synced 2025-09-08 14:21:33 +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_connect_callback(self._on_connect)
|
||||||
self._server.set_on_disconnect_callback(self._on_disconnect)
|
self._server.set_on_disconnect_callback(self._on_disconnect)
|
||||||
|
|
||||||
|
self._host_id = f"{host}:{port}"
|
||||||
|
|
||||||
def _on_update(self) -> None:
|
def _on_update(self) -> None:
|
||||||
"""Snapserver on_update callback."""
|
"""Snapserver on_update callback."""
|
||||||
# Assume availability if an update is received.
|
# Assume availability if an update is received.
|
||||||
@@ -77,3 +79,8 @@ class SnapcastUpdateCoordinator(DataUpdateCoordinator[None]):
|
|||||||
def server(self) -> Snapserver:
|
def server(self) -> Snapserver:
|
||||||
"""Get the Snapserver object."""
|
"""Get the Snapserver object."""
|
||||||
return self._server
|
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 __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Callable, Mapping
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -19,13 +19,13 @@ from homeassistant.components.media_player import (
|
|||||||
MediaType,
|
MediaType,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import ServiceValidationError
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
from homeassistant.helpers import (
|
from homeassistant.helpers import (
|
||||||
config_validation as cv,
|
config_validation as cv,
|
||||||
entity_platform,
|
entity_platform,
|
||||||
entity_registry as er,
|
entity_registry as er,
|
||||||
|
issue_registry as ir,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
@@ -52,6 +52,12 @@ STREAM_STATUS = {
|
|||||||
"unknown": None,
|
"unknown": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_SUPPORTED_FEATURES = (
|
||||||
|
MediaPlayerEntityFeature.VOLUME_MUTE
|
||||||
|
| MediaPlayerEntityFeature.VOLUME_SET
|
||||||
|
| MediaPlayerEntityFeature.SELECT_SOURCE
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -82,106 +88,91 @@ async def async_setup_entry(
|
|||||||
# Fetch coordinator from global data
|
# Fetch coordinator from global data
|
||||||
coordinator: SnapcastUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
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()
|
register_services()
|
||||||
|
|
||||||
_known_group_ids: set[str] = set()
|
_known_group_ids: set[str] = set()
|
||||||
_known_client_ids: set[str] = set()
|
_known_client_ids: set[str] = set()
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _check_entities() -> None:
|
def _update_entities(
|
||||||
nonlocal _known_group_ids, _known_client_ids
|
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]]:
|
# Update known IDs
|
||||||
ids_to_add = ids - known_ids
|
ids_to_add = snapcast_ids - known_ids
|
||||||
ids_to_remove = known_ids - ids
|
ids_to_remove = known_ids - snapcast_ids
|
||||||
|
|
||||||
# Update known IDs
|
known_ids.difference_update(ids_to_remove)
|
||||||
known_ids.difference_update(ids_to_remove)
|
known_ids.update(ids_to_add)
|
||||||
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
|
# 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
|
return
|
||||||
|
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"New clients: %s",
|
"New %s: %s",
|
||||||
str([coordinator.server.client(c).friendly_name for c in clients_to_add]),
|
entity_class,
|
||||||
|
str([get_device(d).friendly_name for d in ids_to_add]),
|
||||||
)
|
)
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"New groups: %s",
|
"Remove %s IDs: %s",
|
||||||
str([coordinator.server.group(g).friendly_name for g in groups_to_add]),
|
entity_class,
|
||||||
)
|
str([list(ids_to_remove)]),
|
||||||
_LOGGER.debug(
|
|
||||||
"Remove client IDs: %s",
|
|
||||||
str([list(clients_to_remove)]),
|
|
||||||
)
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Remove group IDs: %s",
|
|
||||||
str(list(groups_to_remove)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add new entities
|
# Add new entities
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
[
|
[
|
||||||
SnapcastGroupDevice(
|
entity_class(coordinator, get_device(snapcast_id))
|
||||||
coordinator, coordinator.server.group(group_id), host_id
|
for snapcast_id in ids_to_add
|
||||||
)
|
|
||||||
for group_id in groups_to_add
|
|
||||||
]
|
|
||||||
+ [
|
|
||||||
SnapcastClientDevice(
|
|
||||||
coordinator, coordinator.server.client(client_id), host_id
|
|
||||||
)
|
|
||||||
for client_id in clients_to_add
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Remove stale entities
|
# Remove stale entities
|
||||||
entity_registry = er.async_get(hass)
|
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(
|
if entity_id := entity_registry.async_get_entity_id(
|
||||||
MEDIA_PLAYER_DOMAIN,
|
MEDIA_PLAYER_DOMAIN,
|
||||||
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)
|
entity_registry.async_remove(entity_id)
|
||||||
|
|
||||||
for client_id in clients_to_remove:
|
def _update_clients() -> None:
|
||||||
if entity_id := entity_registry.async_get_entity_id(
|
_update_entities(
|
||||||
MEDIA_PLAYER_DOMAIN,
|
SnapcastClientDevice,
|
||||||
DOMAIN,
|
_known_client_ids,
|
||||||
SnapcastClientDevice.get_unique_id(host_id, client_id),
|
coordinator.server.client,
|
||||||
):
|
lambda: coordinator.server.clients,
|
||||||
entity_registry.async_remove(entity_id)
|
)
|
||||||
|
|
||||||
coordinator.async_add_listener(_check_entities)
|
# Create client entities and add listener to update clients on server update
|
||||||
_check_entities()
|
_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):
|
class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity):
|
||||||
"""Base class representing a Snapcast device."""
|
"""Base class representing a Snapcast device."""
|
||||||
|
|
||||||
_attr_should_poll = False
|
_attr_should_poll = False
|
||||||
_attr_supported_features = (
|
_attr_supported_features = _SUPPORTED_FEATURES
|
||||||
MediaPlayerEntityFeature.VOLUME_MUTE
|
|
||||||
| MediaPlayerEntityFeature.VOLUME_SET
|
|
||||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
|
||||||
)
|
|
||||||
_attr_media_content_type = MediaType.MUSIC
|
_attr_media_content_type = MediaType.MUSIC
|
||||||
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
|
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
|
||||||
|
|
||||||
@@ -189,13 +180,14 @@ class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity):
|
|||||||
self,
|
self,
|
||||||
coordinator: SnapcastUpdateCoordinator,
|
coordinator: SnapcastUpdateCoordinator,
|
||||||
device: Snapgroup | Snapclient,
|
device: Snapgroup | Snapclient,
|
||||||
host_id: str,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the base device."""
|
"""Initialize the base device."""
|
||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
|
|
||||||
self._device = device
|
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
|
@classmethod
|
||||||
def get_unique_id(cls, host, id) -> str:
|
def get_unique_id(cls, host, id) -> str:
|
||||||
@@ -279,6 +271,19 @@ class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity):
|
|||||||
"""Handle the unjoin service."""
|
"""Handle the unjoin service."""
|
||||||
raise NotImplementedError
|
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
|
@property
|
||||||
def metadata(self) -> Mapping[str, Any]:
|
def metadata(self) -> Mapping[str, Any]:
|
||||||
"""Get metadata from the current stream."""
|
"""Get metadata from the current stream."""
|
||||||
@@ -389,11 +394,62 @@ class SnapcastGroupDevice(SnapcastBaseDevice):
|
|||||||
"""Handle the unjoin service."""
|
"""Handle the unjoin service."""
|
||||||
raise ServiceValidationError("Entity is not a client. Can only unjoin clients.")
|
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):
|
class SnapcastClientDevice(SnapcastBaseDevice):
|
||||||
"""Representation of a Snapcast client device."""
|
"""Representation of a Snapcast client device."""
|
||||||
|
|
||||||
_device: Snapclient
|
_device: Snapclient
|
||||||
|
_attr_supported_features = (
|
||||||
|
_SUPPORTED_FEATURES | MediaPlayerEntityFeature.GROUPING
|
||||||
|
) # Clients support grouping
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_unique_id(cls, host, id) -> str:
|
def get_unique_id(cls, host, id) -> str:
|
||||||
@@ -439,6 +495,9 @@ class SnapcastClientDevice(SnapcastBaseDevice):
|
|||||||
|
|
||||||
async def async_join(self, master) -> None:
|
async def async_join(self, master) -> None:
|
||||||
"""Join the group of the master player."""
|
"""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)
|
entity_registry = er.async_get(self.hass)
|
||||||
master_entity = entity_registry.async_get(master)
|
master_entity = entity_registry.async_get(master)
|
||||||
if master_entity is None:
|
if master_entity is None:
|
||||||
@@ -463,5 +522,53 @@ class SnapcastClientDevice(SnapcastBaseDevice):
|
|||||||
|
|
||||||
async def async_unjoin(self) -> None:
|
async def async_unjoin(self) -> None:
|
||||||
"""Unjoin the group the player is currently in."""
|
"""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)
|
await self._current_group.remove_client(self._device.identifier)
|
||||||
self.async_write_ha_state()
|
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
|
@pytest.fixture
|
||||||
def mock_create_server(
|
def mock_create_server(
|
||||||
mock_group: AsyncMock,
|
mock_group_1: AsyncMock,
|
||||||
mock_client: AsyncMock,
|
mock_group_2: AsyncMock,
|
||||||
|
mock_client_1: AsyncMock,
|
||||||
|
mock_client_2: AsyncMock,
|
||||||
mock_stream_1: AsyncMock,
|
mock_stream_1: AsyncMock,
|
||||||
mock_stream_2: AsyncMock,
|
mock_stream_2: AsyncMock,
|
||||||
) -> Generator[AsyncMock]:
|
) -> Generator[AsyncMock]:
|
||||||
@@ -46,16 +48,26 @@ def mock_create_server(
|
|||||||
"homeassistant.components.snapcast.coordinator.Snapserver", autospec=True
|
"homeassistant.components.snapcast.coordinator.Snapserver", autospec=True
|
||||||
) as mock_snapserver:
|
) as mock_snapserver:
|
||||||
mock_server = mock_snapserver.return_value
|
mock_server = mock_snapserver.return_value
|
||||||
mock_server.groups = [mock_group]
|
mock_server.groups = [mock_group_1, mock_group_2]
|
||||||
mock_server.clients = [mock_client]
|
mock_server.clients = [mock_client_1, mock_client_2]
|
||||||
mock_server.streams = [mock_stream_1, mock_stream_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:
|
def get_stream(identifier: str) -> AsyncMock:
|
||||||
return {s.identifier: s for s in mock_server.streams}[identifier]
|
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.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
|
yield mock_server
|
||||||
|
|
||||||
|
|
||||||
@@ -74,34 +86,66 @@ async def mock_config_entry() -> MockConfigEntry:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@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."""
|
"""Create a mock Snapgroup."""
|
||||||
group = AsyncMock(spec=Snapgroup)
|
group = AsyncMock(spec=Snapgroup)
|
||||||
group.identifier = "4dcc4e3b-c699-a04b-7f0c-8260d23c43e1"
|
group.identifier = "4dcc4e3b-c699-a04b-7f0c-8260d23c43e1"
|
||||||
group.name = "test_group"
|
group.name = "test_group_1"
|
||||||
group.friendly_name = "test_group"
|
group.friendly_name = "Test Group 1"
|
||||||
group.stream = stream
|
group.stream = mock_stream_1.identifier
|
||||||
group.muted = False
|
group.muted = False
|
||||||
group.stream_status = streams[stream].status
|
group.stream_status = mock_stream_1.status
|
||||||
group.volume = 48
|
group.volume = 48
|
||||||
group.streams_by_name.return_value = {s.friendly_name: s for s in streams.values()}
|
group.streams_by_name.return_value = {s.friendly_name: s for s in streams.values()}
|
||||||
return group
|
return group
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@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."""
|
"""Create a mock Snapclient."""
|
||||||
client = AsyncMock(spec=Snapclient)
|
client = AsyncMock(spec=Snapclient)
|
||||||
client.identifier = "00:21:6a:7d:74:fc#2"
|
client.identifier = "00:21:6a:7d:74:fc#1"
|
||||||
client.friendly_name = "test_client"
|
client.friendly_name = "test_client_1"
|
||||||
client.version = "0.10.0"
|
client.version = "0.10.0"
|
||||||
client.connected = True
|
client.connected = True
|
||||||
client.name = "Snapclient"
|
client.name = "Snapclient 1"
|
||||||
client.latency = 6
|
client.latency = 6
|
||||||
client.muted = False
|
client.muted = False
|
||||||
client.volume = 48
|
client.volume = 48
|
||||||
client.group = mock_group
|
client.group = mock_group_1
|
||||||
mock_group.clients = [client.identifier]
|
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
|
return client
|
||||||
|
|
||||||
|
|
||||||
@@ -149,17 +193,6 @@ def mock_stream_2() -> AsyncMock:
|
|||||||
return stream
|
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
|
@pytest.fixture
|
||||||
def streams(mock_stream_1: AsyncMock, mock_stream_2: AsyncMock) -> dict[str, AsyncMock]:
|
def streams(mock_stream_1: AsyncMock, mock_stream_2: AsyncMock) -> dict[str, AsyncMock]:
|
||||||
"""Return a dictionary of mock streams."""
|
"""Return a dictionary of mock streams."""
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
# serializer version: 1
|
# 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({
|
EntityRegistryEntrySnapshot({
|
||||||
'aliases': set({
|
'aliases': set({
|
||||||
}),
|
}),
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
'disabled_by': None,
|
'disabled_by': None,
|
||||||
'domain': 'media_player',
|
'domain': 'media_player',
|
||||||
'entity_category': None,
|
'entity_category': None,
|
||||||
'entity_id': 'media_player.test_client_snapcast_client',
|
'entity_id': 'media_player.test_client_1_snapcast_client',
|
||||||
'has_entity_name': False,
|
'has_entity_name': False,
|
||||||
'hidden_by': None,
|
'hidden_by': None,
|
||||||
'icon': None,
|
'icon': None,
|
||||||
@@ -29,22 +29,25 @@
|
|||||||
}),
|
}),
|
||||||
'original_device_class': <MediaPlayerDeviceClass.SPEAKER: 'speaker'>,
|
'original_device_class': <MediaPlayerDeviceClass.SPEAKER: 'speaker'>,
|
||||||
'original_icon': None,
|
'original_icon': None,
|
||||||
'original_name': 'test_client Snapcast Client',
|
'original_name': 'test_client_1 Snapcast Client',
|
||||||
'platform': 'snapcast',
|
'platform': 'snapcast',
|
||||||
'previous_unique_id': None,
|
'previous_unique_id': None,
|
||||||
'suggested_object_id': None,
|
'suggested_object_id': None,
|
||||||
'supported_features': <MediaPlayerEntityFeature: 2060>,
|
'supported_features': <MediaPlayerEntityFeature: 526348>,
|
||||||
'translation_key': None,
|
'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,
|
'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({
|
StateSnapshot({
|
||||||
'attributes': ReadOnlyDict({
|
'attributes': ReadOnlyDict({
|
||||||
'device_class': 'speaker',
|
'device_class': 'speaker',
|
||||||
'entity_picture': '/api/media_player_proxy/media_player.test_client_snapcast_client?token=mock_token&cache=6e2dee674d9d1dc7',
|
'entity_picture': '/api/media_player_proxy/media_player.test_client_1_snapcast_client?token=mock_token&cache=6e2dee674d9d1dc7',
|
||||||
'friendly_name': 'test_client Snapcast Client',
|
'friendly_name': 'test_client_1 Snapcast Client',
|
||||||
|
'group_members': list([
|
||||||
|
'media_player.test_client_1_snapcast_client',
|
||||||
|
]),
|
||||||
'is_volume_muted': False,
|
'is_volume_muted': False,
|
||||||
'latency': 6,
|
'latency': 6,
|
||||||
'media_album_artist': 'Test Album Artist 1, Test Album Artist 2',
|
'media_album_artist': 'Test Album Artist 1, Test Album Artist 2',
|
||||||
@@ -60,18 +63,18 @@
|
|||||||
'Test Stream 1',
|
'Test Stream 1',
|
||||||
'Test Stream 2',
|
'Test Stream 2',
|
||||||
]),
|
]),
|
||||||
'supported_features': <MediaPlayerEntityFeature: 2060>,
|
'supported_features': <MediaPlayerEntityFeature: 526348>,
|
||||||
'volume_level': 0.48,
|
'volume_level': 0.48,
|
||||||
}),
|
}),
|
||||||
'context': <ANY>,
|
'context': <ANY>,
|
||||||
'entity_id': 'media_player.test_client_snapcast_client',
|
'entity_id': 'media_player.test_client_1_snapcast_client',
|
||||||
'last_changed': <ANY>,
|
'last_changed': <ANY>,
|
||||||
'last_reported': <ANY>,
|
'last_reported': <ANY>,
|
||||||
'last_updated': <ANY>,
|
'last_updated': <ANY>,
|
||||||
'state': 'playing',
|
'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({
|
EntityRegistryEntrySnapshot({
|
||||||
'aliases': set({
|
'aliases': set({
|
||||||
}),
|
}),
|
||||||
@@ -89,7 +92,7 @@
|
|||||||
'disabled_by': None,
|
'disabled_by': None,
|
||||||
'domain': 'media_player',
|
'domain': 'media_player',
|
||||||
'entity_category': None,
|
'entity_category': None,
|
||||||
'entity_id': 'media_player.test_group_snapcast_group',
|
'entity_id': 'media_player.test_client_2_snapcast_client',
|
||||||
'has_entity_name': False,
|
'has_entity_name': False,
|
||||||
'hidden_by': None,
|
'hidden_by': None,
|
||||||
'icon': None,
|
'icon': None,
|
||||||
@@ -101,7 +104,74 @@
|
|||||||
}),
|
}),
|
||||||
'original_device_class': <MediaPlayerDeviceClass.SPEAKER: 'speaker'>,
|
'original_device_class': <MediaPlayerDeviceClass.SPEAKER: 'speaker'>,
|
||||||
'original_icon': None,
|
'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',
|
'platform': 'snapcast',
|
||||||
'previous_unique_id': None,
|
'previous_unique_id': None,
|
||||||
'suggested_object_id': None,
|
'suggested_object_id': None,
|
||||||
@@ -111,12 +181,12 @@
|
|||||||
'unit_of_measurement': None,
|
'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({
|
StateSnapshot({
|
||||||
'attributes': ReadOnlyDict({
|
'attributes': ReadOnlyDict({
|
||||||
'device_class': 'speaker',
|
'device_class': 'speaker',
|
||||||
'entity_picture': '/api/media_player_proxy/media_player.test_group_snapcast_group?token=mock_token&cache=6e2dee674d9d1dc7',
|
'entity_picture': '/api/media_player_proxy/media_player.test_group_1_snapcast_group?token=mock_token&cache=6e2dee674d9d1dc7',
|
||||||
'friendly_name': 'test_group Snapcast Group',
|
'friendly_name': 'Test Group 1 Snapcast Group',
|
||||||
'is_volume_muted': False,
|
'is_volume_muted': False,
|
||||||
'media_album_artist': 'Test Album Artist 1, Test Album Artist 2',
|
'media_album_artist': 'Test Album Artist 1, Test Album Artist 2',
|
||||||
'media_album_name': 'Test Album',
|
'media_album_name': 'Test Album',
|
||||||
@@ -135,14 +205,14 @@
|
|||||||
'volume_level': 0.48,
|
'volume_level': 0.48,
|
||||||
}),
|
}),
|
||||||
'context': <ANY>,
|
'context': <ANY>,
|
||||||
'entity_id': 'media_player.test_group_snapcast_group',
|
'entity_id': 'media_player.test_group_1_snapcast_group',
|
||||||
'last_changed': <ANY>,
|
'last_changed': <ANY>,
|
||||||
'last_reported': <ANY>,
|
'last_reported': <ANY>,
|
||||||
'last_updated': <ANY>,
|
'last_updated': <ANY>,
|
||||||
'state': 'playing',
|
'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({
|
EntityRegistryEntrySnapshot({
|
||||||
'aliases': set({
|
'aliases': set({
|
||||||
}),
|
}),
|
||||||
@@ -160,7 +230,7 @@
|
|||||||
'disabled_by': None,
|
'disabled_by': None,
|
||||||
'domain': 'media_player',
|
'domain': 'media_player',
|
||||||
'entity_category': None,
|
'entity_category': None,
|
||||||
'entity_id': 'media_player.test_client_snapcast_client',
|
'entity_id': 'media_player.test_group_2_snapcast_group',
|
||||||
'has_entity_name': False,
|
'has_entity_name': False,
|
||||||
'hidden_by': None,
|
'hidden_by': None,
|
||||||
'icon': None,
|
'icon': None,
|
||||||
@@ -172,85 +242,21 @@
|
|||||||
}),
|
}),
|
||||||
'original_device_class': <MediaPlayerDeviceClass.SPEAKER: 'speaker'>,
|
'original_device_class': <MediaPlayerDeviceClass.SPEAKER: 'speaker'>,
|
||||||
'original_icon': None,
|
'original_icon': None,
|
||||||
'original_name': 'test_client Snapcast Client',
|
'original_name': 'Test Group 2 Snapcast Group',
|
||||||
'platform': 'snapcast',
|
'platform': 'snapcast',
|
||||||
'previous_unique_id': None,
|
'previous_unique_id': None,
|
||||||
'suggested_object_id': None,
|
'suggested_object_id': None,
|
||||||
'supported_features': <MediaPlayerEntityFeature: 2060>,
|
'supported_features': <MediaPlayerEntityFeature: 2060>,
|
||||||
'translation_key': None,
|
'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,
|
'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({
|
StateSnapshot({
|
||||||
'attributes': ReadOnlyDict({
|
'attributes': ReadOnlyDict({
|
||||||
'device_class': 'speaker',
|
'device_class': 'speaker',
|
||||||
'friendly_name': 'test_client Snapcast Client',
|
'friendly_name': 'Test Group 2 Snapcast Group',
|
||||||
'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',
|
|
||||||
'is_volume_muted': False,
|
'is_volume_muted': False,
|
||||||
'media_content_type': <MediaType.MUSIC: 'music'>,
|
'media_content_type': <MediaType.MUSIC: 'music'>,
|
||||||
'source': 'test_stream_2',
|
'source': 'test_stream_2',
|
||||||
@@ -259,10 +265,10 @@
|
|||||||
'Test Stream 2',
|
'Test Stream 2',
|
||||||
]),
|
]),
|
||||||
'supported_features': <MediaPlayerEntityFeature: 2060>,
|
'supported_features': <MediaPlayerEntityFeature: 2060>,
|
||||||
'volume_level': 0.48,
|
'volume_level': 0.65,
|
||||||
}),
|
}),
|
||||||
'context': <ANY>,
|
'context': <ANY>,
|
||||||
'entity_id': 'media_player.test_group_snapcast_group',
|
'entity_id': 'media_player.test_group_2_snapcast_group',
|
||||||
'last_changed': <ANY>,
|
'last_changed': <ANY>,
|
||||||
'last_reported': <ANY>,
|
'last_reported': <ANY>,
|
||||||
'last_updated': <ANY>,
|
'last_updated': <ANY>,
|
||||||
|
@@ -2,11 +2,23 @@
|
|||||||
|
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
from syrupy.assertion import SnapshotAssertion
|
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.config_entries import ConfigEntryState
|
||||||
|
from homeassistant.const import ATTR_ENTITY_ID
|
||||||
from homeassistant.core import HomeAssistant
|
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
|
from . import setup_integration
|
||||||
|
|
||||||
@@ -28,3 +40,190 @@ async def test_state(
|
|||||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||||
|
|
||||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
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