在網路上存取 USB 裝置

WebUSB API 可將 USB 裝置帶入網路,讓使用者更安全、更輕鬆地使用 USB。

François Beaufort
François Beaufort

如果我簡單地說「USB」,你很可能會立刻想到鍵盤、滑鼠、音訊、視訊和儲存空間裝置。你說得沒錯,但市面上也有其他類型的通用序列匯流排 (USB) 裝置。

這些非標準 USB 裝置需要硬體供應商編寫平台專屬的驅動程式和 SDK,才能讓您 (開發人員) 加以利用。很遺憾,這段平台專屬程式碼在過去曾阻止這些裝置使用網路。這也是我們建立 WebUSB API 的原因之一:提供一種方法,讓 USB 裝置服務可公開至網路。有了這個 API,硬體製造商就能為自家裝置建構跨平台 JavaScript SDK。

但最重要的是,將 USB 帶入網路,可讓 USB 更安全、更容易使用。

以下是 WebUSB API 的預期行為:

  1. 購買 USB 裝置。
  2. 將點字顯示器連接至電腦。系統會立即顯示通知,並提供適合這部裝置的網站。
  3. 按一下通知。網站已上線,可以開始使用了!
  4. 點選「連線」後,Chrome 會顯示 USB 裝置選擇器,讓你選擇裝置。

完成!

如果沒有 WebUSB API,這個程序會是什麼樣子?

  1. 安裝特定平台的應用程式。
  2. 即使我的作業系統支援,也請確認我下載的東西是否正確。
  3. 安裝裝置。運氣好的話,您不會看到嚇人的 OS 提示或彈出式視窗,警告您從網際網路安裝驅動程式/應用程式。不幸的是,安裝的驅動程式或應用程式可能會發生故障,並損害電腦。(請注意,網路的設計目的是包含故障的網站)。
  4. 如果您只使用這項功能一次,程式碼會保留在電腦上,直到您決定移除為止。(在網路上,未使用的空間最終會回收)。

事前說明

本文假設您具備 USB 運作方式的基本知識。如果沒有,建議您閱讀「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。如果您不熟悉這些概念,請參閱這份實用的承諾教學課程。另外,() => {} 只是 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」這個字詞即可。

在上述已履行的應許中傳回的 USB device 包含一些基本但重要的裝置資訊,例如支援的 USB 版本、最大封包大小、供應商和產品 ID,以及裝置可能有的設定數量。基本上,它包含裝置 USB Descriptor 中的所有欄位。

// 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 板進行通訊。如要啟用 WebUSB 的草圖,請參閱 https://github.com/webusb/arduino 的操作說明。

別擔心,我會在本文稍後介紹下方提到的所有 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 覆寫預設 API。

再次查看 JavaScript 程式碼。取得使用者選取的 device 後,device.open() 會執行所有平台專屬步驟,以便透過 USB 裝置啟動工作階段。接著,我必須使用 device.selectConfiguration() 選取可用的 USB 設定。請注意,設定會指定裝置的供電方式、最大耗電量和介面數量。談到介面,我還需要使用 device.claimInterface() 要求專屬存取權,因為只有在宣告介面時,資料才能傳輸至介面或相關聯的端點。最後,您需要呼叫 device.controlTransferOut(),才能使用適當的指令設定 Arduino 裝置,以便透過 WebUSB Serial API 進行通訊。

接著,device.transferIn() 會在裝置上執行大量轉移作業,通知裝置主機已準備好接收大量資料。接著,使用含有 DataView dataresult 物件來履行承諾,並適當地剖析該物件。

如果您熟悉 USB,這些都應該不陌生。

我想要更多

您可以使用 WebUSB API 與所有 USB 傳輸/端點類型互動:

  • 用於傳送或接收設定或指令參數至 USB 裝置的控制傳輸,會由 controlTransferIn(setup, length)controlTransferOut(setup, data) 處理。
  • 中斷傳輸 (用於少量時間敏感資料) 的處理方式與使用 transferIn(endpointNumber, length)transferOut(endpointNumber, data) 的大量傳輸相同。
  • 用於視訊和音訊等資料串流的等時傳輸,會由 isochronousTransferIn(endpointNumber, packetLengths)isochronousTransferOut(endpointNumber, data, packetLengths) 處理。
  • 大量傳輸作業可用於以可靠的方式傳輸大量非時效性資料,並由 transferIn(endpointNumber, length)transferOut(endpointNumber, data) 處理。

您也可以參考 Mike Tsao 的 WebLight 專案,其中提供從頭開始建立 USB 控制 LED 裝置的範例,這個裝置是專為 WebUSB API 設計 (這裡不使用 Arduino)。您會看到硬體、軟體和韌體。

撤銷 USB 裝置的存取權

網站可以在 USBDevice 例項上呼叫 forget(),藉此清除存取不再需要的 USB 裝置的權限。舉例來說,如果在有多部裝置共用的電腦上使用教育用途的網路應用程式,大量累積的使用者產生權限會導致使用者體驗不佳。

// 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 裝置的連線和斷線情形。這在進行 UI 測試時相當實用,而且不需要使用實際的硬體。

在 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 審查本文。