Add support for topdown shades to hunterdouglas_powerview (#62788)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
kingy444 2022-05-31 03:54:39 +10:00 committed by GitHub
parent 3399be2dad
commit 45e4dd379b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 602 additions and 261 deletions

View file

@ -506,10 +506,13 @@ omit =
homeassistant/components/huawei_lte/switch.py homeassistant/components/huawei_lte/switch.py
homeassistant/components/hue/light.py homeassistant/components/hue/light.py
homeassistant/components/hunterdouglas_powerview/__init__.py homeassistant/components/hunterdouglas_powerview/__init__.py
homeassistant/components/hunterdouglas_powerview/scene.py homeassistant/components/hunterdouglas_powerview/coordinator.py
homeassistant/components/hunterdouglas_powerview/sensor.py
homeassistant/components/hunterdouglas_powerview/cover.py homeassistant/components/hunterdouglas_powerview/cover.py
homeassistant/components/hunterdouglas_powerview/entity.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/binary_sensor.py
homeassistant/components/hvv_departures/sensor.py homeassistant/components/hvv_departures/sensor.py
homeassistant/components/hvv_departures/__init__.py homeassistant/components/hvv_departures/__init__.py

View file

@ -467,8 +467,8 @@ build.json @home-assistant/supervisor
/tests/components/huisbaasje/ @dennisschroer /tests/components/huisbaasje/ @dennisschroer
/homeassistant/components/humidifier/ @home-assistant/core @Shulyaka /homeassistant/components/humidifier/ @home-assistant/core @Shulyaka
/tests/components/humidifier/ @home-assistant/core @Shulyaka /tests/components/humidifier/ @home-assistant/core @Shulyaka
/homeassistant/components/hunterdouglas_powerview/ @bdraco @trullock /homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
/tests/components/hunterdouglas_powerview/ @bdraco @trullock /tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
/homeassistant/components/hvv_departures/ @vigonotion /homeassistant/components/hvv_departures/ @vigonotion
/tests/components/hvv_departures/ @vigonotion /tests/components/hvv_departures/ @vigonotion
/homeassistant/components/hydrawise/ @ptcryan /homeassistant/components/hydrawise/ @ptcryan

View file

@ -1,10 +1,8 @@
"""The Hunter Douglas PowerView integration.""" """The Hunter Douglas PowerView integration."""
from datetime import timedelta
import logging import logging
from aiopvapi.helpers.aiorequest import AioRequest from aiopvapi.helpers.aiorequest import AioRequest
from aiopvapi.helpers.api_base import ApiEntryPoint from aiopvapi.helpers.api_base import ApiEntryPoint
from aiopvapi.helpers.constants import ATTR_ID
from aiopvapi.helpers.tools import base64_to_unicode from aiopvapi.helpers.tools import base64_to_unicode
from aiopvapi.rooms import Rooms from aiopvapi.rooms import Rooms
from aiopvapi.scenes import Scenes from aiopvapi.scenes import Scenes
@ -14,11 +12,10 @@ import async_timeout
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform 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.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import ( from .const import (
API_PATH_FWVERSION, API_PATH_FWVERSION,
@ -50,6 +47,9 @@ from .const import (
SHADE_DATA, SHADE_DATA,
USER_DATA, USER_DATA,
) )
from .coordinator import PowerviewShadeUpdateCoordinator
from .shade_data import PowerviewShadeData
from .util import async_map_data_by_id
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 1
@ -64,7 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
config = entry.data config = entry.data
hub_address = config.get(CONF_HOST) hub_address = config[CONF_HOST]
websession = async_get_clientsession(hass) websession = async_get_clientsession(hass)
pv_request = AioRequest(hub_address, loop=hass.loop, websession=websession) 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): async with async_timeout.timeout(10):
rooms = Rooms(pv_request) 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): async with async_timeout.timeout(10):
scenes = Scenes(pv_request) scenes = Scenes(pv_request)
scene_data = _async_map_data_by_id( scene_data = async_map_data_by_id(
(await scenes.get_resources())[SCENE_DATA] (await scenes.get_resources())[SCENE_DATA]
) )
async with async_timeout.timeout(10): async with async_timeout.timeout(10):
shades = Shades(pv_request) shades = Shades(pv_request)
shade_data = _async_map_data_by_id( shade_data = async_map_data_by_id(
(await shades.get_resources())[SHADE_DATA] (await shades.get_resources())[SHADE_DATA]
) )
except HUB_EXCEPTIONS as err: except HUB_EXCEPTIONS as err:
@ -95,24 +95,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if not device_info: if not device_info:
raise ConfigEntryNotReady(f"Unable to initialize PowerView hub: {hub_address}") raise ConfigEntryNotReady(f"Unable to initialize PowerView hub: {hub_address}")
async def async_update_data(): coordinator = PowerviewShadeUpdateCoordinator(hass, shades, hub_address)
"""Fetch data from shade endpoint.""" coordinator.async_set_updated_data(PowerviewShadeData())
async with async_timeout.timeout(10): hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
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] = {
PV_API: pv_request, PV_API: pv_request,
PV_ROOM_DATA: room_data, PV_ROOM_DATA: room_data,
PV_SCENE_DATA: scene_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: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View file

@ -7,7 +7,6 @@ from aiopvapi.helpers.aiorequest import PvApiConnectionError, PvApiResponseStatu
DOMAIN = "hunterdouglas_powerview" DOMAIN = "hunterdouglas_powerview"
MANUFACTURER = "Hunter Douglas" MANUFACTURER = "Hunter Douglas"
HUB_ADDRESS = "address" HUB_ADDRESS = "address"
@ -48,7 +47,6 @@ ROOM_NAME = "name"
ROOM_NAME_UNICODE = "name_unicode" ROOM_NAME_UNICODE = "name_unicode"
ROOM_ID = "id" ROOM_ID = "id"
SHADE_RESPONSE = "shade"
SHADE_BATTERY_LEVEL = "batteryStrength" SHADE_BATTERY_LEVEL = "batteryStrength"
SHADE_BATTERY_LEVEL_MAX = 200 SHADE_BATTERY_LEVEL_MAX = 200
@ -81,5 +79,10 @@ DEFAULT_LEGACY_MAINPROCESSOR = {
FIRMWARE_NAME: LEGACY_DEVICE_MODEL, FIRMWARE_NAME: LEGACY_DEVICE_MODEL,
} }
API_PATH_FWVERSION = "api/fwversion" 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

