mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 11:17:21 +00:00
Set Sonos availability based on activity and discovery (#59994)
This commit is contained in:
parent
263101b2ab
commit
aa5cf175f4
@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@ -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."
|
|
||||||
|
@ -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,
|
||||||
|
@ -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."""
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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:
|
||||||
|
Loading…
x
Reference in New Issue
Block a user