From fbe3ce1bf7829e6986480d2532c50ed0215f8296 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 19 Oct 2021 19:36:15 +0200 Subject: [PATCH] Add cover platform to Tuya (#58045) --- .coveragerc | 1 + homeassistant/components/tuya/const.py | 15 ++ homeassistant/components/tuya/cover.py | 340 +++++++++++++++++++++++++ 3 files changed, 356 insertions(+) create mode 100644 homeassistant/components/tuya/cover.py diff --git a/.coveragerc b/.coveragerc index aa6f63ca915..9f122c09007 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1115,6 +1115,7 @@ omit = homeassistant/components/tuya/camera.py homeassistant/components/tuya/climate.py homeassistant/components/tuya/const.py + homeassistant/components/tuya/cover.py homeassistant/components/tuya/fan.py homeassistant/components/tuya/humidifier.py homeassistant/components/tuya/light.py diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index b6564ab9024..233edeac445 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -91,6 +91,7 @@ PLATFORMS = [ "binary_sensor", "camera", "climate", + "cover", "fan", "humidifier", "light", @@ -122,6 +123,8 @@ class DPCode(str, Enum): ALARM_SWITCH = "alarm_switch" # Alarm switch ALARM_TIME = "alarm_time" # Alarm time ALARM_VOLUME = "alarm_volume" # Alarm volume + ANGLE_HORIZONTAL = "angle_horizontal" + ANGLE_VERTICAL = "angle_vertical" ANION = "anion" # Ionizer unit BATTERY_PERCENTAGE = "battery_percentage" # Battery percentage BATTERY_STATE = "battery_state" # Battery state @@ -138,12 +141,17 @@ class DPCode(str, Enum): COLOUR_DATA = "colour_data" # Colored light mode COLOUR_DATA_V2 = "colour_data_v2" # Colored light mode CONCENTRATION_SET = "concentration_set" # Concentration setting + CONTROL = "control" + CONTROL_2 = "control_2" + CONTROL_3 = "control_3" CUP_NUMBER = "cup_number" # NUmber of cups CUR_CURRENT = "cur_current" # Actual current CUR_POWER = "cur_power" # Actual power CUR_VOLTAGE = "cur_voltage" # Actual voltage DEHUMIDITY_SET_VALUE = "dehumidify_set_value" DOORCONTACT_STATE = "doorcontact_state" # Status of door window sensor + DOORCONTACT_STATE_2 = "doorcontact_state_3" + DOORCONTACT_STATE_3 = "doorcontact_state_3" ELECTRICITY_LEFT = "electricity_left" FAN_DIRECTION = "fan_direction" # Fan direction FAN_SPEED_ENUM = "fan_speed_enum" # Speed mode @@ -159,6 +167,12 @@ class DPCode(str, Enum): MOTION_SWITCH = "motion_switch" # Motion switch MUFFLING = "muffling" # Muffling PAUSE = "pause" + PERCENT_CONTROL = "percent_control" + PERCENT_CONTROL_2 = "percent_control_2" + PERCENT_CONTROL_3 = "percent_control_3" + PERCENT_STATE = "percent_state" + PERCENT_STATE_2 = "percent_state_2" + PERCENT_STATE_3 = "percent_state_3" PIR = "pir" # Motion sensor POWDER_SET = "powder_set" # Powder POWER_GO = "power_go" @@ -168,6 +182,7 @@ class DPCode(str, Enum): SENSITIVITY = "sensitivity" # Sensitivity SHAKE = "shake" # Oscillating SHOCK_STATE = "shock_state" # Vibration status + SITUATION_SET = "situation_set" SOS = "sos" # Emergency State SOS_STATE = "sos_state" # Emergency mode SPEED = "speed" # Speed level diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py new file mode 100644 index 00000000000..bc5a5b85772 --- /dev/null +++ b/homeassistant/components/tuya/cover.py @@ -0,0 +1,340 @@ +"""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_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, + device_class=DEVICE_CLASS_GARAGE, + ), + TuyaCoverEntityDescription( + key=DPCode.SWITCH_2, + name="Door 2", + current_state=DPCode.DOORCONTACT_STATE_2, + device_class=DEVICE_CLASS_GARAGE, + ), + TuyaCoverEntityDescription( + key=DPCode.SWITCH_3, + name="Door 3", + current_state=DPCode.DOORCONTACT_STATE_3, + 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 + + position = self.device.status.get(dpcode) + if position 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 + + angle = self.device.status.get(self._tilt_dpcode) + if angle is None: + return None + + return round(self._tilt_type.remap_value_to(angle, 0, 100)) + + @property + def is_closed(self) -> bool | None: + """Return is 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 current_state in (True, "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 + ) + ), + } + ] + )