ha-core/homeassistant/components/homekit_controller/config_flow.py

603 lines
23 KiB
Python

"""Config flow to configure homekit_controller."""
from __future__ import annotations
import logging
import re
from typing import TYPE_CHECKING, Any, cast
import aiohomekit
from aiohomekit import Controller, const as aiohomekit_const
from aiohomekit.controller.abstract import (
AbstractDiscovery,
AbstractPairing,
FinishPairing,
)
from aiohomekit.exceptions import AuthenticationError
from aiohomekit.model.categories import Categories
from aiohomekit.model.status_flags import StatusFlags
from aiohomekit.utils import domain_supported, domain_to_name, serialize_broadcast_key
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components import zeroconf
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import AbortFlow, FlowResult
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN, KNOWN_DEVICES
from .storage import async_get_entity_storage
from .utils import async_get_controller
if TYPE_CHECKING:
from homeassistant.components import bluetooth
HOMEKIT_DIR = ".homekit"
HOMEKIT_BRIDGE_DOMAIN = "homekit"
HOMEKIT_IGNORE = [
# eufy Indoor Cam 2K and 2K Pan & Tilt
# https://github.com/home-assistant/core/issues/42307
"T8400",
"T8410",
# Hive Hub - vendor does not give user a pairing code
"HHKBridge1,1",
]
PAIRING_FILE = "pairing.json"
PIN_FORMAT = re.compile(r"^(\d{3})-{0,1}(\d{2})-{0,1}(\d{3})$")
_LOGGER = logging.getLogger(__name__)
BLE_DEFAULT_NAME = "Bluetooth device"
INSECURE_CODES = {
"00000000",
"11111111",
"22222222",
"33333333",
"44444444",
"55555555",
"66666666",
"77777777",
"88888888",
"99999999",
"12345678",
"87654321",
}
def normalize_hkid(hkid: str) -> str:
"""Normalize a hkid so that it is safe to compare with other normalized hkids."""
return hkid.lower()
def formatted_category(category: Categories) -> str:
"""Return a human readable category name."""
return str(category.name).replace("_", " ").title()
@callback
def find_existing_host(
hass: HomeAssistant, serial: str
) -> config_entries.ConfigEntry | None:
"""Return a set of the configured hosts."""
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.data.get("AccessoryPairingID") == serial:
return entry
return None
def ensure_pin_format(pin: str, allow_insecure_setup_codes: Any = None) -> str:
"""Ensure a pin code is correctly formatted.
Ensures a pin code is in the format 111-11-111. Handles codes with and without dashes.
If incorrect code is entered, an exception is raised.
"""
if not (match := PIN_FORMAT.search(pin.strip())):
raise aiohomekit.exceptions.MalformedPinError(f"Invalid PIN code f{pin}")
pin_without_dashes = "".join(match.groups())
if not allow_insecure_setup_codes and pin_without_dashes in INSECURE_CODES:
raise InsecureSetupCode(f"Invalid PIN code f{pin}")
return "-".join(match.groups())
class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a HomeKit config flow."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the homekit_controller flow."""
self.model: str | None = None
self.hkid: str | None = None
self.name: str | None = None
self.category: Categories | None = None
self.devices: dict[str, AbstractDiscovery] = {}
self.controller: Controller | None = None
self.finish_pairing: FinishPairing | None = None
async def _async_setup_controller(self) -> None:
"""Create the controller."""
self.controller = await async_get_controller(self.hass)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow start."""
errors: dict[str, str] = {}
if user_input is not None:
key = user_input["device"]
discovery = self.devices[key]
self.category = discovery.description.category
self.hkid = discovery.description.id
self.model = getattr(discovery.description, "model", BLE_DEFAULT_NAME)
self.name = discovery.description.name or BLE_DEFAULT_NAME
await self.async_set_unique_id(
normalize_hkid(self.hkid), raise_on_progress=False
)
return await self.async_step_pair()
if self.controller is None:
await self._async_setup_controller()
assert self.controller
self.devices = {}
async for discovery in self.controller.async_discover():
if discovery.paired:
continue
self.devices[discovery.description.name] = discovery
if not self.devices:
return self.async_abort(reason="no_devices")
return self.async_show_form(
step_id="user",
errors=errors,
data_schema=vol.Schema(
{
vol.Required("device"): vol.In(
{
key: (
f"{key} ({formatted_category(discovery.description.category)})"
)
for key, discovery in self.devices.items()
}
)
}
),
)
async def async_step_unignore(self, user_input: dict[str, Any]) -> FlowResult:
"""Rediscover a previously ignored discover."""
unique_id = user_input["unique_id"]
await self.async_set_unique_id(unique_id)
if self.controller is None:
await self._async_setup_controller()
assert self.controller
try:
discovery = await self.controller.async_find(unique_id)
except aiohomekit.AccessoryNotFoundError:
return self.async_abort(reason="accessory_not_found_error")
self.name = discovery.description.name
self.model = getattr(discovery.description, "model", BLE_DEFAULT_NAME)
self.category = discovery.description.category
self.hkid = discovery.description.id
return self._async_step_pair_show_form()
async def _hkid_is_homekit(self, hkid: str) -> bool:
"""Determine if the device is a homekit bridge or accessory."""
dev_reg = dr.async_get(self.hass)
device = dev_reg.async_get_device(
identifiers=set(), connections={(dr.CONNECTION_NETWORK_MAC, hkid)}
)
if device is None:
return False
for entry_id in device.config_entries:
entry = self.hass.config_entries.async_get_entry(entry_id)
if entry and entry.domain == HOMEKIT_BRIDGE_DOMAIN:
return True
return False
async def async_step_zeroconf(
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> FlowResult:
"""Handle a discovered HomeKit accessory.
This flow is triggered by the discovery component.
"""
# Normalize properties from discovery
# homekit_python has code to do this, but not in a form we can
# easily use, so do the bare minimum ourselves here instead.
properties = {
key.lower(): value for (key, value) in discovery_info.properties.items()
}
if zeroconf.ATTR_PROPERTIES_ID not in properties:
# This can happen if the TXT record is received after the PTR record
# we will wait for the next update in this case
_LOGGER.debug(
(
"HomeKit device %s: id not exposed; TXT record may have not yet"
" been received"
),
properties,
)
return self.async_abort(reason="invalid_properties")
# The hkid is a unique random number that looks like a pairing code.
# It changes if a device is factory reset.
hkid = properties[zeroconf.ATTR_PROPERTIES_ID]
normalized_hkid = normalize_hkid(hkid)
# If this aiohomekit doesn't support this particular device, ignore it.
if not domain_supported(discovery_info.name):
return self.async_abort(reason="ignored_model")
model = properties["md"]
name = domain_to_name(discovery_info.name)
status_flags = int(properties["sf"])
category = Categories(int(properties.get("ci", 0)))
paired = not status_flags & 0x01
# Set unique-id and error out if it's already configured
existing_entry = await self.async_set_unique_id(
normalized_hkid, raise_on_progress=False
)
updated_ip_port = {
"AccessoryIP": discovery_info.host,
"AccessoryPort": discovery_info.port,
}
# If the device is already paired and known to us we should monitor c#
# (config_num) for changes. If it changes, we check for new entities
if paired and hkid in self.hass.data.get(KNOWN_DEVICES, {}):
if existing_entry:
self.hass.config_entries.async_update_entry(
existing_entry, data={**existing_entry.data, **updated_ip_port}
)
return self.async_abort(reason="already_configured")
_LOGGER.debug("Discovered device %s (%s - %s)", name, model, hkid)
# Device isn't paired with us or anyone else.
# But we have a 'complete' config entry for it - that is probably
# invalid. Remove it automatically.
existing = find_existing_host(self.hass, hkid)
if not paired and existing:
if self.controller is None:
await self._async_setup_controller()
# mypy can't see that self._async_setup_controller() always sets self.controller or throws
assert self.controller
pairing = self.controller.load_pairing(
existing.data["AccessoryPairingID"], dict(existing.data)
)
try:
await pairing.list_accessories_and_characteristics()
except AuthenticationError:
_LOGGER.debug(
(
"%s (%s - %s) is unpaired. Removing invalid pairing for this"
" device"
),
name,
model,
hkid,
)
await self.hass.config_entries.async_remove(existing.entry_id)
else:
_LOGGER.debug(
(
"%s (%s - %s) claims to be unpaired but isn't. "
"It's implementation of HomeKit is defective "
"or a zeroconf relay is broadcasting stale data"
),
name,
model,
hkid,
)
return self.async_abort(reason="already_paired")
# Set unique-id and error out if it's already configured
self._abort_if_unique_id_configured(updates=updated_ip_port)
for progress in self._async_in_progress(include_uninitialized=True):
context = progress["context"]
if context.get("unique_id") == normalized_hkid and not context.get(
"pairing"
):
if paired:
# If the device gets paired, we want to dismiss
# an existing discovery since we can no longer
# pair with it
self.hass.config_entries.flow.async_abort(progress["flow_id"])
else:
raise AbortFlow("already_in_progress")
if paired:
# Device is paired but not to us - ignore it
_LOGGER.debug("HomeKit device %s ignored as already paired", hkid)
return self.async_abort(reason="already_paired")
# Devices in HOMEKIT_IGNORE have native local integrations - users
# should be encouraged to use native integration and not confused
# by alternative HK API.
if model in HOMEKIT_IGNORE:
return self.async_abort(reason="ignored_model")
# If this is a HomeKit bridge/accessory exported by *this* HA instance ignore it.
if await self._hkid_is_homekit(hkid):
return self.async_abort(reason="ignored_model")
self.name = name
self.model = model
self.category = category
self.hkid = hkid
# We want to show the pairing form - but don't call async_step_pair
# directly as it has side effects (will ask the device to show a
# pairing code)
return self._async_step_pair_show_form()
async def async_step_bluetooth(
self, discovery_info: bluetooth.BluetoothServiceInfoBleak
) -> FlowResult:
"""Handle the bluetooth discovery step."""
if not aiohomekit_const.BLE_TRANSPORT_SUPPORTED:
return self.async_abort(reason="ignored_model")
# Late imports in case BLE is not available
from aiohomekit.controller.ble.discovery import ( # pylint: disable=import-outside-toplevel
BleDiscovery,
)
from aiohomekit.controller.ble.manufacturer_data import ( # pylint: disable=import-outside-toplevel
HomeKitAdvertisement,
)
await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured()
mfr_data = discovery_info.manufacturer_data
try:
device = HomeKitAdvertisement.from_manufacturer_data(
discovery_info.name, discovery_info.address, mfr_data
)
except ValueError:
return self.async_abort(reason="ignored_model")
if not (device.status_flags & StatusFlags.UNPAIRED):
return self.async_abort(reason="already_paired")
if self.controller is None:
await self._async_setup_controller()
assert self.controller is not None
try:
discovery = await self.controller.async_find(device.id)
except aiohomekit.AccessoryNotFoundError:
return self.async_abort(reason="accessory_not_found_error")
if TYPE_CHECKING:
discovery = cast(BleDiscovery, discovery)
self.name = discovery.description.name
self.model = BLE_DEFAULT_NAME
self.category = discovery.description.category
self.hkid = discovery.description.id
return self._async_step_pair_show_form()
async def async_step_pair(
self, pair_info: dict[str, Any] | None = None
) -> FlowResult:
"""Pair with a new HomeKit accessory."""
# If async_step_pair is called with no pairing code then we do the M1
# phase of pairing. If this is successful the device enters pairing
# mode.
# If it doesn't have a screen then the pin is static.
# If it has a display it will display a pin on that display. In
# this case the code is random. So we have to call the async_start_pairing
# API before the user can enter a pin. But equally we don't want to
# call async_start_pairing when the device is discovered, only when they
# click on 'Configure' in the UI.
# async_start_pairing will make the device show its pin and return a
# callable. We call the callable with the pin that the user has typed
# in.
# Should never call this step without setting self.hkid
assert self.hkid
description_placeholders = {}
errors = {}
if self.controller is None:
await self._async_setup_controller()
assert self.controller
if pair_info and self.finish_pairing:
self.context["pairing"] = True
code = pair_info["pairing_code"]
try:
code = ensure_pin_format(
code,
allow_insecure_setup_codes=pair_info.get(
"allow_insecure_setup_codes"
),
)
pairing = await self.finish_pairing(code)
return await self._entry_from_accessory(pairing)
except aiohomekit.exceptions.MalformedPinError:
# Library claimed pin was invalid before even making an API call
errors["pairing_code"] = "authentication_error"
except aiohomekit.AuthenticationError:
# PairSetup M4 - SRP proof failed
# PairSetup M6 - Ed25519 signature verification failed
# PairVerify M4 - Decryption failed
# PairVerify M4 - Device not recognised
# PairVerify M4 - Ed25519 signature verification failed
errors["pairing_code"] = "authentication_error"
self.finish_pairing = None
except aiohomekit.UnknownError:
# An error occurred on the device whilst performing this
# operation.
errors["pairing_code"] = "unknown_error"
self.finish_pairing = None
except aiohomekit.MaxPeersError:
# The device can't pair with any more accessories.
errors["pairing_code"] = "max_peers_error"
self.finish_pairing = None
except aiohomekit.AccessoryNotFoundError:
# Can no longer find the device on the network
return self.async_abort(reason="accessory_not_found_error")
except InsecureSetupCode:
errors["pairing_code"] = "insecure_setup_code"
except Exception as err: # pylint: disable=broad-except
_LOGGER.exception("Pairing attempt failed with an unhandled exception")
self.finish_pairing = None
errors["pairing_code"] = "pairing_failed"
description_placeholders["error"] = str(err)
if not self.finish_pairing:
# Its possible that the first try may have been busy so
# we always check to see if self.finish_paring has been
# set.
try:
discovery = await self.controller.async_find(self.hkid)
self.finish_pairing = await discovery.async_start_pairing(self.hkid)
except aiohomekit.BusyError:
# Already performing a pair setup operation with a different
# controller
return await self.async_step_busy_error()
except aiohomekit.MaxTriesError:
# The accessory has received more than 100 unsuccessful auth
# attempts.
return await self.async_step_max_tries_error()
except aiohomekit.UnavailableError:
# The accessory is already paired - cannot try to pair again.
return self.async_abort(reason="already_paired")
except aiohomekit.AccessoryNotFoundError:
# Can no longer find the device on the network
return self.async_abort(reason="accessory_not_found_error")
except IndexError:
# TLV error, usually not in pairing mode
_LOGGER.exception("Pairing communication failed")
return await self.async_step_protocol_error()
except Exception as err: # pylint: disable=broad-except
_LOGGER.exception("Pairing attempt failed with an unhandled exception")
errors["pairing_code"] = "pairing_failed"
description_placeholders["error"] = str(err)
return self._async_step_pair_show_form(errors, description_placeholders)
async def async_step_busy_error(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Retry pairing after the accessory is busy."""
if user_input is not None:
return await self.async_step_pair()
return self.async_show_form(step_id="busy_error")
async def async_step_max_tries_error(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Retry pairing after the accessory has reached max tries."""
if user_input is not None:
return await self.async_step_pair()
return self.async_show_form(step_id="max_tries_error")
async def async_step_protocol_error(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Retry pairing after the accessory has a protocol error."""
if user_input is not None:
return await self.async_step_pair()
return self.async_show_form(step_id="protocol_error")
@callback
def _async_step_pair_show_form(
self,
errors: dict[str, str] | None = None,
description_placeholders: dict[str, str] | None = None,
) -> FlowResult:
assert self.category
placeholders = self.context["title_placeholders"] = {
"name": self.name,
"category": formatted_category(self.category),
}
schema = {vol.Required("pairing_code"): vol.All(str, vol.Strip)}
if errors and errors.get("pairing_code") == "insecure_setup_code":
schema[vol.Optional("allow_insecure_setup_codes")] = bool
return self.async_show_form(
step_id="pair",
errors=errors or {},
description_placeholders=placeholders | (description_placeholders or {}),
data_schema=vol.Schema(schema),
)
async def _entry_from_accessory(self, pairing: AbstractPairing) -> FlowResult:
"""Return a config entry from an initialized bridge."""
# The bulk of the pairing record is stored on the config entry.
# A specific exception is the 'accessories' key. This is more
# volatile. We do cache it, but not against the config entry.
# So copy the pairing data and mutate the copy.
pairing_data = pairing.pairing_data.copy() # type: ignore[attr-defined]
# Use the accessories data from the pairing operation if it is
# available. Otherwise request a fresh copy from the API.
# This removes the 'accessories' key from pairing_data at
# the same time.
name = await pairing.get_primary_name()
await pairing.close()
# Save the state of the accessories so we do not
# have to request them again when we setup the
# config entry.
accessories_state = pairing.accessories_state
entity_storage = await async_get_entity_storage(self.hass)
assert self.unique_id is not None
entity_storage.async_create_or_update_map(
pairing.id,
accessories_state.config_num,
accessories_state.accessories.serialize(),
serialize_broadcast_key(accessories_state.broadcast_key),
accessories_state.state_num,
)
return self.async_create_entry(title=name, data=pairing_data)
class InsecureSetupCode(Exception):
"""An exception for insecure trivial setup codes."""