🎮

Rust だけで WebHID を使おうとした話

2022/10/02に公開

概要

タイトルの通り、できる限り javascript を書かずに Rust だけで WebHID API を使おうとした、その記録です。

web-sys crate の web_sys::HID から web_sys::HidDevice を開き、Input Report を取得できるようになるところまで順に見ていきたいと思います。

はじめに

WebHID は未だ W3C のドラフト仕様とのこと。なのでそれをバインドする web_sys::Hid の仕様も今後変更される可能性があるかと思います。

また WebHID は基本的には Chromium 系でのみサポートされていることにもご注意を。
今回の動作確認はすべて Edge で行いました。

あと筆者は基本的には Web 技術も Rust もド素人ですので、そのつもりでご笑覧いただければと思います。

準備

公式リファレンス には wasm-pack という crate を使った WASM ライブラリ作成方法が解説されています。ただこの方法だと npm にガッツリ依存することになり、本業で js に全く縁のない筆者は npm が裏で何やっているのか理解できず、なんとなく気分的に受け入れられませんでした。

そこで trunk という crate を使うことにしました。何をやっているのか分からないのは同じなのですが、 npm からよく分からないモノが勝手にインストールされるよりは crate.io だけに依存しているぶんまだ安心できるのではないかと。
(ちゃんと理解すれば本来は npm も trunk もいらない筈なので、これは完全に気分の問題だと思います)
公式サイトに従ってインストールします。結構時間がかかりました。

プロジェクト設定

web_sys のドキュメント

This API requires the following crate features to be activated: Hid

とある通り、web_sys の中の使いたい機能は明示的に有効化する必要があります。

cargo.toml には以下のように書きました。

[dependencies.web-sys]
version = "0.3.60"
features = [
  "Window",
  "Navigator",
  "Hid", #	The Hid class.
  "HidCollectionInfo", #	The HidCollectionInfo dictionary.
  "HidConnectionEvent", #	The HidConnectionEvent class.
  "HidConnectionEventInit", #	The HidConnectionEventInit dictionary.
  "HidDevice", #	The HidDevice class.
  "HidDeviceFilter", #	The HidDeviceFilter dictionary.
  "HidDeviceRequestOptions", #	The HidDeviceRequestOptions dictionary.
  "HidInputReportEvent", #	The HidInputReportEvent class.
  "HidInputReportEventInit", #	The HidInputReportEventInit dictionary.
  "HidReportInfo", #	The HidReportInfo dictionary.
  "HidReportItem", #	The HidReportItem dictionary.
  "HiddenPluginEventInit", #	The HiddenPluginEventInit dictionary.
  "HidUnitSystem", #	The HidUnitSystem enum.
]

とりあえず WebHID を扱う上で必要そうな featrue は一通りならべてみました。

またドキュメントには、こうも書いてあります。

This API is unstable and requires --cfg=web_sys_unstable_apis to be activated, as described in the wasm-bindgen guide

Crates can opt-in to unstable APIs at compile-time by passing the cfg flag web_sys_unstable_apis. Typically the RUSTFLAGS environment variable is used to do this. For example:
RUSTFLAGS=--cfg=web_sys_unstable_apis cargo run

まだ標準化されていない API を使うからこのような手間が必要なのでしょうね。
毎度指定するのは面倒なので ".cargo/config.toml" を作成し、以下のようにしました。
(参考: RUSTFLAGS の設定方法)

[build]
rustflags = [
  "--cfg=web_sys_unstable_apis",
]

さて、これで web_sys::Hid を使う準備が整ったはずです。
js でいうところの navigator.hid を取得してみます。

let win = web_sys::window().expect("FAILED to get web_sys::Window object.");
let hid = win.navigator().hid();

これはまだブラウザの WebHID API にアクセスできただけなので、次はデバイスを開きます。

HID デバイスを開く

js だと、HID デバイスを要求するためには HID.requestDevice() を呼ぶことになります。 rust バインドされた関数は request_device() ですね。

