Web Serial API,web端通过串口与硬件通信

15,909 阅读12分钟

Web Serial API

本文主要是翻译:https://web.dev/serial/该博客内容,用于学习该API的使用,并且用于项目需求开发。关注我公众号:小笑残虹,了解更多关于Web Serial API的踩坑经验分享,视频分享版:https://www.bilibili.com/video/BV1N54y1L7Wz/

Web Serial API注意事项

有好多同学加我微信请教关于该API的问题,所以特此在此罗列一下大家遇到的问题:

  • 该API是JS本身navigator对象上就独有的,所以与任何框架开发都没有太大的关系,不管你是用Vue还是React开发。
  • 要使用该API需要服务器使用https协议,这也是为什么很多同学问我本地开发明明没有问题,部署到线上这个API就不管用的原因。同时呢,本地开发建议使用http://localhost:端口号。
  • 遇到问题了,大家一定要先冷静,学着去分析原因,不要不思考,多读 web.dev/serial/ 这篇文章,我当时遇到问题就是读了好多遍这篇文章才解决问题的,相信大家也可以。
  • 最后我的wx:xxch-168,或者公众号:小笑残虹,有问题大家一起交流。

什么是Web串行API?

串口是一个双向通信接口,允许字节发送和接收数据。

Web Serial API为网站提供了一种使用JavaScript对串行设备进行读写的方法。串行设备可以通过用户系统上的串行端口连接,也可以通过模拟串行端口的可移动USB和蓝牙设备连接。

换句话说,Web Serial API通过允许网站与串行设备(如微控制器和3D打印机)通信来连接网络和物理世界。

这个API也是WebUSB的好伙伴,因为操作系统要求应用程序使用它们的高级串行API而不是低级的USB API与一些串行端口通信。

建议用例

在教育、业余爱好者和工业部门,用户连接外围设备到他们的计算机。这些设备通常由微控制器通过定制软件使用的串行连接来控制。一些控制这些设备的定制软件是通过网络技术构建的:

  • Arduino Create
  • Betaflight Configurator
  • Espruino Web IDE
  • Microsoft MakeCode

在某些情况下,网站通过用户手动安装的代理应用程序与设备通信。在其他情况下,应用程序是通过诸如Electron这样的框架以打包应用程序的形式交付的。在其他情况下,用户需要执行额外的步骤,例如通过USB闪存驱动器将编译后的应用程序复制到设备上。

在所有这些情况下,通过提供网站与其控制的设备之间的直接交流,用户体验将得到改善。

如何使用Web Serial API

特征检测

检查浏览器是否支持Web Serial API

if ("serial" in navigator) {
  // The Web Serial API is supported.
}

打开串口

Web Serial API在设计上是异步的。这可以防止网站UI在等待输入时阻塞,这一点很重要,因为串行数据可以在任何时候接收,需要一种方法来侦听它。要打开串口,首先访问一个SerialPort对象。为此,您可以通过调用navigator.serial.requestPort()来提示用户选择一个串行端口,或者从navigator.serial.getPorts()中选择一个,该方法返回一个先前授予该网站访问权限的串行端口列表。

// 提示用户选择一个串口。
const port = await navigator.serial.requestPort();
// 获取用户之前授予该网站访问权限的所有串口。
const ports = await navigator.serial.getPorts();

requestport()函数接受一个可选的定义过滤器的对象字面量。它们用于将任何通过USB连接的串行设备与强制USB厂商(usbVendorId)和可选USB产品标识符(usbProductId)匹配。

// 过滤设备与Arduino Uno USB供应商/产品id。
const filters = [
  { usbVendorId: 0x2341, usbProductId: 0x0043 },
  { usbVendorId: 0x2341, usbProductId: 0x0001 }
];

// 提示用户选择Arduino Uno设备。
const port = await navigator.serial.requestPort({ filters });

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

选择一个串口打开

