UNPKG

zwave-js

Version:

Z-Wave driver written entirely in JavaScript/TypeScript

604 lines 24 kB
import { NodeType, Protocols, SecurityClass, ZWaveError, ZWaveErrorCodes, dskFromString, dskToString, securityClassOrder, } from "@zwave-js/core"; import { Bytes, getEnumMemberName, num2hex, pickDeep, } from "@zwave-js/shared"; import { isArray, isObject } from "alcalzone-shared/typeguards"; import path from "pathe"; import { ProvisioningEntryStatus, } from "../controller/Inclusion.js"; import { DeviceClass } from "../node/DeviceClass.js"; import { InterviewStage } from "../node/_Types.js"; /** * Defines the keys that are used to store certain properties in the network cache. */ export const cacheKeys = { controller: { provisioningList: "controller.provisioningList", associations: (groupId) => `controller.associations.${groupId}`, securityKeys: (secClass) => `controller.securityKeys.${getEnumMemberName(SecurityClass, secClass)}`, securityKeysLongRange: (secClass) => `controller.securityKeyLongRange.${getEnumMemberName(SecurityClass, secClass)}`, privateKey: "controller.privateKey", }, // TODO: somehow these functions should be combined with the pattern matching below node: (nodeId) => { const nodeBaseKey = `node.${nodeId}.`; return { _baseKey: nodeBaseKey, _securityClassBaseKey: `${nodeBaseKey}securityClasses`, _priorityReturnRouteBaseKey: `${nodeBaseKey}priorityReturnRoute`, 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) => `${nodeBaseKey}securityClasses.${getEnumMemberName(SecurityClass, secClass)}`, dsk: `${nodeBaseKey}dsk`, endpoint: (index) => { const endpointBaseKey = `${nodeBaseKey}endpoint.${index}.`; const ccBaseKey = `${endpointBaseKey}commandClass.`; return { _baseKey: endpointBaseKey, _ccBaseKey: ccBaseKey, commandClass: (ccId) => { const ccAsHex = num2hex(ccId); return `${ccBaseKey}${ccAsHex}`; }, }; }, hasSUCReturnRoute: `${nodeBaseKey}hasSUCReturnRoute`, priorityReturnRoute: (destinationNodeId) => `${nodeBaseKey}priorityReturnRoute.${destinationNodeId}`, prioritySUCReturnRoute: `${nodeBaseKey}priorityReturnRoute.SUC`, customReturnRoutes: (destinationNodeId) => `${nodeBaseKey}customReturnRoutes.${destinationNodeId}`, customSUCReturnRoutes: `${nodeBaseKey}customReturnRoutes.SUC`, defaultTransitionDuration: `${nodeBaseKey}defaultTransitionDuration`, defaultVolume: `${nodeBaseKey}defaultVolume`, lastSeen: `${nodeBaseKey}lastSeen`, deviceConfigHash: `${nodeBaseKey}deviceConfigHash`, }; }, }; export const cacheKeyUtils = { nodeIdFromKey: (key) => { const match = /^node\.(?<nodeId>\d+)\./.exec(key); if (match) { return parseInt(match.groups.nodeId, 10); } }, nodePropertyFromKey: (key) => { const match = /^node\.\d+\.(?<property>[^.]+)$/.exec(key); return match?.groups?.property; }, isEndpointKey: (key) => { return /endpoints\.(?<index>\d+)$/.test(key); }, endpointIndexFromKey: (key) => { const match = /endpoints\.(?<index>\d+)$/.exec(key); if (match) { return parseInt(match.groups.index, 10); } }, destinationFromPriorityReturnRouteKey: (key) => { const match = /\.priorityReturnRoute\.(?<nodeId>\d+)$/.exec(key); if (match) { return parseInt(match.groups.nodeId, 10); } }, }; function tryParseInterviewStage(value) { if ((typeof value === "string" || typeof value === "number") && value in InterviewStage) { return typeof value === "number" ? value : InterviewStage[value]; } } function tryParseDeviceClass(value) { if (isObject(value)) { const { basic, generic, specific } = value; if (typeof basic === "number" && typeof generic === "number" && typeof specific === "number") { return new DeviceClass(basic, generic, specific); } } } function tryParseSecurityClasses(value) { if (isObject(value)) { const ret = new Map(); for (const [key, val] of Object.entries(value)) { if (key in SecurityClass && typeof SecurityClass[key] === "number" && typeof val === "boolean") { ret.set(SecurityClass[key], val); } } return ret; } } function tryParseNodeType(value) { if (typeof value === "string" && value in NodeType) { return NodeType[value]; } } function tryParseProvisioningList(value) { const ret = []; 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)))) // protocol and supportedProtocols are (supposed to be) stored as strings, not the enum values && (entry.protocol == undefined || isSerializedProtocol(entry.protocol)) && (entry.supportedProtocols == undefined || (isArray(entry.supportedProtocols) && entry.supportedProtocols.every((s) => isSerializedProtocol(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, }; parsed.securityClasses = entry.securityClasses .map((s) => tryParseSerializedSecurityClass(s)) .filter((s) => s !== undefined); if (entry.requestedSecurityClasses) { parsed.requestedSecurityClasses = entry.requestedSecurityClasses .map((s) => tryParseSerializedSecurityClass(s)) .filter((s) => s !== undefined); } if (entry.status != undefined) { parsed.status = ProvisioningEntryStatus[entry.status]; } if (entry.protocol != undefined) { parsed.protocol = tryParseSerializedProtocol(entry.protocol); } if (entry.supportedProtocols) { parsed.supportedProtocols = entry.supportedProtocols .map((s) => tryParseSerializedProtocol(s)) .filter((s) => s !== undefined); } ret.push(parsed); } else { return; } } return ret; } function isSerializedSecurityClass(value) { // 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 in SecurityClass && typeof SecurityClass[value] === "number") { return true; } } return false; } function tryParseSerializedSecurityClass(value) { // 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 in SecurityClass && typeof SecurityClass[value] === "number") { return SecurityClass[value]; } } } function isSerializedProvisioningEntryStatus(s) { return (typeof s === "string" && s in ProvisioningEntryStatus && typeof ProvisioningEntryStatus[s] === "number"); } function isSerializedProtocol(s) { // The list of supported protocols has been around since before we started // saving them as their stringified variant, so we // now have to deal with the following variants: // 1. plain numbers representing a valid Protocol: 0 // 2. strings representing a valid Protocols: "ZWave" if (typeof s === "number" && s in Protocols) return true; return (typeof s === "string" && s in Protocols && typeof Protocols[s] === "number"); } function tryParseSerializedProtocol(value) { // The list of supported protocols has been around since before we started // saving them as their stringified variant, so we // now have to deal with the following variants: // 1. plain numbers representing a valid Protocol: 0 // 2. strings representing a valid Protocols: "ZWave" if (typeof value === "number" && value in Protocols) return value; if (typeof value === "string") { if (value in Protocols && typeof Protocols[value] === "number") { return Protocols[value]; } } } function tryParseDate(value) { // Dates are stored as timestamps if (typeof value === "number") { const ret = new Date(value); if (!isNaN(ret.getTime())) return ret; } } function tryParseAssociationAddress(value) { if (isObject(value)) { const { nodeId, endpoint } = value; if (typeof nodeId !== "number") return; if (endpoint !== undefined && typeof endpoint !== "number") return; return { nodeId, endpoint }; } } function tryParseBuffer(value) { if (typeof value === "string") { try { return Bytes.from(value, "hex"); } catch { // ignore } } } function tryParseBufferBase64(value) { if (typeof value === "string") { try { return Bytes.from(value, "base64"); } catch { // ignore } } } export function deserializeNetworkCacheValue(key, value) { function ensureType(value, type) { if (typeof value === type) return value; 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; fail(); } case "deviceClass": { value = tryParseDeviceClass(value); if (value) return value; 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; } fail(); } case "dsk": { if (typeof value === "string") { return dskFromString(value); } 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; fail(); } case "supportedDataRates": { if (isArray(value) && value.every((r) => typeof r === "number")) { return value; } fail(); } case "lastSeen": { value = tryParseDate(value); if (value) return value; fail(); } case "deviceConfigHash": { if (typeof value !== "string") fail(); const versionMatch = value.match(/^\$v\d+\$/)?.[0]; if (versionMatch) { // Versioned hash, stored as base64, preserve the version prefix value = tryParseBufferBase64(value.slice(versionMatch.length)); if (value) { value = Bytes.concat([ Bytes.from(versionMatch, "utf8"), value, ]); } } else { // Legacy hash, no version prefix, stored as hex value = tryParseBuffer(value); } if (value) return value; fail(); } } // Other properties if (key.startsWith("controller.associations.")) { value = tryParseAssociationAddress(value); if (value) return value; fail(); } else if (key.startsWith("controller.securityKeys.")) { value = tryParseBuffer(value); if (value) return value; fail(); } switch (key) { case cacheKeys.controller.provisioningList: { value = tryParseProvisioningList(value); if (value) return value; fail(); } case cacheKeys.controller.privateKey: { value = tryParseBuffer(value); if (value) return value; fail(); } } return value; } export function serializeNetworkCacheValue(key, value) { // Node-specific properties switch (cacheKeyUtils.nodePropertyFromKey(key)) { case "interviewStage": { return InterviewStage[value]; } case "deviceClass": { const deviceClass = value; return { basic: deviceClass.basic, generic: deviceClass.generic.key, specific: deviceClass.specific.key, }; } case "nodeType": { return NodeType[value]; } case "securityClasses": { const ret = {}; // Save security classes where they are known for (const secClass of securityClassOrder) { if (secClass in value) { ret[SecurityClass[secClass]] = value[secClass]; } } return ret; } case "dsk": { return dskToString(value); } case "lastSeen": { // Dates are stored as timestamps return value.getTime(); } case "deviceConfigHash": { // Preserve the version prefix if it exists const valueAsString = Bytes.view(value).toString("utf8"); const versionMatch = valueAsString.match(/^\$v\d+\$/)?.[0]; if (versionMatch) { return versionMatch + Bytes.view(value).subarray(versionMatch.length).toString("base64"); } else { // For lecacy hashes, just return the hex representation return Bytes.view(value).toString("hex"); } } } // Other dynamic properties if (key.startsWith("controller.securityKeys.")) { return Bytes.view(value).toString("hex"); } // Other fixed properties switch (key) { case cacheKeys.controller.provisioningList: { const ret = []; for (const entry of value) { const serialized = { ...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); } if (entry.protocol != undefined) { serialized.protocol = getEnumMemberName(Protocols, entry.protocol); } if (entry.supportedProtocols != undefined) { serialized.supportedProtocols = entry.supportedProtocols .map((p) => getEnumMemberName(Protocols, p)); } ret.push(serialized); } return ret; } case cacheKeys.controller.privateKey: { return Bytes.view(value).toString("hex"); } } 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", }, 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) => `endpoints.${index}`, }, }; export async function migrateLegacyNetworkCache(homeId, networkCache, valueDB, fs, cacheDir) { const cacheFile = path.join(cacheDir, `${homeId.toString(16)}.json`); try { const stat = await fs.stat(cacheFile); if (!stat.isFile()) return; } catch { // The file does not exist return; } const legacyContents = await fs.readFile(cacheFile); const legacy = JSON.parse(Bytes.view(legacyContents).toString("utf8")); const jsonl = networkCache; function tryMigrate(targetKey, source, sourcePath, converter) { 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 tryMigrate(cacheKeys.controller.provisioningList, legacy, legacyPaths.controller.provisioningList, tryParseProvisioningList); // All nodes, ... if (isObject(legacy.nodes)) { for (const node of Object.values(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(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(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); } } } } //# sourceMappingURL=NetworkCache.js.map