1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-24 12:59:34 +00:00

HomeKit Controller: Adopt config entries for pairing with homekit accessories (#23825)

* Fix user initiated pairing + show more user friendly name

* Add lock around async_refresh_entity_map

* Migrate homekit_controller to config entries.

* Improve docstring

Co-Authored-By: Martin Hjelmare <marhje52@kth.se>

* Add dummy async_setup_platform

* add_service -> async_add_service

* Add missing returns

* Enable coverage checks for homekit_controller
This commit is contained in:
Jc2k
2019-05-13 07:56:05 +01:00
committed by Paulus Schoutsen
parent 3508622e3b
commit b8cbd39985
19 changed files with 334 additions and 287 deletions

View File

@@ -1,11 +1,11 @@
"""Support for Homekit device discovery."""
import logging
from homeassistant.components.discovery import SERVICE_HOMEKIT
from homeassistant.helpers import discovery
from homeassistant.helpers.entity import Entity
from homeassistant.exceptions import ConfigEntryNotReady
from .config_flow import load_old_pairings
# We need an import from .config_flow, without it .config_flow is never loaded.
from .config_flow import HomekitControllerFlowHandler # noqa: F401
from .connection import get_accessory_information, HKDevice
from .const import (
CONTROLLER, ENTITY_MAP, KNOWN_DEVICES
@@ -13,12 +13,6 @@ from .const import (
from .const import DOMAIN # noqa: pylint: disable=unused-import
from .storage import EntityMapStorage
HOMEKIT_IGNORE = [
'BSB002',
'Home Assistant Bridge',
'TRADFRI gateway',
]
_LOGGER = logging.getLogger(__name__)
@@ -150,61 +144,29 @@ class HomeKitEntity(Entity):
raise NotImplementedError
async def async_setup_entry(hass, entry):
"""Set up a HomeKit connection on a config entry."""
conn = HKDevice(hass, entry, entry.data)
hass.data[KNOWN_DEVICES][conn.unique_id] = conn
if not await conn.async_setup():
del hass.data[KNOWN_DEVICES][conn.unique_id]
raise ConfigEntryNotReady
return True
async def async_setup(hass, config):
"""Set up for Homekit devices."""
# pylint: disable=import-error
import homekit
from homekit.controller.ip_implementation import IpPairing
map_storage = hass.data[ENTITY_MAP] = EntityMapStorage(hass)
await map_storage.async_initialize()
hass.data[CONTROLLER] = controller = homekit.Controller()
old_pairings = await hass.async_add_executor_job(
load_old_pairings,
hass
)
for hkid, pairing_data in old_pairings.items():
controller.pairings[hkid] = IpPairing(pairing_data)
def discovery_dispatch(service, discovery_info):
"""Dispatcher for Homekit discovery events."""
# model, id
host = discovery_info['host']
port = discovery_info['port']
# Fold property keys to lower case, making them effectively
# case-insensitive. Some HomeKit devices capitalize them.
properties = {
key.lower(): value
for (key, value) in discovery_info['properties'].items()
}
model = properties['md']
hkid = properties['id']
config_num = int(properties['c#'])
if model in HOMEKIT_IGNORE:
return
# Only register a device once, but rescan if the config has changed
if hkid in hass.data[KNOWN_DEVICES]:
device = hass.data[KNOWN_DEVICES][hkid]
if config_num > device.config_num and \
device.pairing is not None:
device.refresh_entity_map(config_num)
return
_LOGGER.debug('Discovered unique device %s', hkid)
device = HKDevice(hass, host, port, model, hkid, config_num, config)
device.setup()
hass.data[CONTROLLER] = homekit.Controller()
hass.data[KNOWN_DEVICES] = {}
await hass.async_add_executor_job(
discovery.listen, hass, SERVICE_HOMEKIT, discovery_dispatch)
return True

View File

@@ -28,13 +28,25 @@ TARGET_STATE_MAP = {
}
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up Homekit Alarm Control Panel support."""
if discovery_info is None:
return
accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']]
add_entities([HomeKitAlarmControlPanel(accessory, discovery_info)],
True)
async def async_setup_platform(
hass, config, async_add_entities, discovery_info=None):
"""Legacy set up platform."""
pass
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Homekit alarm control panel."""
hkid = config_entry.data['AccessoryPairingID']
conn = hass.data[KNOWN_DEVICES][hkid]
def async_add_service(aid, service):
if service['stype'] != 'security-system':
return False
info = {'aid': aid, 'iid': service['iid']}
async_add_entities([HomeKitAlarmControlPanel(conn, info)], True)
return True
conn.add_listener(async_add_service)
class HomeKitAlarmControlPanel(HomeKitEntity, AlarmControlPanel):

View File

@@ -8,11 +8,25 @@ from . import KNOWN_DEVICES, HomeKitEntity
_LOGGER = logging.getLogger(__name__)
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up Homekit motion sensor support."""
if discovery_info is not None:
accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']]
add_entities([HomeKitMotionSensor(accessory, discovery_info)], True)
async def async_setup_platform(
hass, config, async_add_entities, discovery_info=None):
"""Legacy set up platform."""
pass
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Homekit lighting."""
hkid = config_entry.data['AccessoryPairingID']
conn = hass.data[KNOWN_DEVICES][hkid]
def async_add_service(aid, service):
if service['stype'] != 'motion':
return False
info = {'aid': aid, 'iid': service['iid']}
async_add_entities([HomeKitMotionSensor(conn, info)], True)
return True
conn.add_listener(async_add_service)
class HomeKitMotionSensor(HomeKitEntity, BinarySensorDevice):

View File

@@ -26,11 +26,25 @@ MODE_HASS_TO_HOMEKIT = {v: k for k, v in MODE_HOMEKIT_TO_HASS.items()}
DEFAULT_VALID_MODES = list(MODE_HOMEKIT_TO_HASS)
def setup_platform(hass, config, add_entities, discovery_info=None):
async def async_setup_platform(
hass, config, async_add_entities, discovery_info=None):
"""Legacy set up platform."""
pass
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Homekit climate."""
if discovery_info is not None:
accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']]
add_entities([HomeKitClimateDevice(accessory, discovery_info)], True)
hkid = config_entry.data['AccessoryPairingID']
conn = hass.data[KNOWN_DEVICES][hkid]
def async_add_service(aid, service):
if service['stype'] != 'thermostat':
return False
info = {'aid': aid, 'iid': service['iid']}
async_add_entities([HomeKitClimateDevice(conn, info)], True)
return True
conn.add_listener(async_add_service)
class HomeKitClimateDevice(HomeKitEntity, ClimateDevice):

View File

@@ -78,9 +78,8 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow):
if user_input is not None:
key = user_input['device']
props = self.devices[key]['properties']
self.hkid = props['id']
self.model = props['md']
self.hkid = self.devices[key]['id']
self.model = self.devices[key]['md']
return await self.async_step_pair()
controller = homekit.Controller()
@@ -90,11 +89,11 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow):
self.devices = {}
for host in all_hosts:
status_flags = int(host['properties']['sf'])
status_flags = int(host['sf'])
paired = not status_flags & 0x01
if paired:
continue
self.devices[host['properties']['id']] = host
self.devices[host['name']] = host
if not self.devices:
return self.async_abort(
@@ -263,13 +262,26 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow):
async def _entry_from_accessory(self, pairing):
"""Return a config entry from an initialized bridge."""
accessories = await self.hass.async_add_executor_job(
pairing.list_accessories_and_characteristics
)
# The bulk of the pairing record is stored on the config entry.
# A specific exception is the 'accessories' key. This is more
# volatile. We do cache it, but not against the config entry.
# So copy the pairing data and mutate the copy.
pairing_data = pairing.pairing_data.copy()
# Use the accessories data from the pairing operation if it is
# available. Otherwise request a fresh copy from the API.
# This removes the 'accessories' key from pairing_data at
# 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
)
bridge_info = get_bridge_information(accessories)
name = get_accessory_name(bridge_info)
return self.async_create_entry(
title=name,
data=pairing.pairing_data,
data=pairing_data,
)

View File

@@ -1,14 +1,8 @@
"""Helpers for managing a pairing with a HomeKit accessory or bridge."""
import asyncio
import logging
import os
from homeassistant.helpers import discovery
from .const import (
CONTROLLER, DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, KNOWN_DEVICES,
PAIRING_FILE, HOMEKIT_DIR, ENTITY_MAP
)
from .const import HOMEKIT_ACCESSORY_DISPATCH, ENTITY_MAP
RETRY_INTERVAL = 60 # seconds
@@ -53,75 +47,69 @@ def get_accessory_name(accessory_info):
class HKDevice():
"""HomeKit device."""
def __init__(self, hass, host, port, model, hkid, config_num, config):
def __init__(self, hass, config_entry, pairing_data):
"""Initialise a generic HomeKit device."""
_LOGGER.info("Setting up Homekit device %s", model)
self.hass = hass
self.controller = hass.data[CONTROLLER]
from homekit.controller.ip_implementation import IpPairing
self.hass = hass
self.config_entry = config_entry
# We copy pairing_data because homekit_python may mutate it, but we
# don't want to mutate a dict owned by a config entry.
self.pairing_data = pairing_data.copy()
self.pairing = IpPairing(self.pairing_data)
self.host = host
self.port = port
self.model = model
self.hkid = hkid
self.config_num = config_num
self.config = config
self.configurator = hass.components.configurator
self.accessories = {}
self.config_num = 0
# A list of callbacks that turn HK service metadata into entities
self.listeners = []
# The platorms we have forwarded the config entry so far. If a new
# accessory is added to a bridge we may have to load additional
# platforms. We don't want to load all platforms up front if its just
# a lightbulb. And we dont want to forward a config entry twice
# (triggers a Config entry already set up error)
self.platforms = set()
# This just tracks aid/iid pairs so we know if a HK service has been
# mapped to a HA entity.
self.entities = []
# There are multiple entities sharing a single connection - only
# allow one entity to use pairing at once.
self.pairing_lock = asyncio.Lock(loop=hass.loop)
self.pairing = self.controller.pairings.get(hkid)
hass.data[KNOWN_DEVICES][hkid] = self
def setup(self):
async def async_setup(self):
"""Prepare to use a paired HomeKit device in homeassistant."""
if self.pairing is None:
self.configure()
return
self.pairing.pairing_data['AccessoryIP'] = self.host
self.pairing.pairing_data['AccessoryPort'] = self.port
cache = self.hass.data[ENTITY_MAP].get_map(self.unique_id)
if not cache or cache['config_num'] < self.config_num:
return self.refresh_entity_map(self.config_num)
if not cache:
return await self.async_refresh_entity_map(self.config_num)
self.accessories = cache['accessories']
self.config_num = cache['config_num']
# Ensure the Pairing object has access to the latest version of the
# entity map.
self.pairing.pairing_data['accessories'] = self.accessories
self.async_load_platforms()
self.add_entities()
return True
def refresh_entity_map(self, config_num):
"""
Handle setup of a HomeKit accessory.
The sync version will be removed when homekit_controller migrates to
config flow.
"""
self.hass.add_job(
self.async_refresh_entity_map,
config_num,
)
async def async_refresh_entity_map(self, config_num):
"""Handle setup of a HomeKit accessory."""
# pylint: disable=import-error
from homekit.exceptions import AccessoryDisconnectedError
try:
self.accessories = await self.hass.async_add_executor_job(
self.pairing.list_accessories_and_characteristics,
)
async with self.pairing_lock:
self.accessories = await self.hass.async_add_executor_job(
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.
@@ -139,94 +127,62 @@ class HKDevice():
# aid/iid to GATT characteristics. So push it to there as well.
self.pairing.pairing_data['accessories'] = self.accessories
# Register add new entities that are available
await self.hass.async_add_executor_job(self.add_entities)
self.async_load_platforms()
# Register and add new entities that are available
self.add_entities()
return True
def add_listener(self, add_entities_cb):
"""Add a callback to run when discovering new entities."""
self.listeners.append(add_entities_cb)
self._add_new_entities([add_entities_cb])
def add_entities(self):
"""Process the entity map and create HA entities."""
# pylint: disable=import-error
self._add_new_entities(self.listeners)
def _add_new_entities(self, callbacks):
from homekit.model.services import ServicesTypes
for accessory in self.accessories:
aid = accessory['aid']
for service in accessory['services']:
iid = service['iid']
stype = ServicesTypes.get_short(service['type'].upper())
service['stype'] = stype
if (aid, iid) in self.entities:
# Don't add the same entity again
continue
devtype = ServicesTypes.get_short(service['type'])
_LOGGER.debug("Found %s", devtype)
service_info = {'serial': self.hkid,
'aid': aid,
'iid': service['iid'],
'model': self.model,
'device-type': devtype}
component = HOMEKIT_ACCESSORY_DISPATCH.get(devtype, None)
if component is not None:
discovery.load_platform(self.hass, component, DOMAIN,
service_info, self.config)
self.entities.append((aid, iid))
for listener in callbacks:
if listener(aid, service):
self.entities.append((aid, iid))
break
def device_config_callback(self, callback_data):
"""Handle initial pairing."""
import homekit # pylint: disable=import-error
code = callback_data.get('code').strip()
try:
self.controller.perform_pairing(self.hkid, self.hkid, code)
except homekit.UnavailableError:
error_msg = "This accessory is already paired to another device. \
Please reset the accessory and try again."
_configurator = self.hass.data[DOMAIN+self.hkid]
self.configurator.notify_errors(_configurator, error_msg)
return
except homekit.AuthenticationError:
error_msg = "Incorrect HomeKit code for {}. Please check it and \
try again.".format(self.model)
_configurator = self.hass.data[DOMAIN+self.hkid]
self.configurator.notify_errors(_configurator, error_msg)
return
except homekit.UnknownError:
error_msg = "Received an unknown error. Please file a bug."
_configurator = self.hass.data[DOMAIN+self.hkid]
self.configurator.notify_errors(_configurator, error_msg)
raise
def async_load_platforms(self):
"""Load any platforms needed by this HomeKit device."""
from homekit.model.services import ServicesTypes
self.pairing = self.controller.pairings.get(self.hkid)
if self.pairing is not None:
pairing_dir = os.path.join(
self.hass.config.path(),
HOMEKIT_DIR,
)
if not os.path.exists(pairing_dir):
os.makedirs(pairing_dir)
pairing_file = os.path.join(
pairing_dir,
PAIRING_FILE,
)
self.controller.save_data(pairing_file)
_configurator = self.hass.data[DOMAIN+self.hkid]
self.configurator.request_done(_configurator)
self.setup()
else:
error_msg = "Unable to pair, please try again"
_configurator = self.hass.data[DOMAIN+self.hkid]
self.configurator.notify_errors(_configurator, error_msg)
for accessory in self.accessories:
for service in accessory['services']:
stype = ServicesTypes.get_short(service['type'].upper())
if stype not in HOMEKIT_ACCESSORY_DISPATCH:
continue
def configure(self):
"""Obtain the pairing code for a HomeKit device."""
description = "Please enter the HomeKit code for your {}".format(
self.model)
self.hass.data[DOMAIN+self.hkid] = \
self.configurator.request_config(self.model,
self.device_config_callback,
description=description,
submit_caption="submit",
fields=[{'id': 'code',
'name': 'HomeKit code',
'type': 'string'}])
platform = HOMEKIT_ACCESSORY_DISPATCH[stype]
if platform in self.platforms:
continue
self.hass.async_create_task(
self.hass.config_entries.async_forward_entry_setup(
self.config_entry,
platform,
)
)
self.platforms.add(platform)
async def get_characteristics(self, *args, **kwargs):
"""Read latest state from homekit accessory."""
@@ -261,4 +217,4 @@ class HKDevice():
This id is random and will change if a device undergoes a hard reset.
"""
return self.hkid
return self.pairing_data['AccessoryPairingID']

View File

@@ -35,18 +35,30 @@ CURRENT_WINDOW_STATE_MAP = {
}
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up HomeKit Cover support."""
if discovery_info is None:
return
accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']]
async def async_setup_platform(
hass, config, async_add_entities, discovery_info=None):
"""Legacy set up platform."""
pass
if discovery_info['device-type'] == 'garage-door-opener':
add_entities([HomeKitGarageDoorCover(accessory, discovery_info)],
True)
else:
add_entities([HomeKitWindowCover(accessory, discovery_info)],
True)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Homekit covers."""
hkid = config_entry.data['AccessoryPairingID']
conn = hass.data[KNOWN_DEVICES][hkid]
def async_add_service(aid, service):
info = {'aid': aid, 'iid': service['iid']}
if service['stype'] == 'garage-door-opener':
async_add_entities([HomeKitGarageDoorCover(conn, info)], True)
return True
if service['stype'] in ('window-covering', 'window'):
async_add_entities([HomeKitWindowCover(conn, info)], True)
return True
return False
conn.add_listener(async_add_service)
class HomeKitGarageDoorCover(HomeKitEntity, CoverDevice):

View File

@@ -10,11 +10,25 @@ from . import KNOWN_DEVICES, HomeKitEntity
_LOGGER = logging.getLogger(__name__)
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up Homekit lighting."""
if discovery_info is not None:
accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']]
add_entities([HomeKitLight(accessory, discovery_info)], True)
async def async_setup_platform(
hass, config, async_add_entities, discovery_info=None):
"""Legacy set up platform."""
pass
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Homekit lightbulb."""
hkid = config_entry.data['AccessoryPairingID']
conn = hass.data[KNOWN_DEVICES][hkid]
def async_add_service(aid, service):
if service['stype'] != 'lightbulb':
return False
info = {'aid': aid, 'iid': service['iid']}
async_add_entities([HomeKitLight(conn, info)], True)
return True
conn.add_listener(async_add_service)
class HomeKitLight(HomeKitEntity, Light):

View File

@@ -24,12 +24,25 @@ TARGET_STATE_MAP = {
}
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up Homekit Lock support."""
if discovery_info is None:
return
accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']]
add_entities([HomeKitLock(accessory, discovery_info)], True)
async def async_setup_platform(
hass, config, async_add_entities, discovery_info=None):
"""Legacy set up platform."""
pass
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Homekit lock."""
hkid = config_entry.data['AccessoryPairingID']
conn = hass.data[KNOWN_DEVICES][hkid]
def async_add_service(aid, service):
if service['stype'] != 'lock-mechanism':
return False
info = {'aid': aid, 'iid': service['iid']}
async_add_entities([HomeKitLock(conn, info)], True)
return True
conn.add_listener(async_add_service)
class HomeKitLock(HomeKitEntity, LockDevice):

View File

@@ -5,7 +5,7 @@
"requirements": [
"homekit[IP]==0.14.0"
],
"dependencies": ["configurator"],
"dependencies": [],
"codeowners": [
"@Jc2k"
]

View File

@@ -11,21 +11,35 @@ UNIT_PERCENT = "%"
UNIT_LUX = "lux"
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up Homekit sensor support."""
if discovery_info is not None:
accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']]
devtype = discovery_info['device-type']
async def async_setup_platform(
hass, config, async_add_entities, discovery_info=None):
"""Legacy set up platform."""
pass
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Homekit covers."""
hkid = config_entry.data['AccessoryPairingID']
conn = hass.data[KNOWN_DEVICES][hkid]
def async_add_service(aid, service):
devtype = service['stype']
info = {'aid': aid, 'iid': service['iid']}
if devtype == 'humidity':
add_entities(
[HomeKitHumiditySensor(accessory, discovery_info)], True)
elif devtype == 'temperature':
add_entities(
[HomeKitTemperatureSensor(accessory, discovery_info)], True)
elif devtype == 'light':
add_entities(
[HomeKitLightSensor(accessory, discovery_info)], True)
async_add_entities([HomeKitHumiditySensor(conn, info)], True)
return True
if devtype == 'temperature':
async_add_entities([HomeKitTemperatureSensor(conn, info)], True)
return True
if devtype == 'light':
async_add_entities([HomeKitLightSensor(conn, info)], True)
return True
return False
conn.add_listener(async_add_service)
class HomeKitHumiditySensor(HomeKitEntity):

View File

@@ -10,11 +10,25 @@ OUTLET_IN_USE = "outlet_in_use"
_LOGGER = logging.getLogger(__name__)
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up Homekit switch support."""
if discovery_info is not None:
accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']]
add_entities([HomeKitSwitch(accessory, discovery_info)], True)
async def async_setup_platform(
hass, config, async_add_entities, discovery_info=None):
"""Legacy set up platform."""
pass
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Homekit lock."""
hkid = config_entry.data['AccessoryPairingID']
conn = hass.data[KNOWN_DEVICES][hkid]
def async_add_service(aid, service):
if service['stype'] not in ('switch', 'outlet'):
return False
info = {'aid': aid, 'iid': service['iid']}
async_add_entities([HomeKitSwitch(conn, info)], True)
return True
conn.add_listener(async_add_service)
class HomeKitSwitch(HomeKitEntity, SwitchDevice):