Control4 System API Reference

# ZigbeeV2

`C4:ZigbeeV2()` gives a Zigbee 3 device driver an object for its own device: ZDO binds and binding table reads, raw ZCL frames, poll control and check-in settings, device and network status, and OTA image registration, with results delivered to Lua callbacks. This reference covers all 63 method names on OS 4.3.0 and the 16 present on OS 4.2.1, and marks each entry by how much of it was seen working.

- **Call**: C4:ZigbeeV2()

- **Object**: C4LuaZigbeeV2

- **OS 4.2.1**: 16 names

- **OS 4.3.0**: 63 names

- **Signing**: unsigned, except SendZdo and off-box OTA URLs

## Confidence markers

> Every method, callback and data shape on this page carries a marker that says how much of it was seen working on a controller and how much is reasoned from its names, parameters and messages.

**Markers**

| Marker | Meaning |
| --- | --- |
| Observed on OS 4.3.0 | Called on a controller running OS 4.3.0. The returns, callbacks and message texts quoted were seen. A detail in the entry that was not seen is marked inferred where it appears. |
| Errors observed, effect not observed | The argument checks and refusal messages were seen. A valid call was deliberately not made, because it changes the device or the controller, so what it does is inferred. |
| Signature only: behavior not observed | The name, parameters and messages are exact. The call was never seen to run, and its behavior is inferred. |
| Shape inferred, not observed | For callbacks and data shapes. Argument order and field names are exact, but the payload was never captured, and the meaning of the values is inferred. |

OS 4.2.1 is covered by name only. The 16 names it carries were confirmed present, but none was called on 4.2.1, so every statement about 4.2.1 behavior is inferred from 4.3.0. Identifiers, numbers and message strings are exact throughout.

## The model

> One object per call, one callback registration per device. `C4:ZigbeeV2()` returns a new object every time, always bound to the calling driver's own Zigbee device. The controller sends that device's callbacks to whichever object called `Init()` last, and drops the registration as soon as any object of the driver is garbage collected. Create one object, keep it for the life of the driver, and never create a second.

The object talks to the controller's Zigbee server, not to the radio. A command returns a `commandId` at once. The controller queues the frame, reports each step on a status callback carrying that id, and delivers the device's answer, if one comes, on a response callback. Getters answer immediately, from state the controller already holds (inferred).

**Scope and state**

| Item | Scope | Notes |
| --- | --- | --- |
| object | per call | A new `C4LuaZigbeeV2` userdata each time. Arguments to the factory are ignored. |
| target device | the caller's own | No method takes a device address. `SendZcl` refuses `targetEuid`. |
| callback registration | one per device | Made by `Init()`. The most recent `Init()` wins, and collecting any object of the driver removes it. |
| callback functions | per object | 22 slots. A 4.2.1 name and its 4.3.0 name set the same slot. |
| tracked ZCL commandIds | per object, 256 (inferred) | Only these reach `OnZclSentStatus`. Inferred: when full, the oldest is dropped. |
| ZCL sequence number | per object | Starts at 0x80 and stays within 0x80 to 0xFF. A frame the controller refuses still uses one (observed). |
| bind config | per device | Held by the controller, so it outlives the object (inferred). |

**Kinds of call**

| Kind | Methods | Returns | Results |
| --- | --- | --- | --- |
| Command | `Send*`, `SetSysCheckInConfig`, `GetSysQueue`, `SetSysQueueEmpty` | `commandId, message`; `SendZcl`, `SendZclSetLongPollInterval`, `SendZclSetShortPollInterval` and `SendZclFastPollStop` add `seq` | A status callback for each step, then a response callback when the device or the server answers. |
| Getter | `GetZdoBindConfig`, and every `GetSys*` except `GetSysQueue` | `table, ""` or `nil, message` | None. Synchronous. |
| Local call | `Init`, `SetZdoBindConfig`, the 30 callback setters | A number, `1` or `0` with a message, or the object | `SetZdoBindConfig` reports later on `OnZdoBindConfigStatusEvent` (inferred). |

**Availability by OS**

| Family | OS 4.2.1 names | OS 4.3.0 names |
| --- | --- | --- |
| Registration | Init | Init |
| Bind, unbind, bind table | CmdZdoBind, CmdZdoUnbind, CmdZdoGetBind | SendZdoBind, SendZdoUnbind, SendZdoGetBindTable |
| Check-in interval | CmdZclSetCheckInInterval, CmdZclGetCheckInInterval | SendZclWriteCheckInInterval, SendZclReadCheckInInterval |
| Check-in response settings | CmdSetCheckInRes, CmdGetCheckInRes | SetSysCheckInConfig, GetSysCheckInConfig |
| Their callbacks | 8 `Cmd*Cb`, `Res*Cb` and `CheckInResHandledCb` setters | 8 matching `On*` setters |
| Poll intervals, raw ZCL and ZDO, bind config, device state, queue, OTA | none | 18 more methods and 14 more callbacks |

OS 4.3.0 adds 47 names: 15 new names for the 4.2.1 functions, which keep their old names as aliases, and 32 for new functions. Code written against the 4.2.1 names finds them on both releases. Releases before 4.2.1 are not covered. On OS 4.3.0 the PUC Lua host carries the same object and the same 63 names as the LuaJIT host; it was not called there.

Unsigned drivers can call everything except `SendZdo`, which refuses them outright (observed), and `SendSysOtaAddImage` refuses image URLs that point off the controller unless the driver is signed (inferred).

## Call flow

_Diagram: Sequence of a bind, a refused bind, and a paged binding table read between the driver, the controller and the device. Bind: the driver calls SendZdoBind(6, 1), which returns a commandId and an empty string at once. The controller sends the device a ZDO Bind_req and calls OnZdoSentStatus three times with that commandId: 61445 ZSTATUS_QUEUE_RECEIVED, then 5 ZSTATUS_IN_PROGRESS, then 0 ZSTATUS_OK. When the device answers with Bind_rsp, the controller calls OnZdoBindResponse(0, ZDO_STATUS_SUCCESS). That callback carries no commandId and no cluster, so only one bind should be outstanding at a time. Refused: SendZdoBind(6, 1, 255) names a coordinator endpoint that does not model the cluster; the only callback is OnZdoSentStatus with 61447 ZSTATUS_INCORRECT_GATEWAY_ENDPOINT, and no bind response follows. Table: the driver calls SendZdoGetBindTable(0) and the controller sends Mgmt_Bind_req from index 0; after the same three status callbacks, OnZdoGetBindTableResponse delivers a page with entries 4, startIndex 0 and three binds. The driver calls SendZdoGetBindTable(3) only while startIndex plus the number of binds is below entries, and receives the last bind in a second OnZdoGetBindTableResponse with startIndex 3._

_A bind, a refused bind, and an illustrative four-record table read in two pages. The status sequence and the three-record page size were observed on OS 4.3.0. **Ochre arrows are callbacks the object delivers to the driver.** Dashed steps are conditional._

## Conventions

### Getting the object

Test for the factory, create the object once, and keep it in a variable that lives as long as the driver. An unknown method name indexes to `nil`, so individual 4.3.0 methods can be tested the same way (observed).

```lua
local zv2   -- file scope: the driver's only ZigbeeV2 object

function OnDriverLateInit()
  if not C4.ZigbeeV2 then return end
  zv2 = C4:ZigbeeV2()
  if zv2.SendZdoBind then --[[ OS 4.3.0 surface ]] end
end
```

Inferred, not tested: create it in `OnDriverLateInit` or later, the same rule that applies to the older Zigbee calls. A driver whose device is not a Zigbee node still gets an object, and its calls fail with `"Device not found in Zigbee Manager"`.

### Colon syntax only

Every method checks that its first argument is the object. Called without one, as `zv2.GetSysMeshStatus()`, it returns nothing; called with a plain table in its place, it raises `"An unknown error occurred"` (both observed). Assigning a field on the object raises an error, so keep driver state in your own tables.

### Three ways to fail

**Failure modes**

| Problem | What happens | Example |
| --- | --- | --- |
| Missing or mistyped argument | A Lua error is raised. | "Not enough arguments passed (expected 1: index)" |
| Value out of range or unsupported | Returns `0` and a message. Calls that return a sequence number add a third value: `0` when the object itself refuses the call, but the sequence number the refused frame used when the controller refuses it, as in `0, "Invalid cluster ID provided", 128`. Test only the first value. | 0, "SendZdoGetBindTable invalid index: -1" |
| Refused or lost on the way | A `commandId` is returned, then a status callback carries a non-zero status. | 61447, "ZSTATUS_INCORRECT_GATEWAY_ENDPOINT" |

Type errors name the parameter: `"clusterId should be a number"`, with `" (or nil)"` appended for optional parameters, as in `"dstEndpoint should be a number (or nil)"`. Wrap calls in `pcall` when their arguments come from user input.

Any command or getter can also fail because the controller cannot reach the device's Zigbee record. A command then returns `0` and a getter `nil`, with one of `"Device not found in Zigbee Manager"`, `"Device not found on the mesh"`, `"Zg server not found for device"` or `"ZigbeeManager is null"` as the message (inferred).

### Argument coercion

**How arguments are read**

