# 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.