Auto connect ble, update realtime setting
This commit is contained in:
+23
@@ -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()
|
||||||
}
|
}
|
||||||
+7
-1
@@ -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 {
|
||||||
|
|||||||
+5
@@ -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)
|
||||||
|
}
|
||||||
+3
@@ -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,
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
-1
@@ -105,7 +105,6 @@ fun ScannerScreen(
|
|||||||
} else {
|
} else {
|
||||||
viewModel.disconnect(deviceId)
|
viewModel.disconnect(deviceId)
|
||||||
}
|
}
|
||||||
viewModel.addBondedDevice(deviceId, device.name ?: "No name")
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-31
@@ -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,7 +95,10 @@ 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()
|
||||||
|
if(_autoConnect.value && isBonded(bleId)){
|
||||||
|
connect(advertisement)
|
||||||
|
} else {
|
||||||
val now = TimeSource.Monotonic.markNow()
|
val now = TimeSource.Monotonic.markNow()
|
||||||
advertisementMap[advertisement.identifier.toString()] = advertisement to now
|
advertisementMap[advertisement.identifier.toString()] = advertisement to now
|
||||||
startCleanupJob()
|
startCleanupJob()
|
||||||
@@ -114,6 +106,7 @@ class ScannerViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun startCleanupJob() {
|
private fun startCleanupJob() {
|
||||||
if (cleanupJob?.isActive == true) return
|
if (cleanupJob?.isActive == true) return
|
||||||
@@ -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
|
||||||
|
|||||||
+3
@@ -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;
|
||||||
|
|
||||||
|
|||||||
+22
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user