diff --git a/homeassistant/components/hunterdouglas_powerview/button.py b/homeassistant/components/hunterdouglas_powerview/button.py index 483e2ca2784..ca9a72a7b99 100644 --- a/homeassistant/components/hunterdouglas_powerview/button.py +++ b/homeassistant/components/hunterdouglas_powerview/button.py @@ -48,6 +48,13 @@ BUTTONS: Final = [ entity_category=EntityCategory.DIAGNOSTIC, press_action=lambda shade: shade.jog(), ), + PowerviewButtonDescription( + key="favorite", + name="Favorite", + icon="mdi:heart", + entity_category=EntityCategory.DIAGNOSTIC, + press_action=lambda shade: shade.favorite(), + ), ] diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 1f04c8ddbd1..0082c68e26e 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -6,6 +6,7 @@ from collections.abc import Iterable from contextlib import suppress from datetime import timedelta import logging +from math import ceil from typing import Any from aiopvapi.helpers.constants import ( @@ -541,6 +542,58 @@ class PowerViewShadeWithTiltAnywhere(PowerViewShadeWithTiltBase): ) +class PowerViewShadeTiltOnly(PowerViewShadeWithTiltBase): + """Representation of a shade with tilt only capability, no move. + + API Class: ShadeTiltOnly + + Type 5 - Tilt Only 180° + """ + + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: PowerviewDeviceInfo, + room_name: str, + shade: BaseShade, + name: str, + ) -> None: + """Initialize the shade.""" + super().__init__(coordinator, device_info, room_name, shade, name) + self._attr_supported_features = ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION + ) + if self._device_info.model != LEGACY_DEVICE_MODEL: + self._attr_supported_features |= CoverEntityFeature.STOP_TILT + self._max_tilt = self._shade.shade_limits.tilt_max + + +class PowerViewShadeTopDown(PowerViewShade): + """Representation of a shade that lowers from the roof to the floor. + + These shades are inverted where MAX_POSITION equates to closed and MIN_POSITION is open + API Class: ShadeTopDown + + Type 6 - Top Down + """ + + @property + def current_cover_position(self) -> int: + """Return the current position of cover.""" + return hd_position_to_hass(MAX_POSITION - self.positions.primary, MAX_POSITION) + + @property + def is_closed(self) -> bool: + """Return if the cover is closed.""" + return (MAX_POSITION - self.positions.primary) <= CLOSED_POSITION + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the shade to a specific position.""" + await self._async_set_cover_position(100 - kwargs[ATTR_POSITION]) + + class PowerViewShadeDualRailBase(PowerViewShade): """Representation of a shade with top/down bottom/up capabilities. @@ -677,11 +730,354 @@ class PowerViewShadeTDBUTop(PowerViewShadeDualRailBase): ) +class PowerViewShadeDualOverlappedBase(PowerViewShade): + """Represent a shade that has a front sheer and rear blackout panel. + + This equates to two shades being controlled by one motor + """ + + @property + def transition_steps(self) -> int: + """Return the steps to make a move.""" + # poskind 1 represents the second half of the shade in hass + # front must be fully closed before rear can move + # 51 - 100 is equiv to 1-100 on other shades - one motor, two shades + primary = (hd_position_to_hass(self.positions.primary, MAX_POSITION) / 2) + 50 + # poskind 2 represents the shade first half of the shade in hass + # rear (blackout) must be fully open before front can move + # 51 - 100 is equiv to 1-100 on other shades - one motor, two shades + secondary = hd_position_to_hass(self.positions.secondary, MAX_POSITION) / 2 + return ceil(primary + secondary) + + @property + def open_position(self) -> PowerviewShadeMove: + """Return the open position and required additional positions.""" + return PowerviewShadeMove( + { + ATTR_POSITION1: MAX_POSITION, + ATTR_POSKIND1: POS_KIND_PRIMARY, + }, + {POS_KIND_SECONDARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + ) + + @property + def close_position(self) -> PowerviewShadeMove: + """Return the open position and required additional positions.""" + return PowerviewShadeMove( + { + ATTR_POSITION1: MIN_POSITION, + ATTR_POSKIND1: POS_KIND_SECONDARY, + }, + {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + ) + + +class PowerViewShadeDualOverlappedCombined(PowerViewShadeDualOverlappedBase): + """Represent a shade that has a front sheer and rear blackout panel. + + This equates to two shades being controlled by one motor. + The front shade must be completely down before the rear shade will move. + Sibling Class: PowerViewShadeDualOverlappedFront, PowerViewShadeDualOverlappedRear + API Class: ShadeDualOverlapped + + Type 8 - Duolite (front and rear shades) + """ + + # type + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: PowerviewDeviceInfo, + room_name: str, + shade: BaseShade, + name: str, + ) -> None: + """Initialize the shade.""" + super().__init__(coordinator, device_info, room_name, shade, name) + self._attr_unique_id = f"{self._shade.id}_combined" + self._attr_name = f"{self._shade_name} Combined" + + @property + def is_closed(self) -> bool: + """Return if the cover is closed.""" + # if rear shade is down it is closed + return self.positions.secondary <= CLOSED_POSITION + + @property + def current_cover_position(self) -> int: + """Return the current position of cover.""" + # if front is open return that (other positions are impossible) + # if front shade is closed get position of rear + position = (hd_position_to_hass(self.positions.primary, MAX_POSITION) / 2) + 50 + if self.positions.primary == MIN_POSITION: + position = hd_position_to_hass(self.positions.secondary, MAX_POSITION) / 2 + + return ceil(position) + + @callback + def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: + position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) + # note we set POS_KIND_VANE: MIN_POSITION here even with shades without tilt so no additional + # override is required for differences between type 8/9/10 + # this just stores the value in the coordinator for future reference + if target_hass_position <= 50: + target_hass_position = target_hass_position * 2 + return PowerviewShadeMove( + { + ATTR_POSITION1: position_shade, + ATTR_POSKIND1: POS_KIND_SECONDARY, + }, + {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + ) + + # 51 <= target_hass_position <= 100 (51-100 represents front sheer shade) + target_hass_position = (target_hass_position - 50) * 2 + return PowerviewShadeMove( + { + ATTR_POSITION1: position_shade, + ATTR_POSKIND1: POS_KIND_PRIMARY, + }, + {POS_KIND_SECONDARY: MAX_POSITION, POS_KIND_VANE: MIN_POSITION}, + ) + + +class PowerViewShadeDualOverlappedFront(PowerViewShadeDualOverlappedBase): + """Represent the shade front panel - These have a blackout panel too. + + This equates to two shades being controlled by one motor. + The front shade must be completely down before the rear shade will move. + Sibling Class: PowerViewShadeDualOverlappedCombined, PowerViewShadeDualOverlappedRear + API Class: ShadeDualOverlapped + ShadeDualOverlappedTilt90 + ShadeDualOverlappedTilt180 + + Type 8 - Duolite (front and rear shades) + Type 9 - Duolite with 90° Tilt (front bottom up shade that also tilts plus a rear blackout (non-tilting) shade) + Type 10 - Duolite with 180° Tilt + """ + + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: PowerviewDeviceInfo, + room_name: str, + shade: BaseShade, + name: str, + ) -> None: + """Initialize the shade.""" + super().__init__(coordinator, device_info, room_name, shade, name) + self._attr_unique_id = f"{self._shade.id}_front" + self._attr_name = f"{self._shade_name} Front" + + @property + def should_poll(self) -> bool: + """Certain shades create multiple entities. + + Do not poll shade multiple times. Combined shade will return data + and multiple polling will cause timeouts. + """ + return False + + @callback + def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: + position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) + # note we set POS_KIND_VANE: MIN_POSITION here even with shades without tilt so no additional + # override is required for differences between type 8/9/10 + # this just stores the value in the coordinator for future reference + return PowerviewShadeMove( + { + ATTR_POSITION1: position_shade, + ATTR_POSKIND1: POS_KIND_PRIMARY, + }, + {POS_KIND_SECONDARY: MAX_POSITION, POS_KIND_VANE: MIN_POSITION}, + ) + + @property + def close_position(self) -> PowerviewShadeMove: + """Return the close position and required additional positions.""" + return PowerviewShadeMove( + { + ATTR_POSITION1: MIN_POSITION, + ATTR_POSKIND1: POS_KIND_PRIMARY, + }, + {POS_KIND_SECONDARY: MAX_POSITION, POS_KIND_VANE: MIN_POSITION}, + ) + + +class PowerViewShadeDualOverlappedRear(PowerViewShadeDualOverlappedBase): + """Represent the shade front panel - These have a blackout panel too. + + This equates to two shades being controlled by one motor. + The front shade must be completely down before the rear shade will move. + Sibling Class: PowerViewShadeDualOverlappedCombined, PowerViewShadeDualOverlappedFront + API Class: ShadeDualOverlapped + ShadeDualOverlappedTilt90 + ShadeDualOverlappedTilt180 + + Type 8 - Duolite (front and rear shades) + Type 9 - Duolite with 90° Tilt (front bottom up shade that also tilts plus a rear blackout (non-tilting) shade) + Type 10 - Duolite with 180° Tilt + """ + + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: PowerviewDeviceInfo, + room_name: str, + shade: BaseShade, + name: str, + ) -> None: + """Initialize the shade.""" + super().__init__(coordinator, device_info, room_name, shade, name) + self._attr_unique_id = f"{self._shade.id}_rear" + self._attr_name = f"{self._shade_name} Rear" + + @property + def should_poll(self) -> bool: + """Certain shades create multiple entities. + + Do not poll shade multiple times. Combined shade will return data + and multiple polling will cause timeouts. + """ + return False + + @property + def is_closed(self) -> bool: + """Return if the cover is closed.""" + # if rear shade is down it is closed + return self.positions.secondary <= CLOSED_POSITION + + @property + def current_cover_position(self) -> int: + """Return the current position of cover.""" + return hd_position_to_hass(self.positions.secondary, MAX_POSITION) + + @callback + def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: + position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) + # note we set POS_KIND_VANE: MIN_POSITION here even with shades without tilt so no additional + # override is required for differences between type 8/9/10 + # this just stores the value in the coordinator for future reference + return PowerviewShadeMove( + { + ATTR_POSITION1: position_shade, + ATTR_POSKIND1: POS_KIND_SECONDARY, + }, + {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + ) + + @property + def open_position(self) -> PowerviewShadeMove: + """Return the open position and required additional positions.""" + return PowerviewShadeMove( + { + ATTR_POSITION1: MAX_POSITION, + ATTR_POSKIND1: POS_KIND_SECONDARY, + }, + {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + ) + + +class PowerViewShadeDualOverlappedCombinedTilt(PowerViewShadeDualOverlappedCombined): + """Represent a shade that has a front sheer and rear blackout panel. + + This equates to two shades being controlled by one motor. + The front shade must be completely down before the rear shade will move. + Tilting this shade will also force positional change of the main roller. + + Sibling Class: PowerViewShadeDualOverlappedFront, PowerViewShadeDualOverlappedRear + API Class: ShadeDualOverlappedTilt90 + ShadeDualOverlappedTilt180 + + Type 9 - Duolite with 90° Tilt (front bottom up shade that also tilts plus a rear blackout (non-tilting) shade) + Type 10 - Duolite with 180° Tilt + """ + + # type + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: PowerviewDeviceInfo, + room_name: str, + shade: BaseShade, + name: str, + ) -> None: + """Initialize the shade.""" + super().__init__(coordinator, device_info, room_name, shade, name) + self._attr_supported_features |= ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION + ) + if self._device_info.model != LEGACY_DEVICE_MODEL: + self._attr_supported_features |= CoverEntityFeature.STOP_TILT + self._max_tilt = self._shade.shade_limits.tilt_max + + @property + def transition_steps(self) -> int: + """Return the steps to make a move.""" + # poskind 1 represents the second half of the shade in hass + # front must be fully closed before rear can move + # 51 - 100 is equiv to 1-100 on other shades - one motor, two shades + primary = (hd_position_to_hass(self.positions.primary, MAX_POSITION) / 2) + 50 + # poskind 2 represents the shade first half of the shade in hass + # rear (blackout) must be fully open before front can move + # 51 - 100 is equiv to 1-100 on other shades - one motor, two shades + secondary = hd_position_to_hass(self.positions.secondary, MAX_POSITION) / 2 + vane = hd_position_to_hass(self.positions.vane, self._max_tilt) + return ceil(primary + secondary + vane) + + @callback + def _get_shade_tilt(self, target_hass_tilt_position: int) -> PowerviewShadeMove: + """Return a PowerviewShadeMove.""" + position_vane = hass_position_to_hd(target_hass_tilt_position, self._max_tilt) + return PowerviewShadeMove( + { + ATTR_POSITION1: position_vane, + ATTR_POSKIND1: POS_KIND_VANE, + }, + {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_SECONDARY: MAX_POSITION}, + ) + + @property + def open_tilt_position(self) -> PowerviewShadeMove: + """Return the open tilt position and required additional positions.""" + return PowerviewShadeMove( + self._shade.open_position_tilt, + {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_SECONDARY: MAX_POSITION}, + ) + + @property + def close_tilt_position(self) -> PowerviewShadeMove: + """Return the open tilt position and required additional positions.""" + return PowerviewShadeMove( + self._shade.open_position_tilt, + {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_SECONDARY: MAX_POSITION}, + ) + + TYPE_TO_CLASSES = { + 0: (PowerViewShade,), 1: (PowerViewShadeWithTiltOnClosed,), 2: (PowerViewShadeWithTiltAnywhere,), + 3: (PowerViewShade,), 4: (PowerViewShadeWithTiltAnywhere,), - 7: (PowerViewShadeTDBUTop, PowerViewShadeTDBUBottom), + 5: (PowerViewShadeTiltOnly,), + 6: (PowerViewShadeTopDown,), + 7: ( + PowerViewShadeTDBUTop, + PowerViewShadeTDBUBottom, + ), + 8: ( + PowerViewShadeDualOverlappedCombined, + PowerViewShadeDualOverlappedFront, + PowerViewShadeDualOverlappedRear, + ), + 9: ( + PowerViewShadeDualOverlappedCombinedTilt, + PowerViewShadeDualOverlappedFront, + PowerViewShadeDualOverlappedRear, + ), + 10: ( + PowerViewShadeDualOverlappedCombinedTilt, + PowerViewShadeDualOverlappedFront, + PowerViewShadeDualOverlappedRear, + ), }