借助 Web Serial API,网站可以与串行设备进行通信。
什么是 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();
调用 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 对象的 readable 和 writable 属性会返回 ReadableStream 和 WritableStream。这些对象将用于从串行设备接收数据和向串行设备发送数据。两者均使用 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"); 关闭串行端口
如果串行端口的 readable 和 writable 成员处于未锁定状态,则 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()。这会将错误通过转换流传播到基础串行端口。由于错误传播不会立即发生,因此您需要使用之前创建的 readableStreamClosed 和 writableStreamClosed promise 来检测 port.readable 和 port.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 设备提供,则该设备可能会连接到系统或与系统断开连接。当网站获得访问串行端口的权限后,应监控 connect 和 disconnect 事件。
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.readable 的 tee() 方法来拆分发送到串行设备或从串行设备发送的流。创建的两个流可以独立使用,这样您就可以将其中一个流打印到控制台以供检查。
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,在一个位置查看所有与串行设备相关的事件。
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 主题标签,告诉我们您在何处以及如何使用它。
实用链接
- 规格
- 跟踪 bug
- ChromeStatus.com 条目
- Blink 组件:
Blink>Serial
演示
致谢
感谢 Reilly Grant 和 Joe Medley 对本文的审核。 飞机工厂照片由 Birmingham Museums Trust 拍摄,选自 Unsplash 网站。