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 <nick@koston.org>

* 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 <nick@koston.org>
This commit is contained in:
Marcel Steinbach 2020-03-31 02:47:03 +02:00 committed by GitHub
parent d0dad4bfd6
commit 6208d8c911
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 143 additions and 4 deletions

View file

@ -1,10 +1,12 @@
"""Constants used be the HomeKit component.""" """Constants used be the HomeKit component."""
# #### Misc #### # #### Misc ####
DEBOUNCE_TIMEOUT = 0.5 DEBOUNCE_TIMEOUT = 0.5
DEVICE_PRECISION_LEEWAY = 6
DOMAIN = "homekit" DOMAIN = "homekit"
HOMEKIT_FILE = ".homekit.state" HOMEKIT_FILE = ".homekit.state"
HOMEKIT_NOTIFY_ID = 4663548 HOMEKIT_NOTIFY_ID = 4663548
# #### Attributes #### # #### Attributes ####
ATTR_DISPLAY_NAME = "display_name" ATTR_DISPLAY_NAME = "display_name"
ATTR_VALUE = "value" ATTR_VALUE = "value"
@ -106,6 +108,7 @@ CHAR_CURRENT_POSITION = "CurrentPosition"
CHAR_CURRENT_HUMIDITY = "CurrentRelativeHumidity" CHAR_CURRENT_HUMIDITY = "CurrentRelativeHumidity"
CHAR_CURRENT_SECURITY_STATE = "SecuritySystemCurrentState" CHAR_CURRENT_SECURITY_STATE = "SecuritySystemCurrentState"
CHAR_CURRENT_TEMPERATURE = "CurrentTemperature" CHAR_CURRENT_TEMPERATURE = "CurrentTemperature"
CHAR_CURRENT_TILT_ANGLE = "CurrentHorizontalTiltAngle"
CHAR_CURRENT_VISIBILITY_STATE = "CurrentVisibilityState" CHAR_CURRENT_VISIBILITY_STATE = "CurrentVisibilityState"
CHAR_FIRMWARE_REVISION = "FirmwareRevision" CHAR_FIRMWARE_REVISION = "FirmwareRevision"
CHAR_HEATING_THRESHOLD_TEMPERATURE = "HeatingThresholdTemperature" CHAR_HEATING_THRESHOLD_TEMPERATURE = "HeatingThresholdTemperature"
@ -141,6 +144,7 @@ CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState"
CHAR_TARGET_POSITION = "TargetPosition" CHAR_TARGET_POSITION = "TargetPosition"
CHAR_TARGET_SECURITY_STATE = "SecuritySystemTargetState" CHAR_TARGET_SECURITY_STATE = "SecuritySystemTargetState"
CHAR_TARGET_TEMPERATURE = "TargetTemperature" CHAR_TARGET_TEMPERATURE = "TargetTemperature"
CHAR_TARGET_TILT_ANGLE = "TargetHorizontalTiltAngle"
CHAR_TEMP_DISPLAY_UNITS = "TemperatureDisplayUnits" CHAR_TEMP_DISPLAY_UNITS = "TemperatureDisplayUnits"
CHAR_VALVE_TYPE = "ValveType" CHAR_VALVE_TYPE = "ValveType"
CHAR_VOLUME = "Volume" CHAR_VOLUME = "Volume"

View file

