-
Notifications
You must be signed in to change notification settings - Fork 878
Added a guide on writing V2 quirks #4349
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
CalamityDeadshot
wants to merge
4
commits into
zigpy:dev
Choose a base branch
from
CalamityDeadshot:dev
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
6763658
Added a guide on writing V2 quirks
CalamityDeadshot 8077717
Fixed spelling :P
CalamityDeadshot 753b8e6
Removed enable_quirks config option in section 2, addessing puddly's …
CalamityDeadshot 45b772b
Fixed section 3 image formatting
CalamityDeadshot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,306 @@ | ||
### Prerequisites | ||
- You have read this repository's [readme file](README.md), at least vaguely familiarizing yourself with the concepts of `endpoints`, `clusters` and `attributes`. | ||
- You have basic knowledge of the Python programming language, or are at least confident enough in your ability to learn how to use it. It's nothing particularly scary. | ||
- Any devices, be it your HA host, or a quirky device, belong to you. | ||
|
||
## 0. A simplified overview. | ||
Any **device** has one or more **endpoints**. Any endpoint has one or more **clusters** of functionality, and any cluster has a number of **attributes**, which can be readable, writable, reportable or any combination of the three. Any attribute has an ID, a name, a data type, and a value. The value is what we're generally after. | ||
|
||
## 1. Discovering what a device hides. | ||
Before attempting to surface any attributes to ZHA, making it available as state or configuration in Home Assistant, you need to know **where** the attribute in question lives: which **ID** of which **cluster** of which **endpoint**. | ||
|
||
A simple way to view that info is through the HA's Device info page: | ||
|
||
<img width="531" height="604" alt="image" src="https://github.com/user-attachments/assets/6393b9cb-46ed-4cca-9188-df86776cf601" /> | ||
|
||
Then, in the `Clusters` tab, you can click through every cluster's attributes. To view any attribute's value, just press the accent `Read attribute` button. | ||
|
||
|
||
<img width="542" height="578" alt="image" src="https://github.com/user-attachments/assets/2dcee89f-c9d5-445a-953b-ff8ffc902e14" /> | ||
|
||
In this example, the device is an air quality monitor capable of measuring VOCs in the air and reporting it as an index (an integer in [1, 500]). This value is accessible through endpoint `3`, cluster `0x000c` (`AnalogInput`), attribute `0x0055` (`present_value`). | ||
|
||
If you can find all the values you are interested in via this process, you're in luck and can jump straight to section 2. But chances are, you can't find _everything_ this way. If it is the case, you will need to do the following: | ||
1. Install and activate the [ZHA Toolkit](https://github.com/mdeweerd/zha-toolkit) integration (just follow the README instructions). | ||
2. Go to Developer tools -> Actions. You'll be using the [scan_device](https://github.com/mdeweerd/zha-toolkit?tab=readme-ov-file#scan_device-scan-a-deviceread-all-attribute-values) service call of the integration. Be sure to read what it does! | ||
Your action yaml should be looking like this: | ||
```yaml | ||
service: zha_toolkit.scan_device | ||
data: | ||
ieee: 00:12:4b:00:2c:85:6f:56 # Find the IEEE of your device on the Device info page. | ||
event_success: scan_device_success # The name of a success event. | ||
event_fail: scan_device_failure # The name of a failure event. | ||
``` | ||
3. Perform the action and wait for a response. This may take some time, so be patient. We'll be assuming the happy path here, so if this action fails for you, consult the integration's readme. | ||
4. In the response, we're interested in the `scan` object, as it contains everything ZHA Toolkit was able to find about the device. It has the following structure: | ||
<details> | ||
<summary>A shortened version of the scan object</summary> | ||
|
||
```yaml | ||
scan: | ||
ieee: your ieee | ||
nwk: your nwk | ||
model: your model name | ||
manufacturer: your manufacturer name | ||
manufacturer_id: "0x0" | ||
endpoints: | ||
- id: 4 | ||
# ... | ||
- id: 3 | ||
device_type: "0x000c" | ||
profile: "0x0104" | ||
in_clusters: | ||
"0x000c": | ||
cluster_id: "0x000c" | ||
title: AnalogInput | ||
name: analog_input | ||
attributes: | ||
"0x0055": | ||
attribute_id: "0x0055" | ||
attribute_name: present_value | ||
value_type: | ||
- "0x39" | ||
- Single | ||
- Analog | ||
access: READ|REPORT | ||
access_acl: 5 | ||
attribute_value: 100 | ||
"0x0221": | ||
attribute_id: "0x0221" | ||
attribute_name: "545" | ||
value_type: | ||
- "0x21" | ||
- uint16_t | ||
- Analog | ||
access: READ|WRITE | ||
access_acl: 3 | ||
attribute_value: 500 | ||
"0x0222": | ||
attribute_id: "0x0222" | ||
attribute_name: "546" | ||
value_type: | ||
- "0x21" | ||
- uint16_t | ||
- Analog | ||
access: READ|WRITE | ||
access_acl: 3 | ||
attribute_value: 0 | ||
"0x0225": | ||
attribute_id: "0x0225" | ||
attribute_name: "549" | ||
value_type: | ||
- "0x10" | ||
- Bool | ||
- Discrete | ||
access: READ|WRITE | ||
access_acl: 3 | ||
attribute_value: 0 | ||
out_clusters: {} | ||
- id: 2 | ||
# ... | ||
- id: 1 | ||
# ... | ||
``` | ||
</details> | ||
|
||
Look at that! Our attribute `present_value` of the endpoint `3`, cluster `0x000c` (`analog_input`) is there, but so are other attributes we haven't seen before: `0x0221`, `0x0222` and `0x0225`. They don't have a `name`, like `present_value` does, their name is just a decimal representation of their ID, and they won't show up in the "Manage Zigbee device" popup. This is the kind of attributes we'll be hunting for in our quirk. | ||
|
||
Be sure to save this data somewhere safe, as you'll be frequently consulting it when developing the quirk. | ||
|
||
At this point you need to assign meaning to the attributes, noting the ones you want and discarding the ones you don't want. If you do not have any documentation on the device, try to find a zigbee-herdsman converter for it (it's basically the same as a quirk, but for the Zigbee2MQTT integration). It looks something like [this](https://github.com/smartboxchannel/EFEKTA-AQ-Smart-Monitor-Gen2/blob/main/z2m_converter/AQSM_G2.js). From it you can probably collect some relevant semantics for the attributes and assigning meaning will become a lot simpler. | ||
|
||
Otherwise, you are on your own, and you are now a reverse engineer. Try doing something to the device, seeing which attributes change and deducing their meaning based on the change. | ||
|
||
## 2. Enabling quirks. | ||
1. Go to your instance's `configuration.yaml` file and add the following configuration: | ||
```yaml | ||
zha: | ||
enable_quirks: true | ||
custom_quirks_path: your/quirks/dir/ | ||
``` | ||
Where `your/quirks/dir/` is the path to the directory where you'll be placing your quirks. If this string doesn't start with a slash, the path is relative to the directory the `configuration.yaml` file is contained in. Create the directory if necessary. | ||
|
||
## 3. Writing a quirk. | ||
We'll be using the Quirks V2 API, as it is far more user-friendly and powerful. In your quirks directory, create a Python file (`.py` extension) with a descriptive name, e.g. `<manufacturer>_<device>_quirk.py`. | ||
|
||
The quirks V2 API starts with the call to a `QuirksBuilder` constructor, and ends with a call to its method `add_to_registry()`: | ||
```python | ||
from zigpy.quirks.v2 import QuirkBuilder | ||
( | ||
QuirkBuilder("your manufacturer name", "your device name") | ||
# interesting stuff in between | ||
.add_to_registry() | ||
) | ||
``` | ||
Pay special attention to the arguments of the constructor (manufacturer name, device name), as the device will be matched against that to decide whether to apply the quirk or not. Also notice the import at the beginning of a file - you can't use something without importing it first. Here, the `QuirkBuilder` is imported from [this file](https://github.com/zigpy/zigpy/blob/dev/zigpy/quirks/v2/__init__.py#L578). See examples of other quirks, or use the "search in repository" function if unsure where you need to import something from. | ||
|
||
### 3.1. Exposing a supproted attribute. | ||
Now, for already available attributes (the ones with non-numerical names) the process of exposing them to ZHA is relatively simple: | ||
```python | ||
from zigpy.quirks.v2 import ( | ||
QuirkBuilder, | ||
ReportingConfig, | ||
SensorDeviceClass, | ||
SensorStateClass | ||
) | ||
from zigpy.zcl.clusters.general import AnalogInput | ||
( | ||
QuirkBuilder("your manufacturer name", "your device name") | ||
.sensor( | ||
attribute_name="present_value", | ||
cluster_id=AnalogInput.cluster_id, # 0x000c works too | ||
endpoint_id=3, | ||
state_class=SensorStateClass.MEASUREMENT, | ||
device_class=SensorDeviceClass.AQI, | ||
reporting_config=ReportingConfig( | ||
min_interval=10, max_interval=120, reportable_change=1 | ||
), | ||
translation_key="voc_index", | ||
fallback_name="VOC index", | ||
) | ||
.add_to_registry() | ||
) | ||
``` | ||
Here's what we've done: | ||
|
||
- The first three arguments of the `sensor()` function (`attribute_name`, `cluster_id` and `endpoint_id`) combined tell Zigpy _where_ to look for the value, | ||
- `state_class` and `device_class` communicate _what_ the value is to Home Assistant, affecting how the entity looks, | ||
- `reporting_config` is an object with the following arguments: | ||
- `reportable_change` - the minimum number the attibute's value has to change by (delta) to be reported before `max_interval`, | ||
- `min_interval` - the minimum amount of time in seconds between reports of the attribute. It will not be reported more often that that, even if the value's delta is greater than `reportable_change`, | ||
- `max_interval` - the maximum amount of time in seconds between reports of the attribute. It will not be reported less often that that, even if the value's delta does not exceed `reportable_change`. | ||
- `translation_key` is the key which HA will try to find in its translations to localize the name, | ||
- `fallback_name` is the name used if `translation_key` is not found. | ||
|
||
Explore [zigpy/quirks/v2](https://github.com/zigpy/zigpy/tree/dev/zigpy/quirks/v2) to see what else is possible. Here are some other functions available right now: | ||
|
||
| Name | Description | Example | | ||
|-----------------------|-----------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------| | ||
| `enum()` | This method allows exposing an enum based entity in Home Assistant, a.k.a. the `select` entity. | <img width="586" height="146" alt="image" src="https://github.com/user-attachments/assets/6385a101-8696-45a0-8ea5-ca0a314b0803" /> | | ||
| `sensor()` | This method allows exposing a sensor entity in Home Assistant. | <img width="575" height="177" alt="image" src="https://github.com/user-attachments/assets/71d2abea-9ed5-4c85-b912-3178e407c40f" /> | | ||
| `switch()` | This method allows exposing a switch entity in Home Assistant. | <img width="582" height="101" alt="image" src="https://github.com/user-attachments/assets/e09c67eb-beba-429f-a87d-0d382e272dc0" /> | | ||
| `number()` | This method allows exposing a number entity in Home Assistant. | <img width="575" height="209" alt="image" src="https://github.com/user-attachments/assets/d0d1c536-cb58-4b97-ba9f-75c1f3b7c84e" /> | | ||
| `binary_sensor()` | This method allows exposing a binary sensor entity in Home Assistant. | <img width="594" height="107" alt="image" src="https://github.com/user-attachments/assets/3b2c584b-cea4-4363-83aa-9a2a3e3abbd7" /> | | ||
| `write_attr_button()` | This method allows exposing a button entity in Home Assistant that writes a value to an attribute when pressed. | <img width="562" height="93" alt="image" src="https://github.com/user-attachments/assets/27c3c4cb-b8c2-45c9-8b7b-44791dae006f" /> | | ||
| `command_button()` | This method allows exposing a button entity in Home Assistant that execute a ZCL command when pressed. | <img width="573" height="104" alt="image" src="https://github.com/user-attachments/assets/9e72611f-d03b-4f83-b30a-d123c55d2215" /> | | ||
|
||
Those entities don't necessarily have to look the way the examples do, since Home Assistant can change the UI depending on `state_class` and `device_class`, `entity_type`, `unit` and `mode` (in case of `number`). | ||
|
||
To apply the quirk, save the file, restart your Home Assistant instance and pay close attention to the `home-assistant.log` file (same directory as `configuration.yaml`), as syntax errors in the code of your quirk will be reported there. | ||
|
||
If you've done everything right, your device will now expose a new sensor entity (VOC index): | ||
|
||
<img width="329" height="471" alt="image" src="https://github.com/user-attachments/assets/e68a40e9-c57e-43ca-af91-da91890b0465" /> | ||
|
||
### 3.2. Exposing an unsupproted attribute. | ||
The device we're working on has a read-write attribute in the Carbon Dioxide (CO₂) Concentration (`0x040d`) cluster under the endpoint `2` with ID `0x0205`, which tells the device the current altitude above sea level in meters for more accurate CO₂ measurements. This attribute being writeable means that it is a _configuration attribute_. | ||
|
||
Sadly, we cannot use its name directly (as it doesn't _have_ one) - we need to give it a name, and for that we need to _replace_ this device's CO₂ cluster with a virtual one we will write. This virtual cluster will find the attribute by its ID (`0x0205`), define its type and access level (all present in the `scan_device` result), and finally give it a name. | ||
|
||
But there's a slight complication in this plan: we already have supported attributes in the Carbon Dioxide (CO₂) Concentration (`0x040d`) cluster, and replacing it with one attribute defined will make already supported attributes unsupported. Do we need to re-define them just for one more attribute? Fortunately, no. | ||
|
||
If we navigate to the [zigpy/zcl/clusters](https://github.com/zigpy/zigpy/tree/dev/zigpy/zcl/clusters) directory of the zigpy repository, we'll find a [measurement.py](https://github.com/zigpy/zigpy/blob/dev/zigpy/zcl/clusters/measurement.py) file with a [CarbonDioxideConcentration](https://github.com/zigpy/zigpy/blob/dev/zigpy/zcl/clusters/measurement.py#L361) class within. Instead of creating a brand new cluster and having to re-define all its attributes, we want to **extend** an existing cluster, appending a new attribute to it instead. Note that this class's `cluster_id` (`0x040D`) corresponds exactly with our CO₂ cluster: | ||
```python | ||
class CarbonDioxideConcentration(_ConcentrationMixin, Cluster): | ||
cluster_id: Final[t.uint16_t] = 0x040D | ||
name: Final = "Carbon Dioxide (CO₂) Concentration" | ||
ep_attribute: Final = "carbon_dioxide_concentration" | ||
``` | ||
Also note that this class does not define any attributes by itself, but **extends** another class - `_ConcentrationMixin`, which defines the attributes: | ||
```python | ||
class _ConcentrationMixin: | ||
"""Mixin for the common attributes of the concentration measurement clusters""" | ||
|
||
class AttributeDefs(BaseAttributeDefs): | ||
measured_value: Final = ZCLAttributeDef( | ||
id=0x0000, type=t.Single, access="rp", mandatory=True | ||
) # fraction of 1 (one) | ||
min_measured_value: Final = ZCLAttributeDef( | ||
id=0x0001, type=t.Single, access="r", mandatory=True | ||
) | ||
max_measured_value: Final = ZCLAttributeDef( | ||
id=0x0002, type=t.Single, access="r", mandatory=True | ||
) | ||
tolerance: Final = ZCLAttributeDef(id=0x0003, type=t.Single, access="r") | ||
cluster_revision: Final = foundation.ZCL_CLUSTER_REVISION_ATTR | ||
reporting_status: Final = foundation.ZCL_REPORTING_STATUS_ATTR | ||
|
||
``` | ||
That's because a lot of other classes representing concentration clusters (`CarbonMonoxideConcentration`, `EthyleneConcentration` and so on) have exactly the same set of attributes. | ||
|
||
So, our custom cluster will look like this: | ||
```python | ||
from typing import Final | ||
from zigpy.quirks import CustomCluster | ||
import zigpy.types as t | ||
from zigpy.zcl.clusters.measurement import CarbonDioxideConcentration | ||
from zigpy.zcl.foundation import ZCLAttributeDef | ||
|
||
class CO2Cluster(CarbonDioxideConcentration, CustomCluster): # Extends CarbonDioxideConcentration and CustomCluster | ||
|
||
class AttributeDefs(CarbonDioxideConcentration.AttributeDefs): # Extends CarbonDioxideConcentration's attribute definitions | ||
altitude: Final = ZCLAttributeDef(id=0x0205, type=t.uint16_t, access="rw") | ||
``` | ||
|
||
The `ZCLAttributeDef`'s `id` argument is the ID of an attribute from the scan response, as is the `type`, as is the `access` (`r` stands for `READ`, `w` stands for `WRITE` and `p` stands for `REPORT`). | ||
|
||
So, after creating this cluster's class and replacing it, our quirk would look like this: | ||
```python | ||
from zigpy.quirks.v2 import ( | ||
QuirkBuilder, | ||
ReportingConfig, | ||
SensorDeviceClass, | ||
SensorStateClass, | ||
NumberDeviceClass, | ||
) | ||
from zigpy.zcl.clusters.general import AnalogInput | ||
|
||
from typing import Final | ||
from zigpy.quirks import CustomCluster | ||
import zigpy.types as t | ||
from zigpy.zcl.clusters.measurement import CarbonDioxideConcentration | ||
from zigpy.zcl.foundation import ZCLAttributeDef | ||
|
||
from zigpy.quirks.v2.homeassistant import UnitOfLength | ||
|
||
class CO2Cluster(CarbonDioxideConcentration, CustomCluster): | ||
|
||
class AttributeDefs(CarbonDioxideConcentration.AttributeDefs): | ||
altitude: Final = ZCLAttributeDef(id=0x0205, type=t.uint16_t, access="rw") | ||
|
||
( | ||
QuirkBuilder("your manufacturer name", "your device name") | ||
.replaces(CO2Cluster, endpoint_id=2) | ||
.sensor( | ||
attribute_name="present_value", | ||
cluster_id=AnalogInput.cluster_id, | ||
endpoint_id=3, | ||
state_class=SensorStateClass.MEASUREMENT, | ||
device_class=SensorDeviceClass.AQI, | ||
reporting_config=ReportingConfig( | ||
min_interval=10, max_interval=120, reportable_change=1 | ||
), | ||
translation_key="voc_index", | ||
fallback_name="VOC index", | ||
) | ||
.number( # Setting the altitude above sea level (for high accuracy of the CO2 sensor) | ||
attribute_name=CO2Cluster.AttributeDefs.altitude.name, | ||
cluster_id=CO2Cluster.cluster_id, | ||
endpoint_id=2, | ||
translation_key="whatever", | ||
fallback_name="Altitude above sea level", | ||
device_class=NumberDeviceClass.DISTANCE, | ||
unit=UnitOfLength.METERS, | ||
min_value=0, | ||
max_value=3000, | ||
step=1 | ||
) | ||
.add_to_registry() | ||
) | ||
``` | ||
|
||
After successfully restarting Home Assistant, we've got a new configuration entity: | ||
<img width="339" height="148" alt="image" src="https://github.com/user-attachments/assets/e91dc4a3-eba7-4edc-bb37-c425a57cdbbf" /> | ||
|
||
## 4. Conclusion | ||
You now know how to scan devices for attributes and expose them to Home Assistatnt as entities. The basics, one might say. One can do a lot more, and if one wants to, being at least a bit familiar with the API is the first step to doing that. |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.