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:
nanosonde
2022-11-28 17:19:32 +01:00
committed by GitHub
parent ad0c14e9d1
commit 7679e3a550
17 changed files with 3629 additions and 12 deletions

View File

@@ -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
View File

@@ -0,0 +1,4 @@
.DS_Store
out/
node_modules/
dist/

11
plugins/sip/.npmignore Normal file
View 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
View 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
View File

@@ -0,0 +1,4 @@
{
"scrypted.debugHost": "koushik-ubuntu",
}

20
plugins/sip/.vscode/tasks.json vendored Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

49
plugins/sip/package.json Normal file
View 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
View 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();

View 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
View 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()
}
}

View 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)
}
}

View 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
View 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
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"resolveJsonModule": true,
"moduleResolution": "Node16",
"target": "esnext",
"esModuleInterop": true,
},
"include": [
"src/**/*"
]
}

View File

@@ -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",