├── .gitignore ├── package.json ├── README.md ├── index.js └── iSight.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homebridge-camera-isight", 3 | "version": "0.0.5", 4 | "description": "iSight camera plugin for homebridge: https://github.com/nfarina/homebridge", 5 | "license": "ISC", 6 | "keywords": [ 7 | "homebridge-plugin" 8 | ], 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/KhaosT/homebridge-camera-isight.git" 12 | }, 13 | "bugs": { 14 | "url": "http://github.com/KhaosT/homebridge-camera-isight/issues" 15 | }, 16 | "engines": { 17 | "node": ">=5.0.0", 18 | "homebridge": ">=0.4.5" 19 | }, 20 | "dependencies": { 21 | "imagesnapjs": "^0.0.7", 22 | "ip": "^1.1.3" 23 | }, 24 | "scripts": { 25 | "start": "homebridge --plugin-path ./", 26 | "debug": "DEBUG=* homebridge --debug --plugin-path ./" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # homebridge-camera-isight 2 | 3 | iSight camera plugin for [Homebridge](https://github.com/nfarina/homebridge) 4 | 5 | ** This plugin only works on macOS ** 6 | 7 | ## Installation 8 | 9 | 1. Install ffmpeg on your Mac 10 | 2. Install this plugin using: npm install -g homebridge-camera-isight 11 | 3. Edit ``config.json`` and add the camera. 12 | 4. Run Homebridge 13 | 5. Add the "iSight Camera" in Home app. 14 | 15 | ### Config.json Example 16 | 17 | { 18 | "platform": "Camera-iSight", 19 | "name": "iSight Camera", 20 | "fps": 30 21 | } 22 | 23 | Optional keys: 24 | 25 | - `video_device`: Video device name or index. eg: `"video_device": "FaceTime HD Camera (Built-in)"` 26 | - `audio_device`: Audio device name or index. 27 | 28 | You can get the device names and indices with `ffmpeg -f avfoundation -list_devices true -i ""` -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var Accessory, hap, UUIDGen; 2 | 3 | var iSight = require('./iSight').iSight; 4 | 5 | module.exports = function(homebridge) { 6 | Accessory = homebridge.platformAccessory; 7 | hap = homebridge.hap; 8 | UUIDGen = homebridge.hap.uuid; 9 | 10 | homebridge.registerPlatform("homebridge-camera-isight", "Camera-iSight", iSightPlatform, true); 11 | } 12 | 13 | function iSightPlatform(log, config, api) { 14 | var self = this; 15 | 16 | self.log = log; 17 | self.config = config; 18 | 19 | if (api) { 20 | self.api = api; 21 | 22 | if (api.version < 2.1) { 23 | throw new Error("Unexpected API version."); 24 | } 25 | 26 | self.api.on('didFinishLaunching', self.didFinishLaunching.bind(this)); 27 | } 28 | } 29 | 30 | iSightPlatform.prototype.configureAccessory = function(accessory) { 31 | // Won't be invoked 32 | } 33 | 34 | iSightPlatform.prototype.didFinishLaunching = function() { 35 | var self = this; 36 | if(self.config) { 37 | var name = "iSight Camera" || self.config.name; 38 | var uuid = UUIDGen.generate(name); 39 | 40 | var cameraAccessory = new Accessory(name, uuid, hap.Accessory.Categories.CAMERA); 41 | var cameraSource = new iSight(hap, self.config); 42 | cameraAccessory.configureCameraSource(cameraSource); 43 | 44 | self.api.publishCameraAccessories("Camera-iSight", [cameraAccessory]); 45 | } 46 | } -------------------------------------------------------------------------------- /iSight.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var uuid, Service, Characteristic, StreamController; 3 | 4 | var imagesnapjs = require('imagesnapjs'); 5 | var crypto = require('crypto'); 6 | var fs = require('fs'); 7 | var ip = require('ip'); 8 | var spawn = require('child_process').spawn; 9 | 10 | module.exports = { 11 | iSight: iSight 12 | }; 13 | 14 | function iSight(hap, config) { 15 | uuid = hap.uuid; 16 | Service = hap.Service; 17 | Characteristic = hap.Characteristic; 18 | StreamController = hap.StreamController; 19 | 20 | this.config = config; 21 | 22 | this.services = []; 23 | this.streamControllers = []; 24 | 25 | this.pendingSessions = {}; 26 | this.ongoingSessions = {}; 27 | 28 | let options = { 29 | proxy: false, // Requires RTP/RTCP MUX Proxy 30 | srtp: true, // Supports SRTP AES_CM_128_HMAC_SHA1_80 encryption 31 | video: { 32 | resolutions: [ 33 | [1920, 1080, 30], // Width, Height, framerate 34 | [320, 240, 15], // Apple Watch requires this configuration 35 | [1280, 960, 30], 36 | [1280, 720, 30], 37 | [1024, 768, 30], 38 | [640, 480, 30], 39 | [640, 360, 30], 40 | [480, 360, 30], 41 | [480, 270, 30], 42 | [320, 240, 30], 43 | [320, 180, 30] 44 | ], 45 | codec: { 46 | profiles: [0, 1, 2], // Enum, please refer StreamController.VideoCodecParamProfileIDTypes 47 | levels: [0, 1, 2] // Enum, please refer StreamController.VideoCodecParamLevelTypes 48 | } 49 | }, 50 | audio: { 51 | codecs: [ 52 | { 53 | type: "OPUS", // Audio Codec 54 | samplerate: 24 // 8, 16, 24 KHz 55 | }, 56 | { 57 | type: "AAC-eld", 58 | samplerate: 16 59 | } 60 | ] 61 | } 62 | } 63 | 64 | this.createCameraControlService(); 65 | this._createStreamControllers(2, options); 66 | } 67 | 68 | iSight.prototype.handleCloseConnection = function(connectionID) { 69 | this.streamControllers.forEach(function(controller) { 70 | controller.handleCloseConnection(connectionID); 71 | }); 72 | } 73 | 74 | iSight.prototype.handleSnapshotRequest = function(request, callback) { 75 | try { 76 | fs.unlinkSync("/tmp/0F0E480E-135D-4D11-86FC-B1C0C3ACA6FD.jpg"); 77 | } catch(err) { 78 | if (err.code != 'ENOENT') { 79 | debug(err); 80 | } 81 | } 82 | let cliFlags = this.config.video_device ? ("-d '" + this.config.video_device + "'") : ''; 83 | imagesnapjs.capture('/tmp/0F0E480E-135D-4D11-86FC-B1C0C3ACA6FD.jpg', { cliflags: cliFlags }, function(err) { 84 | if (!err) { 85 | var snapshot = fs.readFileSync('/tmp/0F0E480E-135D-4D11-86FC-B1C0C3ACA6FD.jpg'); 86 | callback(undefined, snapshot); 87 | } else { 88 | callback(err); 89 | } 90 | }); 91 | } 92 | 93 | iSight.prototype.prepareStream = function(request, callback) { 94 | var sessionInfo = {}; 95 | 96 | let sessionID = request["sessionID"]; 97 | let targetAddress = request["targetAddress"]; 98 | 99 | sessionInfo["address"] = targetAddress; 100 | 101 | var response = {}; 102 | 103 | let videoInfo = request["video"]; 104 | if (videoInfo) { 105 | let targetPort = videoInfo["port"]; 106 | let srtp_key = videoInfo["srtp_key"]; 107 | let srtp_salt = videoInfo["srtp_salt"]; 108 | 109 | // SSRC is a 32 bit integer that is unique per stream 110 | let ssrcSource = crypto.randomBytes(4); 111 | ssrcSource[0] = 0; 112 | let ssrc = ssrcSource.readInt32BE(0, true); 113 | 114 | let videoResp = { 115 | port: targetPort, 116 | ssrc: ssrc, 117 | srtp_key: srtp_key, 118 | srtp_salt: srtp_salt 119 | }; 120 | 121 | response["video"] = videoResp; 122 | 123 | sessionInfo["video_port"] = targetPort; 124 | sessionInfo["video_srtp"] = Buffer.concat([srtp_key, srtp_salt]); 125 | sessionInfo["video_ssrc"] = ssrc; 126 | } 127 | 128 | let audioInfo = request["audio"]; 129 | if (audioInfo) { 130 | let targetPort = audioInfo["port"]; 131 | let srtp_key = audioInfo["srtp_key"]; 132 | let srtp_salt = audioInfo["srtp_salt"]; 133 | 134 | // SSRC is a 32 bit integer that is unique per stream 135 | let ssrcSource = crypto.randomBytes(4); 136 | ssrcSource[0] = 0; 137 | let ssrc = ssrcSource.readInt32BE(0, true); 138 | 139 | let audioResp = { 140 | port: targetPort, 141 | ssrc: ssrc, 142 | srtp_key: srtp_key, 143 | srtp_salt: srtp_salt 144 | }; 145 | 146 | response["audio"] = audioResp; 147 | 148 | sessionInfo["audio_port"] = targetPort; 149 | sessionInfo["audio_srtp"] = Buffer.concat([srtp_key, srtp_salt]); 150 | sessionInfo["audio_ssrc"] = ssrc; 151 | } 152 | 153 | let currentAddress = ip.address(); 154 | var addressResp = { 155 | address: currentAddress 156 | }; 157 | 158 | if (ip.isV4Format(currentAddress)) { 159 | addressResp["type"] = "v4"; 160 | } else { 161 | addressResp["type"] = "v6"; 162 | } 163 | 164 | response["address"] = addressResp; 165 | this.pendingSessions[uuid.unparse(sessionID)] = sessionInfo; 166 | 167 | callback(response); 168 | } 169 | 170 | iSight.prototype.handleStreamRequest = function(request) { 171 | var sessionID = request["sessionID"]; 172 | var requestType = request["type"]; 173 | if (sessionID) { 174 | let sessionIdentifier = uuid.unparse(sessionID); 175 | 176 | if (requestType == "start") { 177 | var sessionInfo = this.pendingSessions[sessionIdentifier]; 178 | if (sessionInfo) { 179 | var width = 1280; 180 | var height = 720; 181 | var fps = 30; 182 | var bitrate = 300; 183 | 184 | let videoInfo = request["video"]; 185 | if (videoInfo) { 186 | width = videoInfo["width"]; 187 | height = videoInfo["height"]; 188 | 189 | let expectedFPS = videoInfo["fps"]; 190 | if (expectedFPS < fps) { 191 | fps = expectedFPS; 192 | } 193 | 194 | bitrate = videoInfo["max_bit_rate"]; 195 | } 196 | 197 | let targetAddress = sessionInfo["address"]; 198 | let targetVideoPort = sessionInfo["video_port"]; 199 | let videoKey = sessionInfo["video_srtp"]; 200 | let videoSsrc = sessionInfo["video_ssrc"]; 201 | 202 | let ffmpegCommandStart = ['-re', '-f', 'avfoundation', '-r', '' + this.config.fps]; 203 | let ffmpegCommandEnd = ['-threads', '0', '-vcodec', 'libx264', '-an', '-pix_fmt', 'yuv420p', '-r', '' + fps, 204 | '-f', 'rawvideo', '-tune', 'zerolatency', '-vf', 205 | 'scale=' + width + ':' + height, 206 | '-b:v', bitrate +'k', 207 | '-bufsize', bitrate +'k', 208 | '-payload_type', '99', '-ssrc', videoSsrc, '-f', 'rtp', 209 | '-srtp_out_suite', 'AES_CM_128_HMAC_SHA1_80', 210 | '-srtp_out_params', videoKey.toString('base64'), 211 | 'srtp://' + targetAddress + ':' + targetVideoPort + '?rtcpport=' + targetVideoPort + '&localrtcpport=' + targetVideoPort + '&pkt_size=1378']; 212 | let ffmpegInputDevice = (this.config.video_device || "0") + ":" + (this.config.audio_device || "0"); 213 | let ffmpegCommand = ffmpegCommandStart.concat(["-i", ffmpegInputDevice]).concat(ffmpegCommandEnd); 214 | console.log("ffmpeg", ffmpegCommand); 215 | let ffmpeg = spawn('ffmpeg', ffmpegCommand, {env: process.env}); 216 | ffmpeg.stderr.on('data', function(data) { 217 | console.error('stderr: ' + data); 218 | }); 219 | ffmpeg.on('close', function(code) { 220 | console.log('closing code: ' + code); 221 | }); 222 | this.ongoingSessions[sessionIdentifier] = ffmpeg; 223 | } 224 | 225 | delete this.pendingSessions[sessionIdentifier]; 226 | } else if (requestType == "stop") { 227 | var ffmpegProcess = this.ongoingSessions[sessionIdentifier]; 228 | if (ffmpegProcess) { 229 | ffmpegProcess.kill(); 230 | } 231 | 232 | delete this.ongoingSessions[sessionIdentifier]; 233 | } 234 | } 235 | } 236 | 237 | iSight.prototype.createCameraControlService = function() { 238 | var controlService = new Service.CameraControl(); 239 | 240 | this.services.push(controlService); 241 | } 242 | 243 | // Private 244 | 245 | iSight.prototype._createStreamControllers = function(maxStreams, options) { 246 | let self = this; 247 | 248 | for (var i = 0; i < maxStreams; i++) { 249 | var streamController = new StreamController(i, options, self); 250 | 251 | self.services.push(streamController.service); 252 | self.streamControllers.push(streamController); 253 | } 254 | } 255 | --------------------------------------------------------------------------------