Skip to content

Commit 4e8a31a

Browse files
epenetCopilot
andauthored
Improve Tuya data validation (home-assistant#157968)
Co-authored-by: Copilot <[email protected]>
1 parent 2beb551 commit 4e8a31a

File tree

5 files changed

+132
-72
lines changed

5 files changed

+132
-72
lines changed

homeassistant/components/tuya/diagnostics.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from . import TuyaConfigEntry
1616
from .const import DOMAIN, DPCode
17-
from .models import DEVICE_WARNINGS
17+
from .type_information import DEVICE_WARNINGS
1818

1919
_REDACTED_DPCODES = {
2020
DPCode.ALARM_MESSAGE,

homeassistant/components/tuya/fan.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def get_speed_count(self) -> int:
8080
"""Get the number of speeds supported by the fan."""
8181
return len(self.type_information.range)
8282

83-
def read_device_status(self, device: CustomerDevice) -> int | None: # type: ignore[override]
83+
def read_device_status(self, device: CustomerDevice) -> int | None:
8484
"""Get the current speed as a percentage."""
8585
if (value := super().read_device_status(device)) is None:
8686
return None

homeassistant/components/tuya/models.py

Lines changed: 7 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -9,32 +9,14 @@
99

1010
from homeassistant.util.json import json_loads
1111

12-
from .const import LOGGER, DPType
12+
from .const import DPType
1313
from .type_information import (
1414
EnumTypeInformation,
1515
IntegerTypeInformation,
1616
TypeInformation,
1717
find_dpcode,
1818
)
1919

20-
# Dictionary to track logged warnings to avoid spamming logs
21-
# Keyed by device ID
22-
DEVICE_WARNINGS: dict[str, set[str]] = {}
23-
24-
25-
def _should_log_warning(device_id: str, warning_key: str) -> bool:
26-
"""Check if a warning has already been logged for a device and add it if not.
27-
28-
Returns: False if the warning was already logged, True if it was added.
29-
"""
30-
if (device_warnings := DEVICE_WARNINGS.get(device_id)) is None:
31-
device_warnings = set()
32-
DEVICE_WARNINGS[device_id] = device_warnings
33-
if warning_key in device_warnings:
34-
return False
35-
DEVICE_WARNINGS[device_id].add(warning_key)
36-
return True
37-
3820

3921
class DeviceWrapper:
4022
"""Base device wrapper."""
@@ -105,6 +87,12 @@ def __init__(self, dpcode: str, type_information: T) -> None:
10587
super().__init__(dpcode)
10688
self.type_information = type_information
10789

90+
def read_device_status(self, device: CustomerDevice) -> Any | None:
91+
"""Read the device value for the dpcode."""
92+
return self.type_information.process_raw_value(
93+
self._read_device_status_raw(device), device
94+
)
95+
10896
@classmethod
10997
def find_dpcode(
11098
cls,
@@ -145,12 +133,6 @@ class DPCodeBooleanWrapper(DPCodeTypeInformationWrapper[TypeInformation]):
145133

146134
DPTYPE = DPType.BOOLEAN
147135

148-
def read_device_status(self, device: CustomerDevice) -> bool | None:
149-
"""Read the device value for the dpcode."""
150-
if (raw_value := self._read_device_status_raw(device)) in (True, False):
151-
return raw_value
152-
return None
153-
154136
def _convert_value_to_raw_value(
155137
self, device: CustomerDevice, value: Any
156138
) -> Any | None:
@@ -179,29 +161,6 @@ class DPCodeEnumWrapper(DPCodeTypeInformationWrapper[EnumTypeInformation]):
179161

180162
DPTYPE = DPType.ENUM
181163

182-
def read_device_status(self, device: CustomerDevice) -> str | None:
183-
"""Read the device value for the dpcode.
184-
185-
Values outside of the list defined by the Enum type information will
186-
return None.
187-
"""
188-
if (raw_value := self._read_device_status_raw(device)) is None:
189-
return None
190-
if raw_value not in self.type_information.range:
191-
if _should_log_warning(
192-
device.id, f"enum_out_range|{self.dpcode}|{raw_value}"
193-
):
194-
LOGGER.warning(
195-
"Found invalid enum value `%s` for datapoint `%s` in product id `%s`,"
196-
" expected one of `%s`; please report this defect to Tuya support",
197-
raw_value,
198-
self.dpcode,
199-
device.product_id,
200-
self.type_information.range,
201-
)
202-
return None
203-
return raw_value
204-
205164
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
206165
"""Convert a Home Assistant value back to a raw device value."""
207166
if value in self.type_information.range:
@@ -223,15 +182,6 @@ def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> Non
223182
super().__init__(dpcode, type_information)
224183
self.native_unit = type_information.unit
225184

226-
def read_device_status(self, device: CustomerDevice) -> float | None:
227-
"""Read the device value for the dpcode.
228-
229-
Value will be scaled based on the Integer type information.
230-
"""
231-
if (raw_value := self._read_device_status_raw(device)) is None:
232-
return None
233-
return raw_value / (10**self.type_information.scale)
234-
235185
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
236186
"""Convert a Home Assistant value back to a raw device value."""
237187
new_value = round(value * (10**self.type_information.scale))
@@ -250,10 +200,6 @@ class DPCodeStringWrapper(DPCodeTypeInformationWrapper[TypeInformation]):
250200

251201
DPTYPE = DPType.STRING
252202

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

258204
class DPCodeBitmapBitWrapper(DPCodeWrapper):
259205
"""Simple wrapper for a specific bit in bitmap values."""

homeassistant/components/tuya/type_information.py

Lines changed: 121 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,30 @@
99

1010
from homeassistant.util.json import json_loads_object
1111

12-
from .const import DPType
12+
from .const import LOGGER, DPType
1313
from .util import parse_dptype, remap_value
1414

15+
# Dictionary to track logged warnings to avoid spamming logs
16+
# Keyed by device ID
17+
DEVICE_WARNINGS: dict[str, set[str]] = {}
18+
19+
20+
def _should_log_warning(device_id: str, warning_key: str) -> bool:
21+
"""Check if a warning has already been logged for a device and add it if not.
22+
23+
Returns: True if the warning should be logged, False if it was already logged.
24+
"""
25+
if (device_warnings := DEVICE_WARNINGS.get(device_id)) is None:
26+
device_warnings = set()
27+
DEVICE_WARNINGS[device_id] = device_warnings
28+
if warning_key in device_warnings:
29+
return False
30+
DEVICE_WARNINGS[device_id].add(warning_key)
31+
return True
32+
1533

1634
@dataclass(kw_only=True)
17-
class TypeInformation:
35+
class TypeInformation[T]:
1836
"""Type information.
1937
2038
As provided by the SDK, from `device.function` / `device.status_range`.
@@ -23,14 +41,24 @@ class TypeInformation:
2341
dpcode: str
2442
type_data: str | None = None
2543

44+
def process_raw_value(
45+
self, raw_value: Any | None, device: CustomerDevice
46+
) -> T | None:
47+
"""Read and process raw value against this type information.
48+
49+
Base implementation does no validation, subclasses may override to provide
50+
specific validation.
51+
"""
52+
return raw_value
53+
2654
@classmethod
2755
def from_json(cls, dpcode: str, type_data: str) -> Self | None:
2856
"""Load JSON string and return a TypeInformation object."""
2957
return cls(dpcode=dpcode, type_data=type_data)
3058

3159

3260
@dataclass(kw_only=True)
33-
class BitmapTypeInformation(TypeInformation):
61+
class BitmapTypeInformation(TypeInformation[int]):
3462
"""Bitmap type information."""
3563

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

4977

5078
@dataclass(kw_only=True)
51-
class EnumTypeInformation(TypeInformation):
79+
class BooleanTypeInformation(TypeInformation[bool]):
80+
"""Boolean type information."""
81+
82+
def process_raw_value(
83+
self, raw_value: Any | None, device: CustomerDevice
84+
) -> bool | None:
85+
"""Read and process raw value against this type information."""
86+
if raw_value is None:
87+
return None
88+
# Validate input against defined range
89+
if raw_value not in (True, False):
90+
if _should_log_warning(
91+
device.id, f"boolean_out_range|{self.dpcode}|{raw_value}"
92+
):
93+
LOGGER.warning(
94+
"Found invalid boolean value `%s` for datapoint `%s` in product "
95+
"id `%s`, expected one of `%s`; please report this defect to "
96+
"Tuya support",
97+
raw_value,
98+
self.dpcode,
99+
device.product_id,
100+
(True, False),
101+
)
102+
return None
103+
return raw_value
104+
105+
106+
@dataclass(kw_only=True)
107+
class EnumTypeInformation(TypeInformation[str]):
52108
"""Enum type information."""
53109

54110
range: list[str]
55111

112+
def process_raw_value(
113+
self, raw_value: Any | None, device: CustomerDevice
114+
) -> str | None:
115+
"""Read and process raw value against this type information."""
116+
if raw_value is None:
117+
return None
118+
# Validate input against defined range
119+
if raw_value not in self.range:
120+
if _should_log_warning(
121+
device.id, f"enum_out_range|{self.dpcode}|{raw_value}"
122+
):
123+
LOGGER.warning(
124+
"Found invalid enum value `%s` for datapoint `%s` in product "
125+
"id `%s`, expected one of `%s`; please report this defect to "
126+
"Tuya support",
127+
raw_value,
128+
self.dpcode,
129+
device.product_id,
130+
self.range,
131+
)
132+
return None
133+
return raw_value
134+
56135
@classmethod
57136
def from_json(cls, dpcode: str, type_data: str) -> Self | None:
58137
"""Load JSON string and return an EnumTypeInformation object."""
@@ -66,7 +145,7 @@ def from_json(cls, dpcode: str, type_data: str) -> Self | None:
66145

67146

68147
@dataclass(kw_only=True)
69-
class IntegerTypeInformation(TypeInformation):
148+
class IntegerTypeInformation(TypeInformation[float]):
70149
"""Integer type information."""
71150

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

200+
def process_raw_value(
201+
self, raw_value: Any | None, device: CustomerDevice
202+
) -> float | None:
203+
"""Read and process raw value against this type information."""
204+
if raw_value is None:
205+
return None
206+
# Validate input against defined range
207+
if not isinstance(raw_value, int) or not (self.min <= raw_value <= self.max):
208+
if _should_log_warning(
209+
device.id, f"integer_out_range|{self.dpcode}|{raw_value}"
210+
):
211+
LOGGER.warning(
212+
"Found invalid integer value `%s` for datapoint `%s` in product "
213+
"id `%s`, expected integer value between %s and %s; please report "
214+
"this defect to Tuya support",
215+
raw_value,
216+
self.dpcode,
217+
device.product_id,
218+
self.min,
219+
self.max,
220+
)
221+
222+
return None
223+
return raw_value / (10**self.scale)
224+
121225
@classmethod
122226
def from_json(cls, dpcode: str, type_data: str) -> Self | None:
123227
"""Load JSON string and return an IntegerTypeInformation object."""
@@ -137,7 +241,7 @@ def from_json(cls, dpcode: str, type_data: str) -> Self | None:
137241

138242
_TYPE_INFORMATION_MAPPINGS: dict[DPType, type[TypeInformation]] = {
139243
DPType.BITMAP: BitmapTypeInformation,
140-
DPType.BOOLEAN: TypeInformation,
244+
DPType.BOOLEAN: BooleanTypeInformation,
141245
DPType.ENUM: EnumTypeInformation,
142246
DPType.INTEGER: IntegerTypeInformation,
143247
DPType.JSON: TypeInformation,
@@ -156,6 +260,16 @@ def find_dpcode(
156260
) -> BitmapTypeInformation | None: ...
157261

158262

263+
@overload
264+
def find_dpcode(
265+
device: CustomerDevice,
266+
dpcodes: str | tuple[str, ...] | None,
267+
*,
268+
prefer_function: bool = False,
269+
dptype: Literal[DPType.BOOLEAN],
270+
) -> BooleanTypeInformation | None: ...
271+
272+
159273
@overload
160274
def find_dpcode(
161275
device: CustomerDevice,
@@ -182,7 +296,7 @@ def find_dpcode(
182296
dpcodes: str | tuple[str, ...] | None,
183297
*,
184298
prefer_function: bool = False,
185-
dptype: Literal[DPType.BOOLEAN, DPType.JSON, DPType.RAW],
299+
dptype: Literal[DPType.JSON, DPType.RAW],
186300
) -> TypeInformation | None: ...
187301

188302

tests/components/tuya/snapshots/test_climate.ambr

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -583,7 +583,7 @@
583583
]),
584584
'supported_features': <ClimateEntityFeature: 401>,
585585
'target_temp_step': 1.0,
586-
'temperature': 2.3,
586+
'temperature': None,
587587
}),
588588
'context': <ANY>,
589589
'entity_id': 'climate.floor_thermostat_kitchen',
@@ -1501,7 +1501,7 @@
15011501
'max_temp': 66,
15021502
'min_temp': 12,
15031503
'target_temp_step': 1.0,
1504-
'temperature': 4,
1504+
'temperature': None,
15051505
})
15061506
# ---
15071507
# name: test_us_customary_system[climate.geti_solar_pv_water_heater]

0 commit comments

Comments
 (0)