通过 Web Serial API读写数据

翻译文章,原文请访问Read from and write to a serial port

如果你不想看复杂的细节,想直接使用,看Web Serial API读写数据实战就可以,我将相关功能在里面做了封装,可以更简单,更快的上手。

web Serial API允许网站与serial设备通讯。

什么是Web Serial API

serial port是允许通过字节接收和发送数据的双向通信的接口。

web Serial API为网站提供了一个方法用javaScript去读写Serial设备。Serial设备通过一个设备上的Serial端口链接或者通过可移动蓝牙usb设备模仿Serial端口连接。

也就是说,Web Serial API 通过允许web站点和物理设备通信,为web和物理设备的交流搭建了一个桥梁,比如位控制或者3D打印机。

这个API也是WebUSB绝佳伙伴,因为系统级操作要求一些设备通过使用更高级的serail API去和Serial端口交流而不是低级别的USB API。

建议使用的场景

在教育场景,业余爱好者,还有工业部门,用户经常将外部设备链接到他们的电脑上。这些设备通常通过使用Serial链接的定制软件的微处理器控制。一些定制软件通过网络科技控制这些外部机器:

在一些场景中,网站通过一些手动安装好的代理和外部设备交互。在其它一些场景中,应用程序通过类似Electron的客户端尽性交互。也有一些场景中,用户被要求做一些附加的步骤,比如通过USB驱动复制一个编译好的应用到外部设备中。

上面的这些场景,都可以通过在网页直接和设备通讯来提神用户体验。

当前所处的阶段:

阶段 状态
1.提案阶段 已经完成
2.创建初稿 已经完成
3.收集反馈和迭代阶段 已经完成
4.试运行阶段 已经完成

|发行|完成|

使用 Web Serial API

功能探查

检查浏览器是否支持Web Serial API,使用下面的代码:

1
2
3
4
5
if ("serial" in navigator) {
// Web Serial API 是支持的,可以继续开发对应的功能
} else {
// 浏览器不支持Web Serial API,做对应的处理
}

打开serial 端口

Web Serial API是同步接口。这是非常重要的,避免了用户界面输入时造成的阻塞,可以实时监听接收数据。

想要打开serial端口,首先需要获取SerialPort对象的权限。可以通过引导用户点击或者触摸来触发调用navigator.serial.requestPort()方法来获取特定端口的权限,也可以通过navigator.serial.getPorts()来获取一个已经获得授权的serial 端口列表。

  • 获取特定端口的用户授权

    1
    2
    3
    4
    5
    document.querySelector('button').addEventListener('click', async () => {
    // Prompt user to select any serial port.
    const port = await navigator.serial.requestPort();
    });

  • 获取已经获得授权的端口的列表

1
2
const ports = await navigator.serial.getPorts();

navigator.serial.requestPort()可以接收一个可配置的对象作为参数,通过在对象中配置filters数组,能够用usbVendorId(供应商id)和usbProductId过滤出对应的设备。如下例:

1
2
3
4
5
6
7
8
const filters = [
{ usbVendorId: 0x2341, usbProductId: 0x0043 },
{ usbVendorId: 0x2341, usbProductId: 0x0001 }
];
const port = await navigator.serial.requestPort({ filters });

const { usbProductId, usbVendorId } = port.getInfo();


调用requestPort()函数会弹出一个列表共用户选择对应的设备,用户选择后会返回一个SerialPort对象。获取到SerialPort对象后,就可以用open()方法以约定的传输速度去打开serial 端口。open()方法接收一个对象配置参数,其中字段baudRate用来配置通过串行每秒发送数据的速度。如果传输数据错误,将可能接收到错误的数据。对于一些模拟串口的usb或者蓝牙设备,会自动忽略baudRate,因此设置成任何都是可以的。

例子

1
2
3
4
5
6
// Prompt user to select any serial port.
const port = await navigator.serial.requestPort();

// Wait for the serial port to open.
await port.open({ baudRate: 9600 });

除了baudRate,还有其它参数可以配置,如下:

  • dataBits: 每帧发送的数据量(7或者8)
  • parity: parity模式,可选值有noneevenodd
  • bufferSize: 读写的buffer数据大小,不能超过16MB
  • flowControl:流控制模式,none或者hardware

从串口中读取数据

流数据的输入输出在Web Serial API 中通过 Streams API来操作。

如果对Stream流还不熟悉,麻烦移步Streams API 概念 查看

串口链接好后,通过SerialPort的属性readbalewritable,可以获取到ReadableStream接口和WritableStream接口。这两个接口将被用来读写串口设备的数据。都用的是Unit8Array类型的二进制数据。

