对串行端口执行读写操作

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

François Beaufort
François Beaufort

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

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

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

由于操作系统要求应用使用更高级别的串行 API(而非低级别的 USB API)与某些串行端口通信,因此此 API 也是 WebUSB 的绝佳伴侣。

建议的用例

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

在某些情况下,网站会通过用户手动安装的代理应用与设备通信。在某些情况下,应用会通过 Electron 等框架以打包应用的形式提供。在某些情况下,用户需要执行额外的步骤,例如通过 USB 闪存盘将已编译的应用复制到设备。

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

当前状态

步骤 状态
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();

通过管道将 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(),则 port.close() 会关闭串行端口。

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),也可以创建自己的转换流,以便解析传入流并返回解析后的数据。转换流位于串行设备和使用该流的读取循环之间。它可以在使用数据之前应用任意转换。不妨将其想象成一条装配线:随着微件沿着装配线移动,装配线中的每个步骤都会修改微件,以便在微件到达最终目的地时,它已成为一个完全正常运行的微件。

飞机工厂的照片
第二次世界大战布罗米奇城堡飞机工厂

例如,考虑如何创建一个转换流类,以便使用流并根据换行符将其分块。每次数据流接收新数据时,系统都会调用其 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 开发者 Codelab 中,您将使用 Web Serial API 与 BBC micro:bit 板进行交互,以便在其 5x5 LED 矩阵上显示图像。

浏览器支持

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

polyfill

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

安全和隐私设置

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

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

反馈

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

请向我们说明 API 设计

API 是否存在未按预期运行的情况?或者,您是否缺少实现自己的想法的方法或属性?

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

报告实现存在的问题

您在 Chrome 的实现过程中是否发现了错误?或者,其实现方式是否与规范有所不同?

https://new.crbug.com 上提交 bug。请务必提供尽可能多的详情,提供有关如何重现 bug 的简单说明,并将 Components 设置为 Blink>SerialGlitch 非常适用于分享轻松快速的重现问题。

表达支持

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

使用 #SerialAPI 标签向 @ChromiumDev 发送推文,告诉我们您在哪里以及如何使用该工具。

实用链接

演示

致谢

感谢 Reilly GrantJoe Medley 对本文的评价。 飞机工厂照片,由伯明翰博物馆信托基金会Unshot 提供。