diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b9412914d..b1375ae82d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Typecheck + run: npm run typecheck + - name: Lint if: matrix['node-version'] == env.PRIMARY_NODE_VERSION run: npm run lint diff --git a/api/app.ts b/api/app.ts index b6db8a8b9f..0ebe66dbc3 100644 --- a/api/app.ts +++ b/api/app.ts @@ -156,6 +156,14 @@ export function createApp(options: CreateAppOptions = {}): AppInstance { const socketManager = new SocketManager() + function requireSocketServer(): SocketIOServer { + const io = socketManager.io + if (!io) { + throw new Error('Socket.IO is not attached') + } + return io + } + const runtime = new AppRuntime({ getSocketServer: () => socketManager.io, gatewayFactory: new GatewayFactory({ @@ -231,7 +239,7 @@ export function createApp(options: CreateAppOptions = {}): AppInstance { * Start http/https server and all the manager */ async function startServer(port: number | string, host?: string) { - let server: HttpServer + let server: HttpServer | undefined installProcessHandlers() @@ -281,9 +289,10 @@ export function createApp(options: CreateAppOptions = {}): AppInstance { server = createHttpServer(app) } - attachSocket(server) - server.listen(port as number, host, function () { - const addr = server.address() + const listeningServer = server + attachSocket(listeningServer) + listeningServer.listen(port as number, host, function () { + const addr = listeningServer.address() const bind = typeof addr === 'string' ? 'pipe ' + addr @@ -295,28 +304,33 @@ export function createApp(options: CreateAppOptions = {}): AppInstance { ) }) - server.on('error', function (error: NodeJS.ErrnoException) { - if (error.syscall !== 'listen') { - throw error - } - - const bind = - typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port - - // handle specific listen errors with friendly messages - switch (error.code) { - case 'EACCES': - logger.error(bind + ' requires elevated privileges') - process.exit(1) - break - case 'EADDRINUSE': - logger.error(bind + ' is already in use') - process.exit(1) - break - default: + listeningServer.on( + 'error', + function (error: NodeJS.ErrnoException) { + if (error.syscall !== 'listen') { throw error - } - }) + } + + const bind = + typeof port === 'string' + ? 'Pipe ' + port + : 'Port ' + port + + // handle specific listen errors with friendly messages + switch (error.code) { + case 'EACCES': + logger.error(bind + ' requires elevated privileges') + process.exit(1) + break + case 'EADDRINUSE': + logger.error(bind + ' is already in use') + process.exit(1) + break + default: + throw error + } + }, + ) const users = jsonStore.get(store.users) @@ -334,7 +348,7 @@ export function createApp(options: CreateAppOptions = {}): AppInstance { await debugManager.init() // Clean up any old debug temp files await runtime.startGateway(settings) - return server + return listeningServer } catch (error) { try { await close() @@ -349,16 +363,16 @@ export function createApp(options: CreateAppOptions = {}): AppInstance { } async function loadCertKey(): Promise<{ - cert: string - key: string + cert: string | undefined + key: string | undefined }> { const certFile = process.env.SSL_CERTIFICATE || utils.joinPath(storeDir, 'cert.pem') const keyFile = process.env.SSL_KEY || utils.joinPath(storeDir, 'key.pem') - let key: string - let cert: string + let key: string | undefined + let cert: string | undefined try { cert = await readFile(certFile, 'utf8') @@ -404,11 +418,11 @@ export function createApp(options: CreateAppOptions = {}): AppInstance { loggers.logStream.off('data', logStreamInterceptor) } + const io = requireSocketServer() + // intercept logs and redirect them to socket const interceptor: (chunk: Buffer | string) => void = (chunk) => { - socketManager.io - .to('debug') - .emit(socketEvents.debug, chunk.toString()) + io.to('debug').emit(socketEvents.debug, chunk.toString()) } logStreamInterceptor = interceptor loggers.logStream.on('data', interceptor) @@ -635,10 +649,7 @@ export function createApp(options: CreateAppOptions = {}): AppInstance { app, attachSocket, get io() { - if (!socketManager.io) { - throw new Error('Socket.IO is not attached') - } - return socketManager.io + return requireSocketServer() }, startServer, loadSnippets: () => runtime.loadSnippets(), diff --git a/api/hass/DiscoveryGenerator.ts b/api/hass/DiscoveryGenerator.ts index 7f31540093..06d704481f 100644 --- a/api/hass/DiscoveryGenerator.ts +++ b/api/hass/DiscoveryGenerator.ts @@ -928,14 +928,16 @@ export class DiscoveryGenerator { let isSensor = true if (commandClass === CommandClasses['Multilevel Sensor']) { // Skip auxiliary values because zwave-js omits their sensor metadata - if (!valueId.ccSpecific) return - const sensorType = valueId.ccSpecific.sensorType + const ccSpecific = valueId.ccSpecific + if (!ccSpecific) return + const sensorType = ccSpecific.sensorType if (typeof sensorType !== 'number') return sensor = Constants.sensorType(sensorType) } else if (commandClass === CommandClasses.Meter) { - if (!valueId.ccSpecific) return - const meterType = valueId.ccSpecific.meterType - const scale = valueId.ccSpecific.scale + const ccSpecific = valueId.ccSpecific + if (!ccSpecific) return + const meterType = ccSpecific.meterType + const scale = ccSpecific.scale if ( typeof meterType !== 'number' || typeof scale !== 'number' diff --git a/api/hass/ports.ts b/api/hass/ports.ts index 443e690c1b..947f115d42 100644 --- a/api/hass/ports.ts +++ b/api/hass/ports.ts @@ -51,7 +51,7 @@ export interface HassValueConfiguration { export interface HassValueTopic { topic: string valueConf?: HassValueConfiguration - targetTopic?: string + targetTopic?: string | null } export interface HassTopicPort { diff --git a/api/lib/DebugManager.ts b/api/lib/DebugManager.ts index f6dfa8b80c..fe8a41ce0d 100644 --- a/api/lib/DebugManager.ts +++ b/api/lib/DebugManager.ts @@ -261,7 +261,13 @@ class DebugManager { session.driverDebugTransport, ) if (session.zwaveClient.driverReady) { - session.zwaveClient.driver.updateLogConfig({ + const driver = session.zwaveClient.driver + if (!driver) { + throw new TypeError( + 'Driver is unavailable while restoring the debug log level', + ) + } + driver.updateLogConfig({ level: session.originalLogLevel, }) } diff --git a/api/lib/Gateway.ts b/api/lib/Gateway.ts index 83aa81df26..0032eb447d 100644 --- a/api/lib/Gateway.ts +++ b/api/lib/Gateway.ts @@ -5,6 +5,7 @@ import { CommandClasses } from '@zwave-js/core' import * as Constants from './Constants.ts' import type { LogLevel } from './logger.ts' import { module } from './logger.ts' +import { getErrorMessage } from './errors.ts' import { PayloadType } from './shared.ts' import type { IClientPublishOptions } from 'mqtt' import MqttClient, { type MqttClientEventCallbacks } from './MqttClient.ts' @@ -15,6 +16,7 @@ import type { ZUINode, ZUIValueId, ZUIValueIdState, + ZwaveClientEventCallbacks, } from './ZwaveClient.ts' import type ZwaveClient from './ZwaveClient.ts' import Cron from 'croner' @@ -25,12 +27,7 @@ import type { CustomDeviceRegistry } from '../hass/CustomDeviceRegistry.ts' import MqttDiscoveryManager, { type MqttDiscoveryManagerOptions, } from '../hass/MqttDiscoveryManager.ts' -import type { - HassNode, - HassTopicNode, - HassValue, - HassValueTopic, -} from '../hass/ports.ts' +import type { HassTopicNode, HassValueTopic } from '../hass/ports.ts' import { isHassNode } from '../hass/ports.ts' const logger = module('Gateway') @@ -48,15 +45,13 @@ export type GatewayType = (typeof GATEWAY_TYPE)[keyof typeof GATEWAY_TYPE] export { PayloadType } -export type GatewayValue = { +type GatewayValueBase = { device: string value: ZUIValueId topic?: string device_class?: string icon?: string postOperation?: string - enablePoll?: boolean - pollInterval?: number parseSend?: boolean sendFunction?: string parseReceive?: boolean @@ -66,6 +61,20 @@ export type GatewayValue = { ccConfigEnableDiscovery?: boolean } +type PollingGatewayValue = GatewayValueBase & { + enablePoll: true + pollInterval: number +} + +export type GatewayValue = GatewayValueBase & + ( + | PollingGatewayValue + | { + enablePoll?: false + pollInterval?: number + } + ) + export type ScheduledJob = { name: string cron?: string @@ -110,21 +119,66 @@ export type GatewayConfig = { interface ValueIdTopic { topic: string - valueConf: GatewayValue - targetTopic?: string + valueConf?: GatewayValue + targetTopic?: string | null +} + +type TopicValue = ZUIValueId + +type TopicDetails = 'conf' extends keyof T + ? ValueIdTopic + : HassValueTopic + +type GatewayTopicNode = Omit & { + values?: Record +} + +interface GatewayMqttPublishPort { + publish( + topic: string | null, + data: unknown, + options: IClientPublishOptions | null, + ): unknown +} + +interface GatewayZwaveApiPort { + callApi( + apiName: string, + ...args: Parameters + ): Promise> +} + +type GatewayZwaveEvent = Extract< + keyof ZwaveClientEventCallbacks, + | 'nodeInited' + | 'driverStatus' + | 'nodeStatus' + | 'nodeLastActive' + | 'valueChanged' + | 'nodeRemoved' + | 'notification' + | 'event' +> + +interface GatewayZwaveEventPort { + on( + event: E, + listener: ZwaveClientEventCallbacks[E], + ): void + off( + event: E, + listener: ZwaveClientEventCallbacks[E], + ): void } -export type GatewayZwave = Pick< +type GatewayZwaveState = Pick< ZwaveClient, - | 'callApi' | 'close' | 'connect' | 'driverFunction' | 'emitNodeUpdate' | 'homeHex' | 'nodes' - | 'off' - | 'on' | 'setPollInterval' | 'updateDevice' | 'writeBroadcast' @@ -132,6 +186,10 @@ export type GatewayZwave = Pick< | 'writeValue' > +export type GatewayZwave = GatewayZwaveState & + GatewayZwaveApiPort & + GatewayZwaveEventPort + export type GatewayMqtt = Pick< MqttClient, | 'clientID' @@ -258,7 +316,11 @@ export default class Gateway< }, zwave: { get homeHex() { - return getZwave().homeHex + const homeHex = getZwave().homeHex + if (homeHex === undefined) { + throw new TypeError('Z-Wave home ID is not initialized') + } + return homeHex }, getNode: (nodeId) => getZwave().nodes.get(nodeId), getNodes: () => getZwave().nodes, @@ -281,7 +343,7 @@ export default class Gateway< topics: { nodeTopic: (node) => this.nodeTopic(node), valueTopic: (node, value, returnObject) => - this.valueTopic(node, value, returnObject), + this.valueTopic(node, value, returnObject ?? false), }, registrySource: this.registrySource, logger, @@ -293,6 +355,22 @@ export default class Gateway< return this.mqttDiscovery.discoveryGenerator } + private get gatewayMqtt(): GatewayMqttPublishPort { + const mqtt = this._mqtt + return { + publish: (topic, data, options) => + Reflect.apply(mqtt.publish.bind(mqtt), mqtt, [ + topic, + data, + options, + ]), + } + } + + private get gatewayZwaveApi(): GatewayZwaveApiPort { + return this._zwave + } + private get customDeviceRegistry(): CustomDeviceRegistry { return this.mqttDiscovery.customDeviceRegistry } @@ -342,7 +420,7 @@ export default class Gateway< if (jobConfig.runOnInit) { this.runJob(jobConfig).catch((error) => { logger.error( - `Error while executing scheduled job "${jobConfig.name}": ${error.message}`, + `Error while executing scheduled job "${jobConfig.name}": ${getErrorMessage(error)}`, ) }) } @@ -356,15 +434,19 @@ export default class Gateway< if (job?.nextRun()) { this.jobs.set(jobConfig.name, job) + const nextRun = job.nextRun() + if (nextRun === null) { + throw new TypeError( + "Cannot read properties of null (reading 'toISOString')", + ) + } logger.info( - `Scheduled job "${jobConfig.name}" will run at ${job - .nextRun() - .toISOString()}`, + `Scheduled job "${jobConfig.name}" will run at ${nextRun.toISOString()}`, ) } } catch (error) { logger.error( - `Error while scheduling job "${jobConfig.name}": ${error.message}`, + `Error while scheduling job "${jobConfig.name}": ${getErrorMessage(error)}`, ) } } @@ -380,17 +462,21 @@ export default class Gateway< await this.zwave.driverFunction(jobConfig.code) } catch (error) { logger.error( - `Error executing scheduled job "${jobConfig.name}": ${error.message}`, + `Error executing scheduled job "${jobConfig.name}": ${getErrorMessage(error)}`, ) } const job = this.jobs.get(jobConfig.name) if (job?.nextRun()) { + const nextRun = job.nextRun() + if (nextRun === null) { + throw new TypeError( + "Cannot read properties of null (reading 'toISOString')", + ) + } logger.info( - `Next scheduled job "${jobConfig.name}" will run at ${job - .nextRun() - .toISOString()}`, + `Next scheduled job "${jobConfig.name}" will run at ${nextRun.toISOString()}`, ) } } @@ -444,8 +530,12 @@ export default class Gateway< if (payload === null) return null if (valueConf) { - if (utils.isValidOperation(valueConf.postOperation)) { - let op = valueConf.postOperation + const postOperation = valueConf.postOperation + if ( + postOperation !== undefined && + utils.isValidOperation(postOperation) + ) { + let op = postOperation // revert operation to write if (op.includes('/')) op = op.replace(/\//, '*') @@ -579,7 +669,7 @@ export default class Gateway< /** * Calculates the node topic based on gateway settings */ - nodeTopic(node: HassTopicNode): string { + nodeTopic(node: GatewayTopicNode): string { const topic = [] if (node.loc && !this.config.ignoreLoc) topic.push(node.loc) @@ -612,16 +702,37 @@ export default class Gateway< * Calculates the valueId topic based on gateway settings * */ + valueTopic( + node: GatewayTopicNode, + valueId: T, + returnObject: true, + ): TopicDetails | null + valueTopic( + node: GatewayTopicNode, + valueId: T, + returnObject?: false, + ): string | null + valueTopic( + node: GatewayTopicNode, + valueId: T, + returnObject: boolean, + ): string | TopicDetails | null valueTopic( - node: HassTopicNode, - valueId: HassValue, + node: GatewayTopicNode, + valueId: TopicValue, returnObject = false, - ): string | HassValueTopic | null { + ): string | HassValueTopic | ValueIdTopic | null { const topic = [] - let valueConf: GatewayValue + let valueConf: GatewayValue | undefined // check if this value is in configuration values array - const values = this.config.values.filter( + const configuredValues = this.config.values + if (configuredValues === undefined) { + throw new TypeError( + "Cannot read properties of undefined (reading 'filter')", + ) + } + const values = configuredValues.filter( (v: GatewayValue) => v.device === node.deviceId, ) if (values && values.length > 0) { @@ -634,16 +745,16 @@ export default class Gateway< topic.push(valueConf.topic) } - let targetTopic: string + let targetTopic: string | null | undefined if (returnObject && valueId.targetValue) { const targetValue = node.values?.[valueId.targetValue] if (targetValue) { - targetTopic = this.valueTopic( - node, - targetValue, - false, - ) as string + const targetResult = this.valueTopic(node, targetValue, false) + if (targetResult !== null && typeof targetResult !== 'string') { + throw new TypeError('Expected a string target topic') + } + targetTopic = targetResult } } @@ -659,6 +770,11 @@ export default class Gateway< topic.push('endpoint_' + (valueId.endpoint || 0)) + if (valueId.propertyName === undefined) { + throw new TypeError( + "Cannot read properties of undefined (reading 'replace')", + ) + } topic.push(utils.removeSlash(valueId.propertyName)) if (valueId.propertyKey !== undefined) { topic.push(utils.removeSlash(valueId.propertyKey)) @@ -772,8 +888,16 @@ export default class Gateway< ) for (const topic of topics) { const valueId = this.topicValues[topic] + if (valueId === undefined) { + throw new TypeError( + "Cannot read properties of undefined (reading 'nodeId')", + ) + } delete this.topicValues[topic] - const updatedTopic = this.valueTopic(node, valueId) as string + const updatedTopic = this.valueTopic(node, valueId) + if (typeof updatedTopic !== 'string') { + throw new TypeError('Expected a string value topic') + } this.topicValues[updatedTopic] = valueId } } @@ -786,11 +910,12 @@ export default class Gateway< const node = this._zwave.nodes.get(nodeId) if (!node) return - const topics = Object.values(node.values).map( - (value) => this.valueTopic(node, value) as string, + const nodeValues = this._requireNodeValues(node) + const topics = Object.values(nodeValues).map((value) => + this.valueTopic(node, value), ) for (const topic of topics) { - this._mqtt.publish(topic, '', { retain: true }) + this.gatewayMqtt.publish(topic, '', { retain: true }) } } @@ -834,9 +959,9 @@ export default class Gateway< this.discoveryGenerator.discoverValueIfNeeded(node, valueId) } - const result = this.valueTopic(node, valueId, true) as ValueIdTopic + const result = this.valueTopic(node, valueId, true) - if (!result) { + if (result === null) { if (this.config.type !== GATEWAY_TYPE.MANUAL) { // if config is manual it is normal that some values are not mapped logger.debug(`No topic found for value ${valueId.id}`) @@ -844,6 +969,9 @@ export default class Gateway< return } + if (typeof result === 'string') { + throw new TypeError('Expected value topic details') + } // if there is a valid topic for this value publish it @@ -853,11 +981,12 @@ export default class Gateway< let tmpVal = valueId.value if (valueConf) { - if (utils.isValidOperation(valueConf.postOperation)) { - tmpVal = utils.applyOperation( - valueId.value, - valueConf.postOperation, - ) + const postOperation = valueConf.postOperation + if ( + postOperation !== undefined && + utils.isValidOperation(postOperation) + ) { + tmpVal = utils.applyOperation(valueId.value, postOperation) } if (valueConf.parseSend) { @@ -926,18 +1055,22 @@ export default class Gateway< } // handle the case the conf is set on current value but not in target value - if (valueId.targetValue && node.values[valueId.targetValue]) { - const targetValueId = utils.copy( - node.values[valueId.targetValue], - ) - targetValueId.conf = valueConf - this.topicValues[topic] = targetValueId + if (valueId.targetValue) { + const nodeValues = this._requireNodeValues(node) + const targetValueId = nodeValues[valueId.targetValue] + if (targetValueId) { + const targetValueIdCopy = utils.copy(targetValueId) + targetValueIdCopy.conf = valueConf + this.topicValues[topic] = targetValueIdCopy + } else { + this.topicValues[topic] = valueId + } } else { this.topicValues[topic] = valueId } } - let mqttOptions: IClientPublishOptions = valueId.stateless + let mqttOptions: IClientPublishOptions | null = valueId.stateless ? { retain: false } : null @@ -961,7 +1094,7 @@ export default class Gateway< `Skipping send of stateless value ${valueId.id}: it's from cache`, ) } else { - this._mqtt.publish(topic, data, mqttOptions) + this.gatewayMqtt.publish(topic, data, mqttOptions) } } @@ -969,17 +1102,22 @@ export default class Gateway< * Triggered when a notification is received from a node in Z-Wave Client */ private _onNotification( - node: ZUINode, + node: ZUINode | undefined, valueId: ZUIValueId, data: Record, ): void { - const topic = this.valueTopic(node, valueId) as string + if (node === undefined) { + throw new TypeError( + "Cannot read properties of undefined (reading 'deviceId')", + ) + } + const topic = this.valueTopic(node, valueId) if (this.config.payloadType !== PayloadType.RAW) { data = { time: Date.now(), value: data } } - this._mqtt.publish(topic, data, { retain: false }) + this.gatewayMqtt.publish(topic, data, { retain: false }) } private _onNodeInited(node: ZUINode): void { @@ -989,19 +1127,26 @@ export default class Gateway< // enable poll if required const values = this.config.values?.filter( - (v: GatewayValue) => v.enablePoll && v.device === node.deviceId, + (v): v is PollingGatewayValue => + v.enablePoll === true && v.device === node.deviceId, ) ?? [] for (let i = 0; i < values.length; i++) { // don't edit the original object, copy it - const valueId = utils.copy(values[i].value) + const valueConfig = values[i] + if (valueConfig === undefined) { + throw new TypeError( + "Cannot read properties of undefined (reading 'value')", + ) + } + const valueId = utils.copy(valueConfig.value) valueId.nodeId = node.id valueId.id = node.id + '-' + valueId.id try { - this._zwave.setPollInterval(valueId, values[i].pollInterval) + this._zwave.setPollInterval(valueId, valueConfig.pollInterval) } catch (error) { logger.error( - `Error while enabling poll interval: ${error.message}`, + `Error while enabling poll interval: ${getErrorMessage(error)}`, ) } } @@ -1079,14 +1224,15 @@ export default class Gateway< /** * Driver status updates */ - private _onDriverStatus(ready: boolean): void { + private _onDriverStatus(ready: boolean | null | undefined): void { logger.info(`Driver is ${ready ? 'READY' : 'CLOSED'}`) this.cancelJobs() if (ready) { - if (this.config.jobs?.length > 0) { - for (const jobConfig of this.config.jobs) { + const jobs = this.config.jobs + if (jobs && jobs.length > 0) { + for (const jobConfig of jobs) { this.scheduleJob(jobConfig) } } @@ -1103,7 +1249,7 @@ export default class Gateway< */ private async _onApiRequest( topic: string, - apiName: AllowedApis, + apiName: string, payload: { args: Parameters }, ): Promise { if (this._zwave) { @@ -1112,7 +1258,7 @@ export default class Gateway< let result: CallAPIResult & { origin?: any } if (Array.isArray(args)) { - result = await this._zwave.callApi(apiName, ...args) + result = await this.gatewayZwaveApi.callApi(apiName, ...args) result.origin = payload } else { result = { @@ -1142,10 +1288,15 @@ export default class Gateway< ) if (values.length > 0) { // all values are the same type just different node,parse the Payload by using the first one + const firstTopic = values[0] + if (firstTopic === undefined) { + throw new TypeError('Expected a broadcast topic') + } + const firstValue = this._requireTopicValue(firstTopic) payload = this.parsePayload( payload, - this.topicValues[values[0]], - this.topicValues[values[0]].conf, + firstValue, + firstValue.conf, ) if (payload === null) { @@ -1153,8 +1304,12 @@ export default class Gateway< } for (let i = 0; i < values.length; i++) { + const valueTopic = values[i] + if (valueTopic === undefined) { + throw new TypeError('Expected a broadcast topic') + } await this._zwave.writeValue( - this.topicValues[values[i]], + this._requireTopicValue(valueTopic), payload, payload?.options, ) @@ -1251,10 +1406,10 @@ export default class Gateway< * */ private _evalFunction( - code: string, + code: string | undefined, valueId: ZUIValueId, value: unknown, - node: ZUINode, + node: ZUINode | undefined, ) { let result = null @@ -1264,12 +1419,12 @@ export default class Gateway< 'valueId', 'node', 'logger', - code, + code === undefined ? 'undefined' : code, ) result = parseFunc(value, valueId, node, logger) } catch (error) { logger.error( - `Error eval function of value ${valueId.id} ${error.message}`, + `Error eval function of value ${valueId.id} ${getErrorMessage(error)}`, ) } @@ -1279,7 +1434,24 @@ export default class Gateway< /** * Get node name from node object */ - private _getIdWithoutNode(valueId: HassValue): string { + private _getIdWithoutNode(valueId: ZUIValueId): string { return valueId.id.replace(valueId.nodeId + '-', '') } + + private _requireNodeValues(node: ZUINode): Record { + if (node.values === undefined) { + throw new TypeError('Cannot convert undefined or null to object') + } + return node.values + } + + private _requireTopicValue(topic: string): ZUIValueId { + const value = this.topicValues[topic] + if (value === undefined) { + throw new TypeError( + "Cannot read properties of undefined (reading 'conf')", + ) + } + return value + } } diff --git a/api/lib/ZnifferManager.ts b/api/lib/ZnifferManager.ts index 651d9784d1..b09e171a56 100644 --- a/api/lib/ZnifferManager.ts +++ b/api/lib/ZnifferManager.ts @@ -64,7 +64,7 @@ export default class ZnifferManager extends TypedEventEmitter { const socketFrame = this.parseFrame(frame, rawData) - this.socket + this.requireSocket() .to('znifferFrames') .emit(socketEvents.znifferFrame, socketFrame) }) @@ -120,7 +120,7 @@ export default class ZnifferManager extends TypedEventEmitter { const socketFrame = this.parseFrame(frame, rawData) - this.socket + this.requireSocket() .to('znifferFrames') .emit(socketEvents.znifferFrame, socketFrame) }) @@ -133,6 +133,14 @@ export default class ZnifferManager extends TypedEventEmitter {}) } + private requireSocket(): SocketServer { + const socket = this.socket + if (!socket) { + throw new Error('Socket.IO is not attached') + } + return socket + } + private async init() { const zniffer = this.ensureReady() try { @@ -178,7 +186,7 @@ export default class ZnifferManager extends TypedEventEmitter & { + nodeId?: number + propertyKey?: string | number | null +} + +function requireDefined(value: T | null | undefined, message: string): T { + if (value == null) { + throw new TypeError(message) + } + return value +} + +function hasProperty( + value: object, + key: K, +): value is object & Record { + return key in value +} + +function getLegacyErrorMessage(error: unknown): unknown { + if (error === null) { + throw new TypeError( + "Cannot read properties of null (reading 'message')", + ) + } + if (error === undefined) { + throw new TypeError( + "Cannot read properties of undefined (reading 'message')", + ) + } + if ( + (typeof error === 'object' || typeof error === 'function') && + hasProperty(error, 'message') + ) { + return error.message + } + return undefined +} + +function formatLegacyMessage(message: unknown): string { + if (typeof message === 'symbol') { + throw new TypeError('Cannot convert a Symbol value to a string') + } + + return String(message) +} + +function formatLegacyErrorMessage(error: unknown): string { + return formatLegacyMessage(getLegacyErrorMessage(error)) +} + +function hasZWaveErrorCode(error: unknown): error is { code: ZWaveErrorCodes } { + if (error === null) { + throw new TypeError("Cannot read properties of null (reading 'code')") + } + if (error === undefined) { + throw new TypeError( + "Cannot read properties of undefined (reading 'code')", + ) + } + return ( + typeof error === 'object' && + 'code' in error && + typeof error.code === 'number' + ) +} + function validateMethods( methods: T, ): T { @@ -356,9 +427,9 @@ const observedCCProps: { } = { [CommandClasses.Battery]: { level(node, value) { - const levels: { [key: number]: number } = node.batteryLevels || {} + const levels: Record = node.batteryLevels || {} - levels[value.endpoint] = value.value + levels[String(value.endpoint)] = value.value node.batteryLevels = levels node.minBatteryLevel = Math.min(...Object.values(levels)) @@ -420,8 +491,8 @@ export type ZUIValueIdState = { } export type ZUIClientStatus = { - driverReady: boolean - status: boolean + driverReady: boolean | null | undefined + status: boolean | null | undefined config: ZwaveConfig } @@ -492,12 +563,12 @@ export type CallAPIResult = | { success: true message: string - result: ReturnType + result: Awaited> | undefined args?: Parameters } | { success: false - message: string + message: unknown args?: Parameters } @@ -549,9 +620,9 @@ export type ZUINode = { productDescription?: string statistics?: ControllerStatistics | NodeStatistics applicationRoute?: Route | null - priorityReturnRoute?: Record - prioritySUCReturnRoute?: Route - customReturnRoute?: Record + priorityReturnRoute?: Record | null + prioritySUCReturnRoute?: Route | null + customReturnRoute?: Record | null customSUCReturnRoutes?: Route[] productType?: number manufacturer?: string @@ -603,7 +674,7 @@ export type ZUINode = { inited: boolean rebuildRoutesProgress?: RebuildRoutesStatus | undefined minBatteryLevel?: number - batteryLevels?: { [key: number]: number } + batteryLevels?: Record firmwareUpdate?: FirmwareUpdateProgress firmwareCapabilities?: FirmwareUpdateCapabilities eventsQueue: NodeEvent[] @@ -630,6 +701,17 @@ export type ZUINode = { lastFirmwareUpdateCheck?: number } +export type ZUIStatisticsUpdate = { + [K in + | 'statistics' + | 'lastActive' + | 'applicationRoute' + | 'customSUCReturnRoutes' + | 'customReturnRoute' + | 'prioritySUCReturnRoute' + | 'priorityReturnRoute']?: ZUINode[K] | null +} & { bgRssi?: ControllerStatistics['backgroundRSSI'] | null } + export type NodeEvent = { event: ZwaveNodeEvents | 'status changed' args: any[] @@ -665,47 +747,74 @@ export interface ZwaveClientEventCallbacks { nodeInited: (node: ZUINode) => void event: (source: EventSource, eventName: string, ...args: any) => void scanComplete: () => void - driverStatus: (status: boolean) => void - notification: (node: ZUINode, valueId: ZUIValueId, data: any) => void + driverStatus: (status: boolean | null | undefined) => void + notification: ( + node: ZUINode | undefined, + valueId: ZUIValueId, + data: any, + ) => void nodeRemoved: (node: Partial) => void valueChanged: ( valueId: ZUIValueId, node: ZUINode, changed?: boolean, ) => void - valueWritten: (valueId: ZUIValueId, node: ZUINode, value: unknown) => void + valueWritten: ( + valueId: ZUIValueId, + node: ZUINode | undefined, + value: unknown, + ) => void } export type ZwaveClientEvents = Extract class ZwaveClient extends TypedEventEmitter { private cfg: ZwaveConfig - private socket: SocketServer - private closed: boolean + private socket: SocketServer | undefined + declare private closed: boolean private destroyed = false - private _driverReady: boolean - private _nodeRegistry!: NodeRegistry + declare private _driverReady: boolean | null | undefined + declare private _nodeRegistry: NodeRegistry private _nodeGeneration = 0 - private _socketEventAdapter!: SocketEventAdapter + declare private _socketEventAdapter: SocketEventAdapter /** * Holds the live driver-side virtual-node instances (multicast groups + * standard/LR broadcast). Keyed by virtual nodeId. */ - private _virtualNodes: Map private _nodesPersistenceTail: Promise = Promise.resolve() - private _devices: Record> - private driverInfo: ZUIDriverInfo - private status: ZwaveClientStatus + declare private _virtualNodes: Map + declare private _devices: Record> + declare private driverInfo: ZUIDriverInfo + declare private status: ZwaveClientStatus private _error: string | undefined - private _scanComplete: boolean - private _cntStatus: string + declare private _scanComplete: boolean + declare private _cntStatus: string - private lastUpdate: number + declare private lastUpdate: number - private _driver: Driver + private _driverState: Driver | null | undefined - /** Reused across init() rather than reconstructed so its generation counter, log transports and adopted server manager survive restarts */ - private _driverLifecycle!: DriverLifecycle + private get _driver(): Driver | null | undefined { + return this._driverState + } + + private set _driver(driver: Driver | null | undefined) { + this._driverState = driver + } + + /** + * Owns the low-level driver lifecycle (creation/options/log transports, + * connect/start, retry-backoff timers, statistics, extra log transports, + * the official `@zwave-js/server` coordination and idempotent close) plus a + * monotonic **generation** counter so a late `driver ready`/`error`/`all + * nodes ready`/OTW callback from an obsolete driver can never mutate a + * replacement generation's state. The public lifecycle methods below + * (`connect`/`close`/`enableStatistics`/…) and the server accessors delegate + * to it so the legacy internal/API surface is unchanged. Constructed once + * (and reused across `init()`) so the generation/transports/server manager + * persist across restarts. + */ + declare private _driverLifecycle: DriverLifecycle /** * The narrow {@link ZwaveServerHost} port the server manager uses to reach @@ -716,7 +825,11 @@ class ZwaveClient extends TypedEventEmitter { */ public buildServerHost(): ZwaveServerHost { return { - getDriver: () => this._driver, + getDriver: () => + requireDefined( + this._driverState, + 'Driver is unavailable while creating the Z-Wave server', + ), getConfig: () => this.cfg, getHasUserCallbacks: () => this._inclusionCoordinator.hasUserCallbacks, @@ -754,8 +867,8 @@ class ZwaveClient extends TypedEventEmitter { this._driverLifecycle.server = value } - private healTimeout: NodeJS.Timeout - private updatesCheckTimeout: NodeJS.Timeout + private healTimeout: NodeJS.Timeout | null | undefined + private updatesCheckTimeout: NodeJS.Timeout | null | undefined /** Invalidated on every init/hardReset/close/restart boundary to fence the daily config-check chain that the DriverLifecycle generation can't, since init and hardReset keep the generation */ private _configCheckEpoch = 0 /** Dedupes same-generation config-check chains that `_configCheckEpoch` can't: `driver ready` re-fires without a boundary on NVM restore or controller firmware update */ @@ -766,20 +879,20 @@ class ZwaveClient extends TypedEventEmitter { private _configInstallEpoch = 0 private _activeConfigInstall: number | null = null private _configInstallPromise: Promise | null = null - private pollIntervals: Record + declare private pollIntervals: Record - private _lockNeighborsRefresh: boolean - private _scheduleService: ScheduleService - private _configTemplateService: ConfigurationTemplateService - private _sceneService: SceneService - private _groupService: GroupService + declare private _lockNeighborsRefresh: boolean + declare private _scheduleService: ScheduleService + declare private _configTemplateService: ConfigurationTemplateService + declare private _sceneService: SceneService + declare private _groupService: GroupService // Generation token for the current GroupService instance; cancelled and replaced on every init(), see GroupServiceGeneration for the cancellation mechanism private _groupServiceGeneration: GroupServiceGeneration | undefined - private _associationService: AssociationService - private _firmwareUpdateService: FirmwareUpdateService - private _inclusionCoordinator: InclusionCoordinator + declare private _associationService: AssociationService + declare private _firmwareUpdateService: FirmwareUpdateService + declare private _inclusionCoordinator: InclusionCoordinator - private nvmEvent: string + declare private nvmEvent: string | null | undefined private driverFunctionCache: utils.Snippet[] = [] @@ -789,14 +902,18 @@ class ZwaveClient extends TypedEventEmitter { private throttledFunctions: Map< string, - { lastUpdate: number; fn: () => void; timeout: NodeJS.Timeout } + { + lastUpdate: number + fn: () => void + timeout: NodeJS.Timeout | null + } > = new Map() public get driverReady() { - return this.driver && this._driverReady && !this.closed + return this._driverState && this._driverReady && !this.closed } - public set driverReady(ready) { + public set driverReady(ready: boolean | null | undefined) { if (this._driverReady !== ready) { this._driverReady = ready this.emit('driverStatus', ready) @@ -815,8 +932,8 @@ class ZwaveClient extends TypedEventEmitter { return this._error } - public get driver() { - return this._driver + public get driver(): Driver | null | undefined { + return this._driverState } public get nodes() { @@ -847,7 +964,7 @@ class ZwaveClient extends TypedEventEmitter { return this.driverFunctionCache } - constructor(config: ZwaveConfig, socket: SocketServer) { + constructor(config: ZwaveConfig, socket: SocketServer | undefined) { super() this.cfg = config @@ -890,6 +1007,14 @@ class ZwaveClient extends TypedEventEmitter { this.init() } + private requireSocket(): SocketServer { + const socket = this.socket + if (!socket) { + throw new Error('Socket.IO is not attached') + } + return socket + } + get homeHex() { return this.driverInfo.name } @@ -917,11 +1042,11 @@ class ZwaveClient extends TypedEventEmitter { this._virtualNodes = new Map() this._socketEventAdapter = new SocketEventAdapter( { - getSocket: () => this.socket, + getSocket: () => this.requireSocket(), getGeneration: () => this._nodeGeneration, isCurrent: (generation, socket) => this._isCurrentNodeGeneration(generation) && - socket === this.socket && + socket === this.requireSocket() && !!this._nodeRegistry, }, logger, @@ -935,7 +1060,7 @@ class ZwaveClient extends TypedEventEmitter { } const templateDriverPort = { - getDriver: () => this._driver, + getDriver: () => this._driverState, } const templateNodeStorePort = { getNode: (nodeId: number) => this._nodes.get(nodeId), @@ -994,7 +1119,7 @@ class ZwaveClient extends TypedEventEmitter { getNode: (nodeId: number) => this._nodes.get(nodeId), } const sceneUtilsPort = { - getValueId: (v: Partial) => this._getValueID(v), + getValueId: (v: ValueIdRef) => this._getValueID(v), } const sceneWritePort = { writeValue: (valueId: ZUIValueIdScene, value: unknown) => @@ -1011,13 +1136,16 @@ class ZwaveClient extends TypedEventEmitter { const groupDriverPort = { isDriverReady: () => this.driverReady, - getOwnNodeId: () => this._driver?.controller?.ownNodeId, + getOwnNodeId: () => this._driverState?.controller?.ownNodeId, hasPhysicalNode: (id: number) => { - const knownPhysicalIds = this._driver?.controller?.nodes + const knownPhysicalIds = this._driverState?.controller?.nodes return !knownPhysicalIds || knownPhysicalIds.has(id) }, getMulticastGroup: (nodeIds: number[]) => - this._driver.controller.getMulticastGroup(nodeIds), + requireDefined( + this._driverState, + 'Driver is unavailable while creating a multicast group', + ).controller.getMulticastGroup(nodeIds), } // this._virtualNodes is replaced with a new Map on every restart; resolving it fresh here (not capturing a reference) keeps an in-flight call from a superseded generation from writing into an abandoned map const groupVirtualNodeRegistryPort = { @@ -1048,7 +1176,7 @@ class ZwaveClient extends TypedEventEmitter { } const groupUtilsPort = { deepEqual: (a: unknown, b: unknown) => utils.deepEqual(a, b), - getValueId: (v: Partial) => this._getValueID(v), + getValueId: (v: ValueIdRef) => this._getValueID(v), buildVirtualValueId: ( nodeId: number, zwaveValue: VirtualValueID, @@ -1082,7 +1210,7 @@ class ZwaveClient extends TypedEventEmitter { ) const associationDriverPort = { - getDriver: () => this._driver, + getDriver: () => this._driverState, } const associationNodeStorePort = { getZWaveNode: (nodeId: number) => this.getNode(nodeId), @@ -1113,7 +1241,7 @@ class ZwaveClient extends TypedEventEmitter { ) const firmwareDriverPort = { - getDriver: () => this._driver, + getDriver: () => this._driverState, isDriverReady: () => this.driverReady, } const createFirmwarePersistenceRestore = ( @@ -1158,8 +1286,10 @@ class ZwaveClient extends TypedEventEmitter { const homeHex = this.homeHex const restore = createFirmwarePersistenceRestore(homeHex) const snapshot: NodesStoreRecord = {} - for (const [key, node] of Object.entries(this.storeNodes)) { - snapshot[Number(key)] = node + for (const [key, storedNode] of Object.entries( + this.storeNodes, + )) { + snapshot[Number(key)] = storedNode } for (const entry of staged) { const existing = snapshot[entry.nodeId] @@ -1234,7 +1364,7 @@ class ZwaveClient extends TypedEventEmitter { } const inclusionDriverPort = { - getDriver: () => this._driver, + getDriver: () => this._driverState, isDriverReady: () => this.driverReady, } const inclusionSocketPort = { @@ -1308,9 +1438,9 @@ class ZwaveClient extends TypedEventEmitter { private _buildDriverLifecycleHost(): DriverLifecycleHost { return { getConfig: () => this.cfg, - getDriver: () => this._driver, + getDriver: () => this._driverState ?? null, setDriver: (driver) => { - this._driver = driver + this._driverState = driver }, isDriverReady: () => this.driverReady, isDriverReadyRaw: () => this._driverReady, @@ -1326,9 +1456,11 @@ class ZwaveClient extends TypedEventEmitter { this.driverReady = ready }, hasConnectedClients: async () => - (await this.socket.fetchSockets()).length > 0, + (await this.requireSocket().fetchSockets()).length > 0, emitDebug: (message) => { - this.socket.to('debug').emit(socketEvents.debug, message) + this.requireSocket() + .to('debug') + .emit(socketEvents.debug, message) }, getInclusionUserCallbacks: () => this._inclusionCoordinator.getUserCallbacks(), @@ -1360,7 +1492,11 @@ class ZwaveClient extends TypedEventEmitter { private _buildNodeRegistryHost(): NodeRegistryHost { return { - getDriver: () => this._driver, + getDriver: () => + requireDefined( + this._driverState, + 'Driver is unavailable while accessing the node registry', + ), getZWaveNode: (nodeId) => this.getNode(nodeId), getGeneration: () => this._nodeGeneration, isCurrent: (registry, generation) => @@ -1444,15 +1580,16 @@ class ZwaveClient extends TypedEventEmitter { updateControllerNodeProps: (node) => this.updateControllerNodeProps(node), registerDevice: (node) => { - if (!this._devices[node.deviceId]) { - this._devices[node.deviceId] = { + const deviceId = String(node.deviceId) + if (!this._devices[deviceId]) { + this._devices[deviceId] = { name: `[${node.deviceId}] ${node.productDescription} (${node.manufacturer})`, values: utils.copy(node.values), } - const values = this._devices[node.deviceId].values - delete this._devices[node.deviceId].hassDevices + const values = this._devices[deviceId].values + delete this._devices[deviceId].hassDevices for (const id in values) { - delete values[id].nodeId + Reflect.deleteProperty(values[id], 'nodeId') values[id].id = id } } @@ -1500,13 +1637,14 @@ class ZwaveClient extends TypedEventEmitter { if (this.pollIntervals) { for (const k in this.pollIntervals) { - clearTimeout(this.pollIntervals[k]) + const timeout = this.pollIntervals[k] + if (timeout) clearTimeout(timeout) delete this.pollIntervals[k] } } for (const [key, entry] of this.throttledFunctions) { - clearTimeout(entry.timeout) + clearTimeout(entry.timeout ?? undefined) this.throttledFunctions.delete(key) } } @@ -1654,8 +1792,9 @@ class ZwaveClient extends TypedEventEmitter { private clearThrottle(key: string) { const entry = this.throttledFunctions.get(key) if (entry) { - if (entry.timeout) { - clearTimeout(entry.timeout) + const timeout = entry.timeout + if (timeout) { + clearTimeout(timeout) } this.throttledFunctions.delete(key) } @@ -1671,8 +1810,11 @@ class ZwaveClient extends TypedEventEmitter { /** * Returns the driver ZWaveNode object for physical nodes */ - getNode(nodeId: number): ZWaveNode { - return this._driver.controller.nodes.get(nodeId) + getNode(nodeId: number): ZWaveNode | undefined { + return requireDefined( + this._driverState, + `Driver is unavailable while looking up Z-Wave node ${nodeId}`, + ).controller.nodes.get(nodeId) } /** @@ -1697,7 +1839,7 @@ class ZwaveClient extends TypedEventEmitter { /** * Returns the driver ZWaveNode ValueId object or null */ - getZwaveValue(idString: string): ValueID { + getZwaveValue(idString: string): ValueID | null { if (!idString || typeof idString !== 'string') { return null } @@ -1941,7 +2083,10 @@ class ZwaveClient extends TypedEventEmitter { * - Time CC * - Schedule Entry Lock CC (for setting the timezone) */ - syncNodeDateAndTime(nodeId: number, date = new Date()): Promise { + syncNodeDateAndTime( + nodeId: number, + date = new Date(), + ): Promise | undefined { const zwaveNode = this.getNode(nodeId) if (zwaveNode) { @@ -1954,7 +2099,10 @@ class ZwaveClient extends TypedEventEmitter { return zwaveNode.setDateAndTime(date) } else { this.logNode( - zwaveNode, + requireDefined( + zwaveNode, + `Z-Wave node ${nodeId} is unavailable while syncing date and time`, + ), 'warn', `Node not found when calling 'syncNodeDateAndTime'`, ) @@ -1968,7 +2116,10 @@ class ZwaveClient extends TypedEventEmitter { zwaveNode.manuallyIdleNotificationValue(valueId) } else { this.logNode( - zwaveNode, + requireDefined( + zwaveNode, + `Z-Wave node ${valueId.nodeId} is unavailable while idling a notification value`, + ), 'warn', `Node not found when calling 'manuallyIdleNotificationValue'`, ) @@ -1985,7 +2136,7 @@ class ZwaveClient extends TypedEventEmitter { /** * Refresh all nodes neighbors */ - async refreshNeighbors(): Promise> { + async refreshNeighbors(): Promise> { if (this._lockNeighborsRefresh === true) { throw Error('you can refresh neighbors only once every 60 seconds') } @@ -1998,15 +2149,19 @@ class ZwaveClient extends TypedEventEmitter { NEIGHBORS_LOCK_REFRESH, ) - const toReturn = {} + const toReturn: Record = {} + const driver = requireDefined( + this._driverState, + 'Driver is unavailable while refreshing node neighbors', + ) // 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) + await 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) + await driver.controller.toggleRF(true) return toReturn } @@ -2024,14 +2179,19 @@ class ZwaveClient extends TypedEventEmitter { throw new DriverNotReadyError() } - const zwaveNode = this.getNode(nodeId) + const zwaveNode = requireDefined( + this.getNode(nodeId), + `Z-Wave node ${nodeId} is unavailable while getting neighbors`, + ) if (zwaveNode.protocol === Protocols.ZWaveLongRange) { return [] } - const neighbors = - await this._driver.controller.getNodeNeighbors(nodeId) + const neighbors = await requireDefined( + this._driverState, + `Driver is unavailable while getting neighbors for node ${nodeId}`, + ).controller.getNodeNeighbors(nodeId) this.logNode(nodeId, 'debug', `Neighbors: ${neighbors.join(', ')}`) const node = this.nodes.get(nodeId) @@ -2049,7 +2209,9 @@ class ZwaveClient extends TypedEventEmitter { this.logNode( nodeId, 'warn', - `Error while getting neighbors from ${nodeId}: ${error.message}`, + `Error while getting neighbors from ${nodeId}: ${formatLegacyErrorMessage( + error, + )}`, ) if (!preventThrow) { @@ -2068,8 +2230,10 @@ class ZwaveClient extends TypedEventEmitter { throw new DriverNotReadyError() } - const result = - await this._driver.controller.discoverNodeNeighbors(nodeId) + const result = await requireDefined( + this._driverState, + `Driver is unavailable while discovering neighbors for node ${nodeId}`, + ).controller.discoverNodeNeighbors(nodeId) if (result) { // update neighbors @@ -2102,7 +2266,10 @@ class ZwaveClient extends TypedEventEmitter { const fn = new AsyncFunction('driver', code) const require = createRequire(import.meta.url) - return fn.call({ zwaveClient: this, require, logger }, this._driver) + return fn.call( + { zwaveClient: this, require, logger }, + this._driverState, + ) } /** @@ -2151,7 +2318,7 @@ class ZwaveClient extends TypedEventEmitter { registry: NodeRegistry = this._nodeRegistry, generation: number = this._nodeGeneration, ) { - const sockets = await this.socket.fetchSockets() + const sockets = await this.requireSocket().fetchSockets() if (!this._isCurrentNodeRegistry(registry, generation)) return for (const socket of sockets) { @@ -2183,28 +2350,17 @@ class ZwaveClient extends TypedEventEmitter { this._groupService.updateVirtualNodesForNode(valueId.nodeId) } - public emitStatistics( - node: ZUINode, - props: Pick< - ZUINode, - | 'statistics' - | 'lastActive' - | 'applicationRoute' - | 'customSUCReturnRoutes' - | 'customReturnRoute' - | 'prioritySUCReturnRoute' - | 'priorityReturnRoute' - > & { bgRssi?: ControllerStatistics['backgroundRSSI'] }, - ) { + public emitStatistics(node: ZUINode, props: ZUIStatisticsUpdate) { // NB: be sure that when `statistics` is defined also `lastActive` must be. // when removing props them should be set to null or false in order to be removed on ui this.sendToSocket(socketEvents.statistics, { nodeId: node.id, - ...Object.keys(props).reduce((acc, k) => { - if (props[k] === null) acc[k] = false - else acc[k] = props[k] - return acc - }, {} as any), + ...Object.fromEntries( + Object.entries(props).map(([key, value]) => [ + key, + value === null ? false : value, + ]), + ), }) } @@ -2580,7 +2736,10 @@ class ZwaveClient extends TypedEventEmitter { private _refreshBroadcastInstances(): void { if (!this.driverReady) return - const controller = this._driver.controller + const controller = requireDefined( + this._driverState, + 'Driver is unavailable while refreshing broadcast nodes', + ).controller if (this._virtualNodes.has(NODE_ID_BROADCAST)) { this._virtualNodes.set( @@ -2642,7 +2801,9 @@ class ZwaveClient extends TypedEventEmitter { this.sendToSocket(socketEvents.nodeUpdated, virtualNode) } catch (error) { logger.error( - `Error updating broadcast node ${nodeId} values: ${error.message}`, + `Error updating broadcast node ${nodeId} values: ${formatLegacyErrorMessage( + error, + )}`, ) } } @@ -2661,7 +2822,10 @@ class ZwaveClient extends TypedEventEmitter { try { // Standard broadcast — always available. - const broadcastNode = this._driver.controller.getBroadcastNode() + const broadcastNode = requireDefined( + this._driverState, + 'Driver is unavailable while creating broadcast nodes', + ).controller.getBroadcastNode() this._virtualNodes.set(NODE_ID_BROADCAST, broadcastNode) const broadcastVirtualNode = this._newVirtualZUINode( @@ -2677,7 +2841,9 @@ class ZwaveClient extends TypedEventEmitter { this._updateBroadcastNodeValues() } catch (error) { - logger.error(`Error creating broadcast nodes: ${error.message}`) + logger.error( + `Error creating broadcast nodes: ${formatLegacyErrorMessage(error)}`, + ) } } @@ -2689,7 +2855,10 @@ class ZwaveClient extends TypedEventEmitter { private _refreshBroadcastLRNode(): void { if (!this.driverReady) return - const controller = this._driver.controller + const controller = requireDefined( + this._driverState, + 'Driver is unavailable while refreshing the LR broadcast node', + ).controller const hasLRNodes = controller.supportsLongRange && [...controller.nodes.values()].some( @@ -2711,7 +2880,9 @@ class ZwaveClient extends TypedEventEmitter { this._nodes.set(NODE_ID_BROADCAST_LR, lrNode) this.sendToSocket(socketEvents.nodeUpdated, lrNode) } catch (error) { - logger.warn(`LR broadcast node not available: ${error.message}`) + logger.warn( + `LR broadcast node not available: ${formatLegacyErrorMessage(error)}`, + ) } } else if (!hasLRNodes && exists) { this._virtualNodes.delete(NODE_ID_BROADCAST_LR) @@ -2775,7 +2946,10 @@ class ZwaveClient extends TypedEventEmitter { */ refreshValues(nodeId: number): Promise { if (this.driverReady) { - const zwaveNode = this.getNode(nodeId) + const zwaveNode = requireDefined( + this.getNode(nodeId), + `Z-Wave node ${nodeId} is unavailable while refreshing values`, + ) return zwaveNode.refreshValues() } @@ -2788,7 +2962,10 @@ class ZwaveClient extends TypedEventEmitter { */ pingNode(nodeId: number): Promise { if (this.driverReady) { - const zwaveNode = this.getNode(nodeId) + const zwaveNode = requireDefined( + this.getNode(nodeId), + `Z-Wave node ${nodeId} is unavailable while pinging`, + ) return zwaveNode.ping() } @@ -2801,7 +2978,10 @@ class ZwaveClient extends TypedEventEmitter { */ refreshCCValues(nodeId: number, cc: CommandClasses): Promise { if (this.driverReady) { - const zwaveNode = this.getNode(nodeId) + const zwaveNode = requireDefined( + this.getNode(nodeId), + `Z-Wave node ${nodeId} is unavailable while refreshing command class ${cc}`, + ) return zwaveNode.refreshCCValues(cc) } @@ -2856,7 +3036,10 @@ class ZwaveClient extends TypedEventEmitter { private async _fetchConfigUpdateVersion(): Promise { if (this.driverReady) { - return this._driver.checkForConfigUpdates() + return requireDefined( + this._driverState, + 'Driver is unavailable while checking for configuration updates', + ).checkForConfigUpdates() } else { throw new DriverNotReadyError() } @@ -2916,7 +3099,10 @@ class ZwaveClient extends TypedEventEmitter { this._configPublicationEpoch++ let updated = false try { - updated = await this._driver.installConfigUpdate() + updated = await requireDefined( + this._driverState, + 'Driver is unavailable while installing a configuration update', + ).installConfigUpdate() return updated } finally { if (this._activeConfigInstall === install) { @@ -2946,7 +3132,10 @@ class ZwaveClient extends TypedEventEmitter { async shutdownZwaveAPI(): Promise { if (this.driverReady) { logger.info('Shutting down ZwaveJS driver...') - const success = await this._driver.shutdown() + const success = await requireDefined( + this._driverState, + 'Driver is unavailable while shutting down the Z-Wave API', + ).shutdown() return success } else { throw new DriverNotReadyError() @@ -2983,7 +3172,10 @@ class ZwaveClient extends TypedEventEmitter { */ pollValue(valueId: ZUIValueId): Promise { if (this.driverReady) { - const zwaveNode = this.getNode(valueId.nodeId) + const zwaveNode = requireDefined( + this.getNode(valueId.nodeId), + `Z-Wave node ${valueId.nodeId} is unavailable while polling a value`, + ) logger.debug(`Polling value ${this._getValueID(valueId)}`) @@ -3085,12 +3277,12 @@ class ZwaveClient extends TypedEventEmitter { measured0dBm: number, ): Promise { if (this.driverReady) { - const result = await this._driver.controller.setPowerlevel( - powerlevel, - measured0dBm, - ) + const result = await requireDefined( + this._driverState, + 'Driver is unavailable while setting the controller power level', + ).controller.setPowerlevel(powerlevel, measured0dBm) - await this.updateControllerNodeProps(null, ['powerlevel']) + await this.updateControllerNodeProps(undefined, ['powerlevel']) return result } @@ -3100,7 +3292,10 @@ class ZwaveClient extends TypedEventEmitter { async setRFRegion(region: RFRegion): Promise { if (this.driverReady) { - const result = await this._driver.controller.setRFRegion(region) + const result = await requireDefined( + this._driverState, + 'Driver is unavailable while setting the RF region', + ).controller.setRFRegion(region) // Determine which properties need updating const propsToUpdate: Array< @@ -3125,7 +3320,7 @@ class ZwaveClient extends TypedEventEmitter { } } - await this.updateControllerNodeProps(null, propsToUpdate) + await this.updateControllerNodeProps(undefined, propsToUpdate) return result } @@ -3134,11 +3329,11 @@ class ZwaveClient extends TypedEventEmitter { async setMaxLRPowerLevel(powerlevel: number): Promise { if (this.driverReady) { - const result = - await this._driver.controller.setMaxLongRangePowerlevel( - powerlevel, - ) - await this.updateControllerNodeProps(null, [ + const result = await requireDefined( + this._driverState, + 'Driver is unavailable while setting the maximum LR power level', + ).controller.setMaxLongRangePowerlevel(powerlevel) + await this.updateControllerNodeProps(undefined, [ 'maxLongRangePowerlevel', ]) return result @@ -3226,8 +3421,10 @@ class ZwaveClient extends TypedEventEmitter { this.sendToSocket(socketEvents.rebuildRoutesProgress, [ [nodeId, status], ]) - const result = - await this._driver.controller.rebuildNodeRoutes(nodeId) + const result = await requireDefined( + this._driverState, + `Driver is unavailable while rebuilding routes for node ${nodeId}`, + ).controller.rebuildNodeRoutes(nodeId) status = result ? 'done' : 'failed' node.rebuildRoutesProgress = status @@ -3252,13 +3449,20 @@ class ZwaveClient extends TypedEventEmitter { getPriorityReturnRoute(nodeId: number, destinationId: number) { if (!this.driverReady) throw new DriverNotReadyError() - const controllerId = this._driver.controller.ownNodeId + const controller = requireDefined( + this._driverState, + `Driver is unavailable while reading priority return routes for node ${nodeId}`, + ).controller + const controllerId = controller.ownNodeId if (!destinationId) { - destinationId = controllerId + destinationId = requireDefined( + controllerId, + 'Controller node ID is unavailable while the driver is ready', + ) } - const result = this._driver.controller.getPriorityReturnRouteCached( + const result = controller.getPriorityReturnRouteCached( nodeId, destinationId, ) @@ -3266,13 +3470,17 @@ class ZwaveClient extends TypedEventEmitter { const node = this.nodes.get(nodeId) if (node) { + const priorityReturnRoute = requireDefined( + node.priorityReturnRoute, + `Priority return routes are not initialized for node ${nodeId}`, + ) if (result) { - node.priorityReturnRoute[destinationId] = result + priorityReturnRoute[destinationId] = result } else { - delete node.priorityReturnRoute[destinationId] + delete priorityReturnRoute[destinationId] } this.emitStatistics(node, { - priorityReturnRoute: node.priorityReturnRoute, + priorityReturnRoute, }) } @@ -3290,7 +3498,10 @@ class ZwaveClient extends TypedEventEmitter { ) { if (!this.driverReady) throw new DriverNotReadyError() - const result = await this._driver.controller.assignPriorityReturnRoute( + const result = await requireDefined( + this._driverState, + `Driver is unavailable while assigning a priority return route for node ${nodeId}`, + ).controller.assignPriorityReturnRoute( nodeId, destinationNodeId, repeaters, @@ -3311,8 +3522,10 @@ class ZwaveClient extends TypedEventEmitter { if (!this.driverReady) throw new DriverNotReadyError() const result = - this._driver.controller.getPrioritySUCReturnRouteCached(nodeId) ?? - null + requireDefined( + this._driverState, + `Driver is unavailable while reading the priority SUC return route for node ${nodeId}`, + ).controller.getPrioritySUCReturnRouteCached(nodeId) ?? null const node = this.nodes.get(nodeId) @@ -3336,12 +3549,10 @@ class ZwaveClient extends TypedEventEmitter { ) { if (!this.driverReady) throw new DriverNotReadyError() - const result = - await this._driver.controller.assignPrioritySUCReturnRoute( - nodeId, - repeaters, - routeSpeed, - ) + const result = await requireDefined( + this._driverState, + `Driver is unavailable while assigning the priority SUC return route for node ${nodeId}`, + ).controller.assignPrioritySUCReturnRoute(nodeId, repeaters, routeSpeed) if (result) { // when changing the SUC priority return routes custom SUC return routes are removed @@ -3358,21 +3569,25 @@ class ZwaveClient extends TypedEventEmitter { getCustomReturnRoute(nodeId: number, destinationId: number) { if (!this.driverReady) throw new DriverNotReadyError() - const result = this._driver.controller.getCustomReturnRoutesCached( - nodeId, - destinationId, - ) + const result = requireDefined( + this._driverState, + `Driver is unavailable while reading custom return routes for node ${nodeId}`, + ).controller.getCustomReturnRoutesCached(nodeId, destinationId) const node = this.nodes.get(nodeId) if (node) { + const customReturnRoute = requireDefined( + node.customReturnRoute, + `Custom return routes are not initialized for node ${nodeId}`, + ) if (result) { - node.customReturnRoute[destinationId] = result + customReturnRoute[destinationId] = result } else { - delete node.customReturnRoute[destinationId] + delete customReturnRoute[destinationId] } this.emitStatistics(node, { - customReturnRoute: node.customReturnRoute, + customReturnRoute, }) } @@ -3390,7 +3605,10 @@ class ZwaveClient extends TypedEventEmitter { ) { if (!this.driverReady) throw new DriverNotReadyError() - const result = await this._driver.controller.assignCustomReturnRoutes( + const result = await requireDefined( + this._driverState, + `Driver is unavailable while assigning custom return routes for node ${nodeId}`, + ).controller.assignCustomReturnRoutes( nodeId, destinationNodeId, routes, @@ -3411,7 +3629,10 @@ class ZwaveClient extends TypedEventEmitter { if (!this.driverReady) throw new DriverNotReadyError() const result = - this._driver.controller.getCustomSUCReturnRoutesCached(nodeId) ?? [] + requireDefined( + this._driverState, + `Driver is unavailable while reading custom SUC return routes for node ${nodeId}`, + ).controller.getCustomSUCReturnRoutesCached(nodeId) ?? [] const node = this.nodes.get(nodeId) @@ -3435,12 +3656,10 @@ class ZwaveClient extends TypedEventEmitter { ) { if (!this.driverReady) throw new DriverNotReadyError() - const result = - await this._driver.controller.assignCustomSUCReturnRoutes( - nodeId, - routes, - priorityRoute, - ) + const result = await requireDefined( + this._driverState, + `Driver is unavailable while assigning custom SUC return routes for node ${nodeId}`, + ).controller.assignCustomSUCReturnRoutes(nodeId, routes, priorityRoute) if (result) { // when changing the SUC return routes the priority SUC return route is removed @@ -3456,8 +3675,10 @@ class ZwaveClient extends TypedEventEmitter { */ async getPriorityRoute(nodeId: number) { if (this.driverReady) { - const result = - await this._driver.controller.getPriorityRoute(nodeId) + const result = await requireDefined( + this._driverState, + `Driver is unavailable while getting the priority route for node ${nodeId}`, + ).controller.getPriorityRoute(nodeId) if (result) { const node = this.nodes.get(nodeId) @@ -3512,7 +3733,10 @@ class ZwaveClient extends TypedEventEmitter { async deleteReturnRoutes(nodeId: number) { if (!this.driverReady) throw new DriverNotReadyError() - const result = await this._driver.controller.deleteReturnRoutes(nodeId) + const result = await requireDefined( + this._driverState, + `Driver is unavailable while deleting return routes for node ${nodeId}`, + ).controller.deleteReturnRoutes(nodeId) if (result) { const node = this.nodes.get(nodeId) @@ -3536,8 +3760,10 @@ class ZwaveClient extends TypedEventEmitter { async deleteSUCReturnRoutes(nodeId: number) { if (!this.driverReady) throw new DriverNotReadyError() - const result = - await this._driver.controller.deleteSUCReturnRoutes(nodeId) + const result = await requireDefined( + this._driverState, + `Driver is unavailable while deleting SUC return routes for node ${nodeId}`, + ).controller.deleteSUCReturnRoutes(nodeId) if (result) { const node = this.nodes.get(nodeId) @@ -3561,10 +3787,10 @@ class ZwaveClient extends TypedEventEmitter { async assignReturnRoutes(nodeId: number, destinationNodeId: number) { if (!this.driverReady) throw new DriverNotReadyError() - const result = await this._driver.controller.assignReturnRoutes( - nodeId, - destinationNodeId, - ) + const result = await requireDefined( + this._driverState, + `Driver is unavailable while assigning return routes for node ${nodeId}`, + ).controller.assignReturnRoutes(nodeId, destinationNodeId) if (result) { this.getCustomReturnRoute(nodeId, destinationNodeId) @@ -3580,8 +3806,10 @@ class ZwaveClient extends TypedEventEmitter { async assignSUCReturnRoutes(nodeId: number) { if (!this.driverReady) throw new DriverNotReadyError() - const result = - await this._driver.controller.assignSUCReturnRoutes(nodeId) + const result = await requireDefined( + this._driverState, + `Driver is unavailable while assigning SUC return routes for node ${nodeId}`, + ).controller.assignSUCReturnRoutes(nodeId) if (result) { this.getCustomSUCReturnRoute(nodeId) @@ -3600,11 +3828,10 @@ class ZwaveClient extends TypedEventEmitter { routeSpeed: ZWaveDataRate, ): Promise { if (this.driverReady) { - const result = await this._driver.controller.setPriorityRoute( - nodeId, - repeaters, - routeSpeed, - ) + const result = await requireDefined( + this._driverState, + `Driver is unavailable while setting the priority route for node ${nodeId}`, + ).controller.setPriorityRoute(nodeId, repeaters, routeSpeed) if (result) { await this.getPriorityRoute(nodeId) @@ -3621,8 +3848,10 @@ class ZwaveClient extends TypedEventEmitter { */ async removePriorityRoute(nodeId: number) { if (this.driverReady) { - const result = - await this._driver.controller.removePriorityRoute(nodeId) + const result = await requireDefined( + this._driverState, + `Driver is unavailable while removing the priority route for node ${nodeId}`, + ).controller.removePriorityRoute(nodeId) if (result) { await this.getPriorityRoute(nodeId) @@ -3645,14 +3874,24 @@ class ZwaveClient extends TypedEventEmitter { if (this.isVirtualNode(nodeId)) { throw new Error(`Node ${nodeId} is a virtual node`) } - const result = await this.getNode(nodeId).checkLifelineHealth( + const targetNodeId = requireDefined( + requireDefined( + this._driverState, + `Driver is unavailable while checking lifeline health for node ${nodeId}`, + ).controller.ownNodeId, + 'Controller node ID is unavailable while the driver is ready', + ) + const result = await requireDefined( + this.getNode(nodeId), + `Z-Wave node ${nodeId} is unavailable while checking lifeline health`, + ).checkLifelineHealth( rounds, this._onHealthCheckProgress.bind(this, { nodeId, - targetNodeId: this.driver.controller.ownNodeId, + targetNodeId, }), ) - return { ...result, targetNodeId: this.driver.controller.ownNodeId } + return { ...result, targetNodeId } } throw new DriverNotReadyError() @@ -3666,7 +3905,10 @@ class ZwaveClient extends TypedEventEmitter { if (this.isVirtualNode(nodeId)) { throw new Error(`Node ${nodeId} is a virtual node`) } - const result = await this.getNode(nodeId).checkLinkReliability({ + const result = await requireDefined( + this.getNode(nodeId), + `Z-Wave node ${nodeId} is unavailable while checking link reliability`, + ).checkLinkReliability({ ...options, onProgress: (progress) => this._onLinkReliabilityCheckProgress({ nodeId }, progress), @@ -3683,7 +3925,10 @@ class ZwaveClient extends TypedEventEmitter { if (this.isVirtualNode(nodeId)) { throw new Error(`Node ${nodeId} is a virtual node`) } - this.getNode(nodeId).abortLinkReliabilityCheck() + requireDefined( + this.getNode(nodeId), + `Z-Wave node ${nodeId} is unavailable while aborting a link reliability check`, + ).abortLinkReliabilityCheck() return } @@ -3702,7 +3947,10 @@ class ZwaveClient extends TypedEventEmitter { if (this.isVirtualNode(nodeId)) { throw new Error(`Node ${nodeId} is a virtual node`) } - const zwaveNode = this.getNode(nodeId) + const zwaveNode = requireDefined( + this.getNode(nodeId), + `Z-Wave node ${nodeId} is unavailable while checking route health`, + ) const result = await zwaveNode.checkRouteHealth( targetNodeId, rounds, @@ -3748,7 +3996,10 @@ class ZwaveClient extends TypedEventEmitter { const zwaveNode = this.getNode(nodeId) // checks if a node was marked as failed in the controller - const result = await this._driver.controller.isFailedNode(nodeId) + const result = await requireDefined( + this._driverState, + `Driver is unavailable while checking whether node ${nodeId} has failed`, + ).controller.isFailedNode(nodeId) if (node) { node.failed = result } @@ -3772,7 +4023,10 @@ class ZwaveClient extends TypedEventEmitter { await backupManager.backupNvm() } - return this._driver.controller.removeFailedNode(nodeId) + return requireDefined( + this._driverState, + `Driver is unavailable while removing failed node ${nodeId}`, + ).controller.removeFailedNode(nodeId) } throw new DriverNotReadyError() @@ -3844,7 +4098,10 @@ class ZwaveClient extends TypedEventEmitter { beginRebuildingRoutes(options?: RebuildRoutesOptions): boolean { if (this.driverReady) { - return this._driver.controller.beginRebuildingRoutes(options) + return requireDefined( + this._driverState, + 'Driver is unavailable while beginning a route rebuild', + ).controller.beginRebuildingRoutes(options) } throw new DriverNotReadyError() @@ -3852,9 +4109,12 @@ class ZwaveClient extends TypedEventEmitter { stopRebuildingRoutes(): boolean { if (this.driverReady) { - const result = this._driver.controller.stopRebuildingRoutes() + const result = requireDefined( + this._driverState, + 'Driver is unavailable while stopping a route rebuild', + ).controller.stopRebuildingRoutes() if (result) { - const toReturn: [number, RebuildRoutesStatus][] = [] + const toReturn: [number, RebuildRoutesStatus | undefined][] = [] for (const [nodeId, node] of this.nodes) { if (node.rebuildRoutesProgress === 'pending') { node.rebuildRoutesProgress = 'skipped' @@ -3872,7 +4132,10 @@ class ZwaveClient extends TypedEventEmitter { async hardReset() { if (this.driverReady) { // Reject leaves lifecycle untouched so public APIs settle normally - await this._driver.hardReset() + await requireDefined( + this._driverState, + 'Driver is unavailable while performing a hard reset', + ).hardReset() this.init() this._inclusionCoordinator.reinstallUserCallbacks() } else { @@ -3882,7 +4145,10 @@ class ZwaveClient extends TypedEventEmitter { softReset() { if (this.driverReady) { - return this._driver.softReset() + return requireDefined( + this._driverState, + 'Driver is unavailable while performing a soft reset', + ).softReset() } throw new DriverNotReadyError() @@ -3929,7 +4195,10 @@ class ZwaveClient extends TypedEventEmitter { : CommandClasses[ctx.commandClass] // get the command class instance to send the command - const api = endpoint.commandClasses[commandClass] + const commandClasses = + endpoint.commandClasses as typeof endpoint.commandClasses & + Partial> + const api = commandClasses[commandClass] if (!api || !api.isSupported()) { throw Error( `Node ${ctx.nodeId}${ @@ -3938,14 +4207,20 @@ class ZwaveClient extends TypedEventEmitter { ctx.commandClass } or it has not been implemented yet`, ) - } else if (!(command in api)) { + } else if (!hasProperty(api, command)) { throw Error( `The command ${command} does not exist for CC ${ctx.commandClass}`, ) } // send the command with args - const method = api[command].bind(api) + const commandMethod = api[command] + if (typeof commandMethod !== 'function') { + throw new TypeError( + `The command ${command} for CC ${ctx.commandClass} is not callable`, + ) + } + const method = commandMethod.bind(api) const result = args ? await method(...args) : await method() return result @@ -3962,11 +4237,11 @@ class ZwaveClient extends TypedEventEmitter { async callApi( apiName: T, ...args: Parameters - ) { - let err: string, result: ReturnType - + ): Promise> { logger.log('info', 'Calling api %s with args: %o', apiName, args) + let err: unknown + let result: Awaited> | undefined if (this.driverReady || this.driver?.mode === DriverMode.Bootloader) { try { const allowed = @@ -3974,20 +4249,24 @@ class ZwaveClient extends TypedEventEmitter { allowedApis.indexOf(apiName) >= 0 if (allowed) { - result = await (this as any)[apiName](...args) + const calledResult = await Reflect.apply( + this[apiName], + this, + args, + ) + result = calledResult // custom scenes and node/location management } else { err = 'Unknown API' } - } catch (e) { - err = e.message + } catch (error) { + err = getLegacyErrorMessage(error) } } else { err = 'Z-Wave client not connected' } let toReturn: CallAPIResult - if (err) { toReturn = { success: false, @@ -4000,7 +4279,11 @@ class ZwaveClient extends TypedEventEmitter { result, } } - logger.log('info', `${toReturn.message} ${apiName} %o`, result) + logger.log( + 'info', + `${formatLegacyMessage(toReturn.message)} ${apiName} %o`, + result, + ) toReturn.args = args @@ -4017,17 +4300,19 @@ class ZwaveClient extends TypedEventEmitter { ) { if (this.driverReady) { try { - const broadcastNode = this._driver.controller.getBroadcastNode() + const broadcastNode = requireDefined( + this._driverState, + 'Driver is unavailable while writing to the broadcast node', + ).controller.getBroadcastNode() await broadcastNode.setValue(valueId, value, options) } catch (error) { logger.error( - // eslint-disable-next-line @typescript-eslint/no-base-to-string `Error while sending broadcast ${value} to CC ${ valueId.commandClass - } ${valueId.property} ${valueId.propertyKey || ''}: ${ - error.message - }`, + } ${valueId.property} ${valueId.propertyKey || ''}: ${formatLegacyErrorMessage( + error, + )}`, ) } } @@ -4045,18 +4330,21 @@ class ZwaveClient extends TypedEventEmitter { if (this.driverReady) { let fallback = false try { - const multicastGroup = - this._driver.controller.getMulticastGroup(nodes) + const multicastGroup = requireDefined( + this._driverState, + 'Driver is unavailable while writing to a multicast group', + ).controller.getMulticastGroup(nodes) await multicastGroup.setValue(valueId, value, options) } catch (error) { - fallback = error.code === ZWaveErrorCodes.CC_NotSupported + fallback = + hasZWaveErrorCode(error) && + error.code === ZWaveErrorCodes.CC_NotSupported logger.error( - // eslint-disable-next-line @typescript-eslint/no-base-to-string `Error while sending multicast ${value} to CC ${ valueId.commandClass - } ${valueId.property} ${valueId.propertyKey || ''}: ${ - error.message - }`, + } ${valueId.property} ${valueId.propertyKey || ''}: ${formatLegacyErrorMessage( + error, + )}`, ) } // try single writes requests @@ -4108,7 +4396,9 @@ class ZwaveClient extends TypedEventEmitter { } catch (error) { logger.log( 'error', - `Error while writing %o on virtual node ${valueId.nodeId}-${vID}: ${error.message}`, + `Error while writing %o on virtual node ${ + valueId.nodeId + }-${vID}: ${formatLegacyErrorMessage(error)}`, value, ) } @@ -4183,7 +4473,12 @@ class ZwaveClient extends TypedEventEmitter { const node = this.nodes.get(valueId.nodeId) - const targetValueId = node?.values[vID] + const targetValueId = node + ? requireDefined( + node.values, + `Values are not initialized for node ${valueId.nodeId}`, + )[vID] + : undefined if (targetValueId) { targetValueId.toUpdate = true @@ -4198,7 +4493,7 @@ class ZwaveClient extends TypedEventEmitter { } catch (error) { logger.log( 'error', - `Error while writing %o on ${vID}: ${error.message}`, + `Error while writing %o on ${vID}: ${formatLegacyErrorMessage(error)}`, value, ) } @@ -4258,11 +4553,15 @@ class ZwaveClient extends TypedEventEmitter { this._updateControllerStatus('Driver ready') try { + const controller = requireDefined( + this._driverState, + 'Driver is unavailable while handling the driver ready event', + ).controller // Arm only after the driver is ready so a fresh chain supersedes any prior same-generation ready this._startScheduledConfigCheck(generation) - nodeRegistry.bindControllerEvents(this.driver.controller) + nodeRegistry.bindControllerEvents(controller) - this.driver.controller + controller .on('inclusion started', this._onInclusionStarted.bind(this)) .on('exclusion started', this._onExclusionStarted.bind(this)) .on('inclusion stopped', this._onInclusionStopped.bind(this)) @@ -4294,10 +4593,14 @@ class ZwaveClient extends TypedEventEmitter { // reset retries this._driverLifecycle.resetBackoff() - this.driverInfo.homeid = this._driver.controller.homeId + const controller = requireDefined( + this._driverState, + 'Driver is unavailable while initializing the ready controller', + ).controller + this.driverInfo.homeid = controller.homeId const homeHex = '0x' + this.driverInfo?.homeid?.toString(16) this.driverInfo.name = homeHex - this.driverInfo.controllerId = this._driver.controller.ownNodeId + this.driverInfo.controllerId = controller.ownNodeId // needs home hex to be set await nodeRegistry.restorePersistedNodes( @@ -4320,7 +4623,11 @@ class ZwaveClient extends TypedEventEmitter { this._groupService.createVirtualNode(group) } - for (const [, node] of this._driver.controller.nodes) { + const currentController = requireDefined( + this._driverState, + 'Driver is unavailable while populating nodes after driver ready', + ).controller + for (const [, node] of currentController.nodes) { // node added will not be triggered if the node is in cache this._createNode(node.id) this._addNode(node) @@ -4452,7 +4759,14 @@ class ZwaveClient extends TypedEventEmitter { } this._updateControllerStatus(message) - const controllerNode = this.getNode(this.driver.controller.ownNodeId) + const controllerId = requireDefined( + requireDefined( + this._driverState, + 'Driver is unavailable during a controller status update', + ).controller.ownNodeId, + 'Controller node ID is unavailable during a controller status update', + ) + const controllerNode = this.getNode(controllerId) if (controllerNode) { this._onNodeEvent('status changed', controllerNode, status) } @@ -4654,7 +4968,9 @@ class ZwaveClient extends TypedEventEmitter { }) } - private _onRebuildRoutesDone(result) { + private _onRebuildRoutesDone( + result: ReadonlyMap, + ) { const message = `Rebuild Routes process COMPLETED. Healed ${result.size} nodes` this._updateControllerStatus(message) } @@ -4680,9 +4996,10 @@ class ZwaveClient extends TypedEventEmitter { const event = this.nvmEvent ? this.nvmEvent + '_' : '' this.nvmEvent = null - const data = await this.driver.controller.backupNVMRaw( - this._onBackupNVMProgress.bind(this), - ) + const data = await requireDefined( + this._driverState, + 'Driver is unavailable while backing up NVM', + ).controller.backupNVMRaw(this._onBackupNVMProgress.bind(this)) const fileName = `${NVM_BACKUP_PREFIX}${utils.fileDate()}${event}` @@ -4706,12 +5023,18 @@ class ZwaveClient extends TypedEventEmitter { } if (useRaw) { - await this.driver.controller.restoreNVMRaw( + await requireDefined( + this._driverState, + 'Driver is unavailable while restoring raw NVM', + ).controller.restoreNVMRaw( data, this._onRestoreNVMProgress.bind(this), ) } else { - await this.driver.controller.restoreNVM( + await requireDefined( + this._driverState, + 'Driver is unavailable while restoring NVM', + ).controller.restoreNVM( data, this._onConvertNVMProgress.bind(this), this._onRestoreNVMProgress.bind(this), @@ -4738,7 +5061,11 @@ class ZwaveClient extends TypedEventEmitter { throw new DriverNotReadyError() } - const result = this.driver.controller.getProvisioningEntries() + const driver = requireDefined( + this._driverState, + 'Driver is unavailable while getting provisioning entries', + ) + const result = driver.controller.getProvisioningEntries() for (const entry of result) { const node = this.nodes.get(entry.nodeId) @@ -4755,7 +5082,7 @@ class ZwaveClient extends TypedEventEmitter { typeof entry.productId === 'number' && typeof entry.applicationVersion === 'string' ) { - const device = await this.driver.configManager.lookupDevice( + const device = await driver.configManager.lookupDevice( entry.manufacturerId, entry.productType, entry.productId, @@ -4777,7 +5104,10 @@ class ZwaveClient extends TypedEventEmitter { throw new DriverNotReadyError() } - return this.driver.controller.getProvisioningEntry(dsk) + return requireDefined( + this._driverState, + 'Driver is unavailable while getting a provisioning entry', + ).controller.getProvisioningEntry(dsk) } unprovisionSmartStartNode(dskOrNodeId: string | number) { @@ -4785,7 +5115,10 @@ class ZwaveClient extends TypedEventEmitter { throw new DriverNotReadyError() } - this.driver.controller.unprovisionSmartStartNode(dskOrNodeId) + requireDefined( + this._driverState, + 'Driver is unavailable while removing a SmartStart provisioning entry', + ).controller.unprovisionSmartStartNode(dskOrNodeId) } async parseQRCodeString(qrString: string): Promise<{ @@ -4798,7 +5131,10 @@ class ZwaveClient extends TypedEventEmitter { let exists = false if (parsed?.dsk) { - node = this.driver.controller.getNodeByDSK(parsed.dsk) + node = requireDefined( + this._driverState, + 'Driver is unavailable while parsing a provisioning QR code', + ).controller.getNodeByDSK(parsed.dsk) if (!node) { exists = !!this.getProvisioningEntry(parsed.dsk) @@ -4826,7 +5162,11 @@ class ZwaveClient extends TypedEventEmitter { throw Error('DSK is required') } - const isNew = !this.driver.controller.getProvisioningEntry(entry.dsk) + const controller = requireDefined( + this._driverState, + 'Driver is unavailable while provisioning a SmartStart node', + ).controller + const isNew = !controller.getProvisioningEntry(entry.dsk) // disable it so user can choose the protocol to use if ( @@ -4836,7 +5176,7 @@ class ZwaveClient extends TypedEventEmitter { entry.status = ProvisioningEntryStatus.Inactive } - this.driver.controller.provisionSmartStartNode(entry) + controller.provisionSmartStartNode(entry) return entry } @@ -5120,11 +5460,21 @@ class ZwaveClient extends TypedEventEmitter { ) { const registry = this._nodeRegistry const generation = this._nodeGeneration - const driver = this._driver - node = node || registry.nodes.get(driver.controller.ownNodeId) + const driver = requireDefined( + this._driverState, + 'Driver is unavailable while updating controller node properties', + ) + const controllerId = requireDefined( + driver.controller.ownNodeId, + 'Controller node ID is unavailable while updating controller properties', + ) + node = requireDefined( + node || registry.nodes.get(controllerId), + 'Controller node is not initialized', + ) const isCurrent = () => this._isCurrentNodeRegistry(registry, generation) && - this._driver === driver && + this._driverState === driver && registry.nodes.get(node.id) === node if (props.includes('powerlevel')) { if ( @@ -5199,11 +5549,16 @@ class ZwaveClient extends TypedEventEmitter { ): ZUIValueId { const node = this._nodes.get(zwaveNode.id) const vID = this._getValueID(zwaveValue) + const value = requireDefined( + requireDefined(node, `Node ${zwaveNode.id} is not initialized`) + .values?.[vID], + `Value ${vID} is not initialized for node ${zwaveNode.id}`, + ) return NodeProjector.updateValueMetadata( zwaveNode, zwaveValue, zwaveValueMeta, - node.values[vID], + value, ) } @@ -5282,7 +5637,7 @@ class ZwaveClient extends TypedEventEmitter { // ------- Utils ------------------------ - private _parseNotification(parameters) { + private _parseNotification(parameters: unknown) { return NodeProjector.parseNotification(parameters) } @@ -5330,8 +5685,15 @@ class ZwaveClient extends TypedEventEmitter { /** * Get a valueId from a valueId object */ - private _getValueID(v: Partial, withNode = false) { - return NodeProjector.getValueId(v as TranslatedValueID, withNode) + private _getValueID(v: ValueIdRef, withNode = false) { + const propertyKey = v.propertyKey === null ? 'null' : v.propertyKey + return NodeProjector.getValueId( + { + ...v, + propertyKey, + }, + withNode, + ) } /** Arms exactly one daily config-check chain per `driver ready`, clearing any prior armed timer and superseding an in-flight check via `_configCheckChain` */ @@ -5423,7 +5785,7 @@ class ZwaveClient extends TypedEventEmitter { `Error while polling value ${this._getValueID( valueId, true, - )}: ${error.message}`, + )}: ${formatLegacyErrorMessage(error)}`, ) } @@ -5449,7 +5811,11 @@ class ZwaveClient extends TypedEventEmitter { ) { const interval = setInterval(() => { const totalFilesFragments = totalFiles * fragmentsPerFile - const progress = this.nodes.get(nodeId).firmwareUpdate ?? { + const progressNode = requireDefined( + this.nodes.get(nodeId), + `Node ${nodeId} is not initialized`, + ) + const progress = progressNode.firmwareUpdate ?? { totalFiles, currentFile: 1, sentFragments: 0, @@ -5466,7 +5832,11 @@ class ZwaveClient extends TypedEventEmitter { if (progress.currentFile > totalFiles) { let api: 'firmwareUpdateOTW' | 'firmwareUpdateOTA' - if (this.nodes.get(nodeId).isControllerNode) { + const node = requireDefined( + this.nodes.get(nodeId), + `Node ${nodeId} is not initialized`, + ) + if (node.isControllerNode) { api = 'firmwareUpdateOTW' this._onOTWFirmwareUpdateFinished({ status: OTWFirmwareUpdateStatus.OK, @@ -5475,7 +5845,13 @@ class ZwaveClient extends TypedEventEmitter { } else { api = 'firmwareUpdateOTA' this._onNodeFirmwareUpdateFinished( - this.driver.controller.nodes.get(nodeId), + requireDefined( + requireDefined( + this._driverState, + 'Driver is unavailable while emulating a firmware update', + ).controller.nodes.get(nodeId), + `Z-Wave node ${nodeId} is not initialized`, + ), { reInterview: false, status: FirmwareUpdateStatus.OK_NoRestart, @@ -5493,7 +5869,7 @@ class ZwaveClient extends TypedEventEmitter { args: [], } - this.socket.emit(socketEvents.api, result) + this.requireSocket().emit(socketEvents.api, result) clearInterval(interval) return @@ -5508,11 +5884,16 @@ class ZwaveClient extends TypedEventEmitter { if (this.nodes.get(nodeId)?.isControllerNode) { // emulate a ping to another node - Array.from(this.driver.controller.nodes.entries())[1][1] - .ping() - .catch(() => { - //noop - }) + const pingNode = requireDefined( + Array.from( + requireDefined( + this._driverState, + 'Driver is unavailable while emulating a firmware update', + ).controller.nodes.values(), + )[1], + 'No secondary Z-Wave node is available', + ) + pingNode.ping().catch(() => {}) this._onOTWFirmwareUpdateProgress({ sentFragments: progress.sentFragments, totalFragments: progress.totalFragments, @@ -5520,16 +5901,15 @@ class ZwaveClient extends TypedEventEmitter { }) } else { // emulate a ping to node - this.driver.controller.nodes - .get(nodeId) - .ping() - .catch(() => { - //noop - }) - this._onNodeFirmwareUpdateProgress( - this.driver.controller.nodes.get(nodeId), - progress, + const zwaveNode = requireDefined( + requireDefined( + this._driverState, + 'Driver is unavailable while emulating a firmware update', + ).controller.nodes.get(nodeId), + `Z-Wave node ${nodeId} is not initialized`, ) + zwaveNode.ping().catch(() => {}) + this._onNodeFirmwareUpdateProgress(zwaveNode, progress) } }, 1000) } diff --git a/api/lib/errors.ts b/api/lib/errors.ts index 04542d546f..1385aa60c9 100644 --- a/api/lib/errors.ts +++ b/api/lib/errors.ts @@ -58,10 +58,7 @@ export function getErrorMessage(value: unknown): string { } // String() invokes Symbol.toPrimitive/toString/valueOf, any of which can throw on a hostile value - const coerced = tryOrUndefined(() => - // eslint-disable-next-line @typescript-eslint/no-base-to-string -- intentional best-effort fallback for values with no useful string form - String(value), - ) + const coerced = tryOrUndefined(() => String(value)) if (typeof coerced === 'string') { return coerced } diff --git a/api/lib/zwave/DriverLifecycle.ts b/api/lib/zwave/DriverLifecycle.ts index cc0712a6d2..0c8d196dd4 100644 --- a/api/lib/zwave/DriverLifecycle.ts +++ b/api/lib/zwave/DriverLifecycle.ts @@ -53,9 +53,9 @@ export interface DriverLifecycleHost { getConfig(): ZwaveConfig getDriver(): Driver | null setDriver(driver: Driver | null): void - isDriverReady(): boolean + isDriverReady(): boolean | null | undefined /** Raw `_driverReady` field without the driver/closed checks `isDriverReady` applies */ - isDriverReadyRaw(): boolean + isDriverReadyRaw(): boolean | null | undefined isClosed(): boolean setClosed(closed: boolean): void isDestroyed(): boolean diff --git a/api/lib/zwave/NodeRegistry.ts b/api/lib/zwave/NodeRegistry.ts index ea4f1241a5..1e40962ac6 100644 --- a/api/lib/zwave/NodeRegistry.ts +++ b/api/lib/zwave/NodeRegistry.ts @@ -77,7 +77,7 @@ type StatisticsUpdate = Pick< | 'prioritySUCReturnRoute' | 'priorityReturnRoute' > & { bgRssi?: ControllerStatistics['backgroundRSSI'] } -export type NodeRegistryController = Pick< +type NodeRegistryControllerState = Pick< Driver['controller'], | 'nodes' | 'ownNodeId' @@ -86,16 +86,45 @@ export type NodeRegistryController = Pick< | 'getCustomSUCReturnRoutesCached' | 'getProvisioningEntry' | 'getSupportedRFRegions' - | 'on' - | 'off' > +interface NodeRegistryControllerEvents { + on(event: 'node found', listener: (node: FoundNode) => void): void + on( + event: 'node added', + listener: (node: ZWaveNode, result: InclusionResult) => void, + ): void + on( + event: 'node removed', + listener: (node: ZWaveNode, reason: RemoveNodeReason) => void, + ): void + on( + event: 'statistics updated', + listener: (stats: ControllerStatistics) => void, + ): void + off(event: 'node found', listener: (node: FoundNode) => void): void + off( + event: 'node added', + listener: (node: ZWaveNode, result: InclusionResult) => void, + ): void + off( + event: 'node removed', + listener: (node: ZWaveNode, reason: RemoveNodeReason) => void, + ): void + off( + event: 'statistics updated', + listener: (stats: ControllerStatistics) => void, + ): void +} + +export type NodeRegistryController = NodeRegistryControllerState & + NodeRegistryControllerEvents + export interface NodeRegistryDriver extends NodeProjectionDriver { controller: NodeRegistryController } function formatLogValue(value: unknown): string { - // eslint-disable-next-line @typescript-eslint/no-base-to-string return String(value) } @@ -166,7 +195,7 @@ export interface NodeRegistryHost { registerDevice(node: ZUINode): void throttle(key: string, callback: () => void, wait: number): void clearThrottle(key: string): void - isDriverReady(): boolean + isDriverReady(): boolean | null | undefined } export class NodeRegistry { diff --git a/api/lib/zwave/ScheduleService.ts b/api/lib/zwave/ScheduleService.ts index 7fa473447e..b3a8dcb184 100644 --- a/api/lib/zwave/ScheduleService.ts +++ b/api/lib/zwave/ScheduleService.ts @@ -3,14 +3,12 @@ import type { ScheduleEntryLockSlotId, ScheduleEntryLockWeekDaySchedule, ScheduleEntryLockYearDaySchedule, - ZWaveNode, } from 'zwave-js' import { ScheduleEntryLockCC, ScheduleEntryLockScheduleKind, UserCodeCC, UserIDStatus, - type Driver, } from 'zwave-js' import { isUnsupervisedOrSucceeded, SupervisionStatus } from '@zwave-js/core' import type { SupervisionResult } from '@zwave-js/core' @@ -24,6 +22,7 @@ import { type ScheduleNodeStorePort, type ScheduleUtilsPort, type ScheduleNodeState, + type ScheduleZWaveNodeHandle, } from './ports.ts' type AnySchedule = @@ -49,11 +48,11 @@ export class ScheduleService { this._utils = utils } - private _getZwaveNode(nodeId: number): ZWaveNode | undefined { + private _getZwaveNode(nodeId: number): ScheduleZWaveNodeHandle | undefined { return this._driver.getDriver()?.controller.nodes.get(nodeId) } - private _requireScheduleCC(nodeId: number): ZWaveNode { + private _requireScheduleCC(nodeId: number): ScheduleZWaveNodeHandle { const zwaveNode = this._getZwaveNode(nodeId) if (!zwaveNode?.commandClasses['Schedule Entry Lock'].isSupported()) { throw new Error( diff --git a/api/lib/zwave/ports.ts b/api/lib/zwave/ports.ts index 63e13ce01a..c52d9b3957 100644 --- a/api/lib/zwave/ports.ts +++ b/api/lib/zwave/ports.ts @@ -20,12 +20,14 @@ import type { JoinNetworkResult, PlannedProvisioningEntry, QRProvisioningInformation, - ZWaveOptions, + PartialZWaveOptions, RFRegion, } from 'zwave-js' import { InclusionStrategy, QRCodeVersion } from 'zwave-js' import type { FirmwareFileFormat, + EndpointId, + GetValueDB, SecurityClass, SupervisionResult, } from '@zwave-js/core' @@ -89,7 +91,7 @@ export type ZwaveConfig = { enableStatistics?: boolean disableOptimisticValueUpdate?: boolean disclaimerVersion?: number - options?: ZWaveOptions + options?: PartialZWaveOptions // healNetwork?: boolean healHour?: number logToFile?: boolean @@ -207,8 +209,35 @@ export interface TemplateNodeState { status?: string } +type ScheduleEntryLockApi = Pick< + ZWaveNode['commandClasses']['Schedule Entry Lock'], + | 'isSupported' + | 'getWeekDaySchedule' + | 'getYearDaySchedule' + | 'getDailyRepeatingSchedule' + | 'setWeekDaySchedule' + | 'setYearDaySchedule' + | 'setDailyRepeatingSchedule' + | 'setEnabled' +> + +export interface ScheduleZWaveNodeHandle { + getEndpoint(index: number): EndpointId + commandClasses: { + 'Schedule Entry Lock': ScheduleEntryLockApi + } +} + +export type ScheduleDriverHandle = GetValueDB & { + controller: { + nodes: { + get(nodeId: number): ScheduleZWaveNodeHandle | undefined + } + } +} + export interface ScheduleDriverPort { - getDriver(): Driver | null + getDriver(): ScheduleDriverHandle | null | undefined } export interface ScheduleNodeStorePort { @@ -224,11 +253,14 @@ export interface ScheduleUtilsPort { } export interface TemplateDriverPort { - getDriver(): { - controller: { - nodes: { get(nodeId: number): ZWaveNode | undefined } - } - } | null + getDriver(): + | { + controller: { + nodes: { get(nodeId: number): ZWaveNode | undefined } + } + } + | null + | undefined } export interface TemplateNodeStorePort { @@ -351,7 +383,7 @@ export interface GroupVirtualNodeHandle { } export interface GroupDriverPort { - isDriverReady(): boolean + isDriverReady(): boolean | null | undefined getOwnNodeId(): number | undefined /** True when the driver has no node list to check yet (permissive) or the id is a known physical node */ hasPhysicalNode(nodeId: number): boolean @@ -449,7 +481,7 @@ export interface AssociationDriverHandle { } export interface AssociationDriverPort { - getDriver(): AssociationDriverHandle | null + getDriver(): AssociationDriverHandle | null | undefined } export type AssociationZWaveNodeHandle = Pick @@ -497,26 +529,29 @@ export interface FwFileRef { } export interface FirmwareDriverPort { - getDriver(): { - controller: { - getAvailableFirmwareUpdates( - nodeId: number, - options?: unknown, - ): Promise - getAllAvailableFirmwareUpdates( - options?: unknown, - ): Promise> - firmwareUpdateOTA( - nodeId: number, - updateInfo: FirmwareUpdateInfo, - ): Promise - nodes: { get(nodeId: number): unknown } - } - firmwareUpdateOTW( - dataOrInfo: Uint8Array | FirmwareUpdateInfo, - ): Promise - } | null - isDriverReady(): boolean + getDriver(): + | { + controller: { + getAvailableFirmwareUpdates( + nodeId: number, + options?: unknown, + ): Promise + getAllAvailableFirmwareUpdates( + options?: unknown, + ): Promise> + firmwareUpdateOTA( + nodeId: number, + updateInfo: FirmwareUpdateInfo, + ): Promise + nodes: { get(nodeId: number): unknown } + } + firmwareUpdateOTW( + dataOrInfo: Uint8Array | FirmwareUpdateInfo, + ): Promise + } + | null + | undefined + isDriverReady(): boolean | null | undefined } export interface FirmwareNodeStorePort { @@ -571,23 +606,28 @@ export interface FirmwareExtractionPort { } export interface InclusionDriverPort { - getDriver(): { - controller: { - inclusionState: InclusionState | undefined - beginInclusion(options?: InclusionOptions): Promise - stopInclusion(): Promise - beginExclusion(options: unknown): Promise - stopExclusion(): Promise - replaceFailedNode( - nodeId: number, - options?: ReplaceNodeOptions, - ): Promise - beginJoiningNetwork(options: unknown): Promise - stopJoiningNetwork(): Promise - } - updateOptions(options: unknown): void - } | null - isDriverReady(): boolean + getDriver(): + | { + controller: { + inclusionState: InclusionState | undefined + beginInclusion(options?: InclusionOptions): Promise + stopInclusion(): Promise + beginExclusion(options: unknown): Promise + stopExclusion(): Promise + replaceFailedNode( + nodeId: number, + options?: ReplaceNodeOptions, + ): Promise + beginJoiningNetwork( + options: unknown, + ): Promise + stopJoiningNetwork(): Promise + } + updateOptions(options: unknown): void + } + | null + | undefined + isDriverReady(): boolean | null | undefined } export interface InclusionSocketPort { diff --git a/api/runtime/AppRuntime.ts b/api/runtime/AppRuntime.ts index 649043e493..7f780f4c31 100644 --- a/api/runtime/AppRuntime.ts +++ b/api/runtime/AppRuntime.ts @@ -66,7 +66,7 @@ export interface GatewayFactoryPort { } export interface AppRuntimeDeps { - getSocketServer(): SocketServer + getSocketServer(): SocketServer | undefined gateway?: GatewayPort zniffer?: ZnifferPort restarting?: boolean diff --git a/api/runtime/ports.ts b/api/runtime/ports.ts index ac6946bacc..e61b00e3ef 100644 --- a/api/runtime/ports.ts +++ b/api/runtime/ports.ts @@ -2,13 +2,11 @@ import type Gateway from '../lib/Gateway.ts' import type MqttClient from '../lib/MqttClient.ts' import type ZWaveClient from '../lib/ZwaveClient.ts' import type ZnifferManager from '../lib/ZnifferManager.ts' +import type { Driver } from 'zwave-js' export type MqttClientPort = Pick -export type ZwaveDriverPort = Pick< - ZWaveClient['driver'], - 'updateOptions' | 'updateLogConfig' -> +export type ZwaveDriverPort = Pick export type ZwaveNodesPort = Pick @@ -47,7 +45,7 @@ export type ZwaveClientPort = Omit< >, 'driver' | 'nodes' > & { - driver: ZwaveDriverPort + driver: ZwaveDriverPort | null | undefined nodes: ZwaveNodesPort } diff --git a/package.json b/package.json index 1a11c21126..d08176143e 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,9 @@ "docs:generate": "node --experimental-strip-types generateDocs.ts", "coverage": "vitest run --coverage", "coverage:server": "vitest run test/lib test/runtime --coverage --coverage.reportsDirectory=./coverage-server --coverage.include=api/**", + "typecheck": "npm-run-all 'typecheck:*'", + "typecheck:server": "tsc --project tsconfig.json --noEmit", + "typecheck:test": "tsc --project tsconfig.test.json", "build": "npm-run-all build:*", "build:server": "tsc", "build:ui": "vite build", diff --git a/test/lib/hass/DiscoveryGenerator.test.ts b/test/lib/hass/DiscoveryGenerator.test.ts index 3732226cc9..a5babdb552 100644 --- a/test/lib/hass/DiscoveryGenerator.test.ts +++ b/test/lib/hass/DiscoveryGenerator.test.ts @@ -25,6 +25,7 @@ import type { } from '#api/hass/types.ts' import { PayloadType } from '#api/lib/shared.ts' import { cleanupTestEnv, ensureTestEnv, TEST_SESSION_SECRET } from './env.ts' +import { assertDefined } from '../testUtils.ts' const GENERIC_DEVICE_CLASS_THERMOSTAT = 0x08 const GENERIC_DEVICE_CLASS_BINARY_SWITCH = 0x10 @@ -86,6 +87,7 @@ function value(overrides: Partial = {}): HassValue { endpoint: 0, property: 'currentValue', propertyName: 'currentValue', + commandClassName: 'Binary Switch', type: 'boolean', readable: true, writeable: false, @@ -105,6 +107,7 @@ function node(overrides: Partial = {}): HassNode { hassDevices: {}, deviceId: '1-2-3', deviceClass: { + basic: 0, generic: GENERIC_DEVICE_CLASS_BINARY_SWITCH, specific: BINARY_POWER_SWITCH_SPECIFIC_DEVICE_CLASS, }, @@ -157,6 +160,8 @@ function setup(options: { getStatusTopic: () => 'prefix/_CLIENTS/ZWAVE_GATEWAY/status', publish: (topic, payload, publishOptions, prefix) => { if (options.publishError !== undefined) { + // Exercise production normalization of non-Error throw values + // eslint-disable-next-line @typescript-eslint/only-throw-error throw options.publishError } published.push({ @@ -259,7 +264,7 @@ describe('DiscoveryGenerator', () => { }, }) const { generator, discovered, emitted, published } = setup({ - nodes: new Map([ + nodes: new Map([ [2, hassNode], [3, { id: 3 }], [4, node({ id: 4, virtual: true })], @@ -805,7 +810,7 @@ describe('DiscoveryGenerator', () => { const rgb = Object.values(hassNode.hassDevices).find( (candidate) => candidate.type === 'light', ) - expect(rgb).toBeDefined() + assertDefined(rgb, 'expected an RGB light discovery') expect(rgb.discovery_payload.supported_color_modes).toEqual([ 'rgb', 'onoff', @@ -901,6 +906,7 @@ describe('DiscoveryGenerator', () => { it('discovers thermostat climates and skips unsupported nodes', () => { const thermostat = node({ deviceClass: { + basic: 0, generic: GENERIC_DEVICE_CLASS_THERMOSTAT, specific: HEATING_THERMOSTAT_SPECIFIC_DEVICE_CLASS, }, @@ -958,6 +964,7 @@ describe('DiscoveryGenerator', () => { generator.discoverClimates( node({ deviceClass: { + basic: 0, generic: GENERIC_DEVICE_CLASS_THERMOSTAT, specific: HEATING_THERMOSTAT_SPECIFIC_DEVICE_CLASS, }, @@ -967,9 +974,11 @@ describe('DiscoveryGenerator', () => { expect(logWarn).toHaveBeenCalled() generator.discoverClimates(thermostat) + const deviceId = thermostat.deviceId + assertDefined(deviceId, 'thermostat fixture must have a device ID') expect( catalog - .get(thermostat.deviceId) + .get(deviceId) ?.find((candidate) => candidate.type === 'climate'), ).toMatchObject({ type: 'climate', @@ -979,10 +988,8 @@ describe('DiscoveryGenerator', () => { cool: ThermostatMode.Cool, }, }) - const firstProjection = structuredClone( - catalog.get(thermostat.deviceId), - ) + const firstProjection = structuredClone(catalog.get(deviceId)) generator.discoverClimates(thermostat) - expect(catalog.get(thermostat.deviceId)).toEqual(firstProjection) + expect(catalog.get(deviceId)).toEqual(firstProjection) }) }) diff --git a/test/lib/hass/HomeAssistantManager.test.ts b/test/lib/hass/HomeAssistantManager.test.ts index 8b550aa9bf..aa4b2157b8 100644 --- a/test/lib/hass/HomeAssistantManager.test.ts +++ b/test/lib/hass/HomeAssistantManager.test.ts @@ -60,11 +60,11 @@ function makeServer(version: string): FakeServer { * constructs exactly one fresh generation per attach. */ function makeFactories( - discovery: HassManagedDiscovery | undefined, + discovery: HassManagedDiscovery, server: HassManagedServer | undefined, ): HomeAssistantClientFactories & { - createDiscovery: Mock - createServer: Mock + createDiscovery: Mock<() => HassManagedDiscovery> + createServer: Mock<() => HassManagedServer | undefined> } { return { createDiscovery: vi.fn(() => discovery), diff --git a/test/lib/hass/customDevices.test.ts b/test/lib/hass/customDevices.test.ts index c054bcb357..559a90a6c2 100644 --- a/test/lib/hass/customDevices.test.ts +++ b/test/lib/hass/customDevices.test.ts @@ -10,6 +10,7 @@ import { } from './gatewayHarness.ts' import { ensureTestEnv } from './env.ts' import { mqttMockFactory } from './mqttMock.ts' +import { requireDefined } from './fixtures.ts' vi.mock('mqtt', () => mqttMockFactory()) @@ -69,10 +70,16 @@ it('discovers devices from custom files', async () => { harness.gw.rediscoverNode(customNode.id) harness.gw.rediscoverNode(injectedNode.id) - expect(customNode.hassDevices.sensor_from_file).toMatchObject({ + expect( + requireDefined(customNode.hassDevices, 'expected file-backed discovery') + .sensor_from_file, + ).toMatchObject({ object_id: 'from_file', }) - expect(injectedNode.hassDevices.sensor_injected).toMatchObject({ + expect( + requireDefined(injectedNode.hassDevices, 'expected injected discovery') + .sensor_injected, + ).toMatchObject({ object_id: 'injected', }) }) diff --git a/test/lib/hass/discoveryClimate.test.ts b/test/lib/hass/discoveryClimate.test.ts index 4e29fcacd4..cacd49a5c6 100644 --- a/test/lib/hass/discoveryClimate.test.ts +++ b/test/lib/hass/discoveryClimate.test.ts @@ -17,13 +17,18 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { CommandClasses } from '@zwave-js/core' import { mqttMockFactory } from './mqttMock.ts' -import { useGatewayHarness, type GatewayHarness } from './gatewayHarness.ts' +import { + useGatewayHarness, + type GatewayHarness, + type PublishedDiscovery, +} from './gatewayHarness.ts' import { buildNode, buildValueId, addValue, state } from './fixtures.ts' import type { + HassDevice, ZUINode, ZUIValueIdState, - HassDevice, } from '#api/lib/ZwaveClient.ts' +import { requireDefined } from '../testUtils.ts' vi.mock('mqtt', () => mqttMockFactory()) @@ -144,13 +149,24 @@ function initNode(node: ZUINode): void { harness.zwave.emit('nodeInited', node) } -function climatePacket(nodeName = 'Thermostat') { - return harness - .publishedDiscoveries() - .find( - (p) => - p.topic === `homeassistant/climate/${nodeName}/climate/config`, - ) +function requireHassDevice(node: ZUINode, key: string): HassDevice { + return requireDefined( + node.hassDevices?.[key], + `Expected HASS device ${key}`, + ) +} + +function climatePacket(nodeName = 'Thermostat'): PublishedDiscovery { + return requireDefined( + harness + .publishedDiscoveries() + .find( + (p) => + p.topic === + `homeassistant/climate/${nodeName}/climate/config`, + ), + `Expected climate discovery packet for ${nodeName}`, + ) } describe('climate thermostat discovery', () => { @@ -159,7 +175,7 @@ describe('climate thermostat discovery', () => { const node = buildThermostatNode({ deviceId: 'test-climate-full' }) initNode(node) - const climate = node.hassDevices['climate_climate'] + const climate = requireHassDevice(node, 'climate_climate') expect(climate).toBeDefined() expect(climate.type).toBe('climate') expect(climate.object_id).toBe('climate') @@ -272,7 +288,7 @@ describe('climate thermostat discovery', () => { }) initNode(node) - const climate = node.hassDevices['climate_climate'] + const climate = requireHassDevice(node, 'climate_climate') expect(climate).toBeDefined() // no mode CC -> modes + mode_state_template deleted, default_setpoint set expect(climate.default_setpoint).toBe('67-0-setpoint-1') @@ -363,7 +379,7 @@ describe('generic composite device discovery', () => { harness.gw.discoverDevice(node, composite) - const stored = node.hassDevices['sensor_composite'] + const stored = requireHassDevice(node, 'sensor_composite') expect(stored).toBeDefined() const p = stored.discovery_payload const base = diff --git a/test/lib/hass/discoveryScenesConfig.test.ts b/test/lib/hass/discoveryScenesConfig.test.ts index b880e729a1..68476015ac 100644 --- a/test/lib/hass/discoveryScenesConfig.test.ts +++ b/test/lib/hass/discoveryScenesConfig.test.ts @@ -16,8 +16,9 @@ import { type GatewayHarness, } from './gatewayHarness.ts' import { buildNode, buildValueId, addValue } from './fixtures.ts' -import type { ZUINode, ZUIValueId, HassDevice } from '#api/lib/ZwaveClient.ts' +import type { ZUINode, ZUIValueId } from '#api/lib/ZwaveClient.ts' import type { GatewayConfig } from '#api/lib/Gateway.ts' +import { requireDefined } from '../testUtils.ts' vi.mock('mqtt', () => mqttMockFactory()) @@ -50,9 +51,18 @@ function discover(value: ZUIValueId, node = readyNode()) { } } +function discoverExpected(value: ZUIValueId, node = readyNode()) { + const result = discover(value, node) + return { + ...result, + device: requireDefined(result.device, 'expected a discovered device'), + payload: requireDefined(result.payload, 'expected a discovery publish'), + } +} + describe('Central Scene and Scene Activation discovery', () => { it('maps a central scene to a scene_state sensor', () => { - const { device, payload } = discover( + const { device, payload } = discoverExpected( buildValueId({ commandClass: CommandClasses['Central Scene'], property: 'scene', @@ -76,7 +86,7 @@ describe('Central Scene and Scene Activation discovery', () => { it('maps a numeric Scene Activation sceneId with the plain (non-nested) template', () => { // sceneId is always a plain number on Scene Activation CC, so it takes // the default non-nested template - const { device, payload } = discover( + const { device, payload } = discoverExpected( buildValueId({ commandClass: CommandClasses['Scene Activation'], property: 'sceneId', @@ -95,7 +105,7 @@ describe('Central Scene and Scene Activation discovery', () => { // dimmingDuration (a zwave-js Duration { value, unit }) is the only // Scene Activation value carrying a unit, so it drives the nested // value_json.value.value template branch - const { device, payload } = discover( + const { device, payload } = discoverExpected( buildValueId({ commandClass: CommandClasses['Scene Activation'], property: 'dimmingDuration', @@ -115,7 +125,7 @@ describe('Configuration parameter discovery', () => { const cc = CommandClasses.Configuration it('maps a 0..1 numeric parameter to a config_switch', () => { - const { device, payload } = discover( + const { device, payload } = discoverExpected( buildValueId({ commandClass: cc, property: 3, @@ -137,7 +147,7 @@ describe('Configuration parameter discovery', () => { }) it('maps a wider numeric parameter to a config_number with min/max', () => { - const { device, payload } = discover( + const { device, payload } = discoverExpected( buildValueId({ commandClass: cc, property: 5, @@ -154,7 +164,7 @@ describe('Configuration parameter discovery', () => { }) it('omits min/max at the 1..100 defaults', () => { - const { payload } = discover( + const { payload } = discoverExpected( buildValueId({ commandClass: cc, property: 6, @@ -213,7 +223,7 @@ describe('Configuration parameter discovery', () => { values: [ { device: '111-2-3', - value: { id: '112-0-9' } as unknown as ZUIValueId, + value: buildValueId({ id: '112-0-9' }), ccConfigEnableDiscovery: true, }, ], @@ -227,7 +237,10 @@ describe('Configuration parameter discovery', () => { max: 1, }) const key = addValue(node, value) - const device = discoverValueOnNode(harness.gw, node, key) + const device = requireDefined( + discoverValueOnNode(harness.gw, node, key), + 'expected a configuration device', + ) expect(device.discovery_payload.enabled_by_default).toBe(true) }) }) @@ -251,7 +264,10 @@ describe('shared entity naming and location behavior', () => { targetValue: '37-2-targetValue', }) const key = addValue(node, current) - const device = discoverValueOnNode(harness.gw, node, key) + const device = requireDefined( + discoverValueOnNode(harness.gw, node, key), + 'expected an endpoint device', + ) expect(device.object_id).toBe('switch_2') expect(harness.lastDiscovery().payload.state_topic).toBe( 'zwave/Dev/switch_binary/endpoint_2/currentValue', @@ -264,10 +280,16 @@ describe('shared entity naming and location behavior', () => { it('indexes a duplicate type+object_id with the endpoint', () => { // Pre-seed a colliding hass device so the dedup branch triggers const node = readyNode() - node.hassDevices['binary_sensor_tamper'] = { - placeholder: true, - } as unknown as HassDevice - const { device } = discover( + requireDefined( + node.hassDevices, + 'expected the node to have a HASS device map', + ).binary_sensor_tamper = { + type: 'binary_sensor', + object_id: 'tamper', + discovery_payload: {}, + values: [], + } + const { device } = discoverExpected( buildValueId({ commandClass: CommandClasses['Binary Sensor'], property: 'Tamper', @@ -305,7 +327,10 @@ describe('shared entity naming and location behavior', () => { propertyName: 'level', }), ) - const device = discoverValueOnNode(harness.gw, node, key) + const device = requireDefined( + discoverValueOnNode(harness.gw, node, key), + 'expected a located device', + ) expect(harness.lastDiscovery().payload.state_topic).toBe( 'zwave/Kitchen/Dev/battery/endpoint_0/level', ) @@ -313,7 +338,9 @@ describe('shared entity naming and location behavior', () => { expect(harness.lastDiscovery().topic).toBe( 'homeassistant/sensor/Kitchen-Dev/battery_level/config', ) - expect(device.discovery_payload.device.name).toBe('Kitchen-Dev') + expect(device.discovery_payload.device).toMatchObject({ + name: 'Kitchen-Dev', + }) }) it('drops the location when ignoreLoc is set', async () => { diff --git a/test/lib/hass/discoverySensors.test.ts b/test/lib/hass/discoverySensors.test.ts index e87222e0a6..4fc7f40430 100644 --- a/test/lib/hass/discoverySensors.test.ts +++ b/test/lib/hass/discoverySensors.test.ts @@ -41,11 +41,27 @@ function discover(value: ZUIValueId, node = readyNode()) { return { device, node, payload: device ? harness.lastDiscovery() : null } } +function discoverExpected(value: ZUIValueId, node = readyNode()) { + const result = discover(value, node) + expect(result.device).toBeDefined() + expect(result.payload).not.toBeNull() + if (result.device === undefined || result.payload === null) { + throw new Error( + 'Expected value discovery to produce a device and payload', + ) + } + return { + device: result.device, + node: result.node, + payload: result.payload, + } +} + describe('Binary Sensor discovery', () => { const cc = CommandClasses['Binary Sensor'] it('maps the safety group (tamper) to a safety binary_sensor', () => { - const { device, payload } = discover( + const { device, payload } = discoverExpected( buildValueId({ commandClass: cc, property: 'Tamper', @@ -68,7 +84,7 @@ describe('Binary Sensor discovery', () => { }) it('maps water/contact to moisture', () => { - const { device, payload } = discover( + const { device, payload } = discoverExpected( buildValueId({ commandClass: cc, property: 'Water', @@ -80,7 +96,7 @@ describe('Binary Sensor discovery', () => { }) it('reverses payloads for the lock sensor type', () => { - const { payload } = discover( + const { payload } = discoverExpected( buildValueId({ commandClass: cc, property: 'lock', @@ -93,7 +109,7 @@ describe('Binary Sensor discovery', () => { }) it('falls back to a plain binary_sensor for unknown types', () => { - const { device, payload } = discover( + const { device, payload } = discoverExpected( buildValueId({ commandClass: cc, property: 'Motion', @@ -110,7 +126,7 @@ describe('Alarm Sensor discovery', () => { it('maps state to a problem binary_sensor with the alarm-type suffix', () => { const propertyKey = 1 // AlarmSensorType.Smoke - const { device, payload } = discover( + const { device, payload } = discoverExpected( buildValueId({ commandClass: cc, property: 'state', @@ -143,7 +159,7 @@ describe('Alarm Sensor discovery', () => { describe('Basic and Notification discovery', () => { it('maps a 2-state Access Control notification to a reversed-off lock sensor', () => { - const { device, payload } = discover( + const { device, payload } = discoverExpected( buildValueId({ commandClass: CommandClasses.Notification, property: 'Access Control', @@ -166,7 +182,7 @@ describe('Basic and Notification discovery', () => { state(1, 'detected'), state(2, 'unknown'), ] - const { device, payload } = discover( + const { device, payload } = discoverExpected( buildValueId({ commandClass: CommandClasses.Notification, property: 'Home Security', @@ -202,7 +218,7 @@ describe('Multilevel Sensor discovery', () => { const cc = CommandClasses['Multilevel Sensor'] it('maps an air-temperature sensor with unit and device class', () => { - const { device, payload } = discover( + const { device, payload } = discoverExpected( buildValueId({ commandClass: cc, property: 'Air temperature', @@ -222,7 +238,7 @@ describe('Multilevel Sensor discovery', () => { }) it('abbreviates verbose time units (seconds -> s)', () => { - const { payload } = discover( + const { payload } = discoverExpected( buildValueId({ commandClass: cc, property: 'foo', @@ -240,7 +256,7 @@ describe('Multilevel Sensor discovery', () => { commandClass: cc, property: 'reset', propertyName: 'reset', - ccSpecific: null, + ccSpecific: undefined, }), ) expect(device).toBeUndefined() @@ -249,7 +265,7 @@ describe('Multilevel Sensor discovery', () => { describe('Meter and Pulse Meter discovery', () => { it('maps an electric kWh meter with the property-suffixed object id', () => { - const { device, payload } = discover( + const { device, payload } = discoverExpected( buildValueId({ commandClass: CommandClasses.Meter, property: 'value', @@ -271,14 +287,14 @@ describe('Meter and Pulse Meter discovery', () => { commandClass: CommandClasses.Meter, property: 'reset', propertyName: 'reset', - ccSpecific: null, + ccSpecific: undefined, }), ) expect(device).toBeUndefined() }) it('maps a Pulse Meter to a pulse_meter sensor', () => { - const { device } = discover( + const { device } = discoverExpected( buildValueId({ commandClass: CommandClasses['Pulse Meter'], property: 'value', @@ -292,7 +308,7 @@ describe('Meter and Pulse Meter discovery', () => { describe('Time discovery', () => { it('maps the current time to a timestamp date sensor', () => { - const { device, payload } = discover( + const { device, payload } = discoverExpected( buildValueId({ commandClass: CommandClasses.Time, property: 'currentTime', @@ -334,7 +350,7 @@ describe('Energy Production discovery', () => { describe('Battery discovery', () => { it('maps level to a battery percentage sensor', () => { - const { device, payload } = discover( + const { device, payload } = discoverExpected( buildValueId({ commandClass: CommandClasses.Battery, property: 'level', @@ -348,7 +364,7 @@ describe('Battery discovery', () => { }) it('maps isLow to a battery binary_sensor', () => { - const { device, payload } = discover( + const { device, payload } = discoverExpected( buildValueId({ commandClass: CommandClasses.Battery, property: 'isLow', diff --git a/test/lib/hass/discoverySwitches.test.ts b/test/lib/hass/discoverySwitches.test.ts index 21d293afaf..5be203bbce 100644 --- a/test/lib/hass/discoverySwitches.test.ts +++ b/test/lib/hass/discoverySwitches.test.ts @@ -16,8 +16,15 @@ import { discoverValueOnNode, type GatewayHarness, } from './gatewayHarness.ts' -import { buildNode, buildValueId, addValue, valueMapKey } from './fixtures.ts' +import { + addValue, + buildNode, + buildValueId, + requireDefined, + valueMapKey, +} from './fixtures.ts' import type { ZUINode, ZUIValueId } from '#api/lib/ZwaveClient.ts' +import { assertDefined } from '../testUtils.ts' vi.mock('mqtt', () => mqttMockFactory()) @@ -91,7 +98,7 @@ describe('Binary Switch discovery', () => { }) const device = discoverValueOnNode(harness.gw, node, key) - expect(device).toBeDefined() + assertDefined(device, 'expected a discovered device') expect(device.type).toBe('switch') expect(device.object_id).toBe('switch') @@ -161,8 +168,12 @@ describe('Binary Switch discovery', () => { discoverValueOnNode(harness.gw, node, key) const published = harness.lastDiscovery() - expect(published.options.qos).toBe(0) - expect(published.options.retain).toBe(false) + const options = requireDefined( + published.options, + 'expected discovery publish options', + ) + expect(options.qos).toBe(0) + expect(options.retain).toBe(false) }) it('skips non-current values (targetValue alone) on switch CC', () => { @@ -189,6 +200,7 @@ describe('Binary Switch discovery', () => { const node = readyNode() const key = addCurrentTargetPair(node, { cc }) const device = discoverValueOnNode(harness.gw, node, key) + assertDefined(device, 'expected a discovered device') expect(device.type).toBe('switch') expect(device.object_id).toBe('switch') } @@ -203,6 +215,7 @@ describe('Barrier Operator discovery', () => { }) const device = discoverValueOnNode(harness.gw, node, key) + assertDefined(device, 'expected a discovered device') expect(device.type).toBe('cover') expect(device.object_id).toBe('barrier_state') @@ -241,6 +254,7 @@ describe('Multilevel Switch discovery', () => { }) const device = discoverValueOnNode(harness.gw, node, key) + assertDefined(device, 'expected a discovered device') expect(device.type).toBe('cover') expect(device.object_id).toBe('position') @@ -269,6 +283,7 @@ describe('Multilevel Switch discovery', () => { }) const device = discoverValueOnNode(harness.gw, node, key) + assertDefined(device, 'expected a discovered device') expect(device.type).toBe('light') expect(device.object_id).toBe('dimmer') @@ -301,6 +316,7 @@ describe('Multilevel Switch discovery', () => { cc: CommandClasses['Multilevel Switch'], }) const device = discoverValueOnNode(harness.gw, node, key) + assertDefined(device, 'expected a discovered device') expect(device.type).toBe('cover') expect(device.object_id).toBe('position') }) @@ -314,6 +330,7 @@ describe('Door Lock discovery', () => { }) const device = discoverValueOnNode(harness.gw, node, key) + assertDefined(device, 'expected a discovered device') expect(device.type).toBe('lock') expect(device.object_id).toBe('lock') @@ -345,6 +362,7 @@ describe('Sound Switch volume discovery', () => { const key = addValue(node, value) const device = discoverValueOnNode(harness.gw, node, key) + assertDefined(device, 'expected a discovered device') expect(device.type).toBe('light') expect(device.object_id).toBe('volume_dimmer') @@ -406,6 +424,7 @@ describe('Color Switch RGB discovery', () => { const key = addValue(node, currentColor) const device = discoverValueOnNode(harness.gw, node, key) + assertDefined(device, 'expected a discovered device') expect(device.type).toBe('light') expect(device.object_id).toBe('rgb_dimmer') diff --git a/test/lib/hass/facades.test.ts b/test/lib/hass/facades.test.ts index b0caee8ff3..bf04849eb3 100644 --- a/test/lib/hass/facades.test.ts +++ b/test/lib/hass/facades.test.ts @@ -16,7 +16,13 @@ import { cleanupGatewayHarnessEnv, createGatewayHarness, } from './gatewayHarness.ts' -import { addValue, buildNode, buildValueId, valueMapKey } from './fixtures.ts' +import { + addValue, + buildNode, + buildValueId, + requireDefined, + valueMapKey, +} from './fixtures.ts' import { ensureTestEnv } from './env.ts' import { mqttMockFactory } from './mqttMock.ts' @@ -100,12 +106,25 @@ describe('Gateway Home Assistant behavior', () => { second.gw.rediscoverNode(sibling.id) expect( - Object.values(thermostat.hassDevices).map(({ type }) => type), + Object.values( + requireDefined( + thermostat.hassDevices, + 'expected thermostat discoveries', + ), + ).map(({ type }) => type), ).toEqual(expect.arrayContaining(['sensor', 'climate'])) expect( - Object.values(sibling.hassDevices).map(({ type }) => type), + Object.values( + requireDefined( + sibling.hassDevices, + 'expected sibling discoveries', + ), + ).map(({ type }) => type), ).toEqual(['sensor']) - expect(sibling.hassDevices.sensor_configured).toMatchObject({ + expect( + requireDefined(sibling.hassDevices, 'expected sibling discoveries') + .sensor_configured, + ).toMatchObject({ object_id: 'configured', }) }) @@ -133,11 +152,13 @@ describe('Gateway Home Assistant behavior', () => { harness.zwave.nodes.set(node.id, node) harness.gw.discoverValue(node, cachedKey) expect( - Object.values(node.hassDevices).some( - ({ type }) => type === 'cover', - ), + Object.values( + requireDefined(node.hassDevices, 'expected cover discoveries'), + ).some(({ type }) => type === 'cover'), ).toBe(true) - delete node.values[valueMapKey(target)] + delete requireDefined(node.values, 'expected node values')[ + valueMapKey(target) + ] expect( harness.gw.parsePayload(BarrierState.Stopped, target, undefined), diff --git a/test/lib/hass/fixtures.ts b/test/lib/hass/fixtures.ts index e6ba3005b7..7de0c9a3ac 100644 --- a/test/lib/hass/fixtures.ts +++ b/test/lib/hass/fixtures.ts @@ -7,16 +7,20 @@ * shape-correct literal drives the real production switch without a real * `zwave-js` graph. Every builder returns a fresh object. */ -import { vi } from 'vitest' +import { vi, type Mock } from 'vitest' import { EventEmitter } from 'node:events' import type { StoreHassDevicesResult } from '#api/hass/types.ts' import type { MqttConfig } from '#api/lib/MqttClient.ts' +import type { GatewayZwave } from '#api/lib/Gateway.ts' import type { HassDevice, ZUINode, ZUIValueId, ZUIValueIdState, } from '#api/lib/ZwaveClient.ts' +import { assertDefined, requireDefined } from '../testUtils.ts' + +export { assertDefined, requireDefined } /** * A complete `MqttConfig` that stays local with `store: false` (so @@ -200,7 +204,11 @@ export function buildNode(partial: Partial = {}): ZUINode { */ export function addValue(node: ZUINode, valueId: ZUIValueId): string { const key = valueMapKey(valueId) - node.values[key] = valueId + const values = requireDefined( + node.values, + `Expected node ${node.id} to have a values map`, + ) + values[key] = valueId return key } @@ -214,19 +222,20 @@ export function addValue(node: ZUINode, valueId: ZUIValueId): string { export type FakeGatewayZwave = EventEmitter & { homeHex: string nodes: Map - updateDevice: ReturnType + updateDevice: Mock addDevice: ReturnType storeDevices: ReturnType - emitNodeUpdate: ReturnType + emitNodeUpdate: Mock getNode: ReturnType - connect: ReturnType - setPollInterval: ReturnType - writeValue: ReturnType - writeBroadcast: ReturnType - writeMulticast: ReturnType - callApi: ReturnType + connect: Mock + setPollInterval: Mock + writeValue: Mock + writeBroadcast: Mock + writeMulticast: Mock + callApi: Mock + driverFunction: Mock /** Real `Gateway.close()` awaits `zwave.close()` before closing MQTT. */ - close: ReturnType + close: Mock } export function createFakeGatewayZwave( @@ -236,26 +245,27 @@ export function createFakeGatewayZwave( return Object.assign(emitter, { homeHex: '0xabcdef01', nodes: new Map(), - updateDevice: vi.fn(), + updateDevice: vi.fn(), addDevice: vi.fn(), storeDevices: vi.fn(() => Promise.resolve({ status: 'stored', } satisfies StoreHassDevicesResult), ), - emitNodeUpdate: vi.fn(), + emitNodeUpdate: vi.fn(), getNode: vi.fn(() => undefined), - connect: vi.fn(() => Promise.resolve(undefined)), - setPollInterval: vi.fn(), - writeValue: vi.fn(() => Promise.resolve(undefined)), - writeBroadcast: vi.fn(() => Promise.resolve(undefined)), - writeMulticast: vi.fn(() => Promise.resolve(undefined)), - callApi: vi.fn(() => + connect: vi.fn(() => Promise.resolve()), + setPollInterval: vi.fn(), + writeValue: vi.fn(), + writeBroadcast: vi.fn(), + writeMulticast: vi.fn(), + callApi: vi.fn(() => Promise.resolve({ success: true, message: 'ok', result: [] }), ), - close: vi.fn(() => Promise.resolve(undefined)), + driverFunction: vi.fn(), + close: vi.fn(() => Promise.resolve(undefined)), ...overrides, - }) as FakeGatewayZwave + }) } /** Deep-clones a captured discovery payload for stable snapshot assertions. */ diff --git a/test/lib/hass/mqttLifecycle.test.ts b/test/lib/hass/mqttLifecycle.test.ts index 79462e4b93..3540cc0793 100644 --- a/test/lib/hass/mqttLifecycle.test.ts +++ b/test/lib/hass/mqttLifecycle.test.ts @@ -24,7 +24,12 @@ import { discoverValueOnNode, type GatewayHarness, } from './gatewayHarness.ts' -import { buildNode, buildValueId, addValue } from './fixtures.ts' +import { + buildNode, + buildValueId, + addValue, + requireDefined, +} from './fixtures.ts' import type { HassDevice, ZUINode } from '#api/lib/ZwaveClient.ts' import type { GatewayConfig } from '#api/lib/Gateway.ts' @@ -402,7 +407,10 @@ describe('inbound MQTT requests drive Z-Wave actions', () => { ) expect( harness.mqtt.getTopic( - harness.gw.valueTopic(node, targetValue) as string, + requireDefined( + harness.gw.valueTopic(node, targetValue), + 'expected a value topic', + ), true, ), ).toBe(setTopic) @@ -441,10 +449,12 @@ describe('inbound MQTT requests drive Z-Wave actions', () => { // Feedback echoed to the same topic without /set, carrying the exact // request body back verbatim (broadcast re-publishes the parsed payload) - const echoed = harness.broker.published.find( - (p) => p.topic === `zwave/_CLIENTS/${cid}/broadcast`, + const echoed = requireDefined( + harness.broker.published.find( + (p) => p.topic === `zwave/_CLIENTS/${cid}/broadcast`, + ), + 'expected broadcast feedback', ) - expect(echoed).toBeDefined() expect(JSON.parse(echoed.payload)).toEqual(payload) }) @@ -496,8 +506,10 @@ describe('inbound MQTT requests drive Z-Wave actions', () => { expect(harness.zwave.callApi).toHaveBeenCalledWith('getNodes') const ackTopic = `zwave/_CLIENTS/${cid}/api/getNodes` - const ack = harness.broker.published.find((p) => p.topic === ackTopic) - expect(ack).toBeDefined() + const ack = requireDefined( + harness.broker.published.find((p) => p.topic === ackTopic), + 'expected API acknowledgement', + ) // Exact wire body: the callApi result verbatim plus the request as origin expect(JSON.parse(ack.payload)).toEqual({ ...apiResult, diff --git a/test/lib/hass/mqttMock.ts b/test/lib/hass/mqttMock.ts index 7bd8f428d6..d952d70f9d 100644 --- a/test/lib/hass/mqttMock.ts +++ b/test/lib/hass/mqttMock.ts @@ -57,11 +57,8 @@ export interface FakeBroker extends EventEmitter { /** Queued deferred subscribe callbacks awaiting `flushSubscribes`. */ pendingSubscribes: Array<{ topic: string - options: Record | undefined - cb?: ( - err: Error | null, - granted: { topic: string; qos: number }[], - ) => void + options: IClientSubscribeOptions | undefined + cb: Parameters[2] }> publish( topic: string, @@ -114,6 +111,14 @@ export interface FakeBroker extends EventEmitter { triggerReconnect(): void } +type ErrorCallback = (err?: Error) => void + +function isErrorCallback( + value: Parameters[2], +): value is ErrorCallback { + return typeof value === 'function' +} + /** * Every `FakeBroker` a `connect()` produced, in creation order; shared with * the importing test file. `resetMqttBrokers()` clears it between tests. @@ -174,7 +179,7 @@ export function createFakeBroker(): FakeBroker { // `'connect'`; only inbound `deliver()` is connection/subscription gated. let opts: IClientPublishOptions | undefined let callback: ((err?: Error) => void) | undefined - if (typeof options === 'function') { + if (isErrorCallback(options)) { callback = options } else { opts = options @@ -209,9 +214,7 @@ export function createFakeBroker(): FakeBroker { if (err) { p.cb?.(err, []) } else { - p.cb?.(null, [ - { topic: p.topic, qos: (p.options?.qos as number) ?? 0 }, - ]) + p.cb?.(null, [{ topic: p.topic, qos: p.options?.qos ?? 0 }]) } } } @@ -226,7 +229,7 @@ export function createFakeBroker(): FakeBroker { broker.subscribed = broker.subscribed.filter( (s) => !topics.includes(s.topic), ) - const callback = typeof options === 'function' ? options : cb + const callback = isErrorCallback(options) ? options : cb callback?.() return broker } diff --git a/test/lib/http/sessionSerialization.test.ts b/test/lib/http/sessionSerialization.test.ts index 95de8e205b..dabad315eb 100644 --- a/test/lib/http/sessionSerialization.test.ts +++ b/test/lib/http/sessionSerialization.test.ts @@ -33,7 +33,9 @@ function findSessionForUsername( username: string, ): SessionFile { const match = sessions.find((s) => s.user?.username === username) - expect(match).toBeDefined() + if (!match) { + throw new Error(`Expected a session for ${username}`) + } return match } @@ -79,6 +81,9 @@ describe('session store serialization', () => { // PUT /api/password assigns the full User record, including the freshly-hashed password, to req.session.user const sessions = await readAllSessionFiles() const match = findSessionForUsername(sessions, 'session-pw-user') + if (!match.user) { + throw new Error('Expected the persisted session to contain a user') + } expect(match.user).toHaveProperty('passwordHash') expect(typeof match.user.passwordHash).toBe('string') diff --git a/test/lib/jsonStore.test.ts b/test/lib/jsonStore.test.ts index 516c35a051..cf0b04f21e 100644 --- a/test/lib/jsonStore.test.ts +++ b/test/lib/jsonStore.test.ts @@ -6,21 +6,141 @@ * real disk I/O through StorageHelper itself) is enough to trigger that, so * it must be a dynamic import() after ensureTestEnv() (see shared/env.ts) */ -import { - describe, - it, - expect, - beforeEach, - beforeAll, - afterAll, - vi, -} from 'vitest' -import type { StorageHelper as StorageHelperClass } from '#api/lib/jsonStore.ts' -import type { StoreFile, StoreKeys } from '#api/config/store.ts' +import { describe, it, expect, beforeEach, beforeAll, afterAll } from 'vitest' +import type { + JFReadOptions, + JFWriteOptions, + Path, + ReadCallback, + WriteCallback, +} from 'jsonfile' +import type { + StorageHelper as StorageHelperClass, + StorageHelperDeps, + StoreConfig, +} from '#api/lib/jsonStore.ts' +import type { StoreFile } from '#api/config/store.ts' import { ensureTestEnv, cleanupTestEnv } from './shared/env.ts' let StorageHelper: typeof StorageHelperClass +type ReadFile = NonNullable +type WriteFile = NonNullable + +function toNodeError(error: unknown): NodeJS.ErrnoException { + if (error instanceof Error) return error + if (typeof error === 'string') return new Error(error) + return new Error('Unknown fake file-system error') +} + +function createReadFile(getResult: () => Promise): ReadFile { + function readFile( + _file: Path, + _options: JFReadOptions, + callback: ReadCallback, + ): void + function readFile(_file: Path, callback: ReadCallback): void + function readFile(_file: Path, _options?: JFReadOptions): Promise + function readFile( + _file: Path, + optionsOrCallback?: JFReadOptions | ReadCallback, + callback?: ReadCallback, + ): void | Promise { + const resolvedCallback = + typeof optionsOrCallback === 'function' + ? optionsOrCallback + : callback + const result = getResult() + if (resolvedCallback) { + void result.then( + (data) => resolvedCallback(null, data), + (error: unknown) => + resolvedCallback(toNodeError(error), undefined), + ) + return + } + return result + } + return readFile +} + +function readFileResolves(data: unknown): ReadFile { + return createReadFile(() => Promise.resolve(data)) +} + +function readFileRejects(error: unknown): ReadFile { + return createReadFile(() => Promise.reject(toNodeError(error))) +} + +function createWriteFile(getResult: () => Promise): WriteFile { + function writeFile( + _file: Path, + _data: unknown, + _options: JFWriteOptions, + callback: WriteCallback, + ): void + function writeFile( + _file: Path, + _data: unknown, + callback: WriteCallback, + ): void + function writeFile( + _file: Path, + _data: unknown, + _options?: JFWriteOptions, + ): Promise + function writeFile( + _file: Path, + _data: unknown, + optionsOrCallback?: JFWriteOptions | WriteCallback, + callback?: WriteCallback, + ): void | Promise { + const resolvedCallback = + typeof optionsOrCallback === 'function' + ? optionsOrCallback + : callback + const result = getResult() + if (resolvedCallback) { + void result.then( + () => resolvedCallback(null), + (error: unknown) => resolvedCallback(toNodeError(error)), + ) + return + } + return result + } + return writeFile +} + +function writeFileResolves(): WriteFile { + return createWriteFile(() => Promise.resolve()) +} + +function writeFileRejects(error: unknown): WriteFile { + return createWriteFile(() => Promise.reject(toNodeError(error))) +} + +function createStoreConfig( + config: StoreFile, +): StoreConfig & { settings: StoreFile } { + return { + settings: config, + scenes: config, + nodes: config, + users: config, + groups: config, + configurationTemplates: config, + } +} + +async function loadFile( + mod: StorageHelperClass, + config: StoreFile, +): Promise { + await mod.init(createStoreConfig(config)) + return mod.store[config.file] +} + beforeAll(async () => { ensureTestEnv() ;({ StorageHelper } = await import('#api/lib/jsonStore.ts')) @@ -36,51 +156,45 @@ describe('#jsonStore', () => { it("doesn't throw error", async () => { const mod = new StorageHelper({ - readFile: vi.fn().mockRejectedValue(Error('FOO')) as any, + readFile: readFileRejects(Error('FOO')), }) - await expect(mod._getFile(config)).resolves.toBeDefined() + await expect(loadFile(mod, config)).resolves.toBeDefined() }) it('data returned', async () => { - const toReturn = { - file: 'foo', - data: { bar: 'mybar', a: 'a', b: 'c' }, - } + const data = { bar: 'mybar', a: 'a', b: 'c' } const mod = new StorageHelper({ - readFile: vi.fn().mockResolvedValue(toReturn.data) as any, + readFile: readFileResolves(data), }) - await expect(mod._getFile(config)).resolves.toEqual({ - file: toReturn.file, - data: { ...toReturn.data, ...config.default }, + await expect(loadFile(mod, config)).resolves.toEqual({ + ...data, + ...config.default, }) }) it('no data, return default', async () => { const mod = new StorageHelper({ - readFile: vi.fn().mockResolvedValue(null) as any, - }) - await expect(mod._getFile(config)).resolves.toEqual({ - file: 'foo', - data: config.default, + readFile: readFileResolves(null), }) + await expect(loadFile(mod, config)).resolves.toEqual(config.default) }) it('file not found, return default', async () => { const mod = new StorageHelper({ - readFile: vi.fn().mockRejectedValue({ code: 'ENOENT' }) as any, - }) - await expect(mod._getFile(config)).resolves.toEqual({ - file: 'foo', - data: config.default, + readFile: readFileRejects( + Object.assign(new Error('not found'), { code: 'ENOENT' }), + ), }) + await expect(loadFile(mod, config)).resolves.toEqual(config.default) }) }) describe('#StorageHelper', () => { - const fakeStore = { - settings: { file: 'settings.json', default: {} }, - } as Record + const fakeStore = createStoreConfig({ + file: 'settings.json', + default: {}, + }) it('class test', () => { const jsonStore = new StorageHelper() expect(jsonStore.store).to.deep.equal({}) @@ -90,7 +204,7 @@ describe('#jsonStore', () => { it('ok', async () => { const data = { foo: 'bar' } const mod = new StorageHelper({ - readFile: vi.fn().mockResolvedValue(data) as any, + readFile: readFileResolves(data), }) await mod.init(fakeStore) @@ -102,7 +216,7 @@ describe('#jsonStore', () => { it('error', async () => { const mod = new StorageHelper({ - readFile: vi.fn().mockRejectedValue(Error('foo')) as any, + readFile: readFileRejects(Error('foo')), }) await mod.init(fakeStore) @@ -118,43 +232,38 @@ describe('#jsonStore', () => { beforeEach(async () => { mod = new StorageHelper({ - readFile: vi.fn().mockResolvedValue('bar') as any, + readFile: readFileResolves('bar'), }) await mod.init(fakeStore) }) it('known', () => - expect( - mod.get({ file: fakeStore.settings.file } as StoreFile), - ).to.equal(fakeStore.settings.default)) - it('unknown', () => { - try { - mod.get({ file: 'unknown' } as StoreFile) - } catch (error) { - return expect((error as Error).message).to.equal( - 'Requested file not present in store: unknown', - ) - } - }) + expect(mod.get(fakeStore.settings)).to.equal( + fakeStore.settings.default, + )) + it('unknown', () => + expect(() => mod.get({ file: 'unknown', default: {} })).toThrow( + 'Requested file not present in store: unknown', + )) }) describe('#put()', () => { it('ok', async () => { const mod = new StorageHelper({ - writeFile: vi.fn().mockResolvedValue('bar') as any, + writeFile: writeFileResolves(), }) await expect( - mod.put({ file: 'foo' } as StoreFile, 'bardata'), + mod.put({ file: 'foo', default: '' }, 'bardata'), ).resolves.toBe('bardata') }) it('error', async () => { const mod = new StorageHelper({ - writeFile: vi.fn().mockRejectedValue(Error('foo')) as any, + writeFile: writeFileRejects(Error('foo')), }) await expect( - mod.put({ file: 'foo' } as StoreFile, ''), + mod.put({ file: 'foo', default: '' }, ''), ).rejects.toThrow('foo') }) }) diff --git a/test/lib/shared/authHelpers.ts b/test/lib/shared/authHelpers.ts index 7ca9118dec..55a63bb40d 100644 --- a/test/lib/shared/authHelpers.ts +++ b/test/lib/shared/authHelpers.ts @@ -3,13 +3,12 @@ import jwt from 'jsonwebtoken' import { TEST_SESSION_SECRET } from './env.ts' import { hashPsw } from '#api/lib/utils.ts' +import type { StorageHelper } from '#api/lib/jsonStore.ts' +import type storeConfig from '#api/config/store.ts' export interface JsonStoreLike { - jsonStore: { - get: (model: { file: string }) => unknown - put: (model: { file: string }, data: unknown) => Promise - } - store: Record + jsonStore: Pick + store: Pick } export interface TestUser { diff --git a/test/lib/shared/fakes.ts b/test/lib/shared/fakes.ts index d77238e7f8..41bed5d9b7 100644 --- a/test/lib/shared/fakes.ts +++ b/test/lib/shared/fakes.ts @@ -50,7 +50,7 @@ export interface FakeZwaveClient extends ZwaveClientPort { } export function createFakeZwaveClient( - overrides: Partial = {}, + overrides: Partial = {}, ): FakeZwaveClient { return { devices: { 2: { name: 'Fake device' } }, @@ -115,7 +115,7 @@ export interface FakeMqttClient extends MqttClientPort { } export function createFakeMqttClient( - overrides: Partial = {}, + overrides: Partial = {}, ): FakeMqttClient { return { getStatus: vi.fn(() => ({ @@ -141,8 +141,22 @@ export interface FakeGateway extends GatewayPort { adoptDiscoveryManager: Mock } +export type FakeGatewayWithClients = FakeGateway & { + zwave: FakeZwaveClient + mqtt: FakeMqttClient +} + +export function createFakeGateway( + overrides: Partial & { zwave: undefined }, +): FakeGateway +export function createFakeGateway( + overrides?: Partial> & { + zwave?: FakeZwaveClient + mqtt?: FakeMqttClient + }, +): FakeGatewayWithClients export function createFakeGateway( - overrides: Partial = {}, + overrides: Partial = {}, ): FakeGateway { return { zwave: createFakeZwaveClient(), diff --git a/test/lib/socket/callApi.test.ts b/test/lib/socket/callApi.test.ts index b0e2c469f4..1dd47800b6 100644 --- a/test/lib/socket/callApi.test.ts +++ b/test/lib/socket/callApi.test.ts @@ -30,6 +30,20 @@ describe('Socket contract: callApi()', () => { return new ZWaveClient({}, harness.io) } + function callApiAtRuntime( + zwave: ZWaveClientType, + api: unknown, + ...args: unknown[] + ): Promise<{ + success: boolean + message: string + args: unknown[] + result?: unknown + }> { + const callApi = zwave.callApi.bind(zwave) + return Reflect.apply(callApi, undefined, [api, ...args]) + } + describe('callApi() dispatch', () => { it('success: calls a real allowed method and returns its result, message, and echoed args', async () => { const zwave = await realZwave() @@ -52,11 +66,7 @@ describe('Socket contract: callApi()', () => { zwave['_driver'] = {} as unknown as Driver zwave.driverReady = true - const res = await zwave.callApi( - 'notARealApiName' as unknown as Parameters< - ZWaveClientType['callApi'] - >[0], - ) + const res = await callApiAtRuntime(zwave, 'notARealApiName') expect(res).toStrictEqual({ success: false, @@ -71,9 +81,7 @@ describe('Socket contract: callApi()', () => { zwave.driverReady = true // init is a real ZWaveClient method intentionally excluded from allowedApis, so callApi must reject it exactly like a nonexistent name - const res = await zwave.callApi( - 'init' as unknown as Parameters[0], - ) + const res = await callApiAtRuntime(zwave, 'init') expect(res).toStrictEqual({ success: false, @@ -149,6 +157,8 @@ describe('Socket contract: callApi()', () => { expect(res.success).toBe(true) expect(res.message).toBe('Success zwave api call') + if (!('result' in res)) + throw new Error('Expected successful API result') expect(res.result).toBeUndefined() // A present-but-undefined key and an absent key both read back as undefined above expect('result' in res).toBe(true) @@ -160,11 +170,7 @@ describe('Socket contract: callApi()', () => { zwave['_driver'] = {} as unknown as Driver zwave.driverReady = true - const res = await zwave.callApi( - 'notARealApiName' as unknown as Parameters< - ZWaveClientType['callApi'] - >[0], - ) + const res = await callApiAtRuntime(zwave, 'notARealApiName') expect(res.success).toBe(false) expect('result' in res).toBe(false) @@ -219,10 +225,9 @@ describe('Socket contract: callApi()', () => { zwave['_driver'] = {} as unknown as Driver zwave.driverReady = true - const res = await zwave.callApi( - 'notARealApiName' as unknown as Parameters< - ZWaveClientType['callApi'] - >[0], + const res = await callApiAtRuntime( + zwave, + 'notARealApiName', 'a', 'b', 3, @@ -234,7 +239,7 @@ describe('Socket contract: callApi()', () => { it('argument echo: `args` is echoed back even on the error path (disconnected)', async () => { const zwave = await realZwave() - const res = await zwave.callApi('_getScenes', 'unused' as never) + const res = await callApiAtRuntime(zwave, '_getScenes', 'unused') expect(res.args).toEqual(['unused']) }) diff --git a/test/lib/socket/fakes.ts b/test/lib/socket/fakes.ts index 69dd3b8495..4f03a91319 100644 --- a/test/lib/socket/fakes.ts +++ b/test/lib/socket/fakes.ts @@ -1,5 +1,4 @@ // Extends the shared base fakes with the members only the Socket.IO wiring reads, so both suites share one FakeGateway/FakeZwaveClient shape -import { vi } from 'vitest' import { createFakeGateway as createSharedFakeGateway, createFakeMqttClient, @@ -18,32 +17,37 @@ export { type FakeZniffer, } -// setUserCallbacks/removeUserCallbacks fire on first-connect/last-disconnect in the 'clients' handler -export interface FakeZwaveClient extends SharedFakeZwaveClient { - setUserCallbacks: ReturnType - removeUserCallbacks: ReturnType -} +export type FakeZwaveClient = SharedFakeZwaveClient export function createFakeZwaveClient( overrides: Partial = {}, ): FakeZwaveClient { - return { - ...createSharedFakeZwaveClient(overrides), - setUserCallbacks: vi.fn(), - removeUserCallbacks: vi.fn(), - ...overrides, - } + return createSharedFakeZwaveClient(overrides) } export interface FakeGateway extends SharedFakeGateway { zwave?: FakeZwaveClient } +export type FakeGatewayWithClient = FakeGateway & { + zwave: FakeZwaveClient +} + +export function createFakeGateway( + overrides: Partial & { + zwave: undefined + }, +): FakeGateway +export function createFakeGateway( + overrides?: Partial> & { + zwave?: FakeZwaveClient + }, +): FakeGatewayWithClient export function createFakeGateway( overrides: Partial = {}, ): FakeGateway { return { - ...createSharedFakeGateway(overrides as Partial), + ...createSharedFakeGateway(), zwave: createFakeZwaveClient(), ...overrides, } diff --git a/test/lib/socket/helpers.ts b/test/lib/socket/helpers.ts index 66425995ee..d109389e82 100644 --- a/test/lib/socket/helpers.ts +++ b/test/lib/socket/helpers.ts @@ -88,6 +88,9 @@ export function barrier( harness: SocketHarness, client: ClientSocket, ): Promise { + if (!client.id) { + throw new Error('Cannot create a barrier for a disconnected client') + } const arrived = waitForEvent(client, '__TEST_BARRIER__') harness.io.to(client.id).emit('__TEST_BARRIER__') return arrived.then(() => undefined) diff --git a/test/lib/socket/outboundProducers.test.ts b/test/lib/socket/outboundProducers.test.ts index 67e898badf..b330fcc524 100644 --- a/test/lib/socket/outboundProducers.test.ts +++ b/test/lib/socket/outboundProducers.test.ts @@ -628,70 +628,6 @@ describe('Socket contract: outbound producers', () => { await znifferManager.close() } }) - - it('a real "frame" event -> parseFrame() -> ZNIFFER_FRAME on "znifferFrames", uncorrupted (has `protocol`)', async () => { - const harness = await getHarness({ gateway: benignGateway() }) - const znifferManager = new ZnifferManager( - { enabled: true, port: '/dev/ttyFAKE' }, - harness.io, - ) - try { - const client = await connectedSubscriber( - harness, - 'znifferFrames', - ) - const received = waitForEvent<{ - corrupted: boolean - protocol: string - raw: string - }>(client, 'ZNIFFER_FRAME') - - const rawData = Uint8Array.from([0xaa, 0xbb, 0xcc]) - // A non-corrupted Frame only needs a protocol key for parseFrame()'s check, so omitting payload keeps buffer2hex() out of the picture for this variant - const frame = { protocol: 'Z-Wave' } - znifferManager['zniffer'].emit('frame', frame, rawData) - - const data = await received - expect(data.corrupted).toBe(false) - expect(data.protocol).toBe('Z-Wave') - expect(data.raw).toBe(buffer2hex(rawData)) - } finally { - await znifferManager.close() - } - }) - - it('a real "corrupted frame" event -> parseFrame() -> ZNIFFER_FRAME, marked corrupted (no `protocol`)', async () => { - const harness = await getHarness({ gateway: benignGateway() }) - const znifferManager = new ZnifferManager( - { enabled: true, port: '/dev/ttyFAKE' }, - harness.io, - ) - try { - const client = await connectedSubscriber( - harness, - 'znifferFrames', - ) - const received = waitForEvent<{ - corrupted: boolean - raw: string - }>(client, 'ZNIFFER_FRAME') - - const rawData = Uint8Array.from([0x01]) - // A corrupted frame has no protocol key at all - const frame = { reason: 'bad checksum' } - znifferManager['zniffer'].emit( - 'corrupted frame', - frame, - rawData, - ) - - const data = await received - expect(data.corrupted).toBe(true) - expect(data.raw).toBe(buffer2hex(rawData)) - } finally { - await znifferManager.close() - } - }) }) }) }) diff --git a/test/lib/testUtils.ts b/test/lib/testUtils.ts new file mode 100644 index 0000000000..df9ed12bca --- /dev/null +++ b/test/lib/testUtils.ts @@ -0,0 +1,36 @@ +export interface Deferred { + promise: Promise + resolve: (value: T | PromiseLike) => void + reject: (reason?: unknown) => void +} + +export function createDeferred(): Deferred { + let resolvePromise: Deferred['resolve'] = () => { + throw new Error('Deferred promise resolved before initialization') + } + let rejectPromise: Deferred['reject'] = () => { + throw new Error('Deferred promise rejected before initialization') + } + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + }) + return { promise, resolve: resolvePromise, reject: rejectPromise } +} + +export function requireDefined( + value: T, + message = 'Expected value to be defined', +): NonNullable { + if (value === undefined || value === null) { + throw new TypeError(message) + } + return value +} + +export function assertDefined( + value: T, + message = 'Expected value to be defined', +): asserts value is NonNullable { + requireDefined(value, message) +} diff --git a/test/lib/utils.test.ts b/test/lib/utils.test.ts index 808d9f9981..bca14d27f6 100644 --- a/test/lib/utils.test.ts +++ b/test/lib/utils.test.ts @@ -145,7 +145,10 @@ describe('#utils', () => { afterEach(async () => { while (tmpDirs.length) { - await rm(tmpDirs.pop(), { recursive: true, force: true }) + const dir = tmpDirs.pop() + if (dir !== undefined) { + await rm(dir, { recursive: true, force: true }) + } } }) @@ -303,6 +306,13 @@ describe('#utils', () => { }) describe('#isValidOperation()', () => { + function callStringPredicateWithUnknown( + predicate: (value: string) => boolean, + value: unknown, + ): boolean { + return Reflect.apply(predicate, undefined, [value]) + } + it('accepts a single operator and number', () => { expect(isValidOperation('/10')).to.equal(true) expect(isValidOperation('*100')).to.equal(true) @@ -316,7 +326,9 @@ describe('#utils', () => { }) it('rejects empty, non-string and malformed operations', () => { expect(isValidOperation('')).to.equal(false) - expect(isValidOperation(undefined)).to.equal(false) + expect( + callStringPredicateWithUnknown(isValidOperation, undefined), + ).to.equal(false) expect(isValidOperation('10')).to.equal(false) expect(isValidOperation('/')).to.equal(false) }) diff --git a/test/lib/zwave/AssociationService.test.ts b/test/lib/zwave/AssociationService.test.ts index 9fd8fa8959..c2f510965d 100644 --- a/test/lib/zwave/AssociationService.test.ts +++ b/test/lib/zwave/AssociationService.test.ts @@ -11,6 +11,7 @@ import type { AssociationNodeState, AssociationZWaveNodeHandle, } from '../../../api/lib/zwave/ports.ts' +import { createDeferred } from '../testUtils.ts' function makeZWaveNode( overrides: Partial<{ @@ -117,20 +118,6 @@ function createService( return { service, driver, nodeStore, log } } -function createDeferred(): { - promise: Promise - resolve: (value: T | PromiseLike) => void - reject: (reason?: unknown) => void -} { - let resolve!: (value: T | PromiseLike) => void - let reject!: (reason?: unknown) => void - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - return { promise, resolve, reject } -} - function createMutableDriverPort( initial: AssociationDriverHandle | null, ): AssociationDriverPort & { diff --git a/test/lib/zwave/ConfigurationTemplateService.test.ts b/test/lib/zwave/ConfigurationTemplateService.test.ts index 82c01a5c93..c62b7eee7d 100644 --- a/test/lib/zwave/ConfigurationTemplateService.test.ts +++ b/test/lib/zwave/ConfigurationTemplateService.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest' import { ConfigurationTemplateService } from '#api/lib/zwave/ConfigurationTemplateService.ts' import type { ZUIConfigurationTemplate, ZUIConfigurationTemplateValue, TemplateNodeState, + TemplateNodeStorePort, TemplateDriverPort, TemplateConfigManagerPort, } from '#api/lib/zwave/ports.ts' @@ -60,6 +61,9 @@ function createNodeStorePort( nodes: Map = new Map(), storeNodes: Record> = {}, ) { + const writeValue: Mock = vi + .fn() + .mockResolvedValue({ status: SetValueStatus.Success }) return { getNode: vi.fn((id: number) => nodes.get(id)), getNodes: () => nodes.entries(), @@ -69,14 +73,10 @@ function createNodeStorePort( }), updateStoreNodes: vi.fn(() => Promise.resolve()), emitNodeUpdate: vi.fn(), - writeValue: vi.fn(() => - Promise.resolve({ - status: SetValueStatus.Success, - }), - ), + writeValue, logNode: vi.fn(), throttle: vi.fn((_key: string, fn: () => unknown) => fn()), - } + } satisfies TemplateNodeStorePort } function makeNode( @@ -680,7 +680,7 @@ describe('ConfigurationTemplateService', () => { callIdx++ if (callIdx === 2) { return Promise.resolve({ - status: SetValueStatus.Fail, + status: SetValueStatus.InvalidValue, message: 'Custom error message', }) } @@ -1120,7 +1120,6 @@ describe('ConfigurationTemplateService', () => { description: 'Wake duration', valueSize: 1, allowed: [], - readOnly: false, writeOnly: undefined, minValue: 0, maxValue: 255, @@ -1503,7 +1502,7 @@ describe('ConfigurationTemplateService', () => { const nodeStore = createNodeStorePort(nodes) nodeStore.writeValue = vi.fn(() => Promise.resolve({ - status: SetValueStatus.Fail, + status: SetValueStatus.InvalidValue, message: 'Custom error message', }), ) @@ -1538,7 +1537,7 @@ describe('ConfigurationTemplateService', () => { callCount++ if (callCount > 1) { return Promise.resolve({ - status: SetValueStatus.Fail, + status: SetValueStatus.InvalidValue, message: 'Failed', }) } @@ -1864,7 +1863,9 @@ describe('ConfigurationTemplateService', () => { const importedTemplate = templates.find( (t) => t.name === 'Imported', ) - expect(importedTemplate).toBeDefined() + if (!importedTemplate) { + throw new Error('Expected the imported template') + } expect(importedTemplate.id).not.toBe('existing-id') }) }) @@ -1968,8 +1969,7 @@ describe('ConfigurationTemplateService', () => { ) await svc.applyConfigurationTemplate(template.id, 2) - expect(node.appliedTemplateContentHashes).toBeDefined() - expect(node.appliedTemplateContentHashes.length).toBe(1) + expect(node.appliedTemplateContentHashes).toHaveLength(1) }) }) diff --git a/test/lib/zwave/DriverLifecycle.test.ts b/test/lib/zwave/DriverLifecycle.test.ts index 64280a1414..899bfeb998 100644 --- a/test/lib/zwave/DriverLifecycle.test.ts +++ b/test/lib/zwave/DriverLifecycle.test.ts @@ -14,7 +14,7 @@ import { CONTROLLER_LOGLEVEL, } from '@zwave-js/core' import { RFRegion } from 'zwave-js' -import type { Driver, ZWaveOptions, PartialZWaveOptions } from 'zwave-js' +import type { Driver, PartialZWaveOptions } from 'zwave-js' import type { JSONTransport } from '@zwave-js/log-transport-json' import type { ZwavejsServer } from '@zwave-js/server' import Transport from 'winston-transport' @@ -31,7 +31,11 @@ import type { InclusionUserCallbacks, } from '#api/lib/zwave/ports.ts' import { ZwaveClientStatus } from '#api/lib/zwave/ports.ts' -import { createDeferred, type Deferred } from './serviceTestSupport.ts' +import { + createDeferred, + requireDefined, + type Deferred, +} from './serviceTestSupport.ts' type StartBehavior = 'resolve' | 'reject' | 'hang' | 'deferred' type DestroyBehavior = 'resolve' | 'reject' | 'deferred' @@ -192,10 +196,8 @@ function makeDeps(world: World): DriverLifecycleDeps { } } -// The base types `cfg.options` as the full ZWaveOptions, but production only ever -// Object.assigns partial user overrides, so adapt partial fixtures in one place -function zwaveOpts(partial: Partial): ZWaveOptions { - return partial as ZWaveOptions +function zwaveOpts(partial: PartialZWaveOptions): PartialZWaveOptions { + return partial } interface HarnessState { @@ -1590,8 +1592,10 @@ describe('DriverLifecycle — driver options', () => { }) await lifecycle.connect() - const driverStorage = world.drivers[0].options.storage - expect(driverStorage).toBeDefined() + const driverStorage = requireDefined( + world.drivers[0].options.storage, + 'expected driver storage options', + ) driverStorage.cacheDir = '/external/cache' driverStorage.throttle = 'fast' diff --git a/test/lib/zwave/FirmwareUpdateService.test.ts b/test/lib/zwave/FirmwareUpdateService.test.ts index 627e40809e..2321b77357 100644 --- a/test/lib/zwave/FirmwareUpdateService.test.ts +++ b/test/lib/zwave/FirmwareUpdateService.test.ts @@ -27,7 +27,8 @@ import type { OTWFirmwareUpdateResult, StagedFirmwareNodeUpdate, } from '#api/lib/zwave/ports.ts' -import { createDeferred, createServiceLogger } from './serviceTestSupport.ts' +import { createServiceLogger } from './serviceTestSupport.ts' +import { createDeferred, requireDefined } from '../testUtils.ts' const successfulFirmwareUpdate: FirmwareUpdateResult = { success: true, @@ -106,10 +107,11 @@ function createNodeStorePort(): FirmwareNodeStorePort & { getNode: (nodeId: number) => nodes.get(nodeId), getStoreNode: (nodeId: number) => store.get(nodeId), ensureStoreNode: (nodeId: number) => { - if (!store.has(nodeId)) { - store.set(nodeId, {}) - } - return store.get(nodeId) + const current = store.get(nodeId) + if (current) return current + const created: Partial = {} + store.set(nodeId, created) + return created }, updateStoreNodes: vi.fn().mockResolvedValue(undefined), persistStagedNodeUpdates: vi @@ -1984,7 +1986,10 @@ describe('FirmwareUpdateService', () => { FirmwareLifecycleCancelledError, ) - const liveNode = nodes._nodes.get(7) + const liveNode = requireDefined( + nodes._nodes.get(7), + 'expected node 7 to remain registered', + ) expect(liveNode.availableFirmwareUpdates).toEqual([]) expect(liveNode.lastFirmwareUpdateCheck).toBe(0) expect(persisted?.availableFirmwareUpdates).toEqual([]) @@ -2048,7 +2053,10 @@ describe('FirmwareUpdateService', () => { await checkPromise - const liveNode = nodes._nodes.get(5) + const liveNode = requireDefined( + nodes._nodes.get(5), + 'expected node 5 to remain registered', + ) expect(liveNode.availableFirmwareUpdates).toEqual([]) expect(liveNode.lastFirmwareUpdateCheck).toBe(0) expect(persisted?.availableFirmwareUpdates).toEqual([]) @@ -2225,7 +2233,10 @@ describe('FirmwareUpdateService', () => { ]), ) - const liveNode = nodes._nodes.get(3) + const liveNode = requireDefined( + nodes._nodes.get(3), + 'expected node 3 to remain registered', + ) expect(liveNode.availableFirmwareUpdates).toEqual(updates) expect(liveNode.lastFirmwareUpdateCheck).toBe(checkTime) @@ -2266,7 +2277,10 @@ describe('FirmwareUpdateService', () => { expect(nodes.persistStagedNodeUpdates).toHaveBeenCalled() - const liveNode = nodes._nodes.get(9) + const liveNode = requireDefined( + nodes._nodes.get(9), + 'expected node 9 to remain registered', + ) expect(liveNode.availableFirmwareUpdates).toEqual(updates) expect(nodes.emitNodeUpdate).toHaveBeenCalledWith( diff --git a/test/lib/zwave/GroupService.test.ts b/test/lib/zwave/GroupService.test.ts index 6b8b0758d2..5563ed60f1 100644 --- a/test/lib/zwave/GroupService.test.ts +++ b/test/lib/zwave/GroupService.test.ts @@ -21,6 +21,7 @@ import type { ZUIGroup, GroupZUINode, } from '../../../api/lib/zwave/ports.ts' +import { createDeferred } from './serviceTestSupport.ts' function makeVirtualNode(_nodeIds: number[]): GroupVirtualNodeHandle { return { @@ -140,29 +141,8 @@ function createPersistencePort() { return port } -// A promise a test resolves manually, to suspend a service call mid-flight and drive an exact restart interleaving -function createDeferred(): { - promise: Promise - resolve: (value: T | PromiseLike) => void - reject: (reason?: unknown) => void -} { - let resolve!: (value: T | PromiseLike) => void - let reject!: (reason?: unknown) => void - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - return { promise, resolve, reject } -} - // deferNextPut() suspends the next put() call, so a test can simulate a restart while persistence is mid-flight and observe both sides of that boundary -function createDeferredPersistencePort(): GroupPersistencePort & { - puts: ZUIGroup[][] - deferNextPut(): { - resolve: () => void - reject: (error: unknown) => void - } -} { +function createDeferredPersistencePort() { let stored: ZUIGroup[] = [] const puts: ZUIGroup[][] = [] let nextDeferred: ReturnType> | null = null diff --git a/test/lib/zwave/InclusionCoordinator.test.ts b/test/lib/zwave/InclusionCoordinator.test.ts index 3b54917f28..04c6ce41c7 100644 --- a/test/lib/zwave/InclusionCoordinator.test.ts +++ b/test/lib/zwave/InclusionCoordinator.test.ts @@ -25,7 +25,8 @@ import type { InclusionServerManagerPort, InclusionSocketPort, } from '#api/lib/zwave/ports.ts' -import { createDeferred, createServiceLogger } from './serviceTestSupport.ts' +import { createServiceLogger } from './serviceTestSupport.ts' +import { createDeferred } from '../testUtils.ts' function createDriverPort( overrides: Partial< @@ -172,10 +173,22 @@ function createCoordinator( nvmEventSetter, socketEvents, ) + const inspectionDriver = { + ...driver, + getDriver() { + const current = driver.getDriver() + if (!current) { + throw new Error( + 'Expected the inclusion driver fixture to be ready', + ) + } + return current + }, + } return { coordinator, - driver, + driver: inspectionDriver, socket, controllerEvent, backup, diff --git a/test/lib/zwave/NodeProjector.test.ts b/test/lib/zwave/NodeProjector.test.ts index 9238e07542..600aace5f9 100644 --- a/test/lib/zwave/NodeProjector.test.ts +++ b/test/lib/zwave/NodeProjector.test.ts @@ -42,7 +42,22 @@ describe('NodeProjector', () => { { name: 'Kitchen', loc: 'Downstairs', - availableFirmwareUpdates: [{ version: '2.0' }], + availableFirmwareUpdates: [ + { + version: '2.0', + normalizedVersion: '2.0', + changelog: '', + channel: 'stable', + files: [], + downgrade: false, + device: { + manufacturerId: 1, + productType: 2, + productId: 3, + firmwareVersion: '1.0', + }, + }, + ], firmwareUpdatesDismissed: { '2.0': true }, lastFirmwareUpdateCheck: 12, appliedTemplateContentHashes: ['hash'], @@ -91,8 +106,8 @@ describe('NodeProjector', () => { createVirtualValue( { type: 'string', - readable: undefined, - writeable: undefined, + readable: false, + writeable: true, minLength: 1, maxLength: 10, }, @@ -187,7 +202,7 @@ describe('NodeProjector', () => { list: false, }) NodeProjector.applyValueMetadata(booleanValue, { - type: 'object', + type: 'any', readable: true, writeable: true, }) @@ -343,7 +358,13 @@ describe('NodeProjector', () => { 2: { name: 'Kitchen', loc: 'Downstairs', - hassDevices: { light: { type: 'light' } }, + hassDevices: { + light: { + type: 'light', + object_id: 'kitchen_light', + discovery_payload: {}, + }, + }, }, } const driver = { diff --git a/test/lib/zwave/NodeRegistry.test.ts b/test/lib/zwave/NodeRegistry.test.ts index a7b3e59ffa..4d76e000a9 100644 --- a/test/lib/zwave/NodeRegistry.test.ts +++ b/test/lib/zwave/NodeRegistry.test.ts @@ -30,6 +30,7 @@ import { socketEvents } from '#api/lib/SocketEvents.ts' import type { ZUINode, ZUIValueId } from '#api/lib/ZwaveClient.ts' import { NodeRegistry, + type NodeRegistryController, type NodeRegistryDriver, type NodeRegistryHost, } from '#api/lib/zwave/NodeRegistry.ts' @@ -38,6 +39,45 @@ import { createValue, createZWaveNode, } from './nodeFixtures.ts' +import { requireDefined } from '../testUtils.ts' + +class ControllerNodeMap extends Map { + getOrThrow(key: number): ZWaveNode { + const node = this.get(key) + if (!node) throw new Error(`Missing controller node ${key}`) + return node + } +} + +function nodeStatistics( + overrides: Partial = {}, +): NodeStatistics { + return { + commandsTX: 0, + commandsRX: 0, + commandsDroppedRX: 0, + commandsDroppedTX: 0, + timeoutResponse: 0, + ...overrides, + } +} + +function controllerStatistics( + overrides: Partial = {}, +): ControllerStatistics { + return { + messagesTX: 0, + messagesRX: 0, + messagesDroppedRX: 0, + messagesDroppedTX: 0, + NAK: 0, + CAN: 0, + timeoutACK: 0, + timeoutResponse: 0, + timeoutCallback: 0, + ...overrides, + } +} function createHarness( options: { @@ -50,17 +90,18 @@ function createHarness( let generation = 1 let current = true const zwaveNode = options.node ?? createZWaveNode() - const controllerNodes = new Map([ - [zwaveNode.id, zwaveNode], - ]) + const controllerNodes = new ControllerNodeMap([[zwaveNode.id, zwaveNode]]) const persisted: NodesStoreFile = options.persisted ?? {} const controller = Object.assign(new EventEmitter(), { nodes: controllerNodes, ownNodeId: 1, supportsLongRange: true, getPrioritySUCReturnRouteCached: vi.fn(() => undefined), - getCustomSUCReturnRoutesCached: vi.fn(() => undefined), - getProvisioningEntry: vi.fn(() => undefined), + getCustomSUCReturnRoutesCached: vi.fn< + NodeRegistryController['getCustomSUCReturnRoutesCached'] + >(() => []), + getProvisioningEntry: + vi.fn(), getSupportedRFRegions: vi.fn(() => []), }) const driver = { @@ -321,6 +362,9 @@ describe('NodeRegistry persistence and lifecycle', () => { vi.mocked( harness.driver.controller.getProvisioningEntry, ).mockReturnValueOnce({ + nodeId: 4, + dsk: '00000-00000-00000-00000-00000-00000-00000-00000', + securityClasses: [SecurityClass.S2_Authenticated], name: 'Provisioned', location: 'Office', }) @@ -416,7 +460,10 @@ describe('NodeRegistry persistence and lifecycle', () => { }) it('continues value updates when persistence fails', async () => { - const harness = createHarness() + const symbol = Symbol('event') + const harness = createHarness({ + node: createZWaveNode({}, { value: symbol }), + }) harness.registry.createNode(2) vi.mocked(harness.host.persistNodes).mockRejectedValueOnce( new Error('write failed'), @@ -424,8 +471,6 @@ describe('NodeRegistry persistence and lifecycle', () => { await expect( harness.registry.updateStoreNodes(false), ).resolves.toBeUndefined() - const symbol = Symbol('event') - harness.zwaveNode.getValue = vi.fn(() => symbol) const result = harness.registry.addValue( harness.zwaveNode, createValue({ property: 'event', propertyName: 'event' }), @@ -518,8 +563,8 @@ describe('NodeRegistry node events and values', () => { available: true, interviewStage: 'Complete', }) - harness.zwaveNode.status = NodeStatus.Dead - harness.registry.updateNodeStatus(harness.zwaveNode, { + const deadZwaveNode = createZWaveNode({ status: NodeStatus.Dead }) + harness.registry.updateNodeStatus(deadZwaveNode, { updateStatusOnly: true, }) expect(harness.host.emitNodeUpdate).toHaveBeenLastCalledWith(node, { @@ -571,6 +616,7 @@ describe('NodeRegistry node events and values', () => { expect(node.interviewProgress).toBe(50) harness.registry.onInterviewFailed(harness.zwaveNode, { errorMessage: 'failed', + isFinal: false, } satisfies NodeInterviewFailedEventArgs) expect(node.interviewProgress).toBe(0) expect(harness.host.emitEvent).toHaveBeenCalledWith( @@ -601,16 +647,20 @@ describe('NodeRegistry node events and values', () => { property: 'targetValue', propertyName: 'targetValue', }) - const zwaveNode = createZWaveNode({ - getDefinedValueIDs: vi.fn(() => [zwaveValue, target]), - getValue: vi.fn(() => 1), - getValueMetadata: vi.fn(() => ({ - type: 'number', - readable: true, - writeable: true, - states: { 0: 'Off', 1: 'On' }, - })), - }) + const zwaveNode = createZWaveNode( + { + getDefinedValueIDs: vi.fn(() => [zwaveValue, target]), + }, + { + value: 1, + metadata: { + type: 'number', + readable: true, + writeable: true, + states: { 0: 'Off', 1: 'On' }, + }, + }, + ) const harness = createHarness({ node: zwaveNode }) const node = harness.registry.createNode(2) const added = harness.registry.addValue(zwaveNode, zwaveValue) @@ -649,8 +699,10 @@ describe('NodeRegistry node events and values', () => { newValue: new Uint8Array([10]), stateless: false, } satisfies ZWaveNodeValueUpdatedArgs & { stateless: boolean }) - const currentValue = - node.values[`${CommandClasses['Binary Switch']}-0-currentValue`] + const currentValue = requireDefined( + node.values, + 'expected projected node values', + )[`${CommandClasses['Binary Switch']}-0-currentValue`] expect(currentValue.value).toBe('0x0a') harness.registry.onValueNotification(zwaveNode, { ...zwaveValue, @@ -661,10 +713,10 @@ describe('NodeRegistry node events and values', () => { await vi.advanceTimersByTimeAsync(1000) expect(currentValue.value).toBeUndefined() - harness.registry.onValueRemoved( - zwaveNode, - zwaveValue satisfies ZWaveNodeValueRemovedArgs, - ) + harness.registry.onValueRemoved(zwaveNode, { + ...zwaveValue, + prevValue: 1, + } satisfies ZWaveNodeValueRemovedArgs) expect(harness.host.sendToSocket).toHaveBeenCalledWith( socketEvents.valueRemoved, expect.objectContaining({ property: 'currentValue' }), @@ -678,15 +730,17 @@ describe('NodeRegistry node events and values', () => { it('publishes ready node values and statistics when routes fail', async () => { const value = createValue() - const zwaveNode = createZWaveNode({ - getDefinedValueIDs: vi.fn(() => [value]), - commandClasses: { - 'Schedule Entry Lock': { isSupported: vi.fn(() => true) }, + const zwaveNode = createZWaveNode( + { + getDefinedValueIDs: vi.fn(() => [value]), }, - }) + { + scheduleEntryLockSupported: true, + }, + ) const harness = createHarness({ node: zwaveNode }) const node = harness.registry.createNode(2) - node.values.old = { + requireDefined(node.values, 'expected projected node values').old = { ...harness.registry.parseValue( zwaveNode, createValue({ property: 'old', propertyName: 'old' }), @@ -709,10 +763,10 @@ describe('NodeRegistry node events and values', () => { `${CommandClasses['Binary Switch']}-0-currentValue`, ) - const stats = { + const stats = nodeStatistics({ lastSeen: new Date(999), commandsTX: 1, - } satisfies NodeStatistics + }) harness.registry.onStatisticsUpdated(zwaveNode, stats) expect(node.lastActive).toBe(999) expect(harness.host.emitNodeLastActive).toHaveBeenCalledWith(node) @@ -774,17 +828,19 @@ describe('NodeRegistry notifications, firmware, statistics, and listeners', () = const controller = createZWaveNode({ id: 1, isControllerNode: true }) const harness = createHarness({ node: controller }) const node = harness.registry.createNode(1) - node.statistics = { messagesRX: 1 } satisfies ControllerStatistics + node.statistics = controllerStatistics({ messagesRX: 1 }) for (let index = 0; index < 362; index++) { - harness.registry.onControllerStatisticsUpdated({ - messagesRX: index + 2, - backgroundRSSI: { - timestamp: index * 60_000, - channel0: { current: -80, average: -80 }, - channel1: { current: -81, average: -81 }, - channel2: { current: -82, average: -82 }, - }, - } satisfies ControllerStatistics) + harness.registry.onControllerStatisticsUpdated( + controllerStatistics({ + messagesRX: index + 2, + backgroundRSSI: { + timestamp: index * 60_000, + channel0: { current: -80, average: -80 }, + channel1: { current: -81, average: -81 }, + channel2: { current: -82, average: -82 }, + }, + }), + ) } expect(node.lastActive).toEqual(expect.any(Number)) const points = node.bgRSSIPoints @@ -814,15 +870,17 @@ describe('NodeRegistry notifications, firmware, statistics, and listeners', () = const node = harness.registry.createNode(1) const controller = harness.driver.controller harness.registry.bindControllerEvents(controller) - controller.emit('statistics updated', { - messagesRX: 2, - } satisfies ControllerStatistics) + controller.emit( + 'statistics updated', + controllerStatistics({ messagesRX: 2 }), + ) expect(node.statistics).toMatchObject({ messagesRX: 2 }) harness.registry.close() - controller.emit('statistics updated', { - messagesRX: 3, - } satisfies ControllerStatistics) + controller.emit( + 'statistics updated', + controllerStatistics({ messagesRX: 3 }), + ) expect(node.statistics).toMatchObject({ messagesRX: 2 }) }) @@ -834,6 +892,7 @@ describe('NodeRegistry notifications, firmware, statistics, and listeners', () = harness.registry.updateValue(harness.zwaveNode, { ...createValue({ property: 'event', propertyName: 'event' }), newValue: 1, + value: 1, stateless: true, }) const emitCount = vi.mocked(harness.host.emitValueChanged).mock diff --git a/test/lib/zwave/ScheduleService.test.ts b/test/lib/zwave/ScheduleService.test.ts index d91929b62a..9399575c27 100644 --- a/test/lib/zwave/ScheduleService.test.ts +++ b/test/lib/zwave/ScheduleService.test.ts @@ -1,9 +1,11 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest' import { ScheduleService } from '#api/lib/zwave/ScheduleService.ts' import { SupervisionStatus } from '@zwave-js/core' import { ScheduleEntryLockScheduleKind, ScheduleEntryLockWeekday, + ScheduleEntryLockCC, + UserCodeCC, UserIDStatus, } from 'zwave-js' import type { @@ -14,49 +16,89 @@ import type { } from 'zwave-js' import type { ScheduleDriverPort, + ScheduleDriverHandle, ScheduleNodeStorePort, ScheduleUtilsPort, ScheduleNodeState, + ScheduleZWaveNodeHandle, } from '#api/lib/zwave/ports.ts' +type ScheduleEntryLockApi = + ScheduleZWaveNodeHandle['commandClasses']['Schedule Entry Lock'] + +interface FakeScheduleEntryLockApi { + isSupported: Mock + getWeekDaySchedule: Mock + getYearDaySchedule: Mock + getDailyRepeatingSchedule: Mock< + ScheduleEntryLockApi['getDailyRepeatingSchedule'] + > + setWeekDaySchedule: Mock + setYearDaySchedule: Mock + setDailyRepeatingSchedule: Mock< + ScheduleEntryLockApi['setDailyRepeatingSchedule'] + > + setEnabled: Mock +} + +interface FakeScheduleZWaveNode extends ScheduleZWaveNodeHandle { + id: number + getEndpoint: Mock + commandClasses: { + 'Schedule Entry Lock': FakeScheduleEntryLockApi + } +} + function createFakeEndpoint() { - return { id: 0 } + return { virtual: false as const, nodeId: 2, index: 0 } } -function createFakeZwaveNode(supported = true) { +function createFakeZwaveNode(supported = true): FakeScheduleZWaveNode { return { id: 2, - getEndpoint: vi.fn(() => createFakeEndpoint()), + getEndpoint: vi.fn(() => + createFakeEndpoint(), + ), commandClasses: { 'Schedule Entry Lock': { - isSupported: vi.fn(() => supported), - getWeekDaySchedule: vi.fn(), - getYearDaySchedule: vi.fn(), - getDailyRepeatingSchedule: vi.fn(), - setWeekDaySchedule: vi.fn(), - setYearDaySchedule: vi.fn(), - setDailyRepeatingSchedule: vi.fn(), - setEnabled: vi.fn(), + isSupported: vi.fn( + () => supported, + ), + getWeekDaySchedule: + vi.fn(), + getYearDaySchedule: + vi.fn(), + getDailyRepeatingSchedule: + vi.fn(), + setWeekDaySchedule: + vi.fn(), + setYearDaySchedule: + vi.fn(), + setDailyRepeatingSchedule: + vi.fn(), + setEnabled: vi.fn(), }, }, } } function createDriverPort( - zwaveNode?: ReturnType, + zwaveNode?: FakeScheduleZWaveNode | null, ): ScheduleDriverPort { - const node = zwaveNode ?? createFakeZwaveNode() + const node = zwaveNode === undefined ? createFakeZwaveNode() : zwaveNode + const driver: ScheduleDriverHandle = { + getValueDB: vi.fn(), + tryGetValueDB: vi.fn(), + controller: { + nodes: { + get: vi.fn((id: number) => + node && id === node.id ? node : undefined, + ), + }, + }, + } return { - getDriver: () => - ({ - controller: { - nodes: { - get: vi.fn((id: number) => - id === node.id ? node : undefined, - ), - }, - }, - }) as ReturnType, + getDriver: () => driver, } } @@ -82,7 +124,7 @@ function createUtilsPort(): ScheduleUtilsPort { } } -async function stubCCStatics( +function stubCCStatics( overrides: Partial<{ supportedUsers: number | undefined numWeekDaySlots: number @@ -91,7 +133,10 @@ async function stubCCStatics( userIdStatuses: Record scheduleEnabled: Record scheduleKind: Record - cachedSchedules: Record + cachedSchedules: Record< + string, + ReturnType + > }> = {}, ) { const supportedUsers = @@ -106,8 +151,6 @@ async function stubCCStatics( cachedSchedules = {}, } = overrides - const { UserCodeCC, ScheduleEntryLockCC } = await import('zwave-js') - vi.spyOn(UserCodeCC, 'getSupportedUsersCached').mockReturnValue( supportedUsers, ) @@ -202,7 +245,7 @@ describe('ScheduleService', () => { createUtilsPort(), ) - await stubCCStatics({ supportedUsers: 0 }) + stubCCStatics({ supportedUsers: 0 }) const p1 = svc.getSchedules(2) @@ -225,7 +268,7 @@ describe('ScheduleService', () => { nodeStore, createUtilsPort(), ) - await stubCCStatics({ supportedUsers: 0 }) + stubCCStatics({ supportedUsers: 0 }) const result = await svc.getSchedules(2) expect(result).toBeUndefined() @@ -241,7 +284,7 @@ describe('ScheduleService', () => { createUtilsPort(), ) - await stubCCStatics({ + stubCCStatics({ supportedUsers: 1, numWeekDaySlots: 1, numYearDaySlots: 1, @@ -277,7 +320,7 @@ describe('ScheduleService', () => { const getDriverImpl = driverPort.getDriver.bind(driverPort) driverPort.getDriver = () => null - await stubCCStatics({ supportedUsers: 0 }) + stubCCStatics({ supportedUsers: 0 }) await expect(svc.getSchedules(2)).rejects.toThrow() @@ -456,14 +499,7 @@ describe('ScheduleService', () => { describe('enabling schedules', () => { it('throws when node not found', async () => { - const driverPort: ScheduleDriverPort = { - getDriver: () => - ({ - controller: { - nodes: { get: () => undefined }, - }, - }) as ReturnType, - } + const driverPort = createDriverPort(null) const svc = new ScheduleService( driverPort, createNodeStorePort(), @@ -574,7 +610,7 @@ describe('ScheduleService', () => { createUtilsPort(), ) - await stubCCStatics({ + stubCCStatics({ supportedUsers: 1, numWeekDaySlots: 1, numYearDaySlots: 0, @@ -629,7 +665,7 @@ describe('ScheduleService', () => { createUtilsPort(), ) - await stubCCStatics({ + stubCCStatics({ supportedUsers: 1, numWeekDaySlots: 1, numYearDaySlots: 0, @@ -683,7 +719,7 @@ describe('ScheduleService', () => { createUtilsPort(), ) - await stubCCStatics({ + stubCCStatics({ supportedUsers: 1, numWeekDaySlots: 1, numYearDaySlots: 0, @@ -795,7 +831,7 @@ describe('ScheduleService', () => { createUtilsPort(), ) - await stubCCStatics({ + stubCCStatics({ supportedUsers: 1, numWeekDaySlots: 2, numYearDaySlots: 0, @@ -819,7 +855,7 @@ describe('ScheduleService', () => { }) expect(result).toBeUndefined() - await stubCCStatics({ supportedUsers: 0 }) + stubCCStatics({ supportedUsers: 0 }) const result2 = await svc.getSchedules(2, { fromCache: true }) expect(result2).toBeDefined() }) @@ -836,7 +872,7 @@ describe('ScheduleService', () => { createUtilsPort(), ) - await stubCCStatics({ + stubCCStatics({ supportedUsers: undefined, numWeekDaySlots: 3, numYearDaySlots: 2, @@ -877,7 +913,7 @@ describe('ScheduleService', () => { createUtilsPort(), ) - await stubCCStatics({ + stubCCStatics({ supportedUsers: 1, numWeekDaySlots: 1, numYearDaySlots: 1, diff --git a/test/lib/zwave/nodeFixtures.ts b/test/lib/zwave/nodeFixtures.ts index b64b06ac44..99172ca047 100644 --- a/test/lib/zwave/nodeFixtures.ts +++ b/test/lib/zwave/nodeFixtures.ts @@ -50,7 +50,16 @@ export function createVirtualValue( } } -export function createZWaveNode(overrides: Partial = {}): ZWaveNode { +export interface ZWaveNodeFixtureSettings { + value?: unknown + metadata?: ValueMetadata + scheduleEntryLockSupported?: boolean +} + +export function createZWaveNode( + overrides: Partial = {}, + settings: ZWaveNodeFixtureSettings = {}, +): ZWaveNode { const node = Object.assign(new EventEmitter(), { id: 2, name: '', @@ -95,7 +104,11 @@ export function createZWaveNode(overrides: Partial = {}): ZWaveNode { manufacturer: 'Manufacturer', }, commandClasses: { - 'Schedule Entry Lock': { isSupported: vi.fn(() => false) }, + 'Schedule Entry Lock': { + isSupported: vi.fn( + () => settings.scheduleEntryLockSupported ?? false, + ), + }, }, getEndpointCount: vi.fn(() => 2), getAllEndpoints: vi.fn(() => [ @@ -117,13 +130,14 @@ export function createZWaveNode(overrides: Partial = {}): ZWaveNode { hasDeviceConfigChanged: vi.fn(() => true), getDefinedValueIDs: vi.fn(() => []), getValueMetadata: vi.fn( - (): ValueMetadata => ({ - type: 'number', - readable: true, - writeable: true, - }), + (): ValueMetadata => + settings.metadata ?? { + type: 'number', + readable: true, + writeable: true, + }, ), - getValue: vi.fn(() => 1), + getValue: vi.fn(() => settings.value ?? 1), getEndpoint: vi.fn(() => ({ getCCVersion: vi.fn(() => 4) })), getCCVersion: vi.fn(() => 3), supportsCC: vi.fn(() => false), diff --git a/test/lib/zwave/serviceTestSupport.ts b/test/lib/zwave/serviceTestSupport.ts index 2075017f04..7a11d947be 100644 --- a/test/lib/zwave/serviceTestSupport.ts +++ b/test/lib/zwave/serviceTestSupport.ts @@ -1,6 +1,7 @@ import { vi, type MockInstance } from 'vitest' import type { ServiceLogger } from '#api/lib/zwave/ports.ts' +export { createDeferred, requireDefined, type Deferred } from '../testUtils.ts' export type MockServiceLogger = ServiceLogger & { info: MockInstance @@ -15,19 +16,3 @@ export function createServiceLogger(): MockServiceLogger { error: vi.fn(), } } - -export interface Deferred { - promise: Promise - resolve: (value: T | PromiseLike) => void - reject: (reason?: unknown) => void -} - -export function createDeferred(): Deferred { - let resolve!: Deferred['resolve'] - let reject!: Deferred['reject'] - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - return { promise, resolve, reject } -} diff --git a/tsconfig.json b/tsconfig.json index cfb89c2eba..94b7390f06 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,8 +25,7 @@ "allowSyntheticDefaultImports": true, "forceConsistentCasingInFileNames": true, - // TODO: Turn this on and fix all the errors - "strict": false + "strict": true }, "include": [ "api/**/*.ts", diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000000..53c5f42a07 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "strict": true, + "noEmit": true, + "rootDir": ".", + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": false + }, + "include": [ + "test/**/*.ts", + "vitest.config.ts" + ] +} diff --git a/vitest.config.ts b/vitest.config.ts index 786b323534..b3138321a9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,11 +4,7 @@ import { defineConfig } from 'vitest/config' const __dirname = path.dirname(fileURLToPath(import.meta.url)) -// Dedicated Vitest config. The app build pipeline (Vue/Vuetify/PWA plugins) -// lives in vite.config.mjs; the current test suites exercise plain JS/TS -// modules and don't need those plugins, so this config stays minimal and fast. -// Add a `projects` entry with the Vue plugin + jsdom here if/when component -// tests are introduced. +// Keep tests separate from Vue/Vuetify/PWA build plugins because current suites exercise plain JS/TS modules export default defineConfig({ resolve: { alias: [ @@ -40,10 +36,8 @@ export default defineConfig({ }, coverage: { provider: 'v8', - // Report every file matched by `include`, not just those imported by - // a test, so untested files count against coverage (matches the old - // `c8 --all` behaviour and keeps the Coveralls report honest). - all: true, + // With Vitest 4's v8 provider, explicit `coverage.include` also adds + // matching files that tests never import, so they count as uncovered. reporter: ['text', 'lcov'], reportsDirectory: './coverage', include: ['api/**/*.{js,ts}', 'src/**/*.{js,ts}'],