Add support for HomeKit (#12488)

* Basic Homekit support

* Added Temperatur Sensor
* Added Window Cover

* Code refactored

* Added class HomeAccessory(Accessory)
* Added class HomeBridge(Bridge)
* Changed homekit imports to relative, to enable use in custom_components
* Updated requirements
* Added docs
* Other smaller changes

* Changed Homekit from entity to class

* Changes based on feedback
* Updated config schema
* Add only covers that support set_cover_position

* Addressed comments, updated to pyhap==1.1.5

* For lint: added files to gen_requirements_all
* Added codeowner

* Small change to Wrapper classes

* Moved imports to import_types, small changes

* Small changes, added tests

* Homekit class: removed add_accessory since it's already covered by pyhap
* Added test requirement: HAP-python
* Added test suit for homekit setup and interaction between HA and pyhap
* Added test suit for get_accessories function

* Test bugfix

* Added validate pincode, tests for cover and sensor types
This commit is contained in:
cdce8p 2018-02-19 23:46:22 +01:00 committed by Paulus Schoutsen
parent dc21c61a44
commit eec3bad94f
14 changed files with 642 additions and 0 deletions

View File

@ -77,6 +77,7 @@ homeassistant/components/*/axis.py @kane610
homeassistant/components/*/broadlink.py @danielhiversen
homeassistant/components/hive.py @Rendili @KJonline
homeassistant/components/*/hive.py @Rendili @KJonline
homeassistant/components/homekit/* @cdce8p
homeassistant/components/*/deconz.py @kane610
homeassistant/components/*/rfxtrx.py @danielhiversen
homeassistant/components/velux.py @Julius2342

View File

@ -0,0 +1,133 @@
"""Support for Apple Homekit.
For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/homekit/
"""
import asyncio
import logging
import re
import voluptuous as vol
from homeassistant.const import (
ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, CONF_PORT,
TEMP_CELSIUS, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
from homeassistant.util import get_local_ip
from homeassistant.util.decorator import Registry
TYPES = Registry()
_LOGGER = logging.getLogger(__name__)
_RE_VALID_PINCODE = re.compile(r"^(\d{3}-\d{2}-\d{3})$")
DOMAIN = 'homekit'
REQUIREMENTS = ['HAP-python==1.1.5']
BRIDGE_NAME = 'Home Assistant'
CONF_PIN_CODE = 'pincode'
HOMEKIT_FILE = '.homekit.state'
def valid_pin(value):
"""Validate pincode value."""
match = _RE_VALID_PINCODE.findall(value.strip())
if match == []:
raise vol.Invalid("Pin must be in the format: '123-45-678'")
return match[0]
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.All({
vol.Optional(CONF_PORT, default=51826): vol.Coerce(int),
vol.Optional(CONF_PIN_CODE, default='123-45-678'): valid_pin,
})
}, extra=vol.ALLOW_EXTRA)
@asyncio.coroutine
def async_setup(hass, config):
"""Setup the homekit component."""
_LOGGER.debug("Begin setup homekit")
conf = config[DOMAIN]
port = conf.get(CONF_PORT)
pin = str.encode(conf.get(CONF_PIN_CODE))
homekit = Homekit(hass, port)
homekit.setup_bridge(pin)
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_START, homekit.start_driver)
return True
def import_types():
"""Import all types from files in the homekit dir."""
_LOGGER.debug("Import type files.")
# pylint: disable=unused-variable
from .covers import Window # noqa F401
# pylint: disable=unused-variable
from .sensors import TemperatureSensor # noqa F401
def get_accessory(hass, state):
"""Take state and return an accessory object if supported."""
if state.domain == 'sensor':
if state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS:
_LOGGER.debug("Add \"%s\" as \"%s\"",
state.entity_id, 'TemperatureSensor')
return TYPES['TemperatureSensor'](hass, state.entity_id,
state.name)
elif state.domain == 'cover':
# Only add covers that support set_cover_position
if state.attributes.get(ATTR_SUPPORTED_FEATURES) & 4:
_LOGGER.debug("Add \"%s\" as \"%s\"",
state.entity_id, 'Window')
return TYPES['Window'](hass, state.entity_id, state.name)
return None
class Homekit():
"""Class to handle all actions between homekit and Home Assistant."""
def __init__(self, hass, port):
"""Initialize a homekit object."""
self._hass = hass
self._port = port
self.bridge = None
self.driver = None
def setup_bridge(self, pin):
"""Setup the bridge component to track all accessories."""
from .accessories import HomeBridge
self.bridge = HomeBridge(BRIDGE_NAME, pincode=pin)
self.bridge.set_accessory_info('homekit.bridge')
def start_driver(self, event):
"""Start the accessory driver."""
from pyhap.accessory_driver import AccessoryDriver
self._hass.bus.listen_once(
EVENT_HOMEASSISTANT_STOP, self.stop_driver)
import_types()
_LOGGER.debug("Start adding accessories.")
for state in self._hass.states.all():
acc = get_accessory(self._hass, state)
if acc is not None:
self.bridge.add_accessory(acc)
ip_address = get_local_ip()
path = self._hass.config.path(HOMEKIT_FILE)
self.driver = AccessoryDriver(self.bridge, self._port,
ip_address, path)
_LOGGER.debug("Driver started")
self.driver.start()
def stop_driver(self, event):
"""Stop the accessory driver."""
_LOGGER.debug("Driver stop")
if self.driver is not None:
self.driver.stop()

View File

@ -0,0 +1,55 @@
"""Extend the basic Accessory and Bridge functions."""
from pyhap.accessory import Accessory, Bridge, Category
from .const import (
SERVICES_ACCESSORY_INFO, MANUFACTURER,
CHAR_MODEL, CHAR_MANUFACTURER, CHAR_SERIAL_NUMBER)
class HomeAccessory(Accessory):
"""Class to extend the Accessory class."""
ALL_CATEGORIES = Category
def __init__(self, display_name):
"""Initialize a Accessory object."""
super().__init__(display_name)
def set_category(self, category):
"""Set the category of the accessory."""
self.category = category
def add_preload_service(self, service):
"""Define the services to be available for the accessory."""
from pyhap.loader import get_serv_loader
self.add_service(get_serv_loader().get(service))
def set_accessory_info(self, model, manufacturer=MANUFACTURER,
serial_number='0000'):
"""Set the default accessory information."""
service_info = self.get_service(SERVICES_ACCESSORY_INFO)
service_info.get_characteristic(CHAR_MODEL) \
.set_value(model)
service_info.get_characteristic(CHAR_MANUFACTURER) \
.set_value(manufacturer)
service_info.get_characteristic(CHAR_SERIAL_NUMBER) \
.set_value(serial_number)
class HomeBridge(Bridge):
"""Class to extend the Bridge class."""
def __init__(self, display_name, pincode):
"""Initialize a Bridge object."""
super().__init__(display_name, pincode=pincode)
def set_accessory_info(self, model, manufacturer=MANUFACTURER,
serial_number='0000'):
"""Set the default accessory information."""
service_info = self.get_service(SERVICES_ACCESSORY_INFO)
service_info.get_characteristic(CHAR_MODEL) \
.set_value(model)
service_info.get_characteristic(CHAR_MANUFACTURER) \
.set_value(manufacturer)
service_info.get_characteristic(CHAR_SERIAL_NUMBER) \
.set_value(serial_number)

View File

@ -0,0 +1,18 @@
"""Constants used be the homekit component."""
MANUFACTURER = 'HomeAssistant'
# Service: AccessoryInfomation
SERVICES_ACCESSORY_INFO = 'AccessoryInformation'
CHAR_MODEL = 'Model'
CHAR_MANUFACTURER = 'Manufacturer'
CHAR_SERIAL_NUMBER = 'SerialNumber'
# Service: TemperatureSensor
SERVICES_TEMPERATURE_SENSOR = 'TemperatureSensor'
CHAR_CURRENT_TEMPERATURE = 'CurrentTemperature'
# Service: WindowCovering
SERVICES_WINDOW_COVERING = 'WindowCovering'
CHAR_CURRENT_POSITION = 'CurrentPosition'
CHAR_TARGET_POSITION = 'TargetPosition'
CHAR_POSITION_STATE = 'PositionState'

View File

@ -0,0 +1,84 @@
"""Class to hold all cover accessories."""
import logging
from homeassistant.components.cover import ATTR_CURRENT_POSITION
from homeassistant.helpers.event import async_track_state_change
from . import TYPES
from .accessories import HomeAccessory
from .const import (
SERVICES_WINDOW_COVERING, CHAR_CURRENT_POSITION,
CHAR_TARGET_POSITION, CHAR_POSITION_STATE)
_LOGGER = logging.getLogger(__name__)
@TYPES.register('Window')
class Window(HomeAccessory):
"""Generate a Window accessory for a cover entity.
The cover entity must support: set_cover_position.
"""
def __init__(self, hass, entity_id, display_name):
"""Initialize a Window accessory object."""
super().__init__(display_name)
self.set_category(self.ALL_CATEGORIES.WINDOW)
self.set_accessory_info(entity_id)
self.add_preload_service(SERVICES_WINDOW_COVERING)
self._hass = hass
self._entity_id = entity_id
self.current_position = None
self.homekit_target = None
self.service_cover = self.get_service(SERVICES_WINDOW_COVERING)
self.char_current_position = self.service_cover. \
get_characteristic(CHAR_CURRENT_POSITION)
self.char_target_position = self.service_cover. \
get_characteristic(CHAR_TARGET_POSITION)
self.char_position_state = self.service_cover. \
get_characteristic(CHAR_POSITION_STATE)
self.char_target_position.setter_callback = self.move_cover
def run(self):
"""Method called be object after driver is started."""
state = self._hass.states.get(self._entity_id)
self.update_cover_position(new_state=state)
async_track_state_change(
self._hass, self._entity_id, self.update_cover_position)
def move_cover(self, value):
"""Move cover to value if call came from homekit."""
if value != self.current_position:
_LOGGER.debug("%s: Set position to %d", self._entity_id, value)
self.homekit_target = value
if value > self.current_position:
self.char_position_state.set_value(1)
elif value < self.current_position:
self.char_position_state.set_value(0)
self._hass.services.call(
'cover', 'set_cover_position',
{'entity_id': self._entity_id, 'position': value})
def update_cover_position(self, entity_id=None, old_state=None,
new_state=None):
"""Update cover position after state changed."""
if new_state is None:
return
current_position = new_state.attributes[ATTR_CURRENT_POSITION]
if current_position is None:
return
self.current_position = int(current_position)
self.char_current_position.set_value(self.current_position)
if self.homekit_target is None or \
abs(self.current_position - self.homekit_target) < 6:
self.char_target_position.set_value(self.current_position)
self.char_position_state.set_value(2)
self.homekit_target = None

View File

@ -0,0 +1,50 @@
"""Class to hold all sensor accessories."""
import logging
from homeassistant.const import STATE_UNKNOWN
from homeassistant.helpers.event import async_track_state_change
from . import TYPES
from .accessories import HomeAccessory
from .const import (
SERVICES_TEMPERATURE_SENSOR, CHAR_CURRENT_TEMPERATURE)
_LOGGER = logging.getLogger(__name__)
@TYPES.register('TemperatureSensor')
class TemperatureSensor(HomeAccessory):
"""Generate a TemperatureSensor accessory for a temperature sensor.
Sensor entity must return either temperature in °C or STATE_UNKNOWN.
"""
def __init__(self, hass, entity_id, display_name):
"""Initialize a TemperatureSensor accessory object."""
super().__init__(display_name)
self.set_category(self.ALL_CATEGORIES.SENSOR)
self.set_accessory_info(entity_id)
self.add_preload_service(SERVICES_TEMPERATURE_SENSOR)
self._hass = hass
self._entity_id = entity_id
self.service_temp = self.get_service(SERVICES_TEMPERATURE_SENSOR)
self.char_temp = self.service_temp. \
get_characteristic(CHAR_CURRENT_TEMPERATURE)
def run(self):
"""Method called be object after driver is started."""
state = self._hass.states.get(self._entity_id)
self.update_temperature(new_state=state)
async_track_state_change(
self._hass, self._entity_id, self.update_temperature)
def update_temperature(self, entity_id=None, old_state=None,
new_state=None):
"""Update temperature after state changed."""
temperature = new_state.state
if temperature != STATE_UNKNOWN:
self.char_temp.set_value(float(temperature))

View File

@ -23,6 +23,9 @@ attrs==17.4.0
# homeassistant.components.doorbird
DoorBirdPy==0.1.2
# homeassistant.components.homekit
HAP-python==1.1.5
# homeassistant.components.isy994
PyISY==1.1.0

View File

@ -18,6 +18,9 @@ flake8-docstrings==1.0.3
asynctest>=0.11.1
# homeassistant.components.homekit
HAP-python==1.1.5
# homeassistant.components.notify.html5
PyJWT==1.5.3

View File

@ -47,6 +47,7 @@ TEST_REQUIREMENTS = (
'evohomeclient',
'feedparser',
'gTTS-token',
'HAP-python',
'ha-ffmpeg',
'haversine',
'hbmqtt',
@ -92,6 +93,9 @@ TEST_REQUIREMENTS = (
IGNORE_PACKAGES = (
'homeassistant.components.recorder.models',
'homeassistant.components.homekit.accessories',
'homeassistant.components.homekit.covers',
'homeassistant.components.homekit.sensors'
)
IGNORE_PIN = ('colorlog>2.1,<3', 'keyring>=9.3,<10.0', 'urllib3')

View File

@ -0,0 +1 @@
"""The tests for the homekit component."""

View File

@ -0,0 +1,83 @@
"""Test different accessory types: Covers."""
import unittest
from homeassistant.core import callback
from homeassistant.components.cover import (
ATTR_POSITION, ATTR_CURRENT_POSITION)
from homeassistant.components.homekit.covers import Window
from homeassistant.const import (
STATE_UNKNOWN, STATE_OPEN,
ATTR_SERVICE, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE)
from tests.common import get_test_home_assistant
class TestHomekitSensors(unittest.TestCase):
"""Test class for all accessory types regarding covers."""
def setUp(self):
"""Setup things to be run when tests are started."""
self.hass = get_test_home_assistant()
self.events = []
@callback
def record_event(event):
"""Track called event."""
self.events.append(event)
self.hass.bus.listen(EVENT_CALL_SERVICE, record_event)
def tearDown(self):
"""Stop down everthing that was started."""
self.hass.stop()
def test_window_set_cover_position(self):
"""Test if accessory and HA are updated accordingly."""
window_cover = 'cover.window'
acc = Window(self.hass, window_cover, 'Cover')
acc.run()
self.assertEqual(acc.char_current_position.value, 0)
self.assertEqual(acc.char_target_position.value, 0)
self.assertEqual(acc.char_position_state.value, 0)
self.hass.states.set(window_cover, STATE_UNKNOWN,
{ATTR_CURRENT_POSITION: None})
self.hass.block_till_done()
self.assertEqual(acc.char_current_position.value, 0)
self.assertEqual(acc.char_target_position.value, 0)
self.assertEqual(acc.char_position_state.value, 0)
self.hass.states.set(window_cover, STATE_OPEN,
{ATTR_CURRENT_POSITION: 50})
self.hass.block_till_done()
self.assertEqual(acc.char_current_position.value, 50)
self.assertEqual(acc.char_target_position.value, 50)
self.assertEqual(acc.char_position_state.value, 2)
# Set from HomeKit
acc.char_target_position.set_value(25)
self.hass.block_till_done()
self.assertEqual(
self.events[0].data[ATTR_SERVICE], 'set_cover_position')
self.assertEqual(
self.events[0].data[ATTR_SERVICE_DATA][ATTR_POSITION], 25)
self.assertEqual(acc.char_current_position.value, 50)
self.assertEqual(acc.char_target_position.value, 25)
self.assertEqual(acc.char_position_state.value, 0)
# Set from HomeKit
acc.char_target_position.set_value(75)
self.hass.block_till_done()
self.assertEqual(
self.events[0].data[ATTR_SERVICE], 'set_cover_position')
self.assertEqual(
self.events[1].data[ATTR_SERVICE_DATA][ATTR_POSITION], 75)
self.assertEqual(acc.char_current_position.value, 50)
self.assertEqual(acc.char_target_position.value, 75)
self.assertEqual(acc.char_position_state.value, 1)

View File

@ -0,0 +1,46 @@
"""Package to test the get_accessory method."""
from unittest.mock import patch, MagicMock
from homeassistant.core import State
from homeassistant.components.homekit import (
TYPES, get_accessory, import_types)
from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT, ATTR_SUPPORTED_FEATURES,
TEMP_CELSIUS, STATE_UNKNOWN)
def test_import_types():
"""Test if all type files are imported correctly."""
try:
import_types()
assert True
# pylint: disable=broad-except
except Exception:
assert False
def test_component_not_supported():
"""Test with unsupported component."""
state = State('demo.unsupported', STATE_UNKNOWN)
assert True if get_accessory(None, state) is None else False
def test_sensor_temperatur_celsius():
"""Test temperature sensor with celsius as unit."""
mock_type = MagicMock()
with patch.dict(TYPES, {'TemperatureSensor': mock_type}):
state = State('sensor.temperatur', '23',
{ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
get_accessory(None, state)
assert len(mock_type.mock_calls) == 1
def test_cover_set_position():
"""Test cover with support for set_cover_position."""
mock_type = MagicMock()
with patch.dict(TYPES, {'Window': mock_type}):
state = State('cover.setposition', 'open',
{ATTR_SUPPORTED_FEATURES: 4})
get_accessory(None, state)
assert len(mock_type.mock_calls) == 1

View File

@ -0,0 +1,124 @@
"""Tests for the homekit component."""
import unittest
from unittest.mock import patch
import voluptuous as vol
from homeassistant import setup
from homeassistant.core import Event
from homeassistant.components.homekit import (
CONF_PIN_CODE, BRIDGE_NAME, Homekit, valid_pin)
from homeassistant.components.homekit.covers import Window
from homeassistant.components.homekit.sensors import TemperatureSensor
from homeassistant.const import (
CONF_PORT, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
from tests.common import get_test_home_assistant
HOMEKIT_PATH = 'homeassistant.components.homekit'
CONFIG_MIN = {'homekit': {}}
CONFIG = {
'homekit': {
CONF_PORT: 11111,
CONF_PIN_CODE: '987-65-432',
}
}
class TestHomekit(unittest.TestCase):
"""Test the Multicover component."""
def setUp(self):
"""Setup things to be run when tests are started."""
self.hass = get_test_home_assistant()
def tearDown(self):
"""Stop down everthing that was started."""
self.hass.stop()
@patch(HOMEKIT_PATH + '.Homekit.start_driver')
@patch(HOMEKIT_PATH + '.Homekit.setup_bridge')
@patch(HOMEKIT_PATH + '.Homekit.__init__')
def test_setup_min(self, mock_homekit, mock_setup_bridge,
mock_start_driver):
"""Test async_setup with minimal config option."""
mock_homekit.return_value = None
self.assertTrue(setup.setup_component(
self.hass, 'homekit', CONFIG_MIN))
mock_homekit.assert_called_once_with(self.hass, 51826)
mock_setup_bridge.assert_called_with(b'123-45-678')
mock_start_driver.assert_not_called()
self.hass.start()
self.hass.block_till_done()
mock_start_driver.assert_called_once()
@patch(HOMEKIT_PATH + '.Homekit.start_driver')
@patch(HOMEKIT_PATH + '.Homekit.setup_bridge')
@patch(HOMEKIT_PATH + '.Homekit.__init__')
def test_setup_parameters(self, mock_homekit, mock_setup_bridge,
mock_start_driver):
"""Test async_setup with full config option."""
mock_homekit.return_value = None
self.assertTrue(setup.setup_component(
self.hass, 'homekit', CONFIG))
mock_homekit.assert_called_once_with(self.hass, 11111)
mock_setup_bridge.assert_called_with(b'987-65-432')
def test_validate_pincode(self):
"""Test async_setup with invalid config option."""
schema = vol.Schema(valid_pin)
for value in ('', '123-456-78', 'a23-45-678', '12345678'):
with self.assertRaises(vol.MultipleInvalid):
schema(value)
for value in ('123-45-678', '234-56-789'):
self.assertTrue(schema(value))
@patch('pyhap.accessory_driver.AccessoryDriver.persist')
@patch('pyhap.accessory_driver.AccessoryDriver.stop')
@patch('pyhap.accessory_driver.AccessoryDriver.start')
@patch(HOMEKIT_PATH + '.import_types')
@patch(HOMEKIT_PATH + '.get_accessory')
def test_homekit_pyhap_interaction(
self, mock_get_accessory, mock_import_types,
mock_driver_start, mock_driver_stop, mock_file_persist):
"""Test the interaction between the homekit class and pyhap."""
acc1 = TemperatureSensor(self.hass, 'sensor.temp', 'Temperature')
acc2 = Window(self.hass, 'cover.hall_window', 'Cover')
mock_get_accessory.side_effect = [acc1, acc2]
homekit = Homekit(self.hass, 51826)
homekit.setup_bridge(b'123-45-678')
self.assertEqual(homekit.bridge.display_name, BRIDGE_NAME)
self.hass.states.set('demo.demo1', 'on')
self.hass.states.set('demo.demo2', 'off')
self.hass.start()
self.hass.block_till_done()
homekit.start_driver(Event(EVENT_HOMEASSISTANT_START))
self.assertEqual(mock_get_accessory.call_count, 2)
mock_import_types.assert_called_once()
mock_driver_start.assert_called_once()
accessories = homekit.bridge.accessories
self.assertEqual(accessories[2], acc1)
self.assertEqual(accessories[3], acc2)
mock_driver_stop.assert_not_called()
self.hass.bus.fire(EVENT_HOMEASSISTANT_STOP)
self.hass.block_till_done()
mock_driver_stop.assert_called_once()

View File

@ -0,0 +1,37 @@
"""Test different accessory types: Sensors."""
import unittest
from homeassistant.components.homekit.sensors import TemperatureSensor
from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, STATE_UNKNOWN)
from tests.common import get_test_home_assistant
class TestHomekitSensors(unittest.TestCase):
"""Test class for all accessory types regarding sensors."""
def setUp(self):
"""Setup things to be run when tests are started."""
self.hass = get_test_home_assistant()
def tearDown(self):
"""Stop down everthing that was started."""
self.hass.stop()
def test_temperature_celsius(self):
"""Test if accessory is updated after state change."""
temperature_sensor = 'sensor.temperature'
self.hass.states.set(temperature_sensor, STATE_UNKNOWN,
{ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
self.hass.block_till_done()
acc = TemperatureSensor(self.hass, temperature_sensor, 'Temperature')
acc.run()
self.assertEqual(acc.char_temp.value, 0.0)
self.hass.states.set(temperature_sensor, '20')
self.hass.block_till_done()
self.assertEqual(acc.char_temp.value, 20)