@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.
- package/README.md +11 -1
- package/dist/esm/MatterNode.js +237 -134
- package/dist/esm/MatterNode.js.map +1 -1
- package/dist/esm/app.js +13 -7
- package/dist/esm/app.js.map +1 -1
- package/dist/esm/shell/cmd_cluster-attributes.js +64 -45
- package/dist/esm/shell/cmd_cluster-attributes.js.map +2 -2
- package/dist/esm/shell/cmd_cluster-commands.js +11 -9
- package/dist/esm/shell/cmd_cluster-commands.js.map +1 -1
- package/dist/esm/shell/cmd_cluster-events.js +36 -9
- package/dist/esm/shell/cmd_cluster-events.js.map +1 -1
- package/dist/esm/shell/cmd_commission.js +119 -93
- package/dist/esm/shell/cmd_commission.js.map +1 -1
- package/dist/esm/shell/cmd_discover.js +23 -21
- package/dist/esm/shell/cmd_discover.js.map +1 -1
- package/dist/esm/shell/cmd_icd.js +46 -30
- package/dist/esm/shell/cmd_icd.js.map +1 -1
- package/dist/esm/shell/cmd_identify.js +11 -7
- package/dist/esm/shell/cmd_identify.js.map +1 -1
- package/dist/esm/shell/cmd_nodes.js +189 -168
- package/dist/esm/shell/cmd_nodes.js.map +1 -1
- package/dist/esm/shell/cmd_session.js +3 -4
- package/dist/esm/shell/cmd_session.js.map +1 -1
- package/dist/esm/shell/cmd_subscribe.js +26 -12
- package/dist/esm/shell/cmd_subscribe.js.map +1 -1
- package/dist/esm/util/ClusterEndpoint.js +61 -0
- package/dist/esm/util/ClusterEndpoint.js.map +6 -0
- package/dist/esm/util/awaitSeeded.js +31 -0
- package/dist/esm/util/awaitSeeded.js.map +6 -0
- package/dist/esm/util/diagnosticLogging.js +99 -0
- package/dist/esm/util/diagnosticLogging.js.map +6 -0
- package/dist/esm/util/legacyStorageMigration.js +188 -0
- package/dist/esm/util/legacyStorageMigration.js.map +6 -0
- package/package.json +10 -11
- package/src/MatterNode.ts +298 -159
- package/src/app.ts +17 -9
- package/src/shell/cmd_cluster-attributes.ts +77 -53
- package/src/shell/cmd_cluster-commands.ts +12 -13
- package/src/shell/cmd_cluster-events.ts +38 -11
- package/src/shell/cmd_commission.ts +136 -113
- package/src/shell/cmd_discover.ts +36 -22
- package/src/shell/cmd_icd.ts +52 -32
- package/src/shell/cmd_identify.ts +11 -7
- package/src/shell/cmd_nodes.ts +226 -199
- package/src/shell/cmd_session.ts +3 -5
- package/src/shell/cmd_subscribe.ts +37 -14
- package/src/shell/webassets/index.html +1 -1
- package/src/tsconfig.json +0 -3
- package/src/util/ClusterEndpoint.ts +107 -0
- package/src/util/awaitSeeded.ts +43 -0
- package/src/util/diagnosticLogging.ts +117 -0
- 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
|
-
|
|
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
|
-
|
|
22
|
+
ClientNode,
|
|
23
|
+
ControllerBehavior,
|
|
24
|
+
DclBehavior,
|
|
23
25
|
Endpoint,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
*
|
|
31
|
-
*
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
108
|
-
throw new
|
|
96
|
+
if (this.#node === undefined) {
|
|
97
|
+
throw new ImplementationError("Controller node not initialized. Call initialize() first.");
|
|
109
98
|
}
|
|
110
|
-
return this
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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:
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
201
|
-
await this.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
await
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
throw new Error("CommissioningController not initialized");
|
|
270
|
-
}
|
|
360
|
+
const commissioned = this.node.peers.commissioned;
|
|
271
361
|
|
|
272
|
-
//
|
|
273
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
399
|
+
|
|
400
|
+
return nodes;
|
|
287
401
|
}
|
|
288
402
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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:
|
|
298
|
-
callback: (device: Endpoint, node:
|
|
299
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
314
|
-
|
|
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);
|