homebridge-ring
Version:
Homebridge plugin for Ring doorbells, cameras, security alarm system and smart lighting
410 lines (409 loc) • 17.5 kB
JavaScript
import { hap } from "./hap.js";
import { generateSrtpOptions, ReturnAudioTranscoder, RtpSplitter, } from '@homebridge/camera-utils';
import { logDebug, logError, logInfo } from 'ring-client-api/util';
import { debounceTime, delay, take } from 'rxjs/operators';
import { interval, merge, of, Subject } from 'rxjs';
import { readFile } from 'fs';
import { promisify } from 'util';
import { getFfmpegPath } from 'ring-client-api/ffmpeg';
import { RtcpSenderInfo, RtcpSrPacket, RtpPacket, SrtpSession, SrtcpSession, } from 'werift';
import path from 'node:path';
const __dirname = new URL('.', import.meta.url).pathname, mediaDirectory = path.join(__dirname.replace(/\/lib\/?$/, ''), 'media'), readFileAsync = promisify(readFile), cameraOfflinePath = path.join(mediaDirectory, 'camera-offline.jpg'), snapshotsBlockedPath = path.join(mediaDirectory, '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 StreamingSessionWrapper {
audioSsrc = hap.CameraController.generateSynchronisationSource();
videoSsrc = hap.CameraController.generateSynchronisationSource();
audioSrtp = generateSrtpOptions();
videoSrtp = generateSrtpOptions();
audioSplitter = new RtpSplitter();
videoSplitter = new RtpSplitter();
transcodedAudioSplitter = new RtpSplitter();
streamingSession;
prepareStreamRequest;
ringCamera;
start;
constructor(streamingSession, prepareStreamRequest, ringCamera, start) {
this.streamingSession = streamingSession;
this.prepareStreamRequest = prepareStreamRequest;
this.ringCamera = ringCamera;
this.start = start;
const { targetAddress, video: { port: videoPort }, } = prepareStreamRequest,
// used to encrypt rtcp to HomeKit for keepalive
videoSrtcpSession = new SrtcpSession(getSessionConfig(this.videoSrtp)), onReturnPacketReceived = new 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;
});
streamingSession.addSubscriptions(merge(of(true).pipe(delay(15000)), onReturnPacketReceived)
.pipe(debounceTime(5000))
.subscribe(() => {
logInfo(`Live stream for ${this.ringCamera.name} appears to be inactive. (${getDurationSeconds(start)}s)`);
streamingSession.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
streamingSession.addSubscriptions(interval(500).subscribe(() => {
const senderInfo = new RtcpSenderInfo({
ntpTimestamp: BigInt(0),
packetCount: 0,
octetCount: 0,
rtpTimestamp: 0,
}), senderReport = new RtcpSrPacket({
ssrc: this.videoSsrc,
senderInfo: senderInfo,
}), message = videoSrtcpSession.encrypt(senderReport.serialize());
this.videoSplitter
.send(message, {
port: videoPort,
address: targetAddress,
})
.catch(logError);
}));
}
listenForAudioPackets(startStreamRequest) {
const { targetAddress, audio: { port: audioPort }, } = this.prepareStreamRequest, timestampIncrement = startStreamRequest.audio.sample_rate *
startStreamRequest.audio.packet_time, audioSrtpSession = new SrtpSession(getSessionConfig(this.audioSrtp));
let runningTimestamp;
this.transcodedAudioSplitter.addMessageHandler(({ message }) => {
const rtp = RtpPacket.deSerialize(message);
// For some reason HAP uses RFC 3550 timestamps instead of following RTP Paylod
// Format for Opus Speech and Audio Codec from RFC 7587 like everyone else.
// This calculates and replaces the timestamps before forwarding to Homekit.
if (!runningTimestamp) {
runningTimestamp = rtp.header.timestamp;
}
rtp.header.timestamp = runningTimestamp % 0xffffffff;
runningTimestamp += timestampIncrement;
// encrypt the packet
const encryptedPacket = audioSrtpSession.encrypt(rtp.payload, rtp.header);
// send the encrypted packet to HomeKit
this.audioSplitter
.send(encryptedPacket, {
port: audioPort,
address: targetAddress,
})
.catch(logError);
return null;
});
}
async activate(request) {
let sentVideo = false;
const { targetAddress, video: { port: videoPort }, } = this.prepareStreamRequest,
// use to encrypt Ring video to HomeKit
videoSrtpSession = new SrtpSession(getSessionConfig(this.videoSrtp));
// Set up packet forwarding for video stream
this.streamingSession.addSubscriptions(this.streamingSession.onVideoRtp.subscribe(({ header, payload }) => {
header.ssrc = this.videoSsrc;
header.payloadType = request.video.pt;
const encryptedPacket = videoSrtpSession.encrypt(payload, header);
if (!sentVideo) {
sentVideo = true;
logInfo(`Received stream data from ${this.ringCamera.name} (${getDurationSeconds(this.start)}s)`);
}
this.videoSplitter
.send(encryptedPacket, {
port: videoPort,
address: targetAddress,
})
.catch(logError);
}));
const transcodingPromise = this.streamingSession.startTranscoding({
input: ['-vn'],
audio: [
'-acodec',
'libopus',
'-application',
'lowdelay',
'-frame_duration',
request.audio.packet_time.toString(),
'-flags',
'+global_header',
'-ar',
`${request.audio.sample_rate}k`,
'-b:a',
`${request.audio.max_bit_rate}k`,
'-bufsize',
`${request.audio.max_bit_rate * 4}k`,
'-ac',
`${request.audio.channel}`,
'-payload_type',
request.audio.pt,
'-ssrc',
this.audioSsrc,
'-f',
'rtp',
`rtp://127.0.0.1:${await this.transcodedAudioSplitter.portPromise}`,
],
video: false,
output: [],
});
let cameraSpeakerActive = false;
// used to send return audio from HomeKit to Ring
const returnAudioTranscodedSplitter = new RtpSplitter(({ message }) => {
if (!cameraSpeakerActive) {
cameraSpeakerActive = true;
this.streamingSession.activateCameraSpeaker();
}
// deserialize and send to Ring - werift will handle encryption and other header params
try {
const rtp = RtpPacket.deSerialize(message);
this.streamingSession.sendAudioPacket(rtp);
}
catch {
// deSerialize will sometimes fail, but the errors can be ignored
}
return null;
}), returnAudioTranscoder = new ReturnAudioTranscoder({
prepareStreamRequest: this.prepareStreamRequest,
startStreamRequest: request,
incomingAudioOptions: {
ssrc: this.audioSsrc,
rtcpPort: 0, // we don't care about rtcp for incoming audio
},
outputArgs: [
'-acodec',
'libopus',
'-application',
'lowdelay',
'-frame_duration',
'60',
'-flags',
'+global_header',
'-ar',
'48k',
'-b:a',
'48k',
'-bufsize',
'192k',
'-ac',
'2',
'-f',
'rtp',
`rtp://127.0.0.1:${await returnAudioTranscodedSplitter.portPromise}`,
],
ffmpegPath: getFfmpegPath(),
logger: {
info: logDebug,
error: logError,
},
logLabel: `Return Audio (${this.ringCamera.name})`,
returnAudioSplitter: this.audioSplitter,
});
this.streamingSession.onCallEnded.pipe(take(1)).subscribe(() => {
returnAudioTranscoder.stop();
returnAudioTranscodedSplitter.close();
});
this.listenForAudioPackets(request);
await returnAudioTranscoder.start();
await transcodingPromise;
}
stop() {
this.audioSplitter.close();
this.transcodedAudioSplitter.close();
this.videoSplitter.close();
this.streamingSession.stop();
}
}
export class CameraSource {
controller;
sessions = {};
cachedSnapshot;
ringCamera;
constructor(ringCamera) {
this.ringCamera = ringCamera;
this.controller = new hap.CameraController({
cameraStreamCount: 10,
delegate: this,
streamingOptions: {
supportedCryptoSuites: [0 /* SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80 */],
video: {
resolutions: [
[1920, 1024, 30],
[1280, 720, 30],
[1024, 768, 30],
[640, 480, 30],
[640, 360, 30],
[480, 360, 30],
[480, 270, 30],
[320, 240, 30],
[320, 240, 15], // Apple Watch requires this configuration
[320, 180, 30],
],
codec: {
profiles: [0 /* H264Profile.BASELINE */],
levels: [0 /* H264Level.LEVEL3_1 */],
},
},
audio: {
codecs: [
{
type: "OPUS" /* AudioStreamingCodecType.OPUS */,
// required by watch
samplerate: 8 /* AudioStreamingSamplerate.KHZ_8 */,
},
{
type: "OPUS" /* AudioStreamingCodecType.OPUS */,
samplerate: 16 /* AudioStreamingSamplerate.KHZ_16 */,
},
{
type: "OPUS" /* AudioStreamingCodecType.OPUS */,
samplerate: 24 /* AudioStreamingSamplerate.KHZ_24 */,
},
],
},
},
});
}
previousLoadSnapshotPromise;
async loadSnapshot(imageUuid) {
// 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(imageUuid);
try {
await this.previousLoadSnapshotPromise;
}
catch {
// ignore errors
}
finally {
// clear so another request can be made
this.previousLoadSnapshotPromise = undefined;
}
}
fn = 1;
async loadAndCacheSnapshot(imageUuid) {
const start = Date.now();
logDebug(`Loading new snapshot into cache for ${this.ringCamera.name}${imageUuid ? ' by uuid' : ''}`);
try {
const previousSnapshot = this.cachedSnapshot, newSnapshot = await this.ringCamera.getSnapshot({ uuid: imageUuid });
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);
}
logDebug(`Snapshot cached for ${this.ringCamera.name}${imageUuid ? ' by uuid' : ''} (${getDurationSeconds(start)}s)`);
}
catch (e) {
this.cachedSnapshot = undefined;
logDebug(`Failed to cache snapshot for ${this.ringCamera.name} (${getDurationSeconds(start)}s), The camera currently reports that it is ${this.ringCamera.isOffline ? 'offline' : 'online'}`);
// log additioanl snapshot error message if one is present
if (e.message.includes('Snapshot')) {
logDebug(e.message);
}
}
}
getCurrentSnapshot() {
if (this.ringCamera.isOffline) {
return readFileAsync(cameraOfflinePath);
}
if (this.ringCamera.snapshotsAreBlocked) {
return readFileAsync(snapshotsBlockedPath);
}
logDebug(`${this.cachedSnapshot ? 'Used cached snapshot' : 'No snapshot cached'} for ${this.ringCamera.name}`);
if (!this.ringCamera.hasSnapshotWithinLifetime) {
this.loadSnapshot().catch(logError);
}
// may or may not have a snapshot cached
return this.cachedSnapshot;
}
async handleSnapshotRequest(request, callback) {
try {
const snapshot = await 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) {
logError(`Error fetching snapshot for ${this.ringCamera.name}`);
logError(e);
callback(e);
}
}
async prepareStream(request, callback) {
const start = Date.now();
logInfo(`Preparing Live Stream for ${this.ringCamera.name}`);
try {
const liveCall = await this.ringCamera.startLiveCall(), session = new StreamingSessionWrapper(liveCall, request, this.ringCamera, start);
this.sessions[request.sessionID] = session;
logInfo(`Stream Prepared for ${this.ringCamera.name} (${getDurationSeconds(start)}s)`);
callback(undefined, {
audio: {
port: await session.audioSplitter.portPromise,
ssrc: session.audioSsrc,
srtp_key: session.audioSrtp.srtpKey,
srtp_salt: session.audioSrtp.srtpSalt,
},
video: {
port: await session.videoSplitter.portPromise,
ssrc: session.videoSsrc,
srtp_key: session.videoSrtp.srtpKey,
srtp_salt: session.videoSrtp.srtpSalt,
},
});
}
catch (e) {
logError(`Failed to prepare stream for ${this.ringCamera.name} (${getDurationSeconds(start)}s)`);
logError(e);
callback(e);
}
}
async handleStreamRequest(request, callback) {
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') {
logInfo(`Activating stream for ${this.ringCamera.name} (${getDurationSeconds(session.start)}s)`);
try {
await session.activate(request);
}
catch (e) {
logError('Failed to activate stream');
logError(e);
callback(new Error('Failed to activate stream'));
return;
}
logInfo(`Streaming active for ${this.ringCamera.name} (${getDurationSeconds(session.start)}s)`);
}
else if (requestType === 'stop') {
logInfo(`Stopped Live Stream for ${this.ringCamera.name}`);
session.stop();
delete this.sessions[sessionID];
}
callback();
}
}