さて両者の引数を比べると、前者(js)は filters という JSON のようなものを渡してるのに対し、後者(rust バインド関数)の引数の型は &HidDeviceRequestOptions です。HidDeviceRequestOptions::new() の引数の型は &JsValue とあります。この new() 引数が js の HID.requestDevice() 引数である filters であると推測されます。
(なお HidDeviceFilter という構造体も用意はされていますが 、こちらは new() が引数を取らない上に変更可能なフィールドも持っていません)

ところで web_sys を使っていると js の object に相当する js_sys::JsValue なる型が頻繁に現れます。 なので JsValue の動的キャストが頻出します。私見ですが rust で wasm を書いていると、本来の rust の愉しみがこの点において失われているような気がします。

さて、まずは「JSON のような」 filters を作るため、シリアライズできる自作 struct を定義します。

#[derive(Serialize, Deserialize, Debug, Default)]
pub struct WebHidFilter {
    #[serde(rename = "vendorId")]
    pub vendor_id: Option<u16>,
    #[serde(rename = "productId")]
    pub product_id: Option<u16>,
    #[serde(rename = "usagePage")]
    pub usage_page: Option<u16>,
    pub usage: Option<u16>,
}

こんなのを用意してやります。その際 Cargo.toml では serde = { version = "1.0.145", features = ["derive"] } のように "derive" を明示的に有効化しておきます。あと struct のメンバーは snake_case にしないと cargo 先生に怒られるので、シリアライズするときに camelCase に直しています。
これなら配列にしたり、usagePage(usage_page) だけ null(None) にしたりといった記述もいくらか簡単に出来そうです。

今回はどのご家庭にもある Sony の DualShock4 (以下 "DS4") を HID デバイスとして開いてみることにします。
フィルタールールを作るための構造体データは以下のように書けます。

/// from "https://bitbucket.org/unessa/dualshock4-rust/src/master/src/dualshock4/mod.rs"
const DUALSHOCK4_VENDOR_ID:u16 = 0x54c;
// Dualshock4 product ID changed after playstation update 5.50
const DUALSHOCK4_PRODUCT_ID_NEW:u16 = 0x9cc;
const DUALSHOCK4_PRODUCT_ID_OLD:u16 = 0x5c4;

// . . .

    let filters = [
        WebHidFilter {
            vendor_id : Some(DUALSHOCK4_VENDOR_ID),
            product_id : Some(DUALSHOCK4_PRODUCT_ID_NEW),
            usage_page : None,  usage : None,
        }, 
        WebHidFilter {
            vendor_id : Some(DUALSHOCK4_VENDOR_ID),
            product_id : Some(DUALSHOCK4_PRODUCT_ID_OLD),
            usage_page : None,  usage : None,
        }
    ];

これを最終的には js でいうところの HID.requestDevice() に渡せるようにしてあげればいいわけです。
配列型 [WebHidFilter; 2] から JSON へのシリアライズ、あるいは JsValue 型への変換は serde_wasm_bindgen という crate がやってくれます。

let f = serde_wasm_bindgen::to_value(&filters).expect("FAILED to cast `WebHidFilter` to `JsValue`.");

こうして JsValue 型になったフィルタールールの object を先程の HidDeviceRequestOptions::new() の引数にして HidDeviceRequestOptions が生成できたら、これを引数にしてようやく request_device() が呼び出せます。

let options = HidDeviceRequestOptions::new(&f);
let promise = hid.request_device(&options);

… そうです、request_device() の返り値は HidDevice ではなく js_sys::Promise です。なぜならバインドされている js 関数 HID.requestDevice() が非同期処理だから…?まあとにかく async/await 的なことをしないといけないのですね。今回は深入りせず、雰囲気だけ掴んで先に進みます。

js_sys::Promise を rust の非同期タスクとして扱うために wasm-bindgen-futures crate を使います。

let devices = wasm_bindgen_futures::JsFuture::from(promise).await.expect("FAILED to enumerate HID devices.");

ここでようやく HID デバイスの配列が得られるのですが、この時点では devices の型は JsValue です。
この JsValue の実体は HidDevice の配列であって欲しいので、 js_sys::Array に動的キャストします。

let devs_array = devices.dyn_ref::<js_sys::Array>().expect("FAILED to cast the returned value from `request_device()`.");

