/
/
/
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 (
390 not self.flow_mode and media.source_id and media.queue_item_id
391 ) or media.media_type == MediaType.PLUGIN_SOURCE:
392 # Regular Queue item playback
393 # create a sonos cloud queue and load it
394 cloud_queue_url = f"{self.mass.streams.base_url}/sonos_queue/v2.3/"
395 await self.client.player.group.play_cloud_queue(
396 cloud_queue_url,
397 item_id=media.queue_item_id,
398 )
399 return
400
401 # play duration-less (long running) radio streams
402 # enforce AAC here because Sonos really does not support FLAC streams without duration
403 media.uri = media.uri.replace(".flac", ".aac").replace(".wav", ".aac")
404 if media.source_id and media.queue_item_id:
405 object_id = f"mass:{media.source_id}:{media.queue_item_id}"
406 else:
407 object_id = media.uri
408 await self.client.player.group.play_stream_url(
409 media.uri,
410 {
411 "name": media.title,
412 "type": "track",
413 "imageUrl": media.image_url,
414 "id": {
415 "objectId": object_id,
416 },
417 "service": {"name": "Music Assistant", "id": "mass"},
418 },
419 )
420
421 async def select_source(self, source: str) -> None:
422 """
423 Handle SELECT SOURCE command on the player.
424
425 Will only be called if the PlayerFeature.SELECT_SOURCE is supported.
426
427 :param source: The source(id) to select, as defined in the source_list.
428 """
429 if source == SOURCE_LINE_IN:
430 await self.client.player.group.load_line_in(play_on_completion=True)
431 elif source == SOURCE_TV:
432 await self.client.player.load_home_theater_playback()
433 else:
434 # unsupported source - try to clear the queue/player
435 await self.stop()
436
437 async def enqueue_next_media(self, media: PlayerMedia) -> None:
438 """
439 Handle enqueuing of the next (queue) item on the player.
440
441 Called when player reports it started buffering a queue item
442 and when the queue items updated.
443
444 A PlayerProvider implementation is in itself responsible for handling this
445 so that the queue items keep playing until its empty or the player stopped.
446
447 Will only be called if the player reports PlayerFeature.ENQUEUE is
448 supported and the player is currently playing a MA queue.
449
450 This will NOT be called if the end of the queue is reached (and repeat disabled).
451 This will NOT be called if the player is using flow mode to playback the queue.
452
453 :param media: Details of the item that needs to be enqueued on the player.
454 """
455 if media.source_id:
456 await self._set_sonos_queue_from_mass_queue(media.source_id)
457 if session_id := self.client.player.group.active_session_id:
458 await self.client.api.playback_session.refresh_cloud_queue(session_id)
459
460 async def set_members(
461 self,
462 player_ids_to_add: list[str] | None = None,
463 player_ids_to_remove: list[str] | None = None,
464 ) -> None:
465 """
466 Handle SET_MEMBERS command on the player.
467
468 Group or ungroup the given child player(s) to/from this player.
469 Will only be called if the PlayerFeature.SET_MEMBERS is supported.
470
471 :param player_ids_to_add: List of player_id's to add to the group.
472 :param player_ids_to_remove: List of player_id's to remove from the group.
473 """
474 player_ids_to_add = player_ids_to_add or []
475 player_ids_to_remove = player_ids_to_remove or []
476 if airplay_player := self.get_linked_airplay_player(False):
477 # if airplay mode is enabled, we could possibly receive child player id's that are
478 # not Sonos players, but AirPlay players. We redirect those.
479 airplay_player_ids_to_add = {x for x in player_ids_to_add if x.startswith("ap")}
480 airplay_player_ids_to_remove = {x for x in player_ids_to_remove if x.startswith("ap")}
481 if airplay_player_ids_to_add or airplay_player_ids_to_remove:
482 await self.mass.players.cmd_set_members(
483 airplay_player.player_id,
484 player_ids_to_add=list(airplay_player_ids_to_add),
485 player_ids_to_remove=list(airplay_player_ids_to_remove),
486 )
487 sonos_player_ids_to_add = {x for x in player_ids_to_add if not x.startswith("ap")}
488 sonos_player_ids_to_remove = {x for x in player_ids_to_remove if not x.startswith("ap")}
489 if sonos_player_ids_to_add or sonos_player_ids_to_remove:
490 await self.client.player.group.modify_group_members(
491 player_ids_to_add=list(sonos_player_ids_to_add),
492 player_ids_to_remove=list(sonos_player_ids_to_remove),
493 )
494
495 async def ungroup(self) -> None:
496 """
497 Handle UNGROUP command on the player.
498
499 Remove the player from any (sync)groups it currently is grouped to.
500 If this player is the sync leader (or group player),
501 all child's will be ungrouped and the group dissolved.
502
503 Will only be called if the PlayerFeature.SET_MEMBERS is supported.
504 """
505 await self.client.player.leave_group()
506
507 async def play_announcement(
508 self, announcement: PlayerMedia, volume_level: int | None = None
509 ) -> None:
510 """
511 Handle (native) playback of an announcement on the player.
512
513 Will only be called if the PlayerFeature.PLAY_ANNOUNCEMENT is supported.
514
515 :param announcement: Details of the announcement that needs to be played on the player.
516 :param volume_level: The volume level to play the announcement at (0..100).
517 If not set, the player should use the current volume level.
518 """
519 self.logger.debug(
520 "Playing announcement %s on %s",
521 announcement.uri,
522 self.display_name,
523 )
524 await self.client.player.play_audio_clip(
525 announcement.uri, volume_level, name="Announcement"
526 )
527 # Wait until the announcement is finished playing
528 # This is helpful for people who want to play announcements in a sequence
529 # yeah we can also setup a subscription on the sonos player for this, but this is easier
530 media_info = await async_parse_tags(announcement.uri, require_duration=True)
531 duration = media_info.duration or 10
532 await asyncio.sleep(duration)
533
534 def on_player_event(self, event: SonosEvent | None) -> None:
535 """Handle incoming event from player."""
536 try:
537 self.update_attributes()
538 except Exception as err:
539 self.logger.exception("Failed to update player attributes: %s", err)
540 return
541 try:
542 self.update_state()
543 except Exception as err:
544 self.logger.exception("Failed to update player state: %s", err)
545
546 def update_attributes(self) -> None: # noqa: PLR0915
547 """Update the player attributes."""
548 self._attr_available = self.connected
549 if not self.connected:
550 return
551 if self.client.player.has_fixed_volume:
552 self._attr_volume_level = 100
553 else:
554 self._attr_volume_level = self.client.player.volume_level or 0
555 self._attr_volume_muted = self.client.player.volume_muted
556
557 group_parent = None
558 airplay_player = self.get_linked_airplay_player(False)
559 if self.client.player.is_coordinator:
560 # player is group coordinator
561 active_group = self.client.player.group
562 if len(self.client.player.group_members) > 1:
563 self._attr_group_members = list(self.client.player.group_members)
564 else:
565 self._attr_group_members.clear()
566 # append airplay child's to group childs
567 if self.airplay_mode_enabled and airplay_player:
568 airplay_childs = [
569 x for x in airplay_player._attr_group_members if x != airplay_player.player_id
570 ]
571 self._attr_group_members.extend(airplay_childs)
572 airplay_prov = airplay_player.provider
573 self._attr_can_group_with.update(
574 x.player_id
575 for x in airplay_prov.players
576 if x.player_id != airplay_player.player_id
577 )
578 else:
579 self._attr_can_group_with = {self._provider.instance_id}
580 else:
581 # player is group child (synced to another player)
582 group_parent: SonosPlayer = self.mass.players.get(
583 self.client.player.group.coordinator_id
584 )
585 if not group_parent or not group_parent.client or not group_parent.client.player:
586 # handle race condition where the group parent is not yet discovered
587 return
588 active_group = group_parent.client.player.group
589 self._attr_group_members.clear()
590
591 # map playback state
592 self._attr_playback_state = PLAYBACK_STATE_MAP[active_group.playback_state]
593 self._attr_elapsed_time = active_group.position
594
595 # figure out the active source based on the container
596 container_type = active_group.container_type
597 active_service = active_group.active_service
598 container = active_group.playback_metadata.get("container")
599 if (
600 not active_service
601 and container
602 and container.get("service", {}).get("id") == MusicService.MUSIC_ASSISTANT
603 ):
604 active_service = MusicService.MUSIC_ASSISTANT
605 if container_type == ContainerType.LINEIN:
606 self._attr_active_source = SOURCE_LINE_IN
607 elif container_type in (ContainerType.HOME_THEATER_HDMI, ContainerType.HOME_THEATER_SPDIF):
608 self._attr_active_source = SOURCE_TV
609 elif container_type == ContainerType.AIRPLAY:
610 # check if the MA airplay player is active
611 if airplay_player and airplay_player.playback_state in (
612 PlaybackState.PLAYING,
613 PlaybackState.PAUSED,
614 ):
615 self._attr_playback_state = airplay_player.playback_state
616 self._attr_active_source = airplay_player.active_source
617 self._attr_elapsed_time = airplay_player.elapsed_time
618 self._attr_elapsed_time_last_updated = airplay_player.elapsed_time_last_updated
619 self._attr_current_media = airplay_player.current_media
620 # return early as we dont need further info
621 return
622 self._attr_active_source = SOURCE_AIRPLAY
623 elif (
624 container_type == ContainerType.STATION
625 and active_service != MusicService.MUSIC_ASSISTANT
626 ):
627 self._attr_active_source = SOURCE_RADIO
628 # add radio to source list if not yet there
629 if SOURCE_RADIO not in [x.id for x in self._attr_source_list]:
630 self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_RADIO])
631 elif active_service == MusicService.SPOTIFY:
632 self._attr_active_source = SOURCE_SPOTIFY
633 # add spotify to source list if not yet there
634 if SOURCE_SPOTIFY not in [x.id for x in self._attr_source_list]:
635 self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_SPOTIFY])
636 elif active_service == MusicService.MUSIC_ASSISTANT:
637 if (object_id := container.get("id", {}).get("objectId")) and object_id.startswith(
638 "mass:"
639 ):
640 self._attr_active_source = object_id.split(":")[1]
641 else:
642 self._attr_active_source = None
643 # its playing some service we did not yet map
644 elif container and container.get("service", {}).get("name"):
645 self._attr_active_source = container["service"]["name"]
646 elif container and container.get("name"):
647 self._attr_active_source = container["name"]
648 elif active_service:
649 self._attr_active_source = active_service
650 elif container_type:
651 self._attr_active_source = container_type
652 else:
653 # the player has nothing loaded at all (empty queue and no service active)
654 self._attr_active_source = None
655
656 # special case: Sonos reports PAUSED state when MA stopped playback
657 if (
658 active_service == MusicService.MUSIC_ASSISTANT
659 and self._attr_playback_state == PlaybackState.PAUSED
660 ):
661 self._attr_playback_state = PlaybackState.IDLE
662
663 # parse current media
664 self._attr_elapsed_time = self.client.player.group.position
665 self._attr_elapsed_time_last_updated = time.time()
666 current_media = None
667 if (current_item := active_group.playback_metadata.get("currentItem")) and (
668 (track := current_item.get("track")) and track.get("name")
669 ):
670 track_images = track.get("images", [])
671 track_image_url = track_images[0].get("url") if track_images else None
672 track_duration_millis = track.get("durationMillis")
673 current_media = PlayerMedia(
674 uri=track.get("id", {}).get("objectId") or track.get("mediaUrl"),
675 media_type=MediaType.TRACK,
676 title=track["name"],
677 artist=track.get("artist", {}).get("name"),
678 album=track.get("album", {}).get("name"),
679 duration=track_duration_millis / 1000 if track_duration_millis else None,
680 image_url=track_image_url,
681 )
682 if active_service == MusicService.MUSIC_ASSISTANT:
683 current_media.source_id = self._attr_active_source
684 current_media.queue_item_id = current_item["id"]
685 # radio stream info
686 if container and container.get("name") and active_group.playback_metadata.get("streamInfo"):
687 images = container.get("images", [])
688 image_url = images[0].get("url") if images else None
689 current_media = PlayerMedia(
690 uri=container.get("id", {}).get("objectId"),
691 media_type=MediaType.RADIO,
692 title=active_group.playback_metadata["streamInfo"],
693 album=container["name"],
694 image_url=image_url,
695 )
696 # generic info from container (also when MA is playing!)
697 if container and container.get("name") and container.get("id"):
698 if not current_media:
699 current_media = PlayerMedia(
700 uri=container["id"]["objectId"], media_type=MediaType.UNKNOWN
701 )
702 if not current_media.image_url:
703 images = container.get("images", [])
704 current_media.image_url = images[0].get("url") if images else None
705 if not current_media.title:
706 current_media.title = container["name"]
707 if not current_media.uri:
708 current_media.uri = container["id"]["objectId"]
709
710 self._attr_current_media = current_media
711
712 def update_elapsed_time(self, elapsed_time: float | None = None) -> None:
713 """Update the elapsed time of the current media."""
714 if elapsed_time is not None:
715 self._attr_elapsed_time = elapsed_time
716 last_updated = time.time()
717 self._attr_elapsed_time_last_updated = last_updated
718 self.update_state()
719
720 async def _connect(self, retry_on_fail: int = 0) -> None:
721 """Connect to the Sonos player."""
722 if self.mass.closing:
723 return
724 if self._listen_task and not self._listen_task.done():
725 self.logger.debug("Already connected to Sonos player: %s", self.player_id)
726 return
727 try:
728 await self.client.connect()
729 except (ConnectionFailed, ClientConnectorError) as err:
730 self.logger.warning("Failed to connect to Sonos player: %s", err)
731 if not retry_on_fail or not self.mass.players.get(self.player_id):
732 raise
733 self._attr_available = False
734 self.update_state()
735 self.reconnect(min(retry_on_fail + 30, 3600))
736 return
737 self.connected = True
738 self.logger.debug("Connected to player API")
739 init_ready = asyncio.Event()
740
741 async def _listener() -> None:
742 try:
743 await self.client.start_listening(init_ready)
744 except Exception as err:
745 if not isinstance(err, ConnectionFailed | asyncio.CancelledError):
746 self.logger.exception("Error in Sonos player listener: %s", err)
747 finally:
748 self.logger.info("Disconnected from player API")
749 if self.connected and not self.mass.closing:
750 # we didn't explicitly disconnect, try to reconnect
751 # this should simply try to reconnect once and if that fails
752 # we rely on mdns to pick it up again later
753 await self._disconnect()
754 self._attr_available = False
755 self.update_state()
756 self.reconnect(5)
757
758 self._listen_task = self.mass.create_task(_listener())
759 await init_ready.wait()
760
761 def reconnect(self, delay: float = 1) -> None:
762 """Reconnect the player."""
763 if self.mass.closing:
764 return
765 # use a task_id to prevent multiple reconnects
766 task_id = f"sonos_reconnect_{self.player_id}"
767 self.mass.call_later(delay, self._connect, delay, task_id=task_id)
768
769 async def _disconnect(self) -> None:
770 """Disconnect the client and cleanup."""
771 self.connected = False
772 if self._listen_task and not self._listen_task.done():
773 self._listen_task.cancel()
774 if self.client:
775 await self.client.disconnect()
776 self.logger.debug("Disconnected from player API")
777
778 def _on_airplay_player_event(self, event: MassEvent) -> None:
779 """Handle incoming event from linked airplay player."""
780 if not self.mass.config.get_raw_player_config_value(self.player_id, CONF_AIRPLAY_MODE):
781 return
782 if event.object_id != self.airplay_player_id:
783 return
784 self.update_attributes()
785 self.update_state()
786
787 async def sync_play_modes(self, queue_id: str) -> None:
788 """Sync the play modes between MA and Sonos."""
789 queue = self.mass.player_queues.get(queue_id)
790 if not queue or queue.state not in (PlaybackState.PLAYING, PlaybackState.PAUSED):
791 return
792 repeat_single_enabled = queue.repeat_mode == RepeatMode.ONE
793 repeat_all_enabled = queue.repeat_mode == RepeatMode.ALL
794 play_modes = self.client.player.group.play_modes
795 if (
796 play_modes.repeat != repeat_all_enabled
797 or play_modes.repeat_one != repeat_single_enabled
798 ):
799 try:
800 await self.client.player.group.set_play_modes(
801 repeat=repeat_all_enabled,
802 repeat_one=repeat_single_enabled,
803 )
804 except FailedCommand as err:
805 if "groupCoordinatorChanged" not in str(err):
806 # this may happen at race conditions
807 raise
808
809 async def _play_media_airplay(
810 self,
811 airplay_player: Player,
812 media: PlayerMedia,
813 ) -> None:
814 """Handle PLAY MEDIA using the legacy upnp api."""
815 player_id = self.player_id
816 if (
817 airplay_player.playback_state == PlaybackState.PLAYING
818 and airplay_player.active_source == media.source_id
819 ):
820 # if the airplay player is already playing,
821 # the stream will be reused so no need to do the whole grouping thing below
822 await self.mass.players.play_media(airplay_player.player_id, media)
823 return
824
825 # Sonos has an annoying bug (for years already, and they dont seem to care),
826 # where it looses its sync childs when airplay playback is (re)started.
827 # Try to handle it here with this workaround.
828 org_group_childs = {x for x in self.client.player.group.player_ids if x != player_id}
829 if org_group_childs:
830 # ungroup all childs first
831 await self.client.player.group.modify_group_members(
832 player_ids_to_add=[], player_ids_to_remove=list(org_group_childs)
833 )
834 # start playback on the airplay player
835 await self.mass.players.play_media(airplay_player.player_id, media)
836 # re-add the original group childs to the sonos player if needed
837 if org_group_childs:
838 # wait a bit to let the airplay playback start
839 await asyncio.sleep(3)
840 await self.client.player.group.modify_group_members(
841 player_ids_to_add=list(org_group_childs),
842 player_ids_to_remove=[],
843 )
844
845 async def _play_media_legacy(
846 self,
847 media: PlayerMedia,
848 ) -> None:
849 """Handle PLAY MEDIA using the legacy upnp api."""
850 xml_data, soap_action = get_xml_soap_set_url(media)
851 player_ip = self.device_info.ip_address
852 async with self.mass.http_session_no_ssl.post(
853 f"http://{player_ip}:1400/MediaRenderer/AVTransport/Control",
854 headers={
855 "SOAPACTION": soap_action,
856 "Content-Type": "text/xml; charset=utf-8",
857 "Connection": "close",
858 },
859 data=xml_data,
860 ) as resp:
861 if resp.status != 200:
862 raise PlayerCommandFailed(
863 f"Failed to send command to Sonos player: {resp.status} {resp.reason}"
864 )
865 await self.play()
866
867 async def _enqueue_next_legacy(
868 self,
869 media: PlayerMedia,
870 ) -> None:
871 """Handle enqueuing of the next (queue) item on the player using legacy upnp api."""
872 xml_data, soap_action = get_xml_soap_set_next_url(media)
873 player_ip = self.device_info.ip_address
874 async with self.mass.http_session_no_ssl.post(
875 f"http://{player_ip}:1400/MediaRenderer/AVTransport/Control",
876 headers={
877 "SOAPACTION": soap_action,
878 "Content-Type": "text/xml; charset=utf-8",
879 "Connection": "close",
880 },
881 data=xml_data,
882 ) as resp:
883 if resp.status != 200:
884 raise PlayerCommandFailed(
885 f"Failed to send command to Sonos player: {resp.status} {resp.reason}"
886 )
887
888 async def _set_sonos_queue_from_mass_queue(self, queue_id: str) -> None:
889 """Set the SonosQueue items from the given MA PlayerQueue."""
890 items: list[PlayerMedia] = []
891 queue = self.mass.player_queues.get(queue_id)
892 if not queue:
893 self.sonos_queue.items.clear()
894 return
895 current_index = queue.current_index or 0
896
897 # Add a few items before the current index for context
898 offset = max(0, current_index - 4)
899 for idx in range(offset, current_index):
900 if queue_item := self.mass.player_queues.get_item(queue_id, idx):
901 if queue_item.available:
902 media = await self.mass.player_queues.player_media_from_queue_item(
903 queue_item, False
904 )
905 items.append(media)
906
907 # Add the current item
908 if current_item := self.mass.player_queues.get_item(queue_id, current_index):
909 if current_item.available:
910 media = await self.mass.player_queues.player_media_from_queue_item(
911 current_item, False
912 )
913 items.append(media)
914
915 # Use get_next_item to fetch next items, which accounts for repeat mode
916 last_index: int | str = current_index
917 for _ in range(5):
918 next_item = self.mass.player_queues.get_next_item(queue_id, last_index)
919 if next_item is None:
920 break
921 media = await self.mass.player_queues.player_media_from_queue_item(next_item, False)
922 items.append(media)
923 last_index = next_item.queue_item_id
924
925 self.sonos_queue.items = items
926 self.logger.debug(
927 "Set Sonos queue items from MA queue %s: %s",
928 queue_id,
929 [x.title for x in self.sonos_queue.items],
930 )
931