p5.jsからWebUSBでカッティングプロッターを動かす

# はじめに

Hamamatsu Micro Maker Faire 2023でカッティングプロッターをWebUSBで動かすというのを展示したのですが、WebUSBについてまとめていなかったままだったのでまとめておくことにしました。
下記展示ではScratch拡張機能としてJavascriptで実装したのですが、WebUSBだけを簡単に試せるように今回はp5.jsにて説明しています。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">明日のHamamatsu Micro Maker Faire 2023 のYara:Makersブースで「Scratchでペンプロッター」を出展します。MFT2023に出したのと見た目はほとんど同じですが、WebUSBを使ってブラウザから直接プロッターに出力できるようになりました！<br>※本人は別のブースにいます。 <a href="https://twitter.com/hashtag/HamamatsuMMF?src=hash&amp;ref_src=twsrc%5Etfw">#HamamatsuMMF</a> <a href="https://t.co/kE1iXNxK6o">pic.twitter.com/kE1iXNxK6o</a></p>&mdash; テツオ (@ktetsuo) <a href="https://twitter.com/ktetsuo/status/1730604555879244091?ref_src=twsrc%5Etfw">December 1, 2023</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## WebUSBとは

WebUSBとは、WebブラウザからJavaScriptを使用して手元のUSB機器を制御する技術です。
インターネットのサイトにアクセスするとJavaScriptで書かれた制御コードが読み込まれ、手元の環境のUSB機器を直接制御することができます。

![WebUSB模式図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70022/87f416c0-628a-ae12-6629-dc6f1145252c.png)

検索のサジェストが「WebUSB 廃止」「WebUSB 危険性」とか怪しいですが…
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70022/9a2361f5-7b80-d5bb-81d6-47e641e3a835.png)

見ず知らずのWebサイトからUSB機器が勝手に操作されないように、いろいろな制限があります。
* デバイス使用前に必ずポップアップが出て使用許可を求められます。
* HTTPS対応のサイトでしか使用できません。

また、ブラウザ側の実装が安定しておらず、Google Chromeでしか動作しません。この記事の内容もChromeでしか動作確認をしていません。

## p5.jsとは

