diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 5f3cc285517..7741ec36708 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -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() diff --git a/homeassistant/components/sonos/alarms.py b/homeassistant/components/sonos/alarms.py index dfe9e8328a4..e7cf05a1ff0 100644 --- a/homeassistant/components/sonos/alarms.py +++ b/homeassistant/components/sonos/alarms.py @@ -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 diff --git a/homeassistant/components/sonos/binary_sensor.py b/homeassistant/components/sonos/binary_sensor.py index 008e0b2cf57..534e5f5dd02 100644 --- a/homeassistant/components/sonos/binary_sensor.py +++ b/homeassistant/components/sonos/binary_sensor.py @@ -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 diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index abb0696360b..2d5dbc5195a 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -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" diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index 53768431a1d..8565fe08e9c 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -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) diff --git a/homeassistant/components/sonos/exception.py b/homeassistant/components/sonos/exception.py index d7f1a2e6a96..bce1e3233c1 100644 --- a/homeassistant/components/sonos/exception.py +++ b/homeassistant/components/sonos/exception.py @@ -7,5 +7,5 @@ class UnknownMediaType(BrowseError): """Unknown media type.""" -class SpeakerUnavailable(HomeAssistantError): - """Speaker is unavailable.""" +class SonosUpdateError(HomeAssistantError): + """Update failed.""" diff --git a/homeassistant/components/sonos/favorites.py b/homeassistant/components/sonos/favorites.py index 64c5b743809..fd651b7740c 100644 --- a/homeassistant/components/sonos/favorites.py +++ b/homeassistant/components/sonos/favorites.py @@ -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() diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py index 625b54b941e..8e7f6fcf294 100644 --- a/homeassistant/components/sonos/helpers.py +++ b/homeassistant/components/sonos/helpers.py @@ -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 diff --git a/homeassistant/components/sonos/household_coordinator.py b/homeassistant/components/sonos/household_coordinator.py index f233b338279..0d76feae461 100644 --- a/homeassistant/components/sonos/household_coordinator.py +++ b/homeassistant/components/sonos/household_coordinator.py @@ -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: diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 41453117c13..5f5220cd164 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -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 diff --git a/homeassistant/components/sonos/number.py b/homeassistant/components/sonos/number.py index 2e6acd55a66..c9b8ec47583 100644 --- a/homeassistant/components/sonos/number.py +++ b/homeassistant/components/sonos/number.py @@ -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) diff --git a/homeassistant/components/sonos/sensor.py b/homeassistant/components/sonos/sensor.py index 4e86edc2ca3..5cccc40ac5f 100644 --- a/homeassistant/components/sonos/sensor.py +++ b/homeassistant/components/sonos/sensor.py @@ -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.""" diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index ca1e5a0a91c..b9844303956 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -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: diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index 4e3303db45d..db10d9189e5 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -4,7 +4,7 @@ from __future__ import annotations import datetime import logging -from 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