diff --git a/homeassistant/components/tuya/diagnostics.py b/homeassistant/components/tuya/diagnostics.py index 770686d1b55..a4f5ed07112 100644 --- a/homeassistant/components/tuya/diagnostics.py +++ b/homeassistant/components/tuya/diagnostics.py @@ -14,6 +14,7 @@ from homeassistant.util import dt as dt_util from . import TuyaConfigEntry from .const import DOMAIN, DPCode +from .models import DEVICE_WARNINGS _REDACTED_DPCODES = { DPCode.ALARM_MESSAGE, @@ -97,6 +98,7 @@ def _async_device_as_dict( "home_assistant": {}, "set_up": device.set_up, "support_local": device.support_local, + "warnings": DEVICE_WARNINGS.get(device.id), } # Gather Tuya states diff --git a/homeassistant/components/tuya/models.py b/homeassistant/components/tuya/models.py index 80cede7d339..2cfa64e2b33 100644 --- a/homeassistant/components/tuya/models.py +++ b/homeassistant/components/tuya/models.py @@ -11,9 +11,27 @@ from tuya_sharing import CustomerDevice from homeassistant.util.json import json_loads, json_loads_object -from .const import DPCode, DPType +from .const import LOGGER, DPCode, 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]] = {} + + +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 + @dataclass(kw_only=True) class TypeInformation: @@ -285,11 +303,22 @@ class DPCodeEnumWrapper(DPCodeTypeInformationWrapper[EnumTypeData]): Values outside of the list defined by the Enum type information will return None. """ - if ( - raw_value := self._read_device_status_raw(device) - ) in self.type_information.range: - return raw_value - 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.""" diff --git a/tests/components/tuya/snapshots/test_diagnostics.ambr b/tests/components/tuya/snapshots/test_diagnostics.ambr index 7af44a1e347..54e31002f16 100644 --- a/tests/components/tuya/snapshots/test_diagnostics.ambr +++ b/tests/components/tuya/snapshots/test_diagnostics.ambr @@ -298,6 +298,7 @@ 'terminal_id': '7cd96aff-6ec8-4006-b093-3dbff7947591', 'time_zone': '+02:00', 'update_time': '2024-12-02T20:08:56+00:00', + 'warnings': None, }) # --- # name: test_device_diagnostics[rqbj_4iqe2hsfyd86kwwc] @@ -413,6 +414,167 @@ 'terminal_id': '7cd96aff-6ec8-4006-b093-3dbff7947591', 'time_zone': '-04:00', 'update_time': '2025-06-24T20:33:10+00:00', + 'warnings': None, + }) +# --- +# name: test_device_diagnostics[tdq_9htyiowaf5rtdhrv] + dict({ + 'active_time': '2024-09-08T13:46:46+00:00', + 'category': 'tdq', + 'create_time': '2024-09-08T13:46:46+00:00', + 'disabled_by': None, + 'disabled_polling': False, + 'endpoint': 'https://apigw.tuyaeu.com', + 'function': dict({ + 'countdown_1': dict({ + 'type': 'Integer', + 'value': '{"unit":"s","min":0,"max":86400,"scale":0,"step":1}', + }), + 'cycle_time': dict({ + 'type': 'String', + 'value': '{"maxlen":255}', + }), + 'random_time': dict({ + 'type': 'String', + 'value': '{"maxlen":255}', + }), + 'relay_status': dict({ + 'type': 'Enum', + 'value': '{"range":["0","1","2"]}', + }), + 'remote_add': dict({ + 'type': 'Raw', + 'value': '{}', + }), + 'remote_list': dict({ + 'type': 'Raw', + 'value': '{}', + }), + 'switch_1': dict({ + 'type': 'Boolean', + 'value': '{}', + }), + 'switch_inching': dict({ + 'type': 'String', + 'value': '{"maxlen":255}', + }), + 'switch_type': dict({ + 'type': 'Enum', + 'value': '{"range":["flip","sync","button"]}', + }), + }), + 'home_assistant': dict({ + 'disabled': False, + 'disabled_by': None, + 'entities': list([ + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': 'config', + 'icon': None, + 'original_device_class': None, + 'original_icon': None, + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Framboisiers Power on behavior', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'entity_id': 'select.framboisiers_power_on_behavior', + 'state': 'unknown', + }), + 'unit_of_measurement': None, + }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': None, + 'icon': None, + 'original_device_class': 'outlet', + 'original_icon': None, + 'state': dict({ + 'attributes': dict({ + 'device_class': 'outlet', + 'friendly_name': 'Framboisiers Switch 1', + }), + 'entity_id': 'switch.framboisiers_switch_1', + 'state': 'off', + }), + 'unit_of_measurement': None, + }), + ]), + 'name': 'Framboisiers', + 'name_by_user': None, + }), + 'id': 'vrhdtr5fawoiyth9qdt', + 'mqtt_connected': True, + 'name': 'Framboisiers', + 'online': True, + 'product_id': '9htyiowaf5rtdhrv', + 'product_name': '1-433', + 'set_up': True, + 'status': dict({ + 'countdown_1': 0, + 'cycle_time': '', + 'random_time': '', + 'relay_status': 2, + 'remote_add': '', + 'remote_list': 'AA==', + 'switch_1': False, + 'switch_inching': 'AAAC', + 'switch_type': 'button', + }), + 'status_range': dict({ + 'countdown_1': dict({ + 'type': 'Integer', + 'value': '{"unit":"s","min":0,"max":86400,"scale":0,"step":1}', + }), + 'cycle_time': dict({ + 'type': 'String', + 'value': '{"maxlen":255}', + }), + 'random_time': dict({ + 'type': 'String', + 'value': '{"maxlen":255}', + }), + 'relay_status': dict({ + 'type': 'Enum', + 'value': '{"range":["0","1","2"]}', + }), + 'remote_add': dict({ + 'type': 'Raw', + 'value': '{}', + }), + 'remote_list': dict({ + 'type': 'Raw', + 'value': '{}', + }), + 'switch_1': dict({ + 'type': 'Boolean', + 'value': '{}', + }), + 'switch_inching': dict({ + 'type': 'String', + 'value': '{"maxlen":255}', + }), + 'switch_type': dict({ + 'type': 'Enum', + 'value': '{"range":["flip","sync","button"]}', + }), + }), + 'sub': False, + 'support_local': True, + 'terminal_id': '7cd96aff-6ec8-4006-b093-3dbff7947591', + 'time_zone': '+02:00', + 'update_time': '2024-09-08T13:46:46+00:00', + 'warnings': list([ + 'enum_out_range|relay_status|2', + ]), }) # --- # name: test_entry_diagnostics[rqbj_4iqe2hsfyd86kwwc] @@ -525,6 +687,7 @@ 'support_local': True, 'time_zone': '-04:00', 'update_time': '2025-06-24T20:33:10+00:00', + 'warnings': None, }), ]), 'disabled_by': None, diff --git a/tests/components/tuya/test_diagnostics.py b/tests/components/tuya/test_diagnostics.py index 0aadbf45532..8f6900ca9c1 100644 --- a/tests/components/tuya/test_diagnostics.py +++ b/tests/components/tuya/test_diagnostics.py @@ -45,8 +45,9 @@ async def test_entry_diagnostics( @pytest.mark.parametrize( "mock_device_code", [ - "mal_gyitctrjj1kefxp2", "rqbj_4iqe2hsfyd86kwwc", + "mal_gyitctrjj1kefxp2", # with redacted dpcodes + "tdq_9htyiowaf5rtdhrv", # with bad enum warnings ], ) async def test_device_diagnostics(