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 <marhje52@gmail.com>

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
pull/51871/head^2
jjlawren 2021-06-16 10:30:05 -05:00 committed by GitHub
parent bc3e5b39ed
commit 31db3fcb23
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 255 additions and 161 deletions

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections import OrderedDict, deque from collections import OrderedDict
import datetime import datetime
from enum import Enum from enum import Enum
import logging import logging
@ -11,7 +11,6 @@ from urllib.parse import urlparse
import pysonos import pysonos
from pysonos import events_asyncio from pysonos import events_asyncio
from pysonos.alarms import Alarm
from pysonos.core import SoCo from pysonos.core import SoCo
from pysonos.exceptions import SoCoException from pysonos.exceptions import SoCoException
import voluptuous as vol 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 import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send
from .alarms import SonosAlarms
from .const import ( from .const import (
DATA_SONOS, DATA_SONOS,
DISCOVERY_INTERVAL, DISCOVERY_INTERVAL,
DOMAIN, DOMAIN,
PLATFORMS, PLATFORMS,
SONOS_ALARM_UPDATE,
SONOS_GROUP_UPDATE, SONOS_GROUP_UPDATE,
SONOS_REBOOTED, SONOS_REBOOTED,
SONOS_SEEN, SONOS_SEEN,
@ -82,11 +81,10 @@ class SonosData:
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize the data.""" """Initialize the data."""
# OrderedDict behavior used by SonosFavorites # OrderedDict behavior used by SonosAlarms and SonosFavorites
self.discovered: OrderedDict[str, SonosSpeaker] = OrderedDict() self.discovered: OrderedDict[str, SonosSpeaker] = OrderedDict()
self.favorites: dict[str, SonosFavorites] = {} self.favorites: dict[str, SonosFavorites] = {}
self.alarms: dict[str, Alarm] = {} self.alarms: dict[str, SonosAlarms] = {}
self.processed_alarm_events = deque(maxlen=5)
self.topology_condition = asyncio.Condition() self.topology_condition = asyncio.Condition()
self.hosts_heartbeat = None self.hosts_heartbeat = None
self.ssdp_known: set[str] = set() 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) _LOGGER.debug("Adding new speaker: %s", speaker_info)
speaker = SonosSpeaker(hass, soco, speaker_info) speaker = SonosSpeaker(hass, soco, speaker_info)
data.discovered[soco.uid] = speaker data.discovered[soco.uid] = speaker
if soco.household_id not in data.favorites: for coordinator, coord_dict in [
data.favorites[soco.household_id] = SonosFavorites( (SonosAlarms, data.alarms),
hass, soco.household_id (SonosFavorites, data.favorites),
) ]:
data.favorites[soco.household_id].update() 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() speaker.setup()
except SoCoException as ex: except (OSError, SoCoException):
_LOGGER.debug("SoCoException, ex=%s", ex) _LOGGER.warning("Failed to add SonosSpeaker using %s", soco, exc_info=True)
def _create_soco(ip_address: str, source: SoCoCreationSource) -> SoCo | None: def _create_soco(ip_address: str, source: SoCoCreationSource) -> SoCo | None:
"""Create a soco instance and return if successful.""" """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) _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(): async def setup_platforms_and_discovery():
await asyncio.gather( await asyncio.gather(
*[ *[
@ -252,11 +249,6 @@ async def async_setup_entry( # noqa: C901
EVENT_HOMEASSISTANT_START, _async_signal_update_groups 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( entry.async_on_unload(
hass.bus.async_listen_once( hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, _async_stop_event_listener EVENT_HOMEASSISTANT_STOP, _async_stop_event_listener

View File

@ -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

View File

@ -140,8 +140,8 @@ SONOS_CREATE_MEDIA_PLAYER = "sonos_create_media_player"
SONOS_ENTITY_CREATED = "sonos_entity_created" SONOS_ENTITY_CREATED = "sonos_entity_created"
SONOS_POLL_UPDATE = "sonos_poll_update" SONOS_POLL_UPDATE = "sonos_poll_update"
SONOS_GROUP_UPDATE = "sonos_group_update" SONOS_GROUP_UPDATE = "sonos_group_update"
SONOS_HOUSEHOLD_UPDATED = "sonos_household_updated" SONOS_ALARMS_UPDATED = "sonos_alarms_updated"
SONOS_ALARM_UPDATE = "sonos_alarm_update" SONOS_FAVORITES_UPDATED = "sonos_favorites_updated"
SONOS_STATE_UPDATED = "sonos_state_updated" SONOS_STATE_UPDATED = "sonos_state_updated"
SONOS_REBOOTED = "sonos_rebooted" SONOS_REBOOTED = "sonos_rebooted"
SONOS_SEEN = "sonos_seen" SONOS_SEEN = "sonos_seen"

View File

@ -17,7 +17,7 @@ from homeassistant.helpers.entity import DeviceInfo, Entity
from .const import ( from .const import (
DOMAIN, DOMAIN,
SONOS_ENTITY_CREATED, SONOS_ENTITY_CREATED,
SONOS_HOUSEHOLD_UPDATED, SONOS_FAVORITES_UPDATED,
SONOS_POLL_UPDATE, SONOS_POLL_UPDATE,
SONOS_STATE_UPDATED, SONOS_STATE_UPDATED,
) )
@ -54,7 +54,7 @@ class SonosEntity(Entity):
self.async_on_remove( self.async_on_remove(
async_dispatcher_connect( async_dispatcher_connect(
self.hass, self.hass,
f"{SONOS_HOUSEHOLD_UPDATED}-{self.soco.household_id}", f"{SONOS_FAVORITES_UPDATED}-{self.soco.household_id}",
self.async_write_ha_state, self.async_write_ha_state,
) )
) )

View File

@ -2,85 +2,52 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterator from collections.abc import Iterator
import datetime
import logging import logging
from typing import Callable from typing import Any
from pysonos import SoCo
from pysonos.data_structures import DidlFavorite from pysonos.data_structures import DidlFavorite
from pysonos.events_base import Event as SonosEvent
from pysonos.exceptions import SoCoException from pysonos.exceptions import SoCoException
from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.dispatcher import 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__) _LOGGER = logging.getLogger(__name__)
class SonosFavorites: class SonosFavorites(SonosHouseholdCoordinator):
"""Storage class for Sonos favorites.""" """Coordinator class for Sonos favorites."""
def __init__(self, hass: HomeAssistant, household_id: str) -> None: def __init__(self, *args: Any) -> None:
"""Initialize the data.""" """Initialize the data."""
self.hass = hass super().__init__(*args)
self.household_id = household_id
self._favorites: list[DidlFavorite] = [] self._favorites: list[DidlFavorite] = []
self._event_version: str | None = None
self._next_update: Callable | None = None
def __iter__(self) -> Iterator: def __iter__(self) -> Iterator:
"""Return an iterator for the known favorites.""" """Return an iterator for the known favorites."""
favorites = self._favorites.copy() favorites = self._favorites.copy()
return iter(favorites) return iter(favorites)
@callback async def async_update_entities(self, soco: SoCo) -> bool:
def async_delayed_update(self, event: SonosEvent) -> None: """Update the cache and update entities."""
"""Add a delay when triggered by an event. 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
""" def update_cache(self, soco: SoCo) -> None:
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:
"""Request new Sonos favorites from a speaker.""" """Request new Sonos favorites from a speaker."""
new_favorites = None new_favorites = soco.music_library.get_sonos_favorites()
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 = [] self._favorites = []
for fav in new_favorites: for fav in new_favorites:
try: try:
# exclude non-playable favorites with no linked resources # exclude non-playable favorites with no linked resources
@ -89,9 +56,9 @@ class SonosFavorites:
except SoCoException as ex: except SoCoException as ex:
# Skip unknown types # Skip unknown types
_LOGGER.error("Unhandled favorite '%s': %s", fav.title, ex) _LOGGER.error("Unhandled favorite '%s': %s", fav.title, ex)
_LOGGER.debug( _LOGGER.debug(
"Cached %s favorites for household %s", "Cached %s favorites for household %s",
len(self._favorites), len(self._favorites),
self.household_id, self.household_id,
) )
dispatcher_send(self.hass, f"{SONOS_HOUSEHOLD_UPDATED}-{self.household_id}")

View File

@ -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()

View File

@ -55,6 +55,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.network import is_internal_request from homeassistant.helpers.network import is_internal_request
from .const import ( from .const import (
DATA_SONOS,
DOMAIN as SONOS_DOMAIN, DOMAIN as SONOS_DOMAIN,
MEDIA_TYPES_TO_SONOS, MEDIA_TYPES_TO_SONOS,
PLAYABLE_MEDIA_TYPES, PLAYABLE_MEDIA_TYPES,
@ -295,6 +296,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
async def async_update(self) -> None: async def async_update(self) -> None:
"""Retrieve latest state by polling.""" """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) await self.hass.async_add_executor_job(self._update)
def _update(self) -> None: def _update(self) -> None:

View File

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections import deque
from collections.abc import Coroutine from collections.abc import Coroutine
import contextlib import contextlib
import datetime import datetime
@ -12,7 +11,6 @@ from typing import Any, Callable
import urllib.parse import urllib.parse
import async_timeout 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.core import MUSIC_SRC_LINE_IN, MUSIC_SRC_RADIO, MUSIC_SRC_TV, SoCo
from pysonos.data_structures import DidlAudioBroadcast, DidlPlaylistContainer from pysonos.data_structures import DidlAudioBroadcast, DidlPlaylistContainer
from pysonos.events_base import Event as SonosEvent, SubscriptionBase 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 homeassistant.util import dt as dt_util
from .alarms import SonosAlarms
from .const import ( from .const import (
BATTERY_SCAN_INTERVAL, BATTERY_SCAN_INTERVAL,
DATA_SONOS, DATA_SONOS,
@ -40,7 +39,6 @@ from .const import (
PLATFORMS, PLATFORMS,
SCAN_INTERVAL, SCAN_INTERVAL,
SEEN_EXPIRE_TIME, SEEN_EXPIRE_TIME,
SONOS_ALARM_UPDATE,
SONOS_CREATE_ALARM, SONOS_CREATE_ALARM,
SONOS_CREATE_BATTERY, SONOS_CREATE_BATTERY,
SONOS_CREATE_MEDIA_PLAYER, SONOS_CREATE_MEDIA_PLAYER,
@ -225,7 +223,9 @@ class SonosSpeaker:
else: else:
self._platforms_ready.update({BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN}) 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) dispatcher_send(self.hass, SONOS_CREATE_ALARM, self, new_alarms)
else: else:
self._platforms_ready.add(SWITCH_DOMAIN) self._platforms_ready.add(SWITCH_DOMAIN)
@ -233,7 +233,7 @@ class SonosSpeaker:
self._event_dispatchers = { self._event_dispatchers = {
"AlarmClock": self.async_dispatch_alarms, "AlarmClock": self.async_dispatch_alarms,
"AVTransport": self.async_dispatch_media_update, "AVTransport": self.async_dispatch_media_update,
"ContentDirectory": self.favorites.async_delayed_update, "ContentDirectory": self.async_dispatch_favorites,
"DeviceProperties": self.async_dispatch_device_properties, "DeviceProperties": self.async_dispatch_device_properties,
"RenderingControl": self.async_update_volume, "RenderingControl": self.async_update_volume,
"ZoneGroupTopology": self.async_update_groups, "ZoneGroupTopology": self.async_update_groups,
@ -246,8 +246,11 @@ class SonosSpeaker:
# #
async def async_handle_new_entity(self, entity_type: str) -> None: async def async_handle_new_entity(self, entity_type: str) -> None:
"""Listen to new entities to trigger first subscription.""" """Listen to new entities to trigger first subscription."""
if self._platforms_ready == PLATFORMS:
return
self._platforms_ready.add(entity_type) 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() self._resubscription_lock = asyncio.Lock()
await self.async_subscribe() await self.async_subscribe()
self._is_ready = True self._is_ready = True
@ -274,6 +277,11 @@ class SonosSpeaker:
"""Return whether this speaker is available.""" """Return whether this speaker is available."""
return self._seen_timer is not None 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 @property
def favorites(self) -> SonosFavorites: def favorites(self) -> SonosFavorites:
"""Return the SonosFavorites instance for this household.""" """Return the SonosFavorites instance for this household."""
@ -284,11 +292,6 @@ class SonosSpeaker:
"""Return true if player is a coordinator.""" """Return true if player is a coordinator."""
return self.coordinator is None 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 @property
def subscription_address(self) -> str | None: def subscription_address(self) -> str | None:
"""Return the current subscription callback address if any.""" """Return the current subscription callback address if any."""
@ -381,13 +384,10 @@ class SonosSpeaker:
@callback @callback
def async_dispatch_alarms(self, event: SonosEvent) -> None: def async_dispatch_alarms(self, event: SonosEvent) -> None:
"""Create a task to update alarms from an event.""" """Add the soco instance associated with the event to the callback."""
if not (update_id := event.variables.get("alarm_list_version")): if not (event_id := event.variables.get("alarm_list_version")):
return return
if update_id in self.processed_alarm_events: self.alarms.async_handle_event(event_id, self.soco)
return
self.processed_alarm_events.append(update_id)
self.hass.async_add_executor_job(self.update_alarms)
@callback @callback
def async_dispatch_device_properties(self, event: SonosEvent) -> None: def async_dispatch_device_properties(self, event: SonosEvent) -> None:
@ -401,6 +401,13 @@ class SonosSpeaker:
await self.async_update_battery_info(battery_dict) await self.async_update_battery_info(battery_dict)
self.async_write_entity_states() 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 @callback
def async_dispatch_media_update(self, event: SonosEvent) -> None: def async_dispatch_media_update(self, event: SonosEvent) -> None:
"""Update information about currently playing media from an event.""" """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_unseen(will_reconnect=True)
await self.async_seen(soco) 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 # Battery management
# #
@ -618,7 +594,7 @@ class SonosSpeaker:
coordinator_uid = self.soco.uid coordinator_uid = self.soco.uid
slave_uids = [] slave_uids = []
with contextlib.suppress(SoCoException): with contextlib.suppress(OSError, SoCoException):
if self.soco.group and self.soco.group.coordinator: if self.soco.group and self.soco.group.coordinator:
coordinator_uid = self.soco.group.coordinator.uid coordinator_uid = self.soco.group.coordinator.uid
slave_uids = [ slave_uids = [

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import datetime import datetime
import logging import logging
from pysonos.exceptions import SoCoUPnPException from pysonos.exceptions import SoCoException, SoCoUPnPException
from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity
from homeassistant.const import ATTR_TIME from homeassistant.const import ATTR_TIME
@ -15,7 +15,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .const import ( from .const import (
DATA_SONOS, DATA_SONOS,
DOMAIN as SONOS_DOMAIN, DOMAIN as SONOS_DOMAIN,
SONOS_ALARM_UPDATE, SONOS_ALARMS_UPDATED,
SONOS_CREATE_ALARM, SONOS_CREATE_ALARM,
) )
from .entity import SonosEntity 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): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Sonos from a config entry.""" """Set up Sonos from a config entry."""
configured_alarms = set() async def _async_create_entity(speaker: SonosSpeaker, alarm_ids: list[str]) -> None:
entities = []
async def _async_create_entity(speaker: SonosSpeaker, new_alarms: set) -> None: for alarm_id in alarm_ids:
for alarm_id in new_alarms: _LOGGER.debug("Creating alarm %s on %s", alarm_id, speaker.zone_name)
if alarm_id not in configured_alarms: entities.append(SonosAlarmEntity(alarm_id, speaker))
_LOGGER.debug("Creating alarm with id %s", alarm_id) async_add_entities(entities)
entity = SonosAlarmEntity(alarm_id, speaker)
async_add_entities([entity])
configured_alarms.add(alarm_id)
config_entry.async_on_unload( config_entry.async_on_unload(
async_dispatcher_connect(hass, SONOS_CREATE_ALARM, _async_create_entity) async_dispatcher_connect(hass, SONOS_CREATE_ALARM, _async_create_entity)
@ -57,7 +54,8 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
"""Initialize the switch.""" """Initialize the switch."""
super().__init__(speaker) 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}") self.entity_id = ENTITY_ID_FORMAT.format(f"sonos_alarm_{self.alarm_id}")
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
@ -66,20 +64,15 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
self.async_on_remove( self.async_on_remove(
async_dispatcher_connect( async_dispatcher_connect(
self.hass, self.hass,
SONOS_ALARM_UPDATE, f"{SONOS_ALARMS_UPDATED}-{self.household_id}",
self.async_update, self.async_update_state,
) )
) )
@property @property
def alarm(self): def alarm(self):
"""Return the alarm instance.""" """Return the alarm instance."""
return self.hass.data[DATA_SONOS].alarms[self.alarm_id] return self.hass.data[DATA_SONOS].alarms[self.household_id].get(self.alarm_id)
@property
def alarm_id(self):
"""Return the ID of the alarm."""
return self._alarm_id
@property @property
def unique_id(self) -> str: def unique_id(self) -> str:
@ -100,10 +93,14 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
str(self.alarm.start_time)[0:5], 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 @callback
def async_check_if_available(self): def async_check_if_available(self):
"""Check if alarm exists and remove alarm entity if not available.""" """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 return True
_LOGGER.debug("%s has been deleted", self.entity_id) _LOGGER.debug("%s has been deleted", self.entity_id)
@ -114,7 +111,7 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
return False return False
async def async_update(self) -> None: async def async_update_state(self) -> None:
"""Poll the device for the current state.""" """Poll the device for the current state."""
if not self.async_check_if_available(): if not self.async_check_if_available():
return return
@ -170,6 +167,11 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
or bool(recurrence == "WEEKENDS" and int(timestr) not in range(1, 7)) 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 @property
def is_on(self): def is_on(self):
"""Return state of Sonos alarm switch.""" """Return state of Sonos alarm switch."""
@ -203,5 +205,5 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
_LOGGER.debug("Toggling the state of %s", self.entity_id) _LOGGER.debug("Toggling the state of %s", self.entity_id)
self.alarm.enabled = turn_on self.alarm.enabled = turn_on
await self.hass.async_add_executor_job(self.alarm.save) await self.hass.async_add_executor_job(self.alarm.save)
except SoCoUPnPException as exc: except (OSError, SoCoException, SoCoUPnPException) as exc:
_LOGGER.error("Could not update %s: %s", self.entity_id, exc, exc_info=True) _LOGGER.error("Could not update %s: %s", self.entity_id, exc)

View File

@ -1,4 +1,6 @@
"""Tests for the Sonos Alarm switch platform.""" """Tests for the Sonos Alarm switch platform."""
from copy import copy
from homeassistant.components.sonos import DOMAIN from homeassistant.components.sonos import DOMAIN
from homeassistant.components.sonos.switch import ( from homeassistant.components.sonos.switch import (
ATTR_DURATION, ATTR_DURATION,
@ -52,24 +54,31 @@ async def test_alarm_create_delete(
hass, config_entry, config, soco, alarm_clock, alarm_clock_extended, alarm_event hass, config_entry, config, soco, alarm_clock, alarm_clock_extended, alarm_event
): ):
"""Test for correct creation and deletion of alarms during runtime.""" """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) 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 sub_callback = subscription.callback
alarm_clock.ListAlarms.return_value = two_alarms
sub_callback(event=alarm_event) sub_callback(event=alarm_event)
await hass.async_block_till_done() 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_14" in entity_registry.entities
assert "switch.sonos_alarm_15" 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_event.increment_variable("alarm_list_version")
alarm_clock.ListAlarms.return_value = one_alarm
sub_callback(event=alarm_event) sub_callback(event=alarm_event)
await hass.async_block_till_done() await hass.async_block_till_done()