mirror of
https://github.com/koush/scrypted.git
synced 2026-03-20 16:40:24 +00:00
btcino 0.0.7 / sip 0.0.6 (#644)
* * Fix an issues in SIP.js where the ACK and BYE replies didn't go to the correct uri * * Implemented outgoing SIP MESSAGE sending * Adding voice mail check * Adding a lock for a bticino doorbell * Cleanup dependencies, code in sip, bticino plugins * Cleanup dependencies, code in sip, bticino plugins * Clear stale devices from our map and clear the voicemail check * Do not require register() for a SIP call * Narrow down the event matching to deletes of devices * Use releaseDevice to clean up stale entries * Fix uuid version * Attempt to make two way audio work * Attempt to make two way audio work - fine tuning * Enable incoming doorbell events * SipCall was never a "sip call" but more like a manager SipSession was more the "sip call" * * Rename sip registered session to persistent sip manager * Allow handling of call pickup in homekit (hopefully!) * * use the consoles from the camera object * * use the consoles from the camera object * * Fix the retry timer * * Added webhook url * * parse record route correctly * * Add gruu and use a custom fork of sip.js which supports keepAlive SIP clients (and dropped Websocket) * use cross-env in package.json * Added webhook urls for faster handling of events * Added videoclips * plugins/sip 0.0.6 * plugins/bticino 0.0.7
This commit is contained in:
4680
plugins/bticino/package-lock.json
generated
4680
plugins/bticino/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,50 +1,49 @@
|
||||
{
|
||||
"name": "@scrypted/bticino",
|
||||
"version": "0.0.5",
|
||||
"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": "BTicino 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",
|
||||
"ts-node": "^10.9.1",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@scrypted/common": "file:../../common",
|
||||
"@scrypted/sdk": "file:../../sdk",
|
||||
"@types/node": "^16.9.6",
|
||||
"@types/uuid": "^8.3.4"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "@scrypted/bticino",
|
||||
"version": "0.0.7",
|
||||
"scripts": {
|
||||
"scrypted-setup-project": "scrypted-setup-project",
|
||||
"prescrypted-setup-project": "scrypted-package-json",
|
||||
"build": "scrypted-webpack",
|
||||
"prepublishOnly": "cross-env 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": "BTicino SIP Plugin",
|
||||
"type": "DeviceProvider",
|
||||
"interfaces": [
|
||||
"DeviceProvider",
|
||||
"DeviceCreator"
|
||||
],
|
||||
"pluginDependencies": [
|
||||
"@scrypted/prebuffer-mixin",
|
||||
"@scrypted/pam-diff",
|
||||
"@scrypted/snapshot"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@slyoldfox/sip": "^0.0.6-1",
|
||||
"sdp": "^3.0.3",
|
||||
"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",
|
||||
"cross-env": "^7.0.3",
|
||||
"ts-node": "^10.9.1"
|
||||
}
|
||||
}
|
||||
|
||||
407
plugins/bticino/src/bticino-camera.ts
Normal file
407
plugins/bticino/src/bticino-camera.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import { closeQuiet, createBindZero, listenZeroSingleClient } from '@scrypted/common/src/listen-cluster';
|
||||
import { sleep } from '@scrypted/common/src/sleep';
|
||||
import { RtspServer } from '@scrypted/common/src/rtsp-server';
|
||||
import { addTrackControls } from '@scrypted/common/src/sdp-utils';
|
||||
import sdk, { BinarySensor, Camera, DeviceProvider, FFmpegInput, HttpRequest, HttpRequestHandler, HttpResponse, Intercom, MediaObject, MediaStreamUrl, PictureOptions, ResponseMediaStreamOptions, ScryptedDevice, ScryptedDeviceBase, ScryptedMimeTypes, Setting, Settings, SettingValue, VideoCamera, VideoClip, VideoClipOptions, VideoClips } from '@scrypted/sdk';
|
||||
import { SipCallSession } from '../../sip/src/sip-call-session';
|
||||
import { RtpDescription } from '../../sip/src/rtp-utils';
|
||||
import { VoicemailHandler } from './bticino-voicemailHandler';
|
||||
import { CompositeSipMessageHandler } from '../../sip/src/compositeSipMessageHandler';
|
||||
import { SipHelper } from './sip-helper';
|
||||
import child_process, { ChildProcess } from 'child_process';
|
||||
import dgram from 'dgram';
|
||||
import { BticinoStorageSettings } from './storage-settings';
|
||||
import { BticinoSipPlugin } from './main';
|
||||
import { BticinoSipLock } from './bticino-lock';
|
||||
import { ffmpegLogInitialOutput, safeKillFFmpeg, safePrintFFmpegArguments } from '@scrypted/common/src/media-helpers';
|
||||
import { PersistentSipManager } from './persistent-sip-manager';
|
||||
import { InviteHandler } from './bticino-inviteHandler';
|
||||
import { SipRequest } from '../../sip/src/sip-manager';
|
||||
|
||||
import { get } from 'http'
|
||||
|
||||
const STREAM_TIMEOUT = 65000;
|
||||
const { mediaManager } = sdk;
|
||||
|
||||
export class BticinoSipCamera extends ScryptedDeviceBase implements DeviceProvider, Intercom, Camera, VideoCamera, Settings, BinarySensor, HttpRequestHandler, VideoClips {
|
||||
|
||||
private session: SipCallSession
|
||||
private remoteRtpDescription: RtpDescription
|
||||
private audioOutForwarder: dgram.Socket
|
||||
private audioOutProcess: ChildProcess
|
||||
private currentMedia: FFmpegInput | MediaStreamUrl
|
||||
private currentMediaMimeType: string
|
||||
private refreshTimeout: NodeJS.Timeout
|
||||
public requestHandlers: CompositeSipMessageHandler = new CompositeSipMessageHandler()
|
||||
public incomingCallRequest : SipRequest
|
||||
private settingsStorage: BticinoStorageSettings = new BticinoStorageSettings( this )
|
||||
public voicemailHandler : VoicemailHandler = new VoicemailHandler(this)
|
||||
private inviteHandler : InviteHandler = new InviteHandler(this)
|
||||
//TODO: randomize this
|
||||
private keyAndSalt : string = "/qE7OPGKp9hVGALG2KcvKWyFEZfSSvm7bYVDjT8X"
|
||||
//private decodedSrtpOptions : SrtpOptions = decodeSrtpOptions( this.keyAndSalt )
|
||||
private persistentSipManager : PersistentSipManager
|
||||
public doorbellWebhookUrl : string
|
||||
public doorbellLockWebhookUrl : string
|
||||
|
||||
constructor(nativeId: string, public provider: BticinoSipPlugin) {
|
||||
super(nativeId)
|
||||
|
||||
this.requestHandlers.add( this.voicemailHandler ).add( this.inviteHandler )
|
||||
this.persistentSipManager = new PersistentSipManager( this );
|
||||
(async() => {
|
||||
this.doorbellWebhookUrl = await this.doorbellWebhookEndpoint()
|
||||
this.doorbellLockWebhookUrl = await this.doorbellLockWebhookEndpoint()
|
||||
})();
|
||||
}
|
||||
|
||||
getVideoClips(options?: VideoClipOptions): Promise<VideoClip[]> {
|
||||
return new Promise<VideoClip[]>( (resolve,reject ) => {
|
||||
let c300x = SipHelper.getIntercomIp(this)
|
||||
if( !c300x ) return []
|
||||
get(`http://${c300x}:8080/videoclips?raw=true&startTime=${options.startTime/1000}&endTime=${options.endTime/1000}`, (res) => {
|
||||
let rawData = '';
|
||||
res.on('data', (chunk) => { rawData += chunk; });
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsedData : [] = JSON.parse(rawData);
|
||||
let videoClips : VideoClip[] = []
|
||||
parsedData.forEach( (item) => {
|
||||
let videoClip : VideoClip = {
|
||||
id: item['file'],
|
||||
startTime: parseInt(item['info']['UnixTime']) * 1000,
|
||||
duration: item['info']['Duration'] * 1000,
|
||||
//description: item['info']['Date'],
|
||||
thumbnailId: item['file']
|
||||
|
||||
}
|
||||
videoClips.push( videoClip )
|
||||
} )
|
||||
return resolve(videoClips)
|
||||
} catch (e) {
|
||||
reject(e.message)
|
||||
console.error(e.message);
|
||||
}
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getVideoClip(videoId: string): Promise<MediaObject> {
|
||||
let c300x = SipHelper.getIntercomIp(this)
|
||||
const url = `http://${c300x}:8080/voicemail?msg=${videoId}/aswm.avi&raw=true`;
|
||||
return mediaManager.createMediaObjectFromUrl(url);
|
||||
}
|
||||
getVideoClipThumbnail(thumbnailId: string): Promise<MediaObject> {
|
||||
let c300x = SipHelper.sipOptions(this)
|
||||
const url = `http://${c300x}:8080/voicemail?msg=${thumbnailId}/aswm.jpg&raw=true`;
|
||||
return mediaManager.createMediaObjectFromUrl(url);
|
||||
}
|
||||
|
||||
removeVideoClips(...videoClipIds: string[]): Promise<void> {
|
||||
//TODO
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
|
||||
sipUnlock(): Promise<void> {
|
||||
this.log.i("unlocking C300X door ")
|
||||
return this.persistentSipManager.enable().then( (sipCall) => {
|
||||
sipCall.message( '*8*19*20##' )
|
||||
.then( () =>
|
||||
sleep(1000)
|
||||
.then( () => sipCall.message( '*8*20*20##' ) )
|
||||
)
|
||||
} )
|
||||
}
|
||||
|
||||
getAswmStatus() : Promise<void> {
|
||||
return this.persistentSipManager.enable().then( (sipCall) => {
|
||||
sipCall.message( "GetAswmStatus!" )
|
||||
} )
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
getSettings(): Promise<Setting[]> {
|
||||
return this.settingsStorage.getSettings()
|
||||
}
|
||||
|
||||
putSetting(key: string, value: SettingValue): Promise<void> {
|
||||
return this.settingsStorage.putSetting(key, value)
|
||||
}
|
||||
|
||||
async startIntercom(media: MediaObject): Promise<void> {
|
||||
if (!this.session)
|
||||
throw new Error("not in call");
|
||||
|
||||
this.stopIntercom();
|
||||
|
||||
const ffmpegInput: FFmpegInput = JSON.parse((await mediaManager.convertMediaObjectToBuffer(media, ScryptedMimeTypes.FFmpegInput)).toString());
|
||||
|
||||
const audioOutForwarder = await createBindZero()
|
||||
this.audioOutForwarder = audioOutForwarder.server
|
||||
audioOutForwarder.server.on('message', message => {
|
||||
if( this.session )
|
||||
this.session.audioSplitter.send(message, 40004, this.remoteRtpDescription.address)
|
||||
return null
|
||||
});
|
||||
|
||||
const args = ffmpegInput.inputArguments.slice();
|
||||
args.push(
|
||||
'-vn', '-dn', '-sn',
|
||||
'-acodec', 'speex',
|
||||
'-flags', '+global_header',
|
||||
'-ac', '1',
|
||||
'-ar', '8k',
|
||||
'-f', 'rtp',
|
||||
//'-srtp_out_suite', 'AES_CM_128_HMAC_SHA1_80',
|
||||
//'-srtp_out_params', encodeSrtpOptions(this.decodedSrtpOptions),
|
||||
`rtp://127.0.0.1:${audioOutForwarder.port}?pkt_size=188`,
|
||||
);
|
||||
|
||||
this.console.log("===========================================")
|
||||
safePrintFFmpegArguments( this.console, args )
|
||||
this.console.log("===========================================")
|
||||
|
||||
const cp = child_process.spawn(await mediaManager.getFFmpegPath(), args);
|
||||
ffmpegLogInitialOutput(this.console, cp)
|
||||
this.audioOutProcess = cp;
|
||||
cp.on('exit', () => this.console.log('two way audio ended'));
|
||||
this.session.onCallEnded.subscribe(() => {
|
||||
closeQuiet(audioOutForwarder.server);
|
||||
safeKillFFmpeg(cp)
|
||||
});
|
||||
}
|
||||
|
||||
async stopIntercom(): Promise<void> {
|
||||
closeQuiet(this.audioOutForwarder)
|
||||
this.audioOutProcess?.kill('SIGKILL')
|
||||
this.audioOutProcess = undefined
|
||||
this.audioOutForwarder = undefined
|
||||
}
|
||||
|
||||
resetStreamTimeout() {
|
||||
this.log.d('starting/refreshing stream')
|
||||
clearTimeout(this.refreshTimeout)
|
||||
this.refreshTimeout = setTimeout(() => this.stopSession(), STREAM_TIMEOUT)
|
||||
}
|
||||
|
||||
hasActiveCall() {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
stopSession() {
|
||||
if (this.session) {
|
||||
this.log.d('ending sip session')
|
||||
this.session.stop()
|
||||
this.session = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async getVideoStream(options?: ResponseMediaStreamOptions): Promise<MediaObject> {
|
||||
if( !SipHelper.sipOptions( this ) ) {
|
||||
// Bail out fast when no options are set and someone enables prebuffering
|
||||
throw new Error('Please configure from/to/domain settings')
|
||||
}
|
||||
|
||||
if (options?.metadata?.refreshAt) {
|
||||
if (!this.currentMedia?.mediaStreamOptions)
|
||||
throw new Error("no stream to refresh");
|
||||
|
||||
const currentMedia = this.currentMedia
|
||||
currentMedia.mediaStreamOptions.refreshAt = Date.now() + STREAM_TIMEOUT;
|
||||
currentMedia.mediaStreamOptions.metadata = {
|
||||
refreshAt: currentMedia.mediaStreamOptions.refreshAt
|
||||
};
|
||||
this.resetStreamTimeout()
|
||||
return mediaManager.createMediaObject(currentMedia, this.currentMediaMimeType)
|
||||
}
|
||||
|
||||
this.stopSession();
|
||||
|
||||
|
||||
const { clientPromise: playbackPromise, port: playbackPort, url: clientUrl } = await listenZeroSingleClient()
|
||||
|
||||
const playbackUrl = clientUrl
|
||||
|
||||
playbackPromise.then(async (client) => {
|
||||
client.setKeepAlive(true, 10000)
|
||||
let sip: SipCallSession
|
||||
try {
|
||||
let rtsp: RtspServer;
|
||||
const cleanup = () => {
|
||||
client.destroy();
|
||||
if (this.session === sip)
|
||||
this.session = undefined
|
||||
try {
|
||||
this.log.d('cleanup(): stopping sip session.')
|
||||
sip.stop()
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
rtsp?.destroy()
|
||||
}
|
||||
|
||||
client.on('close', cleanup)
|
||||
client.on('error', cleanup)
|
||||
|
||||
let sipOptions = SipHelper.sipOptions( this )
|
||||
|
||||
sip = await this.persistentSipManager.session( sipOptions );
|
||||
// Validate this sooner
|
||||
if( !sip ) return Promise.reject("Cannot create session")
|
||||
|
||||
sip.onCallEnded.subscribe(cleanup)
|
||||
|
||||
// Call the C300X
|
||||
this.remoteRtpDescription = await sip.callOrAcceptInvite(
|
||||
( audio ) => {
|
||||
return [
|
||||
//TODO: Payload types are hardcoded
|
||||
`m=audio 65000 RTP/SAVP 110`,
|
||||
`a=rtpmap:110 speex/8000`,
|
||||
`a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:${this.keyAndSalt}`,
|
||||
]
|
||||
}, ( video ) => {
|
||||
if( false ) {
|
||||
//TODO: implement later
|
||||
return [
|
||||
`m=video 0 RTP/SAVP 0`
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
//TODO: Payload types are hardcoded
|
||||
`m=video 65002 RTP/SAVP 96`,
|
||||
`a=rtpmap:96 H264/90000`,
|
||||
`a=fmtp:96 profile-level-id=42801F`,
|
||||
`a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:${this.keyAndSalt}`,
|
||||
'a=recvonly'
|
||||
]
|
||||
}
|
||||
}, this.incomingCallRequest );
|
||||
|
||||
this.incomingCallRequest = undefined
|
||||
|
||||
//let sdp: string = replacePorts(this.remoteRtpDescription.sdp, 0, 0 )
|
||||
let sdp : string = [
|
||||
"v=0",
|
||||
"m=audio 5000 RTP/AVP 110",
|
||||
"c=IN IP4 127.0.0.1",
|
||||
"a=rtpmap:110 speex/8000/1",
|
||||
"m=video 5002 RTP/AVP 96",
|
||||
"c=IN IP4 127.0.0.1",
|
||||
"a=rtpmap:96 H264/90000",
|
||||
].join('\r\n')
|
||||
//sdp = sdp.replaceAll(/a=crypto\:1.*/g, '')
|
||||
//sdp = sdp.replaceAll(/RTP\/SAVP/g, 'RTP\/AVP')
|
||||
//sdp = sdp.replaceAll('\r\n\r\n', '\r\n')
|
||||
sdp = addTrackControls(sdp)
|
||||
sdp = sdp.split('\n').filter(line => !line.includes('a=rtcp-mux')).join('\n')
|
||||
if( sipOptions.debugSip )
|
||||
this.log.d('SIP: Updated SDP:\n' + sdp);
|
||||
|
||||
client.write(sdp)
|
||||
client.end()
|
||||
|
||||
this.session = sip
|
||||
}
|
||||
catch (e) {
|
||||
this.console.error(e)
|
||||
sip?.stop()
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
this.resetStreamTimeout();
|
||||
|
||||
const mediaStreamOptions = Object.assign(this.getSipMediaStreamOptions(), {
|
||||
refreshAt: Date.now() + STREAM_TIMEOUT,
|
||||
});
|
||||
|
||||
const ffmpegInput: FFmpegInput = {
|
||||
url: undefined,
|
||||
container: 'sdp',
|
||||
mediaStreamOptions,
|
||||
inputArguments: [
|
||||
'-f', 'sdp',
|
||||
'-i', playbackUrl,
|
||||
],
|
||||
};
|
||||
this.currentMedia = ffmpegInput;
|
||||
this.currentMediaMimeType = ScryptedMimeTypes.FFmpegInput;
|
||||
|
||||
return mediaManager.createFFmpegMediaObject(ffmpegInput);
|
||||
}
|
||||
|
||||
getSipMediaStreamOptions(): ResponseMediaStreamOptions {
|
||||
return {
|
||||
id: 'sip',
|
||||
name: 'SIP',
|
||||
// this stream is NOT scrypted blessed due to wackiness in the h264 stream.
|
||||
// tool: "scrypted",
|
||||
container: 'sdp',
|
||||
audio: {
|
||||
// this is a hint to let homekit, et al, know that it's speex audio and needs transcoding.
|
||||
codec: 'speex',
|
||||
},
|
||||
source: 'cloud', // to disable prebuffering
|
||||
userConfigurable: false,
|
||||
};
|
||||
}
|
||||
|
||||
async getVideoStreamOptions(): Promise<ResponseMediaStreamOptions[]> {
|
||||
return [
|
||||
this.getSipMediaStreamOptions(),
|
||||
]
|
||||
}
|
||||
|
||||
async getDevice(nativeId: string) : Promise<BticinoSipLock> {
|
||||
return new BticinoSipLock(this)
|
||||
}
|
||||
|
||||
async releaseDevice(id: string, nativeId: string): Promise<void> {
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.console.log("Reset the incoming call request")
|
||||
this.incomingCallRequest = undefined
|
||||
this.binaryState = false
|
||||
}
|
||||
|
||||
public async onRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
|
||||
if (request.url.endsWith('/pressed')) {
|
||||
this.binaryState = true
|
||||
setTimeout( () => {
|
||||
// Assumption that flexisip only holds this call active for 20 seconds ... might be revised
|
||||
this.reset()
|
||||
}, 20 * 1000 )
|
||||
response.send('Success', {
|
||||
code: 200,
|
||||
});
|
||||
} else {
|
||||
response.send('Unsupported operation', {
|
||||
code: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async doorbellWebhookEndpoint(): Promise<string> {
|
||||
let webhookUrl = await sdk.endpointManager.getLocalEndpoint( this.nativeId, { insecure: false, public: true });
|
||||
let endpoints = ["/pressed"]
|
||||
this.console.log( webhookUrl + " , endpoints: " + endpoints.join(' - ') )
|
||||
return `${webhookUrl}`;
|
||||
}
|
||||
|
||||
private async doorbellLockWebhookEndpoint(): Promise<string> {
|
||||
let webhookUrl = await sdk.endpointManager.getLocalEndpoint(this.nativeId + '-lock', { insecure: false, public: true });
|
||||
let endpoints = ["/lock", "/unlock", "/unlocked", "/locked"]
|
||||
this.console.log( webhookUrl + " -> endpoints: " + endpoints.join(' - ') )
|
||||
return `${webhookUrl}`;
|
||||
}
|
||||
}
|
||||
32
plugins/bticino/src/bticino-inviteHandler.ts
Normal file
32
plugins/bticino/src/bticino-inviteHandler.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { SipRequestHandler, SipRequest } from "../../sip/src/sip-manager"
|
||||
import { BticinoSipCamera } from "./bticino-camera"
|
||||
import { stringifyUri } from '@slyoldfox/sip'
|
||||
|
||||
export class InviteHandler extends SipRequestHandler {
|
||||
constructor( private sipCamera : BticinoSipCamera ) {
|
||||
super()
|
||||
this.sipCamera.binaryState = false
|
||||
}
|
||||
|
||||
handle(request: SipRequest) {
|
||||
//TODO: restrict this to call from:c300x@ AND to:alluser@ ?
|
||||
if( request.method == 'CANCEL' ) {
|
||||
let reason = request.headers["reason"] ? ( ' - ' + request.headers["reason"] ) : ''
|
||||
this.sipCamera.console.log('CANCEL voice call from: ' + stringifyUri( request.headers.from.uri ) + ' to: ' + stringifyUri( request.headers.to.uri ) + reason )
|
||||
this.sipCamera?.reset()
|
||||
}
|
||||
if( request.method === 'INVITE' ) {
|
||||
this.sipCamera.console.log("INCOMING voice call from: " + stringifyUri( request.headers.from.uri ) + ' to: ' + stringifyUri( request.headers.to.uri ) )
|
||||
|
||||
this.sipCamera.binaryState = true
|
||||
this.sipCamera.incomingCallRequest = request
|
||||
|
||||
setTimeout( () => {
|
||||
// Assumption that flexisip only holds this call active for 20 seconds ... might be revised
|
||||
this.sipCamera?.reset()
|
||||
}, 20 * 1000 )
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
56
plugins/bticino/src/bticino-lock.ts
Normal file
56
plugins/bticino/src/bticino-lock.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import sdk, { ScryptedDeviceBase, Lock, LockState, HttpRequest, HttpResponse, HttpRequestHandler } from "@scrypted/sdk";
|
||||
import { BticinoSipCamera } from "./bticino-camera";
|
||||
|
||||
export class BticinoSipLock extends ScryptedDeviceBase implements Lock, HttpRequestHandler {
|
||||
private timeout : NodeJS.Timeout
|
||||
|
||||
constructor(public camera: BticinoSipCamera) {
|
||||
super( camera.nativeId + "-lock")
|
||||
}
|
||||
|
||||
lock(): Promise<void> {
|
||||
if( !this.timeout ) {
|
||||
this.timeout = setTimeout(() => {
|
||||
this.lockState = LockState.Locked
|
||||
this.timeout = undefined
|
||||
} , 3000);
|
||||
} else {
|
||||
this.camera.console.log("Still attempting previous locking ...")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
unlock(): Promise<void> {
|
||||
this.lockState = LockState.Unlocked
|
||||
this.lock()
|
||||
return this.camera.sipUnlock()
|
||||
}
|
||||
|
||||
public async onRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
|
||||
if (request.url.endsWith('/unlocked')) {
|
||||
this.lockState = LockState.Unlocked
|
||||
response.send('Success', {
|
||||
code: 200,
|
||||
});
|
||||
} else if( request.url.endsWith('/locked') ) {
|
||||
this.lockState = LockState.Locked
|
||||
response.send('Success', {
|
||||
code: 200,
|
||||
});
|
||||
} else if( request.url.endsWith('/lock') ) {
|
||||
this.lock();
|
||||
response.send('Success', {
|
||||
code: 200,
|
||||
});
|
||||
} else if( request.url.endsWith('/unlock') ) {
|
||||
this.unlock();
|
||||
response.send('Success', {
|
||||
code: 200,
|
||||
});
|
||||
} else {
|
||||
response.send('Unsupported operation', {
|
||||
code: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
69
plugins/bticino/src/bticino-voicemailHandler.ts
Normal file
69
plugins/bticino/src/bticino-voicemailHandler.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { SipRequestHandler, SipRequest } from "../../sip/src/sip-manager"
|
||||
import { BticinoSipCamera } from "./bticino-camera"
|
||||
|
||||
export class VoicemailHandler extends SipRequestHandler {
|
||||
private timeout : NodeJS.Timeout
|
||||
|
||||
constructor( private sipCamera : BticinoSipCamera ) {
|
||||
super()
|
||||
setTimeout( () => {
|
||||
// Delay a bit an run in a different thread in case this fails
|
||||
this.checkVoicemail()
|
||||
}, 10000 )
|
||||
}
|
||||
|
||||
checkVoicemail() {
|
||||
if( !this.sipCamera )
|
||||
return
|
||||
if( this.isEnabled() ) {
|
||||
this.sipCamera.console.debug("Checking answering machine, cameraId: " + this.sipCamera.id )
|
||||
this.sipCamera.getAswmStatus().catch( e => this.sipCamera.console.error(e) )
|
||||
} else {
|
||||
this.sipCamera.console.debug("Answering machine check not enabled, cameraId: " + this.sipCamera.id )
|
||||
}
|
||||
//TODO: make interval customizable, now every 5 minutes
|
||||
this.timeout = setTimeout( () => this.checkVoicemail() , 5 * 60 * 1000 )
|
||||
}
|
||||
|
||||
cancelVoicemailCheck() {
|
||||
if( this.timeout ) {
|
||||
clearTimeout(this.timeout)
|
||||
}
|
||||
}
|
||||
|
||||
handle(request: SipRequest) {
|
||||
if( this.isEnabled() ) {
|
||||
const lastVoicemailMessageTimestamp : number = Number.parseInt( this.sipCamera.storage.getItem('lastVoicemailMessageTimestamp') ) || -1
|
||||
const message : string = request.content.toString()
|
||||
if( message.startsWith('*#8**40*0*0*1176*0*2##') ) {
|
||||
this.sipCamera.console.debug("Handling incoming answering machine reply")
|
||||
const messages : string[] = message.split(';')
|
||||
let lastMessageTimestamp : number = 0
|
||||
let countNewMessages : number = 0
|
||||
messages.forEach( (message, index) => {
|
||||
if( index > 0 ) {
|
||||
const parts = message.split('|')
|
||||
if( parts.length == 4 ) {
|
||||
let messageTimestamp = Number.parseInt( parts[2] )
|
||||
if( messageTimestamp > lastVoicemailMessageTimestamp )
|
||||
countNewMessages++
|
||||
if( index == messages.length-2 )
|
||||
lastMessageTimestamp = messageTimestamp
|
||||
}
|
||||
}
|
||||
} )
|
||||
if( (lastVoicemailMessageTimestamp == null && lastMessageTimestamp > 0) ||
|
||||
( lastVoicemailMessageTimestamp != null && lastMessageTimestamp > lastVoicemailMessageTimestamp ) ) {
|
||||
this.sipCamera.log.a(`You have ${countNewMessages} new voicemail messages.`)
|
||||
this.sipCamera.storage.setItem('lastVoicemailMessageTimestamp', lastMessageTimestamp.toString())
|
||||
} else {
|
||||
this.sipCamera.console.debug("No new messages since: " + lastVoicemailMessageTimestamp + " lastMessage: " + lastMessageTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isEnabled() : boolean {
|
||||
return this.sipCamera?.storage?.getItem('notifyVoicemail')?.toLocaleLowerCase() === 'true' || false
|
||||
}
|
||||
}
|
||||
@@ -1,379 +1,97 @@
|
||||
import { listenZeroSingleClient } from '@scrypted/common/src/listen-cluster';
|
||||
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 { SipSession } from '../../sip/src/sip-session';
|
||||
import { isStunMessage, getPayloadType, getSequenceNumber, isRtpMessagePayloadType } from '../../sip/src/rtp-utils';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
const STREAM_TIMEOUT = 50000;
|
||||
const SIP_EXPIRATION_DEFAULT = 3600;
|
||||
const { deviceManager, mediaManager } = sdk;
|
||||
|
||||
export class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCamera, Settings, BinarySensor {
|
||||
session: SipSession;
|
||||
currentMedia: FFmpegInput | MediaStreamUrl;
|
||||
currentMediaMimeType: string;
|
||||
refreshTimeout: NodeJS.Timeout;
|
||||
messageHandler: SipMessageHandler;
|
||||
|
||||
constructor(nativeId: string, public provider: SipCamProvider) {
|
||||
super(nativeId);
|
||||
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.");
|
||||
}
|
||||
|
||||
async getPictureOptions(): Promise<PictureOptions[]> {
|
||||
return;
|
||||
}
|
||||
|
||||
settingsStorage = new StorageSettings(this, {
|
||||
sipfrom: {
|
||||
title: 'SIP From: URI',
|
||||
type: 'string',
|
||||
value: this.storage.getItem('sipfrom'),
|
||||
description: 'SIP URI From field: Using the IP address of your server you will be calling from. Also the user and IP you added in /etc/flexisip/users/route_ext.conf on the intercom.',
|
||||
placeholder: 'user@192.168.0.111',
|
||||
multiple: false,
|
||||
},
|
||||
sipto: {
|
||||
title: 'SIP To: URI',
|
||||
type: 'string',
|
||||
description: 'SIP URI To field: Must look like c300x@IP;transport=udp;rport and UDP transport is the only one supported right now.',
|
||||
placeholder: 'c300x@192.168.0.2[:5060];transport=udp;rport',
|
||||
},
|
||||
sipdomain: {
|
||||
title: 'SIP domain',
|
||||
type: 'string',
|
||||
description: 'SIP domain: The internal BTicino domain, usually has the following format: 2048362.bs.iotleg.com',
|
||||
placeholder: '2048362.bs.iotleg.com',
|
||||
},
|
||||
sipexpiration: {
|
||||
title: 'SIP UA expiration',
|
||||
type: 'number',
|
||||
range: [60, SIP_EXPIRATION_DEFAULT],
|
||||
description: 'SIP UA expiration: How long the UA should remain active before expiring. Use 3600.',
|
||||
placeholder: '3600',
|
||||
},
|
||||
sipdebug: {
|
||||
title: 'SIP debug logging',
|
||||
type: 'boolean',
|
||||
description: 'Enable SIP debugging',
|
||||
placeholder: 'true or false',
|
||||
},
|
||||
});
|
||||
|
||||
getSettings(): Promise<Setting[]> {
|
||||
return this.settingsStorage.getSettings();
|
||||
}
|
||||
|
||||
putSetting(key: string, value: SettingValue): Promise<void> {
|
||||
return this.settingsStorage.putSetting(key, value);
|
||||
}
|
||||
|
||||
async startIntercom(media: MediaObject): Promise<void> {
|
||||
this.log.d( "TODO: startIntercom" + media );
|
||||
}
|
||||
|
||||
async stopIntercom(): Promise<void> {
|
||||
this.log.d( "TODO: stopIntercom" );
|
||||
}
|
||||
|
||||
resetStreamTimeout() {
|
||||
this.log.d('starting/refreshing stream');
|
||||
clearTimeout(this.refreshTimeout);
|
||||
this.refreshTimeout = setTimeout(() => this.stopSession(), STREAM_TIMEOUT);
|
||||
}
|
||||
|
||||
stopSession() {
|
||||
if (this.session) {
|
||||
this.log.d('ending sip session');
|
||||
this.session.stop();
|
||||
this.session = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async getVideoStream(options?: ResponseMediaStreamOptions): Promise<MediaObject> {
|
||||
if (options?.metadata?.refreshAt) {
|
||||
if (!this.currentMedia?.mediaStreamOptions)
|
||||
throw new Error("no stream to refresh");
|
||||
|
||||
const currentMedia = this.currentMedia;
|
||||
currentMedia.mediaStreamOptions.refreshAt = Date.now() + STREAM_TIMEOUT;
|
||||
currentMedia.mediaStreamOptions.metadata = {
|
||||
refreshAt: currentMedia.mediaStreamOptions.refreshAt
|
||||
};
|
||||
this.resetStreamTimeout();
|
||||
return mediaManager.createMediaObject(currentMedia, this.currentMediaMimeType);
|
||||
}
|
||||
|
||||
this.stopSession();
|
||||
|
||||
|
||||
const { clientPromise: playbackPromise, port: playbackPort, url: clientUrl } = await listenZeroSingleClient();
|
||||
|
||||
const playbackUrl = `rtsp://127.0.0.1:${playbackPort}`;
|
||||
|
||||
playbackPromise.then(async (client) => {
|
||||
client.setKeepAlive(true, 10000);
|
||||
let sip: SipSession;
|
||||
try {
|
||||
let rtsp: RtspServer;
|
||||
const cleanup = () => {
|
||||
client.destroy();
|
||||
if (this.session === sip)
|
||||
this.session = undefined;
|
||||
try {
|
||||
this.log.d('cleanup(): stopping sip session.');
|
||||
sip.stop();
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
rtsp?.destroy();
|
||||
}
|
||||
|
||||
client.on('close', cleanup);
|
||||
client.on('error', cleanup);
|
||||
|
||||
const from = this.storage.getItem('sipfrom')?.trim();
|
||||
const to = this.storage.getItem('sipto')?.trim();
|
||||
const localIp = from?.split(':')[0].split('@')[1];
|
||||
const localPort = parseInt(from?.split(':')[1]) || 5060;
|
||||
const domain = this.storage.getItem('sipdomain')?.trim();
|
||||
const expiration : string = this.storage.getItem('sipuaexpiration')?.trim() || '3600';
|
||||
const sipdebug : boolean = this.storage.getItem('sipdebug')?.toLocaleLowerCase() === 'true' || false;
|
||||
|
||||
if (!from || !to || !localIp || !localPort || !domain || !expiration ) {
|
||||
this.log.e('Error: SIP From/To/Domain URIs not specified!');
|
||||
return;
|
||||
}
|
||||
|
||||
//TODO settings
|
||||
let sipOptions : SipOptions = {
|
||||
from: "sip:" + from,
|
||||
to: "sip:" + to,
|
||||
domain: domain,
|
||||
expire: Number.parseInt( expiration ),
|
||||
localIp,
|
||||
localPort,
|
||||
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.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.debugSip )
|
||||
this.log.d('SIP: Updated SDP:\n' + sdp);
|
||||
|
||||
let vseq = 0;
|
||||
let vseen = 0;
|
||||
let vlost = 0;
|
||||
let aseq = 0;
|
||||
let aseen = 0;
|
||||
let alost = 0;
|
||||
|
||||
rtsp = new RtspServer(client, sdp, true);
|
||||
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.debugSip ) {
|
||||
rtsp.console = this.console;
|
||||
}
|
||||
|
||||
await rtsp.handlePlayback();
|
||||
sip.videoSplitter.on('message', message => {
|
||||
if (!isStunMessage(message)) {
|
||||
const isRtpMessage = isRtpMessagePayloadType(getPayloadType(message));
|
||||
if (!isRtpMessage)
|
||||
return;
|
||||
vseen++;
|
||||
rtsp.sendTrack(videoTrack, message, !isRtpMessage);
|
||||
const seq = getSequenceNumber(message);
|
||||
if (seq !== (vseq + 1) % 0x0FFFF)
|
||||
vlost++;
|
||||
vseq = seq;
|
||||
}
|
||||
});
|
||||
|
||||
sip.videoRtcpSplitter.on('message', message => {
|
||||
rtsp.sendTrack(videoTrack, message, true);
|
||||
});
|
||||
|
||||
sip.audioSplitter.on('message', message => {
|
||||
if (!isStunMessage(message)) {
|
||||
const isRtpMessage = isRtpMessagePayloadType(getPayloadType(message));
|
||||
if (!isRtpMessage)
|
||||
return;
|
||||
aseen++;
|
||||
rtsp.sendTrack(audioTrack, message, !isRtpMessage);
|
||||
const seq = getSequenceNumber(message);
|
||||
if (seq !== (aseq + 1) % 0x0FFFF)
|
||||
alost++;
|
||||
aseq = seq;
|
||||
}
|
||||
});
|
||||
|
||||
sip.audioRtcpSplitter.on('message', message => {
|
||||
rtsp.sendTrack(audioTrack, message, true);
|
||||
});
|
||||
|
||||
this.session = sip;
|
||||
|
||||
try {
|
||||
await rtsp.handleTeardown();
|
||||
this.log.d('rtsp client ended');
|
||||
}
|
||||
catch (e) {
|
||||
this.log.e('rtsp client ended ungracefully' + e);
|
||||
}
|
||||
finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
sip?.stop();
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
this.resetStreamTimeout();
|
||||
|
||||
const mediaStreamOptions = Object.assign(this.getSipMediaStreamOptions(), {
|
||||
refreshAt: Date.now() + STREAM_TIMEOUT,
|
||||
});
|
||||
|
||||
const mediaStreamUrl: MediaStreamUrl = {
|
||||
url: playbackUrl,
|
||||
mediaStreamOptions,
|
||||
};
|
||||
this.currentMedia = mediaStreamUrl;
|
||||
this.currentMediaMimeType = ScryptedMimeTypes.MediaStreamUrl;
|
||||
|
||||
return mediaManager.createMediaObject(mediaStreamUrl, ScryptedMimeTypes.MediaStreamUrl);
|
||||
}
|
||||
|
||||
getSipMediaStreamOptions(): ResponseMediaStreamOptions {
|
||||
return {
|
||||
id: 'sip',
|
||||
name: 'SIP',
|
||||
// this stream is NOT scrypted blessed due to wackiness in the h264 stream.
|
||||
// tool: "scrypted",
|
||||
container: 'sdp',
|
||||
audio: {
|
||||
// this is a hint to let homekit, et al, know that it's speex audio and needs transcoding.
|
||||
codec: 'speex',
|
||||
},
|
||||
source: 'local',
|
||||
userConfigurable: false,
|
||||
};
|
||||
}
|
||||
|
||||
async getVideoStreamOptions(): Promise<ResponseMediaStreamOptions[]> {
|
||||
return [
|
||||
this.getSipMediaStreamOptions(),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
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 releaseDevice(id: string, nativeId: string): Promise<void> {
|
||||
}
|
||||
|
||||
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();
|
||||
import sdk, { Device, DeviceCreator, DeviceCreatorSettings, DeviceProvider, LockState, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting } from '@scrypted/sdk'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { BticinoSipCamera } from './bticino-camera'
|
||||
|
||||
const { systemManager, deviceManager } = sdk
|
||||
|
||||
export class BticinoSipPlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator {
|
||||
|
||||
devices = new Map<string, BticinoSipCamera>()
|
||||
|
||||
async getCreateDeviceSettings(): Promise<Setting[]> {
|
||||
return [
|
||||
{
|
||||
key: 'newCamera',
|
||||
title: 'Add Camera',
|
||||
placeholder: 'Camera name, e.g.: Back Yard Camera, Baby Camera, etc',
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async createDevice(settings: DeviceCreatorSettings): Promise<string> {
|
||||
const nativeId = randomBytes(4).toString('hex')
|
||||
const name = settings.newCamera?.toString()
|
||||
const camera = await this.updateDevice(nativeId, name)
|
||||
|
||||
const device: Device = {
|
||||
providerNativeId: nativeId,
|
||||
info: {
|
||||
//model: `${camera.model} (${camera.data.kind})`,
|
||||
manufacturer: 'BticinoPlugin',
|
||||
//firmware: camera.data.firmware_version,
|
||||
//serialNumber: camera.data.device_id
|
||||
},
|
||||
nativeId: nativeId + '-lock',
|
||||
name: name + ' Lock',
|
||||
type: ScryptedDeviceType.Lock,
|
||||
interfaces: [ScryptedInterface.Lock, ScryptedInterface.HttpRequestHandler],
|
||||
}
|
||||
|
||||
const ret = await deviceManager.onDevicesChanged({
|
||||
providerNativeId: nativeId,
|
||||
devices: [device],
|
||||
})
|
||||
|
||||
let sipCamera : BticinoSipCamera = await this.getDevice(nativeId)
|
||||
let foo : BticinoSipCamera = systemManager.getDeviceById<BticinoSipCamera>(sipCamera.id)
|
||||
|
||||
let lock = await sipCamera.getDevice(undefined)
|
||||
lock.lockState = LockState.Locked
|
||||
|
||||
return nativeId
|
||||
}
|
||||
|
||||
updateDevice(nativeId: string, name: string) {
|
||||
return deviceManager.onDeviceDiscovered({
|
||||
nativeId,
|
||||
info: {
|
||||
//model: `${camera.model} (${camera.data.kind})`,
|
||||
manufacturer: 'BticinoSipPlugin',
|
||||
//firmware: camera.data.firmware_version,
|
||||
//serialNumber: camera.data.device_id
|
||||
},
|
||||
name,
|
||||
interfaces: [
|
||||
ScryptedInterface.Camera,
|
||||
ScryptedInterface.VideoCamera,
|
||||
ScryptedInterface.Settings,
|
||||
ScryptedInterface.Intercom,
|
||||
ScryptedInterface.BinarySensor,
|
||||
ScryptedDeviceType.DeviceProvider,
|
||||
ScryptedInterface.HttpRequestHandler,
|
||||
ScryptedInterface.VideoClips
|
||||
],
|
||||
type: ScryptedDeviceType.Doorbell,
|
||||
})
|
||||
}
|
||||
|
||||
async getDevice(nativeId: string): Promise<any> {
|
||||
if (!this.devices.has(nativeId)) {
|
||||
const camera = new BticinoSipCamera(nativeId, this)
|
||||
this.devices.set(nativeId, camera)
|
||||
}
|
||||
return this.devices.get(nativeId)
|
||||
}
|
||||
|
||||
async releaseDevice(id: string, nativeId: string): Promise<void> {
|
||||
let camera = this.devices.get(nativeId)
|
||||
if( camera ) {
|
||||
camera.voicemailHandler.cancelVoicemailCheck()
|
||||
if( this.devices.delete( nativeId ) ) {
|
||||
this.console.log("Removed device from list: " + id + " / " + nativeId )
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new BticinoSipPlugin()
|
||||
71
plugins/bticino/src/persistent-sip-manager.ts
Normal file
71
plugins/bticino/src/persistent-sip-manager.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { SipCallSession } from "../../sip/src/sip-call-session";
|
||||
import { BticinoSipCamera } from "./bticino-camera";
|
||||
import { SipHelper } from "./sip-helper";
|
||||
import { SipManager, SipOptions } from "../../sip/src/sip-manager";
|
||||
|
||||
/**
|
||||
* This class registers itself with the SIP server as a contact for a user account.
|
||||
* The registration expires after the expires time in sipOptions is reached.
|
||||
* The sip session will re-register itself after the expires time is reached.
|
||||
*/
|
||||
const CHECK_INTERVAL : number = 10 * 1000
|
||||
export class PersistentSipManager {
|
||||
|
||||
private sipManager : SipManager
|
||||
private lastRegistration : number = 0
|
||||
private expireInterval : number = 0
|
||||
|
||||
constructor( private camera : BticinoSipCamera ) {
|
||||
// Give it a second and run in seperate thread to avoid failure on creation for from/to/domain check
|
||||
setTimeout( () => this.enable() , CHECK_INTERVAL )
|
||||
}
|
||||
|
||||
async enable() : Promise<SipManager> {
|
||||
if( this.sipManager ) {
|
||||
return this.sipManager
|
||||
} else {
|
||||
return this.register()
|
||||
}
|
||||
}
|
||||
|
||||
private async register() : Promise<SipManager> {
|
||||
let now = Date.now()
|
||||
try {
|
||||
let sipOptions : SipOptions = SipHelper.sipOptions( this.camera )
|
||||
if( Number.isNaN( sipOptions.expire ) || sipOptions.expire <= 0 || sipOptions.expire > 3600 ) {
|
||||
sipOptions.expire = 300
|
||||
}
|
||||
if( this.expireInterval == 0 ) {
|
||||
this.expireInterval = (sipOptions.expire * 1000) - 10000
|
||||
}
|
||||
|
||||
if( !this.camera.hasActiveCall() && now - this.lastRegistration >= this.expireInterval ) {
|
||||
let sipOptions : SipOptions = SipHelper.sipOptions( this.camera )
|
||||
|
||||
this.sipManager?.destroy()
|
||||
this.sipManager = new SipManager(this.camera.console, sipOptions )
|
||||
await this.sipManager.register()
|
||||
|
||||
this.lastRegistration = now
|
||||
|
||||
return this.sipManager;
|
||||
}
|
||||
} catch(e) {
|
||||
this.camera.console.error("Error enabling persistent SIP manager: " + e )
|
||||
// Try again in a minute
|
||||
this.lastRegistration = now + (60 * 1000) - this.expireInterval
|
||||
throw e
|
||||
} finally {
|
||||
setTimeout( () => this.register(), CHECK_INTERVAL )
|
||||
}
|
||||
}
|
||||
|
||||
async session( sipOptions: SipOptions ) : Promise<SipCallSession> {
|
||||
let sm = await this.enable()
|
||||
return SipCallSession.createCallSession(this.camera.console, "Bticino", sipOptions, sm )
|
||||
}
|
||||
|
||||
reloadSipOptions() {
|
||||
this.sipManager?.setSipOptions( null )
|
||||
}
|
||||
}
|
||||
59
plugins/bticino/src/sip-helper.ts
Normal file
59
plugins/bticino/src/sip-helper.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { SipOptions } from "../../sip/src/sip-manager";
|
||||
import { BticinoSipCamera } from "./bticino-camera";
|
||||
import crypto from 'crypto';
|
||||
|
||||
export class SipHelper {
|
||||
public static sipOptions( camera : BticinoSipCamera ) : SipOptions {
|
||||
// Might be removed soon?
|
||||
if( camera.storage.getItem('sipto') && camera.storage.getItem('sipto').toString().indexOf(';') > 0 ) {
|
||||
camera.storage.setItem('sipto', camera.storage.getItem('sipto').toString().split(';')[0] )
|
||||
}
|
||||
const from = camera.storage.getItem('sipfrom')?.trim()
|
||||
const to = camera.storage.getItem('sipto')?.trim()
|
||||
const localIp = from?.split(':')[0].split('@')[1]
|
||||
// Although this might not occur directly, each camera should run on its own port
|
||||
// Might need to use a random free port here (?)
|
||||
const localPort = parseInt(from?.split(':')[1]) || 5060
|
||||
const domain = camera.storage.getItem('sipdomain')?.trim()
|
||||
const expiration : string = camera.storage.getItem('sipexpiration')?.trim() || '600'
|
||||
const sipdebug : boolean = camera.storage.getItem('sipdebug')?.toLocaleLowerCase() === 'true' || false
|
||||
|
||||
if (!from || !to || !localIp || !localPort || !domain || !expiration ) {
|
||||
camera.log.e('Error: SIP From/To/Domain URIs not specified!')
|
||||
throw new Error('SIP From/To/Domain URIs not specified!')
|
||||
}
|
||||
|
||||
return {
|
||||
from: "sip:" + from,
|
||||
//TCP is more reliable for large messages, also see useTcp=true below
|
||||
to: "sip:" + to + ";transport=tcp",
|
||||
domain: domain,
|
||||
expire: Number.parseInt( expiration ),
|
||||
localIp,
|
||||
localPort,
|
||||
debugSip: sipdebug,
|
||||
gruuInstanceId: SipHelper.getGruuInstanceId(camera),
|
||||
useTcp: true,
|
||||
sipRequestHandler: camera.requestHandlers
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public static getIntercomIp( camera : BticinoSipCamera ): string {
|
||||
let to = camera.storage.getItem('sipto')?.trim();
|
||||
if( to ) {
|
||||
return to.split('@')[1];
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
public static getGruuInstanceId( camera : BticinoSipCamera ): string {
|
||||
let md5 = camera.storage.getItem('md5hash')
|
||||
if( !md5 ) {
|
||||
md5 = crypto.createHash('md5').update( camera.nativeId ).digest("hex")
|
||||
md5 = md5.substring(0, 8) + '-' + md5.substring(8, 12) + '-' + md5.substring(12,16) + '-' + md5.substring(16, 32)
|
||||
camera.storage.setItem('md5has', md5)
|
||||
}
|
||||
return md5
|
||||
}
|
||||
}
|
||||
78
plugins/bticino/src/storage-settings.ts
Normal file
78
plugins/bticino/src/storage-settings.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Setting, SettingValue } from '@scrypted/sdk';
|
||||
import { StorageSettings } from '@scrypted/sdk/storage-settings';
|
||||
import { BticinoSipCamera } from './bticino-camera';
|
||||
|
||||
export class BticinoStorageSettings {
|
||||
private storageSettings
|
||||
|
||||
constructor(camera : BticinoSipCamera) {
|
||||
this.storageSettings = new StorageSettings( camera, {
|
||||
sipfrom: {
|
||||
title: 'SIP From: URI',
|
||||
type: 'string',
|
||||
value: camera.storage.getItem('sipfrom'),
|
||||
description: 'SIP URI From field: Using the IP address of your server you will be calling from.',
|
||||
placeholder: 'user@192.168.0.111',
|
||||
multiple: false,
|
||||
},
|
||||
sipto: {
|
||||
title: 'SIP To: URI',
|
||||
type: 'string',
|
||||
description: 'SIP URI To field: Must look like c300x@192.168.0.2',
|
||||
placeholder: 'c300x@192.168.0.2',
|
||||
},
|
||||
sipdomain: {
|
||||
title: 'SIP domain',
|
||||
type: 'string',
|
||||
description: 'SIP domain - tshe internal BTicino domain, usually has the following format: 2048362.bs.iotleg.com',
|
||||
placeholder: '2048362.bs.iotleg.com',
|
||||
},
|
||||
sipexpiration: {
|
||||
title: 'SIP UA expiration',
|
||||
type: 'number',
|
||||
range: [60, 3600],
|
||||
description: 'How long the UA should remain active before expiring and having to re-register (in seconds)',
|
||||
defaultValue: 600,
|
||||
placeholder: '600',
|
||||
},
|
||||
sipdebug: {
|
||||
title: 'SIP debug logging',
|
||||
type: 'boolean',
|
||||
description: 'Enable SIP debugging',
|
||||
placeholder: 'true or false',
|
||||
},
|
||||
notifyVoicemail: {
|
||||
title: 'Notify on new voicemail messages',
|
||||
type: 'boolean',
|
||||
description: 'Enable voicemail alerts',
|
||||
placeholder: 'true or false',
|
||||
},
|
||||
doorbellWebhookUrl: {
|
||||
title: 'Doorbell Sensor Webhook',
|
||||
type: 'string',
|
||||
readonly: true,
|
||||
mapGet: () => {
|
||||
return camera.doorbellWebhookUrl;
|
||||
},
|
||||
description: 'Incoming doorbell sensor webhook url.',
|
||||
},
|
||||
doorbellLockWebhookUrl: {
|
||||
title: 'Doorbell Lock Webhook',
|
||||
type: 'string',
|
||||
readonly: true,
|
||||
mapGet: () => {
|
||||
return camera.doorbellLockWebhookUrl;
|
||||
},
|
||||
description: 'Incoming doorbell sensor webhook url.',
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getSettings(): Promise<Setting[]> {
|
||||
return this.storageSettings.getSettings();
|
||||
}
|
||||
|
||||
putSetting(key: string, value: SettingValue): Promise<void> {
|
||||
return this.storageSettings.putSetting(key, value);
|
||||
}
|
||||
}
|
||||
4671
plugins/sip/package-lock.json
generated
4671
plugins/sip/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,49 +1,49 @@
|
||||
{
|
||||
"name": "@scrypted/sip",
|
||||
"version": "0.0.5",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "@scrypted/sip",
|
||||
"version": "0.0.6",
|
||||
"scripts": {
|
||||
"scrypted-setup-project": "scrypted-setup-project",
|
||||
"prescrypted-setup-project": "scrypted-package-json",
|
||||
"build": "scrypted-webpack",
|
||||
"prepublishOnly": "cross-env 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",
|
||||
"@slyoldfox/sip": "^0.0.6-1",
|
||||
"sdp": "^3.0.3",
|
||||
"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",
|
||||
"cross-env": "^7.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
15
plugins/sip/src/compositeSipMessageHandler.ts
Normal file
15
plugins/sip/src/compositeSipMessageHandler.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { SipRequestHandler, SipRequest } from "./sip-manager";
|
||||
|
||||
export class CompositeSipMessageHandler extends SipRequestHandler {
|
||||
private handlers : SipRequestHandler[] = []
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
handle(request: SipRequest) {
|
||||
this.handlers.forEach( (handler) => handler.handle( request ) )
|
||||
}
|
||||
add( handler : SipRequestHandler ) {
|
||||
this.handlers.push( handler )
|
||||
return this
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ 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 { SipCallSession } from './sip-call-session';
|
||||
import { SipOptions } from './sip-manager';
|
||||
import { RtpDescription, isStunMessage, getPayloadType, getSequenceNumber, isRtpMessagePayloadType } from './rtp-utils';
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
@@ -14,7 +14,7 @@ const { deviceManager, mediaManager } = sdk;
|
||||
|
||||
class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCamera, Settings, BinarySensor {
|
||||
buttonTimeout: NodeJS.Timeout;
|
||||
session: SipSession;
|
||||
callSession: SipCallSession;
|
||||
remoteRtpDescription: RtpDescription;
|
||||
audioOutForwarder: dgram.Socket;
|
||||
audioOutProcess: ChildProcess;
|
||||
@@ -107,7 +107,7 @@ class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCam
|
||||
|
||||
await this.callDoorbell();
|
||||
|
||||
if (!this.session)
|
||||
if (!this.callSession)
|
||||
throw new Error("not in call");
|
||||
|
||||
this.stopAudioOut();
|
||||
@@ -118,7 +118,7 @@ class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCam
|
||||
const audioOutForwarder = await createBindZero();
|
||||
this.audioOutForwarder = audioOutForwarder.server;
|
||||
audioOutForwarder.server.on('message', message => {
|
||||
this.session.audioSplitter.send(message, remoteRtpDescription.audio.port, remoteRtpDescription.address);
|
||||
this.callSession.audioSplitter.send(message, remoteRtpDescription.audio.port, remoteRtpDescription.address);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -136,7 +136,7 @@ class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCam
|
||||
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(() => {
|
||||
this.callSession.onCallEnded.subscribe(() => {
|
||||
closeQuiet(audioOutForwarder.server);
|
||||
cp.kill('SIGKILL');
|
||||
});
|
||||
@@ -157,19 +157,19 @@ class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCam
|
||||
stopSession() {
|
||||
this.doorbellAudioActive = false;
|
||||
this.audioInProcess?.kill('SIGKILL');
|
||||
if (this.session) {
|
||||
if (this.callSession) {
|
||||
this.console.log('ending sip session');
|
||||
this.session.stop();
|
||||
this.session = undefined;
|
||||
this.callSession.stop();
|
||||
this.callSession = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async callDoorbell(): Promise<void> {
|
||||
let sip: SipSession;
|
||||
let sip: SipCallSession;
|
||||
|
||||
const cleanup = () => {
|
||||
if (this.session === sip)
|
||||
this.session = undefined;
|
||||
if (this.callSession === sip)
|
||||
this.callSession = undefined;
|
||||
try {
|
||||
this.console.log('stopping sip session.');
|
||||
sip.stop();
|
||||
@@ -193,7 +193,7 @@ class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCam
|
||||
|
||||
let sipOptions: SipOptions = { from: "sip:" + from, to: "sip:" + to, localIp, localPort };
|
||||
|
||||
sip = await SipSession.createSipSession(this.console, this.name, sipOptions);
|
||||
sip = await SipCallSession.createCallSession(this.console, this.name, sipOptions);
|
||||
sip.onCallEnded.subscribe(cleanup);
|
||||
this.remoteRtpDescription = await sip.call(
|
||||
( audio ) => {
|
||||
@@ -206,7 +206,7 @@ class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCam
|
||||
);
|
||||
this.console.log('SIP: Received remote SDP:\n', this.remoteRtpDescription.sdp)
|
||||
|
||||
let [rtpPort, rtcpPort] = await SipSession.reserveRtpRtcpPorts()
|
||||
let [rtpPort, rtcpPort] = await SipCallSession.reserveRtpRtcpPorts()
|
||||
this.console.log(`Reserved RTP port ${rtpPort} and RTCP port ${rtcpPort} for incoming SIP audio`);
|
||||
|
||||
const ffmpegPath = await mediaManager.getFFmpegPath();
|
||||
@@ -260,7 +260,7 @@ class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCam
|
||||
sip.audioRtcpSplitter.send(message, rtcpPort, "127.0.0.1");
|
||||
});
|
||||
|
||||
this.session = sip;
|
||||
this.callSession = sip;
|
||||
}
|
||||
|
||||
getRawVideoStreamOptions(): ResponseMediaStreamOptions[] {
|
||||
@@ -437,8 +437,7 @@ export class SipCamProvider extends ScryptedDeviceBase implements DeviceProvider
|
||||
}
|
||||
}
|
||||
|
||||
async releaseDevice(id: string, nativeId: string): Promise<void> {
|
||||
}
|
||||
|
||||
|
||||
async createDevice(settings: DeviceCreatorSettings): Promise<string> {
|
||||
const nativeId = randomBytes(4).toString('hex');
|
||||
@@ -482,6 +481,12 @@ export class SipCamProvider extends ScryptedDeviceBase implements DeviceProvider
|
||||
return ret;
|
||||
}
|
||||
|
||||
async releaseDevice(id: string, nativeId: string): Promise<void> {
|
||||
if( this.devices.delete( nativeId ) ) {
|
||||
this.console.log("Removed device from list: " + id + " / " + nativeId )
|
||||
}
|
||||
}
|
||||
|
||||
createCamera(nativeId: string): SipCamera {
|
||||
return new SipCamera(nativeId, this);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// by @dgrief from @homebridge/camera-utils
|
||||
import { SrtpOptions } 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 {
|
||||
export interface RtpStreamOptions extends SrtpOptions {
|
||||
port: number
|
||||
rtcpPort: number
|
||||
}
|
||||
|
||||
@@ -1,187 +1,206 @@
|
||||
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 './rtp-utils';
|
||||
import { SipCall, SipOptions } from './sip-call';
|
||||
import { Subscribed } from './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: Console,
|
||||
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 : 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 call( audioSection, videoSection? ): 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 {
|
||||
if( this.sipOptions.shouldRegister )
|
||||
await this.sipCall.register()
|
||||
const rtpDescription = await this.sipCall.invite( audioSection, videoSection ),
|
||||
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 > 0 && rtpDescription.audio.port === rtpDescription.audio.rtcpPort) {
|
||||
this.audioRtcpSplitter.close()
|
||||
this.audioRtcpSplitter = this.audioSplitter
|
||||
}
|
||||
|
||||
if ( rtpDescription.video.port > 0 && rtpDescription.video.port === rtpDescription.video.rtcpPort) {
|
||||
this.videoRtcpSplitter.close()
|
||||
this.videoRtcpSplitter = this.videoSplitter
|
||||
}
|
||||
|
||||
if ( (rtpDescription.audio.port > 0 && rtpDescription.audio.iceUFrag)|| (rtpDescription.video.port > 0 && rtpDescription.video.iceUFrag ) ) {
|
||||
// ICE is supported
|
||||
this.console.log(`Connecting to ${this.cameraName} using ICE`)
|
||||
if( rtpDescription.audio.port > 0 ) {
|
||||
createStunResponder(this.audioSplitter)
|
||||
}
|
||||
if( rtpDescription.video.port > 0 ) {
|
||||
createStunResponder(this.videoSplitter)
|
||||
}
|
||||
|
||||
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}, port: ${this.rtpOptions.audio.port}`)
|
||||
})
|
||||
|
||||
this.videoSplitter.once('message', () => {
|
||||
this.console.log(`Video stream latched for ${this.cameraName}, 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 async callEnded(sendBye: boolean) {
|
||||
if (this.hasCallEnded) {
|
||||
return
|
||||
}
|
||||
this.hasCallEnded = true
|
||||
|
||||
if (sendBye) {
|
||||
await this.sipCall.sendBye().catch(this.console.error)
|
||||
}
|
||||
|
||||
// clean up
|
||||
this.console.log("sip-session callEnded")
|
||||
this.onCallEndedSubject.next(null)
|
||||
this.sipCall.destroy()
|
||||
this.audioSplitter.close()
|
||||
this.audioRtcpSplitter.close()
|
||||
this.videoSplitter.close()
|
||||
this.videoRtcpSplitter.close()
|
||||
this.unsubscribe()
|
||||
this.console.log("sip-session callEnded: done")
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await this.callEnded(true)
|
||||
}
|
||||
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 './rtp-utils';
|
||||
import { SipManager, SipOptions, SipRequest } from './sip-manager';
|
||||
import { Subscribed } from './subscribed';
|
||||
|
||||
/*
|
||||
A SipCallSession
|
||||
*/
|
||||
export class SipCallSession extends Subscribed {
|
||||
private hasStarted = false
|
||||
private hasCallEnded = false
|
||||
private onCallEndedSubject = new ReplaySubject(1)
|
||||
onCallEnded = this.onCallEndedSubject.asObservable()
|
||||
|
||||
constructor(
|
||||
private readonly console: Console,
|
||||
private readonly sipOptions: SipOptions,
|
||||
private 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,
|
||||
private sipManager: SipManager
|
||||
) {
|
||||
super()
|
||||
if( !sipManager ) {
|
||||
this.sipManager = this.createSipManager( sipOptions )
|
||||
}
|
||||
//TODO: make this more clean
|
||||
this.addSubscriptions( this.sipManager.onEndedByRemote.subscribe(() => {
|
||||
this.callEnded(false)
|
||||
} ))
|
||||
|
||||
sipManager.setSipOptions( sipOptions )
|
||||
}
|
||||
|
||||
static async createCallSession(console: Console, cameraName: string, sipOptions: SipOptions, sipManager?: SipManager ) {
|
||||
const audioSplitter = await createBindZero(),
|
||||
audioRtcpSplitter = await createBindUdp(audioSplitter.port + 1),
|
||||
videoSplitter = await createBindZero(),
|
||||
videoRtcpSplitter = await createBindUdp(videoSplitter.port + 1),
|
||||
rtpOptions : RtpOptions = {
|
||||
audio: {
|
||||
port: audioSplitter.port,
|
||||
rtcpPort: audioRtcpSplitter.port,
|
||||
//TODO: make this cleaner
|
||||
srtpKey: undefined,
|
||||
srtpSalt: undefined
|
||||
},
|
||||
video: {
|
||||
port: videoSplitter.port,
|
||||
rtcpPort: videoRtcpSplitter.port,
|
||||
//TODO: make this cleaner
|
||||
srtpKey: undefined,
|
||||
srtpSalt: undefined
|
||||
}
|
||||
}
|
||||
|
||||
return new SipCallSession(
|
||||
console,
|
||||
sipOptions,
|
||||
rtpOptions,
|
||||
audioSplitter.server,
|
||||
audioRtcpSplitter.server,
|
||||
videoSplitter.server,
|
||||
videoRtcpSplitter.server,
|
||||
cameraName,
|
||||
sipManager
|
||||
)
|
||||
}
|
||||
|
||||
createSipManager(sipOptions: SipOptions) {
|
||||
if (this.sipManager) {
|
||||
this.sipManager.destroy()
|
||||
}
|
||||
|
||||
const call = (this.sipManager = new SipManager(
|
||||
this.console,
|
||||
sipOptions
|
||||
))
|
||||
|
||||
this.addSubscriptions(
|
||||
call.onEndedByRemote.subscribe(() => {
|
||||
this.callEnded(false)
|
||||
} )
|
||||
)
|
||||
|
||||
return this.sipManager
|
||||
}
|
||||
|
||||
async call( audioSection, videoSection? ): Promise<RtpDescription> {
|
||||
return this.callOrAcceptInvite(audioSection, videoSection)
|
||||
}
|
||||
|
||||
async callOrAcceptInvite( audioSection, videoSection?, incomingCallRequest? : SipRequest ): 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 : RtpDescription = await this.sipManager.invite( this.rtpOptions, audioSection, videoSection, incomingCallRequest ),
|
||||
sendStunRequests = () => {
|
||||
sendStunBindingRequest({
|
||||
rtpSplitter: this.audioSplitter,
|
||||
rtcpSplitter: this.audioRtcpSplitter,
|
||||
rtpDescription,
|
||||
localUfrag: this.sipManager.audioUfrag,
|
||||
type: 'audio',
|
||||
})
|
||||
sendStunBindingRequest({
|
||||
rtpSplitter: this.videoSplitter,
|
||||
rtcpSplitter: this.videoRtcpSplitter,
|
||||
rtpDescription,
|
||||
localUfrag: this.sipManager.videoUfrag,
|
||||
type: 'video',
|
||||
})
|
||||
}
|
||||
|
||||
// if rtcp-mux is supported, rtp splitter will be used for both rtp and rtcp
|
||||
if ( rtpDescription.audio.port > 0 && rtpDescription.audio.port === rtpDescription.audio.rtcpPort) {
|
||||
this.audioRtcpSplitter.close()
|
||||
this.audioRtcpSplitter = this.audioSplitter
|
||||
}
|
||||
|
||||
if ( rtpDescription.video.port > 0 && rtpDescription.video.port === rtpDescription.video.rtcpPort) {
|
||||
this.videoRtcpSplitter.close()
|
||||
this.videoRtcpSplitter = this.videoSplitter
|
||||
}
|
||||
|
||||
if ( (rtpDescription.audio.port > 0 && rtpDescription.audio.iceUFrag)|| (rtpDescription.video.port > 0 && rtpDescription.video.iceUFrag ) ) {
|
||||
// ICE is supported
|
||||
this.console.log(`Connecting to ${this.cameraName} using ICE`)
|
||||
if( rtpDescription.audio.port > 0 ) {
|
||||
createStunResponder(this.audioSplitter)
|
||||
}
|
||||
if( rtpDescription.video.port > 0 ) {
|
||||
createStunResponder(this.videoSplitter)
|
||||
}
|
||||
|
||||
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}, port: ${this.rtpOptions.audio.port}`)
|
||||
})
|
||||
|
||||
this.videoSplitter.once('message', () => {
|
||||
this.console.log(`Video stream latched for ${this.cameraName}, 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 async callEnded(sendBye: boolean) {
|
||||
if (this.hasCallEnded) {
|
||||
return
|
||||
}
|
||||
|
||||
this.hasCallEnded = true
|
||||
if (sendBye) {
|
||||
await this.sipManager.sendBye().catch(this.console.error)
|
||||
}
|
||||
|
||||
// clean up
|
||||
this.console.log("sip-call-session callEnded")
|
||||
this.onCallEndedSubject.next(null)
|
||||
//this.sipManager.destroy()
|
||||
this.audioSplitter.close()
|
||||
this.audioRtcpSplitter.close()
|
||||
this.videoSplitter.close()
|
||||
this.videoRtcpSplitter.close()
|
||||
this.unsubscribe()
|
||||
this.console.log("sip-call-session callEnded: done")
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await this.callEnded(true)
|
||||
}
|
||||
}
|
||||
@@ -1,426 +1,532 @@
|
||||
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'
|
||||
import { timeoutPromise } from '@scrypted/common/src/promise-utils';
|
||||
|
||||
const sip = require('sip'),
|
||||
sdp = require('sdp')
|
||||
|
||||
export interface SipOptions {
|
||||
to: string
|
||||
from: string
|
||||
domain?: string
|
||||
expire?: number
|
||||
localIp: string
|
||||
localPort: number
|
||||
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
|
||||
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] || undefined
|
||||
|
||||
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,
|
||||
...(encodedCrypto? 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: Console
|
||||
|
||||
public readonly audioUfrag = randomString(16)
|
||||
public readonly videoUfrag = randomString(16)
|
||||
|
||||
constructor(
|
||||
console: Console,
|
||||
private sipOptions: SipOptions,
|
||||
private rtpOptions: RtpOptions,
|
||||
//tlsPort: number
|
||||
) {
|
||||
this.console = console;
|
||||
|
||||
const host = this.sipOptions.localIp,
|
||||
port = this.sipOptions.localPort,
|
||||
contactId = 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,
|
||||
logger: {
|
||||
recv: function(m, remote) {
|
||||
if( m.status == '200' && m.reason =='Ok' && m.headers.contact ) {
|
||||
// ACK for INVITE and BYE must use the registrar contact uri
|
||||
this.registrarContact = m.headers.contact[0].uri;
|
||||
}
|
||||
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.uri = "sip:" + sipOptions.domain
|
||||
} else if( m.method == 'INVITE' ) {
|
||||
m.uri = toWithDomain
|
||||
} else if( m.method == 'ACK' || m.method == 'BYE' ) {
|
||||
m.uri = this.registrarContact
|
||||
} 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 && 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') {
|
||||
this.console.info('received BYE from remote end')
|
||||
this.sipStack.send(this.sipStack.makeResponse(request, 200, 'Ok'))
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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`,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: {
|
||||
supported: 'replaces, outbound',
|
||||
allow:
|
||||
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
|
||||
'content-type': 'application/sdp',
|
||||
contact: [{ uri: from }],
|
||||
},
|
||||
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;
|
||||
await timeoutPromise( 500,
|
||||
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 } }],
|
||||
},
|
||||
}).catch(() => {
|
||||
// Don't care if we get an exception here.
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 await timeoutPromise( 3000, 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")
|
||||
}
|
||||
}
|
||||
import { noop, Subject } from 'rxjs'
|
||||
import { randomInteger, randomString } from './util'
|
||||
import { RtpDescription, RtpOptions, RtpStreamDescription } from './rtp-utils'
|
||||
import { decodeSrtpOptions } from '../../ring/src/srtp-utils'
|
||||
import { stringify, stringifyUri } from '@slyoldfox/sip'
|
||||
import { timeoutPromise } from '@scrypted/common/src/promise-utils';
|
||||
import sdp from 'sdp'
|
||||
|
||||
const sip = require('@slyoldfox/sip')
|
||||
|
||||
export interface SipOptions {
|
||||
to: string
|
||||
from: string
|
||||
domain?: string
|
||||
expire?: number
|
||||
localIp: string
|
||||
localPort: number
|
||||
debugSip?: boolean
|
||||
useTcp?: boolean
|
||||
gruuInstanceId?: string
|
||||
sipRequestHandler?: SipRequestHandler
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows handling of SIP messages
|
||||
*/
|
||||
export abstract class SipRequestHandler {
|
||||
abstract handle( request: SipRequest )
|
||||
}
|
||||
|
||||
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,
|
||||
extension?: {
|
||||
headers: Partial<SipHeaders>,
|
||||
content
|
||||
}
|
||||
) => 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,
|
||||
srtpKey: undefined,
|
||||
srtpSalt: undefined
|
||||
};
|
||||
}
|
||||
|
||||
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] || undefined
|
||||
|
||||
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,
|
||||
...(encodedCrypto? decodeSrtpOptions(encodedCrypto) : { srtpKey: undefined, srtpSalt: 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'),
|
||||
video: getRtpDescription(console, sections, 'video')
|
||||
}
|
||||
}
|
||||
|
||||
export class SipManager {
|
||||
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: Console
|
||||
|
||||
public readonly audioUfrag = randomString(16)
|
||||
public readonly videoUfrag = randomString(16)
|
||||
|
||||
constructor(
|
||||
console: Console,
|
||||
private sipOptions: SipOptions,
|
||||
) {
|
||||
this.console = console;
|
||||
const host = this.sipOptions.localIp,
|
||||
port = this.sipOptions.localPort
|
||||
|
||||
this.sipStack = {
|
||||
makeResponse: sip.makeResponse,
|
||||
...sip.create({
|
||||
host,
|
||||
hostname: host,
|
||||
port: port,
|
||||
udp: !this.sipOptions.useTcp,
|
||||
tcp: this.sipOptions.useTcp,
|
||||
tls: false,
|
||||
// tls_port: tlsPort,
|
||||
// tls: {
|
||||
// rejectUnauthorized: false,
|
||||
// },
|
||||
ws: false,
|
||||
logger: {
|
||||
recv: function(m, remote) {
|
||||
if( (m.status == '200' || m.method === 'INVITE' ) && m.headers && m.headers.cseq && m.headers.cseq.method === 'INVITE' && m.headers.contact && m.headers.contact[0] ) {
|
||||
// ACK for INVITE and BYE must use the registrar contact uri
|
||||
this.registrarContact = m.headers.contact[0].uri;
|
||||
}
|
||||
if( sipOptions.debugSip ) {
|
||||
console.log("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<")
|
||||
console.log(stringify( m ));
|
||||
console.log("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<")
|
||||
}
|
||||
},
|
||||
appendGruu: function( contact, gruuUrn ) {
|
||||
if( sipOptions.gruuInstanceId ) {
|
||||
if( contact && contact[0] ) {
|
||||
if( !contact[0].params ) {
|
||||
contact[0].params = {}
|
||||
}
|
||||
contact[0].params['+sip.instance'] = '"<urn:uuid:' + sipOptions.gruuInstanceId + '>"'
|
||||
if( gruuUrn ) {
|
||||
contact[0].uri = contact[0].uri + ';gr=urn:uuid:' + sipOptions.gruuInstanceId
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
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.uri = "sip:" + sipOptions.domain
|
||||
m.headers.to.uri = fromWithDomain
|
||||
this.appendGruu( m.headers.contact )
|
||||
} else if( m.method == 'INVITE' || m.method == 'MESSAGE' ) {
|
||||
m.uri = toWithDomain
|
||||
m.headers.to.uri = toWithDomain
|
||||
if( m.method == 'MESSAGE' && m.headers.to ) {
|
||||
m.headers.to.params = null;
|
||||
}
|
||||
} else if( m.method == 'ACK' || m.method == 'BYE' ) {
|
||||
m.headers.to.uri = toWithDomain
|
||||
m.uri = this.registrarContact
|
||||
} else if( (m.method == undefined && m.status) && m.headers.cseq ) {
|
||||
if( m.status == '200' ) {
|
||||
// Response on invite
|
||||
this.appendGruu( m.headers.contact, true )
|
||||
}
|
||||
|
||||
// 183, 200, OK, CSeq: INVITE
|
||||
} else {
|
||||
console.error("Error: Method construct for uri not implemented: " + m.method)
|
||||
}
|
||||
|
||||
if( m.method ) {
|
||||
m.headers.from.uri = fromWithDomain
|
||||
if( m.headers.contact && m.headers.contact[0].uri.split('@')[0].lastIndexOf('-') < 0 ) {
|
||||
// Also a bug in SIP.js ? append the transport for the contact if the transport is udp (according to RFC)
|
||||
if( remote.protocol != 'udp' && m.headers.contact[0].uri.indexOf( "transport=" ) < 0 ) {
|
||||
m.headers.contact[0].uri = m.headers.contact[0].uri + ";transport=" + remote.protocol
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if( sipOptions.debugSip ) {
|
||||
console.log(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
|
||||
if( m.uri ) {
|
||||
console.log(stringify( m ));
|
||||
} else {
|
||||
m.uri = '';
|
||||
console.log( stringify( m ) )
|
||||
}
|
||||
|
||||
console.log(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
(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)
|
||||
}
|
||||
} else if( request.method === 'MESSAGE' && sipOptions.sipRequestHandler ) {
|
||||
sipOptions.sipRequestHandler.handle( request )
|
||||
this.sipStack.send(this.sipStack.makeResponse(request, 200, 'Ok'))
|
||||
} else if( request.method === 'INVITE' && sipOptions.sipRequestHandler ) {
|
||||
//let tryingResponse = this.sipStack.makeResponse( request, 100, 'Trying' )
|
||||
//this.sipStack.send(tryingResponse)
|
||||
//TODO: sporadic re-INVITEs are possible and should reply with 486 Busy here if already being handled
|
||||
let ringResponse = this.sipStack.makeResponse(request, 180, 'Ringing')
|
||||
this.toParams.tag = getRandomId()
|
||||
ringResponse.headers.to.params.tag = this.toParams.tag
|
||||
ringResponse.headers["record-route"] = request.headers["record-route"];
|
||||
ringResponse.headers["supported"] = "replaces, outbound, gruu"
|
||||
// Can include SDP and could send 183 here for early media
|
||||
this.sipStack.send(ringResponse)
|
||||
|
||||
sipOptions.sipRequestHandler.handle( request )
|
||||
// }, 100 )
|
||||
} else if( request.method === 'CANCEL' || request.method === 'ACK' ) {
|
||||
sipOptions.sipRequestHandler.handle( request )
|
||||
} else {
|
||||
if( sipOptions.debugSip ) {
|
||||
this.console.warn("unimplemented method received from remote: " + request.method)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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.headers.from.params && response.headers.from.params.tag) {
|
||||
this.fromParams.tag = response.headers.from.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)
|
||||
}
|
||||
|
||||
public setSipOptions( sipOptions : SipOptions ) {
|
||||
this.sipOptions = sipOptions
|
||||
}
|
||||
|
||||
sendDtmf(key: string) {
|
||||
return this.request({
|
||||
method: 'INFO',
|
||||
headers: {
|
||||
'Content-Type': 'application/dtmf-relay',
|
||||
},
|
||||
content: `Signal=${key}\r\nDuration=250`,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate a call by sending a SIP INVITE request
|
||||
*/
|
||||
async invite( rtpOptions : RtpOptions, audioSection, videoSection?, incomingCallRequest? ) : Promise<RtpDescription> {
|
||||
let ssrc = randomInteger()
|
||||
let audio = audioSection ? audioSection( rtpOptions.audio, ssrc ).concat( ...[`a=rtcp:${rtpOptions.audio.rtcpPort}`] ) : []
|
||||
let video = videoSection ? videoSection( rtpOptions.video, ssrc ).concat( ...[`a=rtcp:${rtpOptions.video.rtcpPort}`] ) : []
|
||||
const { from, localIp } = this.sipOptions;
|
||||
|
||||
|
||||
if( incomingCallRequest ) {
|
||||
let callResponse = this.sipStack.makeResponse(incomingCallRequest, 200, 'Ok', {
|
||||
headers: {
|
||||
supported: 'replaces, outbound, gruu',
|
||||
allow:
|
||||
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
|
||||
'content-type': 'application/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'
|
||||
} )
|
||||
if( incomingCallRequest.headers["record-route"] )
|
||||
callResponse.headers["record-route"] = incomingCallRequest.headers["record-route"];
|
||||
let fromWithDomain: string = (from.split('@')[0] + '@' + this.sipOptions.domain).trim()
|
||||
callResponse.headers.contact = [{ uri: fromWithDomain }]
|
||||
|
||||
// Invert the params if the request comes from the server
|
||||
this.fromParams.tag = incomingCallRequest.headers.to.params.tag
|
||||
this.toParams.tag = incomingCallRequest.headers.from.params.tag
|
||||
this.callId = incomingCallRequest.headers["call-id"]
|
||||
|
||||
await this.sipStack.send(callResponse)
|
||||
|
||||
return parseRtpDescription(this.console, incomingCallRequest)
|
||||
} else {
|
||||
if( this.sipOptions.to.toLocaleLowerCase().indexOf('c300x') >= 0 ) {
|
||||
// Needed for bt_answering_machine (bticino specific)
|
||||
audio.unshift('a=DEVADDR:20')
|
||||
}
|
||||
let inviteResponse = await this.request({
|
||||
method: 'INVITE',
|
||||
headers: {
|
||||
supported: 'replaces, outbound, gruu',
|
||||
allow:
|
||||
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
|
||||
'content-type': 'application/sdp',
|
||||
contact: [{ uri: from }],
|
||||
},
|
||||
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() : Promise<void> {
|
||||
const { from } = this.sipOptions;
|
||||
await timeoutPromise( 3000,
|
||||
this.request({
|
||||
method: 'REGISTER',
|
||||
headers: {
|
||||
supported: 'replaces, outbound, gruu',
|
||||
allow:
|
||||
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
|
||||
contact: [{ uri: from }],
|
||||
expires: this.sipOptions.expire // as seen in tcpdump for Door Entry app
|
||||
},
|
||||
}).catch(noop));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the current call contact
|
||||
*/
|
||||
async message( content: string ) : Promise<SipResponse> {
|
||||
const { from } = this.sipOptions,
|
||||
messageResponse = await this.request({
|
||||
method: 'MESSAGE',
|
||||
headers: {
|
||||
//supported: 'replaces, outbound',
|
||||
allow:
|
||||
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
|
||||
'content-type': 'text/plain',
|
||||
contact: [{ uri: from, params: { expires: this.sipOptions.expire } }],
|
||||
},
|
||||
content: content
|
||||
});
|
||||
return messageResponse;
|
||||
}
|
||||
|
||||
async sendBye() : Promise<void | SipResponse> {
|
||||
this.console.log('Sending BYE...')
|
||||
return await timeoutPromise( 3000, this.request({ method: 'BYE' }).catch(() => {
|
||||
// Don't care if we get an exception here.
|
||||
}));
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.console.debug("detroying sip-manager")
|
||||
this.destroyed = true
|
||||
this.sipStack.destroy()
|
||||
this.console.debug("detroying sip-manager: done")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user