/
/
/
1"""
2Remote Access subcomponent for the Webserver Controller.
3
4This module manages WebRTC-based remote access to Music Assistant instances.
5It connects to a signaling server and handles incoming WebRTC connections,
6bridging them to the local WebSocket API.
7"""
8
9from __future__ import annotations
10
11import asyncio
12from collections.abc import Callable
13from dataclasses import dataclass
14from typing import TYPE_CHECKING, cast
15
16from awesomeversion import AwesomeVersion
17from mashumaro import DataClassDictMixin
18from music_assistant_models.auth import Scope
19from music_assistant_models.enums import EventType
20
21from music_assistant.constants import CONF_CORE
22from music_assistant.helpers.webrtc_certificate import (
23 get_or_create_remote_id,
24 get_or_create_webrtc_certificate_pems,
25)
26
27if TYPE_CHECKING:
28 from music_assistant_models.event import MassEvent
29
30 from music_assistant.controllers.webserver import WebserverController
31 from music_assistant.controllers.webserver.remote_access.gateway import WebRTCGateway
32 from music_assistant.providers.hass import HomeAssistantProvider
33
34# Signaling server URL
35SIGNALING_SERVER_URL = "wss://signaling.music-assistant.io/ws"
36
37CONF_KEY_MAIN = "remote_access"
38CONF_ENABLED = "enabled"
39
40TASK_ID_START_GATEWAY = "remote_access_start_gateway"
41STARTUP_DELAY = 5
42
43
44@dataclass
45class RemoteAccessInfo(DataClassDictMixin):
46 """Remote Access information dataclass."""
47
48 enabled: bool
49 running: bool
50 connected: bool
51 remote_id: str
52 using_ha_cloud: bool
53 signaling_url: str
54
55
56class RemoteAccessManager:
57 """Manages WebRTC-based remote access for the webserver."""
58
59 def __init__(self, webserver: WebserverController) -> None:
60 """Initialize the remote access manager."""
61 self.webserver = webserver
62 self.mass = webserver.mass
63 self.logger = webserver.logger.getChild("remote_access")
64 self.gateway: WebRTCGateway | None = None
65 self._gateway_lock = asyncio.Lock()
66 self._remote_id: str
67 self._cert_pem: str
68 self._key_pem: str
69 self._enabled: bool = False
70 self._using_ha_cloud: bool = False
71 self._target_using_ha_cloud: bool = False
72 self._on_unload_callbacks: list[Callable[[], None]] = []
73
74 async def setup(self) -> None:
75 """Initialize the remote access manager."""
76 # derive the Remote ID without importing aiolibdatachannel, so a disabled instance
77 # never spins up the native lib's thread pool while remote_access/info still works
78 self._remote_id = get_or_create_remote_id(self.mass.storage_path)
79
80 enabled_value = self.mass.config.get(f"{CONF_CORE}/{CONF_KEY_MAIN}/{CONF_ENABLED}", False)
81 self._enabled = bool(enabled_value)
82 self._register_api_commands()
83 self.mass.subscribe(self._on_providers_updated, EventType.PROVIDERS_UPDATED)
84 if self._enabled:
85 self._schedule_start()
86
87 async def close(self) -> None:
88 """Cleanup on exit."""
89 self._enabled = False
90 await self.stop()
91 for unload_cb in self._on_unload_callbacks:
92 unload_cb()
93
94 async def stop(self) -> None:
95 """Stop the remote access gateway."""
96 self.mass.cancel_timer(TASK_ID_START_GATEWAY)
97 self.mass.cancel_task(TASK_ID_START_GATEWAY)
98 async with self._gateway_lock:
99 await self._stop_gateway_locked()
100
101 async def get_ice_servers(self) -> list[dict[str, str]]:
102 """
103 Get ICE servers for WebRTC connections.
104
105 Returns HA Cloud TURN servers if available, otherwise returns public STUN servers.
106 This method can be called regardless of whether remote access is enabled.
107
108 :return: List of ICE server configurations.
109 """
110 # Default public STUN servers
111 default_ice_servers: list[dict[str, str]] = [
112 {"urls": "stun:stun.l.google.com:19302"},
113 {"urls": "stun:stun.cloudflare.com:3478"},
114 {"urls": "stun:stun.home-assistant.io:3478"},
115 ]
116
117 # Try to get HA Cloud ICE servers
118 _, ice_servers = await self._get_ha_cloud_status()
119 if ice_servers:
120 return ice_servers
121
122 return default_ice_servers
123
124 @property
125 def is_enabled(self) -> bool:
126 """Return whether WebRTC remote access is enabled."""
127 return self._enabled
128
129 @property
130 def is_running(self) -> bool:
131 """Return whether the gateway is running."""
132 return self.gateway is not None and self.gateway.is_running
133
134 @property
135 def is_connected(self) -> bool:
136 """Return whether the gateway is connected to the signaling server."""
137 return self.gateway is not None and self.gateway.is_connected
138
139 @property
140 def remote_id(self) -> str:
141 """Return the current Remote ID."""
142 return self._remote_id
143
144 def _schedule_start(self) -> None:
145 """Schedule a debounced gateway restart."""
146 self.mass.cancel_timer(TASK_ID_START_GATEWAY)
147 self.logger.debug("Scheduling remote access gateway start in %s seconds", STARTUP_DELAY)
148 self.mass.call_later(
149 STARTUP_DELAY,
150 self._restart_gateway,
151 task_id=TASK_ID_START_GATEWAY,
152 )
153
154 def _can_start_gateway(self) -> bool:
155 """Return whether remote access currently allows a gateway start."""
156 return self._enabled
157
158 async def _start_gateway(self) -> None:
159 """Start the remote access gateway if it is not already running."""
160 self.mass.cancel_timer(TASK_ID_START_GATEWAY)
161 async with self._gateway_lock:
162 if self.is_running:
163 self.logger.debug("Remote access gateway is already running")
164 return
165 await self._start_gateway_locked()
166
167 async def _restart_gateway(self) -> None:
168 """Replace the remote access gateway with a freshly configured instance."""
169 async with self._gateway_lock:
170 if self.is_running:
171 ha_cloud_available, ice_servers = await self._get_ha_cloud_status()
172 self._target_using_ha_cloud = bool(ha_cloud_available and ice_servers)
173 if self._target_using_ha_cloud == self._using_ha_cloud:
174 self.logger.debug("Remote access mode settled before restart")
175 return
176 await self._stop_gateway_locked()
177 await self._start_gateway_locked()
178
179 async def _start_gateway_locked(self) -> None:
180 """Start the remote access gateway while holding the lifecycle lock."""
181 if not self._can_start_gateway():
182 self.logger.debug("Remote access disabled, skipping start")
183 return
184
185 if self.gateway is not None:
186 await self._stop_gateway_locked()
187
188 # imported here so the native WebRTC lib (and its thread pool) is only loaded
189 # when remote access is actually enabled, never at idle
190 from music_assistant.controllers.webserver.remote_access.gateway import ( # noqa: PLC0415
191 WebRTCGateway,
192 )
193
194 self._cert_pem, self._key_pem = get_or_create_webrtc_certificate_pems(
195 self.mass.storage_path
196 )
197
198 # resolved once, at gateway start: this follows the webserver's bind address, which
199 # can only change through a reload of the webserver - and that restarts this gateway
200 local_ws_url = self.webserver.internal_base_url.replace("http", "ws", 1) + "/ws"
201
202 ha_cloud_available, ice_servers = await self._get_ha_cloud_status()
203 using_ha_cloud = bool(ha_cloud_available and ice_servers)
204 self._target_using_ha_cloud = using_ha_cloud
205 if not self._can_start_gateway():
206 self.logger.debug("Remote access disabled while preparing gateway")
207 return
208
209 mode = "optimized" if using_ha_cloud else "basic"
210 self.logger.info("Starting remote access in %s mode", mode)
211
212 # resolved once, at gateway start: this follows the streams bind IP, while the socket
213 # behind it is only bound when the Sendspin provider loads, so the two can disagree
214 # either way and re-resolving per session would be no more reliable
215 sendspin_url = self.webserver.internal_sendspin_url
216
217 gateway = WebRTCGateway(
218 http_session=self.mass.http_session,
219 remote_id=self._remote_id,
220 cert_pem=self._cert_pem,
221 key_pem=self._key_pem,
222 signaling_url=SIGNALING_SERVER_URL,
223 local_ws_url=local_ws_url,
224 sendspin_url=sendspin_url,
225 ice_servers=ice_servers,
226 # Pass callback to get fresh ICE servers for each client connection
227 # This ensures TURN credentials are always valid
228 ice_servers_callback=self.get_ice_servers if ha_cloud_available else None,
229 # Pass callback to set sendspin player on websocket client
230 set_sendspin_player_callback=self.webserver.set_sendspin_player_for_webrtc_session,
231 )
232
233 try:
234 await gateway.start()
235 except BaseException:
236 await gateway.stop()
237 raise
238 if not self._can_start_gateway():
239 await gateway.stop()
240 return
241 self.gateway = gateway
242 self._using_ha_cloud = using_ha_cloud
243
244 async def _stop_gateway_locked(self) -> None:
245 """Stop the remote access gateway while holding the lifecycle lock."""
246 if self.gateway is None:
247 return
248 gateway = self.gateway
249 await gateway.stop()
250 self.gateway = None
251
252 async def _on_providers_updated(self, event: MassEvent) -> None:
253 """
254 Handle providers updated event to detect HA Cloud status changes.
255
256 :param event: The providers updated event.
257 """
258 if not self._enabled:
259 return
260
261 # Check if HA Cloud status changed
262 ha_cloud_available, ice_servers = await self._get_ha_cloud_status()
263 new_using_ha_cloud = bool(ha_cloud_available and ice_servers)
264
265 if new_using_ha_cloud != self._target_using_ha_cloud:
266 self._target_using_ha_cloud = new_using_ha_cloud
267 self.logger.info("HA Cloud status changed, restarting remote access")
268 self._schedule_start()
269
270 async def _get_ha_cloud_status(self) -> tuple[bool, list[dict[str, str]] | None]:
271 """
272 Get Home Assistant Cloud status and ICE servers.
273
274 :return: Tuple of (ha_cloud_available, ice_servers).
275 """
276 ha_provider = cast("HomeAssistantProvider | None", self.mass.get_provider("hass"))
277 if not ha_provider:
278 return False, None
279 try:
280 hass_client = ha_provider.hass
281 if not hass_client or not hass_client.connected:
282 return False, None
283
284 result = await hass_client.send_command("cloud/status")
285 logged_in = result.get("logged_in", False)
286 active_subscription = result.get("active_subscription", False)
287 if not (logged_in and active_subscription):
288 return False, None
289 # HA Cloud is available, get ICE servers
290 # The cloud/webrtc/ice_servers command was added in HA 2025.12.0b6
291 if AwesomeVersion(hass_client.version) >= AwesomeVersion("2025.12.0b6"):
292 if ice_servers := await hass_client.send_command("cloud/webrtc/ice_servers"):
293 return True, ice_servers
294 else:
295 self.logger.debug(
296 "HA version %s not supported for optimized WebRTC mode "
297 "(requires 2025.12.0b6 or later)",
298 hass_client.version,
299 )
300 self.logger.debug("HA Cloud available but no ICE servers returned")
301 except Exception:
302 self.logger.exception("Error getting HA Cloud status")
303 return False, None
304
305 def _register_api_commands(self) -> None:
306 """Register API commands for remote access."""
307
308 async def get_remote_access_info() -> RemoteAccessInfo:
309 """Get remote access information."""
310 return RemoteAccessInfo(
311 enabled=self.is_enabled,
312 running=self.is_running,
313 connected=self.is_connected,
314 remote_id=self._remote_id,
315 using_ha_cloud=self._using_ha_cloud,
316 signaling_url=SIGNALING_SERVER_URL,
317 )
318
319 async def configure_remote_access(enabled: bool) -> RemoteAccessInfo:
320 """
321 Configure remote access settings.
322
323 :param enabled: Enable or disable remote access.
324 """
325 changed = self._enabled != enabled
326 self._enabled = enabled
327 self.mass.config.set(f"{CONF_CORE}/{CONF_KEY_MAIN}/{CONF_ENABLED}", enabled)
328 if self._enabled and not self.is_running:
329 await self._start_gateway()
330 elif not self._enabled:
331 await self.stop()
332 if changed:
333 self.mass.signal_event(
334 EventType.CORE_STATE_UPDATED, data=self.mass.get_server_info()
335 )
336 return await get_remote_access_info()
337
338 self._on_unload_callbacks.append(
339 self.mass.register_api_command(
340 "remote_access/info", get_remote_access_info, required_scope=Scope.SYSTEM_MANAGE
341 )
342 )
343 self._on_unload_callbacks.append(
344 self.mass.register_api_command(
345 "remote_access/configure",
346 configure_remote_access,
347 required_scope=Scope.SYSTEM_MANAGE,
348 )
349 )
350