对串行端口执行读写操作

借助 Web Serial API,网站可以与串行设备进行通信。

François Beaufort
François Beaufort

什么是 Web Serial API?

串行端口是一种双向通信接口,可以逐字节发送和接收数据。

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

换言之,Web Serial API 允许网站与串行设备(例如微控制器和 3D 打印机)进行通信,从而连接了网络和现实世界。

此 API 也是 WebUSB 的配套应用,因为操作系统要求应用使用较高级别的串行 API(而不是低级 USB API)与某些串行端口进行通信。

建议的用例

在教育、业余爱好者和工业领域,用户将外围设备连接到计算机。这些设备通常由微控制器通过定制软件使用的串行连接进行控制。用于控制这些设备的一些定制软件采用了 Web 技术:

在某些情况下,网站会通过用户手动安装的代理应用与设备进行通信。在其他情况下,应用通过 Electron 等框架在封装应用中交付。而在其他情况下,用户需要执行额外的步骤,例如通过 U 盘将已编译的应用复制到设备。

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

当前状态

步骤 状态
1. 创建铺垫消息 完成
2. 创建规范的初始草稿 完成
3. 收集反馈并不断改进设计 完成
4. 源试用 完成
5. 启动 完成

使用 Web Serial API

功能检测

如需检查 Web Serial API 是否受支持,请使用:

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

打开串行端口

Web Serial API 在设计上是异步的。这样可以防止网站界面在等待输入时阻塞,这一点很重要,因为随时可能接收串行数据,而需要通过某种方式进行监听。

如需打开串行端口,请先访问 SerialPort 对象。为此,您可以通过调用 navigator.serial.requestPort() 来响应用户手势(例如触摸或点击鼠标)来提示用户选择单个串行端口,也可以从 navigator.serial.getPorts() 中选择一个串行端口,后者会返回网站已获访问权限的串行端口列表。

document.querySelector('button').addEventListener('click', async () => {
  // Prompt user to select any serial port.
  const port = await navigator.serial.requestPort();
});
// Get all serial ports the user has previously granted the website access to.
const ports = await navigator.serial.getPorts();

navigator.serial.requestPort() 函数接受用于定义过滤条件的可选对象字面量。这些标识符用于匹配通过 USB 连接的任何串行设备,它们具有强制性的 USB 供应商 (usbVendorId) 和可选的 USB 产品标识符 (usbProductId)。

// Filter on devices with the Arduino Uno USB Vendor/Product IDs.
const filters = [
  { usbVendorId: 0x2341, usbProductId: 0x0043 },
  { usbVendorId: 0x2341, usbProductId: 0x0001 }
];

// Prompt user to select an Arduino Uno device.
const port = await navigator.serial.requestPort({ filters });

const { usbProductId, usbVendorId } = port.getInfo();
网站上串行端口提示的屏幕截图
选择 BBC micro:bit 的用户提示

调用 requestPort() 会提示用户选择设备,并返回 SerialPort 对象。有了 SerialPort 对象后,使用所需波特率调用 port.open() 将打开串行端口。baudRate 字典成员指定通过串行发送数据的速度。它以每秒位数 (bps) 为单位表示。请查看设备文档以获取正确值,如果指定不正确,您发送和接收的所有数据都将是乱码。对于某些模拟串行端口的 USB 和蓝牙设备,您可以将此值安全地设置为任意值,因为模拟会将其忽略。

// 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 });

您也可以在打开串行端口时指定以下任何选项。这些选项是可选的,具有方便使用的默认值

  • dataBits:每帧的数据位数(7 或 8)。
  • stopBits:帧末尾的停止位数(1 或 2)。
  • parity:对等模式("none""even""odd")。
  • bufferSize:应创建的读写缓冲区的大小(必须小于 16MB)。
  • flowControl:流控制模式("none""hardware")。

从串行端口读取数据

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

建立串行端口连接后,SerialPort 对象的 readablewritable 属性会返回 ReadableStreamWritableStream。它们将用于从串行设备接收数据和向串行设备发送数据。两者都使用 Uint8Array 实例进行数据传输。

当新数据从串行设备到达时,port.readable.getReader().read() 会异步返回两个属性:valuedone 布尔值。如果 done 为 true,则表示串行端口已关闭,或者没有更多数据传入。调用 port.readable.getReader() 会创建一个读取器,并将 readable 锁定到该读取器。当 readable 处于锁定状态时,串行端口无法关闭。

const reader = port.readable.getReader();

// Listen to data coming from the serial device.
while (true) {
  const { value, done } = await reader.read();
  if (done) {
    // Allow the serial port to be closed later.
    reader.releaseLock();
    break;
  }
  // value is a Uint8Array.
  console.log(value);
}

