通过网页访问 USB 设备

WebUSB API 将 USB 引入到 Web 中,使其更安全、更易用。

François Beaufort
François Beaufort

如果我简单明了地说出“USB”,您很可能会立即想到键盘、鼠标、音频设备、视频设备和存储设备。没错,但您也可以选择其他类型的通用串行总线 (USB) 设备。

这些非标准化 USB 设备需要硬件供应商编写平台专用驱动程序和 SDK,以便您(开发者)能够充分利用它们。遗憾的是,这种平台专用代码长期以来一直阻止 Web 使用这些设备。这也是创建 WebUSB API 的原因之一:为了提供一种向网络公开 USB 设备服务的方法。借助此 API,硬件制造商将能够为其设备构建跨平台 JavaScript SDK。

但最重要的是,这将通过将 USB 引入 Web 来提高其安全性并简化其使用

我们来看看使用 WebUSB API 时可能出现的行为:

  1. 购买 USB 设备。
  2. 将其插入计算机。系统会立即显示一条通知,其中包含适用于此设备的正确网站。
  3. 点击此通知。网站已上线,可以使用了!
  4. 点击连接,Chrome 中会显示一个 USB 设备选择器,您可以在其中选择设备。

大功告成!

如果没有 WebUSB API,此过程会是怎样的?

  1. 安装特定于平台的应用。
  2. 即使我的操作系统支持该应用,我也需要确认自己下载的是否为正确的应用。
  3. 安装该设备。如果幸运的话,您不会收到可怕的操作系统提示或弹出式窗口,提醒您从互联网安装驱动程序/应用。如果您不幸,则安装的驱动程序或应用会出现故障,损害您的计算机。(请注意,网络上会存在运行不正常的网站)。
  4. 如果您只使用过此功能一次,则该代码会一直保留在您的计算机上,直到您想移除它为止。(在 Web 上,系统最终会回收未使用的空间。)

开始之前

本文假定您对 USB 的工作原理有基本的了解。如果不是,建议您阅读 NutShell 中的 USB。如需了解 USB 的背景信息,请参阅 USB 官方规范

WebUSB API 可在 Chrome 61 中使用。

适用于起源试验

为了尽可能从实际使用 WebUSB API 的开发者那里获得反馈,我们之前在 Chrome 54 和 Chrome 57 中以源试用的形式添加了此功能。

最新的试用已于 2017 年 9 月成功结束。

隐私权和安全

仅限 HTTPS

由于此功能的强大功能,因此它仅适用于安全上下文。这意味着您在构建时需要考虑 TLS

需要用户手势

出于安全考虑,navigator.usb.requestDevice() 只能通过用户手势(例如轻触或点击鼠标)调用。

“权限”政策

权限政策是一种机制,可让开发者选择启用和停用各种浏览器功能和 API。您可以通过 HTTP 标头和/或 iframe“allow”属性来定义此行为。

您可以定义一个权限政策,用于控制是否在 Navigator 对象上公开 usb 属性,换句话说,如果您允许 WebUSB。

以下是一个不允许 WebUSB 的标头政策示例:

Feature-Policy: fullscreen "*"; usb "none"; payment "self" https://payment.example.com

下面是允许 USB 的另一个容器政策示例:

<iframe allowpaymentrequest allow="usb; fullscreen"></iframe>

开始编码

WebUSB API 在很大程度上依赖于 JavaScript promise。如果您不熟悉 Promise,请参阅这篇出色的 Promise 教程。还有一点,() => {} 只是 ECMAScript 2015 箭头函数

获取 USB 设备的访问权限

您可以提示用户使用 navigator.usb.requestDevice() 选择单个已连接的 USB 设备,也可以调用 navigator.usb.getDevices() 来获取网站有权访问的所有已连接 USB 设备的列表。

navigator.usb.requestDevice() 函数接受用于定义 filters 的强制性 JavaScript 对象。这些过滤器用于将任何 USB 设备与给定的供应商 (vendorId) 和(可选)产品 (productId) 标识符进行匹配。您还可以在其中定义 classCodeprotocolCodeserialNumbersubclassCode 键。

Chrome 中显示的 USB 设备用户提示的屏幕截图
USB 设备用户提示。

例如,下面展示了如何访问已配置为允许源的已连接 Arduino 设备。

