UNPKG

inventoresed

Version:

Z-Wave driver written entirely in JavaScript/TypeScript

492 lines (451 loc) 15.8 kB
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), ); } }