ZHA quirk for Tuya _TZE204_eekpf0ft Radiator valve (TR-M3Z)

Hello everyone! Recently bought Zigbee thermostats from Tuya (photo):

It is defined in ZHA as TS0601 _TZE204_eekpf0ft

Data on lqi and rssi are available, as well as the «Firmware Update» item in the unavailable status

I’m looking for a suitable converter (quirk) for them to use with ZHA. So far, I see only for Zigbee2MQTT: https://github.com/Koenkk/zigbee2mqtt/issues/24706

Can you tell me if I can somehow adapt it for use with ZHA? Or maybe there is a ready-made solution that is worth trying?

Thank you in advance for your answers❤️

For convenience, I’ll consolidate all the information into one thread. Maybe this will make it easier for someone more knowledgeable about this quirk to work on it.
So, what I have managed to find out so far:

  1. List of data points for the device :
    "2": "Mode",
    "3": "Working status",
    "4": "Set Temp.",
    "5": "Current Temp.",
    "6": "Battery capacity",
    "7": " lock",
    "14": "Window  opening  detection",
    "15": "State of the window",
    "21": "Holiday Temp.",
    "28": "Week Program",
    "29": "Tuesday",
    "30": "Wednesday",
    "31": "Thursday",
    "32": "Friday",
    "33": "Saturday",
    "34": "Sunday",
    "35": "Fault alarm",
    "36": "Frost protection",
    "47": "Temp. Calibration",
    "101": "Switch",
    "102": "Temp. Accuracy",
    "103": "Eco Temp. Set",
    "104": "Comfort Temp. Set",
    "105": "Antifrost Temp. Set"

Device signature in ZHA:

{
  "node_descriptor": {
    "logical_type": 2,
    "complex_descriptor_available": 0,
    "user_descriptor_available": 0,
    "reserved": 0,
    "aps_flags": 0,
    "frequency_band": 8,
    "mac_capability_flags": 128,
    "manufacturer_code": 4417,
    "maximum_buffer_size": 66,
    "maximum_incoming_transfer_size": 66,
    "server_mask": 10752,
    "maximum_outgoing_transfer_size": 66,
    "descriptor_capability_field": 0
  },
  "endpoints": {
    "1": {
      "profile_id": "0x0104",
      "device_type": "0x0301",
      "input_clusters": [
        "0x0000",
        "0x0001",
        "0x0004",
        "0x0005",
        "0x0201",
        "0x0204",
        "0xef00"
      ],
      "output_clusters": [
        "0x000a",
        "0x0019"
      ]
    },
    "2": {
      "profile_id": "0x0104",
      "device_type": "0x0007",
      "input_clusters": [
        "0xfd00"
      ],
      "output_clusters": []
    }
  },
  "manufacturer": "_TZE204_eekpf0ft",
  "model": "TS0601",
  "class": "ts0601_trv_m3z.TuyaTRV"
}

I’m still figuring out how to create a quirk for ZHA. So far, I’ve managed to get the device’s basic functions working. At this stage, I can turn the heating on/off and select the temperature. The battery status display also works correctly. I haven’t tested the threshold settings yet.

Also, here’s a quirk code based on this: [Github]: zha-device-handlers/zhaquirks/tuya/ts0601_trv_tze204.py at 94e15bdcdeeab9dc333fbd587416bffd1b76048f · Teka101/zha-device-handlers · GitHub

I think we should build on this by adding/changing missing/incorrect clusters. I just need to know how to do it correctly. My programming skills are still too weak for that :smiling_face:

Here is the code I use:

from typing import Optional, Union

from zigpy.profiles import zha
import zigpy.types as t
from zigpy.zcl import foundation
from zigpy.zcl.clusters.general import (
    AnalogOutput,
    Basic,
    Groups,
    OnOff,
    Ota,
    Scenes,
    Time,
)

from zhaquirks import Bus, LocalDataCluster
from zhaquirks.const import (
    DEVICE_TYPE,
    ENDPOINTS,
    INPUT_CLUSTERS,
    MODELS_INFO,
    OUTPUT_CLUSTERS,
    PROFILE_ID,
)
from zhaquirks.tuya import (
    TUYA_DP_TYPE_BOOL,
    TUYA_DP_TYPE_ENUM,
    TUYA_DP_TYPE_FAULT,
    TUYA_DP_TYPE_VALUE,
    TuyaManufClusterAttributes,
    TuyaPowerConfigurationCluster2AA,
    TuyaThermostat,
    TuyaThermostatCluster,
    TuyaUserInterfaceCluster,
)

# Data Points based on your device configuration
TRV_PRESET = TUYA_DP_TYPE_ENUM + 2
TRV_TARGET_TEMP = TUYA_DP_TYPE_VALUE + 4
TRV_LOCAL_TEMP = TUYA_DP_TYPE_VALUE + 5
TRV_BATTERY = TUYA_DP_TYPE_VALUE + 6
TRV_CHILD_LOCK = TUYA_DP_TYPE_BOOL + 7
TRV_SYSTEM_MODE = TUYA_DP_TYPE_BOOL + 101

