通过网页访问 USB 设备

WebUSB API 支持 Web,通过将其引入到 Web 中,提高了 USB 的安全性和易用性。

François Beaufort
François Beaufort

如果我简单直白地说“USB”,您很有可能会立即想到键盘、鼠标、音频、视频和存储设备。没错,不过您会发现其他种类的通用串行总线 (USB) 设备。

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

但最重要的是,将 USB 引入 Web 中,这将使 USB 更安全、更易于使用

我们来看一看 WebUSB API 的预期行为:

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

好啦!

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

  1. 安装针对具体平台的应用。
  2. 如果我的操作系统甚至支持它,请验证我下载的内容是否正确。
  3. 安装游戏。如果幸运,您将不会收到可怕的操作系统提示,也不会看到关于从互联网安装驱动程序/应用的弹出式窗口。如果运气不好,安装的驱动程序或应用就会出现故障并损害您的计算机。(请注意,网络的初衷是包含运行异常的网站)。
  4. 如果您只使用一次该功能,该代码就会一直保留在您的计算机上,直到您考虑将其移除。(在 Web 上,未使用的空间最终会被回收)。

开始前须知

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

Chrome 61 支持 WebUSB API

可用于源试用

为了从实际使用 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 教程。此外,() => {} 只是 ECMAScript 2015 箭头函数

获取 USB 设备的访问权限

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

navigator.usb.requestDevice() 函数采用强制性的 JavaScript 对象来定义 filters。这些过滤条件用于匹配具有指定供应商 (vendorId) 标识符和(可选)产品 (productId) 标识符的任何 USB 设备。也可以在其中定义 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 Serial API,您可以使用它来替换默认 API。

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

然后,device.transferIn() 会将数据批量传输到设备上,以告知它主机已准备好接收批量数据。然后,使用包含必须正确解析的 DataView dataresult 对象来执行 promise。

如果您熟悉 USB,那么所有这些看起来都会非常熟悉。

我想要更多

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

  • 用于向 USB 设备发送或接收配置或命令参数的 CONTROL 传输作业通过 controlTransferIn(setup, length)controlTransferOut(setup, data) 进行处理。
  • INTERRUPT 传输用于处理少量时效性数据,其处理方法与使用 transferIn(endpointNumber, length)transferOut(endpointNumber, data) 的 BULK 传输的处理方式相同。
  • ISOCHRONOUS 传输(用于视频和声音等数据流)通过 isochronousTransferIn(endpointNumber, packetLengths)isochronousTransferOut(endpointNumber, data, packetLengths) 处理。
  • BULK 传输用于以可靠方式传输大量非时间敏感数据,它通过 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,您可以在一个位置查看所有与 USB 设备相关的事件,从而更轻松地在 Chrome 中调试 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 审核本文。