WebUSBことはじめ

この記事は[Muroran Institute of Technology Advent Calendar 2018](https://adventar.org/calendars/3589)の14日目の記事になりました。

# はじめに
皆さん、WebUSB使ってますか[^1]？WebUSBは標準化もされたばかりで、まだまだ知見も溜まってきていないところではありますが、Webの世界を物理デバイスから拡張できる可能性を秘めたAPIです。ちょっと使い方が難しいAPIではありますが、これが使えると世界がぐっと広がりますのでこの記事を読んでぜひご自身でいろんな工作を楽しんでみてください！

# 執筆環境
この記事は以下の環境で書かれています。WebUSBはOS標準のドライバが間に挟まると途端に動かなくなったりします。また、セキュリティ上接続が拒否されるデバイスもあります[^2]。動作が確認されていない環境で動かすのは、場合によってはかなり難易度が高くなることに注意してください。また、USBデバイスの例として国内で普及しているNFCリーダーである`PaSoRi RC-S380`を使用します。

- macOS Mojave 10.14.2
- Google Chrome 71[^3]
- PaSoRi RC-S380[^4]

# WebUSBでデバイスと接続する
ではいよいよWebUSBを使ってデバイスに接続してみましょう。Google Chromeを起動して、JavaScriptコンソールを用意してください。

## VendorIDとProductIDの特定
WebUSBでUSBデバイスに接続を要求するには`navigator.usb.requestDevice({'filters': []})`を叩けば良いのですが、そのままだと目的としていないデバイスまで一覧に表示されてしまいます。試しにやってみましょう。コンソールに以下のコードを打ち込むと、接続できるデバイスの一覧がポップアップ画面に表示されます。目的とするRC-S380以外にも、キーボードやトラックパッドまで表示されてしまっています。ユーザーが意図しないデバイスを選択しないためにも、フィルター条件としてVendorIDとProductIDを指定しておきます。

```javascript:WebUSBの接続要求
// Promiseが返ってくるためawaitします
await navigator.usb.requestDevice({'filters': []})
```

![フィルターなし状態でデバイスに接続しようとした場合](https://qiita-image-store.s3.amazonaws.com/0/53168/4a796df4-e718-178e-37a8-0b29be40b033.jpeg)

VendorIDとProductIDを特定するのは簡単で、Linuxの場合`lsusb`コマンドを、macOSの場合`system_profiler SPUSBDataType`コマンドを利用すれば、特定することができます。macOSでの例を以下に示します。

```terminal:RC-S80のVendorIDとProductIDの特定
$ system_profiler SPUSBDataType
USB:

    USB 3.0 Bus:

      Host Controller Driver: AppleUSBXHCISPT

      ~中略~

        RC-S380/S:

          Product ID: 0x06c1
          Vendor ID: 0x054c  (Sony Corporation)
          Version: 1.11
          Serial Number: 0563527
          Speed: Up to 12 Mb/sec
          Manufacturer: SONY
          Location ID: 0x14200000 / 7
          Current Available (mA): 500
          Extra Operating Current (mA): 0
```

VendorIDとProductIDが特定できたら、今度はフィルターを設定してから叩いてみます。今度こそ目的のデバイスだけを表示することができるようになりました。

```javascript:フィルターを設定してUSB接続要求を実施
const vendor_id = 0x054c
const product_id = 0x06c1
await navigator.usb.requestDevice(
  {
    'filters': [
      {'vendorId': vendor_id, 'product_id': product_id}
    ]
  }
)
```

![フィルターを指定してデバイスに接続しようとした場合](https://qiita-image-store.s3.amazonaws.com/0/53168/6e86ac34-78b8-1655-dea3-463442104479.jpeg)

## デバイスの初期化
デバイスの接続要求をしたら、次はデバイスを開いて初期化する作業が続きます。USBデバイスの初期化には、`Configuration`と`Interface`を調べる必要があります。まずは以下のコードをコンソールから叩いて、この2つの値を読み出します。以下のコードを入力してみましょう。`device.configurations`を呼ぶと、そのUSBデバイスで利用可能なConfigurationの一覧が`USBConfiguration`オブジェクトの配列で返ってきます。

```javascript:ConfigurationとInterfaceの確認
// デバイスの接続要求
// ポップアップが出るので接続するデバイスを選択すること
const device = await navigator.usb.requestDevice(
  {
    'filters': [
      {'vendorId': vendor_id, 'product_id': product_id}
    ]
  }
)
// Configurationsの表示
device.configurations
```

<img width="677" alt="USBConfigurationオブジェクトが返ってきた様子" src="https://qiita-image-store.s3.amazonaws.com/0/53168/f2ca9c5b-34ab-354d-0efb-d2701141d2b3.png">

今回は`USBConfiguration`オブジェクトが1つしか存在しないので、そこに記載されている情報を元に初期化していきます。`USBConfiguration`オブジェクトの`configurationValue`フィールドに、Configuration番号が数値で記載されているのでこれをメモしておきます。また、`USBInterface`オブジェクトの配列形式で、`interfaces`フィールドにこのUSB機器が受け付けるインターフェース情報が格納されています。これも今回ひとつしか存在していませんので、`interfaceNumber`フィールドに格納されている数値をメモしておきます。

ここでついでに、USB通信の出口と入口を確認しておきます。`USBInterface`オブジェクトの`alternate`フィールドの中に`USBAlternateInterface`が格納されています。さらにその中にある`endpoints`フィールドに`USBEndpoint`オブジェクトが配列形式で格納されています。この中にある`in`と`out`の`endpointNumber`を控えておきます。`in`がデバイスからデータを受け取るエンドポイントで、`out`がデバイスにデータを送るエンドポイントです。

<img width="619" alt="USB通信の出入り口の確認" src="https://qiita-image-store.s3.amazonaws.com/0/53168/1f94d88e-e0e1-a854-45dc-1e118240173b.png">

最後に、次のようにデバイスを初期化します。

```javascript:デバイスの初期化
// いずれもPromiseが返るので、awaitします
await device.open()
await device.selectConfiguration(1)
await device.claimInterface(0)
```

## Let's USB通信
### Send ACK!!!
デバイスの初期化も終わり、デバイスと接続できたのでいよいよUSBデバイスと通信をします。ではまずUSBパケットを組み立てましょう。Uint8Arrayオブジェクトにデータを詰め込んで送信します。まず例として、RC-S380にACKパケットを送ってみます。RC-S380の送信エンドポイントは`2`だったので、`transferOut`メソッドを使ってそこに向けてデータを出力します。うまく行くと`USBOutTransferResult`オブジェクトが返ってきます。

```javascript:ACKパケットの送信
const ack_packet = Uint8Array.of(0x00, 0x00, 0xff, 0x00, 0xff, 0x00)
await device.transferOut(2, ack_packet)
```

<img width="381" alt="スクリーンショット 2018-12-20 14.12.43.png" src="https://qiita-image-store.s3.amazonaws.com/0/53168/112fa59c-c8d3-ddc7-ae45-1679142bb04d.png">

### RC-S380のパケットを組み立てる
ACKを送ったらRC-S380に対して`CommandType`を`1`に設定する命令を送信します。`CommandType`を設定する命令は`0x2a`で、引数に`0x01`を取ればいいだけなのですが、そのまま`0x2a 0x01`を送っても何の反応もありません。仕様に従って専用のパケットを組み立てる必要があります。

まずコマンドヘッダである`0xd6`をコマンドの先頭にくっつける必要があります。従って送信するコマンドは`0xd6 0x2a 0x01`となります。さらに、この末尾に2バイト分のチェックサムをリトルエンディアンでくっつけます。チェックサムは以下の計算式で定義されています。

```math
\mbox{checksum} = \left(256 - \sum\left(\mbox{コマンド}\right)\right) \bmod 256
```

今回の場合はチェックサムが`255`となりますので、16進数化してリトルエンディアンにすると`0xff 0x00`となります。これをくっつけてコマンドは`0xd6 0x2a 0x01 0xff 0x00`となります。

```math
\left( 256 - (\mbox{0xd6} + \mbox{0x2a} + \mbox{0x01}) \right) \bmod 256 = 255
```

これで終わりかと思いきや、まだまだ続きがあります。次はパケット自体のヘッダを組み立てる必要があります。ヘッダの仕様は以下の通りです。

| オフセット | 0x00 | 0x01 | 0x02 | 0x03 | 0x04 | 0x05   | 0x06   | 0x07        |
|------------|------|------|------|------|------|-------|--------|--------------|
| 値         | 0x00 | 0x00 | 0xff | 0xff | 0xff | [コマンド | サイズ] | チェックサム |

コマンドサイズはコマンドヘッダを含み、コマンドのチェックサムを含まない部分で定義されていますので、今回は`3`となります。これをリトルエンディアンでくっつけます。従って今回の場合`0x03 0x00`となります。チェックサムはコマンドサイズのチェックサムとなっています。計算方法は先ほどと同じです。`0x03 0x00`の和から計算すると`0xfd`となるのでこれをくっつけます。

最後にパケットヘッダとコマンドをくっつけて、送信してみましょう。

```javascript:SetCommandType1の送信
const set_command_type_packet = Uint8Array.of(0x00, 0x00, 0xff, 0xff, 0xff, 0x03, 0x00, 0xfd, 0xd6, 0x2a, 0x01, 0xff, 0x00)
await device.transferOut(2, set_command_type_packet)
```

### パケットの受信
では`SetCommandType`がうまく行ったか確認するため、USBデバイスから送り返されてくるACKを受信します。RC-S380ではコマンドを送った後まずACKが送り返されてきて、そのあとコマンドに対する返り値が送り返される仕様になっています。そのため、コマンド送信後には必ず2回データを受信する必要があります[^5]。

受信にはさっきと逆の`transferIn`メソッドを使います。受信エンドポイントは`1`だったので、そこからデータを受信しましょう。なおデータを受信する際には受信バイト数を指定する必要があります。[nfcpyのソースコード](https://github.com/nfcpy/nfcpy/blob/master/src/nfc/clf/rcs380.py#L895)によると、RC-S380の最大受信バイト数は290バイトらしいので、その数を利用します。必要な分だけを受信すれば良さそうにも思いますが、データ長を事前に知る方法を今のところ見つけていないので、最大受信バイト数分一気に受信することにします。`USBTransferResult`オブジェクトが返ってくるので、`data`フィールドに入っている`DataView`オブジェクトの`buffer`フィールドを読んで、`0x00 0x00 0xff 0x00 0xff 0x00`が返ってきていればOKです。

```javascript:ACKパケットの受信
await device.transferIn(1, 290)
```

<img width="415" alt="ACKパケットの受信" src="https://qiita-image-store.s3.amazonaws.com/0/53168/3660d9b9-7c7f-b971-aac8-d46294c14e40.png">

続いて`SetCommandType`に対する応答パケットを読み取ります。同じく最大受信バイト数を指定してデータを受信しましょう。おそらく以下のようなデータが返ってくると思います。応答パケットの内訳は送信パケットと同じなので、先頭8バイトがパケットヘッダ、その次が応答ヘッダ、最後の2バイトがチェックサムとなっているので、残った`0x2b 0x00`が正味の応答パケットとなります[^6]。これで一通りWebUSBを用いてUSB機器と通信することができました。ちなみにこの後NFCリーダーを使ってカードと通信するにはもっともっと手順を踏む必要がありますが、この記事からは逸脱するのでこれ以上は触れません。

```javascript:SetCommandTypeに対する応答パケットの受信
await device.transferIn(1, 290)
```

<img width="623" alt="SetCommandTypeに対する応答パケットの受信" src="https://qiita-image-store.s3.amazonaws.com/0/53168/50f4a3c4-fa4c-3c99-4929-6c7df3216d6e.png">

| 0x00 | 0x01 | 0x02 | 0x03 | 0x04 | 0x05 | 0x06 | 0x07 | 0x08 | 0x09 | 0x0a | 0x0b | 0x0c |
|------|------|------|------|------|------|------|------|------|------|------|------|------|
| 0x00 | 0x00 | 0xff | 0xff | 0xff | 0x03 | 0x00 | 0xfd | 0xd7 | 0x2b | 0x00 | 0xfe | 0x00 |

# おわりに
ここまでWebUSBを使ってJavaScriptからUSB機器を扱う方法を見てきました。結局のところ、USBの生パケットを組み立てたりパースしたりしながら送受信するということで、やっていることは低レイヤな感じになります。USBでつながっているものはだいたい制御できるので、ブラウザからArduinoとかをいじったりすることもできますし、[USBメモリを読み込んでいる先人](https://speakerdeck.com/fadis/webusbdereiyagadi-maruwebkai-fa)もいたりします。

皆さんもぜひUSBパケットと戯れて、JavaScript用の様々なUSBドライバーの開発にいそしんでみてはいかがでしょうか。それではまた。

[^1]: 私は色々あってかなり使い込んでます...
[^2]: [blink-devのML](https://groups.google.com/a/chromium.org/forum/#!msg/blink-dev/LZXocaeCwDw/GLfAffGLAAAJ)に詳細がありますが、オーディオ機器やスマートカードリーダー、ワイヤレス機器などはWebUSBからの接続がブロックされています。`RC-S380`はスマートカードリーダーに当たるような気がしますが、今のところなぜかブロックされていません。
[^3]: 今のところWebUSBはChromeやOperaなどChromiumをベースにしたブラウザでしか動きません。
[^4]: PaSoRi RC-S380もドライバが挟まると動かなくなります。とは言ってもWindows用のドライバしか存在しないので、macOSでは普通に接続できます。Linuxではrootなしでアクセスできるようにする[設定](https://qiita.com/frameair/items/596724fc2f3438ea7925)が必要です。
[^5]: なおACKパケットに対しては何も送り返してきません。`transferIn`しても次に何か出力があるまで無反応になります。
[^6]: この応答パケットの意味は仕様書を読んでいないのでよくわかってないです...