Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow for Universal Devices ISY/IoX integration."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Mapping
7 import logging
8 from typing import Any
9 from urllib.parse import urlparse, urlunparse
10 
11 from aiohttp import CookieJar
12 from pyisy import ISYConnectionError, ISYInvalidAuthError, ISYResponseParseError
13 from pyisy.configuration import Configuration
14 from pyisy.connection import Connection
15 import voluptuous as vol
16 
17 from homeassistant.components import dhcp, ssdp
18 from homeassistant.config_entries import (
19  SOURCE_IGNORE,
20  ConfigEntry,
21  ConfigFlow,
22  ConfigFlowResult,
23  OptionsFlow,
24 )
25 from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
26 from homeassistant.core import HomeAssistant, callback
27 from homeassistant.data_entry_flow import AbortFlow
28 from homeassistant.exceptions import HomeAssistantError
29 from homeassistant.helpers import aiohttp_client
30 
31 from .const import (
32  CONF_IGNORE_STRING,
33  CONF_RESTORE_LIGHT_STATE,
34  CONF_SENSOR_STRING,
35  CONF_TLS_VER,
36  CONF_VAR_SENSOR_STRING,
37  DEFAULT_IGNORE_STRING,
38  DEFAULT_RESTORE_LIGHT_STATE,
39  DEFAULT_SENSOR_STRING,
40  DEFAULT_TLS_VERSION,
41  DEFAULT_VAR_SENSOR_STRING,
42  DOMAIN,
43  HTTP_PORT,
44  HTTPS_PORT,
45  ISY_CONF_NAME,
46  ISY_CONF_UUID,
47  ISY_URL_POSTFIX,
48  SCHEME_HTTP,
49  SCHEME_HTTPS,
50  UDN_UUID_PREFIX,
51 )
52 
53 _LOGGER = logging.getLogger(__name__)
54 
55 
56 def _data_schema(schema_input: dict[str, str]) -> vol.Schema:
57  """Generate schema with defaults."""
58  return vol.Schema(
59  {
60  vol.Required(CONF_HOST, default=schema_input.get(CONF_HOST, "")): str,
61  vol.Required(CONF_USERNAME): str,
62  vol.Required(CONF_PASSWORD): str,
63  vol.Optional(CONF_TLS_VER, default=DEFAULT_TLS_VERSION): vol.In([1.1, 1.2]),
64  },
65  extra=vol.ALLOW_EXTRA,
66  )
67 
68 
69 async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
70  """Validate the user input allows us to connect.
71 
72  Data has the keys from DATA_SCHEMA with values provided by the user.
73  """
74  user = data[CONF_USERNAME]
75  password = data[CONF_PASSWORD]
76  host = urlparse(data[CONF_HOST])
77  tls_version = data.get(CONF_TLS_VER)
78 
79  if host.scheme == SCHEME_HTTP:
80  https = False
81  port = host.port or HTTP_PORT
82  session = aiohttp_client.async_create_clientsession(
83  hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True)
84  )
85  elif host.scheme == SCHEME_HTTPS:
86  https = True
87  port = host.port or HTTPS_PORT
88  session = aiohttp_client.async_get_clientsession(hass)
89  else:
90  _LOGGER.error("The ISY/IoX host value in configuration is invalid")
91  raise InvalidHost
92 
93  # Connect to ISY controller.
94  isy_conn = Connection(
95  host.hostname,
96  port,
97  user,
98  password,
99  use_https=https,
100  tls_ver=tls_version,
101  webroot=host.path,
102  websession=session,
103  )
104 
105  try:
106  async with asyncio.timeout(30):
107  isy_conf_xml = await isy_conn.test_connection()
108  except ISYInvalidAuthError as error:
109  raise InvalidAuth from error
110  except ISYConnectionError as error:
111  raise CannotConnect from error
112 
113  try:
114  isy_conf = Configuration(xml=isy_conf_xml)
115  except ISYResponseParseError as error:
116  raise CannotConnect from error
117  if not isy_conf or ISY_CONF_NAME not in isy_conf or not isy_conf[ISY_CONF_NAME]:
118  raise CannotConnect
119 
120  # Return info that you want to store in the config entry.
121  return {
122  "title": f"{isy_conf[ISY_CONF_NAME]} ({host.hostname})",
123  ISY_CONF_UUID: isy_conf[ISY_CONF_UUID],
124  }
125 
126 
127 class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN):
128  """Handle a config flow for Universal Devices ISY/IoX."""
129 
130  VERSION = 1
131 
132  def __init__(self) -> None:
133  """Initialize the ISY/IoX config flow."""
134  self.discovered_confdiscovered_conf: dict[str, str] = {}
135  self._existing_entry_existing_entry: ConfigEntry | None = None
136 
137  @staticmethod
138  @callback
140  config_entry: ConfigEntry,
141  ) -> OptionsFlow:
142  """Get the options flow for this handler."""
143  return OptionsFlowHandler()
144 
145  async def async_step_user(
146  self, user_input: dict[str, Any] | None = None
147  ) -> ConfigFlowResult:
148  """Handle the initial step."""
149  errors = {}
150  info: dict[str, str] = {}
151  if user_input is not None:
152  try:
153  info = await validate_input(self.hass, user_input)
154  except CannotConnect:
155  errors["base"] = "cannot_connect"
156  except InvalidHost:
157  errors["base"] = "invalid_host"
158  except InvalidAuth:
159  errors[CONF_PASSWORD] = "invalid_auth"
160  except Exception:
161  _LOGGER.exception("Unexpected exception")
162  errors["base"] = "unknown"
163 
164  if not errors:
165  await self.async_set_unique_idasync_set_unique_id(
166  info[ISY_CONF_UUID], raise_on_progress=False
167  )
168  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
169  return self.async_create_entryasync_create_entryasync_create_entry(title=info["title"], data=user_input)
170 
171  return self.async_show_formasync_show_formasync_show_form(
172  step_id="user",
173  data_schema=_data_schema(self.discovered_confdiscovered_conf),
174  errors=errors,
175  )
176 
178  self, isy_mac: str, ip_address: str, port: int | None
179  ) -> None:
180  """Abort and update the ip address on change."""
181  existing_entry = await self.async_set_unique_idasync_set_unique_id(isy_mac)
182  if not existing_entry:
183  return
184  if existing_entry.source == SOURCE_IGNORE:
185  raise AbortFlow("already_configured")
186  parsed_url = urlparse(existing_entry.data[CONF_HOST])
187  if parsed_url.hostname != ip_address:
188  new_netloc = ip_address
189  if port:
190  new_netloc = f"{ip_address}:{port}"
191  elif parsed_url.port:
192  new_netloc = f"{ip_address}:{parsed_url.port}"
193  self.hass.config_entries.async_update_entry(
194  existing_entry,
195  data={
196  **existing_entry.data,
197  CONF_HOST: urlunparse(
198  (
199  parsed_url.scheme,
200  new_netloc,
201  parsed_url.path,
202  parsed_url.query,
203  parsed_url.fragment,
204  None,
205  )
206  ),
207  },
208  )
209  raise AbortFlow("already_configured")
210 
211  async def async_step_dhcp(
212  self, discovery_info: dhcp.DhcpServiceInfo
213  ) -> ConfigFlowResult:
214  """Handle a discovered ISY/IoX device via dhcp."""
215  friendly_name = discovery_info.hostname
216  if friendly_name.startswith(("polisy", "eisy")):
217  url = f"http://{discovery_info.ip}:8080"
218  else:
219  url = f"http://{discovery_info.ip}"
220  mac = discovery_info.macaddress
221  isy_mac = (
222  f"{mac[0:2]}:{mac[2:4]}:{mac[4:6]}:{mac[6:8]}:{mac[8:10]}:{mac[10:12]}"
223  )
224  await self._async_set_unique_id_or_update_async_set_unique_id_or_update(isy_mac, discovery_info.ip, None)
225 
226  self.discovered_confdiscovered_conf = {
227  CONF_NAME: friendly_name,
228  CONF_HOST: url,
229  }
230 
231  self.context["title_placeholders"] = self.discovered_confdiscovered_conf
232  return await self.async_step_userasync_step_userasync_step_user()
233 
234  async def async_step_ssdp(
235  self, discovery_info: ssdp.SsdpServiceInfo
236  ) -> ConfigFlowResult:
237  """Handle a discovered ISY/IoX Device."""
238  friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]
239  url = discovery_info.ssdp_location
240  assert isinstance(url, str)
241  parsed_url = urlparse(url)
242  mac = discovery_info.upnp[ssdp.ATTR_UPNP_UDN]
243  mac = mac.removeprefix(UDN_UUID_PREFIX)
244  url = url.removesuffix(ISY_URL_POSTFIX)
245 
246  port = HTTP_PORT
247  if parsed_url.port:
248  port = parsed_url.port
249  elif parsed_url.scheme == SCHEME_HTTPS:
250  port = HTTPS_PORT
251 
252  assert isinstance(parsed_url.hostname, str)
253  await self._async_set_unique_id_or_update_async_set_unique_id_or_update(mac, parsed_url.hostname, port)
254 
255  self.discovered_confdiscovered_conf = {
256  CONF_NAME: friendly_name,
257  CONF_HOST: url,
258  }
259 
260  self.context["title_placeholders"] = self.discovered_confdiscovered_conf
261  return await self.async_step_userasync_step_userasync_step_user()
262 
263  async def async_step_reauth(
264  self, entry_data: Mapping[str, Any]
265  ) -> ConfigFlowResult:
266  """Handle reauth."""
267  self._existing_entry_existing_entry = await self.async_set_unique_idasync_set_unique_id(self.context["unique_id"])
268  return await self.async_step_reauth_confirmasync_step_reauth_confirm()
269 
271  self, user_input: dict[str, Any] | None = None
272  ) -> ConfigFlowResult:
273  """Handle reauth input."""
274  errors = {}
275  assert self._existing_entry_existing_entry is not None
276  existing_entry = self._existing_entry_existing_entry
277  existing_data = existing_entry.data
278  if user_input is not None:
279  new_data = {
280  **existing_data,
281  CONF_USERNAME: user_input[CONF_USERNAME],
282  CONF_PASSWORD: user_input[CONF_PASSWORD],
283  }
284  try:
285  await validate_input(self.hass, new_data)
286  except CannotConnect:
287  errors["base"] = "cannot_connect"
288  except InvalidAuth:
289  errors[CONF_PASSWORD] = "invalid_auth"
290  else:
291  return self.async_update_reload_and_abortasync_update_reload_and_abort(
292  self._existing_entry_existing_entry, data=new_data
293  )
294 
295  self.context["title_placeholders"] = {
296  CONF_NAME: existing_entry.title,
297  CONF_HOST: existing_data[CONF_HOST],
298  }
299  return self.async_show_formasync_show_formasync_show_form(
300  description_placeholders={CONF_HOST: existing_data[CONF_HOST]},
301  step_id="reauth_confirm",
302  data_schema=vol.Schema(
303  {
304  vol.Required(
305  CONF_USERNAME, default=existing_data[CONF_USERNAME]
306  ): str,
307  vol.Required(CONF_PASSWORD): str,
308  }
309  ),
310  errors=errors,
311  )
312 
313 
315  """Handle a option flow for ISY/IoX."""
316 
317  async def async_step_init(
318  self, user_input: dict[str, Any] | None = None
319  ) -> ConfigFlowResult:
320  """Handle options flow."""
321  if user_input is not None:
322  return self.async_create_entryasync_create_entry(title="", data=user_input)
323 
324  options = self.config_entryconfig_entryconfig_entry.options
325  restore_light_state = options.get(
326  CONF_RESTORE_LIGHT_STATE, DEFAULT_RESTORE_LIGHT_STATE
327  )
328  ignore_string = options.get(CONF_IGNORE_STRING, DEFAULT_IGNORE_STRING)
329  sensor_string = options.get(CONF_SENSOR_STRING, DEFAULT_SENSOR_STRING)
330  var_sensor_string = options.get(
331  CONF_VAR_SENSOR_STRING, DEFAULT_VAR_SENSOR_STRING
332  )
333 
334  options_schema = vol.Schema(
335  {
336  vol.Optional(CONF_IGNORE_STRING, default=ignore_string): str,
337  vol.Optional(CONF_SENSOR_STRING, default=sensor_string): str,
338  vol.Optional(CONF_VAR_SENSOR_STRING, default=var_sensor_string): str,
339  vol.Required(
340  CONF_RESTORE_LIGHT_STATE, default=restore_light_state
341  ): bool,
342  }
343  )
344 
345  return self.async_show_formasync_show_form(step_id="init", data_schema=options_schema)
346 
347 
349  """Error to indicate the host value is invalid."""
350 
351 
352 class CannotConnect(HomeAssistantError):
353  """Error to indicate we cannot connect."""
354 
355 
357  """Error to indicate there is invalid auth."""
ConfigFlowResult async_step_reauth_confirm(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:272
ConfigFlowResult async_step_reauth(self, Mapping[str, Any] entry_data)
Definition: config_flow.py:265
ConfigFlowResult async_step_dhcp(self, dhcp.DhcpServiceInfo discovery_info)
Definition: config_flow.py:213
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:147
OptionsFlow async_get_options_flow(ConfigEntry config_entry)
Definition: config_flow.py:141
None _async_set_unique_id_or_update(self, str isy_mac, str ip_address, int|None port)
Definition: config_flow.py:179
ConfigFlowResult async_step_ssdp(self, ssdp.SsdpServiceInfo discovery_info)
Definition: config_flow.py:236
ConfigFlowResult async_step_init(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:319
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_step_user(self, dict[str, Any]|None user_input=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_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_create_entry(self, *str|None title=None, Mapping[str, Any] data, str|None description=None, Mapping[str, str]|None description_placeholders=None)
vol.Schema _data_schema(dict[str, str] schema_input)
Definition: config_flow.py:56
dict[str, str] validate_input(HomeAssistant hass, dict[str, Any] data)
Definition: config_flow.py:69