private@homelab: ~/latest
local-first guides · privacy-aware · no noisy tracking
private@homelab:~$ cat guides/article.md
·
· ·
8–13 minutes
read

Writing a Custom ZHA Quirk for an Unsupported Zigbee Device

Your Zigbee device pairs with ZHA but exposes only generic entities. How to enable custom quirks and write one with QuirkBuilder, entirely locally.

A Zigbee device that pairs cleanly with ZHA but shows up with a generic entity or two, missing attributes, or an unrecognized-cluster warning in the log isn’t broken. It’s a device ZHA doesn’t have a quirk for, or one whose quirk doesn’t cover the attribute you actually want. The fix is a custom quirk, a local Python file that tells zigpy how to interpret the device’s clusters correctly, with no cloud lookup or manufacturer support ticket involved.

This walks through the actual mechanics: enabling custom quirks in ZHA, writing one with the current QuirkBuilder API, where Tuya devices break the pattern, and a documented conflict that catches people who assume a custom quirk always overrides a built-in one. Everything here runs on your Home Assistant instance and zigpy’s local device-handler library. Nothing leaves the network to make a quirk work.

When you actually need a custom quirk

Re-pairing or restarting Home Assistant fixes a surprising number of “missing entities” reports, and it’s worth ruling those out first. A half-finished device interview looks identical to a genuine quirk gap until you’ve retried it.

A custom quirk is the actual fix when the device is fully paired, responding to commands, and still exposing only generic entities or an incomplete attribute set. That’s the signature of a manufacturer deviating from the Zigbee Cluster Library (ZCL) spec in a way zigpy’s built-in device handlers don’t already account for: a nonstandard cluster, an attribute reported under the wrong ID, or manufacturer-specific data zigpy has no mapping for. Quirks exist specifically to bridge that gap between what the device sends and what a standards-compliant ZCL client expects.

Note · before you start

Check the device page in the zha-device-handlers repository first. If a built-in quirk already exists for your exact model and manufacturer ID, the fix is usually updating ZHA’s device-handler dependency, not writing a new quirk.

Enabling custom quirks in ZHA

Custom quirks are off by default and live outside the zha-device-handlers package itself, so ZHA needs to be told two things: that custom quirks are allowed at all, and where to find them. Both live under the zha: integration block in configuration.yaml.

configuration.yaml
zha:
  enable_quirks: true
  custom_quirks_path: /config/zha_quirks/

custom_quirks_path points at a local directory (representative path shown, use whatever your Home Assistant config structure actually maps to) containing one or more Python files. The directory has to exist before Home Assistant starts, and each file registers its quirk with zigpy on load. Restart Home Assistant after adding a new quirk file or changing this config. There’s no hot-reload for quirks, and that restart is also your first debugging tool if something doesn’t take.

Quirks V2: the QuirkBuilder pattern

The current recommended way to write a quirk is “Quirks V2,” built around the QuirkBuilder class, a fluent method-chaining API that replaces the older subclass-based v1 quirk format. Where a v1 quirk meant subclassing a device class and manually redefining its cluster and endpoint structure, QuirkBuilder lets you describe only the deltas: which cluster to replace, and what to expose from it.

A representative pattern from the ecosystem’s own documentation:

zha_quirks/example_quirk.py
from zigpy.quirks.v2 import QuirkBuilder

(
    QuirkBuilder("ManufacturerName", "ModelString")
    .replaces(SomeCluster)
    .sensor("attribute_name", cluster_id)
    .add_to_registry()
)

That chain replaces the device’s existing cluster handling for SomeCluster and exposes one of its attributes as a Home Assistant sensor entity. QuirkBuilder supports chaining additional .sensor(), .switch(), .binary_sensor(), and similar calls for devices that need more than one entity fixed, but the pattern is the same throughout. Identify the manufacturer and model strings exactly as ZHA reports them, then describe what the built-in handling gets wrong.

The Tuya complication: datapoints instead of ZCL attributes

