inventoresed
Version:
Z-Wave driver written entirely in JavaScript/TypeScript
1,654 lines (1,520 loc) • 139 kB
text/typescript
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
*