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