[New device support] ZHA custom quirk for Tuya TS0601 _TZE204_aai5grix occupancy sensor

I have this Wenzhi MTD285-ZB ZigBee 24GHz Radar device and wanted to integrate using ZHA (Zigbee Home Automation). I have a SLZB-06M and the sensor is discovered normally, but only shows the Firmware, LQI and RSSI entities. Unfortunately, I did not find any quirks available for this sensor. I have renamed a couple of pre-existing quirks for presence sensors to see if the entities match and also tried using ChatGPT to create a quirk but could not find or make it work so far.
There is already a discussion on Z2M for this device on Wenzhi 24G Presence Sensor - not working · Koenkk/zigbee2mqtt · Discussion #29173, but since I have many sensors working with ZHA, I don’t want to migrate them all to Z2M only because of this sensor.
I’d really appreciate if you could help me developing a proper quirk for this sensor.

Quirks are applied using the device and model found under Device info

What are the device and model of your device?

That info is already available in the title of this topic.


Unfortunately I could not find a quirk for this model.

So far, I did not find a way to use this sensor. About to throw it on a trash can. It is useless on Home Assistant with ZHA. Did anybody find custom quirk?

I have been trying to get a quirk configured myself with zero luck. If anyone has an example of a XXXX.py file they configured for this quirk for Tuya TS0601 _TZE204_aai5grix it would be much appreciated.

I was able to make the sensor work. However, it is getting stuck at “detected” state. I’ve tried to change all the parameters, but they don’t seem to make any effect. Also I could not get the illuminance sensor to work. Let me know if it works for you.

"""Wenzhi MTD285-ZB 24GHz Radar Presence Sensor."""

import math
from typing import Dict

from zigpy.profiles import zgp, zha
from zigpy.quirks import CustomDevice
import zigpy.types as t
from zigpy.zcl.clusters.general import (
    AnalogInput,
    AnalogOutput,
    Basic,
    GreenPowerProxy,
    Groups,
    OnOff,
    Ota,
    Scenes,
    Time,
)
from zigpy.zcl.clusters.measurement import (
    IlluminanceMeasurement,
    OccupancySensing,
)

from zhaquirks.const import (
    DEVICE_TYPE,
    ENDPOINTS,
    INPUT_CLUSTERS,
    MODELS_INFO,
    OUTPUT_CLUSTERS,
    PROFILE_ID,
)
from zhaquirks.tuya import (
    NoManufacturerCluster,
    TuyaLocalCluster,
    TuyaNewManufCluster,
)
from zhaquirks.tuya.mcu import (
    DPToAttributeMapping,
    TuyaAttributesCluster,
    TuyaMCUCluster,
)


class TuyaOccupancySensing(OccupancySensing, TuyaLocalCluster):
    """Tuya local OccupancySensing cluster."""


class TuyaIlluminanceMeasurement(IlluminanceMeasurement, TuyaLocalCluster):
    """Tuya local IlluminanceMeasurement cluster."""


class WenzhiNearDetection(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for near detection distance."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "near_detection",
        AnalogOutput.AttributeDefs.min_present_value.id: 0,
        AnalogOutput.AttributeDefs.max_present_value.id: 840,
        AnalogOutput.AttributeDefs.resolution.id: 1,
        AnalogOutput.AttributeDefs.engineering_units.id: 118,  # centimeters
    }


class WenzhiFarDetection(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for far detection distance."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "far_detection",
        AnalogOutput.AttributeDefs.min_present_value.id: 0,
        AnalogOutput.AttributeDefs.max_present_value.id: 840,
        AnalogOutput.AttributeDefs.resolution.id: 1,
        AnalogOutput.AttributeDefs.engineering_units.id: 118,  # centimeters
    }


