Add bluetooth discovery to HomeKit Controller (#75333)

Co-authored-by: Jc2k <john.carr@unrouted.co.uk>
pull/75359/head
J. Nick Koston 2022-07-17 08:19:05 -05:00 committed by GitHub
parent 503b31fb15
commit 8d63f81821
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 268 additions and 55 deletions

View File

@ -1,13 +1,17 @@
"""Config flow to configure homekit_controller."""
from __future__ import annotations
from collections.abc import Awaitable
import logging
import re
from typing import Any
from typing import TYPE_CHECKING, Any, cast
import aiohomekit
from aiohomekit.controller.abstract import AbstractPairing
from aiohomekit import Controller, const as aiohomekit_const
from aiohomekit.controller.abstract import AbstractDiscovery, AbstractPairing
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
import voluptuous as vol
@ -16,6 +20,7 @@ from homeassistant.components import zeroconf
from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow, FlowResult
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.service_info import bluetooth
from .connection import HKDevice
from .const import DOMAIN, KNOWN_DEVICES
@ -41,6 +46,8 @@ 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",
@ -62,6 +69,11 @@ def normalize_hkid(hkid: str) -> str:
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, serial: str) -> config_entries.ConfigEntry | None:
"""Return a set of the configured hosts."""
@ -92,14 +104,15 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
def __init__(self):
def __init__(self) -> None:
"""Initialize the homekit_controller flow."""
self.model = None
self.hkid = None
self.name = None
self.devices = {}
self.controller = None
self.finish_pairing = None
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: Awaitable[AbstractPairing] | None = None
async def _async_setup_controller(self):
"""Create the controller."""
@ -111,9 +124,11 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is not None:
key = user_input["device"]
self.hkid = self.devices[key].description.id
self.model = self.devices[key].description.model
self.name = self.devices[key].description.name
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
@ -138,7 +153,14 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
step_id="user",
errors=errors,
data_schema=vol.Schema(
{vol.Required("device"): vol.In(self.devices.keys())}
{
vol.Required("device"): vol.In(
{
key: f"{key} ({formatted_category(discovery.description.category)})"
for key, discovery in self.devices.items()
}
)
}
),
)
@ -151,13 +173,14 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
await self._async_setup_controller()
try:
device = await self.controller.async_find(unique_id)
discovery = await self.controller.async_find(unique_id)
except aiohomekit.AccessoryNotFoundError:
return self.async_abort(reason="accessory_not_found_error")
self.name = device.description.name
self.model = device.description.model
self.hkid = device.description.id
self.name = discovery.description.name
self.model = discovery.description.model
self.category = discovery.description.category
self.hkid = discovery.description.id
return self._async_step_pair_show_form()
@ -213,6 +236,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
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
# The configuration number increases every time the characteristic map
@ -326,6 +350,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
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
@ -333,6 +358,55 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
# pairing code)
return self._async_step_pair_show_form()
async def async_step_bluetooth(
self, discovery_info: bluetooth.BluetoothServiceInfo
) -> 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=None):
"""Pair with a new HomeKit accessory."""
# If async_step_pair is called with no pairing code then we do the M1
@ -453,8 +527,10 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
@callback
def _async_step_pair_show_form(self, errors=None):
placeholders = {"name": self.name}
self.context["title_placeholders"] = {"name": self.name}
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":

View File

@ -3,8 +3,9 @@
"name": "HomeKit Controller",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"requirements": ["aiohomekit==1.1.1"],
"requirements": ["aiohomekit==1.1.4"],
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."],
"bluetooth": [{ "manufacturer_id": 76, "manufacturer_data_first_byte": 6 }],
"after_dependencies": ["zeroconf"],
"codeowners": ["@Jc2k", "@bdraco"],
"iot_class": "local_push",

View File

