Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion homeassistant/components/tuya/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from . import TuyaConfigEntry
from .const import DOMAIN, DPCode
from .models import DEVICE_WARNINGS
from .type_information import DEVICE_WARNINGS

_REDACTED_DPCODES = {
DPCode.ALARM_MESSAGE,
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/tuya/fan.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def get_speed_count(self) -> int:
"""Get the number of speeds supported by the fan."""
return len(self.type_information.range)

def read_device_status(self, device: CustomerDevice) -> int | None: # type: ignore[override]
def read_device_status(self, device: CustomerDevice) -> int | None:
"""Get the current speed as a percentage."""
if (value := super().read_device_status(device)) is None:
return None
Expand Down
68 changes: 7 additions & 61 deletions homeassistant/components/tuya/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,32 +9,14 @@

from homeassistant.util.json import json_loads

from .const import LOGGER, DPType
from .const import DPType
from .type_information import (
EnumTypeInformation,
IntegerTypeInformation,
TypeInformation,
find_dpcode,
)

# Dictionary to track logged warnings to avoid spamming logs
# Keyed by device ID
DEVICE_WARNINGS: dict[str, set[str]] = {}


def _should_log_warning(device_id: str, warning_key: str) -> bool:
"""Check if a warning has already been logged for a device and add it if not.

Returns: False if the warning was already logged, True if it was added.
"""
if (device_warnings := DEVICE_WARNINGS.get(device_id)) is None:
device_warnings = set()
DEVICE_WARNINGS[device_id] = device_warnings
if warning_key in device_warnings:
return False
DEVICE_WARNINGS[device_id].add(warning_key)
return True


class DeviceWrapper:
"""Base device wrapper."""
Expand Down Expand Up @@ -105,6 +87,12 @@ def __init__(self, dpcode: str, type_information: T) -> None:
super().__init__(dpcode)
self.type_information = type_information

def read_device_status(self, device: CustomerDevice) -> Any | None:
"""Read the device value for the dpcode."""
return self.type_information.process_raw_value(
self._read_device_status_raw(device), device
)

Comment thread
epenet marked this conversation as resolved.
@classmethod
def find_dpcode(
cls,
Expand Down Expand Up @@ -145,12 +133,6 @@ class DPCodeBooleanWrapper(DPCodeTypeInformationWrapper[TypeInformation]):

DPTYPE = DPType.BOOLEAN

def read_device_status(self, device: CustomerDevice) -> bool | None:
"""Read the device value for the dpcode."""
if (raw_value := self._read_device_status_raw(device)) in (True, False):
return raw_value
return None

def _convert_value_to_raw_value(
self, device: CustomerDevice, value: Any
) -> Any | None:
Expand Down Expand Up @@ -179,29 +161,6 @@ class DPCodeEnumWrapper(DPCodeTypeInformationWrapper[EnumTypeInformation]):

DPTYPE = DPType.ENUM

def read_device_status(self, device: CustomerDevice) -> str | None:
"""Read the device value for the dpcode.

Values outside of the list defined by the Enum type information will
return None.
"""
if (raw_value := self._read_device_status_raw(device)) is None:
return None
if raw_value not in self.type_information.range:
if _should_log_warning(
device.id, f"enum_out_range|{self.dpcode}|{raw_value}"
):
LOGGER.warning(
"Found invalid enum value `%s` for datapoint `%s` in product id `%s`,"
" expected one of `%s`; please report this defect to Tuya support",
raw_value,
self.dpcode,
device.product_id,
self.type_information.range,
)
return None
return raw_value

def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
"""Convert a Home Assistant value back to a raw device value."""
if value in self.type_information.range:
Expand All @@ -223,15 +182,6 @@ def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> Non
super().__init__(dpcode, type_information)
self.native_unit = type_information.unit

def read_device_status(self, device: CustomerDevice) -> float | None:
"""Read the device value for the dpcode.

Value will be scaled based on the Integer type information.
"""
if (raw_value := self._read_device_status_raw(device)) is None:
return None
return raw_value / (10**self.type_information.scale)

def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
"""Convert a Home Assistant value back to a raw device value."""
new_value = round(value * (10**self.type_information.scale))
Expand All @@ -250,10 +200,6 @@ class DPCodeStringWrapper(DPCodeTypeInformationWrapper[TypeInformation]):

DPTYPE = DPType.STRING

def read_device_status(self, device: CustomerDevice) -> str | None:
"""Read the device value for the dpcode."""
return self._read_device_status_raw(device)


class DPCodeBitmapBitWrapper(DPCodeWrapper):
"""Simple wrapper for a specific bit in bitmap values."""
Expand Down
128 changes: 121 additions & 7 deletions homeassistant/components/tuya/type_information.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,30 @@

from homeassistant.util.json import json_loads_object

from .const import DPType
from .const import LOGGER, DPType
from .util import parse_dptype, remap_value

# Dictionary to track logged warnings to avoid spamming logs
# Keyed by device ID
DEVICE_WARNINGS: dict[str, set[str]] = {}


Comment thread
epenet marked this conversation as resolved.
def _should_log_warning(device_id: str, warning_key: str) -> bool:
"""Check if a warning has already been logged for a device and add it if not.

Returns: False if the warning was already logged, True if it was added.
Comment thread
epenet marked this conversation as resolved.
Outdated
"""
if (device_warnings := DEVICE_WARNINGS.get(device_id)) is None:
device_warnings = set()
DEVICE_WARNINGS[device_id] = device_warnings
if warning_key in device_warnings:
return False
DEVICE_WARNINGS[device_id].add(warning_key)
return True


@dataclass(kw_only=True)
class TypeInformation:
class TypeInformation[T]:
"""Type information.

As provided by the SDK, from `device.function` / `device.status_range`.
Expand All @@ -23,14 +41,24 @@ class TypeInformation:
dpcode: str
type_data: str | None = None

def process_raw_value(
self, raw_value: Any | None, device: CustomerDevice
) -> T | None:
"""Read and process raw value against this type information.

Base implementation does no validation, subclasses may override to provide
specific validation.
"""
return raw_value

@classmethod
def from_json(cls, dpcode: str, type_data: str) -> Self | None:
"""Load JSON string and return a TypeInformation object."""
return cls(dpcode=dpcode, type_data=type_data)


@dataclass(kw_only=True)
class BitmapTypeInformation(TypeInformation):
class BitmapTypeInformation(TypeInformation[int]):
"""Bitmap type information."""

label: list[str]
Expand All @@ -48,11 +76,62 @@ def from_json(cls, dpcode: str, type_data: str) -> Self | None:


@dataclass(kw_only=True)
class EnumTypeInformation(TypeInformation):
class BooleanTypeInformation(TypeInformation[bool]):
"""Boolean type information."""

def process_raw_value(
self, raw_value: Any | None, device: CustomerDevice
) -> bool | None:
"""Read and process raw value against this type information."""
if raw_value is None:
return None
# Validate input against defined range
if raw_value not in (True, False):
if _should_log_warning(
device.id, f"boolean_out_range|{self.dpcode}|{raw_value}"
):
LOGGER.warning(
"Found invalid boolean value `%s` for datapoint `%s` in product "
"id `%s`, expected one of `%s`; please report this defect to "
"Tuya support",
raw_value,
self.dpcode,
device.product_id,
(True, False),
)
Comment thread
epenet marked this conversation as resolved.
return None
return raw_value


@dataclass(kw_only=True)
class EnumTypeInformation(TypeInformation[str]):
"""Enum type information."""

range: list[str]

def process_raw_value(
self, raw_value: Any | None, device: CustomerDevice
) -> str | None:
"""Read and process raw value against this type information."""
if raw_value is None:
return None
# Validate input against defined range
if raw_value not in self.range:
if _should_log_warning(
device.id, f"enum_out_range|{self.dpcode}|{raw_value}"
):
LOGGER.warning(
"Found invalid enum value `%s` for datapoint `%s` in product "
"id `%s`, expected one of `%s`; please report this defect to "
"Tuya support",
raw_value,
self.dpcode,
device.product_id,
self.range,
)
Comment thread
epenet marked this conversation as resolved.
return None
return raw_value

@classmethod
def from_json(cls, dpcode: str, type_data: str) -> Self | None:
"""Load JSON string and return an EnumTypeInformation object."""
Expand All @@ -66,7 +145,7 @@ def from_json(cls, dpcode: str, type_data: str) -> Self | None:


@dataclass(kw_only=True)
class IntegerTypeInformation(TypeInformation):
class IntegerTypeInformation(TypeInformation[float]):
"""Integer type information."""

min: int
Expand Down Expand Up @@ -118,6 +197,31 @@ def remap_value_from(
"""Remap a value from its current range to this range."""
return remap_value(value, from_min, from_max, self.min, self.max, reverse)

def process_raw_value(
self, raw_value: Any | None, device: CustomerDevice
) -> float | None:
"""Read and process raw value against this type information."""
if raw_value is None:
return None
# Validate input against defined range
if not isinstance(raw_value, int) or not (self.min <= raw_value <= self.max):
if _should_log_warning(
device.id, f"integer_out_range|{self.dpcode}|{raw_value}"
):
LOGGER.warning(
"Found invalid integer value `%s` for datapoint `%s` in product "
"id `%s`, expected value between %s and %s; please report this "
"defect to Tuya support",
raw_value,
self.dpcode,
device.product_id,
self.min,
self.max,
)
Comment on lines +207 to +220

Copilot AI Dec 4, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation logic combines type checking and range checking in a single condition. If raw_value is not an integer (e.g., a float or string), the warning message will incorrectly suggest the issue is with the range rather than the type. Consider separating these checks:

if not isinstance(raw_value, int):
    if _should_log_warning(...):
        LOGGER.warning("Found invalid type `%s` for integer datapoint `%s`...", type(raw_value).__name__, ...)
    return None
if not (self.min <= raw_value <= self.max):
    if _should_log_warning(...):
        LOGGER.warning("Found integer value `%s` out of range for datapoint `%s`...", raw_value, ...)
    return None

Copilot uses AI. Check for mistakes.
Comment thread
epenet marked this conversation as resolved.

return None
return raw_value / (10**self.scale)

@classmethod
def from_json(cls, dpcode: str, type_data: str) -> Self | None:
"""Load JSON string and return an IntegerTypeInformation object."""
Expand All @@ -137,7 +241,7 @@ def from_json(cls, dpcode: str, type_data: str) -> Self | None:

_TYPE_INFORMATION_MAPPINGS: dict[DPType, type[TypeInformation]] = {
DPType.BITMAP: BitmapTypeInformation,
DPType.BOOLEAN: TypeInformation,
DPType.BOOLEAN: BooleanTypeInformation,
DPType.ENUM: EnumTypeInformation,
DPType.INTEGER: IntegerTypeInformation,
DPType.JSON: TypeInformation,
Expand All @@ -156,6 +260,16 @@ def find_dpcode(
) -> BitmapTypeInformation | None: ...


@overload
def find_dpcode(
device: CustomerDevice,
dpcodes: str | tuple[str, ...] | None,
*,
prefer_function: bool = False,
dptype: Literal[DPType.BOOLEAN],
) -> BooleanTypeInformation | None: ...


@overload
def find_dpcode(
device: CustomerDevice,
Expand All @@ -182,7 +296,7 @@ def find_dpcode(
dpcodes: str | tuple[str, ...] | None,
*,
prefer_function: bool = False,
dptype: Literal[DPType.BOOLEAN, DPType.JSON, DPType.RAW],
dptype: Literal[DPType.JSON, DPType.RAW],
) -> TypeInformation | None: ...


Expand Down
4 changes: 2 additions & 2 deletions tests/components/tuya/snapshots/test_climate.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -583,7 +583,7 @@
]),
'supported_features': <ClimateEntityFeature: 401>,
'target_temp_step': 1.0,
'temperature': 2.3,
'temperature': None,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This device reports temp_set with status 23, but min 50 / max 350

}),
'context': <ANY>,
'entity_id': 'climate.floor_thermostat_kitchen',
Expand Down Expand Up @@ -1439,7 +1439,7 @@
'max_temp': 66,
'min_temp': 12,
'target_temp_step': 1.0,
'temperature': 4,
'temperature': None,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This (same) device reports temp_set_f with status 41, but min 122 / max 662

})
# ---
# name: test_us_customary_system[climate.geti_solar_pv_water_heater]
Expand Down
Loading