The .replaces() and .sensor() chain above assumes the device is communicating in standard ZCL, with attributes on recognizable clusters even when they’re mismapped. Many Tuya Zigbee devices aren’t doing that at all. Tuya devices send their state as datapoints (DPs) on a Tuya-specific cluster, 0xEF00, and those datapoints don’t appear in the device signature ZHA shows you. That’s the reason the value you want can be missing even though the device is obviously reporting something.

Tuya quirks are therefore built with a dedicated builder class, TuyaQuirkBuilder, rather than the plain QuirkBuilder shown above. It extends the v2 builder, so the standard methods stay available, and it adds Tuya-specific ones (.tuya_dp, .tuya_dp_attribute, .tuya_attribute) that map a datapoint ID to an attribute name and a datatype. The first task on a new Tuya device is working out which DP carries which value and what type each one uses, since none of that mapping can be written until you know. The project’s own Tuya reference documents both the builder and the DP-discovery process. If you’re quirking a Tuya-branded device and an attribute-to-cluster mapping doesn’t produce the entity you expect, that’s the first place to look, ahead of hunting for a syntax mistake in the plainer pattern.

A known gap: conflicts with built-in quirks

Warning

zigpy cannot currently load a custom v2 quirk for a device that already has a matching built-in v2 quirk shipped inside zha-device-handlers. The result is a MultipleQuirksMatchException, not the custom quirk cleanly overriding the built-in one. Quirks written in the older v1 format are not affected by this.

This matters if your plan is to write a custom quirk to fix or extend a device that zha-device-handlers already partially supports. The intuitive assumption, that a custom quirk in custom_quirks_path takes priority over the package’s own handler for the same device, doesn’t hold. The tracking issue is open, and the workaround discussed on it is to explicitly remove the built-in quirk from zigpy’s registry inside your own quirk file before registering yours.

Honestly, I’d treat that workaround as a development and testing aid rather than a permanent setup. It leaves you maintaining a local file whose whole job is to disable a file the project ships and updates. If the built-in quirk is wrong or incomplete for your device, contributing the fix upstream is the version that survives your next update.

Debugging a quirk that won’t load

There’s no dedicated validation tool for a custom quirk file. No lint step, no dry-run command that checks it before Home Assistant tries to load it. The documented approach is more direct: restart Home Assistant, then check home-assistant.log for the load attempt. A successful load is visible there, with Home Assistant reporting that it loaded custom quirks from the configured path. A syntax error, an incorrect manufacturer or model string, or an import problem in the quirk file shows up in the same place as a load failure rather than a silent no-op.

Note · debugging

If a quirk “does nothing” after a restart, confirm first that the manufacturer and model strings in the `QuirkBuilder` call match exactly what ZHA reports for the device (check the device’s page in the ZHA integration UI). A mismatched string is a quieter failure than a syntax error and won’t always throw an obvious log entry.

ZHA quirk vs. Zigbee2MQTT converter: which path fits your setup

Both mechanisms solve the same underlying problem, a device that pairs but doesn’t expose everything it should, but they belong to different Zigbee stacks and aren’t interchangeable. If you’re weighing ZHA against Zigbee2MQTT specifically because of unsupported-device handling, the practical differences below are what actually carry over between platforms.

ZHA custom quirk Zigbee2MQTT external converter
Written in Python (zigpy quirks API) JavaScript (zigbee-herdsman-converters format)
Current recommended pattern Quirks V2 / QuirkBuilder External converter file per device
Tuya-specific path TuyaQuirkBuilder + datapoint methods tuyaDatapoints in the converter definition
Enable step enable_quirks + custom_quirks_path in configuration.yaml External converter path setting in Zigbee2MQTT config
Validation tooling None. Check the Home Assistant log after restart Zigbee2MQTT logs at start/reload
Known built-in conflict Yes. MultipleQuirksMatchException when a built-in v2 quirk already matches Not the same failure mode; a separate concept

