心拍計やセンサー、自作のマイコン基板などの 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 の中身
| プロパティ | 型 | 内容 |
|---|---|---|
address | string | 接続に使う識別子。Windows / Linux / Android は MAC アドレス、macOS / iOS は OS が割り当てた UUID |
name | string | 広告された名前。無ければ識別子の文字列 |
rssi | number | 受信の強さ(dBm)。受信できていないと null |
services | string[] | 広告しているサービス UUID(小文字) |
manufacturerData | Record<number, number[]> | キーは企業 ID(Apple は 76 = 0x004C) |
serviceData | Record<string, number[]> | キーは小文字の UUID |
isBonded | boolean | ペアリング済みか。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 で得た値とは一致しません。
