mirror of
https://github.com/koush/scrypted.git
synced 2026-02-03 06:03:27 +00:00
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:
@@ -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
4
plugins/bticino/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.DS_Store
|
||||
out/
|
||||
node_modules/
|
||||
dist/
|
||||
11
plugins/bticino/.npmignore
Normal file
11
plugins/bticino/.npmignore
Normal 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
23
plugins/bticino/.vscode/launch.json
vendored
Normal 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
4
plugins/bticino/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
{
|
||||
"scrypted.debugHost": "127.0.0.1",
|
||||
}
|
||||
20
plugins/bticino/.vscode/tasks.json
vendored
Normal file
20
plugins/bticino/.vscode/tasks.json
vendored
Normal 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
132
plugins/bticino/README.md
Normal 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 don’t 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
2654
plugins/bticino/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
plugins/bticino/package.json
Normal file
50
plugins/bticino/package.json
Normal 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
367
plugins/bticino/src/main.ts
Normal 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();
|
||||
380
plugins/bticino/src/sip-call.ts
Normal file
380
plugins/bticino/src/sip-call.ts
Normal 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")
|
||||
}
|
||||
}
|
||||
180
plugins/bticino/src/sip-session.ts
Normal file
180
plugins/bticino/src/sip-session.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
11
plugins/bticino/tsconfig.json
Normal file
11
plugins/bticino/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "Node16",
|
||||
"target": "esnext",
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user