@matter/nodejs-shell 0.17.9 → 0.18.0-alpha.0-20260812-4d7f2790e

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 (52) hide show
  1. package/README.md +11 -1
  2. package/dist/esm/MatterNode.js +237 -134
  3. package/dist/esm/MatterNode.js.map +1 -1
  4. package/dist/esm/app.js +13 -7
  5. package/dist/esm/app.js.map +1 -1
  6. package/dist/esm/shell/cmd_cluster-attributes.js +64 -45
  7. package/dist/esm/shell/cmd_cluster-attributes.js.map +2 -2
  8. package/dist/esm/shell/cmd_cluster-commands.js +11 -9
  9. package/dist/esm/shell/cmd_cluster-commands.js.map +1 -1
  10. package/dist/esm/shell/cmd_cluster-events.js +36 -9
  11. package/dist/esm/shell/cmd_cluster-events.js.map +1 -1
  12. package/dist/esm/shell/cmd_commission.js +119 -93
  13. package/dist/esm/shell/cmd_commission.js.map +1 -1
  14. package/dist/esm/shell/cmd_discover.js +23 -21
  15. package/dist/esm/shell/cmd_discover.js.map +1 -1
  16. package/dist/esm/shell/cmd_icd.js +46 -30
  17. package/dist/esm/shell/cmd_icd.js.map +1 -1
  18. package/dist/esm/shell/cmd_identify.js +11 -7
  19. package/dist/esm/shell/cmd_identify.js.map +1 -1
  20. package/dist/esm/shell/cmd_nodes.js +189 -168
  21. package/dist/esm/shell/cmd_nodes.js.map +1 -1
  22. package/dist/esm/shell/cmd_session.js +3 -4
  23. package/dist/esm/shell/cmd_session.js.map +1 -1
  24. package/dist/esm/shell/cmd_subscribe.js +26 -12
  25. package/dist/esm/shell/cmd_subscribe.js.map +1 -1
  26. package/dist/esm/util/ClusterEndpoint.js +61 -0
  27. package/dist/esm/util/ClusterEndpoint.js.map +6 -0
  28. package/dist/esm/util/awaitSeeded.js +31 -0
  29. package/dist/esm/util/awaitSeeded.js.map +6 -0
  30. package/dist/esm/util/diagnosticLogging.js +99 -0
  31. package/dist/esm/util/diagnosticLogging.js.map +6 -0
  32. package/dist/esm/util/legacyStorageMigration.js +188 -0
  33. package/dist/esm/util/legacyStorageMigration.js.map +6 -0
  34. package/package.json +10 -11
  35. package/src/MatterNode.ts +298 -159
  36. package/src/app.ts +17 -9
  37. package/src/shell/cmd_cluster-attributes.ts +77 -53
  38. package/src/shell/cmd_cluster-commands.ts +12 -13
  39. package/src/shell/cmd_cluster-events.ts +38 -11
  40. package/src/shell/cmd_commission.ts +136 -113
  41. package/src/shell/cmd_discover.ts +36 -22
  42. package/src/shell/cmd_icd.ts +52 -32
  43. package/src/shell/cmd_identify.ts +11 -7
  44. package/src/shell/cmd_nodes.ts +226 -199
  45. package/src/shell/cmd_session.ts +3 -5
  46. package/src/shell/cmd_subscribe.ts +37 -14
  47. package/src/shell/webassets/index.html +1 -1
  48. package/src/tsconfig.json +0 -3
  49. package/src/util/ClusterEndpoint.ts +107 -0
  50. package/src/util/awaitSeeded.ts +43 -0
  51. package/src/util/diagnosticLogging.ts +117 -0
  52. package/src/util/legacyStorageMigration.ts +275 -0
package/src/MatterNode.ts CHANGED
@@ -6,86 +6,75 @@
6
6
 
7
7
  // Include this first to auto-register Crypto, Network and Time Node.js implementations
8
8
  import {
9
- Diagnostic,
9
+ Duration,
10
10
  Environment,
11
11
  Filesystem,
12
+ ImplementationError,
13
+ InternalError,
12
14
  Logger,
13
15
  ObserverGroup,
16
+ Seconds,
14
17
  StorageContext,
15
18
  StorageManager,
16
19
  StorageService,
17
20
  } from "@matter/general";
