How I got it working - WZ-M100 Tuya Presence Sensor (TS0601 _TZE204_7gclukjs)

TLDR - I bought a sensor, realised it didn’t work and spent a small fraction of my life getting it to work. I publish a short ‘how-to’ to outline the steps to get the sensor to work. I ask for some further support to get the sensor ‘out of the box’ ready.

After using a couple of the Sonoff SNZB-03’s for a short while I decided it was time for an upgrade to mmWave. I initially got the ZY-M100 wall mount version which integrated seamlessly into HA through ZHA - a great sensor.

Shortly after, I purchased the ceiling mount version (WZ-M100), punched a hole into the ceiling and wired it into the 230V mains. Only then did I decide to begin the setup process - this was quite the mistake!

The ceiling version, or at least the particular variant I had purchased, does not integrate as seamlessly as the wall mount version. In fact it does not function at all out of the box. I should note other people have had better luck using zigbee2mqtt and/or different variants of the same sensor.

After having a slight panic that I had punched a hole in the ceiling for a dud-sensor, I began trawling the forums looking for people in a similar situation - of which there are plenty. A couple hours later and I had managed to get the sensor working perfectly (minus the lux sensor which I am yet to bother with). The particular variant I had purchased isn’t as widespread as some others so I think it best to summarise my experience below. With the exception of the code, you may choose to follow the extremely helpful guide linked here.

The following guide is a collection of steps from various sources, I take no credit for any of this. This guide applies to the ZHA implementation of Zigbee and I use HA OS based on an SD card on a Raspberry Pi 4. I assume that you already have Studio Code Server (or equivalent) installed on your HA instance, and you can follow instructions (you do not need any python knowledge).

If I can do this, as a complete python novice, so can you!

Step 1: Attempt to setup the sensor BEFORE considering using a drill or any other hole making device. This is very important.
Step 2: Realise your device is not out of the box ready and have a panic proportional to the situation.
Step 3: Using Studio Code Server (or equivalent) create a folder within the config folder named ‘custom_zha_quirks’
Step 4: Within the newly created folder create a file named ‘ts0601_radar.py’. I do not know if the name of this file is particularly important, but I do know that the file extension is paramount.
Step 5: Copy the below code into the newly created file and save it. Ensure indentation is identical.

import math

from typing import Dict, Optional, Tuple, Union

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,
    Identify,
    Ota,
    Scenes,
    Time,
)
from zigpy.zcl.clusters.measurement import (
    IlluminanceMeasurement,
    OccupancySensing
)
from zigpy.zcl.clusters.security import IasZone

from zhaquirks import Bus, LocalDataCluster, MotionOnEvent
from zhaquirks.const import (
    DEVICE_TYPE,
    ENDPOINTS,
    INPUT_CLUSTERS,
    MODELS_INFO,
    MOTION_EVENT,
    OUTPUT_CLUSTERS,
    PROFILE_ID,
)

