diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 72e5a33ca28c32613574b1ef1b597db75bac1f38..c8defb1ff14f9ec2c706ea2fd103c2096c2edc65 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -5,6 +5,7 @@ import asyncio from collections import OrderedDict import datetime from enum import Enum +from functools import partial import logging import socket from urllib.parse import urlparse @@ -22,17 +23,19 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOSTS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback 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 from .alarms import SonosAlarms from .const import ( + AVAILABILITY_CHECK_INTERVAL, DATA_SONOS, DATA_SONOS_DISCOVERY_MANAGER, DISCOVERY_INTERVAL, DOMAIN, PLATFORMS, + SONOS_CHECK_ACTIVITY, SONOS_REBOOTED, - SONOS_SEEN, + SONOS_SPEAKER_ACTIVITY, UPNP_ST, ) from .favorites import SonosFavorites @@ -187,7 +190,7 @@ class SonosDiscoveryManager: async def _async_stop_event_listener(self, event: Event | None = None) -> None: await asyncio.gather( - *(speaker.async_unsubscribe() for speaker in self.data.discovered.values()) + *(speaker.async_offline() for speaker in self.data.discovered.values()) ) if events_asyncio.event_listener: await events_asyncio.event_listener.async_stop() @@ -212,7 +215,7 @@ class SonosDiscoveryManager: new_coordinator = coordinator(self.hass, soco.household_id) new_coordinator.setup(soco) coord_dict[soco.household_id] = new_coordinator - speaker.setup() + speaker.setup(self.entry) except (OSError, SoCoException): _LOGGER.warning("Failed to add SonosSpeaker using %s", soco, exc_info=True) @@ -228,10 +231,7 @@ class SonosDiscoveryManager: ), None, ) - - if known_uid: - dispatcher_send(self.hass, f"{SONOS_SEEN}-{known_uid}") - else: + if not known_uid: soco = self._create_soco(ip_addr, SoCoCreationSource.CONFIGURED) if soco and soco.is_visible: self._discovered_player(soco) @@ -261,7 +261,9 @@ class SonosDiscoveryManager: ): async_dispatcher_send(self.hass, f"{SONOS_REBOOTED}-{uid}", soco) else: - async_dispatcher_send(self.hass, f"{SONOS_SEEN}-{uid}") + async_dispatcher_send( + self.hass, f"{SONOS_SPEAKER_ACTIVITY}-{uid}", "discovery" + ) async def _async_ssdp_discovered_player(self, info, change): if change == ssdp.SsdpChange.BYEBYE: @@ -327,3 +329,14 @@ class SonosDiscoveryManager: self.hass, self._async_ssdp_discovered_player, {"st": UPNP_ST} ) ) + + self.entry.async_on_unload( + self.hass.helpers.event.async_track_time_interval( + partial( + async_dispatcher_send, + self.hass, + SONOS_CHECK_ACTIVITY, + ), + AVAILABILITY_CHECK_INTERVAL, + ) + ) diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index abd0465293689e9f255038e3b3c6ed0770c23b7f..05abc662b480badff4b0becfc1ffe5a55c289806 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -135,6 +135,7 @@ PLAYABLE_MEDIA_TYPES = [ MEDIA_TYPE_TRACK, ] +SONOS_CHECK_ACTIVITY = "sonos_check_activity" SONOS_CREATE_ALARM = "sonos_create_alarm" SONOS_CREATE_BATTERY = "sonos_create_battery" SONOS_CREATE_SWITCHES = "sonos_create_switches" @@ -143,18 +144,17 @@ SONOS_ENTITY_CREATED = "sonos_entity_created" SONOS_POLL_UPDATE = "sonos_poll_update" SONOS_ALARMS_UPDATED = "sonos_alarms_updated" SONOS_FAVORITES_UPDATED = "sonos_favorites_updated" +SONOS_SPEAKER_ACTIVITY = "sonos_speaker_activity" SONOS_SPEAKER_ADDED = "sonos_speaker_added" SONOS_STATE_UPDATED = "sonos_state_updated" SONOS_REBOOTED = "sonos_rebooted" -SONOS_SEEN = "sonos_seen" SOURCE_LINEIN = "Line-in" SOURCE_TV = "TV" +AVAILABILITY_CHECK_INTERVAL = datetime.timedelta(minutes=1) +AVAILABILITY_TIMEOUT = AVAILABILITY_CHECK_INTERVAL.total_seconds() * 4.5 BATTERY_SCAN_INTERVAL = datetime.timedelta(minutes=15) SCAN_INTERVAL = datetime.timedelta(seconds=10) DISCOVERY_INTERVAL = datetime.timedelta(seconds=60) -SEEN_EXPIRE_TIME = 3.5 * DISCOVERY_INTERVAL SUBSCRIPTION_TIMEOUT = 1200 - -MDNS_SERVICE = "_sonos._tcp.local." diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index 0579c4f5c9b2307a712685f8739a76cdbfcff76f..d8196ffdfa6336374f06d316eec8331743f06435 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -39,8 +39,6 @@ class SonosEntity(Entity): async def async_added_to_hass(self) -> None: """Handle common setup when added to hass.""" - await self.speaker.async_seen() - self.async_on_remove( async_dispatcher_connect( self.hass, diff --git a/homeassistant/components/sonos/exception.py b/homeassistant/components/sonos/exception.py index 3d5a1230bcbad32a5b8996a28955c3f205838c14..d7f1a2e6a9630bf335dfde83270df4cfbdfc132d 100644 --- a/homeassistant/components/sonos/exception.py +++ b/homeassistant/components/sonos/exception.py @@ -1,6 +1,11 @@ """Sonos specific exceptions.""" from homeassistant.components.media_player.errors import BrowseError +from homeassistant.exceptions import HomeAssistantError class UnknownMediaType(BrowseError): """Unknown media type.""" + + +class SpeakerUnavailable(HomeAssistantError): + """Speaker is unavailable.""" diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py index 490bcdefba589f06e502dc1b52767036376e6fb7..e80d16a491b2fe241f709e5fca96e080bc615283 100644 --- a/homeassistant/components/sonos/helpers.py +++ b/homeassistant/components/sonos/helpers.py @@ -1,32 +1,43 @@ """Helper methods for common tasks.""" from __future__ import annotations -from collections.abc import Callable -import functools as ft import logging -from typing import Any +from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast from soco.exceptions import SoCoException, SoCoUPnPException from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import dispatcher_send + +from .const import SONOS_SPEAKER_ACTIVITY +from .exception import SpeakerUnavailable + +if TYPE_CHECKING: + from .entity import SonosEntity + from .speaker import SonosSpeaker UID_PREFIX = "RINCON_" UID_POSTFIX = "01400" +WrapFuncType = TypeVar("WrapFuncType", bound=Callable[..., Any]) + _LOGGER = logging.getLogger(__name__) -def soco_error(errorcodes: list[str] | None = None) -> Callable: +def soco_error( + errorcodes: list[str] | None = None, raise_on_err: bool = True +) -> Callable: """Filter out specified UPnP errors and raise exceptions for service calls.""" - def decorator(funct: Callable) -> Callable: + def decorator(funct: WrapFuncType) -> WrapFuncType: """Decorate functions.""" - @ft.wraps(funct) - def wrapper(*args: Any, **kwargs: Any) -> Any: + def wrapper(self: SonosSpeaker | SonosEntity, *args: Any, **kwargs: Any) -> Any: """Wrap for all soco UPnP exception.""" try: - return funct(*args, **kwargs) + result = funct(self, *args, **kwargs) + except SpeakerUnavailable: + return None except (OSError, SoCoException, SoCoUPnPException) as err: error_code = getattr(err, "error_code", None) function = funct.__name__ @@ -34,10 +45,25 @@ def soco_error(errorcodes: list[str] | None = None) -> Callable: _LOGGER.debug( "Error code %s ignored in call to %s", error_code, function ) - return - raise HomeAssistantError(f"Error calling {function}: {err}") from err + return None + + # 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 + + dispatcher_send( + self.hass, f"{SONOS_SPEAKER_ACTIVITY}-{self.soco.uid}", funct.__name__ + ) + return result - return wrapper + return cast(WrapFuncType, wrapper) return decorator diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 593bd8f034aa612ffdbf03fc0336517d3e176be9..1acb814ee178cb21813ade45697f550f37098497 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -7,6 +7,7 @@ import contextlib import datetime from functools import partial import logging +import time from typing import Any import urllib.parse @@ -19,30 +20,30 @@ from soco.music_library import MusicLibrary from soco.plugins.sharelink import ShareLinkPlugin from soco.snapshot import Snapshot -from homeassistant.components import zeroconf from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as ent_reg from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, async_dispatcher_send, - dispatcher_connect, dispatcher_send, ) from homeassistant.util import dt as dt_util from .alarms import SonosAlarms from .const import ( + AVAILABILITY_TIMEOUT, BATTERY_SCAN_INTERVAL, DATA_SONOS, DOMAIN, - MDNS_SERVICE, PLATFORMS, SCAN_INTERVAL, - SEEN_EXPIRE_TIME, + SONOS_CHECK_ACTIVITY, SONOS_CREATE_ALARM, SONOS_CREATE_BATTERY, SONOS_CREATE_MEDIA_PLAYER, @@ -50,7 +51,7 @@ from .const import ( SONOS_ENTITY_CREATED, SONOS_POLL_UPDATE, SONOS_REBOOTED, - SONOS_SEEN, + SONOS_SPEAKER_ACTIVITY, SONOS_SPEAKER_ADDED, SONOS_STATE_PLAYING, SONOS_STATE_TRANSITIONING, @@ -154,6 +155,7 @@ class SonosSpeaker: self.household_id: str = soco.household_id self.media = SonosMedia(soco) self._share_link_plugin: ShareLinkPlugin | None = None + self.available = True # Synchronization helpers self._is_ready: bool = False @@ -164,16 +166,13 @@ class SonosSpeaker: self._subscriptions: list[SubscriptionBase] = [] self._resubscription_lock: asyncio.Lock | None = None self._event_dispatchers: dict[str, Callable] = {} + self._last_activity: datetime.datetime | None = None # Scheduled callback handles self._poll_timer: Callable | None = None - self._seen_timer: Callable | None = None # Dispatcher handles - self._entity_creation_dispatcher: Callable | None = None - self._group_dispatcher: Callable | None = None - self._reboot_dispatcher: Callable | None = None - self._seen_dispatcher: Callable | None = None + self.dispatchers: list[Callable] = [] # Device information self.mac_address = speaker_info["mac_address"] @@ -208,26 +207,32 @@ class SonosSpeaker: self.snapshot_group: list[SonosSpeaker] | None = None self._group_members_missing: set[str] = set() - def setup(self) -> None: + async def async_setup_dispatchers(self, entry: ConfigEntry) -> None: + """Connect dispatchers in async context during setup.""" + dispatch_pairs = ( + (SONOS_CHECK_ACTIVITY, self.async_check_activity), + (SONOS_SPEAKER_ADDED, self.update_group_for_uid), + (f"{SONOS_ENTITY_CREATED}-{self.soco.uid}", self.async_handle_new_entity), + (f"{SONOS_REBOOTED}-{self.soco.uid}", self.async_rebooted), + (f"{SONOS_SPEAKER_ACTIVITY}-{self.soco.uid}", self.speaker_activity), + ) + + for (signal, target) in dispatch_pairs: + entry.async_on_unload( + async_dispatcher_connect( + self.hass, + signal, + target, + ) + ) + + def setup(self, entry: ConfigEntry) -> None: """Run initial setup of the speaker.""" self.set_basic_info() - - self._entity_creation_dispatcher = dispatcher_connect( - self.hass, - f"{SONOS_ENTITY_CREATED}-{self.soco.uid}", - self.async_handle_new_entity, - ) - self._seen_dispatcher = dispatcher_connect( - self.hass, f"{SONOS_SEEN}-{self.soco.uid}", self.async_seen - ) - self._reboot_dispatcher = dispatcher_connect( - self.hass, f"{SONOS_REBOOTED}-{self.soco.uid}", self.async_rebooted - ) - self._group_dispatcher = dispatcher_connect( - self.hass, - SONOS_SPEAKER_ADDED, - self.update_group_for_uid, + future = asyncio.run_coroutine_threadsafe( + self.async_setup_dispatchers(entry), self.hass.loop ) + future.result(timeout=10) if battery_info := fetch_battery_info_or_none(self.soco): self.battery_info = battery_info @@ -291,11 +296,6 @@ class SonosSpeaker: # # Properties # - @property - def available(self) -> bool: - """Return whether this speaker is available.""" - return self._seen_timer is not None - @property def alarms(self) -> SonosAlarms: """Return the SonosAlarms instance for this household.""" @@ -408,7 +408,7 @@ class SonosSpeaker: self.zone_name, exc_info=exception, ) - await self.async_unseen() + await self.async_offline() @callback def async_dispatch_event(self, event: SonosEvent) -> None: @@ -420,6 +420,8 @@ class SonosSpeaker: self._poll_timer() self._poll_timer = None + self.speaker_activity(f"{event.service.service_type} subscription") + dispatcher = self._event_dispatchers[event.service.service_type] dispatcher(event) @@ -500,65 +502,43 @@ class SonosSpeaker: # Speaker availability methods # @callback - def _async_reset_seen_timer(self): - """Reset the _seen_timer scheduler.""" - if self._seen_timer: - self._seen_timer() - self._seen_timer = self.hass.helpers.event.async_call_later( - SEEN_EXPIRE_TIME.total_seconds(), self.async_unseen - ) - - async def async_seen(self, soco: SoCo | None = None) -> None: - """Record that this speaker was seen right now.""" - if soco is not None: - self.soco = soco - + def speaker_activity(self, source): + """Track the last activity on this speaker, set availability and resubscribe.""" + _LOGGER.debug("Activity on %s from %s", self.zone_name, source) + self._last_activity = time.monotonic() was_available = self.available - - self._async_reset_seen_timer() - - if was_available: + self.available = True + if not was_available: self.async_write_entity_states() - return + self.hass.async_create_task(self.async_subscribe()) - _LOGGER.debug( - "%s [%s] was not available, setting up", - self.zone_name, - self.soco.ip_address, - ) - - if self._is_ready and not self.subscriptions_failed: - done = await self.async_subscribe() - if not done: - await self.async_unseen() + async def async_check_activity(self, now: datetime.datetime) -> None: + """Validate availability of the speaker based on recent activity.""" + if time.monotonic() - self._last_activity < AVAILABILITY_TIMEOUT: + return - self.async_write_entity_states() + try: + _ = await self.hass.async_add_executor_job(getattr, self.soco, "volume") + except (OSError, SoCoException): + pass + else: + self.speaker_activity("timeout poll") + return - async def async_unseen( - self, callback_timestamp: datetime.datetime | None = None - ) -> None: - """Make this player unavailable when it was not seen recently.""" - data = self.hass.data[DATA_SONOS] - if (zcname := data.mdns_names.get(self.soco.uid)) and callback_timestamp: - # Called by a _seen_timer timeout, check mDNS one more time - # This should not be checked in an "active" unseen scenario - aiozeroconf = await zeroconf.async_get_async_instance(self.hass) - if await aiozeroconf.async_get_service_info(MDNS_SERVICE, zcname): - # We can still see the speaker via zeroconf check again later. - self._async_reset_seen_timer() - return + if not self.available: + return _LOGGER.debug( - "No activity and could not locate %s on the network. Marking unavailable", - zcname, + "No recent activity and cannot reach %s, marking unavailable", + self.zone_name, ) + await self.async_offline() + async def async_offline(self) -> None: + """Handle removal of speaker when unavailable.""" + self.available = False self._share_link_plugin = None - if self._seen_timer: - self._seen_timer() - self._seen_timer = None - if self._poll_timer: self._poll_timer() self._poll_timer = None @@ -575,11 +555,9 @@ class SonosSpeaker: self.zone_name, soco, ) - await self.async_unsubscribe() + await self.async_offline() self.soco = soco - await self.async_subscribe() - self._async_reset_seen_timer() - self.async_write_entity_states() + self.speaker_activity("reboot") # # Battery management diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index 830f3b09481e45e1a02f9202790e172975e55c09..78469dc8bbce983aaeeba785e0f7b32d2696f1fb 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -20,6 +20,8 @@ from .const import ( SONOS_CREATE_SWITCHES, ) from .entity import SonosEntity +from .exception import SpeakerUnavailable +from .helpers import soco_error from .speaker import SonosSpeaker _LOGGER = logging.getLogger(__name__) @@ -144,8 +146,12 @@ class SonosSwitchEntity(SonosEntity, SwitchEntity): 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 + state = getattr(self.soco, self.feature_type) setattr(self.speaker, self.feature_type, state) @@ -164,6 +170,7 @@ class SonosSwitchEntity(SonosEntity, SwitchEntity): """Turn the entity off.""" self.send_command(False) + @soco_error() def send_command(self, enable: bool) -> None: """Enable or disable the feature on the device.""" if self.needs_coordinator: