From f1168c869c9c53b5cabc7ceedc1cefa8810452a0 Mon Sep 17 00:00:00 2001 From: slyoldfox Date: Thu, 5 Jan 2023 17:32:25 +0000 Subject: [PATCH] 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 --- plugins/bticino/package-lock.json | 4 +- plugins/bticino/package.json | 2 +- plugins/bticino/src/main.ts | 60 +++-- plugins/bticino/src/sip-call.ts | 380 ----------------------------- plugins/bticino/src/sip-session.ts | 180 -------------- plugins/sip/package-lock.json | 4 +- plugins/sip/package.json | 2 +- plugins/sip/src/main.ts | 13 +- plugins/sip/src/sip-call.ts | 193 ++++++++++----- plugins/sip/src/sip-session.ts | 11 +- 10 files changed, 198 insertions(+), 651 deletions(-) delete mode 100644 plugins/bticino/src/sip-call.ts delete mode 100644 plugins/bticino/src/sip-session.ts diff --git a/plugins/bticino/package-lock.json b/plugins/bticino/package-lock.json index a066d09dd..847b37f50 100644 --- a/plugins/bticino/package-lock.json +++ b/plugins/bticino/package-lock.json @@ -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", diff --git a/plugins/bticino/package.json b/plugins/bticino/package.json index 6a92fbcf0..b8c6b5ede 100644 --- a/plugins/bticino/package.json +++ b/plugins/bticino/package.json @@ -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", diff --git a/plugins/bticino/src/main.ts b/plugins/bticino/src/main.ts index a5c86350a..5e5e0d582 100644 --- a/plugins/bticino/src/main.ts +++ b/plugins/bticino/src/main.ts @@ -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; + 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 { 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; } diff --git a/plugins/bticino/src/sip-call.ts b/plugins/bticino/src/sip-call.ts deleted file mode 100644 index ba5eb5832..000000000 --- a/plugins/bticino/src/sip-call.ts +++ /dev/null @@ -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 - content?: string - seq?: number - }) { - if (this.destroyed) { - return Promise.reject( - new Error('SIP request made after call was destroyed') - ) - } - - return new Promise((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") - } -} \ No newline at end of file diff --git a/plugins/bticino/src/sip-session.ts b/plugins/bticino/src/sip-session.ts deleted file mode 100644 index 05d367b11..000000000 --- a/plugins/bticino/src/sip-session.ts +++ /dev/null @@ -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 { - 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) - } -} \ No newline at end of file diff --git a/plugins/sip/package-lock.json b/plugins/sip/package-lock.json index dc8e00285..630a20327 100644 --- a/plugins/sip/package-lock.json +++ b/plugins/sip/package-lock.json @@ -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", diff --git a/plugins/sip/package.json b/plugins/sip/package.json index 02199645d..d00af7b5c 100644 --- a/plugins/sip/package.json +++ b/plugins/sip/package.json @@ -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", diff --git a/plugins/sip/src/main.ts b/plugins/sip/src/main.ts index 4ba448490..1ab32a986 100644 --- a/plugins/sip/src/main.ts +++ b/plugins/sip/src/main.ts @@ -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 { + } + async createDevice(settings: DeviceCreatorSettings): Promise { const nativeId = randomBytes(4).toString('hex'); const name = settings.newCamera.toString(); diff --git a/plugins/sip/src/sip-call.ts b/plugins/sip/src/sip-call.ts index a5e1e2346..3002b71a2 100644 --- a/plugins/sip/src/sip-call.ts +++ b/plugins/sip/src/sip-call.ts @@ -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(() => { diff --git a/plugins/sip/src/sip-session.ts b/plugins/sip/src/sip-session.ts index c65b6e9ac..9f7c11018 100644 --- a/plugins/sip/src/sip-session.ts +++ b/plugins/sip/src/sip-session.ts @@ -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 { + async call( audioSection, videoSection? ): Promise { 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,