18
- import { DclBehavior, ServerNode, SoftwareUpdateManager } from "@matter/node";
19
- import { NodeId } from "@matter/types";
20
- import { CommissioningController } from "@project-chip/matter.js";
21
21
  import {
22
- CommissioningControllerNodeOptions,
22
+ ClientNode,
23
+ ControllerBehavior,
24
+ DclBehavior,
23
25
  Endpoint,
24
- NodeStateInformation,
25
- PairedNode,
26
- } from "@project-chip/matter.js/device";
26
+ NetworkClient,
27
+ ServerNode,
28
+ SoftwareUpdateManager,
29
+ } from "@matter/node";
30
+ import { OtaProviderEndpoint } from "@matter/node/endpoints/ota-provider";
31
+ import { Ble, Fabric, FabricAuthority } from "@matter/protocol";
32
+ import { NodeId } from "@matter/types";
27
33
  import { join } from "node:path";
34
+ import { installDiagnosticLogging } from "./util/diagnosticLogging.js";
35
+ import {
36
+ cleanupLegacyStorage as purgeLegacyStorage,
37
+ eraseLegacyStorage,
38
+ migrateLegacyCommissionedNodes,
39
+ migrateLegacyControllerCredentials,
40
+ } from "./util/legacyStorageMigration.js";
41
+
42
+ const logger = Logger.get("Node");
43
+
44
+ const ADMIN_FABRIC_LABEL = "matter.js Shell";
28
45
 
29
46
  /**
30
- * The shell's default per-node diagnostic callbacks state-information, attribute-change and event logging. Applied by
31
- * default to every {@link MatterNode.connectAndGetNodes} connection so any command that reaches a node (not just
32
- * `nodes connect`) gets the same logging regardless of which command touched the node first.
47
+ * Options for {@link MatterNode.connectAndGetNodes}, expressed in the legacy vocabulary and mapped onto
48
+ * {@link NetworkClient} state per docs/MIGRATION_CONTROLLER_018.md.
33
49
  */
