@matter-server/ws-controller 0.7.0-alpha.0-20260512-b404bea → 0.7.0

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 (35) hide show
  1. package/dist/esm/controller/ControllerCommandHandler.d.ts +10 -0
  2. package/dist/esm/controller/ControllerCommandHandler.d.ts.map +1 -1
  3. package/dist/esm/controller/ControllerCommandHandler.js +93 -24
  4. package/dist/esm/controller/ControllerCommandHandler.js.map +1 -1
  5. package/dist/esm/controller/MatterController.d.ts +7 -2
  6. package/dist/esm/controller/MatterController.d.ts.map +1 -1
  7. package/dist/esm/controller/MatterController.js +46 -3
  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 +2 -1
  22. package/dist/esm/index.d.ts.map +1 -1
  23. package/dist/esm/index.js +3 -1
  24. package/dist/esm/index.js.map +1 -1
  25. package/dist/esm/server/WebSocketControllerHandler.d.ts.map +1 -1
  26. package/dist/esm/server/WebSocketControllerHandler.js +17 -0
  27. package/dist/esm/server/WebSocketControllerHandler.js.map +1 -1
  28. package/package.json +22 -21
  29. package/src/controller/ControllerCommandHandler.ts +121 -42
  30. package/src/controller/MatterController.ts +54 -4
  31. package/src/controller/Nodes.ts +13 -78
  32. package/src/controller/WebRtcCallbackBridge.ts +79 -0
  33. package/src/controller/behaviors/WebRtcTransportRequestorServer.ts +149 -0
  34. package/src/index.ts +2 -1
  35. package/src/server/WebSocketControllerHandler.ts +24 -0
@@ -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
  }
@@ -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";
@@ -32,4 +33,4 @@ export { formatNodeId } from "./util/formatNodeId.js";
32
33
  export * from "./util/matterVersion.js";
33
34
 
34
35
  // Re-Export classes from matter.js
35
- export { Crypto, Environment, LogDestination, LogFormat, LogLevel, Logger } from "@matter/main";
36
+ export { Crypto, Environment, LogDestination, LogFormat, LogLevel, Logger, StorageService } from "@matter/main";
@@ -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;
@@ -866,6 +878,18 @@ export class WebSocketControllerHandler implements WebServerHandler {
866
878
  return cmdResult;
867
879
  }
868
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
+
869
893
  async #handleInterviewNode(args: ArgsOf<"interview_node">): Promise<ResponseOf<"interview_node">> {
870
894
  const { node_id } = args;
871
895
  const nodeId = NodeId(node_id);