UNPKG

inventoresed

Version:

Z-Wave driver written entirely in JavaScript/TypeScript

605 lines (568 loc) 16.2 kB
import type { JsonlDB } from "@alcalzone/jsonl-db"; import { CommandClasses, dskFromString, dskToString, NodeType, SecurityClass, securityClassOrder, ZWaveError, ZWaveErrorCodes, } from "@zwave-js/core"; import type { FileSystem } from "@zwave-js/host"; import { getEnumMemberName, num2hex, pickDeep } from "@zwave-js/shared"; import { isArray, isObject } from "alcalzone-shared/typeguards"; import path from "path"; import { ProvisioningEntryStatus, SmartStartProvisioningEntry, } from "../controller/Inclusion"; import { DeviceClass } from "../node/DeviceClass"; import { InterviewStage } from "../node/_Types"; import type { Driver } from "./Driver"; /** * Defines the keys that are used to store certain properties in the network cache. */ export const cacheKeys = { controller: { provisioningList: "controller.provisioningList", supportsSoftReset: "controller.supportsSoftReset", }, // TODO: somehow these functions should be combined with the pattern matching below node: (nodeId: number) => { const nodeBaseKey = `node.${nodeId}.`; return { _baseKey: nodeBaseKey, _securityClassBaseKey: `${nodeBaseKey}securityClasses`, interviewStage: `${nodeBaseKey}interviewStage`, deviceClass: `${nodeBaseKey}deviceClass`, isListening: `${nodeBaseKey}isListening`, isFrequentListening: `${nodeBaseKey}isFrequentListening`, isRouting: `${nodeBaseKey}isRouting`, supportedDataRates: `${nodeBaseKey}supportedDataRates`, protocolVersion: `${nodeBaseKey}protocolVersion`, nodeType: `${nodeBaseKey}nodeType`, supportsSecurity: `${nodeBaseKey}supportsSecurity`, supportsBeaming: `${nodeBaseKey}supportsBeaming`, securityClass: (secClass: SecurityClass) => `${nodeBaseKey}securityClasses.${getEnumMemberName( SecurityClass, secClass, )}`, dsk: `${nodeBaseKey}dsk`, endpoint: (index: number) => { const endpointBaseKey = `${nodeBaseKey}endpoint.${index}.`; const ccBaseKey = `${endpointBaseKey}commandClass.`; return { _baseKey: endpointBaseKey, _ccBaseKey: ccBaseKey, commandClass: (ccId: CommandClasses) => { const ccAsHex = num2hex(ccId); return `${ccBaseKey}${ccAsHex}`; }, }; }, hasSUCReturnRoute: `${nodeBaseKey}hasSUCReturnRoute`, }; }, } as const; export const cacheKeyUtils = { nodeIdFromKey: (key: string): number | undefined => { const match = /^node\.(?<nodeId>\d+)\./.exec(key); if (match) { return parseInt(match.groups!.nodeId, 10); } }, nodePropertyFromKey: (key: string): string | undefined => { const match = /^node\.\d+\.(?<property>[^\.]+)$/.exec(key); return match?.groups?.property; }, isEndpointKey: (key: string): boolean => { return /endpoints\.(?<index>\d+)$/.test(key); }, endpointIndexFromKey: (key: string): number | undefined => { const match = /endpoints\.(?<index>\d+)$/.exec(key); if (match) { return parseInt(match.groups!.index, 10); } }, } as const; function tryParseInterviewStage(value: unknown): InterviewStage | undefined { if ( (typeof value === "string" || typeof value === "number") && value in InterviewStage ) { return typeof value === "number" ? value : (InterviewStage as any)[value]; } } function tryParseDeviceClass( driver: Driver, value: unknown, ): DeviceClass | undefined { if (isObject(value)) { const { basic, generic, specific } = value; if ( typeof basic === "number" && typeof generic === "number" && typeof specific === "number" ) { return new DeviceClass( driver.configManager, basic, generic, specific, ); } } } function tryParseSecurityClasses( value: unknown, ): Map<SecurityClass, boolean> | undefined { if (isObject(value)) { const ret = new Map<SecurityClass, boolean>(); for (const [key, val] of Object.entries(value)) { if ( key in SecurityClass && typeof (SecurityClass as any)[key] === "number" && typeof val === "boolean" ) { ret.set((SecurityClass as any)[key] as SecurityClass, val); } } return ret; } } function tryParseNodeType(value: unknown): NodeType | undefined { if (typeof value === "string" && value in NodeType) { return (NodeType as any)[value]; } } function tryParseProvisioningList( value: unknown, ): SmartStartProvisioningEntry[] | undefined { const ret: SmartStartProvisioningEntry[] = []; if (!isArray(value)) return; for (const entry of value) { if ( isObject(entry) && typeof entry.dsk === "string" && isArray(entry.securityClasses) && // securityClasses are stored as strings, not the enum values entry.securityClasses.every((s) => isSerializedSecurityClass(s)) && (entry.requestedSecurityClasses == undefined || (isArray(entry.requestedSecurityClasses) && entry.requestedSecurityClasses.every((s) => isSerializedSecurityClass(s), ))) && (entry.status == undefined || isSerializedProvisioningEntryStatus(entry.status)) ) { // This is at least a PlannedProvisioningEntry, maybe it is an IncludedProvisioningEntry if ("nodeId" in entry && typeof entry.nodeId !== "number") { return; } const parsed = { ...entry } as SmartStartProvisioningEntry; parsed.securityClasses = entry.securityClasses .map((s) => tryParseSerializedSecurityClass(s)) .filter((s): s is SecurityClass => s !== undefined); if (entry.requestedSecurityClasses) { parsed.requestedSecurityClasses = ( entry.requestedSecurityClasses as any[] ) .map((s) => tryParseSerializedSecurityClass(s)) .filter((s): s is SecurityClass => s !== undefined); } if (entry.status != undefined) { parsed.status = ProvisioningEntryStatus[ entry.status as any ] as any as ProvisioningEntryStatus; } ret.push(parsed); } else { return; } } return ret; } function isSerializedSecurityClass(value: unknown): boolean { // There was an error in previous iterations of the migration code, so we // now have to deal with the following variants: // 1. plain numbers representing a valid Security Class: 1 // 2. strings representing a valid Security Class: "S2_Unauthenticated" // 3. strings represending a mis-formatted Security Class: "unknown (0xS2_Unauthenticated)" if (typeof value === "number" && value in SecurityClass) return true; if (typeof value === "string") { if (value.startsWith("unknown (0x") && value.endsWith(")")) { value = value.slice(11, -1); } if ( (value as any) in SecurityClass && typeof SecurityClass[value as any] === "number" ) { return true; } } return false; } function tryParseSerializedSecurityClass( value: unknown, ): SecurityClass | undefined { // There was an error in previous iterations of the migration code, so we // now have to deal with the following variants: // 1. plain numbers representing a valid Security Class: 1 // 2. strings representing a valid Security Class: "S2_Unauthenticated" // 3. strings represending a mis-formatted Security Class: "unknown (0xS2_Unauthenticated)" if (typeof value === "number" && value in SecurityClass) return value; if (typeof value === "string") { if (value.startsWith("unknown (0x") && value.endsWith(")")) { value = value.slice(11, -1); } if ( (value as any) in SecurityClass && typeof SecurityClass[value as any] === "number" ) { return (SecurityClass as any)[value as any]; } } } function isSerializedProvisioningEntryStatus( s: unknown, ): s is keyof typeof ProvisioningEntryStatus { return ( typeof s === "string" && s in ProvisioningEntryStatus && typeof ProvisioningEntryStatus[s as any] === "number" ); } export function deserializeNetworkCacheValue( driver: Driver, key: string, value: unknown, ): unknown { function ensureType<T extends "boolean" | "number" | "string">( value: any, type: T, ): T | boolean { if (typeof value === type) return value as T; throw new ZWaveError( `Incorrect type ${typeof value} for property "${key}"`, ZWaveErrorCodes.Driver_InvalidCache, ); } function fail() { throw new ZWaveError( `Failed to deserialize property "${key}"`, ZWaveErrorCodes.Driver_InvalidCache, ); } switch (cacheKeyUtils.nodePropertyFromKey(key)) { case "interviewStage": { value = tryParseInterviewStage(value); if (value) return value; throw fail(); } case "deviceClass": { value = tryParseDeviceClass(driver, value); if (value) return value; throw fail(); } case "isListening": case "isRouting": case "hasSUCReturnRoute": return ensureType(value, "boolean"); case "isFrequentListening": { switch (value) { case "1000ms": case true: return "1000ms"; case "250ms": return "250ms"; case false: return false; } throw fail(); } case "dsk": { if (typeof value === "string") { return dskFromString(value); } throw fail(); } case "supportsSecurity": return ensureType(value, "boolean"); case "supportsBeaming": try { return ensureType(value, "boolean"); } catch { return ensureType(value, "string"); } case "protocolVersion": return ensureType(value, "number"); case "nodeType": { value = tryParseNodeType(value); if (value) return value; throw fail(); } case "supportedDataRates": { if ( isArray(value) && value.every((r: unknown) => typeof r === "number") ) { return value; } throw fail(); } } // Other properties switch (key) { case cacheKeys.controller.provisioningList: { value = tryParseProvisioningList(value); if (value) return value; throw fail(); } } return value; } export function serializeNetworkCacheValue( driver: Driver, key: string, value: unknown, ): unknown { // Node-specific properties switch (cacheKeyUtils.nodePropertyFromKey(key)) { case "interviewStage": { return InterviewStage[value as any]; } case "deviceClass": { const deviceClass = value as DeviceClass; return { basic: deviceClass.basic.key, generic: deviceClass.generic.key, specific: deviceClass.specific.key, }; } case "nodeType": { return NodeType[value as any]; } case "securityClasses": { const ret: Record<string, boolean> = {}; // Save security classes where they are known for (const secClass of securityClassOrder) { if (secClass in (value as any)) { ret[SecurityClass[secClass]] = (value as any)[secClass]; } } return ret; } case "dsk": { return dskToString(value as Buffer); } } // Other properties switch (key) { case cacheKeys.controller.provisioningList: { const ret: any = []; for (const entry of value as SmartStartProvisioningEntry[]) { const serialized: Record<string, any> = { ...entry }; serialized.securityClasses = entry.securityClasses.map((c) => getEnumMemberName(SecurityClass, c), ); if (entry.requestedSecurityClasses) { serialized.requestedSecurityClasses = entry.requestedSecurityClasses.map((c) => getEnumMemberName(SecurityClass, c), ); } if (entry.status != undefined) { serialized.status = getEnumMemberName( ProvisioningEntryStatus, entry.status, ); } ret.push(serialized); } return ret; } } return value; } /** Defines the JSON paths that were used to store certain properties in the legacy network cache */ const legacyPaths = { // These seem to duplicate the ones in cacheKeys, but this allows us to change // something in the future without breaking migration controller: { provisioningList: "controller.provisioningList", supportsSoftReset: "controller.supportsSoftReset", }, node: { // These are relative to the node object interviewStage: `interviewStage`, deviceClass: `deviceClass`, isListening: `isListening`, isFrequentListening: `isFrequentListening`, isRouting: `isRouting`, supportedDataRates: `supportedDataRates`, protocolVersion: `protocolVersion`, nodeType: `nodeType`, supportsSecurity: `supportsSecurity`, supportsBeaming: `supportsBeaming`, securityClasses: `securityClasses`, dsk: `dsk`, }, commandClass: { // These are relative to the commandClasses object name: `name`, endpoint: (index: number) => `endpoints.${index}`, }, } as const; export async function migrateLegacyNetworkCache( driver: Driver, homeId: number, networkCache: JsonlDB, valueDB: JsonlDB, storageDriver: FileSystem, cacheDir: string, ): Promise<void> { const cacheFile = path.join(cacheDir, `${homeId.toString(16)}.json`); if (!(await storageDriver.pathExists(cacheFile))) return; const legacy = JSON.parse(await storageDriver.readFile(cacheFile, "utf8")); const jsonl = networkCache; function tryMigrate( targetKey: string, source: Record<string, any>, sourcePath: string, converter?: (value: any) => any, ): void { let val = pickDeep(source, sourcePath); if (val != undefined && converter) val = converter(val); if (val != undefined) jsonl.set(targetKey, val); } // Translate all possible entries // Controller provisioning list and supportsSoftReset info tryMigrate( cacheKeys.controller.provisioningList, legacy, legacyPaths.controller.provisioningList, tryParseProvisioningList, ); tryMigrate( cacheKeys.controller.supportsSoftReset, legacy, legacyPaths.controller.supportsSoftReset, ); // All nodes, ... if (isObject(legacy.nodes)) { for (const node of Object.values<any>(legacy.nodes)) { if (!isObject(node) || typeof node.id !== "number") continue; const nodeCacheKeys = cacheKeys.node(node.id); // ... their properties tryMigrate( nodeCacheKeys.interviewStage, node, legacyPaths.node.interviewStage, tryParseInterviewStage, ); tryMigrate( nodeCacheKeys.deviceClass, node, legacyPaths.node.deviceClass, (v) => tryParseDeviceClass(driver, v), ); tryMigrate( nodeCacheKeys.isListening, node, legacyPaths.node.isListening, ); tryMigrate( nodeCacheKeys.isFrequentListening, node, legacyPaths.node.isFrequentListening, ); tryMigrate( nodeCacheKeys.isRouting, node, legacyPaths.node.isRouting, ); tryMigrate( nodeCacheKeys.supportedDataRates, node, legacyPaths.node.supportedDataRates, ); tryMigrate( nodeCacheKeys.protocolVersion, node, legacyPaths.node.protocolVersion, ); tryMigrate( nodeCacheKeys.nodeType, node, legacyPaths.node.nodeType, tryParseNodeType, ); tryMigrate( nodeCacheKeys.supportsSecurity, node, legacyPaths.node.supportsSecurity, ); tryMigrate( nodeCacheKeys.supportsBeaming, node, legacyPaths.node.supportsBeaming, ); // Convert security classes to single entries const securityClasses = tryParseSecurityClasses( pickDeep(node, legacyPaths.node.securityClasses), ); if (securityClasses) { for (const [secClass, val] of securityClasses) { jsonl.set(nodeCacheKeys.securityClass(secClass), val); } } tryMigrate( nodeCacheKeys.dsk, node, legacyPaths.node.dsk, dskFromString, ); // ... and command classes // The nesting was inverted from the legacy cache: node -> EP -> CCs // as opposed to node -> CC -> EPs if (isObject(node.commandClasses)) { for (const [ccIdHex, cc] of Object.entries<any>( node.commandClasses, )) { const ccId = parseInt(ccIdHex, 16); if (isObject(cc.endpoints)) { for (const endpointId of Object.keys(cc.endpoints)) { const endpointIndex = parseInt(endpointId, 10); const cacheKey = nodeCacheKeys .endpoint(endpointIndex) .commandClass(ccId); tryMigrate( cacheKey, cc, legacyPaths.commandClass.endpoint( endpointIndex, ), ); } } } } // In addition, try to move the hacky value ID for hasSUCReturnRoute from the value DB to the network cache const dbKey = JSON.stringify({ nodeId: node.id, commandClass: -1, endpoint: 0, property: "hasSUCReturnRoute", }); if (valueDB.has(dbKey)) { const hasSUCReturnRoute = valueDB.get(dbKey); valueDB.delete(dbKey); jsonl.set(nodeCacheKeys.hasSUCReturnRoute, hasSUCReturnRoute); } } } }