First working version for a BTicino C300X (#468)

* First working version for a BTicino C300X

* Implement cleaner settings retrieval/storage

* Let rebroadcast handle video
This commit is contained in:
slyoldfox
2022-12-19 16:11:43 +01:00
committed by GitHub
parent 28eb9a8182
commit 060ff2bd33
13 changed files with 3837 additions and 1 deletions

View File

@@ -149,7 +149,7 @@ export function parseFmtp(msection: string[]) {
const paramLine = fmtpLine.substring(firstSpace + 1);
const payloadType = parseInt(fmtp.split(':')[1]);
if (!fmtp || !paramLine || payloadType === NaN) {
if (!fmtp || !paramLine || Number.isNaN( payloadType )) {
return;
}

4
plugins/bticino/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,11 @@
.DS_Store
out/
node_modules/
*.map
fs
src
.vscode
dist/*.js
dist/*.txt
HAP-NodeJS
.gitmodules

23
plugins/bticino/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,23 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Scrypted Debugger",
"address": "${config:scrypted.debugHost}",
"port": 10081,
"request": "attach",
"skipFiles": [
"**/plugin-remote-worker.*",
"<node_internals>/**"
],
"preLaunchTask": "scrypted: deploy+debug",
"sourceMaps": true,
"localRoot": "${workspaceFolder}/out",
"remoteRoot": "/plugin/",
"type": "node"
}
]
}

4
plugins/bticino/.vscode/settings.json vendored Normal file
View File

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

20
plugins/bticino/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "scrypted: deploy+debug",
"type": "shell",
"presentation": {
"echo": true,
"reveal": "silent",
"focus": false,
"panel": "shared",
"showReuseMessage": true,
"clear": false
},
"command": "npm run scrypted-vscode-launch ${config:scrypted.debugHost}",
},
]
}

132
plugins/bticino/README.md Normal file
View File