| Input | Result | Seen |
| --- | --- | --- |
| A numeric string where a number is expected | Accepted as the number. `SendZdoGetBindTable("3")` read from index 3. | observed |
| A fraction | Truncated. `SendZdoBind(6, 0.5)` is refused as endpoint 0. | observed |
| A number where a string is expected | Converted to text. For an EUI that text is then read as hex. | observed |
| A value too wide for its field | Refused where the method or the controller checks it; otherwise not refused, and the value is cut on the way to the device, so `SendZdoGetBindTable(256)` reads from 0. | observed for 256; rule inferred |
| `0` or `1` for a boolean parameter | Accepted, but both count as `true`. | inferred |
| Extra arguments | Ignored. | observed |

### EUIs are 16-digit hex strings

Every EUI the API accepts or reports as a string is 16 hex digits, reported in upper case, for example `"0200000000000001"`. Pass EUIs the same way. The one exception is `GetSysDeviceInfo().euid`, which arrives as a Lua number and has lost precision. Do not rebuild an EUI string from it (inferred: an EUI with the top bit set would also come out negative).

### commandId is a double

A `commandId` is a 64-bit value delivered as a Lua number, so it arrives as a value like `2.4418784162631213e+17` and is not exact. The value a command returns and the value its callbacks carry convert the same way, so comparing them with `==` works (observed). Do not format one with `%d` or do arithmetic on it. A `commandId` of `0` always means the command was refused.

### What each result can be matched by

**Correlation**

| Callback | Carries | Match it by |
| --- | --- | --- |
| OnZdoSentStatus, OnZclSentStatus, OnSysCheckInConfigSentStatus, OnSysQueueSentStatus, OnSysOtaSentStatus | `commandId` | The returned `commandId`. |
| OnZdoBindResponse, OnZdoUnbindResponse | nothing | Keep one bind or unbind outstanding at a time. |
| OnZdoGetBindTableResponse | `startIndex` | The index you asked for. Simplest with one read outstanding. |
| OnZclReceivedEvent | `seqNumber` | The `seq` that `SendZcl` returned. |
| OnSysQueueResponse | `cmdId` | The `commandId` that `GetSysQueue` returned. |
| OnSysOtaAddImageResponse | `commandId` (first argument) | The `commandId` that `SendSysOtaAddImage` returned. |
| OnZclRead*Response | the value only | One read per attribute outstanding. |

Callbacks for different commands interleave. A bind table page was observed arriving between two status callbacks of the next command.

### Binding tables come in pages of three

Each `SendZdoGetBindTable` answer held at most three records (observed); inferred, the device sets the page size. `entries` is the size of the whole table, so keep asking from `startIndex + #binds` until that reaches `entries`, and stop on an empty page.

### Omitted endpoints

For `SendZdoBind` on OS 4.3.0, omitting `dstEndpoint` or passing `0` lets the controller pick the coordinator endpoint that lists the cluster as a client cluster (observed). If no coordinator endpoint lists it, the bind is refused with status 61447 and nothing else. Whether 4.2.1 picks the endpoint was not established, so on 4.2.1 pass it.

For `SendZcl`, a Basic read with the endpoint omitted went out with no destination endpoint and was answered from endpoint 1, the only endpoint with Basic on that two-endpoint device (observed). What happens when a cluster sits on several endpoints is not known, so pass `dstEndpoint`.

### OS 4.2.1 and OS 4.3.0 names

On OS 4.3.0 each 4.2.1 name is a separate Lua function that calls the same implementation as its new name, and gives identical results (observed). The refusal texts differ between releases: on 4.2.1 they begin with the old name, as in `"CmdZdoBind invalid clusterId: "`, so test the first return value, never the message.

**Aliases**

| OS 4.2.1 name | OS 4.3.0 name |
| --- | --- |
| CmdZdoBind | SendZdoBind |
| CmdZdoUnbind | SendZdoUnbind |
| CmdZdoGetBind | SendZdoGetBindTable |
| CmdSetCheckInRes | SetSysCheckInConfig |
| CmdGetCheckInRes | GetSysCheckInConfig |
| CmdZclSetCheckInInterval | SendZclWriteCheckInInterval |
| CmdZclGetCheckInInterval | SendZclReadCheckInInterval |
| CmdZdoExecStatusCb | OnZdoSentStatus |
| CmdZclExecStatusCb | OnZclSentStatus |
| CmdSetCheckInExecStatusCb | OnSysCheckInConfigSentStatus |
| ResZdoBindCb | OnZdoBindResponse |
| ResZdoUnbindCb | OnZdoUnbindResponse |
| ResZdoGetBindCb | OnZdoGetBindTableResponse |
| ResZclGetCheckInIntervalCb | OnZclReadCheckInIntervalResponse |
| CheckInResHandledCb | OnSysCheckInHandledEvent |

### Two meanings of bind

In Composer, the connection that ties a Zigbee driver to its device is also called a binding. Every bind on this page is a ZDO bind instead: an entry in the device's own binding table that tells the device where to send reports and commands for one cluster.

### What is fixed and what is not

Method names, parameter names, table keys, status names and numbers, and the message texts are the interface, and are quoted exactly. There is no helper library around this API. Variable and function names in the examples, such as `zv2`, `onSent` and `readBindTable`, are placeholders chosen for this page.

## Methods

> Called on the object with colon syntax, grouped by what they touch. The callbacks they lead to are in the next section.

### Object and registration

#### C4:ZigbeeV2

Factory. Observed on OS 4.3.0.

```lua
C4:ZigbeeV2()  --> object
```

Returns a new `C4LuaZigbeeV2` userdata on every call, bound to the calling driver's own device. Its `tostring` is `C4LuaZigbeeV2 (0x...)`. Arguments are ignored: `C4:ZigbeeV2(12345, "x")` still returns an object for the caller's device.

**Side effects.** None until `Init()`. When the object is later garbage collected it removes the device's callback registration, whichever object made it. See Behavior notes.

**Failure.** Inferred: on a driver whose device is not a Zigbee node the object is still created and its calls fail with `"Device not found in Zigbee Manager"`.

**Availability.** OS 4.2.1 and OS 4.3.0. Unsigned drivers can call it.

#### Init

Local call. Observed on OS 4.3.0.

```lua
zv2:Init()  --> 0 | 1
```

Registers this object as the receiver of the device's callbacks, replacing any earlier registration for the same device, including one made by another object.

**Returns.** `0` on success. Inferred: `1` when the controller's Zigbee manager is unavailable. Calling it again returns `0` and registers again, which is how a lost registration is restored.

**Without it.** Commands and getters work, but no callback reaches this object. Status and response callbacks for its commands go to whichever object of the device did call `Init()`, and nowhere if none did (observed).

**Availability.** OS 4.3.0. Present on OS 4.2.1, not called there.

### Binds and the bind table

#### SendZdoBind, SendZdoUnbind

Command. Observed on OS 4.3.0.

```lua
zv2:SendZdoBind(clusterId, srcEndpoint[, dstEndpoint, dstDeviceEuid])
zv2:SendZdoUnbind(clusterId, srcEndpoint[, dstEndpoint, dstDeviceEuid])
  --> commandId, message
```

Asks the device to add, or remove, one entry in its own binding table: from `clusterId` on `srcEndpoint` of the device to an endpoint on another node, by default the controller's coordinator.

**Parameters**

| Name | Type | Notes |
| --- | --- | --- |
| clusterId | number req | The cluster. Values below 1 are refused (-1 observed; 0 inferred), so cluster 0x0000 can be neither bound nor unbound here. No upper limit is checked; inferred, the value is cut to 16 bits. |
| srcEndpoint | number req | The device endpoint that hosts the cluster. Values below 1 are refused. |
| dstEndpoint | number or nil | Endpoint on the destination. Omit it or pass `0` and the controller picks the coordinator endpoint that lists the cluster. Negative values are refused. |
| dstDeviceEuid | string or nil | Destination EUI, 16 hex digits. Omit it or pass `""` for the coordinator. A number is turned into text and read as hex: `12345` bound to `0000000000012345`. A string that is not hex makes a bind fall back to the coordinator; inferred, an unbind returns `"Invalid destination device EUID"` instead. |

**Returns.** `commandId, ""` once the controller accepts the command. Progress arrives on `OnZdoSentStatus` with that `commandId`, then the device's answer on `OnZdoBindResponse` or `OnZdoUnbindResponse`, which carry no `commandId`.

**Failures**

| Result | When |
| --- | --- |
| raises "Not enough arguments passed (expected 2: clusterId, srcEndpoint[, dstEndpoint, dstDeviceEuid])" | Fewer than two arguments. |
| raises "clusterId should be a number", "srcEndpoint should be a number", "dstEndpoint should be a number (or nil)", "dstDeviceEuid should be a string (or nil)" | Wrong type. |
| 0, "SendZdoBind invalid clusterId: -1" | `clusterId` below 1. |
| 0, "SendZdoBind invalid srcEndpoint: 0" | `srcEndpoint` below 1, including 0.5. |
| 0, "SendZdoBind invalid dstEndpoint: -1" | `dstEndpoint` below 0. Not observed. |
| status 61447 "ZSTATUS_INCORRECT_GATEWAY_ENDPOINT", then nothing | The destination endpoint does not list the cluster, or no coordinator endpoint does. |

`SendZdoUnbind` refuses with the same `"SendZdoBind"` prefix (observed). Inferred: the message can also be one of the controller's own, `"Device not found in Zigbee Manager"`, `"Device not found on the mesh"`, `"Zg server not found for device"` or `"ZigbeeManager is null"`.

