/
/
/
1"""Tests for the generic LinkPlay grouping/identity shell of the WiiM provider."""
2
3from __future__ import annotations
4
5import asyncio
6from types import SimpleNamespace
7from typing import TYPE_CHECKING, Any, cast
8from unittest.mock import AsyncMock, MagicMock, patch
9
10import pytest
11from music_assistant_models.enums import IdentifierType, PlaybackState, PlayerFeature, PlayerType
12from music_assistant_models.errors import PlayerCommandFailed, UnsupportedFeaturedException
13from pywiim import WiiMError
14
15from music_assistant.controllers.players.protocol_linking import ProtocolLinkingMixin
16from music_assistant.models.player import LinkedOutputProtocol
17from music_assistant.providers.wiim.constants import PLAYER_ID_PREFIX
18from music_assistant.providers.wiim.helpers import (
19 is_official_manufacturer,
20 linkplay_group_compatible,
21 linkplay_slave_uuid_to_player_id,
22 linkplay_slave_uuid_to_udn,
23)
24from music_assistant.providers.wiim.linkplay_player import LinkPlayPlayer
25from music_assistant.providers.wiim.provider import WiimProvider
26
27if TYPE_CHECKING:
28 from pywiim.models import DeviceInfo as PywiimDeviceInfo
29
30# Verified Edifier MS50A identity used across the tests.
31EDIFIER_HTTP_UUID = "FF97F002783E65056579F15F"
32EDIFIER_UDN = "uuid:FF97F002-783E-6505-6579-F15FFF97F002"
33EDIFIER_PLAYER_ID = f"{PLAYER_ID_PREFIX}{EDIFIER_UDN}"
34
35# A second generic device, used for same-backend grouping tests.
36PEER_HTTP_UUID = "AA11BB22CC33DD44EE55FF66"
37PEER_UDN = "uuid:AA11BB22-CC33-DD44-EE55-FF66AA11BB22"
38PEER_PLAYER_ID = f"{PLAYER_ID_PREFIX}{PEER_UDN}"
39
40
41class TestIdentityNormalization:
42 """The 24-char HTTP UUID must map deterministically to the UPnP UDN/player id."""
43
44 def test_http_uuid_to_udn_matches_hardware(self) -> None:
45 """The Edifier HTTP UUID resolves to its verified UPnP UDN."""
46 assert linkplay_slave_uuid_to_udn(EDIFIER_HTTP_UUID) == EDIFIER_UDN
47
48 def test_http_uuid_to_player_id(self) -> None:
49 """The player id is the prefixed UDN."""
50 assert linkplay_slave_uuid_to_player_id(EDIFIER_HTTP_UUID) == EDIFIER_PLAYER_ID
51
52 def test_lowercase_input_normalizes_to_canonical_udn(self) -> None:
53 """Case does not matter; the canonical UDN is uppercase."""
54 assert linkplay_slave_uuid_to_udn(EDIFIER_HTTP_UUID.lower()) == EDIFIER_UDN
55
56 @pytest.mark.parametrize(
57 "value",
58 [
59 EDIFIER_UDN,
60 EDIFIER_UDN.removeprefix("uuid:"),
61 EDIFIER_UDN.removeprefix("uuid:").replace("-", ""),
62 EDIFIER_UDN.lower(),
63 ],
64 )
65 def test_full_udn_forms_normalize_to_canonical(self, value: str) -> None:
66 """Slaves that report a full 32-hex UDN (any form) resolve to the same UDN."""
67 assert linkplay_slave_uuid_to_udn(value) == EDIFIER_UDN
68 assert linkplay_slave_uuid_to_player_id(value) == EDIFIER_PLAYER_ID
69
70 @pytest.mark.parametrize(
71 "value", ["", "tooshort", "ZZ97F002783E65056579F15F", "1" * 30, "1" * 40]
72 )
73 def test_invalid_uuid_returns_none(self, value: str) -> None:
74 """Input that is neither a 24-hex HTTP UUID nor a 32-hex UDN is rejected."""
75 assert linkplay_slave_uuid_to_udn(value) is None
76 assert linkplay_slave_uuid_to_player_id(value) is None
77
78
79class TestManufacturerClassification:
80 """Only WiiM/Audio Pro manufacturers select the official backend."""
81
82 @pytest.mark.parametrize("manufacturer", ["Linkplay", "linkplay technology", "Audio Pro AB"])
83 def test_official_manufacturers(self, manufacturer: str) -> None:
84 """Official manufacturers are recognised (mirrors the official SDK)."""
85 assert is_official_manufacturer(manufacturer) is True
86
87 @pytest.mark.parametrize("manufacturer", ["Edifier Inc", "", None, "Arylic", "WiiM"])
88 def test_generic_manufacturers(self, manufacturer: str | None) -> None:
89 """Everything else is treated as generic LinkPlay."""
90 assert is_official_manufacturer(manufacturer) is False
91
92
93def _slaves(uuids: list[str]) -> list[dict[str, str]]:
94 """Build a pywiim get_slaves_info()-style slave list."""
95 return [{"uuid": uuid, "ip": f"10.0.0.{index + 2}"} for index, uuid in enumerate(uuids)]
96
97
98def _device_info(wmrm_version: str = "4.2", *, legacy: bool = False) -> PywiimDeviceInfo:
99 """Build a device-info stand-in; legacy=True marks it as a Wi-Fi-Direct device."""
100 return cast(
101 "PywiimDeviceInfo",
102 SimpleNamespace(wmrm_version=wmrm_version, needs_wifi_direct_multiroom=legacy),
103 )
104
105
106@pytest.fixture
107def mock_provider() -> MagicMock:
108 """Create a mock WiimProvider suitable for constructing players."""
109 provider = MagicMock()
110 provider.instance_id = "wiim_test"
111 provider.domain = "wiim"
112 provider.mass = MagicMock()
113 provider.mass.players = MagicMock()
114 config = MagicMock()
115 config.name = None
116 config.default_name = "Edifier MS50A"
117 config.enabled = True
118 config.player_type = None
119 config.get_value = MagicMock(return_value=None)
120 provider.mass.config.get_base_player_config.return_value = config
121 return provider
122
123
124@pytest.fixture
125def mock_client() -> MagicMock:
126 """Create a mock low-level WiiMClient with the GroupAPI methods the shell uses."""
127 client = MagicMock()
128 client.host = "192.168.1.50"
129 client.get_device_info_model = AsyncMock(
130 return_value=SimpleNamespace(
131 uuid=EDIFIER_HTTP_UUID, wmrm_version="4.2", firmware="Linkplay.4.6.430230"
132 )
133 )
134 client.get_slaves_info = AsyncMock(return_value=_slaves([]))
135 client.join_slave = AsyncMock()
136 client.leave_group = AsyncMock()
137 return client
138
139
140@pytest.fixture
141def mock_upnp_device() -> MagicMock:
142 """Create a mock async-upnp-client UpnpDevice."""
143 device = MagicMock()
144 device.manufacturer = "Edifier Inc"
145 device.model_name = "Edifier MS50A"
146 device.friendly_name = "Edifier MS50A"
147 device.udn = EDIFIER_UDN
148 return device
149
150
151def _make_shell(
152 provider: MagicMock,
153 client: MagicMock,
154 upnp_device: MagicMock,
155 player_id: str = EDIFIER_PLAYER_ID,
156) -> LinkPlayPlayer:
157 player = LinkPlayPlayer(
158 provider=provider,
159 player_id=player_id,
160 client=client,
161 upnp_device=upnp_device,
162 description_url="http://192.168.1.50:49152/description.xml",
163 mac_address="AA:BB:CC:DD:EE:FF",
164 )
165 player.update_state = MagicMock() # type: ignore[misc,method-assign]
166 # a modern, router-based device so compatibility checks pass by default
167 player._cached_device_info = _device_info("4.2")
168 return player
169
170
171class TestShellConstruction:
172 """The shell exposes device identity and a grouping-only native capability."""
173
174 def test_is_a_native_player(
175 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
176 ) -> None:
177 """The shell is a native PlayerType.PLAYER so the controller can link protocols."""
178 player = _make_shell(mock_provider, mock_client, mock_upnp_device)
179 assert player._attr_type == PlayerType.PLAYER
180
181 def test_prefers_dlna_default_output(
182 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
183 ) -> None:
184 """The shell steers automatic output selection at its linked DLNA protocol."""
185 player = _make_shell(mock_provider, mock_client, mock_upnp_device)
186 assert player.default_output_protocol_domain == "dlna"
187
188 def test_device_info_identifiers(
189 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
190 ) -> None:
191 """UUID/IP/MAC identifiers are set so DLNA/AirPlay children link by identity."""
192 player = _make_shell(mock_provider, mock_client, mock_upnp_device)
193 identifiers = player.device_info.identifiers
194 assert identifiers[IdentifierType.UUID] == EDIFIER_UDN.removeprefix("uuid:")
195 assert identifiers[IdentifierType.IP_ADDRESS] == "192.168.1.50"
196 assert identifiers[IdentifierType.MAC_ADDRESS] == "AA:BB:CC:DD:EE:FF"
197
198 def test_has_no_native_playback(
199 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
200 ) -> None:
201 """The shell must not implement native playback: no play_media, no PLAY_MEDIA."""
202 player = _make_shell(mock_provider, mock_client, mock_upnp_device)
203 # play_media is only defined on the base Player as the default reject stub, never
204 # overridden here, so the shell exposes no native PLAY_MEDIA feature.
205 assert "play_media" not in LinkPlayPlayer.__dict__
206 assert PlayerFeature.PLAY_MEDIA not in player._attr_supported_features
207 # no eventing/state-machine internals leaked from the old backend
208 for leaked in ("_push_state", "_connect_eventing", "_refresh_state", "_dmr_device"):
209 assert not hasattr(player, leaked)
210
211
212class TestHealthGating:
213 """Native grouping is only offered while the LinkPlay HTTP API is reachable."""
214
215 def test_set_members_feature_requires_health(
216 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
217 ) -> None:
218 """SET_MEMBERS is withdrawn when the LinkPlay API is unreachable."""
219 player = _make_shell(mock_provider, mock_client, mock_upnp_device)
220 player._linkplay_available = True
221 assert PlayerFeature.SET_MEMBERS in player.supported_features
222 player._linkplay_available = False
223 assert PlayerFeature.SET_MEMBERS not in player.supported_features
224
225 def test_prefers_native_grouping_when_healthy(
226 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
227 ) -> None:
228 """A reachable shell prefers native LinkPlay grouping over a linked protocol."""
229 player = _make_shell(mock_provider, mock_client, mock_upnp_device)
230 player._linkplay_available = True
231 assert player.prefer_native_grouping is True
232 player._linkplay_available = False
233 assert player.prefer_native_grouping is False
234
235 def test_can_group_with_requires_health(
236 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
237 ) -> None:
238 """A shell with an unreachable API advertises no grouping peers."""
239 player = _make_shell(mock_provider, mock_client, mock_upnp_device)
240 peer = _make_shell(mock_provider, mock_client, mock_upnp_device, PEER_PLAYER_ID)
241 peer._linkplay_available = True
242 mock_provider.players = [player, peer]
243 player._linkplay_available = False
244 assert player.can_group_with == set()
245 player._linkplay_available = True
246 assert player.can_group_with == {PEER_PLAYER_ID}
247
248 def test_can_group_with_skips_unhealthy_peer(
249 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
250 ) -> None:
251 """An unreachable peer is not offered as a grouping target."""
252 player = _make_shell(mock_provider, mock_client, mock_upnp_device)
253 player._linkplay_available = True
254 peer = _make_shell(mock_provider, mock_client, mock_upnp_device, PEER_PLAYER_ID)
255 peer._linkplay_available = False
256 mock_provider.players = [player, peer]
257 assert player.can_group_with == set()
258
259 def test_native_group_compat_only_generic_peers(
260 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
261 ) -> None:
262 """The native-compat seam only accepts reachable generic peers, never other backends."""
263 player = _make_shell(mock_provider, mock_client, mock_upnp_device)
264 player._linkplay_available = True
265 peer = _make_shell(mock_provider, mock_client, mock_upnp_device, PEER_PLAYER_ID)
266 peer._linkplay_available = True
267 mock_provider.players = [player, peer]
268 assert player.is_native_group_compatible(peer) is True
269 # a same-instance player of another backend (e.g. official WiiM) is not native-compatible,
270 # so the grouping layer never routes such a pair onto a native group.
271 official = MagicMock(player_id="wiim_official")
272 assert player.is_native_group_compatible(official) is False
273
274
275class TestPlaybackAvailability:
276 """Playback availability derives from linked protocols, not the LinkPlay API (B2)."""
277
278 def test_available_from_linked_protocol_even_when_api_unhealthy(
279 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
280 ) -> None:
281 """A reachable linked DLNA keeps the shell available even if the LinkPlay API is down."""
282 player = _make_shell(mock_provider, mock_client, mock_upnp_device)
283 player._linkplay_available = False
284 player.set_linked_output_protocols([LinkedOutputProtocol("dlna_x", "dlna", priority=50)])
285 protocol_player = MagicMock(available_for_playback=True)
286 mock_provider.mass.players.get_player.return_value = protocol_player
287 assert player.available is True
288
289 def test_unavailable_without_linked_protocols(
290 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
291 ) -> None:
292 """With no linked protocol players there is nothing to play through."""
293 player = _make_shell(mock_provider, mock_client, mock_upnp_device)
294 assert player.available is False
295
296 def test_backing_ids_derive_from_linked_protocols(
297 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
298 ) -> None:
299 """The backing protocol ids come from the linked output protocols."""
300 player = _make_shell(mock_provider, mock_client, mock_upnp_device)
301 player.set_linked_output_protocols(
302 [
303 LinkedOutputProtocol("dlna_x", "dlna", priority=50),
304 LinkedOutputProtocol("ap_x", "airplay", priority=10),
305 ]
306 )
307 assert player._backing_protocol_player_ids() == ["dlna_x", "ap_x"]
308
309
310class TestGrouping:
311 """Same-backend grouping uses the low-level WiiMClient GroupAPI only."""
312
313 async def test_join_uses_low_level_client(
314 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
315 ) -> None:
316 """Adding a member joins it to this leader via join_slave with the leader device info."""
317 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
318 leader._linkplay_available = True
319 member_client = MagicMock(join_slave=AsyncMock(), leave_group=AsyncMock())
320 member = _make_shell(mock_provider, member_client, mock_upnp_device, PEER_PLAYER_ID)
321 member._linkplay_available = True
322 mock_provider.mass.players.get_player.return_value = member
323 # after joining, the leader lists the member as a slave
324 mock_client.get_slaves_info = AsyncMock(return_value=_slaves([PEER_HTTP_UUID]))
325 mock_provider.players = [leader, member]
326 await leader.set_members(player_ids_to_add=[PEER_PLAYER_ID])
327 member_client.join_slave.assert_awaited_once()
328 args, kwargs = member_client.join_slave.call_args
329 assert args[0] == "192.168.1.50" # leader ip
330 assert kwargs["master_device_info"] is leader.cached_device_info
331
332 async def test_leave_uses_low_level_client(
333 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
334 ) -> None:
335 """Removing a current member calls leave_group and verifies it left the leader's group."""
336 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
337 leader._linkplay_available = True
338 member_client = MagicMock(join_slave=AsyncMock(), leave_group=AsyncMock())
339 member = _make_shell(mock_provider, member_client, mock_upnp_device, PEER_PLAYER_ID)
340 member._linkplay_available = True
341 mock_provider.mass.players.get_player.return_value = member
342 # the leader currently owns the member, then reports it gone after the leave
343 mock_client.get_slaves_info = AsyncMock(
344 side_effect=[_slaves([PEER_HTTP_UUID]), _slaves([])]
345 )
346 mock_provider.players = [leader, member]
347 await leader.set_members(player_ids_to_remove=[PEER_PLAYER_ID])
348 member_client.leave_group.assert_awaited_once()
349
350 async def test_remove_member_not_in_group_skips_leave(
351 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
352 ) -> None:
353 """Removing a member the leader no longer owns issues no leave_group."""
354 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
355 leader._linkplay_available = True
356 member_client = MagicMock(join_slave=AsyncMock(), leave_group=AsyncMock())
357 member = _make_shell(mock_provider, member_client, mock_upnp_device, PEER_PLAYER_ID)
358 member._linkplay_available = True
359 mock_provider.mass.players.get_player.return_value = member
360 mock_provider.players = [leader, member]
361 # the leader's live topology no longer lists the member (it moved to another group)
362 mock_client.get_slaves_info = AsyncMock(return_value=_slaves([]))
363 await leader.set_members(player_ids_to_remove=[PEER_PLAYER_ID])
364 member_client.leave_group.assert_not_awaited()
365
366 async def test_unhealthy_current_removal_aborts_batch(
367 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
368 ) -> None:
369 """An unreachable current removal target fails the batch before any leave."""
370 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
371 leader._linkplay_available = True
372 healthy_client = MagicMock(leave_group=AsyncMock())
373 healthy = _make_shell(mock_provider, healthy_client, mock_upnp_device, PEER_PLAYER_ID)
374 healthy._linkplay_available = True
375 unhealthy_client = MagicMock(leave_group=AsyncMock())
376 unhealthy = _make_shell(
377 mock_provider, unhealthy_client, mock_upnp_device, "wiim_uuid:third"
378 )
379 unhealthy._linkplay_available = False # a current member that is unreachable
380 players = {PEER_PLAYER_ID: healthy, "wiim_uuid:third": unhealthy}
381 mock_provider.mass.players.get_player.side_effect = lambda pid, *_a, **_k: players.get(pid)
382 # both are current members of the leader
383 leader._current_slave_ids = AsyncMock( # type: ignore[method-assign]
384 return_value={PEER_PLAYER_ID, "wiim_uuid:third"}
385 )
386 with pytest.raises(PlayerCommandFailed):
387 await leader.set_members(player_ids_to_remove=[PEER_PLAYER_ID, "wiim_uuid:third"])
388 healthy_client.leave_group.assert_not_awaited()
389 unhealthy_client.leave_group.assert_not_awaited()
390
391 async def test_absent_unhealthy_removal_skipped_healthy_left(
392 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
393 ) -> None:
394 """An absent unreachable target is skipped while a healthy current member is left."""
395 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
396 leader._linkplay_available = True
397 leader._verify_group_change = AsyncMock() # type: ignore[method-assign]
398 healthy_client = MagicMock(leave_group=AsyncMock())
399 healthy = _make_shell(mock_provider, healthy_client, mock_upnp_device, PEER_PLAYER_ID)
400 healthy._linkplay_available = True
401 absent_client = MagicMock(leave_group=AsyncMock())
402 absent = _make_shell(mock_provider, absent_client, mock_upnp_device, "wiim_uuid:third")
403 absent._linkplay_available = False # unreachable, but no longer a member
404 players = {PEER_PLAYER_ID: healthy, "wiim_uuid:third": absent}
405 mock_provider.mass.players.get_player.side_effect = lambda pid, *_a, **_k: players.get(pid)
406 # only the healthy member is still owned; the unreachable one has moved away
407 leader._current_slave_ids = AsyncMock(return_value={PEER_PLAYER_ID}) # type: ignore[method-assign]
408 await leader.set_members(player_ids_to_remove=[PEER_PLAYER_ID, "wiim_uuid:third"])
409 healthy_client.leave_group.assert_awaited_once()
410 absent_client.leave_group.assert_not_awaited()
411
412 async def test_valid_removal_batch_leaves_all(
413 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
414 ) -> None:
415 """A fully valid multi-member removal leaves every current member."""
416 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
417 leader._linkplay_available = True
418 leader._verify_group_change = AsyncMock() # type: ignore[method-assign]
419 first_client = MagicMock(leave_group=AsyncMock())
420 first = _make_shell(mock_provider, first_client, mock_upnp_device, PEER_PLAYER_ID)
421 first._linkplay_available = True
422 second_client = MagicMock(leave_group=AsyncMock())
423 second = _make_shell(mock_provider, second_client, mock_upnp_device, "wiim_uuid:third")
424 second._linkplay_available = True
425 players = {PEER_PLAYER_ID: first, "wiim_uuid:third": second}
426 mock_provider.mass.players.get_player.side_effect = lambda pid, *_a, **_k: players.get(pid)
427 leader._current_slave_ids = AsyncMock( # type: ignore[method-assign]
428 return_value={PEER_PLAYER_ID, "wiim_uuid:third"}
429 )
430 await leader.set_members(player_ids_to_remove=[PEER_PLAYER_ID, "wiim_uuid:third"])
431 first_client.leave_group.assert_awaited_once()
432 second_client.leave_group.assert_awaited_once()
433
434 async def test_cross_backend_member_rejected(
435 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
436 ) -> None:
437 """A non-generic member raises a typed unsupported error (B1)."""
438 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
439 leader._linkplay_available = True
440 mock_provider.mass.players.get_player.return_value = MagicMock() # not a LinkPlayPlayer
441 with pytest.raises(UnsupportedFeaturedException):
442 await leader.set_members(player_ids_to_add=["airplay_other"])
443
444 async def test_unreachable_leader_fails(
445 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
446 ) -> None:
447 """Grouping fails cleanly when the leader's LinkPlay API is unreachable."""
448 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
449 leader._linkplay_available = False
450 with pytest.raises(PlayerCommandFailed):
451 await leader.set_members(player_ids_to_add=[PEER_PLAYER_ID])
452
453 async def test_group_error_becomes_command_failed(
454 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
455 ) -> None:
456 """A WiiMError from the device is surfaced as PlayerCommandFailed."""
457 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
458 leader._linkplay_available = True
459 member_client = MagicMock(join_slave=AsyncMock(side_effect=WiiMError("boom")))
460 member = _make_shell(mock_provider, member_client, mock_upnp_device, PEER_PLAYER_ID)
461 member._linkplay_available = True
462 mock_provider.mass.players.get_player.return_value = member
463 with pytest.raises(PlayerCommandFailed):
464 await leader.set_members(player_ids_to_add=[PEER_PLAYER_ID])
465
466 async def test_incompatible_generation_rejected(
467 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
468 ) -> None:
469 """A peer on an incompatible/legacy multiroom generation is rejected before joining."""
470 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
471 leader._linkplay_available = True
472 member_client = MagicMock(join_slave=AsyncMock())
473 member = _make_shell(mock_provider, member_client, mock_upnp_device, PEER_PLAYER_ID)
474 member._linkplay_available = True
475 # a legacy Wi-Fi-Direct / older-generation device that we do not group
476 member._cached_device_info = _device_info("2.0", legacy=True)
477 mock_provider.mass.players.get_player.return_value = member
478 with pytest.raises(UnsupportedFeaturedException):
479 await leader.set_members(player_ids_to_add=[PEER_PLAYER_ID])
480 member_client.join_slave.assert_not_awaited()
481
482 async def test_unverified_join_raises(
483 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
484 ) -> None:
485 """If the member never appears in the leader's slave list, the command fails."""
486 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
487 leader._linkplay_available = True
488 member_client = MagicMock(join_slave=AsyncMock(), leave_group=AsyncMock())
489 member = _make_shell(mock_provider, member_client, mock_upnp_device, PEER_PLAYER_ID)
490 member._linkplay_available = True
491 mock_provider.mass.players.get_player.return_value = member
492 # leader keeps reporting no slaves -> join not verified
493 mock_client.get_slaves_info = AsyncMock(return_value=_slaves([]))
494 mock_provider.players = [leader, member]
495 with (
496 patch("music_assistant.providers.wiim.linkplay_player.asyncio.sleep", AsyncMock()),
497 pytest.raises(PlayerCommandFailed),
498 ):
499 await leader.set_members(player_ids_to_add=[PEER_PLAYER_ID])
500
501 async def test_join_verification_retries_until_settled(
502 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
503 ) -> None:
504 """A join that the device reports only after settling still succeeds."""
505 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
506 leader._linkplay_available = True
507 member_client = MagicMock(join_slave=AsyncMock(), leave_group=AsyncMock())
508 member = _make_shell(mock_provider, member_client, mock_upnp_device, PEER_PLAYER_ID)
509 member._linkplay_available = True
510 mock_provider.mass.players.get_player.return_value = member
511 # first read still shows no slaves (group forming), second read shows the member
512 mock_client.get_slaves_info = AsyncMock(
513 side_effect=[_slaves([]), _slaves([PEER_HTTP_UUID])]
514 )
515 mock_provider.players = [leader, member]
516 with patch("music_assistant.providers.wiim.linkplay_player.asyncio.sleep", AsyncMock()):
517 await leader.set_members(player_ids_to_add=[PEER_PLAYER_ID])
518 assert mock_client.get_slaves_info.await_count == 2
519
520 async def test_grouping_available_during_external_source(
521 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
522 ) -> None:
523 """Native grouping stays available while a linked protocol plays an external source."""
524 player = _make_shell(mock_provider, mock_client, mock_upnp_device)
525 player._linkplay_available = True
526 # a linked DLNA protocol player is casting an external source
527 player.set_linked_output_protocols([LinkedOutputProtocol("dlna_x", "dlna", priority=50)])
528 ext_player = MagicMock(
529 available=True,
530 active_source="spotify",
531 playback_state=PlaybackState.PLAYING,
532 supported_features={PlayerFeature.PAUSE, PlayerFeature.SEEK},
533 )
534 ext_player.provider.domain = "dlna"
535 mock_provider.mass.players.get_player.return_value = ext_player
536 assert PlayerFeature.SET_MEMBERS in player.supported_features
537
538 async def test_invalid_target_aborts_before_any_join(
539 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
540 ) -> None:
541 """A batch containing a cross-backend target performs no join at all (transactional)."""
542 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
543 leader._linkplay_available = True
544 valid_client = MagicMock(join_slave=AsyncMock(), leave_group=AsyncMock())
545 valid = _make_shell(mock_provider, valid_client, mock_upnp_device, PEER_PLAYER_ID)
546 valid._linkplay_available = True
547 players = {PEER_PLAYER_ID: valid, "official_x": MagicMock()} # official = not a shell
548 mock_provider.mass.players.get_player.side_effect = lambda pid, *_a, **_k: players.get(pid)
549 with pytest.raises(UnsupportedFeaturedException):
550 await leader.set_members(player_ids_to_add=[PEER_PLAYER_ID, "official_x"])
551 valid_client.join_slave.assert_not_awaited()
552
553 async def test_incompatible_target_aborts_before_any_join(
554 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
555 ) -> None:
556 """A later incompatible target aborts the batch before the earlier valid join runs."""
557 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
558 leader._linkplay_available = True
559 good_client = MagicMock(join_slave=AsyncMock())
560 good = _make_shell(mock_provider, good_client, mock_upnp_device, PEER_PLAYER_ID)
561 good._linkplay_available = True
562 bad_client = MagicMock(join_slave=AsyncMock())
563 bad = _make_shell(mock_provider, bad_client, mock_upnp_device, "wiim_uuid:third")
564 bad._linkplay_available = True
565 bad._cached_device_info = _device_info("2.0", legacy=True) # incompatible
566 players = {PEER_PLAYER_ID: good, "wiim_uuid:third": bad}
567 mock_provider.mass.players.get_player.side_effect = lambda pid, *_a, **_k: players.get(pid)
568 with pytest.raises(UnsupportedFeaturedException):
569 await leader.set_members(player_ids_to_add=[PEER_PLAYER_ID, "wiim_uuid:third"])
570 good_client.join_slave.assert_not_awaited()
571
572 async def test_duplicate_ids_rejected(
573 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
574 ) -> None:
575 """A duplicate member id in the request is rejected up front."""
576 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
577 leader._linkplay_available = True
578 with pytest.raises(PlayerCommandFailed):
579 await leader.set_members(player_ids_to_add=[PEER_PLAYER_ID, PEER_PLAYER_ID])
580
581 async def test_conflicting_add_and_remove_rejected(
582 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
583 ) -> None:
584 """Adding and removing the same member in one request is rejected up front."""
585 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
586 leader._linkplay_available = True
587 with pytest.raises(PlayerCommandFailed):
588 await leader.set_members(
589 player_ids_to_add=[PEER_PLAYER_ID], player_ids_to_remove=[PEER_PLAYER_ID]
590 )
591
592 async def test_valid_batch_applies_all_members(
593 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
594 ) -> None:
595 """A fully valid multi-member batch joins every member."""
596 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
597 leader._linkplay_available = True
598 leader._verify_group_change = AsyncMock() # type: ignore[method-assign]
599 first_client = MagicMock(join_slave=AsyncMock())
600 first = _make_shell(mock_provider, first_client, mock_upnp_device, PEER_PLAYER_ID)
601 first._linkplay_available = True
602 second_client = MagicMock(join_slave=AsyncMock())
603 second = _make_shell(mock_provider, second_client, mock_upnp_device, "wiim_uuid:third")
604 second._linkplay_available = True
605 players = {PEER_PLAYER_ID: first, "wiim_uuid:third": second}
606 mock_provider.mass.players.get_player.side_effect = lambda pid, *_a, **_k: players.get(pid)
607 await leader.set_members(player_ids_to_add=[PEER_PLAYER_ID, "wiim_uuid:third"])
608 first_client.join_slave.assert_awaited_once()
609 second_client.join_slave.assert_awaited_once()
610
611
612class TestTopology:
613 """Native group topology is mapped to MA group members, leader first."""
614
615 async def test_master_lists_leader_first(
616 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
617 ) -> None:
618 """A master with a known slave reports [leader, follower]."""
619 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
620 peer = _make_shell(mock_provider, mock_client, mock_upnp_device, PEER_PLAYER_ID)
621 mock_client.get_slaves_info = AsyncMock(return_value=_slaves([PEER_HTTP_UUID]))
622 mock_provider.players = [leader, peer]
623 await leader._update_group_members()
624 assert leader._attr_group_members == [EDIFIER_PLAYER_ID, PEER_PLAYER_ID]
625
626 async def test_solo_has_no_members(
627 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
628 ) -> None:
629 """A solo device manages no members."""
630 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
631 mock_client.get_slaves_info = AsyncMock(return_value=_slaves([]))
632 await leader._update_group_members()
633 assert leader._attr_group_members == []
634
635 async def test_follower_has_no_members(
636 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
637 ) -> None:
638 """A follower derives its relationship from the leader, managing no members itself."""
639 follower = _make_shell(mock_provider, mock_client, mock_upnp_device)
640 mock_client.get_slaves_info = AsyncMock(return_value=_slaves([]))
641 await follower._update_group_members()
642 assert follower._attr_group_members == []
643
644 async def test_unknown_slave_ignored(
645 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
646 ) -> None:
647 """A master whose only slave is not a registered player reports no members."""
648 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
649 mock_client.get_slaves_info = AsyncMock(return_value=_slaves(["001122334455667788990011"]))
650 mock_provider.players = [leader]
651 await leader._update_group_members()
652 assert leader._attr_group_members == []
653
654
655class TestMixedGroupReadOnly:
656 """An externally-created mixed group is read-only from the generic side (until layer 2)."""
657
658 def _mixed_leader(
659 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
660 ) -> LinkPlayPlayer:
661 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
662 leader._linkplay_available = True
663 # an official WiiM member (not a LinkPlayPlayer) shares the hardware group
664 official = MagicMock()
665 official.player_id = PEER_PLAYER_ID
666 mock_provider.players = [leader, official]
667 mock_provider.mass.players.get_player.side_effect = lambda pid, *_a, **_k: (
668 official if pid == PEER_PLAYER_ID else None
669 )
670 mock_client.get_slaves_info = AsyncMock(return_value=_slaves([PEER_HTTP_UUID]))
671 return leader
672
673 async def test_mixed_group_detected_and_represented(
674 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
675 ) -> None:
676 """A group containing an official member is flagged mixed but still shown read-only."""
677 leader = self._mixed_leader(mock_provider, mock_client, mock_upnp_device)
678 await leader._update_group_members()
679 assert leader._in_mixed_group is True
680 assert leader._attr_group_members == [EDIFIER_PLAYER_ID, PEER_PLAYER_ID]
681
682 async def test_mixed_group_withdraws_grouping(
683 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
684 ) -> None:
685 """Grouping feature and targets are withdrawn while in a mixed group."""
686 leader = self._mixed_leader(mock_provider, mock_client, mock_upnp_device)
687 await leader._update_group_members()
688 assert leader.grouping_locked is True
689 assert PlayerFeature.SET_MEMBERS not in leader.supported_features
690 assert leader.can_group_with == set()
691
692 async def test_mixed_group_rejects_set_members(
693 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
694 ) -> None:
695 """Mutating a mixed group is rejected rather than partially applied."""
696 leader = self._mixed_leader(mock_provider, mock_client, mock_upnp_device)
697 await leader._update_group_members()
698 with pytest.raises(PlayerCommandFailed):
699 await leader.set_members(player_ids_to_add=[EDIFIER_PLAYER_ID])
700
701 async def test_mixed_group_member_rejected(
702 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
703 ) -> None:
704 """A member that is itself in a mixed group is rejected before any join."""
705 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
706 leader._linkplay_available = True
707 member_client = MagicMock(join_slave=AsyncMock(), leave_group=AsyncMock())
708 member = _make_shell(mock_provider, member_client, mock_upnp_device, PEER_PLAYER_ID)
709 member._linkplay_available = True
710 # the member leads an externally-created mixed group with an official (other-backend) device
711 official = MagicMock(player_id="wiim_uuid:official", linkplay_backend="official")
712 official._attr_group_members = []
713 member._attr_group_members = [PEER_PLAYER_ID, "wiim_uuid:official"]
714 mock_provider.players = [leader, member, official]
715 registered = {PEER_PLAYER_ID: member, "wiim_uuid:official": official}
716 mock_provider.mass.players.get_player.side_effect = lambda pid, *_a, **_k: registered.get(
717 pid
718 )
719 assert member._in_mixed_group is True
720 with pytest.raises(PlayerCommandFailed):
721 await leader.set_members(player_ids_to_add=[PEER_PLAYER_ID])
722 member_client.join_slave.assert_not_awaited()
723
724 def test_generic_follower_of_official_leader_is_mixed(
725 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
726 ) -> None:
727 """A generic device an official leader lists as a follower is read-only too."""
728 follower = _make_shell(mock_provider, mock_client, mock_upnp_device)
729 follower._linkplay_available = True
730 follower._attr_group_members = [] # a follower manages nothing itself
731 # an official leader (other backend) currently lists this generic device
732 official_leader = MagicMock(
733 player_id="wiim_uuid:official-leader", linkplay_backend="official"
734 )
735 official_leader._attr_group_members = [official_leader.player_id, EDIFIER_PLAYER_ID]
736 mock_provider.players = [follower, official_leader]
737 assert PlayerFeature.SET_MEMBERS not in follower.supported_features
738 assert follower.can_group_with == set()
739
740
741class TestGroupCompatibility:
742 """Only compatible, modern router-based generic LinkPlay devices may be grouped."""
743
744 def test_same_major_generation_compatible(self) -> None:
745 """4.2 and 4.3 (same WMRM major, router-based) can be grouped."""
746 first = _device_info("4.2")
747 second = _device_info("4.3")
748 assert linkplay_group_compatible(first, second) is True
749
750 def test_wifi_direct_rejected(self) -> None:
751 """A legacy Wi-Fi-Direct device is never grouped."""
752 first = _device_info("4.2")
753 second = _device_info("2.0", legacy=True)
754 assert linkplay_group_compatible(first, second) is False
755
756 def test_different_major_generation_rejected(self) -> None:
757 """Different WMRM major generations are not grouped."""
758 first = _device_info("4.2")
759 second = _device_info("3.0")
760 assert linkplay_group_compatible(first, second) is False
761
762 def test_unknown_device_info_rejected(self) -> None:
763 """An unknown (missing) device info is treated as incompatible."""
764 known = _device_info("4.2")
765 assert linkplay_group_compatible(None, known) is False
766
767 def test_unknown_generation_rejected(self) -> None:
768 """A device whose WMRM generation cannot be determined is not grouped."""
769 known = _device_info("4.2")
770 unknown = cast(
771 "PywiimDeviceInfo",
772 SimpleNamespace(wmrm_version=None, needs_wifi_direct_multiroom=False),
773 )
774 assert linkplay_group_compatible(known, unknown) is False
775
776 async def test_can_group_with_excludes_incompatible_peer(
777 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
778 ) -> None:
779 """A reachable but legacy/incompatible peer is not offered as a grouping target."""
780 player = _make_shell(mock_provider, mock_client, mock_upnp_device)
781 player._linkplay_available = True
782 peer = _make_shell(mock_provider, mock_client, mock_upnp_device, PEER_PLAYER_ID)
783 peer._linkplay_available = True
784 peer._cached_device_info = _device_info("2.0", legacy=True)
785 mock_provider.players = [player, peer]
786 assert player.can_group_with == set()
787
788
789class TestRefreshResilience:
790 """A poll must survive a transient topology blip and detect an unreachable API."""
791
792 async def test_transient_topology_read_keeps_last_members(
793 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
794 ) -> None:
795 """A failed slave-list read must not clear a real group; last members are kept."""
796 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
797 peer = _make_shell(mock_provider, mock_client, mock_upnp_device, PEER_PLAYER_ID)
798 mock_provider.players = [leader, peer]
799 mock_client.get_slaves_info = AsyncMock(return_value=_slaves([PEER_HTTP_UUID]))
800 await leader._refresh_linkplay()
801 assert leader._attr_group_members == [EDIFIER_PLAYER_ID, PEER_PLAYER_ID]
802 # a topology read blip must not drop the group, and the device stays available
803 mock_client.get_slaves_info = AsyncMock(side_effect=WiiMError("blip"))
804 await leader._refresh_linkplay()
805 assert leader._attr_group_members == [EDIFIER_PLAYER_ID, PEER_PLAYER_ID]
806 assert leader._linkplay_available is True
807
808 async def test_unreachable_api_marks_unhealthy_but_keeps_topology(
809 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
810 ) -> None:
811 """When unreachable the shell goes unhealthy but keeps its last known group."""
812 leader = _make_shell(mock_provider, mock_client, mock_upnp_device)
813 leader._linkplay_available = True
814 leader._attr_group_members = [EDIFIER_PLAYER_ID, PEER_PLAYER_ID]
815 mock_client.get_device_info_model = AsyncMock(side_effect=WiiMError("down"))
816 await leader._refresh_linkplay()
817 assert leader._linkplay_available is False
818 # last known topology is kept so the reachable side stays locked read-only
819 assert leader._attr_group_members == [EDIFIER_PLAYER_ID, PEER_PLAYER_ID]
820
821 async def test_setup_without_primed_info_does_full_refresh(
822 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
823 ) -> None:
824 """A shell built without a primed device info probes the API during setup."""
825 player = LinkPlayPlayer(
826 provider=mock_provider,
827 player_id=EDIFIER_PLAYER_ID,
828 client=mock_client,
829 upnp_device=mock_upnp_device,
830 description_url="http://192.168.1.50:49152/description.xml",
831 )
832 player.update_state = MagicMock() # type: ignore[misc,method-assign]
833 mock_provider.players = [player]
834 assert player._linkplay_available is False
835 await player.setup()
836 mock_client.get_device_info_model.assert_awaited_once()
837 assert player._linkplay_available is True
838
839
840class TestAddressChange:
841 """A moved device rebuilds its low-level client without touching the MA player."""
842
843 async def test_successful_rebuild_swaps_client(
844 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
845 ) -> None:
846 """A reachable new address swaps in a fresh client and updates the IP identifier."""
847 player = _make_shell(mock_provider, mock_client, mock_upnp_device)
848 new_client = MagicMock(host="192.168.1.99")
849 new_client.get_device_info_model = AsyncMock(return_value=SimpleNamespace(uuid=""))
850 new_client.get_slaves_info = AsyncMock(return_value=_slaves([]))
851 mock_provider.players = [player]
852 with patch(
853 "music_assistant.providers.wiim.linkplay_player.WiiMClient", return_value=new_client
854 ):
855 await player.async_handle_address_change(
856 "192.168.1.99", mock_upnp_device, "http://192.168.1.99:49152/description.xml"
857 )
858 assert player._client is new_client
859 assert player.device_info.identifiers[IdentifierType.IP_ADDRESS] == "192.168.1.99"
860
861 async def test_failed_rebuild_keeps_old_client(
862 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
863 ) -> None:
864 """If the new address is unreachable, the existing client is preserved."""
865 player = _make_shell(mock_provider, mock_client, mock_upnp_device)
866 new_client = MagicMock(host="192.168.1.99")
867 new_client.get_device_info_model = AsyncMock(side_effect=WiiMError("unreachable"))
868 with patch(
869 "music_assistant.providers.wiim.linkplay_player.WiiMClient", return_value=new_client
870 ):
871 await player.async_handle_address_change(
872 "192.168.1.99", mock_upnp_device, "http://192.168.1.99:49152/description.xml"
873 )
874 assert player._client is mock_client
875
876
877class TestGroupingConcurrency:
878 """Grouping and address rebuilds serialize per device via a deterministic lock order."""
879
880 async def test_concurrent_grouping_sharing_device_serializes(
881 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
882 ) -> None:
883 """Two leaders adding the same member never touch that member's hardware at once."""
884 overlap = {"current": 0, "max": 0}
885
886 async def _join(*_args: Any, **_kwargs: Any) -> None:
887 overlap["current"] += 1
888 overlap["max"] = max(overlap["max"], overlap["current"])
889 await asyncio.sleep(0) # yield so a second, unserialized join could overlap
890 overlap["current"] -= 1
891
892 member_client = MagicMock(join_slave=AsyncMock(side_effect=_join), leave_group=AsyncMock())
893 member = _make_shell(mock_provider, member_client, mock_upnp_device, PEER_PLAYER_ID)
894 member._linkplay_available = True
895 leader_one = _make_shell(
896 mock_provider, MagicMock(host="192.168.1.51"), mock_upnp_device, "wiim_uuid:leader-1"
897 )
898 leader_two = _make_shell(
899 mock_provider, MagicMock(host="192.168.1.52"), mock_upnp_device, "wiim_uuid:leader-2"
900 )
901 for leader in (leader_one, leader_two):
902 leader._linkplay_available = True
903 leader._verify_group_change = AsyncMock() # type: ignore[method-assign]
904 players = {
905 PEER_PLAYER_ID: member,
906 "wiim_uuid:leader-1": leader_one,
907 "wiim_uuid:leader-2": leader_two,
908 }
909 mock_provider.mass.players.get_player.side_effect = lambda pid, *_a, **_k: players.get(pid)
910 await asyncio.wait_for(
911 asyncio.gather(
912 leader_one.set_members(player_ids_to_add=[PEER_PLAYER_ID]),
913 leader_two.set_members(player_ids_to_add=[PEER_PLAYER_ID]),
914 ),
915 timeout=5,
916 )
917 # the shared member lock forced the two joins to run one after the other
918 assert overlap["max"] == 1
919 assert member_client.join_slave.await_count == 2
920
921 async def test_cross_grouping_does_not_deadlock(
922 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
923 ) -> None:
924 """Two leaders adding each other complete because every caller locks in the same order."""
925 x_client = MagicMock(host="192.168.1.60", join_slave=AsyncMock(), leave_group=AsyncMock())
926 y_client = MagicMock(host="192.168.1.61", join_slave=AsyncMock(), leave_group=AsyncMock())
927 shell_x = _make_shell(mock_provider, x_client, mock_upnp_device, "wiim_uuid:x")
928 shell_y = _make_shell(mock_provider, y_client, mock_upnp_device, "wiim_uuid:y")
929 for shell in (shell_x, shell_y):
930 shell._linkplay_available = True
931 shell._verify_group_change = AsyncMock() # type: ignore[method-assign]
932 players = {"wiim_uuid:x": shell_x, "wiim_uuid:y": shell_y}
933 mock_provider.mass.players.get_player.side_effect = lambda pid, *_a, **_k: players.get(pid)
934 # Pre-hold the lowest-ordered lock so both requests are queued and contending before
935 # either runs; a non-deterministic per-caller order would deadlock this arrangement.
936 await shell_x._rebuild_lock.acquire()
937 task_x = asyncio.create_task(shell_x.set_members(player_ids_to_add=["wiim_uuid:y"]))
938 task_y = asyncio.create_task(shell_y.set_members(player_ids_to_add=["wiim_uuid:x"]))
939 await asyncio.sleep(0) # let both tasks reach their first lock acquisition
940 shell_x._rebuild_lock.release()
941 await asyncio.wait_for(asyncio.gather(task_x, task_y), timeout=5)
942 # each leader joined the other exactly once, with no deadlock
943 y_client.join_slave.assert_awaited_once()
944 x_client.join_slave.assert_awaited_once()
945
946 async def test_address_change_waits_for_in_flight_grouping(
947 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
948 ) -> None:
949 """A member's address rebuild blocks until the grouping holding its lock finishes."""
950 join_started = asyncio.Event()
951 release_join = asyncio.Event()
952
953 async def _join(*_args: Any, **_kwargs: Any) -> None:
954 join_started.set()
955 await release_join.wait()
956
957 member_client = MagicMock(
958 host="192.168.1.50", join_slave=AsyncMock(side_effect=_join), leave_group=AsyncMock()
959 )
960 member = _make_shell(mock_provider, member_client, mock_upnp_device, PEER_PLAYER_ID)
961 member._linkplay_available = True
962 leader = _make_shell(
963 mock_provider, MagicMock(host="192.168.1.51"), mock_upnp_device, "wiim_uuid:leader-1"
964 )
965 leader._linkplay_available = True
966 leader._verify_group_change = AsyncMock() # type: ignore[method-assign]
967 players = {PEER_PLAYER_ID: member, "wiim_uuid:leader-1": leader}
968 mock_provider.mass.players.get_player.side_effect = lambda pid, *_a, **_k: players.get(pid)
969 new_client = MagicMock(host="192.168.1.99")
970 new_client.get_device_info_model = AsyncMock(return_value=SimpleNamespace(uuid=""))
971 new_client.get_slaves_info = AsyncMock(return_value=_slaves([]))
972
973 group_task = asyncio.create_task(leader.set_members(player_ids_to_add=[PEER_PLAYER_ID]))
974 await asyncio.wait_for(join_started.wait(), timeout=5) # grouping now holds member's lock
975 with patch(
976 "music_assistant.providers.wiim.linkplay_player.WiiMClient", return_value=new_client
977 ):
978 addr_task = asyncio.create_task(
979 member.async_handle_address_change(
980 "192.168.1.99", mock_upnp_device, "http://192.168.1.99:49152/description.xml"
981 )
982 )
983 await asyncio.sleep(0)
984 # the rebuild cannot proceed while grouping holds the member lock
985 assert not addr_task.done()
986 assert member._client is member_client
987 release_join.set()
988 await asyncio.wait_for(asyncio.gather(group_task, addr_task), timeout=5)
989 assert member._client is new_client
990
991 async def test_unrelated_player_not_locked_during_grouping(
992 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
993 ) -> None:
994 """A device outside the group request can rebuild its address while grouping runs."""
995 join_started = asyncio.Event()
996 release_join = asyncio.Event()
997
998 async def _join(*_args: Any, **_kwargs: Any) -> None:
999 join_started.set()
1000 await release_join.wait()
1001
1002 member_client = MagicMock(
1003 host="192.168.1.50", join_slave=AsyncMock(side_effect=_join), leave_group=AsyncMock()
1004 )
1005 member = _make_shell(mock_provider, member_client, mock_upnp_device, PEER_PLAYER_ID)
1006 member._linkplay_available = True
1007 leader = _make_shell(
1008 mock_provider, MagicMock(host="192.168.1.51"), mock_upnp_device, "wiim_uuid:leader-1"
1009 )
1010 leader._linkplay_available = True
1011 leader._verify_group_change = AsyncMock() # type: ignore[method-assign]
1012 other = _make_shell(
1013 mock_provider, MagicMock(host="192.168.1.70"), mock_upnp_device, "wiim_uuid:other"
1014 )
1015 other._linkplay_available = True
1016 players = {
1017 PEER_PLAYER_ID: member,
1018 "wiim_uuid:leader-1": leader,
1019 "wiim_uuid:other": other,
1020 }
1021 mock_provider.mass.players.get_player.side_effect = lambda pid, *_a, **_k: players.get(pid)
1022 new_client = MagicMock(host="192.168.1.99")
1023 new_client.get_device_info_model = AsyncMock(return_value=SimpleNamespace(uuid=""))
1024 new_client.get_slaves_info = AsyncMock(return_value=_slaves([]))
1025
1026 group_task = asyncio.create_task(leader.set_members(player_ids_to_add=[PEER_PLAYER_ID]))
1027 await asyncio.wait_for(join_started.wait(), timeout=5) # grouping holds leader + member
1028 with patch(
1029 "music_assistant.providers.wiim.linkplay_player.WiiMClient", return_value=new_client
1030 ):
1031 # the unrelated device is not part of the request, so its rebuild is not blocked
1032 await asyncio.wait_for(
1033 other.async_handle_address_change(
1034 "192.168.1.99", mock_upnp_device, "http://192.168.1.99:49152/description.xml"
1035 ),
1036 timeout=5,
1037 )
1038 assert other._client is new_client
1039 release_join.set()
1040 await asyncio.wait_for(group_task, timeout=5)
1041
1042
1043class TestProviderRouting:
1044 """Discovery classifies a device and routes it to the correct backend."""
1045
1046 async def test_generic_device_registers_shell(
1047 self, mock_provider: MagicMock, mock_client: MagicMock, mock_upnp_device: MagicMock
1048 ) -> None:
1049 """A reachable generic LinkPlay device is registered as a shell."""
1050 mock_provider.players = []
1051 registered: list[Any] = []
1052 mock_provider.mass.players.register_or_update = AsyncMock(
1053 side_effect=lambda p: registered.append(p)
1054 )
1055 with patch("music_assistant.providers.wiim.provider.WiiMClient", return_value=mock_client):
1056 await WiimProvider.try_add_linkplay_player(
1057 mock_provider,
1058 EDIFIER_PLAYER_ID,
1059 "192.168.1.50",
1060 mock_upnp_device,
1061 "http://192.168.1.50:49152/description.xml",
1062 "AA:BB:CC:DD:EE:FF",
1063 )
1064 assert registered
1065 assert isinstance(registered[0], LinkPlayPlayer)
1066 # discovery does a single authoritative device-info probe that primes the shell:
1067 # setup must not repeat it, and the shell is registered already reachable.
1068 mock_client.get_device_info_model.assert_awaited_once()
1069 assert registered[0]._linkplay_available is True
1070
1071 async def test_unreachable_device_not_registered(
1072 self, mock_provider: MagicMock, mock_upnp_device: MagicMock
1073 ) -> None:
1074 """A device that does not answer the LinkPlay API is not registered."""
1075 mock_provider.mass.players.register_or_update = AsyncMock()
1076 bad_client = MagicMock()
1077 bad_client.get_device_info_model = AsyncMock(side_effect=WiiMError("no api"))
1078 with patch("music_assistant.providers.wiim.provider.WiiMClient", return_value=bad_client):
1079 await WiimProvider.try_add_linkplay_player(
1080 mock_provider,
1081 EDIFIER_PLAYER_ID,
1082 "192.168.1.50",
1083 mock_upnp_device,
1084 "http://192.168.1.50:49152/description.xml",
1085 )
1086 mock_provider.mass.players.register_or_update.assert_not_called()
1087
1088
1089class TestDefaultProtocolSelection:
1090 """The default_output_protocol_domain steers automatic output selection."""
1091
1092 def _controller(self, protocol_players: dict[str, Any], preferred: Any = None) -> MagicMock:
1093 controller = MagicMock()
1094 controller.get_player.side_effect = protocol_players.get
1095 controller.mass.config.get_raw_player_config_value.return_value = preferred
1096 controller._is_protocol_grouped.return_value = False
1097 controller.logger = MagicMock()
1098 return controller
1099
1100 def _shell_player(self, links: list[LinkedOutputProtocol]) -> MagicMock:
1101 player = MagicMock()
1102 player.default_output_protocol_domain = "dlna"
1103 player.supported_features = set() # no native PLAY_MEDIA
1104 player.linked_output_protocols = links
1105 player.get_linked_protocol.side_effect = lambda pid: next(
1106 (link for link in links if link.output_protocol_id == pid), None
1107 )
1108 return player
1109
1110 def test_prefers_default_domain_when_available(self) -> None:
1111 """With DLNA available, the DLNA output is chosen over the higher-priority AirPlay."""
1112 links = [
1113 LinkedOutputProtocol("dlna_x", "dlna", priority=50),
1114 LinkedOutputProtocol("ap_x", "airplay", priority=10),
1115 ]
1116 players = {
1117 "dlna_x": MagicMock(available_for_playback=True),
1118 "ap_x": MagicMock(available_for_playback=True),
1119 }
1120 controller = self._controller(players)
1121 player = self._shell_player(links)
1122 target, _ = ProtocolLinkingMixin._select_best_output_protocol(controller, player)
1123 assert target is players["dlna_x"]
1124
1125 def test_falls_back_to_priority_when_default_absent(self) -> None:
1126 """Without an available DLNA, selection falls back to priority (AirPlay)."""
1127 links = [
1128 LinkedOutputProtocol("dlna_x", "dlna", priority=50),
1129 LinkedOutputProtocol("ap_x", "airplay", priority=10),
1130 ]
1131 players = {
1132 "dlna_x": MagicMock(available_for_playback=False),
1133 "ap_x": MagicMock(available_for_playback=True),
1134 }
1135 controller = self._controller(players)
1136 player = self._shell_player(links)
1137 target, _ = ProtocolLinkingMixin._select_best_output_protocol(controller, player)
1138 assert target is players["ap_x"]
1139
1140 def test_explicit_user_preference_wins(self) -> None:
1141 """An explicit stored user preference overrides the default domain."""
1142 links = [
1143 LinkedOutputProtocol("dlna_x", "dlna", priority=50),
1144 LinkedOutputProtocol("ap_x", "airplay", priority=10),
1145 ]
1146 players = {
1147 "dlna_x": MagicMock(available_for_playback=True),
1148 "ap_x": MagicMock(available_for_playback=True),
1149 }
1150 controller = self._controller(players, preferred="ap_x")
1151 player = self._shell_player(links)
1152 target, _ = ProtocolLinkingMixin._select_best_output_protocol(controller, player)
1153 assert target is players["ap_x"]
1154
1155 def test_explicit_auto_resolves_to_default_domain(self) -> None:
1156 """Selecting Auto resolves to the default domain, just like an unset value."""
1157 links = [
1158 LinkedOutputProtocol("dlna_x", "dlna", priority=50),
1159 LinkedOutputProtocol("ap_x", "airplay", priority=10),
1160 ]
1161 players = {
1162 "dlna_x": MagicMock(available_for_playback=True),
1163 "ap_x": MagicMock(available_for_playback=True),
1164 }
1165 controller = self._controller(players, preferred="auto")
1166 player = self._shell_player(links)
1167 target, _ = ProtocolLinkingMixin._select_best_output_protocol(controller, player)
1168 assert target is players["dlna_x"]
1169