-
-
Notifications
You must be signed in to change notification settings - Fork 37.5k
Improve Tuya data validation #157968
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Improve Tuya data validation #157968
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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]] = {} | ||
|
|
||
|
|
||
|
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: True if the warning should be logged, False if it was already logged. | ||
| """ | ||
| 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`. | ||
|
|
@@ -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] | ||
|
|
@@ -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), | ||
| ) | ||
|
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, | ||
| ) | ||
|
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.""" | ||
|
|
@@ -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 | ||
|
|
@@ -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 integer 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
|
||
|
|
||
| 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.""" | ||
|
|
@@ -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, | ||
|
|
@@ -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, | ||
|
|
@@ -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: ... | ||
|
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -583,7 +583,7 @@ | |
| ]), | ||
| 'supported_features': <ClimateEntityFeature: 401>, | ||
| 'target_temp_step': 1.0, | ||
| 'temperature': 2.3, | ||
| 'temperature': None, | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This device reports |
||
| }), | ||
| 'context': <ANY>, | ||
| 'entity_id': 'climate.floor_thermostat_kitchen', | ||
|
|
@@ -1439,7 +1439,7 @@ | |
| 'max_temp': 66, | ||
| 'min_temp': 12, | ||
| 'target_temp_step': 1.0, | ||
| 'temperature': 4, | ||
| 'temperature': None, | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This (same) device reports |
||
| }) | ||
| # --- | ||
| # name: test_us_customary_system[climate.geti_solar_pv_water_heater] | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.