mirror of
https://github.com/home-assistant/core.git
synced 2025-07-17 02:07:09 +00:00
Refactor Snapcast client and group classes to use a common base clase (#124499)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
parent
b6b340ae63
commit
b3ff8f56b9
@ -1,37 +1,28 @@
|
|||||||
"""Snapcast Integration."""
|
"""Snapcast Integration."""
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import snapcast.control
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
|
|
||||||
from .const import DOMAIN, PLATFORMS
|
from .const import DOMAIN, PLATFORMS
|
||||||
from .server import HomeAssistantSnapcast
|
from .coordinator import SnapcastUpdateCoordinator
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up Snapcast from a config entry."""
|
"""Set up Snapcast from a config entry."""
|
||||||
host = entry.data[CONF_HOST]
|
host = entry.data[CONF_HOST]
|
||||||
port = entry.data[CONF_PORT]
|
port = entry.data[CONF_PORT]
|
||||||
|
coordinator = SnapcastUpdateCoordinator(hass, host, port)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
server = await snapcast.control.create_server(
|
await coordinator.async_config_entry_first_refresh()
|
||||||
hass.loop, host, port, reconnect=True
|
|
||||||
)
|
|
||||||
except OSError as ex:
|
except OSError as ex:
|
||||||
raise ConfigEntryNotReady(
|
raise ConfigEntryNotReady(
|
||||||
f"Could not connect to Snapcast server at {host}:{port}"
|
f"Could not connect to Snapcast server at {host}:{port}"
|
||||||
) from ex
|
) from ex
|
||||||
|
|
||||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantSnapcast(
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||||
hass, server, f"{host}:{port}", entry.entry_id
|
|
||||||
)
|
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
72
homeassistant/components/snapcast/coordinator.py
Normal file
72
homeassistant/components/snapcast/coordinator.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
"""Data update coordinator for Snapcast server."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from snapcast.control.server import Snapserver
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SnapcastUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||||
|
"""Data update coordinator for pushed data from Snapcast server."""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, host: str, port: int) -> None:
|
||||||
|
"""Initialize coordinator."""
|
||||||
|
super().__init__(
|
||||||
|
hass,
|
||||||
|
logger=_LOGGER,
|
||||||
|
name=f"{host}:{port}",
|
||||||
|
update_interval=None, # Disable update interval as server pushes
|
||||||
|
)
|
||||||
|
|
||||||
|
self._server = Snapserver(hass.loop, host, port, True)
|
||||||
|
self.last_update_success = False
|
||||||
|
|
||||||
|
self._server.set_on_update_callback(self._on_update)
|
||||||
|
self._server.set_new_client_callback(self._on_update)
|
||||||
|
self._server.set_on_connect_callback(self._on_connect)
|
||||||
|
self._server.set_on_disconnect_callback(self._on_disconnect)
|
||||||
|
|
||||||
|
def _on_update(self) -> None:
|
||||||
|
"""Snapserver on_update callback."""
|
||||||
|
# Assume availability if an update is received.
|
||||||
|
self.last_update_success = True
|
||||||
|
self.async_update_listeners()
|
||||||
|
|
||||||
|
def _on_connect(self) -> None:
|
||||||
|
"""Snapserver on_connect callback."""
|
||||||
|
self.last_update_success = True
|
||||||
|
self.async_update_listeners()
|
||||||
|
|
||||||
|
def _on_disconnect(self, ex):
|
||||||
|
"""Snapsever on_disconnect callback."""
|
||||||
|
self.async_set_update_error(ex)
|
||||||
|
|
||||||
|
async def _async_setup(self) -> None:
|
||||||
|
"""Perform async setup for the coordinator."""
|
||||||
|
# Start the server
|
||||||
|
try:
|
||||||
|
await self._server.start()
|
||||||
|
except OSError as ex:
|
||||||
|
raise UpdateFailed from ex
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> None:
|
||||||
|
"""Empty update method since data is pushed."""
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
"""Disconnect from the server."""
|
||||||
|
self._server.set_on_update_callback(None)
|
||||||
|
self._server.set_on_connect_callback(None)
|
||||||
|
self._server.set_on_disconnect_callback(None)
|
||||||
|
self._server.set_new_client_callback(None)
|
||||||
|
self._server.stop()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def server(self) -> Snapserver:
|
||||||
|
"""Get the Snapserver object."""
|
||||||
|
return self._server
|
11
homeassistant/components/snapcast/entity.py
Normal file
11
homeassistant/components/snapcast/entity.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
"""Coordinator entity for Snapcast server."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .coordinator import SnapcastUpdateCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
class SnapcastCoordinatorEntity(CoordinatorEntity[SnapcastUpdateCoordinator]):
|
||||||
|
"""Coordinator entity for Snapcast."""
|
@ -2,18 +2,29 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from snapcast.control.server import Snapserver
|
from collections.abc import Mapping
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from snapcast.control.client import Snapclient
|
||||||
|
from snapcast.control.group import Snapgroup
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.media_player import (
|
from homeassistant.components.media_player import (
|
||||||
|
DOMAIN as MEDIA_PLAYER_DOMAIN,
|
||||||
MediaPlayerEntity,
|
MediaPlayerEntity,
|
||||||
MediaPlayerEntityFeature,
|
MediaPlayerEntityFeature,
|
||||||
MediaPlayerState,
|
MediaPlayerState,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
|
from homeassistant.helpers import (
|
||||||
|
config_validation as cv,
|
||||||
|
entity_platform,
|
||||||
|
entity_registry as er,
|
||||||
|
)
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
@ -30,6 +41,8 @@ from .const import (
|
|||||||
SERVICE_SNAPSHOT,
|
SERVICE_SNAPSHOT,
|
||||||
SERVICE_UNJOIN,
|
SERVICE_UNJOIN,
|
||||||
)
|
)
|
||||||
|
from .coordinator import SnapcastUpdateCoordinator
|
||||||
|
from .entity import SnapcastCoordinatorEntity
|
||||||
|
|
||||||
STREAM_STATUS = {
|
STREAM_STATUS = {
|
||||||
"idle": MediaPlayerState.IDLE,
|
"idle": MediaPlayerState.IDLE,
|
||||||
@ -37,21 +50,23 @@ STREAM_STATUS = {
|
|||||||
"unknown": None,
|
"unknown": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
def register_services():
|
|
||||||
|
def register_services() -> None:
|
||||||
"""Register snapcast services."""
|
"""Register snapcast services."""
|
||||||
platform = entity_platform.async_get_current_platform()
|
platform = entity_platform.async_get_current_platform()
|
||||||
|
|
||||||
platform.async_register_entity_service(SERVICE_SNAPSHOT, None, "snapshot")
|
platform.async_register_entity_service(SERVICE_SNAPSHOT, None, "snapshot")
|
||||||
platform.async_register_entity_service(SERVICE_RESTORE, None, "async_restore")
|
platform.async_register_entity_service(SERVICE_RESTORE, None, "async_restore")
|
||||||
platform.async_register_entity_service(
|
platform.async_register_entity_service(
|
||||||
SERVICE_JOIN, {vol.Required(ATTR_MASTER): cv.entity_id}, handle_async_join
|
SERVICE_JOIN, {vol.Required(ATTR_MASTER): cv.entity_id}, "async_join"
|
||||||
)
|
)
|
||||||
platform.async_register_entity_service(SERVICE_UNJOIN, None, handle_async_unjoin)
|
platform.async_register_entity_service(SERVICE_UNJOIN, None, "async_unjoin")
|
||||||
platform.async_register_entity_service(
|
platform.async_register_entity_service(
|
||||||
SERVICE_SET_LATENCY,
|
SERVICE_SET_LATENCY,
|
||||||
{vol.Required(ATTR_LATENCY): cv.positive_int},
|
{vol.Required(ATTR_LATENCY): cv.positive_int},
|
||||||
handle_set_latency,
|
"async_set_latency",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -61,51 +76,103 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the snapcast config entry."""
|
"""Set up the snapcast config entry."""
|
||||||
snapcast_server: Snapserver = hass.data[DOMAIN][config_entry.entry_id].server
|
|
||||||
|
# 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()
|
register_services()
|
||||||
|
|
||||||
host = config_entry.data[CONF_HOST]
|
_known_group_ids: set[str] = set()
|
||||||
port = config_entry.data[CONF_PORT]
|
_known_client_ids: set[str] = set()
|
||||||
hpid = f"{host}:{port}"
|
|
||||||
|
|
||||||
groups: list[MediaPlayerEntity] = [
|
@callback
|
||||||
SnapcastGroupDevice(group, hpid, config_entry.entry_id)
|
def _check_entities() -> None:
|
||||||
for group in snapcast_server.groups
|
nonlocal _known_group_ids, _known_client_ids
|
||||||
]
|
|
||||||
clients: list[MediaPlayerEntity] = [
|
def _update_known_ids(known_ids, ids) -> tuple[set[str], set[str]]:
|
||||||
SnapcastClientDevice(client, hpid, config_entry.entry_id)
|
ids_to_add = ids - known_ids
|
||||||
for client in snapcast_server.clients
|
ids_to_remove = known_ids - ids
|
||||||
]
|
|
||||||
async_add_entities(clients + groups)
|
# Update known IDs
|
||||||
hass.data[DOMAIN][
|
known_ids.difference_update(ids_to_remove)
|
||||||
config_entry.entry_id
|
known_ids.update(ids_to_add)
|
||||||
].hass_async_add_entities = async_add_entities
|
|
||||||
|
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):
|
||||||
|
return
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"New clients: %s",
|
||||||
|
str([coordinator.server.client(c).friendly_name for c in clients_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)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove stale entities
|
||||||
|
entity_registry = er.async_get(hass)
|
||||||
|
for group_id in groups_to_remove:
|
||||||
|
if entity_id := entity_registry.async_get_entity_id(
|
||||||
|
MEDIA_PLAYER_DOMAIN,
|
||||||
|
DOMAIN,
|
||||||
|
SnapcastGroupDevice.get_unique_id(host_id, group_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)
|
||||||
|
|
||||||
|
coordinator.async_add_listener(_check_entities)
|
||||||
|
_check_entities()
|
||||||
|
|
||||||
|
|
||||||
async def handle_async_join(entity, service_call):
|
class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity):
|
||||||
"""Handle the entity service join."""
|
"""Base class representing a Snapcast device."""
|
||||||
if not isinstance(entity, SnapcastClientDevice):
|
|
||||||
raise TypeError("Entity is not a client. Can only join clients.")
|
|
||||||
await entity.async_join(service_call.data[ATTR_MASTER])
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_async_unjoin(entity, service_call):
|
|
||||||
"""Handle the entity service unjoin."""
|
|
||||||
if not isinstance(entity, SnapcastClientDevice):
|
|
||||||
raise TypeError("Entity is not a client. Can only unjoin clients.")
|
|
||||||
await entity.async_unjoin()
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_set_latency(entity, service_call):
|
|
||||||
"""Handle the entity service set_latency."""
|
|
||||||
if not isinstance(entity, SnapcastClientDevice):
|
|
||||||
raise TypeError("Latency can only be set for a Snapcast client.")
|
|
||||||
await entity.async_set_latency(service_call.data[ATTR_LATENCY])
|
|
||||||
|
|
||||||
|
|
||||||
class SnapcastGroupDevice(MediaPlayerEntity):
|
|
||||||
"""Representation of a Snapcast group device."""
|
|
||||||
|
|
||||||
_attr_should_poll = False
|
_attr_should_poll = False
|
||||||
_attr_supported_features = (
|
_attr_supported_features = (
|
||||||
@ -114,166 +181,172 @@ class SnapcastGroupDevice(MediaPlayerEntity):
|
|||||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
| MediaPlayerEntityFeature.SELECT_SOURCE
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, group, uid_part, entry_id):
|
def __init__(
|
||||||
"""Initialize the Snapcast group device."""
|
self,
|
||||||
self._attr_available = True
|
coordinator: SnapcastUpdateCoordinator,
|
||||||
self._group = group
|
device: Snapgroup | Snapclient,
|
||||||
self._entry_id = entry_id
|
host_id: str,
|
||||||
self._attr_unique_id = f"{GROUP_PREFIX}{uid_part}_{self._group.identifier}"
|
) -> None:
|
||||||
|
"""Initialize the base device."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
|
||||||
|
self._device = device
|
||||||
|
self._attr_unique_id = self.get_unique_id(host_id, device.identifier)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_unique_id(cls, host, id) -> str:
|
||||||
|
"""Build a unique ID."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _current_group(self) -> Snapgroup:
|
||||||
|
"""Return the group."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Subscribe to group events."""
|
"""Subscribe to events."""
|
||||||
self._group.set_callback(self.schedule_update_ha_state)
|
await super().async_added_to_hass()
|
||||||
self.hass.data[DOMAIN][self._entry_id].groups.append(self)
|
self._device.set_callback(self.schedule_update_ha_state)
|
||||||
|
|
||||||
async def async_will_remove_from_hass(self) -> None:
|
async def async_will_remove_from_hass(self) -> None:
|
||||||
"""Disconnect group object when removed."""
|
"""Disconnect object when removed."""
|
||||||
self._group.set_callback(None)
|
self._device.set_callback(None)
|
||||||
self.hass.data[DOMAIN][self._entry_id].groups.remove(self)
|
|
||||||
|
|
||||||
def set_availability(self, available: bool) -> None:
|
@property
|
||||||
"""Set availability of group."""
|
def identifier(self) -> str:
|
||||||
self._attr_available = available
|
"""Return the snapcast identifier."""
|
||||||
self.schedule_update_ha_state()
|
return self._device.identifier
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source(self) -> str | None:
|
||||||
|
"""Return the current input source."""
|
||||||
|
return self._current_group.stream
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_list(self) -> list[str]:
|
||||||
|
"""List of available input sources."""
|
||||||
|
return list(self._current_group.streams_by_name().keys())
|
||||||
|
|
||||||
|
async def async_select_source(self, source: str) -> None:
|
||||||
|
"""Set input source."""
|
||||||
|
streams = self._current_group.streams_by_name()
|
||||||
|
if source in streams:
|
||||||
|
await self._current_group.set_stream(streams[source].identifier)
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_volume_muted(self) -> bool:
|
||||||
|
"""Volume muted."""
|
||||||
|
return self._device.muted
|
||||||
|
|
||||||
|
async def async_mute_volume(self, mute: bool) -> None:
|
||||||
|
"""Send the mute command."""
|
||||||
|
await self._device.set_muted(mute)
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def volume_level(self) -> float:
|
||||||
|
"""Return the volume level."""
|
||||||
|
return self._device.volume / 100
|
||||||
|
|
||||||
|
async def async_set_volume_level(self, volume: float) -> None:
|
||||||
|
"""Set the volume level."""
|
||||||
|
await self._device.set_volume(round(volume * 100))
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
def snapshot(self) -> None:
|
||||||
|
"""Snapshot the group state."""
|
||||||
|
self._device.snapshot()
|
||||||
|
|
||||||
|
async def async_restore(self) -> None:
|
||||||
|
"""Restore the group state."""
|
||||||
|
await self._device.restore()
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
async def async_set_latency(self, latency) -> None:
|
||||||
|
"""Handle the set_latency service."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def async_join(self, master) -> None:
|
||||||
|
"""Handle the join service."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def async_unjoin(self) -> None:
|
||||||
|
"""Handle the unjoin service."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class SnapcastGroupDevice(SnapcastBaseDevice):
|
||||||
|
"""Representation of a Snapcast group device."""
|
||||||
|
|
||||||
|
_device: Snapgroup
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_unique_id(cls, host, id) -> str:
|
||||||
|
"""Get a unique ID for a group."""
|
||||||
|
return f"{GROUP_PREFIX}{host}_{id}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _current_group(self) -> Snapgroup:
|
||||||
|
"""Return the group."""
|
||||||
|
return self._device
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Return the name of the device."""
|
||||||
|
return f"{self._device.friendly_name} {GROUP_SUFFIX}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def state(self) -> MediaPlayerState | None:
|
def state(self) -> MediaPlayerState | None:
|
||||||
"""Return the state of the player."""
|
"""Return the state of the player."""
|
||||||
if self.is_volume_muted:
|
if self.is_volume_muted:
|
||||||
return MediaPlayerState.IDLE
|
return MediaPlayerState.IDLE
|
||||||
return STREAM_STATUS.get(self._group.stream_status)
|
return STREAM_STATUS.get(self._device.stream_status)
|
||||||
|
|
||||||
@property
|
async def async_set_latency(self, latency) -> None:
|
||||||
def identifier(self):
|
"""Handle the set_latency service."""
|
||||||
"""Return the snapcast identifier."""
|
raise ServiceValidationError("Latency can only be set for a Snapcast client.")
|
||||||
return self._group.identifier
|
|
||||||
|
|
||||||
@property
|
async def async_join(self, master) -> None:
|
||||||
def name(self):
|
"""Handle the join service."""
|
||||||
"""Return the name of the device."""
|
raise ServiceValidationError("Entity is not a client. Can only join clients.")
|
||||||
return f"{self._group.friendly_name} {GROUP_SUFFIX}"
|
|
||||||
|
|
||||||
@property
|
async def async_unjoin(self) -> None:
|
||||||
def source(self):
|
"""Handle the unjoin service."""
|
||||||
"""Return the current input source."""
|
raise ServiceValidationError("Entity is not a client. Can only unjoin clients.")
|
||||||
return self._group.stream
|
|
||||||
|
|
||||||
@property
|
|
||||||
def volume_level(self):
|
|
||||||
"""Return the volume level."""
|
|
||||||
return self._group.volume / 100
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_volume_muted(self):
|
|
||||||
"""Volume muted."""
|
|
||||||
return self._group.muted
|
|
||||||
|
|
||||||
@property
|
|
||||||
def source_list(self):
|
|
||||||
"""List of available input sources."""
|
|
||||||
return list(self._group.streams_by_name().keys())
|
|
||||||
|
|
||||||
async def async_select_source(self, source: str) -> None:
|
|
||||||
"""Set input source."""
|
|
||||||
streams = self._group.streams_by_name()
|
|
||||||
if source in streams:
|
|
||||||
await self._group.set_stream(streams[source].identifier)
|
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|
||||||
async def async_mute_volume(self, mute: bool) -> None:
|
|
||||||
"""Send the mute command."""
|
|
||||||
await self._group.set_muted(mute)
|
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|
||||||
async def async_set_volume_level(self, volume: float) -> None:
|
|
||||||
"""Set the volume level."""
|
|
||||||
await self._group.set_volume(round(volume * 100))
|
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|
||||||
def snapshot(self):
|
|
||||||
"""Snapshot the group state."""
|
|
||||||
self._group.snapshot()
|
|
||||||
|
|
||||||
async def async_restore(self):
|
|
||||||
"""Restore the group state."""
|
|
||||||
await self._group.restore()
|
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|
||||||
|
|
||||||
class SnapcastClientDevice(MediaPlayerEntity):
|
class SnapcastClientDevice(SnapcastBaseDevice):
|
||||||
"""Representation of a Snapcast client device."""
|
"""Representation of a Snapcast client device."""
|
||||||
|
|
||||||
_attr_should_poll = False
|
_device: Snapclient
|
||||||
_attr_supported_features = (
|
|
||||||
MediaPlayerEntityFeature.VOLUME_MUTE
|
|
||||||
| MediaPlayerEntityFeature.VOLUME_SET
|
|
||||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, client, uid_part, entry_id):
|
@classmethod
|
||||||
"""Initialize the Snapcast client device."""
|
def get_unique_id(cls, host, id) -> str:
|
||||||
self._attr_available = True
|
"""Get a unique ID for a client."""
|
||||||
self._client = client
|
return f"{CLIENT_PREFIX}{host}_{id}"
|
||||||
# Note: Host part is needed, when using multiple snapservers
|
|
||||||
self._attr_unique_id = f"{CLIENT_PREFIX}{uid_part}_{self._client.identifier}"
|
|
||||||
self._entry_id = entry_id
|
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
|
||||||
"""Subscribe to client events."""
|
|
||||||
self._client.set_callback(self.schedule_update_ha_state)
|
|
||||||
self.hass.data[DOMAIN][self._entry_id].clients.append(self)
|
|
||||||
|
|
||||||
async def async_will_remove_from_hass(self) -> None:
|
|
||||||
"""Disconnect client object when removed."""
|
|
||||||
self._client.set_callback(None)
|
|
||||||
self.hass.data[DOMAIN][self._entry_id].clients.remove(self)
|
|
||||||
|
|
||||||
def set_availability(self, available: bool) -> None:
|
|
||||||
"""Set availability of group."""
|
|
||||||
self._attr_available = available
|
|
||||||
self.schedule_update_ha_state()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def identifier(self):
|
def _current_group(self) -> Snapgroup:
|
||||||
"""Return the snapcast identifier."""
|
"""Return the group the client is associated with."""
|
||||||
return self._client.identifier
|
return self._device.group
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self) -> str:
|
||||||
"""Return the name of the device."""
|
"""Return the name of the device."""
|
||||||
return f"{self._client.friendly_name} {CLIENT_SUFFIX}"
|
return f"{self._device.friendly_name} {CLIENT_SUFFIX}"
|
||||||
|
|
||||||
@property
|
|
||||||
def source(self):
|
|
||||||
"""Return the current input source."""
|
|
||||||
return self._client.group.stream
|
|
||||||
|
|
||||||
@property
|
|
||||||
def volume_level(self):
|
|
||||||
"""Return the volume level."""
|
|
||||||
return self._client.volume / 100
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_volume_muted(self):
|
|
||||||
"""Volume muted."""
|
|
||||||
return self._client.muted
|
|
||||||
|
|
||||||
@property
|
|
||||||
def source_list(self):
|
|
||||||
"""List of available input sources."""
|
|
||||||
return list(self._client.group.streams_by_name().keys())
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def state(self) -> MediaPlayerState | None:
|
def state(self) -> MediaPlayerState | None:
|
||||||
"""Return the state of the player."""
|
"""Return the state of the player."""
|
||||||
if self._client.connected:
|
if self._device.connected:
|
||||||
if self.is_volume_muted or self._client.group.muted:
|
if self.is_volume_muted or self._current_group.muted:
|
||||||
return MediaPlayerState.IDLE
|
return MediaPlayerState.IDLE
|
||||||
return STREAM_STATUS.get(self._client.group.stream_status)
|
return STREAM_STATUS.get(self._current_group.stream_status)
|
||||||
return MediaPlayerState.STANDBY
|
return MediaPlayerState.STANDBY
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self):
|
def extra_state_attributes(self) -> Mapping[str, Any]:
|
||||||
"""Return the state attributes."""
|
"""Return the state attributes."""
|
||||||
state_attrs = {}
|
state_attrs = {}
|
||||||
if self.latency is not None:
|
if self.latency is not None:
|
||||||
@ -281,60 +354,40 @@ class SnapcastClientDevice(MediaPlayerEntity):
|
|||||||
return state_attrs
|
return state_attrs
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def latency(self):
|
def latency(self) -> float | None:
|
||||||
"""Latency for Client."""
|
"""Latency for Client."""
|
||||||
return self._client.latency
|
return self._device.latency
|
||||||
|
|
||||||
async def async_select_source(self, source: str) -> None:
|
async def async_set_latency(self, latency) -> None:
|
||||||
"""Set input source."""
|
"""Set the latency of the client."""
|
||||||
streams = self._client.group.streams_by_name()
|
await self._device.set_latency(latency)
|
||||||
if source in streams:
|
|
||||||
await self._client.group.set_stream(streams[source].identifier)
|
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|
||||||
async def async_mute_volume(self, mute: bool) -> None:
|
|
||||||
"""Send the mute command."""
|
|
||||||
await self._client.set_muted(mute)
|
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
async def async_set_volume_level(self, volume: float) -> None:
|
async def async_join(self, master) -> None:
|
||||||
"""Set the volume level."""
|
|
||||||
await self._client.set_volume(round(volume * 100))
|
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|
||||||
async def async_join(self, master):
|
|
||||||
"""Join the group of the master player."""
|
"""Join the group of the master player."""
|
||||||
master_entity = next(
|
entity_registry = er.async_get(self.hass)
|
||||||
entity
|
master_entity = entity_registry.async_get(master)
|
||||||
for entity in self.hass.data[DOMAIN][self._entry_id].clients
|
if master_entity is None:
|
||||||
if entity.entity_id == master
|
raise ServiceValidationError(f"Master entity '{master}' not found.")
|
||||||
)
|
|
||||||
if not isinstance(master_entity, SnapcastClientDevice):
|
|
||||||
raise TypeError("Master is not a client device. Can only join clients.")
|
|
||||||
|
|
||||||
|
# Validate master entity is a client
|
||||||
|
unique_id = master_entity.unique_id
|
||||||
|
if not unique_id.startswith(CLIENT_PREFIX):
|
||||||
|
raise ServiceValidationError(
|
||||||
|
"Master is not a client device. Can only join clients."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract the client ID and locate it's group
|
||||||
|
identifier = unique_id.split("_")[-1]
|
||||||
master_group = next(
|
master_group = next(
|
||||||
group
|
group
|
||||||
for group in self._client.groups_available()
|
for group in self._device.groups_available()
|
||||||
if master_entity.identifier in group.clients
|
if identifier in group.clients
|
||||||
)
|
)
|
||||||
await master_group.add_client(self._client.identifier)
|
await master_group.add_client(self._device.identifier)
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
async def async_unjoin(self):
|
async def async_unjoin(self) -> None:
|
||||||
"""Unjoin the group the player is currently in."""
|
"""Unjoin the group the player is currently in."""
|
||||||
await self._client.group.remove_client(self._client.identifier)
|
await self._current_group.remove_client(self._device.identifier)
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|
||||||
def snapshot(self):
|
|
||||||
"""Snapshot the client state."""
|
|
||||||
self._client.snapshot()
|
|
||||||
|
|
||||||
async def async_restore(self):
|
|
||||||
"""Restore the client state."""
|
|
||||||
await self._client.restore()
|
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|
||||||
async def async_set_latency(self, latency):
|
|
||||||
"""Set the latency of the client."""
|
|
||||||
await self._client.set_latency(latency)
|
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
@ -1,143 +0,0 @@
|
|||||||
"""Snapcast Integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import snapcast.control
|
|
||||||
from snapcast.control.client import Snapclient
|
|
||||||
|
|
||||||
from homeassistant.components.media_player import MediaPlayerEntity
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.helpers import entity_registry as er
|
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
||||||
|
|
||||||
from .media_player import SnapcastClientDevice, SnapcastGroupDevice
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class HomeAssistantSnapcast:
|
|
||||||
"""Snapcast server and data stored in the Home Assistant data object."""
|
|
||||||
|
|
||||||
hass: HomeAssistant
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
hass: HomeAssistant,
|
|
||||||
server: snapcast.control.Snapserver,
|
|
||||||
hpid: str,
|
|
||||||
entry_id: str,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the HomeAssistantSnapcast object.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
hass: HomeAssistant
|
|
||||||
hass object
|
|
||||||
server : snapcast.control.Snapserver
|
|
||||||
Snapcast server
|
|
||||||
hpid : str
|
|
||||||
host and port
|
|
||||||
entry_id: str
|
|
||||||
ConfigEntry entry_id
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
None
|
|
||||||
|
|
||||||
"""
|
|
||||||
self.hass: HomeAssistant = hass
|
|
||||||
self.server: snapcast.control.Snapserver = server
|
|
||||||
self.hpid: str = hpid
|
|
||||||
self._entry_id = entry_id
|
|
||||||
self.clients: list[SnapcastClientDevice] = []
|
|
||||||
self.groups: list[SnapcastGroupDevice] = []
|
|
||||||
self.hass_async_add_entities: AddEntitiesCallback
|
|
||||||
# connect callbacks
|
|
||||||
self.server.set_on_update_callback(self.on_update)
|
|
||||||
self.server.set_on_connect_callback(self.on_connect)
|
|
||||||
self.server.set_on_disconnect_callback(self.on_disconnect)
|
|
||||||
self.server.set_new_client_callback(self.on_add_client)
|
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
|
||||||
"""Disconnect from server."""
|
|
||||||
self.server.set_on_update_callback(None)
|
|
||||||
self.server.set_on_connect_callback(None)
|
|
||||||
self.server.set_on_disconnect_callback(None)
|
|
||||||
self.server.set_new_client_callback(None)
|
|
||||||
self.server.stop()
|
|
||||||
|
|
||||||
def on_update(self) -> None:
|
|
||||||
"""Update all entities.
|
|
||||||
|
|
||||||
Retrieve all groups/clients from server and add/update/delete entities.
|
|
||||||
"""
|
|
||||||
if not self.hass_async_add_entities:
|
|
||||||
return
|
|
||||||
new_groups: list[MediaPlayerEntity] = []
|
|
||||||
groups: list[MediaPlayerEntity] = []
|
|
||||||
hass_groups = {g.identifier: g for g in self.groups}
|
|
||||||
for group in self.server.groups:
|
|
||||||
if group.identifier in hass_groups:
|
|
||||||
groups.append(hass_groups[group.identifier])
|
|
||||||
hass_groups[group.identifier].async_schedule_update_ha_state()
|
|
||||||
else:
|
|
||||||
new_groups.append(SnapcastGroupDevice(group, self.hpid, self._entry_id))
|
|
||||||
new_clients: list[MediaPlayerEntity] = []
|
|
||||||
clients: list[MediaPlayerEntity] = []
|
|
||||||
hass_clients = {c.identifier: c for c in self.clients}
|
|
||||||
for client in self.server.clients:
|
|
||||||
if client.identifier in hass_clients:
|
|
||||||
clients.append(hass_clients[client.identifier])
|
|
||||||
hass_clients[client.identifier].async_schedule_update_ha_state()
|
|
||||||
else:
|
|
||||||
new_clients.append(
|
|
||||||
SnapcastClientDevice(client, self.hpid, self._entry_id)
|
|
||||||
)
|
|
||||||
del_entities: list[MediaPlayerEntity] = [
|
|
||||||
x for x in self.groups if x not in groups
|
|
||||||
]
|
|
||||||
del_entities.extend([x for x in self.clients if x not in clients])
|
|
||||||
|
|
||||||
_LOGGER.debug("New clients: %s", str([c.name for c in new_clients]))
|
|
||||||
_LOGGER.debug("New groups: %s", str([g.name for g in new_groups]))
|
|
||||||
_LOGGER.debug("Delete: %s", str(del_entities))
|
|
||||||
|
|
||||||
ent_reg = er.async_get(self.hass)
|
|
||||||
for entity in del_entities:
|
|
||||||
ent_reg.async_remove(entity.entity_id)
|
|
||||||
self.hass_async_add_entities(new_clients + new_groups)
|
|
||||||
|
|
||||||
def on_connect(self) -> None:
|
|
||||||
"""Activate all entities and update."""
|
|
||||||
for client in self.clients:
|
|
||||||
client.set_availability(True)
|
|
||||||
for group in self.groups:
|
|
||||||
group.set_availability(True)
|
|
||||||
_LOGGER.debug("Server connected: %s", self.hpid)
|
|
||||||
self.on_update()
|
|
||||||
|
|
||||||
def on_disconnect(self, ex: Exception | None) -> None:
|
|
||||||
"""Deactivate all entities."""
|
|
||||||
for client in self.clients:
|
|
||||||
client.set_availability(False)
|
|
||||||
for group in self.groups:
|
|
||||||
group.set_availability(False)
|
|
||||||
_LOGGER.warning(
|
|
||||||
"Server disconnected: %s. Trying to reconnect. %s", self.hpid, str(ex or "")
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_add_client(self, client: Snapclient) -> None:
|
|
||||||
"""Add a Snapcast client.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
client : Snapclient
|
|
||||||
Snapcast client to be added to HA.
|
|
||||||
|
|
||||||
"""
|
|
||||||
if not self.hass_async_add_entities:
|
|
||||||
return
|
|
||||||
clients = [SnapcastClientDevice(client, self.hpid, self._entry_id)]
|
|
||||||
self.hass_async_add_entities(clients)
|
|
Loading…
x
Reference in New Issue
Block a user