This file provides guidance to AI agents when working with code in this repository.
npm test # Run all tests (mocha)
npm run lint # ESLint (extends athom config)
npm run build # Generate JSDoc documentationRun single test file:
npx mocha test/onOff.jsZigbee Cluster Library (ZCL) implementation for Node.js, designed for Homey's Zigbee stack.
- ZCLNode (
lib/Node.js) - Entry point. Wraps Homey's ZigBeeNode, manages endpoints, routes incoming frames. - Endpoint (
lib/Endpoint.js) - Represents device endpoint. Containsclusters(client) andbindings(server). Routes frames to appropriate cluster. - Cluster (
lib/Cluster.js) - Base class for all clusters. Handles frame parsing, command execution, attribute operations. Commands auto-generate methods via_addPrototypeMethods. - BoundCluster (
lib/BoundCluster.js) - Server-side cluster for receiving commands from remote nodes.
ZCLNode.handleFrame() → Endpoint.handleFrame() → Cluster/BoundCluster.handleFrame()
Cluster.sendCommand() → Endpoint.sendFrame() → ZCLNode.sendFrame()
Each cluster in lib/clusters/ follows:
- Define
ATTRIBUTESobject with{id, type}usingZCLDataTypes - Define
COMMANDSobject with{id, args?} - Extend
Clusterwith static getters:ID,NAME,ATTRIBUTES,COMMANDS - Call
Cluster.addCluster(MyCluster)to register
Example: lib/clusters/onOff.js
ZCLDataTypes- Primitive types (uint8, bool, enum8, etc.) from@athombv/data-typesZCLStruct- Composite types for command argumentsCLUSTERconstant - Maps cluster names to{ID, NAME, ATTRIBUTES, COMMANDS}
Prefer Cluster.ID over hardcoded numbers:
// Good
const OnOffCluster = require('../lib/clusters/onOff');
inputClusters: [OnOffCluster.ID]
// Avoid
inputClusters: [6]
inputClusters: [0x0006]Tests use mock nodes from test/util/mockNode.js:
Single node with loopback (command → same node's BoundCluster):
const { createMockNode } = require('./util');
const OnOffCluster = require('../lib/clusters/onOff');
const node = createMockNode({
loopback: true,
endpoints: [{ endpointId: 1, inputClusters: [OnOffCluster.ID] }],
});
node.endpoints[1].bind('onOff', new (class extends BoundCluster {
async setOn() { /* handle command */ }
})());
await node.endpoints[1].clusters.onOff.setOn(); // loops back to BoundClusterConnected node pair (node A sends → node B receives):
const { createConnectedNodePair } = require('./util');
const [sender, receiver] = createConnectedNodePair(
{ endpoints: [{ endpointId: 1, inputClusters: [6] }] },
{ endpoints: [{ endpointId: 1, inputClusters: [6] }] },
);
receiver.endpoints[1].bind('onOff', new BoundCluster());
await sender.endpoints[1].clusters.onOff.toggle();Server-to-client notifications (device → controller) still require manual frames:
const { ZCLStandardHeader } = require('../lib/zclFrames');
node.endpoints[1].clusters.iasZone.onZoneStatusChangeNotification = data => { ... };
const frame = new ZCLStandardHeader();
frame.cmdId = IASZoneCluster.COMMANDS.zoneStatusChangeNotification.id;
frame.frameControl.directionToClient = true;
frame.frameControl.clusterSpecific = true;
frame.data = Buffer.from([...]);
node.handleFrame(1, IASZoneCluster.ID, frame.toBuffer(), {});Preset mock devices for common sensor types:
const { MOCK_DEVICES } = require('./util');
const sensor = MOCK_DEVICES.motionSensor();
const boundCluster = sensor.endpoints[1].bindings.iasZone;index.js- Public exportslib/clusters/index.js- All cluster exports +CLUSTERconstantlib/zclTypes.js- ZCL data typeslib/zclFrames.js- ZCL frame parsing/serialization
Reusable guide for adding new clusters or updating existing cluster definitions.
- Spec PDF:
docs/zigbee-cluster-specification-r8.pdf - Extract text:
pdftotext docs/zigbee-cluster-specification-r8.pdf - - Cluster files:
lib/clusters/*.js
'use strict';
const Cluster = require('../Cluster');
const { ZCLDataTypes } = require('../zclTypes');
// Reusable enum definitions (if needed)
const EXAMPLE_ENUM = ZCLDataTypes.enum8({
value1: 0,
value2: 1,
});
// ============================================================================
// Server Attributes
// ============================================================================
const ATTRIBUTES = {
// Section Name (0x0000 - 0x000F)
// Description from spec, copied 1-on-1.
// Multi-line if needed, wrapped at 100 chars.
attrName: { id: 0x0000, type: ZCLDataTypes.uint8 }, // Mandatory
};
// ============================================================================
// Commands
// ============================================================================
const COMMANDS = {
// --- Client to Server Commands ---
// Description from spec.
commandName: { // Mandatory
id: 0x0000,
args: {
argName: ZCLDataTypes.uint8,
},
response: {
id: 0x0000,
args: { status: ZCLDataTypes.uint8 },
},
},
// --- Server to Client Commands ---
// Description from spec.
notificationName: { // Optional
id: 0x0020, // 32
direction: Cluster.DIRECTION_SERVER_TO_CLIENT,
args: {
argName: ZCLDataTypes.uint8,
},
},
};
class ExampleCluster extends Cluster {
static get ID() {
return 0x0000; // Add decimal comment if > 9, e.g.: return 0x0101; // 257
}
static get NAME() {
return 'example';
}
static get ATTRIBUTES() {
return ATTRIBUTES;
}
static get COMMANDS() {
return COMMANDS;
}
}
Cluster.addCluster(ExampleCluster);
module.exports = ExampleCluster;- Description placement: ABOVE the attribute/command
- M/O marker placement:
- Single-line: at END of line (
attrName: { ... }, // Mandatory) - Multi-line: on OPENING brace (
attrName: { // Mandatory) - NEVER on closing brace
- Single-line: at END of line (
- Copy exactly: Text from spec, 1-on-1
- Skip if >5 sentences: Skip and only refer to section in spec
- Line wrap: Respect 100 char limit (ESLint)
- M/O source for attrs: Server-side column in spec table
- M/O source for cmds:
- Server "receives" → server-side M/O
- Server "generates" → client-side M/O
- Conditional (M)*: Use
// Conditional¹with footnote for attrs/cmds marked "M*" in spec- Reason on new line below:
// ¹ Reason here - Shared reasons use same footnote number
- Reason on new line below:
currentLevel: { id: 0x0000, type: ZCLDataTypes.uint8 }, // Mandatory
pirOccupiedToUnoccupiedDelay: { id: 0x0010, type: ZCLDataTypes.uint16 }, // 16, Conditional¹
pirUnoccupiedToOccupiedDelay: { id: 0x0011, type: ZCLDataTypes.uint16 }, // 17, Conditional¹
// ¹ PIR sensor type supported| Field | Format | Notes |
|---|---|---|
id |
Hex (0x0000) |
Always 4-digit format (0x0000); add decimal comment if > 9 |
type |
ZCLDataTypes.* |
See type reference below |
| M/O | Inline comment | // Mandatory, // Optional, or // Conditional |
For hex values > 9 (where hex differs from decimal), add decimal in comment:
id: 0x000A, // 10
id: 0x0010, // 16
id: 0x0100, // 256For values 0-9, no decimal comment needed:
id: 0x0000,
id: 0x0009,For multi-line definitions, decimal goes on the id: line, M/O on opening brace:
operatingMode: { // Optional
id: 0x0025, // 37
type: ZCLDataTypes.enum8({
normal: 0,
vacation: 1,
}),
},Group attrs by function with section headers:
// Section Name (0x0000 - 0x000F)
attr1: { ... },
attr2: { ... },
// Another Section (0x0010 - 0x001F)
attr3: { ... },| Field | Required | Notes |
|---|---|---|
id |
Yes | Always 4-digit hex format (0x0000); add decimal comment if > 9 |
args |
If has params | Object with typed fields |
response |
If expects response | Has own id and args |
encodeMissingFieldsBehavior |
Controls the behavior of missing fields when encoding the struct | Set to skip if some fields are optional, omit if all fields are mandatory |
direction |
For server→client | Cluster.DIRECTION_SERVER_TO_CLIENT |
// --- Client to Server Commands ---
lockDoor: { id: 0x0000, ... }, // Mandatory
// --- Server to Client Commands ---
operationEventNotification: { // Optional
id: 0x0020, // 32
direction: Cluster.DIRECTION_SERVER_TO_CLIENT,
...
},Focus on client→server commands with inline response: when applicable:
lockDoor: {
id: 0x0000,
args: { pinCode: ZCLDataTypes.octstr },
response: {
id: 0x0000,
args: { status: ZCLDataTypes.uint8 },
},
},Server→client commands should only be added as standalone commands when they can be sent unsolicited (not just as a response to a request). Examples:
- Unsolicited notifications (e.g.,
operationEventNotificationfor door locks) — add as standalone - Responses that the server can also send unsolicited (e.g.,
upgradeEndResponsefor synchronized upgrades) — add as standalone - Responses that are only sent in reply to a request (e.g.,
queryNextImageResponse) — do NOT add as standalone; they are already covered by the inlineresponse:on the request command - These require
direction: Cluster.DIRECTION_SERVER_TO_CLIENT
Some commands contain optional fields. If this is the case add encodeMissingFieldsBehavior: 'skip' to the command definition.
lockDoor: {
id: 0x0000,
encodeMissingFieldsBehavior: 'skip',
args: { pinCode: ZCLDataTypes.octstr },
response: {
id: 0x0000,
args: { status: ZCLDataTypes.uint8 },
},
}Some commands might change their structure based on a status field. If this is the case, add encodeMissingFieldsBehavior: 'skip'. And add a comment above each field
when the field should be present.
IMPORTANT: This applies to response: structs too, not just the top-level command. If a response has variant fields (e.g., different fields depending on a status value), add encodeMissingFieldsBehavior: 'skip' to the response and include all variant fields with comments indicating when each group is present.
imageBlockRequest: {
id: 0x0003,
args: { ... },
response: {
id: 0x0005,
encodeMissingFieldsBehavior: 'skip',
args: {
status: ZCLDataTypes.enum8Status,
// When status is SUCCESS
manufacturerCode: ZCLDataTypes.uint16,
fileOffset: ZCLDataTypes.uint32,
imageData: ZCLDataTypes.buffer,
// When status is WAIT_FOR_DATA
currentTime: ZCLDataTypes.uint32,
requestTime: ZCLDataTypes.uint32,
},
},
}bool,uint8,uint16,uint24,uint32,uint48,uint64int8,int16,int24,int32string,octstr
ZCLDataTypes.enum8({
valueName: 0,
anotherValue: 1,
})ZCLDataTypes.map8('bit0', 'bit1', 'bit2')
ZCLDataTypes.map16('bit0', 'bit1', ...)
ZCLDataTypes.map64(...)ZCLDataTypes.Array0(ZCLDataTypes.uint8)
ZCLDataTypes.Array8(...)Define at module level if used multiple times:
const USER_STATUS_ENUM = ZCLDataTypes.enum8({
available: 0,
occupied: 1,
});
// Then use in commands:
args: { userStatus: USER_STATUS_ENUM }Same pattern for bitmaps used multiple times:
const ALARM_MASK = ZCLDataTypes.map8(
'generalHardwareFault',
'generalSoftwareFault',
'reserved2',
'reserved3',
);
// Then use in attributes/commands:
alarmMask: { id: 0x0010, type: ALARM_MASK },pdftotext docs/zigbee-cluster-specification-r8.pdf - | grep -A 500 "X.Y.Z Cluster Name"From spec tables, extract:
- Cluster ID and Name
- All attributes (ID, name, type, M/O)
- All commands (ID, name, args, direction, M/O)
- Descriptions (≤5 sentences)
- Use template above
- Follow naming:
lib/clusters/clusterName.js - Export in
lib/clusters/index.js
npm run lint
npm test
npm run build- Cluster ID correct (hex in class, matches spec)
- Cluster NAME matches spec (camelCase)
- All mandatory attrs present with
// Mandatory - All mandatory cmds present with
// Mandatory - Conditional attrs/cmds marked
// Conditional(M* in spec) - Optional attrs/cmds marked
// Optional - Descriptions copied from spec (≤5 sentences)
- Section comments group related attrs
- Client/server cmd sections separated
- Server→client cmds have
directionfield - Responses defined where applicable
- Reusable enums/bitmaps extracted if used 2+ times
- Hex IDs used consistently (with decimal comments if > 9)
- Lint passes
- Tests pass
- Best documented:
lib/clusters/doorLock.js - Simple attrs only:
lib/clusters/metering.js - Color/enum heavy:
lib/clusters/colorControl.js