From a19b43a304dc60d6ece0ad7f5366f56be9217bc4 Mon Sep 17 00:00:00 2001 From: Julius Mittenzwei Date: Sun, 27 Sep 2020 07:07:59 +0200 Subject: [PATCH] Add support for homekit windows (#40635) Co-authored-by: J. Nick Koston --- .../components/homekit/accessories.py | 11 +++- homeassistant/components/homekit/const.py | 1 + .../components/homekit/type_covers.py | 60 +++++++++++++----- .../homekit/test_get_accessories.py | 6 ++ tests/components/homekit/test_type_covers.py | 62 +++++++++++++------ 5 files changed, 106 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 3912d8e9056..6dc2e2364b6 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -9,7 +9,11 @@ from pyhap.accessory_driver import AccessoryDriver from pyhap.const import CATEGORY_OTHER from homeassistant.components import cover, vacuum -from homeassistant.components.cover import DEVICE_CLASS_GARAGE, DEVICE_CLASS_GATE +from homeassistant.components.cover import ( + DEVICE_CLASS_GARAGE, + DEVICE_CLASS_GATE, + DEVICE_CLASS_WINDOW, +) from homeassistant.components.media_player import DEVICE_CLASS_TV from homeassistant.const import ( ATTR_BATTERY_CHARGING, @@ -155,6 +159,11 @@ def get_accessory(hass, driver, state, aid, config): cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE ): a_type = "GarageDoorOpener" + elif ( + device_class == DEVICE_CLASS_WINDOW + and features & cover.SUPPORT_SET_POSITION + ): + a_type = "Window" elif features & cover.SUPPORT_SET_POSITION: a_type = "WindowCovering" elif features & (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE): diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index d8eec057191..9a2bc37a5a9 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -136,6 +136,7 @@ SERV_TELEVISION_SPEAKER = "TelevisionSpeaker" SERV_TEMPERATURE_SENSOR = "TemperatureSensor" SERV_THERMOSTAT = "Thermostat" SERV_VALVE = "Valve" +SERV_WINDOW = "Window" SERV_WINDOW_COVERING = "WindowCovering" # #### Characteristics #### diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 1e18ad82b94..d8d8da5a974 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -1,7 +1,11 @@ """Class to hold all cover accessories.""" import logging -from pyhap.const import CATEGORY_GARAGE_DOOR_OPENER, CATEGORY_WINDOW_COVERING +from pyhap.const import ( + CATEGORY_GARAGE_DOOR_OPENER, + CATEGORY_WINDOW, + CATEGORY_WINDOW_COVERING, +) from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, @@ -46,6 +50,7 @@ from .const import ( HK_POSITION_GOING_TO_MIN, HK_POSITION_STOPPED, SERV_GARAGE_DOOR_OPENER, + SERV_WINDOW, SERV_WINDOW_COVERING, ) @@ -128,16 +133,16 @@ class GarageDoorOpener(HomeAccessory): self.char_current_state.set_value(current_door_state) -class WindowCoveringBase(HomeAccessory): +class OpeningDeviceBase(HomeAccessory): """Generate a base Window accessory for a cover entity. This class is used for WindowCoveringBasic and WindowCovering """ - def __init__(self, *args, category): - """Initialize a WindowCoveringBase accessory object.""" - super().__init__(*args, category=CATEGORY_WINDOW_COVERING) + def __init__(self, *args, category, service): + """Initialize a OpeningDeviceBase accessory object.""" + super().__init__(*args, category=category) state = self.hass.states.get(self.entity_id) self.features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) @@ -151,7 +156,7 @@ class WindowCoveringBase(HomeAccessory): if self._supports_tilt: self.chars.extend([CHAR_TARGET_TILT_ANGLE, CHAR_CURRENT_TILT_ANGLE]) - self.serv_cover = self.add_preload_service(SERV_WINDOW_COVERING, self.chars) + self.serv_cover = self.add_preload_service(service, self.chars) if self._supports_stop: self.char_hold_position = self.serv_cover.configure_char( @@ -211,16 +216,15 @@ class WindowCoveringBase(HomeAccessory): self._homekit_target_tilt = None -@TYPES.register("WindowCovering") -class WindowCovering(WindowCoveringBase, HomeAccessory): - """Generate a Window accessory for a cover entity. +class OpeningDevice(OpeningDeviceBase, HomeAccessory): + """Generate a Window/WindowOpening accessory for a cover entity. The cover entity must support: set_cover_position. """ - def __init__(self, *args): + def __init__(self, *args, category, service): """Initialize a WindowCovering accessory object.""" - super().__init__(*args, category=CATEGORY_WINDOW_COVERING) + super().__init__(*args, category=category, service=service) state = self.hass.states.get(self.entity_id) self._homekit_target = None @@ -278,8 +282,34 @@ class WindowCovering(WindowCoveringBase, HomeAccessory): super().async_update_state(new_state) +@TYPES.register("Window") +class Window(OpeningDevice): + """Generate a Window accessory for a cover entity with DEVICE_CLASS_WINDOW. + + The entity must support: set_cover_position. + """ + + def __init__(self, *args): + """Initialize a Window accessory object.""" + super().__init__(*args, category=CATEGORY_WINDOW, service=SERV_WINDOW) + + +@TYPES.register("WindowCovering") +class WindowCovering(OpeningDevice): + """Generate a WindowCovering accessory for a cover entity. + + The entity must support: set_cover_position. + """ + + def __init__(self, *args): + """Initialize a WindowCovering accessory object.""" + super().__init__( + *args, category=CATEGORY_WINDOW_COVERING, service=SERV_WINDOW_COVERING + ) + + @TYPES.register("WindowCoveringBasic") -class WindowCoveringBasic(WindowCoveringBase, HomeAccessory): +class WindowCoveringBasic(OpeningDeviceBase, HomeAccessory): """Generate a Window accessory for a cover entity. The cover entity must support: open_cover, close_cover, @@ -287,8 +317,10 @@ class WindowCoveringBasic(WindowCoveringBase, HomeAccessory): """ def __init__(self, *args): - """Initialize a WindowCovering accessory object.""" - super().__init__(*args, category=CATEGORY_WINDOW_COVERING) + """Initialize a WindowCoveringBasic accessory object.""" + super().__init__( + *args, category=CATEGORY_WINDOW_COVERING, service=SERV_WINDOW_COVERING + ) state = self.hass.states.get(self.entity_id) self.char_current_position = self.serv_cover.configure_char( CHAR_CURRENT_POSITION, value=0 diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index a7468955d36..ea91733fdab 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -120,6 +120,12 @@ def test_types(type_name, entity_id, state, attrs, config): ATTR_SUPPORTED_FEATURES: cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE, }, ), + ( + "Window", + "cover.set_position", + "open", + {ATTR_DEVICE_CLASS: "window", ATTR_SUPPORTED_FEATURES: 4}, + ), ("WindowCovering", "cover.set_position", "open", {ATTR_SUPPORTED_FEATURES: 4}), ( "WindowCoveringBasic", diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index 3eed6d05816..cd193b61646 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -48,10 +48,13 @@ def cls(): "homeassistant.components.homekit.type_covers", fromlist=["GarageDoorOpener", "WindowCovering", "WindowCoveringBasic"], ) - patcher_tuple = namedtuple("Cls", ["window", "window_basic", "garage"]) + patcher_tuple = namedtuple( + "Cls", ["window", "windowcovering", "windowcovering_basic", "garage"] + ) yield patcher_tuple( - window=_import.WindowCovering, - window_basic=_import.WindowCoveringBasic, + window=_import.Window, + windowcovering=_import.WindowCovering, + windowcovering_basic=_import.WindowCoveringBasic, garage=_import.GarageDoorOpener, ) patcher.stop() @@ -136,13 +139,13 @@ async def test_garage_door_open_close(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] is None -async def test_window_set_cover_position(hass, hk_driver, cls, events): +async def test_windowcovering_set_cover_position(hass, hk_driver, cls, events): """Test if accessory and HA are updated accordingly.""" entity_id = "cover.window" hass.states.async_set(entity_id, None) await hass.async_block_till_done() - acc = cls.window(hass, hk_driver, "Cover", entity_id, 2, None) + acc = cls.windowcovering(hass, hk_driver, "Cover", entity_id, 2, None) await acc.run_handler() await hass.async_block_till_done() @@ -206,7 +209,24 @@ 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): +async def test_window_instantiate(hass, hk_driver, cls, events): + """Test if Window accessory is instantiated correctly.""" + entity_id = "cover.window" + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = cls.window(hass, hk_driver, "Window", entity_id, 2, None) + await acc.run_handler() + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 13 # Window + + assert acc.char_current_position.value == 0 + assert acc.char_target_position.value == 0 + + +async def test_windowcovering_cover_set_tilt(hass, hk_driver, cls, events): """Test if accessory and HA update slat tilt accordingly.""" entity_id = "cover.window" @@ -214,7 +234,7 @@ async def test_window_cover_set_tilt(hass, hk_driver, cls, events): 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) + acc = cls.windowcovering(hass, hk_driver, "Cover", entity_id, 2, None) await acc.run_handler() await hass.async_block_till_done() @@ -273,12 +293,12 @@ async def test_window_cover_set_tilt(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] == 75 -async def test_window_open_close(hass, hk_driver, cls, events): +async def test_windowcovering_open_close(hass, hk_driver, cls, events): """Test if accessory and HA are updated accordingly.""" entity_id = "cover.window" hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_SUPPORTED_FEATURES: 0}) - acc = cls.window_basic(hass, hk_driver, "Cover", entity_id, 2, None) + acc = cls.windowcovering_basic(hass, hk_driver, "Cover", entity_id, 2, None) await acc.run_handler() await hass.async_block_till_done() @@ -354,14 +374,14 @@ async def test_window_open_close(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] is None -async def test_window_open_close_stop(hass, hk_driver, cls, events): +async def test_windowcovering_open_close_stop(hass, hk_driver, cls, events): """Test if accessory and HA are updated accordingly.""" entity_id = "cover.window" hass.states.async_set( entity_id, STATE_UNKNOWN, {ATTR_SUPPORTED_FEATURES: SUPPORT_STOP} ) - acc = cls.window_basic(hass, hk_driver, "Cover", entity_id, 2, None) + acc = cls.windowcovering_basic(hass, hk_driver, "Cover", entity_id, 2, None) await acc.run_handler() await hass.async_block_till_done() @@ -401,7 +421,9 @@ 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): +async def test_windowcovering_open_close_with_position_and_stop( + hass, hk_driver, cls, events +): """Test if accessory and HA are updated accordingly.""" entity_id = "cover.stop_window" @@ -410,7 +432,7 @@ async def test_window_open_close_with_position_and_stop(hass, hk_driver, cls, ev STATE_UNKNOWN, {ATTR_SUPPORTED_FEATURES: SUPPORT_STOP | SUPPORT_SET_POSITION}, ) - acc = cls.window(hass, hk_driver, "Cover", entity_id, 2, None) + acc = cls.windowcovering(hass, hk_driver, "Cover", entity_id, 2, None) await acc.run_handler() await hass.async_block_till_done() @@ -430,7 +452,7 @@ async def test_window_open_close_with_position_and_stop(hass, hk_driver, cls, ev assert events[-1].data[ATTR_VALUE] is None -async def test_window_basic_restore(hass, hk_driver, cls, events): +async def test_windowcovering_basic_restore(hass, hk_driver, cls, events): """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running @@ -455,20 +477,22 @@ async def test_window_basic_restore(hass, hk_driver, cls, events): hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) await hass.async_block_till_done() - acc = cls.window_basic(hass, hk_driver, "Cover", "cover.simple", 2, None) + acc = cls.windowcovering_basic(hass, hk_driver, "Cover", "cover.simple", 2, None) assert acc.category == 14 assert acc.char_current_position is not None assert acc.char_target_position is not None assert acc.char_position_state is not None - acc = cls.window_basic(hass, hk_driver, "Cover", "cover.all_info_set", 2, None) + acc = cls.windowcovering_basic( + hass, hk_driver, "Cover", "cover.all_info_set", 2, None + ) assert acc.category == 14 assert acc.char_current_position is not None assert acc.char_target_position is not None assert acc.char_position_state is not None -async def test_window_restore(hass, hk_driver, cls, events): +async def test_windowcovering_restore(hass, hk_driver, cls, events): """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running @@ -493,13 +517,13 @@ async def test_window_restore(hass, hk_driver, cls, events): hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) await hass.async_block_till_done() - acc = cls.window(hass, hk_driver, "Cover", "cover.simple", 2, None) + acc = cls.windowcovering(hass, hk_driver, "Cover", "cover.simple", 2, None) assert acc.category == 14 assert acc.char_current_position is not None assert acc.char_target_position is not None assert acc.char_position_state is not None - acc = cls.window(hass, hk_driver, "Cover", "cover.all_info_set", 2, None) + acc = cls.windowcovering(hass, hk_driver, "Cover", "cover.all_info_set", 2, None) assert acc.category == 14 assert acc.char_current_position is not None assert acc.char_target_position is not None