如果说Nodejs将JavaScript的应用从网页端扩展到了服务器和操作系统端,Electron为JavaScript实现了跨平台应用的能力,那么SerialPort就是打通JavaScript软件与硬件的关键部件。著名的Johnny-Five物联网平台开发包的核心部件就是SerialPort,而Mozilla的WebThings

Gateway物联网关也是在SerialPort基础上实现的。这是因为,虽然已经历经了几十年光阴,串口在通讯传输速度上已经远远跟不上现代的通讯手段,但由于其廉价、简便、稳定可靠且经历时间验证的特点,在当前的工业与民生中仍然具有相当重要的地位,而SerialPort正是作为串口与计算机系统连接的中枢,成为互联网开发利器的JavaScript打通软件与硬件系统的关键。

更准确来说,SerialPort是运行在Node平台上的开发包,其安装也是通过npm install完成的。在SerialPort官方首页有一段简单的应用例程。装完SerialPort后,用这段程序就可以立即上手(当然,你还需要一个串口终端设备并且编写了终端部分的程序,如果没有,也可以通过本文后面的仿真器模拟出来一个。)

  1. const SerialPort = require('serialport')
  2. const Readline = require('@serialport/parser-readline')
  3. const port = new SerialPort(path, { baudRate: 256000 })
  4. const parser = new Readline()
  5. port.pipe(parser)
  6. parser.on('data', line => console.log(`> ${line}`))
  7. port.write('ROBOT POWER ON\n')
  8. //> ROBOT ONLINE

SerialPort包由SerialPort,Bindings,Interfaces和Parsers几部分组成,并且提供了一些比如串口列表等命令行工具(这些工具在旧版本里是SerialPort的一部分,但是目前版本中都可以独立运行了)。

1. Bindings

Bindings(绑定)是SerialPort连接软件与硬件平台的基础,一般来说,SerialPort库会自动探测并与平台绑定(Binding),不需要人为去调用Bindings来绑定Linux、Windows或是mac平台。当然,SerialPort也提供了一个修改Bindings的路径,这是通过内置的Stream包实现的(按照开发者的意图,用户永远不需要直接操作Bindings包)。

  1. var SerialPort = require('@serialport/stream');
  2. SerialPort.Binding = MyBindingClass;

通常来说,人为修改绑定在使用中并没有太大便利,但对于调试则非常重要,这是因为,在调试时用户可以修改绑定来调用一个仿真的串口。

2. Stream Interfaces

SerialPort的接口界面是通过流(Stream)实现的,流也是Nodejs的核心部件之一。在新建SerialPort时,需要提供串口的常规参数,包括portName端口号,baudRate波特率等等,主要包括下面这些属性。

  1. /**
  2. * @typedef {Object} openOptions
  3. * @property {boolean} [autoOpen=true] 此选项为真时会在自动打开串口.
  4. * @property {number=} [baudRate=9600] 波特率110~115200,支持自定义(这一点太强大了)
  5. * @property {number} [dataBits=8] 数据位,可以是8, 7, 6, or 5.
  6. * @property {number} [highWaterMark=65536] 数据缓冲区大小,最大64k.
  7. * @property {boolean} [lock=true] 锁定串口禁止其他设备使用,在windows平台下只能为true.
  8. * @property {number} [stopBits=1] 停止位: 1 or 2.
  9. * @property {string} [parity=none] 校验位:'none','even','mark','odd','space'.
  10. * @property {boolean} [rtscts=false] 流(flow)控制设置,以下几个都是
  11. * @property {boolean} [xon=false]
  12. * @property {boolean} [xoff=false]
  13. * @property {boolean} [xany=false]
  14. * @property {object=} bindingOptions 绑定选项,一般不需要设置
  15. * @property {Binding=} Binding 默认为静态属性`Serialport.Binding`.
  16. * @property {number} [bindingOptions.vmin=1] 参见linux下 termios命令
  17. * @property {number} [bindingOptions.vtime=0]
  18. */

