对串行端口执行读写操作

借助 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 等框架以打包应用的形式提供。 而在其他情况下,用户需要执行额外的步骤,例如通过 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.readableTextDecoderStream 是一个 转换流 ,用于抓取所有 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");

关闭串行端口

port.close() 会关闭串行端口,前提是其 readablewritable 成员 已解锁,这意味着已为其各自的 读取器和写入器调用 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),也可以创建自己的转换流,以便解析传入的流并返回解析后的数据。转换流位于串行设备和使用该流的读取循环之间。它可以在使用数据之前应用任意转换。您可以将其视为装配线:当小部件沿着装配线移动时,装配线中的每个步骤都会修改小部件,因此当小部件到达最终目的地时,它就是一个功能齐全的小部件。

飞机工厂的照片
二战时期的卡斯尔布罗姆维奇飞机工厂

例如,假设您要创建一个转换流类,该类使用流并根据换行符对其进行分块。每当流收到新数据时,都会调用其 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)。

聚酯纤维

在 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 的实现存在 bug?或者,实现是否与规范不同?

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

表示支持

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

使用主题标签 #SerialAPI@ChromiumDev 发送推文,并告知我们您在何处以及如何使用该 API。

实用链接

演示

致谢

感谢 Reilly GrantJoe Medley 对本文档的审核。