Support HomeKit Controller Thread Provisioning (#87809)
This commit is contained in:
parent
402170d49e
commit
f5a05c1bd2
7 changed files with 220 additions and 4 deletions
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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."]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue