From 27c7112f6da7cdef86d462bccc29037a7bd674ca Mon Sep 17 00:00:00 2001 From: Anton Golubev Date: Sun, 2 Nov 2025 17:33:49 +0100 Subject: [PATCH 1/6] Add Tuya MoesBHT6 thermostat _TZE204_u9bfwha0 Adds support for _TZE204_u9bfwha0 Moes BHT-006GBZB electric floor heating thermostat. This variant uses a single mode attribute instead of separate manual/schedule attributes and includes endpoint 242 with GreenPowerProxy support. --- zhaquirks/tuya/ts0601_electric_heating.py | 241 +++++++++++++++++++++- 1 file changed, 239 insertions(+), 2 deletions(-) diff --git a/zhaquirks/tuya/ts0601_electric_heating.py b/zhaquirks/tuya/ts0601_electric_heating.py index e8c56e26f6..02ce3605f7 100644 --- a/zhaquirks/tuya/ts0601_electric_heating.py +++ b/zhaquirks/tuya/ts0601_electric_heating.py @@ -1,10 +1,11 @@ """Map from manufacturer to standard clusters for electric heating thermostats.""" -from typing import Final +from typing import Final, Optional, Union from zigpy.profiles import zha import zigpy.types as t -from zigpy.zcl.clusters.general import Basic, Groups, Ota, Scenes, Time +from zigpy.zcl import foundation +from zigpy.zcl.clusters.general import Basic, Groups, GreenPowerProxy, Ota, Scenes, Time from zigpy.zcl.foundation import ZCLAttributeDef from zhaquirks.const import ( @@ -16,11 +17,15 @@ PROFILE_ID, ) from zhaquirks.tuya import ( + NoManufacturerCluster, + TuyaLocalCluster, TuyaManufClusterAttributes, TuyaThermostat, TuyaThermostatCluster, TuyaUserInterfaceCluster, + TUYA_MCU_COMMAND, ) +from zhaquirks.tuya.mcu import TuyaClusterData # info from https://github.com/zigpy/zha-device-handlers/pull/538#issuecomment-723334124 # https://github.com/Koenkk/zigbee-herdsman-converters/blob/master/converters/fromZigbee.js#L239 @@ -33,6 +38,15 @@ MOESBHT_RUNNING_MODE_ATTR = 0x0424 # [1] idle [0] heating /!\ inverted MOESBHT_CHILD_LOCK_ATTR = 0x0128 # [0] unlocked [1] child-locked +# MoesBHT6 attributes (variant with single mode attribute) +MOESBHT6_TARGET_TEMP_ATTR = 0x0210 # [0,0,0,21] target room temp (degree) +MOESBHT6_TEMPERATURE_ATTR = 0x0218 # [0,0,0,200] current room temp (decidegree) +MOESBHT6_MODE_ATTR = 0x0402 # [0] manual [1] scheduled +MOESBHT6_ENABLED_ATTR = 0x0101 # [0] off [1] on +MOESBHT6_RUNNING_MODE_ATTR = 0x0424 # [1] idle [0] heating +MOESBHT6_RUNNING_STATE_ATTR = 0x0424 # [1] idle [0] heating +MOESBHT6_CHILD_LOCK_ATTR = 0x0128 # [0] unlocked [1] child-locked + class MoesBHTManufCluster(TuyaManufClusterAttributes): """Manufacturer Specific Cluster of some electric heating thermostats.""" @@ -192,3 +206,226 @@ class MoesBHT(TuyaThermostat): } } } + + +class MoesBHT6ManufCluster(TuyaManufClusterAttributes, NoManufacturerCluster, TuyaLocalCluster): + """Manufacturer Specific Cluster for MoesBHT6 variant thermostats.""" + + class AttributeDefs(TuyaManufClusterAttributes.AttributeDefs): + """Attribute definitions.""" + + target_temperature: Final = ZCLAttributeDef( + id=MOESBHT6_TARGET_TEMP_ATTR, type=t.uint32_t, is_manufacturer_specific=True + ) + temperature: Final = ZCLAttributeDef( + id=MOESBHT6_TEMPERATURE_ATTR, type=t.uint32_t, is_manufacturer_specific=True + ) + system_mode: Final = ZCLAttributeDef( + id=MOESBHT6_MODE_ATTR, type=t.uint8_t, is_manufacturer_specific=True + ) + enabled: Final = ZCLAttributeDef( + id=MOESBHT6_ENABLED_ATTR, type=t.uint8_t, is_manufacturer_specific=True + ) + running_mode: Final = ZCLAttributeDef( + id=MOESBHT6_RUNNING_MODE_ATTR, type=t.uint8_t, is_manufacturer_specific=True + ) + running_state: Final = ZCLAttributeDef( + id=MOESBHT6_RUNNING_STATE_ATTR, type=t.uint8_t, is_manufacturer_specific=True + ) + child_lock: Final = ZCLAttributeDef( + id=MOESBHT6_CHILD_LOCK_ATTR, type=t.uint8_t, is_manufacturer_specific=True + ) + + async def command( + self, + command_id: Union[foundation.GeneralCommand, int, t.uint8_t], + *args, + manufacturer: Optional[Union[int, t.uint16_t]] = None, + expect_reply: bool = True, + tsn: Optional[Union[int, t.uint8_t]] = None, + ): + """Override the default Cluster command.""" + + self.debug( + "Sending Tuya Cluster Command. Cluster Command is %x, Arguments are %s", + command_id, + args, + ) + + # (off, on) + if command_id in (0x0000, 0x0001): + cluster_data = TuyaClusterData( + endpoint_id=self.endpoint.endpoint_id, + cluster_name=self.ep_attribute, + cluster_attr="enabled", + attr_value=command_id, + expect_reply=expect_reply, + manufacturer=-1, + ) + self.endpoint.device.command_bus.listener_event( + TUYA_MCU_COMMAND, + cluster_data, + ) + return foundation.GENERAL_COMMANDS[ + foundation.GeneralCommand.Default_Response + ].schema(command_id=command_id, status=foundation.Status.SUCCESS) + + def _update_attribute(self, attrid, value): + super()._update_attribute(attrid, value) + if attrid == MOESBHT6_TARGET_TEMP_ATTR: + self.endpoint.device.thermostat_bus.listener_event( + "temperature_change", + "occupied_heating_setpoint", + value * 100, # degree to centidegree + ) + elif attrid == MOESBHT6_TEMPERATURE_ATTR: + self.endpoint.device.thermostat_bus.listener_event( + "temperature_change", + "local_temperature", + value * 10, # decidegree to centidegree + ) + elif attrid == MOESBHT6_MODE_ATTR: + if value == 0: # manual + self.endpoint.device.thermostat_bus.listener_event( + "program_change", "manual" + ) + elif value == 1: # scheduled + self.endpoint.device.thermostat_bus.listener_event( + "program_change", "scheduled" + ) + elif attrid == MOESBHT6_ENABLED_ATTR: + self.endpoint.device.thermostat_bus.listener_event("enabled_change", value) + elif attrid == MOESBHT6_RUNNING_MODE_ATTR: + self.endpoint.device.thermostat_bus.listener_event("running_change", value) + elif attrid == MOESBHT6_RUNNING_STATE_ATTR: + self.endpoint.device.thermostat_bus.listener_event("running_change", value) + elif attrid == MOESBHT6_CHILD_LOCK_ATTR: + self.endpoint.device.ui_bus.listener_event("child_lock_change", value) + + +class MoesBHT6Thermostat(TuyaThermostatCluster): + """Thermostat cluster for MoesBHT6 variant electric heating controllers.""" + + def map_attribute(self, attribute, value): + """Map standardized attribute value to dict of manufacturer values.""" + + if attribute == "occupied_heating_setpoint": + # centidegree to degree + return {MOESBHT6_TARGET_TEMP_ATTR: round(value / 100)} + if attribute == "system_mode": + if value == self.SystemMode.Off: + return {MOESBHT6_ENABLED_ATTR: 0} + if value == self.SystemMode.Heat: + return {MOESBHT6_ENABLED_ATTR: 1} + self.error("Unsupported value for SystemMode") + elif attribute == "programing_oper_mode": + if value == self.ProgrammingOperationMode.Simple: + return {MOESBHT6_MODE_ATTR: 0} + if value == self.ProgrammingOperationMode.Schedule_programming_mode: + return {MOESBHT6_MODE_ATTR: 1} + self.error("Unsupported value for ProgrammingOperationMode") + elif attribute == "running_state": + if value == self.RunningState.Idle: + return {MOESBHT6_RUNNING_STATE_ATTR: 1} + if value == self.RunningState.Heat_State_On: + return {MOESBHT6_RUNNING_STATE_ATTR: 0} + self.error("Unsupported value for RunningState") + elif attribute == "running_mode": + if value == self.RunningMode.Off: + return {MOESBHT6_RUNNING_MODE_ATTR: 1} + if value == self.RunningMode.Heat: + return {MOESBHT6_RUNNING_MODE_ATTR: 0} + self.error("Unsupported value for RunningMode") + + return super().map_attribute(attribute, value) + + def program_change(self, mode): + """Programming mode change.""" + if mode == "manual": + value = self.ProgrammingOperationMode.Simple + else: + value = self.ProgrammingOperationMode.Schedule_programming_mode + + self._update_attribute( + self.attributes_by_name["programing_oper_mode"].id, value + ) + + def enabled_change(self, value): + """System mode change.""" + if value == 0: + mode = self.SystemMode.Off + else: + mode = self.SystemMode.Heat + self._update_attribute(self.attributes_by_name["system_mode"].id, mode) + + def running_change(self, value): + """Running state change.""" + if value == 0: + mode = self.RunningMode.Heat + state = self.RunningState.Heat_State_On + else: + mode = self.RunningMode.Off + state = self.RunningState.Idle + self._update_attribute(self.attributes_by_name["running_mode"].id, mode) + self._update_attribute(self.attributes_by_name["running_state"].id, state) + + +class MoesBHT6UserInterface(TuyaUserInterfaceCluster): + """HVAC User interface cluster for MoesBHT6 variant thermostats.""" + + _CHILD_LOCK_ATTR = MOESBHT6_CHILD_LOCK_ATTR + + +class MoesBHT6(TuyaThermostat): + """Tuya thermostat for devices like the Moes BHT-006GBZB Electric floor heating.""" + + signature = { + MODELS_INFO: [ + ("_TZE204_u9bfwha0", "TS0601"), + ], + ENDPOINTS: { + # endpoint=1 profile=260 device_type=81 device_version=1 input_clusters=[0, 4, 5, 61184], + # output_clusters=[10, 25] + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.SMART_PLUG, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + TuyaManufClusterAttributes.cluster_id, + ], + OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], + }, + 242: { + PROFILE_ID: 41440, + DEVICE_TYPE: 97, + INPUT_CLUSTERS: [], + OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id], + }, + }, + } + + replacement = { + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.THERMOSTAT, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + MoesBHT6ManufCluster, + MoesBHT6Thermostat, + MoesBHT6UserInterface, + ], + OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], + }, + 242: { + PROFILE_ID: 41440, + DEVICE_TYPE: 97, + INPUT_CLUSTERS: [], + OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id], + }, + } + } From 414cbdcd9f604cafc989610f37f2faae9b66d331 Mon Sep 17 00:00:00 2001 From: Anton Golubev Date: Sun, 2 Nov 2025 17:47:08 +0100 Subject: [PATCH 2/6] Fix pre-commit issues for MoesBHT6 thermostat Apply ruff formatting and fix linting issues: - Merge multiple attribute comparisons using tuple membership - Fix docstring to use imperative mood - Alphabetize imports --- zhaquirks/tuya/ts0601_electric_heating.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/zhaquirks/tuya/ts0601_electric_heating.py b/zhaquirks/tuya/ts0601_electric_heating.py index 02ce3605f7..fb92bd1e92 100644 --- a/zhaquirks/tuya/ts0601_electric_heating.py +++ b/zhaquirks/tuya/ts0601_electric_heating.py @@ -5,7 +5,7 @@ from zigpy.profiles import zha import zigpy.types as t from zigpy.zcl import foundation -from zigpy.zcl.clusters.general import Basic, Groups, GreenPowerProxy, Ota, Scenes, Time +from zigpy.zcl.clusters.general import Basic, GreenPowerProxy, Groups, Ota, Scenes, Time from zigpy.zcl.foundation import ZCLAttributeDef from zhaquirks.const import ( @@ -17,13 +17,13 @@ PROFILE_ID, ) from zhaquirks.tuya import ( + TUYA_MCU_COMMAND, NoManufacturerCluster, TuyaLocalCluster, TuyaManufClusterAttributes, TuyaThermostat, TuyaThermostatCluster, TuyaUserInterfaceCluster, - TUYA_MCU_COMMAND, ) from zhaquirks.tuya.mcu import TuyaClusterData @@ -208,7 +208,9 @@ class MoesBHT(TuyaThermostat): } -class MoesBHT6ManufCluster(TuyaManufClusterAttributes, NoManufacturerCluster, TuyaLocalCluster): +class MoesBHT6ManufCluster( + TuyaManufClusterAttributes, NoManufacturerCluster, TuyaLocalCluster +): """Manufacturer Specific Cluster for MoesBHT6 variant thermostats.""" class AttributeDefs(TuyaManufClusterAttributes.AttributeDefs): @@ -230,7 +232,9 @@ class AttributeDefs(TuyaManufClusterAttributes.AttributeDefs): id=MOESBHT6_RUNNING_MODE_ATTR, type=t.uint8_t, is_manufacturer_specific=True ) running_state: Final = ZCLAttributeDef( - id=MOESBHT6_RUNNING_STATE_ATTR, type=t.uint8_t, is_manufacturer_specific=True + id=MOESBHT6_RUNNING_STATE_ATTR, + type=t.uint8_t, + is_manufacturer_specific=True, ) child_lock: Final = ZCLAttributeDef( id=MOESBHT6_CHILD_LOCK_ATTR, type=t.uint8_t, is_manufacturer_specific=True @@ -295,9 +299,7 @@ def _update_attribute(self, attrid, value): ) elif attrid == MOESBHT6_ENABLED_ATTR: self.endpoint.device.thermostat_bus.listener_event("enabled_change", value) - elif attrid == MOESBHT6_RUNNING_MODE_ATTR: - self.endpoint.device.thermostat_bus.listener_event("running_change", value) - elif attrid == MOESBHT6_RUNNING_STATE_ATTR: + elif attrid in (MOESBHT6_RUNNING_MODE_ATTR, MOESBHT6_RUNNING_STATE_ATTR): self.endpoint.device.thermostat_bus.listener_event("running_change", value) elif attrid == MOESBHT6_CHILD_LOCK_ATTR: self.endpoint.device.ui_bus.listener_event("child_lock_change", value) @@ -359,7 +361,7 @@ def enabled_change(self, value): self._update_attribute(self.attributes_by_name["system_mode"].id, mode) def running_change(self, value): - """Running state change.""" + """Change running state.""" if value == 0: mode = self.RunningMode.Heat state = self.RunningState.Heat_State_On From ed66748d2e3c260d14d3dcafcd5abd82c4abdd28 Mon Sep 17 00:00:00 2001 From: Anton Golubev Date: Sun, 2 Nov 2025 17:58:02 +0100 Subject: [PATCH 3/6] Add comprehensive unit tests for MoesBHT6 thermostat Add test coverage for the new MoesBHT6 quirk including: - Temperature and target temperature reporting - Mode reporting (manual/scheduled) - System mode reporting (on/off) - Running state reporting (heating/idle) - Attribute writing (setpoint, system mode, programming mode) These tests follow the same pattern as existing MoesBHT tests and should significantly improve code coverage for the new quirk. --- tests/test_tuya.py | 258 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) diff --git a/tests/test_tuya.py b/tests/test_tuya.py index d0498a290c..aaa44a0d36 100644 --- a/tests/test_tuya.py +++ b/tests/test_tuya.py @@ -2023,3 +2023,261 @@ async def test_ts601_door_sensor( attrs = await cluster.read_attributes(attributes=[attribute]) assert attrs[0].get(attribute) == expected_value + + +# Test frames for MoesBHT6 +ZCL_TUYA_EHEAT6_TEMPERATURE = b"\tp\x02\x00\x02\x18\x02\x00\x04\x00\x00\x00\xb3" # Current temp 17.9°C (0xb3 = 179 decidegree) +ZCL_TUYA_EHEAT6_TARGET_TEMP = b"\t3\x01\x03\x05\x10\x02\x00\x04\x00\x00\x00\x15" # Target temp 21°C (0x15 = 21 degree) +ZCL_TUYA_EHEAT6_MODE_MANUAL = ( + b"\t4\x02\x00\x06\x02\x04\x00\x01\x00" # Manual mode (0x02 = 0x0402, value = 0) +) +ZCL_TUYA_EHEAT6_MODE_SCHEDULED = ( + b"\t5\x02\x00\x06\x02\x04\x00\x01\x01" # Scheduled mode (0x02 = 0x0402, value = 1) +) +ZCL_TUYA_EHEAT6_ENABLED_ON = b"\t6\x02\x00\x06\x01\x01\x00\x01\x01" # Enabled (ON) +ZCL_TUYA_EHEAT6_ENABLED_OFF = b"\t7\x02\x00\x06\x01\x01\x00\x01\x00" # Disabled (OFF) +ZCL_TUYA_EHEAT6_RUNNING_HEAT = ( + b"\t8\x02\x00\x06\x24\x04\x00\x01\x00" # Running/Heating (value = 0) +) +ZCL_TUYA_EHEAT6_RUNNING_IDLE = ( + b"\t9\x02\x00\x06\x24\x04\x00\x01\x01" # Idle (value = 1) +) +ZCL_TUYA_EHEAT6_CHILD_LOCK_ON = ( + b"\t\x0a\x02\x00\x06\x28\x01\x00\x01\x01" # Child lock ON +) +ZCL_TUYA_EHEAT6_CHILD_LOCK_OFF = ( + b"\t\x0b\x02\x00\x06\x28\x01\x00\x01\x00" # Child lock OFF +) + + +@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_electric_heating.MoesBHT6,)) +async def test_moesbht6_state_report(zigpy_device_from_quirk, quirk): + """Test MoesBHT6 thermostat standard reporting from incoming commands.""" + + electric_dev = zigpy_device_from_quirk(quirk) + tuya_cluster = electric_dev.endpoints[1].tuya_manufacturer + + thermostat_listener = ClusterListener(electric_dev.endpoints[1].thermostat) + + # Test temperature and target temperature reporting + frames = (ZCL_TUYA_EHEAT6_TEMPERATURE, ZCL_TUYA_EHEAT6_TARGET_TEMP) + for frame in frames: + hdr, args = tuya_cluster.deserialize(frame) + tuya_cluster.handle_message(hdr, args) + + assert len(thermostat_listener.cluster_commands) == 0 + assert len(thermostat_listener.attribute_updates) == 2 + assert thermostat_listener.attribute_updates[0][0] == 0x0000 # local_temperature + assert thermostat_listener.attribute_updates[0][1] == 1790 # 17.9°C in centidegrees + assert ( + thermostat_listener.attribute_updates[1][0] == 0x0012 + ) # occupied_heating_setpoint + assert thermostat_listener.attribute_updates[1][1] == 2100 # 21°C in centidegrees + + +@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_electric_heating.MoesBHT6,)) +async def test_moesbht6_mode_report(zigpy_device_from_quirk, quirk): + """Test MoesBHT6 mode reporting (manual/scheduled).""" + + electric_dev = zigpy_device_from_quirk(quirk) + tuya_cluster = electric_dev.endpoints[1].tuya_manufacturer + thermostat_listener = ClusterListener(electric_dev.endpoints[1].thermostat) + + # Test manual mode + hdr, args = tuya_cluster.deserialize(ZCL_TUYA_EHEAT6_MODE_MANUAL) + tuya_cluster.handle_message(hdr, args) + + assert len(thermostat_listener.attribute_updates) == 1 + assert thermostat_listener.attribute_updates[0][0] == 0x0025 # programing_oper_mode + assert thermostat_listener.attribute_updates[0][1] == 0x00 # Simple (manual) + + thermostat_listener.attribute_updates.clear() + + # Test scheduled mode + hdr, args = tuya_cluster.deserialize(ZCL_TUYA_EHEAT6_MODE_SCHEDULED) + tuya_cluster.handle_message(hdr, args) + + assert len(thermostat_listener.attribute_updates) == 1 + assert thermostat_listener.attribute_updates[0][0] == 0x0025 # programing_oper_mode + assert ( + thermostat_listener.attribute_updates[0][1] == 0x01 + ) # Schedule_programming_mode + + +@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_electric_heating.MoesBHT6,)) +async def test_moesbht6_system_mode_report(zigpy_device_from_quirk, quirk): + """Test MoesBHT6 system mode reporting (on/off).""" + + electric_dev = zigpy_device_from_quirk(quirk) + tuya_cluster = electric_dev.endpoints[1].tuya_manufacturer + thermostat_listener = ClusterListener(electric_dev.endpoints[1].thermostat) + + # Test enabled (Heat mode) + hdr, args = tuya_cluster.deserialize(ZCL_TUYA_EHEAT6_ENABLED_ON) + tuya_cluster.handle_message(hdr, args) + + assert len(thermostat_listener.attribute_updates) == 1 + assert thermostat_listener.attribute_updates[0][0] == 0x001C # system_mode + assert thermostat_listener.attribute_updates[0][1] == 0x04 # Heat + + thermostat_listener.attribute_updates.clear() + + # Test disabled (Off mode) + hdr, args = tuya_cluster.deserialize(ZCL_TUYA_EHEAT6_ENABLED_OFF) + tuya_cluster.handle_message(hdr, args) + + assert len(thermostat_listener.attribute_updates) == 1 + assert thermostat_listener.attribute_updates[0][0] == 0x001C # system_mode + assert thermostat_listener.attribute_updates[0][1] == 0x00 # Off + + +@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_electric_heating.MoesBHT6,)) +async def test_moesbht6_running_state_report(zigpy_device_from_quirk, quirk): + """Test MoesBHT6 running state reporting.""" + + electric_dev = zigpy_device_from_quirk(quirk) + tuya_cluster = electric_dev.endpoints[1].tuya_manufacturer + thermostat_listener = ClusterListener(electric_dev.endpoints[1].thermostat) + + # Test heating state + hdr, args = tuya_cluster.deserialize(ZCL_TUYA_EHEAT6_RUNNING_HEAT) + tuya_cluster.handle_message(hdr, args) + + assert len(thermostat_listener.attribute_updates) == 2 + # Should update both running_mode and running_state + assert thermostat_listener.attribute_updates[0][0] == 0x001E # running_mode + assert thermostat_listener.attribute_updates[0][1] == 0x04 # Heat + assert thermostat_listener.attribute_updates[1][0] == 0x0029 # running_state + assert thermostat_listener.attribute_updates[1][1] == 0x0001 # Heat_State_On + + thermostat_listener.attribute_updates.clear() + + # Test idle state + hdr, args = tuya_cluster.deserialize(ZCL_TUYA_EHEAT6_RUNNING_IDLE) + tuya_cluster.handle_message(hdr, args) + + assert len(thermostat_listener.attribute_updates) == 2 + assert thermostat_listener.attribute_updates[0][0] == 0x001E # running_mode + assert thermostat_listener.attribute_updates[0][1] == 0x00 # Off + assert thermostat_listener.attribute_updates[1][0] == 0x0029 # running_state + assert thermostat_listener.attribute_updates[1][1] == 0x0000 # Idle + + +@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_electric_heating.MoesBHT6,)) +async def test_moesbht6_send_attribute(zigpy_device_from_quirk, quirk): + """Test MoesBHT6 thermostat outgoing commands.""" + + eheat_dev = zigpy_device_from_quirk(quirk) + tuya_cluster = eheat_dev.endpoints[1].tuya_manufacturer + thermostat_cluster = eheat_dev.endpoints[1].thermostat + + async def async_success(*args, **kwargs): + return foundation.Status.SUCCESS + + with mock.patch.object( + tuya_cluster.endpoint, "request", side_effect=async_success + ) as m1: + # Test setting target temperature (25°C = 2500 centidegrees = 25 degrees) + (status,) = await thermostat_cluster.write_attributes( + { + "occupied_heating_setpoint": 2500, + } + ) + m1.assert_called_with( + cluster=0xEF00, + sequence=1, + data=b"\x01\x01\x00\x00\x01\x10\x02\x00\x04\x00\x00\x00\x19", + command_id=0, + timeout=5, + expect_reply=False, + use_ieee=False, + ask_for_ack=None, + priority=None, + ) + assert status == [ + foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS) + ] + + # Test setting system mode to Off + (status,) = await thermostat_cluster.write_attributes( + { + "system_mode": 0x00, + } + ) + m1.assert_called_with( + cluster=0xEF00, + sequence=2, + data=b"\x01\x02\x00\x00\x02\x01\x01\x00\x01\x00", + command_id=0, + timeout=5, + expect_reply=False, + use_ieee=False, + ask_for_ack=None, + priority=None, + ) + assert status == [ + foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS) + ] + + # Test setting system mode to Heat + (status,) = await thermostat_cluster.write_attributes( + { + "system_mode": 0x04, + } + ) + m1.assert_called_with( + cluster=0xEF00, + sequence=3, + data=b"\x01\x03\x00\x00\x03\x01\x01\x00\x01\x01", + command_id=0, + timeout=5, + expect_reply=False, + use_ieee=False, + ask_for_ack=None, + priority=None, + ) + assert status == [ + foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS) + ] + + # Test setting programming operation mode to manual + (status,) = await thermostat_cluster.write_attributes( + { + "programing_oper_mode": 0x00, # Simple (manual) + } + ) + m1.assert_called_with( + cluster=0xEF00, + sequence=4, + data=b"\x01\x04\x00\x00\x04\x02\x04\x00\x01\x00", + command_id=0, + timeout=5, + expect_reply=False, + use_ieee=False, + ask_for_ack=None, + priority=None, + ) + assert status == [ + foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS) + ] + + # Test setting programming operation mode to scheduled + (status,) = await thermostat_cluster.write_attributes( + { + "programing_oper_mode": 0x01, # Schedule_programming_mode + } + ) + m1.assert_called_with( + cluster=0xEF00, + sequence=5, + data=b"\x01\x05\x00\x00\x05\x02\x04\x00\x01\x01", + command_id=0, + timeout=5, + expect_reply=False, + use_ieee=False, + ask_for_ack=None, + priority=None, + ) + assert status == [ + foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS) + ] From 33ee764f3d69f007205cff380a71765042be0d07 Mon Sep 17 00:00:00 2001 From: Anton Golubev Date: Sun, 2 Nov 2025 18:01:49 +0100 Subject: [PATCH 4/6] Add test for MoesBHT6 command handling Add test coverage for the custom command() method in MoesBHT6ManufCluster that handles on/off commands. This improves coverage from 70% to 73%. --- tests/test_tuya.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_tuya.py b/tests/test_tuya.py index aaa44a0d36..db885197e0 100644 --- a/tests/test_tuya.py +++ b/tests/test_tuya.py @@ -2281,3 +2281,21 @@ async def async_success(*args, **kwargs): assert status == [ foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS) ] + + +@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_electric_heating.MoesBHT6,)) +async def test_moesbht6_command_on_off(zigpy_device_from_quirk, quirk): + """Test MoesBHT6 on/off command handling via ManufCluster.""" + + eheat_dev = zigpy_device_from_quirk(quirk) + tuya_manuf_cluster = eheat_dev.endpoints[1].tuya_manufacturer + + # Test on command (0x0001) + result = await tuya_manuf_cluster.command(0x0001) + assert result.command_id == 0x0001 + assert result.status == foundation.Status.SUCCESS + + # Test off command (0x0000) + result = await tuya_manuf_cluster.command(0x0000) + assert result.command_id == 0x0000 + assert result.status == foundation.Status.SUCCESS From 96c2d8e7e18eb475da378d7fc6ec20ec5c79834b Mon Sep 17 00:00:00 2001 From: Anton Golubev Date: Sun, 2 Nov 2025 18:12:19 +0100 Subject: [PATCH 5/6] Add comprehensive error handling and edge case tests for MoesBHT6 Adds 6 new test functions to improve coverage from 73% to 82%: - Setpoint rounding with various boundary values (half-steps, extremes) - Error handling for unsupported system modes (Cool, Auto) - Error handling for unsupported programming modes - Error handling for unsupported running states (Cool_State_On) - Error handling for unsupported running modes (Cool) - Program change with various mode strings All error branches now exercised and verified to log errors correctly. --- tests/test_tuya.py | 197 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) diff --git a/tests/test_tuya.py b/tests/test_tuya.py index db885197e0..e2103d82b9 100644 --- a/tests/test_tuya.py +++ b/tests/test_tuya.py @@ -2299,3 +2299,200 @@ async def test_moesbht6_command_on_off(zigpy_device_from_quirk, quirk): result = await tuya_manuf_cluster.command(0x0000) assert result.command_id == 0x0000 assert result.status == foundation.Status.SUCCESS + + +@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_electric_heating.MoesBHT6,)) +async def test_moesbht6_setpoint_rounding(zigpy_device_from_quirk, quirk): + """Test MoesBHT6 setpoint rounding and boundary behavior.""" + + eheat_dev = zigpy_device_from_quirk(quirk) + thermostat_cluster = eheat_dev.endpoints[1].thermostat + + # Test various setpoint values and rounding + test_cases = [ + (1500, 15), # 15.00°C → 15°C + (1550, 16), # 15.50°C → 16°C (rounds up) + (2050, 20), # 20.50°C → 20°C (rounds to even) + (2150, 22), # 21.50°C → 22°C (rounds to even) + (2549, 25), # 25.49°C → 25°C (rounds down) + (2551, 26), # 25.51°C → 26°C (rounds up) + (500, 5), # 5.00°C → 5°C + (3500, 35), # 35.00°C → 35°C + ] + + for input_val, expected in test_cases: + result = thermostat_cluster.map_attribute( + "occupied_heating_setpoint", input_val + ) + assert result == {0x0210: expected}, ( + f"Input {input_val} should map to {expected}" + ) + + +@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_electric_heating.MoesBHT6,)) +async def test_moesbht6_unsupported_system_mode(zigpy_device_from_quirk, quirk, caplog): + """Test MoesBHT6 error handling for unsupported system modes.""" + + eheat_dev = zigpy_device_from_quirk(quirk) + thermostat_cluster = eheat_dev.endpoints[1].thermostat + + # Test supported modes first + result = thermostat_cluster.map_attribute( + "system_mode", thermostat_cluster.SystemMode.Off + ) + assert result == {0x0101: 0} + + result = thermostat_cluster.map_attribute( + "system_mode", thermostat_cluster.SystemMode.Heat + ) + assert result == {0x0101: 1} + + # Test unsupported modes - these should call self.error() and fall through to super() + import logging + + with caplog.at_level(logging.ERROR): + result = thermostat_cluster.map_attribute( + "system_mode", thermostat_cluster.SystemMode.Cool + ) + # self.error() logs but doesn't prevent super().map_attribute() from being called + # super() returns {} for unsupported attributes + assert result == {} + assert "Unsupported value for SystemMode" in caplog.text + + caplog.clear() + with caplog.at_level(logging.ERROR): + result = thermostat_cluster.map_attribute( + "system_mode", thermostat_cluster.SystemMode.Auto + ) + assert result == {} + assert "Unsupported value for SystemMode" in caplog.text + + +@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_electric_heating.MoesBHT6,)) +async def test_moesbht6_unsupported_programming_mode( + zigpy_device_from_quirk, quirk, caplog +): + """Test MoesBHT6 error handling for unsupported programming modes.""" + + eheat_dev = zigpy_device_from_quirk(quirk) + thermostat_cluster = eheat_dev.endpoints[1].thermostat + + # Test supported modes first + result = thermostat_cluster.map_attribute( + "programing_oper_mode", thermostat_cluster.ProgrammingOperationMode.Simple + ) + assert result == {0x0402: 0} + + result = thermostat_cluster.map_attribute( + "programing_oper_mode", + thermostat_cluster.ProgrammingOperationMode.Schedule_programming_mode, + ) + assert result == {0x0402: 1} + + # Test unsupported mode + import logging + + with caplog.at_level(logging.ERROR): + # Try an unsupported programming mode if available + # ProgrammingOperationMode only has Simple (0) and Schedule_programming_mode (1) + # Test with an invalid value + result = thermostat_cluster.map_attribute("programing_oper_mode", 0xFF) + assert result == {} + assert "Unsupported value for ProgrammingOperationMode" in caplog.text + + +@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_electric_heating.MoesBHT6,)) +async def test_moesbht6_unsupported_running_state( + zigpy_device_from_quirk, quirk, caplog +): + """Test MoesBHT6 error handling for unsupported running states.""" + + eheat_dev = zigpy_device_from_quirk(quirk) + thermostat_cluster = eheat_dev.endpoints[1].thermostat + + # Test supported states first + result = thermostat_cluster.map_attribute( + "running_state", thermostat_cluster.RunningState.Idle + ) + assert result == {0x0424: 1} + + result = thermostat_cluster.map_attribute( + "running_state", thermostat_cluster.RunningState.Heat_State_On + ) + assert result == {0x0424: 0} + + # Test unsupported state + import logging + + with caplog.at_level(logging.ERROR): + result = thermostat_cluster.map_attribute( + "running_state", thermostat_cluster.RunningState.Cool_State_On + ) + assert result == {} + assert "Unsupported value for RunningState" in caplog.text + + +@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_electric_heating.MoesBHT6,)) +async def test_moesbht6_unsupported_running_mode( + zigpy_device_from_quirk, quirk, caplog +): + """Test MoesBHT6 error handling for unsupported running modes.""" + + eheat_dev = zigpy_device_from_quirk(quirk) + thermostat_cluster = eheat_dev.endpoints[1].thermostat + + # Test supported modes first + result = thermostat_cluster.map_attribute( + "running_mode", thermostat_cluster.RunningMode.Off + ) + assert result == {0x0424: 1} + + result = thermostat_cluster.map_attribute( + "running_mode", thermostat_cluster.RunningMode.Heat + ) + assert result == {0x0424: 0} + + # Test unsupported mode + import logging + + with caplog.at_level(logging.ERROR): + result = thermostat_cluster.map_attribute( + "running_mode", thermostat_cluster.RunningMode.Cool + ) + assert result == {} + assert "Unsupported value for RunningMode" in caplog.text + + +@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_electric_heating.MoesBHT6,)) +async def test_moesbht6_program_change_modes(zigpy_device_from_quirk, quirk): + """Test MoesBHT6 program_change with various mode strings.""" + + eheat_dev = zigpy_device_from_quirk(quirk) + thermostat_cluster = eheat_dev.endpoints[1].thermostat + + # Test manual mode + thermostat_cluster.program_change("manual") + assert ( + thermostat_cluster.get(0x0025) + == thermostat_cluster.ProgrammingOperationMode.Simple + ) + + # Test scheduled mode (any non-"manual" string) + thermostat_cluster.program_change("scheduled") + assert ( + thermostat_cluster.get(0x0025) + == thermostat_cluster.ProgrammingOperationMode.Schedule_programming_mode + ) + + # Test other strings also map to scheduled + thermostat_cluster.program_change("schedule") + assert ( + thermostat_cluster.get(0x0025) + == thermostat_cluster.ProgrammingOperationMode.Schedule_programming_mode + ) + + thermostat_cluster.program_change("auto") + assert ( + thermostat_cluster.get(0x0025) + == thermostat_cluster.ProgrammingOperationMode.Schedule_programming_mode + ) From 2c8fef571a8889c774a543e2736254283ca75532 Mon Sep 17 00:00:00 2001 From: Anton Golubev Date: Sun, 2 Nov 2025 18:13:21 +0100 Subject: [PATCH 6/6] Add child lock test for MoesBHT6 Test child lock on/off reporting through UI cluster to improve coverage to 83%. Verifies child_lock attribute updates correctly. --- tests/test_tuya.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_tuya.py b/tests/test_tuya.py index e2103d82b9..042f949f39 100644 --- a/tests/test_tuya.py +++ b/tests/test_tuya.py @@ -2496,3 +2496,34 @@ async def test_moesbht6_program_change_modes(zigpy_device_from_quirk, quirk): thermostat_cluster.get(0x0025) == thermostat_cluster.ProgrammingOperationMode.Schedule_programming_mode ) + + +@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_electric_heating.MoesBHT6,)) +async def test_moesbht6_child_lock_report(zigpy_device_from_quirk, quirk): + """Test MoesBHT6 child lock reporting.""" + + electric_dev = zigpy_device_from_quirk(quirk) + tuya_cluster = electric_dev.endpoints[1].tuya_manufacturer + ui_cluster = electric_dev.endpoints[1].thermostat_ui + + from tests.common import ClusterListener + + ui_listener = ClusterListener(ui_cluster) + + # Test child lock on + hdr, args = tuya_cluster.deserialize(ZCL_TUYA_EHEAT6_CHILD_LOCK_ON) + tuya_cluster.handle_message(hdr, args) + + assert len(ui_listener.attribute_updates) == 1 + assert ui_listener.attribute_updates[0][0] == 0x0001 # child_lock attribute + assert ui_listener.attribute_updates[0][1] == 1 # Locked + + ui_listener.attribute_updates.clear() + + # Test child lock off + hdr, args = tuya_cluster.deserialize(ZCL_TUYA_EHEAT6_CHILD_LOCK_OFF) + tuya_cluster.handle_message(hdr, args) + + assert len(ui_listener.attribute_updates) == 1 + assert ui_listener.attribute_updates[0][0] == 0x0001 # child_lock attribute + assert ui_listener.attribute_updates[0][1] == 0 # Unlocked