Berry ZCN system manual
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 (currently 2) slots for Berry converters. Each registered converter occupies one ZCN_BE_COUNTslot (id 0..ZCN_BE_COUNT-1).slot. 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 aswaiting(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 resetsto0xff(unattached)every boot and is set only by:- Interview matching — during a device
interview,intw_zcnchecks Berry converters and setszcn_bewhen one matches;interview. - Explicit
ZCN.attach(slot, ieee)from your script.
- Interview matching — during a device
Since interviews only run at pairing (or forced re-interview),pairing, a converter script must call ZCN.attach() for its devices every time it starts. The standard pattern:
var slot = ZCN.register(my_converter)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, thehighest 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:
#META {"start":0}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.slot. 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 |
no | Called when the device announces itself (power-on / rejoin) |
on_intw_stage |
function |
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 |
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 number this override applies to |
clusterCount |
int | Number of clusters in clusters |
clusters |
list of maps | Cluster |
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 | |
attrs |
list of maps | — | Attribute |
cmd_config |
map | — | Cluster command handling (buttons/actions), see below |
on_mqtt_cmd |
function |
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 |
Converts the received command record into an action string (e.g. Must return a string to publish it as an |
exposes |
string | "|"-separated list of all possible action strings — used to generate device-trigger discovery |
exposesOverride |
function |
Dynamic alternative to exposes: return the "|"-separated list at discovery time; |
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 Can be omitted if the attribute is not writable and not reported. |
ram |
bool | false | If True, |
rp |
bool | false | Configure attribute reporting during interview. Also adds this attribute to web UI "configure reporting" |
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 |
Multiplier for the web UI "configure reporting" dialog: raw ZCL reportable change = human value × |
query |
bool | false | If True, the coordinator will send a request to read this attribute when the hub starts. |
on_normalize |
function |
nil | Tells ZHB how to convert raw Zigbee data into usable data, see §5.4 |
on_serialization |
function |
nil | Tells ZHB how to convert data in a container (record) into a text representation, see §5.5 |
on_discovery |
function |
nil | Allow you to customize/veto discovery for this attribute, see §5.6 |
on_mqtt_write |
function |
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:
"on_normalize": def (record, dev)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:
"on_serialization": def (record, dev, 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)Berry Zigbee device proxy
| 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)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 theready-made 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):logic:
import ZCNP| 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 |
divides received two-byte signed number by 10/100/1000 to get a float. Typically used for temperature sensors, etc. |
zcn_norm_u16_divide_10/100/1000 |
divides received two-byte unsigned number by 10/100/1000 to get a float. Typically used for humidity or voltage sensors, etc |
zcn_norm_lumi_basic |
handle battery percentage for LUMI devices |
zcn_dis_gen_basic |
generates the minimum possible discovery payload |
zcn_dis_gen_basic_measurement |
generates the minimum possible discovery payload and add: {"state_class": "measurement"}
|
zcn_dis_tuya_batt_enum |
battery enumeration: high, med, low |
zcn_cmd_onoff_action |
handles standard ZCL ON/OFF commands for cluster 0x0006 |
tuya_ias_wd_cmd_action |
|
zcn_write_tuya_int |
sends the received value as tuya int type |
zcn_write_tuya_float_int100 |
converts the received float value to int by multiplying by 100 and sends it as tuya int type |
zcn_write_int16 |
writes a ZCL attribute with int16 data type |
tuya_switch_handler |
converts the received text "ON"/"OFF" to bool (1/0) and sends it as tuya bool type |
8. ZCN module API
Public functions:
| Function | Description |
|---|---|
ZCN.register(converter_map) -> |
Register a converter from a Berry map (see §4). returns |
ZCN.attach(slot, ieee_str) -> bool |
Attach converter Raises on: hub not started, bad slot, unknown IEEE. |
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 exampleExample — AqaraSNZB-01P watercustom leakbutton sensoractions(triggers)
lumi.sensor_wleak.aq1reports leak state via IAS Zone and battery/telemetry via the proprietary Lumi struct in Basic attr 0xFF01:#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 ZCNimport ZCNPvarlumi_weather_zcnsnzb01p_zcn = {"signature": {"model": "lumi.weather"SNZB-01P","manuf": "LUMI"eWeLink",},"on_intw_stage": def(dev, tag)# Lumi ignores standard Configure Reporting - finish the interview right awayif (tag == "intw_get_power_source")dev.setPS(3) # batteryendreturn-0xff0 end return 3 #forceZCNinterviewunuseddonestandard handling end,"overrides_count": 1,"overrides": [{"endpoint": 1,"clusterCount":1,2,"clusters": [{"id":0x0006,0x0001,"{attrCount"cmd_config":1,"attrs": [{"id": 0x0021,"name"exposes": "Battery"btn_single|btn_double|btn_long","def(dev,unit"on_cmd":"%","mqttClass":record)9,"mqttSubClass": "battery","ram": true,"on_serialization": ZCNP.zcn_ser_float,"on_discovery": ZCNP.zcn_dis_gen_basic,},],},#long,Handleattr:LUMI0proprietary=attribute.return#1This=attributedouble,takes2a=proprietarysingleLUMIvarstructure,namesparses=it,['btn_long',extracts'btn_double',the'btn_single']batteryvarvalue,cmd_idand=sendsrecord.getAttr()it as standard battery reportingif (clustercmd_id0x0001,<attr3)0x0021).names[id]{"id":end0x0000,"attrCount": 1,"attrs": [{"id": 0xFF01,"name":return "","on_normalize":end,ZCNP.zcn_norm_lumi_basic,"on_serialization": ZCNP.zcn_ser_lumi_basic,},}, ],},],},],}var zcn_slot = ZCN.register(snzb01p_zcn)lumi_weather_zcn)ZCN.attach(zcn_slot, "0x00124b0012345678") # attach to a device manually
A fully custom (no-preset) variant of the struct unpacking would userecord.asLumiStruct()inon_normalizeand redirect values withrecord.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
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.ZCN_BE_COUNT2 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.