@matter-server/ws-controller 0.2.0-alpha.0-00000000-000000000

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +11 -0
  3. package/dist/esm/controller/AttributeDataCache.d.ts +49 -0
  4. package/dist/esm/controller/AttributeDataCache.d.ts.map +1 -0
  5. package/dist/esm/controller/AttributeDataCache.js +154 -0
  6. package/dist/esm/controller/AttributeDataCache.js.map +6 -0
  7. package/dist/esm/controller/ControllerCommandHandler.d.ts +118 -0
  8. package/dist/esm/controller/ControllerCommandHandler.d.ts.map +1 -0
  9. package/dist/esm/controller/ControllerCommandHandler.js +1015 -0
  10. package/dist/esm/controller/ControllerCommandHandler.js.map +6 -0
  11. package/dist/esm/controller/LegacyDataInjector.d.ts +95 -0
  12. package/dist/esm/controller/LegacyDataInjector.d.ts.map +1 -0
  13. package/dist/esm/controller/LegacyDataInjector.js +196 -0
  14. package/dist/esm/controller/LegacyDataInjector.js.map +6 -0
  15. package/dist/esm/controller/MatterController.d.ts +59 -0
  16. package/dist/esm/controller/MatterController.d.ts.map +1 -0
  17. package/dist/esm/controller/MatterController.js +212 -0
  18. package/dist/esm/controller/MatterController.js.map +6 -0
  19. package/dist/esm/controller/Nodes.d.ts +62 -0
  20. package/dist/esm/controller/Nodes.d.ts.map +1 -0
  21. package/dist/esm/controller/Nodes.js +85 -0
  22. package/dist/esm/controller/Nodes.js.map +6 -0
  23. package/dist/esm/controller/TestNodeCommandHandler.d.ts +84 -0
  24. package/dist/esm/controller/TestNodeCommandHandler.d.ts.map +1 -0
  25. package/dist/esm/controller/TestNodeCommandHandler.js +225 -0
  26. package/dist/esm/controller/TestNodeCommandHandler.js.map +6 -0
  27. package/dist/esm/data/VendorIDs.d.ts +7 -0
  28. package/dist/esm/data/VendorIDs.d.ts.map +1 -0
  29. package/dist/esm/data/VendorIDs.js +1237 -0
  30. package/dist/esm/data/VendorIDs.js.map +6 -0
  31. package/dist/esm/example/send-command.d.ts +7 -0
  32. package/dist/esm/example/send-command.d.ts.map +1 -0
  33. package/dist/esm/example/send-command.js +60 -0
  34. package/dist/esm/example/send-command.js.map +6 -0
  35. package/dist/esm/index.d.ts +21 -0
  36. package/dist/esm/index.d.ts.map +1 -0
  37. package/dist/esm/index.js +26 -0
  38. package/dist/esm/index.js.map +6 -0
  39. package/dist/esm/model/ModelMapper.d.ts +34 -0
  40. package/dist/esm/model/ModelMapper.d.ts.map +1 -0
  41. package/dist/esm/model/ModelMapper.js +62 -0
  42. package/dist/esm/model/ModelMapper.js.map +6 -0
  43. package/dist/esm/package.json +3 -0
  44. package/dist/esm/server/ConfigStorage.d.ts +29 -0
  45. package/dist/esm/server/ConfigStorage.d.ts.map +1 -0
  46. package/dist/esm/server/ConfigStorage.js +84 -0
  47. package/dist/esm/server/ConfigStorage.js.map +6 -0
  48. package/dist/esm/server/Converters.d.ts +53 -0
  49. package/dist/esm/server/Converters.d.ts.map +1 -0
  50. package/dist/esm/server/Converters.js +343 -0
  51. package/dist/esm/server/Converters.js.map +6 -0
  52. package/dist/esm/server/WebSocketControllerHandler.d.ts +21 -0
  53. package/dist/esm/server/WebSocketControllerHandler.d.ts.map +1 -0
  54. package/dist/esm/server/WebSocketControllerHandler.js +767 -0
  55. package/dist/esm/server/WebSocketControllerHandler.js.map +6 -0
  56. package/dist/esm/types/CommandHandler.d.ts +258 -0
  57. package/dist/esm/types/CommandHandler.d.ts.map +1 -0
  58. package/dist/esm/types/CommandHandler.js +6 -0
  59. package/dist/esm/types/CommandHandler.js.map +6 -0
  60. package/dist/esm/types/WebServer.d.ts +12 -0
  61. package/dist/esm/types/WebServer.d.ts.map +1 -0
  62. package/dist/esm/types/WebServer.js +6 -0
  63. package/dist/esm/types/WebServer.js.map +6 -0
  64. package/dist/esm/types/WebSocketMessageTypes.d.ts +478 -0
  65. package/dist/esm/types/WebSocketMessageTypes.d.ts.map +1 -0
  66. package/dist/esm/types/WebSocketMessageTypes.js +77 -0
  67. package/dist/esm/types/WebSocketMessageTypes.js.map +6 -0
  68. package/dist/esm/util/matterVersion.d.ts +12 -0
  69. package/dist/esm/util/matterVersion.d.ts.map +1 -0
  70. package/dist/esm/util/matterVersion.js +32 -0
  71. package/dist/esm/util/matterVersion.js.map +6 -0
  72. package/dist/esm/util/network.d.ts +14 -0
  73. package/dist/esm/util/network.d.ts.map +1 -0
  74. package/dist/esm/util/network.js +63 -0
  75. package/dist/esm/util/network.js.map +6 -0
  76. package/package.json +45 -0
  77. package/src/controller/AttributeDataCache.ts +194 -0
  78. package/src/controller/ControllerCommandHandler.ts +1256 -0
  79. package/src/controller/LegacyDataInjector.ts +314 -0
  80. package/src/controller/MatterController.ts +265 -0
  81. package/src/controller/Nodes.ts +115 -0
  82. package/src/controller/TestNodeCommandHandler.ts +305 -0
  83. package/src/data/VendorIDs.ts +1234 -0
  84. package/src/example/send-command.ts +82 -0
  85. package/src/index.ts +33 -0
  86. package/src/model/ModelMapper.ts +87 -0
  87. package/src/server/ConfigStorage.ts +112 -0
  88. package/src/server/Converters.ts +483 -0
  89. package/src/server/WebSocketControllerHandler.ts +917 -0
  90. package/src/tsconfig.json +7 -0
  91. package/src/types/CommandHandler.ts +270 -0
  92. package/src/types/WebServer.ts +14 -0
  93. package/src/types/WebSocketMessageTypes.ts +525 -0
  94. package/src/util/matterVersion.ts +45 -0
  95. package/src/util/network.ts +85 -0
