/
/
/
1"""
2Sonos Player provider for Music Assistant for speakers running the S2 firmware.
3
4Based on the aiosonos library, which leverages the new websockets API of the Sonos S2 firmware.
5https://github.com/music-assistant/aiosonos
6
7SonosPlayer: Holds the details of the (discovered) Sonosplayer.
8"""
9
10from __future__ import annotations
11
12import asyncio
13import time
14from dataclasses import dataclass, field
15from typing import TYPE_CHECKING, cast
16
17from aiohttp import ClientError
18from aiosonos.api.models import Container, ContainerType, MusicService, SonosCapability
19from aiosonos.client import SonosLocalApiClient
20from aiosonos.const import EventType as SonosEventType
21from aiosonos.const import SonosEvent
22from aiosonos.exceptions import CannotConnect, ConnectionFailed, FailedCommand
23from music_assistant_models.enums import (
24 IdentifierType,
25 MediaType,
26 PlaybackState,
27 PlayerFeature,
28 RepeatMode,
29)
30from music_assistant_models.errors import PlayerCommandFailed
31from music_assistant_models.player import OutputProtocol, PlayerMedia
32
33from music_assistant.constants import (
34 CONF_ENTRY_HTTP_PROFILE_DEFAULT_2,
35 CONF_ENTRY_PREFER_WAV_FOR_LIVE_SOURCES_DEFAULT_ENABLED,
36 EXTERNAL_PAUSE_IDLE_TIMEOUT,
37 VERBOSE_LOG_LEVEL,
38)
39from music_assistant.helpers.util import is_valid_mac_address
40from music_assistant.models.player import Player
41from music_assistant.providers.sonos.const import (
42 NON_HIRES_MODELS,
43 PLAYBACK_STATE_MAP,
44 PLAYER_SOURCE_MAP,
45 PREVIOUS_ITEMS,
46 SOURCE_AIRPLAY,
47 SOURCE_LINE_IN,
48 SOURCE_RADIO,
49 SOURCE_SPOTIFY,
50 SOURCE_TV,
51 UNSUPPORTED_MODELS_NATIVE_ANNOUNCEMENTS,
52 UPCOMING_ITEMS,
53)
54
55if TYPE_CHECKING:
56 from aiosonos.api.models import DiscoveryInfo as SonosDiscoveryInfo
57 from aiosonos.group import SonosGroup
58 from music_assistant_models.config_entries import ConfigEntry
59 from music_assistant_models.queue_item import QueueItem
60
61 from .provider import SonosPlayerProvider
62
63SUPPORTED_FEATURES = {
64 PlayerFeature.PLAY_MEDIA,
65 PlayerFeature.PAUSE,
66 PlayerFeature.SEEK,
67 PlayerFeature.SELECT_SOURCE,
68 PlayerFeature.SET_MEMBERS,
69 PlayerFeature.GAPLESS_PLAYBACK,
70}
71
72
73@dataclass
74class SonosQueueWindow:
75 """A window of queue items as served to a Sonos speaker."""
76
77 items: list[PlayerMedia] = field(default_factory=list)
78 includes_beginning: bool = False
79 includes_end: bool = False
80
81
82class SonosPlayer(Player):
83 """Holds the details of the (discovered) Sonosplayer."""
84
85 _attr_external_pause_idle_timeout = EXTERNAL_PAUSE_IDLE_TIMEOUT
86
87 def __init__(
88 self,
89 prov: SonosPlayerProvider,
90 player_id: str,
91 discovery_info: SonosDiscoveryInfo,
92 ) -> None:
93 """Initialize the SonosPlayer."""
94 super().__init__(prov, player_id)
95 self.discovery_info = discovery_info
96 self.connected: bool = False
97 self._listen_task: asyncio.Task[None] | None = None
98 # the MA queue the loaded cloud queue serves, and the version the speaker
99 # compares against to decide whether its cached copy is still valid
100 self.cloud_queue_id: str | None = None
101 self.cloud_queue_version: float = time.time()
102 self._announcement_media: PlayerMedia | None = None
103
104 @property
105 def group_controller(self) -> SonosGroup:
106 """Get the group controller, raising if unavailable."""
107 if self.client.player.group is None:
108 msg = "Group controller unavailable"
109 raise RuntimeError(msg)
110 return self.client.player.group
111
112 @property
113 def synced_to(self) -> str | None:
114 """
115 Return the id of the player this player is synced to (sync leader).
116
117 If this player is not synced to another player (or is the sync leader itself),
118 this should return None.
119 If it is part of a (permanent) group, this should also return None.
120 """
121 if self.client.player.is_coordinator:
122 return None
123 if self.client.player.group:
124 return self.client.player.group.coordinator_id
125 return None
126
127 async def setup(self) -> None:
128 """Handle setup of the player."""
129 assert self.device_info.ip_address is not None # for type checking
130 # connect the player first so we can fail early
131 self.client = SonosLocalApiClient(
132 self.device_info.ip_address, self.mass.http_session_no_ssl
133 )
134 await self._connect(False)
135
136 # collect supported features
137 _supported_features = SUPPORTED_FEATURES.copy()
138 if (
139 SonosCapability.AUDIO_CLIP in self.discovery_info["device"]["capabilities"]
140 and self.discovery_info["device"]["modelDisplayName"]
141 not in UNSUPPORTED_MODELS_NATIVE_ANNOUNCEMENTS
142 ):
143 _supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
144 if not self.client.player.has_fixed_volume:
145 _supported_features.add(PlayerFeature.VOLUME_SET)
146 _supported_features.add(PlayerFeature.VOLUME_MUTE)
147 _supported_features.add(PlayerFeature.NEXT_PREVIOUS)
148 _supported_features.add(PlayerFeature.ENQUEUE)
149 self._attr_supported_features = _supported_features
150
151 self._attr_name = (
152 self.discovery_info["device"]["name"]
153 or self.discovery_info["device"]["modelDisplayName"]
154 )
155 self._attr_device_info.model = self.discovery_info["device"]["modelDisplayName"]
156 self._attr_device_info.manufacturer = self._provider.manifest.name
157 self._attr_can_group_with = {self._provider.instance_id}
158
159 # all current Sonos models accept up to 24-bit/48kHz; the older models in
160 # NON_HIRES_MODELS are limited to 16-bit playback
161 if self._attr_device_info.model in NON_HIRES_MODELS:
162 self._attr_supported_sample_rates = [(44100, 16), (48000, 16)]
163 else:
164 self._attr_supported_sample_rates = [
165 (44100, 16),
166 (48000, 16),
167 (44100, 24),
168 (48000, 24),
169 ]
170
171 # Add identifiers for matching with other protocols (like AirPlay, DLNA)
172 # The player_id is the Sonos UUID (e.g., RINCON_xxxxxxxxxxxx)
173 self._attr_device_info.add_identifier(IdentifierType.UUID, self.player_id)
174 # Extract MAC address from Sonos player_id (RINCON_XXXXXXXXXXXX01400)
175 # The middle part contains the MAC address (last 6 bytes in hex)
176 mac_address = self._extract_mac_from_player_id()
177 # Only add MAC address if it's valid (not 00:00:00:00:00:00)
178 if mac_address and is_valid_mac_address(mac_address):
179 self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
180
181 if SonosCapability.LINE_IN in self.discovery_info["device"]["capabilities"]:
182 self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_LINE_IN])
183 if SonosCapability.HT_PLAYBACK in self.discovery_info["device"]["capabilities"]:
184 self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_TV])
185 if SonosCapability.AIRPLAY in self.discovery_info["device"]["capabilities"]:
186 self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_AIRPLAY])
187
188 self.update_attributes()
189 await self.mass.players.register_or_update(self)
190
191 # register callback for state changed
192 self._on_unload_callbacks.append(
193 self.client.subscribe(
194 self.on_player_event,
195 (
196 SonosEventType.GROUP_UPDATED,
197 SonosEventType.PLAYER_UPDATED,
198 ),
199 )
200 )
201
202 async def get_config_entries(self) -> list[ConfigEntry]:
203 """Return all (provider/player specific) Config Entries for the player."""
204 return [
205 CONF_ENTRY_HTTP_PROFILE_DEFAULT_2,
206 CONF_ENTRY_PREFER_WAV_FOR_LIVE_SOURCES_DEFAULT_ENABLED,
207 ]
208
209 async def on_unload(self) -> None:
210 """Handle logic when the player is unloaded from the Player controller."""
211 await super().on_unload()
212 for task_id in (
213 f"sonos_reconnect_{self.player_id}",
214 f"restore_airplay_group_{self.player_id}",
215 ):
216 # a timer that already fired lives on as a task under the same id,
217 # so both are needed to cover the pending and the running case
218 self.mass.cancel_timer(task_id)
219 self.mass.cancel_task(task_id)
220 try:
221 await self._disconnect()
222 except Exception:
223 self.logger.exception("Error disconnecting from Sonos player %s", self.name)
224
225 async def volume_set(self, volume_level: int) -> None:
226 """
227 Handle VOLUME_SET command on the player.
228
229 Will only be called if the PlayerFeature.VOLUME_SET is supported.
230
231 :param volume_level: volume level (0..100) to set on the player.
232 """
233 await self.client.player.set_volume(volume_level)
234
235 async def volume_mute(self, muted: bool) -> None:
236 """
237 Handle VOLUME MUTE command on the player.
238
239 Will only be called if the PlayerFeature.VOLUME_MUTE is supported.
240
241 :param muted: bool if player should be muted.
242 """
243 await self.client.player.set_volume(muted=muted)
244
245 async def play(self) -> None:
246 """Handle PLAY command on the player."""
247 if self.client.player.is_passive:
248 self.logger.debug("Ignore PLAY command: Player is synced to another player.")
249 return
250 try:
251 await self.group_controller.play()
252 except FailedCommand as err:
253 if self._attr_active_source is None or "groupCoordinatorChanged" in str(err):
254 # only a source Sonos loaded itself can go away like this, and a coordinator
255 # change is a race condition rather than a source that disappeared
256 raise
257 # the loaded source refused to resume, so it is not merely paused after all
258 self.logger.debug(
259 "Source %s on Sonos player %s can not be resumed: %s",
260 self._attr_active_source,
261 self.player_id,
262 err,
263 )
264 self.mark_external_source_ended()
265 self.update_state()
266
267 async def stop(self) -> None:
268 """Handle STOP command on the player."""
269 self.mark_stop_called()
270 if self.client.player.is_passive:
271 self.logger.debug("Ignore STOP command: Player is synced to another player.")
272 return
273 await self.group_controller.stop()
274 self.cloud_queue_id = None
275 self._announcement_media = None
276 self.update_state()
277
278 async def pause(self) -> None:
279 """
280 Handle PAUSE command on the player.
281
282 Will only be called if the player reports PlayerFeature.PAUSE is supported.
283 """
284 if self.client.player.is_passive:
285 self.logger.debug("Ignore PAUSE command: Player is synced to another player.")
286 return
287 active_source = self.state.active_source
288 if active_source and self.mass.player_queues.get(active_source):
289 # Sonos seems to be bugged when playing our queue tracks and we send pause,
290 # it can't resume the current track and simply aborts/skips it
291 # so we stop the player instead.
292 # https://github.com/music-assistant/support/issues/3758
293 # TODO: revisit this later once we implemented support for range requests
294 # as I have the feeling the pause issue is related to seek support (=range requests)
295 await self.stop()
296 return
297 if not self.group_controller.playback_actions.can_pause:
298 await self.stop()
299 return
300 await self.group_controller.pause()
301
302 async def next_track(self) -> None:
303 """
304 Handle NEXT_TRACK command on the player.
305
306 Will only be called if the player reports PlayerFeature.NEXT_PREVIOUS
307 is supported and the player is not currently playing a MA queue.
308 """
309 await self.group_controller.skip_to_next_track()
310
311 async def previous_track(self) -> None:
312 """
313 Handle PREVIOUS_TRACK command on the player.
314
315 Will only be called if the player reports PlayerFeature.NEXT_PREVIOUS
316 is supported and the player is not currently playing a MA queue.
317 """
318 await self.group_controller.skip_to_previous_track()
319
320 async def seek(self, position: int) -> None:
321 """
322 Handle SEEK command on the player.
323
324 Seek to a specific position in the current track.
325 Will only be called if the player reports PlayerFeature.SEEK is
326 supported and the player is NOT currently playing a MA queue.
327
328 :param position: The position to seek to, in seconds.
329 """
330 # sonos expects milliseconds
331 await self.group_controller.seek(position * 1000)
332
333 async def play_media(
334 self,
335 media: PlayerMedia,
336 ) -> None:
337 """
338 Handle PLAY MEDIA command on given player.
339
340 This is called by the Player controller to start playing Media on the player,
341 which can be a MA queue item/stream or a native source.
342 The provider's own implementation should work out how to handle this request.
343
344 :param media: Details of the item that needs to be played on the player.
345 """
346 if self.client.player.is_passive:
347 # this should be already handled by the player manager, but just in case...
348 msg = (
349 f"Player {self.display_name} can not "
350 "accept play_media command, it is synced to another player."
351 )
352 raise PlayerCommandFailed(
353 msg,
354 translation_key="player_synced_cannot_play",
355 translation_owner=self.translation_owner,
356 translation_args=[self.display_name],
357 )
358 # for now always reset the active session
359 self.group_controller.active_session_id = None
360 # what is playing stays described until its replacement is loaded below: there are
361 # awaits in between, and an empty window served in that gap stops the current queue
362 self._announcement_media = None
363 self.bump_cloud_queue_version()
364
365 if media.media_type == MediaType.ANNOUNCEMENT:
366 # We cannot use play_stream_url for announcements because Sonos treats those
367 # as duration less radio streams and will retry/loop them.
368 media.duration = await self.mass.streams.get_announcement_duration(media)
369 media.queue_item_id = "announcement"
370 self._announcement_media = media
371 cloud_queue_url = f"{self.mass.streams.base_url}/sonos_queue/{self.player_id}/v2.3/"
372 try:
373 await self.group_controller.play_cloud_queue(
374 cloud_queue_url,
375 item_id=media.queue_item_id,
376 )
377 except Exception:
378 # the speaker never got the queue, so describing one is worse than
379 # admitting there is none - its session was reset above either way
380 self._announcement_media = None
381 raise
382 return
383
384 if not self.flow_mode and media.source_id and media.queue_item_id:
385 # Regular Queue item playback
386 # create a sonos cloud queue and load it
387 self.cloud_queue_id = media.source_id
388 cloud_queue_url = f"{self.mass.streams.base_url}/sonos_queue/{self.player_id}/v2.3/"
389 try:
390 await self.group_controller.play_cloud_queue(
391 cloud_queue_url,
392 item_id=media.queue_item_id,
393 )
394 except Exception:
395 # the speaker never got the queue, so describing one is worse than
396 # admitting there is none - its session was reset above either way
397 self.cloud_queue_id = None
398 raise
399 return
400
401 # play duration-less (long running) radio streams
402 # this path loads no cloud queue, so the speaker must not be signalled about one
403 self.cloud_queue_id = None
404 # enforce AAC here because Sonos really does not support FLAC streams without duration
405 stream_url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
406 stream_url = stream_url.replace(".flac", ".aac").replace(".wav", ".aac")
407 if media.source_id and media.queue_item_id:
408 object_id = f"mass:{media.source_id}:{media.queue_item_id}"
409 else:
410 object_id = stream_url
411 container: Container = {
412 "_objectType": "container",
413 "name": media.title or "",
414 "type": "track",
415 "id": {
416 "_objectType": "id",
417 "objectId": object_id,
418 },
419 "service": {
420 "_objectType": "service",
421 "name": "Music Assistant",
422 "id": "mass",
423 },
424 }
425 if media.image_url:
426 container["imageUrl"] = media.image_url
427 await self.group_controller.play_stream_url(stream_url, container)
428
429 async def select_source(self, source: str) -> None:
430 """
431 Handle SELECT SOURCE command on the player.
432
433 Will only be called if the PlayerFeature.SELECT_SOURCE is supported.
434
435 :param source: The source(id) to select, as defined in the source_list.
436 """
437 # whatever the source turns out to be, it is not the cloud queue any more
438 self.cloud_queue_id = None
439 self._announcement_media = None
440 if source == SOURCE_LINE_IN:
441 await self.group_controller.load_line_in(play_on_completion=True)
442 elif source == SOURCE_TV:
443 await self.client.player.load_home_theater_playback()
444 else:
445 # unsupported source - try to clear the queue/player
446 await self.stop()
447
448 async def enqueue_next_media(self, media: PlayerMedia) -> None:
449 """
450 Handle enqueuing of the next (queue) item on the player.
451
452 Called when player reports it started buffering a queue item
453 and when the queue items updated.
454
455 A PlayerProvider implementation is in itself responsible for handling this
456 so that the queue items keep playing until its empty or the player stopped.
457
458 Will only be called if the player reports PlayerFeature.ENQUEUE is
459 supported and the player is currently playing a MA queue.
460
461 This will NOT be called if the end of the queue is reached (and repeat disabled).
462 This will NOT be called if the player is using flow mode to playback the queue.
463
464 :param media: Details of the item that needs to be enqueued on the player.
465 """
466 if media.source_id:
467 self.cloud_queue_id = media.source_id
468 await self.refresh_cloud_queue()
469
470 def bump_cloud_queue_version(self) -> None:
471 """
472 Advance the version the speaker compares its cached queue against.
473
474 An unchanged queueVersion reads as "nothing changed", so this must happen the moment
475 the queue does: a window served in between would carry a version read as current.
476 """
477 self.cloud_queue_version = time.time()
478
479 async def refresh_cloud_queue(self) -> None:
480 """Signal the speaker that the queue it is playing changed."""
481 self.bump_cloud_queue_version()
482 if not self.connected:
483 return
484 group = self.client.player.group
485 if group is None or not group.active_session_id:
486 return
487 try:
488 await self.client.api.playback_session.refresh_cloud_queue(group.active_session_id)
489 except FailedCommand as err:
490 # an app outside MA can take the session over, leaving us with a session id the
491 # speaker no longer knows. Only a nudge is lost: it reads a live window regardless.
492 self.logger.debug("Could not refresh the cloud queue: %s", err)
493
494 async def build_cloud_queue_window(
495 self,
496 item_id: str | None,
497 max_previous: int = PREVIOUS_ITEMS,
498 max_upcoming: int = UPCOMING_ITEMS,
499 ) -> SonosQueueWindow:
500 """
501 Return the item the speaker asked about and the one that follows it, as the queue is now.
502
503 :param item_id: queue_item_id the speaker asked about; an omitted or empty one asks
504 for the start of the queue.
505 :param max_previous: Ceiling on the items before the centre, if the speaker asked for
506 fewer than we would otherwise serve.
507 :param max_upcoming: Ceiling on the items after the centre, same.
508 """
509 if self._announcement_media is not None:
510 # an announcement is a queue of exactly one item
511 return SonosQueueWindow(
512 items=[self._announcement_media], includes_beginning=True, includes_end=True
513 )
514 queue_id = self.cloud_queue_id
515 if not queue_id or not (queue := self.mass.player_queues.get(queue_id)):
516 # nothing to describe: both ends flagged, or the speaker holds what it cached
517 return SonosQueueWindow(includes_beginning=True, includes_end=True)
518
519 if not item_id:
520 # an omitted or empty itemId asks for the start of the queue
521 center_index = 0
522 elif (found := self.mass.player_queues.index_by_id(queue_id, item_id)) is not None:
523 center_index = found
524 else:
525 # an item the queue no longer holds: answer around the playing one (not
526 # index_in_buffer, which runs an item ahead with crossfade)
527 center_index = (
528 queue.current_index
529 if queue.current_index is not None
530 else (queue.index_in_buffer or 0)
531 )
532
533 items: list[PlayerMedia] = []
534 offset = max(0, center_index - min(PREVIOUS_ITEMS, max_previous))
535 for idx in range(offset, center_index + 1):
536 queue_item = self.mass.player_queues.get_item(queue_id, idx)
537 if queue_item and queue_item.available:
538 items.append(await self._player_media_for_speaker(queue_item))
539
540 # get_next_item accounts for repeat mode, so this is the item that will really
541 # play next rather than whatever sits at the next index
542 last_index: int | str = center_index
543 for _ in range(min(UPCOMING_ITEMS, max_upcoming)):
544 next_item = self.mass.player_queues.get_next_item(queue_id, last_index)
545 if next_item is None:
546 break
547 items.append(await self._player_media_for_speaker(next_item))
548 last_index = next_item.queue_item_id
549
550 window = SonosQueueWindow(
551 items=items,
552 includes_beginning=offset == 0,
553 # check after the loop in case the window filled exactly up to the last item
554 includes_end=self.mass.player_queues.get_next_item(queue_id, last_index) is None,
555 )
556 self.logger.log(
557 VERBOSE_LOG_LEVEL,
558 "Serving Sonos queue window for %s on player %s: %s",
559 queue_id,
560 self.player_id,
561 [x.title for x in window.items],
562 )
563 return window
564
565 async def set_members(
566 self,
567 player_ids_to_add: list[str] | None = None,
568 player_ids_to_remove: list[str] | None = None,
569 ) -> None:
570 """
571 Handle SET_MEMBERS command on the player.
572
573 Group or ungroup the given child player(s) to/from this player.
574 Will only be called if the PlayerFeature.SET_MEMBERS is supported.
575
576 :param player_ids_to_add: List of player_id's to add to the group.
577 :param player_ids_to_remove: List of player_id's to remove from the group.
578 """
579 player_ids_to_add = player_ids_to_add or []
580 player_ids_to_remove = player_ids_to_remove or []
581 if player_ids_to_add or player_ids_to_remove:
582 await self.group_controller.modify_group_members(
583 player_ids_to_add=player_ids_to_add,
584 player_ids_to_remove=player_ids_to_remove,
585 )
586
587 async def ungroup(self) -> None:
588 """
589 Handle UNGROUP command on the player.
590
591 Remove the player from any (sync)groups it currently is grouped to.
592 If this player is the sync leader (or group player),
593 all child's will be ungrouped and the group dissolved.
594
595 Will only be called if the PlayerFeature.SET_MEMBERS is supported.
596 """
597 await self.client.player.leave_group()
598
599 async def play_announcement(
600 self, announcement: PlayerMedia, volume_level: int | None = None
601 ) -> None:
602 """
603 Handle (native) playback of an announcement on the player.
604
605 Will only be called if the PlayerFeature.PLAY_ANNOUNCEMENT is supported.
606
607 :param announcement: Details of the announcement that needs to be played on the player.
608 :param volume_level: The volume level to play the announcement at (0..100).
609 If not set, the player should use the current volume level.
610 """
611 self.logger.debug(
612 "Playing announcement %s on %s",
613 announcement.uri,
614 self.display_name,
615 )
616 await self.client.player.play_audio_clip(
617 announcement.uri, volume_level, name="Announcement"
618 )
619 # Wait until the announcement is finished playing
620 # This is helpful for people who want to play announcements in a sequence
621 # yeah we can also setup a subscription on the sonos player for this, but this is easier
622 duration = await self.mass.streams.get_announcement_duration(announcement)
623 await asyncio.sleep(duration or 10)
624
625 def on_player_event(self, event: SonosEvent | None) -> None:
626 """Handle incoming event from player."""
627 try:
628 self.update_attributes()
629 except Exception:
630 self.logger.exception("Failed to update player attributes")
631 return
632 try:
633 self.update_state()
634 except Exception:
635 self.logger.exception("Failed to update player state")
636
637 def update_attributes(self) -> None: # noqa: PLR0915
638 """Update the player attributes."""
639 self._attr_available = self.connected
640 if not self.connected:
641 return
642 # guard against the race where a volume event arrives before aiosonos'
643 # async_init has populated _volume_data (the accessors raise AttributeError
644 # on None). The next event re-runs once the data is there.
645 try:
646 has_fixed_volume = self.client.player.has_fixed_volume
647 volume_muted = self.client.player.volume_muted
648 volume_level = self.client.player.volume_level
649 except AttributeError:
650 pass
651 else:
652 if has_fixed_volume:
653 self._attr_volume_level = 100
654 elif not volume_muted or volume_level:
655 self._attr_volume_level = volume_level or 0
656 self._attr_volume_muted = volume_muted
657
658 group_parent: SonosPlayer | None = None
659 active_group: SonosGroup | None
660 if self.client.player.is_coordinator:
661 # player is group coordinator - always report native group members
662 active_group = self.group_controller
663 if len(self.client.player.group_members) > 1:
664 self._attr_group_members = list(self.client.player.group_members)
665 else:
666 self._attr_group_members.clear()
667 self._attr_can_group_with = {self._provider.instance_id}
668 else:
669 # player is group child (synced to another player)
670 group_parent = cast(
671 "SonosPlayer | None",
672 self.mass.players.get_player(self.group_controller.coordinator_id),
673 )
674 if not group_parent or not group_parent.client or not group_parent.client.player:
675 # handle race condition where the group parent is not yet discovered
676 return
677 active_group = group_parent.client.player.group
678 self._attr_group_members.clear()
679
680 if not active_group:
681 # should not happen, but guard it anyways
682 return
683
684 # map playback state
685 self._attr_playback_state = PLAYBACK_STATE_MAP[active_group.playback_state]
686 self._attr_elapsed_time = active_group.position
687
688 # figure out the active source based on the container
689 container_type = active_group.container_type
690 active_service = active_group.active_service
691 container = active_group.playback_metadata.get("container")
692 if (
693 not active_service
694 and container
695 and container.get("service", {}).get("id") == MusicService.MUSIC_ASSISTANT
696 ):
697 active_service = MusicService.MUSIC_ASSISTANT
698 if container_type == ContainerType.LINEIN:
699 self._attr_active_source = SOURCE_LINE_IN
700 elif container_type in (ContainerType.HOME_THEATER_HDMI, ContainerType.HOME_THEATER_SPDIF):
701 self._attr_active_source = SOURCE_TV
702 elif container_type == ContainerType.AIRPLAY and self.active_output_protocol not in (
703 "airplay",
704 "sendspin",
705 ):
706 self._attr_active_source = SOURCE_AIRPLAY
707 elif (
708 container_type == ContainerType.STATION
709 and active_service != MusicService.MUSIC_ASSISTANT
710 ):
711 self._attr_active_source = SOURCE_RADIO
712 # add radio to source list if not yet there
713 if SOURCE_RADIO not in [x.id for x in self._attr_source_list]:
714 self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_RADIO])
715 elif active_service == MusicService.SPOTIFY:
716 self._attr_active_source = SOURCE_SPOTIFY
717 # add spotify to source list if not yet there
718 if SOURCE_SPOTIFY not in [x.id for x in self._attr_source_list]:
719 self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_SPOTIFY])
720 elif active_service == MusicService.MUSIC_ASSISTANT:
721 # setting active source to None is fine
722 self._attr_active_source = None
723 # its playing some service we did not yet map
724 elif container and container.get("service", {}).get("name"):
725 self._attr_active_source = container["service"]["name"]
726 elif container and container.get("name"):
727 self._attr_active_source = container["name"]
728 elif active_service:
729 self._attr_active_source = active_service
730 elif container_type:
731 self._attr_active_source = container_type
732 else:
733 # the player has nothing loaded at all (empty queue and no service active)
734 self._attr_active_source = None
735
736 # special case: Sonos reports PAUSED state when MA stopped playback
737 if (
738 active_service == MusicService.MUSIC_ASSISTANT
739 and self._attr_playback_state == PlaybackState.PAUSED
740 ):
741 self._attr_playback_state = PlaybackState.IDLE
742
743 # parse current media
744 self._attr_elapsed_time = active_group.position
745 self._attr_elapsed_time_last_updated = time.time()
746 current_media = None
747 if (current_item := active_group.playback_metadata.get("currentItem")) and (
748 (track := current_item.get("track")) and track.get("name")
749 ):
750 track_images = track.get("images", [])
751 track_image_url = track_images[0].get("url") if track_images else None
752 track_duration_millis = track.get("durationMillis")
753 current_media = PlayerMedia(
754 uri=track.get("id", {}).get("objectId") or track.get("mediaUrl") or "",
755 media_type=MediaType.TRACK,
756 title=track["name"],
757 artist=track.get("artist", {}).get("name"),
758 album=track.get("album", {}).get("name"),
759 duration=int(track_duration_millis / 1000) if track_duration_millis else None,
760 image_url=track_image_url,
761 )
762 if active_service == MusicService.MUSIC_ASSISTANT:
763 current_media.source_id = self._attr_active_source
764 current_media.queue_item_id = current_item["id"]
765 # radio stream info
766 if container and container.get("name") and active_group.playback_metadata.get("streamInfo"):
767 images = container.get("images", [])
768 image_url = images[0].get("url") if images else None
769 current_media = PlayerMedia(
770 uri=container.get("id", {}).get("objectId") or "",
771 media_type=MediaType.RADIO,
772 title=active_group.playback_metadata["streamInfo"],
773 album=container["name"],
774 image_url=image_url,
775 )
776 # generic info from container (also when MA is playing!)
777 if container and container.get("name") and container.get("id"):
778 if not current_media:
779 current_media = PlayerMedia(
780 uri=container["id"]["objectId"], media_type=MediaType.UNKNOWN
781 )
782 if not current_media.image_url:
783 images = container.get("images", [])
784 current_media.image_url = images[0].get("url") if images else None
785 if not current_media.title:
786 current_media.title = container["name"]
787 if not current_media.uri:
788 current_media.uri = container["id"]["objectId"]
789
790 self._attr_current_media = current_media
791
792 async def on_protocol_playback(
793 self,
794 output_protocol: OutputProtocol,
795 ) -> None:
796 """Handle callback when playback starts on a protocol output."""
797 # Only handle AirPlay protocol
798 if output_protocol.protocol_domain != "airplay":
799 return
800
801 # Only if this player is a coordinator with group members
802 if not self.client.player.is_coordinator:
803 return
804
805 current_members = list(self.client.player.group_members)
806 if len(current_members) <= 1:
807 # No group members to worry about
808 return
809
810 # Workaround for Sonos AirPlay ungrouping bug: when AirPlay playback starts
811 # on a Sonos speaker that has native group members, Sonos dissolves the group.
812 # We capture the group state here and restore it after a delay.
813
814 self.logger.debug(
815 "AirPlay playback starting on %s with native group members %s - "
816 "scheduling restoration to work around Sonos ungrouping bug",
817 self.name,
818 current_members,
819 )
820 members_to_restore = [m for m in current_members if m != self.player_id]
821
822 async def _restore_airplay_group() -> None:
823 try:
824 self.logger.info(
825 "Restoring AirPlay group for %s with members %s",
826 self.name,
827 members_to_restore,
828 )
829 # we call set_members on the PlayerController here so it
830 # can try to regroup via the preferred protocol (which may be AirPlay),
831 await self.set_members(player_ids_to_add=members_to_restore)
832 except Exception as err:
833 self.logger.warning("Failed to restore AirPlay group: %s", err)
834
835 # Schedule restoration after 6 seconds to let AirPlay settle
836 self.mass.call_later(
837 6,
838 _restore_airplay_group,
839 task_id=f"restore_airplay_group_{self.player_id}",
840 )
841
842 def update_elapsed_time(self, elapsed_time: float | None = None) -> None:
843 """Update the elapsed time of the current media."""
844 if elapsed_time is not None:
845 self._attr_elapsed_time = elapsed_time
846 last_updated = time.time()
847 self._attr_elapsed_time_last_updated = last_updated
848 self.update_state()
849
850 def reconnect(self, delay: float = 1) -> None:
851 """Reconnect the player."""
852 if self.mass.closing:
853 return
854 # use a task_id to prevent multiple reconnects
855 task_id = f"sonos_reconnect_{self.player_id}"
856 self.mass.call_later(delay, self._connect, delay, task_id=task_id)
857
858 async def sync_play_modes(self, queue_id: str) -> None:
859 """Sync the play modes between MA and Sonos."""
860 queue = self.mass.player_queues.get(queue_id)
861 if not queue or queue.state not in (PlaybackState.PLAYING, PlaybackState.PAUSED):
862 return
863 repeat_single_enabled = queue.repeat_mode == RepeatMode.ONE
864 repeat_all_enabled = queue.repeat_mode == RepeatMode.ALL
865 if not self.client.player.group:
866 return
867 play_modes = self.group_controller.play_modes
868 if (
869 play_modes.repeat != repeat_all_enabled
870 or play_modes.repeat_one != repeat_single_enabled
871 ):
872 try:
873 await self.group_controller.set_play_modes(
874 repeat=repeat_all_enabled,
875 repeat_one=repeat_single_enabled,
876 )
877 except FailedCommand as err:
878 if "groupCoordinatorChanged" not in str(err):
879 # this may happen at race conditions
880 raise
881
882 async def _connect(self, retry_on_fail: int = 0) -> None:
883 """Connect to the Sonos player."""
884 if self.mass.closing:
885 return
886 if self._listen_task and not self._listen_task.done():
887 self.logger.debug("Already connected to Sonos player: %s", self.player_id)
888 return
889 try:
890 await self.client.connect()
891 except (ConnectionFailed, CannotConnect, ClientError) as err:
892 self.logger.warning("Failed to connect to Sonos player: %s", err)
893 if not retry_on_fail or not self.mass.players.get_player(self.player_id):
894 raise
895 self._attr_available = False
896 self.update_state()
897 self.reconnect(min(retry_on_fail + 30, 3600))
898 return
899 self.connected = True
900 self.logger.debug("Connected to player API")
901 init_ready = asyncio.Event()
902
903 async def _listener() -> None:
904 try:
905 await self.client.start_listening(init_ready)
906 except Exception as err:
907 if not isinstance(err, ConnectionFailed | asyncio.CancelledError):
908 self.logger.exception("Error in Sonos player listener")
909 finally:
910 self.logger.info("Disconnected from player API")
911 if self.connected and not self.mass.closing:
912 # we didn't explicitly disconnect, try to reconnect
913 # this should simply try to reconnect once and if that fails
914 # we rely on mdns to pick it up again later
915 await self._disconnect()
916 self._attr_available = False
917 self.update_state()
918 self.reconnect(5)
919
920 self._listen_task = self.mass.create_task(_listener())
921 await init_ready.wait()
922
923 async def _disconnect(self) -> None:
924 """Disconnect the client and cleanup."""
925 self.connected = False
926 if self._listen_task and not self._listen_task.done():
927 self._listen_task.cancel()
928 if self.client:
929 await self.client.disconnect()
930 self.logger.debug("Disconnected from player API")
931
932 async def _player_media_for_speaker(self, queue_item: QueueItem) -> PlayerMedia:
933 """Return the media for a queue item, with its stream URL resolved for this player."""
934 media = await self.mass.player_queues.player_media_from_queue_item(queue_item)
935 media.uri = await self.mass.streams.resolve_stream_url(self.player_id, media)
936 return media
937
938 def _extract_mac_from_player_id(self) -> str | None:
939 """
940 Extract MAC address from Sonos player_id.
941
942 Sonos player_ids follow the format RINCON_XXXXXXXXXXXX01400 where
943 the middle 12 hex characters represent the MAC address.
944
945 :return: MAC address string in XX:XX:XX:XX:XX:XX format, or None if not extractable.
946 """
947 # Remove RINCON_ prefix if present
948 player_id = self.player_id
949 player_id = player_id.removeprefix("RINCON_") # Remove "RINCON_"
950
951 # Remove the 01400 suffix (or similar) - should be last 5 chars
952 if len(player_id) >= 17: # 12 hex chars for MAC + 5 chars suffix
953 mac_hex = player_id[:12]
954 else:
955 return None
956
957 # Validate it looks like a MAC (all hex characters)
958 try:
959 int(mac_hex, 16)
960 except ValueError:
961 return None
962
963 # Format as XX:XX:XX:XX:XX:XX
964 return ":".join(mac_hex[i : i + 2].upper() for i in range(0, 12, 2))
965