mirror of
https://github.com/home-assistant/core.git
synced 2025-07-25 06:07:17 +00:00
Support HomeKit Controller Thread Provisioning (#87809)
This commit is contained in:
parent
402170d49e
commit
f5a05c1bd2
@ -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,
|
||||||
}
|
}
|
||||||
|
@ -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.
|
||||||
|
@ -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 = (
|
||||||
|
@ -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."]
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user