Compare commits

...

7 Commits

Author SHA1 Message Date
epenet
1c19c2a278 Fix comment 2025-10-09 07:06:32 +00:00
epenet
631480349e Improve 2025-10-09 07:06:32 +00:00
epenet
114d721973 Simplify 2025-10-09 07:06:32 +00:00
epenet
c4148d723f More work around climate 2025-10-09 07:06:31 +00:00
epenet
5f314c40df Add climate/switch 2025-10-09 07:06:03 +00:00
epenet
65d45f0052 Simplify 2025-10-09 07:06:02 +00:00
epenet
73a8375d2d DNM: Add ability to load Tuya device quirks 2025-10-09 07:06:02 +00:00
23 changed files with 847 additions and 28 deletions

View File

@@ -31,6 +31,7 @@ from .const import (
TUYA_DISCOVERY_NEW,
TUYA_HA_SIGNAL_UPDATE_ENTITY,
)
from .xternal_tuya_device_quirks import register_tuya_quirks
# Suppress logs from the library, it logs unneeded on error
logging.getLogger("tuya_sharing").setLevel(logging.CRITICAL)
@@ -103,6 +104,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool
model_id=device.product_id,
)
# Should be loaded from configuration.yaml
# but for now, we can use a hardcoded path for testing
quirks_path = "/config/tuya_quirks/"
await hass.async_add_executor_job(register_tuya_quirks, quirks_path)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# If the device does not register any entities, the device does not need to subscribe
# So the subscription is here

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Any
from tuya_sharing import CustomerDevice, Manager
@@ -26,8 +26,10 @@ 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
from .xternal_tuya_quirks.climate import CommonClimateType, TuyaClimateDefinition
TUYA_HVAC_TO_HA = {
"auto": HVACMode.HEAT_COOL,
@@ -46,6 +48,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] = {
@@ -75,6 +80,25 @@ CLIMATE_DESCRIPTIONS: dict[DeviceCategory, TuyaClimateEntityDescription] = {
),
}
COMMON_CLIMATE_DEFINITIONS: dict[CommonClimateType, TuyaClimateEntityDescription] = {
CommonClimateType.SWITCH_ONLY_HEAT_COOL: TuyaClimateEntityDescription(
key="tbc",
switch_only_hvac_mode=HVACMode.HEAT_COOL,
)
}
def _create_quirk_description(
definition: TuyaClimateDefinition,
) -> TuyaClimateEntityDescription:
return replace(
COMMON_CLIMATE_DEFINITIONS[definition.common_type],
key=definition.key,
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,
@@ -90,7 +114,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,
@@ -194,8 +228,17 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
# 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
if convert := self.entity_description.target_temperature_state_conversion:
self._attr_max_temp = convert(
self.device, self._set_temperature, self._set_temperature.max
)
self._attr_min_temp = convert(
self.device, self._set_temperature, self._set_temperature.min
)
else:
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
@@ -347,13 +390,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 +418,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 +452,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

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Any
from tuya_sharing import CustomerDevice, Manager
@@ -24,6 +24,8 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import EnumTypeData, IntegerTypeData
from .util import get_dpcode
from .xternal_tuya_quirks import TUYA_QUIRKS_REGISTRY
from .xternal_tuya_quirks.cover import CommonCoverType, TuyaCoverDefinition
@dataclass(frozen=True)
@@ -142,6 +144,26 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
),
}
COMMON_COVER_DEFINITIONS: dict[CommonCoverType, TuyaCoverEntityDescription] = {
CommonCoverType.CURTAIN: TuyaCoverEntityDescription(
key="tbc",
translation_key="curtain",
device_class=CoverDeviceClass.CURTAIN,
)
}
def _create_quirk_description(
definition: TuyaCoverDefinition,
) -> TuyaCoverEntityDescription:
return replace(
COMMON_COVER_DEFINITIONS[definition.common_type],
key=definition.key,
current_state=definition.current_state_dp_code,
current_position=definition.current_position_dp_code,
set_position=definition.set_position_dp_code,
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -157,7 +179,18 @@ async def async_setup_entry(
entities: list[TuyaCoverEntity] = []
for device_id in device_ids:
device = manager.device_map[device_id]
if descriptions := COVERS.get(device.category):
if quirk := TUYA_QUIRKS_REGISTRY.get_quirk_for_device(device):
entities.extend(
TuyaCoverEntity(
device, manager, _create_quirk_description(definition)
)
for definition in quirk.cover_definitions
if (
definition.key in device.function
or definition.key in device.status_range
)
)
elif descriptions := COVERS.get(device.category):
entities.extend(
TuyaCoverEntity(device, manager, description)
for description in descriptions

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:

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
from dataclasses import replace
from tuya_sharing import CustomerDevice, Manager
from homeassistant.components.select import SelectEntity, SelectEntityDescription
@@ -13,6 +15,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .xternal_tuya_quirks import TUYA_QUIRKS_REGISTRY
from .xternal_tuya_quirks.select import CommonSelectType, TuyaSelectDefinition
# All descriptions can be found here. Mostly the Enum data types in the
# default instructions set of each category end up being a select.
@@ -342,6 +346,23 @@ SELECTS[DeviceCategory.DGHSXJ] = SELECTS[DeviceCategory.SP]
# Power Socket (duplicate of `kg`)
SELECTS[DeviceCategory.PC] = SELECTS[DeviceCategory.KG]
COMMON_SELECT_DEFINITIONS: dict[CommonSelectType, SelectEntityDescription] = {
CommonSelectType.CONTROL_BACK_MODE: SelectEntityDescription(
key="tbc",
entity_category=EntityCategory.CONFIG,
translation_key="curtain_motor_mode",
)
}
def _create_quirk_description(
definition: TuyaSelectDefinition,
) -> SelectEntityDescription:
return replace(
COMMON_SELECT_DEFINITIONS[definition.common_type],
key=DPCode(definition.key),
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -357,7 +378,15 @@ async def async_setup_entry(
entities: list[TuyaSelectEntity] = []
for device_id in device_ids:
device = manager.device_map[device_id]
if descriptions := SELECTS.get(device.category):
if quirk := TUYA_QUIRKS_REGISTRY.get_quirk_for_device(device):
entities.extend(
TuyaSelectEntity(
device, manager, _create_quirk_description(definition)
)
for definition in quirk.select_definitions
if definition.key in device.status
)
elif descriptions := SELECTS.get(device.category):
entities.extend(
TuyaSelectEntity(device, manager, description)
for description in descriptions

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import Any
from tuya_sharing import CustomerDevice, Manager
@@ -44,6 +44,8 @@ from .const import (
)
from .entity import TuyaEntity
from .models import ComplexValue, ElectricityValue, EnumTypeData, IntegerTypeData
from .xternal_tuya_quirks import TUYA_QUIRKS_REGISTRY
from .xternal_tuya_quirks.sensor import CommonSensorType, TuyaSensorDefinition
_WIND_DIRECTIONS = {
"north": 0.0,
@@ -1624,6 +1626,23 @@ SENSORS[DeviceCategory.DGHSXJ] = SENSORS[DeviceCategory.SP]
# Power Socket (duplicate of `kg`)
SENSORS[DeviceCategory.PC] = SENSORS[DeviceCategory.KG]
COMMON_SENSOR_DEFINITIONS: dict[CommonSensorType, TuyaSensorEntityDescription] = {
CommonSensorType.TIME_TOTAL: TuyaSensorEntityDescription(
key="tbc",
translation_key="last_operation_duration",
entity_category=EntityCategory.DIAGNOSTIC,
),
}
def _create_quirk_description(
definition: TuyaSensorDefinition,
) -> TuyaSensorEntityDescription:
return replace(
COMMON_SENSOR_DEFINITIONS[definition.common_type],
key=DPCode(definition.key),
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -1639,7 +1658,15 @@ async def async_setup_entry(
entities: list[TuyaSensorEntity] = []
for device_id in device_ids:
device = manager.device_map[device_id]
if descriptions := SENSORS.get(device.category):
if quirk := TUYA_QUIRKS_REGISTRY.get_quirk_for_device(device):
entities.extend(
TuyaSensorEntity(
device, manager, _create_quirk_description(definition)
)
for definition in quirk.sensor_definitions
if definition.key in device.status
)
elif descriptions := SENSORS.get(device.category):
entities.extend(
TuyaSensorEntity(device, manager, description)
for description in descriptions

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import Any
from tuya_sharing import CustomerDevice, Manager
@@ -27,6 +27,8 @@ 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
from .xternal_tuya_quirks.switch import CommonSwitchType, TuyaSwitchDefinition
@dataclass(frozen=True, kw_only=True)
@@ -898,6 +900,23 @@ SWITCHES[DeviceCategory.CZ] = SWITCHES[DeviceCategory.PC]
# Smart Camera - Low power consumption camera (duplicate of `sp`)
SWITCHES[DeviceCategory.DGHSXJ] = SWITCHES[DeviceCategory.SP]
COMMON_SWITCH_DEFINITIONS: dict[CommonSwitchType, SwitchEntityDescription] = {
CommonSwitchType.CHILD_LOCK: SwitchEntityDescription(
key="tbc",
translation_key="child_lock",
entity_category=EntityCategory.CONFIG,
)
}
def _create_quirk_description(
definition: TuyaSwitchDefinition,
) -> SwitchEntityDescription:
return replace(
COMMON_SWITCH_DEFINITIONS[definition.common_type],
key=DPCode(definition.key),
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -914,7 +933,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

View File

@@ -0,0 +1,60 @@
"""Quirks for Tuya."""
from __future__ import annotations
import importlib
import logging
import pathlib
import pkgutil
import sys
from ..xternal_tuya_quirks import TUYA_QUIRKS_REGISTRY
_LOGGER = logging.getLogger(__name__)
def register_tuya_quirks(custom_quirks_path: str | None = None) -> None:
"""Register all quirks with xternal_tuya_quirks.
- remove custom quirks from `custom_quirks_path`
- add quirks from `xternal_tuya_device_quirks`
- add custom quirks from `custom_quirks_path`
"""
if custom_quirks_path is not None:
TUYA_QUIRKS_REGISTRY.purge_custom_quirks(custom_quirks_path)
# Import all quirks in the `xternal_tuya_device_quirks` package first
for _importer, modname, _ispkg in pkgutil.walk_packages(
path=__path__,
prefix=__name__ + ".",
):
_LOGGER.debug("Loading quirks module %r", modname)
importlib.import_module(modname)
if custom_quirks_path is None:
return
path = pathlib.Path(custom_quirks_path)
_LOGGER.debug("Loading custom quirks from %r", path)
loaded = False
# Treat the custom quirk path (e.g. `/config/tuya_quirks/`) itself as a module
for importer, modname, _ispkg in pkgutil.walk_packages(path=[str(path)]):
_LOGGER.debug("Loading custom quirk module %r", modname)
try:
spec = importer.find_spec(modname) # type: ignore[call-arg]
module = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
sys.modules[modname] = module
spec.loader.exec_module(module) # type: ignore[union-attr]
except Exception:
_LOGGER.exception("Unexpected exception importing custom quirk %r", modname)
else:
loaded = True
if loaded:
_LOGGER.warning(
"Loaded custom quirks. Please contribute them to https://github.com/TBD"
)

View File

@@ -0,0 +1,47 @@
"""Quirks for Tuya."""
from __future__ import annotations
from ..const import DPCode
from ..xternal_tuya_quirks import TUYA_QUIRKS_REGISTRY, TuyaDeviceQuirk
from ..xternal_tuya_quirks.cover import CommonCoverType
from ..xternal_tuya_quirks.select import CommonSelectType
from ..xternal_tuya_quirks.sensor import CommonSensorType
(
# This model has percent_state and percent_control but percent_state never
# gets updated - force percent_control instead
TuyaDeviceQuirk()
.applies_to(category="cl", product_id="g1cp07dsqnbdbbki")
.add_common_cover(
key=DPCode.CONTROL,
common_type=CommonCoverType.CURTAIN,
current_position_dp_code=DPCode.PERCENT_CONTROL,
set_position_dp_code=DPCode.PERCENT_CONTROL,
)
.add_common_select(
key=DPCode.CONTROL_BACK_MODE,
common_type=CommonSelectType.CONTROL_BACK_MODE,
)
.register(TUYA_QUIRKS_REGISTRY)
)
(
# This model has percent_control / percent_state / situation_set
# but they never get updated - use control instead to get the state
TuyaDeviceQuirk()
.applies_to(category="cl", product_id="lfkr93x0ukp5gaia")
.add_common_cover(
key=DPCode.CONTROL,
common_type=CommonCoverType.CURTAIN,
current_state_dp_code=DPCode.CONTROL,
)
.add_common_select(
key=DPCode.CONTROL_BACK_MODE,
common_type=CommonSelectType.CONTROL_BACK_MODE,
)
.add_common_sensor(
key=DPCode.TIME_TOTAL,
common_type=CommonSensorType.TIME_TOTAL,
)
.register(TUYA_QUIRKS_REGISTRY)
)

View File

@@ -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.climate import CommonClimateType
from ..xternal_tuya_quirks.switch import CommonSwitchType
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)
(
# This model has invalid scale 0 for temperature dps - force scale 1
TuyaDeviceQuirk()
.applies_to(category="wk", product_id="IAYz2WK1th0cMLmL")
.add_common_climate(
key="wk", # to avoid breaking change
common_type=CommonClimateType.SWITCH_ONLY_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_common_switch(
key=DPCode.CHILD_LOCK,
common_type=CommonSwitchType.CHILD_LOCK,
)
.register(TUYA_QUIRKS_REGISTRY)
)

View File

@@ -0,0 +1,16 @@
"""Quirks for Tuya."""
from __future__ import annotations
from .device_quirk import TuyaDeviceQuirk
from .homeassistant import parse_enum
from .registry import QuirksRegistry
__all__ = [
"TUYA_QUIRKS_REGISTRY",
"QuirksRegistry",
"TuyaDeviceQuirk",
"parse_enum",
]
TUYA_QUIRKS_REGISTRY = QuirksRegistry()

View File

@@ -0,0 +1,27 @@
"""Common climate quirks for Tuya devices."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from ..models import StateConversionFunction
class CommonClimateType(StrEnum):
"""Common climate types."""
SWITCH_ONLY_HEAT_COOL = "switch_only_heat_cool"
@dataclass(kw_only=True)
class TuyaClimateDefinition:
"""Definition for a climate entity."""
key: str
common_type: CommonClimateType
current_temperature_state_conversion: StateConversionFunction | None = None
target_temperature_state_conversion: StateConversionFunction | None = None
target_temperature_command_conversion: StateConversionFunction | None = None

View File

@@ -0,0 +1,28 @@
"""Common cover quirks for Tuya devices."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from ..const import DPCode
class CommonCoverType(StrEnum):
"""Common cover types."""
CURTAIN = "curtain"
@dataclass(kw_only=True)
class TuyaCoverDefinition:
"""Definition for a cover entity."""
key: str
common_type: CommonCoverType
current_position_dp_code: DPCode | None = None
current_state_dp_code: DPCode | None = None
set_position_dp_code: DPCode | None = None
set_state_dp_code: DPCode

View File

@@ -0,0 +1,146 @@
"""Quirks registry."""
from __future__ import annotations
import inspect
import pathlib
from typing import TYPE_CHECKING, Self
from ..const import DPCode
from ..models import StateConversionFunction
from .climate import CommonClimateType, TuyaClimateDefinition
from .cover import CommonCoverType, TuyaCoverDefinition
from .select import CommonSelectType, TuyaSelectDefinition
from .sensor import CommonSensorType, TuyaSensorDefinition
from .switch import CommonSwitchType, TuyaSwitchDefinition
if TYPE_CHECKING:
from .registry import QuirksRegistry
class TuyaDeviceQuirk:
"""Quirk for Tuya device."""
_applies_to: list[tuple[str, str]]
climate_definitions: list[TuyaClimateDefinition]
cover_definitions: list[TuyaCoverDefinition]
select_definitions: list[TuyaSelectDefinition]
sensor_definitions: list[TuyaSensorDefinition]
switch_definitions: list[TuyaSwitchDefinition]
def __init__(self) -> None:
"""Initialize the quirk."""
self._applies_to = []
self.climate_definitions = []
self.cover_definitions = []
self.select_definitions = []
self.sensor_definitions = []
self.switch_definitions = []
current_frame = inspect.currentframe()
if TYPE_CHECKING:
assert current_frame is not None
caller = current_frame.f_back
if TYPE_CHECKING:
assert caller is not None
self.quirk_file = pathlib.Path(caller.f_code.co_filename)
self.quirk_file_line = caller.f_lineno
def applies_to(self, *, category: str, product_id: str) -> Self:
"""Set the device type the quirk applies to."""
self._applies_to.append((category, product_id))
return self
def register(self, registry: QuirksRegistry) -> None:
"""Register the quirk in the registry."""
for category, product_id in self._applies_to:
registry.register(category, product_id, self)
def add_common_climate(
self,
*,
key: str,
common_type: CommonClimateType,
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,
common_type=common_type,
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_common_cover(
self,
*,
key: DPCode,
common_type: CommonCoverType,
current_position_dp_code: DPCode | None = None,
current_state_dp_code: DPCode | None = None,
set_position_dp_code: DPCode | None = None,
set_state_dp_code: DPCode | None = None,
) -> Self:
"""Add cover definition."""
self.cover_definitions.append(
TuyaCoverDefinition(
key=key,
common_type=common_type,
current_position_dp_code=current_position_dp_code,
current_state_dp_code=current_state_dp_code,
set_position_dp_code=set_position_dp_code,
set_state_dp_code=set_state_dp_code or key,
)
)
return self
def add_common_select(
self,
*,
key: DPCode,
common_type: CommonSelectType,
) -> Self:
"""Add select definition."""
self.select_definitions.append(
TuyaSelectDefinition(
key=key,
common_type=common_type,
)
)
return self
def add_common_sensor(
self,
*,
key: DPCode,
common_type: CommonSensorType,
) -> Self:
"""Add sensor definition."""
self.sensor_definitions.append(
TuyaSensorDefinition(
key=key,
common_type=common_type,
)
)
return self
def add_common_switch(
self,
*,
key: DPCode,
common_type: CommonSwitchType,
) -> Self:
"""Add switch definition."""
self.switch_definitions.append(
TuyaSwitchDefinition(
key=key,
common_type=common_type,
)
)
return self

View File

@@ -0,0 +1,15 @@
"""Quirks registry."""
from __future__ import annotations
from enum import StrEnum
def parse_enum[T: StrEnum](enum_class: type[T], value: str | None) -> T | None:
"""Parse a string to an enum member, or return None if value is None."""
if value is None:
return None
try:
return enum_class(value)
except ValueError:
return None

View File

@@ -0,0 +1,51 @@
"""Quirks registry."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Self
from tuya_sharing import CustomerDevice
if TYPE_CHECKING:
from .device_quirk import TuyaDeviceQuirk
_LOGGER = logging.getLogger(__name__)
class QuirksRegistry:
"""Registry for Tuya quirks."""
instance: Self
_quirks: dict[str, dict[str, TuyaDeviceQuirk]]
def __new__(cls) -> Self:
"""Create a new class."""
if not hasattr(cls, "instance"):
cls.instance = super().__new__(cls)
return cls.instance
def __init__(self) -> None:
"""Initialize the registry."""
self._quirks = {}
def register(self, category: str, product_id: str, quirk: TuyaDeviceQuirk) -> None:
"""Register a quirk for a specific device type."""
self._quirks.setdefault(category, {})[product_id] = quirk
def get_quirk_for_device(self, device: CustomerDevice) -> TuyaDeviceQuirk | None:
"""Get the quirk for a specific device."""
return self._quirks.get(device.category, {}).get(device.product_id)
def purge_custom_quirks(self, custom_quirks_root: str) -> None:
"""Purge custom quirks from the registry."""
for category_quirks in self._quirks.values():
to_remove = []
for product_id, quirk in category_quirks.items():
if quirk.quirk_file.is_relative_to(custom_quirks_root):
to_remove.append(product_id)
for product_id in to_remove:
_LOGGER.debug("Removing stale custom quirk: %s", product_id)
category_quirks.pop(product_id)

View File

@@ -0,0 +1,23 @@
"""Common select quirks for Tuya devices."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from ..const import DPCode
class CommonSelectType(StrEnum):
"""Common select types."""
CONTROL_BACK_MODE = "control_back_mode"
@dataclass(kw_only=True)
class TuyaSelectDefinition:
"""Definition for a select entity."""
key: DPCode
common_type: CommonSelectType

View File

@@ -0,0 +1,23 @@
"""Common sensor quirks for Tuya devices."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from ..const import DPCode
class CommonSensorType(StrEnum):
"""Common sensor types."""
TIME_TOTAL = "time_total"
@dataclass(kw_only=True)
class TuyaSensorDefinition:
"""Definition for a sensor entity."""
key: DPCode
common_type: CommonSensorType

View File

@@ -0,0 +1,23 @@
"""Common switch quirks for Tuya devices."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from ..const import DPCode
class CommonSwitchType(StrEnum):
"""Common switch types."""
CHILD_LOCK = "child_lock"
@dataclass(kw_only=True)
class TuyaSwitchDefinition:
"""Definition for a switch entity."""
key: DPCode
common_type: CommonSwitchType

View File

@@ -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)

View File

@@ -383,8 +383,8 @@
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
]),
'max_temp': 7.0,
'min_temp': 1.0,
'max_temp': 35.0,
'min_temp': 5.0,
'target_temp_step': 0.5,
}),
'config_entry_id': <ANY>,
@@ -419,17 +419,17 @@
# 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'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
]),
'max_temp': 7.0,
'min_temp': 1.0,
'max_temp': 35.0,
'min_temp': 5.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',

View File

@@ -493,7 +493,7 @@
# name: test_platform_setup_and_discovery[cover.persiana_do_quarto_curtain-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_position': 100,
'current_position': 0,
'device_class': 'curtain',
'friendly_name': 'Persiana do Quarto Curtain',
'supported_features': <CoverEntityFeature: 15>,
@@ -503,7 +503,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'open',
'state': 'closed',
})
# ---
# name: test_platform_setup_and_discovery[cover.projector_screen_curtain-entry]
@@ -535,7 +535,7 @@
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <CoverEntityFeature: 15>,
'supported_features': <CoverEntityFeature: 11>,
'translation_key': 'curtain',
'unique_id': 'tuya.aiag5pku0x39rkfllccontrol',
'unit_of_measurement': None,
@@ -544,17 +544,16 @@
# name: test_platform_setup_and_discovery[cover.projector_screen_curtain-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_position': 100,
'device_class': 'curtain',
'friendly_name': 'Projector Screen Curtain',
'supported_features': <CoverEntityFeature: 15>,
'supported_features': <CoverEntityFeature: 11>,
}),
'context': <ANY>,
'entity_id': 'cover.projector_screen_curtain',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'open',
'state': 'closed',
})
# ---
# name: test_platform_setup_and_discovery[cover.roller_shutter_living_room_curtain-entry]

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from typing import Any
from unittest.mock import patch
import pytest
@@ -352,3 +353,108 @@ async def test_cl_n3xgr5pdmpinictg_state(
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
assert state.state == expected_state
@pytest.mark.parametrize(
"mock_device_code",
["cl_lfkr93x0ukp5gaia"],
)
@pytest.mark.parametrize(
("tuya_status", "expected_state"),
[
(
{
"control": "open",
"percent_control": 100,
"percent_state": 0,
"control_back_mode": "forward",
"work_state": "opening",
"countdown_left": 0,
"time_total": 0,
"situation_set": "fully_open",
"fault": 0,
"border": "down",
},
"open",
),
(
{
"control": "close",
"percent_control": 100,
"percent_state": 0,
"control_back_mode": "forward",
"work_state": "opening",
"countdown_left": 0,
"time_total": 0,
"situation_set": "fully_open",
"fault": 0,
"border": "down",
},
"closed",
),
],
)
@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER])
async def test_cl_lfkr93x0ukp5gaia_state(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
tuya_status: dict[str, Any],
expected_state: str,
) -> None:
"""Test cover position for lfkr93x0ukp5gaia device.
See https://github.com/home-assistant/core/issues/152826
percent_control / percent_state / situation_set never change, regardless
of open or closed state
"""
entity_id = "cover.projector_screen_curtain"
mock_device.status.update(**tuya_status)
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
assert state.state == expected_state
assert ATTR_CURRENT_POSITION not in state.attributes
@pytest.mark.parametrize(
"mock_device_code",
["cl_g1cp07dsqnbdbbki"],
)
@pytest.mark.parametrize(
("initial_percent_control", "expected_state", "expected_position"),
[
(0, "open", 100),
(25, "open", 75),
(50, "open", 50),
(75, "open", 25),
(100, "closed", 0),
],
)
@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER])
async def test_cl_g1cp07dsqnbdbbki_state(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
initial_percent_control: int,
expected_state: str,
expected_position: int,
) -> None:
"""Test cover position for g1cp07dsqnbdbbki device.
See https://github.com/home-assistant/core/issues/139966
percent_state never changes, regardless of actual position
"""
entity_id = "cover.persiana_do_quarto_curtain"
mock_device.status["percent_control"] = initial_percent_control
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
assert state.state == expected_state
assert state.attributes[ATTR_CURRENT_POSITION] == expected_position