@matterbridge/core 3.10.9-dev-20260907-5fcc59a → 3.10.9-dev-20260910-ca91bcb

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 (41) hide show
  1. package/dist/behaviors/cameraAvSettingsUserLevelManagementServer.d.ts +24 -0
  2. package/dist/behaviors/cameraAvSettingsUserLevelManagementServer.js +53 -0
  3. package/dist/behaviors/cameraAvStreamManagementServer.d.ts +80 -0
  4. package/dist/behaviors/cameraAvStreamManagementServer.js +414 -0
  5. package/dist/behaviors/chimeServer.d.ts +12 -0
  6. package/dist/behaviors/chimeServer.js +37 -0
  7. package/dist/behaviors/clients.d.ts +4 -0
  8. package/dist/behaviors/clients.js +33 -0
  9. package/dist/behaviors/ffmpeg.d.ts +4 -0
  10. package/dist/behaviors/ffmpeg.js +103 -0
  11. package/dist/behaviors/snapshot.d.ts +15 -0
  12. package/dist/behaviors/snapshot.js +139 -0
  13. package/dist/behaviors/webRtcTransportProviderServer.d.ts +28 -0
  14. package/dist/behaviors/webRtcTransportProviderServer.js +422 -0
  15. package/dist/behaviors/webRtcTransportRequestorServer.d.ts +10 -0
  16. package/dist/behaviors/webRtcTransportRequestorServer.js +24 -0
  17. package/dist/behaviors/weriftSession.d.ts +63 -0
  18. package/dist/behaviors/weriftSession.js +538 -0
  19. package/dist/devices/audioDoorbell.d.ts +20 -0
  20. package/dist/devices/audioDoorbell.js +49 -0
  21. package/dist/devices/camera.d.ts +37 -0
  22. package/dist/devices/camera.js +64 -0
  23. package/dist/devices/chime.d.ts +16 -0
  24. package/dist/devices/chime.js +37 -0
  25. package/dist/devices/doorbell.d.ts +11 -0
  26. package/dist/devices/doorbell.js +31 -0
  27. package/dist/devices/floodlightCamera.d.ts +19 -0
  28. package/dist/devices/floodlightCamera.js +81 -0
  29. package/dist/devices/intercom.d.ts +22 -0
  30. package/dist/devices/intercom.js +55 -0
  31. package/dist/devices/snapshotCamera.d.ts +20 -0
  32. package/dist/devices/snapshotCamera.js +51 -0
  33. package/dist/devices/videoDoorbell.d.ts +21 -0
  34. package/dist/devices/videoDoorbell.js +82 -0
  35. package/dist/matterbridgeEndpoint.d.ts +4 -1
  36. package/dist/matterbridgeEndpoint.js +4 -1
  37. package/dist/matterbridgeEndpointHelpers.d.ts +3 -0
  38. package/dist/matterbridgeEndpointHelpers.js +51 -2
  39. package/dist/matterbridgeEndpointTypes.d.ts +15 -0
  40. package/dist/matterbridgeEndpointTypes.js +1 -2
  41. package/package.json +6 -5
