Преглед на файлове

refactor(CAN):
- 调整CAN节点扫描方式为线性扫描,并增加结束节点参数
- `CanReadyPlugin`在扫描完成后初始化锁和钥匙状态
- `CanCommand`增加读取超时配置
- `NodeIdHelper`调整为线性扫描和按位扫描两种模式

feat(系统):
- `ISCSApplication`在启动时根据硬件模式连接硬件
- `BootReceiver`重命名为`BootAndUnlockReceiver`,并在接收到开机广播后启动App

fix(UI):
- 统一部分弹窗样式,增加倒计时或取消按钮
- `SlotsManageFragment`修复CAN模式下布局显示问题
- `HomeFragment`在`onResume`时刷新首页数据
- 移除新增用户和管理员时的用户名正则校验

refactor(蓝牙):
- 优化`CheckKeyInfoTask`逻辑,确保在非登录态下执行,并增加超时和重试机制
- 优化`BleUtil`中蓝牙扫描逻辑,确保单实例扫描,并统一扫描参数设置

refactor(硬件):
- `HardwareBusinessManager`在处理CAN设备状态时,未登录情况下不做处理
- 增加`IHardwareHelper`中更新钥匙、锁RFID和新硬件状态等接口,并在`CanHardwareHelper`和`ModbusHardwareHelper`中实现
- `ModbusController`初始化时关闭所有钥匙仓位

周文健 преди 1 месец
родител
ревизия
4799b82591
променени са 25 файла, в които са добавени 562 реда и са изтрити 280 реда
  1. 1 1
      app/src/main/AndroidManifest.xml
  2. 1 1
      app/src/main/assets/i18n/zh-CN.json
  3. 11 5
      app/src/main/java/com/grkj/iscs/ISCSApplication.kt
  4. 4 4
      app/src/main/java/com/grkj/iscs/features/init/fragment/InitSetAdminAccountFragment.kt
  5. 4 4
      app/src/main/java/com/grkj/iscs/features/main/dialog/data_manage/AddUserDialog.kt
  6. 2 2
      app/src/main/java/com/grkj/iscs/features/main/fragment/exception_manage/ExceptionDetailFragment.kt
  7. 3 3
      app/src/main/java/com/grkj/iscs/features/main/fragment/exception_manage/ExceptionJobFragment.kt
  8. 7 1
      app/src/main/java/com/grkj/iscs/features/main/fragment/hardware_manage/SlotsManageFragment.kt
  9. 5 0
      app/src/main/java/com/grkj/iscs/features/main/fragment/home/HomeFragment.kt
  10. 22 0
      app/src/main/java/com/grkj/iscs/receivers/BootAndUnlockReceiver.kt
  11. 0 42
      app/src/main/java/com/grkj/iscs/receivers/BootReceiver.kt
  12. 1 1
      data/src/main/java/com/grkj/data/config/ISCSConfig.kt
  13. 3 3
      data/src/main/java/com/grkj/data/database/PresetData.kt
  14. 30 0
      data/src/main/java/com/grkj/data/hardware/IHardwareHelper.kt
  15. 48 57
      data/src/main/java/com/grkj/data/hardware/ble/BleUtil.kt
  16. 18 17
      data/src/main/java/com/grkj/data/hardware/can/CanCommand.kt
  17. 61 19
      data/src/main/java/com/grkj/data/hardware/can/CanHardwareHelper.kt
  18. 189 1
      data/src/main/java/com/grkj/data/hardware/can/CanReadyPlugin.kt
  19. 2 1
      data/src/main/java/com/grkj/data/hardware/can/CustomCanConfig.kt
  20. 34 48
      data/src/main/java/com/grkj/data/hardware/can/NodeIdHelper.kt
  21. 1 1
      data/src/main/java/com/grkj/data/hardware/modbus/ModBusController.kt
  22. 32 0
      data/src/main/java/com/grkj/data/hardware/modbus/ModBusHardwareHelper.kt
  23. 1 1
      ui-base/src/main/java/com/grkj/ui_base/base/BaseFormFragment.kt
  24. 7 1
      ui-base/src/main/java/com/grkj/ui_base/business/HardwareBusinessManager.kt
  25. 75 67
      ui-base/src/main/java/com/grkj/ui_base/service/CheckKeyInfoTask.kt

+ 1 - 1
app/src/main/AndroidManifest.xml

@@ -68,7 +68,7 @@
             android:windowSoftInputMode="stateHidden|adjustPan" />
 
         <receiver
-            android:name=".receivers.BootReceiver"
+            android:name=".receivers.BootAndUnlockReceiver"
             android:enabled="true"
             android:exported="true">
             <intent-filter android:priority="1000">

+ 1 - 1
app/src/main/assets/i18n/zh-CN.json

@@ -132,7 +132,7 @@
   "admin_username": {
     "key": "admin_username",
     "type": "text",
-    "value": "管理员账号:(数字、字母、6-20位)"
+    "value": "管理员账号:(数字、字母)"
   },
   "all": {
     "key": "all",

+ 11 - 5
app/src/main/java/com/grkj/iscs/ISCSApplication.kt

@@ -25,6 +25,8 @@ import com.grkj.shared.utils.i18n.source.AssetsI18nSource
 import com.grkj.shared.utils.i18n.source.FileI18nSource
 import com.grkj.ui_base.business.HardwareBusinessManager
 import com.grkj.data.config.ISCSConfig
+import com.grkj.data.data.MMKVConstants
+import com.grkj.data.enums.HardwareMode
 import com.grkj.ui_base.service.CheckKeyInfoTask
 import com.grkj.ui_base.utils.CommonUtils
 import com.grkj.data.hardware.ble.BleUtil
@@ -36,6 +38,7 @@ import com.scwang.smart.refresh.layout.SmartRefreshLayout
 import com.sik.cronjob.managers.CronJobScanner
 import com.sik.sikcore.SIKCore
 import com.sik.sikcore.crash.GlobalCrashCatch
+import com.sik.sikcore.extension.getMMKVData
 import com.sik.sikcore.extension.toJson
 import com.sik.sikcore.log.LogUtils
 import com.sik.sikcore.thread.ThreadUtils
@@ -107,6 +110,9 @@ class ISCSApplication : Application() {
             DbReadyGate.await()
             LogicManager.init(this@ISCSApplication)
             initImageLoader()
+            if (MMKVConstants.KEY_HARDWARE_MODE.getMMKVData("").isNotEmpty()){
+                HardwareMode.getCurrentHardwareMode().connectAndAddListener()
+            }
         }
     }
 
@@ -147,11 +153,11 @@ class ISCSApplication : Application() {
             }
 
             EventConstants.EVENT_START_MODBUS_COMPLETE -> {
-                if (ISCSConfig.isInit) {
-                    val jobList = CronJobScanner.scanJobs(checkKeyInfoTask)
-                    logger.info("扫描任务结果:${jobList.toJson()},开始注册")
-                    GlobalManager.cronJobManager.registerJobs(jobList)
-                }
+//                if (ISCSConfig.isInit) {
+//                    val jobList = CronJobScanner.scanJobs(checkKeyInfoTask)
+//                    logger.info("扫描任务结果:${jobList.toJson()},开始注册")
+//                    GlobalManager.cronJobManager.registerJobs(jobList)
+//                }
             }
         }
     }

+ 4 - 4
app/src/main/java/com/grkj/iscs/features/init/fragment/InitSetAdminAccountFragment.kt

@@ -79,10 +79,10 @@ class InitSetAdminAccountFragment : BaseFragment<FragmentInitSetAdminAccountBind
             return false
         }
         val username = binding.adminUsernameEt.text.toString()
