Files
hdpv_ble/custom_components/hunterdouglas_powerview_ble/cover.py
Richard Mann 31185a4446 Improve config flow UX for multi-shade setups
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.
2026-04-06 09:07:16 +10:00

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
)