Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow for Elk-M1 Control integration."""
2 
3 from __future__ import annotations
4 
5 import logging
6 from typing import Any, Self
7 
8 from elkm1_lib.discovery import ElkSystem
9 from elkm1_lib.elk import Elk
10 import voluptuous as vol
11 
12 from homeassistant.components import dhcp
13 from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
14 from homeassistant.const import (
15  CONF_ADDRESS,
16  CONF_HOST,
17  CONF_PASSWORD,
18  CONF_PREFIX,
19  CONF_PROTOCOL,
20  CONF_USERNAME,
21 )
22 from homeassistant.exceptions import HomeAssistantError
23 from homeassistant.helpers import device_registry as dr
24 from homeassistant.helpers.typing import DiscoveryInfoType, VolDictType
25 from homeassistant.util import slugify
26 from homeassistant.util.network import is_ip_address
27 
28 from . import async_wait_for_elk_to_sync, hostname_from_url
29 from .const import CONF_AUTO_CONFIGURE, DISCOVER_SCAN_TIMEOUT, DOMAIN, LOGIN_TIMEOUT
30 from .discovery import (
31  _short_mac,
32  async_discover_device,
33  async_discover_devices,
34  async_update_entry_from_discovery,
35 )
36 
37 CONF_DEVICE = "device"
38 
39 NON_SECURE_PORT = 2101
40 SECURE_PORT = 2601
41 STANDARD_PORTS = {NON_SECURE_PORT, SECURE_PORT}
42 
43 _LOGGER = logging.getLogger(__name__)
44 
45 PROTOCOL_MAP = {
46  "secure": "elks://",
47  "TLS 1.2": "elksv1_2://",
48  "non-secure": "elk://",
49  "serial": "serial://",
50 }
51 
52 
53 VALIDATE_TIMEOUT = 35
54 
55 BASE_SCHEMA: VolDictType = {
56  vol.Optional(CONF_USERNAME, default=""): str,
57  vol.Optional(CONF_PASSWORD, default=""): str,
58 }
59 
60 SECURE_PROTOCOLS = ["secure", "TLS 1.2"]
61 ALL_PROTOCOLS = [*SECURE_PROTOCOLS, "non-secure", "serial"]
62 DEFAULT_SECURE_PROTOCOL = "secure"
63 DEFAULT_NON_SECURE_PROTOCOL = "non-secure"
64 
65 PORT_PROTOCOL_MAP = {
66  NON_SECURE_PORT: DEFAULT_NON_SECURE_PROTOCOL,
67  SECURE_PORT: DEFAULT_SECURE_PROTOCOL,
68 }
69 
70 
71 async def validate_input(data: dict[str, str], mac: str | None) -> dict[str, str]:
72  """Validate the user input allows us to connect.
73 
74  Data has the keys from DATA_SCHEMA with values provided by the user.
75  """
76  userid = data.get(CONF_USERNAME)
77  password = data.get(CONF_PASSWORD)
78 
79  prefix = data[CONF_PREFIX]
80  url = _make_url_from_data(data)
81  requires_password = url.startswith(("elks://", "elksv1_2"))
82 
83  if requires_password and (not userid or not password):
84  raise InvalidAuth
85 
86  elk = Elk(
87  {"url": url, "userid": userid, "password": password, "element_list": ["panel"]}
88  )
89  elk.connect()
90 
91  try:
92  if not await async_wait_for_elk_to_sync(elk, LOGIN_TIMEOUT, VALIDATE_TIMEOUT):
93  raise InvalidAuth
94  finally:
95  elk.disconnect()
96 
97  short_mac = _short_mac(mac) if mac else None
98  if prefix and prefix != short_mac:
99  device_name = prefix
100  elif mac:
101  device_name = f"ElkM1 {short_mac}"
102  else:
103  device_name = "ElkM1"
104  return {"title": device_name, CONF_HOST: url, CONF_PREFIX: slugify(prefix)}
105 
106 
107 def _address_from_discovery(device: ElkSystem) -> str:
108  """Append the port only if its non-standard."""
109  if device.port in STANDARD_PORTS:
110  return device.ip_address
111  return f"{device.ip_address}:{device.port}"
112 
113 
114 def _make_url_from_data(data: dict[str, str]) -> str:
115  if host := data.get(CONF_HOST):
116  return host
117 
118  protocol = PROTOCOL_MAP[data[CONF_PROTOCOL]]
119  address = data[CONF_ADDRESS]
120  return f"{protocol}{address}"
121 
122 
123 def _placeholders_from_device(device: ElkSystem) -> dict[str, str]:
124  return {
125  "mac_address": _short_mac(device.mac_address),
126  "host": _address_from_discovery(device),
127  }
128 
129 
130 class Elkm1ConfigFlow(ConfigFlow, domain=DOMAIN):
131  """Handle a config flow for Elk-M1 Control."""
132 
133  VERSION = 1
134 
135  host: str | None = None
136 
137  def __init__(self) -> None:
138  """Initialize the elkm1 config flow."""
139  self._discovered_device_discovered_device: ElkSystem | None = None
140  self._discovered_devices_discovered_devices: dict[str, ElkSystem] = {}
141 
142  async def async_step_dhcp(
143  self, discovery_info: dhcp.DhcpServiceInfo
144  ) -> ConfigFlowResult:
145  """Handle discovery via dhcp."""
146  self._discovered_device_discovered_device = ElkSystem(
147  discovery_info.macaddress, discovery_info.ip, 0
148  )
149  _LOGGER.debug("Elk discovered from dhcp: %s", self._discovered_device_discovered_device)
150  return await self._async_handle_discovery_async_handle_discovery()
151 
153  self, discovery_info: DiscoveryInfoType
154  ) -> ConfigFlowResult:
155  """Handle integration discovery."""
156  self._discovered_device_discovered_device = ElkSystem(
157  discovery_info["mac_address"],
158  discovery_info["ip_address"],
159  discovery_info["port"],
160  )
161  _LOGGER.debug(
162  "Elk discovered from integration discovery: %s", self._discovered_device_discovered_device
163  )
164  return await self._async_handle_discovery_async_handle_discovery()
165 
166  async def _async_handle_discovery(self) -> ConfigFlowResult:
167  """Handle any discovery."""
168  device = self._discovered_device_discovered_device
169  assert device is not None
170  mac = dr.format_mac(device.mac_address)
171  host = device.ip_address
172  await self.async_set_unique_idasync_set_unique_id(mac)
173  for entry in self._async_current_entries_async_current_entries(include_ignore=False):
174  if (
175  entry.unique_id == mac
176  or hostname_from_url(entry.data[CONF_HOST]) == host
177  ):
178  if async_update_entry_from_discovery(self.hass, entry, device):
179  self.hass.config_entries.async_schedule_reload(entry.entry_id)
180  return self.async_abortasync_abortasync_abort(reason="already_configured")
181  self.hosthost = host
182  if self.hass.config_entries.flow.async_has_matching_flow(self):
183  return self.async_abortasync_abortasync_abort(reason="already_in_progress")
184  # Handled ignored case since _async_current_entries
185  # is called with include_ignore=False
186  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
187  if not device.port:
188  if discovered_device := await async_discover_device(self.hass, host):
189  self._discovered_device_discovered_device = discovered_device
190  else:
191  return self.async_abortasync_abortasync_abort(reason="cannot_connect")
192  return await self.async_step_discovery_confirmasync_step_discovery_confirm()
193 
194  def is_matching(self, other_flow: Self) -> bool:
195  """Return True if other_flow is matching this flow."""
196  return other_flow.host == self.hosthost
197 
199  self, user_input: dict[str, Any] | None = None
200  ) -> ConfigFlowResult:
201  """Confirm discovery."""
202  assert self._discovered_device_discovered_device is not None
203  self.context["title_placeholders"] = _placeholders_from_device(
204  self._discovered_device_discovered_device
205  )
206  return await self.async_step_discovered_connectionasync_step_discovered_connection()
207 
208  async def async_step_user(
209  self, user_input: dict[str, Any] | None = None
210  ) -> ConfigFlowResult:
211  """Handle the initial step."""
212  if user_input is not None:
213  if mac := user_input[CONF_DEVICE]:
214  await self.async_set_unique_idasync_set_unique_id(mac, raise_on_progress=False)
215  self._discovered_device_discovered_device = self._discovered_devices_discovered_devices[mac]
216  return await self.async_step_discovered_connectionasync_step_discovered_connection()
217  return await self.async_step_manual_connectionasync_step_manual_connection()
218 
219  current_unique_ids = self._async_current_ids_async_current_ids()
220  current_hosts = {
221  hostname_from_url(entry.data[CONF_HOST])
222  for entry in self._async_current_entries_async_current_entries(include_ignore=False)
223  }
224  discovered_devices = await async_discover_devices(
225  self.hass, DISCOVER_SCAN_TIMEOUT
226  )
227  self._discovered_devices_discovered_devices = {
228  dr.format_mac(device.mac_address): device for device in discovered_devices
229  }
230  devices_name: dict[str | None, str] = {
231  mac: f"{_short_mac(device.mac_address)} ({device.ip_address})"
232  for mac, device in self._discovered_devices_discovered_devices.items()
233  if mac not in current_unique_ids and device.ip_address not in current_hosts
234  }
235  if not devices_name:
236  return await self.async_step_manual_connectionasync_step_manual_connection()
237  devices_name[None] = "Manual Entry"
238  return self.async_show_formasync_show_formasync_show_form(
239  step_id="user",
240  data_schema=vol.Schema({vol.Required(CONF_DEVICE): vol.In(devices_name)}),
241  )
242 
244  self, user_input: dict[str, Any], importing: bool
245  ) -> tuple[dict[str, str] | None, ConfigFlowResult | None]:
246  """Try to connect and create the entry or error."""
247  if self._url_already_configured_url_already_configured(_make_url_from_data(user_input)):
248  return None, self.async_abortasync_abortasync_abort(reason="address_already_configured")
249 
250  try:
251  info = await validate_input(user_input, self.unique_idunique_id)
252  except TimeoutError:
253  return {"base": "cannot_connect"}, None
254  except InvalidAuth:
255  return {CONF_PASSWORD: "invalid_auth"}, None
256  except Exception:
257  _LOGGER.exception("Unexpected exception")
258  return {"base": "unknown"}, None
259 
260  if importing:
261  return None, self.async_create_entryasync_create_entryasync_create_entry(title=info["title"], data=user_input)
262 
263  return None, self.async_create_entryasync_create_entryasync_create_entry(
264  title=info["title"],
265  data={
266  CONF_HOST: info[CONF_HOST],
267  CONF_USERNAME: user_input[CONF_USERNAME],
268  CONF_PASSWORD: user_input[CONF_PASSWORD],
269  CONF_AUTO_CONFIGURE: True,
270  CONF_PREFIX: info[CONF_PREFIX],
271  },
272  )
273 
275  self, user_input: dict[str, Any] | None = None
276  ) -> ConfigFlowResult:
277  """Handle connecting the device when we have a discovery."""
278  errors: dict[str, str] | None = {}
279  device = self._discovered_device_discovered_device
280  assert device is not None
281  if user_input is not None:
282  user_input[CONF_ADDRESS] = _address_from_discovery(device)
283  if self._async_current_entries_async_current_entries():
284  user_input[CONF_PREFIX] = _short_mac(device.mac_address)
285  else:
286  user_input[CONF_PREFIX] = ""
287  errors, result = await self._async_create_or_error_async_create_or_error(user_input, False)
288  if result is not None:
289  return result
290 
291  default_proto = PORT_PROTOCOL_MAP.get(device.port, DEFAULT_SECURE_PROTOCOL)
292  return self.async_show_formasync_show_formasync_show_form(
293  step_id="discovered_connection",
294  data_schema=vol.Schema(
295  {
296  **BASE_SCHEMA,
297  vol.Required(CONF_PROTOCOL, default=default_proto): vol.In(
298  ALL_PROTOCOLS
299  ),
300  }
301  ),
302  errors=errors,
303  description_placeholders=_placeholders_from_device(device),
304  )
305 
307  self, user_input: dict[str, Any] | None = None
308  ) -> ConfigFlowResult:
309  """Handle connecting the device when we need manual entry."""
310  errors: dict[str, str] | None = {}
311  if user_input is not None:
312  # We might be able to discover the device via directed UDP
313  # in case its on another subnet
314  if device := await async_discover_device(
315  self.hass, user_input[CONF_ADDRESS]
316  ):
317  await self.async_set_unique_idasync_set_unique_id(
318  dr.format_mac(device.mac_address), raise_on_progress=False
319  )
320  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
321  # Ignore the port from discovery since its always going to be
322  # 2601 if secure is turned on even though they may want insecure
323  user_input[CONF_ADDRESS] = device.ip_address
324  errors, result = await self._async_create_or_error_async_create_or_error(user_input, False)
325  if result is not None:
326  return result
327 
328  return self.async_show_formasync_show_formasync_show_form(
329  step_id="manual_connection",
330  data_schema=vol.Schema(
331  {
332  **BASE_SCHEMA,
333  vol.Required(CONF_ADDRESS): str,
334  vol.Optional(CONF_PREFIX, default=""): str,
335  vol.Required(
336  CONF_PROTOCOL, default=DEFAULT_SECURE_PROTOCOL
337  ): vol.In(ALL_PROTOCOLS),
338  }
339  ),
340  errors=errors,
341  )
342 
343  async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
344  """Handle import."""
345  _LOGGER.debug("Elk is importing from yaml")
346  url = _make_url_from_data(import_data)
347 
348  if self._url_already_configured_url_already_configured(url):
349  return self.async_abortasync_abortasync_abort(reason="address_already_configured")
350 
351  host = hostname_from_url(url)
352  _LOGGER.debug(
353  "Importing is trying to fill unique id from discovery for %s", host
354  )
355  if (
356  host
357  and is_ip_address(host)
358  and (device := await async_discover_device(self.hass, host))
359  ):
360  await self.async_set_unique_idasync_set_unique_id(
361  dr.format_mac(device.mac_address), raise_on_progress=False
362  )
363  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
364 
365  errors, result = await self._async_create_or_error_async_create_or_error(import_data, True)
366  if errors:
367  return self.async_abortasync_abortasync_abort(reason=list(errors.values())[0])
368  assert result is not None
369  return result
370 
371  def _url_already_configured(self, url: str) -> bool:
372  """See if we already have a elkm1 matching user input configured."""
373  existing_hosts = {
374  hostname_from_url(entry.data[CONF_HOST])
375  for entry in self._async_current_entries_async_current_entries()
376  }
377  return hostname_from_url(url) in existing_hosts
378 
379 
381  """Error to indicate there is invalid auth."""
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:210
ConfigFlowResult async_step_integration_discovery(self, DiscoveryInfoType discovery_info)
Definition: config_flow.py:154
ConfigFlowResult async_step_dhcp(self, dhcp.DhcpServiceInfo discovery_info)
Definition: config_flow.py:144
ConfigFlowResult async_step_manual_connection(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:308
ConfigFlowResult async_step_discovered_connection(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:276
ConfigFlowResult async_step_discovery_confirm(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:200
ConfigFlowResult async_step_import(self, dict[str, Any] import_data)
Definition: config_flow.py:343
tuple[dict[str, str]|None, ConfigFlowResult|None] _async_create_or_error(self, dict[str, Any] user_input, bool importing)
Definition: config_flow.py:245
None _abort_if_unique_id_configured(self, dict[str, Any]|None updates=None, bool reload_on_update=True, *str error="already_configured")
set[str|None] _async_current_ids(self, bool include_ignore=True)
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)
list[ConfigEntry] _async_current_entries(self, bool|None include_ignore=None)
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)
_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)
_FlowResultT async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
str _make_url_from_data(dict[str, str] data)
Definition: config_flow.py:114
dict[str, str] _placeholders_from_device(ElkSystem device)
Definition: config_flow.py:123
str _address_from_discovery(ElkSystem device)
Definition: config_flow.py:107
dict[str, str] validate_input(dict[str, str] data, str|None mac)
Definition: config_flow.py:71
ElkSystem|None async_discover_device(HomeAssistant hass, str host)
Definition: discovery.py:78
list[ElkSystem] async_discover_devices(HomeAssistant hass, int timeout, str|None address=None)
Definition: discovery.py:43
bool async_update_entry_from_discovery(HomeAssistant hass, config_entries.ConfigEntry entry, ElkSystem device)
Definition: discovery.py:30
bool async_wait_for_elk_to_sync(Elk elk, int login_timeout, int sync_timeout)
Definition: __init__.py:351
str hostname_from_url(str url)
Definition: __init__.py:97
bool is_ip_address(str address)
Definition: network.py:63