魔改车钥匙实现远程控车:(4)编写一个控制APP

前言

这篇文章不出意外的话应该是魔改车钥匙系列的最后一篇了,自此我们的魔改计划除了最后的布线和安装外已经全部完成了。

不过由于布线以及安装不属于编程技术范围,且我也是第一次做,就不献丑继续写一篇文章了。

在前面的文章中,我们已经完成了 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 physical
     location information, you can strongly assert that your app
     doesn'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 Bluetooth
     devices. -->
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />

<!-- Needed only if your app communicates with already-paired Bluetooth
     devices. -->
<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!!.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

 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 = 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 往服务端写入数据:

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<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))
        }
    }
}

同样的,这里的读取和写入都是耗时操作,所以我都声明了是挂起函数 suspend

另外,接收服务器的数据时,需要一直循环读取 inputStream 直至 socket 抛出异常(连接被断开)。

这里我们在接收到新数据时,依然使用一个匿名函数回调接收到的数据 onReceive: (numBytes: Int, byteBufferArray: ByteArray) -> Unit

其中 numBytes 是本次接收到的数据大小, byteBufferArray 是完整的缓冲数组,实际数据可能没有这么多。

完整的帮助类

结合我们的需求,我写了一个蓝牙连接和通信的帮助类 BtHelper :

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

控制页:

s2

控制页(打开设置):

s3

逻辑实现

其实,这个代码逻辑也很简单,这里就挑几个说说,其他的大伙可以直接看源码。

何时初始化

上面说到,我为蓝牙通信编写了一个简单的帮助类,并且实现了一个单例模式:

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.current
    DisposableEffect(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_DOWNACTION_UP 事件。

然后在 OnClickButton 这个 Action 中做如下处理:

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 上(设置界面):

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 = 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!