UNPKG

inventoresed

Version:

Z-Wave driver written entirely in JavaScript/TypeScript

1,657 lines (1,506 loc) 139 kB
import { CCAPI, CentralSceneKeys, CommandClass, DoorLockMode, EntryControlDataTypes, entryControlEventTypeLabels, FirmwareUpdateCapabilities, FirmwareUpdateRequestStatus, FirmwareUpdateStatus, getCCValues, isCommandClassContainer, MultilevelSwitchCommand, PollValueImplementation, Powerlevel, PowerlevelTestStatus, SetValueAPIOptions, TimeCCDateGet, TimeCCTimeGet, TimeCCTimeOffsetGet, ZWavePlusNodeType, ZWavePlusRoleType, } from "@zwave-js/cc"; import { AssociationCCValues } from "@zwave-js/cc/AssociationCC"; import { BasicCC, BasicCCReport, BasicCCSet, BasicCCValues, } from "@zwave-js/cc/BasicCC"; import { CentralSceneCCNotification, CentralSceneCCValues, } from "@zwave-js/cc/CentralSceneCC"; import { ClockCCReport } from "@zwave-js/cc/ClockCC"; import { DoorLockCCValues } from "@zwave-js/cc/DoorLockCC"; import { EntryControlCCNotification } from "@zwave-js/cc/EntryControlCC"; import { FirmwareUpdateMetaDataCC, FirmwareUpdateMetaDataCCGet, FirmwareUpdateMetaDataCCReport, FirmwareUpdateMetaDataCCStatusReport, FirmwareUpdateMetaDataCCValues, } from "@zwave-js/cc/FirmwareUpdateMetaDataCC"; import { HailCC } from "@zwave-js/cc/HailCC"; import { LockCCValues } from "@zwave-js/cc/LockCC"; import { ManufacturerSpecificCCValues } from "@zwave-js/cc/ManufacturerSpecificCC"; import { MultiChannelCCValues } from "@zwave-js/cc/MultiChannelCC"; import { MultilevelSwitchCC, MultilevelSwitchCCSet, MultilevelSwitchCCStartLevelChange, MultilevelSwitchCCStopLevelChange, MultilevelSwitchCCValues, } from "@zwave-js/cc/MultilevelSwitchCC"; import { NodeNamingAndLocationCCValues } from "@zwave-js/cc/NodeNamingCC"; import { getNotificationValueMetadata, NotificationCC, NotificationCCReport, NotificationCCValues, } from "@zwave-js/cc/NotificationCC"; import { PowerlevelCCTestNodeReport } from "@zwave-js/cc/PowerlevelCC"; import { SceneActivationCCSet } from "@zwave-js/cc/SceneActivationCC"; import { Security2CCNonceGet, Security2CCNonceReport, } from "@zwave-js/cc/Security2CC"; import { SecurityCCNonceGet, SecurityCCNonceReport, } from "@zwave-js/cc/SecurityCC"; import { VersionCCValues } from "@zwave-js/cc/VersionCC"; import { WakeUpCCValues, WakeUpCCWakeUpNotification, } from "@zwave-js/cc/WakeUpCC"; import { ZWavePlusCCGet, ZWavePlusCCValues } from "@zwave-js/cc/ZWavePlusCC"; import type { DeviceConfig, Notification, NotificationValueDefinition, } from "@zwave-js/config"; import { actuatorCCs, applicationCCs, CacheBackedMap, CommandClasses, CRC16_CCITT, DataRate, FLiRS, getCCName, getDSTInfo, isRssiError, isTransmissionError, isUnsupervisedOrSucceeded, isZWaveError, IZWaveNode, Maybe, MessagePriority, MetadataUpdatedArgs, NodeType, NodeUpdatePayload, nonApplicationCCs, normalizeValueID, ProtocolVersion, RSSI, RssiError, SecurityClass, securityClassIsS2, securityClassOrder, SecurityClassOwner, SendCommandOptions, sensorCCs, timespan, topologicalSort, TXReport, unknownBoolean, ValueDB, ValueID, valueIdToString, ValueMetadata, ValueMetadataNumeric, ValueRemovedArgs, ValueUpdatedArgs, ZWaveError, ZWaveErrorCodes, } from "@zwave-js/core"; import type { NodeSchedulePollOptions } from "@zwave-js/host"; import type { Message } from "@zwave-js/serial"; import { discreteLinearSearch, formatId, getEnumMemberName, getErrorMessage, Mixin, num2hex, ObjectKeyMap, pick, stringify, TypedEventEmitter, } from "@zwave-js/shared"; import { roundTo } from "alcalzone-shared/math"; import { padStart } from "alcalzone-shared/strings"; import { isArray, isObject } from "alcalzone-shared/typeguards"; import { randomBytes } from "crypto"; import { EventEmitter } from "events"; import { isDeepStrictEqual } from "util"; import type { Driver } from "../driver/Driver"; import { cacheKeys } from "../driver/NetworkCache"; import { Extended, interpretEx } from "../driver/StateMachineShared"; import type { StatisticsEventCallbacksWithSelf } from "../driver/Statistics"; import type { Transaction } from "../driver/Transaction"; import { ApplicationUpdateRequest, ApplicationUpdateRequestNodeInfoReceived, ApplicationUpdateRequestNodeInfoRequestFailed, } from "../serialapi/application/ApplicationUpdateRequest"; import { GetNodeProtocolInfoRequest, type GetNodeProtocolInfoResponse, } from "../serialapi/network-mgmt/GetNodeProtocolInfoMessages"; import { RequestNodeInfoRequest, RequestNodeInfoResponse, } from "../serialapi/network-mgmt/RequestNodeInfoMessages"; import { DeviceClass } from "./DeviceClass"; import { Endpoint } from "./Endpoint"; import { formatLifelineHealthCheckSummary, formatRouteHealthCheckSummary, healthCheckTestFrameCount, } from "./HealthCheck"; import { createNodeReadyMachine, NodeReadyInterpreter, } from "./NodeReadyMachine"; import { NodeStatistics, NodeStatisticsHost, RouteStatistics, routeStatisticsEquals, } from "./NodeStatistics"; import { createNodeStatusMachine, NodeStatusInterpreter, nodeStatusMachineStateToNodeStatus, } from "./NodeStatusMachine"; import * as nodeUtils from "./utils"; import type { LifelineHealthCheckResult, LifelineHealthCheckSummary, RefreshInfoOptions, RouteHealthCheckResult, RouteHealthCheckSummary, TranslatedValueID, ZWaveNodeEventCallbacks, ZWaveNodeValueEventCallbacks, } from "./_Types"; import { InterviewStage, NodeStatus } from "./_Types"; interface ScheduledPoll { timeout: NodeJS.Timeout; expectedValue?: unknown; } export interface ZWaveNode extends TypedEventEmitter< ZWaveNodeEventCallbacks & StatisticsEventCallbacksWithSelf<ZWaveNode, NodeStatistics> >, NodeStatisticsHost {} /** * A ZWaveNode represents a node in a Z-Wave network. It is also an instance * of its root endpoint (index 0) */ @Mixin([EventEmitter, NodeStatisticsHost]) export class ZWaveNode extends Endpoint implements SecurityClassOwner, IZWaveNode { public constructor( public readonly id: number, driver: Driver, deviceClass?: DeviceClass, supportedCCs: CommandClasses[] = [], controlledCCs: CommandClasses[] = [], valueDB?: ValueDB, ) { // Define this node's intrinsic endpoint as the root device (0) super(id, driver, 0, deviceClass, supportedCCs); this._valueDB = valueDB ?? new ValueDB(id, driver.valueDB!, driver.metadataDB!); // Pass value events to our listeners for (const event of [ "value added", "value updated", "value removed", "value notification", "metadata updated", ] as const) { this._valueDB.on(event, this.translateValueEvent.bind(this, event)); } // Also avoid verifying a value change for which we recently received an update for (const event of ["value updated", "value removed"] as const) { this._valueDB.on( event, (args: ValueUpdatedArgs | ValueRemovedArgs) => { // Value updates caused by the driver should never cancel a scheduled poll if ("source" in args && args.source === "driver") return; if ( this.cancelScheduledPoll( args, (args as ValueUpdatedArgs).newValue, ) ) { this.driver.controllerLog.logNode( this.nodeId, "Scheduled poll canceled because expected value was received", "verbose", ); } }, ); } this.securityClasses = new CacheBackedMap(this.driver.networkCache, { prefix: cacheKeys.node(this.id)._securityClassBaseKey + ".", suffixSerializer: (value: SecurityClass) => getEnumMemberName(SecurityClass, value), suffixDeserializer: (key: string) => { if ( key in SecurityClass && typeof (SecurityClass as any)[key] === "number" ) { return (SecurityClass as any)[key]; } }, }); // Add optional controlled CCs - endpoints don't have this for (const cc of controlledCCs) this.addCC(cc, { isControlled: true }); // Create and hook up the status machine this.statusMachine = interpretEx(createNodeStatusMachine(this)); this.statusMachine.onTransition((state) => { if (state.changed) { this.onStatusChange( nodeStatusMachineStateToNodeStatus(state.value as any), ); } }); this.statusMachine.start(); this.readyMachine = interpretEx(createNodeReadyMachine()); this.readyMachine.onTransition((state) => { if (state.changed) { this.onReadyChange(state.value === "ready"); } }); this.readyMachine.start(); } /** * Cleans up all resources used by this node */ public destroy(): void { // Stop all state machines this.statusMachine.stop(); this.readyMachine.stop(); // Remove all timeouts for (const timeout of [ this.centralSceneKeyHeldDownContext?.timeout, ...this.notificationIdleTimeouts.values(), ...this.manualRefreshTimers.values(), ]) { if (timeout) clearTimeout(timeout); } // Remove all event handlers this.removeAllListeners(); // Clear all scheduled polls that would interfere with the interview for (const valueId of this.scheduledPolls.keys()) { this.cancelScheduledPoll(valueId); } } /** * Enhances the raw event args of the ValueDB so it can be consumed better by applications */ private translateValueEvent<T extends ValueID>( eventName: keyof ZWaveNodeValueEventCallbacks, arg: T, ): void { // Try to retrieve the speaking CC name const outArg = nodeUtils.translateValueID(this.driver, this, arg); // @ts-expect-error This can happen for value updated events if ("source" in outArg) delete outArg.source; // If this is a metadata event, make sure we return the merged metadata if ("metadata" in outArg) { (outArg as unknown as MetadataUpdatedArgs).metadata = this.getValueMetadata(arg); } const ccInstance = CommandClass.createInstanceUnchecked( this.driver, this, arg.commandClass, ); const isInternalValue = ccInstance?.isInternalValue(arg); // Check whether this value change may be logged const isSecretValue = !!ccInstance?.isSecretValue(arg); if ( !isSecretValue && (arg as any as ValueUpdatedArgs).source !== "driver" ) { // Log the value change, except for updates caused by the driver itself // I don't like the splitting and any but its the easiest solution here const [changeTarget, changeType] = eventName.split(" "); const logArgument = { ...outArg, nodeId: this.nodeId, internal: isInternalValue, }; if (changeTarget === "value") { this.driver.controllerLog.value( changeType as any, logArgument as any, ); } else if (changeTarget === "metadata") { this.driver.controllerLog.metadataUpdated(logArgument); } } //Don't expose value events for internal value IDs... if (isInternalValue) return; // ... and root values ID that mirrors endpoint functionality if ( // Only root endpoint values need to be filtered !arg.endpoint && // Only application CCs need to be filtered applicationCCs.includes(arg.commandClass) && // and only if the endpoints are not unnecessary and the root values mirror them nodeUtils.shouldHideRootApplicationCCValues(this.driver, this) ) { // Iterate through all possible non-root endpoints of this node and // check if there is a value ID that mirrors root endpoint functionality for (const endpoint of this.getEndpointIndizes()) { const possiblyMirroredValueID: ValueID = { // same CC, property and key ...pick(arg, ["commandClass", "property", "propertyKey"]), // but different endpoint endpoint, }; if (this.valueDB.hasValue(possiblyMirroredValueID)) return; } } // And pass the translated event to our listeners this.emit(eventName, this, outArg as any); } private statusMachine: Extended<NodeStatusInterpreter>; private _status: NodeStatus = NodeStatus.Unknown; private onStatusChange(newStatus: NodeStatus) { // Ignore duplicate events if (newStatus === this._status) return; const oldStatus = this._status; this._status = newStatus; if (this._status === NodeStatus.Asleep) { this.emit("sleep", this, oldStatus); } else if (this._status === NodeStatus.Awake) { this.emit("wake up", this, oldStatus); } else if (this._status === NodeStatus.Dead) { this.emit("dead", this, oldStatus); } else if (this._status === NodeStatus.Alive) { this.emit("alive", this, oldStatus); } // To be marked ready, a node must be known to be not dead. // This means that listening nodes must have communicated with us and // sleeping nodes are assumed to be ready this.readyMachine.send( this._status !== NodeStatus.Unknown && this._status !== NodeStatus.Dead ? "NOT_DEAD" : "MAYBE_DEAD", ); } /** * Which status the node is believed to be in */ public get status(): NodeStatus { return this._status; } /** * @internal * Marks this node as dead (if applicable) */ public markAsDead(): void { this.statusMachine.send("DEAD"); } /** * @internal * Marks this node as alive (if applicable) */ public markAsAlive(): void { this.statusMachine.send("ALIVE"); } /** * @internal * Marks this node as asleep (if applicable) */ public markAsAsleep(): void { this.statusMachine.send("ASLEEP"); } /** * @internal * Marks this node as awake (if applicable) */ public markAsAwake(): void { this.statusMachine.send("AWAKE"); } /** Returns a promise that resolves when the node wakes up the next time or immediately if the node is already awake. */ public waitForWakeup(): Promise<void> { if (!this.canSleep || !this.supportsCC(CommandClasses["Wake Up"])) { throw new ZWaveError( `Node ${this.id} does not support wakeup!`, ZWaveErrorCodes.CC_NotSupported, ); } else if (this._status === NodeStatus.Awake) { return Promise.resolve(); } return new Promise((resolve) => { this.once("wake up", () => resolve()); }); } // The node is only ready when the interview has been completed // to a certain degree private readyMachine: Extended<NodeReadyInterpreter>; private _ready: boolean = false; private onReadyChange(ready: boolean) { // Ignore duplicate events if (ready === this._ready) return; this._ready = ready; if (ready) this.emit("ready", this); } /** * Whether the node is ready to be used */ public get ready(): boolean { return this._ready; } /** Whether this node is always listening or not */ public get isListening(): boolean | undefined { return this.driver.cacheGet(cacheKeys.node(this.id).isListening); } private set isListening(value: boolean | undefined) { this.driver.cacheSet(cacheKeys.node(this.id).isListening, value); } /** Indicates the wakeup interval if this node is a FLiRS node. `false` if it isn't. */ public get isFrequentListening(): FLiRS | undefined { return this.driver.cacheGet( cacheKeys.node(this.id).isFrequentListening, ); } private set isFrequentListening(value: FLiRS | undefined) { this.driver.cacheSet( cacheKeys.node(this.id).isFrequentListening, value, ); } public get canSleep(): boolean | undefined { if (this.isListening == undefined) return undefined; if (this.isFrequentListening == undefined) return undefined; return !this.isListening && !this.isFrequentListening; } /** Whether the node supports routing/forwarding messages. */ public get isRouting(): boolean | undefined { return this.driver.cacheGet(cacheKeys.node(this.id).isRouting); } private set isRouting(value: boolean | undefined) { this.driver.cacheSet(cacheKeys.node(this.id).isRouting, value); } public get supportedDataRates(): readonly DataRate[] | undefined { return this.driver.cacheGet(cacheKeys.node(this.id).supportedDataRates); } private set supportedDataRates(value: readonly DataRate[] | undefined) { this.driver.cacheSet(cacheKeys.node(this.id).supportedDataRates, value); } public get maxDataRate(): DataRate | undefined { if (this.supportedDataRates) { return Math.max(...this.supportedDataRates) as DataRate; } } /** @internal */ // This a CacheBackedMap that's assigned in the constructor public readonly securityClasses: Map<SecurityClass, boolean>; /** * The device specific key (DSK) of this node in binary format. * This is only set if included with Security S2. */ public get dsk(): Buffer | undefined { return this.driver.cacheGet(cacheKeys.node(this.id).dsk); } /** @internal */ public set dsk(value: Buffer | undefined) { const cacheKey = cacheKeys.node(this.id).dsk; this.driver.cacheSet(cacheKey, value); } /** Whether the node was granted at least one security class */ public get isSecure(): Maybe<boolean> { const securityClass = this.getHighestSecurityClass(); if (securityClass == undefined) return unknownBoolean; if (securityClass === SecurityClass.None) return false; return true; } public hasSecurityClass(securityClass: SecurityClass): Maybe<boolean> { return this.securityClasses.get(securityClass) ?? unknownBoolean; } public setSecurityClass( securityClass: SecurityClass, granted: boolean, ): void { this.securityClasses.set(securityClass, granted); } /** Returns the highest security class this node was granted or `undefined` if that information isn't known yet */ public getHighestSecurityClass(): SecurityClass | undefined { if (this.securityClasses.size === 0) return undefined; let missingSome = false; for (const secClass of securityClassOrder) { if (this.securityClasses.get(secClass) === true) return secClass; if (!this.securityClasses.has(secClass)) { missingSome = true; } } // If we don't have the info for every security class, we don't know the highest one yet return missingSome ? undefined : SecurityClass.None; } /** The Z-Wave protocol version this node implements */ public get protocolVersion(): ProtocolVersion | undefined { return this.driver.cacheGet(cacheKeys.node(this.id).protocolVersion); } private set protocolVersion(value: ProtocolVersion | undefined) { this.driver.cacheSet(cacheKeys.node(this.id).protocolVersion, value); } /** Whether this node is a controller (can calculate routes) or an end node (relies on route info) */ public get nodeType(): NodeType | undefined { return this.driver.cacheGet(cacheKeys.node(this.id).nodeType); } private set nodeType(value: NodeType | undefined) { this.driver.cacheSet(cacheKeys.node(this.id).nodeType, value); } /** * Whether this node supports security (S0 or S2). * **WARNING:** Nodes often report this incorrectly - do not blindly trust it. */ public get supportsSecurity(): boolean | undefined { return this.driver.cacheGet(cacheKeys.node(this.id).supportsSecurity); } private set supportsSecurity(value: boolean | undefined) { this.driver.cacheSet(cacheKeys.node(this.id).supportsSecurity, value); } /** Whether this node can issue wakeup beams to FLiRS nodes */ public get supportsBeaming(): boolean | undefined { return this.driver.cacheGet(cacheKeys.node(this.id).supportsBeaming); } private set supportsBeaming(value: boolean | undefined) { this.driver.cacheSet(cacheKeys.node(this.id).supportsBeaming, value); } public get manufacturerId(): number | undefined { return this.getValue(ManufacturerSpecificCCValues.manufacturerId.id); } public get productId(): number | undefined { return this.getValue(ManufacturerSpecificCCValues.productId.id); } public get productType(): number | undefined { return this.getValue(ManufacturerSpecificCCValues.productType.id); } public get firmwareVersion(): string | undefined { // On supporting nodes, use the applicationVersion, which MUST be // same as the first (main) firmware, plus the patch version. const firmware0Version = this.getValue<string[]>( VersionCCValues.firmwareVersions.id, )?.[0]; const applicationVersion = this.getValue<string>( VersionCCValues.applicationVersion.id, ); let ret: string | undefined = firmware0Version; if (applicationVersion) { // If the application version is set, we cannot blindly trust that it is the firmware version. // Some nodes incorrectly set this field to the Z-Wave Application Framework API Version if (!ret || applicationVersion.startsWith(`${ret}.`)) { ret = applicationVersion; } } // Special case for the official 700 series firmwares which are aligned with the SDK version // We want to work with the full x.y.z firmware version here. if (ret && this.isControllerNode) { const sdkVersion = this.sdkVersion; if (sdkVersion && sdkVersion.startsWith(`${ret}.`)) { return sdkVersion; } } // For all others, just return the simple x.y firmware version return ret; } public get sdkVersion(): string | undefined { return this.getValue(VersionCCValues.sdkVersion.id); } public get zwavePlusVersion(): number | undefined { return this.getValue(ZWavePlusCCValues.zwavePlusVersion.id); } public get zwavePlusNodeType(): ZWavePlusNodeType | undefined { return this.getValue(ZWavePlusCCValues.nodeType.id); } public get zwavePlusRoleType(): ZWavePlusRoleType | undefined { return this.getValue(ZWavePlusCCValues.roleType.id); } public get supportsWakeUpOnDemand(): boolean | undefined { return this.getValue(WakeUpCCValues.wakeUpOnDemandSupported.id); } /** * The user-defined name of this node. Uses the value reported by `Node Naming and Location CC` if it exists. * * **Note:** Setting this value only updates the name locally. To permanently change the name of the node, use * the `commandClasses` API. */ public get name(): string | undefined { return this.getValue(NodeNamingAndLocationCCValues.name.id); } public set name(value: string | undefined) { if (value != undefined) { this._valueDB.setValue( NodeNamingAndLocationCCValues.name.id, value, ); } else { this._valueDB.removeValue(NodeNamingAndLocationCCValues.name.id); } } /** * The user-defined location of this node. Uses the value reported by `Node Naming and Location CC` if it exists. * * **Note:** Setting this value only updates the location locally. To permanently change the location of the node, use * the `commandClasses` API. */ public get location(): string | undefined { return this.getValue(NodeNamingAndLocationCCValues.location.id); } public set location(value: string | undefined) { if (value != undefined) { this._valueDB.setValue( NodeNamingAndLocationCCValues.location.id, value, ); } else { this._valueDB.removeValue( NodeNamingAndLocationCCValues.location.id, ); } } /** Whether a SUC return route was configured for this node */ public get hasSUCReturnRoute(): boolean { return !!this.driver.cacheGet( cacheKeys.node(this.id).hasSUCReturnRoute, ); } public set hasSUCReturnRoute(value: boolean) { this.driver.cacheSet(cacheKeys.node(this.id).hasSUCReturnRoute, value); } private _deviceConfig: DeviceConfig | undefined; /** * Contains additional information about this node, loaded from a config file */ public get deviceConfig(): DeviceConfig | undefined { return this._deviceConfig; } public get label(): string | undefined { return this._deviceConfig?.label; } public get deviceDatabaseUrl(): string | undefined { if ( this.manufacturerId != undefined && this.productType != undefined && this.productId != undefined ) { const manufacturerId = formatId(this.manufacturerId); const productType = formatId(this.productType); const productId = formatId(this.productId); const firmwareVersion = this.firmwareVersion || "0.0"; return `https://devices.zwave-js.io/?jumpTo=${manufacturerId}:${productType}:${productId}:${firmwareVersion}`; } } private _valueDB: ValueDB; /** * Provides access to this node's values * @internal */ public get valueDB(): ValueDB { return this._valueDB; } /** * Retrieves a stored value for a given value id. * This does not request an updated value from the node! */ public getValue<T = unknown>(valueId: ValueID): T | undefined { return this._valueDB.getValue(valueId); } /** * Retrieves metadata for a given value id. * This can be used to enhance the user interface of an application */ public getValueMetadata(valueId: ValueID): ValueMetadata { // First attempt: look in the value DB if (this._valueDB.hasMetadata(valueId)) { return this._valueDB.getMetadata(valueId)!; } // Second attempt: check if a corresponding CC value is defined for this value ID const definedCCValues = getCCValues(valueId.commandClass); if (definedCCValues) { const value = Object.values(definedCCValues).find((v) => v?.is(valueId), ); if (value && typeof value !== "function") return value.meta; } // Default: Any return ValueMetadata.Any; } /** Returns a list of all value names that are defined on all endpoints of this node */ public getDefinedValueIDs(): TranslatedValueID[] { return nodeUtils.getDefinedValueIDs(this.driver, this); } /** * Updates a value for a given property of a given CommandClass on the node. * This will communicate with the node! */ public async setValue( valueId: ValueID, value: unknown, options?: SetValueAPIOptions, ): Promise<boolean> { // Try to retrieve the corresponding CC API try { // Access the CC API by name const endpointInstance = this.getEndpoint(valueId.endpoint || 0); if (!endpointInstance) return false; const api = (endpointInstance.commandClasses as any)[ valueId.commandClass ] as CCAPI; // Check if the setValue method is implemented if (!api.setValue) return false; // And call it const result = await api.setValue( { property: valueId.property, propertyKey: valueId.propertyKey, }, value, options, ); // Remember the new value if... // ... the call did not throw (assume that the call was successful) // ... the call was supervised and successful if ( api.isSetValueOptimistic(valueId) && isUnsupervisedOrSucceeded(result) ) { this._valueDB.setValue( valueId, value, // We need to emit an event if applications opted in, or if this was a supervised call // because in this case there won't be a verification query which would result in an update !!result || !!this.driver.options.emitValueUpdateAfterSetValue ? { source: "driver" } : { noEvent: true }, ); } return true; } catch (e) { // Define which errors during setValue are expected and won't crash // the driver: if (isZWaveError(e)) { let handled = false; let emitErrorEvent = false; switch (e.code) { // This CC or API is not implemented case ZWaveErrorCodes.CC_NotImplemented: case ZWaveErrorCodes.CC_NoAPI: handled = true; break; // A user tried to set an invalid value case ZWaveErrorCodes.Argument_Invalid: handled = true; emitErrorEvent = true; break; } if (emitErrorEvent) this.driver.emit("error", e); if (handled) return false; } throw e; } } /** * Requests a value for a given property of a given CommandClass by polling the node. * **Warning:** Some value IDs share a command, so make sure not to blindly call this for every property */ public pollValue<T = unknown>( valueId: ValueID, sendCommandOptions: SendCommandOptions = {}, ): Promise<T | undefined> { // Try to retrieve the corresponding CC API const endpointInstance = this.getEndpoint(valueId.endpoint || 0); if (!endpointInstance) { throw new ZWaveError( `Endpoint ${valueId.endpoint} does not exist on Node ${this.id}`, ZWaveErrorCodes.Argument_Invalid, ); } const api = ( (endpointInstance.commandClasses as any)[ valueId.commandClass ] as CCAPI ).withOptions({ // We do not want to delay more important communication by polling, so give it // the lowest priority and don't retry unless overwritten by the options maxSendAttempts: 1, priority: MessagePriority.Poll, ...sendCommandOptions, }); // Check if the pollValue method is implemented if (!api.pollValue) { throw new ZWaveError( `The pollValue API is not implemented for CC ${getCCName( valueId.commandClass, )}!`, ZWaveErrorCodes.CC_NoAPI, ); } // And call it return (api.pollValue as PollValueImplementation<T>)({ property: valueId.property, propertyKey: valueId.propertyKey, }); } /** * @internal * All polls that are currently scheduled for this node */ public scheduledPolls = new ObjectKeyMap<ValueID, ScheduledPoll>(); /** * @internal * Schedules a value to be polled after a given time. Only one schedule can be active for a given value ID. * @returns `true` if the poll was scheduled, `false` otherwise */ public schedulePoll( valueId: ValueID, options: NodeSchedulePollOptions = {}, ): boolean { const { timeoutMs = this.driver.options.timeouts.refreshValue, expectedValue, } = options; // Avoid false positives or false negatives due to a mis-formatted value ID valueId = normalizeValueID(valueId); // Try to retrieve the corresponding CC API const endpointInstance = this.getEndpoint(valueId.endpoint || 0); if (!endpointInstance) return false; const api = ( (endpointInstance.commandClasses as any)[ valueId.commandClass ] as CCAPI ).withOptions({ // We do not want to delay more important communication by polling, so give it // the lowest priority and don't retry unless overwritten by the options maxSendAttempts: 1, priority: MessagePriority.Poll, }); // Check if the pollValue method is implemented if (!api.pollValue) return false; // make sure there is only one timeout instance per poll this.cancelScheduledPoll(valueId); const timeout = setTimeout(async () => { // clean up after the timeout this.cancelScheduledPoll(valueId); try { await api.pollValue!(valueId); } catch { /* ignore */ } }, timeoutMs).unref(); this.scheduledPolls.set(valueId, { timeout, expectedValue }); return true; } /** * @internal * Cancels a poll that has been scheduled with schedulePoll. * * @param actualValue If given, this indicates the value that was received by a node, which triggered the poll to be canceled. * If the scheduled poll expects a certain value and this matches the expected value for the scheduled poll, the poll will be canceled. */ public cancelScheduledPoll( valueId: ValueID, actualValue?: unknown, ): boolean { // Avoid false positives or false negatives due to a mis-formatted value ID valueId = normalizeValueID(valueId); const poll = this.scheduledPolls.get(valueId); if (!poll) return false; if ( actualValue != undefined && poll.expectedValue != undefined && !isDeepStrictEqual(poll.expectedValue, actualValue) ) { return false; } clearTimeout(poll.timeout); this.scheduledPolls.delete(valueId); return true; } public get endpointCountIsDynamic(): boolean | undefined { return nodeUtils.endpointCountIsDynamic(this.driver, this); } public get endpointsHaveIdenticalCapabilities(): boolean | undefined { return nodeUtils.endpointsHaveIdenticalCapabilities(this.driver, this); } public get individualEndpointCount(): number | undefined { return nodeUtils.getIndividualEndpointCount(this.driver, this); } public get aggregatedEndpointCount(): number | undefined { return nodeUtils.getAggregatedEndpointCount(this.driver, this); } /** Returns the device class of an endpoint. Falls back to the node's device class if the information is not known. */ private getEndpointDeviceClass(index: number): DeviceClass | undefined { const deviceClass = this.getValue<{ generic: number; specific: number; }>( MultiChannelCCValues.endpointDeviceClass.endpoint( this.endpointsHaveIdenticalCapabilities ? 1 : index, ), ); if (deviceClass && this.deviceClass) { return new DeviceClass( this.driver.configManager, this.deviceClass.basic.key, deviceClass.generic, deviceClass.specific, ); } // fall back to the node's device class if it is known return this.deviceClass; } private getEndpointCCs(index: number): CommandClasses[] | undefined { const ret = this.getValue( MultiChannelCCValues.endpointCCs.endpoint( this.endpointsHaveIdenticalCapabilities ? 1 : index, ), ); // Workaround for the change in #1977 if (isArray(ret)) { // The value is set up correctly, return it return ret as CommandClasses[]; } else if (isObject(ret) && "supportedCCs" in ret) { return ret.supportedCCs as CommandClasses[]; } } /** * Returns the current endpoint count of this node. * * If you want to enumerate the existing endpoints, use `getEndpointIndizes` instead. * Some devices are known to contradict themselves. */ public getEndpointCount(): number { return nodeUtils.getEndpointCount(this.driver, this); } /** * Returns indizes of all endpoints on the node. */ public getEndpointIndizes(): number[] { return nodeUtils.getEndpointIndizes(this.driver, this); } /** Whether the Multi Channel CC has been interviewed and all endpoint information is known */ private get isMultiChannelInterviewComplete(): boolean { return nodeUtils.isMultiChannelInterviewComplete(this.driver, this); } /** Cache for this node's endpoint instances */ private _endpointInstances = new Map<number, Endpoint>(); /** * Returns an endpoint of this node with the given index. 0 returns the node itself. */ public getEndpoint(index: 0): Endpoint; public getEndpoint(index: number): Endpoint | undefined; public getEndpoint(index: number): Endpoint | undefined { if (index < 0) throw new ZWaveError( "The endpoint index must be positive!", ZWaveErrorCodes.Argument_Invalid, ); // Zero is the root endpoint - i.e. this node if (index === 0) return this; // Check if the Multi Channel CC interview for this node is completed, // because we don't have all the information before that if (!this.isMultiChannelInterviewComplete) { this.driver.driverLog.print( `Node ${this.nodeId}, Endpoint ${index}: Trying to access endpoint instance before Multi Channel interview`, "error", ); return undefined; } // Check if the endpoint index is in the list of known endpoint indizes if (!this.getEndpointIndizes().includes(index)) return undefined; // Create an endpoint instance if it does not exist if (!this._endpointInstances.has(index)) { this._endpointInstances.set( index, new Endpoint( this.id, this.driver, index, this.getEndpointDeviceClass(index), this.getEndpointCCs(index), ), ); } return this._endpointInstances.get(index)!; } public getEndpointOrThrow(index: number): Endpoint { const ret = this.getEndpoint(index); if (!ret) { throw new ZWaveError( `Endpoint ${index} does not exist on Node ${this.id}`, ZWaveErrorCodes.Controller_EndpointNotFound, ); } return ret; } /** Returns a list of all endpoints of this node, including the root endpoint (index 0) */ public getAllEndpoints(): Endpoint[] { return nodeUtils.getAllEndpoints(this.driver, this) as Endpoint[]; } /** * This tells us which interview stage was last completed */ public get interviewStage(): InterviewStage { return ( this.driver.cacheGet(cacheKeys.node(this.id).interviewStage) ?? InterviewStage.None ); } public set interviewStage(value: InterviewStage) { this.driver.cacheSet(cacheKeys.node(this.id).interviewStage, value); } private _interviewAttempts: number = 0; /** How many attempts to interview this node have already been made */ public get interviewAttempts(): number { return this._interviewAttempts; } private _hasEmittedNoS2NetworkKeyError: boolean = false; private _hasEmittedNoS0NetworkKeyError: boolean = false; /** Returns whether this node is the controller */ public get isControllerNode(): boolean { return this.id === this.driver.controller.ownNodeId; } /** * Starts or resumes a deferred initial interview of this node. * * **WARNING:** This is only allowed when the initial interview was deferred using the * `interview.disableOnNodeAdded` option. Otherwise, this method will throw an error. * * **NOTE:** It is advised to NOT await this method as it can take a very long time (minutes to hours)! */ public async interview(): Promise<void> { // The initial interview of the controller node is always done // and cannot be deferred. if (this.isControllerNode) return; if (!this.driver.options.interview?.disableOnNodeAdded) { throw new ZWaveError( `Calling ZWaveNode.interview() is not allowed because automatic node interviews are enabled. Wait for the driver to interview the node or use ZWaveNode.refreshInfo() to re-interview a node.`, ZWaveErrorCodes.Driver_FeatureDisabled, ); } return this.driver.interviewNodeInternal(this); } /** * Resets all information about this node and forces a fresh interview. * **Note:** This does nothing for the controller node. * * **WARNING:** Take care NOT to call this method when the node is already being interviewed. * Otherwise the node information may become inconsistent. */ public async refreshInfo(options: RefreshInfoOptions = {}): Promise<void> { // It does not make sense to re-interview the controller. All important information is queried // directly via the serial API if (this.isControllerNode) return; const { resetSecurityClasses = false, waitForWakeup = true } = options; // Unless desired, don't forget the information about sleeping nodes immediately, so they continue to function if ( waitForWakeup && this.canSleep && this.supportsCC(CommandClasses["Wake Up"]) ) { // eslint-disable-next-line @typescript-eslint/no-empty-function await this.waitForWakeup().catch(() => {}); } // preserve the node name and location, since they might not be stored on the node const name = this.name; const location = this.location; // Force a new detection of security classes if desired if (resetSecurityClasses) this.securityClasses.clear(); this._interviewAttempts = 0; this.interviewStage = InterviewStage.None; this._ready = false; this.deviceClass = undefined; this.isListening = undefined; this.isFrequentListening = undefined; this.isRouting = undefined; this.supportedDataRates = undefined; this.protocolVersion = undefined; this.nodeType = undefined; this.supportsSecurity = undefined; this.supportsBeaming = undefined; this._deviceConfig = undefined; this._hasEmittedNoS0NetworkKeyError = false; this._hasEmittedNoS2NetworkKeyError = false; this._valueDB.clear({ noEvent: true }); this._endpointInstances.clear(); super.reset(); // Restart all state machines this.readyMachine.restart(); this.statusMachine.restart(); // Remove queued polls that would interfere with the interview for (const valueId of this.scheduledPolls.keys()) { this.cancelScheduledPoll(valueId); } // Restore the previously saved name/location if (name != undefined) this.name = name; if (location != undefined) this.location = location; // Don't keep the node awake after the interview this.keepAwake = false; void this.driver.interviewNodeInternal(this); } /** * @internal * Interviews this node. Returns true when it succeeded, false otherwise * * WARNING: Do not call this method from application code. To refresh the information * for a specific node, use `node.refreshInfo()` instead */ public async interviewInternal(): Promise<boolean> { if (this.interviewStage === InterviewStage.Complete) { this.driver.controllerLog.logNode( this.id, `skipping interview because it is already completed`, ); return true; } else { this.driver.controllerLog.interviewStart(this); } // Remember that we tried to interview this node this._interviewAttempts++; // Wrapper around interview methods to return false in case of a communication error // This way the single methods don't all need to have the same error handler const tryInterviewStage = async ( method: () => Promise<void>, ): Promise<boolean> => { try { await method(); return true; } catch (e) { if (isTransmissionError(e)) { return false; } throw e; } }; // The interview is done in several stages. At each point, the interview process might be aborted // due to a stage failing. The reached stage is saved, so we can continue it later without // repeating stages unnecessarily if (this.interviewStage === InterviewStage.None) { // do a full interview starting with the protocol info this.driver.controllerLog.logNode( this.id, `new node, doing a full interview...`, ); this.emit("interview started", this); await this.queryProtocolInfo(); } if (!this.isControllerNode) { if ( (this.isListening || this.isFrequentListening) && this.status !== NodeStatus.Alive ) { // Ping non-sleeping nodes to determine their status await this.ping(); } if (this.interviewStage === InterviewStage.ProtocolInfo) { if (!(await tryInterviewStage(() => this.queryNodeInfo()))) { return false; } } // At this point the basic interview of new nodes is done. Start here when re-interviewing known nodes // to get updated information about command classes if (this.interviewStage === InterviewStage.NodeInfo) { // Only advance the interview if it was completed, otherwise abort if (await this.interviewCCs()) { this.setInterviewStage(InterviewStage.CommandClasses); } else { return false; } } } if ( (this.isControllerNode && this.interviewStage === InterviewStage.ProtocolInfo) || (!this.isControllerNode && this.interviewStage === InterviewStage.CommandClasses) ) { // Load a config file for this node if it exists and overwrite the previously reported information await this.overwriteConfig(); } this.setInterviewStage(InterviewStage.Complete); this.readyMachine.send("INTERVIEW_DONE"); // Tell listeners that the interview is completed // The driver will then send this node to sleep this.emit("interview completed", this); return true; } /** Updates this node's interview stage and saves to cache when appropriate */ private setInterviewStage(completedStage: InterviewStage): void { this.interviewStage = completedStage; this.emit( "interview stage completed", this, getEnumMemberName(InterviewStage, completedStage), ); this.driver.controllerLog.interviewStage(this); } /** Step #1 of the node interview */ protected async queryProtocolInfo(): Promise<void> { this.driver.controllerLog.logNode(this.id, { message: "querying protocol info...", direction: "outbound", }); const resp = await this.driver.sendMessage<GetNodeProtocolInfoResponse>( new GetNodeProtocolInfoRequest(this.driver, { requestedNodeId: this.id, }), ); this.isListening = resp.isListening; this.isFrequentListening = resp.isFrequentListening; this.isRouting = resp.isRouting; this.supportedDataRates = resp.supportedDataRates; this.protocolVersion = resp.protocolVersion; this.nodeType = resp.nodeType; this.supportsSecurity = resp.supportsSecurity; this.supportsBeaming = resp.supportsBeaming; const deviceClass = new DeviceClass( this.driver.configManager, resp.basicDeviceClass, resp.genericDeviceClass, resp.specificDeviceClass, ); this.applyDeviceClass(deviceClass); const logMessage = `received response for protocol info: basic device class: ${this.deviceClass!.basic.label} generic device class: ${this.deviceClass!.generic.label} specific device class: ${this.deviceClass!.specific.label} node type: ${getEnumMemberName(NodeType, this.nodeType)} is always listening: ${this.isListening} is frequent listening: ${this.isFrequentListening} can route messages: ${this.isRouting} supports security: ${this.supportsSecurity} supports beaming: ${this.supportsBeaming} maximum data rate: ${this.maxDataRate} kbps protocol version: ${this.protocolVersion}`; this.driver.controllerLog.logNode(this.id, { message: logMessage, direction: "inbound", }); // Assume that sleeping nodes start asleep if (this.canSleep) { if (this.status === NodeStatus.Alive) { // unless it was just included and is currently communicating with us // In that case we need to switch from alive/dead to awake/asleep this.markAsAwake(); } else { this.markAsAsleep(); } } this.setInterviewStage(InterviewStage.ProtocolInfo); } /** Node interview: pings the node to see if it responds */ public async ping(): Promise<boolean> { if (this.isControllerNode) { this.driver.controllerLog.logNode( this.id, "is the controller node, cannot ping", "warn", ); return true; } this.driver.controllerLog.logNode(this.id, { message: "pinging the node...", direction: "outbound", }); try { await this.commandClasses["No Operation"].send(); this.driver.controllerLog.logNode(this.id, { message: "ping successful", direction: "inbound", }); return true; } catch (e) { this.driver.controllerLog.logNode( this.id, `ping failed: ${getErrorMessage(e)}`, ); return false; } } /** * Step #5 of the node interview * Request node info */ protected async queryNodeInfo(): Promise<void> { if (this.isControllerNode) { this.driver.controllerLog.logNode( this.id, "is the controller node, cannot query node info", "warn", ); return; } this.driver.controllerLog.logNode(this.id, { message: "querying node info...", direction: "outbound", }); const resp = await this.driver.sendMessage< RequestNodeInfoResponse | ApplicationUpdateRequest >(new RequestNodeInfoRequest(this.driver, { nodeId: this.id })); if (resp instanceof RequestNodeInfoResponse && !resp.wasSent) { // TODO: handle this in SendThreadMachine this.driver.controllerLog.logNode( this.id, `Querying the node info failed`, "error", ); throw new ZWaveError( `Querying the node info failed`, ZWaveErrorCodes.Controller_ResponseNOK, ); } else if ( resp instanceof ApplicationUpdateRequestNodeInfoRequestFailed ) { // TODO: handle this in SendThreadMachine this.driver.controllerLog.logNode( this.id, `Querying the node info failed`, "error", ); throw new ZWaveError( `Querying the node info failed`, ZWaveErrorCodes.Controller_CallbackNOK, ); } else if (resp instanceof ApplicationUpdateRequestNodeInfoReceived) { const logLines: string[] = ["node info received", "supported CCs:"]; for (const cc of resp.nodeInformation.supportedCCs) { const ccName = CommandClasses[cc]; logLines.push(`· ${ccName ? ccName : num2hex(cc)}`); } this.driver.controllerLog.logNode(this.id, { message: logLines.join("\n"), direction: "inbound", }); this.updateNodeInfo(resp.nodeInformation); } this.setInterviewStage(InterviewStage.NodeInfo); } /** * Loads the device configuration for this node from a config file */ protected async loadDeviceConfig(): Promise<void> { // But the configuration definitions might change if ( this.manufacturerId != undefined && this.productType != undefined && this.productId != undefined ) { // Try to load the config file this._deviceConfig = await this.driver.configManager.lookupDevice( this.manufacturerId, this.productType, this.productId, this.firmwareVersion, ); if (this._deviceConfig) { this.driver.controllerLog.logNode( this.id, `${ this._deviceConfig.isEmbedded ? "Embedded" : "User-provided" } device config loaded`, ); } else { this.driver.controllerLog.logNode( this.id, "No device config found", "warn", ); } } } /** Step #? of the node interview */ protected async interviewCCs(): Promise<boolean> { if (this.isControllerNode) { this.driver.controllerLog.logNode( this.id, "is the controller node, cannot interview CCs", "warn", ); return true; } const interviewEndpoint = async ( endpoint: Endpoint, cc: CommandClasses, ): Promise<"continue" | false | void> => { let instance: CommandClass; try { instance = endpoint.createCCInstance(cc)!; } catch (e) { if ( isZWaveError(e) && e.code === ZWaveErrorCodes.CC_NotSupported ) { // The CC is no longer supported. This can happen if the node tells us // something different in the Version interview than it did in its NIF return "continue"; } // we want to pass all other errors through throw e; } if ( endpoint.isCCSecure(cc) && !this.driver.securityManager && !this.driver.securityManager2 ) { // The CC is only supported securely, but the network key is not set up // Skip the CC this.driver.controllerLog.logNode( this.id, `Skipping interview for secure CC ${getCCName( cc, )} because no network key is configured!`, "error", ); return "continue"; } // Skip this step if the CC was already interviewed if (instance.isInterviewComplete(this.driver)) return "continue"; try { await instance.interview(this.driver); } catch (e) { if (isTransmissionError(e)) { // We had a CAN or timeout during the interview // or the node is presumed dead. Abort the process return false; } // we want to pass all other errors through throw e; } }; // Always interview Security first because it changes the interview order if (this.supportsCC(CommandClasses["Security 2"])) { // Security S2 is always supported *securely* this.addCC(CommandClasses["Security 2"], { secure: true }); // Query supported CCs unless we know for sure that the node wasn't assigned a S2 security class const secu