diff --git a/api/app.ts b/api/app.ts index 806d510a91..eb56c53c12 100644 --- a/api/app.ts +++ b/api/app.ts @@ -48,6 +48,7 @@ import * as utils from './lib/utils' import backupManager from './lib/BackupManager' import { readFile, realpath } from 'fs/promises' import { generate } from 'selfsigned' +import ZnifferManager, { ZnifferConfig } from './lib/ZnifferManager' const createCertificate = promisify(generate) @@ -158,6 +159,7 @@ socketManager.authMiddleware = function ( } let gw: Gateway // the gateway instance +let zniffer: ZnifferManager // the zniffer instance const plugins: CustomPlugin[] = [] let pluginsRouter: Router @@ -257,6 +259,7 @@ export async function startServer(port: number | string, host?: string) { setupInterceptor() await loadSnippets() await loadManager() + startZniffer(settings.zniffer) await startGateway(settings) } @@ -424,6 +427,12 @@ async function startGateway(settings: Settings) { restarting = false } +function startZniffer(settings: ZnifferConfig) { + if (settings) { + zniffer = new ZnifferManager(settings, socketManager.io) + } +} + async function destroyPlugins() { while (plugins.length > 0) { const instance = plugins.pop() @@ -619,10 +628,17 @@ function setupSocket(server: HttpServer) { // Server: https://socket.io/docs/v4/server-application-structure/#all-event-handlers-are-registered-in-the-indexjs-file // Client: https://socket.io/docs/v4/client-api/#socketemiteventname-args socket.on(inboundEvents.init, (data, cb = noop) => { + let state = {} as any + if (gw.zwave) { - const state = gw.zwave.getState() - cb(state) + state = gw.zwave.getState() } + + if (zniffer) { + state.zniffer = zniffer.status() + } + + cb(state) }) socket.on( @@ -730,6 +746,49 @@ function setupSocket(server: HttpServer) { cb(result) }) + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + socket.on(inboundEvents.zniffer, async (data, cb = noop) => { + logger.info(`Zniffer api call: ${data.api}`) + + let res: any, err: string + try { + switch (data.apiName) { + case 'start': + res = await zniffer.start() + break + case 'stop': + res = await zniffer.stop() + break + case 'clear': + res = zniffer.clear() + break + case 'getFrames': + res = zniffer.getFrames() + break + case 'setFrequency': + res = await zniffer.setFrequency(data.frequency) + break + case 'saveCaptureToFile': + res = await zniffer.saveCaptureToFile() + break + default: + throw new Error(`Unknown ZNIFFER api ${data.apiName}`) + } + } catch (error) { + logger.error('Error while calling ZNIFFER api', error) + err = error.message + } + + const result = { + success: !err, + message: err || 'Success ZNIFFER api call', + result: res, + api: data.apiName, + } + + cb(result) + }) }) // emitted every time a new client connects/disconnects @@ -1074,15 +1133,50 @@ app.post( } // TODO: validate settings using calss-validator const settings = req.body + + const actualSettings = jsonStore.get(store.settings) as Settings + + const shouldRestartGw = !utils.deepEqual( + { + zwave: actualSettings.zwave, + gateway: actualSettings.gateway, + mqtt: actualSettings.mqtt, + }, + { + zwave: settings.zwave, + gateway: settings.gateway, + mqtt: settings.mqtt, + }, + ) + + const shouldRestartZniffer = !utils.deepEqual( + actualSettings.zniffer, + settings.zniffer, + ) + + // nothing changed, consider it a forced restart + const restartAll = !shouldRestartGw && !shouldRestartZniffer + restarting = true await jsonStore.put(store.settings, settings) - await gw.close() - await destroyPlugins() - // reload loggers settings - setupLogging(settings) - // restart clients and gateway - await startGateway(settings) - backupManager.init(gw.zwave) + + if (restartAll || shouldRestartGw) { + await gw.close() + + await destroyPlugins() + // reload loggers settings + setupLogging(settings) + // restart clients and gateway + await startGateway(settings) + backupManager.init(gw.zwave) + } + + if (restartAll || shouldRestartZniffer) { + if (zniffer) { + await zniffer.close() + } + startZniffer(settings.zniffer) + } res.json({ success: true, diff --git a/api/config/store.ts b/api/config/store.ts index eb6ae2492d..f27ce34f65 100644 --- a/api/config/store.ts +++ b/api/config/store.ts @@ -2,6 +2,7 @@ import { GatewayConfig } from '../lib/Gateway' import { MqttConfig } from '../lib/MqttClient' +import { ZnifferConfig } from '../lib/ZnifferManager' import { ZwaveConfig, deviceConfigPriorityDir } from '../lib/ZwaveClient' export type StoreKeys = 'settings' | 'scenes' | 'nodes' | 'users' @@ -21,6 +22,7 @@ export interface Settings { mqtt?: MqttConfig zwave?: ZwaveConfig gateway?: GatewayConfig + zniffer?: ZnifferConfig } const store: Record = { diff --git a/api/lib/SocketEvents.ts b/api/lib/SocketEvents.ts index ee4d183628..e74dc67e63 100644 --- a/api/lib/SocketEvents.ts +++ b/api/lib/SocketEvents.ts @@ -19,6 +19,8 @@ export enum socketEvents { grantSecurityClasses = 'GRANT_SECURITY_CLASSES', validateDSK = 'VALIDATE_DSK', inclusionAborted = 'INCLUSION_ABORTED', + znifferFrame = 'ZNIFFER_FRAME', + znifferState = 'ZNIFFER_STATE', } // events from client ---> server @@ -27,4 +29,5 @@ export enum inboundEvents { zwave = 'ZWAVE_API', // call a zwave api hass = 'HASS_API', // call an hass api mqtt = 'MQTT_API', // call an mqtt api + zniffer = 'ZNIFFER_API', // call a zniffer api } diff --git a/api/lib/SocketManager.ts b/api/lib/SocketManager.ts index 069bafb5aa..e768b40ccf 100644 --- a/api/lib/SocketManager.ts +++ b/api/lib/SocketManager.ts @@ -13,6 +13,7 @@ export interface SocketManagerEventCallbacks { [inboundEvents.zwave]: (socket: Socket, data: any) => void [inboundEvents.hass]: (socket: Socket, data: any) => void [inboundEvents.mqtt]: (socket: Socket, data: any) => void + [inboundEvents.zniffer]: (socket: Socket, data: any) => void clients: ( event: 'connection' | 'disconnect', sockets: Map, diff --git a/api/lib/ZnifferManager.ts b/api/lib/ZnifferManager.ts new file mode 100644 index 0000000000..daacce822e --- /dev/null +++ b/api/lib/ZnifferManager.ts @@ -0,0 +1,314 @@ +import { + CommandClass, + CorruptedFrame, + Frame, + isEncapsulatingCommandClass, + isMultiEncapsulatingCommandClass, + Zniffer, + ZnifferOptions, +} from 'zwave-js' +import { TypedEventEmitter } from './EventEmitter' +import { module } from './logger' +import { Server as SocketServer } from 'socket.io' +import { socketEvents } from './SocketEvents' +import { ZwaveConfig } from './ZwaveClient' +import { logsDir, storeDir } from '../config/app' +import { buffer2hex, joinPath, parseSecurityKeys } from './utils' +import { isDocker } from '@zwave-js/shared' +import { basename } from 'path' + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const loglevels = require('triple-beam').configs.npm.levels + +export type ZnifferConfig = Pick< + ZwaveConfig, + | 'securityKeys' + | 'securityKeysLongRange' + | 'maxFiles' + | 'logEnabled' + | 'logToFile' + | 'logLevel' + | 'nodeFilter' +> & { + port: string + enabled: boolean + convertRSSI?: boolean + defaultFrequency?: number +} + +export interface ZnifferManagerEventCallbacks {} + +const logger = module('ZnifferManager') + +const ZNIFFER_LOG_FILE = joinPath(logsDir, 'zniffer_%DATE%.log') +const ZNIFFER_CAPTURE_FILE = joinPath(storeDir, 'zniffer_capture_%DATE%.zlf') + +export type SocketFrame = (Frame | CorruptedFrame) & { + payload: string + parsedPayload?: Record + corrupted: boolean + timestamp: number + raw: string +} + +export interface FrameCCLogEntry { + tags: string[] + message?: { + encapsulated?: FrameCCLogEntry[] + [key: string]: string | number | boolean | FrameCCLogEntry[] + } +} + +export default class ZnifferManager extends TypedEventEmitter { + private zniffer: Zniffer + + private config: ZnifferConfig + + private socket: SocketServer + + private error: string + + private restartTimeout: NodeJS.Timeout + + get started() { + return this.zniffer.active + } + + constructor(config: ZnifferConfig, socket: SocketServer) { + super() + + this.config = config + this.socket = socket + + if (!config.enabled) { + logger.info('Zniffer is DISABLED') + return + } + + const znifferOptions: ZnifferOptions = { + convertRSSI: config.convertRSSI, + defaultFrequency: config.defaultFrequency, + logConfig: { + enabled: config.logEnabled, + level: config.logLevel ? loglevels[config.logLevel] : 'info', + logToFile: config.logToFile, + filename: ZNIFFER_LOG_FILE, + forceConsole: isDocker() ? !config.logToFile : false, + maxFiles: config.maxFiles || 7, + nodeFilter: + config.nodeFilter && config.nodeFilter.length > 0 + ? config.nodeFilter.map((n) => parseInt(n)) + : undefined, + }, + } + + parseSecurityKeys(config, znifferOptions) + + this.zniffer = new Zniffer(config.port, znifferOptions) + + logger.info('Initing Zniffer...') + this.init().catch(() => {}) + } + + private async init() { + try { + await this.zniffer.init() + + this.zniffer.on('frame', (frame, rawData) => { + const socketFrame = this.parseFrame(frame, rawData) + + this.socket.emit(socketEvents.znifferFrame, socketFrame) + }) + + this.zniffer.on('corrupted frame', (frame, rawData) => { + const socketFrame = this.parseFrame(frame, rawData) + + this.socket.emit(socketEvents.znifferFrame, socketFrame) + }) + + this.zniffer.on('error', (error) => { + this.onError(error) + }) + + this.zniffer.on('ready', () => { + logger.info('Zniffer ready') + }) + } catch (error) { + this.onError(error) + + logger.info('Retrying in 5s...') + + await this.close() + + this.restartTimeout = setTimeout(() => { + this.init().catch(() => {}) + }, 5000) + } + + this.onStateChange() + } + + private parseFrame( + frame: Frame | CorruptedFrame, + rawData: Buffer, + timestamp = Date.now(), + ): SocketFrame { + const socketFrame: SocketFrame = { + ...frame, + corrupted: !('protocol' in frame), + payload: '' as any, + timestamp, + raw: buffer2hex(rawData), + } + + if ('payload' in frame) { + if (frame.payload instanceof CommandClass) { + socketFrame.parsedPayload = this.ccToLogRecord(frame.payload) + } else { + socketFrame.payload = buffer2hex(frame.payload) + } + } + + return socketFrame + } + + private onError(error: Error) { + logger.error('Zniffer error:', error) + this.error = error.message + this.onStateChange() + } + + private onStateChange() { + this.socket.emit(socketEvents.znifferState, this.status()) + } + + private checkReady() { + if (!this.config.enabled || !this.zniffer) { + throw new Error('Zniffer is not initialized') + } + } + + public status() { + return { + error: this.error, + started: this.started, + frequency: this.zniffer?.currentFrequency, + } + } + + public getFrames() { + this.checkReady() + + return this.zniffer.capturedFrames.map((frame) => { + return this.parseFrame( + frame.parsedFrame, + frame.frameData, + frame.timestamp.getTime(), + ) + }) + } + + public async setFrequency(frequency: number) { + this.checkReady() + + logger.info(`Setting Zniffer frequency to ${frequency}`) + await this.zniffer.setFrequency(frequency) + + this.onStateChange() + + logger.info(`Zniffer frequency set to ${frequency}`) + } + + private ccToLogRecord(commandClass: CommandClass): Record { + try { + const parsed: Record = commandClass.toLogEntry( + this.zniffer as any, + ) + + if (isEncapsulatingCommandClass(commandClass)) { + parsed.encapsulated = [ + this.ccToLogRecord(commandClass.encapsulated), + ] + } else if (isMultiEncapsulatingCommandClass(commandClass)) { + parsed.encapsulated = [ + commandClass.encapsulated.map((cc) => + this.ccToLogRecord(cc), + ), + ] + } + return parsed + } catch (error) { + logger.error('Error parsing command class:', error) + return { + error: error.message, + } + } + } + + public async close() { + if (this.restartTimeout) clearTimeout(this.restartTimeout) + + if (this.zniffer) { + this.zniffer.removeAllListeners() + await this.stop() + await this.zniffer.destroy() + } + } + + public async start() { + this.checkReady() + + if (this.started) { + logger.info('Zniffer already started') + return + } + + logger.info('Starting...') + await this.zniffer.start() + + this.onStateChange() + + logger.info('Started') + } + + public async stop() { + this.checkReady() + + if (!this.started) { + logger.info('Zniffer is already stopped') + return + } + + logger.info('Stopping...') + await this.zniffer.stop() + + this.onStateChange() + + logger.info('Stopped') + } + + public clear() { + this.checkReady() + + logger.info('Clearing...') + this.zniffer.clearCapturedFrames() + + logger.info('Frames cleared') + } + + public async saveCaptureToFile() { + this.checkReady() + + const filePath = ZNIFFER_CAPTURE_FILE.replace( + '%DATE%', + new Date().toISOString(), + ) + logger.info(`Saving capture to ${filePath}`) + await this.zniffer.saveCaptureToFile(filePath) + logger.info('Capture saved') + return { + path: filePath, + name: basename(filePath), + } + } +} diff --git a/api/lib/ZwaveClient.ts b/api/lib/ZwaveClient.ts index ff731d7b19..40eaa0d4b2 100644 --- a/api/lib/ZwaveClient.ts +++ b/api/lib/ZwaveClient.ts @@ -569,6 +569,7 @@ export type NodeEvent = { } export type ZwaveConfig = { + enabled?: boolean allowBootloaderOnly?: boolean port?: string networkKey?: string @@ -2122,268 +2123,218 @@ class ZwaveClient extends TypedEventEmitter { * Method used to start Z-Wave connection using configuration `port` */ async connect() { - if (!this.driverReady) { - // 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 - } - - // extend options with hidden `options` - const zwaveOptions: PartialZWaveOptions = { - allowBootloaderOnly: this.cfg.allowBootloaderOnly || false, - storage: { - cacheDir: storeDir, - deviceConfigPriorityDir: - this.cfg.deviceConfigPriorityDir || - deviceConfigPriorityDir, - }, - logConfig: { - // https://zwave-js.github.io/node-zwave-js/#/api/driver?id=logconfig - enabled: this.cfg.logEnabled, - level: this.cfg.logLevel - ? loglevels[this.cfg.logLevel] - : 'info', - logToFile: this.cfg.logToFile, - filename: ZWAVEJS_LOG_FILE, - forceConsole: isDocker() ? !this.cfg.logToFile : false, - maxFiles: this.cfg.maxFiles || 7, - nodeFilter: - this.cfg.nodeFilter && this.cfg.nodeFilter.length > 0 - ? this.cfg.nodeFilter.map((n) => parseInt(n)) - : undefined, - }, - 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, - }, - userAgent: { - [utils.pkgJson.name]: utils.pkgJson.version, - }, - } + if (this.cfg.enabled === false) { + logger.info('Z-Wave driver DISABLED') + return + } - if (this.cfg.rf) { - const { region, txPower } = this.cfg.rf + if (this.driverReady) { + logger.info(`Driver already connected to ${this.cfg.port}`) + return + } - zwaveOptions.rf = {} + // this could happen when the driver fails the connect and a reconnect timeout triggers + if (this.closed || this.checkIfDestroyed()) { + return + } - if (region) { - zwaveOptions.rf.region = region - } + if (!this.cfg?.port) { + logger.warn('Z-Wave driver not inited, no port configured') + return + } - if ( - txPower && - typeof txPower.measured0dBm === 'number' && - typeof txPower.powerlevel === 'number' - ) { - zwaveOptions.rf.txPower = txPower - } - } + // extend options with hidden `options` + const zwaveOptions: PartialZWaveOptions = { + allowBootloaderOnly: this.cfg.allowBootloaderOnly || false, + storage: { + cacheDir: storeDir, + deviceConfigPriorityDir: + this.cfg.deviceConfigPriorityDir || deviceConfigPriorityDir, + }, + logConfig: { + // https://zwave-js.github.io/node-zwave-js/#/api/driver?id=logconfig + enabled: this.cfg.logEnabled, + level: this.cfg.logLevel + ? loglevels[this.cfg.logLevel] + : 'info', + logToFile: this.cfg.logToFile, + filename: ZWAVEJS_LOG_FILE, + forceConsole: isDocker() ? !this.cfg.logToFile : false, + maxFiles: this.cfg.maxFiles || 7, + nodeFilter: + this.cfg.nodeFilter && this.cfg.nodeFilter.length > 0 + ? this.cfg.nodeFilter.map((n) => parseInt(n)) + : undefined, + }, + 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, + }, + userAgent: { + [utils.pkgJson.name]: utils.pkgJson.version, + }, + } - // ensure deviceConfigPriorityDir exists to prevent warnings #2374 - // lgtm [js/path-injection] - await ensureDir(zwaveOptions.storage.deviceConfigPriorityDir) + if (this.cfg.rf) { + const { region, txPower } = this.cfg.rf - // when not set let zwavejs handle this based on the environment - if (typeof this.cfg.enableSoftReset === 'boolean') { - zwaveOptions.features.softReset = this.cfg.enableSoftReset - } + zwaveOptions.rf = {} - // when server is not enabled, disable the user callbacks set/remove - // so it can be used through MQTT - if (!this.cfg.serverEnabled) { - zwaveOptions.inclusionUserCallbacks = { - ...this.inclusionUserCallbacks, - } + if (region) { + zwaveOptions.rf.region = region } - if (this.cfg.scales) { - const scales: Record = {} - for (const s of this.cfg.scales) { - scales[s.key] = s.label - } - - zwaveOptions.preferences = { - scales, - } + if ( + txPower && + typeof txPower.measured0dBm === 'number' && + typeof txPower.powerlevel === 'number' + ) { + zwaveOptions.rf.txPower = txPower } + } - Object.assign(zwaveOptions, this.cfg.options) + // ensure deviceConfigPriorityDir exists to prevent warnings #2374 + // lgtm [js/path-injection] + await ensureDir(zwaveOptions.storage.deviceConfigPriorityDir) - let s0Key: string + // when not set let zwavejs handle this based on the environment + if (typeof this.cfg.enableSoftReset === 'boolean') { + zwaveOptions.features.softReset = this.cfg.enableSoftReset + } - // back compatibility - if (this.cfg.networkKey) { - s0Key = this.cfg.networkKey - delete this.cfg.networkKey + // when server is not enabled, disable the user callbacks set/remove + // so it can be used through MQTT + if (!this.cfg.serverEnabled) { + zwaveOptions.inclusionUserCallbacks = { + ...this.inclusionUserCallbacks, } + } - this.cfg.securityKeys = this.cfg.securityKeys || {} - - if (s0Key && !this.cfg.securityKeys.S0_Legacy) { - this.cfg.securityKeys.S0_Legacy = s0Key - const settings = jsonStore.get(store.settings) - settings.zwave = this.cfg - await jsonStore.put(store.settings, settings) - } else if (process.env.NETWORK_KEY) { - this.cfg.securityKeys.S0_Legacy = process.env.NETWORK_KEY + if (this.cfg.scales) { + const scales: Record = {} + for (const s of this.cfg.scales) { + scales[s.key] = s.label } - const availableKeys = [ - 'S2_Unauthenticated', - 'S2_Authenticated', - 'S2_AccessControl', - 'S0_Legacy', - ] - - const envKeys = Object.keys(process.env) - .filter((k) => k?.startsWith('KEY_')) - .map((k) => k.substring(4)) - - // load security keys from env - for (const k of envKeys) { - if (availableKeys.includes(k)) { - this.cfg.securityKeys[k] = process.env[`KEY_${k}`] - } + zwaveOptions.preferences = { + scales, } + } - zwaveOptions.securityKeys = {} - zwaveOptions.securityKeysLongRange = {} + Object.assign(zwaveOptions, this.cfg.options) - // convert security keys to buffer - for (const key in this.cfg.securityKeys) { - if ( - availableKeys.includes(key) && - this.cfg.securityKeys[key].length === 32 - ) { - zwaveOptions.securityKeys[key] = Buffer.from( - this.cfg.securityKeys[key], - 'hex', - ) - } - } + let s0Key: string - this.cfg.securityKeysLongRange = - this.cfg.securityKeysLongRange || {} + // back compatibility + if (this.cfg.networkKey) { + s0Key = this.cfg.networkKey + delete this.cfg.networkKey + } - // convert security keys to buffer - for (const key in this.cfg.securityKeysLongRange) { - if ( - availableKeys.includes(key) && - this.cfg.securityKeysLongRange[key].length === 32 - ) { - zwaveOptions.securityKeysLongRange[key] = Buffer.from( - this.cfg.securityKeysLongRange[key], - 'hex', - ) - } - } + this.cfg.securityKeys = this.cfg.securityKeys || {} - try { - // init driver here because if connect fails the driver is destroyed - // this could throw so include in the try/catch - this._driver = new Driver(this.cfg.port, zwaveOptions) - this._driver.on('error', this._onDriverError.bind(this)) - this._driver.once( - 'driver ready', - this._onDriverReady.bind(this), - ) - this._driver.on( - 'all nodes ready', - this._onScanComplete.bind(this), - ) - this._driver.on( - 'bootloader ready', - this._onBootLoaderReady.bind(this), - ) + // update settings to fix compatibility + if (s0Key && !this.cfg.securityKeys.S0_Legacy) { + this.cfg.securityKeys.S0_Legacy = s0Key + const settings = jsonStore.get(store.settings) + settings.zwave = this.cfg + await jsonStore.put(store.settings, settings) + } - logger.info(`Connecting to ${this.cfg.port}`) + utils.parseSecurityKeys(this.cfg, zwaveOptions) - // setup user callbacks only if there are connected clients - this.hasUserCallbacks = - (await this.socket.fetchSockets()).length > 0 + try { + // init driver here because if connect fails the driver is destroyed + // this could throw so include in the try/catch + this._driver = new Driver(this.cfg.port, zwaveOptions) + this._driver.on('error', this._onDriverError.bind(this)) + this._driver.once('driver ready', this._onDriverReady.bind(this)) + this._driver.on('all nodes ready', this._onScanComplete.bind(this)) + this._driver.on( + 'bootloader ready', + this._onBootLoaderReady.bind(this), + ) - if (this.hasUserCallbacks) { - this.setUserCallbacks() - } + logger.info(`Connecting to ${this.cfg.port}`) - await this._driver.start() + // setup user callbacks only if there are connected clients + this.hasUserCallbacks = + (await this.socket.fetchSockets()).length > 0 - if (this.checkIfDestroyed()) { - return - } + if (this.hasUserCallbacks) { + this.setUserCallbacks() + } - if (this.cfg.serverEnabled) { - this.server = new ZwavejsServer(this._driver, { - port: this.cfg.serverPort || 3000, - host: this.cfg.serverHost, - logger: LogManager.module('Z-Wave-Server'), - enableDNSServiceDiscovery: - !this.cfg.serverServiceDiscoveryDisabled, - }) + await this._driver.start() - this.server.on('error', () => { - // this is already logged by the server but we need this to prevent - // unhandled exceptions - }) + if (this.checkIfDestroyed()) { + return + } - this.server.on('hard reset', () => { - logger.info('Hard reset requested by ZwaveJS Server') - this.restart().catch((err) => { - logger.error(err) - }) - }) - } + if (this.cfg.serverEnabled) { + this.server = new ZwavejsServer(this._driver, { + port: this.cfg.serverPort || 3000, + host: this.cfg.serverHost, + logger: LogManager.module('Z-Wave-Server'), + enableDNSServiceDiscovery: + !this.cfg.serverServiceDiscoveryDisabled, + }) - if (this.cfg.enableStatistics) { - this.enableStatistics() - } + this.server.on('error', () => { + // this is already logged by the server but we need this to prevent + // unhandled exceptions + }) - this.status = ZwaveClientStatus.CONNECTED - } catch (error) { - // destroy diver instance when it fails - if (this._driver) { - this._driver.destroy().catch((err) => { - logger.error( - `Error while destroying driver ${err.message}`, - error, - ) + this.server.on('hard reset', () => { + logger.info('Hard reset requested by ZwaveJS Server') + this.restart().catch((err) => { + logger.error(err) }) - } - - if (this.checkIfDestroyed()) { - return - } + }) + } - this._onDriverError(error, true) + if (this.cfg.enableStatistics) { + this.enableStatistics() + } - if (error.code !== ZWaveErrorCodes.Driver_InvalidOptions) { - this.backoffRestart() - } else { + this.status = ZwaveClientStatus.CONNECTED + } catch (error) { + // destroy diver instance when it fails + if (this._driver) { + this._driver.destroy().catch((err) => { logger.error( - `Invalid options for driver: ${error.message}`, + `Error while destroying driver ${err.message}`, error, ) - } + }) + } + + if (this.checkIfDestroyed()) { + return + } + + this._onDriverError(error, true) + + if (error.code !== ZWaveErrorCodes.Driver_InvalidOptions) { + this.backoffRestart() + } else { + logger.error( + `Invalid options for driver: ${error.message}`, + error, + ) } - } else { - logger.info(`Driver already connected to ${this.cfg.port}`) } } diff --git a/api/lib/utils.ts b/api/lib/utils.ts index 099279cae4..2f00e96e5d 100644 --- a/api/lib/utils.ts +++ b/api/lib/utils.ts @@ -1,8 +1,9 @@ // eslint-disable-next-line one-var -import { ValueID } from 'zwave-js' +import { PartialZWaveOptions, ValueID, ZnifferOptions } from 'zwave-js' import path, { resolve } from 'path' import crypto from 'crypto' import { readFileSync } from 'fs' +import type { ZwaveConfig } from './ZwaveClient' // don't use import here, it will break the build // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -311,3 +312,63 @@ export function parseJSON(str: string): any { return v }) } + +export function parseSecurityKeys( + config: ZwaveConfig, + options: PartialZWaveOptions | ZnifferOptions, +): void { + config.securityKeys = config.securityKeys || {} + + if (process.env.NETWORK_KEY) { + config.securityKeys.S0_Legacy = process.env.NETWORK_KEY + } + + const availableKeys = [ + 'S2_Unauthenticated', + 'S2_Authenticated', + 'S2_AccessControl', + 'S0_Legacy', + ] + + const envKeys = Object.keys(process.env) + .filter((k) => k?.startsWith('KEY_')) + .map((k) => k.substring(4)) + + // load security keys from env + for (const k of envKeys) { + if (availableKeys.includes(k)) { + config.securityKeys[k] = process.env[`KEY_${k}`] + } + } + + options.securityKeys = {} + options.securityKeysLongRange = {} + + // convert security keys to buffer + for (const key in config.securityKeys) { + if ( + availableKeys.includes(key) && + config.securityKeys[key].length === 32 + ) { + options.securityKeys[key] = Buffer.from( + config.securityKeys[key], + 'hex', + ) + } + } + + config.securityKeysLongRange = config.securityKeysLongRange || {} + + // convert security keys to buffer + for (const key in config.securityKeysLongRange) { + if ( + availableKeys.includes(key) && + config.securityKeysLongRange[key].length === 32 + ) { + options.securityKeysLongRange[key] = Buffer.from( + config.securityKeysLongRange[key], + 'hex', + ) + } + } +} diff --git a/package-lock.json b/package-lock.json index e466eeafeb..c43ac2a449 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9063,20 +9063,6 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", diff --git a/src/App.vue b/src/App.vue index 635abfb542..8a214743d1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -34,7 +34,14 @@ :color="item.path === $route.path ? 'primary' : ''" > - {{ item.icon }} + + {{ item.icon }} + - +