From b40ddfa4c518c6353cfe37c6ac301e56e86ae836 Mon Sep 17 00:00:00 2001 From: Michael Carroll Date: Wed, 3 Dec 2025 18:53:03 -0500 Subject: [PATCH 1/3] Support authentication --- custom_components/mass_queue/__init__.py | 29 +- custom_components/mass_queue/config_flow.py | 359 +++++++++++++++++--- custom_components/mass_queue/const.py | 5 + custom_components/mass_queue/manifest.json | 2 +- custom_components/mass_queue/strings.json | 27 +- 5 files changed, 360 insertions(+), 62 deletions(-) diff --git a/custom_components/mass_queue/__init__.py b/custom_components/mass_queue/__init__.py index 6650c2e..aa898ad 100644 --- a/custom_components/mass_queue/__init__.py +++ b/custom_components/mass_queue/__init__.py @@ -9,7 +9,7 @@ from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -19,15 +19,25 @@ async_delete_issue, ) from music_assistant_client import MusicAssistantClient -from music_assistant_client.exceptions import CannotConnect, InvalidServerVersion -from music_assistant_models.errors import ActionUnavailable, MusicAssistantError +from music_assistant_client.exceptions import ( + CannotConnect, + InvalidServerVersion, + MusicAssistantClientException, +) +from music_assistant_models.errors import ( + ActionUnavailable, + AuthenticationFailed, + AuthenticationRequired, + InvalidToken, + MusicAssistantError, +) from .actions import ( MassQueueActions, get_music_assistant_client, setup_controller_and_actions, ) -from .const import DOMAIN, LOGGER +from .const import CONF_TOKEN, DOMAIN, LOGGER from .services import register_actions from .websocket_commands import ( api_download_and_encode_image, @@ -63,14 +73,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: return True -async def async_setup_entry( +async def async_setup_entry( # noqa: PLR0915 hass: HomeAssistant, entry: MusicAssistantConfigEntry, ) -> bool: """Set up Music Assistant from a config entry.""" http_session = async_get_clientsession(hass, verify_ssl=False) mass_url = entry.data[CONF_URL] - mass = MusicAssistantClient(mass_url, http_session) + token = entry.data.get(CONF_TOKEN) + mass = MusicAssistantClient(mass_url, http_session, token=token) try: async with asyncio.timeout(CONNECT_TIMEOUT): @@ -91,8 +102,12 @@ async def async_setup_entry( ) exc = f"Invalid server version: {err}" raise ConfigEntryNotReady(exc) from err - except MusicAssistantError as err: + except (AuthenticationRequired, AuthenticationFailed, InvalidToken) as err: + exc = f"Authentication failed for {mass_url}: {err}" + raise ConfigEntryAuthFailed(exc) from err + except MusicAssistantClientException as err: LOGGER.exception("Failed to connect to music assistant server", exc_info=err) + except MusicAssistantError as err: exc = f"Unknown error connecting to the Music Assistant server {mass_url}" raise ConfigEntryNotReady( exc, diff --git a/custom_components/mass_queue/config_flow.py b/custom_components/mass_queue/config_flow.py index 4e76043..5142ec2 100644 --- a/custom_components/mass_queue/config_flow.py +++ b/custom_components/mass_queue/config_flow.py @@ -3,11 +3,13 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any +from urllib.parse import urlencode import voluptuous as vol from homeassistant.config_entries import ( - SOURCE_IGNORE, + SOURCE_REAUTH, ConfigEntry, + ConfigEntryState, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -15,29 +17,56 @@ from homeassistant.const import CONF_URL from homeassistant.core import callback from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.config_entry_oauth2_flow import ( + _encode_jwt, + async_get_redirect_uri, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from music_assistant_client import MusicAssistantClient +from music_assistant_client.auth_helpers import create_long_lived_token, get_server_info from music_assistant_client.exceptions import ( CannotConnect, InvalidServerVersion, MusicAssistantClientException, ) from music_assistant_models.api import ServerInfoMessage +from music_assistant_models.errors import AuthenticationFailed, InvalidToken from .const import ( + AUTH_SCHEMA_VERSION, CONF_DOWNLOAD_LOCAL, + CONF_TOKEN, DOMAIN, + HASSIO_DISCOVERY_SCHEMA_VERSION, LOGGER, ) if TYPE_CHECKING: + from collections.abc import Mapping + from homeassistant.core import HomeAssistant + from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo - DEFAULT_URL = "http://mass.local:8095" DEFAULT_TITLE = "Music Assistant Queue Items" DEFAULT_DOWNLOAD_LOCAL = False +STEP_AUTH_TOKEN_SCHEMA = vol.Schema({vol.Required(CONF_TOKEN): str}) + + +def _parse_zeroconf_server_info(properties: dict[str, str]) -> ServerInfoMessage: + """Parse zeroconf properties to ServerInfoMessage.""" + return ServerInfoMessage( + server_id=properties["server_id"], + server_version=properties["server_version"], + schema_version=int(properties["schema_version"]), + min_supported_schema_version=int(properties["min_supported_schema_version"]), + base_url=properties["base_url"], + homeassistant_addon=properties["homeassistant_addon"].lower() == "true", + onboard_done=properties["onboard_done"].lower() == "true", + ) + def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema: """Return a schema for the manual step.""" @@ -49,15 +78,30 @@ def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema: ) -async def get_server_info(hass: HomeAssistant, url: str) -> ServerInfoMessage: +async def _get_server_info(hass: HomeAssistant, url: str) -> ServerInfoMessage: """Validate the user input allows us to connect.""" + """Get MA server info for the given URL.""" + session = aiohttp_client.async_get_clientsession(hass) + return await get_server_info(server_url=url, aiohttp_session=session) + + +async def _test_connection(hass: HomeAssistant, url: str, token: str): + """Test connection to MA server with given URL and token.""" + session = aiohttp_client.async_get_clientsession(hass) async with MusicAssistantClient( url, aiohttp_client.async_get_clientsession(hass), + server_url=url, + aiohttp_session=session, + token=token, ) as client: if TYPE_CHECKING: assert client.server_info is not None return client.server_info + # Just executing any command to test the connection. + # If auth is required and the token is invalid, this will raise. + await client.send_command("info") + return None class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN): @@ -67,6 +111,8 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Set up flow instance.""" + self.url: str | None = None + self.token: str | None = None self.server_info: ServerInfoMessage | None = None async def async_step_user( @@ -76,19 +122,9 @@ async def async_step_user( """Handle a manual configuration.""" errors: dict[str, str] = {} if user_input is not None: + self.url = user_input[CONF_URL] try: - self.server_info = await get_server_info( - self.hass, - user_input[CONF_URL], - ) - await self.async_set_unique_id( - self.server_info.server_id, - raise_on_progress=False, - ) - self._abort_if_unique_id_configured( - updates={CONF_URL: user_input[CONF_URL]}, - reload_on_update=True, - ) + server_info = await _get_server_info(self.hass, self.url) except CannotConnect: errors["base"] = "cannot_connect" except InvalidServerVersion: @@ -97,6 +133,14 @@ async def async_step_user( LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: + self.server_info = server_info + await self.async_set_unique_id( + server_info.server_id, + raise_on_progress=False, + ) + self._abort_if_unique_id_configured(updates={CONF_URL: self.url}) + if server_info.schema_version >= AUTH_SCHEMA_VERSION: + return await self.async_step_auth() return self.async_create_entry( title=DEFAULT_TITLE, data={ @@ -113,6 +157,77 @@ async def async_step_user( return self.async_show_form(step_id="user", data_schema=get_manual_schema({})) + async def async_step_hassio( + self, + discovery_info: HassioServiceInfo, + ) -> ConfigFlowResult: + """Handle Home Assistant add-on discovery. + + This flow is triggered by the Music Assistant add-on. + """ + # Build URL from add-on discovery info + # The add-on exposes the API on port 8095, but also hosts an internal-only + # webserver (default at port 8094) for the Home Assistant integration to connect to. + # The info where the internal API is exposed is passed via discovery_info + host = discovery_info.config["host"] + port = discovery_info.config["port"] + self.url = f"http://{host}:{port}" + try: + server_info = await _get_server_info(self.hass, self.url) + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except InvalidServerVersion: + return self.async_abort(reason="invalid_server_version") + except MusicAssistantClientException: + LOGGER.exception("Unexpected exception during add-on discovery") + return self.async_abort(reason="unknown") + + if not server_info.onboard_done: + return self.async_abort(reason="server_not_ready") + + # We trust the token from hassio discovery and validate it during setup + self.token = discovery_info.config["auth_token"] + + self.server_info = server_info + + # Check if there's an existing entry + if entry := await self.async_set_unique_id(server_info.server_id): + # Update the entry with new URL and token + if self.hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_URL: self.url, CONF_TOKEN: self.token}, + ) and entry.state in ( + ConfigEntryState.LOADED, + ConfigEntryState.SETUP_ERROR, + ConfigEntryState.SETUP_RETRY, + ): + self.hass.config_entries.async_schedule_reload(entry.entry_id) + + # Abort since entry already exists + return self.async_abort(reason="already_configured") + + return await self.async_step_hassio_confirm() + + async def async_step_hassio_confirm( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Confirm the add-on discovery.""" + if TYPE_CHECKING: + assert self.url is not None + + if user_input is not None: + data = {CONF_URL: self.url} + if self.token: + data[CONF_TOKEN] = self.token + return self.async_create_entry( + title=DEFAULT_TITLE, + data=data, + ) + + self._set_confirm_only() + return self.async_show_form(step_id="hassio_confirm") + async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo, @@ -123,43 +238,27 @@ async def async_step_zeroconf( host is already configured and delegate to the import step if not. """ # abort if discovery info is not what we expect - if "server_id" not in discovery_info.properties: - return self.async_abort(reason="missing_server_id") - - self.server_info = ServerInfoMessage.from_dict(discovery_info.properties) - await self.async_set_unique_id(self.server_info.server_id) + try: + server_info = ServerInfoMessage.from_dict(discovery_info.properties) + except LookupError: + server_info = _parse_zeroconf_server_info(discovery_info.properties) + except (KeyError, ValueError): + return self.async_abort(reason="invalid_discovery_info") + if server_info.schema_version >= HASSIO_DISCOVERY_SCHEMA_VERSION: + if server_info.homeassistant_addon: + LOGGER.debug("Ignoring add-on server in zeroconf discovery") + return self.async_abort(reason="already_discovered_addon") + if not server_info.onboard_done: + LOGGER.debug("Ignoring server that hasn't completed onboarding.") + return self.async_abort(reason="server_on_ready") + self.url = server_info.base_url + self.server_info = server_info - # Check if we already have a config entry for this server_id - existing_entry = self.hass.config_entries.async_entry_for_domain_unique_id( - DOMAIN, - self.server_info.server_id, - ) - if existing_entry: - # If the entry was ignored or disabled, don't make any changes - if existing_entry.source == SOURCE_IGNORE or existing_entry.disabled_by: - return self.async_abort(reason="already_configured") - # Test connectivity to the current URL first - current_url = existing_entry.data[CONF_URL] - try: - await get_server_info(self.hass, current_url) - # Current URL is working, no need to update - return self.async_abort(reason="already_configured") - except CannotConnect: - # Current URL is not working, update to the discovered URL - # and continue to discovery confirm - self.hass.config_entries.async_update_entry( - existing_entry, - data={**existing_entry.data, CONF_URL: self.server_info.base_url}, - ) - # Schedule reload since URL changed - self.hass.config_entries.async_schedule_reload(existing_entry.entry_id) - else: - # No existing entry, proceed with normal flow - self._abort_if_unique_id_configured() + await self.async_set_unique_id(server_info.server_id) + self._abort_if_unique_id_configured(updates={CONF_URL: self.url}) - # Test connectivity to the discovered URL try: - await get_server_info(self.hass, self.server_info.base_url) + await _get_server_info(self.hass, self.url) except CannotConnect: return self.async_abort(reason="cannot_connect") return await self.async_step_discovery_confirm() @@ -170,19 +269,177 @@ async def async_step_discovery_confirm( ) -> ConfigFlowResult: """Handle user-confirmation of discovered server.""" if TYPE_CHECKING: + assert self.url is not None assert self.server_info is not None if user_input is not None: + if self.server_info.schema_version >= AUTH_SCHEMA_VERSION: + return await self.async_step_auth() return self.async_create_entry( title=DEFAULT_TITLE, data={ - CONF_URL: self.server_info.base_url, + CONF_URL: self.url, }, options={CONF_DOWNLOAD_LOCAL: DEFAULT_DOWNLOAD_LOCAL}, ) self._set_confirm_only() return self.async_show_form( step_id="discovery_confirm", - description_placeholders={"url": self.server_info.base_url}, + description_placeholders={"url": self.url}, + ) + + async def async_step_auth( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle authentication via redirect to MA login.""" + if TYPE_CHECKING: + assert self.url is not None + + # Check if we're returning from the external auth step with a token + if user_input is not None: + if "error" in user_input: + return self.async_abort(reason="auth_error") + # OAuth2 callback sends token as "code" parameter + if "code" in user_input: + self.token = user_input["code"] + return self.async_external_step_done(next_step_id="finish_auth") + + # Check if we can use external auth (redirect flow) + try: + redirect_uri = async_get_redirect_uri(self.hass) + except RuntimeError: + # No current request context or missing required headers + return await self.async_step_auth_manual() + + # Use OAuth2 callback URL with JWT-encoded state + state = _encode_jwt( + self.hass, + {"flow_id": self.flow_id, "redirect_uri": redirect_uri}, + ) + # Music Assistant server will redirect to: {redirect_uri}?state={state}&code={token} + params = urlencode( + { + "return_url": f"{redirect_uri}?state={state}", + "device_name": "Home Assistant", + }, + ) + login_url = f"{self.url}/login?{params}" + return self.async_external_step(step_id="auth", url=login_url) + + async def async_step_finish_auth( + self, + user_input: dict[str, Any] | None = None, # noqa: ARG002 + ) -> ConfigFlowResult: + """Finish authentication after receiving token.""" + if TYPE_CHECKING: + assert self.url is not None + assert self.token is not None + + # Exchange session token for long-lived token + # The login flow gives us a session token (short expiration) + session = aiohttp_client.async_get_clientsession(self.hass) + + try: + LOGGER.debug("Creating long-lived token") + long_lived_token = await create_long_lived_token( + self.url, + self.token, + "Home Assistant", + aiohttp_session=session, + ) + LOGGER.debug("Successfully created long-lived token") + except (TimeoutError, CannotConnect): + return self.async_abort(reason="cannot_connect") + except (AuthenticationFailed, InvalidToken) as err: + LOGGER.error("Authentication failed: %s", err) + return self.async_abort(reason="auth_failed") + except InvalidServerVersion as err: + LOGGER.error("Invalid server version: %s", err) + return self.async_abort(reason="invalid_server_version") + except MusicAssistantClientException: + LOGGER.exception("Unexpected exception during connection test") + return self.async_abort(reason="unknown") + + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() + return self.async_update_reload_and_abort( + reauth_entry, + data={CONF_URL: self.url, CONF_TOKEN: long_lived_token}, + ) + + # Connection has been validated by creating a long-lived token + return self.async_create_entry( + title=DEFAULT_TITLE, + data={CONF_URL: self.url, CONF_TOKEN: long_lived_token}, + ) + + async def async_step_auth_manual( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle manual token entry as fallback.""" + if TYPE_CHECKING: + assert self.url is not None + + errors: dict[str, str] = {} + + if user_input is not None: + self.token = user_input[CONF_TOKEN] + try: + # Test the connection with the provided token + await _test_connection(self.hass, self.url, self.token) + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except InvalidServerVersion: + return self.async_abort(reason="invalid_server_version") + except (AuthenticationFailed, InvalidToken): + errors["base"] = "auth_failed" + except MusicAssistantClientException: + LOGGER.exception("Unexpected exception during manual auth") + return self.async_abort(reason="unknown") + else: + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data={CONF_URL: self.url, CONF_TOKEN: self.token}, + ) + + return self.async_create_entry( + title=DEFAULT_TITLE, + data={CONF_URL: self.url, CONF_TOKEN: self.token}, + ) + + return self.async_show_form( + step_id="auth_manual", + data_schema=vol.Schema({vol.Required(CONF_TOKEN): str}), + description_placeholders={"url": self.url}, + errors=errors, + ) + + async def async_step_reauth( + self, + entry_data: Mapping[str, Any], + ) -> ConfigFlowResult: + """Handle reauth when token is invalid or expired.""" + self.url = entry_data[CONF_URL] + # Show confirmation before redirecting to auth + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + if TYPE_CHECKING: + assert self.url is not None + + if user_input is not None: + # Redirect to auth flow + return await self.async_step_auth() + + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={"url": self.url}, ) @staticmethod diff --git a/custom_components/mass_queue/const.py b/custom_components/mass_queue/const.py index 4a9051a..34eedd4 100644 --- a/custom_components/mass_queue/const.py +++ b/custom_components/mass_queue/const.py @@ -2,6 +2,11 @@ import logging +AUTH_SCHEMA_VERSION = 28 +HASSIO_DISCOVERY_SCHEMA_VERSION = 28 + +CONF_TOKEN = "token" # noqa: S105 + DOMAIN = "mass_queue" DEFAULT_NAME = "Music Assistant Queue Items" SERVICE_CLEAR_QUEUE_FROM_HERE = "clear_queue_from_here" diff --git a/custom_components/mass_queue/manifest.json b/custom_components/mass_queue/manifest.json index 5d7d828..47d6c39 100644 --- a/custom_components/mass_queue/manifest.json +++ b/custom_components/mass_queue/manifest.json @@ -3,7 +3,7 @@ "name": "Music Assistant Queue Actions", "codeowners": ["@droans"], "config_flow": true, - "dependencies": ["music_assistant"], + "dependencies": ["auth", "music_assistant"], "documentation": "https://www.github.com/droans/mass_queue", "homekit": {}, "integration_type": "service", diff --git a/custom_components/mass_queue/strings.json b/custom_components/mass_queue/strings.json index 593ae04..6a86a85 100644 --- a/custom_components/mass_queue/strings.json +++ b/custom_components/mass_queue/strings.json @@ -1,6 +1,15 @@ { "config": { "step": { + "auth_manual": { + "data": { + "token": "Long-lived access token" + }, + "data_description": { + "token": "Create a long-lived access token in your Music Assistant server settings and paste it here" + }, + "title": "Enter long-lived access token" + }, "init": { "data": { "url": "URL of the Music Assistant server" @@ -16,19 +25,31 @@ "discovery_confirm": { "description": "Do you want to add the Music Assistant server `{url}` to Home Assistant?", "title": "Discovered Music Assistant server" + }, + "hassio_confirm": { + "description": "Do you want to add the Music Assistant server to Home Assistant?", + "title": "Discovered Music Assistant add-on" + }, + "reauth_confirm": { + "description": "The authentication token for Music Assistant server `{url}` is no longer valid. Please re-authenticate to continue using the integration.", + "title": "Reauthentication required" } }, "error": { + "auth_failed": "[%key:component::music_assistant::config::abort::auth_failed%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_server_version": "The Music Assistant server is not the correct version", + "invalid_server_version": "[%key:component::music_assistant::config::abort::invalid_server_version%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "reconfiguration_successful": "Successfully reconfigured the Music Assistant integration.", + "auth_error": "Authentication error, please try again", + "auth_failed": "Authentication failed, please try again", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + "invalid_server_version": "The Music Assistant server is not the correct version", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]" } }, "options": { From 3480058b1ba35acb875948f95f32249df89cc4f0 Mon Sep 17 00:00:00 2001 From: Michael Carroll Date: Wed, 3 Dec 2025 20:05:08 -0500 Subject: [PATCH 2/3] Update manifest --- custom_components/mass_queue/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/mass_queue/manifest.json b/custom_components/mass_queue/manifest.json index 47d6c39..cac01e0 100644 --- a/custom_components/mass_queue/manifest.json +++ b/custom_components/mass_queue/manifest.json @@ -11,6 +11,6 @@ "issue_tracker": "https://github.com/droans/mass_queue/issues", "requirements": ["music-assistant-client"], "ssdp": [], - "version": "0.8.1", + "version": "0.9.0", "zeroconf": ["_mass._tcp.local."] } From 512049d4c7d28e09a79973da601e5f17fd43bdf4 Mon Sep 17 00:00:00 2001 From: Michael Carroll Date: Thu, 4 Dec 2025 11:23:52 -0500 Subject: [PATCH 3/3] Fix Reauth --- custom_components/mass_queue/__init__.py | 5 +- custom_components/mass_queue/config_flow.py | 58 +++++++++++---------- 2 files changed, 35 insertions(+), 28 deletions(-) diff --git a/custom_components/mass_queue/__init__.py b/custom_components/mass_queue/__init__.py index aa898ad..fd929ec 100644 --- a/custom_components/mass_queue/__init__.py +++ b/custom_components/mass_queue/__init__.py @@ -80,6 +80,7 @@ async def async_setup_entry( # noqa: PLR0915 """Set up Music Assistant from a config entry.""" http_session = async_get_clientsession(hass, verify_ssl=False) mass_url = entry.data[CONF_URL] + # Get token from config entry (for schema >= AUTH_SCHEMA_VERSION) token = entry.data.get(CONF_TOKEN) mass = MusicAssistantClient(mass_url, http_session, token=token) @@ -106,8 +107,10 @@ async def async_setup_entry( # noqa: PLR0915 exc = f"Authentication failed for {mass_url}: {err}" raise ConfigEntryAuthFailed(exc) from err except MusicAssistantClientException as err: - LOGGER.exception("Failed to connect to music assistant server", exc_info=err) + exc = f"Failed to connect to music assistant server {mass_url}: {err}" + raise ConfigEntryNotReady(exc) from err except MusicAssistantError as err: + LOGGER.exception("Failed to connect to music assistant server", exc_info=err) exc = f"Unknown error connecting to the Music Assistant server {mass_url}" raise ConfigEntryNotReady( exc, diff --git a/custom_components/mass_queue/config_flow.py b/custom_components/mass_queue/config_flow.py index 5142ec2..e38d588 100644 --- a/custom_components/mass_queue/config_flow.py +++ b/custom_components/mass_queue/config_flow.py @@ -21,7 +21,6 @@ _encode_jwt, async_get_redirect_uri, ) -from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from music_assistant_client import MusicAssistantClient from music_assistant_client.auth_helpers import create_long_lived_token, get_server_info from music_assistant_client.exceptions import ( @@ -80,28 +79,21 @@ def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema: async def _get_server_info(hass: HomeAssistant, url: str) -> ServerInfoMessage: """Validate the user input allows us to connect.""" - """Get MA server info for the given URL.""" session = aiohttp_client.async_get_clientsession(hass) return await get_server_info(server_url=url, aiohttp_session=session) -async def _test_connection(hass: HomeAssistant, url: str, token: str): +async def _test_connection(hass: HomeAssistant, url: str, token: str) -> None: """Test connection to MA server with given URL and token.""" session = aiohttp_client.async_get_clientsession(hass) async with MusicAssistantClient( - url, - aiohttp_client.async_get_clientsession(hass), server_url=url, aiohttp_session=session, token=token, ) as client: - if TYPE_CHECKING: - assert client.server_info is not None - return client.server_info # Just executing any command to test the connection. # If auth is required and the token is invalid, this will raise. await client.send_command("info") - return None class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN): @@ -144,18 +136,23 @@ async def async_step_user( return self.async_create_entry( title=DEFAULT_TITLE, data={ - CONF_URL: user_input[CONF_URL], + CONF_URL: self.url, }, options={CONF_DOWNLOAD_LOCAL: DEFAULT_DOWNLOAD_LOCAL}, ) - return self.async_show_form( - step_id="user", - data_schema=get_manual_schema(user_input), - errors=errors, - ) + suggested_values = user_input + if suggested_values is None: + suggested_values = {CONF_URL: DEFAULT_URL} - return self.async_show_form(step_id="user", data_schema=get_manual_schema({})) + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + get_manual_schema(user_input), + suggested_values, + ), + errors=errors, + ) async def async_step_hassio( self, @@ -232,25 +229,25 @@ async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo, ) -> ConfigFlowResult: - """Handle a discovered Mass server. - - This flow is triggered by the Zeroconf component. It will check if the - host is already configured and delegate to the import step if not. - """ - # abort if discovery info is not what we expect + """Handle a zeroconf discovery for a Music Assistant server.""" try: - server_info = ServerInfoMessage.from_dict(discovery_info.properties) - except LookupError: + # Parse zeroconf properties (strings) to ServerInfoMessage server_info = _parse_zeroconf_server_info(discovery_info.properties) - except (KeyError, ValueError): + except (LookupError, KeyError, ValueError): return self.async_abort(reason="invalid_discovery_info") + if server_info.schema_version >= HASSIO_DISCOVERY_SCHEMA_VERSION: + # Ignore servers running as Home Assistant add-on + # (they should be discovered through hassio discovery instead) if server_info.homeassistant_addon: LOGGER.debug("Ignoring add-on server in zeroconf discovery") return self.async_abort(reason="already_discovered_addon") + + # Ignore servers that have not completed onboarding yet if not server_info.onboard_done: - LOGGER.debug("Ignoring server that hasn't completed onboarding.") - return self.async_abort(reason="server_on_ready") + LOGGER.debug("Ignoring server that hasn't completed onboarding") + return self.async_abort(reason="server_not_ready") + self.url = server_info.base_url self.server_info = server_info @@ -261,6 +258,7 @@ async def async_step_zeroconf( await _get_server_info(self.hass, self.url) except CannotConnect: return self.async_abort(reason="cannot_connect") + return await self.async_step_discovery_confirm() async def async_step_discovery_confirm( @@ -271,9 +269,14 @@ async def async_step_discovery_confirm( if TYPE_CHECKING: assert self.url is not None assert self.server_info is not None + if user_input is not None: + # Check if authentication is required for this server if self.server_info.schema_version >= AUTH_SCHEMA_VERSION: + # Redirect to browser-based authentication return await self.async_step_auth() + + # Old server, no auth needed return self.async_create_entry( title=DEFAULT_TITLE, data={ @@ -281,6 +284,7 @@ async def async_step_discovery_confirm( }, options={CONF_DOWNLOAD_LOCAL: DEFAULT_DOWNLOAD_LOCAL}, ) + self._set_confirm_only() return self.async_show_form( step_id="discovery_confirm",