在某些情况下,可能会发生一些非严重的串行端口读取错误,例如缓冲区溢出、分帧错误或奇异错误。这些是作为异常抛出的,可以通过在用于检查 port.readable 的上一个循环的基础上再添加一个循环来捕获。这种方法之所以有效,是因为只要错误不严重,系统就会自动创建新的 ReadableStream。如果发生严重错误(如串行设备被移除),则 port.readable 会变为 null。

while (port.readable) {
  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 是一个转换流,它可以获取所有 Uint8Array 区块并将其转换为字符串。

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

// Listen to data coming from the serial device.
while (true) {
  const { value, done } = await reader.read();
  if (done) {
    // Allow the serial port to be closed later.
    reader.releaseLock();
    break;
  }
  // value is a string.
  console.log(value);
}

当您使用“自带缓冲区”读取器从流读取数据时,您可以控制内存的分配方式。调用 port.readable.getReader({ mode: "byob" }) 以获取 ReadableStreamBYOBReader 接口,并在调用 read() 时提供您自己的 ArrayBuffer。请注意,Web Serial API 在 Chrome 106 或更高版本中支持此功能。

try {
  const reader = port.readable.getReader({ mode: "byob" });
  // Call reader.read() to read data into a buffer...
} catch (error) {
  if (error instanceof TypeError) {
    // BYOB readers are not supported.
    // Fallback to port.readable.getReader()...
  }
}

以下示例展示了如何重复使用 value.buffer 中的缓冲区:

const bufferSize = 1024; // 1kB
let buffer = new ArrayBuffer(bufferSize);

// Set `bufferSize` on open() to at least the size of the buffer.
await port.open({ baudRate: 9600, bufferSize });

const reader = port.readable.getReader({ mode: "byob" });
while (true) {
  const { value, done } = await reader.read(new Uint8Array(buffer));
  if (done) {
    break;
  }
  buffer = value.buffer;
  // Handle `value`.
}

以下示例说明了如何从串行端口读取特定数量的数据:

async function readInto(reader, buffer) {
  let offset = 0;
  while (offset < buffer.byteLength) {
    const { value, done } = await reader.read(
      new Uint8Array(buffer, offset)
    );
    if (done) {
      break;
    }
    buffer = value.buffer;
    offset += value.byteLength;
  }
  return buffer;
}

const reader = port.readable.getReader({ mode: "byob" });
let buffer = new ArrayBuffer(512);
// Read the first 512 bytes.
buffer = await readInto(reader, buffer);
// Then read the next 512 bytes.
buffer = await readInto(reader, buffer);

写入串行端口

如需将数据发送到串行设备,请将数据传递给 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);


// Allow the serial port to be closed later.
writer.releaseLock();

通过管道传送到 port.writableTextEncoderStream 将文本发送到设备,如下所示。

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

const writer = textEncoder.writable.getWriter();

await writer.write("hello");

关闭串行端口

如果 readablewritable 成员已解锁,则 port.close() 会关闭串行端口,这意味着已针对各自的读取器和写入器调用 releaseLock()

await port.close();

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

// Without transform streams.

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() has been called.
          break;
        }
        // value is a Uint8Array.
        console.log(value);
      }
    } catch (error) {
      // Handle error...
    } finally {
      // Allow the serial port to be closed later.
      reader.releaseLock();
    }
  }

  await port.close();
}

const closedPromise = readUntilClosed();

document.querySelector('button').addEventListener('click', async () => {
  // User clicked a button to close the serial port.
  keepReading = false;
  // Force reader.read() to resolve immediately and subsequently
  // call reader.releaseLock() in the loop example above.
  reader.cancel();
  await closedPromise;
});

使用转换流时,关闭串行端口会变得更加复杂。像以前一样调用 reader.cancel()。然后,调用 writer.close()port.close()。这会通过转换流将错误传播到底层串行端口。由于错误传播不会立即发生,因此您需要使用之前创建的 readableStreamClosedwritableStreamClosed promise 来检测 port.readableport.writable 何时解锁。取消 reader 会导致数据流中止;因此,您必须捕获并忽略生成的错误。

// With transform streams.

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

