diff --git a/.gitignore b/.gitignore index 2d2b47d..2cc5f6a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea -node_modules \ No newline at end of file +node_modules +package-lock.json diff --git a/Gruntfile.js b/Gruntfile.js index 3f4f766..177c8e4 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -24,14 +24,20 @@ module.exports = function(grunt) { purgebadges: { command: 'node_modules/.bin/camo-purge' } + }, + mochaTest: { + test: { + src: [ 'test/**/*.js' ] + } } }); + grunt.loadNpmTasks('grunt-mocha-test'); grunt.loadNpmTasks('grunt-xo'); grunt.loadNpmTasks('grunt-contrib-concat'); grunt.loadNpmTasks('grunt-shell'); - grunt.registerTask('test', ['xo', 'shell:purgebadges']); + grunt.registerTask('test', ['xo', 'shell:purgebadges', 'mochaTest' ]); grunt.loadNpmTasks('grunt-jsdoc-to-markdown'); grunt.registerTask('doc', ['jsdoc2md', 'concat']); -}; \ No newline at end of file +}; diff --git a/index.js b/index.js index 53d0501..5b4b7ed 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,7 @@ /* eslint-disable no-unused-vars */ const net = require('net'); -const { EventEmitter } = require('events'); +const {EventEmitter} = require('events'); module.exports = function (options) { return new module.exports.Lirc(options); @@ -15,6 +15,7 @@ module.exports.Lirc = class Lirc extends EventEmitter { * @param {string} [config.host='127.0.0.1'] Host running LIRC. * @param {number} [config.port=8765] Port of running LIRC daemon. * @param {string} [config.path] Path to LIRC socket. + * @param {number} [config.queue_delay=250] Send queue delay. * @param {boolean} [config.reconnect=true] Automatically reconnect. * @param {number} [config.reconnect_delay=5000] Delay when reconnecting. */ @@ -30,12 +31,14 @@ module.exports.Lirc = class Lirc extends EventEmitter { _default('host', '127.0.0.1'); _default('port', 8765); _default('path', ''); + _default('queue_delay', 250); _default('reconnect', true); _default('reconnect_delay', 5000); this._connected = false; this._connecting = false; this._queue = []; + this._queueTimeout = null; this._readbuffer = []; this._socket = null; @@ -49,8 +52,8 @@ module.exports.Lirc = class Lirc extends EventEmitter { } }); - // Start handling send queue - this._handleQueue(); + // Clean the queue upon connect + this.on('connect', () => this._handleQueue()); if (this._autoconnect) { this.connect(); @@ -61,13 +64,22 @@ module.exports.Lirc = class Lirc extends EventEmitter { * @private * Handle send queue. */ - _handleQueue() { - const next = () => setTimeout(() => this._handleQueue(), 100); + _handleQueue(calledSelf = false) { + if (this._queueTimeout && !calledSelf) { + return; + } + + const next = () => { + this._queueTimeout = setTimeout( + () => this._handleQueue(true), + this._queue_delay + ); + }; - if (this._queue.length > 0) { + if (this._connected && this._queue.length > 0) { this._queue.shift().call().then(next).catch(next); } else { - next(); + this._queueTimeout = null; } } @@ -151,6 +163,7 @@ module.exports.Lirc = class Lirc extends EventEmitter { * @see available commands http://www.lirc.org/html/lircd.html * @param {string} command Command to send. * @param {string} [...args] optional parameters. + * @param {string} [callback] optional callback. * @return {Promise>} Resulting response from LIRC daemon. */ send(...args) { @@ -160,17 +173,13 @@ module.exports.Lirc = class Lirc extends EventEmitter { callback = args.pop(); } - let promise = new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { this._queue.push(() => { - this._send(args.join(' ')).then(resolve).catch(reject) + return this._send(args.join(' '), callback).then(resolve).catch(reject) }); - }); - if (typeof callback === 'function') { - promise.then(data => callback(null, data)).catch(callback); - } - - return promise; + setImmediate(() => this._handleQueue()); + }); } /** @@ -182,7 +191,7 @@ module.exports.Lirc = class Lirc extends EventEmitter { * @param {function} [callback] Optional callback. * @return {Promise>} Response from LIRC. */ - sendOnce(remote, button, repeat = '', callback) { + sendOnce(remote, button, repeat, callback) { return this.send('send_once', remote, button, repeat, callback); } @@ -253,11 +262,16 @@ module.exports.Lirc = class Lirc extends EventEmitter { {host: this._host, port: this._port}; this._socket = net.connect(options, () => { - this._socket.removeListener('error', reject); - this.emit('connect'); - resolve(); + if (this._socket !== null) { + this._socket.removeListener('error', reject); + this._connected = true; + this._connecting = false; + this.emit('connect'); + resolve(); + } }); + this._connecting = true; this._socket.once('error', reject); this._socket.on('close', () => this.emit('disconnect')); @@ -289,11 +303,13 @@ module.exports.Lirc = class Lirc extends EventEmitter { this._readbuffer.splice(0, this._readbuffer.length); this._socket = null; - Object.keys(events).forEach(ev => { - socket.removeAllListeners(ev); - }); + if (socket !== null) { + events.forEach(ev => { + socket.removeAllListeners(ev); + }); - socket.end(); + socket.end(); + } if (typeof callback === 'function') { callback(); diff --git a/package.json b/package.json index b7dea9d..5bb942a 100644 --- a/package.json +++ b/package.json @@ -34,12 +34,20 @@ "homepage": "https://github.com/hobbyquaker/lirc-client", "devDependencies": { "camo-purge": "latest", + "chai": "^4.1.2", + "chai-as-promised": "^7.1.1", "grunt": "latest", "grunt-cli": "latest", "grunt-contrib-concat": "latest", "grunt-jsdoc-to-markdown": "latest", + "grunt-mocha-test": "^0.13.3", "grunt-shell": "latest", "grunt-xo": "latest", + "mitm": "^1.3.3", + "mocha": "^4.0.1", + "promise-polyfill": "^6.1.0", + "sinon": "^4.1.3", + "sinon-chai": "^2.14.0", "xo": "latest" }, "xo": { diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..a3c279a --- /dev/null +++ b/test/index.js @@ -0,0 +1,322 @@ + +const Lirc = require('../index'); +const Mitm = require('mitm'); +const chai = require('chai'); +const promiseBuiltin = Promise; +const promisePolyfill = require('promise-polyfill'); +const sinon = require('sinon'); + +const ltrimg = string => string.replace(/^ +/gm, ''); +const should = chai.should(); + +chai.use(require('sinon-chai')); +chai.use(require('chai-as-promised')); + +describe('Lirc', () => { + let lirc; + let mitm; + let socket; + let clock; + + let versionResponse = ltrimg(` + BEGIN + version + SUCCESS + DATA + 1 + 0.9.4c + END + `); + + let errorResponse = ltrimg(` + BEGIN + + ERROR + DATA + 1 + bad send packet + END + `); + + beforeEach(() => { + global.Promise = promisePolyfill; + clock = sinon.useFakeTimers({ + toFake: [ 'setTimeout', 'setImmediate', 'setInterval', 'nextTick' ], + }); + + mitm = Mitm(); + mitm.once('connection', _socket => { + socket = _socket; + socket.setEncoding('utf8'); + }); + + lirc = new Lirc({ + autoconnect: false, + }); + }); + + afterEach(() => { + lirc.disconnect(); + mitm.disable(); + clock.restore(); + global.Promise = promiseBuiltin; + }); + + describe('#_handleQueue()', () => { + it('should do nothing while disconnected', () => { + lirc._queue.push(() => Promise.resolve()); + lirc._handleQueue(); + should.not.exist(lirc._queueTimeout); + }); + + it('should set #_queueTimeout to the current timeout timer', () => { + lirc._queue.push(sinon.stub().resolves()); + should.not.exist(lirc._queueTimeout); + lirc._connected = true; + lirc._handleQueue(); + clock.next(); + should.exist(lirc._queueTimeout); + }); + + it('should stop when #_queue is empty', () => { + lirc._queue.push(sinon.stub().resolves()); + lirc._connected = true; + lirc._handleQueue(); + clock.next(); + should.exist(lirc._queueTimeout); + clock.tick(300); + should.not.exist(lirc._queueTimeout); + }); + + it('should call each function in #_queue', () => { + let callback1 = sinon.stub().resolves(); + let callback2 = sinon.stub().resolves(); + lirc._queue.push(callback1); + lirc._queue.push(callback2); + lirc._connected = true; + lirc._handleQueue(); + clock.next(); + callback1.should.have.been.called; + callback2.should.not.have.been.called; + clock.runAll(); + callback2.should.have.been.called; + should.not.exist(lirc._queueTimeout); + }); + }); + + + describe('#_read()', () => { + it('should handle standard messages', done => { + lirc.once('message', (err, data) => { + should.not.exist(err); + data.should.deep.equal(['0.9.4c']); + done(); + }); + + lirc._read(versionResponse); + }); + + it('should handle error messages', done => { + lirc.once('message', (err, data) => { + err.should.equal('bad send packet'); + data.should.deep.equal(['bad send packet']); + done(); + }); + + lirc._read(errorResponse); + }); + + it('should handle received button presses', done => { + lirc.once('receive', (remote, button, status, thing) => { + remote.should.equal('remote1'); + button.should.equal('button1'); + status.should.equal('00'); + thing.should.equal('0000000000000000'); + done(); + }); + + lirc._read('0000000000000000 00 button1 remote1\n'); + }); + }); + + describe('#_send()', () => { + beforeEach(() => { + lirc.connect().then(() => + socket.once('data', (...args) => { + socket.write(versionResponse); + }) + ); + }); + + it('should write data to socket', done => { + lirc.connect().then(() => { + lirc._send('test'); + + socket.once('data', string => { + string.should.equal('test\n'); + done(); + }); + }); + + clock.runAll(); + }); + + it('should call callback with data if supplied', done => { + lirc.connect().then(() => { + lirc._send('version', (err, data) => { + data.should.deep.equal([ '0.9.4c' ]); + done(); + }); + }); + + clock.runAll(); + }); + + it('should resolve with data', done => { + lirc.connect().then(() => { + lirc._send('version').then(data => { + data.should.deep.equal([ '0.9.4c' ]); + done(); + }); + }); + + clock.runAll(); + }); + }); + + describe('#send()', () => { + it('should call #_handleQueue() on next tick', () => { + lirc._handleQueue = sinon.spy(lirc, '_handleQueue'); + lirc.send(); + clock.next(); + lirc._handleQueue.should.have.been.called; + }); + + it('should append a function to the #_queue when when called', () => { + lirc._send = sinon.stub(lirc, '_send').resolves(); + lirc._connected = true; + lirc._queue.length.should.equal(0); + lirc.send(); + lirc._queue.length.should.equal(1); + lirc._queue[0].should.be.a('function'); + }); + + it('should create a function that calls #_send() with expected args', () => { + lirc._send = sinon.stub(lirc, '_send').resolves(); + lirc._connected = true; + lirc.send('test', 'one'); + lirc._queue[0](); + lirc._send.should.have.been.calledWith('test one'); + }); + + it('should call #_send() with callback function if callback is passed', () => { + let callback = sinon.stub(); + lirc._send = sinon.stub(lirc, '_send').resolves(); + lirc._connected = true; + lirc.send('test', 'one', callback); + lirc._queue[0](); + lirc._send.should.have.been.calledWith('test one', callback); + }); + }); + + describe('#sendOnce()', () => { + it('should call #send() with expected args', () => { + lirc.send = sinon.spy(lirc, 'send'); + lirc.sendOnce('tv', 'power'); + lirc.send.should.have.been.calledWith('send_once', 'tv', 'power'); + }); + }); + + describe('#sendStart()', () => { + it('should call #send() with expected args', () => { + lirc.send = sinon.spy(lirc, 'send'); + lirc.sendStart('tv', 'power'); + lirc.send.should.have.been.calledWith('send_start', 'tv', 'power'); + }); + }); + + describe('#sendStop()', () => { + it('should call #send() with expected args', () => { + lirc.send = sinon.spy(lirc, 'send'); + lirc.sendStop('tv', 'power'); + lirc.send.should.have.been.calledWith('send_stop', 'tv', 'power'); + }); + }); + + describe('#list()', () => { + it('should call #send() with expected args', () => { + lirc.send = sinon.spy(lirc, 'send'); + lirc.list('tv'); + lirc.send.should.have.been.calledWith('list', 'tv'); + }); + }); + + describe('#version()', () => { + it('should call #send() with expected args', () => { + lirc.send = sinon.spy(lirc, 'send'); + lirc.version(); + lirc.send.should.have.been.calledWith('version'); + }); + }); + + describe('#connect()', () => { + it('should set #_connecting to true', () => { + lirc._connecting.should.equal(false); + lirc.connect(); + lirc._connecting.should.equal(true); + }); + + it('should set #_connected to true upon connection', () => { + lirc._connected.should.equal(false); + lirc.connect(); + clock.runAll(); + lirc._connected.should.equal(true); + }); + + it('should establish a connection', () => { + let callback = sinon.spy(); + mitm.on('connection', callback); + lirc.connect(); + clock.runAll(); + callback.should.have.been.called; + }); + + it('should call callback when connected', () => { + let callback = sinon.spy(); + lirc.connect(callback); + clock.runAll(); + callback.should.have.been.called; + }); + }); + + describe('#disconnect()', () => { + it('should clean up after itself', () => { + let end = sinon.spy(); + let removeAllListeners = sinon.spy(); + + lirc._connected = true; + lirc._connecting = true; + lirc._readbuffer.push('foo', 'bar'); + lirc._socket = { end, removeAllListeners }; + + lirc.disconnect(); + + lirc._connected.should.equal(false); + lirc._connecting.should.equal(false); + lirc._readbuffer.should.deep.equal([]); + should.not.exist(lirc._socket); + + end.should.have.been.called; + removeAllListeners.should.have.been.calledWith('close'); + }); + + it('should resolve when disconnected', () => { + lirc.disconnect().should.be.fulfilled; + clock.runAll(); + }); + + it('should call callback when disconnected', done => { + lirc.disconnect(done); + }); + }); +});