@koush/ring-client-api
Version:
Unofficial API for Ring doorbells, cameras, security alarm system and smart lighting
217 lines (216 loc) • 9.9 kB
JavaScript
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.SipSession = void 0;
const rxjs_1 = require("rxjs");
const rtp_utils_1 = require("./rtp-utils");
const camera_utils_1 = require("@homebridge/camera-utils");
const sip_call_1 = require("./sip-call");
const subscribed_1 = require("./subscribed");
const util_1 = require("./util");
const ffmpeg_1 = require("./ffmpeg");
const operators_1 = require("rxjs/operators");
class SipSession extends subscribed_1.Subscribed {
constructor(sipOptions, rtpOptions, audioSplitter, audioRtcpSplitter, videoSplitter, videoRtcpSplitter, tlsPort, camera) {
super();
this.sipOptions = sipOptions;
this.rtpOptions = rtpOptions;
this.audioSplitter = audioSplitter;
this.audioRtcpSplitter = audioRtcpSplitter;
this.videoSplitter = videoSplitter;
this.videoRtcpSplitter = videoRtcpSplitter;
this.tlsPort = tlsPort;
this.camera = camera;
this.hasStarted = false;
this.hasCallEnded = false;
this.onCallEndedSubject = new rxjs_1.ReplaySubject(1);
this.onCallEnded = this.onCallEndedSubject.asObservable();
this.sipCall = this.createSipCall(this.sipOptions);
}
createSipCall(sipOptions) {
if (this.sipCall) {
this.sipCall.destroy();
}
const call = (this.sipCall = new sip_call_1.SipCall(sipOptions, this.rtpOptions, this.tlsPort));
this.addSubscriptions(call.onEndedByRemote.subscribe(() => this.callEnded(false)));
return this.sipCall;
}
start(ffmpegOptions) {
return __awaiter(this, void 0, void 0, function* () {
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 videoPort = yield this.reservePort(1), audioPort = yield this.reservePort(1), rtpDescription = yield this.sipCall.invite(), sendStunRequests = () => {
(0, rtp_utils_1.sendStunBindingRequest)({
rtpSplitter: this.audioSplitter,
rtcpSplitter: this.audioRtcpSplitter,
rtpDescription,
localUfrag: this.sipCall.audioUfrag,
type: 'audio',
});
(0, rtp_utils_1.sendStunBindingRequest)({
rtpSplitter: this.videoSplitter,
rtcpSplitter: this.videoRtcpSplitter,
rtpDescription,
localUfrag: this.sipCall.videoUfrag,
type: 'video',
});
};
if (ffmpegOptions) {
this.startTranscoder(ffmpegOptions, rtpDescription, audioPort, videoPort);
}
// 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
(0, util_1.logDebug)(`Connecting to ${this.camera.name} using ICE`);
(0, rtp_utils_1.createStunResponder)(this.audioSplitter);
(0, rtp_utils_1.createStunResponder)(this.videoSplitter);
sendStunRequests();
}
else {
// ICE is not supported, use stun as keep alive
(0, util_1.logDebug)(`Connecting to ${this.camera.name} using STUN`);
this.addSubscriptions(
// hole punch every .5 seconds to keep stream alive and port open (matches behavior from Ring app)
(0, rxjs_1.timer)(0, 500).subscribe(sendStunRequests));
}
this.addSubscriptions(this.audioSplitter.onMessage.pipe((0, operators_1.take)(1)).subscribe(() => {
(0, util_1.logDebug)(`Audio stream latched for ${this.camera.name}`);
}), this.videoSplitter.onMessage.pipe((0, operators_1.take)(1)).subscribe(() => {
(0, util_1.logDebug)(`Video stream latched for ${this.camera.name}`);
}));
return rtpDescription;
}
catch (e) {
if (e === sip_call_1.expiredDingError) {
const sipOptions = yield this.camera.getUpdatedSipOptions(this.sipOptions.dingId);
this.createSipCall(sipOptions);
this.hasStarted = false;
return this.start(ffmpegOptions);
}
this.callEnded(true);
throw e;
}
});
}
prepareTranscoder(transcodeVideoStream, ffmpegInputOptions, remoteRtpOptions, audioPort, videoPort, sdpInput) {
const ffmpegInputArguments = [
'-hide_banner',
'-protocol_whitelist',
'pipe,udp,rtp,file,crypto',
'-f',
'sdp',
...(ffmpegInputOptions || []),
'-i',
sdpInput,
], inputSdpLines = [
'v=0',
'o=105202070 3747 461 IN IP4 127.0.0.1',
's=Talk',
'c=IN IP4 127.0.0.1',
'b=AS:380',
't=0 0',
'a=rtcp-xr:rcvr-rtt=all:10000 stat-summary=loss,dup,jitt,TTL voip-metrics',
`m=audio ${audioPort} RTP/SAVP 0 101`,
'a=rtpmap:0 PCMU/8000',
(0, camera_utils_1.createCryptoLine)(remoteRtpOptions.audio),
'a=rtcp-mux',
];
if (transcodeVideoStream) {
inputSdpLines.push(`m=video ${videoPort} RTP/SAVP 99`, 'a=rtpmap:99 H264/90000', (0, camera_utils_1.createCryptoLine)(remoteRtpOptions.video), 'a=rtcp-mux');
let haveReceivedStreamPacket = false;
this.videoSplitter.addMessageHandler(({ isRtpMessage, message }) => {
if ((0, rtp_utils_1.isStunMessage)(message)) {
return null;
}
if (!haveReceivedStreamPacket) {
this.sipCall.requestKeyFrame().catch(rxjs_1.noop);
haveReceivedStreamPacket = true;
}
return {
port: isRtpMessage ? videoPort : videoPort + 1,
};
});
}
this.audioSplitter.addMessageHandler(({ isRtpMessage, message }) => {
if ((0, rtp_utils_1.isStunMessage)(message)) {
return null;
}
return {
port: isRtpMessage ? audioPort : audioPort + 1,
};
});
return {
ffmpegInputArguments,
inputSdpLines,
};
}
startTranscoder(ffmpegOptions, remoteRtpOptions, audioPort, videoPort) {
const transcodeVideoStream = ffmpegOptions.video !== false, { ffmpegInputArguments, inputSdpLines } = this.prepareTranscoder(transcodeVideoStream, ffmpegOptions.input, remoteRtpOptions, audioPort, videoPort, 'pipe:'), ff = new camera_utils_1.FfmpegProcess({
ffmpegArgs: ffmpegInputArguments.concat(...(ffmpegOptions.audio || ['-acodec', 'aac']), ...(transcodeVideoStream
? ffmpegOptions.video || ['-vcodec', 'copy']
: []), ...(ffmpegOptions.output || [])),
ffmpegPath: (0, ffmpeg_1.getFfmpegPath)(),
exitCallback: () => this.callEnded(true),
logLabel: `From Ring (${this.camera.name})`,
logger: {
error: util_1.logError,
info: util_1.logDebug,
},
});
this.onCallEnded.subscribe(() => ff.stop());
ff.writeStdin(inputSdpLines.filter((x) => Boolean(x)).join('\n'));
}
reservePort(bufferPorts = 0) {
return __awaiter(this, void 0, void 0, function* () {
const ports = yield (0, camera_utils_1.reservePorts)({ count: bufferPorts + 1 });
return ports[0];
});
}
requestKeyFrame() {
return this.sipCall.requestKeyFrame();
}
activateCameraSpeaker() {
return this.sipCall.activateCameraSpeaker();
}
callEnded(sendBye) {
if (this.hasCallEnded) {
return;
}
this.hasCallEnded = true;
if (sendBye) {
this.sipCall.sendBye().catch(util_1.logError);
}
// clean up
this.onCallEndedSubject.next(null);
this.sipCall.destroy();
this.videoSplitter.close();
this.audioSplitter.close();
this.unsubscribe();
}
stop() {
this.callEnded(true);
}
}
exports.SipSession = SipSession;