UNPKG

zigbee-herdsman

Version:

An open source ZigBee gateway solution with node.js.

1,185 lines (1,052 loc) 55.6 kB
import assert from "node:assert"; import type {Events as AdapterEvents} from "../../adapter"; import type {LQINeighbor, RoutingTableEntry} from "../../adapter/tstype"; import {wait} from "../../utils"; import {logger} from "../../utils/logger"; import * as ZSpec from "../../zspec"; import {BroadcastAddress} from "../../zspec/enums"; import type {Eui64} from "../../zspec/tstypes"; import * as Zcl from "../../zspec/zcl"; import type {ClusterDefinition, CustomClusters} from "../../zspec/zcl/definition/tstype"; import * as Zdo from "../../zspec/zdo"; import type {ControllerEventMap} from "../controller"; import {ZclFrameConverter} from "../helpers"; import zclTransactionSequenceNumber from "../helpers/zclTransactionSequenceNumber"; import type {DatabaseEntry, DeviceType, KeyValue} from "../tstype"; import Endpoint from "./endpoint"; import Entity from "./entity"; /** * @ignore */ const OneJanuary2000 = new Date("January 01, 2000 00:00:00 UTC+00:00").getTime(); const NS = "zh:controller:device"; interface Lqi { neighbors: { ieeeAddr: string; networkAddress: number; linkquality: number; relationship: number; depth: number; }[]; } interface RoutingTable { table: {destinationAddress: number; status: string; nextHop: number}[]; } type CustomReadResponse = (frame: Zcl.Frame, endpoint: Endpoint) => boolean; export enum InterviewState { Pending = "PENDING", InProgress = "IN_PROGRESS", Successful = "SUCCESSFUL", Failed = "FAILED", } export class Device extends Entity<ControllerEventMap> { // biome-ignore lint/style/useNamingConvention: cross-repo impact private readonly ID: number; private _applicationVersion?: number; private _dateCode?: string; private _endpoints: Endpoint[]; private _hardwareVersion?: number; private _ieeeAddr: string; private _interviewState: InterviewState; 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; private _linkquality?: number; private _skipDefaultResponse: boolean; private _customReadResponse?: CustomReadResponse; private _lastDefaultResponseSequenceNumber?: number; private _checkinInterval?: number; private _pendingRequestTimeout: number; private _customClusters: CustomClusters = {}; private _gpSecurityKey?: number[]; // Getters/setters get ieeeAddr(): string { return this._ieeeAddr; } set ieeeAddr(ieeeAddr: string) { this._ieeeAddr = ieeeAddr; } get applicationVersion(): number | undefined { return this._applicationVersion; } set applicationVersion(applicationVersion: number) { this._applicationVersion = applicationVersion; } get endpoints(): Endpoint[] { return this._endpoints; } get interviewState(): InterviewState { return this._interviewState; } get lastSeen(): number | undefined { return this._lastSeen; } get manufacturerID(): number | undefined { return this._manufacturerID; } get isDeleted(): boolean { return Device.deletedDevices.has(this.ieeeAddr); } set type(type: DeviceType) { this._type = type; } get type(): DeviceType { return this._type; } get dateCode(): string | undefined { return this._dateCode; } set dateCode(dateCode: string) { this._dateCode = dateCode; } set hardwareVersion(hardwareVersion: number) { this._hardwareVersion = hardwareVersion; } get hardwareVersion(): number | undefined { return this._hardwareVersion; } get manufacturerName(): string | undefined { return this._manufacturerName; } set manufacturerName(manufacturerName: string | undefined) { this._manufacturerName = manufacturerName; } set modelID(modelID: string) { this._modelID = modelID; } get modelID(): string | undefined { return this._modelID; } get networkAddress(): number { return this._networkAddress; } set networkAddress(networkAddress: number) { Device.nwkToIeeeCache.delete(this._networkAddress); this._networkAddress = networkAddress; Device.nwkToIeeeCache.set(this._networkAddress, this.ieeeAddr); for (const endpoint of this._endpoints) { endpoint.deviceNetworkAddress = networkAddress; } } get powerSource(): string | undefined { return this._powerSource; } set powerSource(powerSource: string) { this._powerSource = typeof powerSource === "number" ? Zcl.POWER_SOURCES[powerSource & ~(1 << 7)] : powerSource; } get softwareBuildID(): string | undefined { return this._softwareBuildID; } set softwareBuildID(softwareBuildID: string) { this._softwareBuildID = softwareBuildID; } get stackVersion(): number | undefined { return this._stackVersion; } set stackVersion(stackVersion: number) { this._stackVersion = stackVersion; } get zclVersion(): number | undefined { return this._zclVersion; } set zclVersion(zclVersion: number) { this._zclVersion = zclVersion; } get linkquality(): number | undefined { return this._linkquality; } set linkquality(linkquality: number) { this._linkquality = linkquality; } get skipDefaultResponse(): boolean { return this._skipDefaultResponse; } set skipDefaultResponse(skipDefaultResponse: boolean) { this._skipDefaultResponse = skipDefaultResponse; } get customReadResponse(): CustomReadResponse | undefined { return this._customReadResponse; } set customReadResponse(customReadResponse: CustomReadResponse | undefined) { this._customReadResponse = customReadResponse; } get checkinInterval(): number | undefined { return this._checkinInterval; } set checkinInterval(checkinInterval: number | undefined) { this._checkinInterval = checkinInterval; this.resetPendingRequestTimeout(); } get pendingRequestTimeout(): number { return this._pendingRequestTimeout; } set pendingRequestTimeout(pendingRequestTimeout: number) { this._pendingRequestTimeout = pendingRequestTimeout; } get customClusters(): CustomClusters { return this._customClusters; } get gpSecurityKey(): number[] | undefined { return this._gpSecurityKey; } public 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 readonly devices: Map<string /* IEEE */, Device> = new Map(); private static loadedFromDatabase = false; private static readonly deletedDevices: Map<string /* IEEE */, Device> = new Map(); private static readonly nwkToIeeeCache: Map<number /* nwk addr */, string /* IEEE */> = new Map(); public static readonly REPORTABLE_PROPERTIES_MAPPING: { [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 | number, d: Device): void => { d.modelID = v as string; }, }, manufacturerName: { key: "manufacturerName", set: (v: string | number, d: Device): void => { d.manufacturerName = v as string; }, }, powerSource: { key: "powerSource", set: (v: string | number, d: Device): void => { d.powerSource = v as string; }, }, zclVersion: { key: "zclVersion", set: (v: string | number, d: Device): void => { d.zclVersion = v as number; }, }, appVersion: { key: "applicationVersion", set: (v: string | number, d: Device): void => { d.applicationVersion = v as number; }, }, stackVersion: { key: "stackVersion", set: (v: string | number, d: Device): void => { d.stackVersion = v as number; }, }, hwVersion: { key: "hardwareVersion", set: (v: string | number, d: Device): void => { d.hardwareVersion = v as number; }, }, dateCode: { key: "dateCode", set: (v: string | number, d: Device): void => { d.dateCode = v as string; }, }, swBuildId: { key: "softwareBuildID", set: (v: string | number, d: Device): void => { d.softwareBuildID = v as string; }, }, }; private constructor( id: number, type: DeviceType, ieeeAddr: string, networkAddress: number, manufacturerID: number | undefined, endpoints: Endpoint[], manufacturerName: string | undefined, powerSource: string | undefined, modelID: string | undefined, applicationVersion: number | undefined, stackVersion: number | undefined, zclVersion: number | undefined, hardwareVersion: number | undefined, dateCode: string | undefined, softwareBuildID: string | undefined, interviewState: InterviewState, meta: KeyValue, lastSeen: number | undefined, checkinInterval: number | undefined, pendingRequestTimeout: number, gpSecurityKey: number[] | undefined, ) { 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._interviewState = interviewState; this._skipDefaultResponse = false; this.meta = meta; this._lastSeen = lastSeen; this._checkinInterval = checkinInterval; this._pendingRequestTimeout = pendingRequestTimeout; this._gpSecurityKey = gpSecurityKey; } public createEndpoint(id: number): 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 changeIeeeAddress(ieeeAddr: string): void { Device.devices.delete(this.ieeeAddr); this.ieeeAddr = ieeeAddr; Device.devices.set(this.ieeeAddr, this); Device.nwkToIeeeCache.set(this.networkAddress, this.ieeeAddr); for (const ep of this.endpoints) { ep.deviceIeeeAddress = ieeeAddr; } this.save(); } public getEndpoint(id: number): Endpoint | undefined { 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 | undefined { const deviceID = Zcl.ENDPOINT_DEVICE_TYPE[deviceType]; return this.endpoints.find((d): boolean => d.deviceID === deviceID); } public implicitCheckin(): void { // No need to do anythign in `catch` as `endpoint.sendRequest` already logs failures. Promise.allSettled(this.endpoints.map((e) => e.sendPendingRequests(false))).catch(() => {}); } public updateLastSeen(): void { this._lastSeen = Date.now(); } private resetPendingRequestTimeout(): void { // pendingRequestTimeout can be changed dynamically at runtime, and it is not persisted. // Default timeout is one checkin interval in milliseconds. this._pendingRequestTimeout = (this._checkinInterval ?? 0) * 1000; } private hasPendingRequests(): boolean { return this.endpoints.find((e) => e.hasPendingRequests()) !== undefined; } public async onZclData(dataPayload: AdapterEvents.ZclPayload, frame: Zcl.Frame, endpoint: Endpoint): Promise<void> { // Update reportable properties if (frame.isCluster("genBasic") && (frame.isCommand("readRsp") || frame.isCommand("report"))) { const attrKeyValue = ZclFrameConverter.attributeKeyValue(frame, this.manufacturerID, this.customClusters); for (const key in attrKeyValue) { Device.REPORTABLE_PROPERTIES_MAPPING[key]?.set(attrKeyValue[key], this); } } // Respond to enroll requests if (frame.header.isSpecific && frame.isCluster("ssIasZone") && frame.isCommand("enrollReq")) { logger.debug(`IAS - '${this.ieeeAddr}' responding to enroll response`, NS); const payload = {enrollrspcode: 0, zoneid: 23}; await endpoint.command("ssIasZone", "enrollRsp", payload, {disableDefaultResponse: true}); } // Reponse to read requests if (frame.header.isGlobal && frame.isCommand("read") && !this._customReadResponse?.(frame, endpoint)) { const time = Math.round((new Date().getTime() - OneJanuary2000) / 1000); const attributes: {[s: string]: KeyValue} = { ...endpoint.clusters, genTime: { attributes: { timeStatus: 3, // Time-master + synchronised time: time, timeZone: new Date().getTimezoneOffset() * -1 * 60, localTime: time - new Date().getTimezoneOffset() * 60, lastSetTime: time, validUntilTime: time + 24 * 60 * 60, // valid for 24 hours }, }, }; if (frame.cluster.name in attributes) { const response: KeyValue = {}; for (const entry of frame.payload) { if (frame.cluster.hasAttribute(entry.attrId)) { const name = frame.cluster.getAttribute(entry.attrId).name; if (name in attributes[frame.cluster.name].attributes) { response[name] = attributes[frame.cluster.name].attributes[name]; } } } try { await endpoint.readResponse(frame.cluster.ID, frame.header.transactionSequenceNumber, response, { srcEndpoint: dataPayload.destinationEndpoint, }); } catch (error) { logger.error(`Read response to ${this.ieeeAddr} failed (${(error as Error).message})`, NS); } } } // Handle check-in from sleeping end devices if (frame.header.isSpecific && frame.isCluster("genPollCtrl") && frame.isCommand("checkin")) { try { if (this.hasPendingRequests() || this._checkinInterval === undefined) { const payload = { startFastPolling: true, fastPollTimeout: 0, }; logger.debug(`check-in from ${this.ieeeAddr}: accepting fast-poll`, NS); await endpoint.command(frame.cluster.ID, "checkinRsp", payload, {sendPolicy: "immediate"}); // This is a good time to read the checkin interval if we haven't stored it previously if (this._checkinInterval === undefined) { const pollPeriod = await endpoint.read("genPollCtrl", ["checkinInterval"], {sendPolicy: "immediate"}); this._checkinInterval = pollPeriod.checkinInterval / 4; // convert to seconds this.resetPendingRequestTimeout(); logger.debug(`Request Queue (${this.ieeeAddr}): default expiration timeout set to ${this.pendingRequestTimeout}`, NS); } await Promise.all(this.endpoints.map(async (e) => await e.sendPendingRequests(true))); // We *must* end fast-poll when we're done sending things. Otherwise // we cause undue power-drain. logger.debug(`check-in from ${this.ieeeAddr}: stopping fast-poll`, NS); await endpoint.command(frame.cluster.ID, "fastPollStop", {}, {sendPolicy: "immediate"}); } else { const payload = { startFastPolling: false, fastPollTimeout: 0, }; logger.debug(`check-in from ${this.ieeeAddr}: declining fast-poll`, NS); await endpoint.command(frame.cluster.ID, "checkinRsp", payload, {sendPolicy: "immediate"}); } } catch (error) { logger.error(`Handling of poll check-in from ${this.ieeeAddr} failed (${(error as Error).message})`, NS); } } // Send a default response if necessary. const isDefaultResponse = frame.header.isGlobal && frame.command.name === "defaultRsp"; const commandHasResponse = frame.command.response !== undefined; const disableDefaultResponse = frame.header.frameControl.disableDefaultResponse; /* v8 ignore next */ const disableTuyaDefaultResponse = endpoint.getDevice().manufacturerName?.startsWith("_TZ") && process.env.DISABLE_TUYA_DEFAULT_RESPONSE; // Sometimes messages are received twice, prevent responding twice const alreadyResponded = this._lastDefaultResponseSequenceNumber === frame.header.transactionSequenceNumber; if ( this.type !== "GreenPower" && !dataPayload.wasBroadcast && !disableDefaultResponse && !isDefaultResponse && !commandHasResponse && !this._skipDefaultResponse && !alreadyResponded && !disableTuyaDefaultResponse ) { try { this._lastDefaultResponseSequenceNumber = frame.header.transactionSequenceNumber; // In the ZCL it is not documented what the direction of the default response should be // In https://github.com/Koenkk/zigbee2mqtt/issues/18096 a commandResponse (SERVER_TO_CLIENT) // is send and the device expects a CLIENT_TO_SERVER back. // Previously SERVER_TO_CLIENT was always used. // Therefore for non-global commands we inverse the direction. const direction = frame.header.isGlobal ? Zcl.Direction.SERVER_TO_CLIENT : frame.header.frameControl.direction === Zcl.Direction.CLIENT_TO_SERVER ? Zcl.Direction.SERVER_TO_CLIENT : Zcl.Direction.CLIENT_TO_SERVER; await endpoint.defaultResponse(frame.command.ID, 0, frame.cluster.ID, frame.header.transactionSequenceNumber, {direction}); } catch (error) { logger.debug(`Default response to ${this.ieeeAddr} failed (${error})`, NS); } } } /* * CRUD */ /** * Reset runtime lookups. */ public static resetCache(): void { Device.devices.clear(); Device.loadedFromDatabase = false; Device.deletedDevices.clear(); Device.nwkToIeeeCache.clear(); } private static fromDatabaseEntry(entry: DatabaseEntry): Device { const networkAddress = entry.nwkAddr; const ieeeAddr = entry.ieeeAddr; const endpoints: Endpoint[] = []; for (const id in entry.endpoints) { endpoints.push(Endpoint.fromDatabaseRecord(entry.endpoints[id], networkAddress, ieeeAddr)); } const meta = entry.meta ?? {}; if (entry.type === "Group") { throw new Error("Cannot load device from group"); } // default: no timeout (messages expire immediately after first send attempt) let pendingRequestTimeout = 0; if (endpoints.filter((e): boolean => e.inputClusters.includes(Zcl.Clusters.genPollCtrl.ID)).length > 0) { // default for devices that support genPollCtrl cluster (RX off when idle): 1 day pendingRequestTimeout = 86400000; } // always load value from database available (modernExtend.quirkCheckinInterval() exists for devices without genPollCtl) if (entry.checkinInterval !== undefined) { // if the checkin interval is known, messages expire by default after one checkin interval pendingRequestTimeout = entry.checkinInterval * 1000; // milliseconds } logger.debug(`Request Queue (${ieeeAddr}): default expiration timeout set to ${pendingRequestTimeout}`, NS); // Migrate interviewCompleted to interviewState if (!entry.interviewState) { entry.interviewState = entry.interviewCompleted ? InterviewState.Successful : InterviewState.Failed; logger.debug(`Migrated interviewState for '${ieeeAddr}': ${entry.interviewCompleted} -> ${entry.interviewState}`, NS); } 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.interviewState, meta, entry.lastSeen, entry.checkinInterval, pendingRequestTimeout, entry.gpSecurityKey, ); } 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, /** @deprecated Keep interviewCompleted for backwards compatibility (in case zh gets downgraded) */ interviewCompleted: this.interviewState === InterviewState.Successful, interviewState: this.interviewState === InterviewState.InProgress ? InterviewState.Pending : this.interviewState, meta: this.meta, lastSeen: this.lastSeen, checkinInterval: this.checkinInterval, gpSecurityKey: this.gpSecurityKey, }; } public save(writeDatabase = true): void { // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` Entity.database!.update(this.toDatabaseEntry(), writeDatabase); } private static loadFromDatabaseIfNecessary(): void { if (!Device.loadedFromDatabase) { // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` for (const entry of Entity.database!.getEntriesIterator(["Coordinator", "EndDevice", "Router", "GreenPower", "Unknown"])) { const device = Device.fromDatabaseEntry(entry); Device.devices.set(device.ieeeAddr, device); Device.nwkToIeeeCache.set(device.networkAddress, device.ieeeAddr); } Device.loadedFromDatabase = true; } } public static find(ieeeOrNwkAddress: string | number, includeDeleted = false): Device | undefined { return typeof ieeeOrNwkAddress === "string" ? Device.byIeeeAddr(ieeeOrNwkAddress, includeDeleted) : Device.byNetworkAddress(ieeeOrNwkAddress, includeDeleted); } public static byIeeeAddr(ieeeAddr: string, includeDeleted = false): Device | undefined { Device.loadFromDatabaseIfNecessary(); return includeDeleted ? (Device.deletedDevices.get(ieeeAddr) ?? Device.devices.get(ieeeAddr)) : Device.devices.get(ieeeAddr); } public static byNetworkAddress(networkAddress: number, includeDeleted = false): Device | undefined { Device.loadFromDatabaseIfNecessary(); const ieeeAddr = Device.nwkToIeeeCache.get(networkAddress); return ieeeAddr ? Device.byIeeeAddr(ieeeAddr, includeDeleted) : undefined; } public static byType(type: DeviceType): Device[] { const devices: Device[] = []; for (const device of Device.allIterator((d) => d.type === type)) { devices.push(device); } return devices; } /** * @deprecated use allIterator() */ public static all(): Device[] { Device.loadFromDatabaseIfNecessary(); return Array.from(Device.devices.values()); } public static *allIterator(predicate?: (value: Device) => boolean): Generator<Device> { Device.loadFromDatabaseIfNecessary(); for (const device of Device.devices.values()) { if (!predicate || predicate(device)) { yield device; } } } public undelete(): void { if (Device.deletedDevices.delete(this.ieeeAddr)) { Device.devices.set(this.ieeeAddr, this); // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` Entity.database!.insert(this.toDatabaseEntry()); } else { throw new Error(`Device '${this.ieeeAddr}' is not deleted`); } } public static create( type: DeviceType, ieeeAddr: string, networkAddress: number, manufacturerID: number | undefined, manufacturerName: string | undefined, powerSource: string | undefined, modelID: string | undefined, interviewState: InterviewState, gpSecurityKey: number[] | undefined, ): Device { Device.loadFromDatabaseIfNecessary(); if (Device.devices.has(ieeeAddr)) { throw new Error(`Device with IEEE address '${ieeeAddr}' already exists`); } // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` const ID = Entity.database!.newID(); const device = new Device( ID, type, ieeeAddr, networkAddress, manufacturerID, [], manufacturerName, powerSource, modelID, undefined, undefined, undefined, undefined, undefined, undefined, interviewState, {}, undefined, undefined, 0, gpSecurityKey, ); // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` Entity.database!.insert(device.toDatabaseEntry()); Device.devices.set(device.ieeeAddr, device); Device.nwkToIeeeCache.set(device.networkAddress, device.ieeeAddr); return device; } /* * Zigbee functions */ public async interview(ignoreCache = false): Promise<void> { if (this.interviewState === InterviewState.InProgress) { const message = `Interview - interview already in progress for '${this.ieeeAddr}'`; logger.debug(message, NS); throw new Error(message); } let err: unknown; this._interviewState = InterviewState.InProgress; logger.debug(`Interview - start device '${this.ieeeAddr}'`, NS); try { await this.interviewInternal(ignoreCache); logger.debug(`Interview - completed for device '${this.ieeeAddr}'`, NS); this._interviewState = InterviewState.Successful; } catch (error) { if (this.interviewQuirks()) { this._interviewState = InterviewState.Successful; logger.debug(`Interview - completed for device '${this.ieeeAddr}' because of quirks ('${error}')`, NS); } else { this._interviewState = InterviewState.Failed; logger.debug(`Interview - failed for device '${this.ieeeAddr}' with error '${error}'`, NS); err = error; } } finally { this.save(); } if (err) { throw err; } } private interviewQuirks(): boolean { logger.debug(`Interview - quirks check for '${this.modelID}'-'${this.manufacturerName}'-'${this.type}'`, NS); // Tuya devices are typically hard to interview. They also don't require a full interview to work correctly // e.g. no ias enrolling is required for the devices to work. // Assume that in case we got both the manufacturerName and modelID the device works correctly. // https://github.com/Koenkk/zigbee2mqtt/issues/7564: // Fails during ias enroll due to UNSUPPORTED_ATTRIBUTE // https://github.com/Koenkk/zigbee2mqtt/issues/4655 // Device does not change zoneState after enroll (event with original gateway) // modelID is mostly in the form of e.g. TS0202 and manufacturerName like e.g. _TYZB01_xph99wvr if (this.modelID?.match("^TS\\d*$") && (this.manufacturerName?.match("^_TZ.*_.*$") || this.manufacturerName?.match("^_TYZB01_.*$"))) { this._powerSource = this._powerSource || "Battery"; logger.debug("Interview - quirks matched for Tuya end device", NS); return true; } // 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; }; } = { "^3R.*?Z": { type: "EndDevice", powerSource: "Battery", }, "lumi..*": { type: "EndDevice", manufacturerID: 4151, manufacturerName: "LUMI", powerSource: "Battery", }, "TERNCY-PP01": { type: "EndDevice", manufacturerID: 4648, manufacturerName: "TERNCY", powerSource: "Battery", }, "3RWS18BZ": {}, // https://github.com/Koenkk/zigbee-herdsman-converters/pull/2710 "MULTI-MECI--EA01": {}, MOT003: {}, // https://github.com/Koenkk/zigbee2mqtt/issues/12471 "C-ZB-SEDC": {}, //candeo device that doesn't follow IAS enrollment process correctly and therefore fails to complete interview "C-ZB-SEMO": {}, //candeo device that doesn't follow IAS enrollment process correctly and therefore fails to complete interview }; let match: string | undefined; for (const key in lookup) { if (this.modelID?.match(key)) { match = key; break; } } if (match) { const info = lookup[match]; logger.debug(`Interview procedure failed but got modelID matching '${match}', assuming interview succeeded`, NS); this._type = this._type === "Unknown" && info.type ? info.type : this._type; this._manufacturerID = this._manufacturerID || info.manufacturerID; this._manufacturerName = this._manufacturerName || info.manufacturerName; this._powerSource = this._powerSource || info.powerSource; logger.debug(`Interview - quirks matched on '${match}'`, NS); return true; } logger.debug("Interview - quirks did not match", NS); return false; } private async interviewInternal(ignoreCache: boolean): Promise<void> { const hasNodeDescriptor = (): boolean => this._manufacturerID !== undefined && this._type !== "Unknown"; if (ignoreCache || !hasNodeDescriptor()) { for (let attempt = 0; attempt < 6; attempt++) { try { await this.updateNodeDescriptor(); break; } catch (error) { if (this.interviewQuirks()) { logger.debug(`Interview - completed for device '${this.ieeeAddr}' because of quirks ('${error}')`, NS); return; } // Most of the times the first node descriptor query fails and the seconds one succeeds. logger.debug(`Interview - node descriptor request failed for '${this.ieeeAddr}', attempt ${attempt + 1}`, NS); } } } else { logger.debug(`Interview - skip node descriptor request for '${this.ieeeAddr}', already got it`, NS); } if (!hasNodeDescriptor()) { throw new Error(`Interview failed because can not get node descriptor ('${this.ieeeAddr}')`); } if (this.manufacturerID === 4619 && this._type === "EndDevice") { // Give Tuya end device some time to pair. Otherwise they leave immediately. // https://github.com/Koenkk/zigbee2mqtt/issues/5814 logger.debug("Interview - Detected Tuya end device, waiting 10 seconds...", NS); await wait(10000); } else if (this.manufacturerID === 0 || this.manufacturerID === 4098) { // Potentially a Tuya device, some sleep fast so make sure to read the modelId and manufacturerName quickly. // In case the device responds, the endoint and modelID/manufacturerName are set // in controller.onZclOrRawData() // https://github.com/Koenkk/zigbee2mqtt/issues/7553 logger.debug("Interview - Detected potential Tuya end device, reading modelID and manufacturerName...", NS); try { const endpoint = Endpoint.create(1, undefined, undefined, [], [], this.networkAddress, this.ieeeAddr); const result = await endpoint.read("genBasic", ["modelId", "manufacturerName"], {sendPolicy: "immediate"}); for (const key in result) { Device.REPORTABLE_PROPERTIES_MAPPING[key].set(result[key], this); } } catch (error) { logger.debug(`Interview - Tuya read modelID and manufacturerName failed (${error})`, NS); } } // 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 gotActiveEndpoints = false; for (let attempt = 0; attempt < 2; attempt++) { try { await this.updateActiveEndpoints(); gotActiveEndpoints = true; break; } catch (error) { logger.debug(`Interview - active endpoints request failed for '${this.ieeeAddr}', attempt ${attempt + 1} (${error})`, NS); } } if (!gotActiveEndpoints) { throw new Error(`Interview failed because can not get active endpoints ('${this.ieeeAddr}')`); } logger.debug(`Interview - got active endpoints for device '${this.ieeeAddr}'`, NS); const coordinator = Device.byType("Coordinator")[0]; for (const endpoint of this._endpoints) { await endpoint.updateSimpleDescriptor(); logger.debug(`Interview - got simple descriptor for endpoint '${endpoint.ID}' device '${this.ieeeAddr}'`, NS); // Read attributes // nice to have but not required for successful pairing as most of the attributes are not mandatory in ZCL specification if (endpoint.supportsInputCluster("genBasic")) { for (const key in Device.REPORTABLE_PROPERTIES_MAPPING) { const item = Device.REPORTABLE_PROPERTIES_MAPPING[key]; if (ignoreCache || !this[item.key]) { try { let result: KeyValue; try { result = await endpoint.read("genBasic", [key], {sendPolicy: "immediate"}); } catch (error) { // Reading attributes can fail for many reason, e.g. it could be that device rejoins // while joining like in: // https://github.com/Koenkk/zigbee-herdsman-converters/issues/2485. // The modelID and manufacturerName are crucial for device identification, so retry. if (item.key === "modelID" || item.key === "manufacturerName") { logger.debug(`Interview - first ${item.key} retrieval attempt failed, retrying after 10 seconds...`, NS); await wait(10000); result = await endpoint.read("genBasic", [key], {sendPolicy: "immediate"}); } else { throw error; } } item.set(result[key], this); logger.debug(`Interview - got '${item.key}' for device '${this.ieeeAddr}'`, NS); } catch (error) { logger.debug(`Interview - failed to read attribute '${item.key}' from endpoint '${endpoint.ID}' (${error})`, NS); } } } } // Enroll IAS device if (endpoint.supportsInputCluster("ssIasZone")) { logger.debug(`Interview - IAS - enrolling '${this.ieeeAddr}' endpoint '${endpoint.ID}'`, NS); const stateBefore = await endpoint.read("ssIasZone", ["iasCieAddr", "zoneState"], {sendPolicy: "immediate"}); logger.debug(`Interview - IAS - before enrolling state: '${JSON.stringify(stateBefore)}'`, NS); // Do not enroll when device has already been enrolled if (stateBefore.zoneState !== 1 || stateBefore.iasCieAddr !== coordinator.ieeeAddr) { logger.debug("Interview - IAS - not enrolled, enrolling", NS); await endpoint.write("ssIasZone", {iasCieAddr: coordinator.ieeeAddr}, {sendPolicy: "immediate"}); logger.debug("Interview - IAS - wrote iasCieAddr", NS); // There are 2 enrollment procedures: // - Auto enroll: coordinator has to send enrollResponse without receiving an enroll request // this case is handled below. // - Manual enroll: coordinator replies to enroll request with an enroll response. // this case in hanled in onZclData(). // https://github.com/Koenkk/zigbee2mqtt/issues/4569#issuecomment-706075676 await wait(500); logger.debug(`IAS - '${this.ieeeAddr}' sending enroll response (auto enroll)`, NS); const payload = {enrollrspcode: 0, zoneid: 23}; await endpoint.command("ssIasZone", "enrollRsp", payload, {disableDefaultResponse: true, sendPolicy: "immediate"}); let enrolled = false; for (let attempt = 0; attempt < 20; attempt++) { await wait(500); const stateAfter = await endpoint.read("ssIasZone", ["iasCieAddr", "zoneState"], {sendPolicy: "immediate"}); logger.debug(`Interview - IAS - after enrolling state (${attempt}): '${JSON.stringify(stateAfter)}'`, NS); if (stateAfter.zoneState === 1) { enrolled = true; break; } } if (enrolled) { logger.debug(`Interview - IAS successfully enrolled '${this.ieeeAddr}' endpoint '${endpoint.ID}'`, NS); } else { throw new Error(`Interview failed because of failed IAS enroll (zoneState didn't change ('${this.ieeeAddr}')`); } } else { logger.debug("Interview - IAS - already enrolled, skipping enroll", NS); } } } // Bind poll control try { for (const endpoint of this.endpoints.filter((e): boolean => e.supportsInputCluster("genPollCtrl"))) { logger.debug(`Interview - Poll control - binding '${this.ieeeAddr}' endpoint '${endpoint.ID}'`, NS); await endpoint.bind("genPollCtrl", coordinator.endpoints[0]); const pollPeriod = await endpoint.read("genPollCtrl", ["checkinInterval"], {sendPolicy: "immediate"}); this._checkinInterval = pollPeriod.checkinInterval / 4; // convert to seconds this.resetPendingRequestTimeout(); } /* v8 ignore start */ } catch (error) { logger.debug(`Interview - failed to bind genPollCtrl (${error})`, NS); } /* v8 ignore stop */ } public async updateNodeDescriptor(): Promise<void> { const clusterId = Zdo.ClusterId.NODE_DESCRIPTOR_REQUEST; // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` const zdoPayload = Zdo.Buffalo.buildRequest(Entity.adapter!.hasZdoMessageOverhead, clusterId, this.networkAddress); // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` const response = await Entity.adapter!.sendZdo(this.ieeeAddr, this.networkAddress, clusterId, zdoPayload, false); if (!Zdo.Buffalo.checkStatus<Zdo.ClusterId.NODE_DESCRIPTOR_RESPONSE>(response)) { throw new Zdo.StatusError(response[0]); } // TODO: make use of: capabilities.rxOnWhenIdle, maxIncTxSize, maxOutTxSize, serverMask.stackComplianceRevision const nodeDescriptor = response[1]; this._manufacturerID = nodeDescriptor.manufacturerCode; switch (nodeDescriptor.logicalType) { case 0x0: this._type = "Coordinator"; break; case 0x1: this._type = "Router"; break; case 0x2: this._type = "EndDevice"; break; } logger.debug(`Interview - got node descriptor for device '${this.ieeeAddr}'`, NS); // TODO: define a property on Device for this value (would be good to have it displayed) // log for devices older than 1 from current revision if (nodeDescriptor.serverMask.stackComplianceRevision < ZSpec.ZIGBEE_REVISION - 1) { // always 0 before revision 21 where field was added const rev = nodeDescriptor.serverMask.stackComplianceRevision < 21 ? "pre-21" : nodeDescriptor.serverMask.stackComplianceRevision; logger.info( `Device '${this.ieeeAddr}' is only compliant to revision '${rev}' of the ZigBee specification (current revision: ${ZSpec.ZIGBEE_REVISION}).`, NS, ); } } public async updateActiveEndpoints(): Promise<void> { const clusterId = Zdo.ClusterId.ACTIVE_ENDPOINTS_REQUEST; // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` const zdoPayload = Zdo.Buffalo.buildRequest(Entity.adapter!.hasZdoMessageOverhead, clusterId, this.networkAddress); // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` const response = await Entity.adapter!.sendZdo(this.ieeeAddr, this.networkAddress, clusterId, zdoPayload, false); if (!Zdo.Buffalo.checkStatus<Zdo.ClusterId.ACTIVE_ENDPOINTS_RESPONSE>(response)) { throw new Zdo.StatusError(response[0]); } const activeEndpoints = response[1]; // Make sure that the endpoint are sorted. activeEndpoints.endpointList.sort((a, b) => a - b); for (const endpoint of activeEndpoints.endpointList) { // 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 if (endpoint !== 0 && !this.getEndpoint(endpoint)) { this._endpoints.push(Endpoint.create(endpoint, undefined, undefined, [], [], this.networkAddress, this.ieeeAddr)); } } // Remove disappeared endpoints (can happen with e.g. custom devices). this._endpoints = this._endpoints.filter((e) => activeEndpoints.endpointList.includes(e.ID)); } /** * Request device to advertise its network address. * Note: This does not actually update the device property (if needed), as this is already done with `zdoResponse` event in Controller. */ public async requestNetworkAddress(): Promise<void> { const clusterId = Zdo.ClusterId.NETWORK_ADDRESS_REQUEST; // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` const zdoPayload = Zdo.Buffalo.buildRequest(Entity.adapter!.hasZdoMessageOverhead, clusterId, this.ieeeAddr as Eui64, false, 0); // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` await Entity.adapter!.sendZdo(this.ieeeAddr, ZSpec.BroadcastAddress.RX_ON_WHEN_IDLE, clusterId, zdoPayload, true); } public async removeFromNetwork(): Promise<void> { if (this._type === "GreenPower") { const payload = { options: 0x002550, srcID: Number(this.ieeeAddr), }; const frame = Zcl.Frame.create( Zcl.FrameType.SPECIFIC, Zcl.Direction.SERVER_TO_CLIENT, true, undefined, zclTransactionSequenceNumber.next(), "pairing", 33, payload, this.customClusters, ); // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` await Entity.adapter!.sendZclFrameToAll(242, frame, 242, BroadcastAddress.RX_ON_WHEN_IDLE); } else { const clusterId = Zdo.ClusterId.LEAVE_REQUEST; const zdoPayload = Zdo.Buffalo.buildRequest( // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` Entity.adapter!.hasZdoMessageOverhead, clusterId, this.ieeeAddr as Eui64, Zdo.LeaveRequestFlags.WITHOUT_REJOIN, ); // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` const response = await Entity.adapter!.sendZdo(this.ieeeAddr, this.networkAddress, clusterId, zdoPayload, false); if (!Zdo.Buffalo.checkStatus<Zdo.ClusterId.LEAVE_RESPONSE>(response)) { throw new Zdo.StatusError(response[0]); } } this.removeFromDatabase(); } public removeFromDatabase(): void { Device.loadFromDatabaseIfNecessary(); for (const endpoint of this.endpoints) { endpoint.removeFromAllGroupsDatabase(); } // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` if (Entity.database!.has(this.ID)) { // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` Entity.database!.remove(this.ID); } Device.deletedDevices.set(this.ieeeAddr, this); Device.devices.delete(this.ieeeAddr); // Clear all data in case device joins aga