-        if (!RegexUtils.isMatch(username, CommonConstants.REGEX_USERNAME)){
-            showToast(CommonUtils.getStr("username_regex_tip"))
-            return false
-        }
+//        if (!RegexUtils.isMatch(username, CommonConstants.REGEX_USERNAME)){
+//            showToast(CommonUtils.getStr("username_regex_tip"))
+//            return false
+//        }
         if (binding.passwordEt.text.toString().isEmpty()) {
             showToast(CommonUtils.getStr("please_input_password"))
             return false

+ 4 - 4
app/src/main/java/com/grkj/iscs/features/main/dialog/data_manage/AddUserDialog.kt

@@ -94,10 +94,10 @@ class AddUserDialog(
             return false
         }
         val username = binding.usernameEt.text.toString()
-        if (!RegexUtils.isMatch(username, CommonConstants.REGEX_USERNAME)){
-            PopTip.build().tip(CommonUtils.getStr("username_regex_tip"))
-            return false
-        }
+//        if (!RegexUtils.isMatch(username, CommonConstants.REGEX_USERNAME)){
+//            PopTip.build().tip(CommonUtils.getStr("username_regex_tip"))
+//            return false
+//        }
         if (binding.nicknameEt.text.isNullOrBlank()) {
             PopTip.build().tip(CommonUtils.getStr("please_input_nickname"))
             return false

+ 2 - 2
app/src/main/java/com/grkj/iscs/features/main/fragment/exception_manage/ExceptionDetailFragment.kt

@@ -150,7 +150,7 @@ class ExceptionDetailFragment : BaseFragment<FragmentExceptionDetailBinding>() {
             title = CommonUtils.getStr("warn"),
             msg = CommonUtils.getStr("handle_exception_will_release_all_colock"),
             dialogType = TipDialog.DialogType.ERROR,
-            showCancel = false,
+            countDownTime = 10,
             onConfirmClick = onConfirm
         )
     }
@@ -163,7 +163,7 @@ class ExceptionDetailFragment : BaseFragment<FragmentExceptionDetailBinding>() {
             title = CommonUtils.getStr("warn"),
             msg = CommonUtils.getStr("current_job_has_cross_job"),
             dialogType = TipDialog.DialogType.ERROR,
-            showCancel = false,
+            countDownTime = 10,
             onConfirmClick = onConfirm
         )
     }

+ 3 - 3
app/src/main/java/com/grkj/iscs/features/main/fragment/exception_manage/ExceptionJobFragment.kt

