import assert from "node:assert"; import {Events as AdapterEvents} from '../../adapter'; import {wait} from "../../utils"; import {logger} from "../../utils/logger"; import * as timeService from "../../utils/timeService"; import * as ZSpec from "../../zspec"; import {BroadcastAddress} from "../../zspec/enums"; import type {Eui64} from "../../zspec/tstypes"; import * as Zcl from "../../zspec/zcl"; import type {TClusterCommandPayload, TPartialClusterAttributes} from "../../zspec/zcl/definition/clusters-types"; import type {Cluster, CustomClusters} from "../../zspec/zcl/definition/tstype"; import type {TFoundationZclFrame, TZclFrame} from "../../zspec/zcl/zclFrame"; import * as Zdo from "../../zspec/zdo"; import type {BindingTableEntry, LQITableEntry, RoutingTableEntry} from "../../zspec/zdo/definition/tstypes"; import type {ControllerEventMap} from "../controller"; import {getOtaFirmware, getOtaIndex, OtaSession, parseOtaImage} from "../helpers/ota"; import zclTransactionSequenceNumber from "../helpers/zclTransactionSequenceNumber"; import type { DatabaseEntry, DeviceType, KeyValue, OtaDataSettings, OtaExtraMetas, OtaImage, OtaSource, OtaUpdateAvailableResult, ZigbeeOtaImageMeta, } from "../tstype"; import Endpoint, {type BindInternal} from "./endpoint"; import Entity from "./entity"; const NS = "zh:controller:device"; const INTERVIEW_GENBASIC_ATTRIBUTES = [ "modelId", "manufacturerName", "powerSource", "zclVersion", "appVersion", "stackVersion", "hwVersion", "dateCode", "swBuildId", ] as const; const GEN_BASIC_CLUSTER_ID = Zcl.Clusters.genBasic.ID; const GEN_TIME_CLUSTER_ID = Zcl.Clusters.genTime.ID; const GEN_POLL_CTRL_CLUSTER_ID = Zcl.Clusters.genPollCtrl.ID; const GEN_OTA_CLUSTER_ID = Zcl.Clusters.genOta.ID; 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 { private databaseID: number; // biome-ignore lint/style/useNamingConvention: cross-repo impact private readonly ID: number; #genBasic: TPartialClusterAttributes<"genBasic"> = {}; private _endpoints: Endpoint[]; private _ieeeAddr: string; private _interviewState: InterviewState; private _lastSeen?: number; private _manufacturerID?: number; private _networkAddress: number; private _type: DeviceType; private _linkquality?: number; private _skipDefaultResponse: boolean; private _customReadResponse?: CustomReadResponse; private _lastDefaultResponseSequenceNumber?: number; private _checkinInterval?: number; private _pendingRequestTimeout: number; private _customClusters: CustomClusters = {}; private _gpSecurityKey?: number[]; #scheduledOta: OtaSource | undefined; #otaInProgress = false; #otaAbortController: AbortController | undefined; // Getters/setters get ieeeAddr(): string { return this._ieeeAddr; } set ieeeAddr(ieeeAddr: string) { this._ieeeAddr = ieeeAddr; } get applicationVersion(): number | undefined { return this.#genBasic.appVersion; } set applicationVersion(version: number) { this.#genBasic.appVersion = version; } 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.get(this.databaseID)?.has(this.ieeeAddr) ?? false; } set type(type: DeviceType) { this._type = type; } get type(): DeviceType { return this._type; } get dateCode(): string | undefined { return this.#genBasic.dateCode; } set dateCode(code: string) { this.#genBasic.dateCode = code; } set hardwareVersion(version: number) { this.#genBasic.hwVersion = version; } get hardwareVersion(): number | undefined { return this.#genBasic.hwVersion; } get manufacturerName(): string | undefined { return this.#genBasic.manufacturerName; } set manufacturerName(name: string | undefined) { this.#genBasic.manufacturerName = name; } set modelID(id: string) { this.#genBasic.modelId = id; } get modelID(): string | undefined { return this.#genBasic.modelId; } get networkAddress(): number { return this._networkAddress; } set networkAddress(networkAddress: number) { Device.nwkToIeeeCache.get(this.databaseID)?.delete(this._networkAddress); this._networkAddress = networkAddress; Device.nwkToIeeeCache.get(this.databaseID)?.set(this._networkAddress, this.ieeeAddr); for (const endpoint of this._endpoints) { endpoint.deviceNetworkAddress = networkAddress; } } get powerSource(): string | undefined { return this.#genBasic.powerSource ? Zcl.POWER_SOURCES[this.#genBasic.powerSource] : undefined; } set powerSource(source: string | number) { if (typeof source === "number") { this.#genBasic.powerSource = source & ~(1 << 7); } else { for (const key in Zcl.POWER_SOURCES) { const val = Zcl.POWER_SOURCES[key]; if (val === source) { this.#genBasic.powerSource = Number(key); break; } } } } get softwareBuildID(): string | undefined { return this.#genBasic.swBuildId; } set softwareBuildID(id: string) { this.#genBasic.swBuildId = id; } get stackVersion(): number | undefined { return this.#genBasic.stackVersion; } set stackVersion(version: number) { this.#genBasic.stackVersion = version; } get zclVersion(): number | undefined { return this.#genBasic.zclVersion; } set zclVersion(version: number) { this.#genBasic.zclVersion = version; } 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; } /** If the set function returns true, the default read response behavior is skipped */ 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; } get genBasic(): TPartialClusterAttributes<"genBasic"> { return this.#genBasic; } get scheduledOta(): OtaSource | undefined { return this.#scheduledOta; } get otaInProgress(): boolean { return this.#otaInProgress; } 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> = new Map>(); private static readonly deletedDevices: Map> = new Map(); private static readonly nwkToIeeeCache: Map> = new Map(); private constructor( databaseID: number, 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, scheduledOta: OtaSource | undefined, ) { super(); this.databaseID = databaseID; this.ID = id; this._type = type; this._ieeeAddr = ieeeAddr; this._networkAddress = networkAddress; this._manufacturerID = manufacturerID; this._endpoints = endpoints; this.#genBasic.manufacturerName = manufacturerName; this.powerSource = powerSource ?? Zcl.PowerSource.Unknown; this.#genBasic.modelId = modelID; this.#genBasic.appVersion = applicationVersion; this.#genBasic.stackVersion = stackVersion; this.#genBasic.zclVersion = zclVersion; this.#genBasic.hwVersion = hardwareVersion; this.#genBasic.dateCode = dateCode; this.#genBasic.swBuildId = softwareBuildID; this._interviewState = interviewState; this._skipDefaultResponse = false; this.meta = meta; this._lastSeen = lastSeen; this._checkinInterval = checkinInterval; this._pendingRequestTimeout = pendingRequestTimeout; this._gpSecurityKey = gpSecurityKey; this.#scheduledOta = scheduledOta; } /** * Reset transient data about the device. * @param cache If true, reset some previously cached data. * Should be set to true when device potentially changed its internal data to prevent mismatching state/config. */ resetTransient(cache: boolean): void { this._lastDefaultResponseSequenceNumber = undefined; if (cache) { // force retrieving this data again this._checkinInterval = undefined; this._pendingRequestTimeout = 0; } } public createEndpoint(id: number): Endpoint { if (this.getEndpoint(id)) { throw new Error(`Device '${this.ieeeAddr}' already has an endpoint '${id}'`); } const endpoint = Endpoint.create(this.databaseID, id, undefined, undefined, [], [], this.networkAddress, this.ieeeAddr ); this.endpoints.push(endpoint); this.save(); return endpoint; } public changeIeeeAddress(ieeeAddr: string): void { Device.devices.get(this.databaseID)?.delete(this.ieeeAddr); this.ieeeAddr = ieeeAddr; Device.devices.get(this.databaseID)?.set(this.ieeeAddr, this); Device.nwkToIeeeCache.get(this.databaseID)?.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 updateGenBasic(data: TPartialClusterAttributes<"genBasic">): void { Object.assign(this.#genBasic, data); } 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, defaultResponse: Zcl.Status | undefined, ): Promise { if (!Device.devices.get(this.databaseID)?.has(this.ieeeAddr)) { // prevent race conditions where device gets deleted during processing return; } if (this.type === "GreenPower") { // nothing below applies return; } const {header, command, cluster} = frame; let sendDefaultResponse = !dataPayload.wasBroadcast && command.response === undefined; let defaultResponseStatus = defaultResponse ?? Zcl.Status.SUCCESS; if (header.isGlobal) { // Response to read requests from device to coordinator switch (command.name) { case "read": { // NOTE: `sendDefaultResponse` always false from `command.response === 0x01` if (this._customReadResponse?.(frame, endpoint)) { break; } const response: KeyValue = {}; switch (dataPayload.clusterID) { case GEN_TIME_CLUSTER_ID: { // relax type to index by attr name, undefined results in non-success attr record const timeAttrs = timeService.getTimeClusterAttributes() as Record; for (const entry of frame.payload) { // TODO: this.manufacturerID or frame.header.manufacturerCode const name = Zcl.Utils.getClusterAttribute(cluster, entry.attrId, this.manufacturerID)?.name; if (name === undefined) { // UNSUPPORTED_ATTRIBUTE response[entry.attrId] = {value: undefined, type: Zcl.DataType.NO_DATA}; } else { response[name] = timeAttrs[name]; } } break; } // NOTE: can add more clusters here to use defaults from spec as needed case GEN_BASIC_CLUSTER_ID: { for (const entry of frame.payload) { // TODO: this.manufacturerID or frame.header.manufacturerCode const attr = Zcl.Utils.getClusterAttribute(cluster, entry.attrId, this.manufacturerID); if (attr?.default === undefined) { // UNSUPPORTED_ATTRIBUTE response[entry.attrId] = {value: undefined, type: Zcl.DataType.NO_DATA}; } else { response[attr.name] = attr.default; } } break; } default: { for (const entry of frame.payload) { // UNSUPPORTED_ATTRIBUTE response[entry.attrId] = {value: undefined, type: Zcl.DataType.NO_DATA}; } break; } } try { await endpoint.readResponse(cluster.ID, header.transactionSequenceNumber, response, { srcEndpoint: dataPayload.destinationEndpoint, }); } catch (error) { logger.error(`Read response to ${this.ieeeAddr} failed (${(error as Error).message})`, NS); // XXX: technically, if `readResponse` fails before reaching the network (internal to ZH), we should send a default response // currently not possible due to implementation (no distinction as to "where" it failed) } break; } case "defaultRsp": { sendDefaultResponse = false; // per spec break; } } } else if (header.isSpecific) { switch (cluster.name) { case "ssIasZone": { if (command.name === "enrollReq") { // Respond to enroll requests logger.debug(`IAS - '${this.ieeeAddr}' responding to enroll response`, NS); try { await endpoint.command( "ssIasZone", "enrollRsp", {enrollrspcode: 0, zoneid: 23}, {transactionSequenceNumber: header.transactionSequenceNumber, disableDefaultResponse: true}, ); sendDefaultResponse = false; // per spec, sending a specific response TODO: no "Effect on receipt" in spec, is this correct? } catch (error) { logger.error(`Handling of IAS zone enroll for ${this.ieeeAddr} failed (${(error as Error).message})`, NS); defaultResponseStatus = Zcl.Status.FAILURE; } } break; } case "genPollCtrl": { if (command.name === "checkin") { let startedFastPolling = false; // Handle check-in from sleeping end devices try { if (this.hasPendingRequests() || this._checkinInterval === undefined) { logger.debug(`check-in from ${this.ieeeAddr}: accepting fast-poll`, NS); await endpoint.command( cluster.name as "genPollCtrl", "checkinRsp", {startFastPolling: 1, fastPollTimeout: 0}, { transactionSequenceNumber: header.transactionSequenceNumber, disableDefaultResponse: true, sendPolicy: "immediate", }, ); startedFastPolling = true; // 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))); } else { logger.debug(`check-in from ${this.ieeeAddr}: declining fast-poll`, NS); await endpoint.command( cluster.name as "genPollCtrl", "checkinRsp", {startFastPolling: 0, fastPollTimeout: 0}, { transactionSequenceNumber: header.transactionSequenceNumber, disableDefaultResponse: true, sendPolicy: "immediate", }, ); } sendDefaultResponse = false; // per spec, sending a specific response } catch (error) { logger.error(`Handling of poll check-in from ${this.ieeeAddr} failed (${(error as Error).message})`, NS); defaultResponseStatus = Zcl.Status.FAILURE; } finally { if (startedFastPolling) { // 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); try { await endpoint.command(cluster.name as "genPollCtrl", "fastPollStop", {}, {sendPolicy: "immediate"}); } catch (error) { logger.error(`Failed to stop fast poll for ${this.ieeeAddr} (${(error as Error).message})`, NS); } } } } break; } } } // Send a default response if necessary. /* v8 ignore next */ const disableTuyaDefaultResponse = this.manufacturerName?.startsWith("_TZ") && process.env.DISABLE_TUYA_DEFAULT_RESPONSE; // Sometimes messages are received twice, prevent responding twice const alreadyResponded = this._lastDefaultResponseSequenceNumber === header.transactionSequenceNumber; if ( !this._skipDefaultResponse && sendDefaultResponse && (!header.frameControl.disableDefaultResponse || defaultResponseStatus !== Zcl.Status.SUCCESS) && !alreadyResponded && !disableTuyaDefaultResponse ) { try { this._lastDefaultResponseSequenceNumber = header.transactionSequenceNumber; const direction = header.frameControl.direction === Zcl.Direction.CLIENT_TO_SERVER ? Zcl.Direction.SERVER_TO_CLIENT : Zcl.Direction.CLIENT_TO_SERVER; await endpoint.defaultResponse(command.ID, defaultResponseStatus, cluster.ID, 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.deletedDevices.clear(); Device.nwkToIeeeCache.clear(); } private static fromDatabaseEntry(entry: DatabaseEntry, databaseID: number): 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, databaseID)); } 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(GEN_POLL_CTRL_CLUSTER_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( databaseID, 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, entry.scheduledOta, ); } 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, scheduledOta: this.scheduledOta, }; } public save(writeDatabase = true): void { Entity.getDatabaseByID(this.databaseID)?.update(this.toDatabaseEntry(), writeDatabase); } private static loadFromDatabaseIfNecessary(): void { Entity.databases.forEach(database => { if (!Device.devices.has(database.id)) { Device.devices.set(database.id, new Map()); Device.deletedDevices.set(database.id, new Map()); Device.nwkToIeeeCache.set(database.id, new Map()); const entries = database.getEntriesIterator(['Coordinator', 'EndDevice', 'Router', 'GreenPower', 'Unknown']); for (const entry of entries) { const device = Device.fromDatabaseEntry(entry, database.id); Device.devices.get(database.id)!.set(device.ieeeAddr, device); Device.nwkToIeeeCache.get(database.id)!.set(device.networkAddress, device.ieeeAddr); } } }); } public static find(databaseID: number, ieeeOrNwkAddress: string | number, includeDeleted = false): Device | undefined { return typeof ieeeOrNwkAddress === "string" ? Device.byIeeeAddr(databaseID, ieeeOrNwkAddress, includeDeleted) : Device.byNetworkAddress(databaseID, ieeeOrNwkAddress, includeDeleted); } public static byIeeeAddr(databaseID: number, ieeeAddr: string, includeDeleted = false): Device | undefined { Device.loadFromDatabaseIfNecessary(); const device = Device.devices.get(databaseID)?.get(ieeeAddr); return includeDeleted ? (Device.deletedDevices.get(databaseID)?.get(ieeeAddr) ?? device) : device; } public static byNetworkAddress(databaseID: number, networkAddress: number, includeDeleted = false): Device | undefined { Device.loadFromDatabaseIfNecessary(); const ieeeAddr = Device.nwkToIeeeCache.get(databaseID)?.get(networkAddress); return ieeeAddr ? Device.byIeeeAddr(databaseID, ieeeAddr, includeDeleted) : undefined; } public static byType(databaseID: number, type: DeviceType): Device[] { return Device.allByDatabaseID(databaseID).filter(d => d.type === type); } public static allByDatabaseID(databaseID: number): Device[] { Device.loadFromDatabaseIfNecessary(); return Array.from(Device.devices.get(databaseID)?.values() ?? []); } /** Check if a device is explicitly deleted */ public static isDeletedByIeeeAddr(databaseID: number, ieeeAddr: string): boolean { Device.loadFromDatabaseIfNecessary(); return Device.deletedDevices.get(databaseID)?.has(ieeeAddr) ?? false; } /** Check if a device is explicitly deleted */ public static isDeletedByNetworkAddress(databaseID: number, networkAddress: number): boolean { Device.loadFromDatabaseIfNecessary(); const ieeeAddr = Device.nwkToIeeeCache.get(databaseID)?.get(networkAddress); return ieeeAddr ? Device.deletedDevices.get(databaseID)?.has(ieeeAddr) ?? false : false; } // public static all(): Device[] { // Device.loadFromDatabaseIfNecessary(); // return Array.from(Device.devices.values()); // } public static *allIterator(databaseID: number, predicate?: (value: Device) => boolean): Generator { Device.loadFromDatabaseIfNecessary(); for (const device of Device.allByDatabaseID(databaseID)) { if (!predicate || predicate(device)) { yield device; } } } public undelete(): void { if (Device.deletedDevices.get(this.databaseID)?.delete(this.ieeeAddr)) { Device.devices.get(this.databaseID)?.set(this.ieeeAddr, this); Entity.getDatabaseByID(this.databaseID)?.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, databaseID: number, ): Device { Device.loadFromDatabaseIfNecessary(); if (Device.devices.get(databaseID)?.has(ieeeAddr)) { throw new Error(`Device with IEEE address '${ieeeAddr}' already exists`); } const database = Entity.getDatabaseByID(databaseID); if (!database) { throw new Error(`Database with ID '${databaseID}' not found`); } const ID = database.newID(); const device = new Device( databaseID, ID, type, ieeeAddr, networkAddress, manufacturerID, [], manufacturerName, powerSource, modelID, undefined, undefined, undefined, undefined, undefined, undefined, interviewState, {}, undefined, undefined, 0, gpSecurityKey, undefined, ); database.insert(device.toDatabaseEntry()); Device.devices.get(databaseID)?.set(device.ieeeAddr, device); Device.nwkToIeeeCache.get(databaseID)?.set(device.networkAddress, device.ieeeAddr); return device; } /* * Zigbee functions */ public async interview(ignoreCache = false): Promise { 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.manufacturerName === "HOBEIAN" || (this.modelID?.match("^TS\\d*$") && (this.manufacturerName?.match("^_TZ.*_.*$") || this.manufacturerName?.match("^_TYZB01_.*$"))) ) { this.#genBasic.powerSource = this.#genBasic.powerSource || Zcl.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?: Zcl.PowerSource; }; } = { "^3R.*?Z": { type: "EndDevice", powerSource: Zcl.PowerSource.Battery, }, "lumi..*": { type: "EndDevice", manufacturerID: 4151, manufacturerName: "LUMI", powerSource: Zcl.PowerSource.Battery, }, "TERNCY-PP01": { type: "EndDevice", manufacturerID: 4648, manufacturerName: "TERNCY", powerSource: Zcl.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 "CS-T9C-A0-BG": {}, // iAS enroll fails: https://github.com/Koenkk/zigbee2mqtt/issues/27822 "SNZB-01": {}, // iAS enroll fails: https://github.com/Koenkk/zigbee2mqtt/issues/29474 }; 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.#genBasic.manufacturerName = this.#genBasic.manufacturerName || info.manufacturerName; this.#genBasic.powerSource = (this.#genBasic.powerSource || info.powerSource) /* v8 ignore next */ ?? Zcl.PowerSource.Unknown; 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 { 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(this.databaseID, 1, undefined, undefined, [], [], this.networkAddress, this.ieeeAddr); const result = await endpoint.read("genBasic", ["modelId", "manufacturerName"], {sendPolicy: "immediate"}); this.updateGenBasic(result); } 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(this.databaseID, "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 of INTERVIEW_GENBASIC_ATTRIBUTES) { if (ignoreCache || !this.#genBasic[key]) { try { let result: TPartialClusterAttributes<"genBasic">; 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 (key === "modelId" || key === "manufacturerName") { logger.debug(`Interview - first ${key} retrieval attempt failed, retrying after 10 seconds...`, NS); await wait(10000); result = await endpoint.read("genBasic", [key], {sendPolicy: "immediate"}); } else { throw error; } } this.updateGenBasic(result); logger.debug(`Interview - got '${key}' for device '${this.ieeeAddr}'`, NS); } catch (error) { logger.debug(`Interview - failed to read attribute '${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}, {disableDefaultResponse: true, 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 { const clusterId = Zdo.ClusterId.NODE_DESCRIPTOR_REQUEST; const adapter = Entity.getAdapterByID(this.databaseID); if (!adapter) { throw new Error(`No adapter found for database ID ${this.databaseID}`); } const zdoPayload = Zdo.Buffalo.buildRequest(adapter.hasZdoMessageOverhead, clusterId, this.networkAddress); const response = await adapter.sendZdo(this.ieeeAddr, this.networkAddress, clusterId, zdoPayload, false); if (!Zdo.Buffalo.checkStatus(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 { const adapter = Entity.getAdapterByID(this.databaseID); if (!adapter) { throw new Error(`No adapter found for database ID ${this.databaseID}`); } const clusterId = Zdo.ClusterId.ACTIVE_ENDPOINTS_REQUEST; const zdoPayload = Zdo.Buffalo.buildRequest(adapter.hasZdoMessageOverhead, clusterId, this.networkAddress); const response = await adapter.sendZdo(this.ieeeAddr, this.networkAddress, clusterId, zdoPayload, false); if (!Zdo.Buffalo.checkStatus(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(this.databaseID, 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 { const clusterId = Zdo.ClusterId.NETWORK_ADDRESS_REQUEST; const adapter = Entity.getAdapterByID(this.databaseID); if (!adapter) { throw new Error(`No adapter found for database ID ${this.databaseID}`); } const zdoPayload = Zdo.Buffalo.buildRequest(adapter.hasZdoMessageOverhead, clusterId, this.ieeeAddr as Eui64, false, 0); await adapter.sendZdo(this.ieeeAddr, ZSpec.BroadcastAddress.RX_ON_WHEN_IDLE, clusterId, zdoPayload, true); } public async removeFromNetwork(): Promise { const adapter = Entity.getAdapterByID(this.databaseID); if (!adapter) { throw new Error(`No adapter found for database ID ${this.databaseID}`); } 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, ); await adapter.sendZclFrameToAll(242, frame, 242, BroadcastAddress.RX_ON_WHEN_IDLE); } else { const clusterId = Zdo.ClusterId.LEAVE_REQUEST; const zdoPayload = Zdo.Buffalo.buildRequest( adapter.hasZdoMessageOverhead, clusterId, this.ieeeAddr as Eui64, Zdo.LeaveRequestFlags.WITHOUT_REJOIN, ); const response = await adapter.sendZdo(this.ieeeAddr, this.networkAddress, clusterId, zdoPayload, false); if (!Zdo.Buffalo.checkStatus(response)) { throw new Zdo.StatusError(response[0]); } } this.removeFromDatabase(); } public removeFromDatabase(): void { Device.loadFromDatabaseIfNecessary(); for (const endpoint of this.endpoints) { endpoint.removeFromAllGroupsDatabase(); } const database = Entity.getDatabaseByID(this.databaseID); if (database?.has(this.ID)) { database.remove(this.ID); } Device.deletedDevices.get(this.databaseID)?.set(this.ieeeAddr, this); Device.devices.get(this.databaseID)?.delete(this.ieeeAddr); // Clear all data in case device joins again // Green power devices are never interviewed, keep existing interview state. this._interviewState = this.type === "GreenPower" ? this._interviewState : InterviewState.Pending; this.meta = {}; const newEndpoints: Endpoint[] = []; for (const endpoint of this.endpoints) { newEndpoints.push( Endpoint.create( this.databaseID, endpoint.ID, endpoint.profileID, endpoint.deviceID, endpoint.inputClusters, endpoint.outputClusters, this.networkAddress, this.ieeeAddr, ), ); } this._endpoints = newEndpoints; } public async lqi(): Promise { const adapter = Entity.getAdapterByID(this.databaseID); if (!adapter) { throw new Error(`No adapter found for database ID ${this.databaseID}`); } const clusterId = Zdo.ClusterId.LQI_TABLE_REQUEST; const table: LQITableEntry[] = []; const request = async (startIndex: number): Promise<[tableEntries: number, entryCount: number]> => { const zdoPayload = Zdo.Buffalo.buildRequest(adapter.hasZdoMessageOverhead, clusterId, startIndex); const response = await adapter.sendZdo(this.ieeeAddr, this.networkAddress, clusterId, zdoPayload, false); if (!Zdo.Buffalo.checkStatus(response)) { throw new Zdo.StatusError(response[0]); } const result = response[1]; table.push(...result.entryList); return [result.neighborTableEntries, result.entryList.length]; }; let [tableEntries, entryCount] = await request(0); const size = tableEntries; let nextStartIndex = entryCount; while (table.length < size) { [tableEntries, entryCount] = await request(nextStartIndex); nextStartIndex += entryCount; } return table; } public async routingTable(): Promise { const adapter = Entity.getAdapterByID(this.databaseID); if (!adapter) { throw new Error(`No adapter found for database ID ${this.databaseID}`); } const clusterId = Zdo.ClusterId.ROUTING_TABLE_REQUEST; const table: RoutingTableEntry[] = []; const request = async (startIndex: number): Promise<[tableEntries: number, entryCount: number]> => { const zdoPayload = Zdo.Buffalo.buildRequest(adapter.hasZdoMessageOverhead, clusterId, startIndex); const response = await adapter.sendZdo(this.ieeeAddr, this.networkAddress, clusterId, zdoPayload, false); if (!Zdo.Buffalo.checkStatus(response)) { throw new Zdo.StatusError(response[0]); } const result = response[1]; table.push(...result.entryList); return [result.routingTableEntries, result.entryList.length]; }; let [tableEntries, entryCount] = await request(0); const size = tableEntries; let nextStartIndex = entryCount; while (table.length < size) { [tableEntries, entryCount] = await request(nextStartIndex); nextStartIndex += entryCount; } return table; } public async bindingTable(): Promise { const adapter = Entity.getAdapterByID(this.databaseID); if (!adapter) { throw new Error(`No adapter found for database ID ${this.databaseID}`); } const clusterId = Zdo.ClusterId.BINDING_TABLE_REQUEST; const table: BindingTableEntry[] = []; const request = async (startIndex: number): Promise<[tableEntries: number, entryCount: number]> => { const zdoPayload = Zdo.Buffalo.buildRequest(adapter.hasZdoMessageOverhead, clusterId, startIndex); const response = await adapter.sendZdo(this.ieeeAddr, this.networkAddress, clusterId, zdoPayload, false); if (!Zdo.Buffalo.checkStatus(response)) { throw new Zdo.StatusError(response[0]); } const result = response[1]; table.push(...result.entryList); return [result.bindingTableEntries, result.entryList.length]; }; let [tableEntries, entryCount] = await request(0); const size = tableEntries; let nextStartIndex = entryCount; while (table.length < size) { [tableEntries, entryCount] = await request(nextStartIndex); nextStartIndex += entryCount; } for (const ep of this._endpoints) { const newBinds: BindInternal[] = []; for (const entry of table) { if (entry.sourceEui64 !== this.ieeeAddr || entry.sourceEndpoint !== ep.ID) { continue; } if (entry.destAddrMode === 0x01) { newBinds.push({type: "group", cluster: entry.clusterId, groupID: entry.dest as number}); } else { newBinds.push({ type: "endpoint", cluster: entry.clusterId, deviceIeeeAddress: entry.dest as Eui64, endpointID: entry.destEndpoint as number, }); } } ep.saveBindings(newBinds); } return table; } /** * Clear all the bindings of a device. * Support of this command is optional (only mandatory if device has a binding table). * @param eui64List list of bind entries to match and clear. Send `["0xffffffffffffffff"]` to clear all. */ public async clearAllBindings(eui64List: Eui64[]): Promise { const adapter = Entity.getAdapterByID(this.databaseID); if (!adapter) { throw new Error(`No adapter found for database ID ${this.databaseID}`); } const clusterId = Zdo.ClusterId.CLEAR_ALL_BINDINGS_REQUEST; const zdoPayload = Zdo.Buffalo.buildRequest(adapter.hasZdoMessageOverhead, clusterId, {eui64List}); const response = await adapter.sendZdo(this.ieeeAddr, this.networkAddress, clusterId, zdoPayload, false); if (!Zdo.Buffalo.checkStatus(response)) { throw new Zdo.StatusError(response[0]); } if ( (eui64List.length === 1 && eui64List[0].toLowerCase() === ZSpec.BLANK_EUI64) || eui64List.some((eui64) => eui64.toLowerCase() === this.ieeeAddr) ) { for (const ep of this._endpoints) { ep.clearBindings(); } } } public async ping(disableRecovery = true): Promise { // Zigbee does not have an official pinging mechanism. Use a read request // of a mandatory basic cluster attribute to keep it as lightweight as // possible. const endpoint = this.endpoints.find((ep) => ep.inputClusters.includes(GEN_BASIC_CLUSTER_ID)) ?? this.endpoints[0]; await endpoint.read("genBasic", ["zclVersion"], {disableRecovery, sendPolicy: "immediate"}); } public addCustomCluster(name: string, cluster: Cluster): void { assert( cluster.ID !== Zcl.Clusters.touchlink.ID && cluster.ID !== Zcl.Clusters.greenPower.ID, "Overriding of greenPower or touchlink cluster is not supported", ); if (Zcl.Utils.isClusterName(name)) { // Extend existing cluster const existingCluster = this._customClusters[name] ?? Zcl.Clusters[name]; assert(existingCluster.ID === cluster.ID, `Custom cluster ID (${cluster.ID}) should match existing cluster ID (${existingCluster.ID})`); const extendedCluster: Cluster = { name: cluster.name, ID: cluster.ID, manufacturerCode: cluster.manufacturerCode, attributes: {...existingCluster.attributes, ...cluster.attributes}, commands: {...existingCluster.commands, ...cluster.commands}, commandsResponse: {...existingCluster.commandsResponse, ...cluster.commandsResponse}, }; this._customClusters[name] = extendedCluster; } else { this._customClusters[name] = cluster; } } #waitForOtaCommand( endpointId: number, commandId: number, defaultRspCommandId: number | undefined, timeout: number, ): {promise: Promise | TFoundationZclFrame<"defaultRsp">>; cancel: () => void} { const adapter = Entity.getAdapterByID(this.databaseID); if (!adapter) { throw new Error(`No adapter found for database ID ${this.databaseID}`); } const waiter = adapter.waitFor( this.networkAddress, endpointId, Zcl.FrameType.SPECIFIC, Zcl.Direction.CLIENT_TO_SERVER, undefined, GEN_OTA_CLUSTER_ID, commandId, defaultRspCommandId, timeout, ); const promise = new Promise | TFoundationZclFrame<"defaultRsp">>((resolve, reject) => { waiter.promise.then( (payload) => { try { const frame = Zcl.Frame.fromBuffer(payload.clusterID, payload.header, payload.data, this.customClusters); resolve(frame as TZclFrame<"genOta", Co> | TFoundationZclFrame<"defaultRsp">); } catch (error) { reject(error); } }, (error) => reject(error), ); }); return {promise, cancel: waiter.cancel}; } async findMatchingOtaImage( source: OtaSource, current: TClusterCommandPayload<"genOta", "queryNextImageRequest">, extraMetas: OtaExtraMetas, ): Promise { logger.debug(() => `Getting image metadata for ${this.ieeeAddr}...`, NS); const images = await getOtaIndex(source); // NOTE: Officially an image can be determined with a combination of manufacturerCode and imageType. // However several manufacturers do not follow the spec properly. // The index provides the needed extra metadata to prevent mismatches. // e.g. Tuya must match on manufacturerName, Gledopto on modelId... return images.find( (i) => i.imageType === current.imageType && i.manufacturerCode === current.manufacturerCode && (i.minFileVersion === undefined || current.fileVersion >= i.minFileVersion) && (i.maxFileVersion === undefined || current.fileVersion <= i.maxFileVersion) && // let extra metas override the match from this.modelID, same for manufacturerName (!i.modelId || i.modelId === this.modelID || i.modelId === extraMetas.modelId) && (!i.manufacturerName || // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` i.manufacturerName.includes(this.manufacturerName!) || // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` i.manufacturerName.includes(extraMetas.manufacturerName!)) && (!extraMetas.otaHeaderString || i.otaHeaderString === extraMetas.otaHeaderString) && (i.hardwareVersionMin === undefined || (current.hardwareVersion !== undefined && current.hardwareVersion >= i.hardwareVersionMin) || (extraMetas.hardwareVersionMin !== undefined && extraMetas.hardwareVersionMin >= i.hardwareVersionMin)) && (i.hardwareVersionMax === undefined || (current.hardwareVersion !== undefined && current.hardwareVersion <= i.hardwareVersionMax) || (extraMetas.hardwareVersionMax !== undefined && extraMetas.hardwareVersionMax <= i.hardwareVersionMax)), ); } async #notifyOta(endpoint: Endpoint): Promise<[payload: TClusterCommandPayload<"genOta", "queryNextImageRequest">, tsn: number]> { // Some devices (e.g. Insta) take a very long trying to discover the correct coordinator EP for OTA const queryNextImageRequest = this.#waitForOtaCommand<"queryNextImageRequest">( endpoint.ID, Zcl.Clusters.genOta.commands.queryNextImageRequest.ID, Zcl.Clusters.genOta.commandsResponse.imageNotify.ID, 60000, ); try { await endpoint.commandResponse("genOta", "imageNotify", {payloadType: 0, queryJitter: 100}, {sendPolicy: "immediate"}); const response = await queryNextImageRequest.promise; assert(response.header.isSpecific); return [(response as TZclFrame<"genOta", "queryNextImageRequest">).payload, response.header.transactionSequenceNumber]; } catch { queryNextImageRequest.cancel(); throw new Error(`Device didn't respond to OTA request`); } } /** * If `current` is undefined, will automatically notify and reply to query with `NO_IMAGE_AVAILABLE` (stops device from doing further requests). */ async checkOta( source: OtaSource, current: TClusterCommandPayload<"genOta", "queryNextImageRequest"> | undefined, extraMetas: OtaExtraMetas, endpoint = this.endpoints.find((e) => e.supportsOutputCluster("genOta")), ): Promise { assert(endpoint !== undefined, `No endpoint found with OTA cluster support for ${this.ieeeAddr}`); if (this.modelID === "PP-WHT-US") { // see https://github.com/Koenkk/zigbee-OTA/pull/14 const scenesEndpoint = this.endpoints.find((e) => e.supportsOutputCluster("genScenes")); if (scenesEndpoint !== undefined) { await scenesEndpoint.write("genScenes", {currentGroup: 49502}, {disableDefaultResponse: true, sendPolicy: "immediate"}); } } if (current === undefined) { let queryTsn: number; [current, queryTsn] = await this.#notifyOta(endpoint); await endpoint.commandResponse("genOta", "queryNextImageResponse", {status: Zcl.Status.NO_IMAGE_AVAILABLE}, undefined, queryTsn); } logger.debug( () => `Checking OTA ${this.ieeeAddr} ${source.downgrade ? "downgrade" : "upgrade"} image availability, current=${JSON.stringify(current)}`, NS, ); if ( this.meta.lumiFileVersion && (this.modelID === "lumi.airrtc.agl001" || this.modelID === "lumi.curtain.acn003" || this.modelID === "lumi.curtain.agl001") ) { // The current.fileVersion which comes from the device is wrong. // Use the `lumiFileVersion` which comes from the manuSpecificLumi.attributeReport instead. // https://github.com/Koenkk/zigbee2mqtt/issues/16345#issuecomment-1454835056 // https://github.com/Koenkk/zigbee2mqtt/issues/16345 doesn't seem to be needed for all // https://github.com/Koenkk/zigbee2mqtt/issues/15745 current = {...current, fileVersion: this.meta.lumiFileVersion}; } const meta = await this.findMatchingOtaImage(source, current, extraMetas); if (!meta) { // no image in repo/URL for specified device return { available: false, current, }; } logger.debug( () => `OTA ${source.downgrade ? "downgrade" : "upgrade"} image availability for ${this.ieeeAddr}, available=${JSON.stringify(meta)}`, NS, ); return { available: meta.force ? true : source.downgrade ? current.fileVersion > meta.fileVersion : current.fileVersion < meta.fileVersion, current, availableMeta: meta, }; } async updateOta( source: Readonly | undefined, requestPayload: TClusterCommandPayload<"genOta", "queryNextImageRequest"> | undefined, requestTsn: number | undefined, extraMetas: Readonly, onProgress: (progress: number, remaining: number) => void, dataSettings: OtaDataSettings, endpoint = this.endpoints.find((e) => e.supportsOutputCluster("genOta")), ): Promise<[from: OtaUpdateAvailableResult["current"], to: OtaUpdateAvailableResult["current"] | undefined]> { assert(this.#otaInProgress === false, `OTA already in progress for ${this.ieeeAddr}`); assert(endpoint !== undefined, `No endpoint found with OTA cluster support for ${this.ieeeAddr}`); if (source === undefined) { assert(this.#scheduledOta !== undefined, `No currently scheduled OTA for ${this.ieeeAddr}`); source = this.#scheduledOta; } this.#otaInProgress = true; // always expected both undefined if one is, but just in case if (requestPayload === undefined || requestTsn === undefined) { try { [requestPayload, requestTsn] = await this.#notifyOta(endpoint); } finally { this.#otaInProgress = false; } } let available: OtaUpdateAvailableResult["available"] = false; let image: OtaImage | undefined; if (source.url && !source.url.endsWith(".json")) { // firmware file at `source.url` try { const downloadedFile = await getOtaFirmware(source.url, undefined); image = parseOtaImage(downloadedFile); available = source.downgrade ? requestPayload.fileVersion > image.header.fileVersion : requestPayload.fileVersion < image.header.fileVersion; logger.debug( () => // biome-ignore lint/style/noNonNullAssertion: valid from above, won't change after assignment `Parsed image from '${source.url}' for ${this.ieeeAddr}, header=${JSON.stringify(image!.header)}`, NS, ); } catch (error) { logger.error(`Failed to parse OTA image from '${source.url}' for ${this.ieeeAddr}, aborting (${(error as Error).message})`, NS); // biome-ignore lint/style/noNonNullAssertion: expected valid logger.debug((error as Error).stack!, NS); } } else { let availableMeta: OtaUpdateAvailableResult["availableMeta"]; try { // index file at `source.url` (or undefined to use defaults) ({available, availableMeta} = await this.checkOta(source, requestPayload, extraMetas, endpoint)); } finally { this.#otaInProgress = false; } if (available && availableMeta) { try { const downloadedFile = await getOtaFirmware(availableMeta.url, availableMeta.sha512); image = parseOtaImage(downloadedFile); logger.debug( () => // biome-ignore lint/style/noNonNullAssertion: valid from above, won't change after assignment `Parsed image from '${availableMeta.url}' for ${this.ieeeAddr}, header=${JSON.stringify(image!.header)}`, NS, ); } catch (error) { logger.error(`Failed to parse OTA image for ${this.ieeeAddr}, aborting (${(error as Error).message})`, NS); // biome-ignore lint/style/noNonNullAssertion: expected valid logger.debug((error as Error).stack!, NS); } } else { logger.info(() => `No OTA ${source.downgrade ? "downgrade" : "upgrade"} image currently available for ${this.ieeeAddr}`, NS); } } // reply to `queryNextImageRequest` now that we have the data for it, should trigger image block/page request from device // NOTE: previous code had try/catch wrapping with ignored error, but that doesn't look good (would fail to start OTA from device side) try { await endpoint.commandResponse( "genOta", "queryNextImageResponse", image && available ? { status: Zcl.Status.SUCCESS, manufacturerCode: image.header.manufacturerCode, imageType: image.header.imageType, fileVersion: image.header.fileVersion, imageSize: image.header.totalImageSize, } : {status: Zcl.Status.NO_IMAGE_AVAILABLE}, undefined, requestTsn, ); } finally { this.#otaInProgress = false; } if (!image || !available) { this.#otaInProgress = false; return [requestPayload, undefined]; } logger.debug(() => `Starting OTA update for ${this.ieeeAddr}`, NS); const session = new OtaSession(this.ieeeAddr, endpoint, image, onProgress, dataSettings, this.#waitForOtaCommand.bind(this)); let endResult: TZclFrame<"genOta", "upgradeEndRequest">; try { this.#otaAbortController = new AbortController(); const runEnd = await session.run(this.#otaAbortController.signal); assert(runEnd.header.isSpecific); endResult = runEnd as TZclFrame<"genOta", "upgradeEndRequest">; } finally { this.#otaInProgress = false; this.#otaAbortController = undefined; } logger.debug(() => `Received upgrade end request for ${this.ieeeAddr}: ${JSON.stringify(endResult.payload)}`, NS); if (endResult.payload.status === Zcl.Status.SUCCESS) { try { const currentTime = timeService.timestampToZigbeeUtcTime(Date.now()); await endpoint.commandResponse( "genOta", "upgradeEndResponse", { manufacturerCode: image.header.manufacturerCode, imageType: image.header.imageType, fileVersion: image.header.fileVersion, currentTime, upgradeTime: currentTime + 1, // TODO: could this tiny offset be a problem for some stacks? }, undefined, endResult.header.transactionSequenceNumber, ); onProgress(100, 0); logger.info( () => `Update of ${this.ieeeAddr} successful (${Math.round((performance.now() - session.startTime) / 1000)} seconds). Waiting for device announce...`, NS, ); let timer: NodeJS.Timeout; await new Promise((resolve) => { const onDeviceAnnounce = () => { clearTimeout(timer); logger.debug(() => `Received device announce for ${this.ieeeAddr}, OTA update finished.`, NS); resolve(); }; // force "finished" after given time timer = setTimeout(() => { this.removeListener("deviceAnnounce", onDeviceAnnounce); logger.debug(() => `Timed out waiting for device announce for ${this.ieeeAddr}, OTA update considered finished.`, NS); resolve(); }, 120000 /** consider "done" after timeout even if no announce seen */); this.once("deviceAnnounce", onDeviceAnnounce); }); // only "cancel" possible scheduled OTA when successful this.#scheduledOta = undefined; this.#otaInProgress = false; return [ requestPayload, { fieldControl: 0, manufacturerCode: image.header.manufacturerCode, imageType: image.header.imageType, fileVersion: image.header.fileVersion, }, ]; } catch (error) { this.#otaInProgress = false; throw new Error(`OTA upgrade end response failed: ${(error as Error).message}`); } } else { /** * For other status value received such as INVALID_IMAGE, REQUIRE_MORE_IMAGE, or ABORT, * the upgrade server SHALL not send Upgrade End Response command but it SHALL send default * response command with status of success and it SHALL wait for the client to reinitiate the upgrade process. */ try { await endpoint.defaultResponse( Zcl.Clusters.genOta.commands.upgradeEndRequest.ID, Zcl.Status.SUCCESS, GEN_OTA_CLUSTER_ID, endResult.header.transactionSequenceNumber, ); } catch (error) { logger.debug(() => `OTA upgrade end request default response for ${this.ieeeAddr} failed: ${(error as Error).message}`, NS); } this.#otaInProgress = false; throw new Error(`OTA update of ${this.ieeeAddr} failed with reason: ${Zcl.Status[endResult.payload.status]}`); } } /** * Abort running OTA if any. Send `ABORT` with next block response to device. */ abortOta(): void { this.#otaAbortController?.abort(); } scheduleOta(source: OtaSource): void { assert( this.endpoints.some((e) => e.supportsOutputCluster("genOta")), `No endpoint found with OTA cluster support for ${this.ieeeAddr}`, ); if (this.#scheduledOta) { logger.info(`Previously scheduled OTA update for '${this.ieeeAddr}' was cancelled in favor of new schedule request`, NS); } this.#scheduledOta = source; logger.info(`Scheduled OTA update for '${this.ieeeAddr}' on next request from device`, NS); } unscheduleOta(): void { if (this.#scheduledOta !== undefined) { this.#scheduledOta = undefined; logger.info(`Previously scheduled OTA update for '${this.ieeeAddr}' was cancelled`, NS); } } } export default Device;