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