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