core/homeassistant/components/hunterdouglas_powerview/cover.py

434 lines
14 KiB
Python

"""Support for hunter douglas shades."""
from abc import abstractmethod
import asyncio
from contextlib import suppress
import logging
from aiopvapi.helpers.constants import ATTR_POSITION1, ATTR_POSITION_DATA
from aiopvapi.resources.shade import (
ATTR_POSKIND1,
MAX_POSITION,
MIN_POSITION,
Silhouette,
factory as PvShade,
)
import async_timeout
from homeassistant.components.cover import (
ATTR_POSITION,
ATTR_TILT_POSITION,
CoverDeviceClass,
CoverEntity,
CoverEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later
from .const import (
COORDINATOR,
DEVICE_INFO,
DEVICE_MODEL,
DOMAIN,
LEGACY_DEVICE_MODEL,
PV_API,
PV_ROOM_DATA,
PV_SHADE_DATA,
ROOM_ID_IN_SHADE,
ROOM_NAME_UNICODE,
SHADE_RESPONSE,
STATE_ATTRIBUTE_ROOM_NAME,
)
from .entity import ShadeEntity
_LOGGER = logging.getLogger(__name__)
# Estimated time it takes to complete a transition
# from one state to another
TRANSITION_COMPLETE_DURATION = 40
PARALLEL_UPDATES = 1
RESYNC_DELAY = 60
POSKIND_NONE = 0
POSKIND_PRIMARY = 1
POSKIND_SECONDARY = 2
POSKIND_VANE = 3
POSKIND_ERROR = 4
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the hunter douglas shades."""
pv_data = hass.data[DOMAIN][entry.entry_id]
room_data = pv_data[PV_ROOM_DATA]
shade_data = pv_data[PV_SHADE_DATA]
pv_request = pv_data[PV_API]
coordinator = pv_data[COORDINATOR]
device_info = pv_data[DEVICE_INFO]
entities = []
for raw_shade in shade_data.values():
# The shade may be out of sync with the hub
# so we force a refresh when we add it if
# possible
shade = PvShade(raw_shade, pv_request)
name_before_refresh = shade.name
with suppress(asyncio.TimeoutError):
async with async_timeout.timeout(1):
await shade.refresh()
if ATTR_POSITION_DATA not in shade.raw_data:
_LOGGER.info(
"The %s shade was skipped because it is missing position data",
name_before_refresh,
)
continue
room_id = shade.raw_data.get(ROOM_ID_IN_SHADE)
room_name = room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "")
entities.append(
create_powerview_shade_entity(
coordinator, device_info, room_name, shade, name_before_refresh
)
)
async_add_entities(entities)
def create_powerview_shade_entity(
coordinator, device_info, room_name, shade, name_before_refresh
):
"""Create a PowerViewShade entity."""
if isinstance(shade, Silhouette):
return PowerViewShadeSilhouette(
coordinator, device_info, room_name, shade, name_before_refresh
)
return PowerViewShade(
coordinator, device_info, room_name, shade, name_before_refresh
)
def hd_position_to_hass(hd_position, max_val):
"""Convert hunter douglas position to hass position."""
return round((hd_position / max_val) * 100)
def hass_position_to_hd(hass_position, max_val):
"""Convert hass position to hunter douglas position."""
return int(hass_position / 100 * max_val)
class PowerViewShadeBase(ShadeEntity, CoverEntity):
"""Representation of a powerview shade."""
# The hub frequently reports stale states
_attr_assumed_state = True
def __init__(self, coordinator, device_info, room_name, shade, name):
"""Initialize the shade."""
super().__init__(coordinator, device_info, room_name, shade, name)
self._shade = shade
self._is_opening = False
self._is_closing = False
self._last_action_timestamp = 0
self._scheduled_transition_update = None
self._current_hd_cover_position = MIN_POSITION
if self._device_info[DEVICE_MODEL] != LEGACY_DEVICE_MODEL:
self._attr_supported_features |= CoverEntityFeature.STOP
self._forced_resync = None
@property
def extra_state_attributes(self):
"""Return the state attributes."""
return {STATE_ATTRIBUTE_ROOM_NAME: self._room_name}
@property
def is_closed(self):
"""Return if the cover is closed."""
return self._current_hd_cover_position == MIN_POSITION
@property
def is_opening(self):
"""Return if the cover is opening."""
return self._is_opening
@property
def is_closing(self):
"""Return if the cover is closing."""
return self._is_closing
@property
def current_cover_position(self):
"""Return the current position of cover."""
return hd_position_to_hass(self._current_hd_cover_position, MAX_POSITION)
@property
def device_class(self):
"""Return device class."""
return CoverDeviceClass.SHADE
@property
def name(self):
"""Return the name of the shade."""
return self._shade_name
async def async_close_cover(self, **kwargs):
"""Close the cover."""
await self._async_move(0)
async def async_open_cover(self, **kwargs):
"""Open the cover."""
await self._async_move(100)
async def async_stop_cover(self, **kwargs):
"""Stop the cover."""
# Cancel any previous updates
self._async_cancel_scheduled_transition_update()
self._async_update_from_command(await self._shade.stop())
await self._async_force_refresh_state()
async def async_set_cover_position(self, **kwargs):
"""Move the shade to a specific position."""
if ATTR_POSITION not in kwargs:
return
await self._async_move(kwargs[ATTR_POSITION])
async def _async_move(self, target_hass_position):
"""Move the shade to a position."""
current_hass_position = hd_position_to_hass(
self._current_hd_cover_position, MAX_POSITION
)
steps_to_move = abs(current_hass_position - target_hass_position)
self._async_schedule_update_for_transition(steps_to_move)
self._async_update_from_command(
await self._shade.move(
{
ATTR_POSITION1: hass_position_to_hd(
target_hass_position, MAX_POSITION
),
ATTR_POSKIND1: POSKIND_PRIMARY,
}
)
)
self._is_opening = False
self._is_closing = False
if target_hass_position > current_hass_position:
self._is_opening = True
elif target_hass_position < current_hass_position:
self._is_closing = True
self.async_write_ha_state()
@callback
def _async_update_from_command(self, raw_data):
"""Update the shade state after a command."""
if not raw_data or SHADE_RESPONSE not in raw_data:
return
self._async_process_new_shade_data(raw_data[SHADE_RESPONSE])
@callback
def _async_process_new_shade_data(self, data):
"""Process new data from an update."""
self._shade.raw_data = data
self._async_update_current_cover_position()
@callback
def _async_update_current_cover_position(self):
"""Update the current cover position from the data."""
_LOGGER.debug("Raw data update: %s", self._shade.raw_data)
position_data = self._shade.raw_data.get(ATTR_POSITION_DATA, {})
self._async_process_updated_position_data(position_data)
self._is_opening = False
self._is_closing = False
@callback
@abstractmethod
def _async_process_updated_position_data(self, position_data):
"""Process position data."""
@callback
def _async_cancel_scheduled_transition_update(self):
"""Cancel any previous updates."""
if self._scheduled_transition_update:
self._scheduled_transition_update()
self._scheduled_transition_update = None
if self._forced_resync:
self._forced_resync()
self._forced_resync = None
@callback
def _async_schedule_update_for_transition(self, steps):
self.async_write_ha_state()
# Cancel any previous updates
self._async_cancel_scheduled_transition_update()
est_time_to_complete_transition = 1 + int(
TRANSITION_COMPLETE_DURATION * (steps / 100)
)
_LOGGER.debug(
"Estimated time to complete transition of %s steps for %s: %s",
steps,
self.name,
est_time_to_complete_transition,
)
# Schedule an update for when we expect the transition
# to be completed.
self._scheduled_transition_update = async_call_later(
self.hass,
est_time_to_complete_transition,
self._async_complete_schedule_update,
)
async def _async_complete_schedule_update(self, _):
"""Update status of the cover."""
_LOGGER.debug("Processing scheduled update for %s", self.name)
self._scheduled_transition_update = None
await self._async_force_refresh_state()
self._forced_resync = async_call_later(
self.hass, RESYNC_DELAY, self._async_force_resync
)
async def _async_force_resync(self, *_):
"""Force a resync after an update since the hub may have stale state."""
self._forced_resync = None
await self._async_force_refresh_state()
async def _async_force_refresh_state(self):
"""Refresh the cover state and force the device cache to be bypassed."""
await self._shade.refresh()
self._async_update_current_cover_position()
self.async_write_ha_state()
async def async_added_to_hass(self):
"""When entity is added to hass."""
self._async_update_current_cover_position()
self.async_on_remove(
self.coordinator.async_add_listener(self._async_update_shade_from_group)
)
async def async_will_remove_from_hass(self):
"""Cancel any pending refreshes."""
self._async_cancel_scheduled_transition_update()
@callback
def _async_update_shade_from_group(self):
"""Update with new data from the coordinator."""
if self._scheduled_transition_update or self._forced_resync:
# If a transition in in progress
# the data will be wrong
return
self._async_process_new_shade_data(self.coordinator.data[self._shade.id])
self.async_write_ha_state()
class PowerViewShade(PowerViewShadeBase):
"""Represent a standard shade."""
_attr_supported_features = (
CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE
| CoverEntityFeature.SET_POSITION
)
@callback
def _async_process_updated_position_data(self, position_data):
"""Process position data."""
if ATTR_POSITION1 in position_data:
self._current_hd_cover_position = int(position_data[ATTR_POSITION1])
class PowerViewShadeWithTilt(PowerViewShade):
"""Representation of a PowerView shade with tilt capabilities."""
_attr_supported_features = (
CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE
| CoverEntityFeature.SET_POSITION
| CoverEntityFeature.OPEN_TILT
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.STOP_TILT
| CoverEntityFeature.SET_TILT_POSITION
)
_max_tilt = MAX_POSITION
_tilt_steps = 10
def __init__(self, coordinator, device_info, room_name, shade, name):
"""Initialize the shade."""
super().__init__(coordinator, device_info, room_name, shade, name)
self._attr_current_cover_tilt_position = 0
async def async_open_cover_tilt(self, **kwargs):
"""Open the cover tilt."""
current_hass_position = hd_position_to_hass(
self._current_hd_cover_position, MAX_POSITION
)
steps_to_move = current_hass_position + self._tilt_steps
self._async_schedule_update_for_transition(steps_to_move)
self._async_update_from_command(await self._shade.tilt_open())
async def async_close_cover_tilt(self, **kwargs):
"""Close the cover tilt."""
current_hass_position = hd_position_to_hass(
self._current_hd_cover_position, MAX_POSITION
)
steps_to_move = current_hass_position + self._tilt_steps
self._async_schedule_update_for_transition(steps_to_move)
self._async_update_from_command(await self._shade.tilt_close())
async def async_set_cover_tilt_position(self, **kwargs):
"""Move the cover tilt to a specific position."""
target_hass_tilt_position = kwargs[ATTR_TILT_POSITION]
current_hass_position = hd_position_to_hass(
self._current_hd_cover_position, MAX_POSITION
)
steps_to_move = current_hass_position + self._tilt_steps
self._async_schedule_update_for_transition(steps_to_move)
self._async_update_from_command(
await self._shade.move(
{
ATTR_POSITION1: hass_position_to_hd(
target_hass_tilt_position, self._max_tilt
),
ATTR_POSKIND1: POSKIND_VANE,
}
)
)
async def async_stop_cover_tilt(self, **kwargs):
"""Stop the cover tilting."""
# Cancel any previous updates
await self.async_stop_cover()
@callback
def _async_process_updated_position_data(self, position_data):
"""Process position data."""
if ATTR_POSKIND1 not in position_data:
return
if int(position_data[ATTR_POSKIND1]) == POSKIND_PRIMARY:
self._current_hd_cover_position = int(position_data[ATTR_POSITION1])
self._attr_current_cover_tilt_position = 0
if int(position_data[ATTR_POSKIND1]) == POSKIND_VANE:
self._current_hd_cover_position = MIN_POSITION
self._attr_current_cover_tilt_position = hd_position_to_hass(
int(position_data[ATTR_POSITION1]), self._max_tilt
)
class PowerViewShadeSilhouette(PowerViewShadeWithTilt):
"""Representation of a Silhouette PowerView shade."""
def __init__(self, coordinator, device_info, room_name, shade, name):
"""Initialize the shade."""
super().__init__(coordinator, device_info, room_name, shade, name)
self._max_tilt = 32767
self._tilt_steps = 4