@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,1015 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+ import { AsyncObservable, isObject } from "@matter/general";
7
+ import {
8
+ Bytes,
9
+ ClusterBehavior,
10
+ FabricIndex,
11
+ ipv4BytesToString,
12
+ ipv6BytesToString,
13
+ Logger,
14
+ Millis,
15
+ NodeId,
16
+ Observable,
17
+ Seconds,
18
+ SoftwareUpdateManager
19
+ } from "@matter/main";
20
+ import {
21
+ AccessControl,
22
+ Binding,
23
+ GeneralCommissioning,
24
+ GeneralDiagnosticsCluster,
25
+ OperationalCredentials
26
+ } from "@matter/main/clusters";
27
+ import {
28
+ PeerAddress,
29
+ Read,
30
+ SupportedTransportsSchema
31
+ } from "@matter/main/protocol";
32
+ import {
33
+ Attribute,
34
+ ClusterId,
35
+ Command,
36
+ DeviceTypeId,
37
+ EndpointNumber,
38
+ getClusterById,
39
+ GroupId,
40
+ ManualPairingCodeCodec,
41
+ QrPairingCodeCodec,
42
+ StatusResponseError,
43
+ TlvAny,
44
+ TlvBoolean,
45
+ TlvByteString,
46
+ TlvInt32,
47
+ TlvNoResponse,
48
+ TlvNullable,
49
+ TlvObject,
50
+ TlvString,
51
+ TlvUInt64,
52
+ TlvVoid,
53
+ VendorId
54
+ } from "@matter/main/types";
55
+ import { NodeStates } from "@project-chip/matter.js/device";
56
+ import { ClusterMap } from "../model/ModelMapper.js";
57
+ import {
58
+ buildAttributePath,
59
+ convertCommandDataToMatter,
60
+ convertMatterToWebSocketTagBased,
61
+ getDateAsString,
62
+ splitAttributePath
63
+ } from "../server/Converters.js";
64
+ import {
65
+ UpdateSource
66
+ } from "../types/WebSocketMessageTypes.js";
67
+ import { pingIp } from "../util/network.js";
68
+ import { Nodes } from "./Nodes.js";
69
+ const logger = Logger.get("ControllerCommandHandler");
70
+ class ControllerCommandHandler {
71
+ #controller;
72
+ #started = false;
73
+ #connected = false;
74
+ #bleEnabled = false;
75
+ #otaEnabled = false;
76
+ /** Node management and attribute cache */
77
+ #nodes = new Nodes();
78
+ /** Cache of available updates keyed by nodeId */
79
+ #availableUpdates = /* @__PURE__ */ new Map();
80
+ events = {
81
+ started: new AsyncObservable(),
82
+ attributeChanged: new Observable(),
83
+ eventChanged: new Observable(),
84
+ nodeAdded: new Observable(),
85
+ nodeStateChanged: new Observable(),
86
+ nodeStructureChanged: new Observable(),
87
+ nodeDecommissioned: new Observable(),
88
+ nodeEndpointAdded: new Observable(),
89
+ nodeEndpointRemoved: new Observable()
90
+ };
91
+ constructor(controllerInstance, bleEnabled, otaEnabled) {
92
+ this.#controller = controllerInstance;
93
+ this.#bleEnabled = bleEnabled;
94
+ this.#otaEnabled = otaEnabled;
95
+ }
96
+ get started() {
97
+ return this.#started;
98
+ }
99
+ get bleEnabled() {
100
+ return this.#bleEnabled;
101
+ }
102
+ async start() {
103
+ if (this.#started) {
104
+ return;
105
+ }
106
+ this.#started = true;
107
+ await this.#controller.start();
108
+ logger.info(`Controller started`);
109
+ if (!this.#bleEnabled) {
110
+ await this.#setupOtaEventHandlers();
111
+ }
112
+ await this.events.started.emit();
113
+ }
114
+ /**
115
+ * Set up event handlers for OTA update notifications from the SoftwareUpdateManager.
116
+ */
117
+ async #setupOtaEventHandlers() {
118
+ if (!this.#otaEnabled) {
119
+ return;
120
+ }
121
+ try {
122
+ const otaProvider = this.#controller.otaProvider;
123
+ if (!otaProvider) {
124
+ logger.info("OTA provider not available");
125
+ return;
126
+ }
127
+ const softwareUpdateManagerEvents = await otaProvider.act((agent) => agent.get(SoftwareUpdateManager).events);
128
+ if (softwareUpdateManagerEvents === void 0) {
129
+ logger.info("SoftwareUpdateManager not available");
130
+ return;
131
+ }
132
+ softwareUpdateManagerEvents.updateAvailable.on(
133
+ (peerAddress, updateDetails) => {
134
+ logger.info(`Update available for node ${peerAddress.nodeId}:`, updateDetails);
135
+ this.#availableUpdates.set(peerAddress.nodeId, updateDetails);
136
+ }
137
+ );
138
+ softwareUpdateManagerEvents.updateDone.on((peerAddress) => {
139
+ logger.info(`Update done for node ${peerAddress.nodeId}`);
140
+ this.#availableUpdates.delete(peerAddress.nodeId);
141
+ });
142
+ logger.info("OTA event handlers registered");
143
+ } catch (error) {
144
+ logger.warn("Failed to setup OTA event handlers:", error);
145
+ }
146
+ }
147
+ close() {
148
+ if (!this.#started) return;
149
+ return this.#controller.close();
150
+ }
151
+ async #registerNode(nodeId) {
152
+ const node = await this.#controller.getNode(nodeId);
153
+ const attributeCache = this.#nodes.attributeCache;
154
+ node.events.attributeChanged.on((data) => {
155
+ attributeCache.updateAttribute(nodeId, data);
156
+ this.events.attributeChanged.emit(nodeId, data);
157
+ });
158
+ node.events.eventTriggered.on((data) => this.events.eventChanged.emit(nodeId, data));
159
+ node.events.stateChanged.on((state) => {
160
+ if (state === NodeStates.Connected) {
161
+ attributeCache.update(node);
162
+ }
163
+ this.events.nodeStateChanged.emit(nodeId, state);
164
+ });
165
+ node.events.structureChanged.on(() => {
166
+ if (node.isConnected) {
167
+ attributeCache.update(node);
168
+ }
169
+ this.events.nodeStructureChanged.emit(nodeId);
170
+ });
171
+ node.events.decommissioned.on(() => this.events.nodeDecommissioned.emit(nodeId));
172
+ node.events.nodeEndpointAdded.on((endpointId) => this.events.nodeEndpointAdded.emit(nodeId, endpointId));
173
+ node.events.nodeEndpointRemoved.on((endpointId) => this.events.nodeEndpointRemoved.emit(nodeId, endpointId));
174
+ this.#nodes.set(nodeId, node);
175
+ if (node.initialized) {
176
+ attributeCache.add(node);
177
+ }
178
+ return node;
179
+ }
180
+ async connect() {
181
+ if (this.#connected) {
182
+ return;
183
+ }
184
+ this.#connected = true;
185
+ await this.start();
186
+ const nodes = this.#controller.getCommissionedNodes();
187
+ logger.info(`Found ${nodes.length} nodes: ${nodes.map((nodeId) => nodeId.toString()).join(", ")}`);
188
+ for (const nodeId of nodes) {
189
+ try {
190
+ logger.info(`Initializing node "${nodeId}" ...`);
191
+ const node = await this.#registerNode(nodeId);
192
+ node.connect({
193
+ subscribeMinIntervalFloorSeconds: 1,
194
+ subscribeMaxIntervalCeilingSeconds: void 0
195
+ });
196
+ } catch (error) {
197
+ logger.warn(`Failed to connect to node "${nodeId}":`, error);
198
+ }
199
+ }
200
+ }
201
+ getNodeIds() {
202
+ return this.#nodes.getIds();
203
+ }
204
+ hasNode(nodeId) {
205
+ return this.#nodes.has(nodeId);
206
+ }
207
+ /**
208
+ * Alias for decommissionNode to match NodeCommandHandler interface.
209
+ */
210
+ removeNode(nodeId) {
211
+ return this.decommissionNode(nodeId);
212
+ }
213
+ async interviewNode(nodeId) {
214
+ const node = this.#nodes.get(nodeId);
215
+ logger.info(`Interview requested for node ${nodeId} - do a complete read`);
216
+ const read = {
217
+ ...Read({
218
+ fabricFilter: true,
219
+ attributes: [{}]
220
+ }),
221
+ includeKnownVersions: true
222
+ // do not send DataVersionFilters, so we do a new clean read
223
+ };
224
+ for await (const _chunk of node.node.interaction.read(read)) ;
225
+ this.events.nodeStateChanged.emit(nodeId, node.connectionState);
226
+ }
227
+ /**
228
+ * Get full node details in WebSocket API format.
229
+ * @param nodeId The node ID
230
+ * @param lastInterviewDate Optional last interview date (tracked externally)
231
+ */
232
+ async getNodeDetails(nodeId, lastInterviewDate) {
233
+ const node = this.#nodes.get(nodeId);
234
+ const attributeCache = this.#nodes.attributeCache;
235
+ let isBridge = false;
236
+ if (!attributeCache.has(nodeId)) {
237
+ attributeCache.add(node);
238
+ }
239
+ const attributes = attributeCache.get(nodeId) ?? {};
240
+ const endpoint1DeviceTypes = attributes["1/29/0"];
241
+ if (Array.isArray(endpoint1DeviceTypes)) {
242
+ isBridge = endpoint1DeviceTypes.some((entry) => entry["0"] === 14);
243
+ }
244
+ const commissionedAt = node.state.commissioning.commissionedAt;
245
+ const dateCommissioned = commissionedAt !== void 0 ? new Date(commissionedAt) : /* @__PURE__ */ new Date();
246
+ return {
247
+ node_id: node.nodeId,
248
+ date_commissioned: getDateAsString(dateCommissioned),
249
+ last_interview: getDateAsString(lastInterviewDate ?? /* @__PURE__ */ new Date()),
250
+ interview_version: 6,
251
+ available: node.isConnected,
252
+ is_bridge: isBridge,
253
+ attributes,
254
+ attribute_subscriptions: []
255
+ };
256
+ }
257
+ /**
258
+ * Read multiple attributes from a node by path strings.
259
+ * Handles wildcards in paths.
260
+ */
261
+ async handleReadAttributes(nodeId, attributePaths, fabricFiltered = false) {
262
+ const node = this.#nodes.get(nodeId);
263
+ const result = {};
264
+ const hasWildcards = attributePaths.some((path) => path.includes("*"));
265
+ let allAttributes;
266
+ if (hasWildcards) {
267
+ if (!node.initialized) {
268
+ throw new Error(`Node ${nodeId} not ready`);
269
+ }
270
+ const rootEndpoint = node.getRootEndpoint();
271
+ if (rootEndpoint === void 0) {
272
+ throw new Error(`Node ${nodeId} not ready`);
273
+ }
274
+ allAttributes = {};
275
+ this.#collectAttributesFromEndpoint(rootEndpoint, allAttributes);
276
+ }
277
+ for (const path of attributePaths) {
278
+ const { endpointId, clusterId, attributeId } = splitAttributePath(path);
279
+ if (path.includes("*") && allAttributes !== void 0) {
280
+ for (const [attrPath, value] of Object.entries(allAttributes)) {
281
+ const parts = attrPath.split("/").map(Number);
282
+ if ((endpointId === void 0 || parts[0] === endpointId) && (clusterId === void 0 || parts[1] === clusterId) && (attributeId === void 0 || parts[2] === attributeId)) {
283
+ result[attrPath] = value;
284
+ }
285
+ }
286
+ continue;
287
+ }
288
+ const { values, status } = await this.handleReadAttribute({
289
+ nodeId,
290
+ endpointId,
291
+ clusterId,
292
+ attributeId,
293
+ fabricFiltered
294
+ });
295
+ if (values.length) {
296
+ for (const valueData of values) {
297
+ const { pathStr, value } = this.#convertAttributeToWebSocket(
298
+ {
299
+ endpointId: EndpointNumber(valueData.endpointId),
300
+ clusterId: ClusterId(valueData.clusterId),
301
+ attributeId: valueData.attributeId
302
+ },
303
+ valueData.value
304
+ );
305
+ result[pathStr] = value;
306
+ }
307
+ } else if (status && status.length > 0) {
308
+ logger.warn(`Failed to read attribute ${path}: status=${JSON.stringify(status)}`);
309
+ }
310
+ }
311
+ return result;
312
+ }
313
+ /**
314
+ * Collect all attributes from an endpoint and its children into WebSocket format.
315
+ */
316
+ #collectAttributesFromEndpoint(endpoint, attributesData) {
317
+ const endpointId = endpoint.number;
318
+ for (const behavior of endpoint.endpoint.behaviors.active) {
319
+ if (!ClusterBehavior.is(behavior)) {
320
+ continue;
321
+ }
322
+ const cluster = behavior.cluster;
323
+ const clusterId = cluster.id;
324
+ const clusterData = ClusterMap[cluster.name.toLowerCase()];
325
+ const clusterState = endpoint.endpoint.stateOf(behavior);
326
+ for (const attributeName in cluster.attributes) {
327
+ const attribute = cluster.attributes[attributeName];
328
+ if (attribute === void 0) {
329
+ continue;
330
+ }
331
+ const attributeValue = clusterState[attributeName];
332
+ const { pathStr, value } = this.#convertAttributeToWebSocket(
333
+ { endpointId, clusterId, attributeId: attribute.id },
334
+ attributeValue,
335
+ clusterData
336
+ );
337
+ attributesData[pathStr] = value;
338
+ }
339
+ }
340
+ for (const childEndpoint of endpoint.getChildEndpoints()) {
341
+ this.#collectAttributesFromEndpoint(childEndpoint, attributesData);
342
+ }
343
+ }
344
+ /**
345
+ * Convert attribute data to WebSocket tag-based format.
346
+ */
347
+ #convertAttributeToWebSocket(path, value, clusterData) {
348
+ const { endpointId, clusterId, attributeId } = path;
349
+ if (!clusterData) {
350
+ const cluster = getClusterById(clusterId);
351
+ clusterData = ClusterMap[cluster.name.toLowerCase()];
352
+ }
353
+ return {
354
+ pathStr: buildAttributePath(endpointId, clusterId, attributeId),
355
+ value: convertMatterToWebSocketTagBased(value, clusterData?.attributes[attributeId], clusterData?.model)
356
+ };
357
+ }
358
+ /**
359
+ * Set the fabric label. Pass null or empty string to reset to "Home".
360
+ * Note: matter.js requires non-empty labels (1-32 chars), so null/empty resets to default.
361
+ */
362
+ setFabricLabel(label) {
363
+ const effectiveLabel = label && label.trim() !== "" ? label : "Home";
364
+ return this.#controller.updateFabricLabel(effectiveLabel);
365
+ }
366
+ disconnectNode(nodeId) {
367
+ return this.#controller.disconnectNode(nodeId, true);
368
+ }
369
+ async handleReadAttribute(data) {
370
+ const { nodeId, endpointId, clusterId, attributeId, fabricFiltered = true } = data;
371
+ const client = await this.#nodes.interactionClientFor(nodeId);
372
+ const { attributeData, attributeStatus } = await client.getMultipleAttributesAndStatus({
373
+ attributes: [{ endpointId, clusterId, attributeId }],
374
+ isFabricFiltered: fabricFiltered
375
+ });
376
+ return {
377
+ values: attributeData.map(
378
+ ({ path: { endpointId: endpointId2, clusterId: clusterId2, attributeId: attributeId2 }, value, version: dataVersion }) => ({
379
+ attributeId: attributeId2,
380
+ clusterId: clusterId2,
381
+ dataVersion,
382
+ endpointId: endpointId2,
383
+ value
384
+ })
385
+ ),
386
+ status: attributeStatus?.map(({ path: { endpointId: endpointId2, clusterId: clusterId2, attributeId: attributeId2 }, status, clusterStatus }) => ({
387
+ attributeId: attributeId2,
388
+ clusterId: clusterId2,
389
+ endpointId: endpointId2,
390
+ status,
391
+ clusterStatus
392
+ }))
393
+ };
394
+ }
395
+ async handleReadEvent(data) {
396
+ const { nodeId, endpointId, clusterId, eventId, eventMin } = data;
397
+ const client = await this.#nodes.interactionClientFor(nodeId);
398
+ const { eventData, eventStatus } = await client.getMultipleEventsAndStatus({
399
+ events: [
400
+ {
401
+ endpointId,
402
+ clusterId,
403
+ eventId
404
+ }
405
+ ],
406
+ eventFilters: eventMin ? [{ eventMin }] : void 0
407
+ });
408
+ return {
409
+ values: eventData.flatMap(
410
+ ({ path: { endpointId: endpointId2, clusterId: clusterId2, eventId: eventId2 }, events }) => events.map(({ eventNumber, data: data2 }) => ({
411
+ eventId: eventId2,
412
+ clusterId: clusterId2,
413
+ endpointId: endpointId2,
414
+ eventNumber,
415
+ value: data2
416
+ }))
417
+ ),
418
+ status: eventStatus?.map(({ path: { endpointId: endpointId2, clusterId: clusterId2, eventId: eventId2 }, status, clusterStatus }) => ({
419
+ clusterId: clusterId2,
420
+ endpointId: endpointId2,
421
+ eventId: eventId2,
422
+ status,
423
+ clusterStatus
424
+ }))
425
+ };
426
+ }
427
+ async handleSubscribeAttribute(data) {
428
+ const { nodeId, endpointId, clusterId, attributeId, minInterval, maxInterval, changeListener } = data;
429
+ const client = await this.#nodes.interactionClientFor(nodeId);
430
+ const updated = Observable();
431
+ let ignoreData = true;
432
+ const { attributeReports = [] } = await client.subscribeMultipleAttributesAndEvents({
433
+ attributes: [
434
+ {
435
+ endpointId,
436
+ clusterId,
437
+ attributeId
438
+ }
439
+ ],
440
+ minIntervalFloorSeconds: minInterval,
441
+ maxIntervalCeilingSeconds: maxInterval,
442
+ attributeListener: (data2) => {
443
+ if (ignoreData) return;
444
+ changeListener({
445
+ attributeId: data2.path.attributeId,
446
+ clusterId: data2.path.clusterId,
447
+ endpointId: data2.path.endpointId,
448
+ dataVersion: data2.version,
449
+ value: data2.value
450
+ });
451
+ },
452
+ updateReceived: () => {
453
+ updated.emit();
454
+ },
455
+ keepSubscriptions: false
456
+ });
457
+ ignoreData = false;
458
+ return {
459
+ values: attributeReports.map(
460
+ ({ path: { endpointId: endpointId2, clusterId: clusterId2, attributeId: attributeId2 }, value, version: dataVersion }) => ({
461
+ attributeId: attributeId2,
462
+ clusterId: clusterId2,
463
+ endpointId: endpointId2,
464
+ dataVersion,
465
+ value
466
+ })
467
+ ),
468
+ updated
469
+ };
470
+ }
471
+ async handleSubscribeEvent(data) {
472
+ const { nodeId, endpointId, clusterId, eventId, minInterval, maxInterval, changeListener } = data;
473
+ const client = await this.#nodes.interactionClientFor(nodeId);
474
+ const updated = Observable();
475
+ let ignoreData = true;
476
+ const { eventReports = [] } = await client.subscribeMultipleAttributesAndEvents({
477
+ events: [
478
+ {
479
+ endpointId,
480
+ clusterId,
481
+ eventId
482
+ }
483
+ ],
484
+ minIntervalFloorSeconds: minInterval,
485
+ maxIntervalCeilingSeconds: maxInterval,
486
+ eventListener: (data2) => {
487
+ if (ignoreData) return;
488
+ data2.events.forEach(
489
+ (event) => changeListener({
490
+ eventId: data2.path.eventId,
491
+ clusterId: data2.path.clusterId,
492
+ endpointId: data2.path.endpointId,
493
+ eventNumber: event.eventNumber,
494
+ value: event.data
495
+ })
496
+ );
497
+ },
498
+ updateReceived: () => {
499
+ updated.emit();
500
+ },
501
+ keepSubscriptions: false
502
+ });
503
+ ignoreData = false;
504
+ return {
505
+ values: eventReports.flatMap(
506
+ ({ path: { endpointId: endpointId2, clusterId: clusterId2, eventId: eventId2 }, events }) => events.map(({ eventNumber, data: data2 }) => ({
507
+ eventId: eventId2,
508
+ clusterId: clusterId2,
509
+ endpointId: endpointId2,
510
+ eventNumber,
511
+ value: data2
512
+ }))
513
+ ),
514
+ updated
515
+ };
516
+ }
517
+ async handleWriteAttribute(data) {
518
+ const { nodeId, endpointId, clusterId, attributeId, value } = data;
519
+ const client = this.#nodes.clusterClientByIdFor(nodeId, endpointId, clusterId);
520
+ logger.info("Writing attribute", attributeId, "with value", value);
521
+ try {
522
+ await client.attributes[attributeId].set(value);
523
+ return {
524
+ attributeId,
525
+ clusterId,
526
+ endpointId,
527
+ status: 0
528
+ };
529
+ } catch (error) {
530
+ StatusResponseError.accept(error);
531
+ return {
532
+ attributeId,
533
+ clusterId,
534
+ endpointId,
535
+ status: error.code,
536
+ clusterStatus: error.clusterCode
537
+ };
538
+ }
539
+ }
540
+ async handleInvoke(data) {
541
+ const {
542
+ nodeId,
543
+ endpointId,
544
+ clusterId,
545
+ commandName,
546
+ timedInteractionTimeoutMs: timedRequestTimeoutMs,
547
+ interactionTimeoutMs
548
+ } = data;
549
+ let { data: commandData } = data;
550
+ const client = this.#nodes.clusterClientByIdFor(nodeId, endpointId, clusterId);
551
+ if (!client[commandName] || !client.isCommandSupportedByName(commandName)) {
552
+ throw new Error("Command not existing");
553
+ }
554
+ if (isObject(commandData)) {
555
+ if (Object.keys(commandData).length === 0) {
556
+ commandData = void 0;
557
+ } else {
558
+ const cluster = ClusterMap[client.name.toLowerCase()];
559
+ const model = cluster?.commands[commandName.toLowerCase()];
560
+ if (cluster && model) {
561
+ commandData = convertCommandDataToMatter(commandData, model, cluster.model);
562
+ }
563
+ }
564
+ }
565
+ return client[commandName](commandData, {
566
+ timedRequestTimeout: Millis(timedRequestTimeoutMs),
567
+ expectedProcessingTime: interactionTimeoutMs !== void 0 ? Millis(interactionTimeoutMs) : void 0
568
+ });
569
+ }
570
+ /** InvokeById minimalistic handler because only used for error testing */
571
+ async handleInvokeById(data) {
572
+ const { nodeId, endpointId, clusterId, commandId, data: commandData, timedInteractionTimeoutMs } = data;
573
+ const client = await this.#nodes.interactionClientFor(nodeId);
574
+ await client.invoke({
575
+ endpointId,
576
+ clusterId,
577
+ command: Command(commandId, TlvAny, 0, TlvNoResponse, {
578
+ timed: timedInteractionTimeoutMs !== void 0
579
+ }),
580
+ request: commandData === void 0 ? TlvVoid.encodeTlv() : TlvObject({}).encodeTlv(commandData),
581
+ asTimedRequest: timedInteractionTimeoutMs !== void 0,
582
+ timedRequestTimeout: Millis(timedInteractionTimeoutMs),
583
+ skipValidation: true
584
+ });
585
+ }
586
+ async handleWriteAttributeById(data) {
587
+ const { nodeId, endpointId, clusterId, attributeId, value } = data;
588
+ const client = await this.#nodes.interactionClientFor(nodeId);
589
+ logger.info("Writing attribute", attributeId, "with value", value);
590
+ let tlvValue;
591
+ if (value === null) {
592
+ tlvValue = TlvNullable(TlvBoolean).encodeTlv(value);
593
+ } else if (value instanceof Uint8Array) {
594
+ tlvValue = TlvByteString.encodeTlv(value);
595
+ } else {
596
+ switch (typeof value) {
597
+ case "boolean":
598
+ tlvValue = TlvBoolean.encodeTlv(value);
599
+ break;
600
+ case "number":
601
+ tlvValue = TlvInt32.encodeTlv(value);
602
+ break;
603
+ case "bigint":
604
+ tlvValue = TlvUInt64.encodeTlv(value);
605
+ break;
606
+ case "string":
607
+ tlvValue = TlvString.encodeTlv(value);
608
+ break;
609
+ default:
610
+ throw new Error("Unsupported value type for Any encoding");
611
+ }
612
+ }
613
+ await client.setAttribute({
614
+ attributeData: {
615
+ endpointId,
616
+ clusterId,
617
+ attribute: Attribute(attributeId, TlvAny),
618
+ value: tlvValue
619
+ }
620
+ });
621
+ }
622
+ #determineCommissionOptions(data) {
623
+ let passcode = void 0;
624
+ let shortDiscriminator = void 0;
625
+ let longDiscriminator = void 0;
626
+ let productId = void 0;
627
+ let vendorId = void 0;
628
+ let knownAddress = void 0;
629
+ if ("manualCode" in data && data.manualCode.length > 0) {
630
+ const pairingCodeCodec = ManualPairingCodeCodec.decode(data.manualCode);
631
+ shortDiscriminator = pairingCodeCodec.shortDiscriminator;
632
+ longDiscriminator = void 0;
633
+ passcode = pairingCodeCodec.passcode;
634
+ } else if ("qrCode" in data && data.qrCode.length > 0) {
635
+ const pairingCodeCodec = QrPairingCodeCodec.decode(data.qrCode);
636
+ longDiscriminator = pairingCodeCodec[0].discriminator;
637
+ shortDiscriminator = void 0;
638
+ passcode = pairingCodeCodec[0].passcode;
639
+ } else if ("passcode" in data) {
640
+ passcode = data.passcode;
641
+ if ("shortDiscriminator" in data) {
642
+ shortDiscriminator = data.shortDiscriminator;
643
+ } else if ("longDiscriminator" in data) {
644
+ longDiscriminator = data.longDiscriminator;
645
+ } else if ("vendorId" in data && "productId" in data) {
646
+ vendorId = VendorId(data.vendorId);
647
+ productId = data.productId;
648
+ }
649
+ } else {
650
+ throw new Error("No pairing code provided");
651
+ }
652
+ if (data.knownAddress !== void 0) {
653
+ const { ip, port } = data.knownAddress;
654
+ knownAddress = {
655
+ type: "udp",
656
+ ip,
657
+ port
658
+ };
659
+ }
660
+ if (passcode == void 0) {
661
+ throw new Error("No passcode provided");
662
+ }
663
+ const { onNetworkOnly } = data;
664
+ return {
665
+ commissioning: {
666
+ nodeId: data.nodeId,
667
+ regulatoryLocation: GeneralCommissioning.RegulatoryLocationType.IndoorOutdoor,
668
+ regulatoryCountryCode: "XX"
669
+ },
670
+ discovery: {
671
+ knownAddress,
672
+ identifierData: longDiscriminator !== void 0 ? { longDiscriminator } : shortDiscriminator !== void 0 ? { shortDiscriminator } : vendorId !== void 0 ? { vendorId, productId } : {},
673
+ discoveryCapabilities: {
674
+ ble: this.bleEnabled && !onNetworkOnly,
675
+ onIpNetwork: true
676
+ }
677
+ },
678
+ passcode
679
+ };
680
+ }
681
+ async commissionNode(data) {
682
+ const nodeId = await this.#controller.commissionNode(this.#determineCommissionOptions(data), {
683
+ connectNodeAfterCommissioning: true
684
+ });
685
+ await this.#registerNode(nodeId);
686
+ this.events.nodeAdded.emit(nodeId);
687
+ return { nodeId };
688
+ }
689
+ getCommissionerNodeId() {
690
+ return this.#controller.nodeId;
691
+ }
692
+ async getCommissionerFabricData() {
693
+ const { fabricId, globalId } = this.#controller.fabric;
694
+ return {
695
+ fabricId,
696
+ compressedFabricId: globalId
697
+ };
698
+ }
699
+ /** Discover commissionable devices */
700
+ async handleDiscovery({ findBy }) {
701
+ const result = await this.#controller.discoverCommissionableDevices(
702
+ findBy ?? {},
703
+ { onIpNetwork: true },
704
+ void 0,
705
+ Seconds(3)
706
+ // Just check for 3 sec
707
+ );
708
+ logger.info("Discovered result", result);
709
+ const latestDiscovery = result[result.length - 1];
710
+ if (latestDiscovery === void 0) {
711
+ return [];
712
+ }
713
+ return [latestDiscovery].map(({ DT, DN, CM, D, RI, PH, PI, T, VP, deviceIdentifier, addresses, SII, SAI }) => {
714
+ const { tcpClient: supportsTcpClient, tcpServer: supportsTcpServer } = SupportedTransportsSchema.decode(
715
+ T ?? 0
716
+ );
717
+ const vendorId = VP === void 0 ? -1 : VP.includes("+") ? parseInt(VP.split("+")[0]) : parseInt(VP);
718
+ const productId = VP === void 0 ? -1 : VP.includes("+") ? parseInt(VP.split("+")[1]) : -1;
719
+ const port = addresses.length ? addresses[0].port : 0;
720
+ const numIPs = addresses.length;
721
+ return {
722
+ commissioningMode: CM,
723
+ deviceName: DN ?? "",
724
+ deviceType: DT ?? 0,
725
+ hostName: "000000000000",
726
+ // Right now we do not return real hostname, only used internally
727
+ instanceName: deviceIdentifier,
728
+ longDiscriminator: D,
729
+ numIPs,
730
+ pairingHint: PH ?? -1,
731
+ pairingInstruction: PI ?? "",
732
+ port,
733
+ productId,
734
+ rotatingId: RI ?? "",
735
+ rotatingIdLen: RI?.length ?? 0,
736
+ shortDiscriminator: D >> 8 & 15,
737
+ vendorId,
738
+ supportsTcpServer,
739
+ supportsTcpClient,
740
+ addresses: addresses.filter(({ type }) => type === "udp").map(({ ip }) => ip),
741
+ mrpSessionIdleInterval: SII,
742
+ mrpSessionActiveInterval: SAI
743
+ };
744
+ });
745
+ }
746
+ async getNodeIpAddresses(nodeId, preferCache = true) {
747
+ const node = this.#nodes.get(nodeId);
748
+ const addresses = /* @__PURE__ */ new Set();
749
+ const generalDiag = node.getRootClusterClient(GeneralDiagnosticsCluster);
750
+ if (generalDiag) {
751
+ try {
752
+ const networkInterfaces = await generalDiag.getNetworkInterfacesAttribute(preferCache ? true : true);
753
+ if (networkInterfaces) {
754
+ const interfaces = networkInterfaces.filter(({ isOperational }) => isOperational);
755
+ if (interfaces.length) {
756
+ logger.info(`Found ${interfaces.length} operational network interfaces`, interfaces);
757
+ interfaces.forEach(({ iPv4Addresses, iPv6Addresses }) => {
758
+ iPv4Addresses.forEach((ip) => addresses.add(ipv4BytesToString(Bytes.of(ip))));
759
+ iPv6Addresses.forEach((ip) => addresses.add(ipv6BytesToString(Bytes.of(ip))));
760
+ });
761
+ }
762
+ }
763
+ } catch (e) {
764
+ logger.info(`Failed to get network interfaces: ${e}`);
765
+ }
766
+ }
767
+ return Array.from(addresses.values());
768
+ }
769
+ /**
770
+ * Ping a node on all its known IP addresses.
771
+ * @param nodeId The node ID to ping
772
+ * @param attempts Number of ping attempts per IP (default: 1)
773
+ * @returns A record of IP addresses to ping success status
774
+ */
775
+ async pingNode(nodeId, attempts = 1) {
776
+ const node = this.#nodes.get(nodeId);
777
+ const result = {};
778
+ const ipAddresses = await this.getNodeIpAddresses(nodeId, false);
779
+ if (ipAddresses.length === 0) {
780
+ logger.info(`No IP addresses found for node ${nodeId}`);
781
+ return result;
782
+ }
783
+ logger.info(`Pinging node ${nodeId} on ${ipAddresses.length} addresses:`, ipAddresses);
784
+ const pingPromises = ipAddresses.map(async (ip) => {
785
+ const cleanIp = ip.includes("%") ? ip.split("%")[0] : ip;
786
+ logger.debug(`Pinging ${cleanIp}`);
787
+ const success = await pingIp(ip, 10, attempts);
788
+ result[ip] = success;
789
+ logger.debug(`Ping result for ${cleanIp}: ${success}`);
790
+ });
791
+ await Promise.all(pingPromises);
792
+ if (node.isConnected) {
793
+ const anySuccess = Object.values(result).some((v) => v);
794
+ if (!anySuccess && ipAddresses.length > 0) {
795
+ logger.info(`Node ${nodeId} is connected but no pings succeeded`);
796
+ }
797
+ }
798
+ return result;
799
+ }
800
+ async decommissionNode(nodeId) {
801
+ const node = this.#nodes.has(nodeId) ? this.#nodes.get(nodeId) : void 0;
802
+ await this.#controller.removeNode(nodeId, !!node?.isConnected);
803
+ this.#nodes.delete(nodeId);
804
+ this.events.nodeDecommissioned.emit(nodeId);
805
+ }
806
+ async openCommissioningWindow(data) {
807
+ const { nodeId, timeout } = data;
808
+ const node = this.#nodes.get(nodeId);
809
+ const { manualPairingCode, qrPairingCode } = await node.openEnhancedCommissioningWindow(timeout);
810
+ return { manualCode: manualPairingCode, qrCode: qrPairingCode };
811
+ }
812
+ async getFabrics(nodeId) {
813
+ const client = this.#nodes.clusterClientFor(nodeId, EndpointNumber(0), OperationalCredentials.Cluster);
814
+ return (await client.getFabricsAttribute(true, false)).map(({ fabricId, fabricIndex, vendorId, label }) => ({
815
+ fabricId,
816
+ vendorId,
817
+ fabricIndex,
818
+ label
819
+ }));
820
+ }
821
+ removeFabric(nodeId, fabricIndex) {
822
+ const client = this.#nodes.clusterClientFor(nodeId, EndpointNumber(0), OperationalCredentials.Cluster);
823
+ return client.removeFabric({ fabricIndex });
824
+ }
825
+ /**
826
+ * Set Access Control List entries on a node.
827
+ * Writes to the ACL attribute on the AccessControl cluster (endpoint 0).
828
+ */
829
+ async setAclEntry(nodeId, entries) {
830
+ const client = this.#nodes.clusterClientFor(nodeId, EndpointNumber(0), AccessControl.Cluster);
831
+ const aclEntries = entries.map((entry) => ({
832
+ privilege: entry.privilege,
833
+ authMode: entry.auth_mode,
834
+ subjects: entry.subjects?.map((s) => NodeId(BigInt(s))) ?? null,
835
+ targets: entry.targets?.map((t) => ({
836
+ cluster: t.cluster !== null ? ClusterId(t.cluster) : null,
837
+ endpoint: t.endpoint !== null ? EndpointNumber(t.endpoint) : null,
838
+ deviceType: t.device_type !== null ? DeviceTypeId(t.device_type) : null
839
+ })) ?? null,
840
+ fabricIndex: FabricIndex.OMIT_FABRIC
841
+ }));
842
+ logger.info("Setting ACL entries", aclEntries);
843
+ try {
844
+ await client.setAclAttribute(aclEntries);
845
+ return [
846
+ {
847
+ path: {
848
+ endpoint_id: 0,
849
+ cluster_id: AccessControl.Cluster.id,
850
+ attribute_id: 0
851
+ // ACL attribute ID
852
+ },
853
+ status: 0
854
+ }
855
+ ];
856
+ } catch (error) {
857
+ StatusResponseError.accept(error);
858
+ return [
859
+ {
860
+ path: {
861
+ endpoint_id: 0,
862
+ cluster_id: AccessControl.Cluster.id,
863
+ attribute_id: 0
864
+ },
865
+ status: error.code
866
+ }
867
+ ];
868
+ }
869
+ }
870
+ /**
871
+ * Set bindings on a specific endpoint of a node.
872
+ * Writes to the Binding attribute on the Binding cluster.
873
+ */
874
+ async setNodeBinding(nodeId, endpointId, bindings) {
875
+ const client = this.#nodes.clusterClientFor(nodeId, endpointId, Binding.Cluster);
876
+ const bindingEntries = bindings.map((binding) => ({
877
+ node: binding.node !== null ? NodeId(binding.node) : void 0,
878
+ group: binding.group !== null ? GroupId(binding.group) : void 0,
879
+ endpoint: binding.endpoint !== null ? EndpointNumber(binding.endpoint) : void 0,
880
+ cluster: binding.cluster !== null ? ClusterId(binding.cluster) : void 0,
881
+ fabricIndex: FabricIndex.OMIT_FABRIC
882
+ }));
883
+ logger.info("Setting bindings on endpoint", endpointId, bindingEntries);
884
+ try {
885
+ await client.attributes.binding.set(bindingEntries);
886
+ return [
887
+ {
888
+ path: {
889
+ endpoint_id: endpointId,
890
+ cluster_id: Binding.Cluster.id,
891
+ attribute_id: 0
892
+ // Binding attribute ID
893
+ },
894
+ status: 0
895
+ }
896
+ ];
897
+ } catch (error) {
898
+ StatusResponseError.accept(error);
899
+ return [
900
+ {
901
+ path: {
902
+ endpoint_id: endpointId,
903
+ cluster_id: Binding.Cluster.id,
904
+ attribute_id: 0
905
+ },
906
+ status: error.code
907
+ }
908
+ ];
909
+ }
910
+ }
911
+ /**
912
+ * Check if a software update is available for a node.
913
+ * First checks the cached updates from OTA events, then queries the DCL if not found.
914
+ */
915
+ async checkNodeUpdate(nodeId) {
916
+ if (!this.#otaEnabled) {
917
+ throw new Error("OTA is disabled.");
918
+ }
919
+ const cachedUpdate = this.#availableUpdates.get(nodeId);
920
+ if (cachedUpdate) {
921
+ return this.#convertToMatterSoftwareVersion(cachedUpdate);
922
+ }
923
+ const node = this.#nodes.get(nodeId);
924
+ try {
925
+ const otaProvider = this.#controller.otaProvider;
926
+ if (!otaProvider) {
927
+ logger.info("OTA provider not available");
928
+ return null;
929
+ }
930
+ const updatesAvailable = await otaProvider.act(
931
+ (agent) => agent.get(SoftwareUpdateManager).queryUpdates({
932
+ peerToCheck: node.node,
933
+ includeStoredUpdates: true
934
+ })
935
+ );
936
+ const peerAddress = this.#controller.fabric.addressOf(nodeId);
937
+ const nodeUpdate = updatesAvailable.find(
938
+ ({ peerAddress: updateAddress }) => PeerAddress.is(peerAddress, updateAddress)
939
+ );
940
+ if (nodeUpdate) {
941
+ const { info } = nodeUpdate;
942
+ this.#availableUpdates.set(nodeId, info);
943
+ return this.#convertToMatterSoftwareVersion(info);
944
+ }
945
+ return null;
946
+ } catch (error) {
947
+ logger.warn(`Failed to check for updates for node ${nodeId}:`, error);
948
+ return null;
949
+ }
950
+ }
951
+ /**
952
+ * Trigger a software update for a node.
953
+ * @param nodeId The node to update
954
+ * @param softwareVersion The target software version to update to
955
+ */
956
+ async updateNode(nodeId, softwareVersion) {
957
+ if (!this.#otaEnabled) {
958
+ throw new Error("OTA is disabled.");
959
+ }
960
+ if (!this.#nodes.has(nodeId)) {
961
+ throw new Error(`Node ${nodeId} not found`);
962
+ }
963
+ try {
964
+ const otaProvider = this.#controller.otaProvider;
965
+ if (!otaProvider) {
966
+ throw new Error("OTA provider not available");
967
+ }
968
+ let updateInfo = this.#availableUpdates.get(nodeId);
969
+ if (!updateInfo) {
970
+ const result = await this.checkNodeUpdate(nodeId);
971
+ if (!result) {
972
+ throw new Error("No update available for this node");
973
+ }
974
+ updateInfo = this.#availableUpdates.get(nodeId);
975
+ if (!updateInfo) {
976
+ throw new Error("Failed to get update info");
977
+ }
978
+ }
979
+ logger.info(`Starting update for node ${nodeId} to version ${softwareVersion}`);
980
+ await otaProvider.act(
981
+ (agent) => agent.get(SoftwareUpdateManager).forceUpdate(
982
+ this.#controller.fabric.addressOf(nodeId),
983
+ updateInfo.vendorId,
984
+ updateInfo.productId,
985
+ softwareVersion
986
+ )
987
+ );
988
+ return this.#convertToMatterSoftwareVersion(updateInfo);
989
+ } catch (error) {
990
+ logger.error(`Failed to update node ${nodeId}:`, error);
991
+ throw error;
992
+ }
993
+ }
994
+ /**
995
+ * Convert SoftwareUpdateInfo to MatterSoftwareVersion format for WebSocket API.
996
+ */
997
+ #convertToMatterSoftwareVersion(updateInfo) {
998
+ const { vendorId, productId, softwareVersion, softwareVersionString, releaseNotesUrl, source } = updateInfo;
999
+ return {
1000
+ vid: vendorId,
1001
+ pid: productId,
1002
+ software_version: softwareVersion,
1003
+ software_version_string: softwareVersionString,
1004
+ min_applicable_software_version: 0,
1005
+ // Not available from SoftwareUpdateInfo
1006
+ max_applicable_software_version: softwareVersion - 1,
1007
+ release_notes_url: releaseNotesUrl,
1008
+ update_source: source === "dcl-prod" ? UpdateSource.MAIN_NET_DCL : source === "dcl-test" ? UpdateSource.TEST_NET_DCL : UpdateSource.LOCAL
1009
+ };
1010
+ }
1011
+ }
1012
+ export {
1013
+ ControllerCommandHandler
1014
+ };
1015
+ //# sourceMappingURL=ControllerCommandHandler.js.map