Add support for topdown shades to hunterdouglas_powerview (#62788)
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
parent
3399be2dad
commit
45e4dd379b
11 changed files with 602 additions and 261 deletions
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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__(
|
||||||
|
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.OPEN
|
||||||
| CoverEntityFeature.CLOSE
|
| CoverEntityFeature.CLOSE
|
||||||
| CoverEntityFeature.SET_POSITION
|
| 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
|
|
||||||
self._attr_current_cover_tilt_position = hd_position_to_hass(
|
|
||||||
int(position_data[ATTR_POSITION1]), self._max_tilt
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@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):
|
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
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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"]
|
||||||
|
|
|
@ -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()
|
||||||
|
|
113
homeassistant/components/hunterdouglas_powerview/shade_data.py
Normal file
113
homeassistant/components/hunterdouglas_powerview/shade_data.py
Normal 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)
|
15
homeassistant/components/hunterdouglas_powerview/util.py
Normal file
15
homeassistant/components/hunterdouglas_powerview/util.py
Normal 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}
|
Loading…
Add table
Add a link
Reference in a new issue