/
/
/
1"""Unit tests for the Sonos S1 player."""
2
3from __future__ import annotations
4
5import asyncio
6import threading
7import time
8from functools import partial
9from typing import TYPE_CHECKING, cast
10from unittest.mock import AsyncMock, MagicMock, patch
11
12import pytest
13from music_assistant_models.enums import IdentifierType, MediaType, PlaybackState, PlayerFeature
14from music_assistant_models.errors import PlayerCommandFailed
15from soco.core import SoCo
16from soco.exceptions import SoCoException
17
18from music_assistant.mass import MusicAssistant
19from music_assistant.models.player import PlayerMedia
20from music_assistant.providers.sonos_s1 import player as player_module
21from music_assistant.providers.sonos_s1.constants import (
22 AVAILABILITY_TIMEOUT,
23 POLL_INTERVAL,
24 SOURCE_LINEIN,
25 SUBSCRIPTION_SERVICES,
26 TRANSITION_POLL_INTERVAL,
27)
28from music_assistant.providers.sonos_s1.helpers import SonosUpdateError
29from music_assistant.providers.sonos_s1.player import SonosPlayer
30
31if TYPE_CHECKING:
32 from collections.abc import AsyncGenerator
33
34STREAM_URL = "http://192.168.1.2:8097/single/sessionabc/queue1/item1/player1.flac"
35
36
37def _make_soco(uid: str = "RINCON_000E58AAAAAA01400", name: str = "Test Sonos") -> MagicMock:
38 """Create a mocked soco device."""
39 soco = MagicMock()
40 soco.uid = uid
41 soco.household_id = "Sonos_household"
42 soco.player_name = name
43 soco._player_name = name
44 soco.ip_address = "127.0.0.1"
45 soco.speaker_info = {"model_name": "Sonos Play:1"}
46 return soco
47
48
49@pytest.fixture
50def sonos_player() -> SonosPlayer:
51 """Create a SonosPlayer with a mocked soco device and provider."""
52 provider = MagicMock()
53 provider.mass.streams.resolve_stream_url = AsyncMock(return_value=STREAM_URL)
54 return SonosPlayer(provider=provider, soco=_make_soco(), fixed_volume=False)
55
56
57@pytest.fixture
58async def timer_mass() -> AsyncGenerator[MusicAssistant]:
59 """Create a bare MusicAssistant exposing the real call_later/cancel_timer machinery."""
60 mass = object.__new__(MusicAssistant)
61 mass.loop = asyncio.get_running_loop()
62 mass.loop_thread_id = threading.get_ident()
63 mass._tracked_timers = {}
64 mass._tracked_tasks = {}
65 mass.config = MagicMock()
66 mass.players = MagicMock()
67 mass.players.all_players.return_value = []
68 yield mass
69 for handle in mass._tracked_timers.values():
70 handle.cancel()
71 for task in mass._tracked_tasks.values():
72 task.cancel()
73
74
75def _make_player(mass: MusicAssistant, uid: str, name: str) -> SonosPlayer:
76 """Create a SonosPlayer bound to the given MusicAssistant."""
77 provider = MagicMock()
78 provider.mass = mass
79 provider.topology_condition = asyncio.Condition()
80 return SonosPlayer(provider=provider, soco=_make_soco(uid, name), fixed_volume=False)
81
82
83def _poll_id(player: SonosPlayer) -> str:
84 """Return the task id that debounces the follow-up poll of the given speaker."""
85 return f"sonos_poll_{player.player_id}"
86
87
88def _pending_polls(mass: MusicAssistant) -> list[str]:
89 """Return the task ids of all pending speaker polls."""
90 return sorted(task_id for task_id in mass._tracked_timers if task_id.startswith("sonos_poll_"))
91
92
93def _make_media() -> PlayerMedia:
94 """Return PlayerMedia as built by the queue controller, with an MA media uri."""
95 return PlayerMedia(
96 uri="library://track/123",
97 media_type=MediaType.TRACK,
98 title="Test Track",
99 artist="Test Artist",
100 album="Test Album",
101 duration=180,
102 source_id="queue1",
103 queue_item_id="item1",
104 )
105
106
107async def test_enqueue_next_media_builds_didl_from_stream_url(
108 sonos_player: SonosPlayer,
109) -> None:
110 """The enqueue metadata res element must contain the stream url, not the MA media uri."""
111 await sonos_player.enqueue_next_media(_make_media())
112 call_args = sonos_player.soco.avTransport.SetNextAVTransportURI.call_args
113 args = dict(call_args.args[0])
114 assert args["NextURI"] == STREAM_URL
115 assert STREAM_URL in args["NextURIMetaData"]
116 assert "library://track/123" not in args["NextURIMetaData"]
117
118
119async def test_play_media_builds_didl_from_stream_url(sonos_player: SonosPlayer) -> None:
120 """The play metadata res element must contain the stream url, not the MA media uri."""
121 await sonos_player.play_media(_make_media())
122 call_args = sonos_player.soco.play_uri.call_args
123 assert call_args.args[0] == STREAM_URL
124 assert STREAM_URL in call_args.kwargs["meta"]
125 assert "library://track/123" not in call_args.kwargs["meta"]
126
127
128def test_pause_is_advertised_as_a_supported_feature(sonos_player: SonosPlayer) -> None:
129 """Without the feature the player controller converts every pause into a stop."""
130 assert PlayerFeature.PAUSE in sonos_player.supported_features
131
132
133def test_volume_is_advertised_for_a_regular_speaker(sonos_player: SonosPlayer) -> None:
134 """A speaker with its own amplifier is driven over its native volume control."""
135 assert PlayerFeature.VOLUME_SET in sonos_player.supported_features
136 assert PlayerFeature.VOLUME_MUTE in sonos_player.supported_features
137
138
139def test_volume_is_not_advertised_for_a_fixed_volume_speaker() -> None:
140 """A speaker with fixed line-out rejects volume commands, so it must not offer them."""
141 player = SonosPlayer(provider=MagicMock(), soco=_make_soco(), fixed_volume=True)
142 assert PlayerFeature.VOLUME_SET not in player.supported_features
143 assert PlayerFeature.VOLUME_MUTE not in player.supported_features
144
145
146class _RecordingSoco:
147 """Minimal soco stand-in that records the thread its speaker query ran on."""
148
149 def __init__(self, actions: list[str]) -> None:
150 self._actions = actions
151 self.query_threads: list[int] = []
152 self.paused = False
153
154 @property
155 def available_actions(self) -> list[str]:
156 """Return the transport actions the speaker currently offers."""
157 self.query_threads.append(threading.get_ident())
158 return self._actions
159
160 def pause(self) -> None:
161 """Pause playback on the speaker."""
162 self.paused = True
163
164
165async def test_pause_queries_the_speaker_off_the_event_loop(sonos_player: SonosPlayer) -> None:
166 """Asking a speaker whether it can pause must not stall the event loop."""
167 soco = _RecordingSoco(["Play", "Stop", "Pause"])
168 sonos_player.soco = soco
169
170 await sonos_player.pause()
171
172 assert len(soco.query_threads) == 1
173 assert soco.query_threads[0] != threading.get_ident()
174 assert soco.paused
175
176
177async def test_pause_falls_back_to_stop_when_the_speaker_cannot_pause(
178 sonos_player: SonosPlayer,
179) -> None:
180 """A speaker that offers no pause action is stopped instead."""
181 soco = _RecordingSoco(["Play", "Stop"])
182 sonos_player.soco = soco
183
184 with patch.object(sonos_player, "stop", AsyncMock()) as stop:
185 await sonos_player.pause()
186
187 stop.assert_awaited_once()
188 assert not soco.paused
189
190
191def _set_transport_state(sonos_player: SonosPlayer, state: str) -> None:
192 """Make the mocked speaker report the given transport state."""
193 sonos_player.soco.get_current_transport_info.return_value = {"current_transport_state": state}
194
195
196def _set_track_info(sonos_player: SonosPlayer, uri: str, position: str = "") -> None:
197 """Make the mocked speaker report the given track uri, classified as SoCo would."""
198 sonos_player.soco.get_current_track_info.return_value = {"uri": uri, "position": position}
199 sonos_player.soco.music_source_from_uri = SoCo.music_source_from_uri
200
201
202def test_transitional_state_shortens_the_poll_interval(sonos_player: SonosPlayer) -> None:
203 """A transitional transport state keeps the last known state and is watched closely."""
204 sonos_player._attr_playback_state = PlaybackState.IDLE
205 _set_transport_state(sonos_player, "TRANSITIONING")
206
207 sonos_player.poll_media()
208
209 assert sonos_player._attr_playback_state == PlaybackState.IDLE
210 assert sonos_player.poll_interval == TRANSITION_POLL_INTERVAL
211
212
213def test_settled_state_restores_the_poll_interval(sonos_player: SonosPlayer) -> None:
214 """A usable transport state returns the speaker to the regular poll interval."""
215 sonos_player._attr_poll_interval = TRANSITION_POLL_INTERVAL
216
217 _set_transport_state(sonos_player, "PLAYING")
218 with (
219 patch.object(sonos_player, "_set_basic_track_info"),
220 patch.object(sonos_player, "update_player"),
221 ):
222 sonos_player.poll_media()
223
224 assert sonos_player.poll_interval == POLL_INTERVAL
225
226
227def test_transitional_event_shortens_the_poll_interval(sonos_player: SonosPlayer) -> None:
228 """A transitional state delivered by subscription event is watched closely too."""
229 event = MagicMock()
230 event.variables = {"transport_state": "TRANSITIONING"}
231
232 sonos_player._handle_avtransport_event(event)
233
234 assert sonos_player.poll_interval == TRANSITION_POLL_INTERVAL
235
236
237def test_line_in_is_reported_as_the_active_source(sonos_player: SonosPlayer) -> None:
238 """A speaker playing line-in reports it as its source and offers it in the source list."""
239 _set_track_info(sonos_player, "x-rincon-stream:RINCON_000E58AAAAAA01400")
240
241 sonos_player._set_basic_track_info()
242
243 assert sonos_player._attr_active_source == SOURCE_LINEIN
244 assert [source.id for source in sonos_player._attr_source_list] == [SOURCE_LINEIN]
245
246
247def test_source_is_cleared_once_the_speaker_has_nothing_loaded(sonos_player: SonosPlayer) -> None:
248 """Stopping line-in empties the transport, which must not leave the source behind."""
249 _set_track_info(sonos_player, "x-rincon-stream:RINCON_000E58AAAAAA01400")
250 sonos_player._set_basic_track_info()
251
252 _set_track_info(sonos_player, "")
253 sonos_player._set_basic_track_info()
254
255 assert sonos_player._attr_active_source is None
256
257
258def test_media_is_cleared_once_the_speaker_has_nothing_loaded(sonos_player: SonosPlayer) -> None:
259 """A stopped speaker must not keep reporting the track it was playing."""
260 _set_track_info(sonos_player, "http://192.168.1.2:8097/track.flac", position="0:00:42")
261 sonos_player._set_basic_track_info()
262
263 _set_track_info(sonos_player, "")
264 sonos_player._set_basic_track_info()
265
266 assert sonos_player._attr_current_media is None
267 assert sonos_player._attr_elapsed_time is None
268 assert sonos_player._attr_elapsed_time_last_updated is None
269
270
271def test_spotify_connect_is_not_reported_as_a_source(sonos_player: SonosPlayer) -> None:
272 """Line-in and TV are the only sources this provider reports."""
273 _set_track_info(sonos_player, "x-sonos-vli:RINCON_000E58AAAAAA01400:2,spotify:abc")
274
275 sonos_player._set_basic_track_info()
276
277 assert sonos_player._attr_active_source is None
278 assert sonos_player._attr_source_list == []
279
280
281async def test_repeated_commands_collapse_to_one_pending_poll(
282 timer_mass: MusicAssistant,
283) -> None:
284 """A burst of commands leaves a single pending poll instead of one poll per command."""
285 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
286
287 await player.volume_set(30)
288 first_handle = timer_mass._tracked_timers[_poll_id(player)]
289 await player.volume_set(40)
290 await player.volume_mute(True)
291 await player.play()
292
293 assert _pending_polls(timer_mass) == [_poll_id(player)]
294 assert first_handle.cancelled()
295
296
297async def test_each_speaker_keeps_its_own_pending_poll(timer_mass: MusicAssistant) -> None:
298 """Commands to different speakers are polled independently."""
299 kitchen = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
300 study = _make_player(timer_mass, "RINCON_000E58BBBBBB01400", "Study")
301
302 await kitchen.volume_set(30)
303 await study.volume_set(40)
304
305 assert _pending_polls(timer_mass) == sorted([_poll_id(kitchen), _poll_id(study)])
306
307
308async def test_set_members_polls_the_speakers_it_regrouped(timer_mass: MusicAssistant) -> None:
309 """Grouping polls each speaker that was joined or unjoined, not the coordinator."""
310 kitchen = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
311 study = _make_player(timer_mass, "RINCON_000E58BBBBBB01400", "Study")
312 hallway = _make_player(timer_mass, "RINCON_000E58CCCCCC01400", "Hallway")
313 cast("MagicMock", timer_mass.players).get_player.side_effect = {
314 study.player_id: study,
315 hallway.player_id: hallway,
316 }.get
317
318 await kitchen.set_members(player_ids_to_add=[study.player_id, hallway.player_id])
319
320 assert _pending_polls(timer_mass) == sorted([_poll_id(study), _poll_id(hallway)])
321
322
323async def test_join_failure_names_the_speaker_that_refused(
324 timer_mass: MusicAssistant,
325) -> None:
326 """A speaker that refuses to join is named in the failure, not the group leader."""
327 kitchen = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
328 study = _make_player(timer_mass, "RINCON_000E58BBBBBB01400", "Study")
329 cast("MagicMock", timer_mass.players).get_player.side_effect = {study.player_id: study}.get
330 study.soco.join.side_effect = SoCoException("the speaker refused to join")
331
332 with pytest.raises(PlayerCommandFailed, match="Study") as exc_info:
333 await kitchen.set_members(player_ids_to_add=[study.player_id])
334
335 assert "Kitchen" not in str(exc_info.value)
336
337
338async def test_grouping_leaves_the_speakers_after_a_failure_untouched(
339 timer_mass: MusicAssistant,
340) -> None:
341 """Speakers joined before a failure keep their poll, the ones after it are never reached."""
342 kitchen = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
343 study = _make_player(timer_mass, "RINCON_000E58BBBBBB01400", "Study")
344 hallway = _make_player(timer_mass, "RINCON_000E58CCCCCC01400", "Hallway")
345 attic = _make_player(timer_mass, "RINCON_000E58DDDDDD01400", "Attic")
346 cast("MagicMock", timer_mass.players).get_player.side_effect = {
347 study.player_id: study,
348 hallway.player_id: hallway,
349 attic.player_id: attic,
350 }.get
351 hallway.soco.join.side_effect = SoCoException("the speaker refused to join")
352
353 with pytest.raises(PlayerCommandFailed, match="Hallway"):
354 await kitchen.set_members(
355 player_ids_to_add=[study.player_id, hallway.player_id, attic.player_id]
356 )
357
358 assert _pending_polls(timer_mass) == [_poll_id(study)]
359 attic.soco.join.assert_not_called()
360
361
362async def test_unjoin_failure_names_the_speaker_that_refused(
363 timer_mass: MusicAssistant,
364) -> None:
365 """A speaker that refuses to leave a group is named in the failure, not the group leader."""
366 kitchen = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
367 study = _make_player(timer_mass, "RINCON_000E58BBBBBB01400", "Study")
368 cast("MagicMock", timer_mass.players).get_player.side_effect = {study.player_id: study}.get
369 study.soco.unjoin.side_effect = SoCoException("the speaker refused to leave")
370
371 with pytest.raises(PlayerCommandFailed, match="Study") as exc_info:
372 await kitchen.set_members(player_ids_to_remove=[study.player_id])
373
374 assert "Kitchen" not in str(exc_info.value)
375
376
377async def test_unload_cancels_the_pending_poll(timer_mass: MusicAssistant) -> None:
378 """An unloaded speaker is not polled by a command that was still in flight."""
379 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
380 await player.volume_set(30)
381 handle = timer_mass._tracked_timers[_poll_id(player)]
382
383 await player.on_unload()
384
385 assert _pending_polls(timer_mass) == []
386 assert handle.cancelled()
387
388
389async def test_unload_cancels_a_poll_that_already_started(
390 timer_mass: MusicAssistant, monkeypatch: pytest.MonkeyPatch
391) -> None:
392 """A poll that started just before the speaker was unloaded is aborted."""
393 monkeypatch.setattr(player_module, "COMMAND_POLL_DELAY", 0)
394 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
395 polling = asyncio.Event()
396
397 async def _slow_poll() -> None:
398 polling.set()
399 await asyncio.sleep(5)
400
401 monkeypatch.setattr(player, "poll", _slow_poll)
402 player.schedule_poll()
403 await polling.wait()
404 task = timer_mass._tracked_tasks[_poll_id(player)]
405
406 await player.on_unload()
407
408 with pytest.raises(asyncio.CancelledError):
409 await task
410
411
412async def test_unloaded_player_ignores_results_from_a_running_poll(
413 timer_mass: MusicAssistant,
414) -> None:
415 """A poll left running in its worker thread cannot update an unloaded speaker."""
416 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
417 await player.on_unload()
418
419 with patch.object(player, "_update_attributes") as update_attributes:
420 player.update_player()
421
422 update_attributes.assert_not_called()
423
424
425async def test_unloaded_player_ignores_group_topology_updates(
426 timer_mass: MusicAssistant,
427) -> None:
428 """A group update raised by a running poll cannot update an unloaded speaker."""
429 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
430 # let the speaker report itself as coordinator of a two-speaker group, so a
431 # topology update would regroup and signal the change if it were not unloaded
432 member = MagicMock(uid="RINCON_000E58BBBBBB01400", is_visible=True)
433 player.soco.group.coordinator.uid = player.player_id
434 player.soco.group.members = [player.soco.group.coordinator, member]
435 await player.on_unload()
436
437 with patch.object(player, "update_state") as update_state:
438 await player.create_update_groups_coro()
439 await asyncio.sleep(0)
440
441 assert player.group_members == []
442 update_state.assert_not_called()
443
444
445async def test_on_unload_unsubscribes_from_soco_events(timer_mass: MusicAssistant) -> None:
446 """Unloading a speaker also tears down its soco event subscriptions."""
447 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
448 subscription = MagicMock()
449 subscription.unsubscribe = AsyncMock()
450 player._subscriptions = [subscription]
451
452 await player.on_unload()
453
454 subscription.unsubscribe.assert_awaited_once()
455 assert player._subscriptions == []
456
457
458async def test_on_unload_unsubscribes_even_when_already_unavailable(
459 timer_mass: MusicAssistant,
460) -> None:
461 """Unlike offline(), on_unload() must still tear down an unavailable speaker's events."""
462 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
463 subscription = MagicMock()
464 subscription.unsubscribe = AsyncMock()
465 player._subscriptions = [subscription]
466 player._attr_available = False
467
468 await player.on_unload()
469
470 subscription.unsubscribe.assert_awaited_once()
471 assert player._subscriptions == []
472
473
474async def test_unloaded_player_is_not_resubscribed_by_a_late_poll(
475 timer_mass: MusicAssistant,
476) -> None:
477 """A poll that reaches an unloaded speaker must not subscribe it to events again."""
478 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
479 # the speaker was marked unavailable earlier, so it carries no subscriptions and
480 # unloading it leaves that unavailable state behind for a late poll to act on
481 player._attr_available = False
482 await player.on_unload()
483
484 with patch.object(player, "subscribe", AsyncMock()) as subscribe:
485 await player.poll()
486
487 subscribe.assert_not_called()
488 assert player._attr_available is False
489
490
491async def test_speaker_answering_a_poll_again_is_resubscribed(
492 timer_mass: MusicAssistant,
493) -> None:
494 """A speaker that answers a poll after being unavailable is subscribed to events again."""
495 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
496 player.soco.group.coordinator.uid = player.player_id
497 player.soco.group.members = [player.soco.group.coordinator]
498 player._attr_available = False
499
500 with patch.object(player, "subscribe", AsyncMock()) as subscribe:
501 await player.poll()
502
503 subscribe.assert_called_once()
504 assert player._attr_available is True
505
506
507async def test_failed_poll_keeps_a_recently_active_speaker_available(
508 timer_mass: MusicAssistant,
509) -> None:
510 """A failed poll does not mark a recently active speaker unavailable."""
511 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
512 player._last_activity = time.monotonic()
513
514 with (
515 patch.object(player, "poll_media", side_effect=SonosUpdateError("no response")),
516 patch.object(player, "ping") as ping,
517 ):
518 await player.poll()
519
520 ping.assert_not_called()
521 assert player._attr_available is True
522
523
524async def test_speaker_silent_too_long_and_unreachable_is_marked_unavailable(
525 timer_mass: MusicAssistant,
526) -> None:
527 """A speaker that is silent too long and fails a ping is marked unavailable."""
528 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
529 player._last_activity = time.monotonic() - AVAILABILITY_TIMEOUT
530
531 with (
532 patch.object(player, "poll_media", side_effect=SonosUpdateError("no response")),
533 patch.object(player, "ping", side_effect=SonosUpdateError("no response")),
534 patch.object(player, "offline", AsyncMock()) as offline,
535 ):
536 await player.poll()
537
538 offline.assert_awaited_once()
539
540
541async def test_successful_poll_counts_as_speaker_activity(
542 timer_mass: MusicAssistant,
543) -> None:
544 """A successful poll counts as activity, so the speaker is not pinged."""
545 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
546 player.soco.group.coordinator.uid = player.player_id
547 player.soco.group.members = [player.soco.group.coordinator]
548 before = time.monotonic()
549
550 with patch.object(player, "ping") as ping:
551 await player.poll()
552
553 ping.assert_not_called()
554 assert player._last_activity >= before
555
556
557async def test_unavailable_speaker_is_pinged_despite_recent_activity(
558 timer_mass: MusicAssistant,
559) -> None:
560 """A speaker taken offline by a failed renewal is pinged so it can recover quickly."""
561 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
562 player.soco.group.coordinator.uid = player.player_id
563 player.soco.group.members = [player.soco.group.coordinator]
564 player._attr_available = False
565 player._last_activity = time.monotonic()
566
567 with (
568 patch.object(player, "ping") as ping,
569 patch.object(player, "subscribe", AsyncMock()) as subscribe,
570 ):
571 await player.poll()
572
573 ping.assert_called_once_with()
574 subscribe.assert_called_once()
575 assert player._attr_available is True
576
577
578async def test_unloading_mid_subscribe_keeps_no_subscriptions(
579 timer_mass: MusicAssistant,
580) -> None:
581 """A speaker unloaded while subscribing must not keep the subscriptions it created."""
582 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
583 subscribing = asyncio.Event()
584 speaker_responds = asyncio.Event()
585
586 async def _slow_subscribe_target(_target: object, _callback: object) -> None:
587 subscribing.set()
588 await speaker_responds.wait()
589 player._subscriptions.append(MagicMock(unsubscribe=AsyncMock()))
590
591 with patch.object(player, "_subscribe_target", _slow_subscribe_target):
592 subscribe_task = asyncio.create_task(player.subscribe())
593 await subscribing.wait()
594
595 unload_task = asyncio.create_task(player.on_unload())
596 await asyncio.sleep(0)
597 assert not unload_task.done()
598
599 speaker_responds.set()
600 async with asyncio.timeout(5):
601 await asyncio.gather(subscribe_task, unload_task)
602
603 assert player._subscriptions == []
604
605
606def _make_rediscovered_soco(ip_address: str = "127.0.0.2") -> MagicMock:
607 """Create the mocked soco device that discovery hands over for a speaker that moved."""
608 soco = _make_soco()
609 soco.ip_address = ip_address
610 return soco
611
612
613async def test_update_ip_talks_to_the_rediscovered_speaker(sonos_player: SonosPlayer) -> None:
614 """A speaker found at another address is reached through the newly discovered device."""
615 sonos_player._attr_available = False
616 new_soco = _make_rediscovered_soco()
617
618 with patch.object(sonos_player, "setup", AsyncMock()) as setup:
619 await sonos_player.update_ip(new_soco)
620
621 assert sonos_player.soco is new_soco
622 setup.assert_awaited_once()
623 assert sonos_player.device_info.identifiers[IdentifierType.IP_ADDRESS] == "127.0.0.2"
624
625
626async def test_update_ip_probes_the_speaker_off_the_event_loop(sonos_player: SonosPlayer) -> None:
627 """Probing the rediscovered speaker must not stall the event loop."""
628 sonos_player._attr_available = False
629 new_soco = _make_rediscovered_soco()
630 probing = threading.Event()
631 probe_may_finish = threading.Event()
632
633 def _blocking_probe(*_args: object, **_kwargs: object) -> None:
634 probing.set()
635 probe_may_finish.wait(5)
636
637 new_soco.renderingControl.GetVolume.side_effect = _blocking_probe
638
639 # a probe held on the event loop would starve this block until it hits the timeout
640 with patch.object(sonos_player, "setup", AsyncMock()) as setup:
641 async with asyncio.timeout(2):
642 update = asyncio.create_task(sonos_player.update_ip(new_soco))
643 await asyncio.to_thread(probing.wait, 5)
644 probe_may_finish.set()
645 await update
646
647 setup.assert_awaited_once()
648
649
650async def test_update_ip_marks_the_recovered_speaker_available(sonos_player: SonosPlayer) -> None:
651 """A speaker that answers at its new address counts as reachable again."""
652 sonos_player._attr_available = False
653 new_soco = _make_rediscovered_soco()
654
655 with patch.object(sonos_player, "setup", AsyncMock()):
656 await sonos_player.update_ip(new_soco)
657
658 assert sonos_player.available
659
660
661async def test_update_ip_skips_setup_when_the_new_address_stays_silent(
662 sonos_player: SonosPlayer,
663) -> None:
664 """An unanswered probe leaves the speaker for the regular poll to pick up."""
665 sonos_player._attr_available = False
666 new_soco = _make_rediscovered_soco()
667 new_soco.renderingControl.GetVolume.side_effect = SoCoException("no answer")
668
669 with patch.object(sonos_player, "setup", AsyncMock()) as setup:
670 await sonos_player.update_ip(new_soco)
671
672 setup.assert_not_awaited()
673 assert sonos_player.soco is new_soco
674
675
676async def test_update_ip_leaves_an_unloaded_speaker_alone(sonos_player: SonosPlayer) -> None:
677 """An unloaded speaker must not be reconnected, its subscriptions would leak."""
678 sonos_player._attr_available = False
679 sonos_player._unloaded = True
680 original_soco = sonos_player.soco
681
682 with patch.object(sonos_player, "setup", AsyncMock()) as setup:
683 await sonos_player.update_ip(_make_rediscovered_soco())
684
685 assert sonos_player.soco is original_soco
686 setup.assert_not_awaited()
687
688
689async def test_update_ip_leaves_a_responding_speaker_alone(sonos_player: SonosPlayer) -> None:
690 """A speaker that is still reachable keeps the device it is already talking to."""
691 original_soco = sonos_player.soco
692
693 with patch.object(sonos_player, "setup", AsyncMock()) as setup:
694 await sonos_player.update_ip(_make_rediscovered_soco())
695
696 assert sonos_player.soco is original_soco
697 setup.assert_not_awaited()
698
699
700async def test_setup_reads_the_speaker_off_the_event_loop(sonos_player: SonosPlayer) -> None:
701 """Reading the initial state of a speaker must not stall the event loop."""
702 cast("MagicMock", sonos_player.mass.players).register_or_update = AsyncMock()
703 reading_threads: list[int] = []
704
705 with (
706 patch.object(
707 sonos_player,
708 "update_groups",
709 lambda: reading_threads.append(threading.get_ident()),
710 ),
711 patch.object(sonos_player, "poll_media"),
712 patch.object(sonos_player, "subscribe", AsyncMock()),
713 ):
714 await sonos_player.setup()
715
716 assert len(reading_threads) == 1
717 assert reading_threads[0] != threading.get_ident()
718
719
720async def test_unsubscribe_drops_subscriptions_even_when_cancelled() -> None:
721 """A cancelled unsubscribe must not leave stale entries that block resubscribing."""
722 provider = MagicMock()
723 player = SonosPlayer(provider=provider, soco=_make_soco(), fixed_volume=False)
724 subscription = MagicMock()
725 subscription.unsubscribe = AsyncMock(side_effect=partial(asyncio.sleep, 5))
726 player._subscriptions = [subscription]
727
728 task = asyncio.create_task(player.unsubscribe())
729 await asyncio.sleep(0)
730 task.cancel()
731 with pytest.raises(asyncio.CancelledError):
732 await task
733
734 assert player._subscriptions == []
735 assert player.missing_subscriptions == SUBSCRIPTION_SERVICES
736
737
738async def _subscribe_with_failing_speaker(player: SonosPlayer) -> None:
739 """Let the given player attempt to subscribe to a speaker that cannot be reached."""
740 with (
741 patch.object(player, "_subscribe_target", AsyncMock(side_effect=OSError("unreachable"))),
742 patch.object(player, "update_state"),
743 ):
744 async with asyncio.timeout(5):
745 await player.subscribe()
746
747
748async def test_failed_subscribe_marks_the_speaker_offline() -> None:
749 """A failed subscription must take the speaker offline and release the lock."""
750 player = SonosPlayer(provider=MagicMock(), soco=_make_soco(), fixed_volume=False)
751
752 await _subscribe_with_failing_speaker(player)
753
754 assert player.available is False
755 assert not player._subscription_lock.locked()
756
757
758async def test_speaker_can_resubscribe_after_a_failed_subscribe() -> None:
759 """A speaker that failed to subscribe must still be able to subscribe later."""
760 player = SonosPlayer(provider=MagicMock(), soco=_make_soco(), fixed_volume=False)
761 await _subscribe_with_failing_speaker(player)
762
763 with patch.object(player, "_subscribe_target", AsyncMock()) as subscribe_target:
764 async with asyncio.timeout(5):
765 await player.subscribe()
766
767 assert subscribe_target.await_count == len(SUBSCRIPTION_SERVICES)
768
769
770async def test_speaker_taken_offline_mid_subscribe_keeps_no_subscriptions() -> None:
771 """A speaker that goes offline while subscribing must not keep the subscriptions it created."""
772 player = SonosPlayer(provider=MagicMock(), soco=_make_soco(), fixed_volume=False)
773 subscribing = asyncio.Event()
774 speaker_responds = asyncio.Event()
775
776 async def _slow_subscribe_target(_target: object, _callback: object) -> None:
777 subscribing.set()
778 await speaker_responds.wait()
779 player._subscriptions.append(MagicMock(unsubscribe=AsyncMock()))
780
781 with (
782 patch.object(player, "_subscribe_target", _slow_subscribe_target),
783 patch.object(player, "update_state"),
784 ):
785 subscribe_task = asyncio.create_task(player.subscribe())
786 await subscribing.wait()
787
788 offline_task = asyncio.create_task(player.offline())
789 await asyncio.sleep(0)
790 assert not offline_task.done()
791
792 speaker_responds.set()
793 async with asyncio.timeout(5):
794 await asyncio.gather(subscribe_task, offline_task)
795
796 assert player.available is False
797 assert player._subscriptions == []
798 assert player.missing_subscriptions == SUBSCRIPTION_SERVICES
799
800
801async def test_speaker_going_offline_is_not_resubscribed_halfway() -> None:
802 """No new subscriptions may be created while a speaker is still going offline."""
803 player = SonosPlayer(provider=MagicMock(), soco=_make_soco(), fixed_volume=False)
804 unsubscribing = asyncio.Event()
805 speaker_responds = asyncio.Event()
806
807 async def _slow_unsubscribe() -> None:
808 unsubscribing.set()
809 await speaker_responds.wait()
810
811 player._subscriptions = [MagicMock(unsubscribe=_slow_unsubscribe)]
812
813 with (
814 patch.object(player, "_subscribe_target", AsyncMock()) as subscribe_target,
815 patch.object(player, "update_state"),
816 ):
817 offline_task = asyncio.create_task(player.offline())
818 await unsubscribing.wait()
819
820 subscribe_task = asyncio.create_task(player.subscribe())
821 await asyncio.sleep(0)
822 subscribe_target.assert_not_called()
823
824 speaker_responds.set()
825 async with asyncio.timeout(5):
826 await asyncio.gather(offline_task, subscribe_task)
827
828 assert subscribe_target.await_count == len(SUBSCRIPTION_SERVICES)
829