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