Auto connect ble, update realtime setting

This commit is contained in:
2026-05-30 14:52:37 +07:00
parent e55fcab158
commit 07611f3ace
9 changed files with 118 additions and 38 deletions
@@ -1,9 +1,14 @@
package com.digitoolsolutions.app.torquevaultkmp package com.digitoolsolutions.app.torquevaultkmp
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage
import kotlin.reflect.KClass import kotlin.reflect.KClass
import androidx.core.content.edit import androidx.core.content.edit
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.filterNotNull
class AppStoragePlatform(context: Context) : AppStorage { class AppStoragePlatform(context: Context) : AppStorage {
private val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
@@ -30,4 +35,22 @@ class AppStoragePlatform(context: Context) : AppStorage {
override fun remove(key: String) { override fun remove(key: String) {
prefs.edit { remove(key) } prefs.edit { remove(key) }
} }
@Suppress("UNCHECKED_CAST")
override fun <T : Any> observe(key: String, type: KClass<T>): Flow<T> = callbackFlow {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, changedKey ->
if (changedKey == key) {
val value: T? = when (type) {
String::class -> prefs.getString(key, null) as T?
Int::class -> prefs.getInt(key, -1) as T?
Boolean::class -> prefs.getBoolean(key, false) as T?
else -> throw IllegalArgumentException("Unsupported type")
}
value?.let { trySend(it) }
}
}
prefs.registerOnSharedPreferenceChangeListener(listener)
load(key, type)?.let { trySend(it) }
awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }
}.filterNotNull()
} }
@@ -11,6 +11,9 @@ import kotlinx.coroutines.flow.Flow
class DeviceRepository( class DeviceRepository(
private val dbQueries: AppDatabaseQueries private val dbQueries: AppDatabaseQueries
) { ) {
companion object {
const val PAGE_SIZE = 20L
}
suspend fun saveDevice(id: String, name: String) { suspend fun saveDevice(id: String, name: String) {
dbQueries.insertDevice(id, name) dbQueries.insertDevice(id, name)
} }
@@ -20,7 +23,10 @@ class DeviceRepository(
.asFlow() .asFlow()
.mapToList(Dispatchers.IO) .mapToList(Dispatchers.IO)
suspend fun getDevicesPaged(limit: Long, lastId: String?): List<Device> { suspend fun getAllDeviceIdsFlow(): Flow<List<String>> {
return dbQueries.getAllDeviceIds().asFlow().mapToList(Dispatchers.IO)
}
suspend fun getDevicesPaged(limit: Long = PAGE_SIZE, lastId: String? = null): List<Device> {
return if (lastId == null) { return if (lastId == null) {
dbQueries.getDevicesFirstPage(limit).executeAsList() dbQueries.getDevicesFirstPage(limit).executeAsList()
} else { } else {
@@ -1,13 +1,18 @@
package com.digitoolsolutions.app.torquevaultkmp.data.storage package com.digitoolsolutions.app.torquevaultkmp.data.storage
import kotlinx.coroutines.flow.Flow
import kotlin.reflect.KClass import kotlin.reflect.KClass
interface AppStorage { interface AppStorage {
fun <T : Any> save(key: String, value: T) fun <T : Any> save(key: String, value: T)
fun <T : Any> load(key: String, type: KClass<T>): T? fun <T : Any> load(key: String, type: KClass<T>): T?
fun remove(key: String) fun remove(key: String)
fun <T : Any> observe(key: String, type: KClass<T>): Flow<T>
} }
inline fun <reified T : Any> AppStorage.load(key: String): T? { inline fun <reified T : Any> AppStorage.load(key: String): T? {
return load(key, T::class) return load(key, T::class)
} }
inline fun <reified T : Any> AppStorage.observe(key: String): Flow<T> {
return observe(key, T::class)
}
@@ -51,6 +51,9 @@ fun BondedScreen(
val devices by viewModel.bondedDevices.collectAsState() val devices by viewModel.bondedDevices.collectAsState()
val isLoading by viewModel.isLoading.collectAsState() val isLoading by viewModel.isLoading.collectAsState()
LaunchedEffect(Unit) {
viewModel.refresh()
}
BondedContent( BondedContent(
devices = devices, devices = devices,
isLoading = isLoading, isLoading = isLoading,
@@ -18,7 +18,7 @@ class BondedViewModel(
val bondedDevices: StateFlow<List<Device>> = _bondedDevices.asStateFlow() val bondedDevices: StateFlow<List<Device>> = _bondedDevices.asStateFlow()
private var lastId: String? = null private var lastId: String? = null
private val pageSize = 20L private val pageSize = DeviceRepository.PAGE_SIZE
private var isLastPage = false private var isLastPage = false
var isLoading = MutableStateFlow(false) var isLoading = MutableStateFlow(false)
private set private set
@@ -105,7 +105,6 @@ fun ScannerScreen(
} else { } else {
viewModel.disconnect(deviceId) viewModel.disconnect(deviceId)
} }
viewModel.addBondedDevice(deviceId, device.name ?: "No name")
} }
) )
} }
@@ -9,6 +9,7 @@ import com.digitoolsolutions.app.torquevaultkmp.data.repository.ScannerRepositor
import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage 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.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
@@ -22,10 +23,12 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -39,7 +42,9 @@ class ScannerViewModel(
private val deviceRepository: DeviceRepository, private val deviceRepository: DeviceRepository,
private val scannerRepository: ScannerRepository private val scannerRepository: ScannerRepository
) : ViewModel() { ) : ViewModel() {
companion object {
private val SCAN_TIMEOUT = 1.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()
@@ -47,10 +52,9 @@ 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 _workOrders = MutableStateFlow<List<WorkOrder>>(emptyList()) private val _workOrders = MutableStateFlow<List<WorkOrder>>(emptyList())
private val _autoConnect = MutableStateFlow(storage.load<Boolean>(ReferKeys.DEVICE_AUTO_CONNECT) ?: false) private val _autoConnect: StateFlow<Boolean> = storage.observe<Boolean>(ReferKeys.DEVICE_AUTO_CONNECT)
val autoConnect: StateFlow<Boolean> = _autoConnect.asStateFlow() .stateIn(viewModelScope, SharingStarted.Eagerly, false)
private val _onlyWithName = MutableStateFlow(true) private val _onlyWithName = MutableStateFlow(true)
private var scanJob: Job? = null private var scanJob: Job? = null
@@ -59,36 +63,21 @@ class ScannerViewModel(
private val advertisementMap = mutableMapOf<String, Pair<Advertisement, TimeSource.Monotonic.ValueTimeMark>>() private val advertisementMap = mutableMapOf<String, Pair<Advertisement, TimeSource.Monotonic.ValueTimeMark>>()
companion object { /** Bonded device */
private val SCAN_TIMEOUT = 1.seconds private val bondedIds = mutableSetOf<String>() // Cache
} suspend fun preloadBondedIds() {
fun fetchWorkOrders(peripheral: Peripheral, woID: String = "0", action: String = "next") {
viewModelScope.launch { viewModelScope.launch {
try { deviceRepository.getAllDeviceIdsFlow().collect { ids ->
val orders = scannerRepository.getAbleWorkOrders(woID = woID, action = action) bondedIds.clear()
_workOrders.value = orders bondedIds.addAll(ids)
sendWorkOrdersToDevice(peripheral,orders)
} catch (e: Exception) {
Napier.e("Failed to load work orders", e)
} }
} }
} }
fun isBonded(id: String): Boolean = bondedIds.contains(id)
private fun sendWorkOrdersToDevice(peripheral: Peripheral, workOrders: List<WorkOrder>) { init {
viewModelScope.launch { viewModelScope.launch {
if (workOrders.isEmpty()) { preloadBondedIds()
bleManager.sendCommand(peripheral, Helper.buildUartCommand(Helper.CMD_EMPTY_WO))
return@launch
}
val formatted = workOrders.joinToString(separator = "\r\n") { wo ->
val torque = wo.services?.values?.firstOrNull()?.torqueRequired ?: 0.0
val actionCode = if (wo.status.lowercase() == "open") 0 else 1
val wheelsMask = wo.computeWheelsNuts()
"$${wo.id},${actionCode},${wo.make},${wo.licensePlate},$torque,ft-lb,$wheelsMask*"
}
bleManager.sendCommand(peripheral, Helper.buildUartCommand(formatted))
} }
} }
@@ -106,11 +95,15 @@ class ScannerViewModel(
} }
.collect { advertisement -> .collect { advertisement ->
if (_onlyWithName.value && advertisement.name == null) return@collect if (_onlyWithName.value && advertisement.name == null) return@collect
val bleId: String = advertisement.identifier.toString()
val now = TimeSource.Monotonic.markNow() if(_autoConnect.value && isBonded(bleId)){
advertisementMap[advertisement.identifier.toString()] = advertisement to now connect(advertisement)
startCleanupJob() } else {
updateDeviceList() val now = TimeSource.Monotonic.markNow()
advertisementMap[advertisement.identifier.toString()] = advertisement to now
startCleanupJob()
updateDeviceList()
}
} }
} }
} }
@@ -165,6 +158,8 @@ class ScannerViewModel(
launch { launch {
peripheral.state.collect { state -> peripheral.state.collect { state ->
_connectedDevices.value += (deviceIdentifier to state) _connectedDevices.value += (deviceIdentifier to state)
/** Add bonded device */
addBondedDevice(deviceIdentifier, adv.name ?: "No name")
if (state is State.Disconnected) { if (state is State.Disconnected) {
this@coroutineScope.cancel(">>>>> Device disconnected") this@coroutineScope.cancel(">>>>> Device disconnected")
} }
@@ -191,7 +186,6 @@ class ScannerViewModel(
} }
connectionJobs[deviceIdentifier] = job connectionJobs[deviceIdentifier] = job
} }
fun disconnect(deviceIdentifier: String) { fun disconnect(deviceIdentifier: String) {
viewModelScope.launch { viewModelScope.launch {
bleManager.disconnect(deviceIdentifier) bleManager.disconnect(deviceIdentifier)
@@ -200,7 +194,32 @@ class ScannerViewModel(
_connectedDevices.value -= deviceIdentifier _connectedDevices.value -= deviceIdentifier
} }
} }
fun fetchWorkOrders(peripheral: Peripheral, woID: String = "0", action: String = "next") {
viewModelScope.launch {
try {
val orders = scannerRepository.getAbleWorkOrders(woID = woID, action = action)
_workOrders.value = orders
sendWorkOrdersToDevice(peripheral,orders)
} catch (e: Exception) {
Napier.e("Failed to load work orders", e)
}
}
}
private fun sendWorkOrdersToDevice(peripheral: Peripheral, workOrders: List<WorkOrder>) {
viewModelScope.launch {
if (workOrders.isEmpty()) {
bleManager.sendCommand(peripheral, Helper.buildUartCommand(Helper.CMD_EMPTY_WO))
return@launch
}
val formatted = workOrders.joinToString(separator = "\r\n") { wo ->
val torque = wo.services?.values?.firstOrNull()?.torqueRequired ?: 0.0
val actionCode = if (wo.status.lowercase() == "open") 0 else 1
val wheelsMask = wo.computeWheelsNuts()
"$${wo.id},${actionCode},${wo.make},${wo.licensePlate},$torque,ft-lb,$wheelsMask*"
}
bleManager.sendCommand(peripheral, Helper.buildUartCommand(formatted))
}
}
private fun handleMessage(peripheral: Peripheral, msg: String, deviceIdentifier: String) { private fun handleMessage(peripheral: Peripheral, msg: String, deviceIdentifier: String) {
val decoded = Helper.decodeRxData(msg) ?: return val decoded = Helper.decodeRxData(msg) ?: return
val data = decoded.data val data = decoded.data
@@ -24,6 +24,9 @@ VALUES (?, ?);
getAllDevices: getAllDevices:
SELECT * FROM Device; SELECT * FROM Device;
getAllDeviceIds:
SELECT id FROM Device;
getDevicesFirstPage: getDevicesFirstPage:
SELECT * FROM Device ORDER BY id ASC LIMIT :limit; SELECT * FROM Device ORDER BY id ASC LIMIT :limit;
@@ -1,7 +1,14 @@
package com.digitoolsolutions.app.torquevaultkmp package com.digitoolsolutions.app.torquevaultkmp
import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import platform.Foundation.NSNotificationCenter
import platform.Foundation.NSUserDefaults import platform.Foundation.NSUserDefaults
import platform.Foundation.NSUserDefaultsDidChangeNotification
import kotlin.reflect.KClass import kotlin.reflect.KClass
class AppStoragePlatform : AppStorage { class AppStoragePlatform : AppStorage {
@@ -28,4 +35,19 @@ class AppStoragePlatform : AppStorage {
override fun remove(key: String) { override fun remove(key: String) {
defaults.removeObjectForKey(key) defaults.removeObjectForKey(key)
} }
override fun <T : Any> observe(key: String, type: KClass<T>): Flow<T> = callbackFlow {
val observer = NSNotificationCenter.defaultCenter.addObserverForName(
name = NSUserDefaultsDidChangeNotification,
`object` = null,
queue = null
) { _ ->
load(key, type)?.let { trySend(it) }
}
// emit initial value
load(key, type)?.let { trySend(it) }
awaitClose {
NSNotificationCenter.defaultCenter.removeObserver(observer)
}
}.filterNotNull().map { it }
} }