From f5a05c1bd21680bca575bde4da195498b93addaa Mon Sep 17 00:00:00 2001 From: Jc2k Date: Wed, 15 Feb 2023 16:41:07 +0000 Subject: [PATCH] Support HomeKit Controller Thread Provisioning (#87809) --- .../components/homekit_controller/button.py | 26 ++++ .../homekit_controller/connection.py | 58 +++++++- .../components/homekit_controller/const.py | 1 + .../homekit_controller/manifest.json | 3 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../homekit_controller/test_connection.py | 132 ++++++++++++++++++ 7 files changed, 220 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homekit_controller/button.py b/homeassistant/components/homekit_controller/button.py index 69acdb8a1aa..ff61c632be9 100644 --- a/homeassistant/components/homekit_controller/button.py +++ b/homeassistant/components/homekit_controller/button.py @@ -6,6 +6,7 @@ characteristics that don't map to a Home Assistant feature. from __future__ import annotations from dataclasses import dataclass +import logging from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes @@ -24,6 +25,8 @@ from . import KNOWN_DEVICES from .connection import HKDevice from .entity import CharacteristicEntity +_LOGGER = logging.getLogger(__name__) + @dataclass class HomeKitButtonEntityDescription(ButtonEntityDescription): @@ -151,6 +154,29 @@ class HomeKitEcobeeClearHoldButton(CharacteristicEntity, ButtonEntity): await self.async_put_characteristics({key: val}) +class HomeKitProvisionPreferredThreadCredentials(CharacteristicEntity, ButtonEntity): + """A button users can press to migrate their HomeKit BLE device to Thread.""" + + _attr_entity_category = EntityCategory.CONFIG + + def get_characteristic_types(self) -> list[str]: + """Define the homekit characteristics the entity is tracking.""" + return [] + + @property + def name(self) -> str: + """Return the name of the device if any.""" + prefix = "" + if name := super().name: + prefix = name + return f"{prefix} Provision Preferred Thread Credentials" + + async def async_press(self) -> None: + """Press the button.""" + await self._accessory.async_thread_provision() + + BUTTON_ENTITY_CLASSES: dict[str, type] = { CharacteristicsTypes.VENDOR_ECOBEE_CLEAR_HOLD: HomeKitEcobeeClearHoldButton, + CharacteristicsTypes.THREAD_CONTROL_POINT: HomeKitProvisionPreferredThreadCredentials, } diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 9a23344f972..4814e7833cf 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -9,6 +9,7 @@ from types import MappingProxyType from typing import Any from aiohomekit import Controller +from aiohomekit.controller import TransportType from aiohomekit.exceptions import ( AccessoryDisconnectedError, AccessoryNotFoundError, @@ -16,11 +17,13 @@ from aiohomekit.exceptions import ( ) from aiohomekit.model import Accessories, Accessory, Transport from aiohomekit.model.characteristics import Characteristic -from aiohomekit.model.services import Service +from aiohomekit.model.services import Service, ServicesTypes +from homeassistant.components.thread.dataset_store import async_get_preferred_dataset from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_VIA_DEVICE, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, Event, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -772,6 +775,59 @@ class HKDevice: """Control a HomeKit device state from Home Assistant.""" await self.pairing.put_characteristics(characteristics) + @property + def is_unprovisioned_thread_device(self) -> bool: + """Is this a thread capable device not connected by CoAP.""" + if self.pairing.controller.transport_type != TransportType.BLE: + return False + + if not self.entity_map.aid(1).services.first( + service_type=ServicesTypes.THREAD_TRANSPORT + ): + return False + + return True + + async def async_thread_provision(self) -> None: + """Migrate a HomeKit pairing to CoAP (Thread).""" + if self.pairing.controller.transport_type == TransportType.COAP: + raise HomeAssistantError("Already connected to a thread network") + + if not (dataset := await async_get_preferred_dataset(self.hass)): + raise HomeAssistantError("No thread network credentials available") + + await self.pairing.thread_provision(dataset) + + try: + discovery = ( + await self.hass.data[CONTROLLER] + .transports[TransportType.COAP] + .async_find(self.unique_id, timeout=30) + ) + self.hass.config_entries.async_update_entry( + self.config_entry, + data={ + **self.config_entry.data, + "Connection": "CoAP", + "AccessoryIP": discovery.description.address, + "AccessoryPort": discovery.description.port, + }, + ) + _LOGGER.debug( + "%s: Found device on local network, migrating integration to Thread", + self.unique_id, + ) + + except AccessoryNotFoundError as exc: + _LOGGER.debug( + "%s: Failed to appear on local network as a Thread device, reverting to BLE", + self.unique_id, + ) + raise HomeAssistantError("Could not migrate device to Thread") from exc + + finally: + await self.hass.config_entries.async_reload(self.config_entry.entry_id) + @property def unique_id(self) -> str: """Return a unique id for this accessory or bridge. diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index 3727fc272ad..5a7c8fe91a3 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -94,6 +94,7 @@ CHARACTERISTIC_PLATFORMS = { CharacteristicsTypes.DENSITY_VOC: "sensor", CharacteristicsTypes.IDENTIFY: "button", CharacteristicsTypes.THREAD_NODE_CAPABILITIES: "sensor", + CharacteristicsTypes.THREAD_CONTROL_POINT: "button", } STARTUP_EXCEPTIONS = ( diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 6bbd8bd1997..e4eeea04f51 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -1,6 +1,7 @@ { "domain": "homekit_controller", "name": "HomeKit Controller", + "after_dependencies": ["thread"], "bluetooth": [ { "manufacturer_id": 76, @@ -13,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==2.5.0"], + "requirements": ["aiohomekit==2.6.1"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 9cc38659d9c..64cb766adac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -174,7 +174,7 @@ aioguardian==2022.07.0 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==2.5.0 +aiohomekit==2.6.1 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 03b0a9ec0fc..45cd0e26fa4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -158,7 +158,7 @@ aioguardian==2022.07.0 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==2.5.0 +aiohomekit==2.6.1 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py index 358d44c204a..5695077475f 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -2,6 +2,7 @@ import dataclasses +from aiohomekit.controller import TransportType import pytest from homeassistant.components.homekit_controller.const import ( @@ -10,7 +11,9 @@ from homeassistant.components.homekit_controller.const import ( IDENTIFIER_LEGACY_ACCESSORY_ID, IDENTIFIER_LEGACY_SERIAL_NUMBER, ) +from homeassistant.components.thread import async_add_dataset from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from .common import setup_accessories_from_file, setup_platform, setup_test_accessories @@ -176,3 +179,132 @@ async def test_migrate_ble_unique_id(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert config_entry.unique_id == "02:03:ef:02:03:ef" + + +async def test_thread_provision_no_creds(hass: HomeAssistant) -> None: + """Test that we don't migrate to thread when there are no creds available.""" + accessories = await setup_accessories_from_file(hass, "nanoleaf_strip_nl55.json") + + fake_controller = await setup_platform(hass) + await fake_controller.add_paired_device(accessories, "02:03:EF:02:03:EF") + config_entry = MockConfigEntry( + version=1, + domain="homekit_controller", + entry_id="TestData", + data={"AccessoryPairingID": "02:03:EF:02:03:EF"}, + title="test", + unique_id="02:03:ef:02:03:ef", + ) + config_entry.add_to_hass(hass) + + fake_controller.transport_type = TransportType.BLE + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "button", + "press", + { + "entity_id": "button.nanoleaf_strip_3b32_provision_preferred_thread_credentials" + }, + blocking=True, + ) + + +async def test_thread_provision(hass: HomeAssistant) -> None: + """Test that a when a thread provision works the config entry is updated.""" + await async_add_dataset( + hass, + "Tests", + "0E080000000000010000000300000F35060004001FFFE0020811111111222222220708FDAD70BF" + "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8", + ) + + accessories = await setup_accessories_from_file(hass, "nanoleaf_strip_nl55.json") + + fake_controller = await setup_platform(hass) + await fake_controller.add_paired_device(accessories, "00:00:00:00:00:00") + config_entry = MockConfigEntry( + version=1, + domain="homekit_controller", + entry_id="TestData", + data={"AccessoryPairingID": "00:00:00:00:00:00"}, + title="test", + unique_id="00:00:00:00:00:00", + ) + config_entry.add_to_hass(hass) + + fake_controller.transport_type = TransportType.BLE + + # Needs a COAP transport to do migration + fake_controller.transports = {TransportType.COAP: fake_controller} + + # Fake discovery won't have an address/port - set one so the migration works + discovery = fake_controller.discoveries["00:00:00:00:00:00"] + discovery.description.address = "127.0.0.1" + discovery.description.port = 53 + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + "button", + "press", + { + "entity_id": "button.nanoleaf_strip_3b32_provision_preferred_thread_credentials" + }, + blocking=True, + ) + + assert config_entry.data["Connection"] == "CoAP" + + +async def test_thread_provision_migration_failed(hass: HomeAssistant) -> None: + """Test that when a device 'migrates' but doesn't show up in CoAP, we remain in BLE mode.""" + await async_add_dataset( + hass, + "Tests", + "0E080000000000010000000300000F35060004001FFFE0020811111111222222220708FDAD70BF" + "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8", + ) + + accessories = await setup_accessories_from_file(hass, "nanoleaf_strip_nl55.json") + + fake_controller = await setup_platform(hass) + await fake_controller.add_paired_device(accessories, "00:00:00:00:00:00") + config_entry = MockConfigEntry( + version=1, + domain="homekit_controller", + entry_id="TestData", + data={"AccessoryPairingID": "00:00:00:00:00:00", "Connection": "BLE"}, + title="test", + unique_id="00:00:00:00:00:00", + ) + config_entry.add_to_hass(hass) + + fake_controller.transport_type = TransportType.BLE + + # Needs a COAP transport to do migration + fake_controller.transports = {TransportType.COAP: fake_controller} + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Make sure not disoverable via CoAP + del fake_controller.discoveries["00:00:00:00:00:00"] + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "button", + "press", + { + "entity_id": "button.nanoleaf_strip_3b32_provision_preferred_thread_credentials" + }, + blocking=True, + ) + + assert config_entry.data["Connection"] == "BLE"