-
Notifications
You must be signed in to change notification settings - Fork 24
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* Update README.md * Camera Streaming (#43) * Adapt homebridge-camera-ffmpeg source * Remove standalone service function * Add some debugging calls * Use self for callback * Add some debugging for ffmpeg * Add required spawn module * Use name instead of ID for device name * Add api check * Split ArloCameraSource into separate file to handle license better * Manage options at constructor rather than at stream configuration * Allow skipping of video transcoding if requested video size matches source * Add ip and debug modules to package * Add documentation to README about streaming * Switch to libopus for audio transcoding due to substantially better performance, also enable comfort noise on stream * Allow for additional audio commands if user desires * Some additional documentation and note that libopus is now default audio codec * Update `ffmpeg` (#41) * Update `ffmpeg` Add `ffmpeg-for-homebridge` - static `ffmpeg` binaries for Homebridge with support for audio (`libfdk-aac`) and hardware decoding (`h264_omx`). * Create config.schema.json Co-authored-by: Donavan Becker <[email protected]> * Added option to disable registering cameras (#33) Arlo now released HomeKit support for Arlo Pro and Pro 2. But the support only provides access to the cameras and motion detection, but not the HomeKit Security System feature. This HomeBridge plugin is still very useful but users might not want to include the cameras, as they would be available twice then. To support this scenario this commit adds a new optional configuration parameter ‘include_cameras” (default is set to true) which will exclude any camera from being registered via HomeBridge. Another option would have been to add a whitelist or blacklist feature for users, but I didn’t want this to be too complex without knowing the need for this. Co-authored-by: Charles Powell <[email protected]> Co-authored-by: David Parry <[email protected]> Co-authored-by: Kristian Matthews <[email protected]> Co-authored-by: Andreas Linde <[email protected]> * update package-lock.json * Higher Firmware Version (#46) * Update package.json * Update package-lock.json * Update README.md * Update config.schema.json * Update config.schema.json * Update config.schema.json * add node-arlo v1.1.0 * Update package.json * Create LICENSE.md * Update ArloCameraSource.js * update node-arlo to v1.2.0 (#55) * Update package.json * update package-lock.json * Update package.json * Update package-lock.json * Update CHANGELOG.md Co-authored-by: Charles Powell <[email protected]> Co-authored-by: David Parry <[email protected]> Co-authored-by: Kristian Matthews <[email protected]> Co-authored-by: Andreas Linde <[email protected]>
- Loading branch information
1 parent
b98195c
commit 1bb1f0d
Showing
7 changed files
with
587 additions
and
116 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,308 @@ | ||
const EventEmitter = require('events').EventEmitter; | ||
const debug = require('debug')('Homebridge-Arlo:CameraSource'); | ||
const debugFFmpeg = require('debug')('ffmpeg'); | ||
const crypto = require('crypto'); | ||
const ip = require('ip'); | ||
const spawn = require('child_process').spawn; | ||
|
||
let StreamController, UUIDGen; | ||
|
||
class ArloCameraSource extends EventEmitter { | ||
constructor(log, accessory, device, hap, config) { | ||
super(); | ||
|
||
StreamController = hap.StreamController; | ||
UUIDGen = hap.uuid; | ||
|
||
this.log = log; | ||
this.accessory = accessory; | ||
this.device = device; | ||
this.services = []; | ||
this.pendingSessions = {}; | ||
this.ongoingSessions = {}; | ||
this.streamControllers = []; | ||
this.lastSnapshot = null; | ||
|
||
this.videoProcessor = config.videoProcessor || 'ffmpeg'; | ||
this.videoDecoder = config.videoDecoder || ''; | ||
this.videoEncoder = config.videoEncoder || 'libx264'; | ||
this.audioCodec = config.audioEncoder || 'libopus'; | ||
this.packetsize = config.packetsize || 1316; //188, 376, 1316 | ||
this.fps = 24; | ||
this.maxBitrate = config.maxBitrate || 300; | ||
this.additionalVideoCommands = (config.additionalVideoCommands ? (' ' + config.additionalVideoCommands) : ''); | ||
this.additionalAudioCommands = (config.additionalAudioCommands ? (' ' + config.additionalAudioCommands) : ''); | ||
|
||
let numberOfStreams = config.maxStreams || 2; | ||
|
||
let options = { | ||
proxy: false, // Requires RTP/RTCP MUX Proxy | ||
srtp: true, // Supports SRTP AES_CM_128_HMAC_SHA1_80 encryption | ||
video: { | ||
resolutions: [ | ||
[1280, 720, 24], | ||
[1280, 720, 15], | ||
[640, 360, 24], | ||
[640, 360, 15], | ||
[320, 240, 24], | ||
[320, 240, 15] | ||
], | ||
codec: { | ||
profiles: [0, 1, 2], //[StreamController.VideoCodecParamProfileIDTypes.MAIN], | ||
levels: [0, 1, 2] //[StreamController.VideoCodecParamLevelTypes.TYPE4_0] | ||
} | ||
}, | ||
audio: { | ||
codecs: [ | ||
{ | ||
type: 'OPUS', | ||
samplerate: 24, | ||
comfort_noise: true | ||
} | ||
] | ||
} | ||
} | ||
|
||
this._createStreamControllers(numberOfStreams, options); | ||
debug('Generated Camera Controller'); | ||
} | ||
|
||
handleCloseConnection(connectionID) { | ||
this.streamControllers.forEach(function(controller) { | ||
controller.handleCloseConnection(connectionID); | ||
}); | ||
} | ||
|
||
handleSnapshotRequest(request, callback) { | ||
debug('Snapshot requested'); | ||
|
||
this.log("Snapshot request: Camera %s [%s]", this.accessory.displayName, this.device.id); | ||
|
||
this.device.downloadSnapshot(this.device.device.presignedLastImageUrl, function (data) { | ||
this.log("Snapshot downloaded: Camera %s [%s]", this.accessory.displayName, this.device.id); | ||
callback(undefined, data); | ||
}.bind(this)); | ||
} | ||
|
||
prepareStream(request, callback) { | ||
debug('Prepare stream request'); | ||
|
||
var self = this; | ||
|
||
this.device.getStream(function (streamURL) { | ||
debug('Preparing stream for URL: %s',streamURL); | ||
debug('Prepare Stream request: %O', request); | ||
|
||
var sessionInfo = {}; | ||
let sessionID = request["sessionID"]; | ||
let targetAddress = request["targetAddress"]; | ||
|
||
sessionInfo["streamURL"] = streamURL; | ||
sessionInfo["address"] = targetAddress; | ||
|
||
var response = {}; | ||
|
||
let videoInfo = request["video"]; | ||
if (videoInfo) { | ||
let targetPort = videoInfo["port"]; | ||
let srtp_key = videoInfo["srtp_key"]; | ||
let srtp_salt = videoInfo["srtp_salt"]; | ||
|
||
// SSRC is a 32 bit integer that is unique per stream | ||
let ssrcSource = crypto.randomBytes(4); | ||
ssrcSource[0] = 0; | ||
let ssrc = ssrcSource.readInt32BE(0, true); | ||
|
||
let videoResponse = { | ||
port: targetPort, | ||
ssrc: ssrc, | ||
srtp_key: srtp_key, | ||
srtp_salt: srtp_salt | ||
}; | ||
|
||
response["video"] = videoResponse; | ||
sessionInfo["video_port"] = targetPort; | ||
sessionInfo["video_srtp"] = Buffer.concat([srtp_key, srtp_salt]); | ||
sessionInfo["video_ssrc"] = ssrc; | ||
} | ||
|
||
let audioInfo = request["audio"]; | ||
if (audioInfo) { | ||
let targetPort = audioInfo["port"]; | ||
let srtp_key = audioInfo["srtp_key"]; | ||
let srtp_salt = audioInfo["srtp_salt"]; | ||
|
||
// SSRC is a 32 bit integer that is unique per stream | ||
let ssrcSource = crypto.randomBytes(4); | ||
ssrcSource[0] = 0; | ||
let ssrc = ssrcSource.readInt32BE(0, true); | ||
|
||
let audioResp = { | ||
port: targetPort, | ||
ssrc: ssrc, | ||
srtp_key: srtp_key, | ||
srtp_salt: srtp_salt | ||
}; | ||
|
||
response["audio"] = audioResp; | ||
|
||
sessionInfo["audio_port"] = targetPort; | ||
sessionInfo["audio_srtp"] = Buffer.concat([srtp_key, srtp_salt]); | ||
sessionInfo["audio_ssrc"] = ssrc; | ||
} | ||
|
||
let currentAddress = ip.address(); | ||
var addressResp = { | ||
address: currentAddress | ||
}; | ||
|
||
if (ip.isV4Format(currentAddress)) { | ||
addressResp["type"] = "v4"; | ||
} else { | ||
addressResp["type"] = "v6"; | ||
} | ||
|
||
response["address"] = addressResp; | ||
|
||
self.pendingSessions[UUIDGen.unparse(sessionID)] = sessionInfo; | ||
|
||
callback(response); | ||
}); | ||
} | ||
|
||
handleStreamRequest(request) { | ||
debug('Handle Stream request: %O', request); | ||
|
||
var sessionID = request["sessionID"]; | ||
var requestType = request["type"]; | ||
if (sessionID) { | ||
let sessionIdentifier = UUIDGen.unparse(sessionID); | ||
|
||
// Start streaming | ||
if (requestType == "start") { | ||
var sessionInfo = this.pendingSessions[sessionIdentifier]; | ||
if (sessionInfo) { | ||
var width = 1280; | ||
var height = 720; | ||
var fps = this.fps; | ||
var vbitrate = 1500; | ||
var packetsize = this.packetsize; | ||
var additionalVideoCommands = this.additionalVideoCommands; | ||
|
||
var vDecoder, vEncoder, scaleCommand; | ||
|
||
let videoInfo = request["video"]; | ||
if (videoInfo) { | ||
var width = videoInfo["width"]; | ||
var height = videoInfo["height"]; | ||
|
||
if (width == 1280 && height == 720) { | ||
// No video transcoding required, use copy codec | ||
vDecoder = ''; | ||
vEncoder = 'copy'; | ||
scaleCommand = ''; | ||
debug('No change to video stream size required'); | ||
} else { | ||
// Scale video requested, requiring video transcoding | ||
vDecoder = this.videoDecoder ? (' -c:v ' + this.videoDecoder) : ''; | ||
vEncoder = this.videoEncoder; | ||
scaleCommand = ' -vf scale=' + width + ':' + height; | ||
} | ||
|
||
let expectedFPS = videoInfo["fps"]; | ||
if (expectedFPS < fps) { | ||
fps = expectedFPS; | ||
} | ||
|
||
if(videoInfo["max_bit_rate"] < vbitrate) { | ||
vbitrate = videoInfo["max_bit_rate"]; | ||
} | ||
} | ||
|
||
let streamURL = sessionInfo["streamURL"]; | ||
|
||
let targetAddress = sessionInfo["address"]; | ||
let targetVideoPort = sessionInfo["video_port"]; | ||
let videoKey = sessionInfo["video_srtp"]; | ||
let videoSsrc = sessionInfo["video_ssrc"]; | ||
|
||
// Video | ||
let ffmpegCommand = '-rtsp_transport tcp' + | ||
vDecoder + | ||
' -re -i ' + streamURL + ' -map 0:0' + | ||
' -c:v ' + vEncoder + | ||
' -pix_fmt yuv420p' + | ||
' -r ' + fps + | ||
' -f rawvideo' + | ||
scaleCommand + | ||
additionalVideoCommands + | ||
' -b:v ' + vbitrate + 'k' + | ||
' -bufsize ' + vbitrate+ 'k' + | ||
' -maxrate '+ vbitrate + 'k' + | ||
' -payload_type 99' + | ||
' -ssrc ' + videoSsrc + | ||
' -f rtp' + | ||
' -srtp_out_suite AES_CM_128_HMAC_SHA1_80' + | ||
' -srtp_out_params ' + videoKey.toString('base64') + | ||
' srtp://' + targetAddress + ':' + targetVideoPort + | ||
'?rtcpport=' + targetVideoPort + | ||
'&localrtcpport=' + targetVideoPort + | ||
'&pkt_size=' + packetsize; | ||
|
||
let ffmpeg = spawn(this.videoProcessor, ffmpegCommand.split(' '), {env: process.env}); | ||
debugFFmpeg("Start streaming video with " + width + "x" + height + "@" + vbitrate + "kBit"); | ||
debugFFmpeg("ffmpeg " + ffmpegCommand); | ||
|
||
// Always setup hook on stderr. | ||
// Without this streaming stops within one to two minutes. | ||
ffmpeg.stderr.on('data', function(data) { | ||
// Do not log to the console if debugging is turned off | ||
debugFFmpeg(data.toString()); | ||
}); | ||
|
||
let self = this; | ||
ffmpeg.on('error', function(error){ | ||
debugFFmpeg("An error occurs while making stream request"); | ||
debugFFmpeg(error); | ||
}); | ||
|
||
ffmpeg.on('close', (code) => { | ||
if(code == null || code == 0 || code == 255){ | ||
debugFFmpeg("Stopped streaming with code %i",code); | ||
} else { | ||
debugFFmpeg("ERROR: FFmpeg exited with code " + code); | ||
for(var i=0; i < self.streamControllers.length; i++){ | ||
var controller = self.streamControllers[i]; | ||
if(controller.sessionIdentifier === sessionID){ | ||
controller.forceStop(); | ||
} | ||
} | ||
} | ||
}); | ||
|
||
// Add to ongoing sessions now that it's been started | ||
this.ongoingSessions[sessionIdentifier] = ffmpeg; | ||
} | ||
// Remove from pending sessions | ||
delete this.pendingSessions[sessionIdentifier]; | ||
} else if (requestType == "stop") { | ||
var ffmpegProcess = this.ongoingSessions[sessionIdentifier]; | ||
if (ffmpegProcess) { | ||
ffmpegProcess.kill('SIGTERM'); | ||
} | ||
} | ||
} | ||
} | ||
|
||
_createStreamControllers(numberOfStreams, options) { | ||
let self = this; | ||
for (var i = 0; i < numberOfStreams; i++) { | ||
var streamController = new StreamController(i, options, self); | ||
|
||
self.services.push(streamController.service); | ||
self.streamControllers.push(streamController); | ||
} | ||
} | ||
} | ||
|
||
module.exports = ArloCameraSource; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.