Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP #1249

Draft
wants to merge 11 commits into
base: dev
Choose a base branch
from
351 changes: 331 additions & 20 deletions homeassistant/components/overkiz/cover.py
@@ -1,40 +1,351 @@
"""Support for Overkiz covers - shutters etc."""
from pyoverkiz.enums import OverkizCommand, UIClass
from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, cast

from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState, UIClass
from pyoverkiz.models import Device

from homeassistant.components.cover import (
ATTR_POSITION,
ATTR_TILT_POSITION,
CoverDeviceClass,
CoverEntity,
CoverEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from . import HomeAssistantOverkizData
from .const import DOMAIN
from .cover_entities.awning import Awning
from .cover_entities.generic_cover import OverkizGenericCover
from .cover_entities.vertical_cover import LowSpeedCover, VerticalCover
from .entity import OverkizDescriptiveEntity


def is_closed(device: Device) -> bool | None:
"""Return if the cover is closed."""

if state := device.states[OverkizState.CORE_OPEN_CLOSED]:
return state.value == OverkizCommandParam.CLOSED

return False


@dataclass
class OverkizCoverDescription(CoverEntityDescription):
"""Class to describe an Overkiz cover."""

open_command: OverkizCommand | None = None
close_command: OverkizCommand | None = None
stop_command: OverkizCommand | None = None
current_position_state: OverkizState | None = None
invert_position: bool = True
set_position_command: OverkizCommand | None = None
is_closed_fn: Callable[[Device], bool | None] | None = None
current_tilt_position: OverkizState | None = None
set_tilt_position_command: OverkizCommand | None = None
open_tilt_command: OverkizCommand | None = None
close_tilt_command: OverkizCommand | None = None
stop_tilt_command: OverkizCommand | None = None


COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
OverkizCoverDescription(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to set supported_features as well, otherwise this won't work like it did before.

key=UIClass.AWNING,
current_position_state=OverkizState.CORE_DEPLOYMENT,
set_position_command=OverkizCommand.SET_DEPLOYMENT,
open_command=OverkizCommand.DEPLOY,
close_command=OverkizCommand.UNDEPLOY,
invert_position=False,
is_closed_fn=is_closed,
stop_command=OverkizCommand.STOP,
device_class=CoverDeviceClass.AWNING,
),
OverkizCoverDescription(
key=UIClass.ROLLER_SHUTTER,
current_position_state=OverkizState.CORE_CLOSURE,
set_position_command=OverkizCommand.SET_CLOSURE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
is_closed_fn=is_closed,
stop_command=OverkizCommand.STOP,
device_class=CoverDeviceClass.SHUTTER,
),
OverkizCoverDescription( # To check, it looks like this device can only tilt. Ask help from an owner
key=UIClass.ADJUSTABLE_SLATS_ROLLER_SHUTTER,
current_position_state=OverkizState.CORE_CLOSURE,
set_position_command=OverkizCommand.SET_CLOSURE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
stop_command=OverkizCommand.STOP,
current_tilt_position=OverkizState.CORE_SLATE_ORIENTATION,
set_tilt_position_command=OverkizCommand.SET_ORIENTATION,
stop_tilt_command=OverkizCommand.STOP,
device_class=CoverDeviceClass.BLIND,
),
OverkizCoverDescription(
key=UIClass.CURTAIN,
set_position_command=OverkizCommand.SET_CLOSURE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
stop_command=OverkizCommand.STOP,
device_class=CoverDeviceClass.CURTAIN,
),
OverkizCoverDescription(
key=UIClass.EXTERIOR_SCREEN,
current_position_state=OverkizState.CORE_CLOSURE,
set_position_command=OverkizCommand.SET_CLOSURE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
is_closed_fn=is_closed,
stop_command=OverkizCommand.STOP,
device_class=CoverDeviceClass.BLIND,
),
OverkizCoverDescription(
key=UIClass.EXTERIOR_VENETIAN_BLIND,
current_position_state=OverkizState.CORE_CLOSURE,
set_position_command=OverkizCommand.SET_CLOSURE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
is_closed_fn=is_closed,
stop_command=OverkizCommand.STOP,
current_tilt_position=OverkizState.CORE_SLATE_ORIENTATION,
set_tilt_position_command=OverkizCommand.SET_ORIENTATION,
stop_tilt_command=OverkizCommand.STOP,
device_class=CoverDeviceClass.BLIND,
),
OverkizCoverDescription(
key=UIClass.PERGOLA,
current_position_state=OverkizState.CORE_DEPLOYMENT,
set_position_command=OverkizCommand.SET_DEPLOYMENT,
open_command=OverkizCommand.DEPLOY,
close_command=OverkizCommand.UNDEPLOY,
invert_position=False,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should not be necessary?

is_closed_fn=is_closed,
current_tilt_position=OverkizState.CORE_SLATE_ORIENTATION,
set_tilt_position_command=OverkizCommand.SET_ORIENTATION,
open_tilt_command=OverkizCommand.OPEN_SLATS,
close_tilt_command=OverkizCommand.CLOSE_SLATS,
stop_tilt_command=OverkizCommand.STOP,
device_class=CoverDeviceClass.AWNING,
),
OverkizCoverDescription(
key=UIClass.GARAGE_DOOR,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
is_closed_fn=is_closed,
stop_command=OverkizCommand.STOP,
device_class=CoverDeviceClass.GARAGE,
),
OverkizCoverDescription(
key=UIClass.SCREEN,
current_position_state=OverkizState.CORE_CLOSURE,
set_position_command=OverkizCommand.SET_CLOSURE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
is_closed_fn=is_closed,
stop_command=OverkizCommand.STOP,
device_class=CoverDeviceClass.BLIND,
),
OverkizCoverDescription(
key=UIClass.SHUTTER,
current_position_state=OverkizState.CORE_CLOSURE,
set_position_command=OverkizCommand.SET_CLOSURE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
is_closed_fn=is_closed,
stop_command=OverkizCommand.STOP,
device_class=CoverDeviceClass.SHUTTER,
),
OverkizCoverDescription(
key=UIClass.SWINGING_SHUTTER,
current_position_state=OverkizState.CORE_CLOSURE,
set_position_command=OverkizCommand.SET_CLOSURE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
is_closed_fn=is_closed,
stop_command=OverkizCommand.STOP,
device_class=CoverDeviceClass.SHUTTER,
),
]

