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