navigator.usb.requestDevice({ filters: [{ vendorId: 0x2341 }] })
.then(device => {
  console.log(device.productName);      // "Arduino Micro"
  console.log(device.manufacturerName); // "Arduino LLC"
})
.catch(error => { console.error(error); });

在您提出疑问之前,我先说明一下,这个 0x2341 十六进制数并非凭空而来。我只需在此 USB ID 列表中搜索“Arduino”一词即可。

上述已执行 promise 中返回的 USB device 包含一些基本但重要的设备信息,例如支持的 USB 版本、最大数据包大小、供应商和产品 ID,以及设备可能具有的可能配置数量。它基本上包含设备 USB 描述符中的所有字段。

// Get all connected USB devices the website has been granted access to.
navigator.usb.getDevices().then(devices => {
  devices.forEach(device => {
    console.log(device.productName);      // "Arduino Micro"
    console.log(device.manufacturerName); // "Arduino LLC"
  });
})

顺便提一下,如果 USB 设备宣布支持 WebUSB 并定义了着陆页网址,Chrome 会在用户插入 USB 设备时显示一个永久性通知。点击此通知即可打开着陆页。

Chrome 中的 WebUSB 通知的屏幕截图
WebUSB 通知。

与 Arduino USB 板交谈

好的,现在我们来看看如何通过 USB 端口从与 WebUSB 兼容的 Arduino 开发板进行通信。请参阅 https://github.com/webusb/arduino 中的说明,为您的草稿启用 WebUSB。

别担心,我将在本文后面介绍下文中提到的所有 WebUSB 设备方法。

let device;

navigator.usb.requestDevice({ filters: [{ vendorId: 0x2341 }] })
.then(selectedDevice => {
    device = selectedDevice;
    return device.open(); // Begin a session.
  })
.then(() => device.selectConfiguration(1)) // Select configuration #1 for the device.
.then(() => device.claimInterface(2)) // Request exclusive control over interface #2.
.then(() => device.controlTransferOut({
    requestType: 'class',
    recipient: 'interface',
    request: 0x22,
    value: 0x01,
    index: 0x02})) // Ready to receive data
.then(() => device.transferIn(5, 64)) // Waiting for 64 bytes of data from endpoint #5.
.then(result => {
  const decoder = new TextDecoder();
  console.log('Received: ' + decoder.decode(result.data));
})
.catch(error => { console.error(error); });

请注意,我使用的 WebUSB 库只是实现一个示例协议(基于标准 USB 串行协议),制造商可以根据需要创建任何集合和类型的端点。控制传输对于小型配置命令特别有用,因为它们具有总线优先级和明确定义的结构。

下面是已上传到 Arduino 开发板的示例程序。

// Third-party WebUSB Arduino library
#include <WebUSB.h>

WebUSB WebUSBSerial(1 /* https:// */, "webusb.github.io/arduino/demos");

#define Serial WebUSBSerial

void setup() {
  Serial.begin(9600);
  while (!Serial) {
    ; // Wait for serial port to connect.
  }
  Serial.write("WebUSB FTW!");
  Serial.flush();
}

void loop() {
  // Nothing here for now.
}

上述示例代码中使用的第三方 WebUSB Arduino 库基本上执行了两项操作:

  • 该设备充当 WebUSB 设备,让 Chrome 能够读取着陆页网址
  • 它会公开一个 WebUSB 串行 API,您可以使用该 API 替换默认 API。

再来看看 JavaScript 代码。获取用户选择的 device 后,device.open() 会运行所有平台专用步骤,以便与 USB 设备启动会话。然后,我必须使用 device.selectConfiguration() 选择一个可用的 USB 配置。请注意,配置会指定设备的电源方式、最大功耗以及接口数量。说到接口,我还需要使用 device.claimInterface() 请求独占访问权限,因为只有在声明接口后,数据才能传输到接口或关联的端点。最后,需要调用 device.controlTransferOut() 以使用适当的命令设置 Arduino 设备,以便通过 WebUSB 串行 API 进行通信。

然后,device.transferIn() 会对设备执行批量传输,以告知设备主机已准备好接收批量数据。然后,使用包含 DataView dataresult 对象来实现该 promise,该 data 必须进行适当解析。

如果您熟悉 USB,那么所有这些都应该非常熟悉。

我想要更多

