UNPKG

zigbee-herdsman-zigate

Version:

An open source ZigBee gateway solution with node.js.

560 lines (492 loc) 24.6 kB
import {KeyValue, DatabaseEntry, DeviceType} from '../tstype'; import {Events as AdapterEvents} from '../../adapter'; import Endpoint from './endpoint'; import Entity from './entity'; import {Wait} from '../../utils'; import Debug from "debug"; import * as Zcl from '../../zcl'; /** * @ignore */ const OneJanuary2000 = new Date('January 01, 2000 00:00:00 UTC+00:00').getTime(); const debug = { error: Debug('zigbee-herdsman:controller:device:error'), log: Debug('zigbee-herdsman:controller:device:log'), }; interface LQI { neighbors: { ieeeAddr: string; networkAddress: number; linkquality: number; relationship: number; depth: number; }[]; } interface RoutingTable { table: {destinationAddress: number; status: string; nextHop: number}[]; } class Device extends Entity { private readonly ID: number; private _applicationVersion?: number; private _dateCode?: string; private _endpoints: Endpoint[]; private _hardwareVersion?: number; private _ieeeAddr: string; private _interviewCompleted: boolean; private _interviewing: boolean; private _lastSeen: number; private _manufacturerID?: number; private _manufacturerName?: string; private _modelID?: string; private _networkAddress: number; private _powerSource?: string; private _softwareBuildID?: string; private _stackVersion?: number; private _type?: DeviceType; private _zclVersion?: number; // Getters/setters get ieeeAddr(): string {return this._ieeeAddr;} set ieeeAddr(ieeeAddr: string) {this._ieeeAddr = ieeeAddr;} get applicationVersion(): number {return this._applicationVersion;} set applicationVersion(applicationVersion: number) {this._applicationVersion = applicationVersion;} get endpoints(): Endpoint[] {return this._endpoints;} get interviewCompleted(): boolean {return this._interviewCompleted;} get interviewing(): boolean {return this._interviewing;} get lastSeen(): number {return this._lastSeen;} get manufacturerID(): number {return this._manufacturerID;} set type(type: DeviceType) {this._type = type;} get type(): DeviceType {return this._type;} get dateCode(): string {return this._dateCode;} set dateCode(dateCode: string) {this._dateCode = dateCode;} set hardwareVersion(hardwareVersion: number) {this._hardwareVersion = hardwareVersion;} get hardwareVersion(): number {return this._hardwareVersion;} get manufacturerName(): string {return this._manufacturerName;} set manufacturerName(manufacturerName: string) {this._manufacturerName = manufacturerName;} set modelID(modelID: string) {this._modelID = modelID;} get modelID(): string {return this._modelID;} get networkAddress(): number {return this._networkAddress;} set networkAddress(networkAddress: number) { this._networkAddress = networkAddress; for (const endpoint of this._endpoints) { endpoint.deviceNetworkAddress = networkAddress; } } get powerSource(): string {return this._powerSource;} set powerSource(powerSource: string) { this._powerSource = typeof powerSource === 'number' ? Zcl.PowerSource[powerSource] : powerSource; } get softwareBuildID(): string {return this._softwareBuildID;} set softwareBuildID(softwareBuildID: string) {this._softwareBuildID = softwareBuildID;} get stackVersion(): number {return this._stackVersion;} set stackVersion(stackVersion: number) {this._stackVersion = stackVersion;} get zclVersion(): number {return this._zclVersion;} set zclVersion(zclVersion: number) {this._zclVersion = zclVersion;} private meta: KeyValue; // This lookup contains all devices that are queried from the database, this is to ensure that always // the same instance is returned. private static devices: {[ieeeAddr: string]: Device} = null; public static readonly ReportablePropertiesMapping: {[s: string]: { set: (value: string | number, device: Device) => void; key: 'modelID' | 'manufacturerName' | 'applicationVersion' | 'zclVersion' | 'powerSource' | 'stackVersion' | 'dateCode' | 'softwareBuildID' | 'hardwareVersion'; };} = { modelId: {key: 'modelID', set: (v: string, d: Device): void => {d.modelID = v;}}, manufacturerName: {key: 'manufacturerName', set: (v: string, d: Device): void => {d.manufacturerName = v;}}, powerSource: {key: 'powerSource', set: (v: string, d: Device): void => {d.powerSource = v;}}, zclVersion: {key: 'zclVersion', set: (v: number, d: Device): void => {d.zclVersion = v;}}, appVersion: {key: 'applicationVersion', set: (v: number, d: Device): void => {d.applicationVersion = v;}}, stackVersion: {key: 'stackVersion', set: (v: number, d: Device): void => {d.stackVersion = v;}}, hwVersion: {key: 'hardwareVersion', set: (v: number, d: Device): void => {d.hardwareVersion = v;}}, dateCode: {key: 'dateCode', set: (v: string, d: Device): void => {d.dateCode = v;}}, swBuildId: {key: 'softwareBuildID', set: (v: string, d: Device): void => {d.softwareBuildID = v;}}, }; private constructor( ID: number, type: DeviceType, ieeeAddr: string, networkAddress: number, manufacturerID: number, endpoints: Endpoint[], manufacturerName: string, powerSource: string, modelID: string, applicationVersion: number, stackVersion: number, zclVersion: number, hardwareVersion: number, dateCode: string, softwareBuildID: string, interviewCompleted: boolean, meta: KeyValue, lastSeen: number, ) { super(); this.ID = ID; this._type = type; this.ieeeAddr = ieeeAddr; this._networkAddress = networkAddress; this._manufacturerID = manufacturerID; this._endpoints = endpoints; this._manufacturerName = manufacturerName; this._powerSource = powerSource; this._modelID = modelID; this._applicationVersion = applicationVersion; this._stackVersion = stackVersion; this._zclVersion = zclVersion; this.hardwareVersion = hardwareVersion; this._dateCode = dateCode; this._softwareBuildID = softwareBuildID; this._interviewCompleted = interviewCompleted; this._interviewing = false; this.meta = meta; this._lastSeen = lastSeen; } public async createEndpoint(ID: number): Promise<Endpoint> { if (this.getEndpoint(ID)) { throw new Error(`Device '${this.ieeeAddr}' already has an endpoint '${ID}'`); } const endpoint = Endpoint.create(ID, undefined, undefined, [], [], this.networkAddress, this.ieeeAddr); this.endpoints.push(endpoint); this.save(); return endpoint; } public getEndpoint(ID: number): Endpoint { return this.endpoints.find((e): boolean => e.ID === ID); } // There might be multiple endpoints with same DeviceId but it is not supported and first endpoint is returned public getEndpointByDeviceType(deviceType: string): Endpoint { const deviceID = Zcl.EndpointDeviceType[deviceType]; return this.endpoints.find((d): boolean => d.deviceID === deviceID); } public updateLastSeen(): void { this._lastSeen = Date.now(); } public async onZclData(dataPayload: AdapterEvents.ZclDataPayload, endpoint: Endpoint): Promise<void> { const frame = dataPayload.frame; // Respond to enroll requests if (frame.isSpecific() && frame.isCluster('ssIasZone') && frame.isCommand('enrollReq')) { debug.log(`IAS - '${this.ieeeAddr}' responding to enroll response`); const payload = {enrollrspcode: 0, zoneid: 23}; await endpoint.command('ssIasZone', 'enrollRsp', payload, {disableDefaultResponse: true}); } // Reponse to genTime reads if (frame.isGlobal() && frame.isCluster('genTime') && frame.isCommand('read')) { const time = Math.round(((new Date()).getTime() - OneJanuary2000) / 1000); const response: KeyValue = {}; const values: KeyValue = { timeStatus: 3, // Time-master + synchronised time: time, timeZone: ((new Date()).getTimezoneOffset() * -1) * 60, localTime: time - (new Date()).getTimezoneOffset() * 60, }; const cluster = Zcl.Utils.getCluster('genTime'); for (const entry of frame.Payload) { const name = cluster.getAttribute(entry.attrId).name; if (values.hasOwnProperty(name)) { response[name] = values[name]; } else { debug.error(`'${this.ieeeAddr}' read unsupported attribute from genTime '${name}'`); } } try { await endpoint.readResponse(frame.Cluster.ID, frame.Header.transactionSequenceNumber, response); } catch (error) { debug.error(`genTime response to ${this.ieeeAddr} failed`); } } // Send a default response if necessary. const isDefaultResponse = frame.isGlobal() && frame.getCommand().name === 'defaultRsp'; const commandHasResponse = frame.getCommand().hasOwnProperty('response'); if (!frame.Header.frameControl.disableDefaultResponse && !isDefaultResponse && !commandHasResponse) { try { await endpoint.defaultResponse( frame.getCommand().ID, 0, frame.Cluster.ID, frame.Header.transactionSequenceNumber, ); } catch (error) { debug.error(`Default response to ${this.ieeeAddr} failed`); } } } /* * CRUD */ private static fromDatabaseEntry(entry: DatabaseEntry): Device { const networkAddress = entry.nwkAddr; const ieeeAddr = entry.ieeeAddr; const endpoints = Object.values(entry.endpoints).map((e): Endpoint => { return Endpoint.fromDatabaseRecord(e, networkAddress, ieeeAddr); }); const meta = entry.meta ? entry.meta : {}; if (entry.type === 'Group') { throw new Error('Cannot load device from group'); } return new Device( entry.id, entry.type, ieeeAddr, networkAddress, entry.manufId, endpoints, entry.manufName, entry.powerSource, entry.modelId, entry.appVersion, entry.stackVersion, entry.zclVersion, entry.hwVersion, entry.dateCode, entry.swBuildId, entry.interviewCompleted, meta, entry.lastSeen || null, ); } private toDatabaseEntry(): DatabaseEntry { const epList = this.endpoints.map((e): number => e.ID); const endpoints: KeyValue = {}; for (const endpoint of this.endpoints) { endpoints[endpoint.ID] = endpoint.toDatabaseRecord(); } return { id: this.ID, type: this.type, ieeeAddr: this.ieeeAddr, nwkAddr: this.networkAddress, manufId: this.manufacturerID, manufName: this.manufacturerName, powerSource: this.powerSource, modelId: this.modelID, epList, endpoints, appVersion: this.applicationVersion, stackVersion: this.stackVersion, hwVersion: this.hardwareVersion, dateCode: this.dateCode, swBuildId: this.softwareBuildID, zclVersion: this.zclVersion, interviewCompleted: this.interviewCompleted, meta: this.meta, lastSeen: this.lastSeen, }; } public save(): void { Entity.database.update(this.toDatabaseEntry()); } private static loadFromDatabaseIfNecessary(): void { if (!Device.devices) { Device.devices = {}; const entries = Entity.database.getEntries(['Coordinator', 'EndDevice', 'Router', 'GreenPower']); for (const entry of entries) { const device = Device.fromDatabaseEntry(entry); Device.devices[device.ieeeAddr] = device; } } } public static byIeeeAddr(ieeeAddr: string): Device { Device.loadFromDatabaseIfNecessary(); return Device.devices[ieeeAddr]; } public static byNetworkAddress(networkAddress: number): Device { Device.loadFromDatabaseIfNecessary(); return Object.values(Device.devices).find(d => d.networkAddress === networkAddress); } public static byType(type: DeviceType): Device[] { Device.loadFromDatabaseIfNecessary(); return Object.values(Device.devices).filter(d => d.type === type); } public static all(): Device[] { Device.loadFromDatabaseIfNecessary(); return Object.values(Device.devices); } public static create( type: DeviceType, ieeeAddr: string, networkAddress: number, manufacturerID: number, manufacturerName: string, powerSource: string, modelID: string, interviewCompleted: boolean, endpoints: { ID: number; profileID: number; deviceID: number; inputClusters: number[]; outputClusters: number[]; }[], ): Device { Device.loadFromDatabaseIfNecessary(); if (Device.devices[ieeeAddr]) { throw new Error(`Device with ieeeAddr '${ieeeAddr}' already exists`); } const endpointsMapped = endpoints.map((e): Endpoint => { return Endpoint.create( e.ID, e.profileID, e.deviceID, e.inputClusters, e.outputClusters, networkAddress, ieeeAddr ); }); const ID = Entity.database.newID(); const device = new Device( ID, type, ieeeAddr, networkAddress, manufacturerID, endpointsMapped, manufacturerName, powerSource, modelID, undefined, undefined, undefined, undefined, undefined, undefined, interviewCompleted, {}, null, ); Entity.database.insert(device.toDatabaseEntry()); Device.devices[device.ieeeAddr] = device; return device; } /* * Zigbee functions */ public async interview(): Promise<void> { if (this.interviewing) { const message = `Interview - interview already in progress for '${this.ieeeAddr}'`; debug.log(message); throw new Error(message); } let error; this._interviewing = true; debug.log(`Interview - start device '${this.ieeeAddr}'`); try { await this.interviewInternal(); debug.log(`Interview - completed for device '${this.ieeeAddr}'`); this._interviewCompleted = true; } catch (e) { if (this.interviewQuirks()) { debug.log(`Interview - completed for device '${this.ieeeAddr}' because of quirks ('${e}')`); } else { debug.log(`Interview - failed for device '${this.ieeeAddr}' with error '${e.stack}'`); error = e; } } finally { this._interviewing = false; this.save(); } if (error) { throw error; } } private interviewQuirks(): boolean { // Some devices, e.g. Xiaomi end devices have a different interview procedure, after pairing they // report it's modelID trough a readResponse. The readResponse is received by the controller and set // on the device. const lookup: {[s: string]: { type: DeviceType; manufacturerID: number; manufacturerName: string; powerSource: string; };} = { 'lumi\..*': { type: 'EndDevice', manufacturerID: 4151, manufacturerName: 'LUMI', powerSource: 'Battery' }, 'TERNCY-PP01': { type: 'EndDevice', manufacturerID: 4648, manufacturerName: 'TERNCY', powerSource: 'Battery' }, }; const match = Object.keys(lookup).find((key) => this.modelID && this.modelID.match(key)); if (match) { const info = lookup[match]; debug.log(`Interview procedure failed but got modelID matching '${match}', assuming interview succeeded`); this._type = this._type || info.type; this._manufacturerID = this._manufacturerID || info.manufacturerID; this._manufacturerName = this._manufacturerName || info.manufacturerName; this._powerSource = this._powerSource || info.powerSource; this._interviewing = false; this._interviewCompleted = true; this.save(); return true; } else { return false; } } private async interviewInternal(): Promise<void> { const nodeDescriptorQuery = async (): Promise<void> => { const nodeDescriptor = await Entity.adapter.nodeDescriptor(this.networkAddress); this._manufacturerID = nodeDescriptor.manufacturerCode; this._type = nodeDescriptor.type; this.save(); debug.log(`Interview - got node descriptor for device '${this.ieeeAddr}'`); }; let gotNodeDescriptor = false; for (let attempt = 0; attempt < 6; attempt++) { try { await nodeDescriptorQuery(); gotNodeDescriptor = true; break; } catch (error) { if (this.interviewQuirks()) { debug.log(`Interview - completed for device '${this.ieeeAddr}' because of quirks ('${error}')`); return; } else { // Most of the times the first node descriptor query fails and the seconds one succeeds. debug.log( `Interview - node descriptor request failed for '${this.ieeeAddr}', attempt ${attempt + 1}` ); } } } if (!gotNodeDescriptor) { throw new Error(`Interview failed because can not get node descriptor ('${this.ieeeAddr}')`); } // e.g. Xiaomi Aqara Opple devices fail to respond to the first active endpoints request, therefore try 2 times // https://github.com/Koenkk/zigbee-herdsman/pull/103 let activeEndpoints; for (let attempt = 0; attempt < 2; attempt++) { try { activeEndpoints = await Entity.adapter.activeEndpoints(this.networkAddress); break; } catch (error) { debug.log(`Interview - active endpoints request failed for '${this.ieeeAddr}', attempt ${attempt + 1}`); } } if (!activeEndpoints) { throw new Error(`Interview failed because can not get active endpoints ('${this.ieeeAddr}')`); } // Make sure that the endpoint are sorted. activeEndpoints.endpoints.sort(); // Some devices, e.g. TERNCY return endpoint 0 in the active endpoints request. // This is not a valid endpoint number according to the ZCL, requesting a simple descriptor will result // into an error. Therefore we filter it, more info: https://github.com/Koenkk/zigbee-herdsman/issues/82 this._endpoints = activeEndpoints.endpoints.filter((e) => e !== 0).map((e): Endpoint => { return Endpoint.create(e, undefined, undefined, [], [], this.networkAddress, this.ieeeAddr); }); this.save(); debug.log(`Interview - got active endpoints for device '${this.ieeeAddr}'`); debug.log(this.endpoints); for (const endpoint of this.endpoints) { const simpleDescriptor = await Entity.adapter.simpleDescriptor(this.networkAddress, endpoint.ID); endpoint.profileID = simpleDescriptor.profileID; endpoint.deviceID = simpleDescriptor.deviceID; endpoint.inputClusters = simpleDescriptor.inputClusters; endpoint.outputClusters = simpleDescriptor.outputClusters; debug.log(`Interview - got simple descriptor for endpoint '${endpoint.ID}' device '${this.ieeeAddr}'`); this.save(); // Read attributes, nice to have but not required for succesfull pairing as most of the attributes // are not mandatory in ZCL specification. if (endpoint.supportsInputCluster('genBasic')) { for (const [key, item] of Object.entries(Device.ReportablePropertiesMapping)) { if (!this[item.key]) { try { const result = await endpoint.read('genBasic', [key]); item.set(result[key], this); debug.log(`Interview - got '${item.key}' for device '${this.ieeeAddr}'`); } catch (error) { debug.log( `Failed to read attribute '${item.key}' from endpoint '${endpoint.ID}' (${error})` ); } } } } } // Enroll IAS device for (const endpoint of this.endpoints.filter((e): boolean => e.supportsInputCluster('ssIasZone'))) { debug.log(`Interview - IAS - enrolling '${this.ieeeAddr}' endpoint '${endpoint.ID}'`); const stateBefore = await endpoint.read('ssIasZone', ['iasCieAddr', 'zoneState']); debug.log(`Interview - IAS - before enrolling state: '${JSON.stringify(stateBefore)}'`); // Do not enroll when device has already been enrolled const coordinator = Device.byType('Coordinator')[0]; if (stateBefore.zoneState !== 1 || stateBefore.iasCieAddr !== coordinator.ieeeAddr) { debug.log(`Interview - IAS - not enrolled, enrolling`); await endpoint.write('ssIasZone', {'iasCieAddr': coordinator.ieeeAddr}); debug.log(`Interview - IAS - wrote iasCieAddr`); // According to the ZCL, after the iasCieAddr is written, the device will do an // enroll request. This should be responded with an enroll response. // As some devices send these enroll requests randomly (while the iasCieAddr has not been written yet) // we always respond to these enroll requests in the onZclData() function. // Therefore we don't have to do it here anymore. // https://github.com/Koenkk/zigbee2mqtt/issues/3012 let enrolled = false; for (let attempt = 0; attempt < 20; attempt++) { await Wait(500); const stateAfter = await endpoint.read('ssIasZone', ['iasCieAddr', 'zoneState']); debug.log(`Interview - IAS - after enrolling state (${attempt}): '${JSON.stringify(stateAfter)}'`); if (stateAfter.zoneState === 1) { enrolled = true; break; } } if (enrolled) { debug.log(`Interview - IAS successfully enrolled '${this.ieeeAddr}' endpoint '${endpoint.ID}'`); } else { throw new Error( `Interview failed because of failed IAS enroll (zoneState didn't change ('${this.ieeeAddr}')` ); } } else { debug.log(`Interview - IAS - already enrolled, skipping enroll`); } } } public async removeFromNetwork(): Promise<void> { await Entity.adapter.removeDevice(this.networkAddress, this.ieeeAddr); await this.removeFromDatabase(); } public async removeFromDatabase(): Promise<void> { Device.loadFromDatabaseIfNecessary(); for (const endpoint of this.endpoints) { endpoint.removeFromAllGroupsDatabase(); } if (Entity.database.has(this.ID)) { Entity.database.remove(this.ID); } delete Device.devices[this.ieeeAddr]; } public async lqi(): Promise<LQI> { return Entity.adapter.lqi(this.networkAddress); } public async routingTable(): Promise<RoutingTable> { return Entity.adapter.routingTable(this.networkAddress); } public async ping(): Promise<void> { // Zigbee does not have an official pining mechamism. Use a read request // of a mandatory basic cluster attribute to keep it as lightweight as // possible. await this.endpoints[0].read('genBasic', ['zclVersion'], {disableRecovery: true}); } } export default Device;