Author SHA1 Message Date
lvmengandClaude Sonnet 4.6 e6cfc4dff2 重构:拆分 HomeActivity 职责,分层解耦串口与业务逻辑
- 新建 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 <noreply@anthropic.com>
2026-03-11 13:41:01 +08:00
lvmengandClaude Sonnet 4.6 85903eb646 重构 HomeActivity:修复多项稳定性与架构问题
架构优化:
- 新增 HomeViewModel,将 companion object 中的共享状态(shelfItems、
  weightMap、selectedShelfIndex、temperatureC、humidity)迁移至 ViewModel,
  消除 Activity 重建时的状态残留与全局状态污染问题

协程修复:
- repeatOnLifecycle(CREATED) 改为 STARTED,确保 Flow 收集与 UI 可见性
  生命周期对齐,进入后台时自动停止订阅
- 串口数据接收改用 Channel<String> + 单一消费协程,替代多 launch 并发模式,
  彻底消除 StringBuilder(serialReader)的并发竞争问题

稳定性修复:
- 修复 tipDialog 从未赋值导致 Dialog 功能失效及 onDestroy 窗口泄漏问题
- 修复 weightHex.toInt() 十进制解析错误,改为 toIntOrNull(16) 并添加兜底
- 修复 updateOverdueState 按索引访问接口数据导致的越界风险,
  改为按 deviceNo 精确匹配,同时修正交叉排列导致的状态串位问题
- 废弃 API getSerializableExtra 替换为版本分支兼容写法(minSdk 25+)

依赖升级:
- androidx.core-ktx 1.10.1 → 1.12.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:45:44 +08:00
7 changed files with 916 additions and 542 deletions
@@ -3,11 +3,11 @@ package com.shuwei.intelligent.shelves.activity
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.util.SparseIntArray
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -26,22 +26,17 @@ import com.shuwei.intelligent.shelves.model.ShelfBody
import com.shuwei.intelligent.shelves.model.ShelfModel import com.shuwei.intelligent.shelves.model.ShelfModel
import com.shuwei.intelligent.shelves.model.ShelfResult import com.shuwei.intelligent.shelves.model.ShelfResult
import com.shuwei.intelligent.shelves.model.WeightEvent import com.shuwei.intelligent.shelves.model.WeightEvent
import com.shuwei.intelligent.shelves.net.HomeViewModel
import com.shuwei.intelligent.shelves.net.Loading import com.shuwei.intelligent.shelves.net.Loading
import com.shuwei.intelligent.shelves.net.NetViewModel import com.shuwei.intelligent.shelves.net.NetViewModel
import com.shuwei.intelligent.shelves.net.RespData 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.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.copyTextToClipboard
import com.shuwei.intelligent.shelves.utils.ext.gone import com.shuwei.intelligent.shelves.utils.ext.gone
import com.shuwei.intelligent.shelves.utils.ext.toJsonString import com.shuwei.intelligent.shelves.utils.ext.toJsonString
import com.shuwei.intelligent.shelves.utils.ext.toast import com.shuwei.intelligent.shelves.utils.ext.toast
import com.shuwei.intelligent.shelves.utils.ext.visible import com.shuwei.intelligent.shelves.utils.ext.visible
import com.shuwei.intelligent.shelves.utils.hexToBinary
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.Subscribe
@@ -54,67 +49,47 @@ class HomeActivity : BaseActivity() {
companion object { companion object {
private const val TAG = "HomeActivity" 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" private const val MM_DD_EEEE__HH_MM_SS = "MM月dd日 EEEE HH:mm:ss"
//协议头 // 协议头
const val HEADER = "D6DADEDB" const val HEADER = "D6DADEDB"
//协议尾 // 协议尾
const val FOOTER = "FFFF" const val FOOTER = "FFFF"
// const val ACTIVE_CMD =
// "${HEADER}000281C5D70A7D428A4FA67B50F7DACA241938938B4B9CBD73673E9C8E7F2E15062D${FOOTER}"
const val LEFT_SHELF_OPEN_CMD = const val LEFT_SHELF_OPEN_CMD =
"${HEADER}020101000000000000000000000000000000000000${FOOTER}" "${HEADER}020101000000000000000000000000000000000000${FOOTER}"
const val RIGHT_SHELF_OPEN_CMD = const val RIGHT_SHELF_OPEN_CMD =
"${HEADER}020100010000000000000000000000000000000000${FOOTER}" "${HEADER}020100010000000000000000000000000000000000${FOOTER}"
//const val TEMPERATURE_CMD = "${HEADER}1001${FOOTER}"
const val DEVICE_INFO_CMD = "${HEADER}0301000000000000000000000000${FOOTER}" const val DEVICE_INFO_CMD = "${HEADER}0301000000000000000000000000${FOOTER}"
const val START_TEMP_CTRL_CMD = "${HEADER}0F01360000${FOOTER}" const val START_TEMP_CTRL_CMD = "${HEADER}0F01360000${FOOTER}"
//const val START_CMD= "${HEADER}0F01050020${FOOTER}" // 1楼餐厅激活码
// const val ACTIVE_CMD = "${HEADER}000281C5D70A7D428948AD7254FADBB1516738938B4B9CBD73673E9D8E7F2E15062D$FOOTER"
// 410激活码
// const val ACTIVE_CMD = "${HEADER}000281C5D70A7D42894EA17650F5D0BB516838E7863C9ABD766C38958AFF0F3C062D${FOOTER}"
//1楼餐厅激活码
const val ACTIVE_CMD = const val ACTIVE_CMD =
"${HEADER}000281C5D70A7D428B44A57454F5DACA241938938B4B9CBD73673E9C8E7FC0C4992D${FOOTER}" "${HEADER}000281C5D70A7D428B44A57454F5DACA241938938B4B9CBD73673E9C8E7FC0C4992D${FOOTER}"
//华氏温度单位 // 华氏温度单位
const val F_TEMP_CMD = "${HEADER}0F01390000${FOOTER}" const val F_TEMP_CMD = "${HEADER}0F01390000${FOOTER}"
//摄氏温度单位 // 摄氏温度单位
const val C_TEMP_CMD = "${HEADER}0F01390001${FOOTER}" const val C_TEMP_CMD = "${HEADER}0F01390001${FOOTER}"
//开灯(开 继电器1 // 开灯(开继电器1
const val LIGHT_CLOSE = "${HEADER}020102020102020000000000000000000000000000${FOOTER}" const val LIGHT_CLOSE = "${HEADER}020102020102020000000000000000000000000000${FOOTER}"
const val LIGHT_OPEN = "${HEADER}020102020002020000000000000000000000000000${FOOTER}" const val LIGHT_OPEN = "${HEADER}020102020002020000000000000000000000000000${FOOTER}"
const val TEMPERATURE_CTRL = "${HEADER}0F01040000${FOOTER}" const val TEMPERATURE_CTRL = "${HEADER}0F01040000${FOOTER}"
val list: MutableList<ShelfModel> = mutableListOf()
val weightArray = SparseIntArray()
var shelfIndex = 0
// public fun getRealWeight():Double = weightArray[shelfIndex]?:0.0
// 温度
var showTemperatureC = "0"
// 湿度
var showHumidity = "0"
} }
//401-false1楼餐厅-true // 401-false1楼餐厅-true
private var isNeedOpenLock = true private var isNeedOpenLock = true
private lateinit var binding: ActivityHomeBinding private lateinit var binding: ActivityHomeBinding
private val viewModel: NetViewModel by viewModels() private val viewModel: NetViewModel by viewModels()
private val homeViewModel: HomeViewModel by viewModels()
private val serialViewModel: SerialViewModel by viewModels()
@SuppressLint("NotifyDataSetChanged", "HardwareIds") @SuppressLint("NotifyDataSetChanged", "HardwareIds")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -124,27 +99,37 @@ class HomeActivity : BaseActivity() {
setBackground() setBackground()
setContentView(binding.root) setContentView(binding.root)
// updateLeftStatus("-°C / -%")
updateLeftStatus("") updateLeftStatus("")
initRecyclerView() initRecyclerView()
// 打开串口,串口生命周期由 SerialViewModel 管理
serialViewModel.openSerial()
// 统一注册所有 ViewModel Flow 观察
observeViewModels()
// 延迟发送温控初始化命令
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 { lifecycleScope.launch {
val openState = SerialPortManager.open() repeatOnLifecycle(Lifecycle.State.STARTED) {
log("onCreate: openState=$openState")
if (openState) { // 获取 Token 结果
SerialPortManager.startReceive { data ->
// 更新UI显示接收数据
lifecycleScope.launch {
try {
receiveSerialPortData(data)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
//SerialPortManager.send(DEVICE_INFO_CMD)
}
repeatOnLifecycle(Lifecycle.State.CREATED) {
launch { launch {
viewModel.getAccessTokenUiState.collect { state -> viewModel.getAccessTokenUiState.collect { state ->
when (state) { when (state) {
@@ -155,6 +140,8 @@ class HomeActivity : BaseActivity() {
} }
} }
} }
// 货架列表加载结果
launch { launch {
viewModel.getShelfListUiState.collect { state -> viewModel.getShelfListUiState.collect { state ->
when (state) { when (state) {
@@ -165,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() {
list.add(ShelfModel(deviceNo = 1, goodsName = "鸡蛋", deviceId = App.deviceId))
list.add(ShelfModel(deviceNo = 6, goodsName = "", deviceId = App.deviceId))
list.add(ShelfModel(deviceNo = 2, goodsName = "", deviceId = App.deviceId))
list.add(ShelfModel(deviceNo = 7, goodsName = "生姜", deviceId = App.deviceId))
list.add(ShelfModel(deviceNo = 3, goodsName = "黄瓜", deviceId = App.deviceId))
list.add(ShelfModel(deviceNo = 8, goodsName = "", deviceId = App.deviceId))
list.add(ShelfModel(deviceNo = 4, goodsName = "", deviceId = App.deviceId))
list.add(ShelfModel(deviceNo = 9, goodsName = "", deviceId = App.deviceId))
list.add(ShelfModel(deviceNo = 5, goodsName = "", deviceId = App.deviceId))
list.add(ShelfModel(deviceNo = 10, goodsName = "", deviceId = App.deviceId))
shelfAdapter.notifyDataSetChanged()
} }
private fun getTokenSuccess(data: RespData<*>) { private fun getTokenSuccess(data: RespData<*>) {
log("getTokenSuccess: $data") log("getTokenSuccess: $data")
data.data?.let { data.data?.let {
App.accessToken = it.toString() App.accessToken = it.toString()
//UIUtils.toast(it.toString())
viewModel.getShelfList(deviceId = App.deviceId) viewModel.getShelfList(deviceId = App.deviceId)
} }
} }
@@ -236,10 +214,9 @@ class HomeActivity : BaseActivity() {
loadEmptyView() loadEmptyView()
return return
} }
deviceName = data.data.deviceName // 将 deviceName 写入 HomeViewModel,跨配置变更保持
homeViewModel.deviceName = data.data.deviceName
// updateLeftStatus("$deviceName -°C / -%") updateLeftStatus(homeViewModel.deviceName)
updateLeftStatus("$deviceName")
App.canteenId = data.data.placeId App.canteenId = data.data.placeId
val tempList = data.data.containerGoodsList val tempList = data.data.containerGoodsList
@@ -247,16 +224,12 @@ class HomeActivity : BaseActivity() {
loadEmptyView() loadEmptyView()
return return
} }
// if (tempList.size < 10) { homeViewModel.shelfItems.clear()
// toast("数据错误")
// return
// }
list.clear()
val subList01 = tempList.subList(0, tempList.size / 2) val subList01 = tempList.subList(0, tempList.size / 2)
val subList02 = tempList.subList(tempList.size / 2, tempList.size) val subList02 = tempList.subList(tempList.size / 2, tempList.size)
repeat(tempList.size / 2) { index -> repeat(tempList.size / 2) { index ->
list.add(subList01[index].also { it.deviceId = App.deviceId }) homeViewModel.shelfItems.add(subList01[index].also { it.deviceId = App.deviceId })
list.add(subList02[index].also { it.deviceId = App.deviceId }) homeViewModel.shelfItems.add(subList02[index].also { it.deviceId = App.deviceId })
} }
shelfAdapter.notifyDataSetChanged() shelfAdapter.notifyDataSetChanged()
} }
@@ -268,375 +241,60 @@ class HomeActivity : BaseActivity() {
} }
/** /**
* 终端id * 显示温控信息弹窗。
* 若弹窗已在展示中则不重复打开,仅更新内容。
*/ */
private var terminalId: String = "" private fun showTipDialog(logMsg: String) {
if (tipDialog == null) {
/** tipDialog = CommonDialog(this).apply {
* 固件版本 dialogTitle = "温馨提示"
*/ confirmBlock = { logMsg.copyTextToClipboard(this@HomeActivity) }
private var terminalVersion: String = ""
private val serialReader by lazy { 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) {
//类似这种:31314FFFFFD6DADEDB01010CFOOTER在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
} }
serialReader.append(srcData.substring(startIndex, endIndex))
data = serialReader.toString()
handleReceivedData(data)
serialReader.clear()
return
} }
if (srcData.contains(HEADER) && srcData.contains(FOOTER).not()) { if (tipDialog?.isShowing == false) {
serialReader.clear() tipDialog?.dialogContent = logMsg
val startIndex = srcData.lastIndexOf(HEADER) tipDialog?.show()
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) {
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 -> {}
} }
} }
private var tipDialog: CommonDialog? = null 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")) {
//正数
realWeight = weightHex.toInt()
} else {
//负数
firstByteBinary = "0${firstByteBinary.substring(1)}"
val firstByteHex = binaryToHex(firstByteBinary)
weightHex = firstByteHex + weightHex.substring(2)
realWeight = -1 * weightHex.toInt()
}
weightArray.put(index, realWeight)
if (index in 1..10 && list.size == 10) {
list.firstOrNull { it.deviceNo == index }?.let {
it.weight = realWeight.toDouble()
val pos = list.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() { override fun onDestroy() {
lifecycleScope.launch { // 弹窗需在 Activity 销毁前手动关闭,防止窗口泄漏
SerialPortManager.close() tipDialog?.dismiss()
} tipDialog = null
// TaskManager.cancelTask()
saveTaskJob?.cancel()
overdueTaskJob?.cancel()
EventBus.getDefault().unregister(this) EventBus.getDefault().unregister(this)
// 串口关闭、Channel 关闭、定时任务取消均由各 ViewModel.onCleared() 自动管理
super.onDestroy() super.onDestroy()
} }
@Suppress("DEPRECATION")
private val shelfAdapter by lazy { private val shelfAdapter by lazy {
ShelfAdapter(list).apply { ShelfAdapter(homeViewModel.shelfItems).apply {
setOnItemClickListener { _, _, position -> setOnItemClickListener { _, _, position ->
shelfIndex = position homeViewModel.selectedShelfIndex = position
lifecycleScope.launch { lifecycleScope.launch {
// 根据货架编号决定开启左侧或右侧柜锁
if (isNeedOpenLock) { if (isNeedOpenLock) {
val openCmd = if (list[position].deviceNo in 1..5) val openCmd = if (homeViewModel.shelfItems[position].deviceNo in 1..5)
LEFT_SHELF_OPEN_CMD LEFT_SHELF_OPEN_CMD
else else
RIGHT_SHELF_OPEN_CMD RIGHT_SHELF_OPEN_CMD
val openState = SerialPortManager.send(openCmd) serialViewModel.sendCmd(openCmd)
} }
// if (openState.not()) {
// toast("${list[position].goodsName}柜门开启失败")
// return@launch
// }
//SerialPortManager.send(START_CMD)
launch(Intent(this@HomeActivity, ShelfActivity::class.java).also { launch(Intent(this@HomeActivity, ShelfActivity::class.java).also {
it.putExtra(ShelfActivity.SHELF_MODEL, list[position]) it.putExtra(ShelfActivity.SHELF_MODEL, homeViewModel.shelfItems[position])
}) { }) {
it?.run { it?.run {
val model = // API 33 起支持带类型参数的重载,低版本回退旧 API + as? 安全转换
getSerializableExtra(ShelfActivity.SHELF_MODEL) as ShelfModel val model = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
list[position].apply { getSerializableExtra(ShelfActivity.SHELF_MODEL, ShelfModel::class.java)
} else {
@Suppress("DEPRECATION")
getSerializableExtra(ShelfActivity.SHELF_MODEL) as? ShelfModel
} ?: return@run
// 将 ShelfActivity 返回的数据同步回列表
homeViewModel.shelfItems[position].apply {
goodsId = model.goodsId goodsId = model.goodsId
goodsName = model.goodsName goodsName = model.goodsName
weight = model.weight weight = model.weight
@@ -646,21 +304,18 @@ class HomeActivity : BaseActivity() {
} }
notifyItemChanged(position) notifyItemChanged(position)
// 货架操作后立即将最新数据同步到服务端
try { try {
val submitList = homeViewModel.shelfItems.sortedBy { item -> item.deviceNo }
val submitList = list.sortedBy { item -> item.deviceNo }
val body = ShelfBody().also { body -> val body = ShelfBody().also { body ->
body.deviceId = App.deviceId ?: "" body.deviceId = App.deviceId ?: ""
body.canteenId = App.canteenId body.canteenId = App.canteenId
body.temperature = showTemperatureC body.temperature = homeViewModel.temperatureC
body.humidity = showHumidity body.humidity = homeViewModel.humidity
body.goodsList = submitList body.goodsList = submitList
} }
Log.d("mzf", "performSync: body:${body.toJsonString()}") Log.d(TAG, "shelfItemClick sync: body=${body.toJsonString()}")
lifecycleScope.launch { viewModel.saveShelfGoodsList(body)
val resp = apiService.saveShelfGoodsList(body = body)
Log.d("mzf", "performSync: resp:${resp.toJsonString()}")
}
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }
@@ -676,7 +331,6 @@ class HomeActivity : BaseActivity() {
layoutManager = layoutManager =
GridLayoutManager(this@HomeActivity, 2, GridLayoutManager.VERTICAL, false) GridLayoutManager(this@HomeActivity, 2, GridLayoutManager.VERTICAL, false)
adapter = shelfAdapter adapter = shelfAdapter
itemAnimator.let { itemAnimator.let {
if (it is DefaultItemAnimator) { if (it is DefaultItemAnimator) {
it.supportsChangeAnimations = false it.supportsChangeAnimations = false
@@ -703,8 +357,6 @@ class HomeActivity : BaseActivity() {
super.onResume() super.onResume()
hideStatusBar() hideStatusBar()
handler.post(updateTask) handler.post(updateTask)
// D6DADEDB00010031323136334238423134303233303456322E313100FFFF
// sendCmd("${HEADER}00010031323136334238423134303233303456322E313152${FOOTER}")
} }
override fun onPause() { override fun onPause() {
@@ -714,13 +366,11 @@ class HomeActivity : BaseActivity() {
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
private fun loadEmptyView() { private fun loadEmptyView() {
list.clear() homeViewModel.shelfItems.clear()
shelfAdapter.notifyDataSetChanged() shelfAdapter.notifyDataSetChanged()
binding.include?.let { binding.include?.let {
it.root.visible() it.root.visible()
it.root.setOnClickListener { it.root.setOnClickListener {
//viewModel.getAccessToken(App.deviceId)
viewModel.getShelfList(deviceId = App.deviceId) viewModel.getShelfList(deviceId = App.deviceId)
} }
it.ivEmptyIcon.setImageResource(R.drawable.ic_empty_white) it.ivEmptyIcon.setImageResource(R.drawable.ic_empty_white)
@@ -729,13 +379,12 @@ class HomeActivity : BaseActivity() {
} }
@Subscribe(threadMode = ThreadMode.MAIN) @Subscribe(threadMode = ThreadMode.MAIN)
public fun weightEvent(event: WeightEvent) { fun weightEvent(event: WeightEvent) {
} }
@Subscribe(threadMode = ThreadMode.MAIN) @Subscribe(threadMode = ThreadMode.MAIN)
public fun clearEmptyShelf(event: ClearShelfEvent) { fun clearEmptyShelf(event: ClearShelfEvent) {
val item = list.firstOrNull { it.deviceNo == event.shelfNo } val item = homeViewModel.shelfItems.firstOrNull { it.deviceNo == event.shelfNo }
item?.apply { item?.apply {
goodsId = "" goodsId = ""
goodsName = null goodsName = null
@@ -744,68 +393,6 @@ class HomeActivity : BaseActivity() {
createTime = null createTime = null
overdueDay = null overdueDay = null
} }
shelfAdapter.notifyItemChanged(list.indexOf(item)) 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
list.forEachIndexed { index, model ->
//接口过期状态变化后更新状态
if (items[index].overdue != model.overdue) {
model.overdue = items[index].overdue
shelfAdapter.notifyItemChanged(index)
log("更新编号为${model.deviceNo}物品:${model.goodsName}的过期状态:${model.overdue}")
}
}
}
}
private fun saveShelfGoodsList() {
log("performSync: list:${list.toJsonString()}")
val submitList = list.sortedBy { it.deviceNo }
submitList.forEach {
if (it.goodsId.isNullOrBlank()) {
it.weight = it.weightBak
}
}
// submitList.forEach {
// it.temperature = HomeActivity.showTemperatureC
// it.humidity = HomeActivity.showHumidity
// }
val body = ShelfBody().also {
it.deviceId = App.deviceId
it.canteenId = App.canteenId
it.temperature = showTemperatureC
it.humidity = showHumidity
it.goodsList = submitList
}
log("performSync: body:${body.toJsonString()}")
viewModel.saveShelfGoodsList(body)
}
}
@@ -0,0 +1,214 @@
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。
*
* 职责:
* 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 触发刷新。
*/
val shelfItems: MutableList<ShelfModel> = mutableListOf()
/**
* 重量数据缓存。
* keydeviceNo(货架编号),value:重量(克)。
*/
private val weightMap: MutableMap<Int, Int> = mutableMapOf()
/**
* 当前选中货架的列表索引(点击 item 时更新)。
*/
var selectedShelfIndex: Int = 0
/**
* 当前摄氏温度,由串口心跳数据更新,字符串格式(如 "24.5")。
*/
var temperatureC: String = "0"
/**
* 当前湿度,由串口心跳数据更新,字符串格式(如 "60")。
*/
var humidity: String = "0"
/**
* 设备名称,由网络接口响应写入,供顶部状态栏显示。
* 原散落于 HomeActivity 局部变量,现迁入 ViewModel 以跨配置变更保持。
*/
var deviceName: String = ""
// ----------------------------------------------------------------
// 对外暴露的 Flow
// ----------------------------------------------------------------
/**
* 过期状态变化通知。
* 发射需要局部刷新的 adapter position 列表,Activity 收到后调用 notifyItemChanged。
* 仅在状态实际发生变化时发射,避免无效刷新。
*/
private val _overdueUpdateFlow = MutableSharedFlow<List<Int>>(extraBufferCapacity = 4)
val overdueUpdateFlow: SharedFlow<List<Int>> = _overdueUpdateFlow
// ----------------------------------------------------------------
// 重量操作
// ----------------------------------------------------------------
/**
* 更新指定货架编号的重量。
* @param deviceNo 货架编号
* @param weight 重量(克)
*/
fun putWeight(deviceNo: Int, weight: Int) {
weightMap[deviceNo] = weight
}
/**
* 获取指定货架编号的重量。
* @param deviceNo 货架编号
* @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<Int>()
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()
}
}
}
@@ -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"
}
// ----------------------------------------------------------------
// 对外暴露的 FlowActivity 通过 repeatOnLifecycle 收集)
// ----------------------------------------------------------------
/**
* 货架重量更新事件。
* Pair.first = 货架编号(deviceNo),Pair.second = 重量(克)。
*/
private val _weightFlow = MutableSharedFlow<Pair<Int, Int>>(extraBufferCapacity = 64)
val weightFlow: SharedFlow<Pair<Int, Int>> = _weightFlow
/**
* 温控信息上报事件,携带完整日志字符串,Activity 收到后弹窗展示。
*/
private val _tempCtrlEventFlow = MutableSharedFlow<String>(extraBufferCapacity = 8)
val tempCtrlEventFlow: SharedFlow<String> = _tempCtrlEventFlow
/**
* 设备状态刷新信号。
* 串口收到需要刷新顶部状态栏的命令时触发,Activity 收到后用 deviceName 重绘。
*/
private val _deviceStatusRefreshFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 4)
val deviceStatusRefreshFlow: SharedFlow<Unit> = _deviceStatusRefreshFlow
// ----------------------------------------------------------------
// 内部:串口数据通道 + 协议解析器
// ----------------------------------------------------------------
/**
* 串口数据接收通道,无界缓冲。
* IO 线程(串口回调)通过 trySend 写入,单一消费协程串行读取,
* 确保 parser 内部 serialReader 始终在同一协程中访问,规避并发竞争。
*/
private val serialDataChannel = Channel<String>(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()
}
}
@@ -0,0 +1,40 @@
package com.shuwei.intelligent.shelves.serial
/**
* 串口协议解析事件监听器。
* [SerialProtocolParser] 解析完成后通过此接口将结果回调给上层(ViewModel 或 Activity)。
*/
interface SerialEventListener {
/**
* 需要向串口发送命令。
* @param cmd 十六进制命令字符串
*/
fun onSendCommand(cmd: String)
/**
* 获取到终端信息(激活指令或设备状态上报时触发)。
* @param terminalId 终端 ID15字节)
* @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)
}
@@ -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) {
// 类似这种:31314FFFFFD6DADEDB01010CFOOTER 在 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)
}
}
+50
View File
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="400dp"
android:layout_height="300dp"
app:cardCornerRadius="10dp"
app:cardElevation="0dp"
xmlns:tools="http://schemas.android.com/tools"
app:cardBackgroundColor="@color/white">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginVertical="30dp"
android:orientation="vertical">
<TextView
android:id="@+id/tvDialogTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:textColor="@color/black"
android:textSize="22sp"
tools:text="标题"/>
<TextView
android:id="@+id/tvDialogContent"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_gravity="center_horizontal"
android:textColor="@color/black"
android:gravity="center"
android:layout_marginHorizontal="50dp"
android:textSize="16sp"
tools:text="内容"/>
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnConfirm"
android:layout_width="120dp"
android:layout_height="50dp"
android:text="确定"
android:background="@color/bg_card_blue"
android:textColor="@color/white"
android:textSize="20sp"
android:layout_gravity="center_horizontal"/>
</LinearLayout>
</androidx.cardview.widget.CardView>
+1 -1
View File
@@ -2,7 +2,7 @@
#agp = "8.11.1" #agp = "8.11.1"
agp = "8.4.0" agp = "8.4.0"
kotlin = "2.0.21" kotlin = "2.0.21"
coreKtx = "1.10.1" coreKtx = "1.12.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.1.5" junitVersion = "1.1.5"
espressoCore = "3.5.1" espressoCore = "3.5.1"