当然のように、この配列の要素も HidDevice ではなく JsValue です。
取り急ぎ、配列の長さのチェックなどはせずに先頭要素を &HidDevice 型にキャストしてみます。

let device: JsValue = devs_array.at(0);
let device: &HidDevice = device.dyn_ref::<HidDevice>().expect("FAILED to cast `JsValue` in array into `HidDevice`.");

これで、ようやく web_sys::HidDevice 型の値を取得するところまで来ました。

Trunk で動かしてみる

ここまでで "main.rs" は今このようになっています。
(wasm_logger でメッセージを表示を追加しています)

use wasm_bindgen::JsCast;
use serde::{Serialize, Deserialize};
use web_sys::{HidDevice, HidDeviceRequestOptions};

/// from "https://bitbucket.org/unessa/dualshock4-rust/src/master/src/dualshock4/mod.rs"
const DUALSHOCK4_VENDOR_ID:u16 = 0x54c;
// Dualshock4 product ID changed after playstation update 5.50
const DUALSHOCK4_PRODUCT_ID_NEW:u16 = 0x9cc;
const DUALSHOCK4_PRODUCT_ID_OLD:u16 = 0x5c4;

#[derive(Serialize, Deserialize, Debug, Default)]
pub struct WebHidFilter {
    #[serde(rename = "vendorId")]
    pub vendor_id: Option<u16>,
    #[serde(rename = "productId")]
    pub product_id: Option<u16>,
    #[serde(rename = "usagePage")]
    pub usage_page: Option<u16>,
    pub usage: Option<u16>,
}

fn main() {
    wasm_logger::init(wasm_logger::Config::default());

    let win = web_sys::window().expect("FAILED to get web_sys::Window object.");
    let hid = win.navigator().hid();

    // filters for DS4
    let filters = [
        WebHidFilter {
            vendor_id : Some(DUALSHOCK4_VENDOR_ID),
            product_id : Some(DUALSHOCK4_PRODUCT_ID_NEW),
            usage_page : None,  usage : None,
        }, 
        WebHidFilter {
            vendor_id : Some(DUALSHOCK4_VENDOR_ID),
            product_id : Some(DUALSHOCK4_PRODUCT_ID_OLD),
            usage_page : None,  usage : None,
        }
    ];

    let f = serde_wasm_bindgen::to_value(&filters).expect("FAILED to cast `WebHidFilter` to `JsValue`.");
    let options = HidDeviceRequestOptions::new(&f);
    let promise = hid.request_device(&options);
    let devices = wasm_bindgen_futures::JsFuture::from(promise).await.expect("FAILED to enumerate HID devices.");
    let devs_array = devices.dyn_ref::<js_sys::Array>().expect("FAILED to cast the returned value from `request_device()`.");
    let device: JsValue = devs_array.at(0);
    let device: &HidDevice = device.dyn_ref::<HidDevice>().expect("FAILED to cast `JsValue` in array into `HidDevice`.");

    log::debug!("device:{:?}", &device);
}

cargo check します。

   |
