From 31db3fcb233e200029b5663af4aa4889bb1880ee Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 16 Jun 2021 10:30:05 -0500 Subject: [PATCH] Refactor Sonos alarms and favorites into system-level coordinators (#51757) * Refactor alarms and favorites into household-level coordinators Create SonosHouseholdCoodinator class for system-level data Fix polling for both alarms and favorites Adjust tests * Fix docstring * Review cleanup * Move exception handling up a level, do not save a failed coordinator * Apply suggestions from code review Co-authored-by: Martin Hjelmare Co-authored-by: Martin Hjelmare --- homeassistant/components/sonos/__init__.py | 36 ++++----- homeassistant/components/sonos/alarms.py | 70 ++++++++++++++++ homeassistant/components/sonos/const.py | 4 +- homeassistant/components/sonos/entity.py | 4 +- homeassistant/components/sonos/favorites.py | 81 ++++++------------- .../components/sonos/household_coordinator.py | 74 +++++++++++++++++ .../components/sonos/media_player.py | 4 + homeassistant/components/sonos/speaker.py | 74 ++++++----------- homeassistant/components/sonos/switch.py | 50 ++++++------ tests/components/sonos/test_switch.py | 19 +++-- 10 files changed, 255 insertions(+), 161 deletions(-) create mode 100644 homeassistant/components/sonos/alarms.py create mode 100644 homeassistant/components/sonos/household_coordinator.py diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index a20805ce136..218ddaa8e15 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections import OrderedDict, deque +from collections import OrderedDict import datetime from enum import Enum import logging @@ -11,7 +11,6 @@ from urllib.parse import urlparse import pysonos from pysonos import events_asyncio -from pysonos.alarms import Alarm from pysonos.core import SoCo from pysonos.exceptions import SoCoException import voluptuous as vol @@ -29,12 +28,12 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send +from .alarms import SonosAlarms from .const import ( DATA_SONOS, DISCOVERY_INTERVAL, DOMAIN, PLATFORMS, - SONOS_ALARM_UPDATE, SONOS_GROUP_UPDATE, SONOS_REBOOTED, SONOS_SEEN, @@ -82,11 +81,10 @@ class SonosData: def __init__(self) -> None: """Initialize the data.""" - # OrderedDict behavior used by SonosFavorites + # OrderedDict behavior used by SonosAlarms and SonosFavorites self.discovered: OrderedDict[str, SonosSpeaker] = OrderedDict() self.favorites: dict[str, SonosFavorites] = {} - self.alarms: dict[str, Alarm] = {} - self.processed_alarm_events = deque(maxlen=5) + self.alarms: dict[str, SonosAlarms] = {} self.topology_condition = asyncio.Condition() self.hosts_heartbeat = None self.ssdp_known: set[str] = set() @@ -148,14 +146,17 @@ async def async_setup_entry( # noqa: C901 _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() + for coordinator, coord_dict in [ + (SonosAlarms, data.alarms), + (SonosFavorites, data.favorites), + ]: + if soco.household_id not in coord_dict: + new_coordinator = coordinator(hass, soco.household_id) + new_coordinator.setup(soco) + coord_dict[soco.household_id] = new_coordinator speaker.setup() - except SoCoException as ex: - _LOGGER.debug("SoCoException, ex=%s", ex) + except (OSError, SoCoException): + _LOGGER.warning("Failed to add SonosSpeaker using %s", soco, exc_info=True) def _create_soco(ip_address: str, source: SoCoCreationSource) -> SoCo | None: """Create a soco instance and return if successful.""" @@ -236,10 +237,6 @@ async def async_setup_entry( # noqa: C901 _async_create_discovered_player(uid, discovered_ip, boot_seqnum) ) - @callback - def _async_signal_update_alarms(event): - async_dispatcher_send(hass, SONOS_ALARM_UPDATE) - async def setup_platforms_and_discovery(): await asyncio.gather( *[ @@ -252,11 +249,6 @@ async def async_setup_entry( # noqa: C901 EVENT_HOMEASSISTANT_START, _async_signal_update_groups ) ) - entry.async_on_unload( - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, _async_signal_update_alarms - ) - ) entry.async_on_unload( hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, _async_stop_event_listener diff --git a/homeassistant/components/sonos/alarms.py b/homeassistant/components/sonos/alarms.py new file mode 100644 index 00000000000..98e4b752cad --- /dev/null +++ b/homeassistant/components/sonos/alarms.py @@ -0,0 +1,70 @@ +"""Class representing Sonos alarms.""" +from __future__ import annotations + +from collections.abc import Iterator +import logging +from typing import Any + +from pysonos import SoCo +from pysonos.alarms import Alarm, get_alarms +from pysonos.exceptions import SoCoException + +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import DATA_SONOS, SONOS_ALARMS_UPDATED, SONOS_CREATE_ALARM +from .household_coordinator import SonosHouseholdCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class SonosAlarms(SonosHouseholdCoordinator): + """Coordinator class for Sonos alarms.""" + + def __init__(self, *args: Any) -> None: + """Initialize the data.""" + super().__init__(*args) + self._alarms: dict[str, Alarm] = {} + + def __iter__(self) -> Iterator: + """Return an iterator for the known alarms.""" + alarms = list(self._alarms.values()) + return iter(alarms) + + def get(self, alarm_id: str) -> Alarm | None: + """Get an Alarm instance.""" + return self._alarms.get(alarm_id) + + async def async_update_entities(self, soco: SoCo) -> bool: + """Create and update alarms entities, return success.""" + try: + new_alarms = await self.hass.async_add_executor_job(self.update_cache, soco) + except (OSError, SoCoException) as err: + _LOGGER.error("Could not refresh alarms using %s: %s", soco, err) + return False + + for alarm in new_alarms: + speaker = self.hass.data[DATA_SONOS].discovered[alarm.zone.uid] + async_dispatcher_send( + self.hass, SONOS_CREATE_ALARM, speaker, [alarm.alarm_id] + ) + async_dispatcher_send(self.hass, f"{SONOS_ALARMS_UPDATED}-{self.household_id}") + return True + + def update_cache(self, soco: SoCo) -> set[Alarm]: + """Populate cache of known alarms. + + Prune deleted alarms and return new alarms. + """ + soco_alarms = get_alarms(soco) + new_alarms = set() + + for alarm in soco_alarms: + if alarm.alarm_id not in self._alarms: + new_alarms.add(alarm) + self._alarms[alarm.alarm_id] = alarm + + for alarm_id, alarm in list(self._alarms.items()): + if alarm not in soco_alarms: + self._alarms.pop(alarm_id) + + return new_alarms diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 84ccb99baed..9072f4cab02 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -140,8 +140,8 @@ SONOS_CREATE_MEDIA_PLAYER = "sonos_create_media_player" SONOS_ENTITY_CREATED = "sonos_entity_created" SONOS_POLL_UPDATE = "sonos_poll_update" SONOS_GROUP_UPDATE = "sonos_group_update" -SONOS_HOUSEHOLD_UPDATED = "sonos_household_updated" -SONOS_ALARM_UPDATE = "sonos_alarm_update" +SONOS_ALARMS_UPDATED = "sonos_alarms_updated" +SONOS_FAVORITES_UPDATED = "sonos_favorites_updated" SONOS_STATE_UPDATED = "sonos_state_updated" SONOS_REBOOTED = "sonos_rebooted" SONOS_SEEN = "sonos_seen" diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index 290c6a64cb1..a2b0d7c5a64 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity import DeviceInfo, Entity from .const import ( DOMAIN, SONOS_ENTITY_CREATED, - SONOS_HOUSEHOLD_UPDATED, + SONOS_FAVORITES_UPDATED, SONOS_POLL_UPDATE, SONOS_STATE_UPDATED, ) @@ -54,7 +54,7 @@ class SonosEntity(Entity): self.async_on_remove( async_dispatcher_connect( self.hass, - f"{SONOS_HOUSEHOLD_UPDATED}-{self.soco.household_id}", + f"{SONOS_FAVORITES_UPDATED}-{self.soco.household_id}", self.async_write_ha_state, ) ) diff --git a/homeassistant/components/sonos/favorites.py b/homeassistant/components/sonos/favorites.py index 2f5cab23be2..25fc58ebba2 100644 --- a/homeassistant/components/sonos/favorites.py +++ b/homeassistant/components/sonos/favorites.py @@ -2,85 +2,52 @@ from __future__ import annotations from collections.abc import Iterator -import datetime import logging -from typing import Callable +from typing import Any +from pysonos import SoCo 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 homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import DATA_SONOS, SONOS_HOUSEHOLD_UPDATED +from .const import SONOS_FAVORITES_UPDATED +from .household_coordinator import SonosHouseholdCoordinator _LOGGER = logging.getLogger(__name__) -class SonosFavorites: - """Storage class for Sonos favorites.""" +class SonosFavorites(SonosHouseholdCoordinator): + """Coordinator class for Sonos favorites.""" - def __init__(self, hass: HomeAssistant, household_id: str) -> None: + def __init__(self, *args: Any) -> None: """Initialize the data.""" - self.hass = hass - self.household_id = household_id + super().__init__(*args) 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. + async def async_update_entities(self, soco: SoCo) -> bool: + """Update the cache and update entities.""" + try: + await self.hass.async_add_executor_job(self.update_cache, soco) + except (OSError, SoCoException) as err: + _LOGGER.warning("Error requesting favorites from %s: %s", soco, err) + return False - Updated favorites are not always immediately available. + async_dispatcher_send( + self.hass, f"{SONOS_FAVORITES_UPDATED}-{self.household_id}" + ) + return True - """ - if not (event_id := event.variables.get("favorites_update_id")): - return - - 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: + def update_cache(self, soco: SoCo) -> 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 - + new_favorites = soco.music_library.get_sonos_favorites() self._favorites = [] + for fav in new_favorites: try: # exclude non-playable favorites with no linked resources @@ -89,9 +56,9 @@ class SonosFavorites: 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/household_coordinator.py b/homeassistant/components/sonos/household_coordinator.py new file mode 100644 index 00000000000..d24ab40b3db --- /dev/null +++ b/homeassistant/components/sonos/household_coordinator.py @@ -0,0 +1,74 @@ +"""Class representing a Sonos household storage helper.""" +from __future__ import annotations + +from collections import deque +from collections.abc import Callable, Coroutine +import logging +from typing import Any + +from pysonos import SoCo + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.debounce import Debouncer + +from .const import DATA_SONOS + +_LOGGER = logging.getLogger(__name__) + + +class SonosHouseholdCoordinator: + """Base class for Sonos household-level storage.""" + + def __init__(self, hass: HomeAssistant, household_id: str) -> None: + """Initialize the data.""" + self.hass = hass + self.household_id = household_id + self._processed_events = deque(maxlen=5) + self.async_poll: Callable[[], Coroutine[None, None, None]] | None = None + + def setup(self, soco: SoCo) -> None: + """Set up the SonosAlarm instance.""" + self.update_cache(soco) + self.hass.add_job(self._async_create_polling_debouncer) + + async def _async_create_polling_debouncer(self) -> None: + """Create a polling debouncer in async context. + + Used to ensure redundant poll requests from all speakers are coalesced. + """ + self.async_poll = Debouncer( + self.hass, + _LOGGER, + cooldown=3, + immediate=False, + function=self._async_poll, + ).async_call + + async def _async_poll(self) -> None: + """Poll any known speaker.""" + discovered = self.hass.data[DATA_SONOS].discovered + + for uid, speaker in discovered.items(): + _LOGGER.debug("Updating %s using %s", type(self).__name__, speaker.soco) + success = await self.async_update_entities(speaker.soco) + + if success: + # Prefer this SoCo instance next update + discovered.move_to_end(uid, last=False) + break + + @callback + def async_handle_event(self, event_id: str, soco: SoCo) -> None: + """Create a task to update from an event callback.""" + if event_id in self._processed_events: + return + self._processed_events.append(event_id) + self.hass.async_create_task(self.async_update_entities(soco)) + + async def async_update_entities(self, soco: SoCo) -> bool: + """Update the cache and update entities.""" + raise NotImplementedError() + + def update_cache(self, soco: SoCo) -> Any: + """Update the cache of the household-level feature.""" + raise NotImplementedError() diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index c44f7fbd4fb..8b1664d4f6c 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -55,6 +55,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.network import is_internal_request from .const import ( + DATA_SONOS, DOMAIN as SONOS_DOMAIN, MEDIA_TYPES_TO_SONOS, PLAYABLE_MEDIA_TYPES, @@ -295,6 +296,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): async def async_update(self) -> None: """Retrieve latest state by polling.""" + await self.hass.data[DATA_SONOS].favorites[ + self.speaker.household_id + ].async_poll() await self.hass.async_add_executor_job(self._update) def _update(self) -> None: diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 59ed94adec6..88e6ac33ba7 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -from collections import deque from collections.abc import Coroutine import contextlib import datetime @@ -12,7 +11,6 @@ from typing import Any, Callable import urllib.parse import async_timeout -from pysonos.alarms import get_alarms from pysonos.core import MUSIC_SRC_LINE_IN, MUSIC_SRC_RADIO, MUSIC_SRC_TV, SoCo from pysonos.data_structures import DidlAudioBroadcast, DidlPlaylistContainer from pysonos.events_base import Event as SonosEvent, SubscriptionBase @@ -33,6 +31,7 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.util import dt as dt_util +from .alarms import SonosAlarms from .const import ( BATTERY_SCAN_INTERVAL, DATA_SONOS, @@ -40,7 +39,6 @@ from .const import ( PLATFORMS, SCAN_INTERVAL, SEEN_EXPIRE_TIME, - SONOS_ALARM_UPDATE, SONOS_CREATE_ALARM, SONOS_CREATE_BATTERY, SONOS_CREATE_MEDIA_PLAYER, @@ -225,7 +223,9 @@ class SonosSpeaker: else: self._platforms_ready.update({BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN}) - if new_alarms := self.update_alarms_for_speaker(): + if new_alarms := [ + alarm.alarm_id for alarm in self.alarms if alarm.zone.uid == self.soco.uid + ]: dispatcher_send(self.hass, SONOS_CREATE_ALARM, self, new_alarms) else: self._platforms_ready.add(SWITCH_DOMAIN) @@ -233,7 +233,7 @@ class SonosSpeaker: self._event_dispatchers = { "AlarmClock": self.async_dispatch_alarms, "AVTransport": self.async_dispatch_media_update, - "ContentDirectory": self.favorites.async_delayed_update, + "ContentDirectory": self.async_dispatch_favorites, "DeviceProperties": self.async_dispatch_device_properties, "RenderingControl": self.async_update_volume, "ZoneGroupTopology": self.async_update_groups, @@ -246,8 +246,11 @@ class SonosSpeaker: # async def async_handle_new_entity(self, entity_type: str) -> None: """Listen to new entities to trigger first subscription.""" + if self._platforms_ready == PLATFORMS: + return + self._platforms_ready.add(entity_type) - if self._platforms_ready == PLATFORMS and not self._subscriptions: + if self._platforms_ready == PLATFORMS: self._resubscription_lock = asyncio.Lock() await self.async_subscribe() self._is_ready = True @@ -274,6 +277,11 @@ class SonosSpeaker: """Return whether this speaker is available.""" return self._seen_timer is not None + @property + def alarms(self) -> SonosAlarms: + """Return the SonosAlarms instance for this household.""" + return self.hass.data[DATA_SONOS].alarms[self.household_id] + @property def favorites(self) -> SonosFavorites: """Return the SonosFavorites instance for this household.""" @@ -284,11 +292,6 @@ class SonosSpeaker: """Return true if player is a coordinator.""" return self.coordinator is None - @property - def processed_alarm_events(self) -> deque[str]: - """Return the container of processed alarm events.""" - return self.hass.data[DATA_SONOS].processed_alarm_events - @property def subscription_address(self) -> str | None: """Return the current subscription callback address if any.""" @@ -381,13 +384,10 @@ class SonosSpeaker: @callback def async_dispatch_alarms(self, event: SonosEvent) -> None: - """Create a task to update alarms from an event.""" - if not (update_id := event.variables.get("alarm_list_version")): + """Add the soco instance associated with the event to the callback.""" + if not (event_id := event.variables.get("alarm_list_version")): return - if update_id in self.processed_alarm_events: - return - self.processed_alarm_events.append(update_id) - self.hass.async_add_executor_job(self.update_alarms) + self.alarms.async_handle_event(event_id, self.soco) @callback def async_dispatch_device_properties(self, event: SonosEvent) -> None: @@ -401,6 +401,13 @@ class SonosSpeaker: await self.async_update_battery_info(battery_dict) self.async_write_entity_states() + @callback + def async_dispatch_favorites(self, event: SonosEvent) -> None: + """Add the soco instance associated with the event to the callback.""" + if not (event_id := event.variables.get("favorites_update_id")): + return + self.favorites.async_handle_event(event_id, self.soco) + @callback def async_dispatch_media_update(self, event: SonosEvent) -> None: """Update information about currently playing media from an event.""" @@ -493,37 +500,6 @@ class SonosSpeaker: await self.async_unseen(will_reconnect=True) await self.async_seen(soco) - # - # Alarm management - # - def update_alarms_for_speaker(self) -> set[str]: - """Update current alarm instances. - - Updates hass.data[DATA_SONOS].alarms and returns a list of all alarms that are new. - """ - new_alarms = set() - stored_alarms = self.hass.data[DATA_SONOS].alarms - updated_alarms = get_alarms(self.soco) - - for alarm in updated_alarms: - if alarm.zone.uid == self.soco.uid and alarm.alarm_id not in list( - stored_alarms.keys() - ): - new_alarms.add(alarm.alarm_id) - stored_alarms[alarm.alarm_id] = alarm - - for alarm_id, alarm in list(stored_alarms.items()): - if alarm not in updated_alarms: - stored_alarms.pop(alarm_id) - - return new_alarms - - def update_alarms(self) -> None: - """Update alarms from an event.""" - if new_alarms := self.update_alarms_for_speaker(): - dispatcher_send(self.hass, SONOS_CREATE_ALARM, self, new_alarms) - dispatcher_send(self.hass, SONOS_ALARM_UPDATE) - # # Battery management # @@ -618,7 +594,7 @@ class SonosSpeaker: coordinator_uid = self.soco.uid slave_uids = [] - with contextlib.suppress(SoCoException): + with contextlib.suppress(OSError, SoCoException): if self.soco.group and self.soco.group.coordinator: coordinator_uid = self.soco.group.coordinator.uid slave_uids = [ diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index 4b24224f6a0..795eded6ec1 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -4,7 +4,7 @@ from __future__ import annotations import datetime import logging -from pysonos.exceptions import SoCoUPnPException +from pysonos.exceptions import SoCoException, SoCoUPnPException from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity from homeassistant.const import ATTR_TIME @@ -15,7 +15,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import ( DATA_SONOS, DOMAIN as SONOS_DOMAIN, - SONOS_ALARM_UPDATE, + SONOS_ALARMS_UPDATED, SONOS_CREATE_ALARM, ) from .entity import SonosEntity @@ -35,15 +35,12 @@ ATTR_INCLUDE_LINKED_ZONES = "include_linked_zones" async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Sonos from a config entry.""" - configured_alarms = set() - - async def _async_create_entity(speaker: SonosSpeaker, new_alarms: set) -> None: - for alarm_id in new_alarms: - if alarm_id not in configured_alarms: - _LOGGER.debug("Creating alarm with id %s", alarm_id) - entity = SonosAlarmEntity(alarm_id, speaker) - async_add_entities([entity]) - configured_alarms.add(alarm_id) + async def _async_create_entity(speaker: SonosSpeaker, alarm_ids: list[str]) -> None: + entities = [] + for alarm_id in alarm_ids: + _LOGGER.debug("Creating alarm %s on %s", alarm_id, speaker.zone_name) + entities.append(SonosAlarmEntity(alarm_id, speaker)) + async_add_entities(entities) config_entry.async_on_unload( async_dispatcher_connect(hass, SONOS_CREATE_ALARM, _async_create_entity) @@ -57,7 +54,8 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity): """Initialize the switch.""" super().__init__(speaker) - self._alarm_id = alarm_id + self.alarm_id = alarm_id + self.household_id = speaker.household_id self.entity_id = ENTITY_ID_FORMAT.format(f"sonos_alarm_{self.alarm_id}") async def async_added_to_hass(self) -> None: @@ -66,20 +64,15 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity): self.async_on_remove( async_dispatcher_connect( self.hass, - SONOS_ALARM_UPDATE, - self.async_update, + f"{SONOS_ALARMS_UPDATED}-{self.household_id}", + self.async_update_state, ) ) @property def alarm(self): """Return the alarm instance.""" - return self.hass.data[DATA_SONOS].alarms[self.alarm_id] - - @property - def alarm_id(self): - """Return the ID of the alarm.""" - return self._alarm_id + return self.hass.data[DATA_SONOS].alarms[self.household_id].get(self.alarm_id) @property def unique_id(self) -> str: @@ -100,10 +93,14 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity): str(self.alarm.start_time)[0:5], ) + async def async_update(self) -> None: + """Call the central alarm polling method.""" + await self.hass.data[DATA_SONOS].alarms[self.household_id].async_poll() + @callback def async_check_if_available(self): """Check if alarm exists and remove alarm entity if not available.""" - if self.alarm_id in self.hass.data[DATA_SONOS].alarms: + if self.alarm: return True _LOGGER.debug("%s has been deleted", self.entity_id) @@ -114,7 +111,7 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity): return False - async def async_update(self) -> None: + async def async_update_state(self) -> None: """Poll the device for the current state.""" if not self.async_check_if_available(): return @@ -170,6 +167,11 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity): or bool(recurrence == "WEEKENDS" and int(timestr) not in range(1, 7)) ) + @property + def available(self) -> bool: + """Return whether this alarm is available.""" + return (self.alarm is not None) and self.speaker.available + @property def is_on(self): """Return state of Sonos alarm switch.""" @@ -203,5 +205,5 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity): _LOGGER.debug("Toggling the state of %s", self.entity_id) self.alarm.enabled = turn_on await self.hass.async_add_executor_job(self.alarm.save) - except SoCoUPnPException as exc: - _LOGGER.error("Could not update %s: %s", self.entity_id, exc, exc_info=True) + except (OSError, SoCoException, SoCoUPnPException) as exc: + _LOGGER.error("Could not update %s: %s", self.entity_id, exc) diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index 41cb241d377..f684a8f351e 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -1,4 +1,6 @@ """Tests for the Sonos Alarm switch platform.""" +from copy import copy + from homeassistant.components.sonos import DOMAIN from homeassistant.components.sonos.switch import ( ATTR_DURATION, @@ -52,24 +54,31 @@ async def test_alarm_create_delete( hass, config_entry, config, soco, alarm_clock, alarm_clock_extended, alarm_event ): """Test for correct creation and deletion of alarms during runtime.""" - soco.alarmClock = alarm_clock_extended + entity_registry = async_get_entity_registry(hass) + + one_alarm = copy(alarm_clock.ListAlarms.return_value) + two_alarms = copy(alarm_clock_extended.ListAlarms.return_value) await setup_platform(hass, config_entry, config) - subscription = alarm_clock_extended.subscribe.return_value + assert "switch.sonos_alarm_14" in entity_registry.entities + assert "switch.sonos_alarm_15" not in entity_registry.entities + + subscription = alarm_clock.subscribe.return_value sub_callback = subscription.callback + alarm_clock.ListAlarms.return_value = two_alarms + sub_callback(event=alarm_event) await hass.async_block_till_done() - entity_registry = async_get_entity_registry(hass) - assert "switch.sonos_alarm_14" in entity_registry.entities assert "switch.sonos_alarm_15" in entity_registry.entities - alarm_clock_extended.ListAlarms.return_value = alarm_clock.ListAlarms.return_value alarm_event.increment_variable("alarm_list_version") + alarm_clock.ListAlarms.return_value = one_alarm + sub_callback(event=alarm_event) await hass.async_block_till_done()