/** * @license * Copyright 2025-2026 Open Home Foundation * SPDX-License-Identifier: Apache-2.0 */ import { Logger, NodeId, Observable } from "@matter/main"; import { parseBigIntAwareJson, splitAttributePath } from "../server/Converters.js"; import { AttributeResponseStatus, AttributesData, InvokeRequest, MatterNodeData, NodeCommandHandler, WriteAttributeRequest, } from "../types/CommandHandler.js"; import { MatterNode, ServerError, TEST_NODE_START } from "../types/WebSocketMessageTypes.js"; const logger = Logger.get("TestNodeCommandHandler"); /** * Command handler for test nodes (imported diagnostic dumps). * Test nodes are stored in memory and provide mock responses for commands. */ export class TestNodeCommandHandler implements NodeCommandHandler { #testNodes = new Map(); /** Observable for node added events */ readonly nodeAdded = new Observable<[nodeId: NodeId, node: MatterNode]>(); /** Observable for node removed events */ readonly nodeRemoved = new Observable<[nodeId: NodeId]>(); /** * Check if a node ID is in the test node range (>= TEST_NODE_START). */ static isTestNodeId(nodeId: number | bigint): boolean { const bigId = typeof nodeId === "bigint" ? nodeId : BigInt(nodeId); return bigId >= TEST_NODE_START; } /** * Check if this handler manages the given node ID. */ hasNode(nodeId: NodeId): boolean { return this.#testNodes.has(BigInt(nodeId)); } /** * Get all test node IDs. */ getNodeIds(): NodeId[] { return Array.from(this.#testNodes.keys()).map(id => NodeId(id)); } /** * Get all test nodes. */ getNodes(): MatterNode[] { return Array.from(this.#testNodes.values()); } /** * Get a test node by ID. */ getNode(nodeId: NodeId): MatterNode | undefined { return this.#testNodes.get(BigInt(nodeId)); } /** * Get full node details in WebSocket API format. */ async getNodeDetails(nodeId: NodeId): Promise { const testNode = this.#testNodes.get(BigInt(nodeId)); if (testNode === undefined) { throw ServerError.nodeNotExists(nodeId); } return testNode; } /** * Read multiple attributes from a test node by path strings. * Handles wildcards in paths. */ async handleReadAttributes( nodeId: NodeId, attributePaths: string[], _fabricFiltered?: boolean, ): Promise { const testNode = this.#testNodes.get(BigInt(nodeId)); if (testNode === undefined) { throw ServerError.nodeNotExists(nodeId); } const result: AttributesData = {}; for (const path of attributePaths) { const { endpointId, clusterId, attributeId } = splitAttributePath(path); // Handle wildcards by matching all attributes if (path.includes("*")) { for (const [attrPath, value] of Object.entries(testNode.attributes)) { const parts = attrPath.split("/").map(Number); if ( (endpointId === undefined || parts[0] === endpointId) && (clusterId === undefined || parts[1] === clusterId) && (attributeId === undefined || parts[2] === attributeId) ) { result[attrPath] = value; } } } else { result[path] = testNode.attributes[path]; } } return result; } /** * Import test nodes from a diagnostic dump. * @param dump JSON string containing the diagnostic dump * @returns Array of imported node IDs */ importTestNodes(dump: string): NodeId[] { // Parse the JSON dump (handles large node IDs as BigInt) const dumpData = parseBigIntAwareJson(dump) as any; // Extract nodes from dump - can be single node or multiple nodes // Format from Home Assistant diagnostics: // - Single node: dump_data.data.node // - Multiple nodes (server dump): dump_data.data.server.nodes let dumpNodes: Array; if (dumpData?.data?.node) { dumpNodes = [dumpData.data.node]; } else if (dumpData?.data?.server?.nodes) { dumpNodes = Object.values(dumpData.data.server.nodes); } else if (dumpData?.data?.nodes) { // Alternative format: direct nodes array dumpNodes = Object.values(dumpData.data.nodes); } else { throw ServerError.invalidArguments("Invalid dump format: cannot find node data"); } // Find the next available test node ID let nextTestNodeId: bigint = TEST_NODE_START; for (const existingId of this.#testNodes.keys()) { if (existingId >= nextTestNodeId) { nextTestNodeId = existingId + 1n; } } const importedNodeIds: NodeId[] = []; // Process each node from the dump for (const nodeDict of dumpNodes) { const testNodeId: bigint = nextTestNodeId++; const nodeId = NodeId(testNodeId); // Create MatterNode with test node ID, keeping original attributes as-is const testNode: MatterNode = { node_id: testNodeId, date_commissioned: nodeDict.date_commissioned, last_interview: nodeDict.last_interview, interview_version: nodeDict.interview_version, available: nodeDict.available, is_bridge: nodeDict.is_bridge, attributes: nodeDict.attributes, attribute_subscriptions: [], }; // Store the test node this.#testNodes.set(testNodeId, testNode); importedNodeIds.push(nodeId); logger.info(`Imported test node ${testNodeId} with ${Object.keys(testNode.attributes).length} attributes`); // Emit node_added event this.nodeAdded.emit(nodeId, testNode); } return importedNodeIds; } /** * Write an attribute to a test node. * Logs the write and returns success (no actual write occurs). */ async handleWriteAttribute(data: WriteAttributeRequest): Promise { const { nodeId, endpointId, clusterId, attributeId, value } = data; logger.debug( `write_attribute for test node ${nodeId} on ${endpointId}/${clusterId}/${attributeId} - value: ${JSON.stringify(value)}`, ); return { endpointId, clusterId, attributeId, status: 0, // Success }; } /** * Invoke a command on a test node. * Logs the command and returns null (no actual command execution). */ async handleInvoke(data: InvokeRequest): Promise { const { nodeId, endpointId, clusterId, commandName, data: payload } = data; logger.debug( `device_command for test node ${nodeId} on endpoint ${endpointId} - ` + `cluster ${clusterId} - command ${commandName} - payload: ${JSON.stringify(payload)}`, ); return null; } /** * Get IP addresses for a test node. * Returns mock IP addresses. */ async getNodeIpAddresses(_nodeId: NodeId, _preferCache?: boolean): Promise { return ["0.0.0.0", "0000:1111:2222:3333:4444"]; } /** * Ping a test node. * Returns mock success results. */ async pingNode(_nodeId: NodeId, _attempts?: number): Promise> { return { "0.0.0.0": true, "0000:1111:2222:3333:4444": true }; } /** * Remove a test node. */ async removeNode(nodeId: NodeId): Promise { const bigId = BigInt(nodeId); if (!this.#testNodes.has(bigId)) { throw ServerError.nodeNotExists(nodeId); } logger.info(`Removing test node ${nodeId}`); this.#testNodes.delete(bigId); // Emit node_removed event this.nodeRemoved.emit(nodeId); } }