UNPKG

inventoresed

Version:

Z-Wave driver written entirely in JavaScript/TypeScript

1,654 lines (1,520 loc) 139 kB
import { JsonlDB, JsonlDBOptions } from "@alcalzone/jsonl-db"; import * as Sentry from "@sentry/node"; import { assertValidCCs, CommandClass, CRC16CC, DeviceResetLocallyCCNotification, FirmwareUpdateStatus, getImplementedVersion, ICommandClassContainer, InvalidCC, isCommandClassContainer, isEncapsulatingCommandClass, isMultiEncapsulatingCommandClass, isTransportServiceEncapsulation, KEXFailType, messageIsPing, MultiChannelCC, Security2CC, Security2CCNonceReport, SecurityCC, SecurityCCCommandEncapsulationNonceGet, SupervisionCC, SupervisionCCGet, SupervisionCCReport, TransportServiceCCFirstSegment, TransportServiceCCSegmentComplete, TransportServiceCCSegmentRequest, TransportServiceCCSegmentWait, TransportServiceCCSubsequentSegment, TransportServiceTimeouts, WakeUpCCNoMoreInformation, WakeUpCCValues, } from "@zwave-js/cc"; import { ConfigManager, DeviceConfig, externalConfigDir, } from "@zwave-js/config"; import { CommandClasses, ControllerLogger, deserializeCacheValue, dskFromString, EncapsulationFlags, highResTimestamp, ICommandClass, isZWaveError, LogConfig, MAX_SUPERVISION_SESSION_ID, Maybe, MessagePriority, nwiHomeIdFromDSK, SecurityClass, securityClassIsS2, SecurityManager, SecurityManager2, SendCommandOptions, SendCommandReturnType, SendMessageOptions, serializeCacheValue, SinglecastCC, SPANState, SupervisionResult, SupervisionStatus, SupervisionUpdateHandler, timespan, TransmitOptions, ValueDB, ValueID, ValueMetadata, ZWaveError, ZWaveErrorCodes, ZWaveLogContainer, } from "@zwave-js/core"; import type { NodeSchedulePollOptions, ZWaveApplicationHost, } from "@zwave-js/host"; import { FunctionType, getDefaultPriority, INodeQuery, isNodeQuery, isSuccessIndicator, isZWaveSerialPortImplementation, Message, MessageHeaders, MessageType, ZWaveSerialPort, ZWaveSerialPortBase, ZWaveSerialPortImplementation, ZWaveSocket, } from "@zwave-js/serial"; import { createWrappingCounter, DeepPartial, getErrorMessage, isDocker, mergeDeep, num2hex, pick, ReadonlyThrowingMap, ThrowingMap, TypedEventEmitter, } from "@zwave-js/shared"; import { wait } from "alcalzone-shared/async"; import { createDeferredPromise, DeferredPromise, } from "alcalzone-shared/deferred-promise"; import { isArray, isObject } from "alcalzone-shared/typeguards"; import { randomBytes } from "crypto"; import type { EventEmitter } from "events"; import fsExtra from "fs-extra"; import path from "path"; import { SerialPort } from "serialport"; import { URL } from "url"; import * as util from "util"; import { interpret } from "xstate"; import { ZWaveController } from "../controller/Controller"; import { InclusionState, ProvisioningEntryStatus, } from "../controller/Inclusion"; import { DriverLogger } from "../log/Driver"; import type { Endpoint } from "../node/Endpoint"; import type { ZWaveNode } from "../node/Node"; import { InterviewStage, NodeStatus } from "../node/_Types"; import { ApplicationCommandRequest } from "../serialapi/application/ApplicationCommandRequest"; import { ApplicationUpdateRequest, ApplicationUpdateRequestNodeInfoReceived, ApplicationUpdateRequestSmartStartHomeIDReceived, } from "../serialapi/application/ApplicationUpdateRequest"; import { BridgeApplicationCommandRequest } from "../serialapi/application/BridgeApplicationCommandRequest"; import type { SerialAPIStartedRequest } from "../serialapi/application/SerialAPIStartedRequest"; import { GetControllerVersionRequest } from "../serialapi/capability/GetControllerVersionMessages"; import { SoftResetRequest } from "../serialapi/misc/SoftResetRequest"; import { SendDataBridgeRequest, SendDataMulticastBridgeRequest, } from "../serialapi/transport/SendDataBridgeMessages"; import { MAX_SEND_ATTEMPTS, SendDataAbort, SendDataMulticastRequest, SendDataRequest, } from "../serialapi/transport/SendDataMessages"; import { hasTXReport, isSendData, isSendDataSinglecast, isSendDataTransmitReport, isTransmitReport, SendDataMessage, } from "../serialapi/transport/SendDataShared"; import { reportMissingDeviceConfig } from "../telemetry/deviceConfig"; import { initSentry } from "../telemetry/sentry"; import { AppInfo, compileStatistics, sendStatistics, } from "../telemetry/statistics"; import { createMessageGenerator } from "./MessageGenerators"; import { cacheKeys, deserializeNetworkCacheValue, migrateLegacyNetworkCache, serializeNetworkCacheValue, } from "./NetworkCache"; import { createSendThreadMachine, SendThreadInterpreter, TransactionReducer, TransactionReducerResult, } from "./SendThreadMachine"; import { throttlePresets } from "./ThrottlePresets"; import { Transaction } from "./Transaction"; import { createTransportServiceRXMachine, TransportServiceRXInterpreter, } from "./TransportServiceMachine"; import { checkForConfigUpdates, installConfigUpdate, installConfigUpdateInDocker, } from "./UpdateConfig"; import type { ZWaveOptions } from "./ZWaveOptions"; const packageJsonPath = require.resolve("zwave-js/package.json"); // eslint-disable-next-line @typescript-eslint/no-var-requires const packageJson = require(packageJsonPath); const libraryRootDir = path.dirname(packageJsonPath); export const libVersion: string = packageJson.version; export const libName: string = packageJson.name; // This is made with cfonts: const libNameString = ` ███████╗ ██╗ ██╗ █████╗ ██╗ ██╗ ███████╗ ██╗ ███████╗ ╚══███╔╝ ██║ ██║ ██╔══██╗ ██║ ██║ ██╔════╝ ██║ ██╔════╝ ███╔╝ ██║ █╗ ██║ ███████║ ██║ ██║ █████╗ █████╗ ██║ ███████╗ ███╔╝ ██║███╗██║ ██╔══██║ ╚██╗ ██╔╝ ██╔══╝ ╚════╝ ██ ██║ ╚════██║ ███████╗ ╚███╔███╔╝ ██║ ██║ ╚████╔╝ ███████╗ ╚█████╔╝ ███████║ ╚══════╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ╚═══╝ ╚══════╝ ╚════╝ ╚══════╝ `; const defaultOptions: ZWaveOptions = { timeouts: { ack: 1000, byte: 150, response: 10000, report: 1000, // ReportTime timeout SHOULD be set to CommandTime + 1 second nonce: 5000, sendDataCallback: 65000, // as defined in INS13954 refreshValue: 5000, // Default should handle most slow devices until we have a better solution refreshValueAfterTransition: 1000, // To account for delays in the device serialAPIStarted: 5000, }, attempts: { openSerialPort: 10, controller: 3, sendData: 3, nodeInterview: 5, }, preserveUnknownValues: false, disableOptimisticValueUpdate: false, // By default enable soft reset unless the env variable is set enableSoftReset: !process.env.ZWAVEJS_DISABLE_SOFT_RESET, interview: { queryAllUserCodes: false, }, storage: { driver: fsExtra, cacheDir: path.resolve(libraryRootDir, "cache"), lockDir: process.env.ZWAVEJS_LOCK_DIRECTORY, throttle: "normal", }, preferences: { scales: { temperature: "Celsius", }, }, }; /** Ensures that the options are valid */ function checkOptions(options: ZWaveOptions): void { if (options.timeouts.ack < 1) { throw new ZWaveError( `The ACK timeout must be positive!`, ZWaveErrorCodes.Driver_InvalidOptions, ); } if (options.timeouts.byte < 1) { throw new ZWaveError( `The BYTE timeout must be positive!`, ZWaveErrorCodes.Driver_InvalidOptions, ); } if (options.timeouts.response < 500 || options.timeouts.response > 20000) { throw new ZWaveError( `The Response timeout must be between 500 and 20000 milliseconds!`, ZWaveErrorCodes.Driver_InvalidOptions, ); } if (options.timeouts.report < 500 || options.timeouts.report > 10000) { throw new ZWaveError( `The Report timeout must be between 500 and 10000 milliseconds!`, ZWaveErrorCodes.Driver_InvalidOptions, ); } if (options.timeouts.nonce < 3000 || options.timeouts.nonce > 20000) { throw new ZWaveError( `The Nonce timeout must be between 3000 and 20000 milliseconds!`, ZWaveErrorCodes.Driver_InvalidOptions, ); } if (options.timeouts.sendDataCallback < 10000) { throw new ZWaveError( `The Send Data Callback timeout must be at least 10000 milliseconds!`, ZWaveErrorCodes.Driver_InvalidOptions, ); } if ( options.timeouts.serialAPIStarted < 1000 || options.timeouts.serialAPIStarted > 30000 ) { throw new ZWaveError( `The Serial API started timeout must be between 1000 and 30000 milliseconds!`, ZWaveErrorCodes.Driver_InvalidOptions, ); } if (options.securityKeys != undefined) { const keys = Object.entries(options.securityKeys); for (let i = 0; i < keys.length; i++) { const [secClass, key] = keys[i]; if (key.length !== 16) { throw new ZWaveError( `The security key for class ${secClass} must be a buffer with length 16!`, ZWaveErrorCodes.Driver_InvalidOptions, ); } if (keys.findIndex(([, k]) => k.equals(key)) !== i) { throw new ZWaveError( `The security key for class ${secClass} was used multiple times!`, ZWaveErrorCodes.Driver_InvalidOptions, ); } } } if (options.attempts.controller < 1 || options.attempts.controller > 3) { throw new ZWaveError( `The Controller attempts must be between 1 and 3!`, ZWaveErrorCodes.Driver_InvalidOptions, ); } if ( options.attempts.sendData < 1 || options.attempts.sendData > MAX_SEND_ATTEMPTS ) { throw new ZWaveError( `The SendData attempts must be between 1 and ${MAX_SEND_ATTEMPTS}!`, ZWaveErrorCodes.Driver_InvalidOptions, ); } if ( options.attempts.nodeInterview < 1 || options.attempts.nodeInterview > 10 ) { throw new ZWaveError( `The Node interview attempts must be between 1 and 10!`, ZWaveErrorCodes.Driver_InvalidOptions, ); } if (options.inclusionUserCallbacks) { if (!isObject(options.inclusionUserCallbacks)) { throw new ZWaveError( `The inclusionUserCallbacks must be an object!`, ZWaveErrorCodes.Driver_InvalidOptions, ); } else if ( typeof options.inclusionUserCallbacks.grantSecurityClasses !== "function" || typeof options.inclusionUserCallbacks.validateDSKAndEnterPIN !== "function" || typeof options.inclusionUserCallbacks.abort !== "function" ) { throw new ZWaveError( `The inclusionUserCallbacks must contain the following functions: grantSecurityClasses, validateDSKAndEnterPIN, abort!`, ZWaveErrorCodes.Driver_InvalidOptions, ); } } } /** * Function signature for a message handler. The return type signals if the * message was handled (`true`) or further handlers should be called (`false`) */ export type RequestHandler<T extends Message = Message> = ( msg: T, ) => boolean | Promise<boolean>; interface RequestHandlerEntry<T extends Message = Message> { invoke: RequestHandler<T>; oneTime: boolean; } interface AwaitedMessageEntry { promise: DeferredPromise<Message>; timeout?: NodeJS.Timeout; predicate: (msg: Message) => boolean; } interface AwaitedCommandEntry { promise: DeferredPromise<ICommandClass>; timeout?: NodeJS.Timeout; predicate: (cc: ICommandClass) => boolean; } interface TransportServiceSession { fragmentSize: number; interpreter: TransportServiceRXInterpreter; } interface Sessions { /** A map of all current Transport Service sessions that may still receive updates */ transportService: Map<number, TransportServiceSession>; /** A map of all current supervision sessions that may still receive updates */ supervision: Map<number, SupervisionUpdateHandler>; } // Strongly type the event emitter events export interface DriverEventCallbacks { "driver ready": () => void; "all nodes ready": () => void; error: (err: Error) => void; } export type DriverEvents = Extract<keyof DriverEventCallbacks, string>; /** * The driver is the core of this library. It controls the serial interface, * handles transmission and receipt of messages and manages the network cache. * Any action you want to perform on the Z-Wave network must go through a driver * instance or its associated nodes. */ export class Driver extends TypedEventEmitter<DriverEventCallbacks> implements ZWaveApplicationHost { public constructor( private port: string | ZWaveSerialPortImplementation, options?: DeepPartial<ZWaveOptions>, ) { super(); // Ensure the given serial port is valid if ( typeof port !== "string" && !isZWaveSerialPortImplementation(port) ) { throw new ZWaveError( `The port must be a string or a valid custom serial port implementation!`, ZWaveErrorCodes.Driver_InvalidOptions, ); } // merge given options with defaults this.options = mergeDeep(options, defaultOptions) as ZWaveOptions; // And make sure they contain valid values checkOptions(this.options); // Initialize logging this._logContainer = new ZWaveLogContainer(this.options.logConfig); this._driverLog = new DriverLogger(this, this._logContainer); this._controllerLog = new ControllerLogger(this._logContainer); // Initialize the cache this.cacheDir = this.options.storage.cacheDir; // Initialize config manager this.configManager = new ConfigManager({ logContainer: this._logContainer, deviceConfigPriorityDir: this.options.storage.deviceConfigPriorityDir, }); // And initialize but don't start the send thread machine const sendThreadMachine = createSendThreadMachine( { sendData: this.writeSerial.bind(this), createSendDataAbort: () => new SendDataAbort(this), notifyUnsolicited: (msg) => { void this.handleUnsolicitedMessage(msg); }, notifyRetry: ( command, lastError, message, attempts, maxAttempts, delay, ) => { if (command === "SendData") { this.controllerLog.logNode( message.getNodeId() ?? 255, `did not respond after ${attempts}/${maxAttempts} attempts. Scheduling next try in ${delay} ms.`, "warn", ); } else { // Translate the error into a better one let errorReason: string; switch (lastError) { case "response timeout": errorReason = "No response from controller"; this._controller?.incrementStatistics( "timeoutResponse", ); break; case "callback timeout": errorReason = "No callback from controller"; this._controller?.incrementStatistics( "timeoutCallback", ); break; case "response NOK": errorReason = "The controller response indicated failure"; break; case "callback NOK": errorReason = "The controller callback indicated failure"; break; case "ACK timeout": this._controller?.incrementStatistics( "timeoutACK", ); // fall through case "CAN": case "NAK": default: errorReason = "Failed to execute controller command"; break; } this.controllerLog.print( `${errorReason} after ${attempts}/${maxAttempts} attempts. Scheduling next try in ${delay} ms.`, "warn", ); } }, timestamp: highResTimestamp, rejectTransaction: (transaction, error) => { // If a node failed to respond in time, it might be sleeping if (this.isMissingNodeACK(transaction, error)) { if (this.handleMissingNodeACK(transaction as any)) return; } // If the transaction was already started, we need to throw the error into the message generator // so it correctly gets ended. Otherwise just reject the result promise if (transaction.parts.self) { // eslint-disable-next-line @typescript-eslint/no-empty-function transaction.parts.self.throw(error).catch(() => {}); } else { transaction.promise.reject(error); } }, resolveTransaction: (transaction, result) => { // If the transaction was already started, we need to end the message generator early by throwing // the result. Otherwise just resolve the result promise if (transaction.parts.self) { // eslint-disable-next-line @typescript-eslint/no-empty-function transaction.parts.self.throw(result).catch(() => {}); } else { transaction.promise.resolve(result); } }, logOutgoingMessage: (msg: Message) => { this.driverLog.logMessage(msg, { direction: "outbound", }); if (process.env.NODE_ENV !== "test") { // Enrich error data in case something goes wrong Sentry.addBreadcrumb({ category: "message", timestamp: Date.now() / 1000, type: "debug", data: { direction: "outbound", msgType: msg.type, functionType: msg.functionType, name: msg.constructor.name, nodeId: msg.getNodeId(), ...msg.toLogEntry(), }, }); } }, log: this.driverLog.print.bind(this.driverLog), logQueue: this.driverLog.sendQueue.bind(this.driverLog), }, pick(this.options, ["timeouts", "attempts"]), ); this.sendThread = interpret(sendThreadMachine); // this.sendThread.onTransition((state) => { // if (state.changed) // this.driverLog.print( // `send thread state: ${state.toStrings().join("->")}`, // "verbose", // ); // }); // this.sendThread.onEvent((evt) => { // if (evt.type === "forward") { // this.driverLog.print( // // @ts-ignore // `forwarding event: ${evt.payload.type} from ${evt.from} to ${evt.to}`, // "verbose", // ); // } else { // this.driverLog.print( // `send thread event: ${evt.type}`, // "verbose", // ); // } // }); } /** The serial port instance */ private serial: ZWaveSerialPortBase | undefined; /** An instance of the Send Thread state machine */ private sendThread: SendThreadInterpreter; /** A map of handlers for all sorts of requests */ private requestHandlers = new Map<FunctionType, RequestHandlerEntry[]>(); /** A map of awaited messages */ private awaitedMessages: AwaitedMessageEntry[] = []; /** A map of awaited commands */ private awaitedCommands: AwaitedCommandEntry[] = []; /** A map of Node ID -> ongoing sessions */ private nodeSessions = new Map<number, Sessions>(); private ensureNodeSessions(nodeId: number): Sessions { if (!this.nodeSessions.has(nodeId)) { this.nodeSessions.set(nodeId, { transportService: new Map(), supervision: new Map(), }); } return this.nodeSessions.get(nodeId)!; } public readonly cacheDir: string; private _valueDB: JsonlDB | undefined; /** @internal */ public get valueDB(): JsonlDB | undefined { return this._valueDB; } private _metadataDB: JsonlDB<ValueMetadata> | undefined; /** @internal */ public get metadataDB(): JsonlDB<ValueMetadata> | undefined { return this._metadataDB; } private _networkCache: JsonlDB<any> | undefined; /** @internal */ public get networkCache(): JsonlDB<any> { if (this._networkCache == undefined) { throw new ZWaveError( "The network cache was not yet initialized!", ZWaveErrorCodes.Driver_NotReady, ); } return this._networkCache; } public readonly configManager: ConfigManager; public get configVersion(): string { return ( this.configManager?.configVersion ?? packageJson?.dependencies?.["@zwave-js/config"] ?? libVersion ); } private _logContainer: ZWaveLogContainer; private _driverLog: DriverLogger; /** @internal */ public get driverLog(): DriverLogger { return this._driverLog; } private _controllerLog: ControllerLogger; /** @internal */ public get controllerLog(): ControllerLogger { return this._controllerLog; } private _controller: ZWaveController | undefined; /** Encapsulates information about the Z-Wave controller and provides access to its nodes */ public get controller(): ZWaveController { if (this._controller == undefined) { throw new ZWaveError( "The controller is not yet ready!", ZWaveErrorCodes.Driver_NotReady, ); } return this._controller; } private _securityManager: SecurityManager | undefined; /** @internal */ public get securityManager(): SecurityManager | undefined { return this._securityManager; } private _securityManager2: SecurityManager2 | undefined; /** @internal */ public get securityManager2(): SecurityManager2 | undefined { return this._securityManager2; } /** @internal This is needed for the ZWaveHost interface */ public get homeId(): number { return this.controller.homeId!; } /** @internal This is needed for the ZWaveHost interface */ public get ownNodeId(): number { return this.controller.ownNodeId!; } /** @internal This is needed for the ZWaveHost interface */ public get nodes(): ReadonlyThrowingMap<number, ZWaveNode> { return this.controller.nodes; } public getNodeUnsafe(msg: Message): ZWaveNode | undefined { const nodeId = msg.getNodeId(); if (nodeId != undefined) return this.controller.nodes.get(nodeId); } public tryGetEndpoint(cc: CommandClass): Endpoint | undefined { if (typeof cc.nodeId === "number") { return this.controller.nodes .get(cc.nodeId) ?.getEndpoint(cc.endpointIndex); } } /** @internal This is needed for the ZWaveHost interface */ public getValueDB(nodeId: number): ValueDB { const node = this.controller.nodes.getOrThrow(nodeId); return node.valueDB; } /** @internal This is needed for the ZWaveHost interface */ public tryGetValueDB(nodeId: number): ValueDB | undefined { const node = this.controller.nodes.get(nodeId); return node?.valueDB; } /** @internal This is needed for the ZWaveHost interface */ public getDeviceConfig(nodeId: number): DeviceConfig | undefined { return this.controller.nodes.get(nodeId)?.deviceConfig; } /** @internal This is needed for the ZWaveHost interface */ public getHighestSecurityClass(nodeId: number): SecurityClass | undefined { const node = this.controller.nodes.getOrThrow(nodeId); return node.getHighestSecurityClass(); } /** @internal This is needed for the ZWaveHost interface */ public hasSecurityClass( nodeId: number, securityClass: SecurityClass, ): Maybe<boolean> { const node = this.controller.nodes.getOrThrow(nodeId); return node.hasSecurityClass(securityClass); } /** @internal This is needed for the ZWaveHost interface */ public setSecurityClass( nodeId: number, securityClass: SecurityClass, granted: boolean, ): void { const node = this.controller.nodes.getOrThrow(nodeId); node.setSecurityClass(securityClass, granted); } /** @internal This is needed for the ZWaveHost interface */ public isControllerNode(nodeId: number): boolean { return nodeId === this.ownNodeId; } /** Updates the logging configuration without having to restart the driver. */ public updateLogConfig(config: DeepPartial<LogConfig>): void { this._logContainer.updateConfiguration(config); } /** Returns the current logging configuration. */ public getLogConfig(): LogConfig { return this._logContainer.getConfiguration(); } /** Updates the preferred sensor scales to use for node queries */ public setPreferredScales( scales: ZWaveOptions["preferences"]["scales"], ): void { this.options.preferences.scales = mergeDeep( defaultOptions.preferences.scales, scales, ); } /** Enumerates all existing serial ports */ public static async enumerateSerialPorts(): Promise<string[]> { const ports = await SerialPort.list(); return ports.map((port) => port.path); } /** @internal */ public options: ZWaveOptions; private _wasStarted: boolean = false; private _isOpen: boolean = false; /** Start the driver */ public async start(): Promise<void> { // avoid starting twice if (this.wasDestroyed) { throw new ZWaveError( "The driver was destroyed. Create a new instance and start that one.", ZWaveErrorCodes.Driver_Destroyed, ); } if (this._wasStarted) return Promise.resolve(); this._wasStarted = true; // Enforce that an error handler is attached, except for testing with a mocked serialport if ( !this.options.testingHooks && (this as unknown as EventEmitter).listenerCount("error") === 0 ) { throw new ZWaveError( `Before starting the driver, a handler for the "error" event must be attached.`, ZWaveErrorCodes.Driver_NoErrorHandler, ); } const spOpenPromise = createDeferredPromise(); // Log which version is running this.driverLog.print(libNameString, "info"); this.driverLog.print(`version ${libVersion}`, "info"); this.driverLog.print("", "info"); this.driverLog.print("starting driver..."); this.sendThread.start(); // Open the serial port if (typeof this.port === "string") { if (this.port.startsWith("tcp://")) { const url = new URL(this.port); this.driverLog.print(`opening serial port ${this.port}`); this.serial = new ZWaveSocket( { host: url.hostname, port: parseInt(url.port), }, this._logContainer, ); } else { this.driverLog.print(`opening serial port ${this.port}`); this.serial = new ZWaveSerialPort( this.port, this._logContainer, this.options.testingHooks?.serialPortBinding, ); } } else { this.driverLog.print( "opening serial port using the provided custom implementation", ); this.serial = new ZWaveSerialPortBase( this.port, this._logContainer, ); } this.serial .on("data", this.serialport_onData.bind(this)) .on("error", (err) => { if (this.isSoftResetting && !this.serial?.isOpen) { // A disconnection while soft resetting is to be expected return; } else if (!this._isOpen) { // tryOpenSerialport takes care of error handling return; } const message = `Serial port errored: ${err.message}`; this.driverLog.print(message, "error"); const error = new ZWaveError( message, ZWaveErrorCodes.Driver_Failed, ); this.emit("error", error); void this.destroy(); }); // If the port is already open, close it first if (this.serial.isOpen) await this.serial.close(); // IMPORTANT: Test code expects the open promise to be created and returned synchronously // Everything async (including opening the serial port) must happen in the setImmediate callback // asynchronously open the serial port setImmediate(async () => { try { await this.openSerialport(); } catch (e) { spOpenPromise.reject(e); void this.destroy(); return; } this.driverLog.print("serial port opened"); this._isOpen = true; spOpenPromise.resolve(); if ( typeof this.options.testingHooks?.onSerialPortOpen === "function" ) { await this.options.testingHooks.onSerialPortOpen(this.serial!); } // Perform initialization sequence await this.writeHeader(MessageHeaders.NAK); // Per the specs, this should be followed by a soft-reset but we need to be able // to handle sticks that don't support the soft reset command. Therefore we do it // after opening the value DBs // Try to create the cache directory. This can fail, in which case we should expose a good error message try { await this.options.storage.driver.ensureDir(this.cacheDir); } catch (e) { let message: string; if ( /\.yarn[/\\]cache[/\\]zwave-js/i.test( getErrorMessage(e, true), ) ) { message = `Failed to create the cache directory. When using Yarn PnP, you need to change the location with the "storage.cacheDir" driver option.`; } else { message = `Failed to create the cache directory. Please make sure that it is writable or change the location with the "storage.cacheDir" driver option.`; } this.driverLog.print(message, "error"); this.emit( "error", new ZWaveError(message, ZWaveErrorCodes.Driver_Failed), ); void this.destroy(); return; } // Load the necessary configuration if (this.options.testingHooks?.loadConfiguration !== false) { this.driverLog.print("loading configuration..."); try { await this.configManager.loadAll(); } catch (e) { const message = `Failed to load the configuration: ${getErrorMessage( e, )}`; this.driverLog.print(message, "error"); this.emit( "error", new ZWaveError(message, ZWaveErrorCodes.Driver_Failed), ); void this.destroy(); return; } } this.driverLog.print("beginning interview..."); try { await this.initializeControllerAndNodes(); } catch (e) { let message: string; if ( isZWaveError(e) && e.code === ZWaveErrorCodes.Controller_MessageDropped ) { message = `Failed to initialize the driver, no response from the controller. Are you sure this is a Z-Wave controller?`; } else { message = `Failed to initialize the driver: ${getErrorMessage( e, true, )}`; } this.driverLog.print(message, "error"); this.emit( "error", new ZWaveError(message, ZWaveErrorCodes.Driver_Failed), ); void this.destroy(); return; } }); return spOpenPromise; } private _controllerInterviewed: boolean = false; private _nodesReady = new Set<number>(); private _nodesReadyEventEmitted: boolean = false; private async openSerialport(): Promise<void> { let lastError: unknown; // After a reset, the serial port may need a few seconds until we can open it - try a few times for ( let attempt = 1; attempt <= this.options.attempts.openSerialPort; attempt++ ) { try { await this.serial!.open(); return; } catch (e) { lastError = e; } if (attempt < this.options.attempts.openSerialPort) { await wait(1000); } } const message = `Failed to open the serial port: ${getErrorMessage( lastError, )}`; this.driverLog.print(message, "error"); throw new ZWaveError(message, ZWaveErrorCodes.Driver_Failed); } /** Indicates whether all nodes are ready, i.e. the "all nodes ready" event has been emitted */ public get allNodesReady(): boolean { return this._nodesReadyEventEmitted; } private getJsonlDBOptions(): JsonlDBOptions<any> { const options: JsonlDBOptions<any> = { ignoreReadErrors: true, ...throttlePresets[this.options.storage.throttle], }; if (this.options.storage.lockDir) { options.lockfile = { directory: this.options.storage.lockDir, }; } return options; } private async initNetworkCache(homeId: number): Promise<void> { const options = this.getJsonlDBOptions(); const networkCacheFile = path.join( this.cacheDir, `${homeId.toString(16)}.jsonl`, ); this._networkCache = new JsonlDB(networkCacheFile, { ...options, serializer: (key, value) => serializeNetworkCacheValue(this, key, value), reviver: (key, value) => deserializeNetworkCacheValue(this, key, value), }); await this._networkCache.open(); if (process.env.NO_CACHE === "true") { // Since the network cache is append-only, we need to // clear it if the cache should be ignored this._networkCache.clear(); } } private async initValueDBs(homeId: number): Promise<void> { const options = this.getJsonlDBOptions(); const valueDBFile = path.join( this.cacheDir, `${homeId.toString(16)}.values.jsonl`, ); this._valueDB = new JsonlDB(valueDBFile, { ...options, reviver: (key, value) => deserializeCacheValue(value), serializer: (key, value) => serializeCacheValue(value), }); await this._valueDB.open(); const metadataDBFile = path.join( this.cacheDir, `${homeId.toString(16)}.metadata.jsonl`, ); this._metadataDB = new JsonlDB(metadataDBFile, options); await this._metadataDB.open(); if (process.env.NO_CACHE === "true") { // Since value/metadata DBs are append-only, we need to // clear them if the cache should be ignored this._valueDB.clear(); this._metadataDB.clear(); } } private async performCacheMigration(): Promise<void> { if ( !this._controller || !this.controller.homeId || !this._networkCache || !this._valueDB ) { return; } // In v9, the network cache was switched from a json file to use a Jsonl-DB // Therefore the legacy cache file must be migrated to the new format if (this._networkCache.size === 0) { // version the cache format, so migrations in the future are easier this._networkCache.set("cacheFormat", 1); try { await migrateLegacyNetworkCache( this, this.controller.homeId, this._networkCache, this._valueDB, this.options.storage.driver, this.cacheDir, ); // Go through the value DB and remove all keys referencing commandClass -1, which used to be a // hacky way to store non-CC specific values for (const key of this._valueDB.keys()) { if (-1 === key.indexOf(`,"commandClass":-1,`)) { continue; } this._valueDB.delete(key); } } catch (e) { const message = `Migrating the legacy cache file to jsonl failed: ${getErrorMessage( e, true, )}`; this.driverLog.print(message, "error"); } } } /** * Initializes the variables for controller and nodes, * adds event handlers and starts the interview process. */ private async initializeControllerAndNodes(): Promise<void> { if (this._controller == undefined) { this._controller = new ZWaveController(this); this._controller .on("node added", this.onNodeAdded.bind(this)) .on("node removed", this.onNodeRemoved.bind(this)); } if (!this.options.testingHooks?.skipControllerIdentification) { // Determine controller IDs to open the Value DBs // We need to do this first because some older controllers, especially the UZB1 and // some 500-series sticks in virtualized environments don't respond after a soft reset // No need to initialize databases if skipInterview is true, because it is only used in some // Driver unit tests that don't need access to them // Identify the controller and determine if it supports soft reset await this.controller.identify(); await this.initNetworkCache(this.controller.homeId!); if (this.options.enableSoftReset && !this.maySoftReset()) { this.driverLog.print( `Soft reset is enabled through config, but this stick does not support it.`, "warn", ); this.options.enableSoftReset = false; } if (this.options.enableSoftReset) { try { await this.softResetInternal(false); } catch (e) { if ( isZWaveError(e) && e.code === ZWaveErrorCodes.Driver_Failed ) { // Remember that soft reset is not supported by this stick this.driverLog.print( "Soft reset seems not to be supported by this stick, disabling it.", "warn", ); this.controller.supportsSoftReset = false; // Then fail the driver await this.destroy(); return; } } } // There are situations where a controller claims it has the ID 0, // which isn't valid. In this case try again after having soft-reset the stick if ( this.controller.ownNodeId === 0 && this.options.enableSoftReset ) { this.driverLog.print( `Controller identification returned invalid node ID 0 - trying again...`, "warn", ); // We might end up with a different home ID, so close the cache before re-identifying the controller await this._networkCache?.close(); await this.controller.identify(); await this.initNetworkCache(this.controller.homeId!); } if (this.controller.ownNodeId === 0) { this.driverLog.print( `Controller identification returned invalid node ID 0`, "error", ); await this.destroy(); return; } // now that we know the home ID, we can open the databases await this.initValueDBs(this.controller.homeId!); await this.performCacheMigration(); // Interview the controller. await this._controller.interview(async () => { // Try to restore the network information from the cache if (process.env.NO_CACHE !== "true") { await this.restoreNetworkStructureFromCache(); } }); // Auto-enable smart start inclusion this._controller.autoProvisionSmartStart(); } // Set up the S0 security manager. We can only do that after the controller // interview because we need to know the controller node id. const S0Key = this.options.securityKeys?.S0_Legacy; if (S0Key) { this.driverLog.print( "Network key for S0 configured, enabling S0 security manager...", ); this._securityManager = new SecurityManager({ networkKey: S0Key, ownNodeId: this._controller.ownNodeId!, nonceTimeout: this.options.timeouts.nonce, }); } else { this.driverLog.print( "No network key for S0 configured, communication with secure (S0) devices won't work!", "warn", ); } // The S2 security manager could be initialized earlier, but we do it here for consistency if ( this.options.securityKeys && // Only set it up if we have security keys for at least one S2 security class Object.keys(this.options.securityKeys).some( (key) => key.startsWith("S2_") && key in SecurityClass && securityClassIsS2((SecurityClass as any)[key]), ) ) { this.driverLog.print( "At least one network key for S2 configured, enabling S2 security manager...", ); this._securityManager2 = new SecurityManager2(); // Set up all keys for (const secClass of [ "S2_Unauthenticated", "S2_Authenticated", "S2_AccessControl", "S0_Legacy", ] as const) { const key = this.options.securityKeys[secClass]; if (key) { this._securityManager2.setKey(SecurityClass[secClass], key); } } } else { this.driverLog.print( "No network key for S2 configured, communication with secure (S2) devices won't work!", "warn", ); } // in any case we need to emit the driver ready event here this._controllerInterviewed = true; this.driverLog.print("driver ready"); this.emit("driver ready"); // Add event handlers for the nodes for (const node of this._controller.nodes.values()) { this.addNodeEventHandlers(node); } // Before interviewing nodes reset our knowledge about their ready state this._nodesReady.clear(); this._nodesReadyEventEmitted = false; if (!this.options.testingHooks?.skipNodeInterview) { // Now interview all nodes // First complete the controller interview const controllerNode = this._controller.nodes.get( this._controller.ownNodeId!, )!; await this.interviewNodeInternal(controllerNode); // Then do all the nodes in parallel for (const node of this._controller.nodes.values()) { if (node.id === this._controller.ownNodeId) { // The controller is always alive node.markAsAlive(); continue; } else if (node.canSleep) { // A node that can sleep should be assumed to be sleeping after resuming from cache node.markAsAsleep(); } void (async () => { // Continue the interview if necessary. If that is not necessary, at least // determine the node's status if (node.interviewStage < InterviewStage.Complete) { // don't await the interview, because it may take a very long time // if a node is asleep await this.interviewNodeInternal(node); } else if (node.isListening || node.isFrequentListening) { // Ping non-sleeping nodes to determine their status await node.ping(); } // Previous versions of zwave-js didn't configure the SUC return route. Make sure each node has one // and remember that we did. If the node is not responsive - tough luck, try again next time if ( !node.hasSUCReturnRoute && node.status !== NodeStatus.Dead ) { node.hasSUCReturnRoute = await this.controller.assignSUCReturnRoute(node.id); } })(); } } } private retryNodeInterviewTimeouts = new Map<number, NodeJS.Timeout>(); /** * @internal * Starts or resumes the interview of a Z-Wave node. It is advised to NOT * await this method as it can take a very long time (minutes to hours)! * * WARNING: Do not call this method from application code. To refresh the information * for a specific node, use `node.refreshInfo()` instead */ public async interviewNodeInternal(node: ZWaveNode): Promise<void> { if (node.interviewStage === InterviewStage.Complete) { return; } // Avoid having multiple restart timeouts active if (this.retryNodeInterviewTimeouts.has(node.id)) { clearTimeout(this.retryNodeInterviewTimeouts.get(node.id)); this.retryNodeInterviewTimeouts.delete(node.id); } // Drop all pending messages that come from a previous interview attempt this.rejectTransactions( (t) => t.message.getNodeId() === node.id && (t.priority === MessagePriority.NodeQuery || t.tag === "interview"), "The interview was restarted", ZWaveErrorCodes.Controller_InterviewRestarted, ); const maxInterviewAttempts = this.options.attempts.nodeInterview; try { if (!(await node.interviewInternal())) { // Find out if we may retry the interview if (node.status === NodeStatus.Dead) { this.controllerLog.logNode( node.id, `Interview attempt (${node.interviewAttempts}/${maxInterviewAttempts}) failed, node is dead.`, "warn", ); node.emit("interview failed", node, { errorMessage: "The node is dead", isFinal: true, }); } else if (node.interviewAttempts < maxInterviewAttempts) { // This is most likely because the node is unable to handle our load of requests now. Give it some time const retryTimeout = Math.min( 30000, node.interviewAttempts * 5000, ); this.controllerLog.logNode( node.id, `Interview attempt ${node.interviewAttempts}/${maxInterviewAttempts} failed, retrying in ${retryTimeout} ms...`, "warn", ); node.emit("interview failed", node, { errorMessage: `Attempt ${node.interviewAttempts}/${maxInterviewAttempts} failed`, isFinal: false, attempt: node.interviewAttempts, maxAttempts: maxInterviewAttempts, }); // Schedule the retry and remember the timeout instance this.retryNodeInterviewTimeouts.set( node.id, setTimeout(() => { this.retryNodeInterviewTimeouts.delete(node.id); void this.interviewNodeInternal(node); }, retryTimeout).unref(), ); } else { this.controllerLog.logNode( node.id, `Failed all interview attempts, giving up.`, "warn", ); node.emit("interview failed", node, { errorMessage: `Maximum interview attempts reached`, isFinal: true, attempt: maxInterviewAttempts, maxAttempts: maxInterviewAttempts, }); } } else if ( node.manufacturerId != undefined && node.productType != undefined && node.productId != undefined && node.firmwareVersion != undefined && !node.deviceConfig && process.env.NODE_ENV !== "test" ) { // The interview succeeded, but we don't have a device config for this node. // Report it, so we can add a config file void reportMissingDeviceConfig(this, node as any).catch( // eslint-disable-next-line @typescript-eslint/no-empty-function () => {}, ); } } catch (e) { if (isZWaveError(e)) { if ( e.code === ZWaveErrorCodes.Driver_NotReady || e.code === ZWaveErrorCodes.Controller_NodeRemoved ) { // This only happens when a node is removed during the interview - we don't log this return; } else if ( e.code === ZWaveErrorCodes.Controller_InterviewRestarted ) { // The interview was restarted by a user - we don't log this return; } this.controllerLog.logNode( node.id, `Error during node interview: ${e.message}`, "error", ); } else { throw e; } } } /** Adds the necessary event handlers for a node instance */ private addNodeEventHandlers(node: ZWaveNode): void { node.on("wake up", this.onNodeWakeUp.bind(this)) .on("sleep", this.onNodeSleep.bind(this)) .on("alive", this.onNodeAlive.bind(this)) .on("dead", this.onNodeDead.bind(this)) .on("interview completed", this.onNodeInterviewCompleted.bind(this)) .on("ready", this.onNodeReady.bind(this)) .on( "firmware update finished", this.onNodeFirmwareUpdated.bind(this), ); } /** Removes a node's event handlers that were added with addNodeEventHandlers */ private removeNodeEventHandlers(node: ZWaveNode): void { node.removeAllListeners("wake up") .removeAllListeners("sleep") .removeAllListeners("alive") .removeAllListeners("dead") .removeAllListeners("interview completed") .removeAllListeners("ready") .removeAllListeners("firmware update finished"); } /** Is called when a node wakes up */ private onNodeWakeUp(node: ZWaveNode, oldStatus: NodeStatus): void { this.controllerLog.logNode( node.id, `The node is ${ oldStatus === NodeStatus.Unknown ? "" : "now " }awake.`, ); // Make sure to handle the pending messages as quickly as possible if (oldStatus === NodeStatus.Asleep) { this.sendThread.send({ type: "reduce", reducer: ({ message }) => { // Ignore messages that are not for this node if (message.getNodeId() !== node.id) return { type: "keep" }; // Resolve pings, so we don't need to send them (we know the node is awake) if (messageIsPing(message)) return { type: "resolve", message: undefined }; // Re-queue all other transactions for this node, so they get added in front of the others return { type: "requeue" }; }, }); } } /** Is called when a node goes to sleep */ private onNodeSleep(node: ZWaveNode, oldStatus: NodeStatus): void { this.controllerLog.logNode( node.id, `The node is ${ oldStatus === NodeStatus.Unknown ? "" : "now " }asleep.`, ); // Move all its pending messages to the WakeupQueue // This clears the current transaction and continues sending the next messages this.moveMessagesToWakeupQueue(node.id); } /** Is called when a previously dead node starts communicating again */ private onNodeAlive(node: ZWaveNode, oldStatus: NodeStatus): void { this.controllerLog.logNode( node.id, `The node is ${ oldStatus === NodeStatus.Unknown ? "" : "now " }alive.`, ); if ( oldStatus === NodeStatus.Dead && node.interviewStage !== InterviewStage.Complete ) { void this.interviewNodeInternal(node); } } /** Is called when a node is marked as dead */ private onNodeDead(node: ZWaveNode, oldStatus: NodeStatus): void { this.controllerLog.logNode( node.id, `The node is ${ oldStatus === NodeStatus.Unknown ? "" : "now " }dead.`, ); // This could mean that we need to ignore it in the all nodes ready check, // so perform the check again this.checkAllNodesReady(); } /** Is called when a node is ready to be used */ private onNodeReady(node: ZWaveNode): void { this._nodesReady.add(node.id); this.controllerLog.logNode(node.id, "The node is ready to be used"); // Regularly query listening nodes for updated values node.scheduleManualValueRefreshes(); this.checkAllNodesReady(); } /** Checks if all nodes are ready and emits the "all nodes ready" event if they are */ private checkAllNodesReady(): void { // Only emit "all nodes ready" once if (this._nodesReadyEventEmitted) return; for (const [id, node] of this.controller.nodes) { // Ignore dead nodes or the all nodes ready event will never be emitted without physical user interaction if (node.status === NodeStatus.Dead) continue; if (!this._nodesReady.has(id)) return; } // All nodes are ready this.controllerLog.print("All nodes are ready to be used"); this.emit("all nodes ready"); this._nodesReadyEventEmitted = true; // We know we have all data, this is the time to send statistics (when enabled) void this.compileAndSendStatistics().catch(() => { /* ignore */ }); } /** * Enables error reporting via Sentry. This is turned off by default, because it registers a * global `unhandledRejection` event handler, which has an influence how the application will * behave in case of an unhandled rejection. */ public enableErrorReporting(): void { // Init sentry, unless we're running a a test or some custom-built userland or PR test versions if ( process.env.NODE_ENV !== "test" && !/\-[a-f0-9]{7,}$/.test(libVersion) && !/\-pr\-\d+\-$/.test(libVersion) ) { void initSentry(libraryRootDir, libName, libVersion).catch(() => { /* ignore */ }); } } private _statisticsEnabled: boolean = false; /** Whether reporting usage statistics is currently enabled */ public get statisticsEnabled(): boolean { return this._statisticsEnabled; } private statisticsAppInfo: | Pick<AppInfo, "applicationName" | "applicationVersion"> | undefined; /** * Enable sending usage statistics. Although this does not include any sensitive information, we expect that you * inform your users before enabling statistics. */ public enableStatistics( appInfo: Pick<AppInfo, "applicationName" | "applicationVersion">, ): void { if (this._statisticsEnabled) return; this._statisticsEnabled = true; if ( !isObject(appInfo) || typeof appInfo.applicationName !== "string" || typeof appInfo.applicationVersion !== "string" ) { throw new ZWaveError( `The application statistics must be an object with two string properties "applicationName" and "applicationVersion"!`, ZWaveErrorCodes.Driver_InvalidOptions, ); } else if (appInfo.applicationName.length > 100) { throw new ZWaveError( `The applicationName for statistics must be maximum 100 characters long!`, ZWaveErrorCodes.Driver_InvalidOptions, ); } else if (appInfo.applicationVersion.length > 100) { throw new ZWaveError( `The applicationVersion for statistics must be maximum 100 characters long!`, ZWaveErrorCodes.Driver_InvalidOptions, ); } this.statisticsAppInfo = appInfo; // If we're already ready, send statistics if (this._nodesReadyEventEmitted) { void this.compileAndSendStatistics().catch(() => { /* ignore */ }); } } /** * Disable sending usage statistics *