@matter-server/ws-controller 0.6.2 → 0.6.3

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 (30) hide show
  1. package/dist/esm/controller/BorderRouterDiscovery.d.ts +66 -0
  2. package/dist/esm/controller/BorderRouterDiscovery.d.ts.map +1 -0
  3. package/dist/esm/controller/BorderRouterDiscovery.js +450 -0
  4. package/dist/esm/controller/BorderRouterDiscovery.js.map +6 -0
  5. package/dist/esm/controller/ControllerCommandHandler.d.ts +0 -1
  6. package/dist/esm/controller/ControllerCommandHandler.d.ts.map +1 -1
  7. package/dist/esm/controller/ControllerCommandHandler.js +30 -35
  8. package/dist/esm/controller/ControllerCommandHandler.js.map +1 -1
  9. package/dist/esm/controller/CustomClusterPoller.d.ts +5 -10
  10. package/dist/esm/controller/CustomClusterPoller.d.ts.map +1 -1
  11. package/dist/esm/controller/CustomClusterPoller.js +19 -17
  12. package/dist/esm/controller/CustomClusterPoller.js.map +1 -1
  13. package/dist/esm/controller/MatterController.d.ts +2 -0
  14. package/dist/esm/controller/MatterController.d.ts.map +1 -1
  15. package/dist/esm/controller/MatterController.js +8 -0
  16. package/dist/esm/controller/MatterController.js.map +1 -1
  17. package/dist/esm/server/WebSocketControllerHandler.d.ts.map +1 -1
  18. package/dist/esm/server/WebSocketControllerHandler.js +3 -0
  19. package/dist/esm/server/WebSocketControllerHandler.js.map +1 -1
  20. package/dist/esm/util/formatNodeId.d.ts +3 -3
  21. package/dist/esm/util/formatNodeId.d.ts.map +1 -1
  22. package/dist/esm/util/formatNodeId.js +3 -2
  23. package/dist/esm/util/formatNodeId.js.map +1 -1
  24. package/package.json +5 -5
  25. package/src/controller/BorderRouterDiscovery.ts +613 -0
  26. package/src/controller/ControllerCommandHandler.ts +32 -37
  27. package/src/controller/CustomClusterPoller.ts +26 -20
  28. package/src/controller/MatterController.ts +10 -0
  29. package/src/server/WebSocketControllerHandler.ts +3 -0
  30. package/src/util/formatNodeId.ts +7 -5
@@ -110,10 +110,7 @@ const RECONNECT_TIMEOUT = Minutes(3);
110
110
  * BasicInformation (0x28) covers firmware version, product name, etc.
111
111
  * BridgedDeviceBasicInformation (0x39) covers the same for bridged child nodes.
112
112
  */
113
- const FULL_UPDATE_CLUSTER_IDS = new Set<ClusterId>([
114
- BasicInformation.Cluster.id,
115
- BridgedDeviceBasicInformation.Cluster.id,
116
- ]);
113
+ const FULL_UPDATE_CLUSTER_IDS = new Set<ClusterId>([BasicInformation.id, BridgedDeviceBasicInformation.id]);
117
114
 
118
115
  /**
119
116
  * Determine the Matter specification version from cached attributes.
@@ -151,8 +148,8 @@ export class ControllerCommandHandler {
151
148
  #controller: CommissioningController;
152
149
  #started = false;
153
150
  #connected = false;
154
- #bleEnabled = false;
155
- #otaEnabled = false;
151
+ readonly #bleEnabled: boolean;
152
+ readonly #otaEnabled: boolean;
156
153
  /** Node management and attribute cache */
157
154
  #nodes = new Nodes();
158
155
  /** Cache of available updates keyed by nodeId */
@@ -188,12 +185,27 @@ export class ControllerCommandHandler {
188
185
  // Initialize custom cluster poller for Eve energy attributes etc.
189
186
  // Reads automatically trigger change events through the normal attribute flow
190
187
  this.#customClusterPoller = new CustomClusterPoller({
191
- nodeConnected: nodeId => !!(this.#nodes.has(nodeId) && this.#nodes.get(nodeId).isConnected),
192
- handleReadAttributes: (nodeId, paths, fabricFiltered) =>
193
- this.handleReadAttributes(nodeId, paths, fabricFiltered),
188
+ nodeConnected: peer => !!(this.#nodes.has(peer.nodeId) && this.#nodes.get(peer.nodeId).isConnected),
189
+ handleReadAttributes: (peer, paths, fabricFiltered) =>
190
+ this.handleReadAttributes(peer.nodeId, paths, fabricFiltered),
194
191
  });
195
192
  }
