UNPKG

@koush/ring-client-api

Version:

Unofficial API for Ring doorbells, cameras, security alarm system and smart lighting

402 lines (401 loc) 18.5 kB
"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;