Repository: zigpy/zha-device-handlers

Issue:
[BUG] Tuya/Saswell TRV (TS601/_TZE200_c88teujp) stuck in schedule mode
**Describe the bug**
Some Tuya TRV's, Sasswell and it clones in particular, like to enable schedule mode in unexpected conditions.

I have 6 TRV's of this type and I have used them with Tuya app ("Smart Life") then with Zigbee2MQTT and now with ZHA. After the migration to ZHA my automations stopped working because my set-points were constantly overridden by values from a schedule that I have previously set in Tuya app. I've tried pairing with Tuya GW again and disabling the schedule but devices seem to enable the schedule by default upon pairing with ZHA and/or possibly after switching from "off" to "heat" mode.

This renders TRV's that have been previously configured with a schedule uncontrollable via ZHA.

I do have a working PoC of a quick-fix for this issue and I'll try to publish a PR with it.

**To Reproduce**
Steps to reproduce the behavior:
1. Pair TRV with Tuya GW using Tuya app
2. Program any schedule using Tuya app
3. Pair TRV with ZHA
4. Get frustrated because values programmed in the schedule keep overriding your desired set-points

**Expected behavior**
I expect TRV to keep the requested set-point,


<details>
<!-- Device signature can be acquired by clicking on the "Zigbee Device Signature" button in the device settings view -->
<summary>Device signature</summary>

```yaml
{
  "node_descriptor": "NodeDescriptor(logical_type=<LogicalType.EndDevice: 2>, complex_descriptor_available=0, user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=<FrequencyBand.Freq2400MHz: 8>, mac_capability_flags=<MACCapabilityFlags.AllocateAddress: 128>, manufacturer_code=4098, maximum_buffer_size=82, maximum_incoming_transfer_size=82, server_mask=11264, maximum_outgoing_transfer_size=82, descriptor_capability_field=<DescriptorCapability.NONE: 0>, *allocate_address=True, *is_alternate_pan_coordinator=False, *is_coordinator=False, *is_end_device=True, *is_full_function_device=False, *is_mains_powered=False, *is_receiver_on_when_idle=False, *is_router=False, *is_security_capable=False)",
  "endpoints": {
    "1": {
      "profile_id": 260,
      "device_type": "0x0301",
      "in_clusters": [
        "0x0000",
        "0x0001",
        "0x0004",
        "0x0005",
        "0x0201",
        "0xef00"
      ],
      "out_clusters": [
        "0x0001",
        "0x000a",
        "0x0019",
        "0x0201",
        "0xef00"
      ]
    }
  },
  "manufacturer": "_TZE200_c88teujp",
  "model": "TS0601",
  "class": "zhaquirks.tuya.ts0601_trv_sas.Thermostat_TZE200_c88teujp"
}
```

</details>

**Additional context**

This issue was already noticed and worked-around by Zigbee2MQTT developers, see code highlighted below:

https://github.com/Koenkk/zigbee-herdsman-converters/blob/master/converters/toZigbee.js#L5877:L5879


Discussion:

Resolving patch:
diff --git a/zhaquirks/tuya/ts0601_trv_sas.py b/zhaquirks/tuya/ts0601_trv_sas.py
--- a/zhaquirks/tuya/ts0601_trv_sas.py
+++ b/zhaquirks/tuya/ts0601_trv_sas.py
@@ -4,18 +4,9 @@
 
 from zigpy.profiles import zha
 import zigpy.types as t
-from zigpy.zcl.clusters.general import (
-    Basic,
-    Groups,
-    Identify,
-    Ota,
-    PowerConfiguration,
-    Scenes,
-    Time,
-)
+from zigpy.zcl.clusters.general import Basic, Groups, Identify, Ota, Scenes, Time
 from zigpy.zcl.clusters.hvac import Thermostat
 
-from zhaquirks import LocalDataCluster
 from zhaquirks.const import (
     DEVICE_TYPE,
     ENDPOINTS,
@@ -26,21 +17,18 @@
 )
 from zhaquirks.tuya import (
     TuyaManufClusterAttributes,
+    TuyaPowerConfigurationCluster,
     TuyaThermostat,
     TuyaThermostatCluster,
 )
 
 _LOGGER = logging.getLogger(__name__)
 
-OCCUPIED_HEATING_SETPOINT_REPORTED = "occupied_heating_setpoint_reported"
-SYSTEM_MODE_REPORTED = "system_mode_reported"
-LOCAL_TEMP_REPORTED = "local_temp_reported"
-BATTERY_REPORTED = "battery_reported"
-
-OCCUPIED_HEATING_SETPOINT_COMMAND_ID = 615
-SYSTEM_MODE_COMMAND_ID = 357
-LOCAL_TEMP_COMMAND_ID = 614
-BATTERY_STATE_COMMAND_ID = 1385
+SASSWEL_HEATING_SETPOINT_ATTR = 0x0267
+SASSWEL_STATE_ATTR = 0x0165
+SASSWEL_LOCAL_TEMP_ATTR = 0x0266
+SASSWEL_BATTERY_LOW_ATTR = 0x0569
+SASSWEL_SCHEDULE_ENABLE_ATTR = 0x016C
 
 CTRL_SEQ_OF_OPER_ATTR = 0x001B
 