TuyaManufClusterSelf = None


class ManufCluster(TuyaManufClusterAttributes):
    """Manufacturer Specific Cluster for Tuya TRV."""

    def __init__(self, *args, **kwargs):
        """Init."""
        super().__init__(*args, **kwargs)
        global TuyaManufClusterSelf
        TuyaManufClusterSelf = self
        # FIX: Set the time offset to avoid AssertionError
        self.set_time_local_offset = 1970

    set_time_offset = 1970

    attributes = TuyaManufClusterAttributes.attributes.copy()
    attributes.update(
        {
            TRV_PRESET: ("preset", t.uint8_t, True),
            TRV_TARGET_TEMP: ("target_temp", t.uint32_t, True),
            TRV_LOCAL_TEMP: ("local_temp", t.uint32_t, True),
            TRV_BATTERY: ("battery", t.uint32_t, True),
            TRV_CHILD_LOCK: ("child_lock", t.uint8_t, True),
            TRV_SYSTEM_MODE: ("system_mode", t.uint8_t, True),
        }
    )

    def _update_attribute(self, attrid, value):
        super()._update_attribute(attrid, value)
        
        # Temperature handling
        if attrid in (TRV_TARGET_TEMP, TRV_LOCAL_TEMP):
            # Convert decidegree to centidegree (divide by 10)
            self.endpoint.device.thermostat_bus.listener_event(
                "temperature_change",
                attrid,
                value * 10,
            )
        elif attrid == TRV_BATTERY:
            self.endpoint.device.battery_bus.listener_event("battery_change", value)
        elif attrid == TRV_PRESET:
            self.endpoint.device.preset_bus.listener_event("preset_change", value)


class ThermostatCluster(TuyaThermostatCluster):
    """Thermostat cluster for Tuya TRV."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # Set temperature limits
        self.endpoint.device.thermostat_bus.listener_event(
            "temperature_change", "min_heat_setpoint_limit", 500
        )
        self.endpoint.device.thermostat_bus.listener_event(
            "temperature_change", "max_heat_setpoint_limit", 3500
        )

    def map_attribute(self, attribute, value):
        """Map standard attribute to Tuya DP."""
        if attribute == "occupied_heating_setpoint":
            return {TRV_TARGET_TEMP: round(value / 10)}  # centidegree to decidegree
        if attribute == "local_temperature":
            return {TRV_LOCAL_TEMP: round(value / 10)}  # centidegree to decidegree
        if attribute == "system_mode":
            if value == self.SystemMode.Off:
                return {TRV_SYSTEM_MODE: 0}
            if value == self.SystemMode.Heat:
                return {TRV_SYSTEM_MODE: 1}
        
        return None


class PresetSelect(LocalDataCluster):
    """Custom cluster for preset selection."""
    
    cluster_id = 0xFD00  # Custom cluster ID
    
    class Preset(t.enum8):
        Manual = 0x00
        Schedule = 0x01
        Eco = 0x02
        Comfort = 0x03
        FrostProtect = 0x04
        Holiday = 0x05
        Off = 0x06

    attributes = {
        0x0000: ("preset", Preset, True),
    }

    def __init__(self, *args, **kwargs):
        """Init."""
        super().__init__(*args, **kwargs)
        self.endpoint.device.preset_bus.add_listener(self)

    def preset_change(self, value):
        """Update preset from device."""
        if 0 <= value <= 6:
            self._update_attribute(0x0000, value)

    async def write_attributes(self, attributes, manufacturer=None):
        """Write preset to device."""
        for attrid, value in attributes.items():
            if attrid == 0x0000:  # preset
                await TuyaManufClusterSelf.endpoint.tuya_manufacturer.write_attributes(
                    {TRV_PRESET: value}, manufacturer=None
                )
                return [foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]
        
        return [foundation.WriteAttributesStatusRecord(foundation.Status.FAILURE)]


class UserInterfaceCluster(TuyaUserInterfaceCluster):
    """User interface cluster for child lock."""
    _CHILD_LOCK_ATTR = TRV_CHILD_LOCK


class TuyaTRV(TuyaThermostat):
    """Tuya TRV device."""

    def __init__(self, *args, **kwargs):
        """Init device."""
        self.preset_bus = Bus()
        super().__init__(*args, **kwargs)

    signature = {
        MODELS_INFO: [
            ("_TZE204_eekpf0ft", "TS0601"),
        ],
        ENDPOINTS: {
            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],
            }
        },
    }

    replacement = {
        ENDPOINTS: {
            1: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.THERMOSTAT,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    Groups.cluster_id,
                    Scenes.cluster_id, 
                    ManufCluster,
                    ThermostatCluster,
                    UserInterfaceCluster,
                    TuyaPowerConfigurationCluster2AA,
                ],
                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
            },
            2: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [
                    PresetSelect,
                ],
                OUTPUT_CLUSTERS: [],
            }
        }
    }

I’ll continue trying to figure this out on my own, but I’d be grateful for any help

Sorry for my bad English