当接收到新数据时,port.readable.getReader.read()会以同步函数的形式返回布尔值done和数据value。如果done的值为true,表示串口已经关闭且数据不会再继续传进来。调用port.readable.getReader()会创建一个阅读器并且锁死readable`,当它被锁死时,串口是无法关闭的。

下面是一个打开串口后读取数据的例子:

1
2
3
4
5
6
7
8
9
10
const reader = port.readable.getReader();
while(true) {
const {value, done} = await reader.read();
if (done) {
reader.releaseLock();
break;
}
console.log(value)
}

像内存溢出,帧错误,或者奇偶校验错误时会导致非致命错误。对于非致命错误的处理,可以通过在顶部检查port.readable,然后在内部用try , catch进行捕获。这样做能行得通是因为当非致命错误发生时,会自动创建一个读取数据流。如果是一个致命的错误,比如说移除了外接设备,port.readable的值将成null

关于错误处理,可以参考下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
while (port.readable) {</font>
const reader = port.readable.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
// Allow the serial port to be closed later.
reader.releaseLock();
break;
}
if (value) {
console.log(value);
}
}
} catch (error) {
// TODO: Handle non-fatal read error.
}
}

如果串口设备发回的是文本,你可以像下面的例子一样用TextDecoderStream作为port.readable的管道。一个TextDecoderStream就是一个抓取流所有Unit8Array数据包并将其转换为字符串的转换流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const textDecoder = new TextDecoderStream();
const readableStreamClosed = port.readable.pipTo(textDecoder.writable);
const reader = textDecoder.readable.getReader();

// 监听来自串口设备的数据
while (true) {
const { value, done } = await reader.read();
if (done) {
// 允许串口关闭
reader.releaseLock();
break;
}
// 打印出来的值是一个字符串
console.log(value);
}

将数据写入到串口设备中去

port.writeable.getWriter.write()将数据发送到串口设备中去。调用port.writable.getWriter()releaseLock()方法在之后关闭端口。具体例子如下:

1
2
3
4
5
const writer = port.writable.getWriter();
const data = new Unit8Array([104,101,108,111]); // hello
await writer.write(data);
// 允许设备在之后关闭
writer.releaseLock()

通过TextEncoderStream通道传输数据到port.writable然后发送到设备,如下:

1
2
3
4
const textEncoder = new TextEncoderStream();
const writableStreamClosed = textEncoder.readable.pipTo(port.writable);
const writer = textEncoder.writable.getWriter();
await writer.write('hello');

关闭串口

如果串口没有被readable或者writable锁定的话使用port.close()就可以关闭串口。要解除锁定状态就需要在读取或者写入完成后调用releaseLock()方法。

关闭串口示例:

1
await port.close;

然后,当使用循环从串口设备中连续读取数据时,port.readable将一直处于被锁定的状态,直到错误发生为止。对于这种情况,可以调用reader.cancel()强制reader.read()立即返回{ value: undefined, done: true } ,然后允许循环去调用reader.releaseLock()
如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 本案例中不使用数据转换通道

let keepReading = true;
let reader;

async function readUntilClosed() {
while (port.readable && keepReading) {
reader = port.readable.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
// reader.cancel() 已经在点击时调用过了
break;
}
// Uint8Array 类型的数据
console.log(value);
}
} catch (error) {
// Handle error...
} finally {
// 允许串口在稍后关闭
reader.releaseLock();
}
}

await port.close();
}

const closedPromise = readUntilClosed();

document.querySelector('button').addEventListener('click', async () => {
// 用户点击取消读取数据的按钮
keepReading = false;
// 强制reader.read()立即返回结果
// 在上面的循环中调用 reader.releaseLock()
reader.cancel();
await closedPromise;
});

当使用了转换管道时关闭串口会更加复杂一点。首先调用reader.cancel(),然后调用writer.close()port.close()的流程会将错误传递到下一级的串口上去。因为错误的传播不会立即发生。你需要调用primosereadableStreamClosedwritableStreamClosedport.readableport.writablecancel前提前去探测。取消reader会造成流的中断,所以在readableStreamClosed或者writableStreamClosed你必须捕获并且忽略掉错误。所以,当使用了转换管道时,正确的关闭顺序是reader.cancel()readableStreamClosedwriter.close()port.close()。查看下面示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 使用管道传输

const textDecoder = new TextDecoderStream();
const readableStreamClosed = port.readable.pipeTo(textDecoder.writable);
const reader = textDecoder.readable.getReader();

// 监听来自设备的数据
while (true) {
const { value, done } = await reader.read();
if (done) {
reader.releaseLock();
break;
}
// 数据是一个字符串
console.log(value);
}

const textEncoder = new TextEncoderStream();
const writableStreamClosed = textEncoder.readable.pipeTo(port.writable);

reader.cancel();
await readableStreamClosed.catch(() => { /* Ignore the error */ });

writer.close();
await writableStreamClosed;

await port.close();

监听设备连接和断开

如果一个串口是由USB设备提供的,那么它就有可能连接上系统或者从系统中断开。当网站获取到对应端口的权限后,它就可以监听对应端口的connectdisconnect事件。

1
2
3
4
5
6
7
navigator.serial.addEventListener("connect", (event) => {
// 根据情况做对应处理
});

navigator.serial.addEventListener("disconnect", (event) => {
// 根据情况做对应处理
});

处理信号

建立了串口连接后,可以显式的查询和设置串口暴露的信号来检测设备和控制数据流。这些信号被定义为布尔值,比如Arduino设备提交了数据终端准备就绪的信号后将会进入一个规定的模式。

port.setSignals()port.getSignals() 分别用来设置输出信号输入信号。案例如下:

1
2
3
4
5
6
7
8
9
// 关闭串口断开新号
await port.setSignals({ break: false });

// 打开串口终端准备就绪新号
await port.setSignals({ dataTerminalReady: true });

// 关闭
await port.setSignals({ requestToSend: false });

1
2
3
4
5
const signals = await port.getSignals();
console.log(`Clear To Send: ${signals.clearToSend}`);
console.log(`Data Carrier Detect: ${signals.dataCarrierDetect}`);
console.log(`Data Set Ready: ${signals.dataSetReady}`);
console.log(`Ring Indicator: ${signals.ringIndicator}`);

数据流转换

当你接收到来自串口设备的数据时,你不一定能一次性获得所有数据。这些数据可能是一些随机大小的包,,更详细的信息查看流API,为了解决这个问题,你可以使用内建的传输流工具TextDecoderStream,也可以创建自己的流处理工具去解析进来的数据流并返回解析的数据。转换流工具位于串口设备和读取流循环之间。它可以进行任意的转换在数据被使用之前。可以把它想象成一条装配线:当一个小部件下线时,生产线上的每一步都会修改这个小部件,因此当它到达最终目的地时,它就是一个功能齐全的小部件。

举个例子,思考一下如何创建一个转换流对象来机遇换行符来使用流数据包。这个对象有一个transform()来处理每次新接受到的数据流,它可以将数据插入队列中,也能将数据在插入后存储起来。有一个flush()方法,它会在流关闭后被调用,用来处理还没有被处理的数据。

使用转换流对象时,你需要让通过通过管道流入的流通过。在《读取数据流》部分,原始的数据流只需要通过管道进入TextDecoderStream,因此我们需要调用pipeThrough()方法去传输通过我们新的LineBreakTransformer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class LineBreakTransformer {
constructor() {
// 在新的一行出现前接收数据流的容器
this.chunks = "";
}

transform(chunk, controller) {
// 添加新的chunk到chunks上
this.chunks += chunk;
// 基于换行符解析数据并发送出去
const lines = this.chunks.split("\r\n");
this.chunks = lines.pop();
lines.forEach((line) => controller.enqueue(line));
}

flush(controller) {
// 当流关闭时,冲掉剩余的区块(注:应该是处理掉的意思)
controller.enqueue(this.chunks);
}
}



1
2
3
4
5
6
7
const textDecoder = new TextDecoderStream();
const readableStreamClosed = port.readable.pipeTo(textDecoder.writable);
const reader = textDecoder.readable
.pipeThrough(new TransformStream(new LineBreakTransformer()))
.getReader();


对于调试串行设备通信问题,使用port.readable.tee()方法去拆分从串行设备流入流出的流。这两种流可以分出来独立使用或者你可以把他们在检查器中打印出来。

1
const [appReadable, devReadable] = port.readable.tee();

撤回已经授予串口设备的权限

调用SerialPort实例的forget()方法,可以清除掉已经授予给串口设备的权限。举个例子,一个教育类型的网站在一个公用的电脑上通过很多设备被使用,大量的用户授权累积导致用户体验很差,这个时候就可以清除授权。如下:

1
await port.forget();

由于 forgot() 在 Chrome 103 或更高版本中可用,请检查以下是否支持此功能(注:103是指版本吗,有点迷糊,当前我电脑最新版才是101.0.4951.64?):

1
2
3
if ("serial" in navigator && "forget" in SerialPort.prototype) {
// forget() is supported.
}

开发小提示

在chrome内建的页面对Web Serial API 进行debug是很容易的,打开about://device-log页面就可以看到所有与串口设备相关的事件。

浏览器支持

Web Serial API 在chrome 89之后的所有设备中都得到了支持。

Polyfill

在 Android 上,可以使用 WebUSB API 和 Serial API polyfill 支持基于 USB 的串行端口。 此 polyfill 仅限于可通过 WebUSB API 访问设备的硬件和平台,因为它尚未被内置设备驱动程序声明。