@@ -0,0 +1,132 @@
# BTicino C300X Plugin for Scrypted
The C300X Plugin for Scrypted allows viewing your C300X intercom with incoming video/audio.
WARNING: You will need access to the device, see https://github.com/fquinto/bticinoClasse300x
## Development instructions
```
$ cd plugins/sip
$ npm ci
$ cd plugins/bticino
$ npm ci
$ npm run build
$ num run scrypted-deploy 127.0.0.1
```
After flashing a custom firmware you must at least:
* Allow access to the SIP server on port 5060
* Allow your IP to authenticated with the SIP server
* Add a SIP user for scrypted
To do this use the guide below:
## Make flexisip listen on a reachable IP and add users to it
To be able to talk to our own SIP server, we need to make the SIP server on the C300X
talk to our internal network, instead of only locally (on the `lo` interface).
Mount the root system read-write
````
$ mount -oremount,rw /
````
Change the listening ports by appending some arguments in `/etc/init.d/flexisipsh`
(look at the end of the line, change to the IP of your C300X)
```
case "$1" in
start)
start-stop-daemon --start --quiet --exec $DAEMON -- $DAEMON_ARGS --transports "sips:$2:5061;maddr=$2;require-peer-certificate=1 sip:127.0.0.1;maddr=127.0.0.1 sip:192.168.0.XX;maddr=192.168.0.XX"
;;
```
You can also change it to - `$2`, the script will then put in the current wifi IP.
````
start-stop-daemon --start --quiet --exec $DAEMON -- $DAEMON_ARGS --transports "sips:$2:5061;maddr=$2;require-peer-certificate=1 sip:127.0.0.1;maddr=127.0.0.1 sip:$2;maddr=$2"
````
The intercom is firewalled, the easiest way is to remove the firewall file (or move it to somewhere on `/home/bticino/cfg/extra` which is a kind of permanent storage)
If you don't want to do that yet, drop the firewall rules from command line: (IMPORTANT: needs to be repeated after each reboot)
````
iptables -P INPUT ACCEPT
iptables -P FORWARD ACCEPT
iptables -P OUTPUT ACCEPT
````
If you are sick of repeating these commands every time you reboot:
````
mv /etc/network/if-pre-up.d/iptables /home/bticino/cfg/extra/iptables.bak
mv /etc/network/if-pre-up.d/iptables6 /home/bticino/cfg/extra/iptables6.bak
````
Edit the `/home/bticino/cfg/flexisip.conf` so `baresip` can authenticate with it.
Set `log-level` and `syslog-level` to `debug` (it logs to `/var/log/log_rotation.log`)
In `trusted-hosts` add the IP address of the server where you will run `baresip`.
This makes sure we dont need to bother with the initial authentication of username/password.
Hosts in `trusted-hosts` can register without needing to authenticate.
````
[global]
...
log-level=debug
syslog-level=debug
[module::Authentication]
enabled=true
auth-domains=c300x.bs.iotleg.com
db-implementation=file
datasource=/etc/flexisip/users/users.db.txt
trusted-hosts=127.0.0.1 192.168.0.XX
hashed-passwords=true
reject-wrong-client-certificates=true
````
Now we will add a `user agent` (user) that will be used by `baresip` to register itself with `flexisip`
Edit the `/etc/flexisip/users/users.db.txt` file and create a new line by copy/pasting the c300x user.
For example:
````
c300x@1234567.bs.iotleg.com md5:ffffffffffffffffffffffffffffffff ;
baresip@1234567.bs.iotleg.com md5:ffffffffffffffffffffffffffffffff ;
````
Leave the md5 as the same value - I use `fffff....` just for this example.
Edit the `/etc/flexisip/users/route.conf` file and add a new line to it, it specifies where this user can be found on the network.
Change the IP address to the place where you will run `baresip` (same as `trusted-hosts` above)
````
<sip:baresip@1234567.bs.iotleg.com> <sip:192.168.0.XX>
````
Edit the `/etc/flexisip/users/route_int.conf` file.
This file contains one line that starts with `<sip:alluser@...` it specifies who will be called when someone rings the doorbell.
You can look at it as a group of users that is called when you call `alluser@1234567.bs.iotleg.com`
Add your username at the end (make sure you stay on the same line, NOT a new line!)
````
<sip:alluser@1234567.bs.iotleg.com> ..., <sip:baresip@1234567.bs.iotleg.com>
````
Reboot and verify flexisip is listening on the new IP address.
````
~# ps aux|grep flexis
bticino 741 0.0 0.3 9732 1988 ? SNs Oct28 0:00 /usr/bin/flexisip --daemon --syslog --pidfile /var/run/flexisip.pid --p12-passphrase-file /var/tmp/bt_answering_machine.fifo --transports sips:192.168.0.XX:5061;maddr=192.168.0.XX;require-peer-certificate=1 sip:127.0.0.1;maddr=127.0.0.1 sip:192.168.0.XX;maddr=192.168.0.XX
bticino 742 0.1 1.6 45684 8408 ? SNl Oct28 1:44 /usr/bin/flexisip --daemon --syslog --pidfile /var/run/flexisip.pid --p12-passphrase-file /var/tmp/bt_answering_machine.fifo --transports sips:192.168.0.XX:5061;maddr=192.168.0.XX;require-peer-certificate=1 sip:127.0.0.1;maddr=127.0.0.1 sip:192.168.0.XX;maddr=192.168.0.XX

2654
plugins/bticino/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
{
"name": "@scrypted/bticino",
"version": "0.0.1",
"scripts": {
"scrypted-setup-project": "scrypted-setup-project",
"prescrypted-setup-project": "scrypted-package-json",
"build": "scrypted-webpack",
"prepublishOnly": "NODE_ENV=production scrypted-webpack",
"prescrypted-vscode-launch": "scrypted-webpack",
"scrypted-vscode-launch": "scrypted-deploy-debug",
"scrypted-deploy-debug": "scrypted-deploy-debug",
"scrypted-debug": "scrypted-debug",
"scrypted-deploy": "scrypted-deploy",
"scrypted-readme": "scrypted-readme",
"scrypted-package-json": "scrypted-package-json"
},
"keywords": [
"scrypted",
"plugin",
"sip"
],
"scrypted": {
"name": "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"
}
}

367
plugins/bticino/src/main.ts Normal file
View File

