zigpy / zha-device-handlers

ZHA device handlers bridge the functionality gap created when manufacturers deviate from the ZCL specification, handling deviations and exceptions by parsing custom messages to and from Zigbee devices.
Apache License 2.0
725 stars 672 forks source link

[Device Support Request] ZY-M100 Series Presence Sensor (TS0601 by _TZE204_ijxvkhd0) #2852

Open logan893 opened 9 months ago

logan893 commented 9 months ago

Problem description

Pairing with Home Assistant but no sensors or controls available. Advertised as Presence sensor for use with Tuya zigbee hub/gateway.

Model: ZY-M100-1 (Side wall version); there is also the ZY-M100-2 Ceiling version, which I do not have myself.

Shows up in ZHA as TS0601 by _TZE204_ijxvkhd0

This is a mains powered 24 GHz presence sensor, zigbee and side wall edition.

https://www.aliexpress.com/item/1005006128737558.html

Solution description

Presence Sensor functionality

Screenshots/Video

Screenshots/Video [Paste/upload your media here]

Device signature

Device signature ```json [Paste the device signature here] ```

Diagnostic information

Diagnostic information ```json [Paste the diagnostic information here] ```

Logs

Logs ```python [Paste the logs here] ```

Custom quirk

Custom quirk ```python ## originally based on ### https://fixtse.com/blog/zy-m100-full-zha-support ### ( https://gist.githubusercontent.com/fixtse/b95753b84c34b45f49b3116d23b66342/raw/0f84bd6e9b6c174970c7b5fc21078d4b4da06a15/TZE204_ijxvkhd0_e5m9c5hl.py ) ## with inspiration from ### https://github.com/zigpy/zha-device-handlers/pull/2525#issuecomment-1826881992 ## and my own changes and additions import math from typing import Dict from zigpy.profiles import zha from zigpy.quirks import CustomDevice import zigpy.types as t from zigpy.zcl import foundation from zigpy.zcl.clusters.general import ( AnalogInput, AnalogOutput, Basic, GreenPowerProxy, Groups, Ota, Scenes, Time, ) from zigpy.zcl.clusters.measurement import ( IlluminanceMeasurement, OccupancySensing, PressureMeasurement, ) from zigpy.zcl.clusters.security import IasZone, ZoneType from zhaquirks import Bus, LocalDataCluster from zhaquirks.const import ( DEVICE_TYPE, ENDPOINTS, INPUT_CLUSTERS, MODELS_INFO, OUTPUT_CLUSTERS, PROFILE_ID, CLUSTER_COMMAND, ZONE_STATUS_CHANGE_COMMAND, ON, OFF, ) from zhaquirks.tuya import ( NoManufacturerCluster, TuyaLocalCluster, TuyaNewManufCluster, ) from zhaquirks.tuya.mcu import ( DPToAttributeMapping, TuyaAttributesCluster, TuyaMCUCluster, ) class TuyaMmwRadarSelfTest(t.enum8): """Mmw radar self test values.""" TESTING = 0 TEST_SUCCESS = 1 TEST_FAILURE = 2 OTHER = 3 COMM_FAULT = 4 RADAR_FAULT = 5 class PresenceMotionEnum(t.enum8): """Presence and motion enum.""" NONE = 0x00 PRESENCE = 0x01 MOTION = 0x02 class TuyaMmwRadarTargetDistanceAsPressureMeasurement(PressureMeasurement, TuyaLocalCluster): # result in centimeteres expressed as hPa """Target Distance.""" class TuyaMmwRadarMotionSensitivity(TuyaAttributesCluster, AnalogOutput): """AnalogOutput cluster for motion sensitivity.""" _CONSTANT_ATTRIBUTES = { AnalogOutput.AttributeDefs.description.id: "motion sensitivity", AnalogOutput.AttributeDefs.min_present_value.id: 1, AnalogOutput.AttributeDefs.max_present_value.id: 9, AnalogOutput.AttributeDefs.resolution.id: 1, } class TuyaMmwRadarPresenceSensitivity(TuyaAttributesCluster, AnalogOutput): """AnalogOutput cluster for presence sensitivity.""" _CONSTANT_ATTRIBUTES = { AnalogOutput.AttributeDefs.description.id: "presence sensitivity", AnalogOutput.AttributeDefs.min_present_value.id: 1, AnalogOutput.AttributeDefs.max_present_value.id: 9, AnalogOutput.AttributeDefs.resolution.id: 1, } class TuyaMmwRadarFadingTime(TuyaAttributesCluster, AnalogOutput): """AnalogOutput cluster for fading time.""" _CONSTANT_ATTRIBUTES = { AnalogOutput.AttributeDefs.description.id: "fading time", AnalogOutput.AttributeDefs.min_present_value.id: 1, AnalogOutput.AttributeDefs.max_present_value.id: 1000, AnalogOutput.AttributeDefs.resolution.id: 1, # Resolution 1 second AnalogOutput.AttributeDefs.engineering_units.id: 73, # 73 defines seconds, the expected unit } class TuyaMmwRadarMaxRange(TuyaAttributesCluster, AnalogOutput): """AnalogOutput cluster for max range.""" _CONSTANT_ATTRIBUTES = { AnalogOutput.AttributeDefs.description.id: "max detection range", AnalogOutput.AttributeDefs.min_present_value.id: 150, # min allowed = 150 AnalogOutput.AttributeDefs.max_present_value.id: 550, # max allowed = 550 AnalogOutput.AttributeDefs.resolution.id: 100, #resolution = 100 centermeters (snaps back to 100 cm intervals between 150 and 550) AnalogOutput.AttributeDefs.engineering_units.id: 118, # 118 defines centimeters, the expected unit } class TuyaOccupancySensing(OccupancySensing, TuyaLocalCluster): """Tuya local OccupancySensing cluster.""" class TuyaIlluminanceMeasurement(IlluminanceMeasurement, TuyaLocalCluster): """Tuya local IlluminanceMeasurement cluster.""" class TuyaOccupancyMotionSensing(OccupancySensing, TuyaLocalCluster): """Tuya local OccupancySensing cluster for motion state.""" class TuyaMmwRadarMotionAnalogInputCluster(TuyaLocalCluster, AnalogInput): """Analog input cluster, only used to relay motion state information to Iaszone motion sensor.""" cluster_id = AnalogInput.cluster_id def __init__(self, *args, **kwargs): """Init.""" super().__init__(*args, **kwargs) def _update_attribute(self, attrid, value): super()._update_attribute(attrid, value) if attrid == AnalogInput.AttributeDefs.present_value.id: # 0x55 = "present_value" for AnalogInput if value == PresenceMotionEnum.MOTION: self.endpoint.device.motion_bus.listener_event("_turn_on") else: self.endpoint.device.motion_bus.listener_event("_turn_off") class TuyaMmwRadarMotionSensing(LocalDataCluster, IasZone): """IasZone cluster for motion.""" _CONSTANT_ATTRIBUTES = { IasZone.AttributeDefs.zone_type.id: ZoneType.Motion_Sensor, # 0x000D, # motion type } cluster_id = IasZone.cluster_id def __init__(self, *args, **kwargs): """Init.""" super().__init__(*args, **kwargs) self.endpoint.device.motion_bus.add_listener(self) def _turn_off(self): self.listener_event( CLUSTER_COMMAND, 253, ZONE_STATUS_CHANGE_COMMAND, [OFF, 0, 0, 0] ) def _turn_on(self): self.listener_event( CLUSTER_COMMAND, 254, ZONE_STATUS_CHANGE_COMMAND, [ON, 0, 0, 0] ) class TuyaMmwRadarCluster(NoManufacturerCluster, TuyaMCUCluster): """Mmw radar cluster.""" attributes = TuyaMCUCluster.attributes.copy() dp_to_attribute: Dict[int, DPToAttributeMapping] = { 103: DPToAttributeMapping( TuyaMCUCluster.ep_attribute, "cli", ), 104: DPToAttributeMapping( TuyaIlluminanceMeasurement.ep_attribute, "measured_value", converter=lambda x: int(math.log10(x) * 10000 + 1) if x > 0 else int(0), ), 105: DPToAttributeMapping( TuyaMmwRadarMotionAnalogInputCluster.ep_attribute, "present_value", converter=lambda x: PresenceMotionEnum(x), endpoint_id=2, ), 106: DPToAttributeMapping( TuyaMmwRadarMotionSensitivity.ep_attribute, "present_value", endpoint_id=6, converter=lambda x: x if x < 10 else x / 10, ), 107: DPToAttributeMapping( TuyaMmwRadarMaxRange.ep_attribute, "present_value", endpoint_id=3, ), 109: DPToAttributeMapping( TuyaMmwRadarTargetDistanceAsPressureMeasurement.ep_attribute, "measured_value", ), 110: DPToAttributeMapping( TuyaMmwRadarFadingTime.ep_attribute, "present_value", endpoint_id=5, ), 111: DPToAttributeMapping( TuyaMmwRadarPresenceSensitivity.ep_attribute, "present_value", endpoint_id=7, converter=lambda x: x if x < 10 else x / 10, ), 112: DPToAttributeMapping( TuyaOccupancySensing.ep_attribute, "occupancy", ), } data_point_handlers = { 103: "_dp_2_attr_update", 104: "_dp_2_attr_update", 105: "_dp_2_attr_update", 106: "_dp_2_attr_update", 107: "_dp_2_attr_update", 109: "_dp_2_attr_update", 110: "_dp_2_attr_update", 111: "_dp_2_attr_update", 112: "_dp_2_attr_update", } class TuyaMmwRadarOccupancy(CustomDevice): """Millimeter wave occupancy sensor.""" def __init__(self, *args, **kwargs): """Init device.""" self.motion_bus = Bus() super().__init__(*args, **kwargs) signature = { # endpoint=1, profile=260, device_type=81, device_version=1, # input_clusters=[0, 4, 5, 61184], output_clusters=[25, 10] MODELS_INFO: [ ("_TZE204_ijxvkhd0", "TS0601"), ("_TZE204_e5m9c5hl", "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, TuyaNewManufCluster.cluster_id, ], OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], }, 242: { #

Additional information

No response

logan893 commented 9 months ago

If there's an easy way to convert the implementation into a quirk or something, zigbee2mqtt has support, even though it doesn't seem to work flawlessly yet.

https://github.com/Koenkk/zigbee2mqtt/issues/18237

logan893 commented 9 months ago

Here is the data from Manage Zigbee Device -> Signature.

It looks identical to the motion sensor already included in the latest ZHA code with class MmwRadarMotionGPP in ts0601_motion.py. Same input clusters and output clusters for endpoints 1 and 242.

{
  "node_descriptor": "NodeDescriptor(logical_type=<LogicalType.Router: 1>, complex_descriptor_available=0, user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=<FrequencyBand.Freq2400MHz: 8>, mac_capability_flags=<MACCapabilityFlags.FullFunctionDevice|MainsPowered|RxOnWhenIdle|AllocateAddress: 142>, manufacturer_code=4417, maximum_buffer_size=66, maximum_incoming_transfer_size=66, server_mask=10752, maximum_outgoing_transfer_size=66, descriptor_capability_field=<DescriptorCapability.NONE: 0>, *allocate_address=True, *is_alternate_pan_coordinator=False, *is_coordinator=False, *is_end_device=False, *is_full_function_device=True, *is_mains_powered=True, *is_receiver_on_when_idle=True, *is_router=True, *is_security_capable=False)",
  "endpoints": {
    "1": {
      "profile_id": "0x0104",
      "device_type": "0x0051",
      "input_clusters": [
        "0x0000",
        "0x0004",
        "0x0005",
        "0xef00"
      ],
      "output_clusters": [
        "0x000a",
        "0x0019"
      ]
    },
    "242": {
      "profile_id": "0xa1e0",
      "device_type": "0x0061",
      "input_clusters": [],
      "output_clusters": [
        "0x0021"
      ]
    }
  },
  "manufacturer": "_TZE204_ijxvkhd0",
  "model": "TS0601",
  "class": "zigpy.device.Device"
}
logan893 commented 9 months ago

I added _TZE204_ijxvkhd0 to the MmwRadarMotionGPP, same as used by _TZE204_qasjif9e ( https://github.com/zigpy/zha-device-handlers/issues/2510 ).

I get a sensor for Occupancy but it is always set to Clear and doesn't change. No other sensors or controls that I can see.

I can get light sensor data from TuyaIlluminanceMeasurement (Endpoint id: 1, Id: 0x0400, Type: in), attribute measured_value (id: 0x0000). It changes the value read when I cover the sensor.

All the occupancy sensing output is None (from TuyaOccupancySensing (Endpoint id: 1, Id: 0x0406, Type: in)), except for occupancy (0x0000) which is always zero (0).

MmwRadarManufCluster, mcu_version returns 1.1.8. Firmware is 0x4a (74)

Edit:

For some unclear reason, a few restarts of HA later (finishing up other upgrade changes), I now have a sensor for Illuminance.

Occupancy is still always Clear (occupancy attribute reading 0).

I do however with manual reads of dp_105 get either 0x0, 0x1 or 0x2, which according to the zigbee2mqtt implementation is for Presence states.

        [105, 'presence_state',     tuya.valueConverterBasic.lookup({'None': tuya.enum(0), 'Presence': tuya.enum(1), 'Move': tuya.enum(2)})],   
logan893 commented 9 months ago

Occupancy seems to work (can show Clear and Detected as expected) if I switch occupancy from using dp_1 attribute, to using dp_112.

It's used in the zigbee2mqtt implementation for "presence" true/false. [112, 'presence', tuya.valueConverter.trueFalse1],

I don't know how to create a custom sensor for the presence_state type attribute.

visata commented 9 months ago

I ordered several of these sensors for testing purposes and discovered they didn't work out of the box since I use ZHA. I don't want to migrate to Zigbee2MQTT just because of this issue.

@logan893, can you share how you added '_TZE204_ijxvkhd0' to the MmwRadarMotionGPP? I would like to test this.

Thank you

logan893 commented 9 months ago

@visata I don't know if it's all proper, but this is what I have changed and added so far.

--- ts0601_motion.py.latest
+++ ts0601_motion.py
@@ -147,11 +147,16 @@
             0xEF6A: ("dp_106", t.enum8, True),
             0xEF6B: ("dp_107", t.enum8, True),
             0xEF6C: ("dp_108", t.uint32_t, True),
+            0xEF6D: ("dp_109", t.uint32_t, True),
+            0xEF6E: ("dp_110", t.uint32_t, True),
+            0xEF6F: ("dp_111", t.uint32_t, True),
+            #0xEF70: ("occupancy", t.uint32_t, True),
         }
     )

     dp_to_attribute: Dict[int, DPToAttributeMapping] = {
-        1: DPToAttributeMapping(
+        # was: 1: DPToAttributeMapping(
+        112: DPToAttributeMapping(
             TuyaOccupancySensing.ep_attribute,
             "occupancy",
         ),
@@ -209,6 +214,18 @@
             TuyaMCUCluster.ep_attribute,
             "dp_108",
         ),
+        109: DPToAttributeMapping(
+            TuyaMCUCluster.ep_attribute,
+            "dp_109",
+        ),
+        110: DPToAttributeMapping(
+            TuyaMCUCluster.ep_attribute,
+            "dp_110",
+        ),
+        111: DPToAttributeMapping(
+            TuyaMCUCluster.ep_attribute,
+            "dp_111",
+        ),
     }

     data_point_handlers = {
@@ -226,6 +243,10 @@
         106: "_dp_2_attr_update",
         107: "_dp_2_attr_update",
         108: "_dp_2_attr_update",
+        109: "_dp_2_attr_update",
+        110: "_dp_2_attr_update",
+        111: "_dp_2_attr_update",
+        112: "_dp_2_attr_update",
     }

@@ -399,6 +420,7 @@
             ("_TZE200_sfiy5tfs", "TS0601"),
             ("_TZE200_mrf6vtua", "TS0601"),
             ("_TZE204_qasjif9e", "TS0601"),
+            ("_TZE204_ijxvkhd0", "TS0601"),
         ],
         ENDPOINTS: {
             1: {
visata commented 9 months ago

@logan893 I'm adding this quirk from here https://fixtse.com/blog/zy-m100-full-zha-support

Will let you know if it works.

logan893 commented 9 months ago

@visata Looks good. Have you gotten the "target distance" to display as a sensor?

logan893 commented 8 months ago

I cobbled together a custom quirk based on fixtse's quirk ( https://fixtse.com/blog/zy-m100-full-zha-support ; https://gist.githubusercontent.com/fixtse/b95753b84c34b45f49b3116d23b66342/raw/0f84bd6e9b6c174970c7b5fc21078d4b4da06a15/TZE204_ijxvkhd0_e5m9c5hl.py ) with some influence from https://github.com/zigpy/zha-device-handlers/pull/2525#issuecomment-1826881992 and my own additions.

I haven't seen that there would be any "fade time remaining" parameter for _TZE204_ijxvkhd0, but I have no clue where to begin digging.

I added an Iaszone motion sensor to separately track the motion detected (seems to be a fixed fade time of roughly 5-6 seconds). Distance to target is via a pressure sensor, showing cm to target with unit hPa.

image

(this custom quirk also pasted in initial problem description)

## originally based on
### https://fixtse.com/blog/zy-m100-full-zha-support
### ( https://gist.githubusercontent.com/fixtse/b95753b84c34b45f49b3116d23b66342/raw/0f84bd6e9b6c174970c7b5fc21078d4b4da06a15/TZE204_ijxvkhd0_e5m9c5hl.py )
## with inspiration from
### https://github.com/zigpy/zha-device-handlers/pull/2525#issuecomment-1826881992
## and my own changes and additions

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

from zigpy.zcl.clusters.security import IasZone, ZoneType

from zhaquirks import Bus, LocalDataCluster

from zhaquirks.const import (
    DEVICE_TYPE,
    ENDPOINTS,
    INPUT_CLUSTERS,
    MODELS_INFO,
    OUTPUT_CLUSTERS,
    PROFILE_ID,

    CLUSTER_COMMAND,
    ZONE_STATUS_CHANGE_COMMAND,
    ON,
    OFF,
)

from zhaquirks.tuya import (
    NoManufacturerCluster,
    TuyaLocalCluster,
    TuyaNewManufCluster,
)
from zhaquirks.tuya.mcu import (
    DPToAttributeMapping,
    TuyaAttributesCluster,
    TuyaMCUCluster,
)

class TuyaMmwRadarSelfTest(t.enum8):
    """Mmw radar self test values."""
    TESTING = 0
    TEST_SUCCESS = 1
    TEST_FAILURE = 2
    OTHER = 3
    COMM_FAULT = 4
    RADAR_FAULT = 5

class PresenceMotionEnum(t.enum8):
    """Presence and motion enum."""
    NONE = 0x00
    PRESENCE = 0x01
    MOTION = 0x02

class TuyaMmwRadarTargetDistanceAsPressureMeasurement(PressureMeasurement, TuyaLocalCluster): # result in centimeteres expressed as hPa
    """Target Distance."""

class TuyaMmwRadarMotionSensitivity(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for motion sensitivity."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "motion sensitivity",
        AnalogOutput.AttributeDefs.min_present_value.id: 1,
        AnalogOutput.AttributeDefs.max_present_value.id: 9,
        AnalogOutput.AttributeDefs.resolution.id: 1,
    }

class TuyaMmwRadarPresenceSensitivity(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for presence sensitivity."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "presence sensitivity",
        AnalogOutput.AttributeDefs.min_present_value.id: 1,
        AnalogOutput.AttributeDefs.max_present_value.id: 9,
        AnalogOutput.AttributeDefs.resolution.id: 1,
    }

class TuyaMmwRadarFadingTime(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for fading time."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "fading time",
        AnalogOutput.AttributeDefs.min_present_value.id: 1,
        AnalogOutput.AttributeDefs.max_present_value.id: 1000,
        AnalogOutput.AttributeDefs.resolution.id: 1, # Resolution 1 second
        AnalogOutput.AttributeDefs.engineering_units.id: 73, # 73 defines seconds, the expected unit
    }

class TuyaMmwRadarMaxRange(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for max range."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "max detection range",
        AnalogOutput.AttributeDefs.min_present_value.id: 150, # min allowed  = 150
        AnalogOutput.AttributeDefs.max_present_value.id: 550, # max allowed  = 550
        AnalogOutput.AttributeDefs.resolution.id: 100, #resolution = 100 centermeters (snaps back to 100 cm intervals between 150 and 550)
        AnalogOutput.AttributeDefs.engineering_units.id: 118, # 118 defines centimeters, the expected unit
    }

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

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

class TuyaOccupancyMotionSensing(OccupancySensing, TuyaLocalCluster):
    """Tuya local OccupancySensing cluster for motion state."""

class TuyaMmwRadarMotionAnalogInputCluster(TuyaLocalCluster, AnalogInput):
    """Analog input cluster, only used to relay motion state information to Iaszone motion sensor."""

    cluster_id = AnalogInput.cluster_id

    def __init__(self, *args, **kwargs):
        """Init."""
        super().__init__(*args, **kwargs)

    def _update_attribute(self, attrid, value):
        super()._update_attribute(attrid, value)

        if attrid == AnalogInput.AttributeDefs.present_value.id: # 0x55 = "present_value" for AnalogInput
            if value == PresenceMotionEnum.MOTION:
                self.endpoint.device.motion_bus.listener_event("_turn_on")
            else:
                self.endpoint.device.motion_bus.listener_event("_turn_off")

class TuyaMmwRadarMotionSensing(LocalDataCluster, IasZone):
    """IasZone cluster for motion."""

    _CONSTANT_ATTRIBUTES = {
        IasZone.AttributeDefs.zone_type.id: ZoneType.Motion_Sensor, # 0x000D, # motion type
    }

    cluster_id = IasZone.cluster_id

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

    def _turn_off(self):
        self.listener_event(
            CLUSTER_COMMAND, 253, ZONE_STATUS_CHANGE_COMMAND, [OFF, 0, 0, 0]
        )

    def _turn_on(self):
        self.listener_event(
            CLUSTER_COMMAND, 254, ZONE_STATUS_CHANGE_COMMAND, [ON, 0, 0, 0]
        )

class TuyaMmwRadarCluster(NoManufacturerCluster, TuyaMCUCluster):
    """Mmw radar cluster."""
    attributes = TuyaMCUCluster.attributes.copy()

    dp_to_attribute: Dict[int, DPToAttributeMapping] = {
        103: DPToAttributeMapping(
            TuyaMCUCluster.ep_attribute,
            "cli",
        ),
        104: DPToAttributeMapping(
            TuyaIlluminanceMeasurement.ep_attribute,
            "measured_value",
            converter=lambda x: int(math.log10(x) * 10000 + 1) if x > 0 else int(0),
        ),
        105: DPToAttributeMapping(
            TuyaMmwRadarMotionAnalogInputCluster.ep_attribute,
            "present_value",
            converter=lambda x: PresenceMotionEnum(x),
            endpoint_id=2,
        ),
        106: DPToAttributeMapping(
            TuyaMmwRadarMotionSensitivity.ep_attribute,
            "present_value",
            endpoint_id=6,
            converter=lambda x: x if x < 10 else x / 10,
        ),
        107: DPToAttributeMapping(
            TuyaMmwRadarMaxRange.ep_attribute,
            "present_value",
            endpoint_id=3,
        ),
        109: DPToAttributeMapping(
            TuyaMmwRadarTargetDistanceAsPressureMeasurement.ep_attribute,
            "measured_value",
        ),
        110: DPToAttributeMapping(
            TuyaMmwRadarFadingTime.ep_attribute,
            "present_value",
            endpoint_id=5,
        ),
        111: DPToAttributeMapping(
            TuyaMmwRadarPresenceSensitivity.ep_attribute,
            "present_value",
            endpoint_id=7,
            converter=lambda x: x if x < 10 else x / 10,
        ),
        112: DPToAttributeMapping(
            TuyaOccupancySensing.ep_attribute,
            "occupancy",
        ),
    }

    data_point_handlers = {
        103: "_dp_2_attr_update",
        104: "_dp_2_attr_update",
        105: "_dp_2_attr_update",
        106: "_dp_2_attr_update",
        107: "_dp_2_attr_update",
        109: "_dp_2_attr_update",
        110: "_dp_2_attr_update",
        111: "_dp_2_attr_update",
        112: "_dp_2_attr_update",
    }

class TuyaMmwRadarOccupancy(CustomDevice):
    """Millimeter wave occupancy sensor."""

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

    signature = {
        #  endpoint=1, profile=260, device_type=81, device_version=1,
        #  input_clusters=[0, 4, 5, 61184], output_clusters=[25, 10]
        MODELS_INFO: [
            ("_TZE204_ijxvkhd0", "TS0601"),
            ("_TZE204_e5m9c5hl", "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,
                    TuyaNewManufCluster.cluster_id,
                ],
                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
            },
            242: {
                # <SimpleDescriptor endpoint=242 profile=41440 device_type=97
                # input_clusters=[]
                # output_clusters=[33]
                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.OCCUPANCY_SENSOR,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    Groups.cluster_id,
                    Scenes.cluster_id,
                    TuyaMmwRadarCluster,
                    TuyaIlluminanceMeasurement,
                    TuyaOccupancySensing,
                    TuyaMmwRadarTargetDistanceAsPressureMeasurement,
                ],
                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
            },
            2: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.OCCUPANCY_SENSOR,
                INPUT_CLUSTERS: [
                    TuyaMmwRadarMotionAnalogInputCluster,
                    TuyaMmwRadarMotionSensing,
                ],
                OUTPUT_CLUSTERS: [],
            },
            3: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [
                    TuyaMmwRadarMaxRange,
                ],
                OUTPUT_CLUSTERS: [],
            },
            5: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [
                    TuyaMmwRadarFadingTime,
                ],
                OUTPUT_CLUSTERS: [],
            },
            6: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [
                    TuyaMmwRadarMotionSensitivity,
                ],
                OUTPUT_CLUSTERS: [],
            },
            7: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [
                    TuyaMmwRadarPresenceSensitivity,
                ],
                OUTPUT_CLUSTERS: [],
            },
            242: {
                PROFILE_ID: 41440,
                DEVICE_TYPE: 97,
                INPUT_CLUSTERS: [],
                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
            },
        }
    }
mariomor commented 7 months ago

Hi @logan893 ,

it works like a charm for _TZE204_ijxvkhd0. Thanks for sharing such a pearl :) One question: how to correlate distance to target in cm's and hPA ? At first glance such units don't correlate. Can you shed some light here ?

Thank you.

ghaisasadvait commented 7 months ago

I cobbled together a custom quirk based on fixtse's quirk ( https://fixtse.com/blog/zy-m100-full-zha-support ; https://gist.githubusercontent.com/fixtse/b95753b84c34b45f49b3116d23b66342/raw/0f84bd6e9b6c174970c7b5fc21078d4b4da06a15/TZE204_ijxvkhd0_e5m9c5hl.py ) with some influence from #2525 (comment) and my own additions.

I haven't seen that there would be any "fade time remaining" parameter for _TZE204_ijxvkhd0, but I have no clue where to begin digging.

I added an Iaszone motion sensor to separately track the motion detected (seems to be a fixed fade time of roughly 5-6 seconds). Distance to target is via a pressure sensor, showing cm to target with unit hPa.

image

(this custom quirk also pasted in initial problem description)

## originally based on
### https://fixtse.com/blog/zy-m100-full-zha-support
### ( https://gist.githubusercontent.com/fixtse/b95753b84c34b45f49b3116d23b66342/raw/0f84bd6e9b6c174970c7b5fc21078d4b4da06a15/TZE204_ijxvkhd0_e5m9c5hl.py )
## with inspiration from
### https://github.com/zigpy/zha-device-handlers/pull/2525#issuecomment-1826881992
## and my own changes and additions

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

from zigpy.zcl.clusters.security import IasZone, ZoneType

from zhaquirks import Bus, LocalDataCluster

from zhaquirks.const import (
    DEVICE_TYPE,
    ENDPOINTS,
    INPUT_CLUSTERS,
    MODELS_INFO,
    OUTPUT_CLUSTERS,
    PROFILE_ID,

    CLUSTER_COMMAND,
    ZONE_STATUS_CHANGE_COMMAND,
    ON,
    OFF,
)

from zhaquirks.tuya import (
    NoManufacturerCluster,
    TuyaLocalCluster,
    TuyaNewManufCluster,
)
from zhaquirks.tuya.mcu import (
    DPToAttributeMapping,
    TuyaAttributesCluster,
    TuyaMCUCluster,
)

class TuyaMmwRadarSelfTest(t.enum8):
    """Mmw radar self test values."""
    TESTING = 0
    TEST_SUCCESS = 1
    TEST_FAILURE = 2
    OTHER = 3
    COMM_FAULT = 4
    RADAR_FAULT = 5

class PresenceMotionEnum(t.enum8):
    """Presence and motion enum."""
    NONE = 0x00
    PRESENCE = 0x01
    MOTION = 0x02

class TuyaMmwRadarTargetDistanceAsPressureMeasurement(PressureMeasurement, TuyaLocalCluster): # result in centimeteres expressed as hPa
    """Target Distance."""

class TuyaMmwRadarMotionSensitivity(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for motion sensitivity."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "motion sensitivity",
        AnalogOutput.AttributeDefs.min_present_value.id: 1,
        AnalogOutput.AttributeDefs.max_present_value.id: 9,
        AnalogOutput.AttributeDefs.resolution.id: 1,
    }

class TuyaMmwRadarPresenceSensitivity(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for presence sensitivity."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "presence sensitivity",
        AnalogOutput.AttributeDefs.min_present_value.id: 1,
        AnalogOutput.AttributeDefs.max_present_value.id: 9,
        AnalogOutput.AttributeDefs.resolution.id: 1,
    }

class TuyaMmwRadarFadingTime(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for fading time."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "fading time",
        AnalogOutput.AttributeDefs.min_present_value.id: 1,
        AnalogOutput.AttributeDefs.max_present_value.id: 1000,
        AnalogOutput.AttributeDefs.resolution.id: 1, # Resolution 1 second
        AnalogOutput.AttributeDefs.engineering_units.id: 73, # 73 defines seconds, the expected unit
    }

class TuyaMmwRadarMaxRange(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for max range."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "max detection range",
        AnalogOutput.AttributeDefs.min_present_value.id: 150, # min allowed  = 150
        AnalogOutput.AttributeDefs.max_present_value.id: 550, # max allowed  = 550
        AnalogOutput.AttributeDefs.resolution.id: 100, #resolution = 100 centermeters (snaps back to 100 cm intervals between 150 and 550)
        AnalogOutput.AttributeDefs.engineering_units.id: 118, # 118 defines centimeters, the expected unit
    }

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

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

class TuyaOccupancyMotionSensing(OccupancySensing, TuyaLocalCluster):
    """Tuya local OccupancySensing cluster for motion state."""

class TuyaMmwRadarMotionAnalogInputCluster(TuyaLocalCluster, AnalogInput):
    """Analog input cluster, only used to relay motion state information to Iaszone motion sensor."""

    cluster_id = AnalogInput.cluster_id

    def __init__(self, *args, **kwargs):
        """Init."""
        super().__init__(*args, **kwargs)

    def _update_attribute(self, attrid, value):
        super()._update_attribute(attrid, value)

        if attrid == AnalogInput.AttributeDefs.present_value.id: # 0x55 = "present_value" for AnalogInput
            if value == PresenceMotionEnum.MOTION:
                self.endpoint.device.motion_bus.listener_event("_turn_on")
            else:
                self.endpoint.device.motion_bus.listener_event("_turn_off")

class TuyaMmwRadarMotionSensing(LocalDataCluster, IasZone):
    """IasZone cluster for motion."""

    _CONSTANT_ATTRIBUTES = {
        IasZone.AttributeDefs.zone_type.id: ZoneType.Motion_Sensor, # 0x000D, # motion type
    }

    cluster_id = IasZone.cluster_id

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

    def _turn_off(self):
        self.listener_event(
            CLUSTER_COMMAND, 253, ZONE_STATUS_CHANGE_COMMAND, [OFF, 0, 0, 0]
        )

    def _turn_on(self):
        self.listener_event(
            CLUSTER_COMMAND, 254, ZONE_STATUS_CHANGE_COMMAND, [ON, 0, 0, 0]
        )

class TuyaMmwRadarCluster(NoManufacturerCluster, TuyaMCUCluster):
    """Mmw radar cluster."""
    attributes = TuyaMCUCluster.attributes.copy()

    dp_to_attribute: Dict[int, DPToAttributeMapping] = {
        103: DPToAttributeMapping(
            TuyaMCUCluster.ep_attribute,
            "cli",
        ),
        104: DPToAttributeMapping(
            TuyaIlluminanceMeasurement.ep_attribute,
            "measured_value",
            converter=lambda x: int(math.log10(x) * 10000 + 1) if x > 0 else int(0),
        ),
        105: DPToAttributeMapping(
            TuyaMmwRadarMotionAnalogInputCluster.ep_attribute,
            "present_value",
            converter=lambda x: PresenceMotionEnum(x),
            endpoint_id=2,
        ),
        106: DPToAttributeMapping(
            TuyaMmwRadarMotionSensitivity.ep_attribute,
            "present_value",
            endpoint_id=6,
            converter=lambda x: x if x < 10 else x / 10,
        ),
        107: DPToAttributeMapping(
            TuyaMmwRadarMaxRange.ep_attribute,
            "present_value",
            endpoint_id=3,
        ),
        109: DPToAttributeMapping(
            TuyaMmwRadarTargetDistanceAsPressureMeasurement.ep_attribute,
            "measured_value",
        ),
        110: DPToAttributeMapping(
            TuyaMmwRadarFadingTime.ep_attribute,
            "present_value",
            endpoint_id=5,
        ),
        111: DPToAttributeMapping(
            TuyaMmwRadarPresenceSensitivity.ep_attribute,
            "present_value",
            endpoint_id=7,
            converter=lambda x: x if x < 10 else x / 10,
        ),
        112: DPToAttributeMapping(
            TuyaOccupancySensing.ep_attribute,
            "occupancy",
        ),
    }

    data_point_handlers = {
        103: "_dp_2_attr_update",
        104: "_dp_2_attr_update",
        105: "_dp_2_attr_update",
        106: "_dp_2_attr_update",
        107: "_dp_2_attr_update",
        109: "_dp_2_attr_update",
        110: "_dp_2_attr_update",
        111: "_dp_2_attr_update",
        112: "_dp_2_attr_update",
    }

class TuyaMmwRadarOccupancy(CustomDevice):
    """Millimeter wave occupancy sensor."""

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

    signature = {
        #  endpoint=1, profile=260, device_type=81, device_version=1,
        #  input_clusters=[0, 4, 5, 61184], output_clusters=[25, 10]
        MODELS_INFO: [
            ("_TZE204_ijxvkhd0", "TS0601"),
            ("_TZE204_e5m9c5hl", "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,
                    TuyaNewManufCluster.cluster_id,
                ],
                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
            },
            242: {
                # <SimpleDescriptor endpoint=242 profile=41440 device_type=97
                # input_clusters=[]
                # output_clusters=[33]
                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.OCCUPANCY_SENSOR,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    Groups.cluster_id,
                    Scenes.cluster_id,
                    TuyaMmwRadarCluster,
                    TuyaIlluminanceMeasurement,
                    TuyaOccupancySensing,
                    TuyaMmwRadarTargetDistanceAsPressureMeasurement,
                ],
                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
            },
            2: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.OCCUPANCY_SENSOR,
                INPUT_CLUSTERS: [
                    TuyaMmwRadarMotionAnalogInputCluster,
                    TuyaMmwRadarMotionSensing,
                ],
                OUTPUT_CLUSTERS: [],
            },
            3: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [
                    TuyaMmwRadarMaxRange,
                ],
                OUTPUT_CLUSTERS: [],
            },
            5: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [
                    TuyaMmwRadarFadingTime,
                ],
                OUTPUT_CLUSTERS: [],
            },
            6: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [
                    TuyaMmwRadarMotionSensitivity,
                ],
                OUTPUT_CLUSTERS: [],
            },
            7: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [
                    TuyaMmwRadarPresenceSensitivity,
                ],
                OUTPUT_CLUSTERS: [],
            },
            242: {
                PROFILE_ID: 41440,
                DEVICE_TYPE: 97,
                INPUT_CLUSTERS: [],
                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
            },
        }
    }

This worked wonders for me!

Tovrin commented 7 months ago

Edit: nm ... PEBKAC error

thefunkygibbon commented 7 months ago

struggling to get it to pick up my model (_TZE204_qasjif9e). I've added that to the sig part of the py script and reloaded but it just keeps on picking up the same (what seems to be the default) Quirk: zhaquirks.tuya.ts0601_motion.MmwRadarMotionGPP

edit ok sorted that, it was just an extra bracket in the code which somehow got in there.

but now it works, it picks up illum values correctly (afaik) but always says clear to the presence/motion. and there doesn't appear to be a minimum distance value nor is there the exposed detected distance (not that detection is working right now). also also, the max distance seems to be locked to 550cm when it should go up to 900cm.

just seen that you mention the 24g model on yours, and mine is not. would that make a difference?

edsmith323 commented 7 months ago

I have a similar issue where the presence remains on detected and never changes to clear. The device is _TZE204_ztc6ggyl TS0601, I tried using the quirk posted, adding the device details to the signature section but get none the adjustment sliders in HA zigbee page. I'm not sure what I'm missing.

thefunkygibbon commented 6 months ago

anyone still caring about these devices?

logan893 commented 6 months ago

Hi @logan893 ,

it works like a charm for _TZE204_ijxvkhd0. Thanks for sharing such a pearl :) One question: how to correlate distance to target in cm's and hPA ? At first glance such units don't correlate. Can you shed some light here ?

Thank you.

The distance is exactly the number of cm reported from the sensor. Minimum distance seems to be 150 cm (i.e. 150 hPa with the sensor), maximum 550 cm.

logan893 commented 6 months ago

anyone still caring about these devices?

The _TZE204_qasjif9e variant is the 5.8GHz version and it behaves quite differently. This quirk of mine is for the 24GHz model only.

I also have that 5.8GHz variant with the same model as yours, and I too had problems with the default implementation. I haven't yet posted my updated quirk file that covers both, as I'm not sure it truly covers everything.

With my variant of a quirk for _TZE204_qasjif9e you get access to a trigger-delay-timer and clear-timer, you get distance sensor and a min/max sense range, sensitivity, occupancy, and illumination. There is no extra active-motion sensor like there is for the 24GHz variant. Again, I don't know if this quirk covers it all.

I'll see if I can post it later today.

logan893 commented 6 months ago

I have a similar issue where the presence remains on detected and never changes to clear. The device is _TZE204_ztc6ggyl TS0601, I tried using the quirk posted, adding the device details to the signature section but get none the adjustment sliders in HA zigbee page. I'm not sure what I'm missing.

Is _TZE204_ztc6ggyl the ceiling mounted 5.8GHz variant? If it works the same like the wall mount variant then my updated quirk file (will post soon) should work for that also.

Edit: Seems this device is quite different from the two I have, and my quirks won't work.

logan893 commented 6 months ago

@thefunkygibbon Try this one for _TZE204_qasjif9e.

## originally based on
### https://fixtse.com/blog/zy-m100-full-zha-support
### ( https://gist.githubusercontent.com/fixtse/b95753b84c34b45f49b3116d23b66342/raw/0f84bd6e9b6c174970c7b5fc21078d4b4da06a15/TZE204_ijxvkhd0_e5m9c5hl.py )
## with inspiration from
### https://github.com/zigpy/zha-device-handlers/pull/2525#issuecomment-1826881992
## and my own changes and additions

## Update: adding a quirk to support USB-C wall mount 5.8GHz variant (_TZE204_qasjif9e) based on zigbee2mqtt implementation (bonus support for _TZE204_ztqnh5cg)
## https://github.com/Koenkk/zigbee-herdsman-converters/blob/master/src/devices/tuya.ts

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 import foundation
from zigpy.zcl.clusters.general import (
    AnalogInput,
    AnalogOutput,
    Basic,
    GreenPowerProxy,
    Groups,
    Ota,
    Scenes,
    Time,
)
from zigpy.zcl.clusters.measurement import (
    IlluminanceMeasurement,
    OccupancySensing,
    PressureMeasurement,
)

from zigpy.zcl.clusters.security import IasZone, ZoneType

from zhaquirks import Bus, LocalDataCluster

from zhaquirks.const import (
    DEVICE_TYPE,
    ENDPOINTS,
    INPUT_CLUSTERS,
    MODELS_INFO,
    OUTPUT_CLUSTERS,
    PROFILE_ID,

    CLUSTER_COMMAND,
    ZONE_STATUS_CHANGE_COMMAND,
    ON,
    OFF,
)

from zhaquirks.tuya import (
    NoManufacturerCluster,
    TuyaLocalCluster,
    TuyaNewManufCluster,
)
from zhaquirks.tuya.mcu import (
    DPToAttributeMapping,
    TuyaAttributesCluster,
    TuyaMCUCluster,
)

class TuyaMmwRadarSelfTest(t.enum8):
    """Mmw radar self test values."""
    TESTING = 0
    TEST_SUCCESS = 1
    TEST_FAILURE = 2
    OTHER = 3
    COMM_FAULT = 4
    RADAR_FAULT = 5

class PresenceMotionEnum(t.enum8):
    """Presence and motion enum."""
    NONE = 0x00
    PRESENCE = 0x01
    MOTION = 0x02

class TuyaMmwRadarTargetDistanceAsPressureMeasurement(PressureMeasurement, TuyaLocalCluster): # result in centimeteres expressed as hPa
    """Target Distance."""

class TuyaMmwRadarMotionSensitivity(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for motion sensitivity."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "motion sensitivity",
        AnalogOutput.AttributeDefs.min_present_value.id: 1,
        AnalogOutput.AttributeDefs.max_present_value.id: 9,
        AnalogOutput.AttributeDefs.resolution.id: 1,
    }

class TuyaMmwRadarPresenceSensitivity(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for presence sensitivity."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "presence sensitivity",
        AnalogOutput.AttributeDefs.min_present_value.id: 1,
        AnalogOutput.AttributeDefs.max_present_value.id: 9,
        AnalogOutput.AttributeDefs.resolution.id: 1,
    }

class TuyaMmwRadarFadingTime(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for fading time."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "fading time",
        AnalogOutput.AttributeDefs.min_present_value.id: 1,
        AnalogOutput.AttributeDefs.max_present_value.id: 1000,
        AnalogOutput.AttributeDefs.resolution.id: 1, # Resolution 1 second
        AnalogOutput.AttributeDefs.engineering_units.id: 73, # 73 defines seconds, the expected unit
    }

class TuyaMmwRadarMaxRange(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for max range."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "max detection range",
        AnalogOutput.AttributeDefs.min_present_value.id: 150, # min allowed  = 150
        AnalogOutput.AttributeDefs.max_present_value.id: 550, # max allowed  = 550
        AnalogOutput.AttributeDefs.resolution.id: 100, #resolution = 100 centermeters (snaps back to 100 cm intervals between 150 and 550)
        AnalogOutput.AttributeDefs.engineering_units.id: 118, # 118 defines centimeters, the expected unit
    }

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

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

class TuyaOccupancyMotionSensing(OccupancySensing, TuyaLocalCluster):
    """Tuya local OccupancySensing cluster for motion state."""

class TuyaMmwRadarMotionAnalogInputCluster(TuyaLocalCluster, AnalogInput):
    """Analog input cluster, only used to relay motion state information to Iaszone motion sensor."""

    cluster_id = AnalogInput.cluster_id

    def __init__(self, *args, **kwargs):
        """Init."""
        super().__init__(*args, **kwargs)

    def _update_attribute(self, attrid, value):
        super()._update_attribute(attrid, value)

        if attrid == AnalogInput.AttributeDefs.present_value.id: # 0x55 = "present_value" for AnalogInput
            if value == PresenceMotionEnum.MOTION:
                self.endpoint.device.motion_bus.listener_event("_turn_on")
            else:
                self.endpoint.device.motion_bus.listener_event("_turn_off")

class TuyaMmwRadarMotionSensing(LocalDataCluster, IasZone):
    """IasZone cluster for motion."""

    _CONSTANT_ATTRIBUTES = {
        IasZone.AttributeDefs.zone_type.id: ZoneType.Motion_Sensor, # 0x000D, # motion type
    }

    cluster_id = IasZone.cluster_id

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

    def _turn_off(self):
        self.listener_event(
            CLUSTER_COMMAND, 253, ZONE_STATUS_CHANGE_COMMAND, [OFF, 0, 0, 0]
        )

    def _turn_on(self):
        self.listener_event(
            CLUSTER_COMMAND, 254, ZONE_STATUS_CHANGE_COMMAND, [ON, 0, 0, 0]
        )

class TuyaMmwRadarCluster(NoManufacturerCluster, TuyaMCUCluster):
    """Mmw radar cluster."""
    attributes = TuyaMCUCluster.attributes.copy()

    dp_to_attribute: Dict[int, DPToAttributeMapping] = {
        103: DPToAttributeMapping(
            TuyaMCUCluster.ep_attribute,
            "cli",
        ),
        104: DPToAttributeMapping(
            TuyaIlluminanceMeasurement.ep_attribute,
            "measured_value",
            converter=lambda x: int(math.log10(x) * 10000 + 1) if x > 0 else int(0),
        ),
        105: DPToAttributeMapping(
            TuyaMmwRadarMotionAnalogInputCluster.ep_attribute,
            "present_value",
            converter=lambda x: PresenceMotionEnum(x),
            endpoint_id=2,
        ),
        106: DPToAttributeMapping(
            TuyaMmwRadarMotionSensitivity.ep_attribute,
            "present_value",
            endpoint_id=6,
            converter=lambda x: x if x < 10 else x / 10,
        ),
        107: DPToAttributeMapping(
            TuyaMmwRadarMaxRange.ep_attribute,
            "present_value",
            endpoint_id=3,
        ),
        109: DPToAttributeMapping(
            TuyaMmwRadarTargetDistanceAsPressureMeasurement.ep_attribute,
            "measured_value",
        ),
        110: DPToAttributeMapping(
            TuyaMmwRadarFadingTime.ep_attribute,
            "present_value",
            endpoint_id=5,
        ),
        111: DPToAttributeMapping(
            TuyaMmwRadarPresenceSensitivity.ep_attribute,
            "present_value",
            endpoint_id=7,
            converter=lambda x: x if x < 10 else x / 10,
        ),
        112: DPToAttributeMapping(
            TuyaOccupancySensing.ep_attribute,
            "occupancy",
        ),
    }

    data_point_handlers = {
        103: "_dp_2_attr_update",
        104: "_dp_2_attr_update",
        105: "_dp_2_attr_update",
        106: "_dp_2_attr_update",
        107: "_dp_2_attr_update",
        109: "_dp_2_attr_update",
        110: "_dp_2_attr_update",
        111: "_dp_2_attr_update",
        112: "_dp_2_attr_update",
    }

class TuyaMmwRadarOccupancy(CustomDevice):
    """Millimeter wave occupancy sensor."""

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

    signature = {
        #  endpoint=1, profile=260, device_type=81, device_version=1,
        #  input_clusters=[0, 4, 5, 61184], output_clusters=[25, 10]
        MODELS_INFO: [
            ("_TZE204_ijxvkhd0", "TS0601"), # USB-C wall mounted 24GHz
            #("_TZE204_e5m9c5hl", "TS0601"), # untested (and it uses a different config according to the zigbee2mqtt implementation)
        ],
        ENDPOINTS: {
            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],
            },
            242: {
                # <SimpleDescriptor endpoint=242 profile=41440 device_type=97
                # input_clusters=[]
                # output_clusters=[33]
                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.OCCUPANCY_SENSOR,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    Groups.cluster_id,
                    Scenes.cluster_id,
                    TuyaMmwRadarCluster,
                    TuyaIlluminanceMeasurement,
                    TuyaOccupancySensing,
                    TuyaMmwRadarTargetDistanceAsPressureMeasurement,
                ],
                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
            },
            2: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.OCCUPANCY_SENSOR,
                INPUT_CLUSTERS: [
                    TuyaMmwRadarMotionAnalogInputCluster,
                    TuyaMmwRadarMotionSensing,
                ],
                OUTPUT_CLUSTERS: [],
            },
            3: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [
                    TuyaMmwRadarMaxRange,
                ],
                OUTPUT_CLUSTERS: [],
            },
            5: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [
                    TuyaMmwRadarFadingTime,
                ],
                OUTPUT_CLUSTERS: [],
            },
            6: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [
                    TuyaMmwRadarMotionSensitivity,
                ],
                OUTPUT_CLUSTERS: [],
            },
            7: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [
                    TuyaMmwRadarPresenceSensitivity,
                ],
                OUTPUT_CLUSTERS: [],
            },
            242: {
                PROFILE_ID: 41440,
                DEVICE_TYPE: 97,
                INPUT_CLUSTERS: [],
                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
            },
        }
    }

# another variant

class TuyaMmwRadarSensitivity(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for sensitivity."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "sensitivity",
        AnalogOutput.AttributeDefs.min_present_value.id: 1,
        AnalogOutput.AttributeDefs.max_present_value.id: 9,
        AnalogOutput.AttributeDefs.resolution.id: 1,
    }

class TuyaMmwRadarMinRange(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for min range."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "min_range",
        AnalogOutput.AttributeDefs.min_present_value.id: 0,
        AnalogOutput.AttributeDefs.max_present_value.id: 950,
        AnalogOutput.AttributeDefs.resolution.id: 10,
        AnalogOutput.AttributeDefs.engineering_units.id: 118,  # 31: meters
    }

class TuyaMmwRadarMaxRangeV2(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for max range."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "max_range",
        AnalogOutput.AttributeDefs.min_present_value.id: 10,
        AnalogOutput.AttributeDefs.max_present_value.id: 950,
        AnalogOutput.AttributeDefs.resolution.id: 10,
        AnalogOutput.AttributeDefs.engineering_units.id: 118,  # 31: meters
    }

class TuyaMmwRadarDetectionDelay(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for detection delay."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "detection_delay",
        AnalogOutput.AttributeDefs.min_present_value.id: 000,
        AnalogOutput.AttributeDefs.max_present_value.id: 20000,
        AnalogOutput.AttributeDefs.resolution.id: 100,
        AnalogOutput.AttributeDefs.engineering_units.id: 159,  # 73: seconds
    }

class TuyaMmwRadarFadingTimeV2(TuyaAttributesCluster, AnalogOutput):
    """AnalogOutput cluster for fading time."""

    _CONSTANT_ATTRIBUTES = {
        AnalogOutput.AttributeDefs.description.id: "fading_time",
        AnalogOutput.AttributeDefs.min_present_value.id: 2000,
        AnalogOutput.AttributeDefs.max_present_value.id: 200000,
        AnalogOutput.AttributeDefs.resolution.id: 1000,
        AnalogOutput.AttributeDefs.engineering_units.id: 159,  # 73: seconds
    }

class TuyaMmwRadarClusterBase(NoManufacturerCluster, TuyaMCUCluster):
    """Mmw radar cluster, base class."""

    attributes = TuyaMCUCluster.attributes.copy()
    attributes.update(
        {
            # Tuya attribute IDs
            0xEF01: ("occupancy", t.uint32_t, True),
            0xEF02: ("sensitivity", t.uint32_t, True),
            0xEF03: ("min_range", t.uint32_t, True),
            0xEF04: ("max_range", t.uint32_t, True),
            0xEF06: ("self_test", TuyaMmwRadarSelfTest, True),
            0xEF09: ("target_distance", t.uint32_t, True),
            0xEF65: ("detection_delay", t.uint32_t, True),
            0xEF66: ("fading_time", t.uint32_t, True),
            0xEF67: ("cli", t.CharacterString, True),
            0xEF68: ("illuminance", t.uint32_t, True),
        }
    )

class TuyaMmwRadarClusterV2(TuyaMmwRadarClusterBase):
    """Mmw radar cluster, variant 2 (5.8GHz)."""

    dp_to_attribute: Dict[int, DPToAttributeMapping] = {
        1: DPToAttributeMapping(
            TuyaOccupancySensing.ep_attribute,
            "occupancy",
        ),
        2: DPToAttributeMapping(
            TuyaMmwRadarSensitivity.ep_attribute,
            "present_value",
            endpoint_id=6,
        ),
        3: DPToAttributeMapping(
            TuyaMmwRadarMinRange.ep_attribute,
            "present_value",
            endpoint_id=2,
        ),
        4: DPToAttributeMapping(
            TuyaMmwRadarMaxRangeV2.ep_attribute,
            "present_value",
            endpoint_id=3,
        ),
        9: DPToAttributeMapping(
            TuyaMmwRadarTargetDistanceAsPressureMeasurement.ep_attribute,
            "measured_value",
        ),
        101: DPToAttributeMapping(
            TuyaMmwRadarDetectionDelay.ep_attribute,
            "present_value",
            converter=lambda x: x * 100,
            dp_converter=lambda x: x // 100,
            endpoint_id=4,
        ),
        102: DPToAttributeMapping(
            TuyaMmwRadarFadingTimeV2.ep_attribute,
            "present_value",
            converter=lambda x: x * 100,
            dp_converter=lambda x: x // 100,
            endpoint_id=5,
        ),
        104: DPToAttributeMapping(
            TuyaIlluminanceMeasurement.ep_attribute,
            "measured_value",
            converter=lambda x: int(math.log10(x) * 10000 + 1) if x > 0 else int(0),
        ),
    }

    data_point_handlers = {
        1: "_dp_2_attr_update",
        2: "_dp_2_attr_update",
        3: "_dp_2_attr_update",
        4: "_dp_2_attr_update",
        9: "_dp_2_attr_update",
        101: "_dp_2_attr_update",
        102: "_dp_2_attr_update",
        104: "_dp_2_attr_update",
    }

class TuyaMmwRadarOccupancyVariant2(CustomDevice):
    """Mini/Ceiling Human Breathe Sensor"""

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

    signature = {
        #  endpoint=1, profile=260, device_type=81, device_version=1,
        #  input_clusters=[4, 5, 61184, 0], output_clusters=[25, 10]
        MODELS_INFO: [
            ("_TZE204_qasjif9e", "TS0601"), # USB-C wall mounted 5.8GHz
            ("_TZE204_ztqnh5cg", "TS0601"), # untested with ZHA but uses same quirk as _TZE204_qasjif9e for zigbee2mqtt
        ],
        ENDPOINTS: {
            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],
            },
            242: {
                # <SimpleDescriptor endpoint=242, profile=41440, device_type=97, device_version=0, input_clusters=[], output_clusters=[33]
                # input_clusters=[]
                # output_clusters=[33]
                PROFILE_ID: zgp.PROFILE_ID,
                DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC,
                INPUT_CLUSTERS: [],
                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
            },
        },
    }

    replacement = {
        ENDPOINTS: {
            1: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.OCCUPANCY_SENSOR,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    Groups.cluster_id,
                    Scenes.cluster_id,
                    TuyaMmwRadarClusterV2,
                    TuyaIlluminanceMeasurement,
                    TuyaOccupancySensing,
                    TuyaMmwRadarTargetDistanceAsPressureMeasurement,
                ],
                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
            },
            2: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [
                    TuyaMmwRadarMinRange,
                ],
                OUTPUT_CLUSTERS: [],
            },
            3: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [
                    TuyaMmwRadarMaxRangeV2,
                ],
                OUTPUT_CLUSTERS: [],
            },
            4: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [
                    TuyaMmwRadarDetectionDelay,
                ],
                OUTPUT_CLUSTERS: [],
            },
            5: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [
                    TuyaMmwRadarFadingTimeV2,
                ],
                OUTPUT_CLUSTERS: [],
            },
            6: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE,
                INPUT_CLUSTERS: [
                    TuyaMmwRadarSensitivity,
                ],
                OUTPUT_CLUSTERS: [],
            },
            242: {
                PROFILE_ID: zgp.PROFILE_ID,
                DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC,
                INPUT_CLUSTERS: [],
                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
            },
        }
    }
thefunkygibbon commented 6 months ago

hi, ah thats great, thank you. I've just tested it and it actually works with the pressure/distance part which i just couldn't get working from hacking around with other quirks. it does seem to be all over the place though but that won't be anything to do with your code and is likely to do with the radar sensor itself. so after all of this its probably going to be a bit tricky to make any automations with the numbers it gives lol

thanks again @logan893 , you're a hero

thefunkygibbon commented 5 months ago

@logan893 sorry to be a PITA, i've just got another (ceiling, 24ghz model) which has a new model number I can't find any references to. I've tried to use your above 24ghz quirk , added the model number, it picks it up, but it says illuminance and pressure are 'unknown' and motion/presence are always 'Clear' and don't seem to pick any movement up. any ideas? or do you need to have a physical device to be able to advise?

stupidly I forgot to post the model - its _TZE204_bmdsp6bs https://www.aliexpress.com/item/1005006529472748.html

thanks

edit: i've just done a ZHA Toolbox - scan device, once whilst room lit and empty and again when i'm sitting in the room and darker... hoping that the two outputs would list different values for some of the attributes, but alas the only thing differing between the two documents is the timestamps :-( i guess i'm misunderstanding what zha toolbox is doing there then... that or the motion sensor isnt working at all

thefunkygibbon commented 5 months ago

ok so i managed to get the occupancy being reported by literally using the quirk from https://raw.githubusercontent.com/zigpy/zha-device-handlers/dev/zhaquirks/tuya/ts0601_motion.py and adding the signature to it.
Not sure how to go about getting the illuminance and the settings (max distance etc) to work at all. any ideas?

thefunkygibbon commented 5 months ago

ugh, ok so i REALLY need to be able to configure the settings on this since it seems to "clear" after no movement within like 10 seconds. it really shouldnt do that should it? i thought the whole point in radar was that it detected presence despite no movement!! :-(

logan893 commented 5 months ago

@thefunkygibbon I believe you'll need to either be very good or lucky at guessing, or have a Tuya gateway, to figure out how these new devices work. I don't have a gateway myself, so most of the quirks I posted here are based on information others have already discovered, and all things considered just a tiny bit of guesswork and trial and error.

Zigbee2MQTT has a tutorial for how to extract the device data points from logs using a Tuya gateway and the Tuya app.

https://www.zigbee2mqtt.io/advanced/support-new-devices/03_find_tuya_data_points.html

thefunkygibbon commented 2 days ago

don't suppose anyone has managed to get a decent quirk working for these devices have they? I really need to get these working else i'll have to find some other ceiling mounted, water resistant radar devices (which aren't tuya) ... and to be honest, there isn't a lot out there