**Side effects.** Changes the device's binding table. Binding a record that already exists succeeds and adds no duplicate (observed). The device then sends that cluster's reports and commands to the destination; attribute reporting itself is set up separately with ZCL.

**Availability.** OS 4.3.0. The same implementation answers to `CmdZdoBind` and `CmdZdoUnbind`, the names present on OS 4.2.1, where the refusals begin `"CmdZdoBind invalid"`. Unsigned drivers can call both.

#### SendZdoGetBindTable

Command. Observed on OS 4.3.0.

```lua
zv2:SendZdoGetBindTable(index)  --> commandId, message
```

Reads one page of the device's binding table with a ZDO `Mgmt_Bind_req`.

**Parameters**

| Name | Type | Notes |
| --- | --- | --- |
| index | number req | First record to return, counting from 0. A numeric string works. Negative values are refused. 256 read from 0; inferred, the index is sent as one byte. |

**Returns.** `commandId, ""`. The page arrives on `OnZdoGetBindTableResponse`. An index at or past the end returns an empty `binds` with `entries` still set to the table size.

**Failure.** Raises `"Not enough arguments passed (expected 1: index)"` or `"index should be a number"`, which booleans also get. Returns `0, "SendZdoGetBindTable invalid index: -1"` for a negative index.

**Availability.** OS 4.3.0. Also answers to `CmdZdoGetBind`, the OS 4.2.1 name, where the refusal begins `"CmdZdoGetBind invalid index: "`.

### Bind config

#### SetZdoBindConfig

Local call. Errors observed, effect not observed.

```lua
zv2:SetZdoBindConfig(entries)  --> 1, "" | 0, message
-- entries: { { clusterId = n, srcEndpoint = n,
--              dstEndpoint = n, dstDeviceEuid = s }, ... }, or nil
```

Hands the controller the complete list of unicast binds the device should hold. Inferred: from then on the controller keeps the device's binding table matched to the list, adding binds that are missing and removing every other unicast bind.

**Entry fields**

| Name | Type | Notes |
| --- | --- | --- |
| clusterId | number req | 1 or more. Inferred: cut to 16 bits. |
| srcEndpoint | number req | 1 or more. Inferred: cut to 8 bits. |
| dstEndpoint | number or nil | 0 or more. Inferred: 0 or absent lets the controller pick. |
| dstDeviceEuid | string or nil | Destination EUI. Inferred: absent means the coordinator. |

**Returns.** `1, ""` when stored (inferred). This is not a `commandId`. Progress arrives on `OnZdoBindConfigStatusEvent` (inferred).

**Failures**

| Result | When |
| --- | --- |
| 0, "SetZdoBindConfig expects a table or nil argument" | Any value that is neither. Observed for a boolean and a string. |
| 0, "SetZdoBindConfig entry 1 is not a table" | Observed. |
| 0, "SetZdoBindConfig entry 1 missing required field: clusterId" | Observed. |
| 0, "SetZdoBindConfig entry 1 missing required field: srcEndpoint" | Observed. |
| 0, "SetZdoBindConfig entry N invalid clusterId: X" | Below 1. Not observed. |
| 0, "SetZdoBindConfig entry N invalid srcEndpoint: X" | Below 1. Not observed. |
| 0, "SetZdoBindConfig entry N invalid dstEndpoint: X" | Below 0. Not observed. |

A refused list changes nothing: the stored config read the same before and after each refusal (observed).

**Side effects.** Inferred, never exercised, and not reversible by the API:

`nil`, no argument, or an empty table clears the list, and the controller then unbinds every unicast bind on the device. The list claims every bind with a 64-bit destination address, whatever node it points at, so binds made by other tools or between two devices are removed too. The list is held by the controller per device: it outlives the object, is sent again when the device comes back online, and is dropped when the device is removed. Sending an unchanged list does nothing. Re-apply the list after a controller restart.

**Availability.** OS 4.3.0 only. Unsigned drivers can call it.

#### GetZdoBindConfig

Getter. Observed on OS 4.3.0.

```lua
zv2:GetZdoBindConfig()  --> entries, "" | nil, message
```

Returns the list last stored with `SetZdoBindConfig`, from the controller. Inferred: it does not read the device.

**Returns.** An array of `{ clusterId, srcEndpoint, dstEndpoint, dstDeviceEuid }`. Inferred: `dstEndpoint` is left out when 0 and `dstDeviceEuid` when empty. Observed only as `{}`, with nothing stored.

**Failure.** `nil, message` as under Three ways to fail (inferred).

**Availability.** OS 4.3.0 only.

### Sending ZCL and ZDO

#### SendZcl

Command. Observed on OS 4.3.0.

```lua
zv2:SendZcl({ clusterId = n, commandId = n, ... })
  --> commandId, message, seq
```

Sends one ZCL frame to the device. The object builds the ZCL header from the fields below; `payload` carries the bytes that follow it.

**Table fields**

| Name | Type | Notes |
| --- | --- | --- |
| clusterId | number req | 0 to 0xFFFF. The controller also refuses ids outside its cluster list with `0, "Invalid cluster ID provided", seq` (observed for 0xEF00, 0xFF03 and 0xFCC0); see Clusters SendZcl accepts. |
| commandId | number req | The ZCL command identifier, 0 to 0xFF. |
| frameType | string | `"cluster"`, the default (inferred), or `"global"`. Any other string, or a number, is refused. |
| direction | string | `"toServer"`, the default (inferred), or `"toClient"`. |
| payload | string | Bytes after the ZCL header. A value of another type is ignored. |
| dstEndpoint | number | Device endpoint, 0 to 0xFE. `endpoint` is accepted as another name for it. Default 0. Inferred: the controller then resolves the endpoint. This was seen only for a cluster the device hosts on one endpoint. Pass it explicitly. |
| srcEndpoint | number | 0 to 0xFE. Default 0; inferred, the controller picks. |
| manufacturerCode | number | Sets the manufacturer-specific bit and code. |
| disableDefaultResponse | boolean | Default `true`. Only a boolean is read; anything else leaves the default. |
| profileId | number | Default 0x0104. The controller refuses unknown profiles (observed for 0xC05E, `"Invalid profile ID provided"`). Inferred accepted list: 0x0000, 0x0104, 0x0105, 0x0109, 0xA1E0, 0xC00E and 0xC25C to 0xC25E. |
| apsOptions | number | Default 0x0100. Inferred: above 0xFFFF is refused by the controller. |
| addressMode | string | Only `"unicast"`. |
| groupId, broadcastAddr, targetEuid, priority | absent | Any value is refused. |

**Returns.** `commandId, "", seq`. `seq` is the ZCL transaction sequence number the object put in the header, always 0x80 to 0xFF; the device's answer carries it back as `seqNumber`. Status arrives on `OnZclSentStatus`. The answer arrives on `OnZclReceivedEvent` when that callback is set, and on the driver's `OnZigbeePacketIn` when it is not (both observed).

**Failure.** A refusal never raises. The object's own checks (the messages that begin `"SendZcl"`) return `0, message, 0`. The controller's checks return `0, message, seq`: observed for `"Invalid cluster ID provided"` and `"Invalid profile ID provided"`, inferred for its endpoint and APS checks. Test the first value only.

**Refusals**

| Message | When |
| --- | --- |
| "SendZcl expects a single table argument" | No argument, or not a table. |
| "SendZcl: required field 'clusterId' missing or not a number" | Missing, or not a number. |
| "SendZcl: required field 'commandId' missing or not a number" | Missing, or not a number. |
| "SendZcl: frameType must be 'cluster' or 'global'" | Any other string, or a number. |
| "SendZcl: direction must be 'toServer' or 'toClient'" | Any other value. |
| "SendZcl: addressMode 'group' is not supported yet (unicast only)" | Any `addressMode` but `"unicast"`; the value is quoted. |
| "SendZcl: 'groupId' is not supported yet (unicast to bound device only)" | Also for `broadcastAddr`, `targetEuid` and `priority`. |
| "SendZcl: invalid clusterId: 70000" | Above 0xFFFF or negative. |
| "SendZcl: invalid commandId: 256" | Above 0xFF. |
| "SendZcl: invalid endpoint: 255" | `dstEndpoint` or `endpoint` above 0xFE. |
| "SendZcl: invalid srcEndpoint: 255" | Above 0xFE. |
| "Invalid cluster ID provided", "Invalid profile ID provided" | From the controller. Observed; the third value is the `seq`. |
| "Invalid APS options provided" | From the controller. Inferred, not observed. |

**Frame type default.** Inferred, never sent (do not test it on Basic): an omitted `frameType` means cluster-specific. A global Read Attributes (command 0x00) sent without `frameType = "global"` becomes command 0x00 of that cluster instead, which is Reset to Factory Defaults on Basic and Off on On/Off.

**Side effects.** Advances the object's sequence number, even when the controller then refuses the frame (observed), and adds the `commandId` to the object's tracked set.

**Availability.** OS 4.3.0 only. Unsigned drivers can call it (observed). A Basic read with `frameType = "global"` was observed end to end; the `manufacturerCode`, `apsOptions`, `srcEndpoint` and `direction = "toClient"` options were not exercised, and `profileId` only as a refusal.

#### SendZdo

Command. Errors observed, effect not observed.

```lua
zv2:SendZdo({ clusterId = n, payload = s })  --> commandId, message
```

Sends a raw ZDO request to the device. Signed drivers and agents only.

**Table fields**

