@matter-server/ws-controller 0.2.0-alpha.0-00000000-000000000

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.
Files changed (95) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +11 -0
  3. package/dist/esm/controller/AttributeDataCache.d.ts +49 -0
  4. package/dist/esm/controller/AttributeDataCache.d.ts.map +1 -0
  5. package/dist/esm/controller/AttributeDataCache.js +154 -0
  6. package/dist/esm/controller/AttributeDataCache.js.map +6 -0
  7. package/dist/esm/controller/ControllerCommandHandler.d.ts +118 -0
  8. package/dist/esm/controller/ControllerCommandHandler.d.ts.map +1 -0
  9. package/dist/esm/controller/ControllerCommandHandler.js +1015 -0
  10. package/dist/esm/controller/ControllerCommandHandler.js.map +6 -0
  11. package/dist/esm/controller/LegacyDataInjector.d.ts +95 -0
  12. package/dist/esm/controller/LegacyDataInjector.d.ts.map +1 -0
  13. package/dist/esm/controller/LegacyDataInjector.js +196 -0
  14. package/dist/esm/controller/LegacyDataInjector.js.map +6 -0
  15. package/dist/esm/controller/MatterController.d.ts +59 -0
  16. package/dist/esm/controller/MatterController.d.ts.map +1 -0
  17. package/dist/esm/controller/MatterController.js +212 -0
  18. package/dist/esm/controller/MatterController.js.map +6 -0
  19. package/dist/esm/controller/Nodes.d.ts +62 -0
  20. package/dist/esm/controller/Nodes.d.ts.map +1 -0
  21. package/dist/esm/controller/Nodes.js +85 -0
  22. package/dist/esm/controller/Nodes.js.map +6 -0
  23. package/dist/esm/controller/TestNodeCommandHandler.d.ts +84 -0
  24. package/dist/esm/controller/TestNodeCommandHandler.d.ts.map +1 -0
  25. package/dist/esm/controller/TestNodeCommandHandler.js +225 -0
  26. package/dist/esm/controller/TestNodeCommandHandler.js.map +6 -0
  27. package/dist/esm/data/VendorIDs.d.ts +7 -0
  28. package/dist/esm/data/VendorIDs.d.ts.map +1 -0
  29. package/dist/esm/data/VendorIDs.js +1237 -0
  30. package/dist/esm/data/VendorIDs.js.map +6 -0
  31. package/dist/esm/example/send-command.d.ts +7 -0
  32. package/dist/esm/example/send-command.d.ts.map +1 -0
  33. package/dist/esm/example/send-command.js +60 -0
  34. package/dist/esm/example/send-command.js.map +6 -0
  35. package/dist/esm/index.d.ts +21 -0
  36. package/dist/esm/index.d.ts.map +1 -0
  37. package/dist/esm/index.js +26 -0
  38. package/dist/esm/index.js.map +6 -0
  39. package/dist/esm/model/ModelMapper.d.ts +34 -0
  40. package/dist/esm/model/ModelMapper.d.ts.map +1 -0
  41. package/dist/esm/model/ModelMapper.js +62 -0
  42. package/dist/esm/model/ModelMapper.js.map +6 -0
  43. package/dist/esm/package.json +3 -0
  44. package/dist/esm/server/ConfigStorage.d.ts +29 -0
  45. package/dist/esm/server/ConfigStorage.d.ts.map +1 -0
  46. package/dist/esm/server/ConfigStorage.js +84 -0
  47. package/dist/esm/server/ConfigStorage.js.map +6 -0
  48. package/dist/esm/server/Converters.d.ts +53 -0
  49. package/dist/esm/server/Converters.d.ts.map +1 -0
  50. package/dist/esm/server/Converters.js +343 -0
  51. package/dist/esm/server/Converters.js.map +6 -0
  52. package/dist/esm/server/WebSocketControllerHandler.d.ts +21 -0
  53. package/dist/esm/server/WebSocketControllerHandler.d.ts.map +1 -0
  54. package/dist/esm/server/WebSocketControllerHandler.js +767 -0
  55. package/dist/esm/server/WebSocketControllerHandler.js.map +6 -0
  56. package/dist/esm/types/CommandHandler.d.ts +258 -0
  57. package/dist/esm/types/CommandHandler.d.ts.map +1 -0
  58. package/dist/esm/types/CommandHandler.js +6 -0
  59. package/dist/esm/types/CommandHandler.js.map +6 -0
  60. package/dist/esm/types/WebServer.d.ts +12 -0
  61. package/dist/esm/types/WebServer.d.ts.map +1 -0
  62. package/dist/esm/types/WebServer.js +6 -0
  63. package/dist/esm/types/WebServer.js.map +6 -0
  64. package/dist/esm/types/WebSocketMessageTypes.d.ts +478 -0
  65. package/dist/esm/types/WebSocketMessageTypes.d.ts.map +1 -0
  66. package/dist/esm/types/WebSocketMessageTypes.js +77 -0
  67. package/dist/esm/types/WebSocketMessageTypes.js.map +6 -0
  68. package/dist/esm/util/matterVersion.d.ts +12 -0
  69. package/dist/esm/util/matterVersion.d.ts.map +1 -0
  70. package/dist/esm/util/matterVersion.js +32 -0
  71. package/dist/esm/util/matterVersion.js.map +6 -0
  72. package/dist/esm/util/network.d.ts +14 -0
  73. package/dist/esm/util/network.d.ts.map +1 -0
  74. package/dist/esm/util/network.js +63 -0
  75. package/dist/esm/util/network.js.map +6 -0
  76. package/package.json +45 -0
  77. package/src/controller/AttributeDataCache.ts +194 -0
  78. package/src/controller/ControllerCommandHandler.ts +1256 -0
  79. package/src/controller/LegacyDataInjector.ts +314 -0
  80. package/src/controller/MatterController.ts +265 -0
  81. package/src/controller/Nodes.ts +115 -0
  82. package/src/controller/TestNodeCommandHandler.ts +305 -0
  83. package/src/data/VendorIDs.ts +1234 -0
  84. package/src/example/send-command.ts +82 -0
  85. package/src/index.ts +33 -0
  86. package/src/model/ModelMapper.ts +87 -0
  87. package/src/server/ConfigStorage.ts +112 -0
  88. package/src/server/Converters.ts +483 -0
  89. package/src/server/WebSocketControllerHandler.ts +917 -0
  90. package/src/tsconfig.json +7 -0
  91. package/src/types/CommandHandler.ts +270 -0
  92. package/src/types/WebServer.ts +14 -0
  93. package/src/types/WebSocketMessageTypes.ts +525 -0
  94. package/src/util/matterVersion.ts +45 -0
  95. package/src/util/network.ts +85 -0
