UNPKG

zigbee-herdsman-zigate

Version:

An open source ZigBee gateway solution with node.js.

609 lines (518 loc) 23.1 kB
import events from 'events'; import Database from './database'; import {TsType as AdapterTsType, Adapter, Events as AdapterEvents} from '../adapter'; import {Entity, Device} from './model'; import {ZclFrameConverter} from './helpers'; import * as Events from './events'; import {KeyValue, DeviceType, GreenPowerEvents, GreenPowerDeviceJoinedPayload} from './tstype'; import Debug from "debug"; import fs from 'fs'; import {Utils as ZclUtils, FrameControl} from '../zcl'; import Touchlink from './touchlink'; import GreenPower from './greenPower'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore import mixin from 'mixin-deep'; import Group from './model/group'; interface Options { network: AdapterTsType.NetworkOptions; serialPort: AdapterTsType.SerialPortOptions; databasePath: string; databaseBackupPath: string; backupPath: string; adapter: AdapterTsType.AdapterOptions; /** * This lambda can be used by an application to explictly reject or accept an incoming device. * When false is returned zigbee-herdsman will not start the interview process and immidiately * try to remove the device from the network. */ acceptJoiningDeviceHandler: (ieeeAddr: string) => Promise<boolean>; } const DefaultOptions: Options = { network: { networkKeyDistribute: false, networkKey: [0x01, 0x03, 0x05, 0x07, 0x09, 0x0B, 0x0D, 0x0F, 0x00, 0x02, 0x04, 0x06, 0x08, 0x0A, 0x0C, 0x0D], panID: 0x1a62, extendedPanID: [0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD], channelList: [11], }, serialPort: {}, databasePath: null, databaseBackupPath: null, backupPath: null, adapter: null, acceptJoiningDeviceHandler: null, }; const debug = { error: Debug('zigbee-herdsman:controller:error'), log: Debug('zigbee-herdsman:controller:log'), }; /** * @noInheritDoc */ class Controller extends events.EventEmitter { private options: Options; private database: Database; private adapter: Adapter; private greenPower: GreenPower; // eslint-disable-next-line private permitJoinTimer: any; // eslint-disable-next-line private backupTimer: any; // eslint-disable-next-line private databaseSaveTimer: any; private touchlink: Touchlink; /** * Create a controller * * To auto detect the port provide `null` for `options.serialPort.path` */ public constructor(options: Options) { super(); this.options = mixin(JSON.parse(JSON.stringify(DefaultOptions)), options); // Validate options for (const channel of this.options.network.channelList) { if (channel < 11 || channel > 26) { throw new Error(`'${channel}' is an invalid channel, use a channel between 11 - 26.`); } } if (!Array.isArray(this.options.network.networkKey) || this.options.network.networkKey.length !== 16) { throw new Error(`Network key must be 16 digits long, got ${this.options.network.networkKey.length}.`); } } /** * Start the Herdsman controller */ public async start(): Promise<void> { this.adapter = await Adapter.create(this.options.network, this.options.serialPort, this.options.backupPath, this.options.adapter); debug.log(`Starting with options '${JSON.stringify(this.options)}'`); this.database = Database.open(this.options.databasePath); const startResult = await this.adapter.start(); debug.log(`Started with result '${startResult}'`); // Inject adapter and database in entity debug.log(`Injected database: ${this.database != null}, adapter: ${this.adapter != null}`); Entity.injectAdapter(this.adapter); Entity.injectDatabase(this.database); this.greenPower = new GreenPower(this.adapter); this.greenPower.on(GreenPowerEvents.deviceJoined, this.onDeviceJoinedGreenPower.bind(this)); // Register adapter events this.adapter.on(AdapterEvents.Events.deviceJoined, this.onDeviceJoined.bind(this)); this.adapter.on(AdapterEvents.Events.zclData, (data) => this.onZclOrRawData('zcl', data)); this.adapter.on(AdapterEvents.Events.rawData, (data) => this.onZclOrRawData('raw', data)); this.adapter.on(AdapterEvents.Events.disconnected, this.onAdapterDisconnected.bind(this)); this.adapter.on(AdapterEvents.Events.deviceAnnounce, this.onDeviceAnnounce.bind(this)); this.adapter.on(AdapterEvents.Events.deviceLeave, this.onDeviceLeave.bind(this)); this.adapter.on(AdapterEvents.Events.networkAddress, this.onNetworkAddress.bind(this)); if (startResult === 'reset') { if (this.options.databaseBackupPath && fs.existsSync(this.options.databasePath)) { fs.copyFileSync(this.options.databasePath, this.options.databaseBackupPath); } debug.log('Clearing database...'); for (const group of Group.all()) { group.removeFromDatabase(); } for (const device of Device.all()) { device.removeFromDatabase(); } } // Add coordinator to the database if it is not there yet. const coordinator = await this.adapter.getCoordinator(); if (Device.byType('Coordinator').length === 0) { debug.log('No coordinator in database, querying...'); Device.create( 'Coordinator', coordinator.ieeeAddr, coordinator.networkAddress, coordinator.manufacturerID, undefined, undefined, undefined, true, coordinator.endpoints ); } // Update coordinator ieeeAddr if changed, can happen due to e.g. reflashing const databaseCoordinator = Device.byType('Coordinator')[0]; if (databaseCoordinator.ieeeAddr !== coordinator.ieeeAddr) { debug.log(`Coordinator address changed, updating to '${coordinator.ieeeAddr}'`); databaseCoordinator.ieeeAddr = coordinator.ieeeAddr; databaseCoordinator.save(); } // Set backup timer to 1 day. await this.backup(); this.backupTimer = setInterval(() => this.backup(), 86400000); // Set database save timer to 1 hour. this.databaseSaveTimer = setInterval(() => this.databaseSave(), 3600000); this.touchlink = new Touchlink(this.adapter); } public async touchlinkIdentify(ieeeAddr: string, channel: number): Promise<void> { await this.touchlink.identify(ieeeAddr, channel); } public async touchlinkScan(): Promise<{ieeeAddr: string; channel: number}[]> { return this.touchlink.scan(); } public async touchlinkFactoryReset(ieeeAddr: string, channel: number): Promise<boolean> { return this.touchlink.factoryReset(ieeeAddr, channel); } public async touchlinkFactoryResetFirst(): Promise<boolean> { return this.touchlink.factoryResetFirst(); } public async permitJoin(permit: boolean, device?: Device): Promise<void> { if (permit && !this.getPermitJoin()) { debug.log('Permit joining'); await this.adapter.permitJoin(254, !device ? null : device.networkAddress); await this.greenPower.permitJoin(254); // Zigbee 3 networks automatically close after max 255 seconds, keep network open. this.permitJoinTimer = setInterval(async (): Promise<void> => { debug.log('Permit joining'); await this.adapter.permitJoin(254, !device ? null : device.networkAddress); await this.greenPower.permitJoin(254); }, 200 * 1000); } else if (permit && this.getPermitJoin()) { debug.log('Joining already permitted'); } else { debug.log('Disable joining'); await this.greenPower.permitJoin(0); await this.adapter.permitJoin(0, null); if (this.permitJoinTimer) { clearInterval(this.permitJoinTimer); this.permitJoinTimer = null; } } } public getPermitJoin(): boolean { return this.permitJoinTimer != null; } public async stop(): Promise<void> { this.databaseSave(); // Unregister adapter events this.adapter.removeAllListeners(AdapterEvents.Events.deviceJoined); this.adapter.removeAllListeners(AdapterEvents.Events.zclData); this.adapter.removeAllListeners(AdapterEvents.Events.rawData); this.adapter.removeAllListeners(AdapterEvents.Events.disconnected); this.adapter.removeAllListeners(AdapterEvents.Events.deviceAnnounce); this.adapter.removeAllListeners(AdapterEvents.Events.deviceLeave); await this.permitJoin(false); clearInterval(this.backupTimer); clearInterval(this.databaseSaveTimer); await this.backup(); await this.adapter.stop(); } private databaseSave(): void { for (const device of Device.all()) { device.save(); } for (const group of Group.all()) { group.save(); } } private async backup(): Promise<void> { if (this.options.backupPath && await this.adapter.supportsBackup()) { debug.log('Creating coordinator backup'); const backup = await this.adapter.backup(); fs.writeFileSync(this.options.backupPath, JSON.stringify(backup, null, 2)); debug.log(`Wrote coordinator backup to '${this.options.backupPath}'`); } } public async reset(type: 'soft' | 'hard'): Promise<void> { await this.adapter.reset(type); } public async getCoordinatorVersion(): Promise<AdapterTsType.CoordinatorVersion> { return this.adapter.getCoordinatorVersion(); } public async getNetworkParameters(): Promise<AdapterTsType.NetworkParameters> { return this.adapter.getNetworkParameters(); } /** * Get all devices */ public getDevices(): Device[] { return Device.all(); } /** * Get all devices with a specific type */ public getDevicesByType(type: DeviceType): Device[] { return Device.byType(type); } /** * Get device by ieeeAddr */ public getDeviceByIeeeAddr(ieeeAddr: string): Device { return Device.byIeeeAddr(ieeeAddr); } /** * Get device by networkAddress */ public getDeviceByNetworkAddress(networkAddress: number): Device { return Device.byNetworkAddress(networkAddress); } /** * Get group by ID */ public getGroupByID(groupID: number): Group { return Group.byGroupID(groupID); } /** * Get all groups */ public getGroups(): Group[] { return Group.all(); } /** * Create a Group */ public createGroup(groupID: number): Group { return Group.create(groupID); } /** * Check if the adapters supports LED */ public async supportsLED(): Promise<boolean> { return this.adapter.supportsLED(); } /** * Set transmit power of the adapter */ public async setTransmitPower(value: number): Promise<void> { return this.adapter.setTransmitPower(value); } /** * Enable/Disable the LED */ public async setLED(enabled: boolean): Promise<void> { if (!(await this.supportsLED())) throw new Error(`Adapter doesn't support LED`); await this.adapter.setLED(enabled); } private onNetworkAddress(payload: AdapterEvents.NetworkAddressPayload): void { debug.log(`Network address '${payload.ieeeAddr}'`); const device = Device.byIeeeAddr(payload.ieeeAddr); if (!device) { debug.log(`Network address is from unknown device '${payload.ieeeAddr}'`); return; } if (device.networkAddress !== payload.networkAddress) { debug.log(`Device '${payload.ieeeAddr}' got new networkAddress '${payload.networkAddress}'`); device.networkAddress = payload.networkAddress; device.save(); } } private onDeviceAnnounce(payload: AdapterEvents.DeviceAnnouncePayload): void { debug.log(`Device announce '${payload.ieeeAddr}'`); const device = Device.byIeeeAddr(payload.ieeeAddr); if (!device) { debug.log(`Device announce is from unknown device '${payload.ieeeAddr}'`); return; } device.updateLastSeen(); if (device.networkAddress !== payload.networkAddress) { debug.log(`Device '${payload.ieeeAddr}' announced with new networkAddress '${payload.networkAddress}'`); device.networkAddress = payload.networkAddress; device.save(); } const data: Events.DeviceAnnouncePayload = {device}; this.emit(Events.Events.deviceAnnounce, data); } private onDeviceLeave(payload: AdapterEvents.DeviceLeavePayload): void { debug.log(`Device leave '${payload.ieeeAddr}'`); const device = Device.byIeeeAddr(payload.ieeeAddr); if (device) { debug.log(`Removing device from database '${payload.ieeeAddr}'`); device.removeFromDatabase(); } const data: Events.DeviceLeavePayload = {ieeeAddr: payload.ieeeAddr}; this.emit(Events.Events.deviceLeave, data); } private async onAdapterDisconnected(): Promise<void> { debug.log(`Adapter disconnected'`); try { await this.adapter.stop(); } catch (error) { } this.emit(Events.Events.adapterDisconnected); } private async onDeviceJoinedGreenPower(payload: GreenPowerDeviceJoinedPayload): Promise<void> { debug.log(`Green power device '${JSON.stringify(payload)}' joined`); // Green power devices don't have an ieeeAddr, the sourceID is unique and static so use this. let ieeeAddr = payload.sourceID.toString(16); ieeeAddr = `0x${'0'.repeat(16 - ieeeAddr.length)}${ieeeAddr}`; // Green power devices dont' have a modelID, create a modelID based on the deviceID (=type) const modelID = `GreenPower_${payload.deviceID}`; let device = Device.byIeeeAddr(ieeeAddr); if (!device) { debug.log(`New green power device '${ieeeAddr}' joined`); debug.log(`Creating device '${ieeeAddr}'`); device = Device.create( 'GreenPower', ieeeAddr, payload.networkAddress, null, undefined, undefined, modelID, true, [], ); device.save(); const deviceJoinedPayload: Events.DeviceJoinedPayload = {device}; this.emit(Events.Events.deviceJoined, deviceJoinedPayload); const deviceInterviewPayload: Events.DeviceInterviewPayload = {status: 'successful', device}; this.emit(Events.Events.deviceInterview, deviceInterviewPayload); } } private async onDeviceJoined(payload: AdapterEvents.DeviceJoinedPayload): Promise<void> { debug.log(`Device '${payload.ieeeAddr}' joined`); if (this.options.acceptJoiningDeviceHandler) { if (!(await this.options.acceptJoiningDeviceHandler(payload.ieeeAddr))) { debug.log(`Device '${payload.ieeeAddr}' rejected by handler, removing it`); await this.adapter.removeDevice(payload.networkAddress, payload.ieeeAddr); return; } else { debug.log(`Device '${payload.ieeeAddr}' accepted by handler`); } } let device = Device.byIeeeAddr(payload.ieeeAddr); if (!device) { debug.log(`New device '${payload.ieeeAddr}' joined`); debug.log(`Creating device '${payload.ieeeAddr}'`); device = Device.create( undefined, payload.ieeeAddr, payload.networkAddress, undefined, undefined, undefined, undefined, false, [] ); const eventData: Events.DeviceJoinedPayload = {device}; this.emit(Events.Events.deviceJoined, eventData); } else if (device.networkAddress !== payload.networkAddress) { debug.log( `Device '${payload.ieeeAddr}' is already in database with different networkAddress, ` + `updating networkAddress` ); device.networkAddress = payload.networkAddress; device.save(); } device.updateLastSeen(); if (!device.interviewCompleted && !device.interviewing) { const payloadStart: Events.DeviceInterviewPayload = {status: 'started', device}; debug.log(`Interview '${device.ieeeAddr}' start`); this.emit(Events.Events.deviceInterview, payloadStart); try { await device.interview(); debug.log(`Succesfully interviewed '${device.ieeeAddr}'`); const event: Events.DeviceInterviewPayload = {status: 'successful', device}; this.emit(Events.Events.deviceInterview, event); } catch (error) { debug.error(`Interview failed for '${device.ieeeAddr} with error '${error}'`); const event: Events.DeviceInterviewPayload = {status: 'failed', device}; this.emit(Events.Events.deviceInterview, event); } } else { debug.log( `Not interviewing '${payload.ieeeAddr}', completed '${device.interviewCompleted}', ` + `in progress '${device.interviewing}'` ); } } private isZclDataPayload( dataPayload: AdapterEvents.ZclDataPayload | AdapterEvents.RawDataPayload, type: 'zcl' | 'raw' ): dataPayload is AdapterEvents.ZclDataPayload { return type === 'zcl'; } private async onZclOrRawData( dataType: 'zcl' | 'raw', dataPayload: AdapterEvents.ZclDataPayload | AdapterEvents.RawDataPayload ): Promise<void> { const logDataPayload = JSON.parse(JSON.stringify(dataPayload)); if (dataType === 'zcl') { delete logDataPayload.frame.Cluster; } debug.log(`Received '${dataType}' data '${JSON.stringify(logDataPayload)}'`); if (this.isZclDataPayload(dataPayload, dataType)) { if (dataPayload.frame.Cluster.name === 'touchlink') { // This is handled by touchlink return; } else if (dataPayload.frame.Cluster.name === 'greenPower') { this.greenPower.onZclGreenPowerData(dataPayload); } } const device = typeof dataPayload.address === 'string' ? Device.byIeeeAddr(dataPayload.address) : Device.byNetworkAddress(dataPayload.address); if (!device) { debug.log( `'${dataType}' data is from unknown device with address '${dataPayload.address}', ` + `skipping...` ); return; } device.updateLastSeen(); let endpoint = device.getEndpoint(dataPayload.endpoint); if (!endpoint) { debug.log( `'${dataType}' data is from unknown endpoint '${dataPayload.endpoint}' from device with ` + `network address '${dataPayload.address}', creating it...` ); endpoint = await device.createEndpoint(dataPayload.endpoint); } // Parse command for event let type: Events.MessagePayloadType = undefined; let data: KeyValue; let clusterName = undefined; const meta: { zclTransactionSequenceNumber?: number; manufacturerCode?: number; frameControl?: FrameControl; } = {}; if (this.isZclDataPayload(dataPayload, dataType)) { const frame = dataPayload.frame; const command = frame.getCommand(); clusterName = frame.Cluster.name; meta.zclTransactionSequenceNumber = frame.Header.transactionSequenceNumber; meta.manufacturerCode = frame.Header.manufacturerCode; meta.frameControl = frame.Header.frameControl; if (frame.isGlobal()) { if (frame.isCommand('report')) { type = 'attributeReport'; data = ZclFrameConverter.attributeKeyValue(dataPayload.frame); } else if (frame.isCommand('read')) { type = 'read'; data = ZclFrameConverter.attributeList(dataPayload.frame); } else if (frame.isCommand('write')) { type = 'write'; data = ZclFrameConverter.attributeKeyValue(dataPayload.frame); } else { /* istanbul ignore else */ if (frame.isCommand('readRsp')) { type = 'readResponse'; data = ZclFrameConverter.attributeKeyValue(dataPayload.frame); } } } else { /* istanbul ignore else */ if (frame.isSpecific()) { if (Events.CommandsLookup[command.name]) { type = Events.CommandsLookup[command.name]; data = dataPayload.frame.Payload; } else { debug.log(`Skipping command '${command.name}' because it is missing from the lookup`); } } } if (type === 'readResponse' || type === 'attributeReport') { // Some device report, e.g. it's modelID through a readResponse or attributeReport for (const [key, value] of Object.entries(data)) { const property = Device.ReportablePropertiesMapping[key]; if (property && !device[property.key]) { property.set(value, device); } } endpoint.saveClusterAttributeKeyValue(clusterName, data); } } else { type = 'raw'; data = dataPayload.data; try { const cluster = ZclUtils.getCluster(dataPayload.clusterID); clusterName = cluster.name; } catch (error) { clusterName = dataPayload.clusterID; } } if (type && data) { const endpoint = device.getEndpoint(dataPayload.endpoint); const linkquality = dataPayload.linkquality; const groupID = dataPayload.groupID; const eventData: Events.MessagePayload = { type: type, device, endpoint, data, linkquality, groupID, cluster: clusterName, meta }; this.emit(Events.Events.message, eventData); } if (this.isZclDataPayload(dataPayload, dataType)) { device.onZclData(dataPayload, endpoint); } } } export default Controller;