Set Sonos availability based on activity and discovery (#59994)

This commit is contained in:
jjlawren 2021-11-21 18:48:57 -06:00 committed by GitHub
parent 263101b2ab
commit aa5cf175f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 139 additions and 112 deletions

View File

@ -5,6 +5,7 @@ import asyncio
from collections import OrderedDict from collections import OrderedDict
import datetime import datetime
from enum import Enum from enum import Enum
from functools import partial
import logging import logging
import socket import socket
from urllib.parse import urlparse 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.const import CONF_HOSTS, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback 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
from .alarms import SonosAlarms from .alarms import SonosAlarms
from .const import ( from .const import (
AVAILABILITY_CHECK_INTERVAL,
DATA_SONOS, DATA_SONOS,
DATA_SONOS_DISCOVERY_MANAGER, DATA_SONOS_DISCOVERY_MANAGER,
DISCOVERY_INTERVAL, DISCOVERY_INTERVAL,
DOMAIN, DOMAIN,
PLATFORMS, PLATFORMS,
SONOS_CHECK_ACTIVITY,
SONOS_REBOOTED, SONOS_REBOOTED,
SONOS_SEEN, SONOS_SPEAKER_ACTIVITY,
UPNP_ST, UPNP_ST,
) )
from .favorites import SonosFavorites from .favorites import SonosFavorites
@ -187,7 +190,7 @@ class SonosDiscoveryManager:
async def _async_stop_event_listener(self, event: Event | None = None) -> None: async def _async_stop_event_listener(self, event: Event | None = None) -> None:
await asyncio.gather( 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: if events_asyncio.event_listener:
await events_asyncio.event_listener.async_stop() await events_asyncio.event_listener.async_stop()
@ -212,7 +215,7 @@ class SonosDiscoveryManager:
new_coordinator = coordinator(self.hass, soco.household_id) new_coordinator = coordinator(self.hass, soco.household_id)
new_coordinator.setup(soco) new_coordinator.setup(soco)
coord_dict[soco.household_id] = new_coordinator coord_dict[soco.household_id] = new_coordinator
speaker.setup() speaker.setup(self.entry)
except (OSError, SoCoException): except (OSError, SoCoException):
_LOGGER.warning("Failed to add SonosSpeaker using %s", soco, exc_info=True) _LOGGER.warning("Failed to add SonosSpeaker using %s", soco, exc_info=True)
@ -228,10 +231,7 @@ class SonosDiscoveryManager:
), ),
None, None,
) )
if not known_uid:
if known_uid:
dispatcher_send(self.hass, f"{SONOS_SEEN}-{known_uid}")
else:
soco = self._create_soco(ip_addr, SoCoCreationSource.CONFIGURED) soco = self._create_soco(ip_addr, SoCoCreationSource.CONFIGURED)
if soco and soco.is_visible: if soco and soco.is_visible:
self._discovered_player(soco) self._discovered_player(soco)
@ -261,7 +261,9 @@ class SonosDiscoveryManager:
): ):
async_dispatcher_send(self.hass, f"{SONOS_REBOOTED}-{uid}", soco) async_dispatcher_send(self.hass, f"{SONOS_REBOOTED}-{uid}", soco)
else: 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): async def _async_ssdp_discovered_player(self, info, change):
if change == ssdp.SsdpChange.BYEBYE: if change == ssdp.SsdpChange.BYEBYE:
@ -327,3 +329,14 @@ class SonosDiscoveryManager:
self.hass, self._async_ssdp_discovered_player, {"st": UPNP_ST} 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,
)
)

View File