一个SerialPort对象支持的属性包括baudRate,binding,isOpen和path,其中除了baudRate可以通过update方法修改为,其他属性均为只读。
一个SerialPort对象支持的事件包括open,error,close,data,drain,使用时可通过监听不同事件处理任务。
一个SrialPort对象支持open,update,close,read,write等方法,用于实现各种串口功能。
需要注意的是,随着版本的不断更迭,SerialPort的接口内容也不断有所调整,编程时要格外小心。例如,通常在新建串口前需要先获取可用的串口列表。官方提供了静态方法SerialPort.list()可以返回当前所有串口列表。但随着版本更新,官方文档中的SerialPort已经升级为Promise方式,文档中直接获取的方法会返回错误,需要修改为类似以下形式才能使用。

  1. SerialPort.list()
  2. .then((ports) => {
  3. ports.forEach( (port)=> {
  4. console.log(port.comName);
  5. console.log(port.pnpId);
  6. console.log(port.manufacturer);
  7. });
  8. })
  9. .catch((err)=>{
  10. console.log(err);
  11. });

3. Parsers

Parsers包继承自Nodejs Transform Stream,提供了一些实用的串口协议解析接口,比如对收到的大量串口数据,要根据特定的标识进行分割、解析的时候,就可以用Delimiter解析器,类似的解析器还包括Readline(分行读取),ByteLength(按长度截取),InterByteTimeout(超时)以及功能强大的Regex(正则表达式)解析器等等。

  1. const SerialPort = require('serialport')
  2. const Readline = require('@serialport/parser-readline')
  3. const port = new SerialPort('/dev/tty-usbserial1')
  4. const parser = new Readline()
  5. port.pipe(parser)
  6. parser.on('data', console.log)
  7. port.write('ROBOT PLEASE RESPOND\n')
  8. //也可以简化为
  9. const parser = port.pipe(new Readline());

SerialPort的Parsers大部分都是分割长数据,在实际使用中遇到了一个数据不足的问题,即由于传输速度低,一条消息被分割成好几条发送,在收到之后需要组合在一起(有特定的字符表示完整信息的结尾),在这种情况下,官方的Parsers均不合适,为了实现这一功能,自己手动写了一个ConcatParser,来实现将几段数据拼接的功能,ConcatParser同时提供了超时和超出缓冲区长度两个选项,以保证端口不会处于无限等待的状态。ConcatParser的实现如下。

  1. 'use strict';
  2. const { Transform } = require('stream');
  3. class ConcatParser extends Transform {
  4. constructor(options = {}) {
  5. super(options);
  6. try {
  7. if (typeof options.boundary === 'undefined') {
  8. throw new TypeError('"boundary" is not a bufferable object');
  9. }
  10. if (options.boundary.length === 0) {
  11. throw new TypeError('"boundary" has a 0 or undefined length');
  12. }
  13. this.includeBoundary = typeof options.includeBoundary !== 'undefined' ? options.includeBoundary : true;
  14. this.interval = typeof options.interval !== 'undefined' ? options.interval : 3000;
  15. this.maxBufferSize = typeof options.maxBufferSize !== 'undefined' ? options.maxBufferSize : 65535;
  16. this.intervalID = -1;
  17. this.boundary = Buffer.from(options.boundary);
  18. this.buffer = Buffer.alloc(0);
  19. } catch (error) {
  20. throw new Error('Init concatparser error');
  21. }
  22. }
  23. _transform(chunk, encoding, cb) {
  24. clearTimeout(this.intervalID);
  25. let data = Buffer.concat([this.buffer, chunk]),
  26. dataLength = data.length,
  27. position;
  28. if (dataLength >= this.maxBufferSize) {
  29. this.buffer = data.slice(0, this.maxBufferSize);
  30. data = Buffer.alloc(0);
  31. this.emitData();
  32. } else if ((position = data.indexOf(this.boundary)) !== -1) {
  33. this.buffer = data.slice(0, position + (this.includeBoundary ? this.boundary.length : 0));
  34. data = Buffer.alloc(0);
  35. this.emitData();
  36. }
  37. this.buffer = data;
  38. this.intervalID = setTimeout(this.emitData.bind(this), this.interval);
  39. cb();
  40. }
  41. emitData() {
  42. clearTimeout(this.intervalID);
  43. if (this.buffer.length > 0) {
  44. this.push(this.buffer);
  45. }
  46. this.buffer = Buffer.alloc(0);
  47. }
  48. _flush(cb) {
  49. this.emitData();
  50. cb();
  51. }
  52. }
  53. module.exports = ConcatParser;

