inventoresed
Version:
Z-Wave driver written entirely in JavaScript/TypeScript
492 lines (451 loc) • 15.8 kB
text/typescript
import {
APIMethodsOf,
CCAPI,
CCAPIs,
CCConstructor,
CCToAPI,
CommandClass,
getCommandClassStatic,
} from "@zwave-js/cc";
import { ZWavePlusCCValues } from "@zwave-js/cc/ZWavePlusCC";
import type { IZWaveEndpoint } from "@zwave-js/core";
import {
actuatorCCs,
CacheBackedMap,
CommandClasses,
CommandClassInfo,
getCCName,
GraphNode,
ZWaveError,
ZWaveErrorCodes,
} from "@zwave-js/core";
import { num2hex } from "@zwave-js/shared";
import { isDeepStrictEqual } from "util";
import type { Driver } from "../driver/Driver";
import { cacheKeys } from "../driver/NetworkCache";
import type { DeviceClass } from "./DeviceClass";
import type { ZWaveNode } from "./Node";
/**
* Represents a physical endpoint of a Z-Wave node. This can either be the root
* device itself (index 0) or a more specific endpoint like a single plug.
*
* Each endpoint may have different capabilities (device class/supported CCs)
*/
export class Endpoint implements IZWaveEndpoint {
public constructor(
/** The id of the node this endpoint belongs to */
public readonly nodeId: number,
/** The driver instance this endpoint belongs to */
protected readonly driver: Driver,
/** The index of this endpoint. 0 for the root device, 1+ otherwise */
public readonly index: number,
deviceClass?: DeviceClass,
supportedCCs?: CommandClasses[],
) {
this.applyDeviceClass(deviceClass);
// Add optional CCs
if (supportedCCs != undefined) {
for (const cc of supportedCCs) {
this.addCC(cc, { isSupported: true });
}
}
}
/** Required by {@link IZWaveEndpoint} */
public readonly virtual = false;
/**
* Only used for endpoints which store their device class differently than nodes.
* DO NOT ACCESS directly!
*/
private _deviceClass: DeviceClass | undefined;
public get deviceClass(): DeviceClass | undefined {
if (this.index > 0) {
return this._deviceClass;
} else {
return this.driver.cacheGet(
cacheKeys.node(this.nodeId).deviceClass,
);
}
}
protected set deviceClass(deviceClass: DeviceClass | undefined) {
if (this.index > 0) {
this._deviceClass = deviceClass;
} else {
this.driver.cacheSet(
cacheKeys.node(this.nodeId).deviceClass,
deviceClass,
);
}
}
/** Resets all stored information of this endpoint */
protected reset(): void {
this._implementedCommandClasses.clear();
this._commandClassAPIs.clear();
}
private _implementedCommandClasses: Map<
CommandClasses,
Readonly<CommandClassInfo>
> = new CacheBackedMap(this.driver.networkCache, {
prefix: cacheKeys.node(this.nodeId).endpoint(this.index)._ccBaseKey,
suffixSerializer: (cc: CommandClasses) => num2hex(cc),
suffixDeserializer: (key: string) => {
const ccId = parseInt(key, 16);
if (ccId in CommandClasses) return ccId;
},
});
/**
* @internal
* Information about the implemented Command Classes of this endpoint.
*/
public get implementedCommandClasses(): ReadonlyMap<
CommandClasses,
Readonly<CommandClassInfo>
> {
return this._implementedCommandClasses;
}
public getCCs(): Iterable<[ccId: CommandClasses, info: CommandClassInfo]> {
return this._implementedCommandClasses.entries();
}
/**
* Sets the device class of this endpoint and configures the mandatory CCs.
* **Note:** This does nothing if the device class was already configured
*/
protected applyDeviceClass(deviceClass?: DeviceClass): void {
if (this.deviceClass) return;
this.deviceClass = deviceClass;
// Add mandatory CCs
if (deviceClass) {
for (const cc of deviceClass.mandatorySupportedCCs) {
this.addMandatoryCC(cc, { isSupported: true });
}
for (const cc of deviceClass.mandatoryControlledCCs) {
this.addMandatoryCC(cc, { isControlled: true });
}
}
}
/**
* Adds a CC to the list of command classes implemented by the endpoint or updates the information.
* You shouldn't need to call this yourself.
* @param info The information about the command class. This is merged with existing information.
*/
public addCC(cc: CommandClasses, info: Partial<CommandClassInfo>): void {
// Endpoints cannot support Multi Channel CC
if (this.index > 0 && cc === CommandClasses["Multi Channel"]) return;
const original = this._implementedCommandClasses.get(cc);
const updated = Object.assign(
{},
original ?? {
isSupported: false,
isControlled: false,
secure: false,
version: 0,
},
info,
);
if (!isDeepStrictEqual(original, updated)) {
this._implementedCommandClasses.set(cc, updated);
}
}
/**
* Adds a mandatory CC to the list of command classes implemented by the endpoint or updates the information.
* Performs some sanity checks before adding so the behavior is in compliance with the specifications
*/
protected addMandatoryCC(
cc: CommandClasses,
info: Partial<CommandClassInfo>,
): void {
if (
this.getNodeUnsafe()?.isListening &&
(cc === CommandClasses.Battery || cc === CommandClasses["Wake Up"])
) {
// Avoid adding Battery and Wake Up CC to always listening nodes or their endpoints
return;
} else if (
this.index > 0 &&
[
CommandClasses["CRC-16 Encapsulation"],
CommandClasses["Device Reset Locally"],
CommandClasses["Manufacturer Specific"],
CommandClasses.Powerlevel,
CommandClasses.Version,
CommandClasses["Transport Service"],
].includes(cc)
) {
// Avoid adding CCs as mandatory to endpoints that should only be implemented by the root device
return;
}
this.addCC(cc, info);
}
/** Removes a CC from the list of command classes implemented by the endpoint */
public removeCC(cc: CommandClasses): void {
this._implementedCommandClasses.delete(cc);
}
/** Tests if this endpoint supports the given CommandClass */
public supportsCC(cc: CommandClasses): boolean {
return !!this._implementedCommandClasses.get(cc)?.isSupported;
}
/** Tests if this endpoint supports or controls the given CC only securely */
public isCCSecure(cc: CommandClasses): boolean {
return !!this._implementedCommandClasses.get(cc)?.secure;
}
/** Tests if this endpoint controls the given CommandClass */
public controlsCC(cc: CommandClasses): boolean {
return !!this._implementedCommandClasses.get(cc)?.isControlled;
}
/** Removes the BasicCC from the supported CCs if any other actuator CCs are supported */
public hideBasicCCInFavorOfActuatorCCs(): void {
// This behavior is defined in SDS14223
if (
this.supportsCC(CommandClasses.Basic) &&
actuatorCCs.some((cc) => this.supportsCC(cc))
) {
// We still want to know if BasicCC is controlled, so only mark it as not supported
this.addCC(CommandClasses.Basic, { isSupported: false });
// If the record is now only a dummy, remove the CC
if (
!this.supportsCC(CommandClasses.Basic) &&
!this.controlsCC(CommandClasses.Basic)
) {
this.removeCC(CommandClasses.Basic);
}
}
}
/**
* Retrieves the version of the given CommandClass this endpoint implements.
* Returns 0 if the CC is not supported.
*/
public getCCVersion(cc: CommandClasses): number {
const ccInfo = this._implementedCommandClasses.get(cc);
const ret = ccInfo?.version ?? 0;
// The specs are contracting themselves here...
//
// CC Control Specification:
// A controlling node interviewing a Multi Channel End Point
// MUST request the End Point’s Command Class version from the Root Device
// if the End Point does not advertise support for the Version Command Class.
// - vs -
// Management CC Specification:
// [...] the Version Command Class SHOULD NOT be supported by individual End Points
// The Root Device MUST respond to Version requests for any Command Class
// implemented by the Multi Channel device; also in cases where the actual
// Command Class is only provided by an End Point.
//
// We go with the 2nd interpretation since the other either results in
// an unnecessary Version CC interview for each endpoint or an incorrect V1 for endpoints
if (ret === 0 && this.index > 0) {
return this.getNodeUnsafe()!.getCCVersion(cc);
}
return ret;
}
/**
* Creates an instance of the given CC and links it to this endpoint.
* Throws if the CC is neither supported nor controlled by the endpoint.
*/
public createCCInstance<T extends CommandClass>(
cc: CommandClasses | CCConstructor<T>,
): T | undefined {
const ccId = typeof cc === "number" ? cc : getCommandClassStatic(cc);
if (!this.supportsCC(ccId) && !this.controlsCC(ccId)) {
throw new ZWaveError(
`Cannot create an instance of the unsupported CC ${
CommandClasses[ccId]
} (${num2hex(ccId)})`,
ZWaveErrorCodes.CC_NotSupported,
);
}
return CommandClass.createInstanceUnchecked(this.driver, this, cc);
}
/**
* Creates an instance of the given CC and links it to this endpoint.
* Returns `undefined` if the CC is neither supported nor controlled by the endpoint.
*/
public createCCInstanceUnsafe<T extends CommandClass>(
cc: CommandClasses | CCConstructor<T>,
): T | undefined {
const ccId = typeof cc === "number" ? cc : getCommandClassStatic(cc);
if (this.supportsCC(ccId) || this.controlsCC(ccId)) {
return CommandClass.createInstanceUnchecked(this.driver, this, cc);
}
}
/** Returns instances for all CCs this endpoint supports, that should be interviewed, and that are implemented in this library */
public getSupportedCCInstances(): readonly CommandClass[] {
let supportedCCInstances = [...this.implementedCommandClasses.keys()]
// Don't interview CCs the node or endpoint only controls
.filter((cc) => this.supportsCC(cc))
// Filter out CCs we don't implement
.map((cc) => this.createCCInstance(cc))
.filter((instance) => !!instance) as CommandClass[];
// For endpoint interviews, we skip some CCs
if (this.index > 0) {
supportedCCInstances = supportedCCInstances.filter(
(instance) => !instance.skipEndpointInterview(),
);
}
return supportedCCInstances;
}
/** Builds the dependency graph used to automatically determine the order of CC interviews */
public buildCCInterviewGraph(
skipCCs: CommandClasses[],
): GraphNode<CommandClasses>[] {
const supportedCCs = this.getSupportedCCInstances()
.map((instance) => instance.ccId)
.filter((ccId) => !skipCCs.includes(ccId));
// Create GraphNodes from all supported CCs that should not be skipped
const ret = supportedCCs.map((cc) => new GraphNode(cc));
// Create the dependencies
for (const node of ret) {
const instance = this.createCCInstance(node.value)!;
for (const requiredCCId of instance.determineRequiredCCInterviews()) {
const requiredCC = ret.find(
(instance) => instance.value === requiredCCId,
);
if (requiredCC) node.edges.add(requiredCC);
}
}
return ret;
}
/**
* @internal
* Creates an API instance for a given command class. Throws if no API is defined.
* @param ccId The command class to create an API instance for
* @param requireSupport Whether accessing the API should throw if it is not supported by the node.
*/
public createAPI<T extends CommandClasses>(
ccId: T,
requireSupport: boolean = true,
): CommandClasses extends T ? CCAPI : CCToAPI<T> {
// Trust me on this, TypeScript :)
return CCAPI.create(ccId, this.driver, this, requireSupport) as any;
}
private _commandClassAPIs = new Map<CommandClasses, CCAPI>();
private _commandClassAPIsProxy = new Proxy(this._commandClassAPIs, {
get: (target, ccNameOrId: string | symbol) => {
// Avoid ultra-weird error messages during testing
if (
process.env.NODE_ENV === "test" &&
typeof ccNameOrId === "string" &&
(ccNameOrId === "$$typeof" ||
ccNameOrId === "constructor" ||
ccNameOrId.includes("@@__IMMUTABLE"))
) {
return undefined;
}
if (typeof ccNameOrId === "symbol") {
// Allow access to the iterator symbol
if (ccNameOrId === Symbol.iterator) {
return this.commandClassesIterator;
} else if (ccNameOrId === Symbol.toStringTag) {
return "[object Object]";
}
// ignore all other symbols
return undefined;
} else {
// typeof ccNameOrId === "string"
let ccId: CommandClasses | undefined;
// The command classes are exposed to library users by their name or the ID
if (/^\d+$/.test(ccNameOrId)) {
// Since this is a property accessor, ccNameOrID is passed as a string,
// even when it was a number (CommandClasses)
ccId = +ccNameOrId;
} else {
// If a name was given, retrieve the corresponding ID
ccId = CommandClasses[ccNameOrId as any] as unknown as
| CommandClasses
| undefined;
if (ccId == undefined) {
throw new ZWaveError(
`Command Class ${ccNameOrId} is not implemented! If you are sure that the name/id is correct, consider opening an issue at https://github.com/AlCalzone/node-zwave-js`,
ZWaveErrorCodes.CC_NotImplemented,
);
}
}
// When accessing a CC API for the first time, we need to create it
if (!target.has(ccId)) {
const api = CCAPI.create(ccId, this.driver, this);
target.set(ccId, api);
}
return target.get(ccId);
}
},
});
/**
* Used to iterate over the commandClasses API without throwing errors by accessing unsupported CCs
*/
private readonly commandClassesIterator: () => Iterator<CCAPI> = function* (
this: Endpoint,
) {
for (const cc of this.implementedCommandClasses.keys()) {
if (this.supportsCC(cc)) yield (this.commandClasses as any)[cc];
}
}.bind(this);
/**
* Provides access to simplified APIs that are tailored to specific CCs.
* Make sure to check support of each API using `API.isSupported()` since
* all other API calls will throw if the API is not supported
*/
public get commandClasses(): CCAPIs {
return this._commandClassAPIsProxy as unknown as CCAPIs;
}
/** Allows checking whether a CC API is supported before calling it with {@link Endpoint.invokeCCAPI} */
public supportsCCAPI(cc: CommandClasses): boolean {
return ((this.commandClasses as any)[cc] as CCAPI).isSupported();
}
/**
* Allows dynamically calling any CC API method on this endpoint by CC ID and method name.
* Use {@link Endpoint.supportsCCAPI} to check support first.
*/
public invokeCCAPI<
CC extends CommandClasses,
TMethod extends keyof TAPI,
TAPI extends Record<
string,
(...args: any[]) => any
> = CommandClasses extends CC ? any : APIMethodsOf<CC>,
>(
cc: CC,
method: TMethod,
...args: Parameters<TAPI[TMethod]>
): ReturnType<TAPI[TMethod]> {
if (typeof cc !== "number" || !(cc in CommandClasses)) {
throw new ZWaveError(
`Invalid CC ${cc}!`,
ZWaveErrorCodes.CC_Invalid,
);
}
const ccName = getCCName(cc);
const CCAPI = (this.commandClasses as any)[cc];
if (!CCAPI) {
throw new ZWaveError(
`The API for the ${ccName} CC does not exist or is not implemented!`,
ZWaveErrorCodes.CC_NoAPI,
);
}
const apiMethod = CCAPI[method];
if (typeof apiMethod !== "function") {
throw new ZWaveError(
`Method "${
method as string
}" does not exist on the API for the ${ccName} CC!`,
ZWaveErrorCodes.CC_NotImplemented,
);
}
return apiMethod.apply(CCAPI, args);
}
/**
* Returns the node this endpoint belongs to (or undefined if the node doesn't exist)
*/
public getNodeUnsafe(): ZWaveNode | undefined {
return this.driver.controller.nodes.get(this.nodeId);
}
/** Z-Wave+ Icon (for management) */
public get installerIcon(): number | undefined {
return this.getNodeUnsafe()?.getValue(
ZWavePlusCCValues.installerIcon.endpoint(this.index),
);
}
/** Z-Wave+ Icon (for end users) */
public get userIcon(): number | undefined {
return this.getNodeUnsafe()?.getValue(
ZWavePlusCCValues.userIcon.endpoint(this.index),
);
}
}