/
/
/
1"""Setup flow for the Home Assistant provider."""
2
3from __future__ import annotations
4
5from typing import TYPE_CHECKING
6
7import shortuuid
8from hass_client.utils import base_url, get_auth_url, get_long_lived_token, get_token
9from music_assistant_models.config_entries import ConfigEntry
10from music_assistant_models.enums import ConfigEntryType
11
12from music_assistant.constants import MASS_LOGO_ONLINE
13from music_assistant.models.setup_flow import AbortFlow, SetupFlowError
14
15from . import CONF_AUTH_TOKEN, CONF_URL, CONF_VERIFY_SSL
16
17if TYPE_CHECKING:
18 from music_assistant.models.setup_flow import SetupSession
19
20
21async def run_setup(session: SetupSession) -> None:
22 """
23 Run the Home Assistant setup flow.
24
25 Collects the instance URL and a long-lived access token, either pasted in manually
26 or obtained through Home Assistant's own OAuth flow, using the flow's local callback
27 URL directly as the redirect URI.
28
29 :param session: The setup session driving the flow.
30 """
31 if session.mass.running_as_hass_addon:
32 # add-on installs are auto-configured against the supervisor's internal api
33 raise AbortFlow("nothing_to_configure")
34
35 prefill_url = session.context.setup_data.get(CONF_URL)
36 prefill_ssl = session.context.setup_data.get(CONF_VERIFY_SSL, True)
37 errors: dict[str, str] | None = None
38 while True:
39 values = await session.form(
40 [
41 ConfigEntry(
42 key=CONF_URL, type=ConfigEntryType.STRING, required=True, value=prefill_url
43 ),
44 ConfigEntry(
45 key=CONF_VERIFY_SSL,
46 type=ConfigEntryType.BOOLEAN,
47 default_value=True,
48 value=prefill_ssl,
49 ),
50 # lets a user paste a long-lived token here to skip OAuth entirely
51 ConfigEntry(
52 key=CONF_AUTH_TOKEN,
53 type=ConfigEntryType.SECURE_STRING,
54 required=False,
55 advanced=True,
56 ),
57 ],
58 step_id="user",
59 errors=errors,
60 last_step=False,
61 )
62 hass_url = str(values[CONF_URL]).strip()
63 verify_ssl = bool(values[CONF_VERIFY_SSL])
64 # re-prefill with what was just submitted, so a failed attempt below doesn't
65 # revert the form back to the original (reconfigure) values
66 prefill_url, prefill_ssl = hass_url, verify_ssl
67 manual_token = str(values.get(CONF_AUTH_TOKEN) or "").strip()
68
69 try:
70 token = manual_token if manual_token else await _authenticate(session, hass_url)
71 except SetupFlowError as err:
72 errors = {"base": err.translation_key or str(err)}
73 continue
74
75 try:
76 await session.finish(
77 {CONF_URL: hass_url, CONF_AUTH_TOKEN: token, CONF_VERIFY_SSL: verify_ssl}
78 )
79 return
80 except SetupFlowError as err:
81 errors = {"base": err.translation_key or str(err)}
82
83
84async def _authenticate(session: SetupSession, hass_url: str) -> str:
85 """
86 Run Home Assistant's OAuth flow via the setup session and return a long-lived token.
87
88 :param session: The setup session driving the flow.
89 :param hass_url: The Home Assistant instance URL entered by the user.
90 """
91 callback = session.callback_url
92 # Home Assistant's indieauth-style auth accepts any redirect URI on the same origin
93 # as the client id, so the local callback is used directly - unlike Spotify/Google/
94 # Microsoft, no fixed hosted-bounce redirect needs to be pre-registered
95 client_id = base_url(callback)
96 auth_url = get_auth_url(hass_url, callback, client_id=client_id, state=session.flow_id)
97 params = await session.external(auth_url, step_id="auth")
98 if params.get("state") != session.flow_id:
99 raise SetupFlowError("Authentication failed: state mismatch")
100 if not params.get("code"):
101 raise SetupFlowError("Authentication failed: no authorization code returned")
102 try:
103 token_details = await get_token(hass_url, params["code"], client_id=client_id)
104 return str(
105 await get_long_lived_token(
106 hass_url,
107 token_details["access_token"],
108 client_name=f"Music Assistant {shortuuid.random(6)}",
109 client_icon=MASS_LOGO_ONLINE,
110 lifespan=365 * 2,
111 )
112 )
113 except Exception as err:
114 # get_token/get_long_lived_token surface different error shapes (RuntimeError,
115 # BaseHassClientError, plain connection errors); collapse them into one retryable slug
116 raise SetupFlowError("auth_failed") from err
117