Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow to configure homekit_controller."""
2 
3 from __future__ import annotations
4 
5 import logging
6 import re
7 from typing import TYPE_CHECKING, Any, Self, cast
8 
9 import aiohomekit
10 from aiohomekit import Controller, const as aiohomekit_const
11 from aiohomekit.controller.abstract import (
12  AbstractDiscovery,
13  AbstractPairing,
14  FinishPairing,
15 )
16 from aiohomekit.exceptions import AuthenticationError
17 from aiohomekit.model.categories import Categories
18 from aiohomekit.model.status_flags import StatusFlags
19 from aiohomekit.utils import domain_supported, domain_to_name, serialize_broadcast_key
20 import voluptuous as vol
21 
22 from homeassistant.components import zeroconf
23 from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
24 from homeassistant.core import callback
25 from homeassistant.data_entry_flow import AbortFlow
26 from homeassistant.helpers import device_registry as dr
27 from homeassistant.helpers.typing import VolDictType
28 
29 from .const import DOMAIN, KNOWN_DEVICES
30 from .storage import async_get_entity_storage
31 from .utils import async_get_controller
32 
33 if TYPE_CHECKING:
34  from homeassistant.components import bluetooth
35 
36 
37 HOMEKIT_DIR = ".homekit"
38 HOMEKIT_BRIDGE_DOMAIN = "homekit"
39 
40 HOMEKIT_IGNORE = [
41  # eufy Indoor Cam 2K and 2K Pan & Tilt
42  # https://github.com/home-assistant/core/issues/42307
43  "T8400",
44  "T8410",
45  # Hive Hub - vendor does not give user a pairing code
46  "HHKBridge1,1",
47 ]
48 
49 PAIRING_FILE = "pairing.json"
50 
51 PIN_FORMAT = re.compile(r"^(\d{3})-{0,1}(\d{2})-{0,1}(\d{3})$")
52 
53 _LOGGER = logging.getLogger(__name__)
54 
55 
56 BLE_DEFAULT_NAME = "Bluetooth device"
57 
58 INSECURE_CODES = {
59  "00000000",
60  "11111111",
61  "22222222",
62  "33333333",
63  "44444444",
64  "55555555",
65  "66666666",
66  "77777777",
67  "88888888",
68  "99999999",
69  "12345678",
70  "87654321",
71 }
72 
73 
74 def normalize_hkid(hkid: str) -> str:
75  """Normalize a hkid so that it is safe to compare with other normalized hkids."""
76  return hkid.lower()
77 
78 
79 def formatted_category(category: Categories) -> str:
80  """Return a human readable category name."""
81  return str(category.name).replace("_", " ").title()
82 
83 
84 def ensure_pin_format(pin: str, allow_insecure_setup_codes: Any = None) -> str:
85  """Ensure a pin code is correctly formatted.
86 
87  Ensures a pin code is in the format 111-11-111.
88  Handles codes with and without dashes.
89 
90  If incorrect code is entered, an exception is raised.
91  """
92  if not (match := PIN_FORMAT.search(pin.strip())):
93  raise aiohomekit.exceptions.MalformedPinError(f"Invalid PIN code f{pin}")
94  pin_without_dashes = "".join(match.groups())
95  if not allow_insecure_setup_codes and pin_without_dashes in INSECURE_CODES:
96  raise InsecureSetupCode(f"Invalid PIN code f{pin}")
97  return "-".join(match.groups())
98 
99 
101  """Handle a HomeKit config flow."""
102 
103  VERSION = 1
104 
105  def __init__(self) -> None:
106  """Initialize the homekit_controller flow."""
107  self.modelmodel: str | None = None
108  self.hkidhkid: str | None = None # This is always lower case
109  self.namename: str | None = None
110  self.categorycategory: Categories | None = None
111  self.devicesdevices: dict[str, AbstractDiscovery] = {}
112  self.controllercontroller: Controller | None = None
113  self.finish_pairingfinish_pairing: FinishPairing | None = None
114  self.pairingpairing = False
115  self._device_paired_device_paired = False
116 
117  async def _async_setup_controller(self) -> None:
118  """Create the controller."""
119  self.controllercontroller = await async_get_controller(self.hass)
120 
121  async def async_step_user(
122  self, user_input: dict[str, Any] | None = None
123  ) -> ConfigFlowResult:
124  """Handle a flow start."""
125  errors: dict[str, str] = {}
126 
127  if user_input is not None:
128  key = user_input["device"]
129  discovery = self.devicesdevices[key]
130  self.categorycategory = discovery.description.category
131  self.hkidhkid = discovery.description.id
132  self.modelmodel = getattr(discovery.description, "model", BLE_DEFAULT_NAME)
133  self.namename = discovery.description.name or BLE_DEFAULT_NAME
134 
135  await self.async_set_unique_idasync_set_unique_id(
136  normalize_hkid(self.hkidhkid), raise_on_progress=False
137  )
138 
139  return await self.async_step_pairasync_step_pair()
140 
141  if self.controllercontroller is None:
142  await self._async_setup_controller_async_setup_controller()
143 
144  assert self.controllercontroller
145 
146  self.devicesdevices = {}
147 
148  async for discovery in self.controllercontroller.async_discover():
149  if discovery.paired:
150  continue
151  self.devicesdevices[discovery.description.name] = discovery
152 
153  if not self.devicesdevices:
154  return self.async_abortasync_abortasync_abort(reason="no_devices")
155 
156  return self.async_show_formasync_show_formasync_show_form(
157  step_id="user",
158  errors=errors,
159  data_schema=vol.Schema(
160  {
161  vol.Required("device"): vol.In(
162  {
163  key: (
164  f"{key} ({formatted_category(discovery.description.category)})"
165  )
166  for key, discovery in self.devicesdevices.items()
167  }
168  )
169  }
170  ),
171  )
172 
173  @callback
174  def _hkid_is_homekit(self, hkid: str) -> bool:
175  """Determine if the device is a homekit bridge or accessory."""
176  dev_reg = dr.async_get(self.hass)
177  device = dev_reg.async_get_device(
178  connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(hkid))}
179  )
180 
181  if device is None:
182  return False
183 
184  for entry_id in device.config_entries:
185  entry = self.hass.config_entries.async_get_entry(entry_id)
186  if entry and entry.domain == HOMEKIT_BRIDGE_DOMAIN:
187  return True
188 
189  return False
190 
192  self, discovery_info: zeroconf.ZeroconfServiceInfo
193  ) -> ConfigFlowResult:
194  """Handle a discovered HomeKit accessory.
195 
196  This flow is triggered by the discovery component.
197  """
198  # Normalize properties from discovery
199  # homekit_python has code to do this, but not in a form we can
200  # easily use, so do the bare minimum ourselves here instead.
201  properties = {
202  key.lower(): value for (key, value) in discovery_info.properties.items()
203  }
204 
205  if zeroconf.ATTR_PROPERTIES_ID not in properties:
206  # This can happen if the TXT record is received after the PTR record
207  # we will wait for the next update in this case
208  _LOGGER.debug(
209  (
210  "HomeKit device %s: id not exposed; TXT record may have not yet"
211  " been received"
212  ),
213  properties,
214  )
215  return self.async_abortasync_abortasync_abort(reason="invalid_properties")
216 
217  # The hkid is a unique random number that looks like a pairing code.
218  # It changes if a device is factory reset.
219  hkid: str = properties[zeroconf.ATTR_PROPERTIES_ID]
220  normalized_hkid = normalize_hkid(hkid)
221  upper_case_hkid = hkid.upper()
222  status_flags = int(properties["sf"])
223  paired = not status_flags & 0x01
224 
225  # Set unique-id and error out if it's already configured
226  existing_entry = await self.async_set_unique_idasync_set_unique_id(
227  normalized_hkid, raise_on_progress=False
228  )
229  updated_ip_port = {
230  "AccessoryIP": discovery_info.host,
231  "AccessoryIPs": [
232  str(ip_addr)
233  for ip_addr in discovery_info.ip_addresses
234  if not ip_addr.is_link_local and not ip_addr.is_unspecified
235  ],
236  "AccessoryPort": discovery_info.port,
237  }
238  # If the device is already paired and known to us we should monitor c#
239  # (config_num) for changes. If it changes, we check for new entities
240  if paired and upper_case_hkid in self.hass.data.get(KNOWN_DEVICES, {}):
241  if existing_entry:
242  self.hass.config_entries.async_update_entry(
243  existing_entry, data={**existing_entry.data, **updated_ip_port}
244  )
245  return self.async_abortasync_abortasync_abort(reason="already_configured")
246 
247  # If this aiohomekit doesn't support this particular device, ignore it.
248  if not domain_supported(discovery_info.name):
249  return self.async_abortasync_abortasync_abort(reason="ignored_model")
250 
251  model = properties["md"]
252  name = domain_to_name(discovery_info.name)
253  _LOGGER.debug("Discovered device %s (%s - %s)", name, model, upper_case_hkid)
254 
255  # Device isn't paired with us or anyone else.
256  # But we have a 'complete' config entry for it - that is probably
257  # invalid. Remove it automatically if it has an accessory pairing id
258  # (which means it was paired with us at some point) and was not
259  # ignored by the user.
260  if (
261  not paired
262  and existing_entry
263  and (accessory_pairing_id := existing_entry.data.get("AccessoryPairingID"))
264  ):
265  if self.controllercontroller is None:
266  await self._async_setup_controller_async_setup_controller()
267 
268  # mypy can't see that self._async_setup_controller() always
269  # sets self.controller or throws
270  assert self.controllercontroller
271 
272  pairing = self.controllercontroller.load_pairing(
273  accessory_pairing_id, dict(existing_entry.data)
274  )
275 
276  try:
277  await pairing.list_accessories_and_characteristics()
278  except AuthenticationError:
279  _LOGGER.debug(
280  (
281  "%s (%s - %s) is unpaired. Removing invalid pairing for this"
282  " device"
283  ),
284  name,
285  model,
286  hkid,
287  )
288  await self.hass.config_entries.async_remove(existing_entry.entry_id)
289  else:
290  _LOGGER.debug(
291  (
292  "%s (%s - %s) claims to be unpaired but isn't. "
293  "It's implementation of HomeKit is defective "
294  "or a zeroconf relay is broadcasting stale data"
295  ),
296  name,
297  model,
298  hkid,
299  )
300  return self.async_abortasync_abortasync_abort(reason="already_paired")
301 
302  # Set unique-id and error out if it's already configured
303  self._abort_if_unique_id_configured_abort_if_unique_id_configured(updates=updated_ip_port)
304 
305  self.hkidhkid = normalized_hkid
306  self._device_paired_device_paired = paired
307  if self.hass.config_entries.flow.async_has_matching_flow(self):
308  raise AbortFlow("already_in_progress")
309 
310  if paired:
311  # Device is paired but not to us - ignore it
312  _LOGGER.debug("HomeKit device %s ignored as already paired", hkid)
313  return self.async_abortasync_abortasync_abort(reason="already_paired")
314 
315  # Devices in HOMEKIT_IGNORE have native local integrations - users
316  # should be encouraged to use native integration and not confused
317  # by alternative HK API.
318  if model in HOMEKIT_IGNORE:
319  return self.async_abortasync_abortasync_abort(reason="ignored_model")
320 
321  # If this is a HomeKit bridge/accessory exported
322  # by *this* HA instance ignore it.
323  if self._hkid_is_homekit_hkid_is_homekit(hkid):
324  return self.async_abortasync_abortasync_abort(reason="ignored_model")
325 
326  self.namename = name
327  self.modelmodel = model
328  self.categorycategory = Categories(int(properties.get("ci", 0)))
329 
330  # We want to show the pairing form - but don't call async_step_pair
331  # directly as it has side effects (will ask the device to show a
332  # pairing code)
333  return self._async_step_pair_show_form_async_step_pair_show_form()
334 
335  def is_matching(self, other_flow: Self) -> bool:
336  """Return True if other_flow is matching this flow."""
337  if other_flow.context.get("unique_id") == self.hkidhkid and not other_flow.pairing:
338  if self._device_paired_device_paired:
339  # If the device gets paired, we want to dismiss
340  # an existing discovery since we can no longer
341  # pair with it
342  self.hass.config_entries.flow.async_abort(other_flow.flow_id)
343  else:
344  return True
345  return False
346 
348  self, discovery_info: bluetooth.BluetoothServiceInfoBleak
349  ) -> ConfigFlowResult:
350  """Handle the bluetooth discovery step."""
351  if not aiohomekit_const.BLE_TRANSPORT_SUPPORTED:
352  return self.async_abortasync_abortasync_abort(reason="ignored_model")
353 
354  # Late imports in case BLE is not available
355  # pylint: disable-next=import-outside-toplevel
356  from aiohomekit.controller.ble.discovery import BleDiscovery
357 
358  # pylint: disable-next=import-outside-toplevel
359  from aiohomekit.controller.ble.manufacturer_data import HomeKitAdvertisement
360 
361  mfr_data = discovery_info.manufacturer_data
362 
363  try:
364  device = HomeKitAdvertisement.from_manufacturer_data(
365  discovery_info.name, discovery_info.address, mfr_data
366  )
367  except ValueError:
368  return self.async_abortasync_abortasync_abort(reason="ignored_model")
369 
370  await self.async_set_unique_idasync_set_unique_id(normalize_hkid(device.id))
371  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
372 
373  if not (device.status_flags & StatusFlags.UNPAIRED):
374  return self.async_abortasync_abortasync_abort(reason="already_paired")
375 
376  if self.controllercontroller is None:
377  await self._async_setup_controller_async_setup_controller()
378  assert self.controllercontroller is not None
379 
380  try:
381  discovery = await self.controllercontroller.async_find(device.id)
382  except aiohomekit.AccessoryNotFoundError:
383  return self.async_abortasync_abortasync_abort(reason="accessory_not_found_error")
384 
385  if TYPE_CHECKING:
386  discovery = cast(BleDiscovery, discovery)
387 
388  self.namename = discovery.description.name
389  self.modelmodel = BLE_DEFAULT_NAME
390  self.categorycategory = discovery.description.category
391  self.hkidhkid = discovery.description.id
392 
393  return self._async_step_pair_show_form_async_step_pair_show_form()
394 
395  async def async_step_pair(
396  self, pair_info: dict[str, Any] | None = None
397  ) -> ConfigFlowResult:
398  """Pair with a new HomeKit accessory."""
399  # If async_step_pair is called with no pairing code then we do the M1
400  # phase of pairing. If this is successful the device enters pairing
401  # mode.
402 
403  # If it doesn't have a screen then the pin is static.
404 
405  # If it has a display it will display a pin on that display. In
406  # this case the code is random. So we have to call the async_start_pairing
407  # API before the user can enter a pin. But equally we don't want to
408  # call async_start_pairing when the device is discovered, only when they
409  # click on 'Configure' in the UI.
410 
411  # async_start_pairing will make the device show its pin and return a
412  # callable. We call the callable with the pin that the user has typed
413  # in.
414 
415  # Should never call this step without setting self.hkid
416  assert self.hkidhkid
417  description_placeholders = {}
418 
419  errors = {}
420 
421  if self.controllercontroller is None:
422  await self._async_setup_controller_async_setup_controller()
423 
424  assert self.controllercontroller
425 
426  if pair_info and self.finish_pairingfinish_pairing:
427  self.pairingpairing = True
428  code = pair_info["pairing_code"]
429  try:
430  code = ensure_pin_format(
431  code,
432  allow_insecure_setup_codes=pair_info.get(
433  "allow_insecure_setup_codes"
434  ),
435  )
436  pairing = await self.finish_pairingfinish_pairing(code)
437  return await self._entry_from_accessory_entry_from_accessory(pairing)
438  except aiohomekit.exceptions.MalformedPinError:
439  # Library claimed pin was invalid before even making an API call
440  errors["pairing_code"] = "authentication_error"
441  except aiohomekit.AuthenticationError:
442  # PairSetup M4 - SRP proof failed
443  # PairSetup M6 - Ed25519 signature verification failed
444  # PairVerify M4 - Decryption failed
445  # PairVerify M4 - Device not recognised
446  # PairVerify M4 - Ed25519 signature verification failed
447  errors["pairing_code"] = "authentication_error"
448  self.finish_pairingfinish_pairing = None
449  except aiohomekit.UnknownError:
450  # An error occurred on the device whilst performing this
451  # operation.
452  errors["pairing_code"] = "unknown_error"
453  self.finish_pairingfinish_pairing = None
454  except aiohomekit.MaxPeersError:
455  # The device can't pair with any more accessories.
456  errors["pairing_code"] = "max_peers_error"
457  self.finish_pairingfinish_pairing = None
458  except aiohomekit.AccessoryNotFoundError:
459  # Can no longer find the device on the network
460  return self.async_abortasync_abortasync_abort(reason="accessory_not_found_error")
461  except InsecureSetupCode:
462  errors["pairing_code"] = "insecure_setup_code"
463  except Exception as err:
464  _LOGGER.exception("Pairing attempt failed with an unhandled exception")
465  self.finish_pairingfinish_pairing = None
466  errors["pairing_code"] = "pairing_failed"
467  description_placeholders["error"] = str(err)
468 
469  if not self.finish_pairingfinish_pairing:
470  # Its possible that the first try may have been busy so
471  # we always check to see if self.finish_paring has been
472  # set.
473  try:
474  discovery = await self.controllercontroller.async_find(self.hkidhkid)
475  self.finish_pairingfinish_pairing = await discovery.async_start_pairing(self.hkidhkid)
476 
477  except aiohomekit.BusyError:
478  # Already performing a pair setup operation with a different
479  # controller
480  return await self.async_step_busy_errorasync_step_busy_error()
481  except aiohomekit.MaxTriesError:
482  # The accessory has received more than 100 unsuccessful auth
483  # attempts.
484  return await self.async_step_max_tries_errorasync_step_max_tries_error()
485  except aiohomekit.UnavailableError:
486  # The accessory is already paired - cannot try to pair again.
487  return self.async_abortasync_abortasync_abort(reason="already_paired")
488  except aiohomekit.AccessoryNotFoundError:
489  # Can no longer find the device on the network
490  return self.async_abortasync_abortasync_abort(reason="accessory_not_found_error")
491  except IndexError:
492  # TLV error, usually not in pairing mode
493  _LOGGER.exception("Pairing communication failed")
494  return await self.async_step_protocol_errorasync_step_protocol_error()
495  except Exception as err:
496  _LOGGER.exception("Pairing attempt failed with an unhandled exception")
497  errors["pairing_code"] = "pairing_failed"
498  description_placeholders["error"] = str(err)
499 
500  return self._async_step_pair_show_form_async_step_pair_show_form(errors, description_placeholders)
501 
503  self, user_input: dict[str, Any] | None = None
504  ) -> ConfigFlowResult:
505  """Retry pairing after the accessory is busy."""
506  if user_input is not None:
507  return await self.async_step_pairasync_step_pair()
508 
509  return self.async_show_formasync_show_formasync_show_form(step_id="busy_error")
510 
512  self, user_input: dict[str, Any] | None = None
513  ) -> ConfigFlowResult:
514  """Retry pairing after the accessory has reached max tries."""
515  if user_input is not None:
516  return await self.async_step_pairasync_step_pair()
517 
518  return self.async_show_formasync_show_formasync_show_form(step_id="max_tries_error")
519 
521  self, user_input: dict[str, Any] | None = None
522  ) -> ConfigFlowResult:
523  """Retry pairing after the accessory has a protocol error."""
524  if user_input is not None:
525  return await self.async_step_pairasync_step_pair()
526 
527  return self.async_show_formasync_show_formasync_show_form(step_id="protocol_error")
528 
529  @callback
531  self,
532  errors: dict[str, str] | None = None,
533  description_placeholders: dict[str, str] | None = None,
534  ) -> ConfigFlowResult:
535  assert self.categorycategory
536 
537  placeholders = self.context["title_placeholders"] = {
538  "name": self.namename or "Homekit Device",
539  "category": formatted_category(self.categorycategory),
540  }
541 
542  schema: VolDictType = {vol.Required("pairing_code"): vol.All(str, vol.Strip)}
543  if errors and errors.get("pairing_code") == "insecure_setup_code":
544  schema[vol.Optional("allow_insecure_setup_codes")] = bool
545 
546  return self.async_show_formasync_show_formasync_show_form(
547  step_id="pair",
548  errors=errors or {},
549  description_placeholders=placeholders | (description_placeholders or {}),
550  data_schema=vol.Schema(schema),
551  )
552 
553  async def _entry_from_accessory(self, pairing: AbstractPairing) -> ConfigFlowResult:
554  """Return a config entry from an initialized bridge."""
555  # The bulk of the pairing record is stored on the config entry.
556  # A specific exception is the 'accessories' key. This is more
557  # volatile. We do cache it, but not against the config entry.
558  # So copy the pairing data and mutate the copy.
559  pairing_data = pairing.pairing_data.copy() # type: ignore[attr-defined]
560 
561  # Use the accessories data from the pairing operation if it is
562  # available. Otherwise request a fresh copy from the API.
563  # This removes the 'accessories' key from pairing_data at
564  # the same time.
565  name = await pairing.get_primary_name()
566 
567  await pairing.close()
568 
569  # Save the state of the accessories so we do not
570  # have to request them again when we setup the
571  # config entry.
572  accessories_state = pairing.accessories_state
573  entity_storage = await async_get_entity_storage(self.hass)
574  assert self.unique_idunique_id is not None
575  entity_storage.async_create_or_update_map(
576  pairing.id,
577  accessories_state.config_num,
578  accessories_state.accessories.serialize(),
579  serialize_broadcast_key(accessories_state.broadcast_key),
580  accessories_state.state_num,
581  )
582 
583  return self.async_create_entryasync_create_entryasync_create_entry(title=name, data=pairing_data)
584 
585 
586 class InsecureSetupCode(Exception):
587  """An exception for insecure trivial setup codes."""
ConfigFlowResult async_step_protocol_error(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:522
ConfigFlowResult async_step_busy_error(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:504
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:123
ConfigFlowResult async_step_max_tries_error(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:513
ConfigFlowResult async_step_pair(self, dict[str, Any]|None pair_info=None)
Definition: config_flow.py:397
ConfigFlowResult async_step_bluetooth(self, bluetooth.BluetoothServiceInfoBleak discovery_info)
Definition: config_flow.py:349
ConfigFlowResult _async_step_pair_show_form(self, dict[str, str]|None errors=None, dict[str, str]|None description_placeholders=None)
Definition: config_flow.py:534
ConfigFlowResult async_step_zeroconf(self, zeroconf.ZeroconfServiceInfo discovery_info)
Definition: config_flow.py:193
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_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)
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)
_FlowResultT async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
str ensure_pin_format(str pin, Any allow_insecure_setup_codes=None)
Definition: config_flow.py:84
EntityMapStorage async_get_entity_storage(HomeAssistant hass)
Definition: storage.py:103
None async_discover(HomeAssistant hass)
Definition: config_flow.py:82
Controller|None async_get_controller(HomeAssistant hass, str ip_address, str password, int port, bool ssl)
Definition: config_flow.py:41