diff --git a/.coveragerc b/.coveragerc index 2b733dd699fa8d..3009eed24f0019 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 693cd3d90f1c71..7a343018db55d4 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -40,6 +40,7 @@ SERVICE_DECONZ = 'deconz' SERVICE_DAIKIN = 'daikin' SERVICE_SAMSUNG_PRINTER = 'samsung_printer' +SERVICE_HOMEKIT = 'homekit' CONFIG_ENTRY_HANDLERS = { SERVICE_HUE: 'hue', @@ -79,13 +80,20 @@ '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 new_service_found(service, info): 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) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py new file mode 100644 index 00000000000000..c33edd079188e4 --- /dev/null +++ b/homeassistant/components/homekit_controller/__init__.py @@ -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 diff --git a/homeassistant/components/light/homekit_controller.py b/homeassistant/components/light/homekit_controller.py new file mode 100644 index 00000000000000..e6dc09e455cb27 --- /dev/null +++ b/homeassistant/components/light/homekit_controller.py @@ -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) diff --git a/homeassistant/components/switch/homekit_controller.py b/homeassistant/components/switch/homekit_controller.py new file mode 100644 index 00000000000000..6b97200ba499a3 --- /dev/null +++ b/homeassistant/components/switch/homekit_controller.py @@ -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) diff --git a/requirements_all.txt b/requirements_all.txt index d26f8717384cd1..290f538f4a1178 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 27b972dcefac99..f15425063b47f8 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -33,6 +33,7 @@ 'i2csense', 'credstash', 'bme680', + 'homekit', ) TEST_REQUIREMENTS = ( diff --git a/tests/components/test_discovery.py b/tests/components/test_discovery.py index f3f63654e8b7f8..a956b672ec5328 100644 --- a/tests/components/test_discovery.py +++ b/tests/components/test_discovery.py @@ -25,7 +25,8 @@ BASE_CONFIG = { discovery.DOMAIN: { - 'ignore': [] + 'ignore': [], + 'enable': [] } }