Processing（Javaベースのビジュアルプログラミング言語）を、ブラウザ上で実行できるようにJavasriptで書けるようにしたものです。[p5.js Web Editor](https://editor.p5js.org/)にアクセスするだけでProcessingによるプログラミングができるようになります。
JavaScriptが動くので、WebUSBが使えます。また、p5.js Web EditorはHTTPSなのでサーバーを用意する必要もありません。

# WebUSBの使い方

本題です。今回は、USBデバイスとしてローランドDG製のカッティングプロッター「[iDecora iD-01](https://www.rolanddg.com/ja/news/2012/121101-idecora)（※生産終了）」を使用しています。
| 項目 | 環境 |
|:-:|:-:|
| OS | Windows 11 Home 23H2 |
| ブラウザ | Google Chtome |
| USBデバイス | iDecora iD-01 |

Linux(Raspberry Pi)でもWebUSBの出力に成功しましたが、また別の機会で。

## ソースコード

今回作成したソースコードは下記です。
[p5.js Web Editorのリンク](https://editor.p5js.org/ktetsuo/sketches/-J1FLKypZ)

```javascript:sketch.js
/* jshint esversion: 8 */ //asyncを使うため

let startButton;
let writeButton;
let usbDevice;

function setup() {
  createCanvas(400, 100);

  // ボタンを作成
  startButton = createButton("Start USB Connection");
  startButton.mousePressed(connectUSB);
  startButton = createButton("End USB Connection");
  startButton.mousePressed(disconnectUSB);
  writeButton = createButton("Write Data");
  writeButton.mousePressed(writeData);
}

function draw() {
  background(220);
}

// WriteDataボタンが押されたときに呼ばれる
async function writeData() {
  const textData = "PU0,0;PU0,1000;PU1000,1000;PU1000,0;PU0,0;";  // 送信するコマンド
  try {
    // データ転送
    const dataArray = new TextEncoder().encode(textData);  // テキストをバイト列にエンコード
    const endpoint = 1;   // Alternate内のEndpoint番号を指定
    console.log("transferOut Start");
    await usbDevice.transferOut(endpoint, dataArray);  // 送信
    console.log("transferOut End");
  } catch(error) {
    console.error(error);
  }
}

// USB接続
async function connectUSB() {
  try {
    // USBデバイスを列挙するためのフィルタ
    const filters = []; // フィルタなし = 全てのUSBデバイスを列挙
    // フィルタをしたい場合はこちら
    // const filters = [
    //   { vendorId: 0x0b75 }  // 0b75: Roland DG Corporation
    // ];

    // USBデバイスの接続要求
    usbDevice = await navigator.usb.requestDevice({ filters: filters });
    // USBデバイスの詳細を表示
    printUSBDevice(usbDevice);
    // USBデバイスをオープン
    console.log("open");
    await usbDevice.open();
    // USBデバイスの設定
    const configuration = 1;  // Configuration番号を指定
    const interface = 0;  // Configuration内のInterface番号を指定
    const alternate = 0;  // Interface内のAlternate番号を指定
    console.log("selectConfiguration");
    await usbDevice.selectConfiguration(configuration); // Configurationを選択
    console.log("claimInterface");
    await usbDevice.claimInterface(interface);  // Interfaceを請求
    console.log("selectAlternateInterface");
    await usbDevice.selectAlternateInterface(interface, alternate);  // InterfaceのAlternateを選択
  } catch (error) {
    console.error(error);
  }
}

async function disconnectUSB() {
  // USBデバイスをクローズ
  await usbDevice.close();
}

// USBデバイスの詳細を表示する
function printUSBDevice(usbDevice) {
  console.log(usbDevice);

  console.log("Vendor ID     : 0x" + usbDevice.vendorId.toString(16));
  console.log("Product ID    : 0x" + usbDevice.productId.toString(16));
  console.log("Manufacturer  : " + usbDevice.manufacturerName); // 製造者名
  console.log("Product Name  : " + usbDevice.productName); // 製品名
  console.log("Serial Number : " + usbDevice.serialNumber); // シリアル番号
  console.log("Device Class     : 0x" + usbDevice.deviceClass.toString(16));
  console.log("Device Sub Class : 0x" + usbDevice.deviceSubclass.toString(16));
  console.log("Device Protocol  : 0x" + usbDevice.deviceProtocol.toString(16));
  // コンフィギュレーション情報を表示
  usbDevice.configurations.forEach(function (configuration) {
    console.log(
      "Configuration " + configuration.configurationValue + ":"
    );
    // Interface
    configuration.interfaces.forEach(function (interface) {
      console.log("  Interface " + interface.interfaceNumber + ":");
      // Alternate
      interface.alternates.forEach(function (alternate) {
        console.log("    Alternate " + alternate.alternateSetting + ":");
        console.log("      Class     : 0x" + alternate.interfaceClass.toString(16));
        console.log("      Sub Class : 0x" + alternate.interfaceSubclass.toString(16));
        console.log("      Protocol  : 0x" + alternate.interfaceProtocol.toString(16));
        // Endpoint
        alternate.endpoints.forEach(function (endpoint) {
          console.log("      Endpoint " + endpoint.endpointNumber + ":");
          console.log("        Direction  : " + endpoint.direction);
          console.log("        Type       : " + endpoint.type);
          console.log("        Packet Size: " + endpoint.packetSize);
        });
      });
    });
  });
}

```

## 実行してみる

こちらを実行すると、以下の画面が表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70022/809a69c9-73ce-c61b-a108-61f1f8750a3f.png)

「Start USB Connection」をクリックすると、ブラウザにポップアップが表示され、接続されているデバイスが表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70022/8ffb9fcf-ad12-4e57-3b6c-c298cc648f25.png)

ここで接続したデバイス「iD-01」を選択し、「接続」をクリックすると…
接続できると思いきや以下のエラーが出ます。

```
Error: Failed to execute 'open' on 'USBDevice': Access denied.
```

## 汎用USBドライバに差し替え

実はWindows上では **WebUSBは「汎用USBドライバ（WinUSB）」での制御しか対応していません。** この状況としてはiD-01のメーカーが提供した専用のUSBドライバが使用されているため、このようなエラーが出ています。
解決方法として、Windowsドライバ差し替えツールの「Zadig」というソフトを使用します。
[https://zadig.akeo.ie/](https://zadig.akeo.ie/)
サイトの見た目がとても怪しいですが、ソースコードも公開されている（[https://github.com/pbatard/libwdi](https://github.com/pbatard/libwdi)）ようなので信じることにします。

:::note warn
ZadigでUSBドライバを差し替えると、元のドライバが無効になるためメーカーの出力ソフトなどは使えなくなります。
デバイスマネージャから「ドライバーの更新」を選択し、「コンピューターに含まれるドライバ一覧から選択する」あたりから戻すことができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70022/2751835e-234d-ff4b-a924-7949e3ac902c.png)
**戻せなかった場合などは責任が持てませんので、ドライバの差し替えは自己責任でお願いします。**
:::

起動すると、コンボボックスでUSBデバイスが選べます。何も表示されていない場合は、[Options]-[List All Devices]をチェック。

![Zadig](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70022/2b832619-1472-68a0-7f79-bf7025386707.png)

「iD-01」を選択して「WinUSB」を選択し、「Replace Driver」を実行します。
インストールに結構時間がかかりますが待ちます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70022/4a6bd3cd-f716-2a3d-6a05-1a14a94d9361.png)

成功したようです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70022/40ffa9d8-147d-e826-081d-6931a1f5c583.png)