196
193
 
194
+ /**
195
+ * Build the canonical PeerAddress for the given node on this controller's fabric.
196
+ *
197
+ * Throws if the controller's fabric is not yet resolved. Callers must run after
198
+ * controller start; a silent fallback would intern PeerAddressMap entries under the
199
+ * wrong fabric index and leak poller registrations.
200
+ */
201
+ #peerOf(nodeId: NodeId): PeerAddress {
202
+ const fabric = this.#controller.fabric;
203
+ if (fabric === undefined) {
204
+ throw new Error(`Cannot resolve PeerAddress for node ${nodeId}: controller fabric is not initialized`);
205
+ }
206
+ return PeerAddress({ fabricIndex: fabric.fabricIndex, nodeId });
207
+ }
208
+
197
209
  /**
198
210
  * Format a NodeId as a PeerAddress string for logging.
199
211
  * Uses the controller's fabric index when available, otherwise "?" is used.
@@ -254,14 +266,14 @@ export class ControllerCommandHandler {
254
266
  // Handle updateAvailable events - cache the update info
255
267
  softwareUpdateManagerEvents.updateAvailable.on(
256
268
  (peerAddress: PeerAddress, updateDetails: SoftwareUpdateInfo) => {
257
- logger.info(`Update available for node ${peerAddress.nodeId}:`, updateDetails);
269
+ logger.info(`Update available for node ${this.formatNode(peerAddress.nodeId)}:`, updateDetails);
258
270
  this.#availableUpdates.set(peerAddress.nodeId, updateDetails);
259
271
  },
260
272
  );
261
273
 
262
274
  // Handle updateDone events - clear the cached update info
263
275
  softwareUpdateManagerEvents.updateDone.on((peerAddress: PeerAddress) => {
264
- logger.info(`Update done for node ${peerAddress.nodeId}`);
276
+ logger.info(`Update done for node ${this.formatNode(peerAddress.nodeId)}`);
265
277
  this.#availableUpdates.delete(peerAddress.nodeId);
266
278
  });
267
279
 
@@ -315,7 +327,7 @@ export class ControllerCommandHandler {
315
327
  attributeCache.update(node);
316
328
  const attributes = attributeCache.get(nodeId);
317
329
  if (attributes) {
318
- this.#customClusterPoller.registerNode(nodeId, attributes);
330
+ this.#customClusterPoller.registerNode(this.#peerOf(nodeId), attributes);
319
331
  }
320
332
  }
321
333
 
@@ -381,7 +393,7 @@ export class ControllerCommandHandler {
381
393
  // Register for custom cluster polling (e.g., Eve energy)
382
394
  const attributes = attributeCache.get(nodeId);
383
395
  if (attributes) {
384
- this.#customClusterPoller.registerNode(nodeId, attributes);
396
+ this.#customClusterPoller.registerNode(this.#peerOf(nodeId), attributes);
385
397
  }
386
398
  }
387
399
 
@@ -615,10 +627,6 @@ export class ControllerCommandHandler {
615
627
  await this.#controller.updateFabricLabel(label);
616
628
  }
617
629
 
618
- disconnectNode(nodeId: NodeId) {
619
- return this.#controller.disconnectNode(nodeId, true);
620
- }
621
-
622
630
  async handleWriteAttribute(data: WriteAttributeRequest): Promise<AttributeResponseStatus> {
623
631
  const { nodeId, endpointId, clusterId, attributeId } = data;
624
632
  let { value } = data;
@@ -792,7 +800,7 @@ export class ControllerCommandHandler {
792
800
  }
793
801
 
794
802
  const { onNetworkOnly, wifiCredentials: wifiNetwork, threadCredentials: threadNetwork } = data;
795
- const options = {
803
+ return {
796
804
  commissioning: {
797
805
  nodeId: data.nodeId,
798
806
  regulatoryLocation: GeneralCommissioning.RegulatoryLocationType.IndoorOutdoor,
@@ -817,7 +825,6 @@ export class ControllerCommandHandler {
817
825
  },
818
826
  passcode,
819
827
  };
820
- return options;
821
828
  }
822
829
 
823
830
  async commissionNode(data: CommissioningRequest): Promise<CommissioningResponse> {
@@ -1040,7 +1047,7 @@ export class ControllerCommandHandler {
1040
1047
  this.#reconnectTimers.get(nodeId)?.stop();
1041
1048
  this.#reconnectTimers.delete(nodeId);
1042
1049
  this.#nodes.delete(nodeId);
1043
- this.#customClusterPoller.unregisterNode(nodeId);
1050
+ this.#customClusterPoller.unregisterNode(this.#peerOf(nodeId));
1044
1051
  this.#availableUpdates.delete(nodeId);
1045
1052
  }
1046
1053
 
@@ -1061,7 +1068,7 @@ export class ControllerCommandHandler {
1061
1068
  },
1062
1069
  Read.Attribute({
1063
1070
  endpoint: EndpointNumber(0),
1064
- cluster: OperationalCredentials.Complete,
1071
+ cluster: OperationalCredentials,
1065
1072
  attributes: "fabrics",
1066
1073
  }),
1067
1074
  ),
@@ -1110,16 +1117,10 @@ export class ControllerCommandHandler {
1110
1117
 
1111
1118
  logger.info("Setting ACL entries", aclEntries);
1112
1119
 
1113
- const { status } = await this.#writeAttribute(
1114
- nodeId,
1115
- EndpointNumber(0),
1116
- AccessControl.Cluster.id,
1117
- "acl",
1118
- aclEntries,
1119
- );
1120
+ const { status } = await this.#writeAttribute(nodeId, EndpointNumber(0), AccessControl.id, "acl", aclEntries);
1120
1121
  return [
1121
1122
  {
1122
- path: { endpoint_id: 0, cluster_id: AccessControl.Cluster.id, attribute_id: 0 },
1123
+ path: { endpoint_id: 0, cluster_id: AccessControl.id, attribute_id: 0 },
1123
1124
  status,
1124
1125
  },
1125
1126
  ];
@@ -1144,16 +1145,10 @@ export class ControllerCommandHandler {
1144
1145
 
1145
1146
  logger.info("Setting bindings on endpoint", endpointId, bindingEntries);
1146
1147
 
1147
- const { status } = await this.#writeAttribute(
1148
- nodeId,
1149
- endpointId,
1150
- Binding.Cluster.id,
1151
- "binding",
1152
- bindingEntries,
1153
- );
1148
+ const { status } = await this.#writeAttribute(nodeId, endpointId, Binding.id, "binding", bindingEntries);
1154
1149
  return [
1155
1150
  {
1156
- path: { endpoint_id: endpointId, cluster_id: Binding.Cluster.id, attribute_id: 0 },
1151
+ path: { endpoint_id: endpointId, cluster_id: Binding.id, attribute_id: 0 },
1157
1152
  status,
1158
1153
  },
1159
1154
  ];
@@ -10,8 +10,10 @@
10
10
  * a custom cluster without standard Matter subscription support.
11
11
  */
