Set Sonos availability based on activity and discovery (#59994)
This commit is contained in:
parent
263101b2ab
commit
aa5cf175f4
7 changed files with 139 additions and 112 deletions
|
@ -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,
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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
|
||||
|
||||
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
|
||||
|
||||
|
|
|
@ -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()
|
||||
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
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s [%s] was not available, setting up",
|
||||
"No recent activity and cannot reach %s, marking unavailable",
|
||||
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
|
||||
|
||||
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:
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue