@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
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type { WebRtcCallbackData } from "@matter-server/ws-client";
8
+ import { Logger } from "@matter/main";
9
+ import type { WebRtcTransportRequestorServer } from "./behaviors/WebRtcTransportRequestorServer.js";
10
+
11
+ const logger = Logger.get("WebRtcCallbackBridge");
12
+
13
+ export function attachWebRtcCallbackBridge(
14
+ events: WebRtcTransportRequestorServer.Events,
15
+ emit: (data: WebRtcCallbackData) => void,
16
+ ): void {
17
+ events.offer.on((session, request) => {
18
+ logger.info(
19
+ `offer from peer node=${session.peerNodeId} ep=${session.peerEndpointId} session=${session.id} sdpLen=${request.sdp.length}`,
20
+ );
21
+ emit({
22
+ event_type: "offer",
23
+ webrtc_session_id: session.id,
24
+ node_id: session.peerNodeId,
25
+ endpoint_id: session.peerEndpointId,
26
+ fabric_index: session.fabricIndex,
27
+ data: {
28
+ sdp: request.sdp,
29
+ ice_servers: request.iceServers,
30
+ ice_transport_policy: request.iceTransportPolicy,
31
+ },
32
+ });
33
+ });
34
+ events.answer.on((session, sdp) => {
35
+ logger.info(
36
+ `answer from peer node=${session.peerNodeId} ep=${session.peerEndpointId} session=${session.id} sdpLen=${sdp.length}`,
37
+ );
38
+ emit({
39
+ event_type: "answer",
40
+ webrtc_session_id: session.id,
41
+ node_id: session.peerNodeId,
42
+ endpoint_id: session.peerEndpointId,
43
+ fabric_index: session.fabricIndex,
44
+ data: { sdp },
45
+ });
46
+ });
47
+ events.iceCandidates.on((session, candidates) => {
48
+ logger.info(
49
+ `ice_candidates from peer node=${session.peerNodeId} ep=${session.peerEndpointId} session=${session.id} count=${candidates.length}`,
50
+ );
51
+ emit({
52
+ event_type: "ice_candidates",
53
+ webrtc_session_id: session.id,
54
+ node_id: session.peerNodeId,
55
+ endpoint_id: session.peerEndpointId,
56
+ fabric_index: session.fabricIndex,
57
+ data: {
58
+ ice_candidates: candidates.map(c => ({
59
+ candidate: c.candidate,
60
+ sdpMid: c.sdpMid ?? null,
61
+ sdpMLineIndex: c.sdpmLineIndex ?? null,
62
+ })),
63
+ },
64
+ });
65
+ });
66
+ events.end.on((session, reason) => {
67
+ logger.info(
68
+ `end from peer node=${session.peerNodeId} ep=${session.peerEndpointId} session=${session.id} reason=${reason}`,
69
+ );
70
+ emit({
71
+ event_type: "end",
72
+ webrtc_session_id: session.id,
73
+ node_id: session.peerNodeId,
74
+ endpoint_id: session.peerEndpointId,
75
+ fabric_index: session.fabricIndex,
76
+ data: { reason },
77
+ });
78
+ });
79
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { Observable } from "@matter/general";
8
+ import { Logger, Node } from "@matter/main";
9
+ import type { ServerNode } from "@matter/main";
10
+ import { AccessControlServer } from "@matter/node/behaviors/access-control";
11
+ import { WebRtcTransportRequestorBehavior } from "@matter/node/behaviors/web-rtc-transport-requestor";
12
+ import { assertRemoteActor, FabricAuthority, NodeSession } from "@matter/protocol";
13
+ import { Status, StatusResponseError } from "@matter/types";
14
+ import { AccessControl } from "@matter/types/clusters/access-control";
15
+ import { WebRtcTransportDefinitions } from "@matter/types/clusters/web-rtc-transport-definitions";
16
+ import { WebRtcTransportRequestor } from "@matter/types/clusters/web-rtc-transport-requestor";
17
+
18
+ type WebRtcSession = WebRtcTransportDefinitions.WebRtcSession;
19
+
20
+ const logger = Logger.get("WebRtcTransportRequestorServer");
21
+
22
+ export class WebRtcTransportRequestorServer extends WebRtcTransportRequestorBehavior {
23
+ declare state: WebRtcTransportRequestorServer.State;
24
+ declare events: WebRtcTransportRequestorServer.Events;
25
+
26
+ upsertSession(session: WebRtcSession): void {
27
+ const enriched: WebRtcSession = {
28
+ ...session,
29
+ videoStreamId: session.videoStreams?.[0] ?? null,
30
+ audioStreamId: session.audioStreams?.[0] ?? null,
31
+ };
32
+ const sessions = this.state.currentSessions;
33
+ const idx = sessions.findIndex(s => s.id === session.id);
34
+ if (idx === -1) {
35
+ this.state.currentSessions = [...sessions, enriched];
36
+ } else {
37
+ this.state.currentSessions = sessions.map((s, i) => (i === idx ? enriched : s));
38
+ }
39
+ }
40
+
41
+ removeSession(id: number): void {
42
+ this.state.currentSessions = this.state.currentSessions.filter(s => s.id !== id);
43
+ }
44
+
45
+ override async offer(request: WebRtcTransportRequestor.OfferRequest): Promise<void> {
46
+ logger.info(`incoming Offer webRtcSessionId=${request.webRtcSessionId} sdpLen=${request.sdp.length}`);
47
+ const session = this.#findSessionStrict(request.webRtcSessionId);
48
+ this.events.offer.emit(session, request);
49
+ }
50
+
51
+ override async answer(request: WebRtcTransportRequestor.AnswerRequest): Promise<void> {
52
+ logger.info(`incoming Answer webRtcSessionId=${request.webRtcSessionId} sdpLen=${request.sdp.length}`);
53
+ const session = this.#findSessionStrict(request.webRtcSessionId);
54
+ this.events.answer.emit(session, request.sdp);
55
+ }
56
+
57
+ override async iceCandidates(request: WebRtcTransportRequestor.IceCandidatesRequest): Promise<void> {
58
+ logger.info(
59
+ `incoming ICECandidates webRtcSessionId=${request.webRtcSessionId} count=${request.iceCandidates.length}`,
60
+ );
61
+ if (request.iceCandidates.length === 0) {
62
+ throw new StatusResponseError("ICE candidates list must not be empty", Status.InvalidCommand);
63
+ }
64
+ const session = this.#findSessionStrict(request.webRtcSessionId);
65
+ this.events.iceCandidates.emit(session, request.iceCandidates);
66
+ }
67
+
68
+ override async end(request: WebRtcTransportRequestor.EndRequest): Promise<void> {
69
+ logger.info(`incoming End webRtcSessionId=${request.webRtcSessionId} reason=${request.reason}`);
70
+ const session = this.#findSessionStrict(request.webRtcSessionId);
71
+ this.removeSession(request.webRtcSessionId);
72
+ this.events.end.emit(session, request.reason);
73
+ }
74
+
75
+ override async initialize() {
76
+ const node = Node.forEndpoint(this.endpoint) as ServerNode;
77
+ logger.info(
78
+ `initialized on endpoint=${this.endpoint.number} (id="${this.endpoint.id}") cluster=0x${WebRtcTransportRequestor.id.toString(16)}`,
79
+ );
80
+ this.reactTo(node.lifecycle.online, this.#nodeOnline);
81
+ if (node.lifecycle.isOnline) {
82
+ await this.#nodeOnline();
83
+ }
84
+ }
85
+
86
+ async #nodeOnline() {
87
+ const fabricAuthority = this.env.get(FabricAuthority);
88
+ const ownFabric = fabricAuthority.fabrics[0];
89
+ if (!ownFabric) {
90
+ // void: fabricAdded is a synchronous observable; awaiting #nodeOnline here deadlocks setStateOf.
91
+ fabricAuthority.fabricAdded.once(() => void this.#nodeOnline());
92
+ return;
93
+ }
94
+
95
+ const node = Node.forEndpoint(this.endpoint) as ServerNode;
96
+ await node.act(agent => agent.load(AccessControlServer));
97
+ if (node.behaviors.has(AccessControlServer)) {
98
+ if (
99
+ !node
100
+ .stateOf(AccessControlServer)
101
+ .acl.some(
102
+ ({ fabricIndex, privilege, authMode, subjects, targets }) =>
103
+ fabricIndex === ownFabric.fabricIndex &&
104
+ privilege === AccessControl.AccessControlEntryPrivilege.Operate &&
105
+ authMode === AccessControl.AccessControlEntryAuthMode.Case &&
106
+ subjects?.length === 0 &&
107
+ targets?.length === 1 &&
108
+ targets[0].endpoint === this.endpoint.number &&
109
+ targets[0].cluster === WebRtcTransportRequestor.id,
110
+ )
111
+ ) {
112
+ const acl = [
113
+ ...node.stateOf(AccessControlServer).acl,
114
+ {
115
+ fabricIndex: ownFabric.fabricIndex,
116
+ privilege: AccessControl.AccessControlEntryPrivilege.Operate,
117
+ authMode: AccessControl.AccessControlEntryAuthMode.Case,
118
+ subjects: [],
119
+ targets: [{ endpoint: this.endpoint.number, cluster: WebRtcTransportRequestor.id }],
120
+ },
121
+ ];
122
+ await node.setStateOf(AccessControlServer, { acl });
123
+ }
124
+ }
125
+ }
126
+
127
+ #findSessionStrict(id: number): WebRtcSession {
128
+ assertRemoteActor(this.context);
129
+ NodeSession.assert(this.context.session);
130
+ const peer = this.context.session.peerAddress;
131
+ const session = this.state.currentSessions.find(s => s.id === id);
132
+ if (session === undefined || session.fabricIndex !== peer.fabricIndex || session.peerNodeId !== peer.nodeId) {
133
+ throw new StatusResponseError(`WebRTC session ${id} not found`, Status.NotFound);
134
+ }
135
+ return session;
136
+ }
137
+ }
138
+
139
+ export namespace WebRtcTransportRequestorServer {
140
+ export class State extends WebRtcTransportRequestorBehavior.State {
141
+ override currentSessions: WebRtcSession[] = new Array<WebRtcSession>();
142
+ }
143
+ export class Events extends WebRtcTransportRequestorBehavior.Events {
144
+ offer = Observable<[session: WebRtcSession, args: WebRtcTransportRequestor.OfferRequest]>();
145
+ answer = Observable<[session: WebRtcSession, sdp: string]>();
146
+ iceCandidates = Observable<[session: WebRtcSession, candidates: WebRtcTransportDefinitions.IceCandidate[]]>();
147
+ end = Observable<[session: WebRtcSession, reason: WebRtcTransportDefinitions.WebRtcEndReason]>();
148
+ }
149
+ }
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  // Export controller components
12
+ export * from "./controller/behaviors/WebRtcTransportRequestorServer.js";
12
13
  export * from "./controller/ControllerCommandHandler.js";
13
14
  export * from "./controller/LegacyDataInjector.js";
14
15
  export * from "./controller/MatterController.js";
@@ -114,6 +114,26 @@ export class ConfigStorage {
114
114
  }
115
115
  }