12
12
 
13
- import { CancelablePromise, Duration, Logger, Millis, NodeId, Time, Timer } from "@matter/main";
13
+ import { CancelablePromise, Duration, Logger, Millis, Time, Timer } from "@matter/main";
14
+ import { PeerAddress, PeerAddressMap } from "@matter/main/protocol";
14
15
  import { AttributesData } from "../types/CommandHandler.js";
16
+ import { formatNodeId } from "../util/formatNodeId.js";
15
17
 
16
18
  const logger = Logger.get("CustomClusterPoller");
17
19
 
@@ -42,8 +44,12 @@ const MAX_INITIAL_DELAY_MS = 30_000;
42
44
  type AttributePath = string;
43
45
 
44
46
  export interface NodeAttributeReader {
45
- handleReadAttributes(nodeId: NodeId, attributePaths: string[], fabricFiltered?: boolean): Promise<AttributesData>;
46
- nodeConnected(nodeId: NodeId): boolean;
47
+ handleReadAttributes(
48
+ peer: PeerAddress,
49
+ attributePaths: string[],
50
+ fabricFiltered?: boolean,
51
+ ): Promise<AttributesData>;
52
+ nodeConnected(peer: PeerAddress): boolean;
47
53
  }
48
54
 
49
55
  /**
@@ -112,7 +118,7 @@ export function checkPolledAttributes(attributes: AttributesData): Set<Attribute
112
118
  * Manages polling of custom cluster attributes for multiple nodes.
113
119
  */
