BLEビーコンを読み取って、AirTagみたいな挙動をするアプリを作る

:::note warn
この記事の内容はiPhoneで動作確認を行なっています。Android端末では正常に動作しない可能性がありますがご了承ください。
:::

## 要約
最終的なソースコード全体は以下のリンクからご確認いただけます。

https://github.com/mochi-yu/qiita-material/tree/main/ble-client

## 背景
前回の[こちらの記事](https://qiita.com/mochi_2225/items/50ab9544f6f2e5849d57)で、M5Stack Atom LiteというマイコンをBLEビーコンとして設定しました。今度はその**BLEビーコンを利用する簡単なスマホアプリ**をFlutterで作っていきたいと思います。

## 実装
BLEビーコンの仕組みを簡単におさらいしておきます。詳細は前回の記事の説明や以下のサイトなどをご参照ください。

https://www.musen-connect.co.jp/blog/course/trial-production/ble-beginner-1/

ビーコン端末（Atom Lite）は親機の接続を待つための**アドバタイズ信号を常時出し続けています**。親機側（スマホ）ではこのアドバタイズ信号を受信し、その**信号の強度**を見ることで周囲のビーコン端末の状況を把握していきます。この信号強度のことを**RSSI**と言いました。

今回はそのアドバタイズ信号を受信してRSSIの値を使うことで、**近くにあるものを検知するアプリ**を作ってみたいと思います。より具体的には、`Key`という端末名と`Remote`という端末名の2台のBLE端末（マイコン）を用意し、RSSIの大きい方から近くに「鍵」があるのか、近くに「リモコン」があるのか、それともどちらも無いのか、といったことを表示します。

アプリの開発は**Flutter**で行いました。Flutterの基本的な事項はご存知の前提で話を進めますので、必要に応じて他の記事もご参照ください。

### 基本の記述
ファイルの構成は初期プロジェクトと同じもので、基本的には`lib/main.dart`を編集していきます。一部分ずつ説明していくので、ファイルの全体は上記にも挙げた[実装のリポジトリ](https://github.com/mochi-yu/qiita-material/tree/main/ble-client)をご参照ください。

```dart:lib/main.dart（一部）
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';

void main() {
  runApp(const MyApp());
  setup();
}

void setup() async {
  // Bluetoothが有効でパーミッションが許可されるまで待機
  await FlutterBluePlus.adapterState
      .where((val) => val == BluetoothAdapterState.on)
      .first;
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}
```

この辺りの記述は、初めの`setup`関数以外はセオリー通りのFlutterの記述かなと思います。BLEの処理には[`flutter_blue_plus`](https://pub.dev/packages/flutter_blue_plus)というライブラリを使用しますので、初めにこれのインポートをしています。`setup`関数の内部では、Bluetoothが利用可能になるまで待機するという処理を書いています。

### `info.plist`の更新
普段、あまりiOSの開発はしていないのでiOSの設定周りを理解しきれていないのですが、**`info.plist`にBluetoothを使用する旨を追記する**必要がありました。以下の通り、`NSBluetoothAlwaysUsageDescription`と`NSBluetoothPeripheralUsageDescription`を追加しました。

```diff_xml:ios/Runner/Info.plist
<?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>
+ <key>NSBluetoothPeripheralUsageDescription</key>
+ <string>このアプリはBluetoothを使用してデバイスと通信します。</string>
+ <!-- 省略 -->
</dict>
</plist>
```

### BLEビーコンのスキャン
ここが処理の本体になります。一旦、`build`関数の記述は省略しています。BLEの処理は概ね`startScan()`関数の内部にまとまっており、これを繰り返し呼び出しています。

```dart:lib/main.dart（一部）
class _MyHomePageState extends State<MyHomePage> {
  static const _deviceNameMap = {"Key": "鍵", "Remote": "リモコン"};
  String _displayText = "近くにデバイスがありません。";

  @override
  void initState() {
    super.initState();
    startScan();
  }

  void startScan() {
    FlutterBluePlus.startScan(timeout: const Duration(seconds: 3));

    int maxRssi = -1000000;
    String maxRssiDevice = "";

    FlutterBluePlus.scanResults.listen((results) {
      for (ScanResult r in results) {
        if(_deviceNameMap.containsKey(r.advertisementData.advName)) {
          // 最も近いものを記録しておく
          if(r.rssi > maxRssi) {
            maxRssi = r.rssi;
            maxRssiDevice = _deviceNameMap[r.advertisementData.advName]!;

            setState(() {
              _displayText = "近くに$maxRssiDeviceがあります。";
            });
          }
        }
      }
    });

    // Restart the scan every 4 seconds to keep it continuous
    Future.delayed(const Duration(seconds: 3), () {
      if(maxRssi == -1000000) {
        setState(() {
          _displayText = "近くにデバイスがありません。";
        });
      }
      // 再度スキャンする
      FlutterBluePlus.stopScan();
      startScan();
    }); 
  }

  @override
  Widget build(BuildContext context) {
    // 省略 //
  }
}
```

より詳しく処理を見ていきます。まず`FlutterBluePlus.startScan(timeout: const Duration(seconds: 3));`で**周囲のBLEデバイスのアドバタイズの検知を開始**しています。

**検知したデバイスが`FlutterBluePlus.scanResults`でStreamとして流れてくる**ので、それを逐次見てその**端末名`r.advertisementData.advName`を確認**しています。ここでは、端末名が`Remote`か`Key`であるとき該当の端末であると判断して、その**アドバタイズのRSSI`r.rssi`** を確認します。RSSIがより大きいもの（＝すなわちより近くにあるもの）が見つかれば、Flutterのstateを更新して、表示に反映しています。

そして、この`FlutterBluePlus.startScan`ですが、一度開始して端末を検知すると、**以後同じ端末の情報は更新されない**ような挙動になっていました。すなわち、一度検知をすると、スマホを動かしたとしてもRSSIの値が変わったことが分からないということです。ただ求めているのは、逐次RSSIの値を更新していって、近い端末の情報が変わっていくような処理です。

そこで、`Future.delayed(const Duration(seconds: 3), () { ... });`の部分で3秒処理を遅らせ、その中の`FlutterBluePlus.stopScan()`で一度スキャンを止め、再度`startScan()`でスキャンを開始するような処理にしました。

課題としては、スキャンの開始 / 停止を3秒おきに繰り返すこととなるので、**アプリのパフォーマンス**の面で懸念があるのかもなと感じています。ただ、実際にどのくらいパフォーマンスが悪いのかといったことを計測したりはできていません。

### 表示
`build`関数では、近くのデバイスの情報をシンプルにTextとして表示しています。

```dart:lib/main.dart（一部）
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text("近くのデバイスを探します"),
            Text(_displayText, style: const TextStyle(fontSize: 25),),
          ],
        ),
      ),
    );
  }
```

## 動作
上記のようなプログラムで、手持ちのiPhoneを用いてデバッグをしました。その時の動作の様子が以下のツイートの動画です。やはり3秒ごとのスキャンのし直しを待つ必要があるので、ラグが少しみられるようには感じます。ただ、スマホの位置に応じて、より近くの端末が表示されていることが見て取れるかと思います。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">BLEビーコンによるAirTagもどき <a href="https://t.co/ktaFxnfnsB">pic.twitter.com/ktaFxnfnsB</a></p>&mdash; もっちー (@mochi_2225) <a href="https://twitter.com/mochi_2225/status/1813436374772781096?ref_src=twsrc%5Etfw">July 17, 2024</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## 参考

https://pub.dev/packages/flutter_blue_plus