接続されている USB デバイス一覧を取得する

rusb で接続中の USB デバイスを列挙し、VID/PID と製品名を取る。Windows でドライバーにより名前が読めない機器、Linux の udev 権限、抜き差しの検知方法も示す。

ハードウェア連携 対象: Tauri 2.x 更新日: 読了目安: 約10分 hw-007
目次
  1. 前提条件
  2. 1. バックエンドから実装する (Rust)
  3. Linux で開く権限を与える(udev)
  4. 2. フロントエンドから呼び出す (TypeScript)
  5. 動作確認
  6. よくあるエラーと対処法
  7. OS ごとの違いと注意点
  8. 関連レシピ

「対応機器がつながっているか確かめる」「機器を抜き差ししたら画面を更新する」といった場面では、接続中の USB デバイスの一覧が必要です。Tauri 自体にも WebView にも、どの OS でも使える USB の API はないので、Rust の rusb クレートで取得してコマンドで JS に返します。VID/PID(メーカーと製品の番号)はどの機器でも取れますが、製品名を読むには機器を開く必要があり、そこで OS とドライバーの差が出ます。

前提条件

src-tauri で rusb を追加します。コードは rusb 0.9 系向けです。

cd src-tauri
cargo add rusb --features vendored

自作コマンドの invoke だけなので、capability の追加は不要です。rusb は libusb という C のライブラリを使います。システムに libusb が見つかればそれに動的にリンクし、見つからなければソースからビルドしてアプリに組み込みます。Homebrew の libusb と pkg-config が入った Mac でビルドすると前者になり、配布先の Mac に同じものが無いと起動に失敗します。vendored 機能を付けると常に組み込む形になり、この心配がなくなります。Linux でシステムの libusb を使うなら、vendored を付けずに libusb-1.0-0-dev と pkg-config(Debian / Ubuntu の場合)を入れます。

取れる情報は次のように分かれます。

情報機器を開かずに取れるか
VID/PID、クラス、差し込み口の位置取れる(すべての OS)
メーカー名・製品名・シリアル番号開く必要がある。Windows はドライバー次第、Linux は権限次第

1. バックエンドから実装する (Rust)

押さえる点は 3 つです。

  • 開けなくても一覧から外さない: 名前が読めなかった機器も VID/PID は正しいので、理由を添えて返します。
  • メインスレッドで動かさない: 機器を開いて文字列を読むと、1 台で数十ミリ秒かかることがあります。#[tauri::command(async)] にして画面を止めないようにします(重い処理を別スレッド・非同期で実行する)。
  • bus と address で機器を覚えない: address は挿し直すたびに変わります。抜き差しの判定には差し込み口の位置(bus とポート番号の並び)を、特定の機器を探すには VID/PID(あればシリアル番号も)を使います。
use std::time::Duration;
use rusb::{Device, DeviceDescriptor, UsbContext};
use serde::Serialize;

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct UsbDeviceInfo {
    port: String, // 差し込み口の位置(例: "1-7.1")
    vendor_id: u16,
    product_id: u16,
    class: u8, // 0x09 はハブ
    manufacturer: Option<String>,
    product: Option<String>,
    note: Option<String>, // 名前が読めなかった理由
}

/// bus とポート番号の並び。同じ口に挿している限り変わらない
fn port_of<T: UsbContext>(device: &Device<T>) -> String {
    let ports: Vec<String> = device
        .port_numbers()
        .unwrap_or_default()
        .iter()
        .map(|p| p.to_string())
        .collect();
    format!("{}-{}", device.bus_number(), ports.join("."))
}

/// 機器を開いてメーカー名と製品名を読む。開けなければ理由を返す
fn read_names<T: UsbContext>(
    device: &Device<T>,
    desc: &DeviceDescriptor,
) -> Result<(Option<String>, Option<String>), String> {
    let handle = device.open().map_err(|e| match e {
        rusb::Error::NotSupported => "ドライバーが対応していないため開けません".to_string(),
        rusb::Error::Access => "権限がありません(udev の設定が必要)".to_string(),
        other => other.to_string(),
    })?;
    let timeout = Duration::from_millis(300);
    let lang = handle
        .read_languages(timeout)
        .map_err(|e| e.to_string())?
        .into_iter()
        .next()
        .ok_or("文字列を持たない機器です")?;
    // 持っていない項目は「Invalid parameter」で失敗するので None にする
    let manufacturer = handle.read_manufacturer_string(lang, desc, timeout).ok();
    let product = handle.read_product_string(lang, desc, timeout).ok();
    Ok((manufacturer, product))
}

