💊

tauri から HID を操作する

2023/01/15に公開

バックエンドには rust のネイティブコード、フロントエンドには Web View を用いるという tauri を使って、HID を操作する GUI アプリケーションを作ってみる。

検証環境

  • Windows 11 Home
    • Version : 22H2
    • OS build : 22621.963
  • Rust
    • rustup : 1.25.1
    • rustc : 1.66.0
    • cargo : 1.66.0

準備

Getting Started に従って以下をインストールする。

npm がなくても cargo だけでなんとかなるみたいだけど

> cargo install create-tauri-app
> cargo create-tauri-app

✔ Project name · try-tauri-cargo
✔ Choose your package manager · cargo
? Choose your UI template ›
❯ vanilla
  yew

と、テンプレートに Yew しか選択できなくなる。さもありなん。
Yew も試してみたいところだけど、これは後日とする。

npm で始めると、いろんなテンプレートが選べる。

> npm create tauri-app
Need to install the following packages:
  create-tauri-app@2.7.6
Ok to proceed? (y) y

✔ Project name · try-tauri-react
✔ Choose your package manager · npm
? Choose your UI template ›
  vanilla
  vanilla-ts
  vue
  vue-ts
  svelte
  svelte-ts
  react
❯ react-ts
  solid
  solid-ts
  next
  next-ts
  preact
  preact-ts
  angular
  clojurescript
  svelte-kit
  svelte-kit-ts

軽く調べただけだけど、パフォーマンスで選ぶなら SolidJSSvelte らしい。でもまあ情報が多いので、やっぱり React でやる。

テンプレートを選ぶと

Done, Now run:
  cd try-tauri-react
  npm install
  npm run tauri dev

とメッセージが表示されるのでその通りにする。 npm と cargo からいろんなパッケージがダウンロード・ビルドされ、Window が表示された。

hidapi を使う

"src-tauri\Cargo.toml" に以下を追記する。

src-tauri\Cargo.toml
[dependencies]
# ...
hidapi = "2.1.0"

"src-tauri\src\main.rs" を以下のように編集した。

src-tauri\src\main.rs
extern crate hidapi;
use hidapi::{HidApi};

fn count_hid() -> usize {
    match HidApi::new() {
        Ok(api) => api.device_list().count(),
        Err(_) => 0,
    }
}

// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
#[tauri::command]
fn greet(name: &str) -> String {
    format!("Hello, {}! I have {} HID devices", name, count_hid())
}

どうやら hidapi クレートが使えている。

GUI から HID を選ぶ

GUI 上のコンボボックス(セレクトボックス?プルダウンメニュー?ここではコンボボックスとする)から、開く HID を選べるようにしたい。
この辺りを参考に Command を実装することになる。
まずは HID のデバイス情報をプロセス間でやり取りするために以下のような構造体を用意した。

src-tauri\src\main.rs
#[derive(Serialize, Deserialize)]
struct MyOption {
    id: String,
    label: String,
}

Serialize トレイトにより JSON 変換されるらしい。
id としては固有値としてデバイスパスを入れることにする。
label は表示名として "PID:0000_VID:0000&UP:0000_U:0000" みたいなのにする。

受け取る App.tsx 側にも同様のものを用意した。

src\App.tsx
class MyOption {
  id: string = "";
  label: string = "";
}

そして App に以下のようなステートを追加した。

src\App.tsx
  const [options, setOptions] = useState<MyOption[]>([]);
  const [productName, setProductName] = useState<string>("");

options はコンボボックスにバインドされるデータの実体、productName は Rust コードから受け取るものとする。

App() が返す HTML は以下のように変更した。

src\App.tsx
      <div className="row">
        <div>
          <button type="button" onClick={() => enum_hid()}>
            Enumerate HID
          </button>
          <select onChange={(e) => sel_hid(e.target.value)}>
            {options.map((opt, idx) => {
              return <option value={opt.id}>[{idx}] {opt.label}</option>
            })}
          </select>
        </div>
      </div>
      <p>🤛{productName}🤜</p>