116
116
 
117
+ async removeWifiCredentials() {
118
+ if (!this.#configStore) {
119
+ throw new Error("Storage not open");
120
+ }
121
+ this.#data.wifiSsid = undefined;
122
+ this.#data.wifiCredentials = undefined;
123
+ await this.#configStore.delete("wifiSsid");
124
+ await this.#configStore.delete("wifiCredentials");
125
+ logger.info("Removed WiFi credentials");
126
+ }
127
+
128
+ async removeThreadDataset() {
129
+ if (!this.#configStore) {
130
+ throw new Error("Storage not open");
131
+ }
132
+ this.#data.threadDataset = undefined;
133
+ await this.#configStore.delete("threadDataset");
134
+ logger.info("Removed Thread dataset");
135
+ }
136
+
117
137
  async close() {
118
138
  if (this.#storage) {
119
139
  await this.#storage.close();
@@ -84,6 +84,7 @@ export class WebSocketControllerHandler implements WebServerHandler {
84
84
  #wss?: WebSocketServer;
85
85
  #closed = false;
86
86
  #shuttingDown = false;
87
+ #serverObservers = new ObserverGroup();
87
88
  /** Circular buffer for recent node events (max 25) */
88
89
  #eventHistory: MatterNodeEvent[] = [];
89
90
  /** Track when each node was last interviewed (connected) - keyed by nodeId */
@@ -142,6 +143,13 @@ export class WebSocketControllerHandler implements WebServerHandler {
142
143
  async register(server: HttpServer) {
143
144
  logger.info(`Starting server: matter-server/${this.#serverVersion} (matter.js/${MATTER_VERSION})`);
144
145
  const wss = (this.#wss = new WebSocketServer({ server: server, path: "/ws" }));
146
+
147
+ // WebRTC callbacks fan out to every connected client, so subscribe once at the server
148
+ // level rather than per-connection.
149
+ this.#serverObservers.on(this.#commandHandler.events.webRtcCallback, data => {
150
+ this.#broadcastEvent("webrtc_callback", data);
151
+ });
152
+
145
153
  wss.on("connection", ws => {
146
154
  if (this.#closed || this.#shuttingDown) {
147
155
  try {
@@ -372,6 +380,7 @@ export class WebSocketControllerHandler implements WebServerHandler {
372
380
  }
373
381
 
374
382
  this.#closed = true;
383
+ this.#serverObservers.close();
375
384
  // Send server_shutdown event to all connected clients before closing
376
385
  const shutdownMessage = toBigIntAwareJson({ event: "server_shutdown", data: {} });
377
386
  this.#wss.clients.forEach(client => {
@@ -445,6 +454,9 @@ export class WebSocketControllerHandler implements WebServerHandler {
445
454
  case "device_command":
446
455
  result = await this.#handleDeviceCommand(args);
447
456
  break;
457
+ case "send_webrtc_provider_command":
458
+ result = await this.#handleSendWebRtcProviderCommand(args);
459
+ break;
448
460
  case "write_attribute":
449
461
  result = await this.#handleWriteAttribute(args);
450
462
  break;
@@ -470,6 +482,12 @@ export class WebSocketControllerHandler implements WebServerHandler {
470
482
  case "set_thread_dataset":
471
483
  result = await this.#handleSetThreadDataset(args);
472
484
  break;
485
+ case "remove_wifi_credentials":
486
+ result = await this.#handleRemoveWifiCredentials();
487
+ break;
488
+ case "remove_thread_dataset":
489
+ result = await this.#handleRemoveThreadDataset();
490
+ break;
473
491
  case "get_thread_border_routers":
474
492
  result = this.#controller.borderRouters.list();
475
493
  break;
@@ -558,6 +576,7 @@ export class WebSocketControllerHandler implements WebServerHandler {
558
576
  min_supported_schema_version: SCHEMA_VERSION,
559
577
  sdk_version: `matter-server/${this.#serverVersion} (matter.js/${MATTER_VERSION})`,
560
578
  wifi_credentials_set: !!(this.#config.wifiSsid && this.#config.wifiCredentials),
579
+ wifi_ssid: this.#config.wifiSsid && this.#config.wifiCredentials ? this.#config.wifiSsid : undefined,
561
580
  thread_credentials_set: !!this.#config.threadDataset,
562
581
  bluetooth_enabled: this.#commandHandler.bleEnabled,
563
582
  };
@@ -631,6 +650,9 @@ export class WebSocketControllerHandler implements WebServerHandler {
631
650
  }
632
651
  }
633
652
 
653
+ // Ensure certificates are loaded and initialized
654
+ await this.#controller.certificateService();
655
+
634
656
  await this.#config.set({
635
657
  nextNodeId: typeof nextNodeId === "bigint" ? nextNodeId + 1n : nextNodeId + 1,
636
658
  });
@@ -688,6 +710,9 @@ export class WebSocketControllerHandler implements WebServerHandler {
688
710
  break;
689
711
  }
690
712
 
713
+ // Ensure certificates are loaded and initialized
714
+ await this.#controller.certificateService();
715
+
691
716
  await this.#config.set({
692
717
  nextNodeId: typeof nextNodeId === "bigint" ? nextNodeId + 1n : nextNodeId + 1,
693
718
  });
@@ -853,6 +878,18 @@ export class WebSocketControllerHandler implements WebServerHandler {
853
878
  return cmdResult;
854
879
  }
855
880
 
881
+ async #handleSendWebRtcProviderCommand(
882
+ args: ArgsOf<"send_webrtc_provider_command">,
883
+ ): Promise<ResponseOf<"send_webrtc_provider_command">> {
884
+ const { node_id, endpoint_id, command_name, payload } = args;
885
+ return this.#commandHandler.sendWebRtcProviderCommand({
886
+ nodeId: NodeId(node_id),
887
+ endpointId: EndpointNumber(endpoint_id),
888
+ commandName: command_name,
889
+ payload,
890
+ });
891
+ }
892
+
856
893
  async #handleInterviewNode(args: ArgsOf<"interview_node">): Promise<ResponseOf<"interview_node">> {
857
894
  const { node_id } = args;
858
895
  const nodeId = NodeId(node_id);
@@ -927,6 +964,26 @@ export class WebSocketControllerHandler implements WebServerHandler {
927
964
  return {};
928
965
  }
929
966
 
967
+ async #handleRemoveWifiCredentials(): Promise<ResponseOf<"remove_wifi_credentials">> {
968
+ await this.#config.removeWifiCredentials();
969
+ try {
970
+ await this.#broadcastServerInfoUpdated();
971
+ } catch (error) {
972
+ logger.warn("Failed to broadcast server info update", error);
973
+ }
974
+ return {};
975
+ }
976
+
977
+ async #handleRemoveThreadDataset(): Promise<ResponseOf<"remove_thread_dataset">> {
978
+ await this.#config.removeThreadDataset();
979
+ try {
980
+ await this.#broadcastServerInfoUpdated();
981
+ } catch (error) {
982
+ logger.warn("Failed to broadcast server info update", error);
983
+ }
984
+ return {};
985
+ }
986
+
930
987
  async #handleOpenCommissioningWindow(
931
988
  args: ArgsOf<"open_commissioning_window">,
932
989
  ): Promise<ResponseOf<"open_commissioning_window">> {