class WenzhiTargetDistance(TuyaAttributesCluster, AnalogInput):
    """AnalogInput cluster for target distance (READ-ONLY SENSOR)."""

    _CONSTANT_ATTRIBUTES = {
        AnalogInput.AttributeDefs.description.id: "target_distance",
        AnalogInput.AttributeDefs.min_present_value.id: 0,
        AnalogInput.AttributeDefs.max_present_value.id: 990,
        AnalogInput.AttributeDefs.resolution.id: 1,
        AnalogInput.AttributeDefs.engineering_units.id: 118,  # centimeters
    }


class WenzhiDelayTime(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for delay time."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "delay_time",
        AnalogOutput.AttributeDefs.min_present_value.id: 5,
        AnalogOutput.AttributeDefs.max_present_value.id: 3600,
        AnalogOutput.AttributeDefs.resolution.id: 1,
        AnalogOutput.AttributeDefs.engineering_units.id: 159,  # seconds
    }


class WenzhiBlockTime(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for block time."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "block_time",
        AnalogOutput.AttributeDefs.min_present_value.id: 0,
        AnalogOutput.AttributeDefs.max_present_value.id: 100,
        AnalogOutput.AttributeDefs.resolution.id: 1,
        AnalogOutput.AttributeDefs.engineering_units.id: 159,  # seconds
    }


class WenzhiMotionThreshold(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for motion threshold."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "motion_thr",
        AnalogOutput.AttributeDefs.min_present_value.id: 0,
        AnalogOutput.AttributeDefs.max_present_value.id: 99,
        AnalogOutput.AttributeDefs.resolution.id: 1,
    }


class WenzhiPresenceThreshold(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for presence threshold."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "presence_thr",
        AnalogOutput.AttributeDefs.min_present_value.id: 0,
        AnalogOutput.AttributeDefs.max_present_value.id: 99,
        AnalogOutput.AttributeDefs.resolution.id: 1,
    }


class WenzhiMTD285Cluster(NoManufacturerCluster, TuyaMCUCluster):
    """Wenzhi MTD285 specific cluster."""

    attributes = TuyaMCUCluster.attributes.copy()
    attributes.update(
        {
            # Custom attributes for tracking
            0xEF01: ("presence", t.Bool, True),
            0xEF03: ("near_detection", t.uint32_t, True),
            0xEF04: ("far_detection", t.uint32_t, True),
            0xEF09: ("target_distance", t.uint32_t, True),
            0xEF65: ("delay_time", t.uint32_t, True),
            0xEF79: ("block_time", t.uint32_t, True),
            0xEF71: ("motion_thr", t.uint32_t, True),
            0xEF72: ("presence_thr", t.uint32_t, True),
            0xEF7D: ("illuminance", t.uint32_t, True),
        }
    )

    dp_to_attribute: Dict[int, DPToAttributeMapping] = {
        # DP 1: Presence (boolean) → Occupancy bitmap
        1: DPToAttributeMapping(
            TuyaOccupancySensing.ep_attribute,
            "occupancy",
            converter=lambda x: OccupancySensing.Occupancy.Occupied if x else OccupancySensing.Occupancy.Unoccupied,
            endpoint_id=1,
        ),
        # DP 3: Near detection distance (writable)
        3: DPToAttributeMapping(
            WenzhiNearDetection.ep_attribute,
            "present_value",
            converter=lambda x: x * 10,  # Convert to cm
            dp_converter=lambda x: x // 10,
            endpoint_id=2,
        ),
        # DP 4: Far detection distance (writable)
        4: DPToAttributeMapping(
            WenzhiFarDetection.ep_attribute,
            "present_value",
            converter=lambda x: x * 10,  # Convert to cm
            dp_converter=lambda x: x // 10,
            endpoint_id=3,
        ),
        # DP 9: Target distance (READ-ONLY sensor)
        9: DPToAttributeMapping(
            WenzhiTargetDistance.ep_attribute,
            "present_value",
            converter=lambda x: x * 10,  # Convert to cm
            # NO dp_converter - this is read-only!
            endpoint_id=4,
        ),
        # DP 113: Motion threshold
        113: DPToAttributeMapping(
            WenzhiMotionThreshold.ep_attribute,
            "present_value",
            endpoint_id=5,
        ),
        # DP 114: Presence threshold
        114: DPToAttributeMapping(
            WenzhiPresenceThreshold.ep_attribute,
            "present_value",
            endpoint_id=6,
        ),
        # DP 120: Delay time
        120: DPToAttributeMapping(
            WenzhiDelayTime.ep_attribute,
            "present_value",
            endpoint_id=7,
        ),
        # DP 121: Block time
        121: DPToAttributeMapping(
            WenzhiBlockTime.ep_attribute,
            "present_value",
            converter=lambda x: x * 10,  # Convert from 0.1s
            dp_converter=lambda x: x // 10,
            endpoint_id=8,
        ),
        # DP 125: Illuminance
        125: DPToAttributeMapping(
            TuyaIlluminanceMeasurement.ep_attribute,
            "measured_value",
            converter=lambda x: int(10000 * math.log10(x) + 1) if x > 0 else 0,
            endpoint_id=1,
        ),
    }

    data_point_handlers = {
        1: "_dp_2_attr_update",
        3: "_dp_2_attr_update",
        4: "_dp_2_attr_update",
        9: "_dp_2_attr_update",
        113: "_dp_2_attr_update",
        114: "_dp_2_attr_update",
        120: "_dp_2_attr_update",
        121: "_dp_2_attr_update",
        125: "_dp_2_attr_update",
    }


