Rust だけで WebHID を使おうとした話
概要
タイトルの通り、できる限り 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 thewasm-bindgen
guide
Crates can opt-in to unstable APIs at compile-time by passing the
cfg
flagweb_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();
async
な hid_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