Bluetooth (BLE) デバイスをスキャンする

tauri-plugin-blec の startScan() で BLE 機器を探し、サービス UUID や電波の強さで絞り込む。Rust の ScanFilter、macOS の Info.plist、Android の許可も扱う。

ハードウェア連携 対象: Tauri 2.x 更新日: 読了目安: 約10分 hw-004
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. スキャンして結果を受け取る
  4. 目的の機器だけに絞り込む
  5. BleDevice の中身
  6. 2. バックエンドから実装する (Rust)
  7. 動作確認
  8. よくあるエラーと対処法
  9. OS ごとの違いと注意点
  10. 関連レシピ

心拍計やセンサー、自作のマイコン基板などの BLE(Bluetooth Low Energy)機器を使うアプリは、周囲の機器を探すスキャンから始まります。Tauri には公式の BLE プラグインがないので、コミュニティ製の tauri-plugin-blec を使います。ここではスキャン結果の受け取り、絞り込み、OS ごとの許可の設定を扱います。見つけた機器への接続と読み書きは BLE デバイスに接続してキャラクタリスティックを読み書きする で説明します。

前提条件

Rust と JS のパッケージを別々に追加します。npm には 0.12 系までしか公開されていないので、crates.io に新しい版があっても Rust 側も 0.12 にそろえます(版がずれると JS と Rust の実装が食い違うおそれがあります)。uuid と tokio は 2 章の自作コマンド用です。

cd src-tauri
cargo add tauri-plugin-blec@0.12
cargo add uuid
cargo add tokio --features sync
cd ..
npm add @mnlphlp/plugin-blec@0.12

プラグインは src-tauri/src/lib.rs で登録します。

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_blec::init())
        // 2 章の自作コマンドを使う場合だけ登録する
        .invoke_handler(tauri::generate_handler![scan_by_service])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

スキャンだけなら権限は次の 5 つです。core:default には含まれません。接続や読み書きも使うなら、全機能を許可する blec:default にまとめます(hw-006 はこちら)。自作コマンドは capability で許可しなくても呼べます。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "blec:allow-scan",
    "blec:allow-stop-scan",
    "blec:allow-scanning-state",
    "blec:allow-get-adapter-state",
    "blec:allow-check-permissions"
  ]
}

1. フロントエンドから実装する (TypeScript)

スキャンして結果を受け取る

startScan(handler, timeoutMs) はスキャンを始めた直後に戻り、指定した時間で自動的に止まります。handler は約 0.2 秒ごとに、その時点で見つかっている全機器の配列で呼ばれます。差分ではないので毎回置き換えます。終了は getScanningUpdates() で受け取りますが、この登録は解除できず呼ぶたびに増えるので、起動時に 1 回だけ行います。型定義では rssi は number ですが、受信できていない機器では null が入ります。

import {
  checkPermissions,
  getAdapterState,
  getScanningUpdates,
  startScan,
  stopScan,
  type BleDevice,
} from '@mnlphlp/plugin-blec';

// rssi は受信できていない機器で null になる
type Found = Omit<BleDevice, 'rssi'> & { rssi: number | null };

let latest: Found[] = [];

// アプリ全体で 1 回だけ登録する
await getScanningUpdates((scanning) => {
  document.body.dataset.scanning = String(scanning);
  if (!scanning) console.log(`スキャン終了: ${latest.length} 台`);
});

export async function scan(timeoutMs = 10_000): Promise<void> {
  if (!(await checkPermissions(true))) throw new Error('Bluetooth の使用が許可されていません');
  if ((await getAdapterState()) === 'Off') throw new Error('Bluetooth がオフです');

  await startScan((devices) => {
    // 毎回「これまでに見つかった全機器」が届くので置き換える。電波の強い順に並べる
    latest = (devices as Found[]).slice().sort((a, b) => (b.rssi ?? -999) - (a.rssi ?? -999));
    render(latest);
  }, timeoutMs);
}

export const cancelScan = () => stopScan();

function render(list: Found[]) {
  const ul = document.querySelector('#devices');
  if (!ul) return;
  ul.replaceChildren(...list.map((d) => {
    const li = document.createElement('li');
    li.textContent = `${d.name} (${d.address}) ${d.rssi ?? '-'} dBm`;
    li.dataset.address = d.address; // 接続(hw-006)に使う
    return li;
  }));
}

checkPermissions(true) は Android 以外では常に true で、Android では実行時の許可を求めます。getAdapterState() が 'Off' なら、Bluetooth をオンにするよう案内します。

目的の機器だけに絞り込む

JS の startScan() には絞り込みの引数がないので、コールバックの中で配列を絞ります。サービス UUID は小文字の 128 ビット表記で届くので、仕様書にある 16 ビットの UUID(心拍計なら 0x180D)はこの形に直して小文字で比べます。大文字の定数と === で比べて 1 台も残らないのがよくある失敗です。

import { startScan, type BleDevice } from '@mnlphlp/plugin-blec';

// 16 ビットの標準 UUID を 128 ビット表記(小文字)に直す
export const uuid16 = (n: number) =>
  `0000${n.toString(16).padStart(4, '0')}-0000-1000-8000-00805f9b34fb`;

type Filter = { service?: string; namePrefix?: string; companyId?: number; minRssi?: number };

export function matches(d: BleDevice, f: Filter): boolean {
  const want = f.service?.toLowerCase();
  if (want && !d.services.some((s) => s.toLowerCase() === want)) return false;
  if (f.namePrefix && !d.name.startsWith(f.namePrefix)) return false;
  if (f.companyId !== undefined && !(f.companyId in d.manufacturerData)) return false; // キーは企業 ID
  const rssi = d.rssi as number | null;
  if (f.minRssi !== undefined && (rssi === null || rssi < f.minRssi)) return false;
  return true;
}

// 心拍計のサービスを広告していて、-80 dBm より強い機器だけ
await startScan((devices) => {
  const hits = devices.filter((d) => matches(d, { service: uuid16(0x180d), minRssi: -80 }));
  console.log(hits.map((d) => `${d.name} ${d.address}`));
}, 10_000);

services に入るのは、機器がアドバタイズ(広告)しているサービスだけです。広告に載せない機器もあるので、名前やメーカー固有データでも絞れるようにしておきます。

BleDevice の中身

プロパティ型内容
addressstring接続に使う識別子。Windows / Linux / Android は MAC アドレス、macOS / iOS は OS が割り当てた UUID
namestring広告された名前。無ければ識別子の文字列
rssinumber受信の強さ(dBm)。受信できていないと null
servicesstring[]広告しているサービス UUID(小文字)
manufacturerDataRecord<number, number[]>キーは企業 ID(Apple は 76 = 0x004C)
serviceDataRecord<string, number[]>キーは小文字の UUID
isBondedbooleanペアリング済みか。Android 以外では常に false

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

Rust では get_handler() で JS と同じハンドラーを取り出し、discover() でスキャンします。こちらは ScanFilter で絞り込めます(Service・AnyService・AllServices・ManufacturerData・ManufacturerDataMasked)。条件を付けると、電波を受けていない機器も除かれます。次のコマンドは、指定したサービスの機器だけを Channel でフロントエンドへ送ります。ハンドラーは JS と共有なので、どちらかでスキャンを始めると、実行中のもう一方は止まります。

use tauri::ipc::Channel;
use tauri_plugin_blec::models::{BleDevice, ScanFilter};
use uuid::Uuid;

#[tauri::command]
async fn scan_by_service(
    service: String,
    timeout_ms: u64,
    on_devices: Channel<Vec<BleDevice>>,
) -> Result<(), String> {
    let uuid = Uuid::parse_str(&service).map_err(|e| e.to_string())?;
    let handler = tauri_plugin_blec::get_handler().map_err(|e| e.to_string())?;
    let (tx, mut rx) = tokio::sync::mpsc::channel(1);
    // スキャン結果をフロントエンドへ中継する。送れなくなったらスキャンも止める
    tauri::async_runtime::spawn(async move {
        while let Some(devices) = rx.recv().await {
            if on_devices.send(devices).is_err() {
                let _ = handler.stop_scan().await;
                break;
            }
        }
    });
    // すぐに戻り、スキャンは timeout_ms の間続く
    handler
        .discover(Some(tx), timeout_ms, ScanFilter::Service(uuid), false)
        .await
        .map_err(|e| e.to_string())
}
import { Channel, invoke } from '@tauri-apps/api/core';
import type { BleDevice } from '@mnlphlp/plugin-blec';

const onDevices = new Channel<BleDevice[]>();
onDevices.onmessage = (devices) => console.log(devices.map((d) => `${d.name} ${d.address}`));
await invoke('scan_by_service', {
  service: '0000180d-0000-1000-8000-00805f9b34fb',
  timeoutMs: 5000,
  onDevices,
});

動作確認

npm run tauri dev で起動して scan() を呼ぶと、#devices の一覧が 0.2 秒ごとに更新され、10 秒後に次の形のログが出ます。近くに BLE 機器が無ければ、Android の nRF Connect などのアプリでアドバタイズを出して試せます。

スキャン終了: 7 台

よくあるエラーと対処法

  • 「blec.scan not allowed. Permissions associated with this command: blec:allow-scan, blec:default」: 権限の追加漏れです(デバッグビルドの文面。リリースビルドでは「Command plugin:blec|scan not allowed by ACL」)。
  • 「no bluetooth adapters found」: アダプターが見つかりません。Bluetooth の有無と、OS の設定で無効になっていないかを確かめます。
  • 1 台も見つからない: Bluetooth がオフか、UUID を大文字のまま比べているのが多い原因です。別の端末と接続中の機器は、アドバタイズを止めていることもあります。
  • Android で「Missing permissions」を含むエラー: 実行時の許可がまだです。checkPermissions(true) を先に呼びます。
  • Linux でビルドが止まり、libdbus-1-dev と pkg-config を入れるよう促される: D-Bus の開発用パッケージが要ります(Fedora は dbus-devel と pkgconf-pkg-config)。

OS ごとの違いと注意点

  • Windows: アプリ側の設定は不要です。OS の設定で Bluetooth をオンにします。
  • macOS: src-tauri/Info.plist に Bluetooth を使う理由を書きます。この文は初回の許可ダイアログに表示され、無いと Bluetooth に触れた時点でアプリが終了させられることがあります。ビルド時に既定の Info.plist へマージされ、tauri dev の実行ファイルにも埋め込まれます。拒否された後は「プライバシーとセキュリティ」→「Bluetooth」で許可し直します。App Sandbox を使うなら com.apple.security.device.bluetooth のエンタイトルメントも要ります。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>NSBluetoothAlwaysUsageDescription</key>
  <string>心拍計と通信するために Bluetooth を使います</string>
</dict>
</plist>
  • Android: 必要な権限(BLUETOOTH_SCAN・BLUETOOTH_CONNECT など)はプラグインのマニフェストから自動で入ります。0.12 の checkPermissions() は許可ダイアログを出した時点で false を返す(回答を待たない)ので、許可後にもう一度スキャンしてもらいます。拒否が続くと、askIfDenied が true のときはアプリの設定画面が開きます。BLE の無い端末には Google Play からインストールできなくなる指定も入ります。
  • iOS: 同じ説明文を src-tauri/Info.ios.plist などに書き、Xcode で CoreBluetooth フレームワークを追加します。macOS と同じく address は MAC アドレスではないので、他の OS で得た値とは一致しません。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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