diff --git a/README.md b/README.md index b416c44..46e74a8 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,22 @@ axiscam Axis (VAPIX) camera control in Node +Run +--- + +To run, make a settings.json file in the root folder: + +```json +{ + "url": "https://:@", + "name": "Name for the camera", + "motion": false +} +``` + +This provides the address and credentials for the camera, a name and whether to emit +any detected motion events. + API --- @@ -26,7 +42,7 @@ Streams MJPEG Creates a stream of javascript objects that represent a snapshot of the Axis camera's motion detection: ```javascript -{group: "0", level: 2, threshold: 10} +{group: 0, level: 2, threshold: 10} ``` ```javascript diff --git a/lib/axis.js b/lib/axis.js index b12949b..ba34f7f 100644 --- a/lib/axis.js +++ b/lib/axis.js @@ -4,14 +4,75 @@ var url = require('url'), util = require('util'), + events = require('events'), _ = require('underscore'), request = require('request'), - motion = require('./motion') + motion = require('./motion3') var sizes = {small: {resolution: 'QCIF'}, medium: {resolution: '320x240'}, large: {resolution: '640x480'}} +var defaultSize = 'medium' +/** + * Returns an object for VAPIX image/video size that can be used in url.format + */ +var getSizeQuery = function(size) { + return sizes[_.contains(_.keys(sizes), size) ? size : defaultSize] +} + +/** + * Wrapper for Axis camera VAPIX API + * + * url: the base URL for the camera + * name: a name that's associated with the camera + */ var AxisCam = function(options) { this.url = url.parse(options.url) + this.name = options.name + if (options.motion) { // Motion events off by default + this.detectMotion() + } +} +util.inherits(AxisCam, events.EventEmitter) + +/** + * Starts motion detection and emits events when motion + * exceeds the threshold on the camera + */ +AxisCam.prototype.detectMotion = function() { + var that = this + var reconnectDelay = 60 * 1000 + this.motionTimeoutId = null // Reset timeouts + + // Set up the motion stream if one doesn't exist already + if (!this.motionStream) { + this.motionStream = motion.createStream() + + this.motionStream.on('data', function(motion) { + if (motion.level < motion.threshold) + that.emit('motion', motion) + }) + } + + var httpStream = request({ + url: this.buildURL('axis-cgi/motion/motiondata.cgi'), + strictSSL: false, + timeout: 5000 + }) + + var startMotionTimeout = function(err) { + if (err) console.error(err) + if (!that.motionTimeoutId) { // Only start timeout if there isn't already one waiting + console.log('Restarting motion detection in ' + reconnectDelay/1000 + ' seconds') + that.motionTimeoutId = setTimeout(function() { + that.detectMotion() + }, reconnectDelay) + } + } + + httpStream.on('error', startMotionTimeout) + httpStream.on('end', startMotionTimeout) + + httpStream.pipe(this.motionStream) } AxisCam.prototype.systemTime = function(cb) { @@ -26,16 +87,16 @@ AxisCam.prototype.buildURL = function(path, query) { return url.format(_.extend({}, this.url, {pathname: path, query: query})) } -AxisCam.prototype.createImageStream = function() { +AxisCam.prototype.createImageStream = function(size) { return request({ - url: this.buildURL('axis-cgi/jpg/image.cgi', sizes.medium), + url: this.buildURL('axis-cgi/jpg/image.cgi', getSizeQuery(size)), strictSSL: false }) } -AxisCam.prototype.createVideoStream = function() { +AxisCam.prototype.createVideoStream = function(size) { return request({ - url: this.buildURL('axis-cgi/mjpg/video.cgi', sizes.medium), + url: this.buildURL('axis-cgi/mjpg/video.cgi', getSizeQuery(size)), strictSSL: false }) } @@ -47,11 +108,15 @@ AxisCam.prototype.createAudioStream = function() { }) } -AxisCam.prototype.createMotionStream = function(cb) { - return request({ - url: this.buildURL('axis-cgi/motion/motiondata.cgi'), - strictSSL: false - }).pipe(motion.createStream()) +AxisCam.prototype.writeImages = function() { + var FileOnWrite = require("file-on-write") + + var writer = new FileOnWrite({ + path: './video', + ext: '.jpg' + }) + + this.createVideoStream().pipe(require('./mjpg-stream').createStream()).pipe(writer) } var createClient = exports.createClient = function(options) { @@ -60,5 +125,11 @@ var createClient = exports.createClient = function(options) { if (require.main === module) { var axis = createClient(JSON.parse(require('fs').readFileSync(__dirname + '/../settings.json'))) + + axis.on('motion', function(data) { + console.log(data) + }) + //axis.createImageStream().pipe(require('fs').createWriteStream('./image.jpg')) + axis.writeImages() } \ No newline at end of file diff --git a/lib/mjpg-stream.js b/lib/mjpg-stream.js new file mode 100644 index 0000000..8488b0d --- /dev/null +++ b/lib/mjpg-stream.js @@ -0,0 +1,38 @@ +/** + * MJPEG decoder for Axis cameras + * Breaks the MJPEG stream into invidividual JPEG images + */ + +var Transform = require('stream').Transform + +Buffer.prototype.toByteArray = function () { + return Array.prototype.slice.call(this, 0) +} + +var MJPGStream = function() { + Transform.call(this, {objectMode: true}) +} + +MJPGStream.prototype = Object.create( + Transform.prototype, { constructor: { value: MJPGStream }}) + +MJPGStream.prototype._transform = function(data, encoding, cb) { + // Step through the data, looking for start/end markers (SOI/EOI) + for (var i = 0; i < data.length - 1; i++) { + var num = data.readUInt16BE(i) + if (num === 0xffd8) { + this.imgBuff = [] + } + else if (num === 0xffd9) { + this.push(new Buffer(this.imgBuff.concat([0xff, 0xd9]))) + this.imgBuff = null + } + if (this.imgBuff) + this.imgBuff.push(data[i]) + } + cb() +} + +exports.createStream = function() { + return new MJPGStream() +} \ No newline at end of file diff --git a/lib/motion.js b/lib/motion.js index 09c1ae1..90a3d5e 100644 --- a/lib/motion.js +++ b/lib/motion.js @@ -20,7 +20,8 @@ MotionLevelStream.prototype.write = function(data) { var motionMatch = motion.match(/group=(\d+);level=(\d+);threshold=(\d+)/) that.emit('data', { group: Number(motionMatch[1]), level: Number(motionMatch[2]), - threshold: Number(motionMatch[3]) }) + threshold: Number(motionMatch[3]), + ts: new Date }) }) } diff --git a/lib/motion2.js b/lib/motion2.js new file mode 100644 index 0000000..d289c27 --- /dev/null +++ b/lib/motion2.js @@ -0,0 +1,31 @@ +/** + * Parses out motion data from the Axis camera motion stream + * Implemented with the new Node streams using prototypes + */ +var Transform = require('stream').Transform + +var MotionLevelStream = function() { + Transform.call(this, {objectMode: true}) +} + +MotionLevelStream.prototype = Object.create( + Transform.prototype, { constructor: { value: MotionLevelStream }}) + +MotionLevelStream.prototype._transform = function(data, encoding, cb) { + var that = this + // Important bit of the stream is: group=0;level=0;threshold=11; + var match = data.toString().match(/group=\d+;level=\d+;threshold=\d+/g) + if (match) + match.forEach(function(motion) { + var motionMatch = motion.match(/group=(\d+);level=(\d+);threshold=(\d+)/) + that.push({ group: Number(motionMatch[1]), + level: Number(motionMatch[2]), + threshold: Number(motionMatch[3]), + ts: new Date }) + }) + cb() +} + +exports.createStream = function() { + return new MotionLevelStream() +} \ No newline at end of file diff --git a/lib/motion3.js b/lib/motion3.js new file mode 100644 index 0000000..c79984b --- /dev/null +++ b/lib/motion3.js @@ -0,0 +1,32 @@ +/** + * Parses out motion data from the Axis camera motion stream + * Implemented with the new Node streams + */ +var stream = require('stream'), + util = require('util') + +var MotionLevelStream = function() { + stream.Transform.call(this, {objectMode: true}) + + this._transform = function(data, encoding, cb) { + var that = this + // Important bit of the stream is: group=0;level=0;threshold=11; + var match = data.toString().match(/group=\d+;level=\d+;threshold=\d+/g) + if (match) + match.forEach(function(motion) { + var motionMatch = motion.match(/group=(\d+);level=(\d+);threshold=(\d+)/) + that.push({ group: Number(motionMatch[1]), + level: Number(motionMatch[2]), + threshold: Number(motionMatch[3]), + ts: new Date }) + }) + cb() + } + +} + +util.inherits(MotionLevelStream, stream.Transform) + +exports.createStream = function() { + return new MotionLevelStream() +} \ No newline at end of file diff --git a/lib/server.js b/lib/server.js index 2fc1f9d..0e0cd92 100644 --- a/lib/server.js +++ b/lib/server.js @@ -10,22 +10,22 @@ var axis = require('./axis').createClient(JSON.parse(require('fs').readFileSync( var handlers = { '/': function(req, res) { - res.writeHead(200, {'Content-Type': 'application/json'}); + res.writeHead(200, {'Content-Type': 'application/json'}) res.end(JSON.stringify({ version: require('../package.json').version, time: new Date, message: 'Welcome to AxisCam' - })); + })) }, - '/image': function(req, res) { + '/image': function(req, res, query) { res.statusCode = 200 res.setHeader('Content-Type', 'img/jpeg') - axis.createImageStream().pipe(res) + axis.createImageStream(query.size).pipe(res) }, - '/video': function(req, res) { + '/video': function(req, res, query) { res.statusCode = 200 res.setHeader('Content-Type', 'img/jpeg') - axis.createVideoStream().pipe(res) + axis.createVideoStream(query.size).pipe(res) }, '/audio': function(req, res) { res.statusCode = 200 @@ -35,23 +35,24 @@ var handlers = { }, '/motion': function(req, res) { res.statusCode = 200 - res.setHeader('Content-Tyoe', 'text/plain') + res.setHeader('Content-Type', 'text/plain') axis.createMotionStream().pipe(es.stringify()).pipe(res) } -}; +} var route = function(req, res) { var uri = url.parse(req.url, true) + var query = uri.query var path = uri.pathname if (handlers[path]) - handlers[path](req, res) + handlers[path](req, res, query) else { // console.log('Invalid URL requested: ' + path); res.writeHead(404, {'Content-Type': 'text/html'}) res.end('

404

Page not found

') } -}; +} http.createServer(route).listen(1337, '127.0.0.1') diff --git a/package.json b/package.json index 2fc57bc..57c4033 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,9 @@ } , "dependencies" : { "underscore" : "1.4.4" - , "request" : "2.16.6" - , "event-stream": "3.0.13" + , "request" : "2.20.0" + , "event-stream": "3.0.14" + , "file-on-write": "0.1.0" } , "devDependencies" : { "mocha" : "1.x" diff --git a/test/axis_test.js b/test/axis_test.js index a9862bd..6a81475 100644 --- a/test/axis_test.js +++ b/test/axis_test.js @@ -7,6 +7,8 @@ var assert = require('assert'), describe('axis', function() { + this.timeout(5000) + beforeEach(function() { this.axisCam = createClient(JSON.parse(require('fs').readFileSync('./settings.json'))) }) @@ -22,4 +24,28 @@ describe('axis', function() { }) }) + describe('createImageStream', function() { + it('should stream an image from an Axis camera', function(done) { + var imageStream = this.axisCam.createImageStream() + var soi = false + var buff + + imageStream.on('data', function(data) { + // Test for JPEG SOI + if (!soi) { + assert.equal(data.readUInt16BE(0), 0xffd8) + soi = true + } + buff = data + }) + + imageStream.on('end', function(data) { + // Test for JPEG EOI + assert.equal(buff.readUInt16BE(buff.length - 2), 0xffd9) + done() + }) + + }) + }) + }) \ No newline at end of file