Berry ZCN system manual
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):
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/anonymousdefblocks 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 to0xff(unattached) every boot and is set only by:- Interview matching — during a device interview,
intw_zcnchecks Berry converters and setszcn_bewhen one matches; - Explicit
ZCN.attach(slot, ieee)from your script.
- Interview matching — during a device interview,
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:
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_beis 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 nativeZCN_CHECK(dev)— data handling, on_cmd, MQTT write/cmd, HA discovery, trigger discovery. A device can technically have bothzcnandzcn_beset; 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
matcherfunction — it is called asdef (dev)with aZigbeeDeviceinstance and must returntrue/false.model/manufstrings are ignored. - Otherwise — exact string compare of both
manuf(Basic attr 0x0004) andmodel(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:
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),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.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. Can be omitted if rp is false |
maxReport |
int | 3600 | Reporting: maximum interval, s. Can be omitted if rp is false |
change |
int | 1 | Reporting: reportable change threshold. 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).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 | |
| 5 | |
| 6 | LIGHT |
| 7 | NUMBER |
| 8 | SELECT |
| 9 | SENSOR |
| 10 | SWITCH |
| 11 | |
| 12 |
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) |
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.
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:
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:
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):
| 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 |
zcn_norm_i16_divide_10/100/1000 |
|
zcn_norm_u16_divide_10/100/1000 |
|
zcn_norm_lumi_basic |
|
zcn_dis_gen_basic |
|
zcn_dis_gen_basic_measurement |
|
zcn_dis_tuya_batt_enum |
|
zcn_cmd_onoff_action |
|
tuya_ias_wd_cmd_action |
|
zcn_write_tuya_int |
|
zcn_write_tuya_float_int100 |
|
zcn_write_int16 |
|
tuya_switch_handler |
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:
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
- Counts are explicit.
overrides_count,clusterCount,attrCountare 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. - Attach every boot.
zcn_beis not saved to the device DB. NoZCN.attach()after reboot (and no fresh interview) = converter silently inactive. signatureis mandatory — registration raises without it.matchermust be a function if present.- 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).
on_cmdneedsexposes. WithoutexposesorexposesOverrideon the same cluster, the command path is never taken.on_discoverymust returntruefor the attribute to be discovered — a forgotten return (nil) vetoes it.ram: truefor redirect targets and dashboard-only values — otherwise the record does not exist until the device first reports it (or ever, for synthetic attrs).- Slot exhaustion. Only
ZCN_BE_COUNTBerry 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. ZCN.attachrequires 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 viaZHB.waitForStart()first.- Multiple matching converters: Not recommended. Converters are meant to be unique.
- 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.