View file

@ -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

View file

@ -1,14 +1,24 @@
"""Support for hunter douglas shades.""" """Support for hunter douglas shades."""
from abc import abstractmethod from __future__ import annotations
import asyncio import asyncio
from collections.abc import Iterable
from contextlib import suppress from contextlib import suppress
import logging 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 ( from aiopvapi.resources.shade import (
ATTR_POSKIND1, ATTR_POSKIND1,
ATTR_POSKIND2,
MAX_POSITION, MAX_POSITION,
MIN_POSITION, MIN_POSITION,
BaseShade,
ShadeTdbu,
Silhouette, Silhouette,
factory as PvShade, factory as PvShade,
) )
@ -22,7 +32,7 @@ from homeassistant.components.cover import (
CoverEntityFeature, CoverEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry 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.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later from homeassistant.helpers.event import async_call_later
@ -32,15 +42,19 @@ from .const import (
DEVICE_MODEL, DEVICE_MODEL,
DOMAIN, DOMAIN,
LEGACY_DEVICE_MODEL, LEGACY_DEVICE_MODEL,
POS_KIND_PRIMARY,
POS_KIND_SECONDARY,
POS_KIND_VANE,
PV_API, PV_API,
PV_ROOM_DATA, PV_ROOM_DATA,
PV_SHADE_DATA, PV_SHADE_DATA,
ROOM_ID_IN_SHADE, ROOM_ID_IN_SHADE,
ROOM_NAME_UNICODE, ROOM_NAME_UNICODE,
SHADE_RESPONSE,
STATE_ATTRIBUTE_ROOM_NAME, STATE_ATTRIBUTE_ROOM_NAME,
) )
from .coordinator import PowerviewShadeUpdateCoordinator
from .entity import ShadeEntity from .entity import ShadeEntity
from .shade_data import PowerviewShadeMove
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -52,11 +66,13 @@ PARALLEL_UPDATES = 1
RESYNC_DELAY = 60 RESYNC_DELAY = 60
POSKIND_NONE = 0 # this equates to 0.75/100 in terms of hass blind position
POSKIND_PRIMARY = 1 # some blinds in a closed position report less than 655.35 (1%)
POSKIND_SECONDARY = 2 # but larger than 0 even though they are clearly closed
POSKIND_VANE = 3 # Find 1 percent of MAX_POSITION, then find 75% of that number
POSKIND_ERROR = 4 # 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( async def async_setup_entry(
@ -65,18 +81,17 @@ async def async_setup_entry(
"""Set up the hunter douglas shades.""" """Set up the hunter douglas shades."""
pv_data = hass.data[DOMAIN][entry.entry_id] 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] shade_data = pv_data[PV_SHADE_DATA]
pv_request = pv_data[PV_API] pv_request = pv_data[PV_API]
coordinator = pv_data[COORDINATOR] coordinator: PowerviewShadeUpdateCoordinator = pv_data[COORDINATOR]
device_info = pv_data[DEVICE_INFO] device_info: dict[str, Any] = pv_data[DEVICE_INFO]
entities = [] entities: list[ShadeEntity] = []
for raw_shade in shade_data.values(): for raw_shade in shade_data.values():
# The shade may be out of sync with the hub # The shade may be out of sync with the hub
# so we force a refresh when we add it if # so we force a refresh when we add it if possible
# possible shade: BaseShade = PvShade(raw_shade, pv_request)
shade = PvShade(raw_shade, pv_request)
name_before_refresh = shade.name name_before_refresh = shade.name
with suppress(asyncio.TimeoutError): with suppress(asyncio.TimeoutError):
async with async_timeout.timeout(1): async with async_timeout.timeout(1):
@ -88,9 +103,10 @@ async def async_setup_entry(
name_before_refresh, name_before_refresh,
) )
continue continue
coordinator.data.update_shade_positions(shade.raw_data)
room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) room_id = shade.raw_data.get(ROOM_ID_IN_SHADE)
room_name = room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") room_name = room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "")
entities.append( entities.extend(
create_powerview_shade_entity( create_powerview_shade_entity(
coordinator, device_info, room_name, shade, name_before_refresh coordinator, device_info, room_name, shade, name_before_refresh
) )
@ -99,26 +115,36 @@ async def async_setup_entry(
def create_powerview_shade_entity( 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.""" """Create a PowerViewShade entity."""
classes: list[BaseShade] = []
if isinstance(shade, Silhouette): # order here is important as both ShadeTDBU are listed in aiovapi as can_tilt
return PowerViewShadeSilhouette( # and both require their own class here to work
coordinator, device_info, room_name, shade, name_before_refresh if isinstance(shade, ShadeTdbu):
) classes.extend([PowerViewShadeTDBUTop, PowerViewShadeTDBUBottom])
elif isinstance(shade, Silhouette):
return PowerViewShade( classes.append(PowerViewShadeSilhouette)
coordinator, device_info, room_name, shade, name_before_refresh 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.""" """Convert hunter douglas position to hass position."""
return round((hd_position / max_val) * 100) 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.""" """Convert hass position to hunter douglas position."""
return int(hass_position / 100 * max_val) return int(hass_position / 100 * max_val)
@ -128,130 +154,126 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity):
# The hub frequently reports stale states # The hub frequently reports stale states
_attr_assumed_state = True _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.""" """Initialize the shade."""
super().__init__(coordinator, device_info, room_name, shade, name) super().__init__(coordinator, device_info, room_name, shade, name)
self._shade = shade self._shade: BaseShade = shade
self._is_opening = False self._attr_name = self._shade_name
self._is_closing = False self._scheduled_transition_update: CALLBACK_TYPE | None = None
self._last_action_timestamp = 0
self._scheduled_transition_update = None
self._current_hd_cover_position = MIN_POSITION
if self._device_info[DEVICE_MODEL] != LEGACY_DEVICE_MODEL: if self._device_info[DEVICE_MODEL] != LEGACY_DEVICE_MODEL:
self._attr_supported_features |= CoverEntityFeature.STOP self._attr_supported_features |= CoverEntityFeature.STOP
self._forced_resync = None self._forced_resync = None
@property @property
def extra_state_attributes(self): def extra_state_attributes(self) -> dict[str, str]:
"""Return the state attributes.""" """Return the state attributes."""
return {STATE_ATTRIBUTE_ROOM_NAME: self._room_name} return {STATE_ATTRIBUTE_ROOM_NAME: self._room_name}
@property @property
def is_closed(self): def is_closed(self):
"""Return if the cover is closed.""" """Return if the cover is closed."""
return self._current_hd_cover_position == MIN_POSITION return self.positions.primary <= CLOSED_POSITION
@property @property
def is_opening(self): def current_cover_position(self) -> int:
"""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):
"""Return the current position of cover.""" """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 @property
def device_class(self): def transition_steps(self) -> int:
"""Return device class.""" """Return the steps to make a move."""
return CoverDeviceClass.SHADE return hd_position_to_hass(self.positions.primary, MAX_POSITION)
@property @property
def name(self): def open_position(self) -> PowerviewShadeMove:
"""Return the name of the shade.""" """Return the open position and required additional positions."""
return self._shade_name 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.""" """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.""" """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.""" """Stop the cover."""
# Cancel any previous updates
self._async_cancel_scheduled_transition_update() 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() await self._async_force_refresh_state()
async def async_set_cover_position(self, **kwargs): @callback
"""Move the shade to a specific position.""" def _clamp_cover_limit(self, target_hass_position: int) -> int:
if ATTR_POSITION not in kwargs: """Dont allow a cover to go into an impossbile position."""
return # no override required in base
await self._async_move(kwargs[ATTR_POSITION]) 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.""" """Move the shade to a position."""
current_hass_position = hd_position_to_hass( target_hass_position = self._clamp_cover_limit(target_hass_position)
self._current_hd_cover_position, MAX_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) await self._async_execute_move(self._get_shade_move(target_hass_position))
self._async_schedule_update_for_transition(steps_to_move) self._attr_is_opening = target_hass_position > current_hass_position
self._async_update_from_command( self._attr_is_closing = target_hass_position < current_hass_position
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
self.async_write_ha_state() self.async_write_ha_state()
@callback @callback
def _async_update_from_command(self, raw_data): def _async_update_shade_data(self, shade_data: dict[str | int, Any]) -> None:
"""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):
"""Update the current cover position from the data.""" """Update the current cover position from the data."""
_LOGGER.debug("Raw data update: %s", self._shade.raw_data) self.data.update_shade_positions(shade_data)
position_data = self._shade.raw_data.get(ATTR_POSITION_DATA, {}) self._attr_is_opening = False
self._async_process_updated_position_data(position_data) self._attr_is_closing = False
self._is_opening = False
self._is_closing = False
@callback @callback
@abstractmethod def _async_cancel_scheduled_transition_update(self) -> None:
def _async_process_updated_position_data(self, position_data):
"""Process position data."""
@callback
def _async_cancel_scheduled_transition_update(self):
"""Cancel any previous updates.""" """Cancel any previous updates."""
if self._scheduled_transition_update: if self._scheduled_transition_update:
self._scheduled_transition_update() self._scheduled_transition_update()
@ -261,9 +283,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity):
self._forced_resync = None self._forced_resync = None
@callback @callback
def _async_schedule_update_for_transition(self, steps): def _async_schedule_update_for_transition(self, steps: int) -> None:
self.async_write_ha_state()
# Cancel any previous updates # Cancel any previous updates
self._async_cancel_scheduled_transition_update() self._async_cancel_scheduled_transition_update()
@ -278,7 +298,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity):
est_time_to_complete_transition, 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. # to be completed.
self._scheduled_transition_update = async_call_later( self._scheduled_transition_update = async_call_later(
self.hass, self.hass,
@ -295,139 +315,281 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity):
self.hass, RESYNC_DELAY, self._async_force_resync 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.""" """Force a resync after an update since the hub may have stale state."""
self._forced_resync = None self._forced_resync = None
_LOGGER.debug("Force resync of shade %s", self.name)
await self._async_force_refresh_state() 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.""" """Refresh the cover state and force the device cache to be bypassed."""
await self._shade.refresh() await self._shade.refresh()
self._async_update_current_cover_position() self._async_update_shade_data(self._shade.raw_data)
self.async_write_ha_state() 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.""" """When entity is added to hass."""
self._async_update_current_cover_position()
self.async_on_remove( self.async_on_remove(
self.coordinator.async_add_listener(self._async_update_shade_from_group) 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.""" """Cancel any pending refreshes."""
self._async_cancel_scheduled_transition_update() self._async_cancel_scheduled_transition_update()
@callback @callback
def _async_update_shade_from_group(self): def _async_update_shade_from_group(self) -> None:
"""Update with new data from the coordinator.""" """Update with new data from the coordinator."""
if self._scheduled_transition_update or self._forced_resync: if self._scheduled_transition_update or self._forced_resync:
# If a transition in in progress # If a transition is in progress the data will be wrong
# the data will be wrong
return 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() self.async_write_ha_state()
class PowerViewShade(PowerViewShadeBase): class PowerViewShade(PowerViewShadeBase):
"""Represent a standard shade.""" """Represent a standard shade."""
_attr_supported_features = ( def __init__(
CoverEntityFeature.OPEN self,
| CoverEntityFeature.CLOSE coordinator: PowerviewShadeUpdateCoordinator,
| CoverEntityFeature.SET_POSITION 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 @callback
def _async_process_updated_position_data(self, position_data): def _clamp_cover_limit(self, target_hass_position: int) -> int:
"""Process position data.""" """Dont allow a cover to go into an impossbile position."""
if ATTR_POSITION1 in position_data: cover_top = hd_position_to_hass(self.positions.secondary, MAX_POSITION)
self._current_hd_cover_position = int(position_data[ATTR_POSITION1]) 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): class PowerViewShadeWithTilt(PowerViewShade):
"""Representation of a PowerView shade with tilt capabilities.""" """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 _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.""" """Initialize the shade."""
super().__init__(coordinator, device_info, room_name, shade, name) super().__init__(coordinator, device_info, room_name, shade, name)
self._attr_current_cover_tilt_position = 0 self._attr_supported_features |= (
CoverEntityFeature.OPEN_TILT
async def async_open_cover_tilt(self, **kwargs): | CoverEntityFeature.CLOSE_TILT
"""Open the cover tilt.""" | CoverEntityFeature.SET_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 if self._device_info[DEVICE_MODEL] != LEGACY_DEVICE_MODEL:
self._async_schedule_update_for_transition(steps_to_move) self._attr_supported_features |= CoverEntityFeature.STOP_TILT
self._async_update_from_command(await self._shade.tilt_open())
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.""" """Close the cover tilt."""
current_hass_position = hd_position_to_hass( self._async_schedule_update_for_transition(self.transition_steps)
self._current_hd_cover_position, MAX_POSITION await self._async_execute_move(self.close_tilt_position)
) self.async_write_ha_state()
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())
async def async_set_cover_tilt_position(self, **kwargs): async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Move the cover tilt to a specific position.""" """Open the cover tilt."""
target_hass_tilt_position = kwargs[ATTR_TILT_POSITION] self._async_schedule_update_for_transition(100 - self.transition_steps)
current_hass_position = hd_position_to_hass( await self._async_execute_move(self.open_tilt_position)
self._current_hd_cover_position, MAX_POSITION self.async_write_ha_state()
)
steps_to_move = current_hass_position + self._tilt_steps
self._async_schedule_update_for_transition(steps_to_move) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
self._async_update_from_command( """Move the vane to a specific position."""
await self._shade.move( await self._async_set_cover_tilt_position(kwargs[ATTR_TILT_POSITION])
{
ATTR_POSITION1: hass_position_to_hd(
target_hass_tilt_position, self._max_tilt
),
ATTR_POSKIND1: POSKIND_VANE,
}
)
)
async def async_stop_cover_tilt(self, **kwargs): async def _async_set_cover_tilt_position(
"""Stop the cover tilting.""" self, target_hass_tilt_position: int
# Cancel any previous updates ) -> None:
await self.async_stop_cover() """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 @callback
def _async_process_updated_position_data(self, position_data): def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove:
"""Process position data.""" """Return a PowerviewShadeMove."""
if ATTR_POSKIND1 not in position_data: position_shade = hass_position_to_hd(target_hass_position)
return return PowerviewShadeMove(
if int(position_data[ATTR_POSKIND1]) == POSKIND_PRIMARY: {ATTR_POSITION1: position_shade, ATTR_POSKIND1: POS_KIND_PRIMARY},
self._current_hd_cover_position = int(position_data[ATTR_POSITION1]) {POS_KIND_VANE: MIN_POSITION},
self._attr_current_cover_tilt_position = 0 )
if int(position_data[ATTR_POSKIND1]) == POSKIND_VANE:
self._current_hd_cover_position = MIN_POSITION @callback
self._attr_current_cover_tilt_position = hd_position_to_hass( def _get_shade_tilt(self, target_hass_tilt_position: int) -> PowerviewShadeMove:
int(position_data[ATTR_POSITION1]), self._max_tilt """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): class PowerViewShadeSilhouette(PowerViewShadeWithTilt):
"""Representation of a Silhouette PowerView shade.""" """Representation of a Silhouette PowerView shade."""
def __init__(self, coordinator, device_info, room_name, shade, name): _max_tilt = 32767
"""Initialize the shade."""
super().__init__(coordinator, device_info, room_name, shade, name)
self._max_tilt = 32767
self._tilt_steps = 4

View file

@ -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 from homeassistant.const import ATTR_MODEL, ATTR_SW_VERSION
import homeassistant.helpers.device_registry as dr import homeassistant.helpers.device_registry as dr
@ -20,22 +22,30 @@ from .const import (
FIRMWARE_SUB_REVISION, FIRMWARE_SUB_REVISION,
MANUFACTURER, MANUFACTURER,
) )
from .coordinator import PowerviewShadeUpdateCoordinator
from .shade_data import PowerviewShadeData, PowerviewShadePositions
class HDEntity(CoordinatorEntity): class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]):
"""Base class for hunter douglas entities.""" """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.""" """Initialize the entity."""
super().__init__(coordinator) super().__init__(coordinator)
self._room_name = room_name self._room_name = room_name
self._unique_id = unique_id self._attr_unique_id = unique_id
self._device_info = device_info self._device_info = device_info
@property @property
def unique_id(self): def data(self) -> PowerviewShadeData:
"""Return the unique id.""" """Return the PowerviewShadeData."""
return self._unique_id return self.coordinator.data
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
@ -58,12 +68,24 @@ class HDEntity(CoordinatorEntity):
class ShadeEntity(HDEntity): class ShadeEntity(HDEntity):
"""Base class for hunter douglas shade entities.""" """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.""" """Initialize the shade."""
super().__init__(coordinator, device_info, room_name, shade.id) super().__init__(coordinator, device_info, room_name, shade.id)
self._shade_name = shade_name self._shade_name = shade_name
self._shade = shade self._shade = shade
@property
def positions(self) -> PowerviewShadePositions:
"""Return the PowerviewShadeData."""
return self.data.get_shade_positions(self._shade.id)
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Return the device_info of the device.""" """Return the device_info of the device."""
@ -78,7 +100,7 @@ class ShadeEntity(HDEntity):
) )
for shade in self._shade.shade_types: 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 device_info[ATTR_MODEL] = shade.description
break break

View file

@ -3,7 +3,7 @@
"name": "Hunter Douglas PowerView", "name": "Hunter Douglas PowerView",
"documentation": "https://www.home-assistant.io/integrations/hunterdouglas_powerview", "documentation": "https://www.home-assistant.io/integrations/hunterdouglas_powerview",
"requirements": ["aiopvapi==1.6.19"], "requirements": ["aiopvapi==1.6.19"],
"codeowners": ["@bdraco", "@trullock"], "codeowners": ["@bdraco", "@kingy444", "@trullock"],
"config_flow": true, "config_flow": true,
"homekit": { "homekit": {
"models": ["PowerView"] "models": ["PowerView"]

View file

@ -63,16 +63,16 @@ class PowerViewShadeBatterySensor(ShadeEntity, SensorEntity):
_attr_device_class = SensorDeviceClass.BATTERY _attr_device_class = SensorDeviceClass.BATTERY
_attr_state_class = SensorStateClass.MEASUREMENT _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 @property
def name(self): def name(self):
"""Name of the shade battery.""" """Name of the shade battery."""
return f"{self._shade_name} Battery" return f"{self._shade_name} Battery"
@property
def unique_id(self):
"""Shade battery Uniqueid."""
return f"{self._unique_id}_charge"
@property @property
def native_value(self): def native_value(self):
"""Get the current value in percentage.""" """Get the current value in percentage."""
@ -89,5 +89,5 @@ class PowerViewShadeBatterySensor(ShadeEntity, SensorEntity):
@callback @callback
def _async_update_shade_from_group(self): def _async_update_shade_from_group(self):
"""Update with new data from the coordinator.""" """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() self.async_write_ha_state()

View file

@ -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)

View file

@ -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}