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