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