diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index dbbeecdcdb3..cdc1169f9f7 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -66,7 +66,6 @@ class SonosData: def __init__(self) -> None: """Initialize the data.""" self.discovered: dict[str, SonosSpeaker] = {} - self.media_player_entities = {} self.topology_condition = asyncio.Condition() self.discovery_thread = None self.hosts_heartbeat = None diff --git a/homeassistant/components/sonos/binary_sensor.py b/homeassistant/components/sonos/binary_sensor.py index 558ee1ee25d..9fd81a1f006 100644 --- a/homeassistant/components/sonos/binary_sensor.py +++ b/homeassistant/components/sonos/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import SONOS_CREATE_BATTERY -from .entity import SonosSensorEntity +from .entity import SonosEntity from .speaker import SonosSpeaker _LOGGER = logging.getLogger(__name__) @@ -32,7 +32,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) -class SonosPowerEntity(SonosSensorEntity, BinarySensorEntity): +class SonosPowerEntity(SonosEntity, BinarySensorEntity): """Representation of a Sonos power entity.""" @property diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 133bf773991..6cecf5169d1 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -128,18 +128,17 @@ PLAYABLE_MEDIA_TYPES = [ MEDIA_TYPE_TRACK, ] -SONOS_CONTENT_UPDATE = "sonos_content_update" SONOS_CREATE_BATTERY = "sonos_create_battery" SONOS_CREATE_MEDIA_PLAYER = "sonos_create_media_player" SONOS_ENTITY_CREATED = "sonos_entity_created" SONOS_ENTITY_UPDATE = "sonos_entity_update" SONOS_GROUP_UPDATE = "sonos_group_update" -SONOS_MEDIA_UPDATE = "sonos_media_update" -SONOS_PLAYER_RECONNECTED = "sonos_player_reconnected" SONOS_STATE_UPDATED = "sonos_state_updated" -SONOS_VOLUME_UPDATE = "sonos_properties_update" SONOS_SEEN = "sonos_seen" +SOURCE_LINEIN = "Line-in" +SOURCE_TV = "TV" + BATTERY_SCAN_INTERVAL = datetime.timedelta(minutes=15) SCAN_INTERVAL = datetime.timedelta(seconds=10) DISCOVERY_INTERVAL = datetime.timedelta(seconds=60) diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index f7319e483d3..146725f90e2 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -48,6 +48,9 @@ class SonosEntity(Entity): self.async_write_ha_state, ) ) + async_dispatcher_send( + self.hass, f"{SONOS_ENTITY_CREATED}-{self.soco.uid}", self.platform.domain + ) @property def soco(self) -> SoCo: @@ -76,14 +79,3 @@ class SonosEntity(Entity): def should_poll(self) -> bool: """Return that we should not be polled (we handle that internally).""" return False - - -class SonosSensorEntity(SonosEntity): - """Representation of a Sonos sensor entity.""" - - async def async_added_to_hass(self) -> None: - """Handle common setup when added to hass.""" - await super().async_added_to_hass() - async_dispatcher_send( - self.hass, f"{SONOS_ENTITY_CREATED}-{self.soco.uid}", self.platform.domain - ) diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py new file mode 100644 index 00000000000..6f22d8ab417 --- /dev/null +++ b/homeassistant/components/sonos/helpers.py @@ -0,0 +1,32 @@ +"""Helper methods for common tasks.""" +from __future__ import annotations + +import functools as ft +import logging +from typing import Any, Callable + +from pysonos.exceptions import SoCoException, SoCoUPnPException + +_LOGGER = logging.getLogger(__name__) + + +def soco_error(errorcodes: list[str] | None = None) -> Callable: + """Filter out specified UPnP errors from logs and avoid exceptions.""" + + def decorator(funct: Callable) -> Callable: + """Decorate functions.""" + + @ft.wraps(funct) + def wrapper(*args: Any, **kwargs: Any) -> Any: + """Wrap for all soco UPnP exception.""" + try: + return funct(*args, **kwargs) + except SoCoUPnPException as err: + if not errorcodes or err.error_code not in errorcodes: + _LOGGER.error("Error on %s with %s", funct.__name__, err) + except SoCoException as err: + _LOGGER.error("Error on %s with %s", funct.__name__, err) + + return wrapper + + return decorator diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index c3971852ac6..2150cd3a464 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -1,30 +1,19 @@ """Support to interface with Sonos players.""" from __future__ import annotations -import asyncio -from collections.abc import Coroutine -from contextlib import suppress import datetime -import functools as ft import logging -from typing import Any, Callable +from typing import Any import urllib.parse -import async_timeout from pysonos import alarms from pysonos.core import ( MUSIC_SRC_LINE_IN, MUSIC_SRC_RADIO, - MUSIC_SRC_TV, PLAY_MODE_BY_MEANING, PLAY_MODES, - SoCo, ) -from pysonos.data_structures import DidlFavorite -from pysonos.events_base import Event as SonosEvent from pysonos.exceptions import SoCoException, SoCoUPnPException -import pysonos.music_library -import pysonos.snapshot import voluptuous as vol from homeassistant.components.media_player import MediaPlayerEntity @@ -60,30 +49,22 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TIME, STATE_IDLE, STATE_PAUSED, STATE_PLAYING from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv, entity_platform, service -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.network import is_internal_request -from homeassistant.util.dt import utcnow from .const import ( - DATA_SONOS, DOMAIN as SONOS_DOMAIN, MEDIA_TYPES_TO_SONOS, PLAYABLE_MEDIA_TYPES, - SONOS_CONTENT_UPDATE, SONOS_CREATE_MEDIA_PLAYER, - SONOS_ENTITY_CREATED, - SONOS_GROUP_UPDATE, - SONOS_MEDIA_UPDATE, - SONOS_PLAYER_RECONNECTED, - SONOS_VOLUME_UPDATE, + SOURCE_LINEIN, + SOURCE_TV, ) from .entity import SonosEntity +from .helpers import soco_error from .media_browser import build_item_response, get_media, library_payload -from .speaker import SonosSpeaker +from .speaker import SonosMedia, SonosSpeaker _LOGGER = logging.getLogger(__name__) @@ -104,8 +85,7 @@ SUPPORT_SONOS = ( | SUPPORT_VOLUME_SET ) -SOURCE_LINEIN = "Line-in" -SOURCE_TV = "TV" +VOLUME_INCREMENT = 2 REPEAT_TO_SONOS = { REPEAT_MODE_OFF: False, @@ -142,8 +122,6 @@ ATTR_SPEECH_ENHANCE = "speech_enhance" ATTR_QUEUE_POSITION = "queue_position" ATTR_STATUS_LIGHT = "status_light" -UNAVAILABLE_VALUES = {"", "NOT_IMPLEMENTED", None} - async def async_setup_entry( hass: HomeAssistant, @@ -167,27 +145,29 @@ async def async_setup_entry( if not entities: return + speakers = [] for entity in entities: assert isinstance(entity, SonosMediaPlayerEntity) + speakers.append(entity.speaker) if service_call.service == SERVICE_JOIN: master = platform.entities.get(service_call.data[ATTR_MASTER]) if master: - await SonosMediaPlayerEntity.join_multi(hass, master, entities) # type: ignore[arg-type] + await SonosSpeaker.join_multi(hass, master.speaker, speakers) # type: ignore[arg-type] else: _LOGGER.error( "Invalid master specified for join service: %s", service_call.data[ATTR_MASTER], ) elif service_call.service == SERVICE_UNJOIN: - await SonosMediaPlayerEntity.unjoin_multi(hass, entities) # type: ignore[arg-type] + await SonosSpeaker.unjoin_multi(hass, speakers) # type: ignore[arg-type] elif service_call.service == SERVICE_SNAPSHOT: - await SonosMediaPlayerEntity.snapshot_multi( - hass, entities, service_call.data[ATTR_WITH_GROUP] # type: ignore[arg-type] + await SonosSpeaker.snapshot_multi( + hass, speakers, service_call.data[ATTR_WITH_GROUP] # type: ignore[arg-type] ) elif service_call.service == SERVICE_RESTORE: - await SonosMediaPlayerEntity.restore_multi( - hass, entities, service_call.data[ATTR_WITH_GROUP] # type: ignore[arg-type] + await SonosSpeaker.restore_multi( + hass, speakers, service_call.data[ATTR_WITH_GROUP] # type: ignore[arg-type] ) config_entry.async_on_unload( @@ -267,134 +247,13 @@ async def async_setup_entry( ) -def _get_entity_from_soco_uid( - hass: HomeAssistant, uid: str -) -> SonosMediaPlayerEntity | None: - """Return SonosMediaPlayerEntity from SoCo uid.""" - return hass.data[DATA_SONOS].media_player_entities.get(uid) # type: ignore[no-any-return] - - -def soco_error(errorcodes: list[str] | None = None) -> Callable: - """Filter out specified UPnP errors from logs and avoid exceptions.""" - - def decorator(funct: Callable) -> Callable: - """Decorate functions.""" - - @ft.wraps(funct) - def wrapper(*args: Any, **kwargs: Any) -> Any: - """Wrap for all soco UPnP exception.""" - try: - return funct(*args, **kwargs) - except SoCoUPnPException as err: - if not errorcodes or err.error_code not in errorcodes: - _LOGGER.error("Error on %s with %s", funct.__name__, err) - except SoCoException as err: - _LOGGER.error("Error on %s with %s", funct.__name__, err) - - return wrapper - - return decorator - - -def soco_coordinator(funct: Callable) -> Callable: - """Call function on coordinator.""" - - @ft.wraps(funct) - def wrapper(entity: SonosMediaPlayerEntity, *args: Any, **kwargs: Any) -> Any: - """Wrap for call to coordinator.""" - if entity.is_coordinator: - return funct(entity, *args, **kwargs) - return funct(entity.coordinator, *args, **kwargs) - - return wrapper - - -def _timespan_secs(timespan: str | None) -> None | float: - """Parse a time-span into number of seconds.""" - if timespan in UNAVAILABLE_VALUES: - return None - - assert timespan is not None - return sum(60 ** x[0] * int(x[1]) for x in enumerate(reversed(timespan.split(":")))) - - class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): """Representation of a Sonos entity.""" - def __init__(self, speaker: SonosSpeaker) -> None: - """Initialize the Sonos entity.""" - super().__init__(speaker) - self._volume_increment = 2 - self._player_volume: int | None = None - self._player_muted: bool | None = None - self._play_mode: str | None = None - self._coordinator: SonosMediaPlayerEntity | None = None - self._sonos_group: list[SonosMediaPlayerEntity] = [self] - self._status: str | None = None - self._uri: str | None = None - self._media_library = pysonos.music_library.MusicLibrary(self.soco) - self._media_duration: float | None = None - self._media_position: float | None = None - self._media_position_updated_at: datetime.datetime | None = None - self._media_image_url: str | None = None - self._media_channel: str | None = None - self._media_artist: str | None = None - self._media_album_name: str | None = None - self._media_title: str | None = None - self._queue_position: int | None = None - self._night_sound: bool | None = None - self._speech_enhance: bool | None = None - self._source_name: str | None = None - self._favorites: list[DidlFavorite] = [] - self._soco_snapshot: pysonos.snapshot.Snapshot | None = None - self._snapshot_group: list[SonosMediaPlayerEntity] | None = None - - async def async_added_to_hass(self) -> None: - """Subscribe sonos events.""" - self.hass.data[DATA_SONOS].media_player_entities[self.unique_id] = self - await self.async_reconnect_player() - await super().async_added_to_hass() - - self.async_on_remove( - async_dispatcher_connect( - self.hass, SONOS_GROUP_UPDATE, self.async_update_groups - ) - ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{SONOS_CONTENT_UPDATE}-{self.soco.uid}", - self.async_update_content, - ) - ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{SONOS_MEDIA_UPDATE}-{self.soco.uid}", - self.async_update_media, - ) - ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{SONOS_VOLUME_UPDATE}-{self.soco.uid}", - self.async_update_volume, - ) - ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{SONOS_PLAYER_RECONNECTED}-{self.soco.uid}", - self.async_reconnect_player, - ) - ) - - if self.hass.is_running: - async_dispatcher_send(self.hass, SONOS_GROUP_UPDATE) - - async_dispatcher_send( - self.hass, f"{SONOS_ENTITY_CREATED}-{self.soco.uid}", self.platform.domain - ) + @property + def coordinator(self) -> SonosSpeaker: + """Return the current coordinator SonosSpeaker.""" + return self.speaker.coordinator or self.speaker @property def unique_id(self) -> str: @@ -411,59 +270,21 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): return self.speaker.zone_name # type: ignore[no-any-return] @property # type: ignore[misc] - @soco_coordinator def state(self) -> str: """Return the state of the entity.""" - if self._status in ( + if self.media.playback_status in ( "PAUSED_PLAYBACK", "STOPPED", ): # Sonos can consider itself "paused" but without having media loaded # (happens if playing Spotify and via Spotify app you pick another device to play on) - if self.media_title is None: + if self.media.title is None: return STATE_IDLE return STATE_PAUSED - if self._status in ("PLAYING", "TRANSITIONING"): + if self.media.playback_status in ("PLAYING", "TRANSITIONING"): return STATE_PLAYING return STATE_IDLE - @property - def is_coordinator(self) -> bool: - """Return true if player is a coordinator.""" - return self._coordinator is None - - @property - def coordinator(self) -> SoCo: - """Return coordinator of this player.""" - return self._coordinator - - def _clear_media_position(self) -> None: - """Clear the media_position.""" - self._media_position = None - self._media_position_updated_at = None - - def _set_favorites(self) -> None: - """Set available favorites.""" - self._favorites = [] - for fav in self.soco.music_library.get_sonos_favorites(): - try: - # Exclude non-playable favorites with no linked resources - if fav.reference.resources: - self._favorites.append(fav) - except SoCoException as ex: - # Skip unknown types - _LOGGER.error("Unhandled favorite '%s': %s", fav.title, ex) - - async def async_reconnect_player(self) -> None: - """Set basic information when player is reconnected.""" - await self.hass.async_add_executor_job(self._reconnect_player) - - def _reconnect_player(self) -> None: - """Set basic information when player is reconnected.""" - self._play_mode = self.soco.play_mode - self.update_volume() - self._set_favorites() - async def async_update(self, now: datetime.datetime | None = None) -> None: """Retrieve latest state.""" await self.hass.async_add_executor_job(self._update, now) @@ -472,310 +293,44 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): """Retrieve latest state.""" _LOGGER.debug("Polling speaker %s", self.speaker.zone_name) try: - self.update_groups() - self.update_volume() - if self.is_coordinator: - self.update_media() + self.speaker.update_groups() + self.speaker.update_volume() + if self.speaker.is_coordinator: + self.speaker.update_media() except SoCoException: pass - @callback - def async_update_media(self, event: SonosEvent | None = None) -> None: - """Update information about currently playing media.""" - self.hass.async_add_executor_job(self.update_media, event) - - def update_media(self, event: SonosEvent | None = None) -> None: - """Update information about currently playing media.""" - variables = event and event.variables - - if variables and "transport_state" in variables: - # If the transport has an error then transport_state will - # not be set - new_status = variables["transport_state"] - else: - transport_info = self.soco.get_current_transport_info() - new_status = transport_info["current_transport_state"] - - # Ignore transitions, we should get the target state soon - if new_status == "TRANSITIONING": - return - - self._play_mode = ( - variables["current_play_mode"] if variables else self.soco.play_mode - ) - self._uri = None - self._media_duration = None - self._media_image_url = None - self._media_channel = None - self._media_artist = None - self._media_album_name = None - self._media_title = None - self._queue_position = None - self._source_name = None - - update_position = new_status != self._status - self._status = new_status - - if variables: - track_uri = variables["current_track_uri"] - music_source = self.soco.music_source_from_uri(track_uri) - else: - # This causes a network round-trip so we avoid it when possible - music_source = self.soco.music_source - - if music_source == MUSIC_SRC_TV: - self.update_media_linein(SOURCE_TV) - elif music_source == MUSIC_SRC_LINE_IN: - self.update_media_linein(SOURCE_LINEIN) - else: - track_info = self.soco.get_current_track_info() - if not track_info["uri"]: - self._clear_media_position() - else: - self._uri = track_info["uri"] - self._media_artist = track_info.get("artist") - self._media_album_name = track_info.get("album") - self._media_title = track_info.get("title") - - if music_source == MUSIC_SRC_RADIO: - self.update_media_radio(variables) - else: - self.update_media_music(update_position, track_info) - - self.schedule_update_ha_state() - - # Also update slaves - entities = self.hass.data[DATA_SONOS].media_player_entities.values() - for entity in entities: - coordinator = entity.coordinator - if coordinator and coordinator.unique_id == self.unique_id: - entity.schedule_update_ha_state() - - def update_media_linein(self, source: str) -> None: - """Update state when playing from line-in/tv.""" - self._clear_media_position() - - self._media_title = source - self._source_name = source - - def update_media_radio(self, variables: dict) -> None: - """Update state when streaming radio.""" - self._clear_media_position() - - try: - album_art_uri = variables["current_track_meta_data"].album_art_uri - self._media_image_url = self._media_library.build_album_art_full_uri( - album_art_uri - ) - except (TypeError, KeyError, AttributeError): - pass - - # Non-playing radios will not have a current title. Radios without tagging - # can have part of the radio URI as title. In these cases we try to use the - # radio name instead. - try: - uri_meta_data = variables["enqueued_transport_uri_meta_data"] - if isinstance( - uri_meta_data, pysonos.data_structures.DidlAudioBroadcast - ) and ( - self.state != STATE_PLAYING - or self.soco.music_source_from_uri(self._media_title) == MUSIC_SRC_RADIO - or ( - isinstance(self._media_title, str) - and isinstance(self._uri, str) - and self._media_title in self._uri - ) - ): - self._media_title = uri_meta_data.title - except (TypeError, KeyError, AttributeError): - pass - - media_info = self.soco.get_current_media_info() - - self._media_channel = media_info["channel"] - - # Check if currently playing radio station is in favorites - for fav in self._favorites: - if fav.reference.get_uri() == media_info["uri"]: - self._source_name = fav.title - - def update_media_music(self, update_media_position: bool, track_info: dict) -> None: - """Update state when playing music tracks.""" - self._media_duration = _timespan_secs(track_info.get("duration")) - current_position = _timespan_secs(track_info.get("position")) - - # player started reporting position? - if current_position is not None and self._media_position is None: - update_media_position = True - - # position jumped? - if current_position is not None and self._media_position is not None: - if self.state == STATE_PLAYING: - assert self._media_position_updated_at is not None - time_delta = utcnow() - self._media_position_updated_at - time_diff = time_delta.total_seconds() - else: - time_diff = 0 - - calculated_position = self._media_position + time_diff - - if abs(calculated_position - current_position) > 1.5: - update_media_position = True - - if current_position is None: - self._clear_media_position() - elif update_media_position: - self._media_position = current_position - self._media_position_updated_at = utcnow() - - self._media_image_url = track_info.get("album_art") - - playlist_position = int(track_info.get("playlist_position")) # type: ignore - if playlist_position > 0: - self._queue_position = playlist_position - 1 - - @callback - def async_update_volume(self, event: SonosEvent) -> None: - """Update information about currently volume settings.""" - variables = event.variables - - if "volume" in variables: - self._player_volume = int(variables["volume"]["Master"]) - - if "mute" in variables: - self._player_muted = variables["mute"]["Master"] == "1" - - if "night_mode" in variables: - self._night_sound = variables["night_mode"] == "1" - - if "dialog_level" in variables: - self._speech_enhance = variables["dialog_level"] == "1" - - self.async_write_ha_state() - - def update_volume(self) -> None: - """Update information about currently volume settings.""" - self._player_volume = self.soco.volume - self._player_muted = self.soco.mute - self._night_sound = self.soco.night_mode - self._speech_enhance = self.soco.dialog_mode - - def update_groups(self, event: SonosEvent | None = None) -> None: - """Handle callback for topology change event.""" - coro = self.create_update_groups_coro(event) - if coro: - self.hass.add_job(coro) # type: ignore - - @callback - def async_update_groups(self, event: SonosEvent | None = None) -> None: - """Handle callback for topology change event.""" - coro = self.create_update_groups_coro(event) - if coro: - self.hass.async_add_job(coro) # type: ignore - - def create_update_groups_coro( - self, event: SonosEvent | None = None - ) -> Coroutine | None: - """Handle callback for topology change event.""" - - def _get_soco_group() -> list[str]: - """Ask SoCo cache for existing topology.""" - coordinator_uid = self.unique_id - slave_uids = [] - - with suppress(SoCoException): - if self.soco.group and self.soco.group.coordinator: - coordinator_uid = self.soco.group.coordinator.uid - slave_uids = [ - p.uid - for p in self.soco.group.members - if p.uid != coordinator_uid - ] - - return [coordinator_uid] + slave_uids - - async def _async_extract_group(event: SonosEvent) -> list[str]: - """Extract group layout from a topology event.""" - group = event and event.zone_player_uui_ds_in_group - if group: - assert isinstance(group, str) - return group.split(",") - - return await self.hass.async_add_executor_job(_get_soco_group) - - @callback - def _async_regroup(group: list[str]) -> None: - """Rebuild internal group layout.""" - sonos_group = [] - for uid in group: - entity = _get_entity_from_soco_uid(self.hass, uid) - if entity: - sonos_group.append(entity) - - self._coordinator = None - self._sonos_group = sonos_group - self.async_write_ha_state() - - for slave_uid in group[1:]: - slave = _get_entity_from_soco_uid(self.hass, slave_uid) - if slave: - # pylint: disable=protected-access - slave._coordinator = self - slave._sonos_group = sonos_group - slave.async_write_ha_state() - - async def _async_handle_group_event(event: SonosEvent) -> None: - """Get async lock and handle event.""" - - async with self.hass.data[DATA_SONOS].topology_condition: - group = await _async_extract_group(event) - - if self.unique_id == group[0]: - _async_regroup(group) - - self.hass.data[DATA_SONOS].topology_condition.notify_all() - - if event and not hasattr(event, "zone_player_uui_ds_in_group"): - return None - - return _async_handle_group_event(event) - - @callback - def async_update_content(self, event: SonosEvent | None = None) -> None: - """Update information about available content.""" - if event and "favorites_update_id" in event.variables: - self.hass.async_add_job(self._set_favorites) - self.async_write_ha_state() - @property def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" - return self._player_volume and self._player_volume / 100 + return self.speaker.volume and self.speaker.volume / 100 @property def is_volume_muted(self) -> bool | None: """Return true if volume is muted.""" - return self._player_muted + return self.speaker.muted @property # type: ignore[misc] - @soco_coordinator def shuffle(self) -> str | None: """Shuffling state.""" - shuffle: str = PLAY_MODES[self._play_mode][0] + shuffle: str = PLAY_MODES[self.media.play_mode][0] return shuffle @property # type: ignore[misc] - @soco_coordinator def repeat(self) -> str | None: """Return current repeat mode.""" - sonos_repeat = PLAY_MODES[self._play_mode][1] + sonos_repeat = PLAY_MODES[self.media.play_mode][1] return SONOS_TO_REPEAT[sonos_repeat] + @property + def media(self) -> SonosMedia: + """Return the SonosMedia object from the coordinator speaker.""" + return self.coordinator.media + @property # type: ignore[misc] - @soco_coordinator def media_content_id(self) -> str | None: """Content id of current playing media.""" - return self._uri + return self.media.uri @property def media_content_type(self) -> str: @@ -783,67 +338,51 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): return MEDIA_TYPE_MUSIC @property # type: ignore[misc] - @soco_coordinator def media_duration(self) -> float | None: """Duration of current playing media in seconds.""" - return self._media_duration + return self.media.duration @property # type: ignore[misc] - @soco_coordinator def media_position(self) -> float | None: """Position of current playing media in seconds.""" - return self._media_position + return self.media.position @property # type: ignore[misc] - @soco_coordinator def media_position_updated_at(self) -> datetime.datetime | None: """When was the position of the current playing media valid.""" - return self._media_position_updated_at + return self.media.position_updated_at @property # type: ignore[misc] - @soco_coordinator def media_image_url(self) -> str | None: """Image url of current playing media.""" - return self._media_image_url or None + return self.media.image_url or None @property # type: ignore[misc] - @soco_coordinator def media_channel(self) -> str | None: """Channel currently playing.""" - return self._media_channel or None + return self.media.channel or None @property # type: ignore[misc] - @soco_coordinator def media_artist(self) -> str | None: """Artist of current playing media, music track only.""" - return self._media_artist or None + return self.media.artist or None @property # type: ignore[misc] - @soco_coordinator def media_album_name(self) -> str | None: """Album name of current playing media, music track only.""" - return self._media_album_name or None + return self.media.album_name or None @property # type: ignore[misc] - @soco_coordinator def media_title(self) -> str | None: """Title of current playing media.""" - return self._media_title or None + return self.media.title or None @property # type: ignore[misc] - @soco_coordinator - def queue_position(self) -> int | None: - """If playing local queue return the position in the queue else None.""" - return self._queue_position - - @property # type: ignore[misc] - @soco_coordinator def source(self) -> str | None: """Name of the current input source.""" - return self._source_name or None + return self.media.source_name or None @property # type: ignore[misc] - @soco_coordinator def supported_features(self) -> int: """Flag media player features that are supported.""" return SUPPORT_SONOS @@ -851,12 +390,12 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): @soco_error() def volume_up(self) -> None: """Volume up media player.""" - self.soco.volume += self._volume_increment + self.soco.volume += VOLUME_INCREMENT @soco_error() def volume_down(self) -> None: """Volume down media player.""" - self.soco.volume -= self._volume_increment + self.soco.volume -= VOLUME_INCREMENT @soco_error() def set_volume_level(self, volume: str) -> None: @@ -864,20 +403,22 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): self.soco.volume = str(int(volume * 100)) @soco_error(UPNP_ERRORS_TO_IGNORE) - @soco_coordinator def set_shuffle(self, shuffle: str) -> None: """Enable/Disable shuffle mode.""" sonos_shuffle = shuffle - sonos_repeat = PLAY_MODES[self._play_mode][1] - self.soco.play_mode = PLAY_MODE_BY_MEANING[(sonos_shuffle, sonos_repeat)] + sonos_repeat = PLAY_MODES[self.media.play_mode][1] + self.coordinator.soco.play_mode = PLAY_MODE_BY_MEANING[ + (sonos_shuffle, sonos_repeat) + ] @soco_error(UPNP_ERRORS_TO_IGNORE) - @soco_coordinator def set_repeat(self, repeat: str) -> None: """Set repeat mode.""" - sonos_shuffle = PLAY_MODES[self._play_mode][0] + sonos_shuffle = PLAY_MODES[self.media.play_mode][0] sonos_repeat = REPEAT_TO_SONOS[repeat] - self.soco.play_mode = PLAY_MODE_BY_MEANING[(sonos_shuffle, sonos_repeat)] + self.coordinator.soco.play_mode = PLAY_MODE_BY_MEANING[ + (sonos_shuffle, sonos_repeat) + ] @soco_error() def mute_volume(self, mute: bool) -> None: @@ -885,35 +426,34 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): self.soco.mute = mute @soco_error() - @soco_coordinator def select_source(self, source: str) -> None: """Select input source.""" + soco = self.coordinator.soco if source == SOURCE_LINEIN: - self.soco.switch_to_line_in() + soco.switch_to_line_in() elif source == SOURCE_TV: - self.soco.switch_to_tv() + soco.switch_to_tv() else: - fav = [fav for fav in self._favorites if fav.title == source] + fav = [fav for fav in self.coordinator.favorites if fav.title == source] if len(fav) == 1: src = fav.pop() uri = src.reference.get_uri() - if self.soco.music_source_from_uri(uri) in [ + if soco.music_source_from_uri(uri) in [ MUSIC_SRC_RADIO, MUSIC_SRC_LINE_IN, ]: - self.soco.play_uri(uri, title=source) + soco.play_uri(uri, title=source) else: - self.soco.clear_queue() - self.soco.add_to_queue(src.reference) - self.soco.play_from_queue(0) + soco.clear_queue() + soco.add_to_queue(src.reference) + soco.play_from_queue(0) @property # type: ignore[misc] - @soco_coordinator def source_list(self) -> list[str]: """List of available input sources.""" - sources = [fav.title for fav in self._favorites] + sources = [fav.title for fav in self.coordinator.favorites] - model = self.speaker.model_name.upper() + model = self.coordinator.model_name.upper() if "PLAY:5" in model or "CONNECT" in model: sources += [SOURCE_LINEIN] elif "PLAYBAR" in model: @@ -924,49 +464,41 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): return sources @soco_error(UPNP_ERRORS_TO_IGNORE) - @soco_coordinator def media_play(self) -> None: """Send play command.""" - self.soco.play() + self.coordinator.soco.play() @soco_error(UPNP_ERRORS_TO_IGNORE) - @soco_coordinator def media_stop(self) -> None: """Send stop command.""" - self.soco.stop() + self.coordinator.soco.stop() @soco_error(UPNP_ERRORS_TO_IGNORE) - @soco_coordinator def media_pause(self) -> None: """Send pause command.""" - self.soco.pause() + self.coordinator.soco.pause() @soco_error(UPNP_ERRORS_TO_IGNORE) - @soco_coordinator def media_next_track(self) -> None: """Send next track command.""" - self.soco.next() + self.coordinator.soco.next() @soco_error(UPNP_ERRORS_TO_IGNORE) - @soco_coordinator def media_previous_track(self) -> None: """Send next track command.""" - self.soco.previous() + self.coordinator.soco.previous() @soco_error(UPNP_ERRORS_TO_IGNORE) - @soco_coordinator def media_seek(self, position: str) -> None: """Send seek command.""" - self.soco.seek(str(datetime.timedelta(seconds=int(position)))) + self.coordinator.soco.seek(str(datetime.timedelta(seconds=int(position)))) @soco_error() - @soco_coordinator def clear_playlist(self) -> None: """Clear players playlist.""" - self.soco.clear_queue() + self.coordinator.soco.clear_queue() @soco_error() - @soco_coordinator def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: """ Send the play_media command to the media player. @@ -978,16 +510,17 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue. """ + soco = self.coordinator.soco if media_id and media_id.startswith(PLEX_URI_SCHEME): media_id = media_id[len(PLEX_URI_SCHEME) :] play_on_sonos(self.hass, media_type, media_id, self.name) # type: ignore[no-untyped-call] elif media_type in (MEDIA_TYPE_MUSIC, MEDIA_TYPE_TRACK): if kwargs.get(ATTR_MEDIA_ENQUEUE): try: - if self.soco.is_service_uri(media_id): - self.soco.add_service_uri_to_queue(media_id) + if soco.is_service_uri(media_id): + soco.add_service_uri_to_queue(media_id) else: - self.soco.add_uri_to_queue(media_id) + soco.add_uri_to_queue(media_id) except SoCoUPnPException: _LOGGER.error( 'Error parsing media uri "%s", ' @@ -996,242 +529,47 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): media_id, ) else: - if self.soco.is_service_uri(media_id): - self.soco.clear_queue() - self.soco.add_service_uri_to_queue(media_id) - self.soco.play_from_queue(0) + if soco.is_service_uri(media_id): + soco.clear_queue() + soco.add_service_uri_to_queue(media_id) + soco.play_from_queue(0) else: - self.soco.play_uri(media_id) + soco.play_uri(media_id) elif media_type == MEDIA_TYPE_PLAYLIST: if media_id.startswith("S:"): - item = get_media(self._media_library, media_id, media_type) # type: ignore[no-untyped-call] - self.soco.play_uri(item.get_uri()) + item = get_media(self.media.library, media_id, media_type) # type: ignore[no-untyped-call] + soco.play_uri(item.get_uri()) return try: - playlists = self.soco.get_sonos_playlists() + playlists = soco.get_sonos_playlists() playlist = next(p for p in playlists if p.title == media_id) - self.soco.clear_queue() - self.soco.add_to_queue(playlist) - self.soco.play_from_queue(0) + soco.clear_queue() + soco.add_to_queue(playlist) + soco.play_from_queue(0) except StopIteration: _LOGGER.error('Could not find a Sonos playlist named "%s"', media_id) elif media_type in PLAYABLE_MEDIA_TYPES: - item = get_media(self._media_library, media_id, media_type) # type: ignore[no-untyped-call] + item = get_media(self.media.library, media_id, media_type) # type: ignore[no-untyped-call] if not item: _LOGGER.error('Could not find "%s" in the library', media_id) return - self.soco.play_uri(item.get_uri()) + soco.play_uri(item.get_uri()) else: _LOGGER.error('Sonos does not support a media type of "%s"', media_type) @soco_error() - def join( - self, slaves: list[SonosMediaPlayerEntity] - ) -> list[SonosMediaPlayerEntity]: - """Form a group with other players.""" - if self._coordinator: - self.unjoin() - group = [self] - else: - group = self._sonos_group.copy() - - for slave in slaves: - if slave.unique_id != self.unique_id: - slave.soco.join(self.soco) - # pylint: disable=protected-access - slave._coordinator = self - if slave not in group: - group.append(slave) - - return group - - @staticmethod - async def join_multi( - hass: HomeAssistant, - master: SonosMediaPlayerEntity, - entities: list[SonosMediaPlayerEntity], - ) -> None: - """Form a group with other players.""" - async with hass.data[DATA_SONOS].topology_condition: - group: list[SonosMediaPlayerEntity] = await hass.async_add_executor_job( - master.join, entities - ) - await SonosMediaPlayerEntity.wait_for_groups(hass, [group]) - - @soco_error() - def unjoin(self) -> None: - """Unjoin the player from a group.""" - self.soco.unjoin() - self._coordinator = None - - @staticmethod - async def unjoin_multi( - hass: HomeAssistant, entities: list[SonosMediaPlayerEntity] - ) -> None: - """Unjoin several players from their group.""" - - def _unjoin_all(entities: list[SonosMediaPlayerEntity]) -> None: - """Sync helper.""" - # Unjoin slaves first to prevent inheritance of queues - coordinators = [e for e in entities if e.is_coordinator] - slaves = [e for e in entities if not e.is_coordinator] - - for entity in slaves + coordinators: - entity.unjoin() - - async with hass.data[DATA_SONOS].topology_condition: - await hass.async_add_executor_job(_unjoin_all, entities) - await SonosMediaPlayerEntity.wait_for_groups(hass, [[e] for e in entities]) - - @soco_error() - def snapshot(self, with_group: bool) -> None: - """Snapshot the state of a player.""" - self._soco_snapshot = pysonos.snapshot.Snapshot(self.soco) - self._soco_snapshot.snapshot() - if with_group: - self._snapshot_group = self._sonos_group.copy() - else: - self._snapshot_group = None - - @staticmethod - async def snapshot_multi( - hass: HomeAssistant, entities: list[SonosMediaPlayerEntity], with_group: bool - ) -> None: - """Snapshot all the entities and optionally their groups.""" - # pylint: disable=protected-access - - def _snapshot_all(entities: list[SonosMediaPlayerEntity]) -> None: - """Sync helper.""" - for entity in entities: - entity.snapshot(with_group) - - # Find all affected players - entities_set = set(entities) - if with_group: - for entity in list(entities_set): - entities_set.update(entity._sonos_group) - - async with hass.data[DATA_SONOS].topology_condition: - await hass.async_add_executor_job(_snapshot_all, entities_set) - - @soco_error() - def restore(self) -> None: - """Restore a snapshotted state to a player.""" - try: - assert self._soco_snapshot is not None - self._soco_snapshot.restore() - except (TypeError, AssertionError, AttributeError, SoCoException) as ex: - # Can happen if restoring a coordinator onto a current slave - _LOGGER.warning("Error on restore %s: %s", self.entity_id, ex) - - self._soco_snapshot = None - self._snapshot_group = None - - @staticmethod - async def restore_multi( - hass: HomeAssistant, entities: list[SonosMediaPlayerEntity], with_group: bool - ) -> None: - """Restore snapshots for all the entities.""" - # pylint: disable=protected-access - - def _restore_groups( - entities: list[SonosMediaPlayerEntity], with_group: bool - ) -> list[list[SonosMediaPlayerEntity]]: - """Pause all current coordinators and restore groups.""" - for entity in (e for e in entities if e.is_coordinator): - if entity.state == STATE_PLAYING: - entity.media_pause() - - groups = [] - - if with_group: - # Unjoin slaves first to prevent inheritance of queues - for entity in [e for e in entities if not e.is_coordinator]: - if entity._snapshot_group != entity._sonos_group: - entity.unjoin() - - # Bring back the original group topology - for entity in (e for e in entities if e._snapshot_group): - assert entity._snapshot_group is not None - if entity._snapshot_group[0] == entity: - entity.join(entity._snapshot_group) - groups.append(entity._snapshot_group.copy()) - - return groups - - def _restore_players(entities: list[SonosMediaPlayerEntity]) -> None: - """Restore state of all players.""" - for entity in (e for e in entities if not e.is_coordinator): - entity.restore() - - for entity in (e for e in entities if e.is_coordinator): - entity.restore() - - # Find all affected players - entities_set = {e for e in entities if e._soco_snapshot} - if with_group: - for entity in [e for e in entities_set if e._snapshot_group]: - assert entity._snapshot_group is not None - entities_set.update(entity._snapshot_group) - - async with hass.data[DATA_SONOS].topology_condition: - groups = await hass.async_add_executor_job( - _restore_groups, entities_set, with_group - ) - - await SonosMediaPlayerEntity.wait_for_groups(hass, groups) - - await hass.async_add_executor_job(_restore_players, entities_set) - - @staticmethod - async def wait_for_groups( - hass: HomeAssistant, groups: list[list[SonosMediaPlayerEntity]] - ) -> None: - """Wait until all groups are present, or timeout.""" - # pylint: disable=protected-access - - def _test_groups(groups: list[list[SonosMediaPlayerEntity]]) -> bool: - """Return whether all groups exist now.""" - for group in groups: - coordinator = group[0] - - # Test that coordinator is coordinating - current_group = coordinator._sonos_group - if coordinator != current_group[0]: - return False - - # Test that slaves match - if set(group[1:]) != set(current_group[1:]): - return False - - return True - - try: - with async_timeout.timeout(5): - while not _test_groups(groups): - await hass.data[DATA_SONOS].topology_condition.wait() - except asyncio.TimeoutError: - _LOGGER.warning("Timeout waiting for target groups %s", groups) - - for entity in hass.data[DATA_SONOS].media_player_entities.values(): - entity.soco._zgs_cache.clear() - - @soco_error() - @soco_coordinator def set_sleep_timer(self, sleep_time: int) -> None: """Set the timer on the player.""" - self.soco.set_sleep_timer(sleep_time) + self.coordinator.soco.set_sleep_timer(sleep_time) @soco_error() - @soco_coordinator def clear_sleep_timer(self) -> None: """Clear the timer on the player.""" - self.soco.set_sleep_timer(None) + self.coordinator.soco.set_sleep_timer(None) @soco_error() - @soco_coordinator def set_alarm( self, alarm_id: int, @@ -1242,7 +580,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): ) -> None: """Set the alarm clock on the player.""" alarm = None - for one_alarm in alarms.get_alarms(self.soco): + for one_alarm in alarms.get_alarms(self.coordinator.soco): # pylint: disable=protected-access if one_alarm._alarm_id == str(alarm_id): alarm = one_alarm @@ -1267,10 +605,10 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): status_light: bool | None = None, ) -> None: """Modify playback options.""" - if night_sound is not None and self._night_sound is not None: + if night_sound is not None and self.speaker.night_mode is not None: self.soco.night_mode = night_sound - if speech_enhance is not None and self._speech_enhance is not None: + if speech_enhance is not None and self.speaker.dialog_mode is not None: self.soco.dialog_mode = speech_enhance if status_light is not None: @@ -1282,26 +620,25 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): self.soco.play_from_queue(queue_position) @soco_error() - @soco_coordinator def remove_from_queue(self, queue_position: int = 0) -> None: """Remove item from the queue.""" - self.soco.remove_from_queue(queue_position) + self.coordinator.soco.remove_from_queue(queue_position) @property def extra_state_attributes(self) -> dict[str, Any]: """Return entity specific state attributes.""" attributes: dict[str, Any] = { - ATTR_SONOS_GROUP: [e.entity_id for e in self._sonos_group] + ATTR_SONOS_GROUP: self.speaker.sonos_group_entities } - if self._night_sound is not None: - attributes[ATTR_NIGHT_SOUND] = self._night_sound + if self.speaker.night_mode is not None: + attributes[ATTR_NIGHT_SOUND] = self.speaker.night_mode - if self._speech_enhance is not None: - attributes[ATTR_SPEECH_ENHANCE] = self._speech_enhance + if self.speaker.dialog_mode is not None: + attributes[ATTR_SPEECH_ENHANCE] = self.speaker.dialog_mode - if self.queue_position is not None: - attributes[ATTR_QUEUE_POSITION] = self.queue_position + if self.media.queue_position is not None: + attributes[ATTR_QUEUE_POSITION] = self.media.queue_position return attributes @@ -1318,7 +655,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): ): item = await self.hass.async_add_executor_job( get_media, - self._media_library, + self.media.library, media_content_id, MEDIA_TYPES_TO_SONOS[media_content_type], ) @@ -1342,7 +679,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): ) -> str | None: if is_internal: item = get_media( # type: ignore[no-untyped-call] - self._media_library, + self.media.library, media_content_id, media_content_type, ) @@ -1356,7 +693,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): if media_content_type in [None, "library"]: return await self.hass.async_add_executor_job( - library_payload, self._media_library, _get_thumbnail_url + library_payload, self.media.library, _get_thumbnail_url ) payload = { @@ -1364,7 +701,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): "idstring": media_content_id, } response = await self.hass.async_add_executor_job( - build_item_response, self._media_library, payload, _get_thumbnail_url + build_item_response, self.media.library, payload, _get_thumbnail_url ) if response is None: raise BrowseError( diff --git a/homeassistant/components/sonos/sensor.py b/homeassistant/components/sonos/sensor.py index 38d6d679219..fcb856e1c06 100644 --- a/homeassistant/components/sonos/sensor.py +++ b/homeassistant/components/sonos/sensor.py @@ -9,7 +9,7 @@ from homeassistant.const import DEVICE_CLASS_BATTERY, PERCENTAGE from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import SONOS_CREATE_BATTERY -from .entity import SonosSensorEntity +from .entity import SonosEntity from .speaker import SonosSpeaker _LOGGER = logging.getLogger(__name__) @@ -27,7 +27,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) -class SonosBatteryEntity(SonosSensorEntity, SensorEntity): +class SonosBatteryEntity(SonosEntity, SensorEntity): """Representation of a Sonos Battery entity.""" @property diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 03cce67e4d8..7c44245bf5d 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -1,20 +1,28 @@ """Base class for common speaker tasks.""" from __future__ import annotations -from asyncio import gather +import asyncio +from collections.abc import Coroutine import contextlib import datetime from functools import partial import logging from typing import Any, Callable -from pysonos.core import SoCo +import async_timeout +from pysonos.core import MUSIC_SRC_LINE_IN, MUSIC_SRC_RADIO, MUSIC_SRC_TV, SoCo +from pysonos.data_structures import DidlAudioBroadcast, DidlFavorite from pysonos.events_base import Event as SonosEvent, SubscriptionBase from pysonos.exceptions import SoCoException +from pysonos.music_library import MusicLibrary +from pysonos.snapshot import Snapshot from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import STATE_PLAYING from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as ent_reg from homeassistant.helpers.dispatcher import ( async_dispatcher_send, dispatcher_connect, @@ -24,26 +32,29 @@ from homeassistant.util import dt as dt_util from .const import ( BATTERY_SCAN_INTERVAL, + DATA_SONOS, + DOMAIN, PLATFORMS, SCAN_INTERVAL, SEEN_EXPIRE_TIME, - SONOS_CONTENT_UPDATE, SONOS_CREATE_BATTERY, SONOS_CREATE_MEDIA_PLAYER, SONOS_ENTITY_CREATED, SONOS_ENTITY_UPDATE, SONOS_GROUP_UPDATE, - SONOS_MEDIA_UPDATE, - SONOS_PLAYER_RECONNECTED, SONOS_SEEN, SONOS_STATE_UPDATED, - SONOS_VOLUME_UPDATE, + SOURCE_LINEIN, + SOURCE_TV, ) +from .helpers import soco_error EVENT_CHARGING = { "CHARGING": True, "NOT_CHARGING": False, } +UNAVAILABLE_VALUES = {"", "NOT_IMPLEMENTED", None} + _LOGGER = logging.getLogger(__name__) @@ -58,6 +69,55 @@ def fetch_battery_info_or_none(soco: SoCo) -> dict[str, Any] | None: return soco.get_battery_info() +def _timespan_secs(timespan: str | None) -> None | float: + """Parse a time-span into number of seconds.""" + if timespan in UNAVAILABLE_VALUES: + return None + + assert timespan is not None + return sum(60 ** x[0] * int(x[1]) for x in enumerate(reversed(timespan.split(":")))) + + +class SonosMedia: + """Representation of the current Sonos media.""" + + def __init__(self, soco: SoCo) -> None: + """Initialize a SonosMedia.""" + self.library = MusicLibrary(soco) + self.play_mode: str | None = None + self.playback_status: str | None = None + + self.album_name: str | None = None + self.artist: str | None = None + self.channel: str | None = None + self.duration: float | None = None + self.image_url: str | None = None + self.queue_position: int | None = None + self.source_name: str | None = None + self.title: str | None = None + self.uri: str | None = None + + self.position: float | None = None + self.position_updated_at: datetime.datetime | None = None + + def clear(self) -> None: + """Clear basic media info.""" + self.album_name = None + self.artist = None + self.channel = None + self.duration = None + self.image_url = None + self.queue_position = None + self.source_name = None + self.title = None + self.uri = None + + def clear_position(self) -> None: + """Clear the position attributes.""" + self.position = None + self.position_updated_at = None + + class SonosSpeaker: """Representation of a Sonos speaker.""" @@ -65,16 +125,19 @@ class SonosSpeaker: self, hass: HomeAssistant, soco: SoCo, speaker_info: dict[str, Any] ) -> None: """Initialize a SonosSpeaker.""" + self.hass: HomeAssistant = hass + self.soco: SoCo = soco + self.media = SonosMedia(soco) + self._is_ready: bool = False self._subscriptions: list[SubscriptionBase] = [] self._poll_timer: Callable | None = None self._seen_timer: Callable | None = None - self._seen_dispatcher: Callable | None = None - self._entity_creation_dispatcher: Callable | None = None self._platforms_ready: set[str] = set() - self.hass: HomeAssistant = hass - self.soco: SoCo = soco + self._entity_creation_dispatcher: Callable | None = None + self._group_dispatcher: Callable | None = None + self._seen_dispatcher: Callable | None = None self.mac_address = speaker_info["mac_address"] self.model_name = speaker_info["model_name"] @@ -85,13 +148,33 @@ class SonosSpeaker: self._last_battery_event: datetime.datetime | None = None self._battery_poll_timer: Callable | None = None + self.volume: int | None = None + self.muted: bool | None = None + self.night_mode: bool | None = None + self.dialog_mode: bool | None = None + + self.coordinator: SonosSpeaker | None = None + self.sonos_group: list[SonosSpeaker] = [self] + self.sonos_group_entities: list[str] = [] + self.soco_snapshot: Snapshot | None = None + self.snapshot_group: list[SonosSpeaker] | None = None + + self.favorites: list[DidlFavorite] = [] + def setup(self) -> None: """Run initial setup of the speaker.""" + self.set_basic_info() + self._entity_creation_dispatcher = dispatcher_connect( self.hass, f"{SONOS_ENTITY_CREATED}-{self.soco.uid}", self.async_handle_new_entity, ) + self._group_dispatcher = dispatcher_connect( + self.hass, + SONOS_GROUP_UPDATE, + self.async_update_groups, + ) self._seen_dispatcher = dispatcher_connect( self.hass, f"{SONOS_SEEN}-{self.soco.uid}", self.async_seen ) @@ -115,11 +198,21 @@ class SonosSpeaker: await self.async_subscribe() self._is_ready = True + def write_entity_states(self) -> None: + """Write states for associated SonosEntity instances.""" + dispatcher_send(self.hass, f"{SONOS_STATE_UPDATED}-{self.soco.uid}") + @callback def async_write_entity_states(self) -> None: """Write states for associated SonosEntity instances.""" async_dispatcher_send(self.hass, f"{SONOS_STATE_UPDATED}-{self.soco.uid}") + def set_basic_info(self) -> None: + """Set basic information when speaker is reconnected.""" + self.media.play_mode = self.soco.play_mode + self.update_volume() + self.set_favorites() + @property def available(self) -> bool: """Return whether this speaker is available.""" @@ -129,7 +222,7 @@ class SonosSpeaker: """Initiate event subscriptions.""" _LOGGER.debug("Creating subscriptions for %s", self.zone_name) try: - self.async_dispatch_player_reconnected() + await self.hass.async_add_executor_job(self.set_basic_info) if self._subscriptions: raise RuntimeError( @@ -137,12 +230,10 @@ class SonosSpeaker: f"when existing subscriptions exist: {self._subscriptions}" ) - await gather( - self._subscribe(self.soco.avTransport, self.async_dispatch_media), - self._subscribe(self.soco.renderingControl, self.async_dispatch_volume), - self._subscribe( - self.soco.contentDirectory, self.async_dispatch_content - ), + await asyncio.gather( + self._subscribe(self.soco.avTransport, self.async_update_media), + self._subscribe(self.soco.renderingControl, self.async_update_volume), + self._subscribe(self.soco.contentDirectory, self.async_update_content), self._subscribe( self.soco.zoneGroupTopology, self.async_dispatch_groups ), @@ -163,25 +254,6 @@ class SonosSpeaker: subscription.callback = sub_callback self._subscriptions.append(subscription) - @callback - def async_dispatch_media(self, event: SonosEvent | None = None) -> None: - """Update currently playing media from event.""" - async_dispatcher_send(self.hass, f"{SONOS_MEDIA_UPDATE}-{self.soco.uid}", event) - - @callback - def async_dispatch_content(self, event: SonosEvent | None = None) -> None: - """Update available content from event.""" - async_dispatcher_send( - self.hass, f"{SONOS_CONTENT_UPDATE}-{self.soco.uid}", event - ) - - @callback - def async_dispatch_volume(self, event: SonosEvent | None = None) -> None: - """Update volume from event.""" - async_dispatcher_send( - self.hass, f"{SONOS_VOLUME_UPDATE}-{self.soco.uid}", event - ) - @callback def async_dispatch_properties(self, event: SonosEvent | None = None) -> None: """Update properties from event.""" @@ -197,12 +269,7 @@ class SonosSpeaker: self._poll_timer() self._poll_timer = None - async_dispatcher_send(self.hass, SONOS_GROUP_UPDATE, event) - - @callback - def async_dispatch_player_reconnected(self) -> None: - """Signal that player has been reconnected.""" - async_dispatcher_send(self.hass, f"{SONOS_PLAYER_RECONNECTED}-{self.soco.uid}") + self.async_update_groups(event) async def async_seen(self, soco: SoCo | None = None) -> None: """Record that this speaker was seen right now.""" @@ -280,6 +347,11 @@ class SonosSpeaker: ): self.battery_info = battery_info + @property + def is_coordinator(self) -> bool: + """Return true if player is a coordinator.""" + return self.coordinator is None + @property def power_source(self) -> str: """Return the name of the current power source. @@ -309,3 +381,460 @@ class SonosSpeaker: ): self.battery_info = battery_info self.async_write_entity_states() + + def update_groups(self, event: SonosEvent | None = None) -> None: + """Handle callback for topology change event.""" + coro = self.create_update_groups_coro(event) + if coro: + self.hass.add_job(coro) # type: ignore + + @callback + def async_update_groups(self, event: SonosEvent | None = None) -> None: + """Handle callback for topology change event.""" + coro = self.create_update_groups_coro(event) + if coro: + self.hass.async_add_job(coro) # type: ignore + + def create_update_groups_coro( + self, event: SonosEvent | None = None + ) -> Coroutine | None: + """Handle callback for topology change event.""" + + def _get_soco_group() -> list[str]: + """Ask SoCo cache for existing topology.""" + coordinator_uid = self.soco.uid + slave_uids = [] + + with contextlib.suppress(SoCoException): + if self.soco.group and self.soco.group.coordinator: + coordinator_uid = self.soco.group.coordinator.uid + slave_uids = [ + p.uid + for p in self.soco.group.members + if p.uid != coordinator_uid + ] + + return [coordinator_uid] + slave_uids + + async def _async_extract_group(event: SonosEvent) -> list[str]: + """Extract group layout from a topology event.""" + group = event and event.zone_player_uui_ds_in_group + if group: + assert isinstance(group, str) + return group.split(",") + + return await self.hass.async_add_executor_job(_get_soco_group) + + @callback + def _async_regroup(group: list[str]) -> None: + """Rebuild internal group layout.""" + entity_registry = ent_reg.async_get(self.hass) + sonos_group = [] + sonos_group_entities = [] + + for uid in group: + speaker = self.hass.data[DATA_SONOS].discovered.get(uid) + if speaker: + sonos_group.append(speaker) + entity_id = entity_registry.async_get_entity_id( + MP_DOMAIN, DOMAIN, uid + ) + sonos_group_entities.append(entity_id) + + self.coordinator = None + self.sonos_group = sonos_group + self.sonos_group_entities = sonos_group_entities + self.async_write_entity_states() + + for slave_uid in group[1:]: + slave = self.hass.data[DATA_SONOS].discovered.get(slave_uid) + if slave: + slave.coordinator = self + slave.sonos_group = sonos_group + slave.sonos_group_entities = sonos_group_entities + slave.async_write_entity_states() + + async def _async_handle_group_event(event: SonosEvent) -> None: + """Get async lock and handle event.""" + + async with self.hass.data[DATA_SONOS].topology_condition: + group = await _async_extract_group(event) + + if self.soco.uid == group[0]: + _async_regroup(group) + + self.hass.data[DATA_SONOS].topology_condition.notify_all() + + if event and not hasattr(event, "zone_player_uui_ds_in_group"): + return None + + return _async_handle_group_event(event) + + @soco_error() + def join(self, slaves: list[SonosSpeaker]) -> list[SonosSpeaker]: + """Form a group with other players.""" + if self.coordinator: + self.unjoin() + group = [self] + else: + group = self.sonos_group.copy() + + for slave in slaves: + if slave.soco.uid != self.soco.uid: + slave.soco.join(self.soco) + slave.coordinator = self + if slave not in group: + group.append(slave) + + return group + + @staticmethod + async def join_multi( + hass: HomeAssistant, + master: SonosSpeaker, + speakers: list[SonosSpeaker], + ) -> None: + """Form a group with other players.""" + async with hass.data[DATA_SONOS].topology_condition: + group: list[SonosSpeaker] = await hass.async_add_executor_job( + master.join, speakers + ) + await SonosSpeaker.wait_for_groups(hass, [group]) + + @soco_error() + def unjoin(self) -> None: + """Unjoin the player from a group.""" + self.soco.unjoin() + self.coordinator = None + + @staticmethod + async def unjoin_multi(hass: HomeAssistant, speakers: list[SonosSpeaker]) -> None: + """Unjoin several players from their group.""" + + def _unjoin_all(speakers: list[SonosSpeaker]) -> None: + """Sync helper.""" + # Unjoin slaves first to prevent inheritance of queues + coordinators = [s for s in speakers if s.is_coordinator] + slaves = [s for s in speakers if not s.is_coordinator] + + for speaker in slaves + coordinators: + speaker.unjoin() + + async with hass.data[DATA_SONOS].topology_condition: + await hass.async_add_executor_job(_unjoin_all, speakers) + await SonosSpeaker.wait_for_groups(hass, [[s] for s in speakers]) + + @soco_error() + def snapshot(self, with_group: bool) -> None: + """Snapshot the state of a player.""" + self.soco_snapshot = Snapshot(self.soco) + self.soco_snapshot.snapshot() + if with_group: + self.snapshot_group = self.sonos_group.copy() + else: + self.snapshot_group = None + + @staticmethod + async def snapshot_multi( + hass: HomeAssistant, speakers: list[SonosSpeaker], with_group: bool + ) -> None: + """Snapshot all the speakers and optionally their groups.""" + + def _snapshot_all(speakers: list[SonosSpeaker]) -> None: + """Sync helper.""" + for speaker in speakers: + speaker.snapshot(with_group) + + # Find all affected players + speakers_set = set(speakers) + if with_group: + for speaker in list(speakers_set): + speakers_set.update(speaker.sonos_group) + + async with hass.data[DATA_SONOS].topology_condition: + await hass.async_add_executor_job(_snapshot_all, speakers_set) + + @soco_error() + def restore(self) -> None: + """Restore a snapshotted state to a player.""" + try: + assert self.soco_snapshot is not None + self.soco_snapshot.restore() + except (TypeError, AssertionError, AttributeError, SoCoException) as ex: + # Can happen if restoring a coordinator onto a current slave + _LOGGER.warning("Error on restore %s: %s", self.zone_name, ex) + + self.soco_snapshot = None + self.snapshot_group = None + + @staticmethod + async def restore_multi( + hass: HomeAssistant, speakers: list[SonosSpeaker], with_group: bool + ) -> None: + """Restore snapshots for all the speakers.""" + + def _restore_groups( + speakers: list[SonosSpeaker], with_group: bool + ) -> list[list[SonosSpeaker]]: + """Pause all current coordinators and restore groups.""" + for speaker in (s for s in speakers if s.is_coordinator): + if speaker.media.playback_status == STATE_PLAYING: + hass.async_create_task(speaker.soco.pause()) + + groups = [] + + if with_group: + # Unjoin slaves first to prevent inheritance of queues + for speaker in [s for s in speakers if not s.is_coordinator]: + if speaker.snapshot_group != speaker.sonos_group: + speaker.unjoin() + + # Bring back the original group topology + for speaker in (s for s in speakers if s.snapshot_group): + assert speaker.snapshot_group is not None + if speaker.snapshot_group[0] == speaker: + speaker.join(speaker.snapshot_group) + groups.append(speaker.snapshot_group.copy()) + + return groups + + def _restore_players(speakers: list[SonosSpeaker]) -> None: + """Restore state of all players.""" + for speaker in (s for s in speakers if not s.is_coordinator): + speaker.restore() + + for speaker in (s for s in speakers if s.is_coordinator): + speaker.restore() + + # Find all affected players + speakers_set = {s for s in speakers if s.soco_snapshot} + if with_group: + for speaker in [s for s in speakers_set if s.snapshot_group]: + assert speaker.snapshot_group is not None + speakers_set.update(speaker.snapshot_group) + + async with hass.data[DATA_SONOS].topology_condition: + groups = await hass.async_add_executor_job( + _restore_groups, speakers_set, with_group + ) + await SonosSpeaker.wait_for_groups(hass, groups) + await hass.async_add_executor_job(_restore_players, speakers_set) + + @staticmethod + async def wait_for_groups( + hass: HomeAssistant, groups: list[list[SonosSpeaker]] + ) -> None: + """Wait until all groups are present, or timeout.""" + + def _test_groups(groups: list[list[SonosSpeaker]]) -> bool: + """Return whether all groups exist now.""" + for group in groups: + coordinator = group[0] + + # Test that coordinator is coordinating + current_group = coordinator.sonos_group + if coordinator != current_group[0]: + return False + + # Test that slaves match + if set(group[1:]) != set(current_group[1:]): + return False + + return True + + try: + with async_timeout.timeout(5): + while not _test_groups(groups): + await hass.data[DATA_SONOS].topology_condition.wait() + except asyncio.TimeoutError: + _LOGGER.warning("Timeout waiting for target groups %s", groups) + + for speaker in hass.data[DATA_SONOS].discovered.values(): + speaker.soco._zgs_cache.clear() # pylint: disable=protected-access + + def set_favorites(self) -> None: + """Set available favorites.""" + self.favorites = [] + for fav in self.soco.music_library.get_sonos_favorites(): + try: + # Exclude non-playable favorites with no linked resources + if fav.reference.resources: + self.favorites.append(fav) + except SoCoException as ex: + # Skip unknown types + _LOGGER.error("Unhandled favorite '%s': %s", fav.title, ex) + + @callback + def async_update_content(self, event: SonosEvent | None = None) -> None: + """Update information about available content.""" + if event and "favorites_update_id" in event.variables: + self.hass.async_add_job(self.set_favorites) + self.async_write_entity_states() + + def update_volume(self) -> None: + """Update information about current volume settings.""" + self.volume = self.soco.volume + self.muted = self.soco.mute + self.night_mode = self.soco.night_mode + self.dialog_mode = self.soco.dialog_mode + + @callback + def async_update_volume(self, event: SonosEvent) -> None: + """Update information about currently volume settings.""" + variables = event.variables + + if "volume" in variables: + self.volume = int(variables["volume"]["Master"]) + + if "mute" in variables: + self.muted = variables["mute"]["Master"] == "1" + + if "night_mode" in variables: + self.night_mode = variables["night_mode"] == "1" + + if "dialog_level" in variables: + self.dialog_mode = variables["dialog_level"] == "1" + + self.async_write_entity_states() + + @callback + def async_update_media(self, event: SonosEvent | None = None) -> None: + """Update information about currently playing media.""" + self.hass.async_add_executor_job(self.update_media, event) + + def update_media(self, event: SonosEvent | None = None) -> None: + """Update information about currently playing media.""" + variables = event and event.variables + + if variables and "transport_state" in variables: + # If the transport has an error then transport_state will + # not be set + new_status = variables["transport_state"] + else: + transport_info = self.soco.get_current_transport_info() + new_status = transport_info["current_transport_state"] + + # Ignore transitions, we should get the target state soon + if new_status == "TRANSITIONING": + return + + self.media.clear() + update_position = new_status != self.media.playback_status + self.media.playback_status = new_status + + if variables: + self.media.play_mode = variables["current_play_mode"] + track_uri = variables["current_track_uri"] + music_source = self.soco.music_source_from_uri(track_uri) + else: + # This causes a network round-trip so we avoid it when possible + self.media.play_mode = self.soco.play_mode + music_source = self.soco.music_source + + if music_source == MUSIC_SRC_TV: + self.update_media_linein(SOURCE_TV) + elif music_source == MUSIC_SRC_LINE_IN: + self.update_media_linein(SOURCE_LINEIN) + else: + track_info = self.soco.get_current_track_info() + if not track_info["uri"]: + self.media.clear_position() + else: + self.media.uri = track_info["uri"] + self.media.artist = track_info.get("artist") + self.media.album_name = track_info.get("album") + self.media.title = track_info.get("title") + + if music_source == MUSIC_SRC_RADIO: + self.update_media_radio(variables) + else: + self.update_media_music(update_position, track_info) + + self.write_entity_states() + + # Also update slaves + speakers = self.hass.data[DATA_SONOS].discovered.values() + for speaker in speakers: + if speaker.coordinator == self: + speaker.write_entity_states() + + def update_media_linein(self, source: str) -> None: + """Update state when playing from line-in/tv.""" + self.media.clear_position() + + self.media.title = source + self.media.source_name = source + + def update_media_radio(self, variables: dict) -> None: + """Update state when streaming radio.""" + self.media.clear_position() + + try: + album_art_uri = variables["current_track_meta_data"].album_art_uri + self.media.image_url = self.media.library.build_album_art_full_uri( + album_art_uri + ) + except (TypeError, KeyError, AttributeError): + pass + + # Non-playing radios will not have a current title. Radios without tagging + # can have part of the radio URI as title. In these cases we try to use the + # radio name instead. + try: + uri_meta_data = variables["enqueued_transport_uri_meta_data"] + if isinstance(uri_meta_data, DidlAudioBroadcast) and ( + self.media.playback_status != STATE_PLAYING + or self.soco.music_source_from_uri(self.media.title) == MUSIC_SRC_RADIO + or ( + isinstance(self.media.title, str) + and isinstance(self.media.uri, str) + and self.media.title in self.media.uri + ) + ): + self.media.title = uri_meta_data.title + except (TypeError, KeyError, AttributeError): + pass + + media_info = self.soco.get_current_media_info() + + self.media.channel = media_info["channel"] + + # Check if currently playing radio station is in favorites + for fav in self.favorites: + if fav.reference.get_uri() == media_info["uri"]: + self.media.source_name = fav.title + + def update_media_music(self, update_media_position: bool, track_info: dict) -> None: + """Update state when playing music tracks.""" + self.media.duration = _timespan_secs(track_info.get("duration")) + current_position = _timespan_secs(track_info.get("position")) + + # player started reporting position? + if current_position is not None and self.media.position is None: + update_media_position = True + + # position jumped? + if current_position is not None and self.media.position is not None: + if self.media.playback_status == STATE_PLAYING: + assert self.media.position_updated_at is not None + time_delta = dt_util.utcnow() - self.media.position_updated_at + time_diff = time_delta.total_seconds() + else: + time_diff = 0 + + calculated_position = self.media.position + time_diff + + if abs(calculated_position - current_position) > 1.5: + update_media_position = True + + if current_position is None: + self.media.clear_position() + elif update_media_position: + self.media.position = current_position + self.media.position_updated_at = dt_util.utcnow() + + self.media.image_url = track_info.get("album_art") + + playlist_position = int(track_info.get("playlist_position")) # type: ignore + if playlist_position > 0: + self.media.queue_position = playlist_position - 1 diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index a2eb5d26645..460c9012aeb 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -1,7 +1,7 @@ """Tests for the Sonos Media Player platform.""" import pytest -from homeassistant.components.sonos import DOMAIN, media_player +from homeassistant.components.sonos import DATA_SONOS, DOMAIN, media_player from homeassistant.const import STATE_IDLE from homeassistant.core import Context from homeassistant.exceptions import Unauthorized @@ -20,18 +20,24 @@ async def test_async_setup_entry_hosts(hass, config_entry, config, soco): """Test static setup.""" await setup_platform(hass, config_entry, config) - entities = list(hass.data[media_player.DATA_SONOS].media_player_entities.values()) - entity = entities[0] - assert entity.soco == soco + speakers = list(hass.data[DATA_SONOS].discovered.values()) + speaker = speakers[0] + assert speaker.soco == soco + + media_player = hass.states.get("media_player.zone_a") + assert media_player.state == STATE_IDLE async def test_async_setup_entry_discover(hass, config_entry, discover): """Test discovery setup.""" await setup_platform(hass, config_entry, {}) - entities = list(hass.data[media_player.DATA_SONOS].media_player_entities.values()) - entity = entities[0] - assert entity.unique_id == "RINCON_test" + speakers = list(hass.data[DATA_SONOS].discovered.values()) + speaker = speakers[0] + assert speaker.soco.uid == "RINCON_test" + + media_player = hass.states.get("media_player.zone_a") + assert media_player.state == STATE_IDLE async def test_services(hass, config_entry, config, hass_read_only_user):