たくさんの自由帳
Androidのお話
たくさんの自由帳
投稿日 : | 0 日前
文字数(だいたい) : 7656
目次
本題
BLE 公式
BLE 登場人物
BLE 流れ
端末
つくる
作成
AndroidManifest
app/build.gradle
画面を作る
権限を求める
UUID を決める
ペリフェラル側(ホスト側)を作る
ペリフェラル側 GATT サーバーを作る
ペリフェラル側 アドバタイジングを作る
ペリフェラル側 終了処理
ペリフェラル側 ここまで
ペリフェラル側の画面を完成させる
セントラル側(ゲスト側)を作る
セントラル側 BLE デバイスを見つける
セントラル側 GATT サーバーへ接続する
セントラル側 キャラクタリスティックへ read する
セントラル側 キャラクタリスティックへ write する
セントラル側 終了処理
セントラル側 ここまで
セントラル側の画面も完成させる
使ってみる
そーすこーど
なぞ
おわりに
どうもこんばんわ。
容量足りんくてパソコンのスクショ整理してたら見覚えある風景あったから見てきた。
時期が時期なだけあってキラキラしている。
作中は反転してるっぽいので反転してください。
Android端末同士をBluetooth Low Energy(以降 BLE)を使って小さなデータをやり取りできるようにしたい。
別件で近くの端末と値の交換をしたくなった。
今回はBLEのペリフェラル、セントラル側をAndroidで作ってみて実際にデータのやり取りをしてみる。
お試しにテキストを送ってみます。バイト配列にシリアライズできればなんでも良いはず?JavaのSerializableとか。Protocol Buffersは使ったことなくわからないです。。
ところで雲行きが怪しい。
BLEだけでサンプル書いてほしかった、な
Bluetooth de bajo consumo | Connectivity | Android Developers
https://developer.android.com/develop/connectivity/bluetooth/ble/ble-overview?hl=es-419
APIを触るのにこの辺知っておかないとなので・・!ざっと
BLEの接続を待ち受ける側です。
IoTだとセンサー側です。スマホ側じゃないです。GATT サーバー機能とアドバタイズ機能を乗せます(後述)BLEで接続する側です。
GATTBLEで繋いだあとデータを送受信するための仕組み?GATT サーバーを搭載させますCharacteristicGATT サーバーの中にあるもので、実際にデータを送り返したり、あるいは書き込んだりする窓口みたいなやつtoByteArray()でバイト配列にします。GATT サーバーを用意するだけじゃ動かないUUIDBLEの世界ではサービスとキャラクタリスティックの識別にUUIDを使っています。UUIDは基本被らないはずなので、自分でUUIDを作ってBLEで使って良いはず?(よくわからず)GATT サーバーへ接続を試みるGATT サーバーと接続するとサービス一覧が取れる、ので狙ったサービスを探して、キャラクタリスティックを読み出したり書き込んだりする| なまえ | あたい |
|---|---|
| Android Studio | Android Studio Ladybug 2024.2.1 Patch 2 |
端末 (ペリフェラル / セントラル確認のため2台以上必要) | Pixel 8 Pro(15) / Xperia 1 V(14) / Pixel 6 Pro(14) / Pixel 3 XL(12) / Xiaomi Mi 11 Lite 5G(11) / OnePlus 7T Pro(11) / Xperia XZ1 Compact(9) / Xperia Z3 Compact(5) |
| minSdkVersion | 21 ? |
| そのほか | Jetpack Compose + Navigation Compose、Kotlin Coroutines |
UIにはJetpack Composeを使おうと思います。ペリフェラル、セントラル画面を作るためのナビゲーションも!
あとコールバックが相変わらずしんどいのでCoroutinesも
手持ちの端末の中でまともに動くやつ(重たすぎるやつを除いて)を総動員させた本記事。
Jetpack Composeのテンプレで
ペリフェラル、セントラル両方を1つのアプリでやるので権限が多い
多分Android 11以下で動かしたい場合はandroid.permission.BLUETOOTH_ADMINも必要です。
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Bluetooth Low Energy -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- Android 11 以下 -->
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- 以下省略 -->画面遷移させたいのでnavigation composeを入れます。
dependencies {
implementation("androidx.navigation:navigation-compose:2.8.3")
// 以下省略とりあえずペリフェラル、セントラルの各画面と、切り替え画面を作ります。
HomeScreen.kt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
onPeripheralClick: () -> Unit,
onCentralClick: () -> Unit
) {
Scaffold(
topBar = { TopAppBar(title = { Text(text = "BLE ホーム画面") }) }
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(5.dp)
) {
Button(onClick = onPeripheralClick) {
Text(text = "ペリフェラル側になる")
}
Button(onClick = onCentralClick) {
Text(text = "セントラル側になる")
}
}
}
}PeripheralScreen.kt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PeripheralScreen() {
Scaffold(
topBar = { TopAppBar(title = { Text(text = "BLE ペリフェラル") }) }
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(5.dp)
) {
}
}
}CentralScreen.kt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CentralScreen() {
Scaffold(
topBar = { TopAppBar(title = { Text(text = "BLE セントラル") }) }
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(5.dp)
) {
}
}
}MainActivityrememberNavController()がエラーになる場合はちゃんとライブラリ(navigation compose)が入ってない可能性があります。
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
AndroidBlePeripheralCentralSampleTheme {
MainScreen()
}
}
}
}
@Composable
fun MainScreen() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home") {
composable("home") {
HomeScreen(
onPeripheralClick = { navController.navigate("peripheral") },
onCentralClick = { navController.navigate("central") }
)
}
composable("peripheral") {
PeripheralScreen()
}
composable("central") {
CentralScreen()
}
}
}最初に表示される画面で必要な権限をリクエストすることにします、付与されていない場合は遷移すらさせない作戦。
バージョンによって必要な権限が違うのがあれ。
あとコードではやってないのですが(じゃあやれ)、Bluetoothが有効になっているか、BLEが利用できるかもこのタイミングでやる必要があると思います。
/** 必要な権限たち */
private val PERMISSION_LIST = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(
android.Manifest.permission.BLUETOOTH,
android.Manifest.permission.BLUETOOTH_CONNECT,
android.Manifest.permission.BLUETOOTH_SCAN,
android.Manifest.permission.BLUETOOTH_ADVERTISE,
android.Manifest.permission.ACCESS_COARSE_LOCATION,
android.Manifest.permission.ACCESS_FINE_LOCATION
)
} else {
listOf(
android.Manifest.permission.BLUETOOTH,
android.Manifest.permission.BLUETOOTH_ADMIN,
android.Manifest.permission.ACCESS_COARSE_LOCATION,
android.Manifest.permission.ACCESS_FINE_LOCATION
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
onPeripheralClick: () -> Unit,
onCentralClick: () -> Unit
) {
val context = LocalContext.current
// 権限を求めるまでボタンを出さない
val isPermissionAllGranted = remember {
mutableStateOf(PERMISSION_LIST.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED })
}
// リクエストするやつ
val permissionRequest = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(),
onResult = { isPermissionAllGranted.value = it.all { it.value } }
)
// 権限をリクエスト
LaunchedEffect(key1 = Unit) {
permissionRequest.launch(PERMISSION_LIST.toTypedArray())
}
Scaffold(
topBar = { TopAppBar(title = { Text(text = "BLE ホーム画面") }) }
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(5.dp)
) {
// 権限がなければ
if (!isPermissionAllGranted.value) {
Text(text = "権限が付与されていません")
return@Scaffold
}
Button(onClick = onPeripheralClick) {
Text(text = "ペリフェラル側になる")
}
Button(onClick = onCentralClick) {
Text(text = "セントラル側になる")
}
}
}
}さて、次はBLEのサービスとキャラクタリスティックに割り当てるUUIDを決めます。
多分自分で作ったものを使えば良いはずです。被んないはずだし。
適当に2つ作ります。多分なにで作っても良いんですが、今回はKotlin Playgroundとかいうブラウザから試せるKotlin環境で作ります。
特に理由はないですが、最近のKotlinにUUID生成機能が入ったそうなので。いままではJVM 環境ならJavaのがあったけどそれ以外(Kotlin/JSとか)で動かしたい場合はまた考えないといけなかったので。マルチプラットフォームだ!
Kotlin Playgroundでこれを貼り付ければ良いはず。
Kotlin Playground: Edit, Run, Share Kotlin Code Online
https://play.kotlinlang.org/
import kotlin.uuid.Uuid
/**
* You can edit, run, and share this code.
* play.kotlinlang.org
*/
fun main() {
println(Uuid.random())
println(Uuid.random())
}2つの値が出てくれば良いはず。
上をサービスの UUID、下をキャラクタリスティックの UUIDにします。
a1bf5691-1851-4d0c-bddd-cd5c9f516595
03f06708-4119-4841-893e-4de78b22c3d4というわけでUUIDを定義しておきましょう。
/** BLE UUID 定数 */
object BleUuid {
/** GATT サーバー サービスの UUID */
val BLE_UUID_SERVICE = UUID.fromString("a1bf5691-1851-4d0c-bddd-cd5c9f516595")
/** GATT サーバー キャラクタリスティックの UUID */
val BLE_UUID_CHARACTERISTIC = UUID.fromString("03f06708-4119-4841-893e-4de78b22c3d4")
}GATT サーバーとアドバタイズ機能を持つあれです。
ペリフェラル側を担当するクラスを作ります。BlePeripheralManagerみたいな。
まだ埋まってないところはこれから書きます
/**
* BLE ペリフェラル側の処理をまとめたクラス
*
* @param onCharacteristicReadRequest キャラクタリスティックへ read が要求された(送り返す)
* @param onCharacteristicWriteRequest キャラクタリスティックへ write が要求された(受信)
*/
class BlePeripheralManager(
private val context: Context,
private val onCharacteristicReadRequest: () -> ByteArray,
private val onCharacteristicWriteRequest: (ByteArray) -> Unit
) {
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private val bluetoothLeAdvertiser = bluetoothManager.adapter.bluetoothLeAdvertiser
/** アドバタイジングのコールバック */
private val advertiseCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) {
super.onStartSuccess(settingsInEffect)
}
override fun onStartFailure(errorCode: Int) {
super.onStartFailure(errorCode)
}
}
/** GATT サーバー */
private var bleGattServer: BluetoothGattServer? = null
private val _connectedDeviceList = MutableStateFlow(emptyList<BluetoothDevice>())
/** 接続中端末の配列 */
val connectedDeviceList = _connectedDeviceList.asStateFlow()
/** GATT サーバーとアドバタイジングするやつを開始する */
fun start() {
startGattServer()
startAdvertising()
}
/** 終了する */
fun destroy() {
// このあとすぐ
}
private fun startGattServer() {
// このあとすぐ
}
private fun startAdvertising() {
// このあとすぐ
}
}startGattServer()を実装します。
キャラクタリスティックへ読み込み、書き込みが要求されたら呼ばれるコールバックを作り、サービス、キャラクタリスティックを登録すれば良いはず。
また、onConnectionStateChange()なんかのコールバックを使えばいま接続中のデバイスの情報が取れたりします。接続中の端末数を表示させたい場合はこれ。
onCharacteristicReadRequestに関しては、多分一度には送り切れないのか、前回受信した位置までがoffsetに入っているので何らかの方法で指定バイト数をスキップする必要があります。
今回はByteArrayInputStreamを使いました、指定バイト数をスキップするためだけに使いました。多分オーバースペックな気がします。
キャラクタリスティックのread / writeする値はこのクラスのコンストラクタ引数にあるonCharacteristicReadRequest / onCharacteristicWriteRequest関数を取って、外から好きな用に渡せるようにしました。
権限チェックは無視しました、多分この画面に来る前に権限を付与してくれると思うので、、
@SuppressLint("MissingPermission") // TODO 権限チェックをする
private fun startGattServer() {
bleGattServer = bluetoothManager.openGattServer(context, object : BluetoothGattServerCallback() {
// セントラル側デバイスと接続したら
// UI に表示するため StateFlow で通知する
override fun onConnectionStateChange(device: BluetoothDevice?, status: Int, newState: Int) {
super.onConnectionStateChange(device, status, newState)
device ?: return
when (newState) {
BluetoothProfile.STATE_DISCONNECTED -> _connectedDeviceList.value -= _connectedDeviceList.value.first { it.address == device.address }
BluetoothProfile.STATE_CONNECTED -> _connectedDeviceList.value += device
}
}
// readCharacteristic が要求されたら呼ばれる
// セントラルへ送信する
override fun onCharacteristicReadRequest(device: BluetoothDevice?, requestId: Int, offset: Int, characteristic: BluetoothGattCharacteristic?) {
super.onCharacteristicReadRequest(device, requestId, offset, characteristic)
val sendByteArray = onCharacteristicReadRequest()
// オフセットを考慮する
// TODO バイト数スキップするのが面倒で ByteArrayInputStream 使ってるけど多分オーバースペック
val sendOffsetByteArray = sendByteArray.inputStream().apply { skip(offset.toLong()) }.readBytes()
bleGattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, sendOffsetByteArray)
}
// writeCharacteristic が要求されたら呼ばれる
// セントラルから受信する
override fun onCharacteristicWriteRequest(device: BluetoothDevice?, requestId: Int, characteristic: BluetoothGattCharacteristic?, preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray?) {
super.onCharacteristicWriteRequest(device, requestId, characteristic, preparedWrite, responseNeeded, offset, value)
value ?: return
onCharacteristicWriteRequest(value)
bleGattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null)
}
})
//サービスとキャラクタリスティックを作る
val gattService = BluetoothGattService(BleUuid.BLE_UUID_SERVICE, BluetoothGattService.SERVICE_TYPE_PRIMARY)
val gattCharacteristics = BluetoothGattCharacteristic(
BleUuid.BLE_UUID_CHARACTERISTIC,
BluetoothGattCharacteristic.PROPERTY_READ or BluetoothGattCharacteristic.PROPERTY_WRITE,
BluetoothGattCharacteristic.PERMISSION_READ or BluetoothGattCharacteristic.PERMISSION_WRITE
)
// サービスにキャラクタリスティックを入れる
gattService.addCharacteristic(gattCharacteristics)
// GATT サーバーにサービスを追加
bleGattServer?.addService(gattService)
}次はペリフェラル側にGATT サーバーがあるということを報知するやつです。
今回も今回とて権限チェックは無視しました、多分この画面に来る前に権限を付与してくれると思うので、、
@SuppressLint("MissingPermission") // TODO 権限チェック
private fun startAdvertising() {
// アドバタイジング。これがないと見つけてもらえない
val advertiseSettings = AdvertiseSettings.Builder().apply {
setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER)
setTimeout(0)
}.build()
val advertiseData = AdvertiseData.Builder().apply {
addServiceUuid(ParcelUuid(BleUuid.BLE_UUID_SERVICE))
}.build()
// アドバタイジング開始
bluetoothLeAdvertiser.startAdvertising(advertiseSettings, advertiseData, advertiseCallback)
}GATT サーバー、アドバタイジングを終了させる処理です。
/** 終了する */
@SuppressLint("MissingPermission")
fun destroy() {
bleGattServer?.close()
bluetoothLeAdvertiser.stopAdvertising(advertiseCallback)
}/**
* BLE ペリフェラル側の処理をまとめたクラス
*
* @param onCharacteristicReadRequest キャラクタリスティックへ read が要求された(送り返す)
* @param onCharacteristicWriteRequest キャラクタリスティックへ write が要求された(受信)
*/
class BlePeripheralManager(
private val context: Context,
private val onCharacteristicReadRequest: () -> ByteArray,
private val onCharacteristicWriteRequest: (ByteArray) -> Unit
) {
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private val bluetoothLeAdvertiser = bluetoothManager.adapter.bluetoothLeAdvertiser
/** アドバタイジングのコールバック */
private val advertiseCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) {
super.onStartSuccess(settingsInEffect)
}
override fun onStartFailure(errorCode: Int) {
super.onStartFailure(errorCode)
}
}
/** GATT サーバー */
private var bleGattServer: BluetoothGattServer? = null
private val _connectedDeviceList = MutableStateFlow(emptyList<BluetoothDevice>())
/** 接続中端末の配列 */
val connectedDeviceList = _connectedDeviceList.asStateFlow()
/** GATT サーバーとアドバタイジングするやつを開始する */
fun start() {
startGattServer()
startAdvertising()
}
/** 終了する */
@SuppressLint("MissingPermission")
fun destroy() {
bleGattServer?.close()
bluetoothLeAdvertiser.stopAdvertising(advertiseCallback)
}
@SuppressLint("MissingPermission") // TODO 権限チェックをする
private fun startGattServer() {
bleGattServer = bluetoothManager.openGattServer(context, object : BluetoothGattServerCallback() {
// セントラル側デバイスと接続したら
// UI に表示するため StateFlow で通知する
override fun onConnectionStateChange(device: BluetoothDevice?, status: Int, newState: Int) {
super.onConnectionStateChange(device, status, newState)
device ?: return
when (newState) {
BluetoothProfile.STATE_DISCONNECTED -> _connectedDeviceList.value -= _connectedDeviceList.value.first { it.address == device.address }
BluetoothProfile.STATE_CONNECTED -> _connectedDeviceList.value += device
}
}
// readCharacteristic が要求されたら呼ばれる
// セントラルへ送信する
override fun onCharacteristicReadRequest(device: BluetoothDevice?, requestId: Int, offset: Int, characteristic: BluetoothGattCharacteristic?) {
super.onCharacteristicReadRequest(device, requestId, offset, characteristic)
val sendByteArray = onCharacteristicReadRequest()
// オフセットを考慮する
// TODO バイト数スキップするのが面倒で ByteArrayInputStream 使ってるけど多分オーバースペック
val sendOffsetByteArray = sendByteArray.inputStream().apply { skip(offset.toLong()) }.readBytes()
bleGattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, sendOffsetByteArray)
}
// writeCharacteristic が要求されたら呼ばれる
// セントラルから受信する
override fun onCharacteristicWriteRequest(device: BluetoothDevice?, requestId: Int, characteristic: BluetoothGattCharacteristic?, preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray?) {
super.onCharacteristicWriteRequest(device, requestId, characteristic, preparedWrite, responseNeeded, offset, value)
value ?: return
onCharacteristicWriteRequest(value)
bleGattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null)
}
})
//サービスとキャラクタリスティックを作る
val gattService = BluetoothGattService(BleUuid.BLE_UUID_SERVICE, BluetoothGattService.SERVICE_TYPE_PRIMARY)
val gattCharacteristics = BluetoothGattCharacteristic(
BleUuid.BLE_UUID_CHARACTERISTIC,
BluetoothGattCharacteristic.PROPERTY_READ or BluetoothGattCharacteristic.PROPERTY_WRITE,
BluetoothGattCharacteristic.PERMISSION_READ or BluetoothGattCharacteristic.PERMISSION_WRITE
)
// サービスにキャラクタリスティックを入れる
gattService.addCharacteristic(gattCharacteristics)
// GATT サーバーにサービスを追加
bleGattServer?.addService(gattService)
}
@SuppressLint("MissingPermission") // TODO 権限チェック
private fun startAdvertising() {
// アドバタイジング。これがないと見つけてもらえない
val advertiseSettings = AdvertiseSettings.Builder().apply {
setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER)
setTimeout(0)
}.build()
val advertiseData = AdvertiseData.Builder().apply {
addServiceUuid(ParcelUuid(BleUuid.BLE_UUID_SERVICE))
}.build()
// アドバタイジング開始
bluetoothLeAdvertiser.startAdvertising(advertiseSettings, advertiseData, advertiseCallback)
}
}さっき作ったBlePeripheralManagerをインスタンス化し、Jetpack Composeで適当にUIを作ります。
キャラクタリスティックのreadで送り返す値を入力するテキストフィールドと、writeで送られてきた値を表示するText()。
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PeripheralScreen() {
val context = LocalContext.current
val readRequestText = remember { mutableStateOf(Build.MODEL) }
val writeRequestList = remember { mutableStateOf(emptyList<String>()) }
val peripheralManager = remember {
BlePeripheralManager(
context = context,
onCharacteristicReadRequest = { readRequestText.value.toByteArray(Charsets.UTF_8) },
onCharacteristicWriteRequest = { writeRequestList.value += it.toString(Charsets.UTF_8) }
)
}
val connectedDeviceList = peripheralManager.connectedDeviceList.collectAsState()
// 開始・終了処理
DisposableEffect(key1 = Unit) {
peripheralManager.start()
onDispose { peripheralManager.destroy() }
}
Scaffold(
topBar = { TopAppBar(title = { Text(text = "BLE ペリフェラル") }) }
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(5.dp)
) {
Text(
text = "キャラクタリスティック read で送り返す値",
fontSize = 20.sp
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = readRequestText.value,
onValueChange = { readRequestText.value = it },
singleLine = true
)
HorizontalDivider()
Text(
text = "接続中デバイス数:${connectedDeviceList.value.size}",
fontSize = 20.sp
)
HorizontalDivider()
Text(
text = "キャラクタリスティック write で受信した値",
fontSize = 20.sp
)
writeRequestList.value.forEach { writeText ->
Text(text = writeText)
}
}
}
}多分こっちのがコールバック地獄でしんどい気がする。コルーチンで幸せになろう。
どうしてクライアント側のがつらいんですか?(ここに電話猫の画像を貼る)
まずはクラスを作ります。空の関数はこのあとすぐ実装していきます。
/** BLE デバイスへ接続し GATT サーバーへ接続しサービスを探しキャラクタリスティックを操作する */
class BleCentralManager(private val context: Context) {
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
/** [readCharacteristic]等で使いたいので */
private val _bluetoothGatt = MutableStateFlow<BluetoothGatt?>(null)
/** コールバックの返り値をコルーチン側から受け取りたいので */
private val _characteristicReadChannel = Channel<ByteArray>()
/** 接続中かどうか */
val isConnected = _bluetoothGatt.map { it != null }
suspend fun connect(){
// このあとすぐ
}
suspend fun readCharacteristic(): ByteArray {
// このあとすぐ
}
suspend fun writeCharacteristic(sendData: ByteArray) {
// このあとすぐ
}
fun destroy() {
// このあとすぐ
}
}まずはデバイスを探す処理です。GATT サーバーのUUIDを指定してすることでアドバタイジングしてるやつが引っかかり、コールバックが呼ばれます。
コールバックしんどいのでコルーチンでいい感じに同期っぽく書きます。ちなみにIoT端末相手の場合はUUID指定よりもMACアドレス指定を使ってそう?
suspend fun connect() {
// GATT サーバーのサービスを元に探す
val bleDevice = findBleDevice() ?: return
}
@SuppressLint("MissingPermission")
private suspend fun findBleDevice() = suspendCancellableCoroutine { continuation ->
val bluetoothLeScanner = bluetoothManager.adapter.bluetoothLeScanner
val bleScanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult?) {
super.onScanResult(callbackType, result)
// 見つけたら返して、スキャンも終了させる
continuation.resume(result?.device)
bluetoothLeScanner.stopScan(this)
}
override fun onScanFailed(errorCode: Int) {
super.onScanFailed(errorCode)
continuation.resume(null)
}
}
// GATT サーバーのサービス UUID を指定して検索を始める
val scanFilter = ScanFilter.Builder().apply {
setServiceUuid(ParcelUuid(BleUuid.BLE_UUID_SERVICE))
}.build()
bluetoothLeScanner.startScan(
listOf(scanFilter),
ScanSettings.Builder().build(),
bleScanCallback
)
continuation.invokeOnCancellation {
bluetoothLeScanner.stopScan(bleScanCallback)
}
}このあたりから辛くなってくるらしい。
うまくいくとこれで動くらしい。まずはconnectGatt()を呼び出してGATT サーバーへ接続したあと、onConnectionStateChange()コールバックを待ちます。
このコールバックで接続に成功したことが分かれば、discoverServices()を呼び出しサービスを探します。
サービスが見つかると、onServicesDiscovered()コールバックが呼ばれるので、ようやくキャラクタリスティックへ操作ができるようになります。
で、で、で、_bluetoothGatt、なんでMutableStateFlowにBluetoothGattを入れているのか?という話はこの後します。_characteristicReadChannelもそうです。
@SuppressLint("MissingPermission")
suspend fun connect() {
// GATT サーバーのサービスを元に探す
val bleDevice = findBleDevice() ?: return
// GATT サーバーへ接続する
bleDevice.connectGatt(context, false, object : BluetoothGattCallback() {
// 接続できたらサービスを探す
override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
super.onConnectionStateChange(gatt, status, newState)
when (newState) {
// 接続できたらサービスを探す
BluetoothProfile.STATE_CONNECTED -> gatt?.discoverServices()
// なくなった
BluetoothProfile.STATE_DISCONNECTED -> _bluetoothGatt.value = null
}
}
// discoverServices() でサービスが見つかった
override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
super.onServicesDiscovered(gatt, status)
// Flow に BluetoothGatt を入れる
_bluetoothGatt.value = gatt
}
// onCharacteristicReadRequest で送られてきたデータを受け取る
override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray, status: Int) {
super.onCharacteristicRead(gatt, characteristic, value, status)
_characteristicReadChannel.trySend(value)
}
// Android 12 ?以前はこっちを実装する必要あり
override fun onCharacteristicRead(gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?, status: Int) {
super.onCharacteristicRead(gatt, characteristic, status)
_characteristicReadChannel.trySend(characteristic?.value ?: byteArrayOf())
}
})
}ペリフェラル側から値を読み出す処理を書きます。
で、なんで一部の値をFlowで扱っているかというと、接続し終わった後にreadするとかいう制御が面倒そう。
かわりに、MutableStateFlowに値が入ってくるまで待つような処理にすれば、まだ接続が成功していなくても、この関数の呼び出しが出来るようになります。
また、readの結果をconnectGatt()のコールバックから受け取る必要があるんだけど、できればこの関数の返り値としてreadの結果がほしい。
そこで、CoroutinesのChannel()を使い、サスペンド関数を超えて値の送受信が出来るようにして、コールバックの値をこの関数のreturnで返せるようにしました。
(コールバック側はサスペンド関数じゃないのでサスペンド関数間ではないんですがまあ)
Channelってこうやって使うんかなあ、、ってのと多分説明下手で伝わってない。
@SuppressLint("MissingPermission")
suspend fun readCharacteristic(): ByteArray {
// GATT サーバーとの接続を待つ
// Flow に値が入ってくるまで(onServicesDiscovered() で入れている)一時停止する。コルーチン便利
val gatt = _bluetoothGatt.filterNotNull().first()
// GATT サーバーへ狙ったサービス内にあるキャラクタリスティックへ read を試みる
val findService = gatt.services?.first { it.uuid == BleUuid.BLE_UUID_SERVICE }
val findCharacteristic = findService?.characteristics?.first { it.uuid == BleUuid.BLE_UUID_CHARACTERISTIC }
// 結果は onCharacteristicRead で
gatt.readCharacteristic(findCharacteristic)
return _characteristicReadChannel.receive()
}こっちも同様にFlowで接続待ちをするようにします。Android 12以前でも使いたい場合は分岐して古い方を使う必要があります。
@SuppressLint("MissingPermission")
suspend fun writeCharacteristic(sendData: ByteArray) {
// GATT サーバーとの接続を待つ
val gatt = _bluetoothGatt.filterNotNull().first()
// GATT サーバーへ狙ったサービス内にあるキャラクタリスティックへ write を試みる
val findService = gatt.services?.first { it.uuid == BleUuid.BLE_UUID_SERVICE } ?: return
val findCharacteristic = findService.characteristics?.first { it.uuid == BleUuid.BLE_UUID_CHARACTERISTIC } ?: return
// 結果は onCharacteristicWriteRequest で
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeCharacteristic(findCharacteristic, sendData, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT)
} else {
findCharacteristic.setValue(sendData)
gatt.writeCharacteristic(findCharacteristic)
}
}使い終わったときに呼び出す処理です。
@SuppressLint("MissingPermission")
fun destroy() {
_bluetoothGatt.value?.close()
}/** BLE デバイスへ接続し GATT サーバーへ接続しサービスを探しキャラクタリスティックを操作する */
class BleCentralManager(private val context: Context) {
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
/** [readCharacteristic]等で使いたいので */
private val _bluetoothGatt = MutableStateFlow<BluetoothGatt?>(null)
/** コールバックの返り値をコルーチン側から受け取りたいので */
private val _characteristicReadChannel = Channel<ByteArray>()
/** 接続中かどうか */
val isConnected = _bluetoothGatt.map { it != null }
/** デバイスを探し、GATT サーバーへ接続する */
@SuppressLint("MissingPermission")
suspend fun connect() {
// GATT サーバーのサービスを元に探す
val bleDevice = findBleDevice() ?: return
// GATT サーバーへ接続する
bleDevice.connectGatt(context, false, object : BluetoothGattCallback() {
// 接続できたらサービスを探す
override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
super.onConnectionStateChange(gatt, status, newState)
when (newState) {
// 接続できたらサービスを探す
BluetoothProfile.STATE_CONNECTED -> gatt?.discoverServices()
// なくなった
BluetoothProfile.STATE_DISCONNECTED -> _bluetoothGatt.value = null
}
}
// discoverServices() でサービスが見つかった
override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
super.onServicesDiscovered(gatt, status)
// Flow に BluetoothGatt を入れる
_bluetoothGatt.value = gatt
}
// onCharacteristicReadRequest で送られてきたデータを受け取る
override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray, status: Int) {
super.onCharacteristicRead(gatt, characteristic, value, status)
_characteristicReadChannel.trySend(value)
}
// Android 12 ?以前はこっちを実装する必要あり
override fun onCharacteristicRead(gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?, status: Int) {
super.onCharacteristicRead(gatt, characteristic, status)
_characteristicReadChannel.trySend(characteristic?.value ?: byteArrayOf())
}
})
}
/** キャラクタリスティックへ read する */
@SuppressLint("MissingPermission")
suspend fun readCharacteristic(): ByteArray {
// GATT サーバーとの接続を待つ
// Flow に値が入ってくるまで(onServicesDiscovered() で入れている)一時停止する。コルーチン便利
val gatt = _bluetoothGatt.filterNotNull().first()
// GATT サーバーへ狙ったサービス内にあるキャラクタリスティックへ read を試みる
val findService = gatt.services?.first { it.uuid == BleUuid.BLE_UUID_SERVICE }
val findCharacteristic = findService?.characteristics?.first { it.uuid == BleUuid.BLE_UUID_CHARACTERISTIC }
// 結果は onCharacteristicRead で
gatt.readCharacteristic(findCharacteristic)
return _characteristicReadChannel.receive()
}
/** キャラクタリスティックへ write する */
@SuppressLint("MissingPermission")
suspend fun writeCharacteristic(sendData: ByteArray) {
// GATT サーバーとの接続を待つ
val gatt = _bluetoothGatt.filterNotNull().first()
// GATT サーバーへ狙ったサービス内にあるキャラクタリスティックへ write を試みる
val findService = gatt.services?.first { it.uuid == BleUuid.BLE_UUID_SERVICE } ?: return
val findCharacteristic = findService.characteristics?.first { it.uuid == BleUuid.BLE_UUID_CHARACTERISTIC } ?: return
// 結果は onCharacteristicWriteRequest で
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeCharacteristic(findCharacteristic, sendData, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT)
} else {
findCharacteristic.setValue(sendData)
gatt.writeCharacteristic(findCharacteristic)
}
}
/** 終了する */
@SuppressLint("MissingPermission")
fun destroy() {
_bluetoothGatt.value?.close()
}
@SuppressLint("MissingPermission")
private suspend fun findBleDevice() = suspendCancellableCoroutine { continuation ->
val bluetoothLeScanner = bluetoothManager.adapter.bluetoothLeScanner
val bleScanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult?) {
super.onScanResult(callbackType, result)
// 見つけたら返して、スキャンも終了させる
continuation.resume(result?.device)
bluetoothLeScanner.stopScan(this)
}
override fun onScanFailed(errorCode: Int) {
super.onScanFailed(errorCode)
continuation.resume(null)
}
}
// GATT サーバーのサービス UUID を指定して検索を始める
val scanFilter = ScanFilter.Builder().apply {
setServiceUuid(ParcelUuid(BleUuid.BLE_UUID_SERVICE))
}.build()
bluetoothLeScanner.startScan(
listOf(scanFilter),
ScanSettings.Builder().build(),
bleScanCallback
)
continuation.invokeOnCancellation {
bluetoothLeScanner.stopScan(bleScanCallback)
}
}
}ペリフェラル側同様、インスタンス化し、キャラクタリスティックへreadするボタンとwriteするテキストフィールドと送信ボタンを置きます。
ペリフェラル側と違って明示的にread / writeボタンを置く必要があります。
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CentralScreen() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val centralManager = remember { BleCentralManager(context) }
val isConnected = centralManager.isConnected.collectAsState(initial = false)
val writeRequestText = remember { mutableStateOf(Build.MODEL) }
val readRequestList = remember { mutableStateOf(emptyList<String>()) }
DisposableEffect(key1 = Unit) {
scope.launch { centralManager.connect() }
onDispose { centralManager.destroy() }
}
Scaffold(
topBar = { TopAppBar(title = { Text(text = "BLE セントラル") }) }
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(5.dp)
) {
// 接続中ならくるくる
if (!isConnected.value) {
CircularProgressIndicator()
Text(text = "接続中です")
return@Scaffold
}
Text(
text = "キャラクタリスティック write で送信する値",
fontSize = 20.sp
)
Row {
OutlinedTextField(
modifier = Modifier.weight(1f),
value = writeRequestText.value,
onValueChange = { writeRequestText.value = it },
singleLine = true
)
Button(
onClick = {
scope.launch {
centralManager.writeCharacteristic(writeRequestText.value.toByteArray(Charsets.UTF_8))
}
}
) {
Text(text = "送信")
}
}
HorizontalDivider()
Text(
text = "キャラクタリスティック read で読み出した値",
fontSize = 20.sp
)
Button(onClick = {
scope.launch {
readRequestList.value += centralManager.readCharacteristic().toString(Charsets.UTF_8)
}
}) {
Text(text = "読み出す")
}
readRequestList.value.forEach { readText ->
Text(text = readText)
}
}
}
}1台をペリフェラル、もう1台をセントラルにして、接続できるまで待ちます。(デバイス数が増えていること、くるくるが消えていること)
接続すると、セントラル側からペリフェラルの値をreadしたり、ペリフェラル側にwrite出来るようになるはずです。
動いたかな。
手持ちのまともに動く端末を総動員させた画像がこれです。
1台のペリフェラルのキャラクタリスティック対してread / write出来てそうです。
冒頭で話した通り、今回は文字列をread / writeしているわけですが別に文字列に限ったことはなく、バイト配列にエンコードデコードできる場合はやり取りできるはずです、BLEなので小さいデータしか送れないとは思いますが。
どうぞ
なぜか誰も接続していないのに謎に 1 台接続済みの表示になってしまった。コードがミスってる可能性もある。Bluetoothのオンオフを試すと直るけどかなり最終手段。
ペリフェラル、セントラル両方ともAndroidだったのでつらみはあんまりなかった?(APIがコールバックの連続だってのはしんどい)
それよりも古いAndroidバージョンに対応させたい場合は多分非推奨の方を使う必要がある。
以上でつ。8888888