Skip to content
Snippets Groups Projects
Unverified Commit df936361 authored by Jc2k's avatar Jc2k Committed by GitHub
Browse files

Refactor homekit_controller to be fully asynchronous (#32111)

* Port homekit_controller to aiohomekit

* Remove succeed() test helper

* Remove fail() test helper
parent a1a835cf
No related branches found
No related tags found
No related merge requests found
Showing
with 89 additions and 178 deletions
"""Support for Homekit device discovery."""
import logging
import homekit
from homekit.model.characteristics import CharacteristicsTypes
import aiohomekit
from aiohomekit.model.characteristics import CharacteristicsTypes
from homeassistant.core import callback
from homeassistant.exceptions import ConfigEntryNotReady
......@@ -94,7 +94,8 @@ class HomeKitEntity(Entity):
def _setup_characteristic(self, char):
"""Configure an entity based on a HomeKit characteristics metadata."""
# Build up a list of (aid, iid) tuples to poll on update()
self.pollable_characteristics.append((self._aid, char["iid"]))
if "pr" in char["perms"]:
self.pollable_characteristics.append((self._aid, char["iid"]))
# Build a map of ctype -> iid
short_name = CharacteristicsTypes.get_short(char["type"])
......@@ -223,7 +224,7 @@ async def async_setup(hass, config):
map_storage = hass.data[ENTITY_MAP] = EntityMapStorage(hass)
await map_storage.async_initialize()
hass.data[CONTROLLER] = homekit.Controller()
hass.data[CONTROLLER] = aiohomekit.Controller()
hass.data[KNOWN_DEVICES] = {}
return True
......
"""Support for HomeKit Controller air quality sensors."""
from homekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.characteristics import CharacteristicsTypes
from homeassistant.components.air_quality import AirQualityEntity
from homeassistant.core import callback
......
"""Support for Homekit Alarm Control Panel."""
import logging
from homekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.characteristics import CharacteristicsTypes
from homeassistant.components.alarm_control_panel import AlarmControlPanel
from homeassistant.components.alarm_control_panel.const import (
......
"""Support for Homekit motion sensors."""
import logging
from homekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.characteristics import CharacteristicsTypes
from homeassistant.components.binary_sensor import (
DEVICE_CLASS_SMOKE,
......
"""Support for Homekit climate devices."""
import logging
from homekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.characteristics import CharacteristicsTypes
from homeassistant.components.climate import (
DEFAULT_MAX_HUMIDITY,
......
......@@ -4,8 +4,9 @@ import logging
import os
import re
import homekit
from homekit.controller.ip_implementation import IpPairing
import aiohomekit
from aiohomekit import Controller
from aiohomekit.controller.ip import IpPairing
import voluptuous as vol
from homeassistant import config_entries
......@@ -72,7 +73,7 @@ def ensure_pin_format(pin):
"""
match = PIN_FORMAT.search(pin)
if not match:
raise homekit.exceptions.MalformedPinError(f"Invalid PIN code f{pin}")
raise aiohomekit.exceptions.MalformedPinError(f"Invalid PIN code f{pin}")
return "{}-{}-{}".format(*match.groups())
......@@ -88,7 +89,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow):
self.model = None
self.hkid = None
self.devices = {}
self.controller = homekit.Controller()
self.controller = Controller()
self.finish_pairing = None
async def async_step_user(self, user_input=None):
......@@ -97,22 +98,22 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow):
if user_input is not None:
key = user_input["device"]
self.hkid = self.devices[key]["id"]
self.model = self.devices[key]["md"]
self.hkid = self.devices[key].device_id
self.model = self.devices[key].info["md"]
await self.async_set_unique_id(
normalize_hkid(self.hkid), raise_on_progress=False
)
return await self.async_step_pair()
all_hosts = await self.hass.async_add_executor_job(self.controller.discover, 5)
all_hosts = await self.controller.discover_ip()
self.devices = {}
for host in all_hosts:
status_flags = int(host["sf"])
status_flags = int(host.info["sf"])
paired = not status_flags & 0x01
if paired:
continue
self.devices[host["name"]] = host
self.devices[host.info["name"]] = host
if not self.devices:
return self.async_abort(reason="no_devices")
......@@ -130,10 +131,11 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow):
unique_id = user_input["unique_id"]
await self.async_set_unique_id(unique_id)
records = await self.hass.async_add_executor_job(self.controller.discover, 5)
for record in records:
if normalize_hkid(record["id"]) != unique_id:
devices = await self.controller.discover_ip(5)
for device in devices:
if normalize_hkid(device.device_id) != unique_id:
continue
record = device.info
return await self.async_step_zeroconf(
{
"host": record["address"],
......@@ -295,55 +297,49 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow):
code = pair_info["pairing_code"]
try:
code = ensure_pin_format(code)
await self.hass.async_add_executor_job(self.finish_pairing, code)
pairing = self.controller.pairings.get(self.hkid)
if pairing:
return await self._entry_from_accessory(pairing)
errors["pairing_code"] = "unable_to_pair"
except homekit.exceptions.MalformedPinError:
pairing = await self.finish_pairing(code)
return await self._entry_from_accessory(pairing)
except aiohomekit.exceptions.MalformedPinError:
# Library claimed pin was invalid before even making an API call
errors["pairing_code"] = "authentication_error"
except homekit.AuthenticationError:
except aiohomekit.AuthenticationError:
# PairSetup M4 - SRP proof failed
# PairSetup M6 - Ed25519 signature verification failed
# PairVerify M4 - Decryption failed
# PairVerify M4 - Device not recognised
# PairVerify M4 - Ed25519 signature verification failed
errors["pairing_code"] = "authentication_error"
except homekit.UnknownError:
except aiohomekit.UnknownError:
# An error occurred on the device whilst performing this
# operation.
errors["pairing_code"] = "unknown_error"
except homekit.MaxPeersError:
except aiohomekit.MaxPeersError:
# The device can't pair with any more accessories.
errors["pairing_code"] = "max_peers_error"
except homekit.AccessoryNotFoundError:
except aiohomekit.AccessoryNotFoundError:
# Can no longer find the device on the network
return self.async_abort(reason="accessory_not_found_error")
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Pairing attempt failed with an unhandled exception")
errors["pairing_code"] = "pairing_failed"
start_pairing = self.controller.start_pairing
discovery = await self.controller.find_ip_by_device_id(self.hkid)
try:
self.finish_pairing = await self.hass.async_add_executor_job(
start_pairing, self.hkid, self.hkid
)
except homekit.BusyError:
self.finish_pairing = await discovery.start_pairing(self.hkid)
except aiohomekit.BusyError:
# Already performing a pair setup operation with a different
# controller
errors["pairing_code"] = "busy_error"
except homekit.MaxTriesError:
except aiohomekit.MaxTriesError:
# The accessory has received more than 100 unsuccessful auth
# attempts.
errors["pairing_code"] = "max_tries_error"
except homekit.UnavailableError:
except aiohomekit.UnavailableError:
# The accessory is already paired - cannot try to pair again.
return self.async_abort(reason="already_paired")
except homekit.AccessoryNotFoundError:
except aiohomekit.AccessoryNotFoundError:
# Can no longer find the device on the network
return self.async_abort(reason="accessory_not_found_error")
except Exception: # pylint: disable=broad-except
......@@ -376,9 +372,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow):
# the same time.
accessories = pairing_data.pop("accessories", None)
if not accessories:
accessories = await self.hass.async_add_executor_job(
pairing.list_accessories_and_characteristics
)
accessories = await pairing.list_accessories_and_characteristics()
bridge_info = get_bridge_information(accessories)
name = get_accessory_name(bridge_info)
......
......@@ -3,14 +3,14 @@ import asyncio
import datetime
import logging
from homekit.controller.ip_implementation import IpPairing
from homekit.exceptions import (
from aiohomekit.controller.ip import IpPairing
from aiohomekit.exceptions import (
AccessoryDisconnectedError,
AccessoryNotFoundError,
EncryptionError,
)
from homekit.model.characteristics import CharacteristicsTypes
from homekit.model.services import ServicesTypes
from aiohomekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.services import ServicesTypes
from homeassistant.core import callback
from homeassistant.helpers.event import async_track_time_interval
......@@ -186,10 +186,7 @@ class HKDevice:
async def async_refresh_entity_map(self, config_num):
"""Handle setup of a HomeKit accessory."""
try:
async with self.pairing_lock:
self.accessories = await self.hass.async_add_executor_job(
self.pairing.list_accessories_and_characteristics
)
self.accessories = await self.pairing.list_accessories_and_characteristics()
except AccessoryDisconnectedError:
# If we fail to refresh this data then we will naturally retry
# later when Bonjour spots c# is still not up to date.
......@@ -305,10 +302,7 @@ class HKDevice:
async def get_characteristics(self, *args, **kwargs):
"""Read latest state from homekit accessory."""
async with self.pairing_lock:
chars = await self.hass.async_add_executor_job(
self.pairing.get_characteristics, *args, **kwargs
)
return chars
return await self.pairing.get_characteristics(*args, **kwargs)
async def put_characteristics(self, characteristics):
"""Control a HomeKit device state from Home Assistant."""
......@@ -317,9 +311,7 @@ class HKDevice:
chars.append((row["aid"], row["iid"], row["value"]))
async with self.pairing_lock:
results = await self.hass.async_add_executor_job(
self.pairing.put_characteristics, chars
)
results = await self.pairing.put_characteristics(chars)
# Feed characteristics back into HA and update the current state
# results will only contain failures, so anythin in characteristics
......
"""Support for Homekit covers."""
import logging
from homekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.characteristics import CharacteristicsTypes
from homeassistant.components.cover import (
ATTR_POSITION,
......
"""Support for Homekit fans."""
import logging
from homekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.characteristics import CharacteristicsTypes
from homeassistant.components.fan import (
DIRECTION_FORWARD,
......
"""Support for Homekit lights."""
import logging
from homekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.characteristics import CharacteristicsTypes
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
......
"""Support for HomeKit Controller locks."""
import logging
from homekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.characteristics import CharacteristicsTypes
from homeassistant.components.lock import LockDevice
from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_LOCKED, STATE_UNLOCKED
......
......@@ -3,7 +3,7 @@
"name": "HomeKit Controller",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"requirements": ["homekit[IP]==0.15.0"],
"requirements": ["aiohomekit[IP]==0.2.10"],
"dependencies": [],
"zeroconf": ["_hap._tcp.local."],
"codeowners": ["@Jc2k"]
......
"""Support for Homekit sensors."""
from homekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.characteristics import CharacteristicsTypes
from homeassistant.const import DEVICE_CLASS_BATTERY, TEMP_CELSIUS
from homeassistant.core import callback
......
"""Support for Homekit switches."""
import logging
from homekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.characteristics import CharacteristicsTypes
from homeassistant.components.switch import SwitchDevice
from homeassistant.core import callback
......
......@@ -161,6 +161,9 @@ aioftp==0.12.0
# homeassistant.components.harmony
aioharmony==0.1.13
# homeassistant.components.homekit_controller
aiohomekit[IP]==0.2.10
# homeassistant.components.emulated_hue
# homeassistant.components.http
aiohttp_cors==0.7.0
......@@ -688,9 +691,6 @@ home-assistant-frontend==20200220.1
# homeassistant.components.zwave
homeassistant-pyozw==0.1.8
# homeassistant.components.homekit_controller
homekit[IP]==0.15.0
# homeassistant.components.homematicip_cloud
homematicip==0.10.17
......
......@@ -61,6 +61,9 @@ aiobotocore==0.11.1
# homeassistant.components.esphome
aioesphomeapi==2.6.1
# homeassistant.components.homekit_controller
aiohomekit[IP]==0.2.10
# homeassistant.components.emulated_hue
# homeassistant.components.http
aiohttp_cors==0.7.0
......@@ -259,9 +262,6 @@ home-assistant-frontend==20200220.1
# homeassistant.components.zwave
homeassistant-pyozw==0.1.8
# homeassistant.components.homekit_controller
homekit[IP]==0.15.0
# homeassistant.components.homematicip_cloud
homematicip==0.10.17
......
......@@ -4,14 +4,10 @@ import json
import os
from unittest import mock
from homekit.exceptions import AccessoryNotFoundError
from homekit.model import Accessory, get_id
from homekit.model.characteristics import (
AbstractCharacteristic,
CharacteristicPermissions,
CharacteristicsTypes,
)
from homekit.model.services import AbstractService, ServicesTypes
from aiohomekit.exceptions import AccessoryNotFoundError
from aiohomekit.model import Accessory
from aiohomekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.services import ServicesTypes
from homeassistant import config_entries
from homeassistant.components.homekit_controller import config_flow
......@@ -40,14 +36,14 @@ class FakePairing:
self.pairing_data = {}
self.available = True
def list_accessories_and_characteristics(self):
async def list_accessories_and_characteristics(self):
"""Fake implementation of list_accessories_and_characteristics."""
accessories = [a.to_accessory_and_service_list() for a in self.accessories]
# replicate what happens upstream right now
self.pairing_data["accessories"] = accessories
return accessories
def get_characteristics(self, characteristics):
async def get_characteristics(self, characteristics):
"""Fake implementation of get_characteristics."""
if not self.available:
raise AccessoryNotFoundError("Accessory not found")
......@@ -64,7 +60,7 @@ class FakePairing:
results[(aid, cid)] = {"value": char.get_value()}
return results
def put_characteristics(self, characteristics):
async def put_characteristics(self, characteristics):
"""Fake implementation of put_characteristics."""
for aid, cid, new_val in characteristics:
for accessory in self.accessories:
......@@ -124,45 +120,6 @@ class Helper:
return state
class FakeCharacteristic(AbstractCharacteristic):
"""
A model of a generic HomeKit characteristic.
Base is abstract and can't be instanced directly so this subclass is
needed even though it doesn't add any methods.
"""
def to_accessory_and_service_list(self):
"""Serialize the characteristic."""
# Upstream doesn't correctly serialize valid_values
# This fix will be upstreamed and this function removed when it
# is fixed.
record = super().to_accessory_and_service_list()
if self.valid_values:
record["valid-values"] = self.valid_values
return record
class FakeService(AbstractService):
"""A model of a generic HomeKit service."""
def __init__(self, service_name):
"""Create a fake service by its short form HAP spec name."""
char_type = ServicesTypes.get_uuid(service_name)
super().__init__(char_type, get_id())
def add_characteristic(self, name):
"""Add a characteristic to this service by name."""
full_name = "public.hap.characteristic." + name
char = FakeCharacteristic(get_id(), full_name, None)
char.perms = [
CharacteristicPermissions.paired_read,
CharacteristicPermissions.paired_write,
]
self.characteristics.append(char)
return char
async def time_changed(hass, seconds):
"""Trigger time changed."""
next_update = dt_util.utcnow() + timedelta(seconds)
......@@ -176,40 +133,7 @@ async def setup_accessories_from_file(hass, path):
load_fixture, os.path.join("homekit_controller", path)
)
accessories_json = json.loads(accessories_fixture)
accessories = []
for accessory_data in accessories_json:
accessory = Accessory("Name", "Mfr", "Model", "0001", "0.1")
accessory.services = []
accessory.aid = accessory_data["aid"]
for service_data in accessory_data["services"]:
service = FakeService("public.hap.service.accessory-information")
service.type = service_data["type"]
service.iid = service_data["iid"]
for char_data in service_data["characteristics"]:
char = FakeCharacteristic(1, "23", None)
char.type = char_data["type"]
char.iid = char_data["iid"]
char.perms = char_data["perms"]
char.format = char_data["format"]
if "description" in char_data:
char.description = char_data["description"]
if "value" in char_data:
char.value = char_data["value"]
if "minValue" in char_data:
char.minValue = char_data["minValue"]
if "maxValue" in char_data:
char.maxValue = char_data["maxValue"]
if "valid-values" in char_data:
char.valid_values = char_data["valid-values"]
service.characteristics.append(char)
accessory.services.append(service)
accessories.append(accessory)
accessories = Accessory.setup_accessories_from_list(accessories_json)
return accessories
......@@ -217,7 +141,7 @@ async def setup_platform(hass):
"""Load the platform but with a fake Controller API."""
config = {"discovery": {}}
with mock.patch("homekit.Controller") as controller:
with mock.patch("aiohomekit.Controller") as controller:
fake_controller = controller.return_value = FakeController()
await async_setup_component(hass, DOMAIN, config)
......@@ -293,15 +217,18 @@ async def device_config_changed(hass, accessories):
await hass.async_block_till_done()
async def setup_test_component(hass, services, capitalize=False, suffix=None):
async def setup_test_component(hass, setup_accessory, capitalize=False, suffix=None):
"""Load a fake homekit accessory based on a homekit accessory model.
If capitalize is True, property names will be in upper case.
If suffix is set, entityId will include the suffix
"""
accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1")
setup_accessory(accessory)
domain = None
for service in services:
for service in accessory.services:
service_name = ServicesTypes.get_short(service.type)
if service_name in HOMEKIT_ACCESSORY_DISPATCH:
domain = HOMEKIT_ACCESSORY_DISPATCH[service_name]
......@@ -309,9 +236,6 @@ async def setup_test_component(hass, services, capitalize=False, suffix=None):
assert domain, "Cannot map test homekit services to Home Assistant domain"
accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1")
accessory.services.extend(services)
config_entry, pairing = await setup_test_accessories(hass, [accessory])
entity = "testdevice" if suffix is None else "testdevice_{}".format(suffix)
return Helper(hass, ".".join((domain, entity)), pairing, accessory, config_entry)
......@@ -6,7 +6,7 @@ https://github.com/home-assistant/home-assistant/issues/15336
from unittest import mock
from homekit import AccessoryDisconnectedError
from aiohomekit import AccessoryDisconnectedError
from homeassistant.components.climate.const import (
SUPPORT_TARGET_HUMIDITY,
......
......@@ -3,7 +3,7 @@
from datetime import timedelta
from unittest import mock
from homekit.exceptions import AccessoryDisconnectedError, EncryptionError
from aiohomekit.exceptions import AccessoryDisconnectedError, EncryptionError
import pytest
from homeassistant.components.light import SUPPORT_BRIGHTNESS, SUPPORT_COLOR
......
"""Basic checks for HomeKit air quality sensor."""
from tests.components.homekit_controller.common import FakeService, setup_test_component
from aiohomekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.services import ServicesTypes
from tests.components.homekit_controller.common import setup_test_component
def create_air_quality_sensor_service():
def create_air_quality_sensor_service(accessory):
"""Define temperature characteristics."""
service = FakeService("public.hap.service.sensor.air-quality")
service = accessory.add_service(ServicesTypes.AIR_QUALITY_SENSOR)
cur_state = service.add_characteristic("air-quality")
cur_state = service.add_char(CharacteristicsTypes.AIR_QUALITY)
cur_state.value = 5
cur_state = service.add_characteristic("density.ozone")
cur_state = service.add_char(CharacteristicsTypes.DENSITY_OZONE)
cur_state.value = 1111
cur_state = service.add_characteristic("density.no2")
cur_state = service.add_char(CharacteristicsTypes.DENSITY_NO2)
cur_state.value = 2222
cur_state = service.add_characteristic("density.so2")
cur_state = service.add_char(CharacteristicsTypes.DENSITY_SO2)
cur_state.value = 3333
cur_state = service.add_characteristic("density.pm25")
cur_state = service.add_char(CharacteristicsTypes.DENSITY_PM25)
cur_state.value = 4444
cur_state = service.add_characteristic("density.pm10")
cur_state = service.add_char(CharacteristicsTypes.DENSITY_PM10)
cur_state.value = 5555
cur_state = service.add_characteristic("density.voc")
cur_state = service.add_char(CharacteristicsTypes.DENSITY_VOC)
cur_state.value = 6666
return service
async def test_air_quality_sensor_read_state(hass, utcnow):
"""Test reading the state of a HomeKit temperature sensor accessory."""
sensor = create_air_quality_sensor_service()
helper = await setup_test_component(hass, [sensor])
helper = await setup_test_component(hass, create_air_quality_sensor_service)
state = await helper.poll_and_get_state()
assert state.state == "4444"
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment