mirror of
https://github.com/koush/scrypted.git
synced 2026-05-04 21:30:30 +01:00
Sip plugin (#444)
* initial commit * Cleanup * Rename * Cleanup * cleanup * Save work * save work * save work * save work * cleanup package.json * Use a testcall n startup to get the SDP and use it later. * Audio via gstreamer * Working with gstreamer mpegtsmux for audio with audiomixer * Make sure that we wait fro the SIP OK after SIP BYE * Cleanup * Cleanup and remove motion sensor * SIP plugin does not support snapshots. * Working version without gstreamer. * Force usage of TCP transport for RTSP video stream * Add H264 infos * Implement settings * Cleanup * Save work * Add SIP settings to UI. Clean up.
This commit is contained in:
19
plugins/rtsp/package-lock.json
generated
19
plugins/rtsp/package-lock.json
generated
@@ -7,7 +7,6 @@
|
||||
"": {
|
||||
"name": "@scrypted/rtsp",
|
||||
"version": "0.0.51",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache",
|
||||
"dependencies": {
|
||||
"@koush/axios-digest-auth": "^0.8.5",
|
||||
@@ -38,7 +37,7 @@
|
||||
},
|
||||
"../../sdk": {
|
||||
"name": "@scrypted/sdk",
|
||||
"version": "0.0.199",
|
||||
"version": "0.2.10",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -47,12 +46,13 @@
|
||||
"axios": "^0.21.4",
|
||||
"babel-loader": "^8.2.3",
|
||||
"babel-plugin-const-enum": "^1.1.0",
|
||||
"esbuild": "^0.13.8",
|
||||
"esbuild": "^0.15.9",
|
||||
"ncp": "^2.0.0",
|
||||
"raw-loader": "^4.0.2",
|
||||
"rimraf": "^3.0.2",
|
||||
"tmp": "^0.2.1",
|
||||
"webpack": "^5.59.0"
|
||||
"webpack": "^5.74.0",
|
||||
"webpack-bundle-analyzer": "^4.5.0"
|
||||
},
|
||||
"bin": {
|
||||
"scrypted-debug": "bin/scrypted-debug.js",
|
||||
@@ -68,9 +68,7 @@
|
||||
"@types/stringify-object": "^4.0.0",
|
||||
"stringify-object": "^3.3.0",
|
||||
"ts-node": "^10.4.0",
|
||||
"typedoc": "^0.22.8",
|
||||
"typescript-json-schema": "^0.50.1",
|
||||
"webpack-bundle-analyzer": "^4.5.0"
|
||||
"typedoc": "^0.23.15"
|
||||
}
|
||||
},
|
||||
"../sdk": {
|
||||
@@ -200,16 +198,15 @@
|
||||
"axios": "^0.21.4",
|
||||
"babel-loader": "^8.2.3",
|
||||
"babel-plugin-const-enum": "^1.1.0",
|
||||
"esbuild": "^0.13.8",
|
||||
"esbuild": "^0.15.9",
|
||||
"ncp": "^2.0.0",
|
||||
"raw-loader": "^4.0.2",
|
||||
"rimraf": "^3.0.2",
|
||||
"stringify-object": "^3.3.0",
|
||||
"tmp": "^0.2.1",
|
||||
"ts-node": "^10.4.0",
|
||||
"typedoc": "^0.22.8",
|
||||
"typescript-json-schema": "^0.50.1",
|
||||
"webpack": "^5.59.0",
|
||||
"typedoc": "^0.23.15",
|
||||
"webpack": "^5.74.0",
|
||||
"webpack-bundle-analyzer": "^4.5.0"
|
||||
}
|
||||
},
|
||||
|
||||
4
plugins/sip/.gitignore
vendored
Normal file
4
plugins/sip/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.DS_Store
|
||||
out/
|
||||
node_modules/
|
||||
dist/
|
||||
11
plugins/sip/.npmignore
Normal file
11
plugins/sip/.npmignore
Normal file
@@ -0,0 +1,11 @@
|
||||
.DS_Store
|
||||
out/
|
||||
node_modules/
|
||||
*.map
|
||||
fs
|
||||
src
|
||||
.vscode
|
||||
dist/*.js
|
||||
dist/*.txt
|
||||
HAP-NodeJS
|
||||
.gitmodules
|
||||
23
plugins/sip/.vscode/launch.json
vendored
Normal file
23
plugins/sip/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Scrypted Debugger",
|
||||
"address": "${config:scrypted.debugHost}",
|
||||
"port": 10081,
|
||||
"request": "attach",
|
||||
"skipFiles": [
|
||||
"**/plugin-remote-worker.*",
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"preLaunchTask": "scrypted: deploy+debug",
|
||||
"sourceMaps": true,
|
||||
"localRoot": "${workspaceFolder}/out",
|
||||
"remoteRoot": "/plugin/",
|
||||
"type": "pwa-node"
|
||||
}
|
||||
]
|
||||
}
|
||||
4
plugins/sip/.vscode/settings.json
vendored
Normal file
4
plugins/sip/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
{
|
||||
"scrypted.debugHost": "koushik-ubuntu",
|
||||
}
|
||||
20
plugins/sip/.vscode/tasks.json
vendored
Normal file
20
plugins/sip/.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// See https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
// for the documentation about the tasks.json format
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "scrypted: deploy+debug",
|
||||
"type": "shell",
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "silent",
|
||||
"focus": false,
|
||||
"panel": "shared",
|
||||
"showReuseMessage": true,
|
||||
"clear": false
|
||||
},
|
||||
"command": "npm run scrypted-vscode-launch ${config:scrypted.debugHost}",
|
||||
},
|
||||
]
|
||||
}
|
||||
3
plugins/sip/README.md
Normal file
3
plugins/sip/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# SIP Plugin for Scrypted
|
||||
|
||||
The SIP Plugin bridges compatible SIP Cameras in Scrypted to HomeKit.
|
||||
2379
plugins/sip/package-lock.json
generated
Normal file
2379
plugins/sip/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
plugins/sip/package.json
Normal file
49
plugins/sip/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@scrypted/sip",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"scrypted-setup-project": "scrypted-setup-project",
|
||||
"prescrypted-setup-project": "scrypted-package-json",
|
||||
"build": "scrypted-webpack",
|
||||
"prepublishOnly": "NODE_ENV=production scrypted-webpack",
|
||||
"prescrypted-vscode-launch": "scrypted-webpack",
|
||||
"scrypted-vscode-launch": "scrypted-deploy-debug",
|
||||
"scrypted-deploy-debug": "scrypted-deploy-debug",
|
||||
"scrypted-debug": "scrypted-debug",
|
||||
"scrypted-deploy": "scrypted-deploy",
|
||||
"scrypted-readme": "scrypted-readme",
|
||||
"scrypted-package-json": "scrypted-package-json"
|
||||
},
|
||||
"keywords": [
|
||||
"scrypted",
|
||||
"plugin",
|
||||
"sip"
|
||||
],
|
||||
"scrypted": {
|
||||
"name": "SIP Plugin",
|
||||
"type": "DeviceProvider",
|
||||
"interfaces": [
|
||||
"DeviceProvider",
|
||||
"DeviceCreator"
|
||||
],
|
||||
"pluginDependencies": [
|
||||
"@scrypted/prebuffer-mixin",
|
||||
"@scrypted/pam-diff",
|
||||
"@scrypted/snapshot"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@homebridge/camera-utils": "^2.0.4",
|
||||
"rxjs": "^7.5.5",
|
||||
"sdp": "^3.0.3",
|
||||
"sip": "0.0.6",
|
||||
"stun": "^2.1.0",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@scrypted/common": "file:../../common",
|
||||
"@scrypted/sdk": "file:../../sdk",
|
||||
"@types/node": "^16.9.6",
|
||||
"@types/uuid": "^8.3.4"
|
||||
}
|
||||
}
|
||||
479
plugins/sip/src/main.ts
Normal file
479
plugins/sip/src/main.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
import { closeQuiet, createBindZero, listenZero } from '@scrypted/common/src/listen-cluster';
|
||||
import { StorageSettings } from '@scrypted/sdk/storage-settings';
|
||||
import sdk, { BinarySensor, Camera, Device, DeviceProvider, DeviceCreator, DeviceCreatorSettings, FFmpegInput, Intercom, MediaObject, MediaStreamUrl, PictureOptions, RequestMediaStreamOptions, RequestPictureOptions, ResponseMediaStreamOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings, SettingValue, VideoCamera } from '@scrypted/sdk';
|
||||
import child_process, { ChildProcess } from 'child_process';
|
||||
import { ffmpegLogInitialOutput, safePrintFFmpegArguments } from "@scrypted/common/src/media-helpers";
|
||||
import dgram from 'dgram';
|
||||
import net from 'net';
|
||||
import { SipSession } from './sip-session';
|
||||
import { SipOptions } from './sip-call';
|
||||
import { RtpDescription, isStunMessage, getPayloadType, getSequenceNumber, isRtpMessagePayloadType } from './rtp-utils';
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
const { deviceManager, mediaManager } = sdk;
|
||||
|
||||
class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCamera, Settings, BinarySensor {
|
||||
buttonTimeout: NodeJS.Timeout;
|
||||
session: SipSession;
|
||||
remoteRtpDescription: RtpDescription;
|
||||
audioOutForwarder: dgram.Socket;
|
||||
audioOutProcess: ChildProcess;
|
||||
doorbellAudioActive: boolean;
|
||||
audioInProcess: ChildProcess;
|
||||
audioSilenceProcess: ChildProcess;
|
||||
clientSocket: net.Socket;
|
||||
|
||||
constructor(nativeId: string, public provider: SipCamProvider) {
|
||||
super(nativeId);
|
||||
this.binaryState = false;
|
||||
this.doorbellAudioActive = false;
|
||||
this.audioSilenceProcess = null;
|
||||
}
|
||||
|
||||
async takePicture(option?: PictureOptions): Promise<MediaObject> {
|
||||
throw new Error("The SIP doorbell camera does not provide snapshots. Install the Snapshot Plugin if snapshots are available via an URL.");
|
||||
}
|
||||
|
||||
async getPictureOptions(): Promise<PictureOptions[]> {
|
||||
return;
|
||||
}
|
||||
|
||||
storageSettings = new StorageSettings(this, {
|
||||
ffmpegInputs: {
|
||||
title: 'RTSP Stream URL',
|
||||
description: 'An RTSP Stream URL provided by the camera.',
|
||||
placeholder: 'rtsp://192.168.1.100[:554]/channel/101',
|
||||
// TODO: Support different streams to support different resolutions
|
||||
multiple: true,
|
||||
},
|
||||
})
|
||||
|
||||
async getFFmpegInputSettings(): Promise<Setting[]> {
|
||||
return this.storageSettings.getSettings();
|
||||
}
|
||||
|
||||
async putSetting(key: string, value: SettingValue) {
|
||||
if (this.storageSettings.settings[key]) {
|
||||
this.storageSettings.putSetting(key, value);
|
||||
}
|
||||
else if (key === 'defaultStream') {
|
||||
const vsos = await this.getVideoStreamOptions();
|
||||
const stream = vsos.find(vso => vso.name === value);
|
||||
this.storage.setItem('defaultStream', stream?.id);
|
||||
}
|
||||
else {
|
||||
this.storage.setItem(key, value.toString());
|
||||
}
|
||||
|
||||
this.onDeviceEvent(ScryptedInterface.Settings, undefined);
|
||||
}
|
||||
|
||||
async getSettings(): Promise<Setting[]> {
|
||||
return [
|
||||
{
|
||||
key: 'username',
|
||||
title: 'Username',
|
||||
value: this.storage.getItem('username'),
|
||||
description: 'Optional: Username for snapshot http requests.',
|
||||
},
|
||||
{
|
||||
key: 'password',
|
||||
title: 'Password',
|
||||
value: this.storage.getItem('password'),
|
||||
type: 'password',
|
||||
description: 'Optional: Password for snapshot http requests.',
|
||||
},
|
||||
...await this.getFFmpegInputSettings(),
|
||||
{
|
||||
key: 'sipfrom',
|
||||
title: 'SIP From: URI',
|
||||
value: this.storage.getItem('sipfrom'),
|
||||
description: 'SIP URI From: field. Host part is the local IP to listen on. Optional local UDP port for SIP signaling can be specified.',
|
||||
placeholder: '1234@192.168.0.111[:5060]',
|
||||
multiple: false,
|
||||
},
|
||||
{
|
||||
key: 'sipto',
|
||||
title: 'SIP To: URI',
|
||||
value: this.storage.getItem('sipto'),
|
||||
description: 'SIP URI To: field.',
|
||||
placeholder: '11@192.168.0.22',
|
||||
multiple: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async startIntercom(media: MediaObject): Promise<void> {
|
||||
|
||||
await this.callDoorbell();
|
||||
|
||||
if (!this.session)
|
||||
throw new Error("not in call");
|
||||
|
||||
this.stopAudioOut();
|
||||
|
||||
const ffmpegInput: FFmpegInput = JSON.parse((await mediaManager.convertMediaObjectToBuffer(media, ScryptedMimeTypes.FFmpegInput)).toString());
|
||||
|
||||
const remoteRtpDescription = this.remoteRtpDescription;
|
||||
const audioOutForwarder = await createBindZero();
|
||||
this.audioOutForwarder = audioOutForwarder.server;
|
||||
audioOutForwarder.server.on('message', message => {
|
||||
this.session.audioSplitter.send(message, remoteRtpDescription.audio.port, remoteRtpDescription.address);
|
||||
return null;
|
||||
});
|
||||
|
||||
const args = ffmpegInput.inputArguments.slice();
|
||||
args.push(
|
||||
'-vn', '-dn', '-sn',
|
||||
'-acodec', 'pcm_mulaw',
|
||||
'-flags', '+global_header',
|
||||
'-ac', '1',
|
||||
'-ar', '8k',
|
||||
'-f', 'rtp',
|
||||
`rtp://127.0.0.1:${audioOutForwarder.port}?pkt_size=188`,
|
||||
);
|
||||
|
||||
const cp = child_process.spawn(await mediaManager.getFFmpegPath(), args);
|
||||
this.audioOutProcess = cp;
|
||||
cp.on('exit', () => this.console.log('two way audio ended'));
|
||||
this.session.onCallEnded.subscribe(() => {
|
||||
closeQuiet(audioOutForwarder.server);
|
||||
cp.kill('SIGKILL');
|
||||
});
|
||||
}
|
||||
|
||||
async stopIntercom(): Promise<void> {
|
||||
this.stopAudioOut();
|
||||
this.stopSession();
|
||||
}
|
||||
|
||||
async stopAudioOut(): Promise<void> {
|
||||
closeQuiet(this.audioOutForwarder);
|
||||
this.audioOutProcess?.kill('SIGKILL');
|
||||
this.audioOutProcess = undefined;
|
||||
this.audioOutForwarder = undefined;
|
||||
}
|
||||
|
||||
stopSession() {
|
||||
this.doorbellAudioActive = false;
|
||||
this.audioInProcess?.kill('SIGKILL');
|
||||
if (this.session) {
|
||||
this.console.log('ending sip session');
|
||||
this.session.stop();
|
||||
this.session = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async callDoorbell(): Promise<void> {
|
||||
let sip: SipSession;
|
||||
|
||||
const cleanup = () => {
|
||||
if (this.session === sip)
|
||||
this.session = undefined;
|
||||
try {
|
||||
this.console.log('stopping sip session.');
|
||||
sip.stop();
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
const from = this.storage.getItem('sipfrom');
|
||||
const to = this.storage.getItem('sipto');
|
||||
const localIp = from.split(':')[0].split('@')[1];
|
||||
const localPort = from.split(':')[1] ?? 5060;
|
||||
|
||||
if (!from || !to || !localIp || !localPort) {
|
||||
this.console.error('SIP From: and To: URIs not specified!');
|
||||
return;
|
||||
}
|
||||
|
||||
this.console.log(`SIP: Calling doorbell: From: ${from}, To: ${to}`);
|
||||
this.console.log(`SIP: localIp: ${localIp}, localPort: ${localPort}`);
|
||||
|
||||
let sipOptions: SipOptions = { from: "sip:" + from, to: "sip:" + to, localIp: "10.10.10.70", localPort: 5060 };
|
||||
|
||||
sip = await SipSession.createSipSession(this.console, this.name, sipOptions);
|
||||
sip.onCallEnded.subscribe(cleanup);
|
||||
this.remoteRtpDescription = await sip.start();
|
||||
this.console.log('SIP: Received remote SDP:\n', this.remoteRtpDescription.sdp)
|
||||
|
||||
let [rtpPort, rtcpPort] = await SipSession.reserveRtpRtcpPorts()
|
||||
this.console.log(`Reserved RTP port ${rtpPort} and RTCP port ${rtcpPort} for incoming SIP audio`);
|
||||
|
||||
const ffmpegPath = await mediaManager.getFFmpegPath();
|
||||
|
||||
const ffmpegArgs = [
|
||||
'-hide_banner',
|
||||
'-nostats',
|
||||
'-f', 'rtp',
|
||||
'-i', `rtp://127.0.0.1:${rtpPort}?listen&localrtcpport=${rtcpPort}`,
|
||||
'-acodec', 'copy',
|
||||
'-f', 'mulaw',
|
||||
'pipe:3'
|
||||
];
|
||||
|
||||
safePrintFFmpegArguments(console, ffmpegArgs);
|
||||
const cp = child_process.spawn(ffmpegPath, ffmpegArgs, {
|
||||
stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
this.audioInProcess = cp;
|
||||
ffmpegLogInitialOutput(console, cp);
|
||||
|
||||
cp.stdout.on('data', data => this.console.log(data.toString()));
|
||||
cp.stderr.on('data', data => this.console.log(data.toString()));
|
||||
|
||||
this.doorbellAudioActive = true;
|
||||
cp.stdio[3].on('data', data => {
|
||||
if (this.doorbellAudioActive && this.clientSocket) {
|
||||
this.clientSocket.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
let aseq = 0;
|
||||
let aseen = 0;
|
||||
let alost = 0;
|
||||
|
||||
sip.audioSplitter.on('message', message => {
|
||||
if (!isStunMessage(message)) {
|
||||
const isRtpMessage = isRtpMessagePayloadType(getPayloadType(message));
|
||||
if (!isRtpMessage)
|
||||
return;
|
||||
aseen++;
|
||||
sip.audioSplitter.send(message, rtpPort, "127.0.0.1");
|
||||
const seq = getSequenceNumber(message);
|
||||
if (seq !== (aseq + 1) % 0x0FFFF)
|
||||
alost++;
|
||||
aseq = seq;
|
||||
}
|
||||
});
|
||||
|
||||
sip.audioRtcpSplitter.on('message', message => {
|
||||
sip.audioRtcpSplitter.send(message, rtcpPort, "127.0.0.1");
|
||||
});
|
||||
|
||||
this.session = sip;
|
||||
}
|
||||
|
||||
getRawVideoStreamOptions(): ResponseMediaStreamOptions[] {
|
||||
const ffmpegInputs = this.storageSettings.values.ffmpegInputs as string[];
|
||||
|
||||
// filter out empty strings.
|
||||
const ret = ffmpegInputs
|
||||
.filter(ffmpegInput => !!ffmpegInput)
|
||||
.map((ffmpegInput, index) => this.createFFmpegMediaStreamOptions(ffmpegInput, index));
|
||||
|
||||
if (!ret.length)
|
||||
return;
|
||||
return ret;
|
||||
|
||||
}
|
||||
|
||||
async getVideoStreamOptions(): Promise<ResponseMediaStreamOptions[]> {
|
||||
const vsos = this.getRawVideoStreamOptions();
|
||||
return vsos;
|
||||
}
|
||||
|
||||
getDefaultStream(vsos: ResponseMediaStreamOptions[]) {
|
||||
return vsos?.[0];
|
||||
}
|
||||
|
||||
async getVideoStream(options?: ResponseMediaStreamOptions): Promise<MediaObject> {
|
||||
const vsos = await this.getVideoStreamOptions();
|
||||
const vso = vsos?.find(s => s.id === options?.id) || this.getDefaultStream(vsos);
|
||||
return this.createVideoStream(vso);
|
||||
}
|
||||
|
||||
|
||||
createFFmpegMediaStreamOptions(ffmpegInput: string, index: number){
|
||||
try {
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
|
||||
return {
|
||||
id: `channel${index}`,
|
||||
name: `Stream ${index + 1}`,
|
||||
url: undefined,
|
||||
container: '', // must be empty to support prebuffering
|
||||
video: {
|
||||
codec: 'h264',
|
||||
h264Info: {
|
||||
sei: false,
|
||||
stapb: false,
|
||||
mtap16: false,
|
||||
mtap32: false,
|
||||
fuab: false,
|
||||
reserved0: false,
|
||||
reserved30: false,
|
||||
reserved31: false,
|
||||
}
|
||||
},
|
||||
audio: { /*this.isAudioDisabled() ? null : {}, */
|
||||
// this is a hint to let homekit, et al, know that it's OPUS audio and does not need transcoding.
|
||||
codec: 'pcm_mulaw',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async startSilenceGenerator() {
|
||||
|
||||
if (this.audioSilenceProcess)
|
||||
return;
|
||||
|
||||
const ffmpegPath = await mediaManager.getFFmpegPath();
|
||||
const ffmpegArgs = [
|
||||
'-hide_banner',
|
||||
'-nostats',
|
||||
'-re',
|
||||
'-f', 'lavfi',
|
||||
'-i', 'anullsrc=r=8000:cl=mono',
|
||||
'-f', 'mulaw',
|
||||
'pipe:3'
|
||||
];
|
||||
|
||||
safePrintFFmpegArguments(console, ffmpegArgs);
|
||||
const cp = child_process.spawn(ffmpegPath, ffmpegArgs, {
|
||||
stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
this.audioSilenceProcess = cp;
|
||||
ffmpegLogInitialOutput(console, cp);
|
||||
|
||||
cp.stdout.on('data', data => this.console.log(data.toString()));
|
||||
cp.stderr.on('data', data => this.console.log(data.toString()));
|
||||
cp.stdio[3].on('data', data => {
|
||||
if (!this.doorbellAudioActive && this.clientSocket) {
|
||||
this.clientSocket.write(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
stopSilenceGenerator() {
|
||||
this.audioSilenceProcess?.kill();
|
||||
this.audioSilenceProcess = null;
|
||||
}
|
||||
|
||||
async startAudioServer(): Promise<number> {
|
||||
|
||||
const server = net.createServer(async (clientSocket) => {
|
||||
clearTimeout(serverTimeout);
|
||||
|
||||
this.clientSocket = clientSocket;
|
||||
|
||||
this.startSilenceGenerator();
|
||||
|
||||
this.clientSocket.on('close', () => {
|
||||
this.stopSilenceGenerator();
|
||||
this.clientSocket = null;
|
||||
});
|
||||
});
|
||||
const serverTimeout = setTimeout(() => {
|
||||
this.console.log('timed out waiting for client');
|
||||
server.close();
|
||||
}, 30000);
|
||||
const port = await listenZero(server);
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
async createVideoStream(options?: ResponseMediaStreamOptions): Promise<MediaObject> {
|
||||
const index = this.getRawVideoStreamOptions()?.findIndex(vso => vso.id === options.id);
|
||||
const ffmpegInputs = this.storageSettings.values.ffmpegInputs as string[];
|
||||
const ffmpegInput = ffmpegInputs[index];
|
||||
|
||||
if (!ffmpegInput)
|
||||
throw new Error('video streams not set up or no longer exists.');
|
||||
|
||||
const port = await this.startAudioServer();
|
||||
|
||||
const ret: FFmpegInput = {
|
||||
url: undefined,
|
||||
inputArguments: [
|
||||
'-analyzeduration', '0',
|
||||
'-probesize', '32',
|
||||
'-fflags', 'nobuffer',
|
||||
'-flags', 'low_delay',
|
||||
'-f', 'rtsp',
|
||||
'-rtsp_transport', 'tcp',
|
||||
'-i', ffmpegInput, //'rtsp://10.10.10.10:8554/hauseingang',
|
||||
'-f', 'mulaw',
|
||||
'-ac', '1',
|
||||
'-ar', '8000',
|
||||
'-channel_layout', 'mono',
|
||||
'-use_wallclock_as_timestamps', 'true',
|
||||
'-i', `tcp://127.0.0.1:${port}?tcp_nodelay=1`,
|
||||
],
|
||||
mediaStreamOptions: options,
|
||||
};
|
||||
|
||||
return mediaManager.createFFmpegMediaObject(ret);
|
||||
}
|
||||
|
||||
triggerBinaryState() {
|
||||
this.binaryState = true;
|
||||
clearTimeout(this.buttonTimeout);
|
||||
this.buttonTimeout = setTimeout(() => this.binaryState = false, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
export class SipCamProvider extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator {
|
||||
|
||||
devices = new Map<string, any>();
|
||||
|
||||
constructor(nativeId?: string) {
|
||||
super(nativeId);
|
||||
|
||||
for (const camId of deviceManager.getNativeIds()) {
|
||||
if (camId)
|
||||
this.getDevice(camId);
|
||||
}
|
||||
}
|
||||
|
||||
async createDevice(settings: DeviceCreatorSettings): Promise<string> {
|
||||
const nativeId = randomBytes(4).toString('hex');
|
||||
const name = settings.newCamera.toString();
|
||||
await this.updateDevice(nativeId, name);
|
||||
return nativeId;
|
||||
}
|
||||
|
||||
async getCreateDeviceSettings(): Promise<Setting[]> {
|
||||
return [
|
||||
{
|
||||
key: 'newCamera',
|
||||
title: 'Add Camera',
|
||||
placeholder: 'Camera name, e.g.: Back Yard Camera, Baby Camera, etc',
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
updateDevice(nativeId: string, name: string) {
|
||||
return deviceManager.onDeviceDiscovered({
|
||||
nativeId,
|
||||
name,
|
||||
interfaces: [
|
||||
ScryptedInterface.Camera,
|
||||
ScryptedInterface.VideoCamera,
|
||||
ScryptedInterface.Settings,
|
||||
ScryptedInterface.Intercom,
|
||||
ScryptedInterface.BinarySensor
|
||||
],
|
||||
type: ScryptedDeviceType.Doorbell,
|
||||
});
|
||||
}
|
||||
|
||||
getDevice(nativeId: string) {
|
||||
let ret = this.devices.get(nativeId);
|
||||
if (!ret) {
|
||||
ret = this.createCamera(nativeId);
|
||||
if (ret)
|
||||
this.devices.set(nativeId, ret);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
createCamera(nativeId: string): SipCamera {
|
||||
return new SipCamera(nativeId, this);
|
||||
}
|
||||
}
|
||||
|
||||
export default new SipCamProvider();
|
||||
121
plugins/sip/src/rtp-utils.ts
Normal file
121
plugins/sip/src/rtp-utils.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
// by @dgrief from @homebridge/camera-utils
|
||||
import dgram from 'dgram'
|
||||
const stun = require('stun')
|
||||
|
||||
const stunMagicCookie = 0x2112a442 // https://tools.ietf.org/html/rfc5389#section-6
|
||||
|
||||
export interface RtpStreamOptions {
|
||||
port: number
|
||||
rtcpPort: number
|
||||
}
|
||||
|
||||
export interface RtpOptions {
|
||||
audio: RtpStreamOptions
|
||||
}
|
||||
|
||||
export interface RtpStreamDescription extends RtpStreamOptions {
|
||||
ssrc?: number
|
||||
iceUFrag?: string
|
||||
icePwd?: string
|
||||
}
|
||||
|
||||
export interface RtpDescription {
|
||||
address: string
|
||||
audio: RtpStreamDescription
|
||||
sdp: string
|
||||
}
|
||||
|
||||
export function isRtpMessagePayloadType(payloadType: number) {
|
||||
return payloadType > 90 || payloadType === 0
|
||||
}
|
||||
|
||||
export function getPayloadType(message: Buffer) {
|
||||
return message.readUInt8(1) & 0x7f
|
||||
}
|
||||
|
||||
export function getSequenceNumber(message: Buffer) {
|
||||
return message.readUInt16BE(2)
|
||||
}
|
||||
|
||||
export function isStunMessage(message: Buffer) {
|
||||
return message.length > 8 && message.readInt32BE(4) === stunMagicCookie
|
||||
}
|
||||
|
||||
export function sendStunBindingRequest({
|
||||
rtpDescription,
|
||||
rtpSplitter,
|
||||
rtcpSplitter,
|
||||
localUfrag,
|
||||
type,
|
||||
}: {
|
||||
rtpSplitter: dgram.Socket
|
||||
rtcpSplitter: dgram.Socket
|
||||
rtpDescription: RtpDescription
|
||||
localUfrag?: string
|
||||
type: 'video' | 'audio'
|
||||
}) {
|
||||
const message = stun.createMessage(1),
|
||||
remoteDescription = rtpDescription[type],
|
||||
{ address } = rtpDescription,
|
||||
{ iceUFrag, icePwd, port, rtcpPort } = remoteDescription
|
||||
|
||||
if (iceUFrag && icePwd && localUfrag) {
|
||||
// Full ICE supported. Send as formal stun request
|
||||
message.addUsername(iceUFrag + ':' + localUfrag)
|
||||
message.addMessageIntegrity(icePwd)
|
||||
|
||||
stun
|
||||
.request(`${address}:${port}`, {
|
||||
socket: rtpSplitter,
|
||||
message,
|
||||
})
|
||||
.then(() => console.debug(`${type} stun complete`))
|
||||
.catch((e: Error) => {
|
||||
console.error(`${type} stun error`)
|
||||
console.error(e)
|
||||
})
|
||||
} else {
|
||||
// ICE not supported. Fire and forget the stun request for RTP and RTCP
|
||||
const encodedMessage = stun.encode(message)
|
||||
try {
|
||||
rtpSplitter.send(encodedMessage, port, address)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
try {
|
||||
rtcpSplitter.send(encodedMessage, rtcpPort, address)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createStunResponder(rtpSplitter: dgram.Socket) {
|
||||
return rtpSplitter.on('message', (message, info) => {
|
||||
if (!isStunMessage(message)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const decodedMessage = stun.decode(message),
|
||||
response = stun.createMessage(
|
||||
stun.constants.STUN_BINDING_RESPONSE,
|
||||
decodedMessage.transactionId
|
||||
)
|
||||
|
||||
response.addXorAddress(info.address, info.port)
|
||||
try {
|
||||
rtpSplitter.send(stun.encode(response), info.port, info.address)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('Failed to Decode STUN Message')
|
||||
console.debug(message.toString('hex'))
|
||||
console.debug(e)
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
}
|
||||
304
plugins/sip/src/sip-call.ts
Normal file
304
plugins/sip/src/sip-call.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { noop, Subject } from 'rxjs'
|
||||
import { randomInteger, randomString } from './util'
|
||||
import { RtpDescription, RtpOptions, RtpStreamDescription } from './rtp-utils'
|
||||
|
||||
const sip = require('sip'),
|
||||
sdp = require('sdp')
|
||||
|
||||
export interface SipOptions {
|
||||
to: string
|
||||
from: string
|
||||
localIp: string
|
||||
localPort: number
|
||||
}
|
||||
|
||||
interface UriOptions {
|
||||
name?: string
|
||||
uri: string
|
||||
params?: { tag?: string }
|
||||
}
|
||||
|
||||
interface SipHeaders {
|
||||
[name: string]: string | any
|
||||
cseq: { seq: number; method: string }
|
||||
to: UriOptions
|
||||
from: UriOptions
|
||||
contact?: UriOptions[]
|
||||
via?: UriOptions[]
|
||||
}
|
||||
|
||||
export interface SipRequest {
|
||||
uri: UriOptions | string
|
||||
method: string
|
||||
headers: SipHeaders
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface SipResponse {
|
||||
status: number
|
||||
reason: string
|
||||
headers: SipHeaders
|
||||
content: string
|
||||
}
|
||||
|
||||
interface SipStack {
|
||||
send: (
|
||||
request: SipRequest | SipResponse,
|
||||
handler?: (response: SipResponse) => void
|
||||
) => void
|
||||
destroy: () => void
|
||||
makeResponse: (
|
||||
response: SipRequest,
|
||||
status: number,
|
||||
method: string
|
||||
) => SipResponse
|
||||
}
|
||||
|
||||
function getRandomId() {
|
||||
return Math.floor(Math.random() * 1e6).toString()
|
||||
}
|
||||
|
||||
function getRtpDescription(
|
||||
console: any,
|
||||
sections: string[],
|
||||
mediaType: 'audio'
|
||||
): RtpStreamDescription {
|
||||
try {
|
||||
const section = sections.find((s) => s.startsWith('m=' + mediaType)),
|
||||
{ port } = sdp.parseMLine(section),
|
||||
lines: string[] = sdp.splitLines(section),
|
||||
rtcpLine = lines.find((l: string) => l.startsWith('a=rtcp:')),
|
||||
rtcpMuxLine = lines.find((l: string) => l.startsWith('a=rtcp-mux')),
|
||||
ssrcLine = lines.find((l: string) => l.startsWith('a=ssrc')),
|
||||
iceUFragLine = lines.find((l: string) => l.startsWith('a=ice-ufrag')),
|
||||
icePwdLine = lines.find((l: string) => l.startsWith('a=ice-pwd'))
|
||||
|
||||
let rtcpPort: number;
|
||||
if (rtcpMuxLine) {
|
||||
rtcpPort = port; // rtcp-mux would cause rtcpLine to not be present
|
||||
}
|
||||
else {
|
||||
rtcpPort = (rtcpLine && Number(rtcpLine.match(/rtcp:(\S*)/)?.[1])) || port + 1; // if there is no explicit RTCP port, then use RTP port + 1
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
rtcpPort,
|
||||
ssrc: (ssrcLine && Number(ssrcLine.match(/ssrc:(\S*)/)?.[1])) || undefined,
|
||||
iceUFrag: (iceUFragLine && iceUFragLine.match(/ice-ufrag:(\S*)/)?.[1]) || undefined,
|
||||
icePwd: (icePwdLine && icePwdLine.match(/ice-pwd:(\S*)/)?.[1]) || undefined,
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SDP from remote end')
|
||||
console.error(sections.join('\r\n'))
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function parseRtpDescription(console: any, inviteResponse: {
|
||||
content: string
|
||||
}): RtpDescription {
|
||||
const sections: string[] = sdp.splitSections(inviteResponse.content),
|
||||
lines: string[] = sdp.splitLines(sections[0]),
|
||||
cLine = lines.find((line: string) => line.startsWith('c='))!
|
||||
|
||||
return {
|
||||
sdp: inviteResponse.content,
|
||||
address: cLine.match(/c=IN IP4 (\S*)/)![1],
|
||||
audio: getRtpDescription(console, sections, 'audio')
|
||||
}
|
||||
}
|
||||
|
||||
export class SipCall {
|
||||
private seq = 20
|
||||
private fromParams = { tag: getRandomId() }
|
||||
private toParams: { tag?: string } = {}
|
||||
private callId = getRandomId()
|
||||
private sipStack: SipStack
|
||||
public readonly onEndedByRemote = new Subject()
|
||||
private destroyed = false
|
||||
private readonly console: any
|
||||
|
||||
public readonly sdp: string
|
||||
public readonly audioUfrag = randomString(16)
|
||||
public readonly videoUfrag = randomString(16)
|
||||
|
||||
constructor(
|
||||
console: any,
|
||||
private sipOptions: SipOptions,
|
||||
rtpOptions: RtpOptions,
|
||||
//tlsPort: number
|
||||
) {
|
||||
this.console = console;
|
||||
|
||||
const { audio } = rtpOptions,
|
||||
{ from } = this.sipOptions,
|
||||
host = this.sipOptions.localIp,
|
||||
port = this.sipOptions.localPort,
|
||||
ssrc = randomInteger();
|
||||
|
||||
this.sipStack = {
|
||||
makeResponse: sip.makeResponse,
|
||||
...sip.create({
|
||||
host,
|
||||
hostname: host,
|
||||
port: port,
|
||||
udp: true,
|
||||
tcp: false,
|
||||
tls: false,
|
||||
// tls_port: tlsPort,
|
||||
// tls: {
|
||||
// rejectUnauthorized: false,
|
||||
// },
|
||||
ws: false
|
||||
},
|
||||
(request: SipRequest) => {
|
||||
if (request.method === 'BYE') {
|
||||
this.console.info('received BYE from remote end')
|
||||
this.sipStack.send(this.sipStack.makeResponse(request, 200, 'Ok'))
|
||||
|
||||
if (this.destroyed) {
|
||||
this.onEndedByRemote.next(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
)}
|
||||
|
||||
this.sdp = ([
|
||||
'v=0',
|
||||
`o=${from.split(':')[1].split('@')[0]} 3747 461 IN IP4 ${host}`,
|
||||
's=ScryptedSipPlugin',
|
||||
`c=IN IP4 ${host}`,
|
||||
't=0 0',
|
||||
`m=audio ${audio.port} RTP/AVP 0`,
|
||||
'a=rtpmap:0 PCMU/8000',
|
||||
`a=rtcp:${audio.rtcpPort}`,
|
||||
`a=ssrc:${ssrc}`,
|
||||
'a=sendrecv'
|
||||
]
|
||||
.filter((l) => l)
|
||||
.join('\r\n')) + '\r\n';
|
||||
}
|
||||
|
||||
request({
|
||||
method,
|
||||
headers,
|
||||
content,
|
||||
seq,
|
||||
}: {
|
||||
method: string
|
||||
headers?: Partial<SipHeaders>
|
||||
content?: string
|
||||
seq?: number
|
||||
}) {
|
||||
if (this.destroyed) {
|
||||
return Promise.reject(
|
||||
new Error('SIP request made after call was destroyed')
|
||||
)
|
||||
}
|
||||
|
||||
return new Promise<SipResponse>((resolve, reject) => {
|
||||
seq = seq || this.seq++
|
||||
this.sipStack.send(
|
||||
{
|
||||
method,
|
||||
uri: this.sipOptions.to,
|
||||
headers: {
|
||||
to: {
|
||||
name: '"Scrypted SIP Plugin Client"',
|
||||
uri: this.sipOptions.to,
|
||||
params: this.toParams,
|
||||
},
|
||||
from: {
|
||||
uri: this.sipOptions.from,
|
||||
params: this.fromParams,
|
||||
},
|
||||
'max-forwards': 70,
|
||||
'call-id': this.callId,
|
||||
cseq: { seq, method },
|
||||
...headers,
|
||||
},
|
||||
content: content || '',
|
||||
},
|
||||
(response: SipResponse) => {
|
||||
if (response.headers.to.params && response.headers.to.params.tag) {
|
||||
this.toParams.tag = response.headers.to.params.tag
|
||||
}
|
||||
|
||||
if (response.status >= 300) {
|
||||
if (response.status !== 408 || method !== 'BYE') {
|
||||
this.console.error(
|
||||
`sip ${method} request failed with status ` + response.status
|
||||
)
|
||||
}
|
||||
reject(
|
||||
new Error(
|
||||
`sip ${method} request failed with status ` + response.status
|
||||
)
|
||||
)
|
||||
} else if (response.status < 200) {
|
||||
// call made progress, do nothing and wait for another response
|
||||
// console.log('call progress status ' + response.status)
|
||||
} else {
|
||||
if (method === 'INVITE') {
|
||||
// The ACK must be sent with every OK to keep the connection alive.
|
||||
this.acknowledge(seq!).catch((e) => {
|
||||
this.console.error('Failed to send SDP ACK')
|
||||
this.console.error(e)
|
||||
})
|
||||
}
|
||||
resolve(response)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private async acknowledge(seq: number) {
|
||||
// Don't wait for ack, it won't ever come back.
|
||||
this.request({
|
||||
method: 'ACK',
|
||||
seq, // The ACK must have the original sequence number.
|
||||
}).catch(noop)
|
||||
}
|
||||
|
||||
sendDtmf(key: string) {
|
||||
return this.request({
|
||||
method: 'INFO',
|
||||
headers: {
|
||||
'Content-Type': 'application/dtmf-relay',
|
||||
},
|
||||
content: `Signal=${key}\r\nDuration=250`,
|
||||
})
|
||||
}
|
||||
|
||||
async invite() {
|
||||
|
||||
const { from } = this.sipOptions,
|
||||
inviteResponse = await this.request({
|
||||
method: 'INVITE',
|
||||
headers: {
|
||||
supported: 'replaces, outbound',
|
||||
allow:
|
||||
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
|
||||
'content-type': 'application/sdp',
|
||||
contact: [{ uri: from }],
|
||||
},
|
||||
content: this.sdp,
|
||||
})
|
||||
|
||||
return parseRtpDescription(this.console, inviteResponse)
|
||||
}
|
||||
|
||||
async sendBye() {
|
||||
this.console.log('Sending BYE...')
|
||||
return this.request({ method: 'BYE' }).catch(() => {
|
||||
// Don't care if we get an exception here.
|
||||
})
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true
|
||||
this.sipStack.destroy()
|
||||
}
|
||||
}
|
||||
179
plugins/sip/src/sip-session.ts
Normal file
179
plugins/sip/src/sip-session.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { ReplaySubject, timer } from 'rxjs'
|
||||
import { once } from 'events'
|
||||
import { createStunResponder, RtpDescription, RtpOptions, sendStunBindingRequest } from './rtp-utils'
|
||||
import { reservePorts } from '@homebridge/camera-utils';
|
||||
import { SipCall, SipOptions } from './sip-call'
|
||||
import { Subscribed } from './subscribed'
|
||||
import dgram from 'dgram'
|
||||
|
||||
export async function bindUdp(server: dgram.Socket, usePort: number) {
|
||||
server.bind({
|
||||
port: usePort,
|
||||
// exclusive: false,
|
||||
// address: '0.0.0.0',
|
||||
})
|
||||
await once(server, 'listening')
|
||||
server.setRecvBufferSize(1024 * 1024)
|
||||
const port = server.address().port
|
||||
return {
|
||||
port,
|
||||
url: `udp://'0.0.0.0':${port}`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBindUdp(usePort: number) {
|
||||
const server = dgram.createSocket({
|
||||
type: 'udp4',
|
||||
// reuseAddr: true,
|
||||
}),
|
||||
{ port, url } = await bindUdp(server, usePort)
|
||||
return {
|
||||
server,
|
||||
port,
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
export class SipSession extends Subscribed {
|
||||
private hasStarted = false
|
||||
private hasCallEnded = false
|
||||
private onCallEndedSubject = new ReplaySubject(1)
|
||||
private sipCall: SipCall
|
||||
onCallEnded = this.onCallEndedSubject.asObservable()
|
||||
|
||||
constructor(
|
||||
public readonly console: any,
|
||||
public readonly sipOptions: SipOptions,
|
||||
public readonly rtpOptions: RtpOptions,
|
||||
public readonly audioSplitter: dgram.Socket,
|
||||
public audioRtcpSplitter: dgram.Socket,
|
||||
public readonly cameraName: string
|
||||
) {
|
||||
super()
|
||||
|
||||
this.sipCall = this.createSipCall(this.sipOptions)
|
||||
}
|
||||
|
||||
static async createSipSession(console: any, cameraName: string, sipOptions: SipOptions) {
|
||||
const audioPort = 0,
|
||||
audioSplitter = await createBindUdp(audioPort),
|
||||
audioRtcpSplitter = await createBindUdp(audioSplitter.port + 1),
|
||||
rtpOptions = {
|
||||
audio: {
|
||||
port: audioSplitter.port,
|
||||
rtcpPort: audioRtcpSplitter.port
|
||||
}
|
||||
}
|
||||
|
||||
return new SipSession(
|
||||
console,
|
||||
sipOptions,
|
||||
rtpOptions,
|
||||
audioSplitter.server,
|
||||
audioRtcpSplitter.server,
|
||||
cameraName
|
||||
)
|
||||
}
|
||||
|
||||
createSipCall(sipOptions: SipOptions) {
|
||||
if (this.sipCall) {
|
||||
this.sipCall.destroy()
|
||||
}
|
||||
|
||||
const call = (this.sipCall = new SipCall(
|
||||
this.console,
|
||||
sipOptions,
|
||||
this.rtpOptions
|
||||
))
|
||||
|
||||
this.addSubscriptions(
|
||||
call.onEndedByRemote.subscribe(() => this.callEnded(false))
|
||||
)
|
||||
|
||||
return this.sipCall
|
||||
}
|
||||
|
||||
async start(): Promise<RtpDescription> {
|
||||
this.console.log(`SipSession::start()`);
|
||||
|
||||
if (this.hasStarted) {
|
||||
throw new Error('SIP Session has already been started')
|
||||
}
|
||||
this.hasStarted = true
|
||||
|
||||
if (this.hasCallEnded) {
|
||||
throw new Error('SIP Session has already ended')
|
||||
}
|
||||
|
||||
try {
|
||||
const rtpDescription = await this.sipCall.invite(),
|
||||
sendStunRequests = () => {
|
||||
sendStunBindingRequest({
|
||||
rtpSplitter: this.audioSplitter,
|
||||
rtcpSplitter: this.audioRtcpSplitter,
|
||||
rtpDescription,
|
||||
localUfrag: this.sipCall.audioUfrag,
|
||||
type: 'audio',
|
||||
})
|
||||
}
|
||||
|
||||
// if rtcp-mux is supported, rtp splitter will be used for both rtp and rtcp
|
||||
if (rtpDescription.audio.port === rtpDescription.audio.rtcpPort) {
|
||||
this.audioRtcpSplitter.close()
|
||||
this.audioRtcpSplitter = this.audioSplitter
|
||||
}
|
||||
|
||||
if (rtpDescription.audio.iceUFrag) {
|
||||
// ICE is supported
|
||||
this.console.log(`Connecting to ${this.cameraName} using ICE`)
|
||||
createStunResponder(this.audioSplitter)
|
||||
|
||||
sendStunRequests()
|
||||
} else {
|
||||
// ICE is not supported, use stun as keep alive
|
||||
this.console.log(`Connecting to ${this.cameraName} using STUN`)
|
||||
this.addSubscriptions(
|
||||
// hole punch every .5 seconds to keep stream alive and port open (matches behavior from Ring app)
|
||||
timer(0, 500).subscribe(sendStunRequests)
|
||||
)
|
||||
}
|
||||
|
||||
this.audioSplitter.once('message', () => {
|
||||
this.console.log(`Audio stream latched for ${this.cameraName}`)
|
||||
})
|
||||
|
||||
return rtpDescription
|
||||
} catch (e) {
|
||||
|
||||
this.callEnded(true)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
static async reserveRtpRtcpPorts() {
|
||||
const ports = await reservePorts({ count: 2, type: 'udp' })
|
||||
return ports
|
||||
}
|
||||
|
||||
private async callEnded(sendBye: boolean) {
|
||||
if (this.hasCallEnded) {
|
||||
return
|
||||
}
|
||||
this.hasCallEnded = true
|
||||
|
||||
if (sendBye) {
|
||||
await this.sipCall.sendBye().catch(this.console.log)
|
||||
}
|
||||
|
||||
// clean up
|
||||
this.onCallEndedSubject.next(null)
|
||||
this.sipCall.destroy()
|
||||
this.audioSplitter.close()
|
||||
this.audioRtcpSplitter.close()
|
||||
this.unsubscribe()
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await this.callEnded(true)
|
||||
}
|
||||
}
|
||||
13
plugins/sip/src/subscribed.ts
Normal file
13
plugins/sip/src/subscribed.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Subscription } from 'rxjs'
|
||||
|
||||
export class Subscribed {
|
||||
private readonly subscriptions: Subscription[] = []
|
||||
|
||||
public addSubscriptions(...subscriptions: Subscription[]) {
|
||||
this.subscriptions.push(...subscriptions)
|
||||
}
|
||||
|
||||
protected unsubscribe() {
|
||||
this.subscriptions.forEach((subscription) => subscription.unsubscribe())
|
||||
}
|
||||
}
|
||||
20
plugins/sip/src/util.ts
Normal file
20
plugins/sip/src/util.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { v4 as generateRandomUuid, v5 as generateUuidFromNamespace } from 'uuid'
|
||||
|
||||
const uuidNamespace = 'e53ffdc0-e91d-4ce1-bec2-df939d94739d'
|
||||
|
||||
export function generateUuid(seed?: string) {
|
||||
if (seed) {
|
||||
return generateUuidFromNamespace(seed, uuidNamespace)
|
||||
}
|
||||
|
||||
return generateRandomUuid()
|
||||
}
|
||||
|
||||
export function randomInteger() {
|
||||
return Math.floor(Math.random() * 99999999) + 100000
|
||||
}
|
||||
|
||||
export function randomString(length: number) {
|
||||
const uuid = generateUuid()
|
||||
return uuid.replace(/-/g, '').substring(0, length).toLowerCase()
|
||||
}
|
||||
11
plugins/sip/tsconfig.json
Normal file
11
plugins/sip/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "Node16",
|
||||
"target": "esnext",
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
"license": "ISC",
|
||||
"scripts": {
|
||||
"prepublishOnly": "npm run build",
|
||||
"build": "rimraf dist gen && typedoc && ts-node ./src/build.ts && tsc && npm link"
|
||||
"build": "rimraf dist gen && typedoc && ts-node ./src/build.ts && tsc && sudo npm link"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/rimraf": "^3.0.2",
|
||||
|
||||
Reference in New Issue
Block a user