@matter-server/ws-controller 0.6.8 → 0.7.0-alpha.0-20260516-8ee9631

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 (40) hide show
  1. package/dist/esm/controller/ControllerCommandHandler.d.ts +12 -1
  2. package/dist/esm/controller/ControllerCommandHandler.d.ts.map +1 -1
  3. package/dist/esm/controller/ControllerCommandHandler.js +115 -34
  4. package/dist/esm/controller/ControllerCommandHandler.js.map +1 -1
  5. package/dist/esm/controller/MatterController.d.ts +8 -3
  6. package/dist/esm/controller/MatterController.d.ts.map +1 -1
  7. package/dist/esm/controller/MatterController.js +46 -2
  8. package/dist/esm/controller/MatterController.js.map +1 -1
  9. package/dist/esm/controller/Nodes.d.ts +7 -61
  10. package/dist/esm/controller/Nodes.d.ts.map +1 -1
  11. package/dist/esm/controller/Nodes.js +13 -77
  12. package/dist/esm/controller/Nodes.js.map +1 -1
  13. package/dist/esm/controller/WebRtcCallbackBridge.d.ts +9 -0
  14. package/dist/esm/controller/WebRtcCallbackBridge.d.ts.map +1 -0
  15. package/dist/esm/controller/WebRtcCallbackBridge.js +75 -0
  16. package/dist/esm/controller/WebRtcCallbackBridge.js.map +6 -0
  17. package/dist/esm/controller/behaviors/WebRtcTransportRequestorServer.d.ts +35 -0
  18. package/dist/esm/controller/behaviors/WebRtcTransportRequestorServer.d.ts.map +1 -0
  19. package/dist/esm/controller/behaviors/WebRtcTransportRequestorServer.js +123 -0
  20. package/dist/esm/controller/behaviors/WebRtcTransportRequestorServer.js.map +6 -0
  21. package/dist/esm/index.d.ts +1 -0
  22. package/dist/esm/index.d.ts.map +1 -1
  23. package/dist/esm/index.js +1 -0
  24. package/dist/esm/index.js.map +1 -1
  25. package/dist/esm/server/ConfigStorage.d.ts +2 -0
  26. package/dist/esm/server/ConfigStorage.d.ts.map +1 -1
  27. package/dist/esm/server/ConfigStorage.js +18 -0
  28. package/dist/esm/server/ConfigStorage.js.map +1 -1
  29. package/dist/esm/server/WebSocketControllerHandler.d.ts.map +1 -1
  30. package/dist/esm/server/WebSocketControllerHandler.js +44 -0
  31. package/dist/esm/server/WebSocketControllerHandler.js.map +1 -1
  32. package/package.json +22 -24
  33. package/src/controller/ControllerCommandHandler.ts +141 -54
  34. package/src/controller/MatterController.ts +56 -3
  35. package/src/controller/Nodes.ts +13 -78
  36. package/src/controller/WebRtcCallbackBridge.ts +79 -0
  37. package/src/controller/behaviors/WebRtcTransportRequestorServer.ts +149 -0
  38. package/src/index.ts +1 -0
  39. package/src/server/ConfigStorage.ts +20 -0
  40. package/src/server/WebSocketControllerHandler.ts +57 -0
@@ -4,6 +4,7 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
6
 
