Compare commits

..

2 Commits

Author SHA1 Message Date
5e0f2f1bb7 Joystick & Pid Configuration
Flexible PID configuration and joysticks were added
2024-02-28 23:41:30 +07:00
3517414ec1 Data tranfering improvement 2024-02-09 19:28:49 +07:00
37 changed files with 751 additions and 114 deletions

10
.idea/deploymentTargetDropDown.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<value>
<entry key="app">
<State />
</entry>
</value>
</component>
</project>

5
.idea/gradle.xml generated
View File

@@ -4,16 +4,15 @@
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="jbr-17" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>

2
.idea/kotlinc.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.6.10" />
<option name="version" value="1.8.10" />
</component>
</project>

10
.idea/migrations.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

View File

@@ -1,8 +1,6 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp").version("1.6.10-1.0.4")
id("org.jlleitschuh.gradle.ktlint").version("12.0.3")
}
android {
@@ -65,6 +63,7 @@ dependencies {
implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1")
implementation("androidx.navigation:navigation-compose:2.6.0")
implementation("com.squareup.moshi:moshi-kotlin:1.14.0")
implementation("com.github.manalkaff:JetStick:1.2")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")

View File

@@ -40,12 +40,12 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Testblue"
android:theme="@style/Theme.Main"
tools:targetApi="31">
<activity
android:name="com.helible.pilot.MainActivity"
android:exported="true"
android:theme="@style/Theme.Testblue">
android:theme="@style/Theme.Main">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

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

@@ -6,10 +6,8 @@ import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -17,8 +15,12 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.helible.pilot.components.CalibrationPage
import com.helible.pilot.components.NotImplementedPage
import com.helible.pilot.components.console.ConsolePage
import com.helible.pilot.components.deviceScreen.DeviceControlScreen
import com.helible.pilot.components.deviceScreen.defaultDeviceActionsList
import com.helible.pilot.components.pidSettings.PidSettingsPage
import com.helible.pilot.components.scannerScreen.ScannerScreen
import com.helible.pilot.permissions.PermissionsLauncher
import com.helible.pilot.permissions.PermissionsRequest
@@ -33,11 +35,7 @@ import com.helible.pilot.viewmodels.SavedPreferencesImpl
class MainActivity : ComponentActivity() {
// TODO: device screen logic
// TODO: constrain text size
// TODO: add Bluetooth telemetry...
// TODO: move text strings to resources
// TODO: review permissions logic
private val preferences by lazy {
SavedPreferencesImpl(getSharedPreferences(packageName, MODE_PRIVATE))
@@ -46,6 +44,7 @@ class MainActivity : ComponentActivity() {
PreferencesViewModel(preferences)
}
@ExperimentalStdlibApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
@@ -68,14 +67,6 @@ class MainActivity : ComponentActivity() {
permissionLauncher.launch()
}
LaunchedEffect(key1 = bluetoothState) {
if (bluetoothState.isConnected) {
Toast.makeText(applicationContext, "Подключение завершено", Toast.LENGTH_SHORT)
.show()
}
}
val navController = rememberNavController()
LaunchedEffect(key1 = bluetoothState.errorMessage) {
@@ -151,13 +142,10 @@ class MainActivity : ComponentActivity() {
}
composable("console/{title}")
{ backStackEntry ->
NotImplementedPage(
ConsolePage(
title = backStackEntry.arguments?.getString("title") ?: "null",
navigateBack = { navController.popBackStack() }
)
Button(onClick = { bluetoothViewModel.sendHelloWorld() }) {
Text("Click me!")
}
}
composable("codeblocks/{title}")
{ backStackEntry ->
@@ -168,7 +156,9 @@ class MainActivity : ComponentActivity() {
}
composable("imu_calibration/{title}")
{ backStackEntry ->
NotImplementedPage(
CalibrationPage(
deviceStatus = bluetoothState.deviceState?.status,
startCalibration = { bluetoothViewModel.startImuCalibration() },
title = backStackEntry.arguments?.getString("title") ?: "null",
navigateBack = { navController.popBackStack() }
)
@@ -182,10 +172,20 @@ class MainActivity : ComponentActivity() {
}
composable("pid_settings/{title}")
{ backStackEntry ->
NotImplementedPage(
PidSettingsPage(
title = backStackEntry.arguments?.getString("title") ?: "null",
navigateBack = { navController.popBackStack() }
navigateBack = {
navController.popBackStack()
bluetoothViewModel.clearPidSettings()
},
requestPidSettings = { bluetoothViewModel.requestPidSettings() },
setPidSettings = {settings -> bluetoothViewModel.applyPidSettings(settings)},
deviceState = bluetoothState.deviceState
)
BackHandler {
navController.popBackStack()
bluetoothViewModel.clearPidSettings()
}
}
composable("reports/{title}")
{ backStackEntry ->

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

@@ -1,4 +1,4 @@
package com.helible.pilot
package com.helible.pilot.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth

View File

@@ -0,0 +1,33 @@
package com.helible.pilot.components.console
import android.util.Log
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.helible.pilot.components.BlankPage
import com.helible.pilot.dataclasses.DeviceStatus
import com.manalkaff.jetstick.JoyStick
@Composable
fun ConsolePage(
title: String,
navigateBack: () -> Unit
) {
BlankPage(title = title, navigateBack = navigateBack) {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
JoyStick(
Modifier.padding(30.dp),
size = 150.dp,
dotSize = 30.dp
){ x: Float, y: Float ->
Log.d("JoyStick", "$x, $y")
}
}
}
}

View File

@@ -19,6 +19,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource
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 com.helible.pilot.R
import com.helible.pilot.dataclasses.BluetoothUiState
import com.helible.pilot.dataclasses.DeviceStatus
import com.helible.pilot.viewmodels.AppPreferences
@Composable
@@ -65,7 +67,19 @@ fun DeviceBadge(
fontWeight = FontWeight.Bold
)
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(
contentAlignment = Alignment.CenterEnd,

View File

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

View File

@@ -0,0 +1,283 @@
package com.helible.pilot.components.pidSettings
import android.util.Log
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.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import com.helible.pilot.components.BlankPage
import com.helible.pilot.dataclasses.DeviceState
import com.helible.pilot.dataclasses.DeviceStatus
import com.helible.pilot.dataclasses.PidParams
import com.helible.pilot.dataclasses.PidSettings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PidSettingsPage(
title: String,
navigateBack: () -> Unit,
requestPidSettings: () -> Unit,
setPidSettings: (PidSettings) -> Unit,
deviceState: DeviceState?,
) {
BlankPage(title = title, navigateBack = navigateBack) {
var pValue by remember { mutableStateOf("") }
var iValue by remember { mutableStateOf("") }
var dValue by remember { mutableStateOf("") }
var selectedRegulator by remember { mutableStateOf(1) }
val dropdownMenuItems = listOf("PID 1", "PID 2", "PID 3")
LaunchedEffect(null) {
requestPidSettings()
}
LaunchedEffect(deviceState?.pidSettings) {
if (deviceState?.pidSettings != null) {
val pidSettings = deviceState.pidSettings
when(selectedRegulator){
1 -> {
pidSettings.p1.p.toString().also { pValue = it }
pidSettings.p1.i.toString().also { iValue = it }
pidSettings.p1.d.toString().also { dValue = it }
}
2 -> {
pidSettings.p2.p.toString().also { pValue = it }
pidSettings.p2.i.toString().also { iValue = it }
pidSettings.p2.d.toString().also { dValue = it }
}
3 -> {
pidSettings.p3.p.toString().also { pValue = it }
pidSettings.p3.i.toString().also { iValue = it }
pidSettings.p3.d.toString().also { dValue = it }
}
}
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
)
{
if (deviceState?.status != DeviceStatus.Idle) {
Text(
text = "Этот раздел доступен только с подключенным заряженным бездействующим устройством.",
textAlign = TextAlign.Center
)
} else if (deviceState.pidSettings == null) {
Column {
CircularProgressIndicator(modifier = Modifier.padding(10.dp))
Text(text = "Синхронизация...")
}
} else {
val pidSettings = deviceState.pidSettings
OutlinedDropdownMenu(
label = "ПИД регулятор",
suggestions = dropdownMenuItems,
onChange = { selected ->
Log.i("BluetoothVM", selected)
when (dropdownMenuItems.indexOf(selected)) {
0 -> {
selectedRegulator = 1
pidSettings.p1.p.toString().also { pValue = it }
pidSettings.p1.i.toString().also { iValue = it }
pidSettings.p1.d.toString().also { dValue = it }
}
1 -> {
selectedRegulator = 2
pidSettings.p2.p.toString().also { pValue = it }
pidSettings.p2.i.toString().also { iValue = it }
pidSettings.p2.d.toString().also { dValue = it }
}
2 -> {
selectedRegulator = 3
pidSettings.p3.p.toString().also { pValue = it }
pidSettings.p3.i.toString().also { iValue = it }
pidSettings.p3.d.toString().also { dValue = it }
}
}
},
modifier = Modifier.padding(10.dp)
)
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(10.dp)
) {
OutlinedTextField(
value = pValue,
onValueChange = { pValue = it },
label = { Text(text = "P") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = iValue,
onValueChange = { iValue = it },
label = { Text(text = "I") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = dValue,
onValueChange = { dValue = it },
label = { Text(text = "D") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f)
)
}
val p = pValue
val i = iValue
val d = dValue
Button(
onClick = {
when (selectedRegulator) {
1 -> {
val newPidSettings = pidSettings.copy(
p1 = PidParams(p.toFloat(), i.toFloat(), d.toFloat())
)
setPidSettings(newPidSettings)
}
2 -> {
val newPidSettings = pidSettings.copy(
p2 = PidParams(p.toFloat(), i.toFloat(), d.toFloat())
)
setPidSettings(newPidSettings)
}
3 -> {
val newPidSettings = pidSettings.copy(
p3 = PidParams(p.toFloat(), i.toFloat(), d.toFloat())
)
setPidSettings(newPidSettings)
}
}
},
enabled = isValidValue(p) && isValidValue(i) && isValidValue(d)
) {
Text(text = "Применить")
}
}
}
}
}
private fun isValidValue(k: String): Boolean {
return k.toFloatOrNull() != null && k.toFloat() >= 0f && k.toFloat() <= 2f
}
@Preview(showBackground = true)
@Composable
fun PidSettingsPreview() {
PidSettingsPage(
title = "Настройки ПИД регуляторов",
navigateBack = { },
requestPidSettings = { },
setPidSettings = {},
deviceState = DeviceState(
status = DeviceStatus.Idle,
pidSettings = PidSettings(
PidParams(1f, 1f, 1f),
PidParams(1f, 1f, 1f),
PidParams(1f, 1f, 1f)
)
)
)
}
@Preview(showBackground = true)
@Composable
fun DropdownDemo() {
OutlinedDropdownMenu(label = "", suggestions = listOf("A", "B"), onChange = {})
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OutlinedDropdownMenu(
label: String,
suggestions: List<String>,
onChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
var selectedText by remember { mutableStateOf(suggestions.first()) }
var textfieldSize by remember { mutableStateOf(Size.Zero) }
val icon = if (expanded)
Icons.Filled.KeyboardArrowUp
else
Icons.Filled.KeyboardArrowDown
Column(modifier = modifier) {
OutlinedTextField(
value = selectedText,
onValueChange = {
selectedText = it
},
modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned { coordinates ->
// This value is used to assign to the DropDown the same width
textfieldSize = coordinates.size.toSize()
},
label = { Text(label) },
trailingIcon = {
Icon(icon, "contentDescription",
Modifier.clickable { expanded = !expanded })
}
)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier
.width(with(LocalDensity.current) { textfieldSize.width.toDp() })
) {
suggestions.forEach { label ->
DropdownMenuItem(onClick = {
selectedText = label
expanded = false
onChange(selectedText)
},
text = { Text(label) }
)
}
}
}
}

View File

@@ -15,8 +15,8 @@ import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import com.helible.pilot.viewmodels.BluetoothDataTransferService
import com.helible.pilot.KMessage
import com.helible.pilot.dataclasses.BluetoothDeviceDomain
import com.helible.pilot.dataclasses.GeneralMessage
import com.helible.pilot.receivers.BluetoothAdapterStateReceiver
import com.helible.pilot.receivers.BluetoothStateReceiver
import kotlinx.coroutines.CoroutineScope
@@ -40,7 +40,7 @@ import java.util.UUID
sealed interface ConnectionResult {
object ConnectionEstablished : ConnectionResult
data class TransferSucceded(val message: String) : ConnectionResult
data class TransferSucceded(val message: GeneralMessage) : ConnectionResult
data class Error(val message: String) : ConnectionResult
}
@@ -61,6 +61,7 @@ interface BluetoothController {
fun onDestroy()
}
@ExperimentalStdlibApi
class AndroidBluetoothController(private val context: Context) : BluetoothController {
private val bluetoothManager by lazy {
@@ -228,21 +229,26 @@ class AndroidBluetoothController(private val context: Context) : BluetoothContro
try {
socket.connect()
emit(ConnectionResult.ConnectionEstablished)
BluetoothDataTransferService(socket).also { it ->
BluetoothDataTransferService(socket).also {
dataTransferService = it
emitAll(
it.listenForIncomingMessages()
.map { ConnectionResult.TransferSucceded(it) }
.map { deviceState ->
ConnectionResult.TransferSucceded(deviceState)
}
)
}
} catch (e: IOException) {
socket.close()
currentClientSocket = null
Log.e("BluetoothController", e.toString())
Log.e("BluetoothController", "I/O exception: ${e.message}")
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 {

View File

@@ -1,9 +0,0 @@
package com.helible.pilot.controllers
data class DeviceState(
val isHandshakeWaiting: Boolean = true,
val isIMUCalibrating: Boolean = false,
val flightMode: Boolean = false,
val batteryCharge: Int?,
val flightHeight: Float?
)

View File

@@ -9,4 +9,5 @@ data class BluetoothUiState(
val errorMessage: String? = null,
val scannedBluetoothDevices: 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,16 @@
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,
val pidSettings: PidSettings? = null
)

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

@@ -0,0 +1,7 @@
package com.helible.pilot.dataclasses
// This dataclass provide message content with its type without any markers
data class GeneralMessage(
val type: MessageType,
val data: String
)

View File

@@ -0,0 +1,6 @@
package com.helible.pilot.dataclasses
enum class MessageType {
UpdateMessage,
PidSettings
}

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

@@ -0,0 +1,10 @@
package com.helible.pilot.dataclasses
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PidParams (
val p: Float,
val i: Float,
val d: Float
)

View File

@@ -0,0 +1,8 @@
package com.helible.pilot.dataclasses
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PidSettingRequiredMessage (
val pidSettingOpened: Boolean = true
)

View File

@@ -0,0 +1,10 @@
package com.helible.pilot.dataclasses
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PidSettings (
val p1: PidParams,
val p2: PidParams,
val p3: PidParams
)

View File

@@ -0,0 +1,6 @@
package com.helible.pilot.dataclasses
data class StickPosition (
val x: Float,
val y: Float
)

View File

@@ -0,0 +1,5 @@
package com.helible.pilot.exceptions
import java.io.IOException
class TransferFailedException : IOException("Reading incoming data failed")

View File

@@ -2,8 +2,17 @@ package com.helible.pilot.viewmodels
import android.bluetooth.BluetoothSocket
import android.util.Log
import com.helible.pilot.KMessage
import com.helible.pilot.toKMessage
import com.helible.pilot.dataclasses.DeviceState
import com.helible.pilot.dataclasses.DeviceStatusJsonAdapter
import com.helible.pilot.dataclasses.GeneralMessage
import com.helible.pilot.dataclasses.MessageType
import com.helible.pilot.dataclasses.PidSettings
import com.helible.pilot.exceptions.TransferFailedException
import com.squareup.moshi.JsonDataException
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.flow.Flow
import kotlinx.coroutines.flow.flow
@@ -12,27 +21,37 @@ import kotlinx.coroutines.withContext
import java.io.IOException
class TransferFailedException : IOException("Reading incoming data failed")
const val maxPackageSize = 512; // bytes
@ExperimentalStdlibApi
class BluetoothDataTransferService(
private val socket: BluetoothSocket,
) {
fun listenForIncomingMessages(): Flow<String> {
fun listenForIncomingMessages(): Flow<GeneralMessage> {
return flow {
if (!socket.isConnected)
return@flow
val buffer = ByteArray(128)
val buffer = ByteArray(maxPackageSize)
while (true) {
val byteCount: Int = try {
socket.inputStream.read(buffer)
} catch (e: IOException) {
Log.e("BluetoothController", "Failed to receive incoming data")
throw TransferFailedException()
}
val strData: String = buffer.decodeToString(endIndex = byteCount)
emit(
strData
)
Log.i("BluetoothController", "Received: ${strData.dropLast(2)}")
var messageData: String = buffer.decodeToString(endIndex = byteCount)
val messageType: MessageType? = MessageType.values()
.elementAtOrNull(messageData.split(";")[0].toInt())
if (messageData.endsWith("\n\r") && messageType != null) {
messageData = messageData.dropLast(2).split(";")[1]
emit(GeneralMessage(messageType, messageData))
Log.d("BluetoothController", "Received: $messageData")
} else {
Log.i("BluetoothController", "Package end isn't valid.")
Log.i("BluetoothController", messageData)
}
}
}.flowOn(Dispatchers.IO)
}
@@ -42,10 +61,11 @@ class BluetoothDataTransferService(
try {
socket.outputStream.write(bytes)
} catch (e: IOException) {
e.printStackTrace()
Log.e("BluetoothController", "Failed to write message: $e")
return@withContext false
}
true
}
}
}

View File

@@ -1,18 +1,24 @@
package com.helible.pilot.viewmodels
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.helible.pilot.controllers.BluetoothController
import com.helible.pilot.controllers.ConnectionResult
import com.helible.pilot.dataclasses.AlarmStateMessage
import com.helible.pilot.dataclasses.BluetoothDevice
import com.helible.pilot.dataclasses.BluetoothUiState
import com.helible.pilot.dataclasses.EmergStopMessage
import com.helible.pilot.dataclasses.RotorsSpeedMessage
import com.helible.pilot.dataclasses.ChangedDeviceStatus
import com.helible.pilot.dataclasses.DeviceState
import com.helible.pilot.dataclasses.DeviceStatus
import com.helible.pilot.dataclasses.DeviceStatusJsonAdapter
import com.helible.pilot.dataclasses.MessageType
import com.helible.pilot.dataclasses.PidSettingRequiredMessage
import com.helible.pilot.dataclasses.PidSettings
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.JsonEncodingException
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -43,10 +49,12 @@ class BluetoothViewModel(
pairedBluetoothDevices = pairedDevices
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), _state.value)
private val moshi: Moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val rotorsStateMessegeAdapter = moshi.adapter(RotorsSpeedMessage::class.java)
private val alarmStateMessageAdapter = moshi.adapter(AlarmStateMessage::class.java)
private val emergStopMessageAdapter = moshi.adapter(EmergStopMessage::class.java)
private var deviceConnectionJob: Job? = null
private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).add(DeviceStatusJsonAdapter()).build()
private val statusMessageAdapter = moshi.adapter(ChangedDeviceStatus::class.java)
private val deviceStateMessageAdapter = moshi.adapter(DeviceState::class.java)
private val pidSittingsMessageAdapter = moshi.adapter(PidSettings::class.java)
private val pidSittingsRequiredMessageAdapter = moshi.adapter(PidSettingRequiredMessage::class.java)
init {
bluetoothController.isConnected.onEach { isConnected ->
@@ -94,9 +102,36 @@ class BluetoothViewModel(
}
is ConnectionResult.TransferSucceded -> {
_state.update { it.copy(
try {
when (result.message.type) {
MessageType.PidSettings -> {
val newPidSettings =
pidSittingsMessageAdapter.fromJson(result.message.data)
_state.update {
it.copy(
deviceState = it.deviceState?.copy(pidSettings = newPidSettings)
)
}
}
) }
MessageType.UpdateMessage -> {
val newDeviceState =
deviceStateMessageAdapter.fromJson(result.message.data)
if (newDeviceState != null) {
_state.update {
it.copy(
deviceState = newDeviceState.copy(pidSettings = it.deviceState?.pidSettings)
)
}
}
}
}
} catch (e: JsonDataException) {
Log.e("BluetoothVM", "Failed to parse message: ${result.message.data}")
} catch (e: JsonEncodingException) {
Log.e("BluetoothVM", "Failed to decode message: ${result.message.data}")
}
}
is ConnectionResult.Error -> {
@@ -111,19 +146,24 @@ class BluetoothViewModel(
}
}
.catch { throwable ->
Log.e(
"BluetoothController",
"Error occured while data transfer: ${throwable.message}"
)
bluetoothController.closeConnection()
_state.update { it.copy(
_state.update {
it.copy(
isConnected = false,
isConnecting = false
) }
isConnecting = false,
deviceState = null
)
}
}
.launchIn(viewModelScope)
}
private var deviceConnectionJob: Job? = null
fun connectToDevice(device: String) {
if (_state.value.isConnected and _state.value.isConnecting) {
if (_state.value.isConnected or _state.value.isConnecting) {
return
}
_state.update { it.copy(isConnecting = true) }
@@ -138,7 +178,8 @@ class BluetoothViewModel(
_state.update {
it.copy(
isConnecting = false,
isConnected = false
isConnected = false,
deviceState = null
)
}
}
@@ -170,4 +211,62 @@ class BluetoothViewModel(
)
}
}
fun startImuCalibration() {
viewModelScope.launch {
val message = statusMessageAdapter.toJson(
ChangedDeviceStatus(DeviceStatus.IsImuCalibration)
) + "\n\r"
val isSuccess = bluetoothController.trySendMessage(
message.toByteArray()
)
if(!isSuccess) {
Log.e("BluetoothVM", "Failed to start IMU calibration: $message")
} else {
_state.update {
it.copy(
deviceState = it.deviceState?.copy(status = DeviceStatus.IsImuCalibration)
)
}
}
}
}
fun requestPidSettings() {
viewModelScope.launch {
val message = pidSittingsRequiredMessageAdapter.toJson(PidSettingRequiredMessage(true)) + "\n\r"
Log.i("BluetoothVM", "Requested PID settings: $message")
val isSuccess = bluetoothController.trySendMessage(
message.toByteArray()
)
if(!isSuccess) {
Log.e("BluetoothVM", "Failed to request PID settings: $message")
}
}
}
fun applyPidSettings(pidSettings: PidSettings) {
viewModelScope.launch {
val message = pidSittingsMessageAdapter.toJson(pidSettings) + "\n\r"
val isSuccess = bluetoothController.trySendMessage(message.toByteArray())
if(!isSuccess) {
Log.e("BluetoothVM", "Failed to request PID settings: $message")
_state.update {
it.copy(errorMessage = "Не удалось обновить значения PID")
}
} else {
_state.update {
it.copy(deviceState = it.deviceState?.copy(pidSettings = pidSettings))
}
}
}
}
fun clearPidSettings() {
Log.i("BluetoothVM", "PidSettings cleared")
_state.update {
it.copy(deviceState = it.deviceState?.copy(pidSettings = null))
}
Log.i("BluetoothVM", "PidSettings: ${_state.value.deviceState?.pidSettings}")
}
}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Testblue" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.Main" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id("com.android.application") version "8.1.0" apply false
id("com.android.application") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.8.10" apply false
id("org.jetbrains.kotlin.jvm") version "1.8.10" apply false
}

View File

@@ -1,6 +1,6 @@
#Sun Aug 13 15:00:54 KRAT 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -3,6 +3,7 @@ pluginManagement {
google()
mavenCentral()
gradlePluginPortal()
maven(url = "https://jitpack.io")
}
}
dependencyResolutionManagement {
@@ -10,6 +11,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven(url = "https://jitpack.io")
}
}