@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/README.md +90 -0
- package/dist/esm/client.d.ts +57 -27
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +215 -110
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/exceptions.d.ts +14 -0
- package/dist/esm/exceptions.d.ts.map +1 -1
- package/dist/esm/exceptions.js +16 -0
- package/dist/esm/exceptions.js.map +1 -1
- package/dist/esm/models/model.d.ts +12 -0
- package/dist/esm/models/model.d.ts.map +1 -1
- package/dist/esm/models/model.js +8 -1
- package/dist/esm/models/model.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +250 -104
- package/src/exceptions.ts +23 -0
- package/src/models/model.ts +17 -0
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<
|
|
42
|
-
|
|
43
|
-
|
|
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(
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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(
|
|
107
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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(
|
|
193
|
-
|
|
194
|
-
|
|
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(
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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(
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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(
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
|
371
|
+
message_id: messageId,
|
|
281
372
|
command,
|
|
282
373
|
args,
|
|
283
374
|
};
|
|
284
375
|
|
|
285
|
-
|
|
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
|
-
|
|
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
|
-
() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/models/model.ts
CHANGED
|
@@ -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
|
+
}
|