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