魔改車鑰匙實現遠程控車:(4)基於compose和經典藍牙編寫一個控制APP
我報名參加金石計劃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
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
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
BluetoothSocket
的 InputStream
從服務端讀取數據;write
BluetoothSocket
的 OutputStream
往服務端寫入數據:
```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
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))
}
}
}
} ```
通信協議與需求
在上一篇文章寫完之後,其實我又加了許多功能。
但是我們的需求實際上總結來説就兩個:
- 能夠直接在手機 APP 上模擬觸發遙控器按鍵
- 能夠設置 ESP32 的某些參數
結合這個需求,我們制定瞭如下通信協議(這裏只寫了重要的):
單指令:
| 指令 | 功能 | 説明 | | :----: | :----: | :----: | | 1 | 開啟電源 | 給遙控器供電 | | 2 | 關閉電源 | 斷開遙控器供電 | | 8 | 讀取當前主板狀態(友好文本) | 讀取當前主板的狀態信息,以友好文本形式返回 | | 9 | 讀取主板設置參數(格式化文本) | 讀取當前主板保存的設置參數,以格式化文本返回 | | 101 | 觸發上鎖按鍵 | 無 | | 102 | 斷開上鎖按鍵 | 無 | | 103 | 觸發解鎖按鍵 | 無 | | 104 | 斷開解鎖按鍵 | 無 | | 105 | 觸發多功能按鍵 | 無 | | 106 | 斷開多功能按鍵 | 無 |
設置參數指令:
設置參數內容格式依舊如同上篇文章所述,這裏並沒有做更改。
| 參數碼 | 功能 | 説明 | | :----: | :----: | :----: | | 1 | 設置間隔時間 | 設置 BLE 掃描一次的時間 | | 2 | 設置 RSSI 閾值 | 設置識別 RSSI 的閾值 | | 3 | 設置是否觸發解鎖按鍵 | 設置掃描到手環且RSSI閾值符合後,是否觸發解鎖按鍵,不開啟該項則只會給遙控起上電,不會自動解鎖 | | 4 | 設置是否啟用感應解鎖 | 設置是否啟用感應解鎖,不開啟則不會掃描手環,只能手動連接主板並給遙控器上電解鎖 | | 5 | 設置掃描失敗多少次後觸發上鎖 | 設置掃描設備失敗多少次後才會觸發上鎖並斷電,有時掃描藍牙會間歇性的掃描失敗,增加該選項是為了避免正常使用時被錯誤的上鎖 |
編寫 APP
界面設計
由於本文的重點不在於如何設計界面,所以這裏不再贅述怎麼實現界面,我直接就上最終實現效果即可。
對了,由於現在還在測試,所以最終界面肯定不會這麼簡陋的(也許吧)。
主頁(等待連接):
控制頁:
控制頁(打開設置):
邏輯實現
其實,這個代碼邏輯也很簡單,這裏就挑幾個説説,其他的大夥可以直接看源碼。
何時初始化
上面説到,我為藍牙通信編寫了一個簡單的幫助類,並且實現了一個單例模式:
```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_DOWN
和 ACTION_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))
}
} ```
項目地址
歡迎 star!
- 安卓與串口通信-校驗篇
- 安卓與串口通信-實踐篇
- 為 Kotlin 的函數添加作用域限制(以 Compose 為例)
- 安卓與串口通信-基礎篇
- Compose For Desktop 實踐:使用 Compose-jb 做一個時間水印助手
- 初探 Compose for Wear OS:實現一個簡易選擇APP
- Compose太香了,不想再寫傳統 xml View?教你如何在已有View項目中混合使用Compose
- 在安卓中壓縮GIF的幾種方法(附實例代碼)
- 魔改車鑰匙實現遠程控車:(1)整體思路及控制方案實現
- 跟我一起使用 compose 做一個跨平台的黑白棋遊戲(3)狀態與遊戲控制邏輯
- 跟我一起使用 compose 做一個跨平台的黑白棋遊戲(2)界面佈局
- 以不同的形式在安卓中創建GIF動圖
- 跟我一起使用 compose 做一個跨平台的黑白棋遊戲(4)移植到compose-jb實現跨平台
- 羨慕大勞星空頂?不如跟我一起使用 Jetpack compose 繪製一個星空背景(帶流星動畫)
- 魔改車鑰匙實現遠程控車:(4)基於compose和經典藍牙編寫一個控制APP