├── homebridge-fixfifo.sh ├── package.json ├── LICENSE ├── README.md └── camera-motion.js /homebridge-fixfifo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | mkfifo /tmp/motion-pipe 3 | chmod 777 /tmp/motion-pipe 4 | chown pi:pi /tmp/motion-pipe 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homebridge-camera-motion", 3 | "version": "1.0.0", 4 | "description": "Motion detector camera plugin for Homebridge", 5 | "main": "camera-motion.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/rxseger/homebridge-camera-motion.git" 12 | }, 13 | "keywords": [ 14 | "homebridge-plugin", 15 | "camera", 16 | "motion" 17 | ], 18 | "author": "rxserger", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/rxseger/homebridge-camera-motion/issues" 22 | }, 23 | "engines": { 24 | "node": ">=6.0.0", 25 | "homebridge": ">=0.2.0" 26 | }, 27 | "homepage": "https://github.com/rxseger/homebridge-camera-motion#readme", 28 | "dependencies": { 29 | "fifo-js": "^2.1.0", 30 | "ip": "^1.1.3" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 rxseger 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # homebridge-camera-motion 2 | 3 | [Motion](https://motion-project.github.io) camera plugin for [Homebridge](https://github.com/nfarina/homebridge) 4 | 5 | ## Installation 6 | 1. Install Homebridge using `npm install -g homebridge` 7 | 2. Install this plugin `npm install -g homebridge-camera-motion` 8 | 3. Update your configuration file - see below for an example 9 | 4. Install and configure [Motion](https://motion-project.github.io) 10 | 11 | Add to your `~/.motion/motion.conf`: 12 | 13 | ``` 14 | on_picture_save printf '%f\t%n\t%v\t%i\t%J\t%K\t%L\t%N\t%D\n' > /tmp/motion-pipe 15 | target_dir /tmp 16 | ``` 17 | 5. Copy homebrige-fixfifo.sh to /home/pi/homebridge-fixfifo.sh and add /home/pi/homebridge-fixfifo.sh to your rc.local file : 18 | ``` 19 | $ cat /etc/rc.local 20 | #!/bin/sh -e 21 | # 22 | # rc.local 23 | # 24 | # This script is executed at the end of each multiuser runlevel. 25 | # Make sure that the script will "exit 0" on success or any other 26 | # value on error. 27 | # 28 | # In order to enable or disable this script just change the execution 29 | # bits. 30 | # 31 | # By default this script does nothing. 32 | 33 | /home/pi/homebridge-fixfifo.sh 34 | exit 0 35 | ``` 36 | 6. Pair to the camera (requires pairing separately from the rest of the Homebridge) 37 | 38 | ## Configuration 39 | * `accessory`: "CameraMotion" 40 | * `name`: descriptive name of the Camera service and platform 41 | * `name_motion`: name of MotionDetector service 42 | * `motion_pipe`: path to a [Unix named pipe](https://en.wikipedia.org/wiki/Named_pipe) where motion events are written (will be created if needed, should match output file pipe written to by Motion `on_picture_save`) 43 | * `motion_timeout`: reset the motion detector after this many milliseconds 44 | * `ffmpeg_path`: path to ffmpeg for streaming (optional) 45 | * `ffmpeg_source`: URL to stream source, should match as configured by motion 46 | 47 | Example configuration: 48 | 49 | ```json 50 | "platforms": [ 51 | { 52 | "platform": "CameraMotion", 53 | "name": "Camera", 54 | "name_motion": "Motion Sensor", 55 | "motion_pipe": "/tmp/motion-pipe", 56 | "motion_timeout": 2000, 57 | "snapshot_path": "/tmp/lastsnap.jpg", 58 | "ffmpeg_path": "/usr/local/bin/ffmpeg", 59 | "ffmpeg_source": "-re -i http://192.168.1.100:8081/" 60 | 61 | } 62 | ] 63 | ``` 64 | 65 | Creates a MotionSensor service and CameraSensor service. 66 | 67 | Currently working: snapshots (still images) and motion detection. 68 | Video streaming 69 | 70 | ## License 71 | 72 | MIT 73 | 74 | -------------------------------------------------------------------------------- /camera-motion.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const ip = require('ip'); 5 | const FIFO = require('fifo-js'); 6 | const spawn = require('child_process').spawn; 7 | 8 | let Service, Characteristic, uuid, StreamController, Accessory, hap; 9 | 10 | module.exports = (homebridge) => { 11 | Service = homebridge.hap.Service; 12 | Characteristic = homebridge.hap.Characteristic; 13 | uuid = homebridge.hap.uuid; 14 | StreamController = homebridge.hap.StreamController; 15 | Accessory = homebridge.platformAccessory; 16 | hap = homebridge.hap; 17 | 18 | homebridge.registerPlatform('homebridge-camera-motion', 'CameraMotion', CameraMotionPlatform, true); 19 | }; 20 | 21 | class CameraMotionPlatform 22 | { 23 | constructor(log, config, api) { 24 | log(`CameraMotion Platform Plugin starting`,config); 25 | this.log = log; 26 | this.api = api; 27 | this.config = config; 28 | if (!config) return; // TODO: what gives with initializing platforms twice, once with config once without? 29 | this.name = config.name || 'Camera'; 30 | 31 | this.motionAccessory = new CameraMotionAccessory(log, config, api); 32 | 33 | this.api.on('didFinishLaunching', this.didFinishLaunching.bind(this)); 34 | } 35 | 36 | accessories(cb) { 37 | cb([this.motionAccessory]); 38 | } 39 | 40 | didFinishLaunching() { 41 | if (global._mcp_launched) return; // call only once 42 | global._mcp_launched = true; // TODO: really, why is this called twice? from where? 43 | 44 | const cameraName = this.name; 45 | const uu = uuid.generate(cameraName); 46 | console.log('uuid=',uu); 47 | const cameraAccessory = new Accessory(cameraName, uu, hap.Accessory.Categories.CAMERA); 48 | this.cameraSource = new CameraSource(hap, this.config); 49 | cameraAccessory.configureCameraSource(this.cameraSource); 50 | this.motionAccessory.setSource(this.cameraSource); 51 | const configuredAccessories = [cameraAccessory]; 52 | this.api.publishCameraAccessories('CameraMotion', configuredAccessories); 53 | this.log(`published camera`); 54 | } 55 | } 56 | 57 | // An accessory with a MotionSensor service 58 | class CameraMotionAccessory 59 | { 60 | constructor(log, config, api) { 61 | log(`CameraMotion accessory starting`); 62 | this.log = log; 63 | this.api = api; 64 | config = config || {}; 65 | this.name = config.name_motion || 'Motion Detector'; 66 | 67 | this.pipePath = config.motion_pipe || '/tmp/motion-pipe'; 68 | this.timeout = config.motion_timeout !== undefined ? config.motion_timeout : 2000; 69 | 70 | this.pipe = new FIFO(this.pipePath); 71 | this.pipe.setReader(this.onPipeRead.bind(this)); 72 | 73 | this.motionService = new Service.MotionSensor(this.name); 74 | this.setMotion(false); 75 | } 76 | 77 | setSource(cameraSource) { 78 | this.cameraSource = cameraSource; 79 | } 80 | 81 | setMotion(detected) { 82 | this.motionService 83 | .getCharacteristic(Characteristic.MotionDetected) 84 | .setValue(detected); 85 | } 86 | 87 | onPipeRead(text) { 88 | console.log(`got from pipe: |${text}|`); 89 | // on_picture_save printf '%f\t%n\t%v\t%i\t%J\t%K\t%L\t%N\t%D\n' > /tmp/camera-pipe 90 | // http://htmlpreview.github.io/?https://github.com/Motion-Project/motion/blob/master/motion_guide.html#conversion_specifiers 91 | // %f filename with full path 92 | // %n number indicating filetype 93 | // %v event 94 | // %i width of motion area 95 | // %J height of motion area 96 | // %K X coordinates of motion center 97 | // %L Y coordinates of motion center 98 | // %N noise level 99 | // %D changed pixels 100 | const [filename, filetype, event, width, height, x, y, noise, dpixels] = text.trim().split('\t'); 101 | console.log('filename is',filename); 102 | this.cameraSource.snapshot_path = filename; 103 | this.setMotion(true); 104 | 105 | setTimeout(() => this.setMotion(false), this.timeout); // TODO: is this how this works? 106 | } 107 | 108 | getServices() { 109 | return [this.motionService]; 110 | } 111 | } 112 | 113 | // Source for the camera images 114 | class CameraSource 115 | { 116 | constructor(hap, config) { 117 | this.hap = hap; 118 | this.config = config; 119 | this.snapshot_path = '/tmp/lastsnap.jpg'; // Will be reset by onPipeRead(...) 120 | this.ffmpeg_path = config.ffmpeg_path || false; 121 | this.ffmpegSource = config.ffmpeg_source; 122 | 123 | this.pendingSessions = {}; 124 | this.ongoingSessions = {}; 125 | 126 | this.services = []; // TODO: where is this used? 127 | 128 | // Create control service 129 | this.controlService = new Service.CameraControl(); 130 | 131 | // Create stream controller(s) (only one for now TODO: more?) 132 | 133 | const videoResolutions = [ 134 | // width, height, fps 135 | [1920, 1080, 30], 136 | [320, 240, 15], 137 | [1280, 960, 30], 138 | [1280, 720, 30], 139 | [1024, 768, 30], 140 | [640, 480, 30], 141 | [640, 360, 30], 142 | [480, 360, 30], 143 | [480, 270, 30], 144 | [320, 240, 30], 145 | [320, 180, 30] 146 | ]; 147 | 148 | // see https://github.com/KhaosT/homebridge-camera-ffmpeg/blob/master/ffmpeg.js 149 | const options = { 150 | proxy: false, // Requires RTP/RTCP MUX Proxy 151 | srtp: true, // Supports SRTP AES_CM_128_HMAC_SHA1_80 encryption 152 | video: { 153 | resolutions: videoResolutions, 154 | codec: { 155 | profiles: [0, 1, 2], // Enum, please refer StreamController.VideoCodecParamProfileIDTypes 156 | levels: [0, 1, 2] // Enum, please refer StreamController.VideoCodecParamLevelTypes 157 | } 158 | }, 159 | audio: { 160 | codecs: [ 161 | { 162 | type: "OPUS", // Audio Codec 163 | samplerate: 24 // 8, 16, 24 KHz 164 | }, 165 | { 166 | type: "AAC-eld", 167 | samplerate: 16 168 | } 169 | ] 170 | } 171 | }; 172 | 173 | 174 | this.streamController = new StreamController(0, options, this); 175 | this.services.push(this.streamController.service); 176 | } 177 | 178 | handleCloseConnection(connectionID) { 179 | this.streamController.handleCloseConnection(connectionID); 180 | } 181 | 182 | // stolen from https://github.com/KhaosT/homebridge-camera-ffmpeg/blob/master/ffmpeg.js TODO: why can't this be in homebridge itself? 183 | prepareStream(request, cb) { 184 | var sessionInfo = {}; 185 | 186 | let sessionID = request["sessionID"]; 187 | let targetAddress = request["targetAddress"]; 188 | 189 | sessionInfo["address"] = targetAddress; 190 | 191 | var response = {}; 192 | 193 | let videoInfo = request["video"]; 194 | if (videoInfo) { 195 | let targetPort = videoInfo["port"]; 196 | let srtp_key = videoInfo["srtp_key"]; 197 | let srtp_salt = videoInfo["srtp_salt"]; 198 | 199 | let videoResp = { 200 | port: targetPort, 201 | ssrc: 1, 202 | srtp_key: srtp_key, 203 | srtp_salt: srtp_salt 204 | }; 205 | 206 | response["video"] = videoResp; 207 | 208 | sessionInfo["video_port"] = targetPort; 209 | sessionInfo["video_srtp"] = Buffer.concat([srtp_key, srtp_salt]); 210 | sessionInfo["video_ssrc"] = 1; 211 | } 212 | 213 | let audioInfo = request["audio"]; 214 | if (audioInfo) { 215 | let targetPort = audioInfo["port"]; 216 | let srtp_key = audioInfo["srtp_key"]; 217 | let srtp_salt = audioInfo["srtp_salt"]; 218 | 219 | let audioResp = { 220 | port: targetPort, 221 | ssrc: 1, 222 | srtp_key: srtp_key, 223 | srtp_salt: srtp_salt 224 | }; 225 | 226 | response["audio"] = audioResp; 227 | 228 | sessionInfo["audio_port"] = targetPort; 229 | sessionInfo["audio_srtp"] = Buffer.concat([srtp_key, srtp_salt]); 230 | sessionInfo["audio_ssrc"] = 1; 231 | } 232 | 233 | let currentAddress = ip.address(); 234 | console.log('currentAddress',currentAddress); 235 | var addressResp = { 236 | address: currentAddress 237 | }; 238 | console.log('addressResp',addressResp); 239 | 240 | if (ip.isV4Format(currentAddress)) { 241 | addressResp["type"] = "v4"; 242 | } else { 243 | addressResp["type"] = "v6"; 244 | } 245 | 246 | response["address"] = addressResp; 247 | 248 | this.pendingSessions[uuid.unparse(sessionID)] = sessionInfo; 249 | cb(response) 250 | } 251 | 252 | // also based on homebridge-camera-ffmpeg! 253 | handleStreamRequest(request) { 254 | if (!this.ffmpeg_path) { 255 | console.log(`No ffmpeg_path set, ignoring handleStreamRequest`,request); 256 | return; 257 | } 258 | console.log('received handleStreamRequest',request); 259 | 260 | var sessionID = request["sessionID"]; 261 | var requestType = request["type"]; 262 | if (sessionID) { 263 | let sessionIdentifier = uuid.unparse(sessionID); 264 | 265 | if (requestType == "start") { 266 | var sessionInfo = this.pendingSessions[sessionIdentifier]; 267 | console.log('starting',sessionInfo); 268 | if (sessionInfo) { 269 | var width = 1280; 270 | var height = 720; 271 | var fps = 30; 272 | var bitrate = 300; 273 | 274 | let videoInfo = request["video"]; 275 | if (videoInfo) { 276 | width = videoInfo["width"]; 277 | height = videoInfo["height"]; 278 | 279 | let expectedFPS = videoInfo["fps"]; 280 | if (expectedFPS < fps) { 281 | fps = expectedFPS; 282 | } 283 | 284 | bitrate = videoInfo["max_bit_rate"]; 285 | } 286 | 287 | let targetAddress = sessionInfo["address"]; 288 | let targetVideoPort = sessionInfo["video_port"]; 289 | let videoKey = sessionInfo["video_srtp"]; 290 | 291 | let ffmpegCommand = this.ffmpegSource + ' -threads 0 -vcodec libx264 -an -pix_fmt yuv420p -r '+ fps +' -f rawvideo -tune zerolatency -vf scale='+ width +':'+ height +' -b:v '+ bitrate +'k -bufsize '+ bitrate +'k -payload_type 99 -ssrc 1 -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=1378'; 292 | console.log('about to spawn ffmpeg'); 293 | console.log(ffmpegCommand); 294 | let ffmpeg = spawn(this.ffmpeg_path, ffmpegCommand.split(' '), {env: process.env}); 295 | this.ongoingSessions[sessionIdentifier] = ffmpeg; 296 | } 297 | 298 | delete this.pendingSessions[sessionIdentifier]; 299 | } else if (requestType == "stop") { 300 | var ffmpegProcess = this.ongoingSessions[sessionIdentifier]; 301 | if (ffmpegProcess) { 302 | ffmpegProcess.kill('SIGKILL'); 303 | } 304 | 305 | delete this.ongoingSessions[sessionIdentifier]; 306 | } 307 | } 308 | } 309 | 310 | handleSnapshotRequest(request, cb) { 311 | console.log('handleSnapshotRequest',request); 312 | fs.readFile(this.snapshot_path, (err, data) => { 313 | if (err) return cb(err); 314 | 315 | // TODO: scale to requested dimensions 316 | cb(null, data); 317 | }); 318 | } 319 | } 320 | 321 | --------------------------------------------------------------------------------