| Name | Type | Notes |
| --- | --- | --- |
| clusterId | number req | ZDO cluster, 0 to 0xFFFF. |
| payload | string | ZDO frame bytes. Whether the leading transaction sequence byte belongs in it is not known. |
| priority | absent | Refused if present. |

**Failure.** From an unsigned driver every call, with any argument or none, returns `0, "SendZdo requires a signed driver or agent"` (observed). Signed callers can also get `"SendZdo expects a single table argument"`, `"SendZdo: 'priority' is not supported yet"`, `"SendZdo: required field 'clusterId' missing or not a number"` and `"SendZdo: invalid clusterId: N"`.

**Results.** Inferred: status on `OnZdoSentStatus`. Of the device's ZDO answers, only `Bind_rsp`, `Unbind_rsp` and `Mgmt_Bind_rsp` reach Lua, through the bind callbacks. Any other ZDO response is dropped.

**Availability.** OS 4.3.0 only. Signed drivers and agents only.

### Poll control and check-in

These calls use the device's Poll Control cluster (0x0020), which sleepy end devices use to agree how often they wake and poll. Intervals and timeouts are in quarter seconds, as the ZCL defines those attributes (inferred for this API). None of these was tested on a device that has the cluster.

#### SendZclReadCheckInInterval, SendZclReadLongPollInterval, SendZclReadShortPollInterval, SendZclReadFastPollTimeout

Command. Observed on OS 4.3.0.

```lua
zv2:SendZclReadCheckInInterval(dstEndpoint[, srcEndpoint])
zv2:SendZclReadLongPollInterval(dstEndpoint[, srcEndpoint])
zv2:SendZclReadShortPollInterval(dstEndpoint[, srcEndpoint])
zv2:SendZclReadFastPollTimeout(dstEndpoint[, srcEndpoint])
  --> commandId, message
```

Each sends a global Read Attributes for one Poll Control attribute, with the default response enabled.

**Attributes**

| Method | Attribute | Value callback |
| --- | --- | --- |
| SendZclReadCheckInInterval | 0x0000 Check-in Interval, uint32 | OnZclReadCheckInIntervalResponse |
| SendZclReadLongPollInterval | 0x0001 Long Poll Interval, uint32 | OnZclReadLongPollIntervalResponse |
| SendZclReadShortPollInterval | 0x0002 Short Poll Interval, uint16 | OnZclReadShortPollIntervalResponse |
| SendZclReadFastPollTimeout | 0x0003 Fast Poll Timeout, uint16 | OnZclReadFastPollTimeoutResponse |

**Parameters**

| Name | Type | Notes |
| --- | --- | --- |
| dstEndpoint | number req | Device endpoint that hosts Poll Control. The controller refuses values above 0xFE. |
| srcEndpoint | number or nil | Default 0. The controller refuses values above 0xFE. |

**Returns.** `commandId, ""`: two values, no sequence number. Status arrives on `OnZclSentStatus`.

**Failure.** Raises `"Not enough arguments passed (expected 1: dstEndpoint[, srcEndpoint])"`, `"dstEndpoint should be a number"` or `"srcEndpoint should be a number (or nil)"`. Returns `0, "Invalid destination endpoint provided"` for a `dstEndpoint` of 300 and `0, "Invalid source endpoint provided"` for a `srcEndpoint` of 300.

**Results.** On a device without Poll Control each read reported status 61445, 5, 0, and the device answered with a Default Response carrying status 0xC3 (unsupported cluster), which arrived on `OnZclReceivedEvent`; no value callback fired (observed). On a device with the cluster the value is expected on the matching callback above (inferred).

**Availability.** OS 4.3.0. `SendZclReadCheckInInterval` also answers to `CmdZclGetCheckInInterval`, present on OS 4.2.1. The other three are OS 4.3.0 only.

#### SendZclWriteCheckInInterval

Command. Errors observed, effect not observed.

```lua
zv2:SendZclWriteCheckInInterval(interval, dstEndpoint[, srcEndpoint])
  --> commandId, message
```

Writes the Check-in Interval attribute (0x0000, uint32) with a global Write Attributes.

**Parameters**

| Name | Type | Notes |
| --- | --- | --- |
| interval | number req | Quarter seconds. Negative values are refused. |
| dstEndpoint | number req | Device endpoint that hosts Poll Control. |
| srcEndpoint | number or nil | Default 0. |

**Returns.** `commandId, ""`. Status on `OnZclSentStatus`; inferred, the device's Write Attributes Response arrives as an incoming frame.

**Failure.** Raises `"Not enough arguments passed (expected 2: interval, dstEndpoint[, srcEndpoint])"` or `"interval should be a number"` (observed). Returns `0, "Invalid interval: N"` for a negative interval.

**Availability.** OS 4.3.0. Also answers to `CmdZclSetCheckInInterval`, present on OS 4.2.1, whose parameter names there are not known.

#### SendZclSetLongPollInterval, SendZclSetShortPollInterval

Command. Errors observed, effect not observed.

```lua
zv2:SendZclSetLongPollInterval(interval, dstEndpoint[, srcEndpoint])
zv2:SendZclSetShortPollInterval(interval, dstEndpoint[, srcEndpoint])
  --> commandId, message, seq
```

Send the Poll Control commands Set Long Poll Interval (0x02, uint32 payload) and Set Short Poll Interval (0x03, uint16 payload) as cluster-specific frames, with the default response enabled.

**Parameters**

| Name | Type | Notes |
| --- | --- | --- |
| interval | number req | Quarter seconds. Not range checked; inferred, cut to the payload width. |
| dstEndpoint | number req | Device endpoint that hosts Poll Control. |
| srcEndpoint | number or nil | Default 0. |

**Returns.** `commandId, "", seq`. Status on `OnZclSentStatus`.

**Failure.** Raises `"Not enough arguments passed (expected 2: interval, dstEndpoint[, srcEndpoint])"`, `"interval should be a number"` or `"dstEndpoint should be a number"` (observed). Inferred: a controller refusal returns `0, message, seq`, as for `SendZcl`.

**Availability.** OS 4.3.0 only.

#### SendZclFastPollStop

Command. Errors observed, effect not observed.

```lua
zv2:SendZclFastPollStop(dstEndpoint[, srcEndpoint])  --> commandId, message, seq
```

Sends Fast Poll Stop (0x01, no payload), telling the device to leave fast polling before its timeout.

**Returns.** `commandId, "", seq`. Status on `OnZclSentStatus`.

**Failure.** Raises `"Not enough arguments passed (expected 1: dstEndpoint[, srcEndpoint])"` or `"dstEndpoint should be a number"` (observed). Inferred: a controller refusal returns `0, message, seq`.

**Availability.** OS 4.3.0 only.

#### GetSysCheckInConfig

Getter. Observed on OS 4.3.0.

```lua
zv2:GetSysCheckInConfig()  --> config, "" | nil, message
```

Returns how the controller answers the device's Poll Control Check-in commands.

**Returns.** `{ fastPoll = boolean, timeout = number, doNotSendResponse = boolean }`. Three devices without Poll Control all returned `{ doNotSendResponse = false, fastPoll = true, timeout = 240 }`. Inferred meanings: `fastPoll`, the Check-in Response tells the device to start fast polling; `timeout`, the fast poll timeout sent with it, in quarter seconds, so 240 is 60 seconds; `doNotSendResponse`, the controller does not answer check-ins itself.

**Failure.** `nil, message` as under Three ways to fail (inferred).

**Availability.** OS 4.3.0. Also answers to `CmdGetCheckInRes`, present on OS 4.2.1.

#### SetSysCheckInConfig

Command. Errors observed, effect not observed.

```lua
zv2:SetSysCheckInConfig([fastPoll, timeout, doNotSendResponse])
  --> commandId, message
```

Changes how the controller answers this device's check-ins (inferred).

**Parameters**

| Name | Type | Notes |
| --- | --- | --- |
| fastPoll | boolean or nil | Default `true`. |
| timeout | number or nil | Default 32, not the 240 that `GetSysCheckInConfig` reported, so `SetSysCheckInConfig(true)` changes the timeout to 32. Negative values are refused. |
| doNotSendResponse | boolean or nil | Default `false`. |

**No arguments is a write.** Every parameter is optional, so `SetSysCheckInConfig()` is not a read: it sends `fastPoll = true`, `timeout = 32` and `doNotSendResponse = false` (inferred).

**Answering check-ins.** Inferred: the controller answers the device's check-ins itself unless `doNotSendResponse` is `true`. A driver that also answers through `C4:SendZigbeePacket` should set it, or not answer.

**Returns.** `commandId, ""`. Status on `OnSysCheckInConfigSentStatus`.

**Failure.** Raises `"fastPoll should be a boolean (or nil)"` or `"timeout should be a number (or nil)"` (observed). Returns `0, "SetSysCheckInConfig invalid timeout: N"` for a negative timeout. Whether `GetSysCheckInConfig` then reports the new values was not checked.

**Availability.** OS 4.3.0. Also answers to `CmdSetCheckInRes`, present on OS 4.2.1, where the refusal begins `"CmdSetCheckInRes invalid timeout: "`.

### Device state and the queue

#### GetSysDeviceInfo

Getter. Observed on OS 4.3.0.

```lua
zv2:GetSysDeviceInfo()  --> info, "" | nil, message
```

