diff --git a/.gitignore b/.gitignore index 9a7ccc5..b34df46 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,7 @@ home-assistant.log.* ozw_log.txt # Development files -custom_components +# custom_components ### Python ### # Byte-compiled / optimized / DLL files diff --git a/custom_components/__init__.py b/custom_components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/tuya_openapi/.DS_Store b/custom_components/tuya_openapi/.DS_Store new file mode 100644 index 0000000..0c713cb Binary files /dev/null and b/custom_components/tuya_openapi/.DS_Store differ diff --git a/custom_components/tuya_openapi/__init__.py b/custom_components/tuya_openapi/__init__.py new file mode 100644 index 0000000..509e7e1 --- /dev/null +++ b/custom_components/tuya_openapi/__init__.py @@ -0,0 +1,288 @@ +"""Support for Tuya Smart devices.""" +from __future__ import annotations + +from typing import NamedTuple + +import requests +from tuya_iot import ( + AuthType, + TuyaDevice, + TuyaDeviceListener, + TuyaDeviceManager, + TuyaHomeManager, + TuyaOpenAPI, + TuyaOpenMQ, +) + +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.dispatcher import dispatcher_send + +from .const import ( + CONF_ACCESS_ID, + CONF_ACCESS_SECRET, + CONF_APP_TYPE, + CONF_AUTH_TYPE, + CONF_COUNTRY_CODE, + CONF_ENDPOINT, + CONF_PASSWORD, + CONF_PROJECT_TYPE, + CONF_USERNAME, + DOMAIN, + LOGGER, + PLATFORMS, + TUYA_DISCOVERY_NEW, + TUYA_HA_SIGNAL_UPDATE_ENTITY, + DPCode, +) + + +class HomeAssistantTuyaData(NamedTuple): + """Tuya data stored in the Home Assistant data object.""" + + device_listener: TuyaDeviceListener + device_manager: TuyaDeviceManager + home_manager: TuyaHomeManager + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Async setup hass config entry.""" + hass.data.setdefault(DOMAIN, {}) + + # Project type has been renamed to auth type in the upstream Tuya IoT SDK. + # This migrates existing config entries to reflect that name change. + if CONF_PROJECT_TYPE in entry.data: + data = {**entry.data, CONF_AUTH_TYPE: entry.data[CONF_PROJECT_TYPE]} + data.pop(CONF_PROJECT_TYPE) + hass.config_entries.async_update_entry(entry, data=data) + + auth_type = AuthType(entry.data[CONF_AUTH_TYPE]) + api = TuyaOpenAPI( + endpoint=entry.data[CONF_ENDPOINT], + access_id=entry.data[CONF_ACCESS_ID], + access_secret=entry.data[CONF_ACCESS_SECRET], + auth_type=auth_type, + ) + + api.set_dev_channel("hass") + + try: + if auth_type == AuthType.CUSTOM: + response = await hass.async_add_executor_job( + api.connect, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] + ) + else: + response = await hass.async_add_executor_job( + api.connect, + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_COUNTRY_CODE], + entry.data[CONF_APP_TYPE], + ) + except requests.exceptions.RequestException as err: + raise ConfigEntryNotReady(err) from err + + if response.get("success", False) is False: + raise ConfigEntryNotReady(response) + + tuya_mq = TuyaOpenMQ(api) + tuya_mq.start() + + device_ids: set[str] = set() + device_manager = TuyaDeviceManager(api, tuya_mq) + home_manager = TuyaHomeManager(api, tuya_mq, device_manager) + listener = DeviceListener(hass, device_manager, device_ids) + device_manager.add_device_listener(listener) + + hass.data[DOMAIN][entry.entry_id] = HomeAssistantTuyaData( + device_listener=listener, + device_manager=device_manager, + home_manager=home_manager, + ) + + # Get devices & clean up device entities + await hass.async_add_executor_job(home_manager.update_device_cache) + await cleanup_device_registry(hass, device_manager) + + # Migrate old unique_ids to the new format + async_migrate_entities_unique_ids(hass, entry, device_manager) + + # Register known device IDs + device_registry = dr.async_get(hass) + for device in device_manager.device_map.values(): + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, device.id)}, + manufacturer="Tuya", + name=device.name, + model=f"{device.product_name} (unsupported)", + ) + device_ids.add(device.id) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def cleanup_device_registry( + hass: HomeAssistant, device_manager: TuyaDeviceManager +) -> None: + """Remove deleted device registry entry if there are no remaining entities.""" + device_registry = dr.async_get(hass) + for dev_id, device_entry in list(device_registry.devices.items()): + for item in device_entry.identifiers: + if item[0] == DOMAIN and item[1] not in device_manager.device_map: + device_registry.async_remove_device(dev_id) + break + + +@callback +def async_migrate_entities_unique_ids( + hass: HomeAssistant, config_entry: ConfigEntry, device_manager: TuyaDeviceManager +) -> None: + """Migrate unique_ids in the entity registry to the new format.""" + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + light_entries = { + entry.unique_id: entry + for entry in registry_entries + if entry.domain == LIGHT_DOMAIN + } + switch_entries = { + entry.unique_id: entry + for entry in registry_entries + if entry.domain == SWITCH_DOMAIN + } + + for device in device_manager.device_map.values(): + # Old lights where in `tuya.{device_id}` format, now the DPCode is added. + # + # If the device is a previously supported light category and still has + # the old format for the unique ID, migrate it to the new format. + # + # Previously only devices providing the SWITCH_LED DPCode were supported, + # thus this can be added to those existing IDs. + # + # `tuya.{device_id}` -> `tuya.{device_id}{SWITCH_LED}` + if ( + device.category in ("dc", "dd", "dj", "fs", "fwl", "jsq", "xdd", "xxj") + and (entry := light_entries.get(f"tuya.{device.id}")) + and f"tuya.{device.id}{DPCode.SWITCH_LED}" not in light_entries + ): + entity_registry.async_update_entity( + entry.entity_id, new_unique_id=f"tuya.{device.id}{DPCode.SWITCH_LED}" + ) + + # Old switches has different formats for the unique ID, but is mappable. + # + # If the device is a previously supported switch category and still has + # the old format for the unique ID, migrate it to the new format. + # + # `tuya.{device_id}` -> `tuya.{device_id}{SWITCH}` + # `tuya.{device_id}_1` -> `tuya.{device_id}{SWITCH_1}` + # ... + # `tuya.{device_id}_6` -> `tuya.{device_id}{SWITCH_6}` + # `tuya.{device_id}_usb1` -> `tuya.{device_id}{SWITCH_USB1}` + # ... + # `tuya.{device_id}_usb6` -> `tuya.{device_id}{SWITCH_USB6}` + # + # In all other cases, the unique ID is not changed. + if device.category in ("bh", "cwysj", "cz", "dlq", "kg", "kj", "pc", "xxj"): + for postfix, dpcode in ( + ("", DPCode.SWITCH), + ("_1", DPCode.SWITCH_1), + ("_2", DPCode.SWITCH_2), + ("_3", DPCode.SWITCH_3), + ("_4", DPCode.SWITCH_4), + ("_5", DPCode.SWITCH_5), + ("_6", DPCode.SWITCH_6), + ("_usb1", DPCode.SWITCH_USB1), + ("_usb2", DPCode.SWITCH_USB2), + ("_usb3", DPCode.SWITCH_USB3), + ("_usb4", DPCode.SWITCH_USB4), + ("_usb5", DPCode.SWITCH_USB5), + ("_usb6", DPCode.SWITCH_USB6), + ): + if ( + entry := switch_entries.get(f"tuya.{device.id}{postfix}") + ) and f"tuya.{device.id}{dpcode}" not in switch_entries: + entity_registry.async_update_entity( + entry.entity_id, new_unique_id=f"tuya.{device.id}{dpcode}" + ) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unloading the Tuya platforms.""" + unload = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload: + hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + hass_data.device_manager.mq.stop() + hass_data.device_manager.remove_device_listener(hass_data.device_listener) + + hass.data[DOMAIN].pop(entry.entry_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + + return unload + + +class DeviceListener(TuyaDeviceListener): + """Device Update Listener.""" + + def __init__( + self, + hass: HomeAssistant, + device_manager: TuyaDeviceManager, + device_ids: set[str], + ) -> None: + """Init DeviceListener.""" + self.hass = hass + self.device_manager = device_manager + self.device_ids = device_ids + + def update_device(self, device: TuyaDevice) -> None: + """Update device status.""" + if device.id in self.device_ids: + LOGGER.debug( + "Received update for device %s: %s", + device.id, + self.device_manager.device_map[device.id].status, + ) + dispatcher_send(self.hass, f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{device.id}") + + def add_device(self, device: TuyaDevice) -> None: + """Add device added listener.""" + # Ensure the device isn't present stale + self.hass.add_job(self.async_remove_device, device.id) + + self.device_ids.add(device.id) + dispatcher_send(self.hass, TUYA_DISCOVERY_NEW, [device.id]) + + device_manager = self.device_manager + device_manager.mq.stop() + tuya_mq = TuyaOpenMQ(device_manager.api) + tuya_mq.start() + + device_manager.mq = tuya_mq + tuya_mq.add_message_listener(device_manager.on_message) + + def remove_device(self, device_id: str) -> None: + """Add device removed listener.""" + self.hass.add_job(self.async_remove_device, device_id) + + @callback + def async_remove_device(self, device_id: str) -> None: + """Remove device from Home Assistant.""" + LOGGER.debug("Remove device: %s", device_id) + device_registry = dr.async_get(self.hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + if device_entry is not None: + device_registry.async_remove_device(device_entry.id) + self.device_ids.discard(device_id) diff --git a/custom_components/tuya_openapi/alarm_control_panel.py b/custom_components/tuya_openapi/alarm_control_panel.py new file mode 100644 index 0000000..cd92e62 --- /dev/null +++ b/custom_components/tuya_openapi/alarm_control_panel.py @@ -0,0 +1,142 @@ +"""Support for Tuya Alarm.""" +from __future__ import annotations + +from enum import StrEnum + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityDescription, + AlarmControlPanelEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeAssistantTuyaData +from .base import TuyaEntity +from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType + + +class Mode(StrEnum): + """Alarm modes.""" + + ARM = "arm" + DISARMED = "disarmed" + HOME = "home" + SOS = "sos" + + +STATE_MAPPING: dict[str, str] = { + Mode.DISARMED: STATE_ALARM_DISARMED, + Mode.ARM: STATE_ALARM_ARMED_AWAY, + Mode.HOME: STATE_ALARM_ARMED_HOME, + Mode.SOS: STATE_ALARM_TRIGGERED, +} + + +# All descriptions can be found here: +# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq +ALARM: dict[str, tuple[AlarmControlPanelEntityDescription, ...]] = { + # Alarm Host + # https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf + "mal": ( + AlarmControlPanelEntityDescription( + key=DPCode.MASTER_MODE, + name="Alarm", + ), + ) +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Tuya alarm 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 siren.""" + entities: list[TuyaAlarmEntity] = [] + for device_id in device_ids: + device = hass_data.device_manager.device_map[device_id] + if descriptions := ALARM.get(device.category): + for description in descriptions: + if description.key in device.status: + entities.append( + TuyaAlarmEntity( + 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 TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity): + """Tuya Alarm Entity.""" + + _attr_icon = "mdi:security" + _attr_name = None + + def __init__( + self, + device: TuyaDevice, + device_manager: TuyaDeviceManager, + description: AlarmControlPanelEntityDescription, + ) -> None: + """Init Tuya Alarm.""" + super().__init__(device, device_manager) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}{description.key}" + + # Determine supported modes + if supported_modes := self.find_dpcode( + description.key, dptype=DPType.ENUM, prefer_function=True + ): + if Mode.HOME in supported_modes.range: + self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME + + if Mode.ARM in supported_modes.range: + self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY + + if Mode.SOS in supported_modes.range: + self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER + + @property + def state(self) -> str | None: + """Return the state of the device.""" + if not (status := self.device.status.get(self.entity_description.key)): + return None + return STATE_MAPPING.get(status) + + def alarm_disarm(self, code: str | None = None) -> None: + """Send Disarm command.""" + self._send_command( + [{"code": self.entity_description.key, "value": Mode.DISARMED}] + ) + + def alarm_arm_home(self, code: str | None = None) -> None: + """Send Home command.""" + self._send_command([{"code": self.entity_description.key, "value": Mode.HOME}]) + + def alarm_arm_away(self, code: str | None = None) -> None: + """Send Arm command.""" + self._send_command([{"code": self.entity_description.key, "value": Mode.ARM}]) + + def alarm_trigger(self, code: str | None = None) -> None: + """Send SOS command.""" + self._send_command([{"code": self.entity_description.key, "value": Mode.SOS}]) diff --git a/custom_components/tuya_openapi/base.py b/custom_components/tuya_openapi/base.py new file mode 100644 index 0000000..a546ae8 --- /dev/null +++ b/custom_components/tuya_openapi/base.py @@ -0,0 +1,332 @@ +"""Tuya Home Assistant Base Device Model.""" +from __future__ import annotations + +import base64 +from dataclasses import dataclass +import json +import struct +from typing import Any, Literal, Self, overload + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import ( + DOMAIN, + LOGGER, + TUYA_HA_SIGNAL_UPDATE_ENTITY, + DPCode, + DPType, + UnitOfTemperature, +) +from .util import remap_value + + +@dataclass +class IntegerTypeData: + """Integer Type Data.""" + + dpcode: DPCode + min: int + max: int + scale: float + step: float + unit: str | None = None + type: str | None = None + + @property + def max_scaled(self) -> float: + """Return the max scaled.""" + return self.scale_value(self.max) + + @property + def min_scaled(self) -> float: + """Return the min scaled.""" + return self.scale_value(self.min) + + @property + def step_scaled(self) -> float: + """Return the step scaled.""" + return self.step / (10**self.scale) + + def scale_value(self, value: float | int) -> float: + """Scale a value.""" + return value / (10**self.scale) + + def scale_value_back(self, value: float | int) -> int: + """Return raw value for scaled.""" + return int(value * (10**self.scale)) + + def remap_value_to( + self, + value: float, + to_min: float | int = 0, + to_max: float | int = 255, + reverse: bool = False, + ) -> float: + """Remap a value from this range to a new range.""" + return remap_value(value, self.min, self.max, to_min, to_max, reverse) + + def remap_value_from( + self, + value: float, + from_min: float | int = 0, + from_max: float | int = 255, + reverse: bool = False, + ) -> float: + """Remap a value from its current range to this range.""" + return remap_value(value, from_min, from_max, self.min, self.max, reverse) + + @classmethod + def from_json(cls, dpcode: DPCode, data: str) -> IntegerTypeData | None: + """Load JSON string and return a IntegerTypeData object.""" + if not (parsed := json.loads(data)): + return None + + return cls( + dpcode, + min=int(parsed["min"]), + max=int(parsed["max"]), + scale=float(parsed["scale"]), + step=max(float(parsed["step"]), 1), + unit=parsed.get("unit"), + type=parsed.get("type"), + ) + + +@dataclass +class EnumTypeData: + """Enum Type Data.""" + + dpcode: DPCode + range: list[str] + + @classmethod + def from_json(cls, dpcode: DPCode, data: str) -> EnumTypeData | None: + """Load JSON string and return a EnumTypeData object.""" + if not (parsed := json.loads(data)): + return None + return cls(dpcode, **parsed) + + +@dataclass +class ElectricityTypeData: + """Electricity Type Data.""" + + electriccurrent: str | None = None + power: str | None = None + voltage: str | None = None + + @classmethod + def from_json(cls, data: str) -> Self: + """Load JSON string and return a ElectricityTypeData object.""" + return cls(**json.loads(data.lower())) + + @classmethod + def from_raw(cls, data: str) -> Self: + """Decode base64 string and return a ElectricityTypeData object.""" + raw = base64.b64decode(data) + voltage = struct.unpack(">H", raw[0:2])[0] / 10.0 + electriccurrent = struct.unpack(">L", b"\x00" + raw[2:5])[0] / 1000.0 + power = struct.unpack(">L", b"\x00" + raw[5:8])[0] / 1000.0 + return cls( + electriccurrent=str(electriccurrent), power=str(power), voltage=str(voltage) + ) + + +@dataclass +class InkbirdB64TypeData: + """B64Temperature Type Data.""" + + temperature_unit: UnitOfTemperature | None = None + temperature: float | None = None + humidity: float | None = None + battery: int | None = None + + def __post_init__(self) -> None: + """Convert temperature to target unit.""" + + # pool sensors register humidity as ~6k, replace with None + if self.humidity and (self.humidity > 100 or self.humidity < 0): + self.humidity = None + + # proactively guard against invalid battery values + if self.battery and (self.battery > 100 or self.battery < 0): + self.battery = None + + @classmethod + def from_raw(cls, data: str) -> Self: + """Parse the raw, base64 encoded data and return a InkbirdB64TypeData object.""" + temperature_unit: UnitOfTemperature | None = None + battery: int | None = None + temperature: float | None = None + humidity: float | None = None + + if len(data) > 0: + try: + if data[0] == "C": + temperature_unit = UnitOfTemperature.CELSIUS + decoded_bytes = base64.b64decode(data) + _temperature, _humidity = struct.Struct(" None: + """Init TuyaHaEntity.""" + self._attr_unique_id = f"tuya.{device.id}" + self.device = device + self.device_manager = device_manager + + @property + def device_info(self) -> DeviceInfo: + """Return a device description for device registry.""" + return DeviceInfo( + identifiers={(DOMAIN, self.device.id)}, + manufacturer="Tuya", + name=self.device.name, + model=f"{self.device.product_name} ({self.device.product_id})", + ) + + @property + def available(self) -> bool: + """Return if the device is available.""" + return self.device.online + + @overload + def find_dpcode( + self, + dpcodes: str | DPCode | tuple[DPCode, ...] | None, + *, + prefer_function: bool = False, + dptype: Literal[DPType.ENUM], + ) -> EnumTypeData | None: + ... + + @overload + def find_dpcode( + self, + dpcodes: str | DPCode | tuple[DPCode, ...] | None, + *, + prefer_function: bool = False, + dptype: Literal[DPType.INTEGER], + ) -> IntegerTypeData | None: + ... + + @overload + def find_dpcode( + self, + dpcodes: str | DPCode | tuple[DPCode, ...] | None, + *, + prefer_function: bool = False, + ) -> DPCode | None: + ... + + def find_dpcode( + self, + dpcodes: str | DPCode | tuple[DPCode, ...] | None, + *, + prefer_function: bool = False, + dptype: DPType | None = None, + ) -> DPCode | EnumTypeData | IntegerTypeData | None: + """Find a matching DP code available on for this device.""" + if dpcodes is None: + return None + + if isinstance(dpcodes, str): + dpcodes = (DPCode(dpcodes),) + elif not isinstance(dpcodes, tuple): + dpcodes = (dpcodes,) + + order = ["status_range", "function"] + if prefer_function: + order = ["function", "status_range"] + + # When we are not looking for a specific datatype, we can append status for + # searching + if not dptype: + order.append("status") + + for dpcode in dpcodes: + for key in order: + if dpcode not in getattr(self.device, key): + continue + if ( + dptype == DPType.ENUM + and getattr(self.device, key)[dpcode].type == DPType.ENUM + ): + if not ( + enum_type := EnumTypeData.from_json( + dpcode, getattr(self.device, key)[dpcode].values + ) + ): + continue + return enum_type + + if ( + dptype == DPType.INTEGER + and getattr(self.device, key)[dpcode].type == DPType.INTEGER + ): + if not ( + integer_type := IntegerTypeData.from_json( + dpcode, getattr(self.device, key)[dpcode].values + ) + ): + continue + return integer_type + + if dptype not in (DPType.ENUM, DPType.INTEGER): + return dpcode + + return None + + def get_dptype( + self, dpcode: DPCode | None, prefer_function: bool = False + ) -> DPType | None: + """Find a matching DPCode data type available on for this device.""" + if dpcode is None: + return None + + order = ["status_range", "function"] + if prefer_function: + order = ["function", "status_range"] + for key in order: + if dpcode in getattr(self.device, key): + return DPType(getattr(self.device, key)[dpcode].type) + + return None + + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{self.device.id}", + self.async_write_ha_state, + ) + ) + + def _send_command(self, commands: list[dict[str, Any]]) -> None: + """Send command to the device.""" + LOGGER.debug("Sending commands for device %s: %s", self.device.id, commands) + self.device_manager.send_commands(self.device.id, commands) diff --git a/custom_components/tuya_openapi/binary_sensor.py b/custom_components/tuya_openapi/binary_sensor.py new file mode 100644 index 0000000..27f4240 --- /dev/null +++ b/custom_components/tuya_openapi/binary_sensor.py @@ -0,0 +1,471 @@ +"""Support for Tuya binary sensors.""" +from __future__ import annotations + +from dataclasses import dataclass + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeAssistantTuyaData +from .base import TuyaEntity +from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode + + +@dataclass +class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes a Tuya binary sensor.""" + + # DPCode, to use. If None, the key will be used as DPCode + dpcode: DPCode | None = None + + # Value or values to consider binary sensor to be "on" + on_value: bool | float | int | str | set[bool | float | int | str] = True + + +# Commonly used sensors +TAMPER_BINARY_SENSOR = TuyaBinarySensorEntityDescription( + key=DPCode.TEMPER_ALARM, + name="Tamper", + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, +) + + +# All descriptions can be found here. Mostly the Boolean data types in the +# default status set of each category (that don't have a set instruction) +# end up being a binary sensor. +# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq +BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { + # Multi-functional Sensor + # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 + "dgnbj": ( + TuyaBinarySensorEntityDescription( + key=DPCode.GAS_SENSOR_STATE, + icon="mdi:gas-cylinder", + device_class=BinarySensorDeviceClass.GAS, + on_value="alarm", + ), + TuyaBinarySensorEntityDescription( + key=DPCode.CH4_SENSOR_STATE, + translation_key="methane", + device_class=BinarySensorDeviceClass.GAS, + on_value="alarm", + ), + TuyaBinarySensorEntityDescription( + key=DPCode.VOC_STATE, + translation_key="voc", + device_class=BinarySensorDeviceClass.SAFETY, + on_value="alarm", + ), + TuyaBinarySensorEntityDescription( + key=DPCode.PM25_STATE, + translation_key="pm25", + device_class=BinarySensorDeviceClass.SAFETY, + on_value="alarm", + ), + TuyaBinarySensorEntityDescription( + key=DPCode.CO_STATE, + translation_key="carbon_monoxide", + icon="mdi:molecule-co", + device_class=BinarySensorDeviceClass.SAFETY, + on_value="alarm", + ), + TuyaBinarySensorEntityDescription( + key=DPCode.CO2_STATE, + translation_key="carbon_dioxide", + icon="mdi:molecule-co2", + device_class=BinarySensorDeviceClass.SAFETY, + on_value="alarm", + ), + TuyaBinarySensorEntityDescription( + key=DPCode.CH2O_STATE, + translation_key="formaldehyde", + device_class=BinarySensorDeviceClass.SAFETY, + on_value="alarm", + ), + TuyaBinarySensorEntityDescription( + key=DPCode.DOORCONTACT_STATE, + device_class=BinarySensorDeviceClass.DOOR, + ), + TuyaBinarySensorEntityDescription( + key=DPCode.WATERSENSOR_STATE, + device_class=BinarySensorDeviceClass.MOISTURE, + on_value="alarm", + ), + TuyaBinarySensorEntityDescription( + key=DPCode.PRESSURE_STATE, + translation_key="pressure", + on_value="alarm", + ), + TuyaBinarySensorEntityDescription( + key=DPCode.SMOKE_SENSOR_STATE, + icon="mdi:smoke-detector", + device_class=BinarySensorDeviceClass.SMOKE, + on_value="alarm", + ), + TAMPER_BINARY_SENSOR, + ), + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + TuyaBinarySensorEntityDescription( + key=DPCode.CO2_STATE, + device_class=BinarySensorDeviceClass.SAFETY, + on_value="alarm", + ), + TAMPER_BINARY_SENSOR, + ), + # CO Detector + # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v + "cobj": ( + TuyaBinarySensorEntityDescription( + key=DPCode.CO_STATE, + device_class=BinarySensorDeviceClass.SAFETY, + on_value="1", + ), + TuyaBinarySensorEntityDescription( + key=DPCode.CO_STATUS, + device_class=BinarySensorDeviceClass.SAFETY, + on_value="alarm", + ), + TAMPER_BINARY_SENSOR, + ), + # Smart Pet Feeder + # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld + "cwwsq": ( + TuyaBinarySensorEntityDescription( + key=DPCode.FEED_STATE, + translation_key="feeding", + icon="mdi:information", + on_value="feeding", + ), + ), + # Human Presence Sensor + # https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs + "hps": ( + TuyaBinarySensorEntityDescription( + key=DPCode.PRESENCE_STATE, + device_class=BinarySensorDeviceClass.MOTION, + on_value="presence", + ), + ), + # Formaldehyde Detector + # Note: Not documented + "jqbj": ( + TuyaBinarySensorEntityDescription( + key=DPCode.CH2O_STATE, + device_class=BinarySensorDeviceClass.SAFETY, + on_value="alarm", + ), + TAMPER_BINARY_SENSOR, + ), + # Methane Detector + # https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm + "jwbj": ( + TuyaBinarySensorEntityDescription( + key=DPCode.CH4_SENSOR_STATE, + device_class=BinarySensorDeviceClass.GAS, + on_value="alarm", + ), + TAMPER_BINARY_SENSOR, + ), + # Door and Window Controller + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r5zjsy9 + "mc": ( + TuyaBinarySensorEntityDescription( + key=DPCode.STATUS, + device_class=BinarySensorDeviceClass.DOOR, + on_value={"open", "opened"}, + ), + ), + # Door Window Sensor + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48hm02l8m + "mcs": ( + TuyaBinarySensorEntityDescription( + key=DPCode.DOORCONTACT_STATE, + device_class=BinarySensorDeviceClass.DOOR, + ), + TAMPER_BINARY_SENSOR, + ), + # Access Control + # https://developer.tuya.com/en/docs/iot/s?id=Kb0o2xhlkxbet + "mk": ( + TuyaBinarySensorEntityDescription( + key=DPCode.CLOSED_OPENED_KIT, + device_class=BinarySensorDeviceClass.LOCK, + on_value={"AQAB"}, + ), + ), + # Luminance Sensor + # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 + "ldcg": ( + TuyaBinarySensorEntityDescription( + key=DPCode.TEMPER_ALARM, + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TAMPER_BINARY_SENSOR, + ), + # PIR Detector + # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 + "pir": ( + TuyaBinarySensorEntityDescription( + key=DPCode.PIR, + device_class=BinarySensorDeviceClass.MOTION, + on_value="pir", + ), + TAMPER_BINARY_SENSOR, + ), + # PM2.5 Sensor + # https://developer.tuya.com/en/docs/iot/categorypm25?id=Kaiuz3qof3yfu + "pm2.5": ( + TuyaBinarySensorEntityDescription( + key=DPCode.PM25_STATE, + device_class=BinarySensorDeviceClass.SAFETY, + on_value="alarm", + ), + TAMPER_BINARY_SENSOR, + ), + # Gas Detector + # https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw + "rqbj": ( + TuyaBinarySensorEntityDescription( + key=DPCode.GAS_SENSOR_STATUS, + device_class=BinarySensorDeviceClass.GAS, + on_value="alarm", + ), + TuyaBinarySensorEntityDescription( + key=DPCode.GAS_SENSOR_STATE, + device_class=BinarySensorDeviceClass.GAS, + on_value="1", + ), + TAMPER_BINARY_SENSOR, + ), + # Water Detector + # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli + "sj": ( + TuyaBinarySensorEntityDescription( + key=DPCode.WATERSENSOR_STATE, + device_class=BinarySensorDeviceClass.MOISTURE, + on_value="alarm", + ), + TAMPER_BINARY_SENSOR, + ), + # Emergency Button + # https://developer.tuya.com/en/docs/iot/categorysos?id=Kaiuz3oi6agjy + "sos": ( + TuyaBinarySensorEntityDescription( + key=DPCode.SOS_STATE, + device_class=BinarySensorDeviceClass.SAFETY, + ), + TAMPER_BINARY_SENSOR, + ), + # Volatile Organic Compound Sensor + # Note: Undocumented in cloud API docs, based on test device + "voc": ( + TuyaBinarySensorEntityDescription( + key=DPCode.VOC_STATE, + device_class=BinarySensorDeviceClass.SAFETY, + on_value="alarm", + ), + TAMPER_BINARY_SENSOR, + ), + # Thermostatic Radiator Valve + # Not documented + "wkf": ( + TuyaBinarySensorEntityDescription( + key=DPCode.WINDOW_STATE, + device_class=BinarySensorDeviceClass.WINDOW, + on_value="opened", + ), + ), + # Temperature and Humidity Sensor + # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 + "wsdcg": ( + TuyaBinarySensorEntityDescription( + key=DPCode.CHANNEL_0_ALARM, + translation_key="ch0_alarm", + name="ch0_alarm", + on_value=1, + ), + TuyaBinarySensorEntityDescription( + key=DPCode.CHANNEL_1_ALARM, + translation_key="ch1_alarm", + name="ch1_alarm", + on_value=1, + ), + TuyaBinarySensorEntityDescription( + key=DPCode.CHANNEL_2_ALARM, + translation_key="ch2_alarm", + name="ch2_alarm", + on_value=1, + ), + TuyaBinarySensorEntityDescription( + key=DPCode.CHANNEL_3_ALARM, + translation_key="ch3_alarm", + name="ch3_alarm", + on_value=1, + ), + TuyaBinarySensorEntityDescription( + key=DPCode.CHANNEL_4_ALARM, + translation_key="ch4_alarm", + name="ch4_alarm", + on_value=1, + ), + TuyaBinarySensorEntityDescription( + key=DPCode.CHANNEL_5_ALARM, + translation_key="ch5_alarm", + name="ch5_alarm", + on_value=1, + ), + TuyaBinarySensorEntityDescription( + key=DPCode.CHANNEL_6_ALARM, + translation_key="ch6_alarm", + name="ch6_alarm", + on_value=1, + ), + TuyaBinarySensorEntityDescription( + key=DPCode.CHANNEL_7_ALARM, + translation_key="ch7_alarm", + name="ch7_alarm", + on_value=1, + ), + TuyaBinarySensorEntityDescription( + key=DPCode.CHANNEL_8_ALARM, + translation_key="ch8_alarm", + name="ch8_alarm", + on_value=1, + ), + TuyaBinarySensorEntityDescription( + key=DPCode.CHANNEL_9_ALARM, + translation_key="ch9_alarm", + name="ch9_alarm", + on_value=1, + ), + TuyaBinarySensorEntityDescription( + key=DPCode.BEEP_STATUS, + translation_key="beep_status", + name="beep_status", + on_value=1, + ), + TAMPER_BINARY_SENSOR, + ), + # Pressure Sensor + # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm + "ylcg": ( + TuyaBinarySensorEntityDescription( + key=DPCode.PRESSURE_STATE, + on_value="alarm", + ), + TAMPER_BINARY_SENSOR, + ), + # Smoke Detector + # https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952 + "ywbj": ( + TuyaBinarySensorEntityDescription( + key=DPCode.SMOKE_SENSOR_STATUS, + device_class=BinarySensorDeviceClass.SMOKE, + on_value="alarm", + ), + TuyaBinarySensorEntityDescription( + key=DPCode.SMOKE_SENSOR_STATE, + device_class=BinarySensorDeviceClass.SMOKE, + on_value={"1", "alarm"}, + ), + TAMPER_BINARY_SENSOR, + ), + # Vibration Sensor + # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno + "zd": ( + TuyaBinarySensorEntityDescription( + key=f"{DPCode.SHOCK_STATE}_vibration", + dpcode=DPCode.SHOCK_STATE, + device_class=BinarySensorDeviceClass.VIBRATION, + on_value="vibration", + ), + TuyaBinarySensorEntityDescription( + key=f"{DPCode.SHOCK_STATE}_drop", + dpcode=DPCode.SHOCK_STATE, + translation_key="drop", + icon="mdi:icon=package-down", + on_value="drop", + ), + TuyaBinarySensorEntityDescription( + key=f"{DPCode.SHOCK_STATE}_tilt", + dpcode=DPCode.SHOCK_STATE, + translation_key="tilt", + icon="mdi:spirit-level", + on_value="tilt", + ), + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Tuya binary sensor 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 binary sensor.""" + entities: list[TuyaBinarySensorEntity] = [] + for device_id in device_ids: + device = hass_data.device_manager.device_map[device_id] + if descriptions := BINARY_SENSORS.get(device.category): + for description in descriptions: + dpcode = description.dpcode or description.key + if dpcode in device.status: + entities.append( + TuyaBinarySensorEntity( + 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 TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity): + """Tuya Binary Sensor Entity.""" + + entity_description: TuyaBinarySensorEntityDescription + + def __init__( + self, + device: TuyaDevice, + device_manager: TuyaDeviceManager, + description: TuyaBinarySensorEntityDescription, + ) -> None: + """Init Tuya binary sensor.""" + super().__init__(device, device_manager) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}{description.key}" + + @property + def is_on(self) -> bool: + """Return true if sensor is on.""" + dpcode = self.entity_description.dpcode or self.entity_description.key + if dpcode not in self.device.status: + return False + + if isinstance(self.entity_description.on_value, set): + return self.device.status[dpcode] in self.entity_description.on_value + + return self.device.status[dpcode] == self.entity_description.on_value diff --git a/custom_components/tuya_openapi/button.py b/custom_components/tuya_openapi/button.py new file mode 100644 index 0000000..4c73b70 --- /dev/null +++ b/custom_components/tuya_openapi/button.py @@ -0,0 +1,112 @@ +"""Support for Tuya buttons.""" +from __future__ import annotations + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeAssistantTuyaData +from .base import TuyaEntity +from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode + +# All descriptions can be found here. +# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq +BUTTONS: dict[str, tuple[ButtonEntityDescription, ...]] = { + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + ButtonEntityDescription( + key=DPCode.RESET_DUSTER_CLOTH, + translation_key="reset_duster_cloth", + icon="mdi:restart", + entity_category=EntityCategory.CONFIG, + ), + ButtonEntityDescription( + key=DPCode.RESET_EDGE_BRUSH, + translation_key="reset_edge_brush", + icon="mdi:restart", + entity_category=EntityCategory.CONFIG, + ), + ButtonEntityDescription( + key=DPCode.RESET_FILTER, + translation_key="reset_filter", + icon="mdi:air-filter", + entity_category=EntityCategory.CONFIG, + ), + ButtonEntityDescription( + key=DPCode.RESET_MAP, + translation_key="reset_map", + icon="mdi:map-marker-remove", + entity_category=EntityCategory.CONFIG, + ), + ButtonEntityDescription( + key=DPCode.RESET_ROLL_BRUSH, + translation_key="reset_roll_brush", + icon="mdi:restart", + entity_category=EntityCategory.CONFIG, + ), + ), + # Wake Up Light II + # Not documented + "hxd": ( + ButtonEntityDescription( + key=DPCode.SWITCH_USB6, + translation_key="snooze", + icon="mdi:sleep", + ), + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Tuya buttons 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 buttons.""" + entities: list[TuyaButtonEntity] = [] + for device_id in device_ids: + device = hass_data.device_manager.device_map[device_id] + if descriptions := BUTTONS.get(device.category): + for description in descriptions: + if description.key in device.status: + entities.append( + TuyaButtonEntity( + 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 TuyaButtonEntity(TuyaEntity, ButtonEntity): + """Tuya Button Device.""" + + def __init__( + self, + device: TuyaDevice, + device_manager: TuyaDeviceManager, + description: ButtonEntityDescription, + ) -> None: + """Init Tuya button.""" + super().__init__(device, device_manager) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}{description.key}" + + def press(self) -> None: + """Press the button.""" + self._send_command([{"code": self.entity_description.key, "value": True}]) diff --git a/custom_components/tuya_openapi/camera.py b/custom_components/tuya_openapi/camera.py new file mode 100644 index 0000000..7221605 --- /dev/null +++ b/custom_components/tuya_openapi/camera.py @@ -0,0 +1,105 @@ +"""Support for Tuya cameras.""" +from __future__ import annotations + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.components import ffmpeg +from homeassistant.components.camera import Camera as CameraEntity, CameraEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeAssistantTuyaData +from .base import TuyaEntity +from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode + +# All descriptions can be found here: +# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq +CAMERAS: tuple[str, ...] = ( + # Smart Camera (including doorbells) + # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + "sp", +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Tuya cameras 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 camera.""" + entities: list[TuyaCameraEntity] = [] + for device_id in device_ids: + device = hass_data.device_manager.device_map[device_id] + if device.category in CAMERAS: + entities.append(TuyaCameraEntity(device, hass_data.device_manager)) + + 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 TuyaCameraEntity(TuyaEntity, CameraEntity): + """Tuya Camera Entity.""" + + _attr_supported_features = CameraEntityFeature.STREAM + _attr_brand = "Tuya" + _attr_name = None + + def __init__( + self, + device: TuyaDevice, + device_manager: TuyaDeviceManager, + ) -> None: + """Init Tuya Camera.""" + super().__init__(device, device_manager) + CameraEntity.__init__(self) + self._attr_model = device.product_name + + @property + def is_recording(self) -> bool: + """Return true if the device is recording.""" + return self.device.status.get(DPCode.RECORD_SWITCH, False) + + @property + def motion_detection_enabled(self) -> bool: + """Return the camera motion detection status.""" + return self.device.status.get(DPCode.MOTION_SWITCH, False) + + async def stream_source(self) -> str | None: + """Return the source of the stream.""" + return await self.hass.async_add_executor_job( + self.device_manager.get_device_stream_allocate, + self.device.id, + "rtsp", + ) + + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return a still image response from the camera.""" + stream_source = await self.stream_source() + if not stream_source: + return None + return await ffmpeg.async_get_image( + self.hass, + stream_source, + width=width, + height=height, + ) + + def enable_motion_detection(self) -> None: + """Enable motion detection in the camera.""" + self._send_command([{"code": DPCode.MOTION_SWITCH, "value": True}]) + + def disable_motion_detection(self) -> None: + """Disable motion detection in camera.""" + self._send_command([{"code": DPCode.MOTION_SWITCH, "value": False}]) diff --git a/custom_components/tuya_openapi/climate.py b/custom_components/tuya_openapi/climate.py new file mode 100644 index 0000000..6b3b84b --- /dev/null +++ b/custom_components/tuya_openapi/climate.py @@ -0,0 +1,496 @@ +"""Support for Tuya Climate.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.components.climate import ( + SWING_BOTH, + SWING_HORIZONTAL, + SWING_OFF, + SWING_ON, + SWING_VERTICAL, + ClimateEntity, + ClimateEntityDescription, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeAssistantTuyaData +from .base import IntegerTypeData, TuyaEntity +from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType + +TUYA_HVAC_TO_HA = { + "auto": HVACMode.HEAT_COOL, + "cold": HVACMode.COOL, + "freeze": HVACMode.COOL, + "heat": HVACMode.HEAT, + "hot": HVACMode.HEAT, + "manual": HVACMode.HEAT_COOL, + "wet": HVACMode.DRY, + "wind": HVACMode.FAN_ONLY, +} + + +@dataclass +class TuyaClimateSensorDescriptionMixin: + """Define an entity description mixin for climate entities.""" + + switch_only_hvac_mode: HVACMode + + +@dataclass +class TuyaClimateEntityDescription( + ClimateEntityDescription, TuyaClimateSensorDescriptionMixin +): + """Describe an Tuya climate entity.""" + + +CLIMATE_DESCRIPTIONS: dict[str, TuyaClimateEntityDescription] = { + # Air conditioner + # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n + "kt": TuyaClimateEntityDescription( + key="kt", + switch_only_hvac_mode=HVACMode.COOL, + ), + # Heater + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46epy4j82 + "qn": TuyaClimateEntityDescription( + key="qn", + switch_only_hvac_mode=HVACMode.HEAT, + ), + # Heater + # https://developer.tuya.com/en/docs/iot/categoryrs?id=Kaiuz0nfferyx + "rs": TuyaClimateEntityDescription( + key="rs", + switch_only_hvac_mode=HVACMode.HEAT, + ), + # Thermostat + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 + "wk": TuyaClimateEntityDescription( + key="wk", + switch_only_hvac_mode=HVACMode.HEAT_COOL, + ), + # Thermostatic Radiator Valve + # Not documented + "wkf": TuyaClimateEntityDescription( + key="wkf", + switch_only_hvac_mode=HVACMode.HEAT, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Tuya climate 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 climate.""" + entities: list[TuyaClimateEntity] = [] + for device_id in device_ids: + device = hass_data.device_manager.device_map[device_id] + if device and device.category in CLIMATE_DESCRIPTIONS: + entities.append( + TuyaClimateEntity( + device, + hass_data.device_manager, + CLIMATE_DESCRIPTIONS[device.category], + ) + ) + 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 TuyaClimateEntity(TuyaEntity, ClimateEntity): + """Tuya Climate Device.""" + + _current_humidity: IntegerTypeData | None = None + _current_temperature: IntegerTypeData | None = None + _hvac_to_tuya: dict[str, str] + _set_humidity: IntegerTypeData | None = None + _set_temperature: IntegerTypeData | None = None + entity_description: TuyaClimateEntityDescription + _attr_name = None + + def __init__( + self, + device: TuyaDevice, + device_manager: TuyaDeviceManager, + description: TuyaClimateEntityDescription, + ) -> None: + """Determine which values to use.""" + self._attr_target_temperature_step = 1.0 + self.entity_description = description + + super().__init__(device, device_manager) + + # If both temperature values for celsius and fahrenheit are present, + # use whatever the device is set to, with a fallback to celsius. + prefered_temperature_unit = None + if all( + dpcode in device.status + for dpcode in (DPCode.TEMP_CURRENT, DPCode.TEMP_CURRENT_F) + ) or all( + dpcode in device.status for dpcode in (DPCode.TEMP_SET, DPCode.TEMP_SET_F) + ): + prefered_temperature_unit = UnitOfTemperature.CELSIUS + if any( + "f" in device.status[dpcode].lower() + for dpcode in (DPCode.C_F, DPCode.TEMP_UNIT_CONVERT) + if isinstance(device.status.get(dpcode), str) + ): + prefered_temperature_unit = UnitOfTemperature.FAHRENHEIT + + # Default to Celsius + self._attr_temperature_unit = UnitOfTemperature.CELSIUS + + # Figure out current temperature, use preferred unit or what is available + celsius_type = self.find_dpcode( + (DPCode.TEMP_CURRENT, DPCode.UPPER_TEMP), dptype=DPType.INTEGER + ) + fahrenheit_type = self.find_dpcode( + (DPCode.TEMP_CURRENT_F, DPCode.UPPER_TEMP_F), dptype=DPType.INTEGER + ) + if fahrenheit_type and ( + prefered_temperature_unit == UnitOfTemperature.FAHRENHEIT + or ( + prefered_temperature_unit == UnitOfTemperature.CELSIUS + and not celsius_type + ) + ): + self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + self._current_temperature = fahrenheit_type + elif celsius_type: + self._attr_temperature_unit = UnitOfTemperature.CELSIUS + self._current_temperature = celsius_type + + # Figure out setting temperature, use preferred unit or what is available + celsius_type = self.find_dpcode( + DPCode.TEMP_SET, dptype=DPType.INTEGER, prefer_function=True + ) + fahrenheit_type = self.find_dpcode( + DPCode.TEMP_SET_F, dptype=DPType.INTEGER, prefer_function=True + ) + if fahrenheit_type and ( + prefered_temperature_unit == UnitOfTemperature.FAHRENHEIT + or ( + prefered_temperature_unit == UnitOfTemperature.CELSIUS + and not celsius_type + ) + ): + self._set_temperature = fahrenheit_type + elif celsius_type: + self._set_temperature = celsius_type + + # Get integer type data for the dpcode to set temperature, use + # it to define min, max & step temperatures + if self._set_temperature: + self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE + self._attr_max_temp = self._set_temperature.max_scaled + self._attr_min_temp = self._set_temperature.min_scaled + self._attr_target_temperature_step = self._set_temperature.step_scaled + + # Determine HVAC modes + self._attr_hvac_modes: list[HVACMode] = [] + self._hvac_to_tuya = {} + if enum_type := self.find_dpcode( + DPCode.MODE, dptype=DPType.ENUM, prefer_function=True + ): + self._attr_hvac_modes = [HVACMode.OFF] + unknown_hvac_modes: list[str] = [] + for tuya_mode in enum_type.range: + if tuya_mode in TUYA_HVAC_TO_HA: + ha_mode = TUYA_HVAC_TO_HA[tuya_mode] + self._hvac_to_tuya[ha_mode] = tuya_mode + self._attr_hvac_modes.append(ha_mode) + else: + unknown_hvac_modes.append(tuya_mode) + + if unknown_hvac_modes: # Tuya modes are presets instead of hvac_modes + self._attr_hvac_modes.append(description.switch_only_hvac_mode) + self._attr_preset_modes = unknown_hvac_modes + self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE + elif self.find_dpcode(DPCode.SWITCH, prefer_function=True): + self._attr_hvac_modes = [ + HVACMode.OFF, + description.switch_only_hvac_mode, + ] + + # Determine dpcode to use for setting the humidity + if int_type := self.find_dpcode( + DPCode.HUMIDITY_SET, dptype=DPType.INTEGER, prefer_function=True + ): + self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY + self._set_humidity = int_type + self._attr_min_humidity = int(int_type.min_scaled) + self._attr_max_humidity = int(int_type.max_scaled) + + # Determine dpcode to use for getting the current humidity + self._current_humidity = self.find_dpcode( + DPCode.HUMIDITY_CURRENT, dptype=DPType.INTEGER + ) + + # Determine fan modes + if enum_type := self.find_dpcode( + (DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED), + dptype=DPType.ENUM, + prefer_function=True, + ): + self._attr_supported_features |= ClimateEntityFeature.FAN_MODE + self._attr_fan_modes = enum_type.range + + # Determine swing modes + if self.find_dpcode( + ( + DPCode.SHAKE, + DPCode.SWING, + DPCode.SWITCH_HORIZONTAL, + DPCode.SWITCH_VERTICAL, + ), + prefer_function=True, + ): + self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + self._attr_swing_modes = [SWING_OFF] + if self.find_dpcode((DPCode.SHAKE, DPCode.SWING), prefer_function=True): + self._attr_swing_modes.append(SWING_ON) + + if self.find_dpcode(DPCode.SWITCH_HORIZONTAL, prefer_function=True): + self._attr_swing_modes.append(SWING_HORIZONTAL) + + if self.find_dpcode(DPCode.SWITCH_VERTICAL, prefer_function=True): + self._attr_swing_modes.append(SWING_VERTICAL) + + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + + def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + commands = [{"code": DPCode.SWITCH, "value": hvac_mode != HVACMode.OFF}] + if hvac_mode in self._hvac_to_tuya: + commands.append( + {"code": DPCode.MODE, "value": self._hvac_to_tuya[hvac_mode]} + ) + self._send_command(commands) + + def set_preset_mode(self, preset_mode): + """Set new target preset mode.""" + commands = [{"code": DPCode.MODE, "value": preset_mode}] + self._send_command(commands) + + def set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + self._send_command([{"code": DPCode.FAN_SPEED_ENUM, "value": fan_mode}]) + + def set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + if self._set_humidity is None: + raise RuntimeError( + "Cannot set humidity, device doesn't provide methods to set it" + ) + + self._send_command( + [ + { + "code": self._set_humidity.dpcode, + "value": self._set_humidity.scale_value_back(humidity), + } + ] + ) + + def set_swing_mode(self, swing_mode: str) -> None: + """Set new target swing operation.""" + # The API accepts these all at once and will ignore the codes + # that don't apply to the device being controlled. + self._send_command( + [ + { + "code": DPCode.SHAKE, + "value": swing_mode == SWING_ON, + }, + { + "code": DPCode.SWING, + "value": swing_mode == SWING_ON, + }, + { + "code": DPCode.SWITCH_VERTICAL, + "value": swing_mode in (SWING_BOTH, SWING_VERTICAL), + }, + { + "code": DPCode.SWITCH_HORIZONTAL, + "value": swing_mode in (SWING_BOTH, SWING_HORIZONTAL), + }, + ] + ) + + def set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if self._set_temperature is None: + raise RuntimeError( + "Cannot set target temperature, device doesn't provide methods to" + " set it" + ) + + self._send_command( + [ + { + "code": self._set_temperature.dpcode, + "value": round( + self._set_temperature.scale_value_back(kwargs["temperature"]) + ), + } + ] + ) + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if self._current_temperature is None: + return None + + temperature = self.device.status.get(self._current_temperature.dpcode) + if temperature is None: + return None + + if self._current_temperature.scale == 0 and self._current_temperature.step != 1: + # The current temperature can have a scale of 0 or 1 and is used for + # rounding, Home Assistant doesn't need to round but we will always + # need to divide the value by 10^1 in case of 0 as scale. + # https://developer.tuya.com/en/docs/iot/shift-temperature-scale-follow-the-setting-of-app-account-center?id=Ka9qo7so58efq#title-7-Round%20values + temperature = temperature / 10 + + return self._current_temperature.scale_value(temperature) + + @property + def current_humidity(self) -> int | None: + """Return the current humidity.""" + if self._current_humidity is None: + return None + + humidity = self.device.status.get(self._current_humidity.dpcode) + if humidity is None: + return None + + return round(self._current_humidity.scale_value(humidity)) + + @property + def target_temperature(self) -> float | None: + """Return the temperature currently set to be reached.""" + if self._set_temperature is None: + return None + + temperature = self.device.status.get(self._set_temperature.dpcode) + if temperature is None: + return None + + return self._set_temperature.scale_value(temperature) + + @property + def target_humidity(self) -> int | None: + """Return the humidity currently set to be reached.""" + if self._set_humidity is None: + return None + + humidity = self.device.status.get(self._set_humidity.dpcode) + if humidity is None: + return None + + return round(self._set_humidity.scale_value(humidity)) + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac mode.""" + # If the switch off, hvac mode is off as well. Unless the switch + # the switch is on or doesn't exists of course... + if not self.device.status.get(DPCode.SWITCH, True): + return HVACMode.OFF + + if DPCode.MODE not in self.device.function: + if self.device.status.get(DPCode.SWITCH, False): + return self.entity_description.switch_only_hvac_mode + return HVACMode.OFF + + if ( + mode := self.device.status.get(DPCode.MODE) + ) is not None and mode in TUYA_HVAC_TO_HA: + return TUYA_HVAC_TO_HA[mode] + + # If the switch is on, and the mode does not match any hvac mode. + if self.device.status.get(DPCode.SWITCH, False): + return self.entity_description.switch_only_hvac_mode + + return HVACMode.OFF + + @property + def preset_mode(self) -> str | None: + """Return preset mode.""" + if DPCode.MODE not in self.device.function: + return None + + mode = self.device.status.get(DPCode.MODE) + if mode in TUYA_HVAC_TO_HA: + return None + + return mode + + @property + def fan_mode(self) -> str | None: + """Return fan mode.""" + return self.device.status.get(DPCode.FAN_SPEED_ENUM) + + @property + def swing_mode(self) -> str: + """Return swing mode.""" + if any( + self.device.status.get(dpcode) for dpcode in (DPCode.SHAKE, DPCode.SWING) + ): + return SWING_ON + + horizontal = self.device.status.get(DPCode.SWITCH_HORIZONTAL) + vertical = self.device.status.get(DPCode.SWITCH_VERTICAL) + if horizontal and vertical: + return SWING_BOTH + if horizontal: + return SWING_HORIZONTAL + if vertical: + return SWING_VERTICAL + + return SWING_OFF + + def turn_on(self) -> None: + """Turn the device on, retaining current HVAC (if supported).""" + if DPCode.SWITCH in self.device.function: + self._send_command([{"code": DPCode.SWITCH, "value": True}]) + return + + # Fake turn on + for mode in (HVACMode.HEAT_COOL, HVACMode.HEAT, HVACMode.COOL): + if mode not in self.hvac_modes: + continue + self.set_hvac_mode(mode) + break + + def turn_off(self) -> None: + """Turn the device on, retaining current HVAC (if supported).""" + if DPCode.SWITCH in self.device.function: + self._send_command([{"code": DPCode.SWITCH, "value": False}]) + return + + # Fake turn off + if HVACMode.OFF in self.hvac_modes: + self.set_hvac_mode(HVACMode.OFF) diff --git a/custom_components/tuya_openapi/config_flow.py b/custom_components/tuya_openapi/config_flow.py new file mode 100644 index 0000000..bf2c54a --- /dev/null +++ b/custom_components/tuya_openapi/config_flow.py @@ -0,0 +1,145 @@ +"""Config flow for Tuya.""" +from __future__ import annotations + +from typing import Any + +from tuya_iot import AuthType, TuyaOpenAPI +import voluptuous as vol + +from homeassistant import config_entries + +from .const import ( + CONF_ACCESS_ID, + CONF_ACCESS_SECRET, + CONF_APP_TYPE, + CONF_AUTH_TYPE, + CONF_COUNTRY_CODE, + CONF_ENDPOINT, + CONF_PASSWORD, + CONF_USERNAME, + DOMAIN, + LOGGER, + SMARTLIFE_APP, + TUYA_COUNTRIES, + TUYA_RESPONSE_CODE, + TUYA_RESPONSE_MSG, + TUYA_RESPONSE_PLATFORM_URL, + TUYA_RESPONSE_RESULT, + TUYA_RESPONSE_SUCCESS, + TUYA_SMART_APP, +) + + +class TuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Tuya Config Flow.""" + + @staticmethod + def _try_login(user_input: dict[str, Any]) -> tuple[dict[Any, Any], dict[str, Any]]: + """Try login.""" + response = {} + + country = [ + country + for country in TUYA_COUNTRIES + if country.name == user_input[CONF_COUNTRY_CODE] + ][0] + + data = { + CONF_ENDPOINT: country.endpoint, + CONF_AUTH_TYPE: AuthType.CUSTOM, + CONF_ACCESS_ID: user_input[CONF_ACCESS_ID], + CONF_ACCESS_SECRET: user_input[CONF_ACCESS_SECRET], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_COUNTRY_CODE: country.country_code, + } + + for app_type in ("", TUYA_SMART_APP, SMARTLIFE_APP): + data[CONF_APP_TYPE] = app_type + if data[CONF_APP_TYPE] == "": + data[CONF_AUTH_TYPE] = AuthType.CUSTOM + else: + data[CONF_AUTH_TYPE] = AuthType.SMART_HOME + + api = TuyaOpenAPI( + endpoint=data[CONF_ENDPOINT], + access_id=data[CONF_ACCESS_ID], + access_secret=data[CONF_ACCESS_SECRET], + auth_type=data[CONF_AUTH_TYPE], + ) + api.set_dev_channel("hass") + + response = api.connect( + username=data[CONF_USERNAME], + password=data[CONF_PASSWORD], + country_code=data[CONF_COUNTRY_CODE], + schema=data[CONF_APP_TYPE], + ) + + LOGGER.debug("Response %s", response) + + if response.get(TUYA_RESPONSE_SUCCESS, False): + break + + return response, data + + async def async_step_user(self, user_input=None): + """Step user.""" + errors = {} + placeholders = {} + + if user_input is not None: + response, data = await self.hass.async_add_executor_job( + self._try_login, user_input + ) + + if response.get(TUYA_RESPONSE_SUCCESS, False): + if endpoint := response.get(TUYA_RESPONSE_RESULT, {}).get( + TUYA_RESPONSE_PLATFORM_URL + ): + data[CONF_ENDPOINT] = endpoint + + data[CONF_AUTH_TYPE] = data[CONF_AUTH_TYPE].value + + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=data, + ) + errors["base"] = "login_error" + placeholders = { + TUYA_RESPONSE_CODE: response.get(TUYA_RESPONSE_CODE), + TUYA_RESPONSE_MSG: response.get(TUYA_RESPONSE_MSG), + } + + if user_input is None: + user_input = {} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_COUNTRY_CODE, + default=user_input.get(CONF_COUNTRY_CODE, "United States"), + ): vol.In( + # We don't pass a dict {code:name} because country codes can be duplicate. + [country.name for country in TUYA_COUNTRIES] + ), + vol.Required( + CONF_ACCESS_ID, default=user_input.get(CONF_ACCESS_ID, "") + ): str, + vol.Required( + CONF_ACCESS_SECRET, + default=user_input.get(CONF_ACCESS_SECRET, ""), + ): str, + vol.Required( + CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") + ): str, + vol.Required( + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + ): str, + } + ), + errors=errors, + description_placeholders=placeholders, + ) diff --git a/custom_components/tuya_openapi/const.py b/custom_components/tuya_openapi/const.py new file mode 100644 index 0000000..c850a64 --- /dev/null +++ b/custom_components/tuya_openapi/const.py @@ -0,0 +1,853 @@ +"""Constants for the Tuya integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import StrEnum +import logging + +from tuya_iot import TuyaCloudOpenAPIEndpoint + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + LIGHT_LUX, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + Platform, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfPressure, + UnitOfTemperature, + UnitOfVolume, +) + +DOMAIN = "tuya" +LOGGER = logging.getLogger(__package__) + +CONF_AUTH_TYPE = "auth_type" +CONF_PROJECT_TYPE = "tuya_project_type" +CONF_ENDPOINT = "endpoint" +CONF_ACCESS_ID = "access_id" +CONF_ACCESS_SECRET = "access_secret" +CONF_USERNAME = "username" +CONF_PASSWORD = "password" +CONF_COUNTRY_CODE = "country_code" +CONF_APP_TYPE = "tuya_app_type" + +TUYA_DISCOVERY_NEW = "tuya_discovery_new" +TUYA_HA_SIGNAL_UPDATE_ENTITY = "tuya_entry_update" + +TUYA_RESPONSE_CODE = "code" +TUYA_RESPONSE_RESULT = "result" +TUYA_RESPONSE_MSG = "msg" +TUYA_RESPONSE_SUCCESS = "success" +TUYA_RESPONSE_PLATFORM_URL = "platform_url" + +TUYA_SMART_APP = "tuyaSmart" +SMARTLIFE_APP = "smartlife" + +PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CAMERA, + Platform.CLIMATE, + Platform.COVER, + Platform.FAN, + Platform.HUMIDIFIER, + Platform.LIGHT, + Platform.NUMBER, + Platform.SCENE, + Platform.SELECT, + Platform.SENSOR, + Platform.SIREN, + Platform.SWITCH, + Platform.VACUUM, +] + + +class WorkMode(StrEnum): + """Work modes.""" + + COLOUR = "colour" + MUSIC = "music" + SCENE = "scene" + WHITE = "white" + + +class DPType(StrEnum): + """Data point types.""" + + BOOLEAN = "Boolean" + ENUM = "Enum" + INTEGER = "Integer" + JSON = "Json" + RAW = "Raw" + STRING = "String" + + +class DPCode(StrEnum): + """Data Point Codes used by Tuya. + + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + """ + + AIR_QUALITY = "air_quality" + ALARM_SWITCH = "alarm_switch" # Alarm switch + ALARM_TIME = "alarm_time" # Alarm time + ALARM_VOLUME = "alarm_volume" # Alarm volume + ALARM_MESSAGE = "alarm_message" + ANGLE_HORIZONTAL = "angle_horizontal" + ANGLE_VERTICAL = "angle_vertical" + ANION = "anion" # Ionizer unit + ARM_DOWN_PERCENT = "arm_down_percent" + ARM_UP_PERCENT = "arm_up_percent" + BASIC_ANTI_FLICKER = "basic_anti_flicker" + BASIC_DEVICE_VOLUME = "basic_device_volume" + BASIC_FLIP = "basic_flip" + BASIC_INDICATOR = "basic_indicator" + BASIC_NIGHTVISION = "basic_nightvision" + BASIC_OSD = "basic_osd" + BASIC_PRIVATE = "basic_private" + BASIC_WDR = "basic_wdr" + BATTERY_PERCENTAGE = "battery_percentage" # Battery percentage + BATTERY_STATE = "battery_state" # Battery state + BATTERY_VALUE = "battery_value" # Battery value + BEEP_STATUS = "beep_status" + BRIGHT_CONTROLLER = "bright_controller" + BRIGHT_STATE = "bright_state" # Brightness status + BRIGHT_VALUE = "bright_value" # Brightness + BRIGHT_VALUE_1 = "bright_value_1" + BRIGHT_VALUE_2 = "bright_value_2" + BRIGHT_VALUE_3 = "bright_value_3" + BRIGHT_VALUE_V2 = "bright_value_v2" + BRIGHTNESS_MAX_1 = "brightness_max_1" + BRIGHTNESS_MAX_2 = "brightness_max_2" + BRIGHTNESS_MAX_3 = "brightness_max_3" + BRIGHTNESS_MIN_1 = "brightness_min_1" + BRIGHTNESS_MIN_2 = "brightness_min_2" + BRIGHTNESS_MIN_3 = "brightness_min_3" + C_F = "c_f" # Temperature unit switching + CHANNEL_CONFIGURATION_PARAMETERS = "ch_cfg" + CHANNEL_PARA = "ch_para" + CHANNEL_0 = "ch_0" + CHANNEL_1 = "ch_1" + CHANNEL_2 = "ch_2" + CHANNEL_3 = "ch_3" + CHANNEL_4 = "ch_4" + CHANNEL_5 = "ch_5" + CHANNEL_6 = "ch_6" + CHANNEL_7 = "ch_7" + CHANNEL_8 = "ch_8" + CHANNEL_9 = "ch_9" + CHANNEL_0_ALARM = "ch0_alarm" + CHANNEL_1_ALARM = "ch1_alarm" + CHANNEL_2_ALARM = "ch2_alarm" + CHANNEL_3_ALARM = "ch3_alarm" + CHANNEL_4_ALARM = "ch4_alarm" + CHANNEL_5_ALARM = "ch5_alarm" + CHANNEL_6_ALARM = "ch6_alarm" + CHANNEL_7_ALARM = "ch7_alarm" + CHANNEL_8_ALARM = "ch8_alarm" + CHANNEL_9_ALARM = "ch9_alarm" + CH2O_STATE = "ch2o_state" + CH2O_VALUE = "ch2o_value" + CH4_SENSOR_STATE = "ch4_sensor_state" + CH4_SENSOR_VALUE = "ch4_sensor_value" + CHILD_LOCK = "child_lock" # Child lock + CISTERN = "cistern" + CLEAN_AREA = "clean_area" + CLEAN_TIME = "clean_time" + CLICK_SUSTAIN_TIME = "click_sustain_time" + CLOUD_RECIPE_NUMBER = "cloud_recipe_number" + CLOSED_OPENED_KIT = "closed_opened_kit" + CO_STATE = "co_state" + CO_STATUS = "co_status" + CO_VALUE = "co_value" + CO2_STATE = "co2_state" + CO2_VALUE = "co2_value" # CO2 concentration + COLLECTION_MODE = "collection_mode" + COLOR_DATA_V2 = "color_data_v2" + COLOUR_DATA = "colour_data" # Colored light mode + COLOUR_DATA_HSV = "colour_data_hsv" # Colored light mode + COLOUR_DATA_V2 = "colour_data_v2" # Colored light mode + COOK_TEMPERATURE = "cook_temperature" + COOK_TIME = "cook_time" + CONCENTRATION_SET = "concentration_set" # Concentration setting + CONTROL = "control" + CONTROL_2 = "control_2" + CONTROL_3 = "control_3" + CONTROL_BACK = "control_back" + CONTROL_BACK_MODE = "control_back_mode" + COUNTDOWN = "countdown" # Countdown + COUNTDOWN_LEFT = "countdown_left" + COUNTDOWN_SET = "countdown_set" # Countdown setting + CRY_DETECTION_SWITCH = "cry_detection_switch" + 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 + DECIBEL_SENSITIVITY = "decibel_sensitivity" + DECIBEL_SWITCH = "decibel_switch" + DEHUMIDITY_SET_ENUM = "dehumidify_set_enum" + DEHUMIDITY_SET_VALUE = "dehumidify_set_value" + DISINFECTION = "disinfection" + DO_NOT_DISTURB = "do_not_disturb" + DOORCONTACT_STATE = "doorcontact_state" # Status of door window sensor + DOORCONTACT_STATE_2 = "doorcontact_state_2" + DOORCONTACT_STATE_3 = "doorcontact_state_3" + DUSTER_CLOTH = "duster_cloth" + ECO2 = "eco2" + EDGE_BRUSH = "edge_brush" + ELECTRICITY_LEFT = "electricity_left" + FAN_BEEP = "fan_beep" # Sound + FAN_COOL = "fan_cool" # Cool wind + FAN_DIRECTION = "fan_direction" # Fan direction + FAN_HORIZONTAL = "fan_horizontal" # Horizontal swing flap angle + FAN_SPEED = "fan_speed" + FAN_SPEED_ENUM = "fan_speed_enum" # Speed mode + FAN_SPEED_PERCENT = "fan_speed_percent" # Stepless speed + FAN_SWITCH = "fan_switch" + FAN_MODE = "fan_mode" + FAN_VERTICAL = "fan_vertical" # Vertical swing flap angle + FAR_DETECTION = "far_detection" + FAULT = "fault" + FEED_REPORT = "feed_report" + FEED_STATE = "feed_state" + FILTER = "filter" + FILTER_LIFE = "filter" + FILTER_RESET = "filter_reset" # Filter (cartridge) reset + FLOODLIGHT_LIGHTNESS = "floodlight_lightness" + FLOODLIGHT_SWITCH = "floodlight_switch" + FORWARD_ENERGY_TOTAL = "forward_energy_total" + GAS_SENSOR_STATE = "gas_sensor_state" + GAS_SENSOR_STATUS = "gas_sensor_status" + GAS_SENSOR_VALUE = "gas_sensor_value" + HUMIDIFIER = "humidifier" # Humidification + HUMIDITY = "humidity" # Humidity + HUMIDITY_CURRENT = "humidity_current" # Current humidity + HUMIDITY_INDOOR = "humidity_indoor" # Indoor humidity + HUMIDITY_SET = "humidity_set" # Humidity setting + HUMIDITY_VALUE = "humidity_value" # Humidity + IPC_WORK_MODE = "ipc_work_mode" + LED_TYPE_1 = "led_type_1" + LED_TYPE_2 = "led_type_2" + LED_TYPE_3 = "led_type_3" + LEVEL = "level" + LEVEL_CURRENT = "level_current" + LIGHT = "light" # Light + LIGHT_MODE = "light_mode" + LOCK = "lock" # Lock / Child lock + MASTER_MODE = "master_mode" # alarm mode + MACH_OPERATE = "mach_operate" + MANUAL_FEED = "manual_feed" + MATERIAL = "material" # Material + MODE = "mode" # Working mode / Mode + MOODLIGHTING = "moodlighting" # Mood light + MOTION_RECORD = "motion_record" + MOTION_SENSITIVITY = "motion_sensitivity" + MOTION_SWITCH = "motion_switch" # Motion switch + MOTION_TRACKING = "motion_tracking" + MOVEMENT_DETECT_PIC = "movement_detect_pic" + MUFFLING = "muffling" # Muffling + NEAR_DETECTION = "near_detection" + OPPOSITE = "opposite" + 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" + POSITION = "position" + PHASE_A = "phase_a" + PHASE_B = "phase_b" + PHASE_C = "phase_c" + PIR = "pir" # Motion sensor + PM1 = "pm1" + PM10 = "pm10" + PM25 = "pm25" + PM25_STATE = "pm25_state" + PM25_VALUE = "pm25_value" + POWDER_SET = "powder_set" # Powder + POWER = "power" + POWER_GO = "power_go" + PRESENCE_STATE = "presence_state" + PRESSURE_STATE = "pressure_state" + PRESSURE_VALUE = "pressure_value" + PUMP_RESET = "pump_reset" # Water pump reset + OXYGEN = "oxygen" # Oxygen bar + RECORD_MODE = "record_mode" + RECORD_SWITCH = "record_switch" # Recording switch + RELAY_STATUS = "relay_status" + REMAIN_TIME = "remain_time" + RESET_DUSTER_CLOTH = "reset_duster_cloth" + RESET_EDGE_BRUSH = "reset_edge_brush" + RESET_FILTER = "reset_filter" + RESET_MAP = "reset_map" + RESET_ROLL_BRUSH = "reset_roll_brush" + ROLL_BRUSH = "roll_brush" + SEEK = "seek" + SENSITIVITY = "sensitivity" # Sensitivity + SENSOR_HUMIDITY = "sensor_humidity" + SENSOR_TEMPERATURE = "sensor_temperature" + SHAKE = "shake" # Oscillating + SHOCK_STATE = "shock_state" # Vibration status + SIGNAL_STRENGTH = "signal_strength" + SIREN_SWITCH = "siren_switch" + SITUATION_SET = "situation_set" + SLEEP = "sleep" # Sleep function + SLOW_FEED = "slow_feed" + SMOKE_SENSOR_STATE = "smoke_sensor_state" + SMOKE_SENSOR_STATUS = "smoke_sensor_status" + SMOKE_SENSOR_VALUE = "smoke_sensor_value" + SOS = "sos" # Emergency State + SOS_STATE = "sos_state" # Emergency mode + SPEED = "speed" # Speed level + SPRAY_MODE = "spray_mode" # Spraying mode + START = "start" # Start + STATUS = "status" + STERILIZATION = "sterilization" # Sterilization + SUCTION = "suction" + SWING = "swing" # Swing mode + SWITCH = "switch" # Switch + SWITCH_1 = "switch_1" # Switch 1 + SWITCH_2 = "switch_2" # Switch 2 + SWITCH_3 = "switch_3" # Switch 3 + SWITCH_4 = "switch_4" # Switch 4 + SWITCH_5 = "switch_5" # Switch 5 + SWITCH_6 = "switch_6" # Switch 6 + SWITCH_7 = "switch_7" # Switch 7 + SWITCH_8 = "switch_8" # Switch 8 + SWITCH_BACKLIGHT = "switch_backlight" # Backlight switch + SWITCH_CHARGE = "switch_charge" + SWITCH_CONTROLLER = "switch_controller" + SWITCH_DISTURB = "switch_disturb" + SWITCH_FAN = "switch_fan" + SWITCH_HORIZONTAL = "switch_horizontal" # Horizontal swing flap switch + SWITCH_LED = "switch_led" # Switch + SWITCH_LED_1 = "switch_led_1" + SWITCH_LED_2 = "switch_led_2" + SWITCH_LED_3 = "switch_led_3" + SWITCH_NIGHT_LIGHT = "switch_night_light" + SWITCH_SAVE_ENERGY = "switch_save_energy" + SWITCH_SOUND = "switch_sound" # Voice switch + SWITCH_SPRAY = "switch_spray" # Spraying switch + SWITCH_USB1 = "switch_usb1" # USB 1 + SWITCH_USB2 = "switch_usb2" # USB 2 + SWITCH_USB3 = "switch_usb3" # USB 3 + SWITCH_USB4 = "switch_usb4" # USB 4 + SWITCH_USB5 = "switch_usb5" # USB 5 + SWITCH_USB6 = "switch_usb6" # USB 6 + SWITCH_VERTICAL = "switch_vertical" # Vertical swing flap switch + SWITCH_VOICE = "switch_voice" # Voice switch + TEMP = "temp" # Temperature setting + TEMP_BOILING_C = "temp_boiling_c" + TEMP_BOILING_F = "temp_boiling_f" + TEMP_CONTROLLER = "temp_controller" + TEMP_CURRENT = "temp_current" # Current temperature in °C + TEMP_CURRENT_F = "temp_current_f" # Current temperature in °F + TEMP_INDOOR = "temp_indoor" # Indoor temperature in °C + TEMP_SET = "temp_set" # Set the temperature in °C + TEMP_SET_F = "temp_set_f" # Set the temperature in °F + TEMP_UNIT_CONVERT = "temp_unit_convert" # Temperature unit switching + TEMP_VALUE = "temp_value" # Color temperature + TEMP_VALUE_V2 = "temp_value_v2" + TEMPER_ALARM = "temper_alarm" # Tamper alarm + TIME_TOTAL = "time_total" + TOTAL_CLEAN_AREA = "total_clean_area" + TOTAL_CLEAN_COUNT = "total_clean_count" + TOTAL_CLEAN_TIME = "total_clean_time" + TOTAL_FORWARD_ENERGY = "total_forward_energy" + TOTAL_TIME = "total_time" + TOTAL_PM = "total_pm" + TVOC = "tvoc" + UPPER_TEMP = "upper_temp" + UPPER_TEMP_F = "upper_temp_f" + UV = "uv" # UV sterilization + VA_BATTERY = "va_battery" + VA_HUMIDITY = "va_humidity" + VA_TEMPERATURE = "va_temperature" + VOC_STATE = "voc_state" + VOC_VALUE = "voc_value" + VOICE_SWITCH = "voice_switch" + VOICE_TIMES = "voice_times" + VOLUME_SET = "volume_set" + WARM = "warm" # Heat preservation + WARM_TIME = "warm_time" # Heat preservation time + WATER = "water" + WATER_RESET = "water_reset" # Resetting of water usage days + WATER_SET = "water_set" # Water level + WATERSENSOR_STATE = "watersensor_state" + WET = "wet" # Humidification + WINDOW_CHECK = "window_check" + WINDOW_STATE = "window_state" + WINDSPEED = "windspeed" + WIRELESS_BATTERYLOCK = "wireless_batterylock" + WIRELESS_ELECTRICITY = "wireless_electricity" + WORK_MODE = "work_mode" # Working mode + WORK_POWER = "work_power" + + +@dataclass +class UnitOfMeasurement: + """Describes a unit of measurement.""" + + unit: str + device_classes: set[str] + + aliases: set[str] = field(default_factory=set) + conversion_unit: str | None = None + conversion_fn: Callable[[float], float] | None = None + + +# A tuple of available units of measurements we can work with. +# Tuya's devices aren't consistent in UOM use, thus this provides +# a list of aliases for units and possible conversions we can do +# to make them compatible with our model. +UNITS = ( + UnitOfMeasurement( + unit="", + aliases={" "}, + device_classes={ + SensorDeviceClass.AQI, + SensorDeviceClass.DATE, + SensorDeviceClass.MONETARY, + SensorDeviceClass.TIMESTAMP, + }, + ), + UnitOfMeasurement( + unit=PERCENTAGE, + aliases={"pct", "percent", "% RH"}, + device_classes={ + SensorDeviceClass.BATTERY, + SensorDeviceClass.HUMIDITY, + SensorDeviceClass.POWER_FACTOR, + }, + ), + UnitOfMeasurement( + unit=CONCENTRATION_PARTS_PER_MILLION, + device_classes={ + SensorDeviceClass.CO, + SensorDeviceClass.CO2, + }, + ), + UnitOfMeasurement( + unit=CONCENTRATION_PARTS_PER_BILLION, + device_classes={ + SensorDeviceClass.CO, + SensorDeviceClass.CO2, + }, + conversion_unit=CONCENTRATION_PARTS_PER_MILLION, + conversion_fn=lambda x: x / 1000, + ), + UnitOfMeasurement( + unit=UnitOfElectricCurrent.AMPERE, + aliases={"a", "ampere"}, + device_classes={SensorDeviceClass.CURRENT}, + ), + UnitOfMeasurement( + unit=UnitOfElectricCurrent.MILLIAMPERE, + aliases={"ma", "milliampere"}, + device_classes={SensorDeviceClass.CURRENT}, + conversion_unit=UnitOfElectricCurrent.AMPERE, + conversion_fn=lambda x: x / 1000, + ), + UnitOfMeasurement( + unit=UnitOfEnergy.WATT_HOUR, + aliases={"wh", "watthour"}, + device_classes={SensorDeviceClass.ENERGY}, + ), + UnitOfMeasurement( + unit=UnitOfEnergy.KILO_WATT_HOUR, + aliases={"kwh", "kilowatt-hour", "kW·h"}, + device_classes={SensorDeviceClass.ENERGY}, + ), + UnitOfMeasurement( + unit=UnitOfVolume.CUBIC_FEET, + aliases={"ft3"}, + device_classes={SensorDeviceClass.GAS}, + ), + UnitOfMeasurement( + unit=UnitOfVolume.CUBIC_METERS, + aliases={"m3"}, + device_classes={SensorDeviceClass.GAS}, + ), + UnitOfMeasurement( + unit=LIGHT_LUX, + aliases={"lux"}, + device_classes={SensorDeviceClass.ILLUMINANCE}, + ), + UnitOfMeasurement( + unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + aliases={"ug/m3", "µg/m3", "ug/m³"}, + device_classes={ + SensorDeviceClass.NITROGEN_DIOXIDE, + SensorDeviceClass.NITROGEN_MONOXIDE, + SensorDeviceClass.NITROUS_OXIDE, + SensorDeviceClass.OZONE, + SensorDeviceClass.PM1, + SensorDeviceClass.PM25, + SensorDeviceClass.PM10, + SensorDeviceClass.SULPHUR_DIOXIDE, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + }, + ), + UnitOfMeasurement( + unit=CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + aliases={"mg/m3"}, + device_classes={ + SensorDeviceClass.NITROGEN_DIOXIDE, + SensorDeviceClass.NITROGEN_MONOXIDE, + SensorDeviceClass.NITROUS_OXIDE, + SensorDeviceClass.OZONE, + SensorDeviceClass.PM1, + SensorDeviceClass.PM25, + SensorDeviceClass.PM10, + SensorDeviceClass.SULPHUR_DIOXIDE, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + }, + conversion_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + conversion_fn=lambda x: x * 1000, + ), + UnitOfMeasurement( + unit=UnitOfPower.WATT, + aliases={"watt"}, + device_classes={SensorDeviceClass.POWER}, + ), + UnitOfMeasurement( + unit=UnitOfPower.KILO_WATT, + aliases={"kilowatt"}, + device_classes={SensorDeviceClass.POWER}, + ), + UnitOfMeasurement( + unit=UnitOfPressure.BAR, + device_classes={SensorDeviceClass.PRESSURE}, + ), + UnitOfMeasurement( + unit=UnitOfPressure.MBAR, + aliases={"millibar"}, + device_classes={SensorDeviceClass.PRESSURE}, + ), + UnitOfMeasurement( + unit=UnitOfPressure.HPA, + aliases={"hpa", "hectopascal"}, + device_classes={SensorDeviceClass.PRESSURE}, + ), + UnitOfMeasurement( + unit=UnitOfPressure.INHG, + aliases={"inhg"}, + device_classes={SensorDeviceClass.PRESSURE}, + ), + UnitOfMeasurement( + unit=UnitOfPressure.PSI, + device_classes={SensorDeviceClass.PRESSURE}, + ), + UnitOfMeasurement( + unit=UnitOfPressure.PA, + device_classes={SensorDeviceClass.PRESSURE}, + ), + UnitOfMeasurement( + unit=SIGNAL_STRENGTH_DECIBELS, + aliases={"db"}, + device_classes={SensorDeviceClass.SIGNAL_STRENGTH}, + ), + UnitOfMeasurement( + unit=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + aliases={"dbm"}, + device_classes={SensorDeviceClass.SIGNAL_STRENGTH}, + ), + UnitOfMeasurement( + unit=UnitOfTemperature.CELSIUS, + aliases={"°c", "c", "celsius", "℃"}, + device_classes={SensorDeviceClass.TEMPERATURE}, + ), + UnitOfMeasurement( + unit=UnitOfTemperature.FAHRENHEIT, + aliases={"°f", "f", "fahrenheit"}, + device_classes={SensorDeviceClass.TEMPERATURE}, + ), + UnitOfMeasurement( + unit=UnitOfElectricPotential.VOLT, + aliases={"volt"}, + device_classes={SensorDeviceClass.VOLTAGE}, + ), + UnitOfMeasurement( + unit=UnitOfElectricPotential.MILLIVOLT, + aliases={"mv", "millivolt"}, + device_classes={SensorDeviceClass.VOLTAGE}, + conversion_unit=UnitOfElectricPotential.VOLT, + conversion_fn=lambda x: x / 1000, + ), +) + + +DEVICE_CLASS_UNITS: dict[str, dict[str, UnitOfMeasurement]] = {} +for uom in UNITS: + for device_class in uom.device_classes: + DEVICE_CLASS_UNITS.setdefault(device_class, {})[uom.unit] = uom + for unit_alias in uom.aliases: + DEVICE_CLASS_UNITS[device_class][unit_alias] = uom + + +@dataclass +class Country: + """Describe a supported country.""" + + name: str + country_code: str + endpoint: str = TuyaCloudOpenAPIEndpoint.AMERICA + + +# https://developer.tuya.com/en/docs/iot/oem-app-data-center-distributed?id=Kafi0ku9l07qb +TUYA_COUNTRIES = [ + Country("Afghanistan", "93", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Albania", "355", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Algeria", "213", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("American Samoa", "1-684", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Andorra", "376", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Angola", "244", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Anguilla", "1-264", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Antarctica", "672", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Antigua and Barbuda", "1-268", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Argentina", "54", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Armenia", "374", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Aruba", "297", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Australia", "61", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Austria", "43", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Azerbaijan", "994", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Bahamas", "1-242", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Bahrain", "973", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Bangladesh", "880", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Barbados", "1-246", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Belarus", "375", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Belgium", "32", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Belize", "501", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Benin", "229", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Bermuda", "1-441", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Bhutan", "975", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Bolivia", "591", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Bosnia and Herzegovina", "387", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Botswana", "267", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Brazil", "55", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("British Indian Ocean Territory", "246", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("British Virgin Islands", "1-284", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Brunei", "673", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Bulgaria", "359", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Burkina Faso", "226", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Burundi", "257", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Cambodia", "855", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Cameroon", "237", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Canada", "1", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Capo Verde", "238", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Cayman Islands", "1-345", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Central African Republic", "236", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Chad", "235", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Chile", "56", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("China", "86", TuyaCloudOpenAPIEndpoint.CHINA), + Country("Christmas Island", "61"), + Country("Cocos Islands", "61"), + Country("Colombia", "57", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Comoros", "269", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Cook Islands", "682", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Costa Rica", "506", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Croatia", "385", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Cuba", "53"), + Country("Curacao", "599", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Cyprus", "357", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Czech Republic", "420", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Democratic Republic of the Congo", "243", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Denmark", "45", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Djibouti", "253", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Dominica", "1-767", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Dominican Republic", "1-809", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("East Timor", "670", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Ecuador", "593", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Egypt", "20", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("El Salvador", "503", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Equatorial Guinea", "240", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Eritrea", "291", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Estonia", "372", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Ethiopia", "251", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Falkland Islands", "500", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Faroe Islands", "298", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Fiji", "679", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Finland", "358", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("France", "33", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("French Polynesia", "689", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Gabon", "241", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Gambia", "220", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Georgia", "995", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Germany", "49", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Ghana", "233", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Gibraltar", "350", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Greece", "30", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Greenland", "299", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Grenada", "1-473", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Guam", "1-671", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Guatemala", "502", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Guernsey", "44-1481"), + Country("Guinea", "224"), + Country("Guinea-Bissau", "245", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Guyana", "592", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Haiti", "509", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Honduras", "504", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Hong Kong", "852", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Hungary", "36", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Iceland", "354", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("India", "91", TuyaCloudOpenAPIEndpoint.INDIA), + Country("Indonesia", "62", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Iran", "98"), + Country("Iraq", "964", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Ireland", "353", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Isle of Man", "44-1624"), + Country("Israel", "972", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Italy", "39", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Ivory Coast", "225", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Jamaica", "1-876", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Japan", "81", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Jersey", "44-1534"), + Country("Jordan", "962", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Kazakhstan", "7", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Kenya", "254", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Kiribati", "686", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Kosovo", "383"), + Country("Kuwait", "965", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Kyrgyzstan", "996", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Laos", "856", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Latvia", "371", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Lebanon", "961", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Lesotho", "266", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Liberia", "231", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Libya", "218", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Liechtenstein", "423", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Lithuania", "370", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Luxembourg", "352", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Macao", "853", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Macedonia", "389", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Madagascar", "261", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Malawi", "265", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Malaysia", "60", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Maldives", "960", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Mali", "223", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Malta", "356", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Marshall Islands", "692", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Mauritania", "222", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Mauritius", "230", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Mayotte", "262", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Mexico", "52", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Micronesia", "691", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Moldova", "373", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Monaco", "377", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Mongolia", "976", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Montenegro", "382", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Montserrat", "1-664", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Morocco", "212", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Mozambique", "258", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Myanmar", "95", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Namibia", "264", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Nauru", "674", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Nepal", "977", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Netherlands", "31", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Netherlands Antilles", "599"), + Country("New Caledonia", "687", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("New Zealand", "64", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Nicaragua", "505", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Niger", "227", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Nigeria", "234", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Niue", "683", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("North Korea", "850"), + Country("Northern Mariana Islands", "1-670", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Norway", "47", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Oman", "968", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Pakistan", "92", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Palau", "680", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Palestine", "970", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Panama", "507", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Papua New Guinea", "675", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Paraguay", "595", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Peru", "51", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Philippines", "63", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Pitcairn", "64"), + Country("Poland", "48", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Portugal", "351", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Puerto Rico", "1-787, 1-939", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Qatar", "974", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Republic of the Congo", "242", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Reunion", "262", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Romania", "40", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Russia", "7", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Rwanda", "250", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Saint Barthelemy", "590", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Saint Helena", "290"), + Country("Saint Kitts and Nevis", "1-869", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Saint Lucia", "1-758", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Saint Martin", "590", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Saint Pierre and Miquelon", "508", TuyaCloudOpenAPIEndpoint.EUROPE), + Country( + "Saint Vincent and the Grenadines", "1-784", TuyaCloudOpenAPIEndpoint.EUROPE + ), + Country("Samoa", "685", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("San Marino", "378", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Sao Tome and Principe", "239", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Saudi Arabia", "966", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Senegal", "221", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Serbia", "381", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Seychelles", "248", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Sierra Leone", "232", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Singapore", "65", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Sint Maarten", "1-721", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Slovakia", "421", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Slovenia", "386", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Solomon Islands", "677", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Somalia", "252", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("South Africa", "27", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("South Korea", "82", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("South Sudan", "211"), + Country("Spain", "34", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Sri Lanka", "94", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Sudan", "249"), + Country("Suriname", "597", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Svalbard and Jan Mayen", "4779", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Swaziland", "268", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Sweden", "46", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Switzerland", "41", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Syria", "963"), + Country("Taiwan", "886", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Tajikistan", "992", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Tanzania", "255", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Thailand", "66", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Togo", "228", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Tokelau", "690", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Tonga", "676", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Trinidad and Tobago", "1-868", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Tunisia", "216", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Turkey", "90", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Turkmenistan", "993", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Turks and Caicos Islands", "1-649", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Tuvalu", "688", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("U.S. Virgin Islands", "1-340", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Uganda", "256", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Ukraine", "380", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("United Arab Emirates", "971", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("United Kingdom", "44", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("United States", "1", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Uruguay", "598", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Uzbekistan", "998", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Vanuatu", "678", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Vatican", "379", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Venezuela", "58", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Vietnam", "84", TuyaCloudOpenAPIEndpoint.AMERICA), + Country("Wallis and Futuna", "681", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Western Sahara", "212", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Yemen", "967", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Zambia", "260", TuyaCloudOpenAPIEndpoint.EUROPE), + Country("Zimbabwe", "263", TuyaCloudOpenAPIEndpoint.EUROPE), +] diff --git a/custom_components/tuya_openapi/cover.py b/custom_components/tuya_openapi/cover.py new file mode 100644 index 0000000..da9f7d2 --- /dev/null +++ b/custom_components/tuya_openapi/cover.py @@ -0,0 +1,382 @@ +"""Support for Tuya Cover.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityDescription, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeAssistantTuyaData +from .base import IntegerTypeData, TuyaEntity +from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType + + +@dataclass +class TuyaCoverEntityDescription(CoverEntityDescription): + """Describe an Tuya cover entity.""" + + current_state: DPCode | None = None + current_state_inverse: bool = False + current_position: DPCode | tuple[DPCode, ...] | None = None + set_position: DPCode | None = None + open_instruction_value: str = "open" + close_instruction_value: str = "close" + stop_instruction_value: str = "stop" + + +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, + translation_key="curtain", + current_state=DPCode.SITUATION_SET, + current_position=(DPCode.PERCENT_CONTROL, DPCode.PERCENT_STATE), + set_position=DPCode.PERCENT_CONTROL, + device_class=CoverDeviceClass.CURTAIN, + ), + TuyaCoverEntityDescription( + key=DPCode.CONTROL_2, + translation_key="curtain_2", + current_position=DPCode.PERCENT_STATE_2, + set_position=DPCode.PERCENT_CONTROL_2, + device_class=CoverDeviceClass.CURTAIN, + ), + TuyaCoverEntityDescription( + key=DPCode.CONTROL_3, + translation_key="curtain_3", + current_position=DPCode.PERCENT_STATE_3, + set_position=DPCode.PERCENT_CONTROL_3, + device_class=CoverDeviceClass.CURTAIN, + ), + TuyaCoverEntityDescription( + key=DPCode.MACH_OPERATE, + translation_key="curtain", + current_position=DPCode.POSITION, + set_position=DPCode.POSITION, + device_class=CoverDeviceClass.CURTAIN, + open_instruction_value="FZ", + close_instruction_value="ZZ", + stop_instruction_value="STOP", + ), + # switch_1 is an undocumented code that behaves identically to control + # It is used by the Kogan Smart Blinds Driver + TuyaCoverEntityDescription( + key=DPCode.SWITCH_1, + translation_key="blind", + current_position=DPCode.PERCENT_CONTROL, + set_position=DPCode.PERCENT_CONTROL, + device_class=CoverDeviceClass.BLIND, + ), + ), + # Garage Door Opener + # https://developer.tuya.com/en/docs/iot/categoryckmkzq?id=Kaiuz0ipcboee + "ckmkzq": ( + TuyaCoverEntityDescription( + key=DPCode.SWITCH_1, + translation_key="door", + current_state=DPCode.DOORCONTACT_STATE, + current_state_inverse=True, + device_class=CoverDeviceClass.GARAGE, + ), + TuyaCoverEntityDescription( + key=DPCode.SWITCH_2, + translation_key="door_2", + current_state=DPCode.DOORCONTACT_STATE_2, + current_state_inverse=True, + device_class=CoverDeviceClass.GARAGE, + ), + TuyaCoverEntityDescription( + key=DPCode.SWITCH_3, + translation_key="door_3", + current_state=DPCode.DOORCONTACT_STATE_3, + current_state_inverse=True, + device_class=CoverDeviceClass.GARAGE, + ), + ), + # Curtain Switch + # https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39 + "clkg": ( + TuyaCoverEntityDescription( + key=DPCode.CONTROL, + translation_key="curtain", + current_position=DPCode.PERCENT_CONTROL, + set_position=DPCode.PERCENT_CONTROL, + device_class=CoverDeviceClass.CURTAIN, + ), + TuyaCoverEntityDescription( + key=DPCode.CONTROL_2, + translation_key="curtain_2", + current_position=DPCode.PERCENT_CONTROL_2, + set_position=DPCode.PERCENT_CONTROL_2, + device_class=CoverDeviceClass.CURTAIN, + ), + ), + # Curtain Robot + # Note: Not documented + "jdcljqr": ( + TuyaCoverEntityDescription( + key=DPCode.CONTROL, + translation_key="curtain", + current_position=DPCode.PERCENT_STATE, + set_position=DPCode.PERCENT_CONTROL, + device_class=CoverDeviceClass.CURTAIN, + ), + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> 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_range + ): + 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: IntegerTypeData | None = None + _set_position: IntegerTypeData | None = None + _tilt: 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 = CoverEntityFeature(0) + + # Check if this cover is based on a switch or has controls + if self.find_dpcode(description.key, prefer_function=True): + if device.function[description.key].type == "Boolean": + self._attr_supported_features |= ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) + elif enum_type := self.find_dpcode( + description.key, dptype=DPType.ENUM, prefer_function=True + ): + if description.open_instruction_value in enum_type.range: + self._attr_supported_features |= CoverEntityFeature.OPEN + if description.close_instruction_value in enum_type.range: + self._attr_supported_features |= CoverEntityFeature.CLOSE + if description.stop_instruction_value in enum_type.range: + self._attr_supported_features |= CoverEntityFeature.STOP + + # Determine type to use for setting the position + if int_type := self.find_dpcode( + description.set_position, dptype=DPType.INTEGER, prefer_function=True + ): + self._attr_supported_features |= CoverEntityFeature.SET_POSITION + self._set_position = int_type + # Set as default, unless overwritten below + self._current_position = int_type + + # Determine type for getting the position + if int_type := self.find_dpcode( + description.current_position, dptype=DPType.INTEGER, prefer_function=True + ): + self._current_position = int_type + + # Determine type to use for setting the tilt + if int_type := self.find_dpcode( + (DPCode.ANGLE_HORIZONTAL, DPCode.ANGLE_VERTICAL), + dptype=DPType.INTEGER, + prefer_function=True, + ): + self._attr_supported_features |= CoverEntityFeature.SET_TILT_POSITION + self._tilt = int_type + + @property + def current_cover_position(self) -> int | None: + """Return cover current position.""" + if self._current_position is None: + return None + + if (position := self.device.status.get(self._current_position.dpcode)) is None: + return None + + return round( + self._current_position.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 is None: + return None + + if (angle := self.device.status.get(self._tilt.dpcode)) is None: + return None + + return round(self._tilt.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 (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.find_dpcode( + self.entity_description.key, dptype=DPType.ENUM, prefer_function=True + ): + value = self.entity_description.open_instruction_value + + commands: list[dict[str, str | int]] = [ + {"code": self.entity_description.key, "value": value} + ] + + if self._set_position is not None: + commands.append( + { + "code": self._set_position.dpcode, + "value": round( + self._set_position.remap_value_from(100, 0, 100, reverse=True), + ), + } + ) + + self._send_command(commands) + + def close_cover(self, **kwargs: Any) -> None: + """Close cover.""" + value: bool | str = False + if self.find_dpcode( + self.entity_description.key, dptype=DPType.ENUM, prefer_function=True + ): + value = self.entity_description.close_instruction_value + + commands: list[dict[str, str | int]] = [ + {"code": self.entity_description.key, "value": value} + ] + + if self._set_position is not None: + commands.append( + { + "code": self._set_position.dpcode, + "value": round( + self._set_position.remap_value_from(0, 0, 100, reverse=True), + ), + } + ) + + self._send_command(commands) + + def set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + if self._set_position is None: + raise RuntimeError( + "Cannot set position, device doesn't provide methods to set it" + ) + + self._send_command( + [ + { + "code": self._set_position.dpcode, + "value": round( + self._set_position.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": self.entity_description.stop_instruction_value, + } + ] + ) + + def set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover tilt to a specific position.""" + if self._tilt 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.remap_value_from( + kwargs[ATTR_TILT_POSITION], 0, 100, reverse=True + ) + ), + } + ] + ) diff --git a/custom_components/tuya_openapi/diagnostics.py b/custom_components/tuya_openapi/diagnostics.py new file mode 100644 index 0000000..4544169 --- /dev/null +++ b/custom_components/tuya_openapi/diagnostics.py @@ -0,0 +1,186 @@ +"""Diagnostics support for Tuya.""" +from __future__ import annotations + +from contextlib import suppress +import json +from typing import Any, cast + +from tuya_iot import TuyaDevice + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.util import dt as dt_util + +from . import HomeAssistantTuyaData +from .const import ( + CONF_APP_TYPE, + CONF_AUTH_TYPE, + CONF_COUNTRY_CODE, + CONF_ENDPOINT, + DOMAIN, + DPCode, +) + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return _async_get_diagnostics(hass, entry) + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device entry.""" + return _async_get_diagnostics(hass, entry, device) + + +@callback +def _async_get_diagnostics( + hass: HomeAssistant, + entry: ConfigEntry, + device: DeviceEntry | None = None, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + + mqtt_connected = None + if hass_data.home_manager.mq.client: + mqtt_connected = hass_data.home_manager.mq.client.is_connected() + + data = { + "endpoint": entry.data[CONF_ENDPOINT], + "auth_type": entry.data[CONF_AUTH_TYPE], + "country_code": entry.data[CONF_COUNTRY_CODE], + "app_type": entry.data[CONF_APP_TYPE], + "mqtt_connected": mqtt_connected, + "disabled_by": entry.disabled_by, + "disabled_polling": entry.pref_disable_polling, + } + + if device: + tuya_device_id = next(iter(device.identifiers))[1] + data |= _async_device_as_dict( + hass, hass_data.device_manager.device_map[tuya_device_id] + ) + else: + data.update( + devices=[ + _async_device_as_dict(hass, device) + for device in hass_data.device_manager.device_map.values() + ] + ) + + return data + + +@callback +def _async_device_as_dict(hass: HomeAssistant, device: TuyaDevice) -> dict[str, Any]: + """Represent a Tuya device as a dictionary.""" + + # Base device information, without sensitive information. + data = { + "name": device.name, + "model": device.model if hasattr(device, "model") else None, + "category": device.category, + "product_id": device.product_id, + "product_name": device.product_name, + "online": device.online, + "sub": device.sub, + "time_zone": device.time_zone, + "active_time": dt_util.utc_from_timestamp(device.active_time).isoformat(), + "create_time": dt_util.utc_from_timestamp(device.create_time).isoformat(), + "update_time": dt_util.utc_from_timestamp(device.update_time).isoformat(), + "function": {}, + "status_range": {}, + "status": {}, + "home_assistant": {}, + } + + # Gather Tuya states + for dpcode, value in device.status.items(): + # These statuses may contain sensitive information, redact these.. + if dpcode in {DPCode.ALARM_MESSAGE, DPCode.MOVEMENT_DETECT_PIC}: + data["status"][dpcode] = REDACTED + continue + + with suppress(ValueError, TypeError): + value = json.loads(value) + data["status"][dpcode] = value + + # Gather Tuya functions + for function in device.function.values(): + value = function.values + with suppress(ValueError, TypeError, AttributeError): + value = json.loads(cast(str, function.values)) + + data["function"][function.code] = { + "type": function.type, + "value": value, + } + + # Gather Tuya status ranges + for status_range in device.status_range.values(): + value = status_range.values + with suppress(ValueError, TypeError, AttributeError): + value = json.loads(status_range.values) + + data["status_range"][status_range.code] = { + "type": status_range.type, + "value": value, + } + + # Gather information how this Tuya device is represented in Home Assistant + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + hass_device = device_registry.async_get_device(identifiers={(DOMAIN, device.id)}) + if hass_device: + data["home_assistant"] = { + "name": hass_device.name, + "name_by_user": hass_device.name_by_user, + "disabled": hass_device.disabled, + "disabled_by": hass_device.disabled_by, + "entities": [], + } + + hass_entities = er.async_entries_for_device( + entity_registry, + device_id=hass_device.id, + include_disabled_entities=True, + ) + + for entity_entry in hass_entities: + state = hass.states.get(entity_entry.entity_id) + state_dict: dict[str, Any] | None = None + if state: + state_dict = dict(state.as_dict()) + + # Redact the `entity_picture` attribute as it contains a token. + if "entity_picture" in state_dict["attributes"]: + state_dict["attributes"] = { + **state_dict["attributes"], + "entity_picture": REDACTED, + } + + # The context doesn't provide useful information in this case. + state_dict.pop("context", None) + + data["home_assistant"]["entities"].append( + { + "disabled": entity_entry.disabled, + "disabled_by": entity_entry.disabled_by, + "entity_category": entity_entry.entity_category, + "device_class": entity_entry.device_class, + "original_device_class": entity_entry.original_device_class, + "icon": entity_entry.icon, + "original_icon": entity_entry.original_icon, + "unit_of_measurement": entity_entry.unit_of_measurement, + "state": state_dict, + } + ) + + return data diff --git a/custom_components/tuya_openapi/fan.py b/custom_components/tuya_openapi/fan.py new file mode 100644 index 0000000..210cc5c --- /dev/null +++ b/custom_components/tuya_openapi/fan.py @@ -0,0 +1,262 @@ +"""Support for Tuya Fan.""" +from __future__ import annotations + +from typing import Any + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.components.fan import ( + DIRECTION_FORWARD, + DIRECTION_REVERSE, + FanEntity, + FanEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) + +from . import HomeAssistantTuyaData +from .base import EnumTypeData, IntegerTypeData, TuyaEntity +from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType + +TUYA_SUPPORT_TYPE = { + "fs", # Fan + "fsd", # Fan with Light + "fskg", # Fan wall switch + "kj", # Air Purifier + "cs", # Dehumidifier +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up tuya fan 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 fan.""" + entities: list[TuyaFanEntity] = [] + for device_id in device_ids: + device = hass_data.device_manager.device_map[device_id] + if device and device.category in TUYA_SUPPORT_TYPE: + entities.append(TuyaFanEntity(device, hass_data.device_manager)) + 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 TuyaFanEntity(TuyaEntity, FanEntity): + """Tuya Fan Device.""" + + _direction: EnumTypeData | None = None + _oscillate: DPCode | None = None + _presets: EnumTypeData | None = None + _speed: IntegerTypeData | None = None + _speeds: EnumTypeData | None = None + _switch: DPCode | None = None + _attr_name = None + + def __init__( + self, + device: TuyaDevice, + device_manager: TuyaDeviceManager, + ) -> None: + """Init Tuya Fan Device.""" + super().__init__(device, device_manager) + + self._switch = self.find_dpcode( + (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH), prefer_function=True + ) + + self._attr_preset_modes = [] + if enum_type := self.find_dpcode( + (DPCode.FAN_MODE, DPCode.MODE), dptype=DPType.ENUM, prefer_function=True + ): + self._presets = enum_type + self._attr_supported_features |= FanEntityFeature.PRESET_MODE + self._attr_preset_modes = enum_type.range + + # Find speed controls, can be either percentage or a set of speeds + dpcodes = ( + DPCode.FAN_SPEED_PERCENT, + DPCode.FAN_SPEED, + DPCode.SPEED, + DPCode.FAN_SPEED_ENUM, + ) + if int_type := self.find_dpcode( + dpcodes, dptype=DPType.INTEGER, prefer_function=True + ): + self._attr_supported_features |= FanEntityFeature.SET_SPEED + self._speed = int_type + elif enum_type := self.find_dpcode( + dpcodes, dptype=DPType.ENUM, prefer_function=True + ): + self._attr_supported_features |= FanEntityFeature.SET_SPEED + self._speeds = enum_type + + if dpcode := self.find_dpcode( + (DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL), prefer_function=True + ): + self._oscillate = dpcode + self._attr_supported_features |= FanEntityFeature.OSCILLATE + + if enum_type := self.find_dpcode( + DPCode.FAN_DIRECTION, dptype=DPType.ENUM, prefer_function=True + ): + self._direction = enum_type + self._attr_supported_features |= FanEntityFeature.DIRECTION + + def set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + if self._presets is None: + return + self._send_command([{"code": self._presets.dpcode, "value": preset_mode}]) + + def set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + if self._direction is None: + return + self._send_command([{"code": self._direction.dpcode, "value": direction}]) + + def set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + if self._speed is not None: + self._send_command( + [ + { + "code": self._speed.dpcode, + "value": int(self._speed.remap_value_from(percentage, 1, 100)), + } + ] + ) + return + + if self._speeds is not None: + self._send_command( + [ + { + "code": self._speeds.dpcode, + "value": percentage_to_ordered_list_item( + self._speeds.range, percentage + ), + } + ] + ) + + def turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + self._send_command([{"code": self._switch, "value": False}]) + + def turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + if self._switch is None: + return + + commands: list[dict[str, str | bool | int]] = [ + {"code": self._switch, "value": True} + ] + + if percentage is not None and self._speed is not None: + commands.append( + { + "code": self._speed.dpcode, + "value": int(self._speed.remap_value_from(percentage, 1, 100)), + } + ) + + if percentage is not None and self._speeds is not None: + commands.append( + { + "code": self._speeds.dpcode, + "value": percentage_to_ordered_list_item( + self._speeds.range, percentage + ), + } + ) + + if preset_mode is not None and self._presets is not None: + commands.append({"code": self._presets.dpcode, "value": preset_mode}) + + self._send_command(commands) + + def oscillate(self, oscillating: bool) -> None: + """Oscillate the fan.""" + if self._oscillate is None: + return + self._send_command([{"code": self._oscillate, "value": oscillating}]) + + @property + def is_on(self) -> bool | None: + """Return true if fan is on.""" + if self._switch is None: + return None + return self.device.status.get(self._switch) + + @property + def current_direction(self) -> str | None: + """Return the current direction of the fan.""" + if ( + self._direction is None + or (value := self.device.status.get(self._direction.dpcode)) is None + ): + return None + + if value.lower() == DIRECTION_FORWARD: + return DIRECTION_FORWARD + + if value.lower() == DIRECTION_REVERSE: + return DIRECTION_REVERSE + + return None + + @property + def oscillating(self) -> bool | None: + """Return true if the fan is oscillating.""" + if self._oscillate is None: + return None + return self.device.status.get(self._oscillate) + + @property + def preset_mode(self) -> str | None: + """Return the current preset_mode.""" + if self._presets is None: + return None + return self.device.status.get(self._presets.dpcode) + + @property + def percentage(self) -> int | None: + """Return the current speed.""" + if self._speed is not None: + if (value := self.device.status.get(self._speed.dpcode)) is None: + return None + return int(self._speed.remap_value_to(value, 1, 100)) + + if self._speeds is not None: + if (value := self.device.status.get(self._speeds.dpcode)) is None: + return None + return ordered_list_item_to_percentage(self._speeds.range, value) + + return None + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + if self._speeds is not None: + return len(self._speeds.range) + return 100 diff --git a/custom_components/tuya_openapi/humidifier.py b/custom_components/tuya_openapi/humidifier.py new file mode 100644 index 0000000..6d09ba4 --- /dev/null +++ b/custom_components/tuya_openapi/humidifier.py @@ -0,0 +1,192 @@ +"""Support for Tuya (de)humidifiers.""" +from __future__ import annotations + +from dataclasses import dataclass + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.components.humidifier import ( + HumidifierDeviceClass, + HumidifierEntity, + HumidifierEntityDescription, + HumidifierEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeAssistantTuyaData +from .base import IntegerTypeData, TuyaEntity +from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType + + +@dataclass +class TuyaHumidifierEntityDescription(HumidifierEntityDescription): + """Describe an Tuya (de)humidifier entity.""" + + # DPCode, to use. If None, the key will be used as DPCode + dpcode: DPCode | tuple[DPCode, ...] | None = None + + current_humidity: DPCode | None = None + humidity: DPCode | None = None + + +HUMIDIFIERS: dict[str, TuyaHumidifierEntityDescription] = { + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha + "cs": TuyaHumidifierEntityDescription( + key=DPCode.SWITCH, + dpcode=(DPCode.SWITCH, DPCode.SWITCH_SPRAY), + current_humidity=DPCode.HUMIDITY_INDOOR, + humidity=DPCode.DEHUMIDITY_SET_VALUE, + device_class=HumidifierDeviceClass.DEHUMIDIFIER, + ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": TuyaHumidifierEntityDescription( + key=DPCode.SWITCH, + dpcode=(DPCode.SWITCH, DPCode.SWITCH_SPRAY), + current_humidity=DPCode.HUMIDITY_CURRENT, + humidity=DPCode.HUMIDITY_SET, + device_class=HumidifierDeviceClass.HUMIDIFIER, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Tuya (de)humidifier 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 (de)humidifier.""" + entities: list[TuyaHumidifierEntity] = [] + for device_id in device_ids: + device = hass_data.device_manager.device_map[device_id] + if description := HUMIDIFIERS.get(device.category): + entities.append( + TuyaHumidifierEntity(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 TuyaHumidifierEntity(TuyaEntity, HumidifierEntity): + """Tuya (de)humidifier Device.""" + + _current_humidity: IntegerTypeData | None = None + _set_humidity: IntegerTypeData | None = None + _switch_dpcode: DPCode | None = None + entity_description: TuyaHumidifierEntityDescription + _attr_name = None + + def __init__( + self, + device: TuyaDevice, + device_manager: TuyaDeviceManager, + description: TuyaHumidifierEntityDescription, + ) -> None: + """Init Tuya (de)humidifier.""" + super().__init__(device, device_manager) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}{description.key}" + + # Determine main switch DPCode + self._switch_dpcode = self.find_dpcode( + description.dpcode or DPCode(description.key), prefer_function=True + ) + + # Determine humidity parameters + if int_type := self.find_dpcode( + description.humidity, dptype=DPType.INTEGER, prefer_function=True + ): + self._set_humidity = int_type + self._attr_min_humidity = int(int_type.min_scaled) + self._attr_max_humidity = int(int_type.max_scaled) + + # Determine current humidity DPCode + if int_type := self.find_dpcode( + description.current_humidity, + dptype=DPType.INTEGER, + ): + self._current_humidity = int_type + + # Determine mode support and provided modes + if enum_type := self.find_dpcode( + DPCode.MODE, dptype=DPType.ENUM, prefer_function=True + ): + self._attr_supported_features |= HumidifierEntityFeature.MODES + self._attr_available_modes = enum_type.range + + @property + def is_on(self) -> bool: + """Return the device is on or off.""" + if self._switch_dpcode is None: + return False + return self.device.status.get(self._switch_dpcode, False) + + @property + def mode(self) -> str | None: + """Return the current mode.""" + return self.device.status.get(DPCode.MODE) + + @property + def target_humidity(self) -> int | None: + """Return the humidity we try to reach.""" + if self._set_humidity is None: + return None + + humidity = self.device.status.get(self._set_humidity.dpcode) + if humidity is None: + return None + + return round(self._set_humidity.scale_value(humidity)) + + @property + def current_humidity(self) -> int | None: + """Return the current humidity.""" + if self._current_humidity is None: + return None + + if ( + current_humidity := self.device.status.get(self._current_humidity.dpcode) + ) is None: + return None + + return round(self._current_humidity.scale_value(current_humidity)) + + def turn_on(self, **kwargs): + """Turn the device on.""" + self._send_command([{"code": self._switch_dpcode, "value": True}]) + + def turn_off(self, **kwargs): + """Turn the device off.""" + self._send_command([{"code": self._switch_dpcode, "value": False}]) + + def set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + if self._set_humidity is None: + raise RuntimeError( + "Cannot set humidity, device doesn't provide methods to set it" + ) + + self._send_command( + [ + { + "code": self._set_humidity.dpcode, + "value": self._set_humidity.scale_value_back(humidity), + } + ] + ) + + def set_mode(self, mode): + """Set new target preset mode.""" + self._send_command([{"code": DPCode.MODE, "value": mode}]) diff --git a/custom_components/tuya_openapi/light.py b/custom_components/tuya_openapi/light.py new file mode 100644 index 0000000..b4396f6 --- /dev/null +++ b/custom_components/tuya_openapi/light.py @@ -0,0 +1,724 @@ +"""Support for the Tuya lights.""" +from __future__ import annotations + +from dataclasses import dataclass, field +import json +from typing import Any, cast + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + ColorMode, + LightEntity, + LightEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeAssistantTuyaData +from .base import IntegerTypeData, TuyaEntity +from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType, WorkMode +from .util import remap_value + + +@dataclass +class ColorTypeData: + """Color Type Data.""" + + h_type: IntegerTypeData + s_type: IntegerTypeData + v_type: IntegerTypeData + + +DEFAULT_COLOR_TYPE_DATA = ColorTypeData( + h_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=360, step=1), + s_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=255, step=1), + v_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=255, step=1), +) + +DEFAULT_COLOR_TYPE_DATA_V2 = ColorTypeData( + h_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=360, step=1), + s_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1), + v_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1), +) + + +@dataclass +class TuyaLightEntityDescription(LightEntityDescription): + """Describe an Tuya light entity.""" + + brightness_max: DPCode | None = None + brightness_min: DPCode | None = None + brightness: DPCode | tuple[DPCode, ...] | None = None + color_data: DPCode | tuple[DPCode, ...] | None = None + color_mode: DPCode | None = None + color_temp: DPCode | tuple[DPCode, ...] | None = None + default_color_type: ColorTypeData = field( + default_factory=lambda: DEFAULT_COLOR_TYPE_DATA + ) + + +LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { + # Curtain Switch + # https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39 + "clkg": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_BACKLIGHT, + translation_key="backlight", + entity_category=EntityCategory.CONFIG, + ), + ), + # String Lights + # https://developer.tuya.com/en/docs/iot/dc?id=Kaof7taxmvadu + "dc": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color_data=DPCode.COLOUR_DATA, + ), + ), + # Strip Lights + # https://developer.tuya.com/en/docs/iot/dd?id=Kaof804aibg2l + "dd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color_data=DPCode.COLOUR_DATA, + default_color_type=DEFAULT_COLOR_TYPE_DATA_V2, + ), + ), + # Light + # https://developer.tuya.com/en/docs/iot/categorydj?id=Kaiuyzy3eheyy + "dj": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=(DPCode.BRIGHT_VALUE_V2, DPCode.BRIGHT_VALUE), + color_temp=(DPCode.TEMP_VALUE_V2, DPCode.TEMP_VALUE), + color_data=(DPCode.COLOUR_DATA_V2, DPCode.COLOUR_DATA), + ), + # Not documented + # Based on multiple reports: manufacturer customized Dimmer 2 switches + TuyaLightEntityDescription( + key=DPCode.SWITCH_1, + translation_key="light", + brightness=DPCode.BRIGHT_VALUE_1, + ), + ), + # Ceiling Fan Light + # https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v + "fsd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color_data=DPCode.COLOUR_DATA, + ), + # Some ceiling fan lights use LIGHT for DPCode instead of SWITCH_LED + TuyaLightEntityDescription( + key=DPCode.LIGHT, + name=None, + ), + ), + # Ambient Light + # https://developer.tuya.com/en/docs/iot/ambient-light?id=Kaiuz06amhe6g + "fwd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color_data=DPCode.COLOUR_DATA, + ), + ), + # Motion Sensor Light + # https://developer.tuya.com/en/docs/iot/gyd?id=Kaof8a8hycfmy + "gyd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color_data=DPCode.COLOUR_DATA, + ), + ), + # Humidifier Light + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_data=DPCode.COLOUR_DATA_HSV, + ), + ), + # Switch + # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + "kg": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_BACKLIGHT, + translation_key="backlight", + entity_category=EntityCategory.CONFIG, + ), + ), + # Air Purifier + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm + "kj": ( + TuyaLightEntityDescription( + key=DPCode.LIGHT, + translation_key="backlight", + entity_category=EntityCategory.CONFIG, + ), + ), + # Air conditioner + # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n + "kt": ( + TuyaLightEntityDescription( + key=DPCode.LIGHT, + translation_key="backlight", + entity_category=EntityCategory.CONFIG, + ), + ), + # Unknown light product + # Found as VECINO RGBW as provided by diagnostics + # Not documented + "mbd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_data=DPCode.COLOUR_DATA, + ), + ), + # Unknown product with light capabilities + # Fond in some diffusers, plugs and PIR flood lights + # Not documented + "qjdcz": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_data=DPCode.COLOUR_DATA, + ), + ), + # Heater + # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm + "qn": ( + TuyaLightEntityDescription( + key=DPCode.LIGHT, + translation_key="backlight", + entity_category=EntityCategory.CONFIG, + ), + ), + # Smart Camera + # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 + "sp": ( + TuyaLightEntityDescription( + key=DPCode.FLOODLIGHT_SWITCH, + brightness=DPCode.FLOODLIGHT_LIGHTNESS, + name="Floodlight", + ), + TuyaLightEntityDescription( + key=DPCode.BASIC_INDICATOR, + name="Indicator light", + entity_category=EntityCategory.CONFIG, + ), + ), + # Dimmer Switch + # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o + "tgkg": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED_1, + translation_key="light", + brightness=DPCode.BRIGHT_VALUE_1, + brightness_max=DPCode.BRIGHTNESS_MAX_1, + brightness_min=DPCode.BRIGHTNESS_MIN_1, + ), + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED_2, + translation_key="light_2", + brightness=DPCode.BRIGHT_VALUE_2, + brightness_max=DPCode.BRIGHTNESS_MAX_2, + brightness_min=DPCode.BRIGHTNESS_MIN_2, + ), + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED_3, + translation_key="light_3", + brightness=DPCode.BRIGHT_VALUE_3, + brightness_max=DPCode.BRIGHTNESS_MAX_3, + brightness_min=DPCode.BRIGHTNESS_MIN_3, + ), + ), + # Dimmer + # https://developer.tuya.com/en/docs/iot/tgq?id=Kaof8ke9il4k4 + "tgq": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + translation_key="light", + brightness=(DPCode.BRIGHT_VALUE_V2, DPCode.BRIGHT_VALUE), + brightness_max=DPCode.BRIGHTNESS_MAX_1, + brightness_min=DPCode.BRIGHTNESS_MIN_1, + ), + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED_1, + translation_key="light", + brightness=DPCode.BRIGHT_VALUE_1, + ), + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED_2, + translation_key="light_2", + brightness=DPCode.BRIGHT_VALUE_2, + ), + ), + # Wake Up Light II + # Not documented + "hxd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + translation_key="light", + brightness=(DPCode.BRIGHT_VALUE_V2, DPCode.BRIGHT_VALUE), + brightness_max=DPCode.BRIGHTNESS_MAX_1, + brightness_min=DPCode.BRIGHTNESS_MIN_1, + ), + ), + # Solar Light + # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 + "tyndj": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color_data=DPCode.COLOUR_DATA, + ), + ), + # Ceiling Light + # https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r + "xdd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color_data=DPCode.COLOUR_DATA, + ), + TuyaLightEntityDescription( + key=DPCode.SWITCH_NIGHT_LIGHT, + translation_key="night_light", + ), + ), + # Remote Control + # https://developer.tuya.com/en/docs/iot/ykq?id=Kaof8ljn81aov + "ykq": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_CONTROLLER, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_CONTROLLER, + color_temp=DPCode.TEMP_CONTROLLER, + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs": ( + TuyaLightEntityDescription( + key=DPCode.LIGHT, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + ), + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + translation_key="light_2", + brightness=DPCode.BRIGHT_VALUE_1, + ), + ), +} + +# Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +LIGHTS["cz"] = LIGHTS["kg"] + +# Power Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +LIGHTS["pc"] = LIGHTS["kg"] + + +@dataclass +class ColorData: + """Color Data.""" + + type_data: ColorTypeData + h_value: int + s_value: int + v_value: int + + @property + def hs_color(self) -> tuple[float, float]: + """Get the HS value from this color data.""" + return ( + self.type_data.h_type.remap_value_to(self.h_value, 0, 360), + self.type_data.s_type.remap_value_to(self.s_value, 0, 100), + ) + + @property + def brightness(self) -> int: + """Get the brightness value from this color data.""" + return round(self.type_data.v_type.remap_value_to(self.v_value, 0, 255)) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up tuya light dynamically through tuya discovery.""" + hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + + @callback + def async_discover_device(device_ids: list[str]): + """Discover and add a discovered tuya light.""" + entities: list[TuyaLightEntity] = [] + for device_id in device_ids: + device = hass_data.device_manager.device_map[device_id] + if descriptions := LIGHTS.get(device.category): + for description in descriptions: + if description.key in device.status: + entities.append( + TuyaLightEntity( + 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 TuyaLightEntity(TuyaEntity, LightEntity): + """Tuya light device.""" + + entity_description: TuyaLightEntityDescription + + _brightness_max: IntegerTypeData | None = None + _brightness_min: IntegerTypeData | None = None + _brightness: IntegerTypeData | None = None + _color_data_dpcode: DPCode | None = None + _color_data_type: ColorTypeData | None = None + _color_mode: DPCode | None = None + _color_temp: IntegerTypeData | None = None + + def __init__( + self, + device: TuyaDevice, + device_manager: TuyaDeviceManager, + description: TuyaLightEntityDescription, + ) -> None: + """Init TuyaHaLight.""" + super().__init__(device, device_manager) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}{description.key}" + self._attr_supported_color_modes: set[ColorMode] = set() + + # Determine DPCodes + self._color_mode_dpcode = self.find_dpcode( + description.color_mode, prefer_function=True + ) + + if int_type := self.find_dpcode( + description.brightness, dptype=DPType.INTEGER, prefer_function=True + ): + self._brightness = int_type + self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS) + self._brightness_max = self.find_dpcode( + description.brightness_max, dptype=DPType.INTEGER + ) + self._brightness_min = self.find_dpcode( + description.brightness_min, dptype=DPType.INTEGER + ) + + if int_type := self.find_dpcode( + description.color_temp, dptype=DPType.INTEGER, prefer_function=True + ): + self._color_temp = int_type + self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP) + + if ( + dpcode := self.find_dpcode(description.color_data, prefer_function=True) + ) and self.get_dptype(dpcode) == DPType.JSON: + self._color_data_dpcode = dpcode + self._attr_supported_color_modes.add(ColorMode.HS) + if dpcode in self.device.function: + values = cast(str, self.device.function[dpcode].values) + else: + values = self.device.status_range[dpcode].values + + # Fetch color data type information + if function_data := json.loads(values): + self._color_data_type = ColorTypeData( + h_type=IntegerTypeData(dpcode, **function_data["h"]), + s_type=IntegerTypeData(dpcode, **function_data["s"]), + v_type=IntegerTypeData(dpcode, **function_data["v"]), + ) + else: + # If no type is found, use a default one + self._color_data_type = self.entity_description.default_color_type + if self._color_data_dpcode == DPCode.COLOUR_DATA_V2 or ( + self._brightness and self._brightness.max > 255 + ): + self._color_data_type = DEFAULT_COLOR_TYPE_DATA_V2 + + if not self._attr_supported_color_modes: + self._attr_supported_color_modes = {ColorMode.ONOFF} + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self.device.status.get(self.entity_description.key, False) + + def turn_on(self, **kwargs: Any) -> None: + """Turn on or control the light.""" + commands = [{"code": self.entity_description.key, "value": True}] + + if self._color_temp and ATTR_COLOR_TEMP in kwargs: + if self._color_mode_dpcode: + commands += [ + { + "code": self._color_mode_dpcode, + "value": WorkMode.WHITE, + }, + ] + + commands += [ + { + "code": self._color_temp.dpcode, + "value": round( + self._color_temp.remap_value_from( + kwargs[ATTR_COLOR_TEMP], + self.min_mireds, + self.max_mireds, + reverse=True, + ) + ), + }, + ] + + if self._color_data_type and ( + ATTR_HS_COLOR in kwargs + or ( + ATTR_BRIGHTNESS in kwargs + and self.color_mode == ColorMode.HS + and ATTR_COLOR_TEMP not in kwargs + ) + ): + if self._color_mode_dpcode: + commands += [ + { + "code": self._color_mode_dpcode, + "value": WorkMode.COLOUR, + }, + ] + + if not (brightness := kwargs.get(ATTR_BRIGHTNESS)): + brightness = self.brightness or 0 + + if not (color := kwargs.get(ATTR_HS_COLOR)): + color = self.hs_color or (0, 0) + + commands += [ + { + "code": self._color_data_dpcode, + "value": json.dumps( + { + "h": round( + self._color_data_type.h_type.remap_value_from( + color[0], 0, 360 + ) + ), + "s": round( + self._color_data_type.s_type.remap_value_from( + color[1], 0, 100 + ) + ), + "v": round( + self._color_data_type.v_type.remap_value_from( + brightness + ) + ), + } + ), + }, + ] + + elif ATTR_BRIGHTNESS in kwargs and self._brightness: + brightness = kwargs[ATTR_BRIGHTNESS] + + # If there is a min/max value, the brightness is actually limited. + # Meaning it is actually not on a 0-255 scale. + if ( + self._brightness_max is not None + and self._brightness_min is not None + and ( + brightness_max := self.device.status.get( + self._brightness_max.dpcode + ) + ) + is not None + and ( + brightness_min := self.device.status.get( + self._brightness_min.dpcode + ) + ) + is not None + ): + # Remap values onto our scale + brightness_max = self._brightness_max.remap_value_to(brightness_max) + brightness_min = self._brightness_min.remap_value_to(brightness_min) + + # Remap the brightness value from their min-max to our 0-255 scale + brightness = remap_value( + brightness, + to_min=brightness_min, + to_max=brightness_max, + ) + + commands += [ + { + "code": self._brightness.dpcode, + "value": round(self._brightness.remap_value_from(brightness)), + }, + ] + + self._send_command(commands) + + def turn_off(self, **kwargs: Any) -> None: + """Instruct the light to turn off.""" + self._send_command([{"code": self.entity_description.key, "value": False}]) + + @property + def brightness(self) -> int | None: + """Return the brightness of the light.""" + # If the light is currently in color mode, extract the brightness from the color data + if self.color_mode == ColorMode.HS and (color_data := self._get_color_data()): + return color_data.brightness + + if not self._brightness: + return None + + brightness = self.device.status.get(self._brightness.dpcode) + if brightness is None: + return None + + # Remap value to our scale + brightness = self._brightness.remap_value_to(brightness) + + # If there is a min/max value, the brightness is actually limited. + # Meaning it is actually not on a 0-255 scale. + if ( + self._brightness_max is not None + and self._brightness_min is not None + and (brightness_max := self.device.status.get(self._brightness_max.dpcode)) + is not None + and (brightness_min := self.device.status.get(self._brightness_min.dpcode)) + is not None + ): + # Remap values onto our scale + brightness_max = self._brightness_max.remap_value_to(brightness_max) + brightness_min = self._brightness_min.remap_value_to(brightness_min) + + # Remap the brightness value from their min-max to our 0-255 scale + brightness = remap_value( + brightness, + from_min=brightness_min, + from_max=brightness_max, + ) + + return round(brightness) + + @property + def color_temp(self) -> int | None: + """Return the color_temp of the light.""" + if not self._color_temp: + return None + + temperature = self.device.status.get(self._color_temp.dpcode) + if temperature is None: + return None + + return round( + self._color_temp.remap_value_to( + temperature, self.min_mireds, self.max_mireds, reverse=True + ) + ) + + @property + def hs_color(self) -> tuple[float, float] | None: + """Return the hs_color of the light.""" + if self._color_data_dpcode is None or not ( + color_data := self._get_color_data() + ): + return None + return color_data.hs_color + + @property + def color_mode(self) -> ColorMode: + """Return the color_mode of the light.""" + # We consider it to be in HS color mode, when work mode is anything + # else than "white". + if ( + self._color_mode_dpcode + and self.device.status.get(self._color_mode_dpcode) != WorkMode.WHITE + ): + return ColorMode.HS + if self._color_temp: + return ColorMode.COLOR_TEMP + if self._brightness: + return ColorMode.BRIGHTNESS + return ColorMode.ONOFF + + def _get_color_data(self) -> ColorData | None: + """Get current color data from device.""" + if ( + self._color_data_type is None + or self._color_data_dpcode is None + or self._color_data_dpcode not in self.device.status + ): + return None + + if not (status_data := self.device.status[self._color_data_dpcode]): + return None + + if not (status := json.loads(status_data)): + return None + + return ColorData( + type_data=self._color_data_type, + h_value=status["h"], + s_value=status["s"], + v_value=status["v"], + ) diff --git a/custom_components/tuya_openapi/manifest.json b/custom_components/tuya_openapi/manifest.json new file mode 100644 index 0000000..567969f --- /dev/null +++ b/custom_components/tuya_openapi/manifest.json @@ -0,0 +1,58 @@ +{ + "domain": "tuya", + "name": "Tuya OpenAPI", + "codeowners": [ + "@Tuya", + "@zlinoliver", + "@frenck", + "@EvanSchalton" + ], + "config_flow": true, + "dependencies": [ + "ffmpeg" + ], + "dhcp": [ + { + "macaddress": "105A17*" + }, + { + "macaddress": "10D561*" + }, + { + "macaddress": "1869D8*" + }, + { + "macaddress": "381F8D*" + }, + { + "macaddress": "508A06*" + }, + { + "macaddress": "68572D*" + }, + { + "macaddress": "708976*" + }, + { + "macaddress": "7CF666*" + }, + { + "macaddress": "84E342*" + }, + { + "macaddress": "D4A651*" + }, + { + "macaddress": "D81F12*" + } + ], + "documentation": "https://www.home-assistant.io/integrations/tuya", + "integration_type": "hub", + "iot_class": "cloud_push", + "loggers": [ + "tuya_iot_openapi" + ], + "requirements": [ + "tuya-iot-py-sdk==0.6.6" + ] +} \ No newline at end of file diff --git a/custom_components/tuya_openapi/number.py b/custom_components/tuya_openapi/number.py new file mode 100644 index 0000000..5e7bdcc --- /dev/null +++ b/custom_components/tuya_openapi/number.py @@ -0,0 +1,430 @@ +"""Support for Tuya number.""" +from __future__ import annotations + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeAssistantTuyaData +from .base import IntegerTypeData, TuyaEntity +from .const import DEVICE_CLASS_UNITS, DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType + +# All descriptions can be found here. Mostly the Integer data types in the +# default instructions set of each category end up being a number. +# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq +NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { + # Multi-functional Sensor + # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 + "dgnbj": ( + NumberEntityDescription( + key=DPCode.ALARM_TIME, + translation_key="time", + entity_category=EntityCategory.CONFIG, + ), + ), + # Smart Kettle + # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 + "bh": ( + NumberEntityDescription( + key=DPCode.TEMP_SET, + translation_key="temperature", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.TEMP_SET_F, + translation_key="temperature", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.TEMP_BOILING_C, + translation_key="temperature_after_boiling", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.TEMP_BOILING_F, + translation_key="temperature_after_boiling", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.WARM_TIME, + translation_key="heat_preservation_time", + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + ), + ), + # Smart Pet Feeder + # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld + "cwwsq": ( + NumberEntityDescription( + key=DPCode.MANUAL_FEED, + translation_key="feed", + icon="mdi:bowl", + ), + NumberEntityDescription( + key=DPCode.VOICE_TIMES, + translation_key="voice_times", + icon="mdi:microphone", + ), + ), + # Human Presence Sensor + # https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs + "hps": ( + NumberEntityDescription( + key=DPCode.SENSITIVITY, + translation_key="sensitivity", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.NEAR_DETECTION, + translation_key="near_detection", + icon="mdi:signal-distance-variant", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.FAR_DETECTION, + translation_key="far_detection", + icon="mdi:signal-distance-variant", + entity_category=EntityCategory.CONFIG, + ), + ), + # Coffee maker + # https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f + "kfj": ( + NumberEntityDescription( + key=DPCode.WATER_SET, + translation_key="water_level", + icon="mdi:cup-water", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.TEMP_SET, + translation_key="temperature", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.WARM_TIME, + translation_key="heat_preservation_time", + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.POWDER_SET, + translation_key="powder", + entity_category=EntityCategory.CONFIG, + ), + ), + # Sous Vide Cooker + # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux + "mzj": ( + NumberEntityDescription( + key=DPCode.COOK_TEMPERATURE, + translation_key="cook_temperature", + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.COOK_TIME, + translation_key="cook_time", + icon="mdi:timer", + native_unit_of_measurement=UnitOfTime.MINUTES, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.CLOUD_RECIPE_NUMBER, + translation_key="cloud_recipe", + entity_category=EntityCategory.CONFIG, + ), + ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + NumberEntityDescription( + key=DPCode.VOLUME_SET, + translation_key="volume", + icon="mdi:volume-high", + entity_category=EntityCategory.CONFIG, + ), + ), + # Siren Alarm + # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + "sgbj": ( + NumberEntityDescription( + key=DPCode.ALARM_TIME, + translation_key="time", + entity_category=EntityCategory.CONFIG, + ), + ), + # Smart Camera + # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 + "sp": ( + NumberEntityDescription( + key=DPCode.BASIC_DEVICE_VOLUME, + translation_key="volume", + icon="mdi:volume-high", + entity_category=EntityCategory.CONFIG, + ), + ), + # Dimmer Switch + # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o + "tgkg": ( + NumberEntityDescription( + key=DPCode.BRIGHTNESS_MIN_1, + translation_key="minimum_brightness", + icon="mdi:lightbulb-outline", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.BRIGHTNESS_MAX_1, + translation_key="maximum_brightness", + icon="mdi:lightbulb-on-outline", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.BRIGHTNESS_MIN_2, + translation_key="minimum_brightness_2", + icon="mdi:lightbulb-outline", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.BRIGHTNESS_MAX_2, + translation_key="maximum_brightness_2", + icon="mdi:lightbulb-on-outline", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.BRIGHTNESS_MIN_3, + translation_key="minimum_brightness_3", + icon="mdi:lightbulb-outline", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.BRIGHTNESS_MAX_3, + translation_key="maximum_brightness_3", + icon="mdi:lightbulb-on-outline", + entity_category=EntityCategory.CONFIG, + ), + ), + # Dimmer Switch + # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o + "tgq": ( + NumberEntityDescription( + key=DPCode.BRIGHTNESS_MIN_1, + translation_key="minimum_brightness", + icon="mdi:lightbulb-outline", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.BRIGHTNESS_MAX_1, + translation_key="maximum_brightness", + icon="mdi:lightbulb-on-outline", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.BRIGHTNESS_MIN_2, + translation_key="minimum_brightness_2", + icon="mdi:lightbulb-outline", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.BRIGHTNESS_MAX_2, + translation_key="maximum_brightness_2", + icon="mdi:lightbulb-on-outline", + entity_category=EntityCategory.CONFIG, + ), + ), + # Vibration Sensor + # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno + "zd": ( + NumberEntityDescription( + key=DPCode.SENSITIVITY, + translation_key="sensitivity", + entity_category=EntityCategory.CONFIG, + ), + ), + # Fingerbot + "szjqr": ( + NumberEntityDescription( + key=DPCode.ARM_DOWN_PERCENT, + translation_key="move_down", + icon="mdi:arrow-down-bold", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.ARM_UP_PERCENT, + translation_key="move_up", + icon="mdi:arrow-up-bold", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.CLICK_SUSTAIN_TIME, + translation_key="down_delay", + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs": ( + NumberEntityDescription( + key=DPCode.TEMP, + translation_key="temperature", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer-lines", + ), + ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( + NumberEntityDescription( + key=DPCode.TEMP_SET, + translation_key="temperature", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer-lines", + ), + NumberEntityDescription( + key=DPCode.TEMP_SET_F, + translation_key="temperature", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer-lines", + ), + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Tuya number 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 number.""" + entities: list[TuyaNumberEntity] = [] + for device_id in device_ids: + device = hass_data.device_manager.device_map[device_id] + if descriptions := NUMBERS.get(device.category): + for description in descriptions: + if description.key in device.status: + entities.append( + TuyaNumberEntity( + 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 TuyaNumberEntity(TuyaEntity, NumberEntity): + """Tuya Number Entity.""" + + _number: IntegerTypeData | None = None + + def __init__( + self, + device: TuyaDevice, + device_manager: TuyaDeviceManager, + description: NumberEntityDescription, + ) -> None: + """Init Tuya sensor.""" + super().__init__(device, device_manager) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}{description.key}" + + if int_type := self.find_dpcode( + description.key, dptype=DPType.INTEGER, prefer_function=True + ): + self._number = int_type + self._attr_native_max_value = self._number.max_scaled + self._attr_native_min_value = self._number.min_scaled + self._attr_native_step = self._number.step_scaled + + # Logic to ensure the set device class and API received Unit Of Measurement + # match Home Assistants requirements. + if ( + self.device_class is not None + and not self.device_class.startswith(DOMAIN) + and description.native_unit_of_measurement is None + ): + # We cannot have a device class, if the UOM isn't set or the + # device class cannot be found in the validation mapping. + if ( + self.native_unit_of_measurement is None + or self.device_class not in DEVICE_CLASS_UNITS + ): + self._attr_device_class = None + return + + uoms = DEVICE_CLASS_UNITS[self.device_class] + self._uom = uoms.get(self.native_unit_of_measurement) or uoms.get( + self.native_unit_of_measurement.lower() + ) + + # Unknown unit of measurement, device class should not be used. + if self._uom is None: + self._attr_device_class = None + return + + # If we still have a device class, we should not use an icon + if self.device_class: + self._attr_icon = None + + # Found unit of measurement, use the standardized Unit + # Use the target conversion unit (if set) + self._attr_native_unit_of_measurement = ( + self._uom.conversion_unit or self._uom.unit + ) + + @property + def native_value(self) -> float | None: + """Return the entity value to represent the entity state.""" + # Unknown or unsupported data type + if self._number is None: + return None + + # Raw value + if (value := self.device.status.get(self.entity_description.key)) is None: + return None + + return self._number.scale_value(value) + + def set_native_value(self, value: float) -> None: + """Set new value.""" + if self._number is None: + raise RuntimeError("Cannot set value, device doesn't provide type data") + + self._send_command( + [ + { + "code": self.entity_description.key, + "value": self._number.scale_value_back(value), + } + ] + ) diff --git a/custom_components/tuya_openapi/scene.py b/custom_components/tuya_openapi/scene.py new file mode 100644 index 0000000..289e319 --- /dev/null +++ b/custom_components/tuya_openapi/scene.py @@ -0,0 +1,60 @@ +"""Support for Tuya scenes.""" +from __future__ import annotations + +from typing import Any + +from tuya_iot import TuyaHomeManager, TuyaScene + +from homeassistant.components.scene import Scene +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeAssistantTuyaData +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Tuya scenes.""" + hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + scenes = await hass.async_add_executor_job(hass_data.home_manager.query_scenes) + async_add_entities( + TuyaSceneEntity(hass_data.home_manager, scene) for scene in scenes + ) + + +class TuyaSceneEntity(Scene): + """Tuya Scene Remote.""" + + _should_poll = False + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, home_manager: TuyaHomeManager, scene: TuyaScene) -> None: + """Init Tuya Scene.""" + super().__init__() + self._attr_unique_id = f"tys{scene.scene_id}" + self.home_manager = home_manager + self.scene = scene + + @property + def device_info(self) -> DeviceInfo: + """Return a device description for device registry.""" + return DeviceInfo( + identifiers={(DOMAIN, f"{self.unique_id}")}, + manufacturer="tuya", + name=self.scene.name, + model="Tuya Scene", + ) + + @property + def available(self) -> bool: + """Return if the scene is enabled.""" + return self.scene.enabled + + def activate(self, **kwargs: Any) -> None: + """Activate the scene.""" + self.home_manager.trigger_scene(self.scene.home_id, self.scene.scene_id) diff --git a/custom_components/tuya_openapi/select.py b/custom_components/tuya_openapi/select.py new file mode 100644 index 0000000..3cc8c72 --- /dev/null +++ b/custom_components/tuya_openapi/select.py @@ -0,0 +1,407 @@ +"""Support for Tuya select.""" +from __future__ import annotations + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeAssistantTuyaData +from .base import TuyaEntity +from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType + +# All descriptions can be found here. Mostly the Enum data types in the +# default instructions set of each category end up being a select. +# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq +SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { + # Multi-functional Sensor + # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 + "dgnbj": ( + SelectEntityDescription( + key=DPCode.ALARM_VOLUME, + translation_key="volume", + entity_category=EntityCategory.CONFIG, + ), + ), + # Coffee maker + # https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f + "kfj": ( + SelectEntityDescription( + key=DPCode.CUP_NUMBER, + translation_key="cups", + icon="mdi:numeric", + ), + SelectEntityDescription( + key=DPCode.CONCENTRATION_SET, + translation_key="concentration", + icon="mdi:altimeter", + entity_category=EntityCategory.CONFIG, + ), + SelectEntityDescription( + key=DPCode.MATERIAL, + translation_key="material", + entity_category=EntityCategory.CONFIG, + ), + SelectEntityDescription( + key=DPCode.MODE, + translation_key="mode", + icon="mdi:coffee", + ), + ), + # Switch + # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + "kg": ( + SelectEntityDescription( + key=DPCode.RELAY_STATUS, + entity_category=EntityCategory.CONFIG, + translation_key="relay_status", + ), + SelectEntityDescription( + key=DPCode.LIGHT_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="light_mode", + ), + ), + # Heater + # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm + "qn": ( + SelectEntityDescription( + key=DPCode.LEVEL, + translation_key="temperature_level", + icon="mdi:thermometer-lines", + ), + ), + # Siren Alarm + # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + "sgbj": ( + SelectEntityDescription( + key=DPCode.ALARM_VOLUME, + translation_key="volume", + entity_category=EntityCategory.CONFIG, + ), + SelectEntityDescription( + key=DPCode.BRIGHT_STATE, + translation_key="brightness", + entity_category=EntityCategory.CONFIG, + ), + ), + # Smart Camera + # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 + "sp": ( + SelectEntityDescription( + key=DPCode.IPC_WORK_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="ipc_work_mode", + ), + SelectEntityDescription( + key=DPCode.DECIBEL_SENSITIVITY, + icon="mdi:volume-vibrate", + entity_category=EntityCategory.CONFIG, + translation_key="decibel_sensitivity", + ), + SelectEntityDescription( + key=DPCode.RECORD_MODE, + icon="mdi:record-rec", + entity_category=EntityCategory.CONFIG, + translation_key="record_mode", + ), + SelectEntityDescription( + key=DPCode.BASIC_NIGHTVISION, + icon="mdi:theme-light-dark", + entity_category=EntityCategory.CONFIG, + translation_key="basic_nightvision", + ), + SelectEntityDescription( + key=DPCode.BASIC_ANTI_FLICKER, + icon="mdi:image-outline", + entity_category=EntityCategory.CONFIG, + translation_key="basic_anti_flicker", + ), + SelectEntityDescription( + key=DPCode.MOTION_SENSITIVITY, + icon="mdi:motion-sensor", + entity_category=EntityCategory.CONFIG, + translation_key="motion_sensitivity", + ), + ), + # IoT Switch? + # Note: Undocumented + "tdq": ( + SelectEntityDescription( + key=DPCode.RELAY_STATUS, + entity_category=EntityCategory.CONFIG, + translation_key="relay_status", + ), + SelectEntityDescription( + key=DPCode.LIGHT_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="light_mode", + ), + ), + # Dimmer Switch + # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o + "tgkg": ( + SelectEntityDescription( + key=DPCode.RELAY_STATUS, + entity_category=EntityCategory.CONFIG, + translation_key="relay_status", + ), + SelectEntityDescription( + key=DPCode.LIGHT_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="light_mode", + ), + SelectEntityDescription( + key=DPCode.LED_TYPE_1, + entity_category=EntityCategory.CONFIG, + translation_key="led_type", + ), + SelectEntityDescription( + key=DPCode.LED_TYPE_2, + entity_category=EntityCategory.CONFIG, + translation_key="led_type_2", + ), + SelectEntityDescription( + key=DPCode.LED_TYPE_3, + entity_category=EntityCategory.CONFIG, + translation_key="led_type_3", + ), + ), + # Dimmer + # https://developer.tuya.com/en/docs/iot/tgq?id=Kaof8ke9il4k4 + "tgq": ( + SelectEntityDescription( + key=DPCode.LED_TYPE_1, + entity_category=EntityCategory.CONFIG, + translation_key="led_type", + ), + SelectEntityDescription( + key=DPCode.LED_TYPE_2, + entity_category=EntityCategory.CONFIG, + translation_key="led_type_2", + ), + ), + # Fingerbot + "szjqr": ( + SelectEntityDescription( + key=DPCode.MODE, + entity_category=EntityCategory.CONFIG, + translation_key="fingerbot_mode", + ), + ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + SelectEntityDescription( + key=DPCode.CISTERN, + entity_category=EntityCategory.CONFIG, + icon="mdi:water-opacity", + translation_key="vacuum_cistern", + ), + SelectEntityDescription( + key=DPCode.COLLECTION_MODE, + entity_category=EntityCategory.CONFIG, + icon="mdi:air-filter", + translation_key="vacuum_collection", + ), + SelectEntityDescription( + key=DPCode.MODE, + entity_category=EntityCategory.CONFIG, + icon="mdi:layers-outline", + translation_key="vacuum_mode", + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45vs7vkge + "fs": ( + SelectEntityDescription( + key=DPCode.FAN_VERTICAL, + entity_category=EntityCategory.CONFIG, + icon="mdi:format-vertical-align-center", + translation_key="vertical_fan_angle", + ), + SelectEntityDescription( + key=DPCode.FAN_HORIZONTAL, + entity_category=EntityCategory.CONFIG, + icon="mdi:format-horizontal-align-center", + translation_key="horizontal_fan_angle", + ), + SelectEntityDescription( + key=DPCode.COUNTDOWN, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-cog-outline", + translation_key="countdown", + ), + SelectEntityDescription( + key=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-cog-outline", + translation_key="countdown", + ), + ), + # Curtain + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc + "cl": ( + SelectEntityDescription( + key=DPCode.CONTROL_BACK_MODE, + entity_category=EntityCategory.CONFIG, + icon="mdi:swap-horizontal", + translation_key="curtain_motor_mode", + ), + SelectEntityDescription( + key=DPCode.MODE, + entity_category=EntityCategory.CONFIG, + translation_key="curtain_mode", + ), + ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( + SelectEntityDescription( + key=DPCode.SPRAY_MODE, + entity_category=EntityCategory.CONFIG, + icon="mdi:spray", + translation_key="humidifier_spray_mode", + ), + SelectEntityDescription( + key=DPCode.LEVEL, + entity_category=EntityCategory.CONFIG, + icon="mdi:spray", + translation_key="humidifier_level", + ), + SelectEntityDescription( + key=DPCode.MOODLIGHTING, + entity_category=EntityCategory.CONFIG, + icon="mdi:lightbulb-multiple", + translation_key="humidifier_moodlighting", + ), + SelectEntityDescription( + key=DPCode.COUNTDOWN, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-cog-outline", + translation_key="countdown", + ), + SelectEntityDescription( + key=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-cog-outline", + translation_key="countdown", + ), + ), + # Air Purifier + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm + "kj": ( + SelectEntityDescription( + key=DPCode.COUNTDOWN, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-cog-outline", + translation_key="countdown", + ), + SelectEntityDescription( + key=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-cog-outline", + translation_key="countdown", + ), + ), + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha + "cs": ( + SelectEntityDescription( + key=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-cog-outline", + translation_key="countdown", + ), + SelectEntityDescription( + key=DPCode.DEHUMIDITY_SET_ENUM, + translation_key="target_humidity", + entity_category=EntityCategory.CONFIG, + icon="mdi:water-percent", + ), + ), +} + +# Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +SELECTS["cz"] = SELECTS["kg"] + +# Power Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +SELECTS["pc"] = SELECTS["kg"] + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Tuya select 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 select.""" + entities: list[TuyaSelectEntity] = [] + for device_id in device_ids: + device = hass_data.device_manager.device_map[device_id] + if descriptions := SELECTS.get(device.category): + for description in descriptions: + if description.key in device.status: + entities.append( + TuyaSelectEntity( + 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 TuyaSelectEntity(TuyaEntity, SelectEntity): + """Tuya Select Entity.""" + + def __init__( + self, + device: TuyaDevice, + device_manager: TuyaDeviceManager, + description: SelectEntityDescription, + ) -> None: + """Init Tuya sensor.""" + super().__init__(device, device_manager) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}{description.key}" + + self._attr_options: list[str] = [] + if enum_type := self.find_dpcode( + description.key, dptype=DPType.ENUM, prefer_function=True + ): + self._attr_options = enum_type.range + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + # Raw value + value = self.device.status.get(self.entity_description.key) + if value is None or value not in self._attr_options: + return None + + return value + + def select_option(self, option: str) -> None: + """Change the selected option.""" + self._send_command( + [ + { + "code": self.entity_description.key, + "value": option, + } + ] + ) diff --git a/custom_components/tuya_openapi/sensor.py b/custom_components/tuya_openapi/sensor.py new file mode 100644 index 0000000..170ca7a --- /dev/null +++ b/custom_components/tuya_openapi/sensor.py @@ -0,0 +1,1294 @@ +"""Support for Tuya sensors.""" +from __future__ import annotations + +from dataclasses import dataclass + +from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_iot.device import TuyaDeviceStatusRange + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfPower, + UnitOfTime, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import HomeAssistantTuyaData +from .base import ( + ElectricityTypeData, + EnumTypeData, + InkbirdB64TypeData, + IntegerTypeData, + TuyaEntity, +) +from .const import ( + DEVICE_CLASS_UNITS, + DOMAIN, + TUYA_DISCOVERY_NEW, + DPCode, + DPType, + UnitOfMeasurement, + UnitOfTemperature, +) + + +@dataclass +class TuyaSensorEntityDescription(SensorEntityDescription): + """Describes Tuya sensor entity.""" + + subkey: str | None = None + + +# Commonly used battery sensors, that are re-used in the sensors down below. +BATTERY_SENSORS: tuple[TuyaSensorEntityDescription, ...] = ( + TuyaSensorEntityDescription( + key=DPCode.BATTERY_PERCENTAGE, + translation_key="battery", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TuyaSensorEntityDescription( + key=DPCode.BATTERY_STATE, + translation_key="battery_state", + icon="mdi:battery", + entity_category=EntityCategory.DIAGNOSTIC, + ), + TuyaSensorEntityDescription( + key=DPCode.BATTERY_VALUE, + translation_key="battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VA_BATTERY, + translation_key="battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), +) + +# All descriptions can be found here. Mostly the Integer data types in the +# default status set of each category (that don't have a set instruction) +# end up being a sensor. +# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq +SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { + # Multi-functional Sensor + # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 + "dgnbj": ( + TuyaSensorEntityDescription( + key=DPCode.GAS_SENSOR_VALUE, + translation_key="gas", + icon="mdi:gas-cylinder", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CH4_SENSOR_VALUE, + translation_key="gas", + name="Methane", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VOC_VALUE, + translation_key="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.PM25_VALUE, + translation_key="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CO_VALUE, + translation_key="carbon_monoxide", + icon="mdi:molecule-co", + device_class=SensorDeviceClass.CO, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CO2_VALUE, + translation_key="carbon_dioxide", + icon="mdi:molecule-co2", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CH2O_VALUE, + translation_key="formaldehyde", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.BRIGHT_STATE, + translation_key="luminosity", + icon="mdi:brightness-6", + ), + TuyaSensorEntityDescription( + key=DPCode.BRIGHT_VALUE, + translation_key="illuminance", + icon="mdi:brightness-6", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.SMOKE_SENSOR_VALUE, + translation_key="smoke_amount", + icon="mdi:smoke-detector", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Smart Kettle + # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 + "bh": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="current_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_F, + translation_key="current_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.STATUS, + translation_key="status", + ), + ), + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CO2_VALUE, + translation_key="carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Two-way temperature and humidity switch + # "MOES Temperature and Humidity Smart Switch Module MS-103" + # Documentation not found + "wkcz": ( + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # CO Detector + # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v + "cobj": ( + TuyaSensorEntityDescription( + key=DPCode.CO_VALUE, + translation_key="carbon_monoxide", + device_class=SensorDeviceClass.CO, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Smart Pet Feeder + # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld + "cwwsq": ( + TuyaSensorEntityDescription( + key=DPCode.FEED_REPORT, + translation_key="last_amount", + icon="mdi:counter", + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # Air Quality Monitor + # No specification on Tuya portal + "hjjcy": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CO2_VALUE, + translation_key="carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CH2O_VALUE, + translation_key="formaldehyde", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VOC_VALUE, + translation_key="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.PM25_VALUE, + translation_key="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # Formaldehyde Detector + # Note: Not documented + "jqbj": ( + TuyaSensorEntityDescription( + key=DPCode.CO2_VALUE, + translation_key="carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VOC_VALUE, + translation_key="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.PM25_VALUE, + translation_key="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VA_HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VA_TEMPERATURE, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CH2O_VALUE, + translation_key="formaldehyde", + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Methane Detector + # https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm + "jwbj": ( + TuyaSensorEntityDescription( + key=DPCode.CH4_SENSOR_VALUE, + translation_key="methane", + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Switch + # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + "kg": ( + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + ), + # IoT Switch + # Note: Undocumented + "tdq": ( + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + ), + # Luminance Sensor + # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 + "ldcg": ( + TuyaSensorEntityDescription( + key=DPCode.BRIGHT_STATE, + translation_key="luminosity", + icon="mdi:brightness-6", + ), + TuyaSensorEntityDescription( + key=DPCode.BRIGHT_VALUE, + translation_key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CO2_VALUE, + translation_key="carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Door and Window Controller + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r5zjsy9 + "mc": BATTERY_SENSORS, + # Door Window Sensor + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48hm02l8m + "mcs": BATTERY_SENSORS, + # Sous Vide Cooker + # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux + "mzj": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="current_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.STATUS, + translation_key="sous_vide_status", + ), + TuyaSensorEntityDescription( + key=DPCode.REMAIN_TIME, + translation_key="remaining_time", + native_unit_of_measurement=UnitOfTime.MINUTES, + icon="mdi:timer", + ), + ), + # PIR Detector + # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 + "pir": BATTERY_SENSORS, + # PM2.5 Sensor + # https://developer.tuya.com/en/docs/iot/categorypm25?id=Kaiuz3qof3yfu + "pm2.5": ( + TuyaSensorEntityDescription( + key=DPCode.PM25_VALUE, + translation_key="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CH2O_VALUE, + translation_key="formaldehyde", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VOC_VALUE, + translation_key="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CO2_VALUE, + translation_key="carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.PM1, + translation_key="pm1", + device_class=SensorDeviceClass.PM1, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.PM10, + translation_key="pm10", + device_class=SensorDeviceClass.PM10, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Heater + # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm + "qn": ( + TuyaSensorEntityDescription( + key=DPCode.WORK_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # Gas Detector + # https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw + "rqbj": ( + TuyaSensorEntityDescription( + key=DPCode.GAS_SENSOR_VALUE, + name=None, + icon="mdi:gas-cylinder", + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Water Detector + # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli + "sj": BATTERY_SENSORS, + # Emergency Button + # https://developer.tuya.com/en/docs/iot/categorysos?id=Kaiuz3oi6agjy + "sos": BATTERY_SENSORS, + # Smart Camera + # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 + "sp": ( + TuyaSensorEntityDescription( + key=DPCode.SENSOR_TEMPERATURE, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.SENSOR_HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.WIRELESS_ELECTRICITY, + translation_key="battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # Fingerbot + "szjqr": BATTERY_SENSORS, + # Solar Light + # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 + "tyndj": BATTERY_SENSORS, + # Volatile Organic Compound Sensor + # Note: Undocumented in cloud API docs, based on test device + "voc": ( + TuyaSensorEntityDescription( + key=DPCode.CO2_VALUE, + translation_key="carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.PM25_VALUE, + translation_key="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CH2O_VALUE, + translation_key="formaldehyde", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VOC_VALUE, + translation_key="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Thermostatic Radiator Valve + # Not documented + "wkf": BATTERY_SENSORS, + # Temperature and Humidity Sensor + # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 + "wsdcg": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_UNIT_CONVERT, + translation_key="temp_unit_convert", + name="temp_unit_convert", + ), + TuyaSensorEntityDescription( + key=DPCode.CHANNEL_0, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + name="base_station_temperature", + subkey="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + TuyaSensorEntityDescription( + key=DPCode.CHANNEL_0, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + name="base_station_humidity", + subkey="humidity", + ), + *[ + i + for dp_code in ( + DPCode.CHANNEL_1, + DPCode.CHANNEL_2, + DPCode.CHANNEL_3, + DPCode.CHANNEL_4, + DPCode.CHANNEL_5, + DPCode.CHANNEL_6, + DPCode.CHANNEL_7, + DPCode.CHANNEL_8, + DPCode.CHANNEL_9, + ) + for i in ( + TuyaSensorEntityDescription( + key=dp_code, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + name=f"{dp_code.value}_temperature", + subkey="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + TuyaSensorEntityDescription( + key=dp_code, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + name=f"{dp_code.value}_battery", + subkey="battery", + ), + ) + ], + TuyaSensorEntityDescription( + key=DPCode.VA_TEMPERATURE, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VA_HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.BRIGHT_VALUE, + translation_key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Pressure Sensor + # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm + "ylcg": ( + TuyaSensorEntityDescription( + key=DPCode.PRESSURE_VALUE, + name=None, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Smoke Detector + # https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952 + "ywbj": ( + TuyaSensorEntityDescription( + key=DPCode.SMOKE_SENSOR_VALUE, + translation_key="smoke_amount", + icon="mdi:smoke-detector", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Vibration Sensor + # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno + "zd": BATTERY_SENSORS, + # Smart Electricity Meter + # https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7 + "zndb": ( + TuyaSensorEntityDescription( + key=DPCode.FORWARD_ENERGY_TOTAL, + translation_key="total_energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_A, + translation_key="phase_a_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + subkey="electriccurrent", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_A, + translation_key="phase_a_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + subkey="power", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_A, + translation_key="phase_a_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + subkey="voltage", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_B, + translation_key="phase_b_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + subkey="electriccurrent", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_B, + translation_key="phase_b_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + subkey="power", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_B, + translation_key="phase_b_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + subkey="voltage", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_C, + translation_key="phase_c_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + subkey="electriccurrent", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_C, + translation_key="phase_c_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + subkey="power", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_C, + translation_key="phase_c_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + subkey="voltage", + ), + ), + # Circuit Breaker + # https://developer.tuya.com/en/docs/iot/dlq?id=Kb0kidk9enyh8 + "dlq": ( + TuyaSensorEntityDescription( + key=DPCode.TOTAL_FORWARD_ENERGY, + translation_key="total_energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_A, + translation_key="phase_a_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + subkey="electriccurrent", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_A, + translation_key="phase_a_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + subkey="power", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_A, + translation_key="phase_a_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + subkey="voltage", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_B, + translation_key="phase_b_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + subkey="electriccurrent", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_B, + translation_key="phase_b_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + subkey="power", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_B, + translation_key="phase_b_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + subkey="voltage", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_C, + translation_key="phase_c_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + subkey="electriccurrent", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_C, + translation_key="phase_c_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + subkey="power", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_C, + translation_key="phase_c_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + subkey="voltage", + ), + ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + TuyaSensorEntityDescription( + key=DPCode.CLEAN_AREA, + translation_key="cleaning_area", + icon="mdi:texture-box", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CLEAN_TIME, + translation_key="cleaning_time", + icon="mdi:progress-clock", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TOTAL_CLEAN_AREA, + translation_key="total_cleaning_area", + icon="mdi:texture-box", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.TOTAL_CLEAN_TIME, + translation_key="total_cleaning_time", + icon="mdi:history", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.TOTAL_CLEAN_COUNT, + translation_key="total_cleaning_times", + icon="mdi:counter", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.DUSTER_CLOTH, + translation_key="duster_cloth_life", + icon="mdi:ticket-percent-outline", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.EDGE_BRUSH, + translation_key="side_brush_life", + icon="mdi:ticket-percent-outline", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.FILTER_LIFE, + translation_key="filter_life", + icon="mdi:ticket-percent-outline", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.ROLL_BRUSH, + translation_key="rolling_brush_life", + icon="mdi:ticket-percent-outline", + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # Curtain + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qy7wkre + "cl": ( + TuyaSensorEntityDescription( + key=DPCode.TIME_TOTAL, + translation_key="last_operation_duration", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:progress-clock", + ), + ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qwjz0i3 + "jsq": ( + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_CURRENT, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_F, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.LEVEL_CURRENT, + translation_key="water_level", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:waves-arrow-up", + ), + ), + # Air Purifier + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r41mn81 + "kj": ( + TuyaSensorEntityDescription( + key=DPCode.FILTER, + translation_key="filter_utilization", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:ticket-percent-outline", + ), + TuyaSensorEntityDescription( + key=DPCode.PM25, + translation_key="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:molecule", + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TVOC, + translation_key="total_volatile_organic_compound", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.ECO2, + translation_key="concentration_carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TOTAL_TIME, + translation_key="total_operating_time", + icon="mdi:history", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TuyaSensorEntityDescription( + key=DPCode.TOTAL_PM, + translation_key="total_absorption_particles", + icon="mdi:texture-box", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TuyaSensorEntityDescription( + key=DPCode.AIR_QUALITY, + translation_key="air_quality", + icon="mdi:air-filter", + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48quojr54 + "fs": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # eMylo Smart WiFi IR Remote + # Air Conditioner Mate (Smart IR Socket) + "wnykq": ( + TuyaSensorEntityDescription( + key=DPCode.VA_TEMPERATURE, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VA_HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + ), + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r6jke8e + "cs": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_INDOOR, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_INDOOR, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # Soil sensor (Plant monitor) + "zwjcy": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), +} + +# Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +SENSORS["cz"] = SENSORS["kg"] + +# Power Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +SENSORS["pc"] = SENSORS["kg"] + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Tuya sensor 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 sensor.""" + entities: list[TuyaSensorEntity] = [] + for device_id in device_ids: + device = hass_data.device_manager.device_map[device_id] + if descriptions := SENSORS.get(device.category): + for description in descriptions: + if description.key in device.status: + entities.append( + TuyaSensorEntity( + 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 TuyaSensorEntity(TuyaEntity, SensorEntity): + """Tuya Sensor Entity.""" + + entity_description: TuyaSensorEntityDescription + + _status_range: TuyaDeviceStatusRange | None = None + _type: DPType | None = None + _type_data: IntegerTypeData | EnumTypeData | None = None + _uom: UnitOfMeasurement | None = None + + def __init__( + self, + device: TuyaDevice, + device_manager: TuyaDeviceManager, + description: TuyaSensorEntityDescription, + ) -> None: + """Init Tuya sensor.""" + super().__init__(device, device_manager) + self.entity_description = description + self._attr_unique_id = ( + f"{super().unique_id}{description.key}{description.subkey or ''}" + ) + + if int_type := self.find_dpcode(description.key, dptype=DPType.INTEGER): + self._type_data = int_type + self._type = DPType.INTEGER + if description.native_unit_of_measurement is None: + self._attr_native_unit_of_measurement = int_type.unit + elif enum_type := self.find_dpcode( + description.key, dptype=DPType.ENUM, prefer_function=True + ): + self._type_data = enum_type + self._type = DPType.ENUM + else: + self._type = self.get_dptype(DPCode(description.key)) + + # Logic to ensure the set device class and API received Unit Of Measurement + # match Home Assistants requirements. + if ( + self.device_class is not None + and not self.device_class.startswith(DOMAIN) + and description.native_unit_of_measurement is None + ): + # We cannot have a device class, if the UOM isn't set or the + # device class cannot be found in the validation mapping. + if ( + self.native_unit_of_measurement is None + or self.device_class not in DEVICE_CLASS_UNITS + ): + self._attr_device_class = None + return + + uoms = DEVICE_CLASS_UNITS[self.device_class] + self._uom = uoms.get(self.native_unit_of_measurement) or uoms.get( + self.native_unit_of_measurement.lower() + ) + + # Unknown unit of measurement, device class should not be used. + if self._uom is None: + self._attr_device_class = None + return + + # If we still have a device class, we should not use an icon + if self.device_class: + self._attr_icon = None + + # Found unit of measurement, use the standardized Unit + # Use the target conversion unit (if set) + self._attr_native_unit_of_measurement = ( + self._uom.conversion_unit or self._uom.unit + ) + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + # Only continue if data type is known + if self._type not in ( + DPType.INTEGER, + DPType.STRING, + DPType.ENUM, + DPType.JSON, + DPType.RAW, + ): + return None + + # Raw value + value = self.device.status.get(self.entity_description.key) + if value is None: + return None + + # Scale integer/float value + if isinstance(self._type_data, IntegerTypeData): + scaled_value = self._type_data.scale_value(value) + if self._uom and self._uom.conversion_fn is not None: + return self._uom.conversion_fn(scaled_value) + return scaled_value + + # Unexpected enum value + if ( + isinstance(self._type_data, EnumTypeData) + and value not in self._type_data.range + ): + return None + + values: None | ElectricityTypeData | InkbirdB64TypeData = None + + # Get subkey value from Json string. + if self._type is DPType.JSON: + if self.entity_description.subkey is None: + return None + values = ElectricityTypeData.from_json(value) + return getattr(values, self.entity_description.subkey) + + if self._type is DPType.RAW: + if self.entity_description.subkey is None: + return None + + if self.entity_description.subkey in [ + "temperature", + "humidity", + "battery", + ]: + try: + values = InkbirdB64TypeData.from_raw(value) + except ValueError: + return None + else: + values = ElectricityTypeData.from_raw(value) + + return getattr(values, self.entity_description.subkey) + + # Valid string or enum value + return value diff --git a/custom_components/tuya_openapi/siren.py b/custom_components/tuya_openapi/siren.py new file mode 100644 index 0000000..c2dc8ce --- /dev/null +++ b/custom_components/tuya_openapi/siren.py @@ -0,0 +1,107 @@ +"""Support for Tuya siren.""" +from __future__ import annotations + +from typing import Any + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.components.siren import ( + SirenEntity, + SirenEntityDescription, + SirenEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeAssistantTuyaData +from .base import TuyaEntity +from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode + +# All descriptions can be found here: +# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq +SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = { + # Multi-functional Sensor + # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 + "dgnbj": ( + SirenEntityDescription( + key=DPCode.ALARM_SWITCH, + ), + ), + # Siren Alarm + # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + "sgbj": ( + SirenEntityDescription( + key=DPCode.ALARM_SWITCH, + ), + ), + # Smart Camera + # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 + "sp": ( + SirenEntityDescription( + key=DPCode.SIREN_SWITCH, + ), + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Tuya siren 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 siren.""" + entities: list[TuyaSirenEntity] = [] + for device_id in device_ids: + device = hass_data.device_manager.device_map[device_id] + if descriptions := SIRENS.get(device.category): + for description in descriptions: + if description.key in device.status: + entities.append( + TuyaSirenEntity( + 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 TuyaSirenEntity(TuyaEntity, SirenEntity): + """Tuya Siren Entity.""" + + _attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF + _attr_name = None + + def __init__( + self, + device: TuyaDevice, + device_manager: TuyaDeviceManager, + description: SirenEntityDescription, + ) -> None: + """Init Tuya Siren.""" + super().__init__(device, device_manager) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}{description.key}" + + @property + def is_on(self) -> bool: + """Return true if siren is on.""" + return self.device.status.get(self.entity_description.key, False) + + def turn_on(self, **kwargs: Any) -> None: + """Turn the siren on.""" + self._send_command([{"code": self.entity_description.key, "value": True}]) + + def turn_off(self, **kwargs: Any) -> None: + """Turn the siren off.""" + self._send_command([{"code": self.entity_description.key, "value": False}]) diff --git a/custom_components/tuya_openapi/strings.json b/custom_components/tuya_openapi/strings.json new file mode 100644 index 0000000..1ea58f5 --- /dev/null +++ b/custom_components/tuya_openapi/strings.json @@ -0,0 +1,829 @@ +{ + "config": { + "step": { + "user": { + "description": "Enter your Tuya credentials", + "data": { + "country_code": "Country", + "access_id": "Tuya IoT Access ID", + "access_secret": "Tuya IoT Access Secret", + "username": "Account", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "login_error": "Login error ({code}): {msg}" + } + }, + "entity": { + "binary_sensor": { + "methane": { + "name": "Methane" + }, + "voc": { + "name": "VOCs" + }, + "pm25": { + "name": "PM2.5" + }, + "carbon_monoxide": { + "name": "Carbon monoxide" + }, + "carbon_dioxide": { + "name": "Carbon dioxide" + }, + "formaldehyde": { + "name": "Formaldehyde" + }, + "pressure": { + "name": "Pressure" + }, + "feeding": { + "name": "Feeding" + }, + "drop": { + "name": "Drop" + }, + "tilt": { + "name": "Tilt" + } + }, + "button": { + "reset_duster_cloth": { + "name": "Reset duster cloth" + }, + "reset_edge_brush": { + "name": "Reset edge brush" + }, + "reset_filter": { + "name": "Reset filter" + }, + "reset_map": { + "name": "Reset map" + }, + "reset_roll_brush": { + "name": "Reset roll brush" + }, + "snooze": { + "name": "Snooze" + } + }, + "cover": { + "blind": { + "name": "[%key:component::cover::entity_component::blind::name%]" + }, + "curtain": { + "name": "[%key:component::cover::entity_component::curtain::name%]" + }, + "curtain_2": { + "name": "Curtain 2" + }, + "curtain_3": { + "name": "Curtain 3" + }, + "door": { + "name": "[%key:component::cover::entity_component::door::name%]" + }, + "door_2": { + "name": "Door 2" + }, + "door_3": { + "name": "Door 3" + } + }, + "light": { + "backlight": { + "name": "Backlight" + }, + "light": { + "name": "[%key:component::light::title%]" + }, + "light_2": { + "name": "Light 2" + }, + "light_3": { + "name": "Light 3" + }, + "night_light": { + "name": "Night light" + } + }, + "number": { + "temperature": { + "name": "[%key:component::number::entity_component::temperature::name%]" + }, + "time": { + "name": "Time" + }, + "temperature_after_boiling": { + "name": "Temperature after boiling" + }, + "heat_preservation_time": { + "name": "Heat preservation time" + }, + "feed": { + "name": "Feed" + }, + "voice_times": { + "name": "Voice times" + }, + "sensitivity": { + "name": "Sensitivity" + }, + "near_detection": { + "name": "Near detection" + }, + "far_detection": { + "name": "Far detection" + }, + "water_level": { + "name": "Water level" + }, + "powder": { + "name": "Powder" + }, + "cook_temperature": { + "name": "Cook temperature" + }, + "cook_time": { + "name": "Cook time" + }, + "cloud_recipe": { + "name": "Cloud recipe" + }, + "volume": { + "name": "Volume" + }, + "minimum_brightness": { + "name": "Minimum brightness" + }, + "maximum_brightness": { + "name": "Maximum brightness" + }, + "minimum_brightness_2": { + "name": "Minimum brightness 2" + }, + "maximum_brightness_2": { + "name": "Maximum brightness 2" + }, + "minimum_brightness_3": { + "name": "Minimum brightness 3" + }, + "maximum_brightness_3": { + "name": "Maximum brightness 3" + }, + "move_down": { + "name": "Move down" + }, + "move_up": { + "name": "Move up" + }, + "down_delay": { + "name": "Down delay" + } + }, + "select": { + "volume": { + "name": "[%key:component::tuya::entity::number::volume::name%]" + }, + "cups": { + "name": "Cups" + }, + "concentration": { + "name": "Concentration" + }, + "material": { + "name": "Material" + }, + "mode": { + "name": "Mode" + }, + "temperature_level": { + "name": "Temperature level" + }, + "brightness": { + "name": "Brightness" + }, + "target_humidity": { + "name": "Target humidity" + }, + "basic_anti_flicker": { + "name": "Anti-flicker", + "state": { + "0": "[%key:common::state::disabled%]", + "1": "50 Hz", + "2": "60 Hz" + } + }, + "basic_nightvision": { + "name": "Night vision", + "state": { + "0": "Automatic", + "1": "[%key:common::state::off%]", + "2": "[%key:common::state::on%]" + } + }, + "decibel_sensitivity": { + "name": "Sound detection sensitivity", + "state": { + "0": "Low sensitivity", + "1": "High sensitivity" + } + }, + "ipc_work_mode": { + "name": "IPC mode", + "state": { + "0": "Low power mode", + "1": "Continuous working mode" + } + }, + "led_type": { + "name": "Light source type", + "state": { + "halogen": "Halogen", + "incandescent": "Incandescent", + "led": "LED" + } + }, + "led_type_2": { + "name": "Light 2 source type", + "state": { + "halogen": "[%key:component::tuya::entity::select::led_type::state::halogen%]", + "incandescent": "[%key:component::tuya::entity::select::led_type::state::incandescent%]", + "led": "[%key:component::tuya::entity::select::led_type::state::led%]" + } + }, + "led_type_3": { + "name": "Light 3 source type", + "state": { + "halogen": "[%key:component::tuya::entity::select::led_type::state::halogen%]", + "incandescent": "[%key:component::tuya::entity::select::led_type::state::incandescent%]", + "led": "[%key:component::tuya::entity::select::led_type::state::led%]" + } + }, + "light_mode": { + "name": "Indicator light mode", + "state": { + "none": "[%key:common::state::off%]", + "pos": "Indicate switch location", + "relay": "Indicate switch on/off state" + } + }, + "motion_sensitivity": { + "name": "Motion detection sensitivity", + "state": { + "0": "Low sensitivity", + "1": "Medium sensitivity", + "2": "High sensitivity" + } + }, + "record_mode": { + "name": "Record mode", + "state": { + "1": "Record events only", + "2": "Continuous recording" + } + }, + "relay_status": { + "name": "Power on behavior", + "state": { + "last": "Remember last state", + "memory": "[%key:component::tuya::entity::select::relay_status::state::last%]", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "power_off": "[%key:common::state::off%]", + "power_on": "[%key:common::state::on%]" + } + }, + "fingerbot_mode": { + "name": "Mode", + "state": { + "click": "Push", + "switch": "Switch" + } + }, + "vacuum_cistern": { + "name": "Water tank adjustment", + "state": { + "low": "Low", + "middle": "Middle", + "high": "High", + "closed": "[%key:common::state::closed%]" + } + }, + "vacuum_collection": { + "name": "Dust collection mode", + "state": { + "small": "Small", + "middle": "Middle", + "large": "Large" + } + }, + "vacuum_mode": { + "name": "Mode", + "state": { + "standby": "[%key:common::state::standby%]", + "random": "Random", + "smart": "Smart", + "wall_follow": "Follow wall", + "mop": "Mop", + "spiral": "Spiral", + "left_spiral": "Spiral left", + "right_spiral": "Spiral right", + "bow": "Bow", + "left_bow": "Bow left", + "right_bow": "Bow right", + "partial_bow": "Bow partially", + "chargego": "Return to dock", + "single": "Single", + "zone": "Zone", + "pose": "Pose", + "point": "Point", + "part": "Part", + "pick_zone": "Pick zone" + } + }, + "vertical_fan_angle": { + "name": "Vertical swing flap angle", + "state": { + "30": "30°", + "60": "60°", + "90": "90°" + } + }, + "horizontal_fan_angle": { + "name": "Horizontal swing flap angle", + "state": { + "30": "30°", + "60": "60°", + "90": "90°" + } + }, + "curtain_mode": { + "name": "Mode", + "state": { + "morning": "Morning", + "night": "Night" + } + }, + "curtain_motor_mode": { + "name": "Motor mode", + "state": { + "forward": "Forward", + "back": "Back" + } + }, + "countdown": { + "name": "Countdown", + "state": { + "cancel": "Cancel", + "1h": "1 hour", + "2h": "2 hours", + "3h": "3 hours", + "4h": "4 hours", + "5h": "5 hours", + "6h": "6 hours" + } + }, + "humidifier_spray_mode": { + "name": "Spray mode", + "state": { + "auto": "Auto", + "health": "Health", + "sleep": "Sleep", + "humidity": "Humidity", + "work": "Work" + } + }, + "humidifier_level": { + "name": "Spraying level", + "state": { + "level_1": "Level 1", + "level_2": "Level 2", + "level_3": "Level 3", + "level_4": "Level 4", + "level_5": "Level 5", + "level_6": "Level 6", + "level_7": "Level 7", + "level_8": "Level 8", + "level_9": "Level 9", + "level_10": "Level 10" + } + }, + "humidifier_moodlighting": { + "name": "Moodlighting", + "state": { + "1": "Mood 1", + "2": "Mood 2", + "3": "Mood 3", + "4": "Mood 4", + "5": "Mood 5" + } + } + }, + "sensor": { + "battery": { + "name": "[%key:component::sensor::entity_component::battery::name%]" + }, + "voc": { + "name": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]" + }, + "carbon_monoxide": { + "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" + }, + "carbon_dioxide": { + "name": "[%key:component::sensor::entity_component::carbon_dioxide::name%]" + }, + "illuminance": { + "name": "[%key:component::sensor::entity_component::illuminance::name%]" + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + }, + "pm25": { + "name": "[%key:component::sensor::entity_component::pm25::name%]" + }, + "pm1": { + "name": "[%key:component::sensor::entity_component::pm1::name%]" + }, + "pm10": { + "name": "[%key:component::sensor::entity_component::pm10::name%]" + }, + "current": { + "name": "[%key:component::sensor::entity_component::current::name%]" + }, + "power": { + "name": "[%key:component::sensor::entity_component::power::name%]" + }, + "voltage": { + "name": "[%key:component::sensor::entity_component::voltage::name%]" + }, + "battery_state": { + "name": "Battery state" + }, + "gas": { + "name": "Gas" + }, + "formaldehyde": { + "name": "[%key:component::tuya::entity::binary_sensor::formaldehyde::name%]" + }, + "luminosity": { + "name": "Luminosity" + }, + "smoke_amount": { + "name": "Smoke amount" + }, + "current_temperature": { + "name": "Current temperature" + }, + "status": { + "name": "Status" + }, + "last_amount": { + "name": "Last amount" + }, + "remaining_time": { + "name": "Remaining time" + }, + "methane": { + "name": "[%key:component::tuya::entity::binary_sensor::methane::name%]" + }, + "total_energy": { + "name": "Total energy" + }, + "phase_a_current": { + "name": "Phase A current" + }, + "phase_a_power": { + "name": "Phase A power" + }, + "phase_a_voltage": { + "name": "Phase A voltage" + }, + "phase_b_current": { + "name": "Phase B current" + }, + "phase_b_power": { + "name": "Phase B power" + }, + "phase_b_voltage": { + "name": "Phase B voltage" + }, + "phase_c_current": { + "name": "Phase C current" + }, + "phase_c_power": { + "name": "Phase C power" + }, + "phase_c_voltage": { + "name": "Phase C voltage" + }, + "cleaning_area": { + "name": "Cleaning area" + }, + "cleaning_time": { + "name": "Cleaning time" + }, + "total_cleaning_area": { + "name": "Total cleaning area" + }, + "total_cleaning_time": { + "name": "Total cleaning time" + }, + "total_cleaning_times": { + "name": "Total cleaning times" + }, + "duster_cloth_life": { + "name": "Duster cloth lifetime" + }, + "side_brush_life": { + "name": "Side brush lifetime" + }, + "filter_life": { + "name": "Filter lifetime" + }, + "rolling_brush_life": { + "name": "Rolling brush lifetime" + }, + "last_operation_duration": { + "name": "Last operation duration" + }, + "water_level": { + "name": "Water level" + }, + "filter_utilization": { + "name": "Filter utilization" + }, + "total_volatile_organic_compound": { + "name": "Total volatile organic compound" + }, + "concentration_carbon_dioxide": { + "name": "Concentration of carbon dioxide" + }, + "total_operating_time": { + "name": "Total operating time" + }, + "total_absorption_particles": { + "name": "Total absorption of particles" + }, + "sous_vide_status": { + "name": "Status", + "state": { + "boiling_temp": "Boiling temperature", + "cooling": "Cooling", + "heating_temp": "Heating temperature", + "heating": "Heating", + "reserve_1": "Reserve 1", + "reserve_2": "Reserve 2", + "reserve_3": "Reserve 3", + "standby": "[%key:common::state::standby%]", + "warm": "Heat preservation" + } + }, + "air_quality": { + "name": "Air quality", + "state": { + "great": "Great", + "mild": "Mild", + "good": "Good", + "severe": "Severe" + } + } + }, + "switch": { + "start": { + "name": "Start" + }, + "heat_preservation": { + "name": "Heat preservation" + }, + "disinfection": { + "name": "Disinfection" + }, + "water": { + "name": "Water" + }, + "slow_feed": { + "name": "Slow feed" + }, + "filter_reset": { + "name": "Filter reset" + }, + "water_pump_reset": { + "name": "Water pump reset" + }, + "power": { + "name": "Power" + }, + "reset_of_water_usage_days": { + "name": "Reset of water usage days" + }, + "uv_sterilization": { + "name": "UV sterilization" + }, + "plug": { + "name": "Plug" + }, + "child_lock": { + "name": "Child lock" + }, + "switch": { + "name": "Switch" + }, + "socket": { + "name": "Socket" + }, + "radio": { + "name": "Radio" + }, + "alarm_1": { + "name": "Alarm 1" + }, + "alarm_2": { + "name": "Alarm 2" + }, + "alarm_3": { + "name": "Alarm 3" + }, + "alarm_4": { + "name": "Alarm 4" + }, + "sleep_aid": { + "name": "Sleep aid" + }, + "switch_1": { + "name": "Switch 1" + }, + "switch_2": { + "name": "Switch 2" + }, + "switch_3": { + "name": "Switch 3" + }, + "switch_4": { + "name": "Switch 4" + }, + "switch_5": { + "name": "Switch 5" + }, + "switch_6": { + "name": "Switch 6" + }, + "switch_7": { + "name": "Switch 7" + }, + "switch_8": { + "name": "Switch 8" + }, + "usb_1": { + "name": "USB 1" + }, + "usb_2": { + "name": "USB 2" + }, + "usb_3": { + "name": "USB 3" + }, + "usb_4": { + "name": "USB 4" + }, + "usb_5": { + "name": "USB 5" + }, + "usb_6": { + "name": "USB 6" + }, + "socket_1": { + "name": "Socket 1" + }, + "socket_2": { + "name": "Socket 2" + }, + "socket_3": { + "name": "Socket 3" + }, + "socket_4": { + "name": "Socket 4" + }, + "socket_5": { + "name": "Socket 5" + }, + "socket_6": { + "name": "Socket 6" + }, + "ionizer": { + "name": "Ionizer" + }, + "filter_cartridge_reset": { + "name": "Filter cartridge reset" + }, + "humidification": { + "name": "Humidification" + }, + "do_not_disturb": { + "name": "Do not disturb" + }, + "mute_voice": { + "name": "Mute voice" + }, + "mute": { + "name": "Mute" + }, + "battery_lock": { + "name": "Battery lock" + }, + "cry_detection": { + "name": "Cry detection" + }, + "sound_detection": { + "name": "Sound detection" + }, + "video_recording": { + "name": "Video recording" + }, + "motion_recording": { + "name": "Motion recording" + }, + "privacy_mode": { + "name": "Privacy mode" + }, + "flip": { + "name": "Flip" + }, + "time_watermark": { + "name": "Time watermark" + }, + "wide_dynamic_range": { + "name": "Wide dynamic range" + }, + "motion_tracking": { + "name": "Motion tracking" + }, + "motion_alarm": { + "name": "Motion alarm" + }, + "energy_saving": { + "name": "Energy saving" + }, + "open_window_detection": { + "name": "Open window detection" + }, + "spray": { + "name": "Spray" + }, + "voice": { + "name": "Voice" + }, + "anion": { + "name": "Anion" + }, + "oxygen_bar": { + "name": "Oxygen bar" + }, + "natural_wind": { + "name": "Natural wind" + }, + "sound": { + "name": "Sound" + }, + "reverse": { + "name": "Reverse" + }, + "sleep": { + "name": "Sleep" + }, + "sterilization": { + "name": "Sterilization" + } + } + }, + "issues": { + "service_deprecation_turn_off": { + "title": "Tuya vacuum support for vacuum.turn_off is being removed", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::tuya::issues::service_deprecation_turn_off::title%]", + "description": "Tuya vacuum support for the vacuum.turn_off service is deprecated and will be removed in Home Assistant 2024.2; Please adjust any automation or script that uses the service to instead call vacuum.stop and select submit below to mark this issue as resolved." + } + } + } + }, + "service_deprecation_turn_on": { + "title": "Tuya vacuum support for vacuum.turn_on is being removed", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::tuya::issues::service_deprecation_turn_on::title%]", + "description": "Tuya vacuum support for the vacuum.turn_on service is deprecated and will be removed in Home Assistant 2024.2; Please adjust any automation or script that uses the service to instead call vacuum.start and select submit below to mark this issue as resolved." + } + } + } + } + } +} diff --git a/custom_components/tuya_openapi/switch.py b/custom_components/tuya_openapi/switch.py new file mode 100644 index 0000000..a48d797 --- /dev/null +++ b/custom_components/tuya_openapi/switch.py @@ -0,0 +1,769 @@ +"""Support for Tuya switches.""" +from __future__ import annotations + +from typing import Any + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeAssistantTuyaData +from .base import TuyaEntity +from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode + +# All descriptions can be found here. Mostly the Boolean data types in the +# default instruction set of each category end up being a Switch. +# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq +SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { + # Smart Kettle + # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 + "bh": ( + SwitchEntityDescription( + key=DPCode.START, + translation_key="start", + icon="mdi:kettle-steam", + ), + SwitchEntityDescription( + key=DPCode.WARM, + translation_key="heat_preservation", + entity_category=EntityCategory.CONFIG, + ), + ), + # EasyBaby + # Undocumented, might have a wider use + "cn": ( + SwitchEntityDescription( + key=DPCode.DISINFECTION, + translation_key="disinfection", + icon="mdi:bacteria", + ), + SwitchEntityDescription( + key=DPCode.WATER, + translation_key="water", + icon="mdi:water", + ), + ), + # Smart Pet Feeder + # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld + "cwwsq": ( + SwitchEntityDescription( + key=DPCode.SLOW_FEED, + translation_key="slow_feed", + icon="mdi:speedometer-slow", + entity_category=EntityCategory.CONFIG, + ), + ), + # Pet Water Feeder + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46aewxem5 + "cwysj": ( + SwitchEntityDescription( + key=DPCode.FILTER_RESET, + translation_key="filter_reset", + icon="mdi:filter", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.PUMP_RESET, + translation_key="water_pump_reset", + icon="mdi:pump", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="power", + ), + SwitchEntityDescription( + key=DPCode.WATER_RESET, + translation_key="reset_of_water_usage_days", + icon="mdi:water-sync", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.UV, + translation_key="uv_sterilization", + icon="mdi:lightbulb", + entity_category=EntityCategory.CONFIG, + ), + ), + # Light + # https://developer.tuya.com/en/docs/iot/f?id=K9i5ql3v98hn3 + "dj": ( + # There are sockets available with an RGB light + # that advertise as `dj`, but provide an additional + # switch to control the plug. + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="plug", + ), + ), + # Circuit Breaker + "dlq": ( + SwitchEntityDescription( + key=DPCode.CHILD_LOCK, + translation_key="child_lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + ), + ), + # Wake Up Light II + # Not documented + "hxd": ( + SwitchEntityDescription( + key=DPCode.SWITCH_1, + translation_key="radio", + icon="mdi:radio", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_2, + translation_key="alarm_1", + icon="mdi:alarm", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_3, + translation_key="alarm_2", + icon="mdi:alarm", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_4, + translation_key="alarm_3", + icon="mdi:alarm", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_5, + translation_key="alarm_4", + icon="mdi:alarm", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_6, + translation_key="sleep_aid", + icon="mdi:power-sleep", + ), + ), + # Two-way temperature and humidity switch + # "MOES Temperature and Humidity Smart Switch Module MS-103" + # Documentation not found + "wkcz": ( + SwitchEntityDescription( + key=DPCode.SWITCH_1, + translation_key="switch_1", + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_2, + translation_key="switch_2", + device_class=SwitchDeviceClass.OUTLET, + ), + ), + # Switch + # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + "kg": ( + SwitchEntityDescription( + key=DPCode.CHILD_LOCK, + translation_key="child_lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_1, + translation_key="switch_1", + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_2, + translation_key="switch_2", + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_3, + translation_key="switch_3", + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_4, + translation_key="switch_4", + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_5, + translation_key="switch_5", + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_6, + translation_key="switch_6", + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_7, + translation_key="switch_7", + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_8, + translation_key="switch_8", + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_USB1, + translation_key="usb_1", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_USB2, + translation_key="usb_2", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_USB3, + translation_key="usb_3", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_USB4, + translation_key="usb_4", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_USB5, + translation_key="usb_5", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_USB6, + translation_key="usb_6", + ), + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + device_class=SwitchDeviceClass.OUTLET, + ), + ), + # Air Purifier + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm + "kj": ( + SwitchEntityDescription( + key=DPCode.ANION, + translation_key="ionizer", + icon="mdi:minus-circle-outline", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.FILTER_RESET, + translation_key="filter_cartridge_reset", + icon="mdi:filter", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.LOCK, + translation_key="child_lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="power", + ), + SwitchEntityDescription( + key=DPCode.WET, + translation_key="humidification", + icon="mdi:water-percent", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.UV, + translation_key="uv_sterilization", + icon="mdi:minus-circle-outline", + entity_category=EntityCategory.CONFIG, + ), + ), + # Air conditioner + # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n + "kt": ( + SwitchEntityDescription( + key=DPCode.ANION, + translation_key="ionizer", + icon="mdi:minus-circle-outline", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.LOCK, + translation_key="child_lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + ), + # Sous Vide Cooker + # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux + "mzj": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + icon="mdi:power", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.START, + translation_key="start", + icon="mdi:pot-steam", + entity_category=EntityCategory.CONFIG, + ), + ), + # Power Socket + # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + "pc": ( + SwitchEntityDescription( + key=DPCode.CHILD_LOCK, + translation_key="child_lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_1, + translation_key="socket_1", + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_2, + translation_key="socket_2", + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_3, + translation_key="socket_3", + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_4, + translation_key="socket_4", + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_5, + translation_key="socket_5", + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_6, + translation_key="socket_6", + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_USB1, + translation_key="usb_1", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_USB2, + translation_key="usb_2", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_USB3, + translation_key="usb_3", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_USB4, + translation_key="usb_4", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_USB5, + translation_key="usb_5", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_USB6, + translation_key="usb_6", + ), + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="socket", + device_class=SwitchDeviceClass.OUTLET, + ), + ), + # Unknown product with switch capabilities + # Fond in some diffusers, plugs and PIR flood lights + # Not documented + "qjdcz": ( + SwitchEntityDescription( + key=DPCode.SWITCH_1, + translation_key="switch", + ), + ), + # Heater + # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm + "qn": ( + SwitchEntityDescription( + key=DPCode.ANION, + translation_key="ionizer", + icon="mdi:minus-circle-outline", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.LOCK, + translation_key="child_lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + SwitchEntityDescription( + key=DPCode.SWITCH_DISTURB, + translation_key="do_not_disturb", + icon="mdi:minus-circle", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.VOICE_SWITCH, + translation_key="mute_voice", + icon="mdi:account-voice", + entity_category=EntityCategory.CONFIG, + ), + ), + # Siren Alarm + # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + "sgbj": ( + SwitchEntityDescription( + key=DPCode.MUFFLING, + translation_key="mute", + entity_category=EntityCategory.CONFIG, + ), + ), + # Smart Camera + # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 + "sp": ( + SwitchEntityDescription( + key=DPCode.WIRELESS_BATTERYLOCK, + translation_key="battery_lock", + icon="mdi:battery-lock", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.CRY_DETECTION_SWITCH, + translation_key="cry_detection", + icon="mdi:emoticon-cry", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.DECIBEL_SWITCH, + translation_key="sound_detection", + icon="mdi:microphone-outline", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.RECORD_SWITCH, + translation_key="video_recording", + icon="mdi:record-rec", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.MOTION_RECORD, + translation_key="motion_recording", + icon="mdi:record-rec", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.BASIC_PRIVATE, + translation_key="privacy_mode", + icon="mdi:eye-off", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.BASIC_FLIP, + translation_key="flip", + icon="mdi:flip-horizontal", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.BASIC_OSD, + translation_key="time_watermark", + icon="mdi:watermark", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.BASIC_WDR, + translation_key="wide_dynamic_range", + icon="mdi:watermark", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.MOTION_TRACKING, + translation_key="motion_tracking", + icon="mdi:motion-sensor", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.MOTION_SWITCH, + translation_key="motion_alarm", + icon="mdi:motion-sensor", + entity_category=EntityCategory.CONFIG, + ), + ), + # Fingerbot + "szjqr": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + icon="mdi:cursor-pointer", + ), + ), + # IoT Switch? + # Note: Undocumented + "tdq": ( + SwitchEntityDescription( + key=DPCode.SWITCH_1, + translation_key="switch_1", + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_2, + translation_key="switch_2", + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_3, + translation_key="switch_3", + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_4, + translation_key="switch_4", + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.CHILD_LOCK, + translation_key="child_lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + ), + # Solar Light + # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 + "tyndj": ( + SwitchEntityDescription( + key=DPCode.SWITCH_SAVE_ENERGY, + translation_key="energy_saving", + icon="mdi:leaf", + entity_category=EntityCategory.CONFIG, + ), + ), + # Thermostatic Radiator Valve + # Not documented + "wkf": ( + SwitchEntityDescription( + key=DPCode.CHILD_LOCK, + translation_key="child_lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.WINDOW_CHECK, + translation_key="open_window_detection", + icon="mdi:window-open", + entity_category=EntityCategory.CONFIG, + ), + ), + # Air Conditioner Mate (Smart IR Socket) + "wnykq": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + name=None, + ), + ), + # SIREN: Siren (switch) with Temperature and humidity sensor + # https://developer.tuya.com/en/docs/iot/f?id=Kavck4sr3o5ek + "wsdcg": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + device_class=SwitchDeviceClass.OUTLET, + ), + ), + # Ceiling Light + # https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r + "xdd": ( + SwitchEntityDescription( + key=DPCode.DO_NOT_DISTURB, + translation_key="do_not_disturb", + icon="mdi:minus-circle-outline", + entity_category=EntityCategory.CONFIG, + ), + ), + # Diffuser + # https://developer.tuya.com/en/docs/iot/categoryxxj?id=Kaiuz1f9mo6bl + "xxj": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="power", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_SPRAY, + translation_key="spray", + icon="mdi:spray", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_VOICE, + translation_key="voice", + icon="mdi:account-voice", + entity_category=EntityCategory.CONFIG, + ), + ), + # Smart Electricity Meter + # https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7 + "zndb": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs": ( + SwitchEntityDescription( + key=DPCode.ANION, + translation_key="anion", + icon="mdi:atom", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.HUMIDIFIER, + translation_key="humidification", + icon="mdi:air-humidifier", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.OXYGEN, + translation_key="oxygen_bar", + icon="mdi:molecule", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.FAN_COOL, + translation_key="natural_wind", + icon="mdi:weather-windy", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.FAN_BEEP, + translation_key="sound", + icon="mdi:minus-circle", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.CHILD_LOCK, + translation_key="child_lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + ), + # Curtain + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc + "cl": ( + SwitchEntityDescription( + key=DPCode.CONTROL_BACK, + translation_key="reverse", + icon="mdi:swap-horizontal", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.OPPOSITE, + translation_key="reverse", + icon="mdi:swap-horizontal", + entity_category=EntityCategory.CONFIG, + ), + ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( + SwitchEntityDescription( + key=DPCode.SWITCH_SOUND, + translation_key="voice", + icon="mdi:account-voice", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.SLEEP, + translation_key="sleep", + icon="mdi:power-sleep", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.STERILIZATION, + translation_key="sterilization", + icon="mdi:minus-circle-outline", + entity_category=EntityCategory.CONFIG, + ), + ), +} + +# Socket (duplicate of `pc`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +SWITCHES["cz"] = SWITCHES["pc"] + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up tuya sensors 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 sensor.""" + entities: list[TuyaSwitchEntity] = [] + for device_id in device_ids: + device = hass_data.device_manager.device_map[device_id] + if descriptions := SWITCHES.get(device.category): + for description in descriptions: + if description.key in device.status: + entities.append( + TuyaSwitchEntity( + 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 TuyaSwitchEntity(TuyaEntity, SwitchEntity): + """Tuya Switch Device.""" + + def __init__( + self, + device: TuyaDevice, + device_manager: TuyaDeviceManager, + description: SwitchEntityDescription, + ) -> None: + """Init TuyaHaSwitch.""" + super().__init__(device, device_manager) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}{description.key}" + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + return self.device.status.get(self.entity_description.key, False) + + def turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + self._send_command([{"code": self.entity_description.key, "value": True}]) + + def turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + self._send_command([{"code": self.entity_description.key, "value": False}]) diff --git a/custom_components/tuya_openapi/translations/en.json b/custom_components/tuya_openapi/translations/en.json new file mode 100644 index 0000000..541e848 --- /dev/null +++ b/custom_components/tuya_openapi/translations/en.json @@ -0,0 +1,839 @@ +{ + "config": { + "abort": { + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "invalid_auth": "Invalid authentication", + "login_error": "Login error ({code}): {msg}" + }, + "step": { + "legacy": { + "data": { + "access_id": "Tuya IoT Access ID", + "access_secret": "Tuya IoT Access Secret", + "country_code": "Country", + "password": "Password", + "username": "Account" + }, + "description": "If you're unable to access the full details of a device, you can try connecting through the legacy API" + }, + "legacy_user": { + "data": { + "user_code": "User code" + }, + "description": "Enter your Smart Life or Tuya Smart user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case sensitive, please be sure to enter it exactly as shown in the app." + }, + "reauth_user_code": { + "data": { + "user_code": "User code" + }, + "description": "The Tuya integration now uses an improved login method. To reauthenticate with your Smart Life or Tuya Smart account, you need to enter your user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case sensitive, please be sure to enter it exactly as shown in the app." + }, + "scan": { + "description": "Use Smart Life app or Tuya Smart app to scan the following QR-code to complete the login.\n\nContinue to the next step once you have completed this step in the app." + } + } + }, + "entity": { + "binary_sensor": { + "carbon_dioxide": { + "name": "Carbon dioxide" + }, + "carbon_monoxide": { + "name": "Carbon monoxide" + }, + "drop": { + "name": "Drop" + }, + "feeding": { + "name": "Feeding" + }, + "formaldehyde": { + "name": "Formaldehyde" + }, + "methane": { + "name": "Methane" + }, + "pm25": { + "name": "PM2.5" + }, + "pressure": { + "name": "Pressure" + }, + "tilt": { + "name": "Tilt" + }, + "voc": { + "name": "VOCs" + } + }, + "button": { + "reset_duster_cloth": { + "name": "Reset duster cloth" + }, + "reset_edge_brush": { + "name": "Reset edge brush" + }, + "reset_filter": { + "name": "Reset filter" + }, + "reset_map": { + "name": "Reset map" + }, + "reset_roll_brush": { + "name": "Reset roll brush" + }, + "snooze": { + "name": "Snooze" + } + }, + "cover": { + "blind": { + "name": "Blind" + }, + "curtain": { + "name": "Curtain" + }, + "curtain_2": { + "name": "Curtain 2" + }, + "curtain_3": { + "name": "Curtain 3" + }, + "door": { + "name": "Door" + }, + "door_2": { + "name": "Door 2" + }, + "door_3": { + "name": "Door 3" + } + }, + "light": { + "backlight": { + "name": "Backlight" + }, + "light": { + "name": "Light" + }, + "light_2": { + "name": "Light 2" + }, + "light_3": { + "name": "Light 3" + }, + "night_light": { + "name": "Night light" + } + }, + "number": { + "cloud_recipe": { + "name": "Cloud recipe" + }, + "cook_temperature": { + "name": "Cook temperature" + }, + "cook_time": { + "name": "Cook time" + }, + "down_delay": { + "name": "Down delay" + }, + "far_detection": { + "name": "Far detection" + }, + "feed": { + "name": "Feed" + }, + "heat_preservation_time": { + "name": "Heat preservation time" + }, + "maximum_brightness": { + "name": "Maximum brightness" + }, + "maximum_brightness_2": { + "name": "Maximum brightness 2" + }, + "maximum_brightness_3": { + "name": "Maximum brightness 3" + }, + "minimum_brightness": { + "name": "Minimum brightness" + }, + "minimum_brightness_2": { + "name": "Minimum brightness 2" + }, + "minimum_brightness_3": { + "name": "Minimum brightness 3" + }, + "move_down": { + "name": "Move down" + }, + "move_up": { + "name": "Move up" + }, + "near_detection": { + "name": "Near detection" + }, + "powder": { + "name": "Powder" + }, + "sensitivity": { + "name": "Sensitivity" + }, + "temperature": { + "name": "Temperature" + }, + "temperature_after_boiling": { + "name": "Temperature after boiling" + }, + "time": { + "name": "Time" + }, + "voice_times": { + "name": "Voice times" + }, + "volume": { + "name": "Volume" + }, + "water_level": { + "name": "Water level" + } + }, + "select": { + "basic_anti_flicker": { + "name": "Anti-flicker", + "state": { + "0": "Disabled", + "1": "50 Hz", + "2": "60 Hz" + } + }, + "basic_nightvision": { + "name": "Night vision", + "state": { + "0": "Automatic", + "1": "Off", + "2": "On" + } + }, + "brightness": { + "name": "Brightness" + }, + "concentration": { + "name": "Concentration" + }, + "countdown": { + "name": "Countdown", + "state": { + "1h": "1 hour", + "2h": "2 hours", + "3h": "3 hours", + "4h": "4 hours", + "5h": "5 hours", + "6h": "6 hours", + "cancel": "Cancel" + } + }, + "cups": { + "name": "Cups" + }, + "curtain_mode": { + "name": "Mode", + "state": { + "morning": "Morning", + "night": "Night" + } + }, + "curtain_motor_mode": { + "name": "Motor mode", + "state": { + "back": "Back", + "forward": "Forward" + } + }, + "decibel_sensitivity": { + "name": "Sound detection sensitivity", + "state": { + "0": "Low sensitivity", + "1": "High sensitivity" + } + }, + "fingerbot_mode": { + "name": "Mode", + "state": { + "click": "Push", + "switch": "Switch" + } + }, + "horizontal_fan_angle": { + "name": "Horizontal swing flap angle", + "state": { + "30": "30\u00b0", + "60": "60\u00b0", + "90": "90\u00b0" + } + }, + "humidifier_level": { + "name": "Spraying level", + "state": { + "level_1": "Level 1", + "level_10": "Level 10", + "level_2": "Level 2", + "level_3": "Level 3", + "level_4": "Level 4", + "level_5": "Level 5", + "level_6": "Level 6", + "level_7": "Level 7", + "level_8": "Level 8", + "level_9": "Level 9" + } + }, + "humidifier_moodlighting": { + "name": "Moodlighting", + "state": { + "1": "Mood 1", + "2": "Mood 2", + "3": "Mood 3", + "4": "Mood 4", + "5": "Mood 5" + } + }, + "humidifier_spray_mode": { + "name": "Spray mode", + "state": { + "auto": "Auto", + "health": "Health", + "humidity": "Humidity", + "sleep": "Sleep", + "work": "Work" + } + }, + "ipc_work_mode": { + "name": "IPC mode", + "state": { + "0": "Low power mode", + "1": "Continuous working mode" + } + }, + "led_type": { + "name": "Light source type", + "state": { + "halogen": "Halogen", + "incandescent": "Incandescent", + "led": "LED" + } + }, + "led_type_2": { + "name": "Light 2 source type", + "state": { + "halogen": "Halogen", + "incandescent": "Incandescent", + "led": "LED" + } + }, + "led_type_3": { + "name": "Light 3 source type", + "state": { + "halogen": "Halogen", + "incandescent": "Incandescent", + "led": "LED" + } + }, + "light_mode": { + "name": "Indicator light mode", + "state": { + "none": "Off", + "pos": "Indicate switch location", + "relay": "Indicate switch on/off state" + } + }, + "material": { + "name": "Material" + }, + "mode": { + "name": "Mode" + }, + "motion_sensitivity": { + "name": "Motion detection sensitivity", + "state": { + "0": "Low sensitivity", + "1": "Medium sensitivity", + "2": "High sensitivity" + } + }, + "record_mode": { + "name": "Record mode", + "state": { + "1": "Record events only", + "2": "Continuous recording" + } + }, + "relay_status": { + "name": "Power on behavior", + "state": { + "last": "Remember last state", + "memory": "Remember last state", + "off": "Off", + "on": "On", + "power_off": "Off", + "power_on": "On" + } + }, + "target_humidity": { + "name": "Target humidity" + }, + "temperature_level": { + "name": "Temperature level" + }, + "vacuum_cistern": { + "name": "Water tank adjustment", + "state": { + "closed": "Closed", + "high": "High", + "low": "Low", + "middle": "Middle" + } + }, + "vacuum_collection": { + "name": "Dust collection mode", + "state": { + "large": "Large", + "middle": "Middle", + "small": "Small" + } + }, + "vacuum_mode": { + "name": "Mode", + "state": { + "bow": "Bow", + "chargego": "Return to dock", + "left_bow": "Bow left", + "left_spiral": "Spiral left", + "mop": "Mop", + "part": "Part", + "partial_bow": "Bow partially", + "pick_zone": "Pick zone", + "point": "Point", + "pose": "Pose", + "random": "Random", + "right_bow": "Bow right", + "right_spiral": "Spiral right", + "single": "Single", + "smart": "Smart", + "spiral": "Spiral", + "standby": "Standby", + "wall_follow": "Follow wall", + "zone": "Zone" + } + }, + "vertical_fan_angle": { + "name": "Vertical swing flap angle", + "state": { + "30": "30\u00b0", + "60": "60\u00b0", + "90": "90\u00b0" + } + }, + "volume": { + "name": "Volume" + }, + "weather_delay": { + "name": "Weather delay", + "state": { + "120h": "120h", + "144h": "144h", + "168h": "168h", + "24h": "24h", + "48h": "48h", + "72h": "72h", + "96h": "96h", + "cancel": "Cancel" + } + } + }, + "sensor": { + "air_quality": { + "name": "Air quality", + "state": { + "good": "Good", + "great": "Great", + "mild": "Mild", + "severe": "Severe" + } + }, + "battery": { + "name": "Battery" + }, + "battery_state": { + "name": "Battery state" + }, + "carbon_dioxide": { + "name": "Carbon dioxide" + }, + "carbon_monoxide": { + "name": "Carbon monoxide" + }, + "cleaning_area": { + "name": "Cleaning area" + }, + "cleaning_time": { + "name": "Cleaning time" + }, + "concentration_carbon_dioxide": { + "name": "Concentration of carbon dioxide" + }, + "current": { + "name": "Current" + }, + "current_temperature": { + "name": "Current temperature" + }, + "duster_cloth_life": { + "name": "Duster cloth lifetime" + }, + "filter_life": { + "name": "Filter lifetime" + }, + "filter_utilization": { + "name": "Filter utilization" + }, + "formaldehyde": { + "name": "Formaldehyde" + }, + "gas": { + "name": "Gas" + }, + "humidity": { + "name": "Humidity" + }, + "illuminance": { + "name": "Illuminance" + }, + "last_amount": { + "name": "Last amount" + }, + "last_operation_duration": { + "name": "Last operation duration" + }, + "luminosity": { + "name": "Luminosity" + }, + "methane": { + "name": "Methane" + }, + "phase_a_current": { + "name": "Phase A current" + }, + "phase_a_power": { + "name": "Phase A power" + }, + "phase_a_voltage": { + "name": "Phase A voltage" + }, + "phase_b_current": { + "name": "Phase B current" + }, + "phase_b_power": { + "name": "Phase B power" + }, + "phase_b_voltage": { + "name": "Phase B voltage" + }, + "phase_c_current": { + "name": "Phase C current" + }, + "phase_c_power": { + "name": "Phase C power" + }, + "phase_c_voltage": { + "name": "Phase C voltage" + }, + "pm1": { + "name": "PM1" + }, + "pm10": { + "name": "PM10" + }, + "pm25": { + "name": "PM2.5" + }, + "power": { + "name": "Power" + }, + "remaining_time": { + "name": "Remaining time" + }, + "rolling_brush_life": { + "name": "Rolling brush lifetime" + }, + "side_brush_life": { + "name": "Side brush lifetime" + }, + "smoke_amount": { + "name": "Smoke amount" + }, + "sous_vide_status": { + "name": "Status", + "state": { + "boiling_temp": "Boiling temperature", + "cooling": "Cooling", + "heating": "Heating", + "heating_temp": "Heating temperature", + "reserve_1": "Reserve 1", + "reserve_2": "Reserve 2", + "reserve_3": "Reserve 3", + "standby": "Standby", + "warm": "Heat preservation" + } + }, + "status": { + "name": "Status" + }, + "temperature": { + "name": "Temperature" + }, + "total_absorption_particles": { + "name": "Total absorption of particles" + }, + "total_cleaning_area": { + "name": "Total cleaning area" + }, + "total_cleaning_time": { + "name": "Total cleaning time" + }, + "total_cleaning_times": { + "name": "Total cleaning times" + }, + "total_energy": { + "name": "Total energy" + }, + "total_operating_time": { + "name": "Total operating time" + }, + "total_volatile_organic_compound": { + "name": "Total volatile organic compound" + }, + "total_watering_time": { + "name": "Total watering time" + }, + "voc": { + "name": "VOCs" + }, + "voltage": { + "name": "Voltage" + }, + "water_level": { + "name": "Water level" + } + }, + "switch": { + "alarm_1": { + "name": "Alarm 1" + }, + "alarm_2": { + "name": "Alarm 2" + }, + "alarm_3": { + "name": "Alarm 3" + }, + "alarm_4": { + "name": "Alarm 4" + }, + "anion": { + "name": "Anion" + }, + "battery_lock": { + "name": "Battery lock" + }, + "child_lock": { + "name": "Child lock" + }, + "cry_detection": { + "name": "Cry detection" + }, + "disinfection": { + "name": "Disinfection" + }, + "do_not_disturb": { + "name": "Do not disturb" + }, + "energy_saving": { + "name": "Energy saving" + }, + "filter_cartridge_reset": { + "name": "Filter cartridge reset" + }, + "filter_reset": { + "name": "Filter reset" + }, + "flip": { + "name": "Flip" + }, + "heat_preservation": { + "name": "Heat preservation" + }, + "humidification": { + "name": "Humidification" + }, + "ionizer": { + "name": "Ionizer" + }, + "motion_alarm": { + "name": "Motion alarm" + }, + "motion_recording": { + "name": "Motion recording" + }, + "motion_tracking": { + "name": "Motion tracking" + }, + "mute": { + "name": "Mute" + }, + "mute_voice": { + "name": "Mute voice" + }, + "natural_wind": { + "name": "Natural wind" + }, + "open_window_detection": { + "name": "Open window detection" + }, + "oxygen_bar": { + "name": "Oxygen bar" + }, + "plug": { + "name": "Plug" + }, + "power": { + "name": "Power" + }, + "privacy_mode": { + "name": "Privacy mode" + }, + "radio": { + "name": "Radio" + }, + "reset_of_water_usage_days": { + "name": "Reset of water usage days" + }, + "reverse": { + "name": "Reverse" + }, + "sleep": { + "name": "Sleep" + }, + "sleep_aid": { + "name": "Sleep aid" + }, + "slow_feed": { + "name": "Slow feed" + }, + "socket": { + "name": "Socket" + }, + "socket_1": { + "name": "Socket 1" + }, + "socket_2": { + "name": "Socket 2" + }, + "socket_3": { + "name": "Socket 3" + }, + "socket_4": { + "name": "Socket 4" + }, + "socket_5": { + "name": "Socket 5" + }, + "socket_6": { + "name": "Socket 6" + }, + "sound": { + "name": "Sound" + }, + "sound_detection": { + "name": "Sound detection" + }, + "spray": { + "name": "Spray" + }, + "start": { + "name": "Start" + }, + "sterilization": { + "name": "Sterilization" + }, + "switch": { + "name": "Switch" + }, + "switch_1": { + "name": "Switch 1" + }, + "switch_2": { + "name": "Switch 2" + }, + "switch_3": { + "name": "Switch 3" + }, + "switch_4": { + "name": "Switch 4" + }, + "switch_5": { + "name": "Switch 5" + }, + "switch_6": { + "name": "Switch 6" + }, + "switch_7": { + "name": "Switch 7" + }, + "switch_8": { + "name": "Switch 8" + }, + "time_watermark": { + "name": "Time watermark" + }, + "usb_1": { + "name": "USB 1" + }, + "usb_2": { + "name": "USB 2" + }, + "usb_3": { + "name": "USB 3" + }, + "usb_4": { + "name": "USB 4" + }, + "usb_5": { + "name": "USB 5" + }, + "usb_6": { + "name": "USB 6" + }, + "uv_sterilization": { + "name": "UV sterilization" + }, + "video_recording": { + "name": "Video recording" + }, + "voice": { + "name": "Voice" + }, + "water": { + "name": "Water" + }, + "water_pump_reset": { + "name": "Water pump reset" + }, + "wide_dynamic_range": { + "name": "Wide dynamic range" + } + } + } +} \ No newline at end of file diff --git a/custom_components/tuya_openapi/util.py b/custom_components/tuya_openapi/util.py new file mode 100644 index 0000000..3b29a3e --- /dev/null +++ b/custom_components/tuya_openapi/util.py @@ -0,0 +1,16 @@ +"""Utility methods for the Tuya integration.""" +from __future__ import annotations + + +def remap_value( + value: float | int, + from_min: float | int = 0, + from_max: float | int = 255, + to_min: float | int = 0, + to_max: float | int = 255, + reverse: bool = False, +) -> float: + """Remap a value from its current range, to a new range.""" + if reverse: + value = from_max - value + from_min + return ((value - from_min) / (from_max - from_min)) * (to_max - to_min) + to_min diff --git a/custom_components/tuya_openapi/vacuum.py b/custom_components/tuya_openapi/vacuum.py new file mode 100644 index 0000000..b332be7 --- /dev/null +++ b/custom_components/tuya_openapi/vacuum.py @@ -0,0 +1,222 @@ +"""Support for Tuya Vacuums.""" +from __future__ import annotations + +from typing import Any + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.components.vacuum import ( + STATE_CLEANING, + STATE_DOCKED, + STATE_RETURNING, + StateVacuumEntity, + VacuumEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_IDLE, STATE_PAUSED +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeAssistantTuyaData +from .base import EnumTypeData, IntegerTypeData, TuyaEntity +from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType + +TUYA_MODE_RETURN_HOME = "chargego" +TUYA_STATUS_TO_HA = { + "charge_done": STATE_DOCKED, + "chargecompleted": STATE_DOCKED, + "chargego": STATE_DOCKED, + "charging": STATE_DOCKED, + "cleaning": STATE_CLEANING, + "docking": STATE_RETURNING, + "goto_charge": STATE_RETURNING, + "goto_pos": STATE_CLEANING, + "mop_clean": STATE_CLEANING, + "part_clean": STATE_CLEANING, + "paused": STATE_PAUSED, + "pick_zone_clean": STATE_CLEANING, + "pos_arrived": STATE_CLEANING, + "pos_unarrive": STATE_CLEANING, + "random": STATE_CLEANING, + "sleep": STATE_IDLE, + "smart_clean": STATE_CLEANING, + "smart": STATE_CLEANING, + "spot_clean": STATE_CLEANING, + "standby": STATE_IDLE, + "wall_clean": STATE_CLEANING, + "wall_follow": STATE_CLEANING, + "zone_clean": STATE_CLEANING, +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Tuya vacuum 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 vacuum.""" + entities: list[TuyaVacuumEntity] = [] + for device_id in device_ids: + device = hass_data.device_manager.device_map[device_id] + if device.category == "sd": + entities.append(TuyaVacuumEntity(device, hass_data.device_manager)) + 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 TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): + """Tuya Vacuum Device.""" + + _fan_speed: EnumTypeData | None = None + _battery_level: IntegerTypeData | None = None + _attr_name = None + + def __init__(self, device: TuyaDevice, device_manager: TuyaDeviceManager) -> None: + """Init Tuya vacuum.""" + super().__init__(device, device_manager) + + self._attr_fan_speed_list = [] + + self._attr_supported_features = ( + VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.STATE + ) + if self.find_dpcode(DPCode.PAUSE, prefer_function=True): + self._attr_supported_features |= VacuumEntityFeature.PAUSE + + if self.find_dpcode(DPCode.SWITCH_CHARGE, prefer_function=True): + self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME + elif ( + enum_type := self.find_dpcode( + DPCode.MODE, dptype=DPType.ENUM, prefer_function=True + ) + ) and TUYA_MODE_RETURN_HOME in enum_type.range: + self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME + + if self.find_dpcode(DPCode.SEEK, prefer_function=True): + self._attr_supported_features |= VacuumEntityFeature.LOCATE + + if self.find_dpcode(DPCode.POWER, prefer_function=True): + self._attr_supported_features |= ( + VacuumEntityFeature.TURN_ON | VacuumEntityFeature.TURN_OFF + ) + + if self.find_dpcode(DPCode.POWER_GO, prefer_function=True): + self._attr_supported_features |= ( + VacuumEntityFeature.STOP | VacuumEntityFeature.START + ) + + if enum_type := self.find_dpcode( + DPCode.SUCTION, dptype=DPType.ENUM, prefer_function=True + ): + self._fan_speed = enum_type + self._attr_fan_speed_list = enum_type.range + self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED + + if int_type := self.find_dpcode(DPCode.ELECTRICITY_LEFT, dptype=DPType.INTEGER): + self._attr_supported_features |= VacuumEntityFeature.BATTERY + self._battery_level = int_type + + @property + def battery_level(self) -> int | None: + """Return Tuya device state.""" + if self._battery_level is None or not ( + status := self.device.status.get(DPCode.ELECTRICITY_LEFT) + ): + return None + return round(self._battery_level.scale_value(status)) + + @property + def fan_speed(self) -> str | None: + """Return the fan speed of the vacuum cleaner.""" + return self.device.status.get(DPCode.SUCTION) + + @property + def state(self) -> str | None: + """Return Tuya vacuum device state.""" + if self.device.status.get(DPCode.PAUSE) and not ( + self.device.status.get(DPCode.STATUS) + ): + return STATE_PAUSED + if not (status := self.device.status.get(DPCode.STATUS)): + return None + return TUYA_STATUS_TO_HA.get(status) + + def turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + self._send_command([{"code": DPCode.POWER, "value": True}]) + ir.async_create_issue( + self.hass, + DOMAIN, + "service_deprecation_turn_on", + breaks_in_ha_version="2024.2.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="service_deprecation_turn_on", + ) + + def turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + self._send_command([{"code": DPCode.POWER, "value": False}]) + ir.async_create_issue( + self.hass, + DOMAIN, + "service_deprecation_turn_off", + breaks_in_ha_version="2024.2.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="service_deprecation_turn_off", + ) + + def start(self, **kwargs: Any) -> None: + """Start the device.""" + self._send_command([{"code": DPCode.POWER_GO, "value": True}]) + + def stop(self, **kwargs: Any) -> None: + """Stop the device.""" + self._send_command([{"code": DPCode.POWER_GO, "value": False}]) + + def pause(self, **kwargs: Any) -> None: + """Pause the device.""" + self._send_command([{"code": DPCode.POWER_GO, "value": False}]) + + def return_to_base(self, **kwargs: Any) -> None: + """Return device to dock.""" + self._send_command( + [ + {"code": DPCode.SWITCH_CHARGE, "value": True}, + {"code": DPCode.MODE, "value": TUYA_MODE_RETURN_HOME}, + ] + ) + + def locate(self, **kwargs: Any) -> None: + """Locate the device.""" + self._send_command([{"code": DPCode.SEEK, "value": True}]) + + def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set fan speed.""" + self._send_command([{"code": DPCode.SUCTION, "value": fan_speed}]) + + def send_command( + self, + command: str, + params: dict[str, Any] | list[Any] | None = None, + **kwargs: Any, + ) -> None: + """Send raw command.""" + if not params: + raise ValueError("Params cannot be omitted for Tuya vacuum commands") + if not isinstance(params, list): + raise TypeError("Params must be a list for Tuya vacuum commands") + self._send_command([{"code": command, "value": params[0]}])