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:
slyoldfox
2023-03-20 15:19:08 +01:00
committed by GitHub
parent 420f070035
commit e8ee21e567
17 changed files with 6061 additions and 6141 deletions

File diff suppressed because it is too large Load Diff

View File

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

View 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}`;
}
}

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

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

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

View File

@@ -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()

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

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

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

File diff suppressed because it is too large Load Diff

View File

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

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

View File

@@ -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);
}

View File

@@ -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
}

View File

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

View File

@@ -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")
}
}