Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Hyperion config flow."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Mapping
7 from contextlib import suppress
8 import logging
9 from typing import Any
10 from urllib.parse import urlparse
11 
12 from hyperion import client, const
13 import voluptuous as vol
14 
15 from homeassistant.components import ssdp
16 from homeassistant.config_entries import (
17  SOURCE_REAUTH,
18  ConfigEntry,
19  ConfigFlow,
20  ConfigFlowResult,
21  OptionsFlow,
22 )
23 from homeassistant.const import (
24  CONF_BASE,
25  CONF_HOST,
26  CONF_ID,
27  CONF_PORT,
28  CONF_SOURCE,
29  CONF_TOKEN,
30 )
31 from homeassistant.core import callback
33 
34 from . import create_hyperion_client
35 from .const import (
36  CONF_AUTH_ID,
37  CONF_CREATE_TOKEN,
38  CONF_EFFECT_HIDE_LIST,
39  CONF_EFFECT_SHOW_LIST,
40  CONF_PRIORITY,
41  DEFAULT_ORIGIN,
42  DEFAULT_PRIORITY,
43  DOMAIN,
44 )
45 
46 _LOGGER = logging.getLogger(__name__)
47 _LOGGER.setLevel(logging.DEBUG)
48 
49 # +------------------+ +------------------+ +--------------------+ +--------------------+
50 # |Step: SSDP | |Step: user | |Step: import | |Step: reauth |
51 # | | | | | | | |
52 # |Input: <discovery>| |Input: <host/port>| |Input: <import data>| |Input: <entry_data> |
53 # +------------------+ +------------------+ +--------------------+ +--------------------+
54 # v v v v
55 # +-------------------+-----------------------+--------------------+
56 # Auth not | Auth |
57 # required? | required? |
58 # | v
59 # | +------------+
60 # | |Step: auth |
61 # | | |
62 # | |Input: token|
63 # | +------------+
64 # | Static |
65 # v token |
66 # <------------------+
67 # | |
68 # | | New token
69 # | v
70 # | +------------------+
71 # | |Step: create_token|
72 # | +------------------+
73 # | |
74 # | v
75 # | +---------------------------+ +--------------------------------+
76 # | |Step: create_token_external|-->|Step: create_token_external_fail|
77 # | +---------------------------+ +--------------------------------+
78 # | |
79 # | v
80 # | +-----------------------------------+
81 # | |Step: create_token_external_success|
82 # | +-----------------------------------+
83 # | |
84 # v<------------------+
85 # |
86 # v
87 # +-------------+ Confirm not required?
88 # |Step: Confirm|---------------------->+
89 # +-------------+ |
90 # | |
91 # v SSDP: Explicit confirm |
92 # +------------------------------>+
93 # |
94 # v
95 # +----------------+
96 # | Create/Update! |
97 # +----------------+
98 
99 # A note on choice of discovery mechanisms: Hyperion supports both Zeroconf and SSDP out
100 # of the box. This config flow needs two port numbers from the Hyperion instance, the
101 # JSON port (for the API) and the UI port (for the user to approve dynamically created
102 # auth tokens). With Zeroconf the port numbers for both are in different Zeroconf
103 # entries, and as Home Assistant only passes a single entry into the config flow, we can
104 # only conveniently 'see' one port or the other (which means we need to guess one port
105 # number). With SSDP, we get the combined block including both port numbers, so SSDP is
106 # the favored discovery implementation.
107 
108 
109 class HyperionConfigFlow(ConfigFlow, domain=DOMAIN):
110  """Handle a Hyperion config flow."""
111 
112  VERSION = 1
113 
114  unique_id: str
115 
116  def __init__(self) -> None:
117  """Instantiate config flow."""
118  self._data_data: dict[str, Any] = {}
119  self._request_token_task_request_token_task: asyncio.Task | None = None
120  self._auth_id_auth_id: str | None = None
121  self._require_confirm_require_confirm: bool = False
122  self._port_ui_port_ui: int = const.DEFAULT_PORT_UI
123 
124  def _create_client(self, raw_connection: bool = False) -> client.HyperionClient:
125  """Create and connect a client instance."""
126  return create_hyperion_client(
127  self._data_data[CONF_HOST],
128  self._data_data[CONF_PORT],
129  token=self._data_data.get(CONF_TOKEN),
130  raw_connection=raw_connection,
131  )
132 
134  self, hyperion_client: client.HyperionClient
135  ) -> ConfigFlowResult:
136  """Determine if auth is required."""
137  auth_resp = await hyperion_client.async_is_auth_required()
138 
139  # Could not determine if auth is required.
140  if not auth_resp or not client.ResponseOK(auth_resp):
141  return self.async_abortasync_abortasync_abort(reason="auth_required_error")
142  auth_required = auth_resp.get(const.KEY_INFO, {}).get(const.KEY_REQUIRED, False)
143  if auth_required:
144  return await self.async_step_authasync_step_auth()
145  return await self.async_step_confirmasync_step_confirm()
146 
147  async def async_step_reauth(
148  self, entry_data: Mapping[str, Any]
149  ) -> ConfigFlowResult:
150  """Handle a reauthentication flow."""
151  self._data_data = dict(entry_data)
152  async with self._create_client_create_client(raw_connection=True) as hyperion_client:
153  if not hyperion_client:
154  return self.async_abortasync_abortasync_abort(reason="cannot_connect")
155  return await self._advance_to_auth_step_if_necessary_advance_to_auth_step_if_necessary(hyperion_client)
156 
157  async def async_step_ssdp(
158  self, discovery_info: ssdp.SsdpServiceInfo
159  ) -> ConfigFlowResult:
160  """Handle a flow initiated by SSDP."""
161  # Sample data provided by SSDP: {
162  # 'ssdp_location': 'http://192.168.0.1:8090/description.xml',
163  # 'ssdp_st': 'upnp:rootdevice',
164  # 'deviceType': 'urn:schemas-upnp-org:device:Basic:1',
165  # 'friendlyName': 'Hyperion (192.168.0.1)',
166  # 'manufacturer': 'Hyperion Open Source Ambient Lighting',
167  # 'manufacturerURL': 'https://www.hyperion-project.org',
168  # 'modelDescription': 'Hyperion Open Source Ambient Light',
169  # 'modelName': 'Hyperion',
170  # 'modelNumber': '2.0.0-alpha.8',
171  # 'modelURL': 'https://www.hyperion-project.org',
172  # 'serialNumber': 'f9aab089-f85a-55cf-b7c1-222a72faebe9',
173  # 'UDN': 'uuid:f9aab089-f85a-55cf-b7c1-222a72faebe9',
174  # 'ports': {
175  # 'jsonServer': '19444',
176  # 'sslServer': '8092',
177  # 'protoBuffer': '19445',
178  # 'flatBuffer': '19400'
179  # },
180  # 'presentationURL': 'index.html',
181  # 'iconList': {
182  # 'icon': {
183  # 'mimetype': 'image/png',
184  # 'height': '100',
185  # 'width': '100',
186  # 'depth': '32',
187  # 'url': 'img/hyperion/ssdp_icon.png'
188  # }
189  # },
190  # 'ssdp_usn': 'uuid:f9aab089-f85a-55cf-b7c1-222a72faebe9',
191  # 'ssdp_ext': '',
192  # 'ssdp_server': 'Raspbian GNU/Linux 10 (buster)/10 UPnP/1.0 Hyperion/2.0.0-alpha.8'}
193 
194  # SSDP requires user confirmation.
195  self._require_confirm_require_confirm = True
196  self._data_data[CONF_HOST] = urlparse(discovery_info.ssdp_location).hostname
197  try:
198  self._port_ui_port_ui = (
199  urlparse(discovery_info.ssdp_location).port or const.DEFAULT_PORT_UI
200  )
201  except ValueError:
202  self._port_ui_port_ui = const.DEFAULT_PORT_UI
203 
204  try:
205  self._data_data[CONF_PORT] = int(
206  discovery_info.upnp.get("ports", {}).get(
207  "jsonServer", const.DEFAULT_PORT_JSON
208  )
209  )
210  except ValueError:
211  self._data_data[CONF_PORT] = const.DEFAULT_PORT_JSON
212 
213  if not (hyperion_id := discovery_info.upnp.get(ssdp.ATTR_UPNP_SERIAL)):
214  return self.async_abortasync_abortasync_abort(reason="no_id")
215 
216  # For discovery mechanisms, we set the unique_id as early as possible to
217  # avoid discovery popping up a duplicate on the screen. The unique_id is set
218  # authoritatively later in the flow by asking the server to confirm its id
219  # (which should theoretically be the same as specified here)
220  await self.async_set_unique_idasync_set_unique_id(hyperion_id)
221  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
222 
223  async with self._create_client_create_client(raw_connection=True) as hyperion_client:
224  if not hyperion_client:
225  return self.async_abortasync_abortasync_abort(reason="cannot_connect")
226  return await self._advance_to_auth_step_if_necessary_advance_to_auth_step_if_necessary(hyperion_client)
227 
228  async def async_step_user(
229  self,
230  user_input: dict[str, Any] | None = None,
231  ) -> ConfigFlowResult:
232  """Handle a flow initiated by the user."""
233  errors = {}
234  if user_input:
235  self._data_data.update(user_input)
236 
237  async with self._create_client_create_client(raw_connection=True) as hyperion_client:
238  if hyperion_client:
239  return await self._advance_to_auth_step_if_necessary_advance_to_auth_step_if_necessary(
240  hyperion_client
241  )
242  errors[CONF_BASE] = "cannot_connect"
243 
244  return self.async_show_formasync_show_formasync_show_form(
245  step_id="user",
246  data_schema=vol.Schema(
247  {
248  vol.Required(CONF_HOST): str,
249  vol.Optional(CONF_PORT, default=const.DEFAULT_PORT_JSON): int,
250  }
251  ),
252  errors=errors,
253  )
254 
255  async def _cancel_request_token_task(self) -> None:
256  """Cancel the request token task if it exists."""
257  if self._request_token_task_request_token_task is not None:
258  if not self._request_token_task_request_token_task.done():
259  self._request_token_task_request_token_task.cancel()
260 
261  with suppress(asyncio.CancelledError):
262  await self._request_token_task_request_token_task
263  self._request_token_task_request_token_task = None
264 
265  async def _request_token_task_func(self, auth_id: str) -> None:
266  """Send an async_request_token request."""
267  auth_resp: dict[str, Any] | None = None
268  async with self._create_client_create_client(raw_connection=True) as hyperion_client:
269  if hyperion_client:
270  # The Hyperion-py client has a default timeout of 3 minutes on this request.
271  auth_resp = await hyperion_client.async_request_token(
272  comment=DEFAULT_ORIGIN, id=auth_id
273  )
274  await self.hass.config_entries.flow.async_configure(
275  flow_id=self.flow_id, user_input=auth_resp
276  )
277 
278  def _get_hyperion_url(self) -> str:
279  """Return the URL of the Hyperion UI."""
280  # If this flow was kicked off by SSDP, this will be the correct frontend URL. If
281  # this is a manual flow instantiation, then it will be a best guess (as this
282  # flow does not have that information available to it). This is only used for
283  # approving new dynamically created tokens, so the complexity of asking the user
284  # manually for this information is likely not worth it (when it would only be
285  # used to open a URL, that the user already knows the address of).
286  return f"http://{self._data[CONF_HOST]}:{self._port_ui}"
287 
288  async def _can_login(self) -> bool | None:
289  """Verify login details."""
290  async with self._create_client_create_client(raw_connection=True) as hyperion_client:
291  if not hyperion_client:
292  return None
293  return bool(
294  client.LoginResponseOK(
295  await hyperion_client.async_login(token=self._data_data[CONF_TOKEN])
296  )
297  )
298 
299  async def async_step_auth(
300  self,
301  user_input: dict[str, Any] | None = None,
302  ) -> ConfigFlowResult:
303  """Handle the auth step of a flow."""
304  errors = {}
305  if user_input:
306  if user_input.get(CONF_CREATE_TOKEN):
307  return await self.async_step_create_tokenasync_step_create_token()
308 
309  # Using a static token.
310  self._data_data[CONF_TOKEN] = user_input.get(CONF_TOKEN)
311  login_ok = await self._can_login_can_login()
312  if login_ok is None:
313  return self.async_abortasync_abortasync_abort(reason="cannot_connect")
314  if login_ok:
315  return await self.async_step_confirmasync_step_confirm()
316  errors[CONF_BASE] = "invalid_access_token"
317 
318  return self.async_show_formasync_show_formasync_show_form(
319  step_id="auth",
320  data_schema=vol.Schema(
321  {
322  vol.Required(CONF_CREATE_TOKEN): bool,
323  vol.Optional(CONF_TOKEN): str,
324  }
325  ),
326  errors=errors,
327  )
328 
330  self, user_input: dict[str, Any] | None = None
331  ) -> ConfigFlowResult:
332  """Send a request for a new token."""
333  if user_input is None:
334  self._auth_id_auth_id = client.generate_random_auth_id()
335  return self.async_show_formasync_show_formasync_show_form(
336  step_id="create_token",
337  description_placeholders={
338  CONF_AUTH_ID: self._auth_id_auth_id,
339  },
340  )
341 
342  # Cancel the request token task if it's already running, then re-create it.
343  await self._cancel_request_token_task_cancel_request_token_task()
344  # Start a task in the background requesting a new token. The next step will
345  # wait on the response (which includes the user needing to visit the Hyperion
346  # UI to approve the request for a new token).
347  assert self._auth_id_auth_id is not None
348  self._request_token_task_request_token_task = self.hass.async_create_task(
349  self._request_token_task_func_request_token_task_func(self._auth_id_auth_id), eager_start=False
350  )
351  return self.async_external_stepasync_external_step(
352  step_id="create_token_external", url=self._get_hyperion_url_get_hyperion_url()
353  )
354 
356  self, auth_resp: dict[str, Any] | None = None
357  ) -> ConfigFlowResult:
358  """Handle completion of the request for a new token."""
359  if auth_resp is not None and client.ResponseOK(auth_resp):
360  token = auth_resp.get(const.KEY_INFO, {}).get(const.KEY_TOKEN)
361  if token:
362  self._data_data[CONF_TOKEN] = token
363  return self.async_external_step_doneasync_external_step_done(
364  next_step_id="create_token_success"
365  )
366  return self.async_external_step_doneasync_external_step_done(next_step_id="create_token_fail")
367 
369  self, _: dict[str, Any] | None = None
370  ) -> ConfigFlowResult:
371  """Create an entry after successful token creation."""
372  # Clean-up the request task.
373  await self._cancel_request_token_task_cancel_request_token_task()
374 
375  # Test the token.
376  login_ok = await self._can_login_can_login()
377 
378  if login_ok is None:
379  return self.async_abortasync_abortasync_abort(reason="cannot_connect")
380  if not login_ok:
381  return self.async_abortasync_abortasync_abort(reason="auth_new_token_not_work_error")
382  return await self.async_step_confirmasync_step_confirm()
383 
385  self, _: dict[str, Any] | None = None
386  ) -> ConfigFlowResult:
387  """Show an error on the auth form."""
388  # Clean-up the request task.
389  await self._cancel_request_token_task_cancel_request_token_task()
390  return self.async_abortasync_abortasync_abort(reason="auth_new_token_not_granted_error")
391 
393  self, user_input: dict[str, Any] | None = None
394  ) -> ConfigFlowResult:
395  """Get final confirmation before entry creation."""
396  if user_input is None and self._require_confirm_require_confirm:
397  return self.async_show_formasync_show_formasync_show_form(
398  step_id="confirm",
399  description_placeholders={
400  CONF_HOST: self._data_data[CONF_HOST],
401  CONF_PORT: self._data_data[CONF_PORT],
402  CONF_ID: self.unique_idunique_id,
403  },
404  )
405 
406  async with self._create_client_create_client() as hyperion_client:
407  if not hyperion_client:
408  return self.async_abortasync_abortasync_abort(reason="cannot_connect")
409  hyperion_id = await hyperion_client.async_sysinfo_id()
410 
411  if not hyperion_id:
412  return self.async_abortasync_abortasync_abort(reason="no_id")
413 
414  entry = await self.async_set_unique_idasync_set_unique_id(hyperion_id, raise_on_progress=False)
415 
416  if self.context.get(CONF_SOURCE) == SOURCE_REAUTH and entry is not None:
417  return self.async_update_reload_and_abortasync_update_reload_and_abort(entry, data=self._data_data)
418 
419  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
420 
421  return self.async_create_entryasync_create_entryasync_create_entry(
422  title=f"{self._data[CONF_HOST]}:{self._data[CONF_PORT]}", data=self._data_data
423  )
424 
425  @staticmethod
426  @callback
428  config_entry: ConfigEntry,
429  ) -> HyperionOptionsFlow:
430  """Get the Hyperion Options flow."""
431  return HyperionOptionsFlow()
432 
433 
435  """Hyperion options flow."""
436 
437  def _create_client(self) -> client.HyperionClient:
438  """Create and connect a client instance."""
439  return create_hyperion_client(
440  self.config_entryconfig_entryconfig_entry.data[CONF_HOST],
441  self.config_entryconfig_entryconfig_entry.data[CONF_PORT],
442  token=self.config_entryconfig_entryconfig_entry.data.get(CONF_TOKEN),
443  )
444 
445  async def async_step_init(
446  self, user_input: dict[str, Any] | None = None
447  ) -> ConfigFlowResult:
448  """Manage the options."""
449 
450  effects = {}
451  async with self._create_client_create_client() as hyperion_client:
452  if not hyperion_client:
453  return self.async_abortasync_abort(reason="cannot_connect")
454  for effect in hyperion_client.effects or []:
455  if const.KEY_NAME in effect:
456  effects[effect[const.KEY_NAME]] = effect[const.KEY_NAME]
457 
458  # If a new effect is added to Hyperion, we always want it to show by default. So
459  # rather than store a 'show list' in the config entry, we store a 'hide list'.
460  # However, it's more intuitive to ask the user to select which effects to show,
461  # so we inverse the meaning prior to storage.
462 
463  if user_input is not None:
464  effect_show_list = user_input.pop(CONF_EFFECT_SHOW_LIST)
465  user_input[CONF_EFFECT_HIDE_LIST] = sorted(
466  set(effects) - set(effect_show_list)
467  )
468  return self.async_create_entryasync_create_entry(title="", data=user_input)
469 
470  default_effect_show_list = list(
471  set(effects) - set(self.config_entryconfig_entryconfig_entry.options.get(CONF_EFFECT_HIDE_LIST, []))
472  )
473 
474  return self.async_show_formasync_show_form(
475  step_id="init",
476  data_schema=vol.Schema(
477  {
478  vol.Optional(
479  CONF_PRIORITY,
480  default=self.config_entryconfig_entryconfig_entry.options.get(
481  CONF_PRIORITY, DEFAULT_PRIORITY
482  ),
483  ): vol.All(vol.Coerce(int), vol.Range(min=0, max=255)),
484  vol.Optional(
485  CONF_EFFECT_SHOW_LIST,
486  default=default_effect_show_list,
487  ): cv.multi_select(effects),
488  }
489  ),
490  )
ConfigFlowResult async_step_create_token_success(self, dict[str, Any]|None _=None)
Definition: config_flow.py:370
ConfigFlowResult async_step_reauth(self, Mapping[str, Any] entry_data)
Definition: config_flow.py:149
ConfigFlowResult async_step_create_token_external(self, dict[str, Any]|None auth_resp=None)
Definition: config_flow.py:357
ConfigFlowResult async_step_ssdp(self, ssdp.SsdpServiceInfo discovery_info)
Definition: config_flow.py:159
HyperionOptionsFlow async_get_options_flow(ConfigEntry config_entry)
Definition: config_flow.py:429
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:231
ConfigFlowResult _advance_to_auth_step_if_necessary(self, client.HyperionClient hyperion_client)
Definition: config_flow.py:135
ConfigFlowResult async_step_confirm(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:394
ConfigFlowResult async_step_create_token_fail(self, dict[str, Any]|None _=None)
Definition: config_flow.py:386
client.HyperionClient _create_client(self, bool raw_connection=False)
Definition: config_flow.py:124
ConfigFlowResult async_step_create_token(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:331
ConfigFlowResult async_step_auth(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:302
ConfigFlowResult async_step_init(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:447
None _abort_if_unique_id_configured(self, dict[str, Any]|None updates=None, bool reload_on_update=True, *str error="already_configured")
ConfigEntry|None async_set_unique_id(self, str|None unique_id=None, *bool raise_on_progress=True)
ConfigFlowResult async_create_entry(self, *str title, Mapping[str, Any] data, str|None description=None, Mapping[str, str]|None description_placeholders=None, Mapping[str, Any]|None options=None)
ConfigFlowResult async_update_reload_and_abort(self, ConfigEntry entry, *str|None|UndefinedType unique_id=UNDEFINED, str|UndefinedType title=UNDEFINED, Mapping[str, Any]|UndefinedType data=UNDEFINED, Mapping[str, Any]|UndefinedType data_updates=UNDEFINED, Mapping[str, Any]|UndefinedType options=UNDEFINED, str|UndefinedType reason=UNDEFINED, bool reload_even_if_entry_is_unchanged=True)
ConfigFlowResult async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
ConfigFlowResult async_show_form(self, *str|None step_id=None, vol.Schema|None data_schema=None, dict[str, str]|None errors=None, Mapping[str, str]|None description_placeholders=None, bool|None last_step=None, str|None preview=None)
None config_entry(self, ConfigEntry value)
_FlowResultT async_external_step(self, *str|None step_id=None, str url, Mapping[str, str]|None description_placeholders=None)
_FlowResultT async_show_form(self, *str|None step_id=None, vol.Schema|None data_schema=None, dict[str, str]|None errors=None, Mapping[str, str]|None description_placeholders=None, bool|None last_step=None, str|None preview=None)
_FlowResultT async_external_step_done(self, *str next_step_id)
_FlowResultT async_create_entry(self, *str|None title=None, Mapping[str, Any] data, str|None description=None, Mapping[str, str]|None description_placeholders=None)
_FlowResultT async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
client.HyperionClient create_hyperion_client(*Any args, **Any kwargs)
Definition: __init__.py:90
IssData update(pyiss.ISS iss)
Definition: __init__.py:33