Returns what the controller knows about the device: network identity, manufacturer and model strings, firmware, and the endpoint and cluster inventory. The shape is under Device info.

`endpoints` is keyed by endpoint number, so iterate it with `pairs`. `euid` is an inexact number. `firmwareVersion` is several lines of text. `lastMessageAge` stayed the same across reads more than an hour apart, and did not move on a device that reported every two seconds, so it is not a live age (observed).

**Failure.** Inferred: `nil` with `"Device info not available"` or `"Device not found in Zigbee Manager"`.

**Availability.** OS 4.3.0 only.

#### GetSysDeviceStatus

Getter. Observed on OS 4.3.0.

```lua
zv2:GetSysDeviceStatus()  --> status, "" | nil, message
```

**Returns.** `{ statusInt = 0, status = "ONLINE" }` (observed) or `{ statusInt = 1, status = "OFFLINE" }`, with `rebootTimestamp` added when it is not zero. Inferred: there are only these two values, and the controller's unknown, offline and unstable states all report `1, "OFFLINE"`. `rebootTimestamp` is probably in milliseconds.

**Failure.** `nil, message` as under Three ways to fail (inferred).

**Availability.** OS 4.3.0 only.

#### GetSysDeviceDiscovery

Getter. Observed on OS 4.3.0.

```lua
zv2:GetSysDeviceDiscovery()  --> discovery, "" | nil, message
```

Returns `{ status, statusInt }`: how far the controller has got interviewing the device after it joined.

**statusInt and status**

| statusInt | status |
| --- | --- |
| 0 | NONE |
| 1 | NEW |
| 2 | STACK_REV |
| 4 | ENDPOINTS |
| 8 | CLUSTERS |
| 16 | NEIGHBORS |
| 32 | CHILDREN |
| 64 | DONE |
| 128 | FAILED |

`64, "DONE"` was observed; the other rows are inferred. `OnSysDeviceDiscoveryEvent` numbers the same process differently.

**Failure.** `nil, message` as under Three ways to fail (inferred).

**Availability.** OS 4.3.0 only.

#### GetSysMeshStatus

Getter. Observed on OS 4.3.0.

```lua
zv2:GetSysMeshStatus()  --> mesh, "" | nil, message
```

Returns `{ meshStatus }`, the state of the Zigbee network the device is on: `"UNKNOWN"`, `"UP"`, `"DOWN"`, `"BUSY"`, `"IDLE"` or `"OPEN"`. `"UP"` was observed; the others are inferred, and `"OPEN"` probably means joining is allowed.

**Failure.** `nil, message` as under Three ways to fail (inferred).

**Availability.** OS 4.3.0 only.

#### GetSysQueue

Command. Observed on OS 4.3.0.

```lua
zv2:GetSysQueue()  --> commandId, message
```

Asks the controller for this device's queue of outgoing messages. Despite the name it is asynchronous.

**Results.** `OnSysQueueResponse(report)`, then `OnSysQueueSentStatus(commandId, 0, "ZSTATUS_OK")`, in that order (observed). The report is under Queue report.

**Failure.** `0, message` as under Three ways to fail (inferred).

**Availability.** OS 4.3.0 only.

#### SetSysQueueEmpty

Command. Signature only: behavior not observed.

```lua
zv2:SetSysQueueEmpty()  --> commandId, message
```

Inferred: discards this device's pending outgoing messages in the controller. It takes no arguments and checks none, so any call acts.

**Results.** Status on `OnSysQueueSentStatus`. Inferred: refused while the controller is still setting the device up after it joined.

**Failure.** `0, message` as under Three ways to fail (inferred).

**Availability.** OS 4.3.0 only.

### OTA images

#### SendSysOtaAddImage

Command. Errors observed, effect not observed.

```lua
zv2:SendSysOtaAddImage({ { url = s, sha256 = s }, ... })
  --> commandId, message
```

Registers firmware image files for the controller to offer the device over the OTA Upgrade cluster (inferred).

**Image fields**

| Name | Type | Notes |
| --- | --- | --- |
| url | string req | Where the controller fetches the image. An unsigned driver may only name the controller itself: `controller://...`, or `http://` or `https://` with the host `director`, `127.0.0.1` or `localhost`, followed by the end of the URL or one of `: / ? #`. Case is ignored. |
| sha256 | string | Digest of the image; inferred, as hex. |

**Returns.** `commandId, ""`. Inferred: status on `OnSysOtaSentStatus`, a per-image report on `OnSysOtaAddImageResponse`, and upgrade progress on `OnSysOtaProgressEvent`.

**Failure.** Returns `0, "SendSysOtaAddImage: images[1] must be a table"` or `0, "SendSysOtaAddImage: images[1].url is required"` (observed), and `0, "SendSysOtaAddImage: images[N] off-box URL '<url>' requires a signed driver or agent"` for an unsigned driver naming another host (not observed).

**Side effects.** Inferred, never exercised: `nil`, no argument, a value that is not a table, or an empty list removes every image registered for the device.

**Availability.** OS 4.3.0 only. Off-box URLs need a signed driver or agent.

#### GetSysOtaInfo

Getter. Observed on OS 4.3.0.

```lua
zv2:GetSysOtaInfo()  --> ota, "" | nil, message
```

Returns the device's OTA state and the images registered for it: `{ stateInt, state, currentVersion, progress, images }`. Observed idle, with `stateInt = 1`, `state = "idle"`, the device's firmware version and an empty `images`. The shape is under OTA.

**Failure.** `nil, message` as under Three ways to fail (inferred).

**Availability.** OS 4.3.0 only.

## Callbacks

> Each callback is set with the method of the same name and later runs with the arguments below. Only the object that called `Init()` last receives any. The object is never passed to the callback.

#### Callback setters

Local call. Observed on OS 4.3.0.

```lua
zv2:OnZdoSentStatus(func)  --> zv2
zv2:OnZdoSentStatus(onSent):OnZdoBindResponse(onBind)   -- setters chain
```

Every setter, all 22 `On*` names and the 8 older `*Cb` names, stores `func` and returns the object, so calls chain.

**Failure.** Raises `"Not enough arguments passed (expected 1: func)"` with no argument, and `"func should be a function"` for `nil`, a number or a string.

No setter clears a slot, because `nil` is refused. The only way to stop callbacks is to drop the object, which also removes the device's registration. A 4.2.1 name and its 4.3.0 name set the same slot: the last one set wins, and a function set under the other name never runs (observed). Inferred: an error raised inside a callback is caught and logged by the controller, and return values are ignored.

**All callbacks**

| Setter | OS 4.2.1 name | Arguments | Seen |
| --- | --- | --- | --- |
| OnZdoSentStatus | CmdZdoExecStatusCb | commandId, status, statusName | observed |
| OnZclSentStatus | CmdZclExecStatusCb | commandId, status, statusName | observed |
| OnSysCheckInConfigSentStatus | CmdSetCheckInExecStatusCb | commandId, status, statusName | not seen |
| OnZdoBindResponse | ResZdoBindCb | zdoStatus, zdoStatusName | observed |
| OnZdoUnbindResponse | ResZdoUnbindCb | zdoStatus, zdoStatusName | observed once |
| OnZdoGetBindTableResponse | ResZdoGetBindCb | page | observed |
| OnZclReceivedEvent | none | frame | observed |
| OnZclReadCheckInIntervalResponse | ResZclGetCheckInIntervalCb | value | not seen |
| OnZclReadLongPollIntervalResponse | none | value | not seen |
| OnZclReadShortPollIntervalResponse | none | value | not seen |
| OnZclReadFastPollTimeoutResponse | none | value | not seen |
| OnSysCheckInHandledEvent | CheckInResHandledCb | handled | not seen |
| OnSysDeviceStatusChangedEvent | none | status | not seen |
| OnSysDeviceInfoEvent | none | info | not seen |
| OnSysDeviceDiscoveryEvent | none | stateInt, stateName, endpoint | not seen |
| OnSysMeshStatusEvent | none | meshStatus | not seen |
| OnZdoBindConfigStatusEvent | none | bindState | not seen |
| OnSysQueueSentStatus | none | commandId, status, statusName | observed |
| OnSysQueueResponse | none | report | observed |
| OnSysOtaSentStatus | none | commandId, status, statusName | not seen |
| OnSysOtaAddImageResponse | none | commandId, report | not seen |
| OnSysOtaProgressEvent | none | progress | not seen |

Callbacks marked not seen did not fire during ordinary traffic; no join, leave or check-in was provoked.

#### OnZdoSentStatus, OnZclSentStatus, OnSysCheckInConfigSentStatus, OnSysQueueSentStatus, OnSysOtaSentStatus

Callback. Observed on OS 4.3.0.

```lua
function onSent(commandId, status, statusName) end
```

Progress of a command, keyed by the `commandId` it returned. `statusName` is the name of `status`; see Status values.

**Which commands report where**

| Setter | Reports | Seen |
| --- | --- | --- |
| OnZdoSentStatus | Bind, unbind and bind table commands from any object of the device (observed); raw ZDO commands (inferred). | observed |
| OnZclSentStatus | `SendZcl` and the poll control commands (observed); only those this object issued (inferred). | observed |
| OnSysCheckInConfigSentStatus | `SetSysCheckInConfig`. | not seen |
| OnSysQueueSentStatus | `GetSysQueue`, `SetSysQueueEmpty`. | observed |
| OnSysOtaSentStatus | `SendSysOtaAddImage`. | not seen |

