From d6c547e9a384ddc3662337745d60594122e6676e Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 24 Jan 2022 09:58:52 -0800 Subject: [PATCH] Add cover platform to Overkiz integration (#64564) --- .coveragerc | 2 + homeassistant/components/overkiz/const.py | 21 +- homeassistant/components/overkiz/cover.py | 34 ++++ .../overkiz/cover_entities/__init__.py | 1 + .../overkiz/cover_entities/awning.py | 69 +++++++ .../overkiz/cover_entities/generic_cover.py | 191 ++++++++++++++++++ .../overkiz/cover_entities/vertical_cover.py | 111 ++++++++++ homeassistant/components/overkiz/executor.py | 5 +- 8 files changed, 431 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/overkiz/cover.py create mode 100644 homeassistant/components/overkiz/cover_entities/__init__.py create mode 100644 homeassistant/components/overkiz/cover_entities/awning.py create mode 100644 homeassistant/components/overkiz/cover_entities/generic_cover.py create mode 100644 homeassistant/components/overkiz/cover_entities/vertical_cover.py diff --git a/.coveragerc b/.coveragerc index 72a40e47ff9..776a84bc5eb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -820,6 +820,8 @@ omit = homeassistant/components/overkiz/__init__.py homeassistant/components/overkiz/binary_sensor.py homeassistant/components/overkiz/button.py + homeassistant/components/overkiz/cover.py + homeassistant/components/overkiz/cover_entities/* homeassistant/components/overkiz/coordinator.py homeassistant/components/overkiz/diagnostics.py homeassistant/components/overkiz/entity.py diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index 749d769d41e..ba5470724cd 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -22,6 +22,7 @@ UPDATE_INTERVAL_ALL_ASSUMED_STATE: Final = timedelta(minutes=60) PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.COVER, Platform.LIGHT, Platform.LOCK, Platform.NUMBER, @@ -37,13 +38,29 @@ IGNORED_OVERKIZ_DEVICES: list[UIClass | UIWidget] = [ # Used to map the Somfy widget and ui_class to the Home Assistant platform OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform] = { + UIClass.ADJUSTABLE_SLATS_ROLLER_SHUTTER: Platform.COVER, + UIClass.AWNING: Platform.COVER, + UIClass.CURTAIN: Platform.COVER, UIClass.DOOR_LOCK: Platform.LOCK, - UIWidget.DOMESTIC_HOT_WATER_TANK: Platform.SWITCH, # widgetName, uiClass is WaterHeatingSystem (not supported) + UIClass.EXTERIOR_SCREEN: Platform.COVER, + UIClass.EXTERIOR_VENETIAN_BLIND: Platform.COVER, + UIClass.GARAGE_DOOR: Platform.COVER, + UIClass.GATE: Platform.COVER, UIClass.LIGHT: Platform.LIGHT, UIClass.ON_OFF: Platform.SWITCH, + UIClass.PERGOLA: Platform.COVER, + UIClass.ROLLER_SHUTTER: Platform.COVER, + UIClass.SCREEN: Platform.COVER, + UIClass.SHUTTER: Platform.COVER, + UIClass.SWIMMING_POOL: Platform.SWITCH, + UIClass.SWINGING_SHUTTER: Platform.COVER, + UIClass.VENETIAN_BLIND: Platform.COVER, + UIClass.WINDOW: Platform.COVER, + UIWidget.DOMESTIC_HOT_WATER_TANK: Platform.SWITCH, # widgetName, uiClass is WaterHeatingSystem (not supported) + UIWidget.MY_FOX_SECURITY_CAMERA: Platform.COVER, # widgetName, uiClass is Camera (not supported) UIWidget.RTD_INDOOR_SIREN: Platform.SWITCH, # widgetName, uiClass is Siren (not supported) UIWidget.RTD_OUTDOOR_SIREN: Platform.SWITCH, # widgetName, uiClass is Siren (not supported) - UIClass.SWIMMING_POOL: Platform.SWITCH, + UIWidget.RTS_GENERIC: Platform.COVER, # widgetName, uiClass is Generic (not supported) } # Map Overkiz camelCase to Home Assistant snake_case for translation diff --git a/homeassistant/components/overkiz/cover.py b/homeassistant/components/overkiz/cover.py new file mode 100644 index 00000000000..a87b3b5edd6 --- /dev/null +++ b/homeassistant/components/overkiz/cover.py @@ -0,0 +1,34 @@ +"""Support for Overkiz covers - shutters etc.""" +from pyoverkiz.enums import UIClass + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeAssistantOverkizData +from .const import DOMAIN +from .cover_entities.awning import Awning +from .cover_entities.generic_cover import OverkizGenericCover +from .cover_entities.vertical_cover import VerticalCover + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Overkiz covers from a config entry.""" + data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] + + entities: list[OverkizGenericCover] = [ + Awning(device.device_url, data.coordinator) + for device in data.platforms[Platform.COVER] + if device.ui_class == UIClass.AWNING + ] + + entities += [ + VerticalCover(device.device_url, data.coordinator) + for device in data.platforms[Platform.COVER] + if device.ui_class != UIClass.AWNING + ] + + async_add_entities(entities) diff --git a/homeassistant/components/overkiz/cover_entities/__init__.py b/homeassistant/components/overkiz/cover_entities/__init__.py new file mode 100644 index 00000000000..930202450d4 --- /dev/null +++ b/homeassistant/components/overkiz/cover_entities/__init__.py @@ -0,0 +1 @@ +"""Cover entities for the Overkiz (by Somfy) integration.""" diff --git a/homeassistant/components/overkiz/cover_entities/awning.py b/homeassistant/components/overkiz/cover_entities/awning.py new file mode 100644 index 00000000000..bb5b0e52186 --- /dev/null +++ b/homeassistant/components/overkiz/cover_entities/awning.py @@ -0,0 +1,69 @@ +"""Support for Overkiz awnings.""" +from __future__ import annotations + +from typing import Any, cast + +from pyoverkiz.enums import OverkizCommand, OverkizState + +from homeassistant.components.cover import ( + ATTR_POSITION, + DEVICE_CLASS_AWNING, + SUPPORT_CLOSE, + SUPPORT_OPEN, + SUPPORT_SET_POSITION, + SUPPORT_STOP, +) + +from .generic_cover import COMMANDS_STOP, OverkizGenericCover + + +class Awning(OverkizGenericCover): + """Representation of an Overkiz awning.""" + + _attr_device_class = DEVICE_CLASS_AWNING + + @property + def supported_features(self) -> int: + """Flag supported features.""" + supported_features: int = super().supported_features + + if self.executor.has_command(OverkizCommand.SET_DEPLOYMENT): + supported_features |= SUPPORT_SET_POSITION + + if self.executor.has_command(OverkizCommand.DEPLOY): + supported_features |= SUPPORT_OPEN + + if self.executor.has_command(*COMMANDS_STOP): + supported_features |= SUPPORT_STOP + + if self.executor.has_command(OverkizCommand.UNDEPLOY): + supported_features |= SUPPORT_CLOSE + + return supported_features + + @property + def current_cover_position(self) -> int | None: + """ + Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + if current_position := self.executor.select_state(OverkizState.CORE_DEPLOYMENT): + return cast(int, current_position) + + return None + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + position = kwargs.get(ATTR_POSITION, 0) + await self.executor.async_execute_command( + OverkizCommand.SET_DEPLOYMENT, position + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.executor.async_execute_command(OverkizCommand.DEPLOY) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self.executor.async_execute_command(OverkizCommand.UNDEPLOY) diff --git a/homeassistant/components/overkiz/cover_entities/generic_cover.py b/homeassistant/components/overkiz/cover_entities/generic_cover.py new file mode 100644 index 00000000000..476ed23ae4d --- /dev/null +++ b/homeassistant/components/overkiz/cover_entities/generic_cover.py @@ -0,0 +1,191 @@ +"""Base class for Overkiz covers, shutters, awnings, etc.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.cover import ( + ATTR_TILT_POSITION, + SUPPORT_CLOSE_TILT, + SUPPORT_OPEN_TILT, + SUPPORT_SET_TILT_POSITION, + SUPPORT_STOP_TILT, + CoverEntity, +) +from homeassistant.components.overkiz.entity import OverkizEntity + +ATTR_OBSTRUCTION_DETECTED = "obstruction-detected" + +COMMANDS_STOP: list[OverkizCommand] = [ + OverkizCommand.STOP, + OverkizCommand.MY, +] +COMMANDS_STOP_TILT: list[OverkizCommand] = [ + OverkizCommand.STOP, + OverkizCommand.MY, +] +COMMANDS_OPEN: list[OverkizCommand] = [ + OverkizCommand.OPEN, + OverkizCommand.UP, + OverkizCommand.CYCLE, +] +COMMANDS_OPEN_TILT: list[OverkizCommand] = [OverkizCommand.OPEN_SLATS] +COMMANDS_CLOSE: list[OverkizCommand] = [ + OverkizCommand.CLOSE, + OverkizCommand.DOWN, + OverkizCommand.CYCLE, +] +COMMANDS_CLOSE_TILT: list[OverkizCommand] = [OverkizCommand.CLOSE_SLATS] + +COMMANDS_SET_TILT_POSITION: list[OverkizCommand] = [OverkizCommand.SET_ORIENTATION] + + +class OverkizGenericCover(OverkizEntity, CoverEntity): + """Representation of an Overkiz Cover.""" + + @property + def current_cover_tilt_position(self) -> int | None: + """Return current position of cover tilt. + + None is unknown, 0 is closed, 100 is fully open. + """ + if position := self.executor.select_state( + OverkizState.CORE_SLATS_ORIENTATION, OverkizState.CORE_SLATE_ORIENTATION + ): + return 100 - cast(int, position) + + return None + + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover tilt to a specific position.""" + if command := self.executor.select_command(*COMMANDS_SET_TILT_POSITION): + await self.executor.async_execute_command( + command, + 100 - kwargs.get(ATTR_TILT_POSITION, 0), + ) + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed.""" + + state = self.executor.select_state( + OverkizState.CORE_OPEN_CLOSED, + OverkizState.CORE_SLATS_OPEN_CLOSED, + OverkizState.CORE_OPEN_CLOSED_PARTIAL, + OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN, + OverkizState.CORE_OPEN_CLOSED_UNKNOWN, + OverkizState.MYFOX_SHUTTER_STATUS, + ) + if state is not None: + return state == OverkizCommandParam.CLOSED + + # Keep this condition after the previous one. Some device like the pedestrian gate, always return 50 as position. + if self.current_cover_position is not None: + return self.current_cover_position == 0 + + if self.current_cover_tilt_position is not None: + return self.current_cover_tilt_position == 0 + + return None + + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + if command := self.executor.select_command(*COMMANDS_OPEN_TILT): + await self.executor.async_execute_command(command) + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + if command := self.executor.select_command(*COMMANDS_CLOSE_TILT): + await self.executor.async_execute_command(command) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + if command := self.executor.select_command(*COMMANDS_STOP): + await self.executor.async_execute_command(command) + + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop the cover tilt.""" + if command := self.executor.select_command(*COMMANDS_STOP_TILT): + await self.executor.async_execute_command(command) + + @property + def is_opening(self) -> bool | None: + """Return if the cover is opening or not.""" + + if self.assumed_state: + return None + + # Check if cover movement execution is currently running + if any( + execution.get("device_url") == self.device.device_url + and execution.get("command_name") in COMMANDS_OPEN + COMMANDS_OPEN_TILT + for execution in self.coordinator.executions.values() + ): + return True + + # Check if cover is moving based on current state + is_moving = self.device.states.get(OverkizState.CORE_MOVING) + current_closure = self.device.states.get(OverkizState.CORE_CLOSURE) + target_closure = self.device.states.get(OverkizState.CORE_TARGET_CLOSURE) + + if not is_moving or not current_closure or not target_closure: + return None + + return cast(int, current_closure.value) > cast(int, target_closure.value) + + @property + def is_closing(self) -> bool | None: + """Return if the cover is closing or not.""" + + if self.assumed_state: + return None + + # Check if cover movement execution is currently running + if any( + execution.get("device_url") == self.device.device_url + and execution.get("command_name") in COMMANDS_CLOSE + COMMANDS_CLOSE_TILT + for execution in self.coordinator.executions.values() + ): + return True + + # Check if cover is moving based on current state + is_moving = self.device.states.get(OverkizState.CORE_MOVING) + current_closure = self.device.states.get(OverkizState.CORE_CLOSURE) + target_closure = self.device.states.get(OverkizState.CORE_TARGET_CLOSURE) + + if not is_moving or not current_closure or not target_closure: + return None + + return cast(int, current_closure.value) < cast(int, target_closure.value) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return the device state attributes.""" + attr = super().extra_state_attributes or {} + + # Obstruction Detected attribute is used by HomeKit + if self.executor.has_state(OverkizState.IO_PRIORITY_LOCK_LEVEL): + return {**attr, **{ATTR_OBSTRUCTION_DETECTED: True}} + + return attr + + @property + def supported_features(self) -> int: + """Flag supported features.""" + supported_features = 0 + + if self.executor.has_command(*COMMANDS_OPEN_TILT): + supported_features |= SUPPORT_OPEN_TILT + + if self.executor.has_command(*COMMANDS_STOP_TILT): + supported_features |= SUPPORT_STOP_TILT + + if self.executor.has_command(*COMMANDS_CLOSE_TILT): + supported_features |= SUPPORT_CLOSE_TILT + + if self.executor.has_command(*COMMANDS_SET_TILT_POSITION): + supported_features |= SUPPORT_SET_TILT_POSITION + + return supported_features diff --git a/homeassistant/components/overkiz/cover_entities/vertical_cover.py b/homeassistant/components/overkiz/cover_entities/vertical_cover.py new file mode 100644 index 00000000000..6e69f24f2f1 --- /dev/null +++ b/homeassistant/components/overkiz/cover_entities/vertical_cover.py @@ -0,0 +1,111 @@ +"""Support for Overkiz Vertical Covers.""" +from __future__ import annotations + +from typing import Any, Union, cast + +from pyoverkiz.enums import OverkizCommand, OverkizState, UIClass, UIWidget + +from homeassistant.components.cover import ( + ATTR_POSITION, + DEVICE_CLASS_AWNING, + DEVICE_CLASS_BLIND, + DEVICE_CLASS_CURTAIN, + DEVICE_CLASS_GARAGE, + DEVICE_CLASS_GATE, + DEVICE_CLASS_SHUTTER, + DEVICE_CLASS_WINDOW, + SUPPORT_CLOSE, + SUPPORT_OPEN, + SUPPORT_SET_POSITION, + SUPPORT_STOP, +) + +from .generic_cover import COMMANDS_STOP, OverkizGenericCover + +COMMANDS_OPEN = [OverkizCommand.OPEN, OverkizCommand.UP, OverkizCommand.CYCLE] +COMMANDS_CLOSE = [OverkizCommand.CLOSE, OverkizCommand.DOWN, OverkizCommand.CYCLE] + +OVERKIZ_DEVICE_TO_DEVICE_CLASS = { + UIClass.CURTAIN: DEVICE_CLASS_CURTAIN, + UIClass.EXTERIOR_SCREEN: DEVICE_CLASS_BLIND, + UIClass.EXTERIOR_VENETIAN_BLIND: DEVICE_CLASS_BLIND, + UIClass.GARAGE_DOOR: DEVICE_CLASS_GARAGE, + UIClass.GATE: DEVICE_CLASS_GATE, + UIWidget.MY_FOX_SECURITY_CAMERA: DEVICE_CLASS_SHUTTER, + UIClass.PERGOLA: DEVICE_CLASS_AWNING, + UIClass.ROLLER_SHUTTER: DEVICE_CLASS_SHUTTER, + UIClass.SWINGING_SHUTTER: DEVICE_CLASS_SHUTTER, + UIClass.WINDOW: DEVICE_CLASS_WINDOW, +} + + +class VerticalCover(OverkizGenericCover): + """Representation of an Overkiz vertical cover.""" + + @property + def supported_features(self) -> int: + """Flag supported features.""" + supported_features: int = super().supported_features + + if self.executor.has_command(OverkizCommand.SET_CLOSURE): + supported_features |= SUPPORT_SET_POSITION + + if self.executor.has_command(*COMMANDS_OPEN): + supported_features |= SUPPORT_OPEN + + if self.executor.has_command(*COMMANDS_STOP): + supported_features |= SUPPORT_STOP + + if self.executor.has_command(*COMMANDS_CLOSE): + supported_features |= SUPPORT_CLOSE + + return supported_features + + @property + def device_class(self) -> str: + """Return the class of the device.""" + return cast( + str, + ( + OVERKIZ_DEVICE_TO_DEVICE_CLASS.get(self.device.widget) + or OVERKIZ_DEVICE_TO_DEVICE_CLASS.get(self.device.ui_class) + or DEVICE_CLASS_BLIND + ), + ) + + @property + def current_cover_position(self) -> int | None: + """ + Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + position = cast( + Union[int, None], + self.executor.select_state( + OverkizState.CORE_CLOSURE, + OverkizState.CORE_CLOSURE_OR_ROCKER_POSITION, + OverkizState.CORE_PEDESTRIAN_POSITION, + ), + ) + + # Uno devices can have a position not in 0 to 100 range when unknown + if position is None or position < 0 or position > 100: + return None + + return 100 - position + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + position = 100 - kwargs.get(ATTR_POSITION, 0) + await self.executor.async_execute_command(OverkizCommand.SET_CLOSURE, position) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + if command := self.executor.select_command(*COMMANDS_OPEN): + await self.executor.async_execute_command(command) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + if command := self.executor.select_command(*COMMANDS_CLOSE): + await self.executor.async_execute_command(command) diff --git a/homeassistant/components/overkiz/executor.py b/homeassistant/components/overkiz/executor.py index 43564d015f8..c362969abf2 100644 --- a/homeassistant/components/overkiz/executor.py +++ b/homeassistant/components/overkiz/executor.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any from urllib.parse import urlparse +from pyoverkiz.enums.command import OverkizCommand from pyoverkiz.models import Command, Device from pyoverkiz.types import StateType as OverkizStateType @@ -76,7 +77,9 @@ class OverkizExecutor: await self.coordinator.async_refresh() - async def async_cancel_command(self, commands_to_cancel: list[str]) -> bool: + async def async_cancel_command( + self, commands_to_cancel: list[OverkizCommand] + ) -> bool: """Cancel running execution by command.""" # Cancel a running execution