@ -5,8 +5,11 @@ from pyhap.const import CATEGORY_GARAGE_DOOR_OPENER, CATEGORY_WINDOW_COVERING
from homeassistant.components.cover import ( from homeassistant.components.cover import (
ATTR_CURRENT_POSITION, ATTR_CURRENT_POSITION,
ATTR_CURRENT_TILT_POSITION,
ATTR_POSITION, ATTR_POSITION,
ATTR_TILT_POSITION,
DOMAIN, DOMAIN,
SUPPORT_SET_TILT_POSITION,
SUPPORT_STOP, SUPPORT_STOP,
) )
from homeassistant.const import ( from homeassistant.const import (
@ -15,6 +18,7 @@ from homeassistant.const import (
SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER,
SERVICE_OPEN_COVER, SERVICE_OPEN_COVER,
SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_POSITION,
SERVICE_SET_COVER_TILT_POSITION,
SERVICE_STOP_COVER, SERVICE_STOP_COVER,
STATE_CLOSED, STATE_CLOSED,
STATE_CLOSING, STATE_CLOSING,
@ -27,9 +31,12 @@ from .accessories import HomeAccessory, debounce
from .const import ( from .const import (
CHAR_CURRENT_DOOR_STATE, CHAR_CURRENT_DOOR_STATE,
CHAR_CURRENT_POSITION, CHAR_CURRENT_POSITION,
CHAR_CURRENT_TILT_ANGLE,
CHAR_POSITION_STATE, CHAR_POSITION_STATE,
CHAR_TARGET_DOOR_STATE, CHAR_TARGET_DOOR_STATE,
CHAR_TARGET_POSITION, CHAR_TARGET_POSITION,
CHAR_TARGET_TILT_ANGLE,
DEVICE_PRECISION_LEEWAY,
SERV_GARAGE_DOOR_OPENER, SERV_GARAGE_DOOR_OPENER,
SERV_WINDOW_COVERING, SERV_WINDOW_COVERING,
) )
@ -94,9 +101,28 @@ class WindowCovering(HomeAccessory):
def __init__(self, *args): def __init__(self, *args):
"""Initialize a WindowCovering accessory object.""" """Initialize a WindowCovering accessory object."""
super().__init__(*args, category=CATEGORY_WINDOW_COVERING) 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( self.char_current_position = serv_cover.configure_char(
CHAR_CURRENT_POSITION, value=0 CHAR_CURRENT_POSITION, value=0
) )
@ -107,6 +133,20 @@ class WindowCovering(HomeAccessory):
CHAR_POSITION_STATE, value=2 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 @debounce
def move_cover(self, value): def move_cover(self, value):
"""Move cover to value if call came from HomeKit.""" """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) self.call_service(DOMAIN, SERVICE_SET_COVER_POSITION, params, value)
def update_state(self, new_state): 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) current_position = new_state.attributes.get(ATTR_CURRENT_POSITION)
if isinstance(current_position, (float, int)): if isinstance(current_position, (float, int)):
current_position = int(current_position) current_position = int(current_position)
self.char_current_position.set_value(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 ( if (
self._homekit_target is None 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.char_target_position.set_value(current_position)
self._homekit_target = None self._homekit_target = None
@ -135,6 +181,25 @@ class WindowCovering(HomeAccessory):
else: else:
self.char_position_state.set_value(2) 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") @TYPES.register("WindowCoveringBasic")
class WindowCoveringBasic(HomeAccessory): class WindowCoveringBasic(HomeAccessory):

View file

@ -5,8 +5,11 @@ import pytest
from homeassistant.components.cover import ( from homeassistant.components.cover import (
ATTR_CURRENT_POSITION, ATTR_CURRENT_POSITION,
ATTR_CURRENT_TILT_POSITION,
ATTR_POSITION, ATTR_POSITION,
ATTR_TILT_POSITION,
DOMAIN, DOMAIN,
SUPPORT_SET_TILT_POSITION,
SUPPORT_STOP, SUPPORT_STOP,
) )
from homeassistant.components.homekit.const import ATTR_VALUE from homeassistant.components.homekit.const import ATTR_VALUE
@ -14,6 +17,7 @@ from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES, ATTR_SUPPORTED_FEATURES,
EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_START,
SERVICE_SET_COVER_TILT_POSITION,
STATE_CLOSED, STATE_CLOSED,
STATE_CLOSING, STATE_CLOSING,
STATE_OPEN, 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 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): async def test_window_open_close(hass, hk_driver, cls, events):
"""Test if accessory and HA are updated accordingly.""" """Test if accessory and HA are updated accordingly."""
entity_id = "cover.window" entity_id = "cover.window"