UNPKG

zwave-js-ui

Version:

Z-Wave Control Panel and MQTT Gateway

1,321 lines 188 kB
import { CommandClasses, dskToString, Duration, isUnsupervisedOrSucceeded, RouteKind, SecurityClass, SupervisionStatus, ZWaveErrorCodes, Protocols, tryUnzipFirmwareFile, extractFirmware, } from '@zwave-js/core'; import { createDefaultTransportFormat } from '@zwave-js/core/bindings/log/node'; import { JSONTransport } from '@zwave-js/log-transport-json'; import { OTWFirmwareUpdateStatus, ControllerStatus, Driver, ExclusionStrategy, FirmwareUpdateStatus, guessFirmwareFileFormat, InclusionStrategy, InterviewStage, libVersion, MultilevelSwitchCommand, NodeStatus, QRCodeVersion, RemoveNodeReason, RFRegion, ScheduleEntryLockCC, ScheduleEntryLockScheduleKind, SerialAPISetupCommand, setValueFailed, SetValueStatus, setValueWasUnsupervisedOrSucceeded, UserCodeCC, UserIDStatus, ProvisioningEntryStatus, AssociationCheckResult, JoinNetworkStrategy, DriverMode, BatteryReplacementStatus, } from 'zwave-js'; import { getEnumMemberName, parseQRCodeString } from 'zwave-js/Utils'; import { configDbDir, nvmBackupsDir, storeDir } from "../config/app.js"; import store from "../config/store.js"; import jsonStore from "./jsonStore.js"; import * as LogManager from "./logger.js"; import * as utils from "./utils.js"; import { serverVersion, ZwavejsServer } from '@zwave-js/server'; import { TypedEventEmitter } from "./EventEmitter.js"; import { ConfigManager } from '@zwave-js/config'; import { readFile, writeFile } from 'node:fs/promises'; import backupManager, { NVM_BACKUP_PREFIX } from "./BackupManager.js"; import { socketEvents } from "./SocketEvents.js"; import { isUint8Array } from 'node:util/types'; import { PkgFsBindings } from "./PkgFsBindings.js"; import { regionSupportsAutoPowerlevel } from "./shared.js"; import { deviceConfigPriorityDir } from "./Constants.js"; import { createRequire } from 'node:module'; export const configManager = new ConfigManager({ deviceConfigPriorityDir, }); const logger = LogManager.module('Z-Wave'); const NEIGHBORS_LOCK_REFRESH = 60 * 1000; function validateMethods(methods) { return methods; } // ZwaveClient Apis that can be called with MQTT apis export const allowedApis = validateMethods([ 'setNodeName', 'setNodeLocation', 'setNodeDefaultSetValueOptions', '_createScene', '_removeScene', '_setScenes', '_getScenes', '_sceneGetValues', '_addSceneValue', '_removeSceneValue', '_activateScene', 'refreshNeighbors', 'getNodeNeighbors', 'discoverNodeNeighbors', 'getAssociations', 'checkAssociation', 'addAssociations', 'removeAssociations', 'removeAllAssociations', 'removeNodeFromAllAssociations', 'getNodes', 'getInfo', 'refreshValues', 'refreshCCValues', 'pollValue', 'setPowerlevel', 'setRFRegion', 'setMaxLRPowerLevel', 'updateControllerNodeProps', 'startInclusion', 'startExclusion', 'stopInclusion', 'stopExclusion', 'replaceFailedNode', 'hardReset', 'softReset', 'rebuildNodeRoutes', 'getPriorityRoute', 'setPriorityRoute', 'assignReturnRoutes', 'getPriorityReturnRoute', 'getPrioritySUCReturnRoute', 'getCustomReturnRoute', 'getCustomSUCReturnRoute', 'assignPriorityReturnRoute', 'assignPrioritySUCReturnRoute', 'assignCustomReturnRoutes', 'assignCustomSUCReturnRoutes', 'deleteReturnRoutes', 'deleteSUCReturnRoutes', 'removePriorityRoute', 'beginRebuildingRoutes', 'stopRebuildingRoutes', 'isFailedNode', 'removeFailedNode', 'refreshInfo', 'updateFirmware', 'firmwareUpdateOTW', 'abortFirmwareUpdate', 'dumpNode', 'getAvailableFirmwareUpdates', 'getAllAvailableFirmwareUpdates', 'checkAllNodesFirmwareUpdates', 'dismissFirmwareUpdate', 'getNodeFirmwareUpdates', 'firmwareUpdateOTA', 'sendCommand', 'writeValue', 'writeBroadcast', 'writeMulticast', 'driverFunction', 'checkForConfigUpdates', 'installConfigUpdate', 'shutdownZwaveAPI', 'startLearnMode', 'stopLearnMode', 'pingNode', 'restart', 'grantSecurityClasses', 'validateDSK', 'abortInclusion', 'backupNVMRaw', 'restoreNVM', 'getProvisioningEntries', 'getProvisioningEntry', 'unprovisionSmartStartNode', 'provisionSmartStartNode', 'parseQRCodeString', 'checkLifelineHealth', 'abortHealthCheck', 'checkRouteHealth', 'checkLinkReliability', 'abortLinkReliabilityCheck', 'syncNodeDateAndTime', 'manuallyIdleNotificationValue', 'getSchedules', 'cancelGetSchedule', 'setSchedule', 'setEnabledSchedule', ]); // Define CommandClasses and properties that should be observed const observedCCProps = { [CommandClasses.Battery]: { level(node, value) { const levels = node.batteryLevels || {}; levels[value.endpoint] = value.value; node.batteryLevels = levels; node.minBatteryLevel = Math.min(...Object.values(levels)); this.emitNodeUpdate(node, { batteryLevels: levels, minBatteryLevel: node.minBatteryLevel, }); }, }, [CommandClasses['User Code']]: { userIdStatus(node, value) { const userId = value.propertyKey; const status = value.value; if (!node.userCodes) { return; } if (status === undefined || status === UserIDStatus.Available || status === UserIDStatus.StatusNotAvailable) { node.userCodes.available = node.userCodes.available.filter((id) => id !== userId); } else { node.userCodes.available.push(userId); } if (status === UserIDStatus.Enabled) { node.userCodes.enabled.push(userId); } else { node.userCodes.enabled = node.userCodes.enabled.filter((id) => id !== userId); } this.emitNodeUpdate(node, { userCodes: node.userCodes, }); }, }, [CommandClasses['Node Naming and Location']]: { name(node, value) { this.setNodeName(node.id, value.value).catch((error) => { logger.error(`Error while setting node name: ${error.message}`); }); }, location(node, value) { this.setNodeLocation(node.id, value.value).catch((error) => { logger.error(`Error while setting node location: ${error.message}`); }); }, }, }; export class DriverNotReadyError extends Error { constructor() { super('Driver is not ready'); // We need to set the prototype explicitly Object.setPrototypeOf(this, DriverNotReadyError.prototype); Object.getPrototypeOf(this).name = 'DriverNotReadyError'; } } export var ZUIScheduleEntryLockMode; (function (ZUIScheduleEntryLockMode) { ZUIScheduleEntryLockMode["DAILY"] = "daily"; ZUIScheduleEntryLockMode["WEEKLY"] = "weekly"; ZUIScheduleEntryLockMode["YEARLY"] = "yearly"; })(ZUIScheduleEntryLockMode || (ZUIScheduleEntryLockMode = {})); export var ZwaveClientStatus; (function (ZwaveClientStatus) { ZwaveClientStatus["CONNECTED"] = "connected"; ZwaveClientStatus["BOOTLOADER_READY"] = "bootloader ready"; ZwaveClientStatus["DRIVER_READY"] = "driver ready"; ZwaveClientStatus["SCAN_DONE"] = "scan done"; ZwaveClientStatus["DRIVER_FAILED"] = "driver failed"; ZwaveClientStatus["CLOSED"] = "closed"; })(ZwaveClientStatus || (ZwaveClientStatus = {})); export var EventSource; (function (EventSource) { EventSource["DRIVER"] = "driver"; EventSource["CONTROLLER"] = "controller"; EventSource["NODE"] = "node"; })(EventSource || (EventSource = {})); class ZwaveClient extends TypedEventEmitter { cfg; socket; closed; destroyed = false; _driverReady; scenes; _nodes; storeNodes; _devices; driverInfo; status; // used to store node info before inclusion like name and location tmpNode; // tells if a node replacement is in progress isReplacing = false; hasUserCallbacks = false; _error; _scanComplete; _cntStatus; lastUpdate; _driver; server; statelessTimeouts; commandsTimeout; healTimeout; updatesCheckTimeout; firmwareUpdateCheckTimeout; pollIntervals; _lockNeighborsRefresh; _lockGetSchedule; _cancelGetSchedule; nvmEvent; backoffRetry = 0; restartTimeout; driverFunctionCache = []; // Foreach valueId, we store a callback function to be called when the value changes valuesObservers = {}; _grantResolve; _dskResolve; throttledFunctions = new Map(); inclusionUserCallbacks = { grantSecurityClasses: this._onGrantSecurityClasses.bind(this), validateDSKAndEnterPIN: this._onValidateDSK.bind(this), abort: this._onAbortInclusion.bind(this), }; _inclusionState = undefined; get driverReady() { return this.driver && this._driverReady && !this.closed; } set driverReady(ready) { if (this._driverReady !== ready) { this._driverReady = ready; this.emit('driverStatus', ready); } } get cntStatus() { return this._cntStatus; } get scanComplete() { return this._scanComplete; } get error() { return this._error; } get driver() { return this._driver; } get nodes() { return this._nodes; } get devices() { return this._devices; } get maxNodeEventsQueueSize() { return this.cfg.maxNodeEventsQueueSize || 100; } get cacheSnippets() { return this.driverFunctionCache; } constructor(config, socket) { super(); this.cfg = config; this.socket = socket; this.init(); } get homeHex() { return this.driverInfo.name; } /** * Init internal vars */ init() { this.statelessTimeouts = {}; this.pollIntervals = {}; this._lockNeighborsRefresh = false; this.closed = false; this.driverReady = false; this.scenes = jsonStore.get(store.scenes); this._nodes = new Map(); this._devices = {}; this.driverInfo = {}; this.healTimeout = null; this.status = ZwaveClientStatus.CLOSED; } /** * Restart client connection * */ async restart() { await this.close(true); this.init(); await this.connect(); } backoffRestart() { // fix edge case where client is half closed and restart is called if (this.checkIfDestroyed()) { return; } const timeout = Math.min(2 ** this.backoffRetry * 1000, 15000); this.backoffRetry++; logger.info(`Restarting client in ${timeout / 1000} seconds, retry ${this.backoffRetry}`); this.restartTimeout = setTimeout(() => { this.restart().catch((error) => { logger.error(`Error while restarting driver: ${error.message}`); }); }, timeout); } /** * Checks if this client is destroyed and if so closes it * @returns True if client is destroyed */ checkIfDestroyed() { if (this.destroyed) { logger.debug(`Client listening on '${this.cfg.port}' is destroyed, closing`); this.close(true).catch((error) => { logger.error(`Error while closing driver: ${error.message}`); }); return true; } return false; } /** * Used to schedule next network rebuildNodeRoutes at hours: cfg.healHours */ // scheduleHeal() { // if (!this.cfg.healNetwork) { // return // } // const now = new Date() // let start: Date // const hour = this.cfg.healHour // if (now.getHours() < hour) { // start = new Date( // now.getFullYear(), // now.getMonth(), // now.getDate(), // hour, // 0, // 0, // 0 // ) // } else { // start = new Date( // now.getFullYear(), // now.getMonth(), // now.getDate() + 1, // hour, // 0, // 0, // 0 // ) // } // const wait = start.getTime() - now.getTime() // if (wait < 0) { // this.scheduleHeal() // } else { // this.healTimeout = setTimeout(() => { // this.rebuildNodeRoutes() // }, wait) // } // } /** * Call `fn` function at most once every `wait` milliseconds * */ throttle(key, fn, wait) { const entry = this.throttledFunctions.get(key); const now = Date.now(); // first time it's called or wait is already passed since last call if (!entry || entry.lastUpdate + wait < now) { this.throttledFunctions.set(key, { lastUpdate: now, fn, timeout: null, }); fn(); } else { // if it's called again and no timeout is set, set a timeout to call function if (!entry.timeout) { entry.timeout = setTimeout(() => { const oldEntry = this.throttledFunctions.get(key); if (oldEntry?.fn) { oldEntry.lastUpdate = Date.now(); fn(); } }, entry.lastUpdate + wait - now); } // discard the old function and store the new one entry.fn = fn; } } clearThrottle(key) { const entry = this.throttledFunctions.get(key); if (entry) { if (entry.timeout) { clearTimeout(entry.timeout); } this.throttledFunctions.delete(key); } } /** * Returns the driver ZWaveNode object */ getNode(nodeId) { return this._driver.controller.nodes.get(nodeId); } setUserCallbacks() { this.hasUserCallbacks = true; if (!this._driver || !this.cfg.serverEnabled) { return; } logger.info('Setting user callbacks'); this.driver.updateOptions({ inclusionUserCallbacks: { ...this.inclusionUserCallbacks, }, }); } removeUserCallbacks() { this.hasUserCallbacks = false; if (!this._driver || !this.cfg.serverEnabled) { return; } logger.info('Removing user callbacks'); this.driver.updateOptions({ inclusionUserCallbacks: undefined, }); // when no user is connected, give back the control to HA server if (this.server?.['sockets'] !== undefined) { this.server.setInclusionUserCallbacks(); } } /** * Returns the driver ZWaveNode ValueId object or null */ getZwaveValue(idString) { if (!idString || typeof idString !== 'string') { return null; } const parts = idString.split('-'); if (parts.length < 3) { return null; } return { commandClass: parseInt(parts[0]), endpoint: parseInt(parts[1]), property: parts[2], propertyKey: parts[3], }; } subscribeObservers(node, valueId) { const valueObserver = observedCCProps[valueId.commandClass]?.[valueId.property]; if (valueObserver) { this.valuesObservers[valueId.id] = valueObserver; valueObserver.call(this, node, valueId); } } /** * Calls driver healNetwork function and schedule next rebuildNodeRoutes * */ // rebuildNodeRoutes() { // if (this.healTimeout) { // clearTimeout(this.healTimeout) // this.healTimeout = null // } // try { // this.beginRebuildingRoutes() // logger.info('Network auto rebuildNodeRoutes started') // } catch (error) { // logger.error( // `Error while doing scheduled network rebuildNodeRoutes ${error.message}`, // error // ) // } // // schedule next // this.scheduleHeal() // } /** * Used to Update an hass device of a specific node * */ updateDevice(hassDevice, nodeId, deleteDevice = false) { const node = this._nodes.get(nodeId); // check for existing node and node hassdevice with given id if (node && hassDevice.id && node.hassDevices?.[hassDevice.id]) { if (deleteDevice) { delete node.hassDevices[hassDevice.id]; } else { const id = hassDevice.id; delete hassDevice.id; node.hassDevices[id] = hassDevice; } this.emitNodeUpdate(node, { hassDevices: node.hassDevices, }); } } /** * Used to Add a new hass device to a specific node */ addDevice(hassDevice, nodeId) { const node = this._nodes.get(nodeId); // check for existing node and node hassdevice with given id if (node && hassDevice.id) { delete hassDevice.id; const id = hassDevice.type + '_' + hassDevice.object_id; hassDevice.persistent = false; node.hassDevices[id] = hassDevice; this.emitNodeUpdate(node, { hassDevices: node.hassDevices, }); } } /** * Used to update hass devices list of a specific node and store them in `nodes.json` * */ async storeDevices(devices, nodeId, remove) { const node = this._nodes.get(nodeId); if (node) { for (const id in devices) { devices[id].persistent = !remove; } if (remove) { delete this.storeNodes[nodeId].hassDevices; } else { this.storeNodes[nodeId].hassDevices = devices; } node.hassDevices = utils.copy(devices); await this.updateStoreNodes(); this.emitNodeUpdate(node, { hassDevices: node.hassDevices, }); } } /** * Method used to close client connection, use this before destroy */ async close(keepListeners = false) { this.status = ZwaveClientStatus.CLOSED; this.closed = true; this.driverReady = false; if (this.commandsTimeout) { clearTimeout(this.commandsTimeout); this.commandsTimeout = null; } if (this.restartTimeout) { clearTimeout(this.restartTimeout); this.restartTimeout = null; } if (this.healTimeout) { clearTimeout(this.healTimeout); this.healTimeout = null; } if (this.updatesCheckTimeout) { clearTimeout(this.updatesCheckTimeout); this.updatesCheckTimeout = null; } if (this.firmwareUpdateCheckTimeout) { clearTimeout(this.firmwareUpdateCheckTimeout); this.firmwareUpdateCheckTimeout = null; } if (this.statelessTimeouts) { for (const k in this.statelessTimeouts) { clearTimeout(this.statelessTimeouts[k]); delete this.statelessTimeouts[k]; } } if (this.pollIntervals) { for (const k in this.pollIntervals) { clearTimeout(this.pollIntervals[k]); delete this.pollIntervals[k]; } } for (const [key, entry] of this.throttledFunctions) { clearTimeout(entry.timeout); this.throttledFunctions.delete(key); } if (this.server) { await this.server.destroy(); this.server = null; } if (this._driver) { await this._driver.destroy(); this._driver = null; } if (!keepListeners) { this.destroyed = true; this.removeAllListeners(); } logger.info('Client closed'); } getStatus() { const status = { driverReady: this.driverReady, status: this.driverReady && !this.closed, config: this.cfg, }; return status; } /** Used to get the general state of the client. Sent to socket on connection */ getState() { return { nodes: this.getNodes(), info: this.getInfo(), error: this.error, cntStatus: this.cntStatus, inclusionState: this._inclusionState, }; } /** * If the node supports Schedule Lock CC parses all available schedules and cache them */ async getSchedules(nodeId, opts = { fromCache: true, }) { const zwaveNode = this.getNode(nodeId); if (!zwaveNode?.commandClasses['Schedule Entry Lock'].isSupported()) { throw new Error('Schedule Entry Lock CC not supported on node ' + nodeId); } if (this._lockGetSchedule) { throw new Error('Another request is in progress, cancel it or wait...'); } const promise = async () => { this._cancelGetSchedule = false; this._lockGetSchedule = true; const { mode, fromCache } = opts; // TODO: should we check also other endpoints? const endpointIndex = 0; const endpoint = zwaveNode.getEndpoint(endpointIndex); const userCodes = UserCodeCC.getSupportedUsersCached(this.driver, endpoint); const numSlots = { numWeekDaySlots: ScheduleEntryLockCC.getNumWeekDaySlotsCached(this.driver, endpoint), numYearDaySlots: ScheduleEntryLockCC.getNumYearDaySlotsCached(this.driver, endpoint), numDailyRepeatingSlots: ScheduleEntryLockCC.getNumDailyRepeatingSlotsCached(this.driver, endpoint), }; const node = this._nodes.get(nodeId); const weeklySchedules = node.schedule?.weekly?.slots ?? []; const yearlySchedules = node.schedule?.yearly?.slots ?? []; const dailySchedules = node.schedule?.daily?.slots ?? []; node.schedule = { daily: { numSlots: numSlots.numDailyRepeatingSlots, slots: dailySchedules, }, weekly: { numSlots: numSlots.numWeekDaySlots, slots: weeklySchedules, }, yearly: { numSlots: numSlots.numYearDaySlots, slots: yearlySchedules, }, }; node.userCodes = { total: userCodes, available: [], enabled: [], }; const pushSchedule = (arr, slot, schedule, enabled) => { const index = arr.findIndex((s) => s.userId === slot.userId && s.slotId === slot.slotId); if (schedule) { const newSlot = { ...slot, ...schedule, enabled, }; if (index === -1) { arr.push(newSlot); } else { arr[index] = newSlot; } } else if (index !== -1) { arr.splice(index, 1); } }; for (let i = 1; i <= userCodes; i++) { const status = UserCodeCC.getUserIdStatusCached(this.driver, endpoint, i); if (status === undefined || status === UserIDStatus.Available || status === UserIDStatus.StatusNotAvailable) { // skip query on not enabled userIds or empty codes continue; } node.userCodes.available.push(i); const enabledUserId = ScheduleEntryLockCC.getUserCodeScheduleEnabledCached(this.driver, endpoint, i); if (enabledUserId) { node.userCodes.enabled.push(i); } const enabledType = ScheduleEntryLockCC.getUserCodeScheduleKindCached(this.driver, endpoint, i); const getCached = (kind, slotId) => ScheduleEntryLockCC.getScheduleCached(this.driver, endpoint, kind, i, slotId); if (!mode || mode === ZUIScheduleEntryLockMode.WEEKLY) { const enabled = enabledType === ScheduleEntryLockScheduleKind.WeekDay; for (let s = 1; s <= numSlots.numWeekDaySlots; s++) { if (this._cancelGetSchedule) return; const slot = { userId: i, slotId: s, }; const schedule = fromCache ? (getCached(ScheduleEntryLockScheduleKind.WeekDay, s)) : await zwaveNode.commandClasses['Schedule Entry Lock'].getWeekDaySchedule(slot); pushSchedule(weeklySchedules, slot, schedule, enabled); } } if (!mode || mode === ZUIScheduleEntryLockMode.YEARLY) { const enabled = enabledType === ScheduleEntryLockScheduleKind.YearDay; for (let s = 1; s <= numSlots.numYearDaySlots; s++) { if (this._cancelGetSchedule) return; const slot = { userId: i, slotId: s, }; const schedule = fromCache ? (getCached(ScheduleEntryLockScheduleKind.YearDay, s)) : await zwaveNode.commandClasses['Schedule Entry Lock'].getYearDaySchedule(slot); pushSchedule(yearlySchedules, slot, schedule, enabled); } } if (!mode || mode === ZUIScheduleEntryLockMode.DAILY) { const enabled = enabledType === ScheduleEntryLockScheduleKind.DailyRepeating; for (let s = 1; s <= numSlots.numDailyRepeatingSlots; s++) { if (this._cancelGetSchedule) return; const slot = { userId: i, slotId: s, }; const schedule = fromCache ? (getCached(ScheduleEntryLockScheduleKind.DailyRepeating, s)) : await zwaveNode.commandClasses['Schedule Entry Lock'].getDailyRepeatingSchedule(slot); pushSchedule(dailySchedules, slot, schedule, enabled); } } } this.emitNodeUpdate(node, { schedule: node.schedule, userCodes: node.userCodes, }); return node.schedule; }; return promise().finally(() => { this._lockGetSchedule = false; this._cancelGetSchedule = false; }); } cancelGetSchedule() { this._cancelGetSchedule = true; } async setSchedule(nodeId, type, schedule) { const zwaveNode = this.getNode(nodeId); if (!zwaveNode?.commandClasses['Schedule Entry Lock'].isSupported()) { throw new Error('Schedule Entry Lock CC not supported on node ' + nodeId); } const slot = { userId: schedule.userId, slotId: schedule.slotId, }; delete schedule.userId; delete schedule.slotId; delete schedule['enabled']; const isDelete = Object.keys(schedule).length === 0; if (isDelete) { schedule = undefined; } let result; if (type === 'daily') { result = await zwaveNode.commandClasses['Schedule Entry Lock'].setDailyRepeatingSchedule(slot, schedule); } else if (type === 'weekly') { result = await zwaveNode.commandClasses['Schedule Entry Lock'].setWeekDaySchedule(slot, schedule); } else if (type === 'yearly') { result = await zwaveNode.commandClasses['Schedule Entry Lock'].setYearDaySchedule(slot, schedule); } else { throw new Error('Invalid schedule type'); } // means that is not using supervision, read slot and check if it matches if (!result) { const methods = { daily: 'getDailyRepeatingSchedule', weekly: 'getWeekDaySchedule', yearly: 'getYearDaySchedule', }; const res = await zwaveNode.commandClasses['Schedule Entry Lock'][methods[type]](slot); if ((isDelete && !res) || (!isDelete && res && utils.deepEqual(res, schedule))) { result = { status: SupervisionStatus.Success, }; } else { result = { status: SupervisionStatus.Fail, }; } } if (result.status === SupervisionStatus.Success) { const node = this._nodes.get(nodeId); // update enabled state for (const mode in node.schedule) { node.schedule[mode].slots = node.schedule[mode].slots.map((s) => ({ ...s, enabled: mode === type, })); } const slots = node.schedule?.[type]?.slots; if (slots) { const slotIndex = slots.findIndex((s) => s.userId === slot.userId && s.slotId === slot.slotId); const newSlot = isDelete ? null : { ...slot, ...schedule, enabled: true, }; if (isDelete) { if (slotIndex !== -1) { slots.splice(slotIndex, 1); } } else if (slotIndex !== -1) { slots[slotIndex] = newSlot; } else { slots.push(newSlot); } const isEnabledUsercode = node.userCodes?.enabled?.includes(slot.userId); if (!isDelete && !isEnabledUsercode) { node.userCodes.enabled.push(slot.userId); } else if (isDelete && isEnabledUsercode) { const index = node.userCodes.enabled.indexOf(slot.userId); if (index >= 0) { node.userCodes.enabled.splice(index, 1); } } this.emitNodeUpdate(node, { schedule: node.schedule, userCodes: node.userCodes, }); } } return result; } async setEnabledSchedule(nodeId, enabled, userId) { const zwaveNode = this.getNode(nodeId); if (!zwaveNode) { throw new Error('Node not found'); } const result = await zwaveNode.commandClasses['Schedule Entry Lock'].setEnabled(enabled, userId); // if result is not defined here we don't have a way // to know if the command was successful or not as there is no // 'get' command for this, so we just assume it was successful if (isUnsupervisedOrSucceeded(result)) { const node = this._nodes.get(nodeId); if (node) { if (userId) { if (enabled) { node.userCodes?.enabled.push(userId); } else { const index = node.userCodes?.enabled.indexOf(userId); if (index >= 0) { node.userCodes.enabled.splice(index, 1); } } } else { node.userCodes.enabled = enabled ? node.userCodes.available.slice() : []; } this.emitNodeUpdate(node, { userCodes: node.userCodes, }); } } return result; } /** * Populate node `groups` */ getGroups(nodeId, ignoreUpdate = false) { const zwaveNode = this.getNode(nodeId); const node = this._nodes.get(nodeId); if (node && zwaveNode) { let endpointGroups = new Map(); try { endpointGroups = this._driver.controller.getAllAssociationGroups(nodeId); } catch (error) { this.logNode(zwaveNode, 'warn', `Error while fetching groups associations: ${error.message}`); } node.groups = []; for (const [endpoint, groups] of endpointGroups) { for (const [groupIndex, group] of groups) { // https://zwave-js.github.io/node-zwave-js/#/api/controller?id=associationgroup-interface node.groups.push({ title: group.label, endpoint: endpoint, value: groupIndex, maxNodes: group.maxNodes, isLifeline: group.isLifeline, multiChannel: group.multiChannel, }); } } } if (node && !ignoreUpdate) { this.emitNodeUpdate(node, { groups: node.groups }); } } /** * Get an array of current [associations](https://zwave-js.github.io/node-zwave-js/#/api/controller?id=association-interface) of a specific group */ async getAssociations(nodeId, refresh = false) { const zwaveNode = this.getNode(nodeId); const toReturn = []; if (zwaveNode) { try { if (refresh) { await zwaveNode.refreshCCValues(CommandClasses.Association); await zwaveNode.refreshCCValues(CommandClasses['Multi Channel Association']); } // https://zwave-js.github.io/node-zwave-js/#/api/controller?id=association-interface // the result is a map where the key is the group number and the value is the array of associations {nodeId, endpoint?} const result = this._driver.controller.getAllAssociations(nodeId); for (const [source, group] of result.entries()) { for (const [groupId, associations] of group) { for (const a of associations) { toReturn.push({ endpoint: source.endpoint, groupId: groupId, nodeId: a.nodeId, targetEndpoint: a.endpoint, }); } } } } catch (error) { this.logNode(zwaveNode, 'warn', `Error while fetching groups associations: ${error.message}`); // node doesn't support groups associations } } else { this.logNode(zwaveNode, 'warn', `Error while fetching groups associations, node not found`); } return toReturn; } /** * Check if a given association is allowed */ checkAssociation(source, groupId, association) { return this.driver.controller.checkAssociation(source, groupId, association); } /** * Add a node to the array of specified [associations](https://zwave-js.github.io/node-zwave-js/#/api/controller?id=association-interface) */ async addAssociations(source, groupId, associations) { const zwaveNode = this.getNode(source.nodeId); const sourceMsg = `Node ${source.nodeId + (source.endpoint ? ' Endpoint ' + source.endpoint : '')}`; if (!zwaveNode) { throw new Error(`Node ${source.nodeId} not found`); } const result = []; for (const a of associations) { const checkResult = this._driver.controller.checkAssociation(source, groupId, a); result.push(checkResult); if (checkResult === AssociationCheckResult.OK) { this.logNode(zwaveNode, 'info', `Adding Node ${a.nodeId} to Group ${groupId} of ${sourceMsg}`); await this._driver.controller.addAssociations(source, groupId, [ a, ]); } else { this.logNode(zwaveNode, 'warn', `Unable to add Node ${a.nodeId} to Group ${groupId} of ${sourceMsg}: ${getEnumMemberName(AssociationCheckResult, checkResult)}`); } } return result; } /** * Remove a node from an association group * */ async removeAssociations(source, groupId, associations) { const zwaveNode = this.getNode(source.nodeId); const sourceMsg = `Node ${source.nodeId + (source.endpoint ? ' Endpoint ' + source.endpoint : '')}`; if (zwaveNode) { try { this.logNode(zwaveNode, 'info', `Removing associations from ${sourceMsg} Group ${groupId}: %o`, associations); await this._driver.controller.removeAssociations(source, groupId, associations); } catch (error) { this.logNode(zwaveNode, 'warn', `Error while removing associations from ${sourceMsg}: ${error.message}`); } } else { this.logNode(zwaveNode, 'warn', `Error while removing associations from ${sourceMsg}, node not found`); } } /** * Remove all associations */ async removeAllAssociations(nodeId) { const zwaveNode = this.getNode(nodeId); if (zwaveNode) { try { const allAssociations = this._driver.controller.getAllAssociations(nodeId); for (const [source, groupAssociations,] of allAssociations.entries()) { for (const [groupId, associations] of groupAssociations) { if (associations.length > 0) { await this._driver.controller.removeAssociations(source, groupId, associations); this.logNode(zwaveNode, 'info', `Removed ${associations.length} associations from Node ${source.nodeId + (source.endpoint ? ' Endpoint ' + source.endpoint : '')} group ${groupId}`); } } } } catch (error) { this.logNode(zwaveNode, 'warn', `Error while removing all associations from ${nodeId}: ${error.message}`); } } else { this.logNode(zwaveNode, 'warn', `Node not found when calling 'removeAllAssociations'`); } } /** * Setting the date and time on a node could be hard, this helper method will set it using the date provided (default to now). * * The following CCs will be used (when supported or necessary) in this process: * - Time Parameters CC * - Clock CC * - Time CC * - Schedule Entry Lock CC (for setting the timezone) */ syncNodeDateAndTime(nodeId, date = new Date()) { const zwaveNode = this.getNode(nodeId); if (zwaveNode) { this.logNode(zwaveNode, 'info', `Syncing Node ${nodeId} date and time`); return zwaveNode.setDateAndTime(date); } else { this.logNode(zwaveNode, 'warn', `Node not found when calling 'syncNodeDateAndTime'`); } } manuallyIdleNotificationValue(valueId) { const zwaveNode = this.getNode(valueId.nodeId); if (zwaveNode) { zwaveNode.manuallyIdleNotificationValue(valueId); } else { this.logNode(zwaveNode, 'warn', `Node not found when calling 'manuallyIdleNotificationValue'`); } } /** * Remove node from all associations */ async removeNodeFromAllAssociations(nodeId) { const zwaveNode = this.getNode(nodeId); if (zwaveNode) { try { this.logNode(zwaveNode, 'info', `Removing Node ${nodeId} from all associations`); await this._driver.controller.removeNodeFromAllAssociations(nodeId); } catch (error) { this.logNode(zwaveNode, 'warn', `Error while removing Node ${nodeId} from all associations: ${error.message}`); } } else { this.logNode(zwaveNode, 'warn', `Node not found when calling 'removeNodeFromAllAssociations'`); } } /** * Refresh all nodes neighbors */ async refreshNeighbors() { if (this._lockNeighborsRefresh === true) { throw Error('you can refresh neighbors only once every 60 seconds'); } this._lockNeighborsRefresh = true; // set the timeout here so if something fails later we don't keep the lock setTimeout(() => (this._lockNeighborsRefresh = false), NEIGHBORS_LOCK_REFRESH); const toReturn = {}; // when accessing the controller memory, the Z-Wave radio must be turned off with to avoid resource conflicts and inconsistent data await this._driver.controller.toggleRF(false); for (const [nodeId, node] of this._nodes) { await this.getNodeNeighbors(nodeId, true, false); toReturn[nodeId] = node.neighbors; } // turn rf back to on await this._driver.controller.toggleRF(true); return toReturn; } /** * Get neighbors of a specific node */ async getNodeNeighbors(nodeId, preventThrow = false, emitNodeUpdate = true) { try { if (!this.driverReady) { throw new DriverNotReadyError(); } const zwaveNode = this.getNode(nodeId); if (zwaveNode.protocol === Protocols.ZWaveLongRange) { return []; } const neighbors = await this._driver.controller.getNodeNeighbors(nodeId); this.logNode(nodeId, 'debug', `Neighbors: ${neighbors.join(', ')}`); const node = this.nodes.get(nodeId); if (node) { node.neighbors = [...neighbors]; if (emitNodeUpdate) { this.emitNodeUpdate(node, { neighbors: node.neighbors, }); } } return neighbors; } catch (error) { this.logNode(nodeId, 'warn', `Error while getting neighbors from ${nodeId}: ${error.message}`); if (!preventThrow) { throw error; } return Promise.resolve([]); } } /** * Instructs a node to (re-)discover its neighbors. */ async discoverNodeNeighbors(nodeId) { if (!this.driverReady) { throw new DriverNotReadyError(); } const result = await this._driver.controller.discoverNodeNeighbors(nodeId); if (result) { // update neighbors this.getNodeNeighbors(nodeId, true).catch(() => { // noop }); } return result; } /** * Execute a driver function. * More info [here](/usage/driver_function?id=driver-function) */ driverFunction(code) { if (!this.driverReady) { throw new DriverNotReadyError(); } if (!this.driverFunctionCache.find((c) => c.content === code)) { const name = `CACHED_${this.driverFunctionCache.length}`; this.driverFunctionCache.push({ name, content: code }); } const AsyncFunction = Object.getPrototypeOf(async function () { }).constructor; const fn = new AsyncFunction('driver', code); const require = createRequire(import.meta.url); return fn.call({ zwaveClient: this, require, logger }, this._driver); } /** * Method used to start Z-Wave connection using configuration `port` */ async connect() { if (this.cfg.enabled === false) { logger.info('Z-Wave driver DISABLED'); return; } if (this.driverReady) { logger.info(`Driver already connected to ${this.cfg.port}`); return; } // this could happen when the driver fails the connect and a reconnect timeout triggers if (this.closed || this.checkIfDestroyed()) { return; } if (!this.cfg?.port) { logger.warn('Z-Wave driver not inited, no port configured'); return; } let shouldUpdateSettings = false; // extend options with hidden `options` const zwaveOptions = { bootloaderMode: this.cfg.allowBootloaderOnly ? 'allow' : 'recover', storage: { cacheDir: storeDir, deviceConfigPriorityDir: this.cfg.deviceConfigPriorityDir || deviceConfigPriorityDir, }, // https://zwave-js.github.io/node-zwave-js/#/api/driver?id=logconfig logConfig: utils.buildLogConfig(this.cfg), emitValueUpdateAfterSetValue: true, apiKeys: { firmwareUpdateService: '421e29797c3c2926f84efc737352d6190354b3b526a6dce6633674dd33a8a4f964c794f5', }, timeouts: { report: this.cfg.higherReportsTimeout ? 10000 : undefined, sendToSleep: this.cfg.sendToSleepTimeout, response: this.cfg.responseTimeout, }, features: { unresponsiveControllerRecovery: this.cfg .disableControllerRecovery ? false : true, watchdog: this.cfg.disableWatchdog ? false : true, }, userAgent: { [utils.pkgJson.name]: utils.pkgJson.version, }, disableOptimisticValueUpdate: this.cfg.disableOptimisticValueUpdate, }; // when no env is specified copy config db to store dir // fixes issues with pkg (and no more need to set this env on docker) if (!process.env.ZWAVEJS_EXTERNAL_CONFIG) { zwaveOptions.storage.deviceConfigExternalDir = configDbDir; } if (this.cfg.rf) { const { region, txPower, maxLongRangePowerlevel } = this.cfg.rf; let { autoPowerlevels } = this.cfg.rf; zwaveOptions.rf = {}; if (typeof region === 'number') { zwaveOptions.rf.region = region; } if (autoPowerlevels === undefined && typeof maxLongRangePowerlevel !== 'number' && typeof txPower?.powerlevel !== 'number') { // if autoPowerlevels is undefined and maxLongRangePowerlevel is not a number (likely '' or undefined), assume autoPowerlevels is true autoPowerlevels = true; this.cfg.rf.autoPowerlevels = true; shouldUpdateSettings = true; } if (autoPowerlevels) { zwaveOptions.rf.maxLongRangePowerlevel = 'auto'; zwaveOptions.rf.txPower ??= {}; zwaveOptions.rf.txPower.powerlevel = 'auto'; } if (!autoPowerlevels && (maxLongRangePowerlevel === 'auto' || typeof maxLongRangePowerlevel === 'number')) { zwaveOptions.rf.maxLongRangePowerlevel = maxLongRangePowerlevel; } if (txPower) { if (!autoPowerlevels && (txPower.powerlevel === 'auto' || typeof txPower.powerlevel === 'number')) { zwaveOptions.rf.txPower ??= {}; zwaveOptions.rf.txPower.powerlevel = txPower.powerlevel; } if (typeof txPower.measured0dBm === 'number') { zwaveOptions.rf.txPower ??= {}; zwaveOptions.rf.txPower.measured0dBm = txPower.measured0dBm; } } } // @ts-expect-error this is defined when running in a pkg bundle if (process.pkg) { // Ensure Z-Wave JS is looking for the configuration files in the right place // when running inside a pkg bundle zwaveOptions.host ??= {}; zwaveOptions.host.fs = new PkgFsBindings(); } // ensure deviceConfigPriorityDir exists to prevent warnings #2374 // lgtm [js/path-injection] await utils.ensureDir(zwaveOptions.storage.deviceConfigPriorityDir); // when not set let zwavejs handle this based on the environment if (typeof this.cfg.enableSoftReset === 'boolean') { zwaveOptions.features.softReset = this.cfg.enableSoftReset; } // when server is not enabled, disable the user callbacks set/remove // so it can be used through MQTT if (!this.cfg.serverEnabled) { zwave