Data tranfering improvement

This commit is contained in:
2024-02-09 19:28:49 +07:00
parent 3b62743481
commit 3517414ec1
16 changed files with 251 additions and 82 deletions

View File

@@ -1,23 +0,0 @@
package com.helible.pilot
// Todo: add checksum
// Todo: add arguments names
data class KMessage(
val r1: UShort,
val r2: UShort,
val r3: UShort,
val emergStop: Boolean,
val alarm: Boolean,
)
fun KMessage.toByteArray(): ByteArray {
return "$$r1;$r2;$r3;$emergStop;$alarm\r\n".encodeToByteArray()
}
fun String.toKMessage(): KMessage {
// TODO: implement
return KMessage(
0u, 0u, 0u, emergStop = false, alarm = false
)
}

View File

@@ -17,6 +17,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.helible.pilot.components.CalibrationPage
import com.helible.pilot.components.deviceScreen.DeviceControlScreen import com.helible.pilot.components.deviceScreen.DeviceControlScreen
import com.helible.pilot.components.deviceScreen.defaultDeviceActionsList import com.helible.pilot.components.deviceScreen.defaultDeviceActionsList
import com.helible.pilot.components.scannerScreen.ScannerScreen import com.helible.pilot.components.scannerScreen.ScannerScreen
@@ -33,11 +34,7 @@ import com.helible.pilot.viewmodels.SavedPreferencesImpl
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
// TODO: device screen logic
// TODO: constrain text size
// TODO: add Bluetooth telemetry...
// TODO: move text strings to resources // TODO: move text strings to resources
// TODO: review permissions logic
private val preferences by lazy { private val preferences by lazy {
SavedPreferencesImpl(getSharedPreferences(packageName, MODE_PRIVATE)) SavedPreferencesImpl(getSharedPreferences(packageName, MODE_PRIVATE))
@@ -46,6 +43,7 @@ class MainActivity : ComponentActivity() {
PreferencesViewModel(preferences) PreferencesViewModel(preferences)
} }
@ExperimentalStdlibApi
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
@@ -68,14 +66,6 @@ class MainActivity : ComponentActivity() {
permissionLauncher.launch() permissionLauncher.launch()
} }
LaunchedEffect(key1 = bluetoothState) {
if (bluetoothState.isConnected) {
Toast.makeText(applicationContext, "Подключение завершено", Toast.LENGTH_SHORT)
.show()
}
}
val navController = rememberNavController() val navController = rememberNavController()
LaunchedEffect(key1 = bluetoothState.errorMessage) { LaunchedEffect(key1 = bluetoothState.errorMessage) {
@@ -168,7 +158,9 @@ class MainActivity : ComponentActivity() {
} }
composable("imu_calibration/{title}") composable("imu_calibration/{title}")
{ backStackEntry -> { backStackEntry ->
NotImplementedPage( CalibrationPage(
deviceStatus = bluetoothState.deviceState?.status,
startCalibration = { bluetoothViewModel.startImuCalibration() },
title = backStackEntry.arguments?.getString("title") ?: "null", title = backStackEntry.arguments?.getString("title") ?: "null",
navigateBack = { navController.popBackStack() } navigateBack = { navController.popBackStack() }
) )

View File

@@ -0,0 +1,84 @@
package com.helible.pilot.components
import android.widget.Spinner
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.helible.pilot.R
import com.helible.pilot.dataclasses.BluetoothUiState
import com.helible.pilot.dataclasses.ChangedDeviceStatus
import com.helible.pilot.dataclasses.DeviceState
import com.helible.pilot.dataclasses.DeviceStatus
@Composable
fun CalibrationPage(
deviceStatus: DeviceStatus?,
title: String,
startCalibration: () -> Unit,
navigateBack: () -> Unit
) {
BlankPage(title = title, navigateBack = navigateBack) {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = LocalContext.current.getString(R.string.calibration_description),
color = MaterialTheme.colorScheme.onSecondaryContainer,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
)
Button(
enabled = deviceStatus != DeviceStatus.IsImuCalibration,
onClick = startCalibration,
modifier = Modifier.padding(10.dp)
) {
if(deviceStatus != DeviceStatus.IsImuCalibration) {
Icon(
painter = painterResource(id = R.drawable.tune),
contentDescription = null,
modifier = Modifier.padding(3.dp)
)
Text(
text = "Начать калибровку"
)
} else {
CircularProgressIndicator ()
Text(
text = "Калибровка...",
modifier = Modifier.padding(5.dp)
)
}
}
}
}
}
@Preview
@Composable
fun CalibrationPagePreview() {
Surface {
CalibrationPage(
DeviceStatus.IsImuCalibration,
title = "Калибровка гироскопа и акселерометра",
startCalibration = {},
navigateBack = {}
)
}
}

View File

@@ -19,6 +19,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -26,6 +27,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.helible.pilot.R import com.helible.pilot.R
import com.helible.pilot.dataclasses.BluetoothUiState import com.helible.pilot.dataclasses.BluetoothUiState
import com.helible.pilot.dataclasses.DeviceStatus
import com.helible.pilot.viewmodels.AppPreferences import com.helible.pilot.viewmodels.AppPreferences
@Composable @Composable
@@ -65,7 +67,19 @@ fun DeviceBadge(
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
DeviceConnectionStatus(bluetoothUiState) DeviceConnectionStatus(bluetoothUiState)
Text(text = "Заряд батареи: 79%") if(bluetoothUiState.isConnected) {
val deviceStatus = bluetoothUiState.deviceState?.status
if (deviceStatus != null) {
Text(text = "Заряд батареи: ${bluetoothUiState.deviceState.batteryCharge}%")
if (deviceStatus == DeviceStatus.ChargeRequired) {
Text(text = "Аккумулятор разряжен", color = Color.Red)
} else {
Text(text = deviceStatus.description())
}
} else {
Text(text = "Ожиданием рукопожатия...")
}
}
} }
Box( Box(
contentAlignment = Alignment.CenterEnd, contentAlignment = Alignment.CenterEnd,

View File

@@ -51,6 +51,8 @@ fun DeviceConnectionStatus(bluetoothState: BluetoothUiState) {
.padding(2.dp) .padding(2.dp)
) )
Text("Подключение...") Text("Подключение...")
} else {
Text("Попытка подключения не удалась.")
} }
} }
} }

View File

@@ -15,8 +15,8 @@ import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import com.helible.pilot.viewmodels.BluetoothDataTransferService import com.helible.pilot.viewmodels.BluetoothDataTransferService
import com.helible.pilot.KMessage
import com.helible.pilot.dataclasses.BluetoothDeviceDomain import com.helible.pilot.dataclasses.BluetoothDeviceDomain
import com.helible.pilot.dataclasses.DeviceState
import com.helible.pilot.receivers.BluetoothAdapterStateReceiver import com.helible.pilot.receivers.BluetoothAdapterStateReceiver
import com.helible.pilot.receivers.BluetoothStateReceiver import com.helible.pilot.receivers.BluetoothStateReceiver
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -40,7 +40,7 @@ import java.util.UUID
sealed interface ConnectionResult { sealed interface ConnectionResult {
object ConnectionEstablished : ConnectionResult object ConnectionEstablished : ConnectionResult
data class TransferSucceded(val message: String) : ConnectionResult data class TransferSucceded(val message: DeviceState) : ConnectionResult
data class Error(val message: String) : ConnectionResult data class Error(val message: String) : ConnectionResult
} }
@@ -61,6 +61,7 @@ interface BluetoothController {
fun onDestroy() fun onDestroy()
} }
@ExperimentalStdlibApi
class AndroidBluetoothController(private val context: Context) : BluetoothController { class AndroidBluetoothController(private val context: Context) : BluetoothController {
private val bluetoothManager by lazy { private val bluetoothManager by lazy {
@@ -228,21 +229,26 @@ class AndroidBluetoothController(private val context: Context) : BluetoothContro
try { try {
socket.connect() socket.connect()
emit(ConnectionResult.ConnectionEstablished) emit(ConnectionResult.ConnectionEstablished)
BluetoothDataTransferService(socket).also { it -> BluetoothDataTransferService(socket).also {
dataTransferService = it dataTransferService = it
emitAll( emitAll(
it.listenForIncomingMessages() it.listenForIncomingMessages()
.map { ConnectionResult.TransferSucceded(it) } .map { deviceState ->
ConnectionResult.TransferSucceded(deviceState)
}
) )
} }
} catch (e: IOException) { } catch (e: IOException) {
socket.close() socket.close()
currentClientSocket = null currentClientSocket = null
Log.e("BluetoothController", e.toString()) Log.e("BluetoothController", "I/O exception: e")
emit(ConnectionResult.Error("Connection was interrupted")) emit(ConnectionResult.Error("Connection was interrupted"))
} }
} }
}.onCompletion { closeConnection() }.flowOn(Dispatchers.IO) }.onCompletion {
Log.i("BluetoothController", "Connection closed on flow completion.")
closeConnection()
}.flowOn(Dispatchers.IO)
} }
override suspend fun trySendMessage(message: ByteArray): Boolean { override suspend fun trySendMessage(message: ByteArray): Boolean {

View File

@@ -9,4 +9,5 @@ data class BluetoothUiState(
val errorMessage: String? = null, val errorMessage: String? = null,
val scannedBluetoothDevices: List<BluetoothDevice> = emptyList(), val scannedBluetoothDevices: List<BluetoothDevice> = emptyList(),
val pairedBluetoothDevices: List<BluetoothDevice> = emptyList(), val pairedBluetoothDevices: List<BluetoothDevice> = emptyList(),
val deviceState: DeviceState? = null
) )

View File

@@ -0,0 +1,6 @@
package com.helible.pilot.dataclasses
data class ChangedDeviceStatus(
val status: DeviceStatus
)

View File

@@ -0,0 +1,15 @@
package com.helible.pilot.dataclasses
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class DeviceState(
val status: DeviceStatus = DeviceStatus.ChargeRequired,
@Json(name = "charge") val batteryCharge: Int = 10,
val flightHeight: Float = 0f,
@Json(name = "y") val yaw: Float = 0f,
@Json(name = "p") val pitch: Float = 0f,
@Json(name = "r") val roll: Float = 0f,
@Json(name = "zIn") val zInertial: Float = 0f,
)

View File

@@ -0,0 +1,21 @@
package com.helible.pilot.dataclasses
enum class DeviceStatus {
Idle,
IsPreparingForTakeoff,
IsFlying,
IsBoarding,
IsImuCalibration,
ChargeRequired;
fun description(): String {
return when (this) {
Idle -> "Готово к работе"
IsPreparingForTakeoff -> "Подготовка к полёту"
IsFlying -> "В полёте"
IsBoarding -> "Посадка"
IsImuCalibration -> "Калибровка..."
ChargeRequired -> "Аккумулятор разряжен"
}
}
}

View File

@@ -0,0 +1,22 @@
package com.helible.pilot.dataclasses
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.ToJson
class DeviceStatusJsonAdapter {
@FromJson
fun fromJson(deviceStatus: String): DeviceStatus {
try {
val index: UInt = deviceStatus.toUInt()
return DeviceStatus.values()[index.toInt()]
} catch (e: IndexOutOfBoundsException) {
throw JsonDataException("Impossible conversation from String to DeviceStatus")
}
}
@ToJson
fun toJson(deviceStatus: DeviceStatus): String {
return DeviceStatus.values().indexOf(deviceStatus).toString()
}
}

View File

@@ -1,12 +0,0 @@
package com.helible.pilot.dataclasses
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class RotorsSpeedMessage(val r1: Short, val r2: Short, val r3: Short)
@JsonClass(generateAdapter = true)
data class EmergStopMessage(val emergStop: Boolean)
@JsonClass(generateAdapter = true)
data class AlarmStateMessage(val isAlarmOn: Boolean)

View File

@@ -2,8 +2,12 @@ package com.helible.pilot.viewmodels
import android.bluetooth.BluetoothSocket import android.bluetooth.BluetoothSocket
import android.util.Log import android.util.Log
import com.helible.pilot.KMessage import com.helible.pilot.dataclasses.DeviceState
import com.helible.pilot.toKMessage import com.helible.pilot.dataclasses.DeviceStatusJsonAdapter
import com.squareup.moshi.JsonEncodingException
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
@@ -14,25 +18,42 @@ import java.io.IOException
class TransferFailedException : IOException("Reading incoming data failed") class TransferFailedException : IOException("Reading incoming data failed")
@ExperimentalStdlibApi
class BluetoothDataTransferService( class BluetoothDataTransferService(
private val socket: BluetoothSocket, private val socket: BluetoothSocket,
) { ) {
fun listenForIncomingMessages(): Flow<String> { fun listenForIncomingMessages(): Flow<DeviceState> {
val moshi = Moshi.Builder().add(DeviceStatusJsonAdapter()).add(KotlinJsonAdapterFactory()).build()
val deviceStateMessageAdapter = moshi.adapter<DeviceState>()
return flow { return flow {
if (!socket.isConnected) if (!socket.isConnected)
return@flow return@flow
val buffer = ByteArray(128) val buffer = ByteArray(512)
while (true) { while (true) {
val byteCount: Int = try { val byteCount: Int = try {
socket.inputStream.read(buffer) socket.inputStream.read(buffer)
} catch (e: IOException) { } catch (e: IOException) {
Log.e("BluetoothController", "Failed to receive incoming data")
throw TransferFailedException() throw TransferFailedException()
} }
val strData: String = buffer.decodeToString(endIndex = byteCount)
emit( val messageData: String = buffer.decodeToString(endIndex = byteCount)
strData if (!messageData.endsWith("\n\r")) {
) Log.i("BluetoothController", "Package end isn't valid.")
Log.i("BluetoothController", "Received: ${strData.dropLast(2)}") Log.i("BluetoothController", messageData)
} else {
val messageJson = messageData.dropLast(2)
try {
val deviceState = deviceStateMessageAdapter.fromJson(messageJson)!!
emit(deviceState)
Log.i("BluetoothController", "Received: $deviceState")
} catch (e: NullPointerException) {
Log.e("BluetoothController", "Nullable message received: $messageJson")
} catch (e: JsonEncodingException) {
Log.e("BluetoothController", "Invalid message received: $messageJson")
}
}
} }
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
} }
@@ -42,10 +63,11 @@ class BluetoothDataTransferService(
try { try {
socket.outputStream.write(bytes) socket.outputStream.write(bytes)
} catch (e: IOException) { } catch (e: IOException) {
e.printStackTrace() Log.e("BluetoothController", "Failed to write message: $e")
return@withContext false return@withContext false
} }
true true
} }
} }
} }