#[tauri::command(async)] // 同期関数のまま、メインスレッドとは別のスレッドで実行される
fn list_usb_devices(with_names: bool, include_hubs: bool) -> Result<Vec<UsbDeviceInfo>, String> {
    let devices = rusb::devices().map_err(|e| e.to_string())?;
    let mut list = Vec::new();
    for device in devices.iter() {
        let Ok(desc) = device.device_descriptor() else { continue };
        if desc.class_code() == 0x09 && !include_hubs {
            continue;
        }
        let (manufacturer, product, note) = if with_names {
            match read_names(&device, &desc) {
                Ok((m, p)) => (m, p, None),
                Err(reason) => (None, None, Some(reason)),
            }
        } else {
            (None, None, None)
        };
        list.push(UsbDeviceInfo {
            port: port_of(&device),
            vendor_id: desc.vendor_id(),
            product_id: desc.product_id(),
            class: desc.class_code(),
            manufacturer,
            product,
            note,
        });
    }
    list.sort_by(|a, b| a.port.cmp(&b.port));
    Ok(list)
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![list_usb_devices])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

シリアル番号も read_serial_number_string() で読めますが、機器を特定できる情報なので、必要なときだけ読み、外部に送らないようにします。

Linux で開く権限を与える(udev)

Linux では一覧と VID/PID は一般ユーザーでも取れますが、機器を開くには書き込み権限が要ります。sudo でアプリを動かすのではなく、対象の機器だけに udev ルールで権限を与えます。uaccess はログイン中のユーザーに権限を与える指定で、効かせるにはファイル名の番号を 73 より小さくします。置いたら機器を挿し直します。

# /etc/udev/rules.d/70-my-device.rules
SUBSYSTEM=="usb", ATTR{idVendor}=="1234", ATTR{idProduct}=="5678", TAG+="uaccess"

アプリと一緒に配るなら、tauri.conf.json の bundle.linux.deb.files でルールを /usr/lib/udev/rules.d/ に置きます。システムの libusb にリンクした場合は depends に libusb-1.0-0 も足します(Linux 用 Deb パッケージを作る)。

{
  "bundle": {
    "linux": {
      "deb": {
        "depends": ["libusb-1.0-0"],
        "files": {
          "/usr/lib/udev/rules.d/70-my-device.rules": "udev/70-my-device.rules"
        }
      }
    }
  }
}

2. フロントエンドから呼び出す (TypeScript)

rusb の抜き差しの通知は Windows では使えない(rusb::has_hotplug() が false)ので、どの OS でも動く方法として一覧を定期的に取り直して比べます。比べるときは名前を読まない軽い呼び出しにし、変化があったときだけ名前付きで取り直します。

import { invoke } from '@tauri-apps/api/core';

type UsbDeviceInfo = {
  port: string;
  vendorId: number;
  productId: number;
  class: number;
  manufacturer: string | null;
  product: string | null;
  note: string | null;
};

const hex4 = (n: number) => n.toString(16).padStart(4, '0');
const idOf = (d: UsbDeviceInfo) => `${d.port}/${hex4(d.vendorId)}:${hex4(d.productId)}`;

export function label(d: UsbDeviceInfo): string {
  const name = [d.manufacturer?.trim(), d.product?.trim()].filter(Boolean).join(' ');
  return `${name || '(名前なし)'} [${hex4(d.vendorId)}:${hex4(d.productId)}]`;
}

const list = (withNames: boolean) =>
  invoke<UsbDeviceInfo[]>('list_usb_devices', { withNames, includeHubs: false });