@@ -0,0 +1,367 @@
import { listenZeroSingleClient } from '@scrypted/common/src/listen-cluster';
import { SipOptions } from './sip-call';
import { RtspServer } from '@scrypted/common/src/rtsp-server';
import { addTrackControls, parseSdp, replacePorts } from '@scrypted/common/src/sdp-utils';
import { StorageSettings } from '@scrypted/sdk/storage-settings';
import sdk, { BinarySensor, Camera, DeviceCreator, DeviceCreatorSettings, DeviceProvider, FFmpegInput, Intercom, MediaObject, MediaStreamUrl, PictureOptions, ResponseMediaStreamOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings, SettingValue, VideoCamera } from '@scrypted/sdk';
import dgram from 'dgram';
import { SipSession } from './sip-session';
import { isStunMessage, getPayloadType, getSequenceNumber, isRtpMessagePayloadType } from '../../sip/src/rtp-utils';
import { ChildProcess } from 'child_process';
import { randomBytes } from 'crypto';
const STREAM_TIMEOUT = 50000;
const SIP_EXPIRATION_DEFAULT = 3600;
const { deviceManager, mediaManager } = sdk;
class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCamera, Settings, BinarySensor {
session: SipSession;
audioOutForwarder: dgram.Socket;
audioOutProcess: ChildProcess;
doorbellAudioActive: boolean;
audioInProcess: ChildProcess;
currentMedia: FFmpegInput | MediaStreamUrl;
currentMediaMimeType: string;
audioSilenceProcess: ChildProcess;
refreshTimeout: NodeJS.Timeout;
pendingPicture: Promise<MediaObject>;
constructor(nativeId: string, public provider: SipCamProvider) {
super(nativeId);
this.binaryState = false;
this.doorbellAudioActive = false;
this.audioSilenceProcess = null;
}
async takePicture(option?: PictureOptions): Promise<MediaObject> {
throw new Error("The SIP doorbell camera does not provide snapshots. Install the Snapshot Plugin if snapshots are available via an URL.");
}
async getPictureOptions(): Promise<PictureOptions[]> {
return;
}
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() {
this.doorbellAudioActive = false;
this.audioInProcess?.kill('SIGKILL');
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,
tcp: false,
udp: true,
debug: sipdebug
};
sip = await SipSession.createSipSession(console, "Bticino", sipOptions);
sip.onCallEnded.subscribe(cleanup);
// Call the C300X
let remoteRtpDescription = await sip.start();
if( sipOptions.debug )
this.log.d('SIP: Received remote SDP:\n' + remoteRtpDescription.sdp)
let sdp: string = replacePorts( remoteRtpDescription.sdp, 0, 0 );
sdp = addTrackControls(sdp);
sdp = sdp.split('\n').filter(line => !line.includes('a=rtcp-mux')).join('\n');
if( sipOptions.debug )
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.debug ) {
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, device: any): 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();

View File

