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