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