# 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><code>signature</code></td><td>map</td><td>**yes** (raises otherwise)</td><td>Device matching, see below</td></tr><tr><td><code>on&#95;annonce</code></td><td>function</td><td>no</td><td>Called when the device announces itself (power-on / rejoin)</td></tr><tr><td><code>on&#95;intw&#95;stage</code></td><td>function</td><td>no</td><td>Called before interview stages; see §5.2</td></tr><tr><td><code>overrides&#95;count</code></td><td>int</td><td>yes, if <code>overrides</code> present</td><td>**Must equal** <code>overrides&#46;size&#40;&#41;</code> — it is not derived automatically</td></tr><tr><td><code>overrides</code></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><code>matcher</code></td><td>function</td><td>If present, used instead of model/manuf. Must be a function</td></tr><tr><td><code>model</code></td><td>string</td><td>Zigbee model identifier (exact match via strcmp())</td></tr><tr><td><code>manuf</code></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><code>endpoint</code></td><td>int</td><td>Zigbee endpoint number this override applies to</td></tr><tr><td><code>clusterCount</code></td><td>int</td><td>Number of clusters in <code>clusters</code></td></tr><tr><td><code>clusters</code></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%;"><code>id</code></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. <code>0x0402</code>)</td></tr><tr><td style="width: 14.3027%;"><code>addMode</code></td><td style="width: 9.41597%;">int</td><td style="width: 6.45156%;">0</td><td style="width: 69.8297%;"><code>0</code> = ADD (merge with the standard cluster: your attrs are used where ids collide, standard attrs fill the rest),  
<code>1</code> = OVERRIDE (standard cluster fully ignored; only your definition used)</td></tr><tr><td style="width: 14.3027%;"><code>bind</code></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%;"><code>attrCount</code></td><td style="width: 9.41597%;">int</td><td style="width: 6.45156%;">0</td><td style="width: 69.8297%;">Number of attributes in <code>attrs</code></td></tr><tr><td style="width: 14.3027%;"><code>attrs</code></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%;"><code>cmd&#95;config</code></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%;"><code>on&#95;mqtt&#95;cmd</code></td><td style="width: 9.41597%;">function</td><td style="width: 6.45156%;">nil</td><td style="width: 69.8297%;">Used for <code>cmd&#95;config</code>.  
Handles MQTT messages on the <code>&#123;base&#125;&#47;cmd&#47;&#46;&#46;&#46;</code> topic for this cluster. Return <code>true</code> = 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%;"><code>on&#95;cmd</code></td><td style="width: 8.34213%;">function</td><td style="width: 80.3349%;">Converts the received command record into an action string (e.g. <code>&#34;btn&#95;single&#34;</code>).

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

