ソースを参照

新增蓝牙作业任务的读取和下发

bjb 1 ヶ月 前
コミット
0333d31829

+ 6 - 0
app/src/main/AndroidManifest.xml

@@ -22,6 +22,12 @@
     <!--  Android11及以后,发现蓝牙设备需要这个定位权限  -->
     <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
 
+    <!--  NFC识别需要使用  -->
+    <uses-permission android:name="android.permission.NFC"/>
+    <uses-feature
+        android:name="android.hardware.nfc"
+        android:required="true"/>
+
 
     <application
         android:name=".Entry"

+ 8 - 0
app/src/main/java/com/iscs/bozzys/Entry.kt

@@ -5,20 +5,28 @@ import com.alibaba.sdk.android.push.noonesdk.PushInitConfig
 import com.alibaba.sdk.android.push.noonesdk.PushServiceFactory
 import com.iscs.bozzys.utils.LogUtil
 import com.iscs.bozzys.utils.Storage
+import com.iscs.bozzys.utils.ble.BleTask
 
 /**
  * App主入口
  */
 class Entry : Application() {
 
+    companion object {
+        lateinit var app: Application
+    }
+
     override fun onCreate() {
         super.onCreate()
+        app = this
         // 初始化日志工具
         LogUtil.init(this)
         // 初始化轻量级存储相关
         Storage.init(this)
         // 初始化消息推送
         initPush()
+        // 初始化蓝牙设备任务处理器
+        BleTask.init(this)
     }
 
     private fun initPush() {

+ 27 - 0
app/src/main/java/com/iscs/bozzys/api/ApiRequest.kt

@@ -351,4 +351,31 @@ object ApiRequest {
         return requestApi { api.uploadUserFace(username, part) }
     }
 
+    /**
+     * 获取钥匙列表
+     *
+     * @param params
+     */
+    suspend fun getKeyList(params: MutableMap<String, Any>): Result<Response<ResponsePage<Key>>> {
+        return requestApi { api.getKeyList(params) }
+    }
+
+    /**
+     * 获取挂锁列表
+     *
+     * @param params
+     */
+    suspend fun getLockList(params: MutableMap<String, Any>): Result<Response<ResponsePage<Lock>>> {
+        return requestApi { api.getLockList(params) }
+    }
+
+    /**
+     * 创建作业票
+     *
+     * @param ticket    创建的作业票信息
+     */
+    suspend fun createTicket(ticket: Ticket): Result<Response<Boolean>> {
+        return requestApi { api.createTicket(ticket) }
+    }
+
 }

+ 30 - 0
app/src/main/java/com/iscs/bozzys/api/ApiService.kt

@@ -312,4 +312,34 @@ interface ApiService {
         @HeaderMap headers: Map<String, String> = ApiRequest.getUserHeaders()
     ): Response<UserCharacteristic>
 
+    /**
+     * 获取钥匙列表
+     */
+    @Headers("Content-Type: application/x-www-form-urlencoded")
+    @GET("/admin-api/iscs/key/getKeyPage")
+    suspend fun getKeyList(
+        @QueryMap params: MutableMap<String, Any>,
+        @HeaderMap headers: Map<String, String> = ApiRequest.getUserHeaders()
+    ): Response<ResponsePage<Key>>
+
+    /**
+     * 获取挂锁列表
+     */
+    @Headers("Content-Type: application/x-www-form-urlencoded")
+    @GET("/admin-api/iscs/lock/getLockPage")
+    suspend fun getLockList(
+        @QueryMap params: MutableMap<String, Any>,
+        @HeaderMap headers: Map<String, String> = ApiRequest.getUserHeaders()
+    ): Response<ResponsePage<Lock>>
+
+    /**
+     * 创建作业票
+     */
+    @Headers("Content-Type: application/json")
+    @POST("/admin-api/isc/work-handle/insertWorkTicket")
+    suspend fun createTicket(
+        @Body body: Ticket,
+        @HeaderMap headers: Map<String, String> = ApiRequest.getUserHeaders()
+    ): Response<Boolean>
+
 }

+ 7 - 2
app/src/main/java/com/iscs/bozzys/api/IsolationPoint.kt

@@ -8,6 +8,11 @@ import kotlinx.serialization.Serializable
 @Serializable
 data class IsolationPoint(
     val id: Int,
-    val pointName: String,
-    val pointNfc: String
+    val pointNfc: String,
+    val pointName: String?,
+    val ticketId: Int?,
+    val nodeId: Int?,
+    val lockNfc: String?,
+    val status: String?,
+    val pointIcon: String?
 )

+ 19 - 0
app/src/main/java/com/iscs/bozzys/api/Key.kt

@@ -0,0 +1,19 @@
+package com.iscs.bozzys.api
+
+import kotlinx.serialization.Serializable
+
+/**
+ * 隔离点位参与上锁的钥匙设备
+ *
+ * @param   id          钥匙id
+ * @param   keyNfc      钥匙RFID
+ * @param   keyStatus   钥匙状态(0-待取出 1-已取出 2-已归还)
+ * @param   macAddress  钥匙设备的蓝牙
+ */
+@Serializable
+data class Key(
+    val id: Int = 0,
+    val keyNfc: String = "",
+    val keyStatus: String = "",
+    val macAddress: String = "",
+)

+ 88 - 0
app/src/main/java/com/iscs/bozzys/api/KeyTicket.kt

@@ -0,0 +1,88 @@
+package com.iscs.bozzys.api
+
+import kotlinx.serialization.Serializable
+
+/**
+ * 票据信息
+ *
+ * @param cardNo        上锁人卡号
+ * @param password      密码 默认 123456
+ * @param effectiveTime 有效时间,单位小时,默认24
+ * @param data          作业票点位信息
+ */
+@Serializable
+data class KeyTicket(
+    val cardNo: String? = null,
+    val password: String = "123456",
+    val effectiveTime: Int = 24,
+    val data: List<TicketTask> = emptyList(),
+    val lockList: List<TicketLock> = emptyList()
+) {
+    /**
+     * 获取归还钥匙提交的参数
+     *
+     * @param keyNfc 钥匙rfid
+     */
+    fun getReturnParams(keyNfc: String): UpdateReturn {
+        val params = UpdateReturn(0, "", "", mutableListOf())
+        // 检查作业完成情况
+        data.getOrNull(0)?.let { keyData ->
+            params.keyNfc = keyNfc
+            keyData.dataList.forEach { point ->
+                params.target = point.target
+                params.list += ReturnKey(
+                    nodeId = keyData.taskId?.toInt() ?: 0,
+                    pointNfc = point.equipRfidNo,
+                    lockNfc = point.infoRfidNo ?: "",
+                    closed = point.closed
+                )
+            }
+        }
+        return params
+    }
+}
+
+/**
+ * 作业票任务信息
+ *
+ * @param taskCode  任务编号 nodeId
+ * @param taskId    任务id nodeId
+ */
+@Serializable
+data class TicketTask(
+    val taskCode: String? = null,
+    val taskId: String? = null,
+    val codeId: Int = 0,
+    val dataList: List<TicketPoint> = emptyList()
+)
+
+/**
+ * 作业票点位信息
+ *
+ * @param dataId        点位id
+ * @param equipRfidNo   点位rfid
+ * @param equipName     点位名称
+ * @param target        0-挂锁 1-解锁
+ * @param infoRfidNo    解锁时必传,锁的rfid
+ */
+@Serializable
+data class TicketPoint(
+    val dataId: Int = 0,
+    val equipRfidNo: String = "",
+    val equipName: String = "",
+    val target: Int = 0,
+    val infoRfidNo: String? = null,
+    val closed: Int = 0
+)
+
+/**
+ * 作业票锁具信息
+ *
+ * @param lockId    锁具id
+ * @param rfid      锁具rfid
+ */
+@Serializable
+data class TicketLock(
+    val lockId: Int = 0,
+    val rfid: String = ""
+)

+ 20 - 0
app/src/main/java/com/iscs/bozzys/api/Lock.kt

@@ -0,0 +1,20 @@
+package com.iscs.bozzys.api
+
+import kotlinx.serialization.Serializable
+
+/**
+ * 参与作业的挂锁
+ *
+ * @param id                id
+ * @param lockNfc           挂锁RFID
+ * @param lockStatus        锁具状态(0-待取出 1-未上锁 2-已上锁 3-待解锁 4-已解锁 5-已归还)
+ * @param isolationPointNfc 隔离点位的RFID
+ *
+ */
+@Serializable
+data class Lock(
+    val id: Int = 0,
+    val lockNfc: String = "",
+    val lockStatus: String = "",
+    val isolationPointNfc: String = ""
+)

+ 39 - 1
app/src/main/java/com/iscs/bozzys/api/Node.kt

@@ -41,6 +41,9 @@ data class Node(
     val appTemplateCode: String? = null,        // app消息推送
     val deviceNumber: String? = null,           // 设备序列号
     val attachments: String? = null,            // 携带的数据
+    val points: List<IsolationPoint>? = null,   // 隔离点位信息
+    val keys: List<Key>? = null,                // 隔离点位参与的挂锁
+    val locks: List<Lock>? = null,              // 隔离点位参与的钥匙
 ) {
 
     /**
@@ -334,7 +337,7 @@ data class Node(
                 true,
                 listOf("请选择隔离点"),
                 multiSelect = true,
-                options = isolationList.map { FormOption(it.pointName, it.id.toString()) },
+                options = isolationList.map { FormOption(it.pointName ?: "", it.id.toString()) },
                 value = valueOfIsolationPoints,
                 enabled = enabled
             )
@@ -449,6 +452,41 @@ data class Node(
         }
     }
 
+    /**
+     * 将作业票转换为JSON
+     */
+    fun toKeyTicket(lockList: List<Lock>): String {
+        val json = Json {
+            // 格式化默认值
+            encodeDefaults = true
+            // 去除字段为null的字段
+            explicitNulls = false
+        }
+        val target = if (type == "releaseIsolation") 1 else 0
+        // 锁具id构成
+        var lockId = 0
+        val keyTicket = KeyTicket(
+            data = listOf(
+                TicketTask(
+                    taskId = id.toString(),
+                    codeId = id,
+                    dataList = points?.map { point ->
+                        TicketPoint(
+                            dataId = point.id,
+                            equipName = point.pointName ?: point.pointNfc,
+                            equipRfidNo = point.pointNfc,
+                            target = target,
+                            infoRfidNo = point.lockNfc
+                        )
+                    } ?: emptyList())
+            ),
+            lockList = lockList.map { lock ->
+                lockId++
+                TicketLock(lockId = lockId, rfid = lock.lockNfc)
+            })
+        return json.encodeToString(keyTicket)
+    }
+
 }
 
 /**

+ 38 - 0
app/src/main/java/com/iscs/bozzys/api/Ticket.kt

@@ -0,0 +1,38 @@
+package com.iscs.bozzys.api
+
+/**
+ * 作业票信息
+ *
+ * @param nodeId        关联的节点id
+ * @param ticketContent 作业票信息
+ * @param keyNfcList    作业票关联的钥匙列表
+ * @param lockNfcList   作业票关联的锁具列表
+ * @param id            作业票id,获取时返回
+ * @param ticketStatus  作业票状态
+ *                      0-未开始 1-待上锁 2-进行中 3-待解锁 4-已解锁 5-已结束 6-已取消
+ */
+data class Ticket(
+    val nodeId: Int,
+    val ticketContent: String,
+    val keyNfcList: List<KeyNfc>,
+    val lockNfcList: List<LockNfc>,
+    val id: Int? = null,
+    val ticketStatus: Int? = null,
+)
+
+/**
+ * 钥匙信息
+ *
+ * @param keyNfc        钥匙rfid
+ * @param hardwareCode  硬件编号,锁控柜id
+ */
+data class KeyNfc(val keyNfc: String, val hardwareCode: String)
+
+/**
+ * 锁信息
+ *
+ * @param nodeId        关联节点id
+ * @param lockNfc       锁具rfid
+ * @param hardwareCode  硬件编号,锁控柜id
+ */
+data class LockNfc(val nodeId: Int, val lockNfc: String, val hardwareCode: String)

+ 23 - 0
app/src/main/java/com/iscs/bozzys/api/UpdateReturn.kt

@@ -0,0 +1,23 @@
+package com.iscs.bozzys.api
+
+import kotlinx.serialization.Serializable
+
+/**
+ * 更新归还信息
+ */
+@Serializable
+data class UpdateReturn(var target: Int, var keyNfc: String, var hardwareCode: String, val list: MutableList<ReturnKey>)
+
+/**
+ * 归还挂锁
+ */
+@Serializable
+data class ReturnLock(val lockNfc: String, val hardwareCode: String)
+
+/**
+ * 归还钥匙
+ *
+ * @param closed 当前点位是否已完成
+ */
+@Serializable
+data class ReturnKey(val nodeId: Int, val pointNfc: String, val lockNfc: String, val closed: Int)

+ 1 - 1
app/src/main/java/com/iscs/bozzys/api/User.kt

@@ -20,7 +20,7 @@ data class User(
     val mobile: String? = null,
     val sex: Int = 1,
     val avatar: String = "",
-    val status: Int = 0,
+    val status: String = "",
     val loginIp: String = "",
     val loginDate: Long = 0L,
     val createTime: Long = 0L,

+ 242 - 16
app/src/main/java/com/iscs/bozzys/ui/pages/detail/task/PageDetailTask.kt

@@ -1,15 +1,24 @@
 package com.iscs.bozzys.ui.pages.detail.task
 
+import android.Manifest
+import android.app.PendingIntent
 import android.content.Context
 import android.content.Intent
+import android.nfc.NfcAdapter
+import android.nfc.Tag
 import android.os.Build
+import android.os.Bundle
+import androidx.activity.viewModels
 import androidx.compose.foundation.background
+import androidx.compose.foundation.horizontalScroll
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.ExperimentalLayoutApi
 import androidx.compose.foundation.layout.FlowRow
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
@@ -30,13 +39,16 @@ import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
-import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.core.app.ActivityCompat
+import coil.compose.AsyncImage
 import com.iscs.bozzys.R
 import com.iscs.bozzys.api.Task
 import com.iscs.bozzys.ui.common.PageBase
@@ -63,6 +75,11 @@ class PageDetailTask : PageBase() {
     // 页面携带数据对象
     private lateinit var task: Task
 
+    private lateinit var nfcAdapter: NfcAdapter
+    private lateinit var nfcPendingIntent: PendingIntent
+    private val vm: VMDetailTask by viewModels()
+
+
     /**
      * 获取页面携带的数据
      */
@@ -76,10 +93,48 @@ class PageDetailTask : PageBase() {
         return true
     }
 
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        nfcAdapter = NfcAdapter.getDefaultAdapter(this)
+        nfcPendingIntent = PendingIntent.getActivity(
+            this,
+            0,
+            Intent(this, javaClass).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
+            PendingIntent.FLAG_MUTABLE
+        )
+        val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+            arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
+        } else {
+            arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
+        }
+        ActivityCompat.requestPermissions(this, permissions, 12)
+        vm.onAddTaskListener()
+    }
+
+    override fun onResume() {
+        super.onResume()
+        nfcAdapter.enableForegroundDispatch(this, nfcPendingIntent, null, null)
+    }
+
+    override fun onPause() {
+        super.onPause()
+        nfcAdapter.disableForegroundDispatch(this)
+    }
+
+    override fun onNewIntent(intent: Intent) {
+        super.onNewIntent(intent)
+        val rfid = intent.getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG) ?: return
+        vm.updateRfid(rfid)
+    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+        vm.onRemoveTaskListener()
+    }
+
     @Composable
     override fun GetViews(pv: PaddingValues) {
         if (!getPageData()) return
-        val vm: VMDetailTask = viewModel()
         LaunchedEffect(Unit) {
             vm.toast.initToast()
             vm.loading.initLoading()
@@ -292,25 +347,166 @@ class PageDetailTask : PageBase() {
      */
     @Composable
     fun TaskDevice(vm: VMDetailTask) {
-        CardBox(
-            Modifier
-                .padding(top = 10.dp)
-                .fillMaxWidth()
-        ) {
+        val state by vm.state.collectAsState()
+//        CardBox(
+//            Modifier
+//                .padding(top = 10.dp)
+//                .fillMaxWidth()
+//        ) {
+//            Column(
+//                Modifier
+//                    .fillMaxWidth()
+//                    .height(120.dp),
+//                horizontalAlignment = Alignment.CenterHorizontally,
+//                verticalArrangement = Arrangement.Center
+//            ) {
+//                Icon(
+//                    painter = painterResource(R.drawable.job_warning),
+//                    contentDescription = null,
+//                    modifier = Modifier.size(60.dp),
+//                    tint = MaterialTheme.colorScheme.primary
+//                )
+//                Text("请前往锁控柜进行取锁,取钥匙操作", fontSize = 14.sp, color = Text)
+//            }
+//        }
+        // 点位信息
+        CardBox(Modifier.padding(top = 10.dp)) {
+            Column(
+                Modifier
+                    .fillMaxWidth()
+                    .padding(10.dp)
+            ) {
+                Row(verticalAlignment = Alignment.CenterVertically) {
+                    Icon(
+                        painterResource(R.drawable.location),
+                        contentDescription = null,
+                        modifier = Modifier
+                            .padding(end = 5.dp)
+                            .size(16.dp),
+                        tint = MaterialTheme.colorScheme.primary
+                    )
+                    Text("隔离点位", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = Text)
+                }
+                Row(
+                    modifier = Modifier
+                        .padding(top = 8.dp)
+                        .height(80.dp)
+                        .fillMaxWidth()
+                        .horizontalScroll(rememberScrollState())
+                ) {
+                    state.node.points?.forEach {
+                        Column(
+                            modifier = Modifier
+                                .padding(end = 10.dp)
+                                .fillMaxHeight()
+                                .aspectRatio(1f)
+                                .clip(RoundedCornerShape(6.dp))
+                                .background(Color(0xFFFFF8E6)),
+                            verticalArrangement = Arrangement.Center,
+                            horizontalAlignment = Alignment.CenterHorizontally
+                        ) {
+                            AsyncImage(
+                                it.pointIcon,
+                                contentDescription = null,
+                                modifier = Modifier.size(40.dp),
+                                contentScale = ContentScale.Fit
+                            )
+                            Text(it.pointName ?: "", fontSize = 12.sp, lineHeight = 12.sp, modifier = Modifier.padding(top = 10.dp), color = Text)
+                        }
+                    }
+                }
+            }
+        }
+        // 所需设备
+        CardBox(Modifier.padding(top = 10.dp)) {
             Column(
                 Modifier
                     .fillMaxWidth()
-                    .height(120.dp),
-                horizontalAlignment = Alignment.CenterHorizontally,
-                verticalArrangement = Arrangement.Center
+                    .padding(10.dp)
             ) {
-                Icon(
-                    painter = painterResource(R.drawable.job_warning),
-                    contentDescription = null,
-                    modifier = Modifier.size(60.dp),
-                    tint = MaterialTheme.colorScheme.primary
+                Row(verticalAlignment = Alignment.CenterVertically) {
+                    Icon(
+                        painterResource(R.drawable.return_device),
+                        contentDescription = null,
+                        modifier = Modifier
+                            .padding(end = 5.dp)
+                            .size(16.dp),
+                        tint = MaterialTheme.colorScheme.primary
+                    )
+                    Text(state.deviceInfo.first, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = Text)
+                }
+                if (state.deviceInfo.second.isNotEmpty()) Text(
+                    state.deviceInfo.second,
+                    fontSize = 14.sp,
+                    color = Text.copy(alpha = 0.5f),
+                    modifier = Modifier.padding(start = 21.dp)
                 )
-                Text("请前往锁控柜进行取锁,取钥匙操作", fontSize = 14.sp, color = Text)
+                Row(
+                    modifier = Modifier
+                        .padding(top = 8.dp)
+                        .height(80.dp)
+                        .fillMaxWidth()
+                        .horizontalScroll(rememberScrollState())
+                ) {
+                    state.keys.forEach {
+                        Column(
+                            modifier = Modifier
+                                .padding(end = 10.dp)
+                                .fillMaxHeight()
+                                .aspectRatio(1f)
+                                .clip(RoundedCornerShape(6.dp))
+                                .background(Color(0xFFFFF8E6)),
+                            verticalArrangement = Arrangement.Center,
+                            horizontalAlignment = Alignment.CenterHorizontally
+                        ) {
+                            Icon(
+                                painter = painterResource(R.drawable.key),
+                                contentDescription = null,
+                                modifier = Modifier
+                                    .size(40.dp)
+                                    .alpha(if (it.keyNfc.isEmpty()) 0.5f else 1f),
+                            )
+                            Text(
+                                it.keyNfc.ifEmpty { "--" },
+                                fontSize = 12.sp,
+                                lineHeight = 12.sp,
+                                modifier = Modifier
+                                    .padding(top = 10.dp)
+                                    .alpha(if (it.keyNfc.isEmpty()) 0.5f else 1f),
+                                color = Text
+                            )
+                        }
+                    }
+                    state.locks.forEach {
+                        Column(
+                            modifier = Modifier
+                                .padding(end = 10.dp)
+                                .fillMaxHeight()
+                                .aspectRatio(1f)
+                                .clip(RoundedCornerShape(6.dp))
+                                .background(Color(0xFFFFF8E6)),
+                            verticalArrangement = Arrangement.Center,
+                            horizontalAlignment = Alignment.CenterHorizontally
+                        ) {
+                            Icon(
+                                painter = painterResource(R.drawable.lock),
+                                contentDescription = null,
+                                modifier = Modifier
+                                    .size(40.dp)
+                                    .alpha(if (it.lockNfc.isEmpty()) 0.5f else 1f),
+                            )
+                            Text(
+                                it.lockNfc.ifEmpty { "--" },
+                                fontSize = 12.sp,
+                                lineHeight = 12.sp,
+                                modifier = Modifier
+                                    .padding(top = 10.dp)
+                                    .alpha(if (it.lockNfc.isEmpty()) 0.5f else 1f),
+                                color = Text
+                            )
+                        }
+                    }
+                }
             }
         }
     }
@@ -322,6 +518,7 @@ class PageDetailTask : PageBase() {
     fun TaskOptions(pv: PaddingValues, vm: VMDetailTask) {
         val pb = pv.calculateBottomPadding()
         val state by vm.state.collectAsState()
+        if (state.node.id <= 0) return
         Column(
             Modifier
                 .padding(bottom = if (pb.value <= 0) 10.dp else pb)
@@ -439,6 +636,35 @@ class PageDetailTask : PageBase() {
                             Text("审核通过", fontSize = 16.sp, lineHeight = 16.sp, fontWeight = FontWeight.Bold, color = Color.White)
                         }
                     }
+                } else if (listOf("isolation", "releaseIsolation").contains(state.node.type) && state.btn.first.isNotEmpty()) {
+                    Button(
+                        { vm.sendTicket2Key() },
+                        enabled = state.node.approvalStatus != "approved" && state.btn.second,
+                        modifier = Modifier
+                            .padding(horizontal = 8.dp)
+                            .weight(1f)
+                            .height(50.dp)
+                            .clip(RoundedCornerShape(12.dp)),
+                        shape = RoundedCornerShape(12.dp)
+                    ) {
+                        Row(verticalAlignment = Alignment.CenterVertically) {
+                            Icon(
+                                painter = painterResource(R.drawable.job_finish),
+                                contentDescription = null,
+                                modifier = Modifier
+                                    .padding(end = 8.dp)
+                                    .size(18.dp),
+                                tint = Color.White
+                            )
+                            Text(
+                                state.btn.first,
+                                fontSize = 16.sp,
+                                lineHeight = 16.sp,
+                                fontWeight = FontWeight.Bold,
+                                color = Color.White
+                            )
+                        }
+                    }
                 }
             }
         }

+ 0 - 73
app/src/main/java/com/iscs/bozzys/ui/pages/home/HomeCompose.kt

@@ -1,6 +1,5 @@
 package com.iscs.bozzys.ui.pages.home
 
-import android.app.Application
 import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -57,20 +56,6 @@ import com.iscs.bozzys.ui.pages.return_device.openPageReturnDevice
 import com.iscs.bozzys.ui.pages.vm.VMHome
 import com.iscs.bozzys.ui.theme.Text
 import com.iscs.bozzys.utils.DateUtil.getShowDateOrTime
-import com.iscs.bozzys.utils.LogUtil
-import com.iscs.bozzys.utils.ble.BleFrameExt
-import com.iscs.bozzys.utils.ble.BleFrameExt.buildBLEGetPowerCMD
-import com.iscs.bozzys.utils.ble.BleFrameExt.buildBLEGetStatusCMD
-import com.iscs.bozzys.utils.ble.BleFrameExt.buildBLESwitchRunModeCMD
-import com.iscs.bozzys.utils.ble.BleFrameExt.buildBLETicketDataCMDList
-import com.iscs.bozzys.utils.ble.BleFrameExt.getPower
-import com.iscs.bozzys.utils.ble.BleFrameExt.getRunMode
-import com.iscs.bozzys.utils.ble.BleFrameExt.getSendTicketResult
-import com.iscs.bozzys.utils.ble.BleFrameExt.getSwitchRunModeResult
-import com.iscs.bozzys.utils.ble.BleFrameExt.getToken
-import com.iscs.bozzys.utils.ble.BleManager
-import com.iscs.bozzys.utils.ble.BleRunMode
-import com.iscs.bozzys.utils.byteArrayToHexString
 import com.iscs.bozzys.utils.getRoleName
 
 @OptIn(ExperimentalMaterial3Api::class)
@@ -224,64 +209,6 @@ private fun TopToolBar(pv: PaddingValues, vm: VMHome) {
     }
 }
 
-val testJobJson =
-    "{\"cardNo\":\"D2931A25\",\"data\":[{\"codeId\":1,\"dataList\":[{\"dataId\":87,\"equipName\":\"E_29\",\"equipRfidNo\":\"1B9105AF\",\"target\":0},{\"dataId\":88,\"equipName\":\"E_30\",\"equipRfidNo\":\"FB9091E5\",\"target\":0}],\"taskCode\":\"165\"}],\"lockList\":[{\"lockId\":1,\"rfid\":\"C097D395\"},{\"lockId\":2,\"rfid\":\"A04AD495\"}],\"password\":\"123456\"}"
-
-
-private suspend fun bleKeyTest(application: Application, mac: String) {
-    // 测试蓝牙扫描
-    val bm = BleManager(application, mac = mac)
-    val result = bm.connect()
-    if (result.connected) {
-        // 获取设备token
-        val token = bm.writeByResponse(BleFrameExt.buildBLEGetTokenCMD()).getToken()
-        LogUtil.i("xiaoming $mac", "获取设备token ${token.byteArrayToHexString()}")
-        // 获取设备电量
-        val power = bm.writeByResponse(token.buildBLEGetPowerCMD()).getPower()
-        LogUtil.i("xiaoming $mac", "当前设备电量:$power")
-        // 获取当前设备运行模式
-        val runMode = bm.writeByResponse(token.buildBLEGetStatusCMD()).getRunMode()
-        LogUtil.i("xiaoming $mac", "当前工作模式:$runMode")
-        // 切换当前设备运行模式
-//                val switch = bm.writeByResponse(token.buildBLESwitchRunModeCMD(RunMode.STBY)).getSwitchRunModeResult()
-//                LogUtil.i("xiaoming $mac", "切换工作模式:$switch")
-        // 下发作业票
-        val tickets = token.buildBLETicketDataCMDList(testJobJson)
-        var ticketSendOk = true
-        tickets.forEach {
-            val ticket = bm.writeByResponse(it).getSendTicketResult()
-            LogUtil.i("xiaoming $mac", "下发作业票:分包${it.data[4].toInt()}发送结果:$ticket")
-            if (ticket != 0) {
-                ticketSendOk = false
-                return@forEach
-            }
-        }
-        LogUtil.i("xiaoming $mac", "下发作业票:$ticketSendOk")
-        // 作业票下发成功,修改设备运行模式为工作模式
-        val switch = bm.writeByResponse(token.buildBLESwitchRunModeCMD(BleRunMode.WORK))
-            .getSwitchRunModeResult()
-        LogUtil.i("xiaoming $mac", "切换工作模式:$switch")
-        // 读取作业票信息
-//            val pkgList = ArrayList<BleTicketDataPackage>()
-//            val ticketInfo = bm.writeByResponse(token.buildBLEGetTicketInfoCMD()).getTicketPackageInfo()
-//            pkgList.add(ticketInfo)
-//            LogUtil.i("xiaoming $mac", "读取作业票:首包信息:$ticketInfo")
-//            // 校验是否有子包,如果有,继续读取子包数据
-//            for (idx in 1 until ticketInfo.pkgTotal) {
-//                val ticketSubPackageInfo = bm.writeByResponse(token.buildBLEGetTicketInfoCMD(idx, ticketInfo.pkgTotal)).getTicketPackageInfo()
-//                pkgList.add(ticketSubPackageInfo)
-//                LogUtil.i("xiaoming $mac", "读取作业票:子包信息:$ticketSubPackageInfo")
-//            }
-//            var datas = byteArrayOf()
-//            pkgList.forEach { datas += it.pkgData }
-//            LogUtil.i("xiaoming $mac", "读取作业票:${String(datas)}")
-    } else {
-        // 进行重连,这里可以封装尝试次数
-        bleKeyTest(application, mac)
-    }
-    bm.disconnect()
-}
-
 /**
  * 待办任务
  */

+ 178 - 1
app/src/main/java/com/iscs/bozzys/ui/pages/vm/VMDetailTask.kt

@@ -1,10 +1,13 @@
 package com.iscs.bozzys.ui.pages.vm
 
+import android.nfc.Tag
 import androidx.lifecycle.viewModelScope
 import com.iscs.bozzys.api.ApiRequest
 import com.iscs.bozzys.api.ApiRequest.getResponse
 import com.iscs.bozzys.api.Attachment
 import com.iscs.bozzys.api.FormField
+import com.iscs.bozzys.api.Key
+import com.iscs.bozzys.api.Lock
 import com.iscs.bozzys.api.Node
 import com.iscs.bozzys.api.Task
 import com.iscs.bozzys.api.TaskFormInfo
@@ -13,7 +16,10 @@ import com.iscs.bozzys.event.RefreshEventBus
 import com.iscs.bozzys.ui.common.VMBase
 import com.iscs.bozzys.ui.pages.compose.checkCanCommitReturnTips
 import com.iscs.bozzys.ui.pages.compose.getFormListByJsonList
+import com.iscs.bozzys.utils.LogUtil
 import com.iscs.bozzys.utils.SystemUtil
+import com.iscs.bozzys.utils.ble.BleTask
+import com.iscs.bozzys.utils.ble.OnTaskStatusChangeListener
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asStateFlow
@@ -44,6 +50,36 @@ class VMDetailTask : VMBase() {
         "{\"id\":\"field_1770613171131\",\"type\":\"upload\",\"label\":\"附件上传\",\"name\":\"attachments\",\"required\":false,\"placeholder\":\"\",\"options\":[],\"uploadType\":\"file\",\"maxCount\":5}"
     )
 
+    private var keys: ArrayList<Key> = arrayListOf()
+
+    private var locks: ArrayList<Lock> = arrayListOf()
+
+    private val onTaskChangeListener = object : OnTaskStatusChangeListener() {
+        override fun onSendTicketSuccess() {
+            super.onSendTicketSuccess()
+            viewModelScope.launch {
+                val node = _state.value.node
+                val keyList = _state.value.keys
+                val lockList = _state.value.locks
+                // 作业票创建
+                val ticket = Ticket(
+                    node.id,
+                    "作业票_${node.id}",
+                    keyList.map { KeyNfc(it.keyNfc, "") },
+                    lockList.map { LockNfc(node.id, it.lockNfc, "") })
+                val ticketRsp = ApiRequest.createTicket(ticket).getOrElse { it.getResponse() }
+                if (!ticketRsp.code.isCodeOk()) {
+                    loading.emit(StateLoading())
+                    toast.emit("作业票创建失败")
+                } else {
+                    // 刷新页面
+                    getTaskFormInfo(this@VMDetailTask.task)
+                    BleTask.removeDeviceUsed(mac)
+                }
+            }
+        }
+    }
+
     /**
      * 获取任务表单信息
      */
@@ -93,6 +129,10 @@ class VMDetailTask : VMBase() {
                                 forms = (this@VMDetailTask.formInfo.fields?.getFormListByJsonList() ?: emptyList()).toMutableList(),
                                 node = taskInfo
                             )
+                        // 更新所需设备信息
+                        updateDeviceInfo()
+                        // 更新底部按钮状态
+                        updateButtonStatus()
                         delay(500)
                         loading.emit(StateLoading())
                     }.onFailure { err ->
@@ -101,6 +141,15 @@ class VMDetailTask : VMBase() {
                         toast.emit("获取表单异常:${err.getResponse<TaskFormInfo>().msg}")
                     }
                 }
+                // 初始化获取所有挂锁和钥匙设备
+                ApiRequest.getKeyList(mutableMapOf("page" to 1, "pageSize" to -1)).onSuccess { rsp ->
+                    this@VMDetailTask.keys.clear()
+                    this@VMDetailTask.keys.addAll(rsp.data?.list ?: emptyList())
+                }
+                ApiRequest.getLockList(mutableMapOf("page" to 1, "pageSize" to -1)).onSuccess { rsp ->
+                    this@VMDetailTask.locks.clear()
+                    this@VMDetailTask.locks.addAll(rsp.data?.list ?: emptyList())
+                }
             }.onFailure {
                 delay(500)
                 loading.emit(StateLoading(show = false))
@@ -109,6 +158,38 @@ class VMDetailTask : VMBase() {
         }
     }
 
+    /**
+     * 更新底部按钮状态
+     */
+    fun updateButtonStatus() {
+        // 校验底部按钮是否可以显示
+        if (!checkCanShowTakeButton()) {
+            _state.value = _state.value.copy(btn = "" to false)
+        } else {
+            _state.value = _state.value.copy(btn = "下发作业票到设备" to true)
+        }
+    }
+
+    /**
+     * 更新设备信息
+     */
+    fun updateDeviceInfo() {
+        val node = _state.value.node
+        if (!checkCanShowTakeButton()) {
+            val keys = mutableListOf<Key>()
+            val locks = mutableListOf<Lock>()
+            node.keys?.forEach { keys += it }
+            node.locks?.forEach { locks += it }
+            _state.value = _state.value.copy(keys = keys, locks = locks, deviceInfo = "关联设备信息" to "")
+        } else {
+            _state.value = _state.value.copy(deviceInfo = "所需设备" to "请将参与作业设备靠近PDA识别区进行录入")
+            // 根据点位个数构造所需钥匙和挂锁数量
+            val locks = mutableListOf<Lock>()
+            node.points?.forEach { locks += Lock() }
+            _state.value = _state.value.copy(keys = mutableListOf(Key()), locks = locks)
+        }
+    }
+
     /**
      * 提交按钮被点击
      */
@@ -158,6 +239,98 @@ class VMDetailTask : VMBase() {
             }
         }
     }
+
+    /**
+     * 更新刷卡识别的rfid
+     */
+    fun updateRfid(tag: Tag) {
+        viewModelScope.launch {
+            val id = tag.id.joinToString("") { "%02X".format(it) }
+            LogUtil.d("VMDetailTask", "RFID -> $id")
+            // 首先从已录入设备列表中查找,设备是否已经录入
+            if (_state.value.keys.any { it.keyNfc == id } || _state.value.locks.any { it.lockNfc == id }) {
+                toast.emit("该设备已录入,无需重复录入")
+                return@launch
+            }
+            // 查找钥匙
+            keys.find { it.keyNfc == id }?.let {
+                // 找到钥匙,匹配到钥匙中
+                _state.value = _state.value.copy(keys = mutableListOf(it))
+                BleTask.markDeviceUsed(it.macAddress, "")
+                return@launch
+            }
+            // 查找挂锁
+            locks.find { it.lockNfc == id }?.let { lock ->
+                var isReplace = false
+                val newList = _state.value.locks.map {
+                    if (!isReplace && it.lockNfc.isEmpty()) {
+                        isReplace = true
+                        Lock(id = lock.id, lockNfc = lock.lockNfc)
+                    } else it
+                }
+                _state.value = _state.value.copy(locks = newList.toMutableList())
+                return@launch
+            }
+            // 未查到任何设备,未知设备
+            toast.emit("未知设备")
+        }
+    }
+
+    /**
+     * 下发作业任务至设备
+     */
+    fun sendTicket2Key() {
+        viewModelScope.launch {
+            if (_state.value.keys.any { it.keyNfc.isEmpty() } || _state.value.locks.any { it.lockNfc.isEmpty() }) {
+                toast.emit("设备录入不完整,请录入剩余设备")
+                return@launch
+            }
+            loading.emit(StateLoading(show = true))
+            val mac = _state.value.keys.getOrNull(0)?.macAddress ?: ""
+            val node = _state.value.node
+            val locks = _state.value.locks
+            BleTask.markDeviceUsed(mac, node.toKeyTicket(locks))
+        }
+    }
+
+    fun onAddTaskListener() {
+        BleTask.addOnTaskChangeListener(onTaskChangeListener)
+    }
+
+    fun onRemoveTaskListener() {
+        BleTask.removeOnTaskChangeListener(onTaskChangeListener)
+    }
+
+    /**
+     * 校验当前是否可以显示获取设备按钮
+     */
+    private fun checkCanShowTakeButton(): Boolean {
+        val node = _state.value.node
+        LogUtil.d("PointInfo", "points -> ${node.points}, keys -> ${node.keys}, locks -> ${node.locks}")
+        if (node.type == "isolation") { // 隔离
+            // 如果初始化还未配置钥匙和锁具,这里是要显示取设备的
+            if (node.keys.isNullOrEmpty() || node.locks.isNullOrEmpty() || node.points.isNullOrEmpty()) return true
+            // 未取出或已归还钥匙数
+            val unTakeKeyCount = node.keys.filter { it.keyStatus != "1" }.size
+            // 未隔离的点位个数
+            val unPointsCount = node.points.filter { it.status != "1" }.size
+            return unTakeKeyCount != 0 && unPointsCount != 0
+        } else if (node.type == "releaseIsolation") { // 解除隔离
+            // 解除隔离按钮只有所有共锁人都解锁后可进行
+            // 是否所有共锁人都已解除共锁
+            val isCoUnlocked = node.nodeUserList?.filter { user -> user.type == "jtcolocker" }?.all { user -> user.status == "2" } ?: false
+            val isNoColocker = node.nodeUserList?.none { it.type == "jtcolocker" } == true
+            if (!isCoUnlocked && !isNoColocker) return false
+            if (node.keys.isNullOrEmpty() || node.locks.isNullOrEmpty() || node.points.isNullOrEmpty()) return true
+            // 未取出或已归还钥匙数
+            val unTakeKeyCount = node.keys.filter { it.keyStatus != "1" }.size
+            // 未解除隔离的点位个数
+            val unPointsCount = node.points.filter { it.status != "2" }.size
+            return unTakeKeyCount != 0 && unPointsCount != 0
+        }
+        return true
+    }
+
 }
 
 /**
@@ -170,5 +343,9 @@ class VMDetailTask : VMBase() {
 data class StateDetailTask(
     val forms: MutableList<FormField> = mutableListOf(),
     val mbForms: MutableList<FormField> = mutableListOf(),
-    val node: Node = Node()
+    val node: Node = Node(),
+    val keys: MutableList<Key> = mutableListOf(),
+    val locks: MutableList<Lock> = mutableListOf(),
+    val btn: Pair<String, Boolean> = "" to false,
+    val deviceInfo: Pair<String, String> = "" to ""
 )

+ 17 - 1
app/src/main/java/com/iscs/bozzys/utils/ble/BleFrameExt.kt

@@ -87,6 +87,13 @@ object BleFrameExt {
         return BleFrame(BleProtocol.REQ_WORK_TICKET_RESULT, getUnixTime() + this, BleProtocol.RSP_WORK_TICKET_RESULT)
     }
 
+    /**
+     * 断开蓝牙操作
+     */
+    fun ByteArray.buildBLEDisconnectCMD(): BleFrame {
+        return BleFrame(BleProtocol.REQ_DISCONNECT, getUnixTime() + this, BleProtocol.RSP_DISCONNECT)
+    }
+
     /**
      * 获取token
      */
@@ -151,12 +158,21 @@ object BleFrameExt {
             val pkgIdx = this.data[4] + this.data[5]
             val pkgSize = this.data[8].toUByte() + this.data[9].toUByte()
             val pkgData = this.data.copyOfRange(10, pkgSize.toInt() + 10)
-            // ISCSLog.i("getTicketPackageInfo", "包信息:总包数:$pkgCount, 当前包:$pkgIdx, 当前包大小:$pkgSize, 当前包数据:${pkgData.byteArrayToHexString("")}")
             return BleTicketDataPackage(pkgIdx, pkgSize.toInt(), pkgData, pkgCount)
         }
         return BleTicketDataPackage(0, 0, byteArrayOf(), 0)
     }
 
+    /**
+     * 获取断开连接结果
+     */
+    fun BleFrame.getDisconnectResult(): Int {
+        if (this.rspCode.contentEquals(BleProtocol.RSP_DISCONNECT)) {
+            return this.data[0].toInt()
+        }
+        return 99
+    }
+
 }
 
 private fun getUnixTime(): ByteArray {

+ 11 - 36
app/src/main/java/com/iscs/bozzys/utils/ble/BleManager.kt

@@ -9,6 +9,7 @@ import android.bluetooth.BluetoothGattCharacteristic
 import android.bluetooth.BluetoothGattDescriptor
 import android.bluetooth.BluetoothManager
 import android.bluetooth.BluetoothProfile
+import android.bluetooth.BluetoothStatusCodes
 import android.bluetooth.le.ScanCallback
 import android.bluetooth.le.ScanFilter
 import android.bluetooth.le.ScanResult
@@ -135,43 +136,11 @@ class BleManager(
             LogUtil.i(TAG, "gattCallback onCharacteristicRead()")
         }
 
-        override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
-            super.onCharacteristicWrite(gatt, characteristic, status)
-            LogUtil.w(TAG, "gattCallback ---> ${characteristic.value.byteArrayToHexString(" ")}")
-        }
-
         override fun onDescriptorRead(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int, value: ByteArray) {
             super.onDescriptorRead(gatt, descriptor, status, value)
             LogUtil.i(TAG, "gattCallback onDescriptorRead() data write success")
         }
 
-        override fun onDescriptorRead(gatt: BluetoothGatt?, descriptor: BluetoothGattDescriptor?, status: Int) {
-            super.onDescriptorRead(gatt, descriptor, status)
-            LogUtil.i(TAG, "gattCallback onDescriptorRead() data write success")
-        }
-
-        override fun onCharacteristicChanged(gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?) {
-            super.onCharacteristicChanged(gatt, characteristic)
-            LogUtil.w(TAG, "gattCallback <--- ${characteristic?.value?.byteArrayToHexString(" ")}")
-            val data = characteristic?.value ?: byteArrayOf()
-            var key = ""
-            receiverPool.forEach { item ->
-                if (data.byteArrayToHexString().startsWith(item.key.split("_")[1])) {
-                    // 找到指定响应体
-                    key = item.key
-                    return@forEach
-                }
-            }
-            if (key.isNotEmpty()) {
-                val deferred = receiverPool.remove(key)
-                if (deferred != null && !deferred.isCompleted) {
-                    val spl = key.split("_")
-                    val rspCodeLen = spl[1].length / 2
-                    deferred.complete(BleFrame(spl[0].hexToByteArray(), data.copyOfRange(rspCodeLen, data.size), spl[1].hexToByteArray()))
-                }
-            }
-        }
-
         override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
             super.onCharacteristicChanged(gatt, characteristic, value)
             LogUtil.w(TAG, "gattCallback <--- ${value.byteArrayToHexString(" ")}")
@@ -197,10 +166,15 @@ class BleManager(
     /**
      * 协程方式的连接
      */
-    suspend fun connect(): BleConnectResult = suspendCancellableCoroutine { block ->
+    suspend fun connect(device: BluetoothDevice? = null): BleConnectResult = suspendCancellableCoroutine { block ->
         this.doneConnect = block
-        // 开始执行扫描连接
-        scan()
+        if (device == null) {
+            // 开始执行扫描连接
+            scan()
+        } else {
+            this.device = device
+            innerConnect()
+        }
     }
 
     /**
@@ -275,7 +249,7 @@ class BleManager(
 
             // 写入desc
             val enable = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
-                gatt?.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_INDICATION_VALUE)
+                gatt?.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_INDICATION_VALUE) == BluetoothStatusCodes.SUCCESS
             } else {
                 descriptor.value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
                 gatt?.writeDescriptor(descriptor)
@@ -294,6 +268,7 @@ class BleManager(
         val deferred = CompletableDeferred<BleFrame>()
         val writeUUID = if (frame.writeUUID.isNotEmpty()) UUID.fromString(frame.writeUUID) else UUID.fromString(writeUUID)
         receiverPool["${frame.reqCode.byteArrayToHexString()}_${frame.rspCode.byteArrayToHexString()}"] = deferred
+        LogUtil.w(TAG, "gattCallback ---> ${frame.reqCode.byteArrayToHexString(" ")} ${frame.data.byteArrayToHexString(" ")}")
         // 发送数据方法兼容处理
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
             gatt?.getService(UUID.fromString(serviceUUID))?.getCharacteristic(writeUUID)?.let {

+ 6 - 0
app/src/main/java/com/iscs/bozzys/utils/ble/BleProtocol.kt

@@ -38,6 +38,12 @@ object BleProtocol {
     // 工作模式切换响应
     val RSP_SWITCH_MODE = byteArrayOf(0x02, 0x02, 0x03, 0x01)
 
+    // 断开蓝牙请求
+    val REQ_DISCONNECT = byteArrayOf(0x02, 0x01, 0x01, 0xEA.toByte())
+
+    // 断开蓝牙响应
+    val RSP_DISCONNECT = byteArrayOf(0x02, 0x02, 0x02, 0xEA.toByte())
+
     // 工作票下发
     val REQ_SEND_WORK_TICKET = byteArrayOf(0x02, 0x01)
 

+ 236 - 0
app/src/main/java/com/iscs/bozzys/utils/ble/BleTask.kt

@@ -0,0 +1,236 @@
+package com.iscs.bozzys.utils.ble
+
+import android.annotation.SuppressLint
+import android.app.Application
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothManager
+import android.bluetooth.le.ScanCallback
+import android.bluetooth.le.ScanFilter
+import android.bluetooth.le.ScanResult
+import android.bluetooth.le.ScanSettings
+import com.iscs.bozzys.Entry
+import com.iscs.bozzys.utils.LogUtil
+import com.iscs.bozzys.utils.ble.BleFrameExt.buildBLEDisconnectCMD
+import com.iscs.bozzys.utils.ble.BleFrameExt.buildBLEGetPowerCMD
+import com.iscs.bozzys.utils.ble.BleFrameExt.buildBLEGetStatusCMD
+import com.iscs.bozzys.utils.ble.BleFrameExt.buildBLEGetTicketInfoCMD
+import com.iscs.bozzys.utils.ble.BleFrameExt.buildBLESwitchRunModeCMD
+import com.iscs.bozzys.utils.ble.BleFrameExt.buildBLETicketDataCMDList
+import com.iscs.bozzys.utils.ble.BleFrameExt.getDisconnectResult
+import com.iscs.bozzys.utils.ble.BleFrameExt.getPower
+import com.iscs.bozzys.utils.ble.BleFrameExt.getRunMode
+import com.iscs.bozzys.utils.ble.BleFrameExt.getSendTicketResult
+import com.iscs.bozzys.utils.ble.BleFrameExt.getSwitchRunModeResult
+import com.iscs.bozzys.utils.ble.BleFrameExt.getTicketPackageInfo
+import com.iscs.bozzys.utils.ble.BleFrameExt.getToken
+import com.iscs.bozzys.utils.byteArrayToHexString
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+/**
+ * 用于管理蓝牙钥匙设备的任务读取操作
+ *
+ * 1. 蓝牙扫描任务常开
+ * 2. 循环连接蓝牙设备,读取设备任务完成情况
+ */
+object BleTask {
+
+    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+    // 蓝牙管理器
+    private lateinit var bm: BluetoothManager
+
+    // 蓝牙适配器
+    private lateinit var ba: BluetoothAdapter
+
+    // 设备上一次连接时间,用于防止设备频繁连接
+    private val deviceLastConnectTimes: MutableMap<String, Long> = mutableMapOf()
+
+    // 正在使用中的设备
+    private val usedDevices: MutableMap<String, String> = mutableMapOf()
+
+    // 待处理的蓝牙设备
+    private val deviceQueue = Channel<BluetoothDevice>(capacity = Channel.BUFFERED)
+
+    // 监听任务的读取和下发完成
+    private val listener: MutableList<OnTaskStatusChangeListener> = mutableListOf()
+
+    // 蓝牙扫描回调
+    private val scanCallback = object : ScanCallback() {
+
+        override fun onBatchScanResults(results: List<ScanResult?>?) {
+            super.onBatchScanResults(results)
+            // 队列为空时,数据放入队列
+            if (!deviceQueue.tryReceive().isSuccess) results?.forEach {
+                if (it != null) deviceQueue.trySend(it.device)
+            }
+        }
+
+    }
+
+    /**
+     * 蓝牙任务管理初始化
+     *
+     * @param app
+     */
+    fun init(app: Application) {
+        bm = app.getSystemService(BluetoothManager::class.java)
+        ba = bm.adapter
+        scan()
+        scope.launch { for (device in deviceQueue) execTask(device) }
+    }
+
+    /**
+     * 标记设备为使用中
+     */
+    fun markDeviceUsed(mac: String, value: String) {
+        usedDevices[mac] = value
+    }
+
+    /**
+     * 从使用列表中移除
+     */
+    fun removeDeviceUsed(mac: String) {
+        usedDevices.remove(mac)
+    }
+
+    fun addOnTaskChangeListener(listener: OnTaskStatusChangeListener) {
+        this.listener += listener
+    }
+
+    fun removeOnTaskChangeListener(listener: OnTaskStatusChangeListener) {
+        this.listener.remove(listener)
+    }
+
+    @SuppressLint("MissingPermission")
+    private fun scan() {
+        // 获取扫描对象
+        val scan = ba.bluetoothLeScanner
+        // 设置扫描过滤
+        val filters = ArrayList<ScanFilter>()
+        filters.add(ScanFilter.Builder().setDeviceName("keyLock").build())
+        val settings = ScanSettings.Builder()
+            .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
+            .setReportDelay(5000)
+            .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
+            .build()
+        scan.startScan(filters, settings, scanCallback)
+    }
+
+    /**
+     * 执行设备的操作
+     */
+    private suspend fun execTask(device: BluetoothDevice) {
+        // 上一次处理过,间隔5s内的不再处理
+        val times = System.currentTimeMillis() - deviceLastConnectTimes.getOrDefault(device.address, 0)
+        if (times < 5000L) {
+            LogUtil.w("BleTask", "[${device.address}] 设备连接间隔短,等待下次执行连接")
+            return
+        }
+        if (usedDevices.contains(device.address)) {
+            if (usedDevices[device.address].isNullOrEmpty()) {
+                LogUtil.w("BleTask", "[${device.address}] 设备即将被使用,不做状态获取处理")
+            } else {
+                // 执行作业任务的下发操作
+                sendTicket2Device(device)
+            }
+            return
+        }
+        LogUtil.d("BleTask", "[${device.address}] 执行设备连接")
+        val bm = BleManager(Entry.app, mac = device.address)
+        val result = bm.connect(device)
+        if (result.connected) {
+            // 获取设备token
+            val token = bm.writeByResponse(BleFrameExt.buildBLEGetTokenCMD()).getToken()
+            LogUtil.i("BleTask", "[${device.address}] 获取token:${token.byteArrayToHexString()}")
+            // 获取设备电量
+            val power = bm.writeByResponse(token.buildBLEGetPowerCMD()).getPower()
+            LogUtil.i("BleTask", "[${device.address}] 当前电量:$power")
+            // 获取当前设备运行模式
+            val runMode = bm.writeByResponse(token.buildBLEGetStatusCMD()).getRunMode()
+            LogUtil.i("BleTask", "[${device.address}] 当前模式:$runMode")
+            if (runMode == BleRunMode.WORK) {
+                // 执行作业票的获取
+                val pkgList = ArrayList<BleTicketDataPackage>()
+                val mainPkg = bm.writeByResponse(token.buildBLEGetTicketInfoCMD()).getTicketPackageInfo()
+                pkgList.add(mainPkg)
+                // 校验是否有子包,如果有,继续读取子包数据
+                for (idx in 1 until mainPkg.pkgTotal) {
+                    val subPkg = bm.writeByResponse(token.buildBLEGetTicketInfoCMD(idx, mainPkg.pkgTotal)).getTicketPackageInfo()
+                    pkgList.add(subPkg)
+                }
+                var datas = byteArrayOf()
+                pkgList.forEach { datas += it.pkgData }
+                val ticketJson = String(datas)
+                LogUtil.i("BleTask", "[${device.address}] 读取钥匙作业 -> $ticketJson")
+            }
+            val disRet = bm.writeByResponse(token.buildBLEDisconnectCMD()).getDisconnectResult()
+            LogUtil.d("BleTask", "[${device.address}] 断开蓝牙连接:${disRet == 1}")
+            deviceLastConnectTimes[device.address] = System.currentTimeMillis()
+        }
+        bm.disconnect()
+    }
+
+    /**
+     * 发送作业票
+     */
+    private suspend fun sendTicket2Device(device: BluetoothDevice) {
+        val json = usedDevices[device.address] ?: "{}"
+        LogUtil.d("BleTask", "[${device.address}] 即将下发作业任务")
+        val bm = BleManager(Entry.app, mac = device.address)
+        val result = bm.connect(device)
+        if (result.connected) {
+            // 获取设备token
+            val token = bm.writeByResponse(BleFrameExt.buildBLEGetTokenCMD()).getToken()
+            LogUtil.i("BleTask", "获取token:${token.byteArrayToHexString()}")
+            // 获取设备电量
+            val power = bm.writeByResponse(token.buildBLEGetPowerCMD()).getPower()
+            LogUtil.i("BleTask", "当前电量:$power")
+            // 获取当前设备运行模式
+            val runMode = bm.writeByResponse(token.buildBLEGetStatusCMD()).getRunMode()
+            LogUtil.i("BleTask", "当前模式:$runMode")
+            // 下发作业票
+            val tickets = token.buildBLETicketDataCMDList(json)
+            var ticketSendOk = true
+            tickets.forEach {
+                delay(200)
+                val ticket = bm.writeByResponse(it).getSendTicketResult()
+                if (ticket != 0) {
+                    ticketSendOk = false
+                    return@forEach
+                }
+            }
+            LogUtil.i("BleTask", "下发作业票:$ticketSendOk")
+            // 作业票下发成功,修改设备运行模式为工作模式
+            val switch = bm.writeByResponse(token.buildBLESwitchRunModeCMD(BleRunMode.WORK))
+                .getSwitchRunModeResult()
+            LogUtil.i("BleTask", "切换工作模式:$switch")
+            val disRet = bm.writeByResponse(token.buildBLEDisconnectCMD()).getDisconnectResult()
+            LogUtil.d("BleTask", "断开蓝牙连接:${disRet == 1}")
+            removeDeviceUsed(device.address)
+            listener.forEach { it.onSendTicketSuccess() }
+        }
+        bm.disconnect()
+    }
+
+}
+
+/**
+ * 任务状态发生变化监听
+ */
+abstract class OnTaskStatusChangeListener {
+
+    open fun onSendTicketSuccess() {
+
+    }
+
+    open fun onReadTicketSuccess() {
+
+    }
+
+}