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