Important
The following should work for most Tuya devices. Some devices may require additional reverse engineering to unlock all functions.
The first step in building a Tuya quirk is to identify the Tuya Datapoints (DPs) for the device. There are two ways ways to do this.
- If the device is supported by Zigbee2MQTT, the DPs can be captured from the herdsman converter.
- Using a Tuya hub, the DPs can be captured from the Tuya developer's console. See Zigbee2MQTT Documentation
Once the DPs are identified, the quirk can be built. For each DP, identify the correct replacement for the quirk using the available methods below. For commonly used replacements, such as a power configuration cluster, we can use a convenience method, such as .tuya_battery
.
Note
Convenience methods will only work once, as these methods internally map Tuya datapoints to standard-compliant ZCL attributes.
If your device has multiple identical clusters, such as OnOff
, use multiple .tuya_switch
calls instead to expose custom entities in Home Assistant. Otherwise, only one switch will be exposed.
For more complex replacements you may need to use a lower level method, such as .tuya_dp_attribute
or even .tuya_dp
and .tuya_attribute
.
All v2 QuirkBuilder methods are available, so using .tuya_dp
to add a DP converter, then .adds
to add the correct class is valid.
Most v2 quirks will match only on the model and manufacturer. This reduces duplicated code where a new variant appears with a slightly different signature. Should you need to filter on a signature as well, use .filter
.
from zigpy.quirks import signature_matches
.filter(signature_matches(device_signature))
Once the quirk is complete, enable custom quirks and test. See Configuration - YAML in ZHA documentation.
from zhaquirks.tuya.builder import TuyaQuirkBuilder
(
TuyaQuirkBuilder("_TZE200_bjawzodf", "TS0601")
.applies_to("_TZE200_zl1kmjqx", "TS0601")
.tuya_temperature(dp_id=1, scale=10)
.tuya_humidity(dp_id=2, scale=10)
.tuya_battery(dp_id=4)
.skip_configuration()
.add_to_registry()
)
TuyaQuirkBuilder is a subclass of QuirkBuilder, retaining all of the v2 QuirkBuilder methods and adding Tuya specific methods.
These methods allow exposing the most common Tuya clusters. These methods were added as part of the quirk building process and it is likely that there are other convenience methods that should be created. If you find that you are repeating the .tuya_dp
and .adds
formula, please PR or suggest additional methods.
tuya_battery(dp_id: int, power_cfg: PowerConfiguration = TuyaPowerConfigurationCluster2AAA, scale: float = 2)
Adds a battery power cluster.
.tuya_battery(dp_id=2, power_config=TuyaPowerConfigurationCluster4AAA)
Adds a metering cluster.
.tuya_metering(dp_id=3)
Adds an on/off cluster.
.tuya_onoff(dp_id=4)
Adds a humidity cluster.
.tuya_humidity(dp_id=5)
Adds a soil moisture cluster.
.tuya_soil_moisture(dp_id=6, scale=10)
tuya_temperature(dp_id: int, temp_cfg: TuyaLocalCluster = TuyaTemperatureMeasurement, scale: float = 10)
Adds a temperature cluster.
.tuya_temperature(dp_id=7)
These methods expose an entity to Home Assistant. The following examples do not cover all available arguments, nor are they listed here to help keep this documentation accurate. For available arguments on each method see TuyaQuirkBuilder.
Adds a switch entity.
.tuya_switch(
dp_id=1,
attribute_name="valve_on_off_1",
entity_type=EntityType.STANDARD,
translation_key="valve_on_off_1",
fallback_name="Valve 1",
)
Adds an enum entity.
Note: In the Tuya developer console, these will appear to be string enums. I have yet to run into a string enum, so assume that they are t.enum8
.
class GiexBatteryStatus(t.enum8):
"""Giex Soil Battery Status Enum."""
Low = 0x00
Middle = 0x01
High = 0x02
.tuya_enum(
dp_id=14,
attribute_name="battery_status",
enum_class=GiexBatteryStatus,
translation_key="battery_status",
fallback_name="Battery Status",
entity_type=EntityType.DIAGNOSTIC,
entity_platform=EntityPlatform.SENSOR,
initially_disabled=True,
)
Adds a number entity.
.tuya_number(
dp_id=13,
attribute_name="valve_countdown_1",
type=t.uint16_t,
device_class=SensorDeviceClass.DURATION,
unit=UnitOfTime.MINUTES,
min_value=0,
max_value=1440,
step=1,
translation_key="valve_countdown_1",
fallback_name="Irrigation time 1",
)
Adds a binary sensor entity.
.tuya_binary_sensor(
dp_id=8,
attribute_name="system_online",
translation_key="system_online",
fallback_name="System online",
)
Adds a sensor entity. Sensors can't return string values, you also need to ensure the return type matches the device_class.
.tuya_sensor(
dp_id=25,
attribute_name="valve_duration_1",
type=t.uint32_t,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.DURATION,
unit=UnitOfTime.SECONDS,
entity_type=EntityType.STANDARD,
translation_key="irrigation_duration_1",
fallback_name="Irrigation duration 1",
)
Some Tuya DPs will return an incompatible type, this example returns a string value that must be converted via a function.
def giex_string_to_td(v: str) -> int:
"""Convert Giex String Duration to seconds."""
dt = datetime.strptime(v, "%H:%M:%S,%f")
return timedelta(hours=dt.hour, minutes=dt.minute, seconds=dt.second).seconds
.tuya_sensor(
dp_id=114,
attribute_name="irrigation_duration",
type=t.uint32_t,
converter=lambda x: giex_string_to_td(x),
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.DURATION,
unit=UnitOfTime.SECONDS,
translation_key="irrigation_duration",
fallback_name="Last irrigation duration",
)
Adds a DP converter.
.tuya_dp(
dp_id=4,
ep_attribute=TuyaPowerConfigurationCluster2AAA.ep_attribute,
attribute_name="battery_percentage_remaining",
converter=lambda x: {0: 50, 1: 100, 2: 200}[x],
)
Add an Attribute definition
.tuya_attribute(
dp_id=4,
attribute_name="irrigation_mode",
type=t.Bool,
)
Add a DP converter and corresponding Attribute definition.
.tuya_dp_attribute(
dp_id=1,
attribute_name="irrigation_mode",
type=t.Bool,
)
To get a device from a v2 Quirk, use zigpy_device_from_v2_quirk
.
async def test_tuya():
"""Example Tuya Test."""
quirked = zigpy_device_from_v2_quirk(model, manuf)
ep = quirked.endpoints[1]
temperature_listener = ClusterListener(ep.temperature)
assert ep.tuya_manufacturer is not None
assert isinstance(ep.tuya_manufacturer, TuyaMCUCluster)
message = b"\x09\xe0\x02\x0b\x33\x01\x02\x00\x04\x00\x00\x00\xfd\x02\x02\x00\x04\x00\x00\x00\x47\x04\x02\x00\x04\x00\x00\x00\x64"
hdr, data = ep.tuya_manufacturer.deserialize(message)
status = ep.tuya_manufacturer.handle_get_data(data.data)
assert status == foundation.Status.SUCCESS
assert len(temperature_listener.attribute_updates) == 1
assert (
temperature_listener.attribute_updates[0][1]
== data.data.datapoints[0].data.payload * temp_scale
)