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