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:
parent
d0dad4bfd6
commit
6208d8c911
3 changed files with 143 additions and 4 deletions
|
@ -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"
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue