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