/
/
/
1"""
2Sonos Player provider for Music Assistant: SonosPlayer object/model.
3
4Note that large parts of this code are copied over from the Home Assistant
5integration for Sonos.
6"""
7
8from __future__ import annotations
9
10import asyncio
11import contextlib
12import logging
13import time
14from collections.abc import Callable, Coroutine
15from typing import TYPE_CHECKING, Any, cast
16
17from music_assistant_models.enums import PlaybackState, PlayerState, PlayerType
18from music_assistant_models.errors import PlayerCommandFailed
19from soco import SoCoException
20from soco.core import MUSIC_SRC_RADIO, SoCo
21from soco.data_structures import DidlAudioBroadcast
22
23from music_assistant.constants import VERBOSE_LOG_LEVEL, create_sample_rates_config_entry
24from music_assistant.helpers.upnp import create_didl_metadata
25from music_assistant.models.player import DeviceInfo, Player, PlayerMedia
26
27from .constants import (
28 DURATION_SECONDS,
29 LINEIN_SOURCE_IDS,
30 LINEIN_SOURCES,
31 NEVER_TIME,
32 PLAYER_FEATURES,
33 PLAYER_SOURCE_MAP,
34 POSITION_SECONDS,
35 RESUB_COOLDOWN_SECONDS,
36 SONOS_STATE_TRANSITIONING,
37 SOURCE_MAPPING,
38 SUBSCRIPTION_SERVICES,
39 SUBSCRIPTION_TIMEOUT,
40)
41from .helpers import SonosUpdateError, soco_error
42
43if TYPE_CHECKING:
44 from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
45 from soco.events_base import Event as SonosEvent
46 from soco.events_base import SubscriptionBase
47
48 from .provider import SonosPlayerProvider
49
50CALLBACK_TYPE = Callable[[], None]
51LOGGER = logging.getLogger(__name__)
52
53
54class SonosSubscriptionsFailed(PlayerCommandFailed):
55 """Subscription creation failed."""
56
57
58class SonosPlayer(Player):
59 """Sonos Player implementation for S1 speakers."""
60
61 def __init__(
62 self,
63 provider: SonosPlayerProvider,
64 soco: SoCo,
65 ) -> None:
66 """Initialize SonosPlayer instance."""
67 super().__init__(provider, soco.uid)
68 self.soco = soco
69 self.household_id: str = soco.household_id
70 self.subscriptions: list[SubscriptionBase] = []
71
72 # Set player attributes
73 self._attr_type = PlayerType.PLAYER
74 self._attr_supported_features = set(PLAYER_FEATURES)
75 self._attr_name = soco.player_name
76 self._attr_device_info = DeviceInfo(
77 model=soco.speaker_info["model_name"],
78 manufacturer="Sonos",
79 )
80 self._attr_device_info.ip_address = soco.ip_address
81 self._attr_needs_poll = True
82 self._attr_poll_interval = 5
83 self._attr_available = True
84 self._attr_can_group_with = {provider.instance_id}
85
86 # Subscriptions and events
87 self._subscriptions: list[SubscriptionBase] = []
88 self._subscription_lock: asyncio.Lock | None = None
89 self._last_activity: float = NEVER_TIME
90 self._resub_cooldown_expires_at: float | None = None
91
92 @property
93 def missing_subscriptions(self) -> set[str]:
94 """Return a list of missing service subscriptions."""
95 subscribed_services = {sub.service.service_type for sub in self._subscriptions}
96 return SUBSCRIPTION_SERVICES - subscribed_services
97
98 async def setup(self) -> None:
99 """Set up the player."""
100 self._attr_volume_level = self.soco.volume
101 self._attr_volume_muted = self.soco.mute
102 self.update_groups()
103 if not self.synced_to:
104 self.poll_media()
105 await self.subscribe()
106 await self.mass.players.register_or_update(self)
107
108 async def offline(self) -> None:
109 """Handle removal of speaker when unavailable."""
110 if not self._attr_available:
111 return
112
113 if self._resub_cooldown_expires_at is None and not self.mass.closing:
114 self._resub_cooldown_expires_at = time.monotonic() + RESUB_COOLDOWN_SECONDS
115 self.logger.debug("Starting resubscription cooldown for %s", self.display_name)
116
117 self._attr_available = False
118 self._share_link_plugin = None
119
120 self.update_state()
121 await self.unsubscribe()
122
123 async def get_config_entries(
124 self,
125 action: str | None = None,
126 values: dict[str, ConfigValueType] | None = None,
127 ) -> list[ConfigEntry]:
128 """Return all (provider/player specific) Config Entries for the player."""
129 return [
130 create_sample_rates_config_entry(
131 supported_sample_rates=[44100, 48000],
132 supported_bit_depths=[16],
133 hidden=True,
134 ),
135 ]
136
137 async def stop(self) -> None:
138 """Send STOP command to the player."""
139 if self.synced_to:
140 self.logger.debug(
141 "Ignore STOP command for %s: Player is synced to another player.",
142 self.player_id,
143 )
144 return
145 if self._attr_active_source in LINEIN_SOURCE_IDS:
146 # Play an invalid URI to force stop line-in sources
147 with contextlib.suppress(SoCoException):
148 await asyncio.to_thread(self.soco.play_uri, "")
149 else:
150 await asyncio.to_thread(self.soco.stop)
151 self.mass.call_later(2, self.poll)
152 self.update_state()
153
154 async def play(self) -> None:
155 """Send PLAY command to the player."""
156 if self.synced_to:
157 self.logger.debug(
158 "Ignore PLAY command for %s: Player is synced to another player.",
159 self.player_id,
160 )
161 return
162 await asyncio.to_thread(self.soco.play)
163 self.mass.call_later(2, self.poll)
164
165 async def pause(self) -> None:
166 """Send PAUSE command to the player."""
167 if self.synced_to:
168 self.logger.debug(
169 "Ignore PAUSE command for %s: Player is synced to another player.",
170 self.player_id,
171 )
172 return
173 if "Pause" not in self.soco.available_actions:
174 # pause not possible
175 await self.stop()
176 return
177 await asyncio.to_thread(self.soco.pause)
178 self.mass.call_later(2, self.poll)
179
180 async def volume_set(self, volume_level: int) -> None:
181 """Send VOLUME_SET command to the player."""
182
183 def set_volume_level(volume_level: int) -> None:
184 self.soco.volume = volume_level
185
186 await asyncio.to_thread(set_volume_level, volume_level)
187 self.mass.call_later(2, self.poll)
188
189 async def volume_mute(self, muted: bool) -> None:
190 """Send VOLUME MUTE command to the player."""
191
192 def set_volume_mute(muted: bool) -> None:
193 self.soco.mute = muted
194
195 await asyncio.to_thread(set_volume_mute, muted)
196 self.mass.call_later(2, self.poll)
197
198 async def play_media(self, media: PlayerMedia) -> None:
199 """Handle PLAY MEDIA on the player."""
200 if self.synced_to:
201 # this should be already handled by the player manager, but just in case...
202 msg = (
203 f"Player {self.display_name} can not "
204 "accept play_media command, it is synced to another player."
205 )
206 raise PlayerCommandFailed(msg)
207
208 if not media.duration:
209 # Sonos really does not like FLAC streams without duration
210 media.uri = media.uri.replace(".flac", ".mp3")
211
212 didl_metadata = create_didl_metadata(media)
213
214 await asyncio.to_thread(
215 self.soco.play_uri, media.uri, meta=didl_metadata, force_radio=not media.duration
216 )
217 self.mass.call_later(2, self.poll)
218
219 async def enqueue_next_media(self, media: PlayerMedia) -> None:
220 """Handle enqueuing next media item."""
221 if self.synced_to:
222 # this should be already handled by the player manager, but just in case...
223 msg = (
224 f"Player {self.display_name} can not "
225 "accept enqueue command, it is synced to another player."
226 )
227 raise PlayerCommandFailed(msg)
228
229 didl_metadata = create_didl_metadata(media)
230
231 def add_to_queue() -> None:
232 self.soco.avTransport.SetNextAVTransportURI(
233 [
234 ("InstanceID", 0),
235 ("NextURI", media.uri),
236 ("NextURIMetaData", didl_metadata),
237 ]
238 )
239
240 await asyncio.to_thread(add_to_queue)
241 self.mass.call_later(2, self.poll)
242
243 @soco_error()
244 async def set_members(
245 self,
246 player_ids_to_add: list[str] | None = None,
247 player_ids_to_remove: list[str] | None = None,
248 ) -> None:
249 """Handle SET_MEMBERS command on the player."""
250 if self.synced_to:
251 # this should not happen, but guard anyways
252 raise RuntimeError("Player is synced, cannot set members")
253 if not player_ids_to_add and not player_ids_to_remove:
254 return
255 player_ids_to_add = player_ids_to_add or []
256 player_ids_to_remove = player_ids_to_remove or []
257
258 if player_ids_to_remove:
259 for player_id in player_ids_to_remove:
260 if player_to_remove := cast("SonosPlayer", self.mass.players.get(player_id)):
261 await asyncio.to_thread(player_to_remove.soco.unjoin)
262 self.mass.call_later(2, player_to_remove.poll)
263
264 if player_ids_to_add:
265 for player_id in player_ids_to_add:
266 if player_to_add := cast("SonosPlayer", self.mass.players.get(player_id)):
267 await asyncio.to_thread(player_to_add.soco.join, self.soco)
268 self.mass.call_later(2, player_to_add.poll)
269
270 async def poll(self) -> None:
271 """Poll player for state updates."""
272
273 def _poll() -> None:
274 """Poll the speaker for updates (NOT async friendly)."""
275 self.update_groups()
276 self.poll_media()
277 self._attr_volume_level = self.soco.volume
278 self._attr_volume_muted = self.soco.mute
279
280 await self._check_availability()
281 if self._attr_available:
282 await asyncio.to_thread(_poll)
283
284 @soco_error()
285 def poll_media(self) -> None:
286 """Poll information about currently playing media."""
287 transport_info = self.soco.get_current_transport_info()
288 new_status = transport_info["current_transport_state"]
289
290 if new_status == SONOS_STATE_TRANSITIONING:
291 return
292
293 new_status = _convert_state(new_status)
294 update_position = new_status != self._attr_playback_state
295 self._attr_playback_state = new_status
296 self._set_basic_track_info(update_position=update_position)
297 self.update_player()
298
299 def update_ip(self, ip_address: str) -> None:
300 """Handle updated IP of a Sonos player (NOT async friendly)."""
301 if self._attr_available:
302 return
303 self.logger.debug(
304 "Player IP-address changed from %s to %s", self.soco.ip_address, ip_address
305 )
306 try:
307 self.ping()
308 except SonosUpdateError:
309 return
310 self.soco.ip_address = ip_address
311 asyncio.run_coroutine_threadsafe(self.setup(), self.mass.loop)
312 self._attr_device_info = DeviceInfo(
313 model=self._attr_device_info.model,
314 manufacturer=self._attr_device_info.manufacturer,
315 )
316 self._attr_device_info.ip_address = ip_address
317 self.update_player()
318
319 async def _check_availability(self) -> None:
320 """Check if the player is still available."""
321 try:
322 await asyncio.to_thread(self.ping)
323 self._speaker_activity("ping")
324 except SonosUpdateError:
325 if not self._attr_available:
326 return
327 self.logger.warning(
328 "No recent activity and cannot reach %s, marking unavailable",
329 self.display_name,
330 )
331 await self.offline()
332
333 @soco_error()
334 def ping(self) -> None:
335 """Test device availability. Failure will raise SonosUpdateError."""
336 self.soco.renderingControl.GetVolume([("InstanceID", 0), ("Channel", "Master")], timeout=1)
337
338 @soco_error()
339 def _poll_track_info(self) -> dict[str, Any]:
340 """Poll the speaker for current track info.
341
342 Add converted position values (NOT async fiendly).
343 """
344 track_info: dict[str, Any] = self.soco.get_current_track_info()
345 track_info[DURATION_SECONDS] = _timespan_secs(track_info.get("duration"))
346 track_info[POSITION_SECONDS] = _timespan_secs(track_info.get("position"))
347 return track_info
348
349 def update_player(self, signal_update: bool = True) -> None:
350 """Update Sonos Player."""
351 self._update_attributes()
352 if signal_update:
353 # send update to the player manager right away only if we are triggered from an event
354 # when we're just updating from a manual poll, the player manager
355 # will detect changes to the player object itself
356 self.mass.loop.call_soon_threadsafe(self.update_state)
357
358 async def _subscribe_target(
359 self, target: SubscriptionBase, sub_callback: Callable[[SonosEvent], None]
360 ) -> None:
361 """Create a Sonos subscription for given target."""
362
363 def on_renew_failed(exception: Exception) -> None:
364 """Handle a failed subscription renewal callback."""
365 self.mass.create_task(self._renew_failed(exception))
366
367 # Use events_asyncio which makes subscribe() async-awaitable
368 subscription = await target.subscribe(
369 auto_renew=True, requested_timeout=SUBSCRIPTION_TIMEOUT
370 )
371 subscription.callback = sub_callback
372 subscription.auto_renew_fail = on_renew_failed
373 self._subscriptions.append(subscription)
374
375 async def _renew_failed(self, exception: Exception) -> None:
376 """Mark the speaker as offline after a subscription renewal failure.
377
378 This is to reset the state to allow a future clean subscription attempt.
379 """
380 if not self._attr_available:
381 return
382
383 self.log_subscription_result(exception, "Subscription renewal", logging.WARNING)
384 await self.offline()
385
386 def log_subscription_result(self, result: Any, event: str, level: int = logging.DEBUG) -> None:
387 """Log a message if a subscription action (create/renew/stop) results in an exception."""
388 if not isinstance(result, Exception):
389 return
390
391 if isinstance(result, asyncio.exceptions.TimeoutError):
392 message = "Request timed out"
393 exc_info = None
394 else:
395 message = str(result)
396 exc_info = result if not str(result) else None
397
398 self.logger.log(
399 level,
400 "%s failed for %s: %s",
401 event,
402 self.display_name,
403 message,
404 exc_info=exc_info if self.logger.isEnabledFor(10) else None,
405 )
406
407 async def subscribe(self) -> None:
408 """Initiate event subscriptions under an async lock."""
409 if not self._subscription_lock:
410 self._subscription_lock = asyncio.Lock()
411
412 async with self._subscription_lock:
413 try:
414 # Create event subscriptions.
415 subscriptions = [
416 self._subscribe_target(getattr(self.soco, service), self._handle_event)
417 for service in self.missing_subscriptions
418 ]
419 if not subscriptions:
420 return
421 self.logger.log(
422 VERBOSE_LOG_LEVEL, "Creating subscriptions for %s", self.display_name
423 )
424 results = await asyncio.gather(*subscriptions, return_exceptions=True)
425 for result in results:
426 self.log_subscription_result(result, "Creating subscription", logging.WARNING)
427 if any(isinstance(result, Exception) for result in results):
428 raise SonosSubscriptionsFailed
429 except SonosSubscriptionsFailed:
430 self.logger.warning("Creating subscriptions failed for %s", self.display_name)
431 assert self._subscription_lock is not None
432 async with self._subscription_lock:
433 await self.offline()
434
435 async def unsubscribe(self) -> None:
436 """Cancel all subscriptions."""
437 if not self._subscriptions:
438 return
439 self.logger.log(VERBOSE_LOG_LEVEL, "Unsubscribing from events for %s", self.display_name)
440 results = await asyncio.gather(
441 *(subscription.unsubscribe() for subscription in self._subscriptions),
442 return_exceptions=True,
443 )
444 for result in results:
445 self.log_subscription_result(result, "Unsubscribe")
446 self._subscriptions = []
447
448 def _handle_event(self, event: SonosEvent) -> None:
449 """Handle SonosEvent callback."""
450 service_type: str = event.service.service_type
451 self._speaker_activity(f"{service_type} subscription")
452 if service_type == "DeviceProperties":
453 self.update_player()
454 return
455 if service_type == "AVTransport":
456 self._handle_avtransport_event(event)
457 return
458 if service_type == "RenderingControl":
459 self._handle_rendering_control_event(event)
460 return
461 if service_type == "ZoneGroupTopology":
462 self._handle_zone_group_topology_event(event)
463 return
464
465 def _handle_avtransport_event(self, event: SonosEvent) -> None:
466 """Update information about currently playing media from an event."""
467 # NOTE: The new coordinator can be provided in a media update event but
468 # before the ZoneGroupState updates. If this happens the playback
469 # state will be incorrect and should be ignored. Switching to the
470 # new coordinator will use its media. The regrouping process will
471 # be completed during the next ZoneGroupState update.
472
473 # Missing transport_state indicates a transient error
474 if (new_status := event.variables.get("transport_state")) is None:
475 return
476
477 # Ignore transitions, we should get the target state soon
478 if new_status == SONOS_STATE_TRANSITIONING:
479 return
480
481 evars = event.variables
482 new_status = _convert_state(evars["transport_state"])
483 state_changed = new_status != self._attr_playback_state
484
485 self._attr_playback_state = new_status
486
487 track_uri = evars["enqueued_transport_uri"] or evars["current_track_uri"]
488 audio_source = self.soco.music_source_from_uri(track_uri)
489
490 self._set_basic_track_info(update_position=state_changed)
491 ct_md = evars["current_track_meta_data"]
492
493 et_uri_md = evars["enqueued_transport_uri_meta_data"]
494
495 channel = ""
496 if audio_source == MUSIC_SRC_RADIO:
497 if et_uri_md:
498 channel = et_uri_md.title
499
500 # Extra guards for S1 compatibility
501 if ct_md and hasattr(ct_md, "radio_show") and ct_md.radio_show:
502 radio_show = ct_md.radio_show.split(",")[0]
503 channel = " • ".join(filter(None, [channel, radio_show]))
504
505 if isinstance(et_uri_md, DidlAudioBroadcast) and self._attr_current_media:
506 self._attr_current_media.title = self._attr_current_media.title or channel
507
508 self.update_player()
509
510 def _handle_rendering_control_event(self, event: SonosEvent) -> None:
511 """Update information about currently volume settings."""
512 variables = event.variables
513
514 if "volume" in variables:
515 volume = variables["volume"]
516 self._attr_volume_level = int(volume["Master"])
517
518 if mute := variables.get("mute"):
519 self._attr_volume_muted = mute["Master"] == "1"
520
521 self.update_player()
522
523 def _handle_zone_group_topology_event(self, event: SonosEvent) -> None:
524 """Handle callback for topology change event."""
525 if "zone_player_uui_ds_in_group" not in event.variables:
526 return
527 asyncio.run_coroutine_threadsafe(self.create_update_groups_coro(event), self.mass.loop)
528
529 def _update_attributes(self) -> None:
530 """Update attributes of the MA Player from SoCo state."""
531 if not self._attr_available:
532 self._attr_playback_state = PlayerState.IDLE
533 self._attr_group_members.clear()
534 return
535
536 def _set_basic_track_info(self, update_position: bool = False) -> None:
537 """Query the speaker to update media metadata and position info."""
538 try:
539 track_info = self._poll_track_info()
540 except SonosUpdateError as err:
541 self.logger.warning("Fetching track info failed: %s", err)
542 return
543 if not track_info["uri"]:
544 return
545 uri = track_info["uri"]
546
547 audio_source = self.soco.music_source_from_uri(uri)
548 if (source_id := SOURCE_MAPPING.get(audio_source)) and audio_source in LINEIN_SOURCES:
549 self._attr_elapsed_time = None
550 self._attr_elapsed_time_last_updated = None
551 self._attr_active_source = source_id
552 self._attr_current_media = None
553 if source_id not in [x.id for x in self._attr_source_list]:
554 self._attr_source_list.append(PLAYER_SOURCE_MAP[source_id])
555 return
556
557 current_media = PlayerMedia(
558 uri=uri,
559 artist=track_info.get("artist"),
560 album=track_info.get("album"),
561 title=track_info.get("title"),
562 image_url=track_info.get("album_art"),
563 )
564 self._attr_current_media = current_media
565 self._attr_active_source = None
566 self._update_media_position(track_info, force_update=update_position)
567
568 def _update_media_position(
569 self, position_info: dict[str, int], force_update: bool = False
570 ) -> None:
571 """Update state when playing music tracks."""
572 duration = position_info.get(DURATION_SECONDS)
573 current_position = position_info.get(POSITION_SECONDS)
574
575 if not (duration or current_position):
576 self._attr_elapsed_time = None
577 self._attr_elapsed_time_last_updated = None
578 return
579
580 should_update = force_update
581 if self._attr_current_media:
582 self._attr_current_media.duration = duration
583
584 # player started reporting position?
585 if current_position is not None and self._attr_elapsed_time is None:
586 should_update = True
587
588 # position jumped?
589 if current_position is not None and self._attr_elapsed_time is not None:
590 if self._attr_playback_state == PlaybackState.PLAYING:
591 assert self._attr_elapsed_time_last_updated is not None
592 time_diff = time.time() - self._attr_elapsed_time_last_updated
593 else:
594 time_diff = 0
595
596 calculated_position = self._attr_elapsed_time + time_diff
597
598 if abs(calculated_position - current_position) > 1.5:
599 should_update = True
600
601 if current_position is None:
602 self._attr_elapsed_time = None
603 self._attr_elapsed_time_last_updated = None
604 elif should_update:
605 self._attr_elapsed_time = current_position
606 self._attr_elapsed_time_last_updated = time.time()
607
608 def _speaker_activity(self, source: str) -> None:
609 """Track the last activity on this speaker, set availability and resubscribe."""
610 if self._resub_cooldown_expires_at:
611 if time.monotonic() < self._resub_cooldown_expires_at:
612 self.logger.debug(
613 "Activity on %s from %s while in cooldown, ignoring",
614 self.display_name,
615 source,
616 )
617 return
618 self._resub_cooldown_expires_at = None
619
620 self.logger.log(VERBOSE_LOG_LEVEL, "Activity on %s from %s", self.display_name, source)
621 self._last_activity = time.monotonic()
622 was_available = self._attr_available
623 self._attr_available = True
624 if not was_available:
625 self.update_player()
626 self.mass.loop.call_soon_threadsafe(self.mass.create_task, self.subscribe())
627
628 def update_groups(self) -> None:
629 """Update group topology when polling."""
630 asyncio.run_coroutine_threadsafe(self.create_update_groups_coro(), self.mass.loop)
631
632 def create_update_groups_coro(
633 self, event: SonosEvent | None = None
634 ) -> Coroutine[Any, Any, None]:
635 """Handle callback for topology change event."""
636
637 def _get_soco_group() -> list[str]:
638 """Ask SoCo cache for existing topology."""
639 coordinator_uid = self.soco.uid
640 joined_uids = []
641 with contextlib.suppress(OSError, SoCoException):
642 if self.soco.group and self.soco.group.coordinator:
643 coordinator_uid = self.soco.group.coordinator.uid
644 joined_uids = [
645 p.uid
646 for p in self.soco.group.members
647 if p.uid != coordinator_uid and p.is_visible
648 ]
649
650 return [coordinator_uid, *joined_uids]
651
652 async def _extract_group(event: SonosEvent | None) -> list[str]:
653 """Extract group layout from a topology event."""
654 group = event and event.zone_player_uui_ds_in_group
655 if group:
656 assert isinstance(group, str)
657 return group.split(",")
658 return await asyncio.to_thread(_get_soco_group)
659
660 def _regroup(group: list[str]) -> None:
661 """Rebuild internal group layout (async safe)."""
662 if group == [self.soco.uid] and not self._attr_group_members:
663 # Skip updating existing single speakers in polling mode
664 return
665
666 group_members_ids = []
667
668 for uid in group:
669 speaker = self.mass.players.get(uid)
670 if speaker:
671 group_members_ids.append(uid)
672 else:
673 self.logger.debug(
674 "%s group member unavailable (%s), will try again",
675 self.display_name,
676 uid,
677 )
678 return
679
680 if self._attr_group_members == group_members_ids:
681 # Useful in polling mode for speakers with stereo pairs or surrounds
682 # as those "invisible" speakers will bypass the single speaker check
683 return
684
685 self._attr_group_members = group_members_ids
686 self.mass.loop.call_soon_threadsafe(self.update_state)
687
688 self.logger.debug("Regrouped %s: %s", self.display_name, self._attr_group_members)
689 self.update_player()
690
691 async def _handle_group_event(event: SonosEvent | None) -> None:
692 """Get async lock and handle event."""
693 _provider = cast("SonosPlayerProvider", self._provider)
694 async with _provider.topology_condition:
695 group = await _extract_group(event)
696 if self.soco.uid == group[0]:
697 _regroup(group)
698 _provider.topology_condition.notify_all()
699
700 return _handle_group_event(event)
701
702 async def wait_for_groups(self, groups: list[list[SonosPlayer]]) -> None:
703 """Wait until all groups are present, or timeout."""
704
705 def _test_groups(groups: list[list[SonosPlayer]]) -> bool:
706 """Return whether all groups exist now."""
707 for group in groups:
708 coordinator = group[0]
709
710 # Test that coordinator is coordinating
711 current_group = coordinator.group_members
712 if coordinator != current_group[0]:
713 return False
714
715 # Test that joined members match
716 if set(group[1:]) != set(current_group[1:]):
717 return False
718
719 return True
720
721 _provider = cast("SonosPlayerProvider", self._provider)
722 try:
723 async with asyncio.timeout(5):
724 while not _test_groups(groups):
725 await _provider.topology_condition.wait()
726 except TimeoutError:
727 self.logger.warning("Timeout waiting for target groups %s", groups)
728
729 if players := self.mass.players.all(provider_filter=_provider.instance_id):
730 any_speaker = cast("SonosPlayer", players[0])
731 any_speaker.soco.zone_group_state.clear_cache()
732
733
734def _convert_state(sonos_state: str | None) -> PlayerState:
735 """Convert Sonos state to PlayerState."""
736 if sonos_state == "PLAYING":
737 return PlayerState.PLAYING
738 if sonos_state == "TRANSITIONING":
739 return PlayerState.PLAYING
740 if sonos_state == "PAUSED_PLAYBACK":
741 return PlayerState.PAUSED
742 return PlayerState.IDLE
743
744
745def _timespan_secs(timespan: str | None) -> int | None:
746 """Parse a time-span into number of seconds."""
747 if timespan in ("", "NOT_IMPLEMENTED"):
748 return None
749 if timespan is None:
750 return None
751 return int(sum(60 ** x[0] * int(x[1]) for x in enumerate(reversed(timespan.split(":")))))
752