Add climate/switch

This commit is contained in:
epenet
2025-10-08 09:06:56 +00:00
parent 65d45f0052
commit 5f314c40df
15 changed files with 347 additions and 63 deletions

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
@@ -28,6 +28,8 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import IntegerTypeData
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,
@@ -75,6 +77,22 @@ 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,
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -90,7 +108,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,

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,7 +24,7 @@ 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, parse_enum
from .xternal_tuya_quirks import TUYA_QUIRKS_REGISTRY
from .xternal_tuya_quirks.cover import CommonCoverType, TuyaCoverDefinition
@@ -156,14 +156,12 @@ COMMON_COVER_DEFINITIONS: dict[CommonCoverType, TuyaCoverEntityDescription] = {
def _create_quirk_description(
definition: TuyaCoverDefinition,
) -> TuyaCoverEntityDescription:
common_definition = COMMON_COVER_DEFINITIONS[definition.common_type]
return TuyaCoverEntityDescription(
return replace(
COMMON_COVER_DEFINITIONS[definition.common_type],
key=definition.key,
device_class=common_definition.device_class,
translation_key=common_definition.translation_key,
current_state=parse_enum(DPCode, definition.current_state_dp_code),
current_position=parse_enum(DPCode, definition.current_position_dp_code),
set_position=parse_enum(DPCode, definition.set_position_dp_code),
current_state=definition.current_state_dp_code,
current_position=definition.current_position_dp_code,
set_position=definition.set_position_dp_code,
)

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
@@ -356,11 +358,9 @@ COMMON_SELECT_DEFINITIONS: dict[CommonSelectType, SelectEntityDescription] = {
def _create_quirk_description(
definition: TuyaSelectDefinition,
) -> SelectEntityDescription:
common_definition = COMMON_SELECT_DEFINITIONS[definition.common_type]
return SelectEntityDescription(
return replace(
COMMON_SELECT_DEFINITIONS[definition.common_type],
key=DPCode(definition.key),
translation_key=common_definition.translation_key,
entity_category=common_definition.entity_category,
)

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
@@ -73,7 +73,12 @@ class TuyaSensorEntityDescription(SensorEntityDescription):
complex_type: type[ComplexValue] | None = None
subkey: str | None = None
state_conversion: Callable[[Any], StateType] | None = None
state_conversion: (
Callable[
[CustomerDevice, EnumTypeData | IntegerTypeData | None, Any], StateType
]
| None
) = None
# Commonly used battery sensors, that are reused in the sensors down below.
@@ -960,7 +965,9 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="wind_direction",
device_class=SensorDeviceClass.WIND_DIRECTION,
state_class=SensorStateClass.MEASUREMENT,
state_conversion=lambda state: _WIND_DIRECTIONS.get(str(state)),
state_conversion=lambda _device, _dptype, state: _WIND_DIRECTIONS.get(
str(state)
),
),
TuyaSensorEntityDescription(
key=DPCode.DEW_POINT_TEMP,
@@ -1627,23 +1634,27 @@ SENSORS[DeviceCategory.DGHSXJ] = SENSORS[DeviceCategory.SP]
SENSORS[DeviceCategory.PC] = SENSORS[DeviceCategory.KG]
COMMON_SENSOR_DEFINITIONS: dict[CommonSensorType, TuyaSensorEntityDescription] = {
CommonSensorType.TEMPERATURE: TuyaSensorEntityDescription(
key="tbc",
translation_key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
CommonSensorType.TIME_TOTAL: TuyaSensorEntityDescription(
key="tbc",
translation_key="last_operation_duration",
entity_category=EntityCategory.DIAGNOSTIC,
)
),
}
def _create_quirk_description(
definition: TuyaSensorDefinition,
) -> TuyaSensorEntityDescription:
common_definition = COMMON_SENSOR_DEFINITIONS[definition.common_type]
return TuyaSensorEntityDescription(
return replace(
COMMON_SENSOR_DEFINITIONS[definition.common_type],
key=DPCode(definition.key),
translation_key=common_definition.translation_key,
device_class=common_definition.device_class,
entity_category=common_definition.entity_category,
state_conversion=definition.state_conversion,
)
@@ -1782,7 +1793,7 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
# Convert value, if required
if (convert := self.entity_description.state_conversion) is not None:
return convert(value)
return convert(self.device, self._type_data, value)
# Scale integer/float value
if isinstance(self._type_data, IntegerTypeData):

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

@@ -2,6 +2,7 @@
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
@@ -13,17 +14,14 @@ from ..xternal_tuya_quirks.sensor import CommonSensorType
TuyaDeviceQuirk()
.applies_to(category="cl", product_id="g1cp07dsqnbdbbki")
.add_common_cover(
key="control",
key=DPCode.CONTROL,
common_type=CommonCoverType.CURTAIN,
current_position_dp_code="percent_control",
current_state_dp_code="control",
set_position_dp_code="percent_control",
set_state_dp_code="control",
current_position_dp_code=DPCode.PERCENT_CONTROL,
set_position_dp_code=DPCode.PERCENT_CONTROL,
)
.add_common_select(
key="control_back_mode",
key=DPCode.CONTROL_BACK_MODE,
common_type=CommonSelectType.CONTROL_BACK_MODE,
dp_code="control_back_mode",
)
.register(TUYA_QUIRKS_REGISTRY)
)
@@ -33,19 +31,17 @@ from ..xternal_tuya_quirks.sensor import CommonSensorType
TuyaDeviceQuirk()
.applies_to(category="cl", product_id="lfkr93x0ukp5gaia")
.add_common_cover(
key="control",
key=DPCode.CONTROL,
common_type=CommonCoverType.CURTAIN,
current_state_dp_code="control",
current_state_dp_code=DPCode.CONTROL,
)
.add_common_select(
key="control_back_mode",
key=DPCode.CONTROL_BACK_MODE,
common_type=CommonSelectType.CONTROL_BACK_MODE,
dp_code="control_back_mode",
)
.add_common_sensor(
key="time_total",
key=DPCode.TIME_TOTAL,
common_type=CommonSensorType.TIME_TOTAL,
dp_code="time_total",
)
.register(TUYA_QUIRKS_REGISTRY)
)

View File

@@ -0,0 +1,39 @@
"""Quirks for Tuya."""
from __future__ import annotations
from typing import cast
from ..const import DPCode
from ..models import IntegerTypeData
from ..xternal_tuya_quirks import TUYA_QUIRKS_REGISTRY, TuyaDeviceQuirk
from ..xternal_tuya_quirks.climate import CommonClimateType
from ..xternal_tuya_quirks.sensor import CommonSensorType
from ..xternal_tuya_quirks.switch import CommonSwitchType
from ..xternal_tuya_quirks.utils import scale_value_force_scale_1
(
# This model has percent_state and percent_control but percent_state never
# gets updated - force percent_control instead
TuyaDeviceQuirk()
.applies_to(category="wk", product_id="IAYz2WK1th0cMLmL")
.add_common_climate(
key="wk", # to avoid breaking change
common_type=CommonClimateType.SWITCH_ONLY_HEAT_COOL,
switch_dp_code=DPCode.SWITCH,
current_temperature_dp_code=DPCode.UPPER_TEMP,
set_temperature_dp_code=DPCode.TEMP_SET,
)
.add_common_sensor(
key=DPCode.UPPER_TEMP,
common_type=CommonSensorType.TEMPERATURE,
state_conversion=lambda _device, dptype, value: scale_value_force_scale_1(
cast(IntegerTypeData, dptype), cast(float, value)
),
)
.add_common_switch(
key=DPCode.CHILD_LOCK,
common_type=CommonSwitchType.CHILD_LOCK,
)
.register(TUYA_QUIRKS_REGISTRY)
)

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 ..const import DPCode
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_dp_code: DPCode | None = None
set_temperature_dp_code: DPCode | None = None
switch_dp_code: DPCode | None = None

View File

@@ -5,6 +5,8 @@ from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from ..const import DPCode
class CommonCoverType(StrEnum):
"""Common cover types."""
@@ -20,7 +22,7 @@ class TuyaCoverDefinition:
common_type: CommonCoverType
current_position_dp_code: str | None = None
current_state_dp_code: str | None = None
set_position_dp_code: str | None = None
set_state_dp_code: str
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

@@ -2,13 +2,20 @@
from __future__ import annotations
from collections.abc import Callable
import inspect
import pathlib
from typing import TYPE_CHECKING, Self
from typing import TYPE_CHECKING, Any, Self
from tuya_sharing import CustomerDevice
from ..const import DPCode
from ..models import EnumTypeData, IntegerTypeData
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
@@ -18,16 +25,20 @@ 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:
@@ -48,15 +59,36 @@ class TuyaDeviceQuirk:
for category, product_id in self._applies_to:
registry.register(category, product_id, self)
def add_common_cover(
def add_common_climate(
self,
*,
key: str,
common_type: CommonClimateType,
current_temperature_dp_code: DPCode | None = None,
set_temperature_dp_code: DPCode | None = None,
switch_dp_code: DPCode | None = None,
) -> Self:
"""Add climate definition."""
self.climate_definitions.append(
TuyaClimateDefinition(
key=key,
common_type=common_type,
switch_dp_code=switch_dp_code,
current_temperature_dp_code=current_temperature_dp_code,
set_temperature_dp_code=set_temperature_dp_code,
)
)
return self
def add_common_cover(
self,
*,
key: DPCode,
common_type: CommonCoverType,
current_position_dp_code: str | None = None,
current_state_dp_code: str | None = None,
set_position_dp_code: str | None = None,
set_state_dp_code: str | None = None,
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(
@@ -74,16 +106,14 @@ class TuyaDeviceQuirk:
def add_common_select(
self,
*,
key: str,
key: DPCode,
common_type: CommonSelectType,
dp_code: str | None = None,
) -> Self:
"""Add select definition."""
self.select_definitions.append(
TuyaSelectDefinition(
key=key,
common_type=common_type,
dp_code=dp_code or key,
)
)
return self
@@ -91,16 +121,34 @@ class TuyaDeviceQuirk:
def add_common_sensor(
self,
*,
key: str,
key: DPCode,
common_type: CommonSensorType,
dp_code: str | None = None,
state_conversion: Callable[
[CustomerDevice, EnumTypeData | IntegerTypeData | None, Any], Any
]
| None = None,
) -> Self:
"""Add sensor definition."""
self.sensor_definitions.append(
TuyaSensorDefinition(
key=key,
common_type=common_type,
dp_code=dp_code or key,
state_conversion=state_conversion,
)
)
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

@@ -1,10 +1,12 @@
"""Common cover quirks for Tuya devices."""
"""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."""
@@ -16,8 +18,6 @@ class CommonSelectType(StrEnum):
class TuyaSelectDefinition:
"""Definition for a select entity."""
key: str
key: DPCode
common_type: CommonSelectType
dp_code: str

View File

@@ -1,14 +1,22 @@
"""Common cover quirks for Tuya devices."""
"""Common sensor quirks for Tuya devices."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
from tuya_sharing import CustomerDevice
from ..const import DPCode
from ..models import EnumTypeData, IntegerTypeData
class CommonSensorType(StrEnum):
"""Common sensor types."""
TEMPERATURE = "temperature"
TIME_TOTAL = "time_total"
@@ -16,8 +24,11 @@ class CommonSensorType(StrEnum):
class TuyaSensorDefinition:
"""Definition for a sensor entity."""
key: str
key: DPCode
common_type: CommonSensorType
dp_code: str
state_conversion: (
Callable[[CustomerDevice, EnumTypeData | IntegerTypeData | None, Any], Any]
| None
) = None

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."""
from typing import Any
from ..models import IntegerTypeData
def scale_value(value: float, step: float, scale: float) -> Any:
"""Official scaling function from Tuya.
See https://support.tuya.com/en/help/_detail/Kadi66s463e2q
"""
return step * value / (10**scale)
def scale_value_force_scale_1(dptype: IntegerTypeData, value: float) -> float:
"""Some devices have incorrect scale, force scale=1."""
return scale_value(value, dptype.step, 1)

View File

@@ -5857,6 +5857,62 @@
'state': '219.72',
})
# ---
# name: test_platform_setup_and_discovery[sensor.el_termostato_de_la_cocina_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.el_termostato_de_la_cocina_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Temperature',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'temperature',
'unique_id': 'tuya.LmLMc0ht1KW2zYAIkwupper_temp',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_platform_setup_and_discovery[sensor.el_termostato_de_la_cocina_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'El termostato de la cocina Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.el_termostato_de_la_cocina_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '27.5',
})
# ---
# name: test_platform_setup_and_discovery[sensor.elivco_kitchen_socket_current-entry]
EntityRegistryEntrySnapshot({
'aliases': set({