mirror of
https://github.com/home-assistant/core.git
synced 2025-07-16 09:47:13 +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."""
|
||||
|
||||
import logging
|
||||
|
||||
import snapcast.control
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
from .server import HomeAssistantSnapcast
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .coordinator import SnapcastUpdateCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Snapcast from a config entry."""
|
||||
host = entry.data[CONF_HOST]
|
||||
port = entry.data[CONF_PORT]
|
||||
coordinator = SnapcastUpdateCoordinator(hass, host, port)
|
||||
|
||||
try:
|
||||
server = await snapcast.control.create_server(
|
||||
hass.loop, host, port, reconnect=True
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
except OSError as ex:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Could not connect to Snapcast server at {host}:{port}"
|
||||
) from ex
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantSnapcast(
|
||||
hass, server, f"{host}:{port}", entry.entry_id
|
||||
)
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
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 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
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
DOMAIN as MEDIA_PLAYER_DOMAIN,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
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,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
@ -30,6 +41,8 @@ from .const import (
|
||||
SERVICE_SNAPSHOT,
|
||||
SERVICE_UNJOIN,
|
||||
)
|
||||
from .coordinator import SnapcastUpdateCoordinator
|
||||
from .entity import SnapcastCoordinatorEntity
|
||||
|
||||
STREAM_STATUS = {
|
||||
"idle": MediaPlayerState.IDLE,
|
||||
@ -37,21 +50,23 @@ STREAM_STATUS = {
|
||||
"unknown": None,
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
def register_services():
|
||||
|
||||
def register_services() -> None:
|
||||
"""Register snapcast services."""
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
|
||||
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_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(
|
||||
SERVICE_SET_LATENCY,
|
||||
{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,
|
||||
) -> None:
|
||||
"""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()
|
||||
|
||||
host = config_entry.data[CONF_HOST]
|
||||
port = config_entry.data[CONF_PORT]
|
||||
hpid = f"{host}:{port}"
|
||||
_known_group_ids: set[str] = set()
|
||||
_known_client_ids: set[str] = set()
|
||||
|
||||
groups: list[MediaPlayerEntity] = [
|
||||
SnapcastGroupDevice(group, hpid, config_entry.entry_id)
|
||||
for group in snapcast_server.groups
|
||||
]
|
||||
clients: list[MediaPlayerEntity] = [
|
||||
SnapcastClientDevice(client, hpid, config_entry.entry_id)
|
||||
for client in snapcast_server.clients
|
||||
]
|
||||
async_add_entities(clients + groups)
|
||||
hass.data[DOMAIN][
|
||||
config_entry.entry_id
|
||||
].hass_async_add_entities = async_add_entities
|
||||
@callback
|
||||
def _check_entities() -> None:
|
||||
nonlocal _known_group_ids, _known_client_ids
|
||||
|
||||
def _update_known_ids(known_ids, ids) -> tuple[set[str], set[str]]:
|
||||
ids_to_add = ids - known_ids
|
||||
ids_to_remove = known_ids - ids
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
# 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):
|
||||
"""Handle the entity service join."""
|
||||
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."""
|
||||
class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity):
|
||||
"""Base class representing a Snapcast device."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_supported_features = (
|
||||
@ -114,166 +181,172 @@ class SnapcastGroupDevice(MediaPlayerEntity):
|
||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
)
|
||||
|
||||
def __init__(self, group, uid_part, entry_id):
|
||||
"""Initialize the Snapcast group device."""
|
||||
self._attr_available = True
|
||||
self._group = group
|
||||
self._entry_id = entry_id
|
||||
self._attr_unique_id = f"{GROUP_PREFIX}{uid_part}_{self._group.identifier}"
|
||||
def __init__(
|
||||
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)
|
||||
|
||||
@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:
|
||||
"""Subscribe to group events."""
|
||||
self._group.set_callback(self.schedule_update_ha_state)
|
||||
self.hass.data[DOMAIN][self._entry_id].groups.append(self)
|
||||
"""Subscribe to events."""
|
||||
await super().async_added_to_hass()
|
||||
self._device.set_callback(self.schedule_update_ha_state)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Disconnect group object when removed."""
|
||||
self._group.set_callback(None)
|
||||
self.hass.data[DOMAIN][self._entry_id].groups.remove(self)
|
||||
"""Disconnect object when removed."""
|
||||
self._device.set_callback(None)
|
||||
|
||||
def set_availability(self, available: bool) -> None:
|
||||
"""Set availability of group."""
|
||||
self._attr_available = available
|
||||
self.schedule_update_ha_state()
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
"""Return the snapcast identifier."""
|
||||
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
|
||||
def state(self) -> MediaPlayerState | None:
|
||||
"""Return the state of the player."""
|
||||
if self.is_volume_muted:
|
||||
return MediaPlayerState.IDLE
|
||||
return STREAM_STATUS.get(self._group.stream_status)
|
||||
return STREAM_STATUS.get(self._device.stream_status)
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
"""Return the snapcast identifier."""
|
||||
return self._group.identifier
|
||||
async def async_set_latency(self, latency) -> None:
|
||||
"""Handle the set_latency service."""
|
||||
raise ServiceValidationError("Latency can only be set for a Snapcast client.")
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return f"{self._group.friendly_name} {GROUP_SUFFIX}"
|
||||
async def async_join(self, master) -> None:
|
||||
"""Handle the join service."""
|
||||
raise ServiceValidationError("Entity is not a client. Can only join clients.")
|
||||
|
||||
@property
|
||||
def source(self):
|
||||
"""Return the current input source."""
|
||||
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()
|
||||
async def async_unjoin(self) -> None:
|
||||
"""Handle the unjoin service."""
|
||||
raise ServiceValidationError("Entity is not a client. Can only unjoin clients.")
|
||||
|
||||
|
||||
class SnapcastClientDevice(MediaPlayerEntity):
|
||||
class SnapcastClientDevice(SnapcastBaseDevice):
|
||||
"""Representation of a Snapcast client device."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_supported_features = (
|
||||
MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
| MediaPlayerEntityFeature.VOLUME_SET
|
||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
)
|
||||
_device: Snapclient
|
||||
|
||||
def __init__(self, client, uid_part, entry_id):
|
||||
"""Initialize the Snapcast client device."""
|
||||
self._attr_available = True
|
||||
self._client = client
|
||||
# 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()
|
||||
@classmethod
|
||||
def get_unique_id(cls, host, id) -> str:
|
||||
"""Get a unique ID for a client."""
|
||||
return f"{CLIENT_PREFIX}{host}_{id}"
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
"""Return the snapcast identifier."""
|
||||
return self._client.identifier
|
||||
def _current_group(self) -> Snapgroup:
|
||||
"""Return the group the client is associated with."""
|
||||
return self._device.group
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
def name(self) -> str:
|
||||
"""Return the name of the device."""
|
||||
return f"{self._client.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())
|
||||
return f"{self._device.friendly_name} {CLIENT_SUFFIX}"
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState | None:
|
||||
"""Return the state of the player."""
|
||||
if self._client.connected:
|
||||
if self.is_volume_muted or self._client.group.muted:
|
||||
if self._device.connected:
|
||||
if self.is_volume_muted or self._current_group.muted:
|
||||
return MediaPlayerState.IDLE
|
||||
return STREAM_STATUS.get(self._client.group.stream_status)
|
||||
return STREAM_STATUS.get(self._current_group.stream_status)
|
||||
return MediaPlayerState.STANDBY
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
def extra_state_attributes(self) -> Mapping[str, Any]:
|
||||
"""Return the state attributes."""
|
||||
state_attrs = {}
|
||||
if self.latency is not None:
|
||||
@ -281,60 +354,40 @@ class SnapcastClientDevice(MediaPlayerEntity):
|
||||
return state_attrs
|
||||
|
||||
@property
|
||||
def latency(self):
|
||||
def latency(self) -> float | None:
|
||||
"""Latency for Client."""
|
||||
return self._client.latency
|
||||
return self._device.latency
|
||||
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
"""Set input source."""
|
||||
streams = self._client.group.streams_by_name()
|
||||
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)
|
||||
async def async_set_latency(self, latency) -> None:
|
||||
"""Set the latency of the client."""
|
||||
await self._device.set_latency(latency)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set the volume level."""
|
||||
await self._client.set_volume(round(volume * 100))
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_join(self, master):
|
||||
async def async_join(self, master) -> None:
|
||||
"""Join the group of the master player."""
|
||||
master_entity = next(
|
||||
entity
|
||||
for entity in self.hass.data[DOMAIN][self._entry_id].clients
|
||||
if entity.entity_id == master
|
||||
)
|
||||
if not isinstance(master_entity, SnapcastClientDevice):
|
||||
raise TypeError("Master is not a client device. Can only join clients.")
|
||||
entity_registry = er.async_get(self.hass)
|
||||
master_entity = entity_registry.async_get(master)
|
||||
if master_entity is None:
|
||||
raise ServiceValidationError(f"Master entity '{master}' not found.")
|
||||
|
||||
# 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(
|
||||
group
|
||||
for group in self._client.groups_available()
|
||||
if master_entity.identifier in group.clients
|
||||
for group in self._device.groups_available()
|
||||
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()
|
||||
|
||||
async def async_unjoin(self):
|
||||
async def async_unjoin(self) -> None:
|
||||
"""Unjoin the group the player is currently in."""
|
||||
await self._client.group.remove_client(self._client.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)
|
||||
await self._current_group.remove_client(self._device.identifier)
|
||||
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