Communication screen for a specific peripheral device

This commit is contained in:
2026-06-05 13:34:34 +07:00
parent 07611f3ace
commit 196623fb25
12 changed files with 515 additions and 36 deletions
@@ -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()) }
}
@@ -0,0 +1,5 @@
package com.digitoolsolutions.app.torquevaultkmp.domain.model
enum class ConnectionReason {
USER, TIMEOUT, LINK_LOSS, MISSING_SERVICE
}
@@ -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())
@@ -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)
@@ -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"
}
@@ -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")
}
}
}
}
}
@@ -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")
}
}
}
}
@@ -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")
}
}
}
@@ -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")
}
}
)
}
@@ -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)
@@ -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")