From 4aadb848e18e8e69f01d31a163f76de2e87025d7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 17 Oct 2021 19:24:49 +0200 Subject: [PATCH] Add unit/device_class validation and normalization to Tuya (#57913) Co-authored-by: Martin Hjelmare --- homeassistant/components/tuya/const.py | 267 +++++++++++++++++++++++- homeassistant/components/tuya/sensor.py | 45 +++- 2 files changed, 309 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 7dc1e529a47..3a9dd15ee55 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -1,9 +1,68 @@ """Constants for the Tuya integration.""" -from dataclasses import dataclass +from __future__ import annotations + +from dataclasses import dataclass, field from enum import Enum +from typing import Callable from tuya_iot import TuyaCloudOpenAPIEndpoint +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + DEVICE_CLASS_AQI, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CO, + DEVICE_CLASS_CO2, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_DATE, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_MONETARY, + DEVICE_CLASS_NITROGEN_DIOXIDE, + DEVICE_CLASS_NITROGEN_MONOXIDE, + DEVICE_CLASS_NITROUS_OXIDE, + DEVICE_CLASS_OZONE, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, + DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_SULPHUR_DIOXIDE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_CURRENT_MILLIAMPERE, + ELECTRIC_POTENTIAL_MILLIVOLT, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + ENERGY_WATT_HOUR, + LIGHT_LUX, + PERCENTAGE, + POWER_KILO_WATT, + POWER_WATT, + PRESSURE_BAR, + PRESSURE_HPA, + PRESSURE_INHG, + PRESSURE_MBAR, + PRESSURE_PA, + PRESSURE_PSI, + SIGNAL_STRENGTH_DECIBELS, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, +) + DOMAIN = "tuya" CONF_AUTH_TYPE = "auth_type" @@ -155,6 +214,212 @@ class DPCode(str, Enum): WORK_MODE = "work_mode" # Working mode +@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={ + DEVICE_CLASS_AQI, + DEVICE_CLASS_DATE, + DEVICE_CLASS_MONETARY, + DEVICE_CLASS_TIMESTAMP, + }, + ), + UnitOfMeasurement( + unit=PERCENTAGE, + aliases={"pct", "percent"}, + device_classes={ + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_POWER_FACTOR, + }, + ), + UnitOfMeasurement( + unit=CONCENTRATION_PARTS_PER_MILLION, + device_classes={ + DEVICE_CLASS_CO, + DEVICE_CLASS_CO2, + }, + ), + UnitOfMeasurement( + unit=CONCENTRATION_PARTS_PER_BILLION, + device_classes={ + DEVICE_CLASS_CO, + DEVICE_CLASS_CO2, + }, + conversion_unit=CONCENTRATION_PARTS_PER_MILLION, + conversion_fn=lambda x: x / 1000, + ), + UnitOfMeasurement( + unit=ELECTRIC_CURRENT_AMPERE, + aliases={"a", "ampere"}, + device_classes={DEVICE_CLASS_CURRENT}, + ), + UnitOfMeasurement( + unit=ELECTRIC_CURRENT_MILLIAMPERE, + aliases={"ma", "milliampere"}, + device_classes={DEVICE_CLASS_CURRENT}, + conversion_unit=ELECTRIC_CURRENT_AMPERE, + conversion_fn=lambda x: x / 1000, + ), + UnitOfMeasurement( + unit=ENERGY_WATT_HOUR, + aliases={"wh", "watthour"}, + device_classes={DEVICE_CLASS_ENERGY}, + ), + UnitOfMeasurement( + unit=ENERGY_KILO_WATT_HOUR, + aliases={"kwh", "kilowatt-hour"}, + device_classes={DEVICE_CLASS_ENERGY}, + ), + UnitOfMeasurement( + unit=VOLUME_CUBIC_FEET, + aliases={"ft3"}, + device_classes={DEVICE_CLASS_GAS}, + ), + UnitOfMeasurement( + unit=VOLUME_CUBIC_METERS, + aliases={"m3"}, + device_classes={DEVICE_CLASS_GAS}, + ), + UnitOfMeasurement( + unit=LIGHT_LUX, + aliases={"lux"}, + device_classes={DEVICE_CLASS_ILLUMINANCE}, + ), + UnitOfMeasurement( + unit="lm", + aliases={"lum", "lumen"}, + device_classes={DEVICE_CLASS_ILLUMINANCE}, + ), + UnitOfMeasurement( + unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + aliases={"ug/m3", "µg/m3", "ug/m³"}, + device_classes={ + DEVICE_CLASS_NITROGEN_DIOXIDE, + DEVICE_CLASS_NITROGEN_MONOXIDE, + DEVICE_CLASS_NITROUS_OXIDE, + DEVICE_CLASS_OZONE, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM25, + DEVICE_CLASS_PM10, + DEVICE_CLASS_SULPHUR_DIOXIDE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + }, + ), + UnitOfMeasurement( + unit=CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + aliases={"mg/m3"}, + device_classes={ + DEVICE_CLASS_NITROGEN_DIOXIDE, + DEVICE_CLASS_NITROGEN_MONOXIDE, + DEVICE_CLASS_NITROUS_OXIDE, + DEVICE_CLASS_OZONE, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM25, + DEVICE_CLASS_PM10, + DEVICE_CLASS_SULPHUR_DIOXIDE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + }, + conversion_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + conversion_fn=lambda x: x * 1000, + ), + UnitOfMeasurement( + unit=POWER_WATT, + aliases={"watt"}, + device_classes={DEVICE_CLASS_POWER}, + ), + UnitOfMeasurement( + unit=POWER_KILO_WATT, + aliases={"kilowatt"}, + device_classes={DEVICE_CLASS_POWER}, + ), + UnitOfMeasurement( + unit=PRESSURE_BAR, + device_classes={DEVICE_CLASS_PRESSURE}, + ), + UnitOfMeasurement( + unit=PRESSURE_MBAR, + aliases={"millibar"}, + device_classes={DEVICE_CLASS_PRESSURE}, + ), + UnitOfMeasurement( + unit=PRESSURE_HPA, + aliases={"hpa", "hectopascal"}, + device_classes={DEVICE_CLASS_PRESSURE}, + ), + UnitOfMeasurement( + unit=PRESSURE_INHG, + aliases={"inhg"}, + device_classes={DEVICE_CLASS_PRESSURE}, + ), + UnitOfMeasurement( + unit=PRESSURE_PSI, + device_classes={DEVICE_CLASS_PRESSURE}, + ), + UnitOfMeasurement( + unit=PRESSURE_PA, + device_classes={DEVICE_CLASS_PRESSURE}, + ), + UnitOfMeasurement( + unit=SIGNAL_STRENGTH_DECIBELS, + aliases={"db"}, + device_classes={DEVICE_CLASS_SIGNAL_STRENGTH}, + ), + UnitOfMeasurement( + unit=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + aliases={"dbm"}, + device_classes={DEVICE_CLASS_SIGNAL_STRENGTH}, + ), + UnitOfMeasurement( + unit=TEMP_CELSIUS, + aliases={"°c", "c", "celsius"}, + device_classes={DEVICE_CLASS_TEMPERATURE}, + ), + UnitOfMeasurement( + unit=TEMP_FAHRENHEIT, + aliases={"°f", "f", "fahrenheit"}, + device_classes={DEVICE_CLASS_TEMPERATURE}, + ), + UnitOfMeasurement( + unit=ELECTRIC_POTENTIAL_VOLT, + aliases={"volt"}, + device_classes={DEVICE_CLASS_VOLTAGE}, + ), + UnitOfMeasurement( + unit=ELECTRIC_POTENTIAL_MILLIVOLT, + aliases={"mv", "millivolt"}, + device_classes={DEVICE_CLASS_VOLTAGE}, + conversion_unit=ELECTRIC_POTENTIAL_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.""" diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index b21d54bb999..3d3647b0cc4 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -31,7 +31,13 @@ from homeassistant.helpers.typing import StateType from . import HomeAssistantTuyaData from .base import EnumTypeData, IntegerTypeData, TuyaEntity -from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode +from .const import ( + DEVICE_CLASS_UNITS, + DOMAIN, + TUYA_DISCOVERY_NEW, + DPCode, + UnitOfMeasurement, +) # 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) @@ -209,6 +215,7 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): _status_range: TuyaDeviceStatusRange | None = None _type_data: IntegerTypeData | EnumTypeData | None = None + _uom: UnitOfMeasurement | None = None def __init__( self, @@ -235,6 +242,37 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): elif self._status_range.type == "Enum": self._type_data = EnumTypeData.from_json(self._status_range.values) + # 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 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.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.unit_of_measurement) or uoms.get( + self.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 + + # 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.""" @@ -253,7 +291,10 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): # Scale integer/float value if isinstance(self._type_data, IntegerTypeData): - return self._type_data.scale_value(value) + 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 (