はじめに
DeNA 25新卒 Advent Calendar 2024の6日目の記事です。25卒同期が魅力的な記事を紹介してくれると思うので、ぜひチェックしてみてください!
この記事では、最近デバイス間の近距離無線通信(すれ違い通信)機能を有したアプリを開発する機会があり、普段はクライアント・サーバ方式による開発が大半の私にとって学びが多かったので、この機会に備忘録としてまとめることにします。
想定読者
- サーバを介さないデバイス間の通信(P2P通信)に興味がある人
- クライアント・サーバ方式による通信は理解しているが、P2Pという言葉はなんとなく知っている人
- Flutterを用いて、P2P通信を実装してみたい人
この記事ではあまり言及しないこと (対象範囲外)
- BLEのセキュリティ面1(特にペアリングやボンディング等に関して)
- BLEのパフォーマンス面(特にOSや通信環境起因の問題が大きいため)
BLE通信の基本概念
前半はBLEを実装するにあたり必要になる概念の紹介、後半はflutter_blue_plus
を用いたP2P通信(BLE)の実装の紹介です。
そもそも 『近距離無線通信』 とは
電波や光などを使ってデータをやり取りする無線通信のうち、通信距離が数十メートル以内のものを一般に近距離無線通信と呼びます。
方式 | 周波数 [GHz] | 通信速度 [kbps] | 通信距離 [m] | トポロジー | 特徴 |
---|---|---|---|---|---|
Wi-Fi | 5.6, 5.2, 2.4 | 300000, 54000, 11000 | 100 | P2P, Star | 汎用的で唯一のストリーム系 |
Bluetooth | 2.4 | 24000, 3000, 1000 | 20 | P2P, Star | スマートフォンなどのデバイスとの親和性が高い |
Bluetooth Low Energy | 2.4 | 1000 | 20 | P2P, Star | Bluetoothの低電力版 |
ANT/ANT+ | 2.4 | 1000 | 20 | P2P, Star | 超低消費電力で通信距離も短い |
ZigBee | 2.4, 0.902~0.928, 0.868~0.870 | 250 | 50 | P2P, Star, Tree, Mesh | センサーネットワーク用途 |
ZigBee Green Power | 2.4 | 250 | 50 | P2P, Star, Tree, Mesh | ZigBeeの低電力版 |
Sub-GHz | 0.15~0.95 | 100 | 700 | P2P, Star, Tree, Mesh | 汎用的で自由度が高い |
Z-wave | 0.779~0.956 | 100, 40, 9.6 | 30 | Mesh | 家庭内ネットワーク向け規格 |
Wireless HART | 2.4 | 250 | 50 | Mesh, Star, Mesh + Star | 産業用に特化した規格 |
EnOcean | 0.868, 0.902, 0.92835 | 125 | 100 | Star | ハーベスト通信に特化した規格 |
上表に示したように、その規格やネットワークトポロジーの種類は多く、特性の違いからそれぞれ適した使用用途は異なります。
この記事では、スマートフォン等でよく使用されるBluethooth, BLEを主に取り扱います。
P2P (Peer to Peer) 通信
サーバを介さずに端末同士で直接データのやり取りを行う通信方式のことを指します。
Peerには、「同等の人、同僚、仲間」などの意味があり、ネットワークに接続している端末のことを、 ”ピア” もしくは “ノード” と呼びます。そして、P2P技術を用いてピア(ノード)同士が接続されているネットワークのことを、『P2Pネットワーク』と呼びます。
詳細は以下の記事をご覧ください。
BLE (Bluetooth Low Energy)
無線通信規格の一つであり、主に近距離通信の用途で広く使用されています。
Bluetooth Classic2との互換性はなく、Bluetooth 4.0以降のバージョンで利用できます。つまり、Bluetoothは「Bluetooth Classic」と「Bluetooth Low Energy (BLE)」の2つの異なる通信規格に大きく分けられます。
特徴として、省電力かつ低コストで利用できるため、ビーコン(AirTagのようなもの)の接続規格としても適しています。また、対応機種も多く、iOSやAndroidのみならずmacOSやWindows OS、Linux、Raspberry Piなどのマイコンでも利用可能です。
2007年にBluetooth SIGが低消費電力無線技術「Wibree」を統合した時点で、「Wibree」を「BLE」へと改称し、2010年のBluetooth 4.0リリース時に、それまでのBR/EDR/HSに続く新たな第4の制御技術として追加した規格。
Central (セントラル) と Peripheral (ペリフェラル)
Central:親機(主にスマートフォンやPCなど)
Peripheral:子機(主にセンサやビーコンなど)
1対1通信のみならず、1対N通信(ブロードキャスト)なども実現可能です。
Advertisement (アドバタイズメント)
Advertisement:Peripheral側がCentral側からの接続を待つ仕組み
ブロードキャスト通信と同じ通信手法です。Advertisementは、1対1の通信方式ではなく、1対Nの不特定多数の端末に一方通行でデータを送信する通信方式です。Advertisementで発信するデータに、Peripheral機器の属性データなどを含めることができます。
Central機器はスキャンすることでこのAdvertisement信号を受信し、周囲にどのPeripheral機器が存在しているかを検知することが可能です。
ちなみに、Central機器がAdvertisement信号を受信した時の電波の強さをRSSI(受信電波強度)と呼びます。つまり、Central機器はこのRSSIを用いることで、Peripheral機器との距離を把握することも可能です。
GATT (Generic Attribute Profile)
GATT:CentralとPeripheral間で接続を行うときのプロファイル(プロトコル)
Central機器は、スキャンを介して見つけたPeripheral機器に接続リクエストを送信します。
接続リクエストを受け取ったPeripheral機器はAdvertiseを中断し、1対1の接続通信に切り替えます。この1対1の接続プロトコルのことをGATT(Generic Attribute Profile)と呼びます。
詳細については後述しますが、GATTでは、ServiceやCharacteristicという概念を用いてデータのやり取りを行います。
Characteristic (キャラクタリスティック)
Characteristic:Peripheralが公開するデータスキーマのようなもの
PeripheralとCentralは、Characteristicを介してデータのやり取りを行います。
データは主に「Read」「Write」「Notify」のメソッドでやり取りします。
メソッド | 概要 |
---|---|
Read | データの読み取り(Central -> Peripheral) |
Write | データの書き込み(Central -> Peripheral) |
Notify | データ更新の通知(Peripheral -> Central) |
上記のメソッド以外にも、送信後の確認応答を要求するIndicateメソッドがあります。
Service(サービス)
Service:Characteristicを機能単位でまとめたもの
1つのServiceには1つ以上のCharacteristicが存在します。つまり、ServiceとCharacteristicの相互関係は1対Nとなります。Peripheralは複数のServiceを持つことができ、CentralはPeripheralが持つServiceとCharacteristicのUUIDを指定して通信を行います。
その他にも、Serviceを包括するProfileやCharacteristicの中にもProperties, Value, Descriptorが含まれますが、今回の実装においてはServiceとCharacteristicの理解のみで問題ないです。
BLE通信のプロトコル
まとめると、BLEでは以下の接続手順に従ってPeripheralとCentral間で通信を行います。
- PeripheralがAdvertiseを開始
- Centralがスキャンし、Peripheralを検出する
- CentralがPeripheralに接続リクエストを送る
- Peripheralがリクエストを受信し、1対1の通信方式に切り替える
- 両者間でServiceとCharacteristicのUUIDとメソッドを指定してデータをやり取りする
通信プロファイル内部の細かい接続プロセス等は省略しています。
flutter_blue_plusを用いた実装
今回は、多くのデバイス間でP2P通信を実現したかったため、クロスプラットフォーム対応のFWであるFlutterと、そのBLEライブラリのflutter_blue_plus
を選定しました。
※ React NativeではP2P通信の具体的な実装例を見つけるのが難しいため、今回はFlutterを使用しました。別の機会にReact Nativeでも試してみたいと考えています。
P2P通信を実現するFlutterのライブラリは他にもありますが、メンテナンスの更新頻度があまり高くないため、今回は避けることにしました。(flutter_nearby_connections3等)
今回のプログラムは、BLE通信の基本機能を検証するためのシンプルな実装に留めています。概要としては、スマートフォン端末をCentral機器として動作させ、以下の機能を実装しました。
- Bluetoothアダプターの状態監視
- 周辺のPeripheral機器のスキャンとAdvertiseの受信(Central側の機能)
- GATT接続の確立と切断
- ServiceとCharacteristicの探索と表示
あくまで検証用なので、main.dart
にまとめて記述しますが、ご自身の実装やデザインパターンに合わせて修正してください。
実行環境
tool | version |
---|---|
Flutter SDK | 3.24.5 |
Dart SDK | 3.5.4 |
Xcode | 16.1 |
CocoaPods | 1.15.2 |
- デバッグ機:iPhone 14(iOS18.1.1)
パーミッション設定
実装に入る前に、AndroidとiOSのそれぞれでパーミッションの設定が必要になります。
Android側
flutter_blue_plus
はAndroid SDKのver.21以降しか対応していないので、android/app/build.gradle
を変更する必要があります。
android {
defaultConfig {
minSdk = 21
// 省略 //
Androidのパーミッション付与
(Bluetoothを使って位置情報を特定しない場合、またはiBeaconをサポートしない場合)
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Google Play StoreへのBluetooth機能の宣言 -->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<!-- Android 12以降の新しいBluetooth権限 -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Android 11以前のレガシー権限 -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<!-- Android 9以前のレガシー権限 -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="28" />
Androidのパーミッション付与 ※今回はこちら
(Bluetoothを使って位置情報を特定したい場合、またはiBeaconをサポートしたい場合)
<!-- Google Play StoreへのBluetooth機能の宣言 -->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<!-- Android 12以降の新しいBluetooth権限 -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- Android 11以前のレガシー権限 -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<!-- Android 9以前のレガシー権限 -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="28" />
念の為、Android Proguard用の設定もしておきます。(設定しなくとも動作はします)
-keep class com.lib.flutter_blue_plus.* { *; }
Proguardを有効化
android {
// 省略 //
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
iOS側
iOSのパーミッション付与
<dict>
<!-- 省略 -->
<key>NSBluetoothAlwaysUsageDescription</key>
<string>このアプリはBluetoothを使用してデバイスと通信します</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>このアプリはBluetoothを使用してデバイスと通信します</string>
<!-- ユーザがアプリを初めて起動したときに、Bluetooth使用の許可を求める際にこの文言が表示されます。 -->
位置情報のパーミッションが必要な場合は、別途NSLocationWhenInUseUsageDescription
やNSLocationAlwaysAndWhenInUseUsageDescription
を追加する必要があります。
BLE通信の実装例
Bluetoothアダプターの監視
@override
void initState() {
super.initState();
FlutterBluePlus.adapterState.listen((BluetoothAdapterState state) {
if (state == BluetoothAdapterState.on) {
logger.i('Bluetoothがオンになりました');
} else {
logger.w('Bluetoothがオフです: $state');
}
});
}
周辺のBLEデバイスのスキャン(Central側の機能)
void startScan() async {
setState(() {
scanResults = [];
isScanning = true;
});
try {
await FlutterBluePlus.startScan(
timeout: const Duration(seconds: 15), // ひとまず15s
androidUsesFineLocation: false,
);
FlutterBluePlus.scanResults.listen((results) {
setState(() {
scanResults = results;
});
});
} catch (e) {
logger.e('スキャンエラー: $e');
}
}
GATT接続の確立とServicesの探索
void connectToDevice(BluetoothDevice device) async {
try {
await device.connect();
setState(() {
selectedDevice = device;
});
// Serviceの探索
services = await device.discoverServices();
setState(() {});
} catch (e) {
logger.e('接続エラー: $e');
}
}
全体のプログラム
検証用プログラムなので、あくまで参考程度に。
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:logger/logger.dart';
final logger = Logger();
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'BLE Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const MyHomePage(title: 'BLE デモ'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
List<ScanResult> scanResults = []; // Advertiseを受信したデバイス(Peripheral機器)一覧
BluetoothDevice? selectedDevice; // 接続済みデバイス
bool isScanning = false; // スキャン状態のフラグ
List<BluetoothService> services = []; // 検出されたService一覧
@override
void initState() {
super.initState();
// Bluetoothの状態を監視
FlutterBluePlus.adapterState.listen((BluetoothAdapterState state) {
if (state == BluetoothAdapterState.on) {
logger.i('Bluetoothがオンになりました');
} else {
logger.w('Bluetoothがオフです: $state');
}
});
}
// デバイスのスキャンを開始
void startScan() async {
setState(() {
scanResults = [];
isScanning = true;
});
try {
// Peripheral機器からのAdvertise信号をスキャン
await FlutterBluePlus.startScan(
timeout: const Duration(seconds: 15),
androidUsesFineLocation: false,
);
// Advertise信号の受信をリアルタイムで監視
FlutterBluePlus.scanResults.listen((results) {
setState(() {
scanResults = results;
});
});
// スキャン完了を監視
FlutterBluePlus.isScanning.listen((scanning) {
setState(() {
isScanning = scanning;
});
});
} catch (e) {
logger.e('スキャンエラー: $e');
}
}
// デバイスに接続
void connectToDevice(BluetoothDevice device) async {
try {
// GATT接続の確立
await device.connect();
setState(() {
selectedDevice = device;
});
// GATT接続状態の監視
device.connectionState.listen((BluetoothConnectionState state) {
logger.i('接続状態: $state');
if (state == BluetoothConnectionState.disconnected) {
setState(() {
selectedDevice = null;
services = []; // 切断時にServiceをクリア
});
}
});
// Serviceの探索
services = await device.discoverServices();
setState(() {});
} catch (e) {
logger.e('接続エラー: $e');
}
}
// デバイスから切断
void disconnectDevice() async {
if (selectedDevice != null) {
try {
await selectedDevice!.disconnect();
setState(() {
selectedDevice = null;
});
} catch (e) {
logger.e('切断エラー: $e');
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: <Widget>[
const SizedBox(height: 20),
if (selectedDevice == null) ...[
ElevatedButton(
onPressed: isScanning ? null : startScan,
child: Text(isScanning ? 'スキャン中...' : 'スキャン開始'),
),
const SizedBox(height: 20),
Expanded(
child: ListView.builder(
itemCount: scanResults.length,
itemBuilder: (context, index) {
final result = scanResults[index];
return ListTile(
title: Text(result.device.platformName.isEmpty
? 'Unknown Device'
: result.device.platformName),
subtitle: Text('RSSI: ${result.rssi}'),
onTap: () => connectToDevice(result.device),
);
},
),
),
] else ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('接続済みデバイス: ${selectedDevice!.platformName}'),
ElevatedButton(
onPressed: disconnectDevice,
child: const Text('切断'),
),
],
),
const SizedBox(height: 20),
Expanded(
child: ListView.builder(
itemCount: services.length,
itemBuilder: (context, serviceIndex) {
final service = services[serviceIndex];
return ExpansionTile(
title: Text('Service: ${service.uuid}'),
children: service.characteristics.map((characteristic) {
return ListTile(
title: Text('Characteristic: ${characteristic.uuid}'),
subtitle: Text(
'Properties: ${characteristic.properties.toString()}'),
dense: true,
);
}).toList(),
);
},
),
),
],
],
),
),
);
}
}
デバッグ検証
アプリの立ち上げ時のログ
Connecting to VM Service at ws://127.0.0.1:59346/rHpU_EzwnqU=/ws
Connected to the VM Service.
flutter: \^[[38;5;12m┌───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────<…>
flutter: \^[[38;5;12m│ #0 _MyHomePageState.initState.<anonymous closure> (package:my_blue_connection_app/main.dart:51:16)<…>
flutter: \^[[38;5;12m│ #1 _RootZone.runUnaryGuarded (dart:async/zone.dart:1594:10)<…>
flutter: \^[[38;5;12m├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄<…>
flutter: \^[[38;5;12m│ 💡 Bluetoothがオンになりました<…>
flutter: \^[[38;5;12m└───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────<…>
任意のPeripheral端末と接続した時のログ
flutter: \^[[38;5;12m┌───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────<…>
flutter: \^[[38;5;12m│ #0 _MyHomePageState.connectToDevice.<anonymous closure> (package:my_blue_connection_app/main.dart:100:16)<…>
flutter: \^[[38;5;12m│ #1 _RootZone.runUnaryGuarded (dart:async/zone.dart:1594:10)<…>
flutter: \^[[38;5;12m├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄<…>
flutter: \^[[38;5;12m│ 💡 接続状態: BluetoothConnectionState.connected<…>
flutter: \^[[38;5;12m└───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────<…>
Peripheral端末との接続を切断した時のログ
flutter: \^[[38;5;12m┌───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────<…>
flutter: \^[[38;5;12m│ #0 _MyHomePageState.connectToDevice.<anonymous closure> (package:my_blue_connection_app/main.dart:100:16)<…>
flutter: \^[[38;5;12m│ #1 _RootZone.runUnaryGuarded (dart:async/zone.dart:1594:10)<…>
flutter: \^[[38;5;12m├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄<…>
flutter: \^[[38;5;12m│ 💡 接続状態: BluetoothConnectionState.disconnected<…>
flutter: \^[[38;5;12m└───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────<…>
検証における注意事項
あいにく手元にAndroid端末がなかったため、Androidの実機デバッグは未実施ですが、BLE通信は端末のBluetooth機能に依存する通信となるため、Central機器として利用する端末の性能やOS差異によるパフォーマンスへの影響はクライアント・サーバ方式よりも大きいと考えられます。
また、今回はフォアグランド通信で行いましたが、設定を追加する4ことでバッググラウンド通信も実装可能なので、バッテリー消費削減やすれ違い通信等の用途で使用する場合には有効な選択肢だと思います。
まとめ
今回は、サーバを介すことなくローカルの端末同士で通信するやり方についてまとめてみました。P2Pは今まで単語としては聞いたことがありましたが、具体的な通信プロトコルや内部の仕組みについては知らなかったので、新鮮な気持ちでキャッチアップできた気がします。
”近くの端末の存在を検知する”ことにおいては、BLEを利用したP2P通信以外にもGPSを併用したり、もしくはGPS単体を使ってデバイス間の相対距離を求め、サーバ側で処理させる手法もあるので、興味のある人はぜひ調べてみてください。
参考文献
-
BLEのセキュリティ面に関してざっくりと言及すると、BLE通信レベルのセキュリティ対策には期待せずに、アプリ層にてセキュリティ強化するのが効果的らしいです。
https://www.musen-connect.co.jp/blog/course/trial-production/ble-beginner-8/ ↩ -
Bluetooth 3.0以前のBluetooth規格であるBR/EDR/HSの総称。レガシーBluetoothと呼ばれることもあります。 ↩
-
https://github.com/chipweinberger/flutter_blue_plus#using-ble-in-app-background ↩