Skip to content
Snippets Groups Projects
Unverified Commit aa5cf175 authored by jjlawren's avatar jjlawren Committed by GitHub
Browse files

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

parent 263101b2
No related branches found
No related tags found
No related merge requests found
......@@ -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,
)
)
......@@ -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."
......@@ -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,
......
"""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."""
"""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
......
......@@ -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
......
......@@ -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:
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment