From e6cfc4dff2ad2ea50ed6002d6df56baf3fdf5b6d Mon Sep 17 00:00:00 2001 From: lvmeng <848755140@qq.com> Date: Wed, 11 Mar 2026 13:41:01 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E6=8B=86=E5=88=86?= =?UTF-8?q?=20HomeActivity=20=E8=81=8C=E8=B4=A3=EF=BC=8C=E5=88=86=E5=B1=82?= =?UTF-8?q?=E8=A7=A3=E8=80=A6=E4=B8=B2=E5=8F=A3=E4=B8=8E=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 SerialEventListener:定义串口协议解析回调接口契约 - 新建 SerialProtocolParser:迁移全部协议解析逻辑(拼包、12种命令分发、重量解析),无 Android 依赖,可单元测试 - 新建 SerialViewModel:管理串口生命周期(open/close)、Channel 数据传递,暴露 weightFlow / tempCtrlEventFlow / deviceStatusRefreshFlow - 扩展 HomeViewModel:迁入定时任务(保存货架数据、刷新过期状态),改用 viewModelScope 管理,新增 deviceName 属性与 overdueUpdateFlow - 精简 HomeActivity:从 835 行削减至 255 行,仅保留 UI 协调职责,所有串口与业务逻辑由 ViewModel 层处理 Co-Authored-By: Claude Sonnet 4.6 --- .../shelves/activity/HomeActivity.kt | 633 +++--------------- .../intelligent/shelves/net/HomeViewModel.kt | 165 ++++- .../shelves/net/SerialViewModel.kt | 157 +++++ .../shelves/serial/SerialEventListener.kt | 40 ++ .../shelves/serial/SerialProtocolParser.kt | 326 +++++++++ app/src/main/res/layout/dialog_layout.xml | 50 ++ 6 files changed, 831 insertions(+), 540 deletions(-) create mode 100644 app/src/main/java/com/shuwei/intelligent/shelves/net/SerialViewModel.kt create mode 100644 app/src/main/java/com/shuwei/intelligent/shelves/serial/SerialEventListener.kt create mode 100644 app/src/main/java/com/shuwei/intelligent/shelves/serial/SerialProtocolParser.kt create mode 100644 app/src/main/res/layout/dialog_layout.xml diff --git a/app/src/main/java/com/shuwei/intelligent/shelves/activity/HomeActivity.kt b/app/src/main/java/com/shuwei/intelligent/shelves/activity/HomeActivity.kt index 6975b5b..72adb97 100644 --- a/app/src/main/java/com/shuwei/intelligent/shelves/activity/HomeActivity.kt +++ b/app/src/main/java/com/shuwei/intelligent/shelves/activity/HomeActivity.kt @@ -30,20 +30,13 @@ import com.shuwei.intelligent.shelves.net.HomeViewModel import com.shuwei.intelligent.shelves.net.Loading import com.shuwei.intelligent.shelves.net.NetViewModel import com.shuwei.intelligent.shelves.net.RespData +import com.shuwei.intelligent.shelves.net.SerialViewModel import com.shuwei.intelligent.shelves.net.UiState -import com.shuwei.intelligent.shelves.net.apiService -import com.shuwei.intelligent.shelves.serial.SerialPortManager -import com.shuwei.intelligent.shelves.utils.HexUtils -import com.shuwei.intelligent.shelves.utils.IntervalExecutor -import com.shuwei.intelligent.shelves.utils.binaryToHex import com.shuwei.intelligent.shelves.utils.ext.copyTextToClipboard import com.shuwei.intelligent.shelves.utils.ext.gone import com.shuwei.intelligent.shelves.utils.ext.toJsonString import com.shuwei.intelligent.shelves.utils.ext.toast import com.shuwei.intelligent.shelves.utils.ext.visible -import com.shuwei.intelligent.shelves.utils.hexToBinary -import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe @@ -56,58 +49,47 @@ class HomeActivity : BaseActivity() { companion object { private const val TAG = "HomeActivity" - private const val LOG_PREFIX = "receiveSerialPortData:收到" - private const val MM_DD_EEEE__HH_MM_SS = "MM月dd日 EEEE HH:mm:ss" - //协议头 + // 协议头 const val HEADER = "D6DADEDB" - //协议尾 + // 协议尾 const val FOOTER = "FFFF" -// const val ACTIVE_CMD = -// "${HEADER}000281C5D70A7D428A4FA67B50F7DACA241938938B4B9CBD73673E9C8E7F2E15062D${FOOTER}" const val LEFT_SHELF_OPEN_CMD = "${HEADER}020101000000000000000000000000000000000000${FOOTER}" const val RIGHT_SHELF_OPEN_CMD = "${HEADER}020100010000000000000000000000000000000000${FOOTER}" - //const val TEMPERATURE_CMD = "${HEADER}1001${FOOTER}" - const val DEVICE_INFO_CMD = "${HEADER}0301000000000000000000000000${FOOTER}" - const val START_TEMP_CTRL_CMD = "${HEADER}0F01360000${FOOTER}" - //const val START_CMD= "${HEADER}0F01050020${FOOTER}" -// const val ACTIVE_CMD = "${HEADER}000281C5D70A7D428948AD7254FADBB1516738938B4B9CBD73673E9D8E7F2E15062D$FOOTER" -// 410激活码 -// const val ACTIVE_CMD = "${HEADER}000281C5D70A7D42894EA17650F5D0BB516838E7863C9ABD766C38958AFF0F3C062D${FOOTER}" - //1楼餐厅激活码 + // 1楼餐厅激活码 const val ACTIVE_CMD = "${HEADER}000281C5D70A7D428B44A57454F5DACA241938938B4B9CBD73673E9C8E7FC0C4992D${FOOTER}" - //华氏温度单位 + // 华氏温度单位 const val F_TEMP_CMD = "${HEADER}0F01390000${FOOTER}" - //摄氏温度单位 + // 摄氏温度单位 const val C_TEMP_CMD = "${HEADER}0F01390001${FOOTER}" - //开灯(开 继电器1) + // 开灯(开继电器1) const val LIGHT_CLOSE = "${HEADER}020102020102020000000000000000000000000000${FOOTER}" const val LIGHT_OPEN = "${HEADER}020102020002020000000000000000000000000000${FOOTER}" const val TEMPERATURE_CTRL = "${HEADER}0F01040000${FOOTER}" - } - //401-false,1楼餐厅-true + // 401-false,1楼餐厅-true private var isNeedOpenLock = true private lateinit var binding: ActivityHomeBinding private val viewModel: NetViewModel by viewModels() private val homeViewModel: HomeViewModel by viewModels() + private val serialViewModel: SerialViewModel by viewModels() @SuppressLint("NotifyDataSetChanged", "HardwareIds") override fun onCreate(savedInstanceState: Bundle?) { @@ -117,38 +99,37 @@ class HomeActivity : BaseActivity() { setBackground() setContentView(binding.root) -// updateLeftStatus("-°C / -%") updateLeftStatus("") - initRecyclerView() - // 串口初始化:独立协程,仅执行一次 - lifecycleScope.launch { - val openState = SerialPortManager.open() - log("onCreate: openState=$openState") - if (openState) { - // 串口回调在 IO 线程,通过 Channel 无锁传递数据,不再嵌套 launch - SerialPortManager.startReceive { data -> - serialDataChannel.trySend(data) - } - //SerialPortManager.send(DEVICE_INFO_CMD) - } - } + // 打开串口,串口生命周期由 SerialViewModel 管理 + serialViewModel.openSerial() - // 串口数据消费:单一协程串行处理,serialReader 访问不存在并发竞争 - lifecycleScope.launch { - for (data in serialDataChannel) { - try { - receiveSerialPortData(data) - } catch (e: Exception) { - e.printStackTrace() - } - } - } + // 统一注册所有 ViewModel Flow 观察 + observeViewModels() - // ViewModel 状态观察:STARTED 状态下收集,onStop 时自动取消,onStart 时恢复 + // 延迟发送温控初始化命令 + binding.root.postDelayed({ + serialViewModel.sendCmd(START_TEMP_CTRL_CMD) + serialViewModel.sendCmd(C_TEMP_CMD) + }, 5000) + + // 启动定时任务(保存货架数据 + 刷新过期状态),生命周期由 viewModelScope 管理 + homeViewModel.startPeriodicTasks() + + // 拉取货架列表 + viewModel.getShelfList(deviceId = App.deviceId) + } + + /** + * 统一注册所有 ViewModel Flow 观察。 + * 在 STARTED 状态下收集,onStop 时自动暂停,onStart 时恢复。 + */ + private fun observeViewModels() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { + + // 获取 Token 结果 launch { viewModel.getAccessTokenUiState.collect { state -> when (state) { @@ -159,6 +140,8 @@ class HomeActivity : BaseActivity() { } } } + + // 货架列表加载结果 launch { viewModel.getShelfListUiState.collect { state -> when (state) { @@ -169,61 +152,52 @@ class HomeActivity : BaseActivity() { } } } + + // 串口重量更新:更新 ViewModel 缓存 + 局部刷新 Adapter + launch { + serialViewModel.weightFlow.collect { (deviceNo, weight) -> + homeViewModel.putWeight(deviceNo, weight) + if (deviceNo in 1..10 && homeViewModel.shelfItems.size == 10) { + homeViewModel.shelfItems.firstOrNull { it.deviceNo == deviceNo } + ?.let { model -> + model.weight = weight.toDouble() + val pos = homeViewModel.shelfItems.indexOf(model) + shelfAdapter.notifyItemChanged(pos) + log("weightFlow: deviceNo=${model.deviceNo}, weight=$weight") + EventBus.getDefault().post(SendWeightEvent(model.deviceNo, weight)) + } + } + } + } + + // 串口温控上报:弹窗展示日志 + launch { + serialViewModel.tempCtrlEventFlow.collect { logMsg -> + showTipDialog(logMsg) + } + } + + // 串口触发顶部状态栏刷新 + launch { + serialViewModel.deviceStatusRefreshFlow.collect { + updateLeftStatus(homeViewModel.deviceName) + } + } + + // 过期状态变化:局部刷新对应 position + launch { + homeViewModel.overdueUpdateFlow.collect { positions -> + positions.forEach { shelfAdapter.notifyItemChanged(it) } + } + } } } - //sendCmd(ACTIVE_CMD) - - binding.root.postDelayed({ - sendCmd(START_TEMP_CTRL_CMD) - sendCmd(C_TEMP_CMD) - }, 5000) - -// TaskManager.startTask() - - saveGoodsTask() - overdueTask() - -// var androidId = Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID) -//// androidId = "d0e7a2d5f25c775c" -// log("onCreate: androidId=${androidId}") -// if (androidId == "65e1012638f78260" || androidId == "40f89e91ee40611d") { -// androidId = "d0e7a2d5f25c775c" -// } -// App.deviceId = androidId -// //"53babf69-0d2f-3875-a649-f2e12f80ad4c1" -// -// val swDpValue = resources?.configuration?.smallestScreenWidthDp -// log("onCreate: swDpValue=$swDpValue") - - -// viewModel.getAccessToken(App.deviceId) - - viewModel.getShelfList(deviceId = App.deviceId) - -// binding.root.postDelayed({ -// loadTestData() -// }, 30*1000) - } - - private fun loadTestData() { - homeViewModel.shelfItems.add(ShelfModel(deviceNo = 1, goodsName = "鸡蛋", deviceId = App.deviceId)) - homeViewModel.shelfItems.add(ShelfModel(deviceNo = 6, goodsName = "", deviceId = App.deviceId)) - homeViewModel.shelfItems.add(ShelfModel(deviceNo = 2, goodsName = "", deviceId = App.deviceId)) - homeViewModel.shelfItems.add(ShelfModel(deviceNo = 7, goodsName = "生姜", deviceId = App.deviceId)) - homeViewModel.shelfItems.add(ShelfModel(deviceNo = 3, goodsName = "黄瓜", deviceId = App.deviceId)) - homeViewModel.shelfItems.add(ShelfModel(deviceNo = 8, goodsName = "", deviceId = App.deviceId)) - homeViewModel.shelfItems.add(ShelfModel(deviceNo = 4, goodsName = "梨", deviceId = App.deviceId)) - homeViewModel.shelfItems.add(ShelfModel(deviceNo = 9, goodsName = "梨", deviceId = App.deviceId)) - homeViewModel.shelfItems.add(ShelfModel(deviceNo = 5, goodsName = "", deviceId = App.deviceId)) - homeViewModel.shelfItems.add(ShelfModel(deviceNo = 10, goodsName = "", deviceId = App.deviceId)) - shelfAdapter.notifyDataSetChanged() } private fun getTokenSuccess(data: RespData<*>) { log("getTokenSuccess: $data") data.data?.let { App.accessToken = it.toString() - //UIUtils.toast(it.toString()) viewModel.getShelfList(deviceId = App.deviceId) } } @@ -240,10 +214,9 @@ class HomeActivity : BaseActivity() { loadEmptyView() return } - deviceName = data.data.deviceName - -// updateLeftStatus("$deviceName -°C / -%") - updateLeftStatus("$deviceName") + // 将 deviceName 写入 HomeViewModel,跨配置变更保持 + homeViewModel.deviceName = data.data.deviceName + updateLeftStatus(homeViewModel.deviceName) App.canteenId = data.data.placeId val tempList = data.data.containerGoodsList @@ -251,10 +224,6 @@ class HomeActivity : BaseActivity() { loadEmptyView() return } -// if (tempList.size < 10) { -// toast("数据错误") -// return -// } homeViewModel.shelfItems.clear() val subList01 = tempList.subList(0, tempList.size / 2) val subList02 = tempList.subList(tempList.size / 2, tempList.size) @@ -272,363 +241,30 @@ class HomeActivity : BaseActivity() { } /** - * 终端id + * 显示温控信息弹窗。 + * 若弹窗已在展示中则不重复打开,仅更新内容。 */ - private var terminalId: String = "" - - /** - * 固件版本 - */ - private var terminalVersion: String = "" - - /** - * 串口数据接收通道,无界缓冲。 - * IO 线程(串口回调)通过 trySend 写入,单一消费协程串行读取, - * 确保 serialReader 始终在同一协程中访问,规避并发竞争。 - */ - private val serialDataChannel = Channel(Channel.UNLIMITED) - - /** - * 串口数据拼包缓冲器。 - * 仅由 serialDataChannel 的单一消费协程访问,线程安全。 - */ - private val serialReader = StringBuilder() - - private fun receiveSerialPortData(srcData: String) { - log("receiveSerialPortData: $srcData") - var data = "" - if (srcData.contains(HEADER) && srcData.contains(FOOTER)) { - serialReader.clear() - val startIndex = srcData.lastIndexOf(HEADER) - var endIndex = srcData.indexOf(FOOTER) + FOOTER.length - if (endIndex < startIndex) { - //类似这种:31314FFFFFD6DADEDB01010C,FOOTER在HEADER前 - data = srcData.substring(startIndex) - if (data.contains(FOOTER)) { - endIndex = srcData.indexOf(FOOTER) + FOOTER.length - data = data.substring(0, endIndex) - handleReceivedData(data) - serialReader.clear() - return - } - serialReader.clear() - serialReader.append(data).append(",") - return + private fun showTipDialog(logMsg: String) { + if (tipDialog == null) { + tipDialog = CommonDialog(this).apply { + dialogTitle = "温馨提示" + confirmBlock = { logMsg.copyTextToClipboard(this@HomeActivity) } } - serialReader.append(srcData.substring(startIndex, endIndex)) - data = serialReader.toString() - handleReceivedData(data) - serialReader.clear() - return } - if (srcData.contains(HEADER) && srcData.contains(FOOTER).not()) { - serialReader.clear() - val startIndex = srcData.lastIndexOf(HEADER) - serialReader.append(srcData.substring(startIndex)).append(",") - return - } - if (srcData.contains(HEADER).not() && srcData.contains(FOOTER).not()) { - serialReader.append(srcData).append(",") - return - } - //if (srcData.contains(HEADER).not() && srcData.contains(FOOTER)) - val endIndex = srcData.indexOf(FOOTER) + FOOTER.length - serialReader.append(srcData.substring(0, endIndex)) - log("receiveSerialPortData:serialReader: $serialReader") - data = serialReader.toString().replace(",", "") - handleReceivedData(data) - } - - private fun handleReceivedData(data: String) { - if (data.isEmpty()) { - return - } - - val cmd = data.substring(HEADER.length, HEADER.length + 4) - val cmdFlag = cmd.substring(0, 2) - when (cmd) { - "0001" -> { - //终端发起激活指令,拿到固定数据体(终端ID-15字节、固件版本-5字节、命令次数-1字节) - val reqBody = - data.substring(HEADER.length + cmd.length, data.length - FOOTER.length) - if (reqBody.length == 44) { - terminalId = reqBody.substring(2, 32) - terminalVersion = reqBody.substring(32, 42) - log("${LOG_PREFIX}[激活]${cmdFlag}指令:${data},终端id:${terminalId},固件版本:${terminalVersion},发送激活命令:$ACTIVE_CMD") - sendCmd(ACTIVE_CMD) - } - } - - "0101", "8101" -> { - //心跳0102${p1}${p2}${version} - //心跳81(终端发起,当终端发送此命令时需要服务端存储串口屏显示信息) - val respCmd = "${HEADER}01020506${terminalVersion}$FOOTER" - log("${LOG_PREFIX}[心跳]${cmdFlag}指令:${data},发送应答指令:${respCmd}") - sendCmd(respCmd) - heartBeat(data) - } - - "0202" -> { - //开关锁反馈020200${deviceId}${version} - val respCmd = "${HEADER}020200${terminalId}${terminalVersion}$FOOTER" - log("${LOG_PREFIX}[开锁]${cmdFlag}指令:${data},发送应答指令:${respCmd}") - sendCmd(respCmd) - //lockFeedback(data) - } - - "0302" -> { - //获取设备状态 - log("${LOG_PREFIX}[获取设备状态]${cmdFlag}指令:${data}") - getDeviceInfo(data) - } - - "0501" -> { - //卡号上发 - val respCmd = "${HEADER}050203$FOOTER" - log("${LOG_PREFIX}[卡号上发]${cmdFlag}指令:${data},发送应答指令:${respCmd}") - sendCmd(respCmd) - } - - "0B01" -> { - //操作记录上传 - val respCmd = "${HEADER}0B0203$FOOTER" - log("${LOG_PREFIX}[操作记录上传]${cmdFlag}指令:${data},发送应答指令:${respCmd}") - sendCmd(respCmd) - } - - "0C01" -> { - //对时 - val timeLong = System.currentTimeMillis() / 1000 + (8 * 3600) - val timeStr = "0000000" + String.format("%X", timeLong) - val time = - timeStr.substring(timeStr.length - 8).chunked(2).reversed().joinToString("") - val respCmd = "${HEADER}0B02${time}$FOOTER" - log("${LOG_PREFIX}[对时]${cmdFlag}指令:${data},发送应答指令:${respCmd}") - sendCmd(respCmd) - } - - "0E01" -> { - //终端位置上报 - val respCmd = "${HEADER}0E0200$FOOTER" - log("${LOG_PREFIX}[终端位置上报]${cmdFlag}指令:${data},发送应答指令:${respCmd}") - sendCmd(respCmd) - } - - "1001" -> { - //温控信息上报 - val respCmd = "${HEADER}100200$FOOTER" - val logMsg = - "${LOG_PREFIX}[温控信息上报]${cmdFlag}指令:${data},发送应答指令:${respCmd}" - log(logMsg) - sendCmd(respCmd) - //val tempC = HexUtils.signedHexToDec(data.take(4)) * 0.1 -// updateLeftStatus("$deviceName ${"%.1f".format(tempC)}°C / -%") -// updateLeftStatus("$deviceName -°C / -%") - updateLeftStatus("$deviceName") - - //D6DADEDB1001015D020000000027001E00020000001E001C00B40000000000010000000100040000001900440003000F00000000000FFFE6000200010002FFD80044000300010005001E0001FFD80056001400280000000000000000008300090000003C00D2000000000000007F003700000000A100000031323136334238423134303233303456322E313100FFFF - runOnUiThread { - if (tipDialog == null) { - tipDialog = CommonDialog(this).apply { - dialogTitle = "温馨提示" -// dialogContent = logMsg - confirmBlock = { - logMsg?.copyTextToClipboard(this@HomeActivity) - } - } - } - if (tipDialog?.isShowing == false) { - tipDialog?.dialogContent = logMsg - tipDialog?.show() - } - } - } - - "1002" -> { - log("${LOG_PREFIX}[温控信息应答]1002数据:${data}") - } - - "0F02" -> { - log("${LOG_PREFIX}[温控信息应答]0F02数据:${data}") - //D6DADEDB0F020331323136334238423134303233303456322E31313EFFFF00000055010000000000002707000001000000220100000000000091040000120100000100000095140080611400800000000000000000000000000000000000000000010100005031323136334238423134303233303456322E31313EFFFF - runOnUiThread { } - } - - else -> {} + if (tipDialog?.isShowing == false) { + tipDialog?.dialogContent = logMsg + tipDialog?.show() } } private var tipDialog: CommonDialog? = null - private var fixedDataBody = "" - -// private fun lockFeedback(data: String) { -// runCatching { -// val fixedDataBodyEndIndex = data.length - FOOTER.length -// val fixedDataBodyStartIndex = HEADER.length + "0202".length + 42 -// fixedDataBody = data.substring(fixedDataBodyStartIndex, fixedDataBodyEndIndex) -// -// val lockNo = if (list[shelfIndex].deviceNo in 1..5) "01" else "02" -// val lockOneState = data.substring(HEADER.length + 4, HEADER.length + 6) -// if (lockOneState == "00") { -// //锁1关 -// SerialPortManager.send("${HEADER}0202${lockNo}${FOOTER}") -// } -// val lockTwoState = data.substring(HEADER.length + 6, HEADER.length + 8) -// if (lockTwoState == "00") { -// //锁2关 -// SerialPortManager.send("${HEADER}0202${lockNo}${FOOTER}") -// } -// }.onFailure { -// it.printStackTrace() -// } -// } - - private fun heartBeat(data: String) { - runCatching { - if (data.length < 200) { - return - } - val weightStartIndex = 14 - val weightEndIndex = weightStartIndex + 12 * 8 - getWeightInfo( - start = weightStartIndex, - end = weightEndIndex, - data = data - ) - val temperatureStartIndex = 158 - val temperatureEndIndex = temperatureStartIndex + 2 -// val temperatureHex = data.substring(temperatureStartIndex, temperatureEndIndex) -// val temperature = hexToDec(temperatureHex) -// val tempC = f2C(temperature.toDouble()) -// showTemperatureC = "%.1f".format(tempC) - - //val percent = 10 * list.count { it.goodsName.isNullOrBlank().not() } - -// updateLeftStatus("${deviceName} [$temperatureHex] ${showTemperatureC}°C / -%") - - getFixDataBody(data, temperatureEndIndex) - }.onFailure { - it.printStackTrace() - } - } - - private var deviceName = "" - - private fun getWeightInfo(start: Int, end: Int, data: String) { - var index = 1 - var count = start - while (count < end) { - val weightInfo = data.substring(count, count + 8) - var weightHex = "" - weightInfo.run { - weightHex = - substring(6, 8) + substring(4, 6) + substring(2, 4) + substring(0, 2) - } - var realWeight = 0 - var firstByteBinary = hexToBinary(weightHex.substring(0, 2)) - if (firstByteBinary.startsWith("0")) { - //正数:按十六进制解析,失败时兜底为 0 - realWeight = weightHex.toIntOrNull(16) ?: 0 - } else { - //负数:清除符号位后取绝对值,再取反 - firstByteBinary = "0${firstByteBinary.substring(1)}" - val firstByteHex = binaryToHex(firstByteBinary) - weightHex = firstByteHex + weightHex.substring(2) - realWeight = -1 * (weightHex.toIntOrNull(16) ?: 0) - } - homeViewModel.putWeight(index, realWeight) - - if (index in 1..10 && homeViewModel.shelfItems.size == 10) { - homeViewModel.shelfItems.firstOrNull { it.deviceNo == index }?.let { - it.weight = realWeight.toDouble() - val pos = homeViewModel.shelfItems.indexOf(it) - shelfAdapter.notifyItemChanged(pos) - log("getWeightInfo: deviceNo=${it.deviceNo},realWeight=${realWeight}") - EventBus.getDefault().post(SendWeightEvent(it.deviceNo, realWeight)) - } -// val pos = index - 1 -// val item = list[pos] -// item.weight = realWeight.toDouble() -// shelfAdapter.notifyItemChanged(pos) -// log("getWeightInfo: deviceNo=${item.deviceNo},realWeight=${realWeight}") -// EventBus.getDefault().post(SendWeightEvent(item.deviceNo, realWeight)) - } - - count += 8 - index++ - } -// ShelfActivity.updateWeight(getRealWeight()) -// if (list.isNotEmpty()) { -// val intent = Intent(ShelfActivity.RECEIVER_DEVICE_INFO + list[shelfIndex].deviceNo) -// intent.putExtra(ShelfActivity.SHELF_WEIGHT, weightArray[shelfIndex]) -// LocalBroadcastManager.getInstance(this).sendBroadcast(intent) -// } - } - - private fun getDeviceInfo(data: String) { - log("getDeviceInfo: data=$data") - val weightStartIndex = HEADER.length + 14 - val temperatureStartIndex = weightStartIndex + 12 * 8 - val temperatureEndIndex = temperatureStartIndex + 2 - val temperatureHex = try { - data.substring(temperatureStartIndex, temperatureEndIndex) - } catch (e: Exception) { - e.printStackTrace() - return - } -// val temperature = hexToDec(temperatureHex) -// val tempC = f2C(temperature.toDouble()) -// showTemperatureC = "%.1f".format(tempC) - - //val percent = 10 * list.count { it.goodsName.isNullOrBlank().not() } - -// updateLeftStatus("${deviceName} ${showTemperatureC}°C / -%") - - -// val tempC = HexUtils.signedHexToDec(temperatureHex) * 0.1 -// updateLeftStatus("$deviceName -°C / -%") - updateLeftStatus("$deviceName") - - getWeightInfo( - start = weightStartIndex, - end = temperatureStartIndex, - data = data - ) - - getFixDataBody(data, temperatureEndIndex) - } - - fun getFixDataBody(data: String, temperatureEndIndex: Int) { - try { - if (terminalId.isNotBlank() && terminalVersion.isNotBlank()) { - return - } - val fixDataBodyStartIndex = temperatureEndIndex + 1 - val fixDataBody = data.substring(fixDataBodyStartIndex, data.length - FOOTER.length) - if (fixDataBody.length == 42) { - terminalId = fixDataBody.substring(0, 30) - terminalVersion = fixDataBody.substring(30, 40) - log("receiveSerialPortData:从设备状态获取数据,终端id:${terminalId},固件版本:${terminalVersion}") - } - } catch (e: Exception) { - e.printStackTrace() - } - } - override fun onDestroy() { - lifecycleScope.launch { - SerialPortManager.close() - } -// TaskManager.cancelTask() - saveTaskJob?.cancel() - overdueTaskJob?.cancel() - // 关闭 Channel,消费协程的 for 循环将随之结束 - serialDataChannel.close() - // 销毁前关闭 Dialog,防止 Activity 已销毁时窗口仍附着导致泄漏 + // 弹窗需在 Activity 销毁前手动关闭,防止窗口泄漏 tipDialog?.dismiss() tipDialog = null EventBus.getDefault().unregister(this) + // 串口关闭、Channel 关闭、定时任务取消均由各 ViewModel.onCleared() 自动管理 super.onDestroy() } @@ -637,18 +273,14 @@ class HomeActivity : BaseActivity() { setOnItemClickListener { _, _, position -> homeViewModel.selectedShelfIndex = position lifecycleScope.launch { + // 根据货架编号决定开启左侧或右侧柜锁 if (isNeedOpenLock) { val openCmd = if (homeViewModel.shelfItems[position].deviceNo in 1..5) LEFT_SHELF_OPEN_CMD else RIGHT_SHELF_OPEN_CMD - val openState = SerialPortManager.send(openCmd) + serialViewModel.sendCmd(openCmd) } -// if (openState.not()) { -// toast("${homeViewModel.shelfItems[position].goodsName}柜门开启失败") -// return@launch -// } - //SerialPortManager.send(START_CMD) launch(Intent(this@HomeActivity, ShelfActivity::class.java).also { it.putExtra(ShelfActivity.SHELF_MODEL, homeViewModel.shelfItems[position]) }) { @@ -660,6 +292,8 @@ class HomeActivity : BaseActivity() { @Suppress("DEPRECATION") getSerializableExtra(ShelfActivity.SHELF_MODEL) as? ShelfModel } ?: return@run + + // 将 ShelfActivity 返回的数据同步回列表 homeViewModel.shelfItems[position].apply { goodsId = model.goodsId goodsName = model.goodsName @@ -670,8 +304,8 @@ class HomeActivity : BaseActivity() { } notifyItemChanged(position) + // 货架操作后立即将最新数据同步到服务端 try { - val submitList = homeViewModel.shelfItems.sortedBy { item -> item.deviceNo } val body = ShelfBody().also { body -> body.deviceId = App.deviceId ?: "" @@ -680,11 +314,8 @@ class HomeActivity : BaseActivity() { body.humidity = homeViewModel.humidity body.goodsList = submitList } - Log.d("mzf", "performSync: body:${body.toJsonString()}") - lifecycleScope.launch { - val resp = apiService.saveShelfGoodsList(body = body) - Log.d("mzf", "performSync: resp:${resp.toJsonString()}") - } + Log.d(TAG, "shelfItemClick sync: body=${body.toJsonString()}") + viewModel.saveShelfGoodsList(body) } catch (e: Exception) { e.printStackTrace() } @@ -700,7 +331,6 @@ class HomeActivity : BaseActivity() { layoutManager = GridLayoutManager(this@HomeActivity, 2, GridLayoutManager.VERTICAL, false) adapter = shelfAdapter - itemAnimator.let { if (it is DefaultItemAnimator) { it.supportsChangeAnimations = false @@ -727,8 +357,6 @@ class HomeActivity : BaseActivity() { super.onResume() hideStatusBar() handler.post(updateTask) -// D6DADEDB00010031323136334238423134303233303456322E313100FFFF -// sendCmd("${HEADER}00010031323136334238423134303233303456322E313152${FOOTER}") } override fun onPause() { @@ -740,11 +368,9 @@ class HomeActivity : BaseActivity() { private fun loadEmptyView() { homeViewModel.shelfItems.clear() shelfAdapter.notifyDataSetChanged() - binding.include?.let { it.root.visible() it.root.setOnClickListener { - //viewModel.getAccessToken(App.deviceId) viewModel.getShelfList(deviceId = App.deviceId) } it.ivEmptyIcon.setImageResource(R.drawable.ic_empty_white) @@ -753,12 +379,11 @@ class HomeActivity : BaseActivity() { } @Subscribe(threadMode = ThreadMode.MAIN) - public fun weightEvent(event: WeightEvent) { - + fun weightEvent(event: WeightEvent) { } @Subscribe(threadMode = ThreadMode.MAIN) - public fun clearEmptyShelf(event: ClearShelfEvent) { + fun clearEmptyShelf(event: ClearShelfEvent) { val item = homeViewModel.shelfItems.firstOrNull { it.deviceNo == event.shelfNo } item?.apply { goodsId = "" @@ -770,66 +395,4 @@ class HomeActivity : BaseActivity() { } shelfAdapter.notifyItemChanged(homeViewModel.shelfItems.indexOf(item)) } - - private val taskExecutor by lazy { IntervalExecutor() } - private var saveTaskJob: Job?=null - - /** - * 保存定时任务 - */ - private fun saveGoodsTask() { - saveTaskJob = taskExecutor.startIntervalTaskWithInitialDelay(1 * 60 * 1000L, 5 * 60 * 1000L) { - saveShelfGoodsList() - } - } - - private var overdueTaskJob: Job?=null - - /** - * 过期时间定时任务 - */ - private fun overdueTask() { - overdueTaskJob = taskExecutor.startIntervalTaskWithInitialDelay(5 * 1000L, 5 * 60* 1000L) { - updateOverdueState() - } - } - - private fun updateOverdueState() { - viewModel.updateOverdueState { state, items -> - if (items.isNullOrEmpty()) return@updateOverdueState - homeViewModel.shelfItems.forEachIndexed { index, model -> - // 按 deviceNo 精确匹配接口数据,避免: - // 1. 两端列表大小不一致时的 IndexOutOfBoundsException - // 2. 本地列表交叉排列与接口顺序不一致导致的状态串位 - val serverModel = items.find { it.deviceNo == model.deviceNo } - ?: return@forEachIndexed - if (serverModel.overdue != model.overdue) { - model.overdue = serverModel.overdue - shelfAdapter.notifyItemChanged(index) - log("更新编号为${model.deviceNo}物品:${model.goodsName}的过期状态:${model.overdue}") - } - } - } - } - - private fun saveShelfGoodsList() { - log("performSync: list:${homeViewModel.shelfItems.toJsonString()}") - val submitList = homeViewModel.shelfItems.sortedBy { it.deviceNo } - submitList.forEach { - if (it.goodsId.isNullOrBlank()) { - it.weight = it.weightBak - } - } - val body = ShelfBody().also { - it.deviceId = App.deviceId - it.canteenId = App.canteenId - it.temperature = homeViewModel.temperatureC - it.humidity = homeViewModel.humidity - it.goodsList = submitList - } - log("performSync: body:${body.toJsonString()}") - viewModel.saveShelfGoodsList(body) - } - - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/shuwei/intelligent/shelves/net/HomeViewModel.kt b/app/src/main/java/com/shuwei/intelligent/shelves/net/HomeViewModel.kt index d228d8b..17274e9 100644 --- a/app/src/main/java/com/shuwei/intelligent/shelves/net/HomeViewModel.kt +++ b/app/src/main/java/com/shuwei/intelligent/shelves/net/HomeViewModel.kt @@ -1,18 +1,48 @@ package com.shuwei.intelligent.shelves.net +import android.util.Log import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.shuwei.intelligent.shelves.App +import com.shuwei.intelligent.shelves.model.ShelfBody import com.shuwei.intelligent.shelves.model.ShelfModel +import com.shuwei.intelligent.shelves.utils.ext.toJsonString +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch /** * HomeActivity 专用 ViewModel。 * - * 职责:管理原本散落在 HomeActivity.companion object 中的 UI 状态数据, - * 避免静态共享状态导致的生命周期污染与状态残留问题。 - * - * 包含:货架列表、重量数据、选中索引、温湿度。 + * 职责: + * 1. 管理货架列表、重量、温湿度等 UI 状态数据; + * 2. 持有 deviceName,供 Activity 更新顶部状态栏; + * 3. 管理定时任务(保存货架数据、刷新过期状态),生命周期由 viewModelScope 保障, + * 跨配置变更存活,Activity 销毁时自动取消,无需手动持有 Job 引用。 */ class HomeViewModel : ViewModel() { + companion object { + private const val TAG = "HomeViewModel" + + /** 首次保存延迟(1 分钟) */ + private const val SAVE_INITIAL_DELAY = 1 * 60 * 1000L + + /** 保存间隔(5 分钟) */ + private const val SAVE_INTERVAL = 5 * 60 * 1000L + + /** 过期检查首次延迟(5 秒) */ + private const val OVERDUE_INITIAL_DELAY = 5 * 1000L + + /** 过期检查间隔(5 分钟) */ + private const val OVERDUE_INTERVAL = 5 * 60 * 1000L + } + + // ---------------------------------------------------------------- + // UI 状态数据 + // ---------------------------------------------------------------- + /** * 货架列表。 * Adapter 持有同一个列表引用,修改后需调用 notifyDataSetChanged / notifyItemChanged 触发刷新。 @@ -22,7 +52,6 @@ class HomeViewModel : ViewModel() { /** * 重量数据缓存。 * key:deviceNo(货架编号),value:重量(克)。 - * 替代原 SparseIntArray,使用更符合 Kotlin 习惯的 MutableMap。 */ private val weightMap: MutableMap = mutableMapOf() @@ -41,6 +70,28 @@ class HomeViewModel : ViewModel() { */ var humidity: String = "0" + /** + * 设备名称,由网络接口响应写入,供顶部状态栏显示。 + * 原散落于 HomeActivity 局部变量,现迁入 ViewModel 以跨配置变更保持。 + */ + var deviceName: String = "" + + // ---------------------------------------------------------------- + // 对外暴露的 Flow + // ---------------------------------------------------------------- + + /** + * 过期状态变化通知。 + * 发射需要局部刷新的 adapter position 列表,Activity 收到后调用 notifyItemChanged。 + * 仅在状态实际发生变化时发射,避免无效刷新。 + */ + private val _overdueUpdateFlow = MutableSharedFlow>(extraBufferCapacity = 4) + val overdueUpdateFlow: SharedFlow> = _overdueUpdateFlow + + // ---------------------------------------------------------------- + // 重量操作 + // ---------------------------------------------------------------- + /** * 更新指定货架编号的重量。 * @param deviceNo 货架编号 @@ -56,4 +107,108 @@ class HomeViewModel : ViewModel() { * @return 重量(克),若无记录返回 0 */ fun getWeight(deviceNo: Int): Int = weightMap[deviceNo] ?: 0 + + // ---------------------------------------------------------------- + // 定时任务 + // ---------------------------------------------------------------- + + /** + * 启动所有定时任务。 + * 应在 Activity.onCreate 中调用一次(ViewModel 跨配置变更存活,不会重复启动)。 + * 任务在 viewModelScope 中运行,ViewModel 销毁时自动取消,无需手动管理 Job。 + */ + fun startPeriodicTasks() { + startSaveTask() + startOverdueTask() + } + + /** + * 启动货架数据定时保存任务(延迟1分钟后,每5分钟执行一次)。 + */ + private fun startSaveTask() { + viewModelScope.launch { + delay(SAVE_INITIAL_DELAY) + while (true) { + periodicSave() + delay(SAVE_INTERVAL) + } + } + } + + /** + * 启动过期状态定时检查任务(延迟5秒后,每5分钟执行一次)。 + */ + private fun startOverdueTask() { + viewModelScope.launch { + delay(OVERDUE_INITIAL_DELAY) + while (true) { + periodicUpdateOverdue() + delay(OVERDUE_INTERVAL) + } + } + } + + // ---------------------------------------------------------------- + // 定时任务内部业务逻辑 + // ---------------------------------------------------------------- + + /** + * 将当前货架数据同步到服务端。 + * 若货架无商品(goodsId 为空),则上报备份重量(weightBak)。 + */ + private suspend fun periodicSave() { + Log.d(TAG, "periodicSave: list=${shelfItems.toJsonString()}") + val submitList = shelfItems.sortedBy { it.deviceNo } + submitList.forEach { + if (it.goodsId.isNullOrBlank()) { + it.weight = it.weightBak + } + } + val body = ShelfBody().also { + it.deviceId = App.deviceId + it.canteenId = App.canteenId + it.temperature = temperatureC + it.humidity = humidity + it.goodsList = submitList + } + Log.d(TAG, "periodicSave: body=${body.toJsonString()}") + runCatching { + val resp = apiService.saveShelfGoodsList(body = body) + Log.d(TAG, "periodicSave: resp=${resp.toJsonString()}") + }.onFailure { + it.printStackTrace() + Log.d(TAG, "periodicSave 异常:${it.message}") + } + } + + /** + * 从服务端拉取货架列表,对比本地数据,将变化的过期状态通过 [overdueUpdateFlow] 发射。 + * 按 deviceNo 精确匹配,避免列表顺序不一致导致的状态串位。 + */ + private suspend fun periodicUpdateOverdue() { + runCatching { + val response = apiService.getShelfList(deviceId = App.deviceId) + if (!response.isSuccess()) return + val serverItems = response.data?.containerGoodsList ?: return + if (serverItems.isEmpty()) return + + // 找出过期状态发生变化的 position 列表 + val changedPositions = mutableListOf() + shelfItems.forEachIndexed { index, model -> + val serverModel = serverItems.find { it.deviceNo == model.deviceNo } + ?: return@forEachIndexed + if (serverModel.overdue != model.overdue) { + model.overdue = serverModel.overdue + changedPositions.add(index) + Log.d(TAG, "过期状态变更:deviceNo=${model.deviceNo}, 商品=${model.goodsName}, overdue=${model.overdue}") + } + } + + if (changedPositions.isNotEmpty()) { + _overdueUpdateFlow.emit(changedPositions) + } + }.onFailure { + it.printStackTrace() + } + } } diff --git a/app/src/main/java/com/shuwei/intelligent/shelves/net/SerialViewModel.kt b/app/src/main/java/com/shuwei/intelligent/shelves/net/SerialViewModel.kt new file mode 100644 index 0000000..d271384 --- /dev/null +++ b/app/src/main/java/com/shuwei/intelligent/shelves/net/SerialViewModel.kt @@ -0,0 +1,157 @@ +package com.shuwei.intelligent.shelves.net + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.shuwei.intelligent.shelves.serial.SerialEventListener +import com.shuwei.intelligent.shelves.serial.SerialPortManager +import com.shuwei.intelligent.shelves.serial.SerialProtocolParser +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch + +/** + * 串口通信 ViewModel。 + * 职责: + * 1. 管理串口的生命周期(open / close); + * 2. 持有 [SerialProtocolParser] 实例,实现 [SerialEventListener] 接收解析结果; + * 3. 通过 [weightFlow]、[tempCtrlEventFlow]、[deviceStatusRefreshFlow] 将事件暴露给 UI 层。 + * + * 不持有任何 View / Context 引用,可安全跨配置变更存活。 + */ +class SerialViewModel : ViewModel() { + + companion object { + private const val TAG = "SerialViewModel" + } + + // ---------------------------------------------------------------- + // 对外暴露的 Flow(Activity 通过 repeatOnLifecycle 收集) + // ---------------------------------------------------------------- + + /** + * 货架重量更新事件。 + * Pair.first = 货架编号(deviceNo),Pair.second = 重量(克)。 + */ + private val _weightFlow = MutableSharedFlow>(extraBufferCapacity = 64) + val weightFlow: SharedFlow> = _weightFlow + + /** + * 温控信息上报事件,携带完整日志字符串,Activity 收到后弹窗展示。 + */ + private val _tempCtrlEventFlow = MutableSharedFlow(extraBufferCapacity = 8) + val tempCtrlEventFlow: SharedFlow = _tempCtrlEventFlow + + /** + * 设备状态刷新信号。 + * 串口收到需要刷新顶部状态栏的命令时触发,Activity 收到后用 deviceName 重绘。 + */ + private val _deviceStatusRefreshFlow = MutableSharedFlow(extraBufferCapacity = 4) + val deviceStatusRefreshFlow: SharedFlow = _deviceStatusRefreshFlow + + // ---------------------------------------------------------------- + // 内部:串口数据通道 + 协议解析器 + // ---------------------------------------------------------------- + + /** + * 串口数据接收通道,无界缓冲。 + * IO 线程(串口回调)通过 trySend 写入,单一消费协程串行读取, + * 确保 parser 内部 serialReader 始终在同一协程中访问,规避并发竞争。 + */ + private val serialDataChannel = Channel(Channel.UNLIMITED) + + /** + * 协议解析器实例。 + * 持有 terminalId / terminalVersion / serialReader 等协议状态。 + */ + private val parser = SerialProtocolParser(object : SerialEventListener { + + /** 解析器需要发送命令时,通过 viewModelScope 异步发送 */ + override fun onSendCommand(cmd: String) { + viewModelScope.launch { SerialPortManager.send(cmd) } + } + + /** 终端信息就绪(激活或设备状态上报时),仅打印日志,信息已在 parser 内持有 */ + override fun onTerminalInfoReady(terminalId: String, terminalVersion: String) { + Log.d(TAG, "终端信息就绪: id=$terminalId, version=$terminalVersion") + } + + /** 货架重量更新,发射到 weightFlow */ + override fun onWeightUpdate(deviceNo: Int, weight: Int) { + _weightFlow.tryEmit(deviceNo to weight) + } + + /** 设备状态需要刷新,发射刷新信号 */ + override fun onDeviceStatusUpdate(text: String) { + _deviceStatusRefreshFlow.tryEmit(Unit) + } + + /** 温控信息上报,发射日志到 tempCtrlEventFlow */ + override fun onTempCtrlReport(logMsg: String) { + _tempCtrlEventFlow.tryEmit(logMsg) + } + }) + + // ---------------------------------------------------------------- + // 对外操作 + // ---------------------------------------------------------------- + + /** + * 打开串口并启动数据接收。 + * 应在 Activity.onCreate 中调用一次。 + * 串口数据通过 Channel 无锁传递给消费协程,由消费协程串行调用 parser。 + */ + fun openSerial() { + viewModelScope.launch { + val opened = SerialPortManager.open() + Log.d(TAG, "openSerial: opened=$opened") + if (!opened) return@launch + + // 串口回调在 IO 线程,通过 Channel 传递数据,不嵌套 launch + SerialPortManager.startReceive { data -> + serialDataChannel.trySend(data) + } + + // 单一消费协程,串行处理所有串口数据 + launch { + for (data in serialDataChannel) { + try { + parser.parseIncoming(data) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + } + + /** + * 向串口发送命令。 + * @param cmd 十六进制命令字符串 + */ + fun sendCmd(cmd: String) { + viewModelScope.launch { + SerialPortManager.send(cmd) + } + } + + // ---------------------------------------------------------------- + // 生命周期清理 + // ---------------------------------------------------------------- + + /** + * ViewModel 销毁时关闭 Channel 和串口。 + * Channel 关闭后,消费协程的 for 循环自然结束。 + * 串口关闭在独立 IO 协程中执行,避免阻塞主线程。 + */ + override fun onCleared() { + serialDataChannel.close() + CoroutineScope(Dispatchers.IO).launch { + SerialPortManager.close() + } + super.onCleared() + } +} diff --git a/app/src/main/java/com/shuwei/intelligent/shelves/serial/SerialEventListener.kt b/app/src/main/java/com/shuwei/intelligent/shelves/serial/SerialEventListener.kt new file mode 100644 index 0000000..7733716 --- /dev/null +++ b/app/src/main/java/com/shuwei/intelligent/shelves/serial/SerialEventListener.kt @@ -0,0 +1,40 @@ +package com.shuwei.intelligent.shelves.serial + +/** + * 串口协议解析事件监听器。 + * [SerialProtocolParser] 解析完成后通过此接口将结果回调给上层(ViewModel 或 Activity)。 + */ +interface SerialEventListener { + + /** + * 需要向串口发送命令。 + * @param cmd 十六进制命令字符串 + */ + fun onSendCommand(cmd: String) + + /** + * 获取到终端信息(激活指令或设备状态上报时触发)。 + * @param terminalId 终端 ID(15字节) + * @param terminalVersion 固件版本(5字节) + */ + fun onTerminalInfoReady(terminalId: String, terminalVersion: String) + + /** + * 重量数据更新。 + * @param deviceNo 货架编号(从 1 开始) + * @param weight 重量(克) + */ + fun onWeightUpdate(deviceNo: Int, weight: Int) + + /** + * 设备状态文本更新(用于顶部状态栏左侧显示)。 + * @param text 显示内容 + */ + fun onDeviceStatusUpdate(text: String) + + /** + * 温控信息上报(需要弹窗提示用户)。 + * @param logMsg 温控日志信息 + */ + fun onTempCtrlReport(logMsg: String) +} diff --git a/app/src/main/java/com/shuwei/intelligent/shelves/serial/SerialProtocolParser.kt b/app/src/main/java/com/shuwei/intelligent/shelves/serial/SerialProtocolParser.kt new file mode 100644 index 0000000..5f33668 --- /dev/null +++ b/app/src/main/java/com/shuwei/intelligent/shelves/serial/SerialProtocolParser.kt @@ -0,0 +1,326 @@ +package com.shuwei.intelligent.shelves.serial + +import android.util.Log +import com.shuwei.intelligent.shelves.activity.HomeActivity.Companion.ACTIVE_CMD +import com.shuwei.intelligent.shelves.activity.HomeActivity.Companion.FOOTER +import com.shuwei.intelligent.shelves.activity.HomeActivity.Companion.HEADER +import com.shuwei.intelligent.shelves.utils.binaryToHex +import com.shuwei.intelligent.shelves.utils.hexToBinary + +/** + * 串口协议解析器。 + * 职责:将原始串口字节流解析为结构化协议事件,通过 [SerialEventListener] 回调通知上层。 + * 无 Android UI 依赖,可进行单元测试。 + * + * @param listener 协议事件监听器,由 SerialViewModel 实现并传入 + */ +class SerialProtocolParser(private val listener: SerialEventListener) { + + companion object { + private const val TAG = "SerialProtocolParser" + private const val LOG_PREFIX = "receiveSerialPortData:收到" + } + + /** + * 终端 ID(15字节),由激活指令或设备状态上报时获取。 + * 对外只读,内部可写。 + */ + var terminalId: String = "" + private set + + /** + * 固件版本(5字节),由激活指令或设备状态上报时获取。 + * 对外只读,内部可写。 + */ + var terminalVersion: String = "" + private set + + /** + * 串口拼包缓冲器。 + * 仅在单一消费协程中调用 [parseIncoming],线程安全。 + */ + private val serialReader = StringBuilder() + + // ---------------------------------------------------------------- + // 对外入口:拼包 + // ---------------------------------------------------------------- + + /** + * 处理一条原始串口数据,执行拼包逻辑后交由 [handleReceivedData] 分发命令。 + * @param srcData 原始十六进制字符串(大写) + */ + fun parseIncoming(srcData: String) { + log("parseIncoming: $srcData") + var data = "" + + if (srcData.contains(HEADER) && srcData.contains(FOOTER)) { + serialReader.clear() + val startIndex = srcData.lastIndexOf(HEADER) + var endIndex = srcData.indexOf(FOOTER) + FOOTER.length + if (endIndex < startIndex) { + // 类似这种:31314FFFFFD6DADEDB01010C,FOOTER 在 HEADER 前 + data = srcData.substring(startIndex) + if (data.contains(FOOTER)) { + endIndex = data.indexOf(FOOTER) + FOOTER.length + data = data.substring(0, endIndex) + handleReceivedData(data) + serialReader.clear() + return + } + serialReader.clear() + serialReader.append(data).append(",") + return + } + serialReader.append(srcData.substring(startIndex, endIndex)) + data = serialReader.toString() + handleReceivedData(data) + serialReader.clear() + return + } + + if (srcData.contains(HEADER) && !srcData.contains(FOOTER)) { + // 只有头,没有尾,等待后续分片 + serialReader.clear() + val startIndex = srcData.lastIndexOf(HEADER) + serialReader.append(srcData.substring(startIndex)).append(",") + return + } + + if (!srcData.contains(HEADER) && !srcData.contains(FOOTER)) { + // 中间分片 + serialReader.append(srcData).append(",") + return + } + + // 只有尾,没有头:拼接并处理 + val endIndex = srcData.indexOf(FOOTER) + FOOTER.length + serialReader.append(srcData.substring(0, endIndex)) + log("parseIncoming:serialReader: $serialReader") + data = serialReader.toString().replace(",", "") + handleReceivedData(data) + } + + // ---------------------------------------------------------------- + // 命令分发 + // ---------------------------------------------------------------- + + /** + * 根据命令码分发处理各类串口协议。 + */ + private fun handleReceivedData(data: String) { + if (data.isEmpty()) return + val cmd = data.substring(HEADER.length, HEADER.length + 4) + val cmdFlag = cmd.substring(0, 2) + when (cmd) { + "0001" -> handleActivation(data, cmd, cmdFlag) + "0101", "8101" -> handleHeartBeat(data, cmd, cmdFlag) + "0202" -> handleLockFeedback(data, cmd, cmdFlag) + "0302" -> handleDeviceStatus(data, cmd, cmdFlag) + "0501" -> handleCardUpload(data, cmd, cmdFlag) + "0B01" -> handleOperationRecord(data, cmd, cmdFlag) + "0C01" -> handleTimeSync(data, cmd, cmdFlag) + "0E01" -> handlePositionReport(data, cmd, cmdFlag) + "1001" -> handleTempCtrlReport(data, cmd, cmdFlag) + "1002" -> log("${LOG_PREFIX}[温控信息应答]1002数据:$data") + "0F02" -> log("${LOG_PREFIX}[温控信息应答]0F02数据:$data") + else -> {} + } + } + + // ---------------------------------------------------------------- + // 各命令处理 + // ---------------------------------------------------------------- + + /** + * 处理终端发起的激活指令(0001)。 + * 解析终端 ID 和固件版本,并发送激活命令。 + */ + private fun handleActivation(data: String, cmd: String, cmdFlag: String) { + val reqBody = data.substring(HEADER.length + cmd.length, data.length - FOOTER.length) + if (reqBody.length == 44) { + terminalId = reqBody.substring(2, 32) + terminalVersion = reqBody.substring(32, 42) + log("${LOG_PREFIX}[激活]${cmdFlag}指令:$data,终端id:$terminalId,固件版本:$terminalVersion,发送激活命令:$ACTIVE_CMD") + listener.onSendCommand(ACTIVE_CMD) + listener.onTerminalInfoReady(terminalId, terminalVersion) + } + } + + /** + * 处理心跳指令(0101 / 8101)。 + * 发送心跳应答,并解析重量数据。 + */ + private fun handleHeartBeat(data: String, cmd: String, cmdFlag: String) { + val respCmd = "${HEADER}01020506${terminalVersion}$FOOTER" + log("${LOG_PREFIX}[心跳]${cmdFlag}指令:$data,发送应答指令:$respCmd") + listener.onSendCommand(respCmd) + parseHeartBeat(data) + } + + /** + * 处理开关锁反馈(0202)。 + * 发送开锁应答。 + */ + private fun handleLockFeedback(data: String, cmd: String, cmdFlag: String) { + val respCmd = "${HEADER}020200${terminalId}${terminalVersion}$FOOTER" + log("${LOG_PREFIX}[开锁]${cmdFlag}指令:$data,发送应答指令:$respCmd") + listener.onSendCommand(respCmd) + } + + /** + * 处理获取设备状态(0302)。 + * 解析设备重量和终端信息。 + */ + private fun handleDeviceStatus(data: String, cmd: String, cmdFlag: String) { + log("${LOG_PREFIX}[获取设备状态]${cmdFlag}指令:$data") + parseDeviceInfo(data) + } + + /** + * 处理卡号上发(0501)。 + */ + private fun handleCardUpload(data: String, cmd: String, cmdFlag: String) { + val respCmd = "${HEADER}050203$FOOTER" + log("${LOG_PREFIX}[卡号上发]${cmdFlag}指令:$data,发送应答指令:$respCmd") + listener.onSendCommand(respCmd) + } + + /** + * 处理操作记录上传(0B01)。 + */ + private fun handleOperationRecord(data: String, cmd: String, cmdFlag: String) { + val respCmd = "${HEADER}0B0203$FOOTER" + log("${LOG_PREFIX}[操作记录上传]${cmdFlag}指令:$data,发送应答指令:$respCmd") + listener.onSendCommand(respCmd) + } + + /** + * 处理对时指令(0C01)。 + * 将当前系统时间转换为小端序十六进制并应答。 + */ + private fun handleTimeSync(data: String, cmd: String, cmdFlag: String) { + val timeLong = System.currentTimeMillis() / 1000 + (8 * 3600) + val timeStr = "0000000" + String.format("%X", timeLong) + val time = timeStr.substring(timeStr.length - 8).chunked(2).reversed().joinToString("") + val respCmd = "${HEADER}0B02${time}$FOOTER" + log("${LOG_PREFIX}[对时]${cmdFlag}指令:$data,发送应答指令:$respCmd") + listener.onSendCommand(respCmd) + } + + /** + * 处理终端位置上报(0E01)。 + */ + private fun handlePositionReport(data: String, cmd: String, cmdFlag: String) { + val respCmd = "${HEADER}0E0200$FOOTER" + log("${LOG_PREFIX}[终端位置上报]${cmdFlag}指令:$data,发送应答指令:$respCmd") + listener.onSendCommand(respCmd) + } + + /** + * 处理温控信息上报(1001)。 + * 发送应答,并通过回调通知上层弹窗展示日志。 + */ + private fun handleTempCtrlReport(data: String, cmd: String, cmdFlag: String) { + val respCmd = "${HEADER}100200$FOOTER" + val logMsg = "${LOG_PREFIX}[温控信息上报]${cmdFlag}指令:$data,发送应答指令:$respCmd" + log(logMsg) + listener.onSendCommand(respCmd) + listener.onDeviceStatusUpdate("") + listener.onTempCtrlReport(logMsg) + } + + // ---------------------------------------------------------------- + // 数据解析辅助 + // ---------------------------------------------------------------- + + /** + * 解析心跳报文中的重量和固定数据体。 + */ + private fun parseHeartBeat(data: String) { + runCatching { + if (data.length < 200) return + val weightStartIndex = 14 + val weightEndIndex = weightStartIndex + 12 * 8 + parseWeightInfo(start = weightStartIndex, end = weightEndIndex, data = data) + val temperatureEndIndex = 158 + 2 + parseFixDataBody(data, temperatureEndIndex) + }.onFailure { it.printStackTrace() } + } + + /** + * 解析设备状态报文(0302)中的重量和固定数据体。 + */ + private fun parseDeviceInfo(data: String) { + val weightStartIndex = HEADER.length + 14 + val temperatureStartIndex = weightStartIndex + 12 * 8 + val temperatureEndIndex = temperatureStartIndex + 2 + try { + data.substring(temperatureStartIndex, temperatureEndIndex) + } catch (e: Exception) { + e.printStackTrace() + return + } + listener.onDeviceStatusUpdate("") + parseWeightInfo(start = weightStartIndex, end = temperatureStartIndex, data = data) + parseFixDataBody(data, temperatureEndIndex) + } + + /** + * 解析重量数据区,逐格读取并通过 [listener] 回调。 + * @param start 重量数据起始索引 + * @param end 重量数据结束索引 + * @param data 完整协议报文(十六进制字符串) + */ + fun parseWeightInfo(start: Int, end: Int, data: String) { + var deviceNo = 1 + var count = start + while (count < end) { + val weightInfo = data.substring(count, count + 8) + // 将小端序 4 字节转换为大端序 + var weightHex = weightInfo.run { + substring(6, 8) + substring(4, 6) + substring(2, 4) + substring(0, 2) + } + var realWeight: Int + var firstByteBinary = hexToBinary(weightHex.substring(0, 2)) + if (firstByteBinary.startsWith("0")) { + // 正数:按十六进制解析,失败时兜底为 0 + realWeight = weightHex.toIntOrNull(16) ?: 0 + } else { + // 负数:清除符号位后取绝对值,再取反 + firstByteBinary = "0${firstByteBinary.substring(1)}" + val firstByteHex = binaryToHex(firstByteBinary) + weightHex = firstByteHex + weightHex.substring(2) + realWeight = -1 * (weightHex.toIntOrNull(16) ?: 0) + } + listener.onWeightUpdate(deviceNo, realWeight) + count += 8 + deviceNo++ + } + } + + /** + * 从数据体尾部提取 terminalId / terminalVersion(首次获取时有效)。 + * @param data 完整协议报文 + * @param temperatureEndIndex 温度字段结束索引,固定数据体从此之后开始 + */ + private fun parseFixDataBody(data: String, temperatureEndIndex: Int) { + try { + // 已有终端信息则无需重复解析 + if (terminalId.isNotBlank() && terminalVersion.isNotBlank()) return + val fixDataBodyStartIndex = temperatureEndIndex + 1 + val fixDataBody = data.substring(fixDataBodyStartIndex, data.length - FOOTER.length) + if (fixDataBody.length == 42) { + terminalId = fixDataBody.substring(0, 30) + terminalVersion = fixDataBody.substring(30, 40) + log("从设备状态获取数据,终端id:$terminalId,固件版本:$terminalVersion") + listener.onTerminalInfoReady(terminalId, terminalVersion) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + private fun log(msg: String) { + Log.d(TAG, msg) + } +} diff --git a/app/src/main/res/layout/dialog_layout.xml b/app/src/main/res/layout/dialog_layout.xml new file mode 100644 index 0000000..1d184ee --- /dev/null +++ b/app/src/main/res/layout/dialog_layout.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + \ No newline at end of file