# Zigbee Hub

Build your local ZigBee network without Z2M/ZHA

# About Zigbee Hub mode

# **4. About Zigbee Hub mode**

<p class="callout success">**It is highly recommended to use U series coordinators for this mode (SLZB-06xU / MRxU / Ultima)**</p>

<p class="callout info">**Limitations:  
- no support for ZigBee devices that require a manufacturer code  
- no support for ZGP  
- no OTA updates for ZigBee devices  
- limited support for ZigBee devices that use non-standard clusters (requires special converters)  
- limited number of devices that can be paired:  
 U series coordinators - up to 100 devices.  
 non-U series - up to 20 devices.  
It is not recommended to use more than this limit (although technically possible)**</p>

## 4.1 What is Zigbee Hub in SLZB-OS?

The **Zigbee Hub** feature allows the SLZB device to run its Zigbee network **directly on the device** — without needing an external computer, Raspberry Pi, or NAS to host the Zigbee stack.  
In Zigbee Hub mode, SLZB-OS launches an integrated Zigbee stack service that can connect directly to your smart home platform over MQTT.

This mode is ideal for:

- **Self-contained setups** where the device acts as both the coordinator and the host.
- **Reducing complexity** by eliminating extra hardware.
- **PoE/Ethernet-based installations** for maximum stability.

When Zigbee Hub is active, a dedicated **Zigbee Hub** menu appears in the SLZB-OS interface, containing the following pages:

1. **Dashboard** – Live overview of Zigbee network status.
2. **Devices** – List and manage all paired Zigbee devices.
3. **MQTT** – Configure the MQTT broker connection.
4. **Settings** – Advanced Zigbee network and coordinator options.

---

## 4.2 Zigbee Hub → Dashboard

The **Dashboard** is the central monitoring page for your Zigbee network.

