@matter-server/ws-client 0.2.7-alpha.0-20260118-45c7af0 → 0.2.7-alpha.0-20260119-49e7237

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { Connection, WebSocketFactory } from "./connection.js";
8
- import { InvalidServerVersion } from "./exceptions.js";
8
+ import { CommandTimeoutError, ConnectionClosedError, InvalidServerVersion } from "./exceptions.js";
9
9
  import {
10
10
  AccessControlEntry,
11
11
  APICommands,
@@ -31,16 +31,28 @@ function toNodeKey(nodeId: number | bigint): string {
31
31
  return String(nodeId);
32
32
  }
33
33
 
34
+ /** Default timeout for WebSocket commands in milliseconds (5 minutes) */
35
+ export const DEFAULT_COMMAND_TIMEOUT = 5 * 60 * 1000;
36
+
34
37
  export class MatterClient {
35
38
  public connection: Connection;
36
39
  public nodes: Record<string, MatterNode> = {};
37
40
  public serverBaseAddress: string;
38
41
  /** Whether this client is connected to a production server (optional, for UI purposes) */
39
42
  public isProduction: boolean = false;
43
+ /** Default timeout for commands in milliseconds. Set to 0 to disable timeouts. */
44
+ public commandTimeout: number = DEFAULT_COMMAND_TIMEOUT;
40
45
  // Using 'unknown' for resolve since the actual types vary by command
41
- private result_futures: Record<string, { resolve: (value: unknown) => void; reject: (reason?: unknown) => void }> =
42
- {};
43
- private msgId = 0;
46
+ private result_futures: Record<
47
+ string,
48
+ {
49
+ resolve: (value: unknown) => void;
50
+ reject: (reason?: unknown) => void;
51
+ timeoutId?: ReturnType<typeof setTimeout>;
52
+ }
53
+ > = {};
54
+ // Start with random offset for defense-in-depth and easier debugging across sessions
55
+ private msgId = Math.floor(Math.random() * 0x7fffffff);
44
56
  private eventListeners: Record<string, Array<() => void>> = {};
45
57
 
46
58
  /**
@@ -73,134 +85,182 @@ export class MatterClient {
73
85
  };
74
86
  }
75
87
 
76
- async commissionWithCode(code: string, networkOnly = true): Promise<MatterNode> {
88
+ async commissionWithCode(code: string, networkOnly = true, timeout?: number): Promise<MatterNode> {
77
89
  // Commission a device using a QR Code or Manual Pairing Code.
78
90
  // code: The QR Code or Manual Pairing Code for device commissioning.
79
91
  // network_only: If True, restricts device discovery to network only.
92
+ // timeout: Optional command timeout in milliseconds.
80
93
  // Returns: The NodeInfo of the commissioned device.
81
- return await this.sendCommand("commission_with_code", 0, {
82
- code: code,
83
- network_only: networkOnly,
84
- });
94
+ return await this.sendCommand(
95
+ "commission_with_code",
96
+ 0,
97
+ {
98
+ code: code,
99
+ network_only: networkOnly,
100
+ },
101
+ timeout,
102
+ );
85
103
  }
86
104
 
87
- async setWifiCredentials(ssid: string, credentials: string) {
105
+ async setWifiCredentials(ssid: string, credentials: string, timeout?: number): Promise<void> {
88
106
  // Set WiFi credentials for commissioning to a (new) device.
89
- await this.sendCommand("set_wifi_credentials", 0, { ssid, credentials });
107
+ await this.sendCommand("set_wifi_credentials", 0, { ssid, credentials }, timeout);
90
108
  }
91
109
 
92
- async setThreadOperationalDataset(dataset: string) {
110
+ async setThreadOperationalDataset(dataset: string, timeout?: number): Promise<void> {
93
111
  // Set Thread Operational dataset in the stack.
94
- await this.sendCommand("set_thread_dataset", 0, { dataset });
112
+ await this.sendCommand("set_thread_dataset", 0, { dataset }, timeout);
95
113
  }
96
114
 
97
115
  async openCommissioningWindow(
98
116
  nodeId: number | bigint,
99
- timeout?: number,
117
+ windowTimeout?: number,
100
118
  iteration?: number,
101
119
  option?: number,
102
120
  discriminator?: number,
121
+ timeout?: number,
103
122
  ): Promise<CommissioningParameters> {
104
123
  // Open a commissioning window to commission a device present on this controller to another.
124
+ // windowTimeout: How long to keep the commissioning window open (in seconds).
125
+ // timeout: Optional command timeout in milliseconds.
105
126
  // Returns code to use as discriminator.
106
- return await this.sendCommand("open_commissioning_window", 0, {
107
- node_id: nodeId,
127
+ return await this.sendCommand(
128
+ "open_commissioning_window",
129
+ 0,
130
+ {
131
+ node_id: nodeId,
132
+ timeout: windowTimeout,
133
+ iteration,
134
+ option,
135
+ discriminator,
136
+ },
108
137
  timeout,
109
- iteration,
110
- option,
111
- discriminator,
112
- });
138
+ );
113
139
  }
114
140
 
115
- async discoverCommissionableNodes(): Promise<CommissionableNodeData[]> {
141
+ async discoverCommissionableNodes(timeout?: number): Promise<CommissionableNodeData[]> {
116
142
  // Discover Commissionable Nodes (discovered on BLE or mDNS).
117
- return await this.sendCommand("discover_commissionable_nodes", 0, {});
143
+ return await this.sendCommand("discover_commissionable_nodes", 0, {}, timeout);
118
144
  }
119
145
 
120
- async getMatterFabrics(nodeId: number | bigint): Promise<MatterFabricData[]> {
146
+ async getMatterFabrics(nodeId: number | bigint, timeout?: number): Promise<MatterFabricData[]> {
121
147
  // Get Matter fabrics from a device.
122
148
  // Returns a list of MatterFabricData objects.
123
- return await this.sendCommand("get_matter_fabrics", 3, { node_id: nodeId });
149
+ return await this.sendCommand("get_matter_fabrics", 3, { node_id: nodeId }, timeout);
124
150
  }
125
151
 
126
- async removeMatterFabric(nodeId: number | bigint, fabricIndex: number) {
152
+ async removeMatterFabric(nodeId: number | bigint, fabricIndex: number, timeout?: number): Promise<void> {
127
153
  // Remove a Matter fabric from a device.
128
- await this.sendCommand("remove_matter_fabric", 3, { node_id: nodeId, fabric_index: fabricIndex });
154
+ await this.sendCommand("remove_matter_fabric", 3, { node_id: nodeId, fabric_index: fabricIndex }, timeout);
129
155
  }
130
156
 
131
- async pingNode(nodeId: number | bigint, attempts = 1): Promise<NodePingResult> {
157
+ async pingNode(nodeId: number | bigint, attempts = 1, timeout?: number): Promise<NodePingResult> {
132
158
  // Ping node on the currently known IP-address(es).
133
- return await this.sendCommand("ping_node", 0, { node_id: nodeId, attempts });
159
+ return await this.sendCommand("ping_node", 0, { node_id: nodeId, attempts }, timeout);
134
160
  }
135
161
 
136
- async getNodeIPAddresses(nodeId: number | bigint, preferCache?: boolean, scoped?: boolean): Promise<string[]> {
162
+ async getNodeIPAddresses(
163
+ nodeId: number | bigint,
164
+ preferCache?: boolean,
165
+ scoped?: boolean,
166
+ timeout?: number,
167
+ ): Promise<string[]> {
137
168
  // Return the currently known (scoped) IP-address(es).
138
- return await this.sendCommand("get_node_ip_addresses", 8, {
139
- node_id: nodeId,
140
- prefer_cache: preferCache,
141
- scoped: scoped,
142
- });
169
+ return await this.sendCommand(
170
+ "get_node_ip_addresses",
171
+ 8,
172
+ {
173
+ node_id: nodeId,
174
+ prefer_cache: preferCache,
175
+ scoped: scoped,
176
+ },
177
+ timeout,
178
+ );
143
179
  }
144
180
 
145
- async removeNode(nodeId: number | bigint) {
181
+ async removeNode(nodeId: number | bigint, timeout?: number): Promise<void> {
146
182
  // Remove a Matter node/device from the fabric.
147
- await this.sendCommand("remove_node", 0, { node_id: nodeId });
183
+ await this.sendCommand("remove_node", 0, { node_id: nodeId }, timeout);
148
184
  }
149
185
 
150
- async interviewNode(nodeId: number | bigint) {
186
+ async interviewNode(nodeId: number | bigint, timeout?: number): Promise<void> {
151
187
  // Interview a node.
152
- await this.sendCommand("interview_node", 0, { node_id: nodeId });
188
+ await this.sendCommand("interview_node", 0, { node_id: nodeId }, timeout);
153
189
  }
154
190
 
155
- async importTestNode(dump: string) {
191
+ async importTestNode(dump: string, timeout?: number): Promise<void> {
156
192
  // Import test node(s) from a HA or Matter server diagnostics dump.
157
- await this.sendCommand("import_test_node", 0, { dump });
193
+ await this.sendCommand("import_test_node", 0, { dump }, timeout);
158
194
  }
159
195
 
160
- async readAttribute(nodeId: number | bigint, attributePath: string | string[]): Promise<Record<string, unknown>> {
196
+ async readAttribute(
197
+ nodeId: number | bigint,
198
+ attributePath: string | string[],
199
+ timeout?: number,
200
+ ): Promise<Record<string, unknown>> {
161
201
  // Read one or more attribute(s) on a node by specifying an attributepath.
162
- return await this.sendCommand("read_attribute", 0, { node_id: nodeId, attribute_path: attributePath });
202
+ return await this.sendCommand("read_attribute", 0, { node_id: nodeId, attribute_path: attributePath }, timeout);
163
203
  }
164
204
 
165
- async writeAttribute(nodeId: number | bigint, attributePath: string, value: unknown): Promise<unknown> {
205
+ async writeAttribute(
206
+ nodeId: number | bigint,
207
+ attributePath: string,
208
+ value: unknown,
209
+ timeout?: number,
210
+ ): Promise<unknown> {
166
211
  // Write an attribute(value) on a target node.
167
- return await this.sendCommand("write_attribute", 0, {
168
- node_id: nodeId,
169
- attribute_path: attributePath,
170
- value: value,
171
- });
212
+ return await this.sendCommand(
213
+ "write_attribute",
214
+ 0,
215
+ {
216
+ node_id: nodeId,
217
+ attribute_path: attributePath,
218
+ value: value,
219
+ },
220
+ timeout,
221
+ );
172
222
  }
173
223
 
174
- async checkNodeUpdate(nodeId: number | bigint): Promise<MatterSoftwareVersion | null> {
224
+ async checkNodeUpdate(nodeId: number | bigint, timeout?: number): Promise<MatterSoftwareVersion | null> {
175
225
  // Check if there is an update for a particular node.
176
226
  // Reads the current software version and checks the DCL if there is an update
177
227
  // available. If there is an update available, the command returns the version
178
228
  // information of the latest update available.
179
- return await this.sendCommand("check_node_update", 10, { node_id: nodeId });
229
+ return await this.sendCommand("check_node_update", 10, { node_id: nodeId }, timeout);
180
230
  }
181
231
 
182
- async updateNode(nodeId: number | bigint, softwareVersion: number | string) {
232
+ async updateNode(nodeId: number | bigint, softwareVersion: number | string, timeout?: number): Promise<void> {
183
233
  // Update a node to a new software version.
184
234
  // This command checks if the requested software version is indeed still available
185
235
  // and if so, it will start the update process. The update process will be handled
186
236
  // by the built-in OTA provider. The OTA provider will download the update and
187
237
  // notify the node about the new update.
188
- await this.sendCommand("update_node", 10, { node_id: nodeId, software_version: softwareVersion });
238
+ await this.sendCommand("update_node", 10, { node_id: nodeId, software_version: softwareVersion }, timeout);
189
239
  }
190
240
 
191
- async setACLEntry(nodeId: number | bigint, entry: AccessControlEntry[]) {
192
- return await this.sendCommand("set_acl_entry", 0, {
193
- node_id: nodeId,
194
- entry: entry,
195
- });
241
+ async setACLEntry(nodeId: number | bigint, entry: AccessControlEntry[], timeout?: number) {
242
+ return await this.sendCommand(
243
+ "set_acl_entry",
244
+ 0,
245
+ {
246
+ node_id: nodeId,
247
+ entry: entry,
248
+ },
249
+ timeout,
250
+ );
196
251
  }
197
252
 
198
- async setNodeBinding(nodeId: number | bigint, endpoint: number, bindings: BindingTarget[]) {
199
- return await this.sendCommand("set_node_binding", 0, {
200
- node_id: nodeId,
201
- endpoint: endpoint,
202
- bindings: bindings,
203
- });
253
+ async setNodeBinding(nodeId: number | bigint, endpoint: number, bindings: BindingTarget[], timeout?: number) {
254
+ return await this.sendCommand(
255
+ "set_node_binding",
256
+ 0,
257
+ {
258
+ node_id: nodeId,
259
+ endpoint: endpoint,
260
+ bindings: bindings,
261
+ },
262
+ timeout,
263
+ );
204
264
  }
205
265
 
206
266
  async deviceCommand(
@@ -209,43 +269,50 @@ export class MatterClient {
209
269
  clusterId: number,
210
270
  commandName: string,
211
271
  payload: Record<string, unknown> = {},
272
+ timeout?: number,
212
273
  ): Promise<unknown> {
213
- return await this.sendCommand("device_command", 0, {
214
- node_id: nodeId,
215
- endpoint_id: endpointId,
216
- cluster_id: clusterId,
217
- command_name: commandName,
218
- payload,
219
- response_type: null,
220
- });
274
+ return await this.sendCommand(
275
+ "device_command",
276
+ 0,
277
+ {
278
+ node_id: nodeId,
279
+ endpoint_id: endpointId,
280
+ cluster_id: clusterId,
281
+ command_name: commandName,
282
+ payload,
283
+ response_type: null,
284
+ },
285
+ timeout,
286
+ );
221
287
  }
222
288
 
223
- async getNodes(onlyAvailable = false): Promise<MatterNode[]> {
224
- return await this.sendCommand("get_nodes", 0, { only_available: onlyAvailable });
289
+ async getNodes(onlyAvailable = false, timeout?: number): Promise<MatterNode[]> {
290
+ return await this.sendCommand("get_nodes", 0, { only_available: onlyAvailable }, timeout);
225
291
  }
226
292
 
227
- async getNode(nodeId: number | bigint): Promise<MatterNode> {
228
- return await this.sendCommand("get_node", 0, { node_id: nodeId });
293
+ async getNode(nodeId: number | bigint, timeout?: number): Promise<MatterNode> {
294
+ return await this.sendCommand("get_node", 0, { node_id: nodeId }, timeout);
229
295
  }
230
296
 
231
- async getVendorNames(filterVendors?: number[]): Promise<Record<string, string>> {
232
- return await this.sendCommand("get_vendor_names", 0, { filter_vendors: filterVendors });
297
+ async getVendorNames(filterVendors?: number[], timeout?: number): Promise<Record<string, string>> {
298
+ return await this.sendCommand("get_vendor_names", 0, { filter_vendors: filterVendors }, timeout);
233
299
  }
234
300
 
235
- async fetchServerInfo() {
236
- return await this.sendCommand("server_info", 0, {});
301
+ async fetchServerInfo(timeout?: number) {
302
+ return await this.sendCommand("server_info", 0, {}, timeout);
237
303
  }
238
304
 
239
- async setDefaultFabricLabel(label: string | null) {
240
- await this.sendCommand("set_default_fabric_label", 0, { label });
305
+ async setDefaultFabricLabel(label: string | null, timeout?: number): Promise<void> {
306
+ await this.sendCommand("set_default_fabric_label", 0, { label }, timeout);
241
307
  }
242
308
 
243
309
  /**
244
310
  * Get the current log levels for console and file logging.
311
+ * @param timeout Optional command timeout in milliseconds
245
312
  * @returns The current log level configuration
246
313
  */
247
- async getLogLevel(): Promise<LogLevelResponse> {
248
- return await this.sendCommand("get_loglevel", 0, {});
314
+ async getLogLevel(timeout?: number): Promise<LogLevelResponse> {
315
+ return await this.sendCommand("get_loglevel", 0, {}, timeout);
249
316
  }
250
317
 
251
318
  /**
@@ -253,19 +320,39 @@ export class MatterClient {
253
320
  * Changes are temporary and will be reset when the server restarts.
254
321
  * @param consoleLoglevel Console log level to set (optional)
255
322
  * @param fileLoglevel File log level to set, only applied if file logging is enabled (optional)
323
+ * @param timeout Optional command timeout in milliseconds
256
324
  * @returns The log level configuration after the change
257
325
  */
258
- async setLogLevel(consoleLoglevel?: LogLevelString, fileLoglevel?: LogLevelString): Promise<LogLevelResponse> {
259
- return await this.sendCommand("set_loglevel", 0, {
260
- console_loglevel: consoleLoglevel,
261
- file_loglevel: fileLoglevel,
262
- });
326
+ async setLogLevel(
327
+ consoleLoglevel?: LogLevelString,
328
+ fileLoglevel?: LogLevelString,
329
+ timeout?: number,
330
+ ): Promise<LogLevelResponse> {
331
+ return await this.sendCommand(
332
+ "set_loglevel",
333
+ 0,
334
+ {
335
+ console_loglevel: consoleLoglevel,
336
+ file_loglevel: fileLoglevel,
337
+ },
338
+ timeout,
339
+ );
263
340
  }
264
341
 
342
+ /**
343
+ * Send a command to the Matter server.
344
+ * @param command The command name
345
+ * @param require_schema Minimum schema version required (0 for any version)
346
+ * @param args Command arguments
347
+ * @param timeout Optional timeout in milliseconds. Defaults to `commandTimeout`. Set to 0 to disable.
348
+ * @returns Promise that resolves with the command result
349
+ * @throws Error if the command times out or fails
350
+ */
265
351
  sendCommand<T extends keyof APICommands>(
266
352
  command: T,
267
353
  require_schema: number | undefined = undefined,
268
354
  args: APICommands[T]["requestArgs"],
355
+ timeout = this.commandTimeout,
269
356
  ): Promise<APICommands[T]["response"]> {
270
357
  if (require_schema && this.serverInfo.schema_version < require_schema) {
271
358
  throw new InvalidServerVersion(
@@ -274,26 +361,88 @@ export class MatterClient {
274
361
  );
275
362
  }
276
363
 
277
- const messageId = ++this.msgId;
364
+ // Reset counter before overflow to maintain precision
365
+ if (this.msgId >= Number.MAX_SAFE_INTEGER) {
366
+ this.msgId = 0;
367
+ }
368
+ const messageId = String(++this.msgId);
278
369
 
279
370
  const message = {
280
- message_id: messageId.toString(),
371
+ message_id: messageId,
281
372
  command,
282
373
  args,
283
374
  };
284
375
 
285
- const messagePromise = new Promise<APICommands[T]["response"]>((resolve, reject) => {
376
+ return new Promise<APICommands[T]["response"]>((resolve, reject) => {
377
+ // Set up timeout if enabled
378
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
379
+ if (timeout > 0) {
380
+ timeoutId = setTimeout(() => {
381
+ // Check if still pending (not already resolved/rejected)
382
+ const pending = this.result_futures[messageId];
383
+ if (pending) {
384
+ // Clear timeout and delete entry BEFORE rejecting to prevent double resolution
385
+ if (pending.timeoutId) {
386
+ clearTimeout(pending.timeoutId);
387
+ }
388
+ delete this.result_futures[messageId];
389
+ reject(new CommandTimeoutError(command, timeout));
390
+ }
391
+ }, timeout);
392
+ }
393
+
286
394
  // Type-erased storage: resolve/reject are stored as unknown handlers
287
395
  this.result_futures[messageId] = {
288
396
  resolve: resolve as (value: unknown) => void,
289
397
  reject,
398
+ timeoutId,
290
399
  };
291
400
  this.connection.sendMessage(message);
292
401
  });
402
+ }
293
403
 
294
- return messagePromise.finally(() => {
404
+ /**
405
+ * Safely resolve a pending command, ensuring it's only resolved once.
406
+ * Clears timeout and removes from pending futures before resolving.
407
+ */
408
+ private _resolvePendingCommand(messageId: string, result: unknown): void {
409
+ const pending = this.result_futures[messageId];
410
+ if (pending) {
411
+ // Clear timeout and delete entry BEFORE resolving to prevent double resolution
412
+ if (pending.timeoutId) {
413
+ clearTimeout(pending.timeoutId);
414
+ }
295
415
  delete this.result_futures[messageId];
296
- });
416
+ pending.resolve(result);
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Safely reject a pending command, ensuring it's only rejected once.
422
+ * Clears timeout and removes from pending futures before rejecting.
423
+ */
424
+ private _rejectPendingCommand(messageId: string, error: Error): void {
425
+ const pending = this.result_futures[messageId];
426
+ if (pending) {
427
+ // Clear timeout and delete entry BEFORE rejecting to prevent double resolution
428
+ if (pending.timeoutId) {
429
+ clearTimeout(pending.timeoutId);
430
+ }
431
+ delete this.result_futures[messageId];
432
+ pending.reject(error);
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Reject all pending commands with a ConnectionClosedError.
438
+ * Called when the connection is closed or lost.
439
+ */
440
+ private _rejectAllPendingCommands(): void {
441
+ const error = new ConnectionClosedError();
442
+ const pendingIds = Object.keys(this.result_futures);
443
+ for (const messageId of pendingIds) {
444
+ this._rejectPendingCommand(messageId, error);
445
+ }
297
446
  }
298
447
 
299
448
  async connect() {
@@ -302,11 +451,16 @@ export class MatterClient {
302
451
  }
303
452
  await this.connection.connect(
304
453
  msg => this._handleIncomingMessage(msg as IncomingMessage),
305
- () => this.fireEvent("connection_lost"),
454
+ () => {
455
+ this._rejectAllPendingCommands();
456
+ this.fireEvent("connection_lost");
457
+ },
306
458
  );
307
459
  }
308
460
 
309
461
  disconnect(clearStorage = false) {
462
+ // Reject all pending commands before disconnecting
463
+ this._rejectAllPendingCommands();
310
464
  // disconnect from the server
311
465
  if (this.connection && this.connection.connected) {
312
466
  this.connection.disconnect();
@@ -336,20 +490,12 @@ export class MatterClient {
336
490
  }
337
491
 
338
492
  if ("error_code" in msg) {
339
- const promise = this.result_futures[msg.message_id];
340
- if (promise) {
341
- promise.reject(new Error(msg.details));
342
- delete this.result_futures[msg.message_id];
343
- }
493
+ this._rejectPendingCommand(msg.message_id, new Error(msg.details));
344
494
  return;
345
495
  }
346
496
 
347
497
  if ("result" in msg) {
348
- const promise = this.result_futures[msg.message_id];
349
- if (promise) {
350
- promise.resolve(msg.result);
351
- delete this.result_futures[msg.message_id];
352
- }
498
+ this._resolvePendingCommand(msg.message_id, msg.result);
353
499
  return;
354
500
  }
355
501
 
package/src/exceptions.ts CHANGED
@@ -7,3 +7,26 @@
7
7
  export class MatterError extends Error {}
8
8
 
9
9
  export class InvalidServerVersion extends MatterError {}
10
+
11
+ /**
12
+ * Error thrown when a WebSocket command times out waiting for a response.
13
+ */
14
+ export class CommandTimeoutError extends MatterError {
15
+ constructor(
16
+ public readonly command: string,
17
+ public readonly timeoutMs: number,
18
+ ) {
19
+ super(`Command '${command}' timed out after ${timeoutMs}ms`);
20
+ this.name = "CommandTimeoutError";
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Error thrown when the WebSocket connection is closed while commands are pending.
26
+ */
27
+ export class ConnectionClosedError extends MatterError {
28
+ constructor(message = "Connection closed while command was pending") {
29
+ super(message);
30
+ this.name = "ConnectionClosedError";
31
+ }
32
+ }
@@ -230,6 +230,8 @@ export interface CommandMessage {
230
230
  export interface ServerInfoMessage {
231
231
  fabric_id: number | bigint;
232
232
  compressed_fabric_id: number | bigint;
233
+ /** The fabric index. Note: Only available with OHF Matter Server, not Python Matter Server. */
234
+ fabric_index?: number;
233
235
  schema_version: number;
234
236
  min_supported_schema_version: number;
235
237
  sdk_version: string;
@@ -359,3 +361,18 @@ export interface MatterFabricData {
359
361
 
360
362
  export type NotificationType = "success" | "info" | "warning" | "error";
361
363
  export type NodePingResult = Record<string, boolean>;
364
+
365
+ /**
366
+ * Minimum test node ID. Node IDs >= this value are reserved for test nodes.
367
+ * Uses high 64-bit range (0xFFFF_FFFE_0000_0000) to avoid collision with real node IDs.
368
+ */
369
+ export const TEST_NODE_START = 0xffff_fffe_0000_0000n;
370
+
371
+ /**
372
+ * Check if a node ID is in the test node range (>= TEST_NODE_START).
373
+ * Test nodes are imported diagnostic dumps, not real commissioned devices.
374
+ */
375
+ export function isTestNodeId(nodeId: number | bigint): boolean {
376
+ const bigId = typeof nodeId === "bigint" ? nodeId : BigInt(nodeId);
377
+ return bigId >= TEST_NODE_START;
378
+ }