// Listen to data coming from the serial device.
while (true) {
  const { value, done } = await reader.read();
  if (done) {
    reader.releaseLock();
    break;
  }
  // value is a 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 设备提供串行端口,则该设备可能与系统连接或断开连接。网站被授予访问串行端口的权限后,应监控 connectdisconnect 事件。

navigator.serial.addEventListener("connect", (event) => {
  // TODO: Automatically open event.target or warn user a port is available.
});

navigator.serial.addEventListener("disconnect", (event) => {
  // TODO: Remove |event.target| from the UI.
  // If the serial port was opened, a stream error would be observed as well.
});

处理信号

建立串行端口连接后,您可以明确查询和设置串行端口公开的信号,以进行设备检测和流控制。这些信号被定义为布尔值。例如,如果数据终端就绪 (DTR) 信号切换,某些设备(如 Arduino)会进入编程模式。

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

// Turn off Serial Break signal.
await port.setSignals({ break: false });

// Turn on Data Terminal Ready (DTR) signal.
await port.setSignals({ dataTerminalReady: true });

// Turn off Request To Send (RTS) signal.
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}`);

转换数据流

从串行设备接收数据时,您不一定会一次性获取所有数据。可以任意分块。如需了解详情,请参阅 Streams API 概念

为解决此问题,您可以使用一些内置的转换流(例如 TextDecoderStream),也可以创建自己的转换流来解析传入流并返回解析后的数据。转换流位于串行设备和使用数据流的读取循环之间。它可以在使用数据之前应用任意转换。您可以将其想象成一条组装线:当一个 widget 下线时,行中的每一步都会修改 widget,以便在它到达最终目的地时,它是一个功能齐全的 widget。

飞机工厂的照片
二战时期,布罗米奇城堡飞机工厂

例如,考虑如何创建转换流类,该类会使用流并根据换行符对其进行分块。每当流收到新数据时,都会调用其 transform() 方法。它可以将数据加入队列,也可以保存数据以备后用。flush() 方法会在流关闭时调用,用于处理尚未处理的所有数据。

如需使用转换流类,您需要通过管道传输传入的流。在从串行端口读取下的第三个代码示例中,原始输入流仅通过 TextDecoderStream 传输,因此我们需要调用 pipeThrough(),以通过新的 LineBreakTransformer 传输该输入流。

class LineBreakTransformer {
  constructor() {
    // A container for holding stream data until a new line.
    this.chunks = "";
  }

  transform(chunk, controller) {
    // Append new chunks to existing chunks.
    this.chunks += chunk;
    // For each line breaks in chunks, send the parsed lines out.
    const lines = this.chunks.split("\r\n");
    this.chunks = lines.pop();
    lines.forEach((line) => controller.enqueue(line));
  }

  flush(controller) {
    // When the stream is closed, flush any remaining chunks out.
    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();

如需调试串行设备通信问题,请使用 port.readabletee() 方法拆分进出串行设备的数据流。创建的两个流可以独立使用,因此您可以将一个流输出到控制台进行检查。

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

// You may want to update UI with incoming data from appReadable
// and log incoming data in JS console for inspection from devReadable.

撤消对串行端口的访问权限

网站可以通过对 SerialPort 实例调用 forget() 来清理访问其不再需要保留的串行端口的权限。例如,对于在具有许多设备的共享计算机上使用的教育类 Web 应用,大量累积的用户生成权限会导致用户体验不佳。

// Voluntarily revoke access to this serial port.
await port.forget();

由于 forget() 在 Chrome 103 或更高版本中可用,请检查以下各项是否支持此功能:

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

开发提示

通过内部页面 about://device-log,您可以轻松地在 Chrome 中调试 Web Serial API。在该页面中,您可以在一个位置查看所有与串行设备相关的事件。

用于调试 Web Serial API 的内部页面的屏幕截图。
Chrome 中用于调试 Web Serial API 的内部页面。

Codelab

Google Developers Codelab 中,您将使用 Web Serial API 与 BBC micro:bit 开发板交互,以便在其 5x5 LED 矩阵上显示图片。

浏览器支持

在 Chrome 89 中,Web Serial API 适用于所有桌面平台(ChromeOS、Linux、macOS 和 Windows)。

聚酯纤维

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

安全和隐私设置

规范作者根据控制对强大网络平台功能的访问权限(包括用户控制、透明度和工效学设计)中定义的核心原则设计和实现了 Web Serial API。能否使用该 API 主要取决于一种权限模型,该模型一次仅授予对一个串行设备的访问权限。为响应用户提示,用户必须采取主动措施来选择特定的串行设备。

如需了解安全方面的权衡,请参阅 Web Serial API 说明的安全隐私部分。

反馈

Chrome 团队希望了解您对 Web Serial API 的看法和体验。

向我们介绍 API 设计

是否存在 API 无法正常运行的问题?或者,您是否需要缺少一些方法或属性来实现您的想法?

Web Serial API GitHub 代码库提交规范问题,或将您的想法添加到现有问题中。

报告实施方面的问题

您是否发现了 Chrome 实现方面的错误?或者实现方式是否不同于规范?

https://new.crbug.com 上提交 bug。请务必提供尽可能多的详细信息,提供重现 bug 的简单说明,并将组件设置为 Blink>SerialGlitch 非常适合用于快速轻松地分享重现的视频。

表达支持

打算使用 Web Serial API 吗?您的公开支持有助于 Chrome 团队确定各项功能的优先级,还能向其他浏览器供应商表明支持这些功能的重要性。

请使用 # 标签 #SerialAPI@ChromiumDev 发送一条推文,并告诉我们您使用该产品的位置和方式。

实用链接

样本歌曲

致谢

感谢 Reilly GrantJoe Medley 审核本文。 飞机工厂照片,由伯明翰博物馆信任 (Birmingham 博物馆 s Trust) 拍摄,拍摄于 Unsplash 用户。