diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index fe11d2668ef8..ad304170b05e 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -26,8 +26,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity -from .models import IntegerTypeData +from .models import IntegerTypeData, StateConversionFunction from .util import get_dpcode +from .xternal_tuya_quirks import TUYA_QUIRKS_REGISTRY, TuyaClimateDefinition TUYA_HVAC_TO_HA = { "auto": HVACMode.HEAT_COOL, @@ -46,6 +47,9 @@ class TuyaClimateEntityDescription(ClimateEntityDescription): """Describe an Tuya climate entity.""" switch_only_hvac_mode: HVACMode + current_temperature_state_conversion: StateConversionFunction | None = None + target_temperature_state_conversion: StateConversionFunction | None = None + target_temperature_command_conversion: StateConversionFunction | None = None CLIMATE_DESCRIPTIONS: dict[DeviceCategory, TuyaClimateEntityDescription] = { @@ -76,6 +80,18 @@ CLIMATE_DESCRIPTIONS: dict[DeviceCategory, TuyaClimateEntityDescription] = { } +def _create_quirk_description( + definition: TuyaClimateDefinition, +) -> TuyaClimateEntityDescription: + return TuyaClimateEntityDescription( + key=definition.key, + switch_only_hvac_mode=HVACMode(definition.switch_only_hvac_mode), + current_temperature_state_conversion=definition.current_temperature_state_conversion, + target_temperature_state_conversion=definition.target_temperature_state_conversion, + target_temperature_command_conversion=definition.target_temperature_command_conversion, + ) + + async def async_setup_entry( hass: HomeAssistant, entry: TuyaConfigEntry, @@ -90,7 +106,17 @@ async def async_setup_entry( entities: list[TuyaClimateEntity] = [] for device_id in device_ids: device = manager.device_map[device_id] - if device and device.category in CLIMATE_DESCRIPTIONS: + if quirk := TUYA_QUIRKS_REGISTRY.get_quirk_for_device(device): + entities.extend( + TuyaClimateEntity( + device, + manager, + _create_quirk_description(definition), + hass.config.units.temperature_unit, + ) + for definition in quirk.climate_definitions + ) + elif device.category in CLIMATE_DESCRIPTIONS: entities.append( TuyaClimateEntity( device, @@ -347,13 +373,20 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): # guarded by ClimateEntityFeature.TARGET_TEMPERATURE assert self._set_temperature is not None + if convert := self.entity_description.target_temperature_command_conversion: + value = convert( + self.device, self._current_temperature, kwargs[ATTR_TEMPERATURE] + ) + else: + value = round( + self._set_temperature.scale_value_back(kwargs[ATTR_TEMPERATURE]) + ) + self._send_command( [ { "code": self._set_temperature.dpcode, - "value": round( - self._set_temperature.scale_value_back(kwargs[ATTR_TEMPERATURE]) - ), + "value": value, } ] ) @@ -368,6 +401,9 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): if temperature is None: return None + if convert := self.entity_description.current_temperature_state_conversion: + return convert(self.device, self._current_temperature, temperature) + 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 @@ -399,6 +435,9 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): if temperature is None: return None + if convert := self.entity_description.target_temperature_state_conversion: + return convert(self.device, self._set_temperature, temperature) + return self._set_temperature.scale_value(temperature) @property diff --git a/homeassistant/components/tuya/models.py b/homeassistant/components/tuya/models.py index 82cf5ebd200a..c14e79d0a07c 100644 --- a/homeassistant/components/tuya/models.py +++ b/homeassistant/components/tuya/models.py @@ -3,14 +3,21 @@ from __future__ import annotations import base64 +from collections.abc import Callable from dataclasses import dataclass import json import struct -from typing import Self +from typing import Any, Self + +from tuya_sharing import CustomerDevice from .const import DPCode from .util import remap_value +type StateConversionFunction = Callable[ + [CustomerDevice, EnumTypeData | IntegerTypeData | None, Any], Any +] + @dataclass class IntegerTypeData: diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 12bacc795761..2ebb3b65c3d0 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -27,6 +27,7 @@ from homeassistant.helpers.issue_registry import ( from . import TuyaConfigEntry from .const import DOMAIN, TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity +from .xternal_tuya_quirks import TUYA_QUIRKS_REGISTRY, TuyaSwitchDefinition, parse_enum @dataclass(frozen=True, kw_only=True) @@ -921,6 +922,17 @@ SWITCHES[DeviceCategory.CZ] = SWITCHES[DeviceCategory.PC] SWITCHES[DeviceCategory.DGHSXJ] = SWITCHES[DeviceCategory.SP] +def _create_quirk_description( + definition: TuyaSwitchDefinition, +) -> SwitchEntityDescription: + return SwitchEntityDescription( + key=DPCode(definition.key), + translation_key=definition.translation_key, + device_class=parse_enum(SwitchDeviceClass, definition.device_class), + entity_category=parse_enum(EntityCategory, definition.entity_category), + ) + + async def async_setup_entry( hass: HomeAssistant, entry: TuyaConfigEntry, @@ -936,7 +948,15 @@ async def async_setup_entry( entities: list[TuyaSwitchEntity] = [] for device_id in device_ids: device = manager.device_map[device_id] - if descriptions := SWITCHES.get(device.category): + if quirk := TUYA_QUIRKS_REGISTRY.get_quirk_for_device(device): + entities.extend( + TuyaSwitchEntity( + device, manager, _create_quirk_description(definition) + ) + for definition in quirk.switch_definitions + if definition.key in device.status + ) + elif descriptions := SWITCHES.get(device.category): entities.extend( TuyaSwitchEntity(device, manager, description) for description in descriptions diff --git a/homeassistant/components/tuya/xternal_tuya_device_quirks/cl.py b/homeassistant/components/tuya/xternal_tuya_device_quirks/cl.py index 6b00d36fbd1e..5424b4b3de22 100644 --- a/homeassistant/components/tuya/xternal_tuya_device_quirks/cl.py +++ b/homeassistant/components/tuya/xternal_tuya_device_quirks/cl.py @@ -2,12 +2,9 @@ from __future__ import annotations -from ..xternal_tuya_quirks import ( - TUYA_QUIRKS_REGISTRY, - TuyaCoverDeviceClass, - TuyaDeviceQuirk, - TuyaEntityCategory, -) +from ..const import DPCode +from ..xternal_tuya_quirks import TUYA_QUIRKS_REGISTRY, TuyaDeviceQuirk +from ..xternal_tuya_quirks.homeassistant import TuyaCoverDeviceClass, TuyaEntityCategory ( # This model has percent_state and percent_control but percent_state never @@ -15,16 +12,16 @@ from ..xternal_tuya_quirks import ( TuyaDeviceQuirk() .applies_to(category="cl", product_id="g1cp07dsqnbdbbki") .add_cover( - key="control", + key=DPCode.CONTROL, translation_key="curtain", translation_string="[%key:component::cover::entity_component::curtain::name%]", - current_state_dp_code="control", - current_position_dp_code="percent_control", - set_position_dp_code="percent_control", + current_state_dp_code=DPCode.CONTROL, + current_position_dp_code=DPCode.PERCENT_CONTROL, + set_position_dp_code=DPCode.PERCENT_CONTROL, device_class=TuyaCoverDeviceClass.CURTAIN, ) .add_select( - key="control_back_mode", + key=DPCode.CONTROL_BACK_MODE, translation_key="curtain_motor_mode", translation_string="Motor mode", entity_category=TuyaEntityCategory.CONFIG, @@ -38,21 +35,21 @@ from ..xternal_tuya_quirks import ( TuyaDeviceQuirk() .applies_to(category="cl", product_id="lfkr93x0ukp5gaia") .add_cover( - key="control", + key=DPCode.CONTROL, translation_key="curtain", translation_string="[%key:component::cover::entity_component::curtain::name%]", - current_state_dp_code="control", + current_state_dp_code=DPCode.CONTROL, device_class=TuyaCoverDeviceClass.CURTAIN, ) .add_select( - key="control_back_mode", + key=DPCode.CONTROL_BACK_MODE, translation_key="curtain_motor_mode", translation_string="Motor mode", entity_category=TuyaEntityCategory.CONFIG, state_translations={"forward": "Forward", "back": "Back"}, ) .add_sensor( - key="time_total", + key=DPCode.TIME_TOTAL, translation_key="last_operation_duration", translation_string="Last operation duration", entity_category=TuyaEntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/tuya/xternal_tuya_device_quirks/wk.py b/homeassistant/components/tuya/xternal_tuya_device_quirks/wk.py new file mode 100644 index 000000000000..d70521e3d196 --- /dev/null +++ b/homeassistant/components/tuya/xternal_tuya_device_quirks/wk.py @@ -0,0 +1,53 @@ +"""Quirks for Tuya.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from tuya_sharing import CustomerDevice + +from ..const import DPCode +from ..models import EnumTypeData, IntegerTypeData +from ..xternal_tuya_quirks import TUYA_QUIRKS_REGISTRY, TuyaDeviceQuirk +from ..xternal_tuya_quirks.homeassistant import TuyaClimateHVACMode, TuyaEntityCategory +from ..xternal_tuya_quirks.utils import scale_value, scale_value_back + + +def _scale_value_force_scale_1( + _device: CustomerDevice, dptype: EnumTypeData | IntegerTypeData | None, value: Any +) -> float: + """Scale value to scale 1.""" + if TYPE_CHECKING: + assert isinstance(dptype, IntegerTypeData) + assert isinstance(value, int) + return scale_value(value, dptype.step, 1) + + +def _scale_value_back_force_scale_1( + _device: CustomerDevice, dptype: EnumTypeData | IntegerTypeData | None, value: Any +) -> int: + """Unscale value to scale 1.""" + if TYPE_CHECKING: + assert isinstance(dptype, IntegerTypeData) + assert isinstance(value, float) + return scale_value_back(value, dptype.step, 1) + + +( + TuyaDeviceQuirk() + .applies_to(category="wk", product_id="IAYz2WK1th0cMLmL") + .add_climate( + key="wk", + switch_only_hvac_mode=TuyaClimateHVACMode.HEAT_COOL, + current_temperature_state_conversion=_scale_value_force_scale_1, + target_temperature_state_conversion=_scale_value_force_scale_1, + target_temperature_command_conversion=_scale_value_back_force_scale_1, + ) + .add_switch( + key=DPCode.CHILD_LOCK, + translation_key="child_lock", + translation_string="Child lock", + entity_category=TuyaEntityCategory.CONFIG, + ) + .register(TUYA_QUIRKS_REGISTRY) +) diff --git a/homeassistant/components/tuya/xternal_tuya_quirks/__init__.py b/homeassistant/components/tuya/xternal_tuya_quirks/__init__.py index 31ac65e52d6c..55342a7ea693 100644 --- a/homeassistant/components/tuya/xternal_tuya_quirks/__init__.py +++ b/homeassistant/components/tuya/xternal_tuya_quirks/__init__.py @@ -3,29 +3,25 @@ from __future__ import annotations from .device_quirk import ( + TuyaClimateDefinition, TuyaCoverDefinition, TuyaDeviceQuirk, TuyaSelectDefinition, TuyaSensorDefinition, + TuyaSwitchDefinition, ) -from .homeassistant import ( - TuyaCoverDeviceClass, - TuyaEntityCategory, - TuyaSensorDeviceClass, - parse_enum, -) +from .homeassistant import parse_enum from .registry import QuirksRegistry __all__ = [ "TUYA_QUIRKS_REGISTRY", "QuirksRegistry", + "TuyaClimateDefinition", "TuyaCoverDefinition", - "TuyaCoverDeviceClass", "TuyaDeviceQuirk", - "TuyaEntityCategory", "TuyaSelectDefinition", "TuyaSensorDefinition", - "TuyaSensorDeviceClass", + "TuyaSwitchDefinition", "parse_enum", ] diff --git a/homeassistant/components/tuya/xternal_tuya_quirks/device_quirk.py b/homeassistant/components/tuya/xternal_tuya_quirks/device_quirk.py index b2b0ae65f1b8..d0e357916d8b 100644 --- a/homeassistant/components/tuya/xternal_tuya_quirks/device_quirk.py +++ b/homeassistant/components/tuya/xternal_tuya_quirks/device_quirk.py @@ -7,7 +7,10 @@ import inspect import pathlib from typing import TYPE_CHECKING, Self +from ..const import DPCode +from ..models import StateConversionFunction from .homeassistant import ( + TuyaClimateHVACMode, TuyaCoverDeviceClass, TuyaEntityCategory, TuyaSensorDeviceClass, @@ -22,13 +25,24 @@ class BaseTuyaDefinition: """Definition for a Tuya entity.""" key: str - translation_key: str - translation_string: str + translation_key: str | None = None + translation_string: str | None = None state_translations: dict[str, str] | None = None device_class: str | None = None entity_category: str | None = None +@dataclass(kw_only=True) +class TuyaClimateDefinition(BaseTuyaDefinition): + """Definition for a climate entity.""" + + switch_only_hvac_mode: TuyaClimateHVACMode + + current_temperature_state_conversion: StateConversionFunction | None = None + target_temperature_state_conversion: StateConversionFunction | None = None + target_temperature_command_conversion: StateConversionFunction | None = None + + @dataclass(kw_only=True) class TuyaCoverDefinition(BaseTuyaDefinition): """Definition for a cover entity.""" @@ -52,20 +66,22 @@ class TuyaSensorDefinition(BaseTuyaDefinition): device_class: TuyaSensorDeviceClass | None = None +@dataclass(kw_only=True) +class TuyaSwitchDefinition(BaseTuyaDefinition): + """Definition for a switch entity.""" + + class TuyaDeviceQuirk: """Quirk for Tuya device.""" - _applies_to: list[tuple[str, str]] - cover_definitions: list[TuyaCoverDefinition] - select_definitions: list[TuyaSelectDefinition] - sensor_definitions: list[TuyaSensorDefinition] - def __init__(self) -> None: """Initialize the quirk.""" - self._applies_to = [] - self.cover_definitions = [] - self.select_definitions = [] - self.sensor_definitions = [] + self._applies_to: list[tuple[str, str]] = [] + self.cover_definitions: list[TuyaCoverDefinition] = [] + self.climate_definitions: list[TuyaClimateDefinition] = [] + self.select_definitions: list[TuyaSelectDefinition] = [] + self.sensor_definitions: list[TuyaSensorDefinition] = [] + self.switch_definitions: list[TuyaSwitchDefinition] = [] current_frame = inspect.currentframe() if TYPE_CHECKING: @@ -86,10 +102,32 @@ class TuyaDeviceQuirk: for category, product_id in self._applies_to: registry.register(category, product_id, self) - def add_cover( + def add_climate( self, *, key: str, + # Climate specific + switch_only_hvac_mode: TuyaClimateHVACMode, + current_temperature_state_conversion: StateConversionFunction | None = None, + target_temperature_state_conversion: StateConversionFunction | None = None, + target_temperature_command_conversion: StateConversionFunction | None = None, + ) -> Self: + """Add climate definition.""" + self.climate_definitions.append( + TuyaClimateDefinition( + key=key, + switch_only_hvac_mode=switch_only_hvac_mode, + current_temperature_state_conversion=current_temperature_state_conversion, + target_temperature_state_conversion=target_temperature_state_conversion, + target_temperature_command_conversion=target_temperature_command_conversion, + ) + ) + return self + + def add_cover( + self, + *, + key: DPCode, translation_key: str, translation_string: str, device_class: TuyaCoverDeviceClass | None = None, @@ -115,7 +153,7 @@ class TuyaDeviceQuirk: def add_select( self, *, - key: str, + key: DPCode, translation_key: str, translation_string: str, entity_category: TuyaEntityCategory | None = None, @@ -137,7 +175,7 @@ class TuyaDeviceQuirk: def add_sensor( self, *, - key: str, + key: DPCode, translation_key: str, translation_string: str, device_class: TuyaSensorDeviceClass | None = None, @@ -155,3 +193,23 @@ class TuyaDeviceQuirk: ) ) return self + + def add_switch( + self, + *, + key: DPCode, + translation_key: str, + translation_string: str, + entity_category: TuyaEntityCategory | None = None, + # Switch specific + ) -> Self: + """Add switch definition.""" + self.switch_definitions.append( + TuyaSwitchDefinition( + key=key, + translation_key=translation_key, + translation_string=translation_string, + entity_category=entity_category, + ) + ) + return self diff --git a/homeassistant/components/tuya/xternal_tuya_quirks/homeassistant.py b/homeassistant/components/tuya/xternal_tuya_quirks/homeassistant.py index 0f2e9c371797..8079b1a776cc 100644 --- a/homeassistant/components/tuya/xternal_tuya_quirks/homeassistant.py +++ b/homeassistant/components/tuya/xternal_tuya_quirks/homeassistant.py @@ -31,6 +31,32 @@ class TuyaEntityCategory(StrEnum): DIAGNOSTIC = "diagnostic" +class TuyaClimateHVACMode(StrEnum): + """HVAC mode for climate devices.""" + + # All activity disabled / Device is off/standby + OFF = "off" + + # Heating + HEAT = "heat" + + # Cooling + COOL = "cool" + + # The device supports heating/cooling to a range + HEAT_COOL = "heat_cool" + + # The temperature is set based on a schedule, learned behavior, AI or some + # other related mechanism. User is not able to adjust the temperature + AUTO = "auto" + + # Device is in Dry/Humidity mode + DRY = "dry" + + # Only the fan is on, not fan and another mode like cool + FAN_ONLY = "fan_only" + + class TuyaCoverDeviceClass(StrEnum): """Device class for cover.""" diff --git a/homeassistant/components/tuya/xternal_tuya_quirks/utils.py b/homeassistant/components/tuya/xternal_tuya_quirks/utils.py new file mode 100644 index 000000000000..7a883ecc3c8c --- /dev/null +++ b/homeassistant/components/tuya/xternal_tuya_quirks/utils.py @@ -0,0 +1,18 @@ +"""Common utility functions for Tuya quirks.""" + + +def scale_value(value: int, step: float, scale: float) -> float: + """Official scaling function from Tuya. + + See https://support.tuya.com/en/help/_detail/Kadi66s463e2q + """ + return step * value / (10**scale) + + +def scale_value_back(value: float, step: float, scale: float) -> int: + """Official scaling function from Tuya. + + See https://support.tuya.com/en/help/_detail/Kadi66s463e2q + """ + + return int(value * (10**scale) / step) diff --git a/tests/components/tuya/snapshots/test_climate.ambr b/tests/components/tuya/snapshots/test_climate.ambr index 0e097db78a86..9214544a6193 100644 --- a/tests/components/tuya/snapshots/test_climate.ambr +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -419,7 +419,7 @@ # name: test_platform_setup_and_discovery[climate.el_termostato_de_la_cocina-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_temperature': 4.5, + 'current_temperature': 22.5, 'friendly_name': 'El termostato de la cocina', 'hvac_modes': list([ , @@ -429,7 +429,7 @@ 'min_temp': 1.0, 'supported_features': , 'target_temp_step': 0.5, - 'temperature': 4.6, + 'temperature': 23.0, }), 'context': , 'entity_id': 'climate.el_termostato_de_la_cocina', diff --git a/tests/components/tuya/test_quirks.py b/tests/components/tuya/test_quirks.py index 04fb296601b9..5b4ae9860ab5 100644 --- a/tests/components/tuya/test_quirks.py +++ b/tests/components/tuya/test_quirks.py @@ -45,11 +45,12 @@ def _validate_quirk_platform( definitions: list[BaseTuyaDefinition] = getattr(quirk, f"{platform}_definitions") for definition in definitions: # Validate entity translations exist in strings.json. - full_key = f"entity.{platform}.{definition.translation_key}.name" - assert definition.translation_string == strings.get(full_key), ( - f"Incorrect or missing translation string for {full_key} in " - "homeassistant/components/tuya/strings.json" - ) + if definition.translation_key: + full_key = f"entity.{platform}.{definition.translation_key}.name" + assert definition.translation_string == strings.get(full_key), ( + f"Incorrect or missing translation string for {full_key} in " + "homeassistant/components/tuya/strings.json" + ) # Validate entity state translations exist in strings.json. if state_translations := definition.state_translations: for state, state_translation in state_translations.items():