diff --git a/lib/clusters/doorLock.js b/lib/clusters/doorLock.js index 8d100b4..f1cf1e8 100644 --- a/lib/clusters/doorLock.js +++ b/lib/clusters/doorLock.js @@ -1,16 +1,614 @@ '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, + apsSecurity: 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 = {}; +// 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: { + id: 0x00, // Mandatory + 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, + args: { + timeout: ZCLDataTypes.uint16, + pinCode: ZCLDataTypes.octstr, + }, + response: { + id: 0x03, + args: { status: ZCLDataTypes.uint8 }, + }, + }, + + // Logging Commands + getLogRecord: { + id: 0x04, + 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 + setPINCode: { + id: 0x05, + args: { + userId: ZCLDataTypes.uint16, + 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 }, + }, + }, + setUserStatus: { + id: 0x09, + args: { + userId: ZCLDataTypes.uint16, + userStatus: USER_STATUS_ENUM, + }, + response: { + id: 0x09, + args: { status: ZCLDataTypes.uint8 }, + }, + }, + getUserStatus: { + id: 0x0A, + args: { + userId: ZCLDataTypes.uint16, + }, + response: { + id: 0x0A, + args: { + userId: ZCLDataTypes.uint16, + userStatus: USER_STATUS_ENUM, + }, + }, + }, + + // 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, + }, + response: { + id: 0x0B, + args: { status: ZCLDataTypes.uint8 }, + }, + }, + getWeekDaySchedule: { + id: 0x0C, + args: { + 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, + args: { + scheduleId: ZCLDataTypes.uint8, + userId: ZCLDataTypes.uint16, + }, + response: { + id: 0x0D, + args: { status: ZCLDataTypes.uint8 }, + }, + }, + setYearDaySchedule: { + id: 0x0E, + args: { + scheduleId: ZCLDataTypes.uint8, + userId: ZCLDataTypes.uint16, + localStartTime: ZCLDataTypes.uint32, + localEndTime: ZCLDataTypes.uint32, + }, + response: { + id: 0x0E, + args: { status: ZCLDataTypes.uint8 }, + }, + }, + getYearDaySchedule: { + id: 0x0F, + args: { + 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, + args: { + scheduleId: ZCLDataTypes.uint8, + userId: ZCLDataTypes.uint16, + }, + response: { + id: 0x10, + args: { status: ZCLDataTypes.uint8 }, + }, + }, + setHolidaySchedule: { + id: 0x11, + args: { + holidayScheduleId: ZCLDataTypes.uint8, + localStartTime: ZCLDataTypes.uint32, + localEndTime: ZCLDataTypes.uint32, + operatingModeDuringHoliday: OPERATING_MODE_ENUM, + }, + response: { + id: 0x11, + args: { status: ZCLDataTypes.uint8 }, + }, + }, + getHolidaySchedule: { + id: 0x12, + 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 + setUserType: { + id: 0x14, + args: { + userId: ZCLDataTypes.uint16, + userType: USER_TYPE_ENUM, + }, + response: { + id: 0x14, + args: { status: ZCLDataTypes.uint8 }, + }, + }, + getUserType: { + id: 0x15, + args: { + userId: ZCLDataTypes.uint16, + }, + response: { + id: 0x15, + args: { + userId: ZCLDataTypes.uint16, + userType: USER_TYPE_ENUM, + }, + }, + }, + + // RFID Code Commands + setRFIDCode: { + id: 0x16, + args: { + userId: ZCLDataTypes.uint16, + 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, + }, + }, +}; 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/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..1932945 --- /dev/null +++ b/test/doorLock.js @@ -0,0 +1,198 @@ +// 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(), {}); + }); + + 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(), {}); + }); +}); diff --git a/test/iasZone.js b/test/iasZone.js index 48ed5f2..d6752f8 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 } = require('./util'); const endpointId = 1; @@ -98,4 +99,37 @@ describe('IAS Zone', function() { // Feed frame to node node.handleFrame(endpointId, IASZoneCluster.ID, frame.toBuffer(), {}); }); + + 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/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/index.js b/test/util/index.js index 2331cc3..73d310d 100644 --- a/test/util/index.js +++ b/test/util/index.js @@ -5,6 +5,7 @@ let { debug } = require('../../lib/util'); debug = debug.extend('test'); const Node = require('../../lib/Node'); +const { createMockDevice, createBoundClusterWithAttributes, MOCK_DEVICES } = require('./mockDevice'); const debugUtil = debug.extend('util'); @@ -20,4 +21,8 @@ const loopbackNode = config => { module.exports = { debug, loopbackNode, + // Mock device utilities + createMockDevice, + createBoundClusterWithAttributes, + MOCK_DEVICES, }; 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, +};