@@ -0,0 +1,314 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { ImplementationError, MaybePromise, SupportedStorageTypes } from "@matter/general";
8
+ import {
9
+ Bytes,
10
+ Crypto,
11
+ FabricId,
12
+ FabricIndex,
13
+ isDeepEqual,
14
+ isObject,
15
+ Logger,
16
+ NodeId,
17
+ StorageContext,
18
+ StorageManager,
19
+ } from "@matter/main";
20
+ import { DescriptorCluster } from "@matter/main/clusters";
21
+ import { CertificateAuthority, Fabric, FabricBuilder, Noc } from "@matter/main/protocol";
22
+ import { VendorId } from "@matter/main/types";
23
+ import { ClusterMap } from "../model/ModelMapper.js";
24
+ import { convertWebSocketTagBasedToMatter } from "../server/Converters.js";
25
+
26
+ const logger = Logger.get("LegacyDataInjector");
27
+
28
+ /* eslint-disable regexp/no-unused-capturing-group */
29
+ const BASE64_REGEX = /^([0-9a-z+/]{4})*(([0-9a-z+/]{2}==)|([0-9a-z+/]{3}=))?$/i;
30
+
31
+ const FEATUREMAP_ID = DescriptorCluster.attributes.featureMap.id.toString();
32
+
33
+ /**
34
+ * Fabric configuration data extracted from chip.json.
35
+ * This is a partial representation of Fabric.SyncConfig from @matter/protocol.
36
+ *
37
+ * IMPORTANT: The controller's operational keypair is NOT available in chip.json.
38
+ *
39
+ * The Python CHIP SDK intentionally does not persist the operational private key to chip.json.
40
+ * When pychip_OpCreds_AllocateController is called without a keypair parameter, it generates
41
+ * an ephemeral P256 keypair, creates a NOC for it, but only stores the keypair in memory
42
+ * (see FabricTable.cpp:190 - "Operational Key is never saved to storage here").
43
+ *
44
+ * This means when migrating from Python Matter Server to matter.js:
45
+ * - The RCAC and ICAC can be reused (they define the fabric's CA chain)
46
+ * - The NOC must be REPLACED with a new one signed for a new keypair
47
+ * - A new operational keypair must be generated for the matter.js controller
48
+ * - The IPK and other fabric data can be preserved
49
+ *
50
+ * The ExampleOpCredsCAKey1/ICAKey1 in chip.json are the CA/ICA signing keys (for issuing
51
+ * certificates to devices), NOT the controller's operational identity key.
52
+ *
53
+ * Fields that need to be computed or provided when creating the Fabric:
54
+ * - keyPair: Must generate a new keypair and issue a new NOC
55
+ * - globalId: Computed from fabricId + rootPublicKey
56
+ * - operationalIdentityProtectionKey: Computed from identityProtectionKey + globalId
57
+ */
58
+ export interface LegacyFabricConfigData {
59
+ /** Fabric index (1, 2, etc.) */
60
+ fabricIndex: number;
61
+ /** Fabric ID from NOC certificate (can be number for small values, bigint for large) */
62
+ fabricId: number | bigint;
63
+ /** Node ID from NOC certificate (can be number for small values, bigint for large) */
64
+ nodeId: number | bigint;
65
+ /** Root node ID from RCAC certificate (can be number for small values, bigint for large) */
66
+ rootNodeId: number | bigint;
67
+ /** Root vendor ID from fabric metadata */
68
+ rootVendorId: number;
69
+ /** Root CA certificate (RCAC) as TLV bytes */
70
+ rootCert: Bytes;
71
+ /** Root CA public key extracted from RCAC */
72
+ rootPublicKey: Bytes;
73
+ /** Identity Protection Key from group key set 0 */
74
+ identityProtectionKey: Bytes;
75
+ /** Intermediate CA certificate (ICAC) as TLV bytes, if present */
76
+ intermediateCACert?: Bytes;
77
+ /** Node Operational Certificate (NOC) as TLV bytes */
78
+ operationalCert: Bytes;
79
+ /** Fabric label */
80
+ label: string;
81
+ }
82
+
83
+ /** Vendor info from Python Matter Server */
84
+ export interface LegacyVendorInfo {
85
+ vendor_id: number;
86
+ vendor_name: string;
87
+ company_legal_name: string;
88
+ company_preferred_name: string;
89
+ vendor_landing_page_url: string;
90
+ creator: string;
91
+ }
92
+
93
+ /** Node data from Python Matter Server nodes map */
94
+ export interface LegacyNodeData {
95
+ node_id: number;
96
+ date_commissioned: string;
97
+ last_interview: string;
98
+ interview_version: number;
99
+ available: boolean;
100
+ is_bridge: boolean;
101
+ attributes: Record<string, unknown>;
102
+ attribute_subscriptions: unknown[];
103
+ }
104
+
105
+ /** Structure of the <compressedFabricId>.json file */
106
+ export interface LegacyServerFile {
107
+ vendor_info: Record<string, LegacyVendorInfo>;
108
+ last_node_id: number;
109
+ nodes: Record<string, LegacyNodeData>;
110
+ }
111
+
112
+ export type CertificateAuthorityConfiguration = CertificateAuthority.Configuration;
113
+
114
+ export interface LegacyServerData {
115
+ credentials?: CertificateAuthority.Configuration;
116
+ fabric?: LegacyFabricConfigData;
117
+ nodeData?: LegacyServerFile;
118
+ vendorId: number;
119
+ fabricId?: number | bigint;
120
+ }
121
+
122
+ export namespace LegacyDataInjector {
123
+ function isPrimitiveType(value: unknown) {
124
+ return (
125
+ typeof value === "number" ||
126
+ value === null ||
127
+ typeof value == "boolean" ||
128
+ (typeof value == "string" && !BASE64_REGEX.test(value))
129
+ );
130
+ }
131
+
132
+ export async function injectCredentials(
133
+ credentialsStorage: StorageContext,
134
+ crypto: Crypto,
135
+ credentialData: CertificateAuthority.Configuration,
136
+ fabricData?: LegacyFabricConfigData,
137
+ ) {
138
+ const rootCertificateAuthority = new CertificateAuthority(crypto, credentialData);
139
+
140
+ for (const [key, value] of Object.entries(credentialData)) {
141
+ if (await credentialsStorage.has(key)) {
142
+ if (!isDeepEqual(await credentialsStorage.get(key), value)) {
143
+ continue;
144
+ }
145
+ logger.warn(`Overriding credential ${key} with new value!`);
146
+ }
147
+ await credentialsStorage.set(key, value);
148
+ }
149
+
150
+ if (fabricData === undefined) {
151
+ logger.warn("Credentials injected, but no fabric data provided. Skipping fabric initialization.");
152
+ return;
153
+ }
154
+
155
+ const {
156
+ fabricIndex,
157
+ fabricId,
158
+ nodeId,
159
+ rootNodeId,
160
+ rootCert,
161
+ intermediateCACert,
162
+ operationalCert,
163
+ rootVendorId,
164
+ rootPublicKey,
165
+ identityProtectionKey,
166
+ label,
167
+ } = fabricData;
168
+
169
+ const tempFabric = await Fabric.create(crypto, {
170
+ fabricIndex: FabricIndex(fabricIndex),
171
+ fabricId: FabricId(fabricId),
172
+ nodeId: NodeId(nodeId),
173
+ rootNodeId: NodeId(rootNodeId),
174
+ rootCert,
175
+ intermediateCACert,
176
+ operationalCert,
177
+ rootVendorId: VendorId(rootVendorId),
178
+ rootPublicKey: rootPublicKey,
179
+ identityProtectionKey: identityProtectionKey,
180
+ label: label,
181
+ keyPair: await crypto.createKeyPair(), // Just use a new keypair temporarily because chip data does not have it
182
+ });
183
+
184
+ if (await credentialsStorage.has("fabric")) {
185
+ const storedFabric = await credentialsStorage.get<Fabric.Config>("fabric");
186
+ if (!Bytes.areEqual(storedFabric.rootPublicKey!, tempFabric.rootPublicKey)) {
187
+ logger.warn("Existing fabric root public key changed. Rewriting from legacy data");
188
+ } else {
189
+ logger.info("Fabric root public key unchanged. Skipping rewrite.");
190
+ return;
191
+ }
192
+ }
193
+
194
+ const builder = await FabricBuilder.create(crypto);
195
+ builder.initializeFromFabricForUpdate(tempFabric);
196
+ const {
197
+ subject: { nodeId: certNodeId, fabricId: certFabricId },
198
+ } = Noc.fromTlv(tempFabric.operationalCert).cert;
199
+ if (certNodeId !== nodeId || certFabricId !== fabricId) {
200
+ throw new ImplementationError(`Cannot rotate NOC for fabric because root node ID changed`);
201
+ }
202
+ await builder.setOperationalCert(
203
+ await rootCertificateAuthority.generateNoc(builder.publicKey, certFabricId, certNodeId),
204
+ tempFabric.intermediateCACert,
205
+ );
206
+ const rootFabric = await builder.build(tempFabric.fabricIndex);
207
+
208
+ await credentialsStorage.set("fabric", rootFabric.config);
209
+ }
210
+
211
+ export async function injectNodeData(baseStorage: StorageManager, nodeData?: LegacyServerFile) {
212
+ const nodesListStorage = baseStorage.createContext("nodes");
213
+
214
+ if (nodeData === undefined) {
215
+ if (!(await nodesListStorage.has("commissionedNodes"))) {
216
+ await nodesListStorage.set("commissionedNodes", []);
217
+ }
218
+ return false;
219
+ }
220
+
221
+ const commissionedNodes = new Array<[NodeId, any]>();
222
+ let injectedNodes = 0;
223
+
224
+ for (const [nodeId, nodeDetails] of Object.entries(nodeData.nodes)) {
225
+ const nodeStorage = baseStorage.createContext(`node-${nodeId}`);
226
+ if (nodeId !== nodeDetails.node_id.toString()) {
227
+ logger.warn(`Node ID mismatch in node data: ${nodeId} != ${nodeDetails.node_id}`);
228
+ }
229
+ commissionedNodes.push([NodeId(BigInt(nodeId)), {}]);
230
+ let newNode = true;
231
+ logger.info(`Injecting node ${nodeId} into storage`);
232
+ const nodeWrites = new Array<MaybePromise<void>>();
233
+ for (const [attributeKey, value] of Object.entries(nodeDetails.attributes)) {
234
+ let currentEndpointId: string | undefined;
235
+ let currentClusterId: string | undefined;
236
+ let endpointStorage: StorageContext | undefined;
237
+ let clusterStorage: StorageContext | undefined;
238
+ const [endpointId, clusterId, attributeId] = attributeKey.split("/");
239
+ if (currentEndpointId !== endpointId) {
240
+ endpointStorage = nodeStorage.createContext(endpointId);
241
+ currentEndpointId = endpointId;
242
+ currentClusterId = undefined;
243
+ }
244
+ if (currentClusterId !== clusterId) {
245
+ clusterStorage = endpointStorage!.createContext(clusterId);
246
+ currentClusterId = clusterId;
247
+ if (newNode) {
248
+ if (await clusterStorage.has("__version__")) {
249
+ logger.info(`Node ${nodeId} already exists. Skipping injection.`);
250
+ break;
251
+ }
252
+ newNode = false;
253
+ injectedNodes++;
254
+ }
255
+ nodeWrites.push(clusterStorage.set("__version__", 1));
256
+ }
257
+
258
+ const clusterModel = ClusterMap[clusterId];
259
+ const model = clusterModel?.attributes?.[attributeId];
260
+ if (clusterModel === undefined || model === undefined) {
261
+ if (attributeId === FEATUREMAP_ID) {
262
+ logger.debug(
263
+ `Node ${nodeId}: Attribute ${attributeKey}, unknown featuremap converted to empty featuremap`,
264
+ );
265
+ nodeWrites.push(clusterStorage!.set(attributeId, { value: {} } as SupportedStorageTypes));
266
+ } else if (isPrimitiveType(value) || (Array.isArray(value) && value.every(isPrimitiveType))) {
267
+ logger.debug(
268
+ `Node ${nodeId}: Attribute ${attributeKey}, unknown primary type converted generically`,
269
+ value,
270
+ );
271
+ nodeWrites.push(clusterStorage!.set(attributeId, { value } as SupportedStorageTypes));
272
+ } else {
273
+ logger.info(
274
+ `Node ${nodeId}: Attribute ${attributeKey}, not found in and unclear value. Skipping injection.`,
275
+ value,
276
+ );
277
+ }
278
+ } else {
279
+ const convertedValue =
280
+ isObject(value) && ("TLVValue" in value || "Reason" in value)
281
+ ? undefined
282
+ : convertWebSocketTagBasedToMatter(value, model, clusterModel.model);
283
+ if (convertedValue !== undefined) {
284
+ logger.debug(
285
+ `Node ${nodeId}: Converted attribute ${attributeKey}:`,
286
+ value,
287
+ "->",
288
+ convertedValue,
289
+ );
290
+ nodeWrites.push(
291
+ clusterStorage!.set(attributeId, { value: convertedValue } as SupportedStorageTypes),
292
+ );
293
+ } else {
294
+ logger.info(`Attribute ${attributeKey} could not be converted. Skipping injection.`);
295
+ }
296
+ }
297
+ }
298
+ await Promise.allSettled(nodeWrites);
299
+ nodeWrites.length = 0;
300
+ }
301
+
302
+ if (injectedNodes > 0) {
303
+ const knownNodes = await nodesListStorage.get<[bigint | number, any][]>("commissionedNodes", []);
304
+ for (const [nodeId] of commissionedNodes) {
305
+ if (!knownNodes.find(([knownNodeId]) => knownNodeId === nodeId)) {
306
+ knownNodes.push([nodeId, {}]);
307
+ }
308
+ }
309
+ await nodesListStorage.set("commissionedNodes", knownNodes);
310
+ }
311
+
312
+ return injectedNodes > 0;
313
+ }
314
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { SharedEnvironmentServices, Timestamp } from "@matter/general";
8
+ import {
9
+ Bytes,
10
+ CommissioningClient,
11
+ Crypto,
12
+ Environment,
13
+ FabricId,
14
+ GlobalFabricId,
15
+ Logger,
16
+ NodeId,
17
+ SoftwareUpdateManager,
18
+ } from "@matter/main";
19
+ import { DclCertificateService, DclOtaUpdateService, DclVendorInfoService, VendorInfo } from "@matter/main/protocol";
20
+ import { VendorId } from "@matter/main/types";
21
+ import { CommissioningController } from "@project-chip/matter.js";
22
+ import { Readable } from "node:stream";
23
+ import { ConfigStorage } from "../server/ConfigStorage.js";
24
+ import { ControllerCommandHandler } from "./ControllerCommandHandler.js";
25
+ import { LegacyDataInjector, LegacyServerData } from "./LegacyDataInjector.js";
26
+
27
+ const logger = Logger.get("MatterController");
28
+
29
+ export async function computeCompressedNodeId(
30
+ crypto: Crypto,
31
+ fabricId: number | bigint,
32
+ caKey: Bytes,
33
+ ): Promise<string> {
34
+ return (await GlobalFabricId.compute(crypto, FabricId(fabricId), caKey)).toString();
35
+ }
36
+
37
+ export interface MatterControllerOptions {
38
+ enableTestNetDcl?: boolean;
39
+ disableOtaProvider?: boolean;
40
+ }
41
+
42
+ // Storage ID used for the Matter server
43
+ const MATTER_SERVER_ID = "server";
44
+
45
+ export class MatterController {
46
+ #env: Environment;
47
+ #services?: SharedEnvironmentServices;
48
+ #controllerInstance?: CommissioningController;
49
+ #commandHandler?: ControllerCommandHandler;
50
+ #config: ConfigStorage;
51
+ #legacyCommissionedDates?: Map<string, Timestamp>;
52
+ #enableTestNetDcl = false;
53
+ #disableOtaProvider = true;
54
+
55
+ static async create(
56
+ environment: Environment,
57
+ config: ConfigStorage,
58
+ options: MatterControllerOptions,
59
+ legacyData?: LegacyServerData,
60
+ ) {
61
+ const instance = new MatterController(environment, config, options);
62
+
63
+ const commissionedDates = new Map<string, Timestamp>();
64
+ if (legacyData !== undefined) {
65
+ const crypto = environment.get(Crypto);
66
+ const baseStorage = await config.service.open(MATTER_SERVER_ID);
67
+ if (legacyData.credentials && legacyData.fabricId) {
68
+ await LegacyDataInjector.injectCredentials(
69
+ baseStorage.createContext("credentials"),
70
+ crypto,
71
+ legacyData.credentials,
72
+ legacyData.fabric,
73
+ );
74
+ }
75
+ if (
76
+ (await LegacyDataInjector.injectNodeData(baseStorage, legacyData.nodeData)) &&
77
+ legacyData.nodeData !== undefined
78
+ ) {
79
+ for (const [nodeIdStr, data] of Object.entries(legacyData.nodeData.nodes)) {
80
+ const { date_commissioned: commissionedAt } = data;
81
+ commissionedDates.set(nodeIdStr, Timestamp(new Date(commissionedAt).getTime()));
82
+ }
83
+ }
84
+ await baseStorage.close();
85
+ }
86
+
87
+ await instance.initialize(legacyData?.vendorId, legacyData?.fabricId, commissionedDates);
88
+ return instance;
89
+ }
90
+
91
+ constructor(environment: Environment, config: ConfigStorage, options: MatterControllerOptions) {
92
+ this.#env = environment;
93
+ this.#config = config;
94
+ this.#enableTestNetDcl = options.enableTestNetDcl ?? this.#enableTestNetDcl;
95
+ this.#disableOtaProvider = options.disableOtaProvider ?? this.#disableOtaProvider;
96
+ }
97
+
98
+ protected async initialize(
99
+ vendorId?: number,
100
+ fabricId?: number | bigint,
101
+ legacyCommissionedDates?: Map<string, Timestamp>,
102
+ ) {
103
+ this.#legacyCommissionedDates = legacyCommissionedDates?.size ? legacyCommissionedDates : undefined;
104
+ this.#controllerInstance = new CommissioningController({
105
+ environment: {
106
+ environment: this.#env,
107
+ id: MATTER_SERVER_ID,
108
+ },
109
+ autoConnect: false, // Do not auto-connect to the commissioned nodes
110
+ adminFabricLabel: this.#config.fabricLabel,
111
+ adminVendorId: vendorId !== undefined ? VendorId(vendorId) : undefined,
112
+ adminFabricId: fabricId !== undefined ? FabricId(fabricId) : undefined,
113
+ enableOtaProvider: !this.#disableOtaProvider,
114
+ });
115
+
116
+ // Start loading and initialization of meta data
117
+ /* eslint-disable @typescript-eslint/no-unused-expressions */
118
+ this.vendorInfoService;
119
+ /* eslint-disable @typescript-eslint/no-unused-expressions */
120
+ this.certificateService;
121
+ }
122
+
123
+ get commandHandler() {
124
+ if (this.#controllerInstance === undefined) {
125
+ throw new Error("Controller not initialized");
126
+ }
127
+ if (this.#commandHandler === undefined) {
128
+ this.#commandHandler = new ControllerCommandHandler(
129
+ this.#controllerInstance,
130
+ this.#env.vars.get("ble.enable", false),
131
+ !this.#disableOtaProvider,
132
+ );
133
+
134
+ this.#commandHandler.events.started.once(async () => {
135
+ if (this.#legacyCommissionedDates !== undefined) {
136
+ await this.injectCommissionedDates();
137
+ }
138
+
139
+ if (!this.#disableOtaProvider && this.#enableTestNetDcl) {
140
+ await this.#enableTestOtaImages();
141
+ }
142
+ });
143
+ }
144
+
145
+ return this.#commandHandler;
146
+ }
147
+
148
+ /**
149
+ * Get the shared environment services instance.
150
+ */
151
+ get services(): SharedEnvironmentServices {
152
+ if (this.#services === undefined) {
153
+ this.#services = this.#env.asDependent();
154
+ }
155
+ return this.#services;
156
+ }
157
+
158
+ /**
159
+ * Get the DCL vendor info service instance.
160
+ * Lazily initializes the service if not already present.
161
+ */
162
+ get vendorInfoService(): DclVendorInfoService {
163
+ if (!this.#env.has(DclVendorInfoService)) {
164
+ new DclVendorInfoService(this.#env);
165
+ }
166
+ return this.services.get(DclVendorInfoService);
167
+ }
168
+
169
+ /**
170
+ * Get the DCL certificate service instance
171
+ * Lazily initializes the service if not already present.
172
+ */
173
+ get certificateService() {
174
+ if (!this.#env.has(DclCertificateService)) {
175
+ new DclCertificateService(this.#env, { fetchTestCertificates: this.#enableTestNetDcl });
176
+ }
177
+ return this.services.get(DclCertificateService);
178
+ }
179
+
180
+ /**
181
+ * Get vendor information by vendor ID.
182
+ * Returns undefined if the vendor is not found.
183
+ */
184
+ async getVendorInfo(vendorId: number): Promise<VendorInfo | undefined> {
185
+ await this.vendorInfoService.construction;
186
+ return this.vendorInfoService.infoFor(vendorId);
187
+ }
188
+
189
+ /**
190
+ * Get all vendor information from the DCL service.
191
+ */
192
+ async getAllVendors(): Promise<ReadonlyMap<number, VendorInfo>> {
193
+ await this.vendorInfoService.construction;
194
+ return this.vendorInfoService.vendors;
195
+ }
196
+
197
+ async injectCommissionedDates() {
198
+ if (this.#controllerInstance === undefined || this.#legacyCommissionedDates === undefined) {
199
+ return;
200
+ }
201
+ for (const [nodeIdStr, commissionedAt] of this.#legacyCommissionedDates) {
202
+ try {
203
+ const peerAddress = this.#controllerInstance.fabric.addressOf(NodeId(BigInt(nodeIdStr)));
204
+ const node = await this.#controllerInstance.node.peers.forAddress(peerAddress);
205
+ const commissioningState = node.maybeStateOf(CommissioningClient);
206
+ if (commissioningState !== undefined && commissioningState.commissionedAt === undefined) {
207
+ await node.setStateOf(CommissioningClient, { commissionedAt });
208
+ logger.info(`Injected commissioned date for node ${nodeIdStr}`);
209
+ }
210
+ } catch (error) {
211
+ logger.warn(`Error injecting commissioned date for node ${nodeIdStr}`, error);
212
+ }
213
+ }
214
+ }
215
+
216
+ async stop() {
217
+ await this.#commandHandler?.close(); // This closes also the controller instance if started
218
+ await this.#services?.close();
219
+ }
220
+
221
+ /**
222
+ * Enable test OTA images (test-net DCL).
223
+ * Must be called after the controller is started.
224
+ */
225
+ async #enableTestOtaImages() {
226
+ if (this.#controllerInstance === undefined) {
227
+ throw new Error("Controller not initialized");
228
+ }
229
+ await this.#controllerInstance.otaProvider.setStateOf(SoftwareUpdateManager, { allowTestOtaImages: true });
230
+ logger.info("Enabled test OTA images (test-net DCL)");
231
+ }
232
+
233
+ /**
234
+ * Store an OTA image file from a file path.
235
+ * @param filePath - Path to the OTA file
236
+ * @returns true if stored successfully
237
+ */
238
+ async storeOtaImageFromFile(filePath: string): Promise<boolean> {
239
+ const { createReadStream } = await import("node:fs");
240
+ const { pathToFileURL } = await import("node:url");
241
+ const otaService = this.services.get(DclOtaUpdateService);
242
+
243
+ // Convert file path to file:// URL for the OTA service
244
+ const fileUrl = pathToFileURL(filePath).href;
245
+
246
+ // Read the file twice - once for info, once for storage
247
+ const infoStream = Readable.toWeb(createReadStream(filePath)) as ReadableStream<Uint8Array>;
248
+ const updateInfo = await otaService.updateInfoFromStream(infoStream, fileUrl);
249
+
250
+ logger.info(
251
+ `Storing OTA image from ${filePath}: vendorId=0x${updateInfo.vid.toString(16)}, productId=0x${updateInfo.pid.toString(16)}, version=${updateInfo.softwareVersion} (${updateInfo.softwareVersionString})`,
252
+ );
253
+
254
+ const storeStream = Readable.toWeb(createReadStream(filePath)) as ReadableStream<Uint8Array>;
255
+ await otaService.store(storeStream, updateInfo, false);
256
+ return true;
257
+ }
258
+
259
+ /**
260
+ * Close the services when shutting down.
261
+ */
262
+ async closeServices() {
263
+ await this.#services?.close();
264
+ }
265
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { NodeId } from "@matter/main";
8
+ import { ClusterClientObj } from "@matter/main/protocol";
9
+ import { ClusterId, ClusterType, EndpointNumber } from "@matter/main/types";
10
+ import { InteractionClient } from "@project-chip/matter.js/cluster";
11
+ import { PairedNode } from "@project-chip/matter.js/device";
12
+ import { ServerError } from "../types/WebSocketMessageTypes.js";
13
+ import { AttributeDataCache } from "./AttributeDataCache.js";
14
+
15
+ /**
16
+ * Manages node storage and provides access to nodes and their clients.
17
+ *
18
+ * This class handles:
19
+ * - Storage of PairedNode instances
20
+ * - Node retrieval and existence checking
21
+ * - Access to interaction clients and cluster clients
22
+ * - Attribute data caching
23
+ */
24
+ export class Nodes {
25
+ #nodes = new Map<NodeId, PairedNode>();
26
+ #attributeCache = new AttributeDataCache();
27
+
28
+ /**
29
+ * Get the attribute cache instance.
30
+ */
31
+ get attributeCache(): AttributeDataCache {
32
+ return this.#attributeCache;
33
+ }
34
+
35
+ /**
36
+ * Get all node IDs.
37
+ */
38
+ getIds(): NodeId[] {
39
+ return Array.from(this.#nodes.keys());
40
+ }
41
+
42
+ /**
43
+ * Get a node by ID.
44
+ * @throws ServerError if node not found
45
+ */
46
+ get(nodeId: NodeId): PairedNode {
47
+ const node = this.#nodes.get(nodeId);
48
+ if (node === undefined) {
49
+ throw ServerError.nodeNotExists(nodeId);
50
+ }
51
+ return node;
52
+ }
53
+
54
+ /**
55
+ * Check if a node exists.
56
+ */
57
+ has(nodeId: NodeId): boolean {
58
+ return this.#nodes.has(nodeId);
59
+ }
60
+
61
+ /**
62
+ * Add or update a node in storage.
63
+ */
64
+ set(nodeId: NodeId, node: PairedNode): void {
65
+ this.#nodes.set(nodeId, node);
66
+ }
67
+
68
+ /**
69
+ * Remove a node from storage and clear its attribute cache.
70
+ */
71
+ delete(nodeId: NodeId): void {
72
+ this.#nodes.delete(nodeId);
73
+ this.#attributeCache.delete(nodeId);
74
+ }
75
+
76
+ /**
77
+ * Get the interaction client for a node.
78
+ */
79
+ interactionClientFor(nodeId: NodeId): Promise<InteractionClient> {
80
+ return this.get(nodeId).getInteractionClient();
81
+ }
82
+
83
+ /**
84
+ * Get a cluster client by cluster ID for a specific endpoint on a node.
85
+ * @throws Error if endpoint or cluster not found
86
+ */
87
+ clusterClientByIdFor(nodeId: NodeId, endpointId: EndpointNumber, clusterId: ClusterId): ClusterClientObj<any> {
88
+ const node = this.get(nodeId);
89
+
90
+ const endpoint = endpointId === 0 ? node.getRootEndpoint() : node.getDeviceById(endpointId);
91
+
92
+ if (endpoint === undefined) {
93
+ throw new Error(`Endpoint ${endpointId} on node ${nodeId} not found`);
94
+ }
95
+
96
+ const client = endpoint.getClusterClientById(clusterId);
97
+
98
+ if (client === undefined) {
99
+ throw new Error(`Cluster ${clusterId} on endpoint ${endpointId} on node ${nodeId} not found`);
100
+ }
101
+
102
+ return client;
103
+ }
104
+
105
+ /**
106
+ * Get a typed cluster client for a specific endpoint on a node.
107
+ */
108
+ clusterClientFor<const T extends ClusterType>(
109
+ nodeId: NodeId,
110
+ endpointId: EndpointNumber,
111
+ cluster: T,
112
+ ): ClusterClientObj<T> {
113
+ return this.clusterClientByIdFor(nodeId, endpointId, cluster.id) as ClusterClientObj<T>;
114
+ }
115
+ }