diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index cdc1169f9f7..c513a73b6e8 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections import OrderedDict import datetime import logging import socket @@ -32,6 +33,7 @@ from .const import ( SONOS_GROUP_UPDATE, SONOS_SEEN, ) +from .favorites import SonosFavorites from .speaker import SonosSpeaker _LOGGER = logging.getLogger(__name__) @@ -65,7 +67,9 @@ class SonosData: def __init__(self) -> None: """Initialize the data.""" - self.discovered: dict[str, SonosSpeaker] = {} + # OrderedDict behavior used by SonosFavorites + self.discovered: OrderedDict[str, SonosSpeaker] = OrderedDict() + self.favorites: dict[str, SonosFavorites] = {} self.topology_condition = asyncio.Condition() self.discovery_thread = None self.hosts_heartbeat = None @@ -122,10 +126,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data = hass.data[DATA_SONOS] if soco.uid not in data.discovered: - _LOGGER.debug("Adding new speaker") speaker_info = soco.get_speaker_info(True) + _LOGGER.debug("Adding new speaker: %s", speaker_info) speaker = SonosSpeaker(hass, soco, speaker_info) data.discovered[soco.uid] = speaker + if soco.household_id not in data.favorites: + data.favorites[soco.household_id] = SonosFavorites( + hass, soco.household_id + ) + data.favorites[soco.household_id].update() speaker.setup() else: dispatcher_send(hass, f"{SONOS_SEEN}-{soco.uid}", soco) diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index e016a473328..9aaecee08af 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -136,6 +136,7 @@ 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_HOUSEHOLD_UPDATED = "sonos_household_updated" SONOS_STATE_UPDATED = "sonos_state_updated" SONOS_SEEN = "sonos_seen" diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index 146725f90e2..8632357d618 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -16,6 +16,7 @@ from .const import ( DOMAIN, SONOS_ENTITY_CREATED, SONOS_ENTITY_UPDATE, + SONOS_HOUSEHOLD_UPDATED, SONOS_STATE_UPDATED, ) from .speaker import SonosSpeaker @@ -48,6 +49,13 @@ class SonosEntity(Entity): self.async_write_ha_state, ) ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SONOS_HOUSEHOLD_UPDATED}-{self.soco.household_id}", + self.async_write_ha_state, + ) + ) async_dispatcher_send( self.hass, f"{SONOS_ENTITY_CREATED}-{self.soco.uid}", self.platform.domain ) diff --git a/homeassistant/components/sonos/favorites.py b/homeassistant/components/sonos/favorites.py new file mode 100644 index 00000000000..19dcb5184e5 --- /dev/null +++ b/homeassistant/components/sonos/favorites.py @@ -0,0 +1,95 @@ +"""Class representing Sonos favorites.""" +from __future__ import annotations + +from collections.abc import Iterator +import datetime +import logging +from typing import Callable + +from pysonos.data_structures import DidlFavorite +from pysonos.events_base import Event as SonosEvent +from pysonos.exceptions import SoCoException + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import dispatcher_send + +from .const import DATA_SONOS, SONOS_HOUSEHOLD_UPDATED + +_LOGGER = logging.getLogger(__name__) + + +class SonosFavorites: + """Storage class for Sonos favorites.""" + + def __init__(self, hass: HomeAssistant, household_id: str) -> None: + """Initialize the data.""" + self.hass = hass + self.household_id = household_id + self._favorites: list[DidlFavorite] = [] + self._event_version: str | None = None + self._next_update: Callable | None = None + + def __iter__(self) -> Iterator: + """Return an iterator for the known favorites.""" + favorites = self._favorites.copy() + return iter(favorites) + + @callback + def async_delayed_update(self, event: SonosEvent) -> None: + """Add a delay when triggered by an event. + + Updated favorites are not always immediately available. + + """ + event_id = event.variables["favorites_update_id"] + if not self._event_version: + self._event_version = event_id + return + + if self._event_version == event_id: + _LOGGER.debug("Favorites haven't changed (event_id: %s)", event_id) + return + + self._event_version = event_id + + if self._next_update: + self._next_update() + + self._next_update = self.hass.helpers.event.async_call_later(3, self.update) + + def update(self, now: datetime.datetime | None = None) -> None: + """Request new Sonos favorites from a speaker.""" + new_favorites = None + discovered = self.hass.data[DATA_SONOS].discovered + + for uid, speaker in discovered.items(): + try: + new_favorites = speaker.soco.music_library.get_sonos_favorites() + except SoCoException as err: + _LOGGER.warning( + "Error requesting favorites from %s: %s", speaker.soco, err + ) + else: + # Prefer this SoCo instance next update + discovered.move_to_end(uid, last=False) + break + + if new_favorites is None: + _LOGGER.error("Could not reach any speakers to update favorites") + return + + self._favorites = [] + for fav in new_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) + _LOGGER.debug( + "Cached %s favorites for household %s", + len(self._favorites), + self.household_id, + ) + dispatcher_send(self.hass, f"{SONOS_HOUSEHOLD_UPDATED}-{self.household_id}") diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 45da26644e3..051a9e29e81 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -439,7 +439,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): elif source == SOURCE_TV: soco.switch_to_tv() else: - fav = [fav for fav in self.coordinator.favorites if fav.title == source] + fav = [fav for fav in self.speaker.favorites if fav.title == source] if len(fav) == 1: src = fav.pop() uri = src.reference.get_uri() @@ -456,7 +456,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): @property # type: ignore[misc] def source_list(self) -> list[str]: """List of available input sources.""" - sources = [fav.title for fav in self.coordinator.favorites] + sources = [fav.title for fav in self.speaker.favorites] model = self.coordinator.model_name.upper() if "PLAY:5" in model or "CONNECT" in model: diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 0960c200da7..0c2b28dbdf2 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -12,7 +12,7 @@ import urllib.parse 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.data_structures import DidlAudioBroadcast from pysonos.events_base import Event as SonosEvent, SubscriptionBase from pysonos.exceptions import SoCoException from pysonos.music_library import MusicLibrary @@ -49,6 +49,7 @@ from .const import ( SOURCE_LINEIN, SOURCE_TV, ) +from .favorites import SonosFavorites from .helpers import soco_error EVENT_CHARGING = { @@ -127,8 +128,9 @@ 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.hass = hass + self.soco = soco + self.household_id: str = soco.household_id self.media = SonosMedia(soco) self._is_ready: bool = False @@ -161,8 +163,6 @@ class SonosSpeaker: 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() @@ -213,7 +213,6 @@ class SonosSpeaker: """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: @@ -654,24 +653,16 @@ class SonosSpeaker: 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) + @property + def favorites(self) -> SonosFavorites: + """Return the SonosFavorites instance for this household.""" + return self.hass.data[DATA_SONOS].favorites[self.household_id] @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() + self.favorites.async_delayed_update(event) def update_volume(self) -> None: """Update information about current volume settings."""