From d4af9e9e8fdc6a3f52e1681376952e4764e1b6ee Mon Sep 17 00:00:00 2001 From: prairiesnpr Date: Wed, 22 Jan 2025 01:20:18 +0000 Subject: [PATCH 1/6] Init quirk --- ...s0601_thermostat.py => tuya_thermostat.py} | 219 +++++++++++++++++- 1 file changed, 213 insertions(+), 6 deletions(-) rename zhaquirks/tuya/{ts0601_thermostat.py => tuya_thermostat.py} (52%) diff --git a/zhaquirks/tuya/ts0601_thermostat.py b/zhaquirks/tuya/tuya_thermostat.py similarity index 52% rename from zhaquirks/tuya/ts0601_thermostat.py rename to zhaquirks/tuya/tuya_thermostat.py index 36dc2f985d..43e6df39d8 100644 --- a/zhaquirks/tuya/ts0601_thermostat.py +++ b/zhaquirks/tuya/tuya_thermostat.py @@ -1,5 +1,6 @@ """Tuya TS0601 Thermostat.""" +from zigpy.quirks.v2 import BinarySensorDeviceClass, EntityType from zigpy.quirks.v2.homeassistant import ( UnitOfElectricCurrent, UnitOfElectricPotential, @@ -17,7 +18,7 @@ class RegulatorPeriod(t.enum8): - """Tuya Regulator Period enum.""" + """Tuya regulator period enum.""" FifteenMin = 0x00 ThirtyMin = 0x01 @@ -27,28 +28,71 @@ class RegulatorPeriod(t.enum8): class ThermostatMode(t.enum8): - """Tuya Thermostat mode.""" + """Tuya thermostat mode.""" Regulator = 0x00 Thermostat = 0x01 -class PresetMode(t.enum8): - """Tuya PresetMode enum.""" +class PresetModeV01(t.enum8): + """Tuya preset mode v01 enum.""" Manual = 0x00 Home = 0x01 Away = 0x02 +class PresetModeV02(t.enum8): + """Tuya preset mode v02 enum.""" + + Manual = 0x00 + Auto = 0x01 + Temporary_Manual = 0x02 + + +class PresetModeV03(t.enum8): + """Tuya preset mode v03 enum.""" + + Auto = 0x00 + Manual = 0x01 + Temporary_Manual = 0x02 + + class SensorMode(t.enum8): - """Tuya SensorMode enum.""" + """Tuya sensor mode enum.""" Air = 0x00 Floor = 0x01 Both = 0x02 +class BacklightMode(t.enum8): + """Tuya backlight mode enum.""" + + Off = 0x00 + Low = 0x01 + Medium = 0x02 + High = 0x03 + + +class WorkingDayV01(t.enum8): + """Tuya Working day v01 enum.""" + + Disabled = 0x00 + Six_One = 0x01 + Five_Two = 0x02 + Seven = 0x03 + + +class WorkingDayV02(t.enum8): + """Tuya Working day v02 enum.""" + + Disabled = 0x00 + Five_Two = 0x01 + Six_One = 0x02 + Seven = 0x03 + + class TuyaThermostat(Thermostat, TuyaAttributesCluster): """Tuya local thermostat cluster.""" @@ -82,7 +126,7 @@ def __init__(self, *args, **kwargs): .tuya_enum( dp_id=2, attribute_name="preset_mode", - enum_class=PresetMode, + enum_class=PresetModeV01, translation_key="preset_mode", fallback_name="Preset mode", ) @@ -215,3 +259,166 @@ def __init__(self, *args, **kwargs): .skip_configuration() .add_to_registry() ) + + +# Tuya ZWT198/ZWT100-BH Avatto wall thermostat +base_avatto_quirk = ( + TuyaQuirkBuilder() + .tuya_dp( + dp_id=1, + ep_attribute=TuyaThermostat.ep_attribute, + attribute_name=TuyaThermostat.AttributeDefs.system_mode.name, + converter=lambda x: 0x00 if not x else 0x04, + dp_converter=lambda x: x != 0x00, + ) + .tuya_dp( + dp_id=2, + ep_attribute=TuyaThermostat.ep_attribute, + attribute_name=TuyaThermostat.AttributeDefs.occupied_heating_setpoint.name, + converter=lambda x: x * 10, + dp_converter=lambda x: x // 10, + ) + .tuya_dp( + dp_id=3, + ep_attribute=TuyaThermostat.ep_attribute, + attribute_name=TuyaThermostat.AttributeDefs.local_temperature.name, + converter=lambda x: x * 10, + ) + .tuya_switch( + dp_id=9, + attribute_name="child_lock", + translation_key="child_lock", + fallback_name="Child lock", + ) + .tuya_binary_sensor( + dp_id=11, + attribute_name="fault_alarm", + entity_type=EntityType.STANDARD, + device_class=BinarySensorDeviceClass.PROBLEM, + translation_key="fault_alarm", + fallback_name="Fault alarm", + ) + .tuya_dp( + dp_id=15, + ep_attribute=TuyaThermostat.ep_attribute, + attribute_name=TuyaThermostat.AttributeDefs.max_heat_setpoint_limit.name, + converter=lambda x: x * 10, + dp_converter=lambda x: x // 10, + ) + .tuya_dp( + dp_id=19, + ep_attribute=TuyaThermostat.ep_attribute, + attribute_name=TuyaThermostat.AttributeDefs.local_temperature_calibration.name, + converter=lambda x: x * 10, + dp_converter=lambda x: x // 10, + ) + .tuya_dp( + dp_id=101, + ep_attribute=TuyaThermostat.ep_attribute, + attribute_name=TuyaThermostat.AttributeDefs.running_state.name, + converter=lambda x: 0x00 if not x else 0x01, + ) + .tuya_switch( + dp_id=102, + attribute_name="frost_protection", + translation_key="frost_protection", + fallback_name="Frost protection", + ) + .tuya_switch( + dp_id=103, + attribute_name="factory_reset", + translation_key="factory_reset", + fallback_name="Factory reset", + ) + .tuya_enum( + dp_id=106, + attribute_name="temperature_sensor_select", + enum_class=SensorMode, + translation_key="sensor_mode", + fallback_name="Sensor mode", + ) + .tuya_number( + dp_id=107, + attribute_name="deadzone_temperature", + type=t.uint16_t, + unit=UnitOfTemperature.CELSIUS, + min_value=0.1, + max_value=10, + step=0.1, + translation_key="deadzone_temperature", + fallback_name="Deadzone temperature", + ) + # 109 ZWT198 schedule, skipped + .tuya_enum( + dp_id=110, + attribute_name="backlight_mode", + enum_class=BacklightMode, + translation_key="backlight_mode", + fallback_name="Backlight mode", + ) + .adds(TuyaThermostat) + .skip_configuration() +) + + +( + base_avatto_quirk.clone() + .applies_to("_TZE204_lzriup1j", "TS0601") + .tuya_enum( + dp_id=4, + attribute_name="preset_mode", + enum_class=PresetModeV02, + translation_key="preset_mode", + fallback_name="Preset mode", + ) + .tuya_enum( + dp_id=104, + attribute_name="working_day", + enum_class=WorkingDayV02, + translation_key="working_day", + fallback_name="Working day", + ) + .add_to_registry() +) + +( + base_avatto_quirk.clone() + .applies_to("_TZE200_viy9ihs7", "TS0601") + .tuya_enum( + dp_id=4, + attribute_name="preset_mode", + enum_class=PresetModeV03, + translation_key="preset_mode", + fallback_name="Preset mode", + ) + .tuya_enum( + dp_id=104, + attribute_name="working_day", + enum_class=WorkingDayV01, + translation_key="working_day", + fallback_name="Working day", + ) + .add_to_registry() +) + + +( + base_avatto_quirk.clone() + .applies_to("_TZE204_xnbkhhdr", "TS0601") + .applies_to("_TZE284_xnbkhhdr", "TS0601") + .tuya_enum( + dp_id=4, + attribute_name="preset_mode", + enum_class=PresetModeV03, + translation_key="preset_mode", + fallback_name="Preset mode", + ) + .tuya_enum( + dp_id=104, + attribute_name="working_day", + enum_class=WorkingDayV02, + translation_key="working_day", + fallback_name="Working day", + ) + .add_to_registry() +) From 3f59acf4779e4e85e52ce0cee7a136520b14c400 Mon Sep 17 00:00:00 2001 From: prairiesnpr Date: Tue, 21 Jan 2025 20:27:58 -0500 Subject: [PATCH 2/6] Formatting --- zhaquirks/tuya/tuya_thermostat.py | 1 + 1 file changed, 1 insertion(+) diff --git a/zhaquirks/tuya/tuya_thermostat.py b/zhaquirks/tuya/tuya_thermostat.py index 43e6df39d8..a0c90b12f0 100644 --- a/zhaquirks/tuya/tuya_thermostat.py +++ b/zhaquirks/tuya/tuya_thermostat.py @@ -381,6 +381,7 @@ def __init__(self, *args, **kwargs): .add_to_registry() ) + ( base_avatto_quirk.clone() .applies_to("_TZE200_viy9ihs7", "TS0601") From 43360f871f6a3278c1821071e70702f79eb4f6b2 Mon Sep 17 00:00:00 2001 From: prairiesnpr Date: Wed, 22 Jan 2025 23:22:15 +0000 Subject: [PATCH 3/6] No manuf on set_time, no MCU version --- zhaquirks/tuya/tuya_thermostat.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/zhaquirks/tuya/tuya_thermostat.py b/zhaquirks/tuya/tuya_thermostat.py index a0c90b12f0..2516fc2c30 100644 --- a/zhaquirks/tuya/tuya_thermostat.py +++ b/zhaquirks/tuya/tuya_thermostat.py @@ -1,5 +1,7 @@ """Tuya TS0601 Thermostat.""" +import copy + from zigpy.quirks.v2 import BinarySensorDeviceClass, EntityType from zigpy.quirks.v2.homeassistant import ( UnitOfElectricCurrent, @@ -13,8 +15,9 @@ from zigpy.zcl import foundation from zigpy.zcl.clusters.hvac import Thermostat +from zhaquirks.tuya import TUYA_MCU_VERSION_RSP, TUYA_SET_TIME, TuyaTimePayload from zhaquirks.tuya.builder import TuyaQuirkBuilder -from zhaquirks.tuya.mcu import TuyaAttributesCluster +from zhaquirks.tuya.mcu import TuyaAttributesCluster, TuyaMCUCluster class RegulatorPeriod(t.enum8): @@ -114,6 +117,25 @@ def __init__(self, *args, **kwargs): self.add_unsupported_attribute(Thermostat.AttributeDefs.pi_heating_demand.id) +class NoManufTimeNoVersionRespTuyaMCUCluster(TuyaMCUCluster): + """Tuya Manufacturer Cluster with set_time mod.""" + + server_commands = copy.deepcopy(TuyaMCUCluster.server_commands) + server_commands.update( + { + TUYA_SET_TIME: foundation.ZCLCommandDef( + "set_time", + {"time": TuyaTimePayload}, + False, + is_manufacturer_specific=False, + ), + } + ) + + client_commands = copy.deepcopy(TuyaMCUCluster.client_commands) + client_commands.pop(TUYA_MCU_VERSION_RSP) + + ( TuyaQuirkBuilder("_TZE204_p3lqqy2r", "TS0601") .tuya_dp( @@ -378,7 +400,7 @@ def __init__(self, *args, **kwargs): translation_key="working_day", fallback_name="Working day", ) - .add_to_registry() + .add_to_registry(replacement_cluster=NoManufTimeNoVersionRespTuyaMCUCluster) ) @@ -399,7 +421,7 @@ def __init__(self, *args, **kwargs): translation_key="working_day", fallback_name="Working day", ) - .add_to_registry() + .add_to_registry(replacement_cluster=NoManufTimeNoVersionRespTuyaMCUCluster) ) @@ -421,5 +443,5 @@ def __init__(self, *args, **kwargs): translation_key="working_day", fallback_name="Working day", ) - .add_to_registry() + .add_to_registry(replacement_cluster=NoManufTimeNoVersionRespTuyaMCUCluster) ) From 3edb6e828ce50319f0fa6c43b4d55311aa4c1d3f Mon Sep 17 00:00:00 2001 From: prairiesnpr Date: Wed, 22 Jan 2025 23:54:19 +0000 Subject: [PATCH 4/6] Clean up no mcu resp --- zhaquirks/tuya/tuya_thermostat.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/zhaquirks/tuya/tuya_thermostat.py b/zhaquirks/tuya/tuya_thermostat.py index 2516fc2c30..2a0f0ca60c 100644 --- a/zhaquirks/tuya/tuya_thermostat.py +++ b/zhaquirks/tuya/tuya_thermostat.py @@ -15,7 +15,7 @@ from zigpy.zcl import foundation from zigpy.zcl.clusters.hvac import Thermostat -from zhaquirks.tuya import TUYA_MCU_VERSION_RSP, TUYA_SET_TIME, TuyaTimePayload +from zhaquirks.tuya import TUYA_SET_TIME, TuyaTimePayload from zhaquirks.tuya.builder import TuyaQuirkBuilder from zhaquirks.tuya.mcu import TuyaAttributesCluster, TuyaMCUCluster @@ -132,8 +132,12 @@ class NoManufTimeNoVersionRespTuyaMCUCluster(TuyaMCUCluster): } ) - client_commands = copy.deepcopy(TuyaMCUCluster.client_commands) - client_commands.pop(TUYA_MCU_VERSION_RSP) + def handle_mcu_version_response( + self, payload: TuyaMCUCluster.MCUVersion + ) -> foundation.Status: + """Handle MCU version response.""" + + return foundation.Status.SUCCESS ( From e8085c41d27db7c8405ac3af2e10c475bc6a3e0c Mon Sep 17 00:00:00 2001 From: prairiesnpr Date: Thu, 23 Jan 2025 21:46:50 +0000 Subject: [PATCH 5/6] Address review --- tests/test_tuya_thermostat.py | 36 ++++++++++++++++++++++++++++--- zhaquirks/tuya/tuya_thermostat.py | 2 +- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/tests/test_tuya_thermostat.py b/tests/test_tuya_thermostat.py index 4818ebfde8..725cddce8d 100644 --- a/tests/test_tuya_thermostat.py +++ b/tests/test_tuya_thermostat.py @@ -12,44 +12,74 @@ @pytest.mark.parametrize( - "msg,attr,value", + "manuf,msg,attr,value", [ ( + "_TZE204_p3lqqy2r", b"\t\x13\x02\x00\x06\x01\x01\x00\x01\x01", Thermostat.AttributeDefs.system_mode, Thermostat.SystemMode.Heat, ), # Set to heat, dp 1 ( + "_TZE204_p3lqqy2r", b"\t\x16\x02\x00\t\x18\x02\x00\x04\x00\x00\x00\x18", Thermostat.AttributeDefs.local_temperature, 2400, ), # Current temp 24, dp 24 ( + "_TZE204_p3lqqy2r", b"\t\x15\x02\x00\x08\x10\x02\x00\x04\x00\x00\x00\x19", Thermostat.AttributeDefs.occupied_heating_setpoint, 2500, ), # Setpoint to 25, dp 16 ( + "_TZE204_p3lqqy2r", b"\t\x17\x02\x00\n\x1c\x02\x00\x04\x00\x00\x00\x00", Thermostat.AttributeDefs.local_temperature_calibration, 0, ), # Local calibration to 0, dp 28 ( + "_TZE204_p3lqqy2r", b"\t\x1c\x02\x00\x0fh\x01\x00\x01\x01", Thermostat.AttributeDefs.running_state, Thermostat.RunningState.Heat_State_On, ), # Running state, dp 104 ( + "_TZE204_p3lqqy2r", b"\t\x1d\x02\x00\x10k\x02\x00\x04\x00\x00\x00\x1b", Thermostat.AttributeDefs.max_heat_setpoint_limit, 2700, ), # Max heat set point, dp 107 + ( + "_TZE204_lzriup1j", + b"\t\x13\x02\x00\x06\x01\x01\x00\x01\x01", + Thermostat.AttributeDefs.system_mode, + Thermostat.SystemMode.Heat, + ), # Set to heat, dp 1 + ( + "_TZE200_viy9ihs7", + b"\t\x13\x02\x00\x06\x01\x01\x00\x01\x01", + Thermostat.AttributeDefs.system_mode, + Thermostat.SystemMode.Heat, + ), # Set to heat, dp 1 + ( + "_TZE204_xnbkhhdr", + b"\t\x13\x02\x00\x06\x01\x01\x00\x01\x01", + Thermostat.AttributeDefs.system_mode, + Thermostat.SystemMode.Heat, + ), # Set to heat, dp 1 + ( + "_TZE284_xnbkhhdr", + b"\t\x13\x02\x00\x06\x01\x01\x00\x01\x01", + Thermostat.AttributeDefs.system_mode, + Thermostat.SystemMode.Heat, + ), # Set to heat, dp 1 ], ) -async def test_handle_get_data(zigpy_device_from_v2_quirk, msg, attr, value): +async def test_handle_get_data(zigpy_device_from_v2_quirk, manuf, msg, attr, value): """Test handle_get_data for multiple attributes.""" - quirked = zigpy_device_from_v2_quirk("_TZE204_p3lqqy2r", "TS0601") + quirked = zigpy_device_from_v2_quirk(manuf, "TS0601") ep = quirked.endpoints[1] assert ep.tuya_manufacturer is not None diff --git a/zhaquirks/tuya/tuya_thermostat.py b/zhaquirks/tuya/tuya_thermostat.py index 2a0f0ca60c..632c07923e 100644 --- a/zhaquirks/tuya/tuya_thermostat.py +++ b/zhaquirks/tuya/tuya_thermostat.py @@ -120,6 +120,7 @@ def __init__(self, *args, **kwargs): class NoManufTimeNoVersionRespTuyaMCUCluster(TuyaMCUCluster): """Tuya Manufacturer Cluster with set_time mod.""" + # Deepcopy required to override 'set_time', without, it will revert server_commands = copy.deepcopy(TuyaMCUCluster.server_commands) server_commands.update( { @@ -136,7 +137,6 @@ def handle_mcu_version_response( self, payload: TuyaMCUCluster.MCUVersion ) -> foundation.Status: """Handle MCU version response.""" - return foundation.Status.SUCCESS From 4c3030374d1923d31db9c93879f38eb43dbe059d Mon Sep 17 00:00:00 2001 From: prairiesnpr Date: Thu, 23 Jan 2025 21:58:25 +0000 Subject: [PATCH 6/6] test no mcu version --- tests/test_tuya_thermostat.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_tuya_thermostat.py b/tests/test_tuya_thermostat.py index 725cddce8d..220d8294fa 100644 --- a/tests/test_tuya_thermostat.py +++ b/tests/test_tuya_thermostat.py @@ -6,10 +6,14 @@ from tests.common import ClusterListener import zhaquirks +from zhaquirks.tuya import TUYA_MCU_VERSION_RSP from zhaquirks.tuya.mcu import TuyaMCUCluster zhaquirks.setup() +ZCL_TUYA_VERSION_RSP = b"\x09\x06\x11\x01\x6d\x82" +ZCL_TUYA_SET_TIME = b"\x09\x12\x24\x0d\x00" + @pytest.mark.parametrize( "manuf,msg,attr,value", @@ -99,3 +103,21 @@ async def test_handle_get_data(zigpy_device_from_v2_quirk, manuf, msg, attr, val assert thermostat_listener.attribute_updates[0][1] == value assert ep.thermostat.get(attr.id) == value + + +async def test_tuya_no_mcu_version(zigpy_device_from_v2_quirk): + """Test lack of TUYA_MCU_VERSION_RSP messages.""" + + tuya_device = zigpy_device_from_v2_quirk("_TZE284_xnbkhhdr", "TS0601") + + tuya_cluster = tuya_device.endpoints[1].tuya_manufacturer + cluster_listener = ClusterListener(tuya_cluster) + + assert len(cluster_listener.attribute_updates) == 0 + + # simulate a TUYA_MCU_VERSION_RSP message + hdr, args = tuya_cluster.deserialize(ZCL_TUYA_VERSION_RSP) + assert hdr.command_id == TUYA_MCU_VERSION_RSP + + tuya_cluster.handle_message(hdr, args) + assert len(cluster_listener.attribute_updates) == 0