スキャンで見つけた BLE 機器(Bluetooth (BLE) デバイスをスキャンする)に接続すると、機器が公開している「キャラクタリスティック」を読み書きできます。電池残量を読む、マイコンにコマンドを書き込む、心拍数のように機器から送られてくる値を通知で受け取る、といった操作です。ここでは tauri-plugin-blec 0.12 で接続と切断、読み書き、通知の購読、切断の検知と再接続を行い、Rust から同じ接続を使う方法も示します。
前提条件
Rust と JS のパッケージを追加します。npm には 0.12 系までしか公開されていないので、Rust 側も 0.12 にそろえます。uuid は 2 章の Rust コード用で、プラグインの登録も 2 章の run() にあります。
cd src-tauri
cargo add tauri-plugin-blec@0.12
cargo add uuid
cd ..
npm add @mnlphlp/plugin-blec@0.12
権限は、接続・読み書き・通知を含む全機能を許可する blec:default を入れます。
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"blec:default"
]
}
型定義にある getMtu()・setWriteBehavior()・setAndroidMtu() は、0.12 ではこれらを許可する権限が用意されていないため、JS から呼ぶと拒否されます。
サービス・キャラクタリスティックと UUID
BLE 機器の中身は、機能のまとまりである「サービス」と、その中の値の入れ物である「キャラクタリスティック」でできていて、どちらも UUID で指定します。Bluetooth SIG が定めた標準のものは 16 ビットで書かれますが、プラグインには 128 ビット表記で渡します(0x2A19 なら 00002a19-0000-1000-8000-00805f9b34fb)。メーカー独自のものは最初から 128 ビットです。
| 用途 | サービス | キャラクタリスティック |
|---|---|---|
| 電池残量(Read) | 0x180F | 0x2A19 |
| 心拍数(Notify) | 0x180D | 0x2A37 |
| Nordic UART(マイコン向けモジュールでよく使われる独自サービス) | 6e400001-… | 6e400002-…(機器へ Write)/ 6e400003-…(機器から Notify) |
1. フロントエンドから実装する (TypeScript)
接続してキャラクタリスティックを一覧する
connect(address, onDisconnect) は、接続とサービスの探索が済んでから戻り、実行中のスキャンは止まります。失敗すると 1 秒おきに最大 3 回まで試し、接続・探索・読み取り・購読の各操作は 5 秒で打ち切られます。同時に接続できるのは 1 台で、read() などにアドレスを渡さないのはそのためです。別の機器に切り替えるときは先に disconnect() します。
import { connect, disconnect, listServices, type BleService } from '@mnlphlp/plugin-blec';
// properties はビットの組み合わせ
const PROPS: [number, string][] = [
[0x02, 'Read'], [0x04, 'WriteWithoutResponse'], [0x08, 'Write'], [0x10, 'Notify'], [0x20, 'Indicate'],
];
const propNames = (bits: number) => PROPS.filter(([b]) => bits & b).map(([, n]) => n);
export async function connectAndInspect(address: string, onLost: () => void): Promise<BleService[]> {
try {
await connect(address, onLost); // 接続とサービスの探索が済むと戻る
} catch (e) {
await disconnect().catch(() => {}); // 途中まで接続できた場合に備えて後始末する
throw new Error(`接続できませんでした: ${String(e)}`);
}
const services = await listServices(address); // 接続中の機器のアドレスを渡す
if (typeof services === 'string') throw new Error(services);
for (const s of services) {
console.log(`service ${s.uuid}`);
for (const c of s.characteristics) console.log(` char ${c.uuid} [${propNames(c.properties).join(', ')}]`);
}
return services;
}
listServices() の戻り値の型は BleService[] | string なので、文字列の場合を分けて扱います。UUID は小文字で返るので、自分の定数も小文字で書いておくと比べやすくなります。
読む・書く
read() は number[](0〜255)を返し、send() も同じ形の配列を受け取ります。'withResponse' は機器の応答を待つので確実ですが遅く、'withoutResponse' は待たずに次を送れます。キャラクタリスティックがその書き方(Write / WriteWithoutResponse)に対応している必要があります。最後の引数のサービス UUID は、同じキャラクタリスティック UUID が複数のサービスにあるときに指定します。readString() / sendString() は UTF-8 の文字列として扱うので、バイナリは read() で受けて DataView などで読みます。
import { read, send, sendString } from '@mnlphlp/plugin-blec';
const BATTERY_SERVICE = '0000180f-0000-1000-8000-00805f9b34fb';
const BATTERY_LEVEL = '00002a19-0000-1000-8000-00805f9b34fb';
const UART_SERVICE = '6e400001-b5a3-f393-e0a9-e50e24dcca9e';
const UART_RX = '6e400002-b5a3-f393-e0a9-e50e24dcca9e'; // 機器が受け取る側
// 電池残量(%)は 1 バイト
export async function readBattery(): Promise<number> {
const [level = 0] = await read(BATTERY_LEVEL, BATTERY_SERVICE);
return level;
}
// 改行付きの文字列を送る。応答を待つので届いたことを確かめられる
export async function sendLine(text: string): Promise<void> {
await sendString(UART_RX, `${text}\n`, 'withResponse', UART_SERVICE);
}
// 16 ビット整数(リトルエンディアン)を応答なしで書く
export async function writeU16(value: number): Promise<void> {
const view = new DataView(new ArrayBuffer(2));
view.setUint16(0, value, true);
await send(UART_RX, [view.getUint8(0), view.getUint8(1)], 'withoutResponse', UART_SERVICE);
}
通知を購読する
subscribe(characteristic, service, handler) は、第 2 引数がサービス UUID(不要なら null)で、コールバックは第 3 引数です。古い例にある subscribe(uuid, handler) の形は型エラーになります。使えるのは Notify か Indicate を持つキャラクタリスティックだけです。同じキャラクタリスティックに 2 回 subscribe() すると 1 つの通知でコールバックが 2 回呼ばれるので、購読は接続ごとに 1 回にします。
import { subscribe, subscribeString, unsubscribe } from '@mnlphlp/plugin-blec';
const HR_SERVICE = '0000180d-0000-1000-8000-00805f9b34fb';
const HR_MEASUREMENT = '00002a37-0000-1000-8000-00805f9b34fb';
// 先頭バイトの bit0 が 1 なら心拍数は 16 ビット、0 なら 8 ビット
function heartRate(d: number[]): number {
return (d[0] ?? 0) & 0x01 ? (d[1] ?? 0) | ((d[2] ?? 0) << 8) : (d[1] ?? 0);
}
export async function watchHeartRate(onBpm: (bpm: number) => void): Promise<() => Promise<void>> {
await subscribe(HR_MEASUREMENT, HR_SERVICE, (data) => onBpm(heartRate(data)));
return () => unsubscribe(HR_MEASUREMENT); // 購読をやめる関数を返す
}
// Nordic UART の機器 → アプリ側を文字列で受ける
export async function watchUart(onText: (s: string) => void): Promise<void> {
await subscribeString('6e400003-b5a3-f393-e0a9-e50e24dcca9e', '6e400001-b5a3-f393-e0a9-e50e24dcca9e', onText);
}
切断を検知して再接続する
connect() に渡した onDisconnect は、disconnect() を呼んだときにも、電源が切れた・離れたなど機器側から切れたときにも呼ばれます。切断されると購読はすべて消えるので、再接続したら購読し直します。画面の表示には getConnectionUpdates() が使えます。登録直後に現在の状態が 1 回届き、その後は変化のたびに呼ばれます(解除できないので 1 回だけ登録します)。
プラグインは直前のスキャン結果から接続先を探すので、スキャンせずに connect() すると「There is no peripheral with id: …」で失敗することがあります。再接続では、スキャンで見つかるのを待ってから接続します。
import { connect, disconnect, getConnectionUpdates, startScan, subscribe } from '@mnlphlp/plugin-blec';
const HR_SERVICE = '0000180d-0000-1000-8000-00805f9b34fb';
const HR_MEASUREMENT = '00002a37-0000-1000-8000-00805f9b34fb';
let target: string | null = null; // つないでおきたい機器
let closing = false; // 自分で切断中か
await getConnectionUpdates((connected) => {
document.body.dataset.ble = connected ? 'on' : 'off';
});
// 接続して購読する(購読は切断で消えるので接続のたびに行う)
async function attach(address: string): Promise<void> {
await connect(address, onLost);
await subscribe(HR_MEASUREMENT, HR_SERVICE, (data) => console.log('HR', data));
}
function onLost(): void {
if (closing || !target) return; // 自分で切ったときは何もしない
console.warn('機器から切断されました。再接続します');
void reconnect(target);
}
// スキャンで見つかってから接続し直す(最大 3 回)
async function reconnect(address: string): Promise<void> {
for (let i = 0; i < 3; i++) {
const seen = await new Promise<boolean>((resolve) => {
window.setTimeout(() => resolve(false), 10_000);
startScan((list) => {
if (list.some((d) => d.address === address)) resolve(true);
}, 10_000).catch(() => resolve(false));
});
if (!seen) continue;
try {
await attach(address); // connect() が実行中のスキャンを止める
return;
} catch {
await disconnect().catch(() => {});
}
}
console.error('再接続できませんでした');
}
export async function open(address: string): Promise<void> {
closing = false;
target = address;
await attach(address);
}
export async function close(): Promise<void> {
closing = true;
await disconnect();
}
2. バックエンドから実装する (Rust)
get_handler() は JS と同じハンドラーを返すので、JS で接続した機器に Rust からそのまま読み書きできます。通知が短い間隔で届く機器は、Rust で受けて必要な値だけをフロントエンドへ送ると IPC の回数を減らせます。コールバックは通知を受け取る処理の中で順に呼ばれるので、重い処理は入れません。
0.12 はアプリの終了時に自動で切断しません。切断しないまま終わると、機器によっては接続中のままと扱われ、しばらく次のスキャンで見つからないことがあります。RunEvent::Exit で切断しておきます。
use std::time::Duration;
use tauri::ipc::Channel;
use tauri::RunEvent;
use tauri_plugin_blec::models::WriteType;
use uuid::{uuid, Uuid};
const HR_SERVICE: Uuid = uuid!("0000180d-0000-1000-8000-00805f9b34fb");
const HR_MEASUREMENT: Uuid = uuid!("00002a37-0000-1000-8000-00805f9b34fb");
const UART_SERVICE: Uuid = uuid!("6e400001-b5a3-f393-e0a9-e50e24dcca9e");
const UART_RX: Uuid = uuid!("6e400002-b5a3-f393-e0a9-e50e24dcca9e");
fn heart_rate(data: &[u8]) -> Option<u16> {
let flags = *data.first()?;
if flags & 0x01 == 0 {
data.get(1).map(|&v| u16::from(v))
} else {
Some(u16::from_le_bytes([*data.get(1)?, *data.get(2)?]))
}
}
/// 心拍数の通知を Rust で受け、数値だけをフロントエンドへ送る
#[tauri::command]
async fn watch_heart_rate(on_bpm: Channel<u16>) -> Result<(), String> {
let handler = tauri_plugin_blec::get_handler().map_err(|e| e.to_string())?;
handler
.subscribe(HR_MEASUREMENT, Some(HR_SERVICE), move |data: Vec<u8>| {
if let Some(bpm) = heart_rate(&data) {
let _ = on_bpm.send(bpm);
}
})
.await
.map_err(|e| e.to_string())
}
/// 接続中の機器へ 1 行送る(JS で接続済みなら、Rust で接続し直さなくてよい)
#[tauri::command]
async fn send_line(text: String) -> Result<(), String> {
let handler = tauri_plugin_blec::get_handler().map_err(|e| e.to_string())?;
let data = format!("{text}\n").into_bytes();
handler
.send_data(UART_RX, Some(UART_SERVICE), &data, WriteType::WithResponse)
.await
.map_err(|e| e.to_string())
}
/// 終了前に切断する。応答が無くても 3 秒で諦める
fn disconnect_before_exit() {
let Ok(handler) = tauri_plugin_blec::get_handler() else { return };
if !handler.is_connected() {
return;
}
let (tx, rx) = std::sync::mpsc::channel();
tauri::async_runtime::spawn(async move {
let _ = handler.disconnect().await;
let _ = tx.send(());
});
let _ = rx.recv_timeout(Duration::from_secs(3));
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_blec::init())
.invoke_handler(tauri::generate_handler![watch_heart_rate, send_line])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|_app, event| {
if let RunEvent::Exit = event {
disconnect_before_exit();
}
});
}
import { Channel, invoke } from '@tauri-apps/api/core';
// JS の connect() で接続してから呼ぶ
const onBpm = new Channel<number>();
onBpm.onmessage = (bpm) => console.log(`心拍数 ${bpm} bpm`);
await invoke('watch_heart_rate', { onBpm });
await invoke('send_line', { text: 'LED ON' });
動作確認
npm run tauri dev で起動し、hw-004 のスキャンで見つけたアドレスを connectAndInspect() に渡すと、コンソールに次の形で一覧が出ます(内容は機器によって異なります)。心拍計なら watchHeartRate() で値が届き続け、機器の電源を切ると onDisconnect が呼ばれます。手元に機器が無ければ、Android の nRF Connect で GATT サーバーを設定して試せます。
service 0000180f-0000-1000-8000-00805f9b34fb
char 00002a19-0000-1000-8000-00805f9b34fb [Read, Notify]
よくあるエラーと対処法
- 「There is no peripheral with id: …」: 直前のスキャンで見つかっていないアドレスです。スキャンで見つかってから接続します。
- 「Timeout during execution of Connect」: 接続の要求が時間切れになりました(1 回 5 秒、最大 3 回)。電波が弱いか、機器がほかの端末(スマートフォンのアプリなど)と接続中で受け付けていないことがあります。
- 「No device connected」: 接続前や切断後に
read()などを呼んでいます。 - 「Characteristic … not available」: 接続中の機器にその UUID がありません。
listServices()の結果と見比べます。 - 「invalid args
characteristicfor commandrecv」で始まるエラー:'2A19'のような 16 ビットの短い形は受け付けません。128 ビット表記で渡します。 - 「blec.mtu not allowed. Command not found」: 0.12 では
getMtu()を許可する権限がありません。MTU は Rust のmtu()で確かめます。
OS ごとの違いと注意点
- Android: 接続時に MTU 517 を要求し、ほかの OS では自動で決まります。1 回の書き込みで送れるのは MTU − 3 バイトが目安で、超えるデータは分けて送ります。
- macOS / iOS:
addressは OS が割り当てた UUID なので、保存したアドレスはほかの OS では使えません。Info.plist の設定は hw-004 を参照してください。 - Windows / Linux: 接続まわりでアプリ側の追加設定は要りません(Linux のビルドに必要なパッケージは hw-004 を参照)。
