Refactor Sonos polling (#65722)

* Refactor Sonos polling

Explicitly rename fallback polling
Catch soco exceptions centrally where possible
Create SonosPollingEntity subclass
Remove unnecessary soco_error fixture argument
Remove unnecessary polling in update_volume()
Adjust log levels and wording
Set explicit timeout on library

* Adjust logging to use raised exceptions

* Simplify availabiliity checks when using built-in poller

* Fix typing for return values
This commit is contained in:
jjlawren 2022-02-08 12:17:05 -06:00 committed by GitHub
parent 4efebcb86c
commit a7fd477c64
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 89 additions and 85 deletions

View file

@ -119,6 +119,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Sonos from a config entry."""
soco_config.EVENTS_MODULE = events_asyncio
soco_config.REQUEST_TIMEOUT = 9.5
if DATA_SONOS not in hass.data:
hass.data[DATA_SONOS] = SonosData()

View file

@ -8,11 +8,11 @@ from typing import TYPE_CHECKING, Any
from soco import SoCo
from soco.alarms import Alarm, Alarms
from soco.events_base import Event as SonosEvent
from soco.exceptions import SoCoException
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import DATA_SONOS, SONOS_ALARMS_UPDATED, SONOS_CREATE_ALARM
from .helpers import soco_error
from .household_coordinator import SonosHouseholdCoordinator
if TYPE_CHECKING:
@ -71,13 +71,10 @@ class SonosAlarms(SonosHouseholdCoordinator):
speaker.event_stats.process(event)
await self.async_update_entities(speaker.soco, event_id)
@soco_error()
def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool:
"""Update cache of known alarms and return if cache has changed."""
try:
self.alarms.update(soco)
except (OSError, SoCoException) as err:
_LOGGER.error("Could not update alarms using %s: %s", soco, err)
return False
self.alarms.update(soco)
if update_id and self.alarms.last_id < update_id:
# Skip updates if latest query result is outdated or lagging

View file

@ -64,7 +64,7 @@ class SonosPowerEntity(SonosEntity, BinarySensorEntity):
self._attr_unique_id = f"{self.soco.uid}-power"
self._attr_name = f"{self.speaker.zone_name} Power"
async def _async_poll(self) -> None:
async def _async_fallback_poll(self) -> None:
"""Poll the device for the current state."""
await self.speaker.async_poll_battery()
@ -98,7 +98,7 @@ class SonosMicrophoneSensorEntity(SonosEntity, BinarySensorEntity):
self._attr_unique_id = f"{self.soco.uid}-microphone"
self._attr_name = f"{self.speaker.zone_name} Microphone"
async def _async_poll(self) -> None:
async def _async_fallback_poll(self) -> None:
"""Stub for abstract class implementation. Not a pollable attribute."""
@property

View file

@ -153,7 +153,7 @@ SONOS_CREATE_MIC_SENSOR = "sonos_create_mic_sensor"
SONOS_CREATE_SWITCHES = "sonos_create_switches"
SONOS_CREATE_LEVELS = "sonos_create_levels"
SONOS_CREATE_MEDIA_PLAYER = "sonos_create_media_player"
SONOS_POLL_UPDATE = "sonos_poll_update"
SONOS_FALLBACK_POLL = "sonos_fallback_poll"
SONOS_ALARMS_UPDATED = "sonos_alarms_updated"
SONOS_FAVORITES_UPDATED = "sonos_favorites_updated"
SONOS_SPEAKER_ACTIVITY = "sonos_speaker_activity"

View file

@ -7,7 +7,6 @@ import logging
import soco.config as soco_config
from soco.core import SoCo
from soco.exceptions import SoCoException
from homeassistant.components import persistent_notification
import homeassistant.helpers.device_registry as dr
@ -17,10 +16,11 @@ from homeassistant.helpers.entity import DeviceInfo, Entity
from .const import (
DATA_SONOS,
DOMAIN,
SONOS_FALLBACK_POLL,
SONOS_FAVORITES_UPDATED,
SONOS_POLL_UPDATE,
SONOS_STATE_UPDATED,
)
from .exception import SonosUpdateError
from .speaker import SonosSpeaker
SUB_FAIL_URL = "https://www.home-assistant.io/integrations/sonos/#network-requirements"
@ -43,8 +43,8 @@ class SonosEntity(Entity):
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{SONOS_POLL_UPDATE}-{self.soco.uid}",
self.async_poll,
f"{SONOS_FALLBACK_POLL}-{self.soco.uid}",
self.async_fallback_poll,
)
)
self.async_on_remove(
@ -66,7 +66,7 @@ class SonosEntity(Entity):
"""Clean up when entity is removed."""
del self.hass.data[DATA_SONOS].entity_id_mappings[self.entity_id]
async def async_poll(self, now: datetime.datetime) -> None:
async def async_fallback_poll(self, now: datetime.datetime) -> None:
"""Poll the entity if subscriptions fail."""
if not self.speaker.subscriptions_failed:
if soco_config.EVENT_ADVERTISE_IP:
@ -86,13 +86,16 @@ class SonosEntity(Entity):
self.speaker.subscriptions_failed = True
await self.speaker.async_unsubscribe()
try:
await self._async_poll()
except (OSError, SoCoException) as ex:
_LOGGER.debug("Error connecting to %s: %s", self.entity_id, ex)
await self._async_fallback_poll()
except SonosUpdateError as err:
_LOGGER.debug("Could not fallback poll: %s", err)
@abstractmethod
async def _async_poll(self) -> None:
"""Poll the specific functionality. Should be implemented by platforms if needed."""
async def _async_fallback_poll(self) -> None:
"""Poll the specific functionality if subscriptions fail.
Should be implemented by platforms if needed.
"""
@property
def soco(self) -> SoCo:
@ -120,3 +123,20 @@ class SonosEntity(Entity):
def available(self) -> bool:
"""Return whether this device is available."""
return self.speaker.available
class SonosPollingEntity(SonosEntity):
"""Representation of a Sonos entity which may not support updating by subscriptions."""
@abstractmethod
def poll_state(self) -> None:
"""Poll the device for the current state."""
def update(self) -> None:
"""Update the state using the built-in entity poller."""
if not self.available:
return
try:
self.poll_state()
except SonosUpdateError as err:
_LOGGER.debug("Could not poll: %s", err)

View file

@ -7,5 +7,5 @@ class UnknownMediaType(BrowseError):
"""Unknown media type."""
class SpeakerUnavailable(HomeAssistantError):
"""Speaker is unavailable."""
class SonosUpdateError(HomeAssistantError):
"""Update failed."""

View file

@ -14,6 +14,7 @@ from soco.exceptions import SoCoException
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import SONOS_FAVORITES_UPDATED
from .helpers import soco_error
from .household_coordinator import SonosHouseholdCoordinator
if TYPE_CHECKING:
@ -90,6 +91,7 @@ class SonosFavorites(SonosHouseholdCoordinator):
self.last_processed_event_id = event_id
await self.async_update_entities(speaker.soco, container_id)
@soco_error()
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()

View file

@ -5,17 +5,18 @@ from collections.abc import Callable
import logging
from typing import TYPE_CHECKING, TypeVar
from soco import SoCo
from soco.exceptions import SoCoException, SoCoUPnPException
from typing_extensions import Concatenate, ParamSpec
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import dispatcher_send
from .const import SONOS_SPEAKER_ACTIVITY
from .exception import SpeakerUnavailable
from .exception import SonosUpdateError
if TYPE_CHECKING:
from .entity import SonosEntity
from .household_coordinator import SonosHouseholdCoordinator
from .speaker import SonosSpeaker
UID_PREFIX = "RINCON_"
@ -23,13 +24,13 @@ UID_POSTFIX = "01400"
_LOGGER = logging.getLogger(__name__)
_T = TypeVar("_T", "SonosSpeaker", "SonosEntity")
_T = TypeVar("_T", "SonosSpeaker", "SonosEntity", "SonosHouseholdCoordinator")
_R = TypeVar("_R")
_P = ParamSpec("_P")
def soco_error(
errorcodes: list[str] | None = None, raise_on_err: bool = True
errorcodes: list[str] | None = None,
) -> Callable[ # type: ignore[misc]
[Callable[Concatenate[_T, _P], _R]], Callable[Concatenate[_T, _P], _R | None]
]:
@ -42,10 +43,9 @@ def soco_error(
def wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> _R | None:
"""Wrap for all soco UPnP exception."""
args_soco = next((arg for arg in args if isinstance(arg, SoCo)), None)
try:
result = funct(self, *args, **kwargs)
except SpeakerUnavailable:
return None
except (OSError, SoCoException, SoCoUPnPException) as err:
error_code = getattr(err, "error_code", None)
function = funct.__qualname__
@ -55,20 +55,22 @@ def soco_error(
)
return None
# In order of preference:
# * SonosSpeaker instance
# * SoCo instance passed as an arg
# * SoCo instance (as self)
speaker_or_soco = getattr(self, "speaker", args_soco or self)
zone_name = speaker_or_soco.zone_name
# Prefer the entity_id if available, zone name as a fallback
# Needed as SonosSpeaker instances are not entities
zone_name = getattr(self, "speaker", self).zone_name
target = getattr(self, "entity_id", zone_name)
message = f"Error calling {function} on {target}: {err}"
if raise_on_err:
raise HomeAssistantError(message) from err
_LOGGER.warning(message)
return None
raise SonosUpdateError(message) from err
dispatch_soco = args_soco or self.soco
dispatcher_send(
self.hass,
f"{SONOS_SPEAKER_ACTIVITY}-{self.soco.uid}",
f"{SONOS_SPEAKER_ACTIVITY}-{dispatch_soco.uid}",
funct.__qualname__,
)
return result

View file

@ -6,12 +6,12 @@ from collections.abc import Callable, Coroutine
import logging
from soco import SoCo
from soco.exceptions import SoCoException
from homeassistant.core import HomeAssistant
from homeassistant.helpers.debounce import Debouncer
from .const import DATA_SONOS
from .exception import SonosUpdateError
_LOGGER = logging.getLogger(__name__)
@ -56,11 +56,10 @@ class SonosHouseholdCoordinator:
_LOGGER.debug("Polling %s using %s", self.class_type, speaker.soco)
try:
await self.async_update_entities(speaker.soco)
except (OSError, SoCoException) as err:
except SonosUpdateError as err:
_LOGGER.error(
"Could not refresh %s using %s: %s",
"Could not refresh %s: %s",
self.class_type,
speaker.soco,
err,
)
else:

View file

@ -283,7 +283,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
return STATE_PLAYING
return STATE_IDLE
async def _async_poll(self) -> None:
async def _async_fallback_poll(self) -> None:
"""Retrieve latest state by polling."""
await self.hass.data[DATA_SONOS].favorites[
self.speaker.household_id

View file

@ -12,7 +12,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import SONOS_CREATE_LEVELS
from .entity import SonosEntity
from .exception import SpeakerUnavailable
from .helpers import soco_error
from .speaker import SonosSpeaker
@ -75,16 +74,13 @@ class SonosLevelEntity(SonosEntity, NumberEntity):
self.level_type = level_type
self._attr_min_value, self._attr_max_value = valid_range
async def _async_poll(self) -> None:
async def _async_fallback_poll(self) -> None:
"""Poll the value if subscriptions are not working."""
await self.hass.async_add_executor_job(self.update)
@soco_error(raise_on_err=False)
def update(self) -> None:
"""Fetch number state if necessary."""
if not self.available:
raise SpeakerUnavailable
await self.hass.async_add_executor_job(self.poll_state)
@soco_error()
def poll_state(self) -> None:
"""Poll the device for the current state."""
state = getattr(self.soco, self.level_type)
setattr(self.speaker, self.level_type, state)

View file

@ -12,7 +12,8 @@ from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import SONOS_CREATE_AUDIO_FORMAT_SENSOR, SONOS_CREATE_BATTERY
from .entity import SonosEntity
from .entity import SonosEntity, SonosPollingEntity
from .helpers import soco_error
from .speaker import SonosSpeaker
_LOGGER = logging.getLogger(__name__)
@ -64,7 +65,7 @@ class SonosBatteryEntity(SonosEntity, SensorEntity):
self._attr_unique_id = f"{self.soco.uid}-battery"
self._attr_name = f"{self.speaker.zone_name} Battery"
async def _async_poll(self) -> None:
async def _async_fallback_poll(self) -> None:
"""Poll the device for the current state."""
await self.speaker.async_poll_battery()
@ -79,7 +80,7 @@ class SonosBatteryEntity(SonosEntity, SensorEntity):
return self.speaker.available and self.speaker.power_source
class SonosAudioInputFormatSensorEntity(SonosEntity, SensorEntity):
class SonosAudioInputFormatSensorEntity(SonosPollingEntity, SensorEntity):
"""Representation of a Sonos audio import format sensor entity."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
@ -93,9 +94,10 @@ class SonosAudioInputFormatSensorEntity(SonosEntity, SensorEntity):
self._attr_name = f"{self.speaker.zone_name} Audio Input Format"
self._attr_native_value = audio_format
def update(self) -> None:
@soco_error()
def poll_state(self) -> None:
"""Poll the device for the current state."""
self._attr_native_value = self.soco.soundbar_audio_input_format
async def _async_poll(self) -> None:
async def _async_fallback_poll(self) -> None:
"""Provide a stub for required ABC method."""

View file

@ -16,7 +16,7 @@ import defusedxml.ElementTree as ET
from soco.core import MUSIC_SRC_LINE_IN, MUSIC_SRC_RADIO, MUSIC_SRC_TV, SoCo
from soco.data_structures import DidlAudioBroadcast, DidlPlaylistContainer
from soco.events_base import Event as SonosEvent, SubscriptionBase
from soco.exceptions import SoCoException, SoCoSlaveException, SoCoUPnPException
from soco.exceptions import SoCoException, SoCoUPnPException
from soco.music_library import MusicLibrary
from soco.plugins.plex import PlexPlugin
from soco.plugins.sharelink import ShareLinkPlugin
@ -50,7 +50,7 @@ from .const import (
SONOS_CREATE_MEDIA_PLAYER,
SONOS_CREATE_MIC_SENSOR,
SONOS_CREATE_SWITCHES,
SONOS_POLL_UPDATE,
SONOS_FALLBACK_POLL,
SONOS_REBOOTED,
SONOS_SPEAKER_ACTIVITY,
SONOS_SPEAKER_ADDED,
@ -354,7 +354,7 @@ class SonosSpeaker:
partial(
async_dispatcher_send,
self.hass,
f"{SONOS_POLL_UPDATE}-{self.soco.uid}",
f"{SONOS_FALLBACK_POLL}-{self.soco.uid}",
),
SCAN_INTERVAL,
)
@ -568,7 +568,7 @@ class SonosSpeaker:
if not self.available:
return
_LOGGER.debug(
_LOGGER.warning(
"No recent activity and cannot reach %s, marking unavailable",
self.zone_name,
)
@ -1044,18 +1044,11 @@ class SonosSpeaker:
#
# Media and playback state handlers
#
@soco_error(raise_on_err=False)
@soco_error()
def update_volume(self) -> None:
"""Update information about current volume settings."""
self.volume = self.soco.volume
self.muted = self.soco.mute
self.night_mode = self.soco.night_mode
self.dialog_level = self.soco.dialog_level
try:
self.cross_fade = self.soco.cross_fade
except SoCoSlaveException:
pass
@soco_error()
def update_media(self, event: SonosEvent | None = None) -> None:

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import datetime
import logging
from soco.exceptions import SoCoException, SoCoSlaveException, SoCoUPnPException
from soco.exceptions import SoCoSlaveException, SoCoUPnPException
from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity
from homeassistant.config_entries import ConfigEntry
@ -22,8 +22,7 @@ from .const import (
SONOS_CREATE_ALARM,
SONOS_CREATE_SWITCHES,
)
from .entity import SonosEntity
from .exception import SpeakerUnavailable
from .entity import SonosEntity, SonosPollingEntity
from .helpers import soco_error
from .speaker import SonosSpeaker
@ -143,7 +142,7 @@ async def async_setup_entry(
)
class SonosSwitchEntity(SonosEntity, SwitchEntity):
class SonosSwitchEntity(SonosPollingEntity, SwitchEntity):
"""Representation of a Sonos feature switch."""
def __init__(self, feature_type: str, speaker: SonosSpeaker) -> None:
@ -163,17 +162,14 @@ class SonosSwitchEntity(SonosEntity, SwitchEntity):
self._attr_entity_registry_enabled_default = False
self._attr_should_poll = True
async def _async_poll(self) -> None:
async def _async_fallback_poll(self) -> None:
"""Handle polling for subscription-based switches when subscription fails."""
if not self.should_poll:
await self.hass.async_add_executor_job(self.update)
@soco_error(raise_on_err=False)
def update(self) -> None:
"""Fetch switch state if necessary."""
if not self.available:
raise SpeakerUnavailable
await self.hass.async_add_executor_job(self.poll_state)
@soco_error()
def poll_state(self) -> None:
"""Poll the current state of the switch."""
state = getattr(self.soco, self.feature_type)
setattr(self.speaker, self.feature_type, state)
@ -244,7 +240,7 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
str(self.alarm.start_time)[0:5],
)
async def _async_poll(self) -> None:
async def _async_fallback_poll(self) -> None:
"""Call the central alarm polling method."""
await self.hass.data[DATA_SONOS].alarms[self.household_id].async_poll()
@ -267,7 +263,6 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
if not self.async_check_if_available():
return
_LOGGER.debug("Updating alarm: %s", self.entity_id)
if self.speaker.soco.uid != self.alarm.zone.uid:
self.speaker = self.hass.data[DATA_SONOS].discovered.get(
self.alarm.zone.uid
@ -350,14 +345,11 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
"""Turn alarm switch off."""
await self.async_handle_switch_on_off(turn_on=False)
@soco_error()
async def async_handle_switch_on_off(self, turn_on: bool) -> None:
"""Handle turn on/off of alarm switch."""
try:
_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 (OSError, SoCoException, SoCoUPnPException) as exc:
_LOGGER.error("Could not update %s: %s", self.entity_id, exc)
self.alarm.enabled = turn_on
await self.hass.async_add_executor_job(self.alarm.save)
@callback