A ZDO or ZCL command normally reports `61445 "ZSTATUS_QUEUE_RECEIVED"`, then `5 "ZSTATUS_IN_PROGRESS"`, then `0 "ZSTATUS_OK"`, in quick succession (observed). `GetSysQueue` reported only `0`. `ZSTATUS_OK` means the frame was sent; the device's answer is a separate callback. Inferred: any status other than 61445 and 5 is final.

Inferred: `OnZclSentStatus` fires only for commands this object issued and still tracks. It tracks at most 256, drops the oldest first, and stops tracking a command at its final status. Frames sent with `C4:SendZigbeePacket` never reach it (observed).

**Availability.** OS 4.3.0. The first three also answer to `CmdZdoExecStatusCb`, `CmdZclExecStatusCb` and `CmdSetCheckInExecStatusCb`, present on OS 4.2.1.

#### OnZdoBindResponse, OnZdoUnbindResponse

Callback. Observed on OS 4.3.0.

```lua
function onBind(zdoStatus, zdoStatusName) end   -- 0, "ZDO_STATUS_SUCCESS"
```

The device's `Bind_rsp` or `Unbind_rsp`. `zdoStatus` is the ZDO status number and `zdoStatusName` its name; see ZDO status values.

It carries no `commandId`, cluster or endpoint, so a driver can only attribute it by having one request outstanding. Inferred: it fires for every bind or unbind response the device sends, whoever sent the request.

**Availability.** OS 4.3.0. Also `ResZdoBindCb` and `ResZdoUnbindCb`, present on OS 4.2.1.

#### OnZdoGetBindTableResponse

Callback. Observed on OS 4.3.0.

```lua
function onTable(page) end
```

One page of the device's binding table, shaped as under Bind table page. `binds` is an array. Inferred: the address mode of each record is dropped, so a group-addressed record cannot be told apart from a unicast one.

**Availability.** OS 4.3.0. Also `ResZdoGetBindCb`, present on OS 4.2.1.

#### OnZclReceivedEvent

Callback. Observed on OS 4.3.0.

```lua
function onZcl(frame) end
```

Each ZCL frame from the device: attribute reports, answers to `SendZcl`, and default responses. `frame.payload` is the whole ZCL frame, header included. The shape is under Incoming ZCL frame.

**Side effect.** Setting it on the registered object takes those frames away from the driver's `OnZigbeePacketIn`. The one exception is a Read Attributes Response with a sequence number below 0x80, which goes only to `OnZigbeePacketIn`. See Behavior notes.

**Availability.** OS 4.3.0 only.

#### OnZclReadCheckInIntervalResponse, OnZclReadLongPollIntervalResponse, OnZclReadShortPollIntervalResponse, OnZclReadFastPollTimeoutResponse

Callback. Shape inferred, not observed.

```lua
function onPollValue(value) end
```

Inferred: the object reads every Poll Control (0x0020) Read Attributes Response from the device and passes each attribute value to its slot: 0x0000 and 0x0001 as uint32, 0x0002 and 0x0003 as uint16. Records of another type, or with a failure status, are skipped, as are manufacturer-specific responses. These fire as well as `OnZclReceivedEvent`, and for any read of those attributes, however it was sent.

**Availability.** OS 4.3.0. The first also answers to `ResZclGetCheckInIntervalCb`, present on OS 4.2.1.

#### OnSysCheckInHandledEvent

Callback. Shape inferred, not observed.

```lua
function onCheckIn(handled) end   -- always true
```

Inferred: fires when the controller has answered a Check-in command from the device, with `handled` always `true`.

**Availability.** OS 4.3.0. Also `CheckInResHandledCb`, present on OS 4.2.1.

#### OnSysDeviceStatusChangedEvent

Callback. Shape inferred, not observed.

```lua
function onDeviceStatus(status) end   -- { statusInt, status, rebootTimestamp }
```

The same table as `GetSysDeviceStatus`. Inferred: fires when the device goes online or offline and when it reboots, with `rebootTimestamp` present only when it is not zero.

**Side effect.** Inferred, not tested: while this callback is set, the controller stops applying the device's online, offline and reboot changes through the older driver path. Test a driver that relies on `OnZigbeeOnlineStatusChanged` before setting it.

**Availability.** OS 4.3.0 only.

#### OnSysDeviceInfoEvent

Callback. Shape inferred, not observed.

```lua
function onDeviceInfo(info) end
```

The same table as `GetSysDeviceInfo`. Inferred: fires when the controller's record of the device changes, such as after the device is interviewed again.

**Availability.** OS 4.3.0 only.

#### OnSysDeviceDiscoveryEvent

Callback. Shape inferred, not observed.

```lua
function onDiscovery(stateInt, stateName, endpoint) end
```

Steps of the controller's interview of the device. The numbering is not the one `GetSysDeviceDiscovery` uses.

**stateInt and stateName**

| stateInt | stateName |
| --- | --- |
| 0 | NEW |
| 1 | NODE_QUEUE |
| 2 | NODE_SEND |
| 3 | NODE_WAITING |
| 4 | ENDPOINTS_QUEUE |
| 5 | ENDPOINTS_SEND |
| 6 | ENDPOINTS_WAITING |
| 7 | SIMPLE_QUEUE |
| 8 | SIMPLE_SEND |
| 9 | SIMPLE_WAITING |
| 10 | LQI_QUEUE |
| 11 | LQI_SEND |
| 12 | LQI_WAITING |
| 13 | IEEE_QUEUE |
| 14 | IEEE_SEND |
| 15 | IEEE_WAITING |
| 16 | JOIN |
| 17 | JOIN_NODE_QUEUE |
| 18 | JOIN_NODE_SEND |
| 19 | JOIN_NODE_WAITING |
| 20 | JOIN_KEY_WAITING |
| 21 | DONE |
| 255 | FAILED |

`endpoint` is the device endpoint the step concerns (inferred).

**Availability.** OS 4.3.0 only.

#### OnSysMeshStatusEvent

Callback. Shape inferred, not observed.

```lua
function onMesh(meshStatus) end   -- a string, as in GetSysMeshStatus
```

A single string, not a table. Inferred: it may never fire on OS 4.3.0. Poll `GetSysMeshStatus` instead.

**Availability.** OS 4.3.0 only.

#### OnZdoBindConfigStatusEvent

Callback. Shape inferred, not observed.

```lua
function onBindConfig(bindState) end   -- { state = "synced" }
```

Inferred: reports how far the controller has got matching the device's binding table to the `SetZdoBindConfig` list. `state` is `"synced"`, `"pending"`, `"disabled"` or `"unknown"`; `"disabled"` means the device refused ZDO binds and the controller stopped trying.

**Availability.** OS 4.3.0 only.

#### OnSysQueueResponse

Callback. Observed on OS 4.3.0.

```lua
function onQueue(report) end
```

The answer to `GetSysQueue`, shaped as under Queue report. It arrived before the command's `OnSysQueueSentStatus`.

**Availability.** OS 4.3.0 only.

#### OnSysOtaAddImageResponse, OnSysOtaProgressEvent

Callback. Shape inferred, not observed.

```lua
function onOtaAdded(commandId, report) end
function onOtaProgress(progress) end
```

`OnSysOtaAddImageResponse` reports the result of `SendSysOtaAddImage` for each image, with the `commandId` first. `OnSysOtaProgressEvent` reports the device's upgrade as it moves between states. Both shapes are under OTA.

**Availability.** OS 4.3.0 only.

## Data shapes

> Field names are exact. Values in the literals are composed examples, shaped like the observed ones. Optional fields are left out of the table when they have no value, as `rebootTimestamp` is when it is zero; empty lists such as `binds` are still present.

### Status values

Confidence: Observed on OS 4.3.0.

`status` and `statusName` in every `*SentStatus` callback. The full set has 154 values, all named `ZSTATUS_*`; these are the ones a driver is most likely to meet. The four marked observed were seen; the rest are inferred from their names.

**Status values**

| status | statusName | Meaning |
| --- | --- | --- |
| 61445 | ZSTATUS_QUEUE_RECEIVED | Accepted into the controller's queue. Observed. |
| 5 | ZSTATUS_IN_PROGRESS | Being sent. Observed. |
| 0 | ZSTATUS_OK | Sent. Final. Observed. |
| 61447 | ZSTATUS_INCORRECT_GATEWAY_ENDPOINT | The coordinator endpoint does not model the cluster. Final. Observed. |
| 61448 | ZSTATUS_INCORRECT_DEVICE_ENDPOINT | The device endpoint does not fit the request. |
| 61441 | ZSTATUS_ZSERVER3_DEVICE_QUEUE_FULL | The device's queue is full. |
| 61442 | ZSTATUS_ZSERVER3_DEVICE_SHARED_QUEUE_FULL | A shared queue is full. |
| 61443 | ZSTATUS_ZSERVER3_ZIGBEE_GATEWAY_QUEUE_FULL | The radio's queue is full. |
| 61444 | ZSTATUS_ZSERVER3_DEVICE_OFFLINE | The device is offline. |
| 61446 | ZSTATUS_PARTIAL_SUCCESS | Part of a request succeeded. |
| 7 | ZSTATUS_TIMEOUT | Timed out. |
| 3074 | ZSTATUS_ZIGBEE_DELIVERY_FAILED | The network could not deliver it. |
| 3104 | ZSTATUS_ZIGBEE_NO_APS_ACK | The device did not acknowledge it. |
| 3110 | ZSTATUS_ZIGBEE_SEND_UNICAST_NO_ROUTE | No route to the device. |