from zhaquirks.tuya import (
    NoManufacturerCluster,
    TuyaLocalCluster,
    TuyaNewManufCluster,
)
from zhaquirks.tuya.mcu import (
    # TuyaDPType,
    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 TuyaOccupancySensing(OccupancySensing, TuyaLocalCluster):
    """Tuya local OccupancySensing cluster."""

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

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

    def __init__(self, *args, **kwargs):
        """Init."""
        super().__init__(*args, **kwargs)
        self._update_attribute(
            self.attributes_by_name["description"].id, "sensitivity"
        )
        self._update_attribute(self.attributes_by_name["min_present_value"].id, 1)
        self._update_attribute(self.attributes_by_name["max_present_value"].id, 9)
        self._update_attribute(self.attributes_by_name["resolution"].id, 1)

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

    def __init__(self, *args, **kwargs):
        """Init."""
        super().__init__(*args, **kwargs)
        self._update_attribute(
            self.attributes_by_name["description"].id, "min_range"
        )
        self._update_attribute(self.attributes_by_name["min_present_value"].id, 0)
        self._update_attribute(self.attributes_by_name["max_present_value"].id, 950)
        self._update_attribute(self.attributes_by_name["resolution"].id, 10)
        self._update_attribute(
            self.attributes_by_name["engineering_units"].id, 118
        )  # 31: meters

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

    def __init__(self, *args, **kwargs):
        """Init."""
        super().__init__(*args, **kwargs)
        self._update_attribute(
            self.attributes_by_name["description"].id, "max_range"
        )
        self._update_attribute(self.attributes_by_name["min_present_value"].id, 10)
        self._update_attribute(self.attributes_by_name["max_present_value"].id, 950)
        self._update_attribute(self.attributes_by_name["resolution"].id, 10)
        self._update_attribute(
            self.attributes_by_name["engineering_units"].id, 118
        )  # 31: meters

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

    def __init__(self, *args, **kwargs):
        """Init."""
        super().__init__(*args, **kwargs)
        self._update_attribute(
            self.attributes_by_name["description"].id, "detection_delay"
        )
        self._update_attribute(self.attributes_by_name["min_present_value"].id, 000)
        self._update_attribute(self.attributes_by_name["max_present_value"].id, 20000)
        self._update_attribute(self.attributes_by_name["resolution"].id, 100)
        self._update_attribute(
            self.attributes_by_name["engineering_units"].id, 159
        )  # 73: seconds

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

    def __init__(self, *args, **kwargs):
        """Init."""
        super().__init__(*args, **kwargs)
        self._update_attribute(
            self.attributes_by_name["description"].id, "fading_time"
        )
        self._update_attribute(self.attributes_by_name["min_present_value"].id, 2000)
        self._update_attribute(self.attributes_by_name["max_present_value"].id, 200000)
        self._update_attribute(self.attributes_by_name["resolution"].id, 1000)
        self._update_attribute(
            self.attributes_by_name["engineering_units"].id, 159
        )  # 73: seconds

class TuyaMmwRadarTargetDistance(TuyaAttributesCluster, AnalogInput):
    """AnalogInput cluster for target distance."""

    def __init__(self, *args, **kwargs):
        """Init."""
        super().__init__(*args, **kwargs)
        self._update_attribute(
            self.attributes_by_name["description"].id, "target_distance"
        )
        self._update_attribute(
            self.attributes_by_name["engineering_units"].id, 118
        )  # 31: meters

class TuyaMmwRadarCluster(NoManufacturerCluster, TuyaMCUCluster):
    """Mmw radar cluster."""
    attributes = TuyaMCUCluster.attributes.copy()
    attributes.update(
        {
            # ramdom 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),
            0xEF09: ("target_distance", t.uint32_t, True),
            0xEF65: ("detection_delay", t.uint32_t, True),
            0xEF66: ("fading_time", t.uint32_t, True),
            0xEF68: ("illuminance", t.uint32_t, True),
        }
    )

    dp_to_attribute: Dict[int, DPToAttributeMapping] = {
        1: DPToAttributeMapping(
           TuyaOccupancySensing.ep_attribute,
           "occupancy",
        ),
        2: DPToAttributeMapping(
           TuyaMmwRadarSensitivity.ep_attribute,
           "present_value",
        ),
        3: DPToAttributeMapping(
           TuyaMmwRadarMinRange.ep_attribute,
           "present_value",
           endpoint_id=2,
        ),
        4: DPToAttributeMapping(
           TuyaMmwRadarMaxRange.ep_attribute,
           "present_value",
           endpoint_id=3,
        ),
        9: DPToAttributeMapping(
            TuyaMmwRadarTargetDistance.ep_attribute,
            "present_value",
        ),
        101: DPToAttributeMapping(
            TuyaMmwRadarDetectionDelay.ep_attribute,
            "present_value",
            converter=lambda x: x * 100,
            dp_converter=lambda x: x // 100,
            endpoint_id=4,
        ),
        102: DPToAttributeMapping(
            TuyaMmwRadarFadingTime.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 TuyaMmwRadarOccupancy(CustomDevice):
    """Millimeter wave occupancy sensor."""
    signature = {
        #  endpoint=1, profile=260, device_type=81, device_version=1,
        #  input_clusters=[4, 5, 61184, 0], output_clusters=[25, 10])
        MODELS_INFO: [
            ("_TZE204_7gclukjs", "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,
                    TuyaMmwRadarTargetDistance,
                    TuyaMmwRadarSensitivity,
                ],
                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: [
                    TuyaMmwRadarMaxRange,
                ],
                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: [
                    TuyaMmwRadarFadingTime,
                ],
                OUTPUT_CLUSTERS: [],
            },
            242: {
                PROFILE_ID: 41440,
                DEVICE_TYPE: 97,
                INPUT_CLUSTERS: [],
                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
            },
        }
    }

Step 6: Add the following code to your configuration.yaml entry, again ensuring correct indentation.

zha:
  custom_quirks_path: /config/custom_zha_quirks

Step 7: Restart Home Assistant.
Step 8: Sigh relief that the sensor now works!
Step 9: Realise you now need to actually install the sensor in a suitable location and do that. Exercise caution!

The resources I used to get the sensor working were;
Fixt’s guide on the ZY-M100 sensor. A big thank you to fixt!
This forum
The code was published by KLM30330 over on GitHub.

It would be beneficial if this code (with some alterations) could be included with ZHA as standard so the ‘kjs’ variant works out of the box for future buyers. How can I go about doing this?
The lux sensor outputs either 0 or 1, therefore the sensor is not fully operational as someone might expect it to be - could someone with a smidge more python experience help me out with getting this to output something useful?

1 Like

That is fantastic… thank you for this. I will give it a go this week.

So, I was playing with this today for a bit and the only data coming from the sensor is a 1 or 0. I started by replacing this Class just to see if a value could be written there:

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

    def _update_attribute(self, attrid, value):
        """Update attribute with conversion if necessary."""
        if attrid == self.attributes_by_name["measured_value"].id:
            # Log the raw value received
            logger.debug(f"Raw illuminance value: {value}")
            
            # Test with a manual value
            value = max(1, value)
            if value == 1:
                value = 1000  # Set a manual test value
                logger.debug(f"Manual test illuminance value: {value}")
                
            value = int(math.log10(value) * 10000 + 1)
            logger.debug(f"Converted illuminance value: {value}")
                
        super()._update_attribute(attrid, value)

The value writes fine. I decided to bring ChatGPT onto the team and after writing some custom logging and several tests, we’re just not getting anything other than a 1 or 0 from the sensor.

Something strange to note though, looking at the sensor history in HA, mine is showing yesterday it WAS working for a short time but then stopped. It was nothing I did… just started on its own. Although, I DID just update HA to the latest either yesterday or day before, can’t remember. Now after all this testing, I can’t see that history showing where it WAS breifly working. Oh well… for FUN, open ChatGPT up and paste all your driver code there. It will walk you thru a bunch of troubleshooting you can do yourself.