Skip to main content

Berry ZCN

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):


#define ZCN_BE_COUNT 2 // converter_list.h
zcn_converter_be_t* zcn_converters_be[ZCN_BE_COUNT]; // heap-allocated per slot

There are ZCN_BE_COUNT (currently 2) slots for Berry converters. Each registered converter occupies one slot (id 0..ZCN_BE_COUNT-1). Registration fails with "no free slots for ZCN" when all slots are taken.

Each slot stores a zcn_converter_be_t — the C mirror of the native zcn_converter_t, but with bvalue Berry callbacks instead of C function pointers, heap-allocated strings, and a back-pointer to the owning Berry VM (converter_defines.h:194).

2.2 VM binding and garbage collection

  • A converter belongs to the VM that registered itZCN._new() records the VM and marks the script as waiting (be_events_mark_waiting) so it stays resident to serve callbacks.
  • All callback functions passed into the structure are GC-pinned (be_gc_fix_set) while the converter exists, and unpinned on delete — you can use closures/anonymous def blocks safely.
  • When a script's VM stops, all its converters are automatically deleted. Restarting the script re-registers them.

2.3 Attachment is NOT persisted — attach on every boot

This is the most important lifecycle difference from native ZCN:

  • The native converter index (dev->data.zcn) is saved in the device database (/zb/db/{IEEE}.json, key "zcn") and restored on boot.
  • The Berry converter index (dev->data.zcn_be) is runtime-only. It resets to 0xff (unattached) every boot and is set only by:
    1. Interview matching — during a device interview, intw_zcn checks Berry converters and sets zcn_be when one matches;
    2. Explicit ZCN.attach(slot, ieee) from your script.

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


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

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

2.4 Priority over native converters

Berry converters take priority everywhere:

  • Interview matching (intw_zcnstandalone.cpp:3638): all Berry slots are checked first; if one matches, zcn_be is set and native matching is skipped. (If several Berry converters match, the highest slot id wins.)
  • Runtime dispatch: every hook site checks ZCN_CHECK_BE(dev) before the native ZCN_CHECK(dev) — data handling, on_cmd, MQTT write/cmd, HA discovery, trigger discovery. A device can technically have both zcn and zcn_be set; the Berry one is consulted first wherever it provides an override.

2.5 Matching logic

Same rules as native (zcn_device_signature_t):

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

3. Quick start

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

Minimal hand-written converter:


#META {"start":0}
import ZCN

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

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

ZCN.register() is implemented in Berry and solidified into the firmware: it validates the map, finds a free slot, and feeds the structure into the native slot via the low-level ZCN._* functions. It returns the slot id (needed for ZCN.attach) and raises a descriptive error on failure (the partially built slot is deleted automatically).


4. Converter map reference

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

4.1 Top level

KeyTypeRequiredMeaning
signaturemapyes (raises otherwise)Device matching, see below
on_annoncefunction def (dev)noCalled when the device announces itself (power-on / rejoin)
on_intw_stagefunction def (dev, tag) -> intnoCalled before interview stages; see §5.2
overrides_countintyes, if overrides presentMust equal overrides.size() — it is not derived automatically
overrideslist of mapsnoEndpoint 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.

KeyTypeMeaning
matcherfunction def (dev) -> boolIf present, used instead of model/manuf. Must be a function
modelstringZigbee model identifier (exact match via strcmp())
manufstringManufacturer name (exact match)

4.3 Endpoint override (element of overrides)

KeyTypeMeaning
endpointintZigbee endpoint this override applies to
clusterCountintNumber of clusters in clusters
clusterslist of mapsCluster overrides

4.4 Cluster override (element of clusters)

KeyTypeDefaultMeaning
idint0ZCL cluster id (e.g. 0x0402)
addModeint00 = 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)
bindboolfalseBind this cluster to the hub during interview
attrCountint0Must equal attrs.size()
attrslist of mapsAttribute overrides
cmd_configmapCluster command handling (buttons/actions), see below
on_mqtt_cmdfunction def (ep, cl, cmd, topic, data, dev) -> boolnilUsed 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.

KeyTypeMeaning
on_cmdfunction def (dev, record) -> string | nilConvert 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"
exposesstring"|"-separated list of all possible action strings — used to generate device-trigger discovery
exposesOverridefunction def (dev, ep, cl) -> string | nilDynamic 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)

