BLE デバイスに接続してキャラクタリスティックを読み書きする

tauri-plugin-blec の connect() で BLE 機器に接続し、UUID を指定して read()・send() で読み書き、subscribe() で通知を受ける。切断の検知と再接続、Rust からの操作も示す。

ハードウェア連携 対象: Tauri 2.x 更新日: 読了目安: 約12分 hw-006
目次
  1. 前提条件
  2. サービス・キャラクタリスティックと UUID
  3. 1. フロントエンドから実装する (TypeScript)
  4. 接続してキャラクタリスティックを一覧する
  5. 読む・書く
  6. 通知を購読する
  7. 切断を検知して再接続する
  8. 2. バックエンドから実装する (Rust)
  9. 動作確認
  10. よくあるエラーと対処法
  11. OS ごとの違いと注意点
  12. 関連レシピ

スキャンで見つけた 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)0x180F0x2A19
心拍数(Notify)0x180D0x2A37
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 characteristic for command recv」で始まるエラー: '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 を参照)。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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