homebridge-camera-ui
Version:
User Interface for RTSP capable cameras.
596 lines (489 loc) • 18.6 kB
JavaScript
/* eslint-disable quotes */
'use-strict';
const logger = require('../../services/logger/logger.service');
const sessions = require('../../services/sessions/sessions.service');
const createSocket = require('dgram').createSocket;
const getPort = require('get-port');
const spawn = require('child_process').spawn;
const FfmpegProcess = require('../services/ffmpeg.service');
class Camera {
constructor(api, accessory, videoProcessor) {
this.api = api;
this.hap = api.hap;
this.accessory = accessory;
this.videoProcessor = videoProcessor;
this.unbridge = accessory.context.config.unbridge;
this.videoConfig = accessory.context.config.videoConfig;
this.services = [];
this.streamControllers = [];
this.pendingSessions = new Map();
this.ongoingSessions = new Map();
this.timeouts = new Map();
this.api.on('shutdown', () => {
for (const session in this.ongoingSessions) {
this.stopStream(session);
}
});
const options = {
cameraStreamCount: this.videoConfig.maxStreams || 2, // HomeKit requires at least 2 streams, but 1 is also just fine
delegate: this,
streamingOptions: {
supportedCryptoSuites: [this.hap.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80],
video: {
resolutions: [
[320, 180, 30],
[320, 240, 15], // Apple Watch requires this configuration
[320, 240, 30],
[480, 270, 30],
[480, 360, 30],
[640, 360, 30],
[640, 480, 30],
[1280, 720, 30],
[1280, 960, 30],
[1920, 1080, 30],
[1600, 1200, 30],
],
codec: {
profiles: [this.hap.H264Profile.BASELINE, this.hap.H264Profile.MAIN, this.hap.H264Profile.HIGH],
levels: [this.hap.H264Level.LEVEL3_1, this.hap.H264Level.LEVEL3_2, this.hap.H264Level.LEVEL4_0],
},
},
audio: {
twoWayAudio: !!this.videoConfig.returnAudioTarget,
codecs: [
{
type: this.hap.AudioStreamingCodecType.AAC_ELD, //'AAC-eld'
samplerate: this.hap.AudioStreamingSamplerate.KHZ_16, //16
/*type: AudioStreamingCodecType.OPUS,
samplerate: AudioStreamingSamplerate.KHZ_24*/
},
],
},
},
};
this.controller = new this.hap.CameraController(options);
}
determineResolution(request, isSnapshot) {
const resultInfo = {
width: request.width,
height: request.height,
};
if (!isSnapshot) {
if (
this.videoConfig.maxWidth !== undefined &&
(this.videoConfig.forceMax || request.width > this.videoConfig.maxWidth)
) {
resultInfo.width = this.videoConfig.maxWidth;
}
if (
this.videoConfig.maxHeight !== undefined &&
(this.videoConfig.forceMax || request.height > this.videoConfig.maxHeight)
) {
resultInfo.height = this.videoConfig.maxHeight;
}
}
let filters = this.videoConfig.videoFilter ? this.videoConfig.videoFilter.split(',') : [];
const noneFilter = filters.indexOf('none');
if (noneFilter >= 0) {
filters.splice(noneFilter, 1);
}
resultInfo.snapFilter = filters.join(',');
if (noneFilter < 0 && (resultInfo.width > 0 || resultInfo.height > 0)) {
resultInfo.resizeFilter =
'scale=' +
(resultInfo.width > 0 ? "'min(" + resultInfo.width + ",iw)'" : 'iw') +
':' +
(resultInfo.height > 0 ? "'min(" + resultInfo.height + ",ih)'" : 'ih') +
':force_original_aspect_ratio=decrease';
filters.push(resultInfo.resizeFilter, 'scale=trunc(iw/2)*2:trunc(ih/2)*2'); // Force to fit encoder restrictions
}
if (filters.length > 0) {
resultInfo.videoFilter = filters.join(',');
}
return resultInfo;
}
fetchSnapshot(snapFilter) {
this.snapshotPromise = new Promise((resolve, reject) => {
const startTime = Date.now();
const ffmpegArguments =
(this.videoConfig.stillImageSource || this.videoConfig.source) + // Still
' -frames:v 1' +
(snapFilter ? ' -filter:v ' + snapFilter : '') +
' -f image2 -' +
' -hide_banner' +
' -loglevel error';
logger.debug(`Snapshot command: ${this.videoProcessor} ${ffmpegArguments}`, this.accessory.displayName);
const ffmpeg = spawn(this.videoProcessor, ffmpegArguments.split(/\s+/), {
env: process.env,
});
let snapshotBuffer = Buffer.alloc(0);
ffmpeg.stdout.on('data', (data) => {
snapshotBuffer = Buffer.concat([snapshotBuffer, data]);
});
ffmpeg.on('error', (error) => {
reject(`FFmpeg process creation failed: ${error.message}`);
});
ffmpeg.stderr.on('data', (data) => {
for (const line of data.toString().split('\n')) {
if (line.length > 0) {
logger.error(line, `${this.accessory.displayName}] [Snapshot`);
}
}
});
ffmpeg.on('close', () => {
if (snapshotBuffer.length > 0) {
resolve(snapshotBuffer);
} else {
reject('Failed to fetch snapshot.');
}
setTimeout(() => {
this.snapshotPromise = undefined;
}, 3 * 1000); // Expire cached snapshot after 3 seconds
const runtime = (Date.now() - startTime) / 1000;
let message = `Fetching snapshot took ${runtime} seconds.`;
if (runtime < 5) {
logger.debug(message, this.accessory.displayName);
} else {
if (!this.unbridge) {
message += ' It is highly recommended you switch to unbridge mode.';
}
if (runtime < 22) {
logger.warn(message, this.accessory.displayName);
} else {
message += ' The request has timed out and the snapshot has not been refreshed in HomeKit.';
logger.error(message, this.accessory.displayName);
}
}
});
});
return this.snapshotPromise;
}
resizeSnapshot(snapshot, resizeFilter) {
return new Promise((resolve, reject) => {
const ffmpegArguments =
'-i pipe:' + // Resize
' -frames:v 1' +
(resizeFilter ? ' -filter:v ' + resizeFilter : '') +
' -f image2 -';
logger.debug(`Resize command: ${this.videoProcessor} ${ffmpegArguments}`, this.accessory.displayName);
const ffmpeg = spawn(this.videoProcessor, ffmpegArguments.split(/\s+/), {
env: process.env,
});
let resizeBuffer = Buffer.alloc(0);
ffmpeg.stdout.on('data', (data) => {
resizeBuffer = Buffer.concat([resizeBuffer, data]);
});
ffmpeg.on('error', (error) => {
reject(`FFmpeg process creation failed: ${error.message}`);
});
ffmpeg.on('close', () => {
resolve(resizeBuffer);
});
ffmpeg.stdin.end(snapshot);
});
}
async handleSnapshotRequest(request, callback) {
const resolution = this.determineResolution(request, true);
try {
const cachedSnapshot = !!this.snapshotPromise;
logger.debug(`Snapshot requested: ${request.width} x ${request.height}`, this.accessory.displayName);
const snapshot = await (this.snapshotPromise || this.fetchSnapshot(resolution.snapFilter));
logger.debug(
`Sending snapshot:
${resolution.width > 0 ? resolution.width : 'native'} x
${resolution.height > 0 ? resolution.height : 'native'}
${cachedSnapshot ? ' (cached)' : ''}`,
this.accessory.displayName
);
const resized = await this.resizeSnapshot(snapshot, resolution.resizeFilter);
callback(undefined, resized);
} catch (error) {
logger.error(error, this.accessory.displayName);
callback(error);
}
}
async prepareStream(request, callback) {
const videoReturnPort = await getPort();
const videoSSRC = this.hap.CameraController.generateSynchronisationSource();
const audioReturnPort = await getPort();
const audioSSRC = this.hap.CameraController.generateSynchronisationSource();
const ipv6 = request.addressVersion === 'ipv6';
const sessionInfo = {
address: request.targetAddress,
ipv6: ipv6,
videoPort: request.video.port,
videoReturnPort: videoReturnPort,
videoCryptoSuite: request.video.srtpCryptoSuite,
videoSRTP: Buffer.concat([request.video.srtp_key, request.video.srtp_salt]),
videoSSRC: videoSSRC,
audioPort: request.audio.port,
audioReturnPort: audioReturnPort,
audioCryptoSuite: request.audio.srtpCryptoSuite,
audioSRTP: Buffer.concat([request.audio.srtp_key, request.audio.srtp_salt]),
audioSSRC: audioSSRC,
};
const response = {
video: {
port: videoReturnPort,
ssrc: videoSSRC,
srtp_key: request.video.srtp_key,
srtp_salt: request.video.srtp_salt,
},
audio: {
port: audioReturnPort,
ssrc: audioSSRC,
srtp_key: request.audio.srtp_key,
srtp_salt: request.audio.srtp_salt,
},
};
this.pendingSessions.set(request.sessionID, sessionInfo);
callback(undefined, response);
}
startStream(request, callback) {
const sessionInfo = this.pendingSessions.get(request.sessionID);
if (sessionInfo) {
const vcodec = this.videoConfig.vcodec || 'libx264';
const mtu = this.videoConfig.packetSize || 1316; // request.video.mtu is not used
let encoderOptions = this.videoConfig.encoderOptions;
if (!encoderOptions && vcodec === 'libx264') {
encoderOptions = '-preset ultrafast -tune zerolatency';
}
const resolution = this.determineResolution(request.video, false);
let fps =
this.videoConfig.maxFPS !== undefined &&
(this.videoConfig.forceMax || request.video.fps > this.videoConfig.maxFPS)
? this.videoConfig.maxFPS
: request.video.fps;
let videoBitrate =
this.videoConfig.maxBitrate !== undefined &&
(this.videoConfig.forceMax || request.video.max_bit_rate > this.videoConfig.maxBitrate)
? this.videoConfig.maxBitrate
: request.video.max_bit_rate;
if (vcodec === 'copy') {
resolution.width = 0;
resolution.height = 0;
resolution.videoFilter = undefined;
fps = 0;
videoBitrate = 0;
}
logger.debug(
`Video stream requested: ${request.video.width} x ${request.video.height},
${request.video.fps} fps, ${request.video.max_bit_rate} kbps`,
this.accessory.displayName
);
logger.info(
`Starting video stream:
${resolution.width > 0 ? resolution.width : 'native'} x
${resolution.height > 0 ? resolution.height : 'native'},
${fps > 0 ? fps : 'native'} fps,
${videoBitrate > 0 ? videoBitrate : '???'} kbps
${this.videoConfig.audio ? ' (' + request.audio.codec + ')' : ''}`,
this.accessory.displayName
);
let ffmpegArguments = this.videoConfig.source;
ffmpegArguments += // Video
(this.videoConfig.mapvideo ? ' -map ' + this.videoConfig.mapvideo : ' -an -sn -dn') +
' -codec:v ' +
vcodec +
' -pix_fmt yuv420p' +
' -color_range mpeg' +
(fps > 0 ? ' -r ' + fps : '') +
' -f rawvideo' +
(encoderOptions ? ' ' + encoderOptions : '') +
(resolution.videoFilter ? ' -filter:v ' + resolution.videoFilter : '') +
(videoBitrate > 0 ? ' -b:v ' + videoBitrate + 'k' : '') +
' -payload_type ' +
request.video.pt;
ffmpegArguments += // Video Stream
' -ssrc ' +
sessionInfo.videoSSRC +
' -f rtp' +
' -srtp_out_suite AES_CM_128_HMAC_SHA1_80' +
' -srtp_out_params ' +
sessionInfo.videoSRTP.toString('base64') +
' srtp://' +
sessionInfo.address +
':' +
sessionInfo.videoPort +
'?rtcpport=' +
sessionInfo.videoPort +
'&pkt_size=' +
mtu;
if (this.videoConfig.audio) {
if (
request.audio.codec === this.hap.AudioStreamingCodecType.OPUS ||
request.audio.codec === this.hap.AudioStreamingCodecType.AAC_ELD
) {
ffmpegArguments += // Audio
(this.videoConfig.mapaudio ? ' -map ' + this.videoConfig.mapaudio : ' -vn -sn -dn') +
(request.audio.codec === this.hap.AudioStreamingCodecType.OPUS
? ' -codec:a libopus' + ' -application lowdelay'
: ' -codec:a libfdk_aac' + ' -profile:a aac_eld') +
' -flags +global_header' +
' -f null' +
' -ar ' +
request.audio.sample_rate +
'k' +
' -b:a ' +
request.audio.max_bit_rate +
'k' +
' -ac ' +
request.audio.channel +
' -payload_type ' +
request.audio.pt;
ffmpegArguments += // Audio Stream
' -ssrc ' +
sessionInfo.audioSSRC +
' -f rtp' +
' -srtp_out_suite AES_CM_128_HMAC_SHA1_80' +
' -srtp_out_params ' +
sessionInfo.audioSRTP.toString('base64') +
' srtp://' +
sessionInfo.address +
':' +
sessionInfo.audioPort +
'?rtcpport=' +
sessionInfo.audioPort +
'&pkt_size=188';
} else {
logger.error(`Unsupported audio codec requested: ${request.audio.codec}`, this.accessory.displayName);
}
}
ffmpegArguments += ' -loglevel level' + (this.videoConfig.debug ? '+verbose' : '') + ' -progress pipe:1';
const activeSession = {};
activeSession.socket = createSocket(sessionInfo.ipv6 ? 'udp6' : 'udp4');
activeSession.socket.on('error', (error) => {
logger.error(`Socket error: ${error.message}`, this.accessory.displayName);
this.stopStream(request.sessionID);
});
activeSession.socket.on('message', () => {
if (activeSession.timeout) {
clearTimeout(activeSession.timeout);
}
activeSession.timeout = setTimeout(() => {
logger.info('Device appears to be inactive. Stopping stream.', this.accessory.displayName);
this.controller.forceStopStreamingSession(request.sessionID);
this.stopStream(request.sessionID);
}, request.video.rtcp_interval * 5 * 1000);
});
activeSession.socket.bind(sessionInfo.videoReturnPort);
activeSession.mainProcess = new FfmpegProcess(
this.accessory.displayName,
this.videoConfig.debug,
request.sessionID,
this.videoProcessor,
ffmpegArguments,
this,
callback
);
if (this.videoConfig.returnAudioTarget) {
const ffmpegReturnArguments =
'-hide_banner' +
' -protocol_whitelist pipe,udp,rtp,file,crypto' +
' -f sdp' +
' -c:a libfdk_aac' +
' -i pipe:' +
' ' +
this.videoConfig.returnAudioTarget +
' -loglevel level' +
(this.videoConfig.debug ? '+verbose' : '');
const ipVersion = sessionInfo.ipv6 ? 'IP6' : 'IP4';
const sdpReturnAudio =
'v=0\r\n' +
'o=- 0 0 IN ' +
ipVersion +
' ' +
sessionInfo.address +
'\r\n' +
's=Talk\r\n' +
'c=IN ' +
ipVersion +
' ' +
sessionInfo.address +
'\r\n' +
't=0 0\r\n' +
'm=audio ' +
sessionInfo.audioReturnPort +
' RTP/AVP 110\r\n' +
'b=AS:24\r\n' +
'a=rtpmap:110 MPEG4-GENERIC/16000/1\r\n' +
'a=rtcp-mux\r\n' + // FFmpeg ignores this, but might as well
'a=fmtp:110 ' +
'profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3; ' +
'config=F8F0212C00BC00\r\n' +
'a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:' +
sessionInfo.audioSRTP.toString('base64') +
'\r\n';
activeSession.returnProcess = new FfmpegProcess(
this.accessory.displayName + '] [Two-way',
this.videoConfig.debug,
request.sessionID,
this.videoProcessor,
ffmpegReturnArguments,
this
);
activeSession.returnProcess.getStdin().end(sdpReturnAudio);
}
this.ongoingSessions.set(request.sessionID, activeSession);
this.pendingSessions.delete(request.sessionID);
} else {
logger.error('Error finding session information.', this.accessory.displayName);
callback(new Error('Error finding session information'));
}
}
handleStreamRequest(request, callback) {
switch (request.type) {
case 'start': {
let allowStream = sessions.requestSession(this.accessory.displayName);
if (!allowStream) {
return callback(new Error('Stream not allowed!'));
}
this.startStream(request, callback);
break;
}
case 'reconfigure': {
logger.debug(
`Received request to reconfigure:
${request.video.width}x${request.video.height},
${request.video.fps} fps,
${request.video.max_bit_rate} kbps (Ignored)`,
this.accessory.displayName
);
callback();
break;
}
case 'stop': {
this.stopStream(request.sessionID);
callback();
break;
}
}
}
stopStream(sessionId) {
const session = this.ongoingSessions.get(sessionId);
if (session) {
if (session.timeout) {
clearTimeout(session.timeout);
}
try {
if (session.socket) session.socket.close();
} catch (error) {
logger.error(`Error occurred closing socket: ${error}`, this.accessory.displayName);
}
try {
if (session.mainProcess) session.mainProcess.stop();
} catch (error) {
logger.error(`Error occurred terminating main FFmpeg process: ${error}`, this.accessory.displayName);
}
try {
if (session.returnProcess) session.returnProcess.stop();
} catch (error) {
logger.error(`Error occurred terminating two-way FFmpeg process: ${error}`, this.accessory.displayName);
}
this.ongoingSessions.delete(sessionId);
sessions.closeSession(this.accessory.displayName);
logger.info('Stopped video stream.', this.accessory.displayName);
}
}
}
module.exports = Camera;