[![image.png](https://smlight.tech/support/manuals/uploads/images/gallery/2025-08/scaled-1680-/keCimage.png)](https://smlight.tech/support/manuals/uploads/images/gallery/2025-08/keCimage.png)

The dashboard contains cards of your ZigBee devices with the data they provide and controls.  
The dashboard is updated in real time via SSE.

---

## 4.3 Zigbee Hub → Devices

The **Devices** page lists every Zigbee device paired to your coordinator.

[![image.png](https://smlight.tech/support/manuals/uploads/images/gallery/2025-08/scaled-1680-/527image.png)](https://smlight.tech/support/manuals/uploads/images/gallery/2025-08/527image.png)

**Pairing control:**

- **Permit Join** (+ button on the bottom right side of the device table) – Allow or deny new devices joining the network.

**For each device, you’ll see:**

- **Name / Friendly Name** – Human-readable identifier.
- **IEEE Address** – Unique device ID.
- **Network Address** – Short Zigbee address assigned by the coordinator.
- **Last Seen** – Timestamp of the last communication.
- **Powering** – Device power source (AC/battery) or ? if the device does not provide information.
- **Link Quality (LQI)** – Signal strength indicator.
- **Actions:**
    
    
    - Rename device
    - Remove/unpair device
    - View device details (clusters, endpoints, bindings)
    - Bind/unbind devices (if supported)

**Typical uses:**

- Verify devices are online and responsive.
- Rename devices for easier identification in automations.
- Remove devices no longer in use.

### Device config

#### How to rename device

Select the pencil icon [![image.png](https://smlight.tech/support/manuals/uploads/images/gallery/2026-04/scaled-1680-/image.png)](https://smlight.tech/support/manuals/uploads/images/gallery/2026-04/image.png)

[![image.png](https://smlight.tech/support/manuals/uploads/images/gallery/2026-04/scaled-1680-/TD5image.png)](https://smlight.tech/support/manuals/uploads/images/gallery/2026-04/TD5image.png)

Enter new name and press "Save". Maximum length - 50 characters.

#### Device config

Click on the wrench[![image.png](https://smlight.tech/support/manuals/uploads/images/gallery/2026-04/scaled-1680-/5YYimage.png)](https://smlight.tech/support/manuals/uploads/images/gallery/2026-04/5YYimage.png)

##### Binding

[![image.png](https://smlight.tech/support/manuals/uploads/images/gallery/2026-04/scaled-1680-/cSRimage.png)](https://smlight.tech/support/manuals/uploads/images/gallery/2026-04/cSRimage.png)

This menu sends the device a bind request **to the coordinator**, the device must be active to accept this. If it is a battery-powered device you need to wake it up.

##### Configure reporting

[![image.png](https://smlight.tech/support/manuals/uploads/images/gallery/2026-04/scaled-1680-/Gr5image.png)](https://smlight.tech/support/manuals/uploads/images/gallery/2026-04/Gr5image.png)

##### Polling

[![image.png](https://smlight.tech/support/manuals/uploads/images/gallery/2026-04/scaled-1680-/FPwimage.png)](https://smlight.tech/support/manuals/uploads/images/gallery/2026-04/FPwimage.png)

Allows you to configure polling of the selected attribute after a certain time interval

##### Exposes

[![image.png](https://smlight.tech/support/manuals/uploads/images/gallery/2026-04/scaled-1680-/VReimage.png)](https://smlight.tech/support/manuals/uploads/images/gallery/2026-04/VReimage.png)

[![image.png](https://smlight.tech/support/manuals/uploads/images/gallery/2026-04/scaled-1680-/Yh4image.png)](https://smlight.tech/support/manuals/uploads/images/gallery/2026-04/Yh4image.png)

Provides information about **expected** data from the device and examples of using MQTT, HTTP, and Berry API to get or set device state.

<p class="callout info">**IMPORTANT!**  
This section provides information about **EXPECTED** data from the device, based on information about the device clusters or converter (if it exists).  
This information may not match the actual behavior of the device if it uses non-standard clusters!  
If this is a Tuya DP device, then information will be displayed here only if a converter exists for the device!  
</p>

##### Other tools

[![image.png](https://smlight.tech/support/manuals/uploads/images/gallery/2026-04/scaled-1680-/wBBimage.png)](https://smlight.tech/support/manuals/uploads/images/gallery/2026-04/wBBimage.png)

Here you can find the ZCN converter number (if used) and download device information

---

## 4.4 Zigbee Hub → MQTT

This page configures the MQTT connection that Zigbee2MQTT (running on SLZB-OS) uses to communicate with your smart home platform.

[![image.png](https://smlight.tech/support/manuals/uploads/images/gallery/2025-08/scaled-1680-/OHQimage.png)](https://smlight.tech/support/manuals/uploads/images/gallery/2025-08/OHQimage.png)

**Configuration fields include:**

- **MQTT Server Address** – IP or hostname of your broker (e.g., `mqtt://192.168.1.100`).
- **Port** – Default 1883 for MQTT, 8883 for MQTT over TLS.
- **Username / Password** – Broker authentication (if required).
- **Base Topic** – Topic prefix for Zigbee messages (default: `zigbee2mqtt`).
- **Discovery Prefix** - Home assistant main topic name.

**Tips:**

- For Home Assistant with Mosquitto add-on, use the HA IP and port `1883`.
- Always use a unique base topic if you run multiple Zigbee networks.
- Save and restart Zigbee Hub after making MQTT changes.

---

## 4.5 Zigbee Hub → Settings

The **Settings** page contains deeper configuration for the Zigbee coordinator and Zigbee2MQTT service.

[![image.png](https://smlight.tech/support/manuals/uploads/images/gallery/2025-08/scaled-1680-/dJpimage.png)](https://smlight.tech/support/manuals/uploads/images/gallery/2025-08/dJpimage.png)

**Typical settings available:**

- **Network Parameters**:
    
    
    - **PAN ID** – Zigbee network identifier.
    - **Channel** – RF channel (11–26; avoid Wi-Fi overlap if possible).
    - **Extended PAN ID** – Long network identifier.
- **Transmit Power** – Radio TX power in dBm (higher = longer range, more power draw).
- **Show in debug log -** Select which categories of Zigbee-related information are recorded in the Log &amp; Debug page. 
    - **Zigbee Hub messages** – Logs high-level events from the Zigbee Hub service (e.g., device joins, status updates).
    - **Raw Zigbee packets** – Logs low-level Zigbee frame data; useful for deep protocol debugging.
    - **Zigbee MQTT** – Logs MQTT messages related to Zigbee communication, including publishes and subscriptions.

**Best practices:**

- Change the Zigbee channel only on a fresh network (re-pair required after change).
- Keep `Permit Join` disabled most of the time for security.
- Adjust transmit power to match your coverage needs and regulatory limits.

## 4.6 Troubleshooting

### Problems when starting a Zigbee network

<p class="callout info">OS v3.0.9 update is breaking. If you updated OS to v3.0.9 and started getting this error then follow the instructions below</p>

<div id="bkmrk-network-commissionin"><div>**Network commissioning timed out - most likely network with the same panId or extendedPanId already exists nearby.**  
<div><div>**Network formation refused there is too much RF interference or network with the same panId or extendedPanId already exists.**</div></div></div></div><div id="bkmrk-if-you-got-this-erro"><div>If you got this error after updating Zigbee chip:  
- turn off coordinator  
- turn off ALL Zigbee routers that were connected to Zigbee Hub. **This is important, it will not work without this**  
- turn on coordinator. Zigbee Hub should start now but zigbee devices will be unavailable  
- remove all devices (click on the red trash can)  
- download and run berry script below, this script will keep permit join enabled as long as it is running</div></div>```python
#META {"start":0}
#Insert your code below
import ZHB

ZHB.waitForStart(0xFF)

while 1
  ZHB.permitJoin(254)
  SLZB.delay(255 * 1000)
end
```

<div id="bkmrk---turn-back-on-previ"><div>- turn back on previously disabled zigbee devices  
- repair all devices  
- after devices repaired you can stop and delete script</div></div><div id="bkmrk--15">  
</div><div id="bkmrk-other-cases%3A--move-t">Other cases:  
- move the coordinator away from the wifi router  
- make sure there is no other coordinator nearby with the same zigbee network settings  
</div>

# MQTT API

[![image.png](https://smlight.tech/support/manuals/uploads/images/gallery/2025-10/scaled-1680-/iqOimage.png)](https://smlight.tech/support/manuals/uploads/images/gallery/2025-10/iqOimage.png)

Zigbee Hub mode has the ability to connect to local or remote MQTT brokers. Currently only TCP connections are supported.  
This document describes MQTT API for SLZB-OS 3.0.9 or higher

Topics are divided into **IN** and **OUT**.  
**IN** - you can send messages to these topics.  
**OUT** - Zigbee Hub sends messages to these topics.

## zHub topics format

### Data topic (OUT)

General `data` topic format below:  
`base topic` / `data` / `zigbee device ieee` / `zigbee endpoint` / `zigbee cluster` / `zigbee attribute`

`base topic` - global prefix, so you can have few Zigbee Hubs connected to one broker, you just have to set different base topics.  
`data` - static text (topic type)  
`zigbee device ieee` - sender IEEE address in HEX format.  
`zigbee endpoint` - zigbee endpoint from which this message is coming, DEC format.  
`zigbee cluster` - zigbee cluster from which this message is coming, HEX format.  
`zigbee attribute` - zigbee attribute from which this message is coming, HEX format.

Data topic example: `zhub/data/a4c1383439bf5cc9/1/0000/0001`

### Read topic (IN) (available from v3.3.0)

This topic accepts requests to read attributes of ZigBee devices.  
When you send a message to this topic, the coordinator generates and sends a request for the specified attribute to the target ZigBee device. The device must be online to accept the request, so this is usually used for AC-powered devices.

Topic format: `base topic` / `read` / `zigbee device ieee` / `zigbee endpoint` / `zigbee cluster` / `zigbee attribute`

`base topic` - global prefix, so you can have few Zigbee Hubs connected to one broker, you just have to set different base topics.  
`read` - static text (topic type)  
`zigbee device ieee` - sender IEEE address in HEX format.  
`zigbee endpoint` - zigbee endpoint from which this message is coming, DEC format.  
`zigbee cluster` - zigbee cluster from which this message is coming, HEX format.  
`zigbee attribute` - zigbee attribute from which this message is coming, HEX format.

Read topic example: `zhub/read/a4c1383439bf5cc9/1/0006/0000`   
payload: `any text`

<p class="callout info">Attribute update will be sent to `data` topic!  
</p>

<p class="callout warning">**PLEASE NOTE!**  
**You must add any text content to the payload! Do not send an empty payload to this topic!** An empty payload will only delete this topic (if it exists). The coordinator will not respond to empty payloads.</p>

### Command topic (IN)

This topic is intended for sending ZCL commands to a Zigbee device.  
General `cmd` topic format below:  
`base topic` / `cmd` / `zigbee device ieee` / `zigbee endpoint` / `zigbee cluster` / `zigbee command`

`base topic` - global prefix, so you can have few Zigbee Hubs connected to one broker, you just have to set different base topics.  
`cmd` - static text (topic type)  
`zigbee device ieee` - target zigbee device IEEE address in HEX format.  
`zigbee endpoint` - target zigbee device endpoint to which this message is coming, DEC format.  
`zigbee cluster` - target zigbee device cluster to which this message is coming, HEX format.  
`zigbee command` - zigbee command to send, HEX format.

Some clusters have special handlers for input commands, such as the ON/OFF or Light cluster. You can find their formats below:

##### <span style="text-decoration: underline;">ON/OFF</span> cluster payload format for command topic

`ON` - send ON command  
`OFF` - send OFF command

Example: `zhub/cmd/a4c1383439bf5cc9/1/0006` payload: `ON`  
Will send command to enable relay or light device.

##### <span style="text-decoration: underline;">Level control for Light</span> cluster payload format

`0000` command payload is a number in DEC from 1 to 254, the larger the number, the brighter the lamp will be.

##### <span style="text-decoration: underline;">Color control</span> cluster payload format

`0007` command payload is a **color** in **HEX** or **RGB** format. For example: `255,29,0` or `#FFFFFF`  
`000a` command payload is a **light temperature** in [**mired**](https://en.wikipedia.org/wiki/Mired). For example: 200

##### Other clusters

If no built-in clusters or ZCN converters has overridden the processing of this command, if the command contains a payload, it must be a HEX string of the following format:  
\- Bytes without spaces, uppercase, multiple of two. Example: `010203FF`  
\- Bytes with spaces, uppercase, grouped by two. Example: `01 02 03 FF`

### Write topic (IN)

This topic is intended for writing ZigBee device ZCL attributes.  
General `write` topic format below:  
`base topic` / `write` / `zigbee device ieee` / `zigbee endpoint` / `zigbee cluster` / `zigbee attribute`

`base topic` - global prefix, so you can have few Zigbee Hubs connected to one broker, you just have to set different base topics.  
`write` - static text (topic type)  
`zigbee device ieee` - target zigbee device IEEE address in HEX format.  
`zigbee endpoint` - target zigbee device endpoint to which this message is coming, DEC format.  
`zigbee cluster` - target zigbee device cluster to which this message is coming, HEX format.  
`zigbee attribute` - target zigbee device attribute to which this message is coming, HEX format.

##### Cluster <span style="text-decoration: underline;">0006</span>, attribute <span style="text-decoration: underline;">4003</span> payload format

<div id="bkmrk-last-state---device-"><div><div><div>`Last state` - device will remember its state</div><div>`ON` - device will be on after power loss  
`OFF` - device will be off after power loss</div></div>  
</div></div>##### Cluster <span style="text-decoration: underline;">EF00</span> (Tuya DP) payload format

<p class="callout warning">**Please note that the write topic format for Tuya is different!**`base topic` / `write` / `zigbee device ieee` / `1` / `ef00` / `data point` / `data type`</p>

`data point` - Tuya data point id. You can read more about it here: [https://www.zigbee2mqtt.io/advanced/support-new-devices/03\_find\_tuya\_data\_points.html](https://www.zigbee2mqtt.io/advanced/support-new-devices/03_find_tuya_data_points.html)  
`data type` - a number that represents the type of data being sent.  
0 - RAW, **1** - BOOL, **2** - INT, **3** - STRING, **4** - ENUM, **5** - BITMAP

Examples:  
topic: zhub/write/a4c138d089d1418c/1/ef00/0018/1  
payload: 1

##### Any other clusters/attributes payload format

Payload should contain **JSON**: {"type": &lt;zigbee data type&gt;, "data":&lt;data to be sent&gt;}  
`type` - a number that represents the type of data being sent.

##### Zigbee Data Types and Data Type IDs

<div class="table-wrap" id="bkmrk-data-class-data-type"><table class="wrapped confluenceTable"><colgroup><col></col><col></col><col></col></colgroup><thead><tr><th class="confluenceTh">**Data Class**

</th><th class="confluenceTh">**Data Type**

</th><th class="confluenceTh">**Data Type ID**

</th></tr></thead><tbody><tr><td class="confluenceTd">Null

</td><td class="confluenceTd">No data  
Reserved

</td><td class="confluenceTd">0x00  
0x01—0x07

</td></tr><tr><td class="confluenceTd">General Data

</td><td class="confluenceTd">8-bit data  
16-bit data  
24-bit data  
32-bit data  
40-bit data  
48-bit data  
56-bit data  
64-bit data

</td><td class="confluenceTd">0x08  
0x09  
0x0a  
0x0b  
0x0c  
0x0d  
0x0e  
0x0f

</td></tr><tr><td class="confluenceTd">Logical

</td><td class="confluenceTd">Boolean  
Reserved

</td><td class="confluenceTd">0x10  
0x11—0x17

</td></tr><tr><td class="confluenceTd">Bitmap

</td><td class="confluenceTd">8-bit data  
16-bit data  
24-bit data  
32-bit data  
40-bit data  
48-bit data  
56-bit data  
64-bit data

</td><td class="confluenceTd">0x18  
0x19  
0x1a  
0x1b  
0x1c  
0x1d  
0x1e  
0x1f

</td></tr><tr><td class="confluenceTd">Unsigned integer

</td><td class="confluenceTd">Unsigned 8-bit integer  
Unsigned 16-bit integer  
Unsigned 24-bit integer  
Unsigned 32-bit integer  
Unsigned 40-bit integer  
Unsigned 48-bit integer  
Unsigned 56-bit integer  
Unsigned 64-bit integer

</td><td class="confluenceTd">0x20  
0x21  
0x22  
0x23  
0x24  
0x25  
0x26  
0x27

</td></tr><tr><td class="confluenceTd">Signed integer

</td><td class="confluenceTd">Signed 8-bit integer  
Signed 16-bit integer  
Signed 24-bit integer  
Signed 32-bit integer  
Signed 40-bit integer  
Signed 48-bit integer  
Signed 56-bit integer  
Signed 64-bit integer

</td><td class="confluenceTd">0x28  
0x29  
0x2a  
0x2b  
0x2c  
0x2d  
0x2e  
0x2f

</td></tr><tr><td class="confluenceTd">Enumeration

</td><td class="confluenceTd">8-bit enumeration  
16-bit enumeration  
Reserved

</td><td class="confluenceTd">0x30  
0x31  
0x32—0x37

</td></tr><tr><td class="confluenceTd">Floating point

</td><td class="confluenceTd">Semi-precision  
Single precision  
Double precision  
Reserved

</td><td class="confluenceTd">0x38  
0x39  
0x3a  
0x3b—0x3f

</td></tr><tr><td class="confluenceTd">String

</td><td class="confluenceTd">Reserved  
Octet string  
Character string  
Long octet string  
Long character string  
Reserved

</td><td class="confluenceTd">0x40  
0x41  
0x42  
0x43  
0x44  
0x45—0x47

</td></tr><tr><td class="confluenceTd">Ordered sequence

</td><td class="confluenceTd">Array  
Reserved  
Structure  
Reserved

</td><td class="confluenceTd">0x48  
0x49—0x4b  
0x4c  
0x4d—0x4f

</td></tr><tr><td class="confluenceTd">Collection

</td><td class="confluenceTd">Set  
Bag  
Reserved

</td><td class="confluenceTd">0x50  
0x51  
0x52—0x57

</td></tr><tr><td class="confluenceTd">Reserved

</td><td class="confluenceTd">-

</td><td class="confluenceTd">0x58—0xdf

</td></tr><tr><td class="confluenceTd">Time

</td><td class="confluenceTd">Time of day  
Date  
UTC Time  
Reserved

</td><td class="confluenceTd">0xe0  
0xe1  
0xe2  
0xe3—0xe7

</td></tr><tr><td class="confluenceTd">Identifier

</td><td class="confluenceTd">Cluster ID  
Attribute ID  
BACnet ID  
Reserved

</td><td class="confluenceTd">0xe8  
0xe9  
0xea  
0xeb—0xef

</td></tr><tr><td class="confluenceTd">Miscellaneous

</td><td class="confluenceTd">IEEE Address  
128-bit security key  
Reserved

</td><td class="confluenceTd">0xf0  
0xf1  
0xf2—0xfe

</td></tr><tr><td class="confluenceTd">Unknown

</td><td class="confluenceTd">Unknown

</td><td class="confluenceTd">0xff

</td></tr></tbody></table>

</div>`data` - a HEX string of the following format:  
\- Bytes without spaces, uppercase, multiple of two. Example: `010203FF`  
\- Bytes with spaces, uppercase, grouped by two. Example: `01 02 03 FF`

Please note that the number of bytes in the payload **strictly** depends on the data type!  
For example, if you send data with type 16-bit integer, the number of bytes in the payload should be 2.  
Example: `{"type": 33, "data":"0000"}`   
33 - is 0x21 converted to DEC

### System control topic (IN) (available from v3.3.0)

This topic allows you to control main functions of the Zigbee Hub.

Topic format: `base topic` / `system_control`

Payload format: {"action": "&lt;system control actions&gt;", &lt;additional parameters&gt;}

#### Permit join action

Allows you to open a ZigBee network to add new devices.

Payload format: {"action": "permit\_join", "time": &lt;time to open the network in seconds, from 1 to 254&gt;, "addr": &lt;network address of the device on which to open the network. Can be omitted if the network needs to be opened on all devices&gt;}

Examples:  
`{"action": "permit_join", "time": 60}` - open the entire network for 60 seconds  
`{"action": "permit_join", "time": 0}` - close network  
`{"action": "permit_join", "time": 60, "addr": 64580}` - open the network for 60 seconds only on device with network address 64580 (DEC number, not HEX)

#### Configure reporting action

Configures reporting for the selected device. If the device is battery-powered, it must be woken up to accept this command.

Payload format: {"action": "configure\_reporting", "ieee": &lt;IEEE address of the target device, HEX string&gt;, "ep": &lt;target endpoint, DEC&gt;, "cl": &lt;target cluster, DEC&gt;, "attr": &lt;target attribute, DEC&gt;, "minRep": &lt;minimum time for reporting in seconds, DEC&gt;, "maxRep": &lt;max time for reporting in seconds, DEC&gt;, "dType": &lt;zigbee reporting data type for this attribute, DEC&gt;, "change": &lt;how much the attribute value must change for reporting to occur. Should be omitted for discrete attributes&gt;}

Examples:  
`{"action": "configure_reporting", "ieee": "3425b4fffe12e9e9", "ep": 1, "cl": 6, "attr": 0, "minRep": 1, "maxRep": 3600, "dType": 0}` - configure ON/OFF attribute repotring to minimum 1s and max 3600s report time. "change" field is omitted because it is a discrete attribute.  
`{"action": "configure_reporting", "ieee": "3425b4fffe12e9e9", "ep": 1, "cl": 2820, "attr": 1285, "minRep": 30, "maxRep": 3600, "dType": 33, "change": 200}` - configure RMS Voltage attribute repotring to minimum 1s and max 3600s report time. Reporting will occur no earlier than 30s if the value has changed by 200 or more (2v) or after a 3600s timeout if value does not changed.

#### Binding action

Binds the target device to the coordinator or to another device.

<p class="callout warning">If one device bound to another, the coordinator will stop receiving reports from the bound device cluster.</p>

Payload format: {"action": "binding", "mode": "&lt;binding mode&gt;", "scrIeee": "&lt;IEEE of the device to which the binding request will be sent, HEX string&gt;", ""scrEp": &lt;endpoint number that needs to be bound, DEC number&gt;, "scrCl": &lt;cluster number that needs to be bound, DEC number&gt;, &lt;additional parameters&gt;}

##### To device mode

Binds one device to another.

Payload format: {"action": "binding", "mode": "to\_device", "scrIeee": "&lt;IEEE of the device to which the binding request will be sent, HEX string&gt;", ""scrEp": &lt;endpoint number that needs to be bound, DEC number&gt;, "scrCl": &lt;cluster number that needs to be bound, DEC number&gt;, "dstEp": &lt;endpoint number **to which** the binding will be done, DEC number&gt;, "dstIeee": "&lt;IEEE device to which binding will be performed, HEX string&gt;"}

Example: `{"action": "binding", "mode": "to_device", "scrIeee": "3425b4fffe12e9e9", ""scrEp": 1, "scrCl": 6, "dstEp": 1, "dstIeee": "a4c1389ffb198304"}` - binding a Zigbee button (ON/OFF cluster) to a Zigbee relay.

##### To coordinator mode

Binds to the coordinator so that it can receive reports from this cluster.

<p class="callout info">Zigbee Hub will attempt to bind automatically when interviewing a device, but this does not always work perfectly.</p>

Payload format: {"action": "binding", "mode": "to\_device", "scrIeee": "&lt;IEEE of the device to which the binding request will be sent, HEX string&gt;", ""scrEp": &lt;endpoint number that needs to be bound, DEC number&gt;, "scrCl": &lt;cluster number that needs to be bound, DEC number&gt;}

Example: `{"action": "binding", "mode": "to_coordinator", "scrIeee": "3425b4fffe12e9e9", ""scrEp": 1, "scrCl": 6}` - binding a Zigbee button (ON/OFF cluster) to the coordinator.

# Zigbee Converter Native (ZCN) concept

### Introduction

When developing the Zigbee stack for zHub, we had to take into account the **strict RAM limitations of the ESP32**, so it is physically impossible to provide the same convenient and functional Zigbee converters as in Zigbee2Mqtt or ZHA.  
So, we develop an alternative **ZCN** concept based on **static C++ structures** that contain a set of **callbacks** that allow you to override the processing of packets from/to ZigBee devices.

#### ZHB Incoming Zigbee message flow structure

<div drawio-diagram="190"><img src="https://smlight.tech/support/manuals/uploads/images/drawio/2026-04/drawing-1-1775384829.png" alt=""/></div>

The life cycle of a ZigBee message consists of two main stages:

##### Normalization

Converting ZigBee data into a format understandable by the zHub.  
zHub records currently support 8 data types:

1. NONE - indicates an empty record. Empty records are not forwarded to MQTT / WEB
2. U32 - uint32\_t
3. I32 - int32\_t
4. FLOAT - float
5. BUF - uint8\_t array
6. STR - char string
7. BOOL - boolean value (true / false)
8. CMD - ZCL command. The command ID will be placed in the attribute field of the record. If it is a Tuya DP, the datapoint number will be placed in the record value (U32).  
    <span style="text-decoration: underline;">COMMANDS ARE NOT CACHED IN RAM</span>

Normalization example: you have a Zigbee temperature sensor, this sensor sends the temperature as an Zigbee int16 number (zType = 0x29), i.e. when you receive a message, the I32 field will contain the value 2150 for a temperature of 21.50.  
To normalize this value you need to convert it to Float, to do this you need to divide 2150 by 100 (for one decimal place in this case),  
i.e. the normalization will look like:

```c++
record.setFloat(record.val.i32 / 100.0);
```

This is a working option, but to simplify things, we have developed helper functions for the most common cases:

```c++
static float u16_divide_by_10(const uint16_t val) {
    return (float)val / 10.0;
}

static float u16_divide_by_100(const uint16_t val) {
    return (float)val / 100.0;
}

static float u16_divide_by_1000(const uint16_t val) {
    return (float)val / 1000.0;
}

static float i16_divide_by_10(const int16_t val) {
    return (float)val / 10.0;
}

static float i16_divide_by_100(const int16_t val) {
    return (float)val / 100.0;
}

static float i16_divide_by_1000(const int16_t val) {
    return (float)val / 1000.0;
}
```

<p class="callout info">Is normalization mandatory?  
The answer is no, if the received value does not require conversion from one format to another, then you can skip this step.</p>

##### Serialization

Once the value has been normalized you need to tell zHub how to properly convert it to text, this is the serialization!

zHub uses ArduinoJson to form a data packet for MQTT / WEB. Therefore, for ordinary int, bool or string, you can use a simple code:

```c++
data[zcl_json.type_sm] = zcl_json.report;
data[zcl_json.val] = record.val.u32;
```

For float, it is better to format manually to get right number of characters:

```c++
char buf[11] = {0};  // "-222.00"
snprintf(buf, sizeof(buf), "\"%.2f\"", record.val.fl);

data[zcl_json.type_sm] = zcl_json.report;
data[zcl_json.val] = serialized(buf);
```

To simplify things, we developed functions to serialize more common cases:

```c++
// converts color in X/Y to text (in R,G,B format (255,255,255))
static void zcn_ser_xy(ZCL_SER_ATTR_HANDLER_ARGS);

// converts a float to its text representation (2 digits precision)
static void zcn_ser_float(ZCL_SER_ATTR_HANDLER_ARGS);

// converts a raw uint32 to its text representation
static void zcn_ser_raw_uint(ZCL_SER_ATTR_HANDLER_ARGS);

// return "ON" if integer value > 0. otherwise return "OFF"
static void zcn_tuya_ser_switch(ZCL_SER_ATTR_HANDLER_ARGS);
```

<p class="callout info">Is serialization mandatory?  
This can be omitted only for ZCL commands, all other records, if they are not empty, must be serialized so that zHub can send them to MQTT / WEB  
</p>

#### ZCN structures

<div id="bkmrk-zcn_endpoint_overrid"><div>**zcn\_endpoint\_override\_t**</div></div>```c++
struct zcn_endpoint_override_t {
    uint8_t endpoint;
    uint8_t clusterCount;
    const zcl_cluster_config_t* clusters;
};
```

`endpoint`- zigbee endpoint to which this converter corresponds. You can find device endpoints in device signature  
`clusterCount` - element count in `zcl_cluster_config_t`  
`clusters` - cluster override list

<div id="bkmrk-zcl_cluster_config_t"><div>---

**zcl\_cluster\_config\_t**</div></div>```c++
struct zcl_cluster_config_t {
    uint16_t id;
    bool bind;  // bind this cluster?
    const zcl_cmd_config_t cmd_config;
    uint8_t attrCount;
    const zcl_attr_config_t *attrs;
};
```

`id` - attribute to which this config entry corresponds. You can find device attributes in device signature  
`bind` - if `true` zHub will send bind request on device interview  
`cmd_config` - structure that defines which triggers this device exposes to MQTT/WEB. Useful for Zigbee buttons etc  
`attrCount` - element count in `zcl_attr_config_t`

<div id="bkmrk-zcl_cmd_config_t"><div>---

**zcl\_cmd\_config\_t**</div></div>```c++
struct zcl_cmd_config_t {
    ZCL_ON_CMD_HANDLER on_cmd;
    ZCL_CMD_EXPOSES_HANDLER exposesOverride;
    const char *exposes;  // "|" separated
};
```

`on_cmd` - сalled when zHub received a ZCL command for this cluster. If this function **exists**, it will be called **instead of the default behavior!** `exposesOverride` - overrides `exposes`, you can use this function if you need to generate dynamic exposes based on some information from the device  
`exposes` - the list of triggers to be generated for this cluster, must be separated by `|`

Example of using `zcl_cmd_config_t` based on converter for SNZB-01P:

```c++
static bool ewelink_cmd_action(ZCL_ON_CMD_HANDLER_ARGS) {
    switch (record.attr) {
        case 0:
            result = "long";
            break;

        case 1:
            result = "double";
            break;

        case 2:
            result = "single";
            break;

        default:
            result = "unknown";
            break;
    }

    if (strcmp(result.c_str(), "unknown")) return true;

    return false;
}

static const zcn_converter_t eWeLink_SNZB_01P = {
    .signature = {
        .model = "SNZB-01P",
        .manuf = "eWeLink",
    },
    .on_intw_stage = [](ZCN_CONV_INTW_STAGE_ARGS) -> uint8_t {
        if (!strcmp(intwTag, "intw_get_power_source")) {
            dev->data.powerSource = ZB_POWER_SOURCE_BATTERY; // mark device as battery-powered
            return 0; // stage overrided

        } else {
            return INTW_F_STATUS_ZCN_UNUSED; // ignore this stage
        }
    },
    .overrides_count = 1,  // we have only one override
    .overrides = {
        {
            .endpoint = 1,      // endpoint number
            .clusterCount = 1,  // clusters array len
            .clusters = (const zcl_cluster_config_t[]){
                {
                    .id = ZCL_CL_ON_OFF,  // we expect the commands to be from the ON/OFF cluster
                    .bind = false,        //
                    .cmd_config = {
                        .on_cmd = ewelink_cmd_action, // commands handler
                        .exposes = "single|double|long", // what trigger types we will expose to MQTT/WEB
                    },
                    .attrCount = 0,  // we have 0 attr overrides
                },
            },
        },
    },
};
```

<div id="bkmrk-zcl_attr_config_t"><div>**zcl\_attr\_config\_t**</div><div>  
</div></div>```c++
struct zcl_attr_config_t {
    zcl_mqtt_dev_class_t mqttClass;
    const char *mqttSubClass;
    const char *name;
    const char *unit;
    uint16_t id;
    bool rp;     // is this attr reported?
    bool ram;    // store received value in ram?
    bool query;  // query this attr?
    uint16_t minReport;
    uint16_t maxReport;
    uint16_t change;
    uint8_t dataType;
    uint16_t poolingInterval;
    ZCL_NORMALIZE_HANDLER on_normalize;     // on raw zb packer received
    ZCL_SER_ATTR_HANDLER on_serialization;  // on converting ZclAttrRecord to string value
    ZCL_DIS_ATTR_HANDLER on_discovery;      // on HA discovery. return false to skip discovery for this attr
};
```

`mqttClass` - interface element class that will be sent to MQTT discovery and zHub dashboard  
`mqttSubClass` - MQTT discovery sub-class  
`name` - name of the element that will be displayed in MQTT discovery and WEB  
`unit` - unit of measurement for this attribute (text)  
`id` - attribute identifier  
`rp` - set to `true` if this attribute support reporting  
`ram` - set to `true` if the value of this attribute should be stored in RAM  
`query` - set to `true` if the value of this attribute should be requested from the ZigBee device when the coordinator starts. Useful for AC powered devices  
`minReport` - minimum reporting time for this attribute in seconds (if reporting is supported)  
`maxReport` - maximum reporting time for this attribute in seconds (if reporting is supported)  
`change` - how much the attribute must change for the report to occur (if reporting is supported)  
`dataType` - the data type of this attribute according to ZCL  
`pollingInterval` - time to poll this attribute. **Сurrently not used**  
`on_normalize` - this function is called to convert a zigbee packet to a C++ data type so that the zHub can process it  
`on_serialization` - called to convert the attribute value to a string

`on_discovery` - used to generate MQTT discovery for this attribute.  
For simple sensors (containing only one attribute), a simple function that returns false is enough, in which case zHub will generate topics automatically:

```c++
static bool zcl_dis_gen_basic(ZCL_DIS_ATTR_HANDLER_ARGS) {
    return true;
}
```

If you need to combine multiple attributes into one MQTT discovery then you will have to do it manually:

```c++
static bool light_discovery(ZCL_DIS_ATTR_HANDLER_ARGS) {
    const uint8_t stateTopicLen = mqttCalcZhubTopicLen(MQTT_TOPIC_DATA, zhbBase);
    char stateTopic[stateTopicLen] = {0};
    mqttBuildZhubTopic(MQTT_TOPIC_DATA, stateTopic, stateTopicLen, zhbBase, dev->data.ieeeAddr, ep, cl, attr);

    const uint8_t cmdRgbTopicLen = mqttCalcZhubTopicLen(MQTT_TOPIC_CMD, zhbBase);
    char cmdTopicRGB[cmdRgbTopicLen] = {0};
    mqttBuildZhubTopic(MQTT_TOPIC_CMD, cmdTopicRGB, cmdRgbTopicLen, zhbBase, dev->data.ieeeAddr, ep, cl, ZCL_CL_CMD_COLOR_CONTROL_MOVE_TO_COLOR);

    char cmdTopic[cmdRgbTopicLen] = {0};
    mqttBuildZhubTopic(MQTT_TOPIC_CMD, cmdTopic, cmdRgbTopicLen, zhbBase, dev->data.ieeeAddr, ep, ZCL_CL_ON_OFF, ZCL_CL_ATTR_ON_OFF_ONOFF);

    const uint8_t stateOnOffTopicLen = mqttCalcZhubTopicLen(MQTT_TOPIC_DATA, zhbBase);
    char stateOnOffTopic[stateOnOffTopicLen] = {0};
    mqttBuildZhubTopic(MQTT_TOPIC_DATA, stateOnOffTopic, stateOnOffTopicLen, zhbBase, dev->data.ieeeAddr, ep, ZCL_CL_ON_OFF, ZCL_CL_ATTR_ON_OFF_ONOFF);

    char stateLevelSetTopic[cmdRgbTopicLen] = {0};
    mqttBuildZhubTopic(MQTT_TOPIC_CMD, stateLevelSetTopic, cmdRgbTopicLen, zhbBase, dev->data.ieeeAddr, ep, ZCL_CL_LEVEL_CONTROL_FOR_LIGHTING, ZCL_CL_ATTR_LEVEL_CONTROL_FOR_LIGHTING_CURRENTLEVEL);

    char stateLevelTopic[stateOnOffTopicLen] = {0};
    mqttBuildZhubTopic(MQTT_TOPIC_DATA, stateLevelTopic, stateOnOffTopicLen, zhbBase, dev->data.ieeeAddr, ep, ZCL_CL_LEVEL_CONTROL_FOR_LIGHTING, ZCL_CL_ATTR_LEVEL_CONTROL_FOR_LIGHTING_CURRENTLEVEL);

    char clorTempSetTopic[cmdRgbTopicLen] = {0};
    mqttBuildZhubTopic(MQTT_TOPIC_CMD, clorTempSetTopic, cmdRgbTopicLen, zhbBase, dev->data.ieeeAddr, ep, cl, ZCL_CL_CMD_COLOR_CONTROL_MOVE_TO_COLOR_TEMPERATURE);

    char clorTempStateTopic[stateOnOffTopicLen] = {0};
    mqttBuildZhubTopic(MQTT_TOPIC_DATA, clorTempStateTopic, stateOnOffTopicLen, zhbBase, dev->data.ieeeAddr, ep, cl, ZCL_CL_ATTR_COLOR_CONTROL_COLOR_TEMPERATURE);

    doc["clr_temp_cmd_t"] = clorTempSetTopic;
    doc["clr_temp_stat_t"] = clorTempStateTopic;
    doc["clr_temp_val_tpl"] = MQTT_DEFAULT_TEMPLATE;

    doc[ha_json.cmd_t] = cmdTopic;
    doc["stat_t"] = stateOnOffTopic;
    doc["stat_val_tpl"] = MQTT_DEFAULT_TEMPLATE;

    doc["bri_cmd_t"] = stateLevelSetTopic;
    doc["bri_stat_t"] = stateLevelTopic;
    doc["bri_val_tpl"] = MQTT_DEFAULT_TEMPLATE;

    doc["rgb_stat_t"] = stateTopic;
    doc["rgb_val_tpl"] = MQTT_DEFAULT_TEMPLATE;
    doc["rgb_cmd_t"] = cmdTopicRGB;

    doc["p"] = "light";
    doc["schema"] = "basic";
    doc["brightness"] = false;
    doc["sup_clrm"] = serialized("[\"rgb\"]");
    doc["on_cmd_type"] = "first";

    return true;
}
```

# Berry ZCN template

This document describes the **Berry** side of the ZCN converter system: writing device-specific
Zigbee Hub converters as Berry scripts, without rebuilding the firmware.

The concept of ZCN primarily refers to native C++ converters, but we decided to keep this name for Berry converters for simplicity. Actually Berry converters are not native, they are a scripting interface.

---

## 1. What Berry ZCN is and when to use it

A Berry ZCN converter is the runtime-scriptable twin of a native `zcn_converter_t`:
a description of how a specific Zigbee device deviates from standard ZCL handling —
extra/proprietary attributes, remapped clusters, custom value normalization, custom
MQTT/Home Assistant discovery — plus Berry callback functions that the hub invokes at the
same points where native converters invoke C function pointers.

Use Berry ZCN when you want to:

- **Prototype** a converter for a new device without a firmware rebuild — iterate directly in
  the on-device script editor, then port the result to a native converter for shipping.
- **Support a device locally** that the stock firmware does not have a converter for.
- **Experiment** with normalization/serialization/discovery logic against a live device.

Everything here requires the device to run in **Zigbee Hub mode**.

---

## 2. Architecture and lifecycle

### 2.1 Slots

Berry converters live in a fixed global slot array (`converter_list.cpp`):

```c
#define ZCN_BE_COUNT 2                                    // converter_list.h
zcn_converter_be_t* zcn_converters_be[ZCN_BE_COUNT];      // heap-allocated per slot
```

There are **`ZCN_BE_COUNT` (currently 2) slots** for Berry converters. Each registered
converter occupies one slot (id `0..ZCN_BE_COUNT-1`). Registration fails with
`"no free slots for ZCN"` when all slots are taken.

Each slot stores a `zcn_converter_be_t` — the C mirror of the native `zcn_converter_t`, but
with `bvalue` Berry callbacks instead of C function pointers, heap-allocated strings, and a
back-pointer to the owning Berry VM (`converter_defines.h:194`).

### 2.2 VM binding and garbage collection

- A converter belongs to the **VM that registered it**. `ZCN._new()` records the VM and marks
  the script as *waiting* (`be_events_mark_waiting`) so it stays resident to serve callbacks.
- All callback functions passed into the structure are **GC-pinned** (`be_gc_fix_set`) while
  the converter exists, and unpinned on delete — you can use closures/anonymous `def` blocks
  safely.
- When a script's VM stops, **all its converters are automatically deleted**. Restarting the script re-registers them.

### 2.3 Attachment is NOT persisted — attach on every boot

This is the most important lifecycle difference from native ZCN:

- The native converter index (`dev->data.zcn`) is saved in the device database
  (`/zb/db/{IEEE}.json`, key `"zcn"`) and restored on boot.
- The Berry converter index (`dev->data.zcn_be`) is **runtime-only**. It resets to `0xff`
  (unattached) every boot and is set only by:
  1. **Interview matching** — during a device interview, `intw_zcn` checks Berry converters
     and sets `zcn_be` when one matches;
  2. **Explicit `ZCN.attach(slot, ieee)`** from your script.

Since interviews only run at pairing (or forced re-interview), a converter script must call
`ZCN.attach()` for its devices every time it starts. The standard pattern:

```berry
var slot = ZCN.register(my_converter)
ZCN.attach(slot, "0x3425b4fffe12e9e9")   # repeat for each device
```

Optional, run the script with autostart (`#META {"start":1}`) so the converter is registered and
attached right after boot.

### 2.4 Priority over native converters

Berry converters take priority everywhere:

- **Interview matching** (`intw_zcn`, `standalone.cpp:3638`): all Berry slots are checked
  *first*; if one matches, `zcn_be` is set and native matching is skipped. (If several Berry
  converters match, the **highest slot id wins**.)
- **Runtime dispatch**: every hook site checks `ZCN_CHECK_BE(dev)` before the native
  `ZCN_CHECK(dev)` — data handling, on_cmd, MQTT write/cmd, HA discovery, trigger discovery.
  A device can technically have both `zcn` and `zcn_be` set; the Berry one is consulted first
  wherever it provides an override.

### 2.5 Matching logic

Same rules as native (`zcn_device_signature_t`):

- If the converter has a **`matcher` function** — it is called as `def (dev)` with a
  `ZigbeeDevice` instance and must return `true`/`false`. `model`/`manuf` strings are ignored.
- Otherwise — exact string compare of **both** `manuf` (Basic attr 0x0004) and `model`
  (Basic attr 0x0005) against the interviewed values.

---

## 3. Quick start

The easiest way to produce a converter is the **ZCN Builder** page in the web UI
(*Zigbee Hub → ZCN Builder*): assemble the structure in the form, pick callback presets, and
copy the generated Berry code. What follows explains what that generated code actually does.

Minimal hand-written converter:

```berry
#META {"start":0}
import ZCN

var snzb01p_zcn = {
  "signature": {
    "model": "SNZB-01P",
    "manuf": "eWeLink",
  },
  "on_intw_stage": def(dev, tag)
    if (tag == "intw_get_power_source")
      dev.setPS(3)  # battery
      return 0
    end
    return 3  # ZCN unused - standard handling
  end,
  "overrides_count": 1,
  "overrides": [
    {
      "endpoint": 1,
      "clusterCount": 1,
      "clusters": [
        {
          "id": 0x0006,
          "cmd_config": {
            "exposes": "btn_single|btn_double|btn_long",
            "on_cmd": def(dev, record)
              # attr: 0 = long, 1 = double, 2 = single
              var names = ['btn_long', 'btn_double', 'btn_single']
              var id = record.getAttr()
              if (id < 3) return names[id] end
              return ""
            end,
          },
        },
      ],
    },
  ],
}

var zcn_slot = ZCN.register(snzb01p_zcn)
ZCN.attach(zcn_slot, "0x00124b0012345678")  # attach to a device manually
```

`ZCN.register()` is implemented in Berry and **solidified** into the firmware: it validates the map, finds a
free slot, and feeds the structure into the native slot via the low-level `ZCN._*` functions.
It returns the **slot id** (needed for `ZCN.attach`) and raises a descriptive error on failure
(the partially built slot is deleted automatically).

---

## 4. Converter map reference

The converter is a plain Berry `map`. Missing keys get defaults:
**int → 0, string → `""`, bool → `false`, function → `nil`** (exceptions noted below).
Extra/unknown keys are ignored.

### 4.1 Top level

| Key | Type | Required | Meaning |
|---|---|---|---|
| `signature` | map | **yes** (raises otherwise) | Device matching, see below |
| `on_annonce` | function `def (dev)` | no | Called when the device announces itself (power-on / rejoin) |
| `on_intw_stage` | function `def (dev, tag) -> int` | no | Called before interview stages; see §5.2 |
| `overrides_count` | int | yes, if `overrides` present | **Must equal** `overrides.size()` — it is not derived automatically |
| `overrides` | list of maps | no | Endpoint overrides |

### 4.2 `signature`

The signature tells SLZB-OS whether this converter is suitable for the device for which the interview is currently being performed.

| Key | Type | Meaning |
|---|---|---|
| `matcher` | function `def (dev) -> bool` | If present, used instead of model/manuf. Must be a function |
| `model` | string | Zigbee model identifier (exact match via strcmp()) |
| `manuf` | string | Manufacturer name (exact match) |

### 4.3 Endpoint override (element of `overrides`)

| Key | Type | Meaning |
|---|---|---|
| `endpoint` | int | Zigbee endpoint this override applies to |
| `clusterCount` | int | Number of clusters in `clusters` |
| `clusters` | list of maps | Cluster overrides |

### 4.4 Cluster override (element of `clusters`)

| Key | Type | Default | Meaning |
|---|---|---|---|
| `id` | int | 0 | ZCL cluster id (e.g. `0x0402`) |
| `addMode` | int | 0 | `0` = ADD (merge with the standard cluster: your attrs are used where ids collide, standard attrs fill the rest),<br>`1` = OVERRIDE (standard cluster fully ignored; only your definition used) |
| `bind` | bool | false | Bind this cluster to the hub during interview |
| `attrCount` | int | 0 | **Must equal** `attrs.size()` |
| `attrs` | list of maps | — | Attribute overrides |
| `cmd_config` | map | — | Cluster **command** handling (buttons/actions), see below |
| `on_mqtt_cmd` | function `def (ep, cl, cmd, topic, data, dev) -> bool` | nil | Used for `cmd_config`.<br>Handles MQTT messages on the `{base}/cmd/...` topic for this cluster. Return `true` = handled (standard handling skipped) |

### 4.5 `cmd_config`

Tells SLZB-OS what actions(triggers) this cluster provides. Typically used for Zigbee buttons and
scene remotes.

| Key | Type | Meaning |
|---|---|---|
| `on_cmd` | function `def (dev, record) -> string \| nil` | Convert the received command record into an action string (e.g. `"btn_single"`). Return a **string** to publish it as an action; any non-string return means "not handled" |
| `exposes` | string | `"\|"`-separated list of all possible action strings — used to generate device-trigger discovery |
| `exposesOverride` | function `def (dev, ep, cl) -> string \| nil` | Dynamic alternative to `exposes`: return the `"\|"`-separated list at discovery time; non-string return skips triggers for this cluster |

Note: the command path is only taken when the cluster has `on_cmd` **and** at least one of
`exposes` / `exposesOverride`.

### 4.6 Attribute override (element of `attrs`)

| Key | Type | Default | Meaning |
|---|---|---|---|
| `id` | int | 0 | ZCL attribute id |
| `name` | string | "" | Human-readable name (shown in web UI) |
| `mqttClass` | int | 0 | MQTT entity type, see table below |
| `mqttSubClass` | string | "" | MQTT entity `device_class` (e.g. `"temperature"`, `"moisture"`) |
| `unit` | string | `""` | Unit of measurement (e.g. `"°C"`) |
| `dataType` | int | 0 | ZCL data type. Required for configuring reporting for this attribute and if this attribute is writable. May be omitted if the attribute is not writable and not reported. |
| `ram` | bool | false | If True, SLZB-OS will create a special container (record) for this attribute in RAM at startup. The received value will be cached in this container, only the last value is stored. |
| `rp` | bool | false | Configure attribute reporting during interview. Also adds this attribute to web UI "configure reporting" dialog |
| `minReport` | int | 1 | Reporting: minimum interval, s.<br>Can be omitted if `rp` is `false` |
| `maxReport` | int | 3600 | Reporting: maximum interval, s.<br>Can be omitted if `rp` is `false` |
| `change` | int | 1 | Reporting: reportable change threshold.<br>Can be omitted if `rp` is `false` |
| `rpChangeMult` | real | 0 (unset) | Multiplier for the web UI "configure reporting" dialog: raw ZCL reportable change = human value × `rpChangeMult` (e.g. `100` for 0.01-unit temperature).<br>Can be omitted if `rp` is `false` |
| `query` | bool | false | If True, the coordinator will send a request to read this attribute when the hub starts. |
| `on_normalize` | function `def (record, dev)` | nil | Tells ZHB how to convert raw Zigbee data into usable data, see §5.4 |
| `on_serialization` | function `def (record, dev, data)` | nil | Tells ZHB how to convert data in a container (record) into a text representation, see §5.5 |
| `on_discovery` | function `def (ep, cl, attr, zType, dev, doc, zhbBase) -> bool` | nil | Allow you to customize/veto discovery for this attribute, see §5.6 |
| `on_mqtt_write` | function `def (ep, cl, attr, dType, topic, data, dev) -> bool` | nil | Tells ZHB how to handle `{base}/write/...` MQTT messages, see §5.7 |

`mqttClass` values:

| Value | Class |
|---|---|
| 0 | NONE (not exposed to WEB/MQTT) |
| 1 | BINARY_SENSOR |
| 2 | BUTTON |
| 3 | TRIGGER |
| 4 | ~~EVENT~~ (*Not supported by ZHB dashboard*) |
| 5 | ~~FAN~~ (*Not supported by ZHB dashboard*) |
| 6 | LIGHT | 
| 7 | NUMBER |
| 8 | SELECT |
| 9 | SENSOR |
| 10 | SWITCH |
| 11 | ~~TEXT~~ (*Not supported by ZHB dashboard*) |
| 12 | ~~COVER~~ (*Not supported by ZHB dashboard*) |

---

## 5. Callback reference

All callbacks run inside your script's VM, scheduled through the Berry event system. The hub
task waits up to ~20 ms for the VM to become available; if the VM
is busy longer (e.g. a blocked loop in your script), the **event is dropped** — keep both the
callbacks and the rest of the script non-blocking.

`dev` arguments are `ZigbeeDevice` instances, `record` arguments are `ZclAttrRecord`
instances (§6). `doc`/`data` are JSON proxy objects supporting `obj["key"] = value` style
access.

### 5.1 `on_annonce` — `def (dev)`

Called when an attached device sends a device announce (typically power-on or rejoin).
Return value ignored.

### 5.2 `on_intw_stage` — `def (dev, tag) -> int`

Called before interview stages, identified by string `tag`. Return one of:

| Return | Meaning |
|---|---|
| `0` (DONE) | Stage handled by the converter — hub skips its standard logic and go to next stage |
| `1` (WAIT) | Converter started something async — hub waits, stage will be re-entered |
| `2` (ERR) | Fail interview on this stage |
| `3` (ZCN_UNUSED) | Converter does not care — hub runs standard logic (**default choice**) |

Stage tags:
`req_ep`, `discover_ep`, `get_power_source`, `autobind`, `conf_reporting`, `ias_enroll`,
`get_ias_type`, `send_tuya_magic`.

Caveat (same as native): on the **first** interview the converter is matched at the
`intw_zcn` stage, so tags for stages ordered *before* it (`req_ep`, `discover_ep`) only fire
on re-interview — unless the device was pre-attached with `ZCN.attach()`.

### 5.3 `cmd_config.on_cmd` — `def (dev, record) -> string | nil`

Called when a ZCL **command** arrives on the cluster (`record.isCmd()` is true;
`record.getAttr()` holds the command id). Return the
action string to publish (must be one of the `exposes` values for HA to recognize it).
Non-string return = not handled.<br>
Important for Tuya commands: `record.asInt()` contains the ID of the Tuya command and `record.getAttr()` its data.

### 5.4 `on_normalize` — `def (record, dev)`

Called right after the raw ZCL value is parsed into the record, **before** it is stored and
serialized. Mutate the record in place:

```berry
"on_normalize": def (record, dev)
  record.setFloat(record.asInt() / 100.0)
end
```

You can also *redirect* a record to another cluster/attr (`record.setCl()`,
`record.setAttr()`, `record.setEp()`) — e.g. unpacking a proprietary struct into standard
records. The redirect target attr must exist (declare it with `ram: true`).

### 5.5 `on_serialization` — `def (record, dev, data)`

Called when the stored record is converted to JSON for MQTT / web dashboard.
Write into `data`:

```berry
"on_serialization": def (record, dev, data)
  data["type_sm"] = "report"
  data["val"] = record.asFloat()
end
```

Keys follow the native `zcl_json` convention: `"type_sm"` (usually `"report"`) and `"val"`.
If absent, standard serialization for the data type applies.

### 5.6 `on_discovery` — `def (ep, cl, attr, zType, dev, doc, zhbBase) -> bool`

Called when generating discovery payload for this attribute. `doc` is the discovery
JSON (base payload already filled from `mqttClass`/`mqttSubClass`/`name`/`unit`); `zhbBase`
is the MQTT base topic. Add or override keys, e.g. `doc["state_class"] = "measurement"`.
In most cases, you only use `doc` if you need to add something (like `min`, `max` and `step` for a slider).

Return `true` to publish, **`false` to veto** discovery of this attribute. Note: with no
callback set the attribute is still discovered when `mqttClass != 0`; but if you *do* set a
callback, you must return `true` for it to be published.

### 5.7 `on_mqtt_write` — `def (ep, cl, attr, dType, topic, data, dev) -> bool`

Called for MQTT messages on the `{base}/write/...` topic targeting this attribute. `data` is
the raw payload string. Convert and send to the device (`dev.sendCmd`, `dev.sendTuyaData`,
etc.). Return `true` = handled (standard ZCL write skipped).

### 5.8 `on_mqtt_cmd` (cluster level) — `def (ep, cl, cmd, topic, data, dev) -> bool`

Same idea for the `{base}/cmd/...` topic — cluster commands sent from MQTT (e.g. switch
toggles). Return `true` = handled.

---

## 6. Objects available in callbacks

### 6.1 `ZigbeeDevice` (class `be_class_ZigbeeDevice`)

| Method | Description |
|---|---|
| `matcher(manuf, model) -> bool` | Exact manuf+model compare |
| `getName()` / `getIeee()` / `getModel()` / `getManuf()` / `getNwk()` | Identity |
| `hasName()` / `setName(s)` / `setModel(s)` / `setManuf(s)` | Identity write access (e.g. normalizing Tuya `_TZE200_...` model strings in a `matcher`) |
| `getPS()` / `setPS(v)` | Power source (`CONST_ZCL_PS.*`) |
| `getBattery()` / `getLqi()` / `getLastSeen()` | Telemetry |
| `getIAS()` / `setIAS(v)` | IAS zone type (`CONST_ZCL_IAS.*`) |
| `isBattery()` / `isAc()` / `isTuya()` / `haveTuyaDP()` / `isInterviewing()` / `isZcnUsed()` | State predicates (`isZcnUsed` = a **native** converter is attached) |
| `haveEndpoint(ep)` / `addEp(ep)` / `removeEp(ep)` | Endpoint list management (`addEp` creates an empty endpoint — populate it with `addInCluster`/`addOutCluster`) |
| `hasInCluster(cl)` / `addInCluster(cl [, ep])` | Input cluster list (ep 0 / omitted = first endpoint); duplicate-safe |
| `hasOutCluster(cl)` / `addOutCluster(cl [, ep])` | Output cluster list, same semantics |
| `removeCluster(ep, cl)` | Remove a single input cluster from an endpoint ("ignore cluster X" quirks) |
| `getVal(ep, cl, attr)` / `setVal(ep, cl, attr, value)` | Read / update a stored record value (`setVal` accepts bool/int/real/string/bytes and only updates an **existing** record) |
| `addRecord(ep, cl, attr)` / `haveRecord(ep, cl, attr)` / `removeRecord(ep, cl, attr)` / `removeAllRecords()` | RAM record management |
| `queryAllRecords([pauseMs])` / `queryMissingRecords([pauseMs])` | Actively read all / value-less records from the device (**warning**: a non-zero pause blocks the script and the hub for pause × records ms — prefer 0) |
| `startPooling(intervalSec, ep, cl, attr)` / `stopPooling(ep, cl, attr)` | Periodic attribute polling driven by the hub task |
| `sendOnOff` / `sendBri` / `sendColor` / `sendColorTemp` | High-level actuator commands |
| `sendCmd(ep, cl, cmd [, bytes])` | Raw ZCL cluster command |
| `sendTuyaData(dp, ztype, val)` | Tuya 0xEF00 datapoint write |
| `sendTuyaQuery()` / `sendTuyaMagic()` | Tuya: query all datapoints / send the "magic" init packet (typical in `on_annonce`) |
| `readAttr(ep, cl, attr...)` | ZCL Read Attributes request |
| `writeAttr(ep, cl, attr, dType, bytes)` | ZCL Write Attribute with a raw payload (for custom `on_mqtt_write` handlers) |
| `confReporting(ep, cl, attr, dType, minReport, maxReport, change)` | ZCL Configure Reporting (for custom `on_intw_stage("conf_reporting")` handling) |
| `nodeDescReq()` / `simpleDescReq(ep)` / `reqEndpoints()` | Low-level ZDO requests; responses are processed by the interview state machine, so these are mainly useful inside `on_intw_stage` |
| `bindToHub` / `bindToDevice` / `bindToGroup` | Binding operations |
| *(module-level)* `ZHB.await(seq [, timeoutMs]) -> bool` / `ZHB.lastStatus()` / `ZHB.on_response(seq, cb(ok, status) [, timeoutMs]) -> bool` | Wait for the device's response to a previous send (sync / async); the device is resolved from `seq`. **`ZHB.await()` raises inside ZCN callbacks** — they run on the ZHB task; use `ZHB.on_response()` there. See `docs/slzb-os-scripts/docs/modules/zhb.md` |

### 6.2 `ZclAttrRecord` (class `be_class_ZclAttrRecord`)

Getters: `getEp()`, `getCl()`, `getAttr()`, `getStatus()`, `getZtype()` (raw ZCL type),
`getSMtype()` (internal storage type, compare against `ZclAttrRecord.TYPE_*` constants:
`TYPE_NONE/U32/I32/FLOAT/BUF/STR/BOOL/CMD/CMD_PAYLOAD`).

Predicates: `isCmd()`, `isCmdPayload()`, `isTuya()` (cluster == 0xEF00),
`hasPayload()` (type is not `TYPE_NONE`), `matcher(cl, attr [, ep]) -> bool`.

Value access: `asInt()`, `asFloat()`, `asStr()`, `asBytes()`,
`asLumiStruct([start])` — parses the Lumi/Aqara proprietary struct (Basic 0xFF01/0xFF02
buffer) into a `{tag_id: int}` map; `getMSB16()` / `getLSB16()` — high/low 16-bit halves
of the raw 32-bit value (e.g. packed color X/Y).

Mutation: `setInt(v)`, `setUInt(v)`, `setFloat(v)`, `setBool(v)`, `setStr(v)`,
`setBuf(bytes)` (replaces the payload with a copy of a Berry `bytes()` buffer, 1–255 bytes),
`setCmd(cmdId [, tuyaCluster])`, `setMSB16(v)` / `setLSB16(v)`,
`setEp(v)`, `setCl(v)`, `setAttr(v)`.

---

## 7. ZCNP — native callback presets

The `ZCNP` module exposes the **preset functions** used by built-in
converters, so a Berry converter can reference them directly instead of re-implementing the
logic (and automatically picks up native fixes):

```berry
import ZCNP
...
"on_normalize": ZCNP.zcn_norm_i16_divide_100,
"on_serialization": ZCNP.zcn_ser_float,
"on_discovery": ZCNP.zcn_dis_gen_basic_measurement,
```

| Kind | Functions |
|---|---|
| normalize | `zcn_norm_i16_divide_10/100/1000`, `zcn_norm_u16_divide_10/100/1000`, `zcn_norm_lumi_basic` |
| serialization | `zcn_ser_float`, `zcn_ser_raw_uint`, `zcn_ser_xy`, `zcn_tuya_ser_switch`, `zcn_ser_tuya_batt_enum`, `zcn_ser_lumi_basic` |
| discovery | `zcn_dis_gen_basic`, `zcn_dis_gen_basic_measurement`, `zcn_dis_tuya_batt_enum` |
| mqtt write | `zcn_write_tuya_int`, `zcn_write_tuya_float_int100`, `zcn_write_int16`, `tuya_switch_handler` |
| cmd | `zcn_cmd_onoff_action`, `tuya_ias_wd_cmd_action` |

---

## 8. ZCN module API

Public functions:

| Function | Description |
|---|---|
| `ZCN.register(converter_map) -> slot` | Register a converter from a Berry map (see §4). Solidified Berry code (`embed/zhb_register_zcn.be`); returns the slot id, raises a descriptive error on failure |
| `ZCN.attach(slot, ieee_str) -> bool` | Attach converter `slot` to a paired device (IEEE as hex string, e.g. `"0x00124b00..."`). Clears the device's saved-message cache and (re)creates the converter's RAM records. Raises on: hub not started, bad slot, unknown IEEE. **Must be called on every script start** (§2.3) |
| `ZCN.delete(slot)` | Free the slot: unpins all callbacks, frees all allocations. Automatic when the VM stops |

The `ZCN._*` functions (`_new`, `_add_signature`, `_add_ep_ov_info`, `_add_ep`, `_add_cl`,
`_add_cl_cmd_config`, `_add_cl_attr_1/2/3`, `_is_zcn_ex`, `_get_slot_count`, `_suspend_zhb`)
are the low-level building blocks used by `ZCN.register()`. Use the map +
`ZCN.register()` instead of calling them directly — they must be called in the exact
allocation order and with the ZHB task suspended.

---

## 9. Worked example — Aqara water leak sensor

`lumi.sensor_wleak.aq1` reports leak state via IAS Zone and battery/telemetry via the
proprietary Lumi struct in Basic attr 0xFF01:

```berry
#META {"start":0}
import ZCN
import ZCNP

var lumi_weather_zcn = {
  "signature": {
    "model": "lumi.weather",
    "manuf": "LUMI",
  },
  "on_intw_stage": def(dev, tag)
    # Lumi ignores standard Configure Reporting - finish the interview right away
    if (tag == "intw_get_power_source")
      dev.setPS(3)  # battery
    end
    return 0xff  # force interview done
  end,
  "overrides_count": 1,
  "overrides": [
    {
      "endpoint": 1,
      "clusterCount": 2,
      "clusters": [
        {
          "id": 0x0001,
          "attrCount": 1,
          "attrs": [
            {
              "id": 0x0021,
              "name": "Battery",
              "unit": "%",
              "mqttClass": 9,
              "mqttSubClass": "battery",
              "ram": true,
              "on_serialization": ZCNP.zcn_ser_float,
              "on_discovery": ZCNP.zcn_dis_gen_basic,
            },
          ],
        },
        # Handle LUMI proprietary attribute.
        # This attribute takes a proprietary LUMI structure, parses it, extracts the battery value, and sends it as standard battery reporting (cluster 0x0001, attr 0x0021).
        {
          "id": 0x0000,
          "attrCount": 1,
          "attrs": [
            {
              "id": 0xFF01,
              "name": "",
              "on_normalize": ZCNP.zcn_norm_lumi_basic,
              "on_serialization": ZCNP.zcn_ser_lumi_basic,
            },
          ],
        },
      ],
    },
  ],
}

var zcn_slot = ZCN.register(lumi_weather_zcn)
ZCN.attach(zcn_slot, "0x00124b0012345678")  # attach to a device manually
```

A fully custom (no-preset) variant of the struct unpacking would use
`record.asLumiStruct()` in `on_normalize` and redirect values with
`record.setCl()`/`record.setAttr()`/`record.setFloat()` — the ZCN Builder page contains this
and five more complete examples ported from native converters.

---

## 10. Gotchas

1. **Counts are explicit.** `overrides_count`, `clusterCount`, `attrCount` are read from the
   map, not derived from list sizes. A count smaller than the list silently drops entries; a
   count larger than the list reads will crash device.
2. **Attach every boot.** `zcn_be` is not saved to the device DB. No `ZCN.attach()` after
   reboot (and no fresh interview) = converter silently inactive.
3. **`signature` is mandatory** — registration raises without it. `matcher` must be a
   function if present.
4. **Callbacks must be fast.** The hub waits ≤ ~20 ms for your VM; a busy script drops the
   event (a lost report/command, not an error).
5. **`on_cmd` needs `exposes`.** Without `exposes` or `exposesOverride` on the same cluster,
   the command path is never taken.
6. **`on_discovery` must return `true`** for the attribute to be discovered — a forgotten
   return (nil) vetoes it.
7. **`ram: true` for redirect targets and dashboard-only values** — otherwise the record does
   not exist until the device first reports it (or ever, for synthetic attrs).
8. **Slot exhaustion.** Only `ZCN_BE_COUNT` Berry converters can exist at once, across all
   scripts. A crashed-then-restarted script frees and re-takes its slots automatically, but a
   script that registers in a loop will run out.
9. **`ZCN.attach` requires a running hub and a paired device** — it raises
   `"enable zHub first"` / `"wrong device ieee"` otherwise. If your script autostarts before
   the hub is up, wait via `ZHB.waitForStart()` first.
10. **Multiple matching converters:** Not recommended. Converters are meant to be unique.
11. **Berry converters are much slower than native ones!** Berry ZCN intended for rapid prototyping, not for continuous use as it will slow down the system.

# Berry ZCN system manual

<p class="callout info">Everything here requires the device to run in **Zigbee Hub** mode and SLZB-OS **v3.3.8.dev0** or **higher.**</p>

This document describes the **Berry** side of the ZCN converter system: writing device-specific Zigbee Hub converters as Berry scripts, without rebuilding the firmware.

The concept of ZCN primarily refers to native C++ converters, but we decided to keep this name for Berry converters for simplicity. Actually Berry converters are not native, they are a scripting interface.

---

## 1. What Berry ZCN is and when to use it

A Berry ZCN converter is the runtime-scriptable twin of a native `zcn_converter_t`: a description of how a specific Zigbee device deviates from standard ZCL handling — extra/proprietary attributes, remapped clusters, custom value normalization, custom MQTT/Home Assistant discovery — plus Berry callback functions that the hub invokes at the same points where native converters invoke C function pointers.

Use Berry ZCN when you want to:

- **Prototype** a converter for a new device without a firmware rebuild — iterate directly in the on-device script editor, then port the result to a native converter for shipping.
- **Support a device locally** that the stock firmware does not have a converter for.
- **Experiment** with normalization/serialization/discovery logic against a live device.

---

## 2. Architecture and lifecycle

### 2.1 Slots

There are  **(currently 2) slots** for Berry converters. Each registered converter occupies one slot. Registration fails with `"no free slots for ZCN"` when all slots are taken.

### 2.2 VM binding and garbage collection

- A converter belongs to the **VM that registered it**.
- All callback functions passed into the structure are **GC-pinned** while the converter exists, and unpinned on delete — you can use closures/anonymous `def` blocks safely.
- When a script's VM stops, **all its converters are automatically deleted**. Restarting the script re-registers them.

### 2.3 Attachment is NOT persisted — attach on every boot

This is the most important lifecycle difference from native ZCN:

- The native converter index is saved in the device database and restored on boot.
- The Berry converter index is **runtime-only**. It resets every boot and is set only by: 
    1. **Interview matching** — during a device interview.
    2. **Explicit `ZCN.attach(slot, ieee)`** from your script.

Since interviews only run at pairing, a converter script must call `ZCN.attach()` for its devices every time it starts. The standard pattern:

```python
var slot = ZCN.register(my_converter)
ZCN.attach(slot, "0x3425b4fffe12e9e9") # repeat for each device
```

Optional, run the script with autostart (`#META {"start":1}`) so the converter is registered and attached right after boot.

### 2.4 Priority over native converters

Berry converters take priority everywhere:

- **Interview matching**: all Berry slots are checked *first*; if one matches, is set and native matching is skipped.
- **Runtime dispatch**: every hook site checks `ZCN_CHECK_BE(dev)` before the native `ZCN_CHECK(dev)` — data handling, on\_cmd, MQTT write/cmd, HA discovery, trigger discovery. A device can technically have both `zcn` and `zcn_be` set; the Berry one is consulted first wherever it provides an override.

### 2.5 Matching logic

- If the converter has a **`matcher` function** — it is called as `def (dev)` with a `ZigbeeDevice` instance and must return `true`/`false`. `model`/`manuf` strings are ignored.
- Otherwise — exact string compare of **both** `manuf` (Basic attr 0x0004) and `model` (Basic attr 0x0005) against the interviewed values.

---

## 3. Quick start

The easiest way to produce a converter is the **ZCN Builder** page in the web UI (*Zigbee Hub → ZCN Builder*): assemble the structure in the form, pick callback presets, and copy the generated Berry code.

`ZCN.register()` validates the map, finds a free slot, and feeds the structure into the native slot. It returns the **slot number** (needed for `ZCN.attach`) and raises a descriptive error on failure (the partially built slot is deleted automatically).

---

## 4. Converter map reference

The converter is a plain Berry `map`. Missing keys get defaults: **int → 0, string → `""`, bool → `false`, function → `nil`** (exceptions noted below). Extra/unknown keys are ignored.

### 4.1 Top level

<table id="bkmrk-key-type-required-me"><thead><tr><th>Key</th><th>Type</th><th>Required</th><th>Meaning</th></tr></thead><tbody><tr><td>`signature`</td><td>map</td><td>**yes** (raises otherwise)</td><td>Device matching, see below</td></tr><tr><td>`on_annonce`</td><td>function</td><td>no</td><td>Called when the device announces itself (power-on / rejoin)</td></tr><tr><td>`on_intw_stage`</td><td>function</td><td>no</td><td>Called before interview stages; see §5.2</td></tr><tr><td>`overrides_count`</td><td>int</td><td>yes, if `overrides` present</td><td>**Must equal** `overrides.size()` — it is not derived automatically</td></tr><tr><td>`overrides`</td><td>list of maps</td><td>no</td><td>Endpoint overrides</td></tr></tbody></table>

### 4.2 `signature`

The signature tells SLZB-OS whether this converter is suitable for the device for which the interview is currently being performed.

<table id="bkmrk-key-type-meaning-mat"><thead><tr><th>Key</th><th>Type</th><th>Meaning</th></tr></thead><tbody><tr><td>`matcher`</td><td>function</td><td>If present, used instead of model/manuf. Must be a function</td></tr><tr><td>`model`</td><td>string</td><td>Zigbee model identifier (exact match via strcmp())</td></tr><tr><td>`manuf`</td><td>string</td><td>Manufacturer name (exact match)</td></tr></tbody></table>

### 4.3 Endpoint override (element of `overrides`)

<table id="bkmrk-key-type-meaning-end"><thead><tr><th>Key</th><th>Type</th><th>Meaning</th></tr></thead><tbody><tr><td>`endpoint`</td><td>int</td><td>Zigbee endpoint number this override applies to</td></tr><tr><td>`clusterCount`</td><td>int</td><td>Number of clusters in `clusters`</td></tr><tr><td>`clusters`</td><td>list of maps</td><td>Cluster object array</td></tr></tbody></table>

### 4.4 Cluster override (element of `clusters`)

<table id="bkmrk-key-type-default-mea" style="width: 100%;"><thead><tr><th style="width: 14.3027%;">Key</th><th style="width: 9.41597%;">Type</th><th style="width: 6.45156%;">Default</th><th style="width: 69.8297%;">Meaning</th></tr></thead><tbody><tr><td style="width: 14.3027%;">`id`</td><td style="width: 9.41597%;">int</td><td style="width: 6.45156%;">0</td><td style="width: 69.8297%;">ZCL cluster id (e.g. `0x0402`)</td></tr><tr><td style="width: 14.3027%;">`addMode`</td><td style="width: 9.41597%;">int</td><td style="width: 6.45156%;">0</td><td style="width: 69.8297%;">`0` = ADD (merge with the standard cluster: your attrs are used where ids collide, standard attrs fill the rest),  
`1` = OVERRIDE (standard cluster fully ignored; only your definition used)</td></tr><tr><td style="width: 14.3027%;">`bind`</td><td style="width: 9.41597%;">bool</td><td style="width: 6.45156%;">false</td><td style="width: 69.8297%;">Bind this cluster to the hub during interview</td></tr><tr><td style="width: 14.3027%;">`attrCount`</td><td style="width: 9.41597%;">int</td><td style="width: 6.45156%;">0</td><td style="width: 69.8297%;">Number of attributes in `attrs`</td></tr><tr><td style="width: 14.3027%;">`attrs`</td><td style="width: 9.41597%;">list of maps</td><td style="width: 6.45156%;">—</td><td style="width: 69.8297%;">Attribute objects array</td></tr><tr><td style="width: 14.3027%;">`cmd_config`</td><td style="width: 9.41597%;">map</td><td style="width: 6.45156%;">—</td><td style="width: 69.8297%;">Cluster **command** handling (buttons/actions), see below</td></tr><tr><td style="width: 14.3027%;">`on_mqtt_cmd`</td><td style="width: 9.41597%;">function</td><td style="width: 6.45156%;">nil</td><td style="width: 69.8297%;">Used for `cmd_config`.  
Handles MQTT messages on the `{base}/cmd/...` topic for this cluster. Return `true` = handled (standard handling skipped)</td></tr></tbody></table>

### 4.5 `cmd_config`

Tells SLZB-OS what actions(triggers) this cluster provides. Typically used for Zigbee buttons and scene remotes.

<table id="bkmrk-key-type-meaning-on_" style="width: 100%;"><thead><tr><th style="width: 11.323%;">Key</th><th style="width: 8.34213%;">Type</th><th style="width: 80.3349%;">Meaning</th></tr></thead><tbody><tr><td style="width: 11.323%;">`on_cmd`</td><td style="width: 8.34213%;">function</td><td style="width: 80.3349%;">Converts the received command record into an action string (e.g. `"btn_single"`).

Must return a **string** to publish it as an action.

</td></tr><tr><td style="width: 11.323%;">`exposes`</td><td style="width: 8.34213%;">string</td><td style="width: 80.3349%;">`"|"`-separated list of all possible action strings — used to generate device-trigger discovery</td></tr><tr><td style="width: 11.323%;">`exposesOverride`</td><td style="width: 8.34213%;">function</td><td style="width: 80.3349%;">Dynamic alternative to `exposes`: return the `"|"`-separated list at discovery time; empty-string return skips triggers for this cluster</td></tr></tbody></table>

Note: the command path is only taken when the cluster has `on_cmd` **and** at least one of `exposes` / `exposesOverride`.

### 4.6 Attribute override (element of `attrs`)

<table id="bkmrk-key-type-default-mea-1" style="width: 100%;"><thead><tr><th style="width: 19.0703%;">Key</th><th style="width: 8.82002%;">Type</th><th style="width: 6.20225%;">Default</th><th style="width: 65.9074%;">Meaning</th></tr></thead><tbody><tr><td style="width: 19.0703%;">`id`</td><td style="width: 8.82002%;">int</td><td style="width: 6.20225%;">0</td><td style="width: 65.9074%;">ZCL attribute id</td></tr><tr><td style="width: 19.0703%;">`name`</td><td style="width: 8.82002%;">string</td><td style="width: 6.20225%;">""</td><td style="width: 65.9074%;">Human-readable name (shown in web UI)</td></tr><tr><td style="width: 19.0703%;">`mqttClass`</td><td style="width: 8.82002%;">int</td><td style="width: 6.20225%;">0</td><td style="width: 65.9074%;">MQTT entity type, see table below</td></tr><tr><td style="width: 19.0703%;">`mqttSubClass`</td><td style="width: 8.82002%;">string</td><td style="width: 6.20225%;">""</td><td style="width: 65.9074%;">MQTT entity `device_class` (e.g. `"temperature"`, `"moisture"`)</td></tr><tr><td style="width: 19.0703%;">`unit`</td><td style="width: 8.82002%;">string</td><td style="width: 6.20225%;">""</td><td style="width: 65.9074%;">Unit of measurement (e.g. `"°C"`)</td></tr><tr><td style="width: 19.0703%;">`dataType`</td><td style="width: 8.82002%;">int</td><td style="width: 6.20225%;">0</td><td style="width: 65.9074%;">ZCL data type.

Required for configuring reporting and if this attribute is writable.

Can be omitted if the attribute is not writable and not reported.

</td></tr><tr><td style="width: 19.0703%;">`ram`</td><td style="width: 8.82002%;">bool</td><td style="width: 6.20225%;">false</td><td style="width: 65.9074%;">If True, ZHB will create a special container (record) for this attribute in RAM at startup. The received value will be cached in this container, only the last value is stored.</td></tr><tr><td style="width: 19.0703%;">`rp`</td><td style="width: 8.82002%;">bool</td><td style="width: 6.20225%;">false</td><td style="width: 65.9074%;">Configure attribute reporting during interview. Also adds this attribute to web UI "configure reporting" dialog.</td></tr><tr><td style="width: 19.0703%;">`minReport`</td><td style="width: 8.82002%;">int</td><td style="width: 6.20225%;">1</td><td style="width: 65.9074%;">Reporting: minimum interval, s.  
Can be omitted if `rp` is `false`</td></tr><tr><td style="width: 19.0703%;">`maxReport`</td><td style="width: 8.82002%;">int</td><td style="width: 6.20225%;">3600</td><td style="width: 65.9074%;">Reporting: maximum interval, s.  
Can be omitted if `rp` is `false`</td></tr><tr><td style="width: 19.0703%;">`change`</td><td style="width: 8.82002%;">int</td><td style="width: 6.20225%;">1</td><td style="width: 65.9074%;">Reporting: reportable change threshold.  
Can be omitted if `rp` is `false`</td></tr><tr><td style="width: 19.0703%;">`rpChangeMult`</td><td style="width: 8.82002%;">real</td><td style="width: 6.20225%;">0</td><td style="width: 65.9074%;">Multiplier for the web UI "configure reporting" dialog:

raw ZCL reportable change = human value × `rpChangeMult` (e.g. `100` for 0.01-unit temperature).  
Can be omitted if `rp` is `false`

</td></tr><tr><td style="width: 19.0703%;">`query`</td><td style="width: 8.82002%;">bool</td><td style="width: 6.20225%;">false</td><td style="width: 65.9074%;">If True, the coordinator will send a request to read this attribute when the hub starts.</td></tr><tr><td style="width: 19.0703%;">`on_normalize`</td><td style="width: 8.82002%;">function</td><td style="width: 6.20225%;">nil</td><td style="width: 65.9074%;">Tells ZHB how to convert raw Zigbee data into usable data, see §5.4</td></tr><tr><td style="width: 19.0703%;">`on_serialization`</td><td style="width: 8.82002%;">function</td><td style="width: 6.20225%;">nil</td><td style="width: 65.9074%;">Tells ZHB how to convert data in a container (record) into a text representation, see §5.5</td></tr><tr><td style="width: 19.0703%;">`on_discovery`</td><td style="width: 8.82002%;">function</td><td style="width: 6.20225%;">nil</td><td style="width: 65.9074%;">Allow you to customize/veto discovery for this attribute, see §5.6</td></tr><tr><td style="width: 19.0703%;">`on_mqtt_write`</td><td style="width: 8.82002%;">function</td><td style="width: 6.20225%;">nil</td><td style="width: 65.9074%;">Tells ZHB how to handle `{base}/write/...` MQTT messages, see §5.7</td></tr></tbody></table>

`mqttClass` values:

<table id="bkmrk-value-class-0-none-%28"><thead><tr><th>Value</th><th>Class</th></tr></thead><tbody><tr><td>0</td><td>NONE (not exposed to WEB/MQTT)</td></tr><tr><td>1</td><td>BINARY\_SENSOR</td></tr><tr><td>2</td><td>BUTTON</td></tr><tr><td>3</td><td>TRIGGER</td></tr><tr><td>4</td><td><s>EVENT</s> (*Not supported by ZHB dashboard*)</td></tr><tr><td>5</td><td><s>FAN</s> (*Not supported by ZHB dashboard*)</td></tr><tr><td>6</td><td>LIGHT</td></tr><tr><td>7</td><td>NUMBER</td></tr><tr><td>8</td><td>SELECT</td></tr><tr><td>9</td><td>SENSOR</td></tr><tr><td>10</td><td>SWITCH</td></tr><tr><td>11</td><td><s>TEXT</s> (*Not supported by ZHB dashboard*)</td></tr><tr><td>12</td><td><s>COVER</s> (*Not supported by ZHB dashboard*)</td></tr></tbody></table>

---

## 5. Callback reference

All callbacks run inside your script's VM, scheduled through the Berry event system. The hub task waits up to ~20 ms for the VM to become available; if the VM is busy longer (e.g. a blocked loop in your script), the **event is dropped** — keep both the callbacks and the rest of the script non-blocking.

`dev` arguments are `ZigbeeDevice` instances, `record` arguments are `ZclAttrRecord` instances (§6). `doc`/`data` are JSON proxy objects supporting `obj["key"] = value` style access.

### 5.1 on\_annonce

Called when an attached device sends a device announce (typically power-on or rejoin). Return value ignored.

### 5.2 on\_intw\_stage

Called before interview stages, identified by string `tag`. Return one of:

<table id="bkmrk-return-meaning-0-%28do"><thead><tr><th>Return</th><th>Meaning</th></tr></thead><tbody><tr><td>`0` (DONE)</td><td>Stage handled by the converter — hub skips its standard logic and go to next stage</td></tr><tr><td>`1` (WAIT)</td><td>Converter started something async — hub waits, stage will be re-entered</td></tr><tr><td>`2` (ERR)</td><td>Fail interview on this stage</td></tr><tr><td>`3` (ZCN\_UNUSED)</td><td>Converter does not care — hub runs standard logic (**default choice**)</td></tr></tbody></table>

Stage tags: `req_ep`, `discover_ep`, `get_power_source`, `autobind`, `conf_reporting`, `ias_enroll`, `get_ias_type`, `send_tuya_magic`.

Caveat (same as native): on the **first** interview the converter is matched at the `intw_zcn` stage, so tags for stages ordered *before* it (`req_ep`, `discover_ep`) only fire on re-interview — unless the device was pre-attached with `ZCN.attach()`.

### 5.3 cmd\_config.on\_cmd

Called when a ZCL **command** arrives on the cluster (`record.isCmd()` is true; `record.getAttr()` holds the command id). Return the action string to publish (must be one of the `exposes` values for HA to recognize it). Non-string return = not handled.  
Important for Tuya commands: `record.asInt()` contains the ID of the Tuya command and `record.getAttr()` its data.

### 5.4 on\_normalize

Called right after the raw ZCL value is parsed into the record, **before** it is stored and serialized.  
Mutate the record in place:

```python
"on_normalize": def (record, dev)
  record.setFloat(record.asInt() / 100.0)
end
```

You can also *redirect* a record to another cluster/attr (`record.setCl()`, `record.setAttr()`, `record.setEp()`) — e.g. unpacking a proprietary struct into standard records. The redirect target attr must exist (declare it with `ram: true`).

### 5.5 on\_serialization

Called when the stored record is converted to JSON for MQTT / web dashboard.  
Write into `data`:

```python
"on_serialization": def (record, dev, data)
  data["type_sm"] = "report"
  data["val"] = record.asFloat()
end
```

Keys follow the native `zcl_json` convention: `"type_sm"` (usually `"report"`) and `"val"`. If absent, standard serialization for the data type applies.

### 5.6 on\_discovery

Called when generating discovery payload for this attribute. `doc` is the discovery JSON (base payload already filled from `mqttClass`/`mqttSubClass`/`name`/`unit`); `zhbBase` is the MQTT base topic. Add or override keys, e.g. `doc["state_class"] = "measurement"`. In most cases, you only use `doc` if you need to add something (like `min`, `max` and `step` for a slider).

Return `true` to publish, **`false` to veto** discovery of this attribute. Note: with no callback set the attribute is still discovered when `mqttClass != 0`; but if you *do* set a callback, you must return `true` for it to be published.

### 5.7 on\_mqtt\_write

Called for MQTT messages on the `{base}/write/...` topic targeting this attribute. `data` is the raw payload string. Convert and send to the device (`dev.sendCmd`, `dev.sendTuyaData`, etc.). Return `true` = handled (standard ZCL write skipped).

### 5.8 on\_mqtt\_cmd (cluster level)

Same idea for the `{base}/cmd/...` topic — cluster commands sent from MQTT (e.g. switch toggles). Return `true` = handled.

---

## 6. Objects available in callbacks

### 6.1 ZigbeeDevice - Berry Zigbee device proxy

<table id="bkmrk-method-description-m"><thead><tr><th>Method</th><th>Description</th></tr></thead><tbody><tr><td>`matcher(manuf, model) -> bool`</td><td>Exact manuf+model compare</td></tr><tr><td>`getName()` / `getIeee()` / `getModel()` / `getManuf()` / `getNwk()`</td><td>Identity</td></tr><tr><td>`hasName()` / `setName(s)` / `setModel(s)` / `setManuf(s)`</td><td>Identity write access (e.g. normalizing Tuya `_TZE200_...` model strings in a `matcher`)</td></tr><tr><td>`getPS()` / `setPS(v)`</td><td>Power source (`CONST_ZCL_PS.*`)</td></tr><tr><td>`getBattery()` / `getLqi()` / `getLastSeen()`</td><td>Telemetry</td></tr><tr><td>`getIAS()` / `setIAS(v)`</td><td>IAS zone type (`CONST_ZCL_IAS.*`)</td></tr><tr><td>`isBattery()` / `isAc()` / `isTuya()` / `haveTuyaDP()` / `isInterviewing()` / `isZcnUsed()`</td><td>State predicates (`isZcnUsed` = a **native** converter is attached)</td></tr><tr><td>`haveEndpoint(ep)` / `addEp(ep)` / `removeEp(ep)`</td><td>Endpoint list management (`addEp` creates an empty endpoint — populate it with `addInCluster`/`addOutCluster`)</td></tr><tr><td>`hasInCluster(cl)` / `addInCluster(cl [, ep])`</td><td>Input cluster list (ep 0 / omitted = first endpoint); duplicate-safe</td></tr><tr><td>`hasOutCluster(cl)` / `addOutCluster(cl [, ep])`</td><td>Output cluster list, same semantics</td></tr><tr><td>`removeCluster(ep, cl)`</td><td>Remove a single input cluster from an endpoint ("ignore cluster X" quirks)</td></tr><tr><td>`getVal(ep, cl, attr)` / `setVal(ep, cl, attr, value)`</td><td>Read / update a stored record value (`setVal` accepts bool/int/real/string/bytes and only updates an **existing** record)</td></tr><tr><td>`addRecord(ep, cl, attr)` / `haveRecord(ep, cl, attr)` / `removeRecord(ep, cl, attr)` / `removeAllRecords()`</td><td>RAM record management</td></tr><tr><td>`queryAllRecords([pauseMs])` / `queryMissingRecords([pauseMs])`</td><td>Actively read all / value-less records from the device (**warning**: a non-zero pause blocks the script and the hub for pause × records ms — prefer 0)</td></tr><tr><td>`startPooling(intervalSec, ep, cl, attr)` / `stopPooling(ep, cl, attr)`</td><td>Periodic attribute polling driven by the hub task</td></tr><tr><td>`sendOnOff` / `sendBri` / `sendColor` / `sendColorTemp`</td><td>High-level actuator commands</td></tr><tr><td>`sendCmd(ep, cl, cmd [, bytes])`</td><td>Raw ZCL cluster command</td></tr><tr><td>`sendTuyaData(dp, ztype, val)`</td><td>Tuya 0xEF00 datapoint write</td></tr><tr><td>`sendTuyaQuery()` / `sendTuyaMagic()`</td><td>Tuya: query all datapoints / send the "magic" init packet (typical in `on_annonce`)</td></tr><tr><td>`readAttr(ep, cl, attr...)`</td><td>ZCL Read Attributes request</td></tr><tr><td>`writeAttr(ep, cl, attr, dType, bytes)`</td><td>ZCL Write Attribute with a raw payload (for custom `on_mqtt_write` handlers)</td></tr><tr><td>`confReporting(ep, cl, attr, dType, minReport, maxReport, change)`</td><td>ZCL Configure Reporting (for custom `on_intw_stage("conf_reporting")` handling)</td></tr><tr><td>`nodeDescReq()` / `simpleDescReq(ep)` / `reqEndpoints()`</td><td>Low-level ZDO requests; responses are processed by the interview state machine, so these are mainly useful inside `on_intw_stage`</td></tr><tr><td>`bindToHub` / `bindToDevice` / `bindToGroup`</td><td>Binding operations</td></tr><tr><td>*(module-level)* `ZHB.await(seq [, timeoutMs]) -> bool` / `ZHB.lastStatus()` / `ZHB.on_response(seq, cb(ok, status) [, timeoutMs]) -> bool`</td><td>Wait for the device's response to a previous send (sync / async); the device is resolved from `seq`. **`ZHB.await()` raises inside ZCN callbacks** — they run on the ZHB task; use `ZHB.on_response()` there. See `docs/slzb-os-scripts/docs/modules/zhb.md`</td></tr></tbody></table>

### 6.2 ZclAttrRecord - Zigbee data container

Getters: `getEp()`, `getCl()`, `getAttr()`, `getStatus()`, `getZtype()` (raw ZCL type), `getSMtype()` (internal storage type, compare against `ZclAttrRecord.TYPE_*` constants: `TYPE_NONE/U32/I32/FLOAT/BUF/STR/BOOL/CMD/CMD_PAYLOAD`).

Predicates: `isCmd()`, `isCmdPayload()`, `isTuya()` (cluster == 0xEF00), `hasPayload()` (type is not `TYPE_NONE`), `matcher(cl, attr [, ep]) -> bool`.

Value access: `asInt()`, `asFloat()`, `asStr()`, `asBytes()`, `asLumiStruct([start])` — parses the Lumi/Aqara proprietary struct (Basic 0xFF01/0xFF02 buffer) into a `{tag_id: int}` map; `getMSB16()` / `getLSB16()` — high/low 16-bit halves of the raw 32-bit value (e.g. packed color X/Y).

Mutation: `setInt(v)`, `setUInt(v)`, `setFloat(v)`, `setBool(v)`, `setStr(v)`, `setBuf(bytes)` (replaces the payload with a copy of a Berry `bytes()` buffer, 1–255 bytes), `setCmd(cmdId [, tuyaCluster])`, `setMSB16(v)` / `setLSB16(v)`, `setEp(v)`, `setCl(v)`, `setAttr(v)`.

---

## 7. ZCNP — native callback presets

The `ZCNP` module exposes ready-made **preset functions** used by built-in converters, so a Berry converter can reference them directly instead of re-implementing the logic:

```python
import ZCNP
...
"on_normalize": ZCNP.zcn_norm_i16_divide_100, # the received zigbee value has data type int16 and needs to be divided by 100 to convert it to float
"on_serialization": ZCNP.zcn_ser_float, # the received value is converted to float, so we convert float to string
"on_discovery": ZCNP.zcn_dis_gen_basic_measurement, # add "measurement" class on discovery
```

<table id="bkmrk-kind-functions-norma"><thead><tr><th>Kind</th><th>Functions</th></tr></thead><tbody><tr><td>normalize</td><td>`zcn_norm_i16_divide_10/100/1000`, `zcn_norm_u16_divide_10/100/1000`, `zcn_norm_lumi_basic`</td></tr><tr><td>serialization</td><td>`zcn_ser_float`, `zcn_ser_raw_uint`, `zcn_ser_xy`, `zcn_tuya_ser_switch`, `zcn_ser_tuya_batt_enum`, `zcn_ser_lumi_basic`</td></tr><tr><td>discovery</td><td>`zcn_dis_gen_basic`, `zcn_dis_gen_basic_measurement`, `zcn_dis_tuya_batt_enum`</td></tr><tr><td>mqtt write</td><td>`zcn_write_tuya_int`, `zcn_write_tuya_float_int100`, `zcn_write_int16`, `tuya_switch_handler`</td></tr><tr><td>cmd</td><td>`zcn_cmd_onoff_action`, `tuya_ias_wd_cmd_action`</td></tr></tbody></table>

<table border="1" id="bkmrk-zcn_norm_i16_divide_" style="border-collapse: collapse; width: 100%; height: 427.25px;"><colgroup><col style="width: 50%;"></col><col style="width: 50%;"></col></colgroup><tbody><tr style="height: 46.5938px;"><td style="height: 46.5938px;">`zcn_norm_i16_divide_10/100/1000`</td><td style="height: 46.5938px;">divides received two-byte signed int by 10/100/1000 and converts result to **float**.

Typically used for temperature sensors, etc.

</td></tr><tr style="height: 46.5938px;"><td style="height: 46.5938px;">`zcn_norm_u16_divide_10/100/1000`</td><td style="height: 46.5938px;">divides received two-byte unsigned int by 10/100/1000 and converts result to **float**.

Typically used for humidity or voltage sensors, etc

</td></tr><tr style="height: 30.1094px;"><td style="height: 30.1094px;">`zcn_norm_lumi_basic`</td><td style="height: 30.1094px;">parses the battery value from the proprietary LUMI structure and sends it to the basic cluster</td></tr><tr style="height: 30.1094px;"><td style="height: 30.1094px;">`zcn_dis_gen_basic`</td><td style="height: 30.1094px;">generates the minimum possible discovery payload</td></tr><tr style="height: 46.5938px;"><td style="height: 46.5938px;">`zcn_dis_gen_basic_measurement`</td><td style="height: 46.5938px;">generates the minimum possible discovery payload and add:

<div><div>{"state_class": "measurement"}</div></div></td></tr><tr style="height: 30.1094px;"><td style="height: 30.1094px;">`zcn_dis_tuya_batt_enum`</td><td style="height: 30.1094px;">battery enumeration: high, med, low</td></tr><tr style="height: 30.1094px;"><td style="height: 30.1094px;">`zcn_cmd_onoff_action`</td><td style="height: 30.1094px;">handles standard ZCL ON/OFF commands for cluster 0x0006</td></tr><tr style="height: 30.1094px;"><td style="height: 30.1094px;">`tuya_ias_wd_cmd_action`</td><td style="height: 30.1094px;">  
</td></tr><tr style="height: 30.1094px;"><td style="height: 30.1094px;">`zcn_write_tuya_int`</td><td style="height: 30.1094px;">sends the received value as **tuya int** type</td></tr><tr style="height: 46.5938px;"><td style="height: 46.5938px;">`zcn_write_tuya_float_int100`</td><td style="height: 46.5938px;">converts the received **float** value to **int** by multiplying by 100 and sends it as **tuya int** type</td></tr><tr style="height: 30.1094px;"><td style="height: 30.1094px;">`zcn_write_int16`</td><td style="height: 30.1094px;">writes a ZCL attribute with int16 data type</td></tr><tr style="height: 30.1094px;"><td style="height: 30.1094px;">`tuya_switch_handler`</td><td style="height: 30.1094px;">converts the received text "ON"/"OFF" text to **bool** (1/0) and sends it as **tuya bool** type</td></tr></tbody></table>

---

## 8. ZCN module API

Public functions:

<table id="bkmrk-function-description" style="width: 100%;"><thead><tr><th style="width: 32.8938%;">Function</th><th style="width: 67.1062%;">Description</th></tr></thead><tbody><tr><td style="width: 32.8938%;">`ZCN.register(converter_map) -> int`</td><td style="width: 67.1062%;">Register a converter from a Berry map (see §4).

returns used slot number, raises a error on failure

</td></tr><tr><td style="width: 32.8938%;">`ZCN.attach(slot, ieee_str) -> bool`</td><td style="width: 67.1062%;">Attach converter to a paired device (IEEE as hex string, e.g. `"0x00124b00..."`). Clears the device's saved-message cache and (re)creates the converter's RAM records.

Raises on: hub not started, bad slot, unknown IEEE.

</td></tr><tr><td style="width: 32.8938%;">`ZCN.delete(slot)`</td><td style="width: 67.1062%;">Free the slot: unpins all callbacks, frees all allocations. Automatic when the VM stops</td></tr></tbody></table>

## 9. Example — SNZB-01P custom button actions(triggers)

```python
#META {"start":0}
#ZCN_BUILDER {"scriptVariableName":"snzb01p_zcn","autostartOnBoot":false,"signatureMode":"model","signatureModel":"SNZB-01P","signatureManufacturer":"eWeLink","matcherCallback":{"enabled":false,"presetReference":"","bodyText":""},"onAnnonce":{"enabled":false,"presetReference":"","bodyText":""},"onIntwStage":{"enabled":true,"presetReference":"","bodyText":"if (tag == \"intw_get_power_source\")\n  dev.setPS(3)  # battery\n  return 0\nend\nreturn 3  # ZCN unused - standard handling"},"attachEnabled":false,"attachIeeeAddress":"","endpointOverrides":[{"endpointNumber":1,"clusters":[{"clusterIdText":"0x0006","addMode":0,"bindCluster":false,"onMqttCmd":{"enabled":false,"presetReference":"","bodyText":""},"commandConfig":{"enabled":true,"exposesList":"btn_single|btn_double|btn_long","onCmd":{"enabled":true,"presetReference":"","bodyText":"# attr: 0 = long, 1 = double, 2 = single\nvar names = ['btn_long', 'btn_double', 'btn_single']\nvar id = record.getAttr()\nif (id < 3) return names[id] end\nreturn \"\""},"exposesOverride":{"enabled":false,"presetReference":"","bodyText":""}},"attributes":[]}]}]}
import ZCN

var snzb01p_zcn = {
  "signature": {
    "model": "SNZB-01P",
    "manuf": "eWeLink",
  },
  "on_intw_stage": def(dev, tag)
    if (tag == "intw_get_power_source")
      dev.setPS(3)  # battery
      return 0
    end
    return 3  # ZCN unused - standard handling
  end,
  "overrides_count": 1,
  "overrides": [
    {
      "endpoint": 1,
      "clusterCount": 1,
      "clusters": [
        {
          "id": 0x0006,
          "cmd_config": {
            "exposes": "btn_single|btn_double|btn_long",
            "on_cmd": def(dev, record)
              # attr: 0 = long, 1 = double, 2 = single
              var names = ['btn_long', 'btn_double', 'btn_single']
              var cmd_id = record.getAttr()
              if (cmd_id < 3)
                return names[id]
              end

              return ""
            end,
          },
        },
      ],
    },
  ],
}

var zcn_slot = ZCN.register(snzb01p_zcn)
ZCN.attach(zcn_slot, "0x00124b0012345678")  # attach to a device manually
```

---

## 10. Gotchas

1. **Counts are explicit.** `overrides_count`, `clusterCount`, `attrCount` are read from the map, not derived from list sizes. A count smaller than the list silently drops entries; a count larger than the list reads will crash device.
2. **Attach every boot.** `zcn_be` is not saved to the device DB. No `ZCN.attach()` after reboot (and no fresh interview) = converter silently inactive.
3. **`signature` is mandatory** — registration raises without it. `matcher` must be a function if present.
4. **Callbacks must be fast.**
5. **`on_cmd` needs `exposes`.** Without `exposes` or `exposesOverride` on the same cluster, the command path is never taken.
6. **`on_discovery` must return `true`** for the attribute to be discovered — a forgotten return (nil) vetoes it.
7. **`ram: true` for redirect targets and dashboard-only values** — otherwise the record does not exist until the device first reports it (or ever, for synthetic attrs).
8. **Slot exhaustion.** Only `2` Berry converters can exist at once, across all scripts. A crashed-then-restarted script frees and re-takes its slots automatically, but a script that registers in a loop will run out.
9. **`ZCN.attach` requires a running hub and a paired device** — it raises `"enable zHub first"` / `"wrong device ieee"` otherwise. If your script autostarts before the hub is up, wait via `ZHB.waitForStart()` first.
10. **Multiple matching converters:** Not recommended. Converters are meant to be unique.
11. **Berry converters are much slower than native ones!** Berry ZCN intended for rapid prototyping, not for continuous use as it will slow down the system.

# Zigbee Hub Converters architecture

### Zigbee data container (Record)

SLZB-OS parses and encapsulates incoming ZigBee messages in to special container (*we will call this **record***)

The container contains the following information:

1. ZCL status code
2. Zigbee data type
3. Parser type
4. Endpoint number
5. Cluster number
6. Attribute number
7. Received Zigbee data

Parser supports following Zigbee data types:

1. <div>0x41 octet string → byte array</div>
2. <div><div>0x42 char string → C string</div></div>
3. <div><div>0x2b int32 → int32</div></div>
4. <div><div>0x29 int16 → int32</div></div>
5. <div><div>0x28 int8 → int32</div></div>
6. <div><div>0x23 uint32 → uint32</div></div>
7. <div><div>0x21 uint16 → uint32</div></div>
8. <div><div>0x20 uint8 → uint32</div></div>
9. <div><div>0x31 enum16 → uint32</div></div>
10. <div><div>0x30 enum8 → uint32</div></div>
11. <div><div>0x19 bitmap16 → uint32</div></div>
12. 0x18 bitmap8 → uint32
13. Tuya bool → uint32
14. Tuya int → int32
15. Tuya enum. → uint32
16. Tuya bitmap → uint32
17. Tuya string → C string. **Length limit 254 symbols**
18. any other received data is converted to a byte array

As you can see there are many types of ZigBee data and we need to bring it to some standard to work with it. The parser does this.  
Parser sets the internal data type based on the conversion algorithm that was applied (*you can see which conversion algorithms are in the list above*).

Each record contains a **uint32** data payload field to store data, this means that a record can store numeric values ​​**up to 32 bits** and anything larger must exist as a **dynamically allocated pointer or byte array pointer.**

Data types that can be stored in a record:

1. **NONE**. The container is empty.
2. **U32**. 32-bit unsigned number
3. **I32**. 32-bit signed number
4. **FLOAT**. 32-bit floating point number
5. **BOOL**. binary value, 1 or 0. Technically it is still stored as a 32bit number
6. **STR**. C string, NULL terminated, stored as a pointer
7. **BUF**. C byte array
8. **CMD**. ZCL command. instead of an attribute, the command ID is stored. For Tuya commands, the command type is specified in the payload as a uint32 number.

### Record Lifecycle

The record lifecycle consists of four main stages:  
1\. Creation  
2\. Filling  
3\. Normalization  
4\. Serialization

#### Record Creation

All records are created when the hub starts.  
The hub checks the list of ZCN and Berry converters and creates an record for each attribute that has `.ram = true` set.

At this point, all containers have been created but they are empty.

#### Record Filling

The record is populated when data is received from a ZigBee device.  
The hub parses the data and sets the **endpoint**, **cluster** and **attribute** values, then converts the ZigBee data to "raw" C++ data.  
At this stage, we have received the so-called "raw" ZigBee data, we can already work with it but it may need further **normalization**.

#### Record Normalization

Zigbee data may not come in a format that we can easily understand.  
For example, Zigbee temperature sensors send two-byte signed measurements (int16), this looks like the number **2230** for a temperature of **22.30**.  
So to get a **float** number **22.30** from this **integer** we need to:  
1\. divide the received value by 100  
2\. save the result in record as a **float**

**This is called normalization.**

#### Record Serialization

MQTT and the web interface using strings, so to send data there we need to correctly convert record to **string**. A special callback **on\_serialization** is used for this.

### Zigbee Hub converter structure