Communication screen for a specific peripheral device
This commit is contained in:
+1
-1
@@ -63,5 +63,5 @@ val viewModelModule = module {
|
||||
factoryOf(::SettingViewModel)
|
||||
factoryOf(::BondedViewModel)
|
||||
factoryOf(::LogViewModel)
|
||||
factory { ScannerViewModel(get(), get(), get(), get()) }
|
||||
single { ScannerViewModel(get(), get(), get(), get()) }
|
||||
}
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package com.digitoolsolutions.app.torquevaultkmp.domain.model
|
||||
|
||||
enum class ConnectionReason {
|
||||
USER, TIMEOUT, LINK_LOSS, MISSING_SERVICE
|
||||
}
|
||||
+10
-7
@@ -24,8 +24,13 @@ class BleManager {
|
||||
}
|
||||
|
||||
private val peripherals = mutableMapOf<String, Peripheral>()
|
||||
private val advertisements = mutableMapOf<String, Advertisement>()
|
||||
private val mutex = Mutex()
|
||||
|
||||
fun getPeripheral(identifier: String): Peripheral? = peripherals[identifier]
|
||||
fun getAdvertisement(identifier: String): Advertisement? = advertisements[identifier]
|
||||
fun getAdvertisementName(identifier: String): String = advertisements[identifier]?.name.toString()
|
||||
|
||||
fun scanDevices(): Flow<Advertisement> = Scanner {
|
||||
filters {
|
||||
match {
|
||||
@@ -37,26 +42,25 @@ class BleManager {
|
||||
level = Logging.Level.Events
|
||||
format = Logging.Format.Multiline
|
||||
}
|
||||
}.advertisements
|
||||
}.advertisements.map {
|
||||
advertisements[it.identifier.toString()] = it
|
||||
it
|
||||
}
|
||||
|
||||
suspend fun connect(advertisement: Advertisement): Peripheral {
|
||||
val identifier = advertisement.identifier.toString()
|
||||
Napier.d(">>>>> Connecting to $identifier")
|
||||
val peripheral = mutex.withLock {
|
||||
return mutex.withLock {
|
||||
peripherals.getOrPut(identifier) {
|
||||
Peripheral(advertisement)
|
||||
}
|
||||
}
|
||||
// Connect outside the lock to allow parallel connections for different devices
|
||||
peripheral.connect()
|
||||
return peripheral
|
||||
}
|
||||
|
||||
suspend fun disconnect(identifier: String) {
|
||||
mutex.withLock {
|
||||
peripherals.remove(identifier)?.disconnect()
|
||||
}
|
||||
Napier.d(">>>>> $identifier disconnected")
|
||||
}
|
||||
|
||||
suspend fun disconnectAll() {
|
||||
@@ -65,7 +69,6 @@ class BleManager {
|
||||
peripherals.clear()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendCommand(peripheral: Peripheral, command: String) {
|
||||
Napier.d(">>>>> Sent to uart: $command")
|
||||
peripheral.write(RX_CHAR, command.encodeToByteArray())
|
||||
|
||||
+25
-5
@@ -10,24 +10,29 @@ import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.digitoolsolutions.app.torquevaultkmp.AppViewModel
|
||||
import com.digitoolsolutions.app.torquevaultkmp.screens.auth.AuthDestination
|
||||
import com.digitoolsolutions.app.torquevaultkmp.screens.auth.LoginScreen
|
||||
import com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.BluetoothRequirementWrapper
|
||||
import com.digitoolsolutions.app.torquevaultkmp.screens.bonded.BondedDestination
|
||||
import com.digitoolsolutions.app.torquevaultkmp.screens.bonded.BondedScreen
|
||||
import com.digitoolsolutions.app.torquevaultkmp.screens.communication.CommunicationDestination
|
||||
import com.digitoolsolutions.app.torquevaultkmp.screens.communication.UartCommunicationScreen
|
||||
import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeDestination
|
||||
import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeScreen
|
||||
import com.digitoolsolutions.app.torquevaultkmp.screens.logs.LogDestination
|
||||
import com.digitoolsolutions.app.torquevaultkmp.screens.logs.LogScreen
|
||||
import com.digitoolsolutions.app.torquevaultkmp.screens.scanner.ScannerDestination
|
||||
import com.digitoolsolutions.app.torquevaultkmp.screens.scanner.ScannerScreen
|
||||
import com.digitoolsolutions.app.torquevaultkmp.screens.scanner.ScannerViewModel
|
||||
import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingDestination
|
||||
import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingScreen
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
@@ -35,13 +40,16 @@ import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import torquevaultkmp.composeapp.generated.resources.Res
|
||||
import torquevaultkmp.composeapp.generated.resources.btn_login
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
isDarkTheme: Boolean,
|
||||
onThemeToggle: (Boolean) -> Unit,
|
||||
isLoggedIn: Boolean,
|
||||
viewModel: AppViewModel = koinViewModel()
|
||||
viewModel: AppViewModel = koinViewModel(),
|
||||
scannerViewModel: ScannerViewModel = koinViewModel(),
|
||||
) {
|
||||
val destinations = listOf(
|
||||
HomeDestination,
|
||||
@@ -53,6 +61,7 @@ fun MainScreen(
|
||||
val navController = rememberNavController()
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
val noNavbar = listOf(AuthDestination.route, CommunicationDestination.route)
|
||||
|
||||
val sessionExpired by viewModel.sessionExpired
|
||||
|
||||
@@ -74,10 +83,14 @@ fun MainScreen(
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
scannerViewModel.startScan()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
bottomBar = {
|
||||
if (currentDestination?.route != AuthDestination.route)
|
||||
if (currentDestination?.route !in noNavbar)
|
||||
NavigationBar {
|
||||
destinations.forEach { dest ->
|
||||
NavigationBarItem(
|
||||
@@ -120,10 +133,17 @@ fun MainScreen(
|
||||
BondedScreen(navController)
|
||||
}
|
||||
composable(ScannerDestination.route) {
|
||||
ScannerScreen(onDeviceClick = {
|
||||
// Handle device click, maybe navigate to details or connect
|
||||
ScannerScreen(onDeviceClick = { device ->
|
||||
navController.navigate(CommunicationDestination.createRoute(device.identifier.toString()))
|
||||
})
|
||||
}
|
||||
composable(
|
||||
route = CommunicationDestination.route,
|
||||
arguments = listOf(navArgument("deviceId") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val deviceId = backStackEntry.savedStateHandle.get<String>("deviceId") ?: ""
|
||||
UartCommunicationScreen(navController, deviceId)
|
||||
}
|
||||
composable(LogDestination.route) { LogScreen(navController) }
|
||||
composable(SettingDestination.route) {
|
||||
SettingScreen(navController, isDarkTheme, onThemeToggle)
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.digitoolsolutions.app.torquevaultkmp.screens.communication
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Bluetooth
|
||||
import com.digitoolsolutions.app.torquevaultkmp.screens.AppIcon
|
||||
import com.digitoolsolutions.app.torquevaultkmp.screens.Destinations
|
||||
|
||||
object CommunicationDestination: Destinations(
|
||||
label = "Communication",
|
||||
icon = AppIcon.Vector(Icons.Outlined.Bluetooth),
|
||||
route = "communication/{deviceId}"
|
||||
) {
|
||||
fun createRoute(deviceId: String) = "communication/$deviceId"
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package com.digitoolsolutions.app.torquevaultkmp.screens.communication
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.digitoolsolutions.app.torquevaultkmp.components.AppBar
|
||||
import com.digitoolsolutions.app.torquevaultkmp.domain.model.ConnectionReason
|
||||
import com.digitoolsolutions.app.torquevaultkmp.screens.communication.views.DeviceConnected
|
||||
import com.digitoolsolutions.app.torquevaultkmp.screens.communication.views.DeviceConnecting
|
||||
import com.digitoolsolutions.app.torquevaultkmp.screens.communication.views.DeviceDisconnected
|
||||
import com.digitoolsolutions.app.torquevaultkmp.screens.scanner.ScannerViewModel
|
||||
import com.juul.kable.State
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import torquevaultkmp.composeapp.generated.resources.Res
|
||||
import torquevaultkmp.composeapp.generated.resources.action_cancel
|
||||
import torquevaultkmp.composeapp.generated.resources.action_retry
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun UartCommunicationScreen(
|
||||
navController: NavController,
|
||||
deviceId: String,
|
||||
viewModel: ScannerViewModel = koinInject()
|
||||
) {
|
||||
val messages by viewModel.rxMessages.collectAsState()
|
||||
val connectedDevices by viewModel.connectedDevices.collectAsState()
|
||||
val connectionReasons by viewModel.connectionReasons.collectAsState()
|
||||
|
||||
val connectionState = remember(connectedDevices, deviceId) {
|
||||
connectedDevices[deviceId] ?: State.Disconnected()
|
||||
}
|
||||
val reason = remember(connectionReasons, deviceId) {
|
||||
connectionReasons[deviceId] ?: ConnectionReason.LINK_LOSS
|
||||
}
|
||||
val deviceName = remember(connectedDevices) { viewModel.getDeviceName(deviceId) }
|
||||
|
||||
LaunchedEffect(deviceId) {
|
||||
viewModel.clearMessages()
|
||||
viewModel.connectById(deviceId)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
AppBar(
|
||||
title = { Text(deviceName) },
|
||||
onNavigationButtonClick = {
|
||||
viewModel.disconnect(deviceId)
|
||||
navController.popBackStack()
|
||||
},
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
when (connectionState) {
|
||||
is State.Connecting -> {
|
||||
DeviceConnecting (
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) { padding ->
|
||||
Button(
|
||||
onClick = { viewModel.disconnect(deviceId) },
|
||||
modifier = Modifier.padding(padding),
|
||||
) {
|
||||
Text(text = stringResource(Res.string.action_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
is State.Disconnected -> {
|
||||
DeviceDisconnected(
|
||||
reason = reason,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) { padding ->
|
||||
Button(
|
||||
onClick = { viewModel.connectById(deviceId) },
|
||||
modifier = Modifier.padding(padding),
|
||||
) {
|
||||
Text(text = stringResource(Res.string.action_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
is State.Connected -> {
|
||||
DeviceConnected(messages = messages, onSend = { msg ->
|
||||
viewModel.sendRawCommand(deviceId, msg)
|
||||
})
|
||||
}
|
||||
else -> {
|
||||
Text(text = "Another State")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package com.digitoolsolutions.app.torquevaultkmp.screens.communication.views
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
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.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun DeviceConnected(
|
||||
messages: List<String>,
|
||||
onSend: (msg: String) -> Unit
|
||||
) {
|
||||
var txInput by rememberSaveable { mutableStateOf("") }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
.imePadding(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.border(1.dp, Color.Gray, RoundedCornerShape(4.dp))
|
||||
.padding(8.dp)
|
||||
) {
|
||||
items(messages) { message ->
|
||||
Text(message)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(messages.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = txInput,
|
||||
onValueChange = { txInput = it },
|
||||
label = { Text("Manual Command") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
onSend(txInput)
|
||||
txInput = ""
|
||||
},
|
||||
modifier = Modifier.align(Alignment.CenterVertically)
|
||||
) {
|
||||
Text("Send")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package com.digitoolsolutions.app.torquevaultkmp.screens.communication.views
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BluetoothAudio
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.digitoolsolutions.app.torquevaultkmp.components.CircularIcon
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import torquevaultkmp.composeapp.generated.resources.Res
|
||||
import torquevaultkmp.composeapp.generated.resources.device_connecting
|
||||
import torquevaultkmp.composeapp.generated.resources.device_explanation
|
||||
|
||||
@Composable
|
||||
internal fun DeviceConnecting(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.(PaddingValues) -> Unit = {}
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
OutlinedCard(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 460.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
CircularIcon(imageVector = Icons.Default.BluetoothAudio)
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.device_connecting),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.device_explanation),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
content(PaddingValues(top = 16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun DeviceConnectingView_Preview() {
|
||||
DeviceConnecting { padding ->
|
||||
Button(
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(padding)
|
||||
) {
|
||||
Text(text = "Cancel")
|
||||
}
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package com.digitoolsolutions.app.torquevaultkmp.screens.communication.views
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BluetoothDisabled
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.digitoolsolutions.app.torquevaultkmp.components.CircularIcon
|
||||
import com.digitoolsolutions.app.torquevaultkmp.domain.model.ConnectionReason
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import torquevaultkmp.composeapp.generated.resources.Res
|
||||
import torquevaultkmp.composeapp.generated.resources.device_disconnected
|
||||
import torquevaultkmp.composeapp.generated.resources.device_reason_link_loss
|
||||
import torquevaultkmp.composeapp.generated.resources.device_reason_missing_service
|
||||
import torquevaultkmp.composeapp.generated.resources.device_reason_timeout
|
||||
import torquevaultkmp.composeapp.generated.resources.device_reason_user
|
||||
|
||||
@Composable
|
||||
internal fun DeviceDisconnected(
|
||||
reason: ConnectionReason,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.(PaddingValues) -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
OutlinedCard(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 460.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
CircularIcon(imageVector = Icons.Default.BluetoothDisabled)
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.device_disconnected),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
val text = when (reason) {
|
||||
ConnectionReason.USER -> stringResource(Res.string.device_reason_user)
|
||||
ConnectionReason.LINK_LOSS -> stringResource(Res.string.device_reason_link_loss)
|
||||
ConnectionReason.MISSING_SERVICE -> stringResource(Res.string.device_reason_missing_service)
|
||||
ConnectionReason.TIMEOUT -> stringResource(Res.string.device_reason_timeout)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
content(PaddingValues(top = 16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun DeviceDisconnectedView_Preview() {
|
||||
DeviceDisconnected(
|
||||
reason = ConnectionReason.MISSING_SERVICE,
|
||||
content = { padding ->
|
||||
Button(
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(padding)
|
||||
) {
|
||||
Text(text = "Retry")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+12
-5
@@ -18,6 +18,7 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.digitoolsolutions.app.torquevaultkmp.components.AppBar
|
||||
import com.digitoolsolutions.app.torquevaultkmp.components.CircularIcon
|
||||
import com.digitoolsolutions.app.torquevaultkmp.components.RssiIcon
|
||||
@@ -27,6 +28,7 @@ import com.juul.kable.Advertisement
|
||||
import com.juul.kable.State
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import torquevaultkmp.composeapp.generated.resources.Res
|
||||
import torquevaultkmp.composeapp.generated.resources.baseline_filter_list_24
|
||||
@@ -40,13 +42,14 @@ import kotlin.uuid.ExperimentalUuidApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class)
|
||||
@Composable
|
||||
fun ScannerScreen(
|
||||
viewModel: ScannerViewModel = koinViewModel(),
|
||||
viewModel: ScannerViewModel = koinInject(),
|
||||
onDeviceClick: (Advertisement) -> Unit
|
||||
) {
|
||||
val devices by viewModel.devices.collectAsState()
|
||||
val connectedDevices by viewModel.connectedDevices.collectAsState()
|
||||
val isScanning by viewModel.isScanning.collectAsState()
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
val autoConnect by viewModel.isAutoConnect.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -101,7 +104,11 @@ fun ScannerScreen(
|
||||
connectionState = connectionState,
|
||||
onClick = {
|
||||
if (connectionState == null || connectionState is State.Disconnected) {
|
||||
viewModel.connect(device)
|
||||
if (!autoConnect) {
|
||||
onDeviceClick(device)
|
||||
} else {
|
||||
viewModel.connect(device)
|
||||
}
|
||||
} else {
|
||||
viewModel.disconnect(deviceId)
|
||||
}
|
||||
@@ -113,9 +120,9 @@ fun ScannerScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.startScan()
|
||||
}
|
||||
// LaunchedEffect(Unit) {
|
||||
// viewModel.startScan()
|
||||
// }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
|
||||
+78
-17
@@ -10,6 +10,7 @@ import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage
|
||||
import com.digitoolsolutions.app.torquevaultkmp.data.storage.ReferKeys
|
||||
import com.digitoolsolutions.app.torquevaultkmp.data.storage.load
|
||||
import com.digitoolsolutions.app.torquevaultkmp.data.storage.observe
|
||||
import com.digitoolsolutions.app.torquevaultkmp.domain.model.ConnectionReason
|
||||
import com.juul.kable.Advertisement
|
||||
import com.juul.kable.Peripheral
|
||||
import com.juul.kable.State
|
||||
@@ -31,6 +32,8 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.TimeSource
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
@@ -44,6 +47,7 @@ class ScannerViewModel(
|
||||
) : ViewModel() {
|
||||
companion object {
|
||||
private val SCAN_TIMEOUT = 1.seconds
|
||||
private val CONNECTION_TIMEOUT = 15.seconds
|
||||
}
|
||||
private val _devices = MutableStateFlow<List<Advertisement>>(emptyList())
|
||||
val devices: StateFlow<List<Advertisement>> = _devices.asStateFlow()
|
||||
@@ -52,11 +56,19 @@ class ScannerViewModel(
|
||||
val isScanning: StateFlow<Boolean> = _isScanning.asStateFlow()
|
||||
private val _connectedDevices = MutableStateFlow<Map<String, State>>(emptyMap())
|
||||
val connectedDevices: StateFlow<Map<String, State>> = _connectedDevices.asStateFlow()
|
||||
|
||||
private val _connectionReasons = MutableStateFlow<Map<String, ConnectionReason>>(emptyMap())
|
||||
val connectionReasons: StateFlow<Map<String, ConnectionReason>> = _connectionReasons.asStateFlow()
|
||||
|
||||
private val _rxMessages = MutableStateFlow<List<String>>(emptyList())
|
||||
val rxMessages: StateFlow<List<String>> = _rxMessages.asStateFlow()
|
||||
|
||||
private val _workOrders = MutableStateFlow<List<WorkOrder>>(emptyList())
|
||||
private val _autoConnect: StateFlow<Boolean> = storage.observe<Boolean>(ReferKeys.DEVICE_AUTO_CONNECT)
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
private val _onlyWithName = MutableStateFlow(true)
|
||||
val isAutoConnect: StateFlow<Boolean> = _autoConnect
|
||||
|
||||
private val _onlyWithName = MutableStateFlow(true)
|
||||
private var scanJob: Job? = null
|
||||
private var cleanupJob: Job? = null
|
||||
private val connectionJobs = mutableMapOf<String, Job>()
|
||||
@@ -89,8 +101,8 @@ class ScannerViewModel(
|
||||
advertisementMap.clear()
|
||||
|
||||
bleManager.scanDevices()
|
||||
.catch {
|
||||
_isScanning.value = false
|
||||
.catch {
|
||||
_isScanning.value = false
|
||||
stopCleanupJob()
|
||||
}
|
||||
.collect { advertisement ->
|
||||
@@ -145,6 +157,37 @@ class ScannerViewModel(
|
||||
startScan()
|
||||
}
|
||||
|
||||
fun connectById(deviceId: String) {
|
||||
val adv = bleManager.getAdvertisement(deviceId)
|
||||
if (adv != null) {
|
||||
connect(adv)
|
||||
} else {
|
||||
Napier.e("Cannot connect: Advertisement not found for $deviceId")
|
||||
}
|
||||
}
|
||||
fun getDeviceName(identifier: String): String {
|
||||
return bleManager.getAdvertisementName(identifier)
|
||||
}
|
||||
fun getPeripheral(identifier: String): Peripheral? {
|
||||
return bleManager.getPeripheral(identifier)
|
||||
}
|
||||
fun clearMessages() {
|
||||
_rxMessages.value = emptyList()
|
||||
}
|
||||
|
||||
fun sendRawCommand(deviceIdentifier: String, command: String) {
|
||||
val peripheral = bleManager.getPeripheral(deviceIdentifier)
|
||||
if (peripheral != null) {
|
||||
sendCommand(peripheral, command)
|
||||
}
|
||||
}
|
||||
fun sendCommand(peripheral: Peripheral, command: String) {
|
||||
viewModelScope.launch {
|
||||
bleManager.sendCommand(peripheral, Helper.buildUartCommand(command))
|
||||
_rxMessages.value += "Sent: $command"
|
||||
}
|
||||
}
|
||||
|
||||
fun connect(adv: Advertisement) {
|
||||
val deviceIdentifier = adv.identifier.toString()
|
||||
if (connectionJobs[deviceIdentifier]?.isActive == true) return
|
||||
@@ -152,17 +195,25 @@ class ScannerViewModel(
|
||||
val job = viewModelScope.launch {
|
||||
try {
|
||||
val peripheral = bleManager.connect(adv)
|
||||
|
||||
|
||||
coroutineScope {
|
||||
// Tracking connection state
|
||||
var hasEmitted = false
|
||||
launch {
|
||||
peripheral.state.collect { state ->
|
||||
_connectedDevices.value += (deviceIdentifier to state)
|
||||
/** Add bonded device */
|
||||
addBondedDevice(deviceIdentifier, adv.name ?: "No name")
|
||||
if (state is State.Disconnected) {
|
||||
if (state is State.Connected) {
|
||||
/** Add bonded device */
|
||||
addBondedDevice(deviceIdentifier, adv.name ?: "No name")
|
||||
_connectionReasons.value -= deviceIdentifier
|
||||
}
|
||||
if (state is State.Disconnected && hasEmitted) {
|
||||
if (_connectionReasons.value[deviceIdentifier] == null) {
|
||||
_connectionReasons.value += (deviceIdentifier to ConnectionReason.LINK_LOSS)
|
||||
}
|
||||
this@coroutineScope.cancel(">>>>> Device disconnected")
|
||||
}
|
||||
hasEmitted = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,9 +223,17 @@ class ScannerViewModel(
|
||||
handleMessage(peripheral, value, deviceIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
withTimeout(CONNECTION_TIMEOUT) {
|
||||
_connectionReasons.value -= deviceIdentifier
|
||||
peripheral.connect()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e !is CancellationException) {
|
||||
if (e is TimeoutCancellationException) {
|
||||
Napier.e(">>>>> Connection timed out for $deviceIdentifier")
|
||||
_connectionReasons.value += (deviceIdentifier to ConnectionReason.TIMEOUT)
|
||||
} else if (e !is CancellationException) {
|
||||
Napier.e(">>>>> Connection failed for $deviceIdentifier", e)
|
||||
}
|
||||
} finally {
|
||||
@@ -187,7 +246,9 @@ class ScannerViewModel(
|
||||
connectionJobs[deviceIdentifier] = job
|
||||
}
|
||||
fun disconnect(deviceIdentifier: String) {
|
||||
Napier.d(">>>>> disconnecting $deviceIdentifier")
|
||||
viewModelScope.launch {
|
||||
_connectionReasons.value += (deviceIdentifier to ConnectionReason.USER)
|
||||
bleManager.disconnect(deviceIdentifier)
|
||||
connectionJobs[deviceIdentifier]?.cancel()
|
||||
connectionJobs.remove(deviceIdentifier)
|
||||
@@ -208,7 +269,7 @@ class ScannerViewModel(
|
||||
private fun sendWorkOrdersToDevice(peripheral: Peripheral, workOrders: List<WorkOrder>) {
|
||||
viewModelScope.launch {
|
||||
if (workOrders.isEmpty()) {
|
||||
bleManager.sendCommand(peripheral, Helper.buildUartCommand(Helper.CMD_EMPTY_WO))
|
||||
sendCommand(peripheral, Helper.CMD_EMPTY_WO)
|
||||
return@launch
|
||||
}
|
||||
val formatted = workOrders.joinToString(separator = "\r\n") { wo ->
|
||||
@@ -217,10 +278,12 @@ class ScannerViewModel(
|
||||
val wheelsMask = wo.computeWheelsNuts()
|
||||
"$${wo.id},${actionCode},${wo.make},${wo.licensePlate},$torque,ft-lb,$wheelsMask*"
|
||||
}
|
||||
bleManager.sendCommand(peripheral, Helper.buildUartCommand(formatted))
|
||||
|
||||
sendCommand(peripheral, formatted)
|
||||
}
|
||||
}
|
||||
private fun handleMessage(peripheral: Peripheral, msg: String, deviceIdentifier: String) {
|
||||
_rxMessages.value += "Received: $msg"
|
||||
val decoded = Helper.decodeRxData(msg) ?: return
|
||||
val data = decoded.data
|
||||
val woId = decoded.id
|
||||
@@ -237,12 +300,10 @@ class ScannerViewModel(
|
||||
var command = Helper.CMD_NOT_AVAILABLE_WO
|
||||
if (res)
|
||||
command = Helper.CMD_RECEIVED_WO
|
||||
bleManager.sendCommand(peripheral, Helper.buildUartCommand(command))
|
||||
sendCommand(peripheral, command)
|
||||
} catch (e: Exception) {
|
||||
Napier.e(">>>>> Failed to handle message: $msg", e)
|
||||
bleManager.sendCommand(
|
||||
peripheral, Helper.buildUartCommand(Helper.CMD_NOT_AVAILABLE_WO)
|
||||
)
|
||||
sendCommand(peripheral, Helper.CMD_NOT_AVAILABLE_WO)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
@@ -265,7 +326,7 @@ class ScannerViewModel(
|
||||
Napier.e("An error occurred while sending the measurement", e, tag = "ScannerViewModel")
|
||||
} finally {
|
||||
// Currently, always send UPLOADED_WO regardless of success or failure
|
||||
bleManager.sendCommand(peripheral, Helper.buildUartCommand(Helper.CMD_UPLOADED_WO))
|
||||
sendCommand(peripheral, Helper.CMD_UPLOADED_WO)
|
||||
}
|
||||
}
|
||||
Helper.TorqueAction.RE_TORQUE -> {
|
||||
@@ -275,7 +336,7 @@ class ScannerViewModel(
|
||||
Napier.e("An error occurred while sending the re-measurement", e, tag = "ScannerViewModel")
|
||||
} finally {
|
||||
// Currently, always send UPLOADED_WO regardless of success or failure
|
||||
bleManager.sendCommand(peripheral, Helper.buildUartCommand(Helper.CMD_UPLOADED_WO))
|
||||
sendCommand(peripheral, Helper.CMD_UPLOADED_WO)
|
||||
}
|
||||
}
|
||||
Helper.TorqueAction.CANCEL -> {
|
||||
@@ -285,7 +346,7 @@ class ScannerViewModel(
|
||||
Napier.e("An error occurred while cancel work order", e, tag = "ScannerViewModel")
|
||||
} finally {
|
||||
// Currently, always send UPLOADED_WO regardless of success or failure
|
||||
bleManager.sendCommand(peripheral, Helper.buildUartCommand(Helper.CMD_UPLOADED_WO))
|
||||
sendCommand(peripheral, Helper.CMD_UPLOADED_WO)
|
||||
}
|
||||
}
|
||||
else -> Napier.w(">>>>> Unknown action: $act")
|
||||
|
||||
Reference in New Issue
Block a user