/
/
1"""Tests for protocol player linking and universal player creation."""
2
3import asyncio
4import inspect
5import logging
6import re
7from collections.abc import Awaitable, Iterator
8from typing import Any
9from unittest.mock import AsyncMock, MagicMock, patch
10from uuid import UUID
11
12import pytest
13from music_assistant_models.config_entries import PlayerConfig
14from music_assistant_models.enums import (
15 EventType,
16 IdentifierType,
17 PlaybackState,
18 PlayerFeature,
19 PlayerType,
20)
21from music_assistant_models.player import PlayerMedia
22
23from music_assistant.constants import (
24 ATTR_ENABLED,
25 CONF_PLAYER_DSP,
26 CONF_PLAYER_QUEUES,
27 CONF_PLAYERS,
28 CONF_PREFERRED_OUTPUT_PROTOCOL,
29 CONF_PROTOCOL_PARENT_ID,
30 CONF_UNDERLYING_PLAYER_ID,
31)
32from music_assistant.controllers.config import ConfigController
33from music_assistant.controllers.config.migrations import migrate
34from music_assistant.controllers.players import PlayerController
35from music_assistant.helpers.util import enrich_device_mac_address
36from music_assistant.models.player import DeviceInfo, LinkedOutputProtocol, Player
37from music_assistant.models.player_provider import PlayerProvider
38from music_assistant.providers.universal_player.constants import CONF_CREATED_AT
39from music_assistant.providers.universal_player.player import UniversalPlayer
40from music_assistant.providers.universal_player.provider import UniversalPlayerProvider
41
42
43def create_mock_config(name: str) -> MagicMock:
44 """Create a mock player config with the given name."""
45 config = MagicMock()
46 config.name = None # No custom name, use default
47 config.default_name = name
48 return config
49
50
51def create_mock_universal_provider(mock_mass: MagicMock) -> UniversalPlayerProvider:
52 """Create a mock UniversalPlayerProvider for testing."""
53 # Create a mock manifest
54 manifest = MagicMock()
55 manifest.domain = "universal_player"
56 manifest.name = "Universal Player"
57
58 # Create provider with the mock manifest
59 provider = UniversalPlayerProvider.__new__(UniversalPlayerProvider)
60 provider.mass = mock_mass
61 provider.manifest = manifest
62 provider.logger = logging.getLogger("test.universal_player")
63 # Add config mock for instance_id property
64 config = MagicMock()
65 config.instance_id = "universal_player"
66 config.name = None
67 provider.config = config
68 # Initialize the lock that would normally be set in handle_async_init
69 provider._lock = asyncio.Lock()
70 return provider
71
72
73# Player id of the universal player a device was wrapped in earlier, as minted once
74# by UniversalPlayerProvider.mint_player_id.
75STORED_UNIVERSAL_ID = "up1a2b3c4d"
76
77
78def _wire_nested_config(mock_mass: MagicMock, store: dict[str, Any]) -> None:
79 """Wire mass.config get/set/remove to a nested, path-addressable store."""
80
81 def _get(key: str, default: object = None) -> object:
82 node: Any = store
83 for part in key.split("/"):
84 if not isinstance(node, dict) or part not in node:
85 return default
86 node = node[part]
87 return node
88
89 def _set(key: str, value: object) -> None:
90 parts = key.split("/")
91 node: dict[str, Any] = store
92 for part in parts[:-1]:
93 node = node.setdefault(part, {})
94 node[parts[-1]] = value
95
96 def _remove(key: str) -> None:
97 parts = key.split("/")
98 node: Any = store
99 for part in parts[:-1]:
100 node = node.get(part) if isinstance(node, dict) else None
101 if node is None:
102 return
103 if isinstance(node, dict):
104 node.pop(parts[-1], None)
105
106 mock_mass.config.get = MagicMock(side_effect=_get)
107 mock_mass.config.set = MagicMock(side_effect=_set)
108 mock_mass.config.remove = MagicMock(side_effect=_remove)
109
110
111def _fake_create_default_player_config(
112 store: dict[str, Any],
113 player_id: str,
114 provider: str,
115 player_type: PlayerType,
116 name: str | None = None,
117 enabled: bool = True,
118 values: dict[str, Any] | None = None,
119) -> None:
120 """
121 Mirror ConfigController.create_default_player_config against a plain dict store.
122
123 Only the parts the universal player flows depend on: a fresh config keeps the
124 creation-time name as the default name only, an existing config keeps its type.
125 """
126 players: dict[str, Any] = store.setdefault(CONF_PLAYERS, {})
127 if existing := players.get(player_id):
128 if name and name != existing.get("default_name"):
129 existing["default_name"] = name
130 return
131 players[player_id] = {
132 "player_id": player_id,
133 "provider": provider,
134 "player_type": player_type.value,
135 "enabled": enabled,
136 "name": None,
137 "default_name": name,
138 "values": dict(values or {}),
139 }
140
141
142class MockProvider:
143 """Mock player provider for testing."""
144
145 def __init__(
146 self, domain: str, instance_id: str = "test_instance", mass: MagicMock | None = None
147 ) -> None:
148 """Initialize the mock provider."""
149 self.domain = domain
150 self.instance_id = instance_id
151 self.name = f"Mock {domain.title()}"
152 self.manifest = MagicMock()
153 self.manifest.name = f"Mock {domain} Provider"
154 self.mass = mass or MagicMock()
155 self.logger = logging.getLogger(f"test.{domain}")
156 # the controller iterates these when a group related field changes
157 self.players: list[Player] = []
158
159
160class MockPlayer(Player):
161 """Mock player for testing."""
162
163 def __init__(
164 self,
165 provider: MockProvider,
166 player_id: str,
167 name: str,
168 player_type: PlayerType = PlayerType.PLAYER,
169 identifiers: dict[IdentifierType, str] | None = None,
170 ) -> None:
171 """Initialize the mock player."""
172 # Set up the mock config before calling super().__init__
173 # because the parent __init__ accesses config
174 provider.mass.config.get_base_player_config.return_value = create_mock_config(name)
175
176 super().__init__(provider, player_id) # type: ignore[arg-type]
177 self._attr_name = name
178 # Set type as instance attribute (overrides class attribute)
179 self._attr_type = player_type
180 self._attr_available = True
181 self._attr_powered = True
182 self._attr_supported_features = {PlayerFeature.VOLUME_SET}
183 self._attr_can_group_with = set()
184
185 # Set up device info with identifiers
186 self._attr_device_info = DeviceInfo(
187 model="Test Model",
188 manufacturer="Test Manufacturer",
189 )
190 if identifiers:
191 for conn_type, value in identifiers.items():
192 self._attr_device_info.add_identifier(conn_type, value)
193
194 # Clear cached properties after modifying attributes
195 self._cache.clear()
196
197 # Update state to reflect the modified attributes
198 self.update_state(signal_event=False)
199
200 async def stop(self) -> None:
201 """Stop playback - required abstract method."""
202
203 async def set_members(
204 self,
205 player_ids_to_add: list[str] | None = None,
206 player_ids_to_remove: list[str] | None = None,
207 ) -> None:
208 """Mock implementation of set_members."""
209 current_members = set(getattr(self, "_attr_group_members", []))
210
211 if player_ids_to_add:
212 current_members.update(player_ids_to_add)
213
214 if player_ids_to_remove:
215 current_members.difference_update(player_ids_to_remove)
216
217 # Always include self as first member if there are members
218 if current_members:
219 self._attr_group_members = [self.player_id] + [
220 pid for pid in current_members if pid != self.player_id
221 ]
222 else:
223 self._attr_group_members = []
224
225 # Clear cache to reflect changes
226 self._cache.clear()
227
228
229class SessionBoundMockPlayer(MockPlayer):
230 """Mock player whose native grouping attaches members to its own stream (e.g. AirPlay)."""
231
232 @property
233 def native_grouping_requires_own_stream(self) -> bool:
234 """Return True: native members ride this player's own stream session."""
235 return True
236
237
238@pytest.fixture
239def mock_mass() -> Iterator[MagicMock]:
240 """Create a mock MusicAssistant instance."""
241 mass = MagicMock()
242 mass.closing = False
243 mass.config = MagicMock()
244 mass.config.get = MagicMock(return_value=[])
245
246 def _get_raw_player_config_value(
247 _player_id: str, key: str, default: str | int | None = None
248 ) -> str | int | None:
249 """Return appropriate defaults for player config values."""
250 if key == "min_volume":
251 return 0
252 if key == "max_volume":
253 return 100
254 return default
255
256 mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw_player_config_value)
257 # Return "GLOBAL" for log level config (standard default)
258 mass.config.get_raw_core_config_value = MagicMock(return_value="GLOBAL")
259 mass.config.set = MagicMock()
260 mass.signal_event = MagicMock()
261 mass.get_providers = MagicMock(return_value=[])
262 # awaited by the tests that replay the tasks scheduled during a config wipe
263 mass.webserver.auth.remove_from_user_filters = AsyncMock()
264 mass.webserver.auth.replace_player_in_user_filters = AsyncMock()
265 yield mass
266 for call in mass.create_task.call_args_list:
267 if call.args and inspect.iscoroutine(coro := call.args[0]):
268 coro.close()
269
270
271class TestIdentifiersMatch:
272 """Tests for identifier matching logic."""
273
274 def test_mac_address_match(self, mock_mass: MagicMock) -> None:
275 """Test that MAC addresses match correctly."""
276 controller = PlayerController(mock_mass)
277
278 provider = MockProvider("test")
279 player_a = MockPlayer(
280 provider,
281 "player_a",
282 "Player A",
283 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
284 )
285 player_b = MockPlayer(
286 provider,
287 "player_b",
288 "Player B",
289 identifiers={IdentifierType.MAC_ADDRESS: "aa:bb:cc:dd:ee:ff"}, # lowercase
290 )
291
292 assert controller._identifiers_match(player_a, player_b) is True
293
294 def test_mac_address_no_match(self, mock_mass: MagicMock) -> None:
295 """Test that different MAC addresses don't match."""
296 controller = PlayerController(mock_mass)
297
298 provider = MockProvider("test")
299 player_a = MockPlayer(
300 provider,
301 "player_a",
302 "Player A",
303 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
304 )
305 player_b = MockPlayer(
306 provider,
307 "player_b",
308 "Player B",
309 identifiers={IdentifierType.MAC_ADDRESS: "11:22:33:44:55:66"},
310 )
311
312 assert controller._identifiers_match(player_a, player_b) is False
313
314 def test_invalid_mac_address_no_match(self, mock_mass: MagicMock) -> None:
315 """Test that an all-zero MAC is not identity and never matches across devices."""
316 controller = PlayerController(mock_mass)
317
318 provider = MockProvider("test")
319 player_a = MockPlayer(
320 provider,
321 "player_a",
322 "Player A",
323 identifiers={IdentifierType.MAC_ADDRESS: "00:00:00:00:00:00"},
324 )
325 player_b = MockPlayer(
326 provider,
327 "player_b",
328 "Player B",
329 identifiers={IdentifierType.MAC_ADDRESS: "00:00:00:00:00:00"},
330 )
331
332 assert controller._identifiers_match(player_a, player_b) is False
333
334 def test_mac_address_locally_administered_bit_match(self, mock_mass: MagicMock) -> None:
335 """
336 Test that MAC addresses differing only in locally-administered bit match.
337
338 Some protocols (like AirPlay) report a MAC with the locally-administered
339 bit set (bit 1 of first octet), while other protocols report the real
340 hardware MAC. These should match as the same device.
341
342 Example: 54:78:C9:E6:0D:A0 (hardware) vs 56:78:C9:E6:0D:A0 (AirPlay)
343 """
344 controller = PlayerController(mock_mass)
345
346 provider = MockProvider("test")
347 # Real hardware MAC (first byte 0x54 = 01010100, bit 1 = 0)
348 player_a = MockPlayer(
349 provider,
350 "player_a",
351 "WiiM Pro (DLNA)",
352 identifiers={IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"},
353 )
354 # AirPlay MAC with locally-administered bit set (first byte 0x56 = 01010110, bit 1 = 1)
355 player_b = MockPlayer(
356 provider,
357 "player_b",
358 "WiiM Pro (AirPlay)",
359 identifiers={IdentifierType.MAC_ADDRESS: "56:78:C9:E6:0D:A0"},
360 )
361
362 # These should match because they differ only in the locally-administered bit
363 assert controller._identifiers_match(player_a, player_b) is True
364
365 def test_mac_address_locally_administered_bit_different_devices_no_match(
366 self, mock_mass: MagicMock
367 ) -> None:
368 """
369 Test that different devices with locally-administered MACs don't match.
370
371 Only the locally-administered bit should be ignored, not other differences.
372 """
373 controller = PlayerController(mock_mass)
374
375 provider = MockProvider("test")
376 player_a = MockPlayer(
377 provider,
378 "player_a",
379 "Device A",
380 identifiers={IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"},
381 )
382 player_b = MockPlayer(
383 provider,
384 "player_b",
385 "Device B",
386 identifiers={IdentifierType.MAC_ADDRESS: "56:78:C9:E6:0D:A1"}, # Different last byte
387 )
388
389 # These should NOT match - they differ in more than just the locally-administered bit
390 assert controller._identifiers_match(player_a, player_b) is False
391
392 def test_ip_address_fallback_with_no_mac(self, mock_mass: MagicMock) -> None:
393 """Test that same IP matches when no MAC is available (ARP couldn't help)."""
394 controller = PlayerController(mock_mass)
395
396 provider = MockProvider("test")
397 player_a = MockPlayer(
398 provider,
399 "player_a",
400 "Player A",
401 identifiers={IdentifierType.IP_ADDRESS: "192.168.1.100"},
402 )
403 player_b = MockPlayer(
404 provider,
405 "player_b",
406 "Player B",
407 identifiers={IdentifierType.IP_ADDRESS: "192.168.1.100"},
408 )
409
410 # Same IP with no MAC at all = match (likely same device, ARP unavailable)
411 assert controller._identifiers_match(player_a, player_b) is True
412
413 def test_sonos_uuid_dlna_suffix_match(self, mock_mass: MagicMock) -> None:
414 """Test Sonos UUID matching with DLNA _MR suffix."""
415 controller = PlayerController(mock_mass)
416
417 provider = MockProvider("test")
418 # Sonos native player
419 player_a = MockPlayer(
420 provider,
421 "player_a",
422 "Sonos Player",
423 identifiers={IdentifierType.UUID: "RINCON_000E58123456"},
424 )
425 # DLNA player with _MR suffix
426 player_b = MockPlayer(
427 provider,
428 "player_b",
429 "DLNA Player",
430 identifiers={IdentifierType.UUID: "RINCON_000E58123456_MR"},
431 )
432
433 assert controller._identifiers_match(player_a, player_b) is True
434
435 def test_no_identifiers_no_match(self, mock_mass: MagicMock) -> None:
436 """Test that players without identifiers don't match."""
437 controller = PlayerController(mock_mass)
438
439 provider = MockProvider("test")
440 player_a = MockPlayer(provider, "player_a", "Player A")
441 player_b = MockPlayer(provider, "player_b", "Player B")
442
443 assert controller._identifiers_match(player_a, player_b) is False
444
445 def test_cast_uuid_match(self, mock_mass: MagicMock) -> None:
446 """
447 Test that CAST_UUID identifiers match for cross-protocol linking.
448
449 When a Chromecast device has no valid MAC (non-Google devices like SEI Robotics),
450 the CAST_UUID identifier allows matching the Chromecast player with its
451 Sendspin bridge player.
452 """
453 controller = PlayerController(mock_mass)
454
455 provider = MockProvider("test")
456 # Chromecast player with UUID and CAST_UUID but no MAC
457 player_a = MockPlayer(
458 provider,
459 "player_a",
460 "Android TV (Cast)",
461 player_type=PlayerType.PROTOCOL,
462 identifiers={
463 IdentifierType.UUID: "88ef6168-67d7-3d08-fae8-b1b1709c1ed7",
464 IdentifierType.CAST_UUID: "88ef6168-67d7-3d08-fae8-b1b1709c1ed7",
465 IdentifierType.IP_ADDRESS: "192.168.1.228",
466 },
467 )
468 # Sendspin bridge player with only MAC and CAST_UUID (no UUID, no IP)
469 player_b = MockPlayer(
470 provider,
471 "player_b",
472 "Android TV (Sendspin)",
473 player_type=PlayerType.PROTOCOL,
474 identifiers={
475 IdentifierType.MAC_ADDRESS: "D4:CF:F9:D9:DA:FD",
476 IdentifierType.CAST_UUID: "88ef6168-67d7-3d08-fae8-b1b1709c1ed7",
477 },
478 )
479
480 # Should match via CAST_UUID even though they share no MAC, UUID, or IP
481 assert controller._identifiers_match(player_a, player_b) is True
482
483 def test_cast_uuid_no_match(self, mock_mass: MagicMock) -> None:
484 """Test that different CAST_UUIDs don't match."""
485 controller = PlayerController(mock_mass)
486
487 provider = MockProvider("test")
488 player_a = MockPlayer(
489 provider,
490 "player_a",
491 "Device A",
492 player_type=PlayerType.PROTOCOL,
493 identifiers={IdentifierType.CAST_UUID: "88ef6168-67d7-3d08-fae8-b1b1709c1ed7"},
494 )
495 player_b = MockPlayer(
496 provider,
497 "player_b",
498 "Device B",
499 player_type=PlayerType.PROTOCOL,
500 identifiers={IdentifierType.CAST_UUID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"},
501 )
502
503 assert controller._identifiers_match(player_a, player_b) is False
504
505 def test_airplay_id_match(self, mock_mass: MagicMock) -> None:
506 """
507 Test that AIRPLAY_ID identifiers match for cross-protocol linking.
508
509 When an AirPlay device's MAC has locally-administered bit differences,
510 AIRPLAY_ID provides a reliable secondary match path between the AirPlay
511 player and its Sendspin bridge player.
512 """
513 controller = PlayerController(mock_mass)
514
515 provider = MockProvider("test")
516 # AirPlay player with its player_id as AIRPLAY_ID
517 player_a = MockPlayer(
518 provider,
519 "player_a",
520 "Apple TV (AirPlay)",
521 player_type=PlayerType.PROTOCOL,
522 identifiers={
523 IdentifierType.MAC_ADDRESS: "54:78:1A:D4:FF:44",
524 IdentifierType.AIRPLAY_ID: "ap54781ad4ff44",
525 IdentifierType.IP_ADDRESS: "192.168.1.50",
526 },
527 )
528 # Sendspin bridge player with MAC and AIRPLAY_ID
529 player_b = MockPlayer(
530 provider,
531 "player_b",
532 "Apple TV (Sendspin)",
533 player_type=PlayerType.PROTOCOL,
534 identifiers={
535 IdentifierType.MAC_ADDRESS: "54:78:1A:D4:FF:44",
536 IdentifierType.AIRPLAY_ID: "ap54781ad4ff44",
537 },
538 )
539
540 # Should match via both MAC and AIRPLAY_ID
541 assert controller._identifiers_match(player_a, player_b) is True
542
543 def test_airplay_id_no_match(self, mock_mass: MagicMock) -> None:
544 """Test that different AIRPLAY_IDs don't match."""
545 controller = PlayerController(mock_mass)
546
547 provider = MockProvider("test")
548 player_a = MockPlayer(
549 provider,
550 "player_a",
551 "Device A",
552 player_type=PlayerType.PROTOCOL,
553 identifiers={IdentifierType.AIRPLAY_ID: "ap54781ad4ff44"},
554 )
555 player_b = MockPlayer(
556 provider,
557 "player_b",
558 "Device B",
559 player_type=PlayerType.PROTOCOL,
560 identifiers={IdentifierType.AIRPLAY_ID: "apaabbccddeeff"},
561 )
562
563 assert controller._identifiers_match(player_a, player_b) is False
564
565 def test_ip_fallback_with_la_mac(self, mock_mass: MagicMock) -> None:
566 """Test IP fallback matching when one player has a locally-administered MAC."""
567 controller = PlayerController(mock_mass)
568
569 provider = MockProvider("test")
570 player_a = MockPlayer(
571 provider,
572 "player_a",
573 "AirPlay Device",
574 player_type=PlayerType.PROTOCOL,
575 identifiers={
576 IdentifierType.MAC_ADDRESS: "C0:F5:35:7B:6D:A0",
577 IdentifierType.IP_ADDRESS: "192.168.1.48",
578 },
579 )
580 player_b = MockPlayer(
581 provider,
582 "player_b",
583 "DLNA Device",
584 player_type=PlayerType.PROTOCOL,
585 identifiers={
586 # Locally-administered MAC from ARP (device uses MAC randomization)
587 IdentifierType.MAC_ADDRESS: "02:4E:52:A1:BC:6B",
588 IdentifierType.IP_ADDRESS: "192.168.1.48",
589 },
590 )
591
592 assert controller._identifiers_match(player_a, player_b) is True
593
594 def test_ip_fallback_with_missing_mac(self, mock_mass: MagicMock) -> None:
595 """Test IP fallback matching when one player has no MAC at all."""
596 controller = PlayerController(mock_mass)
597
598 provider = MockProvider("test")
599 player_a = MockPlayer(
600 provider,
601 "player_a",
602 "AirPlay Device",
603 player_type=PlayerType.PROTOCOL,
604 identifiers={
605 IdentifierType.MAC_ADDRESS: "C0:F5:35:7B:6D:A0",
606 IdentifierType.IP_ADDRESS: "192.168.1.48",
607 },
608 )
609 player_b = MockPlayer(
610 provider,
611 "player_b",
612 "DLNA Device",
613 player_type=PlayerType.PROTOCOL,
614 identifiers={
615 IdentifierType.IP_ADDRESS: "192.168.1.48",
616 },
617 )
618
619 assert controller._identifiers_match(player_a, player_b) is True
620
621 def test_ip_fallback_protocol_players_with_real_macs(self, mock_mass: MagicMock) -> None:
622 """
623 Test that IP matches protocol players even when both have real MACs.
624
625 Some devices (e.g., Yamaha MusicCast) report different valid globally-unique
626 MACs per protocol (DLNA vs AirPlay differ by 1 in the last octet).
627 IP matching is safe here because two devices on a LAN can't share an IP.
628 """
629 controller = PlayerController(mock_mass)
630
631 provider = MockProvider("test")
632 player_a = MockPlayer(
633 provider,
634 "player_a",
635 "Device A",
636 player_type=PlayerType.PROTOCOL,
637 identifiers={
638 IdentifierType.MAC_ADDRESS: "C0:F5:35:7B:6D:A0",
639 IdentifierType.IP_ADDRESS: "192.168.1.48",
640 },
641 )
642 player_b = MockPlayer(
643 provider,
644 "player_b",
645 "Device B",
646 player_type=PlayerType.PROTOCOL,
647 identifiers={
648 # A8 first octet = 10101000, LA bit (0x02) is clear -> real MAC
649 IdentifierType.MAC_ADDRESS: "A8:BB:CC:DD:EE:FF",
650 IdentifierType.IP_ADDRESS: "192.168.1.48",
651 },
652 )
653
654 # Protocol players with real MACs on same IP -> match (same physical device)
655 assert controller._identifiers_match(player_a, player_b) is True
656
657 def test_ip_no_fallback_native_players_with_real_macs(self, mock_mass: MagicMock) -> None:
658 """Test that IP alone does NOT match two native PLAYER-type players with real MACs."""
659 controller = PlayerController(mock_mass)
660
661 provider = MockProvider("test")
662 player_a = MockPlayer(
663 provider,
664 "player_a",
665 "Device A",
666 player_type=PlayerType.PLAYER,
667 identifiers={
668 IdentifierType.MAC_ADDRESS: "C0:F5:35:7B:6D:A0",
669 IdentifierType.IP_ADDRESS: "192.168.1.48",
670 },
671 )
672 player_b = MockPlayer(
673 provider,
674 "player_b",
675 "Device B",
676 player_type=PlayerType.PLAYER,
677 identifiers={
678 IdentifierType.MAC_ADDRESS: "A8:BB:CC:DD:EE:FF",
679 IdentifierType.IP_ADDRESS: "192.168.1.48",
680 },
681 )
682
683 # Two native players with real MACs that don't match - IP should NOT be used
684 assert controller._identifiers_match(player_a, player_b) is False
685
686 def test_ip_no_fallback_different_ips(self, mock_mass: MagicMock) -> None:
687 """Test that different IPs don't match even with LA MAC."""
688 controller = PlayerController(mock_mass)
689
690 provider = MockProvider("test")
691 player_a = MockPlayer(
692 provider,
693 "player_a",
694 "Device A",
695 player_type=PlayerType.PROTOCOL,
696 identifiers={
697 IdentifierType.MAC_ADDRESS: "C0:F5:35:7B:6D:A0",
698 IdentifierType.IP_ADDRESS: "192.168.1.48",
699 },
700 )
701 player_b = MockPlayer(
702 provider,
703 "player_b",
704 "Device B",
705 player_type=PlayerType.PROTOCOL,
706 identifiers={
707 IdentifierType.MAC_ADDRESS: "02:4E:52:A1:BC:6B",
708 IdentifierType.IP_ADDRESS: "192.168.1.99",
709 },
710 )
711
712 assert controller._identifiers_match(player_a, player_b) is False
713
714
715class TestProtocolPlayerDetection:
716 """Tests for protocol player type detection."""
717
718 def test_is_protocol_player_true(self, mock_mass: MagicMock) -> None:
719 """Test that PlayerType.PROTOCOL is correctly detected."""
720 controller = PlayerController(mock_mass)
721
722 provider = MockProvider("airplay")
723 player = MockPlayer(
724 provider,
725 "ap_123456",
726 "Samsung TV (AirPlay)",
727 player_type=PlayerType.PROTOCOL,
728 )
729
730 assert controller._is_protocol_player(player) is True
731
732 def test_is_protocol_player_false(self, mock_mass: MagicMock) -> None:
733 """Test that PlayerType.PLAYER is not detected as protocol."""
734 controller = PlayerController(mock_mass)
735
736 provider = MockProvider("airplay")
737 player = MockPlayer(
738 provider,
739 "ap_123456",
740 "HomePod",
741 player_type=PlayerType.PLAYER, # Apple device with native support
742 )
743
744 assert controller._is_protocol_player(player) is False
745
746
747class TestFindMatchingProtocolPlayers:
748 """Tests for finding matching protocol players."""
749
750 def test_find_matching_by_mac(self, mock_mass: MagicMock) -> None:
751 """Test finding matching protocol players by MAC address."""
752 controller = PlayerController(mock_mass)
753
754 # Set up providers
755 airplay_provider = MockProvider("airplay")
756 chromecast_provider = MockProvider("chromecast")
757
758 # Create matching protocol players (same device, different protocols)
759 airplay_player = MockPlayer(
760 airplay_provider,
761 "ap_aabbccddee",
762 "Samsung TV (AirPlay)",
763 player_type=PlayerType.PROTOCOL,
764 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
765 )
766 chromecast_player = MockPlayer(
767 chromecast_provider,
768 "cc_aabbccddee",
769 "Samsung TV (Chromecast)",
770 player_type=PlayerType.PROTOCOL,
771 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
772 )
773
774 # Register players
775 controller._players = {
776 "ap_aabbccddee": airplay_player,
777 "cc_aabbccddee": chromecast_player,
778 }
779
780 # Mark players as initialized so they are returned by all_players()
781 airplay_player.set_initialized()
782 chromecast_player.set_initialized()
783
784 # Find matching players for AirPlay player
785 matches = controller._find_matching_protocol_players(airplay_player)
786
787 assert len(matches) == 2
788 assert airplay_player in matches
789 assert chromecast_player in matches
790
791 def test_same_protocol_not_matched(self, mock_mass: MagicMock) -> None:
792 """Test that multiple players of same protocol on same host are NOT matched together."""
793 controller = PlayerController(mock_mass)
794
795 # Set up provider
796 snapcast_provider = MockProvider("snapcast")
797
798 # Create multiple Snapcast players on same host (same MAC/IP)
799 # This simulates multiple Snapcast clients running on the same server
800 snapcast_player_1 = MockPlayer(
801 snapcast_provider,
802 "snapcast_client_1",
803 "Snapcast Client 1",
804 player_type=PlayerType.PROTOCOL,
805 identifiers={
806 IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
807 IdentifierType.IP_ADDRESS: "192.168.1.100",
808 },
809 )
810 snapcast_player_2 = MockPlayer(
811 snapcast_provider,
812 "snapcast_client_2",
813 "Snapcast Client 2",
814 player_type=PlayerType.PROTOCOL,
815 identifiers={
816 IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
817 IdentifierType.IP_ADDRESS: "192.168.1.100",
818 },
819 )
820
821 # Register players
822 controller._players = {
823 "snapcast_client_1": snapcast_player_1,
824 "snapcast_client_2": snapcast_player_2,
825 }
826
827 # Find matching players for first Snapcast player
828 matches = controller._find_matching_protocol_players(snapcast_player_1)
829
830 # Should only match itself, NOT the other Snapcast player (same protocol domain)
831 assert len(matches) == 1
832 assert snapcast_player_1 in matches
833 assert snapcast_player_2 not in matches
834
835
836class TestResolveStoredUniversalPlayer:
837 """
838 Tests for resolving the universal player a protocol player is persistently linked to.
839
840 Regression tests for music-assistant/support#5888: the universal player id is
841 minted once and a device is always routed back to the wrapper its protocol
842 players point at, so the id API consumers (such as the Home Assistant
843 integration) bind their entities to stays put.
844 """
845
846 @staticmethod
847 def _stored_universal_config(**overrides: Any) -> dict[str, Any]:
848 """Return the stored config of a universal player wrapping the ap_123456 device."""
849 return {
850 "player_id": STORED_UNIVERSAL_ID,
851 "provider": "universal_player",
852 "player_type": "player",
853 "enabled": True,
854 "default_name": "Living Room",
855 "values": {
856 "linked_protocol_ids": ["ap_123456"],
857 "device_identifiers": {"mac_address": "54:78:C9:E6:0D:A0"},
858 "device_info": {"model": "WiiM Pro", "manufacturer": "Linkplay"},
859 },
860 } | overrides
861
862 @staticmethod
863 def _wire_config(
864 mock_mass: MagicMock,
865 parent_id: str | None,
866 parent_config: dict[str, Any] | None = None,
867 ) -> None:
868 """
869 Wire a config store holding the ap_123456 protocol player and its parent.
870
871 :param mock_mass: The mocked MusicAssistant instance to wire.
872 :param parent_id: Parent link persisted on the protocol player, if any.
873 :param parent_config: Stored config of the parent player, when it (still) exists.
874 """
875 players: dict[str, Any] = {
876 "ap_123456": {
877 "player_id": "ap_123456",
878 "provider": "airplay",
879 "player_type": "protocol",
880 "enabled": True,
881 "values": {"protocol_parent_id": parent_id},
882 }
883 }
884 if parent_config is not None:
885 players[parent_config["player_id"]] = parent_config
886 _wire_nested_config(mock_mass, {CONF_PLAYERS: players})
887
888 @staticmethod
889 def _wire_players(mock_mass: MagicMock, registry: dict[str, Player]) -> None:
890 """Wire mass.players so registrations land in the given registry."""
891
892 async def _register(player: Player) -> None:
893 registry[player.player_id] = player
894 player.set_initialized()
895
896 mock_mass.players = MagicMock()
897 mock_mass.players.get_player = MagicMock(
898 side_effect=lambda pid, *_args, **_kwargs: registry.get(pid)
899 )
900 mock_mass.players.register_or_update = AsyncMock(side_effect=_register)
901
902 @staticmethod
903 def _make_protocol_player(mock_mass: MagicMock) -> MockPlayer:
904 """Return the unlinked AirPlay protocol player of the wrapped device."""
905 player = MockPlayer(
906 MockProvider("airplay", mass=mock_mass),
907 "ap_123456",
908 "Test Player",
909 player_type=PlayerType.PROTOCOL,
910 identifiers={IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"},
911 )
912 player.set_initialized()
913 return player
914
915 async def test_registered_parent_resolves_to_its_universal_player(
916 self, mock_mass: MagicMock
917 ) -> None:
918 """The stored parent link resolves to the universal player it names."""
919 universal_provider = create_mock_universal_provider(mock_mass)
920 self._wire_config(mock_mass, STORED_UNIVERSAL_ID, self._stored_universal_config())
921 registry: dict[str, Player] = {}
922 self._wire_players(mock_mass, registry)
923 universal = _create_universal_player(
924 mock_mass, STORED_UNIVERSAL_ID, "Living Room", protocol_player_ids=["ap_123456"]
925 )
926 registry[STORED_UNIVERSAL_ID] = universal
927 player = self._make_protocol_player(mock_mass)
928
929 assert await universal_provider._resolve_stored_universal_player(player) is universal
930 mock_mass.players.register_or_update.assert_not_awaited()
931
932 async def test_unregistered_parent_is_restored_from_its_config(
933 self, mock_mass: MagicMock
934 ) -> None:
935 """A stored parent that is not registered is restored under its own player id."""
936 universal_provider = create_mock_universal_provider(mock_mass)
937 self._wire_config(mock_mass, STORED_UNIVERSAL_ID, self._stored_universal_config())
938 registry: dict[str, Player] = {}
939 self._wire_players(mock_mass, registry)
940 player = self._make_protocol_player(mock_mass)
941
942 resolved = await universal_provider._resolve_stored_universal_player(player)
943
944 assert resolved is not None
945 assert resolved.player_id == STORED_UNIVERSAL_ID
946 assert resolved._protocol_player_ids == ["ap_123456"]
947 assert registry[STORED_UNIVERSAL_ID] is resolved
948
949 async def test_parent_config_of_another_provider_is_ignored(self, mock_mass: MagicMock) -> None:
950 """A parent config owned by another provider is never ours to resolve to."""
951 universal_provider = create_mock_universal_provider(mock_mass)
952 # a native player whose id merely happens to start with the universal player prefix
953 self._wire_config(
954 mock_mass,
955 "upnp_media_renderer",
956 {
957 "player_id": "upnp_media_renderer",
958 "provider": "some_native_provider",
959 "enabled": True,
960 "values": {"linked_protocol_ids": ["ap_123456"]},
961 },
962 )
963 registry: dict[str, Player] = {}
964 self._wire_players(mock_mass, registry)
965 player = self._make_protocol_player(mock_mass)
966
967 assert await universal_provider._resolve_stored_universal_player(player) is None
968 mock_mass.players.register_or_update.assert_not_awaited()
969
970 async def test_disabled_parent_config_is_ignored(self, mock_mass: MagicMock) -> None:
971 """A disabled universal player stays off: the user turned this device off."""
972 universal_provider = create_mock_universal_provider(mock_mass)
973 self._wire_config(
974 mock_mass, STORED_UNIVERSAL_ID, self._stored_universal_config(enabled=False)
975 )
976 registry: dict[str, Player] = {}
977 self._wire_players(mock_mass, registry)
978 player = self._make_protocol_player(mock_mass)
979
980 assert await universal_provider._resolve_stored_universal_player(player) is None
981 mock_mass.players.register_or_update.assert_not_awaited()
982
983 async def test_stored_parent_without_a_config_is_ignored(self, mock_mass: MagicMock) -> None:
984 """A parent link left dangling by a deleted config resolves to nothing."""
985 universal_provider = create_mock_universal_provider(mock_mass)
986 self._wire_config(mock_mass, STORED_UNIVERSAL_ID)
987 registry: dict[str, Player] = {}
988 self._wire_players(mock_mass, registry)
989 player = self._make_protocol_player(mock_mass)
990
991 assert await universal_provider._resolve_stored_universal_player(player) is None
992 mock_mass.players.register_or_update.assert_not_awaited()
993
994 async def test_without_a_stored_parent_link_nothing_resolves(
995 self, mock_mass: MagicMock
996 ) -> None:
997 """A protocol player that was never wrapped resolves to no universal player."""
998 universal_provider = create_mock_universal_provider(mock_mass)
999 self._wire_config(mock_mass, None)
1000 registry: dict[str, Player] = {}
1001 self._wire_players(mock_mass, registry)
1002 player = self._make_protocol_player(mock_mass)
1003
1004 assert await universal_provider._resolve_stored_universal_player(player) is None
1005
1006
1007class TestMintPlayerId:
1008 """Tests for minting the opaque player id of a new universal player."""
1009
1010 def test_minted_id_is_free_as_a_player_and_as_a_config(self, mock_mass: MagicMock) -> None:
1011 """A minted id is never one that is already taken."""
1012 universal_provider = create_mock_universal_provider(mock_mass)
1013 _wire_nested_config(
1014 mock_mass,
1015 {CONF_PLAYERS: {"upbbbbbbbb": {"player_id": "upbbbbbbbb", "enabled": False}}},
1016 )
1017 registered = {"upaaaaaaaa": MagicMock()}
1018 mock_mass.players = MagicMock()
1019 mock_mass.players.get_player = MagicMock(
1020 side_effect=lambda pid, *_args, **_kwargs: registered.get(pid)
1021 )
1022 candidates = [
1023 UUID("aaaaaaaa-0000-0000-0000-000000000000"), # taken by a registered player
1024 UUID("bbbbbbbb-0000-0000-0000-000000000000"), # taken by a (stale) config
1025 UUID("cccccccc-0000-0000-0000-000000000000"),
1026 ]
1027
1028 with patch(
1029 "music_assistant.providers.universal_player.provider.uuid4", side_effect=candidates
1030 ):
1031 assert universal_provider.mint_player_id() == "upcccccccc"
1032
1033
1034class TestGetCleanPlayerName:
1035 """Tests for player name selection."""
1036
1037 def test_prefers_chromecast_name(self, mock_mass: MagicMock) -> None:
1038 """Test that Chromecast names are preferred over other protocols."""
1039 universal_provider = create_mock_universal_provider(mock_mass)
1040
1041 airplay_provider = MockProvider("airplay")
1042 chromecast_provider = MockProvider("chromecast")
1043
1044 airplay_player = MockPlayer(
1045 airplay_provider,
1046 "ap_123456",
1047 "Samsung TV",
1048 player_type=PlayerType.PROTOCOL,
1049 )
1050 chromecast_player = MockPlayer(
1051 chromecast_provider,
1052 "cc_123456",
1053 "Living Room Speaker",
1054 player_type=PlayerType.PROTOCOL,
1055 )
1056
1057 # Chromecast should be preferred (priority 1)
1058 clean_name = universal_provider._get_clean_player_name([airplay_player, chromecast_player])
1059 assert clean_name == "Living Room Speaker"
1060
1061 def test_filters_mac_address_names(self, mock_mass: MagicMock) -> None:
1062 """Test that MAC address-like names are filtered out."""
1063 universal_provider = create_mock_universal_provider(mock_mass)
1064
1065 squeezelite_provider = MockProvider("squeezelite")
1066 airplay_provider = MockProvider("airplay")
1067
1068 # Squeezelite with MAC address as name
1069 sq_player = MockPlayer(
1070 squeezelite_provider,
1071 "sq_123456",
1072 "AA:BB:CC:DD:EE:FF",
1073 player_type=PlayerType.PROTOCOL,
1074 )
1075 # AirPlay with proper name
1076 ap_player = MockPlayer(
1077 airplay_provider,
1078 "ap_123456",
1079 "Kitchen Speaker",
1080 player_type=PlayerType.PROTOCOL,
1081 )
1082
1083 # Should prefer Kitchen Speaker over MAC address
1084 clean_name = universal_provider._get_clean_player_name([sq_player, ap_player])
1085 assert clean_name == "Kitchen Speaker"
1086
1087 def test_filters_player_id_names(self, mock_mass: MagicMock) -> None:
1088 """Test that player ID-like names are filtered out."""
1089 universal_provider = create_mock_universal_provider(mock_mass)
1090
1091 sendspin_provider = MockProvider("sendspin")
1092 dlna_provider = MockProvider("dlna")
1093
1094 # SendSpin with player ID as name
1095 ss_player = MockPlayer(
1096 sendspin_provider,
1097 "sendspin_123456",
1098 "sendspin_device_abc",
1099 player_type=PlayerType.PROTOCOL,
1100 )
1101 # DLNA with proper name
1102 dlna_player = MockPlayer(
1103 dlna_provider,
1104 "dlna_123456",
1105 "Bedroom TV",
1106 player_type=PlayerType.PROTOCOL,
1107 )
1108
1109 # Should prefer Bedroom TV over player ID
1110 clean_name = universal_provider._get_clean_player_name([ss_player, dlna_player])
1111 assert clean_name == "Bedroom TV"
1112
1113 def test_valid_name_unchanged(self, mock_mass: MagicMock) -> None:
1114 """Test that valid names are returned unchanged."""
1115 universal_provider = create_mock_universal_provider(mock_mass)
1116
1117 provider = MockProvider("airplay")
1118 player = MockPlayer(
1119 provider,
1120 "ap_123456",
1121 "HomePod Mini",
1122 player_type=PlayerType.PLAYER,
1123 )
1124
1125 clean_name = universal_provider._get_clean_player_name([player])
1126 assert clean_name == "HomePod Mini"
1127
1128
1129class TestCachedProtocolParentRestore:
1130 """Tests for restoring cached protocol parent links."""
1131
1132 def test_protocol_parent_id_restored_from_config(self, mock_mass: MagicMock) -> None:
1133 """Test that cached protocol_parent_id is loaded and used for immediate linking."""
1134 controller = PlayerController(mock_mass)
1135
1136 # Mock config to return cached parent_id when queried
1137 def mock_config_get(key: str, default: str | None = None) -> str | None:
1138 if "protocol_parent_id" in str(key):
1139 return "native_player_id"
1140 return default
1141
1142 mock_mass.config.get.side_effect = mock_config_get
1143
1144 # Create native player
1145 native_provider = MockProvider("sonos", mass=mock_mass)
1146 native_player = MockPlayer(
1147 native_provider,
1148 "native_player_id",
1149 "Sonos Speaker",
1150 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1151 )
1152
1153 # Create protocol player
1154 dlna_provider = MockProvider("dlna", mass=mock_mass)
1155 protocol_player = MockPlayer(
1156 dlna_provider,
1157 "uuid:RINCON_AABBCCDDEEFF_MR",
1158 "Sonos DLNA",
1159 player_type=PlayerType.PROTOCOL,
1160 )
1161
1162 # Register native player
1163 controller._players = {"native_player_id": native_player}
1164
1165 # Try to link protocol to native - should load cached parent_id
1166 controller._try_link_protocol_to_native(protocol_player)
1167
1168 # Verify protocol_parent_id was set
1169 assert protocol_player.protocol_parent_id == "native_player_id"
1170
1171 # Verify protocol was linked to native player
1172 assert any(
1173 link.output_protocol_id == protocol_player.player_id
1174 for link in native_player.linked_output_protocols
1175 )
1176
1177 def test_missing_cached_parent_schedules_protocol_evaluation(
1178 self, mock_mass: MagicMock
1179 ) -> None:
1180 """Test that a missing cached parent does not block delayed protocol evaluation."""
1181 controller = PlayerController(mock_mass)
1182
1183 # Mock config to return cached parent_id (parent not yet registered)
1184 def mock_config_get(key: str, default: str | None = None) -> str | None:
1185 if "protocol_parent_id" in str(key):
1186 return "native_player_id"
1187 return default
1188
1189 mock_mass.config.get.side_effect = mock_config_get
1190
1191 # Create protocol player
1192 dlna_provider = MockProvider("dlna", mass=mock_mass)
1193 protocol_player = MockPlayer(
1194 dlna_provider,
1195 "uuid:RINCON_AABBCCDDEEFF_MR",
1196 "Sonos DLNA",
1197 player_type=PlayerType.PROTOCOL,
1198 )
1199
1200 # No native player registered yet
1201 controller._players = {}
1202
1203 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
1204 controller._try_link_protocol_to_native(protocol_player)
1205
1206 # A cached parent that is not registered yet should be handled by the
1207 # delayed evaluation path, not by assigning a dangling in-memory parent.
1208 assert protocol_player.protocol_parent_id is None
1209 mock_schedule.assert_called_once_with(protocol_player)
1210
1211 def test_stale_cache_does_not_claim_a_protocol_waiting_for_its_owner(
1212 self, mock_mass: MagicMock
1213 ) -> None:
1214 """A protocol whose persisted owner is still starting up is not claimed by another parent."""
1215 controller = PlayerController(mock_mass)
1216
1217 store: dict[str, Any] = {
1218 CONF_PLAYERS: {
1219 "ap_shared": {
1220 "provider": "airplay--x",
1221 "player_type": "protocol",
1222 "values": {CONF_PROTOCOL_PARENT_ID: "sonos_owner"},
1223 },
1224 "heos_player": {
1225 "provider": "heos--y",
1226 "player_type": "player",
1227 "values": {"linked_protocol_ids": ["ap_shared"]},
1228 },
1229 "sonos_owner": {
1230 "provider": "sonos--z",
1231 "player_type": "player",
1232 "values": {"linked_protocol_ids": ["ap_shared"]},
1233 },
1234 }
1235 }
1236 _wire_nested_config(mock_mass, store)
1237
1238 # a native player that kept a stale cached id for a protocol it no longer owns
1239 heos_player = MockPlayer(
1240 MockProvider("heos", mass=mock_mass),
1241 "heos_player",
1242 "Old Receiver",
1243 identifiers={IdentifierType.MAC_ADDRESS: "11:22:33:44:55:66"},
1244 )
1245 heos_player.set_initialized()
1246 airplay = MockPlayer(
1247 MockProvider("airplay", mass=mock_mass),
1248 "ap_shared",
1249 "Speaker (AirPlay)",
1250 player_type=PlayerType.PROTOCOL,
1251 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1252 )
1253 airplay.set_initialized()
1254 controller._players = {"heos_player": heos_player, "ap_shared": airplay}
1255
1256 # the persisted owner has not registered yet
1257 controller._try_link_protocol_to_native(airplay)
1258
1259 assert airplay.protocol_parent_id is None
1260 assert heos_player.linked_output_protocols == []
1261 assert mock_mass.loop.call_later.call_args.args[0] == 45.0
1262
1263 # once the owner registers it takes its own protocol
1264 sonos_owner = MockPlayer(
1265 MockProvider("sonos", mass=mock_mass),
1266 "sonos_owner",
1267 "Speaker",
1268 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1269 )
1270 sonos_owner.set_initialized()
1271 controller._players["sonos_owner"] = sonos_owner
1272 controller._try_link_protocols_to_native(sonos_owner)
1273
1274 assert airplay.protocol_parent_id == "sonos_owner"
1275 assert [ # type: ignore[unreachable]
1276 link.output_protocol_id for link in sonos_owner.linked_output_protocols
1277 ] == ["ap_shared"]
1278
1279 def test_identifier_match_does_not_claim_a_protocol_waiting_for_its_owner(
1280 self, mock_mass: MagicMock
1281 ) -> None:
1282 """Identifier matching does not override a protocol's own persisted owner."""
1283 controller = PlayerController(mock_mass)
1284
1285 store: dict[str, Any] = {
1286 CONF_PLAYERS: {
1287 "ap_shared": {
1288 "provider": "airplay--x",
1289 "player_type": "protocol",
1290 "values": {CONF_PROTOCOL_PARENT_ID: "sonos_owner"},
1291 },
1292 "sonos_owner": {"provider": "sonos--z", "player_type": "player", "values": {}},
1293 }
1294 }
1295 _wire_nested_config(mock_mass, store)
1296
1297 # shares identifiers with the protocol but is not its persisted owner
1298 other_native = MockPlayer(
1299 MockProvider("heos", mass=mock_mass),
1300 "heos_player",
1301 "Other Receiver",
1302 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1303 )
1304 other_native.set_initialized()
1305 airplay = MockPlayer(
1306 MockProvider("airplay", mass=mock_mass),
1307 "ap_shared",
1308 "Speaker (AirPlay)",
1309 player_type=PlayerType.PROTOCOL,
1310 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1311 )
1312 airplay.set_initialized()
1313 controller._players = {"heos_player": other_native, "ap_shared": airplay}
1314
1315 controller._try_link_protocol_to_native(airplay)
1316
1317 assert airplay.protocol_parent_id is None
1318 assert other_native.linked_output_protocols == []
1319
1320 def test_other_native_does_not_claim_a_protocol_reserved_for_its_owner(
1321 self, mock_mass: MagicMock
1322 ) -> None:
1323 """A native registering before the persisted owner leaves that owner's protocol alone."""
1324 controller = PlayerController(mock_mass)
1325
1326 store: dict[str, Any] = {
1327 CONF_PLAYERS: {
1328 "ap_shared": {
1329 "provider": "airplay--x",
1330 "player_type": "protocol",
1331 "values": {CONF_PROTOCOL_PARENT_ID: "sonos_owner"},
1332 },
1333 "sonos_owner": {"provider": "sonos--z", "player_type": "player", "values": {}},
1334 }
1335 }
1336 _wire_nested_config(mock_mass, store)
1337
1338 airplay = MockPlayer(
1339 MockProvider("airplay", mass=mock_mass),
1340 "ap_shared",
1341 "Speaker (AirPlay)",
1342 player_type=PlayerType.PROTOCOL,
1343 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1344 )
1345 airplay.set_initialized()
1346 controller._players = {"ap_shared": airplay}
1347
1348 controller._try_link_protocol_to_native(airplay)
1349 assert airplay.protocol_parent_id is None
1350
1351 # another native with a colliding identifier registers before the owner
1352 other_native = MockPlayer(
1353 MockProvider("heos", mass=mock_mass),
1354 "heos_player",
1355 "Other Receiver",
1356 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1357 )
1358 other_native.set_initialized()
1359 controller._players["heos_player"] = other_native
1360 controller._try_link_protocols_to_native(other_native)
1361
1362 assert airplay.protocol_parent_id is None
1363 assert other_native.linked_output_protocols == []
1364
1365 # the owner still gets its own protocol
1366 sonos_owner = MockPlayer(
1367 MockProvider("sonos", mass=mock_mass),
1368 "sonos_owner",
1369 "Speaker",
1370 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1371 )
1372 sonos_owner.set_initialized()
1373 controller._players["sonos_owner"] = sonos_owner
1374 controller._try_link_protocols_to_native(sonos_owner)
1375
1376 assert airplay.protocol_parent_id == "sonos_owner"
1377
1378 @pytest.mark.asyncio
1379 async def test_protocol_links_elsewhere_when_its_owner_never_registers(
1380 self, mock_mass: MagicMock
1381 ) -> None:
1382 """A protocol whose persisted owner never registers still links on delayed evaluation."""
1383 controller = PlayerController(mock_mass)
1384
1385 store: dict[str, Any] = {
1386 CONF_PLAYERS: {
1387 "ap_shared": {
1388 "provider": "airplay--x",
1389 "player_type": "protocol",
1390 "values": {CONF_PROTOCOL_PARENT_ID: "gone_owner"},
1391 },
1392 "heos_player": {
1393 "provider": "heos--y",
1394 "player_type": "player",
1395 "values": {"linked_protocol_ids": ["ap_shared"]},
1396 },
1397 }
1398 }
1399 _wire_nested_config(mock_mass, store)
1400
1401 heos_player = MockPlayer(
1402 MockProvider("heos", mass=mock_mass),
1403 "heos_player",
1404 "Receiver",
1405 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1406 )
1407 heos_player.set_initialized()
1408 airplay = MockPlayer(
1409 MockProvider("airplay", mass=mock_mass),
1410 "ap_shared",
1411 "Speaker (AirPlay)",
1412 player_type=PlayerType.PROTOCOL,
1413 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1414 )
1415 airplay.set_initialized()
1416 controller._players = {"heos_player": heos_player, "ap_shared": airplay}
1417
1418 controller._try_link_protocol_to_native(airplay)
1419 assert airplay.protocol_parent_id is None
1420
1421 await controller._delayed_protocol_evaluation("ap_shared")
1422
1423 assert airplay.protocol_parent_id == "heos_player"
1424
1425 @pytest.mark.asyncio
1426 async def test_cached_parent_registers_before_delayed_eval_links_without_universal(
1427 self, mock_mass: MagicMock
1428 ) -> None:
1429 """Test cached parent startup grace still links when native registers during delay."""
1430 controller = PlayerController(mock_mass)
1431
1432 def mock_config_get(
1433 key: str, default: str | list[str] | None = None
1434 ) -> str | list[str] | None:
1435 if "protocol_parent_id" in str(key):
1436 return "native_player_id"
1437 return default
1438
1439 mock_mass.config.get.side_effect = mock_config_get
1440
1441 dlna_provider = MockProvider("dlna", mass=mock_mass)
1442 protocol_player = MockPlayer(
1443 dlna_provider,
1444 "uuid:RINCON_AABBCCDDEEFF_MR",
1445 "Sonos DLNA",
1446 player_type=PlayerType.PROTOCOL,
1447 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1448 )
1449 protocol_player.set_initialized()
1450 controller._players = {protocol_player.player_id: protocol_player}
1451
1452 controller._try_link_protocol_to_native(protocol_player)
1453
1454 assert protocol_player.protocol_parent_id is None
1455 assert mock_mass.loop.call_later.call_args.args[0] == 45.0
1456
1457 native_provider = MockProvider("sonos", mass=mock_mass)
1458 native_player = MockPlayer(
1459 native_provider,
1460 "native_player_id",
1461 "Sonos Speaker",
1462 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1463 )
1464 native_player.set_initialized()
1465 controller._players[native_player.player_id] = native_player
1466
1467 controller._try_link_protocols_to_native(native_player)
1468
1469 assert protocol_player.protocol_parent_id == native_player.player_id
1470 assert any( # type: ignore[unreachable]
1471 link.output_protocol_id == protocol_player.player_id
1472 for link in native_player.linked_output_protocols
1473 )
1474
1475 with patch.object(controller, "_create_or_update_universal_player") as mock_create_up:
1476 await controller._delayed_protocol_evaluation(protocol_player.player_id)
1477
1478 mock_create_up.assert_not_called()
1479
1480
1481class TestSelectBestOutputProtocol:
1482 """Tests for output protocol selection logic."""
1483
1484 def test_select_native_when_preferred_is_native(self, mock_mass: MagicMock) -> None:
1485 """Test that native protocol is selected when user prefers native."""
1486
1487 # Mock config to return "native" as preferred, but still handle min/max volume
1488 def _get_raw_config(
1489 _player_id: str, key: str, _default: str | int | None = None
1490 ) -> str | int | None:
1491 if key == "min_volume":
1492 return 0
1493 if key == "max_volume":
1494 return 100
1495 return "native"
1496
1497 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw_config)
1498
1499 controller = PlayerController(mock_mass)
1500 provider = MockProvider("sonos", mass=mock_mass)
1501
1502 # Create native player with PLAY_MEDIA support
1503 native_player = MockPlayer(
1504 provider,
1505 "sonos_123",
1506 "Kantoor",
1507 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1508 )
1509 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1510
1511 # Wire up mock_mass.players to controller
1512 mock_mass.players = controller
1513
1514 # Register players
1515 controller._players = {"sonos_123": native_player}
1516
1517 # Select protocol
1518 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
1519
1520 # Should select native player
1521 assert selected_player == native_player
1522 assert output_protocol is None # None means native playback
1523
1524 def test_select_dlna_when_preferred_is_dlna(self, mock_mass: MagicMock) -> None:
1525 """Test that DLNA protocol is selected when user prefers DLNA."""
1526
1527 # Mock config to return the full player ID as preferred, but still handle min/max volume
1528 def _get_raw_config(
1529 _player_id: str, key: str, _default: str | int | None = None
1530 ) -> str | int | None:
1531 if key == "min_volume":
1532 return 0
1533 if key == "max_volume":
1534 return 100
1535 return "dlna_AABBCCDDEEFF"
1536
1537 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw_config)
1538
1539 controller = PlayerController(mock_mass)
1540
1541 # Create native player with linked protocols
1542 sonos_provider = MockProvider("sonos", mass=mock_mass)
1543 native_player = MockPlayer(
1544 sonos_provider,
1545 "sonos_123",
1546 "Kantoor",
1547 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1548 )
1549 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1550
1551 # Create DLNA protocol player
1552 dlna_provider = MockProvider("dlna", mass=mock_mass)
1553 dlna_player = MockPlayer(
1554 dlna_provider,
1555 "dlna_AABBCCDDEEFF",
1556 "Kantoor DLNA",
1557 player_type=PlayerType.PROTOCOL,
1558 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1559 )
1560
1561 # Register players
1562 controller._players = {
1563 "sonos_123": native_player,
1564 "dlna_AABBCCDDEEFF": dlna_player,
1565 }
1566
1567 # Link DLNA protocol to native player
1568 native_player.set_linked_output_protocols(
1569 [
1570 LinkedOutputProtocol(
1571 output_protocol_id="dlna_AABBCCDDEEFF",
1572 protocol_domain="dlna",
1573 priority=50,
1574 )
1575 ]
1576 )
1577
1578 # Select protocol
1579 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
1580
1581 # Should select DLNA player, not native
1582 assert selected_player == dlna_player
1583 assert output_protocol is not None
1584 assert output_protocol.output_protocol_id == "dlna_AABBCCDDEEFF"
1585
1586 def test_select_airplay_when_preferred_is_airplay(self, mock_mass: MagicMock) -> None:
1587 """Test that AirPlay protocol is selected when user prefers AirPlay."""
1588
1589 # Mock config to return the full player ID as preferred, but still handle min/max volume
1590 def _get_raw_config(
1591 _player_id: str, key: str, _default: str | int | None = None
1592 ) -> str | int | None:
1593 if key == "min_volume":
1594 return 0
1595 if key == "max_volume":
1596 return 100
1597 return "airplay_AABBCCDDEEFF"
1598
1599 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw_config)
1600
1601 controller = PlayerController(mock_mass)
1602
1603 # Create native player
1604 sonos_provider = MockProvider("sonos", mass=mock_mass)
1605 native_player = MockPlayer(
1606 sonos_provider,
1607 "sonos_123",
1608 "Kantoor",
1609 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1610 )
1611 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1612
1613 # Create AirPlay and DLNA protocol players
1614 airplay_provider = MockProvider("airplay", mass=mock_mass)
1615 airplay_player = MockPlayer(
1616 airplay_provider,
1617 "airplay_AABBCCDDEEFF",
1618 "Kantoor AirPlay",
1619 player_type=PlayerType.PROTOCOL,
1620 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1621 )
1622
1623 dlna_provider = MockProvider("dlna", mass=mock_mass)
1624 dlna_player = MockPlayer(
1625 dlna_provider,
1626 "dlna_AABBCCDDEEFF",
1627 "Kantoor DLNA",
1628 player_type=PlayerType.PROTOCOL,
1629 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1630 )
1631
1632 # Register players
1633 controller._players = {
1634 "sonos_123": native_player,
1635 "airplay_AABBCCDDEEFF": airplay_player,
1636 "dlna_AABBCCDDEEFF": dlna_player,
1637 }
1638
1639 # Link protocols to native player
1640 native_player.set_linked_output_protocols(
1641 [
1642 LinkedOutputProtocol(
1643 output_protocol_id="airplay_AABBCCDDEEFF",
1644 protocol_domain="airplay",
1645 priority=10,
1646 ),
1647 LinkedOutputProtocol(
1648 output_protocol_id="dlna_AABBCCDDEEFF",
1649 protocol_domain="dlna",
1650 priority=50,
1651 ),
1652 ]
1653 )
1654
1655 # Select protocol
1656 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
1657
1658 # Should select AirPlay player (even though DLNA has lower priority value),
1659 # because user preference overrides priority
1660 assert selected_player == airplay_player
1661 assert output_protocol is not None
1662 assert output_protocol.output_protocol_id == "airplay_AABBCCDDEEFF"
1663
1664 def test_fallback_to_native_when_auto(self, mock_mass: MagicMock) -> None:
1665 """Test that native playback is used when preference is auto."""
1666
1667 # Mock config to return "auto" as preferred, but still handle min/max volume
1668 def _get_raw_config(
1669 _player_id: str, key: str, _default: str | int | None = None
1670 ) -> str | int | None:
1671 if key == "min_volume":
1672 return 0
1673 if key == "max_volume":
1674 return 100
1675 return "auto"
1676
1677 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw_config)
1678
1679 controller = PlayerController(mock_mass)
1680 provider = MockProvider("sonos", mass=mock_mass)
1681
1682 native_player = MockPlayer(
1683 provider,
1684 "sonos_123",
1685 "Kantoor",
1686 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1687 )
1688 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1689
1690 controller._players = {"sonos_123": native_player}
1691
1692 # Select protocol with auto preference
1693 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
1694
1695 # Should select native player
1696 assert selected_player == native_player
1697 assert output_protocol is None # None means native playback
1698
1699 def test_skip_protocol_that_needs_setup(self, mock_mass: MagicMock) -> None:
1700 """Test that a protocol player which still needs setup is never selected."""
1701 controller = PlayerController(mock_mass)
1702
1703 # Universal player wrapping the protocols of a device without native playback
1704 universal_provider = MockProvider("universal_player", mass=mock_mass)
1705 universal_player = MockPlayer(
1706 universal_provider,
1707 "up_AABBCCDDEEFF",
1708 "Living Room TV",
1709 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1710 )
1711
1712 # AirPlay protocol player that is discovered but still awaits pairing
1713 airplay_provider = MockProvider("airplay", mass=mock_mass)
1714 airplay_player = MockPlayer(
1715 airplay_provider,
1716 "airplay_AABBCCDDEEFF",
1717 "Living Room TV AirPlay",
1718 player_type=PlayerType.PROTOCOL,
1719 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1720 )
1721 airplay_player._attr_needs_setup = True
1722
1723 dlna_provider = MockProvider("dlna", mass=mock_mass)
1724 dlna_player = MockPlayer(
1725 dlna_provider,
1726 "dlna_AABBCCDDEEFF",
1727 "Living Room TV DLNA",
1728 player_type=PlayerType.PROTOCOL,
1729 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1730 )
1731
1732 # Wire up mock_mass.players to controller
1733 mock_mass.players = controller
1734
1735 # Register players
1736 controller._players = {
1737 "up_AABBCCDDEEFF": universal_player,
1738 "airplay_AABBCCDDEEFF": airplay_player,
1739 "dlna_AABBCCDDEEFF": dlna_player,
1740 }
1741
1742 # Link both protocols, AirPlay with the better priority
1743 universal_player.set_linked_output_protocols(
1744 [
1745 LinkedOutputProtocol(
1746 output_protocol_id="airplay_AABBCCDDEEFF",
1747 protocol_domain="airplay",
1748 priority=10,
1749 ),
1750 LinkedOutputProtocol(
1751 output_protocol_id="dlna_AABBCCDDEEFF",
1752 protocol_domain="dlna",
1753 priority=50,
1754 ),
1755 ]
1756 )
1757
1758 selected_player, output_protocol = controller._select_best_output_protocol(universal_player)
1759
1760 # Should skip the unpaired AirPlay output and fall through to DLNA
1761 assert selected_player == dlna_player
1762 assert output_protocol is not None
1763 assert output_protocol.output_protocol_id == "dlna_AABBCCDDEEFF"
1764
1765 # and the unpaired output must be reported as unavailable
1766 universal_player.update_state(signal_event=False)
1767 airplay_output = next(
1768 protocol
1769 for protocol in universal_player.output_protocols
1770 if protocol.output_protocol_id == "airplay_AABBCCDDEEFF"
1771 )
1772 assert airplay_output.available is False
1773
1774
1775class TestGetControlTarget:
1776 """Tests for resolving the target of an audio-path command."""
1777
1778 def test_fallback_follows_protocol_priority(self, mock_mass: MagicMock) -> None:
1779 """
1780 Without an active output the fallback picks the highest priority protocol.
1781
1782 An announcement on an idle player reaches this path, so it has to land on the
1783 same output that regular playback selection would have chosen.
1784 """
1785 controller = PlayerController(mock_mass)
1786 mock_mass.players = controller
1787
1788 universal_provider = MockProvider("universal_player", mass=mock_mass)
1789 universal_player = MockPlayer(universal_provider, "universal_123", "Kantoor")
1790 universal_player._attr_supported_features = set()
1791
1792 sendspin_player = MockPlayer(
1793 MockProvider("sendspin", mass=mock_mass),
1794 "sendspin_AABBCCDDEEFF",
1795 "Kantoor Sendspin",
1796 player_type=PlayerType.PROTOCOL,
1797 )
1798 sendspin_player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
1799 airplay_player = MockPlayer(
1800 MockProvider("airplay", mass=mock_mass),
1801 "airplay_AABBCCDDEEFF",
1802 "Kantoor AirPlay",
1803 player_type=PlayerType.PROTOCOL,
1804 )
1805 airplay_player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
1806
1807 controller._players = {
1808 "universal_123": universal_player,
1809 "sendspin_AABBCCDDEEFF": sendspin_player,
1810 "airplay_AABBCCDDEEFF": airplay_player,
1811 }
1812 # sendspin links first, but airplay outranks it
1813 universal_player.set_linked_output_protocols(
1814 [
1815 LinkedOutputProtocol(
1816 output_protocol_id="sendspin_AABBCCDDEEFF",
1817 protocol_domain="sendspin",
1818 priority=40,
1819 ),
1820 LinkedOutputProtocol(
1821 output_protocol_id="airplay_AABBCCDDEEFF",
1822 protocol_domain="airplay",
1823 priority=10,
1824 ),
1825 ]
1826 )
1827
1828 target = controller._get_control_target(
1829 universal_player, PlayerFeature.PLAY_ANNOUNCEMENT, require_active=False
1830 )
1831 assert target == airplay_player
1832
1833 def test_fallback_follows_preferred_output_protocol(self, mock_mass: MagicMock) -> None:
1834 """An explicitly preferred output beats the priority order."""
1835 controller = PlayerController(mock_mass)
1836 mock_mass.players = controller
1837
1838 def _get_raw_player_config_value(
1839 _player_id: str, key: str, default: str | int | None = None
1840 ) -> str | int | None:
1841 if key == CONF_PREFERRED_OUTPUT_PROTOCOL:
1842 return "sendspin_AABBCCDDEEFF"
1843 if key == "min_volume":
1844 return 0
1845 if key == "max_volume":
1846 return 100
1847 return default
1848
1849 mock_mass.config.get_raw_player_config_value = MagicMock(
1850 side_effect=_get_raw_player_config_value
1851 )
1852
1853 universal_player = MockPlayer(
1854 MockProvider("universal_player", mass=mock_mass), "universal_123", "Kantoor"
1855 )
1856 universal_player._attr_supported_features = set()
1857 sendspin_player = MockPlayer(
1858 MockProvider("sendspin", mass=mock_mass),
1859 "sendspin_AABBCCDDEEFF",
1860 "Kantoor Sendspin",
1861 player_type=PlayerType.PROTOCOL,
1862 )
1863 sendspin_player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
1864 airplay_player = MockPlayer(
1865 MockProvider("airplay", mass=mock_mass),
1866 "airplay_AABBCCDDEEFF",
1867 "Kantoor AirPlay",
1868 player_type=PlayerType.PROTOCOL,
1869 )
1870 airplay_player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
1871
1872 controller._players = {
1873 "universal_123": universal_player,
1874 "sendspin_AABBCCDDEEFF": sendspin_player,
1875 "airplay_AABBCCDDEEFF": airplay_player,
1876 }
1877 # airplay outranks sendspin, but the user picked sendspin
1878 universal_player.set_linked_output_protocols(
1879 [
1880 LinkedOutputProtocol(
1881 output_protocol_id="airplay_AABBCCDDEEFF",
1882 protocol_domain="airplay",
1883 priority=10,
1884 ),
1885 LinkedOutputProtocol(
1886 output_protocol_id="sendspin_AABBCCDDEEFF",
1887 protocol_domain="sendspin",
1888 priority=40,
1889 ),
1890 ]
1891 )
1892
1893 target = controller._get_control_target(
1894 universal_player, PlayerFeature.PLAY_ANNOUNCEMENT, require_active=False
1895 )
1896 assert target == sendspin_player
1897
1898 def test_fallback_ignores_preference_for_another_players_output(
1899 self, mock_mass: MagicMock
1900 ) -> None:
1901 """A preference left behind by a relink does not steal another speaker's output."""
1902 controller = PlayerController(mock_mass)
1903 mock_mass.players = controller
1904
1905 def _get_raw_player_config_value(
1906 _player_id: str, key: str, default: str | int | None = None
1907 ) -> str | int | None:
1908 if key == CONF_PREFERRED_OUTPUT_PROTOCOL:
1909 return "airplay_998877665544"
1910 if key == "min_volume":
1911 return 0
1912 if key == "max_volume":
1913 return 100
1914 return default
1915
1916 mock_mass.config.get_raw_player_config_value = MagicMock(
1917 side_effect=_get_raw_player_config_value
1918 )
1919
1920 universal_player = MockPlayer(
1921 MockProvider("universal_player", mass=mock_mass), "universal_123", "Kantoor"
1922 )
1923 universal_player._attr_supported_features = set()
1924 own_player = MockPlayer(
1925 MockProvider("sendspin", mass=mock_mass),
1926 "sendspin_AABBCCDDEEFF",
1927 "Kantoor Sendspin",
1928 player_type=PlayerType.PROTOCOL,
1929 )
1930 own_player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
1931 # the preference now points at an output belonging to a different speaker
1932 other_player = MockPlayer(
1933 MockProvider("airplay", mass=mock_mass),
1934 "airplay_998877665544",
1935 "Woonkamer AirPlay",
1936 player_type=PlayerType.PROTOCOL,
1937 )
1938 other_player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
1939
1940 controller._players = {
1941 "universal_123": universal_player,
1942 "sendspin_AABBCCDDEEFF": own_player,
1943 "airplay_998877665544": other_player,
1944 }
1945 universal_player.set_linked_output_protocols(
1946 [
1947 LinkedOutputProtocol(
1948 output_protocol_id="sendspin_AABBCCDDEEFF",
1949 protocol_domain="sendspin",
1950 priority=40,
1951 )
1952 ]
1953 )
1954
1955 target = controller._get_control_target(
1956 universal_player, PlayerFeature.PLAY_ANNOUNCEMENT, require_active=False
1957 )
1958 assert target == own_player
1959
1960 def test_require_active_skips_the_fallback(self, mock_mass: MagicMock) -> None:
1961 """With require_active there is no fallback to an idle linked protocol."""
1962 controller = PlayerController(mock_mass)
1963 mock_mass.players = controller
1964
1965 universal_player = MockPlayer(
1966 MockProvider("universal_player", mass=mock_mass),
1967 "universal_123",
1968 "Kantoor",
1969 )
1970 universal_player._attr_supported_features = set()
1971 airplay_player = MockPlayer(
1972 MockProvider("airplay", mass=mock_mass),
1973 "airplay_AABBCCDDEEFF",
1974 "Kantoor AirPlay",
1975 player_type=PlayerType.PROTOCOL,
1976 )
1977 airplay_player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
1978
1979 controller._players = {
1980 "universal_123": universal_player,
1981 "airplay_AABBCCDDEEFF": airplay_player,
1982 }
1983 universal_player.set_linked_output_protocols(
1984 [
1985 LinkedOutputProtocol(
1986 output_protocol_id="airplay_AABBCCDDEEFF",
1987 protocol_domain="airplay",
1988 priority=10,
1989 )
1990 ]
1991 )
1992
1993 assert (
1994 controller._get_control_target(
1995 universal_player, PlayerFeature.PLAY_ANNOUNCEMENT, require_active=True
1996 )
1997 is None
1998 )
1999
2000
2001class TestPlayerGrouping:
2002 """Tests for player grouping scenarios."""
2003
2004 def test_native_to_native_grouping(self, mock_mass: MagicMock) -> None:
2005 """Test that native players from same provider can group together."""
2006 controller = PlayerController(mock_mass)
2007
2008 sonos_provider = MockProvider("sonos", mass=mock_mass)
2009
2010 # Create two Sonos players
2011 player_a = MockPlayer(
2012 sonos_provider,
2013 "sonos_123",
2014 "Living Room",
2015 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2016 )
2017 player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2018 player_a._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2019 player_a._attr_can_group_with = {"sonos_456"}
2020 player_a._cache.clear() # Clear cached properties after modifying attributes
2021
2022 player_b = MockPlayer(
2023 sonos_provider,
2024 "sonos_456",
2025 "Kitchen",
2026 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2027 )
2028 player_b._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2029 player_b._cache.clear()
2030
2031 controller._players = {
2032 "sonos_123": player_a,
2033 "sonos_456": player_b,
2034 }
2035
2036 # Translate members for native grouping
2037 protocol_members, native_members, _, _ = controller._translate_members_for_protocols(
2038 parent_player=player_a,
2039 player_ids=["sonos_456"],
2040 parent_protocol_player=None,
2041 parent_protocol_domain=None,
2042 )
2043
2044 # Should use native grouping (same provider)
2045 assert len(native_members) == 1
2046 assert "sonos_456" in native_members
2047 assert len(protocol_members) == 0
2048
2049 def test_protocol_to_protocol_grouping(self, mock_mass: MagicMock) -> None:
2050 """Test that protocol players can group via shared protocol."""
2051 controller = PlayerController(mock_mass)
2052
2053 # Create two players with AirPlay protocol support
2054 sonos_provider = MockProvider("sonos", mass=mock_mass)
2055 wiim_provider = MockProvider("wiim", mass=mock_mass)
2056 airplay_provider = MockProvider("airplay", mass=mock_mass)
2057
2058 # Sonos player
2059 sonos_player = MockPlayer(
2060 sonos_provider,
2061 "sonos_123",
2062 "Living Room",
2063 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2064 )
2065 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2066 sonos_player._cache.clear()
2067
2068 # WiiM player
2069 wiim_player = MockPlayer(
2070 wiim_provider,
2071 "wiim_456",
2072 "Bedroom",
2073 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2074 )
2075 wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2076 wiim_player._cache.clear()
2077
2078 # AirPlay protocol players
2079 sonos_airplay = MockPlayer(
2080 airplay_provider,
2081 "airplay_sonos",
2082 "Living Room (AirPlay)",
2083 player_type=PlayerType.PROTOCOL,
2084 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2085 )
2086 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2087 sonos_airplay._attr_can_group_with = {"airplay_wiim"}
2088 sonos_airplay._cache.clear()
2089 sonos_airplay.update_state(signal_event=False)
2090
2091 wiim_airplay = MockPlayer(
2092 airplay_provider,
2093 "airplay_wiim",
2094 "Bedroom (AirPlay)",
2095 player_type=PlayerType.PROTOCOL,
2096 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2097 )
2098
2099 # Link protocol players to native players
2100 sonos_player.set_linked_output_protocols(
2101 [
2102 LinkedOutputProtocol(
2103 output_protocol_id="airplay_sonos",
2104 protocol_domain="airplay",
2105 priority=10,
2106 )
2107 ]
2108 )
2109 wiim_player.set_linked_output_protocols(
2110 [
2111 LinkedOutputProtocol(
2112 output_protocol_id="airplay_wiim",
2113 protocol_domain="airplay",
2114 priority=10,
2115 )
2116 ]
2117 )
2118
2119 controller._players = {
2120 "sonos_123": sonos_player,
2121 "wiim_456": wiim_player,
2122 "airplay_sonos": sonos_airplay,
2123 "airplay_wiim": wiim_airplay,
2124 }
2125
2126 # Translate members for protocol grouping (via AirPlay)
2127 protocol_members, native_members, protocol_player, _ = (
2128 controller._translate_members_for_protocols(
2129 parent_player=sonos_player,
2130 player_ids=["wiim_456"],
2131 parent_protocol_player=sonos_airplay,
2132 parent_protocol_domain="airplay",
2133 )
2134 )
2135
2136 # Should use protocol grouping (AirPlay)
2137 assert len(protocol_members) == 1
2138 assert "airplay_wiim" in protocol_members
2139 assert len(native_members) == 0
2140 assert protocol_player == sonos_airplay
2141
2142 def test_hybrid_grouping(self, mock_mass: MagicMock) -> None:
2143 """Test hybrid grouping: native + protocol players in same group."""
2144 controller = PlayerController(mock_mass)
2145
2146 # Create Sonos players (native grouping capability)
2147 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
2148 sonos_a = MockPlayer(
2149 sonos_provider,
2150 "sonos_123",
2151 "Living Room",
2152 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2153 )
2154 sonos_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2155 sonos_a._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2156 sonos_a._attr_can_group_with = {"sonos_456"}
2157 sonos_a._cache.clear()
2158
2159 sonos_b = MockPlayer(
2160 sonos_provider,
2161 "sonos_456",
2162 "Kitchen",
2163 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2164 )
2165 sonos_b._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2166 sonos_b._cache.clear()
2167
2168 # Create WiiM player with AirPlay protocol
2169 wiim_provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
2170 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
2171
2172 wiim_player = MockPlayer(
2173 wiim_provider,
2174 "wiim_789",
2175 "Bedroom",
2176 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
2177 )
2178 wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2179 wiim_player._cache.clear()
2180
2181 # AirPlay protocol players
2182 sonos_airplay = MockPlayer(
2183 airplay_provider,
2184 "airplay_sonos",
2185 "Living Room (AirPlay)",
2186 player_type=PlayerType.PROTOCOL,
2187 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2188 )
2189 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2190 sonos_airplay._attr_can_group_with = {"airplay_wiim"}
2191 sonos_airplay._cache.clear()
2192 sonos_airplay.update_state(signal_event=False)
2193
2194 wiim_airplay = MockPlayer(
2195 airplay_provider,
2196 "airplay_wiim",
2197 "Bedroom (AirPlay)",
2198 player_type=PlayerType.PROTOCOL,
2199 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
2200 )
2201
2202 # Link AirPlay to Sonos A
2203 sonos_a.set_linked_output_protocols(
2204 [
2205 LinkedOutputProtocol(
2206 output_protocol_id="airplay_sonos",
2207 protocol_domain="airplay",
2208 priority=10,
2209 )
2210 ]
2211 )
2212 wiim_player.set_linked_output_protocols(
2213 [
2214 LinkedOutputProtocol(
2215 output_protocol_id="airplay_wiim",
2216 protocol_domain="airplay",
2217 priority=10,
2218 )
2219 ]
2220 )
2221 wiim_player.set_active_output_protocol("airplay_wiim")
2222 wiim_player.set_protocol_parent_id("airplay_wiim")
2223
2224 # Wire up mock_mass.players to controller so player lookups resolve
2225 mock_mass.players = controller
2226
2227 controller._players = {
2228 "sonos_123": sonos_a,
2229 "sonos_456": sonos_b,
2230 "wiim_789": wiim_player,
2231 "airplay_sonos": sonos_airplay,
2232 "airplay_wiim": wiim_airplay,
2233 }
2234
2235 # Group Sonos B (native) + WiiM (via AirPlay) to Sonos A
2236 protocol_members, native_members, _protocol_player, _ = (
2237 controller._translate_members_for_protocols(
2238 parent_player=sonos_a,
2239 player_ids=["sonos_456", "wiim_789"],
2240 parent_protocol_player=sonos_airplay,
2241 parent_protocol_domain="airplay",
2242 )
2243 )
2244
2245 # Should have hybrid group: native Sonos B + protocol WiiM
2246 assert len(native_members) == 1
2247 assert "sonos_456" in native_members
2248 assert len(protocol_members) == 1
2249 assert "airplay_wiim" in protocol_members
2250
2251 def test_protocol_selection_requires_set_members(self, mock_mass: MagicMock) -> None:
2252 """Test that only protocols with SET_MEMBERS support are selected for grouping."""
2253 controller = PlayerController(mock_mass)
2254
2255 sonos_provider = MockProvider("sonos", mass=mock_mass)
2256 wiim_provider = MockProvider("wiim", mass=mock_mass)
2257 dlna_provider = MockProvider("dlna", mass=mock_mass)
2258 airplay_provider = MockProvider("airplay", mass=mock_mass)
2259
2260 # Sonos player
2261 sonos_player = MockPlayer(
2262 sonos_provider,
2263 "sonos_123",
2264 "Living Room",
2265 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2266 )
2267 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2268 sonos_player._cache.clear()
2269
2270 # WiiM player
2271 wiim_player = MockPlayer(
2272 wiim_provider,
2273 "wiim_456",
2274 "Bedroom",
2275 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2276 )
2277 wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2278 wiim_player._cache.clear()
2279
2280 # DLNA protocol (does NOT support SET_MEMBERS)
2281 sonos_dlna = MockPlayer(
2282 dlna_provider,
2283 "dlna_sonos",
2284 "Living Room (DLNA)",
2285 player_type=PlayerType.PROTOCOL,
2286 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2287 )
2288 # Note: NO SET_MEMBERS feature
2289
2290 wiim_dlna = MockPlayer(
2291 dlna_provider,
2292 "dlna_wiim",
2293 "Bedroom (DLNA)",
2294 player_type=PlayerType.PROTOCOL,
2295 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2296 )
2297
2298 # AirPlay protocol (DOES support SET_MEMBERS)
2299 sonos_airplay = MockPlayer(
2300 airplay_provider,
2301 "airplay_sonos",
2302 "Living Room (AirPlay)",
2303 player_type=PlayerType.PROTOCOL,
2304 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2305 )
2306 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2307 sonos_airplay._attr_can_group_with = {"airplay_wiim"}
2308 sonos_airplay._cache.clear()
2309
2310 wiim_airplay = MockPlayer(
2311 airplay_provider,
2312 "airplay_wiim",
2313 "Bedroom (AirPlay)",
2314 player_type=PlayerType.PROTOCOL,
2315 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2316 )
2317 wiim_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2318 wiim_airplay._attr_can_group_with = {"airplay_sonos"}
2319 wiim_airplay._cache.clear()
2320
2321 # Link protocols (DLNA has lower priority than AirPlay)
2322 sonos_player.set_linked_output_protocols(
2323 [
2324 LinkedOutputProtocol(
2325 output_protocol_id="dlna_sonos",
2326 protocol_domain="dlna",
2327 priority=50, # Lower priority (higher number)
2328 ),
2329 LinkedOutputProtocol(
2330 output_protocol_id="airplay_sonos",
2331 protocol_domain="airplay",
2332 priority=10, # Higher priority (lower number)
2333 ),
2334 ]
2335 )
2336 wiim_player.set_linked_output_protocols(
2337 [
2338 LinkedOutputProtocol(
2339 output_protocol_id="dlna_wiim",
2340 protocol_domain="dlna",
2341 priority=50,
2342 ),
2343 LinkedOutputProtocol(
2344 output_protocol_id="airplay_wiim",
2345 protocol_domain="airplay",
2346 priority=10,
2347 ),
2348 ]
2349 )
2350
2351 mock_mass.players = controller
2352 controller._players = {
2353 "sonos_123": sonos_player,
2354 "wiim_456": wiim_player,
2355 "dlna_sonos": sonos_dlna,
2356 "dlna_wiim": wiim_dlna,
2357 "airplay_sonos": sonos_airplay,
2358 "airplay_wiim": wiim_airplay,
2359 }
2360
2361 # Update state after modifying attributes
2362 sonos_dlna.update_state(signal_event=False)
2363 wiim_dlna.update_state(signal_event=False)
2364 sonos_airplay.update_state(signal_event=False)
2365 wiim_airplay.update_state(signal_event=False)
2366
2367 # Translate members - should skip DLNA (no SET_MEMBERS) and select AirPlay
2368 protocol_members, _native_members, protocol_player, protocol_domain = (
2369 controller._translate_members_for_protocols(
2370 parent_player=sonos_player,
2371 player_ids=["wiim_456"],
2372 parent_protocol_player=None,
2373 parent_protocol_domain=None,
2374 )
2375 )
2376
2377 # Should select AirPlay (supports SET_MEMBERS) not DLNA
2378 assert len(protocol_members) == 1
2379 assert "airplay_wiim" in protocol_members
2380 assert protocol_domain == "airplay"
2381 assert protocol_player == sonos_airplay
2382
2383 # A group already on DLNA must be moved off it as well, because DLNA cannot
2384 # group members itself
2385 protocol_members, _native_members, protocol_player, protocol_domain = (
2386 controller._translate_members_for_protocols(
2387 parent_player=sonos_player,
2388 player_ids=["wiim_456"],
2389 parent_protocol_player=sonos_dlna,
2390 parent_protocol_domain="dlna",
2391 )
2392 )
2393
2394 assert protocol_members == ["airplay_wiim"]
2395 assert protocol_domain == "airplay"
2396 assert protocol_player == sonos_airplay
2397
2398
2399class TestJoinActiveNativeSession:
2400 """
2401 Grouping a child onto a parent that is already playing natively.
2402
2403 A child's preferred output protocol must only steer protocol selection when the child
2404 initiates its own playback. When the child joins a parent that already holds an active
2405 native session, it should adopt native grouping if compatible, instead of forcing the
2406 whole group onto the child's preferred protocol.
2407 """
2408
2409 @staticmethod
2410 def _mark_native_playing(player: MockPlayer) -> None:
2411 """Put the player into a live native playback session."""
2412 player._attr_playback_state = PlaybackState.PLAYING
2413 player._cache.clear()
2414 player.set_active_output_protocol("native")
2415
2416 @staticmethod
2417 def _link_airplay(native_player: MockPlayer, airplay_id: str) -> None:
2418 """Link an AirPlay protocol player to a native player."""
2419 native_player.set_linked_output_protocols(
2420 [
2421 LinkedOutputProtocol(
2422 output_protocol_id=airplay_id,
2423 protocol_domain="airplay",
2424 priority=10,
2425 )
2426 ]
2427 )
2428
2429 def _build_topology(
2430 self, mock_mass: MagicMock
2431 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
2432 """
2433 Build parent Sonos A + child Sonos B, both AirPlay-capable and natively groupable.
2434
2435 The parent also exposes an AirPlay protocol player that supports SET_MEMBERS, so the
2436 child's preferred AirPlay protocol *would* be selectable if it were given priority.
2437 """
2438 controller = PlayerController(mock_mass)
2439 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
2440 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
2441
2442 sonos_a = MockPlayer(
2443 sonos_provider,
2444 "sonos_a",
2445 "Office",
2446 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2447 )
2448 sonos_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2449 sonos_a._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2450 sonos_a._attr_can_group_with = {"sonos_b"}
2451 sonos_a._cache.clear()
2452
2453 sonos_b = MockPlayer(
2454 sonos_provider,
2455 "sonos_b",
2456 "Bathroom",
2457 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2458 )
2459 sonos_b._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2460 sonos_b._attr_can_group_with = {"sonos_a"}
2461 sonos_b._cache.clear()
2462
2463 airplay_a = MockPlayer(
2464 airplay_provider,
2465 "airplay_a",
2466 "Office (AirPlay)",
2467 player_type=PlayerType.PROTOCOL,
2468 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2469 )
2470 airplay_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2471 airplay_a._attr_can_group_with = {"airplay_b"}
2472 airplay_a._cache.clear()
2473
2474 airplay_b = MockPlayer(
2475 airplay_provider,
2476 "airplay_b",
2477 "Bathroom (AirPlay)",
2478 player_type=PlayerType.PROTOCOL,
2479 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2480 )
2481 airplay_b._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2482 airplay_b._attr_can_group_with = {"airplay_a"}
2483 airplay_b._cache.clear()
2484
2485 self._link_airplay(sonos_a, "airplay_a")
2486 self._link_airplay(sonos_b, "airplay_b")
2487
2488 mock_mass.players = controller
2489 players = {
2490 "sonos_a": sonos_a,
2491 "sonos_b": sonos_b,
2492 "airplay_a": airplay_a,
2493 "airplay_b": airplay_b,
2494 }
2495 controller._players = dict(players)
2496 for player in players.values():
2497 player.update_state(signal_event=False)
2498 return controller, players
2499
2500 def _add_airplay_only_child(
2501 self, controller: PlayerController, players: dict[str, MockPlayer], mock_mass: MagicMock
2502 ) -> None:
2503 """
2504 Add a WiiM child that can only group via AirPlay (no native grouping with Sonos).
2505
2506 Different provider instance and absent from the parent's can_group_with, so
2507 _can_use_native_grouping returns False for it.
2508 """
2509 wiim_provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
2510 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
2511 wiim_c = MockPlayer(
2512 wiim_provider,
2513 "wiim_c",
2514 "Bedroom",
2515 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
2516 )
2517 wiim_c._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2518 wiim_c._cache.clear()
2519 airplay_c = MockPlayer(
2520 airplay_provider,
2521 "airplay_c",
2522 "Bedroom (AirPlay)",
2523 player_type=PlayerType.PROTOCOL,
2524 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
2525 )
2526 airplay_c._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2527 airplay_c._attr_can_group_with = {"airplay_a"}
2528 airplay_c._cache.clear()
2529 self._link_airplay(wiim_c, "airplay_c")
2530 # Allow the parent's AirPlay protocol to group with the new AirPlay child.
2531 players["airplay_a"]._attr_can_group_with = {"airplay_b", "airplay_c"}
2532 players["airplay_a"]._cache.clear()
2533 controller._players["wiim_c"] = wiim_c
2534 controller._players["airplay_c"] = airplay_c
2535 wiim_c.update_state(signal_event=False)
2536 airplay_c.update_state(signal_event=False)
2537
2538 def test_native_active_parent_ignores_child_preferred_protocol(
2539 self, mock_mass: MagicMock
2540 ) -> None:
2541 """A child joining a natively-playing parent groups natively, not via preferred AirPlay."""
2542
2543 def _get_raw(
2544 player_id: str, key: str, default: str | int | None = None
2545 ) -> str | int | None:
2546 if key == CONF_PREFERRED_OUTPUT_PROTOCOL and player_id == "sonos_b":
2547 return "airplay_b"
2548 if key == "min_volume":
2549 return 0
2550 if key == "max_volume":
2551 return 100
2552 return default
2553
2554 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw)
2555
2556 controller, players = self._build_topology(mock_mass)
2557 # Parent A is already playing natively.
2558 self._mark_native_playing(players["sonos_a"])
2559
2560 protocol_members, native_members, _, _ = controller._translate_members_for_protocols(
2561 parent_player=players["sonos_a"],
2562 player_ids=["sonos_b"],
2563 parent_protocol_player=None,
2564 parent_protocol_domain=None,
2565 )
2566
2567 # Native grouping is used; the child's AirPlay preference is ignored on join.
2568 assert native_members == ["sonos_b"]
2569 assert protocol_members == []
2570
2571 def test_fresh_group_still_honors_child_preferred_protocol(self, mock_mass: MagicMock) -> None:
2572 """When the parent is not playing, the child's preferred protocol still steers selection."""
2573
2574 def _get_raw(
2575 player_id: str, key: str, default: str | int | None = None
2576 ) -> str | int | None:
2577 if key == CONF_PREFERRED_OUTPUT_PROTOCOL and player_id == "sonos_b":
2578 return "airplay_b"
2579 if key == "min_volume":
2580 return 0
2581 if key == "max_volume":
2582 return 100
2583 return default
2584
2585 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw)
2586
2587 controller, players = self._build_topology(mock_mass)
2588 # Parent A has no active session (active_output_protocol stays None).
2589 assert players["sonos_a"].active_output_protocol is None
2590
2591 protocol_members, native_members, _, protocol_domain = (
2592 controller._translate_members_for_protocols(
2593 parent_player=players["sonos_a"],
2594 player_ids=["sonos_b"],
2595 parent_protocol_player=None,
2596 parent_protocol_domain=None,
2597 )
2598 )
2599
2600 # Same topology as the active-native case, but here the preference applies.
2601 assert protocol_members == ["airplay_b"]
2602 assert native_members == []
2603 assert protocol_domain == "airplay"
2604
2605 def test_idle_parent_with_stale_native_protocol_honors_child_preferred(
2606 self, mock_mass: MagicMock
2607 ) -> None:
2608 """A stopped parent (active protocol lingers briefly) does not force the native-join path."""
2609
2610 def _get_raw(
2611 player_id: str, key: str, default: str | int | None = None
2612 ) -> str | int | None:
2613 if key == CONF_PREFERRED_OUTPUT_PROTOCOL and player_id == "sonos_b":
2614 return "airplay_b"
2615 if key == "min_volume":
2616 return 0
2617 if key == "max_volume":
2618 return 100
2619 return default
2620
2621 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw)
2622
2623 controller, players = self._build_topology(mock_mass)
2624 # Parent A stopped: active protocol still reads "native" but playback is idle.
2625 players["sonos_a"].set_active_output_protocol("native")
2626 assert players["sonos_a"].state.playback_state == PlaybackState.IDLE
2627
2628 protocol_members, native_members, _, protocol_domain = (
2629 controller._translate_members_for_protocols(
2630 parent_player=players["sonos_a"],
2631 player_ids=["sonos_b"],
2632 parent_protocol_player=None,
2633 parent_protocol_domain=None,
2634 )
2635 )
2636
2637 # No live session to join, so the child's preference applies.
2638 assert protocol_members == ["airplay_b"]
2639 assert native_members == []
2640 assert protocol_domain == "airplay"
2641
2642 def test_native_active_parent_airplay_only_child_uses_protocol(
2643 self, mock_mass: MagicMock
2644 ) -> None:
2645 """An AirPlay-only child (cannot group natively) still joins via its preferred protocol."""
2646
2647 def _get_raw(
2648 player_id: str, key: str, default: str | int | None = None
2649 ) -> str | int | None:
2650 if key == CONF_PREFERRED_OUTPUT_PROTOCOL and player_id == "wiim_c":
2651 return "airplay_c"
2652 if key == "min_volume":
2653 return 0
2654 if key == "max_volume":
2655 return 100
2656 return default
2657
2658 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw)
2659
2660 controller, players = self._build_topology(mock_mass)
2661 self._add_airplay_only_child(controller, players, mock_mass)
2662 self._mark_native_playing(players["sonos_a"])
2663
2664 protocol_members, native_members, _, protocol_domain = (
2665 controller._translate_members_for_protocols(
2666 parent_player=players["sonos_a"],
2667 player_ids=["wiim_c"],
2668 parent_protocol_player=None,
2669 parent_protocol_domain=None,
2670 )
2671 )
2672
2673 # No native path exists for the WiiM, so it falls back to AirPlay grouping.
2674 assert protocol_members == ["airplay_c"]
2675 assert native_members == []
2676 assert protocol_domain == "airplay"
2677
2678 def test_native_active_parent_protocol_only_child_switches_via_common_protocol(
2679 self, mock_mass: MagicMock
2680 ) -> None:
2681 """
2682 A child with no native path (and no explicit preference) still forces the switch.
2683
2684 Even without a configured preferred protocol, a child that can only reach the group via
2685 a shared protocol (here AirPlay, which the natively-playing parent also supports) is
2686 grouped via that protocol - moving the group onto it.
2687 """
2688 controller, players = self._build_topology(mock_mass)
2689 self._add_airplay_only_child(controller, players, mock_mass)
2690 self._mark_native_playing(players["sonos_a"])
2691
2692 # No preferred_output_protocol override - the WiiM resolves via the common-protocol path.
2693 protocol_members, native_members, _, protocol_domain = (
2694 controller._translate_members_for_protocols(
2695 parent_player=players["sonos_a"],
2696 player_ids=["wiim_c"],
2697 parent_protocol_player=None,
2698 parent_protocol_domain=None,
2699 )
2700 )
2701
2702 assert protocol_members == ["airplay_c"]
2703 assert native_members == []
2704 assert protocol_domain == "airplay"
2705
2706 @pytest.mark.parametrize(
2707 "member_order",
2708 [["wiim_c", "sonos_b"], ["sonos_b", "wiim_c"]],
2709 )
2710 def test_native_active_parent_keeps_mixed_batch_cohesive(
2711 self, mock_mass: MagicMock, member_order: list[str]
2712 ) -> None:
2713 """
2714 A mixed batch stays cohesive on one protocol regardless of member order.
2715
2716 When an AirPlay-only child forces the group onto AirPlay, the natively-groupable Sonos
2717 child adopts that same protocol rather than splitting off into a native sub-group -
2718 whether it is added before or after the AirPlay-only child.
2719 """
2720 controller, players = self._build_topology(mock_mass)
2721 self._add_airplay_only_child(controller, players, mock_mass)
2722 self._mark_native_playing(players["sonos_a"])
2723
2724 protocol_members, native_members, _, protocol_domain = (
2725 controller._translate_members_for_protocols(
2726 parent_player=players["sonos_a"],
2727 player_ids=member_order,
2728 parent_protocol_player=None,
2729 parent_protocol_domain=None,
2730 )
2731 )
2732
2733 assert native_members == []
2734 assert set(protocol_members) == {"airplay_c", "airplay_b"}
2735 assert protocol_domain == "airplay"
2736
2737
2738class TestSessionBoundNativeGrouping:
2739 """
2740 Grouping a parent whose native grouping only works while it renders its own stream.
2741
2742 Such native members are attached to that very stream, so they cannot stay natively
2743 grouped once the group is moved onto one of the parent's output protocols. Parents
2744 with device-side native grouping are unaffected and keep their hybrid groups.
2745 """
2746
2747 @staticmethod
2748 def _link_bridge(player: MockPlayer, bridge_id: str) -> None:
2749 """Link a bridge protocol player to a native player."""
2750 player.set_linked_output_protocols(
2751 [
2752 LinkedOutputProtocol(
2753 output_protocol_id=bridge_id,
2754 protocol_domain="sendspin",
2755 priority=40,
2756 )
2757 ]
2758 )
2759
2760 def _build_topology(
2761 self,
2762 mock_mass: MagicMock,
2763 parent_class: type[MockPlayer],
2764 with_orphan: bool = False,
2765 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
2766 """
2767 Build a leader with a natively groupable member and a bridge-only member.
2768
2769 The leader and its member speak the same (speaker) provider, so they group natively.
2770 Both also expose a bridge protocol player, which is the only way to reach the third
2771 member: a native player of the bridge domain.
2772
2773 :param mock_mass: The mocked MusicAssistant instance.
2774 :param parent_class: The player class to build the leader from.
2775 :param with_orphan: Add a second natively groupable member without a bridge protocol.
2776 """
2777 controller = PlayerController(mock_mass)
2778 speaker_provider = MockProvider("speaker", instance_id="speaker_instance", mass=mock_mass)
2779 bridge_provider = MockProvider("sendspin", instance_id="sendspin_instance", mass=mock_mass)
2780
2781 leader = parent_class(speaker_provider, "speaker_leader", "Living Room")
2782 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2783 leader._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2784 leader._cache.clear()
2785
2786 member = MockPlayer(speaker_provider, "speaker_member", "Kitchen")
2787 member._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2788 member._cache.clear()
2789
2790 bridge_leader = MockPlayer(
2791 bridge_provider,
2792 "bridge_leader",
2793 "Living Room (Bridge)",
2794 player_type=PlayerType.PROTOCOL,
2795 )
2796 bridge_leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2797 bridge_leader._cache.clear()
2798 bridge_leader.set_protocol_parent_id("speaker_leader")
2799
2800 bridge_member = MockPlayer(
2801 bridge_provider,
2802 "bridge_member",
2803 "Kitchen (Bridge)",
2804 player_type=PlayerType.PROTOCOL,
2805 )
2806 bridge_member._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2807 bridge_member._cache.clear()
2808 bridge_member.set_protocol_parent_id("speaker_member")
2809
2810 # native player of the bridge domain: only reachable through the bridge protocol
2811 bridge_only = MockPlayer(bridge_provider, "bridge_only", "Voice Assistant")
2812 bridge_only._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2813 bridge_only._cache.clear()
2814
2815 self._link_bridge(leader, "bridge_leader")
2816 self._link_bridge(member, "bridge_member")
2817
2818 players: dict[str, MockPlayer] = {
2819 "speaker_leader": leader,
2820 "speaker_member": member,
2821 "bridge_leader": bridge_leader,
2822 "bridge_member": bridge_member,
2823 "bridge_only": bridge_only,
2824 }
2825 if with_orphan:
2826 orphan = MockPlayer(speaker_provider, "speaker_orphan", "Bedroom")
2827 orphan._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2828 orphan._cache.clear()
2829 players["speaker_orphan"] = orphan
2830
2831 mock_mass.players = controller
2832 controller._players = dict(players)
2833 for player in players.values():
2834 player.update_state(signal_event=False)
2835 return controller, players
2836
2837 @pytest.mark.parametrize(
2838 "member_order",
2839 [
2840 ["speaker_member", "bridge_only"],
2841 ["bridge_only", "speaker_member"],
2842 ],
2843 )
2844 def test_native_members_follow_the_group_protocol(
2845 self, mock_mass: MagicMock, member_order: list[str]
2846 ) -> None:
2847 """A natively groupable member moves onto the protocol the group ends up on."""
2848 controller, players = self._build_topology(mock_mass, SessionBoundMockPlayer)
2849
2850 protocol_members, native_members, _, protocol_domain = (
2851 controller._translate_members_for_protocols(
2852 parent_player=players["speaker_leader"],
2853 player_ids=member_order,
2854 parent_protocol_player=None,
2855 parent_protocol_domain=None,
2856 )
2857 )
2858
2859 assert native_members == []
2860 assert set(protocol_members) == {"bridge_only", "bridge_member"}
2861 assert protocol_domain == "sendspin"
2862
2863 def test_member_without_the_group_protocol_is_refused(
2864 self, mock_mass: MagicMock, caplog: pytest.LogCaptureFixture
2865 ) -> None:
2866 """A native member that lacks the group's protocol is dropped, the others still move."""
2867 controller, players = self._build_topology(
2868 mock_mass, SessionBoundMockPlayer, with_orphan=True
2869 )
2870
2871 with caplog.at_level(logging.WARNING):
2872 protocol_members, native_members, _, _ = controller._translate_members_for_protocols(
2873 parent_player=players["speaker_leader"],
2874 player_ids=["speaker_member", "speaker_orphan", "bridge_only"],
2875 parent_protocol_player=None,
2876 parent_protocol_domain=None,
2877 )
2878
2879 assert native_members == []
2880 assert set(protocol_members) == {"bridge_only", "bridge_member"}
2881 assert (
2882 "Cannot group Bedroom with Living Room: the group plays through the sendspin "
2883 "protocol, which Bedroom does not support" in caplog.text
2884 )
2885
2886 def test_device_side_parent_keeps_its_hybrid_group(self, mock_mass: MagicMock) -> None:
2887 """A parent with device-side native grouping keeps its members natively grouped."""
2888 controller, players = self._build_topology(mock_mass, MockPlayer)
2889
2890 protocol_members, native_members, _, protocol_domain = (
2891 controller._translate_members_for_protocols(
2892 parent_player=players["speaker_leader"],
2893 player_ids=["speaker_member", "bridge_only"],
2894 parent_protocol_player=None,
2895 parent_protocol_domain=None,
2896 )
2897 )
2898
2899 assert native_members == ["speaker_member"]
2900 assert protocol_members == ["bridge_only"]
2901 assert protocol_domain == "sendspin"
2902
2903
2904class TestCanGroupWith:
2905 """Tests for can_group_with property with three scenarios."""
2906
2907 def test_scenario_1_native_active_only_native_players(self, mock_mass: MagicMock) -> None:
2908 """Test Scenario 1: Native playback active -> all protocols shown (new behavior)."""
2909 controller = PlayerController(mock_mass)
2910
2911 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
2912 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
2913
2914 # Create Sonos player with native and AirPlay support
2915 sonos_player = MockPlayer(
2916 sonos_provider,
2917 "sonos_123",
2918 "Living Room",
2919 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2920 )
2921 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2922 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2923 sonos_player._attr_can_group_with = {"sonos_456"}
2924 sonos_player._cache.clear()
2925 sonos_player.set_active_output_protocol("native")
2926
2927 # Create another Sonos player
2928 sonos_player_b = MockPlayer(
2929 sonos_provider,
2930 "sonos_456",
2931 "Kitchen",
2932 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2933 )
2934
2935 # Create AirPlay protocol player
2936 sonos_airplay = MockPlayer(
2937 airplay_provider,
2938 "airplay_sonos",
2939 "Living Room (AirPlay)",
2940 player_type=PlayerType.PROTOCOL,
2941 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2942 )
2943 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2944 sonos_airplay._attr_can_group_with = {"airplay_other"}
2945 sonos_airplay._cache.clear()
2946 sonos_airplay.set_protocol_parent_id("sonos_123")
2947
2948 sonos_player.set_linked_output_protocols(
2949 [
2950 LinkedOutputProtocol(
2951 output_protocol_id="airplay_sonos",
2952 protocol_domain="airplay",
2953 priority=10,
2954 )
2955 ]
2956 )
2957
2958 # Wire up mock_mass.players to controller so player lookups resolve
2959 mock_mass.players = controller
2960
2961 controller._players = {
2962 "sonos_123": sonos_player,
2963 "sonos_456": sonos_player_b,
2964 "airplay_sonos": sonos_airplay,
2965 }
2966
2967 # Update state after modifying attributes and registering with controller
2968 sonos_player.update_state(signal_event=False)
2969 sonos_player_b.update_state(signal_event=False)
2970 sonos_airplay.update_state(signal_event=False)
2971
2972 # Get can_group_with while native is active
2973 groupable = sonos_player.state.can_group_with
2974
2975 # NEW BEHAVIOR: Should show both native AND protocol players
2976 # even when native protocol is active
2977 assert "sonos_456" in groupable # Native Sonos player
2978 # Note: airplay_other is not registered in controller._players, so it won't appear
2979 # But the logic should still allow showing AirPlay options if they were registered
2980
2981 def test_scenario_2_protocol_active_hybrid_groups(self, mock_mass: MagicMock) -> None:
2982 """Test Scenario 2: Protocol active -> show all protocols (new behavior)."""
2983 controller = PlayerController(mock_mass)
2984
2985 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
2986 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
2987
2988 # Create Sonos player with AirPlay active
2989 sonos_player = MockPlayer(
2990 sonos_provider,
2991 "sonos_123",
2992 "Living Room",
2993 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2994 )
2995 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2996 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2997 sonos_player._attr_can_group_with = {"sonos_456"}
2998 sonos_player._cache.clear()
2999
3000 # Create another Sonos player
3001 sonos_player_b = MockPlayer(
3002 sonos_provider,
3003 "sonos_456",
3004 "Kitchen",
3005 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
3006 )
3007
3008 # Create AirPlay protocol player
3009 sonos_airplay = MockPlayer(
3010 airplay_provider,
3011 "airplay_sonos",
3012 "Living Room (AirPlay)",
3013 player_type=PlayerType.PROTOCOL,
3014 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3015 )
3016 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3017 sonos_airplay._attr_can_group_with = {"airplay_other"}
3018 sonos_airplay._cache.clear()
3019 sonos_airplay.set_protocol_parent_id("sonos_123")
3020
3021 # Create another device with AirPlay
3022 wiim_player = MockPlayer(
3023 sonos_provider,
3024 "wiim_789",
3025 "Bedroom",
3026 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
3027 )
3028
3029 airplay_other = MockPlayer(
3030 airplay_provider,
3031 "airplay_other",
3032 "Bedroom (AirPlay)",
3033 player_type=PlayerType.PROTOCOL,
3034 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
3035 )
3036 airplay_other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3037 airplay_other._attr_can_group_with = {"airplay_sonos"}
3038 airplay_other._cache.clear()
3039 airplay_other.set_protocol_parent_id("wiim_789")
3040
3041 wiim_player.set_linked_output_protocols(
3042 [
3043 LinkedOutputProtocol(
3044 output_protocol_id="airplay_other",
3045 protocol_domain="airplay",
3046 priority=10,
3047 ),
3048 ]
3049 )
3050
3051 sonos_player.set_linked_output_protocols(
3052 [
3053 LinkedOutputProtocol(
3054 output_protocol_id="airplay_sonos",
3055 protocol_domain="airplay",
3056 priority=10,
3057 )
3058 ]
3059 )
3060 sonos_player.set_active_output_protocol("airplay_sonos")
3061
3062 # Wire up mock_mass.players to controller so player lookups resolve
3063 mock_mass.players = controller
3064
3065 controller._players = {
3066 "sonos_123": sonos_player,
3067 "sonos_456": sonos_player_b,
3068 "wiim_789": wiim_player,
3069 "airplay_sonos": sonos_airplay,
3070 "airplay_other": airplay_other,
3071 }
3072
3073 # Clear cache after setting linked protocols
3074 sonos_player._cache.clear()
3075 wiim_player._cache.clear()
3076
3077 # Refresh state after modifying attributes and registering with controller
3078 # (refresh_state: registration changed cross-player state without the
3079 # production fan-out running, as events are suppressed in this test)
3080 # IMPORTANT: Update protocol players FIRST, then parent players
3081 sonos_airplay.refresh_state(signal_event=False)
3082 airplay_other.refresh_state(signal_event=False)
3083 sonos_player.refresh_state(signal_event=False)
3084 sonos_player_b.refresh_state(signal_event=False)
3085 wiim_player.refresh_state(signal_event=False)
3086
3087 # Get can_group_with while AirPlay is active
3088 groupable = sonos_player.state.can_group_with
3089
3090 # NEW BEHAVIOR: Should show ALL protocols + native players
3091 # regardless of which protocol is active
3092 assert "sonos_456" in groupable # Native Sonos player
3093 assert "wiim_789" in groupable # Via airplay_other protocol
3094
3095 def test_scenario_3_no_active_output_all_protocols_shown(self, mock_mass: MagicMock) -> None:
3096 """Test Scenario 3: No active output -> show all compatible protocols + native."""
3097 controller = PlayerController(mock_mass)
3098
3099 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
3100 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
3101 dlna_provider = MockProvider("dlna", instance_id="dlna_instance", mass=mock_mass)
3102
3103 # Create Sonos player (no active protocol)
3104 sonos_player = MockPlayer(
3105 sonos_provider,
3106 "sonos_123",
3107 "Living Room",
3108 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3109 )
3110 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3111 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3112 sonos_player._attr_can_group_with = {"sonos_456"}
3113 sonos_player._cache.clear()
3114 # No active output protocol set
3115
3116 # Create another Sonos player
3117 sonos_player_b = MockPlayer(
3118 sonos_provider,
3119 "sonos_456",
3120 "Kitchen",
3121 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
3122 )
3123
3124 # Create AirPlay protocol player (supports SET_MEMBERS)
3125 sonos_airplay = MockPlayer(
3126 airplay_provider,
3127 "airplay_sonos",
3128 "Living Room (AirPlay)",
3129 player_type=PlayerType.PROTOCOL,
3130 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3131 )
3132 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3133 sonos_airplay._attr_can_group_with = {"airplay_other"}
3134 sonos_airplay._cache.clear()
3135 sonos_airplay.set_protocol_parent_id("sonos_123")
3136
3137 # Create DLNA protocol player (does NOT support SET_MEMBERS)
3138 sonos_dlna = MockPlayer(
3139 dlna_provider,
3140 "dlna_sonos",
3141 "Living Room (DLNA)",
3142 player_type=PlayerType.PROTOCOL,
3143 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3144 )
3145 # No SET_MEMBERS support
3146 sonos_dlna._attr_can_group_with = {"dlna_other"}
3147 sonos_dlna.set_protocol_parent_id("sonos_123")
3148
3149 # Another device
3150 wiim_player = MockPlayer(
3151 sonos_provider,
3152 "wiim_789",
3153 "Bedroom",
3154 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
3155 )
3156
3157 airplay_other = MockPlayer(
3158 airplay_provider,
3159 "airplay_other",
3160 "Bedroom (AirPlay)",
3161 player_type=PlayerType.PROTOCOL,
3162 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
3163 )
3164 airplay_other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3165 airplay_other._attr_can_group_with = {"airplay_sonos"}
3166 airplay_other._cache.clear()
3167 airplay_other.set_protocol_parent_id("wiim_789")
3168
3169 sonos_player.set_linked_output_protocols(
3170 [
3171 LinkedOutputProtocol(
3172 output_protocol_id="airplay_sonos",
3173 protocol_domain="airplay",
3174 priority=10,
3175 ),
3176 LinkedOutputProtocol(
3177 output_protocol_id="dlna_sonos",
3178 protocol_domain="dlna",
3179 priority=50,
3180 ),
3181 ]
3182 )
3183
3184 wiim_player.set_linked_output_protocols(
3185 [
3186 LinkedOutputProtocol(
3187 output_protocol_id="airplay_other",
3188 protocol_domain="airplay",
3189 priority=10,
3190 ),
3191 ]
3192 )
3193
3194 # Clear cache after setting linked protocols (output_protocols is cached)
3195 sonos_player._cache.clear()
3196 wiim_player._cache.clear()
3197
3198 # Wire up mock_mass.players to controller so player lookups resolve
3199 mock_mass.players = controller
3200
3201 controller._players = {
3202 "sonos_123": sonos_player,
3203 "sonos_456": sonos_player_b,
3204 "wiim_789": wiim_player,
3205 "airplay_sonos": sonos_airplay,
3206 "airplay_other": airplay_other,
3207 "dlna_sonos": sonos_dlna,
3208 }
3209
3210 # Update state after modifying attributes and registering with controller
3211 # Note: set_linked_output_protocols calls trigger_player_update, but since mass.players
3212 # is a MagicMock, we need to manually call update_state
3213 # IMPORTANT: Update protocol players FIRST, then parent players, because parent players
3214 # access protocol_player.state.can_group_with during their update_state()
3215 sonos_airplay.update_state(signal_event=False)
3216 airplay_other.update_state(signal_event=False)
3217 sonos_dlna.update_state(signal_event=False)
3218 sonos_player.update_state(signal_event=False)
3219 sonos_player_b.update_state(signal_event=False)
3220 wiim_player.update_state(signal_event=False)
3221
3222 # Get can_group_with with no active protocol
3223 groupable = sonos_player.state.can_group_with
3224
3225 # Should show native players + AirPlay players (supports SET_MEMBERS)
3226 # but NOT DLNA players (no SET_MEMBERS support)
3227 assert "sonos_456" in groupable
3228 assert "wiim_789" in groupable # Via AirPlay protocol
3229 # DLNA players should not be shown since DLNA doesn't support SET_MEMBERS
3230
3231
3232class TestNativePlayerProtocolGrouping:
3233 """Tests for grouping between native PLAYER type and PROTOCOL type AirPlay players."""
3234
3235 def test_native_airplay_player_sees_protocol_players_as_visible_parents(
3236 self, mock_mass: MagicMock
3237 ) -> None:
3238 """Test that a native PLAYER type translates protocol players to visible parents."""
3239 controller = PlayerController(mock_mass)
3240
3241 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
3242 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
3243
3244 # HomePod: native AirPlay PLAYER (not PROTOCOL)
3245 homepod = MockPlayer(airplay_provider, "homepod_1", "Office")
3246 homepod._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3247 homepod._attr_can_group_with = {"airplay"} # Provider instance ID
3248 homepod._cache.clear()
3249
3250 # Sonos native player (visible to the user)
3251 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Kitchen")
3252 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3253 sonos_player._cache.clear()
3254
3255 # AirPlay protocol player for the Sonos (hidden, linked to sonos_player)
3256 sonos_airplay = MockPlayer(
3257 airplay_provider,
3258 "airplay_sonos_1",
3259 "Kitchen (AirPlay)",
3260 player_type=PlayerType.PROTOCOL,
3261 )
3262 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3263 sonos_airplay._attr_can_group_with = {"airplay"}
3264 sonos_airplay._cache.clear()
3265 sonos_airplay.set_protocol_parent_id("sonos_1")
3266
3267 sonos_player.set_linked_output_protocols(
3268 [
3269 LinkedOutputProtocol(
3270 output_protocol_id="airplay_sonos_1",
3271 protocol_domain="airplay",
3272 priority=10,
3273 )
3274 ]
3275 )
3276
3277 mock_mass.players = controller
3278 mock_mass.get_provider = MagicMock(return_value=airplay_provider)
3279
3280 controller._players = {
3281 "homepod_1": homepod,
3282 "sonos_1": sonos_player,
3283 "airplay_sonos_1": sonos_airplay,
3284 }
3285
3286 # Mark players as initialized so they are returned by all_players()
3287 homepod.set_initialized()
3288 sonos_player.set_initialized()
3289 sonos_airplay.set_initialized()
3290
3291 # Update protocol players first, then parents
3292 sonos_airplay.update_state(signal_event=False)
3293 sonos_player.update_state(signal_event=False)
3294 homepod.update_state(signal_event=False)
3295
3296 groupable = homepod.state.can_group_with
3297
3298 # HomePod should see Sonos's VISIBLE player, not the hidden protocol player
3299 assert "sonos_1" in groupable
3300 assert "airplay_sonos_1" not in groupable # Hidden protocol ID must NOT appear
3301
3302 def test_protocol_linked_player_sees_native_airplay_player(self, mock_mass: MagicMock) -> None:
3303 """Test that a player with linked AirPlay protocol sees native PLAYER type players."""
3304 controller = PlayerController(mock_mass)
3305
3306 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
3307 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
3308
3309 # HomePod: native AirPlay PLAYER
3310 homepod = MockPlayer(airplay_provider, "homepod_1", "Office")
3311 homepod._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3312 homepod._attr_can_group_with = {"airplay"}
3313 homepod._cache.clear()
3314
3315 # Sonos native player (visible to the user)
3316 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Kitchen")
3317 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3318 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3319 sonos_player._attr_can_group_with = set() # No native Sonos grouping peers
3320 sonos_player._cache.clear()
3321
3322 # AirPlay protocol player for the Sonos (hidden, linked to sonos_player)
3323 sonos_airplay = MockPlayer(
3324 airplay_provider,
3325 "airplay_sonos_1",
3326 "Kitchen (AirPlay)",
3327 player_type=PlayerType.PROTOCOL,
3328 )
3329 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3330 sonos_airplay._attr_can_group_with = {"airplay"} # Provider instance ID
3331 sonos_airplay._cache.clear()
3332 sonos_airplay.set_protocol_parent_id("sonos_1")
3333
3334 sonos_player.set_linked_output_protocols(
3335 [
3336 LinkedOutputProtocol(
3337 output_protocol_id="airplay_sonos_1",
3338 protocol_domain="airplay",
3339 priority=10,
3340 )
3341 ]
3342 )
3343
3344 mock_mass.players = controller
3345 mock_mass.get_provider = MagicMock(return_value=airplay_provider)
3346
3347 controller._players = {
3348 "homepod_1": homepod,
3349 "sonos_1": sonos_player,
3350 "airplay_sonos_1": sonos_airplay,
3351 }
3352
3353 # Mark players as initialized so they are returned by all_players()
3354 homepod.set_initialized()
3355 sonos_player.set_initialized()
3356 sonos_airplay.set_initialized()
3357
3358 # Update protocol players first, then parents
3359 sonos_airplay.update_state(signal_event=False)
3360 homepod.update_state(signal_event=False)
3361 sonos_player.update_state(signal_event=False)
3362
3363 groupable = sonos_player.state.can_group_with
3364
3365 # Sonos should see HomePod via its linked AirPlay protocol's can_group_with
3366 assert "homepod_1" in groupable
3367
3368
3369class TestProtocolSwitchingDuringPlayback:
3370 """Tests for dynamic protocol switching when group members change during playback."""
3371
3372 def _build_session_bound_group(
3373 self, mock_mass: MagicMock, playback_state: PlaybackState
3374 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
3375 """
3376 Build a leader that renders its own stream, one native member and a bridge-only player.
3377
3378 :param mock_mass: The mocked MusicAssistant instance.
3379 :param playback_state: The state the leader is in when the switch happens.
3380 :return: The controller and its players by ID.
3381 """
3382 controller = PlayerController(mock_mass)
3383 speaker_provider = MockProvider("speaker", instance_id="speaker_instance", mass=mock_mass)
3384 bridge_provider = MockProvider("sendspin", instance_id="sendspin_instance", mass=mock_mass)
3385
3386 leader = SessionBoundMockPlayer(speaker_provider, "speaker_leader", "Living Room")
3387 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3388 leader._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3389 leader._attr_group_members = ["speaker_leader", "speaker_member"]
3390 leader._attr_playback_state = playback_state
3391 leader._cache.clear()
3392
3393 member = MockPlayer(speaker_provider, "speaker_member", "Kitchen")
3394 member._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3395 member._cache.clear()
3396
3397 bridge_only = MockPlayer(bridge_provider, "bridge_only", "Voice Assistant")
3398 bridge_only._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3399 bridge_only._cache.clear()
3400
3401 players: dict[str, MockPlayer] = {
3402 "speaker_leader": leader,
3403 "speaker_member": member,
3404 "bridge_only": bridge_only,
3405 }
3406 for parent_id, bridge_id, bridge_name in (
3407 ("speaker_leader", "bridge_leader", "Living Room (Bridge)"),
3408 ("speaker_member", "bridge_member", "Kitchen (Bridge)"),
3409 ):
3410 bridge = MockPlayer(
3411 bridge_provider, bridge_id, bridge_name, player_type=PlayerType.PROTOCOL
3412 )
3413 bridge._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3414 bridge._cache.clear()
3415 bridge.set_protocol_parent_id(parent_id)
3416 players[bridge_id] = bridge
3417 players[parent_id].set_linked_output_protocols(
3418 [
3419 LinkedOutputProtocol(
3420 output_protocol_id=bridge_id,
3421 protocol_domain="sendspin",
3422 priority=40,
3423 )
3424 ]
3425 )
3426
3427 mock_mass.players = controller
3428 controller._players = dict(players)
3429 for registered_player in players.values():
3430 registered_player.update_state(signal_event=False)
3431 leader.set_active_output_protocol("native")
3432 return controller, players
3433
3434 async def test_no_protocol_set_during_grouping_without_playback(
3435 self, mock_mass: MagicMock
3436 ) -> None:
3437 """Test that no protocol is set when grouping players without active playback."""
3438 controller = PlayerController(mock_mass)
3439
3440 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
3441 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
3442
3443 # Create Sonos player with AirPlay support
3444 sonos_player = MockPlayer(
3445 sonos_provider,
3446 "sonos_123",
3447 "Living Room",
3448 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3449 )
3450 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3451 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3452 sonos_player._attr_can_group_with = {"sonos_456"}
3453
3454 # Create another Sonos player
3455 sonos_player_b = MockPlayer(
3456 sonos_provider,
3457 "sonos_456",
3458 "Kitchen",
3459 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
3460 )
3461 sonos_player_b._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3462
3463 # Create AirPlay protocol player
3464 sonos_airplay = MockPlayer(
3465 airplay_provider,
3466 "airplay_sonos",
3467 "Living Room (AirPlay)",
3468 player_type=PlayerType.PROTOCOL,
3469 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3470 )
3471 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3472 sonos_airplay.set_protocol_parent_id("sonos_123")
3473
3474 sonos_player.set_linked_output_protocols(
3475 [
3476 LinkedOutputProtocol(
3477 output_protocol_id="airplay_sonos",
3478 protocol_domain="airplay",
3479 priority=10,
3480 )
3481 ]
3482 )
3483
3484 mock_mass.players = controller
3485 controller._players = {
3486 "sonos_123": sonos_player,
3487 "sonos_456": sonos_player_b,
3488 "airplay_sonos": sonos_airplay,
3489 }
3490
3491 # Group players via protocol (simulate grouping through AirPlay)
3492 # This should NOT set active_output_protocol anymore
3493 await controller._forward_protocol_set_members(
3494 parent_player=sonos_player,
3495 parent_protocol_player=sonos_airplay,
3496 protocol_members_to_add=["airplay_other"], # Add a protocol member
3497 protocol_members_to_remove=[],
3498 )
3499
3500 # NEW BEHAVIOR: Protocol should NOT be set during grouping without playback
3501 # After grouping, protocol should not be activated until playback starts
3502 assert sonos_player.active_output_protocol is None
3503
3504 async def test_protocol_selected_at_playback_time(self, mock_mass: MagicMock) -> None:
3505 """Test that protocol is selected when playback starts, not during grouping."""
3506 controller = PlayerController(mock_mass)
3507
3508 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
3509 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
3510
3511 # Create Sonos player with AirPlay support
3512 sonos_player = MockPlayer(
3513 sonos_provider,
3514 "sonos_123",
3515 "Living Room",
3516 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3517 )
3518 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3519 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3520
3521 # Create AirPlay protocol player with group members
3522 sonos_airplay = MockPlayer(
3523 airplay_provider,
3524 "airplay_sonos",
3525 "Living Room (AirPlay)",
3526 player_type=PlayerType.PROTOCOL,
3527 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3528 )
3529 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3530 sonos_airplay._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3531 sonos_airplay.set_protocol_parent_id("sonos_123")
3532 # Simulate that AirPlay protocol has group members (needs >1 for grouping check)
3533 sonos_airplay._attr_group_members = ["airplay_sonos", "airplay_other"]
3534
3535 sonos_player.set_linked_output_protocols(
3536 [
3537 LinkedOutputProtocol(
3538 output_protocol_id="airplay_sonos",
3539 protocol_domain="airplay",
3540 priority=10,
3541 )
3542 ]
3543 )
3544
3545 mock_mass.players = controller
3546 controller._players = {
3547 "sonos_123": sonos_player,
3548 "airplay_sonos": sonos_airplay,
3549 }
3550
3551 # Update state to apply group members to state
3552 sonos_airplay.update_state(signal_event=False)
3553 sonos_player.update_state(signal_event=False)
3554
3555 # Protocol should not be set yet
3556 assert sonos_player.active_output_protocol is None
3557
3558 # Select protocol for playback
3559 selected_player, output_protocol = controller._select_best_output_protocol(sonos_player)
3560
3561 # Should select AirPlay protocol because it has group members (Priority 1)
3562 assert selected_player == sonos_airplay
3563 assert output_protocol is not None
3564 assert output_protocol.output_protocol_id == "airplay_sonos"
3565
3566 async def test_no_restart_from_handle_set_members(self, mock_mass: MagicMock) -> None:
3567 """
3568 Test that _handle_set_members does NOT restart playback.
3569
3570 Protocol switching and playback restarts are handled in _forward_protocol_set_members,
3571 not in _handle_set_members. This test verifies that _handle_set_members doesn't
3572 trigger any redundant playback restarts.
3573 """
3574 controller = PlayerController(mock_mass)
3575
3576 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
3577 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
3578
3579 # Create Sonos player currently playing via AirPlay
3580 sonos_player = MockPlayer(
3581 sonos_provider,
3582 "sonos_123",
3583 "Living Room",
3584 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3585 )
3586 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3587 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3588 sonos_player._attr_playback_state = PlaybackState.PLAYING
3589 sonos_player._attr_group_members = ["sonos_123", "sonos_456"]
3590
3591 # Create another Sonos player in the group (member of sonos_123's group)
3592 sonos_player_b = MockPlayer(
3593 sonos_provider,
3594 "sonos_456",
3595 "Kitchen",
3596 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
3597 )
3598 sonos_player_b._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3599 # sonos_player_b's synced_to is derived from group_members, not a direct attribute
3600
3601 # Create AirPlay protocol player (was used for grouping)
3602 sonos_airplay = MockPlayer(
3603 airplay_provider,
3604 "airplay_sonos",
3605 "Living Room (AirPlay)",
3606 player_type=PlayerType.PROTOCOL,
3607 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3608 )
3609 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3610 sonos_airplay.set_protocol_parent_id("sonos_123")
3611
3612 sonos_player.set_linked_output_protocols(
3613 [
3614 LinkedOutputProtocol(
3615 output_protocol_id="airplay_sonos",
3616 protocol_domain="airplay",
3617 priority=10,
3618 )
3619 ]
3620 )
3621
3622 mock_mass.players = controller
3623 controller._players = {
3624 "sonos_123": sonos_player,
3625 "sonos_456": sonos_player_b,
3626 "airplay_sonos": sonos_airplay,
3627 }
3628
3629 # Update state and set active output protocol AFTER registering with controller
3630 sonos_player.update_state(signal_event=False)
3631 sonos_player_b.update_state(signal_event=False)
3632 sonos_airplay.update_state(signal_event=False)
3633
3634 # Set active output protocol (must be done after controller is set up)
3635 sonos_player.set_active_output_protocol("airplay_sonos")
3636
3637 # Track if cmd_resume was called
3638 resume_called = False
3639
3640 async def mock_cmd_resume(
3641 player_id: str, # noqa: ARG001
3642 source: str | None = None, # noqa: ARG001
3643 media: PlayerMedia | None = None, # noqa: ARG001
3644 ) -> None:
3645 nonlocal resume_called
3646 resume_called = True
3647
3648 controller.cmd_resume = mock_cmd_resume # type: ignore[method-assign]
3649
3650 # Remove member - now only the parent player is left
3651 # After removal, _select_best_output_protocol would return native
3652 sonos_player._attr_group_members = ["sonos_123"]
3653 sonos_player._cache.clear()
3654
3655 # Call _handle_set_members directly to trigger the protocol change check
3656 await controller._handle_set_members(
3657 sonos_player,
3658 player_ids_to_add=None,
3659 player_ids_to_remove=["sonos_456"],
3660 )
3661
3662 # Playback should NOT have been restarted because we're going back to native
3663 assert not resume_called, "cmd_resume should not be called when switching to native"
3664
3665 @pytest.mark.parametrize("playback_state", [PlaybackState.PLAYING, PlaybackState.PAUSED])
3666 async def test_native_session_is_stopped_and_members_migrate(
3667 self, mock_mass: MagicMock, playback_state: PlaybackState
3668 ) -> None:
3669 """
3670 A parent that renders its own stream hands its native members over to the new protocol.
3671
3672 The members join the protocol group in the same call as the new member, after which
3673 the native session releases them and stops. Playback resumes only for a player that
3674 was playing, so adding a member to a paused group does not start it.
3675 """
3676 controller, players = self._build_session_bound_group(mock_mass, playback_state)
3677 leader = players["speaker_leader"]
3678 bridge_leader = players["bridge_leader"]
3679
3680 # record the sequence of provider level calls the switch performs
3681 calls: list[tuple[str, Any]] = []
3682 leader_set_members = leader.set_members
3683 bridge_set_members = bridge_leader.set_members
3684
3685 async def record_leader_set_members(
3686 player_ids_to_add: list[str] | None = None,
3687 player_ids_to_remove: list[str] | None = None,
3688 ) -> None:
3689 calls.append(("leader_set_members", player_ids_to_remove))
3690 await leader_set_members(player_ids_to_add, player_ids_to_remove)
3691
3692 async def record_bridge_set_members(
3693 player_ids_to_add: list[str] | None = None,
3694 player_ids_to_remove: list[str] | None = None,
3695 ) -> None:
3696 calls.append(("bridge_set_members", player_ids_to_add))
3697 await bridge_set_members(player_ids_to_add, player_ids_to_remove)
3698
3699 async def record_leader_stop() -> None:
3700 calls.append(("leader_stop", None))
3701
3702 leader.set_members = record_leader_set_members # type: ignore[method-assign]
3703 leader.stop = record_leader_stop # type: ignore[method-assign]
3704 bridge_leader.set_members = record_bridge_set_members # type: ignore[method-assign]
3705 controller.cmd_resume = AsyncMock() # type: ignore[method-assign]
3706 controller._handle_set_members = AsyncMock() # type: ignore[method-assign]
3707
3708 await controller._forward_protocol_set_members(
3709 parent_player=leader,
3710 parent_protocol_player=bridge_leader,
3711 protocol_members_to_add=["bridge_only"],
3712 protocol_members_to_remove=[],
3713 )
3714
3715 assert calls == [
3716 # the native member joins the protocol group together with the new member
3717 ("bridge_set_members", ["bridge_only", "bridge_member"]),
3718 ("leader_set_members", ["speaker_member"]),
3719 ("leader_stop", None),
3720 ]
3721 assert leader.active_output_protocol == "bridge_leader"
3722 # Only a player that was playing resumes: adding a member to a paused group
3723 # hands the output over without starting playback.
3724 if playback_state == PlaybackState.PLAYING:
3725 controller.cmd_resume.assert_awaited_once_with("speaker_leader")
3726 else:
3727 controller.cmd_resume.assert_not_awaited()
3728 controller._handle_set_members.assert_not_awaited()
3729
3730 async def test_idle_native_members_migrate_without_stopping_the_parent(
3731 self, mock_mass: MagicMock
3732 ) -> None:
3733 """
3734 An idle parent hands its native members over too, with no session to stop.
3735
3736 A native group outlives the session it was formed for, so the members are still
3737 attached to the parent when it switches protocol. Leaving them behind would keep
3738 them listed as grouped while nothing feeds them on the next play.
3739 """
3740 controller, players = self._build_session_bound_group(mock_mass, PlaybackState.IDLE)
3741 leader = players["speaker_leader"]
3742 bridge_leader = players["bridge_leader"]
3743
3744 calls: list[tuple[str, Any]] = []
3745 leader_set_members = leader.set_members
3746 bridge_set_members = bridge_leader.set_members
3747
3748 async def record_leader_set_members(
3749 player_ids_to_add: list[str] | None = None,
3750 player_ids_to_remove: list[str] | None = None,
3751 ) -> None:
3752 calls.append(("leader_set_members", player_ids_to_remove))
3753 await leader_set_members(player_ids_to_add, player_ids_to_remove)
3754
3755 async def record_bridge_set_members(
3756 player_ids_to_add: list[str] | None = None,
3757 player_ids_to_remove: list[str] | None = None,
3758 ) -> None:
3759 calls.append(("bridge_set_members", player_ids_to_add))
3760 await bridge_set_members(player_ids_to_add, player_ids_to_remove)
3761
3762 async def record_leader_stop() -> None:
3763 calls.append(("leader_stop", None))
3764
3765 leader.set_members = record_leader_set_members # type: ignore[method-assign]
3766 leader.stop = record_leader_stop # type: ignore[method-assign]
3767 bridge_leader.set_members = record_bridge_set_members # type: ignore[method-assign]
3768 controller.cmd_resume = AsyncMock() # type: ignore[method-assign]
3769
3770 await controller._forward_protocol_set_members(
3771 parent_player=leader,
3772 parent_protocol_player=bridge_leader,
3773 protocol_members_to_add=["bridge_only"],
3774 protocol_members_to_remove=[],
3775 )
3776
3777 # the native member joins the protocol group and is released from the parent,
3778 # but an idle parent has no session to stop and nothing to resume
3779 assert calls == [
3780 ("bridge_set_members", ["bridge_only", "bridge_member"]),
3781 ("leader_set_members", ["speaker_member"]),
3782 ]
3783 assert leader.active_output_protocol == "bridge_leader"
3784 controller.cmd_resume.assert_not_awaited()
3785
3786 async def test_joining_member_protocol_is_active_before_its_stream_starts(
3787 self, mock_mass: MagicMock
3788 ) -> None:
3789 """
3790 A joining member's parent knows which protocol carries its audio before its stream starts.
3791
3792 The member's stream is started from inside set_members, and providers resolve the
3793 member's volume control while starting it. A device that also exposes a higher
3794 priority sibling interface (e.g. a soundbar that is both Chromecast and AirPlay)
3795 would otherwise resolve volume to that sibling and apply its volume semantics to
3796 a stream it does not attenuate.
3797 """
3798 controller = PlayerController(mock_mass)
3799
3800 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
3801 cast_provider = MockProvider("chromecast", instance_id="chromecast", mass=mock_mass)
3802
3803 leader = MockPlayer(airplay_provider, "leader", "Kitchen")
3804 leader_airplay = MockPlayer(
3805 airplay_provider,
3806 "leader_airplay",
3807 "Kitchen (AirPlay)",
3808 player_type=PlayerType.PROTOCOL,
3809 )
3810 leader_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3811 leader_airplay.set_protocol_parent_id("leader")
3812
3813 # the joining device exposes both a Chromecast and an AirPlay interface, and has no
3814 # native volume of its own, so its volume control is one of the two protocol players
3815 joiner = MockPlayer(cast_provider, "joiner", "Living Room")
3816 joiner._attr_supported_features = set()
3817 joiner_cast = MockPlayer(
3818 cast_provider,
3819 "joiner_cast",
3820 "Living Room (Cast)",
3821 player_type=PlayerType.PROTOCOL,
3822 )
3823 joiner_cast.set_protocol_parent_id("joiner")
3824 joiner_airplay = MockPlayer(
3825 airplay_provider,
3826 "joiner_airplay",
3827 "Living Room (AirPlay)",
3828 player_type=PlayerType.PROTOCOL,
3829 )
3830 joiner_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3831 joiner_airplay.set_protocol_parent_id("joiner")
3832
3833 leader.set_linked_output_protocols(
3834 [
3835 LinkedOutputProtocol(
3836 output_protocol_id="leader_airplay",
3837 protocol_domain="airplay",
3838 priority=10,
3839 )
3840 ]
3841 )
3842 joiner.set_linked_output_protocols(
3843 [
3844 LinkedOutputProtocol(
3845 output_protocol_id="joiner_cast",
3846 protocol_domain="chromecast",
3847 priority=10,
3848 ),
3849 LinkedOutputProtocol(
3850 output_protocol_id="joiner_airplay",
3851 protocol_domain="airplay",
3852 priority=20,
3853 ),
3854 ]
3855 )
3856
3857 mock_mass.players = controller
3858 controller._players = {
3859 player.player_id: player
3860 for player in (leader, leader_airplay, joiner, joiner_cast, joiner_airplay)
3861 }
3862 for player in controller._players.values():
3863 player.update_state(signal_event=False)
3864
3865 # While the device sits idle its Chromecast interface owns the volume: the winner is
3866 # decided by the hardcoded per-domain control priority, not by the link priorities.
3867 assert joiner.volume_control == "joiner_cast"
3868
3869 observed_volume_controls: list[str] = []
3870
3871 async def record_set_members(
3872 player_ids_to_add: list[str] | None = None, # noqa: ARG001
3873 player_ids_to_remove: list[str] | None = None, # noqa: ARG001
3874 ) -> None:
3875 observed_volume_controls.append(joiner.volume_control)
3876
3877 leader_airplay.set_members = record_set_members # type: ignore[method-assign]
3878 controller._handle_set_members = AsyncMock() # type: ignore[method-assign]
3879
3880 await controller._forward_protocol_set_members(
3881 parent_player=leader,
3882 parent_protocol_player=leader_airplay,
3883 protocol_members_to_add=["joiner_airplay"],
3884 protocol_members_to_remove=[],
3885 )
3886
3887 assert observed_volume_controls == ["joiner_airplay"]
3888
3889
3890class TestNativeProtocolPlayerGrouping:
3891 """Tests for grouping with native protocol players (e.g., native AirPlay like Apple TV)."""
3892
3893 def test_native_airplay_groups_with_protocol_linked_player(self, mock_mass: MagicMock) -> None:
3894 """
3895 Test grouping a native AirPlay player (Apple TV) with a protocol-linked player (Sonos).
3896
3897 This tests the scenario where:
3898 - Apple TV is a native AirPlay PLAYER (not PROTOCOL type)
3899 - Sonos has AirPlay as a linked protocol
3900 - Apple TV groups with Sonos via the common AirPlay protocol
3901 """
3902 controller = PlayerController(mock_mass)
3903
3904 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
3905 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
3906
3907 # Apple TV: native AirPlay PLAYER (supports grouping via AirPlay)
3908 apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV Slaapkamer")
3909 apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3910 apple_tv._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3911 apple_tv._attr_can_group_with = {"airplay"} # Provider instance ID
3912 apple_tv._cache.clear()
3913
3914 # Sonos native player (visible)
3915 sonos_player = MockPlayer(sonos_provider, "sonos_badkamer", "Badkamer")
3916 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3917 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3918 sonos_player._cache.clear()
3919
3920 # AirPlay protocol player for Sonos (hidden, linked to sonos)
3921 sonos_airplay = MockPlayer(
3922 airplay_provider,
3923 "airplay_sonos",
3924 "Badkamer (AirPlay)",
3925 player_type=PlayerType.PROTOCOL,
3926 )
3927 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3928 sonos_airplay._attr_can_group_with = {"airplay"}
3929 sonos_airplay._cache.clear()
3930 sonos_airplay.set_protocol_parent_id("sonos_badkamer")
3931
3932 sonos_player.set_linked_output_protocols(
3933 [
3934 LinkedOutputProtocol(
3935 output_protocol_id="airplay_sonos",
3936 protocol_domain="airplay",
3937 priority=10,
3938 )
3939 ]
3940 )
3941
3942 mock_mass.players = controller
3943 controller._players = {
3944 "apple_tv_1": apple_tv,
3945 "sonos_badkamer": sonos_player,
3946 "airplay_sonos": sonos_airplay,
3947 }
3948
3949 # Update states
3950 sonos_airplay.update_state(signal_event=False)
3951 sonos_player.update_state(signal_event=False)
3952 apple_tv.update_state(signal_event=False)
3953
3954 # Translate members for grouping Sonos to Apple TV
3955 protocol_members, _native_members, protocol_player, protocol_domain = (
3956 controller._translate_members_for_protocols(
3957 parent_player=apple_tv,
3958 player_ids=["sonos_badkamer"],
3959 parent_protocol_player=None,
3960 parent_protocol_domain=None,
3961 )
3962 )
3963
3964 # Should find common AirPlay protocol
3965 assert len(protocol_members) == 1
3966 assert "airplay_sonos" in protocol_members
3967 assert protocol_domain == "airplay"
3968 # For native AirPlay player, protocol_player should be the Apple TV itself
3969 assert protocol_player == apple_tv
3970
3971 def test_get_output_protocol_by_domain_finds_native(self, mock_mass: MagicMock) -> None:
3972 """Test that get_output_protocol_by_domain finds native protocol."""
3973 controller = PlayerController(mock_mass)
3974
3975 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
3976
3977 # Native AirPlay player
3978 apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV")
3979 apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3980 apple_tv._cache.clear()
3981
3982 mock_mass.players = controller
3983 controller._players = {"apple_tv_1": apple_tv}
3984
3985 apple_tv.update_state(signal_event=False)
3986
3987 # Should find native AirPlay protocol
3988 protocol = apple_tv.get_output_protocol_by_domain("airplay")
3989 assert protocol is not None
3990 assert protocol.output_protocol_id == "native"
3991 assert protocol.protocol_domain == "airplay"
3992 assert protocol.is_native is True
3993
3994
3995class TestFinalGroupMembersTranslation:
3996 """Tests for __final_group_members translation of protocol player IDs."""
3997
3998 def test_final_group_members_translates_protocol_ids(self, mock_mass: MagicMock) -> None:
3999 """
4000 Test that __final_group_members translates protocol player IDs to visible IDs.
4001
4002 When a native AirPlay player (Apple TV) has protocol players in its group_members,
4003 the final state should show the visible parent player IDs instead.
4004 """
4005 controller = PlayerController(mock_mass)
4006
4007 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
4008 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4009
4010 # Apple TV with group members containing a protocol player ID
4011 apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV")
4012 apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4013 apple_tv._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4014 apple_tv._attr_group_members = ["apple_tv_1", "airplay_sonos"]
4015 apple_tv._cache.clear()
4016
4017 # Sonos visible player
4018 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Sonos")
4019 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4020 sonos_player._cache.clear()
4021
4022 # AirPlay protocol player for Sonos
4023 sonos_airplay = MockPlayer(
4024 airplay_provider,
4025 "airplay_sonos",
4026 "Sonos (AirPlay)",
4027 player_type=PlayerType.PROTOCOL,
4028 )
4029 sonos_airplay._cache.clear()
4030 sonos_airplay.set_protocol_parent_id("sonos_1")
4031
4032 mock_mass.players = controller
4033 controller._players = {
4034 "apple_tv_1": apple_tv,
4035 "sonos_1": sonos_player,
4036 "airplay_sonos": sonos_airplay,
4037 }
4038
4039 sonos_airplay.update_state(signal_event=False)
4040 sonos_player.update_state(signal_event=False)
4041 apple_tv.update_state(signal_event=False)
4042
4043 # Final group_members should show visible player IDs
4044 final_members = apple_tv.state.group_members
4045 assert "apple_tv_1" in final_members
4046 assert "sonos_1" in final_members
4047 # Protocol player ID should NOT appear in final state
4048 assert "airplay_sonos" not in final_members
4049
4050
4051class TestFinalActiveGroupTranslation:
4052 """Tests for __final_active_group with translated protocol player IDs."""
4053
4054 def test_active_group_uses_translated_group_members(self, mock_mass: MagicMock) -> None:
4055 """Test that visible players resolve active_group from translated group members."""
4056 controller = PlayerController(mock_mass)
4057
4058 group_provider = MockProvider(
4059 "universal_group", instance_id="universal_group", mass=mock_mass
4060 )
4061 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
4062 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4063
4064 group_player = MockPlayer(
4065 group_provider,
4066 "group_1",
4067 "House Group",
4068 player_type=PlayerType.GROUP,
4069 )
4070 group_player._attr_playback_state = PlaybackState.PLAYING
4071 group_player._attr_group_members = ["airplay_sonos"]
4072 group_player._cache.clear()
4073
4074 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Sonos")
4075 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4076 sonos_player._cache.clear()
4077
4078 sonos_airplay = MockPlayer(
4079 airplay_provider,
4080 "airplay_sonos",
4081 "Sonos (AirPlay)",
4082 player_type=PlayerType.PROTOCOL,
4083 )
4084 sonos_airplay.set_protocol_parent_id("sonos_1")
4085 sonos_airplay._cache.clear()
4086
4087 mock_mass.players = controller
4088 controller._players = {
4089 "group_1": group_player,
4090 "sonos_1": sonos_player,
4091 "airplay_sonos": sonos_airplay,
4092 }
4093
4094 group_player.set_initialized()
4095 sonos_player.set_initialized()
4096 sonos_airplay.set_initialized()
4097
4098 group_player.update_state(signal_event=False)
4099 sonos_airplay.update_state(signal_event=False)
4100 sonos_player.update_state(signal_event=False)
4101
4102 assert sonos_player.state.active_group == "group_1"
4103 assert sonos_airplay.state.active_group is None
4104
4105
4106class TestFinalSyncedToWithNativeProtocolParent:
4107 """Tests for __final_synced_to when sync parent is a native protocol player."""
4108
4109 def test_synced_to_native_airplay_player(self, mock_mass: MagicMock) -> None:
4110 """
4111 Test that synced_to correctly shows native AirPlay player as parent.
4112
4113 When a Sonos player's AirPlay protocol player is synced to a native AirPlay
4114 player (Apple TV), the Sonos's final synced_to should show the Apple TV.
4115 """
4116 controller = PlayerController(mock_mass)
4117
4118 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
4119 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4120
4121 # Apple TV: native AirPlay PLAYER (the group leader)
4122 apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV")
4123 apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4124 apple_tv._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4125 apple_tv._attr_group_members = ["apple_tv_1", "airplay_sonos"]
4126 apple_tv._cache.clear()
4127
4128 # Sonos visible player
4129 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Sonos")
4130 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4131 sonos_player._cache.clear()
4132
4133 # AirPlay protocol player for Sonos - synced to Apple TV
4134 sonos_airplay = MockPlayer(
4135 airplay_provider,
4136 "airplay_sonos",
4137 "Sonos (AirPlay)",
4138 player_type=PlayerType.PROTOCOL,
4139 )
4140 # Set group_members with Apple TV first to indicate synced_to Apple TV
4141 sonos_airplay._attr_group_members = ["apple_tv_1", "airplay_sonos"]
4142 sonos_airplay._cache.clear()
4143 sonos_airplay.set_protocol_parent_id("sonos_1")
4144
4145 sonos_player.set_linked_output_protocols(
4146 [
4147 LinkedOutputProtocol(
4148 output_protocol_id="airplay_sonos",
4149 protocol_domain="airplay",
4150 priority=10,
4151 )
4152 ]
4153 )
4154
4155 mock_mass.players = controller
4156 controller._players = {
4157 "apple_tv_1": apple_tv,
4158 "sonos_1": sonos_player,
4159 "airplay_sonos": sonos_airplay,
4160 }
4161
4162 apple_tv.set_initialized()
4163 sonos_player.set_initialized()
4164 sonos_airplay.set_initialized()
4165
4166 apple_tv.update_state(signal_event=False)
4167 sonos_airplay.update_state(signal_event=False)
4168 sonos_player.update_state(signal_event=False)
4169
4170 # Sonos's final synced_to should be Apple TV (visible player)
4171 assert sonos_player.state.synced_to == "apple_tv_1"
4172
4173
4174class TestUngroupTranslation:
4175 """Tests for translation when ungrouping from native protocol players."""
4176
4177 def test_ungroup_translates_visible_to_protocol_id(self, mock_mass: MagicMock) -> None:
4178 """
4179 Test that ungrouping correctly translates visible ID to protocol ID.
4180
4181 When ungrouping Sonos from Apple TV, the visible Sonos ID should be
4182 translated to its AirPlay protocol player ID for the removal.
4183 """
4184 controller = PlayerController(mock_mass)
4185
4186 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
4187 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4188
4189 # Apple TV with Sonos's AirPlay protocol player in group_members
4190 apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV")
4191 apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4192 apple_tv._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4193 apple_tv._attr_group_members = ["apple_tv_1", "airplay_sonos"]
4194 apple_tv._cache.clear()
4195
4196 # Sonos visible player
4197 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Sonos")
4198 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4199 sonos_player._cache.clear()
4200
4201 # AirPlay protocol player for Sonos
4202 sonos_airplay = MockPlayer(
4203 airplay_provider,
4204 "airplay_sonos",
4205 "Sonos (AirPlay)",
4206 player_type=PlayerType.PROTOCOL,
4207 )
4208 sonos_airplay._cache.clear()
4209 sonos_airplay.set_protocol_parent_id("sonos_1")
4210
4211 sonos_player.set_linked_output_protocols(
4212 [
4213 LinkedOutputProtocol(
4214 output_protocol_id="airplay_sonos",
4215 protocol_domain="airplay",
4216 priority=10,
4217 )
4218 ]
4219 )
4220
4221 mock_mass.players = controller
4222 controller._players = {
4223 "apple_tv_1": apple_tv,
4224 "sonos_1": sonos_player,
4225 "airplay_sonos": sonos_airplay,
4226 }
4227
4228 sonos_airplay.update_state(signal_event=False)
4229 sonos_player.update_state(signal_event=False)
4230 apple_tv.update_state(signal_event=False)
4231
4232 # Translate members for removal - visible ID should become protocol ID
4233 _protocol_members, native_members = controller._translate_members_to_remove_for_protocols(
4234 parent_player=apple_tv,
4235 player_ids=["sonos_1"], # Visible player ID
4236 parent_protocol_player=None,
4237 parent_protocol_domain=None,
4238 )
4239
4240 # Should translate to the protocol player ID for native removal
4241 assert "airplay_sonos" in native_members
4242 assert "sonos_1" not in native_members
4243
4244
4245class TestNativeProtocolDomainPlayerGrouping:
4246 """
4247 Tests for grouping with native protocol-domain players.
4248
4249 This tests the scenario where a player's native provider domain IS the protocol
4250 domain (e.g., a sendspin web player with PlayerType.PLAYER and provider.domain="sendspin"),
4251 rather than having the protocol as a linked protocol player.
4252 """
4253
4254 def test_native_protocol_player_groups_via_active_protocol(self, mock_mass: MagicMock) -> None:
4255 """
4256 Test grouping a native protocol player via the parent's active protocol.
4257
4258 Scenario: Kantoor (has sendspin linked protocol, active) groups with
4259 Web player (native sendspin PlayerType.PLAYER).
4260 This should use Priority 2 (parent's active protocol).
4261 """
4262 controller = PlayerController(mock_mass)
4263
4264 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4265 sendspin_provider = MockProvider("sendspin", instance_id="sendspin", mass=mock_mass)
4266
4267 # Kantoor: native Sonos player with sendspin as linked protocol
4268 kantoor = MockPlayer(sonos_provider, "sonos_kantoor", "Kantoor")
4269 kantoor._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4270 kantoor._cache.clear()
4271
4272 # Sendspin protocol player linked to Kantoor
4273 sendspin_kantoor = MockPlayer(
4274 sendspin_provider,
4275 "sendspin_kantoor",
4276 "Kantoor (Sendspin)",
4277 player_type=PlayerType.PROTOCOL,
4278 )
4279 sendspin_kantoor._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4280 sendspin_kantoor._attr_can_group_with = {"sendspin"}
4281 sendspin_kantoor._cache.clear()
4282 sendspin_kantoor.set_protocol_parent_id("sonos_kantoor")
4283
4284 # Web player: native sendspin player (PlayerType.PLAYER, standalone)
4285 web_player = MockPlayer(sendspin_provider, "sendspin_web", "Web (Chrome on Mac)")
4286 web_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4287 web_player._cache.clear()
4288
4289 # Link sendspin protocol to Kantoor
4290 kantoor.set_linked_output_protocols(
4291 [
4292 LinkedOutputProtocol(
4293 output_protocol_id="sendspin_kantoor",
4294 protocol_domain="sendspin",
4295 priority=40,
4296 )
4297 ]
4298 )
4299
4300 mock_mass.players = controller
4301 controller._players = {
4302 "sonos_kantoor": kantoor,
4303 "sendspin_kantoor": sendspin_kantoor,
4304 "sendspin_web": web_player,
4305 }
4306
4307 # Update states
4308 sendspin_kantoor.update_state(signal_event=False)
4309 kantoor.update_state(signal_event=False)
4310 web_player.update_state(signal_event=False)
4311
4312 # Group Web player with Kantoor, with sendspin as active protocol
4313 protocol_members, native_members, protocol_player, _ = (
4314 controller._translate_members_for_protocols(
4315 parent_player=kantoor,
4316 player_ids=["sendspin_web"],
4317 parent_protocol_player=sendspin_kantoor,
4318 parent_protocol_domain="sendspin",
4319 )
4320 )
4321
4322 # Web player's own player_id should be in protocol_members
4323 assert len(protocol_members) == 1
4324 assert "sendspin_web" in protocol_members
4325 assert len(native_members) == 0
4326 assert protocol_player == sendspin_kantoor
4327
4328 def test_native_protocol_player_groups_via_common_protocol(self, mock_mass: MagicMock) -> None:
4329 """
4330 Test grouping a native protocol player via common protocol search (Priority 4).
4331
4332 Same scenario but without a pre-set active protocol — the common protocol
4333 search should find sendspin as the shared protocol.
4334 """
4335 controller = PlayerController(mock_mass)
4336
4337 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4338 sendspin_provider = MockProvider("sendspin", instance_id="sendspin", mass=mock_mass)
4339
4340 # Kantoor: native Sonos player with sendspin as linked protocol
4341 kantoor = MockPlayer(sonos_provider, "sonos_kantoor", "Kantoor")
4342 kantoor._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4343 kantoor._cache.clear()
4344
4345 # Sendspin protocol player linked to Kantoor
4346 sendspin_kantoor = MockPlayer(
4347 sendspin_provider,
4348 "sendspin_kantoor",
4349 "Kantoor (Sendspin)",
4350 player_type=PlayerType.PROTOCOL,
4351 )
4352 sendspin_kantoor._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4353 sendspin_kantoor._attr_can_group_with = {"sendspin"}
4354 sendspin_kantoor._cache.clear()
4355 sendspin_kantoor.set_protocol_parent_id("sonos_kantoor")
4356
4357 # Web player: native sendspin player (PlayerType.PLAYER, standalone)
4358 web_player = MockPlayer(sendspin_provider, "sendspin_web", "Web (Chrome on Mac)")
4359 web_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4360 web_player._cache.clear()
4361
4362 # Link sendspin protocol to Kantoor
4363 kantoor.set_linked_output_protocols(
4364 [
4365 LinkedOutputProtocol(
4366 output_protocol_id="sendspin_kantoor",
4367 protocol_domain="sendspin",
4368 priority=40,
4369 )
4370 ]
4371 )
4372
4373 mock_mass.players = controller
4374 controller._players = {
4375 "sonos_kantoor": kantoor,
4376 "sendspin_kantoor": sendspin_kantoor,
4377 "sendspin_web": web_player,
4378 }
4379
4380 # Update states
4381 sendspin_kantoor.update_state(signal_event=False)
4382 kantoor.update_state(signal_event=False)
4383 web_player.update_state(signal_event=False)
4384
4385 # Group Web player with Kantoor, without pre-set active protocol
4386 protocol_members, native_members, protocol_player, protocol_domain = (
4387 controller._translate_members_for_protocols(
4388 parent_player=kantoor,
4389 player_ids=["sendspin_web"],
4390 parent_protocol_player=None,
4391 parent_protocol_domain=None,
4392 )
4393 )
4394
4395 # Should find common sendspin protocol via Priority 4
4396 assert len(protocol_members) == 1
4397 assert "sendspin_web" in protocol_members
4398 assert len(native_members) == 0
4399 assert protocol_domain == "sendspin"
4400 assert protocol_player == sendspin_kantoor
4401
4402 def test_ungroup_native_protocol_player(self, mock_mass: MagicMock) -> None:
4403 """
4404 Test ungrouping a native protocol player from a protocol-linked parent.
4405
4406 When ungrouping a native sendspin web player from Kantoor's sendspin group,
4407 the web player's own player_id should be used for removal.
4408 """
4409 controller = PlayerController(mock_mass)
4410
4411 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4412 sendspin_provider = MockProvider("sendspin", instance_id="sendspin", mass=mock_mass)
4413
4414 # Kantoor with sendspin linked protocol
4415 kantoor = MockPlayer(sonos_provider, "sonos_kantoor", "Kantoor")
4416 kantoor._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4417 kantoor._cache.clear()
4418
4419 # Sendspin protocol player linked to Kantoor, with web player in group
4420 sendspin_kantoor = MockPlayer(
4421 sendspin_provider,
4422 "sendspin_kantoor",
4423 "Kantoor (Sendspin)",
4424 player_type=PlayerType.PROTOCOL,
4425 )
4426 sendspin_kantoor._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4427 sendspin_kantoor._attr_group_members = ["sendspin_kantoor", "sendspin_web"]
4428 sendspin_kantoor._cache.clear()
4429 sendspin_kantoor.set_protocol_parent_id("sonos_kantoor")
4430
4431 # Web player: native sendspin player
4432 web_player = MockPlayer(sendspin_provider, "sendspin_web", "Web (Chrome on Mac)")
4433 web_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4434 web_player._cache.clear()
4435
4436 kantoor.set_linked_output_protocols(
4437 [
4438 LinkedOutputProtocol(
4439 output_protocol_id="sendspin_kantoor",
4440 protocol_domain="sendspin",
4441 priority=40,
4442 )
4443 ]
4444 )
4445
4446 mock_mass.players = controller
4447 controller._players = {
4448 "sonos_kantoor": kantoor,
4449 "sendspin_kantoor": sendspin_kantoor,
4450 "sendspin_web": web_player,
4451 }
4452
4453 sendspin_kantoor.update_state(signal_event=False)
4454 kantoor.update_state(signal_event=False)
4455 web_player.update_state(signal_event=False)
4456
4457 # Translate removal — web player's own player_id should be used
4458 protocol_members, native_members = controller._translate_members_to_remove_for_protocols(
4459 parent_player=kantoor,
4460 player_ids=["sendspin_web"],
4461 parent_protocol_player=sendspin_kantoor,
4462 parent_protocol_domain="sendspin",
4463 )
4464
4465 # Web player's player_id should be in protocol removal list
4466 assert "sendspin_web" in protocol_members
4467 assert len(native_members) == 0
4468
4469 def test_filter_protocol_members_accepts_native_protocol_player(
4470 self, mock_mass: MagicMock
4471 ) -> None:
4472 """
4473 Test that _filter_protocol_members accepts native protocol-domain players.
4474
4475 A PlayerType.PLAYER with matching provider domain should pass through the
4476 filter, not just PlayerType.PROTOCOL players.
4477 """
4478 controller = PlayerController(mock_mass)
4479
4480 sendspin_provider = MockProvider("sendspin", instance_id="sendspin", mass=mock_mass)
4481
4482 # Protocol player (the parent's linked sendspin)
4483 sendspin_parent = MockPlayer(
4484 sendspin_provider,
4485 "sendspin_parent",
4486 "Parent (Sendspin)",
4487 player_type=PlayerType.PROTOCOL,
4488 )
4489 sendspin_parent._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4490 sendspin_parent._cache.clear()
4491
4492 # Native sendspin web player (PlayerType.PLAYER)
4493 web_player = MockPlayer(sendspin_provider, "sendspin_web", "Web (Chrome on Mac)")
4494 web_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4495 web_player._cache.clear()
4496
4497 # Another protocol type sendspin player
4498 sendspin_other = MockPlayer(
4499 sendspin_provider,
4500 "sendspin_other",
4501 "Other (Sendspin)",
4502 player_type=PlayerType.PROTOCOL,
4503 )
4504 sendspin_other._cache.clear()
4505
4506 mock_mass.players = controller
4507 controller._players = {
4508 "sendspin_parent": sendspin_parent,
4509 "sendspin_web": web_player,
4510 "sendspin_other": sendspin_other,
4511 }
4512
4513 sendspin_parent.update_state(signal_event=False)
4514 web_player.update_state(signal_event=False)
4515 sendspin_other.update_state(signal_event=False)
4516
4517 # Both native and protocol players should pass through the filter
4518 filtered = controller._filter_protocol_members(
4519 ["sendspin_web", "sendspin_other"],
4520 sendspin_parent,
4521 )
4522
4523 assert "sendspin_web" in filtered
4524 assert "sendspin_other" in filtered
4525 assert len(filtered) == 2
4526
4527 def test_sendspin_visualizer_groups_via_common_protocol(self, mock_mass: MagicMock) -> None:
4528 """
4529 Test grouping a sendspin visualizer (e.g. Hue light) with a sendspin-bridged parent.
4530
4531 Visualizer players have no PLAY_MEDIA so they are not native players, but the
4532 Sendspin provider advertises a self-referential sendspin output protocol on them
4533 (is_native=True) so the common-protocol search (Priority 4) can match them against
4534 any parent that has a sendspin bridge linked.
4535 """
4536 controller = PlayerController(mock_mass)
4537
4538 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4539 sendspin_provider = MockProvider("sendspin", instance_id="sendspin", mass=mock_mass)
4540
4541 # Mancave: native Sonos parent with sendspin bridge linked
4542 mancave = MockPlayer(sonos_provider, "sonos_mancave", "Mancave")
4543 mancave._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4544 mancave._cache.clear()
4545
4546 # Sendspin bridge protocol player linked to Mancave
4547 sendspin_bridge = MockPlayer(
4548 sendspin_provider,
4549 "spb_mancave",
4550 "Mancave (AirPlay)",
4551 player_type=PlayerType.PROTOCOL,
4552 )
4553 sendspin_bridge._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4554 sendspin_bridge._attr_can_group_with = {"sendspin"}
4555 sendspin_bridge._cache.clear()
4556 sendspin_bridge.set_protocol_parent_id("sonos_mancave")
4557
4558 # Hue visualizer: sendspin provider, no PLAY_MEDIA, has SET_MEMBERS, type LIGHT
4559 hue_light = MockPlayer(
4560 sendspin_provider,
4561 "hue-mancave",
4562 "Hue: Mancave",
4563 player_type=PlayerType.LIGHT,
4564 )
4565 hue_light._attr_supported_features = {PlayerFeature.SET_MEMBERS}
4566 hue_light._cache.clear()
4567
4568 mancave.set_linked_output_protocols(
4569 [
4570 LinkedOutputProtocol(
4571 output_protocol_id="spb_mancave",
4572 protocol_domain="sendspin",
4573 priority=40,
4574 )
4575 ]
4576 )
4577
4578 mock_mass.players = controller
4579 controller._players = {
4580 "sonos_mancave": mancave,
4581 "spb_mancave": sendspin_bridge,
4582 "hue-mancave": hue_light,
4583 }
4584
4585 sendspin_bridge.update_state(signal_event=False)
4586 mancave.update_state(signal_event=False)
4587 hue_light.update_state(signal_event=False)
4588
4589 protocol_members, native_members, protocol_player, protocol_domain = (
4590 controller._translate_members_for_protocols(
4591 parent_player=mancave,
4592 player_ids=["hue-mancave"],
4593 parent_protocol_player=None,
4594 parent_protocol_domain=None,
4595 )
4596 )
4597
4598 # Priority 4 should match sendspin↔sendspin and route via the bridge,
4599 # using the visualizer's player_id directly (is_native=True).
4600 assert protocol_members == ["hue-mancave"]
4601 assert native_members == []
4602 assert protocol_domain == "sendspin"
4603 assert protocol_player == sendspin_bridge
4604
4605
4606class TestEnrichPlayerIdentifiers:
4607 """Tests for MAC address enrichment via ARP lookup."""
4608
4609 @pytest.mark.asyncio
4610 async def test_no_ip_skips_enrichment(self, mock_mass: MagicMock) -> None:
4611 """Test that enrichment is skipped when no IP address is available."""
4612 provider = MockProvider("sendspin", mass=mock_mass)
4613 # Sendspin bridge player has MAC but no IP
4614 player = MockPlayer(
4615 provider,
4616 "spb_62e5974593d3",
4617 "Apple TV (Sendspin)",
4618 player_type=PlayerType.PROTOCOL,
4619 identifiers={IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3"},
4620 )
4621
4622 await enrich_device_mac_address(player.device_info)
4623
4624 # MAC should remain unchanged (no IP = no ARP lookup)
4625 assert player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == "62:E5:97:45:93:D3"
4626
4627 @pytest.mark.asyncio
4628 async def test_valid_hardware_mac_skips_enrichment(self, mock_mass: MagicMock) -> None:
4629 """Test that a valid, non-locally-administered MAC skips enrichment."""
4630 provider = MockProvider("sonos", mass=mock_mass)
4631 player = MockPlayer(
4632 provider,
4633 "sonos_123",
4634 "Sonos Speaker",
4635 player_type=PlayerType.PLAYER,
4636 identifiers={
4637 IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0",
4638 IdentifierType.IP_ADDRESS: "192.168.1.100",
4639 },
4640 )
4641
4642 await enrich_device_mac_address(player.device_info)
4643
4644 # MAC should remain unchanged (valid hardware MAC, not locally administered)
4645 assert player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == "54:78:C9:E6:0D:A0"
4646
4647 @pytest.mark.asyncio
4648 async def test_locally_administered_bit_difference_replaces_mac(
4649 self, mock_mass: MagicMock
4650 ) -> None:
4651 """
4652 Test that ARP MAC replaces reported MAC when only the locally-administered bit differs.
4653
4654 Example: AirPlay reports 56:78:C9:E6:0D:A0, ARP resolves 54:78:C9:E6:0D:A0.
4655 These differ only in bit 1 of the first octet (locally-administered bit).
4656 """
4657 provider = MockProvider("airplay", mass=mock_mass)
4658 player = MockPlayer(
4659 provider,
4660 "ap_5678c9e60da0",
4661 "WiiM Pro (AirPlay)",
4662 player_type=PlayerType.PROTOCOL,
4663 identifiers={
4664 IdentifierType.MAC_ADDRESS: "56:78:C9:E6:0D:A0",
4665 IdentifierType.IP_ADDRESS: "192.168.1.50",
4666 },
4667 )
4668
4669 with patch(
4670 "music_assistant.helpers.util.resolve_real_mac_address",
4671 new_callable=AsyncMock,
4672 return_value="54:78:C9:E6:0D:A0",
4673 ):
4674 await enrich_device_mac_address(player.device_info)
4675
4676 # MAC should be replaced with the ARP-resolved hardware MAC
4677 assert player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == "54:78:C9:E6:0D:A0"
4678
4679 @pytest.mark.asyncio
4680 async def test_completely_different_mac_replaced_by_arp(self, mock_mass: MagicMock) -> None:
4681 """
4682 Test that ARP result always replaces a locally-administered MAC.
4683
4684 Even when the ARP MAC is completely different from the reported MAC
4685 (e.g., Apple devices with random private MACs), the ARP result is used
4686 because it's the hardware truth that reliably unifies protocols.
4687 Sendspin bridges match via AIRPLAY_ID/CAST_UUID, not MAC.
4688 """
4689 provider = MockProvider("airplay", mass=mock_mass)
4690 player = MockPlayer(
4691 provider,
4692 "ap62e5974593d3",
4693 "Apple TV (AirPlay)",
4694 player_type=PlayerType.PLAYER,
4695 identifiers={
4696 IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3",
4697 IdentifierType.IP_ADDRESS: "192.168.1.200",
4698 },
4699 )
4700
4701 with patch(
4702 "music_assistant.helpers.util.resolve_real_mac_address",
4703 new_callable=AsyncMock,
4704 return_value="C0:95:6D:51:34:E0",
4705 ):
4706 await enrich_device_mac_address(player.device_info)
4707
4708 # MAC should be replaced with ARP result (hardware truth)
4709 assert player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == "C0:95:6D:51:34:E0"
4710
4711 @pytest.mark.asyncio
4712 async def test_no_reported_mac_uses_arp_result(self, mock_mass: MagicMock) -> None:
4713 """Test that ARP MAC is used when no MAC was reported at all."""
4714 provider = MockProvider("chromecast", mass=mock_mass)
4715 player = MockPlayer(
4716 provider,
4717 "cc_123",
4718 "Chromecast",
4719 player_type=PlayerType.PROTOCOL,
4720 identifiers={IdentifierType.IP_ADDRESS: "192.168.1.75"},
4721 )
4722
4723 with patch(
4724 "music_assistant.helpers.util.resolve_real_mac_address",
4725 new_callable=AsyncMock,
4726 return_value="AA:BB:CC:DD:EE:FF",
4727 ):
4728 await enrich_device_mac_address(player.device_info)
4729
4730 # MAC should be set from ARP since there was none before
4731 assert player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == "AA:BB:CC:DD:EE:FF"
4732
4733 @pytest.mark.asyncio
4734 async def test_invalid_mac_replaced_by_arp(self, mock_mass: MagicMock) -> None:
4735 """Test that an invalid MAC (00:00:00:00:00:00) is replaced by ARP result."""
4736 provider = MockProvider("dlna", mass=mock_mass)
4737 player = MockPlayer(
4738 provider,
4739 "dlna_123",
4740 "DLNA Device",
4741 player_type=PlayerType.PROTOCOL,
4742 identifiers={
4743 IdentifierType.MAC_ADDRESS: "00:00:00:00:00:00",
4744 IdentifierType.IP_ADDRESS: "192.168.1.60",
4745 },
4746 )
4747
4748 with patch(
4749 "music_assistant.helpers.util.resolve_real_mac_address",
4750 new_callable=AsyncMock,
4751 return_value="11:22:33:44:55:66",
4752 ):
4753 await enrich_device_mac_address(player.device_info)
4754
4755 # Invalid MAC should be replaced
4756 assert player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == "11:22:33:44:55:66"
4757
4758 @pytest.mark.asyncio
4759 async def test_arp_returns_none_no_change(self, mock_mass: MagicMock) -> None:
4760 """Test that MAC is unchanged when ARP lookup returns None."""
4761 provider = MockProvider("airplay", mass=mock_mass)
4762 player = MockPlayer(
4763 provider,
4764 "ap_123",
4765 "AirPlay Device",
4766 player_type=PlayerType.PROTOCOL,
4767 identifiers={
4768 IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3",
4769 IdentifierType.IP_ADDRESS: "192.168.1.100",
4770 },
4771 )
4772
4773 with patch(
4774 "music_assistant.helpers.util.resolve_real_mac_address",
4775 new_callable=AsyncMock,
4776 return_value=None,
4777 ):
4778 await enrich_device_mac_address(player.device_info)
4779
4780 # MAC should remain unchanged
4781 assert player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == "62:E5:97:45:93:D3"
4782
4783 @pytest.mark.asyncio
4784 async def test_ipv6_mapped_ipv4_normalized(self, mock_mass: MagicMock) -> None:
4785 """Test that IPv6-mapped IPv4 addresses are normalized."""
4786 provider = MockProvider("airplay", mass=mock_mass)
4787 player = MockPlayer(
4788 provider,
4789 "ap_123",
4790 "AirPlay Device",
4791 player_type=PlayerType.PROTOCOL,
4792 identifiers={
4793 IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0",
4794 IdentifierType.IP_ADDRESS: "::ffff:192.168.1.100",
4795 },
4796 )
4797
4798 await enrich_device_mac_address(player.device_info)
4799
4800 # IP should be normalized to IPv4
4801 assert player.device_info.identifiers[IdentifierType.IP_ADDRESS] == "192.168.1.100"
4802
4803
4804def _create_universal_player(
4805 mock_mass: MagicMock,
4806 player_id: str,
4807 name: str,
4808 protocol_player_ids: list[str],
4809 identifiers: dict[IdentifierType, str] | None = None,
4810) -> UniversalPlayer:
4811 """Create a UniversalPlayer for testing."""
4812 universal_provider = create_mock_universal_provider(mock_mass)
4813 # Set up config so provider.instance_id works
4814 provider_config = MagicMock()
4815 provider_config.instance_id = "universal_player"
4816 provider_config.name = None
4817 universal_provider.config = provider_config
4818 mock_mass.config.get_base_player_config.return_value = create_mock_config(name)
4819
4820 device_info = DeviceInfo(
4821 model="Universal Player",
4822 manufacturer="Music Assistant",
4823 )
4824 if identifiers:
4825 for conn_type, value in identifiers.items():
4826 device_info.add_identifier(conn_type, value)
4827
4828 player = UniversalPlayer(
4829 provider=universal_provider,
4830 player_id=player_id,
4831 name=name,
4832 device_info=device_info,
4833 protocol_player_ids=protocol_player_ids,
4834 )
4835 player._attr_available = True
4836 player._cache.clear()
4837 player.set_initialized()
4838 return player
4839
4840
4841class TestProtocolToUniversalIdentifierFallback:
4842 """
4843 Tests for Fix 1: identifier-based fallback matching for Universal Players.
4844
4845 When a new protocol player (like Sendspin bridge) registers and its ID isn't
4846 in the Universal Player's stored _protocol_player_ids list, the system should
4847 fall back to identifier matching (MAC address) to find the correct parent.
4848 """
4849
4850 def test_new_protocol_matches_universal_by_mac(self, mock_mass: MagicMock) -> None:
4851 """Test that a new protocol player matches a Universal Player by MAC address."""
4852 controller = PlayerController(mock_mass)
4853
4854 # No cached parent_id for the sendspin player
4855 def mock_config_get(_key: str, default: str | None = None) -> str | None:
4856 return default
4857
4858 mock_mass.config.get.side_effect = mock_config_get
4859
4860 # Create existing universal player with known MAC (from AirPlay)
4861 universal = _create_universal_player(
4862 mock_mass,
4863 "up_62e5974593d3",
4864 "Apple TV",
4865 protocol_player_ids=["ap62e5974593d3"],
4866 identifiers={IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3"},
4867 )
4868
4869 # Create AirPlay protocol player (already linked)
4870 airplay_provider = MockProvider("airplay", mass=mock_mass)
4871 airplay_player = MockPlayer(
4872 airplay_provider,
4873 "ap62e5974593d3",
4874 "Apple TV (AirPlay)",
4875 player_type=PlayerType.PROTOCOL,
4876 identifiers={IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3"},
4877 )
4878 airplay_player.set_protocol_parent_id("up_62e5974593d3")
4879
4880 # Create NEW Sendspin bridge player with same MAC (not yet in _protocol_player_ids)
4881 sendspin_provider = MockProvider("sendspin", mass=mock_mass)
4882 sendspin_player = MockPlayer(
4883 sendspin_provider,
4884 "spb_62e5974593d3",
4885 "Apple TV (Sendspin)",
4886 player_type=PlayerType.PROTOCOL,
4887 identifiers={IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3"},
4888 )
4889
4890 mock_mass.players = controller
4891 controller._players = {
4892 "up_62e5974593d3": universal,
4893 "ap62e5974593d3": airplay_player,
4894 "spb_62e5974593d3": sendspin_player,
4895 }
4896
4897 # Initialize players so all_players() returns them
4898 airplay_player.set_initialized()
4899 sendspin_player.set_initialized()
4900
4901 # Try to link Sendspin player
4902 controller._try_link_protocol_to_native(sendspin_player)
4903
4904 # Should be linked to the universal player via identifier matching
4905 assert sendspin_player.protocol_parent_id == "up_62e5974593d3"
4906
4907 # Should be added to universal player's protocol list
4908 assert "spb_62e5974593d3" in universal._protocol_player_ids
4909
4910 # Should have a linked output protocol
4911 assert any(
4912 link.output_protocol_id == "spb_62e5974593d3"
4913 for link in universal.linked_output_protocols
4914 )
4915
4916 def test_new_protocol_no_match_skips_universal(self, mock_mass: MagicMock) -> None:
4917 """Test that a protocol player with different MAC doesn't match the wrong Universal."""
4918 controller = PlayerController(mock_mass)
4919
4920 # No cached parent_id
4921 def mock_config_get(_key: str, default: str | None = None) -> str | None:
4922 return default
4923
4924 mock_mass.config.get.side_effect = mock_config_get
4925
4926 # Create universal player for Device A
4927 universal = _create_universal_player(
4928 mock_mass,
4929 "up_aabbccddeeff",
4930 "Device A",
4931 protocol_player_ids=["ap_aabbccddeeff"],
4932 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
4933 )
4934
4935 # Create protocol player for a completely different device
4936 sendspin_provider = MockProvider("sendspin", mass=mock_mass)
4937 sendspin_player = MockPlayer(
4938 sendspin_provider,
4939 "spb_112233445566",
4940 "Device B (Sendspin)",
4941 player_type=PlayerType.PROTOCOL,
4942 identifiers={IdentifierType.MAC_ADDRESS: "11:22:33:44:55:66"},
4943 )
4944
4945 mock_mass.players = controller
4946 controller._players = {
4947 "up_aabbccddeeff": universal,
4948 "spb_112233445566": sendspin_player,
4949 }
4950
4951 sendspin_player.set_initialized()
4952
4953 controller._try_link_protocol_to_native(sendspin_player)
4954
4955 # Should NOT be linked to the wrong universal player
4956 assert sendspin_player.protocol_parent_id != "up_aabbccddeeff"
4957
4958 def test_known_protocol_id_still_works(self, mock_mass: MagicMock) -> None:
4959 """Test that the existing path (player_id in _protocol_player_ids) still works."""
4960 controller = PlayerController(mock_mass)
4961
4962 # No cached parent_id
4963 def mock_config_get(_key: str, default: str | None = None) -> str | None:
4964 return default
4965
4966 mock_mass.config.get.side_effect = mock_config_get
4967
4968 airplay_provider = MockProvider("airplay", mass=mock_mass)
4969 airplay_player = MockPlayer(
4970 airplay_provider,
4971 "ap_aabbccddeeff",
4972 "AirPlay Device",
4973 player_type=PlayerType.PROTOCOL,
4974 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
4975 )
4976
4977 # Universal player already has this player_id in its list
4978 universal = _create_universal_player(
4979 mock_mass,
4980 "up_aabbccddeeff",
4981 "Device",
4982 protocol_player_ids=["ap_aabbccddeeff"],
4983 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
4984 )
4985
4986 mock_mass.players = controller
4987 controller._players = {
4988 "up_aabbccddeeff": universal,
4989 "ap_aabbccddeeff": airplay_player,
4990 }
4991
4992 airplay_player.set_initialized()
4993
4994 controller._try_link_protocol_to_native(airplay_player)
4995
4996 # Should be linked via the existing stored ID path
4997 assert airplay_player.protocol_parent_id == "up_aabbccddeeff"
4998
4999
5000class TestCachedParentIdentifierCopying:
5001 """
5002 Tests for Fix 2: identifier copying when restoring cached parent links.
5003
5004 When a protocol player reconnects and links to a Universal Player via the
5005 cached_parent_id fast path, identifiers must be copied to the Universal Player.
5006 Restored Universal Players start with empty identifiers, so without this copy,
5007 subsequent protocol players (like Sendspin bridges) cannot match by identifiers.
5008 """
5009
5010 def test_identifiers_copied_on_cached_parent_restore(self, mock_mass: MagicMock) -> None:
5011 """Test that identifiers are copied from protocol player to Universal Player on restore."""
5012 controller = PlayerController(mock_mass)
5013
5014 # Mock config to return cached parent_id
5015 def mock_config_get(key: str, default: str | None = None) -> str | None:
5016 if "protocol_parent_id" in str(key):
5017 return "up_62e5974593d3"
5018 return default
5019
5020 mock_mass.config.get.side_effect = mock_config_get
5021
5022 # Create restored universal player with EMPTY identifiers (simulates restart)
5023 universal = _create_universal_player(
5024 mock_mass,
5025 "up_62e5974593d3",
5026 "Apple TV",
5027 protocol_player_ids=["ap62e5974593d3"],
5028 # No identifiers - simulates restored state
5029 )
5030
5031 # Create AirPlay protocol player reconnecting with its identifiers
5032 airplay_provider = MockProvider("airplay", mass=mock_mass)
5033 airplay_player = MockPlayer(
5034 airplay_provider,
5035 "ap62e5974593d3",
5036 "Apple TV (AirPlay)",
5037 player_type=PlayerType.PROTOCOL,
5038 identifiers={
5039 IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3",
5040 IdentifierType.IP_ADDRESS: "192.168.1.200",
5041 },
5042 )
5043
5044 mock_mass.players = controller
5045 controller._players = {
5046 "up_62e5974593d3": universal,
5047 "ap62e5974593d3": airplay_player,
5048 }
5049
5050 # Link protocol player via cached parent path
5051 controller._try_link_protocol_to_native(airplay_player)
5052
5053 # Should be linked
5054 assert airplay_player.protocol_parent_id == "up_62e5974593d3"
5055
5056 # Universal player should now have the MAC from the protocol player
5057 assert (
5058 universal.device_info.identifiers.get(IdentifierType.MAC_ADDRESS) == "62:E5:97:45:93:D3"
5059 )
5060
5061 def test_sendspin_matches_after_identifier_copy(self, mock_mass: MagicMock) -> None:
5062 """
5063 Test the full scenario: AirPlay restores, copies identifiers, Sendspin matches.
5064
5065 AirPlay restores via cache, copies identifiers to the Universal Player,
5066 then the Sendspin bridge matches the Universal Player by MAC.
5067 This is the end-to-end test for the combined Fix 2 + Fix 1 interaction.
5068 """
5069 controller = PlayerController(mock_mass)
5070
5071 # Step 1: Mock config - AirPlay has cached parent, Sendspin does not
5072 def mock_config_get(key: str, default: str | None = None) -> str | None:
5073 if "ap62e5974593d3" in str(key) and "protocol_parent_id" in str(key):
5074 return "up_62e5974593d3"
5075 return default
5076
5077 mock_mass.config.get.side_effect = mock_config_get
5078
5079 # Create restored universal player with empty identifiers
5080 universal = _create_universal_player(
5081 mock_mass,
5082 "up_62e5974593d3",
5083 "Apple TV",
5084 protocol_player_ids=["ap62e5974593d3"],
5085 )
5086
5087 # Create AirPlay protocol player
5088 airplay_provider = MockProvider("airplay", mass=mock_mass)
5089 airplay_player = MockPlayer(
5090 airplay_provider,
5091 "ap62e5974593d3",
5092 "Apple TV (AirPlay)",
5093 player_type=PlayerType.PROTOCOL,
5094 identifiers={IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3"},
5095 )
5096
5097 # Create Sendspin bridge player with same MAC
5098 sendspin_provider = MockProvider("sendspin", mass=mock_mass)
5099 sendspin_player = MockPlayer(
5100 sendspin_provider,
5101 "spb_62e5974593d3",
5102 "Apple TV (Sendspin)",
5103 player_type=PlayerType.PROTOCOL,
5104 identifiers={IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3"},
5105 )
5106
5107 mock_mass.players = controller
5108 controller._players = {
5109 "up_62e5974593d3": universal,
5110 "ap62e5974593d3": airplay_player,
5111 "spb_62e5974593d3": sendspin_player,
5112 }
5113
5114 # Initialize players so all_players() returns them
5115 airplay_player.set_initialized()
5116 sendspin_player.set_initialized()
5117
5118 # Step 2: AirPlay reconnects first - links via cached parent_id
5119 controller._try_link_protocol_to_native(airplay_player)
5120
5121 # Verify AirPlay linked and identifiers copied
5122 assert airplay_player.protocol_parent_id == "up_62e5974593d3"
5123 assert (
5124 universal.device_info.identifiers.get(IdentifierType.MAC_ADDRESS) == "62:E5:97:45:93:D3"
5125 )
5126
5127 # Step 3: Sendspin registers next - should match Universal Player by MAC
5128 controller._try_link_protocol_to_native(sendspin_player)
5129
5130 # Sendspin should be linked to the SAME universal player
5131 assert sendspin_player.protocol_parent_id == "up_62e5974593d3"
5132 assert "spb_62e5974593d3" in universal._protocol_player_ids
5133
5134 def test_non_universal_parent_skips_identifier_copy(self, mock_mass: MagicMock) -> None:
5135 """Test that identifier copy is only done for Universal Players, not native players."""
5136 controller = PlayerController(mock_mass)
5137
5138 # Mock config to return cached parent_id pointing to a native player
5139 def mock_config_get(key: str, default: str | None = None) -> str | None:
5140 if "protocol_parent_id" in str(key):
5141 return "sonos_123"
5142 return default
5143
5144 mock_mass.config.get.side_effect = mock_config_get
5145
5146 # Create native Sonos player
5147 sonos_provider = MockProvider("sonos", mass=mock_mass)
5148 sonos_player = MockPlayer(
5149 sonos_provider,
5150 "sonos_123",
5151 "Sonos Speaker",
5152 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5153 )
5154 original_mac = sonos_player.device_info.identifiers[IdentifierType.MAC_ADDRESS]
5155
5156 # Create DLNA protocol player with different MAC
5157 dlna_provider = MockProvider("dlna", mass=mock_mass)
5158 dlna_player = MockPlayer(
5159 dlna_provider,
5160 "dlna_123",
5161 "Sonos DLNA",
5162 player_type=PlayerType.PROTOCOL,
5163 identifiers={IdentifierType.MAC_ADDRESS: "11:22:33:44:55:66"},
5164 )
5165
5166 mock_mass.players = controller
5167 controller._players = {
5168 "sonos_123": sonos_player,
5169 "dlna_123": dlna_player,
5170 }
5171
5172 controller._try_link_protocol_to_native(dlna_player)
5173
5174 # Should be linked
5175 assert dlna_player.protocol_parent_id == "sonos_123"
5176
5177 # Native player's MAC should NOT be overwritten
5178 assert sonos_player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == original_mac
5179
5180
5181class TestEnrichAndMatchIntegration:
5182 """
5183 Integration tests for the interaction between MAC enrichment and identifier matching.
5184
5185 These tests verify that Fix 3 (preserving original MAC when ARP resolves a
5186 completely different address) works correctly with the identifier matching system.
5187 """
5188
5189 @pytest.mark.asyncio
5190 async def test_apple_device_sendspin_matches_via_airplay_id(self, mock_mass: MagicMock) -> None:
5191 """
5192 Test the full Apple device scenario end-to-end.
5193
5194 1. AirPlay player registers with private MAC 62:E5:97:45:93:D3
5195 2. ARP resolves to hardware MAC C0:95:6D:51:34:E0 (always used)
5196 3. Sendspin bridge matches via AIRPLAY_ID, not MAC
5197 """
5198 controller = PlayerController(mock_mass)
5199
5200 # Create AirPlay player (Apple TV - native PLAYER type)
5201 airplay_provider = MockProvider("airplay", mass=mock_mass)
5202 airplay_player = MockPlayer(
5203 airplay_provider,
5204 "ap62e5974593d3",
5205 "Apple TV",
5206 player_type=PlayerType.PLAYER,
5207 identifiers={
5208 IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3",
5209 IdentifierType.AIRPLAY_ID: "62:E5:97:45:93:D3",
5210 IdentifierType.IP_ADDRESS: "192.168.1.200",
5211 },
5212 )
5213
5214 # ARP enrichment replaces MAC with hardware truth
5215 with patch(
5216 "music_assistant.helpers.util.resolve_real_mac_address",
5217 new_callable=AsyncMock,
5218 return_value="C0:95:6D:51:34:E0",
5219 ):
5220 await enrich_device_mac_address(airplay_player.device_info)
5221
5222 # MAC replaced with ARP result
5223 assert (
5224 airplay_player.device_info.identifiers[IdentifierType.MAC_ADDRESS]
5225 == "C0:95:6D:51:34:E0"
5226 )
5227
5228 # Create Sendspin bridge player with AIRPLAY_ID (how Sendspin actually matches)
5229 sendspin_provider = MockProvider("sendspin", mass=mock_mass)
5230 sendspin_player = MockPlayer(
5231 sendspin_provider,
5232 "spb_62e5974593d3",
5233 "Apple TV (Sendspin)",
5234 player_type=PlayerType.PROTOCOL,
5235 identifiers={IdentifierType.AIRPLAY_ID: "62:E5:97:45:93:D3"},
5236 )
5237
5238 # Identifiers match via AIRPLAY_ID
5239 assert controller._identifiers_match(airplay_player, sendspin_player, "sendspin") is True
5240
5241 @pytest.mark.asyncio
5242 async def test_wiim_device_mac_replaced_still_matches(self, mock_mass: MagicMock) -> None:
5243 """
5244 Test that WiiM devices match after ARP enrichment.
5245
5246 WiiM AirPlay reports 56:78:C9:E6:0D:A0 (locally administered), ARP resolves to
5247 54:78:C9:E6:0D:A0 (hardware). The MAC is always replaced with the ARP result.
5248 """
5249 controller = PlayerController(mock_mass)
5250
5251 # Create AirPlay player (WiiM - PROTOCOL type)
5252 airplay_provider = MockProvider("airplay", mass=mock_mass)
5253 airplay_player = MockPlayer(
5254 airplay_provider,
5255 "ap_5678c9e60da0",
5256 "WiiM Pro (AirPlay)",
5257 player_type=PlayerType.PROTOCOL,
5258 identifiers={
5259 IdentifierType.MAC_ADDRESS: "56:78:C9:E6:0D:A0",
5260 IdentifierType.IP_ADDRESS: "192.168.1.50",
5261 },
5262 )
5263
5264 # ARP resolves MAC that differs only in locally-administered bit
5265 with patch(
5266 "music_assistant.helpers.util.resolve_real_mac_address",
5267 new_callable=AsyncMock,
5268 return_value="54:78:C9:E6:0D:A0",
5269 ):
5270 await enrich_device_mac_address(airplay_player.device_info)
5271
5272 # MAC should be replaced (only bit difference)
5273 assert (
5274 airplay_player.device_info.identifiers[IdentifierType.MAC_ADDRESS]
5275 == "54:78:C9:E6:0D:A0"
5276 )
5277
5278 # DLNA player also reports hardware MAC
5279 dlna_provider = MockProvider("dlna", mass=mock_mass)
5280 dlna_player = MockPlayer(
5281 dlna_provider,
5282 "dlna_5478c9e60da0",
5283 "WiiM Pro (DLNA)",
5284 player_type=PlayerType.PROTOCOL,
5285 identifiers={IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"},
5286 )
5287
5288 # Should match by MAC
5289 assert controller._identifiers_match(airplay_player, dlna_player, "dlna") is True
5290
5291
5292class TestDuplicateProtocolPrevention:
5293 """
5294 Tests for preventing duplicate protocol domain linking.
5295
5296 When multiple instances of the same protocol (e.g., two AirPlay or ShairPort-Sync
5297 instances, or multi-zone Bluesound players) run on the same host, they share the
5298 same IP/MAC. These must NOT all bind to the same parent player — each should get
5299 its own separate universal player.
5300 """
5301
5302 def test_parent_has_active_protocol_from_domain_empty(self, mock_mass: MagicMock) -> None:
5303 """Test helper returns False when parent has no linked protocols."""
5304 controller = PlayerController(mock_mass)
5305
5306 sonos_provider = MockProvider("sonos")
5307 sonos_player = MockPlayer(
5308 sonos_provider, "sonos_1", "Sonos Speaker", player_type=PlayerType.PLAYER
5309 )
5310
5311 assert controller._parent_has_active_protocol_from_domain(sonos_player, "airplay") is False
5312
5313 def test_parent_has_active_protocol_from_domain_true(self, mock_mass: MagicMock) -> None:
5314 """Test helper returns True when parent has an active link from the given domain."""
5315 controller = PlayerController(mock_mass)
5316
5317 sonos_provider = MockProvider("sonos")
5318 sonos_player = MockPlayer(
5319 sonos_provider, "sonos_1", "Sonos Speaker", player_type=PlayerType.PLAYER
5320 )
5321
5322 airplay_provider = MockProvider("airplay")
5323 airplay_player = MockPlayer(
5324 airplay_provider,
5325 "ap_1",
5326 "AirPlay 1",
5327 player_type=PlayerType.PROTOCOL,
5328 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5329 )
5330 airplay_player._attr_available = True
5331 airplay_player._cache.clear()
5332 airplay_player.update_state(signal_event=False)
5333
5334 # Register players
5335 controller._players = {
5336 "sonos_1": sonos_player,
5337 "ap_1": airplay_player,
5338 }
5339 airplay_player.set_initialized()
5340 sonos_player.set_initialized()
5341
5342 # Add a protocol link
5343 controller._add_protocol_link(sonos_player, airplay_player, "airplay")
5344
5345 assert controller._parent_has_active_protocol_from_domain(sonos_player, "airplay") is True
5346 assert controller._parent_has_active_protocol_from_domain(sonos_player, "dlna") is False
5347
5348 def test_parent_has_active_protocol_excludes_player_id(self, mock_mass: MagicMock) -> None:
5349 """Test helper excludes the specified player_id from the check."""
5350 controller = PlayerController(mock_mass)
5351
5352 sonos_provider = MockProvider("sonos")
5353 sonos_player = MockPlayer(
5354 sonos_provider, "sonos_1", "Sonos Speaker", player_type=PlayerType.PLAYER
5355 )
5356
5357 airplay_provider = MockProvider("airplay")
5358 airplay_player = MockPlayer(
5359 airplay_provider,
5360 "ap_1",
5361 "AirPlay 1",
5362 player_type=PlayerType.PROTOCOL,
5363 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5364 )
5365 airplay_player._attr_available = True
5366 airplay_player._cache.clear()
5367 airplay_player.update_state(signal_event=False)
5368
5369 controller._players = {
5370 "sonos_1": sonos_player,
5371 "ap_1": airplay_player,
5372 }
5373 airplay_player.set_initialized()
5374 sonos_player.set_initialized()
5375
5376 controller._add_protocol_link(sonos_player, airplay_player, "airplay")
5377
5378 # Should return False when the only match is the excluded player
5379 assert (
5380 controller._parent_has_active_protocol_from_domain(
5381 sonos_player, "airplay", exclude_player_id="ap_1"
5382 )
5383 is False
5384 )
5385
5386 def test_add_protocol_link_refuses_duplicate_domain(self, mock_mass: MagicMock) -> None:
5387 """Test that _add_protocol_link refuses when domain already has an active link."""
5388 controller = PlayerController(mock_mass)
5389
5390 sonos_provider = MockProvider("sonos")
5391 sonos_player = MockPlayer(
5392 sonos_provider, "sonos_1", "Sonos Speaker", player_type=PlayerType.PLAYER
5393 )
5394
5395 airplay_provider = MockProvider("airplay")
5396 ap1 = MockPlayer(
5397 airplay_provider,
5398 "ap_1",
5399 "AirPlay 1",
5400 player_type=PlayerType.PROTOCOL,
5401 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5402 )
5403 ap2 = MockPlayer(
5404 airplay_provider,
5405 "ap_2",
5406 "AirPlay 2",
5407 player_type=PlayerType.PROTOCOL,
5408 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5409 )
5410
5411 # Both available
5412 ap1._attr_available = True
5413 ap1._cache.clear()
5414 ap1.update_state(signal_event=False)
5415 ap2._attr_available = True
5416 ap2._cache.clear()
5417 ap2.update_state(signal_event=False)
5418
5419 controller._players = {
5420 "sonos_1": sonos_player,
5421 "ap_1": ap1,
5422 "ap_2": ap2,
5423 }
5424 ap1.set_initialized()
5425 ap2.set_initialized()
5426 sonos_player.set_initialized()
5427
5428 # Link first AirPlay - should succeed
5429 controller._add_protocol_link(sonos_player, ap1, "airplay")
5430 assert ap1.protocol_parent_id == "sonos_1"
5431 assert len(sonos_player.linked_output_protocols) == 1
5432
5433 # Link second AirPlay - should be refused (domain already active)
5434 controller._add_protocol_link(sonos_player, ap2, "airplay")
5435 assert ap2.protocol_parent_id is None
5436 assert len(sonos_player.linked_output_protocols) == 1
5437 assert sonos_player.linked_output_protocols[0].output_protocol_id == "ap_1"
5438
5439 def test_add_protocol_link_allows_different_domains(self, mock_mass: MagicMock) -> None:
5440 """Test that _add_protocol_link allows links from different protocol domains."""
5441 controller = PlayerController(mock_mass)
5442
5443 sonos_provider = MockProvider("sonos")
5444 sonos_player = MockPlayer(
5445 sonos_provider, "sonos_1", "Sonos Speaker", player_type=PlayerType.PLAYER
5446 )
5447
5448 airplay_provider = MockProvider("airplay")
5449 ap1 = MockPlayer(
5450 airplay_provider,
5451 "ap_1",
5452 "AirPlay",
5453 player_type=PlayerType.PROTOCOL,
5454 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5455 )
5456
5457 dlna_provider = MockProvider("dlna")
5458 dlna1 = MockPlayer(
5459 dlna_provider,
5460 "dlna_1",
5461 "DLNA",
5462 player_type=PlayerType.PROTOCOL,
5463 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5464 )
5465
5466 ap1._attr_available = True
5467 ap1._cache.clear()
5468 ap1.update_state(signal_event=False)
5469 dlna1._attr_available = True
5470 dlna1._cache.clear()
5471 dlna1.update_state(signal_event=False)
5472
5473 controller._players = {
5474 "sonos_1": sonos_player,
5475 "ap_1": ap1,
5476 "dlna_1": dlna1,
5477 }
5478 ap1.set_initialized()
5479 dlna1.set_initialized()
5480 sonos_player.set_initialized()
5481
5482 # Link AirPlay - should succeed
5483 controller._add_protocol_link(sonos_player, ap1, "airplay")
5484 assert ap1.protocol_parent_id == "sonos_1"
5485
5486 # Link DLNA - should also succeed (different domain)
5487 controller._add_protocol_link(sonos_player, dlna1, "dlna")
5488 assert dlna1.protocol_parent_id == "sonos_1"
5489 assert len(sonos_player.linked_output_protocols) == 2
5490
5491 def test_add_protocol_link_allows_replacing_removed_player(self, mock_mass: MagicMock) -> None:
5492 """Test that a new link can replace a stale one after the old player is removed."""
5493 controller = PlayerController(mock_mass)
5494
5495 sonos_provider = MockProvider("sonos")
5496 sonos_player = MockPlayer(
5497 sonos_provider, "sonos_1", "Sonos Speaker", player_type=PlayerType.PLAYER
5498 )
5499
5500 airplay_provider = MockProvider("airplay")
5501 ap1 = MockPlayer(
5502 airplay_provider,
5503 "ap_1",
5504 "AirPlay 1",
5505 player_type=PlayerType.PROTOCOL,
5506 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5507 )
5508 ap2 = MockPlayer(
5509 airplay_provider,
5510 "ap_2",
5511 "AirPlay 2",
5512 player_type=PlayerType.PROTOCOL,
5513 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5514 )
5515
5516 ap1._attr_available = True
5517 ap1._cache.clear()
5518 ap1.update_state(signal_event=False)
5519 ap2._attr_available = True
5520 ap2._cache.clear()
5521 ap2.update_state(signal_event=False)
5522
5523 controller._players = {
5524 "sonos_1": sonos_player,
5525 "ap_1": ap1,
5526 "ap_2": ap2,
5527 }
5528 ap1.set_initialized()
5529 ap2.set_initialized()
5530 sonos_player.set_initialized()
5531
5532 # Link first AirPlay
5533 controller._add_protocol_link(sonos_player, ap1, "airplay")
5534 assert ap1.protocol_parent_id == "sonos_1"
5535
5536 # Remove first AirPlay from registry (provider cleaned up stale player)
5537 del controller._players["ap_1"]
5538
5539 # Link second AirPlay - should succeed since first was removed
5540 controller._add_protocol_link(sonos_player, ap2, "airplay")
5541 assert ap2.protocol_parent_id == "sonos_1"
5542
5543 def test_add_protocol_link_blocks_when_existing_still_registered(
5544 self, mock_mass: MagicMock
5545 ) -> None:
5546 """Test that a new link is blocked when the old player is still registered."""
5547 controller = PlayerController(mock_mass)
5548
5549 sonos_provider = MockProvider("sonos")
5550 sonos_player = MockPlayer(
5551 sonos_provider, "sonos_1", "Sonos Speaker", player_type=PlayerType.PLAYER
5552 )
5553
5554 snap_provider = MockProvider("snapcast")
5555 snap1 = MockPlayer(
5556 snap_provider,
5557 "snap_1",
5558 "Snapcast 1",
5559 player_type=PlayerType.PROTOCOL,
5560 identifiers={IdentifierType.MAC_ADDRESS: "D0:11:E5:00:67:2F"},
5561 )
5562 snap2 = MockPlayer(
5563 snap_provider,
5564 "snap_2",
5565 "Snapcast 2",
5566 player_type=PlayerType.PROTOCOL,
5567 identifiers={IdentifierType.MAC_ADDRESS: "D0:11:E5:00:67:2F"},
5568 )
5569
5570 # Both unavailable
5571 snap1._attr_available = False
5572 snap1._cache.clear()
5573 snap1.update_state(signal_event=False)
5574 snap2._attr_available = False
5575 snap2._cache.clear()
5576 snap2.update_state(signal_event=False)
5577
5578 controller._players = {
5579 "sonos_1": sonos_player,
5580 "snap_1": snap1,
5581 "snap_2": snap2,
5582 }
5583 snap1.set_initialized()
5584 snap2.set_initialized()
5585 sonos_player.set_initialized()
5586
5587 # Link first snapcast
5588 controller._add_protocol_link(sonos_player, snap1, "snapcast")
5589 assert snap1.protocol_parent_id == "sonos_1"
5590
5591 # Try to link second snapcast - should be BLOCKED (snap1 still registered)
5592 controller._add_protocol_link(sonos_player, snap2, "snapcast")
5593 assert snap2.protocol_parent_id is None
5594
5595 def test_try_link_protocol_to_native_skips_duplicate_domain(self, mock_mass: MagicMock) -> None:
5596 """Test that _try_link_protocol_to_native falls through when domain is duplicate."""
5597 controller = PlayerController(mock_mass)
5598
5599 sonos_provider = MockProvider("sonos")
5600 sonos_player = MockPlayer(
5601 sonos_provider,
5602 "sonos_1",
5603 "Sonos Speaker",
5604 player_type=PlayerType.PLAYER,
5605 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5606 )
5607
5608 airplay_provider = MockProvider("airplay")
5609 ap1 = MockPlayer(
5610 airplay_provider,
5611 "ap_1",
5612 "AirPlay 1",
5613 player_type=PlayerType.PROTOCOL,
5614 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5615 )
5616 ap2 = MockPlayer(
5617 airplay_provider,
5618 "ap_2",
5619 "AirPlay 2",
5620 player_type=PlayerType.PROTOCOL,
5621 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5622 )
5623
5624 ap1._attr_available = True
5625 ap1._cache.clear()
5626 ap1.update_state(signal_event=False)
5627 ap2._attr_available = True
5628 ap2._cache.clear()
5629 ap2.update_state(signal_event=False)
5630
5631 controller._players = {
5632 "sonos_1": sonos_player,
5633 "ap_1": ap1,
5634 "ap_2": ap2,
5635 }
5636 ap1.set_initialized()
5637 ap2.set_initialized()
5638 sonos_player.set_initialized()
5639
5640 # Link first AirPlay to native player
5641 controller._add_protocol_link(sonos_player, ap1, "airplay")
5642 assert ap1.protocol_parent_id == "sonos_1"
5643
5644 # Mock _schedule_protocol_evaluation to track if it's called
5645 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
5646 controller._try_link_protocol_to_native(ap2)
5647
5648 # ap2 should NOT have been linked to sonos
5649 assert ap2.protocol_parent_id is None
5650 # ap2 should be scheduled for delayed evaluation (which will create its own
5651 # universal player)
5652 mock_schedule.assert_called_once_with(ap2)
5653
5654 def test_try_link_protocols_to_native_skips_duplicate_domain(
5655 self, mock_mass: MagicMock
5656 ) -> None:
5657 """Test that _try_link_protocols_to_native skips protocol players with duplicate domain."""
5658 controller = PlayerController(mock_mass)
5659
5660 # Config.get returns {} for CONF_PLAYERS dict lookups
5661 mock_mass.config.get = MagicMock(side_effect=lambda _key, default=None: default or {})
5662
5663 sonos_provider = MockProvider("sonos")
5664 sonos_player = MockPlayer(
5665 sonos_provider,
5666 "sonos_1",
5667 "Sonos Speaker",
5668 player_type=PlayerType.PLAYER,
5669 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5670 )
5671
5672 airplay_provider = MockProvider("airplay")
5673 ap1 = MockPlayer(
5674 airplay_provider,
5675 "ap_1",
5676 "AirPlay 1",
5677 player_type=PlayerType.PROTOCOL,
5678 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5679 )
5680 ap2 = MockPlayer(
5681 airplay_provider,
5682 "ap_2",
5683 "AirPlay 2",
5684 player_type=PlayerType.PROTOCOL,
5685 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5686 )
5687
5688 dlna_provider = MockProvider("dlna")
5689 dlna_player = MockPlayer(
5690 dlna_provider,
5691 "dlna_1",
5692 "DLNA",
5693 player_type=PlayerType.PROTOCOL,
5694 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5695 )
5696
5697 ap1._attr_available = True
5698 ap1._cache.clear()
5699 ap1.update_state(signal_event=False)
5700 ap2._attr_available = True
5701 ap2._cache.clear()
5702 ap2.update_state(signal_event=False)
5703 dlna_player._attr_available = True
5704 dlna_player._cache.clear()
5705 dlna_player.update_state(signal_event=False)
5706
5707 controller._players = {
5708 "ap_1": ap1,
5709 "ap_2": ap2,
5710 "dlna_1": dlna_player,
5711 "sonos_1": sonos_player,
5712 }
5713 ap1.set_initialized()
5714 ap2.set_initialized()
5715 dlna_player.set_initialized()
5716 sonos_player.set_initialized()
5717
5718 # Call _try_link_protocols_to_native for the Sonos player
5719 # It should link ap1 and dlna but NOT ap2 (duplicate airplay domain)
5720 controller._try_link_protocols_to_native(sonos_player)
5721
5722 # First AirPlay should be linked
5723 assert ap1.protocol_parent_id == "sonos_1"
5724 # DLNA should be linked (different domain)
5725 assert dlna_player.protocol_parent_id == "sonos_1"
5726 # Second AirPlay should NOT be linked (duplicate domain)
5727 assert ap2.protocol_parent_id is None
5728 assert len(sonos_player.linked_output_protocols) == 2
5729
5730 def test_normal_multi_protocol_device_still_works(self, mock_mass: MagicMock) -> None:
5731 """Test that a device with AirPlay+DLNA (different protocols) still links correctly."""
5732 controller = PlayerController(mock_mass)
5733
5734 # Config.get returns {} for CONF_PLAYERS dict lookups
5735 mock_mass.config.get = MagicMock(side_effect=lambda _key, default=None: default or {})
5736
5737 sonos_provider = MockProvider("sonos")
5738 sonos_player = MockPlayer(
5739 sonos_provider,
5740 "sonos_1",
5741 "Sonos Speaker",
5742 player_type=PlayerType.PLAYER,
5743 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5744 )
5745
5746 airplay_provider = MockProvider("airplay")
5747 ap = MockPlayer(
5748 airplay_provider,
5749 "ap_1",
5750 "AirPlay",
5751 player_type=PlayerType.PROTOCOL,
5752 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5753 )
5754
5755 dlna_provider = MockProvider("dlna")
5756 dlna = MockPlayer(
5757 dlna_provider,
5758 "dlna_1",
5759 "DLNA",
5760 player_type=PlayerType.PROTOCOL,
5761 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5762 )
5763
5764 ap._attr_available = True
5765 ap._cache.clear()
5766 ap.update_state(signal_event=False)
5767 dlna._attr_available = True
5768 dlna._cache.clear()
5769 dlna.update_state(signal_event=False)
5770
5771 controller._players = {
5772 "sonos_1": sonos_player,
5773 "ap_1": ap,
5774 "dlna_1": dlna,
5775 }
5776 ap.set_initialized()
5777 dlna.set_initialized()
5778 sonos_player.set_initialized()
5779
5780 # Link both protocols
5781 controller._try_link_protocols_to_native(sonos_player)
5782
5783 assert ap.protocol_parent_id == "sonos_1"
5784 assert dlna.protocol_parent_id == "sonos_1"
5785 assert len(sonos_player.linked_output_protocols) == 2
5786
5787 def test_cached_parent_restore_refuses_duplicate_domain(self, mock_mass: MagicMock) -> None:
5788 """Test that restoring a cached parent is refused when domain already has active link."""
5789 controller = PlayerController(mock_mass)
5790
5791 sonos_provider = MockProvider("sonos")
5792 sonos_player = MockPlayer(
5793 sonos_provider,
5794 "sonos_1",
5795 "Sonos Speaker",
5796 player_type=PlayerType.PLAYER,
5797 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5798 )
5799
5800 airplay_provider = MockProvider("airplay")
5801 ap1 = MockPlayer(
5802 airplay_provider,
5803 "ap_1",
5804 "AirPlay 1",
5805 player_type=PlayerType.PROTOCOL,
5806 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5807 )
5808 ap2 = MockPlayer(
5809 airplay_provider,
5810 "ap_2",
5811 "AirPlay 2",
5812 player_type=PlayerType.PROTOCOL,
5813 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5814 )
5815
5816 ap1._attr_available = True
5817 ap1._cache.clear()
5818 ap1.update_state(signal_event=False)
5819 ap2._attr_available = True
5820 ap2._cache.clear()
5821 ap2.update_state(signal_event=False)
5822
5823 controller._players = {
5824 "sonos_1": sonos_player,
5825 "ap_1": ap1,
5826 "ap_2": ap2,
5827 }
5828 ap1.set_initialized()
5829 ap2.set_initialized()
5830 sonos_player.set_initialized()
5831
5832 # Link first AirPlay
5833 controller._add_protocol_link(sonos_player, ap1, "airplay")
5834 assert ap1.protocol_parent_id == "sonos_1"
5835
5836 # Simulate ap2 having a cached parent from previous session
5837 with (
5838 patch.object(
5839 controller,
5840 "_get_cached_protocol_parent_id",
5841 return_value="sonos_1",
5842 ),
5843 patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule,
5844 ):
5845 controller._try_link_protocol_to_native(ap2)
5846
5847 # ap2 should NOT have been linked (domain already active on sonos_1)
5848 assert ap2.protocol_parent_id is None
5849 # ap2 should be scheduled for delayed evaluation
5850 mock_schedule.assert_called_once_with(ap2)
5851
5852
5853class TestUniversalPlayerMerging:
5854 """Tests for merging universal players when identifiers overlap."""
5855
5856 def test_merge_universal_players_on_identifier_copy(self, mock_mass: MagicMock) -> None:
5857 """
5858 Test that two universal players merge when they share a MAC after identifier copy.
5859
5860 Simulates the Edifier scenario: DLNA creates a UUID-based universal player,
5861 AirPlay creates a MAC-based universal player. When ARP enrichment adds the
5862 real MAC to the DLNA player and it's copied to the DLNA universal player,
5863 the two universal players should merge because they now share the same MAC.
5864 """
5865 controller = PlayerController(mock_mass)
5866 up_provider = create_mock_universal_provider(mock_mass)
5867
5868 # Universal player 1: from DLNA (UUID-based, no MAC initially)
5869 up1 = UniversalPlayer(
5870 provider=up_provider,
5871 player_id="upf15fff97f002",
5872 name="Speaker Liv",
5873 device_info=DeviceInfo(model="DLNA Speaker", manufacturer="Edifier"),
5874 protocol_player_ids=["dlna_uuid_1"],
5875 )
5876 up1._attr_device_info.add_identifier(
5877 IdentifierType.UUID, "FF97F002-783E-6505-6579-F15FFF97F002"
5878 )
5879 up1._cache.clear()
5880 up1.update_state(signal_event=False)
5881 up1.set_initialized()
5882
5883 # Universal player 2: from AirPlay (MAC-based)
5884 up2 = UniversalPlayer(
5885 provider=up_provider,
5886 player_id="up50411c2f00ee",
5887 name="Speaker Liv",
5888 device_info=DeviceInfo(model="AirPlay Speaker", manufacturer="Edifier"),
5889 protocol_player_ids=["ap_1", "spb_1"],
5890 )
5891 up2._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "50:41:1C:2F:00:EE")
5892 up2._attr_device_info.add_identifier(IdentifierType.AIRPLAY_ID, "52:41:1C:2F:00:EE")
5893 up2._cache.clear()
5894 up2.update_state(signal_event=False)
5895 up2.set_initialized()
5896
5897 # Create mock DLNA protocol player (has MAC from ARP enrichment)
5898 dlna_provider = MockProvider("dlna", mass=mock_mass)
5899 dlna_player = MockPlayer(
5900 dlna_provider,
5901 "dlna_uuid_1",
5902 "Speaker Liv DLNA",
5903 player_type=PlayerType.PROTOCOL,
5904 identifiers={
5905 IdentifierType.UUID: "FF97F002-783E-6505-6579-F15FFF97F002",
5906 IdentifierType.MAC_ADDRESS: "50:41:1C:2F:00:EE", # From ARP
5907 IdentifierType.IP_ADDRESS: "192.168.1.80",
5908 },
5909 )
5910 dlna_player.set_initialized()
5911
5912 # Create mock AirPlay protocol player
5913 airplay_provider = MockProvider("airplay", mass=mock_mass)
5914 ap_player = MockPlayer(
5915 airplay_provider,
5916 "ap_1",
5917 "Speaker Liv AirPlay",
5918 player_type=PlayerType.PROTOCOL,
5919 identifiers={
5920 IdentifierType.MAC_ADDRESS: "50:41:1C:2F:00:EE",
5921 IdentifierType.AIRPLAY_ID: "52:41:1C:2F:00:EE",
5922 },
5923 )
5924 ap_player.set_initialized()
5925
5926 # Link protocols to their respective universal players
5927 controller._players = {
5928 "upf15fff97f002": up1,
5929 "up50411c2f00ee": up2,
5930 "dlna_uuid_1": dlna_player,
5931 "ap_1": ap_player,
5932 }
5933
5934 # Link DLNA to up1
5935 controller._add_protocol_link(up1, dlna_player, "dlna")
5936 assert dlna_player.protocol_parent_id == "upf15fff97f002"
5937
5938 # Link AirPlay to up2
5939 controller._add_protocol_link(up2, ap_player, "airplay")
5940 assert ap_player.protocol_parent_id == "up50411c2f00ee"
5941
5942 # Now simulate what happens when DLNA player copies identifiers to up1
5943 # (this happens in _try_restore_cached_parent or _try_link_to_existing_player)
5944 for conn_type, value in dlna_player.device_info.identifiers.items():
5945 up1.device_info.add_identifier(conn_type, value)
5946
5947 # up1 now has MAC 50:41:1C:2F:00:EE which matches up2
5948 assert up1.device_info.identifiers.get(IdentifierType.MAC_ADDRESS) == "50:41:1C:2F:00:EE"
5949
5950 # Call merge check
5951 controller._check_merge_universal_players(up1)
5952
5953 # Both UPs have one linked protocol -> equal-count merge falls through to
5954 # the deterministic tiebreaker on player_id. "up50411c2f00ee" (up2) is
5955 # lex-smaller than "upf15fff97f002" (up1), so up2 absorbs up1.
5956
5957 # up2 (lex-smaller player_id) should have absorbed up1's DLNA link
5958 protocol_domains = {link.protocol_domain for link in up2.linked_output_protocols}
5959 assert "dlna" in protocol_domains
5960 assert "airplay" in protocol_domains
5961
5962 # up1 should have no more protocol links
5963 assert len(up1.linked_output_protocols) == 0
5964
5965 # DLNA player should now be linked to up2 (was on up1 before merge)
5966 assert dlna_player.protocol_parent_id == "up50411c2f00ee"
5967
5968 # AirPlay player should still be linked to up2
5969 assert ap_player.protocol_parent_id == "up50411c2f00ee"
5970
5971 # up2 should have protocol player IDs from both
5972 assert "dlna_uuid_1" in up2._protocol_player_ids
5973 assert "ap_1" in up2._protocol_player_ids
5974
5975 # Unregister should have been called for up1
5976 mock_mass.create_task.assert_called()
5977
5978 def test_no_merge_when_identifiers_dont_match(self, mock_mass: MagicMock) -> None:
5979 """Test that universal players are not merged when identifiers don't overlap."""
5980 controller = PlayerController(mock_mass)
5981 up_provider = create_mock_universal_provider(mock_mass)
5982
5983 up1 = UniversalPlayer(
5984 provider=up_provider,
5985 player_id="up_1",
5986 name="Speaker A",
5987 device_info=DeviceInfo(model="Model A", manufacturer="Maker A"),
5988 protocol_player_ids=["dlna_1"],
5989 )
5990 up1._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:01")
5991 up1._cache.clear()
5992 up1.update_state(signal_event=False)
5993 up1.set_initialized()
5994
5995 up2 = UniversalPlayer(
5996 provider=up_provider,
5997 player_id="up_2",
5998 name="Speaker B",
5999 device_info=DeviceInfo(model="Model B", manufacturer="Maker B"),
6000 protocol_player_ids=["ap_1"],
6001 )
6002 up2._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:02")
6003 up2._cache.clear()
6004 up2.update_state(signal_event=False)
6005 up2.set_initialized()
6006
6007 controller._players = {"up_1": up1, "up_2": up2}
6008
6009 # Call merge - should do nothing since MACs are different
6010 controller._check_merge_universal_players(up1)
6011
6012 # Both players should remain untouched
6013 assert "up_1" in controller._players
6014 assert "up_2" in controller._players
6015
6016 def test_merge_keeps_player_with_more_protocols(self, mock_mass: MagicMock) -> None:
6017 """Test that of two equally old universal players, the one with more links wins."""
6018 controller = PlayerController(mock_mass)
6019 up_provider = create_mock_universal_provider(mock_mass)
6020
6021 # up1 has only 1 protocol link (DLNA)
6022 up1 = UniversalPlayer(
6023 provider=up_provider,
6024 player_id="up_small",
6025 name="Small Player",
6026 device_info=DeviceInfo(model="Test", manufacturer="Test"),
6027 protocol_player_ids=["dlna_1"],
6028 )
6029 up1._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6030 up1._cache.clear()
6031 up1.update_state(signal_event=False)
6032 up1.set_initialized()
6033
6034 # up2 has 2 protocol links (AirPlay + Sendspin)
6035 up2 = UniversalPlayer(
6036 provider=up_provider,
6037 player_id="up_big",
6038 name="Big Player",
6039 device_info=DeviceInfo(model="Test", manufacturer="Test"),
6040 protocol_player_ids=["ap_1", "spb_1"],
6041 )
6042 up2._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6043 up2._cache.clear()
6044 up2.update_state(signal_event=False)
6045 up2.set_initialized()
6046
6047 # Create protocol players
6048 dlna_provider = MockProvider("dlna", mass=mock_mass)
6049 dlna_player = MockPlayer(
6050 dlna_provider,
6051 "dlna_1",
6052 "DLNA",
6053 player_type=PlayerType.PROTOCOL,
6054 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6055 )
6056 dlna_player.set_initialized()
6057
6058 airplay_provider = MockProvider("airplay", mass=mock_mass)
6059 ap_player = MockPlayer(
6060 airplay_provider,
6061 "ap_1",
6062 "AirPlay",
6063 player_type=PlayerType.PROTOCOL,
6064 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6065 )
6066 ap_player.set_initialized()
6067
6068 sendspin_provider = MockProvider("sendspin", mass=mock_mass)
6069 spb_player = MockPlayer(
6070 sendspin_provider,
6071 "spb_1",
6072 "Sendspin",
6073 player_type=PlayerType.PROTOCOL,
6074 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6075 )
6076 spb_player.set_initialized()
6077
6078 controller._players = {
6079 "up_small": up1,
6080 "up_big": up2,
6081 "dlna_1": dlna_player,
6082 "ap_1": ap_player,
6083 "spb_1": spb_player,
6084 }
6085
6086 # Link DLNA to small, AirPlay+Sendspin to big
6087 controller._add_protocol_link(up1, dlna_player, "dlna")
6088 controller._add_protocol_link(up2, ap_player, "airplay")
6089 controller._add_protocol_link(up2, spb_player, "sendspin")
6090
6091 # Merge from the small player's perspective
6092 controller._check_merge_universal_players(up1)
6093
6094 # up2 (bigger) should have absorbed up1's DLNA link
6095 protocol_domains = {link.protocol_domain for link in up2.linked_output_protocols}
6096 assert "airplay" in protocol_domains
6097 assert "sendspin" in protocol_domains
6098 assert "dlna" in protocol_domains
6099
6100 # DLNA player should now point to up_big
6101 assert dlna_player.protocol_parent_id == "up_big"
6102
6103 # up1 should have no more links
6104 assert len(up1.linked_output_protocols) == 0
6105
6106 def test_merge_keeps_the_older_player_with_fewer_protocols(self, mock_mass: MagicMock) -> None:
6107 """
6108 The oldest universal player wins even when it serves fewer protocols.
6109
6110 Its player id is the one API consumers have been bound to the longest, and the
6111 links of the absorbed player move over anyway. A player stored before ids were
6112 minted has no creation moment at all, which must count as the oldest of the two.
6113 """
6114 controller = PlayerController(mock_mass)
6115 up_provider = create_mock_universal_provider(mock_mass)
6116 _wire_nested_config(
6117 mock_mass,
6118 {
6119 CONF_PLAYERS: {
6120 # the established player, from before player ids were minted
6121 "up_old": {"player_id": "up_old", "enabled": True, "values": {}},
6122 "up_new": {
6123 "player_id": "up_new",
6124 "enabled": True,
6125 "values": {CONF_CREATED_AT: 9000},
6126 },
6127 }
6128 },
6129 )
6130
6131 players: dict[str, UniversalPlayer] = {}
6132 for player_id, protocol_ids in (("up_old", ["dlna_1"]), ("up_new", ["ap_1", "spb_1"])):
6133 player = UniversalPlayer(
6134 provider=up_provider,
6135 player_id=player_id,
6136 name=player_id,
6137 device_info=DeviceInfo(model="Test", manufacturer="Test"),
6138 protocol_player_ids=protocol_ids,
6139 )
6140 player._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6141 player._cache.clear()
6142 player.update_state(signal_event=False)
6143 player.set_initialized()
6144 players[player_id] = player
6145
6146 protocol_players: dict[str, MockPlayer] = {}
6147 for protocol_id, domain in (("dlna_1", "dlna"), ("ap_1", "airplay"), ("spb_1", "sendspin")):
6148 protocol_player = MockPlayer(
6149 MockProvider(domain, mass=mock_mass),
6150 protocol_id,
6151 domain,
6152 player_type=PlayerType.PROTOCOL,
6153 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6154 )
6155 protocol_player.set_initialized()
6156 protocol_players[protocol_id] = protocol_player
6157
6158 controller._players = {**players, **protocol_players}
6159 controller._add_protocol_link(players["up_old"], protocol_players["dlna_1"], "dlna")
6160 controller._add_protocol_link(players["up_new"], protocol_players["ap_1"], "airplay")
6161 controller._add_protocol_link(players["up_new"], protocol_players["spb_1"], "sendspin")
6162
6163 controller._check_merge_universal_players(players["up_new"])
6164
6165 domains = {link.protocol_domain for link in players["up_old"].linked_output_protocols}
6166 assert domains == {"dlna", "airplay", "sendspin"}
6167 assert protocol_players["ap_1"].protocol_parent_id == "up_old"
6168 assert len(players["up_new"].linked_output_protocols) == 0
6169
6170 @staticmethod
6171 def _setup_merge_tiebreak(
6172 mock_mass: MagicMock, created_at: dict[str, int]
6173 ) -> tuple[PlayerController, UniversalPlayer, UniversalPlayer, MockPlayer, MockPlayer]:
6174 """
6175 Set up two universal players of one device, each with a single protocol link.
6176
6177 :param mock_mass: The mocked MusicAssistant instance to wire.
6178 :param created_at: Stored creation timestamp per universal player id.
6179 :return: Tuple of (controller, up_aaa, up_bbb, its DLNA player, its AirPlay player).
6180 """
6181 controller = PlayerController(mock_mass)
6182 up_provider = create_mock_universal_provider(mock_mass)
6183 _wire_nested_config(
6184 mock_mass,
6185 {
6186 CONF_PLAYERS: {
6187 player_id: {
6188 "player_id": player_id,
6189 "provider": "universal_player",
6190 "enabled": True,
6191 "values": {CONF_CREATED_AT: timestamp},
6192 }
6193 for player_id, timestamp in created_at.items()
6194 }
6195 },
6196 )
6197
6198 # up_a: lex-smaller player_id, one protocol link (DLNA)
6199 up_a = UniversalPlayer(
6200 provider=up_provider,
6201 player_id="up_aaa",
6202 name="Player A",
6203 device_info=DeviceInfo(model="Test", manufacturer="Test"),
6204 protocol_player_ids=["dlna_1"],
6205 )
6206 up_a._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6207 up_a._cache.clear()
6208 up_a.update_state(signal_event=False)
6209 up_a.set_initialized()
6210
6211 # up_b: lex-larger player_id, one protocol link (AirPlay)
6212 up_b = UniversalPlayer(
6213 provider=up_provider,
6214 player_id="up_bbb",
6215 name="Player B",
6216 device_info=DeviceInfo(model="Test", manufacturer="Test"),
6217 protocol_player_ids=["ap_1"],
6218 )
6219 up_b._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6220 up_b._cache.clear()
6221 up_b.update_state(signal_event=False)
6222 up_b.set_initialized()
6223
6224 # Create protocol players
6225 dlna_provider = MockProvider("dlna", mass=mock_mass)
6226 dlna_player = MockPlayer(
6227 dlna_provider,
6228 "dlna_1",
6229 "DLNA",
6230 player_type=PlayerType.PROTOCOL,
6231 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6232 )
6233 dlna_player.set_initialized()
6234
6235 airplay_provider = MockProvider("airplay", mass=mock_mass)
6236 ap_player = MockPlayer(
6237 airplay_provider,
6238 "ap_1",
6239 "AirPlay",
6240 player_type=PlayerType.PROTOCOL,
6241 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6242 )
6243 ap_player.set_initialized()
6244
6245 controller._players = {
6246 "up_aaa": up_a,
6247 "up_bbb": up_b,
6248 "dlna_1": dlna_player,
6249 "ap_1": ap_player,
6250 }
6251
6252 controller._add_protocol_link(up_a, dlna_player, "dlna")
6253 controller._add_protocol_link(up_b, ap_player, "airplay")
6254
6255 return controller, up_a, up_b, dlna_player, ap_player
6256
6257 def test_merge_deterministic_tiebreaker_on_equal_link_counts(
6258 self, mock_mass: MagicMock
6259 ) -> None:
6260 """Test that merges of equally old players resolve to the lex-smaller player_id."""
6261 controller, up_a, up_b, _, ap_player = self._setup_merge_tiebreak(
6262 mock_mass, {"up_aaa": 1000, "up_bbb": 1000}
6263 )
6264
6265 # Call merge from the lex-larger side. Under the old non-deterministic
6266 # behavior the caller (up_b) would have won. With the tiebreaker the
6267 # lex-smaller player_id (up_a) wins regardless of which side calls.
6268 controller._check_merge_universal_players(up_b)
6269
6270 # up_a (lex-smaller) should have absorbed up_b's AirPlay link
6271 protocol_domains = {link.protocol_domain for link in up_a.linked_output_protocols}
6272 assert protocol_domains == {"dlna", "airplay"}
6273
6274 # AirPlay player should now point to up_aaa
6275 assert ap_player.protocol_parent_id == "up_aaa"
6276
6277 # up_b should have no more links
6278 assert len(up_b.linked_output_protocols) == 0
6279
6280 def test_merge_tiebreaker_prefers_the_older_universal_player(
6281 self, mock_mass: MagicMock
6282 ) -> None:
6283 """Test that the player that has been around longest absorbs the other one."""
6284 controller, up_a, up_b, dlna_player, _ = self._setup_merge_tiebreak(
6285 mock_mass, {"up_aaa": 2000, "up_bbb": 1000}
6286 )
6287
6288 controller._check_merge_universal_players(up_a)
6289
6290 # up_b is older, so it wins despite its lex-larger player_id
6291 protocol_domains = {link.protocol_domain for link in up_b.linked_output_protocols}
6292 assert protocol_domains == {"dlna", "airplay"}
6293 assert dlna_player.protocol_parent_id == "up_bbb"
6294 assert len(up_a.linked_output_protocols) == 0
6295
6296 async def test_merge_preserves_moved_protocols_during_parent_cleanup(
6297 self, mock_mass: MagicMock
6298 ) -> None:
6299 """Moved protocol ownership must survive the removed parent's permanent cleanup."""
6300 controller = PlayerController(mock_mass)
6301 up_provider = create_mock_universal_provider(mock_mass)
6302
6303 config_store: dict[str, object] = {
6304 "players/up_keep": {"enabled": True},
6305 "players/up_remove": {"enabled": True},
6306 "players/ap_keep": {"enabled": True},
6307 "players/dlna_live": {"enabled": True},
6308 "players/dlna_cached": {
6309 "enabled": True,
6310 "provider": "dlna--test",
6311 "player_type": "protocol",
6312 "values": {"protocol_parent_id": "up_remove"},
6313 },
6314 }
6315
6316 def config_get(key: str, default: object = None) -> object:
6317 return config_store.get(key, default)
6318
6319 def config_set(key: str, value: object) -> None:
6320 config_store[key] = value
6321
6322 def config_remove(key: str) -> None:
6323 config_store.pop(key, None)
6324
6325 scheduled_tasks: list[Awaitable[object]] = []
6326
6327 def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
6328 scheduled_tasks.append(task)
6329
6330 mock_mass.config.get = MagicMock(side_effect=config_get)
6331 mock_mass.config.set = MagicMock(side_effect=config_set)
6332 mock_mass.config.remove = MagicMock(side_effect=config_remove)
6333 mock_mass.players = controller
6334 mock_mass.player_queues = MagicMock()
6335 mock_mass.call_later = MagicMock()
6336 mock_mass.loop = MagicMock()
6337 mock_mass.create_task = MagicMock(side_effect=capture_task)
6338
6339 keep = UniversalPlayer(
6340 provider=up_provider,
6341 player_id="up_keep",
6342 name="Keep",
6343 device_info=DeviceInfo(model="Test", manufacturer="Test"),
6344 protocol_player_ids=["ap_keep"],
6345 )
6346 keep._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6347 keep._cache.clear()
6348 keep.update_state(signal_event=False)
6349 keep.set_initialized()
6350
6351 remove = UniversalPlayer(
6352 provider=up_provider,
6353 player_id="up_remove",
6354 name="Remove",
6355 device_info=DeviceInfo(model="Test", manufacturer="Test"),
6356 protocol_player_ids=["dlna_live", "dlna_cached"],
6357 )
6358 remove._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6359 remove._cache.clear()
6360 remove.update_state(signal_event=False)
6361 remove.set_initialized()
6362
6363 airplay_provider = MockProvider("airplay", mass=mock_mass)
6364 ap_keep = MockPlayer(
6365 airplay_provider,
6366 "ap_keep",
6367 "AirPlay",
6368 player_type=PlayerType.PROTOCOL,
6369 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6370 )
6371 ap_keep.set_initialized()
6372
6373 dlna_provider = MockProvider("dlna", mass=mock_mass)
6374 dlna_live = MockPlayer(
6375 dlna_provider,
6376 "dlna_live",
6377 "DLNA",
6378 player_type=PlayerType.PROTOCOL,
6379 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6380 )
6381 dlna_live.set_initialized()
6382
6383 controller._players = {
6384 "up_keep": keep,
6385 "up_remove": remove,
6386 "ap_keep": ap_keep,
6387 "dlna_live": dlna_live,
6388 }
6389
6390 controller._add_protocol_link(keep, ap_keep, "airplay")
6391 controller._add_protocol_link(remove, dlna_live, "dlna")
6392
6393 with patch.object(controller, "_save_universal_player_data"):
6394 controller._check_merge_universal_players(keep)
6395
6396 assert dlna_live.protocol_parent_id == "up_keep"
6397 assert config_store["players/dlna_cached/values/protocol_parent_id"] == "up_keep"
6398 unregister_tasks = [t for t in scheduled_tasks if "unregister" in repr(t)]
6399 assert len(unregister_tasks) == 1
6400
6401 await unregister_tasks[0]
6402
6403 assert "up_remove" not in controller._players
6404 assert dlna_live.protocol_parent_id == "up_keep"
6405 assert "dlna_cached" in keep._protocol_player_ids
6406 assert config_store["players/dlna_cached/values/protocol_parent_id"] == "up_keep"
6407
6408 def test_no_merge_same_domain_protocols_on_same_ip(self, mock_mass: MagicMock) -> None:
6409 """
6410 Test that UPs with same-domain protocols on the same IP are NOT merged.
6411
6412 Scenario: 3 squeezelite instances on the same VM (same IP, different real MACs).
6413 Each gets its own universal player. The merge check must NOT merge them,
6414 because merging would combine protocols from the same domain.
6415
6416 Reproduces: https://github.com/music-assistant/support/issues/5266
6417 """
6418 controller = PlayerController(mock_mass)
6419 up_provider = create_mock_universal_provider(mock_mass)
6420
6421 # Create 3 universal players, each with one squeezelite protocol
6422 squeeze_provider = MockProvider("squeezelite", mass=mock_mass)
6423 ups = []
6424 protocols = []
6425 for i in range(1, 4):
6426 mac = f"00:00:00:00:00:{i:02X}"
6427 up = UniversalPlayer(
6428 provider=up_provider,
6429 player_id=f"up00000000000{i}",
6430 name=f"Zone {i}",
6431 device_info=DeviceInfo(model="Squeezelite", manufacturer="Test"),
6432 protocol_player_ids=[mac],
6433 )
6434 up._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac)
6435 up._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, "192.168.1.50")
6436 up._cache.clear()
6437 up.update_state(signal_event=False)
6438 up.set_initialized()
6439 ups.append(up)
6440
6441 proto = MockPlayer(
6442 squeeze_provider,
6443 mac,
6444 f"squeezeplay: {mac}",
6445 player_type=PlayerType.PROTOCOL,
6446 identifiers={
6447 IdentifierType.MAC_ADDRESS: mac,
6448 IdentifierType.IP_ADDRESS: "192.168.1.50",
6449 },
6450 )
6451 proto.set_initialized()
6452 protocols.append(proto)
6453
6454 controller._players = {}
6455 for up in ups:
6456 controller._players[up.player_id] = up
6457 for proto in protocols:
6458 controller._players[proto.player_id] = proto
6459
6460 # Link each protocol to its UP
6461 for up, proto in zip(ups, protocols, strict=True):
6462 controller._add_protocol_link(up, proto, "squeezelite")
6463
6464 # Verify initial state: each protocol linked to its own UP
6465 for up, proto in zip(ups, protocols, strict=True):
6466 assert proto.protocol_parent_id == up.player_id
6467
6468 # Now run merge check on each UP - none should merge
6469 with patch.object(controller, "_save_universal_player_data"):
6470 for up in ups:
6471 controller._check_merge_universal_players(up)
6472
6473 # All 3 UPs must still exist with their original protocol links
6474 for up, proto in zip(ups, protocols, strict=True):
6475 assert proto.protocol_parent_id == up.player_id, (
6476 f"Protocol {proto.player_id} should still be linked to {up.player_id}, "
6477 f"but is linked to {proto.protocol_parent_id}"
6478 )
6479 assert len(up.linked_output_protocols) == 1, (
6480 f"UP {up.player_id} should have exactly 1 protocol, "
6481 f"but has {len(up.linked_output_protocols)}"
6482 )
6483
6484 @staticmethod
6485 def _setup_merge_scenario(
6486 mock_mass: MagicMock, config_store: dict[str, Any]
6487 ) -> tuple[PlayerController, UniversalPlayer, list[Awaitable[object]]]:
6488 """
6489 Wire a merge scenario for the config carry-over tests.
6490
6491 Universal players 'up_keep' and 'up_remove' share a MAC; with equal
6492 link counts the deterministic tiebreaker keeps 'up_keep' and removes
6493 'up_remove', so 'up_remove' is the loser whose config is carried over.
6494 """
6495 controller = PlayerController(mock_mass)
6496 up_provider = create_mock_universal_provider(mock_mass)
6497
6498 def config_get(key: str, default: object = None) -> object:
6499 return config_store.get(key, default)
6500
6501 def config_set(key: str, value: object) -> None:
6502 config_store[key] = value
6503
6504 def config_remove(key: str) -> None:
6505 config_store.pop(key, None)
6506
6507 scheduled_tasks: list[Awaitable[object]] = []
6508
6509 def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
6510 scheduled_tasks.append(task)
6511
6512 mock_mass.config.get = MagicMock(side_effect=config_get)
6513 mock_mass.config.set = MagicMock(side_effect=config_set)
6514 mock_mass.config.remove = MagicMock(side_effect=config_remove)
6515 mock_mass.players = controller
6516 mock_mass.player_queues = MagicMock()
6517 mock_mass.call_later = MagicMock()
6518 mock_mass.loop = MagicMock()
6519 mock_mass.create_task = MagicMock(side_effect=capture_task)
6520
6521 keep = UniversalPlayer(
6522 provider=up_provider,
6523 player_id="up_keep",
6524 name="Keep (Universal)",
6525 device_info=DeviceInfo(model="Test", manufacturer="Test"),
6526 protocol_player_ids=["ap_keep"],
6527 )
6528 keep._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6529 keep._cache.clear()
6530 keep.update_state(signal_event=False)
6531 keep.set_initialized()
6532
6533 remove = UniversalPlayer(
6534 provider=up_provider,
6535 player_id="up_remove",
6536 name="Remove (Universal)",
6537 device_info=DeviceInfo(model="Test", manufacturer="Test"),
6538 protocol_player_ids=["dlna_live"],
6539 )
6540 remove._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6541 remove._cache.clear()
6542 remove.update_state(signal_event=False)
6543 remove.set_initialized()
6544
6545 ap_keep = MockPlayer(
6546 MockProvider("airplay", mass=mock_mass),
6547 "ap_keep",
6548 "AirPlay",
6549 player_type=PlayerType.PROTOCOL,
6550 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6551 )
6552 ap_keep.set_initialized()
6553
6554 dlna_live = MockPlayer(
6555 MockProvider("dlna", mass=mock_mass),
6556 "dlna_live",
6557 "DLNA",
6558 player_type=PlayerType.PROTOCOL,
6559 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6560 )
6561 dlna_live.set_initialized()
6562
6563 controller._players = {
6564 "up_keep": keep,
6565 "up_remove": remove,
6566 "ap_keep": ap_keep,
6567 "dlna_live": dlna_live,
6568 }
6569 controller._add_protocol_link(keep, ap_keep, "airplay")
6570 controller._add_protocol_link(remove, dlna_live, "dlna")
6571 return controller, keep, scheduled_tasks
6572
6573 async def test_merge_carries_over_universal_config(self, mock_mass: MagicMock) -> None:
6574 """Merge migrates name, values, DSP and queue settings from loser to keeper."""
6575 config_store: dict[str, Any] = {
6576 "players/up_keep": {
6577 "enabled": True,
6578 "name": "Keep (Universal)",
6579 "default_name": "Keep (Universal)",
6580 "values": {"volume_control": "keep_ctrl"},
6581 },
6582 "players/up_remove": {
6583 "enabled": True,
6584 "name": "Living Room",
6585 "default_name": "Remove (Universal)",
6586 "values": {
6587 "hide_in_ui": True,
6588 "volume_control": "remove_ctrl",
6589 "device_info": {"model": "Test", "manufacturer": "Test"},
6590 },
6591 },
6592 "players/ap_keep": {"enabled": True},
6593 "players/dlna_live": {"enabled": True},
6594 "player_dsp/up_remove": {"enabled": True, "filters": []},
6595 "player_queues/up_remove": {
6596 "queue_id": "up_remove",
6597 "values": {"crossfade_duration": 5},
6598 },
6599 }
6600 controller, keep, scheduled_tasks = self._setup_merge_scenario(mock_mass, config_store)
6601
6602 with patch.object(controller, "_save_universal_player_data"):
6603 controller._check_merge_universal_players(keep)
6604
6605 # the custom display name and non-conflicting values moved onto the keeper
6606 assert config_store["players/up_keep/name"] == "Living Room"
6607 assert config_store["players/up_keep/values/hide_in_ui"] is True
6608 # values the keeper has explicitly set are not overwritten
6609 assert "players/up_keep/values/volume_control" not in config_store
6610 # wrapper bookkeeping is not carried over
6611 assert "players/up_keep/values/device_info" not in config_store
6612 # DSP moved wholesale and queue settings moved and re-keyed onto the keeper
6613 assert config_store["player_dsp/up_keep"] == {"enabled": True, "filters": []}
6614 assert config_store["player_queues/up_keep"] == {
6615 "queue_id": "up_keep",
6616 "values": {"crossfade_duration": 5},
6617 }
6618 assert "player_queues/up_remove" not in config_store
6619 # the keeper's in-place config gets reloaded to apply the migrated values
6620 assert any("_reapply_player_config" in repr(t) for t in scheduled_tasks)
6621
6622 # and the losing universal player is still permanently removed
6623 unregister_tasks = [t for t in scheduled_tasks if "unregister" in repr(t)]
6624 assert len(unregister_tasks) == 1
6625 await unregister_tasks[0]
6626 assert "up_remove" not in controller._players
6627
6628 def test_merge_keeps_keeper_explicit_values(self, mock_mass: MagicMock) -> None:
6629 """Values explicitly set on the keeper win over the losing player's."""
6630 config_store: dict[str, Any] = {
6631 "players/up_keep": {
6632 "enabled": True,
6633 "name": "Kitchen",
6634 "default_name": "Keep (Universal)",
6635 "values": {"hide_in_ui": False},
6636 },
6637 "players/up_remove": {
6638 "enabled": True,
6639 "name": "Living Room",
6640 "default_name": "Remove (Universal)",
6641 "values": {"hide_in_ui": True, "flow_mode": True},
6642 },
6643 "players/ap_keep": {"enabled": True},
6644 "players/dlna_live": {"enabled": True},
6645 "player_dsp/up_keep": {"enabled": False, "filters": ["keep"]},
6646 "player_dsp/up_remove": {"enabled": True, "filters": ["remove"]},
6647 }
6648 controller, keep, _ = self._setup_merge_scenario(mock_mass, config_store)
6649
6650 with patch.object(controller, "_save_universal_player_data"):
6651 controller._check_merge_universal_players(keep)
6652
6653 # the keeper's own custom name and DSP config win
6654 assert "players/up_keep/name" not in config_store
6655 assert config_store["players/up_keep"]["name"] == "Kitchen"
6656 assert config_store["player_dsp/up_keep"] == {"enabled": False, "filters": ["keep"]}
6657 # the keeper's explicit value is not overwritten
6658 assert "players/up_keep/values/hide_in_ui" not in config_store
6659 # non-conflicting values still migrate
6660 assert config_store["players/up_keep/values/flow_mode"] is True
6661
6662 def test_merge_does_not_carry_over_the_creation_moment(self, mock_mass: MagicMock) -> None:
6663 """
6664 The absorbed player's creation moment stays behind.
6665
6666 It is bookkeeping of that wrapper alone; copying it onto the keeper would let a
6667 later merge discard the keeper's older - and therefore established - player id.
6668 """
6669 config_store: dict[str, Any] = {
6670 "players/up_keep": {
6671 "enabled": True,
6672 "name": "Keep (Universal)",
6673 "default_name": "Keep (Universal)",
6674 "values": {},
6675 },
6676 "players/up_remove": {
6677 "enabled": True,
6678 "name": "Remove (Universal)",
6679 "default_name": "Remove (Universal)",
6680 "values": {CONF_CREATED_AT: 5000},
6681 },
6682 "players/ap_keep": {"enabled": True},
6683 "players/dlna_live": {"enabled": True},
6684 }
6685 controller, keep, _ = self._setup_merge_scenario(mock_mass, config_store)
6686
6687 with patch.object(controller, "_save_universal_player_data"):
6688 controller._check_merge_universal_players(keep)
6689
6690 assert f"players/up_keep/values/{CONF_CREATED_AT}" not in config_store
6691 assert CONF_CREATED_AT not in config_store["players/up_keep"]["values"]
6692
6693 def test_merge_repoints_group_memberships(self, mock_mass: MagicMock) -> None:
6694 """Merge re-points group memberships from the removed wrapper to the keeper."""
6695 config_store: dict[str, Any] = {
6696 "players/up_keep": {
6697 "enabled": True,
6698 "name": "Keep (Universal)",
6699 "default_name": "Keep (Universal)",
6700 "values": {},
6701 },
6702 "players/up_remove": {
6703 "enabled": True,
6704 "name": "Remove (Universal)",
6705 "default_name": "Remove (Universal)",
6706 "values": {},
6707 },
6708 "players/ap_keep": {"enabled": True},
6709 "players/dlna_live": {"enabled": True},
6710 # nested players dict scanned by _update_group_memberships
6711 "players": {
6712 "group_1": {
6713 "enabled": True,
6714 "values": {
6715 "group_members": ["up_remove", "other"],
6716 "allowed_members": ["up_remove"],
6717 },
6718 },
6719 "group_2": {
6720 "enabled": True,
6721 # keeper already listed -> no duplicate after re-point
6722 "values": {"group_members": ["up_keep", "up_remove"]},
6723 },
6724 },
6725 }
6726 controller, keep, _ = self._setup_merge_scenario(mock_mass, config_store)
6727
6728 with patch.object(controller, "_save_universal_player_data"):
6729 controller._check_merge_universal_players(keep)
6730
6731 assert config_store["players/group_1/values/group_members"] == ["up_keep", "other"]
6732 assert config_store["players/group_1/values/allowed_members"] == ["up_keep"]
6733 # no duplicate when the keeper is already a member
6734 assert config_store["players/group_2/values/group_members"] == ["up_keep"]
6735
6736 async def test_merge_stops_playing_loser_before_unregister(self, mock_mass: MagicMock) -> None:
6737 """A playing losing wrapper is stopped before it is permanently removed."""
6738 config_store: dict[str, Any] = {
6739 "players/up_keep": {"enabled": True},
6740 "players/up_remove": {"enabled": True},
6741 "players/ap_keep": {"enabled": True},
6742 "players/dlna_live": {"enabled": True},
6743 }
6744 controller, keep, scheduled_tasks = self._setup_merge_scenario(mock_mass, config_store)
6745 controller._players["up_remove"]._attr_playback_state = PlaybackState.PLAYING
6746
6747 call_order: list[str] = []
6748 mock_mass.player_queues.stop = AsyncMock(
6749 side_effect=lambda *_a, **_k: call_order.append("stop")
6750 )
6751
6752 with patch.object(controller, "_save_universal_player_data"):
6753 controller._check_merge_universal_players(keep)
6754
6755 task = next(t for t in scheduled_tasks if "unregister" in repr(t))
6756
6757 def _record_unregister(*_a: Any, **_k: Any) -> None:
6758 call_order.append("unregister")
6759
6760 with patch.object(controller, "unregister", new=AsyncMock(side_effect=_record_unregister)):
6761 await task
6762
6763 assert call_order == ["stop", "unregister"]
6764 mock_mass.player_queues.stop.assert_awaited_once_with("up_remove")
6765
6766 async def test_merge_hands_the_user_filters_to_the_keeper(self, mock_mass: MagicMock) -> None:
6767 """The absorbed wrapper hands its access filter entries to the keeper."""
6768 config_store: dict[str, Any] = {
6769 "players/up_keep": {"enabled": True},
6770 "players/up_remove": {"enabled": True},
6771 "players/ap_keep": {"enabled": True},
6772 "players/dlna_live": {"enabled": True},
6773 }
6774 controller, keep, scheduled_tasks = self._setup_merge_scenario(mock_mass, config_store)
6775
6776 with patch.object(controller, "_save_universal_player_data"):
6777 controller._check_merge_universal_players(keep)
6778
6779 task = next(t for t in scheduled_tasks if "unregister" in repr(t))
6780 with patch.object(controller, "unregister", new=AsyncMock()) as mock_unregister:
6781 await task
6782
6783 mock_unregister.assert_awaited_once_with(
6784 "up_remove", permanent=True, replacement_player_id="up_keep"
6785 )
6786
6787 async def test_merge_idle_loser_not_stopped(self, mock_mass: MagicMock) -> None:
6788 """An idle losing wrapper is removed without a stop command."""
6789 config_store: dict[str, Any] = {
6790 "players/up_keep": {"enabled": True},
6791 "players/up_remove": {"enabled": True},
6792 "players/ap_keep": {"enabled": True},
6793 "players/dlna_live": {"enabled": True},
6794 }
6795 controller, keep, scheduled_tasks = self._setup_merge_scenario(mock_mass, config_store)
6796 mock_mass.player_queues.stop = AsyncMock()
6797
6798 with patch.object(controller, "_save_universal_player_data"):
6799 controller._check_merge_universal_players(keep)
6800
6801 task = next(t for t in scheduled_tasks if "unregister" in repr(t))
6802 await task
6803
6804 mock_mass.player_queues.stop.assert_not_awaited()
6805 assert "up_remove" not in controller._players
6806
6807
6808class TestUniversalPlayerReplacement:
6809 """Tests for replacing a universal player with a native player."""
6810
6811 @pytest.mark.parametrize(
6812 ("airplay_registered", "missing_protocol_id", "missing_domain", "missing_enabled"),
6813 [
6814 (True, "dlna_cached", "dlna", True),
6815 (True, "dlna_cached", "dlna", False),
6816 (False, "spb_cached", "sendspin", False),
6817 ],
6818 )
6819 async def test_replace_migrates_missing_cached_protocol_during_parent_cleanup(
6820 self,
6821 mock_mass: MagicMock,
6822 airplay_registered: bool,
6823 missing_protocol_id: str,
6824 missing_domain: str,
6825 missing_enabled: bool,
6826 ) -> None:
6827 """Missing cached protocol ownership must survive replacement and cleanup."""
6828 controller = PlayerController(mock_mass)
6829 up_provider = create_mock_universal_provider(mock_mass)
6830
6831 missing_values = {"protocol_parent_id": "up_old"}
6832 if missing_domain == "sendspin":
6833 missing_values[CONF_UNDERLYING_PLAYER_ID] = "ap_live"
6834 config_store: dict[str, Any] = {
6835 CONF_PLAYERS: {
6836 "sonos_1": {"enabled": True, "values": {}},
6837 "up_old": {"enabled": airplay_registered, "values": {}},
6838 "ap_live": {
6839 "enabled": airplay_registered,
6840 "provider": "airplay--test",
6841 "player_type": "protocol",
6842 "values": {"protocol_parent_id": "up_old"},
6843 },
6844 missing_protocol_id: {
6845 "enabled": missing_enabled,
6846 "provider": f"{missing_domain}--test",
6847 "player_type": "protocol",
6848 "values": missing_values,
6849 },
6850 }
6851 }
6852 _wire_nested_config(mock_mass, config_store)
6853
6854 scheduled_tasks: list[Awaitable[object]] = []
6855
6856 def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
6857 scheduled_tasks.append(task)
6858
6859 mock_mass.players = controller
6860 mock_mass.player_queues = MagicMock()
6861 mock_mass.call_later = MagicMock()
6862 mock_mass.loop = MagicMock()
6863 mock_mass.create_task = MagicMock(side_effect=capture_task)
6864
6865 universal = UniversalPlayer(
6866 provider=up_provider,
6867 player_id="up_old",
6868 name="Old Universal",
6869 device_info=DeviceInfo(model="Test", manufacturer="Test"),
6870 protocol_player_ids=["ap_live", missing_protocol_id],
6871 )
6872 universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6873 universal._cache.clear()
6874 universal.update_state(signal_event=False)
6875
6876 native_provider = MockProvider("sonos", mass=mock_mass)
6877 native = MockPlayer(
6878 native_provider,
6879 "sonos_1",
6880 "Sonos",
6881 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6882 )
6883 native.set_initialized()
6884
6885 airplay_provider = MockProvider("airplay", mass=mock_mass)
6886 ap_live = MockPlayer(
6887 airplay_provider,
6888 "ap_live",
6889 "AirPlay",
6890 player_type=PlayerType.PROTOCOL,
6891 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6892 )
6893 ap_live.set_initialized()
6894
6895 controller._players = {"up_old": universal, "sonos_1": native}
6896 if airplay_registered:
6897 controller._players["ap_live"] = ap_live
6898 controller._add_protocol_link(universal, ap_live, "airplay")
6899 else:
6900 universal.set_linked_output_protocols(
6901 [
6902 LinkedOutputProtocol(
6903 output_protocol_id="ap_live",
6904 protocol_domain="airplay",
6905 priority=10,
6906 )
6907 ]
6908 )
6909 universal.set_linked_output_protocols(
6910 [
6911 *universal.linked_output_protocols,
6912 LinkedOutputProtocol(
6913 output_protocol_id=missing_protocol_id,
6914 protocol_domain=missing_domain,
6915 priority=20,
6916 derived_from="ap_live" if missing_domain == "sendspin" else None,
6917 ),
6918 ]
6919 )
6920
6921 controller._check_replace_universal_player(native)
6922
6923 if airplay_registered:
6924 assert ap_live.protocol_parent_id == "sonos_1"
6925 else:
6926 assert (
6927 config_store[CONF_PLAYERS]["ap_live"]["values"][CONF_PROTOCOL_PARENT_ID]
6928 == "sonos_1"
6929 )
6930 assert set(config_store[CONF_PLAYERS]["sonos_1"]["values"]["linked_protocol_ids"]) == {
6931 "ap_live",
6932 missing_protocol_id,
6933 }
6934 assert (
6935 config_store[CONF_PLAYERS][missing_protocol_id]["values"][CONF_PROTOCOL_PARENT_ID]
6936 == "sonos_1"
6937 )
6938 unregister_tasks = [t for t in scheduled_tasks if "unregister" in repr(t)]
6939 assert len(unregister_tasks) == 1
6940
6941 await unregister_tasks[0]
6942
6943 assert "up_old" not in controller._players
6944 if airplay_registered:
6945 assert ap_live.protocol_parent_id == "sonos_1"
6946 else:
6947 assert (
6948 config_store[CONF_PLAYERS]["ap_live"]["values"][CONF_PROTOCOL_PARENT_ID]
6949 == "sonos_1"
6950 )
6951 assert (
6952 config_store[CONF_PLAYERS][missing_protocol_id]["values"][CONF_PROTOCOL_PARENT_ID]
6953 == "sonos_1"
6954 )
6955 assert set(config_store[CONF_PLAYERS]["sonos_1"]["values"]["linked_protocol_ids"]) == {
6956 "ap_live",
6957 missing_protocol_id,
6958 }
6959
6960 def test_replace_keeps_universal_when_all_links_refused(self, mock_mass: MagicMock) -> None:
6961 """
6962 Keep the universal player when no link could migrate.
6963
6964 If the native player already has an active link from the same protocol
6965 domain as every protocol on the universal player, each `_add_protocol_link`
6966 call refuses and leaves the protocol's parent unchanged. The universal player
6967 must NOT be permanently unregistered in that case, otherwise it disappears
6968 from the UI on restart.
6969 """
6970 controller = PlayerController(mock_mass)
6971 up_provider = create_mock_universal_provider(mock_mass)
6972
6973 config_store: dict[str, object] = {
6974 "players/cast_1": {"enabled": True},
6975 "players/up_old": {"enabled": True},
6976 "players/spb_old": {"enabled": True},
6977 "players/spb_new": {"enabled": True},
6978 }
6979
6980 def config_get(key: str, default: object = None) -> object:
6981 return config_store.get(key, default)
6982
6983 def config_set(key: str, value: object) -> None:
6984 config_store[key] = value
6985
6986 def config_remove(key: str) -> None:
6987 config_store.pop(key, None)
6988
6989 scheduled_tasks: list[Awaitable[object]] = []
6990
6991 def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
6992 scheduled_tasks.append(task)
6993
6994 mock_mass.config.get = MagicMock(side_effect=config_get)
6995 mock_mass.config.set = MagicMock(side_effect=config_set)
6996 mock_mass.config.remove = MagicMock(side_effect=config_remove)
6997 mock_mass.players = controller
6998 mock_mass.player_queues = MagicMock()
6999 mock_mass.call_later = MagicMock()
7000 mock_mass.loop = MagicMock()
7001 mock_mass.create_task = MagicMock(side_effect=capture_task)
7002
7003 universal = UniversalPlayer(
7004 provider=up_provider,
7005 player_id="up_old",
7006 name="Soundbar (Universal)",
7007 device_info=DeviceInfo(model="Test", manufacturer="Test"),
7008 protocol_player_ids=["spb_old"],
7009 )
7010 universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
7011 universal._cache.clear()
7012 universal.update_state(signal_event=False)
7013 universal.set_initialized()
7014
7015 cast_provider = MockProvider("cast", mass=mock_mass)
7016 native = MockPlayer(
7017 cast_provider,
7018 "cast_1",
7019 "Soundbar",
7020 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7021 )
7022 native.set_initialized()
7023
7024 sendspin_provider = MockProvider("sendspin", mass=mock_mass)
7025 spb_old = MockPlayer(
7026 sendspin_provider,
7027 "spb_old",
7028 "Soundbar (Sendspin)",
7029 player_type=PlayerType.PROTOCOL,
7030 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7031 )
7032 spb_old.set_initialized()
7033 spb_new = MockPlayer(
7034 sendspin_provider,
7035 "spb_new",
7036 "Soundbar (Sendspin v2)",
7037 player_type=PlayerType.PROTOCOL,
7038 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7039 )
7040 spb_new.set_initialized()
7041
7042 controller._players = {
7043 "up_old": universal,
7044 "cast_1": native,
7045 "spb_old": spb_old,
7046 "spb_new": spb_new,
7047 }
7048
7049 controller._add_protocol_link(universal, spb_old, "sendspin")
7050 controller._add_protocol_link(native, spb_new, "sendspin")
7051
7052 controller._check_replace_universal_player(native)
7053
7054 unregister_tasks = [t for t in scheduled_tasks if "unregister" in repr(t)]
7055 assert unregister_tasks == []
7056 assert "up_old" in controller._players
7057 assert spb_old.protocol_parent_id == "up_old"
7058 assert any(
7059 link.output_protocol_id == "spb_old" for link in universal.linked_output_protocols
7060 )
7061
7062 def test_replace_keeps_universal_when_some_links_refused(self, mock_mass: MagicMock) -> None:
7063 """
7064 Keep the universal player when only some of its links could migrate.
7065
7066 The native player already owns the sendspin domain, so the universal player's
7067 sendspin link is refused while its airplay link migrates. The universal player
7068 must be kept (holding the refused link) instead of being unregistered, which
7069 would orphan the refused protocol during cleanup.
7070 """
7071 controller = PlayerController(mock_mass)
7072 up_provider = create_mock_universal_provider(mock_mass)
7073
7074 config_store: dict[str, object] = {
7075 "players/cast_1": {"enabled": True},
7076 "players/up_old": {"enabled": True},
7077 "players/spb_old": {"enabled": True},
7078 "players/spb_new": {"enabled": True},
7079 "players/ap_old": {"enabled": True},
7080 }
7081
7082 def config_get(key: str, default: object = None) -> object:
7083 return config_store.get(key, default)
7084
7085 def config_set(key: str, value: object) -> None:
7086 config_store[key] = value
7087
7088 def config_remove(key: str) -> None:
7089 config_store.pop(key, None)
7090
7091 scheduled_tasks: list[Awaitable[object]] = []
7092
7093 def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
7094 scheduled_tasks.append(task)
7095
7096 mock_mass.config.get = MagicMock(side_effect=config_get)
7097 mock_mass.config.set = MagicMock(side_effect=config_set)
7098 mock_mass.config.remove = MagicMock(side_effect=config_remove)
7099 mock_mass.players = controller
7100 mock_mass.player_queues = MagicMock()
7101 mock_mass.call_later = MagicMock()
7102 mock_mass.loop = MagicMock()
7103 mock_mass.create_task = MagicMock(side_effect=capture_task)
7104
7105 universal = UniversalPlayer(
7106 provider=up_provider,
7107 player_id="up_old",
7108 name="Soundbar (Universal)",
7109 device_info=DeviceInfo(model="Test", manufacturer="Test"),
7110 protocol_player_ids=["spb_old", "ap_old"],
7111 )
7112 universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
7113 universal._cache.clear()
7114 universal.update_state(signal_event=False)
7115 universal.set_initialized()
7116
7117 cast_provider = MockProvider("cast", mass=mock_mass)
7118 native = MockPlayer(
7119 cast_provider,
7120 "cast_1",
7121 "Soundbar",
7122 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7123 )
7124 native.set_initialized()
7125
7126 sendspin_provider = MockProvider("sendspin", mass=mock_mass)
7127 spb_old = MockPlayer(
7128 sendspin_provider,
7129 "spb_old",
7130 "Soundbar (Sendspin)",
7131 player_type=PlayerType.PROTOCOL,
7132 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7133 )
7134 spb_old.set_initialized()
7135 spb_new = MockPlayer(
7136 sendspin_provider,
7137 "spb_new",
7138 "Soundbar (Sendspin v2)",
7139 player_type=PlayerType.PROTOCOL,
7140 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7141 )
7142 spb_new.set_initialized()
7143
7144 airplay_provider = MockProvider("airplay", mass=mock_mass)
7145 ap_old = MockPlayer(
7146 airplay_provider,
7147 "ap_old",
7148 "Soundbar (AirPlay)",
7149 player_type=PlayerType.PROTOCOL,
7150 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7151 )
7152 ap_old.set_initialized()
7153
7154 controller._players = {
7155 "up_old": universal,
7156 "cast_1": native,
7157 "spb_old": spb_old,
7158 "spb_new": spb_new,
7159 "ap_old": ap_old,
7160 }
7161
7162 controller._add_protocol_link(universal, spb_old, "sendspin")
7163 controller._add_protocol_link(universal, ap_old, "airplay")
7164 controller._add_protocol_link(native, spb_new, "sendspin")
7165
7166 controller._check_replace_universal_player(native)
7167
7168 unregister_tasks = [t for t in scheduled_tasks if "unregister" in repr(t)]
7169 assert unregister_tasks == []
7170 assert "up_old" in controller._players
7171 # The refused sendspin link stays owned by the universal player.
7172 assert spb_old.protocol_parent_id == "up_old"
7173 assert any(
7174 link.output_protocol_id == "spb_old" for link in universal.linked_output_protocols
7175 )
7176 # The airplay link migrated to the native player and left the universal player.
7177 assert ap_old.protocol_parent_id == "cast_1"
7178 assert any(link.output_protocol_id == "ap_old" for link in native.linked_output_protocols)
7179 assert all(
7180 link.output_protocol_id != "ap_old" for link in universal.linked_output_protocols
7181 )
7182
7183 def test_replace_keeps_cached_protocols_when_a_link_is_refused(
7184 self, mock_mass: MagicMock
7185 ) -> None:
7186 """
7187 Keep cached-only protocol ownership on a universal player that survives.
7188
7189 The native player refuses the sendspin link, so the universal player stays in
7190 charge. Ownership that only exists in config must not be handed over, so a
7191 protocol derived from a refused one keeps pointing at the parent it will link to.
7192 """
7193 controller = PlayerController(mock_mass)
7194 up_provider = create_mock_universal_provider(mock_mass)
7195
7196 config_store: dict[str, Any] = {
7197 "players/cast_1": {"enabled": True},
7198 "players/up_old": {"enabled": True},
7199 "players/spb_old": {"enabled": True},
7200 "players/spb_new": {"enabled": True},
7201 "players/ap_old": {"enabled": True},
7202 "players/dlna_cached": {"enabled": False},
7203 }
7204 mock_mass.config.get = MagicMock(side_effect=config_store.get)
7205 mock_mass.config.set = MagicMock(side_effect=config_store.__setitem__)
7206 mock_mass.config.remove = MagicMock(side_effect=lambda key: config_store.pop(key, None))
7207 mock_mass.players = controller
7208 mock_mass.player_queues = MagicMock()
7209 mock_mass.call_later = MagicMock()
7210 mock_mass.loop = MagicMock()
7211 mock_mass.create_task = MagicMock()
7212
7213 identifiers = {IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"}
7214 universal = UniversalPlayer(
7215 provider=up_provider,
7216 player_id="up_old",
7217 name="Soundbar (Universal)",
7218 device_info=DeviceInfo(model="Test", manufacturer="Test"),
7219 protocol_player_ids=["spb_old", "ap_old", "dlna_cached"],
7220 )
7221 universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
7222 universal._cache.clear()
7223 universal.update_state(signal_event=False)
7224 universal.set_initialized()
7225
7226 native = MockPlayer(
7227 MockProvider("cast", mass=mock_mass), "cast_1", "Soundbar", identifiers=identifiers
7228 )
7229 native.set_initialized()
7230 sendspin_provider = MockProvider("sendspin", mass=mock_mass)
7231 spb_old = MockPlayer(
7232 sendspin_provider,
7233 "spb_old",
7234 "Soundbar (Sendspin)",
7235 player_type=PlayerType.PROTOCOL,
7236 identifiers=identifiers,
7237 )
7238 spb_old.set_initialized()
7239 spb_new = MockPlayer(
7240 sendspin_provider,
7241 "spb_new",
7242 "Soundbar (Sendspin v2)",
7243 player_type=PlayerType.PROTOCOL,
7244 identifiers=identifiers,
7245 )
7246 spb_new.set_initialized()
7247 ap_old = MockPlayer(
7248 MockProvider("airplay", mass=mock_mass),
7249 "ap_old",
7250 "Soundbar (AirPlay)",
7251 player_type=PlayerType.PROTOCOL,
7252 identifiers=identifiers,
7253 )
7254 ap_old.set_initialized()
7255
7256 controller._players = {
7257 "up_old": universal,
7258 "cast_1": native,
7259 "spb_old": spb_old,
7260 "spb_new": spb_new,
7261 "ap_old": ap_old,
7262 }
7263 controller._add_protocol_link(universal, spb_old, "sendspin")
7264 controller._add_protocol_link(universal, ap_old, "airplay")
7265 controller._add_protocol_link(native, spb_new, "sendspin")
7266
7267 controller._check_replace_universal_player(native)
7268
7269 # the registered airplay link moved, the refused sendspin link did not
7270 assert ap_old.protocol_parent_id == "cast_1"
7271 assert spb_old.protocol_parent_id == "up_old"
7272 # the cached-only protocol stays with the universal player that remains in charge
7273 assert "dlna_cached" in universal._protocol_player_ids
7274 assert "dlna_cached" not in (
7275 config_store.get("players/cast_1/values/linked_protocol_ids") or []
7276 )
7277 assert config_store.get("players/dlna_cached/values/protocol_parent_id") != "cast_1"
7278
7279 @staticmethod
7280 def _setup_carry_over_scenario(
7281 mock_mass: MagicMock, config_store: dict[str, Any]
7282 ) -> tuple[PlayerController, MockPlayer, list[Awaitable[object]]]:
7283 """
7284 Wire a replacement scenario for the config carry-over tests.
7285
7286 Universal player 'up_old' (holding protocol player 'ap_live') is
7287 replaceable by native player 'cast_1'.
7288 """
7289 controller = PlayerController(mock_mass)
7290 up_provider = create_mock_universal_provider(mock_mass)
7291
7292 def config_get(key: str, default: object = None) -> object:
7293 return config_store.get(key, default)
7294
7295 def config_set(key: str, value: object) -> None:
7296 config_store[key] = value
7297
7298 def config_remove(key: str) -> None:
7299 config_store.pop(key, None)
7300
7301 scheduled_tasks: list[Awaitable[object]] = []
7302
7303 def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
7304 scheduled_tasks.append(task)
7305
7306 mock_mass.config.get = MagicMock(side_effect=config_get)
7307 mock_mass.config.set = MagicMock(side_effect=config_set)
7308 mock_mass.config.remove = MagicMock(side_effect=config_remove)
7309 mock_mass.players = controller
7310 mock_mass.player_queues = MagicMock()
7311 mock_mass.player_queues.get.return_value = None
7312 mock_mass.call_later = MagicMock()
7313 mock_mass.loop = MagicMock()
7314 mock_mass.create_task = MagicMock(side_effect=capture_task)
7315
7316 universal = UniversalPlayer(
7317 provider=up_provider,
7318 player_id="up_old",
7319 name="Soundbar (Universal)",
7320 device_info=DeviceInfo(model="Test", manufacturer="Test"),
7321 protocol_player_ids=["ap_live"],
7322 )
7323 universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
7324 universal._cache.clear()
7325 universal.update_state(signal_event=False)
7326 universal.set_initialized()
7327
7328 cast_provider = MockProvider("cast", mass=mock_mass)
7329 native = MockPlayer(
7330 cast_provider,
7331 "cast_1",
7332 "Soundbar",
7333 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7334 )
7335 native.set_initialized()
7336
7337 airplay_provider = MockProvider("airplay", mass=mock_mass)
7338 ap_live = MockPlayer(
7339 airplay_provider,
7340 "ap_live",
7341 "Soundbar (AirPlay)",
7342 player_type=PlayerType.PROTOCOL,
7343 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7344 )
7345 ap_live.set_initialized()
7346
7347 controller._players = {"up_old": universal, "cast_1": native, "ap_live": ap_live}
7348 controller._add_protocol_link(universal, ap_live, "airplay")
7349 return controller, native, scheduled_tasks
7350
7351 async def test_replace_carries_over_universal_config(self, mock_mass: MagicMock) -> None:
7352 """Replacement migrates name, values, DSP and queue settings to the native player."""
7353 config_store: dict[str, Any] = {
7354 # fresh native configs store name == default_name; that must not
7355 # count as a user override blocking the name carry-over
7356 "players/cast_1": {
7357 "enabled": True,
7358 "name": "Soundbar",
7359 "default_name": "Soundbar",
7360 "values": {"volume_control": "native"},
7361 },
7362 "players/up_old": {
7363 "enabled": True,
7364 "name": "Living Room",
7365 "default_name": "Soundbar (Universal)",
7366 "values": {
7367 "hide_in_ui": True,
7368 "volume_control": "up_volume_control",
7369 "linked_protocol_ids": ["ap_live", "ghost_proto"],
7370 "device_identifiers": {"mac_address": "AA:BB:CC:DD:EE:FF"},
7371 "device_info": {"model": "Test", "manufacturer": "Test"},
7372 "ap_live||protocol||output_codec": "flac",
7373 },
7374 },
7375 "players/ap_live": {"enabled": True},
7376 "player_dsp/up_old": {"enabled": True, "filters": []},
7377 "player_queues/up_old": {"queue_id": "up_old", "values": {"crossfade_duration": 5}},
7378 }
7379 controller, native, scheduled_tasks = self._setup_carry_over_scenario(
7380 mock_mass, config_store
7381 )
7382
7383 controller._check_replace_universal_player(native)
7384
7385 # the custom display name and user-set values moved onto the native player
7386 assert config_store["players/cast_1/name"] == "Living Room"
7387 assert config_store["players/cast_1/values/hide_in_ui"] is True
7388 # values the native player has explicitly set are not overwritten
7389 assert "players/cast_1/values/volume_control" not in config_store
7390 # wrapper bookkeeping and protocol-mirror values are not carried over
7391 assert "players/cast_1/values/device_identifiers" not in config_store
7392 assert "players/cast_1/values/device_info" not in config_store
7393 assert "players/cast_1/values/ap_live||protocol||output_codec" not in config_store
7394 # linked ids come from the protocol move machinery, not the universal's raw list
7395 assert config_store["players/cast_1/values/linked_protocol_ids"] == ["ap_live"]
7396 # DSP settings moved wholesale
7397 assert config_store["player_dsp/cast_1"] == {"enabled": True, "filters": []}
7398 # queue settings moved and re-keyed onto the native player's queue
7399 assert config_store["player_queues/cast_1"] == {
7400 "queue_id": "cast_1",
7401 "values": {"crossfade_duration": 5},
7402 }
7403 assert "player_queues/up_old" not in config_store
7404 # the native player's in-place config gets reloaded to apply the new values
7405 assert any("_reapply_player_config" in repr(t) for t in scheduled_tasks)
7406
7407 # and the universal player is still permanently removed
7408 unregister_tasks = [t for t in scheduled_tasks if "unregister" in repr(t)]
7409 assert len(unregister_tasks) == 1
7410 await unregister_tasks[0]
7411 assert "up_old" not in controller._players
7412 # migrated values survive the permanent cleanup of the universal player
7413 assert config_store["players/cast_1/name"] == "Living Room"
7414 assert "players/up_old" not in config_store
7415 assert "player_dsp/up_old" not in config_store
7416
7417 def test_replace_carries_over_nothing_without_user_config(self, mock_mass: MagicMock) -> None:
7418 """A non-customized universal player migrates nothing (no default-name carry)."""
7419 config_store: dict[str, Any] = {
7420 "players/cast_1": {"enabled": True},
7421 "players/up_old": {
7422 "enabled": True,
7423 "name": "Soundbar (Universal)",
7424 "default_name": "Soundbar (Universal)",
7425 "values": {"linked_protocol_ids": ["ap_live"]},
7426 },
7427 "players/ap_live": {"enabled": True},
7428 }
7429 controller, native, scheduled_tasks = self._setup_carry_over_scenario(
7430 mock_mass, config_store
7431 )
7432
7433 controller._check_replace_universal_player(native)
7434
7435 assert "players/cast_1/name" not in config_store
7436 assert "player_dsp/cast_1" not in config_store
7437 assert "player_queues/cast_1" not in config_store
7438 # nothing migrated, so no config reload is scheduled
7439 assert not any("_reapply_player_config" in repr(t) for t in scheduled_tasks)
7440 assert any("unregister" in repr(t) for t in scheduled_tasks)
7441
7442 def test_replace_keeps_native_name_dsp_and_queue_values(self, mock_mass: MagicMock) -> None:
7443 """Values explicitly set on the native player win over the universal player's."""
7444 config_store: dict[str, Any] = {
7445 "players/cast_1": {"enabled": True, "name": "Kitchen", "default_name": "Soundbar"},
7446 "players/up_old": {
7447 "enabled": True,
7448 "name": "Living Room",
7449 "default_name": "Soundbar (Universal)",
7450 "values": {"hide_in_ui": True},
7451 },
7452 "players/ap_live": {"enabled": True},
7453 "player_dsp/cast_1": {"enabled": False, "filters": ["native"]},
7454 "player_dsp/up_old": {"enabled": True, "filters": ["universal"]},
7455 "player_queues/cast_1": {"queue_id": "cast_1", "values": {"crossfade_duration": 2}},
7456 "player_queues/up_old": {
7457 "queue_id": "up_old",
7458 "values": {"crossfade_duration": 8, "autoplay_mode": "smart"},
7459 },
7460 }
7461 controller, native, _ = self._setup_carry_over_scenario(mock_mass, config_store)
7462
7463 controller._check_replace_universal_player(native)
7464
7465 # the native player's own custom name and DSP config win
7466 assert "players/cast_1/name" not in config_store
7467 assert config_store["players/cast_1"]["name"] == "Kitchen"
7468 assert config_store["player_dsp/cast_1"] == {"enabled": False, "filters": ["native"]}
7469 # queue values merge without overwriting the native queue's own values
7470 assert config_store["player_queues/cast_1"] == {
7471 "queue_id": "cast_1",
7472 "values": {"crossfade_duration": 2, "autoplay_mode": "smart"},
7473 }
7474 assert "player_queues/up_old" not in config_store
7475 # non-conflicting values still migrate
7476 assert config_store["players/cast_1/values/hide_in_ui"] is True
7477
7478 async def test_reapply_player_config_refreshes_native_player(
7479 self, mock_mass: MagicMock
7480 ) -> None:
7481 """Reloading the config applies a migrated custom name to the live player."""
7482 controller = PlayerController(mock_mass)
7483 mock_mass.players = controller
7484 mock_mass.player_queues = MagicMock()
7485 mock_mass.call_later = MagicMock()
7486 provider = MockProvider("cast", mass=mock_mass)
7487 native = MockPlayer(provider, "cast_1", "Soundbar")
7488 native.set_initialized()
7489 controller._players = {"cast_1": native}
7490
7491 new_config = PlayerConfig.parse(
7492 [],
7493 {
7494 "player_id": "cast_1",
7495 "provider": "cast",
7496 "name": "Living Room",
7497 "default_name": "Soundbar",
7498 },
7499 )
7500 mock_mass.config.get_player_config = AsyncMock(return_value=new_config)
7501
7502 await controller._reapply_player_config("cast_1")
7503
7504 assert native.config is new_config
7505 assert native.display_name == "Living Room"
7506 signaled_events = [call.args[0] for call in mock_mass.signal_event.call_args_list]
7507 assert EventType.PLAYER_CONFIG_UPDATED in signaled_events
7508
7509 async def test_replace_repoints_group_memberships(self, mock_mass: MagicMock) -> None:
7510 """Replacement re-points group memberships from the wrapper to the native player."""
7511 config_store: dict[str, Any] = {
7512 "players/cast_1": {"enabled": True},
7513 "players/up_old": {
7514 "enabled": True,
7515 "name": "Soundbar (Universal)",
7516 "default_name": "Soundbar (Universal)",
7517 "values": {"linked_protocol_ids": ["ap_live"]},
7518 },
7519 "players/ap_live": {"enabled": True},
7520 # nested players dict scanned by _update_group_memberships
7521 "players": {
7522 "group_1": {
7523 "enabled": True,
7524 "values": {
7525 "group_members": ["up_old", "other"],
7526 "allowed_members": ["up_old"],
7527 },
7528 },
7529 "group_2": {
7530 "enabled": True,
7531 # native id already listed -> no duplicate after re-point
7532 "values": {"group_members": ["up_old", "cast_1", "extra"]},
7533 },
7534 },
7535 }
7536 controller, native, scheduled_tasks = self._setup_carry_over_scenario(
7537 mock_mass, config_store
7538 )
7539 group_1 = MockPlayer(MockProvider("player_group", mass=mock_mass), "group_1", "Group")
7540 group_1.set_initialized()
7541 controller._players["group_1"] = group_1
7542
7543 with patch.object(group_1, "refresh_state") as mock_refresh:
7544 controller._check_replace_universal_player(native)
7545 for task in [t for t in scheduled_tasks if "_reload_group_members" in repr(t)]:
7546 await task
7547
7548 # the wrapper id is replaced by the native id on both membership keys
7549 assert config_store["players/group_1/values/group_members"] == ["cast_1", "other"]
7550 assert config_store["players/group_1/values/allowed_members"] == ["cast_1"]
7551 # no duplicate when the native id is already a member
7552 assert config_store["players/group_2/values/group_members"] == ["cast_1", "extra"]
7553 # a registered player whose membership changed re-reads it and is refreshed
7554 mock_refresh.assert_called()
7555
7556 async def test_replace_stops_playing_universal_before_unregister(
7557 self, mock_mass: MagicMock
7558 ) -> None:
7559 """A playing universal player is stopped before it is permanently removed."""
7560 config_store: dict[str, Any] = {
7561 "players/cast_1": {"enabled": True},
7562 "players/up_old": {
7563 "enabled": True,
7564 "name": "Soundbar (Universal)",
7565 "default_name": "Soundbar (Universal)",
7566 "values": {"linked_protocol_ids": ["ap_live"]},
7567 },
7568 "players/ap_live": {"enabled": True},
7569 }
7570 controller, native, scheduled_tasks = self._setup_carry_over_scenario(
7571 mock_mass, config_store
7572 )
7573 controller._players["up_old"]._attr_playback_state = PlaybackState.PLAYING
7574
7575 call_order: list[str] = []
7576 mock_mass.player_queues.stop = AsyncMock(
7577 side_effect=lambda *_a, **_k: call_order.append("stop")
7578 )
7579
7580 controller._check_replace_universal_player(native)
7581 task = next(t for t in scheduled_tasks if "unregister" in repr(t))
7582
7583 def _record_unregister(*_a: Any, **_k: Any) -> None:
7584 call_order.append("unregister")
7585
7586 with patch.object(controller, "unregister", new=AsyncMock(side_effect=_record_unregister)):
7587 await task
7588
7589 assert call_order == ["stop", "unregister"]
7590 mock_mass.player_queues.stop.assert_awaited_once_with("up_old")
7591
7592 async def test_replace_hands_the_user_filters_to_the_native_player(
7593 self, mock_mass: MagicMock
7594 ) -> None:
7595 """The replaced wrapper hands its access filter entries to the native player."""
7596 config_store: dict[str, Any] = {
7597 "players/cast_1": {"enabled": True},
7598 "players/up_old": {
7599 "enabled": True,
7600 "name": "Soundbar (Universal)",
7601 "default_name": "Soundbar (Universal)",
7602 "values": {"linked_protocol_ids": ["ap_live"]},
7603 },
7604 "players/ap_live": {"enabled": True},
7605 }
7606 controller, native, scheduled_tasks = self._setup_carry_over_scenario(
7607 mock_mass, config_store
7608 )
7609
7610 controller._check_replace_universal_player(native)
7611
7612 task = next(t for t in scheduled_tasks if "unregister" in repr(t))
7613 with patch.object(controller, "unregister", new=AsyncMock()) as mock_unregister:
7614 await task
7615
7616 mock_unregister.assert_awaited_once_with(
7617 "up_old", permanent=True, replacement_player_id="cast_1"
7618 )
7619
7620 async def test_replace_idle_universal_not_stopped(self, mock_mass: MagicMock) -> None:
7621 """An idle universal player is removed without a stop command."""
7622 config_store: dict[str, Any] = {
7623 "players/cast_1": {"enabled": True},
7624 "players/up_old": {
7625 "enabled": True,
7626 "name": "Soundbar (Universal)",
7627 "default_name": "Soundbar (Universal)",
7628 "values": {"linked_protocol_ids": ["ap_live"]},
7629 },
7630 "players/ap_live": {"enabled": True},
7631 }
7632 controller, native, scheduled_tasks = self._setup_carry_over_scenario(
7633 mock_mass, config_store
7634 )
7635 mock_mass.player_queues.stop = AsyncMock()
7636
7637 controller._check_replace_universal_player(native)
7638 task = next(t for t in scheduled_tasks if "unregister" in repr(t))
7639 await task
7640
7641 mock_mass.player_queues.stop.assert_not_awaited()
7642 assert "up_old" not in controller._players
7643
7644 async def test_provider_discovery_rechecks_registered_native_players(
7645 self, mock_mass: MagicMock
7646 ) -> None:
7647 """A native player that registered before its wrapper was restored is promoted."""
7648 controller = PlayerController(mock_mass)
7649 config_store: dict[str, Any] = {
7650 CONF_PLAYERS: {
7651 "sonos_1": {"enabled": True, "values": {}},
7652 "up_old": {"enabled": True, "provider": "universal_player", "values": {}},
7653 "ap_live": {
7654 "enabled": True,
7655 "provider": "airplay--test",
7656 "player_type": "protocol",
7657 "values": {CONF_PROTOCOL_PARENT_ID: "up_old"},
7658 },
7659 }
7660 }
7661 _wire_nested_config(mock_mass, config_store)
7662 mock_mass.players = controller
7663 mock_mass.player_queues = MagicMock()
7664 mock_mass.create_task = MagicMock()
7665 mock_mass.config.get_player_configs = AsyncMock(return_value=[])
7666
7667 provider = create_mock_universal_provider(mock_mass)
7668 identifiers = {IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"}
7669 universal = _create_universal_player(
7670 mock_mass, "up_old", "Old Universal", ["ap_live"], identifiers=identifiers
7671 )
7672 native = MockPlayer(
7673 MockProvider("sonos", mass=mock_mass), "sonos_1", "Sonos", identifiers=identifiers
7674 )
7675 native.set_initialized()
7676 ap_live = MockPlayer(
7677 MockProvider("airplay", mass=mock_mass),
7678 "ap_live",
7679 "AirPlay",
7680 player_type=PlayerType.PROTOCOL,
7681 identifiers=identifiers,
7682 )
7683 ap_live.set_initialized()
7684 controller._players = {
7685 "up_old": universal,
7686 "sonos_1": native,
7687 "ap_live": ap_live,
7688 }
7689 controller._add_protocol_link(universal, ap_live, "airplay")
7690
7691 await provider.discover_players()
7692
7693 assert ap_live.protocol_parent_id == "sonos_1"
7694 assert native.protocol_parent_id is None
7695
7696 async def test_provider_discovery_does_not_replace_wrapper_with_group(
7697 self, mock_mass: MagicMock
7698 ) -> None:
7699 """A matching group player must never take over a wrapper's protocols."""
7700 controller = PlayerController(mock_mass)
7701 config_store: dict[str, Any] = {
7702 CONF_PLAYERS: {
7703 "up_old": {"enabled": True, "provider": "universal_player", "values": {}},
7704 "ap_owned": {
7705 "enabled": True,
7706 "provider": "airplay--test",
7707 "player_type": "protocol",
7708 "values": {CONF_PROTOCOL_PARENT_ID: "up_old"},
7709 },
7710 }
7711 }
7712 _wire_nested_config(mock_mass, config_store)
7713 mock_mass.players = controller
7714 mock_mass.player_queues = MagicMock()
7715 mock_mass.create_task = MagicMock()
7716 mock_mass.config.get_player_configs = AsyncMock(return_value=[])
7717
7718 provider = create_mock_universal_provider(mock_mass)
7719 identifiers = {IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"}
7720 universal = _create_universal_player(
7721 mock_mass, "up_old", "Old Universal", ["ap_owned"], identifiers=identifiers
7722 )
7723 group = MockPlayer(
7724 MockProvider("sync_group", mass=mock_mass),
7725 "group_1",
7726 "Group",
7727 player_type=PlayerType.GROUP,
7728 identifiers=identifiers,
7729 )
7730 group.set_initialized()
7731 ap_owned = MockPlayer(
7732 MockProvider("airplay", mass=mock_mass),
7733 "ap_owned",
7734 "AirPlay",
7735 player_type=PlayerType.PROTOCOL,
7736 identifiers=identifiers,
7737 )
7738 ap_owned.set_initialized()
7739 controller._players = {"up_old": universal, "group_1": group, "ap_owned": ap_owned}
7740 controller._add_protocol_link(universal, ap_owned, "airplay")
7741
7742 await provider.discover_players()
7743
7744 assert ap_owned.protocol_parent_id == "up_old"
7745 assert not group.linked_output_protocols
7746
7747 async def test_eviction_from_an_emptied_wrapper_does_not_schedule_its_removal(
7748 self, mock_mass: MagicMock
7749 ) -> None:
7750 """
7751 Eviction leaves a wrapper it empties registered, and deliberately so.
7752
7753 `_add_protocol_link` evicts a protocol from every other parent, but tearing
7754 down a parent it empties is not its job: that belongs to the flow that owns
7755 the takeover (`_check_replace_universal_player`, `_merge_universal_players`
7756 or `_unlink_from_protocol_parent`). Scheduling a removal here would even be
7757 harmful, because `mass.create_task` runs with `eager_start=True` and
7758 `PlayerController.unregister` drops the player from `_players` before its
7759 first await: the removal would run synchronously and turn the owning flow's
7760 later `permanent=True` removal into a silent no-op, skipping the config
7761 deletion and the PLAYER_REMOVED event.
7762 """
7763 controller = PlayerController(mock_mass)
7764 config_store: dict[str, Any] = {
7765 CONF_PLAYERS: {
7766 "sonos_1": {"enabled": True, "values": {}},
7767 "up_old": {"enabled": True, "provider": "universal_player", "values": {}},
7768 "ap_live": {
7769 "enabled": True,
7770 "provider": "airplay--test",
7771 "player_type": "protocol",
7772 "values": {CONF_PROTOCOL_PARENT_ID: "up_old"},
7773 },
7774 }
7775 }
7776 _wire_nested_config(mock_mass, config_store)
7777
7778 scheduled_tasks: list[Awaitable[object]] = []
7779
7780 def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
7781 scheduled_tasks.append(task)
7782
7783 mock_mass.players = controller
7784 mock_mass.player_queues = MagicMock()
7785 mock_mass.call_later = MagicMock()
7786 mock_mass.loop = MagicMock()
7787 mock_mass.create_task = MagicMock(side_effect=capture_task)
7788
7789 identifiers = {IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"}
7790 universal = _create_universal_player(
7791 mock_mass, "up_old", "Old Universal", ["ap_live"], identifiers=identifiers
7792 )
7793 native = MockPlayer(
7794 MockProvider("sonos", mass=mock_mass), "sonos_1", "Sonos", identifiers=identifiers
7795 )
7796 native.set_initialized()
7797 ap_live = MockPlayer(
7798 MockProvider("airplay", mass=mock_mass),
7799 "ap_live",
7800 "AirPlay",
7801 player_type=PlayerType.PROTOCOL,
7802 identifiers=identifiers,
7803 )
7804 ap_live.set_initialized()
7805 controller._players = {"up_old": universal, "sonos_1": native, "ap_live": ap_live}
7806
7807 controller._add_protocol_link(universal, ap_live, "airplay")
7808 assert [link.output_protocol_id for link in universal.linked_output_protocols] == [
7809 "ap_live"
7810 ]
7811 scheduled_tasks.clear()
7812
7813 with patch.object(controller, "unregister", new=AsyncMock()) as mock_unregister:
7814 controller._add_protocol_link(native, ap_live, "airplay")
7815 # replay whatever the eviction scheduled, so a missing unregister is what makes
7816 # the assertion below hold and not a task that never got the chance to run
7817 for task in scheduled_tasks:
7818 await task
7819
7820 assert ap_live.protocol_parent_id == "sonos_1"
7821 assert universal.linked_output_protocols == []
7822 assert universal._protocol_player_ids == []
7823 assert "up_old" in controller._players
7824 # removing the emptied wrapper is left to the flow that owns the takeover
7825 assert mock_unregister.call_args_list == []
7826
7827 async def test_wrapper_listing_the_native_id_as_its_protocol_completes_the_takeover(
7828 self, mock_mass: MagicMock
7829 ) -> None:
7830 """
7831 A wrapper that lists the native player's own id is still fully replaced.
7832
7833 A device that keeps its player id across a PROTOCOL -> PLAYER type change stays
7834 one of the wrapper's linked protocols. It cannot be taken over from itself, so it
7835 is left out of the transfer: the wrapper's genuine protocol moves to the native
7836 player, the user's configuration is carried over and the emptied wrapper is
7837 permanently removed.
7838 """
7839 controller = PlayerController(mock_mass)
7840 config_store: dict[str, Any] = {
7841 CONF_PLAYERS: {
7842 "esp_client": {
7843 "enabled": True,
7844 "provider": "sendspin--test",
7845 # left over from before the device changed type
7846 "values": {CONF_PROTOCOL_PARENT_ID: "up_old"},
7847 },
7848 "up_old": {
7849 "enabled": True,
7850 "provider": "universal_player",
7851 "name": "Living Room",
7852 "default_name": "ESP (Universal)",
7853 "values": {"hide_in_ui": True},
7854 },
7855 "ap_live": {
7856 "enabled": True,
7857 "provider": "airplay--test",
7858 "player_type": "protocol",
7859 "values": {CONF_PROTOCOL_PARENT_ID: "up_old"},
7860 },
7861 # a disabled protocol that never registered, so it only exists in config
7862 "dlna_off": {
7863 "enabled": False,
7864 "provider": "dlna--test",
7865 "player_type": "protocol",
7866 "values": {CONF_PROTOCOL_PARENT_ID: "up_old"},
7867 },
7868 }
7869 }
7870 _wire_nested_config(mock_mass, config_store)
7871
7872 scheduled_tasks: list[Awaitable[object]] = []
7873
7874 def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
7875 scheduled_tasks.append(task)
7876
7877 mock_mass.players = controller
7878 mock_mass.player_queues = MagicMock()
7879 # keep the state refreshes below from scheduling an unrelated palette fetch
7880 mock_mass.player_queues.get = MagicMock(return_value=None)
7881 mock_mass.call_later = MagicMock()
7882 mock_mass.loop = MagicMock()
7883 mock_mass.create_task = MagicMock(side_effect=capture_task)
7884 mock_mass.config.get_player_config = AsyncMock(
7885 return_value=PlayerConfig.parse(
7886 [],
7887 {
7888 "player_id": "esp_client",
7889 "provider": "sendspin--test",
7890 "name": "Living Room",
7891 "default_name": "ESP",
7892 },
7893 )
7894 )
7895
7896 identifiers = {IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"}
7897 universal = _create_universal_player(
7898 mock_mass,
7899 "up_old",
7900 "ESP (Universal)",
7901 ["ap_live", "esp_client", "dlna_off"],
7902 identifiers=identifiers,
7903 )
7904 native = MockPlayer(
7905 MockProvider("sendspin", mass=mock_mass), "esp_client", "ESP", identifiers=identifiers
7906 )
7907 native.set_initialized()
7908 ap_live = MockPlayer(
7909 MockProvider("airplay", mass=mock_mass),
7910 "ap_live",
7911 "AirPlay",
7912 player_type=PlayerType.PROTOCOL,
7913 identifiers=identifiers,
7914 )
7915 ap_live.set_initialized()
7916 controller._players = {"up_old": universal, "esp_client": native, "ap_live": ap_live}
7917
7918 controller._add_protocol_link(universal, ap_live, "airplay")
7919 # the wrapper linked the device as a protocol before it changed type
7920 universal.set_linked_output_protocols(
7921 [
7922 *universal.linked_output_protocols,
7923 LinkedOutputProtocol(
7924 output_protocol_id="esp_client", protocol_domain="sendspin", priority=20
7925 ),
7926 ]
7927 )
7928 scheduled_tasks.clear()
7929
7930 with patch.object(controller, "unregister", new=AsyncMock()) as mock_unregister:
7931 controller._check_replace_universal_player(native)
7932 # replay what the takeover scheduled, otherwise the unregister assertion below
7933 # would hold for a task that never got the chance to run
7934 for task in scheduled_tasks:
7935 await task
7936
7937 # the genuine protocol moved over and the wrapper is left without any output
7938 assert ap_live.protocol_parent_id == "esp_client"
7939 assert universal.linked_output_protocols == []
7940 assert universal._protocol_player_ids == []
7941 # the emptied wrapper is permanently removed in favour of the native player
7942 mock_unregister.assert_awaited_once_with(
7943 "up_old", permanent=True, replacement_player_id="esp_client"
7944 )
7945 # and the user's configuration is carried over before that removal deletes it
7946 assert config_store[CONF_PLAYERS]["esp_client"]["name"] == "Living Room"
7947 assert config_store[CONF_PLAYERS]["esp_client"]["values"]["hide_in_ui"] is True
7948 # ownership is handed over in config as well, for the protocol that moved and for
7949 # the one that only exists in config: nothing may keep pointing at the removed wrapper
7950 ap_values = config_store[CONF_PLAYERS]["ap_live"]["values"]
7951 assert ap_values[CONF_PROTOCOL_PARENT_ID] == "esp_client"
7952 dlna_values = config_store[CONF_PLAYERS]["dlna_off"]["values"]
7953 assert dlna_values[CONF_PROTOCOL_PARENT_ID] == "esp_client"
7954 # the native player is never left pointing at the wrapper it replaces
7955 assert native.protocol_parent_id is None
7956 assert config_store[CONF_PLAYERS]["esp_client"]["values"][CONF_PROTOCOL_PARENT_ID] is None
7957
7958 async def test_wrapper_holding_only_the_native_id_completes_the_takeover(
7959 self, mock_mass: MagicMock
7960 ) -> None:
7961 """
7962 A wrapper whose only protocol is the native player itself is cleaned up.
7963
7964 Leaving the native player out of the transfer empties the wrapper without
7965 anything to hand over, which still carries the user's configuration across
7966 and removes the wrapper next to the player that replaces it.
7967 """
7968 controller = PlayerController(mock_mass)
7969 config_store: dict[str, Any] = {
7970 CONF_PLAYERS: {
7971 "esp_client": {"enabled": True, "provider": "sendspin--test", "values": {}},
7972 "up_old": {
7973 "enabled": True,
7974 "provider": "universal_player",
7975 "name": "Living Room",
7976 "default_name": "ESP (Universal)",
7977 "values": {"hide_in_ui": True},
7978 },
7979 }
7980 }
7981 _wire_nested_config(mock_mass, config_store)
7982
7983 scheduled_tasks: list[Awaitable[object]] = []
7984
7985 def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
7986 scheduled_tasks.append(task)
7987
7988 mock_mass.players = controller
7989 mock_mass.player_queues = MagicMock()
7990 mock_mass.player_queues.get = MagicMock(return_value=None)
7991 mock_mass.call_later = MagicMock()
7992 mock_mass.loop = MagicMock()
7993 mock_mass.create_task = MagicMock(side_effect=capture_task)
7994 mock_mass.config.get_player_config = AsyncMock(
7995 return_value=PlayerConfig.parse(
7996 [],
7997 {
7998 "player_id": "esp_client",
7999 "provider": "sendspin--test",
8000 "name": "Living Room",
8001 "default_name": "ESP",
8002 },
8003 )
8004 )
8005
8006 universal = _create_universal_player(mock_mass, "up_old", "ESP (Universal)", ["esp_client"])
8007 universal.set_linked_output_protocols(
8008 [
8009 LinkedOutputProtocol(
8010 output_protocol_id="esp_client", protocol_domain="sendspin", priority=20
8011 )
8012 ]
8013 )
8014 native = MockPlayer(MockProvider("sendspin", mass=mock_mass), "esp_client", "ESP")
8015 native.set_initialized()
8016 controller._players = {"up_old": universal, "esp_client": native}
8017 scheduled_tasks.clear()
8018
8019 with patch.object(controller, "unregister", new=AsyncMock()) as mock_unregister:
8020 controller._check_replace_universal_player(native)
8021 for task in scheduled_tasks:
8022 await task
8023
8024 assert universal.linked_output_protocols == []
8025 assert universal._protocol_player_ids == []
8026 mock_unregister.assert_awaited_once_with(
8027 "up_old", permanent=True, replacement_player_id="esp_client"
8028 )
8029 assert config_store[CONF_PLAYERS]["esp_client"]["name"] == "Living Room"
8030 assert config_store[CONF_PLAYERS]["esp_client"]["values"]["hide_in_ui"] is True
8031 assert native.protocol_parent_id is None
8032
8033 async def test_wrapper_with_a_bridge_riding_on_the_native_completes_the_takeover(
8034 self, mock_mass: MagicMock
8035 ) -> None:
8036 """
8037 A protocol riding on the native player's own id moves along with the takeover.
8038
8039 The wrapper lists the device that kept its id across a type change together with
8040 the bridge riding on it, so both sit in one group with the device as its base.
8041 Leaving the device out of the transfer must not hold the bridge back: the bridge
8042 moves over as a protocol derived from the native player itself.
8043 """
8044 controller = PlayerController(mock_mass)
8045 config_store: dict[str, Any] = {
8046 CONF_PLAYERS: {
8047 "cast_base": {"enabled": True, "provider": "chromecast--test", "values": {}},
8048 "up_old": {
8049 "enabled": True,
8050 "provider": "universal_player",
8051 "name": "Kitchen",
8052 "default_name": "Speaker (Universal)",
8053 "values": {"hide_in_ui": True},
8054 },
8055 "spb_bridge": {
8056 "enabled": True,
8057 "provider": "sendspin--test",
8058 "player_type": "protocol",
8059 "values": {
8060 CONF_PROTOCOL_PARENT_ID: "up_old",
8061 CONF_UNDERLYING_PLAYER_ID: "cast_base",
8062 },
8063 },
8064 }
8065 }
8066 _wire_nested_config(mock_mass, config_store)
8067
8068 scheduled_tasks: list[Awaitable[object]] = []
8069
8070 def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
8071 scheduled_tasks.append(task)
8072
8073 mock_mass.players = controller
8074 mock_mass.player_queues = MagicMock()
8075 # keep the state refreshes below from scheduling an unrelated palette fetch
8076 mock_mass.player_queues.get = MagicMock(return_value=None)
8077 mock_mass.call_later = MagicMock()
8078 mock_mass.loop = MagicMock()
8079 mock_mass.create_task = MagicMock(side_effect=capture_task)
8080 mock_mass.config.get_player_config = AsyncMock(
8081 return_value=PlayerConfig.parse(
8082 [],
8083 {
8084 "player_id": "cast_base",
8085 "provider": "chromecast--test",
8086 "name": "Kitchen",
8087 "default_name": "Speaker",
8088 },
8089 )
8090 )
8091
8092 identifiers = {IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"}
8093 universal = _create_universal_player(
8094 mock_mass,
8095 "up_old",
8096 "Speaker (Universal)",
8097 ["cast_base", "spb_bridge"],
8098 identifiers=identifiers,
8099 )
8100 native = MockPlayer(
8101 MockProvider("chromecast", mass=mock_mass),
8102 "cast_base",
8103 "Speaker",
8104 identifiers=identifiers,
8105 )
8106 native.set_initialized()
8107 bridge = MockPlayer(
8108 MockProvider("sendspin", mass=mock_mass),
8109 "spb_bridge",
8110 "Speaker (Bridge)",
8111 player_type=PlayerType.PROTOCOL,
8112 )
8113 bridge._attr_underlying_player_id = "cast_base"
8114 bridge.set_initialized()
8115 controller._players = {"up_old": universal, "cast_base": native, "spb_bridge": bridge}
8116
8117 controller._add_protocol_link(universal, bridge, "sendspin")
8118 # the wrapper linked the device as a protocol before it changed type, which makes
8119 # it the base of the group the bridge rides on
8120 universal.set_linked_output_protocols(
8121 [
8122 *universal.linked_output_protocols,
8123 LinkedOutputProtocol(
8124 output_protocol_id="cast_base", protocol_domain="chromecast", priority=30
8125 ),
8126 ]
8127 )
8128 scheduled_tasks.clear()
8129
8130 with patch.object(controller, "unregister", new=AsyncMock()) as mock_unregister:
8131 controller._check_replace_universal_player(native)
8132 # replay what the takeover scheduled, otherwise the unregister assertion below
8133 # would hold for a task that never got the chance to run
8134 for task in scheduled_tasks:
8135 await task
8136
8137 assert bridge.protocol_parent_id == "cast_base"
8138 # the bridge now rides on the parent player itself, recorded as "native"
8139 bridge_link = next(
8140 link
8141 for link in native.linked_output_protocols
8142 if link.output_protocol_id == "spb_bridge"
8143 )
8144 assert bridge_link.derived_from == "native"
8145 # the native player is never left pointing at the wrapper it replaces
8146 assert native.protocol_parent_id is None
8147 # the wrapper is left without any output and permanently removed
8148 assert universal.linked_output_protocols == []
8149 assert universal._protocol_player_ids == []
8150 mock_unregister.assert_awaited_once_with(
8151 "up_old", permanent=True, replacement_player_id="cast_base"
8152 )
8153
8154
8155class TestEndToEndDuplicateProtocol:
8156 """
8157 End-to-end integration tests for duplicate protocol → separate universal player flow.
8158
8159 These tests exercise the full async flow through _delayed_protocol_evaluation,
8160 ensure_universal_players_for_protocols, and _create_separate_universal_player,
8161 verifying that a second instance of the same protocol domain on the same host
8162 results in a separate universal player.
8163 """
8164
8165 @staticmethod
8166 def _setup_e2e_controller(
8167 mock_mass: MagicMock,
8168 ) -> tuple[PlayerController, UniversalPlayerProvider]:
8169 """
8170 Set up a PlayerController and UniversalPlayerProvider wired for E2E testing.
8171
8172 The mock_mass is configured so that:
8173 - get_providers(ProviderType.PLAYER) returns the universal provider
8174 - register_or_update adds the player to the controller's _players dict
8175 - config.create_default_player_config does not fail
8176 - config.set does not fail
8177
8178 :return: Tuple of (controller, universal_provider).
8179 """
8180 controller = PlayerController(mock_mass)
8181 up_provider = create_mock_universal_provider(mock_mass)
8182
8183 # Wire get_providers to return our universal provider
8184 mock_mass.get_providers = MagicMock(return_value=[up_provider])
8185
8186 # Wire register_or_update to add the player to controller._players
8187 async def fake_register_or_update(player: Player) -> None:
8188 controller._players[player.player_id] = player
8189 player.set_initialized()
8190
8191 mock_mass.players = MagicMock()
8192 mock_mass.players.register_or_update = AsyncMock(side_effect=fake_register_or_update)
8193 mock_mass.players.get_player = lambda pid: controller._players.get(pid)
8194
8195 # Wire config methods
8196 mock_mass.config.create_default_player_config = MagicMock()
8197 mock_mass.config.set = MagicMock()
8198 mock_mass.config.get_base_player_config.return_value = create_mock_config("Test")
8199
8200 return controller, up_provider
8201
8202 @pytest.mark.asyncio
8203 async def test_path_a_delayed_eval_creates_separate_universal_for_duplicate(
8204 self, mock_mass: MagicMock
8205 ) -> None:
8206 """
8207 Path A: _delayed_protocol_evaluation creates separate universal player for duplicate.
8208
8209 Scenario: Two AirPlay instances register on the same host.
8210 1. First AirPlay links to existing universal player
8211 via _add_protocol_to_existing_universal
8212 2. Second AirPlay arrives at _delayed_protocol_evaluation
8213 3. It finds the existing universal player but is refused (domain duplicate)
8214 4. Falls through to _find_matching_protocol_players (which skips same-domain)
8215 5. Creates a NEW separate universal player via _create_or_update_universal_player
8216 """
8217 controller, _ = self._setup_e2e_controller(mock_mass)
8218
8219 # Create existing universal player with AirPlay already linked
8220 universal = _create_universal_player(
8221 mock_mass,
8222 "up_aabbccddeeff",
8223 "Living Room Speaker",
8224 protocol_player_ids=["ap_1"],
8225 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8226 )
8227
8228 # Create first AirPlay (already linked to universal)
8229 ap_provider = MockProvider("airplay", mass=mock_mass)
8230 ap1 = MockPlayer(
8231 ap_provider,
8232 "ap_1",
8233 "AirPlay Instance 1",
8234 player_type=PlayerType.PROTOCOL,
8235 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8236 )
8237 ap1.set_initialized()
8238 controller._add_protocol_link(universal, ap1, "airplay")
8239 assert ap1.protocol_parent_id == "up_aabbccddeeff"
8240
8241 # Create second AirPlay (same MAC, not yet linked)
8242 ap2 = MockPlayer(
8243 ap_provider,
8244 "ap_2",
8245 "AirPlay Instance 2",
8246 player_type=PlayerType.PROTOCOL,
8247 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8248 )
8249 ap2.set_initialized()
8250
8251 controller._players = {
8252 "up_aabbccddeeff": universal,
8253 "ap_1": ap1,
8254 "ap_2": ap2,
8255 }
8256
8257 # Run delayed evaluation for ap2 - full async flow
8258 await controller._delayed_protocol_evaluation("ap_2")
8259
8260 # ap2 should NOT be linked to the existing universal player
8261 assert ap2.protocol_parent_id != "up_aabbccddeeff"
8262
8263 # ap2 should have its own (freshly minted) universal player
8264 assert ap2.protocol_parent_id is not None
8265 separate_up_id = ap2.protocol_parent_id
8266 assert separate_up_id in controller._players
8267 separate_up = controller._players[separate_up_id]
8268 assert isinstance(separate_up, UniversalPlayer)
8269
8270 # The original universal player should still have only ap_1
8271 original_ap_links = [
8272 link for link in universal.linked_output_protocols if link.protocol_domain == "airplay"
8273 ]
8274 assert len(original_ap_links) == 1
8275 assert original_ap_links[0].output_protocol_id == "ap_1"
8276
8277 @pytest.mark.asyncio
8278 async def test_path_a_delayed_eval_joins_existing_universal_different_domain(
8279 self, mock_mass: MagicMock
8280 ) -> None:
8281 """
8282 Path A: _delayed_protocol_evaluation correctly joins when domain is different.
8283
8284 Scenario: AirPlay universal player exists, DLNA arrives via delayed eval.
8285 DLNA should successfully join the existing universal player (different domain).
8286 """
8287 controller, _ = self._setup_e2e_controller(mock_mass)
8288
8289 # Create existing universal player with AirPlay linked
8290 universal = _create_universal_player(
8291 mock_mass,
8292 "up_aabbccddeeff",
8293 "Living Room Speaker",
8294 protocol_player_ids=["ap_1"],
8295 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8296 )
8297
8298 ap_provider = MockProvider("airplay", mass=mock_mass)
8299 ap1 = MockPlayer(
8300 ap_provider,
8301 "ap_1",
8302 "AirPlay",
8303 player_type=PlayerType.PROTOCOL,
8304 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8305 )
8306 ap1.set_initialized()
8307 controller._add_protocol_link(universal, ap1, "airplay")
8308
8309 # Create DLNA player (same device, different protocol)
8310 dlna_provider = MockProvider("dlna", mass=mock_mass)
8311 dlna1 = MockPlayer(
8312 dlna_provider,
8313 "dlna_1",
8314 "DLNA",
8315 player_type=PlayerType.PROTOCOL,
8316 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8317 )
8318 dlna1.set_initialized()
8319
8320 controller._players = {
8321 "up_aabbccddeeff": universal,
8322 "ap_1": ap1,
8323 "dlna_1": dlna1,
8324 }
8325
8326 # Run delayed evaluation for DLNA
8327 await controller._delayed_protocol_evaluation("dlna_1")
8328
8329 # DLNA should join the existing universal player
8330 assert dlna1.protocol_parent_id == "up_aabbccddeeff"
8331
8332 # Universal player should now have both AirPlay and DLNA
8333 domains = {link.protocol_domain for link in universal.linked_output_protocols}
8334 assert "airplay" in domains
8335 assert "dlna" in domains
8336
8337 @pytest.mark.asyncio
8338 async def test_path_b_ensure_universal_separates_duplicate_domain(
8339 self, mock_mass: MagicMock
8340 ) -> None:
8341 """
8342 Path B: ensure_universal_players_for_protocols separates can_join vs rejected.
8343
8344 Scenario: Two AirPlay players arrive simultaneously for delayed eval.
8345 Both are unlinked, both pass to _create_or_update_universal_player.
8346 The first creates a universal player. The second finds the existing one
8347 and is rejected (duplicate domain), getting its own separate universal player.
8348 """
8349 controller, _ = self._setup_e2e_controller(mock_mass)
8350
8351 ap_provider = MockProvider("airplay", mass=mock_mass)
8352 ap1 = MockPlayer(
8353 ap_provider,
8354 "ap_1",
8355 "AirPlay Instance 1",
8356 player_type=PlayerType.PROTOCOL,
8357 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8358 )
8359 ap1.set_initialized()
8360
8361 ap2 = MockPlayer(
8362 ap_provider,
8363 "ap_2",
8364 "AirPlay Instance 2",
8365 player_type=PlayerType.PROTOCOL,
8366 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8367 )
8368 ap2.set_initialized()
8369
8370 controller._players = {
8371 "ap_1": ap1,
8372 "ap_2": ap2,
8373 }
8374
8375 # First: ap1 goes through delayed evaluation, creates a universal player
8376 await controller._delayed_protocol_evaluation("ap_1")
8377
8378 # ap1 should have a universal player parent
8379 assert ap1.protocol_parent_id is not None
8380 first_up_id = ap1.protocol_parent_id
8381 assert first_up_id in controller._players
8382
8383 # Second: ap2 goes through delayed evaluation
8384 # _find_matching_universal_player will find the universal player by MAC
8385 # _add_protocol_to_existing_universal will refuse (duplicate domain)
8386 # _find_matching_protocol_players will skip ap1 (same domain)
8387 # _create_or_update_universal_player → ensure_universal_players_for_protocols
8388 # finds existing universal player, separates ap2 as rejected
8389 await controller._delayed_protocol_evaluation("ap_2")
8390
8391 # ap2 should have its own separate universal player
8392 assert ap2.protocol_parent_id is not None
8393 second_up_id = ap2.protocol_parent_id
8394 assert second_up_id != first_up_id
8395 assert second_up_id in controller._players
8396
8397 # Both universal players should exist
8398 first_up = controller._players[first_up_id]
8399 second_up = controller._players[second_up_id]
8400 assert isinstance(first_up, UniversalPlayer)
8401 assert isinstance(second_up, UniversalPlayer)
8402
8403 @pytest.mark.asyncio
8404 async def test_path_b_first_creates_then_second_gets_separate(
8405 self, mock_mass: MagicMock
8406 ) -> None:
8407 """
8408 Path B: First protocol player creates universal, second gets separate.
8409
8410 Scenario: AirPlay and DLNA create a universal player together.
8411 Then a second AirPlay arrives and gets its own separate universal player.
8412 """
8413 controller, _ = self._setup_e2e_controller(mock_mass)
8414
8415 ap_provider = MockProvider("airplay", mass=mock_mass)
8416 dlna_provider = MockProvider("dlna", mass=mock_mass)
8417
8418 ap1 = MockPlayer(
8419 ap_provider,
8420 "ap_1",
8421 "AirPlay",
8422 player_type=PlayerType.PROTOCOL,
8423 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8424 )
8425 ap1.set_initialized()
8426
8427 dlna1 = MockPlayer(
8428 dlna_provider,
8429 "dlna_1",
8430 "DLNA",
8431 player_type=PlayerType.PROTOCOL,
8432 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8433 )
8434 dlna1.set_initialized()
8435
8436 ap2 = MockPlayer(
8437 ap_provider,
8438 "ap_2",
8439 "AirPlay 2",
8440 player_type=PlayerType.PROTOCOL,
8441 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8442 )
8443 ap2.set_initialized()
8444
8445 controller._players = {
8446 "ap_1": ap1,
8447 "dlna_1": dlna1,
8448 "ap_2": ap2,
8449 }
8450
8451 # First: ap1 arrives at delayed eval, finds dlna1 as matching (different domain)
8452 # Together they create a universal player
8453 await controller._delayed_protocol_evaluation("ap_1")
8454
8455 assert ap1.protocol_parent_id is not None
8456 # dlna1 should also be linked (found by _find_matching_protocol_players)
8457 assert dlna1.protocol_parent_id == ap1.protocol_parent_id
8458 shared_up_id = ap1.protocol_parent_id
8459
8460 # Second: ap2 arrives at delayed eval
8461 await controller._delayed_protocol_evaluation("ap_2")
8462
8463 # ap2 should get its own universal player
8464 assert ap2.protocol_parent_id is not None
8465 assert ap2.protocol_parent_id != shared_up_id
8466 assert ap2.protocol_parent_id in controller._players
8467
8468 # Shared universal player should have AirPlay + DLNA
8469 shared_up = controller._players[shared_up_id]
8470 shared_domains = {link.protocol_domain for link in shared_up.linked_output_protocols}
8471 assert "airplay" in shared_domains
8472 assert "dlna" in shared_domains
8473
8474 @pytest.mark.asyncio
8475 async def test_path_c_sendspin_duplicate_gets_separate_universal(
8476 self, mock_mass: MagicMock
8477 ) -> None:
8478 """
8479 Path C: Two Sendspin bridge instances on same host get separate universal players.
8480
8481 Sendspin bridges match via CAST_UUID / AIRPLAY_ID, not MAC. Two bridges
8482 on the same host share the same MAC (from ARP) but have different CAST_UUIDs.
8483 Each should get its own universal player.
8484 """
8485 controller, _ = self._setup_e2e_controller(mock_mass)
8486
8487 sendspin_provider = MockProvider("sendspin", mass=mock_mass)
8488
8489 # First Sendspin bridge
8490 spb1 = MockPlayer(
8491 sendspin_provider,
8492 "spb_1",
8493 "Sendspin Bridge 1",
8494 player_type=PlayerType.PROTOCOL,
8495 identifiers={
8496 IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
8497 IdentifierType.CAST_UUID: "cast-uuid-111",
8498 },
8499 )
8500 spb1.set_initialized()
8501
8502 # Second Sendspin bridge (same MAC, different CAST_UUID)
8503 spb2 = MockPlayer(
8504 sendspin_provider,
8505 "spb_2",
8506 "Sendspin Bridge 2",
8507 player_type=PlayerType.PROTOCOL,
8508 identifiers={
8509 IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
8510 IdentifierType.CAST_UUID: "cast-uuid-222",
8511 },
8512 )
8513 spb2.set_initialized()
8514
8515 controller._players = {
8516 "spb_1": spb1,
8517 "spb_2": spb2,
8518 }
8519
8520 # First: spb1 goes through delayed evaluation
8521 await controller._delayed_protocol_evaluation("spb_1")
8522
8523 assert spb1.protocol_parent_id is not None
8524 first_up_id = spb1.protocol_parent_id
8525
8526 # Second: spb2 goes through delayed evaluation
8527 # _find_matching_protocol_players skips spb1 (same domain: "sendspin")
8528 # _find_matching_universal_player may find the first universal player by MAC
8529 # _add_protocol_to_existing_universal refuses (duplicate domain: "sendspin")
8530 # Falls through to create separate universal player
8531 await controller._delayed_protocol_evaluation("spb_2")
8532
8533 assert spb2.protocol_parent_id is not None
8534 second_up_id = spb2.protocol_parent_id
8535
8536 # Each should have its own universal player
8537 assert first_up_id != second_up_id
8538 assert first_up_id in controller._players
8539 assert second_up_id in controller._players
8540
8541 first_up = controller._players[first_up_id]
8542 second_up = controller._players[second_up_id]
8543 assert isinstance(first_up, UniversalPlayer)
8544 assert isinstance(second_up, UniversalPlayer)
8545
8546 @pytest.mark.asyncio
8547 async def test_three_airplay_instances_get_separate_universals(
8548 self, mock_mass: MagicMock
8549 ) -> None:
8550 """
8551 Test that three AirPlay instances each get their own universal player.
8552
8553 Exercises the flow when there are 3+ instances of the same protocol.
8554 """
8555 controller, _ = self._setup_e2e_controller(mock_mass)
8556
8557 ap_provider = MockProvider("airplay", mass=mock_mass)
8558 players = []
8559 for i in range(1, 4):
8560 p = MockPlayer(
8561 ap_provider,
8562 f"ap_{i}",
8563 f"AirPlay Instance {i}",
8564 player_type=PlayerType.PROTOCOL,
8565 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8566 )
8567 p.set_initialized()
8568 players.append(p)
8569
8570 controller._players = {p.player_id: p for p in players}
8571
8572 # Run delayed evaluation for each in sequence
8573 for p in players:
8574 await controller._delayed_protocol_evaluation(p.player_id)
8575
8576 # Each should have its own universal player
8577 parent_ids = {p.protocol_parent_id for p in players}
8578 assert None not in parent_ids
8579 # All three should have distinct parents
8580 assert len(parent_ids) == 3
8581
8582 # All three universal players should exist
8583 for pid in parent_ids:
8584 assert pid in controller._players
8585 assert isinstance(controller._players[pid], UniversalPlayer)
8586
8587 @pytest.mark.asyncio
8588 async def test_same_domain_same_ip_get_separate_universals(self, mock_mass: MagicMock) -> None:
8589 """
8590 Test that multiple instances of the same protocol on the same host stay separate.
8591
8592 Scenario: 3 shairport-sync AirPlay instances on the same Raspberry Pi.
8593 All share the same IP and have locally-administered MACs.
8594 The IP fallback must NOT merge them — domain dedup takes precedence.
8595 """
8596 controller, _ = self._setup_e2e_controller(mock_mass)
8597
8598 ap_provider = MockProvider("airplay", mass=mock_mass)
8599 players = []
8600 for i in range(1, 4):
8601 p = MockPlayer(
8602 ap_provider,
8603 f"ap_{i}",
8604 f"Shairport Zone {i}",
8605 player_type=PlayerType.PROTOCOL,
8606 identifiers={
8607 # Each has a distinct LA MAC (as shairport-sync generates)
8608 IdentifierType.MAC_ADDRESS: f"02:AA:BB:CC:DD:{i:02X}",
8609 IdentifierType.IP_ADDRESS: "192.168.1.10",
8610 },
8611 )
8612 p.set_initialized()
8613 players.append(p)
8614
8615 controller._players = {p.player_id: p for p in players}
8616
8617 for p in players:
8618 await controller._delayed_protocol_evaluation(p.player_id)
8619
8620 # Each should have its own universal player (NOT merged)
8621 parent_ids = {p.protocol_parent_id for p in players}
8622 assert None not in parent_ids
8623 assert len(parent_ids) == 3
8624
8625 @pytest.mark.asyncio
8626 async def test_same_domain_same_mac_get_separate_universals(self, mock_mass: MagicMock) -> None:
8627 """
8628 Test that two snapcast instances with the same ARP-resolved MAC stay separate.
8629
8630 Scenario: Two snapcast clients on the same Mac mini. Both have distinct
8631 locally-administered MACs but ARP resolves both to the same real hardware MAC.
8632 They should each get their own universal player, not be merged.
8633 """
8634 controller, _ = self._setup_e2e_controller(mock_mass)
8635
8636 snap_provider = MockProvider("snapcast", mass=mock_mass)
8637 snap1 = MockPlayer(
8638 snap_provider,
8639 "ma_cea0b4ed2221",
8640 "Mac-mini-15269.local",
8641 player_type=PlayerType.PROTOCOL,
8642 identifiers={
8643 # ARP-enriched: both resolve to same real MAC
8644 IdentifierType.MAC_ADDRESS: "D0:11:E5:00:67:2F",
8645 IdentifierType.IP_ADDRESS: "192.168.1.42",
8646 },
8647 )
8648 snap2 = MockPlayer(
8649 snap_provider,
8650 "ma_cea0b4ed2220",
8651 "Mac-mini-63197.local",
8652 player_type=PlayerType.PROTOCOL,
8653 identifiers={
8654 IdentifierType.MAC_ADDRESS: "D0:11:E5:00:67:2F",
8655 IdentifierType.IP_ADDRESS: "192.168.1.42",
8656 },
8657 )
8658 snap1._attr_available = False
8659 snap1._cache.clear()
8660 snap1.update_state(signal_event=False)
8661 snap2._attr_available = False
8662 snap2._cache.clear()
8663 snap2.update_state(signal_event=False)
8664 snap1.set_initialized()
8665 snap2.set_initialized()
8666
8667 controller._players = {"ma_cea0b4ed2221": snap1, "ma_cea0b4ed2220": snap2}
8668
8669 await controller._delayed_protocol_evaluation("ma_cea0b4ed2221")
8670 await controller._delayed_protocol_evaluation("ma_cea0b4ed2220")
8671
8672 # Each should have its own universal player
8673 assert snap1.protocol_parent_id is not None
8674 assert snap2.protocol_parent_id is not None
8675 assert snap1.protocol_parent_id != snap2.protocol_parent_id
8676
8677 @pytest.mark.asyncio
8678 async def test_mixed_protocols_plus_duplicate_creates_correct_topology(
8679 self, mock_mass: MagicMock
8680 ) -> None:
8681 """
8682 Test that mixed protocols + duplicate create correct player topology.
8683
8684 Scenario:
8685 - Device has AirPlay, DLNA, and Chromecast protocols
8686 - Plus a second AirPlay instance (duplicate)
8687 Result:
8688 - One universal player with AirPlay + DLNA + Chromecast
8689 - One separate universal player for the second AirPlay
8690 """
8691 controller, _ = self._setup_e2e_controller(mock_mass)
8692
8693 ap_provider = MockProvider("airplay", mass=mock_mass)
8694 dlna_provider = MockProvider("dlna", mass=mock_mass)
8695 cc_provider = MockProvider("chromecast", mass=mock_mass)
8696
8697 ap1 = MockPlayer(
8698 ap_provider,
8699 "ap_1",
8700 "AirPlay",
8701 player_type=PlayerType.PROTOCOL,
8702 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8703 )
8704 ap1.set_initialized()
8705
8706 dlna1 = MockPlayer(
8707 dlna_provider,
8708 "dlna_1",
8709 "DLNA",
8710 player_type=PlayerType.PROTOCOL,
8711 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8712 )
8713 dlna1.set_initialized()
8714
8715 cc1 = MockPlayer(
8716 cc_provider,
8717 "cc_1",
8718 "Chromecast",
8719 player_type=PlayerType.PROTOCOL,
8720 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8721 )
8722 cc1.set_initialized()
8723
8724 ap2 = MockPlayer(
8725 ap_provider,
8726 "ap_2",
8727 "AirPlay Duplicate",
8728 player_type=PlayerType.PROTOCOL,
8729 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8730 )
8731 ap2.set_initialized()
8732
8733 controller._players = {
8734 "ap_1": ap1,
8735 "dlna_1": dlna1,
8736 "cc_1": cc1,
8737 "ap_2": ap2,
8738 }
8739
8740 # ap1 goes through delayed eval - finds dlna1 and cc1 as matching
8741 # (different domains), creates universal player with all three
8742 await controller._delayed_protocol_evaluation("ap_1")
8743
8744 assert ap1.protocol_parent_id is not None
8745 assert dlna1.protocol_parent_id == ap1.protocol_parent_id
8746 assert cc1.protocol_parent_id == ap1.protocol_parent_id
8747 shared_up_id = ap1.protocol_parent_id
8748
8749 # ap2 goes through delayed eval - finds universal player but is rejected
8750 await controller._delayed_protocol_evaluation("ap_2")
8751
8752 assert ap2.protocol_parent_id is not None
8753 assert ap2.protocol_parent_id != shared_up_id
8754
8755 # Shared universal player should have 3 protocols
8756 shared_up = controller._players[shared_up_id]
8757 shared_domains = {link.protocol_domain for link in shared_up.linked_output_protocols}
8758 assert shared_domains == {"airplay", "dlna", "chromecast"}
8759
8760
8761class TestEndToEndUniversalPlayerId:
8762 """
8763 End-to-end tests for the minted player id of a universal player.
8764
8765 Regression tests for music-assistant/support#5888: the id used to be derived from
8766 whichever device identifiers happened to be available when the wrapper was created,
8767 so a re-created wrapper could silently change id and orphan the entities API
8768 consumers (such as the Home Assistant integration) bind to it. These tests drive
8769 the real evaluation path against a config store that persists what the flow writes.
8770 """
8771
8772 @staticmethod
8773 def _setup_controller(
8774 mock_mass: MagicMock,
8775 ) -> tuple[PlayerController, dict[str, Any]]:
8776 """
8777 Wire a controller, the universal player provider and a persisting config store.
8778
8779 :param mock_mass: The mocked MusicAssistant instance to wire.
8780 :return: Tuple of (controller, backing config store).
8781 """
8782 controller = PlayerController(mock_mass)
8783 up_provider = create_mock_universal_provider(mock_mass)
8784 mock_mass.get_providers = MagicMock(return_value=[up_provider])
8785
8786 store: dict[str, Any] = {CONF_PLAYERS: {}}
8787 _wire_nested_config(mock_mass, store)
8788 mock_mass.config.create_default_player_config = MagicMock(
8789 side_effect=lambda *args, **kwargs: _fake_create_default_player_config(
8790 store, *args, **kwargs
8791 )
8792 )
8793 mock_mass.config.get_base_player_config.return_value = create_mock_config("Test")
8794
8795 async def fake_register_or_update(player: Player) -> None:
8796 controller._players[player.player_id] = player
8797 player.set_initialized()
8798
8799 mock_mass.players = MagicMock()
8800 mock_mass.players.register_or_update = AsyncMock(side_effect=fake_register_or_update)
8801 mock_mass.players.get_player = lambda pid, *_args, **_kwargs: controller._players.get(pid)
8802 return controller, store
8803
8804 @staticmethod
8805 def _make_airplay_player(
8806 mock_mass: MagicMock, player_id: str, name: str, identifiers: dict[IdentifierType, str]
8807 ) -> MockPlayer:
8808 """Return an unlinked AirPlay protocol player."""
8809 player = MockPlayer(
8810 MockProvider("airplay", mass=mock_mass),
8811 player_id,
8812 name,
8813 player_type=PlayerType.PROTOCOL,
8814 identifiers=identifiers,
8815 )
8816 player.set_initialized()
8817 return player
8818
8819 async def test_id_survives_recreation_with_other_identifiers(
8820 self, mock_mass: MagicMock
8821 ) -> None:
8822 """
8823 A re-created wrapper keeps its id when a different set of identifiers is known.
8824
8825 The first evaluation only knows the device's MAC, the second only its UUID.
8826 The derived key changed along with that, taking the player id with it.
8827 """
8828 controller, store = self._setup_controller(mock_mass)
8829 first = self._make_airplay_player(
8830 mock_mass, "ap_1", "Living Room", {IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"}
8831 )
8832 controller._players = {"ap_1": first}
8833
8834 await controller._delayed_protocol_evaluation("ap_1")
8835
8836 universal_id = first.protocol_parent_id
8837 assert universal_id is not None
8838 assert re.fullmatch(r"up[0-9a-f]{8}", universal_id)
8839
8840 # tear the wrapper down (its config is kept, as a non-permanent unregister does)
8841 # and bring the device back with only a UUID to identify it by
8842 del controller._players[universal_id]
8843 second = self._make_airplay_player(
8844 mock_mass,
8845 "ap_1",
8846 "Living Room",
8847 {IdentifierType.UUID: "12345678-1234-1234-1234-123456789abc"},
8848 )
8849 controller._players = {"ap_1": second}
8850
8851 await controller._delayed_protocol_evaluation("ap_1")
8852
8853 assert second.protocol_parent_id == universal_id
8854 assert universal_id in controller._players
8855 assert store[CONF_PLAYERS]["ap_1"]["values"][CONF_PROTOCOL_PARENT_ID] == universal_id
8856
8857 async def test_wrappers_created_in_one_second_still_order(self, mock_mass: MagicMock) -> None:
8858 """
8859 Two wrappers created back to back get distinct creation timestamps.
8860
8861 A merge between them must be able to keep the older one, which it cannot do
8862 when both carry the same timestamp and the tiebreak falls back to the id.
8863 """
8864 controller, store = self._setup_controller(mock_mass)
8865 players = [
8866 self._make_airplay_player(
8867 mock_mass, f"ap_{index}", f"Speaker {index}", {IdentifierType.MAC_ADDRESS: mac}
8868 )
8869 for index, mac in enumerate(("54:78:C9:E6:0D:A0", "AA:BB:CC:DD:EE:FF"), start=1)
8870 ]
8871 controller._players = {player.player_id: player for player in players}
8872
8873 for player in players:
8874 await controller._delayed_protocol_evaluation(player.player_id)
8875
8876 created = [
8877 store[CONF_PLAYERS][player.protocol_parent_id]["values"][CONF_CREATED_AT]
8878 for player in players
8879 ]
8880 assert all(isinstance(value, int) for value in created)
8881 assert created[0] < created[1]
8882
8883 async def test_new_devices_get_distinct_minted_ids(self, mock_mass: MagicMock) -> None:
8884 """Devices that were never wrapped before each get their own minted id."""
8885 controller, _ = self._setup_controller(mock_mass)
8886 players = [
8887 self._make_airplay_player(
8888 mock_mass, f"ap_{index}", f"Speaker {index}", {IdentifierType.MAC_ADDRESS: mac}
8889 )
8890 for index, mac in enumerate(("54:78:C9:E6:0D:A0", "AA:BB:CC:DD:EE:FF"), start=1)
8891 ]
8892 controller._players = {player.player_id: player for player in players}
8893
8894 for player in players:
8895 await controller._delayed_protocol_evaluation(player.player_id)
8896
8897 universal_ids = [player.protocol_parent_id for player in players]
8898 assert all(pid and re.fullmatch(r"up[0-9a-f]{8}", pid) for pid in universal_ids)
8899 assert len(set(universal_ids)) == len(players)
8900
8901 async def test_duplicate_domain_gets_a_second_wrapper_with_a_parent_link(
8902 self, mock_mass: MagicMock
8903 ) -> None:
8904 """
8905 Two players of one protocol domain end up on two wrappers, both linked.
8906
8907 The handoff must persist a parent link for the second player as well,
8908 otherwise it silently stays unwrapped.
8909 """
8910 controller, store = self._setup_controller(mock_mass)
8911 identifiers = {IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"}
8912 ap1 = self._make_airplay_player(mock_mass, "ap_1", "Zone 1", identifiers)
8913 ap2 = self._make_airplay_player(mock_mass, "ap_2", "Zone 2", identifiers)
8914 controller._players = {"ap_1": ap1, "ap_2": ap2}
8915
8916 await controller._create_or_update_universal_player([ap1, ap2])
8917
8918 assert ap1.protocol_parent_id is not None
8919 assert ap2.protocol_parent_id is not None
8920 assert ap1.protocol_parent_id != ap2.protocol_parent_id
8921 for player in (ap1, ap2):
8922 stored = store[CONF_PLAYERS][player.player_id]["values"][CONF_PROTOCOL_PARENT_ID]
8923 assert stored == player.protocol_parent_id
8924
8925
8926class TestSiblingProtocolMatching:
8927 """
8928 Tests for sibling protocol matching via _match_via_linked_protocols.
8929
8930 When a native player (e.g., HEOS) doesn't have its own MAC/serial identifiers
8931 but has protocol players (e.g., AirPlay) already linked, a new protocol player
8932 (e.g., Sendspin bridge) should be matched to the native player through the
8933 shared identifiers of sibling protocols.
8934 """
8935
8936 def test_sendspin_matches_heos_via_airplay_sibling(self, mock_mass: MagicMock) -> None:
8937 """
8938 Test Sendspin links to HEOS through AirPlay's shared AIRPLAY_ID.
8939
8940 HEOS has no MAC/UUID identifiers. AirPlay is already linked to HEOS.
8941 Sendspin has AIRPLAY_ID matching AirPlay. Sibling matching should link
8942 Sendspin to HEOS.
8943 """
8944 controller = PlayerController(mock_mass)
8945
8946 # HEOS native player - no MAC, no serial, no UUID (just IP from discovery)
8947 heos_provider = MockProvider("heos", mass=mock_mass)
8948 heos_player = MockPlayer(
8949 heos_provider,
8950 "2140037800",
8951 "Denon AVR-X2700H",
8952 player_type=PlayerType.PLAYER,
8953 )
8954 heos_player._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.PLAY_MEDIA}
8955 heos_player._cache.clear()
8956 heos_player.set_initialized()
8957
8958 # AirPlay protocol player already linked to HEOS
8959 ap_provider = MockProvider("airplay", mass=mock_mass)
8960 ap_player = MockPlayer(
8961 ap_provider,
8962 "ap000678747b16",
8963 "Denon AVR-X2700H (AirPlay)",
8964 player_type=PlayerType.PROTOCOL,
8965 identifiers={
8966 IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
8967 IdentifierType.AIRPLAY_ID: "ap000678747b16",
8968 },
8969 )
8970 ap_player.set_initialized()
8971
8972 # Link AirPlay to HEOS
8973 controller._players = {
8974 "2140037800": heos_player,
8975 "ap000678747b16": ap_player,
8976 }
8977 controller._add_protocol_link(heos_player, ap_player, "airplay")
8978 assert ap_player.protocol_parent_id == "2140037800"
8979
8980 # Sendspin bridge with AIRPLAY_ID matching AirPlay
8981 spb_provider = MockProvider("sendspin", mass=mock_mass)
8982 spb_player = MockPlayer(
8983 spb_provider,
8984 "spb_000678747b16",
8985 "Denon AVR-X2700H (Sendspin)",
8986 player_type=PlayerType.PROTOCOL,
8987 identifiers={
8988 IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
8989 IdentifierType.AIRPLAY_ID: "ap000678747b16",
8990 },
8991 )
8992 spb_player.set_initialized()
8993 controller._players["spb_000678747b16"] = spb_player
8994
8995 # Direct identifier match would fail (HEOS has no identifiers)
8996 assert controller._identifiers_match(heos_player, spb_player, "sendspin") is False
8997
8998 # Sibling matching should succeed through AirPlay's identifiers
8999 assert controller._match_via_linked_protocols(heos_player, spb_player, "sendspin") is True
9000 assert spb_player.protocol_parent_id == "2140037800"
9001
9002 def test_sibling_match_no_linked_protocols(self, mock_mass: MagicMock) -> None:
9003 """Test sibling matching returns False when native player has no linked protocols."""
9004 controller = PlayerController(mock_mass)
9005
9006 heos_provider = MockProvider("heos", mass=mock_mass)
9007 heos_player = MockPlayer(
9008 heos_provider, "heos_1", "HEOS Player", player_type=PlayerType.PLAYER
9009 )
9010 heos_player.set_initialized()
9011
9012 spb_provider = MockProvider("sendspin", mass=mock_mass)
9013 spb_player = MockPlayer(
9014 spb_provider,
9015 "spb_1",
9016 "Sendspin",
9017 player_type=PlayerType.PROTOCOL,
9018 identifiers={IdentifierType.AIRPLAY_ID: "ap_1"},
9019 )
9020 spb_player.set_initialized()
9021
9022 controller._players = {"heos_1": heos_player, "spb_1": spb_player}
9023
9024 assert controller._match_via_linked_protocols(heos_player, spb_player, "sendspin") is False
9025
9026 def test_sibling_match_no_identifier_overlap(self, mock_mass: MagicMock) -> None:
9027 """Test sibling matching returns False when identifiers don't overlap."""
9028 controller = PlayerController(mock_mass)
9029
9030 heos_provider = MockProvider("heos", mass=mock_mass)
9031 heos_player = MockPlayer(
9032 heos_provider, "heos_1", "HEOS Player", player_type=PlayerType.PLAYER
9033 )
9034 heos_player.set_initialized()
9035
9036 # AirPlay with one MAC
9037 ap_provider = MockProvider("airplay", mass=mock_mass)
9038 ap_player = MockPlayer(
9039 ap_provider,
9040 "ap_1",
9041 "AirPlay",
9042 player_type=PlayerType.PROTOCOL,
9043 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
9044 )
9045 ap_player.set_initialized()
9046
9047 controller._players = {"heos_1": heos_player, "ap_1": ap_player}
9048 controller._add_protocol_link(heos_player, ap_player, "airplay")
9049
9050 # Sendspin with different MAC, no shared identifiers
9051 spb_provider = MockProvider("sendspin", mass=mock_mass)
9052 spb_player = MockPlayer(
9053 spb_provider,
9054 "spb_1",
9055 "Sendspin",
9056 player_type=PlayerType.PROTOCOL,
9057 identifiers={IdentifierType.MAC_ADDRESS: "11:22:33:44:55:66"},
9058 )
9059 spb_player.set_initialized()
9060 controller._players["spb_1"] = spb_player
9061
9062 assert controller._match_via_linked_protocols(heos_player, spb_player, "sendspin") is False
9063 assert spb_player.protocol_parent_id is None
9064
9065 def test_sibling_match_refuses_duplicate_domain(self, mock_mass: MagicMock) -> None:
9066 """Test sibling matching refuses link when domain is already active on native player."""
9067 controller = PlayerController(mock_mass)
9068
9069 heos_provider = MockProvider("heos", mass=mock_mass)
9070 heos_player = MockPlayer(
9071 heos_provider, "heos_1", "HEOS Player", player_type=PlayerType.PLAYER
9072 )
9073 heos_player.set_initialized()
9074
9075 # AirPlay already linked
9076 ap_provider = MockProvider("airplay", mass=mock_mass)
9077 ap_player = MockPlayer(
9078 ap_provider,
9079 "ap_1",
9080 "AirPlay",
9081 player_type=PlayerType.PROTOCOL,
9082 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
9083 )
9084 ap_player.set_initialized()
9085
9086 controller._players = {"heos_1": heos_player, "ap_1": ap_player}
9087 controller._add_protocol_link(heos_player, ap_player, "airplay")
9088
9089 # Second AirPlay instance with same MAC - should be refused (duplicate domain)
9090 ap2 = MockPlayer(
9091 ap_provider,
9092 "ap_2",
9093 "AirPlay 2",
9094 player_type=PlayerType.PROTOCOL,
9095 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
9096 )
9097 ap2.set_initialized()
9098 controller._players["ap_2"] = ap2
9099
9100 assert controller._match_via_linked_protocols(heos_player, ap2, "airplay") is False
9101 assert ap2.protocol_parent_id is None
9102
9103 def test_try_link_to_existing_uses_sibling_matching(self, mock_mass: MagicMock) -> None:
9104 """Test _try_link_to_existing_player falls back to sibling matching."""
9105 controller = PlayerController(mock_mass)
9106
9107 # No cached protocol IDs for HEOS
9108 mock_mass.config.get = MagicMock(return_value=[])
9109
9110 heos_provider = MockProvider("heos", mass=mock_mass)
9111 heos_player = MockPlayer(
9112 heos_provider,
9113 "2140037800",
9114 "Denon AVR-X2700H",
9115 player_type=PlayerType.PLAYER,
9116 )
9117 heos_player.set_initialized()
9118
9119 ap_provider = MockProvider("airplay", mass=mock_mass)
9120 ap_player = MockPlayer(
9121 ap_provider,
9122 "ap000678747b16",
9123 "Denon AirPlay",
9124 player_type=PlayerType.PROTOCOL,
9125 identifiers={IdentifierType.AIRPLAY_ID: "ap000678747b16"},
9126 )
9127 ap_player.set_initialized()
9128
9129 controller._players = {
9130 "2140037800": heos_player,
9131 "ap000678747b16": ap_player,
9132 }
9133 controller._add_protocol_link(heos_player, ap_player, "airplay")
9134
9135 # Sendspin with matching AIRPLAY_ID
9136 spb_provider = MockProvider("sendspin", mass=mock_mass)
9137 spb_player = MockPlayer(
9138 spb_provider,
9139 "spb_000678747b16",
9140 "Denon Sendspin",
9141 player_type=PlayerType.PROTOCOL,
9142 identifiers={IdentifierType.AIRPLAY_ID: "ap000678747b16"},
9143 )
9144 spb_player.set_initialized()
9145 controller._players["spb_000678747b16"] = spb_player
9146
9147 # _try_link_to_existing_player should succeed via sibling matching
9148 result = controller._try_link_to_existing_player(spb_player, "sendspin")
9149 assert result is True
9150 assert spb_player.protocol_parent_id == "2140037800"
9151
9152
9153class TestDelayedEvalRetry:
9154 """
9155 Tests for the retry of _try_link_to_existing_player in _delayed_protocol_evaluation.
9156
9157 When a protocol player's delayed evaluation fires, native players may have
9158 registered during the delay period. The retry ensures they are checked before
9159 falling through to universal player creation.
9160 """
9161
9162 @pytest.mark.asyncio
9163 async def test_delayed_eval_links_to_native_registered_during_delay(
9164 self, mock_mass: MagicMock
9165 ) -> None:
9166 """
9167 Test delayed eval links to a native player that registered during the delay.
9168
9169 Scenario: Sendspin registers before HEOS. The delayed evaluation fires
9170 after HEOS has registered and has AirPlay linked. The retry should find
9171 HEOS via sibling matching and link Sendspin.
9172 """
9173 controller, _ = TestEndToEndDuplicateProtocol._setup_e2e_controller(mock_mass)
9174
9175 # No cached protocol IDs
9176 mock_mass.config.get = MagicMock(return_value=[])
9177
9178 # HEOS native player (no device identifiers)
9179 heos_provider = MockProvider("heos", mass=mock_mass)
9180 heos_player = MockPlayer(
9181 heos_provider,
9182 "2140037800",
9183 "Denon AVR-X2700H",
9184 player_type=PlayerType.PLAYER,
9185 )
9186 heos_player._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.PLAY_MEDIA}
9187 heos_player._cache.clear()
9188 heos_player.set_initialized()
9189
9190 # AirPlay already linked to HEOS
9191 ap_provider = MockProvider("airplay", mass=mock_mass)
9192 ap_player = MockPlayer(
9193 ap_provider,
9194 "ap000678747b16",
9195 "Denon AirPlay",
9196 player_type=PlayerType.PROTOCOL,
9197 identifiers={
9198 IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
9199 IdentifierType.AIRPLAY_ID: "ap000678747b16",
9200 },
9201 )
9202 ap_player.set_initialized()
9203 ap_player.set_protocol_parent_id("2140037800")
9204
9205 # Sendspin (not yet linked)
9206 spb_provider = MockProvider("sendspin", mass=mock_mass)
9207 spb_player = MockPlayer(
9208 spb_provider,
9209 "spb_000678747b16",
9210 "Denon Sendspin",
9211 player_type=PlayerType.PROTOCOL,
9212 identifiers={
9213 IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
9214 IdentifierType.AIRPLAY_ID: "ap000678747b16",
9215 },
9216 )
9217 spb_player.set_initialized()
9218
9219 # Set up players dict (simulating: HEOS registered during the delay)
9220 controller._players = {
9221 "2140037800": heos_player,
9222 "ap000678747b16": ap_player,
9223 "spb_000678747b16": spb_player,
9224 }
9225
9226 # Link AirPlay to HEOS
9227 controller._add_protocol_link(heos_player, ap_player, "airplay")
9228
9229 # Run delayed evaluation for Sendspin
9230 await controller._delayed_protocol_evaluation("spb_000678747b16")
9231
9232 # Sendspin should be linked to HEOS, not a new universal player
9233 assert spb_player.protocol_parent_id == "2140037800"
9234
9235 @pytest.mark.asyncio
9236 async def test_delayed_eval_skips_already_linked(self, mock_mass: MagicMock) -> None:
9237 """Test delayed eval returns early if protocol player was linked during the delay."""
9238 controller, _ = TestEndToEndDuplicateProtocol._setup_e2e_controller(mock_mass)
9239
9240 spb_provider = MockProvider("sendspin", mass=mock_mass)
9241 spb_player = MockPlayer(
9242 spb_provider,
9243 "spb_1",
9244 "Sendspin",
9245 player_type=PlayerType.PROTOCOL,
9246 identifiers={IdentifierType.AIRPLAY_ID: "ap_1"},
9247 )
9248 spb_player.set_initialized()
9249 # Simulate it was linked during the delay
9250 spb_player.set_protocol_parent_id("some_parent")
9251
9252 controller._players = {"spb_1": spb_player}
9253
9254 # Should return early without creating universal player
9255 await controller._delayed_protocol_evaluation("spb_1")
9256 assert spb_player.protocol_parent_id == "some_parent"
9257
9258
9259class TestNativePlayerSiblingLinking:
9260 """
9261 Tests for the second pass in _try_link_protocols_to_native using sibling matching.
9262
9263 After a native player registers and recovers cached protocol links, the second
9264 pass should match remaining unlinked protocol players via sibling identifiers.
9265 """
9266
9267 def test_native_registration_links_sendspin_via_sibling(self, mock_mass: MagicMock) -> None:
9268 """
9269 Test HEOS registration links Sendspin via AirPlay sibling identifiers.
9270
9271 Scenario: Sendspin registered before HEOS. When HEOS registers, the first
9272 pass can't match Sendspin (no direct identifiers). After recovering cached
9273 AirPlay link, the second pass matches Sendspin via AirPlay's AIRPLAY_ID.
9274 """
9275 controller = PlayerController(mock_mass)
9276
9277 # Set up config to return cached AirPlay link for HEOS
9278 def config_get_side_effect(key: str, default: object = None) -> object:
9279 if key == "players/2140037800/values/linked_protocol_ids":
9280 return ["ap000678747b16"]
9281 if key == "players/ap000678747b16":
9282 return {
9283 "provider": "airplay--test",
9284 "player_type": "protocol",
9285 "values": {"protocol_parent_id": "2140037800"},
9286 }
9287 if key == "players":
9288 return {
9289 "ap000678747b16": {
9290 "provider": "airplay--test",
9291 "player_type": "protocol",
9292 "values": {"protocol_parent_id": "2140037800"},
9293 },
9294 "spb_000678747b16": {
9295 "provider": "sendspin--test",
9296 "player_type": "protocol",
9297 "values": {},
9298 },
9299 }
9300 return default
9301
9302 mock_mass.config.get = MagicMock(side_effect=config_get_side_effect)
9303 mock_mass.get_provider = MagicMock(return_value=None)
9304
9305 # AirPlay protocol player (already registered, waiting for parent)
9306 ap_provider = MockProvider("airplay", mass=mock_mass)
9307 ap_player = MockPlayer(
9308 ap_provider,
9309 "ap000678747b16",
9310 "Denon AirPlay",
9311 player_type=PlayerType.PROTOCOL,
9312 identifiers={
9313 IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
9314 IdentifierType.AIRPLAY_ID: "ap000678747b16",
9315 },
9316 )
9317 ap_player.set_initialized()
9318 # AirPlay has cached parent set but parent hasn't registered yet
9319 ap_player.set_protocol_parent_id("2140037800")
9320
9321 # Sendspin (registered, not linked)
9322 spb_provider = MockProvider("sendspin", mass=mock_mass)
9323 spb_player = MockPlayer(
9324 spb_provider,
9325 "spb_000678747b16",
9326 "Denon Sendspin",
9327 player_type=PlayerType.PROTOCOL,
9328 identifiers={
9329 IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
9330 IdentifierType.AIRPLAY_ID: "ap000678747b16",
9331 },
9332 )
9333 spb_player.set_initialized()
9334
9335 # HEOS native player registers now
9336 heos_provider = MockProvider("heos", mass=mock_mass)
9337 heos_player = MockPlayer(
9338 heos_provider,
9339 "2140037800",
9340 "Denon AVR-X2700H",
9341 player_type=PlayerType.PLAYER,
9342 )
9343 heos_player._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.PLAY_MEDIA}
9344 heos_player._cache.clear()
9345 heos_player.set_initialized()
9346
9347 controller._players = {
9348 "ap000678747b16": ap_player,
9349 "spb_000678747b16": spb_player,
9350 "2140037800": heos_player,
9351 }
9352
9353 # Simulate what happens when HEOS registers
9354 controller._try_link_protocols_to_native(heos_player)
9355
9356 # AirPlay should be recovered from cache (linked_output_protocols)
9357 airplay_linked = any(
9358 link.output_protocol_id == "ap000678747b16"
9359 for link in heos_player.linked_output_protocols
9360 )
9361 assert airplay_linked, "AirPlay should be recovered from cached protocol links"
9362
9363 # Sendspin should be linked via sibling matching (second pass)
9364 assert spb_player.protocol_parent_id == "2140037800"
9365 sendspin_linked = any(
9366 link.output_protocol_id == "spb_000678747b16"
9367 for link in heos_player.linked_output_protocols
9368 )
9369 assert sendspin_linked, "Sendspin should be linked via sibling protocol matching"
9370
9371
9372class TestCachedProtocolLinkRecovery:
9373 """Tests for recovering protocol links from config."""
9374
9375 def test_recovered_links_are_applied_through_the_setter(self, mock_mass: MagicMock) -> None:
9376 """Recovery applies a single link per protocol domain through the setter."""
9377 controller = PlayerController(mock_mass)
9378
9379 protocol_configs = {
9380 "ap_first": {
9381 "provider": "airplay--first",
9382 "player_type": "protocol",
9383 "values": {"protocol_parent_id": "heos_player"},
9384 },
9385 "ap_second": {
9386 "provider": "airplay--second",
9387 "player_type": "protocol",
9388 "values": {"protocol_parent_id": "heos_player"},
9389 },
9390 }
9391
9392 def config_get_side_effect(key: str, default: object = None) -> object:
9393 if key == "players/heos_player/values/linked_protocol_ids":
9394 return ["ap_first", "ap_second"]
9395 if key == "players":
9396 return protocol_configs
9397 if key.startswith("players/"):
9398 return protocol_configs.get(key.split("/", maxsplit=1)[1])
9399 return default
9400
9401 mock_mass.config.get = MagicMock(side_effect=config_get_side_effect)
9402
9403 heos_player = MockPlayer(
9404 MockProvider("heos", mass=mock_mass),
9405 "heos_player",
9406 "Denon AVR-X2700H",
9407 )
9408
9409 controller._recover_cached_protocol_links(heos_player)
9410
9411 recovered = [link.output_protocol_id for link in heos_player.linked_output_protocols]
9412 assert recovered == ["ap_first"]
9413 mock_mass.players.trigger_player_update.assert_called_once_with("heos_player")
9414
9415 # a repeat pass has nothing left to recover, so no update is triggered
9416 controller._recover_cached_protocol_links(heos_player)
9417 mock_mass.players.trigger_player_update.assert_called_once_with("heos_player")
9418
9419 def test_protocol_owned_by_another_parent_is_not_recovered(self, mock_mass: MagicMock) -> None:
9420 """A cached protocol that another parent owns stays out of this parent's outputs."""
9421 controller = PlayerController(mock_mass)
9422
9423 store: dict[str, Any] = {
9424 CONF_PLAYERS: {
9425 "ap_shared": {
9426 "provider": "airplay--x",
9427 "player_type": "protocol",
9428 "values": {CONF_PROTOCOL_PARENT_ID: "cast_owner"},
9429 },
9430 "heos_player": {
9431 "provider": "heos--y",
9432 "player_type": "player",
9433 "values": {"linked_protocol_ids": ["ap_shared"]},
9434 },
9435 "cast_owner": {
9436 "provider": "chromecast--z",
9437 "player_type": "player",
9438 "values": {"linked_protocol_ids": ["ap_shared"]},
9439 },
9440 }
9441 }
9442 _wire_nested_config(mock_mass, store)
9443
9444 airplay = MockPlayer(
9445 MockProvider("airplay", mass=mock_mass),
9446 "ap_shared",
9447 "AVR (AirPlay)",
9448 player_type=PlayerType.PROTOCOL,
9449 )
9450 airplay.set_protocol_parent_id("cast_owner")
9451 owner = MockPlayer(MockProvider("chromecast", mass=mock_mass), "cast_owner", "AVR (Cast)")
9452 owner.set_linked_output_protocols(
9453 [LinkedOutputProtocol(output_protocol_id="ap_shared", protocol_domain="airplay")]
9454 )
9455 heos_player = MockPlayer(MockProvider("heos", mass=mock_mass), "heos_player", "AVR")
9456 controller._players = {
9457 "ap_shared": airplay,
9458 "cast_owner": owner,
9459 "heos_player": heos_player,
9460 }
9461
9462 controller._recover_cached_protocol_links(heos_player)
9463
9464 assert heos_player.linked_output_protocols == []
9465 # the domain slot stays free for this parent's own AirPlay endpoint
9466 assert not controller._parent_has_active_protocol_from_domain(heos_player, "airplay")
9467 assert airplay.protocol_parent_id == "cast_owner"
9468
9469 def test_unowned_and_own_cached_protocols_are_still_recovered(
9470 self, mock_mass: MagicMock
9471 ) -> None:
9472 """A disabled protocol without a live parent is still recovered for its parent."""
9473 controller = PlayerController(mock_mass)
9474
9475 store: dict[str, Any] = {
9476 CONF_PLAYERS: {
9477 "ap_disabled": {
9478 "provider": "airplay--x",
9479 "player_type": "protocol",
9480 "values": {CONF_PROTOCOL_PARENT_ID: "heos_player"},
9481 },
9482 "dlna_own": {
9483 "provider": "dlna--x",
9484 "player_type": "protocol",
9485 "values": {CONF_PROTOCOL_PARENT_ID: "heos_player"},
9486 },
9487 "heos_player": {
9488 "provider": "heos--y",
9489 "player_type": "player",
9490 "values": {"linked_protocol_ids": ["ap_disabled", "dlna_own"]},
9491 },
9492 }
9493 }
9494 _wire_nested_config(mock_mass, store)
9495
9496 # registered but not owned by anyone (e.g. freshly detached)
9497 dlna = MockPlayer(
9498 MockProvider("dlna", mass=mock_mass),
9499 "dlna_own",
9500 "AVR (DLNA)",
9501 player_type=PlayerType.PROTOCOL,
9502 )
9503 heos_player = MockPlayer(MockProvider("heos", mass=mock_mass), "heos_player", "AVR")
9504 controller._players = {"dlna_own": dlna, "heos_player": heos_player}
9505
9506 controller._recover_cached_protocol_links(heos_player)
9507
9508 recovered = {link.output_protocol_id for link in heos_player.linked_output_protocols}
9509 # ap_disabled is not registered at all, dlna_own is registered without a parent
9510 assert recovered == {"ap_disabled", "dlna_own"}
9511
9512 def test_protocol_waiting_for_its_owner_is_not_recovered(self, mock_mass: MagicMock) -> None:
9513 """A protocol whose owner has not registered yet is left to that owner."""
9514 controller = PlayerController(mock_mass)
9515
9516 store: dict[str, Any] = {
9517 CONF_PLAYERS: {
9518 "ap_shared": {
9519 "provider": "airplay--x",
9520 "player_type": "protocol",
9521 "values": {CONF_PROTOCOL_PARENT_ID: "cast_owner"},
9522 },
9523 "heos_player": {
9524 "provider": "heos--y",
9525 "player_type": "player",
9526 "values": {"linked_protocol_ids": ["ap_shared"]},
9527 },
9528 "cast_owner": {
9529 "provider": "chromecast--z",
9530 "player_type": "player",
9531 "values": {"linked_protocol_ids": ["ap_shared"]},
9532 },
9533 }
9534 }
9535 _wire_nested_config(mock_mass, store)
9536
9537 # the protocol is registered but still unparented: its owner is not registered yet
9538 airplay = MockPlayer(
9539 MockProvider("airplay", mass=mock_mass),
9540 "ap_shared",
9541 "AVR (AirPlay)",
9542 player_type=PlayerType.PROTOCOL,
9543 )
9544 heos_player = MockPlayer(MockProvider("heos", mass=mock_mass), "heos_player", "AVR")
9545 controller._players = {"ap_shared": airplay, "heos_player": heos_player}
9546
9547 controller._recover_cached_protocol_links(heos_player)
9548
9549 assert airplay.protocol_parent_id is None
9550 assert heos_player.linked_output_protocols == []
9551 assert not controller._parent_has_active_protocol_from_domain(heos_player, "airplay")
9552
9553 def test_bridge_owned_by_wrapper_leaves_native_domain_slot_free(
9554 self, mock_mass: MagicMock
9555 ) -> None:
9556 """
9557 A bridge that stayed with its wrapper is not recovered onto a native player.
9558
9559 A native player may carry a stale cached id for a bridge whose underlying
9560 connection it could not claim. The bridge follows that connection, so the
9561 native player must leave the domain slot free for its own bridge.
9562 """
9563 controller = PlayerController(mock_mass)
9564
9565 store: dict[str, Any] = {
9566 CONF_PLAYERS: {
9567 "cast_base": {
9568 "provider": "chromecast--x",
9569 "player_type": "protocol",
9570 "values": {CONF_PROTOCOL_PARENT_ID: STORED_UNIVERSAL_ID},
9571 },
9572 "spb_bridge": {
9573 "provider": "sendspin--y",
9574 "player_type": "protocol",
9575 "values": {
9576 CONF_PROTOCOL_PARENT_ID: "heos_player",
9577 CONF_UNDERLYING_PLAYER_ID: "cast_base",
9578 },
9579 },
9580 STORED_UNIVERSAL_ID: {
9581 "provider": "universal_player",
9582 "player_type": "player",
9583 "values": {"linked_protocol_ids": ["cast_base"]},
9584 },
9585 "heos_player": {
9586 "provider": "heos--z",
9587 "player_type": "player",
9588 "values": {"linked_protocol_ids": ["spb_bridge"]},
9589 },
9590 }
9591 }
9592 _wire_nested_config(mock_mass, store)
9593
9594 wrapper = _create_universal_player(
9595 mock_mass, STORED_UNIVERSAL_ID, "AVR (wrapper)", ["cast_base"]
9596 )
9597 base = MockPlayer(
9598 MockProvider("chromecast", mass=mock_mass),
9599 "cast_base",
9600 "AVR (Cast 2)",
9601 player_type=PlayerType.PROTOCOL,
9602 )
9603 base.set_protocol_parent_id(STORED_UNIVERSAL_ID)
9604 wrapper.set_linked_output_protocols(
9605 [LinkedOutputProtocol(output_protocol_id="cast_base", protocol_domain="chromecast")]
9606 )
9607 bridge = MockPlayer(
9608 MockProvider("sendspin", mass=mock_mass),
9609 "spb_bridge",
9610 "AVR (Bridge)",
9611 player_type=PlayerType.PROTOCOL,
9612 )
9613 bridge._attr_underlying_player_id = "cast_base"
9614 bridge.set_initialized()
9615 heos_player = MockPlayer(MockProvider("heos", mass=mock_mass), "heos_player", "AVR")
9616 controller._players = {
9617 STORED_UNIVERSAL_ID: wrapper,
9618 "cast_base": base,
9619 "spb_bridge": bridge,
9620 "heos_player": heos_player,
9621 }
9622
9623 # the bridge resolves via its underlying connection, so it joins the wrapper
9624 controller._try_link_protocol_to_native(bridge)
9625 assert bridge.protocol_parent_id == STORED_UNIVERSAL_ID
9626
9627 controller._recover_cached_protocol_links(heos_player)
9628
9629 assert heos_player.linked_output_protocols == []
9630 assert not controller._parent_has_active_protocol_from_domain(heos_player, "sendspin")
9631
9632 def test_claiming_a_protocol_clears_it_from_its_previous_parent(
9633 self, mock_mass: MagicMock
9634 ) -> None:
9635 """
9636 A parent loses its output entry for a protocol that another parent claims.
9637
9638 A bridge that registers before the connection it runs on has no owner to
9639 compare against yet, so a stale cached id still recovers it onto a native
9640 player. Once the connection registers and takes the bridge along, the native
9641 player must give up its entry instead of listing the bridge as well.
9642 """
9643 controller = PlayerController(mock_mass)
9644
9645 store: dict[str, Any] = {
9646 CONF_PLAYERS: {
9647 "cast_base": {
9648 "provider": "chromecast--x",
9649 "player_type": "protocol",
9650 "values": {CONF_PROTOCOL_PARENT_ID: STORED_UNIVERSAL_ID},
9651 },
9652 "spb_bridge": {
9653 "provider": "sendspin--y",
9654 "player_type": "protocol",
9655 "values": {
9656 CONF_PROTOCOL_PARENT_ID: "heos_player",
9657 CONF_UNDERLYING_PLAYER_ID: "cast_base",
9658 },
9659 },
9660 STORED_UNIVERSAL_ID: {
9661 "provider": "universal_player",
9662 "player_type": "player",
9663 "values": {"linked_protocol_ids": ["cast_base"]},
9664 },
9665 "heos_player": {
9666 "provider": "heos--z",
9667 "player_type": "player",
9668 "values": {"linked_protocol_ids": ["spb_bridge"]},
9669 },
9670 }
9671 }
9672 _wire_nested_config(mock_mass, store)
9673
9674 wrapper = _create_universal_player(
9675 mock_mass, STORED_UNIVERSAL_ID, "AVR (wrapper)", ["cast_base"]
9676 )
9677 bridge = MockPlayer(
9678 MockProvider("sendspin", mass=mock_mass),
9679 "spb_bridge",
9680 "AVR (Bridge)",
9681 player_type=PlayerType.PROTOCOL,
9682 )
9683 bridge._attr_underlying_player_id = "cast_base"
9684 bridge.set_initialized()
9685 heos_player = MockPlayer(MockProvider("heos", mass=mock_mass), "heos_player", "AVR")
9686 controller._players = {
9687 STORED_UNIVERSAL_ID: wrapper,
9688 "spb_bridge": bridge,
9689 "heos_player": heos_player,
9690 }
9691
9692 # the bridge has no owner yet, so the cached id recovers it onto the native player
9693 controller._recover_cached_protocol_links(heos_player)
9694 assert [link.output_protocol_id for link in heos_player.linked_output_protocols] == [
9695 "spb_bridge"
9696 ]
9697
9698 # the connection registers and joins the wrapper, taking the bridge with it
9699 base = MockPlayer(
9700 MockProvider("chromecast", mass=mock_mass),
9701 "cast_base",
9702 "AVR (Cast 2)",
9703 player_type=PlayerType.PROTOCOL,
9704 )
9705 controller._players["cast_base"] = base
9706 controller._try_link_protocol_to_native(base)
9707
9708 assert bridge.protocol_parent_id == STORED_UNIVERSAL_ID
9709 assert heos_player.linked_output_protocols == []
9710 assert not controller._parent_has_active_protocol_from_domain(heos_player, "sendspin")
9711 # the stale cached id is gone too, so the bridge is not offered here as disabled
9712 assert store[CONF_PLAYERS]["heos_player"]["values"]["linked_protocol_ids"] == []
9713
9714 def test_claiming_a_protocol_clears_universal_membership(self, mock_mass: MagicMock) -> None:
9715 """A wrapper that loses a protocol also gives up its stored membership."""
9716 controller = PlayerController(mock_mass)
9717
9718 store: dict[str, Any] = {
9719 CONF_PLAYERS: {
9720 "ap_shared": {
9721 "provider": "airplay--x",
9722 "player_type": "protocol",
9723 "values": {CONF_PROTOCOL_PARENT_ID: STORED_UNIVERSAL_ID},
9724 },
9725 STORED_UNIVERSAL_ID: {
9726 "provider": "universal_player",
9727 "player_type": "player",
9728 "values": {"linked_protocol_ids": ["ap_shared"]},
9729 },
9730 "heos_player": {
9731 "provider": "heos--z",
9732 "player_type": "player",
9733 "values": {},
9734 },
9735 }
9736 }
9737 _wire_nested_config(mock_mass, store)
9738
9739 wrapper = _create_universal_player(
9740 mock_mass, STORED_UNIVERSAL_ID, "AVR (wrapper)", ["ap_shared"]
9741 )
9742 wrapper.set_linked_output_protocols(
9743 [LinkedOutputProtocol(output_protocol_id="ap_shared", protocol_domain="airplay")]
9744 )
9745 airplay = MockPlayer(
9746 MockProvider("airplay", mass=mock_mass),
9747 "ap_shared",
9748 "AVR (AirPlay)",
9749 player_type=PlayerType.PROTOCOL,
9750 )
9751 airplay.set_protocol_parent_id(STORED_UNIVERSAL_ID)
9752 heos_player = MockPlayer(MockProvider("heos", mass=mock_mass), "heos_player", "AVR")
9753 controller._players = {
9754 STORED_UNIVERSAL_ID: wrapper,
9755 "ap_shared": airplay,
9756 "heos_player": heos_player,
9757 }
9758
9759 controller._add_protocol_link(heos_player, airplay, "airplay")
9760
9761 assert airplay.protocol_parent_id == "heos_player"
9762 assert wrapper.linked_output_protocols == []
9763 # the wrapper's stored membership is authoritative and gets written back on save,
9764 # so it has to give up the protocol there as well
9765 assert wrapper._protocol_player_ids == []
9766 assert store[CONF_PLAYERS][STORED_UNIVERSAL_ID]["values"]["linked_protocol_ids"] == []
9767
9768
9769class TestMultiMACMatching:
9770 """Tests for multi-MAC matching (reported MAC vs ARP MAC)."""
9771
9772 def test_match_on_reported_mac_when_arp_mac_differs(self, mock_mass: MagicMock) -> None:
9773 """Two players match when one's reported MAC equals the other's ARP-resolved MAC."""
9774 controller = PlayerController(mock_mass)
9775
9776 provider = MockProvider("test")
9777 # Player A has ARP-resolved MAC in identifiers, reported MAC saved in extra_data
9778 player_a = MockPlayer(
9779 provider,
9780 "player_a",
9781 "Player A",
9782 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
9783 )
9784 player_a.extra_data["reported_mac"] = "AA:BB:CC:DD:EE:02"
9785
9786 # Player B has a MAC that matches player_a's reported MAC
9787 player_b = MockPlayer(
9788 provider,
9789 "player_b",
9790 "Player B",
9791 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
9792 )
9793
9794 assert controller._identifiers_match(player_a, player_b) is True
9795
9796 def test_match_on_reported_mac_reverse(self, mock_mass: MagicMock) -> None:
9797 """Match when player_b's reported MAC matches player_a's ARP MAC."""
9798 controller = PlayerController(mock_mass)
9799
9800 provider = MockProvider("test")
9801 player_a = MockPlayer(
9802 provider,
9803 "player_a",
9804 "Player A",
9805 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
9806 )
9807
9808 player_b = MockPlayer(
9809 provider,
9810 "player_b",
9811 "Player B",
9812 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
9813 )
9814 player_b.extra_data["reported_mac"] = "AA:BB:CC:DD:EE:02"
9815
9816 assert controller._identifiers_match(player_a, player_b) is True
9817
9818 def test_no_match_when_both_macs_differ(self, mock_mass: MagicMock) -> None:
9819 """No match when all MACs differ between players."""
9820 controller = PlayerController(mock_mass)
9821
9822 provider = MockProvider("test")
9823 player_a = MockPlayer(
9824 provider,
9825 "player_a",
9826 "Player A",
9827 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
9828 )
9829 player_a.extra_data["reported_mac"] = "AA:BB:CC:DD:EE:02"
9830
9831 player_b = MockPlayer(
9832 provider,
9833 "player_b",
9834 "Player B",
9835 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
9836 )
9837 player_b.extra_data["reported_mac"] = "AA:BB:CC:DD:EE:04"
9838
9839 assert controller._identifiers_match(player_a, player_b) is False
9840
9841 def test_match_reported_mac_with_la_bit_normalization(self, mock_mass: MagicMock) -> None:
9842 """Reported MAC matching also applies LA bit normalization."""
9843 controller = PlayerController(mock_mass)
9844
9845 provider = MockProvider("test")
9846 # Player A has ARP MAC, reported MAC has LA bit set
9847 player_a = MockPlayer(
9848 provider,
9849 "player_a",
9850 "Player A",
9851 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
9852 )
9853 player_a.extra_data["reported_mac"] = "56:78:C9:E6:0D:A0" # LA variant
9854
9855 # Player B has the hardware MAC
9856 player_b = MockPlayer(
9857 provider,
9858 "player_b",
9859 "Player B",
9860 identifiers={IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"}, # real MAC
9861 )
9862
9863 assert controller._identifiers_match(player_a, player_b) is True
9864
9865
9866class TestProtocolParentIdPersistence:
9867 """Tests for protocol_parent_id persistence for all parent types."""
9868
9869 def test_parent_id_saved_for_universal_parent(self, mock_mass: MagicMock) -> None:
9870 """protocol_parent_id should be saved when linking to a universal player."""
9871 controller = PlayerController(mock_mass)
9872
9873 def _config_get(key: str, default: object = None) -> object:
9874 # Protocol player config must exist for _save_protocol_parent_id to write
9875 if key == "players/airplay_test":
9876 return {"enabled": True}
9877 return default if default is not None else []
9878
9879 mock_mass.config.get = MagicMock(side_effect=_config_get)
9880
9881 universal_provider = create_mock_universal_provider(mock_mass)
9882 parent = UniversalPlayer(
9883 provider=universal_provider,
9884 player_id="up_test",
9885 name="Test Universal",
9886 device_info=DeviceInfo(),
9887 protocol_player_ids=[],
9888 )
9889 parent.update_state(signal_event=False)
9890
9891 protocol_provider = MockProvider("airplay")
9892 protocol_player = MockPlayer(
9893 protocol_provider,
9894 "airplay_test",
9895 "AirPlay Test",
9896 player_type=PlayerType.PROTOCOL,
9897 )
9898
9899 controller._players = {
9900 "up_test": parent,
9901 "airplay_test": protocol_player,
9902 }
9903
9904 controller._add_protocol_link(parent, protocol_player, "airplay")
9905
9906 # Verify _save_protocol_parent_id was called (config.set with parent ID)
9907 config_set_calls = [
9908 call
9909 for call in mock_mass.config.set.call_args_list
9910 if "protocol_parent_id" in str(call)
9911 ]
9912 assert len(config_set_calls) >= 1, (
9913 "protocol_parent_id should be saved for universal player parents"
9914 )
9915
9916 def test_parent_id_cleared_for_universal_parent(self, mock_mass: MagicMock) -> None:
9917 """protocol_parent_id should be cleared when unlinking from a universal player."""
9918 controller = PlayerController(mock_mass)
9919 mock_mass.config.get = MagicMock(return_value={"enabled": True})
9920
9921 universal_provider = create_mock_universal_provider(mock_mass)
9922 parent = UniversalPlayer(
9923 provider=universal_provider,
9924 player_id="up_test",
9925 name="Test Universal",
9926 device_info=DeviceInfo(),
9927 protocol_player_ids=["airplay_test"],
9928 )
9929 parent.update_state(signal_event=False)
9930
9931 protocol_provider = MockProvider("airplay")
9932 protocol_player = MockPlayer(
9933 protocol_provider,
9934 "airplay_test",
9935 "AirPlay Test",
9936 player_type=PlayerType.PROTOCOL,
9937 )
9938 protocol_player.set_protocol_parent_id("up_test")
9939
9940 # Set up linked protocols
9941 parent.set_linked_output_protocols(
9942 [
9943 LinkedOutputProtocol(
9944 output_protocol_id="airplay_test",
9945 protocol_domain="airplay",
9946 priority=10,
9947 )
9948 ]
9949 )
9950
9951 controller._players = {
9952 "up_test": parent,
9953 "airplay_test": protocol_player,
9954 }
9955
9956 controller._remove_protocol_link(parent, "airplay_test")
9957
9958 # Verify protocol_parent_id was cleared in config
9959 config_set_calls = [
9960 call
9961 for call in mock_mass.config.set.call_args_list
9962 if "protocol_parent_id" in str(call) and call.args[1] is None
9963 ]
9964 assert len(config_set_calls) >= 1, (
9965 "protocol_parent_id should be cleared for universal player parents"
9966 )
9967
9968
9969class TestCleanupProtocolLinks:
9970 """Tests for protocol link cleanup on player removal."""
9971
9972 def test_cleanup_clears_config_parent_id(self, mock_mass: MagicMock) -> None:
9973 """When a universal player is removed, protocol parent_ids are cleared in config."""
9974 controller = PlayerController(mock_mass)
9975 mock_mass.config.get = MagicMock(return_value={"enabled": True})
9976
9977 universal_provider = create_mock_universal_provider(mock_mass)
9978 parent = UniversalPlayer(
9979 provider=universal_provider,
9980 player_id="up_test",
9981 name="Test Universal",
9982 device_info=DeviceInfo(),
9983 protocol_player_ids=["airplay_test"],
9984 )
9985 parent.update_state(signal_event=False)
9986
9987 protocol_provider = MockProvider("airplay")
9988 protocol_player = MockPlayer(
9989 protocol_provider,
9990 "airplay_test",
9991 "AirPlay Test",
9992 player_type=PlayerType.PROTOCOL,
9993 )
9994 protocol_player.set_protocol_parent_id("up_test")
9995
9996 parent.set_linked_output_protocols(
9997 [
9998 LinkedOutputProtocol(
9999 output_protocol_id="airplay_test",
10000 protocol_domain="airplay",
10001 priority=10,
10002 )
10003 ]
10004 )
10005
10006 controller._players = {
10007 "up_test": parent,
10008 "airplay_test": protocol_player,
10009 }
10010
10011 controller._cleanup_protocol_links(parent)
10012
10013 # Verify protocol_parent_id was cleared in config
10014 config_set_calls = [
10015 call
10016 for call in mock_mass.config.set.call_args_list
10017 if "airplay_test" in str(call) and "protocol_parent_id" in str(call)
10018 ]
10019 assert len(config_set_calls) >= 1, (
10020 "protocol_parent_id should be cleared in config on parent removal"
10021 )
10022 # Verify in-memory parent cleared
10023 assert protocol_player.protocol_parent_id is None
10024
10025 def test_cleanup_native_parent_schedules_protocol_re_evaluation(
10026 self, mock_mass: MagicMock
10027 ) -> None:
10028 """Permanent native parent removal should reset links and reschedule protocols."""
10029 controller = PlayerController(mock_mass)
10030
10031 config_store: dict[str, object] = {
10032 "players/sonos_1": {"enabled": True},
10033 "players/sonos_1/values/linked_protocol_ids": ["airplay_live", "dlna_cached"],
10034 "players/airplay_live": {"enabled": True},
10035 "players/dlna_cached": {"enabled": True},
10036 }
10037
10038 def config_get(key: str, default: object = None) -> object:
10039 return config_store.get(key, default)
10040
10041 def config_set(key: str, value: object) -> None:
10042 config_store[key] = value
10043
10044 mock_mass.config.get = MagicMock(side_effect=config_get)
10045 mock_mass.config.set = MagicMock(side_effect=config_set)
10046
10047 native_provider = MockProvider("sonos", mass=mock_mass)
10048 parent = MockPlayer(
10049 native_provider,
10050 "sonos_1",
10051 "Sonos",
10052 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
10053 )
10054
10055 protocol_provider = MockProvider("airplay", mass=mock_mass)
10056 protocol_player = MockPlayer(
10057 protocol_provider,
10058 "airplay_live",
10059 "AirPlay Test",
10060 player_type=PlayerType.PROTOCOL,
10061 )
10062 protocol_player.set_protocol_parent_id("sonos_1")
10063
10064 parent.set_linked_output_protocols(
10065 [
10066 LinkedOutputProtocol(
10067 output_protocol_id="airplay_live",
10068 protocol_domain="airplay",
10069 priority=10,
10070 )
10071 ]
10072 )
10073
10074 controller._players = {
10075 "sonos_1": parent,
10076 "airplay_live": protocol_player,
10077 }
10078
10079 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
10080 controller._cleanup_protocol_links(parent)
10081
10082 assert protocol_player.protocol_parent_id is None
10083 assert config_store["players/airplay_live/values/protocol_parent_id"] is None
10084 assert config_store["players/dlna_cached/values/protocol_parent_id"] is None
10085 mock_schedule.assert_called_once_with(protocol_player)
10086
10087 def test_cleanup_universal_parent_clears_cached_only_protocol_ids(
10088 self, mock_mass: MagicMock
10089 ) -> None:
10090 """Universal cleanup should also clear cached protocol ids that are not active."""
10091 controller = PlayerController(mock_mass)
10092
10093 config_store: dict[str, object] = {
10094 "players/up_test": {"enabled": True},
10095 "players/airplay_live": {"enabled": True},
10096 "players/dlna_cached": {"enabled": True},
10097 }
10098
10099 def config_get(key: str, default: object = None) -> object:
10100 return config_store.get(key, default)
10101
10102 def config_set(key: str, value: object) -> None:
10103 config_store[key] = value
10104
10105 mock_mass.config.get = MagicMock(side_effect=config_get)
10106 mock_mass.config.set = MagicMock(side_effect=config_set)
10107
10108 universal_provider = create_mock_universal_provider(mock_mass)
10109 parent = UniversalPlayer(
10110 provider=universal_provider,
10111 player_id="up_test",
10112 name="Test Universal",
10113 device_info=DeviceInfo(),
10114 protocol_player_ids=["airplay_live", "dlna_cached"],
10115 )
10116 parent.update_state(signal_event=False)
10117
10118 protocol_provider = MockProvider("airplay", mass=mock_mass)
10119 protocol_player = MockPlayer(
10120 protocol_provider,
10121 "airplay_live",
10122 "AirPlay Test",
10123 player_type=PlayerType.PROTOCOL,
10124 )
10125 protocol_player.set_protocol_parent_id("up_test")
10126
10127 parent.set_linked_output_protocols(
10128 [
10129 LinkedOutputProtocol(
10130 output_protocol_id="airplay_live",
10131 protocol_domain="airplay",
10132 priority=10,
10133 )
10134 ]
10135 )
10136
10137 controller._players = {
10138 "up_test": parent,
10139 "airplay_live": protocol_player,
10140 }
10141
10142 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
10143 controller._cleanup_protocol_links(parent)
10144
10145 assert protocol_player.protocol_parent_id is None
10146 assert config_store["players/airplay_live/values/protocol_parent_id"] is None
10147 assert config_store["players/dlna_cached/values/protocol_parent_id"] is None
10148 mock_schedule.assert_called_once_with(protocol_player)
10149
10150
10151class TestDetachProtocolChildren:
10152 """Tests for detaching protocol players of a removed player that is not registered."""
10153
10154 @staticmethod
10155 def _setup(
10156 mock_mass: MagicMock, live_parent_id: str | None
10157 ) -> tuple[PlayerController, MockPlayer, dict[str, object]]:
10158 """Set up a registered protocol player whose (unregistered) parent is sonos_1."""
10159 controller = PlayerController(mock_mass)
10160
10161 config_store: dict[str, object] = {
10162 "players": {"sonos_1": {}, "airplay_live": {}},
10163 "players/sonos_1": {"enabled": True},
10164 "players/airplay_live": {"enabled": True},
10165 "players/airplay_live/values/protocol_parent_id": "sonos_1",
10166 }
10167
10168 mock_mass.config.get = MagicMock(
10169 side_effect=lambda key, default=None: config_store.get(key, default)
10170 )
10171 mock_mass.config.set = MagicMock(
10172 side_effect=lambda key, value: config_store.__setitem__(key, value)
10173 )
10174
10175 protocol_player = MockPlayer(
10176 MockProvider("airplay", mass=mock_mass),
10177 "airplay_live",
10178 "AirPlay Test",
10179 player_type=PlayerType.PROTOCOL,
10180 )
10181 protocol_player.set_protocol_parent_id(live_parent_id)
10182 controller._players = {"airplay_live": protocol_player}
10183 return controller, protocol_player, config_store
10184
10185 def test_detach_on_removal_of_an_unregistered_parent(self, mock_mass: MagicMock) -> None:
10186 """Removing a player that is no longer registered detaches its protocol player."""
10187 controller, protocol_player, config_store = self._setup(mock_mass, "sonos_1")
10188
10189 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
10190 controller.delete_player_config("sonos_1")
10191
10192 assert protocol_player.protocol_parent_id is None
10193 assert config_store["players/airplay_live/values/protocol_parent_id"] is None
10194 mock_schedule.assert_called_once_with(protocol_player)
10195
10196 def test_detach_of_a_protocol_player_waiting_for_its_parent(self, mock_mass: MagicMock) -> None:
10197 """A protocol player that only has the parent link in its config is detached too."""
10198 controller, protocol_player, config_store = self._setup(mock_mass, None)
10199
10200 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
10201 controller.delete_player_config("sonos_1")
10202
10203 assert config_store["players/airplay_live/values/protocol_parent_id"] is None
10204 mock_schedule.assert_called_once_with(protocol_player)
10205
10206 def test_detach_skips_a_protocol_player_of_another_parent(self, mock_mass: MagicMock) -> None:
10207 """A protocol player that already moved to another parent keeps its link."""
10208 controller, protocol_player, config_store = self._setup(mock_mass, "cast_1")
10209
10210 with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
10211 controller.delete_player_config("sonos_1")
10212
10213 assert protocol_player.protocol_parent_id == "cast_1"
10214 assert config_store["players/airplay_live/values/protocol_parent_id"] == "sonos_1"
10215 mock_schedule.assert_not_called()
10216
10217
10218class TestStaleConfigMigration:
10219 """Tests for protocol parent link repair on startup."""
10220
10221 def test_clears_stale_parent_ids(self, mock_mass: MagicMock) -> None:
10222 """Stale parent_ids pointing to deleted players are cleared on startup."""
10223 controller = PlayerController(mock_mass)
10224
10225 # Set up config with a protocol player pointing to a deleted universal player
10226 mock_mass.config.get = MagicMock(
10227 return_value={
10228 "airplay_test": {
10229 "player_type": "protocol",
10230 "values": {
10231 "protocol_parent_id": "up_deleted_player",
10232 },
10233 },
10234 "heos_player": {
10235 "player_type": "player",
10236 "values": {},
10237 },
10238 }
10239 )
10240
10241 controller._repair_protocol_parent_links()
10242
10243 # Verify the stale parent_id was cleared
10244 mock_mass.config.set.assert_any_call(
10245 "players/airplay_test/values/protocol_parent_id",
10246 None,
10247 )
10248 mock_mass.config.set_player_type.assert_not_called()
10249
10250 def test_keeps_valid_parent_ids(self, mock_mass: MagicMock) -> None:
10251 """Valid parent_ids pointing to existing players are preserved."""
10252 controller = PlayerController(mock_mass)
10253
10254 mock_mass.config.get = MagicMock(
10255 return_value={
10256 "airplay_test": {
10257 "player_type": "protocol",
10258 "values": {
10259 "protocol_parent_id": "up_valid_player",
10260 },
10261 },
10262 "up_valid_player": {
10263 "player_type": "group",
10264 "provider": "universal_player",
10265 "values": {},
10266 },
10267 }
10268 )
10269
10270 controller._repair_protocol_parent_links()
10271
10272 # Verify no set calls were made to clear the parent_id
10273 for call in mock_mass.config.set.call_args_list:
10274 assert "protocol_parent_id" not in str(call), "Valid parent_id should not be cleared"
10275 mock_mass.config.set_player_type.assert_not_called()
10276
10277 def test_heals_stale_player_type_of_linked_child(self, mock_mass: MagicMock) -> None:
10278 """A parented child whose player_type was corrupted is healed back to protocol."""
10279 controller = PlayerController(mock_mass)
10280
10281 mock_mass.config.get = MagicMock(
10282 return_value={
10283 "squeezelite_child": {
10284 "player_type": "player",
10285 "values": {
10286 "protocol_parent_id": "up_valid_player",
10287 },
10288 },
10289 "up_valid_player": {
10290 "player_type": "group",
10291 "provider": "universal_player",
10292 "values": {},
10293 },
10294 }
10295 )
10296
10297 controller._repair_protocol_parent_links()
10298
10299 mock_mass.config.set_player_type.assert_called_once_with(
10300 "squeezelite_child", PlayerType.PROTOCOL
10301 )
10302
10303 def test_native_registration_clears_stale_parent_link(self, mock_mass: MagicMock) -> None:
10304 """A player registering with a non-protocol type drops its leftover parent link."""
10305 controller = PlayerController(mock_mass)
10306 provider = MockProvider("sendspin", mass=mock_mass)
10307 player = MockPlayer(provider, "web_player", "Web Player", player_type=PlayerType.PLAYER)
10308
10309 config_values = {
10310 f"{CONF_PLAYERS}/web_player": {"player_type": "player"},
10311 f"{CONF_PLAYERS}/web_player/values/protocol_parent_id": "up_old_parent",
10312 }
10313 mock_mass.config.get = MagicMock(
10314 side_effect=lambda key, default=None: config_values.get(key, default)
10315 )
10316
10317 with patch.object(controller, "_try_link_protocols_to_native"):
10318 controller._evaluate_protocol_links(player)
10319
10320 mock_mass.config.set.assert_any_call(
10321 f"{CONF_PLAYERS}/web_player/values/protocol_parent_id",
10322 None,
10323 )
10324
10325
10326class TestUniversalPlayerRestoreOrphanCleanup:
10327 """Tests for UniversalPlayerProvider._restore_player membership repair behavior."""
10328
10329 @staticmethod
10330 def _setup_config_get(
10331 mock_mass: MagicMock,
10332 universal_id: str,
10333 linked_protocol_ids: list[str],
10334 protocol_configs: dict[str, dict[str, object]],
10335 ) -> None:
10336 """Wire mass.config.get to return universal/protocol configs by key."""
10337 universal_conf = {
10338 "values": {
10339 "linked_protocol_ids": list(linked_protocol_ids),
10340 "device_identifiers": {},
10341 "device_info": {},
10342 },
10343 "name": "Test Universal",
10344 }
10345 all_configs: dict[str, object] = {universal_id: universal_conf, **protocol_configs}
10346
10347 def _get(key: str, default: object = None) -> object:
10348 if key == CONF_PLAYERS:
10349 return all_configs
10350 if key.startswith(f"{CONF_PLAYERS}/"):
10351 pid = key.split("/", 1)[1]
10352 return all_configs.get(pid, default)
10353 return default
10354
10355 mock_mass.config.get.side_effect = _get
10356
10357 @pytest.mark.asyncio
10358 async def test_orphan_protocol_dropped_but_configs_kept(self, mock_mass: MagicMock) -> None:
10359 """Orphan protocol is dropped from membership without deleting any config."""
10360 provider = create_mock_universal_provider(mock_mass)
10361 universal_id = "up_test"
10362 orphan_id = "spb_orphan"
10363
10364 self._setup_config_get(
10365 mock_mass,
10366 universal_id,
10367 [orphan_id],
10368 {
10369 orphan_id: {
10370 "player_type": "protocol",
10371 "values": {"protocol_parent_id": None},
10372 },
10373 },
10374 )
10375 mock_mass.players = MagicMock()
10376 mock_mass.players.get_player = MagicMock(return_value=None)
10377 mock_mass.players.delete_player_config = MagicMock()
10378 mock_mass.players.unregister = AsyncMock()
10379 mock_mass.players.register_or_update = AsyncMock()
10380 mock_mass.config.remove_player_config = AsyncMock()
10381
10382 await provider._restore_player(universal_id)
10383
10384 # Neither the orphan's config nor the universal's config is deleted
10385 mock_mass.players.delete_player_config.assert_not_called()
10386 mock_mass.players.unregister.assert_not_called()
10387 mock_mass.config.remove_player_config.assert_not_called()
10388 # With no valid protocols left, the universal is simply not restored
10389 mock_mass.players.register_or_update.assert_not_called()
10390
10391 @pytest.mark.asyncio
10392 async def test_valid_protocol_kept_and_no_cleanup(self, mock_mass: MagicMock) -> None:
10393 """Protocol with a valid protocol_parent_id is kept and no cleanup runs."""
10394 provider = create_mock_universal_provider(mock_mass)
10395 universal_id = "up_test"
10396 protocol_id = "spb_valid"
10397
10398 self._setup_config_get(
10399 mock_mass,
10400 universal_id,
10401 [protocol_id],
10402 {
10403 protocol_id: {
10404 "player_type": "protocol",
10405 "values": {"protocol_parent_id": universal_id},
10406 },
10407 },
10408 )
10409 mock_mass.players = MagicMock()
10410 mock_mass.players.get_player = MagicMock(return_value=None)
10411 mock_mass.players.delete_player_config = MagicMock()
10412 mock_mass.players.unregister = AsyncMock()
10413 mock_mass.players.register_or_update = AsyncMock()
10414 mock_mass.config.remove_player_config = AsyncMock()
10415 mock_mass.config.get_base_player_config.return_value = create_mock_config("Test Universal")
10416
10417 await provider._restore_player(universal_id)
10418
10419 mock_mass.players.delete_player_config.assert_not_called()
10420 mock_mass.players.unregister.assert_not_called()
10421 mock_mass.config.remove_player_config.assert_not_called()
10422 # Universal is constructed and registered with the valid protocol kept
10423 mock_mass.players.register_or_update.assert_awaited_once()
10424 registered = mock_mass.players.register_or_update.call_args.args[0]
10425 assert protocol_id in registered._protocol_player_ids
10426
10427 @pytest.mark.asyncio
10428 async def test_restore_keeps_linked_protocol_after_startup_type_rewrite(
10429 self, mock_mass: MagicMock
10430 ) -> None:
10431 """A linked protocol whose type was rewritten must not delete its universal config."""
10432 provider = create_mock_universal_provider(mock_mass)
10433 universal_id = "upe45f0170ef67"
10434 protocol_id = "e4:5f:01:70:ef:67"
10435 universal_config: dict[str, Any] = {
10436 "player_id": universal_id,
10437 "provider": "universal_player",
10438 "player_type": "player",
10439 "enabled": True,
10440 "name": "WC-Player",
10441 "default_name": "solarium-bath-sl",
10442 "values": {
10443 "hide_in_ui": True,
10444 "announce_volume_min": 55,
10445 "announce_volume_max": 98,
10446 "play_media_overrides_group": False,
10447 "linked_protocol_ids": [protocol_id],
10448 "device_identifiers": {},
10449 "device_info": {},
10450 },
10451 }
10452 configs: dict[str, dict[str, Any]] = {
10453 universal_id: universal_config,
10454 protocol_id: {
10455 "player_id": protocol_id,
10456 "provider": "squeezelite",
10457 "player_type": "player",
10458 "enabled": True,
10459 "values": {"protocol_parent_id": universal_id},
10460 },
10461 }
10462
10463 def _config_get(key: str, default: object = None) -> object:
10464 if key == CONF_PLAYERS:
10465 return configs
10466 if key.startswith(f"{CONF_PLAYERS}/"):
10467 pid = key.split("/", 1)[1]
10468 return configs.get(pid, default)
10469 return default
10470
10471 mock_mass.config.get.side_effect = _config_get
10472 mock_mass.config.remove_player_config = AsyncMock()
10473 mock_mass.config.get_base_player_config.return_value = create_mock_config("WC-Player")
10474 mock_mass.players = MagicMock()
10475 mock_mass.players.get_player = MagicMock(return_value=None)
10476 mock_mass.players.delete_player_config = MagicMock()
10477 mock_mass.players.unregister = AsyncMock()
10478 mock_mass.players.register_or_update = AsyncMock()
10479
10480 await provider._restore_player(universal_id)
10481
10482 mock_mass.config.remove_player_config.assert_not_called()
10483 mock_mass.players.delete_player_config.assert_not_called()
10484 mock_mass.players.unregister.assert_not_called()
10485 mock_mass.players.register_or_update.assert_awaited_once()
10486 registered = mock_mass.players.register_or_update.call_args.args[0]
10487 assert protocol_id in registered._protocol_player_ids
10488 assert configs[universal_id]["values"]["hide_in_ui"] is True
10489 assert configs[universal_id]["values"]["announce_volume_min"] == 55
10490
10491 @pytest.mark.asyncio
10492 async def test_membership_augmented_from_child_parent_link(self, mock_mass: MagicMock) -> None:
10493 """A child whose parent link points at the universal is re-added to membership."""
10494 provider = create_mock_universal_provider(mock_mass)
10495 universal_id = "up_test"
10496 stored_id = "spb_stored"
10497 missing_id = "spb_missing"
10498
10499 self._setup_config_get(
10500 mock_mass,
10501 universal_id,
10502 [stored_id],
10503 {
10504 stored_id: {
10505 "player_type": "protocol",
10506 "values": {"protocol_parent_id": universal_id},
10507 },
10508 missing_id: {
10509 "player_type": "protocol",
10510 "values": {"protocol_parent_id": universal_id},
10511 },
10512 },
10513 )
10514 mock_mass.players = MagicMock()
10515 mock_mass.players.get_player = MagicMock(return_value=None)
10516 mock_mass.players.register_or_update = AsyncMock()
10517 mock_mass.config.remove_player_config = AsyncMock()
10518 mock_mass.config.get_base_player_config.return_value = create_mock_config("Test Universal")
10519
10520 await provider._restore_player(universal_id)
10521
10522 # The missing child is restored to membership and persisted
10523 mock_mass.config.set.assert_any_call(
10524 f"{CONF_PLAYERS}/{universal_id}/values/linked_protocol_ids",
10525 [stored_id, missing_id],
10526 )
10527 registered = mock_mass.players.register_or_update.call_args.args[0]
10528 assert registered._protocol_player_ids == [stored_id, missing_id]
10529
10530 @pytest.mark.asyncio
10531 async def test_universal_config_kept_when_protocol_moved_to_other_parent(
10532 self, mock_mass: MagicMock
10533 ) -> None:
10534 """A protocol that moved to another parent is dropped, universal config is kept."""
10535 provider = create_mock_universal_provider(mock_mass)
10536 universal_id = "up_test"
10537 protocol_id = "spb_moved"
10538
10539 self._setup_config_get(
10540 mock_mass,
10541 universal_id,
10542 [protocol_id],
10543 {
10544 protocol_id: {
10545 "player_type": "protocol",
10546 "values": {"protocol_parent_id": "up_other"},
10547 },
10548 },
10549 )
10550 mock_mass.players = MagicMock()
10551 mock_mass.players.get_player = MagicMock(return_value=None)
10552 mock_mass.players.register_or_update = AsyncMock()
10553 mock_mass.config.remove_player_config = AsyncMock()
10554
10555 await provider._restore_player(universal_id)
10556
10557 mock_mass.config.remove_player_config.assert_not_called()
10558 mock_mass.players.register_or_update.assert_not_called()
10559
10560 @pytest.mark.asyncio
10561 async def test_disabled_parent_reparents_and_disables_orphaned_protocols(
10562 self, mock_mass: MagicMock
10563 ) -> None:
10564 """
10565 Self-heal: stale UP whose protocols belong to a disabled native parent.
10566
10567 Restoring such a UP should skip the wrapper, restore each protocol's
10568 parent_id back to the rightful (disabled) native parent, cascade-disable
10569 each protocol so the next registration cycle doesn't rebuild the wrapper,
10570 and hand the wrapper's config over to the native parent before deleting it.
10571 """
10572 provider = create_mock_universal_provider(mock_mass)
10573 universal_id = "up_test"
10574 native_parent_id = "sonos_disabled"
10575 ap_id = "airplay_1"
10576 dlna_id = "dlna_1"
10577
10578 all_configs = {
10579 universal_id: {
10580 "values": {
10581 "linked_protocol_ids": [ap_id, dlna_id],
10582 "device_identifiers": {},
10583 "device_info": {},
10584 },
10585 "name": "Test UP",
10586 },
10587 native_parent_id: {
10588 "enabled": False,
10589 "provider": "sonos",
10590 "values": {"linked_protocol_ids": [ap_id, dlna_id]},
10591 },
10592 ap_id: {
10593 "player_type": "protocol",
10594 "enabled": True,
10595 "values": {"protocol_parent_id": universal_id},
10596 },
10597 dlna_id: {
10598 "player_type": "protocol",
10599 "enabled": True,
10600 "values": {"protocol_parent_id": universal_id},
10601 },
10602 }
10603
10604 def _config_get(key: str, default: object = None) -> object:
10605 if key == CONF_PLAYERS:
10606 return all_configs
10607 if key.startswith(f"{CONF_PLAYERS}/"):
10608 pid = key.split("/", 1)[1]
10609 return all_configs.get(pid, default)
10610 return default
10611
10612 mock_mass.config.get.side_effect = _config_get
10613 mock_mass.config.set = MagicMock()
10614 mock_mass.config.save_player_config = AsyncMock()
10615 mock_mass.config.remove_player_config = AsyncMock()
10616 mock_mass.players = MagicMock()
10617 mock_mass.players.get_player = MagicMock(return_value=None)
10618
10619 await provider._restore_player(universal_id)
10620
10621 # The wrapper is not restored; its settings are handed over to the
10622 # native parent and its now-obsolete config is deleted
10623 mock_mass.players._migrate_universal_player_config.assert_called_once_with(
10624 universal_id, native_parent_id
10625 )
10626 mock_mass.players.delete_player_config.assert_called_once_with(
10627 universal_id, replacement_player_id=native_parent_id
10628 )
10629
10630 # Each protocol's parent_id is restored to the disabled native parent
10631 parent_restorations = {
10632 call.args[0]: call.args[1]
10633 for call in mock_mass.config.set.call_args_list
10634 if "protocol_parent_id" in call.args[0]
10635 }
10636 assert parent_restorations == {
10637 f"{CONF_PLAYERS}/{ap_id}/values/protocol_parent_id": native_parent_id,
10638 f"{CONF_PLAYERS}/{dlna_id}/values/protocol_parent_id": native_parent_id,
10639 }
10640
10641 # Each protocol is cascade-disabled
10642 disabled_ids = {
10643 call.args[0] for call in mock_mass.config.save_player_config.await_args_list
10644 }
10645 assert disabled_ids == {ap_id, dlna_id}
10646
10647 @pytest.mark.asyncio
10648 async def test_universal_replaced_by_linkplay_shell(self, mock_mass: MagicMock) -> None:
10649 """A generic LinkPlay shell that owns DLNA+AirPlay children replaces the Universal."""
10650 provider = create_mock_universal_provider(mock_mass)
10651 universal_id = "up_edifier"
10652 shell_id = "wiim_uuid:FF97F002-783E-6505-6579-F15FFF97F002"
10653 ap_id = "airplay_edifier"
10654 dlna_id = "dlna_edifier"
10655
10656 all_configs = {
10657 universal_id: {
10658 "values": {
10659 "linked_protocol_ids": [ap_id, dlna_id],
10660 "device_identifiers": {},
10661 "device_info": {},
10662 },
10663 "name": "Edifier MS50A",
10664 },
10665 shell_id: {
10666 "enabled": True,
10667 "provider": "wiim",
10668 "player_type": "player",
10669 "values": {"linked_protocol_ids": [ap_id, dlna_id]},
10670 },
10671 ap_id: {
10672 "player_type": "protocol",
10673 "enabled": True,
10674 "values": {"protocol_parent_id": universal_id},
10675 },
10676 dlna_id: {
10677 "player_type": "protocol",
10678 "enabled": True,
10679 "values": {"protocol_parent_id": universal_id},
10680 },
10681 }
10682
10683 def _config_get(key: str, default: object = None) -> object:
10684 if key == CONF_PLAYERS:
10685 return all_configs
10686 if key.startswith(f"{CONF_PLAYERS}/"):
10687 pid = key.split("/", 1)[1]
10688 return all_configs.get(pid, default)
10689 return default
10690
10691 mock_mass.config.get.side_effect = _config_get
10692 mock_mass.config.set = MagicMock()
10693 mock_mass.config.save_player_config = AsyncMock()
10694 mock_mass.config.remove_player_config = AsyncMock()
10695 mock_mass.players = MagicMock()
10696 mock_mass.players.get_player = MagicMock(return_value=None)
10697 mock_mass.players.register_or_update = AsyncMock()
10698
10699 await provider._restore_player(universal_id)
10700
10701 # both protocol children are reparented from the Universal to the LinkPlay shell
10702 parent_restorations = {
10703 call.args[0]: call.args[1]
10704 for call in mock_mass.config.set.call_args_list
10705 if "protocol_parent_id" in call.args[0]
10706 }
10707 assert parent_restorations == {
10708 f"{CONF_PLAYERS}/{ap_id}/values/protocol_parent_id": shell_id,
10709 f"{CONF_PLAYERS}/{dlna_id}/values/protocol_parent_id": shell_id,
10710 }
10711 # the Universal's saved config/name/DSP/preference and group refs migrate to the
10712 # shell, and the now-obsolete Universal is removed
10713 mock_mass.players._migrate_universal_player_config.assert_called_once_with(
10714 universal_id, shell_id
10715 )
10716 mock_mass.players.delete_player_config.assert_called_once_with(
10717 universal_id, replacement_player_id=shell_id
10718 )
10719
10720 @pytest.mark.asyncio
10721 async def test_protocols_reparented_to_their_own_native_claimer(
10722 self, mock_mass: MagicMock
10723 ) -> None:
10724 """Each protocol is reparented to the native player that claims it, not the first found."""
10725 provider = create_mock_universal_provider(mock_mass)
10726 universal_id = "up_test"
10727 ap_id = "airplay_1"
10728 slimproto_id = "slimproto_1"
10729
10730 all_configs = {
10731 universal_id: {
10732 "values": {
10733 "linked_protocol_ids": [ap_id, slimproto_id],
10734 "device_identifiers": {},
10735 "device_info": {},
10736 },
10737 "name": "Test UP",
10738 },
10739 "native_a": {
10740 "provider": "dlna",
10741 "values": {"linked_protocol_ids": [ap_id]},
10742 },
10743 "native_b": {
10744 "provider": "squeezelite",
10745 "values": {"linked_protocol_ids": [slimproto_id]},
10746 },
10747 ap_id: {
10748 "player_type": "protocol",
10749 "values": {"protocol_parent_id": universal_id},
10750 },
10751 slimproto_id: {
10752 "player_type": "protocol",
10753 "values": {"protocol_parent_id": universal_id},
10754 },
10755 }
10756
10757 def _config_get(key: str, default: object = None) -> object:
10758 if key == CONF_PLAYERS:
10759 return all_configs
10760 if key.startswith(f"{CONF_PLAYERS}/"):
10761 pid = key.split("/", 1)[1]
10762 return all_configs.get(pid, default)
10763 return default
10764
10765 mock_mass.config.get.side_effect = _config_get
10766 mock_mass.config.set = MagicMock()
10767 mock_mass.config.save_player_config = AsyncMock()
10768 mock_mass.config.remove_player_config = AsyncMock()
10769 mock_mass.players = MagicMock()
10770 mock_mass.players.get_player = MagicMock(return_value=None)
10771 mock_mass.players.register_or_update = AsyncMock()
10772
10773 await provider._restore_player(universal_id)
10774
10775 mock_mass.players.register_or_update.assert_not_called()
10776 parent_restorations = {
10777 call.args[0]: call.args[1]
10778 for call in mock_mass.config.set.call_args_list
10779 if "protocol_parent_id" in call.args[0]
10780 }
10781 assert parent_restorations == {
10782 f"{CONF_PLAYERS}/{ap_id}/values/protocol_parent_id": "native_a",
10783 f"{CONF_PLAYERS}/{slimproto_id}/values/protocol_parent_id": "native_b",
10784 }
10785 for call in mock_mass.config.save_player_config.await_args_list:
10786 assert call.args[1] == {ATTR_ENABLED: False}
10787 # The wrapper's settings are handed to the first claimer, then its
10788 # config is deleted
10789 mock_mass.players._migrate_universal_player_config.assert_called_once_with(
10790 universal_id, "native_a"
10791 )
10792 mock_mass.players.delete_player_config.assert_called_once_with(
10793 universal_id, replacement_player_id="native_a"
10794 )
10795
10796 @pytest.mark.asyncio
10797 async def test_restore_native_claims_carries_config_and_deletes_wrapper(
10798 self, mock_mass: MagicMock
10799 ) -> None:
10800 """The wrapper's user settings move to the native claimer and its config is deleted."""
10801 universal_id = "up_old"
10802 native_id = "cast_1"
10803 ap_id = "ap_1"
10804 store: dict[str, Any] = {
10805 CONF_PLAYERS: {
10806 universal_id: {
10807 "provider": "universal_player",
10808 "enabled": True,
10809 "name": "Living Room",
10810 "default_name": "Soundbar (Universal)",
10811 "values": {
10812 "hide_in_ui": True,
10813 "volume_control": "up_volume_control",
10814 "linked_protocol_ids": [ap_id],
10815 "device_identifiers": {"mac_address": "AA:BB:CC:DD:EE:FF"},
10816 "device_info": {"model": "Test", "manufacturer": "Test"},
10817 },
10818 },
10819 native_id: {
10820 "provider": "cast",
10821 "enabled": True,
10822 "name": "Soundbar",
10823 "default_name": "Soundbar",
10824 "values": {
10825 "volume_control": "native",
10826 "linked_protocol_ids": [ap_id],
10827 },
10828 },
10829 ap_id: {
10830 "player_type": "protocol",
10831 "enabled": True,
10832 "values": {"protocol_parent_id": universal_id},
10833 },
10834 "group_1": {
10835 "provider": "sync_group",
10836 "enabled": True,
10837 "values": {
10838 "group_members": [universal_id, "other"],
10839 "allowed_members": [universal_id],
10840 },
10841 },
10842 },
10843 CONF_PLAYER_DSP: {universal_id: {"enabled": True, "filters": []}},
10844 CONF_PLAYER_QUEUES: {
10845 universal_id: {"queue_id": universal_id, "values": {"crossfade_duration": 5}}
10846 },
10847 }
10848 _wire_nested_config(mock_mass, store)
10849 mock_mass.config.save_player_config = AsyncMock()
10850 scheduled_tasks: list[Awaitable[object]] = []
10851 mock_mass.create_task = MagicMock(
10852 side_effect=lambda task, *_a, **_kw: scheduled_tasks.append(task)
10853 )
10854 controller = PlayerController(mock_mass)
10855 controller._players = {}
10856 mock_mass.players = controller
10857 mock_mass.player_queues = MagicMock()
10858 provider = create_mock_universal_provider(mock_mass)
10859
10860 await provider._restore_player(universal_id)
10861
10862 players_tree = store[CONF_PLAYERS]
10863 # the protocol player is reparented to the native claimer
10864 assert players_tree[ap_id]["values"]["protocol_parent_id"] == native_id
10865 # the custom display name and user-set values moved onto the native player
10866 assert players_tree[native_id]["name"] == "Living Room"
10867 assert players_tree[native_id]["values"]["hide_in_ui"] is True
10868 # values the native player has explicitly set are not overwritten
10869 assert players_tree[native_id]["values"]["volume_control"] == "native"
10870 # wrapper bookkeeping values are not carried over
10871 assert "device_identifiers" not in players_tree[native_id]["values"]
10872 assert "device_info" not in players_tree[native_id]["values"]
10873 # DSP and queue settings moved onto the native player
10874 assert store[CONF_PLAYER_DSP] == {native_id: {"enabled": True, "filters": []}}
10875 assert store[CONF_PLAYER_QUEUES] == {
10876 native_id: {"queue_id": native_id, "values": {"crossfade_duration": 5}}
10877 }
10878 # group memberships that referenced the wrapper now point at the native player
10879 assert players_tree["group_1"]["values"]["group_members"] == [native_id, "other"]
10880 assert players_tree["group_1"]["values"]["allowed_members"] == [native_id]
10881 # the wrapper's own config entries are all gone
10882 assert universal_id not in players_tree
10883 # cached queue state of the wrapper is purged as well
10884 mock_mass.player_queues.purge_saved_queue.assert_called_once_with(universal_id)
10885 # the wrapper itself is not registered as a player
10886 assert universal_id not in controller._players
10887 for task in scheduled_tasks:
10888 await task
10889
10890 @pytest.mark.asyncio
10891 async def test_restore_native_claims_keeps_native_name_dsp_and_queue_values(
10892 self, mock_mass: MagicMock
10893 ) -> None:
10894 """Values explicitly set on the native player win over the wrapper's on restore."""
10895 universal_id = "up_old"
10896 native_id = "cast_1"
10897 ap_id = "ap_1"
10898 store: dict[str, Any] = {
10899 CONF_PLAYERS: {
10900 universal_id: {
10901 "provider": "universal_player",
10902 "enabled": True,
10903 "name": "Living Room",
10904 "default_name": "Soundbar (Universal)",
10905 "values": {"hide_in_ui": True, "linked_protocol_ids": [ap_id]},
10906 },
10907 native_id: {
10908 "provider": "cast",
10909 "enabled": True,
10910 "name": "Kitchen",
10911 "default_name": "Soundbar",
10912 "values": {"linked_protocol_ids": [ap_id]},
10913 },
10914 ap_id: {
10915 "player_type": "protocol",
10916 "enabled": True,
10917 "values": {"protocol_parent_id": universal_id},
10918 },
10919 },
10920 CONF_PLAYER_DSP: {
10921 universal_id: {"enabled": True, "filters": ["universal"]},
10922 native_id: {"enabled": False, "filters": ["native"]},
10923 },
10924 CONF_PLAYER_QUEUES: {
10925 universal_id: {
10926 "queue_id": universal_id,
10927 "values": {"crossfade_duration": 8, "autoplay_mode": "smart"},
10928 },
10929 native_id: {"queue_id": native_id, "values": {"crossfade_duration": 2}},
10930 },
10931 }
10932 _wire_nested_config(mock_mass, store)
10933 mock_mass.config.save_player_config = AsyncMock()
10934 scheduled_tasks: list[Awaitable[object]] = []
10935 mock_mass.create_task = MagicMock(
10936 side_effect=lambda task, *_a, **_kw: scheduled_tasks.append(task)
10937 )
10938 controller = PlayerController(mock_mass)
10939 controller._players = {}
10940 mock_mass.players = controller
10941 mock_mass.player_queues = MagicMock()
10942 provider = create_mock_universal_provider(mock_mass)
10943
10944 await provider._restore_player(universal_id)
10945
10946 players_tree = store[CONF_PLAYERS]
10947 # the native player's own custom name and DSP config win
10948 assert players_tree[native_id]["name"] == "Kitchen"
10949 assert store[CONF_PLAYER_DSP] == {native_id: {"enabled": False, "filters": ["native"]}}
10950 # queue values merge without overwriting the native queue's own values
10951 assert store[CONF_PLAYER_QUEUES] == {
10952 native_id: {
10953 "queue_id": native_id,
10954 "values": {"crossfade_duration": 2, "autoplay_mode": "smart"},
10955 }
10956 }
10957 # non-conflicting values still migrate and the wrapper config is gone
10958 assert players_tree[native_id]["values"]["hide_in_ui"] is True
10959 assert universal_id not in players_tree
10960 for task in scheduled_tasks:
10961 await task
10962
10963 @pytest.mark.asyncio
10964 async def test_restore_native_claims_keeps_config_while_wrapper_registered(
10965 self, mock_mass: MagicMock
10966 ) -> None:
10967 """No config carry-over/deletion while the wrapper is still registered."""
10968 provider = create_mock_universal_provider(mock_mass)
10969 universal_id = "up_test"
10970 ap_id = "airplay_1"
10971
10972 all_configs = {
10973 universal_id: {
10974 "values": {
10975 "linked_protocol_ids": [ap_id],
10976 "device_identifiers": {},
10977 "device_info": {},
10978 },
10979 "name": "Test UP",
10980 },
10981 "native_a": {
10982 "provider": "dlna",
10983 "values": {"linked_protocol_ids": [ap_id]},
10984 },
10985 ap_id: {
10986 "player_type": "protocol",
10987 "values": {"protocol_parent_id": universal_id},
10988 },
10989 }
10990
10991 def _config_get(key: str, default: object = None) -> object:
10992 if key == CONF_PLAYERS:
10993 return all_configs
10994 if key.startswith(f"{CONF_PLAYERS}/"):
10995 pid = key.split("/", 1)[1]
10996 return all_configs.get(pid, default)
10997 return default
10998
10999 mock_mass.config.get.side_effect = _config_get
11000 mock_mass.config.set = MagicMock()
11001 mock_mass.config.save_player_config = AsyncMock()
11002 mock_mass.players = MagicMock()
11003 mock_mass.players.get_player = MagicMock(return_value=MagicMock())
11004
11005 await provider._restore_player(universal_id)
11006
11007 # the protocol link repair still runs
11008 parent_restorations = {
11009 call.args[0]: call.args[1]
11010 for call in mock_mass.config.set.call_args_list
11011 if "protocol_parent_id" in call.args[0]
11012 }
11013 assert parent_restorations == {
11014 f"{CONF_PLAYERS}/{ap_id}/values/protocol_parent_id": "native_a",
11015 }
11016 # but the registered wrapper keeps its config and group memberships
11017 mock_mass.players._migrate_universal_player_config.assert_not_called()
11018 mock_mass.players.delete_player_config.assert_not_called()
11019 mock_mass.player_queues.on_player_remove.assert_not_called()
11020
11021
11022class TestParentDisableCascade:
11023 """Tests for cascading enable/disable from a native parent to its protocols."""
11024
11025 @staticmethod
11026 def _make_config(player_id: str, *, enabled: bool, provider: str = "sonos") -> MagicMock:
11027 """Build a minimal PlayerConfig mock for on_player_config_change."""
11028 config = MagicMock()
11029 config.player_id = player_id
11030 config.provider = provider
11031 config.enabled = enabled
11032 config.values = {}
11033 return config
11034
11035 @pytest.mark.asyncio
11036 async def test_disabling_native_parent_cascades_disable_to_linked_protocols(
11037 self, mock_mass: MagicMock
11038 ) -> None:
11039 """Disabling a native parent saves enabled=False for each linked protocol."""
11040 controller = PlayerController(mock_mass)
11041
11042 native_provider = MockProvider("sonos", mass=mock_mass)
11043 native_player = MockPlayer(
11044 native_provider,
11045 "sonos_native",
11046 "Native Speaker",
11047 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
11048 )
11049 # Bypass the power-off branch in on_player_config_change
11050 native_player._attr_available = False
11051 native_player.set_linked_output_protocols(
11052 [
11053 LinkedOutputProtocol(
11054 output_protocol_id="ap_1",
11055 protocol_domain="airplay",
11056 priority=10,
11057 ),
11058 LinkedOutputProtocol(
11059 output_protocol_id="dlna_1",
11060 protocol_domain="dlna",
11061 priority=20,
11062 ),
11063 ]
11064 )
11065 controller._players = {native_player.player_id: native_player}
11066
11067 protocol_configs = {
11068 "ap_1": {"enabled": True, "player_type": "protocol"},
11069 "dlna_1": {"enabled": True, "player_type": "protocol"},
11070 }
11071
11072 def _config_get(key: str, default: object = None) -> object:
11073 if key.startswith(f"{CONF_PLAYERS}/"):
11074 pid = key.split("/", 1)[1]
11075 return protocol_configs.get(pid, default)
11076 return default
11077
11078 mock_mass.config.get = MagicMock(side_effect=_config_get)
11079 mock_mass.config.save_player_config = MagicMock()
11080 mock_mass.create_task = MagicMock()
11081 mock_mass.get_provider = MagicMock(return_value=MagicMock(spec=PlayerProvider))
11082
11083 config = self._make_config(native_player.player_id, enabled=False)
11084
11085 await controller.on_player_config_change(config, {ATTR_ENABLED})
11086
11087 called = mock_mass.config.save_player_config.call_args_list
11088 assert {call.args[0] for call in called} == {"ap_1", "dlna_1"}
11089 for call in called:
11090 assert call.args[1] == {ATTR_ENABLED: False}
11091
11092 @pytest.mark.asyncio
11093 async def test_enabling_native_parent_cascades_enable_to_linked_protocols(
11094 self, mock_mass: MagicMock
11095 ) -> None:
11096 """Re-enabling a native parent saves enabled=True for each (currently disabled) protocol."""
11097 controller = PlayerController(mock_mass)
11098
11099 native_provider = MockProvider("sonos", mass=mock_mass)
11100 native_player = MockPlayer(
11101 native_provider,
11102 "sonos_native",
11103 "Native Speaker",
11104 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
11105 )
11106 native_player._attr_available = False
11107 native_player.set_linked_output_protocols(
11108 [
11109 LinkedOutputProtocol(
11110 output_protocol_id="ap_1",
11111 protocol_domain="airplay",
11112 priority=10,
11113 ),
11114 ]
11115 )
11116 controller._players = {native_player.player_id: native_player}
11117
11118 protocol_configs = {
11119 "ap_1": {"enabled": False, "player_type": "protocol"},
11120 }
11121
11122 def _config_get(key: str, default: object = None) -> object:
11123 if key.startswith(f"{CONF_PLAYERS}/"):
11124 pid = key.split("/", 1)[1]
11125 return protocol_configs.get(pid, default)
11126 return default
11127
11128 mock_mass.config.get = MagicMock(side_effect=_config_get)
11129 mock_mass.config.save_player_config = MagicMock()
11130 mock_mass.create_task = MagicMock()
11131 mock_mass.get_provider = MagicMock(return_value=MagicMock(spec=PlayerProvider))
11132
11133 config = self._make_config(native_player.player_id, enabled=True)
11134
11135 await controller.on_player_config_change(config, {ATTR_ENABLED})
11136
11137 mock_mass.config.save_player_config.assert_called_once_with("ap_1", {ATTR_ENABLED: True})
11138
11139 @pytest.mark.asyncio
11140 async def test_disabling_native_parent_falls_back_to_cached_protocol_ids(
11141 self, mock_mass: MagicMock
11142 ) -> None:
11143 """When the parent isn't registered, cascade uses cached linked_protocol_ids."""
11144 controller = PlayerController(mock_mass)
11145
11146 # Parent is not in _players (e.g. provider didn't register a disabled parent)
11147 controller._players = {}
11148
11149 cached_protocol_ids = ["ap_cached", "dlna_cached"]
11150 protocol_configs = {
11151 "ap_cached": {"enabled": True, "player_type": "protocol"},
11152 "dlna_cached": {"enabled": True, "player_type": "protocol"},
11153 }
11154
11155 def _config_get(key: str, default: object = None) -> object:
11156 if key == f"{CONF_PLAYERS}/unregistered_native/values/linked_protocol_ids":
11157 return list(cached_protocol_ids)
11158 if key.startswith(f"{CONF_PLAYERS}/"):
11159 pid = key.split("/", 1)[1]
11160 return protocol_configs.get(pid, default)
11161 return default
11162
11163 mock_mass.config.get = MagicMock(side_effect=_config_get)
11164 mock_mass.config.save_player_config = MagicMock()
11165 mock_mass.create_task = MagicMock()
11166 mock_mass.get_provider = MagicMock(return_value=MagicMock(spec=PlayerProvider))
11167
11168 config = self._make_config("unregistered_native", enabled=False)
11169
11170 await controller.on_player_config_change(config, {ATTR_ENABLED})
11171
11172 called_ids = {call.args[0] for call in mock_mass.config.save_player_config.call_args_list}
11173 assert called_ids == set(cached_protocol_ids)
11174
11175 @pytest.mark.asyncio
11176 async def test_toggling_protocol_player_does_not_cascade(self, mock_mass: MagicMock) -> None:
11177 """A PROTOCOL-type player flipping enabled must not trigger any cascade."""
11178 controller = PlayerController(mock_mass)
11179
11180 ap_provider = MockProvider("airplay", mass=mock_mass)
11181 ap_player = MockPlayer(
11182 ap_provider,
11183 "ap_1",
11184 "AirPlay",
11185 player_type=PlayerType.PROTOCOL,
11186 )
11187 ap_player._attr_available = False
11188 controller._players = {ap_player.player_id: ap_player}
11189
11190 mock_mass.config.save_player_config = MagicMock()
11191 mock_mass.create_task = MagicMock()
11192 mock_mass.get_provider = MagicMock(return_value=MagicMock(spec=PlayerProvider))
11193
11194 config = self._make_config(ap_player.player_id, enabled=False, provider="airplay")
11195
11196 await controller.on_player_config_change(config, {ATTR_ENABLED})
11197
11198 mock_mass.config.save_player_config.assert_not_called()
11199
11200
11201class TestDelayedEvalDisabledParentGuard:
11202 """Tests for the guard in _delayed_protocol_evaluation against disabled parents."""
11203
11204 @pytest.mark.asyncio
11205 async def test_skips_universal_creation_when_cached_parent_is_disabled(
11206 self, mock_mass: MagicMock
11207 ) -> None:
11208 """No universal player is created when the protocol's cached parent is disabled."""
11209 controller = PlayerController(mock_mass)
11210
11211 dlna_provider = MockProvider("dlna", mass=mock_mass)
11212 protocol_player = MockPlayer(
11213 dlna_provider,
11214 "dlna_orphan",
11215 "Orphan DLNA",
11216 player_type=PlayerType.PROTOCOL,
11217 )
11218 protocol_player.set_initialized()
11219 controller._players = {protocol_player.player_id: protocol_player}
11220
11221 cached_parent_id = "sonos_disabled"
11222
11223 def _config_get(key: str, default: object = None) -> object:
11224 if key.endswith("/protocol_parent_id"):
11225 return cached_parent_id
11226 if key == f"{CONF_PLAYERS}/{cached_parent_id}":
11227 return {"enabled": False, "provider": "sonos"}
11228 return default
11229
11230 mock_mass.config.get = MagicMock(side_effect=_config_get)
11231
11232 with (
11233 patch.object(controller, "_try_link_to_existing_player", return_value=False),
11234 patch.object(controller, "_find_matching_universal_player", return_value=None),
11235 patch.object(controller, "_create_or_update_universal_player") as mock_create_up,
11236 ):
11237 await controller._delayed_protocol_evaluation(protocol_player.player_id)
11238
11239 mock_create_up.assert_not_called()
11240
11241 @pytest.mark.asyncio
11242 async def test_creates_universal_when_cached_parent_is_enabled_but_offline(
11243 self, mock_mass: MagicMock
11244 ) -> None:
11245 """Sanity check: an enabled-but-unregistered cached parent still falls through."""
11246 controller = PlayerController(mock_mass)
11247
11248 dlna_provider = MockProvider("dlna", mass=mock_mass)
11249 protocol_player = MockPlayer(
11250 dlna_provider,
11251 "dlna_orphan",
11252 "Orphan DLNA",
11253 player_type=PlayerType.PROTOCOL,
11254 )
11255 protocol_player.set_initialized()
11256 controller._players = {protocol_player.player_id: protocol_player}
11257
11258 cached_parent_id = "sonos_offline"
11259
11260 def _config_get(key: str, default: object = None) -> object:
11261 if key.endswith("/protocol_parent_id"):
11262 return cached_parent_id
11263 if key == f"{CONF_PLAYERS}/{cached_parent_id}":
11264 return {"enabled": True, "provider": "sonos"}
11265 return default
11266
11267 mock_mass.config.get = MagicMock(side_effect=_config_get)
11268
11269 with (
11270 patch.object(controller, "_try_link_to_existing_player", return_value=False),
11271 patch.object(controller, "_find_matching_universal_player", return_value=None),
11272 patch.object(controller, "_create_or_update_universal_player") as mock_create_up,
11273 patch.object(
11274 controller,
11275 "_find_matching_protocol_players",
11276 return_value=[protocol_player],
11277 ),
11278 ):
11279 await controller._delayed_protocol_evaluation(protocol_player.player_id)
11280
11281 mock_create_up.assert_called_once()
11282
11283
11284class TestUniversalPlayerCurrentMedia:
11285 """Tests for current_media resolution while an output protocol is active."""
11286
11287 def test_current_media_surfaces_active_output_protocol(self, mock_mass: MagicMock) -> None:
11288 """
11289 While outputting via a protocol player, surface that player's raw current_media.
11290
11291 A sync group mirrors its leader's raw current_media to resolve the active queue
11292 item. When the leader is a universal player outputting via AirPlay, returning None
11293 here strands the group with a frozen current item (regression guard for grouped
11294 AirPlay live-metadata never refreshing).
11295 """
11296 protocol_provider = MockProvider("airplay", mass=mock_mass)
11297 protocol_player = MockPlayer(
11298 protocol_provider, "ap_1", "AirPlay", player_type=PlayerType.PROTOCOL
11299 )
11300 media = PlayerMedia(
11301 uri="http://x/flow/sess/queue_1/item_1/ap_1.flac", queue_item_id="item_1"
11302 )
11303 protocol_player._attr_current_media = media
11304
11305 universal = _create_universal_player(mock_mass, "up_1", "Universal", ["ap_1"])
11306 players = {"up_1": universal, "ap_1": protocol_player}
11307 mock_mass.players.get_player = MagicMock(side_effect=lambda pid: players.get(pid))
11308
11309 # without an active output protocol there is nothing to surface
11310 assert universal.current_media is None
11311
11312 universal.set_active_output_protocol("ap_1")
11313 assert universal.current_media is media
11314
11315 def test_current_media_none_for_native_output(self, mock_mass: MagicMock) -> None:
11316 """A native output protocol has no separate protocol player to surface."""
11317 universal = _create_universal_player(mock_mass, "up_1", "Universal", [])
11318 universal.set_active_output_protocol("native")
11319 assert universal.current_media is None
11320
11321
11322class TestSelfReferentialProtocolLinks:
11323 """
11324 Tests for the self-link bug when a Sendspin device changes player type.
11325
11326 A Sendspin device keeps a stable client_id (the MA player_id) across reflashes.
11327 When it first registers as a protocol player it gets wrapped in a universal
11328 player that caches that id as one of its protocols. If the device later
11329 reconnects as a non-protocol player (e.g. its roles changed to display-only)
11330 with the same id, the replace logic could link it to itself, hiding it.
11331 """
11332
11333 def test_add_protocol_link_refuses_self(self, mock_mass: MagicMock) -> None:
11334 """A player must never become its own protocol parent."""
11335 controller = PlayerController(mock_mass)
11336 provider = MockProvider("sendspin", mass=mock_mass)
11337 player = MockPlayer(provider, "esp_client", "ESP", player_type=PlayerType.PROTOCOL)
11338 mock_mass.players = controller
11339 controller._players = {"esp_client": player}
11340
11341 controller._add_protocol_link(player, player, "sendspin")
11342
11343 assert player.protocol_parent_id is None
11344 assert all(
11345 link.output_protocol_id != "esp_client" for link in player.linked_output_protocols
11346 )
11347
11348 def test_type_change_replaces_universal_without_self_link(self, mock_mass: MagicMock) -> None:
11349 """Replacing a universal player with the same-id native player must not self-link."""
11350 universal = _create_universal_player(
11351 mock_mass, "upespclient", "ESP", protocol_player_ids=["esp_client"]
11352 )
11353 display_provider = MockProvider("sendspin", mass=mock_mass)
11354 display_player = MockPlayer(
11355 display_provider, "esp_client", "ESP", player_type=PlayerType.DISPLAY
11356 )
11357
11358 store: dict[str, Any] = {"players/esp_client": {"enabled": True}}
11359 mock_mass.config.get.side_effect = lambda key, default=None: store.get(key, default)
11360 mock_mass.config.set.side_effect = lambda key, value: store.__setitem__(key, value)
11361 mock_mass.create_task = MagicMock()
11362 mock_mass.players = controller = PlayerController(mock_mass)
11363 controller._players = {"upespclient": universal, "esp_client": display_player}
11364
11365 controller._check_replace_universal_player(display_player)
11366
11367 assert display_player.protocol_parent_id is None
11368 assert store.get("players/esp_client/values/protocol_parent_id") != "esp_client"
11369 assert "esp_client" not in store.get("players/esp_client/values/linked_protocol_ids", [])
11370 # the obsolete universal player must not keep listing the native player,
11371 # or its permanent cleanup would re-evaluate it as an orphaned protocol
11372 assert "esp_client" not in universal._protocol_player_ids
11373
11374 async def test_migrate_clears_persisted_self_referential_link(self) -> None:
11375 """Config migration scrubs a self-referential link left by an older version."""
11376 config = ConfigController.__new__(ConfigController)
11377 config._data = {
11378 CONF_PLAYERS: {
11379 "esp_client": {
11380 "provider": "sendspin",
11381 "player_id": "esp_client",
11382 "values": {
11383 "protocol_parent_id": "esp_client",
11384 "linked_protocol_ids": ["esp_client"],
11385 },
11386 }
11387 }
11388 }
11389 await migrate(config._data)
11390
11391 values = config._data[CONF_PLAYERS]["esp_client"]["values"]
11392 assert values["protocol_parent_id"] is None
11393 assert "esp_client" not in values["linked_protocol_ids"]
11394
11395
11396class TestDerivedProtocolLinking:
11397 """
11398 Tests for derived protocol players (players with underlying_player_id set).
11399
11400 Derived protocol players (e.g. Sendspin bridges riding on an AirPlay player)
11401 resolve their parent deterministically via the underlying player instead of
11402 device identifier matching, and never seed universal players themselves.
11403 """
11404
11405 @staticmethod
11406 def _make_derived_player(
11407 mock_mass: MagicMock, player_id: str, underlying_player_id: str
11408 ) -> MockPlayer:
11409 """Create a derived protocol player without any device identifiers."""
11410 provider = MockProvider("sendspin", mass=mock_mass)
11411 player = MockPlayer(
11412 provider,
11413 player_id,
11414 f"Bridge {player_id}",
11415 player_type=PlayerType.PROTOCOL,
11416 )
11417 player._attr_underlying_player_id = underlying_player_id
11418 player.set_initialized()
11419 return player
11420
11421 def test_derived_links_to_parent_of_linked_underlying(self, mock_mass: MagicMock) -> None:
11422 """Test a derived player joins the parent of its (linked) underlying player."""
11423 controller = PlayerController(mock_mass)
11424 mock_mass.config.get = MagicMock(return_value=[])
11425
11426 native = MockPlayer(
11427 MockProvider("heos", mass=mock_mass), "native_1", "AVR", player_type=PlayerType.PLAYER
11428 )
11429 native.set_initialized()
11430 ap_player = MockPlayer(
11431 MockProvider("airplay", mass=mock_mass),
11432 "ap_1",
11433 "AVR AirPlay",
11434 player_type=PlayerType.PROTOCOL,
11435 )
11436 ap_player.set_initialized()
11437 controller._players = {"native_1": native, "ap_1": ap_player}
11438 controller._add_protocol_link(native, ap_player, "airplay")
11439
11440 # The derived player has NO identifiers - the edge alone must resolve it
11441 derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
11442 controller._players["spb_1"] = derived
11443 controller._try_link_protocol_to_native(derived)
11444
11445 assert derived.protocol_parent_id == "native_1"
11446 # the derived link carries its base output, the non-derived link does not
11447 links = {link.output_protocol_id: link for link in native.linked_output_protocols}
11448 assert links["spb_1"].derived_from == "ap_1"
11449 assert links["ap_1"].derived_from is None
11450
11451 def test_waiting_derived_follows_when_underlying_links(self, mock_mass: MagicMock) -> None:
11452 """Test a derived player that registered early follows once its underlying links."""
11453 controller = PlayerController(mock_mass)
11454 mock_mass.config.get = MagicMock(return_value=[])
11455
11456 native = MockPlayer(
11457 MockProvider("heos", mass=mock_mass), "native_1", "AVR", player_type=PlayerType.PLAYER
11458 )
11459 native.set_initialized()
11460 ap_player = MockPlayer(
11461 MockProvider("airplay", mass=mock_mass),
11462 "ap_1",
11463 "AVR AirPlay",
11464 player_type=PlayerType.PROTOCOL,
11465 )
11466 ap_player.set_initialized()
11467 derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
11468 controller._players = {"native_1": native, "ap_1": ap_player, "spb_1": derived}
11469
11470 # Underlying is not linked yet: derived stays unlinked
11471 controller._try_link_protocol_to_native(derived)
11472 assert derived.protocol_parent_id is None
11473
11474 # Linking the underlying player propagates to the waiting derived player
11475 controller._add_protocol_link(native, ap_player, "airplay")
11476 assert derived.protocol_parent_id == "native_1"
11477
11478 def test_derived_links_directly_to_non_protocol_underlying(self, mock_mass: MagicMock) -> None:
11479 """Test a derived player parents to the underlying player itself when not a protocol."""
11480 controller = PlayerController(mock_mass)
11481 mock_mass.config.get = MagicMock(side_effect=lambda _key, default=None: default)
11482
11483 local_player = MockPlayer(
11484 MockProvider("local_audio", mass=mock_mass),
11485 "local_1",
11486 "Dummy Output",
11487 player_type=PlayerType.PLAYER,
11488 )
11489 local_player.set_initialized()
11490 derived = self._make_derived_player(mock_mass, "spb_1", "local_1")
11491 controller._players = {"spb_1": derived, "local_1": local_player}
11492
11493 # The final derived pass of _try_link_protocols_to_native picks it up
11494 controller._try_link_protocols_to_native(local_player)
11495
11496 assert derived.protocol_parent_id == "local_1"
11497 # riding on the parent player itself normalizes the base output to "native"
11498 links = {link.output_protocol_id: link for link in local_player.linked_output_protocols}
11499 assert links["spb_1"].derived_from == "native"
11500
11501 @pytest.mark.asyncio
11502 async def test_derived_delayed_eval_never_creates_universal_player(
11503 self, mock_mass: MagicMock
11504 ) -> None:
11505 """Test delayed evaluation of a derived player never wraps it in a universal player."""
11506 controller, _ = TestEndToEndDuplicateProtocol._setup_e2e_controller(mock_mass)
11507 mock_mass.config.get = MagicMock(return_value=[])
11508
11509 derived = self._make_derived_player(mock_mass, "spb_1", "ap_missing")
11510 controller._players = {"spb_1": derived}
11511
11512 await controller._delayed_protocol_evaluation("spb_1")
11513
11514 assert derived.protocol_parent_id is None
11515 assert not any(pid.startswith("up") for pid in controller._players)
11516
11517 def test_derived_link_refused_on_domain_duplicate(self, mock_mass: MagicMock) -> None:
11518 """Test a derived player stays unlinked when the parent already has its domain."""
11519 controller = PlayerController(mock_mass)
11520 mock_mass.config.get = MagicMock(return_value=[])
11521
11522 native = MockPlayer(
11523 MockProvider("heos", mass=mock_mass), "native_1", "AVR", player_type=PlayerType.PLAYER
11524 )
11525 native.set_initialized()
11526 ap_player = MockPlayer(
11527 MockProvider("airplay", mass=mock_mass),
11528 "ap_1",
11529 "AVR AirPlay",
11530 player_type=PlayerType.PROTOCOL,
11531 )
11532 ap_player.set_initialized()
11533 other_sendspin = MockPlayer(
11534 MockProvider("sendspin", mass=mock_mass),
11535 "sendspin_native_client",
11536 "AVR Sendspin",
11537 player_type=PlayerType.PROTOCOL,
11538 )
11539 other_sendspin.set_initialized()
11540 controller._players = {
11541 "native_1": native,
11542 "ap_1": ap_player,
11543 "sendspin_native_client": other_sendspin,
11544 }
11545 controller._add_protocol_link(native, ap_player, "airplay")
11546 controller._add_protocol_link(native, other_sendspin, "sendspin")
11547
11548 derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
11549 controller._players["spb_1"] = derived
11550 controller._try_link_protocol_to_native(derived)
11551
11552 assert derived.protocol_parent_id is None
11553
11554 def test_derived_joins_universal_parent(self, mock_mass: MagicMock) -> None:
11555 """Test a derived player joins the universal player of its underlying player."""
11556 controller = PlayerController(mock_mass)
11557 mock_mass.config.get = MagicMock(return_value=[])
11558
11559 def _close_coro(coro: Any, *_args: Any, **_kwargs: Any) -> None:
11560 if hasattr(coro, "close"):
11561 coro.close()
11562
11563 mock_mass.create_task = MagicMock(side_effect=_close_coro)
11564
11565 universal = _create_universal_player(
11566 mock_mass,
11567 "up_aabbccddeeff",
11568 "Living Room Speaker",
11569 protocol_player_ids=["ap_1"],
11570 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
11571 )
11572 ap_player = MockPlayer(
11573 MockProvider("airplay", mass=mock_mass),
11574 "ap_1",
11575 "AirPlay Speaker",
11576 player_type=PlayerType.PROTOCOL,
11577 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
11578 )
11579 ap_player.set_initialized()
11580 controller._players = {"up_aabbccddeeff": universal, "ap_1": ap_player}
11581 controller._add_protocol_link(universal, ap_player, "airplay")
11582
11583 derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
11584 controller._players["spb_1"] = derived
11585 controller._try_link_protocol_to_native(derived)
11586
11587 assert derived.protocol_parent_id == "up_aabbccddeeff"
11588 assert "spb_1" in universal._protocol_player_ids
11589
11590 @pytest.mark.parametrize("derived_first", [False, True])
11591 def test_derived_stays_with_refused_base_on_replacement(
11592 self, mock_mass: MagicMock, derived_first: bool
11593 ) -> None:
11594 """
11595 Keep a derived protocol with its base when the base's link is refused.
11596
11597 The native player absorbs the first universal player's cast protocol, so the
11598 second universal player's cast protocol is refused and stays behind. The
11599 sendspin bridge riding on it must stay behind too instead of moving on its own,
11600 whichever order the two links are listed in.
11601 """
11602 controller = PlayerController(mock_mass)
11603 up_provider = create_mock_universal_provider(mock_mass)
11604
11605 config_store: dict[str, Any] = {
11606 f"players/{player_id}": {"enabled": True}
11607 for player_id in ("native", "up_a", "up_b", "cc_1", "cc_2", "spb_2")
11608 }
11609 mock_mass.config.get = MagicMock(side_effect=config_store.get)
11610 mock_mass.config.set = MagicMock(side_effect=config_store.__setitem__)
11611 mock_mass.config.remove = MagicMock(side_effect=lambda key: config_store.pop(key, None))
11612 mock_mass.players = controller
11613 mock_mass.player_queues = MagicMock()
11614 mock_mass.call_later = MagicMock()
11615 mock_mass.loop = MagicMock()
11616 mock_mass.create_task = MagicMock()
11617
11618 mac = "AA:BB:CC:DD:EE:FF"
11619 identifiers = {IdentifierType.MAC_ADDRESS: mac}
11620 universals = {}
11621 for universal_id, protocol_ids in (("up_a", ["cc_1"]), ("up_b", ["cc_2", "spb_2"])):
11622 universal = UniversalPlayer(
11623 provider=up_provider,
11624 player_id=universal_id,
11625 name=f"Soundbar ({universal_id})",
11626 device_info=DeviceInfo(model="Test", manufacturer="Test"),
11627 protocol_player_ids=protocol_ids,
11628 )
11629 universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac)
11630 universal._cache.clear()
11631 universal.update_state(signal_event=False)
11632 universal.set_initialized()
11633 universals[universal_id] = universal
11634
11635 native = MockPlayer(
11636 MockProvider("linkplay", mass=mock_mass), "native", "Soundbar", identifiers=identifiers
11637 )
11638 native.set_initialized()
11639
11640 cast_provider = MockProvider("chromecast", mass=mock_mass)
11641 cast_players = {}
11642 for cast_id in ("cc_1", "cc_2"):
11643 cast_player = MockPlayer(
11644 cast_provider,
11645 cast_id,
11646 f"Cast {cast_id}",
11647 player_type=PlayerType.PROTOCOL,
11648 identifiers=identifiers,
11649 )
11650 cast_player.set_initialized()
11651 cast_players[cast_id] = cast_player
11652
11653 derived = self._make_derived_player(mock_mass, "spb_2", "cc_2")
11654
11655 controller._players = {
11656 **universals,
11657 **cast_players,
11658 "native": native,
11659 "spb_2": derived,
11660 }
11661 controller._add_protocol_link(universals["up_a"], cast_players["cc_1"], "chromecast")
11662 if derived_first:
11663 controller._add_protocol_link(universals["up_b"], derived, "sendspin")
11664 controller._add_protocol_link(universals["up_b"], cast_players["cc_2"], "chromecast")
11665 else:
11666 controller._add_protocol_link(universals["up_b"], cast_players["cc_2"], "chromecast")
11667 controller._add_protocol_link(universals["up_b"], derived, "sendspin")
11668
11669 controller._check_replace_universal_player(native)
11670
11671 # the second universal player keeps the refused cast protocol and its bridge
11672 assert cast_players["cc_2"].protocol_parent_id == "up_b"
11673 assert derived.protocol_parent_id == "up_b"
11674 assert {link.output_protocol_id for link in universals["up_b"].linked_output_protocols} == {
11675 "cc_2",
11676 "spb_2",
11677 }
11678 # the native player only took over the protocol it could actually claim
11679 assert {link.output_protocol_id for link in native.linked_output_protocols} == {"cc_1"}
11680 assert "spb_2" not in (config_store.get("players/native/values/linked_protocol_ids") or [])
11681
11682 def test_base_stays_with_refused_derived_on_replacement(self, mock_mass: MagicMock) -> None:
11683 """
11684 Keep a base protocol with its derived protocol when the derived link is refused.
11685
11686 The native player already serves the sendspin domain, so the universal player's
11687 sendspin bridge cannot move. The cast protocol it rides on must stay behind with
11688 it instead of moving on its own.
11689 """
11690 controller = PlayerController(mock_mass)
11691 up_provider = create_mock_universal_provider(mock_mass)
11692
11693 config_store: dict[str, Any] = {
11694 f"players/{player_id}": {"enabled": True}
11695 for player_id in ("native", "up_b", "cc_2", "spb_2", "spb_native")
11696 }
11697 mock_mass.config.get = MagicMock(side_effect=config_store.get)
11698 mock_mass.config.set = MagicMock(side_effect=config_store.__setitem__)
11699 mock_mass.config.remove = MagicMock(side_effect=lambda key: config_store.pop(key, None))
11700 mock_mass.players = controller
11701 mock_mass.player_queues = MagicMock()
11702 mock_mass.call_later = MagicMock()
11703 mock_mass.loop = MagicMock()
11704 mock_mass.create_task = MagicMock()
11705
11706 mac = "AA:BB:CC:DD:EE:FF"
11707 identifiers = {IdentifierType.MAC_ADDRESS: mac}
11708 universal = UniversalPlayer(
11709 provider=up_provider,
11710 player_id="up_b",
11711 name="Soundbar (Universal)",
11712 device_info=DeviceInfo(model="Test", manufacturer="Test"),
11713 protocol_player_ids=["cc_2", "spb_2"],
11714 )
11715 universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac)
11716 universal._cache.clear()
11717 universal.update_state(signal_event=False)
11718 universal.set_initialized()
11719
11720 native = MockPlayer(
11721 MockProvider("linkplay", mass=mock_mass), "native", "Soundbar", identifiers=identifiers
11722 )
11723 native.set_initialized()
11724 cast_player = MockPlayer(
11725 MockProvider("chromecast", mass=mock_mass),
11726 "cc_2",
11727 "Cast",
11728 player_type=PlayerType.PROTOCOL,
11729 identifiers=identifiers,
11730 )
11731 cast_player.set_initialized()
11732 derived = self._make_derived_player(mock_mass, "spb_2", "cc_2")
11733 sendspin_native = MockPlayer(
11734 MockProvider("sendspin", mass=mock_mass),
11735 "spb_native",
11736 "Soundbar (Sendspin)",
11737 player_type=PlayerType.PROTOCOL,
11738 identifiers=identifiers,
11739 )
11740 sendspin_native.set_initialized()
11741
11742 controller._players = {
11743 "up_b": universal,
11744 "native": native,
11745 "cc_2": cast_player,
11746 "spb_2": derived,
11747 "spb_native": sendspin_native,
11748 }
11749 controller._add_protocol_link(universal, cast_player, "chromecast")
11750 controller._add_protocol_link(universal, derived, "sendspin")
11751 controller._add_protocol_link(native, sendspin_native, "sendspin")
11752
11753 controller._check_replace_universal_player(native)
11754
11755 # the bridge could not move, so the cast protocol it rides on stays with it
11756 assert derived.protocol_parent_id == "up_b"
11757 assert cast_player.protocol_parent_id == "up_b"
11758 assert {link.output_protocol_id for link in universal.linked_output_protocols} == {
11759 "cc_2",
11760 "spb_2",
11761 }
11762 assert {link.output_protocol_id for link in native.linked_output_protocols} == {
11763 "spb_native"
11764 }
11765
11766 def test_save_underlying_player_id_persists_and_clears(self, mock_mass: MagicMock) -> None:
11767 """Test the derived edge is persisted, cleared when revoked and skipped otherwise."""
11768 controller = PlayerController(mock_mass)
11769 derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
11770 conf_key = "players/spb_1/values/underlying_player_id"
11771 stored: dict[str, Any] = {"players/spb_1": {"values": {}}}
11772 mock_mass.config.get = MagicMock(
11773 side_effect=lambda key, default=None: stored.get(key, default)
11774 )
11775 mock_mass.config.set = MagicMock(side_effect=stored.__setitem__)
11776
11777 # derived player: edge gets persisted
11778 controller._save_underlying_player_id(derived)
11779 assert stored[conf_key] == "ap_1"
11780
11781 # edge revoked (e.g. bridge client turned web player): stale value is cleared
11782 derived._attr_underlying_player_id = None
11783 controller._save_underlying_player_id(derived)
11784 assert stored[conf_key] is None
11785
11786 # nothing persisted and no edge: no write at all
11787 mock_mass.config.set.reset_mock()
11788 del stored[conf_key]
11789 controller._save_underlying_player_id(derived)
11790 mock_mass.config.set.assert_not_called()
11791
11792 # player config gone: never create partial entries
11793 derived._attr_underlying_player_id = "ap_1"
11794 del stored["players/spb_1"]
11795 controller._save_underlying_player_id(derived)
11796 mock_mass.config.set.assert_not_called()
11797
11798
11799class TestLocalAudioPlayerPromotion:
11800 """
11801 Tests for the local_audio reshape: attribution stub -> regular native player.
11802
11803 The local_audio device player (bare device-uuid player_id) is a regular,
11804 visible PLAYER that parents its Sendspin bridge directly. On upgrade, the
11805 previously auto-created universal player wrapper (up<uuid-hex>) must be
11806 absorbed by the native player without re-wrapping it.
11807 """
11808
11809 DEVICE_UUID = "aabbccdd-1122-3344-5566-77889900aabb"
11810 UNIVERSAL_ID = "upaabbccdd11223344556677889900aabb"
11811
11812 def test_native_local_player_absorbs_universal_wrapper(self, mock_mass: MagicMock) -> None:
11813 """The native player takes over the bridge protocol and the wrapper is emptied."""
11814 universal = _create_universal_player(
11815 mock_mass,
11816 self.UNIVERSAL_ID,
11817 "Dummy Output",
11818 protocol_player_ids=[self.DEVICE_UUID, "spb_1"],
11819 )
11820 native = MockPlayer(
11821 MockProvider("local_audio", mass=mock_mass),
11822 self.DEVICE_UUID,
11823 "Dummy Output",
11824 player_type=PlayerType.PLAYER,
11825 identifiers={IdentifierType.UUID: self.DEVICE_UUID},
11826 )
11827 native.set_initialized()
11828 spb = MockPlayer(
11829 MockProvider("sendspin", mass=mock_mass),
11830 "spb_1",
11831 "Dummy Output Sendspin",
11832 player_type=PlayerType.PROTOCOL,
11833 )
11834 spb._attr_underlying_player_id = self.DEVICE_UUID
11835 spb.set_initialized()
11836
11837 store: dict[str, Any] = {
11838 f"players/{self.DEVICE_UUID}": {"enabled": True},
11839 "players/spb_1": {"enabled": True},
11840 }
11841 mock_mass.config.get = MagicMock(
11842 side_effect=lambda key, default=None: store.get(key, default)
11843 )
11844 mock_mass.config.set = MagicMock(side_effect=store.__setitem__)
11845
11846 def _close_coro(coro: Any, *_args: Any, **_kwargs: Any) -> None:
11847 if hasattr(coro, "close"):
11848 coro.close()
11849
11850 mock_mass.create_task = MagicMock(side_effect=_close_coro)
11851 mock_mass.players = controller = PlayerController(mock_mass)
11852 controller._players = {
11853 self.UNIVERSAL_ID: universal,
11854 self.DEVICE_UUID: native,
11855 "spb_1": spb,
11856 }
11857 controller._add_protocol_link(universal, spb, "sendspin")
11858
11859 controller._try_link_protocols_to_native(native)
11860
11861 assert spb.protocol_parent_id == self.DEVICE_UUID
11862 assert [link.output_protocol_id for link in native.linked_output_protocols] == ["spb_1"]
11863 # the obsolete wrapper keeps neither the bridge nor the native player itself,
11864 # so its permanent cleanup has no orphaned protocols to re-evaluate
11865 assert universal._protocol_player_ids == []
11866 assert store.get("players/spb_1/values/protocol_parent_id") == self.DEVICE_UUID
11867
11868 async def test_delayed_eval_skips_type_changed_player(self, mock_mass: MagicMock) -> None:
11869 """A pending evaluation for a player that is not (or no longer) a protocol is a no-op."""
11870 controller, _ = TestEndToEndDuplicateProtocol._setup_e2e_controller(mock_mass)
11871 mock_mass.config.get = MagicMock(return_value=[])
11872
11873 native = MockPlayer(
11874 MockProvider("local_audio", mass=mock_mass),
11875 self.DEVICE_UUID,
11876 "Dummy Output",
11877 player_type=PlayerType.PLAYER,
11878 )
11879 native.set_initialized()
11880 controller._players = {self.DEVICE_UUID: native}
11881
11882 await controller._delayed_protocol_evaluation(self.DEVICE_UUID)
11883
11884 assert native.protocol_parent_id is None
11885 assert not any(pid.startswith("up") for pid in controller._players)
11886
11887
11888class TestLocalAudioStubMigration:
11889 """Tests for the config migration that promotes local_audio stubs to regular players."""
11890
11891 DEVICE_UUID = "aabbccdd-1122-3344-5566-77889900aabb"
11892 UNIVERSAL_ID = "upaabbccdd11223344556677889900aabb"
11893
11894 def _make_data(self) -> dict[str, Any]:
11895 """Build a settings dict as persisted by the previous local_audio setup."""
11896 return {
11897 CONF_PLAYERS: {
11898 self.DEVICE_UUID: {
11899 "player_id": self.DEVICE_UUID,
11900 "provider": "local_audio",
11901 "player_type": "protocol",
11902 "enabled": True,
11903 "values": {"protocol_parent_id": self.UNIVERSAL_ID},
11904 },
11905 self.UNIVERSAL_ID: {
11906 "player_id": self.UNIVERSAL_ID,
11907 "provider": "universal_player",
11908 "player_type": "group",
11909 "enabled": False,
11910 "name": "Kitchen Output",
11911 "values": {
11912 # ghost_spb has no config anymore (device gone) - must not crash
11913 "linked_protocol_ids": [self.DEVICE_UUID, "spb_1", "ghost_spb"],
11914 "device_identifiers": {"uuid": self.DEVICE_UUID},
11915 "device_info": {"model": "Dummy Output", "manufacturer": "Local Audio"},
11916 "expose_to_ha": False,
11917 "hide_in_ui": True,
11918 },
11919 },
11920 "spb_1": {
11921 "player_id": "spb_1",
11922 "provider": "sendspin",
11923 "player_type": "protocol",
11924 "enabled": True,
11925 "values": {"protocol_parent_id": self.UNIVERSAL_ID},
11926 },
11927 "syncgroup_1": {
11928 "player_id": "syncgroup_1",
11929 "provider": "sync_group",
11930 "player_type": "group",
11931 "enabled": True,
11932 "values": {
11933 "group_members": [self.UNIVERSAL_ID, "other_player"],
11934 "allowed_members": [self.UNIVERSAL_ID],
11935 },
11936 },
11937 },
11938 CONF_PLAYER_QUEUES: {
11939 self.UNIVERSAL_ID: {
11940 "queue_id": self.UNIVERSAL_ID,
11941 "values": {"autoplay_mode": "smart"},
11942 },
11943 },
11944 CONF_PLAYER_DSP: {
11945 self.UNIVERSAL_ID: {"enabled": True},
11946 },
11947 }
11948
11949 async def test_stub_promoted_and_universal_absorbed(self) -> None:
11950 """The stub becomes a regular player carrying the universal player's settings."""
11951 data = self._make_data()
11952
11953 assert await migrate(data) is True
11954
11955 stub_cfg = data[CONF_PLAYERS][self.DEVICE_UUID]
11956 assert stub_cfg["player_type"] == "player"
11957 # the universal player was the device toggle the user operated
11958 assert stub_cfg["enabled"] is False
11959 assert stub_cfg["name"] == "Kitchen Output"
11960 values = stub_cfg["values"]
11961 assert "protocol_parent_id" not in values
11962 assert values["expose_to_ha"] is False
11963 assert values["hide_in_ui"] is True
11964 assert values["linked_protocol_ids"] == ["spb_1", "ghost_spb"]
11965 assert "device_identifiers" not in values
11966 assert "device_info" not in values
11967
11968 # the wrapper is gone and the bridge protocol re-parented
11969 assert self.UNIVERSAL_ID not in data[CONF_PLAYERS]
11970 assert data[CONF_PLAYERS]["spb_1"]["values"]["protocol_parent_id"] == self.DEVICE_UUID
11971 assert data[CONF_PLAYERS]["spb_1"]["player_type"] == "protocol"
11972
11973 # queue settings, DSP config and group memberships follow the new player_id
11974 queue_cfg = data[CONF_PLAYER_QUEUES][self.DEVICE_UUID]
11975 assert queue_cfg["queue_id"] == self.DEVICE_UUID
11976 assert queue_cfg["values"]["autoplay_mode"] == "smart"
11977 assert self.UNIVERSAL_ID not in data[CONF_PLAYER_QUEUES]
11978 assert data[CONF_PLAYER_DSP] == {self.DEVICE_UUID: {"enabled": True}}
11979 group_values = data[CONF_PLAYERS]["syncgroup_1"]["values"]
11980 assert group_values["group_members"] == [self.DEVICE_UUID, "other_player"]
11981 assert group_values["allowed_members"] == [self.DEVICE_UUID]
11982
11983 async def test_stub_without_universal_wrapper(self) -> None:
11984 """A stub without a universal player wrapper is still promoted."""
11985 data = self._make_data()
11986 del data[CONF_PLAYERS][self.UNIVERSAL_ID]
11987
11988 assert await migrate(data) is True
11989
11990 stub_cfg = data[CONF_PLAYERS][self.DEVICE_UUID]
11991 assert stub_cfg["player_type"] == "player"
11992 assert stub_cfg["enabled"] is True
11993 assert "protocol_parent_id" not in stub_cfg["values"]
11994
11995 async def test_migration_is_idempotent(self) -> None:
11996 """A second run finds nothing left to migrate."""
11997 data = self._make_data()
11998
11999 assert await migrate(data) is True
12000 assert await migrate(data) is False
12001
12002
12003class _LockedMockPlayer(MockPlayer):
12004 """A player that keeps its group read-only (e.g. an externally-created mixed group)."""
12005
12006 @property
12007 def grouping_locked(self) -> bool:
12008 """Suppress grouping in the exposed state."""
12009 return True
12010
12011
12012class TestGroupingLockedPolicy:
12013 """A grouping-locked player withholds SET_MEMBERS and group targets in its final state."""
12014
12015 def test_locked_player_withholds_set_members(self, mock_mass: MagicMock) -> None:
12016 """A locked player drops SET_MEMBERS from its exposed features."""
12017 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
12018 player = _LockedMockPlayer(provider, "shell_1", "Shell")
12019 player._attr_supported_features = {PlayerFeature.SET_MEMBERS, PlayerFeature.VOLUME_SET}
12020 player._cache.clear()
12021 player.update_state(signal_event=False)
12022 assert PlayerFeature.SET_MEMBERS not in player.state.supported_features
12023 assert PlayerFeature.VOLUME_SET in player.state.supported_features
12024
12025 def test_locked_player_withholds_protocol_derived_set_members(
12026 self, mock_mass: MagicMock
12027 ) -> None:
12028 """Even a linked protocol player's SET_MEMBERS is suppressed while locked."""
12029 controller = PlayerController(mock_mass)
12030 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
12031 player = _LockedMockPlayer(provider, "shell_1", "Shell")
12032 player._attr_supported_features = set() # no native grouping of its own
12033 protocol = MockPlayer(provider, "airplay_p", "P (AirPlay)", player_type=PlayerType.PROTOCOL)
12034 protocol._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
12035 protocol._cache.clear()
12036 protocol.set_protocol_parent_id("shell_1")
12037 player.set_linked_output_protocols(
12038 [
12039 LinkedOutputProtocol(
12040 output_protocol_id="airplay_p", protocol_domain="airplay", priority=10
12041 )
12042 ]
12043 )
12044 mock_mass.players = controller
12045 controller._players = {"shell_1": player, "airplay_p": protocol}
12046 protocol.refresh_state(signal_event=False)
12047 player.refresh_state(signal_event=False)
12048 assert PlayerFeature.SET_MEMBERS not in player.state.supported_features
12049
12050 def test_locked_player_offers_no_group_targets(self, mock_mass: MagicMock) -> None:
12051 """A locked player exposes no group targets, even an otherwise groupable peer."""
12052 controller = PlayerController(mock_mass)
12053 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
12054 player = _LockedMockPlayer(provider, "shell_1", "Shell")
12055 player._attr_supported_features = {PlayerFeature.SET_MEMBERS}
12056 player._attr_can_group_with = {"peer_1"}
12057 peer = MockPlayer(provider, "peer_1", "Peer")
12058 mock_mass.players = controller
12059 controller._players = {"shell_1": player, "peer_1": peer}
12060 player._cache.clear()
12061 peer.refresh_state(signal_event=False)
12062 player.refresh_state(signal_event=False)
12063 assert player.state.can_group_with == set()
12064
12065 def test_unlocked_player_keeps_grouping(self, mock_mass: MagicMock) -> None:
12066 """A normal (unlocked) player keeps SET_MEMBERS and its group targets."""
12067 controller = PlayerController(mock_mass)
12068 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
12069 player = MockPlayer(provider, "shell_2", "Shell2")
12070 player._attr_supported_features = {PlayerFeature.SET_MEMBERS}
12071 player._attr_can_group_with = {"peer_2"}
12072 peer = MockPlayer(provider, "peer_2", "Peer2")
12073 mock_mass.players = controller
12074 controller._players = {"shell_2": player, "peer_2": peer}
12075 player._cache.clear()
12076 peer.refresh_state(signal_event=False)
12077 player.refresh_state(signal_event=False)
12078 assert PlayerFeature.SET_MEMBERS in player.state.supported_features
12079 assert "peer_2" in player.state.can_group_with
12080
12081 def test_locked_peer_excluded_from_group_targets(self, mock_mass: MagicMock) -> None:
12082 """An unlocked leader drops a locked peer but keeps an unlocked one as a target."""
12083 controller = PlayerController(mock_mass)
12084 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
12085 leader = MockPlayer(provider, "shell_1", "Shell")
12086 leader._attr_supported_features = {PlayerFeature.SET_MEMBERS}
12087 leader._attr_can_group_with = {"locked_peer", "open_peer"}
12088 locked_peer = _LockedMockPlayer(provider, "locked_peer", "Locked Peer")
12089 open_peer = MockPlayer(provider, "open_peer", "Open Peer")
12090 mock_mass.players = controller
12091 controller._players = {
12092 "shell_1": leader,
12093 "locked_peer": locked_peer,
12094 "open_peer": open_peer,
12095 }
12096 leader._cache.clear()
12097 for player in (locked_peer, open_peer, leader):
12098 player.refresh_state(signal_event=False)
12099 # the locked peer keeps its own group read-only, so it is not offered as a target,
12100 # while the ordinary peer still is
12101 assert "locked_peer" not in leader.state.can_group_with
12102 assert "open_peer" in leader.state.can_group_with
12103
12104 def test_linked_protocol_cannot_reintroduce_locked_member(self, mock_mass: MagicMock) -> None:
12105 """A locked member offered by a linked protocol is still kept out of the targets."""
12106 controller = PlayerController(mock_mass)
12107 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
12108 leader = MockPlayer(provider, "shell_1", "Shell")
12109 leader._attr_supported_features = set() # no native grouping of its own
12110 protocol = MockPlayer(provider, "airplay_p", "P (AirPlay)", player_type=PlayerType.PROTOCOL)
12111 protocol._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
12112 protocol._attr_can_group_with = {"locked_peer", "open_peer"}
12113 protocol._cache.clear()
12114 protocol.set_protocol_parent_id("shell_1")
12115 leader.set_linked_output_protocols(
12116 [
12117 LinkedOutputProtocol(
12118 output_protocol_id="airplay_p", protocol_domain="airplay", priority=10
12119 )
12120 ]
12121 )
12122 locked_peer = _LockedMockPlayer(provider, "locked_peer", "Locked Peer")
12123 open_peer = MockPlayer(provider, "open_peer", "Open Peer")
12124 mock_mass.players = controller
12125 controller._players = {
12126 "shell_1": leader,
12127 "airplay_p": protocol,
12128 "locked_peer": locked_peer,
12129 "open_peer": open_peer,
12130 }
12131 for player in (locked_peer, open_peer, protocol):
12132 player.refresh_state(signal_event=False)
12133 leader._cache.clear()
12134 leader.refresh_state(signal_event=False)
12135 # the linked protocol lists the locked peer, but the final filter still excludes it
12136 assert "locked_peer" not in leader.state.can_group_with
12137 assert "open_peer" in leader.state.can_group_with
12138
12139
12140class _NativeGroupingMockPlayer(MockPlayer):
12141 """A player that runs its own multiroom and prefers native grouping."""
12142
12143 @property
12144 def prefer_native_grouping(self) -> bool:
12145 """Prefer native grouping over any linked protocol."""
12146 return True
12147
12148
12149class _RejectsNativeGroupingMockPlayer(MockPlayer):
12150 """A parent that never accepts another player as a native group peer."""
12151
12152 def is_native_group_compatible(self, other: Player) -> bool:
12153 """Reject every peer as native-incompatible (e.g. a cross-backend device)."""
12154 return False
12155
12156
12157class TestPreferNativeGrouping:
12158 """A player that prefers native grouping uses it before its preferred output protocol."""
12159
12160 def test_prefer_native_uses_native_before_child_preferred(self, mock_mass: MagicMock) -> None:
12161 """A prefer-native child groups natively even with an explicit protocol preference."""
12162 controller = PlayerController(mock_mass)
12163 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
12164 parent = MockPlayer(provider, "shell_parent", "Parent")
12165 child = _NativeGroupingMockPlayer(provider, "shell_child", "Child")
12166 # an explicit child output-protocol preference that would otherwise win
12167 mock_mass.config.get_raw_player_config_value = MagicMock(return_value="airplay_child")
12168 mock_mass.players = controller
12169 controller._players = {"shell_parent": parent, "shell_child": child}
12170 native_members: list[str] = []
12171 protocol_members: list[str] = []
12172 controller._select_grouping_for_member(
12173 child, parent, None, None, True, protocol_members, native_members
12174 )
12175 assert native_members == ["shell_child"]
12176 assert protocol_members == []
12177
12178 def test_prefer_native_falls_through_same_instance_not_native_peer(
12179 self, mock_mass: MagicMock
12180 ) -> None:
12181 """A same-instance pair the parent rejects as a native peer is not forced native."""
12182 controller = PlayerController(mock_mass)
12183 # official + generic LinkPlay share one wiim provider instance, but a cross-backend
12184 # peer is not native-group compatible, so the seam must not force native grouping.
12185 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
12186 parent = _RejectsNativeGroupingMockPlayer(provider, "shell_parent", "Parent")
12187 child = _NativeGroupingMockPlayer(provider, "shell_child", "Child")
12188 mock_mass.config.get_raw_player_config_value = MagicMock(return_value=None)
12189 mock_mass.players = controller
12190 controller._players = {"shell_parent": parent, "shell_child": child}
12191 native_members: list[str] = []
12192 protocol_members: list[str] = []
12193 controller._select_grouping_for_member(
12194 child, parent, None, None, True, protocol_members, native_members
12195 )
12196 assert native_members == []
12197
12198 def test_prefer_native_falls_through_when_incompatible(self, mock_mass: MagicMock) -> None:
12199 """When native grouping is not possible (cross-backend), the seam does not force it."""
12200 controller = PlayerController(mock_mass)
12201 parent_provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
12202 child_provider = MockProvider("other", instance_id="other_instance", mass=mock_mass)
12203 parent = MockPlayer(parent_provider, "shell_parent", "Parent")
12204 parent._attr_can_group_with = set() # does not accept the cross-backend child
12205 child = _NativeGroupingMockPlayer(child_provider, "other_child", "Child")
12206 mock_mass.config.get_raw_player_config_value = MagicMock(return_value=None)
12207 mock_mass.players = controller
12208 controller._players = {"shell_parent": parent, "other_child": child}
12209 native_members: list[str] = []
12210 protocol_members: list[str] = []
12211 controller._select_grouping_for_member(
12212 child, parent, None, None, True, protocol_members, native_members
12213 )
12214 # the preferred-native seam did not force a cross-backend native group
12215 assert native_members == []
12216
12217 def test_default_player_keeps_protocol_first_ordering(self, mock_mass: MagicMock) -> None:
12218 """An ordinary player (no native preference) is unaffected by the seam."""
12219 controller = PlayerController(mock_mass)
12220 provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
12221 parent = MockPlayer(provider, "shell_parent", "Parent")
12222 child = MockPlayer(provider, "plain_child", "Child")
12223 assert child.prefer_native_grouping is False
12224 mock_mass.config.get_raw_player_config_value = MagicMock(return_value=None)
12225 mock_mass.players = controller
12226 controller._players = {"shell_parent": parent, "plain_child": child}
12227 native_members: list[str] = []
12228 protocol_members: list[str] = []
12229 controller._select_grouping_for_member(
12230 child, parent, None, None, True, protocol_members, native_members
12231 )
12232 # with no preferred/active/common protocol, the default player still reaches
12233 # native grouping via the normal priority (Priority 3), not the preferred seam
12234 assert native_members == ["plain_child"]
12235