Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow for UniFi Network integration.
2 
3 Provides user initiated configuration flow.
4 Discovery of UniFi Network instances hosted on UDM and UDM Pro devices
5 through SSDP. Reauthentication when issue with credentials are reported.
6 Configuration of options through options flow.
7 """
8 
9 from __future__ import annotations
10 
11 from collections.abc import Mapping
12 import operator
13 import socket
14 from types import MappingProxyType
15 from typing import Any
16 from urllib.parse import urlparse
17 
18 from aiounifi.interfaces.sites import Sites
19 import voluptuous as vol
20 
21 from homeassistant.components import ssdp
22 from homeassistant.config_entries import (
23  SOURCE_REAUTH,
24  ConfigEntryState,
25  ConfigFlow,
26  ConfigFlowResult,
27  OptionsFlow,
28 )
29 from homeassistant.const import (
30  CONF_HOST,
31  CONF_PASSWORD,
32  CONF_PORT,
33  CONF_USERNAME,
34  CONF_VERIFY_SSL,
35 )
36 from homeassistant.core import HomeAssistant, callback
38 from homeassistant.helpers.device_registry import format_mac
39 
40 from . import UnifiConfigEntry
41 from .const import (
42  CONF_ALLOW_BANDWIDTH_SENSORS,
43  CONF_ALLOW_UPTIME_SENSORS,
44  CONF_BLOCK_CLIENT,
45  CONF_CLIENT_SOURCE,
46  CONF_DETECTION_TIME,
47  CONF_DPI_RESTRICTIONS,
48  CONF_IGNORE_WIRED_BUG,
49  CONF_SITE_ID,
50  CONF_SSID_FILTER,
51  CONF_TRACK_CLIENTS,
52  CONF_TRACK_DEVICES,
53  CONF_TRACK_WIRED_CLIENTS,
54  DEFAULT_DPI_RESTRICTIONS,
55  DOMAIN as UNIFI_DOMAIN,
56 )
57 from .errors import AuthenticationRequired, CannotConnect
58 from .hub import UnifiHub, get_unifi_api
59 
60 DEFAULT_PORT = 443
61 DEFAULT_SITE_ID = "default"
62 DEFAULT_VERIFY_SSL = False
63 
64 
65 MODEL_PORTS = {
66  "UniFi Dream Machine": 443,
67  "UniFi Dream Machine Pro": 443,
68 }
69 
70 
71 class UnifiFlowHandler(ConfigFlow, domain=UNIFI_DOMAIN):
72  """Handle a UniFi Network config flow."""
73 
74  VERSION = 1
75 
76  sites: Sites
77 
78  @staticmethod
79  @callback
81  config_entry: UnifiConfigEntry,
82  ) -> UnifiOptionsFlowHandler:
83  """Get the options flow for this handler."""
84  return UnifiOptionsFlowHandler(config_entry)
85 
86  def __init__(self) -> None:
87  """Initialize the UniFi Network flow."""
88  self.configconfig: dict[str, Any] = {}
89  self.reauth_schemareauth_schema: dict[vol.Marker, Any] = {}
90 
91  async def async_step_user(
92  self, user_input: dict[str, Any] | None = None
93  ) -> ConfigFlowResult:
94  """Handle a flow initialized by the user."""
95  errors = {}
96 
97  if user_input is not None:
98  self.configconfig = {
99  CONF_HOST: user_input[CONF_HOST],
100  CONF_USERNAME: user_input[CONF_USERNAME],
101  CONF_PASSWORD: user_input[CONF_PASSWORD],
102  CONF_PORT: user_input.get(CONF_PORT),
103  CONF_VERIFY_SSL: user_input.get(CONF_VERIFY_SSL),
104  CONF_SITE_ID: DEFAULT_SITE_ID,
105  }
106 
107  try:
108  hub = await get_unifi_api(self.hass, MappingProxyType(self.configconfig))
109  await hub.sites.update()
110  self.sitessites = hub.sites
111 
112  except AuthenticationRequired:
113  errors["base"] = "faulty_credentials"
114 
115  except CannotConnect:
116  errors["base"] = "service_unavailable"
117 
118  else:
119  if (
120  self.sourcesourcesourcesource == SOURCE_REAUTH
121  and (
122  (reauth_unique_id := self._get_reauth_entry_get_reauth_entry().unique_id)
123  is not None
124  )
125  and reauth_unique_id in self.sitessites
126  ):
127  return await self.async_step_siteasync_step_site({CONF_SITE_ID: reauth_unique_id})
128 
129  return await self.async_step_siteasync_step_site()
130 
131  if not (host := self.configconfig.get(CONF_HOST, "")) and await _async_discover_unifi(
132  self.hass
133  ):
134  host = "unifi"
135 
136  data = self.reauth_schemareauth_schema or {
137  vol.Required(CONF_HOST, default=host): str,
138  vol.Required(CONF_USERNAME): str,
139  vol.Required(CONF_PASSWORD): str,
140  vol.Optional(
141  CONF_PORT, default=self.configconfig.get(CONF_PORT, DEFAULT_PORT)
142  ): int,
143  vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
144  }
145 
146  return self.async_show_formasync_show_formasync_show_form(
147  step_id="user",
148  data_schema=vol.Schema(data),
149  errors=errors,
150  )
151 
152  async def async_step_site(
153  self, user_input: dict[str, Any] | None = None
154  ) -> ConfigFlowResult:
155  """Select site to control."""
156  if user_input is not None:
157  unique_id = user_input[CONF_SITE_ID]
158  self.configconfig[CONF_SITE_ID] = self.sitessites[unique_id].name
159 
160  config_entry = await self.async_set_unique_idasync_set_unique_id(unique_id)
161  abort_reason = "configuration_updated"
162 
163  if self.sourcesourcesourcesource == SOURCE_REAUTH:
164  config_entry = self._get_reauth_entry_get_reauth_entry()
165  abort_reason = "reauth_successful"
166 
167  if config_entry:
168  if (
169  config_entry.state is ConfigEntryState.LOADED
170  and (hub := config_entry.runtime_data)
171  and hub.available
172  ):
173  return self.async_abortasync_abortasync_abort(reason="already_configured")
174 
175  return self.async_update_reload_and_abortasync_update_reload_and_abort(
176  config_entry, data=self.configconfig, reason=abort_reason
177  )
178 
179  site_nice_name = self.sitessites[unique_id].description
180  return self.async_create_entryasync_create_entryasync_create_entry(title=site_nice_name, data=self.configconfig)
181 
182  if len(self.sitessites.values()) == 1:
183  return await self.async_step_siteasync_step_site({CONF_SITE_ID: next(iter(self.sitessites))})
184 
185  site_names = {site.site_id: site.description for site in self.sitessites.values()}
186  return self.async_show_formasync_show_formasync_show_form(
187  step_id="site",
188  data_schema=vol.Schema({vol.Required(CONF_SITE_ID): vol.In(site_names)}),
189  )
190 
191  async def async_step_reauth(
192  self, entry_data: Mapping[str, Any]
193  ) -> ConfigFlowResult:
194  """Trigger a reauthentication flow."""
195  reauth_entry = self._get_reauth_entry_get_reauth_entry()
196 
197  self.context["title_placeholders"] = {
198  CONF_HOST: reauth_entry.data[CONF_HOST],
199  CONF_SITE_ID: reauth_entry.title,
200  }
201 
202  self.reauth_schemareauth_schema = {
203  vol.Required(CONF_HOST, default=reauth_entry.data[CONF_HOST]): str,
204  vol.Required(CONF_USERNAME, default=reauth_entry.data[CONF_USERNAME]): str,
205  vol.Required(CONF_PASSWORD): str,
206  vol.Required(CONF_PORT, default=reauth_entry.data[CONF_PORT]): int,
207  vol.Required(
208  CONF_VERIFY_SSL, default=reauth_entry.data[CONF_VERIFY_SSL]
209  ): bool,
210  }
211 
212  return await self.async_step_userasync_step_userasync_step_user()
213 
214  async def async_step_ssdp(
215  self, discovery_info: ssdp.SsdpServiceInfo
216  ) -> ConfigFlowResult:
217  """Handle a discovered UniFi device."""
218  parsed_url = urlparse(discovery_info.ssdp_location)
219  model_description = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_DESCRIPTION]
220  mac_address = format_mac(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL])
221 
222  self.configconfig = {
223  CONF_HOST: parsed_url.hostname,
224  }
225 
226  self._async_abort_entries_match_async_abort_entries_match({CONF_HOST: self.configconfig[CONF_HOST]})
227 
228  await self.async_set_unique_idasync_set_unique_id(mac_address)
229  self._abort_if_unique_id_configured_abort_if_unique_id_configured(updates=self.configconfig)
230 
231  self.context["title_placeholders"] = {
232  CONF_HOST: self.configconfig[CONF_HOST],
233  CONF_SITE_ID: DEFAULT_SITE_ID,
234  }
235 
236  if (port := MODEL_PORTS.get(model_description)) is not None:
237  self.configconfig[CONF_PORT] = port
238  self.context["configuration_url"] = (
239  f"https://{self.config[CONF_HOST]}:{port}"
240  )
241 
242  return await self.async_step_userasync_step_userasync_step_user()
243 
244 
246  """Handle Unifi Network options."""
247 
248  hub: UnifiHub
249 
250  def __init__(self, config_entry: UnifiConfigEntry) -> None:
251  """Initialize UniFi Network options flow."""
252  self.optionsoptions = dict(config_entry.options)
253 
254  async def async_step_init(
255  self, user_input: dict[str, Any] | None = None
256  ) -> ConfigFlowResult:
257  """Manage the UniFi Network options."""
258  self.hubhub = self.config_entryconfig_entryconfig_entry.runtime_data
259  self.optionsoptions[CONF_BLOCK_CLIENT] = self.hubhub.config.option_block_clients
260 
261  if self.show_advanced_optionsshow_advanced_options:
262  return await self.async_step_configure_entity_sourcesasync_step_configure_entity_sources()
263 
264  return await self.async_step_simple_optionsasync_step_simple_options()
265 
267  self, user_input: dict[str, Any] | None = None
268  ) -> ConfigFlowResult:
269  """For users without advanced settings enabled."""
270  if user_input is not None:
271  self.optionsoptions.update(user_input)
272  return await self._update_options_update_options()
273 
274  clients_to_block = {}
275 
276  for client in self.hubhub.api.clients.values():
277  clients_to_block[client.mac] = (
278  f"{client.name or client.hostname} ({client.mac})"
279  )
280 
281  return self.async_show_formasync_show_form(
282  step_id="simple_options",
283  data_schema=vol.Schema(
284  {
285  vol.Optional(
286  CONF_TRACK_CLIENTS,
287  default=self.hubhub.config.option_track_clients,
288  ): bool,
289  vol.Optional(
290  CONF_TRACK_DEVICES,
291  default=self.hubhub.config.option_track_devices,
292  ): bool,
293  vol.Optional(
294  CONF_BLOCK_CLIENT, default=self.optionsoptions[CONF_BLOCK_CLIENT]
295  ): cv.multi_select(clients_to_block),
296  }
297  ),
298  last_step=True,
299  )
300 
302  self, user_input: dict[str, Any] | None = None
303  ) -> ConfigFlowResult:
304  """Select sources for entities."""
305  if user_input is not None:
306  self.optionsoptions.update(user_input)
307  return await self.async_step_device_trackerasync_step_device_tracker()
308 
309  clients = {
310  client.mac: f"{client.name or client.hostname} ({client.mac})"
311  for client in self.hubhub.api.clients.values()
312  }
313  clients |= {
314  mac: f"Unknown ({mac})"
315  for mac in self.optionsoptions.get(CONF_CLIENT_SOURCE, [])
316  if mac not in clients
317  }
318 
319  return self.async_show_formasync_show_form(
320  step_id="configure_entity_sources",
321  data_schema=vol.Schema(
322  {
323  vol.Optional(
324  CONF_CLIENT_SOURCE,
325  default=self.optionsoptions.get(CONF_CLIENT_SOURCE, []),
326  ): cv.multi_select(
327  dict(sorted(clients.items(), key=operator.itemgetter(1)))
328  ),
329  }
330  ),
331  last_step=False,
332  )
333 
335  self, user_input: dict[str, Any] | None = None
336  ) -> ConfigFlowResult:
337  """Manage the device tracker options."""
338  if user_input is not None:
339  self.optionsoptions.update(user_input)
340  return await self.async_step_client_controlasync_step_client_control()
341 
342  ssids = (
343  {wlan.name for wlan in self.hubhub.api.wlans.values()}
344  | {
345  f"{wlan.name}{wlan.name_combine_suffix}"
346  for wlan in self.hubhub.api.wlans.values()
347  if not wlan.name_combine_enabled
348  and wlan.name_combine_suffix is not None
349  }
350  | {
351  wlan["name"]
352  for ap in self.hubhub.api.devices.values()
353  for wlan in ap.wlan_overrides
354  if "name" in wlan
355  }
356  )
357  ssid_filter = {ssid: ssid for ssid in sorted(ssids)}
358 
359  selected_ssids_to_filter = [
360  ssid for ssid in self.hubhub.config.option_ssid_filter if ssid in ssid_filter
361  ]
362 
363  return self.async_show_formasync_show_form(
364  step_id="device_tracker",
365  data_schema=vol.Schema(
366  {
367  vol.Optional(
368  CONF_TRACK_CLIENTS,
369  default=self.hubhub.config.option_track_clients,
370  ): bool,
371  vol.Optional(
372  CONF_TRACK_WIRED_CLIENTS,
373  default=self.hubhub.config.option_track_wired_clients,
374  ): bool,
375  vol.Optional(
376  CONF_TRACK_DEVICES,
377  default=self.hubhub.config.option_track_devices,
378  ): bool,
379  vol.Optional(
380  CONF_SSID_FILTER, default=selected_ssids_to_filter
381  ): cv.multi_select(ssid_filter),
382  vol.Optional(
383  CONF_DETECTION_TIME,
384  default=int(
385  self.hubhub.config.option_detection_time.total_seconds()
386  ),
387  ): int,
388  vol.Optional(
389  CONF_IGNORE_WIRED_BUG,
390  default=self.hubhub.config.option_ignore_wired_bug,
391  ): bool,
392  }
393  ),
394  last_step=False,
395  )
396 
398  self, user_input: dict[str, Any] | None = None
399  ) -> ConfigFlowResult:
400  """Manage configuration of network access controlled clients."""
401  if user_input is not None:
402  self.optionsoptions.update(user_input)
403  return await self.async_step_statistics_sensorsasync_step_statistics_sensors()
404 
405  clients_to_block = {}
406 
407  for client in self.hubhub.api.clients.values():
408  clients_to_block[client.mac] = (
409  f"{client.name or client.hostname} ({client.mac})"
410  )
411 
412  selected_clients_to_block = [
413  client
414  for client in self.optionsoptions.get(CONF_BLOCK_CLIENT, [])
415  if client in clients_to_block
416  ]
417 
418  return self.async_show_formasync_show_form(
419  step_id="client_control",
420  data_schema=vol.Schema(
421  {
422  vol.Optional(
423  CONF_BLOCK_CLIENT, default=selected_clients_to_block
424  ): cv.multi_select(clients_to_block),
425  vol.Optional(
426  CONF_DPI_RESTRICTIONS,
427  default=self.optionsoptions.get(
428  CONF_DPI_RESTRICTIONS, DEFAULT_DPI_RESTRICTIONS
429  ),
430  ): bool,
431  }
432  ),
433  last_step=False,
434  )
435 
437  self, user_input: dict[str, Any] | None = None
438  ) -> ConfigFlowResult:
439  """Manage the statistics sensors options."""
440  if user_input is not None:
441  self.optionsoptions.update(user_input)
442  return await self._update_options_update_options()
443 
444  return self.async_show_formasync_show_form(
445  step_id="statistics_sensors",
446  data_schema=vol.Schema(
447  {
448  vol.Optional(
449  CONF_ALLOW_BANDWIDTH_SENSORS,
450  default=self.hubhub.config.option_allow_bandwidth_sensors,
451  ): bool,
452  vol.Optional(
453  CONF_ALLOW_UPTIME_SENSORS,
454  default=self.hubhub.config.option_allow_uptime_sensors,
455  ): bool,
456  }
457  ),
458  last_step=True,
459  )
460 
461  async def _update_options(self) -> ConfigFlowResult:
462  """Update config entry options."""
463  return self.async_create_entryasync_create_entry(title="", data=self.optionsoptions)
464 
465 
466 async def _async_discover_unifi(hass: HomeAssistant) -> str | None:
467  """Discover UniFi Network address."""
468  try:
469  return await hass.async_add_executor_job(socket.gethostbyname, "unifi")
470  except socket.gaierror:
471  return None
ConfigFlowResult async_step_site(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:154
ConfigFlowResult async_step_ssdp(self, ssdp.SsdpServiceInfo discovery_info)
Definition: config_flow.py:216
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:93
ConfigFlowResult async_step_reauth(self, Mapping[str, Any] entry_data)
Definition: config_flow.py:193
UnifiOptionsFlowHandler async_get_options_flow(UnifiConfigEntry config_entry)
Definition: config_flow.py:82
ConfigFlowResult async_step_device_tracker(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:336
None __init__(self, UnifiConfigEntry config_entry)
Definition: config_flow.py:250
ConfigFlowResult async_step_simple_options(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:268
ConfigFlowResult async_step_client_control(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:399
ConfigFlowResult async_step_configure_entity_sources(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:303
ConfigFlowResult async_step_statistics_sensors(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:438
ConfigFlowResult async_step_init(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:256
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_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
None _async_abort_entries_match(self, dict[str, Any]|None match_dict=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)
bool show_advanced_options(self)
_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)
str|None source(self)
_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
IssData update(pyiss.ISS iss)
Definition: __init__.py:33
str|None _async_discover_unifi(HomeAssistant hass)
Definition: config_flow.py:466
aiounifi.Controller get_unifi_api(HomeAssistant hass, MappingProxyType[str, Any] config)
Definition: api.py:31