@@ -49,63 +37,62 @@
 
 
 class ManufacturerThermostatCluster(TuyaManufClusterAttributes):
-    """Manufacturer thermostat cluster."""
+    """Tuya manufacturer specific cluster."""
+
+    class State(t.enum8):
+        """State option."""
+
+        Off = 0x00
+        On = 0x01
+
+    class BatteryState(t.enum8):
+        """Battery state option."""
+
+        Normal = 0x00
+        Low = 0x01
+
+    class ScheduleState(t.enum8):
+        """Schedule state option."""
+
+        Disabled = 0x00
+        Enabled = 0x01
 
     attributes = TuyaManufClusterAttributes.attributes.copy()
     attributes.update(
         {
-            SYSTEM_MODE_COMMAND_ID: ("system_mode", t.uint8_t, True),
-            OCCUPIED_HEATING_SETPOINT_COMMAND_ID: (
-                "occupied_heating_setpoint",
-                t.uint32_t,
-                True,
-            ),
-            LOCAL_TEMP_COMMAND_ID: ("local_temperature", t.uint32_t, True),
-            BATTERY_STATE_COMMAND_ID: ("battery_state", t.uint8_t, True),
+            SASSWEL_STATE_ATTR: ("state", State, True),
+            SASSWEL_HEATING_SETPOINT_ATTR: ("heating_setpoint", t.uint32_t, True),
+            SASSWEL_LOCAL_TEMP_ATTR: ("local_temperature", t.uint32_t, True),
+            SASSWEL_BATTERY_LOW_ATTR: ("battery_state", BatteryState, True),
+            SASSWEL_SCHEDULE_ENABLE_ATTR: ("schedule_enabled", ScheduleState, True),
         }
     )
 
+    TEMPERATURE_ATTRS = {
+        SASSWEL_HEATING_SETPOINT_ATTR: "occupied_heating_setpoint",
+        SASSWEL_LOCAL_TEMP_ATTR: "local_temperature",
+    }
+
     def _update_attribute(self, attrid, value):
         super()._update_attribute(attrid, value)
-        if attrid == OCCUPIED_HEATING_SETPOINT_COMMAND_ID:
+        if attrid in self.TEMPERATURE_ATTRS:
             self.endpoint.device.thermostat_bus.listener_event(
-                OCCUPIED_HEATING_SETPOINT_REPORTED, value
+                "temperature_change",
+                self.TEMPERATURE_ATTRS[attrid],
+                value * 10,
             )
-        elif attrid == SYSTEM_MODE_COMMAND_ID:
+        elif attrid == SASSWEL_STATE_ATTR:
             self.endpoint.device.thermostat_bus.listener_event(
-                SYSTEM_MODE_REPORTED, value
+                "system_mode_reported", value
             )
-        elif attrid == BATTERY_STATE_COMMAND_ID:
-            self.endpoint.device.battery_bus.listener_event(BATTERY_REPORTED, value)
-        elif attrid == LOCAL_TEMP_COMMAND_ID:
-            self.endpoint.device.thermostat_bus.listener_event(
-                LOCAL_TEMP_REPORTED, value
+        elif attrid == SASSWEL_BATTERY_LOW_ATTR:
+            # this device doesn't have battery level reporting, only battery low alert
+            # when the alert is active (1) we report 0% and 100% otherwise (0)
+            self.endpoint.device.battery_bus.listener_event(
+                "battery_change", 0 if value == 1 else 100
             )
 
 
-class PowerConfigurationCluster(LocalDataCluster, PowerConfiguration):
-    """Power configuration cluster."""
-
-    cluster_id = PowerConfiguration.cluster_id
-
-    def __init__(self, *args, **kwargs):
-        """Init."""
-        super().__init__(*args, **kwargs)
-        self.endpoint.device.battery_bus.add_listener(self)
-
-    def battery_reported(self, value):
-        """Handle reported battery state."""
-        _LOGGER.debug("reported battery alert: %d", value)
-        if value == 1:  # alert
-            self._update_attribute(
-                self.attributes_by_name["battery_percentage_remaining"].id, 0
-            )  # report 0% battery
-        else:
-            self._update_attribute(
-                self.attributes_by_name["battery_percentage_remaining"].id, 200
-            )  # report 100% battery
-
-
 class ThermostatCluster(TuyaThermostatCluster):
     """Thermostat cluster."""
 
@@ -117,25 +104,6 @@ class ThermostatCluster(TuyaThermostatCluster):
         CTRL_SEQ_OF_OPER_ATTR: Thermostat.ControlSequenceOfOperation.Heating_Only,  # the device supports heating mode
     }
 
