前言
这篇文章不出意外的话应该是魔改车钥匙系列的最后一篇了,自此我们的魔改计划除了最后的布线和安装外已经全部完成了。
不过由于布线以及安装不属于编程技术范围,且我也是第一次做,就不献丑继续写一篇文章了。
在前面的文章中,我们已经完成了 Arduino 控制程序的编写,接下来就差编写一个简单易用的手机端控制 APP 了。
这里我们依旧选择使用 compose 作为 UI 框架。
编写这个控制 APP 会涉及到安卓上的蓝牙开发知识,因此我们会先简要介绍一下如何在安卓上进行蓝牙开发。
开始编写
蓝牙基础
蓝牙分为经典蓝牙和低功耗蓝牙(BLE)这个知识点前面的文章已经介绍过了,在我们当前的需求中,我们只需要使用经典蓝牙去与 ESP32 通信,所以我们也只介绍如何使用经典蓝牙。
蓝牙权限
在使用之前,我们需要确保蓝牙权限正确,根据官网教程添加如下权限:
<!-- Request legacy Bluetooth permissions on older devices. -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /><!-- Needed only if your app looks for Bluetooth devices.If your app doesn't use Bluetooth scan results to derive physicallocation information, you can strongly assert that your appdoesn't derive physical location. -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"android:usesPermissionFlags="neverForLocation" /><!-- Needed only if your app makes the device discoverable to Bluetoothdevices. -->
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" /><!-- Needed only if your app communicates with already-paired Bluetoothdevices. -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
实际使用时不用添加所有的权限,只需要根据你的需求添加需要的权限即可。
详细可以查阅官网文档:Bluetooth permissions
因为我们在这里需要连接到 ESP32 所以不要忘记判断运行时权限:
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {// xxxxx// 没有权限
}
某些设备可能不支持经典蓝牙或BLE,亦或是两者均不支持,所以我们需要做一下检查:
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),后续的大多数操作都将基于这个适配器展开:
val bluetoothManager: BluetoothManager = context.getSystemService(BluetoothManager::class.java)
val bluetoothAdapter = bluetoothManager.adapter
需要注意的是,获取到的 bluetoothAdapter
可能为空,需要自己做一下判空处理。
拿到 bluetoothAdapter
后,下一步是判断是否开启了蓝牙,如果没有开启则需要请求开启:
if (bluetoothAdapter?.isEnabled == false) {val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT)
}
查找蓝牙设备
由于在这个项目中, ESP32 没法实时加密配对,所以我采用的是直接手动配对好我的手机,然后就不再配对新设备,日后如果有需求,我会研究一下怎么实时加密配对。
所以我们这里暂时不需要搜索新的蓝牙设备,只需要查询已经连接的设备即可:
fun queryPairDevices(): Set<BluetoothDevice>? {if (bluetoothAdapter == null) {Log.e(TAG, "queryPairDevices: bluetoothAdapter is null!")return null}val pairedDevices: Set<BluetoothDevice>? = bluetoothAdapter!!.bondedDevicespairedDevices?.forEach { device ->val deviceName = device.nameval deviceHardwareAddress = device.addressLog.i(TAG, "queryPairDevices: deveice name=$deviceName, mac=$deviceHardwareAddress")}return pairedDevices
}
连接到指定设备
连接蓝牙设备有两种角色:服务端和客户端,在我们这里的使用场景中,我们的 APP 是客户端,而 ESP32 是服务端,所以我们需要实现的是客户端连接。
因为这里我们连接的是已配对设备,所以相对来说简单的多,不需要做额外的处理,直接连接即可,连接后会拿到一个 BluetoothSocket
,后续的通信将用到这个 BluetoothSocket
:
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) {socket = mmSocketonConnected(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
往服务端写入数据:
suspend fun startBtReceiveServer(mmSocket: BluetoothSocket, onReceive: (numBytes: Int, byteBufferArray: ByteArray) -> Unit) {keepReceive = trueval mmInStream: InputStream = mmSocket.inputStreamval 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<ByteArray>) -> Unit) {val mmOutStream: OutputStream = mmSocket.outputStreamwithContext(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
:
class BtHelper {private var bluetoothAdapter: BluetoothAdapter? = nullprivate var keepReceive: Boolean = truecompanion 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.adapterreturn 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!!.bondedDevicespairedDevices?.forEach { device ->val deviceName = device.nameval deviceHardwareAddress = device.addressLog.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 = trueval mmInStream: InputStream = mmSocket.inputStreamval mmBuffer = ByteArray(1024) // mmBuffer store for the streamwithContext(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.outputStreamwithContext(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
界面设计
由于本文的重点不在于如何设计界面,所以这里不再赘述怎么实现界面,我直接就上最终实现效果即可。
对了,由于现在还在测试,所以最终界面肯定不会这么简陋的(也许吧)。
主页(等待连接):
控制页:
控制页(打开设置):
逻辑实现
其实,这个代码逻辑也很简单,这里就挑几个说说,其他的大伙可以直接看源码。
何时初始化
上面说到,我为蓝牙通信编写了一个简单的帮助类,并且实现了一个单例模式:
companion object {val instance by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {BtHelper()}
}
我最开始是在 Application 中调用 BtHelper.instance.init(this)
初始化,但是我后来发现,这样初始化的话,在实际使用中时,bluetoothAdapter
始终为 null 。
没办法,我把初始化放到了顶级 composable 中:
@Composable
fun HomeView(viewModel: HomeViewModel) {val context = LocalContext.currentDisposableEffect(Unit) {viewModel.dispatch(HomeAction.InitBt(context))onDispose { }}// .....
}
在 InitBt
这个 Action 中,我调用了 BtHelper.instance.init(context)
重新初始化。
这下基本没问题了。
发送模拟按键数据
因为遥控器的按键涉及到短按和长按的逻辑操作,所以这里我不能直接使用 Button 的点击回调,而是要自己处理按下和抬起手指事件。
并且在按下 Button 时发送触发按键指令,松开 Button 时触发断开按键命令。
以上锁这个 Button 为例:
Button(onClick = { },modifier = Modifier.presBtn {viewModel.dispatch(HomeAction.OnClickButton(ButtonIndex.Lock, it))}
) {Text(text = "上锁")
}
其中,presBtn
是我自己定义的一个扩展函数:
@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 中做如下处理:
private fun onClickButton(index: ButtonIndex, action: ButtonAction) {val sendValue: Byte = when (index) {ButtonIndex.Lock -> {if (action == ButtonAction.Down) 101else 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 上(设置界面):
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),用于处理返回数据:
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 = 0while (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!