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