// 2 秒ごとに比べ、増えた機器と減った機器を知らせる。戻り値の関数で止める
export function watchUsb(onChange: (added: UsbDeviceInfo[], removed: UsbDeviceInfo[]) => void) {
  let known = new Map<string, UsbDeviceInfo>();
  let busy = false;
  const tick = async () => {
    if (busy) return;
    busy = true;
    try {
      const quick = await list(false);
      if (quick.length === known.size && quick.every((d) => known.has(idOf(d)))) return;
      const next = new Map((await list(true)).map((d) => [idOf(d), d]));
      const added = [...next.values()].filter((d) => !known.has(idOf(d)));
      const removed = [...known.values()].filter((d) => !next.has(idOf(d)));
      known = next;
      onChange(added, removed);
    } finally {
      busy = false;
    }
  };
  void tick();
  const timer = window.setInterval(() => void tick(), 2000);
  return () => window.clearInterval(timer);
}

watchUsb((added, removed) => {
  for (const d of added) console.log('接続:', label(d), d.note ?? '');
  for (const d of removed) console.log('切断:', label(d));
});

特定の機器を待つなら、vendorId と productId が一致するものが added に来たかを見ます。USB シリアル変換器(Arduino など)が目的なら、COM ポート名と VID/PID を一緒に取れる シリアルポート通信 の一覧の方が便利です。

動作確認

npm run tauri dev で起動すると、最初の比較で接続中の機器がすべて「接続」として出ます。Windows では次のように(一部を抜粋。機器名と ID は例)、名前が読める機器と読めない機器が混ざります。名前が読めたのは、HID の機器(キーボードやマウスのレシーバー)と、HID を含む複合機器(Web カメラやスピーカー)です。

接続: Example USB Speaker [1234:0001]
接続: (名前なし) [1234:0002] ドライバーが対応していないため開けません
接続: Example USB Receiver [1234:0003]
接続: Example Web Camera [1234:0004]
接続: (名前なし) [1234:0005] ドライバーが対応していないため開けません

機器を挿すと 2 秒ほどで「接続」、抜くと「切断」が出ます。

よくあるエラーと対処法

  • 「Operation not supported or unimplemented on this platform」: Windows で、機器のドライバーが rusb から開けない種類です(例では「ドライバーが対応していないため開けません」に置き換えています)。名前は諦めて VID/PID で扱います。自社の機器と通信までするなら、ドライバーを WinUSB にします。開発中は Zadig などで入れ替え、配布する機器ではファームウェアに Microsoft OS ディスクリプタを持たせると WinUSB が自動で割り当てられます。
  • 「Access denied (insufficient permissions)」: Linux で機器を開く権限がありません。上の udev ルールを置きます。
  • 製品名はあるのにメーカー名が null: メーカー名の文字列を持たない機器です。読み出しは「Invalid parameter」で失敗するので、例では None として扱っています。
  • 「Operation timed out」「Pipe error」: 機器が文字列の要求に応えていません。一覧全体は失敗させず、その機器だけ名前なしにします(例の note)。
  • 配布先の Mac で起動直後に落ちる: Homebrew の libusb に動的にリンクされています。vendored 機能を付けてビルドし直します。

OS ごとの違いと注意点

  • Windows: 名前まで読めるのは、HID(キーボード・マウス・それらのレシーバー)や HID を含む複合機器、WinUSB の機器です。ハブ、LAN アダプター、Bluetooth アダプターなどは VID/PID だけになり、USB メモリも多くの場合同じです。
  • Linux: 開く権限は udev で与えます(1 章)。一部の機器は、ディストリビューションの既定のルールで最初から開けることがあります。
  • macOS: 一覧は特別な設定なしで取れます。App Sandbox を有効にする場合(Mac App Store 向け)は、エンタイトルメント com.apple.security.device.usb が必要です。
  • ライセンス: libusb をアプリに組み込む場合(vendored や Windows の既定)、libusb のライセンスは LGPL なので、配布前に条件を確認します。
  • キーボードやマウスの入力そのものを読みたいなら、USB として開くのではなく キーボードやマウス (HID) の入力を取る の方法を使います。Bluetooth でつないだ機器は USB の一覧に出ないので、Bluetooth (BLE) デバイスをスキャンする を参照してください。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する