この記事の内容はiPhoneで動作確認を行なっています。Android端末では正常に動作しない可能性がありますがご了承ください。
要約
最終的なソースコード全体は以下のリンクからご確認いただけます。
背景
前回のこちらの記事で、M5Stack Atom LiteというマイコンをBLEビーコンとして設定しました。今度はそのBLEビーコンを利用する簡単なスマホアプリをFlutterで作っていきたいと思います。
実装
BLEビーコンの仕組みを簡単におさらいしておきます。詳細は前回の記事の説明や以下のサイトなどをご参照ください。
ビーコン端末(Atom Lite)は親機の接続を待つためのアドバタイズ信号を常時出し続けています。親機側(スマホ)ではこのアドバタイズ信号を受信し、その信号の強度を見ることで周囲のビーコン端末の状況を把握していきます。この信号強度のことをRSSIと言いました。
今回はそのアドバタイズ信号を受信してRSSIの値を使うことで、近くにあるものを検知するアプリを作ってみたいと思います。より具体的には、Key
という端末名とRemote
という端末名の2台のBLE端末(マイコン)を用意し、RSSIの大きい方から近くに「鍵」があるのか、近くに「リモコン」があるのか、それともどちらも無いのか、といったことを表示します。
アプリの開発はFlutterで行いました。Flutterの基本的な事項はご存知の前提で話を進めますので、必要に応じて他の記事もご参照ください。
基本の記述
ファイルの構成は初期プロジェクトと同じもので、基本的には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
というライブラリを使用しますので、初めにこれのインポートをしています。setup
関数の内部では、Bluetoothが利用可能になるまで待機するという処理を書いています。
info.plist
の更新
普段、あまりiOSの開発はしていないのでiOSの設定周りを理解しきれていないのですが、info.plist
にBluetoothを使用する旨を追記する必要がありました。以下の通り、NSBluetoothAlwaysUsageDescription
とNSBluetoothPeripheralUsageDescription
を追加しました。
<?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()
関数の内部にまとまっており、これを繰り返し呼び出しています。
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
であるとき該当の端末であると判断して、そのアドバタイズのRSSIr.rssi
を確認します。RSSIがより大きいもの(=すなわちより近くにあるもの)が見つかれば、Flutterのstateを更新して、表示に反映しています。
そして、このFlutterBluePlus.startScan
ですが、一度開始して端末を検知すると、以後同じ端末の情報は更新されないような挙動になっていました。すなわち、一度検知をすると、スマホを動かしたとしてもRSSIの値が変わったことが分からないということです。ただ求めているのは、逐次RSSIの値を更新していって、近い端末の情報が変わっていくような処理です。
そこで、Future.delayed(const Duration(seconds: 3), () { ... });
の部分で3秒処理を遅らせ、その中のFlutterBluePlus.stopScan()
で一度スキャンを止め、再度startScan()
でスキャンを開始するような処理にしました。
課題としては、スキャンの開始 / 停止を3秒おきに繰り返すこととなるので、アプリのパフォーマンスの面で懸念があるのかもなと感じています。ただ、実際にどのくらいパフォーマンスが悪いのかといったことを計測したりはできていません。
表示
build
関数では、近くのデバイスの情報をシンプルにTextとして表示しています。
@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秒ごとのスキャンのし直しを待つ必要があるので、ラグが少しみられるようには感じます。ただ、スマホの位置に応じて、より近くの端末が表示されていることが見て取れるかと思います。
BLEビーコンによるAirTagもどき pic.twitter.com/ktaFxnfnsB
— もっちー (@mochi_2225) July 17, 2024
参考