@ -135,6 +135,7 @@ PLAYABLE_MEDIA_TYPES = [
MEDIA_TYPE_TRACK, MEDIA_TYPE_TRACK,
] ]
SONOS_CHECK_ACTIVITY = "sonos_check_activity"
SONOS_CREATE_ALARM = "sonos_create_alarm" SONOS_CREATE_ALARM = "sonos_create_alarm"
SONOS_CREATE_BATTERY = "sonos_create_battery" SONOS_CREATE_BATTERY = "sonos_create_battery"
SONOS_CREATE_SWITCHES = "sonos_create_switches" SONOS_CREATE_SWITCHES = "sonos_create_switches"
@ -143,18 +144,17 @@ SONOS_ENTITY_CREATED = "sonos_entity_created"
SONOS_POLL_UPDATE = "sonos_poll_update" SONOS_POLL_UPDATE = "sonos_poll_update"
SONOS_ALARMS_UPDATED = "sonos_alarms_updated" SONOS_ALARMS_UPDATED = "sonos_alarms_updated"
SONOS_FAVORITES_UPDATED = "sonos_favorites_updated" SONOS_FAVORITES_UPDATED = "sonos_favorites_updated"
SONOS_SPEAKER_ACTIVITY = "sonos_speaker_activity"
SONOS_SPEAKER_ADDED = "sonos_speaker_added" SONOS_SPEAKER_ADDED = "sonos_speaker_added"
SONOS_STATE_UPDATED = "sonos_state_updated" SONOS_STATE_UPDATED = "sonos_state_updated"
SONOS_REBOOTED = "sonos_rebooted" SONOS_REBOOTED = "sonos_rebooted"
SONOS_SEEN = "sonos_seen"
SOURCE_LINEIN = "Line-in" SOURCE_LINEIN = "Line-in"
SOURCE_TV = "TV" 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) BATTERY_SCAN_INTERVAL = datetime.timedelta(minutes=15)
SCAN_INTERVAL = datetime.timedelta(seconds=10) SCAN_INTERVAL = datetime.timedelta(seconds=10)
DISCOVERY_INTERVAL = datetime.timedelta(seconds=60) DISCOVERY_INTERVAL = datetime.timedelta(seconds=60)
SEEN_EXPIRE_TIME = 3.5 * DISCOVERY_INTERVAL
SUBSCRIPTION_TIMEOUT = 1200 SUBSCRIPTION_TIMEOUT = 1200
MDNS_SERVICE = "_sonos._tcp.local."

View File