</td></tr><tr><td style="width: 11.323%;"><code>exposes</code></td><td style="width: 8.34213%;">string</td><td style="width: 80.3349%;"><code>&#34;&#124;&#34;</code>-separated list of all possible action strings — used to generate device-trigger discovery</td></tr><tr><td style="width: 11.323%;"><code>exposesOverride</code></td><td style="width: 8.34213%;">function</td><td style="width: 80.3349%;">Dynamic alternative to <code>exposes</code>: return the <code>&#34;&#124;&#34;</code>-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%;"><code>id</code></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%;"><code>name</code></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%;"><code>mqttClass</code></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%;"><code>mqttSubClass</code></td><td style="width: 8.82002%;">string</td><td style="width: 6.20225%;">""</td><td style="width: 65.9074%;">MQTT entity <code>device&#95;class</code> (e.g. <code>&#34;temperature&#34;</code>, <code>&#34;moisture&#34;</code>)</td></tr><tr><td style="width: 19.0703%;"><code>unit</code></td><td style="width: 8.82002%;">string</td><td style="width: 6.20225%;">""</td><td style="width: 65.9074%;">Unit of measurement (e.g. <code>&#34;°C&#34;</code>)</td></tr><tr><td style="width: 19.0703%;"><code>dataType</code></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%;"><code>ram</code></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%;"><code>rp</code></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%;"><code>minReport</code></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 <code>rp</code> is <code>false</code></td></tr><tr><td style="width: 19.0703%;"><code>maxReport</code></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 <code>rp</code> is <code>false</code></td></tr><tr><td style="width: 19.0703%;"><code>change</code></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 <code>rp</code> is <code>false</code></td></tr><tr><td style="width: 19.0703%;"><code>rpChangeMult</code></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%;"><code>query</code></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%;"><code>on&#95;normalize</code></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%;"><code>on&#95;serialization</code></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%;"><code>on&#95;discovery</code></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%;"><code>on&#95;mqtt&#95;write</code></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 <code>&#123;base&#125;&#47;write&#47;&#46;&#46;&#46;</code> 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><code>0</code> (DONE)</td><td>Stage handled by the converter — hub skips its standard logic and go to next stage</td></tr><tr><td><code>1</code> (WAIT)</td><td>Converter started something async — hub waits, stage will be re-entered</td></tr><tr><td><code>2</code> (ERR)</td><td>Fail interview on this stage</td></tr><tr><td><code>3</code> (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><code>matcher&#40;manuf&#44; model&#41; &#45;&#62; bool</code></td><td>Exact manuf+model compare</td></tr><tr><td><code>getName&#40;&#41;</code> / <code>getIeee&#40;&#41;</code> / <code>getModel&#40;&#41;</code> / <code>getManuf&#40;&#41;</code> / <code>getNwk&#40;&#41;</code></td><td>Identity</td></tr><tr><td><code>hasName&#40;&#41;</code> / <code>setName&#40;s&#41;</code> / <code>setModel&#40;s&#41;</code> / <code>setManuf&#40;s&#41;</code></td><td>Identity write access (e.g. normalizing Tuya <code>&#95;TZE200&#95;&#46;&#46;&#46;</code> model strings in a <code>matcher</code>)</td></tr><tr><td><code>getPS&#40;&#41;</code> / <code>setPS&#40;v&#41;</code></td><td>Power source (<code>CONST&#95;ZCL&#95;PS&#46;&#42;</code>)</td></tr><tr><td><code>getBattery&#40;&#41;</code> / <code>getLqi&#40;&#41;</code> / <code>getLastSeen&#40;&#41;</code></td><td>Telemetry</td></tr><tr><td><code>getIAS&#40;&#41;</code> / <code>setIAS&#40;v&#41;</code></td><td>IAS zone type (<code>CONST&#95;ZCL&#95;IAS&#46;&#42;</code>)</td></tr><tr><td><code>isBattery&#40;&#41;</code> / <code>isAc&#40;&#41;</code> / <code>isTuya&#40;&#41;</code> / <code>haveTuyaDP&#40;&#41;</code> / <code>isInterviewing&#40;&#41;</code> / <code>isZcnUsed&#40;&#41;</code></td><td>State predicates (<code>isZcnUsed</code> = a **native** converter is attached)</td></tr><tr><td><code>haveEndpoint&#40;ep&#41;</code> / <code>addEp&#40;ep&#41;</code> / <code>removeEp&#40;ep&#41;</code></td><td>Endpoint list management (<code>addEp</code> creates an empty endpoint — populate it with <code>addInCluster</code>/<code>addOutCluster</code>)</td></tr><tr><td><code>hasInCluster&#40;cl&#41;</code> / <code>addInCluster&#40;cl &#91;&#44; ep&#93;&#41;</code></td><td>Input cluster list (ep 0 / omitted = first endpoint); duplicate-safe</td></tr><tr><td><code>hasOutCluster&#40;cl&#41;</code> / <code>addOutCluster&#40;cl &#91;&#44; ep&#93;&#41;</code></td><td>Output cluster list, same semantics</td></tr><tr><td><code>removeCluster&#40;ep&#44; cl&#41;</code></td><td>Remove a single input cluster from an endpoint ("ignore cluster X" quirks)</td></tr><tr><td><code>getVal&#40;ep&#44; cl&#44; attr&#41;</code> / <code>setVal&#40;ep&#44; cl&#44; attr&#44; value&#41;</code></td><td>Read / update a stored record value (<code>setVal</code> accepts bool/int/real/string/bytes and only updates an **existing** record)</td></tr><tr><td><code>addRecord&#40;ep&#44; cl&#44; attr&#41;</code> / <code>haveRecord&#40;ep&#44; cl&#44; attr&#41;</code> / <code>removeRecord&#40;ep&#44; cl&#44; attr&#41;</code> / <code>removeAllRecords&#40;&#41;</code></td><td>RAM record management</td></tr><tr><td><code>queryAllRecords&#40;&#91;pauseMs&#93;&#41;</code> / <code>queryMissingRecords&#40;&#91;pauseMs&#93;&#41;</code></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><code>startPooling&#40;intervalSec&#44; ep&#44; cl&#44; attr&#41;</code> / <code>stopPooling&#40;ep&#44; cl&#44; attr&#41;</code></td><td>Periodic attribute polling driven by the hub task</td></tr><tr><td><code>sendOnOff</code> / <code>sendBri</code> / <code>sendColor</code> / <code>sendColorTemp</code></td><td>High-level actuator commands</td></tr><tr><td><code>sendCmd&#40;ep&#44; cl&#44; cmd &#91;&#44; bytes&#93;&#41;</code></td><td>Raw ZCL cluster command</td></tr><tr><td><code>sendTuyaData&#40;dp&#44; ztype&#44; val&#41;</code></td><td>Tuya 0xEF00 datapoint write</td></tr><tr><td><code>sendTuyaQuery&#40;&#41;</code> / <code>sendTuyaMagic&#40;&#41;</code></td><td>Tuya: query all datapoints / send the "magic" init packet (typical in <code>on&#95;annonce</code>)</td></tr><tr><td><code>readAttr&#40;ep&#44; cl&#44; attr&#46;&#46;&#46;&#41;</code></td><td>ZCL Read Attributes request</td></tr><tr><td><code>writeAttr&#40;ep&#44; cl&#44; attr&#44; dType&#44; bytes&#41;</code></td><td>ZCL Write Attribute with a raw payload (for custom <code>on&#95;mqtt&#95;write</code> handlers)</td></tr><tr><td><code>confReporting&#40;ep&#44; cl&#44; attr&#44; dType&#44; minReport&#44; maxReport&#44; change&#41;</code></td><td>ZCL Configure Reporting (for custom <code>on&#95;intw&#95;stage&#40;&#34;conf&#95;reporting&#34;&#41;</code> handling)</td></tr><tr><td><code>nodeDescReq&#40;&#41;</code> / <code>simpleDescReq&#40;ep&#41;</code> / <code>reqEndpoints&#40;&#41;</code></td><td>Low-level ZDO requests; responses are processed by the interview state machine, so these are mainly useful inside <code>on&#95;intw&#95;stage</code></td></tr><tr><td><code>bindToHub</code> / <code>bindToDevice</code> / <code>bindToGroup</code></td><td>Binding operations</td></tr><tr><td>*(module-level)* <code>ZHB&#46;await&#40;seq &#91;&#44; timeoutMs&#93;&#41; &#45;&#62; bool</code> / <code>ZHB&#46;lastStatus&#40;&#41;</code> / <code>ZHB&#46;on&#95;response&#40;seq&#44; cb&#40;ok&#44; status&#41; &#91;&#44; timeoutMs&#93;&#41; &#45;&#62; bool</code></td><td>Wait for the device's response to a previous send (sync / async); the device is resolved from <code>seq</code>. **<code>ZHB&#46;await&#40;&#41;</code> raises inside ZCN callbacks** — they run on the ZHB task; use <code>ZHB&#46;on&#95;response&#40;&#41;</code> there. See <code>docs&#47;slzb&#45;os&#45;scripts&#47;docs&#47;modules&#47;zhb&#46;md</code></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><code>zcn&#95;norm&#95;i16&#95;divide&#95;10&#47;100&#47;1000</code>, <code>zcn&#95;norm&#95;u16&#95;divide&#95;10&#47;100&#47;1000</code>, <code>zcn&#95;norm&#95;lumi&#95;basic</code></td></tr><tr><td>serialization</td><td><code>zcn&#95;ser&#95;float</code>, <code>zcn&#95;ser&#95;raw&#95;uint</code>, <code>zcn&#95;ser&#95;xy</code>, <code>zcn&#95;tuya&#95;ser&#95;switch</code>, <code>zcn&#95;ser&#95;tuya&#95;batt&#95;enum</code>, <code>zcn&#95;ser&#95;lumi&#95;basic</code></td></tr><tr><td>discovery</td><td><code>zcn&#95;dis&#95;gen&#95;basic</code>, <code>zcn&#95;dis&#95;gen&#95;basic&#95;measurement</code>, <code>zcn&#95;dis&#95;tuya&#95;batt&#95;enum</code></td></tr><tr><td>mqtt write</td><td><code>zcn&#95;write&#95;tuya&#95;int</code>, <code>zcn&#95;write&#95;tuya&#95;float&#95;int100</code>, <code>zcn&#95;write&#95;int16</code>, <code>tuya&#95;switch&#95;handler</code></td></tr><tr><td>cmd</td><td><code>zcn&#95;cmd&#95;onoff&#95;action</code>, <code>tuya&#95;ias&#95;wd&#95;cmd&#95;action</code></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;"><code>zcn&#95;norm&#95;i16&#95;divide&#95;10&#47;100&#47;1000</code></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;"><code>zcn&#95;norm&#95;u16&#95;divide&#95;10&#47;100&#47;1000</code></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;"><code>zcn&#95;norm&#95;lumi&#95;basic</code></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;"><code>zcn&#95;dis&#95;gen&#95;basic</code></td><td style="height: 30.1094px;">generates the minimum possible discovery payload</td></tr><tr style="height: 46.5938px;"><td style="height: 46.5938px;"><code>zcn&#95;dis&#95;gen&#95;basic&#95;measurement</code></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;"><code>zcn&#95;dis&#95;tuya&#95;batt&#95;enum</code></td><td style="height: 30.1094px;">battery enumeration: high, med, low</td></tr><tr style="height: 30.1094px;"><td style="height: 30.1094px;"><code>zcn&#95;cmd&#95;onoff&#95;action</code></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;"><code>tuya&#95;ias&#95;wd&#95;cmd&#95;action</code></td><td style="height: 30.1094px;">  
</td></tr><tr style="height: 30.1094px;"><td style="height: 30.1094px;"><code>zcn&#95;write&#95;tuya&#95;int</code></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;"><code>zcn&#95;write&#95;tuya&#95;float&#95;int100</code></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;"><code>zcn&#95;write&#95;int16</code></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;"><code>tuya&#95;switch&#95;handler</code></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%;"><code>ZCN&#46;register&#40;converter&#95;map&#41; &#45;&#62; int</code></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%;"><code>ZCN&#46;attach&#40;slot&#44; ieee&#95;str&#41; &#45;&#62; bool</code></td><td style="width: 67.1062%;">Attach converter to a paired device (IEEE as hex string, e.g. <code>&#34;0x00124b00&#46;&#46;&#46;&#34;</code>). 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%;"><code>ZCN&#46;delete&#40;slot&#41;</code></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.