@ -1,7 +1,7 @@
{
"title": "HomeKit Controller",
"config": {
"flow_title": "{name}",
"flow_title": "{name} ({category})",
"step": {
"user": {
"title": "Device selection",
@ -12,7 +12,7 @@
},
"pair": {
"title": "Pair with a device via HomeKit Accessory Protocol",
"description": "HomeKit Controller communicates with {name} over the local area network using a secure encrypted connection without a separate HomeKit controller or iCloud. Enter your HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory. This code is usually found on the device itself or in the packaging.",
"description": "HomeKit Controller communicates with {name} ({category}) over the local area network using a secure encrypted connection without a separate HomeKit controller or iCloud. Enter your HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory. This code is usually found on the device itself or in the packaging.",
"data": {
"pairing_code": "Pairing Code",
"allow_insecure_setup_codes": "Allow pairing with insecure setup codes."

View File

@ -18,7 +18,7 @@
"unable_to_pair": "Unable to pair, please try again.",
"unknown_error": "Device reported an unknown error. Pairing failed."
},
"flow_title": "{name}",
"flow_title": "{name} ({category})",
"step": {
"busy_error": {
"description": "Abort pairing on all controllers, or try restarting the device, then continue to resume pairing.",
@ -33,7 +33,7 @@
"allow_insecure_setup_codes": "Allow pairing with insecure setup codes.",
"pairing_code": "Pairing Code"
},
"description": "HomeKit Controller communicates with {name} over the local area network using a secure encrypted connection without a separate HomeKit controller or iCloud. Enter your HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory. This code is usually found on the device itself or in the packaging.",
"description": "HomeKit Controller communicates with {name} ({category}) over the local area network using a secure encrypted connection without a separate HomeKit controller or iCloud. Enter your HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory. This code is usually found on the device itself or in the packaging.",
"title": "Pair with a device via HomeKit Accessory Protocol"
},
"protocol_error": {

View File

@ -7,6 +7,11 @@ from __future__ import annotations
# fmt: off
BLUETOOTH: list[dict[str, str | int]] = [
{
"domain": "homekit_controller",
"manufacturer_id": 76,
"manufacturer_data_first_byte": 6
},
{
"domain": "switchbot",
"service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"

View File

@ -168,7 +168,7 @@ aioguardian==2022.03.2
aioharmony==0.2.9
# homeassistant.components.homekit_controller
aiohomekit==1.1.1
aiohomekit==1.1.4
# homeassistant.components.emulated_hue
# homeassistant.components.http

View File

@ -152,7 +152,7 @@ aioguardian==2022.03.2
aioharmony==0.2.9
# homeassistant.components.homekit_controller
aiohomekit==1.1.1
aiohomekit==1.1.4
# homeassistant.components.emulated_hue
# homeassistant.components.http

View File

@ -1,6 +1,5 @@
"""Tests for homekit_controller config flow."""
import asyncio
from unittest import mock
import unittest.mock
from unittest.mock import AsyncMock, MagicMock, patch
@ -15,8 +14,13 @@ from homeassistant import config_entries
from homeassistant.components import zeroconf
from homeassistant.components.homekit_controller import config_flow
from homeassistant.components.homekit_controller.const import KNOWN_DEVICES
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.data_entry_flow import (
RESULT_TYPE_ABORT,
RESULT_TYPE_FORM,
FlowResultType,
)
from homeassistant.helpers import device_registry
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
from tests.common import MockConfigEntry, mock_device_registry
@ -78,23 +82,55 @@ VALID_PAIRING_CODES = [
" 98765432 ",
]
NOT_HK_BLUETOOTH_SERVICE_INFO = BluetoothServiceInfo(
name="FakeAccessory",
address="AA:BB:CC:DD:EE:FF",
rssi=-81,
manufacturer_data={12: b"\x06\x12\x34"},
service_data={},
service_uuids=[],
source="local",
)
def _setup_flow_handler(hass, pairing=None):
flow = config_flow.HomekitControllerFlowHandler()
flow.hass = hass
flow.context = {}
HK_BLUETOOTH_SERVICE_INFO_NOT_DISCOVERED = BluetoothServiceInfo(
name="Eve Energy Not Found",
address="AA:BB:CC:DD:EE:FF",
rssi=-81,
# ID is '9b:86:af:01:af:db'
manufacturer_data={
76: b"\x061\x01\x9b\x86\xaf\x01\xaf\xdb\x07\x00\x06\x00\x02\x02X\x19\xb1Q"
},
service_data={},
service_uuids=[],
source="local",
)
finish_pairing = unittest.mock.AsyncMock(return_value=pairing)
HK_BLUETOOTH_SERVICE_INFO_DISCOVERED_UNPAIRED = BluetoothServiceInfo(
name="Eve Energy Found Unpaired",
address="AA:BB:CC:DD:EE:FF",
rssi=-81,
# ID is '00:00:00:00:00:00', pairing flag is byte 3
manufacturer_data={
76: b"\x061\x01\x00\x00\x00\x00\x00\x00\x07\x00\x06\x00\x02\x02X\x19\xb1Q"
},
service_data={},
service_uuids=[],
source="local",
)
discovery = mock.Mock()
discovery.description.id = "00:00:00:00:00:00"
discovery.async_start_pairing = unittest.mock.AsyncMock(return_value=finish_pairing)
flow.controller = mock.Mock()
flow.controller.pairings = {}
flow.controller.async_find = unittest.mock.AsyncMock(return_value=discovery)
return flow
HK_BLUETOOTH_SERVICE_INFO_DISCOVERED_PAIRED = BluetoothServiceInfo(
name="Eve Energy Found Paired",
address="AA:BB:CC:DD:EE:FF",
rssi=-81,
# ID is '00:00:00:00:00:00', pairing flag is byte 3
manufacturer_data={
76: b"\x061\x00\x00\x00\x00\x00\x00\x00\x07\x00\x06\x00\x02\x02X\x19\xb1Q"
},
service_data={},
service_uuids=[],
source="local",
)
@pytest.mark.parametrize("pairing_code", INVALID_PAIRING_CODES)
@ -151,7 +187,7 @@ def get_device_discovery_info(
"c#": device.description.config_num,
"s#": device.description.state_num,
"ff": "0",
"ci": "0",
"ci": "7",
"sf": "0" if paired else "1",
"sh": "",
},
@ -208,7 +244,7 @@ async def test_discovery_works(hass, controller, upper_case_props, missing_cshar
assert result["step_id"] == "pair"
assert get_flow_context(hass, result) == {
"source": config_entries.SOURCE_ZEROCONF,
"title_placeholders": {"name": "TestDevice"},
"title_placeholders": {"name": "TestDevice", "category": "Outlet"},
"unique_id": "00:00:00:00:00:00",
}
@ -592,7 +628,7 @@ async def test_pair_form_errors_on_start(hass, controller, exception, expected):
)
assert get_flow_context(hass, result) == {
"title_placeholders": {"name": "TestDevice"},
"title_placeholders": {"name": "TestDevice", "category": "Outlet"},
"unique_id": "00:00:00:00:00:00",
"source": config_entries.SOURCE_ZEROCONF,
}
@ -607,7 +643,7 @@ async def test_pair_form_errors_on_start(hass, controller, exception, expected):
assert result["errors"]["pairing_code"] == expected
assert get_flow_context(hass, result) == {
"title_placeholders": {"name": "TestDevice"},
"title_placeholders": {"name": "TestDevice", "category": "Outlet"},
"unique_id": "00:00:00:00:00:00",
"source": config_entries.SOURCE_ZEROCONF,
}
@ -640,7 +676,7 @@ async def test_pair_abort_errors_on_finish(hass, controller, exception, expected
)
assert get_flow_context(hass, result) == {
"title_placeholders": {"name": "TestDevice"},
"title_placeholders": {"name": "TestDevice", "category": "Outlet"},
"unique_id": "00:00:00:00:00:00",
"source": config_entries.SOURCE_ZEROCONF,
}
@ -653,7 +689,7 @@ async def test_pair_abort_errors_on_finish(hass, controller, exception, expected
assert result["type"] == "form"
assert get_flow_context(hass, result) == {
"title_placeholders": {"name": "TestDevice"},
"title_placeholders": {"name": "TestDevice", "category": "Outlet"},
"unique_id": "00:00:00:00:00:00",
"source": config_entries.SOURCE_ZEROCONF,
}
@ -680,7 +716,7 @@ async def test_pair_form_errors_on_finish(hass, controller, exception, expected)
)
assert get_flow_context(hass, result) == {
"title_placeholders": {"name": "TestDevice"},
"title_placeholders": {"name": "TestDevice", "category": "Outlet"},
"unique_id": "00:00:00:00:00:00",
"source": config_entries.SOURCE_ZEROCONF,
}
@ -693,7 +729,7 @@ async def test_pair_form_errors_on_finish(hass, controller, exception, expected)
assert result["type"] == "form"
assert get_flow_context(hass, result) == {
"title_placeholders": {"name": "TestDevice"},
"title_placeholders": {"name": "TestDevice", "category": "Outlet"},
"unique_id": "00:00:00:00:00:00",
"source": config_entries.SOURCE_ZEROCONF,
}
@ -706,7 +742,7 @@ async def test_pair_form_errors_on_finish(hass, controller, exception, expected)
assert result["errors"]["pairing_code"] == expected
assert get_flow_context(hass, result) == {
"title_placeholders": {"name": "TestDevice"},
"title_placeholders": {"name": "TestDevice", "category": "Outlet"},
"unique_id": "00:00:00:00:00:00",
"source": config_entries.SOURCE_ZEROCONF,
"pairing": True,
@ -737,7 +773,7 @@ async def test_user_works(hass, controller):
assert get_flow_context(hass, result) == {
"source": config_entries.SOURCE_USER,
"unique_id": "00:00:00:00:00:00",
"title_placeholders": {"name": "TestDevice"},
"title_placeholders": {"name": "TestDevice", "category": "Other"},
}
result = await hass.config_entries.flow.async_configure(
@ -772,7 +808,7 @@ async def test_user_pairing_with_insecure_setup_code(hass, controller):
assert get_flow_context(hass, result) == {
"source": config_entries.SOURCE_USER,
"unique_id": "00:00:00:00:00:00",
"title_placeholders": {"name": "TestDevice"},
"title_placeholders": {"name": "TestDevice", "category": "Other"},
}
result = await hass.config_entries.flow.async_configure(
@ -829,7 +865,7 @@ async def test_unignore_works(hass, controller):
assert result["type"] == "form"
assert result["step_id"] == "pair"
assert get_flow_context(hass, result) == {
"title_placeholders": {"name": "TestDevice"},
"title_placeholders": {"name": "TestDevice", "category": "Other"},
"unique_id": "00:00:00:00:00:00",
"source": config_entries.SOURCE_UNIGNORE,
}
@ -917,7 +953,7 @@ async def test_mdns_update_to_paired_during_pairing(hass, controller):
)
assert get_flow_context(hass, result) == {
"title_placeholders": {"name": "TestDevice"},
"title_placeholders": {"name": "TestDevice", "category": "Outlet"},
"unique_id": "00:00:00:00:00:00",
"source": config_entries.SOURCE_ZEROCONF,
}
@ -942,7 +978,7 @@ async def test_mdns_update_to_paired_during_pairing(hass, controller):
assert result["type"] == "form"
assert get_flow_context(hass, result) == {
"title_placeholders": {"name": "TestDevice"},
"title_placeholders": {"name": "TestDevice", "category": "Outlet"},
"unique_id": "00:00:00:00:00:00",
"source": config_entries.SOURCE_ZEROCONF,
}
@ -967,3 +1003,98 @@ async def test_mdns_update_to_paired_during_pairing(hass, controller):
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "Koogeek-LS1-20833F"
assert result["data"] == {}
async def test_discovery_no_bluetooth_support(hass, controller):
"""Test discovery with bluetooth support not available."""
with patch(
"homeassistant.components.homekit_controller.config_flow.aiohomekit_const.BLE_TRANSPORT_SUPPORTED",
False,
):
result = await hass.config_entries.flow.async_init(
"homekit_controller",
context={"source": config_entries.SOURCE_BLUETOOTH},
data=HK_BLUETOOTH_SERVICE_INFO_NOT_DISCOVERED,
)
assert result["type"] == RESULT_TYPE_ABORT
assert result["reason"] == "ignored_model"
async def test_bluetooth_not_homekit(hass, controller):
"""Test bluetooth discovery with a non-homekit device."""
with patch(
"homeassistant.components.homekit_controller.config_flow.aiohomekit_const.BLE_TRANSPORT_SUPPORTED",
True,
):
result = await hass.config_entries.flow.async_init(
"homekit_controller",
context={"source": config_entries.SOURCE_BLUETOOTH},
data=NOT_HK_BLUETOOTH_SERVICE_INFO,
)
assert result["type"] == RESULT_TYPE_ABORT
assert result["reason"] == "ignored_model"
async def test_bluetooth_valid_device_no_discovery(hass, controller):
"""Test bluetooth discovery with a homekit device and discovery fails."""
with patch(
"homeassistant.components.homekit_controller.config_flow.aiohomekit_const.BLE_TRANSPORT_SUPPORTED",
True,
):
result = await hass.config_entries.flow.async_init(
"homekit_controller",
context={"source": config_entries.SOURCE_BLUETOOTH},
data=HK_BLUETOOTH_SERVICE_INFO_NOT_DISCOVERED,
)
assert result["type"] == RESULT_TYPE_ABORT
assert result["reason"] == "accessory_not_found_error"
async def test_bluetooth_valid_device_discovery_paired(hass, controller):
"""Test bluetooth discovery with a homekit device and discovery works."""
setup_mock_accessory(controller)
with patch(
"homeassistant.components.homekit_controller.config_flow.aiohomekit_const.BLE_TRANSPORT_SUPPORTED",
True,
):
result = await hass.config_entries.flow.async_init(
"homekit_controller",
context={"source": config_entries.SOURCE_BLUETOOTH},
data=HK_BLUETOOTH_SERVICE_INFO_DISCOVERED_PAIRED,
)
assert result["type"] == RESULT_TYPE_ABORT
assert result["reason"] == "already_paired"
async def test_bluetooth_valid_device_discovery_unpaired(hass, controller):
"""Test bluetooth discovery with a homekit device and discovery works."""
setup_mock_accessory(controller)
with patch(
"homeassistant.components.homekit_controller.config_flow.aiohomekit_const.BLE_TRANSPORT_SUPPORTED",
True,
):
result = await hass.config_entries.flow.async_init(
"homekit_controller",
context={"source": config_entries.SOURCE_BLUETOOTH},
data=HK_BLUETOOTH_SERVICE_INFO_DISCOVERED_UNPAIRED,
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "pair"
assert get_flow_context(hass, result) == {
"source": config_entries.SOURCE_BLUETOOTH,
"unique_id": "AA:BB:CC:DD:EE:FF",
"title_placeholders": {"name": "TestDevice", "category": "Other"},
}
result2 = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result2["type"] == RESULT_TYPE_FORM
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"], user_input={"pairing_code": "111-22-333"}
)
assert result3["type"] == FlowResultType.CREATE_ENTRY
assert result3["title"] == "Koogeek-LS1-20833F"
assert result3["data"] == {}