## 再実行

WinUSBドライバに入れ替えたところで、改めて実行し、「Start USB Connection」をクリックし、デバイス「iD-01」を選択するとコンソールに下記が出力されます。

```text:コンソール出力

Vendor ID     : 0xb75 
Product ID    : 0x175 
Manufacturer  : Roland DG 
Product Name  : iD-01 
Serial Number : A 
Device Class     : 0x0 
Device Sub Class : 0x0 
Device Protocol  : 0x0 
Configuration 1: 
  Interface 0: 
    Alternate 0: 
      Class     : 0x7 
      Sub Class : 0x1 
      Protocol  : 0x2 
      Endpoint 1: 
        Direction  : out 
        Type       : bulk 
        Packet Size: 64 
      Endpoint 2: 
        Direction  : in 
        Type       : bulk 
        Packet Size: 64 
```

ここで表示されているのは選択したUSBデバイスの内部情報です。Vendor ID（製造者ID）、Product ID（製品ID）などを表示しています。
Confuguration以下はUSBデバイスのエンドポイント（簡単に言うとデータ通信ライン）の論理構成です。Configuration - Interface - Alternate - Endpoint のツリー構造になっています。
ここを見ると、「Configuration 1 - Interface 0 - Alternate 0 - Endpoint 1」が「Direction: out」「Type:bulk」となっています。「Direction: out」はPCからデバイスへの出力の方向を表しており、「Type: bulk」は「バルク転送」といって、大量のデータを送る用途を表しています。したがって、ここがカッティングプロッターにデータを送るエンドポイントであることが予測できます。

`ConnectUSB()`関数の以下の部分で、Configuration 1 - Interface 0 - Alternate 0を使用するために選択しています。

```javascript
    // USBデバイスの設定
    const configuration = 1;  // Configuration番号を指定
    const interface = 0;  // Configuration内のInterface番号を指定
    const alternate = 0;  // Interface内のAlternate番号を指定
    console.log("selectConfiguration");
    await usbDevice.selectConfiguration(configuration); // Configurationを選択
    console.log("claimInterface");
    await usbDevice.claimInterface(interface);  // Interfaceを請求
    console.log("selectAlternateInterface");
    await usbDevice.selectAlternateInterface(interface, alternate);  // InterfaceのAlternateを選択

```

Endpoint 1は、`writeData()`関数内で指定しています。

## カッティングデータの出力

「Start USB Connection」を押して接続した状態で、「Write Data」ボタンをクリックすると、カッティングデータがデバイスに送られます。**うまくいくと、カッティング動作が始まるはずです。**

ソースコードの`writeData()`関数でカッティングのコマンドを出力しています。

## 余談：USBデータの解析方法

どんなデータが送られているかは、WiresharkでUSBラインに流れているデータを見ることができます。WiresharkはEthernet等のネットワークパケットキャプチャとして有名ですが、インストールの途中で「usbpcap」を追加することでUSBのパケットキャプチャもできるようになります。

https://www.wireshark.org/download.html

純正のデータ出力ソフトなどで実際にデータを出力してみて、Wiresharkでデータを眺めてみてください。どのエンドポイントにカッティングデータが流れているかもわかります。


# 最後に

誰が得するかよくわからない記事でしたが、[Qiita Engineer Festa 2024](https://qiita.com/official-campaigns/engineer-festa/2024)にて「[この記事誰得？ 私しか得しないニッチな技術で記事投稿！](https://qiita.com/official-events/d340ce2d6bd2d5b557f5)」というイベントが開催されたこともあり投稿してみました。
WebUSBはブラウザからUSBデバイスを直接制御することができる面白い技術だと思うのですが、普及するかどうかが怪しいです…。