KeyTypeDefaultMeaning
idint0ZCL attribute id
namestring""Human-readable name (shown in web UI)
mqttClassint0MQTT entity type, see table below
mqttSubClassstring""MQTT entity device_class (e.g. "temperature""moisture")
unitstring""Unit of measurement (e.g. "°C")
dataTypeint0ZCL 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.
ramboolfalseIf 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.
rpboolfalseConfigure attribute reporting during interview. Also adds this attribute to web UI "configure reporting" dialog
minReportint1Reporting: minimum interval, s.
Can be omitted if rp is false
maxReportint3600Reporting: maximum interval, s.
Can be omitted if rp is false
changeint1Reporting: reportable change threshold.
Can be omitted if rp is false
rpChangeMultreal0 (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
queryboolfalseIf True, the coordinator will send a request to read this attribute when the hub starts.
on_normalizefunction def (record, dev)nilTells ZHB how to convert raw Zigbee data into usable data, see §5.4
on_serializationfunction def (record, dev, data)nilTells ZHB how to convert data in a container (record) into a text representation, see §5.5
on_discoveryfunction def (ep, cl, attr, zType, dev, doc, zhbBase) -> boolnilAllow you to customize/veto discovery for this attribute, see §5.6
on_mqtt_writefunction def (ep, cl, attr, dType, topic, data, dev) -> boolnilTells ZHB how to handle {base}/write/... MQTT messages, see §5.7

mqttClass values:

ValueClass
0NONE (not exposed to WEB/MQTT)
1BINARY_SENSOR
2BUTTON
3TRIGGER
4EVENT (Not supported by ZHB dashboard)
5FAN (Not supported by ZHB dashboard)
6LIGHT
7NUMBER
8SELECT
9SENSOR
10SWITCH
11TEXT (Not supported by ZHB dashboard)
12COVER (Not supported by ZHB dashboard)

5. Callback reference

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

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

5.1 on_annonce — def (dev)

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

5.2 on_intw_stage — def (dev, tag) -> int

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

ReturnMeaning
0 (DONE)Stage handled by the converter — hub skips its standard logic and go to next stage
1 (WAIT)Converter started something async — hub waits, stage will be re-entered
2 (ERR)Fail interview on this stage
3 (ZCN_UNUSED)Converter does not care — hub runs standard logic (default choice)

Stage tags: req_epdiscover_epget_power_sourceautobindconf_reportingias_enrollget_ias_typesend_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_epdiscover_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)
record.setFloat(record.asInt() / 100.0)
end

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

5.5 on_serialization — def (record, dev, data)

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


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

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

5.6 on_discovery — def (ep, cl, attr, zType, dev, doc, zhbBase) -> bool

Called when generating discovery payload for this attribute. doc is the discovery JSON (base payload already filled from mqttClass/mqttSubClass/name/unit); zhbBase is the MQTT base topic. Add or override keys, e.g. doc["state_class"] = "measurement". In most cases, you only use doc if you need to add something (like minmax 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.sendCmddev.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)

MethodDescription
matcher(manuf, model) -> boolExact 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 / sendColorTempHigh-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 / bindToGroupBinding operations
(module-level) ZHB.await(seq [, timeoutMs]) -> bool / ZHB.lastStatus() / ZHB.on_response(seq, cb(ok, status) [, timeoutMs]) -> boolWait for the device's response to a previous send (sync / async); the device is resolved from seqZHB.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):


import ZCNP
...
"on_normalize": ZCNP.zcn_norm_i16_divide_100,
"on_serialization": ZCNP.zcn_ser_float,
"on_discovery": ZCNP.zcn_dis_gen_basic_measurement,
KindFunctions
normalizezcn_norm_i16_divide_10/100/1000zcn_norm_u16_divide_10/100/1000zcn_norm_lumi_basic
serializationzcn_ser_floatzcn_ser_raw_uintzcn_ser_xyzcn_tuya_ser_switchzcn_ser_tuya_batt_enumzcn_ser_lumi_basic
discoveryzcn_dis_gen_basiczcn_dis_gen_basic_measurementzcn_dis_tuya_batt_enum
mqtt writezcn_write_tuya_intzcn_write_tuya_float_int100zcn_write_int16tuya_switch_handler
cmdzcn_cmd_onoff_actiontuya_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:

FunctionDescription
ZCN.register(converter_map) -> slotRegister 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) -> boolAttach 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:


#META {"start":0}
import ZCN
import ZCNP

var lumi_weather_zcn = {
"signature": {
"model": "lumi.weather",
"manuf": "LUMI",
},
"on_intw_stage": def(dev, tag)
# Lumi ignores standard Configure Reporting - finish the interview right away
if (tag == "intw_get_power_source")
dev.setPS(3) # battery
end
return 0xff # force interview done
end,
"overrides_count": 1,
"overrides": [
{
"endpoint": 1,
"clusterCount": 2,
"clusters": [
{
"id": 0x0001,
"attrCount": 1,
"attrs": [
{
"id": 0x0021,
"name": "Battery",
"unit": "%",
"mqttClass": 9,
"mqttSubClass": "battery",
"ram": true,
"on_serialization": ZCNP.zcn_ser_float,
"on_discovery": ZCNP.zcn_dis_gen_basic,
},
],
},
# Handle LUMI proprietary attribute.
# This attribute takes a proprietary LUMI structure, parses it, extracts the battery value, and sends it as standard battery reporting (cluster 0x0001, attr 0x0021).
{
"id": 0x0000,
"attrCount": 1,
"attrs": [
{
"id": 0xFF01,
"name": "",
"on_normalize": ZCNP.zcn_norm_lumi_basic,
"on_serialization": ZCNP.zcn_ser_lumi_basic,
},
],
},
],
},
],
}

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

A fully custom (no-preset) variant of the struct unpacking would use record.asLumiStruct() in on_normalize and redirect values with record.setCl()/record.setAttr()/record.setFloat() — the ZCN Builder page contains this and five more complete examples ported from native converters.


10. Gotchas

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