たくさんの自由帳
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
だけでサンプル書いてほしかった、な
https://developer.android.com/develop/connectivity/bluetooth/ble/ble-overview
API
を触るのにこの辺知っておかないとなので・・!ざっと
BLE
の接続を待ち受ける側です。
IoT
だとセンサー側です。スマホ側じゃないです。GATT サーバー
機能とアドバタイズ
機能を乗せます(後述)BLE
で接続する側です。
GATT
BLE
で繋いだあとデータを送受信するための仕組み?GATT サーバー
を搭載させますCharacteristic
GATT サーバー
の中にあるもので、実際にデータを送り返したり、あるいは書き込んだりする窓口みたいなやつtoByteArray()
でバイト配列にします。GATT サーバー
を用意するだけじゃ動かないUUID
BLE
の世界ではサービス
とキャラクタリスティック
の識別に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)
) {
}
}
}
MainActivity
rememberNavController()
がエラーになる場合はちゃんとライブラリ(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
でこれを貼り付ければ良いはず。
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
なので小さいデータしか送れないとは思いますが。
どうぞ
https://github.com/takusan23/AndroidBlePeripheralCentralSample
なぜか誰も接続していないのに謎に 1 台接続済みの表示になってしまった。コードがミスってる可能性もある。
Bluetooth
のオンオフを試すと直るけどかなり最終手段。
ペリフェラル、セントラル両方ともAndroid
だったのでつらみはあんまりなかった?(API
がコールバックの連続だってのはしんどい)
それよりも古いAndroid
バージョンに対応させたい場合は多分非推奨の方を使う必要がある。
以上でつ。8888888