Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow to configure esphome component."""
2 
3 from __future__ import annotations
4 
5 from collections import OrderedDict
6 from collections.abc import Mapping
7 import json
8 import logging
9 from typing import Any, cast
10 
11 from aioesphomeapi import (
12  APIClient,
13  APIConnectionError,
14  DeviceInfo,
15  InvalidAuthAPIError,
16  InvalidEncryptionKeyAPIError,
17  RequiresEncryptionAPIError,
18  ResolveAPIError,
19 )
20 import aiohttp
21 import voluptuous as vol
22 
23 from homeassistant.components import dhcp, zeroconf
24 from homeassistant.config_entries import (
25  SOURCE_REAUTH,
26  ConfigEntry,
27  ConfigFlow,
28  ConfigFlowResult,
29  OptionsFlow,
30 )
31 from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
32 from homeassistant.core import callback
33 from homeassistant.helpers.device_registry import format_mac
34 from homeassistant.helpers.service_info.hassio import HassioServiceInfo
35 from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
36 from homeassistant.util.json import json_loads_object
37 
38 from .const import (
39  CONF_ALLOW_SERVICE_CALLS,
40  CONF_DEVICE_NAME,
41  CONF_NOISE_PSK,
42  DEFAULT_ALLOW_SERVICE_CALLS,
43  DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
44  DOMAIN,
45 )
46 from .dashboard import async_get_or_create_dashboard_manager, async_set_dashboard_info
47 
48 ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key"
49 ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk"
50 ESPHOME_URL = "https://esphome.io/"
51 _LOGGER = logging.getLogger(__name__)
52 
53 ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
54 
55 
56 class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
57  """Handle a esphome config flow."""
58 
59  VERSION = 1
60 
61  _reauth_entry: ConfigEntry
62 
63  def __init__(self) -> None:
64  """Initialize flow."""
65  self._host_host: str | None = None
66  self.__name__name: str | None = None
67  self._port_port: int | None = None
68  self._password_password: str | None = None
69  self._noise_required_noise_required: bool | None = None
70  self._noise_psk_noise_psk: str | None = None
71  self._device_info_device_info: DeviceInfo | None = None
72  # The ESPHome name as per its config
73  self._device_name_device_name: str | None = None
74 
76  self, user_input: dict[str, Any] | None = None, error: str | None = None
77  ) -> ConfigFlowResult:
78  if user_input is not None:
79  self._host_host = user_input[CONF_HOST]
80  self._port_port = user_input[CONF_PORT]
81  return await self._async_try_fetch_device_info_async_try_fetch_device_info()
82 
83  fields: dict[Any, type] = OrderedDict()
84  fields[vol.Required(CONF_HOST, default=self._host_host or vol.UNDEFINED)] = str
85  fields[vol.Optional(CONF_PORT, default=self._port_port or 6053)] = int
86 
87  errors = {}
88  if error is not None:
89  errors["base"] = error
90 
91  return self.async_show_formasync_show_formasync_show_form(
92  step_id="user",
93  data_schema=vol.Schema(fields),
94  errors=errors,
95  description_placeholders={"esphome_url": ESPHOME_URL},
96  )
97 
98  async def async_step_user(
99  self, user_input: dict[str, Any] | None = None
100  ) -> ConfigFlowResult:
101  """Handle a flow initialized by the user."""
102  return await self._async_step_user_base_async_step_user_base(user_input=user_input)
103 
104  async def async_step_reauth(
105  self, entry_data: Mapping[str, Any]
106  ) -> ConfigFlowResult:
107  """Handle a flow initialized by a reauth event."""
108  self._reauth_entry_reauth_entry = self._get_reauth_entry_get_reauth_entry()
109  self._host_host = entry_data[CONF_HOST]
110  self._port_port = entry_data[CONF_PORT]
111  self._password_password = entry_data[CONF_PASSWORD]
112  self._name_name_name_name = self._reauth_entry_reauth_entry.title
113  self._device_name_device_name = entry_data.get(CONF_DEVICE_NAME)
114 
115  # Device without encryption allows fetching device info. We can then check
116  # if the device is no longer using a password. If we did try with a password,
117  # we know setting password to empty will allow us to authenticate.
118  error = await self.fetch_device_infofetch_device_info()
119  if (
120  error is None
121  and self._password_password
122  and self._device_info_device_info
123  and not self._device_info_device_info.uses_password
124  ):
125  self._password_password = ""
126  return await self._async_authenticate_or_add_async_authenticate_or_add()
127 
128  return await self.async_step_reauth_confirmasync_step_reauth_confirm()
129 
131  self, user_input: dict[str, Any] | None = None
132  ) -> ConfigFlowResult:
133  """Handle reauthorization flow."""
134  errors = {}
135 
136  if await self._retrieve_encryption_key_from_dashboard_retrieve_encryption_key_from_dashboard():
137  error = await self.fetch_device_infofetch_device_info()
138  if error is None:
139  return await self._async_authenticate_or_add_async_authenticate_or_add()
140 
141  if user_input is not None:
142  self._noise_psk_noise_psk = user_input[CONF_NOISE_PSK]
143  error = await self.fetch_device_infofetch_device_info()
144  if error is None:
145  return await self._async_authenticate_or_add_async_authenticate_or_add()
146  errors["base"] = error
147 
148  return self.async_show_formasync_show_formasync_show_form(
149  step_id="reauth_confirm",
150  data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}),
151  errors=errors,
152  description_placeholders={"name": self._name_name_name_name},
153  )
154 
155  @property
156  def _name(self) -> str:
157  return self.__name__name or "ESPHome"
158 
159  @_name.setter
160  def _name(self, value: str) -> None:
161  self.__name__name = value
162  self.context["title_placeholders"] = {"name": self._name_name_name_name}
163 
164  async def _async_try_fetch_device_info(self) -> ConfigFlowResult:
165  """Try to fetch device info and return any errors."""
166  response: str | None
167  if self._noise_required_noise_required:
168  # If we already know we need encryption, don't try to fetch device info
169  # without encryption.
170  response = ERROR_REQUIRES_ENCRYPTION_KEY
171  else:
172  # After 2024.08, stop trying to fetch device info without encryption
173  # so we can avoid probe requests to check for password. At this point
174  # most devices should announce encryption support and password is
175  # deprecated and can be discovered by trying to connect only after they
176  # interact with the flow since it is expected to be a rare case.
177  response = await self.fetch_device_infofetch_device_info()
178 
179  if response == ERROR_REQUIRES_ENCRYPTION_KEY:
180  if not self._device_name_device_name and not self._noise_psk_noise_psk:
181  # If device name is not set we can send a zero noise psk
182  # to get the device name which will allow us to populate
183  # the device name and hopefully get the encryption key
184  # from the dashboard.
185  self._noise_psk_noise_psk = ZERO_NOISE_PSK
186  response = await self.fetch_device_infofetch_device_info()
187  self._noise_psk_noise_psk = None
188 
189  if (
190  self._device_name_device_name
191  and await self._retrieve_encryption_key_from_dashboard_retrieve_encryption_key_from_dashboard()
192  ):
193  response = await self.fetch_device_infofetch_device_info()
194 
195  # If the fetched key is invalid, unset it again.
196  if response == ERROR_INVALID_ENCRYPTION_KEY:
197  self._noise_psk_noise_psk = None
198  response = ERROR_REQUIRES_ENCRYPTION_KEY
199 
200  if response == ERROR_REQUIRES_ENCRYPTION_KEY:
201  return await self.async_step_encryption_keyasync_step_encryption_key()
202  if response is not None:
203  return await self._async_step_user_base_async_step_user_base(error=response)
204  return await self._async_authenticate_or_add_async_authenticate_or_add()
205 
206  async def _async_authenticate_or_add(self) -> ConfigFlowResult:
207  # Only show authentication step if device uses password
208  assert self._device_info_device_info is not None
209  if self._device_info_device_info.uses_password:
210  return await self.async_step_authenticateasync_step_authenticate()
211 
212  self._password_password = ""
213  return self._async_get_entry_async_get_entry()
214 
216  self, user_input: dict[str, Any] | None = None
217  ) -> ConfigFlowResult:
218  """Handle user-confirmation of discovered node."""
219  if user_input is not None:
220  return await self._async_try_fetch_device_info_async_try_fetch_device_info()
221  return self.async_show_formasync_show_formasync_show_form(
222  step_id="discovery_confirm", description_placeholders={"name": self._name_name_name_name}
223  )
224 
226  self, discovery_info: zeroconf.ZeroconfServiceInfo
227  ) -> ConfigFlowResult:
228  """Handle zeroconf discovery."""
229  mac_address: str | None = discovery_info.properties.get("mac")
230 
231  # Mac address was added in Sept 20, 2021.
232  # https://github.com/esphome/esphome/pull/2303
233  if mac_address is None:
234  return self.async_abortasync_abortasync_abort(reason="mdns_missing_mac")
235 
236  # mac address is lowercase and without :, normalize it
237  mac_address = format_mac(mac_address)
238 
239  # Hostname is format: livingroom.local.
240  device_name = discovery_info.hostname.removesuffix(".local.")
241 
242  self._name_name_name_name = discovery_info.properties.get("friendly_name", device_name)
243  self._device_name_device_name = device_name
244  self._host_host = discovery_info.host
245  self._port_port = discovery_info.port
246  self._noise_required_noise_required = bool(discovery_info.properties.get("api_encryption"))
247 
248  # Check if already configured
249  await self.async_set_unique_idasync_set_unique_id(mac_address)
250  self._abort_if_unique_id_configured_abort_if_unique_id_configured(
251  updates={CONF_HOST: self._host_host, CONF_PORT: self._port_port}
252  )
253 
254  return await self.async_step_discovery_confirmasync_step_discovery_confirm()
255 
256  async def async_step_mqtt(
257  self, discovery_info: MqttServiceInfo
258  ) -> ConfigFlowResult:
259  """Handle MQTT discovery."""
260  if not discovery_info.payload:
261  return self.async_abortasync_abortasync_abort(reason="mqtt_missing_payload")
262 
263  device_info = json_loads_object(discovery_info.payload)
264  if "mac" not in device_info:
265  return self.async_abortasync_abortasync_abort(reason="mqtt_missing_mac")
266 
267  # there will be no port if the API is not enabled
268  if "port" not in device_info:
269  return self.async_abortasync_abortasync_abort(reason="mqtt_missing_api")
270 
271  if "ip" not in device_info:
272  return self.async_abortasync_abortasync_abort(reason="mqtt_missing_ip")
273 
274  # mac address is lowercase and without :, normalize it
275  unformatted_mac = cast(str, device_info["mac"])
276  mac_address = format_mac(unformatted_mac)
277 
278  device_name = cast(str, device_info["name"])
279 
280  self._device_name_device_name = device_name
281  self._name_name_name_name = cast(str, device_info.get("friendly_name", device_name))
282  self._host_host = cast(str, device_info["ip"])
283  self._port_port = cast(int, device_info["port"])
284 
285  self._noise_required_noise_required = "api_encryption" in device_info
286 
287  # Check if already configured
288  await self.async_set_unique_idasync_set_unique_id(mac_address)
289  self._abort_if_unique_id_configured_abort_if_unique_id_configured(
290  updates={CONF_HOST: self._host_host, CONF_PORT: self._port_port}
291  )
292 
293  return await self.async_step_discovery_confirmasync_step_discovery_confirm()
294 
295  async def async_step_dhcp(
296  self, discovery_info: dhcp.DhcpServiceInfo
297  ) -> ConfigFlowResult:
298  """Handle DHCP discovery."""
299  await self.async_set_unique_idasync_set_unique_id(format_mac(discovery_info.macaddress))
300  self._abort_if_unique_id_configured_abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip})
301  # This should never happen since we only listen to DHCP requests
302  # for configured devices.
303  return self.async_abortasync_abortasync_abort(reason="already_configured")
304 
305  async def async_step_hassio(
306  self, discovery_info: HassioServiceInfo
307  ) -> ConfigFlowResult:
308  """Handle Supervisor service discovery."""
310  self.hass,
311  discovery_info.slug,
312  discovery_info.config["host"],
313  discovery_info.config["port"],
314  )
315  return self.async_abortasync_abortasync_abort(reason="service_received")
316 
317  @callback
318  def _async_get_entry(self) -> ConfigFlowResult:
319  config_data = {
320  CONF_HOST: self._host_host,
321  CONF_PORT: self._port_port,
322  # The API uses protobuf, so empty string denotes absence
323  CONF_PASSWORD: self._password_password or "",
324  CONF_NOISE_PSK: self._noise_psk_noise_psk or "",
325  CONF_DEVICE_NAME: self._device_name_device_name,
326  }
327  config_options = {
328  CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
329  }
330  if self.sourcesourcesourcesource == SOURCE_REAUTH:
331  return self.async_update_reload_and_abortasync_update_reload_and_abort(
332  self._reauth_entry_reauth_entry, data=self._reauth_entry_reauth_entry.data | config_data
333  )
334 
335  assert self._name_name_name_name is not None
336  return self.async_create_entryasync_create_entryasync_create_entry(
337  title=self._name_name_name_name,
338  data=config_data,
339  options=config_options,
340  )
341 
343  self, user_input: dict[str, Any] | None = None
344  ) -> ConfigFlowResult:
345  """Handle getting psk for transport encryption."""
346  errors = {}
347  if user_input is not None:
348  self._noise_psk_noise_psk = user_input[CONF_NOISE_PSK]
349  error = await self.fetch_device_infofetch_device_info()
350  if error is None:
351  return await self._async_authenticate_or_add_async_authenticate_or_add()
352  errors["base"] = error
353 
354  return self.async_show_formasync_show_formasync_show_form(
355  step_id="encryption_key",
356  data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}),
357  errors=errors,
358  description_placeholders={"name": self._name_name_name_name},
359  )
360 
362  self, user_input: dict[str, Any] | None = None, error: str | None = None
363  ) -> ConfigFlowResult:
364  """Handle getting password for authentication."""
365  if user_input is not None:
366  self._password_password = user_input[CONF_PASSWORD]
367  error = await self.try_logintry_login()
368  if error:
369  return await self.async_step_authenticateasync_step_authenticate(error=error)
370  return self._async_get_entry_async_get_entry()
371 
372  errors = {}
373  if error is not None:
374  errors["base"] = error
375 
376  return self.async_show_formasync_show_formasync_show_form(
377  step_id="authenticate",
378  data_schema=vol.Schema({vol.Required("password"): str}),
379  description_placeholders={"name": self._name_name_name_name},
380  errors=errors,
381  )
382 
383  async def fetch_device_info(self) -> str | None:
384  """Fetch device info from API and return any errors."""
385  zeroconf_instance = await zeroconf.async_get_instance(self.hass)
386  assert self._host_host is not None
387  assert self._port_port is not None
388  cli = APIClient(
389  self._host_host,
390  self._port_port,
391  "",
392  zeroconf_instance=zeroconf_instance,
393  noise_psk=self._noise_psk_noise_psk,
394  )
395 
396  try:
397  await cli.connect()
398  self._device_info_device_info = await cli.device_info()
399  except RequiresEncryptionAPIError:
400  return ERROR_REQUIRES_ENCRYPTION_KEY
401  except InvalidEncryptionKeyAPIError as ex:
402  if ex.received_name:
403  self._device_name_device_name = ex.received_name
404  self._name_name_name_name = ex.received_name
405  return ERROR_INVALID_ENCRYPTION_KEY
406  except ResolveAPIError:
407  return "resolve_error"
408  except APIConnectionError:
409  return "connection_error"
410  finally:
411  await cli.disconnect(force=True)
412 
413  self._name_name_name_name = self._device_info_device_info.friendly_name or self._device_info_device_info.name
414  self._device_name_device_name = self._device_info_device_info.name
415  mac_address = format_mac(self._device_info_device_info.mac_address)
416  await self.async_set_unique_idasync_set_unique_id(mac_address, raise_on_progress=False)
417  if self.sourcesourcesourcesource != SOURCE_REAUTH:
418  self._abort_if_unique_id_configured_abort_if_unique_id_configured(
419  updates={CONF_HOST: self._host_host, CONF_PORT: self._port_port}
420  )
421 
422  return None
423 
424  async def try_login(self) -> str | None:
425  """Try logging in to device and return any errors."""
426  zeroconf_instance = await zeroconf.async_get_instance(self.hass)
427  assert self._host_host is not None
428  assert self._port_port is not None
429  cli = APIClient(
430  self._host_host,
431  self._port_port,
432  self._password_password,
433  zeroconf_instance=zeroconf_instance,
434  noise_psk=self._noise_psk_noise_psk,
435  )
436 
437  try:
438  await cli.connect(login=True)
439  except InvalidAuthAPIError:
440  return "invalid_auth"
441  except APIConnectionError:
442  return "connection_error"
443  finally:
444  await cli.disconnect(force=True)
445 
446  return None
447 
449  """Try to retrieve the encryption key from the dashboard.
450 
451  Return boolean if a key was retrieved.
452  """
453  if (
454  self._device_name_device_name is None
455  or (manager := await async_get_or_create_dashboard_manager(self.hass))
456  is None
457  or (dashboard := manager.async_get()) is None
458  ):
459  return False
460 
461  await dashboard.async_request_refresh()
462  if not dashboard.last_update_success:
463  return False
464 
465  device = dashboard.data.get(self._device_name_device_name)
466 
467  if device is None:
468  return False
469 
470  try:
471  noise_psk = await dashboard.api.get_encryption_key(device["configuration"])
472  except aiohttp.ClientError as err:
473  _LOGGER.error("Error talking to the dashboard: %s", err)
474  return False
475  except json.JSONDecodeError:
476  _LOGGER.exception("Error parsing response from dashboard")
477  return False
478 
479  self._noise_psk_noise_psk = noise_psk
480  return True
481 
482  @staticmethod
483  @callback
485  config_entry: ConfigEntry,
486  ) -> OptionsFlowHandler:
487  """Get the options flow for this handler."""
488  return OptionsFlowHandler()
489 
490 
492  """Handle a option flow for esphome."""
493 
494  async def async_step_init(
495  self, user_input: dict[str, Any] | None = None
496  ) -> ConfigFlowResult:
497  """Handle options flow."""
498  if user_input is not None:
499  return self.async_create_entryasync_create_entry(title="", data=user_input)
500 
501  data_schema = vol.Schema(
502  {
503  vol.Required(
504  CONF_ALLOW_SERVICE_CALLS,
505  default=self.config_entryconfig_entryconfig_entry.options.get(
506  CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS
507  ),
508  ): bool,
509  }
510  )
511  return self.async_show_formasync_show_form(step_id="init", data_schema=data_schema)
ConfigFlowResult async_step_reauth_confirm(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:132
ConfigFlowResult async_step_reauth(self, Mapping[str, Any] entry_data)
Definition: config_flow.py:106
ConfigFlowResult _async_step_user_base(self, dict[str, Any]|None user_input=None, str|None error=None)
Definition: config_flow.py:77
ConfigFlowResult async_step_zeroconf(self, zeroconf.ZeroconfServiceInfo discovery_info)
Definition: config_flow.py:227
ConfigFlowResult async_step_mqtt(self, MqttServiceInfo discovery_info)
Definition: config_flow.py:258
OptionsFlowHandler async_get_options_flow(ConfigEntry config_entry)
Definition: config_flow.py:486
ConfigFlowResult async_step_dhcp(self, dhcp.DhcpServiceInfo discovery_info)
Definition: config_flow.py:297
ConfigFlowResult async_step_encryption_key(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:344
ConfigFlowResult async_step_discovery_confirm(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:217
ConfigFlowResult async_step_authenticate(self, dict[str, Any]|None user_input=None, str|None error=None)
Definition: config_flow.py:363
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:100
ConfigFlowResult async_step_hassio(self, HassioServiceInfo discovery_info)
Definition: config_flow.py:307
ConfigFlowResult async_step_init(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:496
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_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)
ESPHomeDashboardManager async_get_or_create_dashboard_manager(HomeAssistant hass)
Definition: dashboard.py:39
None async_set_dashboard_info(HomeAssistant hass, str addon_slug, str host, int port)
Definition: dashboard.py:149
JsonObjectType json_loads_object(bytes|bytearray|memoryview|str obj)
Definition: json.py:54