/
/
/
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, cast
16
17from aiohttp import ClientError
18from aiosonos.api.models import Container, ContainerType, MusicService, SonosCapability
19from aiosonos.client import SonosLocalApiClient
20from aiosonos.const import EventType as SonosEventType
21from aiosonos.const import SonosEvent
22from aiosonos.exceptions import CannotConnect, ConnectionFailed, FailedCommand
23from music_assistant_models.enums import (
24 IdentifierType,
25 MediaType,
26 PlaybackState,
27 PlayerFeature,
28 RepeatMode,
29)
30from music_assistant_models.errors import PlayerCommandFailed
31from music_assistant_models.player import OutputProtocol, PlayerMedia
32
33from music_assistant.constants import (
34 CONF_ENTRY_HTTP_PROFILE_DEFAULT_2,
35 CONF_ENTRY_PREFER_WAV_FOR_LIVE_SOURCES_DEFAULT_ENABLED,
36 EXTERNAL_PAUSE_IDLE_TIMEOUT,
37 VERBOSE_LOG_LEVEL,
38)
39from music_assistant.helpers.util import is_valid_mac_address
40from music_assistant.models.player import Player
41from music_assistant.providers.sonos.const import (
42 NON_HIRES_MODELS,
43 PLAYBACK_STATE_MAP,
44 PLAYER_SOURCE_MAP,
45 PREVIOUS_ITEMS,
46 SOURCE_AIRPLAY,
47 SOURCE_LINE_IN,
48 SOURCE_RADIO,
49 SOURCE_SPOTIFY,
50 SOURCE_TV,
51 UNSUPPORTED_MODELS_NATIVE_ANNOUNCEMENTS,
52 UPCOMING_ITEMS,
53)
54
55if TYPE_CHECKING:
56 from aiosonos.api.models import DiscoveryInfo as SonosDiscoveryInfo
57 from aiosonos.group import SonosGroup
58 from music_assistant_models.config_entries import ConfigEntry
59 from music_assistant_models.queue_item import QueueItem
60
61 from .provider import SonosPlayerProvider
62
63SUPPORTED_FEATURES = {
64 PlayerFeature.PLAY_MEDIA,
65 PlayerFeature.PAUSE,
66 PlayerFeature.SEEK,
67 PlayerFeature.SELECT_SOURCE,
68 PlayerFeature.SET_MEMBERS,
69 PlayerFeature.GAPLESS_PLAYBACK,
70}
71
72
73@dataclass
74class SonosQueueWindow:
75 """A window of queue items as served to a Sonos speaker."""
76
77 items: list[PlayerMedia] = field(default_factory=list)
78 includes_beginning: bool = False
79 includes_end: bool = False
80
81
82class SonosPlayer(Player):
83 """Holds the details of the (discovered) Sonosplayer."""
84
85 _attr_external_pause_idle_timeout = EXTERNAL_PAUSE_IDLE_TIMEOUT
86
87 def __init__(
88 self,
89 prov: SonosPlayerProvider,
90 player_id: str,
91 discovery_info: SonosDiscoveryInfo,
92 ) -> None:
93 """Initialize the SonosPlayer."""
94 super().__init__(prov, player_id)
95 self.discovery_info = discovery_info
96 self.connected: bool = False
97 self._listen_task: asyncio.Task[None] | None = None
98 # the MA queue the loaded cloud queue serves, and the version the speaker
99 # compares against to decide whether its cached copy is still valid
100 self.cloud_queue_id: str | None = None
101 self.cloud_queue_version: float = time.time()
102 # advanced on every play_media: item ids are served suffixed with it, so a
103 # reload of the item the speaker is already playing gets a fresh identity.
104 # Clock-seeded (nanoseconds), so a recreated player never reuses a
105 # generation the speaker may still hold items under
106 self.cloud_queue_item_generation = time.time_ns()
107 self._announcement_media: PlayerMedia | None = None
108
109 @property
110 def group_controller(self) -> SonosGroup:
111 """Get the group controller, raising if unavailable."""
112 if self.client.player.group is None:
113 msg = "Group controller unavailable"
114 raise RuntimeError(msg)
115 return self.client.player.group
116
117 @property
118 def synced_to(self) -> str | None:
119 """
120 Return the id of the player this player is synced to (sync leader).
121
122 If this player is not synced to another player (or is the sync leader itself),
123 this should return None.
124 If it is part of a (permanent) group, this should also return None.
125 """
126 if self.client.player.is_coordinator:
127 return None
128 if self.client.player.group:
129 return self.client.player.group.coordinator_id
130 return None
131
132 async def setup(self) -> None:
133 """Handle setup of the player."""
134 assert self.device_info.ip_address is not None # for type checking
135 # connect the player first so we can fail early
136 self.client = SonosLocalApiClient(
137 self.device_info.ip_address, self.mass.http_session_no_ssl
138 )
139 await self._connect(False)
140
141 # collect supported features
142 _supported_features = SUPPORTED_FEATURES.copy()
143 if (
144 SonosCapability.AUDIO_CLIP in self.discovery_info["device"]["capabilities"]
145 and self.discovery_info["device"]["modelDisplayName"]
146 not in UNSUPPORTED_MODELS_NATIVE_ANNOUNCEMENTS
147 ):
148 _supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
149 if not self.client.player.has_fixed_volume:
150 _supported_features.add(PlayerFeature.VOLUME_SET)
151 _supported_features.add(PlayerFeature.VOLUME_MUTE)
152 _supported_features.add(PlayerFeature.NEXT_PREVIOUS)
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 # all current Sonos models accept up to 24-bit/48kHz; the older models in
165 # NON_HIRES_MODELS are limited to 16-bit playback
166 if self._attr_device_info.model in NON_HIRES_MODELS:
167 self._attr_supported_sample_rates = [(44100, 16), (48000, 16)]
168 else:
169 self._attr_supported_sample_rates = [
170 (44100, 16),
171 (48000, 16),
172 (44100, 24),
173 (48000, 24),
174 ]
175
176 # Add identifiers for matching with other protocols (like AirPlay, DLNA)
177 # The player_id is the Sonos UUID (e.g., RINCON_xxxxxxxxxxxx)
178 self._attr_device_info.add_identifier(IdentifierType.UUID, self.player_id)
179 # Extract MAC address from Sonos player_id (RINCON_XXXXXXXXXXXX01400)
180 # The middle part contains the MAC address (last 6 bytes in hex)
181 mac_address = self._extract_mac_from_player_id()
182 # Only add MAC address if it's valid (not 00:00:00:00:00:00)
183 if mac_address and is_valid_mac_address(mac_address):
184 self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
185
186 if SonosCapability.LINE_IN in self.discovery_info["device"]["capabilities"]:
187 self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_LINE_IN])
188 if SonosCapability.HT_PLAYBACK in self.discovery_info["device"]["capabilities"]:
189 self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_TV])
190 if SonosCapability.AIRPLAY in self.discovery_info["device"]["capabilities"]:
191 self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_AIRPLAY])
192
193 self.update_attributes()
194 await self.mass.players.register_or_update(self)
195
196 # register callback for state changed
197 self._on_unload_callbacks.append(
198 self.client.subscribe(
199 self.on_player_event,
200 (
201 SonosEventType.GROUP_UPDATED,
202 SonosEventType.PLAYER_UPDATED,
203 ),
204 )
205 )
206
207 async def get_config_entries(self) -> list[ConfigEntry]:
208 """Return all (provider/player specific) Config Entries for the player."""
209 return [
210 CONF_ENTRY_HTTP_PROFILE_DEFAULT_2,
211 CONF_ENTRY_PREFER_WAV_FOR_LIVE_SOURCES_DEFAULT_ENABLED,
212 ]
213
214 async def on_unload(self) -> None:
215 """Handle logic when the player is unloaded from the Player controller."""
216 await super().on_unload()
217 for task_id in (
218 f"sonos_reconnect_{self.player_id}",
219 f"restore_airplay_group_{self.player_id}",
220 ):
221 # a timer that already fired lives on as a task under the same id,
222 # so both are needed to cover the pending and the running case
223 self.mass.cancel_timer(task_id)
224 self.mass.cancel_task(task_id)
225 try:
226 await self._disconnect()
227 except Exception:
228 self.logger.exception("Error disconnecting from Sonos player %s", self.name)
229
230 async def volume_set(self, volume_level: int) -> None:
231 """
232 Handle VOLUME_SET command on the player.
233
234 Will only be called if the PlayerFeature.VOLUME_SET is supported.
235
236 :param volume_level: volume level (0..100) to set on the player.
237 """
238 await self.client.player.set_volume(volume_level)
239
240 async def volume_mute(self, muted: bool) -> None:
241 """
242 Handle VOLUME MUTE command on the player.
243
244 Will only be called if the PlayerFeature.VOLUME_MUTE is supported.
245
246 :param muted: bool if player should be muted.
247 """
248 await self.client.player.set_volume(muted=muted)
249
250 async def play(self) -> None:
251 """Handle PLAY command on the player."""
252 if self.client.player.is_passive:
253 self.logger.debug("Ignore PLAY command: Player is synced to another player.")
254 return
255 try:
256 await self.group_controller.play()
257 except FailedCommand as err:
258 if self._attr_active_source is None or "groupCoordinatorChanged" in str(err):
259 # only a source Sonos loaded itself can go away like this, and a coordinator
260 # change is a race condition rather than a source that disappeared
261 raise
262 # the loaded source refused to resume, so it is not merely paused after all
263 self.logger.debug(
264 "Source %s on Sonos player %s can not be resumed: %s",
265 self._attr_active_source,
266 self.player_id,
267 err,
268 )
269 self.mark_external_source_ended()
270 self.update_state()
271
272 async def stop(self) -> None:
273 """Handle STOP command on the player."""
274 self.mark_stop_called()
275 if self.client.player.is_passive:
276 self.logger.debug("Ignore STOP command: Player is synced to another player.")
277 return
278 await self.group_controller.stop()
279 self.cloud_queue_id = None
280 self._announcement_media = None
281 self.update_state()
282
283 async def pause(self) -> None:
284 """
285 Handle PAUSE command on the player.
286
287 Will only be called if the player reports PlayerFeature.PAUSE is supported.
288 """
289 if self.client.player.is_passive:
290 self.logger.debug("Ignore PAUSE command: Player is synced to another player.")
291 return
292 active_source = self.state.active_source
293 if active_source and self.mass.player_queues.get(active_source):
294 # Sonos seems to be bugged when playing our queue tracks and we send pause,
295 # it can't resume the current track and simply aborts/skips it
296 # so we stop the player instead.
297 # https://github.com/music-assistant/support/issues/3758
298 # TODO: revisit this later once we implemented support for range requests
299 # as I have the feeling the pause issue is related to seek support (=range requests)
300 await self.stop()
301 return
302 if not self.group_controller.playback_actions.can_pause:
303 await self.stop()
304 return
305 await self.group_controller.pause()
306
307 async def next_track(self) -> None:
308 """
309 Handle NEXT_TRACK command on the player.
310
311 Will only be called if the player reports PlayerFeature.NEXT_PREVIOUS
312 is supported and the player is not currently playing a MA queue.
313 """
314 await self.group_controller.skip_to_next_track()
315
316 async def previous_track(self) -> None:
317 """
318 Handle PREVIOUS_TRACK command on the player.
319
320 Will only be called if the player reports PlayerFeature.NEXT_PREVIOUS
321 is supported and the player is not currently playing a MA queue.
322 """
323 await self.group_controller.skip_to_previous_track()
324
325 async def seek(self, position: int) -> None:
326 """
327 Handle SEEK command on the player.
328
329 Seek to a specific position in the current track.
330 Will only be called if the player reports PlayerFeature.SEEK is
331 supported and the player is NOT currently playing a MA queue.
332
333 :param position: The position to seek to, in seconds.
334 """
335 # sonos expects milliseconds
336 await self.group_controller.seek(position * 1000)
337
338 async def play_media(
339 self,
340 media: PlayerMedia,
341 ) -> None:
342 """
343 Handle PLAY MEDIA command on given player.
344
345 This is called by the Player controller to start playing Media on the player,
346 which can be a MA queue item/stream or a native source.
347 The provider's own implementation should work out how to handle this request.
348
349 :param media: Details of the item that needs to be played on the player.
350 """
351 if self.client.player.is_passive:
352 # this should be already handled by the player manager, but just in case...
353 msg = (
354 f"Player {self.display_name} can not "
355 "accept play_media command, it is synced to another player."
356 )
357 raise PlayerCommandFailed(
358 msg,
359 translation_key="player_synced_cannot_play",
360 translation_owner=self.translation_owner,
361 translation_args=[self.display_name],
362 )
363 # for now always reset the active session
364 self.group_controller.active_session_id = None
365 # what is playing stays described until its replacement is loaded below: there are
366 # awaits in between, and an empty window served in that gap stops the current queue
367 self._announcement_media = None
368 self.cloud_queue_item_generation += 1
369 self.bump_cloud_queue_version()
370
371 if media.media_type == MediaType.ANNOUNCEMENT:
372 # We cannot use play_stream_url for announcements because Sonos treats those
373 # as duration less radio streams and will retry/loop them.
374 media.duration = await self.mass.streams.get_announcement_duration(media)
375 media.queue_item_id = "announcement"
376 self._announcement_media = media
377 cloud_queue_url = f"{self.mass.streams.base_url}/sonos_queue/{self.player_id}/v2.3/"
378 try:
379 await self.group_controller.play_cloud_queue(
380 cloud_queue_url,
381 item_id=self.wire_item_id(media.queue_item_id),
382 )
383 except Exception:
384 # the speaker never got the queue, so describing one is worse than
385 # admitting there is none - its session was reset above either way
386 self._announcement_media = None
387 raise
388 return
389
390 if not self.flow_mode and media.source_id and media.queue_item_id:
391 # Regular Queue item playback
392 # create a sonos cloud queue and load it
393 self.cloud_queue_id = media.source_id
394 cloud_queue_url = f"{self.mass.streams.base_url}/sonos_queue/{self.player_id}/v2.3/"
395 try:
396 await self.group_controller.play_cloud_queue(
397 cloud_queue_url,
398 item_id=self.wire_item_id(media.queue_item_id),
399 )
400 except Exception:
401 # the speaker never got the queue, so describing one is worse than
402 # admitting there is none - its session was reset above either way
403 self.cloud_queue_id = None
404 raise
405 # only now that the speaker accepted the load: it keeps playing the old
406 # audio until the cutover, but it will not act on the load while its
407 # current stream connection is still open, and a response blocked in a
408 # write never notices on its own that its session is gone
409 if media.source_id and media.queue_session_id:
410 self.mass.streams.close_superseded_item_streams(
411 media.source_id, media.queue_session_id
412 )
413 return
414
415 # play duration-less (long running) radio streams
416 # this path loads no cloud queue, so the speaker must not be signalled about one
417 self.cloud_queue_id = None
418 # enforce AAC here because Sonos really does not support FLAC streams without duration
419 stream_url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
420 stream_url = stream_url.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 = stream_url
425 container: Container = {
426 "_objectType": "container",
427 "name": media.title or "",
428 "type": "track",
429 "id": {
430 "_objectType": "id",
431 "objectId": object_id,
432 },
433 "service": {
434 "_objectType": "service",
435 "name": "Music Assistant",
436 "id": "mass",
437 },
438 }
439 if media.image_url:
440 container["imageUrl"] = media.image_url
441 await self.group_controller.play_stream_url(stream_url, container)
442 # same post-success sweep as the cloud-queue branch: a forced-flow queue
443 # (overlay) streams through here and its replaced session's response must
444 # die now that the speaker accepted the new stream
445 if media.source_id and media.queue_session_id:
446 self.mass.streams.close_superseded_item_streams(media.source_id, media.queue_session_id)
447
448 async def select_source(self, source: str) -> None:
449 """
450 Handle SELECT SOURCE command on the player.
451
452 Will only be called if the PlayerFeature.SELECT_SOURCE is supported.
453
454 :param source: The source(id) to select, as defined in the source_list.
455 """
456 # whatever the source turns out to be, it is not the cloud queue any more
457 self.cloud_queue_id = None
458 self._announcement_media = None
459 if source == SOURCE_LINE_IN:
460 await self.group_controller.load_line_in(play_on_completion=True)
461 elif source == SOURCE_TV:
462 await self.client.player.load_home_theater_playback()
463 else:
464 # unsupported source - try to clear the queue/player
465 await self.stop()
466
467 async def enqueue_next_media(self, media: PlayerMedia) -> None:
468 """
469 Handle enqueuing of the next (queue) item on the player.
470
471 Called when player reports it started buffering a queue item
472 and when the queue items updated.
473
474 A PlayerProvider implementation is in itself responsible for handling this
475 so that the queue items keep playing until its empty or the player stopped.
476
477 Will only be called if the player reports PlayerFeature.ENQUEUE is
478 supported and the player is currently playing a MA queue.
479
480 This will NOT be called if the end of the queue is reached (and repeat disabled).
481 This will NOT be called if the player is using flow mode to playback the queue.
482
483 :param media: Details of the item that needs to be enqueued on the player.
484 """
485 if media.source_id:
486 self.cloud_queue_id = media.source_id
487 await self.refresh_cloud_queue()
488
489 def wire_item_id(self, queue_item_id: str | None, generation: int | None = None) -> str | None:
490 """
491 Return the id a queue item is served under in the speaker's cloud queue.
492
493 :param queue_item_id: The MA queue item id, passed through when empty.
494 :param generation: The load generation to serve under, for a response that
495 must stay on the generation it started with; the current one otherwise.
496 """
497 # The speaker caches the track it loaded per item id: reloading the item it
498 # is already playing under the same id (a seek within the track) leaves it
499 # waiting out its stale stream for seconds before it fetches the new one.
500 # A generation suffix makes every reload cut over like a track change.
501 if not queue_item_id:
502 return queue_item_id
503 if generation is None:
504 generation = self.cloud_queue_item_generation
505 return f"{queue_item_id}@{generation}"
506
507 @staticmethod
508 def bare_item_id(item_id: str) -> str:
509 """
510 Return the MA queue item id behind an id the speaker echoes back.
511
512 :param item_id: The id as served to the speaker, tolerating unsuffixed ids.
513 """
514 return item_id.rsplit("@", 1)[0]
515
516 def bump_cloud_queue_version(self) -> None:
517 """
518 Advance the version the speaker compares its cached queue against.
519
520 An unchanged queueVersion reads as "nothing changed", so this must happen the moment
521 the queue does: a window served in between would carry a version read as current.
522 """
523 self.cloud_queue_version = time.time()
524
525 async def refresh_cloud_queue(self) -> None:
526 """Signal the speaker that the queue it is playing changed."""
527 self.bump_cloud_queue_version()
528 if not self.connected:
529 return
530 group = self.client.player.group
531 if group is None or not group.active_session_id:
532 return
533 try:
534 await self.client.api.playback_session.refresh_cloud_queue(group.active_session_id)
535 except FailedCommand as err:
536 # an app outside MA can take the session over, leaving us with a session id the
537 # speaker no longer knows. Only a nudge is lost: it reads a live window regardless.
538 self.logger.debug("Could not refresh the cloud queue: %s", err)
539
540 async def build_cloud_queue_window(
541 self,
542 item_id: str | None,
543 max_previous: int = PREVIOUS_ITEMS,
544 max_upcoming: int = UPCOMING_ITEMS,
545 ) -> SonosQueueWindow:
546 """
547 Return the item the speaker asked about and the one that follows it, as the queue is now.
548
549 :param item_id: queue_item_id the speaker asked about; an omitted or empty one asks
550 for the start of the queue.
551 :param max_previous: Ceiling on the items before the centre, if the speaker asked for
552 fewer than we would otherwise serve.
553 :param max_upcoming: Ceiling on the items after the centre, same.
554 """
555 if self._announcement_media is not None:
556 # an announcement is a queue of exactly one item
557 return SonosQueueWindow(
558 items=[self._announcement_media], includes_beginning=True, includes_end=True
559 )
560 queue_id = self.cloud_queue_id
561 if not queue_id or not (queue := self.mass.player_queues.get(queue_id)):
562 # nothing to describe: both ends flagged, or the speaker holds what it cached
563 return SonosQueueWindow(includes_beginning=True, includes_end=True)
564
565 if not item_id:
566 # an omitted or empty itemId asks for the start of the queue
567 center_index = 0
568 elif (found := self.mass.player_queues.index_by_id(queue_id, item_id)) is not None:
569 center_index = found
570 else:
571 # an item the queue no longer holds: answer around the playing one (not
572 # index_in_buffer, which runs an item ahead with crossfade)
573 center_index = (
574 queue.current_index
575 if queue.current_index is not None
576 else (queue.index_in_buffer or 0)
577 )
578
579 items: list[PlayerMedia] = []
580 offset = max(0, center_index - min(PREVIOUS_ITEMS, max_previous))
581 for idx in range(offset, center_index + 1):
582 queue_item = self.mass.player_queues.get_item(queue_id, idx)
583 if queue_item and queue_item.available:
584 items.append(await self._player_media_for_speaker(queue_item))
585
586 # get_next_item accounts for repeat mode, so this is the item that will really
587 # play next rather than whatever sits at the next index
588 last_index: int | str = center_index
589 for _ in range(min(UPCOMING_ITEMS, max_upcoming)):
590 next_item = self.mass.player_queues.get_next_item(queue_id, last_index)
591 if next_item is None:
592 break
593 items.append(await self._player_media_for_speaker(next_item))
594 last_index = next_item.queue_item_id
595
596 window = SonosQueueWindow(
597 items=items,
598 includes_beginning=offset == 0,
599 # check after the loop in case the window filled exactly up to the last item
600 includes_end=self.mass.player_queues.get_next_item(queue_id, last_index) is None,
601 )
602 self.logger.log(
603 VERBOSE_LOG_LEVEL,
604 "Serving Sonos queue window for %s on player %s: %s",
605 queue_id,
606 self.player_id,
607 [x.title for x in window.items],
608 )
609 return window
610
611 async def set_members(
612 self,
613 player_ids_to_add: list[str] | None = None,
614 player_ids_to_remove: list[str] | None = None,
615 ) -> None:
616 """
617 Handle SET_MEMBERS command on the player.
618
619 Group or ungroup the given child player(s) to/from this player.
620 Will only be called if the PlayerFeature.SET_MEMBERS is supported.
621
622 :param player_ids_to_add: List of player_id's to add to the group.
623 :param player_ids_to_remove: List of player_id's to remove from the group.
624 """
625 player_ids_to_add = player_ids_to_add or []
626 player_ids_to_remove = player_ids_to_remove or []
627 if player_ids_to_add or player_ids_to_remove:
628 await self.group_controller.modify_group_members(
629 player_ids_to_add=player_ids_to_add,
630 player_ids_to_remove=player_ids_to_remove,
631 )
632
633 async def ungroup(self) -> None:
634 """
635 Handle UNGROUP command on the player.
636
637 Remove the player from any (sync)groups it currently is grouped to.
638 If this player is the sync leader (or group player),
639 all child's will be ungrouped and the group dissolved.
640
641 Will only be called if the PlayerFeature.SET_MEMBERS is supported.
642 """
643 await self.client.player.leave_group()
644
645 async def play_announcement(
646 self, announcement: PlayerMedia, volume_level: int | None = None
647 ) -> None:
648 """
649 Handle (native) playback of an announcement on the player.
650
651 Will only be called if the PlayerFeature.PLAY_ANNOUNCEMENT is supported.
652
653 :param announcement: Details of the announcement that needs to be played on the player.
654 :param volume_level: The volume level to play the announcement at (0..100).
655 If not set, the player should use the current volume level.
656 """
657 self.logger.debug(
658 "Playing announcement %s on %s",
659 announcement.uri,
660 self.display_name,
661 )
662 await self.client.player.play_audio_clip(
663 announcement.uri, volume_level, name="Announcement"
664 )
665 # Wait until the announcement is finished playing
666 # This is helpful for people who want to play announcements in a sequence
667 # yeah we can also setup a subscription on the sonos player for this, but this is easier
668 duration = await self.mass.streams.get_announcement_duration(announcement)
669 await asyncio.sleep(duration or 10)
670
671 def on_player_event(self, event: SonosEvent | None) -> None:
672 """Handle incoming event from player."""
673 try:
674 self.update_attributes()
675 except Exception:
676 self.logger.exception("Failed to update player attributes")
677 return
678 try:
679 self.update_state()
680 except Exception:
681 self.logger.exception("Failed to update player state")
682
683 def update_attributes(self) -> None: # noqa: PLR0915
684 """Update the player attributes."""
685 self._attr_available = self.connected
686 if not self.connected:
687 return
688 # guard against the race where a volume event arrives before aiosonos'
689 # async_init has populated _volume_data (the accessors raise AttributeError
690 # on None). The next event re-runs once the data is there.
691 try:
692 has_fixed_volume = self.client.player.has_fixed_volume
693 volume_muted = self.client.player.volume_muted
694 volume_level = self.client.player.volume_level
695 except AttributeError:
696 pass
697 else:
698 if has_fixed_volume:
699 self._attr_volume_level = 100
700 elif not volume_muted or volume_level:
701 self._attr_volume_level = volume_level or 0
702 self._attr_volume_muted = volume_muted
703
704 group_parent: SonosPlayer | None = None
705 active_group: SonosGroup | None
706 if self.client.player.is_coordinator:
707 # player is group coordinator - always report native group members
708 active_group = self.group_controller
709 if len(self.client.player.group_members) > 1:
710 self._attr_group_members = list(self.client.player.group_members)
711 else:
712 self._attr_group_members.clear()
713 self._attr_can_group_with = {self._provider.instance_id}
714 else:
715 # player is group child (synced to another player)
716 group_parent = cast(
717 "SonosPlayer | None",
718 self.mass.players.get_player(self.group_controller.coordinator_id),
719 )
720 if not group_parent or not group_parent.client or not group_parent.client.player:
721 # handle race condition where the group parent is not yet discovered
722 return
723 active_group = group_parent.client.player.group
724 self._attr_group_members.clear()
725
726 if not active_group:
727 # should not happen, but guard it anyways
728 return
729
730 # map playback state
731 self._attr_playback_state = PLAYBACK_STATE_MAP[active_group.playback_state]
732 self._attr_elapsed_time = active_group.position
733
734 # figure out the active source based on the container
735 container_type = active_group.container_type
736 active_service = active_group.active_service
737 container = active_group.playback_metadata.get("container")
738 if (
739 not active_service
740 and container
741 and container.get("service", {}).get("id") == MusicService.MUSIC_ASSISTANT
742 ):
743 active_service = MusicService.MUSIC_ASSISTANT
744 if container_type == ContainerType.LINEIN:
745 self._attr_active_source = SOURCE_LINE_IN
746 elif container_type in (ContainerType.HOME_THEATER_HDMI, ContainerType.HOME_THEATER_SPDIF):
747 self._attr_active_source = SOURCE_TV
748 elif container_type == ContainerType.AIRPLAY and self.active_output_protocol not in (
749 "airplay",
750 "sendspin",
751 ):
752 self._attr_active_source = SOURCE_AIRPLAY
753 elif (
754 container_type == ContainerType.STATION
755 and active_service != MusicService.MUSIC_ASSISTANT
756 ):
757 self._attr_active_source = SOURCE_RADIO
758 # add radio to source list if not yet there
759 if SOURCE_RADIO not in [x.id for x in self._attr_source_list]:
760 self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_RADIO])
761 elif active_service == MusicService.SPOTIFY:
762 self._attr_active_source = SOURCE_SPOTIFY
763 # add spotify to source list if not yet there
764 if SOURCE_SPOTIFY not in [x.id for x in self._attr_source_list]:
765 self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_SPOTIFY])
766 elif active_service == MusicService.MUSIC_ASSISTANT:
767 # setting active source to None is fine
768 self._attr_active_source = None
769 # its playing some service we did not yet map
770 elif container and container.get("service", {}).get("name"):
771 self._attr_active_source = container["service"]["name"]
772 elif container and container.get("name"):
773 self._attr_active_source = container["name"]
774 elif active_service:
775 self._attr_active_source = active_service
776 elif container_type:
777 self._attr_active_source = container_type
778 else:
779 # the player has nothing loaded at all (empty queue and no service active)
780 self._attr_active_source = None
781
782 # special case: Sonos reports PAUSED state when MA stopped playback
783 if (
784 active_service == MusicService.MUSIC_ASSISTANT
785 and self._attr_playback_state == PlaybackState.PAUSED
786 ):
787 self._attr_playback_state = PlaybackState.IDLE
788
789 # parse current media
790 self._attr_elapsed_time = active_group.position
791 self._attr_elapsed_time_last_updated = time.time()
792 current_media = None
793 if (current_item := active_group.playback_metadata.get("currentItem")) and (
794 (track := current_item.get("track")) and track.get("name")
795 ):
796 track_images = track.get("images", [])
797 track_image_url = track_images[0].get("url") if track_images else None
798 track_duration_millis = track.get("durationMillis")
799 current_media = PlayerMedia(
800 uri=track.get("id", {}).get("objectId") or track.get("mediaUrl") or "",
801 media_type=MediaType.TRACK,
802 title=track["name"],
803 artist=track.get("artist", {}).get("name"),
804 album=track.get("album", {}).get("name"),
805 duration=int(track_duration_millis / 1000) if track_duration_millis else None,
806 image_url=track_image_url,
807 )
808 if active_service == MusicService.MUSIC_ASSISTANT:
809 current_media.source_id = self._attr_active_source
810 current_media.queue_item_id = self.bare_item_id(current_item["id"])
811 # radio stream info
812 if container and container.get("name") and active_group.playback_metadata.get("streamInfo"):
813 images = container.get("images", [])
814 image_url = images[0].get("url") if images else None
815 current_media = PlayerMedia(
816 uri=container.get("id", {}).get("objectId") or "",
817 media_type=MediaType.RADIO,
818 title=active_group.playback_metadata["streamInfo"],
819 album=container["name"],
820 image_url=image_url,
821 )
822 # generic info from container (also when MA is playing!)
823 if container and container.get("name") and container.get("id"):
824 if not current_media:
825 current_media = PlayerMedia(
826 uri=container["id"]["objectId"], media_type=MediaType.UNKNOWN
827 )
828 if not current_media.image_url:
829 images = container.get("images", [])
830 current_media.image_url = images[0].get("url") if images else None
831 if not current_media.title:
832 current_media.title = container["name"]
833 if not current_media.uri:
834 current_media.uri = container["id"]["objectId"]
835
836 self._attr_current_media = current_media
837
838 async def on_protocol_playback(
839 self,
840 output_protocol: OutputProtocol,
841 ) -> None:
842 """Handle callback when playback starts on a protocol output."""
843 # Only handle AirPlay protocol
844 if output_protocol.protocol_domain != "airplay":
845 return
846
847 # Only if this player is a coordinator with group members
848 if not self.client.player.is_coordinator:
849 return
850
851 current_members = list(self.client.player.group_members)
852 if len(current_members) <= 1:
853 # No group members to worry about
854 return
855
856 # Workaround for Sonos AirPlay ungrouping bug: when AirPlay playback starts
857 # on a Sonos speaker that has native group members, Sonos dissolves the group.
858 # We capture the group state here and restore it after a delay.
859
860 self.logger.debug(
861 "AirPlay playback starting on %s with native group members %s - "
862 "scheduling restoration to work around Sonos ungrouping bug",
863 self.name,
864 current_members,
865 )
866 members_to_restore = [m for m in current_members if m != self.player_id]
867
868 async def _restore_airplay_group() -> None:
869 try:
870 self.logger.info(
871 "Restoring AirPlay group for %s with members %s",
872 self.name,
873 members_to_restore,
874 )
875 # we call set_members on the PlayerController here so it
876 # can try to regroup via the preferred protocol (which may be AirPlay),
877 await self.set_members(player_ids_to_add=members_to_restore)
878 except Exception as err:
879 self.logger.warning("Failed to restore AirPlay group: %s", err)
880
881 # Schedule restoration after 6 seconds to let AirPlay settle
882 self.mass.call_later(
883 6,
884 _restore_airplay_group,
885 task_id=f"restore_airplay_group_{self.player_id}",
886 )
887
888 def update_elapsed_time(self, elapsed_time: float | None = None) -> None:
889 """Update the elapsed time of the current media."""
890 if elapsed_time is not None:
891 self._attr_elapsed_time = elapsed_time
892 last_updated = time.time()
893 self._attr_elapsed_time_last_updated = last_updated
894 self.update_state()
895
896 def reconnect(self, delay: float = 1) -> None:
897 """Reconnect the player."""
898 if self.mass.closing:
899 return
900 # use a task_id to prevent multiple reconnects
901 task_id = f"sonos_reconnect_{self.player_id}"
902 self.mass.call_later(delay, self._connect, delay, task_id=task_id)
903
904 async def sync_play_modes(self, queue_id: str) -> None:
905 """Sync the play modes between MA and Sonos."""
906 queue = self.mass.player_queues.get(queue_id)
907 if not queue or queue.state not in (PlaybackState.PLAYING, PlaybackState.PAUSED):
908 return
909 repeat_single_enabled = queue.repeat_mode == RepeatMode.ONE
910 repeat_all_enabled = queue.repeat_mode == RepeatMode.ALL
911 if not self.client.player.group:
912 return
913 play_modes = self.group_controller.play_modes
914 if (
915 play_modes.repeat != repeat_all_enabled
916 or play_modes.repeat_one != repeat_single_enabled
917 ):
918 try:
919 await self.group_controller.set_play_modes(
920 repeat=repeat_all_enabled,
921 repeat_one=repeat_single_enabled,
922 )
923 except FailedCommand as err:
924 if "groupCoordinatorChanged" not in str(err):
925 # this may happen at race conditions
926 raise
927
928 async def _connect(self, retry_on_fail: int = 0) -> None:
929 """Connect to the Sonos player."""
930 if self.mass.closing:
931 return
932 if self._listen_task and not self._listen_task.done():
933 self.logger.debug("Already connected to Sonos player: %s", self.player_id)
934 return
935 try:
936 await self.client.connect()
937 except (ConnectionFailed, CannotConnect, ClientError) as err:
938 self.logger.warning("Failed to connect to Sonos player: %s", err)
939 if not retry_on_fail or not self.mass.players.get_player(self.player_id):
940 raise
941 self._attr_available = False
942 self.update_state()
943 self.reconnect(min(retry_on_fail + 30, 3600))
944 return
945 self.connected = True
946 self.logger.debug("Connected to player API")
947 init_ready = asyncio.Event()
948
949 async def _listener() -> None:
950 try:
951 await self.client.start_listening(init_ready)
952 except Exception as err:
953 if not isinstance(err, ConnectionFailed | asyncio.CancelledError):
954 self.logger.exception("Error in Sonos player listener")
955 finally:
956 self.logger.info("Disconnected from player API")
957 if self.connected and not self.mass.closing:
958 # we didn't explicitly disconnect, try to reconnect
959 # this should simply try to reconnect once and if that fails
960 # we rely on mdns to pick it up again later
961 await self._disconnect()
962 self._attr_available = False
963 self.update_state()
964 self.reconnect(5)
965
966 self._listen_task = self.mass.create_task(_listener())
967 await init_ready.wait()
968
969 async def _disconnect(self) -> None:
970 """Disconnect the client and cleanup."""
971 self.connected = False
972 if self._listen_task and not self._listen_task.done():
973 self._listen_task.cancel()
974 if self.client:
975 await self.client.disconnect()
976 self.logger.debug("Disconnected from player API")
977
978 async def _player_media_for_speaker(self, queue_item: QueueItem) -> PlayerMedia:
979 """Return the media for a queue item, with its stream URL resolved for this player."""
980 media = await self.mass.player_queues.player_media_from_queue_item(queue_item)
981 media.uri = await self.mass.streams.resolve_stream_url(self.player_id, media)
982 return media
983
984 def _extract_mac_from_player_id(self) -> str | None:
985 """
986 Extract MAC address from Sonos player_id.
987
988 Sonos player_ids follow the format RINCON_XXXXXXXXXXXX01400 where
989 the middle 12 hex characters represent the MAC address.
990
991 :return: MAC address string in XX:XX:XX:XX:XX:XX format, or None if not extractable.
992 """
993 # Remove RINCON_ prefix if present
994 player_id = self.player_id
995 player_id = player_id.removeprefix("RINCON_") # Remove "RINCON_"
996
997 # Remove the 01400 suffix (or similar) - should be last 5 chars
998 if len(player_id) >= 17: # 12 hex chars for MAC + 5 chars suffix
999 mac_hex = player_id[:12]
1000 else:
1001 return None
1002
1003 # Validate it looks like a MAC (all hex characters)
1004 try:
1005 int(mac_hex, 16)
1006 except ValueError:
1007 return None
1008
1009 # Format as XX:XX:XX:XX:XX:XX
1010 return ":".join(mac_hex[i : i + 2].upper() for i in range(0, 12, 2))
1011