34
- export function createDiagnosticCallbacks(): Partial<CommissioningControllerNodeOptions> {
35
- return {
36
- attributeChangedCallback: (peerNodeId, { path: { nodeId, clusterId, endpointId, attributeName }, value }) =>
37
- console.log(
38
- `attributeChangedCallback ${peerNodeId}: Attribute ${nodeId}/${endpointId}/${clusterId}/${attributeName} changed to ${Diagnostic.json(
39
- value,
40
- )}`,
41
- ),
42
- eventTriggeredCallback: (peerNodeId, { path: { nodeId, clusterId, endpointId, eventName }, events }) =>
43
- console.log(
44
- `eventTriggeredCallback ${peerNodeId}: Event ${nodeId}/${endpointId}/${clusterId}/${eventName} triggered with ${Diagnostic.json(
45
- events,
46
- )}`,
47
- ),
48
- stateInformationCallback: (peerNodeId, info) => {
49
- switch (info) {
50
- case NodeStateInformation.Connected:
51
- console.log(`stateInformationCallback Node ${peerNodeId} connected`);
52
- break;
53
- case NodeStateInformation.Disconnected:
54
- console.log(`stateInformationCallback Node ${peerNodeId} disconnected`);
55
- break;
56
- case NodeStateInformation.Reconnecting:
57
- console.log(`stateInformationCallback Node ${peerNodeId} reconnecting`);
58
- break;
59
- case NodeStateInformation.WaitingForDeviceDiscovery:
60
- console.log(
61
- `stateInformationCallback Node ${peerNodeId} waiting that device gets discovered again`,
62
- );
63
- break;
64
- case NodeStateInformation.StructureChanged:
65
- console.log(`stateInformationCallback Node ${peerNodeId} structure changed`);
66
- break;
67
- case NodeStateInformation.Decommissioned:
68
- console.log(`stateInformationCallback Node ${peerNodeId} decommissioned`);
69
- break;
70
- }
71
- },
72
- };
50
+ export interface ConnectClientNodeOptions {
51
+ /** When `false`, the node is not connected (left offline); it is never disabled. */
52
+ autoConnect?: boolean;
53
+ /** When `true`, opts into a subscription on connect; otherwise the node connects without subscribing. */
54
+ autoSubscribe?: boolean;
55
+ /** Maps to {@link NetworkClient.State.defaultSubscription}.minIntervalFloor. */
56
+ subscribeMinIntervalFloorSeconds?: number;
57
+ /** Maps to {@link NetworkClient.State.defaultSubscription}.maxIntervalCeiling. */
58
+ subscribeMaxIntervalCeilingSeconds?: number;
73
59
  }
74
60
 
75
- const logger = Logger.get("Node");
76
-
77
61
  export class MatterNode {
78
62
  #storageLocation?: string;
79
63
  #storageManager?: StorageManager;
80
64
  #storageContext?: StorageContext;
81
65
  readonly #environment: Environment;
82
- commissioningController?: CommissioningController;
66
+ #nodeEnvironment?: Environment;
67
+ #node?: ServerNode;
68
+ #nodePromise?: Promise<ServerNode>;
69
+ #fabric?: Fabric;
83
70
  #started = false;
71
+ #startPromise?: Promise<void>;
84
72
  readonly #nodeNum: number;
85
73
  readonly #netInterface?: string;
86
74
  #dclFetchTestCertificates = false;
87
75
  #allowTestOtaImages = false;
88
76
  #transportPreference?: "tcp" | "udp";
77
+ #bleEnabled = false;
89
78
  #observers?: ObserverGroup;
90
79
 
91
80
  constructor(nodeNum: number, netInterface?: string) {
@@ -104,83 +93,159 @@ export class MatterNode {
104
93
  }
105
94
 
106
95
  get node(): ServerNode {
107
- if (this.commissioningController === undefined) {
108
- throw new Error("CommissioningController not initialized. Start first");
96
+ if (this.#node === undefined) {
97
+ throw new ImplementationError("Controller node not initialized. Call initialize() first.");
109
98
  }
110
- return this.commissioningController.node;
99
+ return this.#node;
100
+ }
101
+
102
+ /**
103
+ * The OTA provider endpoint on the controller node. The cast is unavoidable: `endpoints.for(id)` resolves by
104
+ * runtime id lookup, so it cannot statically know which endpoint type lives at "ota-provider".
105
+ */
106
+ get otaProviderEndpoint(): Endpoint<OtaProviderEndpoint> {
107
+ return this.node.endpoints.for("ota-provider") as Endpoint<OtaProviderEndpoint>;
111
108
  }
112
109
 
113
110
  async otaService() {
111
+ await this.start();
114
112
  const service = await this.node.act(agent => agent.get(DclBehavior).otaUpdateService);
115
113
  await service.construction;
116
114
  return service;
117
115
  }
118
116
 
119
117
  async certificateService() {
118
+ await this.start();
120
119
  const service = await this.node.act(agent => agent.get(DclBehavior).certificateService);
121
120
  await service.construction;
122
121
  return service;
123
122
  }
124
123
 
125
124
  async vendorInfoService() {
125
+ await this.start();
126
126
  const service = await this.node.act(agent => agent.get(DclBehavior).vendorInfoService);
127
127
  await service.construction;
128
128
  return service;
129
129
  }
130
130
 
131
- async initialize(resetStorage: boolean) {
132
- /**
133
- * Initialize the storage system.
134
- *
135
- * The Matter server then also uses the storage manager, so this code block in general is required,
136
- * but you can choose a different storage backend as long as it implements the required API.
137
- */
138
-
139
- if (this.#environment) {
140
- if (this.#netInterface !== undefined) {
141
- this.#environment.vars.set("mdns.networkinterface", this.#netInterface);
142
- }
131
+ async initialize(resetStorage: boolean, cleanupLegacyStorage = false) {
132
+ if (this.#netInterface !== undefined) {
133
+ this.#environment.vars.set("mdns.networkinterface", this.#netInterface);
134
+ }
143
135
 
144
- const id = `shell-${this.#nodeNum.toString()}`;
136
+ const id = `shell-${this.#nodeNum.toString()}`;
137
+
138
+ // Scope the controller node's services (storage, mDNS) under its id, mirroring the legacy controller wrapper.
139
+ const nodeEnvironment = new Environment(id, this.#environment);
140
+ this.#nodeEnvironment = nodeEnvironment;
141
+
142
+ // Open storage up front so persisted settings are available before the controller node is built.
143
+ this.#storageManager = await nodeEnvironment.get(StorageService).open(id);
144
+ this.#storageContext = this.#storageManager.createContext("Node");
145
145
 
146
- // Open storage up front so persisted settings can flow into the CommissioningController constructor.
147
- this.#storageManager = await this.#environment.get(StorageService).open(id);
148
- this.#storageContext = this.#storageManager.createContext("Node");
146
+ this.#dclFetchTestCertificates = await this.#storageContext.get<boolean>("DclFetchTestCertificates", false);
147
+ this.#allowTestOtaImages = await this.#storageContext.get<boolean>("AllowTestOtaImages", false);
148
+ const storedPref = await this.#storageContext.get<string>("TransportPreference", "");
149
+ this.#transportPreference = storedPref === "tcp" || storedPref === "udp" ? storedPref : undefined;
149
150
 
150
- this.#dclFetchTestCertificates = await this.#storageContext.get<boolean>("DclFetchTestCertificates", false);
151
- this.#allowTestOtaImages = await this.#storageContext.get<boolean>("AllowTestOtaImages", false);
152
- const storedPref = await this.#storageContext.get<string>("TransportPreference", "");
153
- this.#transportPreference = storedPref === "tcp" || storedPref === "udp" ? storedPref : undefined;
151
+ this.#bleEnabled = (nodeEnvironment.maybeGet(Ble) ?? Environment.default.maybeGet(Ble)) !== undefined;
154
152
 
155
- // Build up the "Not-so-legacy" Controller
156
- this.commissioningController = new CommissioningController({
157
- environment: {
158
- environment: this.#environment,
159
- id,
153
+ // storageLocation only reads the filesystem path; it neither creates nor onlines the node and is consumed at
154
+ // boot (shell history), so it stays out of the lazy #ensureNode() path.
155
+ if (nodeEnvironment.has(Filesystem)) {
156
+ this.#storageLocation = join(nodeEnvironment.get(Filesystem).path, id);
157
+ }
158
+
159
+ // Factory reset and legacy migration are the only paths that need the node constructed before start(),
160
+ // hence the eager #ensureNode() here rather than the usual lazy creation.
161
+ if (resetStorage) {
162
+ await (await this.#ensureNode()).erase();
163
+ // erase() clears only the current-format fabric/peers. The legacy source must go too, or the next
164
+ // boot's migration would re-materialize the identity and key material the reset just destroyed.
165
+ await eraseLegacyStorage(nodeEnvironment, id);
166
+ return;
167
+ }
168
+
169
+ // Both migration steps are self-guarding no-ops when there is nothing left to migrate, so calling them
170
+ // unconditionally on every boot also resumes a migration that crashed after only some peers were done.
171
+ // Step 1 must run before the controller ServerNode is created, so construction loads the migrated fabric.
172
+ await migrateLegacyControllerCredentials(nodeEnvironment, id);
173
+
174
+ const node = await this.#ensureNode();
175
+ const { nodes, endpoints, failed } = await migrateLegacyCommissionedNodes(node);
176
+ if (nodes > 0) {
177
+ logger.info(`Legacy storage migration: ${nodes} node(s), ${endpoints} endpoint(s) migrated`);
178
+ }
179
+ if (failed > 0) {
180
+ logger.warn(`${failed} peer(s) failed to migrate; do not run --cleanup-legacy-storage until resolved`);
181
+ }
182
+
183
+ if (cleanupLegacyStorage) {
184
+ if (failed > 0) {
185
+ logger.warn(`Skipping --cleanup-legacy-storage this run: ${failed} peer(s) failed to migrate`);
186
+ } else {
187
+ await purgeLegacyStorage(nodeEnvironment, id);
188
+ }
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Lazily creates the controller {@link ServerNode} on first real use. Creation runs the controller behaviors
194
+ * (binding the mDNS socket), which is why boot defers it until {@link start}.
195
+ *
196
+ * Concurrent callers (e.g. two websocket requests) share one creation via {@link #nodePromise}, so the node is
197
+ * never built twice. A failed creation clears the cached promise so a subsequent call can retry.
198
+ */
199
+ async #ensureNode(): Promise<ServerNode> {
200
+ if (this.#node !== undefined) {
201
+ return this.#node;
202
+ }
203
+ if (this.#nodePromise !== undefined) {
204
+ return this.#nodePromise;
205
+ }
206
+ if (this.#nodeEnvironment === undefined) {
207
+ throw new ImplementationError("Controller node accessed before initialize()");
208
+ }
209
+
210
+ this.#nodePromise = (async () => {
211
+ const id = `shell-${this.#nodeNum.toString()}`;
212
+ const node = await ServerNode.create(ServerNode.RootEndpoint.with(ControllerBehavior), {
213
+ environment: this.#nodeEnvironment,
214
+ id,
215
+ network: {
216
+ ble: false,
217
+ tcp: true,
218
+ transportPreference: this.#transportPreference,
219
+ // The shell connects peers strictly on demand, so opt out of the online-time bulk connect.
220
+ autoStartCommissionedPeers: false,
160
221
  },
161
- autoConnect: false,
162
- adminFabricLabel: "matter.js Shell",
163
- enableOtaProvider: true,
164
- tcp: true,
165
- transportPreference: this.#transportPreference,
166
222
  basicInformation: {
167
- productName: "matter.js Shell",
223
+ productName: ADMIN_FABRIC_LABEL,
224
+ },
225
+ controller: {
226
+ adminFabricLabel: ADMIN_FABRIC_LABEL,
227
+ ble: this.#bleEnabled,
228
+ },
229
+ commissioning: {
230
+ enabled: false, // The controller node is never commissionable itself.
231
+ },
232
+ subscriptions: {
233
+ persistenceEnabled: false, // Subscription persistence is a device feature, not a controller one.
168
234
  },
169
235
  });
170
236
 
171
- const env = this.commissioningController.env;
172
- if (env.has(Filesystem)) {
173
- this.#storageLocation = join(env.get(Filesystem).path, id);
174
- }
237
+ // Pulls in SoftwareUpdateManager (and thereby DclBehavior on the root node) for OTA provider support.
238
+ await node.add(new Endpoint(OtaProviderEndpoint, { id: "ota-provider" }));
175
239
 
176
- if (resetStorage) {
177
- await this.commissioningController.node.erase();
178
- }
179
- } else {
180
- console.log(
181
- "Legacy support was removed in Matter.js 0.13. Please downgrade or migrate the storage manually",
182
- );
183
- process.exit(1);
240
+ this.#node = node;
241
+ return node;
242
+ })();
243
+
244
+ try {
245
+ return await this.#nodePromise;
246
+ } catch (e) {
247
+ this.#nodePromise = undefined;
248
+ throw e;
184
249
  }
185
250
  }
186
251
 
@@ -197,8 +262,8 @@ export class MatterNode {
197
262
  await this.Store.set("AllowTestOtaImages", value);
198
263
  }
199
264
  this.#allowTestOtaImages = value ?? false;
200
- if (this.#started && this.commissioningController !== undefined) {
201
- await this.commissioningController.otaProvider.setStateOf(SoftwareUpdateManager, {
265
+ if (this.#started && this.#node !== undefined) {
266
+ await this.otaProviderEndpoint.setStateOf(SoftwareUpdateManager, {
202
267
  allowTestOtaImages: this.#allowTestOtaImages,
203
268
  });
204
269
  }
@@ -206,111 +271,185 @@ export class MatterNode {
206
271
 
207
272
  get Store() {
208
273
  if (!this.#storageContext) {
209
- throw new Error("Storage uninitialized");
274
+ throw new ImplementationError("Storage uninitialized");
210
275
  }
211
276
  return this.#storageContext;
212
277
  }
213
278
 
214
279
  async close() {
215
280
  try {
216
- await this.commissioningController?.close();
281
+ await this.#node?.close();
217
282
  } finally {
218
283
  this.#observers?.close();
219
284
  await this.#storageManager?.close();
220
285
  }
221
286
  }
222
287
 
288
+ /** Concurrent callers share one in-flight run via {@link #startPromise} so the setup below executes exactly once. */
223
289
  async start() {
224
290
  if (this.#started) {
225
291
  return;
226
292
  }
227
- logger.info(`matter.js shell controller started for node ${this.#nodeNum}`);
293
+ if (this.#startPromise !== undefined) {
294
+ return this.#startPromise;
295
+ }
296
+
297
+ this.#startPromise = (async () => {
298
+ logger.info(`matter.js shell controller started for node ${this.#nodeNum}`);
299
+
300
+ const node = await this.#ensureNode();
228
301
 
229
- if (this.commissioningController !== undefined) {
230
- await this.commissioningController.start();
302
+ // Reuse the existing controller fabric (matched by CA) or create one, rotating the NOC once per runtime.
303
+ const fabricAuthority = await node.env.load(FabricAuthority);
304
+ this.#fabric = await fabricAuthority.defaultFabric({ adminFabricLabel: ADMIN_FABRIC_LABEL });
231
305
 
232
- await this.commissioningController.node.setStateOf(DclBehavior, {
306
+ await node.start();
307
+
308
+ // The shell subscribes on demand (the `subscribe` command), never persistently. Clear any autoSubscribe
309
+ // carried over from a prior session or pre-migration commissioning once here, so a plain connect never
310
+ // resumes a subscription; the connect path then leaves autoSubscribe untouched (see below).
311
+ for (const peer of node.peers.commissioned) {
312
+ if (peer.stateOf(NetworkClient).autoSubscribe) {
313
+ await peer.setStateOf(NetworkClient, { autoSubscribe: false });
314
+ }
315
+ }
316
+
317
+ await node.setStateOf(DclBehavior, {
233
318
  fetchTestCertificates: this.#dclFetchTestCertificates,
234
319
  });
235
320
 
236
- await this.commissioningController.otaProvider.setStateOf(SoftwareUpdateManager, {
321
+ await this.otaProviderEndpoint.setStateOf(SoftwareUpdateManager, {
237
322
  allowTestOtaImages: this.#allowTestOtaImages,
238
323
  });
239
324
 
240
325
  if (await this.Store.has("ControllerFabricLabel")) {
241
- await this.commissioningController.updateFabricLabel(
242
- await this.Store.get<string>("ControllerFabricLabel", "matter.js Shell"),
243
- );
326
+ await this.#fabric.setLabel(await this.Store.get<string>("ControllerFabricLabel", ADMIN_FABRIC_LABEL));
244
327
  }
245
- } else {
246
- throw new Error("No controller initialized");
247
- }
248
328
 
249
- this.#observers = this.#observers ?? new ObserverGroup(this.#environment.runtime);
250
- const updateManagerEvents = this.commissioningController.otaProvider.eventsOf(SoftwareUpdateManager);
251
- this.#observers.on(updateManagerEvents.updateAvailable, (peer, details) => {
252
- logger.info(`Update available for peer`, peer, `:`, details);
253
- });
254
- this.#observers.on(updateManagerEvents.updateDone, peer => {
255
- logger.info(`Update done for peer`, peer);
256
- });
257
- this.#observers.on(updateManagerEvents.updateFailed, peer => {
258
- logger.info(`Update failed for peer`, peer);
259
- });
260
-
261
- this.#started = true;
329
+ this.#observers = this.#observers ?? new ObserverGroup(this.#environment.runtime);
330
+ const updateManagerEvents = this.otaProviderEndpoint.eventsOf(SoftwareUpdateManager);
331
+ this.#observers.on(updateManagerEvents.updateAvailable, (peer, details) => {
332
+ logger.info(`Update available for peer`, peer, `:`, details);
333
+ });
334
+ this.#observers.on(updateManagerEvents.updateDone, peer => {
335
+ logger.info(`Update done for peer`, peer);
336
+ });
337
+ this.#observers.on(updateManagerEvents.updateFailed, peer => {
338
+ logger.info(`Update failed for peer`, peer);
339
+ });
340
+
341
+ installDiagnosticLogging(node, this.#observers);
342
+
343
+ this.#started = true;
344
+ })();
345
+
346
+ try {
347
+ await this.#startPromise;
348
+ } finally {
349
+ this.#startPromise = undefined;
350
+ }
262
351
  }
263
352
 
264
- async connectAndGetNodes(nodeIdStr?: string, connectOptions?: CommissioningControllerNodeOptions) {
353
+ /**
354
+ * Returns the {@link ClientNode}s for the commissioned peers, connecting them (unless `autoConnect: false`).
355
+ * Nodes connect asynchronously; use {@link awaitSeeded} before relying on the endpoint structure.
356
+ */
357
+ async connectAndGetNodes(nodeIdStr?: string, connectOptions?: ConnectClientNodeOptions): Promise<ClientNode[]> {
265
358
  await this.start();
266
- const nodeId = nodeIdStr !== undefined ? NodeId(BigInt(nodeIdStr)) : undefined;
267
359
 
268
- if (this.commissioningController === undefined) {
269
- throw new Error("CommissioningController not initialized");
270
- }
360
+ const commissioned = this.node.peers.commissioned;
271
361
 
272
- // Default the shell's diagnostic callbacks so a node reached via any command (icd, cluster-*, subscribe, ),
273
- // not just `nodes connect`, gets the same logging. A caller-supplied option still wins.
274
- const options = { ...createDiagnosticCallbacks(), ...connectOptions };
362
+ // A specific node id must surface its own failure; the all-nodes path is best-effort (see below).
363
+ const singleNodeRequested = nodeIdStr !== undefined;
275
364
 
276
- if (nodeId === undefined) {
277
- return await this.commissioningController.connect(options);
365
+ let nodes: ClientNode[];
366
+ if (nodeIdStr !== undefined) {
367
+ const nodeId = NodeId(BigInt(nodeIdStr));
368
+ const node = commissioned.find(peer => peer.peerAddress?.nodeId === nodeId);
369
+ if (node === undefined) {
370
+ throw new ImplementationError(`Node ${nodeId} is not commissioned!`);
371
+ }
372
+ nodes = [node];
373
+ } else {
374
+ nodes = commissioned;
278
375
  }
279
376
 
280
- const node = await this.commissioningController.connectNode(nodeId, {
281
- ...options /*autoConnect: false*/,
282
- });
283
- if (!node.initialized) {
284
- await node.events.initialized;
377
+ for (const node of nodes) {
378
+ try {
379
+ await this.#applyClientNodeNetworkOptions(node, connectOptions);
380
+ if (connectOptions?.autoConnect === false) {
381
+ continue;
382
+ }
383
+ // A disabled peer (persisted from a prior run) rejects start(); enable() clears isDisabled and
384
+ // starts it. Safe because autoStartCommissionedPeers is off, so the now-enabled peer won't
385
+ // auto-connect on next boot.
386
+ if (node.stateOf(NetworkClient).isDisabled) {
387
+ await node.enable();
388
+ } else {
389
+ await node.start();
390
+ }
391
+ } catch (e) {
392
+ if (singleNodeRequested) {
393
+ throw e;
394
+ }
395
+ // Best-effort across all commissioned peers: one unreachable peer must not abort the rest.
396
+ logger.warn(`Node ${node.peerAddress?.nodeId} failed to connect:`, e);
397
+ }
285
398
  }
286
- return [node];
399
+
400
+ return nodes;
287
401
  }
288
402
 
289
- get controller() {
290
- if (this.commissioningController === undefined) {
291
- throw new Error("CommissioningController not initialized. Start first");
403
+ async #applyClientNodeNetworkOptions(node: ClientNode, options?: ConnectClientNodeOptions) {
404
+ // connect ≠ subscribe: a plain connect leaves autoSubscribe untouched (it was normalized to false once at
405
+ // startup), so a subscription established this session via the `subscribe` command survives subsequent
406
+ // commands. Only an explicit opt-in flips it here.
407
+ if (options?.autoSubscribe !== undefined) {
408
+ await node.setStateOf(NetworkClient, { autoSubscribe: options.autoSubscribe });
409
+ }
410
+
411
+ const { subscribeMinIntervalFloorSeconds, subscribeMaxIntervalCeilingSeconds } = options ?? {};
412
+ if (subscribeMinIntervalFloorSeconds !== undefined || subscribeMaxIntervalCeilingSeconds !== undefined) {
413
+ const defaultSubscription: { minIntervalFloor?: Duration; maxIntervalCeiling?: Duration } = {};
414
+ if (subscribeMinIntervalFloorSeconds !== undefined) {
415
+ defaultSubscription.minIntervalFloor = Seconds(subscribeMinIntervalFloorSeconds);
416
+ }
417
+ if (subscribeMaxIntervalCeilingSeconds !== undefined) {
418
+ defaultSubscription.maxIntervalCeiling = Seconds(subscribeMaxIntervalCeilingSeconds);
419
+ }
420
+ await node.setStateOf(NetworkClient, { defaultSubscription });
292
421
  }
293
- return this.commissioningController;
294
422
  }
295
423
 
424
+ /**
425
+ * Iterates the endpoints of each {@link ClientNode}, including the root endpoint (number 0); pass
426
+ * {@link endpointIds} to restrict iteration.
427
+ */
296
428
  async iterateNodeDevices(
297
- nodes: PairedNode[],
298
- callback: (device: Endpoint, node: PairedNode) => Promise<void>,
299
- endpointId?: number,
300
- ) {
429
+ nodes: ClientNode[],
430
+ callback: (device: Endpoint, node: ClientNode) => Promise<void>,
431
+ endpointIds?: number[],
432
+ ): Promise<void> {
301
433
  for (const node of nodes) {
302
- let devices = node.getDevices();
303
- if (endpointId !== undefined) {
304
- devices = devices.filter(device => device.number === endpointId);
305
- }
306
-
307
- for (const device of devices) {
434
+ for (const device of node.endpoints) {
435
+ if (endpointIds !== undefined && !endpointIds.includes(device.number)) {
436
+ continue;
437
+ }
308
438
  await callback(device, node);
309
439
  }
310
440
  }
311
441
  }
312
442
 
313
- updateFabricLabel(label: string) {
314
- return this.commissioningController?.updateFabricLabel(label);
443
+ /**
444
+ * Updates the controller's admin fabric label on the local fabric. Propagation to already-connected peers is not
445
+ * reproduced and moves to the node-management layer per docs/MIGRATION_CONTROLLER_018.md.
446
+ * MIGRATION-GAP: updatefabriclabel-note.
447
+ */
448
+ async updateFabricLabel(label: string) {
449
+ await this.start();
450
+ if (this.#fabric === undefined) {
451
+ throw new InternalError("Controller fabric not initialized");
452
+ }
453
+ await this.#fabric.setLabel(label);
315
454
  }
316
455
  }
package/src/app.ts CHANGED
@@ -81,6 +81,12 @@ async function main() {
81
81
  default: false,
82
82
  type: "boolean",
83
83
  },
84
+ "cleanup-legacy-storage": {
85
+ description:
86
+ "Irreversibly delete pre-0.16 storage artifacts after migration. Only use this once you are certain the migration succeeded and you will not downgrade below 0.16.",
87
+ default: false,
88
+ type: "boolean",
89
+ },
84
90
  netInterface: {
85
91
  description: "Network interface to use for MDNS announcements and scanning.",
86
92
  type: "string",
@@ -126,6 +132,7 @@ async function main() {
126
132
  bleHciId,
127
133
  nodeType,
128
134
  factoryReset,
135
+ cleanupLegacyStorage,
129
136
  netInterface,
130
137
  logfile,
131
138
  webSocketInterface,
@@ -134,8 +141,17 @@ async function main() {
134
141
  webAddress,
135
142
  } = argv;
136
143
 
144
+ // Install BLE before the controller node initializes: the Ble service is registered reactively
145
+ // when `ble.enable` flips, and the node reads its availability during initialize().
146
+ if (bleEnable) {
147
+ Environment.default.vars.set("ble.enable", true);
148
+ }
149
+ if (bleHciId !== undefined) {
150
+ Environment.default.vars.set("ble.hci.id", bleHciId);
151
+ }
152
+
137
153
  theNode = new MatterNode(nodeNum, netInterface);
138
- await theNode.initialize(factoryReset);
154
+ await theNode.initialize(factoryReset, cleanupLegacyStorage);
139
155
 
140
156
  if (logfile !== undefined) {
141
157
  await theNode.Store.set("LogFile", logfile);
@@ -159,14 +175,6 @@ async function main() {
159
175
  theShell = new Shell(theNode, nodeNum, PROMPT, process.stdin, process.stdout);
160
176
  }
161
177
 
162
- if (bleEnable) {
163
- Environment.default.vars.set("ble.enable", true);
164
- }
165
-
166
- if (bleHciId !== undefined) {
167
- Environment.default.vars.set("ble.hci.id", bleHciId);
168
- }
169
-
170
178
  console.log(`Started Node #${nodeNum} (Type: ${nodeType}) ${bleEnable ? "with" : "without"} BLE`);
171
179
  if (!webSocketInterface) {
172
180
  theShell.start(theNode.storageLocation);