7
+ import type { WebRtcCallbackData } from "@matter-server/ws-client";
7
8
  import {
8
9
  Abort,
9
10
  AsyncObservable,
@@ -21,7 +22,6 @@ import {
21
22
  Observable,
22
23
  Seconds,
23
24
  ServerAddress,
24
- ServerAddressUdp,
25
25
  SoftwareUpdateInfo,
26
26
  SoftwareUpdateManager,
27
27
  Time,
@@ -37,6 +37,8 @@ import {
37
37
  GeneralCommissioning,
38
38
  OperationalCredentials,
39
39
  } from "@matter/main/clusters";
40
+ import { WebRtcTransportDefinitions } from "@matter/main/clusters/web-rtc-transport-definitions";
41
+ import { WebRtcTransportProvider } from "@matter/main/clusters/web-rtc-transport-provider";
40
42
  import {
41
43
  DecodedAttributeReportValue,
42
44
  DecodedEventReportValue,
@@ -44,7 +46,6 @@ import {
44
46
  PeerAddress,
45
47
  Read,
46
48
  Specifier,
47
- SupportedTransportsSchema,
48
49
  PeerSet,
49
50
  } from "@matter/main/protocol";
50
51
  import {
@@ -61,6 +62,8 @@ import {
61
62
  StatusResponseError,
62
63
  VendorId,
63
64
  } from "@matter/main/types";
65
+ import { Endpoint } from "@matter/node";
66
+ import { CameraControllerDevice } from "@matter/node/devices/camera-controller";
64
67
  import { CommissioningController, NodeCommissioningOptions } from "@project-chip/matter.js";
65
68
  import { NodeStates } from "@project-chip/matter.js/device";
66
69
  import { ClusterMap, ClusterMapEntry, GlobalAttributes } from "../model/ModelMapper.js";
@@ -98,12 +101,14 @@ import {
98
101
  } from "../types/WebSocketMessageTypes.js";
99
102
  import { formatNodeId } from "../util/formatNodeId.js";
100
103
  import { pingIp } from "../util/network.js";
104
+ import { WebRtcTransportRequestorServer } from "./behaviors/WebRtcTransportRequestorServer.js";
101
105
  import { CustomClusterPoller } from "./CustomClusterPoller.js";
102
106
  import { Nodes } from "./Nodes.js";
107
+ import { attachWebRtcCallbackBridge } from "./WebRtcCallbackBridge.js";
103
108
 
104
109
  const logger = Logger.get("ControllerCommandHandler");
105
110
 
106
- /** After this duration in Reconnecting state, declare the node unavailable */
111
+ /** Grace period after leaving Connected before a node is declared unavailable. */
107
112
  const RECONNECT_TIMEOUT = Minutes(3);
108
113
 
109
114
  /**
@@ -173,6 +178,7 @@ export class ControllerCommandHandler {
173
178
  nodeDecommissioned: new Observable<[nodeId: NodeId]>(),
174
179
  nodeEndpointAdded: new Observable<[nodeId: NodeId, endpointId: EndpointNumber]>(),
175
180
  nodeEndpointRemoved: new Observable<[nodeId: NodeId, endpointId: EndpointNumber]>(),
181
+ webRtcCallback: new Observable<[WebRtcCallbackData]>(),
176
182
  };
177
183
  #peers?: PeerSet;
178
184
 
@@ -240,6 +246,7 @@ export class ControllerCommandHandler {
240
246
  }
241
247
 
242
248
  await this.events.started.emit();
249
+ await this.#setupWebRtcCallbackBridge();
243
250
  }
244
251
 
245
252
  /**
@@ -284,6 +291,88 @@ export class ControllerCommandHandler {
284
291
  }
285
292
  }
286
293
 
294
+ async #setupWebRtcCallbackBridge() {
295
+ try {
296
+ await this.#cameraControllerEndpoint().act(agent => {
297
+ attachWebRtcCallbackBridge(agent.get(WebRtcTransportRequestorServer).events, data =>
298
+ this.events.webRtcCallback.emit(data),
299
+ );
300
+ });
301
+ logger.info("WebRTC callback bridge wired");
302
+ } catch (error) {
303
+ logger.warn("Failed to setup WebRTC callback bridge:", error);
304
+ }
305
+ }
306
+
307
+ #cameraControllerEndpoint(): Endpoint<typeof CameraControllerDevice> {
308
+ return this.#controller.node.endpoints.for("camera-controller") as Endpoint<typeof CameraControllerDevice>;
309
+ }
310
+
311
+ /** `originatingEndpointId` is server-injected; any client-supplied value in `payload` is overwritten. */
312
+ async sendWebRtcProviderCommand(args: {
313
+ nodeId: NodeId;
314
+ endpointId: EndpointNumber;
315
+ commandName: "ProvideOffer" | "SolicitOffer";
316
+ payload: Record<string, unknown>;
317
+ }): Promise<WebRtcTransportProvider.ProvideOfferResponse | WebRtcTransportProvider.SolicitOfferResponse> {
318
+ const { nodeId, endpointId, commandName, payload } = args;
319
+
320
+ if (commandName !== "ProvideOffer" && commandName !== "SolicitOffer") {
321
+ throw ServerError.invalidArguments(
322
+ `Unsupported WebRTC provider command "${commandName}"; expected ProvideOffer or SolicitOffer`,
323
+ );
324
+ }
325
+
326
+ const requestorEndpoint = this.#cameraControllerEndpoint();
327
+ const originatingEndpointId = EndpointNumber(requestorEndpoint.number);
328
+ const fabricIndex = this.#controller.fabric.fabricIndex;
329
+
330
+ const node = this.#nodes.get(nodeId);
331
+
332
+ const fields: Record<string, unknown> = {
333
+ ...payload,
334
+ originatingEndpointId,
335
+ };
336
+ const command = commandName === "ProvideOffer" ? "provideOffer" : "solicitOffer";
337
+
338
+ const response = (await this.#invokeCommand(node.node, {
339
+ endpoint: endpointId,
340
+ cluster: WebRtcTransportProvider,
341
+ command,
342
+ fields,
343
+ })) as WebRtcTransportProvider.ProvideOfferResponse | WebRtcTransportProvider.SolicitOfferResponse | undefined;
344
+
345
+ if (response === undefined || typeof response.webRtcSessionId !== "number") {
346
+ throw ServerError.sdkStackError(
347
+ `${commandName} did not return a WebRTCSessionID for node ${this.formatNode(nodeId)}`,
348
+ );
349
+ }
350
+
351
+ const streamUsage = payload.streamUsage as WebRtcTransportDefinitions.WebRtcSession["streamUsage"];
352
+ const metadataEnabled = (payload.metadataEnabled as boolean | undefined) ?? false;
353
+ const videoStreams = response.videoStreamId != null ? [response.videoStreamId] : new Array<number>();
354
+ const audioStreams = response.audioStreamId != null ? [response.audioStreamId] : new Array<number>();
355
+ const session: WebRtcTransportDefinitions.WebRtcSession = {
356
+ id: response.webRtcSessionId,
357
+ peerNodeId: nodeId,
358
+ peerEndpointId: endpointId,
359
+ streamUsage,
360
+ metadataEnabled,
361
+ videoStreams,
362
+ audioStreams,
363
+ fabricIndex,
364
+ };
365
+
366
+ logger.info(
367
+ `upserting WebRTC session id=${session.id} peerNodeId=${nodeId} peerEndpointId=${endpointId} fabricIndex=${fabricIndex} streamUsage=${streamUsage} originatingEndpointId=${originatingEndpointId}`,
368
+ );
369
+ await requestorEndpoint.act(agent => {
370
+ agent.get(WebRtcTransportRequestorServer).upsertSession(session);
371
+ });
372
+
373
+ return response;
374
+ }
375
+
287
376
  async close() {
288
377
  for (const timer of this.#reconnectTimers.values()) {
289
378
  timer.stop();
@@ -300,16 +389,12 @@ export class ControllerCommandHandler {
300
389
  const node = await this.#controller.getNode(nodeId);
301
390
  const attributeCache = this.#nodes.attributeCache;
302
391
 
303
- // Wire all Events to the Event emitters
304
- // Track if a BasicInformation or BridgedDeviceBasicInformation attribute changed during
305
- // a subscription batch. When the batch ends (connectionAlive), emit a full node_updated.
392
+ // Defer the full node_updated until the subscription batch ends (connectionAlive)
393
+ // so consumers see one update per batch rather than one per attribute.
306
394
  let basicInfoChangedInBatch = false;
307
395
  node.events.attributeChanged.on(data => {
308
- // Update the attribute cache with the new value in WebSocket format
309
396
  attributeCache.updateAttribute(nodeId, data);
310
- // Then emit the event for listeners
311
397
  this.events.attributeChanged.emit(nodeId, data);
312
- // Mark for full node_updated if any BasicInformation cluster attribute changed
313
398
  if (FULL_UPDATE_CLUSTER_IDS.has(data.path.clusterId)) {
314
399
  basicInfoChangedInBatch = true;
315
400
  }
@@ -323,7 +408,6 @@ export class ControllerCommandHandler {
323
408
  });
324
409
  node.events.eventTriggered.on(data => this.events.eventChanged.emit(nodeId, data));
325
410
  node.events.stateChanged.on(state => {
326
- // Only refresh cache on Connected state
327
411
  if (state === NodeStates.Connected) {
328
412
  attributeCache.update(node);
329
413
  const attributes = attributeCache.get(nodeId);
@@ -332,35 +416,35 @@ export class ControllerCommandHandler {
332
416
  }
333
417
  }
334
418
 
335
- // Manage reconnect timer
336
- if (state === NodeStates.Reconnecting) {
337
- if (!this.#reconnectTimers.has(nodeId)) {
338
- const timer = Time.getTimer(`reconnect-timeout-${nodeId}`, RECONNECT_TIMEOUT, () => {
339
- this.#reconnectTimers.delete(nodeId);
340
- if (this.#nodes.forceUnavailable(nodeId)) {
341
- logger.info(
342
- `Node ${this.formatNode(nodeId)} still reconnecting after timeout, marking unavailable`,
343
- );
344
- this.events.nodeAvailabilityChanged.emit(nodeId, false);
345
- }
346
- });
347
- timer.utility = true;
348
- timer.start();
349
- this.#reconnectTimers.set(nodeId, timer);
350
- }
351
- } else {
352
- // Any non-Reconnecting state clears the timer
419
+ // Arm on Connected->Reconnecting only; keep running across later
420
+ // non-Connected states; cancel on return to Connected.
421
+ if (state === NodeStates.Connected) {
353
422
  this.#reconnectTimers.get(nodeId)?.stop();
354
423
  this.#reconnectTimers.delete(nodeId);
424
+ } else if (
425
+ state === NodeStates.Reconnecting &&
426
+ !this.#reconnectTimers.has(nodeId) &&
427
+ this.#nodes.isAvailable(nodeId)
428
+ ) {
429
+ const timer = Time.getTimer(`reconnect-timeout-${nodeId}`, RECONNECT_TIMEOUT, () => {
430
+ this.#reconnectTimers.delete(nodeId);
431
+ if (this.#nodes.forceUnavailable(nodeId)) {
432
+ logger.info(
433
+ `Node ${this.formatNode(nodeId)} offline grace period expired, marking unavailable`,
434
+ );
435
+ this.events.nodeAvailabilityChanged.emit(nodeId, false);
436
+ }
437
+ });
438
+ timer.utility = true;
439
+ timer.start();
440
+ this.#reconnectTimers.set(nodeId, timer);
355
441
  }
356
442
 
357
- // Process state change and check if availability changed
358
- const result = this.#nodes.processStateChange(nodeId, state);
443
+ const debouncePending = this.#reconnectTimers.has(nodeId);
444
+ const result = this.#nodes.processStateChange(nodeId, state, debouncePending);
359
445
 
360
- // Emit state changed event
361
446
  this.events.nodeStateChanged.emit(nodeId, state);
362
447
 
363
- // Emit availability changed if it actually changed
364
448
  if (result.availabilityChanged) {
365
449
  logger.info(
366
450
  `Node ${this.formatNode(nodeId)} availability changed to ${result.available} (state: ${NodeStates[state]})`,
@@ -369,13 +453,10 @@ export class ControllerCommandHandler {
369
453
  }
370
454
  });
371
455
  node.events.structureChanged.on(() => {
372
- // Structure changed means endpoints may have been added/removed, refresh cache
373
456
  if (node.isConnected) {
374
457
  attributeCache.update(node);
375
458
  }
376
459
  basicInfoChangedInBatch = false;
377
- // Emit node_updated first so consumers see the new endpoint in node.endpoints,
378
- // then drain any endpoint_added events queued since the previous structure change.
379
460
  this.events.nodeStructureChanged.emit(nodeId);
380
461
  for (const endpointId of this.#nodes.drainPendingEndpointAdds(nodeId)) {
381
462
  this.events.nodeEndpointAdded.emit(nodeId, endpointId);
@@ -388,15 +469,12 @@ export class ControllerCommandHandler {
388
469
  node.events.nodeEndpointAdded.on(endpointId => this.#nodes.queueEndpointAdded(nodeId, endpointId));
389
470
  node.events.nodeEndpointRemoved.on(endpointId => this.events.nodeEndpointRemoved.emit(nodeId, endpointId));
390
471
 
391
- // Store the node for direct access
392
472
  this.#nodes.set(nodeId, node);
393
473
 
394
474
  this.#nodes.seedState(nodeId, node.connectionState);
395
475
 
396
- // Initialize attribute cache if node is already initialized
397
476
  if (node.initialized) {
398
477
  attributeCache.add(node);
399
- // Register for custom cluster polling (e.g., Eve energy)
400
478
  const attributes = attributeCache.get(nodeId);
401
479
  if (attributes) {
402
480
  this.#customClusterPoller.registerNode(this.#peerOf(nodeId), attributes);
@@ -659,7 +737,8 @@ export class ControllerCommandHandler {
659
737
 
660
738
  value = convertWebSocketTagBasedToMatter(value, attributeModel, clusterEntry.model);
661
739
 
662
- logger.info("Writing attribute", attributeId, "with value", value);
740
+ const attributeName = attributeModel.propertyName;
741
+ logger.info(`Writing attribute ${clusterId}.${attributeName} (${clusterId}.${attributeId}) with value`, value);
663
742
  const { status, clusterStatus } = await this.#writeAttribute(
664
743
  nodeId,
665
744
  endpointId,
@@ -675,12 +754,11 @@ export class ControllerCommandHandler {
675
754
  request: Invoke.ConcreteCommandRequest<C>,
676
755
  options: Omit<Invoke.Definition, "commands"> = {},
677
756
  ) {
678
- for await (const data of node.interaction.invoke(
679
- Invoke({
680
- commands: [request],
681
- ...options,
682
- }),
683
- )) {
757
+ const invoke = Invoke({
758
+ commands: [request],
759
+ ...options,
760
+ });
761
+ for await (const data of node.interaction.invoke(invoke)) {
684
762
  for (const entry of data) {
685
763
  // We send only one command, so we only get one response back
686
764
  switch (entry.kind) {
@@ -827,6 +905,17 @@ export class ControllerCommandHandler {
827
905
  regulatoryCountryCode: "XX",
828
906
  wifiNetwork,
829
907
  threadNetwork,
908
+ onAttestationFailure: findings => {
909
+ let accept = true;
910
+ for (const f of findings) {
911
+ if (f.level === "error") {
912
+ accept = false;
913
+ }
914
+ logger.info(`Attestation finding (${f.level}):`, f.type, f.message);
915
+ }
916
+ logger.info(`Attestation ${accept ? "accepted" : "rejected"}`);
917
+ return accept;
918
+ },
830
919
  },
831
920
  discovery: {
832
921
  knownAddress,
@@ -901,12 +990,12 @@ export class ControllerCommandHandler {
901
990
  return [];
902
991
  }
903
992
  return [latestDiscovery].map(({ DT, DN, CM, D, RI, PH, PI, T, VP, deviceIdentifier, addresses, SII, SAI }) => {
904
- const { tcpClient: supportsTcpClient, tcpServer: supportsTcpServer } = SupportedTransportsSchema.decode(
905
- T ?? 0,
906
- );
993
+ const supportsTcpClient = T?.tcpClient ?? false;
994
+ const supportsTcpServer = T?.tcpServer ?? false;
907
995
  const vendorId = VP === undefined ? -1 : VP.includes("+") ? parseInt(VP.split("+")[0]) : parseInt(VP);
908
996
  const productId = VP === undefined ? -1 : VP.includes("+") ? parseInt(VP.split("+")[1]) : -1;
909
- const port = addresses.length ? (addresses[0] as ServerAddressUdp).port : 0;
997
+ const firstAddress = addresses[0];
998
+ const port = firstAddress && ServerAddress.isIp(firstAddress) ? firstAddress.port : 0;
910
999
  const numIPs = addresses.length;
911
1000
  return {
912
1001
  commissioningMode: CM,
@@ -926,7 +1015,7 @@ export class ControllerCommandHandler {
926
1015
  vendorId,
927
1016
  supportsTcpServer,
928
1017
  supportsTcpClient,
929
- addresses: (addresses.filter(({ type }) => type === "udp") as ServerAddressUdp[]).map(({ ip }) => ip),
1018
+ addresses: addresses.filter(ServerAddress.isIp).map(({ ip }) => ip),
930
1019
  mrpSessionIdleInterval: SII,
931
1020
  mrpSessionActiveInterval: SAI,
932
1021
  };
@@ -948,7 +1037,7 @@ export class ControllerCommandHandler {
948
1037
  }
949
1038
  }
950
1039
  if (peer) {
951
- const sessionIp = peer.newestSession?.channel.networkAddress?.ip;
1040
+ const sessionIp = peer.newestSession()?.channel.networkAddress?.ip;
952
1041
  if (sessionIp) {
953
1042
  addresses.add(sessionIp);
954
1043
  }
@@ -958,9 +1047,7 @@ export class ControllerCommandHandler {
958
1047
  const node = this.#nodes.get(nodeId);
959
1048
  const commissioningAddresses = node.node.maybeStateOf(CommissioningClient)?.addresses;
960
1049
  if (commissioningAddresses !== undefined && commissioningAddresses.length > 0) {
961
- const fallbackAddresses = commissioningAddresses
962
- .filter((addr): addr is ServerAddressUdp => addr.type === "udp")
963
- .map(addr => addr.ip);
1050
+ const fallbackAddresses = commissioningAddresses.filter(ServerAddress.isIp).map(addr => addr.ip);
964
1051
  for (const address of fallbackAddresses) {
965
1052
  addresses.add(address);
966
1053
  }
@@ -4,6 +4,7 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
6
 
7
+ import { cdSigners, paaRoots, vendors } from "@matter/dcl-data/node";
7
8
  import {
8
9
  Bytes,
9
10
  CommissioningClient,
@@ -18,11 +19,14 @@ import {
18
19
  SoftwareUpdateManager,
19
20
  Timestamp,
20
21
  } from "@matter/main";
21
- import { VendorInfo } from "@matter/main/protocol";
22
+ import { VendorInfo, DclCertificateService, DclVendorInfoService } from "@matter/main/protocol";
22
23
  import { VendorId } from "@matter/main/types";
24
+ import { Endpoint } from "@matter/node";
25
+ import { CameraControllerDevice } from "@matter/node/devices/camera-controller";
23
26
  import { CommissioningController } from "@project-chip/matter.js";
24
27
  import { Readable } from "node:stream";
25
28
  import { ConfigStorage } from "../server/ConfigStorage.js";
29
+ import { WebRtcTransportRequestorServer } from "./behaviors/WebRtcTransportRequestorServer.js";
26
30
  import { BorderRouterDiscovery } from "./BorderRouterDiscovery.js";
27
31
  import { ControllerCommandHandler } from "./ControllerCommandHandler.js";
28
32
  import { LegacyDataInjector, LegacyServerData } from "./LegacyDataInjector.js";
@@ -62,6 +66,8 @@ export async function computeCompressedNodeId(
62
66
  export interface MatterControllerOptions {
63
67
  enableTestNetDcl?: boolean;
64
68
  disableOtaProvider?: boolean;
69
+ /** Disable bundled offline DCL seed data (PAA roots, CD signers, vendors). When true, only network DCL is used. */
70
+ disableDclSeed?: boolean;
65
71
  /** Server ID for storage. Default is "server", but may be "server-<hex(fabricId)>-<hex(vendorId)>" for multi-fabric support */
66
72
  serverId?: string;
67
73
  /** Server version string (e.g., "0.2.10" or "0.2.10-alpha.0"). Used for BasicInformation cluster. */
@@ -95,7 +101,9 @@ export class MatterController {
95
101
  #legacyCommissionedDates?: Map<string, Timestamp>;
96
102
  #enableTestNetDcl = false;
97
103
  #disableOtaProvider = true;
104
+ #disableDclSeed = false;
98
105
  readonly #borderRouterDiscovery: BorderRouterDiscovery;
106
+ #webRtcRequestor?: Endpoint<typeof CameraControllerDevice>;
99
107
 
100
108
  static async create(
101
109
  environment: Environment,
@@ -173,6 +181,7 @@ export class MatterController {
173
181
  this.#serverVersion = options.serverVersion ?? "0.0.0";
174
182
  this.#enableTestNetDcl = options.enableTestNetDcl ?? this.#enableTestNetDcl;
175
183
  this.#disableOtaProvider = options.disableOtaProvider ?? this.#disableOtaProvider;
184
+ this.#disableDclSeed = options.disableDclSeed ?? this.#disableDclSeed;
176
185
  }
177
186
 
178
187
  protected async initialize(
@@ -181,6 +190,24 @@ export class MatterController {
181
190
  legacyCommissionedDates?: Map<string, Timestamp>,
182
191
  ) {
183
192
  this.#legacyCommissionedDates = legacyCommissionedDates?.size ? legacyCommissionedDates : undefined;
193
+
194
+ // Register DCL services on the root environment; DclBehavior picks them up.
195
+ // When seeding is enabled (default), pre-populate from the bundled offline snapshot so
196
+ // commissioning works without internet access.
197
+ const includeTest = this.#enableTestNetDcl;
198
+ if (this.#disableDclSeed) {
199
+ new DclCertificateService(this.#env.root, { fetchTestCertificates: includeTest });
200
+ } else {
201
+ new DclCertificateService(this.#env.root, {
202
+ fetchTestCertificates: includeTest,
203
+ seed: {
204
+ paaRoots: paaRoots({ includeTest }),
205
+ cdSigners: cdSigners({ includeTest }),
206
+ },
207
+ });
208
+ new DclVendorInfoService(this.#env.root, { seed: { vendors: vendors({ includeTest }) } });
209
+ }
210
+
184
211
  this.#controllerInstance = new CommissioningController({
185
212
  environment: {
186
213
  environment: this.#env,
@@ -192,6 +219,8 @@ export class MatterController {
192
219
  adminFabricId: fabricId !== undefined ? FabricId(fabricId) : undefined,
193
220
  rootNodeId: NodeId(112233), // TODO Remove when we switch to random IDs
194
221
  enableOtaProvider: !this.#disableOtaProvider,
222
+ tcp: true,
223
+ transportPreference: "tcp",
195
224
  basicInformation: {
196
225
  vendorName: "Open Home Foundation",
197
226
  productName: "OHF Matter Server",
@@ -216,7 +245,8 @@ export class MatterController {
216
245
  );
217
246
 
218
247
  this.#commandHandler.events.started.once(async () => {
219
- this.#controllerInstance!.node.behaviors.require(DclBehavior, {
248
+ this.#controllerInstance!.node.behaviors.require(DclBehavior);
249
+ await this.#controllerInstance!.node.setStateOf(DclBehavior, {
220
250
  fetchTestCertificates: this.#enableTestNetDcl,
221
251
  });
222
252
 
@@ -228,13 +258,14 @@ export class MatterController {
228
258
 
229
259
  // Start loading and initialization of meta data
230
260
  initPromises.push(this.vendorInfoService());
231
- initPromises.push(this.certificateService());
261
+ // initPromises.push(this.certificateService()); // postponed to commissioning needs
232
262
 
233
263
  if (!this.#disableOtaProvider && this.#enableTestNetDcl) {
234
264
  initPromises.push(this.#enableTestOtaImages());
235
265
  }
236
266
 
237
267
  initPromises.push(this.#borderRouterDiscovery.start());
268
+ initPromises.push(this.#enableWebRtcRequestor());
238
269
 
239
270
  try {
240
271
  await MatterAggregateError.allSettled(initPromises);
@@ -251,6 +282,27 @@ export class MatterController {
251
282
  return this.#borderRouterDiscovery;
252
283
  }
253
284
 
285
+ get webRtcRequestor(): Endpoint<typeof CameraControllerDevice> {
286
+ if (!this.#webRtcRequestor) {
287
+ throw new Error("WebRTC requestor endpoint not initialized");
288
+ }
289
+ return this.#webRtcRequestor;
290
+ }
291
+
292
+ async #enableWebRtcRequestor(): Promise<void> {
293
+ if (!this.#controllerInstance) {
294
+ throw new Error("Controller not started");
295
+ }
296
+ const node = this.#controllerInstance.node;
297
+ if (node.endpoints.has("camera-controller")) {
298
+ this.#webRtcRequestor = node.endpoints.for("camera-controller") as Endpoint<typeof CameraControllerDevice>;
299
+ return;
300
+ }
301
+ this.#webRtcRequestor = await node.add(
302
+ new Endpoint(CameraControllerDevice.with(WebRtcTransportRequestorServer), { id: "camera-controller" }),
303
+ );
304
+ }
305
+
254
306
  /**
255
307
  * Get the DCL vendor info service instance.
256
308
  * Lazily initializes the service if not already present.
@@ -324,6 +376,7 @@ export class MatterController {
324
376
  }
325
377
 
326
378
  async stop() {
379
+ await this.certificateService(); // Ensure it was initialized so that shutdown works
327
380
  await this.#borderRouterDiscovery.stop();
328
381
  await this.#commandHandler?.close(); // This closes also the controller instance if started
329
382
  }
@@ -22,36 +22,24 @@ import { AttributeDataCache } from "./AttributeDataCache.js";
22
22
  export class Nodes {
23
23
  #nodes = new Map<NodeId, PairedNode>();
24
24
  #attributeCache = new AttributeDataCache();
25
- /** Track previous connection state for availability debouncing */
26
- #previousStates = new Map<NodeId, NodeStates>();
27
- /** Cached availability so serialization and event paths always agree */
25
+ /** Cached so serialization and event paths always agree on availability. */
28
26
  #lastAvailability = new Map<NodeId, boolean>();
29
27
  /**
30
- * Endpoint additions queued until the next nodeStructureChanged for that node.
31
- * Preserves the wire contract used by python-matter-server: endpoint_added must
32
- * arrive AFTER a node_updated that already carries the new endpoint, so consumers
33
- * (e.g., Home Assistant) can resolve node.endpoints[endpoint_id] in their callback.
28
+ * Buffered endpoint_added events. python-matter-server wire contract requires
29
+ * node_updated (carrying the new endpoint) to arrive before endpoint_added,
30
+ * so HA can resolve node.endpoints[endpoint_id] in its callback.
34
31
  */
35
32
  #pendingEndpointAdds = new Map<NodeId, EndpointNumber[]>();
36
33
 
37
- /**
38
- * Get the attribute cache instance.
39
- */
40
34
  get attributeCache(): AttributeDataCache {
41
35
  return this.#attributeCache;
42
36
  }
43
37
 
44
- /**
45
- * Get all node IDs.
46
- */
47
38
  getIds(): NodeId[] {
48
39
  return Array.from(this.#nodes.keys());
49
40
  }
50
41
 
51
- /**
52
- * Get a node by ID.
53
- * @throws ServerError if node not found
54
- */
42
+ /** @throws ServerError if node not found */
55
43
  get(nodeId: NodeId): PairedNode {
56
44
  const node = this.#nodes.get(nodeId);
57
45
  if (node === undefined) {
@@ -60,34 +48,21 @@ export class Nodes {
60
48
  return node;
61
49
  }
62
50
 
63
- /**
64
- * Check if a node exists.
65
- */
66
51
  has(nodeId: NodeId): boolean {
67
52
  return this.#nodes.has(nodeId);
68
53
  }
69
54
 
70
- /**
71
- * Add or update a node in storage.
72
- */
73
55
  set(nodeId: NodeId, node: PairedNode): void {
74
56
  this.#nodes.set(nodeId, node);
75
57
  }
76
58
 
77
- /**
78
- * Remove a node from storage and clear its attribute cache and state tracking.
79
- */
80
59
  delete(nodeId: NodeId): void {
81
60
  this.#nodes.delete(nodeId);
82
61
  this.#attributeCache.delete(nodeId);
83
- this.#previousStates.delete(nodeId);
84
62
  this.#lastAvailability.delete(nodeId);
85
63
  this.#pendingEndpointAdds.delete(nodeId);
86
64
  }
87
65
 
88
- /**
89
- * Buffer an endpoint_added until the next nodeStructureChanged for that node.
90
- */
91
66
  queueEndpointAdded(nodeId: NodeId, endpointId: EndpointNumber): void {
92
67
  let queue = this.#pendingEndpointAdds.get(nodeId);
93
68
  if (queue === undefined) {
@@ -97,10 +72,7 @@ export class Nodes {
97
72
  queue.push(endpointId);
98
73
  }
99
74
 
100
- /**
101
- * Take ownership of buffered endpoint additions for a node and clear the queue.
102
- * Returned array is in insertion order; an empty array is returned if nothing is queued.
103
- */
75
+ /** Returns insertion-ordered queue; empty if nothing pending. */
104
76
  drainPendingEndpointAdds(nodeId: NodeId): EndpointNumber[] {
105
77
  const queue = this.#pendingEndpointAdds.get(nodeId);
106
78
  if (queue === undefined || queue.length === 0) {
@@ -110,30 +82,19 @@ export class Nodes {
110
82
  return queue;
111
83
  }
112
84
 
113
- /**
114
- * Initialize state tracking for a newly paired/discovered node.
115
- * Sets previous state and initial availability so the first stateChanged event
116
- * has a real previous state instead of undefined.
117
- */
118
85
  seedState(nodeId: NodeId, initialState: NodeStates): void {
119
- this.#previousStates.set(nodeId, initialState);
120
86
  this.#lastAvailability.set(nodeId, initialState === NodeStates.Connected);
121
87
  }
122
88
 
123
- /**
124
- * Process a state change for a node. Reads previous state BEFORE updating,
125
- * computes new availability, and updates both tracking maps atomically.
126
- * Returns whether availability changed and the new value.
127
- */
89
+ /** `debouncePending` = reconnect timer armed by caller; keeps non-Connected states available. */
128
90
  processStateChange(
129
91
  nodeId: NodeId,
130
92
  newState: NodeStates,
93
+ debouncePending: boolean,
131
94
  ): { availabilityChanged: true; available: boolean } | { availabilityChanged: false } {
132
- const previousState = this.#previousStates.get(nodeId);
133
95
  const wasAvailable = this.#lastAvailability.get(nodeId) ?? false;
134
- const available = this.isNodeAvailable(newState, previousState);
96
+ const available = this.isNodeAvailable(newState, debouncePending);
135
97
 
136
- this.#previousStates.set(nodeId, newState);
137
98
  this.#lastAvailability.set(nodeId, available);
138
99
 
139
100
  if (wasAvailable !== available) {
@@ -142,47 +103,21 @@ export class Nodes {
142
103
  return { availabilityChanged: false };
143
104
  }
144
105
 
145
- /**
146
- * Force a node to unavailable state. Used by reconnect timeout
147
- * when debounce period expires without reconnection.
148
- * Only updates #lastAvailability — #previousStates is left as-is because
149
- * processStateChange() will overwrite it on the next real state transition.
150
- * Returns true if the node was previously considered available.
151
- */
106
+ /** Returns true if the node was previously considered available. */
152
107
  forceUnavailable(nodeId: NodeId): boolean {
153
108
  const wasAvailable = this.#lastAvailability.get(nodeId) ?? false;
154
109
  this.#lastAvailability.set(nodeId, false);
155
110
  return wasAvailable;
156
111
  }
157
112
 
158
- /**
159
- * Determine if a node should be considered available based on its connection state.
160
- * Uses debouncing logic similar to Python Matter Server:
161
- * - Connected: available
162
- * - Reconnecting when previously Connected: still available (debouncing)
163
- * - WaitingForDeviceDiscovery or Disconnected: unavailable
164
- *
165
- * @param currentState Current connection state
166
- * @param previousState Previous connection state (undefined if first state change)
167
- * @returns true if node should be considered available
168
- */
169
- isNodeAvailable(currentState: NodeStates, previousState?: NodeStates): boolean {
113
+ isNodeAvailable(currentState: NodeStates, debouncePending = false): boolean {
170
114
  if (currentState === NodeStates.Connected) {
171
115
  return true;
172
116
  }
173
- // Debounce: if transitioning from Connected to Reconnecting, still consider available
174
- if (currentState === NodeStates.Reconnecting && previousState === NodeStates.Connected) {
175
- return true;
176
- }
177
- // WaitingForDeviceDiscovery, Disconnected, or Reconnecting from non-connected state
178
- return false;
117
+ return debouncePending;
179
118
  }
180
119
 
181
- /**
182
- * Check if a node is available. Returns the cached availability value set by
183
- * seedState/processStateChange/forceUnavailable, avoiding the race where
184
- * recomputing from live state disagrees with the event path's determination.
185
- */
120
+ /** Returns the cached value, not a recomputation — avoids disagreement with the event path. */
186
121
  isAvailable(nodeId: NodeId): boolean {
187
122
  return this.#lastAvailability.get(nodeId) ?? false;
188
123
  }