/
/
/
1"""AirPlay Player implementations."""
2
3from __future__ import annotations
4
5import asyncio
6import time
7from typing import TYPE_CHECKING, cast
8
9from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
10from music_assistant_models.enums import (
11 ConfigEntryType,
12 IdentifierType,
13 PlaybackState,
14 PlayerFeature,
15 PlayerType,
16)
17
18from music_assistant.constants import CONF_ENTRY_SYNC_ADJUST, create_sample_rates_config_entry
19from music_assistant.helpers.util import is_valid_mac_address
20from music_assistant.models.player import DeviceInfo, Player, PlayerMedia
21
22from .constants import (
23 AIRPLAY_DISCOVERY_TYPE,
24 AIRPLAY_FLOW_PCM_FORMAT,
25 BASE_PLAYER_FEATURES,
26 BROKEN_AIRPLAY_WARN,
27 CACHE_CATEGORY_PREV_VOLUME,
28 CONF_ACTION_FINISH_PAIRING,
29 CONF_ACTION_RESET_PAIRING,
30 CONF_ACTION_START_PAIRING,
31 CONF_AIRPLAY_CREDENTIALS,
32 CONF_AIRPLAY_PROTOCOL,
33 CONF_ALAC_ENCODE,
34 CONF_ENCRYPTION,
35 CONF_IGNORE_VOLUME,
36 CONF_PAIRING_PIN,
37 CONF_PASSWORD,
38 CONF_RAOP_CREDENTIALS,
39 FALLBACK_VOLUME,
40 LEGACY_PAIRING_BIT,
41 PIN_REQUIRED,
42 RAOP_DISCOVERY_TYPE,
43 StreamingProtocol,
44)
45from .helpers import (
46 get_primary_ip_address_from_zeroconf,
47 is_airplay2_preferred_model,
48 is_apple_device,
49 is_broken_airplay_model,
50 player_id_to_mac_address,
51)
52from .stream_session import AirPlayStreamSession
53
54if TYPE_CHECKING:
55 from zeroconf.asyncio import AsyncServiceInfo
56
57 from .pairing import AirPlayPairing
58 from .protocols._protocol import AirPlayProtocol
59 from .protocols.airplay2 import AirPlay2Stream
60 from .protocols.raop import RaopStream
61 from .provider import AirPlayProvider
62
63
64class AirPlayPlayer(Player):
65 """AirPlay Player implementation."""
66
67 def __init__(
68 self,
69 provider: AirPlayProvider,
70 player_id: str,
71 raop_discovery_info: AsyncServiceInfo | None,
72 airplay_discovery_info: AsyncServiceInfo | None,
73 address: str,
74 display_name: str,
75 manufacturer: str,
76 model: str,
77 initial_volume: int = FALLBACK_VOLUME,
78 ) -> None:
79 """Initialize AirPlayPlayer."""
80 self.raop_discovery_info = raop_discovery_info
81 self.airplay_discovery_info = airplay_discovery_info
82 super().__init__(provider, player_id)
83 self.address = address
84 self.stream: RaopStream | AirPlay2Stream | None = None
85 self.last_command_sent = 0.0
86 self._lock = asyncio.Lock()
87 self._active_pairing: AirPlayPairing | None = None
88 self._transitioning = False # Set during stream replacement to ignore stale DACP messages
89 # Set (static) player attributes
90 self._attr_name = display_name
91 self._attr_available = True
92 mac_address = player_id_to_mac_address(player_id)
93 self._attr_device_info = DeviceInfo(
94 model=model,
95 manufacturer=manufacturer,
96 )
97 # Only add MAC address if it's valid (not 00:00:00:00:00:00)
98 if is_valid_mac_address(mac_address):
99 self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
100 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, address)
101 self._attr_volume_level = initial_volume
102 self._attr_can_group_with = {provider.instance_id}
103 self._attr_enabled_by_default = not is_broken_airplay_model(manufacturer, model)
104
105 # Set player type based on manufacturer:
106 # - Apple devices (HomePod, Apple TV, Mac) have native AirPlay support -> PLAYER
107 # - Non-Apple devices are generic AirPlay receivers -> PROTOCOL (wrapped in UniversalPlayer)
108 if is_apple_device(manufacturer):
109 self._attr_type = PlayerType.PLAYER
110 else:
111 self._attr_type = PlayerType.PROTOCOL
112
113 @property
114 def protocol(self) -> StreamingProtocol:
115 """Get the streaming protocol to use/prefer for this player."""
116 preferred_option = cast("int", self.config.get_value(CONF_AIRPLAY_PROTOCOL))
117 return self._get_protocol_for_config_value(preferred_option)
118
119 @property
120 def available(self) -> bool:
121 """Return if the player is currently available."""
122 if self._requires_pairing():
123 # check if we have credentials stored for the current protocol
124 creds_key = self._get_credentials_key(self.protocol)
125 if not self.config.get_value(creds_key):
126 return False
127 return super().available
128
129 @property
130 def requires_flow_mode(self) -> bool:
131 """Return if the player requires flow mode."""
132 return True
133
134 @property
135 def supported_features(self) -> set[PlayerFeature]:
136 """Return the supported features of this player."""
137 features = set(BASE_PLAYER_FEATURES)
138 if not (self.group_members or self.synced_to):
139 # we only support pause when the player is not synced,
140 # because we don't want to deal with the complexity of pausing a group of players
141 # so in this case stop will be used to pause the stream instead of pausing it,
142 # which is a common approach for AirPlay players
143 features.add(PlayerFeature.PAUSE)
144 return features
145
146 async def get_config_entries(
147 self,
148 action: str | None = None,
149 values: dict[str, ConfigValueType] | None = None,
150 ) -> list[ConfigEntry]:
151 """Return all (provider/player specific) Config Entries for the given player (if any)."""
152 base_entries: list[ConfigEntry] = []
153 require_pairing = self._requires_pairing()
154
155 # Handle pairing actions
156 if action and require_pairing:
157 await self._handle_pairing_action(action=action, values=values)
158
159 # Add pairing config entries for Apple TV and macOS devices
160 if require_pairing:
161 base_entries = [*self._get_pairing_config_entries(values)]
162
163 # Regular AirPlay config entries
164 base_entries += [
165 ConfigEntry(
166 key=CONF_AIRPLAY_PROTOCOL,
167 type=ConfigEntryType.INTEGER,
168 required=False,
169 label="AirPlay protocol version to use for streaming",
170 description="AirPlay version 1 protocol uses RAOP.\n"
171 "AirPlay version 2 is an extension of RAOP.\n"
172 "Some newer devices do not fully support RAOP and "
173 "will only work with AirPlay version 2, "
174 "while older devices may only support RAOP.\n\n"
175 "In most cases the default automatic selection will work fine.",
176 options=[
177 ConfigValueOption("Automatically select", 0),
178 ConfigValueOption("Prefer AirPlay 1 (RAOP)", StreamingProtocol.RAOP.value),
179 ConfigValueOption("Prefer AirPlay 2", StreamingProtocol.AIRPLAY2.value),
180 ],
181 default_value=0,
182 category="protocol_generic",
183 ),
184 ConfigEntry(
185 key=CONF_ENCRYPTION,
186 type=ConfigEntryType.BOOLEAN,
187 default_value=True,
188 label="Enable encryption",
189 description="Enable encrypted communication with the player, "
190 "some (3rd party) players require this to be disabled.",
191 depends_on=CONF_AIRPLAY_PROTOCOL,
192 depends_on_value=StreamingProtocol.RAOP.value,
193 hidden=self.protocol != StreamingProtocol.RAOP,
194 category="protocol_generic",
195 advanced=True,
196 ),
197 ConfigEntry(
198 key=CONF_ALAC_ENCODE,
199 type=ConfigEntryType.BOOLEAN,
200 default_value=True,
201 label="Enable compression",
202 description="Save some network bandwidth by sending the audio as "
203 "(lossless) ALAC at the cost of a bit of CPU.",
204 depends_on=CONF_AIRPLAY_PROTOCOL,
205 depends_on_value=StreamingProtocol.RAOP.value,
206 hidden=self.protocol != StreamingProtocol.RAOP,
207 category="protocol_generic",
208 advanced=True,
209 ),
210 CONF_ENTRY_SYNC_ADJUST,
211 ConfigEntry(
212 key=CONF_PASSWORD,
213 type=ConfigEntryType.SECURE_STRING,
214 default_value=None,
215 required=False,
216 label="Device password",
217 description="Some devices require a password to connect/play.",
218 category="protocol_generic",
219 advanced=True,
220 ),
221 # airplay has fixed sample rate/bit depth so make this config entry static and hidden
222 create_sample_rates_config_entry(
223 supported_sample_rates=[44100], supported_bit_depths=[16], hidden=True
224 ),
225 ConfigEntry(
226 key=CONF_IGNORE_VOLUME,
227 type=ConfigEntryType.BOOLEAN,
228 default_value=False,
229 label="Ignore volume reports sent by the device itself",
230 description=(
231 "The AirPlay protocol allows devices to report their own volume "
232 "level. \n"
233 "For some devices this is not reliable and can cause unexpected "
234 "volume changes. \n"
235 "Enable this option to ignore these reports."
236 ),
237 category="protocol_generic",
238 # TODO: remove depends_on when DACP support is added for AirPlay2
239 depends_on=CONF_AIRPLAY_PROTOCOL,
240 depends_on_value=StreamingProtocol.RAOP.value,
241 hidden=self.protocol != StreamingProtocol.RAOP,
242 advanced=True,
243 ),
244 ]
245
246 if is_broken_airplay_model(self.device_info.manufacturer, self.device_info.model):
247 base_entries.insert(-1, BROKEN_AIRPLAY_WARN)
248
249 return base_entries
250
251 def _get_flags(self) -> int:
252 # Flags are either present via "sf" or "flags. Taken from pyatv.protocols.airplay.utils"
253 if self.airplay_discovery_info:
254 properties = self.airplay_discovery_info.properties
255 elif self.raop_discovery_info:
256 properties = self.raop_discovery_info.properties
257 else:
258 return 0
259
260 flags = properties.get(b"sf") or properties.get(b"flags") or "0x0"
261 return int(flags, 16)
262
263 def _requires_pairing(self) -> bool:
264 """Check if this device requires pairing.
265
266 Adapted from pyatv.protocols.airplay.utils.get_pairing_requirement.
267 """
268 return bool(self._get_flags() & (LEGACY_PAIRING_BIT | PIN_REQUIRED))
269
270 def _get_credentials_key(self, protocol: StreamingProtocol) -> str:
271 """Get the config key for credentials for given protocol."""
272 if protocol == StreamingProtocol.RAOP:
273 return CONF_RAOP_CREDENTIALS
274 return CONF_AIRPLAY_CREDENTIALS
275
276 def _get_protocol_for_config_value(self, config_option: int) -> StreamingProtocol:
277 if config_option == StreamingProtocol.AIRPLAY2 and self.airplay_discovery_info:
278 return StreamingProtocol.AIRPLAY2
279 if config_option == StreamingProtocol.RAOP and self.raop_discovery_info:
280 return StreamingProtocol.RAOP
281 # automatic selection
282 if self.airplay_discovery_info and is_airplay2_preferred_model(
283 self.device_info.manufacturer, self.device_info.model
284 ):
285 return StreamingProtocol.AIRPLAY2
286 return StreamingProtocol.RAOP
287
288 def _get_pairing_config_entries(
289 self, values: dict[str, ConfigValueType] | None
290 ) -> list[ConfigEntry]:
291 """
292 Return pairing config entries for Apple TV and macOS devices.
293
294 Uses native pairing for both AirPlay 2 (HAP) and RAOP protocols.
295 """
296 entries: list[ConfigEntry] = []
297
298 # Determine protocol name for UI
299 conf_protocol: int = 0
300 if values and (val := values.get(CONF_AIRPLAY_PROTOCOL)):
301 conf_protocol = cast("int", val)
302 else:
303 conf_protocol = cast("int", self.config.get_value(CONF_AIRPLAY_PROTOCOL, 0) or 0)
304 protocol = self._get_protocol_for_config_value(conf_protocol)
305 protocol_name = "RAOP" if protocol == StreamingProtocol.RAOP else "AirPlay"
306 protocol_key = (
307 CONF_RAOP_CREDENTIALS
308 if protocol == StreamingProtocol.RAOP
309 else CONF_AIRPLAY_CREDENTIALS
310 )
311 has_creds_for_current_protocol = (
312 values.get(protocol_key) if values else self.config.get_value(protocol_key)
313 )
314
315 if not has_creds_for_current_protocol:
316 # If pairing was started, show PIN entry
317 if self._active_pairing and self._active_pairing.is_pairing:
318 entries.append(
319 ConfigEntry(
320 key=CONF_PAIRING_PIN,
321 type=ConfigEntryType.STRING,
322 label="Enter the 4-digit PIN shown on the device",
323 required=True,
324 category="protocol_generic",
325 )
326 )
327 entries.append(
328 ConfigEntry(
329 key=CONF_ACTION_FINISH_PAIRING,
330 type=ConfigEntryType.ACTION,
331 label=f"Complete {protocol_name} pairing with the PIN",
332 action=CONF_ACTION_FINISH_PAIRING,
333 category="protocol_generic",
334 )
335 )
336 else:
337 # Show pairing instructions and start button
338 entries.append(
339 ConfigEntry(
340 key="pairing_instructions",
341 type=ConfigEntryType.LABEL,
342 label=(
343 f"This device requires {protocol_name} pairing before it can be used. "
344 "Click the button below to start the pairing process."
345 ),
346 category="protocol_generic",
347 )
348 )
349 entries.append(
350 ConfigEntry(
351 key=CONF_ACTION_START_PAIRING,
352 type=ConfigEntryType.ACTION,
353 label=f"Start {protocol_name} pairing",
354 action=CONF_ACTION_START_PAIRING,
355 category="protocol_generic",
356 )
357 )
358 else:
359 # Show paired status
360 entries.append(
361 ConfigEntry(
362 key="pairing_status",
363 type=ConfigEntryType.LABEL,
364 label=f"Device is paired ({protocol_name}) and ready to use.",
365 category="protocol_generic",
366 )
367 )
368 # Add reset pairing button
369 entries.append(
370 ConfigEntry(
371 key=CONF_ACTION_RESET_PAIRING,
372 type=ConfigEntryType.ACTION,
373 label=f"Reset {protocol_name} pairing",
374 action=CONF_ACTION_RESET_PAIRING,
375 category="protocol_generic",
376 )
377 )
378
379 # Store credentials (hidden from UI)
380 for protocol in (StreamingProtocol.RAOP, StreamingProtocol.AIRPLAY2):
381 conf_key = self._get_credentials_key(protocol)
382 entries.append(
383 ConfigEntry(
384 key=conf_key,
385 type=ConfigEntryType.SECURE_STRING,
386 label=conf_key,
387 default_value=None,
388 value=values.get(conf_key) if values else None,
389 required=False,
390 hidden=True,
391 category="protocol_generic",
392 )
393 )
394 return entries
395
396 async def _handle_pairing_action(
397 self, action: str, values: dict[str, ConfigValueType] | None
398 ) -> None:
399 """
400 Handle pairing actions.
401
402 Uses native pairing for both AirPlay 2 (HAP) and RAOP protocols.
403 Both produce credentials compatible with cliap2/cliraop respectively.
404 """
405 conf_protocol: int = 0
406 if values and (val := values.get(CONF_AIRPLAY_PROTOCOL)):
407 conf_protocol = cast("int", val)
408 else:
409 conf_protocol = cast("int", self.config.get_value(CONF_AIRPLAY_PROTOCOL, 0) or 0)
410 protocol = self._get_protocol_for_config_value(conf_protocol)
411 protocol_name = "RAOP" if protocol == StreamingProtocol.RAOP else "AirPlay"
412
413 if action == CONF_ACTION_START_PAIRING:
414 if self._active_pairing and self._active_pairing.is_pairing:
415 self.logger.warning("Pairing process already in progress for %s", self.display_name)
416 return
417
418 self.logger.info("Starting %s pairing for %s", protocol_name, self.display_name)
419
420 from .pairing import AirPlayPairing # noqa: PLC0415
421
422 # Determine port based on protocol
423 # Note: For Apple devices, pairing always happens on the AirPlay port (7000)
424 # even when streaming will use RAOP. The RAOP port (5000) is only for streaming.
425 port: int | None = None
426 if self.airplay_discovery_info:
427 port = self.airplay_discovery_info.port or 7000
428 elif self.raop_discovery_info:
429 # Fallback for devices without AirPlay service
430 port = self.raop_discovery_info.port or 5000
431 # Get the DACP ID from the provider - must match what cliap2 uses
432 provider = cast("AirPlayProvider", self.provider)
433 device_id = provider.dacp_id
434
435 self._active_pairing = AirPlayPairing(
436 address=self.address,
437 name=self.display_name,
438 protocol=protocol,
439 logger=self.logger,
440 port=port,
441 device_id=device_id,
442 )
443 await self._active_pairing.start_pairing()
444
445 elif action == CONF_ACTION_FINISH_PAIRING:
446 if not values:
447 return
448
449 pin = values.get(CONF_PAIRING_PIN)
450 if not pin:
451 self.logger.warning("No PIN provided for pairing")
452 return
453
454 if not self._active_pairing:
455 self.logger.warning("No active pairing session for %s", self.display_name)
456 return
457
458 credentials = await self._active_pairing.finish_pairing(pin=str(pin))
459 self._active_pairing = None
460
461 # Store credentials with the protocol-specific key
462 cred_key = self._get_credentials_key(protocol)
463 values[cred_key] = credentials
464
465 self.logger.info("Finished %s pairing for %s", protocol_name, self.display_name)
466
467 elif action == CONF_ACTION_RESET_PAIRING:
468 cred_key = self._get_credentials_key(protocol)
469 self.logger.info("Resetting %s pairing for %s", protocol_name, self.display_name)
470 if values is not None:
471 values[cred_key] = None
472
473 async def stop(self) -> None:
474 """Send STOP command to player."""
475 if self.stream and self.stream.session:
476 # forward stop to the entire stream session
477 await self.stream.session.stop()
478 self._attr_current_media = None
479 self.update_state()
480
481 async def play(self) -> None:
482 """Send PLAY (unpause) command to player."""
483 async with self._lock:
484 if self.stream and self.stream.running:
485 await self.stream.send_cli_command("ACTION=PLAY")
486
487 async def pause(self) -> None:
488 """Send PAUSE command to player."""
489 if self.group_members:
490 # pause is not supported while synced, use stop instead
491 self.logger.debug("Player is synced, using STOP instead of PAUSE")
492 await self.stop()
493 return
494
495 async with self._lock:
496 if not self.stream or not self.stream.running:
497 return
498 await self.stream.send_cli_command("ACTION=PAUSE")
499
500 async def play_media(self, media: PlayerMedia) -> None:
501 """Handle PLAY MEDIA on given player."""
502 if self.synced_to:
503 # this should not happen, but guard anyways
504 raise RuntimeError("Player is synced")
505 self._attr_current_media = media
506
507 # Always stop any existing stream
508 if self.stream and self.stream.running and self.stream.session:
509 # Set transitioning flag to ignore stale DACP messages (like prevent-playback)
510 self._transitioning = True
511 await self.stream.session.stop()
512 self.stream = None
513
514 # select audio source
515 audio_source = self.mass.streams.get_stream(media, AIRPLAY_FLOW_PCM_FORMAT)
516
517 # setup StreamSession for player (and its sync childs if any)
518 sync_clients = self._get_sync_clients()
519 provider = cast("AirPlayProvider", self.provider)
520 stream_session = AirPlayStreamSession(provider, sync_clients, AIRPLAY_FLOW_PCM_FORMAT)
521 await stream_session.start(audio_source)
522 self._attr_elapsed_time = time.time() - stream_session.start_time
523 self._attr_elapsed_time_last_updated = time.time()
524 self._transitioning = False
525
526 async def volume_set(self, volume_level: int) -> None:
527 """Send VOLUME_SET command to given player."""
528 if self.stream and self.stream.running:
529 await self.stream.send_cli_command(f"VOLUME={volume_level}")
530 self._attr_volume_level = volume_level
531 self.update_state()
532 # store last state in cache
533 await self.mass.cache.set(
534 key=self.player_id,
535 data=volume_level,
536 provider=self.provider.instance_id,
537 category=CACHE_CATEGORY_PREV_VOLUME,
538 )
539
540 async def set_members(
541 self,
542 player_ids_to_add: list[str] | None = None,
543 player_ids_to_remove: list[str] | None = None,
544 ) -> None:
545 """Handle SET_MEMBERS command on the player."""
546 if self.synced_to:
547 # this should not happen, but guard anyways
548 raise RuntimeError("Player is synced, cannot set members")
549 if not player_ids_to_add and not player_ids_to_remove:
550 # nothing to do
551 return
552
553 stream_session = (
554 self.stream.session
555 if self.stream and self.stream.running and self.stream.session
556 else None
557 )
558 # handle removals first
559 if player_ids_to_remove:
560 if self.player_id in player_ids_to_remove:
561 # dissolve the entire sync group
562 if stream_session:
563 # stop the stream session if it is running
564 await stream_session.stop()
565 self._attr_group_members = []
566 self.update_state()
567 return
568
569 for child_player in self._get_sync_clients():
570 if child_player.player_id in player_ids_to_remove:
571 if stream_session:
572 await stream_session.remove_client(child_player)
573 if child_player.player_id in self._attr_group_members:
574 self._attr_group_members.remove(child_player.player_id)
575
576 # If group leader is left alone after removals, clear the group_members list
577 if (
578 self._attr_group_members
579 and len(self._attr_group_members) == 1
580 and self.player_id in self._attr_group_members
581 ):
582 self._attr_group_members = []
583
584 # handle additions
585 for player_id in player_ids_to_add or []:
586 if player_id == self.player_id or player_id in self.group_members:
587 # nothing to do: player is already part of the group
588 continue
589 child_player_to_add: AirPlayPlayer | None = cast(
590 "AirPlayPlayer | None", self.mass.players.get_player(player_id)
591 )
592 if not child_player_to_add:
593 # should not happen, but guard against it
594 continue
595 if child_player_to_add.synced_to and child_player_to_add.synced_to != self.player_id:
596 raise RuntimeError("Player is already synced to another player")
597
598 # ensure the child does not have an existing stream session active
599 if child_player_to_add := cast(
600 "AirPlayPlayer | None", self.mass.players.get_player(player_id)
601 ):
602 if (
603 child_player_to_add.playback_state == PlaybackState.PAUSED
604 and child_player_to_add.stream
605 ):
606 # Stop the paused stream to avoid a deadlock situation
607 await child_player_to_add.stream.stop()
608 if (
609 child_player_to_add.stream
610 and child_player_to_add.stream.running
611 and child_player_to_add.stream.session
612 and child_player_to_add.stream.session != stream_session
613 ):
614 await child_player_to_add.stream.session.remove_client(child_player_to_add)
615
616 # add new child to the existing stream (RAOP or AirPlay2) session (if any)
617 self._attr_group_members.append(player_id)
618 if stream_session:
619 await stream_session.add_client(child_player_to_add)
620
621 # Ensure group leader includes itself in group_members when it has members
622 # This is required for the synced_to property to work correctly
623 if self._attr_group_members and self.player_id not in self._attr_group_members:
624 self._attr_group_members.insert(0, self.player_id)
625
626 # always update the state after modifying group members
627 self.update_state()
628
629 def _on_player_media_updated(self) -> None:
630 """Handle callback when the current media of the player is updated."""
631 if not self.stream or not self.stream.running or not self.stream.session:
632 return
633 metadata = self.state.current_media
634 if not metadata:
635 return
636 progress = int(metadata.corrected_elapsed_time or 0)
637 self.mass.create_task(self.stream.send_metadata(progress, metadata))
638
639 def update_volume_from_device(self, volume: int) -> None:
640 """Update volume from device feedback."""
641 ignore_volume_report = (
642 self.config.get_value(CONF_IGNORE_VOLUME)
643 or self.device_info.manufacturer.lower() == "apple"
644 )
645
646 if ignore_volume_report:
647 return
648
649 cur_volume = self.volume_level or 0
650 if abs(cur_volume - volume) > 3 or (time.time() - self.last_command_sent) > 3:
651 self.mass.create_task(self.volume_set(volume))
652 else:
653 self._attr_volume_level = volume
654 self.update_state()
655
656 def set_discovery_info(self, discovery_info: AsyncServiceInfo, display_name: str) -> None:
657 """Set/update the discovery info for the player."""
658 self._attr_name = display_name
659 if discovery_info.type == AIRPLAY_DISCOVERY_TYPE:
660 self.airplay_discovery_info = discovery_info
661 elif discovery_info.type == RAOP_DISCOVERY_TYPE:
662 self.raop_discovery_info = discovery_info
663 else: # guard
664 return
665 cur_address = self.address
666 new_address = get_primary_ip_address_from_zeroconf(discovery_info)
667 if new_address is None:
668 # should always be set, but guard against None
669 return
670 if cur_address != new_address:
671 self.logger.debug("Address updated from %s to %s", cur_address, new_address)
672 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, new_address)
673 self.address = new_address
674 self.update_state()
675
676 def set_state_from_stream(
677 self,
678 state: PlaybackState | None = None,
679 elapsed_time: float | None = None,
680 stream: AirPlayProtocol | None = None,
681 ) -> None:
682 """Set the playback state from stream (RAOP or AirPlay2).
683
684 :param state: New playback state (or None to keep current).
685 :param elapsed_time: New elapsed time (or None to keep current).
686 :param stream: The stream instance sending this update (for validation).
687 """
688 # Ignore state updates from old/stale streams
689 if stream is not None and stream != self.stream:
690 return
691 if state is not None:
692 self._attr_playback_state = state
693 if elapsed_time is not None:
694 self._attr_elapsed_time = elapsed_time
695 self._attr_elapsed_time_last_updated = time.time()
696 self.update_state()
697
698 async def on_unload(self) -> None:
699 """Handle logic when the player is unloaded from the Player controller."""
700 await super().on_unload()
701 if self.stream:
702 # stop the stream session if it is running
703 if self.stream.running and self.stream.session:
704 self.mass.create_task(self.stream.session.stop())
705 self.stream = None
706 if self._active_pairing:
707 await self._active_pairing.close()
708 self._active_pairing = None
709
710 def _get_sync_clients(self) -> list[AirPlayPlayer]:
711 """Get all sync clients for a player."""
712 sync_clients: list[AirPlayPlayer] = []
713 # we need to return the player itself too
714 group_child_ids = {self.player_id}
715 group_child_ids.update(self.group_members)
716 for child_id in group_child_ids:
717 if client := cast("AirPlayPlayer | None", self.mass.players.get_player(child_id)):
718 sync_clients.append(client)
719 return sync_clients
720