114
120
  export class CustomClusterPoller {
115
- #polledAttributes = new Map<NodeId, Set<AttributePath>>();
121
+ #polledAttributes = new PeerAddressMap<Set<AttributePath>>();
116
122
  #pollerTimer: Timer;
117
123
  #attributeReader: NodeAttributeReader;
118
124
  #isPolling = false;
@@ -130,18 +136,18 @@ export class CustomClusterPoller {
130
136
  * Register a node for polling if it has custom attributes that need polling.
131
137
  * Call this after a node is connected and its attributes are available.
132
138
  */
133
- registerNode(nodeId: NodeId, attributes: AttributesData): void {
139
+ registerNode(peer: PeerAddress, attributes: AttributesData): void {
134
140
  const attributesToPoll = checkPolledAttributes(attributes);
135
141
 
136
142
  if (attributesToPoll.size === 0) {
137
143
  // Remove from polling if it was previously registered
138
- this.unregisterNode(nodeId);
144
+ this.unregisterNode(peer);
139
145
  return;
140
146
  }
141
147
 
142
- this.#polledAttributes.set(nodeId, attributesToPoll);
148
+ this.#polledAttributes.set(peer, attributesToPoll);
143
149
  logger.info(
144
- `Registered node ${nodeId} for custom attribute polling: ${Array.from(attributesToPoll).join(", ")}`,
150
+ `Registered node ${formatNodeId(peer)} for custom attribute polling: ${Array.from(attributesToPoll).join(", ")}`,
145
151
  );
146
152
 
147
153
  // Start the poller if not already running
@@ -151,9 +157,9 @@ export class CustomClusterPoller {
151
157
  /**
152
158
  * Unregister a node from polling (e.g., when decommissioned or disconnected).
153
159
  */
154
- unregisterNode(nodeId: NodeId): void {
155
- if (this.#polledAttributes.delete(nodeId)) {
156
- logger.info(`Unregistered node ${nodeId} from custom attribute polling`);
160
+ unregisterNode(peer: PeerAddress): void {
161
+ if (this.#polledAttributes.delete(peer)) {
162
+ logger.info(`Unregistered node ${formatNodeId(peer)} from custom attribute polling`);
157
163
  }
158
164
  if (this.#polledAttributes.size === 0) {
159
165
  this.#pollerTimer.stop();
@@ -217,13 +223,13 @@ export class CustomClusterPoller {
217
223
  if (this.#closed) {
218
224
  break;
219
225
  }
220
- const [nodeId, attributePaths] = entries[i];
221
- if (!this.#polledAttributes.has(nodeId)) {
226
+ const [peer, attributePaths] = entries[i];
227
+ if (!this.#polledAttributes.has(peer)) {
222
228
  // Node was removed, so skip it
223
229
  continue;
224
230
  }
225
231
  polledNodes++;
226
- await this.#pollNode(nodeId, attributePaths);
232
+ await this.#pollNode(peer, attributePaths);
227
233
  // Small delay between nodes to avoid overwhelming the network
228
234
  // Only add this delay if there are more nodes remaining to be polled
229
235
  if (i < entries.length - 1) {
@@ -249,25 +255,25 @@ export class CustomClusterPoller {
249
255
  * Poll a single node for its custom attributes.
250
256
  * The read will automatically trigger change events through the normal attribute flow.
251
257
  */
252
- async #pollNode(nodeId: NodeId, attributePaths: Set<AttributePath>): Promise<void> {
253
- if (!this.#attributeReader.nodeConnected(nodeId)) {
254
- logger.debug(`Node ${nodeId} not connected, skipping custom attribute polling`);
258
+ async #pollNode(peer: PeerAddress, attributePaths: Set<AttributePath>): Promise<void> {
259
+ if (!this.#attributeReader.nodeConnected(peer)) {
260
+ logger.debug(`Node ${formatNodeId(peer)} not connected, skipping custom attribute polling`);
255
261
  return;
256
262
  }
257
263
  const paths = Array.from(attributePaths);
258
- logger.debug(`Polling ${paths.length} custom attributes for node ${nodeId}`);
264
+ logger.debug(`Polling ${paths.length} custom attributes for node ${formatNodeId(peer)}`);
259
265
 
260
266
  try {
261
267
  // Read with fabricFiltered=true as per Eve's requirements
262
268
  // This automatically updates the attribute cache and triggers change events
263
- const readPromise = this.#attributeReader.handleReadAttributes(nodeId, paths, true);
269
+ const readPromise = this.#attributeReader.handleReadAttributes(peer, paths, true);
264
270
  this.#currentReadPromise = readPromise.then(
265
271
  () => {},
266
272
  () => {},
267
273
  );
268
274
  await readPromise;
269
275
  } catch (error) {
270
- logger.warn(`Failed to poll custom attributes for node ${nodeId}: `, error);
276
+ logger.warn(`Failed to poll custom attributes for node ${formatNodeId(peer)}: `, error);
271
277
  } finally {
272
278
  this.#currentReadPromise = undefined;
273
279
  }
@@ -23,6 +23,7 @@ import { VendorId } from "@matter/main/types";
23
23
  import { CommissioningController } from "@project-chip/matter.js";
24
24
  import { Readable } from "node:stream";
25
25
  import { ConfigStorage } from "../server/ConfigStorage.js";
26
+ import { BorderRouterDiscovery } from "./BorderRouterDiscovery.js";
26
27
  import { ControllerCommandHandler } from "./ControllerCommandHandler.js";
27
28
  import { LegacyDataInjector, LegacyServerData } from "./LegacyDataInjector.js";
28
29
  import { resolveServerId } from "./ServerIdResolver.js";
@@ -75,6 +76,7 @@ export class MatterController {
75
76
  #legacyCommissionedDates?: Map<string, Timestamp>;
76
77
  #enableTestNetDcl = false;
77
78
  #disableOtaProvider = true;
79
+ readonly #borderRouterDiscovery: BorderRouterDiscovery;
78
80
 
79
81
  static async create(
80
82
  environment: Environment,
@@ -144,6 +146,7 @@ export class MatterController {
144
146
 
145
147
  constructor(environment: Environment, config: ConfigStorage, options: MatterControllerOptions, serverId: string) {
146
148
  this.#env = environment;
149
+ this.#borderRouterDiscovery = new BorderRouterDiscovery(this.#env);
147
150
  this.#config = config;
148
151
  this.#serverId = serverId;
149
152
  this.#serverVersion = options.serverVersion ?? "0.0.0";
@@ -210,6 +213,8 @@ export class MatterController {
210
213
  initPromises.push(this.#enableTestOtaImages());
211
214
  }
212
215
 
216
+ initPromises.push(this.#borderRouterDiscovery.start());
217
+
213
218
  try {
214
219
  await MatterAggregateError.allSettled(initPromises);
215
220
  } catch (error) {
@@ -221,6 +226,10 @@ export class MatterController {
221
226
  return this.#commandHandler;
222
227
  }
223
228
 
229
+ get borderRouters(): BorderRouterDiscovery {
230
+ return this.#borderRouterDiscovery;
231
+ }
232
+
224
233
  /**
225
234
  * Get the DCL vendor info service instance.
226
235
  * Lazily initializes the service if not already present.
@@ -294,6 +303,7 @@ export class MatterController {
294
303
  }
295
304
 
296
305
  async stop() {
306
+ await this.#borderRouterDiscovery.stop();
297
307
  await this.#commandHandler?.close(); // This closes also the controller instance if started
298
308
  }
299
309
 
@@ -470,6 +470,9 @@ export class WebSocketControllerHandler implements WebServerHandler {
470
470
  case "set_thread_dataset":
471
471
  result = await this.#handleSetThreadDataset(args);
472
472
  break;
473
+ case "get_thread_border_routers":
474
+ result = this.#controller.borderRouters.list();
475
+ break;
473
476
  case "open_commissioning_window":
474
477
  result = await this.#handleOpenCommissioningWindow(args);
475
478
  break;
@@ -5,16 +5,18 @@
5
5
  */
6
6
 
7
7
  import { FabricIndex, NodeId } from "@matter/main";
8
+ import { PeerAddress } from "@matter/main/protocol";
8
9
 
9
10
  /**
10
- * Format a NodeId as a PeerAddress string for logging.
11
+ * Format a NodeId or PeerAddress as a string for logging.
11
12
  * Uses the Matter address format: @fabricIndexDecimal:nodeIdHex (e.g., "@1:a", "@10:1f").
12
13
  *
13
- * @param nodeId The node ID to format (rendered in hexadecimal)
14
- * @param fabricIndex The fabric index (rendered in decimal). If omitted or unknown, "?" is used.
15
14
  * @returns Formatted PeerAddress string like "@1:a", "@10:1f", or "@?:1f" when fabric index is unknown.
16
15
  */
17
- export function formatNodeId(nodeId: NodeId, fabricIndex?: FabricIndex): string {
18
- const fabricIndexPart = fabricIndex === undefined ? "?" : fabricIndex.toString(10);
16
+ export function formatNodeId(peer: PeerAddress): string;
17
+ export function formatNodeId(nodeId: NodeId, fabricIndex?: FabricIndex): string;
18
+ export function formatNodeId(arg: NodeId | PeerAddress, fabricIndex?: FabricIndex): string {
19
+ const [nodeId, resolvedFabricIndex] = typeof arg === "object" ? [arg.nodeId, arg.fabricIndex] : [arg, fabricIndex];
20
+ const fabricIndexPart = resolvedFabricIndex === undefined ? "?" : resolvedFabricIndex.toString(10);
19
21
  return `@${fabricIndexPart}:${nodeId.toString(16)}`;
20
22
  }