From 5ee70bb503db8434181fc97404e348d0debc304e Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 2 Dec 2025 16:56:02 +0100 Subject: [PATCH] Remote access changes --- .../controllers/webserver/controller.py | 6 + .../webserver/remote_access/__init__.py | 287 +++++++++++++++++ .../webserver}/remote_access/gateway.py | 10 +- .../providers/remote_access/__init__.py | 290 ------------------ .../providers/remote_access/manifest.json | 13 - pyproject.toml | 1 + 6 files changed, 295 insertions(+), 312 deletions(-) create mode 100644 music_assistant/controllers/webserver/remote_access/__init__.py rename music_assistant/{providers => controllers/webserver}/remote_access/gateway.py (98%) delete mode 100644 music_assistant/providers/remote_access/__init__.py delete mode 100644 music_assistant/providers/remote_access/manifest.json diff --git a/music_assistant/controllers/webserver/controller.py b/music_assistant/controllers/webserver/controller.py index e46c00e3..34b562ab 100644 --- a/music_assistant/controllers/webserver/controller.py +++ b/music_assistant/controllers/webserver/controller.py @@ -56,6 +56,7 @@ from .helpers.auth_middleware import ( set_current_user, ) from .helpers.auth_providers import BuiltinLoginProvider +from .remote_access import RemoteAccessManager from .websocket_client import WebsocketClientHandler if TYPE_CHECKING: @@ -91,6 +92,7 @@ class WebserverController(CoreController): ) self.manifest.icon = "web-box" self.auth = AuthenticationManager(self) + self.remote_access = RemoteAccessManager(self) @property def base_url(self) -> str: @@ -372,8 +374,12 @@ class WebserverController(CoreController): # announce to HA supervisor await self._announce_to_homeassistant() + # Setup remote access after webserver is running + await self.remote_access.setup() + async def close(self) -> None: """Cleanup on exit.""" + await self.remote_access.close() for client in set(self.clients): await client.disconnect() await self._server.close() diff --git a/music_assistant/controllers/webserver/remote_access/__init__.py b/music_assistant/controllers/webserver/remote_access/__init__.py new file mode 100644 index 00000000..281bd07d --- /dev/null +++ b/music_assistant/controllers/webserver/remote_access/__init__.py @@ -0,0 +1,287 @@ +""" +Remote Access subcomponent for the Webserver Controller. + +This module manages WebRTC-based remote access to Music Assistant instances. +It connects to a signaling server and handles incoming WebRTC connections, +bridging them to the local WebSocket API. + +Requires an active Home Assistant Cloud subscription due to STUN/TURN/SIGNALING server usage. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast + +from mashumaro import DataClassDictMixin +from music_assistant_models.enums import EventType +from music_assistant_models.errors import UnsupportedFeaturedException + +from music_assistant.constants import CONF_CORE +from music_assistant.controllers.webserver.remote_access.gateway import ( + WebRTCGateway, + generate_remote_id, +) +from music_assistant.helpers.api import api_command + +if TYPE_CHECKING: + from music_assistant_models.event import MassEvent + + from music_assistant.controllers.webserver import WebserverController + from music_assistant.providers.hass import HomeAssistantProvider + +# Signaling server URL +SIGNALING_SERVER_URL = "wss://signaling.music-assistant.io/ws" + +# Storage keys +CONF_KEY_MAIN = "remote_access" +CONF_REMOTE_ID = "remote_id" +CONF_ENABLED = "enabled" + + +@dataclass +class RemoteAccessInfo(DataClassDictMixin): + """Remote Access information dataclass.""" + + enabled: bool + running: bool + connected: bool + remote_id: str + ha_cloud_available: bool + signaling_url: str + + +class RemoteAccessManager: + """Manages WebRTC-based remote access for the webserver.""" + + def __init__(self, webserver: WebserverController) -> None: + """Initialize the remote access manager.""" + self.webserver = webserver + self.mass = webserver.mass + self.logger = webserver.logger.getChild("remote_access") + self.gateway: WebRTCGateway | None = None + self._remote_id: str | None = None + self._enabled: bool = False + self._ha_cloud_available: bool = False + + async def setup(self) -> None: + """Initialize the remote access manager.""" + # Load config from storage + enabled_value = self.mass.config.get(f"{CONF_CORE}/{CONF_KEY_MAIN}/{CONF_ENABLED}", False) + self._enabled = bool(enabled_value) + remote_id_value = self.mass.config.get( + f"{CONF_CORE}/{CONF_KEY_MAIN}/{CONF_REMOTE_ID}", None + ) + if not remote_id_value: + remote_id_value = generate_remote_id() + self.mass.config.set(f"{CONF_CORE}/{CONF_KEY_MAIN}/{CONF_REMOTE_ID}", remote_id_value) + self.logger.debug("Generated new Remote ID: %s", remote_id_value) + + self._remote_id = str(remote_id_value) + self._register_api_commands() + # Subscribe to provider updates to check for Home Assistant Cloud + self._ha_cloud_available = await self._check_ha_cloud_status() + self.mass.subscribe( + self._on_providers_updated, EventType.PROVIDERS_UPDATED, id_filter="hass" + ) + if self._enabled and self._ha_cloud_available: + await self.start() + + async def close(self) -> None: + """Cleanup on exit.""" + await self.stop() + + async def start(self) -> None: + """Start the remote access gateway.""" + if self.is_running: + self.logger.debug("Remote access already running") + return + if not self._ha_cloud_available: + raise UnsupportedFeaturedException( + "Home Assistant Cloud subscription is required for remote access" + ) + if not self._enabled: + # should not happen, but guard anyway + self.logger.debug("Remote access is disabled in configuration") + return + + self.logger.info("Starting remote access with Remote ID: %s", self._remote_id) + + # Determine local WebSocket URL from webserver config + base_url = self.mass.webserver.base_url + local_ws_url = base_url.replace("http", "ws") + if not local_ws_url.endswith("/"): + local_ws_url += "/" + local_ws_url += "ws" + + # Get ICE servers from HA Cloud if available + ice_servers: list[dict[str, str]] | None = None + if await self._check_ha_cloud_status(): + self.logger.info( + "Home Assistant Cloud subscription detected, using HA cloud ICE servers" + ) + ice_servers = await self._get_ha_cloud_ice_servers() + else: + self.logger.info( + "Home Assistant Cloud subscription not detected, using default STUN servers" + ) + + # Initialize and start the WebRTC gateway + self.gateway = WebRTCGateway( + http_session=self.mass.http_session, + signaling_url=SIGNALING_SERVER_URL, + local_ws_url=local_ws_url, + remote_id=self._remote_id, + ice_servers=ice_servers, + ) + + await self.gateway.start() + self._enabled = True + + async def stop(self) -> None: + """Stop the remote access gateway.""" + if self.gateway: + await self.gateway.stop() + self.gateway = None + self.logger.debug("WebRTC Remote Access stopped") + + async def _on_providers_updated(self, event: MassEvent) -> None: + """ + Handle providers updated event. + + :param event: The providers updated event. + """ + last_ha_cloud_available = self._ha_cloud_available + self._ha_cloud_available = await self._check_ha_cloud_status() + if self._ha_cloud_available == last_ha_cloud_available: + return # No change in HA Cloud status + if self.is_running and not self._ha_cloud_available: + self.logger.warning( + "Home Assistant Cloud subscription is no longer active, stopping remote access" + ) + await self.stop() + return + allow_start = self._ha_cloud_available and self._enabled + if allow_start and self.is_running: + return # Already running + if allow_start: + self.mass.create_task(self.start()) + + async def _check_ha_cloud_status(self) -> bool: + """Check if Home Assistant Cloud subscription is active. + + :return: True if HA Cloud is logged in and has active subscription. + """ + # Find the Home Assistant provider + ha_provider = cast("HomeAssistantProvider | None", self.mass.get_provider("hass")) + if not ha_provider: + return False + + try: + # Access the hass client from the provider + hass_client = ha_provider.hass + if not hass_client or not hass_client.connected: + return False + + # Call cloud/status command to check subscription + result = await hass_client.send_command("cloud/status") + + # Check for logged_in and active_subscription + logged_in = result.get("logged_in", False) + active_subscription = result.get("active_subscription", False) + + return bool(logged_in and active_subscription) + + except Exception: + return False + + async def _get_ha_cloud_ice_servers(self) -> list[dict[str, str]] | None: + """Get ICE servers from Home Assistant Cloud. + + :return: List of ICE server configurations or None if unavailable. + """ + # Find the Home Assistant provider + ha_provider = cast("HomeAssistantProvider | None", self.mass.get_provider("hass")) + if not ha_provider: + return None + + try: + hass_client = ha_provider.hass + if not hass_client or not hass_client.connected: + return None + + # Try to get ICE servers from HA Cloud + # This might be available via a cloud API endpoint + # For now, return None and use default STUN servers + # TODO: Research if HA Cloud exposes ICE/TURN server endpoints + self.logger.debug( + "Using default STUN servers (HA Cloud ICE servers not yet implemented)" + ) + return None + + except Exception: + self.logger.exception("Error getting Home Assistant Cloud ICE servers") + return None + + @property + def is_enabled(self) -> bool: + """Return whether WebRTC remote access is enabled.""" + return self._enabled + + @property + def is_running(self) -> bool: + """Return whether the gateway is running.""" + return self.gateway is not None and self.gateway.is_running + + @property + def is_connected(self) -> bool: + """Return whether the gateway is connected to the signaling server.""" + return self.gateway is not None and self.gateway.is_connected + + @property + def remote_id(self) -> str | None: + """Return the current Remote ID.""" + return self._remote_id + + def _register_api_commands(self) -> None: + """Register API commands for remote access.""" + + @api_command("remote_access/info") + def get_remote_access_info() -> RemoteAccessInfo: + """Get remote access information. + + Returns information about the remote access configuration including + whether it's enabled, running status, connected status, and the Remote ID. + """ + return RemoteAccessInfo( + enabled=self.is_enabled, + running=self.is_running, + connected=self.is_connected, + remote_id=self._remote_id or "", + ha_cloud_available=self._ha_cloud_available, + signaling_url=SIGNALING_SERVER_URL, + ) + + @api_command("remote_access/configure", required_role="admin") + async def configure_remote_access( + enabled: bool, + ) -> RemoteAccessInfo: + """ + Configure remote access settings. + + :param enabled: Enable or disable remote access. + + Starts or stops the WebRTC gateway based on the enabled parameter. + Returns the updated remote access info. + """ + # Save configuration + self._enabled = enabled + self.mass.config.set(f"{CONF_CORE}/{CONF_KEY_MAIN}/{CONF_ENABLED}", enabled) + allow_start = self._ha_cloud_available and self._enabled + + # Start or stop the gateway based on enabled flag + if allow_start and not self.is_running: + await self.start() + elif not allow_start and self.is_running: + await self.stop() + return get_remote_access_info() diff --git a/music_assistant/providers/remote_access/gateway.py b/music_assistant/controllers/webserver/remote_access/gateway.py similarity index 98% rename from music_assistant/providers/remote_access/gateway.py rename to music_assistant/controllers/webserver/remote_access/gateway.py index 8f9197ba..80d82b8b 100644 --- a/music_assistant/providers/remote_access/gateway.py +++ b/music_assistant/controllers/webserver/remote_access/gateway.py @@ -14,7 +14,7 @@ import logging import secrets import string from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any +from typing import Any import aiohttp from aiortc import ( @@ -27,9 +27,6 @@ from aiortc import ( from music_assistant.constants import MASS_LOGGER_NAME -if TYPE_CHECKING: - from collections.abc import Callable - LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.remote_access") # Reduce verbose logging from aiortc/aioice @@ -75,7 +72,6 @@ class WebRTCGateway: local_ws_url: str = "ws://localhost:8095/ws", ice_servers: list[dict[str, Any]] | None = None, remote_id: str | None = None, - on_remote_id_ready: Callable[[str], None] | None = None, ) -> None: """Initialize the WebRTC Gateway. @@ -84,13 +80,11 @@ class WebRTCGateway: :param local_ws_url: Local WebSocket URL to bridge to. :param ice_servers: List of ICE server configurations. :param remote_id: Optional Remote ID to use (generated if not provided). - :param on_remote_id_ready: Callback when Remote ID is registered. """ self.http_session = http_session self.signaling_url = signaling_url self.local_ws_url = local_ws_url self.remote_id = remote_id or generate_remote_id() - self.on_remote_id_ready = on_remote_id_ready self.logger = LOGGER self.ice_servers = ice_servers or [ @@ -279,8 +273,6 @@ class WebRTCGateway: pass elif msg_type == "registered": self.logger.info("Registered with signaling server as: %s", message.get("remoteId")) - if self.on_remote_id_ready: - self.on_remote_id_ready(self.remote_id) elif msg_type == "error": self.logger.error( "Signaling server error: %s", diff --git a/music_assistant/providers/remote_access/__init__.py b/music_assistant/providers/remote_access/__init__.py deleted file mode 100644 index 1ec87564..00000000 --- a/music_assistant/providers/remote_access/__init__.py +++ /dev/null @@ -1,290 +0,0 @@ -""" -Remote Access Plugin Provider for Music Assistant. - -This plugin manages WebRTC-based remote access to Music Assistant instances. -It connects to a signaling server and handles incoming WebRTC connections, -bridging them to the local WebSocket API. - -Requires an active Home Assistant Cloud subscription for the best experience. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, cast - -from music_assistant_models.config_entries import ConfigEntry -from music_assistant_models.enums import ConfigEntryType, EventType, ProviderFeature - -from music_assistant.constants import CONF_ENABLED -from music_assistant.helpers.api import api_command -from music_assistant.models.plugin import PluginProvider -from music_assistant.providers.remote_access.gateway import WebRTCGateway, generate_remote_id - -if TYPE_CHECKING: - from music_assistant_models.config_entries import ConfigValueType, ProviderConfig - from music_assistant_models.event import MassEvent - from music_assistant_models.provider import ProviderManifest - - from music_assistant import MusicAssistant - from music_assistant.models import ProviderInstanceType - from music_assistant.providers.hass import HomeAssistantProvider - -# Signaling server URL -SIGNALING_SERVER_URL = "wss://signaling.music-assistant.io/ws" - -# Config keys -CONF_REMOTE_ID = "remote_id" - -SUPPORTED_FEATURES: set[ProviderFeature] = set() - - -async def setup( - mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig -) -> ProviderInstanceType: - """Initialize provider(instance) with given configuration.""" - return RemoteAccessProvider(mass, manifest, config) - - -async def get_config_entries( - mass: MusicAssistant, - instance_id: str | None = None, - action: str | None = None, # noqa: ARG001 - values: dict[str, ConfigValueType] | None = None, # noqa: ARG001 -) -> tuple[ConfigEntry, ...]: - """ - Return Config entries to setup this provider. - - :param mass: MusicAssistant instance. - :param instance_id: id of an existing provider instance (None if new instance setup). - :param action: [optional] action key called from config entries UI. - :param values: the (intermediate) raw values for config entries sent with the action. - """ - entries: list[ConfigEntry] = [ - ConfigEntry( - key=CONF_REMOTE_ID, - type=ConfigEntryType.STRING, - label="Remote ID", - description="Unique identifier for WebRTC remote access. " - "Generated automatically and should not be changed.", - required=False, - hidden=True, - ) - # TODO: Add a message that optimal experience requires Home Assistant Cloud subscription - ] - # Get the remote ID if instance exists - remote_id: str | None = None - if instance_id: - if remote_id_value := mass.config.get_raw_provider_config_value( - instance_id, CONF_REMOTE_ID - ): - remote_id = str(remote_id_value) - entries += [ - ConfigEntry( - key="remote_access_id_intro", - type=ConfigEntryType.LABEL, - label="Remote access is enabled. You can securely connect to your " - "Music Assistant instance from https://app.music-assistant.io or supported " - "(mobile) apps using the Remote ID below.", - hidden=False, - ), - ConfigEntry( - key="remote_access_id_label", - type=ConfigEntryType.LABEL, - label=f"Remote Access ID: {remote_id}", - hidden=False, - ), - ] - - return tuple(entries) - - -async def _check_ha_cloud_status(mass: MusicAssistant) -> bool: - """Check if Home Assistant Cloud subscription is active. - - :param mass: MusicAssistant instance. - :return: True if HA Cloud is logged in and has active subscription. - """ - # Find the Home Assistant provider - ha_provider = cast("HomeAssistantProvider | None", mass.get_provider("hass")) - if not ha_provider: - return False - - try: - # Access the hass client from the provider - hass_client = ha_provider.hass - if not hass_client or not hass_client.connected: - return False - - # Call cloud/status command to check subscription - result = await hass_client.send_command("cloud/status") - - # Check for logged_in and active_subscription - logged_in = result.get("logged_in", False) - active_subscription = result.get("active_subscription", False) - - return bool(logged_in and active_subscription) - - except Exception: - return False - - -class RemoteAccessProvider(PluginProvider): - """Plugin Provider for WebRTC-based remote access.""" - - gateway: WebRTCGateway | None = None - _remote_id: str | None = None - - async def loaded_in_mass(self) -> None: - """Call after the provider has been loaded.""" - remote_id_value = self.config.get_value(CONF_REMOTE_ID) - if not remote_id_value: - # First time setup, generate a new Remote ID - remote_id_value = generate_remote_id() - self._remote_id = remote_id_value - self.logger.debug("Generated new Remote ID: %s", remote_id_value) - await self.mass.config.save_provider_config( - self.domain, - { - CONF_ENABLED: True, - CONF_REMOTE_ID: remote_id_value, - }, - instance_id=self.instance_id, - ) - - else: - self._remote_id = str(remote_id_value) - - # Register API commands - self._register_api_commands() - - # Subscribe to provider updates to check for Home Assistant Cloud - self.mass.subscribe(self._on_providers_updated, EventType.PROVIDERS_UPDATED) - - # Try initial setup (providers might already be loaded) - await self._try_enable_remote_access() - - async def unload(self, is_removed: bool = False) -> None: - """Handle unload/close of the provider. - - :param is_removed: True when the provider is removed from the configuration. - """ - if self.gateway: - await self.gateway.stop() - self.gateway = None - self.logger.debug("WebRTC Remote Access stopped") - - def _on_remote_id_ready(self, remote_id: str) -> None: - """Handle Remote ID registration with signaling server. - - :param remote_id: The registered Remote ID. - """ - self.logger.debug("Remote ID registered with signaling server: %s", remote_id) - self._remote_id = remote_id - - def _on_providers_updated(self, event: MassEvent) -> None: - """Handle providers updated event. - - :param event: The providers updated event. - """ - if self.gateway is not None: - # Already set up, no need to check again - return - # Try to enable remote access when providers are updated - self.mass.create_task(self._try_enable_remote_access()) - - async def _try_enable_remote_access(self) -> None: - """Try to enable remote access if Home Assistant Cloud is available.""" - if self.gateway is not None: - # Already set up - return - - # Determine local WebSocket URL from webserver config - base_url = self.mass.webserver.base_url - local_ws_url = base_url.replace("http", "ws") - if not local_ws_url.endswith("/"): - local_ws_url += "/" - local_ws_url += "ws" - - # Get ICE servers from HA Cloud if available - ice_servers: list[dict[str, str]] | None = None - if await _check_ha_cloud_status(self.mass): - self.logger.info( - "Home Assistant Cloud subscription detected, using HA cloud ICE servers" - ) - ice_servers = await self._get_ha_cloud_ice_servers() - else: - self.logger.info( - "Home Assistant Cloud subscription not detected, using default STUN servers" - ) - - # Initialize and start the WebRTC gateway - self.gateway = WebRTCGateway( - http_session=self.mass.http_session, - signaling_url=SIGNALING_SERVER_URL, - local_ws_url=local_ws_url, - remote_id=self._remote_id, - on_remote_id_ready=self._on_remote_id_ready, - ice_servers=ice_servers, - ) - - await self.gateway.start() - self.logger.info("WebRTC Remote Access enabled - Remote ID: %s", self._remote_id) - - async def _get_ha_cloud_ice_servers(self) -> list[dict[str, str]] | None: - """Get ICE servers from Home Assistant Cloud. - - :return: List of ICE server configurations or None if unavailable. - """ - # Find the Home Assistant provider - ha_provider = cast("HomeAssistantProvider | None", self.mass.get_provider("hass")) - if not ha_provider: - return None - try: - hass_client = ha_provider.hass - if not hass_client or not hass_client.connected: - return None - - # Try to get ICE servers from HA Cloud - # This might be available via a cloud API endpoint - # For now, return None and use default STUN servers - # TODO: Research if HA Cloud exposes ICE/TURN server endpoints - self.logger.debug( - "Using default STUN servers (HA Cloud ICE servers not yet implemented)" - ) - return None - - except Exception: - self.logger.exception("Error getting Home Assistant Cloud ICE servers") - return None - - @property - def is_enabled(self) -> bool: - """Return whether WebRTC remote access is enabled.""" - return self.gateway is not None and self.gateway.is_running - - @property - def is_connected(self) -> bool: - """Return whether the gateway is connected to the signaling server.""" - return self.gateway is not None and self.gateway.is_connected - - @property - def remote_id(self) -> str | None: - """Return the current Remote ID.""" - return self._remote_id - - def _register_api_commands(self) -> None: - """Register API commands for remote access.""" - - @api_command("remote_access/info") - def get_remote_access_info() -> dict[str, str | bool]: - """Get remote access information. - - Returns information about the remote access configuration including - whether it's enabled, connected status, and the Remote ID for connecting. - """ - return { - "enabled": self.is_enabled, - "connected": self.is_connected, - "remote_id": self._remote_id or "", - "signaling_url": SIGNALING_SERVER_URL, - } diff --git a/music_assistant/providers/remote_access/manifest.json b/music_assistant/providers/remote_access/manifest.json deleted file mode 100644 index a6600c87..00000000 --- a/music_assistant/providers/remote_access/manifest.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "type": "plugin", - "domain": "remote_access", - "stage": "alpha", - "name": "Remote Access", - "description": "WebRTC-based encrypted, secure remote access for connecting to Music Assistant from outside your local network (requires Home Assistant Cloud subscription for the best experience).", - "codeowners": ["@music-assistant"], - "icon": "cloud-lock", - "multi_instance": false, - "builtin": true, - "allow_disable": true, - "requirements": ["aiortc>=1.6.0"] -} diff --git a/pyproject.toml b/pyproject.toml index c26830cd..33c2c926 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "librosa==0.11.0", "gql[all]==4.0.0", "aiovban>=0.6.3", + "aiortc>=1.6.0", ] description = "Music Assistant" license = {text = "Apache-2.0"} -- 2.34.1