Implementation BLE using kable lib, handle scan ble, connect and send data via uart service

This commit is contained in:
2026-05-30 12:15:24 +07:00
parent 31ea6be8cd
commit e55fcab158
21 changed files with 824 additions and 35 deletions
+2
View File
@@ -67,6 +67,8 @@ kotlin {
implementation(libs.sqldelight.coroutines) implementation(libs.sqldelight.coroutines)
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
implementation(libs.ble.kable)
implementation(libs.logger.napier) implementation(libs.logger.napier)
} }
iosMain.dependencies { iosMain.dependencies {
@@ -3,7 +3,7 @@
<string name="navigation_item_accessibility">Back</string> <string name="navigation_item_accessibility">Back</string>
<string name="menu_item_accessibility">Menu</string> <string name="menu_item_accessibility">Menu</string>
<string name="dbm">%d&#xA0;dBm</string> <string name="dbm">dBm</string>
<string name="home_title">Home</string> <string name="home_title">Home</string>
@@ -31,4 +31,8 @@
<string name="filter_title">Filters</string> <string name="filter_title">Filters</string>
<string name="sort_by_title">Sort by</string> <string name="sort_by_title">Sort by</string>
<string name="clear">Clear</string> <string name="clear">Clear</string>
<string name="scanner_title">Scanner</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>
</resources> </resources>
@@ -42,7 +42,7 @@ fun RssiIcon(rssi: Int) {
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
) )
Text( Text(
text = stringResource(Res.string.dbm, rssi), text = "$rssi ${stringResource(Res.string.dbm)}",
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall
) )
} }
@@ -5,6 +5,7 @@ import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.LoginResponseDt
import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.RefreshRequestDto import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.RefreshRequestDto
import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.RefreshResponseDto import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.RefreshResponseDto
import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager
import io.github.aakira.napier.Napier
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.post import io.ktor.client.request.post
@@ -29,7 +30,7 @@ class AuthApi(
tokenManager.saveTokens(res.access, res.refresh) tokenManager.saveTokens(res.access, res.refresh)
true true
} catch (e: Exception) { } catch (e: Exception) {
println(">>>>> [AuthApi.kt] Exception: $e") Napier.d(">>>>> [AuthApi.kt] Exception: $e")
false false
} }
} }
@@ -3,6 +3,10 @@ package com.digitoolsolutions.app.torquevaultkmp.data.network.api
object Endpoints { object Endpoints {
const val AUTH_TOKEN = "/token/" const val AUTH_TOKEN = "/token/"
const val AUTH_REFRESH = "/token/refresh/" const val AUTH_REFRESH = "/token/refresh/"
const val WORK_ORDERS = "/work-orders/able-v2/" const val WORK_ORDERS = "work-orders"
const val CONFIRM = "/confirm" const val ABLE_WO = "$WORK_ORDERS/able-v2/"
const val CONFIRM = "connect-wrench/"
const val MEASURE = "measure/"
const val RE_MEASURE = "remeasure/"
const val CANCEL = "cancel-by-wrench/"
} }
@@ -1,14 +1,24 @@
package com.digitoolsolutions.app.torquevaultkmp.data.network.api package com.digitoolsolutions.app.torquevaultkmp.data.network.api
import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.ConfirmRequestDto import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.ConfirmRequestDto
import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.LoginRequestDto
import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.LoginResponseDto
import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.MeasureResDto
import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.SendMeasureDto
import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.WorkOrderDto import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.WorkOrderDto
import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager
import io.github.aakira.napier.Napier
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.parameter import io.ktor.client.request.parameter
import io.ktor.client.request.patch
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.http.path
class WorkOrderApi( class WorkOrderApi(
private val client: HttpClient, private val client: HttpClient,
@@ -17,15 +27,59 @@ class WorkOrderApi(
private val baseUrl: String? private val baseUrl: String?
get() = tokenManager.getServerUrl() get() = tokenManager.getServerUrl()
suspend fun fetchAbleWorkOrders(page: Int): List<WorkOrderDto> { suspend fun fetchAbleWorkOrders(woID: String, action: String): List<WorkOrderDto> {
return client.get("$baseUrl${Endpoints.WORK_ORDERS}") { return client.get("$baseUrl/${Endpoints.ABLE_WO}") {
parameter("page", page) parameter("woID", woID)
parameter("action", action)
}.body() }.body()
} }
suspend fun confirm(mac: String, type: Int) { suspend fun confirm(woID: String, deviceId: String, type: Int): Boolean {
client.post("$baseUrl${Endpoints.CONFIRM}") { return try {
setBody(ConfirmRequestDto(mac, type)) val response = client.patch("$baseUrl/${Endpoints.WORK_ORDERS}/$woID/${Endpoints.CONFIRM}"){
contentType(ContentType.Application.Json)
setBody(ConfirmRequestDto(deviceId, type))
}
response.status == HttpStatusCode.OK
} catch (e: Exception) {
Napier.e(">>>>> Exception: $e", tag="WorkOrderApi.kt")
false
}
}
suspend fun sendMeasurement(woID: String, bodyPayload: SendMeasureDto, isRemeasure: Boolean = false): Result<MeasureResDto> {
return try {
val endPath = if(isRemeasure) Endpoints.RE_MEASURE else Endpoints.MEASURE
val response = client.patch("$baseUrl/${Endpoints.WORK_ORDERS}/$woID/$endPath") {
contentType(ContentType.Application.Json)
setBody(bodyPayload)
}
if (response.status.value in 200..299) {
Result.success(response.body<MeasureResDto>())
} else {
Napier.e("Error status: ${response.status}")
Result.failure(Exception("HTTP ${response.status}"))
}
} catch (e: Exception) {
Napier.e("Exception: $e")
Result.failure(e)
}
}
suspend fun cancelWorkOrder(woId: String, bodyPayload: SendMeasureDto): Result<WorkOrderDto> {
return try {
val response = client.patch("$baseUrl/${Endpoints.WORK_ORDERS}/$woId/${Endpoints.CANCEL}") {
contentType(ContentType.Application.Json)
setBody(bodyPayload)
}
if (response.status.value in 200..299) {
Result.success(response.body<WorkOrderDto>())
} else {
Napier.e("Error status: ${response.status}")
Result.failure(Exception("HTTP ${response.status}"))
}
} catch (e: Exception) {
Napier.e("Exception: $e")
Result.failure(e)
} }
} }
} }
@@ -9,11 +9,11 @@ data class WorkOrderDto(
@SerialName("wo_id") val id: String, @SerialName("wo_id") val id: String,
val status: String, val status: String,
val car: String? = null, val car: String? = null,
val make: String?, val make: String? = null,
@SerialName("license_plate") val licensePlate: String, @SerialName("license_plate") val licensePlate: String,
val services: Map<String, ServiceDto>?, val services: Map<String, ServiceDto>?,
@SerialName("meas_result") val measResult: Map<String, JsonElement>?, @SerialName("meas_result") val measResult: MeasResultDto? = null,
@SerialName("meas_history") val measHistory: Map<String, JsonElement>?, @SerialName("meas_history") val measHistory: MeasResultDto? = null,
@SerialName("finished_at") val finishedAt: String?, @SerialName("finished_at") val finishedAt: String?,
@SerialName("created_at") val createdAt: String, @SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String? = null, @SerialName("updated_at") val updatedAt: String? = null,
@@ -26,9 +26,29 @@ data class ServiceDto(
val wrench: String?, val wrench: String?,
@SerialName("torque_required") val torqueRequired: Double @SerialName("torque_required") val torqueRequired: Double
) )
@Serializable
data class MeasResultDto(
val nut: Int? = null,
@SerialName("torque_val") val torqueVal: Map<String, List<Double>>? = null
)
@Serializable @Serializable
data class ConfirmRequestDto( data class ConfirmRequestDto(
@SerialName("wrench_mac_addr") val mac: String, @SerialName("device_id") val deviceId: String,
val type: Int val type: Int
) )
@Serializable
data class SendMeasureDto(
@SerialName("device_id") val deviceId: String,
val type: Int,
@SerialName("nuts_of_wheel") val nuts: Int? = null,
@SerialName("torque_results") val torqueData: Map<String, List<Double>>? = null,
@SerialName("oil_filter_val") val oilFilterVal: Double? = null,
@SerialName("drain_plug_val") val drainPlugVal: Double? = null
)
@Serializable
data class MeasureResDto(
val status: String,
val message: String,
val data: WorkOrderDto
)
@@ -1,6 +1,9 @@
package com.digitoolsolutions.app.torquevaultkmp.data.network.mapper package com.digitoolsolutions.app.torquevaultkmp.data.network.mapper
import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.MeasureResDto
import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.WorkOrderDto import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.WorkOrderDto
import com.digitoolsolutions.app.torquevaultkmp.domain.model.MeasResult
import com.digitoolsolutions.app.torquevaultkmp.domain.model.MeasureResult
import com.digitoolsolutions.app.torquevaultkmp.domain.model.Service import com.digitoolsolutions.app.torquevaultkmp.domain.model.Service
import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder
import kotlinx.serialization.json.doubleOrNull import kotlinx.serialization.json.doubleOrNull
@@ -22,14 +25,31 @@ fun WorkOrderDto.toDomain(): WorkOrder {
torqueRequired = dto.torqueRequired torqueRequired = dto.torqueRequired
) )
}, },
measResult = measResult?.mapValues { (_, value) -> measResult = measResult?.let {
value.jsonPrimitive.doubleOrNull ?: Double.NaN it.torqueVal?.let { torqueVal ->
MeasResult(
nut = it.nut,
torqueVal = torqueVal
)
}
}, },
measHistory = measHistory?.mapValues { (_, value) -> measHistory = measHistory?.let {
value.jsonPrimitive.doubleOrNull ?: Double.NaN it.torqueVal?.let { torqueVal ->
MeasResult(
nut = it.nut,
torqueVal = torqueVal
)
}
}, },
finishedAt = finishedAt, finishedAt = finishedAt,
createdAt = createdAt, createdAt = createdAt,
updatedAt = updatedAt updatedAt = updatedAt
) )
} }
fun MeasureResDto.toDomain(): MeasureResult {
return MeasureResult(
status = status,
message = message,
data = data.toDomain()
)
}
@@ -0,0 +1,26 @@
package com.digitoolsolutions.app.torquevaultkmp.data.repository
import com.digitoolsolutions.app.torquevaultkmp.data.network.api.WorkOrderApi
import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.MeasureResDto
import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.SendMeasureDto
import com.digitoolsolutions.app.torquevaultkmp.data.network.mapper.toDomain
import com.digitoolsolutions.app.torquevaultkmp.domain.model.MeasureResult
import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder
class ScannerRepository(private val woRepository: WorkOrderRepository) {
suspend fun getAbleWorkOrders(woID: String = "0", action: String = "next"): List<WorkOrder> {
return woRepository.fetchAbleWorkOrders(woID, action)
}
suspend fun confirmWorkOrder(woID: String, deviceId: String, type: Int): Boolean {
return woRepository.confirm(woID, deviceId, type)
}
suspend fun sendMeasurementResult(woId: String, data: SendMeasureDto, isReTorque: Boolean = false): Result<MeasureResult> {
return woRepository.sendMeasurement(woId, data, isReTorque)
}
suspend fun sendCancelWorkOrder(woId: String, data: SendMeasureDto): Result<WorkOrder> {
return woRepository.cancelWorkOrder(woId, data)
}
}
@@ -1,8 +1,13 @@
package com.digitoolsolutions.app.torquevaultkmp.data.repository package com.digitoolsolutions.app.torquevaultkmp.data.repository
import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.MeasureResDto
import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.SendMeasureDto
import com.digitoolsolutions.app.torquevaultkmp.domain.model.MeasureResult
import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder
interface WorkOrderRepository { interface WorkOrderRepository {
suspend fun fetchAbleWorkOrders(page: Int): List<WorkOrder> suspend fun fetchAbleWorkOrders(woId: String, action: String): List<WorkOrder>
suspend fun confirm(mac: String, type: Int) suspend fun confirm(woID: String, deviceId: String, type: Int): Boolean
suspend fun sendMeasurement(woId: String, data: SendMeasureDto, isReTorque: Boolean = false): Result<MeasureResult>
suspend fun cancelWorkOrder(woId: String, data: SendMeasureDto): Result<WorkOrder>
} }
@@ -1,15 +1,31 @@
package com.digitoolsolutions.app.torquevaultkmp.data.repository package com.digitoolsolutions.app.torquevaultkmp.data.repository
import com.digitoolsolutions.app.torquevaultkmp.data.network.api.WorkOrderApi import com.digitoolsolutions.app.torquevaultkmp.data.network.api.WorkOrderApi
import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.SendMeasureDto
import com.digitoolsolutions.app.torquevaultkmp.data.network.mapper.toDomain import com.digitoolsolutions.app.torquevaultkmp.data.network.mapper.toDomain
import com.digitoolsolutions.app.torquevaultkmp.domain.model.MeasureResult
import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder
class WorkOrderRepositoryImpl(private val api: WorkOrderApi) : WorkOrderRepository { class WorkOrderRepositoryImpl(private val api: WorkOrderApi) : WorkOrderRepository {
override suspend fun fetchAbleWorkOrders(page: Int): List<WorkOrder> { override suspend fun fetchAbleWorkOrders(woId: String, action: String): List<WorkOrder> {
val dto = api.fetchAbleWorkOrders(page) val dto = api.fetchAbleWorkOrders(woId, action)
return dto.map { it.toDomain() } return dto.map { it.toDomain() }
} }
override suspend fun confirm(mac: String, type: Int) { override suspend fun confirm(
api.confirm(mac, type) woID: String,
deviceId: String,
type: Int
): Boolean {
return api.confirm(woID,deviceId, type)
}
override suspend fun sendMeasurement(woId: String, data: SendMeasureDto, isReTorque: Boolean): Result<MeasureResult> {
val dto = api.sendMeasurement(woId, data, isReTorque)
return dto.map { it.toDomain() }
}
override suspend fun cancelWorkOrder(woId: String, data: SendMeasureDto): Result<WorkOrder> {
val dto = api.cancelWorkOrder(woId, data)
return dto.map { it.toDomain() }
} }
} }
@@ -6,15 +6,18 @@ import com.digitoolsolutions.app.torquevaultkmp.data.network.api.AuthApi
import com.digitoolsolutions.app.torquevaultkmp.data.network.api.WorkOrderApi import com.digitoolsolutions.app.torquevaultkmp.data.network.api.WorkOrderApi
import com.digitoolsolutions.app.torquevaultkmp.data.repository.DeviceRepository import com.digitoolsolutions.app.torquevaultkmp.data.repository.DeviceRepository
import com.digitoolsolutions.app.torquevaultkmp.data.repository.LogRepository import com.digitoolsolutions.app.torquevaultkmp.data.repository.LogRepository
import com.digitoolsolutions.app.torquevaultkmp.data.repository.ScannerRepository
import com.digitoolsolutions.app.torquevaultkmp.data.repository.WorkOrderRepository import com.digitoolsolutions.app.torquevaultkmp.data.repository.WorkOrderRepository
import com.digitoolsolutions.app.torquevaultkmp.data.repository.WorkOrderRepositoryImpl import com.digitoolsolutions.app.torquevaultkmp.data.repository.WorkOrderRepositoryImpl
import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager
import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.AppDatabase import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.AppDatabase
import com.digitoolsolutions.app.torquevaultkmp.kable.BleManager
import com.digitoolsolutions.app.torquevaultkmp.domain.usecase.FetchWorkOrdersUseCase import com.digitoolsolutions.app.torquevaultkmp.domain.usecase.FetchWorkOrdersUseCase
import com.digitoolsolutions.app.torquevaultkmp.screens.auth.AuthViewModel import com.digitoolsolutions.app.torquevaultkmp.screens.auth.AuthViewModel
import com.digitoolsolutions.app.torquevaultkmp.screens.bonded.BondedViewModel import com.digitoolsolutions.app.torquevaultkmp.screens.bonded.BondedViewModel
import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeViewModel import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeViewModel
import com.digitoolsolutions.app.torquevaultkmp.screens.logs.LogViewModel import com.digitoolsolutions.app.torquevaultkmp.screens.logs.LogViewModel
import com.digitoolsolutions.app.torquevaultkmp.screens.scanner.ScannerViewModel
import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingViewModel import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingViewModel
import org.koin.core.module.Module import org.koin.core.module.Module
import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.factoryOf
@@ -27,6 +30,7 @@ expect val platformModule: Module
val appModule = module { val appModule = module {
// TokenManager use AppStorage // TokenManager use AppStorage
single { TokenManager(get()) } single { TokenManager(get()) }
singleOf(::BleManager)
} }
val storageModule = module { val storageModule = module {
@@ -34,6 +38,7 @@ val storageModule = module {
single { get<AppDatabase>().appDatabaseQueries } single { get<AppDatabase>().appDatabaseQueries }
single { LogRepository(get()) } single { LogRepository(get()) }
single { DeviceRepository(get()) } single { DeviceRepository(get()) }
single { ScannerRepository(get()) }
} }
val networkModule = module { val networkModule = module {
@@ -58,4 +63,5 @@ val viewModelModule = module {
factoryOf(::SettingViewModel) factoryOf(::SettingViewModel)
factoryOf(::BondedViewModel) factoryOf(::BondedViewModel)
factoryOf(::LogViewModel) factoryOf(::LogViewModel)
factory { ScannerViewModel(get(), get(), get(), get()) }
} }
@@ -29,8 +29,8 @@ data class WorkOrder(
val make: String?, val make: String?,
val licensePlate: String, val licensePlate: String,
val services: Map<String, Service>?, val services: Map<String, Service>?,
val measResult: Map<String, Double>?, val measResult: MeasResult?,
val measHistory: Map<String, Double>?, val measHistory: MeasResult?,
val finishedAt: String?, val finishedAt: String?,
val createdAt: String, val createdAt: String,
val updatedAt: String? val updatedAt: String?
@@ -42,3 +42,14 @@ data class Service(
val wrench: String?, val wrench: String?,
val torqueRequired: Double val torqueRequired: Double
) )
data class MeasResult(
val nut: Int? = null,
val torqueVal: Map<String, List<Double>>
)
data class MeasureResult(
val status: String,
val message: String,
val data: WorkOrder
)
@@ -4,7 +4,7 @@ import com.digitoolsolutions.app.torquevaultkmp.data.repository.WorkOrderReposit
import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder
class FetchWorkOrdersUseCase(private val repository: WorkOrderRepository) { class FetchWorkOrdersUseCase(private val repository: WorkOrderRepository) {
suspend operator fun invoke(page: Int): List<WorkOrder> { suspend operator fun invoke(page: Int, woId: String = "0", action: String = "next"): List<WorkOrder> {
return repository.fetchAbleWorkOrders(page) return repository.fetchAbleWorkOrders(woId, action)
} }
} }
@@ -0,0 +1,75 @@
package com.digitoolsolutions.app.torquevaultkmp.kable
import com.juul.kable.Advertisement
import com.juul.kable.Peripheral
import com.juul.kable.Scanner
import com.juul.kable.characteristicOf
import com.juul.kable.logs.Logging
import com.juul.kable.logs.SystemLogEngine
import io.github.aakira.napier.Napier
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlin.uuid.Uuid
import kotlin.uuid.ExperimentalUuidApi
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@OptIn(ExperimentalUuidApi::class)
class BleManager {
companion object {
val SERVICE_UUID = Uuid.parse("6E400001-B5A3-F393-E0A9-E50E24DCCA9E")
val RX_CHAR = characteristicOf(SERVICE_UUID, Uuid.parse("6E400002-B5A3-F393-E0A9-E50E24DCCA9E"))
val TX_CHAR = characteristicOf(SERVICE_UUID, Uuid.parse("6E400003-B5A3-F393-E0A9-E50E24DCCA9E"))
}
private val peripherals = mutableMapOf<String, Peripheral>()
private val mutex = Mutex()
fun scanDevices(): Flow<Advertisement> = Scanner {
filters {
match {
services = listOf(SERVICE_UUID)
}
}
logging {
engine = SystemLogEngine
level = Logging.Level.Events
format = Logging.Format.Multiline
}
}.advertisements
suspend fun connect(advertisement: Advertisement): Peripheral {
val identifier = advertisement.identifier.toString()
Napier.d(">>>>> Connecting to $identifier")
val peripheral = 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() {
mutex.withLock {
peripherals.values.forEach { it.disconnect() }
peripherals.clear()
}
}
suspend fun sendCommand(peripheral: Peripheral, command: String) {
Napier.d(">>>>> Sent to uart: $command")
peripheral.write(RX_CHAR, command.encodeToByteArray())
}
fun observeRx(peripheral: Peripheral): Flow<String> =
peripheral.observe(TX_CHAR).map { it.decodeToString() }
}
@@ -26,6 +26,8 @@ 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.ScannerScreen
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
@@ -43,6 +45,7 @@ fun MainScreen(
) { ) {
val destinations = listOf( val destinations = listOf(
HomeDestination, HomeDestination,
ScannerDestination,
BondedDestination, BondedDestination,
LogDestination, LogDestination,
SettingDestination, SettingDestination,
@@ -111,14 +114,15 @@ fun MainScreen(
) { ) {
composable(AuthDestination.route) { LoginScreen(navController) } composable(AuthDestination.route) { LoginScreen(navController) }
composable(HomeDestination.route) { composable(HomeDestination.route) {
BluetoothRequirementWrapper { HomeScreen(navController)
HomeScreen(navController)
}
} }
composable(BondedDestination.route) { composable(BondedDestination.route) {
BluetoothRequirementWrapper { BondedScreen(navController)
BondedScreen(navController) }
} composable(ScannerDestination.route) {
ScannerScreen(onDeviceClick = {
// Handle device click, maybe navigate to details or connect
})
} }
composable(LogDestination.route) { LogScreen(navController) } composable(LogDestination.route) { LogScreen(navController) }
composable(SettingDestination.route) { composable(SettingDestination.route) {
@@ -0,0 +1,12 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.scanner
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 ScannerDestination: Destinations(
label = "Scanner",
icon = AppIcon.Vector(Icons.Outlined.Bluetooth),
route = "scanner"
)
@@ -0,0 +1,188 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.scanner
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.BluetoothSearching
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
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 com.digitoolsolutions.app.torquevaultkmp.components.AppBar
import com.digitoolsolutions.app.torquevaultkmp.components.CircularIcon
import com.digitoolsolutions.app.torquevaultkmp.components.RssiIcon
import com.digitoolsolutions.app.torquevaultkmp.components.WarningView
import com.digitoolsolutions.app.torquevaultkmp.kable.BleManager
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.viewmodel.koinViewModel
import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.baseline_filter_list_24
import torquevaultkmp.composeapp.generated.resources.baseline_warning_24
import torquevaultkmp.composeapp.generated.resources.filter_title
import torquevaultkmp.composeapp.generated.resources.no_device_guide_info
import torquevaultkmp.composeapp.generated.resources.scan_empty_title
import torquevaultkmp.composeapp.generated.resources.scanner_title
import kotlin.uuid.ExperimentalUuidApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class)
@Composable
fun ScannerScreen(
viewModel: ScannerViewModel = koinViewModel(),
onDeviceClick: (Advertisement) -> Unit
) {
val devices by viewModel.devices.collectAsState()
val connectedDevices by viewModel.connectedDevices.collectAsState()
val isScanning by viewModel.isScanning.collectAsState()
val pullToRefreshState = rememberPullToRefreshState()
Scaffold(
topBar = {
AppBar(
title = { Text(stringResource(Res.string.scanner_title)) },
actions = {
if (isScanning) {
CircularProgressIndicator(
modifier = Modifier.padding(4.dp).size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
IconButton(onClick = { viewModel.startScan() }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
}
}
)
}
) { paddingValues ->
PullToRefreshBox(
isRefreshing = false,
onRefresh = {
viewModel.refreshScan()
},
state = pullToRefreshState,
modifier = Modifier.padding(paddingValues).fillMaxSize(),
) {
if (devices.isEmpty()) {
WarningView(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
imageVector = Icons.AutoMirrored.Outlined.BluetoothSearching,
title = stringResource(Res.string.scan_empty_title),
hint = stringResource(Res.string.no_device_guide_info),
hintTextAlign = TextAlign.Justify,
){}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(devices) { device ->
val deviceId = device.identifier.toString()
val connectionState = connectedDevices[deviceId]
DeviceListItem(
device = device,
connectionState = connectionState,
onClick = {
if (connectionState == null || connectionState is State.Disconnected) {
viewModel.connect(device)
} else {
viewModel.disconnect(deviceId)
}
viewModel.addBondedDevice(deviceId, device.name ?: "No name")
}
)
}
}
}
}
}
LaunchedEffect(Unit) {
viewModel.startScan()
}
}
@OptIn(ExperimentalUuidApi::class)
@Composable
fun DeviceListItem(
device: Advertisement,
connectionState: State?,
onClick: () -> Unit
) {
val statusText = when (connectionState) {
is State.Connecting -> "Connecting..."
is State.Connected -> "Connected"
is State.Disconnecting -> "Disconnecting..."
is State.Disconnected -> "Disconnected"
null -> ""
}
OutlinedCard(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(4.dp))
.clickable { onClick() },
colors = if (connectionState is State.Connected) {
CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f))
} else {
CardDefaults.outlinedCardColors()
}
) {
ListItem(
headlineContent = {
Text(
text = device.name ?: "No name",
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
supportingContent = {
Column {
Text(
text = device.identifier.toString(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (statusText.isNotEmpty()) {
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = if (connectionState is State.Connected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
)
}
}
},
leadingContent = {
CircularIcon(
imageVector = Icons.Default.Bluetooth,
backgroundColor = if (connectionState is State.Connected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondaryContainer,
iconTint = if (connectionState is State.Connected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSecondaryContainer
)
},
trailingContent = {
if (connectionState is State.Connecting) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
} else {
RssiIcon(rssi = device.rssi)
}
}
)
}
}
@@ -0,0 +1,284 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.scanner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.SendMeasureDto
import com.digitoolsolutions.app.torquevaultkmp.kable.BleManager
import com.digitoolsolutions.app.torquevaultkmp.data.repository.DeviceRepository
import com.digitoolsolutions.app.torquevaultkmp.data.repository.ScannerRepository
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.juul.kable.Advertisement
import com.juul.kable.Peripheral
import com.juul.kable.State
import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder
import com.digitoolsolutions.app.torquevaultkmp.domain.model.computeWheelsNuts
import com.digitoolsolutions.app.torquevaultkmp.utils.Helper
import io.github.aakira.napier.Napier
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource
import kotlin.uuid.ExperimentalUuidApi
@OptIn(ExperimentalUuidApi::class)
class ScannerViewModel(
private val bleManager: BleManager,
private val storage: AppStorage,
private val deviceRepository: DeviceRepository,
private val scannerRepository: ScannerRepository
) : ViewModel() {
private val _devices = MutableStateFlow<List<Advertisement>>(emptyList())
val devices: StateFlow<List<Advertisement>> = _devices.asStateFlow()
private val _isScanning = MutableStateFlow(false)
val isScanning: StateFlow<Boolean> = _isScanning.asStateFlow()
private val _connectedDevices = MutableStateFlow<Map<String, State>>(emptyMap())
val connectedDevices: StateFlow<Map<String, State>> = _connectedDevices.asStateFlow()
private val _workOrders = MutableStateFlow<List<WorkOrder>>(emptyList())
private val _autoConnect = MutableStateFlow(storage.load<Boolean>(ReferKeys.DEVICE_AUTO_CONNECT) ?: false)
val autoConnect: StateFlow<Boolean> = _autoConnect.asStateFlow()
private val _onlyWithName = MutableStateFlow(true)
private var scanJob: Job? = null
private var cleanupJob: Job? = null
private val connectionJobs = mutableMapOf<String, Job>()
private val advertisementMap = mutableMapOf<String, Pair<Advertisement, TimeSource.Monotonic.ValueTimeMark>>()
companion object {
private val SCAN_TIMEOUT = 1.seconds
}
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))
}
}
fun startScan() {
if (_isScanning.value) return
scanJob = viewModelScope.launch {
_isScanning.value = true
_devices.value = emptyList()
advertisementMap.clear()
bleManager.scanDevices()
.catch {
_isScanning.value = false
stopCleanupJob()
}
.collect { advertisement ->
if (_onlyWithName.value && advertisement.name == null) return@collect
val now = TimeSource.Monotonic.markNow()
advertisementMap[advertisement.identifier.toString()] = advertisement to now
startCleanupJob()
updateDeviceList()
}
}
}
private fun startCleanupJob() {
if (cleanupJob?.isActive == true) return
cleanupJob = viewModelScope.launch {
while (advertisementMap.isNotEmpty() && isActive) {
delay(1000)
val keysToRemove = advertisementMap.filter { it.value.second.elapsedNow() > SCAN_TIMEOUT }.keys
if (keysToRemove.isNotEmpty()) {
keysToRemove.forEach { advertisementMap.remove(it) }
updateDeviceList()
}
}
}
}
private fun stopCleanupJob() {
cleanupJob?.cancel()
cleanupJob = null
}
private fun updateDeviceList() {
_devices.value = advertisementMap.values
.map { it.first }
.sortedByDescending { it.rssi }
}
fun stopScan() {
scanJob?.cancel()
scanJob = null
_isScanning.value = false
stopCleanupJob()
}
fun refreshScan() {
_devices.value = emptyList()
startScan()
}
fun connect(adv: Advertisement) {
val deviceIdentifier = adv.identifier.toString()
if (connectionJobs[deviceIdentifier]?.isActive == true) return
val job = viewModelScope.launch {
try {
val peripheral = bleManager.connect(adv)
coroutineScope {
// Tracking connection state
launch {
peripheral.state.collect { state ->
_connectedDevices.value += (deviceIdentifier to state)
if (state is State.Disconnected) {
this@coroutineScope.cancel(">>>>> Device disconnected")
}
}
}
// Listen rx data
launch {
bleManager.observeRx(peripheral).collectLatest { value ->
handleMessage(peripheral, value, deviceIdentifier)
}
}
}
} catch (e: Exception) {
if (e !is CancellationException) {
Napier.e(">>>>> Connection failed for $deviceIdentifier", e)
}
} finally {
_connectedDevices.value -= deviceIdentifier
connectionJobs.remove(deviceIdentifier)
bleManager.disconnect(deviceIdentifier)
Napier.d(">>>>> Cleaned up connection for $deviceIdentifier")
}
}
connectionJobs[deviceIdentifier] = job
}
fun disconnect(deviceIdentifier: String) {
viewModelScope.launch {
bleManager.disconnect(deviceIdentifier)
connectionJobs[deviceIdentifier]?.cancel()
connectionJobs.remove(deviceIdentifier)
_connectedDevices.value -= deviceIdentifier
}
}
private fun handleMessage(peripheral: Peripheral, msg: String, deviceIdentifier: String) {
val decoded = Helper.decodeRxData(msg) ?: return
val data = decoded.data
val woId = decoded.id
val cmd = data.take(4)
val action = if (cmd == "back") "back" else "next"
Napier.d(">>>>> Received data from $deviceIdentifier: $msg", tag="ScannerViewModel.kt")
Napier.d(">>>>> Data: $data", tag="ScannerViewModel.kt")
viewModelScope.launch {
when (cmd) {
"requ", "next", "back" -> fetchWorkOrders(peripheral, woId, action)
"conf" -> {
try {
val res = scannerRepository.confirmWorkOrder(woId, deviceIdentifier, 0)
var command = Helper.CMD_NOT_AVAILABLE_WO
if (res)
command = Helper.CMD_RECEIVED_WO
bleManager.sendCommand(peripheral, Helper.buildUartCommand(command))
} catch (e: Exception) {
Napier.e(">>>>> Failed to handle message: $msg", e)
bleManager.sendCommand(
peripheral, Helper.buildUartCommand(Helper.CMD_NOT_AVAILABLE_WO)
)
}
}
else -> {
/** Send torque, re-torque or cancel case */
val bleData = data.trim(' ').trimEnd('*').split(",")
val nuts: Int = bleData[Helper.BLE_NUTS_POS].toInt()
val torqueData: List<String> = bleData.subList(Helper.BLE_TORQUE_POS, bleData.size)
val wheelTorqueData = Helper.parseWheelTorqueData(torqueData, nuts)
val dto = SendMeasureDto(
deviceId = deviceIdentifier,
type = 0, // tbu
nuts = nuts,
torqueData = wheelTorqueData
)
when (val act = bleData.firstOrNull()) {
Helper.TorqueAction.TORQUE -> {
try {
scannerRepository.sendMeasurementResult(woId, dto)
} catch (e: Exception) {
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))
}
}
Helper.TorqueAction.RE_TORQUE -> {
try {
scannerRepository.sendMeasurementResult(woId, dto, true)
} catch (e: Exception) {
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))
}
}
Helper.TorqueAction.CANCEL -> {
try {
scannerRepository.sendCancelWorkOrder(woId, dto)
} catch (e: Exception) {
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))
}
}
else -> Napier.w(">>>>> Unknown action: $act")
}
}
}
}
}
fun addBondedDevice(id: String, name: String) {
viewModelScope.launch {
deviceRepository.saveDevice(id, name)
}
}
}
@@ -0,0 +1,55 @@
package com.digitoolsolutions.app.torquevaultkmp.utils
object Helper {
const val CMD_EMPTY_WO = $$"$e*\r\n"
const val CMD_RECEIVED_WO = $$"$r*\r\n"
const val CMD_NOT_AVAILABLE_WO = $$"$n*\r\n"
const val CMD_UPLOADED_WO = $$"$u*\r\n"
const val END_LINE_FEED = "#\r\n"
const val BLE_NUTS_POS = 4
const val BLE_TORQUE_POS = BLE_NUTS_POS + 1
object TorqueAction {
const val TORQUE = "0"
const val RE_TORQUE = "1"
const val CANCEL = "2"
}
data class RxMessage(
val id: String,
val data: String
)
fun buildUartCommand(cmd: String): String {
return "$cmd$END_LINE_FEED"
}
fun decodeRxData(msg: String): RxMessage? {
if (!msg.startsWith("$")) return null
val payload = msg.drop(1).trimEnd('*', ' ', '\r', '\n')
val parts = payload.split(",", limit = 2)
if (parts.size < 2) return null
val woID = parts[0]
val data = parts[1]
return RxMessage(woID, data)
}
fun parseWheelTorqueData(torqueData: List<String>, nutsPerWheel: Int): Map<String, List<Double>> {
val result = mutableMapOf<String, List<Double>>()
var i = 0
while (i < torqueData.size) {
val wheel = torqueData[i] // Wheel name
val torques = ArrayList<Double>(nutsPerWheel)
for (j in 0 until nutsPerWheel) {
val index = i + 1 + j
if (index < torqueData.size) {
val value = torqueData[index].toDoubleOrNull() ?: 0.0
torques.add(value)
}
}
result[wheel] = torques
i += nutsPerWheel + 1
}
return result
}
}
+2
View File
@@ -20,6 +20,7 @@ navigation-compose = "2.9.2"
logger = "2.7.1" logger = "2.7.1"
sqldelight = "2.3.2" sqldelight = "2.3.2"
kotlinx-datetime = "0.8.0" kotlinx-datetime = "0.8.0"
kable = "0.43.0"
[libraries] [libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
@@ -66,6 +67,7 @@ sqldelight-android = { module = "app.cash.sqldelight:android-driver", version.re
sqldelight-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" } sqldelight-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
ble-kable = { module = "com.juul.kable:kable-core", version.ref = "kable" }
[plugins] [plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" } androidApplication = { id = "com.android.application", version.ref = "agp" }