ring-client-api
Version:
Unofficial API for Ring doorbells, cameras, security alarm system and smart lighting
182 lines (181 loc) • 6.66 kB
JavaScript
import { RtpPacket } from 'werift';
import { FfmpegProcess, reservePorts, RtpSplitter, } from '@homebridge/camera-utils';
import { firstValueFrom, ReplaySubject, Subject } from 'rxjs';
import { getFfmpegPath } from "../ffmpeg.js";
import { logDebug, logError } from "../util.js";
import { concatMap, map, mergeWith, take } from 'rxjs/operators';
import { Subscribed } from "../subscribed.js";
function getCleanSdp(sdp, includeVideo) {
return sdp
.split('\nm=')
.slice(1)
.map((section) => 'm=' + section)
.filter((section) => includeVideo || !section.startsWith('m=video'))
.join('\n');
}
export class StreamingSession extends Subscribed {
onCallEnded = new ReplaySubject(1);
onUsingOpus = new ReplaySubject(1);
onVideoRtp = new Subject();
onAudioRtp = new Subject();
audioSplitter = new RtpSplitter();
videoSplitter = new RtpSplitter();
returnAudioSplitter = new RtpSplitter();
camera;
connection;
constructor(camera, connection) {
super();
this.camera = camera;
this.connection = connection;
this.bindToConnection(connection);
}
bindToConnection(connection) {
this.addSubscriptions(connection.onAudioRtp.subscribe(this.onAudioRtp), connection.onVideoRtp.subscribe(this.onVideoRtp), connection.onCallAnswered.subscribe((sdp) => {
this.onUsingOpus.next(sdp.toLocaleLowerCase().includes(' opus/'));
}), connection.onCallEnded.subscribe(() => this.callEnded()));
}
/**
* @deprecated
* activate will be removed in the future. Please use requestKeyFrame if you want to explicitly request an initial key frame
*/
activate() {
this.requestKeyFrame();
}
cameraSpeakerActivated = false;
activateCameraSpeaker() {
if (this.cameraSpeakerActivated || this.hasEnded) {
return;
}
this.cameraSpeakerActivated = true;
this.connection.activateCameraSpeaker();
}
async reservePort(bufferPorts = 0) {
const ports = await reservePorts({ count: bufferPorts + 1 });
return ports[0];
}
get isUsingOpus() {
return firstValueFrom(this.onUsingOpus.pipe(mergeWith(this.connection.onError.pipe(map((e) => {
throw e;
})))));
}
async startTranscoding(ffmpegOptions) {
if (this.hasEnded) {
return;
}
const videoPort = await this.reservePort(1), audioPort = await this.reservePort(1), transcodeVideoStream = ffmpegOptions.video !== false, ringSdp = await Promise.race([
firstValueFrom(this.connection.onCallAnswered),
firstValueFrom(this.onCallEnded),
]);
if (!ringSdp) {
logDebug('Call ended before answered');
return;
}
const usingOpus = await this.isUsingOpus, ffmpegInputArguments = [
'-hide_banner',
'-protocol_whitelist',
'pipe,udp,rtp,file,crypto',
// Ring will answer with either opus or pcmu
...(usingOpus ? ['-acodec', 'libopus'] : []),
'-f',
'sdp',
...(ffmpegOptions.input || []),
'-i',
'pipe:',
], inputSdp = getCleanSdp(ringSdp, transcodeVideoStream)
.replace(/m=audio \d+/, `m=audio ${audioPort}`)
.replace(/m=video \d+/, `m=video ${videoPort}`), ff = new FfmpegProcess({
ffmpegArgs: ffmpegInputArguments.concat(...(ffmpegOptions.audio || ['-acodec', 'aac']), ...(transcodeVideoStream
? ffmpegOptions.video || ['-vcodec', 'copy']
: []), ...(ffmpegOptions.output || [])),
ffmpegPath: getFfmpegPath(),
stdoutCallback: ffmpegOptions.stdoutCallback,
exitCallback: () => this.callEnded(),
logLabel: `From Ring (${this.camera.name})`,
logger: {
error: logError,
info: logDebug,
},
});
this.addSubscriptions(this.onAudioRtp
.pipe(concatMap((rtp) => {
return this.audioSplitter.send(rtp.serialize(), {
port: audioPort,
});
}))
.subscribe());
if (transcodeVideoStream) {
this.addSubscriptions(this.onVideoRtp
.pipe(concatMap((rtp) => {
return this.videoSplitter.send(rtp.serialize(), {
port: videoPort,
});
}))
.subscribe());
}
this.onCallEnded.pipe(take(1)).subscribe(() => ff.stop());
ff.writeStdin(inputSdp);
// Request a key frame now that ffmpeg is ready to receive
this.requestKeyFrame();
}
async transcodeReturnAudio(ffmpegOptions) {
if (this.hasEnded) {
return;
}
const audioOutForwarder = new RtpSplitter(({ message }) => {
const rtp = RtpPacket.deSerialize(message);
this.connection.sendAudioPacket(rtp);
return null;
}), usingOpus = await this.isUsingOpus, ff = new FfmpegProcess({
ffmpegArgs: [
'-hide_banner',
'-protocol_whitelist',
'pipe,udp,rtp,file,crypto',
'-re',
'-i',
...ffmpegOptions.input,
'-acodec',
...(usingOpus
? ['libopus', '-ac', 2, '-ar', '48k']
: ['pcm_mulaw', '-ac', 1, '-ar', '8k']),
'-flags',
'+global_header',
'-f',
'rtp',
`rtp://127.0.0.1:${await audioOutForwarder.portPromise}`,
],
ffmpegPath: getFfmpegPath(),
exitCallback: () => this.callEnded(),
logLabel: `Return Audio (${this.camera.name})`,
logger: {
error: logError,
info: logDebug,
},
});
this.onCallEnded.pipe(take(1)).subscribe(() => ff.stop());
}
hasEnded = false;
callEnded() {
if (this.hasEnded) {
return;
}
this.hasEnded = true;
this.unsubscribe();
this.onCallEnded.next();
this.connection.stop();
this.audioSplitter.close();
this.videoSplitter.close();
this.returnAudioSplitter.close();
}
stop() {
this.callEnded();
}
sendAudioPacket(rtp) {
if (this.hasEnded) {
return;
}
this.connection.sendAudioPacket(rtp);
}
requestKeyFrame() {
this.connection.requestKeyFrame();
}
}