music-assistant-server

33.7 KBPY
player.py
33.7 KB820 lines • python
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