魔改車鑰匙實現遠程控車:(4)基於compose和經典藍牙編寫一個控制APP

語言: CN / TW / HK

我報名參加金石計劃1期挑戰——瓜分10萬獎池,這是我的第2篇文章,點擊查看活動詳情

前言

這篇文章不出意外的話應該是魔改車鑰匙系列的最後一篇了,自此我們的魔改計劃除了最後的佈線和安裝外已經全部完成了。

不過由於佈線以及安裝不屬於編程技術範圍,且我也是第一次做,就不獻醜繼續寫一篇文章了。

在前面的文章中,我們已經完成了 Arduino 控制程序的編寫,接下來就差編寫一個簡單易用的手機端控制 APP 了。

這裏我們依舊選擇使用 compose 作為 UI 框架。

編寫這個控制 APP 會涉及到安卓上的藍牙開發知識,因此我們會先簡要介紹一下如何在安卓上進行藍牙開發。

開始編寫

藍牙基礎

藍牙分為經典藍牙和低功耗藍牙(BLE)這個知識點前面的文章已經介紹過了,在我們當前的需求中,我們只需要使用經典藍牙去與 ESP32 通信,所以我們也只介紹如何使用經典藍牙。

藍牙權限

在使用之前,我們需要確保藍牙權限正確,根據官網教程添加如下權限:

```xml

```

實際使用時不用添加所有的權限,只需要根據你的需求添加需要的權限即可。

詳細可以查閲官網文檔:Bluetooth permissions

因為我們在這裏需要連接到 ESP32 所以不要忘記判斷運行時權限:

kotlin if (ActivityCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) { // xxxxx // 沒有權限 }

某些設備可能不支持經典藍牙或BLE,亦或是兩者均不支持,所以我們需要做一下檢查:

kotlin private fun PackageManager.missingSystemFeature(name: String): Boolean = !hasSystemFeature(name) // ... // Check to see if the Bluetooth classic feature is available. packageManager.takeIf { it.missingSystemFeature(PackageManager.FEATURE_BLUETOOTH) }?.also { Toast.makeText(this, "不支持經典藍牙", Toast.LENGTH_SHORT).show() finish() } // Check to see if the BLE feature is available. packageManager.takeIf { it.missingSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE) }?.also { Toast.makeText(this, "不支持BLE", Toast.LENGTH_SHORT).show() finish() }

以上代碼來自官網示例

初始化藍牙

在使用藍牙前,我們需要獲取到系統的藍牙適配器(BluetoothAdapter),後續的大多數操作都將基於這個適配器展開:

kotlin val bluetoothManager: BluetoothManager = context.getSystemService(BluetoothManager::class.java) val bluetoothAdapter = bluetoothManager.adapter

需要注意的是,獲取到的 bluetoothAdapter 可能為空,需要自己做一下判空處理。

拿到 bluetoothAdapter 後,下一步是判斷是否開啟了藍牙,如果沒有開啟則需要請求開啟:

kotlin if (bluetoothAdapter?.isEnabled == false) { val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT) }

查找藍牙設備

由於在這個項目中, ESP32 沒法實時加密配對,所以我採用的是直接手動配對好我的手機,然後就不再配對新設備,日後如果有需求,我會研究一下怎麼實時加密配對。

所以我們這裏暫時不需要搜索新的藍牙設備,只需要查詢已經連接的設備即可:

```kotlin fun queryPairDevices(): Set? { if (bluetoothAdapter == null) { Log.e(TAG, "queryPairDevices: bluetoothAdapter is null!") return null }

val pairedDevices: Set<BluetoothDevice>? = bluetoothAdapter!!.bondedDevices

pairedDevices?.forEach { device ->
    val deviceName = device.name
    val deviceHardwareAddress = device.address

    Log.i(TAG, "queryPairDevices: deveice name=$deviceName, mac=$deviceHardwareAddress")
}

return pairedDevices

} ```

連接到指定設備

連接藍牙設備有兩種角色:服務端和客户端,在我們這裏的使用場景中,我們的 APP 是客户端,而 ESP32 是服務端,所以我們需要實現的是客户端連接。

因為這裏我們連接的是已配對設備,所以相對來説簡單的多,不需要做額外的處理,直接連接即可,連接後會拿到一個 BluetoothSocket ,後續的通信將用到這個 BluetoothSocket

```kotlin suspend fun connectDevice(device: BluetoothDevice, onConnected : (socket: Result) -> Unit) { val mmSocket: BluetoothSocket? by lazy(LazyThreadSafetyMode.NONE) { device.createRfcommSocketToServiceRecord(UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")) }

    withContext(Dispatchers.IO) {

        kotlin.runCatching {
            // 開始連接前應該關閉掃描,否則會減慢連接速度
            bluetoothAdapter?.cancelDiscovery()

            mmSocket?.connect()
        }.fold({
            withContext(Dispatchers.Main) {
                socket = mmSocket
                onConnected(Result.success(mmSocket!!))
            }
        }, {
            withContext(Dispatchers.Main) {
                onConnected(Result.failure(it))
            }
            Log.e(TAG, "connectDevice: connect fail!", it)
        })
    }
}

```

需要注意的一點是,UUID需要和 ESP32 設置的 UUID 一致,這裏我的 ESP32 並沒有設置什麼特殊的 UUID, 所以我們在 APP 中使用的是常用的 UUID:

Hint: If you are connecting to a Bluetooth serial board then try using the well-known SPP UUID 00001101-0000-1000-8000-00805F9B34FB. However if you are connecting to an Android peer then please generate your own unique UUID.

另外,其實從名字 Socket 就能看出,這是個耗時操作,所以我們將其放到協程中,並使用工作線程執行 withContext(Dispatchers.IO)

對了,上面的代碼中,我加了一句 bluetoothAdapter?.cancelDiscovery() 其實這行代碼在這裏純屬多餘,因為我壓根沒有搜索設備的操作,但是為了避免我以後新增搜索設備後忘記加上,所以我沒有給它刪掉。

最後,我這裏使用了一個匿名函數回調連接結果 onConnected : (socket: Result<BluetoothSocket>) -> Unit

數據通信

數據通信需要使用上一節拿到的 BluetoothSocket ,通過 read BluetoothSocketInputStream 從服務端讀取數據;write BluetoothSocketOutputStream 往服務端寫入數據:

```kotlin suspend fun startBtReceiveServer(mmSocket: BluetoothSocket, onReceive: (numBytes: Int, byteBufferArray: ByteArray) -> Unit) { keepReceive = true val mmInStream: InputStream = mmSocket.inputStream val mmBuffer = ByteArray(1024) // 緩衝區大小

withContext(Dispatchers.IO) {
    var numBytes = 0 // 實際讀取的數據大小
    while (true) {

        kotlin.runCatching {
            mmInStream.read(mmBuffer)
        }.fold(
            {
                numBytes = it
            },
            {
                Log.e(TAG, "Input stream was disconnected", it)
                return@withContext
            }
        )

        withContext(Dispatchers.Main) {
            onReceive(numBytes, mmBuffer)
        }
    }
}

}

suspend fun sendByteToDevice(mmSocket: BluetoothSocket, bytes: ByteArray, onSend: (result: Result) -> Unit) { val mmOutStream: OutputStream = mmSocket.outputStream

withContext(Dispatchers.IO) {
    val result = kotlin.runCatching {
        mmOutStream.write(bytes)
    }

    if (result.isFailure) {
        Log.e(TAG, "Error occurred when sending data", result.exceptionOrNull())
        onSend(Result.failure(result.exceptionOrNull() ?: Exception("not found exception")))
    }
    else {
        onSend(Result.success(bytes))
    }
}

} ```

同樣的,這裏的讀取和寫入都是耗時操作,所以我都聲明瞭是掛起函數 suspend

另外,接收服務器的數據時,需要一直循環讀取 inputStream 直至 socket 拋出異常(連接被斷開)。

這裏我們在接收到新數據時,依然使用一個匿名函數回調接收到的數據 onReceive: (numBytes: Int, byteBufferArray: ByteArray) -> Unit

其中 numBytes 是本次接收到的數據大小, byteBufferArray 是完整的緩衝數組,實際數據可能沒有這麼多。

完整的幫助類

結合我們的需求,我寫了一個藍牙連接和通信的幫助類 BtHelper :

```kotlin class BtHelper { private var bluetoothAdapter: BluetoothAdapter? = null private var keepReceive: Boolean = true

companion object {
    private const val TAG = "BtHelper"

    val instance by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
        BtHelper()
    }
}

fun init(bluetoothAdapter: BluetoothAdapter) {
    this.bluetoothAdapter = bluetoothAdapter
}

fun init(context: Context): Boolean {
    val bluetoothManager: BluetoothManager = context.getSystemService(BluetoothManager::class.java)
    this.bluetoothAdapter = bluetoothManager.adapter
    return if (bluetoothAdapter == null) {
        Log.e(TAG, "init: bluetoothAdapter is null, may this device not support bluetooth!")
        false
    } else {
        true
    }
}

fun checkBluetooth(context: Context): Boolean {
    return ActivityCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
            && bluetoothAdapter?.isEnabled == true
}

@SuppressLint("MissingPermission")
fun queryPairDevices(): Set<BluetoothDevice>? {
    if (bluetoothAdapter == null) {
        Log.e(TAG, "queryPairDevices: bluetoothAdapter is null!")
        return null
    }

    val pairedDevices: Set<BluetoothDevice>? = bluetoothAdapter!!.bondedDevices

    pairedDevices?.forEach { device ->
        val deviceName = device.name
        val deviceHardwareAddress = device.address

        Log.i(TAG, "queryPairDevices: deveice name=$deviceName, mac=$deviceHardwareAddress")
    }

    return pairedDevices
}

@SuppressLint("MissingPermission")
suspend fun connectDevice(device: BluetoothDevice, onConnected : (socket: Result<BluetoothSocket>) -> Unit) {
    val mmSocket: BluetoothSocket? by lazy(LazyThreadSafetyMode.NONE) {
        device.createRfcommSocketToServiceRecord(UUID.fromString("00001101-0000-1000-8000-00805f9b34fb"))
    }

    withContext(Dispatchers.IO) {

        kotlin.runCatching {
            // 開始連接前應該關閉掃描,否則會減慢連接速度
            bluetoothAdapter?.cancelDiscovery()

            mmSocket?.connect()
        }.fold({
            withContext(Dispatchers.Main) {
                onConnected(Result.success(mmSocket!!))
            }
        }, {
            withContext(Dispatchers.Main) {
                onConnected(Result.failure(it))
            }
            Log.e(TAG, "connectDevice: connect fail!", it)
        })
    }
}

fun cancelConnect(mmSocket: BluetoothSocket?) {
    try {
        mmSocket?.close()
    } catch (e: IOException) {
        Log.e(TAG, "Could not close the client socket", e)
    }
}

suspend fun startBtReceiveServer(mmSocket: BluetoothSocket, onReceive: (numBytes: Int, byteBufferArray: ByteArray) -> Unit) {
    keepReceive = true
    val mmInStream: InputStream = mmSocket.inputStream
    val mmBuffer = ByteArray(1024) // mmBuffer store for the stream

    withContext(Dispatchers.IO) {
        var numBytes = 0 // bytes returned from read()
        while (true) {

            kotlin.runCatching {
                mmInStream.read(mmBuffer)
            }.fold(
                {
                    numBytes = it
                },
                {
                    Log.e(TAG, "Input stream was disconnected", it)
                    return@withContext
                }
            )

            withContext(Dispatchers.Main) {
                onReceive(numBytes, mmBuffer)
            }
        }
    }
}

fun stopBtReceiveServer() {
    keepReceive = false
}

suspend fun sendByteToDevice(mmSocket: BluetoothSocket, bytes: ByteArray, onSend: (result: Result<ByteArray>) -> Unit) {
    val mmOutStream: OutputStream = mmSocket.outputStream

    withContext(Dispatchers.IO) {
        val result = kotlin.runCatching {
            mmOutStream.write(bytes)
        }

        if (result.isFailure) {
            Log.e(TAG, "Error occurred when sending data", result.exceptionOrNull())
            onSend(Result.failure(result.exceptionOrNull() ?: Exception("not found exception")))
        }
        else {
            onSend(Result.success(bytes))
        }
    }
}

} ```

通信協議與需求

在上一篇文章寫完之後,其實我又加了許多功能。

但是我們的需求實際上總結來説就兩個:

  1. 能夠直接在手機 APP 上模擬觸發遙控器按鍵
  2. 能夠設置 ESP32 的某些參數

結合這個需求,我們制定瞭如下通信協議(這裏只寫了重要的):

單指令:

| 指令 | 功能 | 説明 | | :----: | :----: | :----: | | 1 | 開啟電源 | 給遙控器供電 | | 2 | 關閉電源 | 斷開遙控器供電 | | 8 | 讀取當前主板狀態(友好文本) | 讀取當前主板的狀態信息,以友好文本形式返回 | | 9 | 讀取主板設置參數(格式化文本) | 讀取當前主板保存的設置參數,以格式化文本返回 | | 101 | 觸發上鎖按鍵 | 無 | | 102 | 斷開上鎖按鍵 | 無 | | 103 | 觸發解鎖按鍵 | 無 | | 104 | 斷開解鎖按鍵 | 無 | | 105 | 觸發多功能按鍵 | 無 | | 106 | 斷開多功能按鍵 | 無 |

設置參數指令:

設置參數內容格式依舊如同上篇文章所述,這裏並沒有做更改。

| 參數碼 | 功能 | 説明 | | :----: | :----: | :----: | | 1 | 設置間隔時間 | 設置 BLE 掃描一次的時間 | | 2 | 設置 RSSI 閾值 | 設置識別 RSSI 的閾值 | | 3 | 設置是否觸發解鎖按鍵 | 設置掃描到手環且RSSI閾值符合後,是否觸發解鎖按鍵,不開啟該項則只會給遙控起上電,不會自動解鎖 | | 4 | 設置是否啟用感應解鎖 | 設置是否啟用感應解鎖,不開啟則不會掃描手環,只能手動連接主板並給遙控器上電解鎖 | | 5 | 設置掃描失敗多少次後觸發上鎖 | 設置掃描設備失敗多少次後才會觸發上鎖並斷電,有時掃描藍牙會間歇性的掃描失敗,增加該選項是為了避免正常使用時被錯誤的上鎖 |

編寫 APP

界面設計

由於本文的重點不在於如何設計界面,所以這裏不再贅述怎麼實現界面,我直接就上最終實現效果即可。

對了,由於現在還在測試,所以最終界面肯定不會這麼簡陋的(也許吧)。

主頁(等待連接):

s1.jpg

控制頁:

s2.jpg

控制頁(打開設置):

s3.jpg

邏輯實現

其實,這個代碼邏輯也很簡單,這裏就挑幾個説説,其他的大夥可以直接看源碼。

何時初始化

上面説到,我為藍牙通信編寫了一個簡單的幫助類,並且實現了一個單例模式:

```kotlin companion object {

val instance by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
    BtHelper()
}

} ```

我最開始是在 Application 中調用 BtHelper.instance.init(this) 初始化,但是我後來發現,這樣初始化的話,在實際使用中時,bluetoothAdapter 始終為 null 。

沒辦法,我把初始化放到了頂級 composable 中:

```kotlin @Composable fun HomeView(viewModel: HomeViewModel) { val context = LocalContext.current DisposableEffect(Unit) { viewModel.dispatch(HomeAction.InitBt(context))

    onDispose { }
}
// .....

} ```

InitBt 這個 Action 中,我調用了 BtHelper.instance.init(context) 重新初始化。

這下基本沒問題了。

發送模擬按鍵數據

因為遙控器的按鍵涉及到短按和長按的邏輯操作,所以這裏我不能直接使用 Button 的點擊回調,而是要自己處理按下和抬起手指事件。

並且在按下 Button 時發送觸發按鍵指令,鬆開 Button 時觸發斷開按鍵命令。

以上鎖這個 Button 為例:

kotlin Button( onClick = { }, modifier = Modifier.presBtn { viewModel.dispatch(HomeAction.OnClickButton(ButtonIndex.Lock, it)) } ) { Text(text = "上鎖") }

其中,presBtn 是我自己定義的一個擴展函數:

```kotlin @SuppressLint("UnnecessaryComposedModifier") @OptIn(ExperimentalComposeUiApi::class) inline fun Modifier.presBtn(crossinline onPress: (btnAction: ButtonAction)->Unit): Modifier = composed {

pointerInteropFilter {
    when (it.action) {
        MotionEvent.ACTION_DOWN -> {
            onPress(ButtonAction.Down)
        }
        MotionEvent.ACTION_UP -> {
            onPress(ButtonAction.Up)
        }
    }
    true
}

} ```

我在這個擴展函數中通過 pointerInteropFilter 獲取原始觸摸事件,並回調其中的 ACTION_DOWNACTION_UP 事件。

然後在 OnClickButton 這個 Action 中做如下處理:

```kotlin private fun onClickButton(index: ButtonIndex, action: ButtonAction) { val sendValue: Byte = when (index) { ButtonIndex.Lock -> { if (action == ButtonAction.Down) 101 else 102 } }

viewModelScope.launch {
    BtHelper.instance.sendByteToDevice(socket!!, byteArrayOf(sendValue)) {
        it.fold(
            {
                Log.i(TAG, "seed successful: byte= ${it.toHexStr()}")
            },
            {
                Log.e(TAG, "seed fail", it)
            }
        )
    }
}

} ```

為了避免讀者看起來太混亂,這裏刪除了其他按鍵的判斷,只保留了上鎖按鍵。

通過判斷是 按下事件 還是 抬起事件 來決定發送給 ESP32 的指令是 101 還是 102

讀取數據

在這個項目中,我們涉及到讀取數據的地方其實就兩個:讀取狀態(友好文本和格式化文本)。

其中返回的數據格式,在上面界面設計一節中的最後兩張截圖已經有所體現,上面返回的是友好文本,下面是格式化文本。

其中格式化文本我需要解析出來並更新到 UI 上(設置界面):

```kotlin BtHelper.instance.startBtReceiveServer(socket!!, onReceive = { numBytes, byteBufferArray -> if (numBytes > 0) { val contentArray = byteBufferArray.sliceArray(0..numBytes) val contentText = contentArray.toText()

    Log.i(TAG, "connectDevice: rev:numBytes=$numBytes, " +
            "\nbyteBuffer(hex)=${contentArray.toHexStr()}, " +
            "\nbyteBuffer(ascii)=$contentText"
    )

    viewStates = viewStates.copy(logText = "${viewStates.logText}\n$contentText")

    if (contentText.length > 6 && contentText.slice(0..2) == "Set") {
        Log.i(TAG, "connectDevice: READ from setting")
        val setList = contentText.split(",")
        viewStates = viewStates.copy(
            availableInduction = setList[1] != "0",
            triggerUnlock = setList[2] != "0",
            scanningTime = setList[3],
            rssiThreshold = setList[4],
            shutdownThreshold = setList[5],
            isReadSettingState = false
        )
    }
}

}) ```

對了,我還寫了一個轉換類(FormatUtils),用於處理返回數據:

```kotlin object FormatUtils {

/**
 * 將十六進制字符串轉成 ByteArray
 * */
fun hexStrToBytes(hexString: String): ByteArray {
    check(hexString.length % 2 == 0) { return ByteArray(0) }

    return hexString.chunked(2)
        .map { it.toInt(16).toByte() }
        .toByteArray()
}

/**
 * 將十六進制字符串轉成 ByteArray
 * */
fun String.toBytes(): ByteArray {
    return hexStrToBytes(this)
}

/**
 * 將 ByteArray 轉成 十六進制字符串
 * */
fun bytesToHexStr(byteArray: ByteArray) =
    with(StringBuilder()) {
        byteArray.forEach {
            val hex = it.toInt() and (0xFF)
            val hexStr = Integer.toHexString(hex)
            if (hexStr.length == 1) append("0").append(hexStr)
            else append(hexStr)
        }
        toString().uppercase(Locale.CHINA)
    }

/**
 * 將字節數組轉成十六進制字符串
 * */
fun ByteArray.toHexStr(): String {
    return bytesToHexStr(this)
}

/**
 * 將字節數組解析成文本(ASCII)
 * */
fun ByteArray.toText(): String {
    return String(this)
}

/**
 * 將 ByteArray 轉為 bit 字符串
 * */
fun ByteArray.toBitsStr(): String {
    if (this.isEmpty()) return ""
    val sb = java.lang.StringBuilder()
    for (aByte in this) {
        for (j in 7 downTo 0) {
            sb.append(if (aByte.toInt() shr j and 0x01 == 0) '0' else '1')
        }
    }
    return sb.toString()
}

/**
 *
 * 將十六進制字符串轉成 ASCII 文本
 *
 * */
fun String.toText(): String {
    val output = java.lang.StringBuilder()
    var i = 0
    while (i < this.length) {
        val str = this.substring(i, i + 2)
        output.append(str.toInt(16).toChar())
        i += 2
    }
    return output.toString()
}

/**
 * 將十六進制字符串轉為帶符號的 Int
 * */
fun String.toNumber(): Int {
    return this.toInt(16).toShort().toInt()
}

/**
 * 將整數轉成有符號十六進制字符串
 *
 * @param length 返回的十六進制總長度,不足會在前面補 0 ,超出會將前面多餘的去除
 * */
fun Int.toHex(length: Int = 4): String {
    val hex = Integer.toHexString(this).uppercase(Locale.CHINA)
    return hex.padStart(length, '0').drop((hex.length-length).coerceAtLeast(0))
}

} ```

項目地址

auto_controller

歡迎 star!