UNPKG

zwave-js

Version:

Z-Wave driver written entirely in JavaScript/TypeScript

395 lines 16.6 kB
import { CCAPI, CommandClass, getCommandClassStatic, normalizeCCNameOrId, } from "@zwave-js/cc"; import { ZWavePlusCCValues } from "@zwave-js/cc/ZWavePlusCC"; import { BasicDeviceClass, CacheBackedMap, CommandClasses, GraphNode, ZWaveError, ZWaveErrorCodes, actuatorCCs, getCCName, isCCInfoEqual, } from "@zwave-js/core"; import { getEnumMemberName, num2hex } from "@zwave-js/shared"; import { cacheKeys } from "../driver/NetworkCache.js"; /** * 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 { nodeId; driver; index; constructor( /** The id of the node this endpoint belongs to */ nodeId, /** The driver instance this endpoint belongs to */ driver, /** The index of this endpoint. 0 for the root device, 1+ otherwise */ index, deviceClass, supportedCCs) { this.nodeId = nodeId; this.driver = driver; this.index = index; // Initialize class fields this._implementedCommandClasses = new CacheBackedMap(this.driver.networkCache, { prefix: cacheKeys.node(this.nodeId).endpoint(this.index)._ccBaseKey, suffixSerializer: (cc) => num2hex(cc), suffixDeserializer: (key) => { const ccId = parseInt(key, 16); if (ccId in CommandClasses) return ccId; }, }); // Optionally initialize the device class if (deviceClass) this.deviceClass = deviceClass; // Add optional CCs if (supportedCCs != undefined) { for (const cc of supportedCCs) { if (cc === CommandClasses.Basic) { // This codepath is only taken when we construct // a node instance with info from a NIF. // // Basic CC MUST not be in the NIF. If it is anyways, we ignore it. // If we blindly add it here as supported, it will always be exposed. // // Whether or not it should be exposed is determined at a later stage. continue; } this.addCC(cc, { isSupported: true }); } } } /** Required by {@link IZWaveEndpoint} */ virtual = false; /** * Only used for endpoints which store their device class differently than nodes. * DO NOT ACCESS directly! */ _deviceClass; get deviceClass() { if (this.index > 0) { return this._deviceClass; } else { return this.driver.cacheGet(cacheKeys.node(this.nodeId).deviceClass); } } set deviceClass(deviceClass) { if (this.index > 0) { this._deviceClass = deviceClass; } else { this.driver.cacheSet(cacheKeys.node(this.nodeId).deviceClass, deviceClass); } } /** Can be used to distinguish multiple endpoints of a node */ get endpointLabel() { return this.tryGetNode()?.deviceConfig?.endpoints?.get(this.index) ?.label; } /** Resets all stored information of this endpoint */ reset() { this._implementedCommandClasses.clear(); this._commandClassAPIs.clear(); } _implementedCommandClasses; /** * @internal * Information about the implemented Command Classes of this endpoint. */ get implementedCommandClasses() { return this._implementedCommandClasses; } getCCs() { return this._implementedCommandClasses.entries(); } /** * 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. */ addCC(cc, info) { // 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 (original == undefined || !isCCInfoEqual(original, updated)) { this._implementedCommandClasses.set(cc, updated); } } /** Removes a CC from the list of command classes implemented by the endpoint */ removeCC(cc) { this._implementedCommandClasses.delete(cc); } /** Tests if this endpoint supports the given CommandClass */ supportsCC(cc) { return !!this._implementedCommandClasses.get(cc)?.isSupported; } /** Tests if this endpoint supports or controls the given CC only securely */ isCCSecure(cc) { return !!this._implementedCommandClasses.get(cc)?.secure; } /** Tests if this endpoint controls the given CommandClass */ controlsCC(cc) { return !!this._implementedCommandClasses.get(cc)?.isControlled; } /** * Checks if this endpoint is allowed to support Basic CC per the specification. * This depends on the device type and the other supported CCs */ maySupportBasicCC() { // Basic CC must not be offered if any other actuator CC is supported if (actuatorCCs.some((cc) => this.supportsCC(cc))) { return false; } // ...or the device class forbids it return this.deviceClass?.specific.maySupportBasicCC ?? this.deviceClass?.generic.maySupportBasicCC ?? true; } /** Determines if support for a CC was force-removed via config file */ wasCCRemovedViaConfig(cc) { if (this.supportsCC(cc)) return false; const compatConfig = this.tryGetNode()?.deviceConfig?.compat; if (!compatConfig) return false; const removedEndpoints = compatConfig.removeCCs?.get(cc); if (!removedEndpoints) return false; return removedEndpoints == "*" || removedEndpoints.includes(this.index); } /** * Determines if support for a CC was force-added via config file. */ wasCCSupportAddedViaConfig(cc) { const compatConfig = this.tryGetNode()?.deviceConfig?.compat; if (!compatConfig) return false; const addedCC = compatConfig.addCCs?.get(cc); if (!addedCC) return false; const endpointInfo = addedCC.endpoints.get(this.index); if (!endpointInfo) return false; return endpointInfo.isSupported === true; } /** * Retrieves the version of the given CommandClass this endpoint implements. * Returns 0 if the CC is not supported. */ getCCVersion(cc) { 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.tryGetNode().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. */ createCCInstance(cc) { 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, 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. */ createCCInstanceUnsafe(cc) { const ccId = typeof cc === "number" ? cc : getCommandClassStatic(cc); if (this.supportsCC(ccId) || this.controlsCC(ccId)) { return CommandClass.createInstanceUnchecked(this, cc); } } /** Returns instances for all CCs this endpoint supports, that should be interviewed, and that are implemented in this library */ getSupportedCCInstances() { 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); // 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 */ buildCCInterviewGraph(skipCCs) { 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. */ createAPI(ccId, requireSupport = true) { // Trust me on this, TypeScript :) return CCAPI.create(ccId, this.driver, this, requireSupport); } _commandClassAPIs = new Map(); _commandClassAPIsProxy = new Proxy(this._commandClassAPIs, { get: (target, ccNameOrId) => { // 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 { // The command classes are exposed to library users by their name or the ID const ccId = normalizeCCNameOrId(ccNameOrId); if (ccId == undefined) { throw new ZWaveError(`Command Class ${ccNameOrId} is not implemented!`, 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 */ commandClassesIterator = function* () { for (const cc of this.implementedCommandClasses.keys()) { if (this.supportsCC(cc)) yield this.commandClasses[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 */ get commandClasses() { return this._commandClassAPIsProxy; } /** Allows checking whether a CC API is supported before calling it with {@link Endpoint.invokeCCAPI} */ supportsCCAPI(cc) { // No need to validate the `cc` parameter, the following line will throw for invalid CCs return this.commandClasses[cc].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. */ invokeCCAPI(cc, method, ...args) { // No need to validate the `cc` parameter, the following line will throw for invalid CCs const CCAPI = this.commandClasses[cc]; const ccId = normalizeCCNameOrId(cc); const ccName = getCCName(ccId); 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}" 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) */ tryGetNode() { return this.driver.controller.nodes.get(this.nodeId); } /** Z-Wave+ Icon (for management) */ get installerIcon() { return this.tryGetNode()?.getValue(ZWavePlusCCValues.installerIcon.endpoint(this.index)); } /** Z-Wave+ Icon (for end users) */ get userIcon() { return this.tryGetNode()?.getValue(ZWavePlusCCValues.userIcon.endpoint(this.index)); } /** * @internal * Returns a dump of this endpoint's information for debugging purposes */ createEndpointDump() { const ret = { index: this.index, deviceClass: "unknown", commandClasses: {}, maySupportBasicCC: this.maySupportBasicCC(), }; if (this.deviceClass) { ret.deviceClass = { basic: { key: this.deviceClass.basic, label: getEnumMemberName(BasicDeviceClass, this.deviceClass.basic), }, generic: { key: this.deviceClass.generic.key, label: this.deviceClass.generic.label, }, specific: { key: this.deviceClass.specific.key, label: this.deviceClass.specific.label, }, }; } for (const [ccId, info] of this._implementedCommandClasses) { ret.commandClasses[getCCName(ccId)] = { ...info, values: [] }; } for (const [prop, value] of Object.entries(ret)) { // @ts-expect-error if (value === undefined) delete ret[prop]; } return ret; } } //# sourceMappingURL=Endpoint.js.map