@@ -0,0 +1,380 @@
import { noop, Subject } from 'rxjs'
import { randomInteger, randomString } from '../../sip/src/util'
import { RtpDescription, RtpOptions, RtpStreamDescription } from '../../sip/src/rtp-utils'
import { stringify } from 'sip/sip'
import { decodeSrtpOptions } from '../../ring/src/srtp-utils'
const contactId = randomInteger();
const sip = require('sip'),
sdp = require('sdp')
export interface SipOptions {
to: string
from: string
domain: string
expire: number
localIp: string
localPort: number
udp: boolean
tcp: boolean
debug: boolean
}
interface UriOptions {
name?: string
uri: string
params?: {
tag?: string
expires?: number
}
}
interface SipHeaders {
[name: string]: string | any
cseq: { seq: number; method: string }
to: UriOptions
from: UriOptions
contact?: UriOptions[]
via?: UriOptions[]
}
export interface SipRequest {
uri: UriOptions | string
method: string
headers: SipHeaders
content: string
}
export interface SipResponse {
status: number
reason: string
headers: SipHeaders
content: string
}
interface SipStack {
send: (
request: SipRequest | SipResponse,
handler?: (response: SipResponse) => void
) => void
destroy: () => void
makeResponse: (
response: SipRequest,
status: number,
method: string
) => SipResponse
}
function getRandomId() {
return Math.floor(Math.random() * 1e6).toString()
}
function getRtpDescription(
console: any,
sections: string[],
mediaType: 'audio' | 'video'
): RtpStreamDescription {
try {
const section = sections.find((s) => s.startsWith('m=' + mediaType));
if( section === undefined ) {
return {
port: 0,
rtcpPort: 0
};
}
const { port } = sdp.parseMLine(section),
lines: string[] = sdp.splitLines(section),
rtcpLine = lines.find((l: string) => l.startsWith('a=rtcp:')),
cryptoLine = lines.find((l: string) => l.startsWith('a=crypto'))!,
rtcpMuxLine = lines.find((l: string) => l.startsWith('a=rtcp-mux')),
ssrcLine = lines.find((l: string) => l.startsWith('a=ssrc')),
iceUFragLine = lines.find((l: string) => l.startsWith('a=ice-ufrag')),
icePwdLine = lines.find((l: string) => l.startsWith('a=ice-pwd')),
encodedCrypto = cryptoLine.match(/inline:(\S*)/)![1]
let rtcpPort: number;
if (rtcpMuxLine) {
rtcpPort = port; // rtcp-mux would cause rtcpLine to not be present
}
else {
rtcpPort = (rtcpLine && Number(rtcpLine.match(/rtcp:(\S*)/)?.[1])) || port + 1; // if there is no explicit RTCP port, then use RTP port + 1
}
return {
port,
rtcpPort,
ssrc: (ssrcLine && Number(ssrcLine.match(/ssrc:(\S*)/)?.[1])) || undefined,
iceUFrag: (iceUFragLine && iceUFragLine.match(/ice-ufrag:(\S*)/)?.[1]) || undefined,
icePwd: (icePwdLine && icePwdLine.match(/ice-pwd:(\S*)/)?.[1]) || undefined,
...decodeSrtpOptions(encodedCrypto),
}
} catch (e) {
console.error('Failed to parse SDP from remote end')
console.error(sections.join('\r\n'))
throw e
}
}
function parseRtpDescription(console: any, inviteResponse: {
content: string
}): RtpDescription {
const sections: string[] = sdp.splitSections(inviteResponse.content),
lines: string[] = sdp.splitLines(sections[0]),
cLine = lines.find((line: string) => line.startsWith('c='))!
return {
sdp: inviteResponse.content,
address: cLine.match(/c=IN IP4 (\S*)/)![1],
audio: getRtpDescription(console, sections, 'audio'),
video: getRtpDescription(console, sections, 'video')
}
}
export class SipCall {
private seq = 20
private fromParams = { tag: getRandomId() }
private toParams: { tag?: string } = {}
private callId = getRandomId()
private sipStack: SipStack
public readonly onEndedByRemote = new Subject()
private destroyed = false
private readonly console: any
public readonly sdp: string
public readonly audioUfrag = randomString(16)
public readonly videoUfrag = randomString(16)
constructor(
console: any,
private sipOptions: SipOptions,
rtpOptions: RtpOptions,
//tlsPort: number
) {
this.console = console;
const { audio, video } = rtpOptions,
{ from } = this.sipOptions,
host = this.sipOptions.localIp,
port = this.sipOptions.localPort,
ssrc = randomInteger();
this.sipStack = {
makeResponse: sip.makeResponse,
...sip.create({
host,
hostname: host,
port: port,
udp: this.sipOptions.udp,
tcp: this.sipOptions.tcp,
tls: false,
logger: {
recv: function(m, remote) {
if( sipOptions.debug ) {
console.log("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<")
console.log(stringify( m ));
console.log("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<")
}
},
send: function(m, remote) {
let toWithDomain: string = (sipOptions.to.split('@')[0] + '@' + sipOptions.domain).trim()
let fromWithDomain: string = (sipOptions.from.split('@')[0] + '@' + sipOptions.domain).trim()
if( m.method == 'REGISTER' || m.method == 'INVITE' ) {
if( m.method == 'REGISTER' ) {
m.uri = "sip:" + sipOptions.domain
} else if( m.method == 'INVITE' ) {
m.uri = toWithDomain
} else {
throw new Error("Error: Method construct for uri not implemented: " + m.method)
}
m.headers.to.uri = toWithDomain
m.headers.from.uri = fromWithDomain
if( m.headers.contact[0].uri.split('@')[0].indexOf('-') < 0 ) {
m.headers.contact[0].uri = m.headers.contact[0].uri.replace("@", "-" + contactId + "@");
}
}
if( sipOptions.debug ) {
console.log(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
console.log(stringify( m ));
console.log(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
}
},
},
// tls_port: tlsPort,
// tls: {
// rejectUnauthorized: false,
// },
ws: false
},
(request: SipRequest) => {
if (request.method === 'BYE') {
this.console.info('received BYE from remote end')
this.sipStack.send(this.sipStack.makeResponse(request, 200, 'Ok'))
if (this.destroyed) {
this.onEndedByRemote.next(null)
}
}
}
)
}
this.sdp = ([
'v=0',
//`o=- 3747 461 IN IP4 ${host}`,
`o=${from.split(':')[1].split('@')[0]} 3747 461 IN IP4 ${host}`,
's=ScryptedSipPlugin',
`c=IN IP4 ${host}`,
't=0 0',
'a=DEVADDR:20',
`m=audio ${audio.port} RTP/SAVP 97`,
`a=rtpmap:97 speex/8000`,
`a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:/qE7OPGKp9hVGALG2KcvKWyFEZfSSvm7bYVDjT8X`,
`m=video ${video.port} RTP/SAVP 97`,
`a=rtpmap:97 H264/90000`,
`a=fmtp:97 profile-level-id=42801F`,
`a=ssrc:${ssrc}`,
`a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:/qE7OPGKp9hVGALG2KcvKWyFEZfSSvm7bYVDjT8X`,
'a=recvonly'
]
.filter((l) => l)
.join('\r\n')) + '\r\n';
}
request({
method,
headers,
content,
seq,
}: {
method: string
headers?: Partial<SipHeaders>
content?: string
seq?: number
}) {
if (this.destroyed) {
return Promise.reject(
new Error('SIP request made after call was destroyed')
)
}
return new Promise<SipResponse>((resolve, reject) => {
seq = seq || this.seq++
this.sipStack.send(
{
method,
uri: this.sipOptions.to,
headers: {
to: {
//name: '"Scrypted SIP Plugin Client"',
uri: this.sipOptions.to,
params: (method == 'REGISTER' || method == 'INVITE' ? null : this.toParams),
},
from: {
uri: this.sipOptions.from,
params: this.fromParams,
},
'max-forwards': 70,
'call-id': this.callId,
cseq: { seq, method },
...headers,
},
content: content || '',
},
(response: SipResponse) => {
if (response.headers.to.params && response.headers.to.params.tag) {
this.toParams.tag = response.headers.to.params.tag
}
if (response.status >= 300) {
if (response.status !== 408 || method !== 'BYE') {
this.console.error(
`sip ${method} request failed with status ` + response.status
)
}
reject(
new Error(
`sip ${method} request failed with status ` + response.status
)
)
} else if (response.status < 200) {
// call made progress, do nothing and wait for another response
// console.log('call progress status ' + response.status)
} else {
if (method === 'INVITE') {
// The ACK must be sent with every OK to keep the connection alive.
this.acknowledge(seq!).catch((e) => {
this.console.error('Failed to send SDP ACK')
this.console.error(e)
})
}
resolve(response)
}
}
)
})
}
private async acknowledge(seq: number) {
// Don't wait for ack, it won't ever come back.
this.request({
method: 'ACK',
seq, // The ACK must have the original sequence number.
}).catch(noop)
}
sendDtmf(key: string) {
return this.request({
method: 'INFO',
headers: {
'Content-Type': 'application/dtmf-relay',
},
content: `Signal=${key}\r\nDuration=250`,
})
}
async invite() {
const { from } = this.sipOptions,
inviteResponse = await this.request({
method: 'INVITE',
headers: {
supported: 'replaces, outbound',
allow:
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
'content-type': 'application/sdp',
contact: [{ uri: from }],
},
content: this.sdp,
})
return parseRtpDescription(this.console, inviteResponse)
}
async register() {
const { from } = this.sipOptions,
inviteResponse = await this.request({
method: 'REGISTER',
headers: {
//supported: 'replaces, outbound',
allow:
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
'content-type': 'application/sdp',
contact: [{ uri: from, params: { expires: this.sipOptions.expire } }],
},
});
}
async sendBye() {
this.console.log('Sending BYE...')
return this.request({ method: 'BYE' }).catch(() => {
// Don't care if we get an exception here.
})
}
destroy() {
this.console.debug("detroying sip-call")
this.destroyed = true
this.sipStack.destroy()
this.console.debug("detroying sip-call: done")
}
}

View File

@@ -0,0 +1,180 @@
import { reservePorts } from '@homebridge/camera-utils';
import { createBindUdp, createBindZero } from '@scrypted/common/src/listen-cluster';
import dgram from 'dgram';
import { ReplaySubject, timer } from 'rxjs';
import { createStunResponder, RtpDescription, RtpOptions, sendStunBindingRequest } from '../../sip/src/rtp-utils';
import { SipCall, SipOptions } from './sip-call';
import { Subscribed } from '../../sip/src/subscribed';
export class SipSession extends Subscribed {
private hasStarted = false
private hasCallEnded = false
private onCallEndedSubject = new ReplaySubject(1)
private sipCall: SipCall
onCallEnded = this.onCallEndedSubject.asObservable()
constructor(
public readonly console: any,
public readonly sipOptions: SipOptions,
public readonly rtpOptions: RtpOptions,
public readonly audioSplitter: dgram.Socket,
public audioRtcpSplitter: dgram.Socket,
public readonly videoSplitter: dgram.Socket,
public videoRtcpSplitter: dgram.Socket,
public readonly cameraName: string
) {
super()
this.sipCall = this.createSipCall(this.sipOptions)
}
static async createSipSession(console: any, cameraName: string, sipOptions: SipOptions) {
const audioSplitter = await createBindZero(),
audioRtcpSplitter = await createBindUdp(audioSplitter.port + 1),
videoSplitter = await createBindZero(),
videoRtcpSplitter = await createBindUdp(videoSplitter.port + 1),
rtpOptions = {
audio: {
port: audioSplitter.port,
rtcpPort: audioRtcpSplitter.port
},
video: {
port: videoSplitter.port,
rtcpPort: videoRtcpSplitter.port
}
}
return new SipSession(
console,
sipOptions,
rtpOptions,
audioSplitter.server,
audioRtcpSplitter.server,
videoSplitter.server,
videoRtcpSplitter.server,
cameraName
)
}
createSipCall(sipOptions: SipOptions) {
if (this.sipCall) {
this.sipCall.destroy()
}
const call = (this.sipCall = new SipCall(
this.console,
sipOptions,
this.rtpOptions
))
this.addSubscriptions(
call.onEndedByRemote.subscribe(() => this.callEnded(false))
)
return this.sipCall
}
async start(): Promise<RtpDescription> {
if (this.hasStarted) {
throw new Error('SIP Session has already been started')
}
this.hasStarted = true
if (this.hasCallEnded) {
throw new Error('SIP Session has already ended')
}
try {
await this.sipCall.register();
const rtpDescription = await this.sipCall.invite(),
sendStunRequests = () => {
sendStunBindingRequest({
rtpSplitter: this.audioSplitter,
rtcpSplitter: this.audioRtcpSplitter,
rtpDescription,
localUfrag: this.sipCall.audioUfrag,
type: 'audio',
})
sendStunBindingRequest({
rtpSplitter: this.videoSplitter,
rtcpSplitter: this.videoRtcpSplitter,
rtpDescription,
localUfrag: this.sipCall.videoUfrag,
type: 'video',
})
}
// if rtcp-mux is supported, rtp splitter will be used for both rtp and rtcp
if (rtpDescription.audio.port === rtpDescription.audio.rtcpPort) {
this.audioRtcpSplitter.close()
this.audioRtcpSplitter = this.audioSplitter
}
if (rtpDescription.video.port === rtpDescription.video.rtcpPort) {
this.videoRtcpSplitter.close()
this.videoRtcpSplitter = this.videoSplitter
}
if (rtpDescription.video.iceUFrag) {
// ICE is supported
this.console.debug(`Connecting to ${this.cameraName} using ICE`)
createStunResponder(this.audioSplitter)
createStunResponder(this.videoSplitter)
sendStunRequests()
} else {
// ICE is not supported, use stun as keep alive
this.console.debug(`Connecting to ${this.cameraName} using STUN`)
this.addSubscriptions(
// hole punch every .5 seconds to keep stream alive and port open (matches behavior from Ring app)
timer(0, 500).subscribe(sendStunRequests)
)
}
this.audioSplitter.once('message', () => {
this.console.debug(`Audio stream latched for ${this.cameraName} on port: ${this.rtpOptions.audio.port}`)
})
this.videoSplitter.once('message', () => {
this.console.debug(`Video stream latched for ${this.cameraName} on port: ${this.rtpOptions.video.port}`)
})
return rtpDescription
} catch (e) {
this.callEnded(true)
throw e
}
}
static async reserveRtpRtcpPorts() {
const ports = await reservePorts({ count: 4, type: 'udp' })
return ports
}
private callEnded(sendBye: boolean) {
if (this.hasCallEnded) {
return
}
this.hasCallEnded = true
if (sendBye) {
this.sipCall.sendBye()
.then(() => {
// clean up
this.console.log("sip-session callEnded")
this.onCallEndedSubject.next(null)
this.sipCall.destroy()
this.videoSplitter.close()
this.audioSplitter.close()
this.audioRtcpSplitter.close()
this.videoRtcpSplitter.close()
this.unsubscribe()
this.console.log("sip-session callEnded: done")
})
.catch()
}
}
stop() {
this.callEnded(true)
}
}

View File

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