core/homeassistant/components/tuya/cover.py

345 lines
12 KiB
Python

"""Support for Tuya Cover."""
from __future__ import annotations
from dataclasses import dataclass
import logging
from typing import Any
from tuya_iot import TuyaDevice, TuyaDeviceManager
from homeassistant.components.cover import (
ATTR_POSITION,
ATTR_TILT_POSITION,
DEVICE_CLASS_CURTAIN,
DEVICE_CLASS_GARAGE,
SUPPORT_CLOSE,
SUPPORT_OPEN,
SUPPORT_SET_POSITION,
SUPPORT_SET_TILT_POSITION,
SUPPORT_STOP,
CoverEntity,
CoverEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import HomeAssistantTuyaData
from .base import EnumTypeData, IntegerTypeData, TuyaEntity
from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode
_LOGGER = logging.getLogger(__name__)
@dataclass
class TuyaCoverEntityDescription(CoverEntityDescription):
"""Describe an Tuya cover entity."""
current_state: DPCode | None = None
current_state_inverse: bool = False
current_position: DPCode | None = None
set_position: DPCode | None = None
COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = {
# Curtain
# Note: Multiple curtains isn't documented
# https://developer.tuya.com/en/docs/iot/categorycl?id=Kaiuz1hnpo7df
"cl": (
TuyaCoverEntityDescription(
key=DPCode.CONTROL,
name="Curtain",
current_state=DPCode.SITUATION_SET,
current_position=DPCode.PERCENT_STATE,
set_position=DPCode.PERCENT_CONTROL,
device_class=DEVICE_CLASS_CURTAIN,
),
TuyaCoverEntityDescription(
key=DPCode.CONTROL_2,
name="Curtain 2",
current_position=DPCode.PERCENT_STATE_2,
set_position=DPCode.PERCENT_CONTROL_2,
device_class=DEVICE_CLASS_CURTAIN,
),
TuyaCoverEntityDescription(
key=DPCode.CONTROL_3,
name="Curtain 3",
current_position=DPCode.PERCENT_STATE_3,
set_position=DPCode.PERCENT_CONTROL_3,
device_class=DEVICE_CLASS_CURTAIN,
),
),
# Garage Door Opener
# https://developer.tuya.com/en/docs/iot/categoryckmkzq?id=Kaiuz0ipcboee
"ckmkzq": (
TuyaCoverEntityDescription(
key=DPCode.SWITCH_1,
name="Door",
current_state=DPCode.DOORCONTACT_STATE,
current_state_inverse=True,
device_class=DEVICE_CLASS_GARAGE,
),
TuyaCoverEntityDescription(
key=DPCode.SWITCH_2,
name="Door 2",
current_state=DPCode.DOORCONTACT_STATE_2,
current_state_inverse=True,
device_class=DEVICE_CLASS_GARAGE,
),
TuyaCoverEntityDescription(
key=DPCode.SWITCH_3,
name="Door 3",
current_state=DPCode.DOORCONTACT_STATE_3,
current_state_inverse=True,
device_class=DEVICE_CLASS_GARAGE,
),
),
# Curtain Switch
# https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39
"clkg": (
TuyaCoverEntityDescription(
key=DPCode.CONTROL,
name="Curtain",
current_position=DPCode.PERCENT_CONTROL,
set_position=DPCode.PERCENT_CONTROL,
device_class=DEVICE_CLASS_CURTAIN,
),
TuyaCoverEntityDescription(
key=DPCode.CONTROL_2,
name="Curtain 2",
current_position=DPCode.PERCENT_CONTROL_2,
set_position=DPCode.PERCENT_CONTROL_2,
device_class=DEVICE_CLASS_CURTAIN,
),
),
# Curtain Robot
# Note: Not documented
"jdcljqr": (
TuyaCoverEntityDescription(
key=DPCode.CONTROL,
current_position=DPCode.PERCENT_STATE,
set_position=DPCode.PERCENT_CONTROL,
device_class=DEVICE_CLASS_CURTAIN,
),
),
}
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
) -> None:
"""Set up Tuya cover dynamically through Tuya discovery."""
hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id]
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered tuya cover."""
entities: list[TuyaCoverEntity] = []
for device_id in device_ids:
device = hass_data.device_manager.device_map[device_id]
if descriptions := COVERS.get(device.category):
for description in descriptions:
if (
description.key in device.function
or description.key in device.status
):
entities.append(
TuyaCoverEntity(
device, hass_data.device_manager, description
)
)
async_add_entities(entities)
async_discover_device([*hass_data.device_manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)
)
class TuyaCoverEntity(TuyaEntity, CoverEntity):
"""Tuya Cover Device."""
_current_position_type: IntegerTypeData | None = None
_set_position_type: IntegerTypeData | None = None
_tilt_dpcode: DPCode | None = None
_tilt_type: IntegerTypeData | None = None
entity_description: TuyaCoverEntityDescription
def __init__(
self,
device: TuyaDevice,
device_manager: TuyaDeviceManager,
description: TuyaCoverEntityDescription,
) -> None:
"""Init Tuya Cover."""
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._attr_supported_features = 0
# Check if this cover is based on a switch or has controls
if device.function[description.key].type == "Boolean":
self._attr_supported_features |= SUPPORT_OPEN | SUPPORT_CLOSE
elif device.function[description.key].type == "Enum":
data_type = EnumTypeData.from_json(
device.status_range[description.key].values
)
if "open" in data_type.range:
self._attr_supported_features |= SUPPORT_OPEN
if "close" in data_type.range:
self._attr_supported_features |= SUPPORT_CLOSE
if "stop" in data_type.range:
self._attr_supported_features |= SUPPORT_STOP
# Determine type to use for setting the position
if (
description.set_position is not None
and description.set_position in device.status_range
):
self._attr_supported_features |= SUPPORT_SET_POSITION
self._set_position_type = IntegerTypeData.from_json(
device.status_range[description.set_position].values
)
# Set as default, unless overwritten below
self._current_position_type = self._set_position_type
# Determine type for getting the position
if (
description.current_position is not None
and description.current_position in device.status_range
):
self._current_position_type = IntegerTypeData.from_json(
device.status_range[description.current_position].values
)
# Determine type to use for setting the tilt
if tilt_dpcode := next(
(
dpcode
for dpcode in (DPCode.ANGLE_HORIZONTAL, DPCode.ANGLE_VERTICAL)
if dpcode in device.function
),
None,
):
self._attr_supported_features |= SUPPORT_SET_TILT_POSITION
self._tilt_dpcode = tilt_dpcode
self._tilt_type = IntegerTypeData.from_json(
device.status_range[tilt_dpcode].values
)
@property
def current_cover_position(self) -> int | None:
"""Return cover current position."""
if self._current_position_type is None:
return None
if not (
dpcode := (
self.entity_description.current_position
or self.entity_description.set_position
)
):
return None
if (position := self.device.status.get(dpcode)) is None:
return None
return round(
self._current_position_type.remap_value_to(position, 0, 100, reverse=True)
)
@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.
"""
if self._tilt_dpcode is None or self._tilt_type is None:
return None
if (angle := self.device.status.get(self._tilt_dpcode)) is None:
return None
return round(self._tilt_type.remap_value_to(angle, 0, 100))
@property
def is_closed(self) -> bool | None:
"""Return true if cover is closed."""
if (
self.entity_description.current_state is not None
and (
current_state := self.device.status.get(
self.entity_description.current_state
)
)
is not None
):
return self.entity_description.current_state_inverse is not (
current_state in (False, "fully_close")
)
if (position := self.current_cover_position) is not None:
return position == 0
return None
def open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
value: bool | str = True
if self.device.function[self.entity_description.key].type == "Enum":
value = "open"
self._send_command([{"code": self.entity_description.key, "value": value}])
def close_cover(self, **kwargs: Any) -> None:
"""Close cover."""
value: bool | str = True
if self.device.function[self.entity_description.key].type == "Enum":
value = "close"
self._send_command([{"code": self.entity_description.key, "value": value}])
def set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
if self._set_position_type is None:
raise RuntimeError(
"Cannot set position, device doesn't provide methods to set it"
)
self._send_command(
[
{
"code": self.entity_description.set_position,
"value": round(
self._set_position_type.remap_value_from(
kwargs[ATTR_POSITION], 0, 100, reverse=True
)
),
}
]
)
def stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
self._send_command([{"code": self.entity_description.key, "value": "stop"}])
def set_cover_tilt_position(self, **kwargs):
"""Move the cover tilt to a specific position."""
if self._tilt_type is None:
raise RuntimeError(
"Cannot set tilt, device doesn't provide methods to set it"
)
self._send_command(
[
{
"code": self._tilt_dpcode,
"value": round(
self._tilt_type.remap_value_from(
kwargs[ATTR_TILT_POSITION], 0, 100, reverse=True
)
),
}
]
)