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