"""Support for interacting with Snapcast clients.""" from __future__ import annotations from collections.abc import Callable, 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, MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, MediaType, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import ( config_validation as cv, entity_platform, entity_registry as er, issue_registry as ir, ) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_LATENCY, ATTR_MASTER, CLIENT_PREFIX, CLIENT_SUFFIX, DOMAIN, GROUP_PREFIX, GROUP_SUFFIX, SERVICE_JOIN, SERVICE_RESTORE, SERVICE_SET_LATENCY, SERVICE_SNAPSHOT, SERVICE_UNJOIN, ) from .coordinator import SnapcastUpdateCoordinator from .entity import SnapcastCoordinatorEntity STREAM_STATUS = { "idle": MediaPlayerState.IDLE, "playing": MediaPlayerState.PLAYING, "unknown": None, } _SUPPORTED_FEATURES = ( MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.SELECT_SOURCE ) _LOGGER = logging.getLogger(__name__) def register_services() -> None: """Register snapcast services.""" platform = entity_platform.async_get_current_platform() platform.async_register_entity_service(SERVICE_SNAPSHOT, None, "async_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}, "async_join" ) 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}, "async_set_latency", ) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the snapcast config entry.""" # Fetch coordinator from global data coordinator: SnapcastUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] register_services() _known_group_ids: set[str] = set() _known_client_ids: set[str] = set() @callback def _update_entities( entity_class: type[SnapcastClientDevice | SnapcastGroupDevice], known_ids: set[str], get_device: Callable[[str], Snapclient | Snapgroup], get_devices: Callable[[], list[Snapclient] | list[Snapgroup]], ) -> None: # Get IDs of current devices on server snapcast_ids = {d.identifier for d in get_devices()} # Update known IDs ids_to_add = snapcast_ids - known_ids ids_to_remove = known_ids - snapcast_ids known_ids.difference_update(ids_to_remove) known_ids.update(ids_to_add) # Exit early if no changes if not (ids_to_add | ids_to_remove): return _LOGGER.debug( "New %s: %s", entity_class, str([get_device(d).friendly_name for d in ids_to_add]), ) _LOGGER.debug( "Remove %s IDs: %s", entity_class, str([list(ids_to_remove)]), ) # Add new entities async_add_entities( [ entity_class(coordinator, get_device(snapcast_id)) for snapcast_id in ids_to_add ] ) # Remove stale entities entity_registry = er.async_get(hass) for snapcast_id in ids_to_remove: if entity_id := entity_registry.async_get_entity_id( MEDIA_PLAYER_DOMAIN, DOMAIN, entity_class.get_unique_id(coordinator.host_id, snapcast_id), ): entity_registry.async_remove(entity_id) def _update_clients() -> None: _update_entities( SnapcastClientDevice, _known_client_ids, coordinator.server.client, lambda: coordinator.server.clients, ) # Create client entities and add listener to update clients on server update _update_clients() coordinator.async_add_listener(_update_clients) def _update_groups() -> None: _update_entities( SnapcastGroupDevice, _known_group_ids, coordinator.server.group, lambda: coordinator.server.groups, ) # Create group entities and add listener to update groups on server update _update_groups() coordinator.async_add_listener(_update_groups) class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity): """Base class representing a Snapcast device.""" _attr_should_poll = False _attr_supported_features = _SUPPORTED_FEATURES _attr_media_content_type = MediaType.MUSIC _attr_device_class = MediaPlayerDeviceClass.SPEAKER def __init__( self, coordinator: SnapcastUpdateCoordinator, device: Snapgroup | Snapclient, ) -> None: """Initialize the base device.""" super().__init__(coordinator) self._device = device self._attr_unique_id = self.get_unique_id( coordinator.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 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 object when removed.""" self._device.set_callback(None) @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() async def async_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 def _async_create_grouping_deprecation_issue(self) -> None: """Create an issue for deprecated grouping actions.""" ir.async_create_issue( self.hass, DOMAIN, "deprecated_grouping_actions", breaks_in_ha_version="2026.2.0", is_fixable=False, is_persistent=False, severity=ir.IssueSeverity.WARNING, translation_key="deprecated_grouping_actions", ) @property def metadata(self) -> Mapping[str, Any]: """Get metadata from the current stream.""" if metadata := self.coordinator.server.stream( self._current_group.stream ).metadata: return metadata # Fallback to an empty dict return {} @property def media_title(self) -> str | None: """Title of current playing media.""" return self.metadata.get("title") @property def media_image_url(self) -> str | None: """Image url of current playing media.""" return self.metadata.get("artUrl") @property def media_artist(self) -> str | None: """Artist of current playing media, music track only.""" if (value := self.metadata.get("artist")) is not None: return ", ".join(value) return None @property def media_album_name(self) -> str | None: """Album name of current playing media, music track only.""" return self.metadata.get("album") @property def media_album_artist(self) -> str | None: """Album artist of current playing media, music track only.""" if (value := self.metadata.get("albumArtist")) is not None: return ", ".join(value) return None @property def media_track(self) -> int | None: """Track number of current playing media, music track only.""" if (value := self.metadata.get("trackNumber")) is not None: return int(value) return None @property def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" if (value := self.metadata.get("duration")) is not None: return int(value) return None @property def media_position(self) -> int | None: """Position of current playing media in seconds.""" # Position is part of properties object, not metadata object if properties := self.coordinator.server.stream( self._current_group.stream ).properties: if (value := properties.get("position")) is not None: return int(value) return None 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._device.stream_status) async def async_set_latency(self, latency) -> None: """Handle the set_latency service.""" raise ServiceValidationError("Latency can only be set for a Snapcast client.") async def async_join(self, master) -> None: """Handle the join service.""" raise ServiceValidationError("Entity is not a client. Can only join clients.") async def async_unjoin(self) -> None: """Handle the unjoin service.""" raise ServiceValidationError("Entity is not a client. Can only unjoin clients.") def _async_create_group_deprecation_issue(self) -> None: """Create an issue for deprecated group entities.""" ir.async_create_issue( self.hass, DOMAIN, "deprecated_group_entities", breaks_in_ha_version="2026.2.0", is_fixable=False, is_persistent=False, severity=ir.IssueSeverity.WARNING, translation_key="deprecated_group_entities", ) async def async_select_source(self, source: str) -> None: """Set input source.""" # Groups are deprecated, create an issue when used self._async_create_group_deprecation_issue() await super().async_select_source(source) async def async_mute_volume(self, mute: bool) -> None: """Send the mute command.""" # Groups are deprecated, create an issue when used self._async_create_group_deprecation_issue() await super().async_mute_volume(mute) async def async_set_volume_level(self, volume: float) -> None: """Set the volume level.""" # Groups are deprecated, create an issue when used self._async_create_group_deprecation_issue() await super().async_set_volume_level(volume) async def async_snapshot(self) -> None: """Snapshot the group state.""" # Groups are deprecated, create an issue when used self._async_create_group_deprecation_issue() await super().async_snapshot() async def async_restore(self) -> None: """Restore the group state.""" # Groups are deprecated, create an issue when used self._async_create_group_deprecation_issue() await super().async_restore() class SnapcastClientDevice(SnapcastBaseDevice): """Representation of a Snapcast client device.""" _device: Snapclient _attr_supported_features = ( _SUPPORTED_FEATURES | MediaPlayerEntityFeature.GROUPING ) # Clients support grouping @classmethod def get_unique_id(cls, host, id) -> str: """Get a unique ID for a client.""" return f"{CLIENT_PREFIX}{host}_{id}" @property def _current_group(self) -> Snapgroup: """Return the group the client is associated with.""" return self._device.group @property def name(self) -> str: """Return the name of the device.""" return f"{self._device.friendly_name} {CLIENT_SUFFIX}" @property def state(self) -> MediaPlayerState | None: """Return the state of the player.""" if self._device.connected: if self.is_volume_muted or self._current_group.muted: return MediaPlayerState.IDLE return STREAM_STATUS.get(self._current_group.stream_status) return MediaPlayerState.OFF @property def extra_state_attributes(self) -> Mapping[str, Any]: """Return the state attributes.""" state_attrs = {} if self.latency is not None: state_attrs["latency"] = self.latency return state_attrs @property def latency(self) -> float | None: """Latency for Client.""" return self._device.latency 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_join(self, master) -> None: """Join the group of the master player.""" # Action is deprecated, create an issue self._async_create_grouping_deprecation_issue() entity_registry = er.async_get(self.hass) master_entity = entity_registry.async_get(master) if master_entity is None: 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._device.groups_available() if identifier in group.clients ) await master_group.add_client(self._device.identifier) self.async_write_ha_state() async def async_unjoin(self) -> None: """Unjoin the group the player is currently in.""" # Action is deprecated, create an issue self._async_create_grouping_deprecation_issue() await self._current_group.remove_client(self._device.identifier) self.async_write_ha_state() @property def group_members(self) -> list[str] | None: """List of player entities which are currently grouped together for synchronous playback.""" entity_registry = er.async_get(self.hass) return [ entity_id for client_id in self._current_group.clients if ( entity_id := entity_registry.async_get_entity_id( MEDIA_PLAYER_DOMAIN, DOMAIN, self.get_unique_id(self.coordinator.host_id, client_id), ) ) ] async def async_join_players(self, group_members: list[str]) -> None: """Add `group_members` to this client's current group.""" # Get the client entity for each group member excluding self entity_registry = er.async_get(self.hass) clients = [ entity for entity_id in group_members if (entity := entity_registry.async_get(entity_id)) and entity.unique_id != self.unique_id ] for client in clients: # Valid entity is a snapcast client if not client.unique_id.startswith(CLIENT_PREFIX): raise ServiceValidationError( f"Entity '{client.entity_id}' is not a Snapcast client device." ) # Extract client ID and join it to the current group identifier = client.unique_id.split("_")[-1] await self._current_group.add_client(identifier) self.async_write_ha_state() async def async_unjoin_player(self) -> None: """Remove this client from it's current group.""" await self._current_group.remove_client(self._device.identifier) self.async_write_ha_state()