1
mirror of https://github.com/home-assistant/core synced 2024-08-28 03:36:46 +02:00
ha-core/homeassistant/components/sonos/speaker.py

1143 lines
42 KiB
Python

"""Base class for common speaker tasks."""
from __future__ import annotations
import asyncio
from collections.abc import Callable, Collection, Coroutine
import contextlib
import datetime
from functools import partial
import logging
import time
from typing import Any, cast
import defusedxml.ElementTree as ET
from soco.core import SoCo
from soco.events_base import Event as SonosEvent, SubscriptionBase
from soco.exceptions import SoCoException, SoCoUPnPException
from soco.plugins.plex import PlexPlugin
from soco.plugins.sharelink import ShareLinkPlugin
from soco.snapshot import Snapshot
from sonos_websocket import SonosWebsocket
from homeassistant.components.media_player import DOMAIN as MP_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 er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
dispatcher_send,
)
from homeassistant.helpers.event import async_track_time_interval, track_time_interval
from homeassistant.util import dt as dt_util
from .alarms import SonosAlarms
from .const import (
AVAILABILITY_TIMEOUT,
BATTERY_SCAN_INTERVAL,
DATA_SONOS,
DOMAIN,
SCAN_INTERVAL,
SONOS_CHECK_ACTIVITY,
SONOS_CREATE_ALARM,
SONOS_CREATE_AUDIO_FORMAT_SENSOR,
SONOS_CREATE_BATTERY,
SONOS_CREATE_LEVELS,
SONOS_CREATE_MEDIA_PLAYER,
SONOS_CREATE_MIC_SENSOR,
SONOS_CREATE_SWITCHES,
SONOS_FALLBACK_POLL,
SONOS_REBOOTED,
SONOS_SPEAKER_ACTIVITY,
SONOS_SPEAKER_ADDED,
SONOS_STATE_PLAYING,
SONOS_STATE_TRANSITIONING,
SONOS_STATE_UPDATED,
SONOS_VANISHED,
SUBSCRIPTION_TIMEOUT,
)
from .exception import S1BatteryMissing, SonosSubscriptionsFailed, SonosUpdateError
from .favorites import SonosFavorites
from .helpers import soco_error
from .media import SonosMedia
from .statistics import ActivityStatistics, EventStatistics
NEVER_TIME = -1200.0
RESUB_COOLDOWN_SECONDS = 10.0
EVENT_CHARGING = {
"CHARGING": True,
"NOT_CHARGING": False,
}
SUBSCRIPTION_SERVICES = {
"alarmClock",
"avTransport",
"contentDirectory",
"deviceProperties",
"renderingControl",
"zoneGroupTopology",
}
SUPPORTED_VANISH_REASONS = ("powered off", "sleeping", "switch to bluetooth", "upgrade")
UNUSED_DEVICE_KEYS = ["SPID", "TargetRoomName"]
_LOGGER = logging.getLogger(__name__)
class SonosSpeaker:
"""Representation of a Sonos speaker."""
def __init__(
self,
hass: HomeAssistant,
soco: SoCo,
speaker_info: dict[str, Any],
zone_group_state_sub: SubscriptionBase | None,
) -> None:
"""Initialize a SonosSpeaker."""
self.hass = hass
self.soco = soco
self.websocket: SonosWebsocket | None = None
self.household_id: str = soco.household_id
self.media = SonosMedia(hass, soco)
self._plex_plugin: PlexPlugin | None = None
self._share_link_plugin: ShareLinkPlugin | None = None
self.available: bool = True
# Device information
self.hardware_version: str = speaker_info["hardware_version"]
self.software_version: str = speaker_info["software_version"]
self.mac_address: str = speaker_info["mac_address"]
self.model_name: str = speaker_info["model_name"]
self.model_number: str = speaker_info["model_number"]
self.uid: str = speaker_info["uid"]
self.version: str = speaker_info["display_version"]
self.zone_name: str = speaker_info["zone_name"]
# Subscriptions and events
self.subscriptions_failed: bool = False
self._subscriptions: list[SubscriptionBase] = []
if zone_group_state_sub:
zone_group_state_sub.callback = self.async_dispatch_event
self._subscriptions.append(zone_group_state_sub)
self._subscription_lock: asyncio.Lock | None = None
self._event_dispatchers: dict[str, Callable] = {}
self._last_activity: float = NEVER_TIME
self._last_event_cache: dict[str, Any] = {}
self.activity_stats: ActivityStatistics = ActivityStatistics(self.zone_name)
self.event_stats: EventStatistics = EventStatistics(self.zone_name)
self._resub_cooldown_expires_at: float | None = None
# Scheduled callback handles
self._poll_timer: Callable | None = None
# Dispatcher handles
self.dispatchers: list[Callable] = []
# Battery
self.battery_info: dict[str, Any] = {}
self._last_battery_event: datetime.datetime | None = None
self._battery_poll_timer: Callable | None = None
# Volume / Sound
self.volume: int | None = None
self.muted: bool | None = None
self.cross_fade: bool | None = None
self.balance: tuple[int, int] | None = None
self.bass: int | None = None
self.treble: int | None = None
self.loudness: bool | None = None
# Home theater
self.audio_delay: int | None = None
self.dialog_level: bool | None = None
self.night_mode: bool | None = None
self.sub_enabled: bool | None = None
self.sub_crossover: int | None = None
self.sub_gain: int | None = None
self.surround_enabled: bool | None = None
self.surround_mode: bool | None = None
self.surround_level: int | None = None
self.music_surround_level: int | None = None
# Misc features
self.buttons_enabled: bool | None = None
self.mic_enabled: bool | None = None
self.status_light: bool | None = None
# Grouping
self.coordinator: SonosSpeaker | None = None
self.sonos_group: list[SonosSpeaker] = [self]
self.sonos_group_entities: list[str] = []
self.soco_snapshot: Snapshot | None = None
self.snapshot_group: list[SonosSpeaker] = []
self._group_members_missing: set[str] = set()
async def async_setup(self, entry: ConfigEntry) -> None:
"""Complete setup in async context."""
self.websocket = SonosWebsocket(
self.soco.ip_address,
player_id=self.soco.uid,
session=async_get_clientsession(self.hass),
)
dispatch_pairs: tuple[tuple[str, Callable[..., Any]], ...] = (
(SONOS_CHECK_ACTIVITY, self.async_check_activity),
(SONOS_SPEAKER_ADDED, self.update_group_for_uid),
(f"{SONOS_REBOOTED}-{self.soco.uid}", self.async_rebooted),
(f"{SONOS_SPEAKER_ACTIVITY}-{self.soco.uid}", self.speaker_activity),
(f"{SONOS_VANISHED}-{self.soco.uid}", self.async_vanished),
)
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.media.play_mode = self.soco.play_mode
self.update_volume()
self.update_groups()
if self.is_coordinator:
self.media.poll_media()
future = asyncio.run_coroutine_threadsafe(
self.async_setup(entry), self.hass.loop
)
future.result(timeout=10)
dispatcher_send(self.hass, SONOS_CREATE_LEVELS, self)
if audio_format := self.soco.soundbar_audio_input_format:
dispatcher_send(
self.hass, SONOS_CREATE_AUDIO_FORMAT_SENSOR, self, audio_format
)
try:
self.battery_info = self.fetch_battery_info()
except SonosUpdateError:
_LOGGER.debug("No battery available for %s", self.zone_name)
else:
# Battery events can be infrequent, polling is still necessary
self._battery_poll_timer = track_time_interval(
self.hass, self.async_poll_battery, BATTERY_SCAN_INTERVAL
)
dispatcher_send(self.hass, SONOS_CREATE_BATTERY, self)
if (mic_enabled := self.soco.mic_enabled) is not None:
self.mic_enabled = mic_enabled
dispatcher_send(self.hass, SONOS_CREATE_MIC_SENSOR, self)
if new_alarms := [
alarm.alarm_id for alarm in self.alarms if alarm.zone.uid == self.soco.uid
]:
dispatcher_send(self.hass, SONOS_CREATE_ALARM, self, new_alarms)
dispatcher_send(self.hass, SONOS_CREATE_SWITCHES, self)
self._event_dispatchers = {
"AlarmClock": self.async_dispatch_alarms,
"AVTransport": self.async_dispatch_media_update,
"ContentDirectory": self.async_dispatch_favorites,
"DeviceProperties": self.async_dispatch_device_properties,
"RenderingControl": self.async_update_volume,
"ZoneGroupTopology": self.async_update_groups,
}
dispatcher_send(self.hass, SONOS_CREATE_MEDIA_PLAYER, self)
dispatcher_send(self.hass, SONOS_SPEAKER_ADDED, self.soco.uid)
self.hass.create_task(self.async_subscribe())
#
# Entity management
#
def write_entity_states(self) -> None:
"""Write states for associated SonosEntity instances."""
dispatcher_send(self.hass, f"{SONOS_STATE_UPDATED}-{self.soco.uid}")
@callback
def async_write_entity_states(self) -> None:
"""Write states for associated SonosEntity instances."""
async_dispatcher_send(self.hass, f"{SONOS_STATE_UPDATED}-{self.soco.uid}")
#
# Properties
#
@property
def alarms(self) -> SonosAlarms:
"""Return the SonosAlarms instance for this household."""
return self.hass.data[DATA_SONOS].alarms[self.household_id]
@property
def favorites(self) -> SonosFavorites:
"""Return the SonosFavorites instance for this household."""
return self.hass.data[DATA_SONOS].favorites[self.household_id]
@property
def is_coordinator(self) -> bool:
"""Return true if player is a coordinator."""
return self.coordinator is None
@property
def plex_plugin(self) -> PlexPlugin:
"""Cache the PlexPlugin instance for this speaker."""
if not self._plex_plugin:
self._plex_plugin = PlexPlugin(self.soco)
return self._plex_plugin
@property
def share_link(self) -> ShareLinkPlugin:
"""Cache the ShareLinkPlugin instance for this speaker."""
if not self._share_link_plugin:
self._share_link_plugin = ShareLinkPlugin(self.soco)
return self._share_link_plugin
@property
def subscription_address(self) -> str:
"""Return the current subscription callback address."""
assert len(self._subscriptions) > 0
addr, port = self._subscriptions[0].event_listener.address
return ":".join([addr, str(port)])
@property
def missing_subscriptions(self) -> set[str]:
"""Return a list of missing service subscriptions."""
subscribed_services = {sub.service.service_type for sub in self._subscriptions}
return SUBSCRIPTION_SERVICES - subscribed_services
#
# Subscription handling and event dispatchers
#
def log_subscription_result(
self, result: Any, event: str, level: int = logging.DEBUG
) -> None:
"""Log a message if a subscription action (create/renew/stop) results in an exception."""
if not isinstance(result, Exception):
return
if isinstance(result, asyncio.exceptions.TimeoutError):
message = "Request timed out"
exc_info = None
else:
message = str(result)
exc_info = result if not str(result) else None
_LOGGER.log(
level,
"%s failed for %s: %s",
event,
self.zone_name,
message,
exc_info=exc_info,
)
async def async_subscribe(self) -> None:
"""Initiate event subscriptions under an async lock."""
if not self._subscription_lock:
self._subscription_lock = asyncio.Lock()
async with self._subscription_lock:
try:
await self._async_subscribe()
except SonosSubscriptionsFailed:
_LOGGER.warning("Creating subscriptions failed for %s", self.zone_name)
await self._async_offline()
async def _async_subscribe(self) -> None:
"""Create event subscriptions."""
subscriptions = [
self._subscribe(getattr(self.soco, service), self.async_dispatch_event)
for service in self.missing_subscriptions
]
if not subscriptions:
return
_LOGGER.debug("Creating subscriptions for %s", self.zone_name)
results = await asyncio.gather(*subscriptions, return_exceptions=True)
for result in results:
self.log_subscription_result(
result, "Creating subscription", logging.WARNING
)
if any(isinstance(result, Exception) for result in results):
raise SonosSubscriptionsFailed
# Create a polling task in case subscriptions fail
# or callback events do not arrive
if not self._poll_timer:
self._poll_timer = async_track_time_interval(
self.hass,
partial(
async_dispatcher_send,
self.hass,
f"{SONOS_FALLBACK_POLL}-{self.soco.uid}",
),
SCAN_INTERVAL,
)
async def _subscribe(
self, target: SubscriptionBase, sub_callback: Callable
) -> None:
"""Create a Sonos subscription."""
subscription = await target.subscribe(
auto_renew=True, requested_timeout=SUBSCRIPTION_TIMEOUT
)
subscription.callback = sub_callback
subscription.auto_renew_fail = self.async_renew_failed
self._subscriptions.append(subscription)
async def async_unsubscribe(self) -> None:
"""Cancel all subscriptions."""
if not self._subscriptions:
return
_LOGGER.debug("Unsubscribing from events for %s", self.zone_name)
results = await asyncio.gather(
*(subscription.unsubscribe() for subscription in self._subscriptions),
return_exceptions=True,
)
for result in results:
self.log_subscription_result(result, "Unsubscribe")
self._subscriptions = []
@callback
def async_renew_failed(self, exception: Exception) -> None:
"""Handle a failed subscription renewal."""
self.hass.async_create_task(self._async_renew_failed(exception))
async def _async_renew_failed(self, exception: Exception) -> None:
"""Mark the speaker as offline after a subscription renewal failure.
This is to reset the state to allow a future clean subscription attempt.
"""
if not self.available:
return
self.log_subscription_result(exception, "Subscription renewal", logging.WARNING)
await self.async_offline()
@callback
def async_dispatch_event(self, event: SonosEvent) -> None:
"""Handle callback event and route as needed."""
if self._poll_timer:
_LOGGER.debug(
"Received event, cancelling poll timer for %s", self.zone_name
)
self._poll_timer()
self._poll_timer = None
self.speaker_activity(f"{event.service.service_type} subscription")
self.event_stats.receive(event)
# Skip if this update is an unchanged subset of the previous event
if last_event := self._last_event_cache.get(event.service.service_type):
if event.variables.items() <= last_event.items():
self.event_stats.duplicate(event)
return
# Save most recently processed event variables for cache and diagnostics
self._last_event_cache[event.service.service_type] = event.variables
dispatcher = self._event_dispatchers[event.service.service_type]
dispatcher(event)
@callback
def async_dispatch_alarms(self, event: SonosEvent) -> None:
"""Add the soco instance associated with the event to the callback."""
if "alarm_list_version" not in event.variables:
return
self.hass.async_create_task(self.alarms.async_process_event(event, self))
@callback
def async_dispatch_device_properties(self, event: SonosEvent) -> None:
"""Update device properties from an event."""
self.event_stats.process(event)
self.hass.async_create_task(self.async_update_device_properties(event))
async def async_update_device_properties(self, event: SonosEvent) -> None:
"""Update device properties from an event."""
if "mic_enabled" in event.variables:
mic_exists = self.mic_enabled is not None
self.mic_enabled = bool(int(event.variables["mic_enabled"]))
if not mic_exists:
async_dispatcher_send(self.hass, SONOS_CREATE_MIC_SENSOR, self)
if more_info := event.variables.get("more_info"):
await self.async_update_battery_info(more_info)
self.async_write_entity_states()
@callback
def async_dispatch_favorites(self, event: SonosEvent) -> None:
"""Add the soco instance associated with the event to the callback."""
if "favorites_update_id" not in event.variables:
return
if "container_update_i_ds" not in event.variables:
return
self.hass.async_create_task(self.favorites.async_process_event(event, self))
@callback
def async_dispatch_media_update(self, event: SonosEvent) -> None:
"""Update information about currently playing media from an event."""
# The new coordinator can be provided in a media update event but
# before the ZoneGroupState updates. If this happens the playback
# state will be incorrect and should be ignored. Switching to the
# new coordinator will use its media. The regrouping process will
# be completed during the next ZoneGroupState update.
av_transport_uri = event.variables.get("av_transport_uri", "")
current_track_uri = event.variables.get("current_track_uri", "")
if av_transport_uri == current_track_uri and av_transport_uri.startswith(
"x-rincon:"
):
new_coordinator_uid = av_transport_uri.split(":")[-1]
if new_coordinator_speaker := self.hass.data[DATA_SONOS].discovered.get(
new_coordinator_uid
):
_LOGGER.debug(
"Media update coordinator (%s) received for %s",
new_coordinator_speaker.zone_name,
self.zone_name,
)
self.coordinator = new_coordinator_speaker
else:
_LOGGER.debug(
"Media update coordinator (%s) for %s not yet available",
new_coordinator_uid,
self.zone_name,
)
return
if crossfade := event.variables.get("current_crossfade_mode"):
crossfade = bool(int(crossfade))
if self.cross_fade != crossfade:
self.cross_fade = crossfade
self.async_write_entity_states()
# Missing transport_state indicates a transient error
if (new_status := event.variables.get("transport_state")) is None:
return
# Ignore transitions, we should get the target state soon
if new_status == SONOS_STATE_TRANSITIONING:
return
self.event_stats.process(event)
self.hass.async_add_executor_job(
self.media.update_media_from_event, event.variables
)
@callback
def async_update_volume(self, event: SonosEvent) -> None:
"""Update information about currently volume settings."""
self.event_stats.process(event)
variables = event.variables
if "volume" in variables:
volume = variables["volume"]
self.volume = int(volume["Master"])
if "LF" in volume and "RF" in volume:
self.balance = (int(volume["LF"]), int(volume["RF"]))
if "mute" in variables:
self.muted = variables["mute"]["Master"] == "1"
if loudness := variables.get("loudness"):
self.loudness = loudness["Master"] == "1"
for bool_var in (
"dialog_level",
"night_mode",
"sub_enabled",
"surround_enabled",
"surround_mode",
):
if bool_var in variables:
setattr(self, bool_var, variables[bool_var] == "1")
for int_var in (
"audio_delay",
"bass",
"treble",
"sub_crossover",
"sub_gain",
"surround_level",
"music_surround_level",
):
if int_var in variables:
setattr(self, int_var, variables[int_var])
self.async_write_entity_states()
#
# Speaker availability methods
#
@soco_error()
def ping(self) -> None:
"""Test device availability. Failure will raise SonosUpdateError."""
self.soco.renderingControl.GetVolume(
[("InstanceID", 0), ("Channel", "Master")], timeout=1
)
@callback
def speaker_activity(self, source: str) -> None:
"""Track the last activity on this speaker, set availability and resubscribe."""
if self._resub_cooldown_expires_at:
if time.monotonic() < self._resub_cooldown_expires_at:
_LOGGER.debug(
"Activity on %s from %s while in cooldown, ignoring",
self.zone_name,
source,
)
return
self._resub_cooldown_expires_at = None
_LOGGER.debug("Activity on %s from %s", self.zone_name, source)
self._last_activity = time.monotonic()
self.activity_stats.activity(source, self._last_activity)
was_available = self.available
self.available = True
if not was_available:
self.async_write_entity_states()
self.hass.async_create_task(self.async_subscribe())
@callback
def async_check_activity(self, now: datetime.datetime) -> None:
"""Validate availability of the speaker based on recent activity."""
if not self.available:
return
if time.monotonic() - self._last_activity < AVAILABILITY_TIMEOUT:
return
# Ensure the ping is canceled at shutdown
self.hass.async_create_background_task(
self._async_check_activity(), f"sonos {self.uid} {self.zone_name} ping"
)
async def _async_check_activity(self) -> None:
"""Validate availability of the speaker based on recent activity."""
try:
await self.hass.async_add_executor_job(self.ping)
except SonosUpdateError:
_LOGGER.warning(
"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."""
assert self._subscription_lock is not None
async with self._subscription_lock:
await self._async_offline()
async def _async_offline(self) -> None:
"""Handle removal of speaker when unavailable."""
if not self.available:
return
if self._resub_cooldown_expires_at is None and not self.hass.is_stopping:
self._resub_cooldown_expires_at = time.monotonic() + RESUB_COOLDOWN_SECONDS
_LOGGER.debug("Starting resubscription cooldown for %s", self.zone_name)
self.available = False
self.async_write_entity_states()
self._share_link_plugin = None
if self._poll_timer:
self._poll_timer()
self._poll_timer = None
await self.async_unsubscribe()
self.hass.data[DATA_SONOS].discovery_known.discard(self.soco.uid)
async def async_vanished(self, reason: str) -> None:
"""Handle removal of speaker when marked as vanished."""
if not self.available:
return
_LOGGER.debug(
"%s has vanished (%s), marking unavailable", self.zone_name, reason
)
await self.async_offline()
async def async_rebooted(self) -> None:
"""Handle a detected speaker reboot."""
_LOGGER.debug("%s rebooted, reconnecting", self.zone_name)
await self.async_offline()
self.speaker_activity("reboot")
#
# Battery management
#
@soco_error()
def fetch_battery_info(self) -> dict[str, Any]:
"""Fetch battery_info for the speaker."""
battery_info = self.soco.get_battery_info()
if not battery_info:
# S1 firmware returns an empty payload
raise S1BatteryMissing
return battery_info
async def async_update_battery_info(self, more_info: str) -> None:
"""Update battery info using a SonosEvent payload value."""
battery_dict = dict(x.split(":") for x in more_info.split(","))
for unused in UNUSED_DEVICE_KEYS:
battery_dict.pop(unused, None)
if not battery_dict:
return
if "BattChg" not in battery_dict:
_LOGGER.debug(
(
"Unknown device properties update for %s (%s),"
" please report an issue: '%s'"
),
self.zone_name,
self.model_name,
more_info,
)
return
self._last_battery_event = dt_util.utcnow()
is_charging = EVENT_CHARGING[battery_dict["BattChg"]]
if not self._battery_poll_timer:
# Battery info received for an S1 speaker
new_battery = not self.battery_info
self.battery_info.update(
{
"Level": int(battery_dict["BattPct"]),
"PowerSource": "EXTERNAL" if is_charging else "BATTERY",
}
)
if new_battery:
_LOGGER.warning(
"S1 firmware detected on %s, battery info may update infrequently",
self.zone_name,
)
async_dispatcher_send(self.hass, SONOS_CREATE_BATTERY, self)
return
if is_charging == self.charging:
self.battery_info.update({"Level": int(battery_dict["BattPct"])})
elif not is_charging:
# Avoid polling the speaker if possible
self.battery_info["PowerSource"] = "BATTERY"
else:
# Poll to obtain current power source not provided by event
try:
self.battery_info = await self.hass.async_add_executor_job(
self.fetch_battery_info
)
except SonosUpdateError as err:
_LOGGER.debug("Could not request current power source: %s", err)
@property
def power_source(self) -> str | None:
"""Return the name of the current power source.
Observed to be either BATTERY or SONOS_CHARGING_RING or USB_POWER.
May be an empty dict if used with an S1 Move.
"""
return self.battery_info.get("PowerSource")
@property
def charging(self) -> bool | None:
"""Return the charging status of the speaker."""
if self.power_source:
return self.power_source != "BATTERY"
return None
async def async_poll_battery(self, now: datetime.datetime | None = None) -> None:
"""Poll the device for the current battery state."""
if not self.available:
return
if (
self._last_battery_event
and dt_util.utcnow() - self._last_battery_event < BATTERY_SCAN_INTERVAL
):
return
try:
self.battery_info = await self.hass.async_add_executor_job(
self.fetch_battery_info
)
except SonosUpdateError as err:
_LOGGER.debug("Could not poll battery info: %s", err)
else:
self.async_write_entity_states()
#
# Group management
#
def update_groups(self) -> None:
"""Update group topology when polling."""
self.hass.add_job(self.create_update_groups_coro())
def update_group_for_uid(self, uid: str) -> None:
"""Update group topology if uid is missing."""
if uid not in self._group_members_missing:
return
missing_zone = self.hass.data[DATA_SONOS].discovered[uid].zone_name
_LOGGER.debug(
"%s was missing, adding to %s group", missing_zone, self.zone_name
)
self.update_groups()
@callback
def async_update_groups(self, event: SonosEvent) -> None:
"""Handle callback for topology change event."""
if xml := event.variables.get("zone_group_state"):
zgs = ET.fromstring(xml)
for vanished_device in zgs.find("VanishedDevices") or []:
if (
reason := vanished_device.get("Reason")
) not in SUPPORTED_VANISH_REASONS:
_LOGGER.debug(
"Ignoring %s marked %s as vanished with reason: %s",
self.zone_name,
vanished_device.get("ZoneName"),
reason,
)
continue
uid = vanished_device.get("UUID")
async_dispatcher_send(
self.hass,
f"{SONOS_VANISHED}-{uid}",
reason,
)
if "zone_player_uui_ds_in_group" not in event.variables:
return
self.event_stats.process(event)
self.hass.async_create_task(self.create_update_groups_coro(event))
def create_update_groups_coro(self, event: SonosEvent | None = None) -> Coroutine:
"""Handle callback for topology change event."""
def _get_soco_group() -> list[str]:
"""Ask SoCo cache for existing topology."""
coordinator_uid = self.soco.uid
joined_uids = []
with contextlib.suppress(OSError, SoCoException):
if self.soco.group and self.soco.group.coordinator:
coordinator_uid = self.soco.group.coordinator.uid
joined_uids = [
p.uid
for p in self.soco.group.members
if p.uid != coordinator_uid and p.is_visible
]
return [coordinator_uid] + joined_uids
async def _async_extract_group(event: SonosEvent | None) -> list[str]:
"""Extract group layout from a topology event."""
group = event and event.zone_player_uui_ds_in_group
if group:
assert isinstance(group, str)
return group.split(",")
return await self.hass.async_add_executor_job(_get_soco_group)
@callback
def _async_regroup(group: list[str]) -> None:
"""Rebuild internal group layout."""
if (
group == [self.soco.uid]
and self.sonos_group == [self]
and self.sonos_group_entities
):
# Skip updating existing single speakers in polling mode
return
entity_registry = er.async_get(self.hass)
sonos_group = []
sonos_group_entities = []
for uid in group:
speaker = self.hass.data[DATA_SONOS].discovered.get(uid)
if speaker:
self._group_members_missing.discard(uid)
sonos_group.append(speaker)
entity_id = cast(
str, entity_registry.async_get_entity_id(MP_DOMAIN, DOMAIN, uid)
)
sonos_group_entities.append(entity_id)
else:
self._group_members_missing.add(uid)
_LOGGER.debug(
"%s group member unavailable (%s), will try again",
self.zone_name,
uid,
)
return
if self.sonos_group_entities == sonos_group_entities:
# Useful in polling mode for speakers with stereo pairs or surrounds
# as those "invisible" speakers will bypass the single speaker check
return
self.coordinator = None
self.sonos_group = sonos_group
self.sonos_group_entities = sonos_group_entities
self.async_write_entity_states()
for joined_uid in group[1:]:
joined_speaker: SonosSpeaker = self.hass.data[
DATA_SONOS
].discovered.get(joined_uid)
if joined_speaker:
joined_speaker.coordinator = self
joined_speaker.sonos_group = sonos_group
joined_speaker.sonos_group_entities = sonos_group_entities
joined_speaker.async_write_entity_states()
_LOGGER.debug("Regrouped %s: %s", self.zone_name, self.sonos_group_entities)
async def _async_handle_group_event(event: SonosEvent | None) -> None:
"""Get async lock and handle event."""
async with self.hass.data[DATA_SONOS].topology_condition:
group = await _async_extract_group(event)
if self.soco.uid == group[0]:
_async_regroup(group)
self.hass.data[DATA_SONOS].topology_condition.notify_all()
return _async_handle_group_event(event)
@soco_error()
def join(self, speakers: list[SonosSpeaker]) -> list[SonosSpeaker]:
"""Form a group with other players."""
if self.coordinator:
self.unjoin()
group = [self]
else:
group = self.sonos_group.copy()
for speaker in speakers:
if speaker.soco.uid != self.soco.uid:
if speaker not in group:
speaker.soco.join(self.soco)
speaker.coordinator = self
group.append(speaker)
return group
@staticmethod
async def join_multi(
hass: HomeAssistant,
master: SonosSpeaker,
speakers: list[SonosSpeaker],
) -> None:
"""Form a group with other players."""
async with hass.data[DATA_SONOS].topology_condition:
group: list[SonosSpeaker] = await hass.async_add_executor_job(
master.join, speakers
)
await SonosSpeaker.wait_for_groups(hass, [group])
@soco_error()
def unjoin(self) -> None:
"""Unjoin the player from a group."""
if self.sonos_group == [self]:
return
self.soco.unjoin()
self.coordinator = None
@staticmethod
async def unjoin_multi(hass: HomeAssistant, speakers: list[SonosSpeaker]) -> None:
"""Unjoin several players from their group."""
def _unjoin_all(speakers: list[SonosSpeaker]) -> None:
"""Sync helper."""
# Detach all joined speakers first to prevent inheritance of queues
coordinators = [s for s in speakers if s.is_coordinator]
joined_speakers = [s for s in speakers if not s.is_coordinator]
for speaker in joined_speakers + coordinators:
speaker.unjoin()
async with hass.data[DATA_SONOS].topology_condition:
await hass.async_add_executor_job(_unjoin_all, speakers)
await SonosSpeaker.wait_for_groups(hass, [[s] for s in speakers])
@soco_error()
def snapshot(self, with_group: bool) -> None:
"""Snapshot the state of a player."""
self.soco_snapshot = Snapshot(self.soco)
self.soco_snapshot.snapshot()
if with_group:
self.snapshot_group = self.sonos_group.copy()
else:
self.snapshot_group = []
@staticmethod
async def snapshot_multi(
hass: HomeAssistant, speakers: list[SonosSpeaker], with_group: bool
) -> None:
"""Snapshot all the speakers and optionally their groups."""
def _snapshot_all(speakers: Collection[SonosSpeaker]) -> None:
"""Sync helper."""
for speaker in speakers:
speaker.snapshot(with_group)
# Find all affected players
speakers_set = set(speakers)
if with_group:
for speaker in list(speakers_set):
speakers_set.update(speaker.sonos_group)
async with hass.data[DATA_SONOS].topology_condition:
await hass.async_add_executor_job(_snapshot_all, speakers_set)
@soco_error()
def restore(self) -> None:
"""Restore a snapshotted state to a player."""
try:
assert self.soco_snapshot is not None
self.soco_snapshot.restore()
except (TypeError, AssertionError, AttributeError, SoCoException) as ex:
# Can happen if restoring a coordinator onto a current group member
_LOGGER.warning("Error on restore %s: %s", self.zone_name, ex)
self.soco_snapshot = None
self.snapshot_group = []
@staticmethod
async def restore_multi(
hass: HomeAssistant, speakers: list[SonosSpeaker], with_group: bool
) -> None:
"""Restore snapshots for all the speakers."""
def _restore_groups(
speakers: set[SonosSpeaker], with_group: bool
) -> list[list[SonosSpeaker]]:
"""Pause all current coordinators and restore groups."""
for speaker in (s for s in speakers if s.is_coordinator):
if (
speaker.media.playback_status == SONOS_STATE_PLAYING
and "Pause" in speaker.soco.available_actions
):
try:
speaker.soco.pause()
except SoCoUPnPException as exc:
_LOGGER.debug(
"Pause failed during restore of %s: %s",
speaker.zone_name,
speaker.soco.available_actions,
exc_info=exc,
)
groups: list[list[SonosSpeaker]] = []
if not with_group:
return groups
# Unjoin non-coordinator speakers not contained in the desired snapshot group
#
# If a coordinator is unjoined from its group, another speaker from the group
# will inherit the coordinator's playqueue and its own playqueue will be lost
speakers_to_unjoin = set()
for speaker in speakers:
if speaker.sonos_group == speaker.snapshot_group:
continue
speakers_to_unjoin.update(
{
s
for s in speaker.sonos_group[1:]
if s not in speaker.snapshot_group
}
)
for speaker in speakers_to_unjoin:
speaker.unjoin()
# Bring back the original group topology
for speaker in (s for s in speakers if s.snapshot_group):
assert len(speaker.snapshot_group)
if speaker.snapshot_group[0] == speaker:
if speaker.snapshot_group not in (speaker.sonos_group, [speaker]):
speaker.join(speaker.snapshot_group)
groups.append(speaker.snapshot_group.copy())
return groups
def _restore_players(speakers: Collection[SonosSpeaker]) -> None:
"""Restore state of all players."""
for speaker in (s for s in speakers if not s.is_coordinator):
speaker.restore()
for speaker in (s for s in speakers if s.is_coordinator):
speaker.restore()
# Find all affected players
speakers_set = {s for s in speakers if s.soco_snapshot}
if missing_snapshots := set(speakers) - speakers_set:
raise HomeAssistantError(
"Restore failed, speakers are missing snapshots:"
f" {[s.zone_name for s in missing_snapshots]}"
)
if with_group:
for speaker in [s for s in speakers_set if s.snapshot_group]:
assert len(speaker.snapshot_group)
speakers_set.update(speaker.snapshot_group)
async with hass.data[DATA_SONOS].topology_condition:
groups = await hass.async_add_executor_job(
_restore_groups, speakers_set, with_group
)
await SonosSpeaker.wait_for_groups(hass, groups)
await hass.async_add_executor_job(_restore_players, speakers_set)
@staticmethod
async def wait_for_groups(
hass: HomeAssistant, groups: list[list[SonosSpeaker]]
) -> None:
"""Wait until all groups are present, or timeout."""
def _test_groups(groups: list[list[SonosSpeaker]]) -> bool:
"""Return whether all groups exist now."""
for group in groups:
coordinator = group[0]
# Test that coordinator is coordinating
current_group = coordinator.sonos_group
if coordinator != current_group[0]:
return False
# Test that joined members match
if set(group[1:]) != set(current_group[1:]):
return False
return True
try:
async with asyncio.timeout(5):
while not _test_groups(groups):
await hass.data[DATA_SONOS].topology_condition.wait()
except asyncio.TimeoutError:
_LOGGER.warning("Timeout waiting for target groups %s", groups)
any_speaker = next(iter(hass.data[DATA_SONOS].discovered.values()))
any_speaker.soco.zone_group_state.clear_cache()
#
# Media and playback state handlers
#
@soco_error()
def update_volume(self) -> None:
"""Update information about current volume settings."""
self.volume = self.soco.volume
self.muted = self.soco.mute