@@ -0,0 +1,422 @@
1
+ import { Node } from '@matter/main';
2
+ import { WebRtcTransportProviderServer } from '@matter/node/behaviors/web-rtc-transport-provider';
3
+ import { WebRtcTransportRequestorClient } from '@matter/node/behaviors/web-rtc-transport-requestor';
4
+ import { EndpointNumber, FabricIndex, NodeId, StreamUsage, Status, StatusResponseError } from '@matter/types';
5
+ import { WebRtcTransportDefinitions } from '@matter/types/clusters/web-rtc-transport-definitions';
6
+ import { emitCommand } from '../matterbridgeEndpointHelpers.js';
7
+ import { MatterbridgeCameraAvStreamManagementServer } from './cameraAvStreamManagementServer.js';
8
+ import { MatterbridgeServer } from './matterbridgeServer.js';
9
+ import { WeriftWebRtcSession } from './weriftSession.js';
10
+ const DEFERRED_INVOKE_DELAY_MS = 250;
11
+ const ICE_CANDIDATE_APPLY_TIMEOUT_MS = 5000;
12
+ const MAX_WEB_RTC_SESSION_ID = 0xfffe;
13
+ const MAX_CONCURRENT_SESSIONS = 5;
14
+ export function allocateWebRtcSessionId(nextCandidate, activeSessionIds) {
15
+ const firstCandidate = nextCandidate;
16
+ let candidate = firstCandidate;
17
+ do {
18
+ const followingCandidate = candidate === MAX_WEB_RTC_SESSION_ID ? 0 : candidate + 1;
19
+ if (!activeSessionIds.has(candidate))
20
+ return { webRtcSessionId: candidate, nextCandidate: followingCandidate };
21
+ candidate = followingCandidate;
22
+ } while (candidate !== firstCandidate);
23
+ throw new StatusResponseError('No WebRTC session identifier is available', Status.ResourceExhausted);
24
+ }
25
+ export class MatterbridgeWebRtcTransportProviderServer extends WebRtcTransportProviderServer {
26
+ #invokeDeferred(action, description) {
27
+ const device = this.endpoint.stateOf(MatterbridgeServer);
28
+ const endpointLabel = `${this.endpoint.maybeId}.${this.endpoint.maybeNumber}`;
29
+ setTimeout(() => {
30
+ Promise.resolve(action()).catch((error) => {
31
+ device.log.error(`MatterbridgeWebRtcTransportProviderServer: failed to invoke ${description} on the peer's WebRtcTransportRequestor: ${error instanceof Error ? error.message : String(error)} (endpoint ${endpointLabel})`);
32
+ });
33
+ }, DEFERRED_INVOKE_DELAY_MS);
34
+ }
35
+ async #resolvePeerRequestorEndpoint(peerNodeId, fabricIndex, peerEndpointId) {
36
+ try {
37
+ const serverNode = Node.forEndpoint(this.endpoint);
38
+ const peerNode = await serverNode.peers.forAddress({ nodeId: peerNodeId, fabricIndex });
39
+ const peerEndpoint = peerNode.endpoints.require(peerEndpointId);
40
+ peerEndpoint.behaviors.require(WebRtcTransportRequestorClient);
41
+ return peerEndpoint;
42
+ }
43
+ catch (error) {
44
+ this.endpoint
45
+ .stateOf(MatterbridgeServer)
46
+ .log.debug(`MatterbridgeWebRtcTransportProviderServer: could not resolve peer WebRtcTransportRequestor endpoint (peerNodeId=${peerNodeId}, fabricIndex=${fabricIndex}, peerEndpointId=${peerEndpointId}): ${error instanceof Error ? error.message : String(error)} (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`);
47
+ return undefined;
48
+ }
49
+ }
50
+ #getPeerInfo() {
51
+ const session = this.context.session;
52
+ return {
53
+ peerNodeId: session?.peerNodeId ?? NodeId(0),
54
+ fabricIndex: session?.fabric?.fabricIndex ?? FabricIndex(1),
55
+ };
56
+ }
57
+ #allocateWebRtcSessionId() {
58
+ const activeSessionIds = new Set(this.state.currentSessions.map((session) => session.id));
59
+ const allocation = allocateWebRtcSessionId(this.internal.nextWebRtcSessionId, activeSessionIds);
60
+ this.internal.nextWebRtcSessionId = allocation.nextCandidate;
61
+ return allocation.webRtcSessionId;
62
+ }
63
+ #resolveStreamLists(request) {
64
+ return {
65
+ videoStreams: request.videoStreams ?? (request.videoStreamId !== undefined && request.videoStreamId !== null ? [request.videoStreamId] : undefined),
66
+ audioStreams: request.audioStreams ?? (request.audioStreamId !== undefined && request.audioStreamId !== null ? [request.audioStreamId] : undefined),
67
+ };
68
+ }
69
+ #hasNoStreamFields(request) {
70
+ return request.videoStreams === undefined && request.audioStreams === undefined && request.videoStreamId === undefined && request.audioStreamId === undefined;
71
+ }
72
+ #validateNoConflictingStreamFields(request) {
73
+ if ((request.videoStreams !== undefined && request.videoStreamId !== undefined) || (request.audioStreams !== undefined && request.audioStreamId !== undefined)) {
74
+ throw new StatusResponseError(`MatterbridgeWebRtcTransportProviderServer: videoStreams/audioStreams and the deprecated videoStreamId/audioStreamId fields are mutually exclusive (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`, Status.InvalidCommand);
75
+ }
76
+ }
77
+ #isStrictWebRtcTransport() {
78
+ return process.env.MATTERBRIDGE_STRICT_WEBRTCTRANSPORT === '1';
79
+ }
80
+ #resolveVideoResolution(videoStreams) {
81
+ const videoStreamId = videoStreams?.[0];
82
+ if (videoStreamId === undefined)
83
+ return undefined;
84
+ if (!this.endpoint.behaviors.has(MatterbridgeCameraAvStreamManagementServer))
85
+ return undefined;
86
+ const { allocatedVideoStreams } = this.endpoint.stateOf(MatterbridgeCameraAvStreamManagementServer);
87
+ const stream = allocatedVideoStreams.find((allocatedStream) => allocatedStream.videoStreamId === videoStreamId);
88
+ if (!stream)
89
+ return undefined;
90
+ return `${stream.maxResolution.width}x${stream.maxResolution.height}`;
91
+ }
92
+ #selectExistingVideoStreamId(streamUsage) {
93
+ const allocatedVideoStreams = [...this.endpoint.stateOf(MatterbridgeCameraAvStreamManagementServer).allocatedVideoStreams];
94
+ return (allocatedVideoStreams.find((stream) => stream.streamUsage === streamUsage) ?? allocatedVideoStreams[0])?.videoStreamId;
95
+ }
96
+ #selectExistingAudioStreamId(streamUsage) {
97
+ const allocatedAudioStreams = [...this.endpoint.stateOf(MatterbridgeCameraAvStreamManagementServer).allocatedAudioStreams];
98
+ return (allocatedAudioStreams.find((stream) => stream.streamUsage === streamUsage) ?? allocatedAudioStreams[0])?.audioStreamId;
99
+ }
100
+ async #autoAssignStreams(streamUsage) {
101
+ if (!this.endpoint.behaviors.has(MatterbridgeCameraAvStreamManagementServer))
102
+ return {};
103
+ const state = this.endpoint.stateOf(MatterbridgeCameraAvStreamManagementServer);
104
+ let videoStreamId = this.#selectExistingVideoStreamId(streamUsage);
105
+ if (videoStreamId === undefined && state.rateDistortionTradeOffPoints.length > 0) {
106
+ let largestTradeOffPoint = state.rateDistortionTradeOffPoints[0];
107
+ for (const point of state.rateDistortionTradeOffPoints) {
108
+ if (point.resolution.width * point.resolution.height > largestTradeOffPoint.resolution.width * largestTradeOffPoint.resolution.height)
109
+ largestTradeOffPoint = point;
110
+ }
111
+ const { codec, resolution, minBitRate } = largestTradeOffPoint;
112
+ ({ videoStreamId } = await this.endpoint.act((agent) => agent.get(MatterbridgeCameraAvStreamManagementServer).videoStreamAllocate({
113
+ streamUsage,
114
+ videoCodec: codec,
115
+ minFrameRate: 1,
116
+ maxFrameRate: state.videoSensorParams.maxFps,
117
+ minResolution: state.minViewportResolution,
118
+ maxResolution: resolution,
119
+ minBitRate,
120
+ maxBitRate: minBitRate * 4,
121
+ keyFrameInterval: 4000,
122
+ })));
123
+ }
124
+ let audioStreamId = this.#selectExistingAudioStreamId(streamUsage);
125
+ if (audioStreamId === undefined && state.microphoneCapabilities.supportedCodecs.length > 0) {
126
+ const { microphoneCapabilities } = state;
127
+ ({ audioStreamId } = await this.endpoint.act((agent) => agent.get(MatterbridgeCameraAvStreamManagementServer).audioStreamAllocate({
128
+ streamUsage,
129
+ audioCodec: microphoneCapabilities.supportedCodecs[0],
130
+ channelCount: microphoneCapabilities.maxNumberOfChannels,
131
+ sampleRate: microphoneCapabilities.supportedSampleRates[0],
132
+ bitRate: 32_000,
133
+ bitDepth: microphoneCapabilities.supportedBitDepths[0],
134
+ })));
135
+ }
136
+ return {
137
+ videoStreams: videoStreamId === undefined ? undefined : [videoStreamId],
138
+ audioStreams: audioStreamId === undefined ? undefined : [audioStreamId],
139
+ };
140
+ }
141
+ #validateAllocatedStreamIds(kind, ids) {
142
+ const state = this.endpoint.stateOf(MatterbridgeCameraAvStreamManagementServer);
143
+ const attributeName = kind === 'video' ? 'AllocatedVideoStreams' : 'AllocatedAudioStreams';
144
+ const allocatedIds = kind === 'video' ? state.allocatedVideoStreams.map((stream) => stream.videoStreamId) : state.allocatedAudioStreams.map((stream) => stream.audioStreamId);
145
+ if (allocatedIds.length === 0) {
146
+ throw new StatusResponseError(`MatterbridgeWebRtcTransportProviderServer: no ${kind} stream is allocated (${attributeName} is empty) (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`, Status.InvalidInState);
147
+ }
148
+ if (new Set(ids).size !== ids.length) {
149
+ throw new StatusResponseError(`MatterbridgeWebRtcTransportProviderServer: duplicate ${kind} stream id in the request (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`, Status.AlreadyExists);
150
+ }
151
+ for (const id of ids) {
152
+ if (!allocatedIds.includes(id)) {
153
+ throw new StatusResponseError(`MatterbridgeWebRtcTransportProviderServer: ${kind} stream ${id} is not present in ${attributeName} (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`, Status.DynamicConstraintError);
154
+ }
155
+ }
156
+ }
157
+ #validateStreamUsage(streamUsage) {
158
+ const streamUsagePriorities = this.endpoint.behaviors.has(MatterbridgeCameraAvStreamManagementServer)
159
+ ? this.endpoint.stateOf(MatterbridgeCameraAvStreamManagementServer).streamUsagePriorities
160
+ : [];
161
+ if (!streamUsagePriorities.includes(streamUsage)) {
162
+ throw new StatusResponseError(`MatterbridgeWebRtcTransportProviderServer: stream usage ${streamUsage} is not present in streamUsagePriorities (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`, Status.DynamicConstraintError);
163
+ }
164
+ }
165
+ #resolveStrictStreamLists(request, streamUsage) {
166
+ let videoStreams;
167
+ if (request.videoStreams !== undefined) {
168
+ videoStreams = request.videoStreams;
169
+ }
170
+ else if (request.videoStreamId === null) {
171
+ const selected = this.#selectExistingVideoStreamId(streamUsage);
172
+ videoStreams = selected === undefined ? [] : [selected];
173
+ }
174
+ else if (request.videoStreamId !== undefined) {
175
+ videoStreams = [request.videoStreamId];
176
+ }
177
+ if (videoStreams !== undefined)
178
+ this.#validateAllocatedStreamIds('video', videoStreams);
179
+ let audioStreams;
180
+ if (request.audioStreams !== undefined) {
181
+ audioStreams = request.audioStreams;
182
+ }
183
+ else if (request.audioStreamId === null) {
184
+ const selected = this.#selectExistingAudioStreamId(streamUsage);
185
+ audioStreams = selected === undefined ? [] : [selected];
186
+ }
187
+ else if (request.audioStreamId !== undefined) {
188
+ audioStreams = [request.audioStreamId];
189
+ }
190
+ if (audioStreams !== undefined)
191
+ this.#validateAllocatedStreamIds('audio', audioStreams);
192
+ return { videoStreams, audioStreams };
193
+ }
194
+ #echoDeprecatedStreamIds(request, videoStreams, audioStreams) {
195
+ return {
196
+ videoStreamId: request.videoStreamId === undefined ? undefined : (videoStreams?.[0] ?? null),
197
+ audioStreamId: request.audioStreamId === undefined ? undefined : (audioStreams?.[0] ?? null),
198
+ };
199
+ }
200
+ #evictIfOverCapacity(webRtcSessionId, requestorEndpoint) {
201
+ if (this.state.currentSessions.length <= MAX_CONCURRENT_SESSIONS)
202
+ return false;
203
+ this.state.currentSessions = this.state.currentSessions.filter((session) => session.id !== webRtcSessionId);
204
+ const device = this.endpoint.stateOf(MatterbridgeServer);
205
+ device.log.notice(`MatterbridgeWebRtcTransportProviderServer: session ${webRtcSessionId} exceeds MAX_CONCURRENT_SESSIONS (${MAX_CONCURRENT_SESSIONS}); evicting with WebRtcEndReason.OutOfResources (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`);
206
+ if (requestorEndpoint) {
207
+ this.#invokeDeferred(async () => requestorEndpoint.commandsOf(WebRtcTransportRequestorClient).end({ webRtcSessionId, reason: WebRtcTransportDefinitions.WebRtcEndReason.OutOfResources }), `End (OutOfResources) for session ${webRtcSessionId}`);
208
+ }
209
+ return true;
210
+ }
211
+ async solicitOffer(request) {
212
+ this.#validateNoConflictingStreamFields(request);
213
+ let videoStreams;
214
+ let audioStreams;
215
+ if (this.#isStrictWebRtcTransport()) {
216
+ if (this.#hasNoStreamFields(request)) {
217
+ throw new StatusResponseError(`MatterbridgeWebRtcTransportProviderServer.solicitOffer: requires at least one of videoStreams, audioStreams, videoStreamId or audioStreamId to be present (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`, Status.InvalidCommand);
218
+ }
219
+ this.#validateStreamUsage(request.streamUsage);
220
+ ({ videoStreams, audioStreams } = this.#resolveStrictStreamLists(request, request.streamUsage));
221
+ }
222
+ else {
223
+ ({ videoStreams, audioStreams } = this.#resolveStreamLists(request));
224
+ if (!videoStreams?.length && !audioStreams?.length) {
225
+ ({ videoStreams, audioStreams } = await this.#autoAssignStreams(request.streamUsage));
226
+ }
227
+ }
228
+ if (!videoStreams?.length && !audioStreams?.length) {
229
+ throw new StatusResponseError(`MatterbridgeWebRtcTransportProviderServer.solicitOffer: requires at least one of videoStreams or audioStreams; the camera has no video or audio stream to assign automatically (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`, Status.ConstraintError);
230
+ }
231
+ const device = this.endpoint.stateOf(MatterbridgeServer);
232
+ const webRtcSessionId = this.#allocateWebRtcSessionId();
233
+ const { peerNodeId, fabricIndex } = this.#getPeerInfo();
234
+ this.state.currentSessions = [
235
+ ...this.state.currentSessions,
236
+ {
237
+ id: webRtcSessionId,
238
+ peerNodeId,
239
+ peerEndpointId: request.originatingEndpointId,
240
+ streamUsage: request.streamUsage,
241
+ metadataEnabled: request.metadataEnabled ?? false,
242
+ videoStreams,
243
+ audioStreams,
244
+ videoStreamId: videoStreams?.[0] ?? null,
245
+ audioStreamId: audioStreams?.[0] ?? null,
246
+ fabricIndex,
247
+ },
248
+ ];
249
+ device.log.info(`MatterbridgeWebRtcTransportProviderServer.solicitOffer: solicited a WebRTC offer for session ${webRtcSessionId} (stream usage ${request.streamUsage}) (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`);
250
+ const requestorEndpoint = await this.#resolvePeerRequestorEndpoint(peerNodeId, fabricIndex, request.originatingEndpointId);
251
+ if (this.#evictIfOverCapacity(webRtcSessionId, requestorEndpoint)) {
252
+ emitCommand(this.endpoint, WebRtcTransportProviderServer.id, 'solicitOffer', request, this.context);
253
+ return { webRtcSessionId, deferredOffer: false, ...this.#echoDeprecatedStreamIds(request, videoStreams, audioStreams) };
254
+ }
255
+ const webRtcPeer = new WeriftWebRtcSession(webRtcSessionId, this.state.weriftOfferOptions);
256
+ this.internal.sessions.set(webRtcSessionId, webRtcPeer);
257
+ const sdp = await webRtcPeer.createOffer({
258
+ video: !!videoStreams?.length,
259
+ audio: !!audioStreams?.length,
260
+ videoResolution: this.state.weriftOfferOptions.videoResolution ?? this.#resolveVideoResolution(videoStreams),
261
+ });
262
+ if (requestorEndpoint) {
263
+ this.#invokeDeferred(async () => requestorEndpoint.commandsOf(WebRtcTransportRequestorClient).offer({ webRtcSessionId, sdp }), `Offer for session ${webRtcSessionId}`);
264
+ device.log.info(`MatterbridgeWebRtcTransportProviderServer.solicitOffer: invoking Offer on the peer's WebRtcTransportRequestor for session ${webRtcSessionId} (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`);
265
+ }
266
+ else {
267
+ device.log.info(`MatterbridgeWebRtcTransportProviderServer.solicitOffer: could not reach the peer's WebRtcTransportRequestor; the Offer for session ${webRtcSessionId} was not sent (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`);
268
+ }
269
+ emitCommand(this.endpoint, WebRtcTransportProviderServer.id, 'solicitOffer', request, this.context);
270
+ return { webRtcSessionId, deferredOffer: false, ...this.#echoDeprecatedStreamIds(request, videoStreams, audioStreams) };
271
+ }
272
+ async provideOffer(request) {
273
+ const device = this.endpoint.stateOf(MatterbridgeServer);
274
+ let webRtcSessionId = request.webRtcSessionId;
275
+ if (webRtcSessionId === null) {
276
+ this.#validateNoConflictingStreamFields(request);
277
+ let videoStreams;
278
+ let audioStreams;
279
+ if (this.#isStrictWebRtcTransport()) {
280
+ if (this.#hasNoStreamFields(request)) {
281
+ throw new StatusResponseError(`MatterbridgeWebRtcTransportProviderServer.provideOffer: requires at least one of videoStreams, audioStreams, videoStreamId or audioStreamId to be present (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`, Status.InvalidCommand);
282
+ }
283
+ this.#validateStreamUsage(request.streamUsage ?? StreamUsage.LiveView);
284
+ ({ videoStreams, audioStreams } = this.#resolveStrictStreamLists(request, request.streamUsage ?? StreamUsage.LiveView));
285
+ }
286
+ else {
287
+ ({ videoStreams, audioStreams } = this.#resolveStreamLists(request));
288
+ if (!videoStreams?.length && !audioStreams?.length) {
289
+ ({ videoStreams, audioStreams } = await this.#autoAssignStreams(request.streamUsage ?? StreamUsage.LiveView));
290
+ }
291
+ }
292
+ if (!videoStreams?.length && !audioStreams?.length) {
293
+ throw new StatusResponseError(`MatterbridgeWebRtcTransportProviderServer.provideOffer: requires at least one of videoStreams or audioStreams; the camera has no video or audio stream to assign automatically (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`, Status.ConstraintError);
294
+ }
295
+ webRtcSessionId = this.#allocateWebRtcSessionId();
296
+ const { peerNodeId, fabricIndex } = this.#getPeerInfo();
297
+ this.state.currentSessions = [
298
+ ...this.state.currentSessions,
299
+ {
300
+ id: webRtcSessionId,
301
+ peerNodeId,
302
+ peerEndpointId: request.originatingEndpointId ?? EndpointNumber(0),
303
+ streamUsage: request.streamUsage ?? StreamUsage.LiveView,
304
+ metadataEnabled: request.metadataEnabled ?? false,
305
+ videoStreams,
306
+ audioStreams,
307
+ videoStreamId: videoStreams?.[0] ?? null,
308
+ audioStreamId: audioStreams?.[0] ?? null,
309
+ fabricIndex,
310
+ },
311
+ ];
312
+ const requestorEndpointForCapacityCheck = await this.#resolvePeerRequestorEndpoint(peerNodeId, fabricIndex, request.originatingEndpointId ?? EndpointNumber(0));
313
+ if (this.#evictIfOverCapacity(webRtcSessionId, requestorEndpointForCapacityCheck)) {
314
+ emitCommand(this.endpoint, WebRtcTransportProviderServer.id, 'provideOffer', request, this.context);
315
+ return { webRtcSessionId, ...this.#echoDeprecatedStreamIds(request, videoStreams, audioStreams) };
316
+ }
317
+ }
318
+ else if (!this.state.currentSessions.some((session) => session.id === webRtcSessionId)) {
319
+ throw new StatusResponseError(`MatterbridgeWebRtcTransportProviderServer.provideOffer: webRTC session ${webRtcSessionId} is not present in currentSessions (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`, Status.NotFound);
320
+ }
321
+ device.log.info(`MatterbridgeWebRtcTransportProviderServer.provideOffer: received an SDP offer for session ${webRtcSessionId} (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`);
322
+ device.log.debug(`MatterbridgeWebRtcTransportProviderServer.provideOffer: received an SDP offer for session ${webRtcSessionId}:\n${request.sdp}\n(endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`);
323
+ const session = this.state.currentSessions.find((s) => s.id === webRtcSessionId);
324
+ const webRtcPeer = this.internal.sessions.get(webRtcSessionId) ?? new WeriftWebRtcSession(webRtcSessionId, this.state.weriftOfferOptions);
325
+ this.internal.sessions.set(webRtcSessionId, webRtcPeer);
326
+ const sdp = await webRtcPeer.createAnswer(request.sdp, {
327
+ video: !!session.videoStreams?.length,
328
+ audio: !!session.audioStreams?.length,
329
+ videoResolution: this.state.weriftOfferOptions.videoResolution ?? this.#resolveVideoResolution(session.videoStreams),
330
+ });
331
+ const requestorEndpoint = await this.#resolvePeerRequestorEndpoint(session.peerNodeId, session.fabricIndex, session.peerEndpointId);
332
+ if (requestorEndpoint) {
333
+ this.#invokeDeferred(async () => requestorEndpoint.commandsOf(WebRtcTransportRequestorClient).answer({ webRtcSessionId, sdp }), `Answer for session ${webRtcSessionId}`);
334
+ device.log.info(`MatterbridgeWebRtcTransportProviderServer.provideOffer: invoking Answer on the peer's WebRtcTransportRequestor for session ${webRtcSessionId} (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`);
335
+ }
336
+ else {
337
+ device.log.info(`MatterbridgeWebRtcTransportProviderServer.provideOffer: could not reach the peer's WebRtcTransportRequestor; the Answer for session ${webRtcSessionId} was not sent (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`);
338
+ }
339
+ emitCommand(this.endpoint, WebRtcTransportProviderServer.id, 'provideOffer', request, this.context);
340
+ return { webRtcSessionId, ...this.#echoDeprecatedStreamIds(request, session.videoStreams && [...session.videoStreams], session.audioStreams && [...session.audioStreams]) };
341
+ }
342
+ async provideAnswer(request) {
343
+ const device = this.endpoint.stateOf(MatterbridgeServer);
344
+ if (!this.state.currentSessions.some((session) => session.id === request.webRtcSessionId)) {
345
+ throw new StatusResponseError(`MatterbridgeWebRtcTransportProviderServer.provideAnswer: webRTC session ${request.webRtcSessionId} is not present in currentSessions (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`, Status.NotFound);
346
+ }
347
+ device.log.info(`MatterbridgeWebRtcTransportProviderServer.provideAnswer: received an SDP answer for session ${request.webRtcSessionId} (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`);
348
+ device.log.debug(`MatterbridgeWebRtcTransportProviderServer.provideAnswer: received SDP answer for session ${request.webRtcSessionId}:\n${request.sdp}\n(endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`);
349
+ const webRtcPeer = this.internal.sessions.get(request.webRtcSessionId);
350
+ if (webRtcPeer) {
351
+ await webRtcPeer.applyAnswer(request.sdp);
352
+ }
353
+ emitCommand(this.endpoint, WebRtcTransportProviderServer.id, 'provideAnswer', request, this.context);
354
+ }
355
+ async provideIceCandidates(request) {
356
+ const device = this.endpoint.stateOf(MatterbridgeServer);
357
+ if (!this.state.currentSessions.some((session) => session.id === request.webRtcSessionId)) {
358
+ throw new StatusResponseError(`MatterbridgeWebRtcTransportProviderServer.provideIceCandidates: webRTC session ${request.webRtcSessionId} is not present in currentSessions (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`, Status.NotFound);
359
+ }
360
+ const endpointLabel = `${this.endpoint.maybeId}.${this.endpoint.maybeNumber}`;
361
+ device.log.info(`MatterbridgeWebRtcTransportProviderServer.provideIceCandidates: received ${request.iceCandidates.length} ICE candidate(s) for session ${request.webRtcSessionId} (endpoint ${endpointLabel})`);
362
+ const webRtcPeer = this.internal.sessions.get(request.webRtcSessionId);
363
+ if (webRtcPeer) {
364
+ void Promise.allSettled(request.iceCandidates.map(async (candidate, index) => {
365
+ const startedAt = Date.now();
366
+ device.log.debug(`MatterbridgeWebRtcTransportProviderServer.provideIceCandidates: applying ICE candidate ${index + 1}/${request.iceCandidates.length} for session ${request.webRtcSessionId} ` +
367
+ `(mid=${candidate.sdpMid ?? 'null'}, mLine=${candidate.sdpmLineIndex ?? 'null'}, endOfCandidates=${candidate.candidate.trim() === ''}) ` +
368
+ `(endpoint ${endpointLabel})`);
369
+ try {
370
+ let timeout;
371
+ try {
372
+ await Promise.race([
373
+ webRtcPeer.addIceCandidate(candidate.candidate, candidate.sdpMid, candidate.sdpmLineIndex),
374
+ new Promise((resolve, reject) => {
375
+ timeout = setTimeout(() => reject(new Error(`MatterbridgeWebRtcTransportProviderServer.provideIceCandidates: timed out applying an ICE candidate after ${ICE_CANDIDATE_APPLY_TIMEOUT_MS}ms`)), ICE_CANDIDATE_APPLY_TIMEOUT_MS);
376
+ }),
377
+ ]);
378
+ }
379
+ finally {
380
+ clearTimeout(timeout);
381
+ }
382
+ device.log.debug(`MatterbridgeWebRtcTransportProviderServer.provideIceCandidates: applied ICE candidate ${index + 1}/${request.iceCandidates.length} for session ${request.webRtcSessionId} ` +
383
+ `in ${Date.now() - startedAt}ms (endpoint ${endpointLabel})`);
384
+ }
385
+ catch (error) {
386
+ device.log.warn(`MatterbridgeWebRtcTransportProviderServer.provideIceCandidates: failed ICE candidate ${index + 1}/${request.iceCandidates.length} for session ${request.webRtcSessionId} after ${Date.now() - startedAt}ms ` +
387
+ `with ${String(error)} (endpoint ${endpointLabel})`);
388
+ }
389
+ }));
390
+ }
391
+ emitCommand(this.endpoint, WebRtcTransportProviderServer.id, 'provideIceCandidates', request, this.context);
392
+ }
393
+ async endSession(request) {
394
+ const device = this.endpoint.stateOf(MatterbridgeServer);
395
+ if (!this.state.currentSessions.some((session) => session.id === request.webRtcSessionId)) {
396
+ throw new StatusResponseError(`MatterbridgeWebRtcTransportProviderServer.endSession: webRTC session ${request.webRtcSessionId} is not present in currentSessions (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`, Status.NotFound);
397
+ }
398
+ this.state.currentSessions = this.state.currentSessions.filter((session) => session.id !== request.webRtcSessionId);
399
+ const webRtcPeer = this.internal.sessions.get(request.webRtcSessionId);
400
+ if (webRtcPeer) {
401
+ this.internal.sessions.delete(request.webRtcSessionId);
402
+ await webRtcPeer.close();
403
+ }
404
+ device.log.info(`MatterbridgeWebRtcTransportProviderServer.endSession: ended webRTC session ${request.webRtcSessionId} (reason ${request.reason}) (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`);
405
+ emitCommand(this.endpoint, WebRtcTransportProviderServer.id, 'endSession', request, this.context);
406
+ }
407
+ }
408
+ (function (MatterbridgeWebRtcTransportProviderServer) {
409
+ class State extends WebRtcTransportProviderServer.State {
410
+ weriftOfferOptions = { video: true, audio: true, videoSource: 'none', audioSource: 'none' };
411
+ }
412
+ MatterbridgeWebRtcTransportProviderServer.State = State;
413
+ class Internal {
414
+ nextWebRtcSessionId = 0;
415
+ sessions = new Map();
416
+ }
417
+ MatterbridgeWebRtcTransportProviderServer.Internal = Internal;
418
+ })(MatterbridgeWebRtcTransportProviderServer || (MatterbridgeWebRtcTransportProviderServer = {}));
419
+ export function createDefaultWebRtcTransportProviderClusterServer(endpoint, weriftOfferOptions = { video: true, audio: true, videoSource: 'none', audioSource: 'none' }) {
420
+ endpoint.behaviors.require(MatterbridgeWebRtcTransportProviderServer, { currentSessions: [], weriftOfferOptions });
421
+ return endpoint;
422
+ }
@@ -0,0 +1,10 @@
1
+ import { WebRtcTransportRequestorServer } from '@matter/node/behaviors/web-rtc-transport-requestor';
2
+ import type { WebRtcTransportRequestor } from '@matter/types/clusters/web-rtc-transport-requestor';
3
+ import type { MatterbridgeEndpoint } from '../matterbridgeEndpoint.js';
4
+ export declare class MatterbridgeWebRtcTransportRequestorServer extends WebRtcTransportRequestorServer {
5
+ offer(request: WebRtcTransportRequestor.OfferRequest): Promise<void>;
6
+ answer(request: WebRtcTransportRequestor.AnswerRequest): Promise<void>;
7
+ iceCandidates(request: WebRtcTransportRequestor.IceCandidatesRequest): Promise<void>;
8
+ end(request: WebRtcTransportRequestor.EndRequest): Promise<void>;
9
+ }
10
+ export declare function createDefaultWebRtcTransportRequestorClusterServer(endpoint: MatterbridgeEndpoint): MatterbridgeEndpoint;
@@ -0,0 +1,24 @@
1
+ import { WebRtcTransportRequestorServer } from '@matter/node/behaviors/web-rtc-transport-requestor';
2
+ import { emitCommand } from '../matterbridgeEndpointHelpers.js';
3
+ export class MatterbridgeWebRtcTransportRequestorServer extends WebRtcTransportRequestorServer {
4
+ async offer(request) {
5
+ await super.offer(request);
6
+ emitCommand(this.endpoint, WebRtcTransportRequestorServer.id, 'offer', request, this.context);
7
+ }
8
+ async answer(request) {
9
+ await super.answer(request);
10
+ emitCommand(this.endpoint, WebRtcTransportRequestorServer.id, 'answer', request, this.context);
11
+ }
12
+ async iceCandidates(request) {
13
+ await super.iceCandidates(request);
14
+ emitCommand(this.endpoint, WebRtcTransportRequestorServer.id, 'iceCandidates', request, this.context);
15
+ }
16
+ async end(request) {
17
+ await super.end(request);
18
+ emitCommand(this.endpoint, WebRtcTransportRequestorServer.id, 'end', request, this.context);
19
+ }
20
+ }
21
+ export function createDefaultWebRtcTransportRequestorClusterServer(endpoint) {
22
+ endpoint.behaviors.require(MatterbridgeWebRtcTransportRequestorServer, { currentSessions: [] });
23
+ return endpoint;
24
+ }
@@ -0,0 +1,63 @@
1
+ import { RTCPeerConnection } from 'werift';
2
+ type VideoSource = 'none' | 'test' | 'webcam' | 'rtsp';
3
+ type AudioSource = 'none' | 'test' | 'microphone' | 'rtsp';
4
+ export interface WeriftOfferOptions {
5
+ video: boolean;
6
+ audio: boolean;
7
+ videoSource: VideoSource;
8
+ videoSourceDevice?: string;
9
+ videoResolution?: string;
10
+ videoBitrate?: number;
11
+ audioSource: AudioSource;
12
+ audioSourceDevice?: string;
13
+ }
14
+ export type WeriftOfferOverrides = Partial<WeriftOfferOptions>;
15
+ export declare class WeriftWebRtcSession {
16
+ private static readonly activeSessions;
17
+ private static exitHandlerRegistered;
18
+ readonly peerConnection: RTCPeerConnection;
19
+ private readonly log;
20
+ private readonly webRtcSessionId;
21
+ private readonly options;
22
+ private testVideoGenerator?;
23
+ private testVideoUdpDisposer?;
24
+ private testVideoAttached;
25
+ private testAudioGenerator?;
26
+ private testAudioUdpDisposer?;
27
+ private testAudioAttached;
28
+ private diagnosticsInterval?;
29
+ private static readonly DIAGNOSTICS_INTERVAL_MS;
30
+ private lastDiagnosticsCounters?;
31
+ private readonly dtlsTransportsWithStateLogging;
32
+ constructor(webRtcSessionId: number, options: WeriftOfferOptions);
33
+ private summarizeSdp;
34
+ private getFreeUdpPort;
35
+ private getPreferredInjectableVideoCodec;
36
+ private getPreferredInjectableAudioCodec;
37
+ private static readonly SUPPORTED_VIDEO_RESOLUTIONS;
38
+ private static readonly DEFAULT_BITRATE_KBPS;
39
+ private getConfiguredVideoBitrate;
40
+ private getConfiguredVideoResolution;
41
+ private getConfiguredVideoSource;
42
+ private buildFfmpegVideoInputArgs;
43
+ private generateVideoTrack;
44
+ private preferVideoCodecOnTransceivers;
45
+ private preferAudioCodecOnTransceivers;
46
+ private static readonly TEST_VOICE_PATH;
47
+ private getConfiguredAudioSource;
48
+ private buildFfmpegAudioInputArgs;
49
+ private generateAudioTrack;
50
+ private cleanupTestVideoArtifacts;
51
+ private cleanupTestAudioArtifacts;
52
+ private applyOptionOverrides;
53
+ createOffer(overrides?: WeriftOfferOverrides): Promise<string>;
54
+ createAnswer(offerSdp: string, overrides?: WeriftOfferOverrides): Promise<string>;
55
+ applyAnswer(answerSdp: string): Promise<void>;
56
+ addIceCandidate(candidate: string, sdpMid: string | null, sdpMLineIndex: number | null): Promise<void>;
57
+ private closing;
58
+ private logDtlsTransportStateChanges;
59
+ private logDiagnosticsSnapshot;
60
+ close(): Promise<void>;
61
+ static closeAll(): Promise<void>;
62
+ }
63
+ export {};