@@ -0,0 +1,917 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { ObserverGroup } from "@matter/general";
8
+ import { camelize, ClusterId, FabricIndex, Logger, Millis, NodeId } from "@matter/main";
9
+ import { ControllerCommissioningFlowOptions } from "@matter/main/protocol";
10
+ import { EndpointNumber, getClusterById, QrPairingCodeCodec } from "@matter/main/types";
11
+ import { NodeStates } from "@project-chip/matter.js/device";
12
+ import { WebSocketServer } from "ws";
13
+ import { ControllerCommandHandler } from "../controller/ControllerCommandHandler.js";
14
+ import { MatterController } from "../controller/MatterController.js";
15
+ import { TestNodeCommandHandler } from "../controller/TestNodeCommandHandler.js";
16
+ import { VendorIds } from "../data/VendorIDs.js";
17
+ import { ClusterMap, ClusterMapEntry } from "../model/ModelMapper.js";
18
+ import { CommissioningRequest } from "../types/CommandHandler.js";
19
+ import { HttpServer, WebServerHandler } from "../types/WebServer.js";
20
+ import {
21
+ ArgsOf,
22
+ ErrorResultMessage,
23
+ EventTypes,
24
+ MatterNode,
25
+ MatterNodeEvent,
26
+ ResponseOf,
27
+ ServerError,
28
+ ServerErrorCode,
29
+ ServerInfoMessage,
30
+ SuccessResultMessage,
31
+ } from "../types/WebSocketMessageTypes.js";
32
+ import { MATTER_VERSION } from "../util/matterVersion.js";
33
+ import { ConfigStorage } from "./ConfigStorage.js";
34
+ import {
35
+ convertMatterToWebSocketTagBased,
36
+ parseBigIntAwareJson,
37
+ splitAttributePath,
38
+ toBigIntAwareJson,
39
+ } from "./Converters.js";
40
+
41
+ const logger = Logger.get("WebSocketControllerHandler");
42
+
43
+ /** Maximum number of events to keep in the history buffer */
44
+ const EVENT_HISTORY_SIZE = 25;
45
+
46
+ const SCHEMA_VERSION = 11;
47
+
48
+ /** WebSocket Server compatible with Schema version 11 */
49
+ export class WebSocketControllerHandler implements WebServerHandler {
50
+ #controller: MatterController;
51
+ #commandHandler: ControllerCommandHandler;
52
+ #testNodeHandler: TestNodeCommandHandler;
53
+ #config: ConfigStorage;
54
+ #wss?: WebSocketServer;
55
+ #closed = false;
56
+ /** Circular buffer for recent node events (max 25) */
57
+ #eventHistory: MatterNodeEvent[] = [];
58
+ /** Track when each node was last interviewed (connected) - keyed by nodeId */
59
+ #lastInterviewDates = new Map<NodeId, Date>();
60
+
61
+ constructor(controller: MatterController, config: ConfigStorage) {
62
+ this.#controller = controller;
63
+ this.#commandHandler = controller.commandHandler;
64
+ this.#testNodeHandler = new TestNodeCommandHandler();
65
+ this.#config = config;
66
+ }
67
+
68
+ /**
69
+ * Get the appropriate command handler for a node ID.
70
+ * Returns TestNodeCommandHandler for test nodes, ControllerCommandHandler for real nodes.
71
+ */
72
+ #handlerFor(nodeId: number | bigint): TestNodeCommandHandler | ControllerCommandHandler {
73
+ return TestNodeCommandHandler.isTestNodeId(nodeId) ? this.#testNodeHandler : this.#commandHandler;
74
+ }
75
+
76
+ /**
77
+ * Add an event to the history buffer, maintaining max size.
78
+ */
79
+ #addEventToHistory(event: MatterNodeEvent) {
80
+ this.#eventHistory.push(event);
81
+ if (this.#eventHistory.length > EVENT_HISTORY_SIZE) {
82
+ this.#eventHistory.shift();
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Get the event history (last 25 events).
88
+ */
89
+ getEventHistory(): MatterNodeEvent[] {
90
+ return [...this.#eventHistory];
91
+ }
92
+
93
+ async register(server: HttpServer) {
94
+ const wss = (this.#wss = new WebSocketServer({ server: server, path: "/ws" }));
95
+ wss.on("connection", ws => {
96
+ if (this.#closed) return;
97
+
98
+ let listening = false;
99
+ const observers = new ObserverGroup();
100
+
101
+ const sendNodeDetailsEvent = <E extends EventTypes>(eventName: E, nodeId: NodeId) => {
102
+ if (this.#closed || !listening) return;
103
+
104
+ switch (eventName) {
105
+ case "node_added":
106
+ case "node_updated":
107
+ this.#collectNodeDetails(nodeId).then(
108
+ nodeDetails => {
109
+ logger.debug(`Sending ${eventName} event for Node ${nodeId}`, nodeDetails);
110
+ ws.send(toBigIntAwareJson({ event: eventName, data: nodeDetails }));
111
+ },
112
+ err => logger.error(`Failed to collect node details for Node ${nodeId}`, err),
113
+ );
114
+ break;
115
+ case "node_removed":
116
+ logger.debug(`Sending node_removed event for Node ${nodeId}`);
117
+ ws.send(toBigIntAwareJson({ event: eventName, data: nodeId }));
118
+ break;
119
+ }
120
+ };
121
+
122
+ // Register all event listeners using ObserverGroup for easy cleanup
123
+ observers.on(this.#commandHandler.events.attributeChanged, (nodeId, data) => {
124
+ if (this.#closed) return;
125
+ const { endpointId, clusterId, attributeId } = data.path;
126
+ const pathStr = `${endpointId}/${clusterId}/${attributeId}`;
127
+ const cluster = getClusterById(clusterId);
128
+ const clusterData = ClusterMap[cluster.name.toLowerCase()];
129
+ const value = convertMatterToWebSocketTagBased(
130
+ data.value,
131
+ clusterData?.attributes[attributeId],
132
+ clusterData?.model,
133
+ );
134
+ logger.debug(`Sending attribute_updated event for Node ${nodeId}`, pathStr, value);
135
+ ws.send(toBigIntAwareJson({ event: "attribute_updated", data: [nodeId, pathStr, value] }));
136
+ });
137
+
138
+ observers.on(this.#commandHandler.events.eventChanged, (nodeId, data) => {
139
+ if (this.#closed || !listening) return;
140
+ const { path, events } = data;
141
+ const { endpointId, clusterId, eventId } = path;
142
+
143
+ for (const event of events) {
144
+ let timestamp: number | bigint;
145
+ let timestampType: number;
146
+
147
+ if (event.epochTimestamp !== undefined) {
148
+ timestamp = event.epochTimestamp;
149
+ timestampType = 1; // Epoch
150
+ } else if (event.systemTimestamp !== undefined) {
151
+ timestamp = event.systemTimestamp;
152
+ timestampType = 0; // System
153
+ } else {
154
+ timestamp = Date.now();
155
+ timestampType = 2; // POSIX (fallback)
156
+ }
157
+
158
+ const nodeEvent: MatterNodeEvent = {
159
+ node_id: nodeId,
160
+ endpoint_id: endpointId,
161
+ cluster_id: clusterId,
162
+ event_id: eventId,
163
+ event_number: event.eventNumber,
164
+ priority: event.priority,
165
+ timestamp,
166
+ timestamp_type: timestampType,
167
+ data: event.data ?? null,
168
+ };
169
+
170
+ // Store event in the history buffer
171
+ this.#addEventToHistory(nodeEvent);
172
+
173
+ logger.debug(`Sending node_event for Node ${nodeId}`, nodeEvent);
174
+ ws.send(toBigIntAwareJson({ event: "node_event", data: nodeEvent }));
175
+ }
176
+ });
177
+
178
+ observers.on(this.#commandHandler.events.nodeAdded, nodeId => {
179
+ sendNodeDetailsEvent("node_added", nodeId);
180
+ });
181
+
182
+ observers.on(this.#commandHandler.events.nodeStateChanged, (nodeId, state) => {
183
+ if (state === NodeStates.Disconnected) return;
184
+ // Track last interview time when node becomes connected
185
+ if (state === NodeStates.Connected) {
186
+ this.#lastInterviewDates.set(nodeId, new Date());
187
+ }
188
+ sendNodeDetailsEvent("node_updated", nodeId);
189
+ });
190
+
191
+ observers.on(this.#commandHandler.events.nodeStructureChanged, nodeId => {
192
+ sendNodeDetailsEvent("node_updated", nodeId);
193
+ });
194
+
195
+ observers.on(this.#commandHandler.events.nodeDecommissioned, nodeId => {
196
+ sendNodeDetailsEvent("node_removed", nodeId);
197
+ });
198
+
199
+ observers.on(this.#commandHandler.events.nodeEndpointAdded, (nodeId, endpointId) => {
200
+ if (this.#closed || !listening) return;
201
+ logger.info(`Sending endpoint_added event for Node ${nodeId} endpoint ${endpointId}`);
202
+ ws.send(
203
+ toBigIntAwareJson({ event: "endpoint_added", data: { node_id: nodeId, endpoint_id: endpointId } }),
204
+ );
205
+ });
206
+
207
+ observers.on(this.#commandHandler.events.nodeEndpointRemoved, (nodeId, endpointId) => {
208
+ if (this.#closed || !listening) return;
209
+ logger.info(`Sending endpoint_removed event for Node ${nodeId} endpoint ${endpointId}`);
210
+ ws.send(
211
+ toBigIntAwareJson({
212
+ event: "endpoint_removed",
213
+ data: { node_id: nodeId, endpoint_id: endpointId },
214
+ }),
215
+ );
216
+ });
217
+
218
+ // Register test node event listeners
219
+ observers.on(this.#testNodeHandler.nodeAdded, (_nodeId, testNode) => {
220
+ if (this.#closed || !listening) return;
221
+ logger.info(`Sending node_added event for test node ${testNode.node_id}`);
222
+ ws.send(toBigIntAwareJson({ event: "node_added", data: testNode }));
223
+ });
224
+
225
+ observers.on(this.#testNodeHandler.nodeRemoved, nodeId => {
226
+ if (this.#closed || !listening) return;
227
+ logger.info(`Sending node_removed event for test node ${nodeId}`);
228
+ ws.send(toBigIntAwareJson({ event: "node_removed", data: nodeId }));
229
+ });
230
+
231
+ const onClose = () => observers.close();
232
+
233
+ ws.on(
234
+ "message",
235
+ data =>
236
+ void this.#handleWebSocketRequest(data.toString()).then(
237
+ ({ response, enableListeners }) => {
238
+ if (this.#closed) return;
239
+ if (enableListeners) {
240
+ listening = true;
241
+ }
242
+ const responseStr = toBigIntAwareJson(response);
243
+ logger.debug("Sending WebSocket response", responseStr);
244
+ ws.send(toBigIntAwareJson(response));
245
+ },
246
+ err => logger.error("Websocket request error", err),
247
+ ),
248
+ );
249
+
250
+ ws.on("close", onClose);
251
+ ws.on("error", err => {
252
+ logger.error("Websocket error", err);
253
+ onClose();
254
+ });
255
+
256
+ this.#getServerInfo().then(
257
+ response => ws.send(toBigIntAwareJson(response)),
258
+ err => logger.error("Websocket handshake error", err),
259
+ );
260
+ });
261
+
262
+ await this.#commandHandler.connect();
263
+ }
264
+
265
+ unregister(): Promise<void> {
266
+ if (!this.#wss || this.#closed) {
267
+ return Promise.resolve();
268
+ }
269
+
270
+ this.#closed = true;
271
+ // Send server_shutdown event to all connected clients before closing
272
+ const shutdownMessage = toBigIntAwareJson({ event: "server_shutdown", data: {} });
273
+ this.#wss.clients.forEach(client => {
274
+ if (client.readyState === 1 /* WebSocket.OPEN */) {
275
+ try {
276
+ client.send(shutdownMessage, () => {
277
+ client.close();
278
+ });
279
+ } catch (err) {
280
+ logger.warn("Failed to send server_shutdown event to client", err);
281
+ }
282
+ }
283
+ });
284
+ console.log("send close to clients");
285
+
286
+ const wss = this.#wss;
287
+ // Wait for the WebSocket server to close properly
288
+ return new Promise<void>((resolve, reject) => {
289
+ wss.close(err => {
290
+ if (err) {
291
+ reject(err);
292
+ } else {
293
+ resolve();
294
+ }
295
+ });
296
+ });
297
+ }
298
+
299
+ async #handleWebSocketRequest(
300
+ data: string,
301
+ ): Promise<{ response: ErrorResultMessage | SuccessResultMessage<any>; enableListeners?: boolean }> {
302
+ let messageId: string | undefined;
303
+ try {
304
+ logger.debug("Received WebSocket request", data);
305
+ const request = parseBigIntAwareJson(data) as { message_id: string; command: string; args: any };
306
+ const { command, args } = request;
307
+ messageId = request.message_id;
308
+ let result: ResponseOf<any>;
309
+ let enableListeners: boolean | undefined = undefined;
310
+ switch (command) {
311
+ case "start_listening":
312
+ result = await this.#handleStartListening(args);
313
+ enableListeners = true;
314
+ break;
315
+ case "set_default_fabric_label":
316
+ result = await this.#handleSetDefaultFabricLabel(args);
317
+ break;
318
+ case "commission_with_code":
319
+ result = await this.#handleCommissionWithCode(args);
320
+ break;
321
+ case "commission_on_network":
322
+ result = await this.#handleCommissionOnNetwork(args);
323
+ break;
324
+ case "get_node":
325
+ result = await this.#handleGetNode(args);
326
+ break;
327
+ case "get_nodes":
328
+ result = await this.#handleGetNodes(args);
329
+ break;
330
+ case "get_node_ip_addresses":
331
+ result = await this.#handleGetNodeIpAddresses(args);
332
+ break;
333
+ case "read_attribute":
334
+ result = await this.#handleReadAttribute(args);
335
+ break;
336
+ case "get_vendor_names":
337
+ result = await this.#handleGetVendorNames(args);
338
+ break;
339
+ case "device_command":
340
+ result = await this.#handleDeviceCommand(args);
341
+ break;
342
+ case "write_attribute":
343
+ result = await this.#handleWriteAttribute(args);
344
+ break;
345
+ case "interview_node":
346
+ result = await this.#handleInterviewNode(args);
347
+ break;
348
+ case "ping_node":
349
+ result = await this.#handlePingNode(args);
350
+ break;
351
+ case "diagnostics":
352
+ result = {
353
+ info: await this.#getServerInfo(),
354
+ nodes: await this.#handleGetNodes(args),
355
+ events: this.getEventHistory(),
356
+ };
357
+ break;
358
+ case "remove_node":
359
+ result = await this.#handleRemoveNode(args);
360
+ break;
361
+ case "set_wifi_credentials":
362
+ result = await this.#handleSetWifiCredentials(args);
363
+ break;
364
+ case "set_thread_dataset":
365
+ result = await this.#handleSetThreadDataset(args);
366
+ break;
367
+ case "open_commissioning_window":
368
+ result = await this.#handleOpenCommissioningWindow(args);
369
+ break;
370
+ case "discover_commissionable_nodes":
371
+ result = await this.#handleDiscoverCommissionableNodes(args);
372
+ break;
373
+ case "get_matter_fabrics":
374
+ result = await this.#handleGetMatterFabrics(args);
375
+ break;
376
+ case "remove_matter_fabric":
377
+ result = await this.#handleRemoveMatterFabric(args);
378
+ break;
379
+ case "set_acl_entry":
380
+ result = await this.#handleSetAclEntry(args);
381
+ break;
382
+ case "set_node_binding":
383
+ result = await this.#handleSetNodeBinding(args);
384
+ break;
385
+ case "import_test_node":
386
+ result = await this.#handleImportTestNode(args);
387
+ break;
388
+ case "check_node_update":
389
+ result = await this.#handleCheckNodeUpdate(args);
390
+ break;
391
+ case "update_node":
392
+ result = await this.#handleUpdateNode(args);
393
+ break;
394
+ case "server_info":
395
+ result = await this.#getServerInfo();
396
+ break;
397
+ case "discover":
398
+ result = await this.#handleDiscoverCommissionableNodes({});
399
+ break;
400
+ default:
401
+ throw ServerError.invalidCommand(command);
402
+ }
403
+ if (result === undefined) {
404
+ throw new Error("No response");
405
+ }
406
+ logger.info("WebSocket request handled", messageId, result);
407
+ return {
408
+ response: {
409
+ message_id: messageId ?? "",
410
+ result,
411
+ },
412
+ enableListeners,
413
+ };
414
+ } catch (err) {
415
+ logger.error("Failed to handle websocket request", err);
416
+ const errorCode = err instanceof ServerError ? err.code : ServerErrorCode.UnknownError;
417
+ return {
418
+ response: {
419
+ message_id: messageId ?? "",
420
+ error_code: errorCode,
421
+ details: (err as Error).message,
422
+ },
423
+ };
424
+ }
425
+ }
426
+
427
+ async #getServerInfo(): Promise<ServerInfoMessage> {
428
+ await this.#commandHandler.start();
429
+ const { fabricId: fabric_id, compressedFabricId: compressed_fabric_id } =
430
+ await this.#commandHandler.getCommissionerFabricData();
431
+ return {
432
+ fabric_id,
433
+ compressed_fabric_id,
434
+ schema_version: SCHEMA_VERSION,
435
+ min_supported_schema_version: SCHEMA_VERSION,
436
+ sdk_version: `matter.js/${MATTER_VERSION}`,
437
+ wifi_credentials_set: !!(this.#config.wifiSsid && this.#config.wifiCredentials),
438
+ thread_credentials_set: !!this.#config.threadDataset,
439
+ bluetooth_enabled: this.#commandHandler.bleEnabled,
440
+ };
441
+ }
442
+
443
+ /**
444
+ * Broadcast an event to all connected WebSocket clients.
445
+ */
446
+ #broadcastEvent(event: string, data: unknown) {
447
+ if (!this.#wss || this.#closed) return;
448
+ const message = toBigIntAwareJson({ event, data });
449
+ this.#wss.clients.forEach(client => {
450
+ if (client.readyState === 1 /* WebSocket.OPEN */) {
451
+ try {
452
+ client.send(message);
453
+ } catch (err) {
454
+ logger.warn(`Failed to broadcast ${event} event to client`, err);
455
+ }
456
+ }
457
+ });
458
+ }
459
+
460
+ /**
461
+ * Send server_info_updated event to all connected clients.
462
+ */
463
+ async #broadcastServerInfoUpdated() {
464
+ const serverInfo = await this.#getServerInfo();
465
+ logger.info("Broadcasting server_info_updated event", serverInfo);
466
+ this.#broadcastEvent("server_info_updated", serverInfo);
467
+ }
468
+
469
+ async #handleStartListening(_args: ArgsOf<"start_listening">): Promise<ResponseOf<"start_listening">> {
470
+ return await this.#handleGetNodes({});
471
+ }
472
+
473
+ async #handleSetDefaultFabricLabel(
474
+ args: ArgsOf<"set_default_fabric_label">,
475
+ ): Promise<ResponseOf<"set_default_fabric_label">> {
476
+ const { label } = args;
477
+ // Use "Home" as default when null/empty is passed (matter.js requires non-empty labels)
478
+ const effectiveLabel = label && label.trim() !== "" ? label.trim() : "Home";
479
+ await this.#commandHandler.setFabricLabel(effectiveLabel);
480
+ await this.#config.set({ fabricLabel: effectiveLabel });
481
+ return null;
482
+ }
483
+
484
+ async #handleCommissionWithCode(args: ArgsOf<"commission_with_code">): Promise<ResponseOf<"commission_with_code">> {
485
+ const { code, network_only } = args;
486
+ const isQrCode = code.startsWith("MT:");
487
+
488
+ const nextNodeId = this.#config.nextNodeId;
489
+
490
+ let wifiCredentials: ControllerCommissioningFlowOptions["wifiNetwork"] | undefined = undefined;
491
+ let threadCredentials: ControllerCommissioningFlowOptions["threadNetwork"] | undefined = undefined;
492
+ if (!network_only && this.#commandHandler.bleEnabled) {
493
+ if (this.#config.wifiSsid && this.#config.wifiCredentials) {
494
+ wifiCredentials = {
495
+ wifiSsid: this.#config.wifiSsid,
496
+ wifiCredentials: this.#config.wifiCredentials,
497
+ };
498
+ }
499
+ if (this.#config.threadDataset) {
500
+ threadCredentials = {
501
+ networkName: "", // Thread network name is not needed when providing operational dataset
502
+ operationalDataset: this.#config.threadDataset,
503
+ };
504
+ }
505
+ }
506
+
507
+ await this.#config.set({ nextNodeId: nextNodeId + 1 });
508
+
509
+ const { nodeId } = await this.#commandHandler.commissionNode({
510
+ nodeId: NodeId(nextNodeId),
511
+ onNetworkOnly: network_only,
512
+ ...(isQrCode ? { qrCode: code } : { manualCode: code }),
513
+ wifiCredentials,
514
+ threadCredentials,
515
+ });
516
+
517
+ return await this.#collectNodeDetails(nodeId);
518
+ }
519
+
520
+ async #handleCommissionOnNetwork(
521
+ args: ArgsOf<"commission_on_network">,
522
+ ): Promise<ResponseOf<"commission_on_network">> {
523
+ const { setup_pin_code, filter_type, filter, ip_addr } = args;
524
+
525
+ const nextNodeId = this.#config.nextNodeId;
526
+
527
+ // Build commissioning request based on filter type
528
+ // Filter types: 0=None, 1=ShortDiscriminator, 2=LongDiscriminator, 3=VendorId, 4=DeviceType
529
+ let commissionRequest: CommissioningRequest;
530
+
531
+ const baseRequest = {
532
+ nodeId: NodeId(nextNodeId),
533
+ onNetworkOnly: true, // commission_on_network is always network-only
534
+ knownAddress: ip_addr ? { ip: ip_addr, port: 5540 } : undefined,
535
+ };
536
+
537
+ switch (filter_type) {
538
+ case 1: // Short discriminator
539
+ if (filter === undefined)
540
+ throw ServerError.invalidArguments("filter required for filter_type 1 (short discriminator)");
541
+ commissionRequest = { ...baseRequest, passcode: setup_pin_code, shortDiscriminator: filter };
542
+ break;
543
+ case 2: // Long discriminator
544
+ if (filter === undefined)
545
+ throw ServerError.invalidArguments("filter required for filter_type 2 (long discriminator)");
546
+ commissionRequest = { ...baseRequest, passcode: setup_pin_code, longDiscriminator: filter };
547
+ break;
548
+ case 3: // Vendor ID (requires product ID too, but Python server only passes vendor ID) // TODO, will basically never work!
549
+ if (filter === undefined)
550
+ throw ServerError.invalidArguments("filter required for filter_type 3 (vendor ID)");
551
+ commissionRequest = { ...baseRequest, passcode: setup_pin_code, vendorId: filter, productId: 0 };
552
+ break;
553
+ case 4: // Device type - not directly supported, fall back to no filter
554
+ case 0: // No filter
555
+ default:
556
+ // Discover any commissionable device with the passcode
557
+ commissionRequest = { ...baseRequest, passcode: setup_pin_code };
558
+ break;
559
+ }
560
+
561
+ await this.#config.set({ nextNodeId: nextNodeId + 1 });
562
+
563
+ const { nodeId } = await this.#commandHandler.commissionNode(commissionRequest);
564
+
565
+ return await this.#collectNodeDetails(nodeId);
566
+ }
567
+
568
+ async #handleGetNodes(args: ArgsOf<"get_nodes">): Promise<ResponseOf<"get_nodes">> {
569
+ const { only_available = false } = args ?? {};
570
+ const nodeDetails = new Array<MatterNode>();
571
+ // Include real nodes
572
+ for (const node of this.#commandHandler.getNodeIds()) {
573
+ const details = await this.#collectNodeDetails(node);
574
+ if (!only_available || details.available) {
575
+ nodeDetails.push(details);
576
+ }
577
+ }
578
+ // Include test nodes
579
+ for (const testNode of this.#testNodeHandler.getNodes()) {
580
+ if (!only_available || testNode.available) {
581
+ nodeDetails.push(testNode);
582
+ }
583
+ }
584
+ return nodeDetails;
585
+ }
586
+
587
+ async #handleGetNode(args: ArgsOf<"get_node">): Promise<ResponseOf<"get_node">> {
588
+ const { node_id } = args;
589
+ const nodeId = NodeId(node_id);
590
+ const handler = this.#handlerFor(node_id);
591
+
592
+ if (!handler.hasNode(nodeId)) {
593
+ throw ServerError.nodeNotExists(node_id);
594
+ }
595
+
596
+ // Pass the last interview date for real nodes
597
+ if (handler === this.#commandHandler) {
598
+ return await this.#commandHandler.getNodeDetails(nodeId, this.#lastInterviewDates.get(nodeId));
599
+ }
600
+ return await handler.getNodeDetails(nodeId);
601
+ }
602
+
603
+ async #handleGetNodeIpAddresses(
604
+ args: ArgsOf<"get_node_ip_addresses">,
605
+ ): Promise<ResponseOf<"get_node_ip_addresses">> {
606
+ const { node_id, prefer_cache, scoped } = args;
607
+ const result = await this.#handlerFor(node_id).getNodeIpAddresses(NodeId(node_id), prefer_cache);
608
+ if (!scoped) {
609
+ return result;
610
+ }
611
+ return result.map(ip => (ip.includes("%") ? ip.split("%")[0] : ip));
612
+ }
613
+
614
+ async #handleReadAttribute(args: ArgsOf<"read_attribute">): Promise<ResponseOf<"read_attribute">> {
615
+ const { node_id: nodeId, attribute_path, fabric_filtered = false } = args;
616
+
617
+ // Normalize attribute_path to array
618
+ const attributePaths = Array.isArray(attribute_path) ? attribute_path : [attribute_path];
619
+
620
+ const result = await this.#handlerFor(nodeId).handleReadAttributes(
621
+ NodeId(nodeId),
622
+ attributePaths,
623
+ fabric_filtered,
624
+ );
625
+
626
+ if (Object.keys(result).length === 0) {
627
+ throw new Error("Failed to read attribute: no values returned");
628
+ }
629
+
630
+ return result;
631
+ }
632
+
633
+ async #handleWriteAttribute(args: ArgsOf<"write_attribute">): Promise<ResponseOf<"write_attribute">> {
634
+ const { node_id: nodeId, attribute_path, value } = args;
635
+ const { endpointId, clusterId, attributeId } = splitAttributePath(attribute_path);
636
+
637
+ // Write operations don't support wildcards
638
+ if (endpointId === undefined || clusterId === undefined || attributeId === undefined) {
639
+ throw ServerError.invalidArguments("write_attribute does not support wildcards in attribute path");
640
+ }
641
+
642
+ const { status } = await this.#handlerFor(nodeId).handleWriteAttribute({
643
+ nodeId: NodeId(nodeId),
644
+ endpointId,
645
+ clusterId,
646
+ attributeId,
647
+ value,
648
+ });
649
+ return [
650
+ {
651
+ Path: { EndpointId: endpointId, ClusterId: clusterId, AttributeId: attributeId },
652
+ Status: status ?? 0,
653
+ },
654
+ ];
655
+ }
656
+
657
+ async #handleGetVendorNames(args: ArgsOf<"get_vendor_names">): Promise<ResponseOf<"get_vendor_names">> {
658
+ const { filter_vendors } = args;
659
+
660
+ // Get vendor info from the DCL service
661
+ const dclVendors = await this.#controller.getAllVendors();
662
+
663
+ // Build merged result: DCL vendors override the static list but include static entries not in DCL
664
+ const mergedVendors: { [key: string]: string } = {};
665
+
666
+ // First add all static vendor IDs
667
+ for (const [vendorIdStr, vendorName] of Object.entries(VendorIds)) {
668
+ mergedVendors[vendorIdStr] = vendorName;
669
+ }
670
+
671
+ // Then override with DCL vendors (DCL wins)
672
+ for (const [vendorId, vendorInfo] of dclVendors) {
673
+ mergedVendors[vendorId] = vendorInfo.vendorName;
674
+ }
675
+
676
+ // If no filter, return all merged vendors
677
+ if (!filter_vendors || !filter_vendors.length) {
678
+ return mergedVendors;
679
+ }
680
+
681
+ // Filter to requested vendor IDs
682
+ const result: { [key: string]: string } = {};
683
+ for (const vendorId of filter_vendors) {
684
+ const vendorName = mergedVendors[vendorId];
685
+ if (vendorName) {
686
+ result[vendorId] = vendorName;
687
+ }
688
+ }
689
+ return result;
690
+ }
691
+
692
+ async #handleDeviceCommand(args: ArgsOf<"device_command">): Promise<ResponseOf<"device_command">> {
693
+ const {
694
+ node_id: nodeId,
695
+ endpoint_id: endpointId,
696
+ cluster_id: clusterId,
697
+ command_name: commandName,
698
+ payload,
699
+ response_type,
700
+ timed_request_timeout_ms: timedInteractionTimeoutMs,
701
+ } = args;
702
+
703
+ const result = await this.#handlerFor(nodeId).handleInvoke({
704
+ nodeId: NodeId(nodeId),
705
+ endpointId: EndpointNumber(endpointId),
706
+ clusterId: ClusterId(clusterId),
707
+ commandName: camelize(commandName),
708
+ data: payload,
709
+ timedInteractionTimeoutMs:
710
+ typeof timedInteractionTimeoutMs === "number" ? Millis(timedInteractionTimeoutMs) : undefined,
711
+ });
712
+
713
+ // Test nodes and null response_type return null
714
+ if (TestNodeCommandHandler.isTestNodeId(nodeId) || response_type === null) {
715
+ return null;
716
+ }
717
+ const cmdResult = this.#convertCommandDataToWebSocketTagBased(ClusterId(clusterId), commandName, result);
718
+ if (cmdResult === undefined) {
719
+ return null;
720
+ }
721
+ return cmdResult;
722
+ }
723
+
724
+ async #handleInterviewNode(args: ArgsOf<"interview_node">): Promise<ResponseOf<"interview_node">> {
725
+ const { node_id } = args;
726
+ const nodeId = NodeId(node_id);
727
+
728
+ // Handle test nodes - just broadcast the node_updated event
729
+ if (TestNodeCommandHandler.isTestNodeId(nodeId)) {
730
+ const testNode = this.#testNodeHandler.getNode(nodeId);
731
+ if (testNode === undefined) {
732
+ throw ServerError.nodeNotExists(nodeId);
733
+ }
734
+ logger.debug(`interview_node called for test node ${nodeId}`);
735
+ this.#broadcastEvent("node_updated", testNode);
736
+ return null;
737
+ }
738
+
739
+ await this.#commandHandler.interviewNode(nodeId);
740
+
741
+ return null;
742
+ }
743
+
744
+ async #handlePingNode(args: ArgsOf<"ping_node">): Promise<ResponseOf<"ping_node">> {
745
+ const { node_id, attempts = 1 } = args;
746
+ return await this.#handlerFor(node_id).pingNode(NodeId(node_id), attempts);
747
+ }
748
+
749
+ async #handleRemoveNode(args: ArgsOf<"remove_node">): Promise<ResponseOf<"remove_node">> {
750
+ const { node_id } = args;
751
+ await this.#handlerFor(node_id).removeNode(NodeId(node_id));
752
+ return null;
753
+ }
754
+
755
+ async #handleSetWifiCredentials(args: ArgsOf<"set_wifi_credentials">): Promise<ResponseOf<"set_wifi_credentials">> {
756
+ const { ssid, credentials } = args;
757
+ await this.#config.set({ wifiSsid: ssid, wifiCredentials: credentials });
758
+ // Broadcast server_info_updated event to notify clients of credential change
759
+ try {
760
+ await this.#broadcastServerInfoUpdated();
761
+ } catch (error) {
762
+ logger.warn("Failed to broadcast server info update", error);
763
+ }
764
+ return {};
765
+ }
766
+
767
+ async #handleSetThreadDataset(args: ArgsOf<"set_thread_dataset">): Promise<ResponseOf<"set_thread_dataset">> {
768
+ const { dataset } = args;
769
+ await this.#config.set({ threadDataset: dataset });
770
+ // Broadcast server_info_updated event to notify clients of credential change
771
+ try {
772
+ await this.#broadcastServerInfoUpdated();
773
+ } catch (error) {
774
+ logger.warn("Failed to broadcast server info update", error);
775
+ }
776
+ return {};
777
+ }
778
+
779
+ async #handleOpenCommissioningWindow(
780
+ args: ArgsOf<"open_commissioning_window">,
781
+ ): Promise<ResponseOf<"open_commissioning_window">> {
782
+ const { node_id, timeout /*, iteration, option, discriminator*/ } = args;
783
+ const nodeId = NodeId(node_id);
784
+ const { manualCode, qrCode } = await this.#commandHandler.openCommissioningWindow({
785
+ nodeId,
786
+ timeout,
787
+ });
788
+ const pairingCodeCodec = QrPairingCodeCodec.decode(qrCode);
789
+ return { setup_pin_code: pairingCodeCodec[0].passcode, setup_manual_code: manualCode, setup_qr_code: qrCode };
790
+ }
791
+
792
+ async #handleDiscoverCommissionableNodes(
793
+ _args: ArgsOf<"discover_commissionable_nodes">,
794
+ ): Promise<ResponseOf<"discover_commissionable_nodes">> {
795
+ const result = await this.#commandHandler.handleDiscovery({});
796
+ return result.map(
797
+ ({
798
+ commissioningMode,
799
+ deviceName,
800
+ deviceType,
801
+ hostName,
802
+ instanceName,
803
+ longDiscriminator,
804
+ // numIPs,
805
+ pairingHint,
806
+ pairingInstruction,
807
+ port,
808
+ productId,
809
+ rotatingId,
810
+ // rotatingIdLen,
811
+ // shortDiscriminator,
812
+ // supportsTcpClient,
813
+ supportsTcpServer,
814
+ vendorId,
815
+ addresses,
816
+ mrpSessionActiveInterval,
817
+ mrpSessionIdleInterval,
818
+ }) => ({
819
+ instance_name: instanceName,
820
+ host_name: hostName, // TODO
821
+ port,
822
+ long_discriminator: longDiscriminator,
823
+ vendor_id: vendorId,
824
+ product_id: productId,
825
+ commissioning_mode: commissioningMode,
826
+ device_type: deviceType,
827
+ device_name: deviceName,
828
+ pairing_instruction: pairingInstruction,
829
+ pairing_hint: pairingHint,
830
+ mrp_retry_interval_idle: mrpSessionIdleInterval,
831
+ mrp_retry_interval_active: mrpSessionActiveInterval,
832
+ supports_tcp: supportsTcpServer,
833
+ addresses,
834
+ rotating_id: rotatingId,
835
+ }),
836
+ );
837
+ }
838
+
839
+ async #handleGetMatterFabrics(args: ArgsOf<"get_matter_fabrics">): Promise<ResponseOf<"get_matter_fabrics">> {
840
+ const { node_id } = args;
841
+ const nodeId = NodeId(node_id);
842
+ const fabrics = await this.#commandHandler.getFabrics(nodeId);
843
+ return fabrics.map(({ fabricId, vendorId, fabricIndex, label }) => ({
844
+ fabric_id: fabricId,
845
+ vendor_id: vendorId,
846
+ fabric_index: fabricIndex,
847
+ fabric_label: label,
848
+ vendor_name: VendorIds[vendorId],
849
+ }));
850
+ }
851
+
852
+ async #handleRemoveMatterFabric(args: ArgsOf<"remove_matter_fabric">): Promise<ResponseOf<"remove_matter_fabric">> {
853
+ const { node_id, fabric_index } = args;
854
+ await this.#commandHandler.removeFabric(NodeId(node_id), FabricIndex(fabric_index));
855
+ return {};
856
+ }
857
+
858
+ async #handleSetAclEntry(args: ArgsOf<"set_acl_entry">): Promise<ResponseOf<"set_acl_entry">> {
859
+ const { node_id, entry } = args;
860
+ return await this.#commandHandler.setAclEntry(NodeId(node_id), entry);
861
+ }
862
+
863
+ async #handleSetNodeBinding(args: ArgsOf<"set_node_binding">): Promise<ResponseOf<"set_node_binding">> {
864
+ const { node_id, endpoint, bindings } = args;
865
+ return await this.#commandHandler.setNodeBinding(NodeId(node_id), EndpointNumber(endpoint), bindings);
866
+ }
867
+
868
+ async #handleImportTestNode(args: ArgsOf<"import_test_node">): Promise<ResponseOf<"import_test_node">> {
869
+ const { dump } = args;
870
+ // Import is handled by TestNodeCommandHandler
871
+ // Events are broadcast via the nodeAdded observable
872
+ this.#testNodeHandler.importTestNodes(dump);
873
+ return null;
874
+ }
875
+
876
+ async #handleCheckNodeUpdate(args: ArgsOf<"check_node_update">): Promise<ResponseOf<"check_node_update">> {
877
+ const { node_id } = args;
878
+ return await this.#commandHandler.checkNodeUpdate(NodeId(node_id));
879
+ }
880
+
881
+ async #handleUpdateNode(args: ArgsOf<"update_node">): Promise<ResponseOf<"update_node">> {
882
+ const { node_id, software_version } = args;
883
+ const targetVersion = typeof software_version === "string" ? parseInt(software_version, 10) : software_version;
884
+ return await this.#commandHandler.updateNode(NodeId(node_id), targetVersion);
885
+ }
886
+
887
+ async #collectNodeDetails(nodeId: NodeId): Promise<MatterNode> {
888
+ const lastInterviewDate = this.#lastInterviewDates.get(nodeId);
889
+ return await this.#commandHandler.getNodeDetails(nodeId, lastInterviewDate);
890
+ }
891
+
892
+ #convertCommandDataToWebSocketTagBased(
893
+ clusterId: ClusterId,
894
+ commandName: string,
895
+ value: unknown,
896
+ clusterData?: ClusterMapEntry,
897
+ ) {
898
+ if (!clusterData) {
899
+ const cluster = getClusterById(clusterId);
900
+ clusterData = clusterData ?? ClusterMap[cluster.name.toLowerCase()];
901
+ }
902
+
903
+ if (clusterData === undefined || clusterData.commands[commandName.toLowerCase()] === undefined) {
904
+ logger.warn(
905
+ `Cluster ${clusterId} does not have command ${commandName}. Do not convert data to WebSocket tag based`,
906
+ value,
907
+ );
908
+ return {};
909
+ }
910
+
911
+ return convertMatterToWebSocketTagBased(
912
+ value,
913
+ clusterData.commands[commandName.toLowerCase()]!.responseModel,
914
+ clusterData.model,
915
+ );
916
+ }
917
+ }