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 updatespull/13776/merge
parent
60508f7215
commit
ac2298189e
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -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)
|
|
@ -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)
|
|
@ -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
|
||||
|
||||
|
|
|
@ -33,6 +33,7 @@ COMMENT_REQUIREMENTS = (
|
|||
'i2csense',
|
||||
'credstash',
|
||||
'bme680',
|
||||
'homekit',
|
||||
)
|
||||
|
||||
TEST_REQUIREMENTS = (
|
||||
|
|
|
@ -25,7 +25,8 @@ UNKNOWN_SERVICE = 'this_service_will_never_be_supported'
|
|||
|
||||
BASE_CONFIG = {
|
||||
discovery.DOMAIN: {
|
||||
'ignore': []
|
||||
'ignore': [],
|
||||
'enable': []
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue