Implementation BLE using kable lib, handle scan ble, connect and send data via uart service
This commit is contained in:
@@ -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 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>
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -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/"
|
||||||
}
|
}
|
||||||
+60
-6
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+24
-4
@@ -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
|
||||||
|
)
|
||||||
+24
-4
@@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
+26
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-2
@@ -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>
|
||||||
}
|
}
|
||||||
+20
-4
@@ -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
@@ -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()) }
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -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
|
||||||
|
)
|
||||||
+2
-2
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+75
@@ -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() }
|
||||||
|
}
|
||||||
+10
-6
@@ -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) {
|
||||||
|
|||||||
+12
@@ -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"
|
||||||
|
)
|
||||||
+188
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+284
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user