-    def __init__(self, *args, **kwargs):
-        """Init."""
-        super().__init__(*args, **kwargs)
-        self.endpoint.device.thermostat_bus.add_listener(self)
-
-    def occupied_heating_setpoint_reported(self, value):
-        """Handle reported occupied heating setpoint state."""
-        self._update_attribute(
-            self.attributes_by_name["occupied_heating_setpoint"].id, value * 10
-        )
-        _LOGGER.debug("reported occupied heating setpoint: %d", value)
-
-    def local_temp_reported(self, value):
-        """Handle reported local temperature."""
-        self._update_attribute(
-            self.attributes_by_name["local_temperature"].id, value * 10
-        )
-        _LOGGER.debug("reported local temperature: %d", value)
-
     def system_mode_reported(self, value):
         """Handle reported system mode."""
         if value == 1:
@@ -160,23 +128,21 @@ def map_attribute(self, attribute, value):
 
         if attribute == "occupied_heating_setpoint":
             # centidegree to decidegree
-            return {OCCUPIED_HEATING_SETPOINT_COMMAND_ID: round(value / 10)}
+            return {SASSWEL_HEATING_SETPOINT_ATTR: round(value / 10)}
 
         if attribute == "system_mode":
+            # this quirk does not support programmig modes so we force the schedule mode to be always off
+            # more details: https://github.com/zigpy/zha-device-handlers/issues/1815
             if value == self.SystemMode.Off:
-                return {SYSTEM_MODE_COMMAND_ID: 0}
+                return {SASSWEL_STATE_ATTR: 0, SASSWEL_SCHEDULE_ENABLE_ATTR: 0}
             if value == self.SystemMode.Heat:
-                return {SYSTEM_MODE_COMMAND_ID: 1}
+                return {SASSWEL_STATE_ATTR: 1, SASSWEL_SCHEDULE_ENABLE_ATTR: 0}
 
 
 class Thermostat_TYST11_c88teujp(TuyaThermostat):
     """Saswell 88teujp thermostat valve."""
 
     signature = {
-        # <SimpleDescriptor endpoint=1 profile=260 device_type=0
-        # device_version=0
-        # input_clusters=[0, 3]
-        # output_clusters=[3, 25]>
         MODELS_INFO: [
             ("_TYST11_KGbxAXL2", "GbxAXL2"),
             ("_TYST11_c88teujp", "88teujp"),
@@ -186,6 +152,10 @@ class Thermostat_TYST11_c88teujp(TuyaThermostat):
             ("_TYST11_zuhszj9s", "uhszj9s"),
         ],
         ENDPOINTS: {
+            # <SimpleDescriptor endpoint=1 profile=260 device_type=0
+            # device_version=0
+            # input_clusters=[0, 3]
+            # output_clusters=[3, 25]>
             1: {
                 PROFILE_ID: zha.PROFILE_ID,
                 DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
@@ -208,16 +178,14 @@ class Thermostat_TYST11_c88teujp(TuyaThermostat):
                 DEVICE_TYPE: zha.DeviceType.THERMOSTAT,
                 INPUT_CLUSTERS: [
                     Basic.cluster_id,
+                    TuyaPowerConfigurationCluster,
                     Identify.cluster_id,
-                    ManufacturerThermostatCluster,
-                    PowerConfigurationCluster,
                     ThermostatCluster,
+                    ManufacturerThermostatCluster,
                 ],
                 OUTPUT_CLUSTERS: [
                     Identify.cluster_id,
                     Ota.cluster_id,
-                    PowerConfigurationCluster,
-                    ThermostatCluster,
                 ],
             }
         },
@@ -228,8 +196,6 @@ class Thermostat_TZE200_c88teujp(TuyaThermostat):
     """Saswell 88teujp thermostat valve."""
 
     signature = {
-        #  endpoint=1 profile=260 device_type=81 device_version=0 input_clusters=[0, 4, 5, 61184]
-        #  output_clusters=[10, 25]>
         MODELS_INFO: [
             ("_TZE200_c88teujp", "TS0601"),
             ("_TZE200_azqp6ssj", "TS0601"),
@@ -239,6 +205,10 @@ class Thermostat_TZE200_c88teujp(TuyaThermostat):
             ("_TZE200_2ekuz3dz", "TS0601"),
         ],
         ENDPOINTS: {
+            # <SimpleDescriptor endpoint=1 profile=260 device_type=81
+            # device_version=0
+            # input_clusters=[0, 4, 5, 61184]
+            # output_clusters=[10, 25]>
             1: {
                 PROFILE_ID: zha.PROFILE_ID,
                 DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
@@ -260,18 +230,13 @@ class Thermostat_TZE200_c88teujp(TuyaThermostat):
                 DEVICE_TYPE: zha.DeviceType.THERMOSTAT,
                 INPUT_CLUSTERS: [
                     Basic.cluster_id,
+                    TuyaPowerConfigurationCluster,
                     Groups.cluster_id,
                     Scenes.cluster_id,
-                    ManufacturerThermostatCluster,
-                    PowerConfigurationCluster,
-                    ThermostatCluster,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Time.cluster_id,
-                    Ota.cluster_id,
-                    PowerConfigurationCluster,
                     ThermostatCluster,
+                    ManufacturerThermostatCluster,
                 ],
+                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
             }
         }
     }


Test patch: