Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow for TP-Link."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Mapping
6 import logging
7 from typing import TYPE_CHECKING, Any, Self
8 
9 from kasa import (
10  AuthenticationError,
11  Credentials,
12  Device,
13  DeviceConfig,
14  Discover,
15  KasaException,
16  TimeoutError,
17 )
18 import voluptuous as vol
19 
20 from homeassistant.components import dhcp
21 from homeassistant.config_entries import (
22  SOURCE_REAUTH,
23  ConfigEntry,
24  ConfigEntryState,
25  ConfigFlow,
26  ConfigFlowResult,
27 )
28 from homeassistant.const import (
29  CONF_ALIAS,
30  CONF_DEVICE,
31  CONF_HOST,
32  CONF_MAC,
33  CONF_MODEL,
34  CONF_PASSWORD,
35  CONF_PORT,
36  CONF_USERNAME,
37 )
38 from homeassistant.core import callback
39 from homeassistant.helpers import device_registry as dr
40 from homeassistant.helpers.typing import DiscoveryInfoType
41 
42 from . import (
43  async_discover_devices,
44  create_async_tplink_clientsession,
45  get_credentials,
46  mac_alias,
47  set_credentials,
48 )
49 from .const import (
50  CONF_AES_KEYS,
51  CONF_CONFIG_ENTRY_MINOR_VERSION,
52  CONF_CONNECTION_PARAMETERS,
53  CONF_CREDENTIALS_HASH,
54  CONF_USES_HTTP,
55  CONNECT_TIMEOUT,
56  DOMAIN,
57 )
58 
59 _LOGGER = logging.getLogger(__name__)
60 
61 STEP_AUTH_DATA_SCHEMA = vol.Schema(
62  {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
63 )
64 
65 
66 class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
67  """Handle a config flow for tplink."""
68 
69  VERSION = 1
70  MINOR_VERSION = CONF_CONFIG_ENTRY_MINOR_VERSION
71 
72  host: str | None = None
73  port: int | None = None
74 
75  def __init__(self) -> None:
76  """Initialize the config flow."""
77  self._discovered_devices_discovered_devices: dict[str, Device] = {}
78  self._discovered_device_discovered_device: Device | None = None
79 
80  async def async_step_dhcp(
81  self, discovery_info: dhcp.DhcpServiceInfo
82  ) -> ConfigFlowResult:
83  """Handle discovery via dhcp."""
84  return await self._async_handle_discovery_async_handle_discovery(
85  discovery_info.ip, dr.format_mac(discovery_info.macaddress)
86  )
87 
89  self, discovery_info: DiscoveryInfoType
90  ) -> ConfigFlowResult:
91  """Handle integration discovery."""
92  return await self._async_handle_discovery_async_handle_discovery(
93  discovery_info[CONF_HOST],
94  discovery_info[CONF_MAC],
95  discovery_info[CONF_DEVICE],
96  )
97 
98  @callback
100  self, entry: ConfigEntry, host: str, device: Device | None
101  ) -> dict | None:
102  """Return updates if the host or device config has changed."""
103  entry_data = entry.data
104  updates: dict[str, Any] = {}
105  new_connection_params = False
106  if entry_data[CONF_HOST] != host:
107  updates[CONF_HOST] = host
108  if device:
109  device_conn_params_dict = device.config.connection_type.to_dict()
110  entry_conn_params_dict = entry_data.get(CONF_CONNECTION_PARAMETERS)
111  if device_conn_params_dict != entry_conn_params_dict:
112  new_connection_params = True
113  updates[CONF_CONNECTION_PARAMETERS] = device_conn_params_dict
114  updates[CONF_USES_HTTP] = device.config.uses_http
115  if not updates:
116  return None
117  updates = {**entry.data, **updates}
118  # If the connection parameters have changed the credentials_hash will be invalid.
119  if new_connection_params:
120  updates.pop(CONF_CREDENTIALS_HASH, None)
121  _LOGGER.debug(
122  "Connection type changed for %s from %s to: %s",
123  host,
124  entry_conn_params_dict,
125  device_conn_params_dict,
126  )
127  return updates
128 
129  @callback
131  self, entry: ConfigEntry, host: str, device: Device | None
132  ) -> ConfigFlowResult | None:
133  """If discovery encounters a device that is in SETUP_ERROR or SETUP_RETRY update the device config."""
134  if entry.state not in (
135  ConfigEntryState.SETUP_ERROR,
136  ConfigEntryState.SETUP_RETRY,
137  ):
138  return None
139  if updates := self._get_config_updates_get_config_updates(entry, host, device):
140  return self.async_update_reload_and_abortasync_update_reload_and_abort(
141  entry,
142  data=updates,
143  reason="already_configured",
144  )
145  return None
146 
148  self, host: str, formatted_mac: str, device: Device | None = None
149  ) -> ConfigFlowResult:
150  """Handle any discovery."""
151  current_entry = await self.async_set_unique_idasync_set_unique_id(
152  formatted_mac, raise_on_progress=False
153  )
154  if current_entry and (
155  result := self._update_config_if_entry_in_setup_error_update_config_if_entry_in_setup_error(
156  current_entry, host, device
157  )
158  ):
159  return result
160  self._abort_if_unique_id_configured_abort_if_unique_id_configured(updates={CONF_HOST: host})
161  self._async_abort_entries_match_async_abort_entries_match({CONF_HOST: host})
162  self.hosthost = host
163  if self.hass.config_entries.flow.async_has_matching_flow(self):
164  return self.async_abortasync_abortasync_abort(reason="already_in_progress")
165  credentials = await get_credentials(self.hass)
166  try:
167  # If integration discovery there will be a device or None for dhcp
168  if device:
169  self._discovered_device_discovered_device = device
170  await self._async_try_connect_async_try_connect(device, credentials)
171  else:
172  await self._async_try_discover_and_update_async_try_discover_and_update(
173  host,
174  credentials,
175  raise_on_progress=True,
176  raise_on_timeout=True,
177  )
178  except AuthenticationError:
179  return await self.async_step_discovery_auth_confirmasync_step_discovery_auth_confirm()
180  except KasaException:
181  return self.async_abortasync_abortasync_abort(reason="cannot_connect")
182 
183  return await self.async_step_discovery_confirmasync_step_discovery_confirm()
184 
185  def is_matching(self, other_flow: Self) -> bool:
186  """Return True if other_flow is matching this flow."""
187  return other_flow.host == self.hosthost
188 
190  self, user_input: dict[str, Any] | None = None
191  ) -> ConfigFlowResult:
192  """Dialog that informs the user that auth is required."""
193  assert self._discovered_device_discovered_device is not None
194  errors = {}
195 
196  credentials = await get_credentials(self.hass)
197  if credentials and credentials != self._discovered_device_discovered_device.config.credentials:
198  try:
199  device = await self._async_try_connect_async_try_connect(
200  self._discovered_device_discovered_device, credentials
201  )
202  except AuthenticationError:
203  pass # Authentication exceptions should continue to the rest of the step
204  else:
205  self._discovered_device_discovered_device = device
206  return await self.async_step_discovery_confirmasync_step_discovery_confirm()
207 
208  placeholders = self._async_make_placeholders_from_discovery_async_make_placeholders_from_discovery()
209 
210  if user_input:
211  username = user_input[CONF_USERNAME]
212  password = user_input[CONF_PASSWORD]
213  credentials = Credentials(username, password)
214  try:
215  device = await self._async_try_connect_async_try_connect(
216  self._discovered_device_discovered_device, credentials
217  )
218  except AuthenticationError as ex:
219  errors[CONF_PASSWORD] = "invalid_auth"
220  placeholders["error"] = str(ex)
221  except KasaException as ex:
222  errors["base"] = "cannot_connect"
223  placeholders["error"] = str(ex)
224  else:
225  self._discovered_device_discovered_device = device
226  await set_credentials(self.hass, username, password)
227  self.hass.async_create_task(
228  self._async_reload_requires_auth_entries_async_reload_requires_auth_entries(), eager_start=False
229  )
230  return self._async_create_entry_from_device_async_create_entry_from_device(self._discovered_device_discovered_device)
231 
232  self.context["title_placeholders"] = placeholders
233  return self.async_show_formasync_show_formasync_show_form(
234  step_id="discovery_auth_confirm",
235  data_schema=STEP_AUTH_DATA_SCHEMA,
236  errors=errors,
237  description_placeholders=placeholders,
238  )
239 
240  def _async_make_placeholders_from_discovery(self) -> dict[str, str]:
241  """Make placeholders for the discovery steps."""
242  discovered_device = self._discovered_device_discovered_device
243  assert discovered_device is not None
244  return {
245  "name": discovered_device.alias or mac_alias(discovered_device.mac),
246  "model": discovered_device.model,
247  "host": discovered_device.host,
248  }
249 
251  self, user_input: dict[str, Any] | None = None
252  ) -> ConfigFlowResult:
253  """Confirm discovery."""
254  assert self._discovered_device_discovered_device is not None
255  if user_input is not None:
256  return self._async_create_entry_from_device_async_create_entry_from_device(self._discovered_device_discovered_device)
257 
258  self._set_confirm_only_set_confirm_only()
259  placeholders = self._async_make_placeholders_from_discovery_async_make_placeholders_from_discovery()
260  self.context["title_placeholders"] = placeholders
261  return self.async_show_formasync_show_formasync_show_form(
262  step_id="discovery_confirm", description_placeholders=placeholders
263  )
264 
265  @staticmethod
266  def _async_get_host_port(host_str: str) -> tuple[str, int | None]:
267  """Parse the host string for host and port."""
268  if "[" in host_str:
269  _, _, bracketed = host_str.partition("[")
270  host, _, port_str = bracketed.partition("]")
271  _, _, port_str = port_str.partition(":")
272  else:
273  host, _, port_str = host_str.partition(":")
274 
275  if not port_str:
276  return host, None
277 
278  try:
279  port = int(port_str)
280  except ValueError:
281  return host, None
282 
283  return host, port
284 
285  async def async_step_user(
286  self, user_input: dict[str, Any] | None = None
287  ) -> ConfigFlowResult:
288  """Handle the initial step."""
289  errors: dict[str, str] = {}
290  placeholders: dict[str, str] = {}
291 
292  if user_input is not None:
293  if not (host := user_input[CONF_HOST]):
294  return await self.async_step_pick_deviceasync_step_pick_device()
295 
296  host, port = self._async_get_host_port_async_get_host_port(host)
297 
298  match_dict = {CONF_HOST: host}
299  if port:
300  self.portport = port
301  match_dict[CONF_PORT] = port
302  self._async_abort_entries_match_async_abort_entries_match(match_dict)
303 
304  self.hosthost = host
305  credentials = await get_credentials(self.hass)
306  try:
307  device = await self._async_try_discover_and_update_async_try_discover_and_update(
308  host,
309  credentials,
310  raise_on_progress=False,
311  raise_on_timeout=False,
312  port=port,
313  ) or await self._async_try_connect_all_async_try_connect_all(
314  host,
315  credentials=credentials,
316  raise_on_progress=False,
317  port=port,
318  )
319  except AuthenticationError:
320  return await self.async_step_user_auth_confirmasync_step_user_auth_confirm()
321  except KasaException as ex:
322  errors["base"] = "cannot_connect"
323  placeholders["error"] = str(ex)
324  else:
325  if not device:
326  return await self.async_step_user_auth_confirmasync_step_user_auth_confirm()
327  return self._async_create_entry_from_device_async_create_entry_from_device(device)
328 
329  return self.async_show_formasync_show_formasync_show_form(
330  step_id="user",
331  data_schema=vol.Schema({vol.Optional(CONF_HOST, default=""): str}),
332  errors=errors,
333  description_placeholders=placeholders,
334  )
335 
337  self, user_input: dict[str, Any] | None = None
338  ) -> ConfigFlowResult:
339  """Dialog that informs the user that auth is required."""
340  errors: dict[str, str] = {}
341  if TYPE_CHECKING:
342  # self.host is set by async_step_user and async_step_pick_device
343  assert self.hosthost is not None
344  placeholders: dict[str, str] = {CONF_HOST: self.hosthost}
345 
346  if user_input:
347  username = user_input[CONF_USERNAME]
348  password = user_input[CONF_PASSWORD]
349  credentials = Credentials(username, password)
350  device: Device | None
351  try:
352  if self._discovered_device_discovered_device:
353  device = await self._async_try_connect_async_try_connect(
354  self._discovered_device_discovered_device, credentials
355  )
356  else:
357  device = await self._async_try_connect_all_async_try_connect_all(
358  self.hosthost,
359  credentials=credentials,
360  raise_on_progress=False,
361  port=self.portport,
362  )
363  except AuthenticationError as ex:
364  errors[CONF_PASSWORD] = "invalid_auth"
365  placeholders["error"] = str(ex)
366  except KasaException as ex:
367  errors["base"] = "cannot_connect"
368  placeholders["error"] = str(ex)
369  else:
370  if not device:
371  errors["base"] = "cannot_connect"
372  placeholders["error"] = "try_connect_all failed"
373  else:
374  await set_credentials(self.hass, username, password)
375  self.hass.async_create_task(
376  self._async_reload_requires_auth_entries_async_reload_requires_auth_entries(), eager_start=False
377  )
378  return self._async_create_entry_from_device_async_create_entry_from_device(device)
379 
380  return self.async_show_formasync_show_formasync_show_form(
381  step_id="user_auth_confirm",
382  data_schema=STEP_AUTH_DATA_SCHEMA,
383  errors=errors,
384  description_placeholders=placeholders,
385  )
386 
388  self, user_input: dict[str, Any] | None = None
389  ) -> ConfigFlowResult:
390  """Handle the step to pick discovered device."""
391  if user_input is not None:
392  mac = user_input[CONF_DEVICE]
393  await self.async_set_unique_idasync_set_unique_id(mac, raise_on_progress=False)
394  self._discovered_device_discovered_device = self._discovered_devices_discovered_devices[mac]
395  self.hosthost = self._discovered_device_discovered_device.host
396  credentials = await get_credentials(self.hass)
397 
398  try:
399  device = await self._async_try_connect_async_try_connect(
400  self._discovered_device_discovered_device, credentials
401  )
402  except AuthenticationError:
403  return await self.async_step_user_auth_confirmasync_step_user_auth_confirm()
404  except KasaException:
405  return self.async_abortasync_abortasync_abort(reason="cannot_connect")
406  return self._async_create_entry_from_device_async_create_entry_from_device(device)
407 
408  configured_devices = {
409  entry.unique_id for entry in self._async_current_entries_async_current_entries()
410  }
411  self._discovered_devices_discovered_devices = await async_discover_devices(self.hass)
412  devices_name = {
413  formatted_mac: (
414  f"{device.alias or mac_alias(device.mac)} {device.model} ({device.host}) {formatted_mac}"
415  )
416  for formatted_mac, device in self._discovered_devices_discovered_devices.items()
417  if formatted_mac not in configured_devices
418  }
419  # Check if there is at least one device
420  if not devices_name:
421  return self.async_abortasync_abortasync_abort(reason="no_devices_found")
422  return self.async_show_formasync_show_formasync_show_form(
423  step_id="pick_device",
424  data_schema=vol.Schema({vol.Required(CONF_DEVICE): vol.In(devices_name)}),
425  )
426 
427  async def _async_reload_requires_auth_entries(self) -> None:
428  """Reload any in progress config flow that now have credentials."""
429  _config_entries = self.hass.config_entries
430 
431  if self.sourcesourcesourcesource == SOURCE_REAUTH:
432  await _config_entries.async_reload(self._get_reauth_entry_get_reauth_entry().entry_id)
433 
434  for flow in _config_entries.flow.async_progress_by_handler(
435  DOMAIN, include_uninitialized=True
436  ):
437  context = flow["context"]
438  if context.get("source") != SOURCE_REAUTH:
439  continue
440  entry_id: str = context["entry_id"]
441  if entry := _config_entries.async_get_entry(entry_id):
442  await _config_entries.async_reload(entry.entry_id)
443  if entry.state is ConfigEntryState.LOADED:
444  _config_entries.flow.async_abort(flow["flow_id"])
445 
446  @callback
447  def _async_create_entry_from_device(self, device: Device) -> ConfigFlowResult:
448  """Create a config entry from a smart device."""
449  # This is only ever called after a successful device update so we know that
450  # the credential_hash is correct and should be saved.
451  self._abort_if_unique_id_configured_abort_if_unique_id_configured(updates={CONF_HOST: device.host})
452  data: dict[str, Any] = {
453  CONF_HOST: device.host,
454  CONF_ALIAS: device.alias,
455  CONF_MODEL: device.model,
456  CONF_CONNECTION_PARAMETERS: device.config.connection_type.to_dict(),
457  CONF_USES_HTTP: device.config.uses_http,
458  }
459  if device.config.aes_keys:
460  data[CONF_AES_KEYS] = device.config.aes_keys
461  if device.credentials_hash:
462  data[CONF_CREDENTIALS_HASH] = device.credentials_hash
463  if port := device.config.port_override:
464  data[CONF_PORT] = port
465  return self.async_create_entryasync_create_entryasync_create_entry(
466  title=f"{device.alias} {device.model}",
467  data=data,
468  )
469 
471  self,
472  host: str,
473  credentials: Credentials | None,
474  raise_on_progress: bool,
475  *,
476  port: int | None = None,
477  ) -> Device | None:
478  """Try to connect to the device speculatively.
479 
480  The connection parameters aren't known but discovery has failed so try
481  to connect with tcp.
482  """
483  if credentials:
484  device = await Discover.try_connect_all(
485  host,
486  credentials=credentials,
487  http_client=create_async_tplink_clientsession(self.hass),
488  port=port,
489  )
490  else:
491  # This will just try the legacy protocol that doesn't require auth
492  # and doesn't use http
493  try:
494  device = await Device.connect(
495  config=DeviceConfig(host, port_override=port)
496  )
497  except Exception: # noqa: BLE001
498  return None
499  if device:
500  await self.async_set_unique_idasync_set_unique_id(
501  dr.format_mac(device.mac),
502  raise_on_progress=raise_on_progress,
503  )
504  return device
505 
507  self,
508  host: str,
509  credentials: Credentials | None,
510  raise_on_progress: bool,
511  raise_on_timeout: bool,
512  *,
513  port: int | None = None,
514  ) -> Device | None:
515  """Try to discover the device and call update.
516 
517  Will try to connect directly if discovery fails.
518  """
519  self._discovered_device_discovered_device = None
520  try:
521  self._discovered_device_discovered_device = await Discover.discover_single(
522  host,
523  credentials=credentials,
524  port=port,
525  )
526  except TimeoutError as ex:
527  if raise_on_timeout:
528  raise ex from ex
529  return None
530  if TYPE_CHECKING:
531  assert self._discovered_device_discovered_device
532  await self.async_set_unique_idasync_set_unique_id(
533  dr.format_mac(self._discovered_device_discovered_device.mac),
534  raise_on_progress=raise_on_progress,
535  )
536  if self._discovered_device_discovered_device.config.uses_http:
537  self._discovered_device_discovered_device.config.http_client = (
539  )
540  await self._discovered_device_discovered_device.update()
541  return self._discovered_device_discovered_device
542 
544  self,
545  discovered_device: Device,
546  credentials: Credentials | None,
547  ) -> Device:
548  """Try to connect."""
549  self._async_abort_entries_match_async_abort_entries_match({CONF_HOST: discovered_device.host})
550 
551  config = discovered_device.config
552  if credentials:
553  config.credentials = credentials
554  config.timeout = CONNECT_TIMEOUT
555  if config.uses_http:
556  config.http_client = create_async_tplink_clientsession(self.hass)
557 
558  self._discovered_device_discovered_device = await Device.connect(config=config)
559  await self.async_set_unique_idasync_set_unique_id(
560  dr.format_mac(self._discovered_device_discovered_device.mac),
561  raise_on_progress=False,
562  )
563  return self._discovered_device_discovered_device
564 
565  async def async_step_reauth(
566  self, entry_data: Mapping[str, Any]
567  ) -> ConfigFlowResult:
568  """Start the reauthentication flow if the device needs updated credentials."""
569  return await self.async_step_reauth_confirmasync_step_reauth_confirm()
570 
572  self, user_input: dict[str, Any] | None = None
573  ) -> ConfigFlowResult:
574  """Dialog that informs the user that reauth is required."""
575  errors: dict[str, str] = {}
576  placeholders: dict[str, str] = {}
577  reauth_entry = self._get_reauth_entry_get_reauth_entry()
578  entry_data = reauth_entry.data
579  host = entry_data[CONF_HOST]
580  port = entry_data.get(CONF_PORT)
581 
582  if user_input:
583  username = user_input[CONF_USERNAME]
584  password = user_input[CONF_PASSWORD]
585  credentials = Credentials(username, password)
586  try:
587  device = await self._async_try_discover_and_update_async_try_discover_and_update(
588  host,
589  credentials=credentials,
590  raise_on_progress=False,
591  raise_on_timeout=False,
592  port=port,
593  ) or await self._async_try_connect_all_async_try_connect_all(
594  host,
595  credentials=credentials,
596  raise_on_progress=False,
597  port=port,
598  )
599  except AuthenticationError as ex:
600  errors[CONF_PASSWORD] = "invalid_auth"
601  placeholders["error"] = str(ex)
602  except KasaException as ex:
603  errors["base"] = "cannot_connect"
604  placeholders["error"] = str(ex)
605  else:
606  if not device:
607  errors["base"] = "cannot_connect"
608  placeholders["error"] = "try_connect_all failed"
609  else:
610  await self.async_set_unique_idasync_set_unique_id(
611  dr.format_mac(device.mac),
612  raise_on_progress=False,
613  )
614  await set_credentials(self.hass, username, password)
615  if updates := self._get_config_updates_get_config_updates(reauth_entry, host, device):
616  self.hass.config_entries.async_update_entry(
617  reauth_entry, data=updates
618  )
619  self.hass.async_create_task(
620  self._async_reload_requires_auth_entries_async_reload_requires_auth_entries(), eager_start=False
621  )
622  return self.async_abortasync_abortasync_abort(reason="reauth_successful")
623 
624  # Old config entries will not have these values.
625  alias = entry_data.get(CONF_ALIAS) or "unknown"
626  model = entry_data.get(CONF_MODEL) or "unknown"
627 
628  placeholders.update({"name": alias, "model": model, "host": host})
629 
630  self.context["title_placeholders"] = placeholders
631  return self.async_show_formasync_show_formasync_show_form(
632  step_id="reauth_confirm",
633  data_schema=STEP_AUTH_DATA_SCHEMA,
634  errors=errors,
635  description_placeholders=placeholders,
636  )
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)
list[ConfigEntry] _async_current_entries(self, bool|None include_ignore=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)
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)
str
_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)
IssData update(pyiss.ISS iss)
Definition: __init__.py:33