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