View File

@@ -1,18 +1,18 @@
package com.helible.pilot.viewmodels package com.helible.pilot.viewmodels
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.helible.pilot.controllers.BluetoothController import com.helible.pilot.controllers.BluetoothController
import com.helible.pilot.controllers.ConnectionResult import com.helible.pilot.controllers.ConnectionResult
import com.helible.pilot.dataclasses.AlarmStateMessage
import com.helible.pilot.dataclasses.BluetoothDevice import com.helible.pilot.dataclasses.BluetoothDevice
import com.helible.pilot.dataclasses.BluetoothUiState import com.helible.pilot.dataclasses.BluetoothUiState
import com.helible.pilot.dataclasses.EmergStopMessage import com.helible.pilot.dataclasses.ChangedDeviceStatus
import com.helible.pilot.dataclasses.RotorsSpeedMessage import com.helible.pilot.dataclasses.DeviceStatus
import com.helible.pilot.dataclasses.DeviceStatusJsonAdapter
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalTime
class BluetoothViewModel( class BluetoothViewModel(
private val bluetoothController: BluetoothController, private val bluetoothController: BluetoothController,
@@ -43,10 +44,9 @@ class BluetoothViewModel(
pairedBluetoothDevices = pairedDevices pairedBluetoothDevices = pairedDevices
) )
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), _state.value) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), _state.value)
private val moshi: Moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private var deviceConnectionJob: Job? = null
private val rotorsStateMessegeAdapter = moshi.adapter(RotorsSpeedMessage::class.java) private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).add(DeviceStatusJsonAdapter()).build()
private val alarmStateMessageAdapter = moshi.adapter(AlarmStateMessage::class.java) private val newStatusMessageAdapter = moshi.adapter(ChangedDeviceStatus::class.java)
private val emergStopMessageAdapter = moshi.adapter(EmergStopMessage::class.java)
init { init {
bluetoothController.isConnected.onEach { isConnected -> bluetoothController.isConnected.onEach { isConnected ->
@@ -94,9 +94,11 @@ class BluetoothViewModel(
} }
is ConnectionResult.TransferSucceded -> { is ConnectionResult.TransferSucceded -> {
_state.update { it.copy( _state.update {
it.copy(
) } deviceState = result.message
)
}
} }
is ConnectionResult.Error -> { is ConnectionResult.Error -> {
@@ -111,19 +113,19 @@ class BluetoothViewModel(
} }
} }
.catch { throwable -> .catch { throwable ->
Log.e("BluetoothController", "Error occured while data transfer: ${throwable.message}")
bluetoothController.closeConnection() bluetoothController.closeConnection()
_state.update { it.copy( _state.update { it.copy(
isConnected = false, isConnected = false,
isConnecting = false isConnecting = false,
deviceState = null
) } ) }
} }
.launchIn(viewModelScope) .launchIn(viewModelScope)
} }
private var deviceConnectionJob: Job? = null
fun connectToDevice(device: String) { fun connectToDevice(device: String) {
if (_state.value.isConnected and _state.value.isConnecting) { if (_state.value.isConnected or _state.value.isConnecting) {
return return
} }
_state.update { it.copy(isConnecting = true) } _state.update { it.copy(isConnecting = true) }
@@ -138,7 +140,8 @@ class BluetoothViewModel(
_state.update { _state.update {
it.copy( it.copy(
isConnecting = false, isConnecting = false,
isConnected = false isConnected = false,
deviceState = null
) )
} }
} }
@@ -170,4 +173,18 @@ class BluetoothViewModel(
) )
} }
} }
fun startImuCalibration() {
viewModelScope.launch {
val message = newStatusMessageAdapter.toJson(
ChangedDeviceStatus(DeviceStatus.IsImuCalibration)
) + "\n\r"
val success = bluetoothController.trySendMessage(
message.toByteArray()
)
if(!success) {
Log.e("BluetoothVM", "Failed to start IMU calibration: $message")
}
}
}
} }

View File

@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.helible.pilot.controllers.AndroidBluetoothController import com.helible.pilot.controllers.AndroidBluetoothController
@ExperimentalStdlibApi
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
class BluetoothViewModelFactory(private val context: Context) : ViewModelProvider.Factory { class BluetoothViewModelFactory(private val context: Context) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {

View File

@@ -1,3 +1,4 @@
<resources> <resources>
<string name="app_name">Digital Pilot</string> <string name="app_name">Digital Pilot</string>
<string name="calibration_description">Расположите устройство на ровной горизонтальной поверхности, чтобы сани вертолета полностью лежали на ней. Нажмите кнопку калибровки ниже и ждите её окончания, не создавая тряски.</string>
</resources> </resources>