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