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