enum_hid コマンド

"Enumerate HID" ボタンを押すと enum_hid() が呼ばれる。
enum_hid() では以下のようにして Rust コードから MyOption[] を取得し options を更新するようにした。

src\App.tsx
  async function enum_hid() {
    setOptions(await invoke("enum_hid"));
  }

Rust コード側の "enum_hid" コマンドは以下のように実装した。

src-tauri\src\main.rs
fn info_to_option(info: &&DeviceInfo) -> MyOption {
    let path = info.path().to_str().unwrap().to_string();
    let text = format!("PID:{:04X}_VID:{:04X}&UP:{:04X}_U:{:04X}", 
        info.vendor_id(), info.product_id(),
        info.usage_page(), info.usage()); 
    MyOption {id: path, label: text}
}

fn new_hidapi() -> HidApi { HidApi::new().expect("Failed to create `HidApi`") }

#[tauri::command]
fn enum_hid() -> Vec<MyOption> {
    println!("enum_hid()");
    let hidapi = new_hidapi();
    let mut devs: Vec<_> = hidapi.device_list().collect();
    devs.sort_by_key(|d| d.product_id());
    devs.sort_by_key(|d| d.vendor_id());
    devs.iter().map(info_to_option).collect()
}

前述のように id にデバイスパスを、label にはデバイス情報文字列を設定するようにした。
enum_hid()Vec<MyOption> 型をそのまま返して良いものかとも思ったが、どうやら JSON シリアライズすると MyPotion[] 型にしてくれるようだ。

sel_hid コマンド

  <select onChange={(e) => sel_hid(e.target.value)}>

コンボボックスから HID を選ぶと sel_hid() が呼ばれる。本当は e.target.valueMyOption 型になってて欲しかったけど、string 以外の型がイベントパラメータにできるものなのか、よくわからなかった。何らかの React UI コンポーネントを使うなど、方法はあるのだろう。

      {options.map((opt, idx) => {
        return <option value={opt.id}>[{idx}] {opt.label}</option>
      })}

(↑ option タグの value 属性には string しか入れられんの?)

sel_hid() は以下のようにして Rust コードに "sel_hid" コマンドを送る。デバイスパス文字列とともに。

src\App.tsx
  async function sel_hid(id: string) {
    setProductName(await invoke("sel_hid", {path: id}));
  }

Rust 側での実装は以下のようにした。
引数と返り値で文字列の型が異なっていることに気づいてなくて (&strString)、結構な時間が溶けた。

src-tauri\src\main.rs
#[tauri::command]
fn sel_hid(path: &str) -> String {
    println!("sel_hid(\"{}\")", path);
    let c_string = CString::new(path).unwrap();
    let cpath = c_string.as_c_str();
    let hidapi = new_hidapi();
    if let Ok(dev) = hidapi.open_path(cpath) {
        if let Ok(Some(prod_str)) = dev.get_product_string() {
            println!("Succeeded to oepn HID {}", &prod_str);
            prod_str
        } else {
            "".to_string()
        }
    } else {
        println!("Failed to open HID : {}", path);
        "Error".to_string()
    }
}

フロントエンド側に返す文字列には dev.get_product_string() の結果が入っている。

デバイス名文字列が返ってきたところ

↑ DualShock4 ("PID:054C_VID:09CC&UP:0001_U:0005") を選ぶと "Wireless Controller" という文字列が Rust コードから返ってきている。

HID Input Report を GUI に表示させる

次は Input Report をどうにか表示させたい。Events なる仕組みを使えばよさそう。

Global Event の実装

実装ガイドにならい、まずは Rust, Typescript それぞれに以下のようなデータ定義を追加した。

src-tauri\src\main.rs
#[derive(Clone, serde::Serialize)]
struct Payload {
    id: String,
    report: Vec<u8>,
    size: usize,
}
src\App.tsx
class Payload {
  id: string = "";
  report: number[] = [];
  size: number = 0;
}

