2018-04-13 19:25:35 +02:00
|
|
|
"""
|
|
|
|
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 json
|
|
|
|
import logging
|
|
|
|
import os
|
|
|
|
|
|
|
|
from homeassistant.components.discovery import SERVICE_HOMEKIT
|
|
|
|
from homeassistant.helpers import discovery
|
|
|
|
from homeassistant.helpers.entity import Entity
|
2018-10-04 09:25:05 +02:00
|
|
|
from homeassistant.helpers.event import call_later
|
2018-04-13 19:25:35 +02:00
|
|
|
|
2019-01-23 20:44:21 +01:00
|
|
|
REQUIREMENTS = ['homekit==0.12.2']
|
2018-04-13 19:25:35 +02:00
|
|
|
|
|
|
|
DOMAIN = 'homekit_controller'
|
|
|
|
HOMEKIT_DIR = '.homekit'
|
|
|
|
|
|
|
|
# Mapping from Homekit type to component.
|
|
|
|
HOMEKIT_ACCESSORY_DISPATCH = {
|
|
|
|
'lightbulb': 'light',
|
|
|
|
'outlet': 'switch',
|
2018-11-06 15:32:32 +01:00
|
|
|
'switch': 'switch',
|
2018-06-25 15:45:26 +02:00
|
|
|
'thermostat': 'climate',
|
2019-01-04 20:54:37 +01:00
|
|
|
'security-system': 'alarm_control_panel',
|
2019-01-13 19:09:47 +01:00
|
|
|
'garage-door-opener': 'cover',
|
|
|
|
'window': 'cover',
|
|
|
|
'window-covering': 'cover',
|
2019-01-12 03:48:28 +01:00
|
|
|
'lock-mechanism': 'lock'
|
2018-04-13 19:25:35 +02:00
|
|
|
}
|
|
|
|
|
2018-07-12 11:52:37 +02:00
|
|
|
HOMEKIT_IGNORE = [
|
|
|
|
'BSB002',
|
|
|
|
'Home Assistant Bridge',
|
|
|
|
'TRADFRI gateway'
|
|
|
|
]
|
|
|
|
|
2018-04-13 19:25:35 +02:00
|
|
|
KNOWN_ACCESSORIES = "{}-accessories".format(DOMAIN)
|
|
|
|
KNOWN_DEVICES = "{}-devices".format(DOMAIN)
|
2018-12-24 22:13:17 +01:00
|
|
|
CONTROLLER = "{}-controller".format(DOMAIN)
|
2018-04-13 19:25:35 +02:00
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
2018-10-04 09:25:05 +02:00
|
|
|
REQUEST_TIMEOUT = 5 # seconds
|
|
|
|
RETRY_INTERVAL = 60 # seconds
|
|
|
|
|
2018-12-30 20:44:26 +01:00
|
|
|
PAIRING_FILE = "pairing.json"
|
|
|
|
|
2018-10-04 09:25:05 +02:00
|
|
|
|
2018-04-13 19:25:35 +02:00
|
|
|
def get_serial(accessory):
|
|
|
|
"""Obtain the serial number of a HomeKit device."""
|
2018-12-24 22:13:17 +01:00
|
|
|
# pylint: disable=import-error
|
|
|
|
from homekit.model.services import ServicesTypes
|
|
|
|
from homekit.model.characteristics import CharacteristicsTypes
|
|
|
|
|
2018-04-13 19:25:35 +02:00
|
|
|
for service in accessory['services']:
|
2018-12-24 22:13:17 +01:00
|
|
|
if ServicesTypes.get_short(service['type']) != \
|
2018-04-13 19:25:35 +02:00
|
|
|
'accessory-information':
|
|
|
|
continue
|
|
|
|
for characteristic in service['characteristics']:
|
2018-12-24 22:13:17 +01:00
|
|
|
ctype = CharacteristicsTypes.get_short(
|
2018-04-13 19:25:35 +02:00
|
|
|
characteristic['type'])
|
|
|
|
if ctype != 'serial-number':
|
|
|
|
continue
|
|
|
|
return characteristic['value']
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
2019-01-28 17:21:20 +01:00
|
|
|
def escape_characteristic_name(char_name):
|
|
|
|
"""Escape any dash or dots in a characteristics name."""
|
|
|
|
return char_name.replace('-', '_').replace('.', '_')
|
|
|
|
|
|
|
|
|
2018-04-13 19:25:35 +02:00
|
|
|
class HKDevice():
|
|
|
|
"""HomeKit device."""
|
|
|
|
|
|
|
|
def __init__(self, hass, host, port, model, hkid, config_num, config):
|
|
|
|
"""Initialise a generic HomeKit device."""
|
|
|
|
_LOGGER.info("Setting up Homekit device %s", model)
|
|
|
|
self.hass = hass
|
2018-12-24 22:13:17 +01:00
|
|
|
self.controller = hass.data[CONTROLLER]
|
|
|
|
|
2018-04-13 19:25:35 +02:00
|
|
|
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
|
2018-10-04 09:25:05 +02:00
|
|
|
self._connection_warning_logged = False
|
2018-04-13 19:25:35 +02:00
|
|
|
|
2018-12-24 22:13:17 +01:00
|
|
|
self.pairing = self.controller.pairings.get(hkid)
|
2018-04-13 19:25:35 +02:00
|
|
|
|
2018-12-24 22:13:17 +01:00
|
|
|
if self.pairing is not None:
|
2018-04-13 19:25:35 +02:00
|
|
|
self.accessory_setup()
|
|
|
|
else:
|
|
|
|
self.configure()
|
|
|
|
|
|
|
|
def accessory_setup(self):
|
|
|
|
"""Handle setup of a HomeKit accessory."""
|
2018-12-24 22:13:17 +01:00
|
|
|
# pylint: disable=import-error
|
|
|
|
from homekit.model.services import ServicesTypes
|
2019-01-23 20:44:21 +01:00
|
|
|
from homekit.exceptions import AccessoryDisconnectedError
|
2018-12-24 22:13:17 +01:00
|
|
|
|
|
|
|
self.pairing.pairing_data['AccessoryIP'] = self.host
|
|
|
|
self.pairing.pairing_data['AccessoryPort'] = self.port
|
2018-10-04 09:25:05 +02:00
|
|
|
|
|
|
|
try:
|
2018-12-24 22:13:17 +01:00
|
|
|
data = self.pairing.list_accessories_and_characteristics()
|
2019-01-23 20:44:21 +01:00
|
|
|
except AccessoryDisconnectedError:
|
2018-10-04 09:25:05 +02:00
|
|
|
call_later(
|
|
|
|
self.hass, RETRY_INTERVAL, lambda _: self.accessory_setup())
|
|
|
|
return
|
2018-12-24 22:13:17 +01:00
|
|
|
for accessory in data:
|
2018-04-13 19:25:35 +02:00
|
|
|
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']:
|
2019-01-13 19:09:47 +01:00
|
|
|
devtype = ServicesTypes.get_short(service['type'])
|
|
|
|
_LOGGER.debug("Found %s", devtype)
|
2018-04-13 19:25:35 +02:00
|
|
|
service_info = {'serial': serial,
|
|
|
|
'aid': aid,
|
2019-01-13 19:09:47 +01:00
|
|
|
'iid': service['iid'],
|
2019-01-12 03:48:28 +01:00
|
|
|
'model': self.model,
|
2019-01-13 19:09:47 +01:00
|
|
|
'device-type': devtype}
|
2018-04-13 19:25:35 +02:00
|
|
|
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."""
|
2018-10-10 12:17:11 +02:00
|
|
|
import homekit # pylint: disable=import-error
|
2018-04-13 19:25:35 +02:00
|
|
|
code = callback_data.get('code').strip()
|
2018-04-22 22:38:01 +02:00
|
|
|
try:
|
2018-12-24 22:13:17 +01:00
|
|
|
self.controller.perform_pairing(self.hkid, self.hkid, code)
|
|
|
|
except homekit.UnavailableError:
|
2018-04-22 22:38:01 +02:00
|
|
|
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
|
2018-12-24 22:13:17 +01:00
|
|
|
except homekit.AuthenticationError:
|
2018-04-22 22:38:01 +02:00
|
|
|
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
|
2018-12-24 22:13:17 +01:00
|
|
|
except homekit.UnknownError:
|
2018-04-22 22:38:01 +02:00
|
|
|
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
|
|
|
|
|
2018-12-24 22:13:17 +01:00
|
|
|
self.pairing = self.controller.pairings.get(self.hkid)
|
|
|
|
if self.pairing is not None:
|
|
|
|
pairing_file = os.path.join(
|
|
|
|
self.hass.config.path(),
|
|
|
|
HOMEKIT_DIR,
|
2018-12-30 20:44:26 +01:00
|
|
|
PAIRING_FILE,
|
2018-12-24 22:13:17 +01:00
|
|
|
)
|
|
|
|
self.controller.save_data(pairing_file)
|
2018-04-22 22:38:01 +02:00
|
|
|
_configurator = self.hass.data[DOMAIN+self.hkid]
|
|
|
|
self.configurator.request_done(_configurator)
|
2018-04-13 19:25:35 +02:00
|
|
|
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
|
2018-10-04 09:25:05 +02:00
|
|
|
self._accessory = accessory
|
2018-04-13 19:25:35 +02:00
|
|
|
self._aid = devinfo['aid']
|
|
|
|
self._iid = devinfo['iid']
|
|
|
|
self._address = "homekit-{}-{}".format(devinfo['serial'], self._iid)
|
|
|
|
self._features = 0
|
|
|
|
self._chars = {}
|
2019-01-28 17:21:20 +01:00
|
|
|
self.setup()
|
|
|
|
|
|
|
|
def setup(self):
|
|
|
|
"""Configure an entity baed on its HomeKit characterstics metadata."""
|
|
|
|
# pylint: disable=import-error
|
|
|
|
from homekit.model.characteristics import CharacteristicsTypes
|
|
|
|
|
|
|
|
pairing_data = self._accessory.pairing.pairing_data
|
|
|
|
|
|
|
|
get_uuid = CharacteristicsTypes.get_uuid
|
|
|
|
characteristic_types = [
|
|
|
|
get_uuid(c) for c in self.get_characteristic_types()
|
|
|
|
]
|
|
|
|
|
|
|
|
self._chars_to_poll = []
|
|
|
|
self._chars = {}
|
|
|
|
self._char_names = {}
|
|
|
|
|
|
|
|
for accessory in pairing_data.get('accessories', []):
|
|
|
|
if accessory['aid'] != self._aid:
|
|
|
|
continue
|
|
|
|
for service in accessory['services']:
|
|
|
|
if service['iid'] != self._iid:
|
|
|
|
continue
|
|
|
|
for char in service['characteristics']:
|
|
|
|
uuid = CharacteristicsTypes.get_uuid(char['type'])
|
|
|
|
if uuid not in characteristic_types:
|
|
|
|
continue
|
|
|
|
self._setup_characteristic(char)
|
|
|
|
|
|
|
|
def _setup_characteristic(self, char):
|
|
|
|
"""Configure an entity based on a HomeKit characteristics metadata."""
|
|
|
|
# pylint: disable=import-error
|
|
|
|
from homekit.model.characteristics import CharacteristicsTypes
|
|
|
|
|
|
|
|
# Build up a list of (aid, iid) tuples to poll on update()
|
|
|
|
self._chars_to_poll.append((self._aid, char['iid']))
|
|
|
|
|
|
|
|
# Build a map of ctype -> iid
|
|
|
|
short_name = CharacteristicsTypes.get_short(char['type'])
|
|
|
|
self._chars[short_name] = char['iid']
|
|
|
|
self._char_names[char['iid']] = short_name
|
|
|
|
|
|
|
|
# Callback to allow entity to configure itself based on this
|
|
|
|
# characteristics metadata (valid values, value ranges, features, etc)
|
|
|
|
setup_fn_name = escape_characteristic_name(short_name)
|
|
|
|
setup_fn = getattr(self, '_setup_{}'.format(setup_fn_name), None)
|
|
|
|
if not setup_fn:
|
|
|
|
return
|
|
|
|
# pylint: disable=E1102
|
|
|
|
setup_fn(char)
|
2018-04-13 19:25:35 +02:00
|
|
|
|
|
|
|
def update(self):
|
|
|
|
"""Obtain a HomeKit device's state."""
|
2019-01-23 20:44:21 +01:00
|
|
|
# pylint: disable=import-error
|
|
|
|
from homekit.exceptions import AccessoryDisconnectedError
|
|
|
|
|
2018-10-04 09:25:05 +02:00
|
|
|
try:
|
2018-12-24 22:13:17 +01:00
|
|
|
pairing = self._accessory.pairing
|
|
|
|
data = pairing.list_accessories_and_characteristics()
|
2019-01-23 20:44:21 +01:00
|
|
|
except AccessoryDisconnectedError:
|
2018-10-04 09:25:05 +02:00
|
|
|
return
|
2018-12-24 22:13:17 +01:00
|
|
|
for accessory in data:
|
2018-04-13 19:25:35 +02:00
|
|
|
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
|
|
|
|
|
2018-10-04 09:25:05 +02:00
|
|
|
@property
|
|
|
|
def available(self) -> bool:
|
|
|
|
"""Return True if entity is available."""
|
2018-12-24 22:13:17 +01:00
|
|
|
return self._accessory.pairing is not None
|
2018-10-04 09:25:05 +02:00
|
|
|
|
2019-01-28 17:21:20 +01:00
|
|
|
def get_characteristic_types(self):
|
|
|
|
"""Define the homekit characteristics the entity cares about."""
|
|
|
|
raise NotImplementedError
|
|
|
|
|
2018-04-13 19:25:35 +02:00
|
|
|
def update_characteristics(self, characteristics):
|
|
|
|
"""Synchronise a HomeKit device state with Home Assistant."""
|
|
|
|
raise NotImplementedError
|
|
|
|
|
2018-06-25 15:45:26 +02:00
|
|
|
def put_characteristics(self, characteristics):
|
|
|
|
"""Control a HomeKit device state from Home Assistant."""
|
2018-12-24 22:13:17 +01:00
|
|
|
chars = []
|
|
|
|
for row in characteristics:
|
|
|
|
chars.append((
|
|
|
|
row['aid'],
|
|
|
|
row['iid'],
|
|
|
|
row['value'],
|
|
|
|
))
|
|
|
|
|
|
|
|
self._accessory.pairing.put_characteristics(chars)
|
2018-06-25 15:45:26 +02:00
|
|
|
|
2018-04-13 19:25:35 +02:00
|
|
|
|
|
|
|
def setup(hass, config):
|
|
|
|
"""Set up for Homekit devices."""
|
2018-12-24 22:13:17 +01:00
|
|
|
# pylint: disable=import-error
|
|
|
|
import homekit
|
|
|
|
from homekit.controller import Pairing
|
|
|
|
|
|
|
|
hass.data[CONTROLLER] = controller = homekit.Controller()
|
|
|
|
|
|
|
|
data_dir = os.path.join(hass.config.path(), HOMEKIT_DIR)
|
|
|
|
if not os.path.isdir(data_dir):
|
|
|
|
os.mkdir(data_dir)
|
|
|
|
|
2018-12-30 20:44:26 +01:00
|
|
|
pairing_file = os.path.join(data_dir, PAIRING_FILE)
|
2018-12-24 22:13:17 +01:00
|
|
|
if os.path.exists(pairing_file):
|
|
|
|
controller.load_data(pairing_file)
|
|
|
|
|
|
|
|
# Migrate any existing pairings to the new internal homekit_python format
|
|
|
|
for device in os.listdir(data_dir):
|
|
|
|
if not device.startswith('hk-'):
|
|
|
|
continue
|
|
|
|
alias = device[3:]
|
|
|
|
if alias in controller.pairings:
|
|
|
|
continue
|
|
|
|
with open(os.path.join(data_dir, device)) as pairing_data_fp:
|
|
|
|
pairing_data = json.load(pairing_data_fp)
|
|
|
|
controller.pairings[alias] = Pairing(pairing_data)
|
|
|
|
controller.save_data(pairing_file)
|
|
|
|
|
2018-04-13 19:25:35 +02:00
|
|
|
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#'])
|
|
|
|
|
2018-07-12 11:52:37 +02:00
|
|
|
if model in HOMEKIT_IGNORE:
|
|
|
|
return
|
|
|
|
|
2018-04-13 19:25:35 +02:00
|
|
|
# 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
|