/
/
/
1"""Remote Access subcomponent for the Webserver Controller.
2
3This module manages WebRTC-based remote access to Music Assistant instances.
4It connects to a signaling server and handles incoming WebRTC connections,
5bridging them to the local WebSocket API.
6"""
7
8from __future__ import annotations
9
10from collections.abc import Callable
11from dataclasses import dataclass
12from typing import TYPE_CHECKING, cast
13
14from awesomeversion import AwesomeVersion
15from mashumaro import DataClassDictMixin
16from music_assistant_models.enums import EventType
17
18from music_assistant.constants import CONF_CORE
19from music_assistant.controllers.webserver.remote_access.gateway import WebRTCGateway
20from music_assistant.helpers.webrtc_certificate import (
21 get_or_create_webrtc_certificate,
22 get_remote_id_from_certificate,
23)
24
25if TYPE_CHECKING:
26 from aiortc.rtcdtlstransport import RTCCertificate
27 from music_assistant_models.event import MassEvent
28
29 from music_assistant.controllers.webserver import WebserverController
30 from music_assistant.providers.hass import HomeAssistantProvider
31
32# Signaling server URL
33SIGNALING_SERVER_URL = "wss://signaling.music-assistant.io/ws"
34
35CONF_KEY_MAIN = "remote_access"
36CONF_ENABLED = "enabled"
37
38TASK_ID_START_GATEWAY = "remote_access_start_gateway"
39STARTUP_DELAY = 5
40
41
42@dataclass
43class RemoteAccessInfo(DataClassDictMixin):
44 """Remote Access information dataclass."""
45
46 enabled: bool
47 running: bool
48 connected: bool
49 remote_id: str
50 using_ha_cloud: bool
51 signaling_url: str
52
53
54class RemoteAccessManager:
55 """Manages WebRTC-based remote access for the webserver."""
56
57 def __init__(self, webserver: WebserverController) -> None:
58 """Initialize the remote access manager."""
59 self.webserver = webserver
60 self.mass = webserver.mass
61 self.logger = webserver.logger.getChild("remote_access")
62 self.gateway: WebRTCGateway | None = None
63 self._remote_id: str
64 self._certificate: RTCCertificate
65 self._enabled: bool = False
66 self._using_ha_cloud: bool = False
67 self._on_unload_callbacks: list[Callable[[], None]] = []
68
69 async def setup(self) -> None:
70 """Initialize the remote access manager."""
71 self._certificate = get_or_create_webrtc_certificate(self.mass.storage_path)
72
73 self._remote_id = get_remote_id_from_certificate(self._certificate)
74
75 enabled_value = self.mass.config.get(f"{CONF_CORE}/{CONF_KEY_MAIN}/{CONF_ENABLED}", False)
76 self._enabled = bool(enabled_value)
77 self._register_api_commands()
78 self.mass.subscribe(self._on_providers_updated, EventType.PROVIDERS_UPDATED)
79 if self._enabled:
80 await self._schedule_start()
81
82 async def close(self) -> None:
83 """Cleanup on exit."""
84 self.mass.cancel_timer(TASK_ID_START_GATEWAY)
85 await self.stop()
86 for unload_cb in self._on_unload_callbacks:
87 unload_cb()
88
89 async def _schedule_start(self) -> None:
90 """Schedule a debounced gateway start, cancelling any existing connection first."""
91 # Cancel any pending timer
92 self.mass.cancel_timer(TASK_ID_START_GATEWAY)
93 # Stop any existing gateway
94 await self.stop()
95 # Schedule new start
96 self.logger.debug("Scheduling remote access gateway start in %s seconds", STARTUP_DELAY)
97 self.mass.call_later(
98 STARTUP_DELAY,
99 self._start_gateway,
100 task_id=TASK_ID_START_GATEWAY,
101 )
102
103 async def _start_gateway(self) -> None:
104 """Start the remote access gateway (internal implementation)."""
105 if not self._enabled:
106 self.logger.debug("Remote access disabled, skipping start")
107 return
108
109 base_url = self.mass.webserver.base_url
110 local_ws_url = base_url.replace("http", "ws")
111 if not local_ws_url.endswith("/"):
112 local_ws_url += "/"
113 local_ws_url += "ws"
114
115 ha_cloud_available, ice_servers = await self._get_ha_cloud_status()
116 self._using_ha_cloud = bool(ha_cloud_available and ice_servers)
117
118 mode = "optimized" if self._using_ha_cloud else "basic"
119 self.logger.info("Starting remote access in %s mode", mode)
120
121 sendspin_url = f"ws://{self.mass.streams.publish_ip}:8927/sendspin"
122
123 self.gateway = WebRTCGateway(
124 http_session=self.mass.http_session,
125 remote_id=self._remote_id,
126 certificate=self._certificate,
127 signaling_url=SIGNALING_SERVER_URL,
128 local_ws_url=local_ws_url,
129 sendspin_url=sendspin_url,
130 ice_servers=ice_servers,
131 # Pass callback to get fresh ICE servers for each client connection
132 # This ensures TURN credentials are always valid
133 ice_servers_callback=self.get_ice_servers if ha_cloud_available else None,
134 # Pass callback to set sendspin player on websocket client
135 set_sendspin_player_callback=self.webserver.set_sendspin_player_for_webrtc_session,
136 )
137
138 await self.gateway.start()
139
140 async def stop(self) -> None:
141 """Stop the remote access gateway."""
142 if self.gateway:
143 await self.gateway.stop()
144 self.gateway = None
145
146 async def _on_providers_updated(self, event: MassEvent) -> None:
147 """Handle providers updated event to detect HA Cloud status changes.
148
149 :param event: The providers updated event.
150 """
151 if not self._enabled:
152 return
153
154 # Check if HA Cloud status changed
155 ha_cloud_available, ice_servers = await self._get_ha_cloud_status()
156 new_using_ha_cloud = bool(ha_cloud_available and ice_servers)
157
158 if new_using_ha_cloud != self._using_ha_cloud:
159 self.logger.info("HA Cloud status changed, restarting remote access")
160 await self._schedule_start()
161
162 async def _get_ha_cloud_status(self) -> tuple[bool, list[dict[str, str]] | None]:
163 """Get Home Assistant Cloud status and ICE servers.
164
165 :return: Tuple of (ha_cloud_available, ice_servers).
166 """
167 ha_provider = cast("HomeAssistantProvider | None", self.mass.get_provider("hass"))
168 if not ha_provider:
169 return False, None
170 try:
171 hass_client = ha_provider.hass
172 if not hass_client or not hass_client.connected:
173 return False, None
174
175 result = await hass_client.send_command("cloud/status")
176 logged_in = result.get("logged_in", False)
177 active_subscription = result.get("active_subscription", False)
178 if not (logged_in and active_subscription):
179 return False, None
180 # HA Cloud is available, get ICE servers
181 # The cloud/webrtc/ice_servers command was added in HA 2025.12.0b6
182 if AwesomeVersion(hass_client.version) >= AwesomeVersion("2025.12.0b6"):
183 if ice_servers := await hass_client.send_command("cloud/webrtc/ice_servers"):
184 return True, ice_servers
185 else:
186 self.logger.debug(
187 "HA version %s not supported for optimized WebRTC mode "
188 "(requires 2025.12.0b6 or later)",
189 hass_client.version,
190 )
191 self.logger.debug("HA Cloud available but no ICE servers returned")
192 except Exception as err:
193 self.logger.exception("Error getting HA Cloud status: %s", err)
194 return False, None
195
196 async def get_ice_servers(self) -> list[dict[str, str]]:
197 """Get ICE servers for WebRTC connections.
198
199 Returns HA Cloud TURN servers if available, otherwise returns public STUN servers.
200 This method can be called regardless of whether remote access is enabled.
201
202 :return: List of ICE server configurations.
203 """
204 # Default public STUN servers
205 default_ice_servers: list[dict[str, str]] = [
206 {"urls": "stun:stun.l.google.com:19302"},
207 {"urls": "stun:stun.cloudflare.com:3478"},
208 {"urls": "stun:stun.home-assistant.io:3478"},
209 ]
210
211 # Try to get HA Cloud ICE servers
212 _, ice_servers = await self._get_ha_cloud_status()
213 if ice_servers:
214 return ice_servers
215
216 return default_ice_servers
217
218 @property
219 def is_enabled(self) -> bool:
220 """Return whether WebRTC remote access is enabled."""
221 return self._enabled
222
223 @property
224 def is_running(self) -> bool:
225 """Return whether the gateway is running."""
226 return self.gateway is not None and self.gateway.is_running
227
228 @property
229 def is_connected(self) -> bool:
230 """Return whether the gateway is connected to the signaling server."""
231 return self.gateway is not None and self.gateway.is_connected
232
233 @property
234 def remote_id(self) -> str:
235 """Return the current Remote ID."""
236 return self._remote_id
237
238 @property
239 def certificate(self) -> RTCCertificate:
240 """Return the persistent WebRTC DTLS certificate."""
241 return self._certificate
242
243 def _register_api_commands(self) -> None:
244 """Register API commands for remote access."""
245
246 async def get_remote_access_info() -> RemoteAccessInfo:
247 """Get remote access information."""
248 return RemoteAccessInfo(
249 enabled=self.is_enabled,
250 running=self.is_running,
251 connected=self.is_connected,
252 remote_id=self._remote_id,
253 using_ha_cloud=self._using_ha_cloud,
254 signaling_url=SIGNALING_SERVER_URL,
255 )
256
257 async def configure_remote_access(enabled: bool) -> RemoteAccessInfo:
258 """Configure remote access settings.
259
260 :param enabled: Enable or disable remote access.
261 """
262 self._enabled = enabled
263 self.mass.config.set(f"{CONF_CORE}/{CONF_KEY_MAIN}/{CONF_ENABLED}", enabled)
264 if self._enabled and not self.is_running:
265 await self._start_gateway()
266 elif not self._enabled and self.is_running:
267 await self.stop()
268 return await get_remote_access_info()
269
270 self._on_unload_callbacks.append(
271 self.mass.register_api_command(
272 "remote_access/info", get_remote_access_info, required_role="admin"
273 )
274 )
275 self._on_unload_callbacks.append(
276 self.mass.register_api_command(
277 "remote_access/configure", configure_remote_access, required_role="admin"
278 )
279 )
280