Refactor Sonos alarms and favorites updating (#55529)

This commit is contained in:
jjlawren 2021-09-28 02:49:32 -05:00 committed by GitHub
parent 922d4c42a3
commit 0d6aa89fd4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 188 additions and 78 deletions

View file

@ -6,9 +6,10 @@ import logging
from typing import Any from typing import Any
from soco import SoCo from soco import SoCo
from soco.alarms import Alarm, get_alarms from soco.alarms import Alarm, Alarms
from soco.exceptions import SoCoException from soco.exceptions import SoCoException
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import DATA_SONOS, SONOS_ALARMS_UPDATED, SONOS_CREATE_ALARM from .const import DATA_SONOS, SONOS_ALARMS_UPDATED, SONOS_CREATE_ALARM
@ -23,48 +24,76 @@ class SonosAlarms(SonosHouseholdCoordinator):
def __init__(self, *args: Any) -> None: def __init__(self, *args: Any) -> None:
"""Initialize the data.""" """Initialize the data."""
super().__init__(*args) super().__init__(*args)
self._alarms: dict[str, Alarm] = {} self.alarms: Alarms = Alarms()
self.created_alarm_ids: set[str] = set()
def __iter__(self) -> Iterator: def __iter__(self) -> Iterator:
"""Return an iterator for the known alarms.""" """Return an iterator for the known alarms."""
alarms = list(self._alarms.values()) return iter(self.alarms)
return iter(alarms)
def get(self, alarm_id: str) -> Alarm | None: def get(self, alarm_id: str) -> Alarm | None:
"""Get an Alarm instance.""" """Get an Alarm instance."""
return self._alarms.get(alarm_id) return self.alarms.get(alarm_id)
async def async_update_entities(self, soco: SoCo) -> bool: async def async_update_entities(
self, soco: SoCo, update_id: int | None = None
) -> None:
"""Create and update alarms entities, return success.""" """Create and update alarms entities, return success."""
updated = await self.hass.async_add_executor_job(
self.update_cache, soco, update_id
)
if not updated:
return
for alarm_id, alarm in self.alarms.alarms.items():
if alarm_id in self.created_alarm_ids:
continue
speaker = self.hass.data[DATA_SONOS].discovered.get(alarm.zone.uid)
if speaker:
async_dispatcher_send(
self.hass, SONOS_CREATE_ALARM, speaker, [alarm_id]
)
async_dispatcher_send(self.hass, f"{SONOS_ALARMS_UPDATED}-{self.household_id}")
@callback
def async_handle_event(self, event_id: str, soco: SoCo) -> None:
"""Create a task to update from an event callback."""
_, event_id = event_id.split(":")
event_id = int(event_id)
self.hass.async_create_task(self.async_process_event(event_id, soco))
async def async_process_event(self, event_id: str, soco: SoCo) -> None:
"""Process the event payload in an async lock and update entities."""
async with self.cache_update_lock:
if event_id <= self.last_processed_event_id:
# Skip updates if this event_id has already been seen
return
await self.async_update_entities(soco, event_id)
def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool:
"""Update cache of known alarms and return if cache has changed."""
try: try:
new_alarms = await self.hass.async_add_executor_job(self.update_cache, soco) self.alarms.update(soco)
except (OSError, SoCoException) as err: except (OSError, SoCoException) as err:
_LOGGER.error("Could not refresh alarms using %s: %s", soco, err) _LOGGER.error("Could not update alarms using %s: %s", soco, err)
return False return False
for alarm in new_alarms: if update_id and self.alarms.last_id < update_id:
speaker = self.hass.data[DATA_SONOS].discovered[alarm.zone.uid] # Skip updates if latest query result is outdated or lagging
async_dispatcher_send( return False
self.hass, SONOS_CREATE_ALARM, speaker, [alarm.alarm_id]
) if (
async_dispatcher_send(self.hass, f"{SONOS_ALARMS_UPDATED}-{self.household_id}") self.last_processed_event_id
and self.alarms.last_id <= self.last_processed_event_id
):
# Skip updates already processed
return False
_LOGGER.debug(
"Updating processed event %s from %s (was %s)",
self.alarms.last_id,
soco,
self.last_processed_event_id,
)
self.last_processed_event_id = self.alarms.last_id
return True 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

@ -3,12 +3,14 @@ from __future__ import annotations
from collections.abc import Iterator from collections.abc import Iterator
import logging import logging
import re
from typing import Any from typing import Any
from soco import SoCo from soco import SoCo
from soco.data_structures import DidlFavorite from soco.data_structures import DidlFavorite
from soco.exceptions import SoCoException from soco.exceptions import SoCoException
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import SONOS_FAVORITES_UPDATED from .const import SONOS_FAVORITES_UPDATED
@ -24,30 +26,87 @@ class SonosFavorites(SonosHouseholdCoordinator):
"""Initialize the data.""" """Initialize the data."""
super().__init__(*args) super().__init__(*args)
self._favorites: list[DidlFavorite] = [] self._favorites: list[DidlFavorite] = []
self.last_polled_ids: dict[str, int] = {}
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)
async def async_update_entities(self, soco: SoCo) -> bool: async def async_update_entities(
self, soco: SoCo, update_id: int | None = None
) -> None:
"""Update the cache and update entities.""" """Update the cache and update entities."""
try: updated = await self.hass.async_add_executor_job(
await self.hass.async_add_executor_job(self.update_cache, soco) self.update_cache, soco, update_id
except (OSError, SoCoException) as err: )
_LOGGER.warning("Error requesting favorites from %s: %s", soco, err) if not updated:
return False return
async_dispatcher_send( async_dispatcher_send(
self.hass, f"{SONOS_FAVORITES_UPDATED}-{self.household_id}" self.hass, f"{SONOS_FAVORITES_UPDATED}-{self.household_id}"
) )
return True
def update_cache(self, soco: SoCo) -> None: @callback
"""Request new Sonos favorites from a speaker.""" def async_handle_event(self, event_id: str, container_ids: str, soco: SoCo) -> None:
"""Create a task to update from an event callback."""
if not (match := re.search(r"FV:2,(\d+)", container_ids)):
return
container_id = int(match.groups()[0])
event_id = int(event_id.split(",")[-1])
self.hass.async_create_task(
self.async_process_event(event_id, container_id, soco)
)
async def async_process_event(
self, event_id: int, container_id: int, soco: SoCo
) -> None:
"""Process the event payload in an async lock and update entities."""
async with self.cache_update_lock:
last_poll_id = self.last_polled_ids.get(soco.uid)
if (
self.last_processed_event_id
and event_id <= self.last_processed_event_id
):
# Skip updates if this event_id has already been seen
if not last_poll_id:
self.last_polled_ids[soco.uid] = container_id
return
if last_poll_id and container_id <= last_poll_id:
return
_LOGGER.debug(
"New favorites event %s from %s (was %s)",
event_id,
soco,
self.last_processed_event_id,
)
self.last_processed_event_id = event_id
await self.async_update_entities(soco, container_id)
def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool:
"""Update cache of known favorites and return if cache has changed."""
new_favorites = soco.music_library.get_sonos_favorites() new_favorites = soco.music_library.get_sonos_favorites()
self._favorites = []
# Polled update_id values do not match event_id values
# Each speaker can return a different polled update_id
last_poll_id = self.last_polled_ids.get(soco.uid)
if last_poll_id and new_favorites.update_id <= last_poll_id:
# Skip updates already processed
return False
self.last_polled_ids[soco.uid] = new_favorites.update_id
_LOGGER.debug(
"Processing favorites update_id %s for %s (was: %s)",
new_favorites.update_id,
soco,
last_poll_id,
)
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
@ -58,7 +117,9 @@ class SonosFavorites(SonosHouseholdCoordinator):
_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 using %s",
len(self._favorites), len(self._favorites),
self.household_id, self.household_id,
soco,
) )
return True

