CONF_REMOTE_ID = "remote_id"
CONF_ENABLED = "enabled"
+TASK_ID_START_GATEWAY = "remote_access_start_gateway"
+STARTUP_DELAY = 5
+
@dataclass
class RemoteAccessInfo(DataClassDictMixin):
self._register_api_commands()
self.mass.subscribe(self._on_providers_updated, EventType.PROVIDERS_UPDATED)
if self._enabled:
- await self.start()
+ self._schedule_start()
async def close(self) -> None:
"""Cleanup on exit."""
+ self.mass.cancel_timer(TASK_ID_START_GATEWAY)
await self.stop()
for unload_cb in self._on_unload_callbacks:
unload_cb()
- async def start(self) -> None:
- """Start the remote access gateway."""
- if self.is_running or not self._enabled:
+ def _schedule_start(self) -> None:
+ """Schedule a debounced gateway start."""
+ self.logger.debug("Scheduling remote access gateway start in %s seconds", STARTUP_DELAY)
+ self.mass.call_later(
+ STARTUP_DELAY,
+ self._start_gateway(),
+ task_id=TASK_ID_START_GATEWAY,
+ )
+
+ async def _start_gateway(self) -> None:
+ """Start the remote access gateway (internal implementation)."""
+ if self.is_running:
+ self.logger.debug("Gateway already running, skipping start")
+ return
+ if not self._enabled:
+ self.logger.debug("Remote access disabled, skipping start")
return
base_url = self.mass.webserver.base_url
)
await self.gateway.start()
- self._enabled = True
async def stop(self) -> None:
"""Stop the remote access gateway."""
:param event: The providers updated event.
"""
- if not self.is_running or not self._enabled:
+ if not self._enabled:
+ return
+
+ # If not running yet, schedule start (debounced)
+ if not self.is_running:
+ self._schedule_start()
return
+ # Check if HA Cloud status changed
ha_cloud_available, ice_servers = await self._get_ha_cloud_status()
new_using_ha_cloud = bool(ha_cloud_available and ice_servers)
if new_using_ha_cloud != self._using_ha_cloud:
self.logger.info("HA Cloud status changed, restarting remote access")
await self.stop()
- self.mass.create_task(self.start())
+ self._schedule_start()
async def _get_ha_cloud_status(self) -> tuple[bool, list[dict[str, str]] | None]:
"""Get Home Assistant Cloud status and ICE servers.
self._enabled = enabled
self.mass.config.set(f"{CONF_CORE}/{CONF_KEY_MAIN}/{CONF_ENABLED}", enabled)
if self._enabled and not self.is_running:
- await self.start()
+ await self._start_gateway()
elif not self._enabled and self.is_running:
await self.stop()
return await get_remote_access_info()
self._current_reconnect_delay = 5
self._run_task: asyncio.Task[None] | None = None
self._is_connected = False
+ self._connecting = False
@property
def is_running(self) -> bool:
async def _connect_to_signaling(self) -> None:
"""Connect to the signaling server."""
+ if self._connecting:
+ self.logger.warning("Already connecting to signaling server, skipping")
+ return
+ self._connecting = True
self.logger.info("Connecting to signaling server: %s", self.signaling_url)
try:
self._signaling_ws = await self.http_session.ws_connect(
self.signaling_url,
- heartbeat=30, # Send WebSocket ping every 30 seconds
- autoping=True, # Automatically respond to pings
+ heartbeat=None,
)
- self.logger.debug("WebSocket connection established")
- # Small delay to let any previous connection fully close on the server side
- # This helps prevent race conditions during reconnection
- await asyncio.sleep(0.5)
+ self.logger.debug("WebSocket connection established, id=%s", id(self._signaling_ws))
self.logger.debug("Sending registration")
await self._register()
- # Note: _is_connected is set to True when we receive "registered" confirmation
- # Reset reconnect delay on successful connection
self._current_reconnect_delay = self._reconnect_delay
self.logger.info("Registration sent, waiting for confirmation...")
else:
self.logger.warning("Unexpected WebSocket message type: %s", msg.type)
+ ws_exception = self._signaling_ws.exception()
self.logger.info(
- "Message loop exited - WebSocket closed: %s", self._signaling_ws.closed
+ "Message loop exited - WebSocket closed: %s, close_code: %s, exception: %s",
+ self._signaling_ws.closed,
+ self._signaling_ws.close_code,
+ ws_exception,
)
except TimeoutError:
self.logger.error("Timeout connecting to signaling server")
self.logger.exception("Unexpected error in signaling connection")
finally:
self._is_connected = False
+ self._connecting = False
self._signaling_ws = None
async def _register(self) -> None:
from typing import TYPE_CHECKING, Any, cast
import aiohttp
-from aiortc import (
- RTCConfiguration,
- RTCIceServer,
- RTCPeerConnection,
- RTCSessionDescription,
-)
+from aiortc import RTCConfiguration, RTCIceServer, RTCPeerConnection, RTCSessionDescription
from aiortc.sdp import candidate_from_sdp
from aiosendspin.server import ClientAddedEvent, ClientRemovedEvent, SendspinEvent, SendspinServer
from music_assistant_models.enums import ProviderFeature
+from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user
from music_assistant.mass import MusicAssistant
from music_assistant.models.player_provider import PlayerProvider
from music_assistant.providers.sendspin.player import SendspinPlayer
# WebRTC signaling commands for Sendspin connections
# this is used to establish WebRTC DataChannels with Sendspin clients
# for example the WebPlayer in the Music Assistant frontend or supported (mobile) apps
+ self.mass.register_api_command(
+ "sendspin/connection_info", self.handle_get_connection_info
+ ),
self.mass.register_api_command("sendspin/connect", self.handle_webrtc_connect),
self.mass.register_api_command("sendspin/ice", self.handle_webrtc_ice),
self.mass.register_api_command("sendspin/disconnect", self.handle_webrtc_disconnect),
"type": pc.localDescription.type,
},
"ice_candidates": ice_candidates,
+ # Include local WebSocket URL for direct connection attempts
+ # Frontend can try this first before falling back to WebRTC
+ "local_ws_url": f"ws://{self.mass.streams.publish_ip}:8927/sendspin",
}
async def handle_webrtc_ice(
"""
return await self.mass.webserver.remote_access.get_ice_servers()
+ async def handle_get_connection_info(self, client_id: str | None = None) -> dict[str, Any]:
+ """
+ Get connection info for Sendspin.
+
+ Returns the local WebSocket URL for direct connection attempts,
+ and ICE servers for WebRTC fallback.
+
+ The frontend should try the local WebSocket URL first for lower latency,
+ and fall back to WebRTC if the direct connection fails.
+
+ :param client_id: Optional Sendspin client ID for auto-whitelisting.
+ :return: Dictionary with local_ws_url and ice_servers.
+ """
+ # Auto-whitelist the player for users with player filters enabled
+ # This allows users with restricted player access to still use the web player
+ if client_id and (user := get_current_user()):
+ if user.player_filter and client_id not in user.player_filter:
+ self.logger.debug(
+ "Auto-whitelisting Sendspin player %s for user %s",
+ client_id,
+ user.username,
+ )
+ new_filter = [*user.player_filter, client_id]
+ await self.mass.webserver.auth.update_user_filters(
+ user, player_filter=new_filter, provider_filter=None
+ )
+
+ return {
+ "local_ws_url": f"ws://{self.mass.streams.publish_ip}:8927/sendspin",
+ "ice_servers": await self.mass.webserver.remote_access.get_ice_servers(),
+ }
+
async def _close_webrtc_session(self, session_id: str) -> None:
"""Close a WebRTC session and clean up resources."""
session = self._webrtc_sessions.pop(session_id, None)