元は [u8] 型の HID レポートを → Vec<u8> → JSON文字列 → JS 配列 → … と次々と変換しならがら受け渡すことになるので、いわゆる一般的な native アプリで HID レポートをそのまま扱う場合 (uint8_t [] とか void const * とかで) に比べると明らかに効率が悪そう。
でもまあ、今は気にしないことにする。

で、 Rust 側からこの Payload データを含む Event を発行するために、以下のような関数 start_read() を追加した。

src-tauri\src\main.rs
// ...
use tauri::{Manager, Window};
// ...
fn start_read(window: Window, dev: HidDevice) {
    std::thread::spawn(move || {
        let mut buf = [0u8; 78];
        let timeout = 1000;
        let info = dev.get_device_info().unwrap();
        let path = info.path().to_str().unwrap().to_string();
        loop {
            match dev.read_timeout(& mut buf, timeout) {
                Ok(sz) => {
                    window.emit("on_input",
                        Payload {
                            id: path.clone(),
                            report: buf.into(),
                            size: sz,
                        }
                    ).unwrap();
                },
                Err(_) => (),
            }
        }
    });
}

start_read() を呼ぶと、HID Input Report を読んだら tauri の Global Event に詰めて発行 (window.emit()) し続けるスレッドが生成される。今のところ、どうやって止めるかについては考えていない。
(どうしたらいいですかね?)

さらに、先程の HID が選択された際に GUI 側から呼び出される sel_hid コマンドは以下のように変更。

src-tauri\src\main.rs
#[tauri::command]
fn sel_hid(window: Window, path: &str) -> String { // <-- modified
    // ... (中略)
        if let Ok(Some(prod_str)) = dev.get_product_string() {
            println!("Succeeded to oepn HID {}", &prod_str);
            start_read(window, dev); // <-- added
            prod_str
    // ... (以下略)
}

これでバックエンドは、GUI 上のコンボボックスから HID が選択されると HID Input Report を Event として送り続けるようになるはず。

次にフロントエンド側で Event を受け取って GUI 上に表示できるようにする。
まず、App コンポーネントには別の State を追加。ここに文字列化した HID Input Report を入れる。

src\App.tsx
  const [inputReport, setInputReport] = useState<string>("");
src\App.tsx
      <p>{inputReport}</p>

それから、ガイドの通りにイベントリスナーを追加する。 listen 関数は Promise を返すそうなので async な関数の中で実行する必要がある。
ということで sel_hid 関数に以下のように追記した。

src\App.tsx
  async function sel_hid(id: string) {
    setProductName(await invoke("sel_hid", {path: id}));

    const unlisten = await listen<Payload>("on_input", (event) => {
      let str = event.payload.report.reduce<string>((pv, cv) => {
        return pv + `${cv.toString(16).padStart(2, "0")} `;
      }, "");
      setInputReport(str);
    });
  }

Promise からの戻り値 unlisten: UnlistenFn (declare type UnlistenFn = () => void;) はイベントの購読を止めるときに実行する関数のようだけど、これも今は考えないことにする。

実行結果

先程と同じように "Enumerate HID" ボタンを押して、DualShock4 ("PID:054C_VID:09CC&UP:0001_U:0005") を選ぶ。
すると HID Input Report が表示されるようになった。

見たところ処理落ちするようなところは見られなかった。個人的な経験上、いわゆる普通の native アプリ上で HID レポートを GUI 上に表示する際、何も考えずに HID レポート毎に GUI の更新を行うと、たいていは表示上何らかの問題を起こしがち。
(HID レポートは大抵、毎秒 100 とか 200 とか送られて来るので GUI の更新が追いつかない)
よく分からないけど React の仮想 DOM がいい感じにしてくれているのだろうか?

おわりに

今回の github リポジトリ
先述の通り、HID Input Report を読むのを止めたり、別の HID を選択したりする処理については何も考慮されていないのでご注意を。

Discussion