Loratap Zigbee Garage Opener

Contrary to Zigbee Device Compatibility Repository (simply known as Blakkader), Loratap’s Zigbee Garage Opener (the “orange one”), also known as _TZE200_wfxuhoea1, doesn’t work with Zigbee Home Automation project (ZHA) out of the box. ZHA discovers it but doesn’t exhibit any entities which would allow to control the device.

Picture of electronic device in orange plastic enclosure

Loratap Zigbee Garage Opener

This is of course Tuya’s, the manufacturer, fault. Tuya (mis)advertises the opener as compatible only with its own Zigbee coordinator and device makes sure that this is true by deviating from the Zigbee Cluster Library Specification.

ZHA has its way of dealing with such misbehaving devices. It’s called “quirks”. It is a repository of rules which translate deviated data to the spec-adhering one and vice-versa. For this particular device there’s an issue on project’s issue where most of the work is done. I’ll spare you reading over 150 posts. For me it was enough to integrate the quirk posted in one of the recent comments. This enabled ZHA to properly discover the Loratap opener (which advertises itself as a “light switch”, with a pretty light bulb icon), together with wireless reef switch sensor for detecting gate open state.

So far everything works, but not without hiccups. The biggest one is that sensor stops reporting open/close state after ~4 hours. I’m pretty sure that it is the problem with a reef switch and not Zigbee module because it has orange LED which blinks promptly when reef switch opens or closes. Standing from the ladder, I can observe how it responds to the magnet and compare it to how it had responded 4 hours earlier.

Surprisingly, two things help:

  1. reconfiguration of Zigbee opener without touching the sensor, and
  2. pressing a button inside the sensor which makes it blink thrice.

I don’t know what else it does, besides blinking. Each of these things fixes the sensor which starts updating again… for next 4 hours.

In hope to fix the issue permanenttly I have also replaced CR2032 battery and repositioned reed switch and magnet, so they’re mutually centered. Now I’m waiting my 4 hours before the next test. I’ll report it if I don’t forget.

EDIT 2023-07-07: Apparently repositioning of magnet helped. I checked it several times this morning and now sensors reports correct status every time.

Adding a Quirk

To add a support for custom ZHA quirks, we must add the following to Home Assistant’s configuration.yaml:

zha:
  custom_quirks_path: /config/zhaquirks

Of course, /config/zhaquirks must exist. It is an absolute path which points to the /config directory, which is the default mount point for the whole HA’s configuration2.

Next we’ll create the Python file inside zhaquirks directory, let’s call it ts0601_garage.py, with the following contents3:

from typing import Dict

from zigpy.profiles import zha
from zigpy.quirks import CustomDevice
import zigpy.types as t
from zigpy.zcl.clusters.general import Basic, GreenPowerProxy, Groups, Ota, Scenes, Time
from zigpy.zcl.clusters.security import IasZone

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

from zhaquirks.tuya import NoManufacturerCluster, TuyaLocalCluster
from zhaquirks.tuya.mcu import (
    DPToAttributeMapping,
    TuyaMCUCluster,
    TuyaOnOff,
)
from zhaquirks.tuya.ts0601_dimmer import TuyaOnOffNM

ZONE_TYPE = 0x0001

class ContactSwitchCluster(TuyaLocalCluster, IasZone):
    """Tuya ContactSwitch Sensor."""

    _CONSTANT_ATTRIBUTES = {ZONE_TYPE: IasZone.ZoneType.Contact_Switch}

    def _update_attribute(self, attrid, value):
        self.debug("_update_attribute '%s': %s", attrid, value)
        super()._update_attribute(attrid, value)


class TuyaGarageManufCluster(NoManufacturerCluster, TuyaMCUCluster):
    """Tuya garage door opener."""

    attributes = TuyaMCUCluster.attributes.copy()
    attributes.update(
        {
            # ramdom attribute IDs
            0xEF02: ("dp_2", t.uint32_t, True),
            0xEF04: ("dp_4", t.uint32_t, True),
            0xEF05: ("dp_5", t.uint32_t, True),
            0xEF0B: ("dp_11", t.Bool, True),
            0xEF0C: ("dp_12", t.enum8, True),
        }
    )

    dp_to_attribute: Dict[int, DPToAttributeMapping] = {
        # garage door trigger ¿on movement, on open, on closed?
        1: DPToAttributeMapping(
            TuyaOnOffNM.ep_attribute,
            "on_off",
        ),
        2: DPToAttributeMapping(
            TuyaMCUCluster.ep_attribute,
            "dp_2",
        ),
        3: DPToAttributeMapping(
            ContactSwitchCluster.ep_attribute,
            "zone_status",
            lambda x: IasZone.ZoneStatus.Alarm_1 if x else 0,
            endpoint_id=2,
        ),
        4: DPToAttributeMapping(
            TuyaMCUCluster.ep_attribute,
            "dp_4",
        ),
        5: DPToAttributeMapping(
            TuyaMCUCluster.ep_attribute,
            "dp_5",
        ),
        11: DPToAttributeMapping(
            TuyaMCUCluster.ep_attribute,
            "dp_11",
        ),
        # garage door status (open, closed, ...)
        12: DPToAttributeMapping(
            TuyaMCUCluster.ep_attribute,
            "dp_12",
        ),
    }

    data_point_handlers = {
        1: "_dp_2_attr_update",
        2: "_dp_2_attr_update",
        3: "_dp_2_attr_update",
        4: "_dp_2_attr_update",
        5: "_dp_2_attr_update",
        11: "_dp_2_attr_update",
        12: "_dp_2_attr_update",
    }


class TuyaGarageSwitchTO(CustomDevice):
    """Tuya Garage switch."""

    signature = {
        MODELS_INFO: [
            ("_TZE200_nklqjk62", "TS0601"),
            ("_TZE200_wfxuhoea", "TS0601"),
        ],
        ENDPOINTS: {
            # <SimpleDescriptor endpoint=1 profile=260 device_type=0x0051
            # input_clusters=[0, 4, 5, 61184]
            # output_clusters=[10, 25]>
            1: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    Groups.cluster_id,
                    Scenes.cluster_id,
                    TuyaGarageManufCluster.cluster_id,
                ],
                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
            },
            # <SimpleDescriptor endpoint=242 profile=41440 device_type=97
            # input_clusters=[]
            # output_clusters=[33]
            242: {
                PROFILE_ID: 41440,
                DEVICE_TYPE: 97,
                INPUT_CLUSTERS: [],
                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
            },
        },
    }

    replacement = {
        ENDPOINTS: {
            1: {
                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    Groups.cluster_id,
                    Scenes.cluster_id,
                    TuyaGarageManufCluster,
                    TuyaOnOffNM,
                ],
                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
            },
            2: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.IAS_ZONE,
                INPUT_CLUSTERS: [
                    ContactSwitchCluster
                ],
                OUTPUT_CLUSTERS: [],
            },
            242: {
                PROFILE_ID: 41440,
                DEVICE_TYPE: 97,
                INPUT_CLUSTERS: [],
                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
            },
        },
    }

I’m totally new to ZHA internals so there’s a lot of terminology, many magic numbers and many things which I don’t understand in above code. Obviously, it is based on a quirk for some kind of Tuya’s dimmer, so some parts don’t make sense (DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT). But it works. Miraculously it works.


  1. wfxuhoea part might differ depending on time of manufacturing. 

  2. It’s the same directory where configuration.yaml resides. 

  3. I think the code is licensed under Apache 2 license, same as the whole repository.