From 993866a31435d286d56aad8d4a5316d4d3007de1 Mon Sep 17 00:00:00 2001 From: Mark Coombes Date: Thu, 12 Apr 2018 12:08:48 -0400 Subject: [PATCH] Support Garage Doors in HomeKit (#13796) --- homeassistant/components/homekit/__init__.py | 11 +++- homeassistant/components/homekit/const.py | 4 ++ .../components/homekit/type_covers.py | 50 ++++++++++++++- .../homekit/test_get_accessories.py | 11 ++++ tests/components/homekit/test_type_covers.py | 63 ++++++++++++++++++- 5 files changed, 132 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 1092cea0c6e..306f399092a 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -8,7 +8,8 @@ from zlib import adler32 import voluptuous as vol -from homeassistant.components.cover import SUPPORT_SET_POSITION +from homeassistant.components.cover import ( + SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_SET_POSITION) from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, ATTR_DEVICE_CLASS, CONF_PORT, TEMP_CELSIUS, TEMP_FAHRENHEIT, @@ -92,9 +93,13 @@ def get_accessory(hass, state, aid, config): a_type = 'Thermostat' elif state.domain == 'cover': - # Only add covers that support set_cover_position features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if features & SUPPORT_SET_POSITION: + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + + if device_class == 'garage' and \ + features & (SUPPORT_OPEN | SUPPORT_CLOSE): + a_type = 'GarageDoorOpener' + elif features & SUPPORT_SET_POSITION: a_type = 'WindowCovering' elif state.domain == 'light': diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 7cde51b5416..79466cd9ff0 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -24,6 +24,7 @@ MANUFACTURER = 'HomeAssistant' # #### Categories #### CATEGORY_ALARM_SYSTEM = 'ALARM_SYSTEM' +CATEGORY_GARAGE_DOOR_OPENER = 'GARAGE_DOOR_OPENER' CATEGORY_LIGHT = 'LIGHTBULB' CATEGORY_LOCK = 'DOOR_LOCK' CATEGORY_SENSOR = 'SENSOR' @@ -38,6 +39,7 @@ SERV_AIR_QUALITY_SENSOR = 'AirQualitySensor' SERV_CARBON_DIOXIDE_SENSOR = 'CarbonDioxideSensor' SERV_CARBON_MONOXIDE_SENSOR = 'CarbonMonoxideSensor' SERV_CONTACT_SENSOR = 'ContactSensor' +SERV_GARAGE_DOOR_OPENER = 'GarageDoorOpener' SERV_HUMIDITY_SENSOR = 'HumiditySensor' # CurrentRelativeHumidity SERV_LEAK_SENSOR = 'LeakSensor' SERV_LIGHT_SENSOR = 'LightSensor' @@ -65,6 +67,7 @@ CHAR_COLOR_TEMPERATURE = 'ColorTemperature' CHAR_CONTACT_SENSOR_STATE = 'ContactSensorState' CHAR_COOLING_THRESHOLD_TEMPERATURE = 'CoolingThresholdTemperature' CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = 'CurrentAmbientLightLevel' +CHAR_CURRENT_DOOR_STATE = 'CurrentDoorState' CHAR_CURRENT_HEATING_COOLING = 'CurrentHeatingCoolingState' CHAR_CURRENT_POSITION = 'CurrentPosition' # Int | [0, 100] CHAR_CURRENT_HUMIDITY = 'CurrentRelativeHumidity' # percent @@ -85,6 +88,7 @@ CHAR_ON = 'On' # boolean CHAR_SATURATION = 'Saturation' # percent CHAR_SERIAL_NUMBER = 'SerialNumber' CHAR_SMOKE_DETECTED = 'SmokeDetected' +CHAR_TARGET_DOOR_STATE = 'TargetDoorState' CHAR_TARGET_HEATING_COOLING = 'TargetHeatingCoolingState' CHAR_TARGET_POSITION = 'TargetPosition' # Int | [0, 100] CHAR_TARGET_SECURITY_STATE = 'SecuritySystemTargetState' diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index d8a6a8c2fdc..9c852bb4d86 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -3,17 +3,63 @@ import logging from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN) -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SET_COVER_POSITION +from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_SET_COVER_POSITION, STATE_OPEN, STATE_CLOSED) from . import TYPES from .accessories import HomeAccessory, add_preload_service, setup_char from .const import ( CATEGORY_WINDOW_COVERING, SERV_WINDOW_COVERING, - CHAR_CURRENT_POSITION, CHAR_TARGET_POSITION) + CHAR_CURRENT_POSITION, CHAR_TARGET_POSITION, + CATEGORY_GARAGE_DOOR_OPENER, SERV_GARAGE_DOOR_OPENER, + CHAR_CURRENT_DOOR_STATE, CHAR_TARGET_DOOR_STATE) _LOGGER = logging.getLogger(__name__) +@TYPES.register('GarageDoorOpener') +class GarageDoorOpener(HomeAccessory): + """Generate a Garage Door Opener accessory for a cover entity. + + The cover entity must be in the 'garage' device class + and support no more than open, close, and stop. + """ + + def __init__(self, *args, config): + """Initialize a GarageDoorOpener accessory object.""" + super().__init__(*args, category=CATEGORY_GARAGE_DOOR_OPENER) + self.flag_target_state = False + + serv_garage_door = add_preload_service(self, SERV_GARAGE_DOOR_OPENER) + self.char_current_state = setup_char( + CHAR_CURRENT_DOOR_STATE, serv_garage_door, value=0) + self.char_target_state = setup_char( + CHAR_TARGET_DOOR_STATE, serv_garage_door, value=0, + callback=self.set_state) + + def set_state(self, value): + """Change garage state if call came from HomeKit.""" + _LOGGER.debug('%s: Set state to %d', self.entity_id, value) + self.flag_target_state = True + + if value == 0: + self.char_current_state.set_value(3) + self.hass.components.cover.open_cover(self.entity_id) + elif value == 1: + self.char_current_state.set_value(2) + self.hass.components.cover.close_cover(self.entity_id) + + def update_state(self, new_state): + """Update cover state after state changed.""" + hass_state = new_state.state + if hass_state in (STATE_OPEN, STATE_CLOSED): + current_state = 0 if hass_state == STATE_OPEN else 1 + self.char_current_state.set_value(current_state) + if not self.flag_target_state: + self.char_target_state.set_value(current_state) + self.flag_target_state = False + + @TYPES.register('WindowCovering') class WindowCovering(HomeAccessory): """Generate a Window accessory for a cover entity. diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 052b7557c11..8333f1fb893 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -4,6 +4,8 @@ import unittest from unittest.mock import patch, Mock from homeassistant.core import State +from homeassistant.components.cover import ( + SUPPORT_OPEN, SUPPORT_CLOSE) from homeassistant.components.climate import ( SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW) from homeassistant.components.homekit import get_accessory, TYPES @@ -136,6 +138,15 @@ class TestGetAccessories(unittest.TestCase): state = State('device_tracker.someone', 'not_home', {}) get_accessory(None, state, 2, {}) + def test_garage_door(self): + """Test cover with device_class: 'garage' and required features.""" + with patch.dict(TYPES, {'GarageDoorOpener': self.mock_type}): + state = State('cover.garage_door', 'open', { + ATTR_DEVICE_CLASS: 'garage', + ATTR_SUPPORTED_FEATURES: + SUPPORT_OPEN | SUPPORT_CLOSE}) + get_accessory(None, state, 2, {}) + def test_cover_set_position(self): """Test cover with support for set_cover_position.""" with patch.dict(TYPES, {'WindowCovering': self.mock_type}): diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index 43e82e74b1a..f9889b1bdd8 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -4,9 +4,10 @@ import unittest from homeassistant.core import callback from homeassistant.components.cover import ( ATTR_POSITION, ATTR_CURRENT_POSITION) -from homeassistant.components.homekit.type_covers import WindowCovering +from homeassistant.components.homekit.type_covers import ( + GarageDoorOpener, WindowCovering) from homeassistant.const import ( - STATE_UNKNOWN, STATE_OPEN, + STATE_CLOSED, STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_OPEN, ATTR_SERVICE, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE) from tests.common import get_test_home_assistant @@ -31,6 +32,64 @@ class TestHomekitSensors(unittest.TestCase): """Stop down everything that was started.""" self.hass.stop() + def test_garage_door_open_close(self): + """Test if accessory and HA are updated accordingly.""" + garage_door = 'cover.garage_door' + + acc = GarageDoorOpener(self.hass, 'Cover', garage_door, 2, config=None) + acc.run() + + self.assertEqual(acc.aid, 2) + self.assertEqual(acc.category, 4) # GarageDoorOpener + + self.assertEqual(acc.char_current_state.value, 0) + self.assertEqual(acc.char_target_state.value, 0) + + self.hass.states.set(garage_door, STATE_CLOSED) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 1) + self.assertEqual(acc.char_target_state.value, 1) + + self.hass.states.set(garage_door, STATE_OPEN) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 0) + self.assertEqual(acc.char_target_state.value, 0) + + self.hass.states.set(garage_door, STATE_UNAVAILABLE) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 0) + self.assertEqual(acc.char_target_state.value, 0) + + self.hass.states.set(garage_door, STATE_UNKNOWN) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 0) + self.assertEqual(acc.char_target_state.value, 0) + + # Set closed from HomeKit + acc.char_target_state.client_update_value(1) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 2) + self.assertEqual(acc.char_target_state.value, 1) + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'close_cover') + + self.hass.states.set(garage_door, STATE_CLOSED) + self.hass.block_till_done() + + # Set open from HomeKit + acc.char_target_state.client_update_value(0) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 3) + self.assertEqual(acc.char_target_state.value, 0) + self.assertEqual( + self.events[1].data[ATTR_SERVICE], 'open_cover') + def test_window_set_cover_position(self): """Test if accessory and HA are updated accordingly.""" window_cover = 'cover.window'