diff --git a/.coveragerc b/.coveragerc index 6f96cccaf5f..19f305dbf3b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -506,10 +506,13 @@ omit = homeassistant/components/huawei_lte/switch.py homeassistant/components/hue/light.py homeassistant/components/hunterdouglas_powerview/__init__.py - homeassistant/components/hunterdouglas_powerview/scene.py - homeassistant/components/hunterdouglas_powerview/sensor.py + homeassistant/components/hunterdouglas_powerview/coordinator.py homeassistant/components/hunterdouglas_powerview/cover.py homeassistant/components/hunterdouglas_powerview/entity.py + homeassistant/components/hunterdouglas_powerview/scene.py + homeassistant/components/hunterdouglas_powerview/sensor.py + homeassistant/components/hunterdouglas_powerview/shade_data.py + homeassistant/components/hunterdouglas_powerview/util.py homeassistant/components/hvv_departures/binary_sensor.py homeassistant/components/hvv_departures/sensor.py homeassistant/components/hvv_departures/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index 3772a7f2dfa..545d5027ecb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -467,8 +467,8 @@ build.json @home-assistant/supervisor /tests/components/huisbaasje/ @dennisschroer /homeassistant/components/humidifier/ @home-assistant/core @Shulyaka /tests/components/humidifier/ @home-assistant/core @Shulyaka -/homeassistant/components/hunterdouglas_powerview/ @bdraco @trullock -/tests/components/hunterdouglas_powerview/ @bdraco @trullock +/homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock +/tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock /homeassistant/components/hvv_departures/ @vigonotion /tests/components/hvv_departures/ @vigonotion /homeassistant/components/hydrawise/ @ptcryan diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index 17dd580f5cc..97f7f8de931 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -1,10 +1,8 @@ """The Hunter Douglas PowerView integration.""" -from datetime import timedelta import logging from aiopvapi.helpers.aiorequest import AioRequest from aiopvapi.helpers.api_base import ApiEntryPoint -from aiopvapi.helpers.constants import ATTR_ID from aiopvapi.helpers.tools import base64_to_unicode from aiopvapi.rooms import Rooms from aiopvapi.scenes import Scenes @@ -14,11 +12,10 @@ import async_timeout from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( API_PATH_FWVERSION, @@ -50,6 +47,9 @@ from .const import ( SHADE_DATA, USER_DATA, ) +from .coordinator import PowerviewShadeUpdateCoordinator +from .shade_data import PowerviewShadeData +from .util import async_map_data_by_id PARALLEL_UPDATES = 1 @@ -64,7 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: config = entry.data - hub_address = config.get(CONF_HOST) + hub_address = config[CONF_HOST] websession = async_get_clientsession(hass) pv_request = AioRequest(hub_address, loop=hass.loop, websession=websession) @@ -75,17 +75,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async with async_timeout.timeout(10): rooms = Rooms(pv_request) - room_data = _async_map_data_by_id((await rooms.get_resources())[ROOM_DATA]) + room_data = async_map_data_by_id((await rooms.get_resources())[ROOM_DATA]) async with async_timeout.timeout(10): scenes = Scenes(pv_request) - scene_data = _async_map_data_by_id( + scene_data = async_map_data_by_id( (await scenes.get_resources())[SCENE_DATA] ) async with async_timeout.timeout(10): shades = Shades(pv_request) - shade_data = _async_map_data_by_id( + shade_data = async_map_data_by_id( (await shades.get_resources())[SHADE_DATA] ) except HUB_EXCEPTIONS as err: @@ -95,24 +95,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not device_info: raise ConfigEntryNotReady(f"Unable to initialize PowerView hub: {hub_address}") - async def async_update_data(): - """Fetch data from shade endpoint.""" - async with async_timeout.timeout(10): - shade_entries = await shades.get_resources() - if not shade_entries: - raise UpdateFailed("Failed to fetch new shade data.") - return _async_map_data_by_id(shade_entries[SHADE_DATA]) - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name="powerview hub", - update_method=async_update_data, - update_interval=timedelta(seconds=60), - ) - - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { + coordinator = PowerviewShadeUpdateCoordinator(hass, shades, hub_address) + coordinator.async_set_updated_data(PowerviewShadeData()) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { PV_API: pv_request, PV_ROOM_DATA: room_data, PV_SCENE_DATA: scene_data, @@ -155,12 +140,6 @@ async def async_get_device_info(pv_request): } -@callback -def _async_map_data_by_id(data): - """Return a dict with the key being the id for a list of entries.""" - return {entry[ATTR_ID]: entry for entry in data} - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/hunterdouglas_powerview/const.py b/homeassistant/components/hunterdouglas_powerview/const.py index ea87150a9ca..1cc9f79df40 100644 --- a/homeassistant/components/hunterdouglas_powerview/const.py +++ b/homeassistant/components/hunterdouglas_powerview/const.py @@ -7,7 +7,6 @@ from aiopvapi.helpers.aiorequest import PvApiConnectionError, PvApiResponseStatu DOMAIN = "hunterdouglas_powerview" - MANUFACTURER = "Hunter Douglas" HUB_ADDRESS = "address" @@ -48,7 +47,6 @@ ROOM_NAME = "name" ROOM_NAME_UNICODE = "name_unicode" ROOM_ID = "id" -SHADE_RESPONSE = "shade" SHADE_BATTERY_LEVEL = "batteryStrength" SHADE_BATTERY_LEVEL_MAX = 200 @@ -81,5 +79,10 @@ DEFAULT_LEGACY_MAINPROCESSOR = { FIRMWARE_NAME: LEGACY_DEVICE_MODEL, } - API_PATH_FWVERSION = "api/fwversion" + +POS_KIND_NONE = 0 +POS_KIND_PRIMARY = 1 +POS_KIND_SECONDARY = 2 +POS_KIND_VANE = 3 +POS_KIND_ERROR = 4 diff --git a/homeassistant/components/hunterdouglas_powerview/coordinator.py b/homeassistant/components/hunterdouglas_powerview/coordinator.py new file mode 100644 index 00000000000..bf3d6eb7a54 --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/coordinator.py @@ -0,0 +1,44 @@ +"""Coordinate data for powerview devices.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from aiopvapi.shades import Shades +import async_timeout + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import SHADE_DATA +from .shade_data import PowerviewShadeData + +_LOGGER = logging.getLogger(__name__) + + +class PowerviewShadeUpdateCoordinator(DataUpdateCoordinator[PowerviewShadeData]): + """DataUpdateCoordinator to gather data from a powerview hub.""" + + def __init__( + self, + hass: HomeAssistant, + shades: Shades, + hub_address: str, + ) -> None: + """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" + self.shades = shades + super().__init__( + hass, + _LOGGER, + name=f"powerview hub {hub_address}", + update_interval=timedelta(seconds=60), + ) + + async def _async_update_data(self) -> PowerviewShadeData: + """Fetch data from shade endpoint.""" + async with async_timeout.timeout(10): + shade_entries = await self.shades.get_resources() + if not shade_entries: + raise UpdateFailed("Failed to fetch new shade data") + self.data.store_group_data(shade_entries[SHADE_DATA]) + return self.data diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 493b5d53639..565bac6a5c8 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -1,14 +1,24 @@ """Support for hunter douglas shades.""" -from abc import abstractmethod +from __future__ import annotations + import asyncio +from collections.abc import Iterable from contextlib import suppress import logging +from typing import Any -from aiopvapi.helpers.constants import ATTR_POSITION1, ATTR_POSITION_DATA +from aiopvapi.helpers.constants import ( + ATTR_POSITION1, + ATTR_POSITION2, + ATTR_POSITION_DATA, +) from aiopvapi.resources.shade import ( ATTR_POSKIND1, + ATTR_POSKIND2, MAX_POSITION, MIN_POSITION, + BaseShade, + ShadeTdbu, Silhouette, factory as PvShade, ) @@ -22,7 +32,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later @@ -32,15 +42,19 @@ from .const import ( DEVICE_MODEL, DOMAIN, LEGACY_DEVICE_MODEL, + POS_KIND_PRIMARY, + POS_KIND_SECONDARY, + POS_KIND_VANE, PV_API, PV_ROOM_DATA, PV_SHADE_DATA, ROOM_ID_IN_SHADE, ROOM_NAME_UNICODE, - SHADE_RESPONSE, STATE_ATTRIBUTE_ROOM_NAME, ) +from .coordinator import PowerviewShadeUpdateCoordinator from .entity import ShadeEntity +from .shade_data import PowerviewShadeMove _LOGGER = logging.getLogger(__name__) @@ -52,11 +66,13 @@ PARALLEL_UPDATES = 1 RESYNC_DELAY = 60 -POSKIND_NONE = 0 -POSKIND_PRIMARY = 1 -POSKIND_SECONDARY = 2 -POSKIND_VANE = 3 -POSKIND_ERROR = 4 +# this equates to 0.75/100 in terms of hass blind position +# some blinds in a closed position report less than 655.35 (1%) +# but larger than 0 even though they are clearly closed +# Find 1 percent of MAX_POSITION, then find 75% of that number +# The means currently 491.5125 or less is closed position +# implemented for top/down shades, but also works fine with normal shades +CLOSED_POSITION = (0.75 / 100) * (MAX_POSITION - MIN_POSITION) async def async_setup_entry( @@ -65,18 +81,17 @@ async def async_setup_entry( """Set up the hunter douglas shades.""" pv_data = hass.data[DOMAIN][entry.entry_id] - room_data = pv_data[PV_ROOM_DATA] + room_data: dict[str | int, Any] = pv_data[PV_ROOM_DATA] shade_data = pv_data[PV_SHADE_DATA] pv_request = pv_data[PV_API] - coordinator = pv_data[COORDINATOR] - device_info = pv_data[DEVICE_INFO] + coordinator: PowerviewShadeUpdateCoordinator = pv_data[COORDINATOR] + device_info: dict[str, Any] = pv_data[DEVICE_INFO] - entities = [] + entities: list[ShadeEntity] = [] for raw_shade in shade_data.values(): # The shade may be out of sync with the hub - # so we force a refresh when we add it if - # possible - shade = PvShade(raw_shade, pv_request) + # so we force a refresh when we add it if possible + shade: BaseShade = PvShade(raw_shade, pv_request) name_before_refresh = shade.name with suppress(asyncio.TimeoutError): async with async_timeout.timeout(1): @@ -88,9 +103,10 @@ async def async_setup_entry( name_before_refresh, ) continue + coordinator.data.update_shade_positions(shade.raw_data) room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) room_name = room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") - entities.append( + entities.extend( create_powerview_shade_entity( coordinator, device_info, room_name, shade, name_before_refresh ) @@ -99,26 +115,36 @@ async def async_setup_entry( def create_powerview_shade_entity( - coordinator, device_info, room_name, shade, name_before_refresh -): + coordinator: PowerviewShadeUpdateCoordinator, + device_info: dict[str, Any], + room_name: str, + shade: BaseShade, + name_before_refresh: str, +) -> Iterable[ShadeEntity]: """Create a PowerViewShade entity.""" - - if isinstance(shade, Silhouette): - return PowerViewShadeSilhouette( - coordinator, device_info, room_name, shade, name_before_refresh - ) - - return PowerViewShade( - coordinator, device_info, room_name, shade, name_before_refresh - ) + classes: list[BaseShade] = [] + # order here is important as both ShadeTDBU are listed in aiovapi as can_tilt + # and both require their own class here to work + if isinstance(shade, ShadeTdbu): + classes.extend([PowerViewShadeTDBUTop, PowerViewShadeTDBUBottom]) + elif isinstance(shade, Silhouette): + classes.append(PowerViewShadeSilhouette) + elif shade.can_tilt: + classes.append(PowerViewShadeWithTilt) + else: + classes.append(PowerViewShade) + return [ + cls(coordinator, device_info, room_name, shade, name_before_refresh) + for cls in classes + ] -def hd_position_to_hass(hd_position, max_val): +def hd_position_to_hass(hd_position: int, max_val: int = MAX_POSITION) -> int: """Convert hunter douglas position to hass position.""" return round((hd_position / max_val) * 100) -def hass_position_to_hd(hass_position, max_val): +def hass_position_to_hd(hass_position: int, max_val: int = MAX_POSITION) -> int: """Convert hass position to hunter douglas position.""" return int(hass_position / 100 * max_val) @@ -128,130 +154,126 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): # The hub frequently reports stale states _attr_assumed_state = True + _attr_device_class = CoverDeviceClass.SHADE + _attr_supported_features = 0 - def __init__(self, coordinator, device_info, room_name, shade, name): + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: dict[str, Any], + room_name: str, + shade: BaseShade, + name: str, + ) -> None: """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade, name) - self._shade = shade - self._is_opening = False - self._is_closing = False - self._last_action_timestamp = 0 - self._scheduled_transition_update = None - self._current_hd_cover_position = MIN_POSITION + self._shade: BaseShade = shade + self._attr_name = self._shade_name + self._scheduled_transition_update: CALLBACK_TYPE | None = None if self._device_info[DEVICE_MODEL] != LEGACY_DEVICE_MODEL: self._attr_supported_features |= CoverEntityFeature.STOP self._forced_resync = None @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" return {STATE_ATTRIBUTE_ROOM_NAME: self._room_name} @property def is_closed(self): """Return if the cover is closed.""" - return self._current_hd_cover_position == MIN_POSITION + return self.positions.primary <= CLOSED_POSITION @property - def is_opening(self): - """Return if the cover is opening.""" - return self._is_opening - - @property - def is_closing(self): - """Return if the cover is closing.""" - return self._is_closing - - @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return the current position of cover.""" - return hd_position_to_hass(self._current_hd_cover_position, MAX_POSITION) + return hd_position_to_hass(self.positions.primary, MAX_POSITION) @property - def device_class(self): - """Return device class.""" - return CoverDeviceClass.SHADE + def transition_steps(self) -> int: + """Return the steps to make a move.""" + return hd_position_to_hass(self.positions.primary, MAX_POSITION) @property - def name(self): - """Return the name of the shade.""" - return self._shade_name + def open_position(self) -> PowerviewShadeMove: + """Return the open position and required additional positions.""" + return PowerviewShadeMove(self._shade.open_position, {}) - async def async_close_cover(self, **kwargs): + @property + def close_position(self) -> PowerviewShadeMove: + """Return the close position and required additional positions.""" + return PowerviewShadeMove(self._shade.close_position, {}) + + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self._async_move(0) + self._async_schedule_update_for_transition(self.transition_steps) + await self._async_execute_move(self.close_position) + self._attr_is_opening = False + self._attr_is_closing = True + self.async_write_ha_state() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._async_move(100) + self._async_schedule_update_for_transition(100 - self.transition_steps) + await self._async_execute_move(self.open_position) + self._attr_is_opening = True + self._attr_is_closing = False + self.async_write_ha_state() - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - # Cancel any previous updates self._async_cancel_scheduled_transition_update() - self._async_update_from_command(await self._shade.stop()) + self.data.update_from_response(await self._shade.stop()) await self._async_force_refresh_state() - async def async_set_cover_position(self, **kwargs): - """Move the shade to a specific position.""" - if ATTR_POSITION not in kwargs: - return - await self._async_move(kwargs[ATTR_POSITION]) + @callback + def _clamp_cover_limit(self, target_hass_position: int) -> int: + """Dont allow a cover to go into an impossbile position.""" + # no override required in base + return target_hass_position - async def _async_move(self, target_hass_position): + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the shade to a specific position.""" + await self._async_set_cover_position(kwargs[ATTR_POSITION]) + + @callback + def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: + position_one = hass_position_to_hd(target_hass_position) + return PowerviewShadeMove( + {ATTR_POSITION1: position_one, ATTR_POSKIND1: POS_KIND_PRIMARY}, {} + ) + + async def _async_execute_move(self, move: PowerviewShadeMove) -> None: + """Execute a move that can affect multiple positions.""" + response = await self._shade.move(move.request) + # Process any positions we know will update as result + # of the request since the hub won't return them + for kind, position in move.new_positions.items(): + self.data.update_shade_position(self._shade.id, position, kind) + # Finally process the response + self.data.update_from_response(response) + + async def _async_set_cover_position(self, target_hass_position: int) -> None: """Move the shade to a position.""" - current_hass_position = hd_position_to_hass( - self._current_hd_cover_position, MAX_POSITION + target_hass_position = self._clamp_cover_limit(target_hass_position) + current_hass_position = self.current_cover_position + self._async_schedule_update_for_transition( + abs(current_hass_position - target_hass_position) ) - steps_to_move = abs(current_hass_position - target_hass_position) - self._async_schedule_update_for_transition(steps_to_move) - self._async_update_from_command( - await self._shade.move( - { - ATTR_POSITION1: hass_position_to_hd( - target_hass_position, MAX_POSITION - ), - ATTR_POSKIND1: POSKIND_PRIMARY, - } - ) - ) - self._is_opening = False - self._is_closing = False - if target_hass_position > current_hass_position: - self._is_opening = True - elif target_hass_position < current_hass_position: - self._is_closing = True + await self._async_execute_move(self._get_shade_move(target_hass_position)) + self._attr_is_opening = target_hass_position > current_hass_position + self._attr_is_closing = target_hass_position < current_hass_position self.async_write_ha_state() @callback - def _async_update_from_command(self, raw_data): - """Update the shade state after a command.""" - if not raw_data or SHADE_RESPONSE not in raw_data: - return - self._async_process_new_shade_data(raw_data[SHADE_RESPONSE]) - - @callback - def _async_process_new_shade_data(self, data): - """Process new data from an update.""" - self._shade.raw_data = data - self._async_update_current_cover_position() - - @callback - def _async_update_current_cover_position(self): + def _async_update_shade_data(self, shade_data: dict[str | int, Any]) -> None: """Update the current cover position from the data.""" - _LOGGER.debug("Raw data update: %s", self._shade.raw_data) - position_data = self._shade.raw_data.get(ATTR_POSITION_DATA, {}) - self._async_process_updated_position_data(position_data) - self._is_opening = False - self._is_closing = False + self.data.update_shade_positions(shade_data) + self._attr_is_opening = False + self._attr_is_closing = False @callback - @abstractmethod - def _async_process_updated_position_data(self, position_data): - """Process position data.""" - - @callback - def _async_cancel_scheduled_transition_update(self): + def _async_cancel_scheduled_transition_update(self) -> None: """Cancel any previous updates.""" if self._scheduled_transition_update: self._scheduled_transition_update() @@ -261,9 +283,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): self._forced_resync = None @callback - def _async_schedule_update_for_transition(self, steps): - self.async_write_ha_state() - + def _async_schedule_update_for_transition(self, steps: int) -> None: # Cancel any previous updates self._async_cancel_scheduled_transition_update() @@ -278,7 +298,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): est_time_to_complete_transition, ) - # Schedule an update for when we expect the transition + # Schedule an forced update for when we expect the transition # to be completed. self._scheduled_transition_update = async_call_later( self.hass, @@ -295,139 +315,281 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): self.hass, RESYNC_DELAY, self._async_force_resync ) - async def _async_force_resync(self, *_): + async def _async_force_resync(self, *_: Any) -> None: """Force a resync after an update since the hub may have stale state.""" self._forced_resync = None + _LOGGER.debug("Force resync of shade %s", self.name) await self._async_force_refresh_state() - async def _async_force_refresh_state(self): + async def _async_force_refresh_state(self) -> None: """Refresh the cover state and force the device cache to be bypassed.""" await self._shade.refresh() - self._async_update_current_cover_position() + self._async_update_shade_data(self._shade.raw_data) self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """When entity is added to hass.""" - self._async_update_current_cover_position() self.async_on_remove( self.coordinator.async_add_listener(self._async_update_shade_from_group) ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Cancel any pending refreshes.""" self._async_cancel_scheduled_transition_update() @callback - def _async_update_shade_from_group(self): + def _async_update_shade_from_group(self) -> None: """Update with new data from the coordinator.""" if self._scheduled_transition_update or self._forced_resync: - # If a transition in in progress - # the data will be wrong + # If a transition is in progress the data will be wrong return - self._async_process_new_shade_data(self.coordinator.data[self._shade.id]) + self.data.update_from_group_data(self._shade.id) self.async_write_ha_state() class PowerViewShade(PowerViewShadeBase): """Represent a standard shade.""" - _attr_supported_features = ( - CoverEntityFeature.OPEN - | CoverEntityFeature.CLOSE - | CoverEntityFeature.SET_POSITION - ) + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: dict[str, Any], + 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 + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + ) + + +class PowerViewShadeTDBU(PowerViewShade): + """Representation of a PowerView shade with top/down bottom/up capabilities.""" + + @property + def transition_steps(self) -> int: + """Return the steps to make a move.""" + return hd_position_to_hass( + self.positions.primary, MAX_POSITION + ) + hd_position_to_hass(self.positions.secondary, MAX_POSITION) + + +class PowerViewShadeTDBUBottom(PowerViewShadeTDBU): + """Representation of a top down bottom up powerview shade.""" + + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: dict[str, Any], + 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}_bottom" + self._attr_name = f"{self._shade_name} Bottom" @callback - def _async_process_updated_position_data(self, position_data): - """Process position data.""" - if ATTR_POSITION1 in position_data: - self._current_hd_cover_position = int(position_data[ATTR_POSITION1]) + def _clamp_cover_limit(self, target_hass_position: int) -> int: + """Dont allow a cover to go into an impossbile position.""" + cover_top = hd_position_to_hass(self.positions.secondary, MAX_POSITION) + return min(target_hass_position, (100 - cover_top)) + + @callback + def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: + position_bottom = hass_position_to_hd(target_hass_position) + position_top = self.positions.secondary + return PowerviewShadeMove( + { + ATTR_POSITION1: position_bottom, + ATTR_POSITION2: position_top, + ATTR_POSKIND1: POS_KIND_PRIMARY, + ATTR_POSKIND2: POS_KIND_SECONDARY, + }, + {}, + ) + + +class PowerViewShadeTDBUTop(PowerViewShadeTDBU): + """Representation of a top down bottom up powerview shade.""" + + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: dict[str, Any], + 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}_top" + self._attr_name = f"{self._shade_name} Top" + # these shades share a class in parent API + # override open position for top shade + self._shade.open_position = { + ATTR_POSITION1: MIN_POSITION, + ATTR_POSITION2: MAX_POSITION, + ATTR_POSKIND1: POS_KIND_PRIMARY, + ATTR_POSKIND2: POS_KIND_SECONDARY, + } + + @property + def is_closed(self): + """Return if the cover is closed.""" + # top shade needs to check other motor + return self.positions.secondary <= CLOSED_POSITION + + @property + def current_cover_position(self) -> int: + """Return the current position of cover.""" + # these need to be inverted to report state correctly in HA + return hd_position_to_hass(self.positions.secondary, MAX_POSITION) + + @callback + def _clamp_cover_limit(self, target_hass_position: int) -> int: + """Dont allow a cover to go into an impossbile position.""" + cover_bottom = hd_position_to_hass(self.positions.primary, MAX_POSITION) + return min(target_hass_position, (100 - cover_bottom)) + + @callback + def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: + position_bottom = self.positions.primary + position_top = hass_position_to_hd(target_hass_position, MAX_POSITION) + return PowerviewShadeMove( + { + ATTR_POSITION1: position_bottom, + ATTR_POSITION2: position_top, + ATTR_POSKIND1: POS_KIND_PRIMARY, + ATTR_POSKIND2: POS_KIND_SECONDARY, + }, + {}, + ) class PowerViewShadeWithTilt(PowerViewShade): """Representation of a PowerView shade with tilt capabilities.""" - _attr_supported_features = ( - CoverEntityFeature.OPEN - | CoverEntityFeature.CLOSE - | CoverEntityFeature.SET_POSITION - | CoverEntityFeature.OPEN_TILT - | CoverEntityFeature.CLOSE_TILT - | CoverEntityFeature.STOP_TILT - | CoverEntityFeature.SET_TILT_POSITION - ) - _max_tilt = MAX_POSITION - _tilt_steps = 10 - def __init__(self, coordinator, device_info, room_name, shade, name): + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: dict[str, Any], + room_name: str, + shade: BaseShade, + name: str, + ) -> None: """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade, name) - self._attr_current_cover_tilt_position = 0 - - async def async_open_cover_tilt(self, **kwargs): - """Open the cover tilt.""" - current_hass_position = hd_position_to_hass( - self._current_hd_cover_position, MAX_POSITION + self._attr_supported_features |= ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION ) - steps_to_move = current_hass_position + self._tilt_steps - self._async_schedule_update_for_transition(steps_to_move) - self._async_update_from_command(await self._shade.tilt_open()) + if self._device_info[DEVICE_MODEL] != LEGACY_DEVICE_MODEL: + self._attr_supported_features |= CoverEntityFeature.STOP_TILT - async def async_close_cover_tilt(self, **kwargs): + @property + def current_cover_tilt_position(self) -> int: + """Return the current cover tile position.""" + return hd_position_to_hass(self.positions.vane, self._max_tilt) + + @property + def transition_steps(self): + """Return the steps to make a move.""" + return hd_position_to_hass( + self.positions.primary, MAX_POSITION + ) + hd_position_to_hass(self.positions.vane, self._max_tilt) + + @property + def open_position(self) -> PowerviewShadeMove: + """Return the open position and required additional positions.""" + return PowerviewShadeMove( + self._shade.open_position, {POS_KIND_VANE: MIN_POSITION} + ) + + @property + def close_position(self) -> PowerviewShadeMove: + """Return the close position and required additional positions.""" + return PowerviewShadeMove( + self._shade.close_position, {POS_KIND_VANE: MIN_POSITION} + ) + + @property + def open_tilt_position(self) -> PowerviewShadeMove: + """Return the open tilt position and required additional positions.""" + # next upstream api release to include self._shade.open_tilt_position + return PowerviewShadeMove( + {ATTR_POSKIND1: POS_KIND_VANE, ATTR_POSITION1: self._max_tilt}, + {POS_KIND_PRIMARY: MIN_POSITION}, + ) + + @property + def close_tilt_position(self) -> PowerviewShadeMove: + """Return the close tilt position and required additional positions.""" + # next upstream api release to include self._shade.close_tilt_position + return PowerviewShadeMove( + {ATTR_POSKIND1: POS_KIND_VANE, ATTR_POSITION1: MIN_POSITION}, + {POS_KIND_PRIMARY: MIN_POSITION}, + ) + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" - current_hass_position = hd_position_to_hass( - self._current_hd_cover_position, MAX_POSITION - ) - steps_to_move = current_hass_position + self._tilt_steps - self._async_schedule_update_for_transition(steps_to_move) - self._async_update_from_command(await self._shade.tilt_close()) + self._async_schedule_update_for_transition(self.transition_steps) + await self._async_execute_move(self.close_tilt_position) + self.async_write_ha_state() - async def async_set_cover_tilt_position(self, **kwargs): - """Move the cover tilt to a specific position.""" - target_hass_tilt_position = kwargs[ATTR_TILT_POSITION] - current_hass_position = hd_position_to_hass( - self._current_hd_cover_position, MAX_POSITION - ) - steps_to_move = current_hass_position + self._tilt_steps + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + self._async_schedule_update_for_transition(100 - self.transition_steps) + await self._async_execute_move(self.open_tilt_position) + self.async_write_ha_state() - self._async_schedule_update_for_transition(steps_to_move) - self._async_update_from_command( - await self._shade.move( - { - ATTR_POSITION1: hass_position_to_hd( - target_hass_tilt_position, self._max_tilt - ), - ATTR_POSKIND1: POSKIND_VANE, - } - ) - ) + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the vane to a specific position.""" + await self._async_set_cover_tilt_position(kwargs[ATTR_TILT_POSITION]) - async def async_stop_cover_tilt(self, **kwargs): - """Stop the cover tilting.""" - # Cancel any previous updates - await self.async_stop_cover() + async def _async_set_cover_tilt_position( + self, target_hass_tilt_position: int + ) -> None: + """Move the vane to a specific position.""" + final_position = self.current_cover_position + target_hass_tilt_position + self._async_schedule_update_for_transition( + abs(self.transition_steps - final_position) + ) + await self._async_execute_move(self._get_shade_tilt(target_hass_tilt_position)) + self.async_write_ha_state() @callback - def _async_process_updated_position_data(self, position_data): - """Process position data.""" - if ATTR_POSKIND1 not in position_data: - return - if int(position_data[ATTR_POSKIND1]) == POSKIND_PRIMARY: - self._current_hd_cover_position = int(position_data[ATTR_POSITION1]) - self._attr_current_cover_tilt_position = 0 - if int(position_data[ATTR_POSKIND1]) == POSKIND_VANE: - self._current_hd_cover_position = MIN_POSITION - self._attr_current_cover_tilt_position = hd_position_to_hass( - int(position_data[ATTR_POSITION1]), self._max_tilt - ) + def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: + """Return a PowerviewShadeMove.""" + position_shade = hass_position_to_hd(target_hass_position) + return PowerviewShadeMove( + {ATTR_POSITION1: position_shade, ATTR_POSKIND1: POS_KIND_PRIMARY}, + {POS_KIND_VANE: MIN_POSITION}, + ) + + @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}, + ) + + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop the cover tilting.""" + await self.async_stop_cover() class PowerViewShadeSilhouette(PowerViewShadeWithTilt): """Representation of a Silhouette PowerView shade.""" - def __init__(self, coordinator, device_info, room_name, shade, name): - """Initialize the shade.""" - super().__init__(coordinator, device_info, room_name, shade, name) - self._max_tilt = 32767 - self._tilt_steps = 4 + _max_tilt = 32767 diff --git a/homeassistant/components/hunterdouglas_powerview/entity.py b/homeassistant/components/hunterdouglas_powerview/entity.py index 50894d59f8b..174e3def2d6 100644 --- a/homeassistant/components/hunterdouglas_powerview/entity.py +++ b/homeassistant/components/hunterdouglas_powerview/entity.py @@ -1,6 +1,8 @@ -"""The nexia integration base entity.""" +"""The powerview integration base entity.""" -from aiopvapi.resources.shade import ATTR_TYPE +from typing import Any + +from aiopvapi.resources.shade import ATTR_TYPE, BaseShade from homeassistant.const import ATTR_MODEL, ATTR_SW_VERSION import homeassistant.helpers.device_registry as dr @@ -20,22 +22,30 @@ from .const import ( FIRMWARE_SUB_REVISION, MANUFACTURER, ) +from .coordinator import PowerviewShadeUpdateCoordinator +from .shade_data import PowerviewShadeData, PowerviewShadePositions -class HDEntity(CoordinatorEntity): +class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]): """Base class for hunter douglas entities.""" - def __init__(self, coordinator, device_info, room_name, unique_id): + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: dict[str, Any], + room_name: str, + unique_id: str, + ) -> None: """Initialize the entity.""" super().__init__(coordinator) self._room_name = room_name - self._unique_id = unique_id + self._attr_unique_id = unique_id self._device_info = device_info @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id + def data(self) -> PowerviewShadeData: + """Return the PowerviewShadeData.""" + return self.coordinator.data @property def device_info(self) -> DeviceInfo: @@ -58,12 +68,24 @@ class HDEntity(CoordinatorEntity): class ShadeEntity(HDEntity): """Base class for hunter douglas shade entities.""" - def __init__(self, coordinator, device_info, room_name, shade, shade_name): + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: dict[str, Any], + room_name: str, + shade: BaseShade, + shade_name: str, + ) -> None: """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade.id) self._shade_name = shade_name self._shade = shade + @property + def positions(self) -> PowerviewShadePositions: + """Return the PowerviewShadeData.""" + return self.data.get_shade_positions(self._shade.id) + @property def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" @@ -78,7 +100,7 @@ class ShadeEntity(HDEntity): ) for shade in self._shade.shade_types: - if shade.shade_type == device_info[ATTR_MODEL]: + if str(shade.shade_type) == device_info[ATTR_MODEL]: device_info[ATTR_MODEL] = shade.description break diff --git a/homeassistant/components/hunterdouglas_powerview/manifest.json b/homeassistant/components/hunterdouglas_powerview/manifest.json index af6aea17de3..c571056be23 100644 --- a/homeassistant/components/hunterdouglas_powerview/manifest.json +++ b/homeassistant/components/hunterdouglas_powerview/manifest.json @@ -3,7 +3,7 @@ "name": "Hunter Douglas PowerView", "documentation": "https://www.home-assistant.io/integrations/hunterdouglas_powerview", "requirements": ["aiopvapi==1.6.19"], - "codeowners": ["@bdraco", "@trullock"], + "codeowners": ["@bdraco", "@kingy444", "@trullock"], "config_flow": true, "homekit": { "models": ["PowerView"] diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index 43e438041f2..8fd492ddb1d 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -63,16 +63,16 @@ class PowerViewShadeBatterySensor(ShadeEntity, SensorEntity): _attr_device_class = SensorDeviceClass.BATTERY _attr_state_class = SensorStateClass.MEASUREMENT + def __init__(self, coordinator, device_info, room_name, shade, name): + """Initialize the shade.""" + super().__init__(coordinator, device_info, room_name, shade, name) + self._attr_unique_id = f"{self._attr_unique_id}_charge" + @property def name(self): """Name of the shade battery.""" return f"{self._shade_name} Battery" - @property - def unique_id(self): - """Shade battery Uniqueid.""" - return f"{self._unique_id}_charge" - @property def native_value(self): """Get the current value in percentage.""" @@ -89,5 +89,5 @@ class PowerViewShadeBatterySensor(ShadeEntity, SensorEntity): @callback def _async_update_shade_from_group(self): """Update with new data from the coordinator.""" - self._shade.raw_data = self.coordinator.data[self._shade.id] + self._shade.raw_data = self.data.get_raw_data(self._shade.id) self.async_write_ha_state() diff --git a/homeassistant/components/hunterdouglas_powerview/shade_data.py b/homeassistant/components/hunterdouglas_powerview/shade_data.py new file mode 100644 index 00000000000..4a7b7be0945 --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/shade_data.py @@ -0,0 +1,113 @@ +"""Shade data for the Hunter Douglas PowerView integration.""" +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +import logging +from typing import Any + +from aiopvapi.helpers.constants import ( + ATTR_ID, + ATTR_POSITION1, + ATTR_POSITION2, + ATTR_POSITION_DATA, + ATTR_POSKIND1, + ATTR_POSKIND2, + ATTR_SHADE, +) +from aiopvapi.resources.shade import MIN_POSITION + +from .const import POS_KIND_PRIMARY, POS_KIND_SECONDARY, POS_KIND_VANE +from .util import async_map_data_by_id + +POSITIONS = ((ATTR_POSITION1, ATTR_POSKIND1), (ATTR_POSITION2, ATTR_POSKIND2)) + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class PowerviewShadeMove: + """Request to move a powerview shade.""" + + # The positions to request on the hub + request: dict[str, int] + + # The positions that will also change + # as a result of the request that the + # hub will not send back + new_positions: dict[int, int] + + +@dataclass +class PowerviewShadePositions: + """Positions for a powerview shade.""" + + primary: int = MIN_POSITION + secondary: int = MIN_POSITION + vane: int = MIN_POSITION + + +class PowerviewShadeData: + """Coordinate shade data between multiple api calls.""" + + def __init__(self): + """Init the shade data.""" + self._group_data_by_id: dict[int, dict[str | int, Any]] = {} + self.positions: dict[int, PowerviewShadePositions] = {} + + def get_raw_data(self, shade_id: int) -> dict[str | int, Any]: + """Get data for the shade.""" + return self._group_data_by_id[shade_id] + + def get_shade_positions(self, shade_id: int) -> PowerviewShadePositions: + """Get positions for a shade.""" + if shade_id not in self.positions: + self.positions[shade_id] = PowerviewShadePositions() + return self.positions[shade_id] + + def update_from_group_data(self, shade_id: int) -> None: + """Process an update from the group data.""" + self.update_shade_positions(self._group_data_by_id[shade_id]) + + def store_group_data(self, shade_data: Iterable[dict[str | int, Any]]) -> None: + """Store data from the all shades endpoint. + + This does not update the shades or positions + as the data may be stale. update_from_group_data + with a shade_id will update a specific shade + from the group data. + """ + self._group_data_by_id = async_map_data_by_id(shade_data) + + def update_shade_position(self, shade_id: int, position: int, kind: int) -> None: + """Update a single shade position.""" + positions = self.get_shade_positions(shade_id) + if kind == POS_KIND_PRIMARY: + positions.primary = position + elif kind == POS_KIND_SECONDARY: + positions.secondary = position + elif kind == POS_KIND_VANE: + positions.vane = position + + def update_from_position_data( + self, shade_id: int, position_data: dict[str, Any] + ) -> None: + """Update the shade positions from the position data.""" + for position_key, kind_key in POSITIONS: + if position_key in position_data: + self.update_shade_position( + shade_id, position_data[position_key], position_data[kind_key] + ) + + def update_shade_positions(self, data: dict[int | str, Any]) -> None: + """Update a shades from data dict.""" + _LOGGER.debug("Raw data update: %s", data) + shade_id = data[ATTR_ID] + position_data = data[ATTR_POSITION_DATA] + self.update_from_position_data(shade_id, position_data) + + def update_from_response(self, response: dict[str, Any]) -> None: + """Update from the response to a command.""" + if response and ATTR_SHADE in response: + shade_data: dict[int | str, Any] = response[ATTR_SHADE] + self.update_shade_positions(shade_data) diff --git a/homeassistant/components/hunterdouglas_powerview/util.py b/homeassistant/components/hunterdouglas_powerview/util.py new file mode 100644 index 00000000000..15330f30bdb --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/util.py @@ -0,0 +1,15 @@ +"""Coordinate data for powerview devices.""" +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from aiopvapi.helpers.constants import ATTR_ID + +from homeassistant.core import callback + + +@callback +def async_map_data_by_id(data: Iterable[dict[str | int, Any]]): + """Return a dict with the key being the id for a list of entries.""" + return {entry[ATTR_ID]: entry for entry in data}