UNPKG

@jmkristian/node-vara

Version:

Communicate via radio in the style of node net, using a VARA FM or VARA HF modem.

486 lines (454 loc) 16.9 kB
/** Utilities for exchanging data via VARA FM or VARA HF. */ const EventEmitter = require('events'); const Net = require('net'); const Readline = require('readline'); const Stream = require('stream'); const DefaultLogID = 'VARA'; const KByte = 1 << 10; const ERR_INVALID_ARG_VALUE = 'ERR_INVALID_ARG_VALUE'; const LogNothing = { child: function(){return LogNothing;}, trace: function(){}, debug: function(){}, info: function(){}, warn: function(){}, error: function(){}, fatal: function(){}, }; function getLogger(options, that) { if (!(options && options.logger)) { return LogNothing; } else if (that) { return options.logger.child({'class': that.constructor.name}); } else { return options.logger; } } function newError(message, code) { const err = new Error(message); err.code = code; return err; } function getDataSummary(data) { if (Buffer.isBuffer(data)) { if (data.length <= 32) { return data.toString('binary').replace(/\r/g, '\\r'); } else { return data.toString('binary', 0, 32).replace(/\r/g, '\\r') + '...'; } } else { var s = data + ''; if (s.length <= 32) { return s.replace(/\r/g, '\\r'); } else { return s.substring(0, 32).replace(/\r/g, '\\r') + '...'; } } } /** Pipes data from a Readable stream to a fromVARA method. */ class DataReceiver extends Stream.Writable { constructor(options, target) { super(); // The defaults are good. if (!target) { this.log.warn(newError('no target', ERR_INVALID_ARG_VALUE)); } this.log = getLogger(options, this); this.target = target; } _write(chunk, encoding, callback) { try { if (!Buffer.isBuffer(chunk)) { throw newError(`DataReceiver._write chunk isn't a Buffer`, ERR_INVALID_ARG_VALUE); } if (this.log.trace()) { this.log.trace('< %s', getDataSummary(chunk)); } this.target.fromVARA(chunk); callback(null); } catch(err) { this.log.warn(err); callback(err); } } } /** Exchanges bytes between one local call sign and one remote call sign. */ class Connection extends Stream.Duplex { /* It's tempting to simply provide the dataSocket to the application, but that doesn't work. The dataSocket doesn't always emit close when you expect. And when the application calls connection.end(data), we must wait for VARA to report that it has transmitted all the data before closing the dataSocket. And it's not clear from the documentation whether VARA closes the dataSocket when the VARA connection is disconnected. */ constructor(options, dataSocket) { super({ allowHalfOpen: true, emitClose: false, // emitClose: true doesn't always emit close. readableObjectMode: false, readableHighWaterMark: 4 * KByte, writableObjectMode: false, writableHighWaterMark: 4 * KByte, }); this.log = getLogger(options, this); this.dataSocket = dataSocket; this.bufferLength = 0; var that = this; this.on('end', function onEnd(err) { that._ended = true; }); this.on('close', function onClose(err) { if (!that._ended) { that._ended = true; that.emit('end'); } that._closed = true; }); } _write(data, encoding, callback) { if (this._ended) { callback(newError('Connection is ended', 'ERR_STREAM_WRITE_AFTER_END')); } else { if (this.log.debug()) { this.log.debug('data> %s', getDataSummary(data)); } this.bufferLength += data.length; this.dataSocket.write(data, encoding, callback); } } _read(size) { this._pushable = true; // fromVARA calls this.push. } _final(callback) { this.log.debug('_final'); callback(); } _destroy(err, callback) { this.log.debug('_destroy'); // The documentation seems to say this.destroy() should emit // 'end' and 'close', but I find that doesn't always happen. // This works reliably: if (!this._ended) { this._ended = true; this.emit('end'); } if (!this._closed) { this._closed = true; this.emit('close'); } delete this.dataSocket; callback(err); } fromVARA(buffer) { if (this.log.debug()) { this.log.debug('data< %s', getDataSummary(buffer)); } if (this._pushable) { this._pushable = this.push(buffer); } else { this.emit('error', new Error( 'VARA receive buffer overflow: ' + getDataSummary(buffer) )); } } } // Connection /** Similar to net.Server, but for VARA connections. Each 'connection' event provides a Duplex stream for exchanging data via one VARA connection. The remote call sign is connection.remoteAddress. To disconnect, call connection.end() or destroy(). The connection emits a 'close' event when VARA is disconnected. */ class Server extends EventEmitter { constructor(options, onConnection) { super(); if (!(options && options.port)) throw new Error('no options.port'); this.log = getLogger(options, this); this.options = options; this.listening = false; this.outputBuffer = []; if (onConnection) this.on('connection', onConnection); } listen(options, callback) { this.log.trace('listen(%o)', options); if (!(options && options.host && (!Array.isArray(options.host) || options.host.length > 0))) { throw newError('no options.host', ERR_INVALID_ARG_VALUE); } if (this.listening) { throw newError('Server is already listening.', 'ERR_SERVER_ALREADY_LISTEN'); } this.listening = true; this.host = options.host; if (callback) { this.on('listening', callback); } this.connectVARA(); } close(callback) { this.log.trace('close()'); if (!this.listening) { if (callback) callback(new Error('Server is already closed')); } else { this.listening = false; if (this.socket) this.socket.destroy(); this.emit('close'); if (callback) callback(); } } toVARA(line, waitFor) { this.log.trace('toVARA(%s, %s)', line, waitFor); this.outputBuffer.push(line); this.outputBuffer.push(waitFor); if (this.waitingFor == null) { this.flushToVARA(); } } flushToVARA() { this.log.trace('flushToVara'); if (this.outputBuffer.length) { var line = this.outputBuffer.shift(); var waitFor = this.outputBuffer.shift(); this.log.debug(`> ${line}`); this.lastRequest = line; this.waitingFor = waitFor && waitFor.toLowerCase(); this.socket.write(line + '\r'); } } fromVARA(line) { var parts = line.split(/\s+/); var part0 = parts[0].toLowerCase(); switch(part0) { case '': case 'busy': case 'iamalive': case 'ptt': // boring this.log.trace(`< ${line}`); break; default: this.log.debug(`< ${line}`); } switch(part0) { case '': break; case 'pending': this.connectDataSocket(); break; case 'cancelpending': if (!this.isConnected) { this.disconnectData(); } break; case 'connected': this.connectData(parts); break; case 'disconnected': this.isConnected = false; this.disconnectData(parts[1]); break; case 'buffer': if (this.endingData && (this.connection.bufferLength = parseInt(parts[1])) <= 0) { this.endingData = false; this.disconnectData(); /* This isn't foolproof. If we send data simultaneous with receiving 'BUFFER 0' from VARA, we might call disconnectData prematurely and consequently VARA would lose the data. */ } break; case 'missing': this.log.error(`< ${line}`); this.close(); break; case 'ok': if (this.lastRequest == 'LISTEN ON') { this.log.trace(`lastRequest ${this.lastRequest}`); this.emit('listening', { host: (!Array.isArray(this.host) || this.host.length > 1) ? this.host : this.host.length == 1 ? this.host[0] : undefined, }); } break; case 'wrong': if (this.waitingFor) { this.emit('error', newError(`TNC responded "${line}"` + ` (not "${this.waitingFor}")` + ` in response to "${this.lastRequest}"`)); } else { this.emit('error', newError(`TNC responded "${line}"`)); } this.waitingFor = undefined; this.flushToVARA(); break; default: // We already logged it. No other action needed. } if (this.waitingFor == part0) { this.waitingFor = undefined; this.lastRequest = undefined; this.flushToVARA(); } } _createConnection(connectOptions, onConnected) { const options = Object.assign({host: '127.0.0.1'}, connectOptions);; delete options.dataPort; delete options.logger; delete options.Net; this.log.trace('%s.createConnection(%s)', this.options.Net ? 'options.Net' : 'Net', options); return (this.options.Net || Net).createConnection(options, onConnected); } connectVARA() { this.log.trace(`connectVARA`); try { if (this.socket) { this.socket.destroy(); } const that = this; const myCallSigns = Array.isArray(this.host) ? this.host.join(' ') : this.host + ''; this.socket = this._createConnection(this.options, function onConnected(err) { that.log.trace('socket connected %s', err || ''); const reader = Readline.createInterface({ input: that.socket, }); reader.on('line', function(line) { that.fromVARA(line); }); reader.on('error', function(err) {}); that.toVARA('VERSION', 'VERSION'); that.toVARA(`MYCALL ${myCallSigns}`, 'OK'); // that.toVARA(`CHAT OFF`, 'OK'); // seems to be unnecessary that.toVARA('LISTEN ON', 'OK'); }); this.socket.on('error', function(err) { that.log.trace(err, 'socket'); that.emit('error', err); if (err && (err.code == 'ECONNREFUSED' || err.code == 'ETIMEDOUT')) { that.close(); } }); // VARA might close the socket. The documentation doesn't say. this.socket.on('close', function(info) { that.log.trace('socket close %s', info || ''); if (that.listening) { that.connectVARA(); } }); ['timeout', 'end', 'finish'].forEach(function(event) { that.socket.on(event, function(info) { that.log.debug('socket emitted %s %s', event, info || ''); }); }); } catch(err) { this.emit('error', err); } } connectDataSocket() { const that = this; const emitEvent = function(event, err) { if (that.connection) { that.connection.emit(event, info); } else { that.emit(event, info); } }; try { if (!this.dataSocket) { this.log.debug('connectDataSocket'); this.dataSocket = this._createConnection( Object.assign({}, this.options, {port: this.options.dataPort || this.options.port + 1}), function onConnected(err) { that.log.trace('dataSocket connected %s', err || ''); }); ['error', 'timeout'].forEach(function(event) { that.dataSocket.on(event, function onDataSocketEvent(info) { that.log.trace('dataSocket %s %s', event, info || ''); emitEvent(event, info); }); }); ['end', 'close'].forEach(function(event) { that.dataSocket.on(event, function(err) { if (err) that.log.warn('dataSocket %s %s', event, err); else that.log.debug('dataSocket %s', event); that.disconnectData(err); delete that.dataSocket; }); }); } } catch(err) { emitEvent('error', err); } } connectData(parts) { this.log.debug('connectData(%o)', parts); if (this.dataSocket && this.dataReceiver) { // It appears we're re-using an old dataSocket. this.dataSocket.unpipe(this.dataReceiver); delete this.dataReceiver; } this.connectDataSocket(); // if necessary this.connection = new Connection(this.options, this.dataSocket); var that = this; ['end', 'close'].forEach(function(event) { that.connection.on(event, function(err) { that.log.debug('connection %s %s', event, err || ''); }); }); this.connection.on('finish', function(err) { if (err) that.log.warn('connection finish %s', err); else that.log.debug('connection finish'); if (that.isConnected) { if (that.connection.bufferLength <= 0) { that.disconnectData(err); } else { /* If we send DISCONNECT now, VARA will lose the data in its buffer. So, wait until VARA reports that its buffer is empty and then end the dataSocket. */ that.endingData = true; } } }); this.connection.remoteAddress = parts[1]; this.connection.localAddress = parts[2]; this.dataReceiver = new DataReceiver(this.options, this.connection); this.dataSocket.pipe(this.dataReceiver); this.isConnected = true; this.emit('connection', this.connection); } disconnectData(err) { if (err) this.log.debug(err, 'disconnectData'); else this.log.debug('disconnectData', err); if (this.isConnected) { this.toVARA('DISCONNECT'); } this.isConnected = false; this.endingData = false; if (this.dataReceiver) { if (this.dataSocket) { this.dataSocket.unpipe(this.dataReceiver); } delete this.dataReceiver; } if (this.connection) { try { this.connection.destroy(err); } catch(err) { this.log.error(err); } delete this.connection; } } } // Server exports.Server = Server;