diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 1c748af8571..3d64aaf3bea 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -148,6 +148,7 @@ CHAR_TARGET_HUMIDITY = "TargetRelativeHumidity" CHAR_TARGET_SECURITY_STATE = "SecuritySystemTargetState" CHAR_TARGET_TEMPERATURE = "TargetTemperature" CHAR_TARGET_TILT_ANGLE = "TargetHorizontalTiltAngle" +CHAR_HOLD_POSITION = "HoldPosition" 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 624f23d0423..8e55bc2a4b9 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -32,6 +32,7 @@ from .const import ( CHAR_CURRENT_DOOR_STATE, CHAR_CURRENT_POSITION, CHAR_CURRENT_TILT_ANGLE, + CHAR_HOLD_POSITION, CHAR_POSITION_STATE, CHAR_TARGET_DOOR_STATE, CHAR_TARGET_POSITION, @@ -123,47 +124,50 @@ class GarageDoorOpener(HomeAccessory): self.char_current_state.set_value(current_door_state) -@TYPES.register("WindowCovering") -class WindowCovering(HomeAccessory): - """Generate a Window accessory for a cover entity. +class WindowCoveringBase(HomeAccessory): + """Generate a base Window accessory for a cover entity. - The cover entity must support: set_cover_position. + This class is used for WindowCoveringBasic and + WindowCovering """ - def __init__(self, *args): - """Initialize a WindowCovering accessory object.""" + def __init__(self, *args, category): + """Initialize a WindowCoveringBase accessory object.""" super().__init__(*args, category=CATEGORY_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( + self.features = self.hass.states.get(self.entity_id).attributes.get( ATTR_SUPPORTED_FEATURES, 0 ) + self._supports_stop = self.features & SUPPORT_STOP + self._homekit_target_tilt = None + self.chars = [] + if self._supports_stop: + self.chars.append(CHAR_HOLD_POSITION) + self._supports_tilt = self.features & SUPPORT_SET_TILT_POSITION - self._supports_tilt = features & SUPPORT_SET_TILT_POSITION if self._supports_tilt: - self.char_target_tilt = serv_cover.configure_char( + self.chars.extend([CHAR_TARGET_TILT_ANGLE, CHAR_CURRENT_TILT_ANGLE]) + + self.serv_cover = self.add_preload_service(SERV_WINDOW_COVERING, self.chars) + + if self._supports_stop: + self.char_hold_position = self.serv_cover.configure_char( + CHAR_HOLD_POSITION, setter_callback=self.set_stop + ) + + if self._supports_tilt: + self.char_target_tilt = self.serv_cover.configure_char( CHAR_TARGET_TILT_ANGLE, setter_callback=self.set_tilt ) - self.char_current_tilt = serv_cover.configure_char( + self.char_current_tilt = self.serv_cover.configure_char( CHAR_CURRENT_TILT_ANGLE, value=0 ) - self.char_current_position = serv_cover.configure_char( - CHAR_CURRENT_POSITION, value=0 - ) - self.char_target_position = serv_cover.configure_char( - CHAR_TARGET_POSITION, value=0, setter_callback=self.move_cover - ) - self.char_position_state = serv_cover.configure_char( - CHAR_POSITION_STATE, value=2 - ) + def set_stop(self, value): + """Stop the cover motion from HomeKit.""" + if value != 1: + return + self.call_service(DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: self.entity_id}) @debounce def set_tilt(self, value): @@ -179,6 +183,51 @@ class WindowCovering(HomeAccessory): self.call_service(DOMAIN, SERVICE_SET_COVER_TILT_POSITION, params, value) + def update_state(self, new_state): + """Update cover position and tilt after state changed.""" + # 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("WindowCovering") +class WindowCovering(WindowCoveringBase, HomeAccessory): + """Generate a Window accessory for a cover entity. + + The cover entity must support: set_cover_position. + """ + + def __init__(self, *args): + """Initialize a WindowCovering accessory object.""" + super().__init__(*args, category=CATEGORY_WINDOW_COVERING) + + self._homekit_target = None + + self.char_current_position = self.serv_cover.configure_char( + CHAR_CURRENT_POSITION, value=0 + ) + self.char_target_position = self.serv_cover.configure_char( + CHAR_TARGET_POSITION, value=0, setter_callback=self.move_cover + ) + self.char_position_state = self.serv_cover.configure_char( + CHAR_POSITION_STATE, value=2 + ) + @debounce def move_cover(self, value): """Move cover to value if call came from HomeKit.""" @@ -213,28 +262,11 @@ 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 + super().update_state(new_state) @TYPES.register("WindowCoveringBasic") -class WindowCoveringBasic(HomeAccessory): +class WindowCoveringBasic(WindowCoveringBase, HomeAccessory): """Generate a Window accessory for a cover entity. The cover entity must support: open_cover, close_cover, @@ -244,19 +276,14 @@ class WindowCoveringBasic(HomeAccessory): def __init__(self, *args): """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 = self.add_preload_service(SERV_WINDOW_COVERING) - self.char_current_position = serv_cover.configure_char( + self.char_current_position = self.serv_cover.configure_char( CHAR_CURRENT_POSITION, value=0 ) - self.char_target_position = serv_cover.configure_char( + self.char_target_position = self.serv_cover.configure_char( CHAR_TARGET_POSITION, value=0, setter_callback=self.move_cover ) - self.char_position_state = serv_cover.configure_char( + self.char_position_state = self.serv_cover.configure_char( CHAR_POSITION_STATE, value=2 ) @@ -298,3 +325,5 @@ class WindowCoveringBasic(HomeAccessory): self.char_position_state.set_value(0) else: self.char_position_state.set_value(2) + + super().update_state(new_state) diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index b26379d576d..c97703f3813 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -9,6 +9,7 @@ from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN, + SUPPORT_SET_POSITION, SUPPORT_SET_TILT_POSITION, SUPPORT_STOP, ) @@ -395,6 +396,30 @@ async def test_window_open_close_stop(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] is None +async def test_window_open_close_with_position_and_stop(hass, hk_driver, cls, events): + """Test if accessory and HA are updated accordingly.""" + entity_id = "cover.stop_window" + + hass.states.async_set( + entity_id, + STATE_UNKNOWN, + {ATTR_SUPPORTED_FEATURES: SUPPORT_STOP | SUPPORT_SET_POSITION}, + ) + acc = cls.window(hass, hk_driver, "Cover", entity_id, 2, None) + await acc.run_handler() + + # Set from HomeKit + call_stop_cover = async_mock_service(hass, DOMAIN, "stop_cover") + + await hass.async_add_executor_job(acc.char_hold_position.client_update_value, 1) + await hass.async_block_till_done() + assert call_stop_cover + assert call_stop_cover[0].data[ATTR_ENTITY_ID] == entity_id + assert acc.char_hold_position.value == 1 + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None + + async def test_window_basic_restore(hass, hk_driver, cls, events): """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running