/
/
1"""Player Provider for Sendspin."""
2
3from __future__ import annotations
4
5import asyncio
6import logging
7import re
8from collections.abc import Callable
9from contextlib import suppress
10from copy import deepcopy
11from dataclasses import dataclass, field
12from ipaddress import ip_address
13from pathlib import Path
14from typing import TYPE_CHECKING, Any, cast
15from urllib.parse import urlsplit
16from uuid import uuid4
17
18from aiosendspin.models.core import ClientHelloPayload
19from aiosendspin.models.core import DeviceInfo as SendspinDeviceInfo
20from aiosendspin.models.player import ClientHelloPlayerSupport, SupportedAudioFormat
21from aiosendspin.models.types import (
22 AudioCodec,
23 ManagementResult,
24 PairAbortReason,
25 PairMethod,
26 PlayerCommand,
27 role_family,
28)
29from aiosendspin.noise.driver import HandshakeAbortedError
30from aiosendspin.noise.pairing import (
31 LocalPairingAbortError,
32 PairingAbortError,
33 PairingAttempt,
34 PairingError,
35 PairingTimeoutError,
36)
37from aiosendspin.noise.pairing_token import decode_token
38from aiosendspin.noise.trust_store import FileServerPairingStore, PskCategory
39from aiosendspin.server import (
40 ClientAddedEvent,
41 ClientConnectedEvent,
42 ClientDisconnectedEvent,
43 ClientRemovedEvent,
44 ClientUpdatedEvent,
45 SendspinEvent,
46 SendspinServer,
47)
48from music_assistant_models.auth import Scope
49from music_assistant_models.config_entries import ConfigEntry
50from music_assistant_models.enums import (
51 ConfigEntryType,
52 EventType,
53 IdentifierType,
54 PlayerFeature,
55 PlayerType,
56 ProviderFeature,
57)
58from music_assistant_models.errors import (
59 AlreadyRegisteredError,
60 InvalidCommand,
61 SetupFailedError,
62)
63
64from music_assistant.constants import (
65 CONF_ENABLED,
66 CONF_ENTRY_MANUAL_DISCOVERY_IPS,
67 CONF_LOG_LEVEL,
68 CONF_PLAYERS,
69 CONF_PROVIDERS,
70 SENDSPIN_SERVER_PORT,
71 VERBOSE_LOG_LEVEL,
72)
73from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user
74from music_assistant.helpers.guest_access import (
75 credential_owner,
76 credential_owner_user_id,
77 credential_owners_for_user_id,
78 is_session_scoped_owner,
79)
80from music_assistant.helpers.util import format_ip_for_url
81from music_assistant.mass import MusicAssistant
82from music_assistant.models.player import Player
83from music_assistant.models.player_provider import PlayerProvider
84from music_assistant.providers.sendspin.bridge_role import (
85 BRIDGE_BIT_DEPTH,
86 BRIDGE_CHANNELS,
87 BRIDGE_ROLE_ID,
88 BRIDGE_SAMPLE_RATE,
89 BridgePlayerRole,
90)
91from music_assistant.providers.sendspin.constants import (
92 CONF_ALLOW_LEGACY_CLIENTS,
93 CONF_MIN_PIN_LENGTH,
94 CONF_SENDSPIN_STATIC_DELAY,
95 CONF_VIRTUAL_PLAYER_OWNER,
96 DEFAULT_MIN_PIN_LENGTH,
97 VIRTUAL_PLAYER_ID_PREFIX,
98)
99from music_assistant.providers.sendspin.helpers import (
100 SecurityActionError,
101 effective_pair_methods,
102 error_alert,
103 negotiated_pin_length,
104 pair_method_descriptor,
105)
106from music_assistant.providers.sendspin.player import (
107 SendspinBasePlayer,
108 SendspinPlayer,
109 SendspinSourcePlayer,
110 SendspinVisualizerPlayer,
111)
112from music_assistant.providers.sendspin.security import (
113 IDENTITY_FILENAME,
114 get_or_create_server_identity,
115)
116
117if TYPE_CHECKING:
118 from collections.abc import Awaitable, Sequence
119
120 from aiosendspin.models.core import PairMethodDescriptor
121 from aiosendspin.models.management import (
122 ManagementResultData,
123 ManagementSetPairingConfigPayload,
124 )
125 from aiosendspin.noise.trust_store import ServerPairingStore
126 from aiosendspin.server.client import SendspinClient
127 from aiosendspin.server.connection import SendspinConnection
128 from aiosendspin.server.server import ExternalStreamStartRequest
129 from music_assistant_models.auth import User
130 from music_assistant_models.config_entries import ProviderConfig
131 from music_assistant_models.event import MassEvent
132 from music_assistant_models.provider import ProviderManifest
133
134 from music_assistant.controllers.webserver.auth import AuthenticationManager
135 from music_assistant.providers.hass import HomeAssistantProvider
136
137
138DEFAULT_SENDSPIN_CLIENT_PORT = 8928
139DEFAULT_SENDSPIN_CLIENT_PATH = "/sendspin"
140VIRTUAL_PLAYER_REGISTER_TIMEOUT = 10.0
141VIRTUAL_PLAYER_CLEANUP_DELAYS = (0.0, 0.5, 2.0)
142WEB_PLAYER_CONNECT_TIMEOUT = 10.0
143# Grace period so a network blip keeps the pairing record.
144SESSION_PAIRING_EVICTION_GRACE = 120.0
145
146PIN_REQUEST_FEEDBACK_TIMEOUT = 2
147PIN_RETRY_IDLE_TIMEOUT = 300
148MANAGEMENT_REQUEST_TIMEOUT = 10
149MANAGEMENT_IDLE_TIMEOUT = 300
150
151
152@dataclass
153class PinPairingSession:
154 """State of an operator PIN pairing session for one client, across retry-in-place attempts."""
155
156 client_id: str
157 method: PairMethod
158 pin_future: asyncio.Future[str]
159 verify: bool = False
160 static: bool = False
161 pin_length: int | None = None
162 task: asyncio.Task[None] | None = None
163 pin_request_event: asyncio.Event = field(default_factory=asyncio.Event)
164 gesture_event: asyncio.Event = field(default_factory=asyncio.Event)
165 error: Exception | None = None
166 retryable: bool = False
167 opened_management: bool = False
168
169 @property
170 def attempt_running(self) -> bool:
171 """Whether an attempt is currently in flight."""
172 return self.task is not None and not self.task.done()
173
174 @property
175 def awaiting_first_message(self) -> bool:
176 """Whether the attempt is still waiting for the client's first pairing message."""
177 return (
178 self.attempt_running
179 and not self.gesture_event.is_set()
180 and not self.pin_request_event.is_set()
181 )
182
183 @property
184 def awaiting_gesture(self) -> bool:
185 """Whether the client reported the attempt gesture-gated and still awaits a window."""
186 return (
187 self.attempt_running
188 and self.gesture_event.is_set()
189 and not self.pin_request_event.is_set()
190 )
191
192 @property
193 def awaiting_pin(self) -> bool:
194 """Whether the attempt is waiting for the operator to submit a PIN."""
195 return self.attempt_running and not self.pin_future.done()
196
197 async def wait_first_message(self) -> None:
198 """Resolve once the client asks for a gesture or the PIN, or the attempt ends."""
199 await self._wait_events(self.gesture_event, self.pin_request_event)
200
201 async def wait_pin_request(self) -> None:
202 """Resolve once the client asks for the PIN, or the attempt ends."""
203 await self._wait_events(self.pin_request_event)
204
205 @property
206 def can_retry(self) -> bool:
207 """Whether a failed attempt can be retried in place."""
208 return self.task is not None and self.task.done() and self.retryable
209
210 @property
211 def finished(self) -> bool:
212 """Whether the session reached a terminal outcome (no retry possible)."""
213 return self.task is not None and self.task.done() and not self.retryable
214
215 async def _wait_events(self, *events: asyncio.Event) -> None:
216 """Resolve on the first of ``events`` or on the attempt ending, whichever comes first."""
217 waiters: list[asyncio.Future[Any]] = [
218 asyncio.ensure_future(event.wait()) for event in events
219 ]
220 if self.task is not None:
221 # Shielded: dropping this wait must never cancel the pairing attempt.
222 waiters.append(asyncio.shield(self.task))
223 try:
224 await asyncio.wait(waiters, return_when=asyncio.FIRST_COMPLETED)
225 finally:
226 for waiter in waiters:
227 waiter.cancel()
228
229
230@dataclass
231class ManagementSession:
232 """State of an operator device-management session for one client."""
233
234 client_id: str
235 connection: SendspinConnection
236 lock: asyncio.Lock = field(default_factory=asyncio.Lock)
237
238
239async def _poll_until[T](check: Callable[[], T | None], timeout: float) -> T | None:
240 """Poll ``check`` every 0.1s until it returns a value, or ``None`` once ``timeout`` lapses."""
241 try:
242 async with asyncio.timeout(timeout):
243 while True:
244 if (result := check()) is not None:
245 return result
246 await asyncio.sleep(0.1)
247 except TimeoutError:
248 return None
249
250
251def _evict_session_pairing_task_id(client_id: str) -> str:
252 """Task id for a client's delayed session-scoped pairing eviction."""
253 return f"sendspin_evict_session_pairing_{client_id}"
254
255
256def _pin_idle_task_id(client_id: str) -> str:
257 """Timer/task id for a client's pairing-retry idle timeout."""
258 return f"sendspin_pin_idle_{client_id}"
259
260
261def _management_idle_task_id(client_id: str) -> str:
262 """Timer/task id for a client's management-session idle timeout."""
263 return f"sendspin_management_idle_{client_id}"
264
265
266_MANAGEMENT_RESULT_ALERTS = {
267 ManagementResult.PERMISSION_DENIED: "management_error_permission_denied",
268 ManagementResult.ALREADY_EXISTS: "management_error_already_exists",
269 ManagementResult.INVALID: "management_error_invalid",
270 ManagementResult.NOT_FOUND: "management_error_not_found",
271 ManagementResult.STORAGE_EXHAUSTED: "management_error_storage_exhausted",
272}
273
274
275def _check_management_result(result: ManagementResult) -> None:
276 """Raise a structured error for a non-ok management result."""
277 if result is ManagementResult.OK:
278 return
279 alert_key = _MANAGEMENT_RESULT_ALERTS.get(result)
280 if alert_key is None:
281 raise SecurityActionError("management_error_generic", detail=result.value)
282 raise SecurityActionError(alert_key)
283
284
285async def _evict_stale_pairings(
286 pairing_store: ServerPairingStore, auth: AuthenticationManager
287) -> tuple[int, int]:
288 """
289 Remove the pairing records whose owning authorization is gone.
290
291 Session-scoped pairings live only as long as their client's connection, and no
292 connection survives a restart. Account-bound ones do survive a restart, but not an
293 account that was deleted or disabled while this provider was not there to hear it.
294
295 :return: How many session-scoped and how many account-bound records were removed.
296 """
297 session_scoped = 0
298 orphaned = 0
299 for record in await pairing_store.list_records():
300 if record.owner is None:
301 continue
302 if is_session_scoped_owner(record.owner):
303 session_scoped += 1
304 else:
305 if await _owner_has_access(record.owner, auth):
306 continue
307 orphaned += 1
308 await pairing_store.remove_record(record.client_id)
309 return session_scoped, orphaned
310
311
312async def _owner_has_access(owner: str, auth: AuthenticationManager) -> bool:
313 """Return whether the account an owner id is bound to still has access."""
314 user_id = credential_owner_user_id(owner)
315 if user_id is None:
316 # another kind of owner, whose lifetime is not ours to judge
317 return True
318 # get_user answers None for a deleted as well as a disabled account
319 return await auth.get_user(user_id) is not None
320
321
322def _manual_client_url(address: str) -> str:
323 """Convert a manually configured Sendspin host/IP to a client WebSocket URL."""
324 stripped_address = address.strip()
325 if not stripped_address:
326 raise ValueError("Address is empty")
327
328 if "://" in stripped_address:
329 return stripped_address
330
331 try:
332 parsed_ip = ip_address(stripped_address)
333 except ValueError:
334 pass
335 else:
336 return (
337 f"ws://{format_ip_for_url(str(parsed_ip))}:"
338 f"{DEFAULT_SENDSPIN_CLIENT_PORT}{DEFAULT_SENDSPIN_CLIENT_PATH}"
339 )
340
341 parsed_address = urlsplit(f"//{stripped_address}")
342 if parsed_address.hostname is None:
343 raise ValueError("Address does not contain a host")
344
345 return (
346 f"ws://{format_ip_for_url(parsed_address.hostname)}:"
347 f"{parsed_address.port or DEFAULT_SENDSPIN_CLIENT_PORT}"
348 f"{parsed_address.path or DEFAULT_SENDSPIN_CLIENT_PATH}"
349 )
350
351
352class SendspinProvider(PlayerProvider):
353 """Player Provider for Sendspin."""
354
355 reload_on_streams_network_change = True
356 server_api: SendspinServer
357 unregister_cbs: list[Callable[[], None]]
358 _pending_unregisters: dict[str, asyncio.Event]
359 _bridge_identifiers: dict[str, dict[IdentifierType, str]]
360 _bridge_underlying_players: dict[str, str]
361 _bridge_static_delay_defaults: dict[str, int]
362 _client_event_versions: dict[str, int]
363 _client_event_task_counts: dict[str, int]
364 _manual_ip_config: tuple[str, ...]
365 _virtual_players: dict[str, str]
366 _unloading: bool
367 _hass_available: bool
368
369 def __init__(
370 self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
371 ) -> None:
372 """Initialize a new Sendspin player provider."""
373 super().__init__(mass, manifest, config)
374 # Handle config option for manual IP's. Read a default here: at construction the
375 # config only carries the server defaults + stored raw values (the provider's typed
376 # option entries are resolved and applied by the config controller right after this).
377 manual_ip_config = cast(
378 "list[str]", config.get_value(CONF_ENTRY_MANUAL_DISCOVERY_IPS.key) or []
379 )
380 self._manual_ip_config = tuple(address for address in manual_ip_config if address.strip())
381 self._pending_unregisters = {}
382 self._bridge_identifiers = {}
383 self._bridge_underlying_players = {}
384 self._bridge_static_delay_defaults = {}
385 self._bridge_player_types: dict[str, PlayerType] = {}
386 self._client_event_versions = {}
387 self._client_event_task_counts = {}
388 self._virtual_players = {}
389 self._pin_sessions: dict[str, PinPairingSession] = {}
390 self._pending_pairing_evictions: set[str] = set()
391 self._running_pairing_evictions: set[asyncio.Task[None]] = set()
392 self._management_sessions: dict[str, ManagementSession] = {}
393 self._pairing_config_snapshots: dict[
394 str, tuple[SendspinConnection, ManagementResultData]
395 ] = {}
396 self._unloading = False
397 self._hass_available = False
398 self.unregister_cbs = []
399
400 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
401 """Return Config entries to configure this provider."""
402 return (
403 CONF_ENTRY_MANUAL_DISCOVERY_IPS,
404 ConfigEntry(
405 key=CONF_ALLOW_LEGACY_CLIENTS,
406 type=ConfigEntryType.BOOLEAN,
407 default_value=True,
408 ),
409 ConfigEntry(
410 key=CONF_MIN_PIN_LENGTH,
411 type=ConfigEntryType.INTEGER,
412 range=(4, 12),
413 default_value=DEFAULT_MIN_PIN_LENGTH,
414 ),
415 )
416
417 async def handle_async_init(self) -> None:
418 """Load the persistent server identity and pairing store, then create the server."""
419 self._set_aiosendspin_log_level()
420 storage_dir = Path(self.mass.storage_path) / "sendspin"
421 identity_path = storage_dir / IDENTITY_FILENAME
422 try:
423 identity = await asyncio.to_thread(get_or_create_server_identity, storage_dir)
424 except ValueError as err:
425 raise SetupFailedError(
426 f"The Sendspin server identity at {identity_path} is corrupt: {err}. Restore it "
427 "from a backup, or remove the file to start fresh - every paired device will "
428 "then need to be re-paired."
429 ) from err
430 except OSError as err:
431 raise SetupFailedError(
432 f"Could not read the Sendspin server identity at {identity_path}: {err}. Fix the "
433 "file-access problem and reload; do not delete the file or every paired device "
434 "will need to be re-paired."
435 ) from err
436 pairing_store_path = storage_dir / "pairing_store.json"
437 try:
438 pairing_store = await FileServerPairingStore.open(pairing_store_path)
439 except (ValueError, TypeError, KeyError) as err:
440 raise SetupFailedError(
441 f"The Sendspin pairing store at {pairing_store_path} is corrupt: {err}. Restore it "
442 "from a backup, or remove the file to start fresh - this discards all pairings and "
443 "unpaired-access approvals."
444 ) from err
445 except OSError as err:
446 raise SetupFailedError(
447 f"Could not read the Sendspin pairing store at {pairing_store_path}: {err}. Fix the "
448 "file-access problem and reload; do not delete the file or all pairings will be "
449 "lost."
450 ) from err
451 session_scoped, orphaned = await _evict_stale_pairings(
452 pairing_store, self.mass.webserver.auth
453 )
454 if session_scoped:
455 self.logger.info(
456 "Removed %d session-scoped pairing(s) from a previous run", session_scoped
457 )
458 if orphaned:
459 self.logger.info("Removed %d pairing(s) of a deleted or disabled account", orphaned)
460 allow_legacy_clients = cast("bool", self.config.get_value(CONF_ALLOW_LEGACY_CLIENTS, True))
461 self.server_api = SendspinServer(
462 self.mass.loop,
463 identity,
464 "Music Assistant",
465 self.mass.http_session,
466 pairing_store=pairing_store,
467 allow_unencrypted=allow_legacy_clients,
468 allow_noncompliant_clients=allow_legacy_clients,
469 min_pin_length=cast(
470 "int", self.config.get_value(CONF_MIN_PIN_LENGTH, DEFAULT_MIN_PIN_LENGTH)
471 ),
472 )
473 # Pitch (YINFFT) is the heaviest visualizer DSP and result quality is
474 # still very mixed, needs more testing. Disable it globally for now to
475 # spare low-power hosts.
476 self.server_api.set_visualizer_pitch_enabled(enabled=False)
477 self.unregister_cbs = [
478 self.server_api.add_event_listener(self.event_cb),
479 self.mass.subscribe(self._on_providers_updated, EventType.PROVIDERS_UPDATED),
480 ]
481 # seed the hass availability snapshot so the first (un)load is seen as a change
482 hass = self.mass.get_provider("hass")
483 self._hass_available = hass is not None and hass.available
484
485 async def update_config(self, config: ProviderConfig, changed_keys: set[str]) -> None:
486 """Handle logic when the config is updated."""
487 await super().update_config(config, changed_keys)
488 # a log level(-only) change does not reload the provider,
489 # so realign aiosendspin's logger here
490 if f"values/{CONF_LOG_LEVEL}" in changed_keys:
491 self._set_aiosendspin_log_level()
492
493 def event_cb(self, server: SendspinServer, event: SendspinEvent) -> None:
494 """Event callback registered to the sendspin server."""
495 match event:
496 case ClientAddedEvent(client_id):
497 event_version = self._begin_client_event(client_id)
498 self.mass.create_task(self._handle_client_added(client_id, event_version))
499 case ClientRemovedEvent(client_id):
500 event_version = self._begin_client_event(client_id)
501 self.mass.create_task(self._handle_client_removed(client_id, event_version))
502 case ClientUpdatedEvent(client_id):
503 event_version = self._begin_client_event(client_id)
504 self.mass.create_task(self._handle_client_updated(client_id, event_version))
505 # Transport lifecycle events, implemented in another PR.
506 case ClientConnectedEvent():
507 pass
508 case ClientDisconnectedEvent(client_id):
509 self._pending_pairing_evictions.add(client_id)
510 self.mass.call_later(
511 SESSION_PAIRING_EVICTION_GRACE,
512 self._evict_session_pairing,
513 client_id,
514 task_id=_evict_session_pairing_task_id(client_id),
515 )
516 case _:
517 self.logger.error("Unknown sendspin event: %s", event)
518
519 def on_player_enabled(self, player_id: str) -> None:
520 """Call (by config manager) when a player gets enabled."""
521 # A client that connected while disabled has no player object;
522 # replay the add event so re-enabling takes effect immediately.
523 if (
524 self.server_api.get_client(player_id) is not None
525 and self.mass.players.get_player(player_id) is None
526 ):
527 event_version = self._begin_client_event(player_id)
528 self.mass.create_task(self._handle_client_added(player_id, event_version))
529 return
530 super().on_player_enabled(player_id)
531
532 def register_bridge_identifiers(
533 self, client_id: str, identifiers: dict[IdentifierType, str]
534 ) -> None:
535 """
536 Pre-register extra identifiers for a bridge client.
537
538 Called by bridge managers (Chromecast, AirPlay) before registering an
539 external player, so that the resulting SendspinPlayer carries the parent
540 player's protocol-specific identifiers for cross-protocol matching.
541
542 :param client_id: The bridge client_id that will be used for registration.
543 :param identifiers: Extra identifiers to attach to the SendspinPlayer.
544 """
545 self._bridge_identifiers[client_id] = identifiers
546
547 def register_bridge_underlying_player(self, client_id: str, underlying_player_id: str) -> None:
548 """
549 Pre-register the underlying player a bridge client runs on top of.
550
551 Called by bridge managers before registering an external player, so that
552 the resulting SendspinPlayer carries the derived-transport edge and the
553 protocol linking layer can resolve its parent deterministically.
554
555 :param client_id: The bridge client_id that will be used for registration.
556 :param underlying_player_id: The player_id of the player the bridge rides on.
557 """
558 self._bridge_underlying_players[client_id] = underlying_player_id
559
560 def register_bridge_static_delay_default(self, client_id: str, default_ms: int) -> None:
561 """
562 Register a protocol-specific default static delay for a bridge client.
563
564 If the SendspinPlayer already exists, the default is applied immediately;
565 otherwise it is stashed and picked up when the player is created.
566
567 :param client_id: The bridge client_id for which the default applies.
568 :param default_ms: Model-specific default static delay in milliseconds.
569 """
570 existing = self.mass.players.get_player(client_id)
571 if isinstance(existing, SendspinPlayer):
572 existing.static_delay_default_ms = default_ms
573 # If no user-set value exists, push the new default to the device now
574 # so already-connected clients pick it up without a config edit.
575 if (
576 self.mass.config.get_raw_player_config_value(client_id, CONF_SENDSPIN_STATIC_DELAY)
577 is None
578 ):
579 self.mass.create_task(existing._apply_static_delay())
580 return
581 self._bridge_static_delay_defaults[client_id] = default_ms
582
583 def register_bridge_player_type(self, client_id: str, player_type: PlayerType) -> None:
584 """
585 Pre-register a PlayerType override for a bridge client.
586
587 Called by bridge managers to set the player type for the resulting
588 player (e.g. PlayerType.LIGHT for Hue Entertainment bridges).
589 """
590 self._bridge_player_types[client_id] = player_type
591
592 async def apply_bridge_claim(
593 self,
594 client_id: str,
595 identifiers: dict[IdentifierType, str],
596 bridge_hello: ClientHelloPayload,
597 underlying_player_id: str | None = None,
598 ) -> bool:
599 """
600 Post-claim an already-registered SendspinPlayer as a bridge client.
601
602 Used when a bridge manager reaches setup_bridge AFTER the external client
603 has already connected on its own (e.g. a JS Cast receiver reconnecting to
604 the server before the Chromecast bridge could register). Attaches the
605 bridge's protocol-specific identifiers so cross-protocol matching can
606 link the SendspinPlayer to its native peer, and replays the bridge
607 hello's supported_commands restriction on the player features.
608
609 :param client_id: The Sendspin client_id whose player should be claimed.
610 :param identifiers: Protocol-specific identifiers (e.g. CAST_UUID) to
611 attach to the player for cross-protocol matching.
612 :param bridge_hello: The bridge's intended ClientHelloPayload. Its
613 player_support.supported_commands gates which volume/mute features
614 the player is allowed to expose.
615 :param underlying_player_id: The player_id of the player the bridge rides
616 on, establishing the derived-transport edge for protocol linking.
617 :return: True if a matching SendspinPlayer was found and updated.
618 """
619 player = self.mass.players.get_player(client_id)
620 if not isinstance(player, SendspinPlayer):
621 return False
622 for id_type, id_value in identifiers.items():
623 player.device_info.add_identifier(id_type, id_value)
624 if underlying_player_id is not None:
625 player._attr_underlying_player_id = underlying_player_id
626 bridge_supported_commands: list[PlayerCommand] = []
627 if bridge_hello.player_support:
628 bridge_supported_commands = list(bridge_hello.player_support.supported_commands)
629 if PlayerCommand.VOLUME in bridge_supported_commands:
630 player._attr_supported_features.add(PlayerFeature.VOLUME_SET)
631 else:
632 player._attr_supported_features.discard(PlayerFeature.VOLUME_SET)
633 if PlayerCommand.MUTE in bridge_supported_commands:
634 player._attr_supported_features.add(PlayerFeature.VOLUME_MUTE)
635 else:
636 player._attr_supported_features.discard(PlayerFeature.VOLUME_MUTE)
637 # Expose the claimed player as a protocol bridge, not a standalone web
638 # player. A JS Cast receiver advertises product_name="Web Browser" and
639 # would otherwise be classified as is_web_player → PlayerType.PLAYER
640 # (hidden). Restore protocol semantics so UI links it under its native peer.
641 player.is_web_player = False
642 player._attr_hidden_by_default = False
643 player._attr_private = False
644 player._attr_expose_to_ha_by_default = True
645 player._attr_type = PlayerType.PROTOCOL
646 self.logger.info(
647 "Bridge claim applied to existing SendspinPlayer %s (client_id=%s)",
648 player.display_name,
649 client_id,
650 )
651 await self.mass.players.register_or_update(player)
652 return True
653
654 async def create_virtual_player(
655 self,
656 owner_instance_id: str,
657 display_name: str,
658 player_id: str | None = None,
659 ) -> str:
660 """
661 Create a hidden, server-side virtual Sendspin player.
662
663 A virtual player owns its own PlayerQueue and leads a native Sendspin
664 group, but never renders audio itself: the audio stream is delivered to
665 the guest players that are attached to it through standard grouping.
666 It is hidden from the UI and not exposed to Home Assistant by default,
667 and is automatically removed when the owning provider unloads.
668
669 :param owner_instance_id: Instance id of the (loaded) provider that owns
670 the virtual player and controls its lifecycle.
671 :param display_name: Human readable name for the virtual player.
672 :param player_id: Optional stable id for the virtual player; a random id
673 is generated when omitted. The id is always prefixed with
674 ``VIRTUAL_PLAYER_ID_PREFIX``.
675 :return: The player_id of the registered virtual player.
676 :raises SetupFailedError: If the virtual player can not be created.
677 """
678 if (owner := self.mass.get_provider(owner_instance_id)) is None:
679 raise SetupFailedError(f"Owner provider {owner_instance_id} is not loaded")
680 if owner.instance_id != owner_instance_id and (
681 len(self.mass.get_provider_instances(owner.domain)) > 1
682 ):
683 raise SetupFailedError(
684 f"Multiple instances exist for {owner_instance_id}: pass an exact instance id"
685 )
686 # normalize a provider domain to the actual instance id
687 owner_instance_id = owner.instance_id
688 if player_id is None:
689 player_id = uuid4().hex
690 elif not re.fullmatch(r"[a-zA-Z0-9_-]+", player_id):
691 raise SetupFailedError(
692 f"Invalid player_id {player_id}: only alphanumerics, '_' and '-' are allowed"
693 )
694 if not player_id.startswith(VIRTUAL_PLAYER_ID_PREFIX):
695 player_id = f"{VIRTUAL_PLAYER_ID_PREFIX}{player_id}"
696 if player_id in self._virtual_players or self.server_api.get_client(player_id) is not None:
697 raise SetupFailedError(f"Virtual player {player_id} already exists")
698 # a persisted config may only be reclaimed by the same owner
699 stored_owner = self._get_virtual_player_config_owner(player_id)
700 if stored_owner is not None and stored_owner != owner_instance_id:
701 raise SetupFailedError(f"Virtual player {player_id} is owned by {stored_owner}")
702 self._virtual_players[player_id] = owner_instance_id
703 try:
704 self._register_virtual_player_client(player_id, display_name)
705 await self._wait_for_virtual_player(player_id)
706 except asyncio.CancelledError:
707 await self._cleanup_failed_virtual_player_creation(player_id)
708 raise
709 except Exception as err:
710 await self._cleanup_failed_virtual_player_creation(player_id)
711 if isinstance(err, SetupFailedError):
712 raise
713 raise SetupFailedError(f"Failed to create virtual player {player_id}") from err
714 # persist the owner so orphaned configs can be swept after a restart
715 self.mass.config.set_raw_player_config_value(
716 player_id, CONF_VIRTUAL_PLAYER_OWNER, owner_instance_id
717 )
718 self.logger.info("Virtual player %s created for %s", player_id, owner_instance_id)
719 return player_id
720
721 async def remove_virtual_player(self, player_id: str) -> None:
722 """
723 Remove a virtual Sendspin player and permanently delete its configuration.
724
725 :param player_id: The player_id returned by create_virtual_player.
726 :raises ValueError: If the given player_id is not a (known) virtual player.
727 """
728 if not player_id.startswith(VIRTUAL_PLAYER_ID_PREFIX) or (
729 player_id not in self._virtual_players
730 and self._get_virtual_player_config_owner(player_id) is None
731 ):
732 raise ValueError(f"{player_id} is not a virtual player")
733 # unregister the player first so the client removed event handler
734 # can not race us with a non-permanent unregister
735 await self.mass.players.unregister(player_id, permanent=True)
736 if self.server_api.get_client(player_id) is not None:
737 await self.server_api.remove_client(player_id)
738 # the config may linger when the player was never registered
739 self.mass.players.delete_player_config(player_id)
740 self._virtual_players.pop(player_id, None)
741 self.logger.info("Virtual player %s removed", player_id)
742
743 def is_virtual_player(self, player_id: str) -> bool:
744 """Return whether the given player_id belongs to a registered virtual player."""
745 return player_id in self._virtual_players
746
747 def get_pin_session(self, client_id: str) -> PinPairingSession | None:
748 """Return the in-flight or just-finished PIN pairing session for a client."""
749 return self._pin_sessions.get(client_id)
750
751 def clear_pin_session(self, client_id: str) -> None:
752 """Drop a finished PIN pairing session (after its outcome has been shown)."""
753 session = self._pin_sessions.get(client_id)
754 if session is not None and session.finished:
755 self._cancel_pin_idle_timeout(client_id)
756 self._pin_sessions.pop(client_id, None)
757 if session.opened_management:
758 self.exit_management(client_id)
759
760 async def start_pin_pairing(
761 self, client_id: str, *, verify: bool = False, static: bool = False
762 ) -> PinPairingSession:
763 """
764 Begin (or retry in place) an operator PIN pairing attempt with a connected client.
765
766 Returns once the client has asked for the PIN, the attempt has failed, or a short
767 feedback window has elapsed, so the caller's first render reflects whether the
768 device-side pairing gesture is still pending. The attempt keeps running until the PIN
769 is supplied via submit_pin (or it times out / is cancelled). A session left retryable
770 by a failed attempt is resumed in place, preserving the chosen method and verify mode.
771
772 :param verify: Re-verify an already-paired device's presence (dynamic PIN only).
773 :param static: Pair with the static PIN even when a dynamic PIN is offered.
774 """
775 session = self._pin_sessions.get(client_id)
776 if session is not None and (session.verify != verify or session.static != static):
777 # a stale session from an earlier run never resumes; the caller's
778 # static/verify choice must win
779 if session.attempt_running:
780 raise SecurityActionError("pairing_error_concurrent")
781 await self.cancel_pin_pairing(client_id)
782 session = None
783 if session is not None and session.can_retry:
784 self._begin_pin_attempt(session)
785 await self._pin_request_feedback(session)
786 return session
787 if session is not None and session.attempt_running:
788 return session
789 client = self.server_api.get_client(client_id)
790 # A disconnected client keeps its last hello, so info alone does not prove it is connected.
791 info = client.info_or_none if client is not None and client.is_connected else None
792 if info is None:
793 raise SecurityActionError("pairing_error_not_connected")
794 offered = effective_pair_methods(info, self.pairing_config_snapshot(client_id))
795 method = self._pick_pin_method(offered, verify=verify, static=static)
796 pin_length = (
797 # From the hello advertisement, not the live config: that is what the server's own
798 # negotiation reads, so the predicted length matches the PIN the device derives.
799 negotiated_pin_length(
800 pair_method_descriptor(info.supported_pair_methods or (), PairMethod.DYNAMIC_PIN),
801 self.server_api.min_pin_length,
802 )
803 if method is PairMethod.DYNAMIC_PIN
804 else None
805 )
806 session = PinPairingSession(
807 client_id=client_id,
808 method=method,
809 pin_future=self.mass.loop.create_future(),
810 verify=verify,
811 static=static,
812 pin_length=pin_length,
813 opened_management=await self._open_pairing_window(client_id),
814 )
815 self._pin_sessions[client_id] = session
816 self._begin_pin_attempt(session)
817 await self._pin_request_feedback(session)
818 return session
819
820 def submit_pin(self, client_id: str, pin: str) -> None:
821 """Deliver the operator-entered PIN to the in-flight pairing attempt."""
822 session = self._pin_sessions.get(client_id)
823 if session is None or session.task is None:
824 raise SecurityActionError("pairing_error_no_pin_session")
825 if not session.pin_future.done():
826 session.pin_future.set_result(pin.strip())
827
828 async def cancel_pin_pairing(self, client_id: str) -> None:
829 """Abort an in-flight or parked PIN pairing session, restoring normal service."""
830 session = self._pin_sessions.pop(client_id, None)
831 if session is None:
832 return
833 self._cancel_pin_idle_timeout(client_id)
834 await self._end_pairing_quietly(client_id)
835 if session.task is not None:
836 with suppress(Exception):
837 await session.task
838 if session.opened_management:
839 self.exit_management(client_id)
840 await self._refresh_player(client_id)
841
842 async def pair_with_token(
843 self, client_id: str, token_value: str, owner: str | None = None
844 ) -> None:
845 """
846 Pair a connected client using its pasted pairing token.
847
848 :param client_id: The connected client to pair.
849 :param token_value: The client's pairing token.
850 :param owner: Application-defined authorization id to bind the pairing to;
851 ``None`` is a standalone pairing.
852 """
853 session = self._pin_sessions.get(client_id)
854 if session is not None and session.attempt_running:
855 raise SecurityActionError("pairing_error_concurrent")
856 try:
857 token = decode_token(token_value)
858 except ValueError as err:
859 raise SecurityActionError("pairing_error_token_invalid") from err
860 if token.client_id != client_id:
861 raise SecurityActionError("pairing_error_token_mismatch")
862 try:
863 await self.server_api.initiate_pairing(
864 client_id,
865 PairingAttempt(PairMethod.PAIRING_PSK, pairing_psk=token.pairing_psk, owner=owner),
866 )
867 except PairingAbortError:
868 # Token pairing is single-shot; unpark the connection before surfacing the failure.
869 await self._end_pairing_quietly(client_id)
870 raise
871 except HandshakeAbortedError as err:
872 # A client that does not recognize the token's PSK closes the connection
873 # without an application-level error (spec); the server has disconnected it.
874 raise PairingError(
875 "the token was rejected by the device; make sure it is correct"
876 ) from err
877 await self._refresh_player(client_id)
878
879 async def unpair_client(self, client_id: str) -> None:
880 """Drop the pairing with a connected client (both sides forget the credential)."""
881 await self.server_api.unpair(client_id)
882 await self._refresh_player(client_id)
883
884 async def set_trusted_unpaired(self, client_id: str, enabled: bool) -> None:
885 """Approve or revoke unpaired (unauthenticated) playback for a client."""
886 if enabled:
887 await self.server_api.trust_unpaired(client_id)
888 else:
889 await self.server_api.untrust_unpaired(client_id)
890 await self._refresh_player(client_id)
891
892 async def pair_web_player(self, pairing_token: str) -> None:
893 """
894 Pair the built-in web player that minted the given pairing token.
895
896 :param pairing_token: The calling web player's version 0 pairing token.
897 """
898 # The token names the client it belongs to, so this works on every transport,
899 # including Ingress where the session carries no client id at all.
900 try:
901 client_id = decode_token(pairing_token).client_id
902 except ValueError as err:
903 raise InvalidCommand(
904 "The pairing token is not valid",
905 translation_key="pairing_error_token_invalid",
906 translation_owner=self.translation_owner,
907 ) from err
908 player = await self._await_connected_client(client_id)
909 if not player.is_web_player:
910 raise InvalidCommand(f"Client {client_id} is not a built-in web player")
911 security = player.api.connection_security
912 # An unencrypted client cannot hold a pairing, same as the setup flow refuses it
913 if security is None:
914 return
915 record = await self.server_api.pairing_store.record_by_client_id(client_id)
916 # The pairing is bound to the caller's account: a guest's ends with their
917 # session or access, a full user's with their account. Only pairings made
918 # through the settings/setup flow are standalone.
919 user = get_current_user()
920 owner = credential_owner(user) if user is not None else None
921 # We already paired this web player so no action needed. A record on its own is not
922 # enough: the client can have lost its half, leaving a record it cannot authenticate.
923 # A record bound to another account is re-paired instead (a browser keeps its
924 # identity across logins), so the lifetime always follows the current caller.
925 if (
926 security.psk_category is PskCategory.LONG_TERM
927 and record is not None
928 and (record.owner is None or record.owner == owner)
929 ):
930 return
931 try:
932 await self.pair_with_token(client_id, pairing_token, owner=owner)
933 except (
934 SecurityActionError,
935 PairingError,
936 HandshakeAbortedError,
937 TimeoutError,
938 OSError,
939 ) as err:
940 # Report the reason without the request, which carries the pairing token.
941 alert = error_alert(err)
942 raise InvalidCommand(
943 f"Cannot pair web player {client_id}",
944 translation_key=alert.key,
945 translation_args=alert.params,
946 translation_owner=self.translation_owner,
947 ) from err
948 # The handshake takes a moment, in which an eviction can have missed this record.
949 if owner is not None and not await _owner_has_access(owner, self.mass.webserver.auth):
950 await self._evict_pairings_for_owner(owner)
951
952 def get_management_session(self, client_id: str) -> ManagementSession | None:
953 """Return the client's management session, dropping one whose connection is gone."""
954 session = self._management_sessions.get(client_id)
955 if session is None:
956 return None
957 client = self.server_api.get_client(client_id)
958 if client is None or client.connection is not session.connection:
959 self._drop_management_session(session)
960 return None
961 return session
962
963 def enter_management(self, client_id: str) -> ManagementSession:
964 """Open (or refresh) the operator management session for a paired connected client."""
965 if (session := self.get_management_session(client_id)) is not None:
966 self._arm_management_idle_timeout(session)
967 return session
968 try:
969 connection = self.server_api.enable_management(client_id)
970 except RuntimeError as err:
971 raise SecurityActionError("management_error_generic", detail=str(err)) from err
972 session = ManagementSession(client_id=client_id, connection=connection)
973 self._management_sessions[client_id] = session
974 self._arm_management_idle_timeout(session)
975 return session
976
977 def exit_management(self, client_id: str) -> None:
978 """Close the client's management session, restoring normal server admission."""
979 if (session := self._management_sessions.get(client_id)) is not None:
980 self._drop_management_session(session)
981
982 async def management_get_pairing_config(self, client_id: str) -> ManagementResultData:
983 """Fetch the device's pairing configuration over its management session."""
984 session = self._management_session_or_raise(client_id)
985 async with session.lock:
986 self._arm_management_idle_timeout(session)
987 result, data, _ = await self._management_call(
988 session.connection, session.connection.get_pairing_config()
989 )
990 _check_management_result(result)
991 self._pairing_config_snapshots[client_id] = (session.connection, data)
992 return data
993
994 async def management_open_pairing_window(self, client_id: str) -> None:
995 """Open a pairing window on the device over its management session, sparing the gesture."""
996 session = self._management_session_or_raise(client_id)
997 async with session.lock:
998 self._arm_management_idle_timeout(session)
999 result = await self._management_call(
1000 session.connection, session.connection.open_pairing_window()
1001 )
1002 _check_management_result(result)
1003
1004 def pairing_config_snapshot(self, client_id: str) -> ManagementResultData | None:
1005 """
1006 Return the last management-fetched pairing config for the client's current connection.
1007
1008 While the connection it was fetched on is still the active one, the snapshot is
1009 fresher than the hello advertisement (which cannot change until reconnect); after a
1010 reconnect the new hello is authoritative and the snapshot is dropped.
1011 """
1012 snapshot = self._pairing_config_snapshots.get(client_id)
1013 if snapshot is None:
1014 return None
1015 connection, data = snapshot
1016 client = self.server_api.get_client(client_id)
1017 if client is None or client.connection is not connection:
1018 self._pairing_config_snapshots.pop(client_id, None)
1019 return None
1020 return data
1021
1022 async def management_set_pairing_config(
1023 self, client_id: str, patch: ManagementSetPairingConfigPayload
1024 ) -> None:
1025 """Apply a pairing-config patch on the device and refresh the cached snapshot."""
1026 session = self._management_session_or_raise(client_id)
1027 async with session.lock:
1028 self._arm_management_idle_timeout(session)
1029 result = await self._management_call(
1030 session.connection, session.connection.set_pairing_config(patch)
1031 )
1032 _check_management_result(result)
1033 await self.management_get_pairing_config(client_id)
1034
1035 @property
1036 def supported_features(self) -> set[ProviderFeature]:
1037 """Return the features supported by this Provider."""
1038 return {
1039 ProviderFeature.SYNC_PLAYERS,
1040 }
1041
1042 async def loaded_in_mass(self) -> None:
1043 """Call after the provider has been loaded."""
1044 await super().loaded_in_mass()
1045 self.unregister_cbs.append(
1046 self.mass.register_api_command(
1047 "sendspin/pair_web_player",
1048 self.pair_web_player,
1049 # Guests pair their own web player too, since party mode plays through Sendspin.
1050 required_scope=Scope.PLAYERS_CONTROL,
1051 )
1052 )
1053 # Pairings bound to a user's access must not outlive it (guest access switched
1054 # off, account deleted, all sessions revoked).
1055 self.unregister_cbs.append(
1056 self.mass.webserver.auth.subscribe_user_access_revoked(self._on_user_access_revoked)
1057 )
1058 self._remove_orphan_virtual_player_configs()
1059 # Start server for handling incoming Sendspin connections from clients
1060 # and mDNS discovery of new clients
1061 await self.server_api.start_server(
1062 port=SENDSPIN_SERVER_PORT,
1063 host=self.mass.streams.bind_ip,
1064 advertise_addresses=[self.mass.streams.publish_ip],
1065 )
1066 for address in self._manual_ip_config:
1067 try:
1068 url = _manual_client_url(address)
1069 except ValueError as err:
1070 self.logger.warning(
1071 "Ignoring invalid manual Sendspin client address %s: %s", address, err
1072 )
1073 continue
1074 self.logger.debug("Connecting to manually configured Sendspin client at %s", url)
1075 self.server_api.connect_to_client(
1076 url,
1077 retry_initial_connection=True,
1078 retry_indefinitely=True,
1079 )
1080
1081 async def unload(self, is_removed: bool = False) -> None:
1082 """
1083 Handle unload/close of the provider.
1084
1085 Called when provider is deregistered (e.g. MA exiting or config reloading).
1086
1087 :param is_removed: True when the provider is removed from the configuration.
1088 """
1089 self._unloading = True
1090 # call_later timers are not swept by mass.stop(), so cancel them explicitly here.
1091 for session in self._pin_sessions.values():
1092 if session.task is not None:
1093 session.task.cancel()
1094 self._cancel_pin_idle_timeout(session.client_id)
1095 self._pin_sessions.clear()
1096 for client_id in self._pending_pairing_evictions:
1097 self.mass.cancel_timer(_evict_session_pairing_task_id(client_id))
1098 self._pending_pairing_evictions.clear()
1099 for management_session in self._management_sessions.values():
1100 self.mass.cancel_timer(_management_idle_task_id(management_session.client_id))
1101 self._management_sessions.clear()
1102 self._pairing_config_snapshots.clear()
1103 if self._running_pairing_evictions:
1104 await asyncio.gather(*self._running_pairing_evictions, return_exceptions=True)
1105 player_ids = [player.player_id for player in self.players]
1106 # Stop the Sendspin server
1107 await self.server_api.close()
1108
1109 for cb in self.unregister_cbs:
1110 cb()
1111 self.unregister_cbs = []
1112 self._client_event_task_counts.clear()
1113 self._client_event_versions.clear()
1114 self._virtual_players.clear()
1115 await asyncio.gather(
1116 *(
1117 self.mass.players.unregister(player_id, permanent=is_removed)
1118 for player_id in player_ids
1119 ),
1120 return_exceptions=True,
1121 )
1122
1123 def _set_aiosendspin_log_level(self) -> None:
1124 """Keep aiosendspin's (very chatty) logging quiet unless verbose logging is enabled."""
1125 # aiosendspin logs every protocol message of every client session at debug
1126 # level, so only pass that through when verbose logging is enabled
1127 if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
1128 logging.getLogger("aiosendspin").setLevel(logging.DEBUG)
1129 else:
1130 logging.getLogger("aiosendspin").setLevel(self.logger.level + 10)
1131
1132 def _begin_client_event(self, client_id: str) -> int:
1133 """Increment version and in-flight task count for a client event."""
1134 version = self._client_event_versions.get(client_id, 0) + 1
1135 self._client_event_versions[client_id] = version
1136 self._client_event_task_counts[client_id] = (
1137 self._client_event_task_counts.get(client_id, 0) + 1
1138 )
1139 return version
1140
1141 def _finish_client_event(self, client_id: str) -> None:
1142 """Drop in-flight bookkeeping and prune version state when idle."""
1143 task_count = self._client_event_task_counts.get(client_id, 0)
1144 if task_count <= 1:
1145 self._client_event_task_counts.pop(client_id, None)
1146 self._client_event_versions.pop(client_id, None)
1147 return
1148 self._client_event_task_counts[client_id] = task_count - 1
1149
1150 def _is_current_client_event(self, client_id: str, event_version: int) -> bool:
1151 """Return True if the event version is still the latest for the client."""
1152 return self._client_event_versions.get(client_id) == event_version
1153
1154 async def _apply_hass_esphome_enrichment(self, players: Sequence[SendspinBasePlayer]) -> None:
1155 """
1156 Apply Home Assistant-sourced enrichment to ESPHome-backed Sendspin players.
1157
1158 Applies the HA display name and resolves the HA media_player entity that
1159 announcements are relayed to: ESPHome devices support announcements
1160 natively, but that capability is only reachable through the HA API.
1161 Players are correlated by MAC address (the Sendspin client id).
1162 """
1163 esphome_players = [
1164 player for player in players if player.device_info.manufacturer == "ESPHome"
1165 ]
1166 if not esphome_players:
1167 return
1168 hass = cast("HomeAssistantProvider | None", self.mass.get_provider("hass"))
1169 if hass is None or not hass.available:
1170 for player in esphome_players:
1171 if isinstance(player, SendspinPlayer):
1172 player.set_hass_announce_entity(None)
1173 return
1174 try:
1175 device_infos = await hass.get_media_player_device_infos(
1176 [player.player_id for player in esphome_players], platform="esphome"
1177 )
1178 except Exception as err:
1179 self.logger.warning("Failed to apply Home Assistant enrichment: %s", err)
1180 return
1181 for player in esphome_players:
1182 device_info = device_infos.get(player.player_id.lower())
1183 if device_info is not None and device_info["name"]:
1184 player._attr_name = device_info["name"]
1185 if isinstance(player, SendspinPlayer):
1186 player.set_hass_announce_entity(
1187 device_info["announce_entity_id"] if device_info is not None else None
1188 )
1189
1190 async def _refresh_hass_esphome_enrichment(self) -> None:
1191 """Re-apply the HA enrichment to all registered ESPHome players (in place)."""
1192 players = [
1193 player
1194 for player in self.players
1195 if isinstance(player, SendspinBasePlayer)
1196 and player.device_info.manufacturer == "ESPHome"
1197 ]
1198 if not players:
1199 return
1200 await self._apply_hass_esphome_enrichment(players)
1201 for player in players:
1202 if player.initialized.is_set():
1203 player.update_state()
1204
1205 def _create_player(
1206 self,
1207 client_id: str,
1208 sendspin_client: SendspinClient,
1209 existing_player: Player | None,
1210 initial_hello: ClientHelloPayload | None = None,
1211 ) -> SendspinBasePlayer:
1212 """
1213 Create the appropriate player class based on client roles.
1214
1215 Priority: player role -> SendspinPlayer, metadata role -> DISPLAY,
1216 visualizer role -> VISUALIZER, source role -> SendspinSourcePlayer.
1217 Bridge-registered type overrides the default.
1218 """
1219 extra_ids = self._bridge_identifiers.pop(client_id, None)
1220 bridge_player_type = self._bridge_player_types.pop(client_id, None)
1221 underlying_player_id = self._bridge_underlying_players.pop(client_id, None)
1222 if underlying_player_id is None and existing_player is not None:
1223 underlying_player_id = existing_player.underlying_player_id
1224 static_delay_default_ms = self._bridge_static_delay_defaults.pop(client_id, None)
1225 if static_delay_default_ms is None and isinstance(existing_player, SendspinPlayer):
1226 static_delay_default_ms = existing_player.static_delay_default_ms
1227
1228 # Select on negotiated (not active) roles: activation follows pairing/trust state,
1229 # which must not change the player class.
1230 negotiated_families = {
1231 role_family(role_id) for role_id in sendspin_client.negotiated_role_ids
1232 }
1233 has_player_role = "player" in negotiated_families
1234 has_metadata_role = "metadata" in negotiated_families
1235 has_visualizer_role = "visualizer" in negotiated_families
1236
1237 if has_player_role:
1238 audio_player = SendspinPlayer(self, client_id, initial_hello=initial_hello)
1239 if isinstance(existing_player, SendspinPlayer):
1240 audio_player.preserve_control_features_from(existing_player)
1241 player: SendspinBasePlayer = audio_player
1242 elif has_metadata_role or has_visualizer_role:
1243 default_type = PlayerType.DISPLAY if has_metadata_role else PlayerType.VISUALIZER
1244 viz_player = SendspinVisualizerPlayer(self, client_id, initial_hello=initial_hello)
1245 viz_player._attr_type = bridge_player_type or default_type
1246 player = viz_player
1247 elif "source" in negotiated_families:
1248 # Capture-only device: a SendspinPlayer here would advertise playback it
1249 # cannot do. It only needs a settings page.
1250 player = SendspinSourcePlayer(self, client_id, initial_hello=initial_hello)
1251 else:
1252 audio_player = SendspinPlayer(self, client_id, initial_hello=initial_hello)
1253 if isinstance(existing_player, SendspinPlayer):
1254 audio_player.preserve_control_features_from(existing_player)
1255 player = audio_player
1256
1257 if extra_ids:
1258 for id_type, id_value in extra_ids.items():
1259 player.device_info.add_identifier(id_type, id_value)
1260 if underlying_player_id is not None:
1261 player._attr_underlying_player_id = underlying_player_id
1262 if static_delay_default_ms is not None and isinstance(player, SendspinPlayer):
1263 player.static_delay_default_ms = static_delay_default_ms
1264 return player
1265
1266 @staticmethod
1267 def _pick_pin_method(
1268 offered: list[PairMethodDescriptor], *, verify: bool = False, static: bool = False
1269 ) -> PairMethod:
1270 """
1271 Select the preferred usable PIN method from the client's offer.
1272
1273 :param verify: Restrict to dynamic PIN, the only method that proves device presence.
1274 :param static: Restrict to static PIN, overriding the dynamic-first default.
1275 """
1276 wanted: tuple[PairMethod, ...]
1277 if verify:
1278 wanted = (PairMethod.DYNAMIC_PIN,)
1279 elif static:
1280 wanted = (PairMethod.STATIC_PIN,)
1281 else:
1282 wanted = (PairMethod.DYNAMIC_PIN, PairMethod.STATIC_PIN)
1283 offered_methods = {descriptor.method for descriptor in offered}
1284 for method in wanted:
1285 if method in offered_methods:
1286 return method
1287 raise SecurityActionError("pairing_error_no_pin_method")
1288
1289 async def _open_pairing_window(self, client_id: str) -> bool:
1290 """
1291 Open a pairing window over management, sparing the operator the device-side gesture.
1292
1293 Only works before the attempt starts: the pairing activate takes management off the
1294 connection's activities. Returns whether a management session was opened here,
1295 for the caller to close once the pairing session ends.
1296 """
1297 opened = self.get_management_session(client_id) is None
1298 keep = False
1299 try:
1300 self.enter_management(client_id)
1301 await self.management_open_pairing_window(client_id)
1302 keep = opened
1303 except SecurityActionError as err:
1304 self.logger.debug("No pairing window opened on %s: %s", client_id, err)
1305 finally:
1306 # Hand back a session opened here unless the caller inherits it, cancellation included.
1307 if opened and not keep:
1308 self.exit_management(client_id)
1309 return keep
1310
1311 def _begin_pin_attempt(self, session: PinPairingSession) -> None:
1312 """Start or restart a pairing attempt for the session, resetting per-attempt state."""
1313 self._cancel_pin_idle_timeout(session.client_id)
1314 session.error = None
1315 session.retryable = False
1316 session.pin_request_event.clear()
1317 session.gesture_event.clear()
1318 if session.pin_future.done():
1319 session.pin_future = self.mass.loop.create_future()
1320 session.task = self.mass.create_task(self._run_pin_pairing(session))
1321
1322 async def _pin_request_feedback(self, session: PinPairingSession) -> None:
1323 """Wait briefly for the attempt to reach the PIN wait (or end), for an accurate render."""
1324 if session.task is None:
1325 return
1326 waiter = self.mass.create_task(session.wait_first_message())
1327 try:
1328 await asyncio.wait((waiter,), timeout=PIN_REQUEST_FEEDBACK_TIMEOUT)
1329 finally:
1330 waiter.cancel()
1331
1332 async def _run_pin_pairing(self, session: PinPairingSession) -> None:
1333 """Run one PIN pairing attempt, classifying the outcome for the UI."""
1334
1335 def pin_provider() -> asyncio.Future[str]:
1336 # Invoked only once the client's pair-init has arrived (post-gesture).
1337 session.pin_request_event.set()
1338 return session.pin_future
1339
1340 def on_pair_pending() -> None:
1341 session.gesture_event.set()
1342
1343 try:
1344 await self.server_api.initiate_pairing(
1345 session.client_id,
1346 PairingAttempt(
1347 session.method,
1348 pin_provider=pin_provider,
1349 verify=session.verify,
1350 on_pair_pending=on_pair_pending,
1351 languages=self._spoken_pin_languages()
1352 if session.method is PairMethod.DYNAMIC_PIN
1353 else (),
1354 ),
1355 )
1356 except PairingTimeoutError as err:
1357 # The device never answered; aiosendspin cancelled the attempt in band and left
1358 # pairing, so the connection is still usable and a retry can start afresh.
1359 session.error = err
1360 self.logger.debug("PIN pairing with %s timed out: %s", session.client_id, err)
1361 session.retryable = True
1362 self._arm_pin_idle_timeout(session)
1363 except PairingAbortError as err:
1364 if (
1365 isinstance(err, LocalPairingAbortError)
1366 and err.reason is PairAbortReason.USER_CANCELLED
1367 ):
1368 # Our own end_pairing cancelled this attempt; the connection is already restored.
1369 return
1370 session.error = err
1371 self.logger.debug("PIN pairing with %s aborted: %s", session.client_id, err)
1372 session.retryable = True
1373 self._arm_pin_idle_timeout(session)
1374 except Exception as err:
1375 # A non-abort failure: the server has already disconnected the client.
1376 session.error = err
1377 self.logger.debug("PIN pairing with %s failed: %s", session.client_id, err)
1378 else:
1379 await self._refresh_player(session.client_id)
1380 finally:
1381 if not session.pin_future.done():
1382 session.pin_future.cancel()
1383
1384 def _spoken_pin_languages(self) -> tuple[str, ...]:
1385 """
1386 Return the language preference for a spoken dynamic PIN, most preferred first.
1387
1388 The metadata locale is the only server-wide language setting, so it stands in for the
1389 operator's own preference.
1390 """
1391 locale = self.mass.metadata.locale.replace("_", "-")
1392 language = locale.split("-")[0]
1393 return (locale, language) if language != locale else (locale,)
1394
1395 def _arm_pin_idle_timeout(self, session: PinPairingSession) -> None:
1396 """Schedule restoration of the connection if a failed attempt is left unretried."""
1397 self.mass.call_later(
1398 PIN_RETRY_IDLE_TIMEOUT,
1399 self._pin_idle_timeout,
1400 session,
1401 task_id=_pin_idle_task_id(session.client_id),
1402 )
1403
1404 def _cancel_pin_idle_timeout(self, client_id: str) -> None:
1405 """Cancel a pairing idle timeout, whether still pending or already firing."""
1406 task_id = _pin_idle_task_id(client_id)
1407 self.mass.cancel_timer(task_id)
1408 self.mass.cancel_task(task_id)
1409
1410 async def _pin_idle_timeout(self, session: PinPairingSession) -> None:
1411 """Terminate an abandoned retryable session, restoring the connection to service."""
1412 session.retryable = False
1413 session.error = TimeoutError("timed out waiting for a pairing retry")
1414 await self._end_pairing_quietly(session.client_id)
1415 await self._refresh_player(session.client_id)
1416
1417 async def _end_pairing_quietly(self, client_id: str) -> None:
1418 """End pairing on a client, tolerating an already-gone connection."""
1419 try:
1420 await self.server_api.end_pairing(client_id)
1421 except Exception as err:
1422 self.logger.debug("Ending pairing for %s failed: %s", client_id, err)
1423
1424 def _management_session_or_raise(self, client_id: str) -> ManagementSession:
1425 """Return the client's management session or raise if none is open."""
1426 session = self.get_management_session(client_id)
1427 if session is None:
1428 raise SecurityActionError("management_error_no_session")
1429 return session
1430
1431 async def _management_call[T](self, connection: SendspinConnection, request: Awaitable[T]) -> T:
1432 """Run a management request with a timeout, mapping transport failures to SecurityActionError."""
1433 try:
1434 async with asyncio.timeout(MANAGEMENT_REQUEST_TIMEOUT):
1435 return await request
1436 except TimeoutError as err:
1437 # Replies match requests by order with no id, so a timed-out request left in
1438 # flight would desync the next one; drop the connection to reset the channel.
1439 await connection.disconnect()
1440 raise SecurityActionError("management_error_timeout") from err
1441 except RuntimeError as err:
1442 raise SecurityActionError("management_error_generic", detail=str(err)) from err
1443
1444 def _arm_management_idle_timeout(self, session: ManagementSession) -> None:
1445 """(Re)start the idle countdown that closes an abandoned management session."""
1446 self.mass.call_later(
1447 MANAGEMENT_IDLE_TIMEOUT,
1448 self._drop_management_session,
1449 session,
1450 task_id=_management_idle_task_id(session.client_id),
1451 )
1452
1453 def _drop_management_session(self, session: ManagementSession) -> None:
1454 """Remove a management session, releasing its hold on the connection."""
1455 self.mass.cancel_timer(_management_idle_task_id(session.client_id))
1456 self._management_sessions.pop(session.client_id, None)
1457 with suppress(Exception):
1458 session.connection.disable_management()
1459
1460 async def _refresh_player(self, client_id: str) -> None:
1461 """Re-evaluate a registered player after a pairing/trust change (in place)."""
1462 player = self.mass.players.get_player(client_id)
1463 if not isinstance(player, SendspinBasePlayer) or not player.initialized.is_set():
1464 return
1465 # A trust change (de)activates roles; role instances are recreated on activation,
1466 # so pushed config (preferred format, static delay) must be re-applied.
1467 await player.on_config_updated()
1468 player.update_state()
1469
1470 def _on_user_access_revoked(self, user: User) -> None:
1471 """Handle a user's access being withdrawn (tokens revoked or account deleted)."""
1472 # Both owner forms, so the match cannot depend on the user's role at mint time.
1473 for owner in credential_owners_for_user_id(user.user_id):
1474 task = self.mass.create_task(self._evict_pairings_for_owner(owner))
1475 self._running_pairing_evictions.add(task)
1476 task.add_done_callback(self._running_pairing_evictions.discard)
1477
1478 async def _evict_pairings_for_owner(self, owner: str) -> None:
1479 """Drop every pairing bound to ``owner``, unpairing connected clients in-band."""
1480 if self._unloading:
1481 return
1482 pairing_store = self.server_api.pairing_store
1483 for record in await pairing_store.records_by_owner(owner):
1484 # records_by_owner was read once: only withdraw what this owner still holds.
1485 current = await pairing_store.record_by_client_id(record.client_id)
1486 if current is None or current.owner != owner:
1487 continue
1488 try:
1489 await self.server_api.unpair(record.client_id)
1490 except ValueError:
1491 # Not connected: there is no client half to notify, drop only our record.
1492 await pairing_store.remove_record(record.client_id)
1493 self.logger.info(
1494 "Removed the pairing of client %s: its owner's access was revoked",
1495 record.client_id,
1496 )
1497 await self._refresh_player(record.client_id)
1498
1499 async def _evict_session_pairing(self, client_id: str) -> None:
1500 """Drop a disconnected client's session-scoped pairing (a no-op for durable ones)."""
1501 self._pending_pairing_evictions.discard(client_id)
1502 if self._unloading:
1503 return
1504 client = self.server_api.get_client(client_id)
1505 if client is not None and client.is_connected:
1506 # Already reconnected: the pairing lives until the connection truly ends.
1507 return
1508 record = await self.server_api.pairing_store.record_by_client_id(client_id)
1509 if record is None or record.owner is None or not is_session_scoped_owner(record.owner):
1510 return
1511 await self.server_api.pairing_store.remove_record(client_id)
1512 self.logger.info("Removed the session-scoped pairing of client %s on disconnect", client_id)
1513 await self._refresh_player(client_id)
1514
1515 async def _await_connected_client(self, client_id: str) -> SendspinBasePlayer:
1516 """Return a client's fully registered player, waiting for its connection to land first."""
1517
1518 # A web player asks to be paired a beat before its Sendspin handshake lands, and
1519 # the pairing refresh needs a fully registered player to re-apply config onto.
1520 def _registered_player() -> SendspinBasePlayer | None:
1521 client = self.server_api.get_client(client_id)
1522 if client is None or not client.is_connected:
1523 return None
1524 player = self.mass.players.get_player(client_id)
1525 if isinstance(player, SendspinBasePlayer) and player.initialized.is_set():
1526 return player
1527 return None
1528
1529 player = await _poll_until(_registered_player, WEB_PLAYER_CONNECT_TIMEOUT)
1530 if player is None:
1531 raise InvalidCommand(f"Client {client_id} did not register")
1532 return player
1533
1534 async def _handle_client_added(self, client_id: str, event_version: int) -> None:
1535 """Handle a new client connection asynchronously."""
1536 try:
1537 if self._unloading:
1538 return
1539 sendspin_client = self.server_api.get_client(client_id)
1540 if sendspin_client is None:
1541 self.logger.debug("Client %s disconnected before add handling started", client_id)
1542 return
1543 bridge_hello_snapshot = None
1544 if (
1545 client_id in self._bridge_identifiers
1546 and (bridge_hello := sendspin_client.info_or_none) is not None
1547 ):
1548 # Snapshot the bridges hello before a reconnect can overwrite it
1549 bridge_hello_snapshot = deepcopy(bridge_hello)
1550 if pending_event := self._pending_unregisters.get(client_id):
1551 self.logger.debug(
1552 "Waiting for pending unregister of %s before registering", client_id
1553 )
1554 await pending_event.wait()
1555 if not self._is_current_client_event(client_id, event_version):
1556 self.logger.debug("Skipping stale add event for %s after waiting", client_id)
1557 return
1558 # Check if client still exists (may have disconnected while waiting)
1559 sendspin_client = self.server_api.get_client(client_id)
1560 if sendspin_client is None:
1561 self.logger.debug("Client %s disconnected before hello completed", client_id)
1562 return
1563 # Wait for client hello to be processed (info becomes available)
1564 # ClientAddedEvent fires before the hello handshake completes
1565 for _ in range(50): # Wait up to 5 seconds
1566 if sendspin_client.info_or_none is not None:
1567 break
1568 await asyncio.sleep(0.1)
1569 else:
1570 self.logger.warning("Client %s hello not received within timeout", client_id)
1571 return
1572 if not self._is_current_client_event(client_id, event_version):
1573 self.logger.debug("Skipping stale add event for %s", client_id)
1574 return
1575 if not self.mass.config.get_raw_player_config_value(client_id, CONF_ENABLED, True):
1576 self.logger.debug("Ignoring disabled sendspin client: %s", client_id)
1577 return
1578 existing_player = self.mass.players.get_player(client_id)
1579 preserved_identifiers = (
1580 dict(existing_player.device_info.identifiers) if existing_player is not None else {}
1581 )
1582 if existing_player is not None:
1583 self.logger.debug("Refreshing existing player object for %s", client_id)
1584 await self.mass.players.unregister(client_id)
1585 if not self._is_current_client_event(client_id, event_version):
1586 self.logger.debug("Skipping stale add event for %s after unregister", client_id)
1587 return
1588 sendspin_client = self.server_api.get_client(client_id)
1589 if sendspin_client is None:
1590 self.logger.debug("Client %s disconnected after unregister", client_id)
1591 return
1592
1593 player = self._create_player(
1594 client_id, sendspin_client, existing_player, bridge_hello_snapshot
1595 )
1596 for id_type, id_value in preserved_identifiers.items():
1597 player.device_info.add_identifier(id_type, id_value)
1598 self.logger.debug("Client %s connected", client_id)
1599 await self._apply_hass_esphome_enrichment([player])
1600 if not self._is_current_client_event(client_id, event_version):
1601 self.logger.debug("Skipping stale add event for %s after HA enrichment", client_id)
1602 player._unsubscribe_client_callbacks()
1603 return
1604 try:
1605 await self.mass.players.register(player)
1606 except AlreadyRegisteredError:
1607 self.logger.debug(
1608 "Client %s already registered while handling add event", client_id
1609 )
1610 player._unsubscribe_client_callbacks()
1611 finally:
1612 self._finish_client_event(client_id)
1613
1614 async def _handle_client_removed(self, client_id: str, event_version: int) -> None:
1615 """Handle a client disconnection asynchronously."""
1616 try:
1617 if self._unloading:
1618 return
1619 self.logger.debug("Client %s disconnected", client_id)
1620 if not self._is_current_client_event(client_id, event_version):
1621 self.logger.debug("Skipping stale remove event for %s", client_id)
1622 return
1623 unregister_event = asyncio.Event()
1624 self._pending_unregisters[client_id] = unregister_event
1625 try:
1626 await self.mass.players.unregister(client_id)
1627 finally:
1628 self._pending_unregisters.pop(client_id, None)
1629 unregister_event.set()
1630 finally:
1631 self._finish_client_event(client_id)
1632
1633 async def _handle_client_updated(self, client_id: str, event_version: int) -> None:
1634 """Handle a client whose hello payload changed on reconnect."""
1635 try:
1636 if self._unloading:
1637 return
1638 if pending_event := self._pending_unregisters.get(client_id):
1639 self.logger.debug("Waiting for pending unregister of %s before updating", client_id)
1640 await pending_event.wait()
1641 if not self._is_current_client_event(client_id, event_version):
1642 self.logger.debug("Skipping stale update event for %s after waiting", client_id)
1643 return
1644 sendspin_client = self.server_api.get_client(client_id)
1645 if sendspin_client is None:
1646 return
1647 if not self._is_current_client_event(client_id, event_version):
1648 self.logger.debug("Skipping stale update event for %s", client_id)
1649 return
1650 existing_player = self.mass.players.get_player(client_id)
1651 if not isinstance(existing_player, SendspinBasePlayer):
1652 return
1653 previous_device_info = existing_player.device_info
1654 previous_type = existing_player.type
1655 existing_player._refresh_client_info(sendspin_client)
1656 if isinstance(existing_player, SendspinPlayer):
1657 existing_player.restore_bridge_identity(previous_device_info, previous_type)
1658 await self._apply_hass_esphome_enrichment([existing_player])
1659 if not self._is_current_client_event(client_id, event_version):
1660 self.logger.debug("Skipping stale update event for %s after refresh", client_id)
1661 return
1662 if previous_type == PlayerType.PROTOCOL and existing_player.type != PlayerType.PROTOCOL:
1663 existing_player.set_protocol_parent_id(None)
1664 existing_player._attr_underlying_player_id = None
1665 await self.mass.players.register_or_update(existing_player)
1666 finally:
1667 self._finish_client_event(client_id)
1668
1669 def _get_virtual_player_config_owner(self, player_id: str) -> str | None:
1670 """Return the owner instance id from a stored virtual player config, if any."""
1671 raw_conf = self.mass.config.get(f"{CONF_PLAYERS}/{player_id}")
1672 if not isinstance(raw_conf, dict) or raw_conf.get("provider") != self.instance_id:
1673 return None
1674 values = raw_conf.get("values")
1675 if not isinstance(values, dict):
1676 return None
1677 return cast("str | None", values.get(CONF_VIRTUAL_PLAYER_OWNER))
1678
1679 def _register_virtual_player_client(self, player_id: str, display_name: str) -> None:
1680 """Register the silent external Sendspin client backing a virtual player."""
1681 hello = ClientHelloPayload(
1682 client_id=player_id,
1683 name=display_name,
1684 version=1,
1685 supported_roles=[BRIDGE_ROLE_ID],
1686 device_info=SendspinDeviceInfo(
1687 product_name="Virtual Player",
1688 manufacturer="Music Assistant",
1689 ),
1690 player_support=ClientHelloPlayerSupport(
1691 supported_formats=[
1692 SupportedAudioFormat(
1693 codec=AudioCodec.PCM,
1694 channels=BRIDGE_CHANNELS,
1695 sample_rate=BRIDGE_SAMPLE_RATE,
1696 bit_depth=BRIDGE_BIT_DEPTH,
1697 )
1698 ],
1699 buffer_capacity=1_000,
1700 supported_commands=[],
1701 ),
1702 )
1703 sendspin_client = self.server_api.register_external_player(
1704 hello, on_stream_start=self._on_virtual_player_stream_start
1705 )
1706 for role in sendspin_client.roles_by_family("player"):
1707 if not isinstance(role, BridgePlayerRole):
1708 continue
1709 # audio delivered to the role is simply discarded: the virtual player
1710 # only anchors the group, the members receive the actual stream
1711 role.set_callbacks(
1712 on_audio_chunk=_virtual_player_noop,
1713 on_volume_change=_virtual_player_noop,
1714 on_mute_change=_virtual_player_noop,
1715 on_stream_start=_virtual_player_noop,
1716 on_stream_end=_virtual_player_noop,
1717 )
1718 role.setup_audio_requirements()
1719 role.set_timing(required_lead_time_ms=0, min_buffer_ms=0)
1720 break
1721
1722 async def _wait_for_virtual_player(self, player_id: str) -> None:
1723 """Wait until the virtual player is registered in MA with its queue."""
1724
1725 def _registered() -> bool | None:
1726 if (
1727 self.mass.players.get_player(player_id) is not None
1728 and self.mass.player_queues.get(player_id) is not None
1729 ):
1730 return True
1731 return None
1732
1733 if await _poll_until(_registered, VIRTUAL_PLAYER_REGISTER_TIMEOUT) is None:
1734 raise SetupFailedError(f"Virtual player {player_id} was not registered in time")
1735
1736 async def _cleanup_failed_virtual_player_creation(self, player_id: str) -> None:
1737 """
1738 Remove a virtual player after its creation does not complete.
1739
1740 :param player_id: Virtual player to remove.
1741 """
1742 last_error: Exception | None = None
1743 for delay in VIRTUAL_PLAYER_CLEANUP_DELAYS:
1744 if delay:
1745 await asyncio.sleep(delay)
1746 try:
1747 # another teardown won the race; a config it left behind is not ours
1748 # to delete - it is kept for the owner to reclaim, and swept at
1749 # startup once that owner is gone
1750 if not self.is_virtual_player(player_id):
1751 return
1752 # awaited to completion on purpose: a timeout is no reliable bound on
1753 # the teardown - parts of it swallow the cancellation (see
1754 # AsyncProcess.close), and one that does land leaves the player
1755 # half torn down for the next attempt to trip over
1756 await self.remove_virtual_player(player_id)
1757 return
1758 except Exception as err:
1759 last_error = err
1760 self.logger.warning(
1761 "Could not clean up failed virtual player creation %s: %s",
1762 player_id,
1763 last_error,
1764 )
1765
1766 def _on_virtual_player_stream_start(self, _request: ExternalStreamStartRequest) -> None:
1767 """Accept stream start requests for virtual players (nothing to connect)."""
1768
1769 async def _on_providers_updated(self, event: MassEvent) -> None:
1770 """Handle a change in the loaded providers."""
1771 # during (server) shutdown all providers unload; the startup sweep
1772 # takes care of configs whose owner is really gone
1773 if self._unloading or self.mass.closing:
1774 return
1775 # remove virtual players whose owning provider is no longer loaded
1776 for player_id, owner_instance_id in list(self._virtual_players.items()):
1777 if self.mass.get_provider(owner_instance_id) is not None:
1778 continue
1779 self.logger.debug(
1780 "Removing virtual player %s: owner %s unloaded", player_id, owner_instance_id
1781 )
1782 await self.remove_virtual_player(player_id)
1783 # (re)apply the HA-backed enrichment when the hass plugin (un)loads
1784 hass = self.mass.get_provider("hass")
1785 hass_available = hass is not None and hass.available
1786 if hass_available != self._hass_available:
1787 self._hass_available = hass_available
1788 await self._refresh_hass_esphome_enrichment()
1789
1790 def _remove_orphan_virtual_player_configs(self) -> None:
1791 """Delete stored configs of virtual players whose owner provider is gone."""
1792 all_player_configs = self.mass.config.get(CONF_PLAYERS, {})
1793 for player_id, raw_conf in list(all_player_configs.items()):
1794 if not isinstance(raw_conf, dict) or raw_conf.get("provider") != self.instance_id:
1795 continue
1796 values = raw_conf.get("values")
1797 if not isinstance(values, dict):
1798 values = {}
1799 owner_instance_id = values.get(CONF_VIRTUAL_PLAYER_OWNER)
1800 if owner_instance_id is None:
1801 continue
1802 if self.mass.config.get(f"{CONF_PROVIDERS}/{owner_instance_id}") is not None:
1803 # owner still configured; it will recreate its virtual players
1804 continue
1805 self.logger.debug("Removing orphan virtual player config %s", player_id)
1806 self.mass.players.delete_player_config(player_id)
1807
1808
1809def _virtual_player_noop(*_args: object) -> None:
1810 """No-op callback for virtual players, which never render audio locally."""
1811