### ZDO status values

Confidence: Observed on OS 4.3.0.

`zdoStatus` and `zdoStatusName` in the bind callbacks, and `statusInt` and `status` in a bind table page. Only `0 "ZDO_STATUS_SUCCESS"` was seen; the other names are exact but were not produced.

**ZDO status values**

| Value | Name |
| --- | --- |
| 0 | ZDO_STATUS_SUCCESS |
| 128 | ZDO_STATUS_INVALID_REQUEST_TYPE |
| 129 | ZDO_STATUS_DEVICE_NOT_FOUND |
| 130 | ZDO_STATUS_INVALID_ENDPOINT |
| 131 | ZDO_STATUS_NOT_ACTIVE |
| 132 | ZDO_STATUS_NOT_SUPPORTED |
| 133 | ZDO_STATUS_TIMEOUT |
| 134 | ZDO_STATUS_NO_MATCH |
| 136 | ZDO_STATUS_NO_ENTRY |
| 137 | ZDO_STATUS_NO_DESCRIPTOR |
| 138 | ZDO_STATUS_INSUFFICIENT_SPACE |
| 139 | ZDO_STATUS_NOT_PERMITTED |
| 140 | ZDO_STATUS_TABLE_FULL |
| 141 | ZDO_STATUS_NOT_AUTHORIZED |
| 142 | ZDO_STATUS_DEVICE_BINDING_TABLE_FULL |
| 143 | ZDO_STATUS_INVALID_INDEX |
| 144 | ZDO_STATUS_FRAME_TOO_LARGE |
| 145 | ZDO_STATUS_BAD_KEY_NEGOTIATION_METHOD |
| 146 | ZDO_STATUS_TEMPORARY_FAILURE |
| 173 | APS_STATUS_SECURITY_FAIL |
| 197 | NWK_STATUS_ALREADY_PRESENT |
| 199 | NWK_STATUS_NWK_TABLE_FULL |
| 200 | NWK_STATUS_UNKNOWN_DEVICE |
| 214 | NWK_STATUS_MISSING_TLV |
| 215 | NWK_STATUS_INVALID_TLV |

### Bind table page

Confidence: Observed on OS 4.3.0.

The argument to `OnZdoGetBindTableResponse`.

```lua
{
  statusInt  = 0,
  status     = "ZDO_STATUS_SUCCESS",
  entries    = 4,        -- records in the whole table
  startIndex = 0,        -- table index of binds[1]
  binds = {              -- an array; at most 3 per page observed
    { clusterId = 6,    srcEndpoint = 1, dstEndpoint = 1,
      dstDeviceEuid = "0200000000000001" },
    { clusterId = 1794, srcEndpoint = 1, dstEndpoint = 3,
      dstDeviceEuid = "0200000000000001" },
    { clusterId = 0,    srcEndpoint = 1, dstEndpoint = 1,
      dstDeviceEuid = "0200000000000001" },   -- SendZdoUnbind refuses cluster 0
  },
}
```

### Device info

Confidence: Observed on OS 4.3.0.

Returned by `GetSysDeviceInfo` and passed to `OnSysDeviceInfoEvent`. The field list is exact and was seen on three devices; the meanings marked inferred were not confirmed.

```lua
{
  euid             = 1.4411518807585587e+17, -- the EUI as a number: inexact
  networkId        = 18344,     -- inferred: the 16-bit network address
  state            = 0,
  stateName        = "NONE",    -- inferred others: JOINED 16, UNRESPONSIVE 17,
                                -- LEAVE_SENT 32, LEFT 48, UNKNOWN 255
  lastMessageAge   = 5172,      -- unit unknown; not a live age
  controllerId     = 20,        -- inferred: device id of the hosting controller
  hardwareVersion  = 1,
  manufacturer     = "<manufacturer name the device reports>",
  product          = "<model the device reports>",
  firmwareVersion  = "DeviceFwVersion:\n     versionStr: 0.0.12\n     version: 12\n",
  stackRevision    = 22,
  capabilities     = 142,
  capabilitiesName = "ROUTER",  -- inferred others: SLEEPY 128, END_DEVICE 140,
                                -- COORDINATOR 143, TRUST_CENTER 207, else UNKNOWN
  endpoints = {
    [1] = {                     -- keyed by endpoint number: iterate with pairs
      deviceType     = 81,
      deviceTypeName = "SMART_PLUG",
      profileId      = 260,
      profileName    = "HA",
      serverClusters = {
        { id = 0,     name = "ZCL_BASIC" },
        { id = 6,     name = "ZCL_ON_OFF" },
        { id = 65280, name = "0x0000ff00" },    -- unknown ids come back as hex
      },
      clientClusters = { { id = 25, name = "ZCL_OTA_BOOTLOAD" } },
    },
  },
}
```

### Incoming ZCL frame

Confidence: Observed on OS 4.3.0.

The argument to `OnZclReceivedEvent`. This one is an On/Off attribute report.

```lua
{
  profileId   = 260,
  clusterId   = 6,
  seqNumber   = 5,       -- ZCL transaction sequence number, also in payload
  apsOptions  = 256,     -- 256 seen on reports, 352 on answers to SendZcl
  srcEndpoint = 1,       -- device endpoint
  dstEndpoint = 1,       -- controller endpoint
  payload     = "\24\5\10\0\0\16\1",
  -- the whole ZCL frame: frame control 0x18, seq 5, command 0x0A (Report
  -- Attributes), attribute 0x0000, type 0x10 (boolean), value 1
}
```

### SendZcl request

Confidence: Observed on OS 4.3.0.

A read of two Basic attributes, the case observed end to end.

```lua
local id, err, seq = zv2:SendZcl({
  clusterId   = 0x0000,
  commandId   = 0x00,        -- Read Attributes
  frameType   = "global",    -- required: the default is "cluster" (inferred)
  dstEndpoint = 1,
  payload     = "\4\0\5\0",  -- attribute ids 0x0004 and 0x0005, little endian
})
-- id ~= 0: the answer comes back with seqNumber == seq
```

### Queue report

Confidence: Observed on OS 4.3.0.

The argument to `OnSysQueueResponse`. Only an empty queue was seen, so the shape of `messages` entries is inferred.

```lua
{
  cmdId         = 2.4418e+17,   -- the commandId GetSysQueue returned
  pendingCount  = 0,
  inflightCount = 0,
  total         = 0,            -- pendingCount + inflightCount
  messages = {
    { cmdId = 2.4418e+17, priority = 0, command = "<text>", inflight = false },
  },
}
```

### Bind config entry

Confidence: Shape inferred, not observed.

What `SetZdoBindConfig` takes and `GetZdoBindConfig` returns. Field names are exact.

```lua
{
  { clusterId = 0x0006, srcEndpoint = 1 },           -- controller picks the rest
  { clusterId = 0x0702, srcEndpoint = 1, dstEndpoint = 3,
    dstDeviceEuid = "0200000000000001" },
}
```

### OTA

Confidence: Shape inferred, not observed.

Only an idle `GetSysOtaInfo` with no images was seen. Field names are exact; the value lists are inferred.

```lua
-- GetSysOtaInfo()
{
  stateInt       = 1,           -- observed idle; inferred: 0 unknown, 2 downloading,
                                -- 3 started, 4 completed, 5 failed
  state          = "idle",      -- also "unknown", "downloading", "started",
                                -- "completed", "failed", "transferring"
  currentVersion = "0.0.12",    -- the device's firmware version
  progress       = 0,           -- unit not confirmed
  images = {
    { url = "controller://...", manufacturerId = 0, imageTypeId = 0, version = 0,
      storedFileName = "...", statusStr = "SUCCESS", selected = false },
  },
}

-- OnSysOtaAddImageResponse(commandId, report)
{
  statusInt = 1, statusStr = "SUCCESS",
  images = {
    { url = "controller://...", statusInt = 1, statusStr = "SUCCESS",
      manufacturerId = 0, imageTypeId = 0, version = 0, storedFileName = "..." },
  },
}
-- statusInt and statusStr: 0 UNKNOWN, 1 SUCCESS, 2 FAILURE, 3 URL_NOT_FOUND,
-- 4 DOWNLOAD_FAILED, 5 INVALID_IMAGE, 6 DUPLICATE_IMAGE_TYPE, 7 ALREADY_EXISTS

-- OnSysOtaProgressEvent(progress)
{ state = "downloading", stateInt = 2, progress = 0, manufacturerId = 0,
  imageTypeId = 0,
  firmwareVersion = 12,   -- a number (the version integer), not the text
  filename = "..." }
```

### Clusters SendZcl accepts

Confidence: Observed on OS 4.3.0.

The controller accepts only the 117 cluster ids below and refuses the rest with `0, "Invalid cluster ID provided", seq`. Refusal was observed for 0xEF00, 0xFF03 and 0xFCC0; the accepted list was not exercised id by id. A cluster the device reports in `GetSysDeviceInfo` can still be refused: 0xFF03 was. Inferred from the list: 0xFF01, 0x000C and 0x0012 are refused too.

**Accepted cluster ids**