24 | fn main() {
   |    ---- this is not `async`
...
47 |     let devices = wasm_bindgen_futures::JsFuture::from(promise).await.expect("FAILED to enumerate HID devices.");
   |                                                                ^^^^^^ only allowed inside `async` functions and blocks

async じゃない関数で await を使ったので怒られてしまいます。
Future を同期実行するために async_std を使い、以下のようにして

// . . .

fn main() {
    wasm_logger::init(wasm_logger::Config::default());

    async_std::task::block_on(hid_device());
}

async fn hid_device() {
    let win = web_sys::window().expect("FAILED to get web_sys::Window object.");
    let hid = win.navigator().hid();
// . . .

これで、いくつか warning は出るものの cargo check は通りました。

次に、とりあえず何でもいいので "index.html" を作ります。

<html>
  <head><title></title></head>
  <body></body>
</html>

ターミナルで trunk serve を実行します。

INFO server listening at http://127.0.0.1:8080

と表示されたら URL を Edge (あるいは他の Chromium 系ブラウザ) で開き F12 を押して devtools の Console を開きます。なにやら "RuntimeError" が起きていることがわかります。

HID.requestDevice() を呼び出している部分を以下のように書き換えてみます。

    // let promise = hid.request_device(&options);
    // let devices = wasm_bindgen_futures::JsFuture::from(promise).await.expect("FAILED to enumerate HID devices.");
    let promise = hid.request_device(&options);
    let result = wasm_bindgen_futures::JsFuture::from(promise).await;
    log::debug!("request_device():{:?}", &result);
    let devices = result.expect("FAILED to enumerate HID devices.");

すると hid.request_device()Result::Err を返していたことがわかりました。
曰く、

request_device():Err(JsValue(SecurityError: Failed to execute 'requestDevice' on 'HID': Must be handling a user gesture to show a permission request.Error: Failed to execute 'requestDevice' on 'HID': Must be handling a user gesture to show a permission request.

要するにマウスクリックなどユーザーのアクションがないと HID.requestDevice() は呼ぶことができないのですね。
ということで今度は web-sys 経由で DOM 要素にイベントリスナーを登録してみます。
(なお後述するように、素直に数行の HTML や JS を書くだけのほうが圧倒的にラクであることは付記しておきます)

button とイベントリスナーを追加する

先ほどの空っぽの "index.html" には手をつけずに、 main() の中でボタンとイベントリスナーを追加してみます。

その前に "Cargo.toml" の [dependencies.web-sys] セクションに DOM 操作のための feature を追加しておきます。

# . . .
[dependencies.web-sys]
version = "0.3.59"
features = [
  "Window",
  "Navigator",
  "Document",
  "Element",
  "HtmlElement",
  "HtmlCanvasElement",
  "MouseEvent",
# . . .

main() は以下のようになりました。

fn main() {
    wasm_logger::init(wasm_logger::Config::default());

    let document = web_sys::window().unwrap().document().unwrap();
    let elem = document.create_element("button").unwrap();
    elem.set_text_content(Some("request hid"));
    document.body().unwrap().append_child(&elem);
    
    let closure = Closure::wrap(Box::new(|e: web_sys::MouseEvent| {
        async_std::task::block_on(hid_device());
    }) as Box<dyn FnMut(web_sys::MouseEvent)>);
    let listener: &js_sys::Function = closure.as_ref().unchecked_ref();
    elem.add_event_listener_with_callback("click", listener);
    closure.forget();
}

これは javascript で書くと

var elem = document.createElement('button');
elem.textContent = 'request hid';
document.appenChild(elem);
elem.addEventListener('click', listener);

くらいの記述で済むはずなので、かなりややこしくなっていますね。
(参考: DOMのまとめ)

前半の記述で、何もなかった HTML にボタンが表示されます。

    let document = web_sys::window().unwrap().document().unwrap();
    let elem = document.create_element("button").unwrap();
    elem.set_text_content(Some("request hid"));
    document.body().unwrap().append_child(&elem);

後半では wasm_bindgen::closure::Closure でなんやかんややっています。

    let closure = Closure::wrap(Box::new(|e: web_sys::MouseEvent| {
        async_std::task::block_on(hid_device());
    }) as Box<dyn FnMut(web_sys::MouseEvent)>);
    let listener: &js_sys::Function = closure.as_ref().unchecked_ref();
    elem.add_event_listener_with_callback("click", listener);
    closure.forget();

asynchid_device() 関数を呼び出す部分が MouseEvent 型を取るラムダ式の中に移動しています。
そのラムダ式を Box<dyn FnMut(web_sys::MouseEvent)>) でラップして、さらに wasm_bindgen::closure::Closure でラップしています。
これを js_sys::Function 型に動的キャストして、ようやく javascript でいうところの addEventListener() が呼び出しています。

(参考: 【Rust】非同期処理でクロージャをうまく使う方法はあるんだろうか…)

注目するべきは最後の closure.forget() ではないかと思います。なんでも

    // The instance of `Closure` that we created will invalidate its
    // corresponding JS callback whenever it is dropped, so if we were to
    // normally return from `setup_clock` then our registered closure will
    // raise an exception when invoked.
    //
    // Normally we'd store the handle to later get dropped at an appropriate
    // time but for now we want it to be a global handler so we use the
    // `forget` method to drop it without invalidating the closure. Note that
    // this is leaking memory in Rust, so this should be done judiciously!
    a.forget();

(引用元: https://rustwasm.github.io/docs/wasm-bindgen/examples/closures.html#srclibrs)

作成した Closure のインスタンスは、ドロップされるたびに対応する JS コールバックを無効にするので、仮に setup_clock から普通に戻った場合、登録したクロージャは呼び出されると例外を発生させます。

通常はハンドルを保存しておいて、後で適切なタイミングで削除するのですが、今はグローバルハンドラとして使用したいので、 forget メソッドを使用してクロージャを無効にせずに削除します。これはRustのメモリリークになるので、慎重に行う必要があることに注意してください!

とのことで、関数オブジェクトをあえてメモリリークさせてるのですね。

今度こそ HID デバイスを取得する

trunk serve して "http://127.0.0.1:8080" を開くと何もないページに "request hid" ボタンだけが表示されます。
DS4 を PC とを接続した状態でボタンをクリックすると、HID を選択するポップアップが表示されますので "Wireless Controller - Paired" を選択します。
すると Edge の devtools Console に

device:HidDevice { obj: EventTarget { obj: Object { obj: JsValue(HIDDevice) } } }

と表示されました!

    log::debug!("device:{:?}", &device);

device の中には確かに web_sys::HidDevice が入っているようです。

"inputreport" イベントをハンドルする

Bluetooth 接続された DS4 は HID Input Report で入力情報をガンガン送り続けます。で、この値を WebHID 経由で読むためには inputreport イベント のハンドラーを登録する必要があります。

先程の "click" イベントリスナー同様、wasm_bindgen::closure::Closure をつかって以下のようにします。

async fn hid_device() {
// . . .
    let closure = Closure::wrap(Box::new(move |e: web_sys::HidInputReportEvent| {
        log::debug!("HidInputReportEvent: {:?}", &e);
        let dataview = e.data();
        log::debug!("  DataView: {:?}", &dataview);
        let ofs = dataview.byte_offset();
        let len = dataview.byte_length();
        let ba: Vec<u8> = (0..len).map(|i| { dataview.get_uint8(i+ofs) }).collect();
        log::debug!("  Vec<u8>: {:?}", &ba);
    }) as Box<dyn FnMut(web_sys::HidInputReportEvent)>);
    device.set_oninputreport(Some(closure.as_ref().unchecked_ref()));
    closure.forget();
    let promise = device.open();
    wasm_bindgen_futures::JsFuture::from(promise).await;
    log::debug!("device:{:?}", &device);
}

web_sys::HidDeviceにはset_oninputreport()
からハンドラー関数を登録できますが、こちらの引数もやはり js_sys::Function 型です。
ということで HidInputReportEvent を引数に取るラムダ式をラップしてラップしてキャストして、set_oninputreport() に渡してあげます。
closure.forget() は忘れずに。

ちなみにラムダ式の中では今後のために DataView から Vec<u8> への変換を行っています。

最後に device.open() すると、inputreport イベントが次々に送られてきます。
実行してみると DS4 が接続されている場合には Console 上に以下のようなテキストが流れ続けます。

HidInputReportEvent: HidInputReportEvent { obj: Event { obj: Object { obj: JsValue(HIDInputReportEvent) } } }
DataView: DataView { obj: Object { obj: JsValue(DataView) } }
Vec<u8>: [192, 0, 137, 123, 129, 121, 8, 0, 192, 0, 0, 224, 116, 16, 15, 0, 0, . . .

あとはフォーマット表に従ってデコードするだけで、DS4 のボタン入力情報や加速度の値などが読めるようになります。
これで当初の目標は達成です!

おしまい!

個人的な感想

動的型付けで何でもやろうとする javascript は相変わらず好きになれそうにありませんが、こと今回のテーマに関しては rust だけでやるのはあまりにも辛すぎると思いました。
npm については気持ちの折り合いを付け、次回は Typescript で同じテーマに挑みたいと思った次第です。

Discussion