diff --git a/packages/core/src/capabilities/NodeInfo.ts b/packages/core/src/capabilities/NodeInfo.ts index eee547cf239a..afd5dd328b70 100644 --- a/packages/core/src/capabilities/NodeInfo.ts +++ b/packages/core/src/capabilities/NodeInfo.ts @@ -266,13 +266,15 @@ export function parseNodeProtocolInfo( let nodeType: NodeType; switch (capability & 0b1010) { - case 0b1000: - nodeType = NodeType["End Node"]; - break; case 0b0010: - default: nodeType = NodeType.Controller; break; + case 0b1000: + // Routing end node + default: + // Non-routing end node + nodeType = NodeType["End Node"]; + break; } const hasSpecificDeviceClass = !!(capability & 0b100); diff --git a/packages/core/src/consts/index.ts b/packages/core/src/consts/index.ts index ac9d11985779..851b37ef51ff 100644 --- a/packages/core/src/consts/index.ts +++ b/packages/core/src/consts/index.ts @@ -1,6 +1,9 @@ /** Max number of nodes in a ZWave network */ export const MAX_NODES = 232; +/** Max number of nodes in a Z-Wave LR network */ +export const MAX_NODES_LR = 4000; // FIXME: This seems too even, figure out the exact number + /** The broadcast target node id */ export const NODE_ID_BROADCAST = 0xff; diff --git a/packages/nvmedit/src/convert.test.ts.md b/packages/nvmedit/src/convert.test.ts.md index edb42938b836..a4c27259f56a 100644 --- a/packages/nvmedit/src/convert.test.ts.md +++ b/packages/nvmedit/src/convert.test.ts.md @@ -220,7 +220,7 @@ Generated by [AVA](https://avajs.dev). 6, ], nlwr: null, - nodeType: 0, + nodeType: 1, optionalFunctionality: true, pendingDiscovery: false, protocolVersion: 3, @@ -247,7 +247,7 @@ Generated by [AVA](https://avajs.dev). 6, ], nlwr: null, - nodeType: 0, + nodeType: 1, optionalFunctionality: true, pendingDiscovery: false, protocolVersion: 3, @@ -274,7 +274,7 @@ Generated by [AVA](https://avajs.dev). 6, ], nlwr: null, - nodeType: 0, + nodeType: 1, optionalFunctionality: true, pendingDiscovery: false, protocolVersion: 3, @@ -301,7 +301,7 @@ Generated by [AVA](https://avajs.dev). 6, ], nlwr: null, - nodeType: 0, + nodeType: 1, optionalFunctionality: true, pendingDiscovery: false, protocolVersion: 3, @@ -900,7 +900,7 @@ Generated by [AVA](https://avajs.dev). 12, ], nlwr: null, - nodeType: 0, + nodeType: 1, optionalFunctionality: true, pendingDiscovery: false, protocolVersion: 3, @@ -932,7 +932,7 @@ Generated by [AVA](https://avajs.dev). 12, ], nlwr: null, - nodeType: 0, + nodeType: 1, optionalFunctionality: true, pendingDiscovery: false, protocolVersion: 3, @@ -964,7 +964,7 @@ Generated by [AVA](https://avajs.dev). 12, ], nlwr: null, - nodeType: 0, + nodeType: 1, optionalFunctionality: true, pendingDiscovery: false, protocolVersion: 3, @@ -992,7 +992,7 @@ Generated by [AVA](https://avajs.dev). 8, ], nlwr: null, - nodeType: 0, + nodeType: 1, optionalFunctionality: true, pendingDiscovery: false, protocolVersion: 3, diff --git a/packages/nvmedit/src/convert.test.ts.snap b/packages/nvmedit/src/convert.test.ts.snap index b4d5570f19a7..4a6b6a846e10 100644 Binary files a/packages/nvmedit/src/convert.test.ts.snap and b/packages/nvmedit/src/convert.test.ts.snap differ diff --git a/packages/nvmedit/src/convert.ts b/packages/nvmedit/src/convert.ts index 8a127662d930..4b0167d66079 100644 --- a/packages/nvmedit/src/convert.ts +++ b/packages/nvmedit/src/convert.ts @@ -30,12 +30,16 @@ import { ControllerInfoFile, ControllerInfoFileID, type ControllerInfoFileOptions, + type LRNodeInfo, + LRNodeInfoFileV5, NVMFile, type NodeInfo, NodeInfoFileV0, NodeInfoFileV1, ProtocolAppRouteLockNodeMaskFile, ProtocolAppRouteLockNodeMaskFileID, + ProtocolLRNodeListFile, + ProtocolLRNodeListFileID, ProtocolNodeListFile, ProtocolNodeListFileID, ProtocolPendingDiscoveryNodeMaskFile, @@ -62,6 +66,7 @@ import { type SUCUpdateEntry, SUC_UPDATES_PER_FILE_V5, getEmptyRoute, + nodeIdToLRNodeInfoFileIDV5, nodeIdToNodeInfoFileIDV0, nodeIdToNodeInfoFileIDV1, nodeIdToRouteCacheFileIDV0, @@ -94,6 +99,7 @@ export interface NVMJSON { meta?: NVMMeta; controller: NVMJSONController; nodes: Record; + lrNodes?: Record; } export interface NVMJSONController { @@ -166,6 +172,13 @@ export interface NVMJSONVirtualNode { isVirtual: true; } +export interface NVMJSONLRNode + extends Omit +{ + genericDeviceClass: number; + specificDeviceClass?: number | null; +} + export type NVMJSONNode = NVMJSONNodeWithInfo | NVMJSONVirtualNode; type ParsedNVM = @@ -210,6 +223,22 @@ function createEmptyPhysicalNode(): NVMJSONNodeWithInfo { }; } +function createEmptyLRNode(): NVMJSONLRNode { + return { + isListening: false, + isFrequentListening: false, + isRouting: false, + supportedDataRates: [], + protocolVersion: 3, + optionalFunctionality: false, + nodeType: NodeType["End Node"], + supportsSecurity: true, + supportsBeaming: false, + genericDeviceClass: 0, + specificDeviceClass: null, + }; +} + /** Converts a compressed set of NVM objects to a JSON representation */ export function nvmObjectsToJSON( objects: ReadonlyMap, @@ -220,6 +249,12 @@ export function nvmObjectsToJSON( return nodes.get(id)!; }; + const lrNodes = new Map(); + const getLRNode = (id: number): NVMJSONLRNode => { + if (!lrNodes.has(id)) lrNodes.set(id, createEmptyLRNode()); + return lrNodes.get(id)!; + }; + const getObject = ( id: number | ((id: number) => boolean), ): NVM3Object | undefined => { @@ -391,6 +426,29 @@ export function nvmObjectsToJSON( delete node.nodeId; } + // If they exist, read info about LR nodes + const lrNodeIds = getFile( + ProtocolLRNodeListFileID, + protocolVersion, + )?.nodeIds; + if (lrNodeIds) { + for (const id of lrNodeIds) { + const node = getLRNode(id); + + // Find node info + const fileId = nodeIdToLRNodeInfoFileIDV5(id); + const file = getFileOrThrow( + fileId, + protocolVersion, + ); + const { nodeId, ...nodeInfo } = file.nodeInfos.find((i) => + i.nodeId === id + )!; + + Object.assign(node, nodeInfo); + } + } + // Now read info about the controller const controllerInfoFile = getFileOrThrow( ControllerInfoFileID, @@ -538,6 +596,9 @@ export function nvmObjectsToJSON( controller, nodes: mapToObject(nodes), }; + if (lrNodes.size > 0) { + ret.lrNodes = mapToObject(lrNodes); + } return ret; } @@ -566,6 +627,28 @@ function nvmJSONNodeToNodeInfo( }; } +function nvmJSONLRNodeToLRNodeInfo( + nodeId: number, + node: NVMJSONLRNode, +): LRNodeInfo { + return { + nodeId, + ...pick(node, [ + "isListening", + "isFrequentListening", + "isRouting", + "supportedDataRates", + "protocolVersion", + "optionalFunctionality", + "nodeType", + "supportsSecurity", + "supportsBeaming", + "genericDeviceClass", + "specificDeviceClass", + ]), + }; +} + function nvmJSONControllerToFileOptions( ctrlr: NVMJSONController, ): ControllerInfoFileOptions { @@ -989,8 +1072,10 @@ export function jsonToNVMObjects_v7_11_0( addProtocolObjects(protocolVersionFile.serialize()); const nodeInfoFiles = new Map(); + const lrNodeInfoFiles = new Map(); const routeCacheFiles = new Map(); const nodeInfoExists = new Set(); + const lrNodeInfoExists = new Set(); const routeCacheExists = new Set(); for (const [id, node] of Object.entries(target.nodes)) { @@ -1036,6 +1121,31 @@ export function jsonToNVMObjects_v7_11_0( } } + if (target.lrNodes) { + for (const [id, node] of Object.entries(target.lrNodes)) { + const nodeId = parseInt(id); + + lrNodeInfoExists.add(nodeId); + + // Create/update node info file + const nodeInfoFileIndex = nodeIdToLRNodeInfoFileIDV5(nodeId); + if (!lrNodeInfoFiles.has(nodeInfoFileIndex)) { + lrNodeInfoFiles.set( + nodeInfoFileIndex, + new LRNodeInfoFileV5({ + nodeInfos: [], + fileVersion: target.controller.protocolVersion, + }), + ); + } + const nodeInfoFile = lrNodeInfoFiles.get(nodeInfoFileIndex)!; + + nodeInfoFile.nodeInfos.push( + nvmJSONLRNodeToLRNodeInfo(nodeId, node), + ); + } + } + // For v3+ targets, the ControllerInfoFile must contain the LongRange properties // or the controller will ignore the file and not have a home ID if (targetProtocolFormat >= 3) { @@ -1072,6 +1182,18 @@ export function jsonToNVMObjects_v7_11_0( ); } + if (lrNodeInfoFiles.size > 0) { + addProtocolObjects( + new ProtocolLRNodeListFile({ + nodeIds: [...lrNodeInfoExists], + fileVersion: target.controller.protocolVersion, + }).serialize(), + ); + addProtocolObjects( + ...[...lrNodeInfoFiles.values()].map((f) => f.serialize()), + ); + } + return { applicationObjects, protocolObjects, @@ -1526,6 +1648,7 @@ export function migrateNVM(sourceNVM: Buffer, targetNVM: Buffer): Buffer { } else if (source.type === 500 && target.type === 700) { // We need to upgrade the source to 700 series const json: Required = { + lrNodes: {}, ...json500To700(source.json, true), meta: target.json.meta, }; diff --git a/packages/nvmedit/src/files/NodeInfoFiles.ts b/packages/nvmedit/src/files/NodeInfoFiles.ts index 5d33755878f6..7d79b8da5f94 100644 --- a/packages/nvmedit/src/files/NodeInfoFiles.ts +++ b/packages/nvmedit/src/files/NodeInfoFiles.ts @@ -1,7 +1,11 @@ import { + type DataRate, + type FLiRS, MAX_NODES, + MAX_NODES_LR, NUM_NODEMASK_BYTES, type NodeProtocolInfo, + NodeType, encodeBitMask, encodeNodeProtocolInfo, parseBitMask, @@ -18,7 +22,9 @@ import { } from "./NVMFile"; export const NODEINFOS_PER_FILE_V1 = 4; +export const LR_NODEINFOS_PER_FILE_V5 = 50; const NODEINFO_SIZE = 1 + 5 + NUM_NODEMASK_BYTES; +const LR_NODEINFO_SIZE = 3; const EMPTY_NODEINFO_FILL = 0xff; const emptyNodeInfo = Buffer.alloc(NODEINFO_SIZE, EMPTY_NODEINFO_FILL); @@ -60,7 +66,7 @@ function parseNodeInfo( } function encodeNodeInfo(nodeInfo: NodeInfo): Buffer { - const ret = Buffer.alloc(1 + 5 + NUM_NODEMASK_BYTES); + const ret = Buffer.alloc(NODEINFO_SIZE); const hasSpecificDeviceClass = nodeInfo.specificDeviceClass != null; const protocolInfo: NodeProtocolInfo = { @@ -87,6 +93,96 @@ function encodeNodeInfo(nodeInfo: NodeInfo): Buffer { return ret; } +export interface LRNodeInfo + extends Omit +{ + nodeId: number; + genericDeviceClass: number; + specificDeviceClass?: number | null; +} + +function parseLRNodeInfo( + nodeId: number, + buffer: Buffer, + offset: number, +): LRNodeInfo { + // The node info in LR NVM files is packed: + // Byte 0 CAPABILITY: + // Bit 0: Routing (?) + // Bit 1: Listening + // Bit 2: has specific device class (?) + // Bit 3: Beam capability + // Bit 4: Optional functionality + // Bits 5-6: FLiRS + // Bit 7: Unused (?) + // Byte 1: Generic device class + // Byte 2: Specific device class + + // Protocol version is always 3 + // Security is always true + // Supported speed is always 100kbps (speed = 0, speed ext = 2) + // Never: routing end node, controller + + const capability = buffer[offset]; + const isRouting = !!(capability & 0b0000_0001); // ZWLR Mesh?? + const isListening = !!(capability & 0b0000_0010); + const hasSpecificDeviceClass = !!(capability & 0b0000_0100); + const supportsBeaming = !!(capability & 0b0000_1000); + const optionalFunctionality = !!(capability & 0b0001_0000); + let isFrequentListening: FLiRS; + switch (capability & 0b0110_0000) { + case 0b0100_0000: + isFrequentListening = "1000ms"; + break; + case 0b0010_0000: + isFrequentListening = "250ms"; + break; + default: + isFrequentListening = false; + } + const nodeType = NodeType["End Node"]; + const supportsSecurity = true; + const protocolVersion = 3; + const supportedDataRates: DataRate[] = [100000]; + + return { + nodeId, + isRouting, + isListening, + supportsBeaming, + isFrequentListening, + optionalFunctionality, + nodeType, + supportsSecurity, + protocolVersion, + supportedDataRates, + genericDeviceClass: buffer[offset + 1], + specificDeviceClass: hasSpecificDeviceClass ? buffer[offset + 2] : null, + }; +} + +function encodeLRNodeInfo(nodeInfo: LRNodeInfo): Buffer { + const ret = Buffer.alloc(LR_NODEINFO_SIZE); + + let capability = 0; + if (nodeInfo.isRouting) capability |= 0b0000_0001; + if (nodeInfo.isListening) capability |= 0b0000_0010; + if (nodeInfo.specificDeviceClass != null) capability |= 0b0000_0100; + if (nodeInfo.supportsBeaming) capability |= 0b0000_1000; + if (nodeInfo.optionalFunctionality) capability |= 0b0001_0000; + if (nodeInfo.isFrequentListening === "1000ms") { + capability |= 0b0100_0000; + } else if (nodeInfo.isFrequentListening === "250ms") { + capability |= 0b0010_0000; + } + + ret[0] = capability; + ret[1] = nodeInfo.genericDeviceClass; + ret[2] = nodeInfo.specificDeviceClass ?? 0; + + return ret; +} + export interface NodeInfoFileV0Options extends NVMFileCreationOptions { nodeInfo: NodeInfo; } @@ -160,11 +256,18 @@ export class NodeInfoFileV1 extends NVMFile { * NODEINFOS_PER_FILE_V1 + 1 + i; - const offset = i * 35; - const entry = this.payload.subarray(offset, offset + 35); + const offset = i * NODEINFO_SIZE; + const entry = this.payload.subarray( + offset, + offset + NODEINFO_SIZE, + ); if (entry.equals(emptyNodeInfo)) continue; - const nodeInfo = parseNodeInfo(nodeId, this.payload, i * 35); + const nodeInfo = parseNodeInfo( + nodeId, + entry, + 0, + ); this.nodeInfos.push(nodeInfo); } } else { @@ -206,3 +309,88 @@ export class NodeInfoFileV1 extends NVMFile { }; } } + +export interface LRNodeInfoFileV5Options extends NVMFileCreationOptions { + nodeInfos: LRNodeInfo[]; +} + +export const LRNodeInfoFileV5IDBase = 0x50800; +export function nodeIdToLRNodeInfoFileIDV5(nodeId: number): number { + return ( + LRNodeInfoFileV5IDBase + + Math.floor((nodeId - 256) / LR_NODEINFOS_PER_FILE_V5) + ); +} + +// Counting starts with 5, because we only implemented this after reaching protocol file format 5 +@nvmFileID( + (id) => + id >= LRNodeInfoFileV5IDBase + && id + < LRNodeInfoFileV5IDBase + MAX_NODES_LR / LR_NODEINFOS_PER_FILE_V5, +) +export class LRNodeInfoFileV5 extends NVMFile { + public constructor( + options: NVMFileDeserializationOptions | LRNodeInfoFileV5Options, + ) { + super(options); + if (gotDeserializationOptions(options)) { + this.nodeInfos = []; + for (let i = 0; i < LR_NODEINFOS_PER_FILE_V5; i++) { + const nodeId = (this.fileId - LRNodeInfoFileV5IDBase) + * LR_NODEINFOS_PER_FILE_V5 + + 256 + + i; + const offset = i * LR_NODEINFO_SIZE; + const entry = this.payload.subarray( + offset, + offset + LR_NODEINFO_SIZE, + ); + if (entry.equals(emptyNodeInfo)) continue; + + const nodeInfo = parseLRNodeInfo( + nodeId, + entry, + 0, + ); + this.nodeInfos.push(nodeInfo); + } + } else { + this.nodeInfos = options.nodeInfos; + } + } + + public nodeInfos: LRNodeInfo[]; + + public serialize(): NVM3Object { + // The infos must be sorted by node ID + this.nodeInfos.sort((a, b) => a.nodeId - b.nodeId); + const minNodeId = this.nodeInfos[0].nodeId; + this.fileId = nodeIdToLRNodeInfoFileIDV5(minNodeId); + + this.payload = Buffer.alloc( + LR_NODEINFO_SIZE * LR_NODEINFOS_PER_FILE_V5, + EMPTY_NODEINFO_FILL, + ); + + const minFileNodeId = + Math.floor((minNodeId - 256) / LR_NODEINFOS_PER_FILE_V5) + * LR_NODEINFOS_PER_FILE_V5 + + 256; + + for (const nodeInfo of this.nodeInfos) { + const offset = (nodeInfo.nodeId - minFileNodeId) * LR_NODEINFO_SIZE; + encodeLRNodeInfo(nodeInfo).copy(this.payload, offset); + } + + return super.serialize(); + } + + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + public toJSON() { + return { + ...super.toJSON(), + "node infos": this.nodeInfos, + }; + } +} diff --git a/packages/zwave-js/src/lib/controller/Controller.ts b/packages/zwave-js/src/lib/controller/Controller.ts index 54151c3e69ce..f7d10071425c 100644 --- a/packages/zwave-js/src/lib/controller/Controller.ts +++ b/packages/zwave-js/src/lib/controller/Controller.ts @@ -1734,8 +1734,6 @@ export class ZWaveController /** * Gets the list of long range nodes from the controller. - * Warning: This only works when followed up by a hard-reset, so don't call this directly - * @internal */ public async getLongRangeNodes(): Promise { const nodeIds: number[] = [];