| Range | Ids |
| --- | --- |
| 0x0000 to 0x00FF | 0x0000-0x000B, 0x000F, 0x0015-0x0016, 0x0019-0x001B, 0x0020-0x0021, 0x0025 |
| 0x0100 to 0x03FF | 0x0100-0x0103, 0x0200-0x0204, 0x0300-0x0301 |
| 0x0400 to 0x06FF | 0x0400-0x0406, 0x040C-0x0429, 0x0500-0x0502, 0x0600-0x0601, 0x0614-0x0615 |
| 0x0700 to 0x0BFF | 0x0700-0x070B, 0x0800, 0x0900-0x0905, 0x0A01-0x0A02, 0x0B00-0x0B05 |
| 0x1000 and up | 0x1000, 0xFC00-0xFC0B, 0xFC57 |

## Behavior notes

#### Collecting any object unregisters the device

Observed. The registration belongs to the device, and every object removes it when it is garbage collected, including an object that never called `Init()`. A throwaway `C4:ZigbeeV2():GetSysDeviceInfo()` therefore silences the driver's long-lived object until that object calls `Init()` again.

Keep one object. If another was ever created and dropped, call `Init()` on the one you keep.

#### The last Init() gets every callback

Observed. A second object that calls `Init()` takes the status and response callbacks for the device, including those for commands the first object sends. `OnZclSentStatus` is the exception: an object reports only commands it issued itself, so ZCL commands sent by one object while another holds the registration report no status (inferred).

#### OnZclReceivedEvent takes incoming ZCL away from OnZigbeePacketIn

Observed. While the registered object has `OnZclReceivedEvent` set, the device's attribute reports, default responses and answers to `SendZcl` arrive there and not at `OnZigbeePacketIn`. No report reached the older handler while the callback was set. Delivery to `OnZigbeePacketIn` resumed the moment that object was replaced by one without the handler. A registered object without the handler leaves the older path alone.

The exception is a Read Attributes Response whose sequence number is below 0x80: it goes only to `OnZigbeePacketIn`, which keeps answers to reads sent with `C4:SendZigbeePacket` on the older path. Observed for one read; inferred, the exception covers no other command, so responses such as Configure Reporting still move to the callback.

#### OnSysDeviceStatusChangedEvent may take over online status

Inferred, not tested. While it is set, the controller appears to stop applying online, offline and reboot changes through the older driver path.

#### Sequence numbers 0x80 to 0xFF belong to the object

Observed. Every ZCL frame the object builds uses a sequence number from 0x80 to 0xFF; a new object started at 128. The Read Attributes exception above applies only below 0x80. So while `OnZclReceivedEvent` is set, a driver that also sends reads with `C4:SendZigbeePacket` gets their answers on `OnZigbeePacketIn` only if its own sequence numbers stay below 0x80 (inferred).

#### Bind responses carry no id

Observed. `OnZdoBindResponse` and `OnZdoUnbindResponse` say only whether it worked. With two binds outstanding there is no way to tell which answer is which. Send one, wait for its response or a final status, then send the next.

#### A number EUI is read as hex

Observed. Passing the number `12345` as `dstDeviceEuid` bound to `0000000000012345`, a node that does not exist, and the device accepted it. Always pass a 16-digit hex string, or leave the argument out.

#### Old and new names share one slot

Observed. `ResZdoBindCb` and `OnZdoBindResponse` set the same slot, and so on for every pair. Setting both leaves only the last one; a function set under the other name never runs.

#### SetZdoBindConfig removes unlisted unicast binds

Inferred, never exercised. The controller treats every bind with a 64-bit destination on the device as its own to manage, and unbinds any that is not in the list, including binds made by other tools or by the user between two devices. `nil`, no argument and `{}` all mean an empty list, which unbinds everything.

#### Some calls act with no arguments

`SetSysCheckInConfig()` writes its defaults, `SetSysQueueEmpty()` empties the queue, `SetZdoBindConfig()` clears the bind list and `SendSysOtaAddImage()` removes every image (all inferred). None of them is a safe probe.

#### Two calls need a signed driver

`SendZdo` refuses unsigned drivers for every call (observed). `SendSysOtaAddImage` refuses URLs that do not point at the controller (inferred). Everything else, including binds and raw ZCL, works unsigned.

#### SendZcl defaults to a cluster command

Inferred, not exercised. Without `frameType = "global"`, command 0x00 is the cluster's own command 0x00: Reset to Factory Defaults on Basic, Off on On/Off.

#### Cluster 0x0000 cannot be bound or unbound here

`SendZdoBind` and `SendZdoUnbind` refuse cluster ids below 1 (-1 observed). A device's table can still hold a Basic bind made some other way (observed), and `SendZdoUnbind` cannot remove it. `SetZdoBindConfig` would (inferred): a cluster-0 bind can never be in its list, so it is unbound with every other unlisted bind.

#### Some values wrap instead of failing

Only some limits are checked. Where a method has no explicit check, a value too wide for its field is cut down rather than refused: `SendZdoGetBindTable(256)` reached the controller as 256 and the device answered from index 0 (observed). The `SendZdoBind` upper bounds, the `SetZdoBindConfig` fields and the poll intervals behave the same way (inferred). `SendZcl`'s own limits and the controller's endpoint, cluster and profile checks do refuse (observed). Range-check your own inputs.

#### Message text differs between releases

Observed on 4.3.0 and present on 4.2.1: the same refusal reads `"SendZdoBind invalid srcEndpoint: 0"` on one and `"CmdZdoBind invalid srcEndpoint: 0"` on the other. Test the first return value.

#### lastMessageAge is not live

Observed. It stayed the same across reads more than an hour apart, and did not move on a device that reported every two seconds. Use your own receive times for liveness.

#### Discovery has two numberings

`GetSysDeviceDiscovery` reports a bit value, where `DONE` is 64 (observed). The event `OnSysDeviceDiscoveryEvent` reports a step number, where `DONE` is 21 (inferred). Compare names, not numbers.

#### OS 4.2.1 handles less incoming traffic

Inferred. On OS 4.2.1 the object has no `OnZclReceivedEvent` and appears to ignore incoming frames from every cluster except Poll Control, so the takeover of `OnZigbeePacketIn` described above cannot happen there.

## Integration recipe

Bind two clusters to the controller one at a time, then read the binding table back page by page. It uses the names present on both OS 4.2.1 and OS 4.3.0; on 4.3.0 they are the same functions as `SendZdoBind`, `SendZdoGetBindTable`, `OnZdoSentStatus`, `OnZdoBindResponse` and `OnZdoGetBindTableResponse`. It was not run on 4.2.1. Leaving `dstEndpoint` out was observed only on 4.3.0; on 4.2.1 pass it. Helper names are placeholders.

```lua
-- 1. The driver's only ZigbeeV2 object. Never create another, even for a
--    one-off getter: collecting it would unregister this device.
local zv2

-- 2. One bind in flight, because a bind response carries no commandId.
local pending = {}   -- { { cluster = n, ep = n }, ... }
local current        -- the bind in flight, with its commandId
local guard          -- timer for a device that never answers

local function finishCurrent()
  if guard then guard:Cancel() end
  current, guard = nil, nil
end

local function readBindTable(startIndex)
  zv2:CmdZdoGetBind(startIndex)
end

-- 3. Send the next bind; when none are left, read the table back.
local function sendNextBind()
  if current then return end
  current = table.remove(pending, 1)
  if not current then return readBindTable(0) end
  local id, err = zv2:CmdZdoBind(current.cluster, current.ep)
  if id == 0 then
    print("bind refused: " .. err)
    finishCurrent()
    return sendNextBind()
  end
  current.id = id
  guard = C4:SetTimer(15000, function()
    print(string.format("no answer for cluster 0x%04X", current.cluster))
    finishCurrent()
    sendNextBind()
  end)
end

-- 4. 61445 and 5 are progress, 0 means sent, anything else ends the bind.
--    61447 on OS 4.2.1: retry with an explicit dstEndpoint.
local function onSent(commandId, status, statusName)
  if not current or commandId ~= current.id then return end
  if status ~= 0 and status ~= 5 and status ~= 61445 then
    print("bind not sent: " .. statusName)
    finishCurrent()
    sendNextBind()
  end
end

-- 5. The device's answer to the bind in flight.
local function onBind(zdoStatus, zdoStatusName)
  if not current then return end
  print("bind answered: " .. zdoStatusName)
  finishCurrent()
  sendNextBind()
end

-- 6. Pages of up to three records; ask again until entries is reached.
local function onTable(page)
  if page.statusInt ~= 0 then return end
  for _, b in ipairs(page.binds) do
    print(string.format("0x%04X ep %d -> %s ep %d",
      b.clusterId, b.srcEndpoint, b.dstDeviceEuid, b.dstEndpoint))
  end
  local nextIndex = page.startIndex + #page.binds
  if #page.binds > 0 and nextIndex < page.entries then
    readBindTable(nextIndex)
  end
end

-- 7. Wire it up once the driver is running: callbacks, Init, then work.
function OnDriverLateInit()
  if not C4.ZigbeeV2 then return end
  zv2 = C4:ZigbeeV2()
  zv2:CmdZdoExecStatusCb(onSent)
  zv2:ResZdoBindCb(onBind)
  zv2:ResZdoGetBindCb(onTable)
  zv2:Init()
  pending = { { cluster = 0x0006, ep = 1 }, { cluster = 0x0702, ep = 1 } }
  sendNextBind()
end
```

ZigbeeV2 driver API reference Observed on OS 4.3.0.763230; OS 4.2.1 names present, not called