借助 WebUSB API,您可以与所有 USB 传输/端点类型进行交互:

  • CONTROL 传输(用于向 USB 设备发送或接收配置或命令参数),通过 controlTransferIn(setup, length)controlTransferOut(setup, data) 进行处理。
  • 用于少量具有时间敏感性的数据的 INTERRUPT 传输的处理方法与使用 transferIn(endpointNumber, length)transferOut(endpointNumber, data) 的 BULK 传输相同。
  • 用于视频和声音等数据流的 ISOCHRONOUS 传输通过 isochronousTransferIn(endpointNumber, packetLengths)isochronousTransferOut(endpointNumber, data, packetLengths) 进行处理。
  • 批量传输用于以可靠的方式传输大量非实时数据,可通过 transferIn(endpointNumber, length)transferOut(endpointNumber, data) 进行处理。

您还可以查看 Mike Tsao 的 WebLight 项目,该项目提供了一个从头开始构建专为 WebUSB API 设计的 USB 控制 LED 设备的示例(此处不使用 Arduino)。您会看到硬件、软件和固件。

撤消对 USB 设备的访问权限

网站可以通过对 USBDevice 实例调用 forget() 来清理访问不再需要的 USB 设备的权限。例如,对于在与多台设备共用的计算机上使用的教育类 Web 应用,如果累积了大量用户生成的权限,就会导致用户体验不佳。

// Voluntarily revoke access to this USB device.
await device.forget();

由于 forget() 在 Chrome 101 或更高版本中提供,请通过以下方式检查设备是否支持此功能:

if ("usb" in navigator && "forget" in USBDevice.prototype) {
  // forget() is supported.
}

转移大小限制

某些操作系统会对待处理的 USB 事务中可以包含的数据量施加限制。将数据拆分为较小的事务,并一次只提交几个事务,有助于避免这些限制。它还可以减少使用的内存量,并允许您的应用在传输完成时报告进度。

由于提交到端点的多个传输始终按顺序执行,因此可以通过提交多个队列分块来提高吞吐量,以避免 USB 传输之间的延迟。每次传输完一个分块时,它都会通知您的代码应提供更多数据,如下面的辅助函数示例所记录。

const BULK_TRANSFER_SIZE = 16 * 1024; // 16KB
const MAX_NUMBER_TRANSFERS = 3;

async function sendRawPayload(device, endpointNumber, data) {
  let i = 0;
  let pendingTransfers = [];
  let remainingBytes = data.byteLength;
  while (remainingBytes > 0) {
    const chunk = data.subarray(
      i * BULK_TRANSFER_SIZE,
      (i + 1) * BULK_TRANSFER_SIZE
    );
    // If we've reached max number of transfers, let's wait.
    if (pendingTransfers.length == MAX_NUMBER_TRANSFERS) {
      await pendingTransfers.shift();
    }
    // Submit transfers that will be executed in order.
    pendingTransfers.push(device.transferOut(endpointNumber, chunk));
    remainingBytes -= chunk.byteLength;
    i++;
  }
  // And wait for last remaining transfers to complete.
  await Promise.all(pendingTransfers);
}

提示

借助内部页面 about://device-log,您可以更轻松地在 Chrome 中调试 USB,在一个位置查看所有与 USB 设备相关的事件。

用于在 Chrome 中调试 WebUSB 的设备日志页面的屏幕截图
Chrome 中的设备日志页面,用于调试 WebUSB API。

内部页面 about://usb-internals 也很实用,可让您模拟虚拟 WebUSB 设备的连接和断开连接。这对于在不使用真实硬件的情况下进行界面测试非常有用。

在 Chrome 中调试 WebUSB 的内部页面的屏幕截图
Chrome 中用于调试 WebUSB API 的内部页面。

在大多数 Linux 系统中,USB 设备默认映射为具有只读权限。如需允许 Chrome 打开 USB 设备,您需要添加新的 udev 规则。在 /etc/udev/rules.d/50-yourdevicename.rules 下创建一个文件,其中包含以下内容:

SUBSYSTEM=="usb", ATTR{idVendor}=="[yourdevicevendor]", MODE="0664", GROUP="plugdev"

例如,如果您的设备是 Arduino,则 [yourdevicevendor]2341。您还可以添加 ATTR{idProduct},以便获得更具体的规则。确保您的 userplugdev 群组的成员。然后,只需重新连接设备即可。

资源

使用 # 标签 #WebUSB@ChromiumDev 发送一条推文,告诉我们您使用它的位置和方式。

致谢

感谢 Joe Medley 审核本文。