Reuse the home key from already-configured shades so adding subsequent shades skips the key step. Show human-readable shade names from the hub in the device picker. Allow selecting multiple shades at once instead of repeating the flow for each one. Default to hub fetch as the key method.
298 lines
11 KiB
Python
298 lines
11 KiB
Python
"""Hunter Douglas Powerview cover."""
|
|
|
|
from typing import Any, Final
|
|
|
|
from bleak.exc import BleakError
|
|
|
|
from homeassistant.components.bluetooth.passive_update_coordinator import (
|
|
PassiveBluetoothCoordinatorEntity,
|
|
)
|
|
from homeassistant.components.cover import (
|
|
ATTR_CURRENT_POSITION,
|
|
ATTR_CURRENT_TILT_POSITION,
|
|
ATTR_POSITION,
|
|
ATTR_TILT_POSITION,
|
|
CoverDeviceClass,
|
|
CoverEntity,
|
|
CoverEntityFeature,
|
|
)
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers.device_registry import DeviceInfo, format_mac
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
|
|
from .api import CLOSED_POSITION, OPEN_POSITION
|
|
from .const import DOMAIN, LOGGER
|
|
from .coordinator import PVCoordinator
|
|
|
|
|
|
async def async_setup_entry(
|
|
_hass: HomeAssistant,
|
|
config_entry: ConfigEntry,
|
|
async_add_entities: AddEntitiesCallback,
|
|
) -> None:
|
|
"""Set up the demo cover platform."""
|
|
|
|
coordinator: PVCoordinator = config_entry.runtime_data
|
|
model: Final[str|None] = coordinator.dev_details.get("model")
|
|
entities: list[PowerViewCover] = []
|
|
if model in ["39"]:
|
|
entities.append(PowerViewCoverTiltOnly(coordinator))
|
|
else:
|
|
entities.append(PowerViewCover(coordinator))
|
|
|
|
async_add_entities(entities)
|
|
|
|
|
|
class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEntity): # type: ignore[reportIncompatibleVariableOverride]
|
|
"""Representation of a PowerView shade with Up/Down functionality only."""
|
|
|
|
_attr_has_entity_name = True
|
|
_attr_device_class = CoverDeviceClass.SHADE
|
|
_attr_supported_features = (
|
|
CoverEntityFeature.OPEN
|
|
| CoverEntityFeature.CLOSE
|
|
| CoverEntityFeature.SET_POSITION
|
|
| CoverEntityFeature.STOP
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
coordinator: PVCoordinator,
|
|
) -> None:
|
|
"""Initialize the shade."""
|
|
LOGGER.debug("%s: init() PowerViewCover", coordinator.name)
|
|
self._attr_name = CoverDeviceClass.SHADE
|
|
self._coord: PVCoordinator = coordinator
|
|
self._attr_device_info = self._coord.device_info
|
|
self._target_position: int | None = round(
|
|
self._coord.data.get(ATTR_CURRENT_POSITION, OPEN_POSITION)
|
|
)
|
|
self._attr_unique_id = (
|
|
f"{DOMAIN}_{format_mac(self._coord.address)}_{CoverDeviceClass.SHADE}"
|
|
)
|
|
super().__init__(coordinator)
|
|
|
|
@property
|
|
def device_info(self) -> DeviceInfo: # type: ignore[reportIncompatibleVariableOverride]
|
|
"""Return the device_info of the device."""
|
|
return self._coord.device_info
|
|
|
|
@property
|
|
def is_opening(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride]
|
|
"""Return if the cover is opening or not."""
|
|
return bool(self._coord.data.get("is_opening")) or (
|
|
isinstance(self._target_position, int)
|
|
and isinstance(self.current_cover_position, int)
|
|
and self._target_position > self.current_cover_position
|
|
and self._coord.api.is_connected
|
|
)
|
|
|
|
@property
|
|
def is_closing(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride]
|
|
"""Return if the cover is closing or not."""
|
|
return bool(self._coord.data.get("is_closing")) or (
|
|
isinstance(self._target_position, int)
|
|
and isinstance(self.current_cover_position, int)
|
|
and self._target_position < self.current_cover_position
|
|
and self._coord.api.is_connected
|
|
)
|
|
|
|
@property
|
|
def is_closed(self) -> bool: # type: ignore[reportIncompatibleVariableOverride]
|
|
"""Return if the cover is closed."""
|
|
return self.current_cover_position == CLOSED_POSITION
|
|
|
|
@property
|
|
def supported_features(self) -> CoverEntityFeature: # type: ignore[reportIncompatibleVariableOverride]
|
|
"""Flag supported features, disable control if encryption is needed."""
|
|
if (
|
|
self._coord.data.get("home_id") and not self._coord.api.has_key
|
|
) or self._coord.data.get("battery_charging"):
|
|
return CoverEntityFeature(0)
|
|
|
|
return super().supported_features
|
|
|
|
@property
|
|
def current_cover_position(self) -> int | None: # type: ignore[reportIncompatibleVariableOverride]
|
|
"""Return current position of cover.
|
|
|
|
None is unknown, 0 is closed, 100 is fully open.
|
|
"""
|
|
pos: Final = self._coord.data.get(ATTR_CURRENT_POSITION)
|
|
return round(pos) if pos is not None else None
|
|
|
|
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
|
"""Move the cover to a specific position."""
|
|
target_position: Final = kwargs.get(ATTR_POSITION)
|
|
if target_position is not None:
|
|
LOGGER.debug("set cover to position %f", target_position)
|
|
if self.current_cover_position == round(target_position) and not (
|
|
self.is_closing or self.is_opening
|
|
):
|
|
return
|
|
self._target_position = round(target_position)
|
|
try:
|
|
await self._coord.api.set_position(round(target_position))
|
|
self.async_write_ha_state()
|
|
except BleakError as err:
|
|
LOGGER.error(
|
|
"Failed to move cover '%s' to %f%%: %s",
|
|
self.name,
|
|
target_position,
|
|
err,
|
|
)
|
|
|
|
def _reset_target_position(self) -> None:
|
|
self._target_position = None
|
|
|
|
async def async_open_cover(self, **kwargs: Any) -> None:
|
|
"""Open the cover."""
|
|
LOGGER.debug("open cover")
|
|
if self.current_cover_position == OPEN_POSITION:
|
|
return
|
|
try:
|
|
self._target_position = OPEN_POSITION
|
|
await self._coord.api.open()
|
|
self.async_write_ha_state()
|
|
except BleakError as err:
|
|
LOGGER.error("Failed to open cover '%s': %s", self.name, err)
|
|
self._reset_target_position()
|
|
|
|
async def async_close_cover(self, **kwargs: Any) -> None:
|
|
"""Close the cover tilt."""
|
|
LOGGER.debug("close cover")
|
|
if self.current_cover_position == CLOSED_POSITION:
|
|
return
|
|
try:
|
|
self._target_position = CLOSED_POSITION
|
|
await self._coord.api.close()
|
|
self.async_write_ha_state()
|
|
except BleakError as err:
|
|
LOGGER.error("Failed to close cover '%s': %s", self.name, err)
|
|
self._reset_target_position()
|
|
|
|
async def async_stop_cover(self, **kwargs: Any) -> None:
|
|
"""Stop the cover."""
|
|
LOGGER.debug("stop cover")
|
|
try:
|
|
await self._coord.api.stop()
|
|
self._reset_target_position()
|
|
self.async_write_ha_state()
|
|
except BleakError as err:
|
|
LOGGER.error("Failed to stop cover '%s': %s", self.name, err)
|
|
|
|
|
|
class PowerViewCoverTilt(PowerViewCover):
|
|
"""Representation of a PowerView shade with additional tilt functionality."""
|
|
|
|
_attr_supported_features = (
|
|
CoverEntityFeature.OPEN
|
|
| CoverEntityFeature.CLOSE
|
|
| CoverEntityFeature.STOP
|
|
| CoverEntityFeature.SET_POSITION
|
|
| CoverEntityFeature.OPEN_TILT
|
|
| CoverEntityFeature.CLOSE_TILT
|
|
| CoverEntityFeature.STOP_TILT
|
|
| CoverEntityFeature.SET_TILT_POSITION
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
coordinator: PVCoordinator,
|
|
) -> None:
|
|
"""Initialize the shade with tilt."""
|
|
LOGGER.debug("%s: init() PowerViewCoverTilt", coordinator.name)
|
|
super().__init__(coordinator)
|
|
|
|
@property
|
|
def current_cover_tilt_position(self) -> int | None: # type: ignore[reportIncompatibleVariableOverride]
|
|
"""Return current tilt of cover.
|
|
|
|
None is unknown
|
|
"""
|
|
pos: Final = self._coord.data.get(ATTR_CURRENT_TILT_POSITION)
|
|
return round(pos) if pos is not None else None
|
|
|
|
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
|
"""Move the tilt to a specific position."""
|
|
|
|
if isinstance(target_position := kwargs.get(ATTR_TILT_POSITION), int):
|
|
LOGGER.debug("set cover tilt to position %i", target_position)
|
|
if (
|
|
self.current_cover_tilt_position == round(target_position)
|
|
or self.current_cover_position is None
|
|
):
|
|
return
|
|
|
|
try:
|
|
await self._coord.api.set_position(
|
|
self.current_cover_position, tilt=target_position
|
|
)
|
|
self.async_write_ha_state()
|
|
except BleakError as err:
|
|
LOGGER.error(
|
|
"Failed to tilt cover '%s' to %f%%: %s",
|
|
self.name,
|
|
target_position,
|
|
err,
|
|
)
|
|
|
|
async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
|
|
"""Stop the cover."""
|
|
await self.async_stop_cover(kwargs=kwargs)
|
|
|
|
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
|
"""Open the cover tilt."""
|
|
LOGGER.debug("open cover tilt")
|
|
_kwargs = {**kwargs, ATTR_TILT_POSITION: OPEN_POSITION}
|
|
await self.async_set_cover_tilt_position(**_kwargs)
|
|
|
|
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
|
"""Close the cover tilt."""
|
|
LOGGER.debug("close cover tilt")
|
|
_kwargs = {**kwargs, ATTR_TILT_POSITION: CLOSED_POSITION}
|
|
await self.async_set_cover_tilt_position(**_kwargs)
|
|
|
|
|
|
class PowerViewCoverTiltOnly(PowerViewCoverTilt):
|
|
"""Representation of a PowerView shade with additional tilt functionality."""
|
|
|
|
OPENCLOSED_THRESHOLD = 5
|
|
|
|
_attr_device_class = CoverDeviceClass.BLIND
|
|
_attr_supported_features = (
|
|
CoverEntityFeature.OPEN_TILT
|
|
| CoverEntityFeature.CLOSE_TILT
|
|
| CoverEntityFeature.STOP_TILT
|
|
| CoverEntityFeature.SET_TILT_POSITION
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
coordinator: PVCoordinator,
|
|
) -> None:
|
|
"""Initialize the shade with tilt only."""
|
|
LOGGER.debug("%s: init() PowerViewCoverTiltOnly", coordinator.name)
|
|
super().__init__(coordinator)
|
|
|
|
@property
|
|
def is_opening(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride]
|
|
"""Return if the cover is opening or not."""
|
|
return False
|
|
|
|
@property
|
|
def is_closing(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride]
|
|
"""Return if the cover is closing or not."""
|
|
return False
|
|
|
|
@property
|
|
def is_closed(self) -> bool: # type: ignore[reportIncompatibleVariableOverride]
|
|
"""Return if the cover is closed."""
|
|
return isinstance(self.current_cover_tilt_position, int) and (
|
|
self.current_cover_tilt_position
|
|
>= OPEN_POSITION - PowerViewCoverTiltOnly.OPENCLOSED_THRESHOLD
|
|
or self.current_cover_tilt_position
|
|
<= CLOSED_POSITION + PowerViewCoverTiltOnly.OPENCLOSED_THRESHOLD
|
|
)
|