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 __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
import logging
from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes
@ -24,6 +25,8 @@ from . import KNOWN_DEVICES
from .connection import HKDevice from .connection import HKDevice
from .entity import CharacteristicEntity from .entity import CharacteristicEntity
_LOGGER = logging.getLogger(__name__)
@dataclass @dataclass
class HomeKitButtonEntityDescription(ButtonEntityDescription): class HomeKitButtonEntityDescription(ButtonEntityDescription):
@ -151,6 +154,29 @@ class HomeKitEcobeeClearHoldButton(CharacteristicEntity, ButtonEntity):
await self.async_put_characteristics({key: val}) 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] = { BUTTON_ENTITY_CLASSES: dict[str, type] = {
CharacteristicsTypes.VENDOR_ECOBEE_CLEAR_HOLD: HomeKitEcobeeClearHoldButton, 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 typing import Any
from aiohomekit import Controller from aiohomekit import Controller
from aiohomekit.controller import TransportType
from aiohomekit.exceptions import ( from aiohomekit.exceptions import (
AccessoryDisconnectedError, AccessoryDisconnectedError,
AccessoryNotFoundError, AccessoryNotFoundError,
@ -16,11 +17,13 @@ from aiohomekit.exceptions import (
) )
from aiohomekit.model import Accessories, Accessory, Transport from aiohomekit.model import Accessories, Accessory, Transport
from aiohomekit.model.characteristics import Characteristic 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.config_entries import ConfigEntry
from homeassistant.const import ATTR_VIA_DEVICE, EVENT_HOMEASSISTANT_STARTED from homeassistant.const import ATTR_VIA_DEVICE, EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import CoreState, Event, HomeAssistant, callback 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 import device_registry as dr, entity_registry as er
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
@ -772,6 +775,59 @@ class HKDevice:
"""Control a HomeKit device state from Home Assistant.""" """Control a HomeKit device state from Home Assistant."""
await self.pairing.put_characteristics(characteristics) 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 @property
def unique_id(self) -> str: def unique_id(self) -> str:
"""Return a unique id for this accessory or bridge. """Return a unique id for this accessory or bridge.

View File

@ -94,6 +94,7 @@ CHARACTERISTIC_PLATFORMS = {
CharacteristicsTypes.DENSITY_VOC: "sensor", CharacteristicsTypes.DENSITY_VOC: "sensor",
CharacteristicsTypes.IDENTIFY: "button", CharacteristicsTypes.IDENTIFY: "button",
CharacteristicsTypes.THREAD_NODE_CAPABILITIES: "sensor", CharacteristicsTypes.THREAD_NODE_CAPABILITIES: "sensor",
CharacteristicsTypes.THREAD_CONTROL_POINT: "button",
} }
STARTUP_EXCEPTIONS = ( STARTUP_EXCEPTIONS = (

View File

@ -1,6 +1,7 @@
{ {
"domain": "homekit_controller", "domain": "homekit_controller",
"name": "HomeKit Controller", "name": "HomeKit Controller",
"after_dependencies": ["thread"],
"bluetooth": [ "bluetooth": [
{ {
"manufacturer_id": 76, "manufacturer_id": 76,
@ -13,6 +14,6 @@
"documentation": "https://www.home-assistant.io/integrations/homekit_controller", "documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["aiohomekit", "commentjson"], "loggers": ["aiohomekit", "commentjson"],
"requirements": ["aiohomekit==2.5.0"], "requirements": ["aiohomekit==2.6.1"],
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
} }

View File

@ -174,7 +174,7 @@ aioguardian==2022.07.0
aioharmony==0.2.9 aioharmony==0.2.9
# homeassistant.components.homekit_controller # homeassistant.components.homekit_controller
aiohomekit==2.5.0 aiohomekit==2.6.1
# homeassistant.components.emulated_hue # homeassistant.components.emulated_hue
# homeassistant.components.http # homeassistant.components.http

View File

@ -158,7 +158,7 @@ aioguardian==2022.07.0
aioharmony==0.2.9 aioharmony==0.2.9
# homeassistant.components.homekit_controller # homeassistant.components.homekit_controller
aiohomekit==2.5.0 aiohomekit==2.6.1
# homeassistant.components.emulated_hue # homeassistant.components.emulated_hue
# homeassistant.components.http # homeassistant.components.http

View File

@ -2,6 +2,7 @@
import dataclasses import dataclasses
from aiohomekit.controller import TransportType
import pytest import pytest
from homeassistant.components.homekit_controller.const import ( from homeassistant.components.homekit_controller.const import (
@ -10,7 +11,9 @@ from homeassistant.components.homekit_controller.const import (
IDENTIFIER_LEGACY_ACCESSORY_ID, IDENTIFIER_LEGACY_ACCESSORY_ID,
IDENTIFIER_LEGACY_SERIAL_NUMBER, IDENTIFIER_LEGACY_SERIAL_NUMBER,
) )
from homeassistant.components.thread import async_add_dataset
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from .common import setup_accessories_from_file, setup_platform, setup_test_accessories 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() await hass.async_block_till_done()
assert config_entry.unique_id == "02:03:ef:02:03:ef" 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"