UNPKG

zigbee-herdsman

Version:

An open source ZigBee gateway solution with node.js.

604 lines (535 loc) 25.8 kB
import assert from "node:assert"; import {createCipheriv} from "node:crypto"; import {EventEmitter} from "node:events"; import {aes128CcmStar} from "zigbee-on-host/dist/zigbee/zigbee"; import type {Adapter, Events as AdapterEvents} from "../adapter"; import {logger} from "../utils/logger"; import {COORDINATOR_ADDRESS, GP_ENDPOINT, GP_GROUP_ID, INTEROPERABILITY_LINK_KEY} from "../zspec/consts"; import {BroadcastAddress} from "../zspec/enums"; import * as Zcl from "../zspec/zcl"; import type {GPDChannelConfiguration, GPDCommissioningReply} from "../zspec/zcl/buffaloZcl"; import zclTransactionSequenceNumber from "./helpers/zclTransactionSequenceNumber"; import {Device} from "./model"; import type {GreenPowerDeviceJoinedPayload} from "./tstype"; const NS = "zh:controller:greenpower"; const enum ZigbeeNWKGPAppId { Default = 0x00, Lped = 0x01, Zgp = 0x02, } const enum ZigbeeNWKGPSecurityLevel { /** No Security */ No = 0x00, /** Reserved? */ OneLsb = 0x01, /** 4 Byte Frame Counter and 4 Byte MIC */ Full = 0x02, /** 4 Byte Frame Counter and 4 Byte MIC with encryption */ FullEncr = 0x03, } const enum ZigbeeNWKGPSecurityKeyType { NoKey = 0x00, ZbNwkKey = 0x01, GpdGroupKey = 0x02, NwkKeyDerivedGpdKeyGroupKey = 0x03, PreconfiguredIndividualGpdKey = 0x04, DerivedIndividualGpdKey = 0x07, } const enum GPCommunicationMode { FullUnicast = 0, GroupcastToDgroupId = 1, GroupcastToPrecommissionedGroupId = 2, LightweightUnicast = 3, } type PairingOptions = { appId: ZigbeeNWKGPAppId; addSink: boolean; removeGpd: boolean; communicationMode: GPCommunicationMode; gpdFixed: boolean; gpdMacSeqNumCapabilities: boolean; securityLevel: ZigbeeNWKGPSecurityLevel; securityKeyType: ZigbeeNWKGPSecurityKeyType; gpdSecurityFrameCounterPresent: boolean; gpdSecurityKeyPresent: boolean; assignedAliasPresent: boolean; groupcastRadiusPresent: boolean; }; type CommissioningModeOptions = { action: number; commissioningWindowPresent: boolean; /** Bits: 0: On first Pairing success | 1: On GP Proxy Commissioning Mode (exit) */ exitMode: number; /** should always be always false in current spec (1.1.2) */ channelPresent: boolean; unicastCommunication: boolean; }; /** @see Zcl.Clusters.greenPower.commandsResponse.pairing */ type PairingPayload = { options: number; srcID?: number; gpdIEEEAddr?: string; gpdEndpoint?: number; sinkIEEEAddr?: string; sinkNwkAddr?: number; sinkGroupID?: number; deviceID?: number; frameCounter?: number; gpdKey?: Buffer; assignedAlias?: number; groupcastRadius?: number; }; /** @see Zcl.Clusters.greenPower.commandsResponse.response */ type ResponsePayload<T extends GPDCommissioningReply | GPDChannelConfiguration> = { options: number; tempMaster: number; tempMasterTx: number; srcID?: number; gpdIEEEAddr?: string; gpdEndpoint?: number; gpdCmd: number; gpdPayload: T; }; interface GreenPowerEventMap { deviceJoined: [payload: GreenPowerDeviceJoinedPayload]; deviceLeave: [sourceID: number]; } export class GreenPower extends EventEmitter<GreenPowerEventMap> { private adapter: Adapter; public constructor(adapter: Adapter) { super(); this.adapter = adapter; } public static sourceIdToIeeeAddress(sourceId: number): string { return `0x${sourceId.toString(16).padStart(16, "0")}`; } private encryptSecurityKey(sourceID: number, securityKey: Buffer): Buffer { const nonce = Buffer.alloc(13); nonce.writeUInt32LE(sourceID, 0); nonce.writeUInt32LE(sourceID, 4); nonce.writeUInt32LE(sourceID, 8); nonce.writeUInt8(0x05, 12); const cipher = createCipheriv("aes-128-ccm", Buffer.from(INTEROPERABILITY_LINK_KEY), nonce, {authTagLength: 16}); const encrypted = cipher.update(securityKey); return Buffer.concat([encrypted, cipher.final()]); } private decryptPayload(sourceID: number, frameCounter: number, securityKey: Buffer, payload: Buffer): Buffer { const nonce = Buffer.alloc(13); nonce.writeUInt32LE(sourceID, 0); nonce.writeUInt32LE(sourceID, 4); nonce.writeUInt32LE(frameCounter, 8); nonce.writeUInt8(0x05, 12); const [, decryptedPayload] = aes128CcmStar(4, securityKey, nonce, payload); return decryptedPayload; } public static encodePairingOptions(options: PairingOptions): number { return ( (options.appId & 0x7) | (((options.addSink ? 1 : 0) << 3) & 0x8) | (((options.removeGpd ? 1 : 0) << 4) & 0x10) | ((options.communicationMode << 5) & 0x60) | (((options.gpdFixed ? 1 : 0) << 7) & 0x80) | (((options.gpdMacSeqNumCapabilities ? 1 : 0) << 8) & 0x100) | ((options.securityLevel << 9) & 0x600) | ((options.securityKeyType << 11) & 0x3800) | (((options.gpdSecurityFrameCounterPresent ? 1 : 0) << 14) & 0x4000) | (((options.gpdSecurityKeyPresent ? 1 : 0) << 15) & 0x8000) | (((options.assignedAliasPresent ? 1 : 0) << 16) & 0x10000) | (((options.groupcastRadiusPresent ? 1 : 0) << 17) & 0x20000) // bits 18..23 reserved ); } public static decodePairingOptions(byte: number): PairingOptions { return { appId: byte & 0x7, addSink: Boolean((byte & 0x8) >> 3), removeGpd: Boolean((byte & 0x10) >> 4), communicationMode: (byte & 0x60) >> 5, gpdFixed: Boolean((byte & 0x80) >> 7), gpdMacSeqNumCapabilities: Boolean((byte & 0x100) >> 8), securityLevel: (byte & 0x600) >> 9, securityKeyType: (byte & 0x3800) >> 11, gpdSecurityFrameCounterPresent: Boolean((byte & 0x4000) >> 14), gpdSecurityKeyPresent: Boolean((byte & 0x8000) >> 15), assignedAliasPresent: Boolean((byte & 0x10000) >> 16), groupcastRadiusPresent: Boolean((byte & 0x20000) >> 17), // bits 18..23 reserved }; } /** see 14-0563-19 A.3.3.5.2 */ private async sendPairingCommand( options: PairingOptions, payload: PairingPayload, gppNwkAddr: number | undefined, ): Promise<AdapterEvents.ZclPayload | undefined> { payload.options = GreenPower.encodePairingOptions(options); logger.debug( `[PAIRING] srcID=${payload.srcID} gpp=${gppNwkAddr ?? "NO"} options=${payload.options} (addSink=${options.addSink} commMode=${options.communicationMode})`, NS, ); // Set sink address based on communication mode switch (options.communicationMode) { case GPCommunicationMode.GroupcastToPrecommissionedGroupId: case GPCommunicationMode.GroupcastToDgroupId: { payload.sinkGroupID = GP_GROUP_ID; break; } /* v8 ignore next */ case GPCommunicationMode.FullUnicast: case GPCommunicationMode.LightweightUnicast: { payload.sinkIEEEAddr = await this.adapter.getCoordinatorIEEE(); payload.sinkNwkAddr = COORDINATOR_ADDRESS; break; } } const replyFrame = Zcl.Frame.create( Zcl.FrameType.SPECIFIC, Zcl.Direction.SERVER_TO_CLIENT, true, undefined, zclTransactionSequenceNumber.next(), "pairing", Zcl.Clusters.greenPower.ID, payload, {}, ); if (options.communicationMode !== GPCommunicationMode.LightweightUnicast) { await this.adapter.sendZclFrameToAll(GP_ENDPOINT, replyFrame, GP_ENDPOINT, BroadcastAddress.RX_ON_WHEN_IDLE); return; } const device = Device.byNetworkAddress(gppNwkAddr ?? /* v8 ignore next */ COORDINATOR_ADDRESS); assert(device, "Failed to find green power proxy device"); return await this.adapter.sendZclFrameToEndpoint( device.ieeeAddr, device.networkAddress, GP_ENDPOINT, replyFrame, 10000, false, false, GP_ENDPOINT, ); } public async processCommand(dataPayload: AdapterEvents.ZclPayload, frame: Zcl.Frame, securityKey?: Buffer): Promise<Zcl.Frame> { try { // notification: A.3.3.4.1 // commissioningNotification: A.3.3.4.3 const isCommissioningNotification = frame.header.commandIdentifier === Zcl.Clusters.greenPower.commands.commissioningNotification.ID; const securityLevel = isCommissioningNotification ? (frame.payload.options >> 4) & 0x3 : (frame.payload.options >> 6) & 0x3; if ( securityLevel === ZigbeeNWKGPSecurityLevel.FullEncr && (!isCommissioningNotification || ((frame.payload.options >> 9) & 0x1) === 1) /* security processing failed */ ) { if (!securityKey) { logger.error( `[FULLENCR] srcID=${frame.payload.srcID} gpp=${frame.payload.gppNwkAddr ?? "NO"} commandIdentifier=${frame.header.commandIdentifier} Unknown security key`, NS, ); return frame; } const oldHeader = dataPayload.data.subarray(0, 15); let dataEndOffset = dataPayload.data.byteLength; if (isCommissioningNotification) { const hasMic = frame.payload.options & 0x200; const hasGppData = frame.payload.options & 0x800; if (hasGppData) { dataEndOffset -= 3; } if (hasMic) { dataEndOffset -= 4; } } else { const hasGppData = frame.payload.options & 0x4000; if (hasGppData) { dataEndOffset -= 3; } } const hashedKey = this.encryptSecurityKey(frame.payload.srcID, securityKey); // 4 bytes appended for MIC placeholder (just needs the bytes present for decrypt) const payload = Buffer.from([frame.payload.commandID, ...dataPayload.data.subarray(15, dataEndOffset), 0, 0, 0, 0]); const decrypted = this.decryptPayload(frame.payload.srcID, frame.payload.frameCounter, hashedKey, payload); const newHeader = Buffer.alloc(15); newHeader.set(oldHeader, 0); // flip necessary bits in options before re-parsing // - "securityLevel" to ZigbeeNWKGPSecurityLevel.NO (for ease) and "securityProcessingFailed" to 0 // - "securityLevel" to ZigbeeNWKGPSecurityLevel.NO (for ease) newHeader.writeUInt16LE(isCommissioningNotification ? frame.payload.options & ~0x30 & ~0x200 : frame.payload.options & ~0xc0, 3); newHeader.writeUInt8(decrypted[0], oldHeader.byteLength - 2); // commandID newHeader.writeUInt8(decrypted.byteLength - 1, oldHeader.byteLength - 1); // payloadSize // re-parse with decrypted data frame = Zcl.Frame.fromBuffer( dataPayload.clusterID, dataPayload.header, Buffer.concat([newHeader, decrypted.subarray(1), dataPayload.data.subarray(dataEndOffset)]), {}, ); } let logStr: string; /* v8 ignore start */ if (frame.payload.gppGpdLink !== undefined) { const rssi = frame.payload.gppGpdLink & 0x3f; const linkQuality = (frame.payload.gppGpdLink >> 6) & 0x3; let linkQualityStr: string | undefined; switch (linkQuality) { case 0b00: linkQualityStr = "Poor"; break; case 0b01: linkQualityStr = "Moderate"; break; case 0b10: linkQualityStr = "High"; break; case 0b11: linkQualityStr = "Excellent"; break; } logStr = `srcID=${frame.payload.srcID} gpp=${frame.payload.gppNwkAddr} rssi=${rssi} linkQuality=${linkQualityStr}`; } else { logStr = `srcID=${frame.payload.srcID} gpp=NO`; } /* v8 ignore stop */ switch (frame.payload.commandID) { case 0xe0: { logger.info(`[COMMISSIONING] ${logStr}`, NS); /* v8 ignore start */ if (frame.payload.options & 0x200) { logger.warning(`[COMMISSIONING] ${logStr} Security processing marked as failed`, NS); } /* v8 ignore stop */ const rxOnCap = frame.payload.commandFrame.options & 0x2; const gpdMacSeqNumCapabilities = Boolean(frame.payload.commandFrame.options & 0x1); const gpdFixed = Boolean(frame.payload.commandFrame.options & 0x40); if (rxOnCap) { // RX capable GPD needs GP Commissioning Reply logger.debug(`[COMMISSIONING] ${logStr} GPD has receiving capabilities in operational mode (RxOnCapability)`, NS); // NOTE: currently encryption is disabled for RX capable GPDs const networkParameters = await this.adapter.getNetworkParameters(); // Commissioning reply const payloadResponse: ResponsePayload<GPDCommissioningReply> = { options: 0, tempMaster: frame.payload.gppNwkAddr ?? /* v8 ignore next */ COORDINATOR_ADDRESS, tempMasterTx: networkParameters.channel - 11, srcID: frame.payload.srcID, gpdCmd: 0xf0, gpdPayload: { commandID: 0xf0, options: 0b00000000, // Disable encryption // panID: number, // securityKey: frame.payload.commandFrame.securityKey, // keyMic: frame.payload.commandFrame.keyMic, // frameCounter: number, }, }; const replyFrame = Zcl.Frame.create( Zcl.FrameType.SPECIFIC, Zcl.Direction.SERVER_TO_CLIENT, true, undefined, zclTransactionSequenceNumber.next(), "response", Zcl.Clusters.greenPower.ID, payloadResponse, {}, ); await this.adapter.sendZclFrameToAll(GP_ENDPOINT, replyFrame, GP_ENDPOINT, BroadcastAddress.RX_ON_WHEN_IDLE); await this.sendPairingCommand( { appId: ZigbeeNWKGPAppId.Default, addSink: true, removeGpd: false, communicationMode: GPCommunicationMode.GroupcastToDgroupId, gpdFixed, gpdMacSeqNumCapabilities, securityLevel: ZigbeeNWKGPSecurityLevel.No, securityKeyType: ZigbeeNWKGPSecurityKeyType.NoKey, gpdSecurityFrameCounterPresent: false, gpdSecurityKeyPresent: false, assignedAliasPresent: false, groupcastRadiusPresent: false, }, { options: 0, // set from first param in `sendPairingCommand` srcID: frame.payload.srcID, deviceID: frame.payload.commandFrame.deviceID, }, frame.payload.gppNwkAddr, ); } else { const gpdKey = this.encryptSecurityKey(frame.payload.srcID, frame.payload.commandFrame.securityKey); await this.sendPairingCommand( { appId: ZigbeeNWKGPAppId.Default, addSink: true, removeGpd: false, // keep communication mode matching incoming tx mode communicationMode: dataPayload.wasBroadcast ? GPCommunicationMode.GroupcastToPrecommissionedGroupId : GPCommunicationMode.LightweightUnicast, gpdFixed, gpdMacSeqNumCapabilities, securityLevel: ZigbeeNWKGPSecurityLevel.Full, securityKeyType: ZigbeeNWKGPSecurityKeyType.PreconfiguredIndividualGpdKey, gpdSecurityFrameCounterPresent: true, gpdSecurityKeyPresent: true, assignedAliasPresent: false, groupcastRadiusPresent: false, }, { options: 0, // set from first param in `sendPairingCommand` srcID: frame.payload.srcID, deviceID: frame.payload.commandFrame.deviceID, frameCounter: frame.payload.commandFrame.outgoingCounter, gpdKey, }, frame.payload.gppNwkAddr, ); } this.emit("deviceJoined", { sourceID: frame.payload.srcID, deviceID: frame.payload.commandFrame.deviceID, // XXX: this has the potential to create conflicting network addresses networkAddress: frame.payload.srcID & 0xffff, securityKey: frame.payload.commandFrame.securityKey, }); break; } case 0xe1: { logger.debug(`[DECOMMISSIONING] ${logStr}`, NS); await this.sendPairingCommand( { appId: ZigbeeNWKGPAppId.Default, addSink: false, removeGpd: true, communicationMode: GPCommunicationMode.GroupcastToDgroupId, gpdFixed: false, gpdMacSeqNumCapabilities: false, securityLevel: ZigbeeNWKGPSecurityLevel.No, securityKeyType: ZigbeeNWKGPSecurityKeyType.NoKey, gpdSecurityFrameCounterPresent: false, gpdSecurityKeyPresent: false, assignedAliasPresent: false, groupcastRadiusPresent: false, }, { options: 0, // set from first param in `sendPairingCommand` srcID: frame.payload.srcID, }, frame.payload.gppNwkAddr, ); this.emit("deviceLeave", frame.payload.srcID); break; } /* v8 ignore start */ case 0xe2: { logger.debug(`[SUCCESS] ${logStr}`, NS); break; } /* v8 ignore stop */ case 0xe3: { logger.debug(`[CHANNEL_REQUEST] ${logStr}`, NS); const networkParameters = await this.adapter.getNetworkParameters(); // Channel notification const payload: ResponsePayload<GPDChannelConfiguration> = { options: 0, tempMaster: frame.payload.gppNwkAddr ?? /* v8 ignore next */ COORDINATOR_ADDRESS, tempMasterTx: frame.payload.commandFrame.nextChannel, srcID: frame.payload.srcID, gpdCmd: 0xf3, gpdPayload: { commandID: 0xf3, operationalChannel: networkParameters.channel - 11, // If EITHER the sink is a GP Basic sink OR the sink is a GP Advanced sink, // but all of the candidate TempMasters are GP Basic proxies (as indicated by the BidirectionalCommunicationCapability // sub-field of the Options field of the received GP Commissioning Notification set to 0b0), // the sink SHALL set the Basic sub-field of the Channel field to 0b1. basic: true, }, }; const replyFrame = Zcl.Frame.create( Zcl.FrameType.SPECIFIC, Zcl.Direction.SERVER_TO_CLIENT, true, undefined, zclTransactionSequenceNumber.next(), "response", Zcl.Clusters.greenPower.ID, payload, {}, ); await this.adapter.sendZclFrameToAll(GP_ENDPOINT, replyFrame, GP_ENDPOINT, BroadcastAddress.RX_ON_WHEN_IDLE); break; } /* v8 ignore start */ case 0xe4: { logger.debug(`[APP_DESCRIPTION] ${logStr}`, NS); break; } case 0xa1: { // GP Manufacturer-specific Attribute Reporting break; } /* v8 ignore stop */ default: { // NOTE: this is spammy because it logs everything that is handed back to Controller without special processing here logger.debug(`[UNHANDLED_CMD/PASSTHROUGH] command=0x${frame.payload.commandID.toString(16)} ${logStr}`, NS); } } /* v8 ignore start */ } catch (error) { // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` logger.error((error as Error).stack!, NS); } /* v8 ignore stop */ return frame; } public static encodeCommissioningModeOptions(options: CommissioningModeOptions): number { return ( (options.action & 0x1) | (((options.commissioningWindowPresent ? 1 : 0) << 1) & 0x2) | ((options.exitMode << 2) & 0x0c) | (((options.channelPresent ? 1 : 0) << 4) & 0x10) | (((options.unicastCommunication ? 1 : 0) << 5) & 0x20) ); } public static decodeCommissioningModeOptions(byte: number): CommissioningModeOptions { return { action: byte & 0x1, commissioningWindowPresent: Boolean((byte & 0x2) >> 1), exitMode: (byte & 0x0c) >> 2, channelPresent: Boolean((byte & 0x10) >> 4), unicastCommunication: Boolean((byte & 0x20) >> 5), }; } public async permitJoin(time: number, networkAddress?: number): Promise<void> { const payload = { options: GreenPower.encodeCommissioningModeOptions({ action: time > 0 ? 1 : 0, commissioningWindowPresent: true, exitMode: 0b10, channelPresent: false, unicastCommunication: networkAddress !== undefined, }), commisioningWindow: time, }; const frame = Zcl.Frame.create( Zcl.FrameType.SPECIFIC, Zcl.Direction.SERVER_TO_CLIENT, true, // avoid receiving many responses, especially from the nodes not supporting this functionality undefined, zclTransactionSequenceNumber.next(), "commisioningMode", Zcl.Clusters.greenPower.ID, payload, {}, ); if (networkAddress === undefined) { await this.adapter.sendZclFrameToAll(GP_ENDPOINT, frame, GP_ENDPOINT, BroadcastAddress.RX_ON_WHEN_IDLE); } else { const device = Device.byNetworkAddress(networkAddress); assert(device, "Failed to find device to permit GP join on"); await this.adapter.sendZclFrameToEndpoint(device.ieeeAddr, networkAddress, GP_ENDPOINT, frame, 10000, false, false, GP_ENDPOINT); } } } export default GreenPower;