This commit is contained in:
epenet
2025-11-03 08:31:06 +00:00
parent d26bb6eeba
commit 4d60d1f2ab
11 changed files with 267 additions and 52 deletions
+44 -5
View File
@@ -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
+8 -1
View File
@@ -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:
+21 -1
View File
@@ -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
@@ -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,
@@ -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)
)
@@ -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",
]
@@ -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
@@ -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."""
@@ -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)
@@ -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([
<HVACMode.OFF: 'off'>,
@@ -429,7 +429,7 @@
'min_temp': 1.0,
'supported_features': <ClimateEntityFeature: 385>,
'target_temp_step': 0.5,
'temperature': 4.6,
'temperature': 23.0,
}),
'context': <ANY>,
'entity_id': 'climate.el_termostato_de_la_cocina',
+6 -5
View File
@@ -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():