/
/
/
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.config_entries import ConfigEntry, ConfigValueType
24from music_assistant_models.enums import (
25 ConfigEntryType,
26 EventType,
27 MediaType,
28 PlaybackState,
29 PlayerFeature,
30 RepeatMode,
31)
32from music_assistant_models.errors import PlayerCommandFailed
33from music_assistant_models.player import PlayerMedia
34
35from music_assistant.constants import (
36 CONF_ENTRY_HTTP_PROFILE_DEFAULT_2,
37 create_sample_rates_config_entry,
38)
39from music_assistant.helpers.tags import async_parse_tags
40from music_assistant.helpers.upnp import get_xml_soap_set_next_url, get_xml_soap_set_url
41from music_assistant.models.player import Player
42from music_assistant.providers.sonos.const import (
43 CONF_AIRPLAY_MODE,
44 PLAYBACK_STATE_MAP,
45 PLAYER_SOURCE_MAP,
46 SOURCE_AIRPLAY,
47 SOURCE_LINE_IN,
48 SOURCE_RADIO,
49 SOURCE_SPOTIFY,
50 SOURCE_TV,
51 UNSUPPORTED_MODELS_NATIVE_ANNOUNCEMENTS,
52)
53
54if TYPE_CHECKING:
55 from aiosonos.api.models import DiscoveryInfo as SonosDiscoveryInfo
56 from music_assistant_models.event import MassEvent
57
58 from .provider import SonosPlayerProvider
59
60SUPPORTED_FEATURES = {
61 PlayerFeature.PAUSE,
62 PlayerFeature.SEEK,
63 PlayerFeature.SELECT_SOURCE,
64 PlayerFeature.SET_MEMBERS,
65 PlayerFeature.GAPLESS_PLAYBACK,
66}
67
68
69@dataclass
70class SonosQueue:
71 """Simple representation of a Sonos (cloud) Queue."""
72
73 items: list[PlayerMedia] = field(default_factory=list)
74 last_updated: float = time.time()
75
76
77class SonosPlayer(Player):
78 """Holds the details of the (discovered) Sonosplayer."""
79
80 def __init__(
81 self,
82 prov: SonosPlayerProvider,
83 player_id: str,
84 discovery_info: SonosDiscoveryInfo,
85 ) -> None:
86 """Initialize the SonosPlayer."""
87 super().__init__(prov, player_id)
88 self.discovery_info = discovery_info
89 self.connected: bool = False
90 self._listen_task: asyncio.Task | None = None
91 # Sonos speakers can optionally have airplay (most S2 speakers do)
92 # and this airplay player can also be a player within MA.
93 # We can do some smart stuff if we link them together where possible.
94 # The player we can just guess from the sonos player id (mac address).
95 self.airplay_player_id = f"ap{self.player_id[7:-5].lower()}"
96 self.sonos_queue: SonosQueue = SonosQueue()
97
98 @property
99 def airplay_mode_enabled(self) -> bool:
100 """Return if airplay mode is enabled for the player."""
101 return self.mass.config.get_raw_player_config_value(
102 self.player_id, CONF_AIRPLAY_MODE, False
103 )
104
105 @property
106 def airplay_mode_active(self) -> bool:
107 """Return if airplay mode is active for the player."""
108 return (
109 self.airplay_mode_enabled
110 and self.client.player.is_coordinator
111 and (airplay_player := self.get_linked_airplay_player(False))
112 and airplay_player.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
113 )
114
115 @property
116 def synced_to(self) -> str | None:
117 """
118 Return the id of the player this player is synced to (sync leader).
119
120 If this player is not synced to another player (or is the sync leader itself),
121 this should return None.
122 If it is part of a (permanent) group, this should also return None.
123 """
124 if self.client.player.is_coordinator:
125 return None
126 if self.client.player.group:
127 return self.client.player.group.coordinator_id
128 return None
129
130 async def setup(self) -> None:
131 """Handle setup of the player."""
132 # connect the player first so we can fail early
133 self.client = SonosLocalApiClient(
134 self.device_info.ip_address, self.mass.http_session_no_ssl
135 )
136 await self._connect(False)
137
138 # collect supported features
139 _supported_features = SUPPORTED_FEATURES.copy()
140 if (
141 SonosCapability.AUDIO_CLIP in self.discovery_info["device"]["capabilities"]
142 and self.discovery_info["device"]["modelDisplayName"]
143 not in UNSUPPORTED_MODELS_NATIVE_ANNOUNCEMENTS
144 ):
145 _supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
146 if not self.client.player.has_fixed_volume:
147 _supported_features.add(PlayerFeature.VOLUME_SET)
148 _supported_features.add(PlayerFeature.VOLUME_MUTE)
149 if not self.get_linked_airplay_player(False):
150 _supported_features.add(PlayerFeature.NEXT_PREVIOUS)
151 if not self.get_linked_airplay_player(True):
152 _supported_features.add(PlayerFeature.ENQUEUE)
153 self._attr_supported_features = _supported_features
154
155 self._attr_name = (
156 self.discovery_info["device"]["name"]
157 or self.discovery_info["device"]["modelDisplayName"]
158 )
159 self._attr_device_info.model = self.discovery_info["device"]["modelDisplayName"]
160 self._attr_device_info.manufacturer = self._provider.manifest.name
161 self._attr_can_group_with = {self._provider.instance_id}
162
163 if SonosCapability.LINE_IN in self.discovery_info["device"]["capabilities"]:
164 self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_LINE_IN])
165 if SonosCapability.HT_PLAYBACK in self.discovery_info["device"]["capabilities"]:
166 self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_TV])
167 if SonosCapability.AIRPLAY in self.discovery_info["device"]["capabilities"]:
168 self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_AIRPLAY])
169
170 self.update_attributes()
171 await self.mass.players.register_or_update(self)
172
173 # register callback for state changed
174 self._on_unload_callbacks.append(
175 self.client.subscribe(
176 self.on_player_event,
177 (
178 SonosEventType.GROUP_UPDATED,
179 SonosEventType.PLAYER_UPDATED,
180 ),
181 )
182 )
183 # register callback for airplay player state changes
184 self._on_unload_callbacks.append(
185 self.mass.subscribe(
186 self._on_airplay_player_event,
187 (EventType.PLAYER_UPDATED, EventType.PLAYER_ADDED),
188 self.airplay_player_id,
189 )
190 )
191
192 async def get_config_entries(
193 self,
194 action: str | None = None,
195 values: dict[str, ConfigValueType] | None = None,
196 ) -> list[ConfigEntry]:
197 """Return all (provider/player specific) Config Entries for the player."""
198 base_entries = [
199 CONF_ENTRY_HTTP_PROFILE_DEFAULT_2,
200 create_sample_rates_config_entry(
201 # set safe max bit depth to 16 bits because the older Sonos players
202 # do not support 24 bit playback (e.g. Play:1)
203 max_sample_rate=48000,
204 max_bit_depth=24,
205 safe_max_bit_depth=16,
206 hidden=False,
207 ),
208 ]
209 return [
210 *base_entries,
211 ConfigEntry(
212 key="airplay_detected",
213 type=ConfigEntryType.BOOLEAN,
214 label="airplay_detected",
215 hidden=True,
216 required=False,
217 default_value=self.get_linked_airplay_player(False) is not None,
218 ),
219 ConfigEntry(
220 key=CONF_AIRPLAY_MODE,
221 type=ConfigEntryType.BOOLEAN,
222 label="Enable AirPlay mode",
223 description="Almost all newer Sonos speakers have AirPlay support. "
224 "If you have the AirPlay provider enabled in Music Assistant, "
225 "your Sonos speaker will also be detected as a AirPlay speaker, meaning "
226 "you can group them with other AirPlay speakers.\n\n"
227 "By default, Music Assistant uses the Sonos protocol for playback but with this "
228 "feature enabled, it will use the AirPlay protocol instead by redirecting "
229 "the playback related commands to the linked AirPlay player in Music Assistant, "
230 "allowing you to mix and match Sonos speakers with AirPlay speakers. \n\n"
231 "NOTE: You need to have the AirPlay provider enabled as well as "
232 "the AirPlay version of this player.",
233 required=False,
234 default_value=False,
235 depends_on="airplay_detected",
236 hidden=SonosCapability.AIRPLAY not in self.discovery_info["device"]["capabilities"],
237 ),
238 ]
239
240 def get_linked_airplay_player(self, enabled_only: bool = True) -> Player | None:
241 """Return the linked airplay player if available/enabled."""
242 if enabled_only and not self.airplay_mode_enabled:
243 return None
244 if not (airplay_player := self.mass.players.get(self.airplay_player_id)):
245 return None
246 if not airplay_player.available:
247 return None
248 return airplay_player
249
250 async def volume_set(self, volume_level: int) -> None:
251 """
252 Handle VOLUME_SET command on the player.
253
254 Will only be called if the PlayerFeature.VOLUME_SET is supported.
255
256 :param volume_level: volume level (0..100) to set on the player.
257 """
258 await self.client.player.set_volume(volume_level)
259 # sync volume level with airplay player
260 if airplay_player := self.get_linked_airplay_player(False):
261 if airplay_player.playback_state not in (PlaybackState.PLAYING, PlaybackState.PAUSED):
262 airplay_player._attr_volume_level = volume_level
263
264 async def volume_mute(self, muted: bool) -> None:
265 """
266 Handle VOLUME MUTE command on the player.
267
268 Will only be called if the PlayerFeature.VOLUME_MUTE is supported.
269
270 :param muted: bool if player should be muted.
271 """
272 await self.client.player.set_volume(muted=muted)
273
274 async def play(self) -> None:
275 """Handle PLAY command on the player."""
276 if self.client.player.is_passive:
277 self.logger.debug("Ignore STOP command: Player is synced to another player.")
278 return
279 if airplay_player := self.get_linked_airplay_player(True):
280 # linked airplay player is active, redirect the command
281 self.logger.debug("Redirecting PLAY command to linked airplay player.")
282 await airplay_player.play()
283 else:
284 await self.client.player.group.play()
285
286 async def stop(self) -> None:
287 """Handle STOP command on the player."""
288 if self.client.player.is_passive:
289 self.logger.debug("Ignore STOP command: Player is synced to another player.")
290 return
291 if (airplay_player := self.get_linked_airplay_player(True)) and self.airplay_mode_active:
292 # linked airplay player is active, redirect the command
293 self.logger.debug("Redirecting STOP command to linked airplay player.")
294 await airplay_player.stop()
295 else:
296 await self.client.player.group.stop()
297 self.update_state()
298
299 async def pause(self) -> None:
300 """
301 Handle PAUSE command on the player.
302
303 Will only be called if the player reports PlayerFeature.PAUSE is supported.
304 """
305 if self.client.player.is_passive:
306 self.logger.debug("Ignore STOP command: Player is synced to another player.")
307 return
308 if (airplay_player := self.get_linked_airplay_player(True)) and self.airplay_mode_active:
309 # linked airplay player is active, redirect the command
310 self.logger.debug("Redirecting PAUSE command to linked airplay player.")
311 await airplay_player.pause()
312 return
313 active_source = self._attr_active_source
314 if self.mass.player_queues.get(active_source):
315 # Sonos seems to be bugged when playing our queue tracks and we send pause,
316 # it can't resume the current track and simply aborts/skips it
317 # so we stop the player instead.
318 # https://github.com/music-assistant/support/issues/3758
319 # TODO: revisit this later once we implemented support for range requests
320 # as I have the feeling the pause issue is related to seek support (=range requests)
321 await self.stop()
322 return
323 if not self.client.player.group.playback_actions.can_pause:
324 await self.stop()
325 return
326 await self.client.player.group.pause()
327
328 async def next_track(self) -> None:
329 """
330 Handle NEXT_TRACK command on the player.
331
332 Will only be called if the player reports PlayerFeature.NEXT_PREVIOUS
333 is supported and the player is not currently playing a MA queue.
334 """
335 await self.client.player.group.skip_to_next_track()
336
337 async def previous_track(self) -> None:
338 """
339 Handle PREVIOUS_TRACK command on the player.
340
341 Will only be called if the player reports PlayerFeature.NEXT_PREVIOUS
342 is supported and the player is not currently playing a MA queue.
343 """
344 await self.client.player.group.skip_to_previous_track()
345
346 async def seek(self, position: int) -> None:
347 """
348 Handle SEEK command on the player.
349
350 Seek to a specific position in the current track.
351 Will only be called if the player reports PlayerFeature.SEEK is
352 supported and the player is NOT currently playing a MA queue.
353
354 :param position: The position to seek to, in seconds.
355 """
356 # sonos expects milliseconds
357 await self.client.player.group.seek(position * 1000)
358
359 async def play_media(
360 self,
361 media: PlayerMedia,
362 ) -> None:
363 """
364 Handle PLAY MEDIA command on given player.
365
366 This is called by the Player controller to start playing Media on the player,
367 which can be a MA queue item/stream or a native source.
368 The provider's own implementation should work out how to handle this request.
369
370 :param media: Details of the item that needs to be played on the player.
371 """
372 if self.client.player.is_passive:
373 # this should be already handled by the player manager, but just in case...
374 msg = (
375 f"Player {self.display_name} can not "
376 "accept play_media command, it is synced to another player."
377 )
378 raise PlayerCommandFailed(msg)
379 # for now always reset the active session
380 self.client.player.group.active_session_id = None
381 if airplay_player := self.get_linked_airplay_player(True):
382 # airplay mode is enabled, redirect the command
383 self.logger.debug("Redirecting PLAY_MEDIA command to linked airplay player.")
384 await self._play_media_airplay(airplay_player, media)
385 return
386 if media.source_id:
387 await self._set_sonos_queue_from_mass_queue(media.source_id)
388
389 if media.media_type == MediaType.ANNOUNCEMENT:
390 # We cannot use play_stream_url for announcements because Sonos treats those
391 # as duration less radio streams and will retry/loop them.
392 if not media.duration and media.custom_data:
393 announcement_url = media.custom_data.get("announcement_url", media.uri)
394 media_info = await async_parse_tags(announcement_url, require_duration=True)
395 media.duration = media_info.duration
396 media.queue_item_id = "announcement"
397 self.sonos_queue.items = [media]
398 self.sonos_queue.last_updated = time.time()
399 cloud_queue_url = f"{self.mass.streams.base_url}/sonos_queue/v2.3/"
400 await self.client.player.group.play_cloud_queue(
401 cloud_queue_url,
402 item_id=media.queue_item_id,
403 )
404 return
405
406 if (
407 not self.flow_mode and media.source_id and media.queue_item_id
408 ) or media.media_type == MediaType.PLUGIN_SOURCE:
409 # Regular Queue item playback
410 # create a sonos cloud queue and load it
411 cloud_queue_url = f"{self.mass.streams.base_url}/sonos_queue/v2.3/"
412 await self.client.player.group.play_cloud_queue(
413 cloud_queue_url,
414 item_id=media.queue_item_id,
415 )
416 return
417
418 # play duration-less (long running) radio streams
419 # enforce AAC here because Sonos really does not support FLAC streams without duration
420 media.uri = media.uri.replace(".flac", ".aac").replace(".wav", ".aac")
421 if media.source_id and media.queue_item_id:
422 object_id = f"mass:{media.source_id}:{media.queue_item_id}"
423 else:
424 object_id = media.uri
425 await self.client.player.group.play_stream_url(
426 media.uri,
427 {
428 "name": media.title,
429 "type": "track",
430 "imageUrl": media.image_url,
431 "id": {
432 "objectId": object_id,
433 },
434 "service": {"name": "Music Assistant", "id": "mass"},
435 },
436 )
437
438 async def select_source(self, source: str) -> None:
439 """
440 Handle SELECT SOURCE command on the player.
441
442 Will only be called if the PlayerFeature.SELECT_SOURCE is supported.
443
444 :param source: The source(id) to select, as defined in the source_list.
445 """
446 if source == SOURCE_LINE_IN:
447 await self.client.player.group.load_line_in(play_on_completion=True)
448 elif source == SOURCE_TV:
449 await self.client.player.load_home_theater_playback()
450 else:
451 # unsupported source - try to clear the queue/player
452 await self.stop()
453
454 async def enqueue_next_media(self, media: PlayerMedia) -> None:
455 """
456 Handle enqueuing of the next (queue) item on the player.
457
458 Called when player reports it started buffering a queue item
459 and when the queue items updated.
460
461 A PlayerProvider implementation is in itself responsible for handling this
462 so that the queue items keep playing until its empty or the player stopped.
463
464 Will only be called if the player reports PlayerFeature.ENQUEUE is
465 supported and the player is currently playing a MA queue.
466
467 This will NOT be called if the end of the queue is reached (and repeat disabled).
468 This will NOT be called if the player is using flow mode to playback the queue.
469
470 :param media: Details of the item that needs to be enqueued on the player.
471 """
472 if media.source_id:
473 await self._set_sonos_queue_from_mass_queue(media.source_id)
474 if session_id := self.client.player.group.active_session_id:
475 await self.client.api.playback_session.refresh_cloud_queue(session_id)
476
477 async def set_members(
478 self,
479 player_ids_to_add: list[str] | None = None,
480 player_ids_to_remove: list[str] | None = None,
481 ) -> None:
482 """
483 Handle SET_MEMBERS command on the player.
484
485 Group or ungroup the given child player(s) to/from this player.
486 Will only be called if the PlayerFeature.SET_MEMBERS is supported.
487
488 :param player_ids_to_add: List of player_id's to add to the group.
489 :param player_ids_to_remove: List of player_id's to remove from the group.
490 """
491 player_ids_to_add = player_ids_to_add or []
492 player_ids_to_remove = player_ids_to_remove or []
493 if airplay_player := self.get_linked_airplay_player(False):
494 # if airplay mode is enabled, we could possibly receive child player id's that are
495 # not Sonos players, but AirPlay players. We redirect those.
496 airplay_player_ids_to_add = {x for x in player_ids_to_add if x.startswith("ap")}
497 airplay_player_ids_to_remove = {x for x in player_ids_to_remove if x.startswith("ap")}
498 if airplay_player_ids_to_add or airplay_player_ids_to_remove:
499 await self.mass.players.cmd_set_members(
500 airplay_player.player_id,
501 player_ids_to_add=list(airplay_player_ids_to_add),
502 player_ids_to_remove=list(airplay_player_ids_to_remove),
503 )
504 sonos_player_ids_to_add = {x for x in player_ids_to_add if not x.startswith("ap")}
505 sonos_player_ids_to_remove = {x for x in player_ids_to_remove if not x.startswith("ap")}
506 if sonos_player_ids_to_add or sonos_player_ids_to_remove:
507 await self.client.player.group.modify_group_members(
508 player_ids_to_add=list(sonos_player_ids_to_add),
509 player_ids_to_remove=list(sonos_player_ids_to_remove),
510 )
511
512 async def ungroup(self) -> None:
513 """
514 Handle UNGROUP command on the player.
515
516 Remove the player from any (sync)groups it currently is grouped to.
517 If this player is the sync leader (or group player),
518 all child's will be ungrouped and the group dissolved.
519
520 Will only be called if the PlayerFeature.SET_MEMBERS is supported.
521 """
522 await self.client.player.leave_group()
523
524 async def play_announcement(
525 self, announcement: PlayerMedia, volume_level: int | None = None
526 ) -> None:
527 """
528 Handle (native) playback of an announcement on the player.
529
530 Will only be called if the PlayerFeature.PLAY_ANNOUNCEMENT is supported.
531
532 :param announcement: Details of the announcement that needs to be played on the player.
533 :param volume_level: The volume level to play the announcement at (0..100).
534 If not set, the player should use the current volume level.
535 """
536 self.logger.debug(
537 "Playing announcement %s on %s",
538 announcement.uri,
539 self.display_name,
540 )
541 await self.client.player.play_audio_clip(
542 announcement.uri, volume_level, name="Announcement"
543 )
544 # Wait until the announcement is finished playing
545 # This is helpful for people who want to play announcements in a sequence
546 # yeah we can also setup a subscription on the sonos player for this, but this is easier
547 media_info = await async_parse_tags(announcement.uri, require_duration=True)
548 duration = media_info.duration or 10
549 await asyncio.sleep(duration)
550
551 def on_player_event(self, event: SonosEvent | None) -> None:
552 """Handle incoming event from player."""
553 try:
554 self.update_attributes()
555 except Exception as err:
556 self.logger.exception("Failed to update player attributes: %s", err)
557 return
558 try:
559 self.update_state()
560 except Exception as err:
561 self.logger.exception("Failed to update player state: %s", err)
562
563 def update_attributes(self) -> None: # noqa: PLR0915
564 """Update the player attributes."""
565 self._attr_available = self.connected
566 if not self.connected:
567 return
568 if self.client.player.has_fixed_volume:
569 self._attr_volume_level = 100
570 else:
571 self._attr_volume_level = self.client.player.volume_level or 0
572 self._attr_volume_muted = self.client.player.volume_muted
573
574 group_parent = None
575 airplay_player = self.get_linked_airplay_player(False)
576 if self.client.player.is_coordinator:
577 # player is group coordinator
578 active_group = self.client.player.group
579 if len(self.client.player.group_members) > 1:
580 self._attr_group_members = list(self.client.player.group_members)
581 else:
582 self._attr_group_members.clear()
583 # append airplay child's to group childs
584 if self.airplay_mode_enabled and airplay_player:
585 airplay_childs = [
586 x for x in airplay_player._attr_group_members if x != airplay_player.player_id
587 ]
588 self._attr_group_members.extend(airplay_childs)
589 airplay_prov = airplay_player.provider
590 self._attr_can_group_with.update(
591 x.player_id
592 for x in airplay_prov.players
593 if x.player_id != airplay_player.player_id
594 )
595 else:
596 self._attr_can_group_with = {self._provider.instance_id}
597 else:
598 # player is group child (synced to another player)
599 group_parent: SonosPlayer = self.mass.players.get(
600 self.client.player.group.coordinator_id
601 )
602 if not group_parent or not group_parent.client or not group_parent.client.player:
603 # handle race condition where the group parent is not yet discovered
604 return
605 active_group = group_parent.client.player.group
606 self._attr_group_members.clear()
607
608 # map playback state
609 self._attr_playback_state = PLAYBACK_STATE_MAP[active_group.playback_state]
610 self._attr_elapsed_time = active_group.position
611
612 # figure out the active source based on the container
613 container_type = active_group.container_type
614 active_service = active_group.active_service
615 container = active_group.playback_metadata.get("container")
616 if (
617 not active_service
618 and container
619 and container.get("service", {}).get("id") == MusicService.MUSIC_ASSISTANT
620 ):
621 active_service = MusicService.MUSIC_ASSISTANT
622 if container_type == ContainerType.LINEIN:
623 self._attr_active_source = SOURCE_LINE_IN
624 elif container_type in (ContainerType.HOME_THEATER_HDMI, ContainerType.HOME_THEATER_SPDIF):
625 self._attr_active_source = SOURCE_TV
626 elif container_type == ContainerType.AIRPLAY:
627 # check if the MA airplay player is active
628 if airplay_player and airplay_player.playback_state in (
629 PlaybackState.PLAYING,
630 PlaybackState.PAUSED,
631 ):
632 self._attr_playback_state = airplay_player.playback_state
633 self._attr_active_source = airplay_player.active_source
634 self._attr_elapsed_time = airplay_player.elapsed_time
635 self._attr_elapsed_time_last_updated = airplay_player.elapsed_time_last_updated
636 self._attr_current_media = airplay_player.current_media
637 # return early as we dont need further info
638 return
639 self._attr_active_source = SOURCE_AIRPLAY
640 elif (
641 container_type == ContainerType.STATION
642 and active_service != MusicService.MUSIC_ASSISTANT
643 ):
644 self._attr_active_source = SOURCE_RADIO
645 # add radio to source list if not yet there
646 if SOURCE_RADIO not in [x.id for x in self._attr_source_list]:
647 self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_RADIO])
648 elif active_service == MusicService.SPOTIFY:
649 self._attr_active_source = SOURCE_SPOTIFY
650 # add spotify to source list if not yet there
651 if SOURCE_SPOTIFY not in [x.id for x in self._attr_source_list]:
652 self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_SPOTIFY])
653 elif active_service == MusicService.MUSIC_ASSISTANT:
654 if (object_id := container.get("id", {}).get("objectId")) and object_id.startswith(
655 "mass:"
656 ):
657 self._attr_active_source = object_id.split(":")[1]
658 else:
659 self._attr_active_source = None
660 # its playing some service we did not yet map
661 elif container and container.get("service", {}).get("name"):
662 self._attr_active_source = container["service"]["name"]
663 elif container and container.get("name"):
664 self._attr_active_source = container["name"]
665 elif active_service:
666 self._attr_active_source = active_service
667 elif container_type:
668 self._attr_active_source = container_type
669 else:
670 # the player has nothing loaded at all (empty queue and no service active)
671 self._attr_active_source = None
672
673 # special case: Sonos reports PAUSED state when MA stopped playback
674 if (
675 active_service == MusicService.MUSIC_ASSISTANT
676 and self._attr_playback_state == PlaybackState.PAUSED
677 ):
678 self._attr_playback_state = PlaybackState.IDLE
679
680 # parse current media
681 self._attr_elapsed_time = self.client.player.group.position
682 self._attr_elapsed_time_last_updated = time.time()
683 current_media = None
684 if (current_item := active_group.playback_metadata.get("currentItem")) and (
685 (track := current_item.get("track")) and track.get("name")
686 ):
687 track_images = track.get("images", [])
688 track_image_url = track_images[0].get("url") if track_images else None
689 track_duration_millis = track.get("durationMillis")
690 current_media = PlayerMedia(
691 uri=track.get("id", {}).get("objectId") or track.get("mediaUrl"),
692 media_type=MediaType.TRACK,
693 title=track["name"],
694 artist=track.get("artist", {}).get("name"),
695 album=track.get("album", {}).get("name"),
696 duration=track_duration_millis / 1000 if track_duration_millis else None,
697 image_url=track_image_url,
698 )
699 if active_service == MusicService.MUSIC_ASSISTANT:
700 current_media.source_id = self._attr_active_source
701 current_media.queue_item_id = current_item["id"]
702 # radio stream info
703 if container and container.get("name") and active_group.playback_metadata.get("streamInfo"):
704 images = container.get("images", [])
705 image_url = images[0].get("url") if images else None
706 current_media = PlayerMedia(
707 uri=container.get("id", {}).get("objectId"),
708 media_type=MediaType.RADIO,
709 title=active_group.playback_metadata["streamInfo"],
710 album=container["name"],
711 image_url=image_url,
712 )
713 # generic info from container (also when MA is playing!)
714 if container and container.get("name") and container.get("id"):
715 if not current_media:
716 current_media = PlayerMedia(
717 uri=container["id"]["objectId"], media_type=MediaType.UNKNOWN
718 )
719 if not current_media.image_url:
720 images = container.get("images", [])
721 current_media.image_url = images[0].get("url") if images else None
722 if not current_media.title:
723 current_media.title = container["name"]
724 if not current_media.uri:
725 current_media.uri = container["id"]["objectId"]
726
727 self._attr_current_media = current_media
728
729 def update_elapsed_time(self, elapsed_time: float | None = None) -> None:
730 """Update the elapsed time of the current media."""
731 if elapsed_time is not None:
732 self._attr_elapsed_time = elapsed_time
733 last_updated = time.time()
734 self._attr_elapsed_time_last_updated = last_updated
735 self.update_state()
736
737 async def _connect(self, retry_on_fail: int = 0) -> None:
738 """Connect to the Sonos player."""
739 if self.mass.closing:
740 return
741 if self._listen_task and not self._listen_task.done():
742 self.logger.debug("Already connected to Sonos player: %s", self.player_id)
743 return
744 try:
745 await self.client.connect()
746 except (ConnectionFailed, ClientConnectorError) as err:
747 self.logger.warning("Failed to connect to Sonos player: %s", err)
748 if not retry_on_fail or not self.mass.players.get(self.player_id):
749 raise
750 self._attr_available = False
751 self.update_state()
752 self.reconnect(min(retry_on_fail + 30, 3600))
753 return
754 self.connected = True
755 self.logger.debug("Connected to player API")
756 init_ready = asyncio.Event()
757
758 async def _listener() -> None:
759 try:
760 await self.client.start_listening(init_ready)
761 except Exception as err:
762 if not isinstance(err, ConnectionFailed | asyncio.CancelledError):
763 self.logger.exception("Error in Sonos player listener: %s", err)
764 finally:
765 self.logger.info("Disconnected from player API")
766 if self.connected and not self.mass.closing:
767 # we didn't explicitly disconnect, try to reconnect
768 # this should simply try to reconnect once and if that fails
769 # we rely on mdns to pick it up again later
770 await self._disconnect()
771 self._attr_available = False
772 self.update_state()
773 self.reconnect(5)
774
775 self._listen_task = self.mass.create_task(_listener())
776 await init_ready.wait()
777
778 def reconnect(self, delay: float = 1) -> None:
779 """Reconnect the player."""
780 if self.mass.closing:
781 return
782 # use a task_id to prevent multiple reconnects
783 task_id = f"sonos_reconnect_{self.player_id}"
784 self.mass.call_later(delay, self._connect, delay, task_id=task_id)
785
786 async def _disconnect(self) -> None:
787 """Disconnect the client and cleanup."""
788 self.connected = False
789 if self._listen_task and not self._listen_task.done():
790 self._listen_task.cancel()
791 if self.client:
792 await self.client.disconnect()
793 self.logger.debug("Disconnected from player API")
794
795 def _on_airplay_player_event(self, event: MassEvent) -> None:
796 """Handle incoming event from linked airplay player."""
797 if not self.mass.config.get_raw_player_config_value(self.player_id, CONF_AIRPLAY_MODE):
798 return
799 if event.object_id != self.airplay_player_id:
800 return
801 self.update_attributes()
802 self.update_state()
803
804 async def sync_play_modes(self, queue_id: str) -> None:
805 """Sync the play modes between MA and Sonos."""
806 queue = self.mass.player_queues.get(queue_id)
807 if not queue or queue.state not in (PlaybackState.PLAYING, PlaybackState.PAUSED):
808 return
809 repeat_single_enabled = queue.repeat_mode == RepeatMode.ONE
810 repeat_all_enabled = queue.repeat_mode == RepeatMode.ALL
811 play_modes = self.client.player.group.play_modes
812 if (
813 play_modes.repeat != repeat_all_enabled
814 or play_modes.repeat_one != repeat_single_enabled
815 ):
816 try:
817 await self.client.player.group.set_play_modes(
818 repeat=repeat_all_enabled,
819 repeat_one=repeat_single_enabled,
820 )
821 except FailedCommand as err:
822 if "groupCoordinatorChanged" not in str(err):
823 # this may happen at race conditions
824 raise
825
826 async def _play_media_airplay(
827 self,
828 airplay_player: Player,
829 media: PlayerMedia,
830 ) -> None:
831 """Handle PLAY MEDIA using the legacy upnp api."""
832 player_id = self.player_id
833 if (
834 airplay_player.playback_state == PlaybackState.PLAYING
835 and airplay_player.active_source == media.source_id
836 ):
837 # if the airplay player is already playing,
838 # the stream will be reused so no need to do the whole grouping thing below
839 await self.mass.players.play_media(airplay_player.player_id, media)
840 return
841
842 # Sonos has an annoying bug (for years already, and they dont seem to care),
843 # where it looses its sync childs when airplay playback is (re)started.
844 # Try to handle it here with this workaround.
845 org_group_childs = {x for x in self.client.player.group.player_ids if x != player_id}
846 if org_group_childs:
847 # ungroup all childs first
848 await self.client.player.group.modify_group_members(
849 player_ids_to_add=[], player_ids_to_remove=list(org_group_childs)
850 )
851 # start playback on the airplay player
852 await self.mass.players.play_media(airplay_player.player_id, media)
853 # re-add the original group childs to the sonos player if needed
854 if org_group_childs:
855 # wait a bit to let the airplay playback start
856 await asyncio.sleep(3)
857 await self.client.player.group.modify_group_members(
858 player_ids_to_add=list(org_group_childs),
859 player_ids_to_remove=[],
860 )
861
862 async def _play_media_legacy(
863 self,
864 media: PlayerMedia,
865 ) -> None:
866 """Handle PLAY MEDIA using the legacy upnp api."""
867 xml_data, soap_action = get_xml_soap_set_url(media)
868 player_ip = self.device_info.ip_address
869 async with self.mass.http_session_no_ssl.post(
870 f"http://{player_ip}:1400/MediaRenderer/AVTransport/Control",
871 headers={
872 "SOAPACTION": soap_action,
873 "Content-Type": "text/xml; charset=utf-8",
874 "Connection": "close",
875 },
876 data=xml_data,
877 ) as resp:
878 if resp.status != 200:
879 raise PlayerCommandFailed(
880 f"Failed to send command to Sonos player: {resp.status} {resp.reason}"
881 )
882 await self.play()
883
884 async def _enqueue_next_legacy(
885 self,
886 media: PlayerMedia,
887 ) -> None:
888 """Handle enqueuing of the next (queue) item on the player using legacy upnp api."""
889 xml_data, soap_action = get_xml_soap_set_next_url(media)
890 player_ip = self.device_info.ip_address
891 async with self.mass.http_session_no_ssl.post(
892 f"http://{player_ip}:1400/MediaRenderer/AVTransport/Control",
893 headers={
894 "SOAPACTION": soap_action,
895 "Content-Type": "text/xml; charset=utf-8",
896 "Connection": "close",
897 },
898 data=xml_data,
899 ) as resp:
900 if resp.status != 200:
901 raise PlayerCommandFailed(
902 f"Failed to send command to Sonos player: {resp.status} {resp.reason}"
903 )
904
905 async def _set_sonos_queue_from_mass_queue(self, queue_id: str) -> None:
906 """Set the SonosQueue items from the given MA PlayerQueue."""
907 items: list[PlayerMedia] = []
908 queue = self.mass.player_queues.get(queue_id)
909 if not queue:
910 self.sonos_queue.items.clear()
911 return
912 current_index = queue.current_index or 0
913 current_index = (
914 queue.index_in_buffer if queue.index_in_buffer is not None else current_index
915 )
916
917 # Add a few items before the current index for context
918 offset = max(0, current_index - 4)
919 for idx in range(offset, current_index):
920 if queue_item := self.mass.player_queues.get_item(queue_id, idx):
921 if queue_item.available:
922 media = await self.mass.player_queues.player_media_from_queue_item(
923 queue_item, False
924 )
925 items.append(media)
926
927 # Add the current item
928 if current_item := self.mass.player_queues.get_item(queue_id, current_index):
929 if current_item.available:
930 media = await self.mass.player_queues.player_media_from_queue_item(
931 current_item, False
932 )
933 items.append(media)
934
935 # Use get_next_item to fetch next items, which accounts for repeat mode
936 last_index: int | str = current_index
937 for _ in range(5):
938 next_item = self.mass.player_queues.get_next_item(queue_id, last_index)
939 if next_item is None:
940 break
941 media = await self.mass.player_queues.player_media_from_queue_item(next_item, False)
942 items.append(media)
943 last_index = next_item.queue_item_id
944
945 self.sonos_queue.items = items
946 self.logger.debug(
947 "Set Sonos queue items from MA queue %s: %s",
948 queue_id,
949 [x.title for x in self.sonos_queue.items],
950 )
951