# Web Serial API

> navigator.serial.requestPort() 与 SerialPort.open() 把页面接到串口设备：请求与打开选项、规范定义的每个异常，以及可运行的读写示例。

Web Serial API 让页面通过 `navigator.serial.requestPort()` 向用户申请一个串口设备，然后经由返回的 `SerialPort` 的 `readable` 与 `writable` 流收发字节。它覆盖 USB 转串口适配器、板载 UART 口，以及（自 Chrome 117 起）蓝牙 RFCOMM 服务。规范由 WHATWG 发布，旧的 WICG 地址会重定向过去。

桌面端 Chrome 89 与 Edge 89 首发，Firefox 151 跟进；Android 上 Chrome 148 同时暴露 USB 与蓝牙串口，而 Chrome 138 至 147 以及 Samsung Internet 30 只列出蓝牙 RFCOMM 串口（BCD `api.Serial`）。Safari、Android 版 Firefox 与 Android WebView（[crbug.com/40740509](https://crbug.com/40740509)）没有实现。

## 语法

```js
navigator.serial.requestPort()
navigator.serial.requestPort(options)
navigator.serial.getPorts()

port.open(options)
port.close()
port.forget()
port.getInfo()
port.getSignals()
port.setSignals(signals)
```

`requestPort()` 以用户在浏览器选择器中选中的那一个 `SerialPort` 兑现；`getPorts()` 以本源已获授权且当前连着的串口数组兑现。`open()`、`close()`、`forget()`、`getSignals()` 与 `setSignals()` 都返回 Promise。`SerialPort` 还暴露 `readable`（`Uint8Array` 的 `ReadableStream`，关闭时为 `null`）、`writable`（`WritableStream`）与 `connected`（布尔值，Chrome 130 与 Firefox 151），并触发会冒泡到 `navigator.serial` 的 `connect` 与 `disconnect` 事件。所有接口都是 `[SecureContext]`，`requestPort()` 另外要求瞬时用户激活。

## 参数

`requestPort(options)` 接受 `SerialPortRequestOptions` 字典；`open(options)` 接受 `SerialOptions` 字典，其中只有 `baudRate` 是必填项。

| 字典 | 成员 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `SerialPortRequestOptions` | `filters` | `sequence<SerialPortFilter>` | 否 | 把选择器限制为匹配的串口；不传则列出全部串口。 |
| `SerialPortRequestOptions` | `allowedBluetoothServiceClassIds` | `sequence<BluetoothServiceUUID>` | 否 | 在标准 Serial Port Profile 之外额外提供的蓝牙服务类 ID（Chrome 117）。 |
| `SerialPortFilter` | `usbVendorId` | `unsigned short` | 仅当设置了 `usbProductId` 时必填 | 适配器的 USB 厂商 ID。 |
| `SerialPortFilter` | `usbProductId` | `unsigned short` | 否 | USB 产品 ID；需要同时给出 `usbVendorId`。 |
| `SerialPortFilter` | `bluetoothServiceClassId` | `BluetoothServiceUUID` | 否 | 蓝牙服务类；同一过滤器里再出现 USB 成员会以 `TypeError` 拒绝。 |
| `SerialOptions` | `baudRate` | `unsigned long` | 是 | 波特率（bit/s），必须大于 0。 |
| `SerialOptions` | `dataBits` | `octet` | 否，默认 `8` | 7 或 8。 |
| `SerialOptions` | `stopBits` | `octet` | 否，默认 `1` | 1 或 2。 |
| `SerialOptions` | `parity` | `ParityType` | 否，默认 `"none"` | `"none"`、`"even"` 或 `"odd"`。 |
| `SerialOptions` | `bufferSize` | `unsigned long` | 否，默认 `255` | 读写缓冲区大小（字节），必须大于 0。 |
| `SerialOptions` | `flowControl` | `FlowControlType` | 否，默认 `"none"` | `"none"` 或 `"hardware"`（RTS/CTS）。 |

`setSignals(signals)` 接受 `SerialOutputSignals` 字典，含布尔成员 `dataTerminalReady`、`requestToSend` 与 `break`，至少要有一个。`getSignals()` 以 `SerialInputSignals`（`dataCarrierDetect`、`clearToSend`、`ringIndicator`、`dataSetReady`）兑现；`getInfo()` 在串口提供时返回 `usbVendorId`、`usbProductId` 与 `bluetoothServiceClassId`。

## 异常

| 方法 | 异常 | 条件 |
|---|---|---|
| `requestPort()` | `SecurityError` | 文档不被允许使用 `serial` Permissions Policy 特性，或调用时没有瞬时激活。 |
| `requestPort()` | `TypeError` | 某个过滤器把 `bluetoothServiceClassId` 和 USB 成员混用，或只给了 `usbProductId` 而没有 `usbVendorId`。 |
| `requestPort()` | `NotFoundError` | 用户没有选择串口就关闭了选择器。 |
| `getPorts()` | `SecurityError` | `serial` Permissions Policy 特性被禁用。 |
| `open()` | `InvalidStateError` | 串口状态不是 `"closed"`（已打开，或有 `open()`/`close()` 正在进行）。 |
| `open()` | `TypeError` | `baudRate` 为 0、`dataBits` 不是 7 或 8、`stopBits` 不是 1 或 2、`bufferSize` 为 0 或超过实现上限。 |
| `open()` | `NetworkError` | 操作系统打开串口失败。 |
| `readable`（流出错） | `BufferOverrunError`、`BreakError`、`FramingError`、`ParityError` | 检测到对应的线路状况；流进入错误态，需要从 `port.readable` 重新获取。 |
| `readable` / `writable` | `UnknownError` | 读或写时发生操作系统错误。 |
| `readable` / `writable` | `NetworkError` | 设备被断开；`writable` 还会置一个致命标志，之后必须关闭串口。 |
| `writable` | `TypeError` | 写入的数据块不是 `BufferSource`。 |
| `setSignals()`、`getSignals()`、`close()` | `InvalidStateError` | 串口不处于 `"opened"`。 |
| `setSignals()` | `TypeError` | 信号字典没有任何成员。 |
| `setSignals()`、`getSignals()` | `NetworkError` | 操作系统拒绝设置或读取控制线。 |

:::observed
Chrome 对常见失败的提示字符串全部来自 [`serial.cc`](https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/modules/serial/serial.cc) 与 [`serial_port.cc`](https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/modules/serial/serial_port.cc)（chromium.googlesource.com）：在 click 处理函数之外调用 `requestPort()` 以 `SecurityError: Must be handling a user gesture to show a permission request.` 拒绝；关闭选择器以 `NotFoundError: No port selected by the user.` 拒绝；对已打开的串口再次 `open()` 以 `InvalidStateError: The port is already open.` 拒绝；`open({ baudRate: 0 })` 以 `TypeError: Requested baud rate must be greater than zero.` 拒绝；串口已被其他程序占用时以 `NetworkError: Failed to open serial port.` 拒绝。读取途中拔掉适配器，流以 `NetworkError: The device has been lost.` 出错。
:::

## 示例

每个示例都先检测 `navigator.serial`，并给出没有它时执行的分支。只有 `requestPort()` 需要用户手势，之后的步骤可以在普通代码里运行。

### 先复用已授权的串口，再申请新的

`getPorts()` 返回用户已经批准的串口，回访用户不该再看到选择器。API 缺失时（Safari、Android 版 Firefox、WebView），函数返回原因，由调用方提供桌面链接或原生配套应用。

```js
async function connect(vendorId) {
  if (!('serial' in navigator)) {
    return { port: null, reason: '当前浏览器不提供 Web Serial。' };
  }

  const granted = await navigator.serial.getPorts();
  let port = granted.find((p) => p.getInfo().usbVendorId === vendorId);

  if (!port) {
    try {
      port = await navigator.serial.requestPort({ filters: [{ usbVendorId: vendorId }] });
    } catch (err) {
      if (err.name === 'NotFoundError') return { port: null, reason: '未选择设备。' };
      throw err;
    }
  }

  await port.open({ baudRate: 115200 });
  return { port, reason: null };
}
```

`filters` 把无关串口挡在选择器之外；省略它则列出机器上的全部串口。

### 逐行读取文本直到设备断开

把 `readable` 通过 `TextDecoderStream` 转成文本，循环读取。断开会让流以 `NetworkError` 出错；奇偶或帧错误则以具体名称出错但串口仍然打开，所以循环重新获取 `port.readable` 继续读。

```js
async function readLines(port, onLine) {
  while (port.readable) {
    const reader = port.readable.pipeThrough(new TextDecoderStream()).getReader();
    let buffer = '';
    try {
      for (;;) {
        const { value, done } = await reader.read();
        if (done) break;
        buffer += value;
        const lines = buffer.split('\n');
        buffer = lines.pop();
        lines.forEach(onLine);
      }
    } catch (err) {
      if (err.name === 'NetworkError') return; // 设备已断开
      console.warn(`${err.name}: ${err.message}`); // 奇偶、帧、break、溢出
    } finally {
      reader.releaseLock();
    }
  }
}
```

`finally` 中的 `releaseLock()` 很关键：`port.close()` 会等待 readable 的锁释放，否则一直挂起。

### 发送命令并干净地关闭

写入走 `WritableStream` 的 writer；`close()` 要求两条流都未上锁，所以先释放 writer。

```js
async function sendAndClose(port, command) {
  const writer = port.writable.getWriter();
  try {
    await writer.write(new TextEncoder().encode(`${command}\r\n`));
  } finally {
    writer.releaseLock();
  }
  await port.close();
}
```

`close()` 兑现后，`port.readable` 与 `port.writable` 都变为 `null`，直到下一次 `open()`。

## 另请参阅

- [WebUSB API](/zh/reference/capabilities/web-usb/)，面向没有串口协议的设备
- [WebHID API](/zh/reference/capabilities/web-hid/)
- [Web Bluetooth API](/zh/reference/capabilities/web-bluetooth/)，面向 GATT 而非 RFCOMM 设备
- [Web Serial API: requestPort() method](https://serial.spec.whatwg.org/#dom-serial-requestport)（whatwg.org）
- [Web Serial API: SerialOptions dictionary](https://serial.spec.whatwg.org/#dom-serialoptions)（whatwg.org）
- [Read from and write to a serial port](https://developer.chrome.com/docs/capabilities/serial)（developer.chrome.com）
- [Chromium bug 40740509: Web Serial in Android WebView](https://crbug.com/40740509)（crbug.com）