/
/
/
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"""
7
8from __future__ import annotations
9
10import logging
11from typing import TYPE_CHECKING, Any, cast
12
13from aiohttp import web
14from aiohttp.client_exceptions import ClientError
15from aiosonos.api.models import SonosCapability
16from aiosonos.utils import get_discovery_info
17from music_assistant_models.enums import IdentifierType
18from zeroconf import ServiceStateChange
19
20from music_assistant.constants import (
21 CONF_ENTRY_MANUAL_DISCOVERY_IPS,
22 CONF_LOG_LEVEL,
23 MASS_LOGO_ONLINE,
24 VERBOSE_LOG_LEVEL,
25)
26from music_assistant.helpers.audio import get_mime_type
27from music_assistant.helpers.json import SerializableType
28from music_assistant.models.player_provider import PlayerProvider
29
30from .helpers import get_primary_ip_address
31from .player import SonosPlayer
32
33if TYPE_CHECKING:
34 from music_assistant_models.config_entries import ConfigEntry, ProviderConfig
35 from music_assistant_models.player import PlayerMedia
36 from zeroconf.asyncio import AsyncServiceInfo
37
38
39class SonosPlayerProvider(PlayerProvider):
40 """Sonos Player provider."""
41
42 _ignored_disabled_players: set[str]
43 _pending_setup_tasks: set[str]
44 _unloaded: bool
45
46 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
47 """Return Config entries to setup this provider."""
48 return (CONF_ENTRY_MANUAL_DISCOVERY_IPS,)
49
50 async def handle_async_init(self) -> None:
51 """Handle async initialization of the provider."""
52 self._ignored_disabled_players = set()
53 self._pending_setup_tasks = set()
54 self._unloaded = False
55 self._set_aiosonos_log_level()
56 self.mass.streams.register_dynamic_route(
57 "/sonos_queue/*", self._handle_sonos_cloud_queue_request
58 )
59
60 async def loaded_in_mass(self) -> None:
61 """Call after the provider has been loaded."""
62 await super().loaded_in_mass()
63 # Handle config option for manual IP's
64 manual_ip_config = cast(
65 "list[str]", self.config.get_value(CONF_ENTRY_MANUAL_DISCOVERY_IPS.key)
66 )
67 for ip_address in manual_ip_config:
68 try:
69 # get discovery info from SONOS speaker so we can provide an ID & other info
70 discovery_info = await get_discovery_info(self.mass.http_session_no_ssl, ip_address)
71 except ClientError as err:
72 self.logger.debug(
73 "Ignoring %s (manual IP) as it is not reachable: %s", ip_address, str(err)
74 )
75 continue
76 player_id = discovery_info["device"]["id"]
77 sonos_player = SonosPlayer(self, player_id, discovery_info=discovery_info)
78 sonos_player.device_info.add_identifier(IdentifierType.IP_ADDRESS, ip_address)
79 await sonos_player.setup()
80
81 async def unload(self, is_removed: bool = False) -> None:
82 """Handle close/cleanup of the provider."""
83 self._unloaded = True
84 self.mass.streams.unregister_dynamic_route("/sonos_queue/*")
85 for task_id in self._pending_setup_tasks:
86 # a timer that already fired lives on as a task under the same id,
87 # so both are needed to cover the pending and the running case
88 self.mass.cancel_timer(task_id)
89 self.mass.cancel_task(task_id)
90 self._pending_setup_tasks.clear()
91
92 async def update_config(self, config: ProviderConfig, changed_keys: set[str]) -> None:
93 """Handle logic when the config is updated."""
94 await super().update_config(config, changed_keys)
95 # a log level(-only) change does not reload the provider,
96 # so realign aiosonos's logger here
97 if f"values/{CONF_LOG_LEVEL}" in changed_keys:
98 self._set_aiosonos_log_level()
99
100 async def get_diagnostics(self) -> dict[str, SerializableType]:
101 """Return diagnostics info for this provider to include in diagnostics reports."""
102 sonos_players = [player for player in self.players if isinstance(player, SonosPlayer)]
103 # active_output_protocol holds "native" or the player id of the protocol player in use
104 active_protocols = [
105 player.active_output_protocol
106 for player in sonos_players
107 if player.active_output_protocol
108 ]
109 return {
110 "speakers_total": len(sonos_players),
111 "speakers_connected": sum(player.connected for player in sonos_players),
112 "coordinators": sum(
113 player.client.player.is_coordinator for player in sonos_players if player.connected
114 ),
115 "native_playback": sum(protocol == "native" for protocol in active_protocols),
116 "protocol_playback": sum(protocol != "native" for protocol in active_protocols),
117 }
118
119 async def on_mdns_service_state_change(
120 self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None
121 ) -> None:
122 """Handle MDNS service state callback."""
123 if self._unloaded:
124 # discovery resolves an announcement before it dispatches it, so a callback
125 # picked up before the unload can still arrive after it
126 return
127 if state_change == ServiceStateChange.Removed:
128 # we don't listen for removed players here.
129 # instead we just wait for the player connection to fail
130 return
131 assert info is not None # for type checking
132 if "uuid" not in info.decoded_properties:
133 # not a S2 player
134 return
135 name = name.split("@", 1)[1] if "@" in name else name
136 player_id = info.decoded_properties["uuid"]
137 assert isinstance(player_id, str) # for type checking
138 # handle update for existing device
139 if sonos_player := self.mass.players.get_player(player_id):
140 assert isinstance(sonos_player, SonosPlayer), (
141 "Player ID already exists but is not a SonosPlayer"
142 )
143 # if mass_player := sonos_player.mass_player:
144 cur_address = get_primary_ip_address(info)
145 if cur_address and cur_address != sonos_player.device_info.ip_address:
146 sonos_player.logger.debug(
147 "Address updated from %s to %s",
148 sonos_player.device_info.ip_address,
149 cur_address,
150 )
151 sonos_player.device_info.add_identifier(IdentifierType.IP_ADDRESS, cur_address)
152 if not sonos_player.connected and cur_address:
153 self.logger.debug("Player back online: %s", sonos_player.display_name)
154 sonos_player.client.player_ip = cur_address
155 # schedule reconnect
156 sonos_player.reconnect()
157 self.mass.players.trigger_player_update(player_id)
158 return
159 if self._ignore_disabled_discovery(player_id, name):
160 return
161 # handle new player setup in a delayed task because mdns announcements
162 # can arrive in (duplicated) bursts
163 task_id = f"setup_sonos_{player_id}"
164 self._pending_setup_tasks.add(task_id)
165 self.mass.call_later(5, self._setup_player, player_id, name, info, task_id=task_id)
166
167 def _set_aiosonos_log_level(self) -> None:
168 """Align aiosonos's log level with the provider's log level."""
169 # aiosonos is very chatty at debug level, so only pass through its
170 # debug logging when verbose logging is enabled
171 if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
172 logging.getLogger("aiosonos").setLevel(logging.DEBUG)
173 else:
174 logging.getLogger("aiosonos").setLevel(self.logger.level + 10)
175
176 async def _setup_player(self, player_id: str, name: str, info: AsyncServiceInfo) -> None:
177 """Handle setup of a new player that is discovered using mdns."""
178 if self.mass.players.get_player(player_id):
179 msg = f"Player {player_id} already exists"
180 raise ValueError(msg)
181 if self._ignore_disabled_discovery(player_id, name):
182 return
183 address = get_primary_ip_address(info)
184 if address is None:
185 return
186 try:
187 discovery_info = await get_discovery_info(self.mass.http_session_no_ssl, address)
188 except ClientError as err:
189 self.logger.debug("Ignoring %s in discovery as it is not reachable: %s", name, str(err))
190 return
191 display_name = discovery_info["device"].get("name") or name
192 if SonosCapability.PLAYBACK not in discovery_info["device"]["capabilities"]:
193 # this will happen for satellite speakers in a surround/stereo setup
194 self.logger.debug(
195 "Ignoring %s in discovery as it is a passive satellite.", display_name
196 )
197 return
198 self.logger.debug("Discovered Sonos device %s on %s", name, address)
199 sonos_player = SonosPlayer(self, player_id, discovery_info=discovery_info)
200 sonos_player.device_info.add_identifier(IdentifierType.IP_ADDRESS, address)
201 await sonos_player.setup()
202
203 def _ignore_disabled_discovery(self, player_id: str, name: str) -> bool:
204 """
205 Return whether discovery should ignore a disabled player.
206
207 :param player_id: The discovered Sonos player ID.
208 :param name: The discovered Sonos service name.
209 """
210 if self.mass.config.get_raw_player_config_value(player_id, "enabled", True):
211 self._ignored_disabled_players.discard(player_id)
212 return False
213 if player_id not in self._ignored_disabled_players:
214 self.logger.debug("Ignoring %s in discovery as it is disabled.", name)
215 self._ignored_disabled_players.add(player_id)
216 return True
217
218 async def _handle_sonos_cloud_queue_request(self, request: web.Request) -> web.Response:
219 """
220 Handle the Sonos CloudQueue request.
221
222 https://docs.sonos.com/reference/itemwindow
223 """
224 self.logger.log(
225 VERBOSE_LOG_LEVEL,
226 "Cloud Queue request\n - path: %s\n - query: %s\n",
227 request.path,
228 request.query,
229 )
230 path_parts = request.path.strip("/").split("/")
231 if len(path_parts) != 4 or path_parts[0] != "sonos_queue":
232 return web.Response(status=404)
233 player_id = path_parts[1]
234 if not (sonos_player := self.mass.players.get_player(player_id)):
235 return web.Response(status=501)
236 if TYPE_CHECKING:
237 assert isinstance(sonos_player, SonosPlayer)
238 endpoint = path_parts[3]
239 if endpoint == "itemWindow":
240 return await self._handle_sonos_queue_itemwindow(sonos_player, request)
241 if endpoint == "version":
242 return await self._handle_sonos_queue_version(sonos_player, request)
243 if endpoint == "context":
244 return await self._handle_sonos_queue_context(sonos_player, request)
245 if endpoint == "timePlayed":
246 return await self._handle_sonos_queue_time_played(sonos_player, request)
247 return web.Response(status=404)
248
249 async def _handle_sonos_queue_itemwindow(
250 self, player: SonosPlayer, request: web.Request
251 ) -> web.Response:
252 """
253 Handle the Sonos CloudQueue ItemWindow endpoint.
254
255 https://docs.sonos.com/reference/itemwindow
256 """
257 context_version = request.query.get("contextVersion", "1")
258 # because Sonos does not show our queue in the app anyways,
259 # we just return the previous, current and next item in the queue.
260 # the beginning/end flags must be honest though: signalling end-of-queue
261 # tells Sonos to drop any older items it may still have cached past our
262 # window, which is what prevents stale tracks from resurrecting after a
263 # queue rewrite (e.g. replace_next).
264 items = list(player.sonos_queue.items)
265 result = {
266 "includesBeginningOfQueue": player.sonos_queue.includes_beginning,
267 "includesEndOfQueue": player.sonos_queue.includes_end,
268 "contextVersion": context_version,
269 # report the version of the items we actually serve (the current window) instead of
270 # echoing the player's requested version, otherwise a refreshed window keeps a stale
271 # version label and Sonos never realises it changed.
272 "queueVersion": str(player.sonos_queue.last_updated),
273 "items": [self._parse_sonos_queue_item(x) for x in items],
274 }
275 return web.json_response(result)
276
277 async def _handle_sonos_queue_version(
278 self, player: SonosPlayer, request: web.Request
279 ) -> web.Response:
280 """
281 Handle the Sonos CloudQueue Version endpoint.
282
283 https://docs.sonos.com/reference/version
284 """
285 context_version = request.query.get("contextVersion") or "1"
286 # keep sub-second resolution: the window can be rebuilt several times within the same
287 # second and Sonos treats an unchanged queueVersion as "nothing changed" (stale window).
288 result = {
289 "contextVersion": context_version,
290 "queueVersion": str(player.sonos_queue.last_updated),
291 }
292 return web.json_response(result)
293
294 async def _handle_sonos_queue_context(
295 self, player: SonosPlayer, request: web.Request
296 ) -> web.Response:
297 """
298 Handle the Sonos CloudQueue Context endpoint.
299
300 https://docs.sonos.com/reference/context
301 """
302 result = {
303 "contextVersion": "1",
304 "queueVersion": str(player.sonos_queue.last_updated),
305 "container": {
306 "type": "trackList",
307 "name": "Music Assistant",
308 "imageUrl": MASS_LOGO_ONLINE,
309 "service": {"name": "Music Assistant", "id": "mass"},
310 "id": {
311 "serviceId": "mass",
312 "objectId": f"mass:{player.sonos_queue.items[-1].source_id}"
313 if player.sonos_queue.items
314 else "mass:unknown",
315 "accountId": "",
316 },
317 },
318 "reports": {
319 "sendUpdateAfterMillis": 1000,
320 "periodicIntervalMillis": 30000,
321 "sendPlaybackActions": True,
322 },
323 "playbackPolicies": {
324 "canSkip": True,
325 "limitedSkips": True,
326 "canSkipToItem": True, # unsure
327 "canSkipBack": True,
328 # seek needs to be disabled because we dont properly support range requests
329 "canSeek": False,
330 "canRepeat": False, # handled by MA queue controller
331 "canRepeatOne": False, # synced from MA queue controller
332 "canCrossfade": False, # handled by MA queue controller
333 "canShuffle": False, # handled by MA queue controller
334 },
335 }
336 return web.json_response(result)
337
338 async def _handle_sonos_queue_time_played(
339 self, player: SonosPlayer, request: web.Request
340 ) -> web.Response:
341 """
342 Handle the Sonos CloudQueue TimePlayed endpoint.
343
344 https://docs.sonos.com/reference/timeplayed
345 """
346 json_body = await request.json()
347 for item in json_body["items"]:
348 if item["type"] != "update":
349 continue
350 if "positionMillis" not in item:
351 continue
352 if player.current_media and player.current_media.queue_item_id == item["id"]:
353 player.update_elapsed_time(item["positionMillis"] / 1000)
354 break
355 return web.Response(status=204)
356
357 def _parse_sonos_queue_item(self, media: PlayerMedia) -> dict[str, Any]:
358 """Parse MusicAssistant PlayerMedia to a Sonos Media (queue) object."""
359 # the speaker tracks its position within the audio we serve, which is
360 # shorter than the media item when playback starts at a seek position
361 duration = media.stream_duration or media.duration
362 return {
363 "id": media.queue_item_id or media.uri,
364 "track": {
365 "type": "track",
366 "mediaUrl": media.uri,
367 "contentType": get_mime_type(media.uri.split(".")[-1]),
368 "service": {"name": "Music Assistant", "id": "mass"},
369 "name": media.title,
370 "imageUrl": media.image_url,
371 "durationMillis": int(duration * 1000) if duration else 0,
372 "artist": {
373 "name": media.artist,
374 }
375 if media.artist
376 else None,
377 "album": {
378 "name": media.album,
379 }
380 if media.album
381 else None,
382 },
383 }
384