4. 命令行接口

SerialPort库提供了几个命令行接口,可以通过npx直接运行。几个接口分别是SerialPort List,SerialPort REPL和SerialPort Terminal,使用方法为:

  1. npx @serialport/list [options] //可能需要先安装 npm @serialport/list
  2. npx @serialport/repl <port>
  3. npx @serialport/terminal -p <port> [options]

@serialport/list 用来列出系统中所有串口,接受格式化、版本等选项,可以通过-h 查看帮助。@serialport/repl提供了一个可以直接通过命令行操作串口的接口,可以通过命令行直接进行串口读写等操作。@serialport/terminal提供了一个简单的接口可以获取连接在串口上的终端设备的基础信息。

5. Mock串口仿真器

Mock是SerialPort库中最好用的功能之一,它通过模拟的硬件串口接口,让开发工作可以脱离硬件来完成测试。这在是DD或者TDD开发过程中是必不可少的。一个简单的Mock串口仿真器使用方法如下:

  1. const SerialPort = require('@serialport/stream')
  2. const MockBinding = require('@serialport/binding-mock')
  3. SerialPort.Binding = MockBinding
  4. // Create a port and enable the echo and recording.
  5. MockBinding.createPort('/dev/ROBOT', { echo: true, record: true })
  6. const port = new SerialPort('/dev/ROBOT')

Mock串口仿真器可以实现SerialPort库中所有功能的测试,通过Mock的源码可以看出这些接口及测试时检测的途径。

  1. const AbstractBinding = require('@serialport/binding-abstract')
  2. const debug = require('debug')('serialport/binding-mock')
  3. let ports = {}
  4. let serialNumber = 0
  5. function resolveNextTick(value) {
  6. return new Promise(resolve => process.nextTick(() => resolve(value)))
  7. }
  8. /**
  9. * Mock包,用来模拟硬件串口的实现
  10. */
  11. class MockBinding extends AbstractBinding {
  12. //如果record为真,这个缓存Buffer中会存入所有写到虚拟串口中的内容,可以检查串口传出的数据
  13. readonly recording: Buffer
  14. // 最后一次写入串口的缓存Buffer
  15. readonly lastWrite: null | Buffer
  16. //静态方法,用于创建一个虚拟串口
  17. static createPort(path: string, opt: { echo?: boolean, record?: boolean, readyData?: Buffer}): void
  18. //静态方法,用于复位所有虚拟串口
  19. static reset(): void
  20. // 静态方法,用于列出所有虚拟串口
  21. static list(): Promise<PortInfo[]>
  22. // 从一个虚拟串口Emit(发出)指定数据
  23. emitData(data: Buffer | string | number[])
  24. // 其他支持的标准串口接口方法
  25. open(path: string, opt: OpenOpts): Promise<void>
  26. close(): Promise<void>
  27. read(buffer: Buffer, offset: number, length: number): Promise<Buffer>
  28. write(buffer: Buffer): Promise<void>
  29. update(options: { baudRate: number }): Promise<void>
  30. set(options): Promise<void>
  31. get(): Promise<Flags>
  32. getBaudRate(): Promise<number>
  33. flush(): Promise<void>
  34. drain(): Promise<void>
  35. }