@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/shell/cmd_session.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* SPDX-License-Identifier: Apache-2.0
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { SessionsBehavior } from "@matter/node";
|
|
7
8
|
import { MatterNode } from "../MatterNode.js";
|
|
8
9
|
|
|
9
10
|
export default function commands(theNode: MatterNode) {
|
|
@@ -12,11 +13,8 @@ export default function commands(theNode: MatterNode) {
|
|
|
12
13
|
describe: "Manage session",
|
|
13
14
|
builder: {},
|
|
14
15
|
handler: async () => {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const sessions = theNode.commissioningController?.getActiveSessionInformation();
|
|
16
|
+
await theNode.start();
|
|
17
|
+
const sessions = Object.values(theNode.node.stateOf(SessionsBehavior).sessions);
|
|
20
18
|
console.log(sessions);
|
|
21
19
|
},
|
|
22
20
|
};
|
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
* SPDX-License-Identifier: Apache-2.0
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { ImplementationError, ObserverGroup } from "@matter/general";
|
|
8
|
+
import { NetworkClient } from "@matter/node";
|
|
8
9
|
import type { Argv } from "yargs";
|
|
9
10
|
import { MatterNode } from "../MatterNode.js";
|
|
10
11
|
|
|
12
|
+
const watchers = new Map<string, ObserverGroup>();
|
|
13
|
+
|
|
11
14
|
export default function commands(theNode: MatterNode) {
|
|
12
15
|
return {
|
|
13
16
|
command: "subscribe [node-id]",
|
|
@@ -23,21 +26,41 @@ export default function commands(theNode: MatterNode) {
|
|
|
23
26
|
handler: async (argv: any) => {
|
|
24
27
|
const { nodeId: subscribeNodeId } = argv;
|
|
25
28
|
const node = (await theNode.connectAndGetNodes(subscribeNodeId))[0];
|
|
29
|
+
if (node === undefined) {
|
|
30
|
+
throw new ImplementationError("No commissioned node to subscribe to");
|
|
31
|
+
}
|
|
32
|
+
const nodeId = node.peerAddress?.nodeId;
|
|
33
|
+
if (nodeId === undefined) {
|
|
34
|
+
throw new ImplementationError("Resolved node has no peer address to subscribe to");
|
|
35
|
+
}
|
|
36
|
+
const key = String(nodeId);
|
|
37
|
+
|
|
38
|
+
// Re-subscribing the same node replaces its watcher so change lines are not logged twice.
|
|
39
|
+
watchers.get(key)?.close();
|
|
40
|
+
watchers.delete(key);
|
|
41
|
+
const observers = new ObserverGroup();
|
|
42
|
+
watchers.set(key, observers);
|
|
43
|
+
observers.on(node.lifecycle.destroyed, () => {
|
|
44
|
+
watchers.get(key)?.close();
|
|
45
|
+
watchers.delete(key);
|
|
46
|
+
});
|
|
26
47
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
),
|
|
34
|
-
eventTriggeredCallback: ({ path: { nodeId, clusterId, endpointId, eventName }, events }) =>
|
|
35
|
-
console.log(
|
|
36
|
-
`${subscribeNodeId} Event ${nodeId}/${endpointId}/${clusterId}/${eventName} triggered with ${Diagnostic.json(
|
|
37
|
-
events,
|
|
38
|
-
)}`,
|
|
39
|
-
),
|
|
48
|
+
// Attribute/event/structure changes for every peer are logged node-wide by installDiagnosticLogging
|
|
49
|
+
// (MatterNode.start); this command only establishes the subscription and reports its liveness.
|
|
50
|
+
|
|
51
|
+
// Surface subscription liveness transitions (establishment, drop, re-establishment).
|
|
52
|
+
observers.on(node.eventsOf(NetworkClient).subscriptionStatusChanged, isActive => {
|
|
53
|
+
console.log(`${nodeId}: subscription ${isActive ? "active" : "inactive"}`);
|
|
40
54
|
});
|
|
55
|
+
|
|
56
|
+
// Enabling auto-subscribe establishes (and thereafter re-establishes) the sustained subscription; changes
|
|
57
|
+
// flow to the already-registered listener above.
|
|
58
|
+
await node.set({ network: { autoSubscribe: true } });
|
|
59
|
+
|
|
60
|
+
const subscriptionActive = await node.act(agent => agent.get(NetworkClient).subscriptionActive);
|
|
61
|
+
console.log(
|
|
62
|
+
`Subscribed to node ${nodeId} (subscription active: ${subscriptionActive}). Attribute and event changes will be logged below as they arrive.`,
|
|
63
|
+
);
|
|
41
64
|
},
|
|
42
65
|
};
|
|
43
66
|
}
|
|
@@ -285,7 +285,7 @@
|
|
|
285
285
|
logMessage(`Received: "${trimmed}"`, trimmed.toLowerCase().includes('error') ? 'error' : 'received');
|
|
286
286
|
|
|
287
287
|
// result of nodes log command
|
|
288
|
-
let matches = message.match(/
|
|
288
|
+
let matches = message.match(/Logging structure of Node\s+(\d+)/);
|
|
289
289
|
if (matches) { // could save this message of extensive node data for later use
|
|
290
290
|
let currentNode = matches[1];
|
|
291
291
|
let currentEndpoint = null;
|
package/src/tsconfig.json
CHANGED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2022-2026 Matter.js Authors
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { ClientNode, ClusterBehavior, Endpoint } from "@matter/node";
|
|
8
|
+
import { Read, ReadResult } from "@matter/protocol";
|
|
9
|
+
import { AttributePath, ClusterId, EventPath, Status } from "@matter/types";
|
|
10
|
+
import { MatterNode } from "../MatterNode.js";
|
|
11
|
+
import { awaitSeeded } from "./awaitSeeded.js";
|
|
12
|
+
|
|
13
|
+
export interface ResolvedClusterEndpoint {
|
|
14
|
+
node: ClientNode;
|
|
15
|
+
endpoint: Endpoint;
|
|
16
|
+
behaviorType: ClusterBehavior.Type;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Connect to `nodeIdStr`, wait for the peer's endpoint structure to seed, and resolve the behavior implementing
|
|
21
|
+
* `clusterId` on `endpointId`. Mirrors the legacy `getDeviceById(endpointId)?.getClusterClientById(clusterId)` lookup
|
|
22
|
+
* (same "not found" message on any failure) so the three cluster-access command files can share one gate.
|
|
23
|
+
*/
|
|
24
|
+
export async function resolveClusterEndpoint(
|
|
25
|
+
theNode: MatterNode,
|
|
26
|
+
nodeIdStr: string,
|
|
27
|
+
endpointId: number,
|
|
28
|
+
clusterId: number,
|
|
29
|
+
): Promise<ResolvedClusterEndpoint | undefined> {
|
|
30
|
+
const node = (await theNode.connectAndGetNodes(nodeIdStr))[0];
|
|
31
|
+
if (!(await awaitSeeded(node))) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const behaviorType = node.endpoints.has(endpointId)
|
|
36
|
+
? node.endpoints.for(endpointId).behaviors.forCluster(ClusterId(clusterId))
|
|
37
|
+
: undefined;
|
|
38
|
+
if (behaviorType === undefined) {
|
|
39
|
+
console.log(`ERROR: Cluster ${node.peerAddress?.nodeId}/${endpointId}/${clusterId} not found.`);
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
return { node, endpoint: node.endpoints.for(endpointId), behaviorType };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* True only when `name` is a *known-absent* attribute/command of `behaviorType` on `endpoint`.
|
|
47
|
+
*
|
|
48
|
+
* The supported set derives from the behavior's global attribute/command list, which is EMPTY until that list has
|
|
49
|
+
* been read (a narrow window right after connect). An empty set is treated as "not yet known" — the caller proceeds
|
|
50
|
+
* to the live read/invoke rather than false-rejecting a genuinely supported element. Only a populated set that omits
|
|
51
|
+
* `name` is a real "unsupported" answer.
|
|
52
|
+
*/
|
|
53
|
+
export function elementKnownUnsupported(
|
|
54
|
+
endpoint: Endpoint,
|
|
55
|
+
behaviorType: ClusterBehavior.Type,
|
|
56
|
+
kind: "attributes" | "commands",
|
|
57
|
+
name: string,
|
|
58
|
+
): boolean {
|
|
59
|
+
const elements = endpoint.behaviors.elementsOf(behaviorType)[kind];
|
|
60
|
+
return elements.size > 0 && !elements.has(name);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Live (un-cached) attribute read via the interaction protocol. Used for `--remote` reads and for the numeric
|
|
65
|
+
* by-id command, which (like its legacy predecessor) must work for clusters the node has no behavior for.
|
|
66
|
+
*/
|
|
67
|
+
export async function readAttributesRemote(
|
|
68
|
+
node: ClientNode,
|
|
69
|
+
attributes: AttributePath[],
|
|
70
|
+
isFabricFiltered: boolean,
|
|
71
|
+
): Promise<ReadResult.AttributeValue[]> {
|
|
72
|
+
const values = new Array<ReadResult.AttributeValue>();
|
|
73
|
+
for await (const chunk of node.interaction.read(Read({ attributes, fabricFilter: isFabricFiltered }))) {
|
|
74
|
+
for await (const report of chunk) {
|
|
75
|
+
if (report.kind === "attr-value") {
|
|
76
|
+
values.push(report);
|
|
77
|
+
} else if (report.kind === "attr-status") {
|
|
78
|
+
// A device-declined read (e.g. UnsupportedAttribute/UnsupportedAccess) otherwise reads as empty.
|
|
79
|
+
const { path, status } = report;
|
|
80
|
+
console.log(
|
|
81
|
+
`ERROR: ${path.endpointId}/${path.clusterId}/${path.attributeId}: ${Status[status] ?? status}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return values;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Live event read via the interaction protocol (there is no local event cache to read from instead). */
|
|
90
|
+
export async function readEventsRemote(
|
|
91
|
+
node: ClientNode,
|
|
92
|
+
events: EventPath[],
|
|
93
|
+
isFabricFiltered: boolean,
|
|
94
|
+
): Promise<ReadResult.EventValue[]> {
|
|
95
|
+
const values = new Array<ReadResult.EventValue>();
|
|
96
|
+
for await (const chunk of node.interaction.read(Read({ events, fabricFilter: isFabricFiltered }))) {
|
|
97
|
+
for await (const report of chunk) {
|
|
98
|
+
if (report.kind === "event-value") {
|
|
99
|
+
values.push(report);
|
|
100
|
+
} else if (report.kind === "event-status") {
|
|
101
|
+
const { path, status } = report;
|
|
102
|
+
console.log(`ERROR: ${path.endpointId}/${path.clusterId}/${path.eventId}: ${Status[status] ?? status}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return values;
|
|
107
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2022-2026 Matter.js Authors
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Duration, ObserverGroup, Seconds, Time } from "@matter/general";
|
|
8
|
+
import { ClientNode } from "@matter/node";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_SEEDED_TIMEOUT = Seconds(60);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Wait (bounded) for a peer's endpoint structure to seed before reading it.
|
|
14
|
+
*
|
|
15
|
+
* Legacy `await node.events.initialized` blocked forever for an offline peer; the direct replacement
|
|
16
|
+
* `await node.lifecycle.seeded` inherits that hang. This bounds the wait: it returns `true` once the node is seeded
|
|
17
|
+
* and, on timeout, prints an "offline?" notice and returns `false` so the caller can abort the command instead of
|
|
18
|
+
* hanging.
|
|
19
|
+
*/
|
|
20
|
+
export async function awaitSeeded(
|
|
21
|
+
node: ClientNode,
|
|
22
|
+
{ timeout = DEFAULT_SEEDED_TIMEOUT, quiet = false }: { timeout?: Duration; quiet?: boolean } = {},
|
|
23
|
+
): Promise<boolean> {
|
|
24
|
+
if (node.lifecycle.isSeeded) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const observers = new ObserverGroup();
|
|
29
|
+
const sleep = Time.sleep("awaitSeeded", timeout);
|
|
30
|
+
try {
|
|
31
|
+
const seeded = new Promise<void>(resolve => observers.on(node.lifecycle.seeded, () => resolve()));
|
|
32
|
+
const isSeeded = await Promise.race([seeded.then(() => true), sleep.then(() => false)]);
|
|
33
|
+
if (!isSeeded && !quiet) {
|
|
34
|
+
console.log(
|
|
35
|
+
`Node ${node.peerAddress?.nodeId} did not become ready within ${Duration.format(timeout)} (offline?); giving up.`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return isSeeded;
|
|
39
|
+
} finally {
|
|
40
|
+
sleep.cancel();
|
|
41
|
+
observers.close();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2022-2026 Matter.js Authors
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Diagnostic, ObserverGroup } from "@matter/general";
|
|
8
|
+
import {
|
|
9
|
+
ChangeNotificationService,
|
|
10
|
+
ClientNode,
|
|
11
|
+
ClusterBehavior,
|
|
12
|
+
Endpoint,
|
|
13
|
+
NodeConnectionState,
|
|
14
|
+
ServerNode,
|
|
15
|
+
} from "@matter/node";
|
|
16
|
+
|
|
17
|
+
/** True if `endpoint` belongs to `node`'s endpoint tree (the node itself is its own root endpoint). */
|
|
18
|
+
function ownedBy(endpoint: Endpoint, node: Endpoint) {
|
|
19
|
+
for (let e: Endpoint | undefined = endpoint; e !== undefined; e = e.owner) {
|
|
20
|
+
if (e === node) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function connectionStateLabel(state: NodeConnectionState) {
|
|
28
|
+
switch (state) {
|
|
29
|
+
case NodeConnectionState.Connected:
|
|
30
|
+
return "connected";
|
|
31
|
+
case NodeConnectionState.Disconnected:
|
|
32
|
+
return "disconnected";
|
|
33
|
+
case NodeConnectionState.Reconnecting:
|
|
34
|
+
return "reconnecting";
|
|
35
|
+
case NodeConnectionState.WaitingForDeviceDiscovery:
|
|
36
|
+
return "waiting for device to be discovered again";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Wire node-wide diagnostic logging once for the controller and all its peers.
|
|
42
|
+
*
|
|
43
|
+
* Replaces the legacy per-connect `createDiagnosticCallbacks` (attribute/event/state callbacks passed into each
|
|
44
|
+
* `PairedNode.connect`), which the ClientNode API dropped. A single aggregate {@link ChangeNotificationService} stream
|
|
45
|
+
* covers attribute/event changes for every peer, and each peer's connection-state transitions are logged from its
|
|
46
|
+
* lifecycle. Registered handlers are owned by `observers` and torn down when it closes.
|
|
47
|
+
*/
|
|
48
|
+
export function installDiagnosticLogging(node: ServerNode, observers: ObserverGroup): void {
|
|
49
|
+
observers.on(node.env.get(ChangeNotificationService).change, change => {
|
|
50
|
+
const { endpoint } = change;
|
|
51
|
+
const peer = node.peers.commissioned.find(peer => ownedBy(endpoint, peer));
|
|
52
|
+
if (peer === undefined) {
|
|
53
|
+
return; // A change on the controller's own node, not a peer.
|
|
54
|
+
}
|
|
55
|
+
const nodeId = peer.peerAddress?.nodeId;
|
|
56
|
+
|
|
57
|
+
switch (change.kind) {
|
|
58
|
+
case "update": {
|
|
59
|
+
const { behavior, properties, version } = change;
|
|
60
|
+
if (!ClusterBehavior.is(behavior)) {
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
const state = endpoint.stateOf(behavior.id);
|
|
64
|
+
const changed =
|
|
65
|
+
properties === undefined ? state : Object.fromEntries(properties.map(name => [name, state[name]]));
|
|
66
|
+
console.log(
|
|
67
|
+
`Node ${nodeId}: Attribute ${endpoint.number}/${behavior.cluster.id} changed to ${Diagnostic.json(changed)} (version ${version})`,
|
|
68
|
+
);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case "event": {
|
|
72
|
+
const { behavior, event, number, timestamp, priority, payload } = change;
|
|
73
|
+
if (!ClusterBehavior.is(behavior)) {
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
console.log(
|
|
77
|
+
`Node ${nodeId}: Event ${endpoint.number}/${behavior.cluster.id}/${event.propertyName} (#${number}, priority ${priority}, at ${timestamp}) triggered with ${Diagnostic.json(payload)}`,
|
|
78
|
+
);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
case "delete": {
|
|
82
|
+
console.log(`Node ${nodeId}: Endpoint ${endpoint.number} removed`);
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Log each commissioned peer's connection-state transitions, torn down when the peer is removed.
|
|
89
|
+
// peers.added/deleted fire for every node in the container (including transient discovery and group nodes),
|
|
90
|
+
// so key the handlers by node to remove them on cull and skip nodes without a peer address.
|
|
91
|
+
const connectionHandlers = new Map<ClientNode, (state: NodeConnectionState) => void>();
|
|
92
|
+
const watchConnection = (peer: ClientNode) => {
|
|
93
|
+
if (connectionHandlers.has(peer)) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const handler = (state: NodeConnectionState) => {
|
|
97
|
+
const nodeId = peer.peerAddress?.nodeId;
|
|
98
|
+
if (nodeId !== undefined) {
|
|
99
|
+
console.log(`Node ${nodeId} ${connectionStateLabel(state)}`);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
connectionHandlers.set(peer, handler);
|
|
103
|
+
peer.lifecycle.connectionStateChanged.on(handler);
|
|
104
|
+
};
|
|
105
|
+
const unwatchConnection = (peer: ClientNode) => {
|
|
106
|
+
const handler = connectionHandlers.get(peer);
|
|
107
|
+
if (handler !== undefined) {
|
|
108
|
+
peer.lifecycle.connectionStateChanged.off(handler);
|
|
109
|
+
connectionHandlers.delete(peer);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
for (const peer of node.peers.commissioned) {
|
|
113
|
+
watchConnection(peer);
|
|
114
|
+
}
|
|
115
|
+
observers.on(node.peers.added, watchConnection);
|
|
116
|
+
observers.on(node.peers.deleted, unwatchConnection);
|
|
117
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2022-2026 Matter.js Authors
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
Environment,
|
|
9
|
+
isObject,
|
|
10
|
+
Logger,
|
|
11
|
+
StorageManager,
|
|
12
|
+
StorageService,
|
|
13
|
+
SupportedStorageTypes,
|
|
14
|
+
Time,
|
|
15
|
+
} from "@matter/general";
|
|
16
|
+
import { ClientNode, NetworkClient, RemoteDescriptor, ServerNode, ServerNodeStore } from "@matter/node";
|
|
17
|
+
import { DiscoveryData, OperationalAddress, PeerAddress } from "@matter/protocol";
|
|
18
|
+
import { EventNumber, FabricIndex, NodeId } from "@matter/types";
|
|
19
|
+
|
|
20
|
+
const logger = Logger.get("LegacyStorageMigration");
|
|
21
|
+
|
|
22
|
+
/** A pre-0.16 `credentials` entry (fabric config or CA key material); relocated verbatim, never decoded. */
|
|
23
|
+
type LegacyCredentialRecord = Record<string, SupportedStorageTypes>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A pre-0.16 `fabrics` entry; only `fabricIndex` is read here, the rest was relocated verbatim in step 1.
|
|
27
|
+
* Intersected with `Record<string, SupportedStorageTypes>` (rather than a plain interface) so the type still
|
|
28
|
+
* satisfies `StorageContext.get`'s `SupportedStorageTypes` constraint.
|
|
29
|
+
*/
|
|
30
|
+
type LegacyFabricRecord = Record<string, SupportedStorageTypes> & { fabricIndex: number };
|
|
31
|
+
|
|
32
|
+
/** Per-node metadata carried in a `commissionedNodes` list entry. `deviceData` is intentionally not migrated. */
|
|
33
|
+
type LegacyCommissionedEntry = Record<string, SupportedStorageTypes> & {
|
|
34
|
+
operationalServerAddress?: OperationalAddress;
|
|
35
|
+
discoveryData?: DiscoveryData;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** One `commissionedNodes` list entry: the peer's raw stored node id paired with its metadata. */
|
|
39
|
+
type LegacyCommissionedNode = [bigint, LegacyCommissionedEntry];
|
|
40
|
+
|
|
41
|
+
/** True for a pre-0.16 cached attribute record (current storage keeps the bare value instead). */
|
|
42
|
+
function isLegacyAttributeRecord(value: SupportedStorageTypes): value is Record<string, SupportedStorageTypes> {
|
|
43
|
+
return isObject(value) && "value" in value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function stepOneNeeded(mgr: StorageManager): Promise<boolean> {
|
|
47
|
+
const credentials = mgr.createContext("credentials");
|
|
48
|
+
if (!(await credentials.has("fabric"))) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
const fabrics = await mgr.createContext("fabrics").get<LegacyCredentialRecord[]>("fabrics", []);
|
|
52
|
+
return fabrics.length === 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function stepTwoNeeded(mgr: StorageManager): Promise<boolean> {
|
|
56
|
+
const nodes = mgr.createContext("nodes");
|
|
57
|
+
const commissioned = await nodes.get<SupportedStorageTypes[]>("commissionedNodes", []);
|
|
58
|
+
if (commissioned.length === 0) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
// Current-format storage keys each peer under its own subcontext of "nodes".
|
|
62
|
+
return (await nodes.contexts()).length === 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* True when a pre-0.16 controller storage still needs migrating to the current layout.
|
|
67
|
+
* See docs/MIGRATION_CONTROLLER_018.md.
|
|
68
|
+
*/
|
|
69
|
+
export async function legacyMigrationNeeded(env: Environment, id: string): Promise<boolean> {
|
|
70
|
+
const mgr = await env.get(StorageService).open(id);
|
|
71
|
+
try {
|
|
72
|
+
return (await stepOneNeeded(mgr)) || (await stepTwoNeeded(mgr));
|
|
73
|
+
} finally {
|
|
74
|
+
await closeStorage(mgr, id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Step 1: relayout fabric + certificate authority data from the legacy `credentials` context into the
|
|
80
|
+
* `fabrics`/`certificates` contexts the current controller reads. Must run before the controller ServerNode is
|
|
81
|
+
* constructed. Idempotent.
|
|
82
|
+
*/
|
|
83
|
+
export async function migrateLegacyControllerCredentials(env: Environment, id: string): Promise<void> {
|
|
84
|
+
const mgr = await env.get(StorageService).open(id);
|
|
85
|
+
try {
|
|
86
|
+
if (!(await stepOneNeeded(mgr))) {
|
|
87
|
+
logger.debug(`No legacy controller credentials to migrate for store ${id}`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const credentials = mgr.createContext("credentials");
|
|
92
|
+
const fabricStore = mgr.createContext("fabrics");
|
|
93
|
+
const certificates = mgr.createContext("certificates");
|
|
94
|
+
|
|
95
|
+
let fabric: LegacyCredentialRecord | undefined;
|
|
96
|
+
for (const key of await credentials.keys()) {
|
|
97
|
+
if (key === "fabric") {
|
|
98
|
+
fabric = await credentials.get<LegacyCredentialRecord>("fabric");
|
|
99
|
+
} else if (!(await certificates.has(key))) {
|
|
100
|
+
await certificates.set(key, await credentials.get<SupportedStorageTypes>(key));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Certificate authority keys must land in `certificates` before `fabrics.fabrics` is written: a crash
|
|
105
|
+
// between the two would otherwise strand the CA under the old layout while the guard reports "migrated".
|
|
106
|
+
if (fabric !== undefined) {
|
|
107
|
+
await fabricStore.set("fabrics", [fabric]);
|
|
108
|
+
}
|
|
109
|
+
logger.info(`Migrated legacy controller credentials for store ${id}`);
|
|
110
|
+
} finally {
|
|
111
|
+
await closeStorage(mgr, id);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Step 2: migrate cached per-node data (`node-<id>` attribute trees + the `commissionedNodes` list) into the
|
|
117
|
+
* current per-peer store layout, and register each peer. Must run after the controller ServerNode is constructed
|
|
118
|
+
* (so its fabric is loaded) but before it goes online. Non-destructive — old data is left in place for
|
|
119
|
+
* {@link cleanupLegacyStorage}. Idempotent (skips peers that already exist); a peer that fails to migrate is
|
|
120
|
+
* skipped without aborting the rest and its partially-written store is cleared. Returns migrated counts.
|
|
121
|
+
*/
|
|
122
|
+
export async function migrateLegacyCommissionedNodes(
|
|
123
|
+
node: ServerNode,
|
|
124
|
+
): Promise<{ nodes: number; endpoints: number; failed: number }> {
|
|
125
|
+
const serverStore = node.env.get(ServerNodeStore);
|
|
126
|
+
const baseStorage = serverStore.storage;
|
|
127
|
+
const nodesCtx = baseStorage.createContext("nodes");
|
|
128
|
+
|
|
129
|
+
// Per-peer idempotency is handled below; this only needs to know whether there is legacy data left to
|
|
130
|
+
// look at, so a resume after a partial run isn't skipped.
|
|
131
|
+
if (!(await nodesCtx.has("commissionedNodes"))) {
|
|
132
|
+
logger.debug("No former commissioned nodes to migrate.");
|
|
133
|
+
return { nodes: 0, endpoints: 0, failed: 0 };
|
|
134
|
+
}
|
|
135
|
+
const commissioned = await nodesCtx.get<LegacyCommissionedNode[]>("commissionedNodes", []);
|
|
136
|
+
|
|
137
|
+
// A migrated Era-B store carries exactly one fabric. Anything else (no fabric yet, or an unexpected
|
|
138
|
+
// multi-fabric store) cannot be addressed here; skip rather than abort the shell's startup. Report the
|
|
139
|
+
// skipped peers as failed so the caller does not treat this as a clean run and proceed to delete them.
|
|
140
|
+
const fabrics = await baseStorage.createContext("fabrics").get<LegacyFabricRecord[]>("fabrics", []);
|
|
141
|
+
if (fabrics.length !== 1) {
|
|
142
|
+
logger.warn(`Skipping legacy node migration: expected exactly one migrated fabric, found ${fabrics.length}`);
|
|
143
|
+
return { nodes: 0, endpoints: 0, failed: commissioned.length };
|
|
144
|
+
}
|
|
145
|
+
const fabricIndex = FabricIndex(fabrics[0].fabricIndex);
|
|
146
|
+
|
|
147
|
+
let migratedNodes = 0;
|
|
148
|
+
let migratedEndpoints = 0;
|
|
149
|
+
let failedNodes = 0;
|
|
150
|
+
|
|
151
|
+
for (const [rawNodeId, { operationalServerAddress, discoveryData }] of commissioned) {
|
|
152
|
+
const nodeId = NodeId(rawNodeId);
|
|
153
|
+
const peerAddress = PeerAddress({ fabricIndex, nodeId });
|
|
154
|
+
|
|
155
|
+
const existingPeer = node.peers.get(peerAddress);
|
|
156
|
+
if (existingPeer !== undefined) {
|
|
157
|
+
logger.debug(`Node ${nodeId} already migrated, skipping`);
|
|
158
|
+
if (existingPeer.stateOf(NetworkClient).autoSubscribe) {
|
|
159
|
+
await existingPeer.setStateOf(NetworkClient, { autoSubscribe: false });
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const id = serverStore.clientStores.allocateId();
|
|
165
|
+
let peerNode: ClientNode | undefined;
|
|
166
|
+
try {
|
|
167
|
+
const oldNode = baseStorage.createContext(`node-${nodeId}`);
|
|
168
|
+
|
|
169
|
+
const maxEventNumber = await oldNode.get<EventNumber>("__maxEventNumber__", EventNumber(0));
|
|
170
|
+
|
|
171
|
+
// Written before forAddress() below allocates the peer's store for the same id, which loads rather
|
|
172
|
+
// than overwrites whatever is already on disk under that context.
|
|
173
|
+
const peerStorage = nodesCtx.createContext(id).createContext("endpoints");
|
|
174
|
+
let endpointsForNode = 0;
|
|
175
|
+
for (const ep of await oldNode.contexts()) {
|
|
176
|
+
const oldEndpoint = oldNode.createContext(ep);
|
|
177
|
+
const newEndpoint = peerStorage.createContext(ep);
|
|
178
|
+
for (const cluster of await oldEndpoint.contexts()) {
|
|
179
|
+
const oldCluster = oldEndpoint.createContext(cluster);
|
|
180
|
+
const newCluster = newEndpoint.createContext(cluster);
|
|
181
|
+
for (const key of await oldCluster.keys()) {
|
|
182
|
+
const value = await oldCluster.get(key);
|
|
183
|
+
if (key === "__version__") {
|
|
184
|
+
await newCluster.set(key, value);
|
|
185
|
+
} else if (isLegacyAttributeRecord(value)) {
|
|
186
|
+
await newCluster.set(key, value.value);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
endpointsForNode++;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const commissioning = RemoteDescriptor.toLongForm({
|
|
194
|
+
// Fallback only — a peer with its own discoveredAt keeps it.
|
|
195
|
+
discoveredAt: Time.nowMs,
|
|
196
|
+
...discoveryData,
|
|
197
|
+
addresses: operationalServerAddress ? [operationalServerAddress] : [],
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
peerNode = await node.peers.forAddress(peerAddress, { id });
|
|
201
|
+
await peerNode.set({ commissioning, network: { maxEventNumber, autoSubscribe: false } });
|
|
202
|
+
migratedNodes++;
|
|
203
|
+
migratedEndpoints += endpointsForNode;
|
|
204
|
+
logger.info(`Migrated commissioned node ${nodeId} as ${id}`);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
failedNodes++;
|
|
207
|
+
logger.error(`Failed to migrate commissioned node ${nodeId} as ${id}, skipping:`, error);
|
|
208
|
+
if (peerNode !== undefined) {
|
|
209
|
+
await peerNode.delete();
|
|
210
|
+
}
|
|
211
|
+
await nodesCtx.createContext(id).clearAll();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { nodes: migratedNodes, endpoints: migratedEndpoints, failed: failedNodes };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Irreversible: once the legacy artifacts are deleted there is no way back, so this must only be run when the
|
|
220
|
+
* operator is certain migration succeeded and the store will not be downgraded below 0.16.
|
|
221
|
+
*
|
|
222
|
+
* Deletes all legacy (pre-0.16) storage artifacts once migration is no longer needed (the same
|
|
223
|
+
* `stepOneNeeded`/`stepTwoNeeded` predicate {@link legacyMigrationNeeded} uses). It does not verify that every
|
|
224
|
+
* individual peer migrated successfully: this is an explicit, user-confirmed action (e.g. the shell's
|
|
225
|
+
* `--cleanup-legacy-storage` flag). Running it against a store where step 1 or step 2 has not fully completed
|
|
226
|
+
* loses the un-migrated data; that is accepted, and is the caller's responsibility. Idempotent.
|
|
227
|
+
*/
|
|
228
|
+
export async function cleanupLegacyStorage(env: Environment, id: string): Promise<void> {
|
|
229
|
+
const mgr = await env.get(StorageService).open(id);
|
|
230
|
+
try {
|
|
231
|
+
if ((await stepOneNeeded(mgr)) || (await stepTwoNeeded(mgr))) {
|
|
232
|
+
logger.warn(`Refusing legacy cleanup for store ${id}: migration has not completed`);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
await wipeLegacyContexts(mgr);
|
|
236
|
+
logger.info(`Removed legacy storage artifacts for store ${id}`);
|
|
237
|
+
} finally {
|
|
238
|
+
await closeStorage(mgr, id);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Unconditionally remove the legacy (pre-0.16) storage artifacts, no migration-state guard.
|
|
244
|
+
*
|
|
245
|
+
* A factory reset clears the current-format fabric but not the legacy source; leaving it would let the next boot
|
|
246
|
+
* re-migrate and resurrect the identity/key material the reset destroyed. The reset path therefore wipes the
|
|
247
|
+
* legacy source too. Unlike {@link cleanupLegacyStorage} this makes no completeness assumption — the reset is
|
|
248
|
+
* discarding everything regardless.
|
|
249
|
+
*/
|
|
250
|
+
export async function eraseLegacyStorage(env: Environment, id: string): Promise<void> {
|
|
251
|
+
const mgr = await env.get(StorageService).open(id);
|
|
252
|
+
try {
|
|
253
|
+
await wipeLegacyContexts(mgr);
|
|
254
|
+
} finally {
|
|
255
|
+
await closeStorage(mgr, id);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function wipeLegacyContexts(mgr: StorageManager): Promise<void> {
|
|
260
|
+
for (const context of await mgr.driver.contexts([])) {
|
|
261
|
+
if (context.startsWith("node-")) {
|
|
262
|
+
await mgr.createContext(context).clearAll();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
await mgr.createContext("nodes").delete("commissionedNodes");
|
|
266
|
+
await mgr.createContext("credentials").clearAll();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function closeStorage(mgr: StorageManager, id: string): Promise<void> {
|
|
270
|
+
try {
|
|
271
|
+
await mgr.close();
|
|
272
|
+
} catch (closeError) {
|
|
273
|
+
logger.warn(`Error closing storage for store ${id}:`, closeError);
|
|
274
|
+
}
|
|
275
|
+
}
|