"""
TODO
GarageDoor Cyclic: Ask to https://github.com/iMicknl/ha-tahoma/issues/146
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would just add it like this:

CyclicGarageDoor

    OverkizCoverDescription(
        key=UIWidget.CYCLIC_GARAGE_DOOR,
        open_command=OverkizCommand.CYCLE,
        close_command=OverkizCommand.CYCLE,
        stop_command=OverkizCommand.STOP,
        device_class=CoverDeviceClass.GARAGE,
    ),

"""

SUPPORTED_DEVICES = {description.key: description for description in COVER_DESCRIPTIONS}


async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the Overkiz covers from a config entry."""
data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id]
entities: list[OverkizCover] = []

entities: list[OverkizGenericCover] = [
Awning(device.device_url, data.coordinator)
for device in data.platforms[Platform.COVER]
if device.ui_class == UIClass.AWNING
]
for device in data.platforms[Platform.COVER]:
if description := SUPPORTED_DEVICES.get(device.widget) or SUPPORTED_DEVICES.get(
device.ui_class
):
entities.append(
OverkizCover(
device.device_url,
data.coordinator,
description,
)
)

entities += [
VerticalCover(device.device_url, data.coordinator)
for device in data.platforms[Platform.COVER]
if device.ui_class != UIClass.AWNING
]
async_add_entities(entities)

entities += [
LowSpeedCover(device.device_url, data.coordinator)
for device in data.platforms[Platform.COVER]
if OverkizCommand.SET_CLOSURE_AND_LINEAR_SPEED in device.definition.commands
]

async_add_entities(entities)
class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
"""Representation of an Overkiz Cover."""

entity_description: OverkizCoverDescription

@property
def is_closed(self) -> bool | None:
"""Return if the cover is closed."""

if is_closed_fn := self.entity_description.is_closed_fn:
return is_closed_fn(self.device)

# Fallback to self.current_cover_position == 0 ?

return None

@property
def current_cover_position(self) -> int | None:
"""
Return current position of cover.

None is unknown, 0 is closed, 100 is fully open.
"""
state_name = self.entity_description.current_position_state

if not state_name:
return None

if state := self.device.states[state_name]:
position = cast(int, state.value)

if self.entity_description.invert_position:
position = 100 - position

return position

async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
position = kwargs[ATTR_POSITION]
if self.entity_description.invert_position:
position = 100 - position

if command := self.entity_description.set_position_command:
await self.executor.async_execute_command(command, position)

async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
if command := self.entity_description.open_command:
await self.executor.async_execute_command(command)

async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
if command := self.entity_description.close_command:
await self.executor.async_execute_command(command)

async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
if command := self.entity_description.stop_command:
await self.executor.async_execute_command(command)

@property
def current_cover_tilt_position(self) -> int | None:
"""Return current position of cover tilt.

None is unknown, 0 is closed, 100 is fully open.
"""
state_name = self.entity_description.current_position_state

if not state_name:
return None

if state := self.device.states[state_name]:
return cast(int, state.value)

return None

async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover tilt to a specific position."""
position = kwargs[ATTR_TILT_POSITION]
if command := self.entity_description.set_tilt_position_command:
await self.executor.async_execute_command(command, position)

async def async_open_tilt_cover(self, **kwargs: Any) -> None:
"""Open the cover tilt."""
if command := self.entity_description.open_tilt_command:
await self.executor.async_execute_command(command)

async def async_close_tilt_cover(self, **kwargs: Any) -> None:
"""Close the cover tilt."""
if command := self.entity_description.close_tilt_command:
await self.executor.async_execute_command(command)

async def async_stop_tilt_cover(self, **kwargs: Any) -> None:
"""Stop the cover tilt."""
if command := self.entity_description.stop_tilt_command:
await self.executor.async_execute_command(command)

@property
def is_opening(self) -> bool | None:
"""Return if the cover is opening or not."""

if command := self.entity_description.open_command:
if self.is_running(command):
return True

if self.moving_offset is None:
return None

if self.entity_description.invert_position:
return self.moving_offset > 0
return self.moving_offset < 0

@property
def is_closing(self) -> bool | None:
"""Return if the cover is opening or not."""

if command := self.entity_description.close_command:
if self.is_running(command):
return True

if self.moving_offset is None:
return None

if self.entity_description.invert_position:
return self.moving_offset < 0
return self.moving_offset > 0

def is_running(self, command: OverkizCommand) -> bool:
"""Return if the given commands are currently running."""
return any(
execution.get("device_url") == self.device.device_url
and execution.get("command_name") == command
for execution in self.coordinator.executions.values()
)

@property
def moving_offset(self) -> int | None:
"""Return the offset between the targeted position and the current one if the cover is moving."""

is_moving = self.device.states.get(OverkizState.CORE_MOVING)
current_closure = self.device.states.get(OverkizState.CORE_CLOSURE)
target_closure = self.device.states.get(OverkizState.CORE_TARGET_CLOSURE)

if not is_moving or not current_closure or not target_closure:
return None

return cast(int, current_closure.value) - cast(int, target_closure.value)