@koush/ring-client-api
Version:
Unofficial API for Ring doorbells, cameras, security alarm system and smart lighting
402 lines (401 loc) • 18.5 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.CameraSource = void 0;
const hap_1 = require("./hap");
const camera_utils_1 = require("@homebridge/camera-utils");
const util_1 = require("../api/util");
const operators_1 = require("rxjs/operators");
const rxjs_1 = require("rxjs");
const fs_1 = require("fs");
const util_2 = require("util");
const ffmpeg_1 = require("../api/ffmpeg");
const werift_1 = require("@koush/werift");
const readFileAsync = (0, util_2.promisify)(fs_1.readFile), cameraOfflinePath = require.resolve('../../media/camera-offline.jpg'), snapshotsBlockedPath = require.resolve('../../media/snapshots-blocked.jpg');
function getDurationSeconds(start) {
return (Date.now() - start) / 1000;
}
function getSessionConfig(srtpOptions) {
return {
keys: {
localMasterKey: srtpOptions.srtpKey,
localMasterSalt: srtpOptions.srtpSalt,
remoteMasterKey: srtpOptions.srtpKey,
remoteMasterSalt: srtpOptions.srtpSalt,
},
profile: 1,
};
}
class StreamingSession {
constructor(liveCall, prepareStreamRequest, ringCamera, start) {
this.liveCall = liveCall;
this.prepareStreamRequest = prepareStreamRequest;
this.ringCamera = ringCamera;
this.start = start;
this.audioSsrc = hap_1.hap.CameraController.generateSynchronisationSource();
this.videoSsrc = hap_1.hap.CameraController.generateSynchronisationSource();
this.audioSrtp = (0, camera_utils_1.generateSrtpOptions)();
this.videoSrtp = (0, camera_utils_1.generateSrtpOptions)();
this.audioSplitter = new camera_utils_1.RtpSplitter();
this.videoSplitter = new camera_utils_1.RtpSplitter();
this.libfdkAacInstalledPromise = (0, camera_utils_1.doesFfmpegSupportCodec)('libfdk_aac', (0, ffmpeg_1.getFfmpegPath)())
.then((supported) => {
if (!supported) {
(0, util_1.logError)('Streaming video only - found ffmpeg, but libfdk_aac is not installed. See https://github.com/dgreif/ring/wiki/FFmpeg for details.');
}
return supported;
})
.catch(() => {
(0, util_1.logError)('Streaming video only - ffmpeg was not found. See https://github.com/dgreif/ring/wiki/FFmpeg for details.');
return false;
});
const { targetAddress, video: { port: videoPort }, } = prepareStreamRequest,
// used to encrypt rtcp to HomeKit for keepalive
videoSrtcpSession = new werift_1.SrtcpSession(getSessionConfig(this.videoSrtp)), onReturnPacketReceived = new rxjs_1.Subject();
// Watch return packets to detect a dead stream from the HomeKit side
// This can happen if the user force-quits the Home app
this.videoSplitter.addMessageHandler(() => {
// return packet from HomeKit
onReturnPacketReceived.next(null);
return null;
});
this.audioSplitter.addMessageHandler(() => {
// return packet from HomeKit
onReturnPacketReceived.next(null);
return null;
});
liveCall.addSubscriptions((0, rxjs_1.merge)((0, rxjs_1.of)(true).pipe((0, operators_1.delay)(15000)), onReturnPacketReceived)
.pipe((0, operators_1.debounceTime)(5000))
.subscribe(() => {
(0, util_1.logInfo)(`Live stream for ${this.ringCamera.name} appears to be inactive. (${getDurationSeconds(start)}s)`);
liveCall.stop();
}));
// Periodically send a blank RTCP packet to the HomeKit video port
// Without this, HomeKit assumes the stream is dead after 30 second and sends a stop request
liveCall.addSubscriptions((0, rxjs_1.interval)(500).subscribe(() => {
const senderInfo = new werift_1.RtcpSenderInfo({
ntpTimestamp: BigInt(0),
packetCount: 0,
octetCount: 0,
rtpTimestamp: 0,
}), senderReport = new werift_1.RtcpSrPacket({
ssrc: this.videoSsrc,
senderInfo: senderInfo,
}), message = videoSrtcpSession.encrypt(senderReport.serialize());
this.videoSplitter
.send(message, {
port: videoPort,
address: targetAddress,
})
.catch(util_1.logError);
}));
}
activate(request) {
return __awaiter(this, void 0, void 0, function* () {
let sentVideo = false;
const { targetAddress, audio: { port: audioPort, srtp_key: remoteAudioSrtpKey, srtp_salt: remoteAudioSrtpSalt, }, video: { port: videoPort }, } = this.prepareStreamRequest,
// use to encrypt Ring video to HomeKit
videoSrtpSession = new werift_1.SrtpSession(getSessionConfig(this.videoSrtp));
// Set up packet forwarding for video stream
this.liveCall.addSubscriptions(this.liveCall.onVideoRtp.subscribe(({ header, payload }) => {
header.ssrc = this.videoSsrc;
header.payloadType = request.video.pt;
const encryptedPacket = videoSrtpSession.encrypt(payload, header);
if (!sentVideo) {
sentVideo = true;
(0, util_1.logInfo)(`Received stream data from ${this.ringCamera.name} (${getDurationSeconds(this.start)}s)`);
}
this.videoSplitter
.send(encryptedPacket, {
port: videoPort,
address: targetAddress,
})
.catch(util_1.logError);
}));
const shouldTranscodeAudio = yield this.libfdkAacInstalledPromise;
if (!shouldTranscodeAudio) {
return this.liveCall.activate();
}
const transcodingPromise = this.liveCall.startTranscoding({
input: ['-vn'],
audio: [
'-map',
'0:a',
// OPUS specific - it works, but audio is very choppy
// '-acodec',
// 'libopus',
// '-vbr',
// 'on',
// '-frame_duration',
// 20,
// '-application',
// 'lowdelay',
// AAC-eld specific
'-acodec',
'libfdk_aac',
'-profile:a',
'aac_eld',
// Shared options
'-flags',
'+global_header',
'-ac',
`${request.audio.channel}`,
'-ar',
`${request.audio.sample_rate}k`,
'-b:a',
`${request.audio.max_bit_rate}k`,
'-bufsize',
`${request.audio.max_bit_rate * 4}k`,
'-payload_type',
request.audio.pt,
'-ssrc',
this.audioSsrc,
'-f',
'rtp',
'-srtp_out_suite',
'AES_CM_128_HMAC_SHA1_80',
'-srtp_out_params',
(0, camera_utils_1.encodeSrtpOptions)(this.audioSrtp),
`srtp://${targetAddress}:${audioPort}?pkt_size=188`,
],
video: false,
output: [],
});
let cameraSpeakerActive = false;
// used to decrypt return audio from HomeKit to Ring
const remoteAudioSrtpOptions = {
srtpKey: remoteAudioSrtpKey,
srtpSalt: remoteAudioSrtpSalt,
}, audioSrtpSession = new werift_1.SrtpSession(getSessionConfig(remoteAudioSrtpOptions)), returnAudioTranscodedSplitter = new camera_utils_1.RtpSplitter(({ message }) => {
if (!cameraSpeakerActive) {
cameraSpeakerActive = true;
this.liveCall.activateCameraSpeaker().catch(util_1.logError);
}
// decrypt the message
try {
const rtp = werift_1.RtpPacket.deSerialize(message);
rtp.payload = audioSrtpSession.decrypt(rtp.payload);
// send to Ring - werift will handle encryption and other header params
this.liveCall.sendAudioPacket(rtp);
}
catch (_) {
// deSerialize will sometimes fail, but the errors can be ignored
}
return null;
}), returnAudioTranscoder = new camera_utils_1.ReturnAudioTranscoder({
prepareStreamRequest: this.prepareStreamRequest,
incomingAudioOptions: {
ssrc: this.audioSsrc,
rtcpPort: 0, // we don't care about rtcp for incoming audio
},
outputArgs: [
'-acodec',
'libopus',
'-flags',
'+global_header',
'-ac',
1,
'-ar',
'48k',
'-f',
'rtp',
`rtp://127.0.0.1:${yield returnAudioTranscodedSplitter.portPromise}`,
],
ffmpegPath: (0, ffmpeg_1.getFfmpegPath)(),
logger: {
info: util_1.logDebug,
error: util_1.logError,
},
logLabel: `Return Audio (${this.ringCamera.name})`,
returnAudioSplitter: this.audioSplitter,
});
this.liveCall.onCallEnded.pipe((0, operators_1.take)(1)).subscribe(() => {
returnAudioTranscoder.stop();
returnAudioTranscodedSplitter.close();
});
yield returnAudioTranscoder.start();
yield transcodingPromise;
});
}
stop() {
this.audioSplitter.close();
this.videoSplitter.close();
this.liveCall.stop();
}
}
class CameraSource {
constructor(ringCamera) {
this.ringCamera = ringCamera;
this.controller = new hap_1.hap.CameraController({
cameraStreamCount: 10,
delegate: this,
streamingOptions: {
supportedCryptoSuites: [0 /* AES_CM_128_HMAC_SHA1_80 */],
video: {
resolutions: [
[1280, 720, 30],
[1024, 768, 30],
[640, 480, 30],
[640, 360, 30],
[480, 360, 30],
[480, 270, 30],
[320, 240, 30],
[320, 240, 15],
[320, 180, 30],
],
codec: {
profiles: [0 /* BASELINE */],
levels: [0 /* LEVEL3_1 */],
},
},
audio: {
codecs: [
{
type: "AAC-eld" /* AAC_ELD */,
samplerate: 16 /* KHZ_16 */,
},
],
},
},
});
this.sessions = {};
}
loadSnapshot() {
return __awaiter(this, void 0, void 0, function* () {
// cache a promise of the snapshot load
// This prevents multiple concurrent requests for snapshot from pilling up and creating lots of logs
if (this.previousLoadSnapshotPromise) {
return this.previousLoadSnapshotPromise;
}
this.previousLoadSnapshotPromise = this.loadAndCacheSnapshot();
try {
yield this.previousLoadSnapshotPromise;
}
catch (_) {
// ignore errors
}
finally {
// clear so another request can be made
this.previousLoadSnapshotPromise = undefined;
}
});
}
loadAndCacheSnapshot() {
return __awaiter(this, void 0, void 0, function* () {
const start = Date.now();
(0, util_1.logDebug)(`Loading new snapshot into cache for ${this.ringCamera.name}`);
try {
const previousSnapshot = this.cachedSnapshot, newSnapshot = yield this.ringCamera.getSnapshot();
this.cachedSnapshot = newSnapshot;
if (previousSnapshot !== newSnapshot) {
// Keep the snapshots in cache 2 minutes longer than their lifetime
// This allows users on LTE with wired camera to get snapshots each 60 second pull even though the cached snapshot is out of date
setTimeout(() => {
if (this.cachedSnapshot === newSnapshot) {
this.cachedSnapshot = undefined;
}
}, this.ringCamera.snapshotLifeTime + 2 * 60 * 1000);
}
(0, util_1.logDebug)(`Snapshot cached for ${this.ringCamera.name} (${getDurationSeconds(start)}s)`);
}
catch (e) {
this.cachedSnapshot = undefined;
(0, util_1.logDebug)(`Failed to cache snapshot for ${this.ringCamera.name} (${getDurationSeconds(start)}s), The camera currently reports that it is ${this.ringCamera.isOffline ? 'offline' : 'online'}`);
}
});
}
getCurrentSnapshot() {
if (this.ringCamera.isOffline) {
return readFileAsync(cameraOfflinePath);
}
if (this.ringCamera.snapshotsAreBlocked) {
return readFileAsync(snapshotsBlockedPath);
}
(0, util_1.logDebug)(`${this.cachedSnapshot ? 'Used cached snapshot' : 'No snapshot cached'} for ${this.ringCamera.name}`);
if (!this.ringCamera.hasSnapshotWithinLifetime) {
this.loadSnapshot().catch(util_1.logError);
}
// may or may not have a snapshot cached
return this.cachedSnapshot;
}
handleSnapshotRequest(request, callback) {
return __awaiter(this, void 0, void 0, function* () {
try {
const snapshot = yield this.getCurrentSnapshot();
if (!snapshot) {
// return an error to prevent "empty image buffer" warnings
return callback(new Error('No Snapshot Cached'));
}
// Not currently resizing the image.
// HomeKit does a good job of resizing and doesn't seem to care if it's not right
callback(undefined, snapshot);
}
catch (e) {
(0, util_1.logError)(`Error fetching snapshot for ${this.ringCamera.name}`);
(0, util_1.logError)(e);
callback(e);
}
});
}
prepareStream(request, callback) {
return __awaiter(this, void 0, void 0, function* () {
const start = Date.now();
(0, util_1.logInfo)(`Preparing Live Stream for ${this.ringCamera.name}`);
try {
const liveCall = yield this.ringCamera.startLiveCall(), session = new StreamingSession(liveCall, request, this.ringCamera, start);
this.sessions[request.sessionID] = session;
(0, util_1.logInfo)(`Stream Prepared for ${this.ringCamera.name} (${getDurationSeconds(start)}s)`);
callback(undefined, {
// SOMEDAY: remove address as it is not needed after homebridge 1.1.3
address: yield (0, camera_utils_1.getDefaultIpAddress)(request.addressVersion === 'ipv6'),
audio: {
port: yield session.audioSplitter.portPromise,
ssrc: session.audioSsrc,
srtp_key: session.audioSrtp.srtpKey,
srtp_salt: session.audioSrtp.srtpSalt,
},
video: {
port: yield session.videoSplitter.portPromise,
ssrc: session.videoSsrc,
srtp_key: session.videoSrtp.srtpKey,
srtp_salt: session.videoSrtp.srtpSalt,
},
});
}
catch (e) {
(0, util_1.logError)(`Failed to prepare stream for ${this.ringCamera.name} (${getDurationSeconds(start)}s)`);
(0, util_1.logError)(e);
callback(e);
}
});
}
handleStreamRequest(request, callback) {
return __awaiter(this, void 0, void 0, function* () {
const sessionID = request.sessionID, session = this.sessions[sessionID], requestType = request.type;
if (!session) {
callback(new Error('Cannot find session for stream ' + sessionID));
return;
}
if (requestType === 'start') {
(0, util_1.logInfo)(`Activating stream for ${this.ringCamera.name} (${getDurationSeconds(session.start)}s)`);
yield session.activate(request);
(0, util_1.logInfo)(`Streaming active for ${this.ringCamera.name} (${getDurationSeconds(session.start)}s)`);
}
else if (requestType === 'stop') {
(0, util_1.logInfo)(`Stopped Live Stream for ${this.ringCamera.name}`);
session.stop();
delete this.sessions[sessionID];
}
callback();
});
}
}
exports.CameraSource = CameraSource;