From 6208d8c911226f690abca34bdb3e446957d4b7ae Mon Sep 17 00:00:00 2001 From: Marcel Steinbach Date: Tue, 31 Mar 2020 02:47:03 +0200 Subject: [PATCH] Add HomeKit support for slat tilting (#33388) * Add HomeKit support for slat tilting * Reset tilt-specific attribute, not position attribute Co-Authored-By: J. Nick Koston * Add explanation why we fix HomeKit's targets We have to assume that the device has worse precision than HomeKit. If it reports back a state that is only _close_ to HK's requested state, we'll "fix" what HomeKit requested so that it won't appear out of sync. Co-authored-by: J. Nick Koston --- homeassistant/components/homekit/const.py | 4 + .../components/homekit/type_covers.py | 73 ++++++++++++++++++- tests/components/homekit/test_type_covers.py | 70 ++++++++++++++++++ 3 files changed, 143 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 82ec296da4b..ac421913f6f 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -1,10 +1,12 @@ """Constants used be the HomeKit component.""" # #### Misc #### DEBOUNCE_TIMEOUT = 0.5 +DEVICE_PRECISION_LEEWAY = 6 DOMAIN = "homekit" HOMEKIT_FILE = ".homekit.state" HOMEKIT_NOTIFY_ID = 4663548 + # #### Attributes #### ATTR_DISPLAY_NAME = "display_name" ATTR_VALUE = "value" @@ -106,6 +108,7 @@ CHAR_CURRENT_POSITION = "CurrentPosition" CHAR_CURRENT_HUMIDITY = "CurrentRelativeHumidity" CHAR_CURRENT_SECURITY_STATE = "SecuritySystemCurrentState" CHAR_CURRENT_TEMPERATURE = "CurrentTemperature" +CHAR_CURRENT_TILT_ANGLE = "CurrentHorizontalTiltAngle" CHAR_CURRENT_VISIBILITY_STATE = "CurrentVisibilityState" CHAR_FIRMWARE_REVISION = "FirmwareRevision" CHAR_HEATING_THRESHOLD_TEMPERATURE = "HeatingThresholdTemperature" @@ -141,6 +144,7 @@ CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState" CHAR_TARGET_POSITION = "TargetPosition" CHAR_TARGET_SECURITY_STATE = "SecuritySystemTargetState" CHAR_TARGET_TEMPERATURE = "TargetTemperature" +CHAR_TARGET_TILT_ANGLE = "TargetHorizontalTiltAngle" CHAR_TEMP_DISPLAY_UNITS = "TemperatureDisplayUnits" CHAR_VALVE_TYPE = "ValveType" CHAR_VOLUME = "Volume" diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index d77ea22dc96..97940952171 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -5,8 +5,11 @@ from pyhap.const import CATEGORY_GARAGE_DOOR_OPENER, CATEGORY_WINDOW_COVERING from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, + ATTR_TILT_POSITION, DOMAIN, + SUPPORT_SET_TILT_POSITION, SUPPORT_STOP, ) from homeassistant.const import ( @@ -15,6 +18,7 @@ from homeassistant.const import ( SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, STATE_CLOSED, STATE_CLOSING, @@ -27,9 +31,12 @@ from .accessories import HomeAccessory, debounce from .const import ( CHAR_CURRENT_DOOR_STATE, CHAR_CURRENT_POSITION, + CHAR_CURRENT_TILT_ANGLE, CHAR_POSITION_STATE, CHAR_TARGET_DOOR_STATE, CHAR_TARGET_POSITION, + CHAR_TARGET_TILT_ANGLE, + DEVICE_PRECISION_LEEWAY, SERV_GARAGE_DOOR_OPENER, SERV_WINDOW_COVERING, ) @@ -94,9 +101,28 @@ class WindowCovering(HomeAccessory): def __init__(self, *args): """Initialize a WindowCovering accessory object.""" super().__init__(*args, category=CATEGORY_WINDOW_COVERING) - self._homekit_target = None - serv_cover = self.add_preload_service(SERV_WINDOW_COVERING) + self._homekit_target = None + self._homekit_target_tilt = None + + serv_cover = self.add_preload_service( + SERV_WINDOW_COVERING, + chars=[CHAR_TARGET_TILT_ANGLE, CHAR_CURRENT_TILT_ANGLE], + ) + + features = self.hass.states.get(self.entity_id).attributes.get( + ATTR_SUPPORTED_FEATURES, 0 + ) + + self._supports_tilt = features & SUPPORT_SET_TILT_POSITION + if self._supports_tilt: + self.char_target_tilt = serv_cover.configure_char( + CHAR_TARGET_TILT_ANGLE, setter_callback=self.set_tilt + ) + self.char_current_tilt = serv_cover.configure_char( + CHAR_CURRENT_TILT_ANGLE, value=0 + ) + self.char_current_position = serv_cover.configure_char( CHAR_CURRENT_POSITION, value=0 ) @@ -107,6 +133,20 @@ class WindowCovering(HomeAccessory): CHAR_POSITION_STATE, value=2 ) + @debounce + def set_tilt(self, value): + """Set tilt to value if call came from HomeKit.""" + self._homekit_target_tilt = value + _LOGGER.info("%s: Set tilt to %d", self.entity_id, value) + + # HomeKit sends values between -90 and 90. + # We'll have to normalize to [0,100] + value = round((value + 90) / 180.0 * 100.0) + + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_TILT_POSITION: value} + + self.call_service(DOMAIN, SERVICE_SET_COVER_TILT_POSITION, params, value) + @debounce def move_cover(self, value): """Move cover to value if call came from HomeKit.""" @@ -117,14 +157,20 @@ class WindowCovering(HomeAccessory): self.call_service(DOMAIN, SERVICE_SET_COVER_POSITION, params, value) def update_state(self, new_state): - """Update cover position after state changed.""" + """Update cover position and tilt after state changed.""" current_position = new_state.attributes.get(ATTR_CURRENT_POSITION) if isinstance(current_position, (float, int)): current_position = int(current_position) self.char_current_position.set_value(current_position) + + # We have to assume that the device has worse precision than HomeKit. + # If it reports back a state that is only _close_ to HK's requested + # state, we'll "fix" what HomeKit requested so that it won't appear + # out of sync. if ( self._homekit_target is None - or abs(current_position - self._homekit_target) < 6 + or abs(current_position - self._homekit_target) + < DEVICE_PRECISION_LEEWAY ): self.char_target_position.set_value(current_position) self._homekit_target = None @@ -135,6 +181,25 @@ class WindowCovering(HomeAccessory): else: self.char_position_state.set_value(2) + # update tilt + current_tilt = new_state.attributes.get(ATTR_CURRENT_TILT_POSITION) + if isinstance(current_tilt, (float, int)): + # HomeKit sends values between -90 and 90. + # We'll have to normalize to [0,100] + current_tilt = (current_tilt / 100.0 * 180.0) - 90.0 + current_tilt = int(current_tilt) + self.char_current_tilt.set_value(current_tilt) + + # We have to assume that the device has worse precision than HomeKit. + # If it reports back a state that is only _close_ to HK's requested + # state, we'll "fix" what HomeKit requested so that it won't appear + # out of sync. + if self._homekit_target_tilt is None or abs( + current_tilt - self._homekit_target_tilt < DEVICE_PRECISION_LEEWAY + ): + self.char_target_tilt.set_value(current_tilt) + self._homekit_target_tilt = None + @TYPES.register("WindowCoveringBasic") class WindowCoveringBasic(HomeAccessory): diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index 87d4fbdcc2b..eb7429aa47e 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -5,8 +5,11 @@ import pytest from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, + ATTR_TILT_POSITION, DOMAIN, + SUPPORT_SET_TILT_POSITION, SUPPORT_STOP, ) from homeassistant.components.homekit.const import ATTR_VALUE @@ -14,6 +17,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, EVENT_HOMEASSISTANT_START, + SERVICE_SET_COVER_TILT_POSITION, STATE_CLOSED, STATE_CLOSING, STATE_OPEN, @@ -193,6 +197,72 @@ async def test_window_set_cover_position(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] == 75 +async def test_window_cover_set_tilt(hass, hk_driver, cls, events): + """Test if accessory and HA update slat tilt accordingly.""" + entity_id = "cover.window" + + hass.states.async_set( + entity_id, STATE_UNKNOWN, {ATTR_SUPPORTED_FEATURES: SUPPORT_SET_TILT_POSITION} + ) + await hass.async_block_till_done() + acc = cls.window(hass, hk_driver, "Cover", entity_id, 2, None) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 14 # CATEGORY_WINDOW_COVERING + + assert acc.char_current_tilt.value == 0 + assert acc.char_target_tilt.value == 0 + + hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: None}) + await hass.async_block_till_done() + assert acc.char_current_tilt.value == 0 + assert acc.char_target_tilt.value == 0 + + hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: 100}) + await hass.async_block_till_done() + assert acc.char_current_tilt.value == 90 + assert acc.char_target_tilt.value == 90 + + hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: 50}) + await hass.async_block_till_done() + assert acc.char_current_tilt.value == 0 + assert acc.char_target_tilt.value == 0 + + hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: 0}) + await hass.async_block_till_done() + assert acc.char_current_tilt.value == -90 + assert acc.char_target_tilt.value == -90 + + # set from HomeKit + call_set_tilt_position = async_mock_service( + hass, DOMAIN, SERVICE_SET_COVER_TILT_POSITION + ) + + # HomeKit sets tilts between -90 and 90 (degrees), whereas + # Homeassistant expects a % between 0 and 100. Keep that in mind + # when comparing + await hass.async_add_job(acc.char_target_tilt.client_update_value, 90) + await hass.async_block_till_done() + assert call_set_tilt_position[0] + assert call_set_tilt_position[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_tilt_position[0].data[ATTR_TILT_POSITION] == 100 + assert acc.char_current_tilt.value == -90 + assert acc.char_target_tilt.value == 90 + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == 100 + + await hass.async_add_job(acc.char_target_tilt.client_update_value, 45) + await hass.async_block_till_done() + assert call_set_tilt_position[1] + assert call_set_tilt_position[1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_tilt_position[1].data[ATTR_TILT_POSITION] == 75 + assert acc.char_current_tilt.value == -90 + assert acc.char_target_tilt.value == 45 + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] == 75 + + async def test_window_open_close(hass, hk_driver, cls, events): """Test if accessory and HA are updated accordingly.""" entity_id = "cover.window"