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
@@ -33,6 +33,15 @@
<string name="clear">Clear</string> <string name="clear">Clear</string>
<string name="scanner_title">Scanner</string> <string name="scanner_title">Scanner</string>
<string name="scan_empty_title">CAN'T SEE YOUR DEVICE?</string> <string name="scan_empty_title">CAN'T SEE YOUR DEVICE?</string>
<string name="no_device_guide_info">1. Make sure the device is connected to a power source and <b>powered on</b>.\n\n2. Make sure the appropriate firmware and SoftDevice are flashed.</string> <string name="no_device_guide_info">1. Make sure the device is connected to a power source and <b>powered on</b>.\n\n2. Make sure the appropriate firmware and Soft Device are flashed.</string>
<string name="device_disconnected">Disconnected</string>
<string name="device_reason_user">Device disconnected successfully.</string>
<string name="device_reason_timeout">The device is not available.\nPlease check if it is turned on and try again.</string>
<string name="device_reason_link_loss">The device got out of range, or has been turned off.</string>
<string name="device_reason_missing_service">The device is not supported.</string>
<string name="action_cancel">Cancel</string>
<string name="action_retry">Retry</string>
<string name="device_connecting">Connecting…</string>
<string name="device_explanation">It should just take a moment…</string>
</resources> </resources>
@@ -63,5 +63,5 @@ val viewModelModule = module {
factoryOf(::SettingViewModel) factoryOf(::SettingViewModel)
factoryOf(::BondedViewModel) factoryOf(::BondedViewModel)
factoryOf(::LogViewModel) 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 peripherals = mutableMapOf<String, Peripheral>()
private val advertisements = mutableMapOf<String, Advertisement>()
private val mutex = Mutex() 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 { fun scanDevices(): Flow<Advertisement> = Scanner {
filters { filters {
match { match {
@@ -37,26 +42,25 @@ class BleManager {
level = Logging.Level.Events level = Logging.Level.Events
format = Logging.Format.Multiline format = Logging.Format.Multiline
} }
}.advertisements }.advertisements.map {
advertisements[it.identifier.toString()] = it
it
}
suspend fun connect(advertisement: Advertisement): Peripheral { suspend fun connect(advertisement: Advertisement): Peripheral {
val identifier = advertisement.identifier.toString() val identifier = advertisement.identifier.toString()
Napier.d(">>>>> Connecting to $identifier") Napier.d(">>>>> Connecting to $identifier")
val peripheral = mutex.withLock { return mutex.withLock {
peripherals.getOrPut(identifier) { peripherals.getOrPut(identifier) {
Peripheral(advertisement) Peripheral(advertisement)
} }
} }
// Connect outside the lock to allow parallel connections for different devices
peripheral.connect()
return peripheral
} }
suspend fun disconnect(identifier: String) { suspend fun disconnect(identifier: String) {
mutex.withLock { mutex.withLock {
peripherals.remove(identifier)?.disconnect() peripherals.remove(identifier)?.disconnect()
} }
Napier.d(">>>>> $identifier disconnected")
} }
suspend fun disconnectAll() { suspend fun disconnectAll() {
@@ -65,7 +69,6 @@ class BleManager {
peripherals.clear() peripherals.clear()
} }
} }
suspend fun sendCommand(peripheral: Peripheral, command: String) { suspend fun sendCommand(peripheral: Peripheral, command: String) {
Napier.d(">>>>> Sent to uart: $command") Napier.d(">>>>> Sent to uart: $command")
peripheral.write(RX_CHAR, command.encodeToByteArray()) peripheral.write(RX_CHAR, command.encodeToByteArray())
@@ -10,24 +10,29 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.digitoolsolutions.app.torquevaultkmp.AppViewModel import com.digitoolsolutions.app.torquevaultkmp.AppViewModel
import com.digitoolsolutions.app.torquevaultkmp.screens.auth.AuthDestination import com.digitoolsolutions.app.torquevaultkmp.screens.auth.AuthDestination
import com.digitoolsolutions.app.torquevaultkmp.screens.auth.LoginScreen 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.BondedDestination
import com.digitoolsolutions.app.torquevaultkmp.screens.bonded.BondedScreen 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.HomeDestination
import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeScreen import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeScreen
import com.digitoolsolutions.app.torquevaultkmp.screens.logs.LogDestination import com.digitoolsolutions.app.torquevaultkmp.screens.logs.LogDestination
import com.digitoolsolutions.app.torquevaultkmp.screens.logs.LogScreen import com.digitoolsolutions.app.torquevaultkmp.screens.logs.LogScreen
import com.digitoolsolutions.app.torquevaultkmp.screens.scanner.ScannerDestination import com.digitoolsolutions.app.torquevaultkmp.screens.scanner.ScannerDestination
import com.digitoolsolutions.app.torquevaultkmp.screens.scanner.ScannerScreen 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.SettingDestination
import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingScreen import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingScreen
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
@@ -35,13 +40,16 @@ import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import torquevaultkmp.composeapp.generated.resources.Res import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.btn_login import torquevaultkmp.composeapp.generated.resources.btn_login
import kotlin.uuid.ExperimentalUuidApi
@OptIn(ExperimentalUuidApi::class)
@Composable @Composable
fun MainScreen( fun MainScreen(
isDarkTheme: Boolean, isDarkTheme: Boolean,
onThemeToggle: (Boolean) -> Unit, onThemeToggle: (Boolean) -> Unit,
isLoggedIn: Boolean, isLoggedIn: Boolean,
viewModel: AppViewModel = koinViewModel() viewModel: AppViewModel = koinViewModel(),
scannerViewModel: ScannerViewModel = koinViewModel(),
) { ) {
val destinations = listOf( val destinations = listOf(
HomeDestination, HomeDestination,
@@ -53,6 +61,7 @@ fun MainScreen(
val navController = rememberNavController() val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination val currentDestination = navBackStackEntry?.destination
val noNavbar = listOf(AuthDestination.route, CommunicationDestination.route)
val sessionExpired by viewModel.sessionExpired val sessionExpired by viewModel.sessionExpired
@@ -74,10 +83,14 @@ fun MainScreen(
) )
} }
LaunchedEffect(Unit) {
scannerViewModel.startScan()
}
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
bottomBar = { bottomBar = {
if (currentDestination?.route != AuthDestination.route) if (currentDestination?.route !in noNavbar)
NavigationBar { NavigationBar {
destinations.forEach { dest -> destinations.forEach { dest ->
NavigationBarItem( NavigationBarItem(
@@ -120,10 +133,17 @@ fun MainScreen(
BondedScreen(navController) BondedScreen(navController)
} }
composable(ScannerDestination.route) { composable(ScannerDestination.route) {
ScannerScreen(onDeviceClick = { ScannerScreen(onDeviceClick = { device ->
// Handle device click, maybe navigate to details or connect 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(LogDestination.route) { LogScreen(navController) }
composable(SettingDestination.route) { composable(SettingDestination.route) {
SettingScreen(navController, isDarkTheme, onThemeToggle) 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.digitoolsolutions.app.torquevaultkmp.components.AppBar import com.digitoolsolutions.app.torquevaultkmp.components.AppBar
import com.digitoolsolutions.app.torquevaultkmp.components.CircularIcon import com.digitoolsolutions.app.torquevaultkmp.components.CircularIcon
import com.digitoolsolutions.app.torquevaultkmp.components.RssiIcon import com.digitoolsolutions.app.torquevaultkmp.components.RssiIcon
@@ -27,6 +28,7 @@ import com.juul.kable.Advertisement
import com.juul.kable.State import com.juul.kable.State
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import torquevaultkmp.composeapp.generated.resources.Res import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.baseline_filter_list_24 import torquevaultkmp.composeapp.generated.resources.baseline_filter_list_24
@@ -40,13 +42,14 @@ import kotlin.uuid.ExperimentalUuidApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class)
@Composable @Composable
fun ScannerScreen( fun ScannerScreen(
viewModel: ScannerViewModel = koinViewModel(), viewModel: ScannerViewModel = koinInject(),
onDeviceClick: (Advertisement) -> Unit onDeviceClick: (Advertisement) -> Unit
) { ) {
val devices by viewModel.devices.collectAsState() val devices by viewModel.devices.collectAsState()
val connectedDevices by viewModel.connectedDevices.collectAsState() val connectedDevices by viewModel.connectedDevices.collectAsState()
val isScanning by viewModel.isScanning.collectAsState() val isScanning by viewModel.isScanning.collectAsState()
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
val autoConnect by viewModel.isAutoConnect.collectAsState()
Scaffold( Scaffold(
topBar = { topBar = {
@@ -101,7 +104,11 @@ fun ScannerScreen(
connectionState = connectionState, connectionState = connectionState,
onClick = { onClick = {
if (connectionState == null || connectionState is State.Disconnected) { if (connectionState == null || connectionState is State.Disconnected) {
if (!autoConnect) {
onDeviceClick(device)
} else {
viewModel.connect(device) viewModel.connect(device)
}
} else { } else {
viewModel.disconnect(deviceId) viewModel.disconnect(deviceId)
} }
@@ -113,9 +120,9 @@ fun ScannerScreen(
} }
} }
LaunchedEffect(Unit) { // LaunchedEffect(Unit) {
viewModel.startScan() // viewModel.startScan()
} // }
} }
@OptIn(ExperimentalUuidApi::class) @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.ReferKeys
import com.digitoolsolutions.app.torquevaultkmp.data.storage.load import com.digitoolsolutions.app.torquevaultkmp.data.storage.load
import com.digitoolsolutions.app.torquevaultkmp.data.storage.observe 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.Advertisement
import com.juul.kable.Peripheral import com.juul.kable.Peripheral
import com.juul.kable.State import com.juul.kable.State
@@ -31,6 +32,8 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.TimeoutCancellationException
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource import kotlin.time.TimeSource
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
@@ -44,6 +47,7 @@ class ScannerViewModel(
) : ViewModel() { ) : ViewModel() {
companion object { companion object {
private val SCAN_TIMEOUT = 1.seconds private val SCAN_TIMEOUT = 1.seconds
private val CONNECTION_TIMEOUT = 15.seconds
} }
private val _devices = MutableStateFlow<List<Advertisement>>(emptyList()) private val _devices = MutableStateFlow<List<Advertisement>>(emptyList())
val devices: StateFlow<List<Advertisement>> = _devices.asStateFlow() val devices: StateFlow<List<Advertisement>> = _devices.asStateFlow()
@@ -52,11 +56,19 @@ class ScannerViewModel(
val isScanning: StateFlow<Boolean> = _isScanning.asStateFlow() val isScanning: StateFlow<Boolean> = _isScanning.asStateFlow()
private val _connectedDevices = MutableStateFlow<Map<String, State>>(emptyMap()) private val _connectedDevices = MutableStateFlow<Map<String, State>>(emptyMap())
val connectedDevices: StateFlow<Map<String, State>> = _connectedDevices.asStateFlow() 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 _workOrders = MutableStateFlow<List<WorkOrder>>(emptyList())
private val _autoConnect: StateFlow<Boolean> = storage.observe<Boolean>(ReferKeys.DEVICE_AUTO_CONNECT) private val _autoConnect: StateFlow<Boolean> = storage.observe<Boolean>(ReferKeys.DEVICE_AUTO_CONNECT)
.stateIn(viewModelScope, SharingStarted.Eagerly, false) .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 scanJob: Job? = null
private var cleanupJob: Job? = null private var cleanupJob: Job? = null
private val connectionJobs = mutableMapOf<String, Job>() private val connectionJobs = mutableMapOf<String, Job>()
@@ -145,6 +157,37 @@ class ScannerViewModel(
startScan() 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) { fun connect(adv: Advertisement) {
val deviceIdentifier = adv.identifier.toString() val deviceIdentifier = adv.identifier.toString()
if (connectionJobs[deviceIdentifier]?.isActive == true) return if (connectionJobs[deviceIdentifier]?.isActive == true) return
@@ -155,14 +198,22 @@ class ScannerViewModel(
coroutineScope { coroutineScope {
// Tracking connection state // Tracking connection state
var hasEmitted = false
launch { launch {
peripheral.state.collect { state -> peripheral.state.collect { state ->
_connectedDevices.value += (deviceIdentifier to state) _connectedDevices.value += (deviceIdentifier to state)
if (state is State.Connected) {
/** Add bonded device */ /** Add bonded device */
addBondedDevice(deviceIdentifier, adv.name ?: "No name") addBondedDevice(deviceIdentifier, adv.name ?: "No name")
if (state is State.Disconnected) { _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") this@coroutineScope.cancel(">>>>> Device disconnected")
} }
hasEmitted = true
} }
} }
@@ -172,9 +223,17 @@ class ScannerViewModel(
handleMessage(peripheral, value, deviceIdentifier) handleMessage(peripheral, value, deviceIdentifier)
} }
} }
withTimeout(CONNECTION_TIMEOUT) {
_connectionReasons.value -= deviceIdentifier
peripheral.connect()
}
} }
} catch (e: Exception) { } 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) Napier.e(">>>>> Connection failed for $deviceIdentifier", e)
} }
} finally { } finally {
@@ -187,7 +246,9 @@ class ScannerViewModel(
connectionJobs[deviceIdentifier] = job connectionJobs[deviceIdentifier] = job
} }
fun disconnect(deviceIdentifier: String) { fun disconnect(deviceIdentifier: String) {
Napier.d(">>>>> disconnecting $deviceIdentifier")
viewModelScope.launch { viewModelScope.launch {
_connectionReasons.value += (deviceIdentifier to ConnectionReason.USER)
bleManager.disconnect(deviceIdentifier) bleManager.disconnect(deviceIdentifier)
connectionJobs[deviceIdentifier]?.cancel() connectionJobs[deviceIdentifier]?.cancel()
connectionJobs.remove(deviceIdentifier) connectionJobs.remove(deviceIdentifier)
@@ -208,7 +269,7 @@ class ScannerViewModel(
private fun sendWorkOrdersToDevice(peripheral: Peripheral, workOrders: List<WorkOrder>) { private fun sendWorkOrdersToDevice(peripheral: Peripheral, workOrders: List<WorkOrder>) {
viewModelScope.launch { viewModelScope.launch {
if (workOrders.isEmpty()) { if (workOrders.isEmpty()) {
bleManager.sendCommand(peripheral, Helper.buildUartCommand(Helper.CMD_EMPTY_WO)) sendCommand(peripheral, Helper.CMD_EMPTY_WO)
return@launch return@launch
} }
val formatted = workOrders.joinToString(separator = "\r\n") { wo -> val formatted = workOrders.joinToString(separator = "\r\n") { wo ->
@@ -217,10 +278,12 @@ class ScannerViewModel(
val wheelsMask = wo.computeWheelsNuts() val wheelsMask = wo.computeWheelsNuts()
"$${wo.id},${actionCode},${wo.make},${wo.licensePlate},$torque,ft-lb,$wheelsMask*" "$${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) { private fun handleMessage(peripheral: Peripheral, msg: String, deviceIdentifier: String) {
_rxMessages.value += "Received: $msg"
val decoded = Helper.decodeRxData(msg) ?: return val decoded = Helper.decodeRxData(msg) ?: return
val data = decoded.data val data = decoded.data
val woId = decoded.id val woId = decoded.id
@@ -237,12 +300,10 @@ class ScannerViewModel(
var command = Helper.CMD_NOT_AVAILABLE_WO var command = Helper.CMD_NOT_AVAILABLE_WO
if (res) if (res)
command = Helper.CMD_RECEIVED_WO command = Helper.CMD_RECEIVED_WO
bleManager.sendCommand(peripheral, Helper.buildUartCommand(command)) sendCommand(peripheral, command)
} catch (e: Exception) { } catch (e: Exception) {
Napier.e(">>>>> Failed to handle message: $msg", e) Napier.e(">>>>> Failed to handle message: $msg", e)
bleManager.sendCommand( sendCommand(peripheral, Helper.CMD_NOT_AVAILABLE_WO)
peripheral, Helper.buildUartCommand(Helper.CMD_NOT_AVAILABLE_WO)
)
} }
} }
else -> { else -> {
@@ -265,7 +326,7 @@ class ScannerViewModel(
Napier.e("An error occurred while sending the measurement", e, tag = "ScannerViewModel") Napier.e("An error occurred while sending the measurement", e, tag = "ScannerViewModel")
} finally { } finally {
// Currently, always send UPLOADED_WO regardless of success or failure // 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 -> { Helper.TorqueAction.RE_TORQUE -> {
@@ -275,7 +336,7 @@ class ScannerViewModel(
Napier.e("An error occurred while sending the re-measurement", e, tag = "ScannerViewModel") Napier.e("An error occurred while sending the re-measurement", e, tag = "ScannerViewModel")
} finally { } finally {
// Currently, always send UPLOADED_WO regardless of success or failure // 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 -> { Helper.TorqueAction.CANCEL -> {
@@ -285,7 +346,7 @@ class ScannerViewModel(
Napier.e("An error occurred while cancel work order", e, tag = "ScannerViewModel") Napier.e("An error occurred while cancel work order", e, tag = "ScannerViewModel")
} finally { } finally {
// Currently, always send UPLOADED_WO regardless of success or failure // 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") else -> Napier.w(">>>>> Unknown action: $act")