📡

Android で自転車のパワーメーターと BLE 接続する

2022/10/30に公開

自転車(ロードバイク)で利用されているパワーメーター(どれだけ力強くペダルを踏んでいるか)と BLE 接続するAndroidアプリを作ろうとしたときのメモです。
Androidアプリを作らずとも市販のサイクルコンピューターはパワーメーターの測定値を描画してくれる機能があるのですが、手持ちのサイクルコンピューターは古く、一部描画機能が対応していないので作ってみようと思いました(描画したいデータを所持しているパワーメーターでは測定できないことが判明しましたが…)。

  • デバッグ方法
  • BLE ドキュメントの追い方
  • Android での実装の仕方

について私が調べて分かったことをまとめる備忘録的記事です。
BLE の詳細仕様については言及せず、 Android が用意しているライブラリを使った実装者としての知見をまとめます。
誤っていることやもっと効率的なやり方を知っていましたら教えていただけると嬉しいです。

デバッグ方法

BLE は普段のアプリ開発のようにデバッグするのが難しいです。
実際にどのようなデータが行き交っているかを把握できると実装が正しくできているか分かりそうです。
GooglePlayStore や AppStore には端末と BLE 接続し、端末が提供しているデータ(Service)を表示できるアプリがあります。
https://play.google.com/store/apps/details?id=no.nordicsemi.android.mcp

まずはこれらのアプリをインストールし、端末と接続しどのような Service が存在するか確認してみるのが良さそうです。

下記は実際にパワーメーターを接続したときのものです。 Cycling Power なる Service が存在することが確認できました。この名前を把握した上でドキュメントを探し始めます。

BLEドキュメントの追い方

データを利用したアプリを実装するために

  • Service が提供する GATT characteristic の構造
  • Service が提供する GATT characteristic の利用、加工方法

を把握する必要があり、 Cycling Power Service はこれらが別々のドキュメントになっていました。(他の Service はまとめられているかもしれません)

GATT characteristic 構造に関するドキュメント

Service が提供する GATT characteristic のデータ構造は下記ドキュメントにまとめられています。

https://www.bluetooth.com/specifications/specs/gatt-specification-supplement-7/

私が所持しているパワーメーター(上記スクショ)の Cycling Power Service には Cycling Power FeatureCycling Power Control Point GATT characteristic が提供されている表示があります。
これらの名前でドキュメント内を検索してみると各octet(≒8bit)が表すデータが把握できるようになります。

GATT characteristic の利用、加工方法に関するドキュメント

アプリを通して端末が提供している Service 名が分かりました。この名前を公式サイトで検索してみます。
Cycling Power Profile のドキュメントが今回は見つかりました。
https://www.bluetooth.com/specifications/specs/cycling-power-profile-1-1/

ドキュメントを読んでみると Cycling Power Measurement から取得できる Crank Revolutions を利用したケイデンス(1分間のクランク回転数)の計算方法など、データの利用、加工方法がまとめられています。

私は Cycling Power Vector が提供するデータを利用してペダルが時計の何時の位置で強く踏み込めているか可視化をしたいと思っていたのですが、 パワーメーターが提供できるデータを示すCycling Power Feature をアプリを利用して取得したところ私のパワーメーターは Cycling Power Vector が使えることを示す bit は 0 になっていました…

Androidでの実装の仕方

接続する端末がどのようなデータを提供しているかが分かったらあとはアプリに実装していくだけです。
と思って Android 公式ドキュメント読みに行くと何だか古めかしい実装だなとなります…

https://developer.android.com/guide/topics/connectivity/bluetooth-le?hl=ja

せっかくなので Flow を利用してシーケンシャルに処理を行えるように実装してみたサンプルを共有します。
(Cycling Power Vectorが利用できないことが分かり最後まで実装は完了していません…)

class BleRepository(
    private val context: Context,
    private val bluetoothManager: BluetoothManager
) {

    @SuppressLint("MissingPermission")
    fun startScanWithFlow(
        timeOutMillis: Long = 5000L
    ): Flow<ScanResult> {
        return callbackFlow {
            val callback = object : ScanCallback() {
                override fun onScanResult(callbackType: Int, result: ScanResult) {
                    trySend(result)
                }

                override fun onScanFailed(errorCode: Int) {
                    channel.close()
                }
            }

            bluetoothManager.adapter.bluetoothLeScanner.startScan(callback)

            launch {
                delay(timeOutMillis)
                channel.close()
            }

            awaitClose {
                bluetoothManager.adapter.bluetoothLeScanner.stopScan(callback)
            }
        }
    }

    fun connectGatt(device: BluetoothDevice): Flow<GattConnection> {
        return callbackFlow {
            val callback = object : BluetoothGattCallback() {
                override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
                    when (newState) {
                        BluetoothProfile.STATE_CONNECTING -> {
                            trySend(GattConnection.Connecting)
                        }
                        BluetoothProfile.STATE_CONNECTED -> {
                            trySend(GattConnection.Connected)
                            gatt?.discoverServices()
                        }
                        BluetoothProfile.STATE_DISCONNECTED -> {
                            trySend(GattConnection.DisConnected)
                        }
                    }
                }

                override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
                    trySend(GattConnection.DiscoverServices(gattServices = gatt.services))
                }

                override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray, status: Int) {
                    trySend(GattConnection.ReadCharacteristic(characteristic = characteristic))
                }

                override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
                    trySend(GattConnection.ChangeCharacteristic(characteristic = characteristic))
                }
            }

            val gatt = device.connectGatt(context, true, callback)

            awaitClose {
                trySend(GattConnection.DisConnected)
                gatt.disconnect()
                gatt.close()
            }
        }
    }
}

sealed class GattConnection {
    object Connecting : GattConnection()
    object Connected : GattConnection()
    object DisConnected : GattConnection()

    data class DiscoverServices(
        val gattServices: List<BluetoothGattService>
    ) : GattConnection()

    data class ReadCharacteristic(
        val characteristic: BluetoothGattCharacteristic
    ) : GattConnection()

    data class ChangeCharacteristic(
        val characteristic: BluetoothGattCharacteristic
    ) : GattConnection()
}
class MainViewModel(
    private val bleRepository: BleRepository
) : ViewModel() {

    var uiState: MutableState<MainUiState> = mutableStateOf(MainUiState.INITIAL)

    fun startScan() {
        bleRepository.startScanWithFlow()
            .filter {
                it.scanRecord?.serviceUuids?.any { serviceUuid ->
                    serviceUuid == ParcelUuid(UUID.fromString("00001818-0000-1000-8000-00805f9b34fb"))
                } ?: false
            }.map {
                it.device
            }
            .onStart { uiState.value = uiState.value.copy(loading = true) }
            .onEach {
                uiState.value = uiState.value.copy(
                    loading = false,
                    devices = addBleDevice(it)
                )
            }.launchIn(viewModelScope)
    }

callbackFlow を利用することで処理の記述、呼び出しで行ったり来たりがなくなって可読性が良くなるかなと思います。
特定の端末UUID(今回はパワーメーターの 00001818-0000-1000-8000-00805f9b34fb) に filter する処理なんかは別途 UseCase みたいのに分けるのも良さそうです。

ほとんど初めてBLE周りの実装をしてみてドキュメントの探し方など結構迷うポイントが多かったので少しでも参考になると嬉しいです。

参考記事など

Discussion