Support HomeKit Controller Thread Provisioning (#87809)

This commit is contained in:
Jc2k 2023-02-15 16:41:07 +00:00 committed by GitHub
parent 402170d49e
commit f5a05c1bd2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 220 additions and 4 deletions

View file

@ -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,
}

View file

@ -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.

View file

@ -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 = (

View file

@ -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."]
}

View file

@ -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

View file

@ -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

View file

@ -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"