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
parent
bc3e5b39ed
commit
31db3fcb23
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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"
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
@ -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}")
|
|
||||||
|
|
|
@ -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()
|
|
@ -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:
|
||||||
|
|
|
@ -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 = [
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue