Home Reference Source Repository

src/serial.js

import {EventEmitter} from 'events'
import SerialPort from 'serialport'

/**
 * A node-serialport wrapper that keeps track of port state and allows from on-the-fly
 * port path/name changes (automatically attaches/detaches event listeners and keeps config).
 */
export default class Serial extends EventEmitter {
	/**
	 * Constructor: the place for setting the baud rate and parser.
	 * @param {Function} [parser=raw] A node-serialport parser.
	 * @param {Number} [baud=19200] Desired baud rate.
	 */
	constructor(logger, parser, baud = 19200) {
		super()

		/**
		 * Holds the current node-serialport instance.
		 * @type {!SerialPort}
		 */
		this._port = undefined

		/**
		 * Holds the desired serial baud rate.
		 * @type {Number}
		 */
		this._baud = baud

		/**
		 * Holds the parsing function.
		 * @type {!Function}
		 */
		this._parser = parser

		/**
		 * Holds the current port path.
		 * @type {!String}
		 */
		this._path = undefined

		/**
		 * Holds current state. Possible values: disconnect, open, close, disconnect_force.
		 * @type {String}
		 */
		this._state = 'close'

		/**
		 * Logger instance. (Bunyan API).
		 * @type Object
		 */
		this._log = logger
	}

	/**
	 * Sets a new path to all future serialport instances. If a port is already open,
	 * it is automatically closed and a new one is opened with the new path (keeps event listeners).
	 * @emits data(packet): New data arrived (after being parsed by Serial#_parser).
	 * @emits stateChange(state): Serial#_state has changed.
	 * @emits error(error): An error ocurred in node-serialport.
	 * @return {Promise} When path is changed and the port recreated/reopened.
	 */
	setPath(path) {
		this._log.debug('serial.setPath')
		// Guard for already changed port...
		if (path === this._path) {
			this._log.trace('serial._path needs no change', path)
			return Promise.resolve()
		}

		return (new Promise(resolve => {
			// Any _port recreation will open this new path
			this._log.trace('serial._path changed to', path)
			this._path = path

			// Destroy and recreate _port when necessary
			if (this._port) {
				this._log.trace('serial._path changed: recreating existing port')
				const open = this._port.isOpen()
				this._destroyPort()
					.then(() => open ? this.open() : this._createPort())
					.then(resolve)
			} else {
				resolve()
			}
		}))
			.then(() => this.emit('pathChange', path))
	}

	/**
	 * Opens the serialport, creating it if needed.
	 * @emits data(packet): New data arrived (after being parsed by Serial#_parser).
	 * @emits stateChange(state): Serial#_state has changed.
	 * @emits error(error): An error ocurred in node-serialport.
	 * @return {Promise} Resolved when the serial port is open.
	 */
	open() {
		this._log.debug('serial.open')
		return new Promise(resolve => {
			// No port? Create it!
			if (!this._port) {
				this._log.trace('port does not exist')
				return this._createPort()
					.then(() => this.open())
					.then(resolve)
			}

			// Already open = nothing to do
			if (this._port.isOpen()) {
				this._log.trace('port already open')
				return resolve()
			}

			// Open the port
			this._log.trace('calling serial._port.open')
			this._port.open(resolve)
		})
	}

	/**
	 * Closes the serialport.
	 * @emits stateChange(state): Serial#_state} has changed.
	 * @emits error(error): An error ocurred in node-serialport.
	 * @return {Promise} Resolved when the serialport is closed.
	 */
	close() {
		this._log.debug('serial.close')
		// Already closed/no port = nothing to do
		if (!this._port || !this._port.isOpen()) {
			this._log.trace('port already closed/does not exist')
			return Promise.resolve()
		}

		this._log.trace('calling serial._port.close')
		return new Promise(resolve => this._port.close(resolve))
	}

	/**
	 * Destroys the current serialport instance. Removing all listeners and closing it beforehand.
	 * @protected
	 * @emits stateChange(state): Serial#_state has changed.
	 * @returns {Promise} Resolves when all is done.
	 */
	_destroyPort() {
		this._log.debug('serial._destroyPort')
		if (!this._port) {
			return Promise.resolve()
		}

		return new Promise(resolve => {
			this._port.removeAllListeners()

			// Only close if necessary
			if (!this._port.isOpen()) {
				this._log.trace('port not open, removing it')
				this._port = undefined
				return resolve()
			}

			this._log.trace('port open, closing')
			this._port.close(error => {
				if (error) {
					this._log.error('Error closing port!', error)
				}

				// Must keep going
				this._port = undefined
				resolve()
			})
		})
	}

	/**
	 * Creates the serialport instance and attaches all relevant event listeners
	 * that forward data and errors and keep track of state.
	 * @protected
	 * @emits data(packet): New data arrived (after being parsed by Serial#_parser).
	 * @emits stateChange(state): Serial#_state has changed.
	 * @emits error(error): An error ocurred in node-serialport.
	 * @return {Promise} A resolved Promise for easy chaining in {@link Serial#setPath}.
	 */
	_createPort() {
		// Only create if it does not exist
		if (this._port) {
			return Promise.resolve()
		}

		// Create serialport instance
		this._port = new SerialPort(this._path, {
			baudRate: this._baud,
			parser: this._parser,
			autoOpen: false
		})

		// Register event listeners
		this._port.on('data', data => this.emit('data', data))
		this._port.on('error', error => {
			this._log.fatal(error)
			this.emit('error', error)
		})

		// State tracking
		this._port.on('open', () => this._updateState('open'))
		this._port.on('disconnect', () => this._updateState('disconnect_force'))
		this._port.on('close', () => {
			// Detect safe disconnections
			if (this._state === 'disconnect_force') {
				return this._updateState('disconnect')
			}
			this._updateState('close')
		})

		return Promise.resolve()
	}

	/**
	 * Shortcut for updating state that changes this._state and emits
	 * a stateChange event with one call.
	 * @protected
	 * @param {String} state New state.
	 * @emits stateChange(state): Serial#_state has changed.
	 */
	_updateState(state) {
		this._state = state
		this.emit('stateChange', state)
	}
}

/**
 * Lists available ports in the system with SerialPort.list.
 * Tries to fill out vendorId and productId fields in Windows from pnpId.
 * @return {Promise<Array>} Array of port information objects.
 */
export function listPorts() {
	return new Promise(resolve => {
		SerialPort.list((err, ports) => {
			if (err) {
				// TODO: reject promise with error, deal with it in Station#getAvailablePorts
				return resolve([])
			}

			// Fill out some missing fields if possible
			ports = ports.map(port => {
				if ((!port.vendorId || !port.productId) && port.pnpId) {
					port.vendorId = '0x' + /VID_([0-9,A-Z]*)&/.exec(port.pnpId)[1].toLowerCase()
					port.productId = '0x' + /PID_([0-9,A-Z]*)&/.exec(port.pnpId)[1].toLowerCase()
				}

				return port
			})

			resolve(ports)
		})
	})
}