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