Add support for controlling homekit lights and switches (#13346)

* Add support for controlling homekit lights and switches

This adds support for controlling lights and switches that expose a HomeKit
control interface, avoiding the requirement to implement protocol-specific
components.

* Comment out the homekit requirement

This needs to build native code, so leave it commented for now

* Review updates

* Make HomeKit auto-discovery optional

Add an "enable" argument to the discovery component and add a list of
optional devices types (currently just HomeKit) to discover

* Further review comments

* Update requirements_all.txt

* Fix houndci complaints

* Further review updates

* Final review fixup

* Lint fixups

* Fix discovery tests

* Further review updates
This commit is contained in:
Matthew Garrett 2018-04-13 10:25:35 -07:00 committed by Martin Hjelmare
parent 60508f7215
commit ac2298189e
8 changed files with 454 additions and 2 deletions

View file

@ -109,6 +109,9 @@ omit =
homeassistant/components/hive.py
homeassistant/components/*/hive.py
homeassistant/components/homekit_controller/__init__.py
homeassistant/components/*/homekit_controller.py
homeassistant/components/homematic/__init__.py
homeassistant/components/*/homematic.py

View file

@ -40,6 +40,7 @@ SERVICE_HUE = 'philips_hue'
SERVICE_DECONZ = 'deconz'
SERVICE_DAIKIN = 'daikin'
SERVICE_SAMSUNG_PRINTER = 'samsung_printer'
SERVICE_HOMEKIT = 'homekit'
CONFIG_ENTRY_HANDLERS = {
SERVICE_HUE: 'hue',
@ -79,13 +80,20 @@ SERVICE_HANDLERS = {
'songpal': ('media_player', 'songpal'),
}
OPTIONAL_SERVICE_HANDLERS = {
SERVICE_HOMEKIT: ('homekit_controller', None),
}
CONF_IGNORE = 'ignore'
CONF_ENABLE = 'enable'
CONFIG_SCHEMA = vol.Schema({
vol.Required(DOMAIN): vol.Schema({
vol.Optional(CONF_IGNORE, default=[]):
vol.All(cv.ensure_list, [
vol.In(list(CONFIG_ENTRY_HANDLERS) + list(SERVICE_HANDLERS))])
vol.In(list(CONFIG_ENTRY_HANDLERS) + list(SERVICE_HANDLERS))]),
vol.Optional(CONF_ENABLE, default=[]):
vol.All(cv.ensure_list, [vol.In(OPTIONAL_SERVICE_HANDLERS)])
}),
}, extra=vol.ALLOW_EXTRA)
@ -104,6 +112,9 @@ async def async_setup(hass, config):
# Platforms ignore by config
ignored_platforms = config[DOMAIN][CONF_IGNORE]
# Optional platforms enabled by config
enabled_platforms = config[DOMAIN][CONF_ENABLE]
async def new_service_found(service, info):
"""Handle a new service if one is found."""
if service in ignored_platforms:
@ -126,6 +137,9 @@ async def async_setup(hass, config):
comp_plat = SERVICE_HANDLERS.get(service)
if not comp_plat and service in enabled_platforms:
comp_plat = OPTIONAL_SERVICE_HANDLERS[service]
# We do not know how to handle this service.
if not comp_plat:
logger.info("Unknown service discovered: %s %s", service, info)

View file

@ -0,0 +1,228 @@
"""
Support for Homekit device discovery.
For more details about this component, please refer to the documentation at
https://home-assistant.io/components/homekit_controller/
"""
import http
import json
import logging
import os
import uuid
from homeassistant.components.discovery import SERVICE_HOMEKIT
from homeassistant.helpers import discovery
from homeassistant.helpers.entity import Entity
REQUIREMENTS = ['homekit==0.5']
DOMAIN = 'homekit_controller'
HOMEKIT_DIR = '.homekit'
# Mapping from Homekit type to component.
HOMEKIT_ACCESSORY_DISPATCH = {
'lightbulb': 'light',
'outlet': 'switch',
}
KNOWN_ACCESSORIES = "{}-accessories".format(DOMAIN)
KNOWN_DEVICES = "{}-devices".format(DOMAIN)
_LOGGER = logging.getLogger(__name__)
def homekit_http_send(self, message_body=None):
r"""Send the currently buffered request and clear the buffer.
Appends an extra \r\n to the buffer.
A message_body may be specified, to be appended to the request.
"""
self._buffer.extend((b"", b""))
msg = b"\r\n".join(self._buffer)
del self._buffer[:]
if message_body is not None:
msg = msg + message_body
self.send(msg)
def get_serial(accessory):
"""Obtain the serial number of a HomeKit device."""
# pylint: disable=import-error
import homekit
for service in accessory['services']:
if homekit.ServicesTypes.get_short(service['type']) != \
'accessory-information':
continue
for characteristic in service['characteristics']:
ctype = homekit.CharacteristicsTypes.get_short(
characteristic['type'])
if ctype != 'serial-number':
continue
return characteristic['value']
return None
class HKDevice():
"""HomeKit device."""
def __init__(self, hass, host, port, model, hkid, config_num, config):
"""Initialise a generic HomeKit device."""
# pylint: disable=import-error
import homekit
_LOGGER.info("Setting up Homekit device %s", model)
self.hass = hass
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
data_dir = os.path.join(hass.config.path(), HOMEKIT_DIR)
if not os.path.isdir(data_dir):
os.mkdir(data_dir)
self.pairing_file = os.path.join(data_dir, 'hk-{}'.format(hkid))
self.pairing_data = homekit.load_pairing(self.pairing_file)
# Monkey patch httpclient for increased compatibility
# pylint: disable=protected-access
http.client.HTTPConnection._send_output = homekit_http_send
self.conn = http.client.HTTPConnection(self.host, port=self.port)
if self.pairing_data is not None:
self.accessory_setup()
else:
self.configure()
def accessory_setup(self):
"""Handle setup of a HomeKit accessory."""
# pylint: disable=import-error
import homekit
self.controllerkey, self.accessorykey = \
homekit.get_session_keys(self.conn, self.pairing_data)
self.securecon = homekit.SecureHttp(self.conn.sock,
self.accessorykey,
self.controllerkey)
response = self.securecon.get('/accessories')
data = json.loads(response.read().decode())
for accessory in data['accessories']:
serial = get_serial(accessory)
if serial in self.hass.data[KNOWN_ACCESSORIES]:
continue
self.hass.data[KNOWN_ACCESSORIES][serial] = self
aid = accessory['aid']
for service in accessory['services']:
service_info = {'serial': serial,
'aid': aid,
'iid': service['iid']}
devtype = homekit.ServicesTypes.get_short(service['type'])
_LOGGER.debug("Found %s", devtype)
component = HOMEKIT_ACCESSORY_DISPATCH.get(devtype, None)
if component is not None:
discovery.load_platform(self.hass, component, DOMAIN,
service_info, self.config)
def device_config_callback(self, callback_data):
"""Handle initial pairing."""
# pylint: disable=import-error
import homekit
pairing_id = str(uuid.uuid4())
code = callback_data.get('code').strip()
self.pairing_data = homekit.perform_pair_setup(
self.conn, code, pairing_id)
if self.pairing_data is not None:
homekit.save_pairing(self.pairing_file, self.pairing_data)
self.accessory_setup()
else:
error_msg = "Unable to pair, please try again"
_configurator = self.hass.data[DOMAIN+self.hkid]
self.configurator.notify_errors(_configurator, error_msg)
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'}])
class HomeKitEntity(Entity):
"""Representation of a Home Assistant HomeKit device."""
def __init__(self, accessory, devinfo):
"""Initialise a generic HomeKit device."""
self._name = accessory.model
self._securecon = accessory.securecon
self._aid = devinfo['aid']
self._iid = devinfo['iid']
self._address = "homekit-{}-{}".format(devinfo['serial'], self._iid)
self._features = 0
self._chars = {}
def update(self):
"""Obtain a HomeKit device's state."""
response = self._securecon.get('/accessories')
data = json.loads(response.read().decode())
for accessory in data['accessories']:
if accessory['aid'] != self._aid:
continue
for service in accessory['services']:
if service['iid'] != self._iid:
continue
self.update_characteristics(service['characteristics'])
break
@property
def unique_id(self):
"""Return the ID of this device."""
return self._address
@property
def name(self):
"""Return the name of the device if any."""
return self._name
def update_characteristics(self, characteristics):
"""Synchronise a HomeKit device state with Home Assistant."""
raise NotImplementedError
# pylint: too-many-function-args
def setup(hass, config):
"""Set up for Homekit devices."""
def discovery_dispatch(service, discovery_info):
"""Dispatcher for Homekit discovery events."""
# model, id
host = discovery_info['host']
port = discovery_info['port']
model = discovery_info['properties']['md']
hkid = discovery_info['properties']['id']
config_num = int(discovery_info['properties']['c#'])
# 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_info is not None:
device.accessory_setup()
return
_LOGGER.debug('Discovered unique device %s', hkid)
device = HKDevice(hass, host, port, model, hkid, config_num, config)
hass.data[KNOWN_DEVICES][hkid] = device
hass.data[KNOWN_ACCESSORIES] = {}
hass.data[KNOWN_DEVICES] = {}
discovery.listen(hass, SERVICE_HOMEKIT, discovery_dispatch)
return True

View file

@ -0,0 +1,134 @@
"""
Support for Homekit lights.
For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/light.homekit_controller/
"""
import json
import logging
from homeassistant.components.homekit_controller import (
HomeKitEntity, KNOWN_ACCESSORIES)
from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_COLOR_TEMP, SUPPORT_BRIGHTNESS,
SUPPORT_COLOR, SUPPORT_COLOR_TEMP, Light)
DEPENDENCIES = ['homekit_controller']
_LOGGER = logging.getLogger(__name__)
def setup_platform(hass, config, add_devices, discovery_info=None):
"""Set up Homekit lighting."""
if discovery_info is not None:
accessory = hass.data[KNOWN_ACCESSORIES][discovery_info['serial']]
add_devices([HomeKitLight(accessory, discovery_info)], True)
class HomeKitLight(HomeKitEntity, Light):
"""Representation of a Homekit light."""
def __init__(self, *args):
"""Initialise the light."""
super().__init__(*args)
self._on = None
self._brightness = None
self._color_temperature = None
self._hue = None
self._saturation = None
def update_characteristics(self, characteristics):
"""Synchronise light state with Home Assistant."""
# pylint: disable=import-error
import homekit
for characteristic in characteristics:
ctype = characteristic['type']
ctype = homekit.CharacteristicsTypes.get_short(ctype)
if ctype == "on":
self._chars['on'] = characteristic['iid']
self._on = characteristic['value']
elif ctype == 'brightness':
self._chars['brightness'] = characteristic['iid']
self._features |= SUPPORT_BRIGHTNESS
self._brightness = characteristic['value']
elif ctype == 'color-temperature':
self._chars['color_temperature'] = characteristic['iid']
self._features |= SUPPORT_COLOR_TEMP
self._color_temperature = characteristic['value']
elif ctype == "hue":
self._chars['hue'] = characteristic['iid']
self._features |= SUPPORT_COLOR
self._hue = characteristic['value']
elif ctype == "saturation":
self._chars['saturation'] = characteristic['iid']
self._features |= SUPPORT_COLOR
self._saturation = characteristic['value']
@property
def is_on(self):
"""Return true if device is on."""
return self._on
@property
def brightness(self):
"""Return the brightness of this light between 0..255."""
if self._features & SUPPORT_BRIGHTNESS:
return self._brightness * 255 / 100
return None
@property
def hs_color(self):
"""Return the color property."""
if self._features & SUPPORT_COLOR:
return (self._hue, self._saturation)
return None
@property
def color_temp(self):
"""Return the color temperature."""
if self._features & SUPPORT_COLOR_TEMP:
return self._color_temperature
return None
@property
def supported_features(self):
"""Flag supported features."""
return self._features
def turn_on(self, **kwargs):
"""Turn the specified light on."""
hs_color = kwargs.get(ATTR_HS_COLOR)
temperature = kwargs.get(ATTR_COLOR_TEMP)
brightness = kwargs.get(ATTR_BRIGHTNESS)
characteristics = []
if hs_color is not None:
characteristics.append({'aid': self._aid,
'iid': self._chars['hue'],
'value': hs_color[0]})
characteristics.append({'aid': self._aid,
'iid': self._chars['saturation'],
'value': hs_color[1]})
if brightness is not None:
characteristics.append({'aid': self._aid,
'iid': self._chars['brightness'],
'value': int(brightness * 100 / 255)})
if temperature is not None:
characteristics.append({'aid': self._aid,
'iid': self._chars['color-temperature'],
'value': int(temperature)})
characteristics.append({'aid': self._aid,
'iid': self._chars['on'],
'value': True})
body = json.dumps({'characteristics': characteristics})
self._securecon.put('/characteristics', body)
def turn_off(self, **kwargs):
"""Turn the specified light off."""
characteristics = [{'aid': self._aid,
'iid': self._chars['on'],
'value': False}]
body = json.dumps({'characteristics': characteristics})
self._securecon.put('/characteristics', body)

View file

@ -0,0 +1,68 @@
"""
Support for Homekit switches.
For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/switch.homekit_controller/
"""
import json
import logging
from homeassistant.components.homekit_controller import (HomeKitEntity,
KNOWN_ACCESSORIES)
from homeassistant.components.switch import SwitchDevice
DEPENDENCIES = ['homekit_controller']
_LOGGER = logging.getLogger(__name__)
def setup_platform(hass, config, add_devices, discovery_info=None):
"""Set up Homekit switch support."""
if discovery_info is not None:
accessory = hass.data[KNOWN_ACCESSORIES][discovery_info['serial']]
add_devices([HomeKitSwitch(accessory, discovery_info)], True)
class HomeKitSwitch(HomeKitEntity, SwitchDevice):
"""Representation of a Homekit switch."""
def __init__(self, *args):
"""Initialise the switch."""
super().__init__(*args)
self._on = None
def update_characteristics(self, characteristics):
"""Synchronise the switch state with Home Assistant."""
# pylint: disable=import-error
import homekit
for characteristic in characteristics:
ctype = characteristic['type']
ctype = homekit.CharacteristicsTypes.get_short(ctype)
if ctype == "on":
self._chars['on'] = characteristic['iid']
self._on = characteristic['value']
elif ctype == "outlet-in-use":
self._chars['outlet-in-use'] = characteristic['iid']
@property
def is_on(self):
"""Return true if device is on."""
return self._on
def turn_on(self, **kwargs):
"""Turn the specified switch on."""
self._on = True
characteristics = [{'aid': self._aid,
'iid': self._chars['on'],
'value': True}]
body = json.dumps({'characteristics': characteristics})
self._securecon.put('/characteristics', body)
def turn_off(self, **kwargs):
"""Turn the specified switch off."""
characteristics = [{'aid': self._aid,
'iid': self._chars['on'],
'value': False}]
body = json.dumps({'characteristics': characteristics})
self._securecon.put('/characteristics', body)

View file

@ -381,6 +381,9 @@ holidays==0.9.4
# homeassistant.components.frontend
home-assistant-frontend==20180404.0
# homeassistant.components.homekit_controller
# homekit==0.5
# homeassistant.components.homematicip_cloud
homematicip==0.8

View file

@ -33,6 +33,7 @@ COMMENT_REQUIREMENTS = (
'i2csense',
'credstash',
'bme680',
'homekit',
)
TEST_REQUIREMENTS = (

View file

@ -25,7 +25,8 @@ UNKNOWN_SERVICE = 'this_service_will_never_be_supported'
BASE_CONFIG = {
discovery.DOMAIN: {
'ignore': []
'ignore': [],
'enable': []
}
}