@@ -93,14 +93,14 @@ class ExceptionJobFragment : BaseFragment<FragmentExceptionJobBinding>() {
                             title = CommonUtils.getStr("warn"),
                             msg = CommonUtils.getStr("handle_exception_will_release_all_colock"),
                             dialogType = TipDialog.DialogType.ERROR,
-                            showCancel = false,
+                            countDownTime = 10,
                             onConfirmClick = {
                                 if (it.second) {
                                     TipDialog.show(
                                         title = CommonUtils.getStr("warn"),
                                         msg = CommonUtils.getStr("current_job_has_cross_job"),
                                         dialogType = TipDialog.DialogType.ERROR,
-                                        showCancel = false,
+                                        countDownTime = 10,
                                         onConfirmClick = {
                                             handleExceptionCheck()
                                         })
@@ -114,7 +114,7 @@ class ExceptionJobFragment : BaseFragment<FragmentExceptionJobBinding>() {
                                 title = CommonUtils.getStr("warn"),
                                 msg = CommonUtils.getStr("current_job_has_cross_job"),
                                 dialogType = TipDialog.DialogType.ERROR,
-                                showCancel = false,
+                                countDownTime = 10,
                                 onConfirmClick = {
                                     handleExceptionCheck()
                                 })

+ 7 - 1
app/src/main/java/com/grkj/iscs/features/main/fragment/hardware_manage/SlotsManageFragment.kt

@@ -355,7 +355,13 @@ class SlotsManageFragment : BaseFragment<FragmentSlotsManageBinding>() {
         lockDock: DockData.LockDock,
     ) {
         val itemBinding = getBinding<ItemDeviceRegistrationLockLayoutBinding>()
-        itemBinding.rvLockLayout.grid(10).setup {
+        itemBinding.rvLockLayout.apply {
+            if (MMKVConstants.KEY_HARDWARE_MODE.getMMKVData(HardwareMode.MODBUS.name) == HardwareMode.MODBUS.name) {
+                grid(10)
+            } else {
+                grid(5)
+            }
+        }.setup {
             addType<DockData.LockDock.LockBean>(R.layout.item_device_slot_manage_lock)
             onBind {
                 val itemLockBinding = getBinding<ItemDeviceSlotManageLockBinding>()

+ 5 - 0
app/src/main/java/com/grkj/iscs/features/main/fragment/home/HomeFragment.kt

@@ -208,6 +208,11 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
             }.setOnCancel(CommonUtils.getStr("cancel")).build().show()
     }
 
+    override fun onResume() {
+        super.onResume()
+        getHomeData()
+    }
+
     override fun initData() {
         super.initData()
         binding.quickEntranceRv.models = quickEntranceList

+ 22 - 0
app/src/main/java/com/grkj/iscs/receivers/BootAndUnlockReceiver.kt

@@ -0,0 +1,22 @@
+package com.grkj.iscs.receivers
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.grkj.iscs.features.splash.activity.SplashActivity
+import org.slf4j.LoggerFactory
+
+class BootAndUnlockReceiver : BroadcastReceiver() {
+    private val logger = LoggerFactory.getLogger(BootAndUnlockReceiver::class.java)
+
+    override fun onReceive(context: Context, intent: Intent?) {
+        if (Intent.ACTION_BOOT_COMPLETED != intent?.action) return
+        logger.debug("BOOT_COMPLETED(无锁设备)→ 直接拉起界面")
+
+        val app = context.applicationContext
+        val start = Intent(app, SplashActivity::class.java).apply {
+            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+        }
+        app.startActivity(start)
+    }
+}

+ 0 - 42
app/src/main/java/com/grkj/iscs/receivers/BootReceiver.kt

@@ -1,42 +0,0 @@
-package com.grkj.iscs.receivers
-
-import android.app.AlarmManager
-import android.app.PendingIntent
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-import com.grkj.data.database.BackupScheduler
-import com.grkj.iscs.features.splash.activity.SplashActivity
-import org.slf4j.LoggerFactory
-
-/**
- * 开机启动接收器:
- * - 仅做两件事:
- *   1) 调用 BackupScheduler.applySaved(context) 按已保存的配置重新安排下一次备份
- *   2) (可选)自启动 App(你已有逻辑,保留)
- *
- * 注:WorkManager 的任务会持久化,理论上重启后也在;
- *     这里再次 applySaved() 是“保险丝”,避免用户还没打开 App、计划丢失的极端情况。
- */
-class BootReceiver : BroadcastReceiver() {
-    private val logger = LoggerFactory.getLogger(BootReceiver::class.java)
-    override fun onReceive(context: Context, intent: Intent?) {
-        if (Intent.ACTION_BOOT_COMPLETED == intent?.action) {
-            logger.debug("接收到 BOOT_COMPLETED,应用备份计划")
-            // 1) 重新应用备份计划(读取 SimpleBackupPrefs 并排下一次)
-            BackupScheduler.applySaved(context)
-
-            // 2) (可选)2 秒后自启动 App(你原有逻辑)
-            val startAppIntent = Intent(context, SplashActivity::class.java).apply {
-                flags = Intent.FLAG_ACTIVITY_NEW_TASK
-            }
-            val pi = PendingIntent.getActivity(
-                context, 0, startAppIntent,
-                PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE
-            )
-            (context.getSystemService(Context.ALARM_SERVICE) as AlarmManager).set(
-                AlarmManager.RTC, System.currentTimeMillis() + 2000, pi
-            )
-        }
-    }
-}

+ 1 - 1
data/src/main/java/com/grkj/data/config/ISCSConfig.kt

@@ -20,7 +20,7 @@ object ISCSConfig {
     /**
      * Debug模式
      */
-    const val DEBUG: Boolean = false
+    const val DEBUG: Boolean = true
 
     /**
      * 是否在注册设备

+ 3 - 3
data/src/main/java/com/grkj/data/database/PresetData.kt

@@ -31,7 +31,7 @@ object PresetData {
         get() = run {
             val presetSysRole =
                 SIKCore.getApplication().readJsonFromAssets("$ASSETS_DIR/preset_sys_role.json")
-            logger.info("预设数据:$presetSysRole")
+//            logger.info("预设数据:$presetSysRole")
             gson.fromJson(presetSysRole, object : TypeToken<List<SysRole>>() {}.type)
         }
 
@@ -43,7 +43,7 @@ object PresetData {
             val presetWorkflowMode =
                 SIKCore.getApplication()
                     .readJsonFromAssets("$ASSETS_DIR/$targetRegion/preset_workflow_mode.json")
-            logger.info("预设数据:$presetWorkflowMode")
+//            logger.info("预设数据:$presetWorkflowMode")
             gson.fromJson(presetWorkflowMode, object : TypeToken<List<WorkflowMode>>() {}.type)
         }
 
@@ -55,7 +55,7 @@ object PresetData {
             val presetWorkflowStep =
                 SIKCore.getApplication()
                     .readJsonFromAssets("$ASSETS_DIR/$targetRegion/preset_workflow_step.json")
-            logger.info("预设数据:$presetWorkflowStep")
+//            logger.info("预设数据:$presetWorkflowStep")
             gson.fromJson(presetWorkflowStep, object : TypeToken<List<WorkflowStep>>() {}.type)
         }
 }

+ 30 - 0
data/src/main/java/com/grkj/data/hardware/IHardwareHelper.kt

@@ -153,6 +153,11 @@ interface IHardwareHelper {
      */
     fun controlAllKeyBuckleOpen(complete: () -> Unit = {})
 
+    /**
+     * 关闭所有钥匙仓位
+     */
+    fun controlAllKeyBuckleClose(complete: () -> Unit = {})
+
     /**
      * 检查硬件是否连接
      */
@@ -217,4 +222,29 @@ interface IHardwareHelper {
      * 移除要设备挂锁状态
      */
     fun removeNewHardwareLock(lockRfid: List<String>)
+
+    /**
+     * 更新锁rfid
+     */
+    fun updateLockRfid(addr: Int, idx: Int, rfid: String)
+
+    /**
+     * 更新锁是否为新设备
+     */
+    fun updateLockNewHardware(addr: Int, idx: Int, newHardware: Boolean)
+
+    /**
+     * 更新钥匙rfid
+     */
+    fun updateKeyRfid(addr: Int, idx: Int, rfid: String)
+
+    /**
+     * 更新钥匙是否为新设备
+     */
+    fun updateKeyNewHardware(addr: Int, idx: Int, newHardware: Boolean)
+
+    /**
+     * 更新钥匙mac
+     */
+    fun updateKeyMac(addr: Int, idx: Int, macAddress: String)
 }

+ 48 - 57
data/src/main/java/com/grkj/data/hardware/ble/BleUtil.kt

@@ -55,65 +55,56 @@ class BleUtil private constructor() {
     }
 
     @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
-    fun scan(bleScanCallback: CustomBleScanCallback) {
-        if (BleManager.isSupportBle(SIKCore.getApplication())) {
-            if (BleManager.isBleEnable(SIKCore.getApplication())) {
-                if (inScan) {
-                    BleManager.cancelScan()
-                }
-                if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
-                    //Android 12及以上不允许添加过滤器
-                    val bleScanRuleConfig = BleScanRuleConfig.Builder()
-                        .setScanTimeOut(BleConst.SCAN_TIMEOUT)
-                        .apply {
-                            if (ISCSConfig.needDeviceName) {
-                                setDeviceName(BleConst.BLE_LOCAL_NAME)
-                            }
-                            setScanSettings(
-                                ScanSettings.Builder()
-                                    .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
-                                    .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
-                                    .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
-                                    .setReportDelay(0) // 立刻回调,不做批处理
-                                    .build()
-                            )
-                        }.build()
-                    BleManager.bleScanRuleConfig = bleScanRuleConfig
+    fun scan(cb: CustomBleScanCallback) {
+        val ctx = SIKCore.getApplication()
+
+        // 0) 前置检查(权限/蓝牙开关/定位开关)
+        if (!BleManager.isSupportBle(ctx)) return cb.onPrompt("设备不支持 BLE")
+        if (!BleManager.isBleEnable(ctx))  return cb.onPrompt("请先打开蓝牙")
+        // Android 6–11:需要位置权限 + 开位置开关;12+ 视 ROM
+        // 这里建议把你项目里的权限/定位检查封装下,失败就 return
+
+        // 1) 统一设置扫描参数(含 S+)
+        val settings = ScanSettings.Builder()
+            .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
+            .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
+            .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
+            .setReportDelay(0)
+            .build()
+        BleManager.bleScanRuleConfig = BleScanRuleConfig.Builder()
+            .apply {
+                if (ISCSConfig.needDeviceName) {
+                    setDeviceName(BleConst.BLE_LOCAL_NAME)
                 }
-                BleManager.scan(object : CustomBleScanCallback() {
-                    override fun onPrompt(promptStr: String?) {
-                        bleScanCallback.onPrompt(promptStr)
-                    }
-
-                    override fun onScanFinished(scanResultList: List<BleDevice>) {
-                        inScan = false
-                        bleScanCallback.onScanFinished(scanResultList)
-                    }
-
-                    override fun onScanStarted(p0: Boolean) {
-                        inScan = true
-                        bleScanCallback.onScanStarted(p0)
-                    }
-
-                    override fun onFilter(bleDevice: BleDevice): Boolean {
-                        return bleDevice.name == BleConst.BLE_LOCAL_NAME
-                    }
-
-                    override fun onLeScan(
-                        oldDevice: BleDevice,
-                        newDevice: BleDevice,
-                        scannedBefore: Boolean
-                    ) {
-                        bleScanCallback.onLeScan(oldDevice, newDevice, scannedBefore)
-                    }
-
-                })
-            } else {
-                bleScanCallback.onPrompt("请打开您的蓝牙后重试")
+                setScanTimeOut(BleConst.SCAN_TIMEOUT)
+                setScanSettings(settings)
             }
-        } else {
-            bleScanCallback.onPrompt("您的设备不支持蓝牙设备")
-        }
+            .build()
+
+        // 2) 保证单实例扫描:如在扫,先“同步停”,再启动
+        if (inScan) stopScan()   // 用你的 stopScan(),会置 inScan=false
+
+        // 3) 启动(先置位,避免 onScanStarted 不回调时卡死在 true)
+        inScan = true
+        BleManager.scan(object : CustomBleScanCallback() {
+            override fun onScanStarted(ok: Boolean) {
+                inScan = ok
+                cb.onScanStarted(ok)
+                if (!ok) cb.onPrompt("启动扫描失败,请检查权限/系统设置")
+            }
+            override fun onFilter(d: BleDevice): Boolean {
+                // 放宽一点,避免漏掉只在 SR 里带名的设备
+                return !ISCSConfig.needDeviceName ||
+                        (d.name ?: "").equals(BleConst.BLE_LOCAL_NAME, ignoreCase = true)
+            }
+            override fun onLeScan(old: BleDevice, new: BleDevice, scannedBefore: Boolean) =
+                cb.onLeScan(old, new, scannedBefore)
+            override fun onScanFinished(list: List<BleDevice>) {
+                inScan = false
+                cb.onScanFinished(list)
+            }
+            override fun onPrompt(p: String?) = cb.onPrompt(p)
+        })
     }
 
     /**

+ 18 - 17
data/src/main/java/com/grkj/data/hardware/can/CanCommand.kt

@@ -2,6 +2,7 @@ package com.grkj.data.hardware.can
 
 import com.sik.comm.impl_can.SdoDialect
 import com.sik.comm.impl_can.SdoRequest
+import kotlin.concurrent.timer
 import kotlin.math.max
 import kotlin.math.min
 
@@ -93,17 +94,17 @@ object CanCommands {
     object Common {
         /** 版本 (R) → 0x6003/0x00, 4B: HW主,HW子,SW主,SW子 */
         fun getDeviceVersion(nodeId: Int): SdoRequest.Read =
-            SdoRequest.Read(nodeId, Command.VERSION, 0x00)
+            SdoRequest.Read(nodeId, Command.VERSION, 0x00, timeoutMs = CustomCanConfig.instance.readTimeoutMs.toLong())
 
         /** 大多数设备复用的状态寄存器 (R) → 0x6010/0x00, 2B */
         fun getStatus(nodeId: Int): SdoRequest.Read =
-            SdoRequest.Read(nodeId, Command.STATUS, 0x00)
+            SdoRequest.Read(nodeId, Command.STATUS, 0x00, timeoutMs = CustomCanConfig.instance.readTimeoutMs.toLong())
 
         /**
          * 获取设备类型
          */
         fun getDeviceType(nodeId: Int): SdoRequest.Read =
-            SdoRequest.Read(nodeId, Command.DEVICE_TYPE, 0x00)
+            SdoRequest.Read(nodeId, Command.DEVICE_TYPE, 0x00, timeoutMs = CustomCanConfig.instance.readTimeoutMs.toLong())
     }
 
     /**
@@ -118,14 +119,14 @@ object CanCommands {
 
         /** 控制/状态 (R/W) 0x6011/0x00, 2B:写仅置相关位,其余写0;读回含工作位 */
         fun readControlReg(): SdoRequest.Read =
-            SdoRequest.Read(nodeId, Command.CONTROL_REG, 0x00)
+            SdoRequest.Read(nodeId, Command.CONTROL_REG, 0x00, timeoutMs = CustomCanConfig.instance.readTimeoutMs.toLong())
 
         /** 设置左右卡扣(bit0=左卡扣,bit4=右卡扣) */
         fun setLatch(left: Boolean? = null, right: Boolean? = null): SdoRequest.Write {
             var v = 0
             if (left != null) v = v or ((if (left) 1 else 0) shl 0)
             if (right != null) v = v or ((if (right) 1 else 0) shl 4)
-            return SdoRequest.Write(nodeId, Command.CONTROL_REG, 0x00, shortLE(v, 0b0001_0001), 2)
+            return SdoRequest.Write(nodeId, Command.CONTROL_REG, 0x00, shortLE(v, 0b0001_0001), 2, timeoutMs = CustomCanConfig.instance.readTimeoutMs.toLong())
         }
 
         /** 设置左右充电(bit1=左充电,bit5=右充电) */
@@ -133,7 +134,7 @@ object CanCommands {
             var v = 0
             if (leftOn != null) v = v or ((if (leftOn) 1 else 0) shl 1)
             if (rightOn != null) v = v or ((if (rightOn) 1 else 0) shl 5)
-            return SdoRequest.Write(nodeId, Command.CONTROL_REG, 0x00, shortLE(v, 0b0010_0010), 2)
+            return SdoRequest.Write(nodeId, Command.CONTROL_REG, 0x00, shortLE(v, 0b0010_0010), 2, timeoutMs = CustomCanConfig.instance.readTimeoutMs.toLong())
         }
 
         /** 单侧卡扣语法糖:keySlotId: 0左/1右;status: 0解锁/1锁住 */
@@ -146,20 +147,20 @@ object CanCommands {
                 Command.CONTROL_REG,
                 0x00,
                 shortLE((status and 1) shl bit, 1 shl bit),
-                2
+                2, timeoutMs = CustomCanConfig.instance.readTimeoutMs.toLong()
             )
         }
 
         /** 左/右 RFID (R) 4B 小端(常见地址) */
-        fun getLeftRfid(): SdoRequest.Read = SdoRequest.Read(nodeId, Command.RFID, 0x00)
-        fun getRightRfid(): SdoRequest.Read = SdoRequest.Read(nodeId, Command.RIGHT_KEY_RFID, 0x00)
+        fun getLeftRfid(): SdoRequest.Read = SdoRequest.Read(nodeId, Command.RFID, 0x00, timeoutMs = CustomCanConfig.instance.readTimeoutMs.toLong())
+        fun getRightRfid(): SdoRequest.Read = SdoRequest.Read(nodeId, Command.RIGHT_KEY_RFID, 0x00, timeoutMs = CustomCanConfig.instance.readTimeoutMs.toLong())
 
         // ---- FiveLock / KeyCabinet(常见 1..5 位同构写法,寄存器通常与 0x6011 兼容) ----
 
         /** 一次写入 5 位控制(低5位有效),适配 5路/柜体同构 */
         fun setLatchBits_1to5(bits01to05: Int): SdoRequest.Write {
             val v = bits01to05 and 0b1_1111
-            return SdoRequest.Write(nodeId, Command.CONTROL_REG, 0x00, shortLE(v, 0b1_1111), 2)
+            return SdoRequest.Write(nodeId, Command.CONTROL_REG, 0x00, shortLE(v, 0b1_1111), 2, timeoutMs = CustomCanConfig.instance.readTimeoutMs.toLong())
         }
 
         /**
@@ -167,7 +168,7 @@ object CanCommands {
          */
         fun setLatchBits_1to5(target: Int, locked: Boolean): SdoRequest.Write {
             val v = if (locked) 0b1_1111 else 0b0_0000
-            return SdoRequest.Write(nodeId, Command.CONTROL_REG, 0x00, shortLE(v, target), 2)
+            return SdoRequest.Write(nodeId, Command.CONTROL_REG, 0x00, shortLE(v, target), 2, timeoutMs = CustomCanConfig.instance.readTimeoutMs.toLong())
         }
 
         /** 单位控制(1..5) */
@@ -179,20 +180,20 @@ object CanCommands {
                 Command.CONTROL_REG,
                 0x00,
                 shortLE(v, 1 shl (slotIndex1to5 - 1)),
-                2
+                2, timeoutMs = CustomCanConfig.instance.readTimeoutMs.toLong()
             )
         }
 
         /** 1..5 位 RFID 常见映射:0x6020..0x6024 */
         fun getSlotRfid_1to5(slotIndex1to5: Int): SdoRequest.Read {
             require(slotIndex1to5 in 1..5) { "slotIndex must be 1..5" }
-            return SdoRequest.Read(nodeId, Command.RFID, 0x00 + (slotIndex1to5 - 1))
+            return SdoRequest.Read(nodeId, Command.RFID, 0x00 + (slotIndex1to5 - 1), timeoutMs = CustomCanConfig.instance.readTimeoutMs.toLong())
         }
 
         // ---- MaterialCabinet(RGB/温湿度扩展) ----
 
         /** RGB 状态灯 (R/W) 0x6016/0x00, 4B: B[0..7],G[8..15],R[16..23], 模式[24..26], 时间[27..29], 单位[30], 锁定[31] */
-        fun getRgb(): SdoRequest.Read = SdoRequest.Read(nodeId, Command.LED_STRIP_CONTROL, 0x00)
+        fun getRgb(): SdoRequest.Read = SdoRequest.Read(nodeId, Command.LED_STRIP_CONTROL, 0x00, timeoutMs = CustomCanConfig.instance.readTimeoutMs.toLong())
 
         fun setRgb(
             r: Int, g: Int, b: Int,      // 0..255
@@ -214,12 +215,12 @@ object CanCommands {
             v = v or ((tt and 0x07) shl 27)
             v = v or ((if (secondsUnit) 1 else 0) shl 30)
             v = v or ((if (lockControl) 1 else 0) shl 31)
-            return SdoRequest.Write(nodeId, Command.LED_STRIP_CONTROL, 0x00, intLE(v), 4)
+            return SdoRequest.Write(nodeId, Command.LED_STRIP_CONTROL, 0x00, intLE(v), 4, timeoutMs = CustomCanConfig.instance.readTimeoutMs.toLong())
         }
 
         /** 温湿度(常见扩展) */
-        fun getTemperature(): SdoRequest.Read = SdoRequest.Read(nodeId, Command.TEMPERATURE, 0x00)
-        fun getHumidity(): SdoRequest.Read = SdoRequest.Read(nodeId, Command.HUMIDITY, 0x00)
+        fun getTemperature(): SdoRequest.Read = SdoRequest.Read(nodeId, Command.TEMPERATURE, 0x00, timeoutMs = CustomCanConfig.instance.readTimeoutMs.toLong())
+        fun getHumidity(): SdoRequest.Read = SdoRequest.Read(nodeId, Command.HUMIDITY, 0x00, timeoutMs = CustomCanConfig.instance.readTimeoutMs.toLong())
 
         // ---- 通用状态 ----
         fun getStatus(): SdoRequest.Read = Common.getStatus(nodeId)

+ 61 - 19
data/src/main/java/com/grkj/data/hardware/can/CanHardwareHelper.kt

@@ -8,6 +8,8 @@ import com.grkj.data.utils.event.StartListenerEvent
 import com.grkj.shared.utils.extension.toHexFromLe
 import com.grkj.shared.utils.i18n.I18nManager
 import com.sik.comm.impl_can.toCommMessage
+import com.sik.sikcore.thread.ThreadUtils
+import com.sik.siknet.http.toMap
 import org.slf4j.Logger
 import org.slf4j.LoggerFactory
 
@@ -17,8 +19,10 @@ import org.slf4j.LoggerFactory
 class CanHardwareHelper : IHardwareHelper {
     private val logger: Logger = LoggerFactory.getLogger(CanHardwareHelper::class.java)
     override fun connectAndAddListener() {
-        CanHelper.connect()
-        StartListenerEvent.sendStartListenerEvent()
+        ThreadUtils.runOnIO {
+            CanHelper.connect()
+            StartListenerEvent.sendStartListenerEvent()
+        }
     }
 
     override fun getKeyMacByRfid(rfid: String): String? {
@@ -53,6 +57,8 @@ class CanHardwareHelper : IHardwareHelper {
         val deviceData = CanHelper.getDeviceByDeviceType(CanDeviceConst.DEVICE_KEY_DOCK)
         val keyDock = deviceData.map {
             DockData.KeyDock().apply {
+                addr = it.key
+                type = CanDeviceConst.DEVICE_KEY_DOCK
                 keyData.addAll(
                     it.value.filterIsInstance<DeviceModel.DeviceKey>().map {
                         DockData.KeyDock.KeyBean().apply {
@@ -79,40 +85,45 @@ class CanHardwareHelper : IHardwareHelper {
         CanHelper.getKeyByRfid(rfid)?.mac = mac
     }
 
+    override fun controlAllKeyBuckleClose(complete: () -> Unit) {
+        getKeyDockData().forEach {
+            val req = CanCommands.forDevice(it.addr).setLatch(false, false)
+            CanHelper.writeTo(req)
+        }
+    }
+
     override fun allSlotOn() {
         getDockData().forEach {
-            val req = when (it.type) {
+            when (it.type) {
                 CanDeviceConst.DEVICE_KEY_DOCK -> {
-                    CanCommands.forDevice(it.addr).setLatch(true, true)
+                    controlAllKeyBuckleOpen()
                 }
 
                 CanDeviceConst.DEVICE_LOCK_DOCK -> {
-                    CanCommands.forDevice(it.addr).setLatchBits_1to5(0b1_1111, true)
+                    controlLockBuckle(
+                        true,
+                        it.addr,
+                        (it as DockData.LockDock).lockData.map { it.idx }.toMutableList()
+                    ) {}
                 }
-
-                else -> null
-            }
-            req?.let {
-                CanHelper.writeTo(it)
             }
         }
     }
 
     override fun allSlotOff() {
         getDockData().forEach {
-            val req = when (it.type) {
+            when (it.type) {
                 CanDeviceConst.DEVICE_KEY_DOCK -> {
-                    CanCommands.forDevice(it.addr).setLatch(true, true)
+                    controlAllKeyBuckleClose()
                 }
 
                 CanDeviceConst.DEVICE_LOCK_DOCK -> {
-                    CanCommands.forDevice(it.addr).setLatchBits_1to5(0b1_1111, true)
+                    controlLockBuckle(
+                        false,
+                        it.addr,
+                        (it as DockData.LockDock).lockData.map { it.idx }.toMutableList()
+                    ) {}
                 }
-
-                else -> null
-            }
-            req?.let {
-                CanHelper.writeTo(it)
             }
         }
     }
@@ -262,7 +273,7 @@ class CanHardwareHelper : IHardwareHelper {
     ) {
         slaveAddress?.let {
             val target = lockIdxList
-            val req = CanCommands.forDevice(slaveAddress).setLatchBits_1to5(target, !isOpen)
+            val req = CanCommands.forDevice(slaveAddress).controlOne_1to5(target, !isOpen)
             CanHelper.writeTo(req) {
                 done?.invoke(it.toCommMessage().payload)
             }
@@ -291,6 +302,35 @@ class CanHardwareHelper : IHardwareHelper {
         }
     }
 
+    override fun updateKeyRfid(addr: Int, idx: Int, rfid: String) {
+        CanHelper.getDeviceByNodeId(addr).find { it.id == idx }?.rfid = rfid
+    }
+
+    override fun updateKeyNewHardware(
+        addr: Int,
+        idx: Int,
+        newHardware: Boolean
+    ) {
+        CanHelper.getDeviceByNodeId(addr).find { it.id == idx }?.newHardware = newHardware
+    }
+
+    override fun updateKeyMac(addr: Int, idx: Int, macAddress: String) {
+        (CanHelper.getDeviceByNodeId(addr).find { it.id == idx } as? DeviceModel.DeviceKey)?.mac =
+            macAddress
+    }
+
+    override fun updateLockRfid(addr: Int, idx: Int, rfid: String) {
+        CanHelper.getDeviceByNodeId(addr).find { it.id == idx }?.rfid = rfid
+    }
+
+    override fun updateLockNewHardware(
+        addr: Int,
+        idx: Int,
+        newHardware: Boolean
+    ) {
+        CanHelper.getDeviceByNodeId(addr).find { it.id == idx }?.newHardware = newHardware
+    }
+
     override fun readLockRfidStr(
         slaveAddress: Int?,
         lockIdx: Int,
@@ -331,6 +371,8 @@ class CanHardwareHelper : IHardwareHelper {
         val deviceData = CanHelper.getDeviceByDeviceType(CanDeviceConst.DEVICE_LOCK_DOCK)
         val lockDock = deviceData.map {
             DockData.LockDock().apply {
+                addr = it.key
+                type = CanDeviceConst.DEVICE_LOCK_DOCK
                 lockData.addAll(it.value.filterIsInstance<DeviceModel.CommonDevice>().map {
                     DockData.LockDock.LockBean().apply {
                         this.addr = it.nodeId

+ 189 - 1
data/src/main/java/com/grkj/data/hardware/can/CanReadyPlugin.kt

@@ -1,5 +1,16 @@
 package com.grkj.data.hardware.can
 
+import com.grkj.data.config.ISCSConfig
+import com.grkj.data.di.LogicManager
+import com.grkj.data.enums.HardwareMode
+import com.grkj.data.hardware.modbus.DeviceConst
+import com.grkj.data.hardware.modbus.DockBean
+import com.grkj.data.hardware.modbus.ModBusController.controlKeyBuckle
+import com.grkj.data.utils.event.ModbusInitCompleteEvent
+import com.grkj.data.utils.event.ToastEvent
+import com.grkj.shared.utils.extension.removeLeadingZeros
+import com.grkj.shared.utils.extension.toHexStrings
+import com.grkj.shared.utils.i18n.I18nManager
 import com.sik.comm.core.model.ProtocolState
 import com.sik.comm.core.plugin.CommPlugin
 import com.sik.comm.core.plugin.PluginScope
@@ -59,12 +70,47 @@ class CanReadyPlugin : CommPlugin {
     private fun startPollingSingleLoop() {
         pollJob?.cancel()
         pollJob = scope.launch {
-            NodeIdHelper.scanRange { nodeId ->
+            NodeIdHelper.scanRangeLinear({ nodeId ->
                 safeRead(CanCommands.Common.getDeviceType(nodeId))?.let {
+                    logger.info("硬件类型:${it.payload[0].toInt()}")
                     activeNodes.add(nodeId)
                     CanHelper.addNode(nodeId, it.payload[0].toInt())
                 }
+            }, endInclusive = 14)
+            for (nodeId in activeNodes) {
+                try {
+                    val cmds = CanCommands.forDevice(nodeId)
+
+                    // 1) 读状态 0x6010/00 (2B)
+                    safeRead(cmds.getStatus())?.let { rd ->
+                        deviceStatusListener.forEach {
+                            it.value.deviceStatus(
+                                nodeId,
+                                rd.index,
+                                rd.payload
+                            )
+                        }
+                    }
+                    safeRead(cmds.readControlReg())?.let { rd ->
+                        deviceStatusListener.forEach {
+                            it.value.deviceStatus(
+                                nodeId,
+                                rd.index,
+                                rd.payload
+                            )
+                        }
+                    }
+
+                } catch (t: Throwable) {
+                    // 单个节点出错不影响整体循环
+                    logger.warn("poll node={} error: {}", nodeId, t.toString())
+                }
+
+                // 给总线/固件留点缝,避免贴脸轰
+                delay(pollMsStatus)
             }
+            initLock()    // 打开所有无锁的卡扣、关闭所有有锁的卡扣、读取所有锁的RFID
+            initKey()     // 打开所有无钥匙的卡扣、关闭所有有钥匙的卡扣、读取所有钥匙的RFID
             while (isActive) {
                 for (nodeId in activeNodes) {
                     try {
@@ -107,6 +153,148 @@ class CanReadyPlugin : CommPlugin {
         }
     }
 
+    /**
+     * 初始化锁具——打开所有无锁的卡扣、读取RFID
+     */
+    private fun initLock() {
+        logger.info("initLock : ${HardwareMode.getCurrentHardwareMode().getLockDockData()}")
+        HardwareMode.getCurrentHardwareMode().getLockDockData()
+            .forEach { dockBean ->
+                val hasLockIdxList =
+                    dockBean.lockData.filter { it.isExist }.map { it.idx } as MutableList<Int>
+                val noLockIdxList =
+                    dockBean.lockData.filter { !it.isExist }.map { it.idx } as MutableList<Int>
+
+                hasLockIdxList.forEach { idx ->
+                    val readRfidReq = CanCommands.forDevice(dockBean.addr).getSlotRfid_1to5(idx)
+                    CanHelper.readFrom(readRfidReq) { result ->
+                        val res = result?.payload ?: return@readFrom
+                        if (res.size < 11) {
+                            logger.error("Lock rfid error")
+                            return@readFrom
+                        }
+                        val rfid = res.copyOfRange(3, 11).toHexStrings(false).removeLeadingZeros()
+                        logger.info("初始化锁具 RFID : $rfid")
+                        HardwareMode.getCurrentHardwareMode()
+                            .updateLockRfid(dockBean.addr, idx, rfid)
+                        //todo 为设备录入增加
+                        LogicManager.hardwareLogic.getLockInfo(rfid) {
+                            HardwareMode.getCurrentHardwareMode().updateLockNewHardware(
+                                dockBean.addr,
+                                idx,
+                                it == null || it.lockNfc?.isEmpty() == true
+                            )
+                        }
+                    }
+                }
+                HardwareMode.getCurrentHardwareMode()
+                    .controlLockBuckle(false, dockBean.addr, hasLockIdxList)
+                HardwareMode.getCurrentHardwareMode()
+                    .controlLockBuckle(true, dockBean.addr, noLockIdxList)
+            }
+    }
+
+    /**
+     * 初始化钥匙
+     */
+    private fun initKey() {
+        logger.info("initKey : ${HardwareMode.getCurrentHardwareMode().getKeyDockData()}")
+        HardwareMode.getCurrentHardwareMode().getKeyDockData()
+            .forEach { dockBean ->
+                if (dockBean.keyData.isEmpty()) {
+                    ISCSConfig.canInitDevice = true
+                    ModbusInitCompleteEvent.sendModbusInitCompleteEvent()
+                } else {
+                    dockBean.keyData.forEach { key ->
+                        if (key.isExist) {
+                            logger.info("initKey : ${dockBean.addr} : ${key.idx == 0}")
+                            HardwareMode.getCurrentHardwareMode()
+                                .readKeyRfidStr(dockBean.addr, key.idx) { idx, rfid ->
+                                    logger.info("初始化钥匙 RFID : $rfid")
+                                    // 更新rfid
+                                    HardwareMode.getCurrentHardwareMode()
+                                        .updateKeyRfid(dockBean.addr, key.idx, rfid)
+                                    // 蓝牙准备操作
+                                    LogicManager.hardwareLogic.getKeyInfo(rfid) { keyInfo ->
+                                        logger.info("getKeyInfo : $rfid - ${keyInfo?.macAddress}")
+                                        HardwareMode.getCurrentHardwareMode().updateKeyNewHardware(
+                                            dockBean.addr,
+                                            key.idx,
+                                            keyInfo == null || keyInfo.keyNfc?.isEmpty() == true || keyInfo.macAddress?.isEmpty() == true
+                                        )
+                                        if (keyInfo != null && !keyInfo.macAddress.isNullOrEmpty()) {
+                                            // 更新mac
+                                            HardwareMode.getCurrentHardwareMode().updateKeyMac(
+                                                dockBean.addr,
+                                                key.idx,
+                                                keyInfo.macAddress!!
+                                            )
+                                            //已经初始化完成才会去连接
+                                            if (ISCSConfig.isInit) {
+                                                HardwareMode.getCurrentHardwareMode()
+                                                    .controlKeyCharge(true, key.idx, dockBean.addr)
+                                            }
+                                            HardwareMode.getCurrentHardwareMode()
+                                                .controlKeyBuckle(false, key.idx, dockBean.addr)
+                                        } else {
+                                            if (ISCSConfig.isInit) {
+                                                ToastEvent.sendToastEvent(I18nManager.t("get_key_info_fail"))
+                                            }
+                                            HardwareMode.getCurrentHardwareMode()
+                                                .controlKeyBuckle(true, key.idx, dockBean.addr)
+                                        }
+                                        val isKeyReady =
+                                            HardwareMode.getCurrentHardwareMode().getKeyDockData()
+                                                .all {
+                                                    it.keyData.filter { it.type == DeviceConst.DEVICE_TYPE_KEY }
+                                                        .filterIsInstance<DockBean.KeyBean>()
+                                                        .filter { it.isExist }
+                                                        .all {
+                                                            logger.info("钥匙信息:${it.rfid}")
+                                                            it.rfid?.isNotEmpty() == true
+                                                        }
+                                                }
+                                        logger.info("钥匙是否准备完毕:${isKeyReady},${ISCSConfig.isInit}")
+                                        if (isKeyReady && ISCSConfig.isInit) {
+                                            ISCSConfig.canInitDevice = true
+                                            logger.info("发送初始化完成事件")
+                                            ModbusInitCompleteEvent.sendModbusInitCompleteEvent()
+                                        } else if (isKeyReady) {
+                                            ISCSConfig.canInitDevice = true
+                                            ModbusInitCompleteEvent.sendModbusInitCompleteEvent()
+                                        }
+                                    }
+                                }
+                        } else {
+                            HardwareMode.getCurrentHardwareMode()
+                                .controlKeyBuckle(true, key.idx, dockBean.addr)
+                            val isKeyReady =
+                                HardwareMode.getCurrentHardwareMode().getKeyDockData()
+                                    .all {
+                                        it.keyData.filter { it.type == DeviceConst.DEVICE_TYPE_KEY }
+                                            .filterIsInstance<DockBean.KeyBean>()
+                                            .filter { it.isExist }
+                                            .all {
+                                                logger.info("钥匙信息:${it.rfid}")
+                                                it.rfid?.isNotEmpty() == true
+                                            }
+                                    }
+                            logger.info("钥匙是否准备完毕:${isKeyReady},${ISCSConfig.isInit}")
+                            if (isKeyReady && ISCSConfig.isInit) {
+                                ISCSConfig.canInitDevice = true
+                                logger.info("发送初始化完成事件")
+                                ModbusInitCompleteEvent.sendModbusInitCompleteEvent()
+                            } else if (isKeyReady) {
+                                ISCSConfig.canInitDevice = true
+                                ModbusInitCompleteEvent.sendModbusInitCompleteEvent()
+                            }
+                        }
+
+                    }
+                }
+            }
+    }
+
     private fun stopPolling() {
         pollJob?.cancel()
         pollJob = null

+ 2 - 1
data/src/main/java/com/grkj/data/hardware/can/CustomCanConfig.kt

@@ -11,7 +11,8 @@ class CustomCanConfig : CanConfig(
     deviceId = "can0@1",
     interfaceName = "can0",
     defaultNodeId = 1,
-    sdo = CanCommands.sdoDialect
+    sdo = CanCommands.sdoDialect,
+    readTimeoutMs = 200
 ) {
     companion object {
         val instance by lazy { CustomCanConfig() }

+ 34 - 48
data/src/main/java/com/grkj/data/hardware/can/NodeIdHelper.kt

@@ -1,81 +1,67 @@
 package com.grkj.data.hardware.can
 
 /**
- * NodeId 计算器:
- * 以“置位个数递增 + 位索引字典序”的顺序枚举 8 位拨码的所有组合。
- * 例如:
- *  k=1: 00000001, 00000010, ..., 10000000
- *  k=2: 00000011, 00000101, ..., 10000001, 00000110, ..., 11000000
- *  ...
- *  k=8: 11111111
+ * NodeId 枚举工具:
+ * - 方式A:线性递增 0x01..0xFF
+ * - 方式B:按置位个数递增 + 位索引字典序(组合序)
  */
 object NodeIdHelper {
 
-    /** 总位数(1..8 对应 bit0..bit7) */
     private const val MAX_BITS = 8
+    private const val MAX_MASK = (1 shl MAX_BITS) - 1 // 0xFF
 
     /**
-     * 枚举所有非零掩码,按置位个数递增(1..8),
-     * 同一置位个数内按“位索引组合字典序”输出。
-     *
-     * @param emit 对每个掩码调用一次(0x01..0xFF),顺序如上。
+     * 方式A:线性枚举(默认 0x01..0xFF)
+     * @param startInclusive 起始(含),会被裁剪到 [1, 0xFF]
+     * @param endInclusive   结束(含),会被裁剪到 [1, 0xFF]
      */
-    suspend fun scanRange(emit: suspend (Int) -> Unit) {
-        // 置位个数 k:1..8
+    suspend fun scanRangeLinear(
+        emit: suspend (Int) -> Unit,
+        startInclusive: Int = 1,
+        endInclusive: Int = MAX_MASK
+    ) {
+        val start = (startInclusive and MAX_MASK).coerceIn(1, MAX_MASK)
+        val end = (endInclusive and MAX_MASK).coerceIn(1, MAX_MASK)
+        require(start <= end) { "start must be <= end (within 1..$MAX_MASK)" }
+        for (id in start..end) emit(id)
+    }
+
+    /**
+     * 方式B:按置位个数递增(1..8),同一置位个数内按“位索引组合字典序”。
+     * 输出范围:0x01..0xFF,且无 0。
+     */
+    suspend fun scanRangeBySetBits(emit: suspend (Int) -> Unit) {
         for (k in 1..MAX_BITS) {
             generateKBitMasks(k, MAX_BITS, emit)
         }
     }
 
-    /**
-     * 生成固定置位个数 k 的所有掩码(组合字典序)。
-     * 例如 k=2, n=8:
-     * 索引组合 (0,1) → 00000011, (0,2) → 00000101, ..., (6,7) → 11000000
-     */
+    /** 生成固定置位个数 k 的所有掩码(组合字典序)。*/
     private suspend fun generateKBitMasks(
         k: Int,
         n: Int,
         emit: suspend (Int) -> Unit
     ) {
-        // 组合索引数组,初始为 [0,1,2,...,k-1]
-        val idx = IntArray(k) { it }
+        val idx = IntArray(k) { it } // [0,1,2,...,k-1]
         while (true) {
-            // 根据当前索引组合生成掩码
             var mask = 0
-            for (i in 0 until k) {
-                mask = mask or (1 shl idx[i])
-            }
+            for (i in 0 until k) mask = mask or (1 shl idx[i])
             emit(mask)
 
-            // 生成下一个组合(典型字典序组合生成算法)
             var pos = k - 1
             while (pos >= 0 && idx[pos] == pos + n - k) pos--
             if (pos < 0) break
             idx[pos]++
-            for (j in pos + 1 until k) {
-                idx[j] = idx[j - 1] + 1
-            }
+            for (j in pos + 1 until k) idx[j] = idx[j - 1] + 1
         }
     }
 
-    /**
-     * 如果你不需要挂起回调,也可以拿到一个 Sequence 方便 for-each。
-     */
-    fun scanRangeSeq(): Sequence<Int> = sequence {
-        for (k in 1..MAX_BITS) {
-            // 复用相同生成逻辑,但以 yield 返回
-            val idx = IntArray(k) { it }
-            while (true) {
-                var mask = 0
-                for (i in 0 until k) mask = mask or (1 shl idx[i])
-                yield(mask)
-
-                var pos = k - 1
-                while (pos >= 0 && idx[pos] == pos + MAX_BITS - k) pos--
-                if (pos < 0) break
-                idx[pos]++
-                for (j in pos + 1 until k) idx[j] = idx[j - 1] + 1
-            }
-        }
+    /** 小工具:线性下一个(到头回 0x01) */
+    fun nextAfterLinear(cur: Int): Int {
+        val x = cur and MAX_MASK
+        return if (x in 1 until MAX_MASK) x + 1 else 1
     }
+
+    /** 小工具:格式化成 0xNN */
+    fun toHex(id: Int): String = "0x%02X".format(id and MAX_MASK)
 }

+ 1 - 1
data/src/main/java/com/grkj/data/hardware/modbus/ModBusController.kt

@@ -148,7 +148,7 @@ object ModBusController {
                     }
                     logger.info("initDevicesStatus 设备(${bytes[0].toInt()})类型:$type")
                 }
-                controlAllKeyBuckleOpen()
+                controlAllKeyBuckleClose()
                 fun initDevice() {
                     modBusManager?.sendToAll(MBFrame.READ_STATUS) { res ->
                         listeners.forEach {

+ 32 - 0
data/src/main/java/com/grkj/data/hardware/modbus/ModBusHardwareHelper.kt

@@ -17,6 +17,10 @@ class ModBusHardwareHelper : IHardwareHelper {
         StartModbusEvent.sendStartModbusEvent()
     }
 
+    override fun controlAllKeyBuckleClose(complete: () -> Unit) {
+        ModBusController.controlAllKeyBuckleClose(complete)
+    }
+
     override fun getKeyMacByRfid(rfid: String): String? {
         return ModBusController.getKeyByRfid(
             rfid
@@ -97,6 +101,34 @@ class ModBusHardwareHelper : IHardwareHelper {
         return getLockDockData().flatMap { it.lockData }.filter { it.newHardware }.map { it.rfid }
     }
 
+    override fun updateLockRfid(addr: Int, idx: Int, rfid: String) {
+        ModBusController.updateLockRfid(addr.toByte(), idx, rfid)
+    }
+
+    override fun updateLockNewHardware(
+        addr: Int,
+        idx: Int,
+        newHardware: Boolean
+    ) {
+        ModBusController.updateLockNewHardware(addr.toByte(), idx, newHardware)
+    }
+
+    override fun updateKeyNewHardware(
+        addr: Int,
+        idx: Int,
+        newHardware: Boolean
+    ) {
+        ModBusController.updateKeyNewHardware(addr.toByte(), idx, newHardware)
+    }
+
+    override fun updateKeyMac(addr: Int, idx: Int, macAddress: String) {
+        ModBusController.updateKeyRfid(addr.toByte(), idx, macAddress)
+    }
+
+    override fun updateKeyRfid(addr: Int, idx: Int, rfid: String) {
+        ModBusController.updateKeyRfid(addr.toByte(), idx, rfid)
+    }
+
     override fun removeNewHardwareLock(lockRfid: List<String>) {
         val dockData =
             ModBusController.dockList.filter { it.type == DeviceConst.DOCK_TYPE_LOCK || it.type == DeviceConst.DOCK_TYPE_KEY || it.type == DeviceConst.DOCK_TYPE_PORTABLE }

+ 1 - 1
ui-base/src/main/java/com/grkj/ui_base/base/BaseFormFragment.kt

@@ -142,7 +142,7 @@ abstract class BaseFormFragment<V : ViewDataBinding> : BaseFragment<V>() {
             title = CommonUtils.getStr("action_hint").toString(),
             msg = CommonUtils.getStr("not_save_tip").toString(),
             dialogType = TipDialog.DialogType.ERROR,
-            showCancel = false,
+            showCancel = true,
             onConfirmClick = onConfirm
         )
     }

+ 7 - 1
ui-base/src/main/java/com/grkj/ui_base/business/HardwareBusinessManager.kt

@@ -360,18 +360,24 @@ object HardwareBusinessManager {
      */
     private fun canDeviceStatusHandle(res: List<DeviceModel>) {
         logger.debug("硬件状态:{}", res)
-        if (MainDomainData.userInfo == null || res.isEmpty()) {
+        if (res.isEmpty()) {
             return@canDeviceStatusHandle
         }
         val deviceType = CanHelper.getDeviceTypeByNodeId(res[0].nodeId)
         when (deviceType) {
             CanDeviceConst.DEVICE_KEY_DOCK -> {
+                if (MainDomainData.userInfo==null){
+                    return
+                }
                 res.filterIsInstance<DeviceModel.DeviceKey>().forEach {
                     canDeviceKeyHandler(it)
                 }
             }
 
             CanDeviceConst.DEVICE_LOCK_DOCK -> {
+                if (MainDomainData.userInfo==null){
+                    return
+                }
                 res.filterIsInstance<DeviceModel.CommonDevice>().forEach {
                     canDeviceLockHandler(it)
                 }

+ 75 - 67
ui-base/src/main/java/com/grkj/ui_base/service/CheckKeyInfoTask.kt

@@ -11,6 +11,9 @@ import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import org.slf4j.Logger
 import org.slf4j.LoggerFactory
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
 
 /**
  * 检查钥匙信息任务
@@ -22,122 +25,127 @@ import org.slf4j.LoggerFactory
 class CheckKeyInfoTask {
 
     private val logger: Logger = LoggerFactory.getLogger(this::class.java)
-
-    /** 私有协程域(IO) */
     private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
-
-    /** 防重入锁:避免并发跑多个 check */
     private val runMutex = Mutex()
 
-    /** 登录闸门:true=放行;false=挂起等待 */
-    private val loginGate = MutableStateFlow(false)
+    /** 闸门:true = 允许跑(即 isInLogin == false) */
+    private val runGate = MutableStateFlow(false)
 
-    /** 延迟启动任务的句柄(可取消) */
     private var startCheckKeyInfoJob: Job? = null
 
+    /** 参数可按需调整 */
+    private companion object {
+        const val STABLE_MS = 1200L          // 退出登录需稳定的时间窗
+        const val DELAY_AFTER_EXIT_MS = 60_000L // 退出登录后再延迟启动(低优先级)
+        const val CHECK_TIMEOUT_MS = 10_000L // 单次 BLE 信息获取的超时时间
+    }
+
     /**
-     * 是否在登录界面
-     * 设为 true:打开闸门,并延迟 60s 启动一次检查
-     * 设为 false:关闭闸门,正在执行的检查会在下一个 await 处挂起
+     * 登录态:false = 允许跑;true = 暂停(必须在非登录下运行)
      */
     var isInLogin: Boolean = false
         set(value) {
             field = value
-            loginGate.value = value
-            if (value) {
-                // 如果已经有一个延迟任务在排队,先取消再重新计时
-                startCheckKeyInfoJob?.cancel()
+            runGate.value = !value // 反向:非登录 → 允许跑
+
+            // 触发策略:只有“退出登录”(value=false)才计划一次低优先级检查
+            startCheckKeyInfoJob?.cancel()
+            if (!value) {
+                // 抗抖 + 延迟(降低优先级,不跟业务抢)
                 startCheckKeyInfoJob = scope.launch {
-                    delay(60_000L)
+                    awaitNotInLogin(stableMs = STABLE_MS) // 先稳定
+                    delay(DELAY_AFTER_EXIT_MS)
                     safeCheckKeyInfo()
                 }
             } else {
-                // 退出登录页时,不强制取消正在跑的任务——让它在 await 处挂起即可
-                startCheckKeyInfoJob?.cancel()
                 startCheckKeyInfoJob = null
             }
         }
 
-    /** 对外:定时器触发的入口(保持不变) */
     @SuppressLint("MissingPermission")
     @CronJob(intervalMillis = 30 * 60_000L, initialDelay = 0, runOnMainThread = false)
     fun checkKeyInfo() {
-        // 用协程跑,避免阻塞 CronJob 线程
         scope.launch { safeCheckKeyInfo() }
     }
 
-    /**
-     * 受互斥保护的检查逻辑
-     */
     @SuppressLint("MissingPermission")
-    private suspend fun safeCheckKeyInfo() = runMutex.withLock {
-        logger.info("开始检查钥匙信息")
-        awaitLogin()
-
-        for (mac in HardwareBusinessManager.getExistsKeyMac()) {
-            mac?.let {
-                handleSingleMac(mac) // 串行执行
-            } ?: continue
+    private suspend fun safeCheckKeyInfo() {
+        // 不在锁里等闸门,避免大锁长期被占
+        awaitNotInLogin()
+        runMutex.withLock {
+            logger.info("开始检查钥匙信息(非登录态)")
+            for (mac in HardwareBusinessManager.getExistsKeyMac()) {
+                mac?.let { handleSingleMac(it) } ?: continue
+            }
+            logger.info("检查钥匙信息结束")
         }
-
-        logger.info("检查钥匙信息结束")
     }
 
-    /**
-     * 串行处理单个 MAC
-     */
     private suspend fun handleSingleMac(mac: String) {
-        // 登录状态检查
-        awaitLogin()
-        waitUntilCanConnect()
+        // 任意时刻闸门关了就暂停
+        awaitNotInLogin()
+        waitUntilCanConnect() // 若无可用连接位,轻量等待;闸门关了会提前返回
 
-        if (!isInLogin) {
-            logger.info("检测到不在登录页,暂停检查;mac=$mac")
-            awaitLogin()
+        if (!runGate.value) {
+            logger.info("检测到进入登录页,暂停检查;mac=$mac")
+            awaitNotInLogin()
         }
 
-        // 提交连接并等待回调完成
-        awaitBleCheck(mac)
+        awaitBleCheck(mac) // 取信息 + 强制断开(见下)
     }
 
-    /**
-     * 提交到 BleSendDispatcher 并等待回调完成
-     */
+    /** 统一策略:完成或取消都 scheduleDisconnect,确保单连接芯片不被占坑 */
     @SuppressLint("MissingPermission")
     private suspend fun awaitBleCheck(mac: String) {
-        return suspendCancellableCoroutine { cont ->
-            BleSendDispatcher.submit(mac) { ok ->
-                if (isInLogin) {
-                    BleSendDispatcher.scheduleDisconnect(mac)
+        withTimeout(CHECK_TIMEOUT_MS) {
+            suspendCancellableCoroutine { cont ->
+                // 监听闸门:一旦进入登录页(runGate=false),立即取消
+                val watcher = scope.launch {
+                    runGate
+                        .first { allowed -> !allowed } // 变为不允许时
+                    if (cont.isActive) cont.cancel(CancellationException("Gate closed"))
+                }
+
+                cont.invokeOnCancellation {
+                    try { BleSendDispatcher.scheduleDisconnect(mac) } catch (_: Throwable) {}
+                    watcher.cancel()
                 }
-                if (cont.isActive) {
-                    cont.resume(Unit) {}
+
+                BleSendDispatcher.submit(mac) { ok ->
+                    // 拿到信息后,无条件断开(不依赖实时 isInLogin,避免竞态)
+                    try { BleSendDispatcher.scheduleDisconnect(mac) } catch (_: Throwable) {}
+                    if (cont.isActive) cont.resume(Unit) {}
+                    watcher.cancel()
                 }
             }
         }
     }
 
-
-    /**
-     * 挂起直到登录页(isInLogin=true)
-     * - 不 busy-wait;真正挂起,省电省 CPU
-     */
-    private suspend fun awaitLogin() {
-        if (loginGate.value) return
-        logger.info("不在登录页,挂起等待 …")
-        loginGate.first { it } // 挂起直到变为 true
-        logger.info("登录页已就绪,恢复检查")
+    /** 等待“非登录态”且稳定 STABLE_MS,真正挂起,不 busy-wait */
+    private suspend fun awaitNotInLogin(stableMs: Long = STABLE_MS) {
+        if (runGate.value) return
+        logger.info("当前在登录页,挂起等待退出登录…")
+        runGate
+            .debounce(stableMs)          // 抗抖:需稳定一段时间
+            .first { it }                // 等到允许跑
+        logger.info("已退出登录且稳定 ${stableMs}ms,继续检查")
     }
 
-    /**
-     * 如果连接达到上限,则小睡等待;防止 submit 直接把队列堆爆
-     * (这里选了一个很轻的策略:短暂重试,避免卡死。你也可以结合信号量/通道做更细控制)
-     */
+    /** 轻量的连接位等待:闸门关闭或超时就让路;指数退避,避免忙等 */
     private suspend fun waitUntilCanConnect(maxWaitMillis: Long = 5_000L) {
         val start = System.currentTimeMillis()
+        var delayMs = 100L
         while (!BleSendDispatcher.canConnect()) {
+            if (!runGate.value) return // 闸门关了,优先让路
             if (System.currentTimeMillis() - start > maxWaitMillis) break
-            delay(100) // 轻量轮询,避免忙等
+            delay(delayMs)
+            delayMs = (delayMs * 2).coerceAtMost(800L)
         }
     }
+
+    /** 建议加:在宿主销毁时调用,避免悬挂 */
+    fun close() {
+        startCheckKeyInfoJob?.cancel()
+        scope.cancel()
+    }
 }