对串行端口执行读写操作

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

François Beaufort
François Beaufort

什么是 Web Serial API?

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

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

换句话说,Web Serial API 允许网站与微控制器和 3D 打印机等串行设备进行通信,从而将 Web 与现实世界联系起来。

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

建议的应用场景

在教育、业余爱好和工业领域,用户会将外围设备连接到计算机。这些设备通常由微控制器通过自定义软件使用的串行连接进行控制。一些用于控制这些设备的自定义软件是使用 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:应创建的读取和写入缓冲区的大小(必须小于 16 MB)。
  • flowControl:流量控制模式("none""hardware")。

从串行端口读取数据

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

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

当串行设备有新数据到达时,port.readable.getReader().read() 会异步返回两个属性:value 和一个 done 布尔值。如果 done 为 true,则表示串口已关闭或没有更多数据传入。调用 port.readable.getReader() 会创建一个 reader 并将 readable 锁定到该 reader。当 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();

通过管道将文本发送到设备,如下所示。TextEncoderStreamport.writable

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

转换流

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

为了解决这个问题,您可以使用一些内置的转换流(例如 TextDecoderStream),也可以创建自己的转换流,以便解析传入的流并返回解析后的数据。转换数据流位于串行设备和使用该数据流的读取循环之间。它可以在使用数据之前应用任意转换。您可以将其视为装配线:当 widget 沿着装配线移动时,装配线上的每个步骤都会修改该 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 开发者 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 的串行端口。此填充区仅限于可通过 WebUSB API 访问的硬件和平台,因为该设备尚未被内置设备驱动程序声明。

安全和隐私设置

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

如需了解安全方面的权衡取舍,请参阅 Web Serial API Explainer 的安全性隐私权部分。

反馈

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

介绍 API 设计

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

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

报告实现方面的问题

您是否发现 Chrome 的实现存在 bug?还是实现与规范不同?

请访问 https://new.crbug.com 提交 bug。请务必尽可能详细地说明问题,提供重现 bug 的简单说明,并将组件设置为 Blink>Serial

显示支持

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

发送一条推文给 @ChromiumDev,使用 ##SerialAPI 主题标签,告诉我们您在何处以及如何使用它。

实用链接

演示

致谢

感谢 Reilly GrantJoe Medley 对本文的审核。 飞机工厂照片由 Birmingham Museums Trust 拍摄,选自 Unsplash 网站。