调用requestPort()提示用户选择一个设备并返回一个SerialPort对象。一旦你有了一个SerialPort对象,用期望的波特率调用port.open()将打开串口。baudRate字典成员指定通过串行线发送数据的速度。它以每秒比特(bps)为单位表示。检查您的设备的文档为正确的值,因为所有的数据发送和接收将是乱码,如果这是指定不正确。对于一些模拟串行端口的USB和蓝牙设备,这个值可以安全地设置为任何值,因为它会被模拟忽略。

// 提示用户选择一个串口
const port = await navigator.serial.requestPort();

// 等待串口打开
await port.open({ baudRate: 9600 });

您还可以在打开串行端口时指定下面的任何选项。这些选项是可选的,并且有方便的默认值。

  • dataBits:每帧的数据位数(7或8)。
  • stopBits:一帧结束时的停止位数(1或2)。
  • parity:校验模式,可以是none,偶数,奇数。
  • bufferSize:应该创建的读写缓冲区大小(必须小于16MB)。
  • flowControl:流控模式(none或hardware)。

从串口读取

Web Serial API中的输入和输出流由streams API处理。

串口连接建立之后,SerialPort对象的readablewritable属性返回一个ReadableStream和一个WritableStream。这些将用于从串行设备接收数据并将数据发送到串行设备。两者都使用Uint8Array实例进行数据传输。

当新数据从串行设备到达时,port.readable.getReader().read()异步返回两个属性:value和一个done的布尔值。如果done为真,则串行端口已经关闭,或者没有更多的数据输入。调用port.readable.getReader()创建一个读取器并将其锁定为readable。当可读被锁定时,串口不能被关闭。

const reader = port.readable.getReader();

// 监听来自串行设备的数据
while (true) {
  const { value, done } = await reader.read();
  if (done) {
    // 允许稍后关闭串口。
    reader.releaseLock();
    break;
  }
  // value 是一个 Uint8Array
  console.log(value);
}

在某些情况下可能会发生一些非致命的串行端口读错误,如缓冲区溢出、帧错误或奇偶校验错误。这些是作为异常抛出的,可以通过在检查port.readable的前一个循环之上添加另一个循环来捕获。这是可行的,因为只要错误是非致命的,一个新的ReadableStream就会自动创建。如果发生致命错误,如串行设备被删除,则端口。可读的变成了零。

while (port.readable) {
  const reader = port.readable.getReader();

  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) {
        // 允许稍后关闭串口。
        reader.releaseLock();
        break;
      }
      if (value) {
        console.log(value);
      }
    }
  } catch (error) {
    // TODO: 处理非致命的读错误。
  }
}

如果串行设备发送文本返回,您可以管道端口。可通过TextDecoderStream读取,如下所示。TextDecoderStream是一个转换流,抓取所有的Uint8Array块并将其转换为字符串。

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;
  }
  // value 是一个 string.
  console.log(value);
}

写入串口

要将数据发送到串行设备,请将数据传递到port.writable.getWriter().write()。在port.writable. getwriter()上调用releaseLock()是为了稍后关闭串口。

const writer = port.writable.getWriter();

const data = new Uint8Array([104, 101, 108, 108, 111]); // hello
await writer.write(data);


// 允许稍后关闭串口。
writer.releaseLock();

通过管道传输到端口的TextEncoderStream向设备发送文本。port.writable如下所示。

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

const writer = textEncoder.writable.getWriter();

await writer.write("hello");

关闭串口

port.close()如果串行端口的readablewritable被解锁,则关闭该串行端口,这意味着已经为其各自的读写成员调用了releaseLock()

await port.close();

但是,当使用循环从串行设备连续读取数据时,端口Readable将一直被锁定,直到遇到错误。在这种情况下,调用reader.cancel()将强制reader.read()立即解析为{value: undefined, done: true},从而允许循环调用reader.releaseLock()

// 没有变换流。
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;
        }
        // value 是一个 Uint8Array.
        console.log(value);
      }
    } catch (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;
});

