Refactor and re-use sip-call and sip-session in btcino plugin (#474)

* Refactor and re-use sip-call and sip-session:
* added sipDebug functionality
* added register functionality
* support for internal domains
* support srtp

* Refactor and re-use sip-call and sip-session:
* added sipDebug functionality
* added register functionality
* support for internal domains
* support srtp

* * implemented SIP message handling
* fix contactId
This commit is contained in:
slyoldfox
2023-01-05 17:32:25 +00:00
committed by GitHub
parent 95a3d3ce6c
commit f1168c869c
10 changed files with 198 additions and 651 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/bticino",
"version": "0.0.4",
"version": "0.0.5",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/bticino",
"version": "0.0.4",
"version": "0.0.5",
"dependencies": {
"@homebridge/camera-utils": "^2.0.4",
"rxjs": "^7.5.5",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/bticino",
"version": "0.0.4",
"version": "0.0.5",
"scripts": {
"scrypted-setup-project": "scrypted-setup-project",
"prescrypted-setup-project": "scrypted-package-json",

View File

@@ -1,37 +1,35 @@
import { listenZeroSingleClient } from '@scrypted/common/src/listen-cluster';
import { SipOptions } from './sip-call';
import { SipMessageHandler, SipCall, SipOptions, SipRequest } from '../../sip/src/sip-call';
import { RtspServer } from '@scrypted/common/src/rtsp-server';
import { addTrackControls, parseSdp, replacePorts } from '@scrypted/common/src/sdp-utils';
import { StorageSettings } from '@scrypted/sdk/storage-settings';
import sdk, { BinarySensor, Camera, DeviceCreator, DeviceCreatorSettings, DeviceProvider, FFmpegInput, Intercom, MediaObject, MediaStreamUrl, PictureOptions, ResponseMediaStreamOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings, SettingValue, VideoCamera } from '@scrypted/sdk';
import dgram from 'dgram';
import { SipSession } from './sip-session';
import { SipSession } from '../../sip/src/sip-session';
import { isStunMessage, getPayloadType, getSequenceNumber, isRtpMessagePayloadType } from '../../sip/src/rtp-utils';
import { ChildProcess } from 'child_process';
import { randomBytes } from 'crypto';
const STREAM_TIMEOUT = 50000;
const SIP_EXPIRATION_DEFAULT = 3600;
const { deviceManager, mediaManager } = sdk;
class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCamera, Settings, BinarySensor {
export class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCamera, Settings, BinarySensor {
session: SipSession;
audioOutForwarder: dgram.Socket;
audioOutProcess: ChildProcess;
doorbellAudioActive: boolean;
audioInProcess: ChildProcess;
currentMedia: FFmpegInput | MediaStreamUrl;
currentMediaMimeType: string;
audioSilenceProcess: ChildProcess;
refreshTimeout: NodeJS.Timeout;
pendingPicture: Promise<MediaObject>;
messageHandler: SipMessageHandler;
constructor(nativeId: string, public provider: SipCamProvider) {
super(nativeId);
this.binaryState = false;
this.doorbellAudioActive = false;
this.audioSilenceProcess = null;
}
let logger = this.log;
this.messageHandler = new class extends SipMessageHandler {
handle( request: SipRequest ) {
// TODO: implement netatmo.onPresence handling?
// {"jsonrpc":"2.0","method":"netatmo.onPresence","params":[{"persons":[]}]}
logger.d("remote message: " + request.content );
}
}()
}
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.");
@@ -100,8 +98,6 @@ class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCam
}
stopSession() {
this.doorbellAudioActive = false;
this.audioInProcess?.kill('SIGKILL');
if (this.session) {
this.log.d('ending sip session');
this.session.stop();
@@ -172,23 +168,39 @@ class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCam
expire: Number.parseInt( expiration ),
localIp,
localPort,
tcp: false,
udp: true,
debug: sipdebug
shouldRegister: true,
debugSip: sipdebug,
messageHandler: this.messageHandler
};
sip = await SipSession.createSipSession(console, "Bticino", sipOptions);
sip.onCallEnded.subscribe(cleanup);
// Call the C300X
let remoteRtpDescription = await sip.start();
if( sipOptions.debug )
let remoteRtpDescription = await sip.call(
( audio ) => {
return [
'a=DEVADDR:20', // Needed for bt_answering_machine (bticino specific)
`m=audio ${audio.port} RTP/SAVP 97`,
`a=rtpmap:97 speex/8000`,
`a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:/qE7OPGKp9hVGALG2KcvKWyFEZfSSvm7bYVDjT8X`,
]
}, ( video ) => {
return [
`m=video ${video.port} RTP/SAVP 97`,
`a=rtpmap:97 H264/90000`,
`a=fmtp:97 profile-level-id=42801F`,
`a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:/qE7OPGKp9hVGALG2KcvKWyFEZfSSvm7bYVDjT8X`,
'a=recvonly'
]
} );
if( sipOptions.debugSip )
this.log.d('SIP: Received remote SDP:\n' + remoteRtpDescription.sdp)
let sdp: string = replacePorts( remoteRtpDescription.sdp, 0, 0 );
sdp = addTrackControls(sdp);
sdp = sdp.split('\n').filter(line => !line.includes('a=rtcp-mux')).join('\n');
if( sipOptions.debug )
if( sipOptions.debugSip )
this.log.d('SIP: Updated SDP:\n' + sdp);
let vseq = 0;
@@ -202,7 +214,7 @@ class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCam
const parsedSdp = parseSdp(rtsp.sdp);
const videoTrack = parsedSdp.msections.find(msection => msection.type === 'video').control;
const audioTrack = parsedSdp.msections.find(msection => msection.type === 'audio').control;
if( sipOptions.debug ) {
if( sipOptions.debugSip ) {
rtsp.console = this.console;
}

View File

@@ -1,380 +0,0 @@
import { noop, Subject } from 'rxjs'
import { randomInteger, randomString } from '../../sip/src/util'
import { RtpDescription, RtpOptions, RtpStreamDescription } from '../../sip/src/rtp-utils'
import { stringify } from 'sip/sip'
import { decodeSrtpOptions } from '../../ring/src/srtp-utils'
const contactId = randomInteger();
const sip = require('sip'),
sdp = require('sdp')
export interface SipOptions {
to: string
from: string
domain: string
expire: number
localIp: string
localPort: number
udp: boolean
tcp: boolean
debug: boolean
}
interface UriOptions {
name?: string
uri: string
params?: {
tag?: string
expires?: number
}
}
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' | 'video'
): RtpStreamDescription {
try {
const section = sections.find((s) => s.startsWith('m=' + mediaType));
if( section === undefined ) {
return {
port: 0,
rtcpPort: 0
};
}
const { port } = sdp.parseMLine(section),
lines: string[] = sdp.splitLines(section),
rtcpLine = lines.find((l: string) => l.startsWith('a=rtcp:')),
cryptoLine = lines.find((l: string) => l.startsWith('a=crypto'))!,
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')),
encodedCrypto = cryptoLine.match(/inline:(\S*)/)![1]
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,
...decodeSrtpOptions(encodedCrypto),
}
} 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'),
video: getRtpDescription(console, sections, 'video')
}
}
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, video } = 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: this.sipOptions.udp,
tcp: this.sipOptions.tcp,
tls: false,
logger: {
recv: function(m, remote) {
if( sipOptions.debug ) {
console.log("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<")
console.log(stringify( m ));
console.log("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<")
}
},
send: function(m, remote) {
let toWithDomain: string = (sipOptions.to.split('@')[0] + '@' + sipOptions.domain).trim()
let fromWithDomain: string = (sipOptions.from.split('@')[0] + '@' + sipOptions.domain).trim()
if( m.method == 'REGISTER' || m.method == 'INVITE' ) {
if( m.method == 'REGISTER' ) {
m.uri = "sip:" + sipOptions.domain
} else if( m.method == 'INVITE' ) {
m.uri = toWithDomain
} else {
throw new Error("Error: Method construct for uri not implemented: " + m.method)
}
m.headers.to.uri = toWithDomain
m.headers.from.uri = fromWithDomain
if( m.headers.contact[0].uri.split('@')[0].indexOf('-') < 0 ) {
m.headers.contact[0].uri = m.headers.contact[0].uri.replace("@", "-" + contactId + "@");
}
}
if( sipOptions.debug ) {
console.log(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
console.log(stringify( m ));
console.log(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
}
},
},
// 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=- 3747 461 IN IP4 ${host}`,
`o=${from.split(':')[1].split('@')[0]} 3747 461 IN IP4 ${host}`,
's=ScryptedSipPlugin',
`c=IN IP4 ${host}`,
't=0 0',
'a=DEVADDR:20',
`m=audio ${audio.port} RTP/SAVP 97`,
`a=rtpmap:97 speex/8000`,
`a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:/qE7OPGKp9hVGALG2KcvKWyFEZfSSvm7bYVDjT8X`,
`m=video ${video.port} RTP/SAVP 97`,
`a=rtpmap:97 H264/90000`,
`a=fmtp:97 profile-level-id=42801F`,
`a=ssrc:${ssrc}`,
`a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:/qE7OPGKp9hVGALG2KcvKWyFEZfSSvm7bYVDjT8X`,
'a=recvonly'
]
.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: (method == 'REGISTER' || method == 'INVITE' ? null : 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 register() {
const { from } = this.sipOptions,
inviteResponse = await this.request({
method: 'REGISTER',
headers: {
//supported: 'replaces, outbound',
allow:
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
'content-type': 'application/sdp',
contact: [{ uri: from, params: { expires: this.sipOptions.expire } }],
},
});
}
async sendBye() {
this.console.log('Sending BYE...')
return this.request({ method: 'BYE' }).catch(() => {
// Don't care if we get an exception here.
})
}
destroy() {
this.console.debug("detroying sip-call")
this.destroyed = true
this.sipStack.destroy()
this.console.debug("detroying sip-call: done")
}
}

View File

@@ -1,180 +0,0 @@
import { reservePorts } from '@homebridge/camera-utils';
import { createBindUdp, createBindZero } from '@scrypted/common/src/listen-cluster';
import dgram from 'dgram';
import { ReplaySubject, timer } from 'rxjs';
import { createStunResponder, RtpDescription, RtpOptions, sendStunBindingRequest } from '../../sip/src/rtp-utils';
import { SipCall, SipOptions } from './sip-call';
import { Subscribed } from '../../sip/src/subscribed';
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 videoSplitter: dgram.Socket,
public videoRtcpSplitter: dgram.Socket,
public readonly cameraName: string
) {
super()
this.sipCall = this.createSipCall(this.sipOptions)
}
static async createSipSession(console: any, cameraName: string, sipOptions: SipOptions) {
const audioSplitter = await createBindZero(),
audioRtcpSplitter = await createBindUdp(audioSplitter.port + 1),
videoSplitter = await createBindZero(),
videoRtcpSplitter = await createBindUdp(videoSplitter.port + 1),
rtpOptions = {
audio: {
port: audioSplitter.port,
rtcpPort: audioRtcpSplitter.port
},
video: {
port: videoSplitter.port,
rtcpPort: videoRtcpSplitter.port
}
}
return new SipSession(
console,
sipOptions,
rtpOptions,
audioSplitter.server,
audioRtcpSplitter.server,
videoSplitter.server,
videoRtcpSplitter.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> {
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 {
await this.sipCall.register();
const rtpDescription = await this.sipCall.invite(),
sendStunRequests = () => {
sendStunBindingRequest({
rtpSplitter: this.audioSplitter,
rtcpSplitter: this.audioRtcpSplitter,
rtpDescription,
localUfrag: this.sipCall.audioUfrag,
type: 'audio',
})
sendStunBindingRequest({
rtpSplitter: this.videoSplitter,
rtcpSplitter: this.videoRtcpSplitter,
rtpDescription,
localUfrag: this.sipCall.videoUfrag,
type: 'video',
})
}
// 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.video.port === rtpDescription.video.rtcpPort) {
this.videoRtcpSplitter.close()
this.videoRtcpSplitter = this.videoSplitter
}
if (rtpDescription.video.iceUFrag) {
// ICE is supported
this.console.debug(`Connecting to ${this.cameraName} using ICE`)
createStunResponder(this.audioSplitter)
createStunResponder(this.videoSplitter)
sendStunRequests()
} else {
// ICE is not supported, use stun as keep alive
this.console.debug(`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.debug(`Audio stream latched for ${this.cameraName} on port: ${this.rtpOptions.audio.port}`)
})
this.videoSplitter.once('message', () => {
this.console.debug(`Video stream latched for ${this.cameraName} on port: ${this.rtpOptions.video.port}`)
})
return rtpDescription
} catch (e) {
this.callEnded(true)
throw e
}
}
static async reserveRtpRtcpPorts() {
const ports = await reservePorts({ count: 4, type: 'udp' })
return ports
}
private callEnded(sendBye: boolean) {
if (this.hasCallEnded) {
return
}
this.hasCallEnded = true
if (sendBye) {
this.sipCall.sendBye()
.then(() => {
// clean up
this.console.log("sip-session callEnded")
this.onCallEndedSubject.next(null)
this.sipCall.destroy()
this.videoSplitter.close()
this.audioSplitter.close()
this.audioRtcpSplitter.close()
this.videoRtcpSplitter.close()
this.unsubscribe()
this.console.log("sip-session callEnded: done")
})
.catch()
}
}
stop() {
this.callEnded(true)
}
}

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/sip",
"version": "0.0.4",
"version": "0.0.5",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/sip",
"version": "0.0.4",
"version": "0.0.5",
"dependencies": {
"@homebridge/camera-utils": "^2.0.4",
"rxjs": "^7.5.5",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/sip",
"version": "0.0.4",
"version": "0.0.5",
"scripts": {
"scrypted-setup-project": "scrypted-setup-project",
"prescrypted-setup-project": "scrypted-package-json",

View File

@@ -195,7 +195,15 @@ class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCam
sip = await SipSession.createSipSession(this.console, this.name, sipOptions);
sip.onCallEnded.subscribe(cleanup);
this.remoteRtpDescription = await sip.start();
this.remoteRtpDescription = await sip.call(
( audio ) => {
return [
`m=audio ${audio.port} RTP/AVP 0`,
'a=rtpmap:0 PCMU/8000',
'a=sendrecv'
]
}
);
this.console.log('SIP: Received remote SDP:\n', this.remoteRtpDescription.sdp)
let [rtpPort, rtcpPort] = await SipSession.reserveRtpRtcpPorts()
@@ -429,6 +437,9 @@ export class SipCamProvider extends ScryptedDeviceBase implements DeviceProvider
}
}
async releaseDevice(id: string, nativeId: string, device: any): Promise<void> {
}
async createDevice(settings: DeviceCreatorSettings): Promise<string> {
const nativeId = randomBytes(4).toString('hex');
const name = settings.newCamera.toString();

View File

@@ -1,6 +1,8 @@
import { noop, Subject } from 'rxjs'
import { randomInteger, randomString } from './util'
import { RtpDescription, RtpOptions, RtpStreamDescription } from './rtp-utils'
import { decodeSrtpOptions } from '@homebridge/camera-utils'
import { stringify } from 'sip/sip'
const sip = require('sip'),
sdp = require('sdp')
@@ -8,16 +10,29 @@ const sip = require('sip'),
export interface SipOptions {
to: string
from: string
domain?: string
expire?: number
localIp: string
localPort: number
udp: boolean
tcp: boolean
debugSip?: boolean
messageHandler?: SipMessageHandler
shouldRegister?: boolean
}
/**
* Allows handling of SIP messages
*/
export abstract class SipMessageHandler {
abstract handle( request: SipRequest )
}
interface UriOptions {
name?: string
uri: string
params?: { tag?: string }
params?: {
tag?: string
expires?: number
}
}
interface SipHeaders {
@@ -77,10 +92,12 @@ function getRtpDescription(
const { port } = sdp.parseMLine(section),
lines: string[] = sdp.splitLines(section),
rtcpLine = lines.find((l: string) => l.startsWith('a=rtcp:')),
cryptoLine = lines.find((l: string) => l.startsWith('a=crypto'))!,
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'))
icePwdLine = lines.find((l: string) => l.startsWith('a=ice-pwd')),
encodedCrypto = cryptoLine?.match(/inline:(\S*)/)![1] || undefined
let rtcpPort: number;
if (rtcpMuxLine) {
@@ -96,6 +113,7 @@ function getRtpDescription(
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,
...(encodedCrypto? decodeSrtpOptions(encodedCrypto) : {})
}
} catch (e) {
console.error('Failed to parse SDP from remote end')
@@ -127,25 +145,22 @@ export class SipCall {
private sipStack: SipStack
public readonly onEndedByRemote = new Subject()
private destroyed = false
private readonly console: any
private readonly console: Console
public readonly sdp: string
public readonly audioUfrag = randomString(16)
public readonly videoUfrag = randomString(16)
constructor(
console: any,
console: Console,
private sipOptions: SipOptions,
rtpOptions: RtpOptions,
private rtpOptions: RtpOptions,
//tlsPort: number
) {
this.console = console;
const { audio, video } = rtpOptions,
{ from } = this.sipOptions,
host = this.sipOptions.localIp,
port = this.sipOptions.localPort,
ssrc = randomInteger();
const host = this.sipOptions.localIp,
port = this.sipOptions.localPort,
contactId = randomInteger()
this.sipStack = {
makeResponse: sip.makeResponse,
@@ -153,14 +168,57 @@ export class SipCall {
host,
hostname: host,
port: port,
udp: this.sipOptions.udp,
tcp: this.sipOptions.tcp,
udp: true,
tcp: false,
tls: false,
// tls_port: tlsPort,
// tls: {
// rejectUnauthorized: false,
// },
ws: false
ws: false,
logger: {
recv: function(m, remote) {
if( sipOptions.debugSip ) {
console.log("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<")
console.log(stringify( m ));
console.log("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<")
}
},
send: function(m, remote) {
/*
Some door bells run an embedded SIP server with an unresolvable public domain
Due to bugs in the DNS resolution in sip/sip we abuse the 'send' logger to modify some headers
just before they get sent to the SIP server.
*/
if( sipOptions.domain && sipOptions.domain.length > 0 ) {
// Bticino CX300 specific: runs on an internet 2048362.bs.iotleg.com domain
// While underlying UDP socket is bound to the IP, the header is rewritten to match the domain
let toWithDomain: string = (sipOptions.to.split('@')[0] + '@' + sipOptions.domain).trim()
let fromWithDomain: string = (sipOptions.from.split('@')[0] + '@' + sipOptions.domain).trim()
if( m.method == 'REGISTER' || m.method == 'INVITE' ) {
if( m.method == 'REGISTER' ) {
m.uri = "sip:" + sipOptions.domain
} else if( m.method == 'INVITE' ) {
m.uri = toWithDomain
} else {
throw new Error("Error: Method construct for uri not implemented: " + m.method)
}
m.headers.to.uri = toWithDomain
m.headers.from.uri = fromWithDomain
if( m.headers.contact[0].uri.split('@')[0].indexOf('-') < 0 ) {
m.headers.contact[0].uri = m.headers.contact[0].uri.replace("@", "-" + contactId + "@");
}
}
}
if( sipOptions.debugSip ) {
console.log(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
console.log(stringify( m ));
console.log(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
}
},
},
},
(request: SipRequest) => {
if (request.method === 'BYE') {
@@ -170,44 +228,17 @@ export class SipCall {
if (this.destroyed) {
this.onEndedByRemote.next(null)
}
} else if( request.method === 'MESSAGE' && sipOptions.messageHandler ) {
sipOptions.messageHandler.handle( request )
this.sipStack.send(this.sipStack.makeResponse(request, 200, 'Ok'))
} else {
if( sipOptions.debugSip ) {
this.console.warn("unimplemented method received from remote: " + request.method)
}
}
}
)
}
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';
/* Example SDP for audio and video
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 97`,
`a=rtpmap:97 speex/8000`,
`m=video ${video.port} RTP/AVP 97`,
`a=rtpmap:97 H264/90000`,
`a=fmtp:97 profile-level-id=42801F`,
`a=ssrc:${ssrc}`,
'a=recvonly'
]
.filter((l) => l)
.join('\r\n')) + '\r\n';
*/
}
request({
@@ -235,9 +266,9 @@ export class SipCall {
uri: this.sipOptions.to,
headers: {
to: {
name: '"Scrypted SIP Plugin Client"',
//name: '"Scrypted SIP Plugin Client"',
uri: this.sipOptions.to,
params: this.toParams,
params: (method == 'REGISTER' || method == 'INVITE' ? null : this.toParams),
},
from: {
uri: this.sipOptions.from,
@@ -302,9 +333,14 @@ export class SipCall {
})
}
async invite() {
const { from } = this.sipOptions,
/**
* Initiate a call by sending a SIP INVITE request
*/
async invite( audioSection, videoSection? ) {
let ssrc = randomInteger()
let audio = audioSection ? audioSection( this.rtpOptions.audio, ssrc ).concat( ...[`a=ssrc:${ssrc}`, `a=rtcp:${this.rtpOptions.audio.rtcpPort}`] ) : []
let video = videoSection ? videoSection( this.rtpOptions.video, ssrc ).concat( ...[`a=ssrc:${ssrc}`, `a=rtcp:${this.rtpOptions.video.rtcpPort}`] ) : []
const { from, localIp } = this.sipOptions,
inviteResponse = await this.request({
method: 'INVITE',
headers: {
@@ -314,12 +350,57 @@ export class SipCall {
'content-type': 'application/sdp',
contact: [{ uri: from }],
},
content: this.sdp,
content: ([
'v=0',
`o=${from.split(':')[1].split('@')[0]} 3747 461 IN IP4 ${localIp}`,
's=ScryptedSipPlugin',
`c=IN IP4 ${this.sipOptions.localIp}`,
't=0 0',
...audio,
...video
]
.filter((l) => l)
.join('\r\n')) + '\r\n'
})
return parseRtpDescription(this.console, inviteResponse)
}
/**
* Register the user agent with a Registrar
*/
async register() {
const { from } = this.sipOptions,
inviteResponse = await this.request({
method: 'REGISTER',
headers: {
//supported: 'replaces, outbound',
allow:
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
'content-type': 'application/sdp',
contact: [{ uri: from, params: { expires: this.sipOptions.expire } }],
},
});
}
/**
* Send a message to the current call contact
*/
async message( content: string ) {
const { from } = this.sipOptions,
inviteResponse = await this.request({
method: 'MESSAGE',
headers: {
//supported: 'replaces, outbound',
allow:
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
'content-type': 'application/sdp',
contact: [{ uri: from, params: { expires: this.sipOptions.expire } }],
},
content: content
});
}
async sendBye() {
this.console.log('Sending BYE...')
return this.request({ method: 'BYE' }).catch(() => {

View File

@@ -14,7 +14,7 @@ export class SipSession extends Subscribed {
onCallEnded = this.onCallEndedSubject.asObservable()
constructor(
public readonly console: any,
public readonly console: Console,
public readonly sipOptions: SipOptions,
public readonly rtpOptions: RtpOptions,
public readonly audioSplitter: dgram.Socket,
@@ -33,7 +33,7 @@ export class SipSession extends Subscribed {
audioRtcpSplitter = await createBindUdp(audioSplitter.port + 1),
videoSplitter = await createBindZero(),
videoRtcpSplitter = await createBindUdp(videoSplitter.port + 1),
rtpOptions = {
rtpOptions : RtpOptions = {
audio: {
port: audioSplitter.port,
rtcpPort: audioRtcpSplitter.port
@@ -74,7 +74,7 @@ export class SipSession extends Subscribed {
return this.sipCall
}
async start(): Promise<RtpDescription> {
async call( audioSection, videoSection? ): Promise<RtpDescription> {
this.console.log(`SipSession::start()`);
if (this.hasStarted) {
@@ -86,8 +86,11 @@ export class SipSession extends Subscribed {
throw new Error('SIP Session has already ended')
}
try {
const rtpDescription = await this.sipCall.invite(),
if( this.sipOptions.shouldRegister )
await this.sipCall.register()
const rtpDescription = await this.sipCall.invite( audioSection, videoSection ),
sendStunRequests = () => {
sendStunBindingRequest({
rtpSplitter: this.audioSplitter,