1 """Config flow to configure homekit_controller."""
3 from __future__
import annotations
7 from typing
import TYPE_CHECKING, Any, Self, cast
10 from aiohomekit
import Controller, const
as aiohomekit_const
11 from aiohomekit.controller.abstract
import (
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
29 from .const
import DOMAIN, KNOWN_DEVICES
30 from .storage
import async_get_entity_storage
31 from .utils
import async_get_controller
37 HOMEKIT_DIR =
".homekit"
38 HOMEKIT_BRIDGE_DOMAIN =
"homekit"
49 PAIRING_FILE =
"pairing.json"
51 PIN_FORMAT = re.compile(
r"^(\d{3})-{0,1}(\d{2})-{0,1}(\d{3})$")
53 _LOGGER = logging.getLogger(__name__)
56 BLE_DEFAULT_NAME =
"Bluetooth device"
75 """Normalize a hkid so that it is safe to compare with other normalized hkids."""
80 """Return a human readable category name."""
81 return str(category.name).replace(
"_",
" ").title()
85 """Ensure a pin code is correctly formatted.
87 Ensures a pin code is in the format 111-11-111.
88 Handles codes with and without dashes.
90 If incorrect code is entered, an exception is raised.
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:
97 return "-".join(match.groups())
101 """Handle a HomeKit config flow."""
106 """Initialize the homekit_controller flow."""
107 self.
modelmodel: str |
None =
None
108 self.
hkidhkid: str |
None =
None
109 self.
namename: str |
None =
None
110 self.
categorycategory: Categories |
None =
None
111 self.
devicesdevices: dict[str, AbstractDiscovery] = {}
112 self.
controllercontroller: Controller |
None =
None
118 """Create the controller."""
122 self, user_input: dict[str, Any] |
None =
None
123 ) -> ConfigFlowResult:
124 """Handle a flow start."""
125 errors: dict[str, str] = {}
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
151 self.
devicesdevices[discovery.description.name] = discovery
159 data_schema=vol.Schema(
161 vol.Required(
"device"): vol.In(
164 f
"{key} ({formatted_category(discovery.description.category)})"
166 for key, discovery
in self.
devicesdevices.items()
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))}
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:
192 self, discovery_info: zeroconf.ZeroconfServiceInfo
193 ) -> ConfigFlowResult:
194 """Handle a discovered HomeKit accessory.
196 This flow is triggered by the discovery component.
202 key.lower(): value
for (key, value)
in discovery_info.properties.items()
205 if zeroconf.ATTR_PROPERTIES_ID
not in properties:
210 "HomeKit device %s: id not exposed; TXT record may have not yet"
219 hkid: str = properties[zeroconf.ATTR_PROPERTIES_ID]
221 upper_case_hkid = hkid.upper()
222 status_flags =
int(properties[
"sf"])
223 paired =
not status_flags & 0x01
227 normalized_hkid, raise_on_progress=
False
230 "AccessoryIP": discovery_info.host,
233 for ip_addr
in discovery_info.ip_addresses
234 if not ip_addr.is_link_local
and not ip_addr.is_unspecified
236 "AccessoryPort": discovery_info.port,
240 if paired
and upper_case_hkid
in self.hass.data.get(KNOWN_DEVICES, {}):
242 self.hass.config_entries.async_update_entry(
243 existing_entry, data={**existing_entry.data, **updated_ip_port}
248 if not domain_supported(discovery_info.name):
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)
263 and (accessory_pairing_id := existing_entry.data.get(
"AccessoryPairingID"))
272 pairing = self.
controllercontroller.load_pairing(
273 accessory_pairing_id,
dict(existing_entry.data)
277 await pairing.list_accessories_and_characteristics()
278 except AuthenticationError:
281 "%s (%s - %s) is unpaired. Removing invalid pairing for this"
288 await self.hass.config_entries.async_remove(existing_entry.entry_id)
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"
305 self.
hkidhkid = normalized_hkid
307 if self.hass.config_entries.flow.async_has_matching_flow(self):
312 _LOGGER.debug(
"HomeKit device %s ignored as already paired", hkid)
318 if model
in HOMEKIT_IGNORE:
327 self.
modelmodel = model
328 self.
categorycategory = Categories(
int(properties.get(
"ci", 0)))
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:
342 self.hass.config_entries.flow.async_abort(other_flow.flow_id)
348 self, discovery_info: bluetooth.BluetoothServiceInfoBleak
349 ) -> ConfigFlowResult:
350 """Handle the bluetooth discovery step."""
351 if not aiohomekit_const.BLE_TRANSPORT_SUPPORTED:
356 from aiohomekit.controller.ble.discovery
import BleDiscovery
359 from aiohomekit.controller.ble.manufacturer_data
import HomeKitAdvertisement
361 mfr_data = discovery_info.manufacturer_data
364 device = HomeKitAdvertisement.from_manufacturer_data(
365 discovery_info.name, discovery_info.address, mfr_data
373 if not (device.status_flags & StatusFlags.UNPAIRED):
381 discovery = await self.
controllercontroller.async_find(device.id)
382 except aiohomekit.AccessoryNotFoundError:
386 discovery = cast(BleDiscovery, discovery)
388 self.
namename = discovery.description.name
389 self.
modelmodel = BLE_DEFAULT_NAME
390 self.
categorycategory = discovery.description.category
391 self.
hkidhkid = discovery.description.id
396 self, pair_info: dict[str, Any] |
None =
None
397 ) -> ConfigFlowResult:
398 """Pair with a new HomeKit accessory."""
417 description_placeholders = {}
428 code = pair_info[
"pairing_code"]
432 allow_insecure_setup_codes=pair_info.get(
433 "allow_insecure_setup_codes"
438 except aiohomekit.exceptions.MalformedPinError:
440 errors[
"pairing_code"] =
"authentication_error"
441 except aiohomekit.AuthenticationError:
447 errors[
"pairing_code"] =
"authentication_error"
449 except aiohomekit.UnknownError:
452 errors[
"pairing_code"] =
"unknown_error"
454 except aiohomekit.MaxPeersError:
456 errors[
"pairing_code"] =
"max_peers_error"
458 except aiohomekit.AccessoryNotFoundError:
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")
466 errors[
"pairing_code"] =
"pairing_failed"
467 description_placeholders[
"error"] =
str(err)
474 discovery = await self.
controllercontroller.async_find(self.
hkidhkid)
475 self.
finish_pairingfinish_pairing = await discovery.async_start_pairing(self.
hkidhkid)
477 except aiohomekit.BusyError:
481 except aiohomekit.MaxTriesError:
485 except aiohomekit.UnavailableError:
488 except aiohomekit.AccessoryNotFoundError:
493 _LOGGER.exception(
"Pairing communication failed")
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)
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:
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:
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:
532 errors: dict[str, str] |
None =
None,
533 description_placeholders: dict[str, str] |
None =
None,
534 ) -> ConfigFlowResult:
537 placeholders = self.context[
"title_placeholders"] = {
538 "name": self.
namename
or "Homekit Device",
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
549 description_placeholders=placeholders | (description_placeholders
or {}),
550 data_schema=vol.Schema(schema),
554 """Return a config entry from an initialized bridge."""
559 pairing_data = pairing.pairing_data.copy()
565 name = await pairing.get_primary_name()
567 await pairing.close()
572 accessories_state = pairing.accessories_state
574 assert self.
unique_idunique_id
is not None
575 entity_storage.async_create_or_update_map(
577 accessories_state.config_num,
578 accessories_state.accessories.serialize(),
579 serialize_broadcast_key(accessories_state.broadcast_key),
580 accessories_state.state_num,
587 """An exception for insecure trivial setup codes."""
ConfigFlowResult async_step_protocol_error(self, dict[str, Any]|None user_input=None)
ConfigFlowResult async_step_busy_error(self, dict[str, Any]|None user_input=None)
ConfigFlowResult _entry_from_accessory(self, AbstractPairing pairing)
None _async_setup_controller(self)
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
ConfigFlowResult async_step_max_tries_error(self, dict[str, Any]|None user_input=None)
ConfigFlowResult async_step_pair(self, dict[str, Any]|None pair_info=None)
ConfigFlowResult async_step_bluetooth(self, bluetooth.BluetoothServiceInfoBleak discovery_info)
bool is_matching(self, Self other_flow)
ConfigFlowResult _async_step_pair_show_form(self, dict[str, str]|None errors=None, dict[str, str]|None description_placeholders=None)
ConfigFlowResult async_step_zeroconf(self, zeroconf.ZeroconfServiceInfo discovery_info)
bool _hkid_is_homekit(self, str hkid)
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)
_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 formatted_category(Categories category)
str ensure_pin_format(str pin, Any allow_insecure_setup_codes=None)
str normalize_hkid(str hkid)
EntityMapStorage async_get_entity_storage(HomeAssistant hass)
None async_discover(HomeAssistant hass)
Controller|None async_get_controller(HomeAssistant hass, str ip_address, str password, int port, bool ssl)