Home Assistant Unofficial Reference 2024.12.1
speaker.py
Go to the documentation of this file.
1 """Base class for common speaker tasks."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Callable, Collection, Coroutine
7 import contextlib
8 import datetime
9 from functools import partial
10 import logging
11 import time
12 from typing import TYPE_CHECKING, Any, cast
13 
14 import defusedxml.ElementTree as ET
15 from soco.core import SoCo
16 from soco.events_base import Event as SonosEvent, SubscriptionBase
17 from soco.exceptions import SoCoException, SoCoUPnPException
18 from soco.plugins.plex import PlexPlugin
19 from soco.plugins.sharelink import ShareLinkPlugin
20 from soco.snapshot import Snapshot
21 from sonos_websocket import SonosWebsocket
22 
23 from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
24 from homeassistant.config_entries import ConfigEntry
25 from homeassistant.core import HomeAssistant, callback
26 from homeassistant.exceptions import HomeAssistantError
27 from homeassistant.helpers import entity_registry as er
28 from homeassistant.helpers.aiohttp_client import async_get_clientsession
30  async_dispatcher_connect,
31  async_dispatcher_send,
32  dispatcher_send,
33 )
34 from homeassistant.helpers.event import async_track_time_interval
35 from homeassistant.util import dt as dt_util
36 
37 from .alarms import SonosAlarms
38 from .const import (
39  AVAILABILITY_TIMEOUT,
40  BATTERY_SCAN_INTERVAL,
41  DATA_SONOS,
42  DOMAIN,
43  SCAN_INTERVAL,
44  SONOS_CHECK_ACTIVITY,
45  SONOS_CREATE_ALARM,
46  SONOS_CREATE_AUDIO_FORMAT_SENSOR,
47  SONOS_CREATE_BATTERY,
48  SONOS_CREATE_LEVELS,
49  SONOS_CREATE_MEDIA_PLAYER,
50  SONOS_CREATE_MIC_SENSOR,
51  SONOS_CREATE_SWITCHES,
52  SONOS_FALLBACK_POLL,
53  SONOS_REBOOTED,
54  SONOS_SPEAKER_ACTIVITY,
55  SONOS_SPEAKER_ADDED,
56  SONOS_STATE_PLAYING,
57  SONOS_STATE_TRANSITIONING,
58  SONOS_STATE_UPDATED,
59  SONOS_VANISHED,
60  SUBSCRIPTION_TIMEOUT,
61 )
62 from .exception import S1BatteryMissing, SonosSubscriptionsFailed, SonosUpdateError
63 from .favorites import SonosFavorites
64 from .helpers import soco_error
65 from .media import SonosMedia
66 from .statistics import ActivityStatistics, EventStatistics
67 
68 if TYPE_CHECKING:
69  from . import SonosData
70 
71 NEVER_TIME = -1200.0
72 RESUB_COOLDOWN_SECONDS = 10.0
73 EVENT_CHARGING = {
74  "CHARGING": True,
75  "NOT_CHARGING": False,
76 }
77 SUBSCRIPTION_SERVICES = {
78  "alarmClock",
79  "avTransport",
80  "contentDirectory",
81  "deviceProperties",
82  "renderingControl",
83  "zoneGroupTopology",
84 }
85 SUPPORTED_VANISH_REASONS = ("powered off", "sleeping", "switch to bluetooth", "upgrade")
86 UNUSED_DEVICE_KEYS = ["SPID", "TargetRoomName"]
87 
88 
89 _LOGGER = logging.getLogger(__name__)
90 
91 
93  """Representation of a Sonos speaker."""
94 
95  def __init__(
96  self,
97  hass: HomeAssistant,
98  soco: SoCo,
99  speaker_info: dict[str, Any],
100  zone_group_state_sub: SubscriptionBase | None,
101  ) -> None:
102  """Initialize a SonosSpeaker."""
103  self.hasshass = hass
104  self.data: SonosData = hass.data[DATA_SONOS]
105  self.socosoco = soco
106  self.websocketwebsocket: SonosWebsocket | None = None
107  self.household_id: str = soco.household_id
108  self.mediamedia = SonosMedia(hass, soco)
109  self._plex_plugin_plex_plugin: PlexPlugin | None = None
110  self._share_link_plugin_share_link_plugin: ShareLinkPlugin | None = None
111  self.availableavailable: bool = True
112 
113  # Device information
114  self.hardware_version: str = speaker_info["hardware_version"]
115  self.software_version: str = speaker_info["software_version"]
116  self.mac_address: str = speaker_info["mac_address"]
117  self.model_name: str = speaker_info["model_name"]
118  self.model_number: str = speaker_info["model_number"]
119  self.uid: str = speaker_info["uid"]
120  self.version: str = speaker_info["display_version"]
121  self.zone_name: str = speaker_info["zone_name"]
122 
123  # Subscriptions and events
124  self.subscriptions_failed: bool = False
125  self._subscriptions_subscriptions: list[SubscriptionBase] = []
126  if zone_group_state_sub:
127  zone_group_state_sub.callback = self.async_dispatch_eventasync_dispatch_event
128  self._subscriptions_subscriptions.append(zone_group_state_sub)
129  self._subscription_lock_subscription_lock: asyncio.Lock | None = None
130  self._last_activity_last_activity: float = NEVER_TIME
131  self._last_event_cache: dict[str, Any] = {}
132  self.activity_stats: ActivityStatistics = ActivityStatistics(self.zone_name)
133  self.event_stats: EventStatistics = EventStatistics(self.zone_name)
134  self._resub_cooldown_expires_at_resub_cooldown_expires_at: float | None = None
135 
136  # Scheduled callback handles
137  self._poll_timer_poll_timer: Callable | None = None
138 
139  # Dispatcher handles
140  self.dispatchers: list[Callable] = []
141 
142  # Battery
143  self.battery_infobattery_info: dict[str, Any] = {}
144  self._last_battery_event_last_battery_event: datetime.datetime | None = None
145  self._battery_poll_timer_battery_poll_timer: Callable | None = None
146 
147  # Volume / Sound
148  self.volumevolume: int | None = None
149  self.mutedmuted: bool | None = None
150  self.cross_fadecross_fade: bool | None = None
151  self.balancebalance: tuple[int, int] | None = None
152  self.bass: int | None = None
153  self.treble: int | None = None
154  self.loudnessloudness: bool | None = None
155 
156  # Home theater
157  self.audio_delay: int | None = None
158  self.dialog_level: bool | None = None
159  self.night_mode: bool | None = None
160  self.sub_enabled: bool | None = None
161  self.sub_crossover: int | None = None
162  self.sub_gain: int | None = None
163  self.surround_enabled: bool | None = None
164  self.surround_mode: bool | None = None
165  self.surround_level: int | None = None
166  self.music_surround_level: int | None = None
167 
168  # Misc features
169  self.buttons_enabled: bool | None = None
170  self.mic_enabledmic_enabled: bool | None = None
171  self.status_light: bool | None = None
172 
173  # Grouping
174  self.coordinatorcoordinator: SonosSpeaker | None = None
175  self.sonos_groupsonos_group: list[SonosSpeaker] = [self]
176  self.sonos_group_entitiessonos_group_entities: list[str] = []
177  self.soco_snapshotsoco_snapshot: Snapshot | None = None
178  self.snapshot_groupsnapshot_group: list[SonosSpeaker] = []
179  self._group_members_missing: set[str] = set()
180 
181  async def async_setup(
182  self, entry: ConfigEntry, has_battery: bool, dispatches: list[tuple[Any, ...]]
183  ) -> None:
184  """Complete setup in async context."""
185  # Battery events can be infrequent, polling is still necessary
186  if has_battery:
188  self.hasshass, self.async_poll_batteryasync_poll_battery, BATTERY_SCAN_INTERVAL
189  )
190 
191  self.websocketwebsocket = SonosWebsocket(
192  self.socosoco.ip_address,
193  player_id=self.socosoco.uid,
194  session=async_get_clientsession(self.hasshass),
195  )
196 
197  dispatch_pairs: tuple[tuple[str, Callable[..., Any]], ...] = (
198  (SONOS_CHECK_ACTIVITY, self.async_check_activityasync_check_activity),
199  (SONOS_SPEAKER_ADDED, self.async_update_group_for_uidasync_update_group_for_uid),
200  (f"{SONOS_REBOOTED}-{self.soco.uid}", self.async_rebootedasync_rebooted),
201  (f"{SONOS_SPEAKER_ACTIVITY}-{self.soco.uid}", self.speaker_activityspeaker_activity),
202  (f"{SONOS_VANISHED}-{self.soco.uid}", self.async_vanishedasync_vanished),
203  )
204 
205  for signal, target in dispatch_pairs:
206  entry.async_on_unload(
208  self.hasshass,
209  signal,
210  target,
211  )
212  )
213 
214  for dispatch in dispatches:
215  async_dispatcher_send(self.hasshass, *dispatch)
216 
217  await self.async_subscribeasync_subscribe()
218 
219  def setup(self, entry: ConfigEntry) -> None:
220  """Run initial setup of the speaker."""
221  self.mediamedia.play_mode = self.socosoco.play_mode
222  self.update_volumeupdate_volume()
223  self.update_groupsupdate_groups()
224  if self.is_coordinatoris_coordinator:
225  self.mediamedia.poll_media()
226 
227  dispatches: list[tuple[Any, ...]] = [(SONOS_CREATE_LEVELS, self)]
228 
229  if audio_format := self.socosoco.soundbar_audio_input_format:
230  dispatches.append((SONOS_CREATE_AUDIO_FORMAT_SENSOR, self, audio_format))
231 
232  has_battery = False
233  try:
234  self.battery_infobattery_info = self.fetch_battery_infofetch_battery_info()
235  except SonosUpdateError:
236  _LOGGER.debug("No battery available for %s", self.zone_name)
237  else:
238  has_battery = True
239  dispatcher_send(self.hasshass, SONOS_CREATE_BATTERY, self)
240 
241  if (mic_enabled := self.socosoco.mic_enabled) is not None:
242  self.mic_enabledmic_enabled = mic_enabled
243  dispatches.append((SONOS_CREATE_MIC_SENSOR, self))
244 
245  if new_alarms := [
246  alarm.alarm_id for alarm in self.alarmsalarms if alarm.zone.uid == self.socosoco.uid
247  ]:
248  dispatches.append((SONOS_CREATE_ALARM, self, new_alarms))
249 
250  dispatches.append((SONOS_CREATE_SWITCHES, self))
251  dispatches.append((SONOS_CREATE_MEDIA_PLAYER, self))
252  dispatches.append((SONOS_SPEAKER_ADDED, self.socosoco.uid))
253 
254  self.hasshass.create_task(self.async_setupasync_setup(entry, has_battery, dispatches))
255 
256  #
257  # Entity management
258  #
259  def write_entity_states(self) -> None:
260  """Write states for associated SonosEntity instances."""
261  dispatcher_send(self.hasshass, f"{SONOS_STATE_UPDATED}-{self.soco.uid}")
262 
263  @callback
264  def async_write_entity_states(self) -> None:
265  """Write states for associated SonosEntity instances."""
266  async_dispatcher_send(self.hasshass, f"{SONOS_STATE_UPDATED}-{self.soco.uid}")
267 
268  #
269  # Properties
270  #
271  @property
272  def alarms(self) -> SonosAlarms:
273  """Return the SonosAlarms instance for this household."""
274  return self.data.alarms[self.household_id]
275 
276  @property
277  def favorites(self) -> SonosFavorites:
278  """Return the SonosFavorites instance for this household."""
279  return self.data.favorites[self.household_id]
280 
281  @property
282  def is_coordinator(self) -> bool:
283  """Return true if player is a coordinator."""
284  return self.coordinatorcoordinator is None
285 
286  @property
287  def plex_plugin(self) -> PlexPlugin:
288  """Cache the PlexPlugin instance for this speaker."""
289  if not self._plex_plugin_plex_plugin:
290  self._plex_plugin_plex_plugin = PlexPlugin(self.socosoco)
291  return self._plex_plugin_plex_plugin
292 
293  @property
294  def share_link(self) -> ShareLinkPlugin:
295  """Cache the ShareLinkPlugin instance for this speaker."""
296  if not self._share_link_plugin_share_link_plugin:
297  self._share_link_plugin_share_link_plugin = ShareLinkPlugin(self.socosoco)
298  return self._share_link_plugin_share_link_plugin
299 
300  @property
301  def subscription_address(self) -> str:
302  """Return the current subscription callback address."""
303  assert len(self._subscriptions_subscriptions) > 0
304  addr, port = self._subscriptions_subscriptions[0].event_listener.address
305  return ":".join([addr, str(port)])
306 
307  @property
308  def missing_subscriptions(self) -> set[str]:
309  """Return a list of missing service subscriptions."""
310  subscribed_services = {sub.service.service_type for sub in self._subscriptions_subscriptions}
311  return SUBSCRIPTION_SERVICES - subscribed_services
312 
313  #
314  # Subscription handling and event dispatchers
315  #
317  self, result: Any, event: str, level: int = logging.DEBUG
318  ) -> None:
319  """Log a message if a subscription action (create/renew/stop) results in an exception."""
320  if not isinstance(result, Exception):
321  return
322 
323  if isinstance(result, asyncio.exceptions.TimeoutError):
324  message = "Request timed out"
325  exc_info = None
326  else:
327  message = str(result)
328  exc_info = result if not str(result) else None
329 
330  _LOGGER.log(
331  level,
332  "%s failed for %s: %s",
333  event,
334  self.zone_name,
335  message,
336  exc_info=exc_info,
337  )
338 
339  async def async_subscribe(self) -> None:
340  """Initiate event subscriptions under an async lock."""
341  if not self._subscription_lock_subscription_lock:
342  self._subscription_lock_subscription_lock = asyncio.Lock()
343 
344  async with self._subscription_lock_subscription_lock:
345  try:
346  await self._async_subscribe_async_subscribe()
347  except SonosSubscriptionsFailed:
348  _LOGGER.warning("Creating subscriptions failed for %s", self.zone_name)
349  await self._async_offline_async_offline()
350 
351  async def _async_subscribe(self) -> None:
352  """Create event subscriptions."""
353  subscriptions = [
354  self._subscribe_subscribe(getattr(self.socosoco, service), self.async_dispatch_eventasync_dispatch_event)
355  for service in self.missing_subscriptionsmissing_subscriptions
356  ]
357  if not subscriptions:
358  return
359 
360  _LOGGER.debug("Creating subscriptions for %s", self.zone_name)
361  results = await asyncio.gather(*subscriptions, return_exceptions=True)
362  for result in results:
363  self.log_subscription_resultlog_subscription_result(
364  result, "Creating subscription", logging.WARNING
365  )
366 
367  if any(isinstance(result, Exception) for result in results):
368  raise SonosSubscriptionsFailed
369 
370  # Create a polling task in case subscriptions fail
371  # or callback events do not arrive
372  if not self._poll_timer_poll_timer:
374  self.hasshass,
375  partial(
376  async_dispatcher_send,
377  self.hasshass,
378  f"{SONOS_FALLBACK_POLL}-{self.soco.uid}",
379  ),
380  SCAN_INTERVAL,
381  )
382 
383  async def _subscribe(
384  self, target: SubscriptionBase, sub_callback: Callable
385  ) -> None:
386  """Create a Sonos subscription."""
387  subscription = await target.subscribe(
388  auto_renew=True, requested_timeout=SUBSCRIPTION_TIMEOUT
389  )
390  subscription.callback = sub_callback
391  subscription.auto_renew_fail = self.async_renew_failedasync_renew_failed
392  self._subscriptions_subscriptions.append(subscription)
393 
394  async def async_unsubscribe(self) -> None:
395  """Cancel all subscriptions."""
396  if not self._subscriptions_subscriptions:
397  return
398  _LOGGER.debug("Unsubscribing from events for %s", self.zone_name)
399  results = await asyncio.gather(
400  *(subscription.unsubscribe() for subscription in self._subscriptions_subscriptions),
401  return_exceptions=True,
402  )
403  for result in results:
404  self.log_subscription_resultlog_subscription_result(result, "Unsubscribe")
405  self._subscriptions_subscriptions = []
406 
407  @callback
408  def async_renew_failed(self, exception: Exception) -> None:
409  """Handle a failed subscription renewal."""
410  self.hasshass.async_create_background_task(
411  self._async_renew_failed_async_renew_failed(exception), "sonos renew failed", eager_start=True
412  )
413 
414  async def _async_renew_failed(self, exception: Exception) -> None:
415  """Mark the speaker as offline after a subscription renewal failure.
416 
417  This is to reset the state to allow a future clean subscription attempt.
418  """
419  if not self.availableavailable:
420  return
421 
422  self.log_subscription_resultlog_subscription_result(exception, "Subscription renewal", logging.WARNING)
423  await self.async_offlineasync_offline()
424 
425  @callback
426  def async_dispatch_event(self, event: SonosEvent) -> None:
427  """Handle callback event and route as needed."""
428  if self._poll_timer_poll_timer:
429  _LOGGER.debug(
430  "Received event, cancelling poll timer for %s", self.zone_name
431  )
432  self._poll_timer_poll_timer()
433  self._poll_timer_poll_timer = None
434 
435  self.speaker_activityspeaker_activity(f"{event.service.service_type} subscription")
436  self.event_stats.receive(event)
437 
438  # Skip if this update is an unchanged subset of the previous event
439  if last_event := self._last_event_cache.get(event.service.service_type):
440  if event.variables.items() <= last_event.items():
441  self.event_stats.duplicate(event)
442  return
443 
444  # Save most recently processed event variables for cache and diagnostics
445  self._last_event_cache[event.service.service_type] = event.variables
446  dispatcher = self._event_dispatchers_event_dispatchers[event.service.service_type]
447  dispatcher(self, event)
448 
449  @callback
450  def async_dispatch_alarms(self, event: SonosEvent) -> None:
451  """Add the soco instance associated with the event to the callback."""
452  if "alarm_list_version" not in event.variables:
453  return
454  self.hasshass.async_create_background_task(
455  self.alarmsalarms.async_process_event(event, self),
456  "sonos process event",
457  eager_start=True,
458  )
459 
460  @callback
461  def async_dispatch_device_properties(self, event: SonosEvent) -> None:
462  """Update device properties from an event."""
463  self.event_stats.process(event)
464  self.hasshass.async_create_background_task(
465  self.async_update_device_propertiesasync_update_device_properties(event),
466  "sonos device properties",
467  eager_start=True,
468  )
469 
470  async def async_update_device_properties(self, event: SonosEvent) -> None:
471  """Update device properties from an event."""
472  if "mic_enabled" in event.variables:
473  mic_exists = self.mic_enabledmic_enabled is not None
474  self.mic_enabledmic_enabled = bool(int(event.variables["mic_enabled"]))
475  if not mic_exists:
476  async_dispatcher_send(self.hasshass, SONOS_CREATE_MIC_SENSOR, self)
477 
478  if more_info := event.variables.get("more_info"):
479  await self.async_update_battery_infoasync_update_battery_info(more_info)
480 
481  self.async_write_entity_statesasync_write_entity_states()
482 
483  @callback
484  def async_dispatch_favorites(self, event: SonosEvent) -> None:
485  """Add the soco instance associated with the event to the callback."""
486  if "favorites_update_id" not in event.variables:
487  return
488  if "container_update_i_ds" not in event.variables:
489  return
490  self.hasshass.async_create_background_task(
491  self.favoritesfavorites.async_process_event(event, self),
492  "sonos dispatch favorites",
493  eager_start=True,
494  )
495 
496  @callback
497  def async_dispatch_media_update(self, event: SonosEvent) -> None:
498  """Update information about currently playing media from an event."""
499  # The new coordinator can be provided in a media update event but
500  # before the ZoneGroupState updates. If this happens the playback
501  # state will be incorrect and should be ignored. Switching to the
502  # new coordinator will use its media. The regrouping process will
503  # be completed during the next ZoneGroupState update.
504  av_transport_uri = event.variables.get("av_transport_uri", "")
505  current_track_uri = event.variables.get("current_track_uri", "")
506  if av_transport_uri == current_track_uri and av_transport_uri.startswith(
507  "x-rincon:"
508  ):
509  new_coordinator_uid = av_transport_uri.split(":")[-1]
510  if new_coordinator_speaker := self.data.discovered.get(new_coordinator_uid):
511  _LOGGER.debug(
512  "Media update coordinator (%s) received for %s",
513  new_coordinator_speaker.zone_name,
514  self.zone_name,
515  )
516  self.coordinatorcoordinator = new_coordinator_speaker
517  else:
518  _LOGGER.debug(
519  "Media update coordinator (%s) for %s not yet available",
520  new_coordinator_uid,
521  self.zone_name,
522  )
523  return
524 
525  if crossfade := event.variables.get("current_crossfade_mode"):
526  crossfade = bool(int(crossfade))
527  if self.cross_fadecross_fade != crossfade:
528  self.cross_fadecross_fade = crossfade
529  self.async_write_entity_statesasync_write_entity_states()
530 
531  # Missing transport_state indicates a transient error
532  if (new_status := event.variables.get("transport_state")) is None:
533  return
534 
535  # Ignore transitions, we should get the target state soon
536  if new_status == SONOS_STATE_TRANSITIONING:
537  return
538 
539  self.event_stats.process(event)
540  self.hasshass.async_add_executor_job(
541  self.mediamedia.update_media_from_event, event.variables
542  )
543 
544  @callback
545  def async_update_volume(self, event: SonosEvent) -> None:
546  """Update information about currently volume settings."""
547  self.event_stats.process(event)
548  variables = event.variables
549 
550  if "volume" in variables:
551  volume = variables["volume"]
552  self.volumevolume = int(volume["Master"])
553  if "LF" in volume and "RF" in volume:
554  self.balancebalance = (int(volume["LF"]), int(volume["RF"]))
555 
556  if "mute" in variables:
557  self.mutedmuted = variables["mute"]["Master"] == "1"
558 
559  if loudness := variables.get("loudness"):
560  self.loudnessloudness = loudness["Master"] == "1"
561 
562  for bool_var in (
563  "dialog_level",
564  "night_mode",
565  "sub_enabled",
566  "surround_enabled",
567  "surround_mode",
568  ):
569  if bool_var in variables:
570  setattr(self, bool_var, variables[bool_var] == "1")
571 
572  for int_var in (
573  "audio_delay",
574  "bass",
575  "treble",
576  "sub_crossover",
577  "sub_gain",
578  "surround_level",
579  "music_surround_level",
580  ):
581  if int_var in variables:
582  setattr(self, int_var, variables[int_var])
583 
584  self.async_write_entity_statesasync_write_entity_states()
585 
586  #
587  # Speaker availability methods
588  #
589  @soco_error()
590  def ping(self) -> None:
591  """Test device availability. Failure will raise SonosUpdateError."""
592  self.socosoco.renderingControl.GetVolume(
593  [("InstanceID", 0), ("Channel", "Master")], timeout=1
594  )
595 
596  @callback
597  def speaker_activity(self, source: str) -> None:
598  """Track the last activity on this speaker, set availability and resubscribe."""
599  if self._resub_cooldown_expires_at_resub_cooldown_expires_at:
600  if time.monotonic() < self._resub_cooldown_expires_at_resub_cooldown_expires_at:
601  _LOGGER.debug(
602  "Activity on %s from %s while in cooldown, ignoring",
603  self.zone_name,
604  source,
605  )
606  return
607  self._resub_cooldown_expires_at_resub_cooldown_expires_at = None
608 
609  _LOGGER.debug("Activity on %s from %s", self.zone_name, source)
610  self._last_activity_last_activity = time.monotonic()
611  self.activity_stats.activity(source, self._last_activity_last_activity)
612  was_available = self.availableavailable
613  self.availableavailable = True
614  if not was_available:
615  self.async_write_entity_statesasync_write_entity_states()
616  self.hasshass.async_create_task(self.async_subscribeasync_subscribe(), eager_start=True)
617 
618  @callback
619  def async_check_activity(self, now: datetime.datetime) -> None:
620  """Validate availability of the speaker based on recent activity."""
621  if not self.availableavailable:
622  return
623  if time.monotonic() - self._last_activity_last_activity < AVAILABILITY_TIMEOUT:
624  return
625  # Ensure the ping is canceled at shutdown
626  self.hasshass.async_create_background_task(
627  self._async_check_activity_async_check_activity(),
628  f"sonos {self.uid} {self.zone_name} ping",
629  eager_start=True,
630  )
631 
632  async def _async_check_activity(self) -> None:
633  """Validate availability of the speaker based on recent activity."""
634  try:
635  await self.hasshass.async_add_executor_job(self.pingping)
636  except SonosUpdateError:
637  _LOGGER.warning(
638  "No recent activity and cannot reach %s, marking unavailable",
639  self.zone_name,
640  )
641  await self.async_offlineasync_offline()
642 
643  async def async_offline(self) -> None:
644  """Handle removal of speaker when unavailable."""
645  assert self._subscription_lock_subscription_lock is not None
646  async with self._subscription_lock_subscription_lock:
647  await self._async_offline_async_offline()
648 
649  async def _async_offline(self) -> None:
650  """Handle removal of speaker when unavailable."""
651  if not self.availableavailable:
652  return
653 
654  if self._resub_cooldown_expires_at_resub_cooldown_expires_at is None and not self.hasshass.is_stopping:
655  self._resub_cooldown_expires_at_resub_cooldown_expires_at = time.monotonic() + RESUB_COOLDOWN_SECONDS
656  _LOGGER.debug("Starting resubscription cooldown for %s", self.zone_name)
657 
658  self.availableavailable = False
659  self.async_write_entity_statesasync_write_entity_states()
660 
661  self._share_link_plugin_share_link_plugin = None
662 
663  if self._poll_timer_poll_timer:
664  self._poll_timer_poll_timer()
665  self._poll_timer_poll_timer = None
666 
667  await self.async_unsubscribeasync_unsubscribe()
668 
669  self.data.discovery_known.discard(self.socosoco.uid)
670 
671  async def async_vanished(self, reason: str) -> None:
672  """Handle removal of speaker when marked as vanished."""
673  if not self.availableavailable:
674  return
675  _LOGGER.debug(
676  "%s has vanished (%s), marking unavailable", self.zone_name, reason
677  )
678  await self.async_offlineasync_offline()
679 
680  async def async_rebooted(self) -> None:
681  """Handle a detected speaker reboot."""
682  _LOGGER.debug("%s rebooted, reconnecting", self.zone_name)
683  await self.async_offlineasync_offline()
684  self.speaker_activityspeaker_activity("reboot")
685 
686  #
687  # Battery management
688  #
689  @soco_error()
690  def fetch_battery_info(self) -> dict[str, Any]:
691  """Fetch battery_info for the speaker."""
692  battery_info = self.socosoco.get_battery_info()
693  if not battery_info:
694  # S1 firmware returns an empty payload
695  raise S1BatteryMissing
696  return battery_info
697 
698  async def async_update_battery_info(self, more_info: str) -> None:
699  """Update battery info using a SonosEvent payload value."""
700  battery_dict = dict(x.split(":") for x in more_info.split(","))
701  for unused in UNUSED_DEVICE_KEYS:
702  battery_dict.pop(unused, None)
703  if not battery_dict:
704  return
705  if "BattChg" not in battery_dict:
706  _LOGGER.debug(
707  (
708  "Unknown device properties update for %s (%s),"
709  " please report an issue: '%s'"
710  ),
711  self.zone_name,
712  self.model_name,
713  more_info,
714  )
715  return
716 
717  self._last_battery_event_last_battery_event = dt_util.utcnow()
718 
719  is_charging = EVENT_CHARGING[battery_dict["BattChg"]]
720 
721  if not self._battery_poll_timer_battery_poll_timer:
722  # Battery info received for an S1 speaker
723  new_battery = not self.battery_infobattery_info
724  self.battery_infobattery_info.update(
725  {
726  "Level": int(battery_dict["BattPct"]),
727  "PowerSource": "EXTERNAL" if is_charging else "BATTERY",
728  }
729  )
730  if new_battery:
731  _LOGGER.warning(
732  "S1 firmware detected on %s, battery info may update infrequently",
733  self.zone_name,
734  )
735  async_dispatcher_send(self.hasshass, SONOS_CREATE_BATTERY, self)
736  return
737 
738  if is_charging == self.chargingcharging:
739  self.battery_infobattery_info.update({"Level": int(battery_dict["BattPct"])})
740  elif not is_charging:
741  # Avoid polling the speaker if possible
742  self.battery_infobattery_info["PowerSource"] = "BATTERY"
743  else:
744  # Poll to obtain current power source not provided by event
745  try:
746  self.battery_infobattery_info = await self.hasshass.async_add_executor_job(
747  self.fetch_battery_infofetch_battery_info
748  )
749  except SonosUpdateError as err:
750  _LOGGER.debug("Could not request current power source: %s", err)
751 
752  @property
753  def power_source(self) -> str | None:
754  """Return the name of the current power source.
755 
756  Observed to be either BATTERY or SONOS_CHARGING_RING or USB_POWER.
757 
758  May be an empty dict if used with an S1 Move.
759  """
760  return self.battery_infobattery_info.get("PowerSource")
761 
762  @property
763  def charging(self) -> bool | None:
764  """Return the charging status of the speaker."""
765  if self.power_sourcepower_source:
766  return self.power_sourcepower_source != "BATTERY"
767  return None
768 
769  async def async_poll_battery(self, now: datetime.datetime | None = None) -> None:
770  """Poll the device for the current battery state."""
771  if not self.availableavailable:
772  return
773 
774  if (
775  self._last_battery_event_last_battery_event
776  and dt_util.utcnow() - self._last_battery_event_last_battery_event < BATTERY_SCAN_INTERVAL
777  ):
778  return
779 
780  try:
781  self.battery_infobattery_info = await self.hasshass.async_add_executor_job(
782  self.fetch_battery_infofetch_battery_info
783  )
784  except SonosUpdateError as err:
785  _LOGGER.debug("Could not poll battery info: %s", err)
786  else:
787  self.async_write_entity_statesasync_write_entity_states()
788 
789  #
790  # Group management
791  #
792  def update_groups(self) -> None:
793  """Update group topology when polling."""
794  self.hasshass.add_job(self.create_update_groups_corocreate_update_groups_coro())
795 
796  @callback
797  def async_update_group_for_uid(self, uid: str) -> None:
798  """Update group topology if uid is missing."""
799  if uid not in self._group_members_missing:
800  return
801  missing_zone = self.data.discovered[uid].zone_name
802  _LOGGER.debug(
803  "%s was missing, adding to %s group", missing_zone, self.zone_name
804  )
805  self.hasshass.async_create_task(self.create_update_groups_corocreate_update_groups_coro(), eager_start=True)
806 
807  @callback
808  def async_update_groups(self, event: SonosEvent) -> None:
809  """Handle callback for topology change event."""
810  if xml := event.variables.get("zone_group_state"):
811  zgs = ET.fromstring(xml)
812  for vanished_device in zgs.find("VanishedDevices") or []:
813  if (
814  reason := vanished_device.get("Reason")
815  ) not in SUPPORTED_VANISH_REASONS:
816  _LOGGER.debug(
817  "Ignoring %s marked %s as vanished with reason: %s",
818  self.zone_name,
819  vanished_device.get("ZoneName"),
820  reason,
821  )
822  continue
823  uid = vanished_device.get("UUID")
825  self.hasshass,
826  f"{SONOS_VANISHED}-{uid}",
827  reason,
828  )
829  self.event_stats.process(event)
830  self.hasshass.async_create_background_task(
831  self.create_update_groups_corocreate_update_groups_coro(event),
832  name=f"sonos group update {self.zone_name}",
833  eager_start=True,
834  )
835 
836  def create_update_groups_coro(self, event: SonosEvent | None = None) -> Coroutine:
837  """Handle callback for topology change event."""
838 
839  def _get_soco_group() -> list[str]:
840  """Ask SoCo cache for existing topology."""
841  coordinator_uid = self.socosoco.uid
842  joined_uids = []
843 
844  with contextlib.suppress(OSError, SoCoException):
845  if self.socosoco.group and self.socosoco.group.coordinator:
846  coordinator_uid = self.socosoco.group.coordinator.uid
847  joined_uids = [
848  p.uid
849  for p in self.socosoco.group.members
850  if p.uid != coordinator_uid and p.is_visible
851  ]
852 
853  return [coordinator_uid, *joined_uids]
854 
855  async def _async_extract_group(event: SonosEvent | None) -> list[str]:
856  """Extract group layout from a topology event."""
857  if group := (event and getattr(event, "zone_player_uui_ds_in_group", None)):
858  assert isinstance(group, str)
859  return group.split(",")
860 
861  return await self.hasshass.async_add_executor_job(_get_soco_group)
862 
863  @callback
864  def _async_regroup(group: list[str]) -> None:
865  """Rebuild internal group layout."""
866  _LOGGER.debug("async_regroup %s %s", self.zone_name, group)
867  if (
868  group == [self.socosoco.uid]
869  and self.sonos_groupsonos_group == [self]
870  and self.sonos_group_entitiessonos_group_entities
871  ):
872  # Single speakers do not have a coodinator, check and clear
873  if self.coordinatorcoordinator is not None:
874  _LOGGER.debug(
875  "Zone %s Cleared coordinator [%s]",
876  self.zone_name,
877  self.coordinatorcoordinator.zone_name,
878  )
879  self.coordinatorcoordinator = None
880  self.async_write_entity_statesasync_write_entity_states()
881  # Skip updating existing single speakers in polling mode
882  return
883 
884  entity_registry = er.async_get(self.hasshass)
885  sonos_group = []
886  sonos_group_entities = []
887 
888  for uid in group:
889  speaker = self.data.discovered.get(uid)
890  if speaker:
891  self._group_members_missing.discard(uid)
892  sonos_group.append(speaker)
893  entity_id = cast(
894  str, entity_registry.async_get_entity_id(MP_DOMAIN, DOMAIN, uid)
895  )
896  sonos_group_entities.append(entity_id)
897  else:
898  self._group_members_missing.add(uid)
899  _LOGGER.debug(
900  "%s group member unavailable (%s), will try again",
901  self.zone_name,
902  uid,
903  )
904  return
905 
906  if self.sonos_group_entitiessonos_group_entities == sonos_group_entities:
907  # Useful in polling mode for speakers with stereo pairs or surrounds
908  # as those "invisible" speakers will bypass the single speaker check
909  return
910 
911  self.coordinatorcoordinator = None
912  self.sonos_groupsonos_group = sonos_group
913  self.sonos_group_entitiessonos_group_entities = sonos_group_entities
914  self.async_write_entity_statesasync_write_entity_states()
915 
916  for joined_uid in group[1:]:
917  if joined_speaker := self.data.discovered.get(joined_uid):
918  joined_speaker.coordinator = self
919  joined_speaker.sonos_group = sonos_group
920  joined_speaker.sonos_group_entities = sonos_group_entities
921  _LOGGER.debug(
922  "Zone %s Set coordinator [%s]",
923  joined_speaker.zone_name,
924  self.zone_name,
925  )
926  joined_speaker.async_write_entity_states()
927 
928  _LOGGER.debug("Regrouped %s: %s", self.zone_name, self.sonos_group_entitiessonos_group_entities)
929 
930  async def _async_handle_group_event(event: SonosEvent | None) -> None:
931  """Get async lock and handle event."""
932 
933  async with self.data.topology_condition:
934  group = await _async_extract_group(event)
935 
936  if self.socosoco.uid == group[0]:
937  _async_regroup(group)
938 
939  self.data.topology_condition.notify_all()
940 
941  return _async_handle_group_event(event)
942 
943  @soco_error()
944  def join(self, speakers: list[SonosSpeaker]) -> list[SonosSpeaker]:
945  """Form a group with other players."""
946  if self.coordinatorcoordinator:
947  self.unjoinunjoin()
948  group = [self]
949  else:
950  group = self.sonos_groupsonos_group.copy()
951 
952  for speaker in speakers:
953  if speaker.soco.uid != self.socosoco.uid:
954  if speaker not in group:
955  speaker.soco.join(self.socosoco)
956  speaker.coordinator = self
957  group.append(speaker)
958 
959  return group
960 
961  @staticmethod
962  async def join_multi(
963  hass: HomeAssistant,
964  master: SonosSpeaker,
965  speakers: list[SonosSpeaker],
966  ) -> None:
967  """Form a group with other players."""
968  async with hass.data[DATA_SONOS].topology_condition:
969  group: list[SonosSpeaker] = await hass.async_add_executor_job(
970  master.join, speakers
971  )
972  await SonosSpeaker.wait_for_groups(hass, [group])
973 
974  @soco_error()
975  def unjoin(self) -> None:
976  """Unjoin the player from a group."""
977  if self.sonos_groupsonos_group == [self]:
978  return
979  self.socosoco.unjoin()
980  self.coordinatorcoordinator = None
981 
982  @staticmethod
983  async def unjoin_multi(hass: HomeAssistant, speakers: list[SonosSpeaker]) -> None:
984  """Unjoin several players from their group."""
985 
986  def _unjoin_all(speakers: list[SonosSpeaker]) -> None:
987  """Sync helper."""
988  # Detach all joined speakers first to prevent inheritance of queues
989  coordinators = [s for s in speakers if s.is_coordinator]
990  joined_speakers = [s for s in speakers if not s.is_coordinator]
991 
992  for speaker in joined_speakers + coordinators:
993  speaker.unjoin()
994 
995  async with hass.data[DATA_SONOS].topology_condition:
996  await hass.async_add_executor_job(_unjoin_all, speakers)
997  await SonosSpeaker.wait_for_groups(hass, [[s] for s in speakers])
998 
999  @soco_error()
1000  def snapshot(self, with_group: bool) -> None:
1001  """Snapshot the state of a player."""
1002  self.soco_snapshotsoco_snapshot = Snapshot(self.socosoco)
1003  self.soco_snapshotsoco_snapshot.snapshot()
1004  if with_group:
1005  self.snapshot_groupsnapshot_group = self.sonos_groupsonos_group.copy()
1006  else:
1007  self.snapshot_groupsnapshot_group = []
1008 
1009  @staticmethod
1010  async def snapshot_multi(
1011  hass: HomeAssistant, speakers: list[SonosSpeaker], with_group: bool
1012  ) -> None:
1013  """Snapshot all the speakers and optionally their groups."""
1014 
1015  def _snapshot_all(speakers: Collection[SonosSpeaker]) -> None:
1016  """Sync helper."""
1017  for speaker in speakers:
1018  speaker.snapshot(with_group)
1019 
1020  # Find all affected players
1021  speakers_set = set(speakers)
1022  if with_group:
1023  for speaker in list(speakers_set):
1024  speakers_set.update(speaker.sonos_group)
1025 
1026  async with hass.data[DATA_SONOS].topology_condition:
1027  await hass.async_add_executor_job(_snapshot_all, speakers_set)
1028 
1029  @soco_error()
1030  def restore(self) -> None:
1031  """Restore a snapshotted state to a player."""
1032  try:
1033  assert self.soco_snapshotsoco_snapshot is not None
1034  self.soco_snapshotsoco_snapshot.restore()
1035  except (TypeError, AssertionError, AttributeError, SoCoException) as ex:
1036  # Can happen if restoring a coordinator onto a current group member
1037  _LOGGER.warning("Error on restore %s: %s", self.zone_name, ex)
1038 
1039  self.soco_snapshotsoco_snapshot = None
1040  self.snapshot_groupsnapshot_group = []
1041 
1042  @staticmethod
1043  async def restore_multi(
1044  hass: HomeAssistant, speakers: list[SonosSpeaker], with_group: bool
1045  ) -> None:
1046  """Restore snapshots for all the speakers."""
1047 
1048  def _restore_groups(
1049  speakers: set[SonosSpeaker], with_group: bool
1050  ) -> list[list[SonosSpeaker]]:
1051  """Pause all current coordinators and restore groups."""
1052  for speaker in (s for s in speakers if s.is_coordinator):
1053  if (
1054  speaker.media.playback_status == SONOS_STATE_PLAYING
1055  and "Pause" in speaker.soco.available_actions
1056  ):
1057  try:
1058  speaker.soco.pause()
1059  except SoCoUPnPException as exc:
1060  _LOGGER.debug(
1061  "Pause failed during restore of %s: %s",
1062  speaker.zone_name,
1063  speaker.soco.available_actions,
1064  exc_info=exc,
1065  )
1066 
1067  groups: list[list[SonosSpeaker]] = []
1068  if not with_group:
1069  return groups
1070 
1071  # Unjoin non-coordinator speakers not contained in the desired snapshot group
1072  #
1073  # If a coordinator is unjoined from its group, another speaker from the group
1074  # will inherit the coordinator's playqueue and its own playqueue will be lost
1075  speakers_to_unjoin = set()
1076  for speaker in speakers:
1077  if speaker.sonos_group == speaker.snapshot_group:
1078  continue
1079 
1080  speakers_to_unjoin.update(
1081  {
1082  s
1083  for s in speaker.sonos_group[1:]
1084  if s not in speaker.snapshot_group
1085  }
1086  )
1087 
1088  for speaker in speakers_to_unjoin:
1089  speaker.unjoin()
1090 
1091  # Bring back the original group topology
1092  for speaker in (s for s in speakers if s.snapshot_group):
1093  assert len(speaker.snapshot_group)
1094  if speaker.snapshot_group[0] == speaker:
1095  if speaker.snapshot_group not in (speaker.sonos_group, [speaker]):
1096  speaker.join(speaker.snapshot_group)
1097  groups.append(speaker.snapshot_group.copy())
1098 
1099  return groups
1100 
1101  def _restore_players(speakers: Collection[SonosSpeaker]) -> None:
1102  """Restore state of all players."""
1103  for speaker in (s for s in speakers if not s.is_coordinator):
1104  speaker.restore()
1105 
1106  for speaker in (s for s in speakers if s.is_coordinator):
1107  speaker.restore()
1108 
1109  # Find all affected players
1110  speakers_set = {s for s in speakers if s.soco_snapshot}
1111  if missing_snapshots := set(speakers) - speakers_set:
1112  raise HomeAssistantError(
1113  "Restore failed, speakers are missing snapshots:"
1114  f" {[s.zone_name for s in missing_snapshots]}"
1115  )
1116 
1117  if with_group:
1118  for speaker in [s for s in speakers_set if s.snapshot_group]:
1119  assert len(speaker.snapshot_group)
1120  speakers_set.update(speaker.snapshot_group)
1121 
1122  async with hass.data[DATA_SONOS].topology_condition:
1123  groups = await hass.async_add_executor_job(
1124  _restore_groups, speakers_set, with_group
1125  )
1126  await SonosSpeaker.wait_for_groups(hass, groups)
1127  await hass.async_add_executor_job(_restore_players, speakers_set)
1128 
1129  @staticmethod
1130  async def wait_for_groups(
1131  hass: HomeAssistant, groups: list[list[SonosSpeaker]]
1132  ) -> None:
1133  """Wait until all groups are present, or timeout."""
1134 
1135  def _test_groups(groups: list[list[SonosSpeaker]]) -> bool:
1136  """Return whether all groups exist now."""
1137  for group in groups:
1138  coordinator = group[0]
1139 
1140  # Test that coordinator is coordinating
1141  current_group = coordinator.sonos_group
1142  if coordinator != current_group[0]:
1143  return False
1144 
1145  # Test that joined members match
1146  if set(group[1:]) != set(current_group[1:]):
1147  return False
1148 
1149  return True
1150 
1151  try:
1152  async with asyncio.timeout(5):
1153  while not _test_groups(groups):
1154  await hass.data[DATA_SONOS].topology_condition.wait()
1155  except TimeoutError:
1156  _LOGGER.warning("Timeout waiting for target groups %s", groups)
1157 
1158  any_speaker = next(iter(hass.data[DATA_SONOS].discovered.values()))
1159  any_speaker.soco.zone_group_state.clear_cache()
1160 
1161  #
1162  # Media and playback state handlers
1163  #
1164  @soco_error()
1165  def update_volume(self) -> None:
1166  """Update information about current volume settings."""
1167  self.volumevolume = self.socosoco.volume
1168  self.mutedmuted = self.socosoco.mute
1169 
1170  _event_dispatchers = {
1171  "AlarmClock": async_dispatch_alarms,
1172  "AVTransport": async_dispatch_media_update,
1173  "ContentDirectory": async_dispatch_favorites,
1174  "DeviceProperties": async_dispatch_device_properties,
1175  "RenderingControl": async_update_volume,
1176  "ZoneGroupTopology": async_update_groups,
1177  }
None log_subscription_result(self, Any result, str event, int level=logging.DEBUG)
Definition: speaker.py:318
None async_renew_failed(self, Exception exception)
Definition: speaker.py:408
None async_poll_battery(self, datetime.datetime|None now=None)
Definition: speaker.py:769
list[SonosSpeaker] join(self, list[SonosSpeaker] speakers)
Definition: speaker.py:944
None async_dispatch_device_properties(self, SonosEvent event)
Definition: speaker.py:461
None _async_renew_failed(self, Exception exception)
Definition: speaker.py:414
None restore_multi(HomeAssistant hass, list[SonosSpeaker] speakers, bool with_group)
Definition: speaker.py:1045
None __init__(self, HomeAssistant hass, SoCo soco, dict[str, Any] speaker_info, SubscriptionBase|None zone_group_state_sub)
Definition: speaker.py:101
None async_update_groups(self, SonosEvent event)
Definition: speaker.py:808
None async_check_activity(self, datetime.datetime now)
Definition: speaker.py:619
None async_dispatch_alarms(self, SonosEvent event)
Definition: speaker.py:450
None async_update_device_properties(self, SonosEvent event)
Definition: speaker.py:470
None snapshot_multi(HomeAssistant hass, list[SonosSpeaker] speakers, bool with_group)
Definition: speaker.py:1012
None wait_for_groups(HomeAssistant hass, list[list[SonosSpeaker]] groups)
Definition: speaker.py:1132
None unjoin_multi(HomeAssistant hass, list[SonosSpeaker] speakers)
Definition: speaker.py:983
None _subscribe(self, SubscriptionBase target, Callable sub_callback)
Definition: speaker.py:385
Coroutine create_update_groups_coro(self, SonosEvent|None event=None)
Definition: speaker.py:836
None async_dispatch_media_update(self, SonosEvent event)
Definition: speaker.py:497
None async_dispatch_favorites(self, SonosEvent event)
Definition: speaker.py:484
None join_multi(HomeAssistant hass, SonosSpeaker master, list[SonosSpeaker] speakers)
Definition: speaker.py:966
None async_update_battery_info(self, str more_info)
Definition: speaker.py:698
None async_setup(self, ConfigEntry entry, bool has_battery, list[tuple[Any,...]] dispatches)
Definition: speaker.py:183
None async_dispatch_event(self, SonosEvent event)
Definition: speaker.py:426
None async_update_volume(self, SonosEvent event)
Definition: speaker.py:545
bool add(self, _T matcher)
Definition: match.py:185
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
IssData update(pyiss.ISS iss)
Definition: __init__.py:33
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103
None dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:137
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193
CALLBACK_TYPE async_track_time_interval(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, timedelta interval, *str|None name=None, bool|None cancel_on_shutdown=None)
Definition: event.py:1679