View file

@ -1,14 +1,14 @@
"""Class representing a Sonos household storage helper.""" """Class representing a Sonos household storage helper."""
from __future__ import annotations from __future__ import annotations
from collections import deque import asyncio
from collections.abc import Callable, Coroutine from collections.abc import Callable, Coroutine
import logging import logging
from typing import Any
from soco import SoCo from soco import SoCo
from soco.exceptions import SoCoException
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.debounce import Debouncer
from .const import DATA_SONOS from .const import DATA_SONOS
@ -23,19 +23,18 @@ class SonosHouseholdCoordinator:
"""Initialize the data.""" """Initialize the data."""
self.hass = hass self.hass = hass
self.household_id = household_id self.household_id = household_id
self._processed_events = deque(maxlen=5)
self.async_poll: Callable[[], Coroutine[None, None, None]] | None = None self.async_poll: Callable[[], Coroutine[None, None, None]] | None = None
self.last_processed_event_id: int | None = None
self.cache_update_lock: asyncio.Lock | None = None
def setup(self, soco: SoCo) -> None: def setup(self, soco: SoCo) -> None:
"""Set up the SonosAlarm instance.""" """Set up the SonosAlarm instance."""
self.update_cache(soco) self.update_cache(soco)
self.hass.add_job(self._async_create_polling_debouncer) self.hass.add_job(self._async_setup)
async def _async_create_polling_debouncer(self) -> None: async def _async_setup(self) -> None:
"""Create a polling debouncer in async context. """Finish setup in async context."""
self.cache_update_lock = asyncio.Lock()
Used to ensure redundant poll requests from all speakers are coalesced.
"""
self.async_poll = Debouncer( self.async_poll = Debouncer(
self.hass, self.hass,
_LOGGER, _LOGGER,
@ -44,31 +43,37 @@ class SonosHouseholdCoordinator:
function=self._async_poll, function=self._async_poll,
).async_call ).async_call
@property
def class_type(self) -> str:
"""Return the class type of this instance."""
return type(self).__name__
async def _async_poll(self) -> None: async def _async_poll(self) -> None:
"""Poll any known speaker.""" """Poll any known speaker."""
discovered = self.hass.data[DATA_SONOS].discovered discovered = self.hass.data[DATA_SONOS].discovered
for uid, speaker in discovered.items(): for uid, speaker in discovered.items():
_LOGGER.debug("Updating %s using %s", type(self).__name__, speaker.soco) _LOGGER.debug("Polling %s using %s", self.class_type, speaker.soco)
success = await self.async_update_entities(speaker.soco) try:
await self.async_update_entities(speaker.soco)
if success: except (OSError, SoCoException) as err:
_LOGGER.error(
"Could not refresh %s using %s: %s",
self.class_type,
speaker.soco,
err,
)
else:
# Prefer this SoCo instance next update # Prefer this SoCo instance next update
discovered.move_to_end(uid, last=False) discovered.move_to_end(uid, last=False)
break break
@callback async def async_update_entities(
def async_handle_event(self, event_id: str, soco: SoCo) -> None: self, soco: SoCo, update_id: int | None = None
"""Create a task to update from an event callback.""" ) -> None:
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.""" """Update the cache and update entities."""
raise NotImplementedError() raise NotImplementedError()
def update_cache(self, soco: SoCo) -> Any: def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool:
"""Update the cache of the household-level feature.""" """Update the cache of the household-level feature and return if cache has changed."""
raise NotImplementedError() raise NotImplementedError()

View file

@ -3,7 +3,7 @@
"name": "Sonos", "name": "Sonos",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sonos", "documentation": "https://www.home-assistant.io/integrations/sonos",
"requirements": ["soco==0.23.3"], "requirements": ["soco==0.24.0"],
"dependencies": ["ssdp"], "dependencies": ["ssdp"],
"after_dependencies": ["plex", "zeroconf"], "after_dependencies": ["plex", "zeroconf"],
"zeroconf": ["_sonos._tcp.local."], "zeroconf": ["_sonos._tcp.local."],

View file

@ -451,7 +451,9 @@ class SonosSpeaker:
"""Add the soco instance associated with the event to the callback.""" """Add the soco instance associated with the event to the callback."""
if not (event_id := event.variables.get("favorites_update_id")): if not (event_id := event.variables.get("favorites_update_id")):
return return
self.favorites.async_handle_event(event_id, self.soco) if not (container_ids := event.variables.get("container_update_i_ds")):
return
self.favorites.async_handle_event(event_id, container_ids, self.soco)
@callback @callback
def async_dispatch_media_update(self, event: SonosEvent) -> None: def async_dispatch_media_update(self, event: SonosEvent) -> None:

View file

@ -37,8 +37,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def _async_create_entity(speaker: SonosSpeaker, alarm_ids: list[str]) -> None: async def _async_create_entity(speaker: SonosSpeaker, alarm_ids: list[str]) -> None:
entities = [] entities = []
created_alarms = (
hass.data[DATA_SONOS].alarms[speaker.household_id].created_alarm_ids
)
for alarm_id in alarm_ids: for alarm_id in alarm_ids:
if alarm_id in created_alarms:
continue
_LOGGER.debug("Creating alarm %s on %s", alarm_id, speaker.zone_name) _LOGGER.debug("Creating alarm %s on %s", alarm_id, speaker.zone_name)
created_alarms.add(alarm_id)
entities.append(SonosAlarmEntity(alarm_id, speaker)) entities.append(SonosAlarmEntity(alarm_id, speaker))
async_add_entities(entities) async_add_entities(entities)

View file

@ -2187,7 +2187,7 @@ smhi-pkg==1.0.15
snapcast==2.1.3 snapcast==2.1.3
# homeassistant.components.sonos # homeassistant.components.sonos
soco==0.23.3 soco==0.24.0
# homeassistant.components.solaredge_local # homeassistant.components.solaredge_local
solaredge-local==0.2.0 solaredge-local==0.2.0

View file

@ -1239,7 +1239,7 @@ smarthab==0.21
smhi-pkg==1.0.15 smhi-pkg==1.0.15
# homeassistant.components.sonos # homeassistant.components.sonos
soco==0.23.3 soco==0.24.0
# homeassistant.components.solaredge # homeassistant.components.solaredge
solaredge==0.0.2 solaredge==0.0.2

View file

@ -39,6 +39,7 @@ class SonosMockEvent:
base, count = self.variables[var_name].split(":") base, count = self.variables[var_name].split(":")
newcount = int(count) + 1 newcount = int(count) + 1
self.variables[var_name] = ":".join([base, str(newcount)]) self.variables[var_name] = ":".join([base, str(newcount)])
return self.variables[var_name]
@pytest.fixture(name="config_entry") @pytest.fixture(name="config_entry")
@ -114,8 +115,8 @@ def config_fixture():
@pytest.fixture(name="music_library") @pytest.fixture(name="music_library")
def music_library_fixture(): def music_library_fixture():
"""Create music_library fixture.""" """Create music_library fixture."""
music_library = Mock() music_library = MagicMock()
music_library.get_sonos_favorites.return_value = [] music_library.get_sonos_favorites.return_value.update_id = 1
return music_library return music_library
@ -125,12 +126,13 @@ def alarm_clock_fixture():
alarm_clock = SonosMockService("AlarmClock") alarm_clock = SonosMockService("AlarmClock")
alarm_clock.ListAlarms = Mock() alarm_clock.ListAlarms = Mock()
alarm_clock.ListAlarms.return_value = { alarm_clock.ListAlarms.return_value = {
"CurrentAlarmListVersion": "RINCON_test:14",
"CurrentAlarmList": "<Alarms>" "CurrentAlarmList": "<Alarms>"
'<Alarm ID="14" StartTime="07:00:00" Duration="02:00:00" Recurrence="DAILY" ' '<Alarm ID="14" StartTime="07:00:00" Duration="02:00:00" Recurrence="DAILY" '
'Enabled="1" RoomUUID="RINCON_test" ProgramURI="x-rincon-buzzer:0" ' 'Enabled="1" RoomUUID="RINCON_test" ProgramURI="x-rincon-buzzer:0" '
'ProgramMetaData="" PlayMode="SHUFFLE_NOREPEAT" Volume="25" ' 'ProgramMetaData="" PlayMode="SHUFFLE_NOREPEAT" Volume="25" '
'IncludeLinkedZones="0"/>' 'IncludeLinkedZones="0"/>'
"</Alarms> " "</Alarms>",
} }
return alarm_clock return alarm_clock
@ -141,6 +143,7 @@ def alarm_clock_fixture_extended():
alarm_clock = SonosMockService("AlarmClock") alarm_clock = SonosMockService("AlarmClock")
alarm_clock.ListAlarms = Mock() alarm_clock.ListAlarms = Mock()
alarm_clock.ListAlarms.return_value = { alarm_clock.ListAlarms.return_value = {
"CurrentAlarmListVersion": "RINCON_test:15",
"CurrentAlarmList": "<Alarms>" "CurrentAlarmList": "<Alarms>"
'<Alarm ID="14" StartTime="07:00:00" Duration="02:00:00" Recurrence="DAILY" ' '<Alarm ID="14" StartTime="07:00:00" Duration="02:00:00" Recurrence="DAILY" '
'Enabled="1" RoomUUID="RINCON_test" ProgramURI="x-rincon-buzzer:0" ' 'Enabled="1" RoomUUID="RINCON_test" ProgramURI="x-rincon-buzzer:0" '
@ -150,7 +153,7 @@ def alarm_clock_fixture_extended():
'Recurrence="DAILY" Enabled="1" RoomUUID="RINCON_test" ' 'Recurrence="DAILY" Enabled="1" RoomUUID="RINCON_test" '
'ProgramURI="x-rincon-buzzer:0" ProgramMetaData="" PlayMode="SHUFFLE_NOREPEAT" ' 'ProgramURI="x-rincon-buzzer:0" ProgramMetaData="" PlayMode="SHUFFLE_NOREPEAT" '
'Volume="25" IncludeLinkedZones="0"/>' 'Volume="25" IncludeLinkedZones="0"/>'
"</Alarms> " "</Alarms>",
} }
return alarm_clock return alarm_clock

View file

@ -69,13 +69,17 @@ async def test_alarm_create_delete(
alarm_clock.ListAlarms.return_value = two_alarms alarm_clock.ListAlarms.return_value = two_alarms
alarm_event.variables["alarm_list_version"] = two_alarms["CurrentAlarmListVersion"]
sub_callback(event=alarm_event) sub_callback(event=alarm_event)
await hass.async_block_till_done() await hass.async_block_till_done()
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_event.increment_variable("alarm_list_version") one_alarm["CurrentAlarmListVersion"] = alarm_event.increment_variable(
"alarm_list_version"
)
alarm_clock.ListAlarms.return_value = one_alarm alarm_clock.ListAlarms.return_value = one_alarm