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