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