Neither path is objectively easier across every device. It depends on which stack you’re already running and how far a given device deviates from spec. If you’re not yet committed to either integration and unsupported-device handling is a deciding factor, the general framing of quirks versus converters (without the write-your-own mechanics) is covered separately. If your device is specifically a Tuya TS0601-family device and you’re leaning Zigbee2MQTT instead, the converter-side equivalent of this guide walks through writing that from the Z2M side. And if Zigbee2MQTT reports “no converter available” for a device you haven’t decided how to handle yet, that’s a separate, earlier-stage problem.

FAQ

Do I need a custom quirk for every unsupported Zigbee device in ZHA?
No. Re-pairing or restarting Home Assistant resolves a meaningful share of “missing entities” cases that look like quirk gaps but are actually an incomplete device interview. A custom quirk is the fix only once the device is confirmed fully paired and responsive and still exposing incomplete or generic entities.

What’s the difference between a ZHA quirk and a Zigbee2MQTT external converter?
Both solve the same class of problem, a device pairing without exposing everything it should, but they’re written in different languages for different Zigbee stacks (Python/zigpy for ZHA quirks, JavaScript/zigbee-herdsman-converters for Zigbee2MQTT converters) and aren’t portable between them.

Where do I put a custom quirk file, and how does ZHA find it?
In the directory set as custom_quirks_path in the zha: block of configuration.yaml, alongside enable_quirks: true. That directory needs to exist before Home Assistant starts. ZHA loads quirk files from that path on startup, and there’s no hot-reload, so a restart is required after adding or changing a file.

Why isn’t my custom quirk loading even though the syntax looks correct?
Check home-assistant.log after a restart, which is the documented way to catch load failures given there’s no dedicated quirk-validation tool. A mismatched manufacturer or model string is a common and quieter cause than an outright syntax error, so confirm both strings match what ZHA reports for the device before assuming the quirk logic itself is wrong. If the device already has a built-in v2 quirk, check for a MultipleQuirksMatchException as well.

Can I write a quirk for a Tuya device the same way as a standard ZCL-compliant device?
Not usually. Many Tuya Zigbee devices communicate through proprietary datapoint (DP) messages on Tuya cluster 0xEF00 rather than standard ZCL clusters, and those datapoints don’t show up in the device signature. Tuya quirks are written with TuyaQuirkBuilder, which extends the v2 builder with datapoint methods (.tuya_dp, .tuya_dp_attribute, .tuya_attribute) for mapping a DP to an attribute and a type.

What this piece verified

This is a research-synthesis piece. No physical device was paired or bench-tested for this article, and there’s no quirk-writing claim above that depends on hands-on testing, consistent with this cluster’s standard voice for protocol and tooling content sourced from docs and issue trackers.

Verified directly from source: the enable_quirks and custom_quirks_path configuration keys, the requirement that the directory exist and that Home Assistant be restarted, and the general quirk-need diagnosis, from the Home Assistant Community’s ZHA custom-quirks setup thread and the zha-device-handlers repository. The QuirkBuilder and Quirks V2 pattern, plus the absence of dedicated quirk-validation tooling, from the zigpy/zha-device-handlers community discussion on writing a quirk (#4339). The Tuya datapoint mechanism, cluster 0xEF00, the datapoints-not-in-signature behavior, and TuyaQuirkBuilder with its .tuya_dp / .tuya_dp_attribute / .tuya_attribute methods, from zha-device-handlers’ own Tuya reference and project wiki. The built-in-quirk conflict producing MultipleQuirksMatchException, that it does not affect v1-format quirks, and the registry-removal workaround, from zigpy issue #1508.

What still depends on upstream: whether zigpy resolves the built-in-versus-custom v2 quirk conflict is unresolved while that issue stays open, so check it before assuming a custom quirk will cleanly override a built-in one for your device. The exact set of entity types QuirkBuilder supports beyond .sensor() (switch, binary_sensor, and others) tracks the zigpy and zha-device-handlers release you’re running, so check your installed version’s documentation rather than assuming parity with the pattern shown here. And if you write a quirk against the pattern in this piece, verify it loads by checking home-assistant.log after a restart rather than assuming clean syntax means a clean load.

local-firstHome Assistantno-cloud