@ -39,8 +39,6 @@ class SonosEntity(Entity):
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Handle common setup when added to hass.""" """Handle common setup when added to hass."""
await self.speaker.async_seen()
self.async_on_remove( self.async_on_remove(
async_dispatcher_connect( async_dispatcher_connect(
self.hass, self.hass,

View File

@ -1,6 +1,11 @@
"""Sonos specific exceptions.""" """Sonos specific exceptions."""
from homeassistant.components.media_player.errors import BrowseError from homeassistant.components.media_player.errors import BrowseError
from homeassistant.exceptions import HomeAssistantError
class UnknownMediaType(BrowseError): class UnknownMediaType(BrowseError):
"""Unknown media type.""" """Unknown media type."""
class SpeakerUnavailable(HomeAssistantError):
"""Speaker is unavailable."""

View File

@ -1,32 +1,43 @@
"""Helper methods for common tasks.""" """Helper methods for common tasks."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
import functools as ft
import logging import logging
from typing import Any from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
from soco.exceptions import SoCoException, SoCoUPnPException from soco.exceptions import SoCoException, SoCoUPnPException
from homeassistant.exceptions import HomeAssistantError 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_PREFIX = "RINCON_"
UID_POSTFIX = "01400" UID_POSTFIX = "01400"
WrapFuncType = TypeVar("WrapFuncType", bound=Callable[..., Any])
_LOGGER = logging.getLogger(__name__) _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.""" """Filter out specified UPnP errors and raise exceptions for service calls."""
def decorator(funct: Callable) -> Callable: def decorator(funct: WrapFuncType) -> WrapFuncType:
"""Decorate functions.""" """Decorate functions."""
@ft.wraps(funct) def wrapper(self: SonosSpeaker | SonosEntity, *args: Any, **kwargs: Any) -> Any:
def wrapper(*args: Any, **kwargs: Any) -> Any:
"""Wrap for all soco UPnP exception.""" """Wrap for all soco UPnP exception."""
try: try:
return funct(*args, **kwargs) result = funct(self, *args, **kwargs)
except SpeakerUnavailable:
return None
except (OSError, SoCoException, SoCoUPnPException) as err: except (OSError, SoCoException, SoCoUPnPException) as err:
error_code = getattr(err, "error_code", None) error_code = getattr(err, "error_code", None)
function = funct.__name__ function = funct.__name__
@ -34,10 +45,25 @@ def soco_error(errorcodes: list[str] | None = None) -> Callable:
_LOGGER.debug( _LOGGER.debug(
"Error code %s ignored in call to %s", error_code, function "Error code %s ignored in call to %s", error_code, function
) )
return return None
raise HomeAssistantError(f"Error calling {function}: {err}") from err
return wrapper # 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 cast(WrapFuncType, wrapper)
return decorator return decorator

View File

@ -7,6 +7,7 @@ import contextlib
import datetime import datetime
from functools import partial from functools import partial
import logging import logging
import time
from typing import Any from typing import Any
import urllib.parse import urllib.parse
@ -19,30 +20,30 @@ from soco.music_library import MusicLibrary
from soco.plugins.sharelink import ShareLinkPlugin from soco.plugins.sharelink import ShareLinkPlugin
from soco.snapshot import Snapshot from soco.snapshot import Snapshot
from homeassistant.components import zeroconf
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as ent_reg from homeassistant.helpers import entity_registry as ent_reg
from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send, async_dispatcher_send,
dispatcher_connect,
dispatcher_send, dispatcher_send,
) )
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .alarms import SonosAlarms from .alarms import SonosAlarms
from .const import ( from .const import (
AVAILABILITY_TIMEOUT,
BATTERY_SCAN_INTERVAL, BATTERY_SCAN_INTERVAL,
DATA_SONOS, DATA_SONOS,
DOMAIN, DOMAIN,
MDNS_SERVICE,
PLATFORMS, PLATFORMS,
SCAN_INTERVAL, SCAN_INTERVAL,
SEEN_EXPIRE_TIME, SONOS_CHECK_ACTIVITY,
SONOS_CREATE_ALARM, SONOS_CREATE_ALARM,
SONOS_CREATE_BATTERY, SONOS_CREATE_BATTERY,
SONOS_CREATE_MEDIA_PLAYER, SONOS_CREATE_MEDIA_PLAYER,
@ -50,7 +51,7 @@ from .const import (
SONOS_ENTITY_CREATED, SONOS_ENTITY_CREATED,
SONOS_POLL_UPDATE, SONOS_POLL_UPDATE,
SONOS_REBOOTED, SONOS_REBOOTED,
SONOS_SEEN, SONOS_SPEAKER_ACTIVITY,
SONOS_SPEAKER_ADDED, SONOS_SPEAKER_ADDED,
SONOS_STATE_PLAYING, SONOS_STATE_PLAYING,
SONOS_STATE_TRANSITIONING, SONOS_STATE_TRANSITIONING,
@ -154,6 +155,7 @@ class SonosSpeaker:
self.household_id: str = soco.household_id self.household_id: str = soco.household_id
self.media = SonosMedia(soco) self.media = SonosMedia(soco)
self._share_link_plugin: ShareLinkPlugin | None = None self._share_link_plugin: ShareLinkPlugin | None = None
self.available = True
# Synchronization helpers # Synchronization helpers
self._is_ready: bool = False self._is_ready: bool = False
@ -164,16 +166,13 @@ class SonosSpeaker:
self._subscriptions: list[SubscriptionBase] = [] self._subscriptions: list[SubscriptionBase] = []
self._resubscription_lock: asyncio.Lock | None = None self._resubscription_lock: asyncio.Lock | None = None
self._event_dispatchers: dict[str, Callable] = {} self._event_dispatchers: dict[str, Callable] = {}
self._last_activity: datetime.datetime | None = None
# Scheduled callback handles # Scheduled callback handles
self._poll_timer: Callable | None = None self._poll_timer: Callable | None = None
self._seen_timer: Callable | None = None
# Dispatcher handles # Dispatcher handles
self._entity_creation_dispatcher: Callable | None = None self.dispatchers: list[Callable] = []
self._group_dispatcher: Callable | None = None
self._reboot_dispatcher: Callable | None = None
self._seen_dispatcher: Callable | None = None
# Device information # Device information
self.mac_address = speaker_info["mac_address"] self.mac_address = speaker_info["mac_address"]
@ -208,26 +207,32 @@ class SonosSpeaker:
self.snapshot_group: list[SonosSpeaker] | None = None self.snapshot_group: list[SonosSpeaker] | None = None
self._group_members_missing: set[str] = set() 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.""" """Run initial setup of the speaker."""
self.set_basic_info() self.set_basic_info()
future = asyncio.run_coroutine_threadsafe(
self._entity_creation_dispatcher = dispatcher_connect( self.async_setup_dispatchers(entry), self.hass.loop
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.result(timeout=10)
if battery_info := fetch_battery_info_or_none(self.soco): if battery_info := fetch_battery_info_or_none(self.soco):
self.battery_info = battery_info self.battery_info = battery_info
@ -291,11 +296,6 @@ class SonosSpeaker:
# #
# Properties # Properties
# #
@property
def available(self) -> bool:
"""Return whether this speaker is available."""
return self._seen_timer is not None
@property @property
def alarms(self) -> SonosAlarms: def alarms(self) -> SonosAlarms:
"""Return the SonosAlarms instance for this household.""" """Return the SonosAlarms instance for this household."""
@ -408,7 +408,7 @@ class SonosSpeaker:
self.zone_name, self.zone_name,
exc_info=exception, exc_info=exception,
) )
await self.async_unseen() await self.async_offline()
@callback @callback
def async_dispatch_event(self, event: SonosEvent) -> None: def async_dispatch_event(self, event: SonosEvent) -> None:
@ -420,6 +420,8 @@ class SonosSpeaker:
self._poll_timer() self._poll_timer()
self._poll_timer = None self._poll_timer = None
self.speaker_activity(f"{event.service.service_type} subscription")
dispatcher = self._event_dispatchers[event.service.service_type] dispatcher = self._event_dispatchers[event.service.service_type]
dispatcher(event) dispatcher(event)
@ -500,65 +502,43 @@ class SonosSpeaker:
# Speaker availability methods # Speaker availability methods
# #
@callback @callback
def _async_reset_seen_timer(self): def speaker_activity(self, source):
"""Reset the _seen_timer scheduler.""" """Track the last activity on this speaker, set availability and resubscribe."""
if self._seen_timer: _LOGGER.debug("Activity on %s from %s", self.zone_name, source)
self._seen_timer() self._last_activity = time.monotonic()
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
was_available = self.available was_available = self.available
self.available = True
self._async_reset_seen_timer() if not was_available:
if was_available:
self.async_write_entity_states() self.async_write_entity_states()
self.hass.async_create_task(self.async_subscribe())
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
try:
_ = await self.hass.async_add_executor_job(getattr, self.soco, "volume")
except (OSError, SoCoException):
pass
else:
self.speaker_activity("timeout poll")
return
if not self.available:
return return
_LOGGER.debug( _LOGGER.debug(
"%s [%s] was not available, setting up", "No recent activity and cannot reach %s, marking unavailable",
self.zone_name, 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()
self.async_write_entity_states()
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
_LOGGER.debug(
"No activity and could not locate %s on the network. Marking unavailable",
zcname,
) )
await self.async_offline()
async def async_offline(self) -> None:
"""Handle removal of speaker when unavailable."""
self.available = False
self._share_link_plugin = None self._share_link_plugin = None
if self._seen_timer:
self._seen_timer()
self._seen_timer = None
if self._poll_timer: if self._poll_timer:
self._poll_timer() self._poll_timer()
self._poll_timer = None self._poll_timer = None
@ -575,11 +555,9 @@ class SonosSpeaker:
self.zone_name, self.zone_name,
soco, soco,
) )
await self.async_unsubscribe() await self.async_offline()
self.soco = soco self.soco = soco
await self.async_subscribe() self.speaker_activity("reboot")
self._async_reset_seen_timer()
self.async_write_entity_states()
# #
# Battery management # Battery management

