From cef34356e2a66c1a51324c8b095183316b612ad4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 14 Oct 2021 21:04:02 +0200 Subject: [PATCH] Add sensor platform to Tuya (#57668) --- .coveragerc | 1 + homeassistant/components/tuya/const.py | 16 +- homeassistant/components/tuya/sensor.py | 187 ++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/tuya/sensor.py diff --git a/.coveragerc b/.coveragerc index 1a5380dd0c9..6274e8526fc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1118,6 +1118,7 @@ omit = homeassistant/components/tuya/light.py homeassistant/components/tuya/scene.py homeassistant/components/tuya/select.py + homeassistant/components/tuya/sensor.py homeassistant/components/tuya/switch.py homeassistant/components/twentemilieu/const.py homeassistant/components/twentemilieu/sensor.py diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 51d92b399e3..fa66d72da4f 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -54,7 +54,16 @@ TUYA_SUPPORTED_PRODUCT_CATEGORIES = ( TUYA_SMART_APP = "tuyaSmart" SMARTLIFE_APP = "smartlife" -PLATFORMS = ["binary_sensor", "climate", "fan", "light", "scene", "select", "switch"] +PLATFORMS = [ + "binary_sensor", + "climate", + "fan", + "light", + "scene", + "select", + "sensor", + "switch", +] class DPCode(str, Enum): @@ -64,6 +73,8 @@ class DPCode(str, Enum): """ ANION = "anion" # Ionizer unit + BATTERY_PERCENTAGE = "battery_percentage" # Battery percentage + BATTERY_STATE = "battery_state" # Battery state BRIGHT_VALUE = "bright_value" # Brightness C_F = "c_f" # Temperature unit switching CHILD_LOCK = "child_lock" # Child lock @@ -71,6 +82,9 @@ class DPCode(str, Enum): COLOUR_DATA_V2 = "colour_data_v2" # Colored light mode CONCENTRATION_SET = "concentration_set" # Concentration setting 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 DOORCONTACT_STATE = "doorcontact_state" # Status of door window sensor FAN_DIRECTION = "fan_direction" # Fan direction FAN_SPEED_ENUM = "fan_speed_enum" # Speed mode diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py new file mode 100644 index 00000000000..e1ab4867df9 --- /dev/null +++ b/homeassistant/components/tuya/sensor.py @@ -0,0 +1,187 @@ +"""Support for Tuya sensors.""" +from __future__ import annotations + +from typing import cast + +from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_iot.device import TuyaDeviceStatusRange + +from homeassistant.components.sensor import ( + DEVICE_CLASS_BATTERY, + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_POWER, + DEVICE_CLASS_VOLTAGE, + PERCENTAGE, +) +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 EnumTypeData, IntegerTypeData, TuyaEntity +from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode + +# 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[SensorEntityDescription, ...]] = { + # Door Window Sensor + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48hm02l8m + "mcs": ( + SensorEntityDescription( + key=DPCode.BATTERY_PERCENTAGE, + name="Battery", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=DPCode.BATTERY_STATE, + name="Battery State", + entity_registry_enabled_default=False, + ), + ), + # Switch + # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + "kg": ( + SensorEntityDescription( + key=DPCode.CUR_CURRENT, + name="Current", + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=DPCode.CUR_POWER, + name="Power", + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + name="Voltage", + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_registry_enabled_default=False, + ), + ), +} + +# 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.function + or 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.""" + + _status_range: TuyaDeviceStatusRange | None = None + _type_data: IntegerTypeData | EnumTypeData | None = None + + def __init__( + self, + device: TuyaDevice, + device_manager: TuyaDeviceManager, + description: SensorEntityDescription, + ) -> None: + """Init Tuya sensor.""" + super().__init__(device, device_manager) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}{description.key}" + + if status_range := device.status_range.get(description.key): + self._status_range = cast(TuyaDeviceStatusRange, status_range) + + # Extract type data from integer status range, + # and determine unit of measurement + if self._status_range.type == "Integer": + self._type_data = IntegerTypeData.from_json(self._status_range.values) + if description.native_unit_of_measurement is None: + self._attr_native_unit_of_measurement = self._type_data.unit + + # Extract type data from enum status range + elif self._status_range.type == "Enum": + self._type_data = EnumTypeData.from_json(self._status_range.values) + + @property + def name(self) -> str | None: + """Return Tuya device name.""" + if self.entity_description.name is not None: + return f"{self.tuya_device.name} {self.entity_description.name}" + return self.tuya_device.name + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + # Unknown or unsupported data type + if self._status_range is None or self._status_range.type not in ( + "Integer", + "String", + "Enum", + ): + return None + + # Raw value + value = self.tuya_device.status.get(self.entity_description.key) + if value is None: + return None + + # Scale integer/float value + if isinstance(self._type_data, IntegerTypeData): + return self.scale(value, self._type_data.scale) + + # Unexpected enum value + if ( + isinstance(self._type_data, EnumTypeData) + and value not in self._type_data.range + ): + return None + + # Valid string or enum value + return value