当使用转换流(如TextDecoderStreamtexttencoderstream)时,关闭串口更复杂。像以前一样调用reader.cancel()。然后调用writer.close()port.close()。这会通过转换流将错误传播到底层串行端口。因为错误传播不会立即发生,所以需要使用前面创建的readableStreamClosedwritableStreamClosed promise来检测何时端口。可读的和端口。可写已解锁。取消读取器将导致流中止;这就是为什么您必须捕获并忽略产生的错误。

// 流与变换。
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;
  }
  // value 是一个 string.
  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设备提供的,那么该设备可以从系统连接或断开。当网站被授予访问串口的权限时,它应该监控连接和断开事件。

navigator.serial.addEventListener("connect", (event) => {
  // TODO: 自动打开事件。目标器或警告用户端口可用。
});

navigator.serial.addEventListener("disconnect", (event) => {
  // TODO: Remove |event.target| from the UI.
  // 如果打开了串行端口,还会观察到流错误。
});

在Chrome 89之前,连接和断开事件触发了一个自定义的SerialConnectionEvent对象,受影响的SerialPort接口作为端口属性可用。你可能想要使用event.port || event.target事件。目标来处理转换。

处理信号

串口连接建立后,可以显式查询和设置串口暴露的信号,用于设备检测和流量控制。这些信号被定义为布尔值。例如,当DTR (Data Terminal Ready)信号被切换时,一些设备(如Arduino)会进入编程模式。

设置输出信号和获取输入信号分别通过调用port.setSignals()port.getSignals()来完成。参见下面的用法示例。

// 关闭串行中断信号。
await port.setSignals({ break: false });

// 打开DTR (Data Terminal Ready)信号。
await port.setSignals({ dataTerminalReady: true });

// 关闭发送请求(RTS)信号。
await port.setSignals({ requestToSend: false });
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管道传输,因此我们需要调用pipe through()将其通过新的LineBreakTransformer管道传输。

class LineBreakTransformer {
  constructor() {
    // 保存流数据直到新行出现的容器。
    this.chunks = "";
  }

  transform(chunk, controller) {
    // 将新块追加到现有块。
    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);
  }
}
const textDecoder = new TextDecoderStream();
const readableStreamClosed = port.readable.pipeTo(textDecoder.writable);
const reader = textDecoder.readable
  .pipeThrough(new TransformStream(new LineBreakTransformer()))
  .getReader();

对于调试串行设备通信问题,使用porttee()方法。port.readable,用于分割进出串行设备的流。创建的两个流可以独立使用,这允许您将其中一个打印到控制台以供检查。

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

// 你可能想用来自可理解的数据更新UI
// 并将传入的数据记录在JS控制台中,以便从devReadable检查。

Dev小贴士

Chrome中调试Web Serial API很容易,有一个内部页面,Chrome://device-log,你可以在一个地方看到所有串行设备相关的事件。 Chrome的内部页面用于调试Web串行API

Codelab

在谷歌Developer代码实验室中,您将使用Web Serial APIBBC micro:bit板交互,在其5x5 LED矩阵上显示图像。

浏览器支持

Web Serial API适用于Chrome 89的所有桌面平台(Chrome OS、Linux、macOS和Windows)。

Polyfill

Android上,可以使用WebUSB APIserial API polyfill来支持基于usb的串口。这个腻子仅限于通过WebUSB API访问设备的硬件和平台,因为它没有被内置的设备驱动程序声明。

安全和隐私

该规范的作者使用控制对强大Web平台特性的访问的核心原则设计并实现了Web Serial API,这些核心原则包括用户控制、透明性和人体工程学。使用此API的能力主要是由一次只授予访问单个串行设备的权限模型决定的。为了响应用户提示,用户必须采取主动步骤来选择特定的串行设备。

要了解安全性权衡,请查看Web Serial API解释器的安全性和隐私部分。