View File

@ -20,6 +20,8 @@ from .const import (
SONOS_CREATE_SWITCHES, SONOS_CREATE_SWITCHES,
) )
from .entity import SonosEntity from .entity import SonosEntity
from .exception import SpeakerUnavailable
from .helpers import soco_error
from .speaker import SonosSpeaker from .speaker import SonosSpeaker
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -144,8 +146,12 @@ class SonosSwitchEntity(SonosEntity, SwitchEntity):
if not self.should_poll: if not self.should_poll:
await self.hass.async_add_executor_job(self.update) await self.hass.async_add_executor_job(self.update)
@soco_error(raise_on_err=False)
def update(self) -> None: def update(self) -> None:
"""Fetch switch state if necessary.""" """Fetch switch state if necessary."""
if not self.available:
raise SpeakerUnavailable
state = getattr(self.soco, self.feature_type) state = getattr(self.soco, self.feature_type)
setattr(self.speaker, self.feature_type, state) setattr(self.speaker, self.feature_type, state)
@ -164,6 +170,7 @@ class SonosSwitchEntity(SonosEntity, SwitchEntity):
"""Turn the entity off.""" """Turn the entity off."""
self.send_command(False) self.send_command(False)
@soco_error()
def send_command(self, enable: bool) -> None: def send_command(self, enable: bool) -> None:
"""Enable or disable the feature on the device.""" """Enable or disable the feature on the device."""
if self.needs_coordinator: if self.needs_coordinator: