From b589dbf26c1d387105935b45f8d5bde1c3d52be1 Mon Sep 17 00:00:00 2001 From: Nick Whyte Date: Wed, 18 Apr 2018 22:39:58 +1000 Subject: [PATCH] Support basic covers with open/close/stop services HomeKit (#13819) * Support basic covers with open/close/stop services * Support optional stop * Tests --- homeassistant/components/homekit/__init__.py | 2 + homeassistant/components/homekit/const.py | 4 +- .../components/homekit/type_covers.py | 67 +++++++++- .../homekit/test_get_accessories.py | 7 ++ tests/components/homekit/test_type_covers.py | 117 +++++++++++++++++- 5 files changed, 189 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 306f399092a..24c6dfa8a76 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -101,6 +101,8 @@ def get_accessory(hass, state, aid, config): a_type = 'GarageDoorOpener' elif features & SUPPORT_SET_POSITION: a_type = 'WindowCovering' + elif features & (SUPPORT_OPEN | SUPPORT_CLOSE): + a_type = 'WindowCoveringBasic' elif state.domain == 'light': a_type = 'Light' diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 79466cd9ff0..1c498b4b3b9 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -52,7 +52,8 @@ SERV_SMOKE_SENSOR = 'SmokeSensor' SERV_SWITCH = 'Switch' SERV_TEMPERATURE_SENSOR = 'TemperatureSensor' SERV_THERMOSTAT = 'Thermostat' -SERV_WINDOW_COVERING = 'WindowCovering' # CurrentPosition, TargetPosition +SERV_WINDOW_COVERING = 'WindowCovering' +# CurrentPosition, TargetPosition, PositionState # #### Characteristics #### @@ -85,6 +86,7 @@ CHAR_MOTION_DETECTED = 'MotionDetected' CHAR_NAME = 'Name' CHAR_OCCUPANCY_DETECTED = 'OccupancyDetected' CHAR_ON = 'On' # boolean +CHAR_POSITION_STATE = 'PositionState' CHAR_SATURATION = 'Saturation' # percent CHAR_SERIAL_NUMBER = 'SerialNumber' CHAR_SMOKE_DETECTED = 'SmokeDetected' diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 9c852bb4d86..8ec715e0e01 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -2,15 +2,17 @@ import logging from homeassistant.components.cover import ( - ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN) + ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN, SUPPORT_STOP) from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_SET_COVER_POSITION, STATE_OPEN, STATE_CLOSED) + ATTR_ENTITY_ID, SERVICE_SET_COVER_POSITION, STATE_OPEN, STATE_CLOSED, + SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, SERVICE_STOP_COVER, + ATTR_SUPPORTED_FEATURES) 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, CHAR_POSITION_STATE, CATEGORY_GARAGE_DOOR_OPENER, SERV_GARAGE_DOOR_OPENER, CHAR_CURRENT_DOOR_STATE, CHAR_TARGET_DOOR_STATE) @@ -96,3 +98,62 @@ class WindowCovering(HomeAccessory): abs(current_position - self.homekit_target) < 6: self.char_target_position.set_value(current_position) self.homekit_target = None + + +@TYPES.register('WindowCoveringBasic') +class WindowCoveringBasic(HomeAccessory): + """Generate a Window accessory for a cover entity. + + The cover entity must support: open_cover, close_cover, + stop_cover (optional). + """ + + def __init__(self, *args, config): + """Initialize a WindowCovering accessory object.""" + super().__init__(*args, category=CATEGORY_WINDOW_COVERING) + features = self.hass.states.get(self.entity_id) \ + .attributes.get(ATTR_SUPPORTED_FEATURES) + self.supports_stop = features & SUPPORT_STOP + + serv_cover = add_preload_service(self, SERV_WINDOW_COVERING) + self.char_current_position = setup_char( + CHAR_CURRENT_POSITION, serv_cover, value=0) + self.char_target_position = setup_char( + CHAR_TARGET_POSITION, serv_cover, value=0, + callback=self.move_cover) + self.char_position_state = setup_char( + CHAR_POSITION_STATE, serv_cover, value=2) + + def move_cover(self, value): + """Move cover to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set position to %d', self.entity_id, value) + + if self.supports_stop: + if value > 70: + service, position = (SERVICE_OPEN_COVER, 100) + elif value < 30: + service, position = (SERVICE_CLOSE_COVER, 0) + else: + service, position = (SERVICE_STOP_COVER, 50) + else: + if value >= 50: + service, position = (SERVICE_OPEN_COVER, 100) + else: + service, position = (SERVICE_CLOSE_COVER, 0) + + self.hass.services.call(DOMAIN, service, + {ATTR_ENTITY_ID: self.entity_id}) + + # Snap the current/target position to the expected final position. + self.char_current_position.set_value(position) + self.char_target_position.set_value(position) + self.char_position_state.set_value(2) + + def update_state(self, new_state): + """Update cover position after state changed.""" + position_mapping = {STATE_OPEN: 100, STATE_CLOSED: 0} + hk_position = position_mapping.get(new_state.state) + if hk_position is not None: + self.char_current_position.set_value(hk_position) + self.char_target_position.set_value(hk_position) + self.char_position_state.set_value(2) diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 8333f1fb893..c26982e170b 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -154,6 +154,13 @@ class TestGetAccessories(unittest.TestCase): {ATTR_SUPPORTED_FEATURES: 4}) get_accessory(None, state, 2, {}) + def test_cover_open_close(self): + """Test cover with support for open and close.""" + with patch.dict(TYPES, {'WindowCoveringBasic': self.mock_type}): + state = State('cover.open_window', 'open', + {ATTR_SUPPORTED_FEATURES: 3}) + get_accessory(None, state, 2, {}) + def test_alarm_control_panel(self): """Test alarm control panel.""" config = {ATTR_CODE: '1234'} diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index f9889b1bdd8..2dcb48a4d4c 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -3,12 +3,13 @@ import unittest from homeassistant.core import callback from homeassistant.components.cover import ( - ATTR_POSITION, ATTR_CURRENT_POSITION) + ATTR_POSITION, ATTR_CURRENT_POSITION, SUPPORT_STOP) from homeassistant.components.homekit.type_covers import ( - GarageDoorOpener, WindowCovering) + GarageDoorOpener, WindowCovering, WindowCoveringBasic) from homeassistant.const import ( STATE_CLOSED, STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_OPEN, - ATTR_SERVICE, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE) + ATTR_SERVICE, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE, + ATTR_SUPPORTED_FEATURES) from tests.common import get_test_home_assistant @@ -132,9 +133,117 @@ class TestHomekitSensors(unittest.TestCase): acc.char_target_position.client_update_value(75) self.hass.block_till_done() self.assertEqual( - self.events[0].data[ATTR_SERVICE], 'set_cover_position') + self.events[1].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) + + def test_window_open_close(self): + """Test if accessory and HA are updated accordingly.""" + window_cover = 'cover.window' + + self.hass.states.set(window_cover, STATE_UNKNOWN, + {ATTR_SUPPORTED_FEATURES: 0}) + acc = WindowCoveringBasic(self.hass, 'Cover', window_cover, 2, + config=None) + acc.run() + + self.assertEqual(acc.aid, 2) + self.assertEqual(acc.category, 14) # WindowCovering + + self.assertEqual(acc.char_current_position.value, 0) + self.assertEqual(acc.char_target_position.value, 0) + self.assertEqual(acc.char_position_state.value, 2) + + self.hass.states.set(window_cover, STATE_UNKNOWN) + 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, 2) + + self.hass.states.set(window_cover, STATE_OPEN) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_position.value, 100) + self.assertEqual(acc.char_target_position.value, 100) + self.assertEqual(acc.char_position_state.value, 2) + + self.hass.states.set(window_cover, STATE_CLOSED) + 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, 2) + + # Set from HomeKit + acc.char_target_position.client_update_value(25) + self.hass.block_till_done() + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'close_cover') + + self.assertEqual(acc.char_current_position.value, 0) + self.assertEqual(acc.char_target_position.value, 0) + self.assertEqual(acc.char_position_state.value, 2) + + # Set from HomeKit + acc.char_target_position.client_update_value(90) + self.hass.block_till_done() + self.assertEqual( + self.events[1].data[ATTR_SERVICE], 'open_cover') + + self.assertEqual(acc.char_current_position.value, 100) + self.assertEqual(acc.char_target_position.value, 100) + self.assertEqual(acc.char_position_state.value, 2) + + # Set from HomeKit + acc.char_target_position.client_update_value(55) + self.hass.block_till_done() + self.assertEqual( + self.events[2].data[ATTR_SERVICE], 'open_cover') + + self.assertEqual(acc.char_current_position.value, 100) + self.assertEqual(acc.char_target_position.value, 100) + self.assertEqual(acc.char_position_state.value, 2) + + def test_window_open_close_stop(self): + """Test if accessory and HA are updated accordingly.""" + window_cover = 'cover.window' + + self.hass.states.set(window_cover, STATE_UNKNOWN, + {ATTR_SUPPORTED_FEATURES: SUPPORT_STOP}) + acc = WindowCoveringBasic(self.hass, 'Cover', window_cover, 2, + config=None) + acc.run() + + # Set from HomeKit + acc.char_target_position.client_update_value(25) + self.hass.block_till_done() + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'close_cover') + + self.assertEqual(acc.char_current_position.value, 0) + self.assertEqual(acc.char_target_position.value, 0) + self.assertEqual(acc.char_position_state.value, 2) + + # Set from HomeKit + acc.char_target_position.client_update_value(90) + self.hass.block_till_done() + self.assertEqual( + self.events[1].data[ATTR_SERVICE], 'open_cover') + + self.assertEqual(acc.char_current_position.value, 100) + self.assertEqual(acc.char_target_position.value, 100) + self.assertEqual(acc.char_position_state.value, 2) + + # Set from HomeKit + acc.char_target_position.client_update_value(55) + self.hass.block_till_done() + self.assertEqual( + self.events[2].data[ATTR_SERVICE], 'stop_cover') + + self.assertEqual(acc.char_current_position.value, 50) + self.assertEqual(acc.char_target_position.value, 50) + self.assertEqual(acc.char_position_state.value, 2)