From 55299209fd2df3121630f7488ed902ff71d36c52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=96=E5=8F=81?= Date: Tue, 24 Dec 2024 12:15:53 +0800 Subject: [PATCH] feat: basic frame --- .../java/icu/fur93/esp32_car/CarController.kt | 184 ------------- .../java/icu/fur93/esp32_car/MainActivity.kt | 241 ++--------------- .../icu/fur93/esp32_car/entity/BleDevice.kt | 18 ++ .../esp32_car/entity/ConnectedDeviceInfo.kt | 7 + .../icu/fur93/esp32_car/page/ControlPage.kt | 16 +- .../java/icu/fur93/esp32_car/page/HomePage.kt | 33 ++- ...ontrolPage.kt => LegacyControlPage.kt.old} | 2 - .../icu/fur93/esp32_car/page/SettingsPage.kt | 95 ++++++- .../repository/BluetoothRepository.kt | 245 ++++++++++++++++++ .../ui/card/UnconnectedStatusCard.kt | 18 +- .../esp32_car/ui/carditem/StatusCardInfo.kt | 9 +- .../ui/carditem/StatusCardInfraredStatus.kt | 19 +- .../ui/carditem/StatusCardUltrasoundStatus.kt | 23 +- .../ui/carditem/StautsCardMotorStatus.kt | 11 +- .../ui/dialog/BleDeviceScanDialog.kt | 118 +++++++++ .../fur93/esp32_car/viewmodel/CarViewModel.kt | 195 ++++++++++++++ .../esp32_car/viewmodel/MainViewModel.kt | 2 - 17 files changed, 780 insertions(+), 456 deletions(-) delete mode 100644 app/src/main/java/icu/fur93/esp32_car/CarController.kt create mode 100644 app/src/main/java/icu/fur93/esp32_car/entity/BleDevice.kt create mode 100644 app/src/main/java/icu/fur93/esp32_car/entity/ConnectedDeviceInfo.kt rename app/src/main/java/icu/fur93/esp32_car/page/{LegacyControlPage.kt => LegacyControlPage.kt.old} (99%) create mode 100644 app/src/main/java/icu/fur93/esp32_car/repository/BluetoothRepository.kt create mode 100644 app/src/main/java/icu/fur93/esp32_car/ui/dialog/BleDeviceScanDialog.kt create mode 100644 app/src/main/java/icu/fur93/esp32_car/viewmodel/CarViewModel.kt delete mode 100644 app/src/main/java/icu/fur93/esp32_car/viewmodel/MainViewModel.kt diff --git a/app/src/main/java/icu/fur93/esp32_car/CarController.kt b/app/src/main/java/icu/fur93/esp32_car/CarController.kt deleted file mode 100644 index c8b30e0..0000000 --- a/app/src/main/java/icu/fur93/esp32_car/CarController.kt +++ /dev/null @@ -1,184 +0,0 @@ -package icu.fur93.esp32_car - -import android.bluetooth.BluetoothGatt -import android.bluetooth.BluetoothGattCharacteristic -import android.util.Log -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf -import androidx.room.util.copy - -class CarCommand { - companion object { - // 数据包头尾定义 - const val PACKET_T_HEAD = 0x00u - const val PACKET_T_TAIL = 0xFFu - const val PACKET_R_HEAD = 0x01u - const val PACKET_R_TAIL = 0xFEu - const val PACKET_MAX_LENGTH = 32u // 数据包最大长度 - - // 指令定义 - const val CMD_GET_BT_STATUS = 0x10u - const val CMD_GET_SPIFFS_STATUS = 0x11u - const val CMD_GET_DISTANCE = 0x12u - const val CMD_MOTOR_MOVE_CONTROL = 0x20u - const val CMD_MOTOR_STEER_CONTROL = 0x21u - const val CMD_MOTOR_SINGLE_CONTROL = 0x22u - const val CMD_MOTOR_ROTATE_CONTROL = 0x23u - const val CMD_MOTOR_XYR_CONTROL = 0x24u - const val CMD_DEMO_PID = 0xf0u - const val CMD_DEMO_PATH = 0xf1u - - const val CMD_STATUS_MOTOR = 0xE0u - } -} - -data class MotorState( - var pwm: UInt = 0u, - var in1: UInt = 0u, - var in2: UInt = 0u -) - -data class CarState( - var controlState: CarControlState = CarControlState(), - var motorAState: MotorState = MotorState(), - var motorBState: MotorState = MotorState(), - var motorCState: MotorState = MotorState(), - var motorDState: MotorState = MotorState() -) - -enum class LogDirection { - SEND, // 发送 - RECEIVE // 接收 -} - - -// 添加日志数据类 -data class LogEntry( - val timestamp: Long = System.currentTimeMillis(), - val direction: LogDirection, // "发送" 或 "接收" - val data: String -) - -class CarController(private val onStateChange: () -> Unit) { - // 使用 MutableState 来存储小车状态 - private val _carState = mutableStateOf(CarState()) - val carState: State = _carState - - var bluetoothGatt: BluetoothGatt? = null - var rxCharacteristic: BluetoothGattCharacteristic? = null - - // 添加日志列表状态 - private val _logs = mutableStateOf>(emptyList()) - val logs: State> = _logs - - // 更新电机状态的方法 - private fun updateMotorState( - motorA: MotorState? = null, - motorB: MotorState? = null, - motorC: MotorState? = null, - motorD: MotorState? = null - ) { - _carState.value.motorAState = motorA ?: _carState.value.motorAState - _carState.value.motorBState = motorB ?: _carState.value.motorBState - _carState.value.motorCState = motorC ?: _carState.value.motorCState - _carState.value.motorDState = motorD ?: _carState.value.motorDState - } - - private fun sendCommand(command: ByteArray) { - rxCharacteristic?.let { characteristic -> - characteristic.value = command - bluetoothGatt?.writeCharacteristic(characteristic) - - // 添加发送日志 - _logs.value = _logs.value + LogEntry( - direction = LogDirection.SEND, - data = command.joinToString(" ") { "0x%02X".format(it) } - ) - } - } - - fun moveForward(speed: Int = 255) { - sendCommand(byteArrayOf(CarCommand.PACKET_T_HEAD.toByte(), 0x06, CarCommand.CMD_MOTOR_MOVE_CONTROL.toByte(), 0x01, speed.toByte(), CarCommand.PACKET_T_TAIL.toByte())) - } - - fun moveBackward(speed: Int = 255) { - sendCommand(byteArrayOf(CarCommand.PACKET_T_HEAD.toByte(), 0x06, CarCommand.CMD_MOTOR_MOVE_CONTROL.toByte(), 0x02, speed.toByte(), CarCommand.PACKET_T_TAIL.toByte())) - } - - fun turnLeft(speed: Int = 255) { - sendCommand(byteArrayOf(CarCommand.PACKET_T_HEAD.toByte(), 0x06, CarCommand.CMD_MOTOR_STEER_CONTROL.toByte(), 0x00, speed.toByte(), CarCommand.PACKET_T_TAIL.toByte())) - } - - fun turnRight(speed: Int = 255) { - sendCommand(byteArrayOf(CarCommand.PACKET_T_HEAD.toByte(), 0x06, CarCommand.CMD_MOTOR_STEER_CONTROL.toByte(), 0x01, speed.toByte(), CarCommand.PACKET_T_TAIL.toByte())) - } - - fun stop() { - sendCommand(byteArrayOf(CarCommand.PACKET_T_HEAD.toByte(), 0x06, CarCommand.CMD_MOTOR_MOVE_CONTROL.toByte(), 0x00, 0x00, CarCommand.PACKET_T_TAIL.toByte())) - } - - fun sendXYR(x: Int, y: Int, r: Int) { - sendCommand(byteArrayOf(CarCommand.PACKET_T_HEAD.toByte(), 0x07, CarCommand.CMD_MOTOR_XYR_CONTROL.toByte(), x.toByte(), y.toByte(), r.toByte(), CarCommand.PACKET_T_TAIL.toByte())) - } - - fun onReceivePacket(packet: ByteArray) { - // 添加接收日志 - _logs.value = _logs.value + LogEntry( - direction = LogDirection.RECEIVE, - data = packet.joinToString(" ") { "0x%02X".format(it) } - ) - - // 判断数据包格式时使用 toUByte() - if (packet[0].toUByte() == CarCommand.PACKET_R_HEAD.toUByte() && - packet.size == packet[1].toUByte().toInt() && - packet[packet[1].toUByte().toInt() - 1].toUByte() == CarCommand.PACKET_R_TAIL.toUByte()) { - val command = packet[2].toUByte() - val data = packet.sliceArray(3 until packet[1].toUByte().toInt() - 1) - - Log.d("CarController", "command: 0x%02X".format(command.toInt() and 0xFF)) - - // 解析数据包 - when (command.toUInt()) { - // `01 0C E0 电机A_IN_1_2 电机A_PWM 电机B_IN_1_2 电机B_PWM 电机C_IN_1_2 电机C_PWM 电机D_IN_1_2 电机D_PWM FE` - CarCommand.CMD_STATUS_MOTOR -> { - - Log.d("CarController", "status motor data: ${data.joinToString(" ") { "0x%02X".format(it) }}") - - // 创建新的状态对象 - _carState.value = _carState.value.copy( - motorAState = MotorState( - pwm = data[1].toUByte().toUInt(), - in1 = data[0].toUByte().toUInt() and 1u, - in2 = (data[0].toUByte().toUInt() shr 1) and 1u - ), - motorBState = MotorState( - pwm = data[3].toUByte().toUInt(), - in1 = data[2].toUByte().toUInt() and 1u, - in2 = (data[2].toUByte().toUInt() shr 1) and 1u - ), - motorCState = MotorState( - pwm = data[5].toUByte().toUInt(), - in1 = data[4].toUByte().toUInt() and 1u, - in2 = (data[4].toUByte().toUInt() shr 1) and 1u - ), - motorDState = MotorState( - pwm = data[7].toUByte().toUInt(), - in1 = data[6].toUByte().toUInt() and 1u, - in2 = (data[6].toUByte().toUInt() shr 1) and 1u - ) - ) - } - } - } - } - - // 清除日志 - fun clearLogs() { - _logs.value = emptyList() - } -} - -class CarControlState { - public var speed: UInt = 0u - public var direction: UInt = 0u -} diff --git a/app/src/main/java/icu/fur93/esp32_car/MainActivity.kt b/app/src/main/java/icu/fur93/esp32_car/MainActivity.kt index 94d4b47..7cc799b 100644 --- a/app/src/main/java/icu/fur93/esp32_car/MainActivity.kt +++ b/app/src/main/java/icu/fur93/esp32_car/MainActivity.kt @@ -1,6 +1,7 @@ package icu.fur93.esp32_car import android.Manifest +import android.annotation.SuppressLint import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCallback @@ -11,7 +12,6 @@ import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanSettings -import android.content.pm.ActivityInfo import android.content.pm.PackageManager import android.os.Build import android.os.Bundle @@ -22,22 +22,16 @@ import android.util.Log import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -45,50 +39,29 @@ import icu.fur93.esp32_car.page.ControlGamepadModePage import icu.fur93.esp32_car.page.ControlPage import icu.fur93.esp32_car.page.ControlPathfinderModePage import icu.fur93.esp32_car.page.ControlSingleJoystickModePage -import icu.fur93.esp32_car.page.GamepadScreen import icu.fur93.esp32_car.page.HomePage import icu.fur93.esp32_car.page.SettingsPage -import icu.fur93.esp32_car.ui.component.Joystick -import icu.fur93.esp32_car.ui.component.JoystickState +import icu.fur93.esp32_car.repository.BluetoothRepositoryImpl import icu.fur93.esp32_car.ui.theme.Esp32carTheme -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import icu.fur93.esp32_car.viewmodel.CarControlUseCase +import icu.fur93.esp32_car.viewmodel.CarViewModel import java.util.UUID -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale class MainActivity : ComponentActivity() { - private val serviceUUID = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" - private val rxCharUUID = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" - private val txCharUUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" - - private var bleScanner: BluetoothLeScanner? = null - - // 将deviceList移动到Activity作用域内并使用MutableList - private val deviceList = mutableListOf() - - // 添加可观察的连接状态 - private val _isConnected = mutableStateOf(false) - val isConnected: State = _isConnected - - private val scanCallback = object : ScanCallback() { - override fun onScanResult(callbackType: Int, result: ScanResult) { - super.onScanResult(callbackType, result) - Log.d("MainActivity", "onScanResult: ${result.device.name} - ${result.device.address}") - if (!deviceList.any { it.device.address == result.device.address }) { - deviceList.add(result) - } - } - - override fun onScanFailed(errorCode: Int) { - super.onScanFailed(errorCode) - Log.e("MainActivity", "扫描失败,错误码: $errorCode") - } + private val bluetoothRepository by lazy { + BluetoothRepositoryImpl(this, lifecycleScope) } - private val carController by lazy { - CarController { } + private val carControlUseCase by lazy { + CarControlUseCase(bluetoothRepository) + } + + private val viewModel by viewModels { + object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return CarViewModel(carControlUseCase, bluetoothRepository) as T + } + } } override fun onCreate(savedInstanceState: Bundle?) { @@ -100,18 +73,6 @@ class MainActivity : ComponentActivity() { WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES } -// window.statusBarColor = ContextCompat -// -// // 隐藏状态栏和导航栏 -// WindowInsetsControllerCompat(window, window.decorView).let { controller -> -// controller.hide(WindowInsetsCompat.Type.systemBars()) -// controller.systemBarsBehavior = -// WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE -// } -// -// // 设置横屏 -// requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - // 检查并请求所有必要的权限 val requiredPermissions = arrayOf( Manifest.permission.BLUETOOTH_SCAN, @@ -130,158 +91,15 @@ class MainActivity : ComponentActivity() { missingPermissions.toTypedArray(), BLUETOOTH_PERMISSION_REQUEST_CODE ) - } else { - // 如果已经有所有权限,直接开始扫描 - val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() - bleScanner = bluetoothAdapter?.bluetoothLeScanner - startBleScan() } setContent { Esp32carTheme { -// GamepadScreen( -// deviceList = deviceList, -// isConnected = isConnected.value, -// onStartScan = ::startBleScan, -// onConnect = ::connectToDevice, -// carController = carController -// ) - App() + App(viewModel) } } } - private fun connectToDevice(result: ScanResult) { - if (ActivityCompat.checkSelfPermission( - this, - Manifest.permission.BLUETOOTH_CONNECT - ) != PackageManager.PERMISSION_GRANTED - ) { - ActivityCompat.requestPermissions( - this, - arrayOf(Manifest.permission.BLUETOOTH_CONNECT), - BLUETOOTH_PERMISSION_REQUEST_CODE - ) - return - } - - result.device.connectGatt(this, false, object : BluetoothGattCallback() { - override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) { - super.onConnectionStateChange(gatt, status, newState) - when (newState) { - BluetoothGatt.STATE_CONNECTED -> { - carController.bluetoothGatt = gatt - _isConnected.value = true - gatt?.discoverServices() - } - - BluetoothGatt.STATE_DISCONNECTED -> { - _isConnected.value = false - carController.bluetoothGatt = null - } - } - } - - override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) { - super.onServicesDiscovered(gatt, status) - if (status == BluetoothGatt.GATT_SUCCESS) { - gatt?.getService(UUID.fromString(serviceUUID))?.let { service -> - carController.bluetoothGatt = gatt - carController.rxCharacteristic = - service.getCharacteristic(UUID.fromString(rxCharUUID)) - - // 注册通知监听器 - val txCharacteristic = - service.getCharacteristic(UUID.fromString(txCharUUID)) - gatt.setCharacteristicNotification(txCharacteristic, true) - txCharacteristic.descriptors.forEach { descriptor -> - descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - gatt.writeDescriptor(descriptor) - } - } - } - } - - override fun onCharacteristicChanged( - gatt: BluetoothGatt, - characteristic: BluetoothGattCharacteristic, - value: ByteArray - ) { - super.onCharacteristicChanged(gatt, characteristic, value) - if (characteristic.uuid == UUID.fromString(txCharUUID)) { - carController.onReceivePacket(value) - } - } - }) - } - - private fun startBleScan() { - if (ActivityCompat.checkSelfPermission( - this, - Manifest.permission.BLUETOOTH_SCAN - ) != PackageManager.PERMISSION_GRANTED - ) { - ActivityCompat.requestPermissions( - this, - arrayOf( - Manifest.permission.BLUETOOTH_SCAN, - Manifest.permission.ACCESS_FINE_LOCATION - ), - BLUETOOTH_PERMISSION_REQUEST_CODE - ) - return - } - - // 先停止当前扫描 - try { - bleScanner?.stopScan(scanCallback) - } catch (e: Exception) { - Log.e("MainActivity", "停止扫描失败: ${e.message}") - } - - // 清空之前的扫描结果 - deviceList.clear() - - Log.d("MainActivity", "开始扫描...") - - // 添加UUID过滤器 - val scanFilter = ScanFilter.Builder() - .setServiceUuid(ParcelUuid.fromString(serviceUUID)) - .build() - - val scanSettings = ScanSettings.Builder() - .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) - .build() - - try { -// bleScanner?.startScan(listOf(scanFilter), scanSettings, scanCallback) - bleScanner?.startScan(scanCallback) - } catch (e: Exception) { - Log.e("MainActivity", "开始扫描失败: ${e.message}") - } - - // 10秒后停止扫描 - Handler(Looper.getMainLooper()).postDelayed({ - stopBleScan() - Log.d("MainActivity", "停止扫描,发现 ${deviceList.size} 个设备") - }, 10000) - } - - private fun stopBleScan() { - if (ActivityCompat.checkSelfPermission( - this, - Manifest.permission.BLUETOOTH_SCAN - ) != PackageManager.PERMISSION_GRANTED - ) { - return - } - try { - bleScanner?.stopScan(scanCallback) - } catch (e: Exception) { - Log.e("MainActivity", "停止扫描失败: ${e.message}") - } - } - // 添加权限请求结果处理 override fun onRequestPermissionsResult( requestCode: Int, @@ -292,28 +110,19 @@ class MainActivity : ComponentActivity() { if (requestCode == BLUETOOTH_PERMISSION_REQUEST_CODE) { if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) { Log.d("MainActivity", "所有权限已获得") - // 权限获得后重新初始化蓝牙 - val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() - bleScanner = bluetoothAdapter?.bluetoothLeScanner } else { Log.e("MainActivity", "部分权限未获得") } } } - override fun onDestroy() { - super.onDestroy() - // 确保在Activity销毁时停止扫描 - stopBleScan() - } - companion object { private const val BLUETOOTH_PERMISSION_REQUEST_CODE = 1 } } @Composable -fun App() { +fun App(viewModel: CarViewModel) { val activity = LocalContext.current as MainActivity activity.window.statusBarColor = MaterialTheme.colorScheme.background.toArgb() @@ -323,9 +132,9 @@ fun App() { navController = navController, startDestination = Route.Home.route ) { - composable(Route.Home.route) { HomePage(navController) } - composable(Route.Control.route) { ControlPage(navController) } - composable(Route.Settings.route) { SettingsPage(navController) } + composable(Route.Home.route) { HomePage(navController, viewModel) } + composable(Route.Control.route) { ControlPage(navController, viewModel) } + composable(Route.Settings.route) { SettingsPage(navController, viewModel) } composable(Route.ControlPathfinderMode.route) { ControlPathfinderModePage(navController) } composable(Route.ControlSingleJoystickMode.route) { ControlSingleJoystickModePage(navController) } composable(Route.ControlGamepadMode.route) { ControlGamepadModePage(navController) } diff --git a/app/src/main/java/icu/fur93/esp32_car/entity/BleDevice.kt b/app/src/main/java/icu/fur93/esp32_car/entity/BleDevice.kt new file mode 100644 index 0000000..006c35a --- /dev/null +++ b/app/src/main/java/icu/fur93/esp32_car/entity/BleDevice.kt @@ -0,0 +1,18 @@ +package icu.fur93.esp32_car.entity + +import android.bluetooth.BluetoothDevice + +data class BleDevice( + val device: BluetoothDevice, + val rssi: Int, + val scanRecord: ByteArray? = null +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as BleDevice + return device.address == other.device.address + } + + override fun hashCode(): Int = device.address.hashCode() +} \ No newline at end of file diff --git a/app/src/main/java/icu/fur93/esp32_car/entity/ConnectedDeviceInfo.kt b/app/src/main/java/icu/fur93/esp32_car/entity/ConnectedDeviceInfo.kt new file mode 100644 index 0000000..fe36e51 --- /dev/null +++ b/app/src/main/java/icu/fur93/esp32_car/entity/ConnectedDeviceInfo.kt @@ -0,0 +1,7 @@ +package icu.fur93.esp32_car.entity + +data class ConnectedDeviceInfo( + val name: String = "", + val address: String = "", + val version: String = "" +) diff --git a/app/src/main/java/icu/fur93/esp32_car/page/ControlPage.kt b/app/src/main/java/icu/fur93/esp32_car/page/ControlPage.kt index e60181f..9166d73 100644 --- a/app/src/main/java/icu/fur93/esp32_car/page/ControlPage.kt +++ b/app/src/main/java/icu/fur93/esp32_car/page/ControlPage.kt @@ -3,8 +3,9 @@ package icu.fur93.esp32_car.page import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource @@ -12,20 +13,29 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import icu.fur93.esp32_car.R import icu.fur93.esp32_car.Route +import icu.fur93.esp32_car.repository.ConnectionState +import icu.fur93.esp32_car.ui.card.UnconnectedStatusCard import icu.fur93.esp32_car.ui.component.BottomNavigationBar import icu.fur93.esp32_car.ui.component.CardButtonGroup import icu.fur93.esp32_car.ui.component.CardButtonItem import icu.fur93.esp32_car.ui.component.PageTitle import icu.fur93.esp32_car.ui.layout.MainLayout import icu.fur93.esp32_car.ui.theme.LayoutContentModifier +import icu.fur93.esp32_car.viewmodel.CarViewModel @Composable -fun ControlPage(navController: NavHostController) { +fun ControlPage(navController: NavHostController, viewModel: CarViewModel) { + val connectionState by viewModel.connectionState.collectAsState() + MainLayout( content = { Column (LayoutContentModifier) { PageTitle("控制") - ControlPageStatusCard() + if (connectionState == ConnectionState.CONNECTED) { + ControlPageStatusCard() + } else { + UnconnectedStatusCard(viewModel) + } } }, bottomBar = { diff --git a/app/src/main/java/icu/fur93/esp32_car/page/HomePage.kt b/app/src/main/java/icu/fur93/esp32_car/page/HomePage.kt index e81ded8..d6fbc8e 100644 --- a/app/src/main/java/icu/fur93/esp32_car/page/HomePage.kt +++ b/app/src/main/java/icu/fur93/esp32_car/page/HomePage.kt @@ -1,11 +1,12 @@ package icu.fur93.esp32_car.page import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.navigation.NavHostController import icu.fur93.esp32_car.Route +import icu.fur93.esp32_car.repository.ConnectionState import icu.fur93.esp32_car.ui.card.UnconnectedStatusCard import icu.fur93.esp32_car.ui.carditem.StatusCardInfo import icu.fur93.esp32_car.ui.carditem.StatusCardInfraredStatus @@ -16,15 +17,21 @@ import icu.fur93.esp32_car.ui.component.PageTitle import icu.fur93.esp32_car.ui.component.StatusCard import icu.fur93.esp32_car.ui.layout.MainLayout import icu.fur93.esp32_car.ui.theme.LayoutContentModifier +import icu.fur93.esp32_car.viewmodel.CarViewModel @Composable -fun HomePage(navController: NavHostController) { +fun HomePage(navController: NavHostController, viewModel: CarViewModel) { + val connectionState by viewModel.connectionState.collectAsState() + MainLayout( content = { - Column (LayoutContentModifier) { + Column(LayoutContentModifier) { PageTitle("标题还没想好") - HomePageStatusCard() -// UnconnectedStatusCard() + if (connectionState == ConnectionState.CONNECTED) { + HomePageStatusCard(viewModel) + } else { + UnconnectedStatusCard(viewModel) + } } }, bottomBar = { @@ -37,14 +44,16 @@ fun HomePage(navController: NavHostController) { } @Composable -fun HomePageStatusCard() { +fun HomePageStatusCard(viewModel: CarViewModel) { + val deviceInfo by viewModel.deviceInfo.collectAsState() + val carState by viewModel.carState.collectAsState() + StatusCard( cardItems = listOf( - StatusCardInfo(), - StatusCardMotorStatus(), - StatusCardInfraredStatus(), - StatusCardUltrasoundStatus() + StatusCardInfo(deviceInfo), + StatusCardMotorStatus(carState), + StatusCardInfraredStatus(carState.infraredState), + StatusCardUltrasoundStatus(carState.ultrasoundState) ) ) } - diff --git a/app/src/main/java/icu/fur93/esp32_car/page/LegacyControlPage.kt b/app/src/main/java/icu/fur93/esp32_car/page/LegacyControlPage.kt.old similarity index 99% rename from app/src/main/java/icu/fur93/esp32_car/page/LegacyControlPage.kt rename to app/src/main/java/icu/fur93/esp32_car/page/LegacyControlPage.kt.old index 740f00b..7fc05dd 100644 --- a/app/src/main/java/icu/fur93/esp32_car/page/LegacyControlPage.kt +++ b/app/src/main/java/icu/fur93/esp32_car/page/LegacyControlPage.kt.old @@ -29,8 +29,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp -import icu.fur93.esp32_car.CarController -import icu.fur93.esp32_car.LogDirection import icu.fur93.esp32_car.ui.component.Joystick import icu.fur93.esp32_car.ui.component.JoystickState import kotlinx.coroutines.delay diff --git a/app/src/main/java/icu/fur93/esp32_car/page/SettingsPage.kt b/app/src/main/java/icu/fur93/esp32_car/page/SettingsPage.kt index fde5ee2..78b13fb 100644 --- a/app/src/main/java/icu/fur93/esp32_car/page/SettingsPage.kt +++ b/app/src/main/java/icu/fur93/esp32_car/page/SettingsPage.kt @@ -1,27 +1,39 @@ package icu.fur93.esp32_car.page +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.navigation.NavHostController import icu.fur93.esp32_car.R import icu.fur93.esp32_car.Route +import icu.fur93.esp32_car.repository.BluetoothRepositoryImpl +import icu.fur93.esp32_car.repository.ConnectionState import icu.fur93.esp32_car.ui.component.BottomNavigationBar import icu.fur93.esp32_car.ui.component.PageTitle +import icu.fur93.esp32_car.ui.dialog.BleDeviceScanDialog import icu.fur93.esp32_car.ui.layout.MainLayout import icu.fur93.esp32_car.ui.theme.LayoutContentPadding import icu.fur93.esp32_car.ui.theme.LayoutTopPadding +import icu.fur93.esp32_car.viewmodel.CarViewModel @Composable -fun SettingsPage(navController: NavHostController) { +fun SettingsPage(navController: NavHostController, viewModel: CarViewModel) { MainLayout( content = { Column { @@ -34,7 +46,7 @@ fun SettingsPage(navController: NavHostController) { ) { PageTitle("设置") } - SettingsList() + SettingsList(viewModel) } }, bottomBar = { @@ -47,16 +59,10 @@ fun SettingsPage(navController: NavHostController) { } @Composable -fun SettingsList() { +fun SettingsList(viewModel: CarViewModel) { LazyColumn { item { - ListItem( - headlineContent = { Text("连接设备") }, - supportingContent = { Text("已连接") }, - trailingContent = { - Icon(ImageVector.vectorResource(R.drawable.arrow_right_24), "连接设备") - } - ) + SettingConnectDeviceItem(viewModel) } item { ListItem( @@ -80,5 +86,74 @@ fun SettingsList() { } ) } + item { + ListItem( + headlineContent = { Text("PID 参数") }, + trailingContent = { + Icon(ImageVector.vectorResource(R.drawable.arrow_right_24), "PID 参数") + } + ) + } + } +} + +@Composable +fun SettingConnectDeviceItem(viewModel: CarViewModel) { + var showScanDialog by remember { mutableStateOf(false) } + var showDisconnectDialog by remember { mutableStateOf(false) } + + val connectionState by viewModel.connectionState.collectAsState() + val deviceInfo by viewModel.deviceInfo.collectAsState() + + + ListItem( + headlineContent = { Text("连接设备") }, + supportingContent = { if (connectionState == ConnectionState.CONNECTED) Text("已连接") else Text("未连接") }, + trailingContent = { + Icon( + ImageVector.vectorResource(R.drawable.arrow_right_24), + contentDescription = "连接设备" + ) + }, + modifier = Modifier.clickable { + if (connectionState == ConnectionState.CONNECTED) { + showDisconnectDialog = true + } else if (connectionState == ConnectionState.DISCONNECTED) { + showScanDialog = true + } + } + ) + + // 扫描设备对话框 + BleDeviceScanDialog( + isVisible = showScanDialog, + onDismiss = { showScanDialog = false }, + viewModel = viewModel + ) + + // 断开连接确认对话框 + if (showDisconnectDialog) { + AlertDialog( + onDismissRequest = { showDisconnectDialog = false }, + title = { Text("断开连接") }, + text = { Text("确定要断开与 ${deviceInfo.name} 的连接吗?") }, + confirmButton = { + TextButton( + onClick = { + (viewModel.repository as BluetoothRepositoryImpl).disconnect() + showDisconnectDialog = false + } + ) { + Text("确定") + } + }, + dismissButton = { + TextButton( + onClick = { showDisconnectDialog = false } + ) { + Text("取消") + } + } + ) } } \ No newline at end of file diff --git a/app/src/main/java/icu/fur93/esp32_car/repository/BluetoothRepository.kt b/app/src/main/java/icu/fur93/esp32_car/repository/BluetoothRepository.kt new file mode 100644 index 0000000..9eebbed --- /dev/null +++ b/app/src/main/java/icu/fur93/esp32_car/repository/BluetoothRepository.kt @@ -0,0 +1,245 @@ +package icu.fur93.esp32_car.repository + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import icu.fur93.esp32_car.entity.BleDevice +import icu.fur93.esp32_car.viewmodel.CarCommands +import icu.fur93.esp32_car.viewmodel.CarState +import icu.fur93.esp32_car.viewmodel.LogDirection +import icu.fur93.esp32_car.viewmodel.LogEntry +import icu.fur93.esp32_car.viewmodel.MotorState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +// 蓝牙通信接口 +interface BluetoothRepository { + fun sendCommand(command: ByteArray) + fun observeCarState(): Flow + fun observeLogs(): Flow> + fun clearLogs() +} + +// 添加连接状态枚举 +enum class ConnectionState { + DISCONNECTED, + CONNECTING, + CONNECTED, + DISCONNECTING +} + +// 蓝牙通信实现 +class BluetoothRepositoryImpl( + private val context: Context, + private val scope: CoroutineScope +) : BluetoothRepository { + private var bluetoothGatt: BluetoothGatt? = null + private var rxCharacteristic: BluetoothGattCharacteristic? = null + + private val _carState = MutableStateFlow(CarState()) + private val _logs = MutableStateFlow>(emptyList()) + + private val bluetoothAdapter: BluetoothAdapter? = + (context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter + + private val _scanResults = MutableStateFlow>(emptyList()) + val scanResults: StateFlow> = _scanResults.asStateFlow() + + private val _isScanning = MutableStateFlow(false) + val isScanning: StateFlow = _isScanning.asStateFlow() + + // 连接状态流 + private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) + val connectionState: StateFlow = _connectionState.asStateFlow() + + private val scanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + val bleDevice = BleDevice( + device = result.device, + rssi = result.rssi, + scanRecord = result.scanRecord?.bytes + ) + + val currentList = _scanResults.value.toMutableList() + val index = currentList.indexOfFirst { it.device.address == bleDevice.device.address } + + if (index >= 0) { + currentList[index] = bleDevice + } else { + currentList.add(bleDevice) + } + + // 按信号强度排序 + _scanResults.value = currentList.sortedByDescending { it.rssi } + } + } + + @SuppressLint("MissingPermission") + fun startScan() { + if (_isScanning.value) return + + _scanResults.value = emptyList() + _isScanning.value = true + + val scanner = bluetoothAdapter?.bluetoothLeScanner + val settings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + + scanner?.startScan(null, settings, scanCallback) + + // 10秒后自动停止扫描 + scope.launch { + delay(10000) + stopScan() + } + } + + @SuppressLint("MissingPermission") + fun stopScan() { + if (!_isScanning.value) return + + _isScanning.value = false + bluetoothAdapter?.bluetoothLeScanner?.stopScan(scanCallback) + } + + @SuppressLint("MissingPermission") + fun connectToDevice(device: BluetoothDevice) { + _connectionState.value = ConnectionState.CONNECTING + + device.connectGatt( + context, + false, + object : BluetoothGattCallback() { + override fun onConnectionStateChange( + gatt: BluetoothGatt, + status: Int, + newState: Int + ) { + when (newState) { + BluetoothProfile.STATE_CONNECTED -> { + bluetoothGatt = gatt + _connectionState.value = ConnectionState.CONNECTED + scope.launch(Dispatchers.Main) { + gatt.discoverServices() + } + } + BluetoothProfile.STATE_CONNECTING -> { + _connectionState.value = ConnectionState.CONNECTING + } + BluetoothProfile.STATE_DISCONNECTING -> { + _connectionState.value = ConnectionState.DISCONNECTING + } + BluetoothProfile.STATE_DISCONNECTED -> { + bluetoothGatt = null + rxCharacteristic = null + _connectionState.value = ConnectionState.DISCONNECTED + } + } + } + + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + if (status == BluetoothGatt.GATT_SUCCESS) { + // 找到特定的服务和特征 + gatt.services?.forEach { service -> + service.characteristics?.forEach { characteristic -> + // 这里需要根据你的设备具体的UUID来匹配 + if (characteristic.uuid.toString() == "YOUR_CHARACTERISTIC_UUID") { + rxCharacteristic = characteristic + } + } + } + } + } + + override fun onCharacteristicChanged( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray + ) { + onReceivePacket(value) + } + } + ) + } + + @SuppressLint("MissingPermission") + override fun sendCommand(command: ByteArray) { + rxCharacteristic?.let { characteristic -> + characteristic.value = command + bluetoothGatt?.writeCharacteristic(characteristic) + addLog(LogDirection.SEND, command) + } + } + + override fun observeCarState(): Flow = _carState.asStateFlow() + override fun observeLogs(): Flow> = _logs.asStateFlow() + override fun clearLogs() { + _logs.value = emptyList() + } + + private fun addLog(direction: LogDirection, data: ByteArray) { + _logs.value += LogEntry( + direction = direction, + data = data.joinToString(" ") { "0x%02X".format(it) } + ) + } + + fun onReceivePacket(packet: ByteArray) { + addLog(LogDirection.RECEIVE, packet) + parsePacket(packet) + } + + private fun parsePacket(packet: ByteArray) { + if (!isValidPacket(packet)) return + + when (packet[2].toUByte().toUInt()) { + CarCommands.CMD_STATUS_MOTOR -> updateMotorStatus(packet) + } + } + + private fun isValidPacket(packet: ByteArray): Boolean = + packet[0].toUByte() == CarCommands.PACKET_R_HEAD.toUByte() && + packet.size == packet[1].toUByte().toInt() && + packet[packet[1].toUByte().toInt() - 1].toUByte() == CarCommands.PACKET_R_TAIL.toUByte() + + private fun updateMotorStatus(packet: ByteArray) { + val data = packet.sliceArray(3 until packet[1].toUByte().toInt() - 1) + _carState.value = _carState.value.copy( + motorAState = createMotorState(data, 0), + motorBState = createMotorState(data, 2), + motorCState = createMotorState(data, 4), + motorDState = createMotorState(data, 6) + ) + } + + private fun createMotorState(data: ByteArray, offset: Int) = MotorState( + pwm = data[offset + 1].toUByte().toUInt(), + in1 = data[offset].toUByte().toUInt() and 1u, + in2 = (data[offset].toUByte().toUInt() shr 1) and 1u + ) + + // 断开连接方法 + @SuppressLint("MissingPermission") + fun disconnect() { + bluetoothGatt?.let { gatt -> + _connectionState.value = ConnectionState.DISCONNECTING + gatt.disconnect() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/icu/fur93/esp32_car/ui/card/UnconnectedStatusCard.kt b/app/src/main/java/icu/fur93/esp32_car/ui/card/UnconnectedStatusCard.kt index 1816857..0477f42 100644 --- a/app/src/main/java/icu/fur93/esp32_car/ui/card/UnconnectedStatusCard.kt +++ b/app/src/main/java/icu/fur93/esp32_car/ui/card/UnconnectedStatusCard.kt @@ -3,12 +3,20 @@ package icu.fur93.esp32_car.ui.card import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import icu.fur93.esp32_car.ui.component.CardItem import icu.fur93.esp32_car.ui.component.StatusCard import icu.fur93.esp32_car.ui.component.StatusCardItemText +import icu.fur93.esp32_car.ui.dialog.BleDeviceScanDialog +import icu.fur93.esp32_car.viewmodel.CarViewModel @Composable -fun UnconnectedStatusCard() { +fun UnconnectedStatusCard(viewModel: CarViewModel) { + var showScanDialog by remember { mutableStateOf(false) } + StatusCard( cardItems = listOf( CardItem( @@ -21,11 +29,17 @@ fun UnconnectedStatusCard() { bottomControl = { Button( onClick = { - + showScanDialog = true } ) { Text("连接设备") } } ) + + BleDeviceScanDialog( + isVisible = showScanDialog, + onDismiss = { showScanDialog = false }, + viewModel = viewModel + ) } \ No newline at end of file diff --git a/app/src/main/java/icu/fur93/esp32_car/ui/carditem/StatusCardInfo.kt b/app/src/main/java/icu/fur93/esp32_car/ui/carditem/StatusCardInfo.kt index 0d3db94..6ac5ebb 100644 --- a/app/src/main/java/icu/fur93/esp32_car/ui/carditem/StatusCardInfo.kt +++ b/app/src/main/java/icu/fur93/esp32_car/ui/carditem/StatusCardInfo.kt @@ -2,18 +2,19 @@ package icu.fur93.esp32_car.ui.carditem import android.annotation.SuppressLint import androidx.compose.runtime.Composable +import icu.fur93.esp32_car.entity.ConnectedDeviceInfo import icu.fur93.esp32_car.ui.component.CardItem import icu.fur93.esp32_car.ui.component.StatusCardItemText @SuppressLint("ComposableNaming") @Composable -fun StatusCardInfo () : CardItem { +fun StatusCardInfo(deviceInfo: ConnectedDeviceInfo) : CardItem { return CardItem( title = "当前连接", content = { - StatusCardItemText("名称 Name") - StatusCardItemText("Mac XX:XX:XX:XX:XX:XX") - StatusCardItemText("版本 001") + StatusCardItemText("名称 ${deviceInfo.name}") + StatusCardItemText("Mac ${deviceInfo.address}") + StatusCardItemText("版本 ${deviceInfo.version}") } ) } \ No newline at end of file diff --git a/app/src/main/java/icu/fur93/esp32_car/ui/carditem/StatusCardInfraredStatus.kt b/app/src/main/java/icu/fur93/esp32_car/ui/carditem/StatusCardInfraredStatus.kt index ef94e8f..037e496 100644 --- a/app/src/main/java/icu/fur93/esp32_car/ui/carditem/StatusCardInfraredStatus.kt +++ b/app/src/main/java/icu/fur93/esp32_car/ui/carditem/StatusCardInfraredStatus.kt @@ -8,20 +8,25 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import icu.fur93.esp32_car.ui.component.CardItem import icu.fur93.esp32_car.ui.component.StatusCardItemText +import icu.fur93.esp32_car.viewmodel.InfraredState @SuppressLint("ComposableNaming") @Composable -fun StatusCardInfraredStatus () : CardItem { +fun StatusCardInfraredStatus(infraredState: InfraredState): CardItem { return CardItem( title = "红外模块状态", content = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - listOf(0, 0, 0, 0, 0).forEach { status -> - StatusCardItemText(status.toString()) + if (infraredState.enable) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + infraredState.statusList.forEach { status -> + StatusCardItemText(status.toString()) + } } + } else { + StatusCardItemText("未启用") } } ) diff --git a/app/src/main/java/icu/fur93/esp32_car/ui/carditem/StatusCardUltrasoundStatus.kt b/app/src/main/java/icu/fur93/esp32_car/ui/carditem/StatusCardUltrasoundStatus.kt index ac46593..32542fa 100644 --- a/app/src/main/java/icu/fur93/esp32_car/ui/carditem/StatusCardUltrasoundStatus.kt +++ b/app/src/main/java/icu/fur93/esp32_car/ui/carditem/StatusCardUltrasoundStatus.kt @@ -8,22 +8,27 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import icu.fur93.esp32_car.ui.component.CardItem import icu.fur93.esp32_car.ui.component.StatusCardItemText +import icu.fur93.esp32_car.viewmodel.UltrasoundState @SuppressLint("ComposableNaming") @Composable -fun StatusCardUltrasoundStatus () : CardItem { +fun StatusCardUltrasoundStatus(ultrasoundState: UltrasoundState): CardItem { return CardItem( title = "超声波状态", content = { - Row( - modifier = Modifier.fillMaxWidth() - ) { - Column(Modifier.weight(1f)) { - StatusCardItemText("舵机角度 90") - } - Column(Modifier.weight(1f)) { - StatusCardItemText("舵机角度 20mm") + if (ultrasoundState.enable) { + Row( + modifier = Modifier.fillMaxWidth() + ) { + Column(Modifier.weight(1f)) { + StatusCardItemText("舵机角度 ${ultrasoundState.servoAngle}") + } + Column(Modifier.weight(1f)) { + StatusCardItemText("障碍距离 ${ultrasoundState.distance}") + } } + } else { + StatusCardItemText("未启用") } } ) diff --git a/app/src/main/java/icu/fur93/esp32_car/ui/carditem/StautsCardMotorStatus.kt b/app/src/main/java/icu/fur93/esp32_car/ui/carditem/StautsCardMotorStatus.kt index bcdc6e5..1601122 100644 --- a/app/src/main/java/icu/fur93/esp32_car/ui/carditem/StautsCardMotorStatus.kt +++ b/app/src/main/java/icu/fur93/esp32_car/ui/carditem/StautsCardMotorStatus.kt @@ -6,10 +6,11 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.runtime.Composable import icu.fur93.esp32_car.ui.component.CardItem import icu.fur93.esp32_car.ui.component.StatusCardItemText +import icu.fur93.esp32_car.viewmodel.CarState @SuppressLint("ComposableNaming") @Composable -fun StatusCardMotorStatus () : CardItem { +fun StatusCardMotorStatus (carState: CarState) : CardItem { return CardItem( title = "电机状态", content = { @@ -17,16 +18,16 @@ fun StatusCardMotorStatus () : CardItem { columns = GridCells.Fixed(2) ) { item { - StatusCardItemText("A 0 1 255") + StatusCardItemText("A ${carState.motorAState.in1} ${carState.motorAState.in2} ${carState.motorAState.pwm}") } item { - StatusCardItemText("D 0 1 255") + StatusCardItemText("D ${carState.motorDState.in1} ${carState.motorDState.in2} ${carState.motorDState.pwm}") } item { - StatusCardItemText("B 1 0 255") + StatusCardItemText("B ${carState.motorBState.in1} ${carState.motorBState.in2} ${carState.motorBState.pwm}") } item { - StatusCardItemText("C 1 0 255") + StatusCardItemText("C ${carState.motorCState.in1} ${carState.motorCState.in2} ${carState.motorCState.pwm}") } } } diff --git a/app/src/main/java/icu/fur93/esp32_car/ui/dialog/BleDeviceScanDialog.kt b/app/src/main/java/icu/fur93/esp32_car/ui/dialog/BleDeviceScanDialog.kt new file mode 100644 index 0000000..6f38008 --- /dev/null +++ b/app/src/main/java/icu/fur93/esp32_car/ui/dialog/BleDeviceScanDialog.kt @@ -0,0 +1,118 @@ +package icu.fur93.esp32_car.ui.dialog + +import android.annotation.SuppressLint +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import icu.fur93.esp32_car.entity.BleDevice +import icu.fur93.esp32_car.viewmodel.CarViewModel + +@Composable +fun BleDeviceScanDialog( + isVisible: Boolean, + onDismiss: () -> Unit, + viewModel: CarViewModel +) { + val scanResults by viewModel.scanResults.collectAsState() + val isScanning by viewModel.isScanning.collectAsState() + + if (isVisible) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("扫描蓝牙设备") + if (isScanning) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } + } + }, + text = { + LazyColumn { + scanResults.forEach { bleDevice -> + item { + DeviceItem( + bleDevice = bleDevice, + onClick = { + viewModel.connectToDevice(bleDevice.device) + onDismiss() + } + ) + } + } + + } + }, + confirmButton = { + TextButton( + onClick = { + if (isScanning) viewModel.stopScan() + else viewModel.startScan() + } + ) { + Text(if (isScanning) "停止扫描" else "开始扫描") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("取消") + } + } + ) + } +} + +@SuppressLint("MissingPermission") +@Composable +private fun DeviceItem( + bleDevice: BleDevice, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = bleDevice.device.name ?: "Unknown Device", + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = bleDevice.device.address, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = "${bleDevice.rssi} dBm", + style = MaterialTheme.typography.bodyMedium + ) + } +} diff --git a/app/src/main/java/icu/fur93/esp32_car/viewmodel/CarViewModel.kt b/app/src/main/java/icu/fur93/esp32_car/viewmodel/CarViewModel.kt new file mode 100644 index 0000000..d2f0180 --- /dev/null +++ b/app/src/main/java/icu/fur93/esp32_car/viewmodel/CarViewModel.kt @@ -0,0 +1,195 @@ +package icu.fur93.esp32_car.viewmodel + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import icu.fur93.esp32_car.entity.BleDevice +import icu.fur93.esp32_car.entity.ConnectedDeviceInfo +import icu.fur93.esp32_car.repository.BluetoothRepository +import icu.fur93.esp32_car.repository.BluetoothRepositoryImpl +import icu.fur93.esp32_car.repository.ConnectionState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +// 数据模型 +data class MotorState( + val pwm: UInt = 0u, + val in1: UInt = 0u, + val in2: UInt = 0u +) + +data class InfraredState( + val enable: Boolean = false, + val statusList: List = listOf() +) + +data class UltrasoundState( + val enable: Boolean = false, + val servoAngle: UInt = 0u, + val distance: UInt = 0u +) + +data class CarState( + val controlState: CarControlState = CarControlState(), + val motorAState: MotorState = MotorState(), + val motorBState: MotorState = MotorState(), + val motorCState: MotorState = MotorState(), + val motorDState: MotorState = MotorState(), + val infraredState: InfraredState = InfraredState(), + val ultrasoundState: UltrasoundState = UltrasoundState() +) + +data class CarControlState( + val speed: UInt = 0u, + val direction: UInt = 0u +) + +data class LogEntry( + val timestamp: Long = System.currentTimeMillis(), + val direction: LogDirection, + val data: String +) + +enum class LogDirection { + SEND, + RECEIVE +} + +// 命令常量 +object CarCommands { + const val PACKET_T_HEAD = 0x00u + const val PACKET_T_TAIL = 0xFFu + const val PACKET_R_HEAD = 0x01u + const val PACKET_R_TAIL = 0xFEu + const val PACKET_MAX_LENGTH = 32u + + const val CMD_GET_BT_STATUS = 0x10u + const val CMD_GET_SPIFFS_STATUS = 0x11u + const val CMD_GET_DISTANCE = 0x12u + const val CMD_MOTOR_MOVE_CONTROL = 0x20u + const val CMD_MOTOR_STEER_CONTROL = 0x21u + const val CMD_MOTOR_SINGLE_CONTROL = 0x22u + const val CMD_MOTOR_ROTATE_CONTROL = 0x23u + const val CMD_MOTOR_XYR_CONTROL = 0x24u + const val CMD_DEMO_PID = 0xf0u + const val CMD_DEMO_PATH = 0xf1u + const val CMD_STATUS_MOTOR = 0xE0u +} + + +// 用例 +class CarControlUseCase( + private val repository: BluetoothRepository +) { + fun moveForward(speed: Int = 255) = repository.sendCommand( + buildCommand(CarCommands.CMD_MOTOR_MOVE_CONTROL, 0x01, speed) + ) + + fun moveBackward(speed: Int = 255) = repository.sendCommand( + buildCommand(CarCommands.CMD_MOTOR_MOVE_CONTROL, 0x02, speed) + ) + + fun turnLeft(speed: Int = 255) = repository.sendCommand( + buildCommand(CarCommands.CMD_MOTOR_STEER_CONTROL, 0x00, speed) + ) + + fun turnRight(speed: Int = 255) = repository.sendCommand( + buildCommand(CarCommands.CMD_MOTOR_STEER_CONTROL, 0x01, speed) + ) + + fun stop() = repository.sendCommand( + buildCommand(CarCommands.CMD_MOTOR_MOVE_CONTROL, 0x00, 0x00) + ) + + fun sendXYR(x: Int, y: Int, r: Int) = repository.sendCommand( + byteArrayOf( + CarCommands.PACKET_T_HEAD.toByte(), + 0x07, + CarCommands.CMD_MOTOR_XYR_CONTROL.toByte(), + x.toByte(), + y.toByte(), + r.toByte(), + CarCommands.PACKET_T_TAIL.toByte() + ) + ) + + private fun buildCommand(cmd: UInt, param1: Int, param2: Int) = byteArrayOf( + CarCommands.PACKET_T_HEAD.toByte(), + 0x06, + cmd.toByte(), + param1.toByte(), + param2.toByte(), + CarCommands.PACKET_T_TAIL.toByte() + ) +} + +class CarViewModel( + private val carControlUseCase: CarControlUseCase, + val repository: BluetoothRepository +) : ViewModel() { + val scanResults: StateFlow> = + (repository as BluetoothRepositoryImpl).scanResults + + val isScanning: StateFlow = + (repository as BluetoothRepositoryImpl).isScanning + + fun startScan() = (repository as BluetoothRepositoryImpl).startScan() + fun stopScan() = (repository as BluetoothRepositoryImpl).stopScan() + fun connectToDevice(device: BluetoothDevice) { + updateDeviceInfo(device) + (repository as BluetoothRepositoryImpl).connectToDevice(device) + } + + val carState: StateFlow = repository.observeCarState() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), CarState()) + + val logs: StateFlow> = repository.observeLogs() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + + // 设备信息状态 + private val _deviceInfo = MutableStateFlow(ConnectedDeviceInfo()) + val deviceInfo: StateFlow = _deviceInfo.asStateFlow() + + val connectionState: StateFlow = + (repository as BluetoothRepositoryImpl).connectionState + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = ConnectionState.DISCONNECTED + ) + + init { + // 监听连接状态变化 + viewModelScope.launch { + connectionState.collect { state -> + if (state == ConnectionState.DISCONNECTED) { + // 断开连接时清空设备信息 + _deviceInfo.value = ConnectedDeviceInfo() + } + } + } + } + + // 更新设备信息的方法 + @SuppressLint("MissingPermission") + fun updateDeviceInfo(device: BluetoothDevice) { + _deviceInfo.value = ConnectedDeviceInfo( + name = device.name ?: "未知设备", + address = device.address, + version = "001" + ) + } + + fun moveForward(speed: Int = 255) = carControlUseCase.moveForward(speed) + fun moveBackward(speed: Int = 255) = carControlUseCase.moveBackward(speed) + fun turnLeft(speed: Int = 255) = carControlUseCase.turnLeft(speed) + fun turnRight(speed: Int = 255) = carControlUseCase.turnRight(speed) + fun stop() = carControlUseCase.stop() + fun sendXYR(x: Int, y: Int, r: Int) = carControlUseCase.sendXYR(x, y, r) + fun clearLogs() = repository.clearLogs() +} \ No newline at end of file diff --git a/app/src/main/java/icu/fur93/esp32_car/viewmodel/MainViewModel.kt b/app/src/main/java/icu/fur93/esp32_car/viewmodel/MainViewModel.kt deleted file mode 100644 index ac470a3..0000000 --- a/app/src/main/java/icu/fur93/esp32_car/viewmodel/MainViewModel.kt +++ /dev/null @@ -1,2 +0,0 @@ -package icu.fur93.esp32_car.viewmodel -