class WenzhiMTD285(CustomDevice):
    """Wenzhi MTD285-ZB 24GHz Radar Presence Sensor."""

    signature = {
        MODELS_INFO: [("_TZE204_aai5grix", "TS0601")],
        ENDPOINTS: {
            # Endpoint 1: Main sensor endpoint
            1: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    Groups.cluster_id,
                    Scenes.cluster_id,
                    TuyaNewManufCluster.cluster_id,
                ],
                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
            },
            # Endpoint 242: Green Power Proxy
            242: {
                PROFILE_ID: zgp.PROFILE_ID,
                DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC,
                INPUT_CLUSTERS: [],
                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
            },
        },
    }

    replacement = {
        ENDPOINTS: {
            # Endpoint 1: Main sensor (occupancy + illuminance)
            1: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.OCCUPANCY_SENSOR,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    Groups.cluster_id,
                    Scenes.cluster_id,
                    WenzhiMTD285Cluster,
                    TuyaOccupancySensing,
                    TuyaIlluminanceMeasurement,
                ],
                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
            },
            # Endpoint 2: Near Detection Distance (writable)
            2: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [WenzhiNearDetection],
                OUTPUT_CLUSTERS: [],
            },
            # Endpoint 3: Far Detection Distance (writable)
            3: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [WenzhiFarDetection],
                OUTPUT_CLUSTERS: [],
            },
            # Endpoint 4: Target Distance (READ-ONLY sensor)
            4: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [WenzhiTargetDistance],
                OUTPUT_CLUSTERS: [],
            },
            # Endpoint 5: Motion Threshold
            5: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [WenzhiMotionThreshold],
                OUTPUT_CLUSTERS: [],
            },
            # Endpoint 6: Presence Threshold
            6: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [WenzhiPresenceThreshold],
                OUTPUT_CLUSTERS: [],
            },
            # Endpoint 7: Delay Time
            7: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [WenzhiDelayTime],
                OUTPUT_CLUSTERS: [],
            },
            # Endpoint 8: Block Time
            8: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [WenzhiBlockTime],
                OUTPUT_CLUSTERS: [],
            },
            # Endpoint 242: Green Power Proxy (passthrough)
            242: {
                PROFILE_ID: zgp.PROFILE_ID,
                DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC,
                INPUT_CLUSTERS: [],
                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
            },
        },
    }