From ac924a46a7d9285a6684117d8938be94e5357db5 Mon Sep 17 00:00:00 2001 From: Robin Bolscher Date: Mon, 26 Jan 2026 18:28:41 +0100 Subject: [PATCH 1/4] feat: extend cluster definitions for BasicZigbeeDevice support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 - Test Infrastructure: - Add mockDevice.js with createMockDevice() and MOCK_DEVICES presets - Add clusterSpec.js with ZCL spec definitions for 9 clusters - Add verifyClusterAttributes() for cluster completeness verification - Add clusterCompleteness.js test suite (14 new tests) Phase 1 - Cluster Fixes: - Metering (0x0702): 8 → 79 attributes (formatting, TOU, status, historical) - Door Lock (0x0101): 0 → 43 attributes + 26 commands (PIN/RFID, schedules) - Window Covering (0x0102): add mode + settings attributes (10 → 20) - Occupancy Sensing (0x0406): add occupancySensorTypeBitmap + physical contact All clusters now pass ZCL mandatory attribute verification. --- lib/clusters/doorLock.js | 438 ++++++++++++++++++++++++++++++- lib/clusters/metering.js | 100 ++++++- lib/clusters/occupancySensing.js | 31 ++- lib/clusters/windowCovering.js | 53 +++- test/clusterCompleteness.js | 189 +++++++++++++ test/iasZone.js | 48 ++++ test/util/clusterSpec.js | 280 ++++++++++++++++++++ test/util/index.js | 10 + test/util/mockDevice.js | 243 +++++++++++++++++ 9 files changed, 1364 insertions(+), 28 deletions(-) create mode 100644 test/clusterCompleteness.js create mode 100644 test/util/clusterSpec.js create mode 100644 test/util/mockDevice.js diff --git a/lib/clusters/doorLock.js b/lib/clusters/doorLock.js index 8d100b4..badc905 100644 --- a/lib/clusters/doorLock.js +++ b/lib/clusters/doorLock.js @@ -1,16 +1,450 @@ 'use strict'; const Cluster = require('../Cluster'); +const { ZCLDataTypes } = require('../zclTypes'); const ATTRIBUTES = { + // Lock Information (0x0000 - 0x000F) + lockState: { + id: 0x0000, // Mandatory + type: ZCLDataTypes.enum8({ + notFullyLocked: 0, + locked: 1, + unlocked: 2, + undefined: 255, + }), + }, + lockType: { + id: 0x0001, // Mandatory + type: ZCLDataTypes.enum8({ + deadBolt: 0, + magnetic: 1, + other: 2, + mortise: 3, + rim: 4, + latchBolt: 5, + cylindricalLock: 6, + tubularLock: 7, + interconnectedLock: 8, + deadLatch: 9, + doorFurniture: 10, + }), + }, + actuatorEnabled: { id: 0x0002, type: ZCLDataTypes.bool }, // Mandatory + doorState: { + id: 0x0003, + type: ZCLDataTypes.enum8({ + open: 0, + closed: 1, + errorJammed: 2, + errorForcedOpen: 3, + errorUnspecified: 4, + undefined: 255, + }), + }, + doorOpenEvents: { id: 0x0004, type: ZCLDataTypes.uint32 }, + doorClosedEvents: { id: 0x0005, type: ZCLDataTypes.uint32 }, + openPeriod: { id: 0x0006, type: ZCLDataTypes.uint16 }, + + // User/PIN/RFID Configuration (0x0010 - 0x001F) + numberOfLogRecordsSupported: { id: 0x0010, type: ZCLDataTypes.uint16 }, + numberOfTotalUsersSupported: { id: 0x0011, type: ZCLDataTypes.uint16 }, + numberOfPINUsersSupported: { id: 0x0012, type: ZCLDataTypes.uint16 }, + numberOfRFIDUsersSupported: { id: 0x0013, type: ZCLDataTypes.uint16 }, + numberOfWeekDaySchedulesSupportedPerUser: { id: 0x0014, type: ZCLDataTypes.uint8 }, + numberOfYearDaySchedulesSupportedPerUser: { id: 0x0015, type: ZCLDataTypes.uint8 }, + numberOfHolidaySchedulesSupported: { id: 0x0016, type: ZCLDataTypes.uint8 }, + maxPINCodeLength: { id: 0x0017, type: ZCLDataTypes.uint8 }, + minPINCodeLength: { id: 0x0018, type: ZCLDataTypes.uint8 }, + maxRFIDCodeLength: { id: 0x0019, type: ZCLDataTypes.uint8 }, + minRFIDCodeLength: { id: 0x001A, type: ZCLDataTypes.uint8 }, + + // Operational Settings (0x0020 - 0x002F) + enableLogging: { id: 0x0020, type: ZCLDataTypes.bool }, + language: { id: 0x0021, type: ZCLDataTypes.string }, + ledSettings: { id: 0x0022, type: ZCLDataTypes.uint8 }, + autoRelockTime: { id: 0x0023, type: ZCLDataTypes.uint32 }, + soundVolume: { id: 0x0024, type: ZCLDataTypes.uint8 }, + operatingMode: { + id: 0x0025, + type: ZCLDataTypes.enum8({ + normal: 0, + vacation: 1, + privacy: 2, + noRFLockOrUnlock: 3, + passage: 4, + }), + }, + supportedOperatingModes: { + id: 0x0026, + type: ZCLDataTypes.map16( + 'normal', + 'vacation', + 'privacy', + 'noRFLockOrUnlock', + 'passage', + ), + }, + defaultConfigurationRegister: { + id: 0x0027, + type: ZCLDataTypes.map16( + 'enableLocalProgramming', + 'keypadInterfaceDefaultAccess', + 'rfInterfaceDefaultAccess', + 'reserved3', + 'reserved4', + 'soundEnabled', + 'autoRelockTimeSet', + 'ledSettingsSet', + ), + }, + enableLocalProgramming: { id: 0x0028, type: ZCLDataTypes.bool }, + enableOneTouchLocking: { id: 0x0029, type: ZCLDataTypes.bool }, + enableInsideStatusLED: { id: 0x002A, type: ZCLDataTypes.bool }, + enablePrivacyModeButton: { id: 0x002B, type: ZCLDataTypes.bool }, + + // Security Settings (0x0030 - 0x003F) + wrongCodeEntryLimit: { id: 0x0030, type: ZCLDataTypes.uint8 }, + userCodeTemporaryDisableTime: { id: 0x0031, type: ZCLDataTypes.uint8 }, + sendPINOverTheAir: { id: 0x0032, type: ZCLDataTypes.bool }, + requirePINforRFOperation: { id: 0x0033, type: ZCLDataTypes.bool }, + securityLevel: { + id: 0x0034, + type: ZCLDataTypes.enum8({ + network: 0, + apsLinkKey: 1, + }), + }, + + // Alarm and Event Masks (0x0040 - 0x004F) + alarmMask: { + id: 0x0040, + type: ZCLDataTypes.map16( + 'deadboltJammed', + 'lockResetToFactoryDefaults', + 'reserved2', + 'rfModulePowerCycled', + 'tamperAlarmWrongCodeEntryLimit', + 'tamperAlarmFrontEscutcheonRemoved', + 'forcedDoorOpenUnderDoorLockedCondition', + ), + }, + keypadOperationEventMask: { + id: 0x0041, + type: ZCLDataTypes.map16( + 'unknownOrManufacturerSpecificKeypadOperationEvent', + 'lockSourceKeypad', + 'unlockSourceKeypad', + 'lockSourceKeypadErrorInvalidPIN', + 'lockSourceKeypadErrorInvalidSchedule', + 'unlockSourceKeypadErrorInvalidCode', + 'unlockSourceKeypadErrorInvalidSchedule', + 'nonAccessUserOperationEventSourceKeypad', + ), + }, + rfOperationEventMask: { + id: 0x0042, + type: ZCLDataTypes.map16( + 'unknownOrManufacturerSpecificKeypadOperationEvent', + 'lockSourceRF', + 'unlockSourceRF', + 'lockSourceRFErrorInvalidCode', + 'lockSourceRFErrorInvalidSchedule', + 'unlockSourceRFErrorInvalidCode', + 'unlockSourceRFErrorInvalidSchedule', + ), + }, + manualOperationEventMask: { + id: 0x0043, + type: ZCLDataTypes.map16( + 'unknownOrManufacturerSpecificManualOperationEvent', + 'thumbturnLock', + 'thumbturnUnlock', + 'oneTouchLock', + 'keyLock', + 'keyUnlock', + 'autoLock', + 'scheduleLock', + 'scheduleUnlock', + 'manualLock', + 'manualUnlock', + ), + }, + rfidOperationEventMask: { + id: 0x0044, + type: ZCLDataTypes.map16( + 'unknownOrManufacturerSpecificKeypadOperationEvent', + 'lockSourceRFID', + 'unlockSourceRFID', + 'lockSourceRFIDErrorInvalidRFIDID', + 'lockSourceRFIDErrorInvalidSchedule', + 'unlockSourceRFIDErrorInvalidRFIDID', + 'unlockSourceRFIDErrorInvalidSchedule', + ), + }, + keypadProgrammingEventMask: { + id: 0x0045, + type: ZCLDataTypes.map16( + 'unknownOrManufacturerSpecificKeypadProgrammingEvent', + 'masterCodeChanged', + 'pinCodeAdded', + 'pinCodeDeleted', + 'pinCodeChanged', + ), + }, + rfProgrammingEventMask: { + id: 0x0046, + type: ZCLDataTypes.map16( + 'unknownOrManufacturerSpecificRFProgrammingEvent', + 'reserved1', + 'pinCodeAdded', + 'pinCodeDeleted', + 'pinCodeChanged', + 'rfidCodeAdded', + 'rfidCodeDeleted', + ), + }, + rfidProgrammingEventMask: { + id: 0x0047, + type: ZCLDataTypes.map16( + 'unknownOrManufacturerSpecificRFIDProgrammingEvent', + 'rfidCodeAdded', + 'rfidCodeDeleted', + ), + }, }; -const COMMANDS = {}; +const COMMANDS = { + // Lock/Unlock Commands + lockDoor: { + id: 0x00, // Mandatory + args: { + pinCode: ZCLDataTypes.octstr, + }, + }, + unlockDoor: { + id: 0x01, // Mandatory + args: { + pinCode: ZCLDataTypes.octstr, + }, + }, + toggle: { + id: 0x02, + args: { + pinCode: ZCLDataTypes.octstr, + }, + }, + unlockWithTimeout: { + id: 0x03, + args: { + timeout: ZCLDataTypes.uint16, + pinCode: ZCLDataTypes.octstr, + }, + }, + + // Logging Commands + getLogRecord: { + id: 0x04, + args: { + logIndex: ZCLDataTypes.uint16, + }, + }, + + // PIN Code Commands + setPINCode: { + id: 0x05, + args: { + userId: ZCLDataTypes.uint16, + userStatus: ZCLDataTypes.enum8({ + available: 0, + occupiedEnabled: 1, + occupiedDisabled: 3, + notSupported: 255, + }), + userType: ZCLDataTypes.enum8({ + unrestricted: 0, + yearDayScheduleUser: 1, + weekDayScheduleUser: 2, + masterUser: 3, + nonAccessUser: 4, + notSupported: 255, + }), + pinCode: ZCLDataTypes.octstr, + }, + }, + getPINCode: { + id: 0x06, + args: { + userId: ZCLDataTypes.uint16, + }, + }, + clearPINCode: { + id: 0x07, + args: { + userId: ZCLDataTypes.uint16, + }, + }, + clearAllPINCodes: { id: 0x08 }, + setUserStatus: { + id: 0x09, + args: { + userId: ZCLDataTypes.uint16, + userStatus: ZCLDataTypes.enum8({ + available: 0, + occupiedEnabled: 1, + occupiedDisabled: 3, + notSupported: 255, + }), + }, + }, + getUserStatus: { + id: 0x0A, + args: { + userId: ZCLDataTypes.uint16, + }, + }, + + // Schedule Commands + setWeekDaySchedule: { + id: 0x0B, + args: { + scheduleId: ZCLDataTypes.uint8, + userId: ZCLDataTypes.uint16, + daysMask: ZCLDataTypes.map8('sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'), + startHour: ZCLDataTypes.uint8, + startMinute: ZCLDataTypes.uint8, + endHour: ZCLDataTypes.uint8, + endMinute: ZCLDataTypes.uint8, + }, + }, + getWeekDaySchedule: { + id: 0x0C, + args: { + scheduleId: ZCLDataTypes.uint8, + userId: ZCLDataTypes.uint16, + }, + }, + clearWeekDaySchedule: { + id: 0x0D, + args: { + scheduleId: ZCLDataTypes.uint8, + userId: ZCLDataTypes.uint16, + }, + }, + setYearDaySchedule: { + id: 0x0E, + args: { + scheduleId: ZCLDataTypes.uint8, + userId: ZCLDataTypes.uint16, + localStartTime: ZCLDataTypes.uint32, + localEndTime: ZCLDataTypes.uint32, + }, + }, + getYearDaySchedule: { + id: 0x0F, + args: { + scheduleId: ZCLDataTypes.uint8, + userId: ZCLDataTypes.uint16, + }, + }, + clearYearDaySchedule: { + id: 0x10, + args: { + scheduleId: ZCLDataTypes.uint8, + userId: ZCLDataTypes.uint16, + }, + }, + setHolidaySchedule: { + id: 0x11, + args: { + holidayScheduleId: ZCLDataTypes.uint8, + localStartTime: ZCLDataTypes.uint32, + localEndTime: ZCLDataTypes.uint32, + operatingModeDuringHoliday: ZCLDataTypes.enum8({ + normal: 0, + vacation: 1, + privacy: 2, + noRFLockOrUnlock: 3, + passage: 4, + }), + }, + }, + getHolidaySchedule: { + id: 0x12, + args: { + holidayScheduleId: ZCLDataTypes.uint8, + }, + }, + clearHolidaySchedule: { + id: 0x13, + args: { + holidayScheduleId: ZCLDataTypes.uint8, + }, + }, + + // User Type Commands + setUserType: { + id: 0x14, + args: { + userId: ZCLDataTypes.uint16, + userType: ZCLDataTypes.enum8({ + unrestricted: 0, + yearDayScheduleUser: 1, + weekDayScheduleUser: 2, + masterUser: 3, + nonAccessUser: 4, + notSupported: 255, + }), + }, + }, + getUserType: { + id: 0x15, + args: { + userId: ZCLDataTypes.uint16, + }, + }, + + // RFID Code Commands + setRFIDCode: { + id: 0x16, + args: { + userId: ZCLDataTypes.uint16, + userStatus: ZCLDataTypes.enum8({ + available: 0, + occupiedEnabled: 1, + occupiedDisabled: 3, + notSupported: 255, + }), + userType: ZCLDataTypes.enum8({ + unrestricted: 0, + yearDayScheduleUser: 1, + weekDayScheduleUser: 2, + masterUser: 3, + nonAccessUser: 4, + notSupported: 255, + }), + rfidCode: ZCLDataTypes.octstr, + }, + }, + getRFIDCode: { + id: 0x17, + args: { + userId: ZCLDataTypes.uint16, + }, + }, + clearRFIDCode: { + id: 0x18, + args: { + userId: ZCLDataTypes.uint16, + }, + }, + clearAllRFIDCodes: { id: 0x19 }, +}; class DoorLockCluster extends Cluster { static get ID() { - return 257; + return 257; // 0x0101 } static get NAME() { diff --git a/lib/clusters/metering.js b/lib/clusters/metering.js index 6ae4d58..030c2cc 100644 --- a/lib/clusters/metering.js +++ b/lib/clusters/metering.js @@ -4,14 +4,98 @@ const Cluster = require('../Cluster'); const { ZCLDataTypes } = require('../zclTypes'); const ATTRIBUTES = { - currentSummationDelivered: { id: 0, type: ZCLDataTypes.uint48 }, - currentSummationReceived: { id: 1, type: ZCLDataTypes.uint48 }, - currentMaxDemandDelivered: { id: 2, type: ZCLDataTypes.uint48 }, - currentMaxDemandReceived: { id: 3, type: ZCLDataTypes.uint48 }, - powerFactor: { id: 6, type: ZCLDataTypes.int8 }, - multiplier: { id: 769, type: ZCLDataTypes.uint24 }, - divisor: { id: 770, type: ZCLDataTypes.uint24 }, - instantaneousDemand: { id: 1024, type: ZCLDataTypes.int24 }, + // Reading Information Set (0x00 - 0x0F) + currentSummationDelivered: { id: 0x0000, type: ZCLDataTypes.uint48 }, // Mandatory + currentSummationReceived: { id: 0x0001, type: ZCLDataTypes.uint48 }, + currentMaxDemandDelivered: { id: 0x0002, type: ZCLDataTypes.uint48 }, + currentMaxDemandReceived: { id: 0x0003, type: ZCLDataTypes.uint48 }, + dftSummation: { id: 0x0004, type: ZCLDataTypes.uint48 }, + dailyFreezeTime: { id: 0x0005, type: ZCLDataTypes.uint16 }, + powerFactor: { id: 0x0006, type: ZCLDataTypes.int8 }, + readingSnapShotTime: { id: 0x0007, type: ZCLDataTypes.uint32 }, // UTC time + currentMaxDemandDeliveredTime: { id: 0x0008, type: ZCLDataTypes.uint32 }, // UTC time + currentMaxDemandReceivedTime: { id: 0x0009, type: ZCLDataTypes.uint32 }, // UTC time + defaultUpdatePeriod: { id: 0x000A, type: ZCLDataTypes.uint8 }, + fastPollUpdatePeriod: { id: 0x000B, type: ZCLDataTypes.uint8 }, + currentBlockPeriodConsumptionDelivered: { id: 0x000C, type: ZCLDataTypes.uint48 }, + dailyConsumptionTarget: { id: 0x000D, type: ZCLDataTypes.uint24 }, + currentBlock: { id: 0x000E, type: ZCLDataTypes.enum8 }, + profileIntervalPeriod: { id: 0x000F, type: ZCLDataTypes.enum8 }, + + // Summation TOU Information Set (0x0100 - 0x01FF) + currentTier1SummationDelivered: { id: 0x0100, type: ZCLDataTypes.uint48 }, + currentTier1SummationReceived: { id: 0x0101, type: ZCLDataTypes.uint48 }, + currentTier2SummationDelivered: { id: 0x0102, type: ZCLDataTypes.uint48 }, + currentTier2SummationReceived: { id: 0x0103, type: ZCLDataTypes.uint48 }, + currentTier3SummationDelivered: { id: 0x0104, type: ZCLDataTypes.uint48 }, + currentTier3SummationReceived: { id: 0x0105, type: ZCLDataTypes.uint48 }, + currentTier4SummationDelivered: { id: 0x0106, type: ZCLDataTypes.uint48 }, + currentTier4SummationReceived: { id: 0x0107, type: ZCLDataTypes.uint48 }, + + // Meter Status (0x0200 - 0x02FF) + status: { id: 0x0200, type: ZCLDataTypes.map8 }, // MeterStatus bitmap + remainingBatteryLife: { id: 0x0201, type: ZCLDataTypes.uint8 }, + hoursInOperation: { id: 0x0202, type: ZCLDataTypes.uint24 }, + hoursInFault: { id: 0x0203, type: ZCLDataTypes.uint24 }, + extendedStatus: { id: 0x0204, type: ZCLDataTypes.map64 }, + + // Formatting Set (0x0300 - 0x03FF) - Critical for value interpretation + unitOfMeasure: { id: 0x0300, type: ZCLDataTypes.enum8 }, // Mandatory: kWh, m³, etc. + multiplier: { id: 0x0301, type: ZCLDataTypes.uint24 }, + divisor: { id: 0x0302, type: ZCLDataTypes.uint24 }, + summationFormatting: { id: 0x0303, type: ZCLDataTypes.map8 }, // Mandatory: decimal places + demandFormatting: { id: 0x0304, type: ZCLDataTypes.map8 }, + historicalConsumptionFormatting: { id: 0x0305, type: ZCLDataTypes.map8 }, + meteringDeviceType: { id: 0x0306, type: ZCLDataTypes.map8 }, // Mandatory: Electric/Gas/Water + siteId: { id: 0x0307, type: ZCLDataTypes.octstr }, + meterSerialNumber: { id: 0x0308, type: ZCLDataTypes.octstr }, + energyCarrierUnitOfMeasure: { id: 0x0309, type: ZCLDataTypes.enum8 }, + energyCarrierSummationFormatting: { id: 0x030A, type: ZCLDataTypes.map8 }, + energyCarrierDemandFormatting: { id: 0x030B, type: ZCLDataTypes.map8 }, + temperatureUnitOfMeasure: { id: 0x030C, type: ZCLDataTypes.enum8 }, + temperatureFormatting: { id: 0x030D, type: ZCLDataTypes.map8 }, + moduleSerialNumber: { id: 0x030E, type: ZCLDataTypes.octstr }, + operatingTariffLabelDelivered: { id: 0x030F, type: ZCLDataTypes.octstr }, + operatingTariffLabelReceived: { id: 0x0310, type: ZCLDataTypes.octstr }, + customerIdNumber: { id: 0x0311, type: ZCLDataTypes.octstr }, + alternativeUnitOfMeasure: { id: 0x0312, type: ZCLDataTypes.enum8 }, + alternativeDemandFormatting: { id: 0x0313, type: ZCLDataTypes.map8 }, + alternativeConsumptionFormatting: { id: 0x0314, type: ZCLDataTypes.map8 }, + + // Historical Consumption (0x0400 - 0x04FF) + instantaneousDemand: { id: 0x0400, type: ZCLDataTypes.int24 }, + currentDayConsumptionDelivered: { id: 0x0401, type: ZCLDataTypes.uint24 }, + currentDayConsumptionReceived: { id: 0x0402, type: ZCLDataTypes.uint24 }, + previousDayConsumptionDelivered: { id: 0x0403, type: ZCLDataTypes.uint24 }, + previousDayConsumptionReceived: { id: 0x0404, type: ZCLDataTypes.uint24 }, + currentPartialProfileIntervalStartTimeDelivered: { id: 0x0405, type: ZCLDataTypes.uint32 }, + currentPartialProfileIntervalStartTimeReceived: { id: 0x0406, type: ZCLDataTypes.uint32 }, + currentPartialProfileIntervalValueDelivered: { id: 0x0407, type: ZCLDataTypes.uint24 }, + currentPartialProfileIntervalValueReceived: { id: 0x0408, type: ZCLDataTypes.uint24 }, + currentDayMaxPressure: { id: 0x0409, type: ZCLDataTypes.uint48 }, + currentDayMinPressure: { id: 0x040A, type: ZCLDataTypes.uint48 }, + previousDayMaxPressure: { id: 0x040B, type: ZCLDataTypes.uint48 }, + previousDayMinPressure: { id: 0x040C, type: ZCLDataTypes.uint48 }, + currentDayMaxDemand: { id: 0x040D, type: ZCLDataTypes.int24 }, + previousDayMaxDemand: { id: 0x040E, type: ZCLDataTypes.int24 }, + currentMonthMaxDemand: { id: 0x040F, type: ZCLDataTypes.int24 }, + currentYearMaxDemand: { id: 0x0410, type: ZCLDataTypes.int24 }, + currentDayMaxEnergyCarrierDemand: { id: 0x0411, type: ZCLDataTypes.int24 }, + previousDayMaxEnergyCarrierDemand: { id: 0x0412, type: ZCLDataTypes.int24 }, + currentMonthMaxEnergyCarrierDemand: { id: 0x0413, type: ZCLDataTypes.int24 }, + currentMonthMinEnergyCarrierDemand: { id: 0x0414, type: ZCLDataTypes.int24 }, + currentYearMaxEnergyCarrierDemand: { id: 0x0415, type: ZCLDataTypes.int24 }, + currentYearMinEnergyCarrierDemand: { id: 0x0416, type: ZCLDataTypes.int24 }, + + // Load Profile Configuration (0x0500 - 0x05FF) + maxNumberOfPeriodsDelivered: { id: 0x0500, type: ZCLDataTypes.uint8 }, + + // Supply Limit (0x0600 - 0x06FF) + currentDemandDelivered: { id: 0x0600, type: ZCLDataTypes.uint24 }, + demandLimit: { id: 0x0601, type: ZCLDataTypes.uint24 }, + demandIntegrationPeriod: { id: 0x0602, type: ZCLDataTypes.uint8 }, + numberOfDemandSubintervals: { id: 0x0603, type: ZCLDataTypes.uint8 }, + demandLimitArmDuration: { id: 0x0604, type: ZCLDataTypes.uint16 }, }; const COMMANDS = {}; diff --git a/lib/clusters/occupancySensing.js b/lib/clusters/occupancySensing.js index ca6e902..75d8d96 100644 --- a/lib/clusters/occupancySensing.js +++ b/lib/clusters/occupancySensing.js @@ -4,21 +4,36 @@ const Cluster = require('../Cluster'); const { ZCLDataTypes } = require('../zclTypes'); const ATTRIBUTES = { - occupancy: { id: 0, type: ZCLDataTypes.map8('occupied') }, // TODO: verify this bitmap + // Occupancy Sensor Information (0x0000 - 0x000F) + occupancy: { id: 0x0000, type: ZCLDataTypes.map8('occupied') }, // Mandatory: bit 0 = occupied occupancySensorType: { - id: 1, + id: 0x0001, // Mandatory type: ZCLDataTypes.enum8({ pir: 0, // 0x00 PIR ultrasonic: 1, // 0x01 Ultrasonic pirAndUltrasonic: 2, // 0x02 PIR and ultrasonic + physicalContact: 3, // 0x03 Physical Contact }), }, - pirOccupiedToUnoccupiedDelay: { id: 16, type: ZCLDataTypes.uint16 }, - pirUnoccupiedToOccupiedDelay: { id: 17, type: ZCLDataTypes.uint16 }, - pirUnoccupiedToOccupiedThreshold: { id: 18, type: ZCLDataTypes.uint8 }, - ultrasonicOccupiedToUnoccupiedDelay: { id: 32, type: ZCLDataTypes.uint16 }, - ultrasonicUnoccupiedToOccupiedDelay: { id: 33, type: ZCLDataTypes.uint16 }, - ultrasonicUnoccupiedToOccupiedThreshold: { id: 34, type: ZCLDataTypes.uint8 }, + occupancySensorTypeBitmap: { + id: 0x0002, // Mandatory + type: ZCLDataTypes.map8('pir', 'ultrasonic', 'physicalContact'), + }, + + // PIR Configuration (0x0010 - 0x001F) + pirOccupiedToUnoccupiedDelay: { id: 0x0010, type: ZCLDataTypes.uint16 }, + pirUnoccupiedToOccupiedDelay: { id: 0x0011, type: ZCLDataTypes.uint16 }, + pirUnoccupiedToOccupiedThreshold: { id: 0x0012, type: ZCLDataTypes.uint8 }, + + // Ultrasonic Configuration (0x0020 - 0x002F) + ultrasonicOccupiedToUnoccupiedDelay: { id: 0x0020, type: ZCLDataTypes.uint16 }, + ultrasonicUnoccupiedToOccupiedDelay: { id: 0x0021, type: ZCLDataTypes.uint16 }, + ultrasonicUnoccupiedToOccupiedThreshold: { id: 0x0022, type: ZCLDataTypes.uint8 }, + + // Physical Contact Configuration (0x0030 - 0x003F) + physicalContactOccupiedToUnoccupiedDelay: { id: 0x0030, type: ZCLDataTypes.uint16 }, + physicalContactUnoccupiedToOccupiedDelay: { id: 0x0031, type: ZCLDataTypes.uint16 }, + physicalContactUnoccupiedToOccupiedThreshold: { id: 0x0032, type: ZCLDataTypes.uint8 }, }; const COMMANDS = {}; diff --git a/lib/clusters/windowCovering.js b/lib/clusters/windowCovering.js index 2856279..5ba5721 100644 --- a/lib/clusters/windowCovering.js +++ b/lib/clusters/windowCovering.js @@ -4,8 +4,9 @@ const Cluster = require('../Cluster'); const { ZCLDataTypes } = require('../zclTypes'); const ATTRIBUTES = { + // Window Covering Information (0x0000 - 0x000F) windowCoveringType: { - id: 0, + id: 0x0000, type: ZCLDataTypes.enum8({ rollershade: 0, rollershade2Motor: 1, @@ -19,15 +20,47 @@ const ATTRIBUTES = { projectorScreen: 9, }), }, - physicalClosedLimitLift: { id: 1, type: ZCLDataTypes.uint16 }, - physicalClosedLimitTilt: { id: 2, type: ZCLDataTypes.uint16 }, - currentPositionLift: { id: 3, type: ZCLDataTypes.uint16 }, - currentPositionTilt: { id: 4, type: ZCLDataTypes.uint16 }, - numberofActuationsLift: { id: 5, type: ZCLDataTypes.uint16 }, - numberofActuationsTilt: { id: 6, type: ZCLDataTypes.uint16 }, - configStatus: { id: 7, type: ZCLDataTypes.map8('operational', 'online', 'reversalLiftCommands', 'controlLift', 'controlTilt', 'encoderLift', 'encoderTilt', 'reserved') }, - currentPositionLiftPercentage: { id: 8, type: ZCLDataTypes.uint8 }, - currentPositionTiltPercentage: { id: 9, type: ZCLDataTypes.uint8 }, + physicalClosedLimitLift: { id: 0x0001, type: ZCLDataTypes.uint16 }, + physicalClosedLimitTilt: { id: 0x0002, type: ZCLDataTypes.uint16 }, + currentPositionLift: { id: 0x0003, type: ZCLDataTypes.uint16 }, + currentPositionTilt: { id: 0x0004, type: ZCLDataTypes.uint16 }, + numberofActuationsLift: { id: 0x0005, type: ZCLDataTypes.uint16 }, + numberofActuationsTilt: { id: 0x0006, type: ZCLDataTypes.uint16 }, + configStatus: { + id: 0x0007, + type: ZCLDataTypes.map8( + 'operational', + 'online', + 'reversalLiftCommands', + 'controlLift', + 'controlTilt', + 'encoderLift', + 'encoderTilt', + 'reserved', + ), + }, + currentPositionLiftPercentage: { id: 0x0008, type: ZCLDataTypes.uint8 }, + currentPositionTiltPercentage: { id: 0x0009, type: ZCLDataTypes.uint8 }, + + // Settings (0x0010 - 0x001F) + installedOpenLimitLift: { id: 0x0010, type: ZCLDataTypes.uint16 }, + installedClosedLimitLift: { id: 0x0011, type: ZCLDataTypes.uint16 }, + installedOpenLimitTilt: { id: 0x0012, type: ZCLDataTypes.uint16 }, + installedClosedLimitTilt: { id: 0x0013, type: ZCLDataTypes.uint16 }, + velocityLift: { id: 0x0014, type: ZCLDataTypes.uint16 }, + accelerationTimeLift: { id: 0x0015, type: ZCLDataTypes.uint16 }, + decelerationTimeLift: { id: 0x0016, type: ZCLDataTypes.uint16 }, + mode: { + id: 0x0017, // Mandatory + type: ZCLDataTypes.map8( + 'motorDirectionReversed', + 'calibrationMode', + 'maintenanceMode', + 'ledFeedback', + ), + }, + intermediateSetpointsLift: { id: 0x0018, type: ZCLDataTypes.octstr }, + intermediateSetpointsTilt: { id: 0x0019, type: ZCLDataTypes.octstr }, }; const COMMANDS = { diff --git a/test/clusterCompleteness.js b/test/clusterCompleteness.js new file mode 100644 index 0000000..4f4650b --- /dev/null +++ b/test/clusterCompleteness.js @@ -0,0 +1,189 @@ +'use strict'; + +const assert = require('assert'); +const { ZCL_SPEC, verifyClusterAttributes } = require('./util'); + +// Load all clusters so they can be verified +require('../lib/clusters/metering'); +require('../lib/clusters/thermostat'); +require('../lib/clusters/windowCovering'); +require('../lib/clusters/doorLock'); +require('../lib/clusters/temperatureMeasurement'); +require('../lib/clusters/relativeHumidity'); +require('../lib/clusters/occupancySensing'); +require('../lib/clusters/powerConfiguration'); +require('../lib/clusters/iasZone'); + +describe('Cluster Completeness Tests', function() { + describe('Metering Cluster (0x0702)', function() { + it('should have all mandatory attributes', function() { + const result = verifyClusterAttributes('metering'); + assert.strictEqual( + result.status, + 'pass', + `Missing mandatory attributes: ${result.missing.join(', ')}`, + ); + }); + + it('should have critical formatting attributes', function() { + const result = verifyClusterAttributes('metering'); + const criticalAttrs = ['unitOfMeasure', 'summationFormatting', 'meteringDeviceType']; + for (const attr of criticalAttrs) { + assert( + result.implemented.includes(attr), + `Critical attribute ${attr} should be implemented`, + ); + } + }); + }); + + describe('Thermostat Cluster (0x0201)', function() { + it('should have all mandatory attributes', function() { + const result = verifyClusterAttributes('thermostat'); + assert.strictEqual( + result.status, + 'pass', + `Missing mandatory attributes: ${result.missing.join(', ')}`, + ); + }); + + it('should have setpoint attributes', function() { + const result = verifyClusterAttributes('thermostat'); + const setpointAttrs = ['occupiedCoolingSetpoint', 'occupiedHeatingSetpoint']; + for (const attr of setpointAttrs) { + assert( + result.implemented.includes(attr), + `Setpoint attribute ${attr} should be implemented`, + ); + } + }); + }); + + describe('Window Covering Cluster (0x0102)', function() { + it('should have all mandatory attributes', function() { + const result = verifyClusterAttributes('windowCovering'); + assert.strictEqual( + result.status, + 'pass', + `Missing mandatory attributes: ${result.missing.join(', ')}`, + ); + }); + + it('should have position percentage attributes', function() { + const result = verifyClusterAttributes('windowCovering'); + const positionAttrs = ['currentPositionLiftPercentage', 'currentPositionTiltPercentage']; + for (const attr of positionAttrs) { + assert( + result.implemented.includes(attr), + `Position attribute ${attr} should be implemented`, + ); + } + }); + }); + + describe('Door Lock Cluster (0x0101)', function() { + it('should have all mandatory attributes', function() { + const result = verifyClusterAttributes('doorLock'); + assert.strictEqual( + result.status, + 'pass', + `Missing mandatory attributes: ${result.missing.join(', ')}`, + ); + }); + + it('should have lock state and type attributes', function() { + const result = verifyClusterAttributes('doorLock'); + const lockAttrs = ['lockState', 'lockType', 'actuatorEnabled']; + for (const attr of lockAttrs) { + assert( + result.implemented.includes(attr), + `Lock attribute ${attr} should be implemented`, + ); + } + }); + }); + + describe('Temperature Measurement Cluster (0x0402)', function() { + it('should have all mandatory attributes', function() { + const result = verifyClusterAttributes('temperatureMeasurement'); + assert.strictEqual( + result.status, + 'pass', + `Missing mandatory attributes: ${result.missing.join(', ')}`, + ); + }); + }); + + describe('Relative Humidity Cluster (0x0405)', function() { + it('should have all mandatory attributes', function() { + const result = verifyClusterAttributes('relativeHumidity'); + assert.strictEqual( + result.status, + 'pass', + `Missing mandatory attributes: ${result.missing.join(', ')}`, + ); + }); + }); + + describe('Occupancy Sensing Cluster (0x0406)', function() { + it('should have all mandatory attributes', function() { + const result = verifyClusterAttributes('occupancySensing'); + assert.strictEqual( + result.status, + 'pass', + `Missing mandatory attributes: ${result.missing.join(', ')}`, + ); + }); + }); + + describe('Power Configuration Cluster (0x0001)', function() { + it('should have battery attributes', function() { + const result = verifyClusterAttributes('powerConfiguration'); + // Power Configuration has no mandatory attributes per ZCL spec + // But we want to ensure battery-related attributes are present + const batteryAttrs = ['batteryVoltage', 'batteryPercentageRemaining']; + for (const attr of batteryAttrs) { + assert( + result.implemented.includes(attr), + `Battery attribute ${attr} should be implemented`, + ); + } + }); + }); + + describe('IAS Zone Cluster (0x0500)', function() { + it('should have all mandatory attributes', function() { + const result = verifyClusterAttributes('iasZone'); + assert.strictEqual( + result.status, + 'pass', + `Missing mandatory attributes: ${result.missing.join(', ')}`, + ); + }); + }); + + describe('All Spec Clusters Summary', function() { + it('should report completeness for all clusters in ZCL_SPEC', function() { + const results = {}; + const clusterNames = Object.keys(ZCL_SPEC); + + for (const name of clusterNames) { + try { + results[name] = verifyClusterAttributes(name); + } catch (err) { + results[name] = { status: 'error', error: err.message }; + } + } + + // All clusters in spec should be complete + const failures = Object.entries(results).filter( + ([, r]) => r.status === 'fail' || r.status === 'error', + ); + assert.strictEqual( + failures.length, + 0, + `Incomplete clusters: ${failures.map(([n]) => n).join(', ')}`, + ); + }); + }); +}); diff --git a/test/iasZone.js b/test/iasZone.js index 48ed5f2..539aa0b 100644 --- a/test/iasZone.js +++ b/test/iasZone.js @@ -6,6 +6,7 @@ const BoundCluster = require('../lib/BoundCluster'); const IASZoneCluster = require('../lib/clusters/iasZone'); const Node = require('../lib/Node'); const { ZCLStandardHeader } = require('../lib/zclFrames'); +const { MOCK_DEVICES, verifyClusterAttributes } = require('./util'); const endpointId = 1; @@ -98,4 +99,51 @@ describe('IAS Zone', function() { // Feed frame to node node.handleFrame(endpointId, IASZoneCluster.ID, frame.toBuffer(), {}); }); + + describe('Cluster Completeness', function() { + it('should have all mandatory IAS Zone attributes', function() { + const result = verifyClusterAttributes('iasZone'); + assert.strictEqual(result.status, 'pass', `Missing: ${result.missing.join(', ')}`); + }); + + it('should report implemented attributes', function() { + const result = verifyClusterAttributes('iasZone'); + assert(result.implemented.includes('zoneState'), 'zoneState should be implemented'); + assert(result.implemented.includes('zoneType'), 'zoneType should be implemented'); + assert(result.implemented.includes('zoneStatus'), 'zoneStatus should be implemented'); + }); + }); + + describe('Mock Device Factory', function() { + it('should create a motion sensor with correct zone type', function() { + const sensor = MOCK_DEVICES.motionSensor(); + // Access bound cluster directly (not via ZCL readAttributes) + const boundCluster = sensor.endpoints[1].bindings.iasZone; + assert.strictEqual(boundCluster.zoneType, 0x000D, 'Should be motion sensor type'); + assert.strictEqual(boundCluster.zoneState, 1, 'Should be enrolled'); + }); + + it('should create a contact sensor with correct zone type', function() { + const sensor = MOCK_DEVICES.contactSensor(); + const boundCluster = sensor.endpoints[1].bindings.iasZone; + assert.strictEqual(boundCluster.zoneType, 0x0015, 'Should be contact switch type'); + assert.strictEqual(boundCluster.zoneState, 1, 'Should be enrolled'); + }); + + it('should allow attribute overrides', function() { + const sensor = MOCK_DEVICES.motionSensor({ + iasZone: { zoneStatus: 0x0001 }, // Alarm1 active + }); + const boundCluster = sensor.endpoints[1].bindings.iasZone; + assert.strictEqual(boundCluster.zoneStatus, 0x0001, 'Should have alarm1 bit set'); + }); + + it('should create temp/humidity sensor with measurement values', function() { + const sensor = MOCK_DEVICES.tempHumiditySensor(); + const tempCluster = sensor.endpoints[1].bindings.temperatureMeasurement; + const humCluster = sensor.endpoints[1].bindings.relativeHumidity; + assert.strictEqual(tempCluster.measuredValue, 2150, 'Should be 21.50°C raw'); + assert.strictEqual(humCluster.measuredValue, 6500, 'Should be 65.00% raw'); + }); + }); }); diff --git a/test/util/clusterSpec.js b/test/util/clusterSpec.js new file mode 100644 index 0000000..7e64b8b --- /dev/null +++ b/test/util/clusterSpec.js @@ -0,0 +1,280 @@ +'use strict'; + +const assert = require('assert'); +const Cluster = require('../../lib/Cluster'); + +/** + * ZCL Cluster specifications for completeness testing. + * Based on ZCL Specification r8 (07-5123-08). + */ +const ZCL_SPEC = { + /** + * IAS Zone Cluster (0x0500) - ZCL 8.2 + */ + iasZone: { + id: 0x0500, + attributes: { + // Mandatory + zoneState: { id: 0x0000, mandatory: true }, + zoneType: { id: 0x0001, mandatory: true }, + zoneStatus: { id: 0x0002, mandatory: true }, + // Optional + iasCIEAddress: { id: 0x0010, mandatory: false }, + zoneId: { id: 0x0011, mandatory: false }, + numberOfZoneSensitivityLevelsSupported: { id: 0x0012, mandatory: false }, + currentZoneSensitivityLevel: { id: 0x0013, mandatory: false }, + }, + commands: { + // Client-to-Server + zoneEnrollResponse: { id: 0x00, direction: 'clientToServer', mandatory: true }, + initiateNormalOperationMode: { id: 0x01, direction: 'clientToServer', mandatory: false }, + initiateTestMode: { id: 0x02, direction: 'clientToServer', mandatory: false }, + // Server-to-Client + zoneStatusChangeNotification: { id: 0x00, direction: 'serverToClient', mandatory: true }, + zoneEnrollRequest: { id: 0x01, direction: 'serverToClient', mandatory: true }, + }, + }, + + /** + * Metering Cluster (0x0702) - ZCL 10.4 + */ + metering: { + id: 0x0702, + attributes: { + // Reading Information Set (0x00 - 0x0F) + currentSummationDelivered: { id: 0x0000, mandatory: true }, + currentSummationReceived: { id: 0x0001, mandatory: false }, + currentMaxDemandDelivered: { id: 0x0002, mandatory: false }, + currentMaxDemandReceived: { id: 0x0003, mandatory: false }, + dftSummation: { id: 0x0004, mandatory: false }, + dailyFreezeTime: { id: 0x0005, mandatory: false }, + powerFactor: { id: 0x0006, mandatory: false }, + readingSnapShotTime: { id: 0x0007, mandatory: false }, + currentMaxDemandDeliveredTime: { id: 0x0008, mandatory: false }, + currentMaxDemandReceivedTime: { id: 0x0009, mandatory: false }, + // Meter Status (0x0200 - 0x02FF) + status: { id: 0x0200, mandatory: false }, + remainingBatteryLife: { id: 0x0201, mandatory: false }, + hoursInOperation: { id: 0x0202, mandatory: false }, + hoursInFault: { id: 0x0203, mandatory: false }, + // Formatting Set (0x0300 - 0x03FF) - Critical for value interpretation + unitOfMeasure: { id: 0x0300, mandatory: true }, + multiplier: { id: 0x0301, mandatory: false }, + divisor: { id: 0x0302, mandatory: false }, + summationFormatting: { id: 0x0303, mandatory: true }, + demandFormatting: { id: 0x0304, mandatory: false }, + historicalConsumptionFormatting: { id: 0x0305, mandatory: false }, + meteringDeviceType: { id: 0x0306, mandatory: true }, + siteId: { id: 0x0307, mandatory: false }, + meterSerialNumber: { id: 0x0308, mandatory: false }, + // Historical Consumption (0x0400 - 0x04FF) + instantaneousDemand: { id: 0x0400, mandatory: false }, + currentDayConsumptionDelivered: { id: 0x0401, mandatory: false }, + previousDayConsumptionDelivered: { id: 0x0403, mandatory: false }, + }, + commands: { + // Most commands are optional per ZCL spec + getProfile: { id: 0x00, direction: 'clientToServer', mandatory: false }, + requestMirror: { id: 0x01, direction: 'clientToServer', mandatory: false }, + getProfileResponse: { id: 0x00, direction: 'serverToClient', mandatory: false }, + }, + }, + + /** + * Temperature Measurement Cluster (0x0402) - ZCL 4.4 + */ + temperatureMeasurement: { + id: 0x0402, + attributes: { + measuredValue: { id: 0x0000, mandatory: true }, + minMeasuredValue: { id: 0x0001, mandatory: true }, + maxMeasuredValue: { id: 0x0002, mandatory: true }, + tolerance: { id: 0x0003, mandatory: false }, + }, + commands: {}, + }, + + /** + * Relative Humidity Cluster (0x0405) - ZCL 4.7 + */ + relativeHumidity: { + id: 0x0405, + attributes: { + measuredValue: { id: 0x0000, mandatory: true }, + minMeasuredValue: { id: 0x0001, mandatory: true }, + maxMeasuredValue: { id: 0x0002, mandatory: true }, + tolerance: { id: 0x0003, mandatory: false }, + }, + commands: {}, + }, + + /** + * Occupancy Sensing Cluster (0x0406) - ZCL 4.8 + */ + occupancySensing: { + id: 0x0406, + attributes: { + occupancy: { id: 0x0000, mandatory: true }, + occupancySensorType: { id: 0x0001, mandatory: true }, + occupancySensorTypeBitmap: { id: 0x0002, mandatory: true }, + // PIR Configuration + pirOccupiedToUnoccupiedDelay: { id: 0x0010, mandatory: false }, + pirUnoccupiedToOccupiedDelay: { id: 0x0011, mandatory: false }, + pirUnoccupiedToOccupiedThreshold: { id: 0x0012, mandatory: false }, + }, + commands: {}, + }, + + /** + * Power Configuration Cluster (0x0001) - ZCL 3.3 + */ + powerConfiguration: { + id: 0x0001, + attributes: { + // Mains Information + mainsVoltage: { id: 0x0000, mandatory: false }, + mainsFrequency: { id: 0x0001, mandatory: false }, + // Battery Information + batteryVoltage: { id: 0x0020, mandatory: false }, + batteryPercentageRemaining: { id: 0x0021, mandatory: false }, + // Battery Settings + batteryManufacturer: { id: 0x0030, mandatory: false }, + batterySize: { id: 0x0031, mandatory: false }, + batteryQuantity: { id: 0x0033, mandatory: false }, + batteryRatedVoltage: { id: 0x0034, mandatory: false }, + batteryAlarmMask: { id: 0x0035, mandatory: false }, + batteryVoltageMinThreshold: { id: 0x0036, mandatory: false }, + }, + commands: {}, + }, + + /** + * Thermostat Cluster (0x0201) - ZCL 6.3 + */ + thermostat: { + id: 0x0201, + attributes: { + // Thermostat Information + localTemperature: { id: 0x0000, mandatory: true }, + outdoorTemperature: { id: 0x0001, mandatory: false }, + occupancy: { id: 0x0002, mandatory: false }, + // Setpoint Limits + absMinHeatSetpointLimit: { id: 0x0003, mandatory: false }, + absMaxHeatSetpointLimit: { id: 0x0004, mandatory: false }, + absMinCoolSetpointLimit: { id: 0x0005, mandatory: false }, + absMaxCoolSetpointLimit: { id: 0x0006, mandatory: false }, + // Setpoints + occupiedCoolingSetpoint: { id: 0x0011, mandatory: false }, + occupiedHeatingSetpoint: { id: 0x0012, mandatory: false }, + unoccupiedCoolingSetpoint: { id: 0x0013, mandatory: false }, + unoccupiedHeatingSetpoint: { id: 0x0014, mandatory: false }, + // Limits + minHeatSetpointLimit: { id: 0x0015, mandatory: false }, + maxHeatSetpointLimit: { id: 0x0016, mandatory: false }, + minCoolSetpointLimit: { id: 0x0017, mandatory: false }, + maxCoolSetpointLimit: { id: 0x0018, mandatory: false }, + // Control Sequence + controlSequenceOfOperation: { id: 0x001B, mandatory: true }, + systemMode: { id: 0x001C, mandatory: true }, + }, + commands: { + setSetpoint: { id: 0x00, direction: 'clientToServer', mandatory: false }, + }, + }, + + /** + * Window Covering Cluster (0x0102) - ZCL 7.4 + */ + windowCovering: { + id: 0x0102, + attributes: { + windowCoveringType: { id: 0x0000, mandatory: true }, + currentPositionLiftPercentage: { id: 0x0008, mandatory: false }, + currentPositionTiltPercentage: { id: 0x0009, mandatory: false }, + configStatus: { id: 0x0007, mandatory: true }, + installedOpenLimitLift: { id: 0x0010, mandatory: false }, + installedClosedLimitLift: { id: 0x0011, mandatory: false }, + mode: { id: 0x0017, mandatory: true }, + }, + commands: { + upOpen: { id: 0x00, direction: 'clientToServer', mandatory: true }, + downClose: { id: 0x01, direction: 'clientToServer', mandatory: true }, + stop: { id: 0x02, direction: 'clientToServer', mandatory: true }, + goToLiftPercentage: { id: 0x05, direction: 'clientToServer', mandatory: false }, + goToTiltPercentage: { id: 0x08, direction: 'clientToServer', mandatory: false }, + }, + }, + + /** + * Door Lock Cluster (0x0101) - ZCL 7.3 + */ + doorLock: { + id: 0x0101, + attributes: { + lockState: { id: 0x0000, mandatory: true }, + lockType: { id: 0x0001, mandatory: true }, + actuatorEnabled: { id: 0x0002, mandatory: true }, + doorState: { id: 0x0003, mandatory: false }, + numberOfLogRecordsSupported: { id: 0x0010, mandatory: false }, + autoRelockTime: { id: 0x0023, mandatory: false }, + }, + commands: { + lockDoor: { id: 0x00, direction: 'clientToServer', mandatory: true }, + unlockDoor: { id: 0x01, direction: 'clientToServer', mandatory: true }, + lockDoorResponse: { id: 0x00, direction: 'serverToClient', mandatory: true }, + unlockDoorResponse: { id: 0x01, direction: 'serverToClient', mandatory: true }, + }, + }, +}; + +/** + * Verifies a cluster implementation has all mandatory attributes. + * + * @param {string} clusterName - Cluster name (e.g., 'iasZone') + * @returns {{ missing: string[], extra: string[], status: 'pass'|'fail' }} + */ +function verifyClusterAttributes(clusterName) { + const spec = ZCL_SPEC[clusterName]; + if (!spec) { + throw new Error(`Unknown cluster: ${clusterName}`); + } + + const ClusterClass = Cluster.getCluster(spec.id); + if (!ClusterClass) { + throw new Error(`Cluster not registered: ${clusterName} (0x${spec.id.toString(16)})`); + } + + const implementedAttrs = Object.keys(ClusterClass.ATTRIBUTES || {}); + const specAttrs = Object.keys(spec.attributes); + const mandatoryAttrs = specAttrs.filter(a => spec.attributes[a].mandatory); + + const missing = mandatoryAttrs.filter(a => !implementedAttrs.includes(a)); + const extra = implementedAttrs.filter(a => !specAttrs.includes(a) && !['clusterRevision', 'attributeReportingStatus'].includes(a)); + + return { + missing, + extra, + implemented: implementedAttrs, + status: missing.length === 0 ? 'pass' : 'fail', + }; +} + +/** + * Asserts a cluster has all mandatory attributes. + * + * @param {string} clusterName - Cluster name + */ +function assertClusterComplete(clusterName) { + const result = verifyClusterAttributes(clusterName); + assert.strictEqual( + result.status, + 'pass', + `Cluster ${clusterName} missing mandatory attributes: ${result.missing.join(', ')}`, + ); +} + +module.exports = { + ZCL_SPEC, + verifyClusterAttributes, + assertClusterComplete, +}; diff --git a/test/util/index.js b/test/util/index.js index 2331cc3..31c4e1b 100644 --- a/test/util/index.js +++ b/test/util/index.js @@ -5,6 +5,8 @@ let { debug } = require('../../lib/util'); debug = debug.extend('test'); const Node = require('../../lib/Node'); +const { createMockDevice, createBoundClusterWithAttributes, MOCK_DEVICES } = require('./mockDevice'); +const { ZCL_SPEC, verifyClusterAttributes, assertClusterComplete } = require('./clusterSpec'); const debugUtil = debug.extend('util'); @@ -20,4 +22,12 @@ const loopbackNode = config => { module.exports = { debug, loopbackNode, + // Mock device utilities + createMockDevice, + createBoundClusterWithAttributes, + MOCK_DEVICES, + // Cluster spec verification + ZCL_SPEC, + verifyClusterAttributes, + assertClusterComplete, }; diff --git a/test/util/mockDevice.js b/test/util/mockDevice.js new file mode 100644 index 0000000..f69eba1 --- /dev/null +++ b/test/util/mockDevice.js @@ -0,0 +1,243 @@ +'use strict'; + +const Node = require('../../lib/Node'); +const BoundCluster = require('../../lib/BoundCluster'); + +// Load all clusters so they can be bound +require('../../lib/clusters/basic'); +require('../../lib/clusters/powerConfiguration'); +require('../../lib/clusters/iasZone'); +require('../../lib/clusters/temperatureMeasurement'); +require('../../lib/clusters/relativeHumidity'); +require('../../lib/clusters/onOff'); +require('../../lib/clusters/metering'); +require('../../lib/clusters/electricalMeasurement'); + +/** + * Creates a BoundCluster with getter properties for each attribute. + * + * @param {Object} attributes - Attribute name/value pairs + * @returns {BoundCluster} Configured BoundCluster + */ +function createBoundClusterWithAttributes(attributes) { + const ClusterClass = class extends BoundCluster { + + constructor() { + super(); + // Store mutable attributes + this._attributes = { ...attributes }; + } + + }; + + // Define getters/setters for each attribute + Object.keys(attributes).forEach(attrName => { + Object.defineProperty(ClusterClass.prototype, attrName, { + get() { + return this._attributes[attrName]; + }, + set(value) { + this._attributes[attrName] = value; + }, + enumerable: true, + configurable: true, + }); + }); + + return new ClusterClass(); +} + +/** + * Creates a mock device Node with configurable cluster attributes. + * + * @example + * const mockMotionSensor = createMockDevice({ + * endpoints: [{ + * endpointId: 1, + * inputClusters: [0x0000, 0x0001, 0x0500], + * clusters: { + * iasZone: { + * zoneType: 0x000D, // Motion sensor + * zoneState: 1, // Enrolled + * zoneStatus: 0, + * }, + * powerConfiguration: { + * batteryPercentageRemaining: 180, // 90% + * }, + * }, + * }], + * }); + * + * @param {Object} config - Device configuration + * @param {Array} config.endpoints - Endpoint configurations + * @param {number} config.endpoints[].endpointId - Endpoint ID + * @param {number[]} config.endpoints[].inputClusters - Input cluster IDs + * @param {number[]} [config.endpoints[].outputClusters] - Output cluster IDs + * @param {Object} [config.endpoints[].clusters] - Cluster attribute values keyed by cluster name + * @returns {Node} Configured mock Node + */ +function createMockDevice(config) { + const endpointDescriptors = config.endpoints.map(ep => ({ + endpointId: ep.endpointId, + inputClusters: ep.inputClusters || [], + outputClusters: ep.outputClusters || [], + })); + + const mockNode = { + sendFrame: () => Promise.resolve(), // No-op by default + endpointDescriptors, + }; + + const node = new Node(mockNode); + + // Bind clusters with preset attribute values + config.endpoints.forEach(ep => { + if (!ep.clusters) return; + + Object.entries(ep.clusters).forEach(([clusterName, attributes]) => { + const boundCluster = createBoundClusterWithAttributes(attributes); + node.endpoints[ep.endpointId].bind(clusterName, boundCluster); + }); + }); + + return node; +} + +/** + * Preset device configurations for common device types. + */ +const MOCK_DEVICES = { + /** + * IAS Zone Motion Sensor + */ + motionSensor: (overrides = {}) => createMockDevice({ + endpoints: [{ + endpointId: 1, + inputClusters: [0x0000, 0x0001, 0x0500], + clusters: { + basic: { + zclVersion: 3, + manufacturerName: 'MockDevice', + modelId: 'MockMotionSensor', + ...overrides.basic, + }, + powerConfiguration: { + batteryPercentageRemaining: 200, // 100% + ...overrides.powerConfiguration, + }, + iasZone: { + zoneState: 1, // Enrolled + zoneType: 0x000D, // Motion sensor + zoneStatus: 0, + iasCIEAddress: '0x0000000000000000', + zoneId: 1, + ...overrides.iasZone, + }, + }, + }], + }), + + /** + * IAS Zone Contact Sensor (door/window) + */ + contactSensor: (overrides = {}) => createMockDevice({ + endpoints: [{ + endpointId: 1, + inputClusters: [0x0000, 0x0001, 0x0500], + clusters: { + basic: { + zclVersion: 3, + manufacturerName: 'MockDevice', + modelId: 'MockContactSensor', + ...overrides.basic, + }, + powerConfiguration: { + batteryPercentageRemaining: 200, + ...overrides.powerConfiguration, + }, + iasZone: { + zoneState: 1, + zoneType: 0x0015, // Contact switch + zoneStatus: 0, + iasCIEAddress: '0x0000000000000000', + zoneId: 1, + ...overrides.iasZone, + }, + }, + }], + }), + + /** + * Temperature + Humidity Sensor + */ + tempHumiditySensor: (overrides = {}) => createMockDevice({ + endpoints: [{ + endpointId: 1, + inputClusters: [0x0000, 0x0001, 0x0402, 0x0405], + clusters: { + basic: { + zclVersion: 3, + manufacturerName: 'MockDevice', + modelId: 'MockTempHumidity', + ...overrides.basic, + }, + powerConfiguration: { + batteryPercentageRemaining: 200, + ...overrides.powerConfiguration, + }, + temperatureMeasurement: { + measuredValue: 2150, // 21.50°C + minMeasuredValue: -4000, + maxMeasuredValue: 8500, + ...overrides.temperatureMeasurement, + }, + relativeHumidity: { + measuredValue: 6500, // 65.00% + minMeasuredValue: 0, + maxMeasuredValue: 10000, + ...overrides.relativeHumidity, + }, + }, + }], + }), + + /** + * Smart Plug with Power Metering + */ + smartPlug: (overrides = {}) => createMockDevice({ + endpoints: [{ + endpointId: 1, + inputClusters: [0x0000, 0x0006, 0x0702, 0x0B04], + clusters: { + basic: { + zclVersion: 3, + manufacturerName: 'MockDevice', + modelId: 'MockSmartPlug', + ...overrides.basic, + }, + onOff: { + onOff: false, + ...overrides.onOff, + }, + metering: { + currentSummationDelivered: 12345678, + multiplier: 1, + divisor: 1000, + ...overrides.metering, + }, + electricalMeasurement: { + activePower: 1500, // 150.0W + rmsVoltage: 2300, // 230.0V + rmsCurrent: 652, // 0.652A + ...overrides.electricalMeasurement, + }, + }, + }], + }), +}; + +module.exports = { + createMockDevice, + createBoundClusterWithAttributes, + MOCK_DEVICES, +}; From 0277fd96dc9c5cfa6ad27f8aafb6b4d5dce7dcf2 Mon Sep 17 00:00:00 2001 From: Robin Bolscher Date: Wed, 4 Feb 2026 16:36:41 +0100 Subject: [PATCH 2/4] chore(doorLock): update apsLinkKey to apsSecurity in attributes --- lib/clusters/doorLock.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/clusters/doorLock.js b/lib/clusters/doorLock.js index badc905..2d03206 100644 --- a/lib/clusters/doorLock.js +++ b/lib/clusters/doorLock.js @@ -112,7 +112,7 @@ const ATTRIBUTES = { id: 0x0034, type: ZCLDataTypes.enum8({ network: 0, - apsLinkKey: 1, + apsSecurity: 1, }), }, From 3e79d14714337bc5b368efd73f1dbdefe50c2ff6 Mon Sep 17 00:00:00 2001 From: Robin Bolscher Date: Wed, 4 Feb 2026 16:55:24 +0100 Subject: [PATCH 3/4] feat(tests): add new tests for Color Control, Door Lock, Level Control, and On/Off clusters - Introduced tests for Color Control cluster commands: moveToColor, moveToColorTemperature, and moveToHueAndSaturation. - Added tests for Door Lock cluster commands: lockDoor, unlockDoor, and setPINCode. - Implemented tests for Level Control cluster commands: moveToLevel, step, and stop. - Created tests for On/Off cluster commands: setOn, setOff, toggle, and onWithTimedOff. - Removed outdated cluster completeness tests from IAS Zone and clusterSpec.js. --- test/clusterCompleteness.js | 189 ------------------------ test/colorControl.js | 110 ++++++++++++++ test/doorLock.js | 111 ++++++++++++++ test/iasZone.js | 16 +-- test/levelControl.js | 106 ++++++++++++++ test/onOff.js | 132 +++++++++++++++++ test/util/clusterSpec.js | 280 ------------------------------------ test/util/index.js | 5 - 8 files changed, 460 insertions(+), 489 deletions(-) delete mode 100644 test/clusterCompleteness.js create mode 100644 test/colorControl.js create mode 100644 test/doorLock.js create mode 100644 test/levelControl.js create mode 100644 test/onOff.js delete mode 100644 test/util/clusterSpec.js diff --git a/test/clusterCompleteness.js b/test/clusterCompleteness.js deleted file mode 100644 index 4f4650b..0000000 --- a/test/clusterCompleteness.js +++ /dev/null @@ -1,189 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const { ZCL_SPEC, verifyClusterAttributes } = require('./util'); - -// Load all clusters so they can be verified -require('../lib/clusters/metering'); -require('../lib/clusters/thermostat'); -require('../lib/clusters/windowCovering'); -require('../lib/clusters/doorLock'); -require('../lib/clusters/temperatureMeasurement'); -require('../lib/clusters/relativeHumidity'); -require('../lib/clusters/occupancySensing'); -require('../lib/clusters/powerConfiguration'); -require('../lib/clusters/iasZone'); - -describe('Cluster Completeness Tests', function() { - describe('Metering Cluster (0x0702)', function() { - it('should have all mandatory attributes', function() { - const result = verifyClusterAttributes('metering'); - assert.strictEqual( - result.status, - 'pass', - `Missing mandatory attributes: ${result.missing.join(', ')}`, - ); - }); - - it('should have critical formatting attributes', function() { - const result = verifyClusterAttributes('metering'); - const criticalAttrs = ['unitOfMeasure', 'summationFormatting', 'meteringDeviceType']; - for (const attr of criticalAttrs) { - assert( - result.implemented.includes(attr), - `Critical attribute ${attr} should be implemented`, - ); - } - }); - }); - - describe('Thermostat Cluster (0x0201)', function() { - it('should have all mandatory attributes', function() { - const result = verifyClusterAttributes('thermostat'); - assert.strictEqual( - result.status, - 'pass', - `Missing mandatory attributes: ${result.missing.join(', ')}`, - ); - }); - - it('should have setpoint attributes', function() { - const result = verifyClusterAttributes('thermostat'); - const setpointAttrs = ['occupiedCoolingSetpoint', 'occupiedHeatingSetpoint']; - for (const attr of setpointAttrs) { - assert( - result.implemented.includes(attr), - `Setpoint attribute ${attr} should be implemented`, - ); - } - }); - }); - - describe('Window Covering Cluster (0x0102)', function() { - it('should have all mandatory attributes', function() { - const result = verifyClusterAttributes('windowCovering'); - assert.strictEqual( - result.status, - 'pass', - `Missing mandatory attributes: ${result.missing.join(', ')}`, - ); - }); - - it('should have position percentage attributes', function() { - const result = verifyClusterAttributes('windowCovering'); - const positionAttrs = ['currentPositionLiftPercentage', 'currentPositionTiltPercentage']; - for (const attr of positionAttrs) { - assert( - result.implemented.includes(attr), - `Position attribute ${attr} should be implemented`, - ); - } - }); - }); - - describe('Door Lock Cluster (0x0101)', function() { - it('should have all mandatory attributes', function() { - const result = verifyClusterAttributes('doorLock'); - assert.strictEqual( - result.status, - 'pass', - `Missing mandatory attributes: ${result.missing.join(', ')}`, - ); - }); - - it('should have lock state and type attributes', function() { - const result = verifyClusterAttributes('doorLock'); - const lockAttrs = ['lockState', 'lockType', 'actuatorEnabled']; - for (const attr of lockAttrs) { - assert( - result.implemented.includes(attr), - `Lock attribute ${attr} should be implemented`, - ); - } - }); - }); - - describe('Temperature Measurement Cluster (0x0402)', function() { - it('should have all mandatory attributes', function() { - const result = verifyClusterAttributes('temperatureMeasurement'); - assert.strictEqual( - result.status, - 'pass', - `Missing mandatory attributes: ${result.missing.join(', ')}`, - ); - }); - }); - - describe('Relative Humidity Cluster (0x0405)', function() { - it('should have all mandatory attributes', function() { - const result = verifyClusterAttributes('relativeHumidity'); - assert.strictEqual( - result.status, - 'pass', - `Missing mandatory attributes: ${result.missing.join(', ')}`, - ); - }); - }); - - describe('Occupancy Sensing Cluster (0x0406)', function() { - it('should have all mandatory attributes', function() { - const result = verifyClusterAttributes('occupancySensing'); - assert.strictEqual( - result.status, - 'pass', - `Missing mandatory attributes: ${result.missing.join(', ')}`, - ); - }); - }); - - describe('Power Configuration Cluster (0x0001)', function() { - it('should have battery attributes', function() { - const result = verifyClusterAttributes('powerConfiguration'); - // Power Configuration has no mandatory attributes per ZCL spec - // But we want to ensure battery-related attributes are present - const batteryAttrs = ['batteryVoltage', 'batteryPercentageRemaining']; - for (const attr of batteryAttrs) { - assert( - result.implemented.includes(attr), - `Battery attribute ${attr} should be implemented`, - ); - } - }); - }); - - describe('IAS Zone Cluster (0x0500)', function() { - it('should have all mandatory attributes', function() { - const result = verifyClusterAttributes('iasZone'); - assert.strictEqual( - result.status, - 'pass', - `Missing mandatory attributes: ${result.missing.join(', ')}`, - ); - }); - }); - - describe('All Spec Clusters Summary', function() { - it('should report completeness for all clusters in ZCL_SPEC', function() { - const results = {}; - const clusterNames = Object.keys(ZCL_SPEC); - - for (const name of clusterNames) { - try { - results[name] = verifyClusterAttributes(name); - } catch (err) { - results[name] = { status: 'error', error: err.message }; - } - } - - // All clusters in spec should be complete - const failures = Object.entries(results).filter( - ([, r]) => r.status === 'fail' || r.status === 'error', - ); - assert.strictEqual( - failures.length, - 0, - `Incomplete clusters: ${failures.map(([n]) => n).join(', ')}`, - ); - }); - }); -}); diff --git a/test/colorControl.js b/test/colorControl.js new file mode 100644 index 0000000..f007768 --- /dev/null +++ b/test/colorControl.js @@ -0,0 +1,110 @@ +// eslint-disable-next-line max-classes-per-file,lines-around-directive +'use strict'; + +const assert = require('assert'); +const BoundCluster = require('../lib/BoundCluster'); +const ColorControlCluster = require('../lib/clusters/colorControl'); +const Node = require('../lib/Node'); +const { ZCLStandardHeader } = require('../lib/zclFrames'); + +const endpointId = 1; + +describe('Color Control', function() { + it('should receive moveToColor', function(done) { + const node = new Node({ + sendFrame: () => null, + endpointDescriptors: [{ + endpointId, + inputClusters: [ColorControlCluster.ID], + }], + }); + + node.endpoints[endpointId].bind( + ColorControlCluster.NAME, + new (class extends BoundCluster { + + async moveToColor(data) { + assert.strictEqual(data.colorX, 0x5000); + assert.strictEqual(data.colorY, 0x3000); + assert.strictEqual(data.transitionTime, 20); + done(); + } + + })(), + ); + + const frame = new ZCLStandardHeader(); + frame.cmdId = ColorControlCluster.COMMANDS.moveToColor.id; + frame.frameControl.directionToClient = false; + frame.frameControl.clusterSpecific = true; + // colorX (uint16 LE): 0x5000, colorY (uint16 LE): 0x3000, transitionTime (uint16 LE): 20 + frame.data = Buffer.from([0x00, 0x50, 0x00, 0x30, 0x14, 0x00]); + + node.handleFrame(endpointId, ColorControlCluster.ID, frame.toBuffer(), {}); + }); + + it('should receive moveToColorTemperature', function(done) { + const node = new Node({ + sendFrame: () => null, + endpointDescriptors: [{ + endpointId, + inputClusters: [ColorControlCluster.ID], + }], + }); + + node.endpoints[endpointId].bind( + ColorControlCluster.NAME, + new (class extends BoundCluster { + + async moveToColorTemperature(data) { + assert.strictEqual(data.colorTemperature, 370); + assert.strictEqual(data.transitionTime, 15); + done(); + } + + })(), + ); + + const frame = new ZCLStandardHeader(); + frame.cmdId = ColorControlCluster.COMMANDS.moveToColorTemperature.id; + frame.frameControl.directionToClient = false; + frame.frameControl.clusterSpecific = true; + // colorTemperature (uint16 LE): 370 = 0x0172, transitionTime (uint16 LE): 15 = 0x000F + frame.data = Buffer.from([0x72, 0x01, 0x0F, 0x00]); + + node.handleFrame(endpointId, ColorControlCluster.ID, frame.toBuffer(), {}); + }); + + it('should receive moveToHueAndSaturation', function(done) { + const node = new Node({ + sendFrame: () => null, + endpointDescriptors: [{ + endpointId, + inputClusters: [ColorControlCluster.ID], + }], + }); + + node.endpoints[endpointId].bind( + ColorControlCluster.NAME, + new (class extends BoundCluster { + + async moveToHueAndSaturation(data) { + assert.strictEqual(data.hue, 180); + assert.strictEqual(data.saturation, 200); + assert.strictEqual(data.transitionTime, 10); + done(); + } + + })(), + ); + + const frame = new ZCLStandardHeader(); + frame.cmdId = ColorControlCluster.COMMANDS.moveToHueAndSaturation.id; + frame.frameControl.directionToClient = false; + frame.frameControl.clusterSpecific = true; + // hue (uint8): 180, saturation (uint8): 200, transitionTime (uint16 LE): 10 + frame.data = Buffer.from([0xB4, 0xC8, 0x0A, 0x00]); + + node.handleFrame(endpointId, ColorControlCluster.ID, frame.toBuffer(), {}); + }); +}); diff --git a/test/doorLock.js b/test/doorLock.js new file mode 100644 index 0000000..1d6487f --- /dev/null +++ b/test/doorLock.js @@ -0,0 +1,111 @@ +// eslint-disable-next-line max-classes-per-file,lines-around-directive +'use strict'; + +const assert = require('assert'); +const BoundCluster = require('../lib/BoundCluster'); +const DoorLockCluster = require('../lib/clusters/doorLock'); +const Node = require('../lib/Node'); +const { ZCLStandardHeader } = require('../lib/zclFrames'); + +const endpointId = 1; + +describe('Door Lock', function() { + it('should receive lockDoor', function(done) { + const node = new Node({ + sendFrame: () => null, + endpointDescriptors: [{ + endpointId, + inputClusters: [DoorLockCluster.ID], + }], + }); + + node.endpoints[endpointId].bind( + DoorLockCluster.NAME, + new (class extends BoundCluster { + + async lockDoor(data) { + assert.deepStrictEqual(data.pinCode, Buffer.from([0x31, 0x32, 0x33, 0x34])); + done(); + } + + })(), + ); + + const frame = new ZCLStandardHeader(); + frame.cmdId = DoorLockCluster.COMMANDS.lockDoor.id; + frame.frameControl.directionToClient = false; + frame.frameControl.clusterSpecific = true; + // pinCode (octstr): length 4, data "1234" = 0x31 0x32 0x33 0x34 + frame.data = Buffer.from([0x04, 0x31, 0x32, 0x33, 0x34]); + + node.handleFrame(endpointId, DoorLockCluster.ID, frame.toBuffer(), {}); + }); + + it('should receive unlockDoor', function(done) { + const node = new Node({ + sendFrame: () => null, + endpointDescriptors: [{ + endpointId, + inputClusters: [DoorLockCluster.ID], + }], + }); + + node.endpoints[endpointId].bind( + DoorLockCluster.NAME, + new (class extends BoundCluster { + + async unlockDoor(data) { + assert.deepStrictEqual(data.pinCode, Buffer.from([0x30, 0x30, 0x30, 0x30])); + done(); + } + + })(), + ); + + const frame = new ZCLStandardHeader(); + frame.cmdId = DoorLockCluster.COMMANDS.unlockDoor.id; + frame.frameControl.directionToClient = false; + frame.frameControl.clusterSpecific = true; + // pinCode (octstr): length 4, data "0000" = 0x30 0x30 0x30 0x30 + frame.data = Buffer.from([0x04, 0x30, 0x30, 0x30, 0x30]); + + node.handleFrame(endpointId, DoorLockCluster.ID, frame.toBuffer(), {}); + }); + + it('should receive setPINCode', function(done) { + const node = new Node({ + sendFrame: () => null, + endpointDescriptors: [{ + endpointId, + inputClusters: [DoorLockCluster.ID], + }], + }); + + node.endpoints[endpointId].bind( + DoorLockCluster.NAME, + new (class extends BoundCluster { + + async setPINCode(data) { + assert.strictEqual(data.userId, 1); + assert.strictEqual(data.userStatus, 'occupiedEnabled'); + assert.strictEqual(data.userType, 'unrestricted'); + assert.deepStrictEqual(data.pinCode, Buffer.from([0x35, 0x36, 0x37, 0x38])); + done(); + } + + })(), + ); + + const frame = new ZCLStandardHeader(); + frame.cmdId = DoorLockCluster.COMMANDS.setPINCode.id; + frame.frameControl.directionToClient = false; + frame.frameControl.clusterSpecific = true; + // userId (uint16 LE): 1 = 0x01 0x00 + // userStatus (enum8): occupiedEnabled = 1 + // userType (enum8): unrestricted = 0 + // pinCode (octstr): length 4, data "5678" = 0x35 0x36 0x37 0x38 + frame.data = Buffer.from([0x01, 0x00, 0x01, 0x00, 0x04, 0x35, 0x36, 0x37, 0x38]); + + node.handleFrame(endpointId, DoorLockCluster.ID, frame.toBuffer(), {}); + }); +}); diff --git a/test/iasZone.js b/test/iasZone.js index 539aa0b..d6752f8 100644 --- a/test/iasZone.js +++ b/test/iasZone.js @@ -6,7 +6,7 @@ const BoundCluster = require('../lib/BoundCluster'); const IASZoneCluster = require('../lib/clusters/iasZone'); const Node = require('../lib/Node'); const { ZCLStandardHeader } = require('../lib/zclFrames'); -const { MOCK_DEVICES, verifyClusterAttributes } = require('./util'); +const { MOCK_DEVICES } = require('./util'); const endpointId = 1; @@ -100,20 +100,6 @@ describe('IAS Zone', function() { node.handleFrame(endpointId, IASZoneCluster.ID, frame.toBuffer(), {}); }); - describe('Cluster Completeness', function() { - it('should have all mandatory IAS Zone attributes', function() { - const result = verifyClusterAttributes('iasZone'); - assert.strictEqual(result.status, 'pass', `Missing: ${result.missing.join(', ')}`); - }); - - it('should report implemented attributes', function() { - const result = verifyClusterAttributes('iasZone'); - assert(result.implemented.includes('zoneState'), 'zoneState should be implemented'); - assert(result.implemented.includes('zoneType'), 'zoneType should be implemented'); - assert(result.implemented.includes('zoneStatus'), 'zoneStatus should be implemented'); - }); - }); - describe('Mock Device Factory', function() { it('should create a motion sensor with correct zone type', function() { const sensor = MOCK_DEVICES.motionSensor(); diff --git a/test/levelControl.js b/test/levelControl.js new file mode 100644 index 0000000..a8c9cab --- /dev/null +++ b/test/levelControl.js @@ -0,0 +1,106 @@ +// eslint-disable-next-line max-classes-per-file,lines-around-directive +'use strict'; + +const assert = require('assert'); +const BoundCluster = require('../lib/BoundCluster'); +const LevelControlCluster = require('../lib/clusters/levelControl'); +const Node = require('../lib/Node'); +const { ZCLStandardHeader } = require('../lib/zclFrames'); + +const endpointId = 1; + +describe('Level Control', function() { + it('should receive moveToLevel', function(done) { + const node = new Node({ + sendFrame: () => null, + endpointDescriptors: [{ + endpointId, + inputClusters: [LevelControlCluster.ID], + }], + }); + + node.endpoints[endpointId].bind( + LevelControlCluster.NAME, + new (class extends BoundCluster { + + async moveToLevel(data) { + assert.strictEqual(data.level, 128); + assert.strictEqual(data.transitionTime, 10); + done(); + } + + })(), + ); + + const frame = new ZCLStandardHeader(); + frame.cmdId = LevelControlCluster.COMMANDS.moveToLevel.id; + frame.frameControl.directionToClient = false; + frame.frameControl.clusterSpecific = true; + // level (uint8): 128 = 0x80, transitionTime (uint16 LE): 10 = 0x0A 0x00 + frame.data = Buffer.from([0x80, 0x0A, 0x00]); + + node.handleFrame(endpointId, LevelControlCluster.ID, frame.toBuffer(), {}); + }); + + it('should receive step', function(done) { + const node = new Node({ + sendFrame: () => null, + endpointDescriptors: [{ + endpointId, + inputClusters: [LevelControlCluster.ID], + }], + }); + + node.endpoints[endpointId].bind( + LevelControlCluster.NAME, + new (class extends BoundCluster { + + async step(data) { + assert.strictEqual(data.mode, 'up'); + assert.strictEqual(data.stepSize, 50); + assert.strictEqual(data.transitionTime, 5); + done(); + } + + })(), + ); + + const frame = new ZCLStandardHeader(); + frame.cmdId = LevelControlCluster.COMMANDS.step.id; + frame.frameControl.directionToClient = false; + frame.frameControl.clusterSpecific = true; + // mode (enum8): up = 0, stepSize (uint8): 50 = 0x32, transitionTime (uint16 LE): 5 = 0x05 0x00 + frame.data = Buffer.from([0x00, 0x32, 0x05, 0x00]); + + node.handleFrame(endpointId, LevelControlCluster.ID, frame.toBuffer(), {}); + }); + + it('should receive stop', function(done) { + const node = new Node({ + sendFrame: () => null, + endpointDescriptors: [{ + endpointId, + inputClusters: [LevelControlCluster.ID], + }], + }); + + node.endpoints[endpointId].bind( + LevelControlCluster.NAME, + new (class extends BoundCluster { + + async stop() { + done(); + } + + })(), + ); + + const frame = new ZCLStandardHeader(); + frame.cmdId = LevelControlCluster.COMMANDS.stop.id; + frame.frameControl.directionToClient = false; + frame.frameControl.clusterSpecific = true; + frame.data = Buffer.alloc(0); + + node.handleFrame(endpointId, LevelControlCluster.ID, frame.toBuffer(), {}); + }); +}); diff --git a/test/onOff.js b/test/onOff.js new file mode 100644 index 0000000..48d055c --- /dev/null +++ b/test/onOff.js @@ -0,0 +1,132 @@ +// eslint-disable-next-line max-classes-per-file,lines-around-directive +'use strict'; + +const assert = require('assert'); +const BoundCluster = require('../lib/BoundCluster'); +const OnOffCluster = require('../lib/clusters/onOff'); +const Node = require('../lib/Node'); +const { ZCLStandardHeader } = require('../lib/zclFrames'); + +const endpointId = 1; + +describe('On/Off', function() { + it('should receive setOn', function(done) { + const node = new Node({ + sendFrame: () => null, + endpointDescriptors: [{ + endpointId, + inputClusters: [OnOffCluster.ID], + }], + }); + + node.endpoints[endpointId].bind( + OnOffCluster.NAME, + new (class extends BoundCluster { + + async setOn() { + done(); + } + + })(), + ); + + const frame = new ZCLStandardHeader(); + frame.cmdId = OnOffCluster.COMMANDS.setOn.id; + frame.frameControl.directionToClient = false; + frame.frameControl.clusterSpecific = true; + frame.data = Buffer.alloc(0); + + node.handleFrame(endpointId, OnOffCluster.ID, frame.toBuffer(), {}); + }); + + it('should receive setOff', function(done) { + const node = new Node({ + sendFrame: () => null, + endpointDescriptors: [{ + endpointId, + inputClusters: [OnOffCluster.ID], + }], + }); + + node.endpoints[endpointId].bind( + OnOffCluster.NAME, + new (class extends BoundCluster { + + async setOff() { + done(); + } + + })(), + ); + + const frame = new ZCLStandardHeader(); + frame.cmdId = OnOffCluster.COMMANDS.setOff.id; + frame.frameControl.directionToClient = false; + frame.frameControl.clusterSpecific = true; + frame.data = Buffer.alloc(0); + + node.handleFrame(endpointId, OnOffCluster.ID, frame.toBuffer(), {}); + }); + + it('should receive toggle', function(done) { + const node = new Node({ + sendFrame: () => null, + endpointDescriptors: [{ + endpointId, + inputClusters: [OnOffCluster.ID], + }], + }); + + node.endpoints[endpointId].bind( + OnOffCluster.NAME, + new (class extends BoundCluster { + + async toggle() { + done(); + } + + })(), + ); + + const frame = new ZCLStandardHeader(); + frame.cmdId = OnOffCluster.COMMANDS.toggle.id; + frame.frameControl.directionToClient = false; + frame.frameControl.clusterSpecific = true; + frame.data = Buffer.alloc(0); + + node.handleFrame(endpointId, OnOffCluster.ID, frame.toBuffer(), {}); + }); + + it('should receive onWithTimedOff', function(done) { + const node = new Node({ + sendFrame: () => null, + endpointDescriptors: [{ + endpointId, + inputClusters: [OnOffCluster.ID], + }], + }); + + node.endpoints[endpointId].bind( + OnOffCluster.NAME, + new (class extends BoundCluster { + + async onWithTimedOff(data) { + assert.strictEqual(data.onOffControl, 0x01); + assert.strictEqual(data.onTime, 100); + assert.strictEqual(data.offWaitTime, 50); + done(); + } + + })(), + ); + + const frame = new ZCLStandardHeader(); + frame.cmdId = OnOffCluster.COMMANDS.onWithTimedOff.id; + frame.frameControl.directionToClient = false; + frame.frameControl.clusterSpecific = true; + // onOffControl: 0x01, onTime (uint16 LE): 100, offWaitTime (uint16 LE): 50 + frame.data = Buffer.from([0x01, 0x64, 0x00, 0x32, 0x00]); + + node.handleFrame(endpointId, OnOffCluster.ID, frame.toBuffer(), {}); + }); +}); diff --git a/test/util/clusterSpec.js b/test/util/clusterSpec.js deleted file mode 100644 index 7e64b8b..0000000 --- a/test/util/clusterSpec.js +++ /dev/null @@ -1,280 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const Cluster = require('../../lib/Cluster'); - -/** - * ZCL Cluster specifications for completeness testing. - * Based on ZCL Specification r8 (07-5123-08). - */ -const ZCL_SPEC = { - /** - * IAS Zone Cluster (0x0500) - ZCL 8.2 - */ - iasZone: { - id: 0x0500, - attributes: { - // Mandatory - zoneState: { id: 0x0000, mandatory: true }, - zoneType: { id: 0x0001, mandatory: true }, - zoneStatus: { id: 0x0002, mandatory: true }, - // Optional - iasCIEAddress: { id: 0x0010, mandatory: false }, - zoneId: { id: 0x0011, mandatory: false }, - numberOfZoneSensitivityLevelsSupported: { id: 0x0012, mandatory: false }, - currentZoneSensitivityLevel: { id: 0x0013, mandatory: false }, - }, - commands: { - // Client-to-Server - zoneEnrollResponse: { id: 0x00, direction: 'clientToServer', mandatory: true }, - initiateNormalOperationMode: { id: 0x01, direction: 'clientToServer', mandatory: false }, - initiateTestMode: { id: 0x02, direction: 'clientToServer', mandatory: false }, - // Server-to-Client - zoneStatusChangeNotification: { id: 0x00, direction: 'serverToClient', mandatory: true }, - zoneEnrollRequest: { id: 0x01, direction: 'serverToClient', mandatory: true }, - }, - }, - - /** - * Metering Cluster (0x0702) - ZCL 10.4 - */ - metering: { - id: 0x0702, - attributes: { - // Reading Information Set (0x00 - 0x0F) - currentSummationDelivered: { id: 0x0000, mandatory: true }, - currentSummationReceived: { id: 0x0001, mandatory: false }, - currentMaxDemandDelivered: { id: 0x0002, mandatory: false }, - currentMaxDemandReceived: { id: 0x0003, mandatory: false }, - dftSummation: { id: 0x0004, mandatory: false }, - dailyFreezeTime: { id: 0x0005, mandatory: false }, - powerFactor: { id: 0x0006, mandatory: false }, - readingSnapShotTime: { id: 0x0007, mandatory: false }, - currentMaxDemandDeliveredTime: { id: 0x0008, mandatory: false }, - currentMaxDemandReceivedTime: { id: 0x0009, mandatory: false }, - // Meter Status (0x0200 - 0x02FF) - status: { id: 0x0200, mandatory: false }, - remainingBatteryLife: { id: 0x0201, mandatory: false }, - hoursInOperation: { id: 0x0202, mandatory: false }, - hoursInFault: { id: 0x0203, mandatory: false }, - // Formatting Set (0x0300 - 0x03FF) - Critical for value interpretation - unitOfMeasure: { id: 0x0300, mandatory: true }, - multiplier: { id: 0x0301, mandatory: false }, - divisor: { id: 0x0302, mandatory: false }, - summationFormatting: { id: 0x0303, mandatory: true }, - demandFormatting: { id: 0x0304, mandatory: false }, - historicalConsumptionFormatting: { id: 0x0305, mandatory: false }, - meteringDeviceType: { id: 0x0306, mandatory: true }, - siteId: { id: 0x0307, mandatory: false }, - meterSerialNumber: { id: 0x0308, mandatory: false }, - // Historical Consumption (0x0400 - 0x04FF) - instantaneousDemand: { id: 0x0400, mandatory: false }, - currentDayConsumptionDelivered: { id: 0x0401, mandatory: false }, - previousDayConsumptionDelivered: { id: 0x0403, mandatory: false }, - }, - commands: { - // Most commands are optional per ZCL spec - getProfile: { id: 0x00, direction: 'clientToServer', mandatory: false }, - requestMirror: { id: 0x01, direction: 'clientToServer', mandatory: false }, - getProfileResponse: { id: 0x00, direction: 'serverToClient', mandatory: false }, - }, - }, - - /** - * Temperature Measurement Cluster (0x0402) - ZCL 4.4 - */ - temperatureMeasurement: { - id: 0x0402, - attributes: { - measuredValue: { id: 0x0000, mandatory: true }, - minMeasuredValue: { id: 0x0001, mandatory: true }, - maxMeasuredValue: { id: 0x0002, mandatory: true }, - tolerance: { id: 0x0003, mandatory: false }, - }, - commands: {}, - }, - - /** - * Relative Humidity Cluster (0x0405) - ZCL 4.7 - */ - relativeHumidity: { - id: 0x0405, - attributes: { - measuredValue: { id: 0x0000, mandatory: true }, - minMeasuredValue: { id: 0x0001, mandatory: true }, - maxMeasuredValue: { id: 0x0002, mandatory: true }, - tolerance: { id: 0x0003, mandatory: false }, - }, - commands: {}, - }, - - /** - * Occupancy Sensing Cluster (0x0406) - ZCL 4.8 - */ - occupancySensing: { - id: 0x0406, - attributes: { - occupancy: { id: 0x0000, mandatory: true }, - occupancySensorType: { id: 0x0001, mandatory: true }, - occupancySensorTypeBitmap: { id: 0x0002, mandatory: true }, - // PIR Configuration - pirOccupiedToUnoccupiedDelay: { id: 0x0010, mandatory: false }, - pirUnoccupiedToOccupiedDelay: { id: 0x0011, mandatory: false }, - pirUnoccupiedToOccupiedThreshold: { id: 0x0012, mandatory: false }, - }, - commands: {}, - }, - - /** - * Power Configuration Cluster (0x0001) - ZCL 3.3 - */ - powerConfiguration: { - id: 0x0001, - attributes: { - // Mains Information - mainsVoltage: { id: 0x0000, mandatory: false }, - mainsFrequency: { id: 0x0001, mandatory: false }, - // Battery Information - batteryVoltage: { id: 0x0020, mandatory: false }, - batteryPercentageRemaining: { id: 0x0021, mandatory: false }, - // Battery Settings - batteryManufacturer: { id: 0x0030, mandatory: false }, - batterySize: { id: 0x0031, mandatory: false }, - batteryQuantity: { id: 0x0033, mandatory: false }, - batteryRatedVoltage: { id: 0x0034, mandatory: false }, - batteryAlarmMask: { id: 0x0035, mandatory: false }, - batteryVoltageMinThreshold: { id: 0x0036, mandatory: false }, - }, - commands: {}, - }, - - /** - * Thermostat Cluster (0x0201) - ZCL 6.3 - */ - thermostat: { - id: 0x0201, - attributes: { - // Thermostat Information - localTemperature: { id: 0x0000, mandatory: true }, - outdoorTemperature: { id: 0x0001, mandatory: false }, - occupancy: { id: 0x0002, mandatory: false }, - // Setpoint Limits - absMinHeatSetpointLimit: { id: 0x0003, mandatory: false }, - absMaxHeatSetpointLimit: { id: 0x0004, mandatory: false }, - absMinCoolSetpointLimit: { id: 0x0005, mandatory: false }, - absMaxCoolSetpointLimit: { id: 0x0006, mandatory: false }, - // Setpoints - occupiedCoolingSetpoint: { id: 0x0011, mandatory: false }, - occupiedHeatingSetpoint: { id: 0x0012, mandatory: false }, - unoccupiedCoolingSetpoint: { id: 0x0013, mandatory: false }, - unoccupiedHeatingSetpoint: { id: 0x0014, mandatory: false }, - // Limits - minHeatSetpointLimit: { id: 0x0015, mandatory: false }, - maxHeatSetpointLimit: { id: 0x0016, mandatory: false }, - minCoolSetpointLimit: { id: 0x0017, mandatory: false }, - maxCoolSetpointLimit: { id: 0x0018, mandatory: false }, - // Control Sequence - controlSequenceOfOperation: { id: 0x001B, mandatory: true }, - systemMode: { id: 0x001C, mandatory: true }, - }, - commands: { - setSetpoint: { id: 0x00, direction: 'clientToServer', mandatory: false }, - }, - }, - - /** - * Window Covering Cluster (0x0102) - ZCL 7.4 - */ - windowCovering: { - id: 0x0102, - attributes: { - windowCoveringType: { id: 0x0000, mandatory: true }, - currentPositionLiftPercentage: { id: 0x0008, mandatory: false }, - currentPositionTiltPercentage: { id: 0x0009, mandatory: false }, - configStatus: { id: 0x0007, mandatory: true }, - installedOpenLimitLift: { id: 0x0010, mandatory: false }, - installedClosedLimitLift: { id: 0x0011, mandatory: false }, - mode: { id: 0x0017, mandatory: true }, - }, - commands: { - upOpen: { id: 0x00, direction: 'clientToServer', mandatory: true }, - downClose: { id: 0x01, direction: 'clientToServer', mandatory: true }, - stop: { id: 0x02, direction: 'clientToServer', mandatory: true }, - goToLiftPercentage: { id: 0x05, direction: 'clientToServer', mandatory: false }, - goToTiltPercentage: { id: 0x08, direction: 'clientToServer', mandatory: false }, - }, - }, - - /** - * Door Lock Cluster (0x0101) - ZCL 7.3 - */ - doorLock: { - id: 0x0101, - attributes: { - lockState: { id: 0x0000, mandatory: true }, - lockType: { id: 0x0001, mandatory: true }, - actuatorEnabled: { id: 0x0002, mandatory: true }, - doorState: { id: 0x0003, mandatory: false }, - numberOfLogRecordsSupported: { id: 0x0010, mandatory: false }, - autoRelockTime: { id: 0x0023, mandatory: false }, - }, - commands: { - lockDoor: { id: 0x00, direction: 'clientToServer', mandatory: true }, - unlockDoor: { id: 0x01, direction: 'clientToServer', mandatory: true }, - lockDoorResponse: { id: 0x00, direction: 'serverToClient', mandatory: true }, - unlockDoorResponse: { id: 0x01, direction: 'serverToClient', mandatory: true }, - }, - }, -}; - -/** - * Verifies a cluster implementation has all mandatory attributes. - * - * @param {string} clusterName - Cluster name (e.g., 'iasZone') - * @returns {{ missing: string[], extra: string[], status: 'pass'|'fail' }} - */ -function verifyClusterAttributes(clusterName) { - const spec = ZCL_SPEC[clusterName]; - if (!spec) { - throw new Error(`Unknown cluster: ${clusterName}`); - } - - const ClusterClass = Cluster.getCluster(spec.id); - if (!ClusterClass) { - throw new Error(`Cluster not registered: ${clusterName} (0x${spec.id.toString(16)})`); - } - - const implementedAttrs = Object.keys(ClusterClass.ATTRIBUTES || {}); - const specAttrs = Object.keys(spec.attributes); - const mandatoryAttrs = specAttrs.filter(a => spec.attributes[a].mandatory); - - const missing = mandatoryAttrs.filter(a => !implementedAttrs.includes(a)); - const extra = implementedAttrs.filter(a => !specAttrs.includes(a) && !['clusterRevision', 'attributeReportingStatus'].includes(a)); - - return { - missing, - extra, - implemented: implementedAttrs, - status: missing.length === 0 ? 'pass' : 'fail', - }; -} - -/** - * Asserts a cluster has all mandatory attributes. - * - * @param {string} clusterName - Cluster name - */ -function assertClusterComplete(clusterName) { - const result = verifyClusterAttributes(clusterName); - assert.strictEqual( - result.status, - 'pass', - `Cluster ${clusterName} missing mandatory attributes: ${result.missing.join(', ')}`, - ); -} - -module.exports = { - ZCL_SPEC, - verifyClusterAttributes, - assertClusterComplete, -}; diff --git a/test/util/index.js b/test/util/index.js index 31c4e1b..73d310d 100644 --- a/test/util/index.js +++ b/test/util/index.js @@ -6,7 +6,6 @@ debug = debug.extend('test'); const Node = require('../../lib/Node'); const { createMockDevice, createBoundClusterWithAttributes, MOCK_DEVICES } = require('./mockDevice'); -const { ZCL_SPEC, verifyClusterAttributes, assertClusterComplete } = require('./clusterSpec'); const debugUtil = debug.extend('util'); @@ -26,8 +25,4 @@ module.exports = { createMockDevice, createBoundClusterWithAttributes, MOCK_DEVICES, - // Cluster spec verification - ZCL_SPEC, - verifyClusterAttributes, - assertClusterComplete, }; From 68d68382ea678e0dff602430884870523ff88a72 Mon Sep 17 00:00:00 2001 From: Robin Bolscher Date: Wed, 4 Feb 2026 17:21:50 +0100 Subject: [PATCH 4/4] feat(doorLock): add response definitions and notification commands Add response property to all Door Lock commands per ZCL spec, enabling proper request/response handling. Add unsolicited notification commands (operationEventNotification, programmingEventNotification) with direction property for server-to-client events. Also adds tests for notification command parsing. --- lib/clusters/doorLock.js | 266 +++++++++++++++++++++++++++++++-------- test/doorLock.js | 87 +++++++++++++ 2 files changed, 302 insertions(+), 51 deletions(-) diff --git a/lib/clusters/doorLock.js b/lib/clusters/doorLock.js index 2d03206..f1cf1e8 100644 --- a/lib/clusters/doorLock.js +++ b/lib/clusters/doorLock.js @@ -214,6 +214,31 @@ const ATTRIBUTES = { }, }; +// Reusable enum definitions +const USER_STATUS_ENUM = ZCLDataTypes.enum8({ + available: 0, + occupiedEnabled: 1, + occupiedDisabled: 3, + notSupported: 255, +}); + +const USER_TYPE_ENUM = ZCLDataTypes.enum8({ + unrestricted: 0, + yearDayScheduleUser: 1, + weekDayScheduleUser: 2, + masterUser: 3, + nonAccessUser: 4, + notSupported: 255, +}); + +const OPERATING_MODE_ENUM = ZCLDataTypes.enum8({ + normal: 0, + vacation: 1, + privacy: 2, + noRFLockOrUnlock: 3, + passage: 4, +}); + const COMMANDS = { // Lock/Unlock Commands lockDoor: { @@ -221,18 +246,30 @@ const COMMANDS = { args: { pinCode: ZCLDataTypes.octstr, }, + response: { + id: 0x00, + args: { status: ZCLDataTypes.uint8 }, + }, }, unlockDoor: { id: 0x01, // Mandatory args: { pinCode: ZCLDataTypes.octstr, }, + response: { + id: 0x01, + args: { status: ZCLDataTypes.uint8 }, + }, }, toggle: { id: 0x02, args: { pinCode: ZCLDataTypes.octstr, }, + response: { + id: 0x02, + args: { status: ZCLDataTypes.uint8 }, + }, }, unlockWithTimeout: { id: 0x03, @@ -240,6 +277,10 @@ const COMMANDS = { timeout: ZCLDataTypes.uint16, pinCode: ZCLDataTypes.octstr, }, + response: { + id: 0x03, + args: { status: ZCLDataTypes.uint8 }, + }, }, // Logging Commands @@ -248,6 +289,18 @@ const COMMANDS = { args: { logIndex: ZCLDataTypes.uint16, }, + response: { + id: 0x04, + args: { + logEntryId: ZCLDataTypes.uint16, + timestamp: ZCLDataTypes.uint32, + eventType: ZCLDataTypes.uint8, + source: ZCLDataTypes.uint8, + eventIdOrAlarmCode: ZCLDataTypes.uint8, + userId: ZCLDataTypes.uint16, + pin: ZCLDataTypes.octstr, + }, + }, }, // PIN Code Commands @@ -255,46 +308,56 @@ const COMMANDS = { id: 0x05, args: { userId: ZCLDataTypes.uint16, - userStatus: ZCLDataTypes.enum8({ - available: 0, - occupiedEnabled: 1, - occupiedDisabled: 3, - notSupported: 255, - }), - userType: ZCLDataTypes.enum8({ - unrestricted: 0, - yearDayScheduleUser: 1, - weekDayScheduleUser: 2, - masterUser: 3, - nonAccessUser: 4, - notSupported: 255, - }), + userStatus: USER_STATUS_ENUM, + userType: USER_TYPE_ENUM, pinCode: ZCLDataTypes.octstr, }, + response: { + id: 0x05, + args: { status: ZCLDataTypes.uint8 }, + }, }, getPINCode: { id: 0x06, args: { userId: ZCLDataTypes.uint16, }, + response: { + id: 0x06, + args: { + userId: ZCLDataTypes.uint16, + userStatus: USER_STATUS_ENUM, + userType: USER_TYPE_ENUM, + pinCode: ZCLDataTypes.octstr, + }, + }, }, clearPINCode: { id: 0x07, args: { userId: ZCLDataTypes.uint16, }, + response: { + id: 0x07, + args: { status: ZCLDataTypes.uint8 }, + }, + }, + clearAllPINCodes: { + id: 0x08, + response: { + id: 0x08, + args: { status: ZCLDataTypes.uint8 }, + }, }, - clearAllPINCodes: { id: 0x08 }, setUserStatus: { id: 0x09, args: { userId: ZCLDataTypes.uint16, - userStatus: ZCLDataTypes.enum8({ - available: 0, - occupiedEnabled: 1, - occupiedDisabled: 3, - notSupported: 255, - }), + userStatus: USER_STATUS_ENUM, + }, + response: { + id: 0x09, + args: { status: ZCLDataTypes.uint8 }, }, }, getUserStatus: { @@ -302,6 +365,13 @@ const COMMANDS = { args: { userId: ZCLDataTypes.uint16, }, + response: { + id: 0x0A, + args: { + userId: ZCLDataTypes.uint16, + userStatus: USER_STATUS_ENUM, + }, + }, }, // Schedule Commands @@ -316,6 +386,10 @@ const COMMANDS = { endHour: ZCLDataTypes.uint8, endMinute: ZCLDataTypes.uint8, }, + response: { + id: 0x0B, + args: { status: ZCLDataTypes.uint8 }, + }, }, getWeekDaySchedule: { id: 0x0C, @@ -323,6 +397,19 @@ const COMMANDS = { scheduleId: ZCLDataTypes.uint8, userId: ZCLDataTypes.uint16, }, + response: { + id: 0x0C, + args: { + scheduleId: ZCLDataTypes.uint8, + userId: ZCLDataTypes.uint16, + status: ZCLDataTypes.uint8, + daysMask: ZCLDataTypes.map8('sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'), + startHour: ZCLDataTypes.uint8, + startMinute: ZCLDataTypes.uint8, + endHour: ZCLDataTypes.uint8, + endMinute: ZCLDataTypes.uint8, + }, + }, }, clearWeekDaySchedule: { id: 0x0D, @@ -330,6 +417,10 @@ const COMMANDS = { scheduleId: ZCLDataTypes.uint8, userId: ZCLDataTypes.uint16, }, + response: { + id: 0x0D, + args: { status: ZCLDataTypes.uint8 }, + }, }, setYearDaySchedule: { id: 0x0E, @@ -339,6 +430,10 @@ const COMMANDS = { localStartTime: ZCLDataTypes.uint32, localEndTime: ZCLDataTypes.uint32, }, + response: { + id: 0x0E, + args: { status: ZCLDataTypes.uint8 }, + }, }, getYearDaySchedule: { id: 0x0F, @@ -346,6 +441,16 @@ const COMMANDS = { scheduleId: ZCLDataTypes.uint8, userId: ZCLDataTypes.uint16, }, + response: { + id: 0x0F, + args: { + scheduleId: ZCLDataTypes.uint8, + userId: ZCLDataTypes.uint16, + status: ZCLDataTypes.uint8, + localStartTime: ZCLDataTypes.uint32, + localEndTime: ZCLDataTypes.uint32, + }, + }, }, clearYearDaySchedule: { id: 0x10, @@ -353,6 +458,10 @@ const COMMANDS = { scheduleId: ZCLDataTypes.uint8, userId: ZCLDataTypes.uint16, }, + response: { + id: 0x10, + args: { status: ZCLDataTypes.uint8 }, + }, }, setHolidaySchedule: { id: 0x11, @@ -360,13 +469,11 @@ const COMMANDS = { holidayScheduleId: ZCLDataTypes.uint8, localStartTime: ZCLDataTypes.uint32, localEndTime: ZCLDataTypes.uint32, - operatingModeDuringHoliday: ZCLDataTypes.enum8({ - normal: 0, - vacation: 1, - privacy: 2, - noRFLockOrUnlock: 3, - passage: 4, - }), + operatingModeDuringHoliday: OPERATING_MODE_ENUM, + }, + response: { + id: 0x11, + args: { status: ZCLDataTypes.uint8 }, }, }, getHolidaySchedule: { @@ -374,12 +481,26 @@ const COMMANDS = { args: { holidayScheduleId: ZCLDataTypes.uint8, }, + response: { + id: 0x12, + args: { + holidayScheduleId: ZCLDataTypes.uint8, + status: ZCLDataTypes.uint8, + localStartTime: ZCLDataTypes.uint32, + localEndTime: ZCLDataTypes.uint32, + operatingMode: OPERATING_MODE_ENUM, + }, + }, }, clearHolidaySchedule: { id: 0x13, args: { holidayScheduleId: ZCLDataTypes.uint8, }, + response: { + id: 0x13, + args: { status: ZCLDataTypes.uint8 }, + }, }, // User Type Commands @@ -387,14 +508,11 @@ const COMMANDS = { id: 0x14, args: { userId: ZCLDataTypes.uint16, - userType: ZCLDataTypes.enum8({ - unrestricted: 0, - yearDayScheduleUser: 1, - weekDayScheduleUser: 2, - masterUser: 3, - nonAccessUser: 4, - notSupported: 255, - }), + userType: USER_TYPE_ENUM, + }, + response: { + id: 0x14, + args: { status: ZCLDataTypes.uint8 }, }, }, getUserType: { @@ -402,6 +520,13 @@ const COMMANDS = { args: { userId: ZCLDataTypes.uint16, }, + response: { + id: 0x15, + args: { + userId: ZCLDataTypes.uint16, + userType: USER_TYPE_ENUM, + }, + }, }, // RFID Code Commands @@ -409,36 +534,75 @@ const COMMANDS = { id: 0x16, args: { userId: ZCLDataTypes.uint16, - userStatus: ZCLDataTypes.enum8({ - available: 0, - occupiedEnabled: 1, - occupiedDisabled: 3, - notSupported: 255, - }), - userType: ZCLDataTypes.enum8({ - unrestricted: 0, - yearDayScheduleUser: 1, - weekDayScheduleUser: 2, - masterUser: 3, - nonAccessUser: 4, - notSupported: 255, - }), + userStatus: USER_STATUS_ENUM, + userType: USER_TYPE_ENUM, rfidCode: ZCLDataTypes.octstr, }, + response: { + id: 0x16, + args: { status: ZCLDataTypes.uint8 }, + }, }, getRFIDCode: { id: 0x17, args: { userId: ZCLDataTypes.uint16, }, + response: { + id: 0x17, + args: { + userId: ZCLDataTypes.uint16, + userStatus: USER_STATUS_ENUM, + userType: USER_TYPE_ENUM, + rfidCode: ZCLDataTypes.octstr, + }, + }, }, clearRFIDCode: { id: 0x18, args: { userId: ZCLDataTypes.uint16, }, + response: { + id: 0x18, + args: { status: ZCLDataTypes.uint8 }, + }, + }, + clearAllRFIDCodes: { + id: 0x19, + response: { + id: 0x19, + args: { status: ZCLDataTypes.uint8 }, + }, + }, + + // Unsolicited notifications (server to client) + operationEventNotification: { + id: 0x20, + direction: Cluster.DIRECTION_SERVER_TO_CLIENT, + args: { + operationEventSource: ZCLDataTypes.uint8, + operationEventCode: ZCLDataTypes.uint8, + userId: ZCLDataTypes.uint16, + pin: ZCLDataTypes.octstr, + zigBeeLocalTime: ZCLDataTypes.uint32, + data: ZCLDataTypes.octstr, + }, + }, + programmingEventNotification: { + id: 0x21, + direction: Cluster.DIRECTION_SERVER_TO_CLIENT, + args: { + programEventSource: ZCLDataTypes.uint8, + programEventCode: ZCLDataTypes.uint8, + userId: ZCLDataTypes.uint16, + pin: ZCLDataTypes.octstr, + userType: USER_TYPE_ENUM, + userStatus: USER_STATUS_ENUM, + zigBeeLocalTime: ZCLDataTypes.uint32, + data: ZCLDataTypes.octstr, + }, }, - clearAllRFIDCodes: { id: 0x19 }, }; class DoorLockCluster extends Cluster { diff --git a/test/doorLock.js b/test/doorLock.js index 1d6487f..1932945 100644 --- a/test/doorLock.js +++ b/test/doorLock.js @@ -108,4 +108,91 @@ describe('Door Lock', function() { node.handleFrame(endpointId, DoorLockCluster.ID, frame.toBuffer(), {}); }); + + it('should receive operationEventNotification', function(done) { + const node = new Node({ + sendFrame: () => null, + endpointDescriptors: [{ + endpointId, + inputClusters: [DoorLockCluster.ID], + }], + }); + + // Listen for incoming operationEventNotification + node.endpoints[endpointId].clusters.doorLock.onOperationEventNotification = data => { + assert.strictEqual(data.operationEventSource, 1); // Keypad + assert.strictEqual(data.operationEventCode, 2); // Unlock + assert.strictEqual(data.userId, 3); + assert.deepStrictEqual(data.pin, Buffer.from([0x31, 0x32, 0x33, 0x34])); + assert.strictEqual(data.zigBeeLocalTime, 0x12345678); + assert.deepStrictEqual(data.data, Buffer.from([])); + done(); + }; + + // Create operationEventNotification frame + const frame = new ZCLStandardHeader(); + frame.cmdId = DoorLockCluster.COMMANDS.operationEventNotification.id; + frame.frameControl.directionToClient = true; + frame.frameControl.clusterSpecific = true; + // operationEventSource (uint8): 1 + // operationEventCode (uint8): 2 + // userId (uint16 LE): 3 = 0x03 0x00 + // pin (octstr): length 4, data "1234" + // zigBeeLocalTime (uint32 LE): 0x12345678 + // data (octstr): empty = 0x00 + frame.data = Buffer.from([ + 0x01, 0x02, 0x03, 0x00, + 0x04, 0x31, 0x32, 0x33, 0x34, + 0x78, 0x56, 0x34, 0x12, + 0x00, + ]); + + node.handleFrame(endpointId, DoorLockCluster.ID, frame.toBuffer(), {}); + }); + + it('should receive programmingEventNotification', function(done) { + const node = new Node({ + sendFrame: () => null, + endpointDescriptors: [{ + endpointId, + inputClusters: [DoorLockCluster.ID], + }], + }); + + // Listen for incoming programmingEventNotification + node.endpoints[endpointId].clusters.doorLock.onProgrammingEventNotification = data => { + assert.strictEqual(data.programEventSource, 2); // RF + assert.strictEqual(data.programEventCode, 3); // PIN added + assert.strictEqual(data.userId, 7); + assert.deepStrictEqual(data.pin, Buffer.from([0x35, 0x36, 0x37, 0x38])); + assert.strictEqual(data.userType, 'unrestricted'); + assert.strictEqual(data.userStatus, 'occupiedEnabled'); + assert.strictEqual(data.zigBeeLocalTime, 0xAABBCCDD); + assert.deepStrictEqual(data.data, Buffer.from([])); + done(); + }; + + // Create programmingEventNotification frame + const frame = new ZCLStandardHeader(); + frame.cmdId = DoorLockCluster.COMMANDS.programmingEventNotification.id; + frame.frameControl.directionToClient = true; + frame.frameControl.clusterSpecific = true; + // programEventSource (uint8): 2 + // programEventCode (uint8): 3 + // userId (uint16 LE): 7 = 0x07 0x00 + // pin (octstr): length 4, data "5678" + // userType (enum8): unrestricted = 0 + // userStatus (enum8): occupiedEnabled = 1 + // zigBeeLocalTime (uint32 LE): 0xAABBCCDD + // data (octstr): empty = 0x00 + frame.data = Buffer.from([ + 0x02, 0x03, 0x07, 0x00, + 0x04, 0x35, 0x36, 0x37, 0x38, + 0x00, 0x01, + 0xDD, 0xCC, 0xBB, 0xAA, + 0x00, + ]); + + node.handleFrame(endpointId, DoorLockCluster.ID, frame.toBuffer(), {}); + }); });