@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,1256 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { AsyncObservable, isObject } from "@matter/general";
8
+ import {
9
+ Bytes,
10
+ ClientNodeInteraction,
11
+ ClusterBehavior,
12
+ FabricId,
13
+ FabricIndex,
14
+ ipv4BytesToString,
15
+ ipv6BytesToString,
16
+ Logger,
17
+ Millis,
18
+ NodeId,
19
+ Observable,
20
+ Seconds,
21
+ ServerAddress,
22
+ ServerAddressUdp,
23
+ SoftwareUpdateInfo,
24
+ SoftwareUpdateManager,
25
+ } from "@matter/main";
26
+ import {
27
+ AccessControl,
28
+ Binding,
29
+ GeneralCommissioning,
30
+ GeneralDiagnosticsCluster,
31
+ OperationalCredentials,
32
+ } from "@matter/main/clusters";
33
+ import {
34
+ DecodedAttributeReportValue,
35
+ DecodedEventReportValue,
36
+ PeerAddress,
37
+ Read,
38
+ SignatureFromCommandSpec,
39
+ SupportedTransportsSchema,
40
+ } from "@matter/main/protocol";
41
+ import {
42
+ Attribute,
43
+ ClusterId,
44
+ Command,
45
+ DeviceTypeId,
46
+ EndpointNumber,
47
+ getClusterById,
48
+ GroupId,
49
+ ManualPairingCodeCodec,
50
+ QrPairingCodeCodec,
51
+ StatusResponseError,
52
+ TlvAny,
53
+ TlvBoolean,
54
+ TlvByteString,
55
+ TlvInt32,
56
+ TlvNoResponse,
57
+ TlvNullable,
58
+ TlvObject,
59
+ TlvString,
60
+ TlvUInt64,
61
+ TlvVoid,
62
+ VendorId,
63
+ } from "@matter/main/types";
64
+ import { CommissioningController, NodeCommissioningOptions } from "@project-chip/matter.js";
65
+ import { Endpoint, NodeStates } from "@project-chip/matter.js/device";
66
+ import { ClusterMap, ClusterMapEntry } from "../model/ModelMapper.js";
67
+ import {
68
+ buildAttributePath,
69
+ convertCommandDataToMatter,
70
+ convertMatterToWebSocketTagBased,
71
+ getDateAsString,
72
+ splitAttributePath,
73
+ } from "../server/Converters.js";
74
+ import {
75
+ AttributeResponseStatus,
76
+ AttributesData,
77
+ CommissioningRequest,
78
+ CommissioningResponse,
79
+ DiscoveryRequest,
80
+ DiscoveryResponse,
81
+ InvokeByIdRequest,
82
+ InvokeRequest,
83
+ MatterNodeData,
84
+ OpenCommissioningWindowRequest,
85
+ OpenCommissioningWindowResponse,
86
+ ReadAttributeRequest,
87
+ ReadAttributeResponse,
88
+ ReadEventRequest,
89
+ ReadEventResponse,
90
+ SubscribeAttributeRequest,
91
+ SubscribeAttributeResponse,
92
+ SubscribeEventRequest,
93
+ SubscribeEventResponse,
94
+ WriteAttributeByIdRequest,
95
+ WriteAttributeRequest,
96
+ } from "../types/CommandHandler.js";
97
+ import {
98
+ AccessControlEntry,
99
+ AccessControlTarget,
100
+ AttributeWriteResult,
101
+ BindingTarget,
102
+ MatterSoftwareVersion,
103
+ NodePingResult,
104
+ UpdateSource,
105
+ } from "../types/WebSocketMessageTypes.js";
106
+ import { pingIp } from "../util/network.js";
107
+ import { Nodes } from "./Nodes.js";
108
+
109
+ const logger = Logger.get("ControllerCommandHandler");
110
+
111
+ export class ControllerCommandHandler {
112
+ #controller: CommissioningController;
113
+ #started = false;
114
+ #connected = false;
115
+ #bleEnabled = false;
116
+ #otaEnabled = false;
117
+ /** Node management and attribute cache */
118
+ #nodes = new Nodes();
119
+ /** Cache of available updates keyed by nodeId */
120
+ #availableUpdates = new Map<NodeId, SoftwareUpdateInfo>();
121
+ events = {
122
+ started: new AsyncObservable(),
123
+ attributeChanged: new Observable<[nodeId: NodeId, data: DecodedAttributeReportValue<any>]>(),
124
+ eventChanged: new Observable<[nodeId: NodeId, data: DecodedEventReportValue<any>]>(),
125
+ nodeAdded: new Observable<[nodeId: NodeId]>(),
126
+ nodeStateChanged: new Observable<[nodeId: NodeId, state: NodeStates]>(),
127
+ nodeStructureChanged: new Observable<[nodeId: NodeId]>(),
128
+ nodeDecommissioned: new Observable<[nodeId: NodeId]>(),
129
+ nodeEndpointAdded: new Observable<[nodeId: NodeId, endpointId: EndpointNumber]>(),
130
+ nodeEndpointRemoved: new Observable<[nodeId: NodeId, endpointId: EndpointNumber]>(),
131
+ };
132
+
133
+ constructor(controllerInstance: CommissioningController, bleEnabled: boolean, otaEnabled: boolean) {
134
+ this.#controller = controllerInstance;
135
+
136
+ this.#bleEnabled = bleEnabled;
137
+ this.#otaEnabled = otaEnabled;
138
+ }
139
+
140
+ get started() {
141
+ return this.#started;
142
+ }
143
+
144
+ get bleEnabled() {
145
+ return this.#bleEnabled;
146
+ }
147
+
148
+ async start() {
149
+ if (this.#started) {
150
+ return;
151
+ }
152
+ this.#started = true;
153
+
154
+ await this.#controller.start();
155
+ logger.info(`Controller started`);
156
+
157
+ if (!this.#bleEnabled) {
158
+ // Subscribe to OTA provider events to track available updates
159
+ await this.#setupOtaEventHandlers();
160
+ }
161
+
162
+ await this.events.started.emit();
163
+ }
164
+
165
+ /**
166
+ * Set up event handlers for OTA update notifications from the SoftwareUpdateManager.
167
+ */
168
+ async #setupOtaEventHandlers() {
169
+ if (!this.#otaEnabled) {
170
+ return;
171
+ }
172
+ try {
173
+ const otaProvider = this.#controller.otaProvider;
174
+ if (!otaProvider) {
175
+ logger.info("OTA provider not available");
176
+ return;
177
+ }
178
+
179
+ // Access the SoftwareUpdateManager behavior events dynamically
180
+ // Using 'any' because SoftwareUpdateManager is not directly exported from @matter/node
181
+ const softwareUpdateManagerEvents = await otaProvider.act(agent => agent.get(SoftwareUpdateManager).events);
182
+ if (softwareUpdateManagerEvents === undefined) {
183
+ logger.info("SoftwareUpdateManager not available");
184
+ return;
185
+ }
186
+
187
+ // Handle updateAvailable events - cache the update info
188
+ softwareUpdateManagerEvents.updateAvailable.on(
189
+ (peerAddress: PeerAddress, updateDetails: SoftwareUpdateInfo) => {
190
+ logger.info(`Update available for node ${peerAddress.nodeId}:`, updateDetails);
191
+ this.#availableUpdates.set(peerAddress.nodeId, updateDetails);
192
+ },
193
+ );
194
+
195
+ // Handle updateDone events - clear the cached update info
196
+ softwareUpdateManagerEvents.updateDone.on((peerAddress: PeerAddress) => {
197
+ logger.info(`Update done for node ${peerAddress.nodeId}`);
198
+ this.#availableUpdates.delete(peerAddress.nodeId);
199
+ });
200
+
201
+ logger.info("OTA event handlers registered");
202
+ } catch (error) {
203
+ logger.warn("Failed to setup OTA event handlers:", error);
204
+ }
205
+ }
206
+
207
+ close() {
208
+ if (!this.#started) return;
209
+ return this.#controller.close();
210
+ }
211
+
212
+ async #registerNode(nodeId: NodeId) {
213
+ const node = await this.#controller.getNode(nodeId);
214
+ const attributeCache = this.#nodes.attributeCache;
215
+
216
+ // Wire all Events to the Event emitters
217
+ node.events.attributeChanged.on(data => {
218
+ // Update the attribute cache with the new value in WebSocket format
219
+ attributeCache.updateAttribute(nodeId, data);
220
+ // Then emit the event for listeners
221
+ this.events.attributeChanged.emit(nodeId, data);
222
+ });
223
+ node.events.eventTriggered.on(data => this.events.eventChanged.emit(nodeId, data));
224
+ node.events.stateChanged.on(state => {
225
+ // Only refresh cache on Connected state (not Reconnecting, WaitingForDiscovery, etc.)
226
+ if (state === NodeStates.Connected) {
227
+ attributeCache.update(node);
228
+ }
229
+ this.events.nodeStateChanged.emit(nodeId, state);
230
+ });
231
+ node.events.structureChanged.on(() => {
232
+ // Structure changed means endpoints may have been added/removed, refresh cache
233
+ if (node.isConnected) {
234
+ attributeCache.update(node);
235
+ }
236
+ this.events.nodeStructureChanged.emit(nodeId);
237
+ });
238
+ node.events.decommissioned.on(() => this.events.nodeDecommissioned.emit(nodeId));
239
+ node.events.nodeEndpointAdded.on(endpointId => this.events.nodeEndpointAdded.emit(nodeId, endpointId));
240
+ node.events.nodeEndpointRemoved.on(endpointId => this.events.nodeEndpointRemoved.emit(nodeId, endpointId));
241
+
242
+ // Store the node for direct access
243
+ this.#nodes.set(nodeId, node);
244
+
245
+ // Initialize attribute cache if node is already initialized
246
+ if (node.initialized) {
247
+ attributeCache.add(node);
248
+ }
249
+
250
+ return node;
251
+ }
252
+
253
+ async connect() {
254
+ if (this.#connected) {
255
+ return;
256
+ }
257
+ this.#connected = true;
258
+
259
+ await this.start();
260
+
261
+ const nodes = this.#controller.getCommissionedNodes();
262
+ logger.info(`Found ${nodes.length} nodes: ${nodes.map(nodeId => nodeId.toString()).join(", ")}`);
263
+
264
+ for (const nodeId of nodes) {
265
+ try {
266
+ logger.info(`Initializing node "${nodeId}" ...`);
267
+ // Initialize Node
268
+ const node = await this.#registerNode(nodeId);
269
+
270
+ // Trigger connect to node, default values are used
271
+ node.connect({
272
+ subscribeMinIntervalFloorSeconds: 1,
273
+ subscribeMaxIntervalCeilingSeconds: undefined,
274
+ });
275
+ } catch (error) {
276
+ logger.warn(`Failed to connect to node "${nodeId}":`, error);
277
+ }
278
+ }
279
+ }
280
+
281
+ getNodeIds() {
282
+ return this.#nodes.getIds();
283
+ }
284
+
285
+ hasNode(nodeId: NodeId): boolean {
286
+ return this.#nodes.has(nodeId);
287
+ }
288
+
289
+ /**
290
+ * Alias for decommissionNode to match NodeCommandHandler interface.
291
+ */
292
+ removeNode(nodeId: NodeId) {
293
+ return this.decommissionNode(nodeId);
294
+ }
295
+
296
+ async interviewNode(nodeId: NodeId) {
297
+ const node = this.#nodes.get(nodeId);
298
+
299
+ // Our nodes are kept up-to-date via attribute subscriptions, so we don't need
300
+ // to re-read all attributes like the Python server does.
301
+ // Just emit a node_updated event with the current (already fresh) data.
302
+ logger.info(`Interview requested for node ${nodeId} - do a complete read`);
303
+
304
+ // Do a full Read of the node
305
+ const read = {
306
+ ...Read({
307
+ fabricFilter: true,
308
+ attributes: [{}],
309
+ }),
310
+ includeKnownVersions: true, // do not send DataVersionFilters, so we do a new clean read
311
+ };
312
+ for await (const _chunk of (node.node.interaction as ClientNodeInteraction).read(read));
313
+
314
+ // Emit node_updated event (same as Python server behavior after the interview)
315
+ this.events.nodeStateChanged.emit(nodeId, node.connectionState);
316
+ }
317
+
318
+ /**
319
+ * Get full node details in WebSocket API format.
320
+ * @param nodeId The node ID
321
+ * @param lastInterviewDate Optional last interview date (tracked externally)
322
+ */
323
+ async getNodeDetails(nodeId: NodeId, lastInterviewDate?: Date): Promise<MatterNodeData> {
324
+ const node = this.#nodes.get(nodeId);
325
+ const attributeCache = this.#nodes.attributeCache;
326
+
327
+ let isBridge = false;
328
+
329
+ // Ensure the cache is populated if node is initialized but cache doesn't exist yet
330
+ if (!attributeCache.has(nodeId)) {
331
+ attributeCache.add(node);
332
+ }
333
+
334
+ // Get cached attributes (empty object if node not yet initialized)
335
+ const attributes = attributeCache.get(nodeId) ?? {};
336
+
337
+ // Bridge detection: Check endpoint 1's Descriptor cluster (29) DeviceTypeList attribute (0)
338
+ // for device type 14 (Aggregator), matching Python Matter Server behavior
339
+ const endpoint1DeviceTypes = attributes["1/29/0"];
340
+ if (Array.isArray(endpoint1DeviceTypes)) {
341
+ isBridge = endpoint1DeviceTypes.some(entry => entry["0"] === 14);
342
+ }
343
+
344
+ // Get commissioned date from node state if available
345
+ const commissionedAt = node.state.commissioning.commissionedAt;
346
+ const dateCommissioned = commissionedAt !== undefined ? new Date(commissionedAt) : new Date();
347
+
348
+ return {
349
+ node_id: node.nodeId,
350
+ date_commissioned: getDateAsString(dateCommissioned),
351
+ last_interview: getDateAsString(lastInterviewDate ?? new Date()),
352
+ interview_version: 6,
353
+ available: node.isConnected,
354
+ is_bridge: isBridge,
355
+ attributes,
356
+ attribute_subscriptions: [],
357
+ };
358
+ }
359
+
360
+ /**
361
+ * Read multiple attributes from a node by path strings.
362
+ * Handles wildcards in paths.
363
+ */
364
+ async handleReadAttributes(
365
+ nodeId: NodeId,
366
+ attributePaths: string[],
367
+ fabricFiltered = false,
368
+ ): Promise<AttributesData> {
369
+ const node = this.#nodes.get(nodeId);
370
+
371
+ const result: AttributesData = {};
372
+
373
+ // Check if any path contains wildcards - if so, we need to collect all attributes from node
374
+ const hasWildcards = attributePaths.some(path => path.includes("*"));
375
+ let allAttributes: AttributesData | undefined;
376
+
377
+ if (hasWildcards) {
378
+ if (!node.initialized) {
379
+ throw new Error(`Node ${nodeId} not ready`);
380
+ }
381
+ const rootEndpoint = node.getRootEndpoint();
382
+ if (rootEndpoint === undefined) {
383
+ throw new Error(`Node ${nodeId} not ready`);
384
+ }
385
+ allAttributes = {};
386
+ this.#collectAttributesFromEndpoint(rootEndpoint, allAttributes);
387
+ }
388
+
389
+ // Process each attribute path
390
+ for (const path of attributePaths) {
391
+ const { endpointId, clusterId, attributeId } = splitAttributePath(path);
392
+
393
+ // For wildcard paths, filter from collected attributes
394
+ if (path.includes("*") && allAttributes !== undefined) {
395
+ for (const [attrPath, value] of Object.entries(allAttributes)) {
396
+ const parts = attrPath.split("/").map(Number);
397
+ if (
398
+ (endpointId === undefined || parts[0] === endpointId) &&
399
+ (clusterId === undefined || parts[1] === clusterId) &&
400
+ (attributeId === undefined || parts[2] === attributeId)
401
+ ) {
402
+ result[attrPath] = value;
403
+ }
404
+ }
405
+ continue;
406
+ }
407
+
408
+ // For non-wildcard paths, use the SDK to read the specific attribute
409
+ const { values, status } = await this.handleReadAttribute({
410
+ nodeId,
411
+ endpointId,
412
+ clusterId,
413
+ attributeId,
414
+ fabricFiltered,
415
+ });
416
+
417
+ if (values.length) {
418
+ for (const valueData of values) {
419
+ const { pathStr, value } = this.#convertAttributeToWebSocket(
420
+ {
421
+ endpointId: EndpointNumber(valueData.endpointId),
422
+ clusterId: ClusterId(valueData.clusterId),
423
+ attributeId: valueData.attributeId,
424
+ },
425
+ valueData.value,
426
+ );
427
+ result[pathStr] = value;
428
+ }
429
+ } else if (status && status.length > 0) {
430
+ logger.warn(`Failed to read attribute ${path}: status=${JSON.stringify(status)}`);
431
+ }
432
+ }
433
+
434
+ return result;
435
+ }
436
+
437
+ /**
438
+ * Collect all attributes from an endpoint and its children into WebSocket format.
439
+ */
440
+ #collectAttributesFromEndpoint(endpoint: Endpoint, attributesData: AttributesData) {
441
+ const endpointId = endpoint.number!;
442
+ for (const behavior of endpoint.endpoint.behaviors.active) {
443
+ if (!ClusterBehavior.is(behavior)) {
444
+ continue;
445
+ }
446
+ const cluster = behavior.cluster;
447
+ const clusterId = cluster.id;
448
+ const clusterData = ClusterMap[cluster.name.toLowerCase()];
449
+ const clusterState = endpoint.endpoint.stateOf(behavior);
450
+
451
+ for (const attributeName in cluster.attributes) {
452
+ const attribute = cluster.attributes[attributeName];
453
+ if (attribute === undefined) {
454
+ continue;
455
+ }
456
+ const attributeValue = (clusterState as Record<string, unknown>)[attributeName];
457
+ const { pathStr, value } = this.#convertAttributeToWebSocket(
458
+ { endpointId, clusterId, attributeId: attribute.id },
459
+ attributeValue,
460
+ clusterData,
461
+ );
462
+ attributesData[pathStr] = value;
463
+ }
464
+ }
465
+
466
+ // Recursively collect from child endpoints
467
+ for (const childEndpoint of endpoint.getChildEndpoints()) {
468
+ this.#collectAttributesFromEndpoint(childEndpoint, attributesData);
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Convert attribute data to WebSocket tag-based format.
474
+ */
475
+ #convertAttributeToWebSocket(
476
+ path: { endpointId: EndpointNumber; clusterId: ClusterId; attributeId: number },
477
+ value: unknown,
478
+ clusterData?: ClusterMapEntry,
479
+ ) {
480
+ const { endpointId, clusterId, attributeId } = path;
481
+ if (!clusterData) {
482
+ const cluster = getClusterById(clusterId);
483
+ clusterData = ClusterMap[cluster.name.toLowerCase()];
484
+ }
485
+ return {
486
+ pathStr: buildAttributePath(endpointId, clusterId, attributeId),
487
+ value: convertMatterToWebSocketTagBased(value, clusterData?.attributes[attributeId], clusterData?.model),
488
+ };
489
+ }
490
+
491
+ /**
492
+ * Set the fabric label. Pass null or empty string to reset to "Home".
493
+ * Note: matter.js requires non-empty labels (1-32 chars), so null/empty resets to default.
494
+ */
495
+ setFabricLabel(label: string | null) {
496
+ const effectiveLabel = label && label.trim() !== "" ? label : "Home";
497
+ return this.#controller.updateFabricLabel(effectiveLabel);
498
+ }
499
+
500
+ disconnectNode(nodeId: NodeId) {
501
+ return this.#controller.disconnectNode(nodeId, true);
502
+ }
503
+
504
+ async handleReadAttribute(data: ReadAttributeRequest): Promise<ReadAttributeResponse> {
505
+ const { nodeId, endpointId, clusterId, attributeId, fabricFiltered = true } = data;
506
+ const client = await this.#nodes.interactionClientFor(nodeId);
507
+
508
+ // Note: This method is for direct SDK reads with explicit paths.
509
+ // Wildcard handling is done at the WebSocket layer before calling this method.
510
+ const { attributeData, attributeStatus } = await client.getMultipleAttributesAndStatus({
511
+ attributes: [{ endpointId, clusterId, attributeId }],
512
+ isFabricFiltered: fabricFiltered,
513
+ });
514
+
515
+ return {
516
+ values: attributeData.map(
517
+ ({ path: { endpointId, clusterId, attributeId }, value, version: dataVersion }) => ({
518
+ attributeId,
519
+ clusterId,
520
+ dataVersion,
521
+ endpointId,
522
+ value,
523
+ }),
524
+ ),
525
+ status: attributeStatus?.map(({ path: { endpointId, clusterId, attributeId }, status, clusterStatus }) => ({
526
+ attributeId,
527
+ clusterId,
528
+ endpointId,
529
+ status,
530
+ clusterStatus,
531
+ })),
532
+ };
533
+ }
534
+
535
+ async handleReadEvent(data: ReadEventRequest): Promise<ReadEventResponse> {
536
+ const { nodeId, endpointId, clusterId, eventId, eventMin } = data;
537
+ const client = await this.#nodes.interactionClientFor(nodeId);
538
+ const { eventData, eventStatus } = await client.getMultipleEventsAndStatus({
539
+ events: [
540
+ {
541
+ endpointId,
542
+ clusterId,
543
+ eventId,
544
+ },
545
+ ],
546
+ eventFilters: eventMin ? [{ eventMin }] : undefined,
547
+ });
548
+
549
+ return {
550
+ values: eventData.flatMap(({ path: { endpointId, clusterId, eventId }, events }) =>
551
+ events.map(({ eventNumber, data }) => ({
552
+ eventId,
553
+ clusterId,
554
+ endpointId,
555
+ eventNumber,
556
+ value: data,
557
+ })),
558
+ ),
559
+ status: eventStatus?.map(({ path: { endpointId, clusterId, eventId }, status, clusterStatus }) => ({
560
+ clusterId,
561
+ endpointId,
562
+ eventId,
563
+ status,
564
+ clusterStatus,
565
+ })),
566
+ };
567
+ }
568
+
569
+ async handleSubscribeAttribute(data: SubscribeAttributeRequest): Promise<SubscribeAttributeResponse> {
570
+ const { nodeId, endpointId, clusterId, attributeId, minInterval, maxInterval, changeListener } = data;
571
+ const client = await this.#nodes.interactionClientFor(nodeId);
572
+ const updated = Observable<[void]>();
573
+ let ignoreData = true; // We ignore data coming in during initial seeding
574
+ const { attributeReports = [] } = await client.subscribeMultipleAttributesAndEvents({
575
+ attributes: [
576
+ {
577
+ endpointId,
578
+ clusterId,
579
+ attributeId,
580
+ },
581
+ ],
582
+ minIntervalFloorSeconds: minInterval,
583
+ maxIntervalCeilingSeconds: maxInterval,
584
+ attributeListener: data => {
585
+ if (ignoreData) return;
586
+ changeListener({
587
+ attributeId: data.path.attributeId,
588
+ clusterId: data.path.clusterId,
589
+ endpointId: data.path.endpointId,
590
+ dataVersion: data.version,
591
+ value: data.value,
592
+ });
593
+ },
594
+ updateReceived: () => {
595
+ updated.emit();
596
+ },
597
+ keepSubscriptions: false,
598
+ });
599
+ ignoreData = false;
600
+
601
+ return {
602
+ values: attributeReports.map(
603
+ ({ path: { endpointId, clusterId, attributeId }, value, version: dataVersion }) => ({
604
+ attributeId,
605
+ clusterId,
606
+ endpointId,
607
+ dataVersion,
608
+ value,
609
+ }),
610
+ ),
611
+ updated,
612
+ };
613
+ }
614
+
615
+ async handleSubscribeEvent(data: SubscribeEventRequest): Promise<SubscribeEventResponse> {
616
+ const { nodeId, endpointId, clusterId, eventId, minInterval, maxInterval, changeListener } = data;
617
+ const client = await this.#nodes.interactionClientFor(nodeId);
618
+ const updated = Observable<[void]>();
619
+ let ignoreData = true; // We ignore data coming in during initial seeding
620
+ const { eventReports = [] } = await client.subscribeMultipleAttributesAndEvents({
621
+ events: [
622
+ {
623
+ endpointId,
624
+ clusterId,
625
+ eventId,
626
+ },
627
+ ],
628
+ minIntervalFloorSeconds: minInterval,
629
+ maxIntervalCeilingSeconds: maxInterval,
630
+ eventListener: data => {
631
+ if (ignoreData) return;
632
+ data.events.forEach(event =>
633
+ changeListener({
634
+ eventId: data.path.eventId,
635
+ clusterId: data.path.clusterId,
636
+ endpointId: data.path.endpointId,
637
+ eventNumber: event.eventNumber,
638
+ value: event.data,
639
+ }),
640
+ );
641
+ },
642
+ updateReceived: () => {
643
+ updated.emit();
644
+ },
645
+ keepSubscriptions: false,
646
+ });
647
+ ignoreData = false;
648
+
649
+ return {
650
+ values: eventReports.flatMap(({ path: { endpointId, clusterId, eventId }, events }) =>
651
+ events.map(({ eventNumber, data }) => ({
652
+ eventId,
653
+ clusterId,
654
+ endpointId,
655
+ eventNumber,
656
+ value: data,
657
+ })),
658
+ ),
659
+ updated,
660
+ };
661
+ }
662
+
663
+ async handleWriteAttribute(data: WriteAttributeRequest): Promise<AttributeResponseStatus> {
664
+ const { nodeId, endpointId, clusterId, attributeId, value } = data;
665
+
666
+ const client = this.#nodes.clusterClientByIdFor(nodeId, endpointId, clusterId);
667
+
668
+ logger.info("Writing attribute", attributeId, "with value", value);
669
+ try {
670
+ await client.attributes[attributeId].set(value);
671
+ return {
672
+ attributeId,
673
+ clusterId,
674
+ endpointId,
675
+ status: 0,
676
+ };
677
+ } catch (error) {
678
+ StatusResponseError.accept(error);
679
+ return {
680
+ attributeId,
681
+ clusterId,
682
+ endpointId,
683
+ status: error.code,
684
+ clusterStatus: error.clusterCode,
685
+ };
686
+ }
687
+ }
688
+
689
+ async handleInvoke(data: InvokeRequest): Promise<any> {
690
+ const {
691
+ nodeId,
692
+ endpointId,
693
+ clusterId,
694
+ commandName,
695
+ timedInteractionTimeoutMs: timedRequestTimeoutMs,
696
+ interactionTimeoutMs,
697
+ } = data;
698
+ let { data: commandData } = data;
699
+
700
+ const client = this.#nodes.clusterClientByIdFor(nodeId, endpointId, clusterId);
701
+
702
+ if (!client[commandName] || !client.isCommandSupportedByName(commandName)) {
703
+ throw new Error("Command not existing");
704
+ }
705
+
706
+ if (isObject(commandData)) {
707
+ if (Object.keys(commandData).length === 0) {
708
+ commandData = undefined;
709
+ } else {
710
+ const cluster = ClusterMap[client.name.toLowerCase()];
711
+ const model = cluster?.commands[commandName.toLowerCase()];
712
+ if (cluster && model) {
713
+ commandData = convertCommandDataToMatter(commandData, model, cluster.model);
714
+ }
715
+ }
716
+ }
717
+ return (client[commandName] as unknown as SignatureFromCommandSpec<Command<any, any, any>>)(commandData, {
718
+ timedRequestTimeout: Millis(timedRequestTimeoutMs),
719
+ expectedProcessingTime: interactionTimeoutMs !== undefined ? Millis(interactionTimeoutMs) : undefined,
720
+ });
721
+ }
722
+
723
+ /** InvokeById minimalistic handler because only used for error testing */
724
+ async handleInvokeById(data: InvokeByIdRequest): Promise<void> {
725
+ const { nodeId, endpointId, clusterId, commandId, data: commandData, timedInteractionTimeoutMs } = data;
726
+ const client = await this.#nodes.interactionClientFor(nodeId);
727
+ await client.invoke<Command<any, any, any>>({
728
+ endpointId,
729
+ clusterId: clusterId,
730
+ command: Command(commandId, TlvAny, 0x00, TlvNoResponse, {
731
+ timed: timedInteractionTimeoutMs !== undefined,
732
+ }),
733
+ request: commandData === undefined ? TlvVoid.encodeTlv() : TlvObject({}).encodeTlv(commandData as any),
734
+ asTimedRequest: timedInteractionTimeoutMs !== undefined,
735
+ timedRequestTimeout: Millis(timedInteractionTimeoutMs),
736
+ skipValidation: true,
737
+ });
738
+ }
739
+
740
+ async handleWriteAttributeById(data: WriteAttributeByIdRequest): Promise<void> {
741
+ const { nodeId, endpointId, clusterId, attributeId, value } = data;
742
+
743
+ const client = await this.#nodes.interactionClientFor(nodeId);
744
+
745
+ logger.info("Writing attribute", attributeId, "with value", value);
746
+
747
+ let tlvValue: any;
748
+
749
+ if (value === null) {
750
+ tlvValue = TlvNullable(TlvBoolean).encodeTlv(value); // Boolean is just a placeholder here
751
+ } else if (value instanceof Uint8Array) {
752
+ tlvValue = TlvByteString.encodeTlv(value);
753
+ } else {
754
+ switch (typeof value) {
755
+ case "boolean":
756
+ tlvValue = TlvBoolean.encodeTlv(value);
757
+ break;
758
+ case "number":
759
+ tlvValue = TlvInt32.encodeTlv(value);
760
+ break;
761
+ case "bigint":
762
+ tlvValue = TlvUInt64.encodeTlv(value);
763
+ break;
764
+ case "string":
765
+ tlvValue = TlvString.encodeTlv(value);
766
+ break;
767
+ default:
768
+ throw new Error("Unsupported value type for Any encoding");
769
+ }
770
+ }
771
+
772
+ await client.setAttribute({
773
+ attributeData: {
774
+ endpointId,
775
+ clusterId,
776
+ attribute: Attribute(attributeId, TlvAny),
777
+ value: tlvValue,
778
+ },
779
+ });
780
+ }
781
+
782
+ #determineCommissionOptions(data: CommissioningRequest): NodeCommissioningOptions {
783
+ let passcode: number | undefined = undefined;
784
+ let shortDiscriminator: number | undefined = undefined;
785
+ let longDiscriminator: number | undefined = undefined;
786
+ let productId: number | undefined = undefined;
787
+ let vendorId: VendorId | undefined = undefined;
788
+ let knownAddress: ServerAddress | undefined = undefined;
789
+
790
+ if ("manualCode" in data && data.manualCode.length > 0) {
791
+ const pairingCodeCodec = ManualPairingCodeCodec.decode(data.manualCode);
792
+ shortDiscriminator = pairingCodeCodec.shortDiscriminator;
793
+ longDiscriminator = undefined;
794
+ passcode = pairingCodeCodec.passcode;
795
+ } else if ("qrCode" in data && data.qrCode.length > 0) {
796
+ const pairingCodeCodec = QrPairingCodeCodec.decode(data.qrCode);
797
+ // TODO handle the case where multiple devices are included
798
+ longDiscriminator = pairingCodeCodec[0].discriminator;
799
+ shortDiscriminator = undefined;
800
+ passcode = pairingCodeCodec[0].passcode;
801
+ } else if ("passcode" in data) {
802
+ passcode = data.passcode;
803
+ // Check for discriminator-based discovery
804
+ if ("shortDiscriminator" in data) {
805
+ shortDiscriminator = data.shortDiscriminator;
806
+ } else if ("longDiscriminator" in data) {
807
+ longDiscriminator = data.longDiscriminator;
808
+ } else if ("vendorId" in data && "productId" in data) {
809
+ vendorId = VendorId(data.vendorId);
810
+ productId = data.productId;
811
+ }
812
+ // If none of the above, will discover any commissionable device
813
+ } else {
814
+ throw new Error("No pairing code provided");
815
+ }
816
+
817
+ if (data.knownAddress !== undefined) {
818
+ const { ip, port } = data.knownAddress;
819
+ knownAddress = {
820
+ type: "udp",
821
+ ip,
822
+ port,
823
+ };
824
+ }
825
+
826
+ if (passcode == undefined) {
827
+ throw new Error("No passcode provided");
828
+ }
829
+
830
+ const { onNetworkOnly } = data;
831
+ return {
832
+ commissioning: {
833
+ nodeId: data.nodeId,
834
+ regulatoryLocation: GeneralCommissioning.RegulatoryLocationType.IndoorOutdoor,
835
+ regulatoryCountryCode: "XX",
836
+ },
837
+ discovery: {
838
+ knownAddress,
839
+ identifierData:
840
+ longDiscriminator !== undefined
841
+ ? { longDiscriminator }
842
+ : shortDiscriminator !== undefined
843
+ ? { shortDiscriminator }
844
+ : vendorId !== undefined
845
+ ? { vendorId, productId }
846
+ : {},
847
+ discoveryCapabilities: {
848
+ ble: this.bleEnabled && !onNetworkOnly,
849
+ onIpNetwork: true,
850
+ },
851
+ },
852
+ passcode,
853
+ };
854
+ }
855
+
856
+ async commissionNode(data: CommissioningRequest): Promise<CommissioningResponse> {
857
+ const nodeId = await this.#controller.commissionNode(this.#determineCommissionOptions(data), {
858
+ connectNodeAfterCommissioning: true,
859
+ });
860
+
861
+ await this.#registerNode(nodeId);
862
+
863
+ this.events.nodeAdded.emit(nodeId);
864
+ return { nodeId };
865
+ }
866
+
867
+ getCommissionerNodeId() {
868
+ return this.#controller.nodeId;
869
+ }
870
+
871
+ async getCommissionerFabricData(): Promise<{ fabricId: FabricId; compressedFabricId: bigint }> {
872
+ const { fabricId, globalId } = this.#controller.fabric;
873
+ return {
874
+ fabricId,
875
+ compressedFabricId: globalId,
876
+ };
877
+ }
878
+
879
+ /** Discover commissionable devices */
880
+ async handleDiscovery({ findBy }: DiscoveryRequest): Promise<DiscoveryResponse> {
881
+ const result = await this.#controller.discoverCommissionableDevices(
882
+ findBy ?? {},
883
+ { onIpNetwork: true },
884
+ undefined,
885
+ Seconds(3), // Just check for 3 sec
886
+ );
887
+ logger.info("Discovered result", result);
888
+ // Chip is not removing old discoveries when being stopped, so we still have old and new devices in the result
889
+ // but the expectation is that it was reset and only new devices are in the result
890
+ const latestDiscovery = result[result.length - 1];
891
+ if (latestDiscovery === undefined) {
892
+ return [];
893
+ }
894
+ return [latestDiscovery].map(({ DT, DN, CM, D, RI, PH, PI, T, VP, deviceIdentifier, addresses, SII, SAI }) => {
895
+ const { tcpClient: supportsTcpClient, tcpServer: supportsTcpServer } = SupportedTransportsSchema.decode(
896
+ T ?? 0,
897
+ );
898
+ const vendorId = VP === undefined ? -1 : VP.includes("+") ? parseInt(VP.split("+")[0]) : parseInt(VP);
899
+ const productId = VP === undefined ? -1 : VP.includes("+") ? parseInt(VP.split("+")[1]) : -1;
900
+ const port = addresses.length ? (addresses[0] as ServerAddressUdp).port : 0;
901
+ const numIPs = addresses.length;
902
+ return {
903
+ commissioningMode: CM,
904
+ deviceName: DN ?? "",
905
+ deviceType: DT ?? 0,
906
+ hostName: "000000000000", // Right now we do not return real hostname, only used internally
907
+ instanceName: deviceIdentifier,
908
+ longDiscriminator: D,
909
+ numIPs,
910
+ pairingHint: PH ?? -1,
911
+ pairingInstruction: PI ?? "",
912
+ port,
913
+ productId,
914
+ rotatingId: RI ?? "",
915
+ rotatingIdLen: RI?.length ?? 0,
916
+ shortDiscriminator: (D >> 8) & 0x0f,
917
+ vendorId,
918
+ supportsTcpServer,
919
+ supportsTcpClient,
920
+ addresses: (addresses.filter(({ type }) => type === "udp") as ServerAddressUdp[]).map(({ ip }) => ip),
921
+ mrpSessionIdleInterval: SII,
922
+ mrpSessionActiveInterval: SAI,
923
+ };
924
+ });
925
+ }
926
+
927
+ async getNodeIpAddresses(nodeId: NodeId, preferCache = true) {
928
+ const node = this.#nodes.get(nodeId);
929
+ const addresses = new Set<string>();
930
+ const generalDiag = node.getRootClusterClient(GeneralDiagnosticsCluster);
931
+ if (generalDiag) {
932
+ try {
933
+ const networkInterfaces = await generalDiag.getNetworkInterfacesAttribute(preferCache ? true : true);
934
+ if (networkInterfaces) {
935
+ const interfaces = networkInterfaces.filter(({ isOperational }) => isOperational);
936
+ if (interfaces.length) {
937
+ logger.info(`Found ${interfaces.length} operational network interfaces`, interfaces);
938
+ interfaces.forEach(({ iPv4Addresses, iPv6Addresses }) => {
939
+ iPv4Addresses.forEach(ip => addresses.add(ipv4BytesToString(Bytes.of(ip))));
940
+ iPv6Addresses.forEach(ip => addresses.add(ipv6BytesToString(Bytes.of(ip))));
941
+ });
942
+ }
943
+ }
944
+ } catch (e) {
945
+ logger.info(`Failed to get network interfaces: ${e}`);
946
+ }
947
+ }
948
+ return Array.from(addresses.values());
949
+ }
950
+
951
+ /**
952
+ * Ping a node on all its known IP addresses.
953
+ * @param nodeId The node ID to ping
954
+ * @param attempts Number of ping attempts per IP (default: 1)
955
+ * @returns A record of IP addresses to ping success status
956
+ */
957
+ async pingNode(nodeId: NodeId, attempts = 1): Promise<NodePingResult> {
958
+ const node = this.#nodes.get(nodeId);
959
+
960
+ const result: NodePingResult = {};
961
+
962
+ // Get all IP addresses for the node (fresh lookup, not cached)
963
+ const ipAddresses = await this.getNodeIpAddresses(nodeId, false);
964
+
965
+ if (ipAddresses.length === 0) {
966
+ logger.info(`No IP addresses found for node ${nodeId}`);
967
+ return result;
968
+ }
969
+
970
+ logger.info(`Pinging node ${nodeId} on ${ipAddresses.length} addresses:`, ipAddresses);
971
+
972
+ // Ping all addresses in parallel
973
+ const pingPromises = ipAddresses.map(async ip => {
974
+ const cleanIp = ip.includes("%") ? ip.split("%")[0] : ip;
975
+ logger.debug(`Pinging ${cleanIp}`);
976
+ const success = await pingIp(ip, 10, attempts);
977
+ result[ip] = success;
978
+ logger.debug(`Ping result for ${cleanIp}: ${success}`);
979
+ });
980
+
981
+ await Promise.all(pingPromises);
982
+
983
+ // If the node is connected, treat the connection as valid
984
+ if (node.isConnected) {
985
+ // Find any successful ping or mark the connection as reachable
986
+ const anySuccess = Object.values(result).some(v => v);
987
+ if (!anySuccess && ipAddresses.length > 0) {
988
+ // Node is connected, but no pings succeeded - this can happen
989
+ // with Thread devices or certain network configurations
990
+ logger.info(`Node ${nodeId} is connected but no pings succeeded`);
991
+ }
992
+ }
993
+
994
+ return result;
995
+ }
996
+
997
+ async decommissionNode(nodeId: NodeId) {
998
+ const node = this.#nodes.has(nodeId) ? this.#nodes.get(nodeId) : undefined;
999
+ await this.#controller.removeNode(nodeId, !!node?.isConnected);
1000
+ // Remove node from storage (also clears attribute cache)
1001
+ this.#nodes.delete(nodeId);
1002
+ // Emit nodeDecommissioned event after successful removal
1003
+ this.events.nodeDecommissioned.emit(nodeId);
1004
+ }
1005
+
1006
+ async openCommissioningWindow(data: OpenCommissioningWindowRequest): Promise<OpenCommissioningWindowResponse> {
1007
+ const { nodeId, timeout } = data;
1008
+ const node = this.#nodes.get(nodeId);
1009
+ const { manualPairingCode, qrPairingCode } = await node.openEnhancedCommissioningWindow(timeout);
1010
+ return { manualCode: manualPairingCode, qrCode: qrPairingCode };
1011
+ }
1012
+
1013
+ async getFabrics(nodeId: NodeId) {
1014
+ const client = this.#nodes.clusterClientFor(nodeId, EndpointNumber(0), OperationalCredentials.Cluster);
1015
+
1016
+ return (await client.getFabricsAttribute(true, false)).map(({ fabricId, fabricIndex, vendorId, label }) => ({
1017
+ fabricId,
1018
+ vendorId,
1019
+ fabricIndex,
1020
+ label,
1021
+ }));
1022
+ }
1023
+
1024
+ removeFabric(nodeId: NodeId, fabricIndex: FabricIndex) {
1025
+ const client = this.#nodes.clusterClientFor(nodeId, EndpointNumber(0), OperationalCredentials.Cluster);
1026
+
1027
+ return client.removeFabric({ fabricIndex });
1028
+ }
1029
+
1030
+ /**
1031
+ * Set Access Control List entries on a node.
1032
+ * Writes to the ACL attribute on the AccessControl cluster (endpoint 0).
1033
+ */
1034
+ async setAclEntry(nodeId: NodeId, entries: AccessControlEntry[]): Promise<AttributeWriteResult[] | null> {
1035
+ const client = this.#nodes.clusterClientFor(nodeId, EndpointNumber(0), AccessControl.Cluster);
1036
+
1037
+ // Convert from WebSocket format (snake_case) to Matter.js format (camelCase)
1038
+ const aclEntries: AccessControl.AccessControlEntry[] = entries.map(entry => ({
1039
+ privilege: entry.privilege as AccessControl.AccessControlEntryPrivilege,
1040
+ authMode: entry.auth_mode as AccessControl.AccessControlEntryAuthMode,
1041
+ subjects: entry.subjects?.map(s => NodeId(BigInt(s))) ?? null,
1042
+ targets:
1043
+ entry.targets?.map((t: AccessControlTarget) => ({
1044
+ cluster: t.cluster !== null ? ClusterId(t.cluster) : null,
1045
+ endpoint: t.endpoint !== null ? EndpointNumber(t.endpoint) : null,
1046
+ deviceType: t.device_type !== null ? DeviceTypeId(t.device_type) : null,
1047
+ })) ?? null,
1048
+ fabricIndex: FabricIndex.OMIT_FABRIC,
1049
+ }));
1050
+
1051
+ logger.info("Setting ACL entries", aclEntries);
1052
+
1053
+ try {
1054
+ await client.setAclAttribute(aclEntries);
1055
+ return [
1056
+ {
1057
+ path: {
1058
+ endpoint_id: 0,
1059
+ cluster_id: AccessControl.Cluster.id,
1060
+ attribute_id: 0, // ACL attribute ID
1061
+ },
1062
+ status: 0,
1063
+ },
1064
+ ];
1065
+ } catch (error) {
1066
+ StatusResponseError.accept(error);
1067
+ return [
1068
+ {
1069
+ path: {
1070
+ endpoint_id: 0,
1071
+ cluster_id: AccessControl.Cluster.id,
1072
+ attribute_id: 0,
1073
+ },
1074
+ status: error.code,
1075
+ },
1076
+ ];
1077
+ }
1078
+ }
1079
+
1080
+ /**
1081
+ * Set bindings on a specific endpoint of a node.
1082
+ * Writes to the Binding attribute on the Binding cluster.
1083
+ */
1084
+ async setNodeBinding(
1085
+ nodeId: NodeId,
1086
+ endpointId: EndpointNumber,
1087
+ bindings: BindingTarget[],
1088
+ ): Promise<AttributeWriteResult[] | null> {
1089
+ const client = this.#nodes.clusterClientFor(nodeId, endpointId, Binding.Cluster);
1090
+
1091
+ // Convert from WebSocket format to Matter.js format
1092
+ const bindingEntries: Binding.Target[] = bindings.map(binding => ({
1093
+ node: binding.node !== null ? NodeId(binding.node) : undefined,
1094
+ group: binding.group !== null ? GroupId(binding.group) : undefined,
1095
+ endpoint: binding.endpoint !== null ? EndpointNumber(binding.endpoint) : undefined,
1096
+ cluster: binding.cluster !== null ? ClusterId(binding.cluster) : undefined,
1097
+ fabricIndex: FabricIndex.OMIT_FABRIC,
1098
+ }));
1099
+
1100
+ logger.info("Setting bindings on endpoint", endpointId, bindingEntries);
1101
+
1102
+ try {
1103
+ await client.attributes.binding.set(bindingEntries);
1104
+ return [
1105
+ {
1106
+ path: {
1107
+ endpoint_id: endpointId,
1108
+ cluster_id: Binding.Cluster.id,
1109
+ attribute_id: 0, // Binding attribute ID
1110
+ },
1111
+ status: 0,
1112
+ },
1113
+ ];
1114
+ } catch (error) {
1115
+ StatusResponseError.accept(error);
1116
+ return [
1117
+ {
1118
+ path: {
1119
+ endpoint_id: endpointId,
1120
+ cluster_id: Binding.Cluster.id,
1121
+ attribute_id: 0,
1122
+ },
1123
+ status: error.code,
1124
+ },
1125
+ ];
1126
+ }
1127
+ }
1128
+
1129
+ /**
1130
+ * Check if a software update is available for a node.
1131
+ * First checks the cached updates from OTA events, then queries the DCL if not found.
1132
+ */
1133
+ async checkNodeUpdate(nodeId: NodeId): Promise<MatterSoftwareVersion | null> {
1134
+ if (!this.#otaEnabled) {
1135
+ throw new Error("OTA is disabled.");
1136
+ }
1137
+ // First check if we have a cached update from the updateAvailable event
1138
+ const cachedUpdate = this.#availableUpdates.get(nodeId);
1139
+ if (cachedUpdate) {
1140
+ return this.#convertToMatterSoftwareVersion(cachedUpdate);
1141
+ }
1142
+
1143
+ // No cached update, query the OTA provider
1144
+ const node = this.#nodes.get(nodeId);
1145
+
1146
+ try {
1147
+ const otaProvider = this.#controller.otaProvider;
1148
+ if (!otaProvider) {
1149
+ logger.info("OTA provider not available");
1150
+ return null;
1151
+ }
1152
+
1153
+ // Query OTA provider for updates using dynamic behavior access
1154
+ const updatesAvailable = await otaProvider.act(agent =>
1155
+ agent.get(SoftwareUpdateManager).queryUpdates({
1156
+ peerToCheck: node.node,
1157
+ includeStoredUpdates: true,
1158
+ }),
1159
+ );
1160
+
1161
+ // Find update for this specific node
1162
+ const peerAddress = this.#controller.fabric.addressOf(nodeId);
1163
+ const nodeUpdate = updatesAvailable.find(({ peerAddress: updateAddress }) =>
1164
+ PeerAddress.is(peerAddress, updateAddress),
1165
+ );
1166
+
1167
+ if (nodeUpdate) {
1168
+ const { info } = nodeUpdate;
1169
+ this.#availableUpdates.set(nodeId, info);
1170
+ return this.#convertToMatterSoftwareVersion(info);
1171
+ }
1172
+
1173
+ return null;
1174
+ } catch (error) {
1175
+ logger.warn(`Failed to check for updates for node ${nodeId}:`, error);
1176
+ return null;
1177
+ }
1178
+ }
1179
+
1180
+ /**
1181
+ * Trigger a software update for a node.
1182
+ * @param nodeId The node to update
1183
+ * @param softwareVersion The target software version to update to
1184
+ */
1185
+ async updateNode(nodeId: NodeId, softwareVersion: number): Promise<MatterSoftwareVersion | null> {
1186
+ if (!this.#otaEnabled) {
1187
+ throw new Error("OTA is disabled.");
1188
+ }
1189
+ if (!this.#nodes.has(nodeId)) {
1190
+ throw new Error(`Node ${nodeId} not found`);
1191
+ }
1192
+
1193
+ try {
1194
+ const otaProvider = this.#controller.otaProvider;
1195
+ if (!otaProvider) {
1196
+ throw new Error("OTA provider not available");
1197
+ }
1198
+
1199
+ // Get the cached update info or query for it
1200
+ let updateInfo = this.#availableUpdates.get(nodeId);
1201
+ if (!updateInfo) {
1202
+ // Try to get update info by querying
1203
+ const result = await this.checkNodeUpdate(nodeId);
1204
+ if (!result) {
1205
+ throw new Error("No update available for this node");
1206
+ }
1207
+ updateInfo = this.#availableUpdates.get(nodeId);
1208
+ if (!updateInfo) {
1209
+ throw new Error("Failed to get update info");
1210
+ }
1211
+ }
1212
+
1213
+ logger.info(`Starting update for node ${nodeId} to version ${softwareVersion}`);
1214
+
1215
+ // Trigger the update using forceUpdate via dynamic behavior access
1216
+ await otaProvider.act(agent =>
1217
+ agent
1218
+ .get(SoftwareUpdateManager)
1219
+ .forceUpdate(
1220
+ this.#controller.fabric.addressOf(nodeId),
1221
+ updateInfo.vendorId,
1222
+ updateInfo.productId,
1223
+ softwareVersion,
1224
+ ),
1225
+ );
1226
+
1227
+ // Return the update info
1228
+ return this.#convertToMatterSoftwareVersion(updateInfo);
1229
+ } catch (error) {
1230
+ logger.error(`Failed to update node ${nodeId}:`, error);
1231
+ throw error;
1232
+ }
1233
+ }
1234
+
1235
+ /**
1236
+ * Convert SoftwareUpdateInfo to MatterSoftwareVersion format for WebSocket API.
1237
+ */
1238
+ #convertToMatterSoftwareVersion(updateInfo: SoftwareUpdateInfo): MatterSoftwareVersion {
1239
+ const { vendorId, productId, softwareVersion, softwareVersionString, releaseNotesUrl, source } = updateInfo;
1240
+ return {
1241
+ vid: vendorId,
1242
+ pid: productId,
1243
+ software_version: softwareVersion,
1244
+ software_version_string: softwareVersionString,
1245
+ min_applicable_software_version: 0, // Not available from SoftwareUpdateInfo
1246
+ max_applicable_software_version: softwareVersion - 1,
1247
+ release_notes_url: releaseNotesUrl,
1248
+ update_source:
1249
+ source === "dcl-prod"
1250
+ ? UpdateSource.MAIN_NET_DCL
1251
+ : source === "dcl-test"
1252
+ ? UpdateSource.TEST_NET_DCL
1253
+ : UpdateSource.LOCAL,
1254
+ };
1255
+ }
1256
+ }